文章

Python 生成器:调用一个函数,函数体却一行都没执行

先看一段再普通不过的代码:

1
2
3
4
5
6
7
8
9
def countdown(n):
    print(f"从 {n} 开始倒计时")
    while n > 0:
        yield n
        n -= 1

c = countdown(3)
print("函数已经'调用'完了,但你看到上面那行 print 了吗?")
print(next(c))

猜一下输出顺序。很多人第一次见时会以为是:

1
2
3
从 3 开始倒计时
函数已经'调用'完了……
3

实际跑一下,从 3 开始倒计时 这行字,直到 next(c) 被调用的那一刻才姗姗来迟。也就是说——你”调用”了一个函数,函数体却一行都没执行。这违反了几乎所有程序员对”函数调用”的直觉。

这个叫 c 的东西,就是生成器(generator)。它不是函数,不是列表,是 Python 里一种能”暂停自己、稍后再接着跑”的奇怪对象。上一篇《Python 迭代器》我们讲了迭代器协议,这篇文章接着往上爬:生成器是怎么从 yield 长出来的、它为什么天生就是迭代器、以及 Python 为什么要设计出”函数能暂停”这种看似离经叛道的东西。

L1:先让它跑起来——yield 是什么

你现在在第一层,目标是把生成器写对、用对。

把一个普通函数里的 return 换成 yield,它就不再是普通函数了:

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

for x in countdown(3):
    print(x)

输出:

1
2
3
3
2
1

记住三件事就够了:

  1. 调用生成器函数不会执行函数体,而是返回一个生成器对象。
  2. 每次对它 next(),函数体就从上次暂停的地方接着跑到下一个 yield,把 yield 后面的值交出来,然后再次暂停。
  3. 函数跑完(走到末尾或 return)时抛出 StopIteration,迭代结束。

for 循环替你把这些细节全包了,所以生成器天然就能放进 for 里。我们可以验证一下它确实是个迭代器:

1
2
3
>>> c = countdown(3)
>>> iter(c) is c       # 还记得迭代器的判据吗?
True

它自己就是自己的迭代器,不像列表还要 iter() 取一根”手指”。这一点后面会用到。

先预测,再看答案:下面这段输出什么?

1
2
3
4
5
6
7
8
9
10
11
def gen():
    print("A")
    yield 1
    print("B")
    yield 2

g = gen()
print("---")
print(next(g))
print("...")
print(next(g))
点我看答案 顺序是 `---` → `A` → `1` → `...` → `B` → `2`。注意 `"A"` 不是在 `gen()` 时打印的,而是在第一次 `next(g)` 时。生成器的函数体是"惰性"的,你不推它,它不动。

L2:底层发生了什么——函数的栈帧被”打包”带走了

到这里你已经会用了,但有个问题绕不过去:普通函数一旦返回,它的局部变量就跟着栈帧一起销毁了;凭什么生成器 yield 出去之后,局部变量还能活着等下一次 next()?

答案藏在 CPython 的实现里。普通函数运行时有一个栈帧(frame)对象,装着这次调用的局部变量、字节码执行到哪一行(最后一条指令的位置)、以及求值栈。函数返回时,栈帧被弹出回收。

而生成器做了一件”作弊”的事:当它执行到 yield,CPython 没有销毁栈帧,而是把整个 frame 对象挂在生成器对象身上,然后才把控制权交还给调用者。生成器对象 g 还在,这个 frame 就在;它的局部变量、它执行到的字节码位置,全都原封不动。下一次 next(g),CPython 把这个 frame 重新塞回解释器继续跑,就像什么都没发生过一样。

可以用一个小实验亲眼看一下这个”暂停的现场”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import sys

def gen():
    x = 42
    yield x
    y = x + 1
    yield y

g = gen()
next(g)                      # 跑到第一个 yield 暂停
f = g.gi_frame               # 生成器挂着的栈帧对象
print(f.f_locals)            # {'x': 42}  —— 局部变量还活着!
print(f.f_lasti)             # 一个整数,表示字节码停在哪条指令
print(g.gi_running)          # False —— 它现在正暂停着,没在跑

gi_frame、gi_running 这些属性平时用不到,但它把生成器的本质暴露无遗:它就是一个被挂起的函数执行现场,连同局部变量和”上次执行到哪”的书签一起,封装成了一个对象。

这也解释了为什么生成器是”一次性”的:

1
2
3
4
5
>>> c = countdown(2)
>>> list(c)
[2, 1]
>>> list(c)          # 同一个生成器再遍历一次
[]                   # 空的!frame 已经走到函数末尾了

栈帧的书签已经翻到最后一页,回不去了。想要再遍历,只能重新调用 countdown(2) 造一个新的——这正是迭代器和可迭代对象的区别,生成器把它演绎到了极致。

台阶句:可是……为什么要发明它?

到这里你懂了生成器怎么工作,但还没回答最该问的那个问题:Python 好好的函数不能用吗?为什么要大费周章让函数能暂停?要理解这一点,得先看一个没有生成器时令人痛苦的场景。

假设你要处理一个巨大的日志文件,找出所有报错行。新手会这么写:

1
2
3
4
5
6
7
8
9
10
def read_errors(path):
    lines = []
    with open(path) as f:
        for line in f:
            if "ERROR" in line:
                lines.append(line)
    return lines

for err in read_errors("huge.log"):
    process(err)

能跑,但你得先把所有报错行攒进 lines,才能开始 process。如果文件有 10GB、报错有几百万行,内存直接爆掉。更糟的是:处理逻辑要等全部读完才能开始,整个程序是”堵”着的。

用生成器改写:

1
2
3
4
5
6
7
8
def read_errors(path):
    with open(path) as f:
        for line in f:
            if "ERROR" in line:
                yield line

for err in read_errors("huge.log"):
    process(err)        # 读一行、处理一行,内存里永远只有一行

注意这段代码读起来和列表版本一样自然,但它读一行、吐一行、处理一行,内存占用恒定。这就是生成器的杀手锏:它让你用同步、顺序的代码风格,写出了流式、惰性的计算。

L3:本质——生成器到底是什么

去掉所有术语,生成器到底是什么?

生成器 = 把”一个函数的执行状态”变成一个可以拿在手里、随时暂停和恢复的对象。

它把两件原本绑死的东西拆开了:

  • “算出一个值“(函数在做的事)
  • “决定要不要下一个值、什么时候要“(调用者在做的事)

普通函数是”一问一答、问完即走”:你调用它,它一口气跑完,把结果塞给你。生成器是”挤牙膏”:调用者要一下,它算一下,中间状态全帮你存着。函数还是那个顺序执行的函数,但节奏的控制权从函数手里交给了调用者。

这里值得追问一层设计权衡:为什么不直接用回调函数(callback)实现流式处理? 这是生成器出现之前的主流方案,很多语言(比如早期的 JavaScript、Node.js)都走过这条路。对比一下就知道 Python 为什么选了生成器:

用回调重写上面的逻辑,大概是这样:

1
2
3
4
5
6
7
def each_error(path, callback):
    with open(path) as f:
        for line in f:
            if "ERROR" in line:
                callback(line)     # 每找到一行,"反向"调用你给的函数

each_error("huge.log", process)

功能一样,但代价是:

  1. 控制权反转:你不再决定”什么时候处理下一行”,是别人的循环来调你。一旦想在两个流之间交错(比如”同时”读两个文件做配对),回调会迅速嵌套成所谓的”回调地狱”。
  2. 状态无处安放:回调每次被调用都是一次全新的函数调用,你想记住”上一行是什么”,得自己在外面搞个变量或者写个类,把本该属于局部变量的状态手动管理起来。
  3. 无法提前结束:你处理到一半想停下来?回调接口得额外支持一个”取消”机制,否则它会一口气把整个文件读完。

生成器把这三个问题一起解决了:状态天然存在栈帧里、节奏由调用者用 next() 掌控、想停就不再调 next() 甚至 .close()。它用”可暂停的函数”这一个抽象,换掉了”回调 + 手动状态机 + 控制反转”这一整套笨重的组合。

这不是 Python 拍脑袋的发明。生成器的根源可以追溯到 1970 年代 Icon 语言和 Alphard,而 Python 的 yield 直接灵感来自 CLU;PEP 255(2001 年,Python 2.2)把它加进来时,最初的动机写得很朴素——“让编写能按需产生值的迭代器变得容易”。在那之前,想写一个自定义迭代器,你得老老实实写一个类,实现 __iter__ 和 __next__,自己在实例属性里维护状态:

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

一个从 3 数到 1 的东西要写 10 行样板。而 yield 版本 3 行搞定,局部变量自动就是状态。生成器本质上是写迭代器的语法糖——只不过这颗糖甜到改变了 Python 的编程风格。

一个小彩蛋:send 是怎么回事

很多教程讲到这里会抛出 .send()、next() 能接收值等等,把人绕晕。其实抓住一个心智模型就够了:yield 是一个”双向的门”。函数从这扇门把值递给调用者;调用者下次敲门时,也可以塞一个值进来,这个值就成为 yield 表达式的值。

1
2
3
4
5
6
7
8
9
10
11
def accumulator():
    total = 0
    while True:
        amount = yield total        # yield 出去 total,停下;下次 send(x) 时,x 赋给 amount
        total += amount

acc = accumulator()
next(acc)                 # 启动,跑到第一个 yield,输出 0
print(acc.send(10))       # 10
print(acc.send(20))       # 30
print(acc.send(5))        # 35

注意:第一次必须先 next(acc)(或 send(None))把生成器推到第一个 yield,否则没有”门”在等你塞值——直接 acc.send(10) 会报错。

.send() 平时用得不多,但它揭示了一个深刻的事实:生成器不只是”能吐值的迭代器”,它还是两个执行片段之间协作通信的通道。后来 Python 的 asyncio 协程,最早就是靠这个机制演化出来的(yield from、@asyncio.coroutine)。等你去看 async/await 时会发现,事件循环在做的事,本质上就是在一堆”可暂停的函数”之间来回 send 和调度。这是后话。

动手环节

实验 1:亲手验证惰性

把下面这段存成 lazy.py,先在心里预测会打印几行,再运行:

1
2
3
4
5
6
7
8
9
10
def numbers():
    for i in range(10):
        print(f"  [生成器正在算 {i}]")
        yield i

g = numbers()
print("开始取前 3 个:")
for _ in range(3):
    print("拿到:", next(g))
print("不再取了。")

你会发现 [生成器正在算 ...] 只打印了 3 次——剩下的 7 个值,Python 根本没有去算。惰性不是”算好了藏起来”,是”没要就不算”。

实验 2:生成器表达式 vs 列表推导

把方括号换成圆括号,你就得到一个生成器表达式:

1
2
3
4
5
6
>>> nums = [x * x for x in range(1_000_000_000)]   # 小心:内存爆炸
>>> nums = (x * x for x in range(1_000_000_000))   # 瞬间完成
>>> next(nums)
0
>>> next(nums)
1

第二种写法不会真的去算十亿个数,它只是返回了一个”知道怎么算下一个平方”的生成器。处理海量数据时,这一个字符之差([] → ())往往就是”能跑”和”OOM”的区别。

自测问题

答得上来,说明这篇你真的读进去了:

  1. 一个生成器对象能遍历几次?为什么?(提示:想想它身上挂着什么。)
  2. 下面的代码有什么问题?怎么改?
    1
    2
    3
    4
    5
    6
    7
    
    def first_three():
        yield 1
        yield 2
        yield 3
    g = first_three()
    print(next(g))
    print(g.send(100))
    
  3. 为什么说”生成器是写迭代器的语法糖”?手写一个等价的迭代器类对比一下。

收尾地图

什么时候用生成器:

场景用列表用生成器
数据量已知且不大✅ 简单直接没必要
数据量大或未知(文件、流、无限序列)❌ 内存爆炸✅ 恒定内存
需要反复多次遍历✅ 可重复遍历❌ 一次性,要重建
需要随机访问(lst[100])✅❌ 只能顺序
处理流水线(grep→map→filter 链式)中间结果全落地✅ itertools 管道

下一站去哪:

  • 想真正发挥生成器的威力,去看标准库的 itertools——chain、islice、groupby 全是吃生成器、吐生成器的工具,能拼出声明式的数据管道。
  • 看懂了 .send() 和 yield from,再去看 asyncio 和 async/await,你会明白”协程”不过是”由事件循环(而不是你)来 next/send 的生成器”的进化版。
  • 好奇栈帧底层的话,可以搜一下 CPython 的 PyFrameObject 和 gen_send_ex,看 C 代码里那一行”把 frame 挂到生成器上”的注释。

生成器教给人的不只是一个语法,而是一种思维方式:别一次把所有答案都算出来,把”怎么算下一个答案”本身变成一个对象,让需要的人来取。 这种”按需生产”的哲学,从迭代器一路贯穿到协程,是 Python 里最值得内化的设计思想之一。

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