文章目录
- Python 生成器讲透:yield 到底做了什么
- 一、先看问题:列表撑爆内存
- 二、生成器函数的本质:不是函数,是工厂
- 执行状态去哪了?
- 三、生成器协议:三个方法
- send:双向通信
- 四、yield from:委托给子生成器
- 五、生成器表达式:惰性版的列表推导
- 六、itertools:生成器最搭的标准库
- 七、管道式数据处理
- 八、生成器的四个坑
- 九、生成器 vs 列表:什么时候别用
- 十、小结
Python 生成器讲透:yield 到底做了什么
很多人用生成器只停留在"知道它省内存",
但说不清yield执行时到底发生了什么、为什么next()一次就停住、
以及为什么生成器只能遍历一次。本篇从函数暂停/恢复这个角度切入,把 yield、send、yield from
一次性讲清楚,最后给出几个真正用得上生成器的场景。
一、先看问题:列表撑爆内存
defread_all(path):withopen(path,encoding="utf-8")asf:returnf.readlines()# 一次性全部读进内存lines=read_all("huge.log")# 10 GB 的日志文件 → 内存直接爆炸改成生成器:
defread_lazy(path):withopen(path,encoding="utf-8")asf:forlineinf:yieldline.rstrip("\n")forlineinread_lazy("huge.log"):# 每次只在内存里放一行if"ERROR"inline:print(line)区别不是"快一点",是从 O(n) 空间降到 O(1)。
10 GB 文件用生成器处理,内存占用可以稳定在几 MB。
二、生成器函数的本质:不是函数,是工厂
这是最关键的一点,理解了它后面全通:
defgen():print("开始")yield1print("继续")yield2print("结束")g=gen()print(type(g))# <class 'generator'>print(g)# <generator object gen at 0x...>注意:调用gen()时,函数体一行都没执行。"开始"没有被打印——因为函数里有yield,
Python 就不把它当普通函数,而是返回一个生成器对象。
函数体要等next()才跑:
g=gen()next(g)# 打印"开始",返回 1,然后卡在 yield 1 这一行next(g)# 从上次卡住的地方继续,打印"继续",返回 2next(g)# 打印"结束",函数结束 → 抛 StopIteration执行状态去哪了?
普通函数返回后,局部变量就没了。生成器凭什么记住执行到哪一行?
看这几个属性:
defcounter():i=0whileTrue:yieldi i+=1c=counter()next(c);next(c);next(c)print(c.gi_frame.f_locals)# {'i': 3} ← 局部变量活着print(c.gi_frame.f_lasti)# 当前执行到的字节码偏移量每个生成器对象都持有自己的一个栈帧(gi_frame)。
局部变量和执行位置都存在这个帧里,所以能暂停也能恢复。
这也是生成器比列表更耗内存 per-item的原因——每个生成器都要带一个帧。
数据量小的时候,列表反而更划算。
三、生成器协议:三个方法
生成器对象实现了迭代器协议,完整接口其实有四个:
| 方法 | 作用 |
|---|---|
__next__() | 推进到下一个 yield |
send(value) | 推进,并把 value 作为 yield 表达式的值 |
throw(exc) | 在暂停处抛出异常 |
close() | 强制结束,内部抛GeneratorExit |
send:双向通信
yield不只是"返回值",它还是一个表达式,可以接收外面送进来的值:
defaccumulator():total=0whileTrue:x=yieldtotal# yield 右边是"送出去",左边是"收进来"ifxisNone:breaktotal+=x acc=accumulator()next(acc)# 必须先 next() 启动,让它跑到 yield 处(返回 0)print(acc.send(10))# 10 → total=0+10print(acc.send(20))# 30 → total=10+20print(acc.send(5))# 35acc.close()为什么必须先next()?因为第一个send()时生成器还没开始执行,
没有"正在等待的 yield" 来接收值。所以规则是:
第一次必须send(None)(等价于next())或用装饰器预激。
fromfunctoolsimportwrapsdefprimed(fn):# 预激装饰器,省得每次手动 next()@wraps(fn)defwrapper(*args,**kwargs):g=fn(*args,**kwargs)next(g)returngreturnwrapper@primeddefaccumulator():...acc=accumulator()print(acc.send(10))# 直接用,不用先 next四、yield from:委托给子生成器
手动遍历子生成器再逐个 yield 很啰嗦:
defchain(*iterables):foritiniterables:forxinit:# 两层循环yieldxyield from一行搞定,而且不只是省代码:
defchain(*iterables):foritiniterables:yieldfromit它真正做的是建立一条直通管道:send()的值会直接送达子生成器,throw()的异常也会传进去,
子生成器的return值会成为yield from表达式的值。
definner():x=yield"inner ready"returnf"inner got{x}"defouter():result=yieldfrominner()# 接收子生成器的返回值print("inner 返回:",result)yield"outer done"o=outer()print(next(o))# "inner ready"print(o.send(42))# 打印 "inner got 42",然后返回 "outer done"这就是async/await的底层形态——await就是异步版的yield from。
理解了yield from,协程就没那么神秘了。
五、生成器表达式:惰性版的列表推导
squares_list=[x*xforxinrange(10)]# 立刻算完,占内存squares_gen=(x*xforxinrange(10))# 惰性,几乎不占内存print(sum(x*xforxinrange(10)))# 285,注意 sum() 里不用再加括号⚠️惰性也意味着延迟求值,闭包陷阱会咬人:
funcs=[lambda:iforiinrange(3)]# 列表推导print([f()forfinfuncs])# [2, 2, 2] ← 全是 2!# 生成器表达式同样有这个问题gen=(lambda:iforiinrange(3))print([f()forfingen])# [2, 2, 2]原因是i是同一个变量,lambda 只是引用它,调用时i已经变成 2 了。
解决:用默认参数固化
funcs=[lambdai=i:iforiinrange(3)]print([f()forfinfuncs])# [0, 1, 2] ✅六、itertools:生成器最搭的标准库
fromitertoolsimportislice,chain,count,cycle,takewhile,groupby,teelist(islice(count(0,2),5))# [0, 2, 4, 6, 8] 从无限序列取 5 个list(chain([1,2],"ab"))# [1, 2, 'a', 'b']# takewhile:按条件截断(遇到不满足的就停,不是过滤)list(takewhile(lambdax:x<5,[1,3,6,2,1]))# [1, 3] ← 6 之后就停了# groupby:必须先排序才能正确分组(它只合并相邻的相同 key)data=sorted([("a",1),("b",2),("a",3)],key=lambdax:x[0])fork,gingroupby(data,key=lambdax:x[0]):print(k,list(g))# a [('a',1),('a',3)] b [('b',2)]# tee:一个生成器复制成多个(注意:之后原生成器别再用了)g=(xforxinrange(5))a,b=tee(g)groupby那个"必须先排序"的坑非常常见,不排序会得到重复的组。
七、管道式数据处理
生成器的最佳用法是串成流水线,每个环节只做一件事:
defread_lines(path):withopen(path,encoding="utf-8")asf:forlineinf:yieldline.rstrip()defgrep(iterable,pattern):forlineiniterable:ifpatterninline:yieldlinedefto_upper(iterable):forlineiniterable:yieldline.upper()deftake(iterable,n):fori,iteminenumerate(iterable):ifi>=n:breakyielditem# 组装:读文件 → 过滤 ERROR → 转大写 → 取前 5 条pipeline=take(to_upper(grep(read_lines("app.log"),"ERROR")),5)forlineinpipeline:print(line)这个写法的三个好处:
- 内存恒定——不管文件多大,同时只有一行在内存里
- 惰性短路——
take(5)拿到 5 条后,break会让整个链条停止,
不会读完整个文件 - 可组合——每个函数独立可测,随便调换顺序
八、生成器的四个坑
坑 1:只能用一次
g=(xforxinrange(3))print(list(g))# [0, 1, 2]print(list(g))# [] ← 耗尽了!生成器是一次性的。需要多次遍历就转列表,
或者用itertools.tee,或者干脆封装成函数每次重新调用。
坑 2:过早耗尽
g=(xforxinrange(10))if5ing:# 这里把 g 消耗到 5 了print("有 5")print(list(g))# [6, 7, 8, 9] ← 前面的没了!in、any()、all()、max()都会消耗生成器。
坑 3:finally 不保证执行
defgen():try:yield1finally:print("清理资源")# 生成器被 GC 回收时才会执行g=gen()next(g)delg# 打印"清理资源"(CPython 的 GC 触发)如果生成器没被显式close(),清理时机取决于 GC,不确定。
涉及资源释放(文件、连接)时,显式close()或用contextlib.closing。
更推荐直接用with+ 上下文管理器(下一篇讲)。
坑 4:生成器不是线程安全的
同一个生成器对象不能在多个线程里同时next()。
要并发就每个线程各自创建自己的生成器。
九、生成器 vs 列表:什么时候别用
生成器不是万能的,这几种情况老实用列表:
| 情况 | 原因 |
|---|---|
| 数据量很小(< 1000) | 列表更快,生成器要维护栈帧,有额外开销 |
| 需要多次遍历 | 生成器一次性 |
| 需要索引 / 切片 / len() | 生成器不支持g[0]、len(g) |
| 需要随机访问 | 生成器只能顺序推进 |
g=(xforxinrange(10))len(g)# TypeError: object of type 'generator' has no len()g[0]# TypeError: 'generator' object is not subscriptable十、小结
- 生成器函数返回的是生成器对象,函数体要等
next()才执行 - 暂停/恢复靠的是每个生成器自带的栈帧(
gi_frame),局部变量因此活着 yield是表达式:右边送出去,左边收进来(send())- 第一次必须
send(None)/next()预激 yield from建立直通管道,是await的同步形态- 生成器只能用一次,
in/any()/max()都会消耗它 - 最佳用法是串成流水线:内存恒定、可短路、易组合
- 小数据量、要索引、要多次遍历——用列表
下一篇讲 asyncio。生成器解决了"函数能不能暂停",
而协程要解决的是"暂停的时候去干点别的"——那是并发层面的事。