被 yield 逼着认真学习 Python 生成器,是因为一次线上数据处理事故。当时我用列表推导式读一个 2 亿行的日志文件,服务器内存直接被打爆,进程被系统杀掉。那次之后我才真正理解,Python 里 yield 和生成器到底解决了什么问题。这篇文章想把 yield 从执行机制到高级用法完整梳理一遍,用最直白的方式讲清楚它和 return 的区别、send/yield from 这些进阶特性,以及面试里围绕生成器的高频考点和隐藏坑。无论你用的是 3.8 还是最新的 3.13,这套机制几乎没有变过,值得一次彻底搞懂。
1. 为什么你需要yield:从一次内存爆炸的真实场景说起
1.1 列表推导式把程序搞崩了
之前做数据清洗,需要逐行处理一个超大的日志文件,最直观的想法往往是这样的:
lines = [line.strip() for line in open("access.log", encoding="utf-8")] for line in lines: parse(line)如果文件只有几千行,这个写法完全没问题。但真实的线上日志动辄几千万甚至上亿行,每行平均 200 个字符,两亿行就是 40GB 左右的内存占用。我当时没有生成器的意识,程序跑起来没几分钟,内存曲线直接拉满,机器卡死,最后进程被系统杀掉。事后复盘时发现,问题不在于"解析逻辑"写错了,而在于数据加载方式本身:列表推导式会把所有数据一次性创建出来,完整地存放在内存里,数据量一大,内存就是唯一的瓶颈。
1.2 惰性求值:生成器解决什么问题
把同样的需求换一种写法,情况完全不同:
lines = (line.strip() for line in open("access.log", encoding="utf-8")) for line in lines: parse(line)中括号换成小括号,看起来只是字符上的差异,背后的行为却截然不同。(...)这行创建的是一个生成器对象,它不会立即读取整个文件,也不会提前构造出所有字符串。for 循环每迭代一次,生成器才从文件中取下一行,处理完就丢。整个过程内存占用基本恒定,只取决于单行的大小,而不是整个文件的大小。
这种"需要的时候才计算"的策略,在 Python 里叫惰性求值(lazy evaluation),也是生成器最核心的价值:不是一次性把结果全部算完放进内存,而是按需逐个产出,把计算和存储压力分摊到整个迭代过程中。说白了,就是用一点点 CPU 换几十 GB 内存,这笔账怎么算都划算。
1.3 什么时候该用生成器
我习惯用一张表来判断:
| 使用场景 | 列表 | 生成器 |
|---|---|---|
| 数据量小、需要反复访问 | 合适 | 也能用,但取不到下标 |
| 数据量很大或无限序列 | 内存扛不住 | 非常适合 |
| 只需要遍历一次 | 浪费内存 | 首选 |
| 需要随机访问某个位置 | 可以 | 不行 |
补充一条个人经验:如果拿不准,先问自己一句"这个结果我是只需要遍历一遍,还是后面还要反复用"。只需要遍历一遍的,无脑选生成器;要反复用、要按位置取元素的,老老实实用列表。另外记住,生成器是一次性的,遍历过就没有了,这一点后面讲面试坑的时候还会再展开。
2. 从yield开始:普通函数如何"变形"成生成器
2.1 调用函数不等于执行函数
在 Python 里,只要函数体中出现了 yield 关键字,这个函数就不再是普通函数了,它变成了一个生成器函数。这种变化带来的第一直觉冲击是:调用它并不会执行函数体。
def countdown(n): print("countdown 开始执行") while n > 0: yield n n -= 1 print("countdown 结束") gen = countdown(3) print("创建了生成器对象")运行这段代码,你会看到先打印"创建了生成器对象",而"countdown 开始执行"根本没出现。原因是 countdown(3) 并不会"运行函数",它只是返回一个生成器对象。真正执行要等到有人向它索要值。这是新手最容易懵的地方:为什么函数没有立即执行?答案在于 yield 把函数变成了"可暂停、可恢复"的状态机。在第一次真正迭代之前,函数只是被包装好了,还没开跑。
2.2 第一次next()发生了完整的链路
生成器对象创建后,怎么让它干活?最底层的方式是调用 next(gen):
gen = countdown(3) print(next(gen)) # 输出: # countdown 开始执行 # 3第一次 next(gen) 时,发生的事情是这样的:
- 函数体从上往下真正开始执行,先打印"countdown 开始执行"。
- 进入 while 循环,此时 n 是 3,条件成立,走到
yield n这一行。 yield n把 3 这个值"抛"给调用方,然后函数在这里暂停。- 整个函数的局部状态(n 当前值、执行到哪一行)都被保存下来。
- next() 返回 3,调用方的 print 打印出 3。
再调用一次 next(gen),函数不会从头重新执行,而是从上次暂停的 yield 那一行继续往下走,执行n -= 1,再回到 while 判断,再次走到 yield 时又暂停,返回 2。这个"暂停-恢复"的循环,就是生成器最底层的执行模型。
2.3 yield真正保存了什么:把函数当成可恢复的状态
很多人以为"暂停"就是函数停下来这么简单,其实关键在状态保存。生成器暂停时,Python 会保留三样东西:函数当前的局部变量、执行位置(相当于指令指针),以及异常状态。正因为这些状态被完整保存,恢复执行时才不会丢失上下文。
如果你用过视频播放器的"暂停/继续",会发现模型很相似:暂停不是结束,只是把画面和声音的进度记住,继续时从暂停点接着放。放到函数上,yield 就是在函数体中间按了一次暂停键,把进度存档,下次调用就是按"继续"。
顺带说一句:很多初学者会问"那 return 不是也能返回一个值吗,为什么非要用 yield?"先别急,return 和 yield 放一起的时候行为非常特殊,这是下一节的考点。
3. yield与return的边界:理解偏差最严重的地方
3.1 同时存在yield和return的函数是什么
先给结论:一个函数里可以同时出现 yield 和 return,它仍然是生成器函数。但 return 在生成器里并不是"返回一个普通值给调用方"的意思,而是"终止生成器"。
def gen_with_return(): yield 1 yield 2 return "done" gen = gen_with_return() print(next(gen)) # 1 print(next(gen)) # 2 print(next(gen)) # StopIteration: done第三次调用 next() 时,函数执行到 return "done",生成器到达终点,于是抛出 StopIteration 异常。for 循环捕获到这个异常后自动退出,所以你写 for 循环感知不到这个返回值;只有手动调用 next() 时,你才会在异常对象里看到 "done"。
这个点坑过不少人:以为生成器里有 return 就会像普通函数一样把值交出来,实际上它是以异常的形式结束迭代。Python 特意这样设计,就是要明确区分"产出值"和"终止信号"两种语义。
3.2 return的值通过StopIteration传递
既然 return 的值被丢进了 StopIteration 异常里,那我们能不能拿到它?可以:
gen = gen_with_return() try: while True: print(next(gen)) except StopIteration as e: print("生成器返回值:", e.value) # 生成器返回值: done注意这个e.value。它就是 return 的值。这个特性的实际用途主要在 yield from 和协程场景里。后面讲 yield from 时你会看到,外层生成器可以捕获内层生成器的"返回值",这就是通过这条隐蔽通道完成的。
3.3 yield和return在普通函数中的本质区别
写到这里,正好把 yield 和 return 的区别做个汇总:
| 对比项 | return | yield |
|---|---|---|
| 函数性质 | 普通函数瞬间执行完毕 | 函数变成生成器,可暂停可恢复 |
| 返回方式 | 一次返回一个值,函数结束 | 每次产出值后暂停,可多次产出 |
| 函数体执行时机 | 调用时立即执行 | 调用时只创建对象,迭代时才执行 |
| 状态保存 | 不保存,函数栈销毁 | 保存局部状态,支持恢复 |
| 结束方式 | 函数自然退出 | 耗尽后抛 StopIteration |
一个普通函数里如果没有 yield,return 一执行,函数栈直接销毁;而生成器里的 return 被当成"终止信号"。这种差异初看别扭,但一旦理解"生成器是状态机"这个本质,一切就顺理成章了。
4. send()、throw()、close():把生成器变成双向管道
4.1 从单行道到对话:send() 的用法
前面说的 next(gen),本质是"外部单向向生成器要值"。但 Python 的生成器还支持另一种能力:从外部往生成器内部传值,用的就是 send()。
def echo(): print("启动") while True: received = yield print(f"收到外部消息: {received}") gen = echo() next(gen) # 必须先把生成器"预热"到第一个 yield gen.send("你好") # 收到外部消息: 你好 gen.send(123) # 收到外部消息: 123这里的关键是 yield 出现在等号右边:received = yield。第一次执行到 yield 时,函数暂停并"让出"控制权;外部调用 send("你好"),这个值就会作为 yield 表达式的结果赋给 received。也就是说,yield 不仅能向外部产出数据,还能从外部接收数据,一条管道双向跑。
这个能力看着基础,但它其实是"协程"的前身。Python 还没有 async/await 之前,很多人就是靠 yield + send() 实现简单的协程调度。现在生成器方案在需要逐步交互的场景——比如手动控制流水线状态、协程框架底层——仍然有用。理解 send() 之后,再去看 asyncio 的 await 机制,会顺很多。
4.2 启动生成器的正确姿势
用 send() 之前,必须先把生成器运行到第一个 yield 处,否则直接 send 会报错:
TypeError: can't send non-None value to a just-started generator标准做法有两种:
gen = echo() gen.send(None) # 等价于 next(gen)或者更直观:
gen = echo() next(gen)原因是:生成器还没启动时,函数体一行都没执行,外部传进来的值根本没有接收点。所以第一次必须用 send(None)(或 next)完成"启动"动作。这个细节我见过很多人踩坑,面试时也经常被问到,记住"先启动再 send"这个口诀就够了。
4.3 close() 与 GeneratorExit:主动终止并释放资源
如果你想提前结束一个生成器,不再迭代下去,可以调用 close():
def job(): try: yield 1 yield 2 yield 3 finally: print("生成器被关闭,释放资源") gen = job() next(gen) # 1 gen.close() # 触发 GeneratorExit,finally 块执行close() 会在生成器暂停的位置抛出一个 GeneratorExit 异常。如果生成器里有 try/finally,finally 块会被执行,方便释放文件句柄、数据库连接等资源。这是"生成器需要主动收尾"场景下非常实用的工具。
实际项目中,如果生成器持有外部资源(比如打开的文件、网络请求会话),强烈建议把清理逻辑放在 finally 里。这样不管生成器是正常耗尽、被 close,还是外层抛异常,资源都能被释放,不会泄漏。
4.4 throw():从外部向生成器注入异常
除了 close(),还有一个 throw(exc_type) 方法,可以在生成器暂停的位置抛入一个指定异常:
def safe_divide(): try: x = yield yield 10 / x except ZeroDivisionError: yield "除数不能为0" gen = safe_divide() next(gen) gen.send(0) print(next(gen)) # 除数不能为0throw() 主要用于协程间的错误传递:外层发现子生成器状态异常时,可以直接把异常丢进去,让生成器内部处理。这个能力在编写复杂流水线时很实用,但在日常业务代码里用得不多,了解即可。
5. yield from:让生成器学会委托
5.1 嵌套迭代的价值:yield from 解决什么问题
如果要在生成器里遍历另一个可迭代对象,最自然的写法是:
def chain(*iterables): for it in iterables: for item in it: yield item但 Python 3.3 引入了一个语法糖叫 yield from,可以把上面的双层循环压成一行:
def chain(*iterables): for it in iterables: yield from ityield from it的意思是:把迭代 it 这件事"委托"出去。外层生成器不再一个个手动 yield,而是让 it 自己产出元素,每个元素自动传递给调用方。
看一个实际点的例子:把多个文件拼接成一个统一的迭代流,非常适合这种写法。
def read_lines(*files): for f in files: with open(f, encoding="utf-8") as fh: yield from fh for line in read_lines("a.log", "b.log", "c.log"): process(line)这样就能连续读取多个文件,全程内存占用恒定,不会把所有文件内容一次性加载进内存。我在处理多个日志分片、合并小文件时经常用这个模式,代码干净又省内存。
5.2 yield from能拿到子生成器的返回值
前面说过,生成器里的 return 值会塞进 StopIteration 里。而 yield from 有一个非常关键的特性:它能捕获子生成器的返回值,并把它作为 yield from 表达式的结果。
def inner(): yield 1 yield 2 return "inner done" def outer(): result = yield from inner() print("子生成器返回值:", result) list(outer()) # 输出: # 子生成器返回值: inner done # 结果是 [1, 2]这里 inner 在产出 1、2 之后 return "inner done",外层通过 yield from 拿到了这个字符串。可以把它理解成"把子生成器当函数调用,但保留它的惰性产出能力"。这个特性在构造嵌套协程时尤其有用。实际上,asyncio 的早期实现,以及很多基于生成器的协程框架,正是依赖 yield from 来组织嵌套协程调用的。现在我们写 async/await,其中 await 的语义在底层就能追溯到 yield from 的设计。
5.3 yield from传递send()和throw():双向通道不断链
还有一个容易被忽略的细节:yield from 会"透明"地传递 send() 和 throw()。什么意思?如果你在外部对带有 yield from 的生成器调用 send(),这个值会直接传给内部被委托的子生成器;子生成器内部抛出的异常,外层也能感知。
这保证了双向通信在嵌套结构中不中断,是协程嵌套调用能正常工作的基础。换句话说,yield from 不只是把"产出值"搬运出去,连同"接收值、抛异常"这些控制通道也一并委托了。这正是它和普通 for 循环加 yield 的最大区别,也是它被称为"语法糖"但又不只是糖的原因。
6. 面试高频考点:生成器的坑和那些纠结的问题
6.1 生成器是一次性的
这是最常被忽略的一点。生成器不是序列,它不能反复遍历:
gen = (x * x for x in range(5)) print(sum(gen)) # 30 print(sum(gen)) # 0,第二次已经空了第一次 sum() 会把生成器遍历完,生成器内部已经耗尽,第二次自然一个元素都没有。如果你需要多次遍历,要么改成列表,要么重新创建生成器。
这个坑在真实代码里的典型表现是:某次判断if x in gen之后,后面的循环发现少了许多数据。因为 in 操作会从头到尾扫描生成器,扫描完它就空了。是的,连"in"都会消耗生成器。
6.2 生成器表达式 vs 列表推导式
面试常问"生成器表达式和列表推导式的区别",要点就三个:语法(小括号 vs 中括号)、计算时机(惰性 vs 立即)、内存占用(几乎为零 vs 全部加载)。
list_comp = [x * x for x in range(10)] # 立即计算所有平方数 gen_exp = (x * x for x in range(10)) # 生成器表达式,惰性有一种容易混淆的情况:如果生成器表达式作为函数唯一的参数,括号可以省略,比如sum(x * x for x in range(10))。不少人看到这个写法以为 sum 里传的是列表,其实不是,这是一个生成器表达式。
6.3 人为制造"空生成器"的细节
还有一个进阶考点:生成器函数里如果 yield 不可达,或者条件不满足时提前 return,生成器不会报错,只是直接为空:
def empty_gen(flag): if not flag: return yield 1 list(empty_gen(False)) # []这在实现防御性逻辑时很管用:让函数无论什么情况都返回一个可迭代对象,调用方不用关心 None 判断,直接 for 循环就行。
6.4 生成器 vs 迭代器:到底谁是谁
这个点在面试时被问过很多次:"生成器是不是迭代器?迭代器是不是生成器?"
答案是:生成器是迭代器,但迭代器不一定是生成器。迭代器是实现了迭代协议(有__iter__和__next__)的对象,范围更大;生成器是 Python 提供的一种便捷方式来创建迭代器。你完全可以手写一个类实现__iter__和__next__来充当迭代器,但它不是生成器。
class Counter: def __init__(self, n): self.n = n self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration self.current += 1 return self.current相比之下,用 yield 写同样的东西:
def counter(n): current = 0 while current < n: current += 1 yield current明显后者代码量更少、可读性更强。这也是 yield 在 Python 中存在的最大意义之一:把"手动实现迭代协议"这种样板代码交给解释器处理。
6.5 实战建议:把链路打通
把整个链路再回顾一遍:列表和元组要求你把数据全部算完;生成器表达式和 yield 让你按需计算;send/yield from 让你在生成器之间、生成器和外部之间建立双向通信;close 让你优雅释放资源;StopIteration 是生成器正常结束的暗号。掌握了这条完整链路,yield 相关的面试题基本都能应对。
我的建议是:不要只背概念,把上面每一段代码都自己在环境里跑一遍。尤其是 countdown 的例子、send 的例子、yield from 返回值的例子,跑通了之后你对生成器的理解会比只看文档深刻得多。我自己带新人的时候,也一直强调:yield 这种抽象的东西,文档读十遍不如亲手 next 三遍。等你哪天写数据处理代码时下意识用生成器而不是列表,说明这一课你真正过关了。