ARTICLE DETAIL

资讯详情

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

Python生成器与yield:惰性求值如何让内存不再爆掉

Python生成器与yield:惰性求值如何让内存不再爆掉 Python 里有个奇怪的现象列表、字典、集合几乎人人都会但一提生成器或者 yield很多写了两年 Python 的人都要愣一下。标题里说的“惰性求值之美”其实就是生成器真正的灵魂——不是一次性把所有结果算出来堆到内存里而是每当你需要的时候才慢吞吞地吐一个值给你。我先说结论如果只背下了 yield 的用法而没有想明白它背后的数据流你很快就会在爬虫、日志分析、大数据流处理上栽跟头。这篇东西我尽量不端着从内存爆掉的现场说起把生成器的行为、yield 的状态保存机制、常见坑位和能直接抄的实战模板一次讲透。不管你是刚学 Python 的萌新还是已经在 VSCode 或 PyCharm 里配好环境、正准备写爬虫或量化策略代码的人都可以跟着过一遍。1. 先搞清楚生成器到底是什么解决什么问题1.1 从一个内存爆掉的列表说起我第一次对生成器产生强烈好感是在处理一个接近 10GB 的日志文件时。当时图省事直接调readlines()把整个文件读进列表结果不到十秒机器卡死内存占用直接飙到几个 G最后连 IDE 都被拖垮。后来换成按行读取的方式问题瞬间消失——核心不是“文件能不能读”而是“你一次到底往内存里放了什么”。普通列表是一次性把所有元素都装进内存。比如读取一个超大文件# 危险版本一次性读入所有行 with open(access.log, r, encodingutf-8) as f: lines f.readlines() # 所有行都躺在内存里 for line in lines: parse(line)如果文件里有一亿行这个列表就存放了一亿个字符串对象。字符串本身有开销再加上列表的指针数组内存很容易爆。而生成器版本则完全不同# 安全版本一行一行产出 def read_log_lines(path): with open(path, r, encodingutf-8) as f: for line in f: yield line.strip() for line in read_log_lines(access.log): parse(line)这个read_log_lines就是生成器函数yield line.strip()会暂停函数执行把当前这一行交出去处理完这一行后再进入下一轮循环接着读下一行。任何时刻内存里只有“当前这一行”和极少的函数状态不会因为文件大小而线性增长。这就是惰性求值的第一个价值把“一次性计算/加载”变成“按需计算/加载”。1.2 生成器与普通函数、迭代器的关系很多教程会把生成器、迭代器、可迭代对象混在一起讲结果新手越听越乱。我用一句话给你理清生成器函数是定义生成器对象是实例生成器对象本身就是一个迭代器而迭代器一定可迭代。普通函数遇到return就彻底结束了每次调用都会重新执行函数体。生成器函数不一样它只要包含任何一个yield关键字调用时不会执行函数体而是返回一个生成器对象。只有当你对它调用next()或者放进for循环里函数体才会开始执行一直跑到第一个yield就暂停住。手写一个迭代器类需要同时实现__iter__和__next__两个方法还要自己维护内部状态。生成器相当于把这套繁琐流程封装成了语法糖。下面这两个例子在行为上基本等价# 手写迭代器 class CountDown: def __init__(self, n): self.n n def __iter__(self): return self def __next__(self): if self.n 0: raise StopIteration self.n - 1 return self.n 1 # 生成器写法 def count_down(n): while n 0: yield n n - 1写业务代码时生成器显然更直观。而且生成器还帮你处理了StopIteration异常for循环会在迭代完时自动退出不需要手动抛出异常。理解这一层后面再聊send、throw、close也有地方安放。2. yield 关键字代码暂停与恢复的开关2.1 用“做饭流程”来理解 yield 的工作方式如果你觉得“暂停/恢复”还是抽象可以想象一个厨师在备菜菜谱就像函数体从上往下执行。普通函数必须一口气把十道菜全部做完才允许你吃中间你不能催也不能要求先上第一道。而生成器函数更像一个“按需上菜的厨师”你喊一次next()厨师就做一道菜端出来然后站在原地不动手里还保持着刚才切菜的动作。你再喊一次他接着刚才的动作继续做下一道菜。如果菜谱已经做完了他就不干了抛出一个StopIteration告诉你“结束了”。关键点在于厨师“站在原地不动”时他的切菜刀、配料位置、火候状态都完整保留着。换成程序语言就是生成器函数的局部变量、循环指针、栈帧信息全都被保存下来。调用next()时不是从头再来而是从刚才挂起的地方继续执行。很多新手会把yield当成一种“特殊的 return”。它们在“交出一个值”这件事上确实相似但区别非常致命return是把函数运行结果交给调用方然后函数彻底销毁yield是把当前值交给调用方然后函数挂起等待恢复。一个结束一个暂停。2.2 yield 与 return 的本质状态保存在哪为了说明状态是怎么保存的看一个最简单的计数器def counter(): index 0 while True: yield index index 1 c counter() print(next(c)) # 0 print(next(c)) # 1 print(next(c)) # 2如果是普通函数每次调用counter()都会重新把index设置成 0所以不可能持续递增。生成器则不同它把index这个局部变量保存到了生成器对象内部的帧对象里。每次next()恢复时index还带着上一次运算后的值。从 CPython 的实现层面看生成器对象持有一个“函数帧”的引用函数帧里存着局部变量、当前执行到的字节码位置、异常状态等。普通函数调用结束后帧会被释放生成器挂起时帧一直存活在堆上。这也是“惰性求值”在底层能成立的原因——暂停不是“卡住”而是把现场整个保存下来等下次再继续。这个机制非常强大。你可以让一个生成器永远不结束随时根据需要产出下一个值也可以让它慢慢处理海量数据而不会因为“函数还没有跑完”就把前面算好的东西丢光。2.3 yield 表达式的进阶send()、throw()、close()很多人不知道yield不仅是“向外产出值”它还能“接收外部值”。看下面这段代码def echo(): received None while True: received yield received e echo() print(next(e)) # None走到 yield 时停下来 print(e.send(hello)) # hellosend 把值传进 yield 表达式 print(e.send(world)) # world这里yield received会先产出当前received的值然后挂起外部调用send(hello)时这个字符串会作为yield表达式的结果赋值给received。相当于生成器内部和外部之间多了一条反向通道。send的第一个坑是一个全新的生成器对象必须先调用一次next()或send(None)让它执行到第一个 yield 处之后才能send具体值。如果上来就send(hello)Python 会抛TypeError: cant send non-None value to a just-started generator。throw和close的使用场景稍微少一点但在资源清理和异常注入时很有用。gen.throw(RuntimeError(xx))可以把异常直接抛到生成器当前挂起的yield那一行gen.close()则会在挂起点给生成器注入GeneratorExit触发finally块做清理然后关闭生成器。3. 惰性求值为什么会更快、更省内存3.1 惰性 vs 饥饿一个斐波那契的对比实验“惰性”这个词听起来像偷懒但在编程里是个褒义词。我们用斐波那契数列来对比两种写法。普通函数必须把前 N 个结果全部算出来放到列表里返回。生成器函数则只负责“下一个是什么”至于要不要继续算由调用方决定。def fib_list(n): result [] a, b 0, 1 for _ in range(n): result.append(a) a, b b, a b return result def fib_gen(n): a, b 0, 1 for _ in range(n): yield a a, b b, a b假设 N 是 100 万fib_list会创建一个包含 100 万个 int 的列表每个 int 都是一次运算后单独建的对象内存轻松达到几十 MB。fib_gen对象本身只有很小的状态不管 N 是 100 万还是一个亿内存占用都几乎不变。而且生成器的上限可以无穷大。列表版本必须传入 n生成器版本可以写死循环配合itertools.islice只取前几个import itertools def fib_infinite(): a, b 0, 1 while True: yield a a, b b, a b first_ten list(itertools.islice(fib_infinite(), 10))这里体现的并不是“生成器一定比列表快”而是“生成器不会一次性制造多余的临时数据”。遍历时的 CPU 总成本可能差不多但内存峰值天差地别。而内存峰值往往才是系统挂掉的真正原因。3.2 超大文件和数据流处理场景实际业务里最需要惰性求值的地方一个是日志文件一个是网络流数据。以日志解析为例假设你有多个来源的原始日志需要统一清洗成结构化字典再交给下游统计模块def parse_access_log(path): with open(path, r, encodingutf-8) as f: for line in f: parts line.strip().split( ) if len(parts) 3: continue yield { ip: parts[0], time: parts[1], path: parts[2], } for record in parse_access_log(access.log): save_to_db(record)parse_access_log本质上是一个“流式转换器”。它不会先读完全部行再处理而是每读一行就产出一条记录。下游save_to_db处理完一条生成器才接着读下一行。如果某个时间段的日志量特别大整个过程仍然保持稳定的低内存。这给代码结构也带来了好处生产者只负责产出数据消费者只负责消费数据中间通过for循环连接。你不需要在函数里写“统计”和“入库”的逻辑生成器让代码边界变干净了。3.3 惰性求值不是银弹什么时候该用什么时候别用惰性求值虽美但不是所有场景都必须用它。如果你只有几千条数据生成器和列表差别可以忽略如果数据需要反复遍历多次、需要按下标随机访问、需要随时知道长度那么生成器并不合适。生成器只能单向迭代一次天然不支持len()也不支持切片和索引。如果你把数据放进生成器后又想在后面某个地方重新遍历一遍必须重新生成或者提前缓存成列表。另一个坑是错误时机普通函数出错时调用和报错在同一瞬间生成器内部如果出了问题可能要等到迭代到那个元素时才抛异常这会增加调试的难度。我的建议很简单小数据用列表追求可读性大数据或无限数据流用生成器不确定时先估算一下内存峰值——如果你要处理的数据集超过内存的五分之一就认真考虑惰性方案。4. 生成器的实战应用场景拆解4.1 场景一爬虫任务队列的批量产出爬虫里躲不开翻页 URL 的批量生成。很多人习惯先构造一个完整的 URL 列表再丢给线程池。如果页码有几百万页这个列表本身就占了大量内存。用生成器产出 URL代码会简洁不少def build_urls(base, total_pages): for page in range(1, total_pages 1): yield f{base}?page{page} base_url https://example.com/list for url in build_urls(base_url, 1000000): fetch(url)再配合一个简单的去重器可以应付很多中小型爬虫任务seen set() def unique_urls(url_iter): for url in url_iter: if url not in seen: seen.add(url) yield url for url in unique_urls(build_urls(base_url, 10000)): fetch(url)这里生成器扮演的是“数据管道”角色。第一层生成原始 URL第二层负责去重第三层才真正请求。每一层都是惰性的不会一次性把所有 URL 都塞进内存。实际爬虫时还要注意请求异常不能只靠生成器解决。网络波动、代理失效都可能在消费端出现你需要在for循环里 catch 异常并决定是否重试。生成器负责按需产出容错策略放在消费者一侧。4.2 场景二量化回测中的 K 线数据流量化回测代码听起来很高大上其实核心就是把历史行情一根一根喂给策略函数。很多人用 pandas 一次性读到内存里做向量化这在单标的没问题但多标的、长时间周期时内存压力很大。用生成器按时间顺序产出 K 线天然适合这种流式回测逻辑。def load_kline_from_csv(file_path): with open(file_path, r, encodingutf-8) as f: next(f) # 跳过表头 for line in f: ts, open_, high, low, close, volume line.strip().split(,) yield { timestamp: ts, open: float(open_), high: float(high_), low: float(low_), close: float(close_), volume: float(volume), } def strategy(bar): if bar[close] bar[open]: return signal_buy return None for bar in load_kline_from_csv(btc_usdt_1h.csv): signal strategy(bar) if signal: print(bar[timestamp], signal)这样处理几百万行 K 线数据时内存里永远只有一根 bar。和 pandas 的全量 DataFrame 相比牺牲的是某种程度的方便你不能随便df[df.close df.open]必须自己维护滚动窗口。但换来的是极稳定的内存表现尤其适合长时间跑在云服务器上的回测任务。另外要注意生成器只能消费一次。如果同一个历史数据要在不同策略参数下反复回放每次回测都要重新创建生成器。更好的做法是把 CSV 解析函数封装成工厂函数每次回测时调一次。4.3 用 yield 和 send 做一个简化版状态机如果你写过复杂业务会发现很多代码本质上是一个状态机登录态、支付状态、任务状态。yield天然支持挂起和恢复所以很适合用来实现轻量级状态机。def light_switch(): state off while True: action yield state if action flip: state on if state off else off switch light_switch() print(next(switch)) # off print(switch.send(flip)) # on print(switch.send(flip)) # off这个例子虽然玩具但思路很实用生成器内部保存state变量外部通过send发送动作生成器根据当前状态做出响应。你完全可以在此基础上扩展成订单状态机、审批流状态机。比起用类加一堆 if-else这种协程式写法在状态明确时更直观。Python 3.5 之后有了原生async/await很多场景下可以直接用协程代替基于生成器的协程。但这不代表yield过时了它在迭代器协议、流式处理上的地位仍然不可替代。5. 我踩过的坑常见问题与排查实录5.1 生成器只能迭代一次第二次就空了这是新手最容易踩的坑。看下面这段代码numbers (x for x in range(10)) first list(numbers) second list(numbers) print(first) # [0, 1, 2, ...] print(second) # []numbers是一个生成器对象它内部有一个“游标”。第一次list(numbers)把所有元素消费完之后游标已经跑到末尾第二次再消费就什么也拿不到。如果你需要在多个地方遍历可以提前转成列表或者用itertools.tee复制出多个迭代器from itertools import tee a, b tee(numbers, 2) print(list(a)) print(list(b))tee也不是没有代价它会在内部缓存未消费的元素所以数据量特别大时要慎用。最稳妥的方案是只消费一次如果业务上必须要多次访问从一开始就用列表。5.2 yield 在 try/finally 里藏着的资源清理陷阱生成器支持在finally里做清理但触发时机很容易被忽略。看这个例子def managed_resource(): print(open resource) try: yield resource finally: print(close resource) gen managed_resource() print(next(gen)) # 没有继续迭代也没有 close()如果生成器没有被完整迭代也没有调用close()finally不会立刻执行。虽然 CPython 的引用计数在生成器对象被垃圾回收时会触发GeneratorExit但回收时机不确定在 Jython 或某些延迟回收的环境里资源可能长期无法释放。正确做法是手动关闭gen managed_resource() print(next(gen)) gen.close() # 立刻打印 close resource如果你的生成器内部打开了文件、锁、数据库连接一定要用try/finally包住yield并在外层保证close()被调用。很多“文件被占用删不掉”的问题根源就是生成器没关干净。5.3 无限生成器配合 itertools.islice 的使用技巧很多教程会写一个 0 到无穷大的序列def infinite_sequence(): n 0 while True: yield n n 1如果你直接for i in infinite_sequence(): print(i)程序会永远跑下去。想要截取前 N 个要么手动nextN 次要么用islicefrom itertools import islice seq infinite_sequence() for i in islice(seq, 5, 10): print(i) # 5 6 7 8 9islice支持起点、终点、步长和列表切片用法很像但它是惰性的不会一次性把所有元素都生出来。用无限生成器时脑子里时刻要有一根弦这是一个无尽的流必须设置边界。5.4 列表推导与生成器表达式括号千万别漏列表推导式用的是方括号生成器表达式用的是圆括号。有人图省事写x for x in range(10)不带括号单独作为表达式时会直接语法错误。但放在函数参数里可以少一层括号total sum(x * x for x in range(10)) # 合法参数位置自动作为生成器表达式 total sum((x * x for x in range(10))) # 也可以不同之处在于列表推导式会一次性生成列表生成器表达式则是惰性求值。比如squares_list [x * x for x in range(1000000)] # 直接占用内存 squares_gen (x * x for x in range(1000000)) # 生成器对象如果只需要遍历一次生成器表达式通常更省内存。但需要注意生成器表达式中的变量是在迭代时才读取的如果引用了外部会变化的变量结果可能和你预想不同。6. 进阶技巧、性能画像与实用速查6.1 yield from让嵌套生成器优雅到极致当你想在一个生成器里逐个产出另一个生成器的元素时可以用yield fromdef flatten(nested): for sub in nested: if isinstance(sub, (list, tuple)): yield from flatten(sub) else: yield sub data [1, [2, [3, 4], 5], 6] print(list(flatten(data))) # [1, 2, 3, 4, 5, 6]yield from不只是语法糖。它会把子生成器的send、throw等调用自动转发到内部生成器这是普通 for 循环加yield做不到的。如果你在做协程式的数据管道yield from能大幅减少样板代码。6.2 生成器表达式里的延迟绑定问题生成器表达式是惰性的这导致它捕获外部变量时只在迭代时才求值。常见反模式x 10 gen (x for _ in range(3)) x 100 print(list(gen)) # [100, 100, 100]你已经改变了x生成器内部并没有做快照。如果在函数里用循环变量构造多个生成器也要小心循环结束后变量残留的问题。尽量避免在生成器表达式中引用外层可变变量或者把值绑定到默认参数def make_gens(): result [] for i in range(3): gen (lambda vali: val) result.append(gen) return result这不算生成器特有的问题但惰性求值会让这种 bug 更难发现——因为报错和赋值时机分离了。6.3 生成器、列表、迭代器的内存画像对比经常有人问我生成器为什么“很省内存”我习惯用一张表说明特性列表 List元组 Tuple生成器 Generator是否一次性存入内存是是否按需产出元素是否可能无限否否可以是无限序列是否支持下标索引支持l[0]支持t[0]不支持是否支持len()支持支持不支持可迭代次数无数次无数次通常只能一次内存开销随元素数量增长随元素数量增长固定极小开销典型用途随机访问、多次遍历不可变数据大文件、流式处理、管道sys.getsizeof可以直观看到差异import sys lst [i for i in range(10000)] gen (i for i in range(10000)) print(sys.getsizeof(lst)) # 87616 左右 print(sys.getsizeof(gen)) # 112 左右生成器对象自身很小因为它不持有元素只持有函数帧和挂起点。但要注意这不代表生成器在完整迭代过程中的总内存一定很低如果每个生成的元素本身非常大那内存峰值取决于“单次产出的元素”而不是“全部元素的总和”。我个人在实际操作中的体会是生成器和 yield 最难的不是语法而是思维方式的转变。你不再一次性准备所有东西而是把计算拆成“下一步需要什么”的节奏。写爬虫时我习惯把所有翻页 URL 做成生成器内存稳了代码也分层了写量化回测时我把 K 线流做成生成器再复杂的多标的循环也能保持内存可控。最后再分享一个小技巧当你不确定某个数据该用列表还是生成器时先问自己三个问题——这个数据我要遍历几次要不要随机访问数量级是否大到内存放不下如果前两个答案都是“否”最后一个答案是“是”那就别犹豫用生成器。
返回列表