文章

Python 迭代器:为什么列表能 for,却不能 next

先做一个实验。下面两行代码,第一行天衣无缝,第二行当场报错:

1
2
3
>>> nums = [1, 2, 3]
>>> next(nums)
TypeError: 'list' object is not an iterator

奇怪的地方在于:for x in nums 明明跑得好好的,nums 显然”可以被遍历”,为什么却不能对它 next()?很多人在这里背下一条结论–“要用 iter() 包一下”——然后就这么用了很多年。这篇文章把这件事讲透:for 循环底下到底发生了什么、iterable 和 iterator 为什么是两个概念、以及 Python 为什么非得这样设计。

第一层:会用——两个函数,一条分界线

你现在在第一层,目标只有一个:把 iter() 和 next() 用对。

1
2
3
4
5
6
7
8
9
10
>>> nums = [1, 2, 3]
>>> it = iter(nums)    # 从"名单"里取出一根"手指"
>>> next(it)
1
>>> next(it)
2
>>> next(it)
3
>>> next(it)
StopIteration         # 没有了,到此为止

由此得到一条清晰的分界线:

  • 可迭代对象(iterable):能交给 for 的东西。列表、字符串、字典、文件……判据:iter(x) 不报错。
  • 迭代器(iterator):能交给 next 的东西。判据:next(x) 不报错。

第一层最实用的一条口诀:迭代器一定可迭代,可迭代不一定迭代器。列表是可迭代的,但不是迭代器——这就是开头报错的原因。一根判断的银针:

1
2
3
4
5
>>> it = iter([1, 2, 3])
>>> iter(it) is it
True        # 迭代器的 iter() 返回自己
>>> iter([1, 2, 3]) is [1, 2, 3]
False       # 列表的 iter() 造出一个新东西

到这里你已经会用了。但口诀解释不了一个现象:for x in nums 从来没让你先调 iter(),它凭什么能直接跑?for 到底拿的是”名单”还是”手指”?往下钻一层。

第二层:原理——for 循环的真面目

你现在在第二层。这一层回答:for 到底干了什么。

答案是:for 从来不直接碰数据,它只跟迭代器打交道。for x in nums 在解释器里大致被翻译成:

1
2
3
4
5
6
7
_it = iter(nums)            # 第一步:先要一根手指
while True:
    try:
        x = next(_it)       # 第二步:反复让手指往下指
    except StopIteration:   # 第三步:指到头了,收工
        break
    ...  # 循环体

所以开头那个 TypeError 就通了:next(nums) 失败是因为你把名单塞给了需要手指的函数;for x in nums 成功是因为 for 替你多走了一步 iter(nums)。字符串、字典、文件,全是同样的流程。

这套流程对应两个魔法方法,也就是所谓迭代器协议:

  • __iter__:返回一个迭代器(可迭代对象的义务);
  • __next__:交出下一个值,没了就抛 StopIteration(迭代器的义务)。

迭代器两个都要实现,其中 __iter__ 只写 return self——这就是为什么”迭代器一定可迭代”。

亲手造一个最小的迭代器,跑一下(复制即可运行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CountDown:
    def __init__(self, n):
        self.n = n
    def __iter__(self):
        return self
    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        self.n -= 1
        return self.n + 1

for x in CountDown(3):
    print(x)
# 输出:3 2 1

注意一个细节:这个 CountDown 迭代一次就空了,for 第二遍不会有任何输出——和列表不一样。先预测再看答案:下面这段代码输出什么?

1
2
3
nums = [1, 2, 3, 4]
it = iter(nums)
print(list(zip(it, it)))

想好了?答案是:

1
[(1, 2), (3, 4)]

zip 从两个”参数”里取值,但传进去的是同一根手指,于是元素被交错消费:1 和 2 配成一对,3 和 4 配成一对。如果传的是列表本身(zip(nums, nums)),得到的就是 [(1, 1), (2, 2), (3, 3), (4, 4)]。”手指”是独立的状态,谁拿着它谁就在移动它——这是理解迭代器最重要的直觉。

到这里机制通了。但还剩一个更根本的问题:Python 为什么要拆成”名单”和”手指”两个角色?让列表自己记住位置,一个协议搞定,不好吗?这是最后一层。

第三层:本质——为什么非得拆成两个对象

你现在在第三层。我们把”显然的替代方案”摆上桌审一遍。

替代方案:一个协议,对象自己记进度。列表自己存一个”当前下标”,next 一次往后走一格。听起来省了一个对象,但它的失败场景是灾难性的:

1
2
3
for x in nums:          # 外层走到第 2 个
    for y in nums:      # 内层从头开始遍历同一个 nums
        ...

如果进度存在 nums 自己身上,内层循环每走一步都会踩坏外层循环的进度——嵌套循环、两个函数先后遍历同一个列表、同一个列表在两处被 for,全都会互相干扰。把”走到哪了”这个状态从数据里拆出来变成独立的游标,每根手指各指各的,数据本身永远干净可重用。这就是本质:

迭代器 = 把”遍历进度”从数据中剥离出来,变成一根可以独立移动的游标。可迭代对象是名单,迭代器是按着名单划过去的那根手指。

去掉术语跟同事解释:名单可以复印给很多人同时看,但每人的手指各自往下划——iter(nums) 每调用一次就伸出一根新手指,互不干扰。

再追问一层:为什么”结束”要抛 StopIteration 异常,而不是返回 None 或者 -1 之类?因为 None 是完全合法的数据——next(it, None) 这种默认值写法正因为此才有意义。用异常表示”没有了”,数据和信号就永远不会混淆。这个决定后来还有了意外收获:生成器函数 return 时自动抛 StopIteration,两套机制天衣无缝地接在了一起。

一段历史:for ... in 这个语法继承自 Python 的前身 ABC 语言,但早期的”可迭代”靠的是另一套协议——__getitem__,从下标 0 开始逐个取,取到 IndexError 为止。2001 年的 PEP 234 才引入 iter()/next() 协议。所以有个反直觉时刻:一个类哪怕不写 __iter__,只写 __getitem__,照样能被 for 遍历——那是解释器在兼容二十年前的旧协议:

1
2
3
4
5
6
7
class OldStyle:
    def __getitem__(self, i):
        if i >= 3:
            raise IndexError
        return i * 10

print(list(OldStyle()))   # [0, 10, 20],居然能用

收尾地图

一张判断表,遇到时直接查:

问题判据
x 是可迭代对象吗?iter(x) 不报错
x 是迭代器吗?iter(x) is x
x 能被 next 吗?是迭代器就能
迭代器能来第二遍吗?不能,耗尽即止;要重来就再 iter() 一次

什么时候自己写迭代器类:几乎没有必要——下一站会告诉你为什么。

两道自测题(不回头看能答上,就是真懂了):

  1. zip(it, it) 传同一个迭代器会交错消费,那 zip(nums, nums) 传列表为什么每对都是相同元素?(答案在第三层的”手指”模型。)
  2. it = iter([1, 2, 3]) 之后执行 list(it) 两次,第二次输出什么?为什么和列表的”可重复遍历”不冲突?(答案在第二层 CountDown 的细节。)

下一站:第三层那个手写的 CountDown 类,其实 90% 的代码都在做”保存进度”这件苦活——Python 专门发明了 yield 让解释器替你保存进度,这就是生成器。理解了迭代器再看生成器,你会看到它不过是”自动帮你写迭代器类”的语法糖;再往后是 itertools(官方替你写好的迭代器武器库)和 for 的各种底层亲戚( unpacking 、in 运算符,全走的迭代器协议)。一根手指,撑起了 Python 半个语言。

本文由作者按照 CC BY 4.0 进行授权