写Python代码写到一定阶段,几乎所有人都会碰到一个绕不过去的坑------处理大数据集的时候,list看着简单顺手,用着用着内存就爆了。这时候有人会甩给你一句话,"用生成器啊"。但生成器到底是什么,yield凭什么能让函数中途停下来又重新跑起来,这背后藏着的其实是Python运行时一套相当精巧的机制。下面就把这套机制拆开揉碎,一步步讲清楚。
🔍 生成器到底是什么
普通函数被调用时,Python会立刻执行函数体,跑到return或者函数末尾就结束,栈帧被销毁,一切归零。生成器函数长得跟普通函数几乎一样,唯一的区别是函数体里出现了yield关键字。只要一个函数里有yield,Python在编译阶段就会把它标记成生成器函数------调用它不会执行函数体,而是返回一个生成器对象。
这个特性最早由PEP 255在2001年正式引入Python语言,目的是提供一种更轻量、更直接的惰性求值方式$CITE_2。在此之前,程序员如果想要类似的暂停恢复效果,通常得手写一个实现了迭代器协议的类,代码冗长又容易出错。
生成器对象本身遵循迭代器协议 ,也就是实现了__iter__和__next__两个方法。每次调用next(),生成器函数体才真正往前跑一步,跑到yield表达式就停下来,把值扔出去,同时把整个执行状态------包括局部变量、指令指针、调用栈------原地冻结保存。下次再调用next(),它会从冻结的地方原地复活,继续往下跑,直到碰到下一个yield或者函数自然结束。函数结束时会抛出StopIteration异常,这是迭代协议里约定好的信号,告诉外界"没有更多数据了"。
这种能记住自己上次跑到哪儿的能力,正是生成器和普通函数最本质的区别。普通函数就像一次性烟花,放完就没了;生成器更像是可以按暂停键的录像带,随时接着放。

💤 惰性求值:不到最后一刻不出手
惰性求值 (lazy evaluation)这个词听着玄乎,其实说的道理特别朴素------不用的东西就不算。
对比一下两种写法。假设要生成一百万个数字的平方:
python
squares_list = [x**2 for x in range(1000000)] # 立即算完,占满内存
squares_gen = (x**2 for x in range(1000000)) # 一个值都没算,只是准备好了怎么算
第一种列表推导式会立刻把一百万个平方数全部算出来,塞进内存里的一个列表对象,哪怕你后面只用到前十个也白算了九十九万九千九百九十个。第二种生成器表达式则完全不同,它只是记住了计算规则 ,真正的计算被无限期推迟,直到你调用next()或者用for循环去取值的那一刻,它才现算现给,用一个吐一个,用完就扔$CITE_1。
这种按需供货的模式带来的最大好处是内存友好。处理一个几十GB的日志文件时,用生成器逐行读取,内存占用几乎恒定,不会随着文件变大而暴涨。有人在Stack Overflow上感慨说这种懒惰行为一开始理解起来挺反直觉的,明明写的是一个函数,调用了却什么都没发生,直到真正去迭代它才看到效果,这恰恰印证了生成器骨子里"能拖就拖,拖到不能拖再动手"的性格$CITE_3。
再打个更形象的比方。列表推导式像是提前把整场自助餐的菜全部做好摆一桌子,生成器则像是后厨看到你点单才现炒现出------你吃十个菜厨房就炒十个菜,绝不多做一份浪费食材。
🧩 生成器涉及的核心概念
要把生成器彻底吃透,离不开下面这几个互相咬合的概念。
迭代器协议(Iterator Protocol)
Python里"可迭代"和"迭代器"是两个不同层次的东西。可迭代对象(iterable)只需要实现__iter__方法,能返回一个迭代器就行;迭代器(iterator)本身则必须同时实现__iter__(返回自己)和__next__(每次前进一步,取完抛StopIteration)。生成器对象天然同时满足这两条,所以既能被for循环直接使用,也可以手动调用next()一步步驱动$CITE_4。
栈帧的挂起与恢复
这是最核心也最"魔法"的部分。CPython在遇到yield时并不会真的销毁函数的执行环境,而是把当前的字节码执行位置、局部变量表、异常处理状态整个打包冻结在生成器对象内部。恢复执行时,解释器直接从冻结点接着跑,这跟操作系统做进程切换的思路有点像,只不过是在用户态、单线程范围内完成的轻量级"上下文切换"。
send()、throw()、close()------双向通道
纯粹的yield只能做到函数往外吐数据,如果想反过来给生成器塞 数据呢?这就是PEP 342要解决的问题。它在生成器基础上加了三个方法,把生成器升级成了简易协程$CITE_5。
send(value)不仅能像next()一样推进生成器,还能把value作为上一个yield表达式的返回值传进去,实现真正的双向通信。throw(exception)可以在生成器挂起的那个yield点上强行注入一个异常,常用于取消操作或者错误传播$CITE_7。close()则是优雅地关闭生成器,内部相当于在挂起点抛出GeneratorExit,让生成器有机会执行清理代码(比如finally块里关文件)后彻底停止。
这套send/throw/close的组合,让生成器从单纯的"只读数据流"进化成了能收能发的"数据管道",也正是后来Python协程和async/await语法的历史前身 CITE8。有开发者专门讨论过如何在两端都写成像普通函数调用一样自然的形式来使用这套PEP342风格的协程,说明这套机制虽然强大,但用起来确实需要一点适应过程CITE_6。
yield from------生成器的委托
当一个生成器内部还想调用另一个生成器,逐层手动转发send/next/异常会很啰嗦。yield from语法(PEP 380)就是专门为了解决这种嵌套委托问题设计的,它能把内层生成器的产出、返回值、异常处理,几乎无缝地透传给外层,写起来比手动循环转发干净太多。
⚙️ 实战场景对照
下面这张表梳理一下几种常见写法在内存和适用场景上的差异,方便直观对比。
| 写法 | 求值时机 | 内存占用 | 典型场景 |
|---|---|---|---|
列表推导式 [x for x in ...] |
立即、一次性算完 | 与数据规模等比增长 | 数据量小,需要反复遍历或随机访问 |
生成器表达式 (x for x in ...) |
惰性、按需计算 | 几乎恒定,只保存当前状态 | 大数据流、只需顺序遍历一次 |
生成器函数(含yield) |
惰性、可暂停可恢复 | 恒定 | 需要复杂控制流、无限序列、协程式交互 |
| 手写迭代器类 | 惰性 | 恒定 | 需要在Python 2.5之前实现类似效果,或需要更细粒度控制 |
无限序列是生成器最能体现价值的场景之一。比方说要生成斐波那契数列,用列表根本没法表示"无限"这个概念,但生成器可以轻松做到:
F0=0,F1=1,Fn=Fn−1+Fn−2 (n≥2)
对应的生成器函数只需要一个while True循环,每算出一个 Fn就yield出去,外部想要多少个就取多少个,永远不会因为"数列太长"而卡死,因为它压根没打算把所有值都算出来存起来。
📌 小结
生成器这套设计的精髓,说到底就是把计算过程本身 变成了一个可以随时暂停、随时接续的对象。它靠语言层面的yield关键字,把原本需要手写状态机才能实现的暂停恢复逻辑,压缩成了几行看起来跟普通函数没什么两样的代码。惰性求值带来的内存优势,加上send/throw/close赋予的双向通信能力,让生成器不只是一个"省内存的列表替代品",更是Python后来协程和异步编程体系的地基。理解了生成器怎么冻结和恢复栈帧,再去看async def和await会发现底层逻辑其实一脉相承,只是包了一层更友好的语法糖而已。