ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python yield生成器:从内存优化到send/yield from全解析

Python yield生成器:从内存优化到send/yield from全解析 被 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, encodingutf-8)] for line in lines: parse(line)如果文件只有几千行这个写法完全没问题。但真实的线上日志动辄几千万甚至上亿行每行平均 200 个字符两亿行就是 40GB 左右的内存占用。我当时没有生成器的意识程序跑起来没几分钟内存曲线直接拉满机器卡死最后进程被系统杀掉。事后复盘时发现问题不在于解析逻辑写错了而在于数据加载方式本身列表推导式会把所有数据一次性创建出来完整地存放在内存里数据量一大内存就是唯一的瓶颈。1.2 惰性求值生成器解决什么问题把同样的需求换一种写法情况完全不同lines (line.strip() for line in open(access.log, encodingutf-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 的区别做个汇总对比项returnyield函数性质普通函数瞬间执行完毕函数变成生成器可暂停可恢复返回方式一次返回一个值函数结束每次产出值后暂停可多次产出函数体执行时机调用时立即执行调用时只创建对象迭代时才执行状态保存不保存函数栈销毁保存局部状态支持恢复结束方式函数自然退出耗尽后抛 StopIteration一个普通函数里如果没有 yieldreturn 一执行函数栈直接销毁而生成器里的 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: cant 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() # 触发 GeneratorExitfinally 块执行close() 会在生成器暂停的位置抛出一个 GeneratorExit 异常。如果生成器里有 try/finallyfinally 块会被执行方便释放文件句柄、数据库连接等资源。这是生成器需要主动收尾场景下非常实用的工具。实际项目中如果生成器持有外部资源比如打开的文件、网络请求会话强烈建议把清理逻辑放在 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, encodingutf-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 三遍。等你哪天写数据处理代码时下意识用生成器而不是列表说明这一课你真正过关了。
返回列表