Python 里有个东西叫生成器Generator我真正理解它是从一次内存崩溃开始的。当时负责清洗一个好几GB的日志文件第一版用列表推导把所有匹配的行收集到内存里程序跑了不到一分钟就卡得像幻灯片然后被系统直接杀掉。旁边的同事看了一眼代码说“这里用生成器吧别把数据一次全装进内存。”我改成生成器写法链路立刻顺畅起来内存曲线也平缓了。从那时起我就意识到生成器不是锦上添花的小技巧而是处理数据流的基础装备。这篇就来分享 Python 生成器的使用心得。适合刚学会函数和列表推导式、正被大文件或爬虫批量任务折磨的读者也适合写数据处理脚本、API 批处理接口的研发同学还有那些想在性能优化上下功夫、搞懂“到底什么时候该用生成器”的 Python 学习者。文中不聊安装环境重点说为什么、什么时候、怎么用最后给出一堆实际排查经验。如果你只是写几百行脚本、数据规模几千条生成器未必非要学但一旦进入日志分析、爬虫抓取、数据库批量取数这类场景它就是最顺手的兵器。下面我从一个“内存危机”说起。1. 为什么用生成器一场内存危机的启示1.1 一次性把数据全展开到底有什么问题很多人的第一反应是把数据读进内存再慢慢处理。比如读一个日志文件把所有包含 Error 的行收集到一个列表里def collect_errors(path): errors [] with open(path, encodingutf-8) as f: for line in f: if error in line: errors.append(line.strip()) return errors这段代码在文件几百兆甚至几个GB的时候会把内存吃满。列表里每一项都是独立字符串对象还伴随列表本身的扩增扩容时可能临时再翻倍占用内存。我在实际机器上试过一个 3GB 的日志文件这样处理时内存占用常常飙到 5GB 以上几十GB的数据源基本直接崩溃。如果换成生成器写法很接近def iter_errors(path): with open(path, encodingutf-8) as f: for line in f: if error in line: yield line.strip()调用方用for err in iter_errors(big.log)消费。这时内存里同时只有一行数据而不是所有行。这就是生成器和列表思路的核心分水岭一个是一次性把结果全部堆出来一个是按需一个一个地造出来。1.2 惰性求值到底是什么逻辑网上经常看到“惰性求值”这个词我换个更容易理解的说法生成器像是工厂的流水线列表则是仓库。生产的货物按订单来你每叫一次 next工厂就产出一个货你不叫它就停在那里不干活也不占地。而普通列表是一次性把整批货都生产完全部堆进仓库哪怕你只想看前两件成本也早就付了。但注意生成器本身还是要经过“产地”的不是完全没有计算开销。它省的是存储开销和部分不必要的计算。比如你只需要取前 10 个结果普通列表方法也要从头到尾算完每个元素才能停止生成器却能算一个交一个取够 10 个后就可以断开后面根本不会执行。这在算法题、分页、数据流场景里都是很大的优势。可以验证一下执行下面这段代码生成器表达式循环会很快停住而如果你先写[x * x for x in range(100000000)]这一行就会先花很长时间建出一亿个结果全部放进内存里。for i in (x * x for x in range(100000000)): if i 100: break print(i)这就是“不必要计算”的差别。对一个数据源的遍历方式做调整内存占用、启动延迟、单次运算量都会跟着变影响范围往往比你想的更大。1.3 什么时候用列表而不是生成器前面说得热闹但生成器不是银弹。数据量小比如几千个元素列表完全够用要随机访问中间某个元素列表用下标生成器做不到同一个结果要反复遍历好几遍列表更方便生成器只能重新创建再遍历一次需要拼接、切片、排序、求长度的场景也还是列表更顺手。所以我的经验是默认写列表只有当“数据源很大、只想遍历一次、或者只需要取前面一部分”时才换成生成器。判断标准很简单如果这个数据完完整整放在内存里没有意义就该考虑生成器如果它就是要被你反复拿出来用那换成列表更省心。2. 两个最基本的生成器写法yield 与生成器表达式2.1 yield 关键字让函数变成“会暂停的工厂”一个函数里只要出现 yield就不再是普通函数而是一个生成器函数。调用它时函数体并不会执行返回的是一个生成器对象。def gen_numbers(): print(进入生成器函数) yield 1 print(第一次被唤醒) yield 2 print(第二次被唤醒) g gen_numbers() print(函数调用后还未打印任何东西) print(next(g)) # 进入生成器函数 / 返回 1 print(next(g)) # 第一次被唤醒 / 返回 2 print(next(g)) # 第二次被唤醒 / StopIteration第一次调用 next(g)代码进入函数体在第一个 yield 处停下来把 1 交给外部第二次 next(g)代码从上一次暂停点继续执行在第二个 yield 停住交出 2第三次到函数结尾没有新的 yield抛出 StopIteration表示整个生成器已经耗尽。for 循环之所以能直接遍历生成器就是因为它在内部替你处理了 StopIteration。这里有个新手常犯的错以为调用gen_numbers()会立刻执行函数体然后等函数里的打印日志结果迟迟看不到输出。其实要让函数体真正跑起来至少要有一次 next() 或 for 遍历。同样如果一个函数内部有 yield你写没写 return 都不影响它是生成器函数这个事实最终会以 StopIteration 正常结束。2.2 生成器表达式列表推导式的“节俭表弟”除了函数形式生成器还有表达式形式。语法就是把列表推导的中括号[]换成圆括号()squares_list [x * x for x in range(1000000)] # 列表推导 squares_gen (x * x for x in range(1000000)) # 生成器表达式左边那个会立刻建出一个容纳一百万元素的列表右边只是一个“生成规则”被消费时才会逐步产出。我写代码时经常用生成器表达式传参比如sum(x * x for x in range(1, 101)) any(item.isdigit() for item in text.split())这样既省内存语义又清晰。尤其是sum、any、all、max、min这类聚合函数天然只会遍历一次没必要先堆出一个列表。需要注意生成器表达式是可迭代对象但它不是一个列表所以不能使用索引和切片也别指望它有len()。一旦你真的需要长度就只能先转成列表或者自己数一遍——这本身就是一次消费。2.3 三种用法对比什么时候选谁把三种方式放进一张表对比项生成器函数生成器表达式列表推导式返回类型generator 对象generator 对象列表执行时机调用时不执行遍历才执行遍历才执行创建时立即执行内存占用低低高可重复遍历每次重新调用函数同一个对象只能遍历一次同一列表可多次遍历是否支持索引否否是是否支持切片否否是结论很简单如果你已经有函数逻辑或者要复杂的条件判断和多步骤处理用生成器函数如果一条表达式就能搞定用生成器表达式如果后面还要反复使用、随机访问或者数据很小用列表推导式。我自己的习惯是能从生成器表达式一行写出来绝不写函数但业务逻辑超过三个条件还是老老实实拆成函数读起来更舒服。3. 进阶控制send、yield from、close 与 throw3.1 send给生成器回传数据前面讲的 next 只能让生成器往外产数据那要是想跟生成器“对话”怎么办生成器对象自带 send(value)作用跟 next 差不多但可以把一个值传进生成器给当前暂停的 yield 表达式作为结果。看一个例子经典的双向计数器。def ticket_box(): sold 0 while True: incoming yield sold # 先把当前 sold 发给调用方再接收调用方传进来的值 sold incoming box ticket_box() next(box) # 必须先生成一次把代码推到第一个 yield 处 print(box.send(10)) # 收到 10sold 变成 10返回 10 print(box.send(5)) # 收到 5sold 变成 15返回 15 print(box.send(3)) # 收到 3sold 变成 18返回 18注意第一次不能直接 send 非 None 的值。生成器函数在刚开始时还没有停在 yield 上外部传进来的值没人接收所以必须先用 next(box) 或 box.send(None) 把它初始化到位。很多人在写协程式生成器时卡在这一步——报错信息会提示TypeError: cant send non-None value to a just-started generator看到这个就要意识到是启动姿势不对。这种双向通信能力让生成器可以当成轻量级协程用比如任务分发、流水线状态机、状态累加器。但如果你要做真正复杂的异步协作还是得用 async/await它内部其实和生成器也有血缘关系只是已经演化得更完善了。生成器作为协程的玩法适合小而美地协调数据不适合做大型异步调度框架。3.2 yield from把子生成器直接“交付出去”如果要在生成器里迭代另一个可迭代对象最常见的写法是def outer(): for item in inner(): yield item这个模式太常用了Python 因此提供了语法糖yield from直接写成def inner(): for i in range(3): yield i def outer(): yield start yield from inner() yield end print(list(outer())) # [start, 0, 1, 2, end]yield from inner()的意思不是“产出 inner 这个对象”而是把 inner 产出的每个元素继续转交给外层调用者。它还会自动处理内层迭代器的关闭和异常传播能让你的代码少一层 for 包裹可读性好很多。在生成器函数内部如果你要遍历另一个生成器或可迭代对象并且整体目的只是搬运数据都应该优先考虑 yield from。我自己写多级数据管道时就经常用 yield from 把子数据流直接接到主数据流上相当于把几段水管直接焊在一起中间不用再接一个三通。3.3 close 和 throw主动终止与异常注入生成器对象还有两个不常提但很有用的方法。close() 会在生成器当前暂停的地方抛出一个 GeneratorExit 异常让生成器跳出循环并结束之后你再继续取值只会得到 StopIteration。throw(exc_type) 则是在暂停点抛入指定异常适合在外部通知生成器“这一路数据有毛病赶紧停下来处理”。实际业务里close 常用来清理资源。比如生成器维护了数据库连接或者临时文件在 finally 块里写清理逻辑外部一调用 close就会触发 finally 代码。def resource_holder(): try: while True: token yield print(处理, token) finally: print(释放连接) g resource_holder() next(g) g.send(task1) g.close() # 输出“释放连接”我刚开始学 close 有个疑惑为什么 close 之后还能看到 finally 里的输出因为 close 会在 yield 暂停点抛异常如果异常在生成器内部被捕获或 finally 处理掉它还是可以执行清理、打印日志最后才结束。由于 GeneratorExit 继承了 BaseException生成器内部不能轻易把它吞掉写except Exception是接不住它的。这一点在排查诡异行为时能回想起来会省很多时间。4. 实战场景三个能直接抄走的工程案例4.1 逐行分析大型日志统计错误行我最早遇到生成器就是在这个场景。现在慢慢理清了def read_lines(path): with open(path, encodingutf-8) as f: for line in f: yield line def filter_lines(lines, keyword): for line in lines: if keyword in line: yield line def count_lines(lines): count 0 for _ in lines: count 1 return count log_lines read_lines(service.log) error_lines filter_lines(log_lines, ERROR) print(count_lines(error_lines))这段其实不是最简写法但它展示了很关键的架构思想三段数据处理逻辑各管一段用生成器串起来就像流水线一样每段只需要处理当前一行内存恒定占一个行缓冲。要加新过滤条件就再插一个生成器或者把 filter_lines 扩展成支持多个关键词。实际工程里这个价值非常明显。我记得有次生产环境日志一天几十GB我把这种按行流式的处理接到监控脚本里不仅没崩还能在日志写入过程中实时统计错误量比之前的“定时全文扫一遍、再做内存聚合”稳太多。注意如果要多行上下文关联的日志分析比如异常堆栈跨好几行单纯按行扫描就会丢信息那需要写带状态的生成器来缓存上下文。4.2 分页拉取接口数据爬虫和 API 客户端爬虫最烦的就是分页。有些接口一页返回 20 条总共几百页如果一次性把所有页面请求完再解析网络卡一道内存又爆一道。用生成器做“分页游标”就很顺def fetch_all_pages(client, page_size50): page 1 while True: data client.get_page(page, page_size) if not data: return yield from data page 1只要 client 每次都返回一个可迭代的条目列表外层就可以这样消费for item in fetch_all_pages(twitter_client, page_size50): if should_stop(item): break save(item)因为是惰性的你可以处理前几十条就 break后面的页面根本不会请求。对依赖第三方接口的爬虫和客户端来说省流量也省时间。如果要限制最大页数可以在循环里加计数器超过某页就 return。如果你自己写数据接口也可以用类似思路把下一页游标封装成可迭代对象给下游使用。业务方不用关心翻页逻辑for 循环拿到的就是你处理好的下一批数据体验很好。4.3 数据库批量查询不把全表一次性塞进内存用 Python 连 MySQL 或 SQLite 查询大数据集时最怕cursor.execute(SELECT * FROM big_table)然后直接fetchall()。数据量一大客户端内存就成瓶颈。下面我用 fetchmany 分批取def fetch_batches(cursor, sql, batch_size1000): cursor.execute(sql) while True: rows cursor.fetchmany(batch_size) if not rows: break yield rows def fetch_rows(cursor, sql, batch_size1000): for batch in fetch_batches(cursor, sql, batch_size): for row in batch: yield rowfetch_batches 的粒度是批fetch_rows 的粒度是行。如果你要对一整批做批量插入直接消费 fetch_batches如果只需要逐行做转换和落盘就消费 fetch_rows。写成生成器后下游可以边取边入库数据库游标也不用一次性把结果集全拉回内存。生成器的影响范围就在这里不仅是文件读取凡是“数据源很大、处理速度跟不上数据本身规模”的场景它都能从内存占用、启动延迟、代码结构三个维度改善体验。很多数据分析脚本、批量导出任务的卡死就是因为多拉了一步 fetchall 或 readlines。4.4 做个简单的数据处理管道再看个综合例子读日志、清洗、提取字段、聚合。用多个生成器串联每级只做一件事。def clean_line(lines): for line in lines: line line.strip() if line and not line.startswith(#): yield line def split_fields(lines): for line in lines: yield line.split(,) def pick_errors(rows): for row in rows: if row and row[0] ERROR: yield row pipeline split_fields(clean_line(read_lines(app.log))) error_rows pick_errors(pipeline) for row in error_rows: print(row)每级职责单一、可单独测试、还能任意组合这就是生成器管道比一层套一层的 for 循环强的原因。写管道最怕的是各级之间格式耦合太紧我一般会明确每级输入输出都是“可迭代对象”并在函数 docstring 里写清楚产出元素的结构。数据流问题一旦标准化后面的扩展就是往管道里再塞一个处理节点不用推翻重写。5. 常见问题与排查技巧5.1 生成器只能遍历一次很多人在这里栽跟头我第一次踩这个坑是在验证爬虫结果时。代码里写了两处 for 遍历同一个生成器第一处打印数量第二处循环体一句话都没执行。原因很简单生成器被消费完就停了不会自己从头再来它没有“回卷”能力。这不是 bug而是设计。解决方案是重新调用生成器函数或者把它转成列表。如果你在写多遍遍历的判断逻辑最好在头脑里分清变量名对应的是列表还是生成器。用一个片段说明g (x for x in range(3)) print(list(g)) # [0, 1, 2] print(list(g)) # []我的习惯是如果一个数据流要被多次消费一定先list(generator)存一份如果只消费一次随手用生成器没有问题。别让“惰性求值”变成“摸鱼求值”让代码角落里的第二次遍历悄悄吞掉你的数据。5.2 空生成器不会立刻报错但有些操作会让人误判如果数据源本来就是空的比如数据库中查不到任何行next(g)会抛 StopIteration这是正常的。但如果你习惯性把它当列表用比如if g:也会得到 True因为生成器对象的布尔值永远是 True跟里面有没有数据无关。应对方法是想判断生成器有没有内容就试着取第一个元素。更好的做法是把这个判断放在业务逻辑里不要依赖空列表式的直觉。常见代码it iter_errors(empty.log) try: first next(it) except StopIteration: print(没有数据) else: for item in itertools.chain([first], it): process(item)记住先取第一个元素就是为了避免“空数据没有报错、后面却静默不处理”的错觉。5.3 send 和 throw 在并发场景里的坑生成器不是线程安全的。在两个线程里同时对一个生成器调用 next可能会处理混乱。虽然 CPython 的 GIL 限制了大部分操作不会真正并行崩溃但数据流被两个消费者交替消费业务上往往就会出错。如果有多个 worker 需要读一个共享数据流正确做法是每线程维护自己的迭代器或用队列对数据做预取再分配。另外生成器与递归配合时要小心。递归生成器如果层级很深会触发 Python 的递归深度限制导致 RecursionError。我碰到过同事用递归生成器去展开多叉树树深度一上去就崩。处理嵌套数据时我改用显式栈每层用一个循环维护待处理节点列表而不是靠递归一层层压栈。这一条算不上只针对生成器的通用建议但放在生成器场景里尤其致命。5.4 调试生成器的最佳姿势分步 next 和 list 快照生成器不像普通函数断点停在函数内部时你很难一眼看出当前推进到哪一行。我的调试经验是先用itertools.islice限制取前几个转成 list 打印或者直接一段段 next。比如g complex_generator(数据) print(next(g)) # 看第一个产出 print(next(g)) # 看第二个产出如果你怀疑某个生成器在某一步被卡住了就在它内部加一个 print或者用一个包装生成器打日志def debug_gen(g, label): for value in g: print(f{label} 产出: {value}) yield value这样你能直观看到传入和产出的数据是否符合预期不用在多个函数之间瞎猜。总结一句话生成器的调试核心是“一步一步看它到底产出了什么”而不是看它函数内部堆了什么局部变量。6. 把生成器真正装进你的工作流6.1 环境和版本上的现实约束这些天经常有人问我 Python 环境和编辑器配置的问题。至少从生成器这个功能看只要别还停留在 Python 2就不用太担心。yield 是 2.3 就有的老功能但 yield from 是 3.3 才落地f-string 和变量注解这一类特性是 3.6/3.7 之后才有的。用 VSCode 或 PyCharm 配置解释器时先确认项目里确实激活了对应虚拟环境命令行里python -c import sys; print(sys.version)能直接告诉你在哪个版本。很多生成器相关奇怪现象Debug 到最后发现是环境不一致尤其是yield from这种语法在老解释器里直接报 SyntaxError根本不是业务逻辑问题。除了版本还要提醒一句生成器表达式的代码写得很紧凑在工程评审里容易被人忽略它的执行时机。如果团队里有人不熟悉生成器建议在关键函数上多写一个短说明或者干脆用生成器函数把函数名和 docstring 当作文档谁都能一眼看懂这个流从哪里来、产出什么结构。这比单个 yield 表达式需要猜含义的情况好维护得多。6.2 关于使用尺度的一点个人体会做技术的容易陷入“为了优化而优化”的陷阱。早期我写代码看到循环就想套生成器结果一个非常简单的过滤逻辑被多层生成器串联可读性下降调试也变难。后来我给自己定下三条原则数据源小用列表数据源大但只会消费一次用生成器数据源大但逻辑复杂拆成多个职责单一的函数再对外提供生成器接口。这就是把生成器当“水管”而不是“发明创造”来用。现在再回头看第一次处理日志文件的场景我仍然觉得那是最生动的课堂。一个内存崩溃换来了对数据流处理方式的全新理解值了。你如果是在读文件、爬分页、拉大数据集的过程中遇到瓶颈不妨把自己代码里的列表推导式临时改成生成器表达式看看内存曲线下降的瞬间——那种感觉确实比刷几十节视频课来得实在。