KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 生成器基础:函数的「暂停键」,逐行演算 — keel 龙骨
本章是全课地基。目标只有一个:你能在脑子里逐行演出一段生成器代码的执行轨迹。请打开 REPL,边读边敲。
本章是全课地基。目标只有一个:你能在脑子里逐行演出一段生成器代码的执行轨迹。请打开 REPL,边读边敲。
一、核心事实:调用生成器函数,函数体一行都不执行
先看最反直觉的事实:
def my_gen():
print("第一行执行了")
yield 1
print("第二行执行了")
yield 2
gen = my_gen()
# ↑ 注意:屏幕上没有任何输出!
my_gen() 看起来是函数调用,但它没有执行函数体,它做的是:
- 检查这个函数是不是生成器函数(看函数体里有没有
yield); - 是 → 不执行,而是创建一个「生成器对象」(generator object)返回,里面记住了:函数代码、执行位置(当前是开头)、局部变量(还没有);
- 你可以把它理解成一张录像带 + 一个暂停的播放头,还没按播放键。
用 type() 验证:
print(type(gen)) # <class 'generator'>
print(gen) # <generator object my_gen at 0x...>
什么时候才真正开始执行?被「要」的时候。 「要」的方式有 next()、for 循环、解包等。
二、逐行演算:next() 触发的「跑到下一个 yield」
2.1 第一次 next
result = next(gen)
这一步内部发生了什么,按时间顺序:
1. 播放头从函数开头开始跑
2. print("第一行执行了") → 屏幕输出:第一行执行了
3. 执行 yield 1:
a. 把 1 作为本次 next() 的返回值
b. 函数在 yield 这里【冻结】:
- 局部变量全部保留
- 执行位置记住是「yield 1 这一行」
c. 控制权交还给调用方
4. result = 1
「冻结」是本章最重要的词。普通函数 return 后局部变量就销毁了;生成器 yield 后局部变量原封不动地留着,下次继续用。
2.2 第二次 next
result2 = next(gen)
1. 播放头从【上次冻结的位置(yield 1)的下一句】继续
2. print("第二行执行了") → 输出:第二行执行了
3. 执行 yield 2 → 冻结,返回 2
4. result2 = 2
注意:print("第一行执行了") 不会再执行——执行位置已经过了它。生成器函数体只被「进入」一次,之后都是暂停/恢复。
2.3 第三次 next:函数跑完了怎么办
next(gen) # → StopIteration 异常!
1. 从 yield 2 之后继续
2. 后面没有代码了,函数自然结束
3. 没有 yield 可以交付了 → 抛出 StopIteration
StopIteration 不是错误,是「我讲完了」的信号。它的处理方式决定了下一节 for 循环的原理。
2.4 完整轨迹图
调用 my_gen() next() #1 next() #2 next() #3
│ │ │ │
▼ ▼ ▼ ▼
[创建生成器] → print第一行 → yield 1 → print第二行 → yield 2 → 函数结束
不执行函数体 冻结@yield1 返回 1 冻结@yield2 返回 2 StopIteration
↑________恢复__↑ ↑______恢复____↑
三个「一次」和「多次」:
- 函数体进入一次(第一次 next 时);
- 暂停/恢复发生多次(每个 yield 一次);
- 创建生成器零次执行(调用时什么都不跑)。
三、for 循环:其实就是「while True + next + 捕获 StopIteration」
你平时写的:
for x in my_gen():
print(x)
Python 解释器大致把它翻译成:
_it = iter(my_gen()) # 生成器自身就是迭代器,iter() 返回它自己
while True:
try:
x = next(_it) # 要一个
except StopIteration: # 「我讲完了」
break # 正常退出循环,不报错
print(x) # 循环体
这解释了三件事:
- for 循环不会触发 StopIteration 报错——它捕获了;
- for 循环没跑完就 break/return,生成器会怎样? ——生成器对象失去引用后被垃圾回收,Python 会对它调用
close()(02 章讲),函数体在冻结的 yield 处收到GeneratorExit。「不再消费」就是「停止生产」; - for 循环拿不到 yield 之间的代码的副作用——只有执行到才有。
gen = my_gen()之后立刻看gen的状态:什么都没打印,因为还没人要。
3.1 一个检验理解的小实验
在 REPL 里跑,先预测输出再看结果:
def tricky():
print("A")
yield 1
print("B")
yield 2
print("C")
g = tricky() # 预测:输出什么?
g # 预测:输出什么?
next(g) # 预测:输出什么?返回什么?
list(g) # 预测:输出什么?返回什么?
答案:第一行无输出(只是创建);g 显示 generator 对象(没执行);next 输出 A 返回 1;list(g) 从冻结处继续——输出 B、C,返回 [2]。最后那个 [2] 最容易错:list 只拿到了剩余的产出,因为 1 已经被 next 消费掉了。生成器是一次性的,像水流过就没了。
四、生成器表达式:小括号版本的 yield
你已经会列表推导式:
squares_list = [x*x for x in range(10**8)] # ← 立刻全部计算,8 亿个数进内存
把方括号换成圆括号,就变成生成器表达式:
squares_gen = (x*x for x in range(10**8)) # ← 什么都不算!瞬间返回
total = sum(squares_gen) # 要的时候才算,边算边丢
它等价于:
def _gen():
for x in range(10**8):
yield x*x
squares_gen = _gen()
内存差别是本质的:列表版先吃下 8 亿个整数的内存再让你求和;生成器版内存里永远只有「当前那个数」。生产代码里对应的真实原则是——凡是「生产一堆东西给下游逐个消费」的场景,用生成器而不是列表,比如把一批数据库记录转换成事件对象再逐个推给下游。
一个实用细节:生成器表达式作为函数唯一参数时可以省一层括号:
total = sum(x*x for x in range(100)) # 合法,不用写 sum((...))
五、为什么说「惰性」是特性而不是偷懒
惰性求值(lazy evaluation)在工程上有三个实打实的好处:
- 省内存:永远只保留「当前项」,10 GB 文件 / 100 万事件都不怕;
- 省计算:消费者只要前 10 个,第 11 个之后的计算根本不发生。
itertools.islice(gen, 10)就是这个用途; - 可以表达无限:
itertools.count()是无限序列,配合惰性消费完全安全。同步代码里你不可能list(count()),但for i in count(): ...随时能 break。
代价也要说清楚(05 章展开):一次性(消费完就空了)、不透明(len() 不了、打印看不见内容)、异常延迟(生成器体里的错误要消费到那一步才抛出)。
六、自检(能答出来再往下走)
def f():
print("start")
yield 1
print("middle")
yield 2
a = f()
b = f()
next(a)
next(a)
next(b)
问:三个 next 各自输出什么?最后再 next(a) 会怎样?a 和 b 互相影响吗?
答案
第一个 next(a):输出 start,返回 1;第二个 next(a):输出 middle,返回 2;next(b):输出 start,返回 1——b 是独立的新生成器,有自己的执行位置(所以「部分消费的生成器」不能复用,但可以重新调用函数再建一个)。最后 next(a) 抛 StopIteration。
↓下一步:02 章 · 双向通道——yield 不只往外吐值,它还能收值。