写 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 data | 26.8 ms | 1.00x |
while + next() 手动拉 | 104.5 ms | 3.89x |
列表推导 [0 for x in data] | 41.8 ms | 1.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 的「一次性」行为全都顺理成章。