Python 的 for 到底怎么跑起来的?拆开迭代协议:GET_ITER 之后 FOR_ITER 在循环什么

0 阅读4分钟

写 Python 的人天天用 for,但多数人说不清 for x in xs 在字节码层面到底发生了什么。for 能遍历 list、str、dict、文件、生成器——这些类型八竿子打不着,凭什么 for 一视同仁?答案不在这些类型里,在它们共同遵守的那份迭代协议里。本文用 CPython 3.13.14 实机数据,把这份协议拆开。

一、先看字节码:for 循环其实只有三条指令

把一个最简单的 for 编译出来,结构极其简单:

RESUME           0
LOAD_FAST        0 (xs)
GET_ITER                 ← 关键第一步
L1:  FOR_ITER     3 (to L2)     ← 循环体每次跳回这里
     STORE_FAST   1 (x)
     JUMP_BACKWARD 5 (to L1)
L2:  END_FOR
     POP_TOP
     RETURN_CONST 0 (None)

三个关键指令说明了一切:GET_ITER 拿迭代器、FOR_ITER 取下一个、END_FOR 善后。for 自己什么都不遍历,它只是把活儿全外包给了一个叫「迭代器」的东西。

二、迭代协议:两个方法的约定

for 对对象的要求只有两条,写在数据模型里:

  • __iter__():返回一个迭代器
  • __next__():返回下一项,没有就抛 StopIteration

实测一个手写协议的类:

class NewProto:
    def __init__(self, n): self.n = n; self.i = 0
    def __iter__(self): return self
    def __next__(self):
        if self.i >= self.n: raise StopIteration
        v = self.i; self.i += 1; return v

for x in NewProto(3) 得到 [0, 1, 2],耗尽后再 next() 抛 StopIteration。for 循环的退出条件不是「数到 3」,而是 __next__ 抛出 StopIteration——for 循环甚至不知道自己在遍历几个元素。

三、老协议还活着:只有 __getitem__ 也能迭代

不写 __iter__ 行不行?也行。这是最反直觉的事实:

class OldProto:
    def __getitem__(self, i):
        if i >= len(self.data): raise IndexError
        return self.data[i]

for x in OldProto(3) 照样输出 [0, 1, 2]。iter() 会把它包成一个 sequence-iterator,用下标 0、1、2…… 去试,直到 IndexError。这是 Python 2 时代的老协议,为了兼容保留至今——你的类只要支持下标,for 就能遍历它。

四、iter() 的三副面孔

iter() 根据参数个数切换行为,这点文档里常被忽略:

调用形式行为实测
iter(可迭代对象)返回其 __iter__() 的结果iter(l) 每次新建,两个对象 is not
iter(可调用对象, 哨兵值)反复调用,直到返回值等于哨兵iter(reader, b'__END__') 分块读
iter(迭代器)返回自身iter(enumerate('ab')) is 那个 enumerate → True

双参形式是 C 里 while (c = read()) != STOP 的 Python 化,读文件流、读 socket 的老代码里到处都是。

五、可迭代 vs 迭代器:一字之差,两种命运

这是面试重灾区,实测区分:

  • 可迭代对象(list、str、dict):iter(l) 每次调用新建一个独立迭代器,两个 is not 成立,各自推进互不干扰
  • 迭代器(generator、enumerate、zip、文件对象):iter(it) is it 恒为 True——迭代器的 __iter__ 返回自己

推论:迭代器是一次性的。it = iter([1,2,3]); list(it); list(it) 第二次得到 [],因为第一次已经耗尽。

六、性能:for 比 while+next 快 3.89 倍

100 万个元素实测(CPython 3.13.14):

写法耗时相对
for x in data26.8 ms1.00x
while + next() 手动拉104.5 ms3.89x
列表推导 [0 for x in data]41.8 ms1.56x

慢在哪不在 next() 函数本身,在于 Python 级 while 循环每次迭代都要重新走一遍字节码分发,而 FOR_ITER 在解释器里是内建快路径——单个指令完成「调用 __next__、捕获 StopIteration、跳转」三件事,没有 Python 函数调用开销。

七、异常穿过 for:迭代器记得断点

一个少有人注意的细节:

it = iter([1,2,3,4])
try:
    for x in it:
        if x == 2: raise ValueError
except ValueError: pass
list(it)   # → [3, 4]

异常穿透 for 后,迭代器从断点继续,不是从头开始。 迭代器的位置状态存在迭代器自己身上,for 循环的提前退出(break、异常)都不会重置它。这就是「生成器只能遍历一次」的机制根源。

八、实战守则

  • 能 for 就别 while+next:3.89 倍的差距,写的代码还更短
  • 需要下标才用 enumerate:for i, x in enumerate(xs),别写 range(len(xs))——enumerate 本身就是迭代器(iter(e) is e),协议复用
  • 协议类型检查:for 遇到 __iter__ 返回非迭代器的对象会抛 TypeError: iter() returned non-iterator of type 'int'——这是协议检查在保护你
  • 一次性消费:把生成器当参数传进两个函数,第二个拿到的是空壳,要用 itertools.tee 或先物化

一句话总结:for 循环是迭代协议的发动机,__iter__ 是钥匙孔,__next__ 是曲轴,StopIteration 是熄火线。理解了这条链,文件对象、生成器、zip 的「一次性」行为全都顺理成章。