for x in something 这行最普通的代码,背后是 Python 设计得相当精巧的一套协议。理解它,不只是为了"会写生成器",而是为了搞懂:为什么读 10GB 文件能不爆内存?yield 凭什么能让函数暂停又恢复?生成器和协程到底什么关系?这些问题都收束到两个核心:迭代器协议和生成器的状态机本质。

场景:for 循环到底在干什么

for 不是 C 那种带计数器的循环,它本质上是迭代器协议的客户端:

1
2
3
4
5
6
7
8
9
10
11
for x in obj:
use(x)

# 解糖后等价于:
it = iter(obj) # 调 obj.__iter__()
while True:
try:
x = next(it) # 调 it.__next__()
except StopIteration:
break
use(x)

这里有两个角色要分清。可迭代对象(Iterable) 实现 __iter__,能交出一个迭代器;迭代器(Iterator) 实现 __next__,每次吐一个值,没了就抛 StopIteration。list 是可迭代对象但不是迭代器(你不能对 list 直接 next()),它的 __iter__ 每次返回一个全新的迭代器——这就是为什么同一个 list 能嵌套 for 互不干扰。

机制:手写一个迭代器

1
2
3
4
5
6
7
8
9
10
11
12
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

print(list(Countdown(3))) # [3, 2, 1]

注意一个关键设计:迭代器把状态(self.n)和"取下一个"的逻辑(__next__)绑在一起。每次 next 推进一步状态。StopIteration 不是错误,而是协议约定的"终止信号"——for 会安静地捕获它。这也是为什么在生成器里 return value 会被包装成 StopIteration(value)

生成器:编译器帮你写状态机

手写迭代器要维护 self.n 这种状态字段,繁琐。生成器让编译器替你做这件事——把含 yield 的函数自动变成一台状态机:

1
2
3
4
def countdown(n):
while n > 0:
yield n
n -= 1

调用 countdown(3) 不执行函数体,而是立刻返回一个生成器对象。每次 next() 才执行到下一个 yield在 yield 处冻结整个执行状态(局部变量、字节码指令指针都保存在生成器帧里),下次从那里继续。这就是"暂停/恢复"的真相:函数的栈帧没有被销毁,而是被挂起保存下来了。

懒求值是它最大的工程价值。处理大文件的经典写法:

1
2
3
4
5
6
7
8
9
def read_lines(path):
with open(path) as f:
for line in f: # 文件对象本身就是迭代器,逐行惰性读
yield line.rstrip("\n")

# 内存占用与文件大小无关,只占一行
for line in read_lines("huge.log"):
if "ERROR" in line:
handle(line)

无论文件 10MB 还是 10GB,内存里始终只有当前这一行。这是生成器对比"先 readlines() 读进 list"的根本优势:空间复杂度从 O(n) 降到 O(1)

yield from 与子生成器

yield from 不只是"展开子可迭代对象"的语法糖,它建立了一条透明通道:把外层 sendthrow、返回值都正确地转发给子生成器。

1
2
3
4
5
def chain(*iterables):
for it in iterables:
yield from it # 等价但远不止于 for x in it: yield x

list(chain([1, 2], (3, 4))) # [1, 2, 3, 4]

在涉及 send 传值的协程场景里,手写 for ... yield 无法正确传递发送的值和异常,yield from 才是正解。这也是它被引入语言的真正动机。

从生成器到协程

yield 不仅能吐值,还能收值gen.send(x) 会让上一个挂起的 yield 表达式的值变成 x,再继续执行:

1
2
3
4
5
6
7
8
9
10
def accumulator():
total = 0
while True:
x = yield total # yield 既输出 total,又接收 send 进来的值
total += x

acc = accumulator()
next(acc) # 启动到第一个 yield(priming)
print(acc.send(10)) # 10
print(acc.send(5)) # 15

这种"双向通信"的生成器就是早期 Python 协程的实现基础。后来的 async/await 在概念上一脉相承——await 之于事件循环,正如 yield 之于驱动它的 next/send,都是"把控制权交还给调度方并保存现场"。理解了生成器的暂停-恢复,再看 asyncio 就不再神秘。

工程权衡与边界

  • 一次性。 生成器耗尽后再迭代直接结束,不会重来。需要多次遍历就别用生成器,或每次重新创建。这是 list 用不完的优势所在。
  • 不能索引、不能 len。 生成器没有随机访问,gen[3]len(gen) 都不行。要这些能力就得物化成 list,但那就放弃了省内存。
  • 延迟执行带来的陷阱。 生成器体内的异常、副作用(如打开文件)要到真正迭代时才发生。g = read_lines("nonexist") 不报错,next(g) 才报。调试时容易误判出错位置。
  • 资源释放。 生成器没迭代完就被丢弃时,with/finally 里的清理依赖 GeneratorExit——当生成器对象被回收,Python 会在挂起的 yield 处抛 GeneratorExit 触发 finally。但如果你压住了这个机制(极少见),文件句柄可能晚释放。生产上建议显式 close() 或确保迭代到底。

小结

迭代器协议是 __iter__/__next__/StopIteration 三件套,是 for 循环的统一接口。生成器是编译器自动生成的迭代器状态机,用 yield 实现暂停与恢复,核心价值是惰性求值带来的 O(1) 空间。再往上,send/yield from 把生成器升级成可双向通信的协程,构成了 async/await 的思想源头。把"暂停时保存整个栈帧"这件事想明白,从大文件流式处理到异步编程的一大片地图就连成片了。