ARTICLE DETAIL

资讯详情

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

Python生成器与yield:惰性求值解决大文件处理内存爆掉的工程实践

Python生成器与yield:惰性求值解决大文件处理内存爆掉的工程实践 写这篇东西的起因很简单有次帮同事排查线上任务脚本读一个2.3GB的Nginx日志做IP统计代码写得很“正常”——先用readlines()全量读进内存再split切字段结果机器内存直接被撑爆Swap飙满最后OOM被kill。同事一脸无奈这代码我看网上教程都是这么写的啊。我问他你处理的是2GB文件人家教程处理的是2KB测试文件能一样吗后来我给他换成逐行迭代逻辑一行没变内存占用从2.3GB降到几十MB问题秒解。这个案例几乎完美诠释了Python生成器Generator的威力——它不是什么炫技特性而是处理数据时绕不开的工程素养。这篇文章我想把生成器和yield关键字彻底讲透。不止讲语法层面怎么用更重要的是讲清楚它背后的惰性求值思路、在实际工程里的典型应用场景以及我在多年写Python过程中踩过的那些坑。文章面向已经掌握Python基础语法、想进阶提升的开发者理解完你会对“Pythonic”这三个字有新的感受。1. 生成器到底解决了什么问题1.1 先看一个让内存爆掉的例子很多人第一次接触生成器都跟我一样是在看某本Python入门书时翻到的。书上说“生成器可以节省内存”然后给了一个大差不差的例子就带过了。但真正触发我深度理解的是开头说的那个线上事故。传统写法是这样的# 非生成器写法一次性把全部数据加载到内存 def load_log_lines(file_path): with open(file_path, r, encodingutf-8) as f: lines f.readlines() # 2.3GB文件这里内存直接爆掉 return lines def count_ip(log_lines): from collections import Counter counter Counter() for line in log_lines: ip line.split()[0] counter[ip] 1 return counter all_lines load_log_lines(access.log) ip_counter count_ip(all_lines)问题出在readlines()这一步。这个方法会把文件里所有行读取出来组装成一个巨型列表每一行作为一个元素。2.3GB的文件加上Python字符串对象的内存开销实际占用可能达到6-8GB内存不够就直接触发OOM。改成生成器版本之后# 生成器写法逐行读取用完即弃 def load_log_lines(file_path): with open(file_path, r, encodingutf-8) as f: for line in f: # 文件对象本身就是惰性迭代的 yield line def count_ip(log_lines): from collections import Counter counter Counter() for line in log_lines: ip line.split()[0] counter[ip] 1 return counter all_lines load_log_lines(access.log) ip_counter count_ip(all_lines)核心改动只有一行把readlines()换成for line in f函数里加了一个yield。但内存表现完全是两个量级。这段经历让我意识到生成器不是语法糖它是处理大规模数据时的必备工具。1.2 生成器的核心机制yield是一个暂停键要理解生成器首先得理解yield这个关键字。网上很多教程说“yield相当于return”这个说法不准确它俩只是长得像而已。return的含义是“这个函数执行完了把结果交给你然后所有局部变量统统销毁”。而yield的含义是“先给你一个值函数暂停在这里所有局部变量的状态我都保留着等你下次叫我我从暂停的位置继续往下执行”。我习惯用“做菜”来类比普通函数像一家做好十道菜再上桌的餐厅。客人点单后后厨把十道菜全部做完一次性端上来。如果客人只吃了一道就饱了剩下九道也是白做。生成器像回转寿司。三文鱼寿司做好一个放到传送带上转一圈客人拿走了厨房再做下一个。客人不拿厨房就不做永远不多做。按这个理解yield就像回转寿司师傅手里的“暂停键”——做好一盘送出去然后师傅歇着等信号客人拿走了再开始做下一盘。代码层面这个“暂停”意味着什么看一个最小示例def demo_generator(): print(第一次进入函数) yield 1 print(第二次调用后恢复执行) yield 2 print(第三次调用后恢复执行) yield 3 gen demo_generator() print(next(gen)) # 输出: 第一次进入函数 / 1 print(next(gen)) # 输出: 第二次调用后恢复执行 / 2 print(next(gen)) # 输出: 第三次调用后恢复执行 / 3你仔细看输出顺序就会明白生成器函数体里的print不是一次性全部输出的而是每次调用next(gen)才执行一段。两次yield之间的代码在下一次调用时才运行。这就是“暂停”的直观感受。更重要的是每次暂停时函数内部所有的局部变量都还活着。这个特性价值极大后面讲无限序列时你会看到它如何改变编程思路。1.3 可迭代对象 vs 迭代器 vs 生成器别再混为一谈这三个概念在Python社区里经常被混用但严格来说它们不是一回事。搞清楚了很多语法层面的困惑都会迎刃而解。可迭代对象Iterable凡是能用for...in...遍历的对象都是可迭代对象。列表、元组、字典、字符串、文件对象、生成器都是可迭代对象。它需要实现__iter__()方法返回一个迭代器。迭代器Iterator实现了__next__()方法的对象每次调用它都会返回下一个值。迭代器自己也是可迭代的它实现了__iter__()返回自己。我们常说的“迭代器协议”就是指这两个方法。生成器Generator生成器是迭代器的一种是由生成器函数含yield关键字或生成器表达式创建的特殊迭代器。它有迭代器的所有能力但还额外实现了send()、throw()、close()等方法能做更复杂的协程操作。用一个判断代码来区分from collections.abc import Iterable, Iterator import inspect my_list [1, 2, 3] # 列表 my_iter iter(my_list) # 列表迭代器 def gen_func(): yield 1 my_gen gen_func() # 生成器 print(isinstance(my_list, Iterable)) # True列表是可迭代对象 print(isinstance(my_list, Iterator)) # False但列表本身不是迭代器 print(isinstance(my_iter, Iterator)) # True迭代器是迭代器 print(isinstance(my_gen, Generator)) # True需要from types import GeneratorType实战里最常遇到的一个误解是所有可迭代对象都能反复遍历。列表可以遍历两次、三次、无数次但生成器只能遍历一次。第一遍遍历完生成器就“耗尽”了。这是生成器与列表最本质的区别也是新手踩坑重灾区。2. 惰性求值用的时候才算而不是提前算好2.1 惰性求值的核心思想生产者不提前备货“惰性求值”Lazy Evaluation这个词看起来高大上其实思想特别朴素一个值在你真正用到它之前不去计算它。对比一下两个版本的平方数# 饿狼式立即计算全部存内存 squares_eager [x * x for x in range(10)] # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] # 惰性式先给你一个“凭证”需要时再算 squares_lazy (x * x for x in range(10)) # generator object genexpr at 0x...[x * x for x in range(10)]是列表推导式执行完立刻生成一个完整的列表对象内存里就有10个整数。而(x * x for x in range(10))是生成器表达式执行完只是创建了一个生成器对象此刻一个平方数都还没算出来。你把next(squares_lazy)调用一次才算出一个数。如果range(10)换成range(100000000)一亿列表推导式会立刻让你的内存告急而生成器表达式创建时零压力。惰性求值的核心好处有三个内存可控。处理多大体积的数据不取决于数据本身大小只取决于你同时保留多少数据在内存里。想象一条流水线每一环只管自己手头那一个零件而不是把整个仓库的零件都堆在面前。可能省时。如果只需要前3个结果惰性求值只在拿到3个结果时就停下来了后面的计算全部不用做。而立即求值要把全部结果都算出来。支持无限数据。只有惰性求值才能表达“自然数序列这种永远算不完的数据结构”。第二个和第三个好处值得展开说说。可能省时这一点很多人忽略。举例你想从一组URL里找第一个响应状态码为200的链接。如果提前把所有URL都请求一遍假设1000个URL你全部请求完才能筛选如果用生成器请求第一个发现不是200继续请求第二个直到遇上200就停后面的999个根本不用碰。计算量从“全部”变成了“直到满足条件为止”。支持无限数据这一点更关键。列表语义上要求所有元素必须真实存在所以“所有自然数”是不可能用列表表达的。但生成器不必它可以这样写def natural_numbers(): n 0 while True: yield n n 1 nums natural_numbers() print(next(nums)) # 0 print(next(nums)) # 1 print(next(nums)) # 2函数里虽然有while True但因为每次yield都会暂停所以不会死循环。你调用一次next它才生成一个数。这里面没有魔法就是“生产者和消费者之间的默契”消费者要多少生产者才产多少。2.2 next()与for循环背后发生了什么之前提到生成器是迭代器所以它遵循迭代器协议。这个协议的核心就是__next__()方法。当你执行for item in gen:时Python内部做的事拆开来看是这样的# for循环的本质 iterator iter(gen) # 1. 获取迭代器生成器自身就是迭代器 while True: try: item next(iterator) # 2. 反复调用 next() 获取下一个值 except StopIteration: # 3. 遇到 StopIteration 就结束循环 break # 这里是对 item 的处理逻辑for循环是next()StopIteration异常的组合语法糖。这也解释了为什么生成器遍历“有穷”数据时能准确知道什么时候该停下——不是靠数元素个数而是靠那个特殊的StopIteration异常。手动驱动生成器的场景其实不少。比如你想从一大批数据中“跳着”取取第1个、第3个、第5个。gen (x * 2 for x in range(50)) # 0, 2, 4, 6, 8, ... first next(gen) # 0 second next(gen) # 2 third next(gen) # 4也可以配合itertools.islice做切片式的惰性抽取import itertools gen (x * x for x in range(1000)) # 取第100到110个元素 selected itertools.islice(gen, 100, 111) for value in selected: print(value)2.3 生成器表达式轻量版的惰性工具生成器表达式Generator Expression是列表推导式的惰性版本语法上只是把方括号换成圆括号。但它的行为模式和列表推导式截然不同。# 列表推导式立即执行立即出结果 list_comp [x * x for x in range(5)] print(type(list_comp)) # class list print(list_comp) # [0, 1, 4, 9, 16] # 生成器表达式延迟执行只创建对象 gen_exp (x * x for x in range(5)) print(type(gen_exp)) # class generator print(gen_exp) # generator object genexpr at 0x...生成器表达式的延迟特性在某些场合特别有用。举个例子你要判断一个大范围里是否存在满足条件的数# 普通方式先构造5千万个元素再判断内存直接爆炸 values [i for i in range(50_000_000)] print(100 in values) # 惰性方式逐个判断找到就停 values_lazy (i for i in range(50_000_000)) print(100 in values_lazy)第二个版本执行时in运算符会逐个调用next()到100就返回True整个生成器到此为止后面的几千万个数字一个都没生成。这就是“可能省时”的典型场景。不过有一点要注意生成器表达式适合“一次性消费”的场景。你要是写sum(x * x for x in range(10))这种没问题sum遍历一遍就完事了。但如果你把生成器表达式赋给变量然后打算遍历两遍第一遍能拿到值第二遍就是空的了因为生成器已经耗尽了。3. yield的进阶玩法3.1 用调试视角看yield的执行流程很多教程讲yield的执行流程都是一句话带过“函数遇到yield就暂停”。但我个人觉得真正动手打印一遍远比看十遍文字描述管用。你可以这样验证def trace_generator(): print(A: 生成器启动) value1 yield 1 print(fB: 收到了 {value1}继续往下) value2 yield 2 print(fC: 收到了 {value2}继续往下) value3 yield 3 print(fD: 收到了 {value3}到这里结束了) gen trace_generator() print(----- 第一次 next -----) r1 next(gen) print(f外部拿到: {r1}) print(----- 第二次 next -----) r2 next(gen) print(f外部拿到: {r2}) print(----- 第三次 next -----) r3 next(gen) print(f外部拿到: {r3})运行后你会看到执行顺序是“外部打印”和“函数内print”交替出现的。第一次调用next(gen)时函数执行到value1 yield 1把1交给外部然后冻结在这里。外部拿到1之后你再调第二次next(gen)函数才从value1 yield 1这一行继续往下执行到下一个yield。注意一个细节第一次yield 1表达式的返回值value1在“第一次next”时还拿不到——因为函数在赋值之前就暂停了。只有当你调用send()往里传值时这个赋值才完成。这就是下一小节要说的内容。3.2 send()往暂停的函数里递纸条yield表达式不仅能向外“吐”数据还能向内“收”数据。这是它和return最大的不同点return是函数向外部单向传递结果而yield是双向通道。send()方法允许你从外部给这个暂停的生成器传递一个值这个值会成为当前yield表达式的结果。看一个经典例子——累加器def accumulator(): total 0 while True: value yield total # 先抛出当前累计值再接收一个外部值 if value is None: # 避免第一次用 next() 启动时报错 continue total value acc accumulator() print(next(acc)) # 首次启动必须先用 next() 或 send(None)输出 0 print(acc.send(10)) # 累计 10输出 10 print(acc.send(20)) # 累计 30输出 30 print(acc.send(5)) # 累计 35输出 35运行机制拆解第一个next(acc)让生成器执行到value yield total这一行把total 0抛出来然后暂停。此时value还没有被赋值。第一次acc.send(10)把10送进暂停处赋给value然后继续循环total变成10再遇到yield total把10抛出来。后面每一步都是重复这个“送入一个值算出一个新结果”的过程。send()能干什么呢最典型的用途是给生成器动态调整行为。比如你在写一个数据处理管线遇到特殊数据时想改变后续处理策略或者写一个协程基础原型用send()在单线程内实现“消息传递”。Python官方的协程模型本质上就是从生成器的send()机制演化出来的这也是为什么生成器被称为“协程的雏形”。实用提醒新创建的生成器第一次调用应该是next(gen)或者gen.send(None)——直接send一个非None值会报TypeError: cant send non-None value to a just-started generator因为生成器还没有执行到第一个yield没有“接收点”。3.3 yield from生成器的委托与组合yield from是Python 3.3引入的语法用来在一个生成器里“委托”另一个生成器或可迭代对象。它简化了嵌套生成器的遍历逻辑。看一个对比例子。假设你有一个子生成器想在外层生成器里逐个产出def sub_gen(): yield 1 yield 2 yield 3 # 老写法手动遍历子生成器 def outer_gen_old(): for item in sub_gen(): yield item yield 999 # 新写法yield from 直接委托 def outer_gen_new(): yield from sub_gen() yield 999 print(list(outer_gen_new())) # [1, 2, 3, 999]yield from sub_gen()的效果是把sub_gen()里所有的元素按顺序“透传”给外层调用者直到子生成器耗尽然后继续执行外层生成器剩余的代码。yield from的真正价值在于简化“生成器嵌套”时的样板代码。常见场景是你有一个多层结构的数据比如目录树、嵌套列表需要写递归式生成器来扁平化遍历。这个时候yield from能把代码写得特别干净def flatten(nested_list): for element in nested_list: if isinstance(element, list): yield from flatten(element) # 递归委托 else: yield element nested [1, [2, [3, 4], 5], 6, [7, 8]] print(list(flatten(nested))) # [1, 2, 3, 4, 5, 6, 7, 8]如果没有yield from你只能写for sub_item in flatten(element): yield sub_item多了两层缩进可读性差很多。3.4 手动实现一个迭代器的完整对照上面讲了函数式创建生成器的路子现在换个角度不用yield纯手写一个迭代器。这能帮你理解生成器到底节省了多少样板代码。from collections.abc import Iterator class CountDown(Iterator): 手写迭代器从 n 倒数到 0 def __init__(self, n): self.n n def __next__(self): if self.n 0: raise StopIteration # 耗尽信号 result self.n self.n - 1 return result # 等价于用生成器函数写 def countdown(n): while n 0: yield n n - 1 # 手写版本使用方式相同 for i in CountDown(3): print(i) # 3, 2, 1, 0对比两个版本手写版本需要自己维护状态self.n、自己判断结束条件、自己手动raise StopIteration。而生成器版本把这些全部隐藏了——局部变量自动保留函数末尾自动抛StopIteration。这就是为什么实战里几乎没人手写迭代器直接用yield就行。但这部分并不是白讲的。深入理解迭代器协议的底层机制你在排查那些“为什么我的生成器不产出值”“为什么它提前停了”之类的问题时能更快定位到根因。4. 实战场景用生成器优雅解决真实问题4.1 大文件流式处理内存占用从“文件大小”降到“常量”这是生成器最经典的应用场景也是开篇那个线上事故的解法。处理的思路是永远不要用read()或readlines()把整个文件加载进内存而是让文件对象自己作为迭代器逐行产出。def process_large_file(file_path): with open(file_path, r, encodingutf-8) as f: for line in f: # 逐行迭代每行用完即释放 # 这里只处理当前这一行不保留历史行 fields line.strip().split(,) if len(fields) 5: yield fields[0], fields[4] # 使用示例统计每个用户的访问次数 from collections import Counter counter Counter() for user_id, action in process_large_file(user_actions.csv): counter[user_id] 1 print(counter.most_common(10))关键点在于for line in f这个写法本身就走的是惰性迭代文件对象内部有缓冲机制每次只从磁盘读出一部分内容处理完一行就丢掉一行。内存里同时存在的最多只有当前这一行字符串和文件缓冲的那一小块数据。无论文件是2MB还是2GB内存占用都稳定在一个常量级别。如果你需要处理多文件可以把文件路径列表也做成惰性的配合itertools.chain串联import itertools def multi_file_lines(file_paths): 依次产出多个文件的所有行 for path in file_paths: with open(path, r, encodingutf-8) as f: yield from f log_files [access.log.1, access.log.2, access.log.3] for line in multi_file_lines(log_files): # 对每一行做分析 pass4.2 无限序列惰性求值最没有对手的场景无限序列是生成器相对列表拥有绝对优势的场景。任何“不知道终点在哪”的数据集列表都表达不了只有生成器能优雅表达。最经典的例子是斐波那契数列。斐波那契的定义天然是“递推式”的你永远不知道要算到第几项才停所以无限生成器是最自然的表达def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b fib fibonacci() # 取前10个 for _, value in zip(range(10), fib): # 用zip截断 print(value, end ) # 输出: 0 1 1 2 3 5 8 13 21 34如果你想知道第100个斐波那契数是多少不用把所有99个全存下来fib fibonacci() for _ in range(100): current next(fib) print(current)注意这里fib已经消耗了100次生成器内部状态就是“当前位置”不保留历史。除了数学序列很多工程场景也用得到无限生成器。比如生成测试用的一次性IDdef generate_test_id(prefixT): counter 1 while True: yield f{prefix}-{counter:06d} counter 1 id_gen generate_test_id(TEST) print(next(id_gen)) # TEST-000001 print(next(id_gen)) # TEST-0000024.3 数据处理管线把几个生成器串起来用生成器最强大也最容易被忽视的用法是把多个生成器串成一条流水线数据像水流一样从一端流到另一端每个环节只处理自己关心的那一步。这是函数式编程里“管道”思想的Python实现。它的优势在于中间环节不落盘、不暂存内存占用跟流水线长度无关只跟单个数据项大小有关。我举个真实的场景处理一坨原始日志需要清洗无效行 → 提取IP和状态码 → 按IP分组计数。import re from collections import Counter # 环节1原始日志逐行产出 def read_log(file_path): with open(file_path, r, encodingutf-8) as f: for line in f: yield line # 环节2清洗提取字段 def parse_log(lines): pattern re.compile(r^(\S) .* (\d{3}) (.*?)) for line in lines: match pattern.match(line) if match: # 匹配失败说明是脏数据直接丢弃 ip, code, ua match.groups() yield ip, code, ua # 环节3统计 def count_by_ip(parsed_lines): counter Counter() for ip, code, ua in parsed_lines: counter[ip] 1 return counter raw_lines read_log(access.log) parsed parse_log(raw_lines) result count_by_ip(parsed) print(result.most_common(5))每条生成器只依赖上一条生成器的“产出”三者之间没有中间列表。read_log产出一条原始日志行parse_log马上处理它count_by_ip立刻统计它。处理完就丢。如果处理逻辑更复杂你甚至可以给管线加“分支”用itertools.tee复制流给多个消费者import itertools from collections import Counter raw_lines read_log(access.log) parsed parse_log(raw_lines) # 分流成两份一份统计IP一份统计状态码 ip_flow, code_flow itertools.tee(parsed, 2) ip_counter Counter() for ip, code, ua in ip_flow: ip_counter[ip] 1 code_counter Counter() for ip, code, ua in code_flow: code_counter[code] 1itertools.tee的实现也是基于生成器和缓存队列的它本身不会把全部数据加载进内存只缓存两个分支消费速度差造成的那部分积压数据。4.4 其他高频使用场景备忘除了上面三大场景有几个日常高频使用生成器的地方值得单独列出来无限分页爬取。假设有个API是分页返回数据的你不知道一共多少页且有些分页可能没数据。用生成器天然适合def fetch_all_pages(api_url): page 1 while True: data request_get(f{api_url}?page{page}) if not data[items]: # 没有更多数据了 break for item in data[items]: yield item page 1 for item in fetch_all_pages(https://api.example.com/list): process(item)观察者模式的简易版“事件流”。你可以在一个while循环里持续产出系统状态快照消费方按需决定何时停止。实现map_filter_reduce的惰性组合。Python的map()和filter()本身就返回迭代器配合生成器表达式可以写出非常RStyle的链式惰性处理values range(10000000) chain (x * 2 for x in values if x % 3 0) result sum(itertools.islice(chain, 10))4.5 生成器 vs 列表什么时候别用生成器讲了很多生成器的优势但别把它当成万能药。生成器是有缺点的主要缺点有两个不能随机访问。生成器没有索引你无法直接gen[5]拿第6个元素。一旦数据需要反复读取、跳跃访问、根据索引定位列表更合适。只能遍历一次。生成器是一次性的遍历完就空了。如果同一份数据你打算遍历三遍做不同统计要么重新创建生成器要么就用列表把数据“固化”下来。调试困难。生成器内部状态被“冻结”网上的调试工具打印局部变量时往往只能看到当前帧回溯信息也不如普通函数直观。数据量小的时候优先用列表让代码更简单。最好的策略是数据量大、逐条处理、不需要回溯时用生成器数据量小、需要多次访问时用列表。用列表推导式还是生成器表达式看的是数据规模和处理模式不是“哪个更高级”。5. 常见问题与排查技巧实录5.1 生成器只能遍历一次这是新手问得最多的问题。生成器遍历一次后就“耗尽”了再拿它for循环什么都不会输出。gen (x * x for x in range(5)) print(list(gen)) # [0, 1, 4, 9, 16] print(list(gen)) # []第二个list(gen)为什么是空的因为第一次list(gen)已经把生成器从头到尾消费干净了内部指针走到了末尾再调next()就直接抛StopIteration。排查思路如果你发现自己“第二次遍历生成器时没数据”先检查这个对象是不是生成器。如果是赶紧重新创建一份def build_gen(): return (x * x for x in range(5)) gen1 build_gen() print(list(gen1)) # 第一次有 gen2 build_gen() print(list(gen2)) # 重新创建的没问题注意for循环遍历生成器和list()转成列表这两种操作都会消耗生成器。所以不要把生成器变量传来传去传出多个消费者除非你清楚知道每个消费者只能消费它“自己那份”。5.2 StopIteration的隐与显生成器耗尽后next()会抛StopIteration。大多数情况下你不需要手动捕获它因为for循环和list()都会自动处理。但有一种场景需要你小心# 错误示范不检查生成器是否为空就直接 next() def find_first(gen): return next(gen) # 如果 gen 为空这里直接抛 StopIteration # 正确示范提供默认值 def find_first(gen): return next(gen, None) # 为空时返回 Nonenext(gen, default)的第二个参数会在生成器耗尽时代替抛异常。这个API在写“取第一个满足条件的元素”时非常实用。5.3 误把生成器当列表len()用不了的坑len()不能作用在生成器上因为生成器不知道自己的长度——你又没让它把所有数据都生成一遍它当然数不出来。gen (x for x in range(10)) len(gen) # TypeError: object of type generator has no len()如果你真的要知道某个生成器产生多少个元素有两种办法用sum(1 for _ in gen)统计但这样会把生成器耗尽。如果后续还要用数据先items list(gen)再len(items)代价是内存占用回到列表级别。我个人的经验是如果你经常需要len()、索引、切片这些“列表专属操作”可能一开始就不该用生成器列表推导式更合适。5.4 在生成器里混用return和yield的坑生成器函数里可以有return但它的作用不是返回一个结果而是表示“这个生成器到此结束”相当于隐式地抛StopIteration。如果你return了一个值这个值会放在StopIteration异常的value属性里但for循环取不出来def gen_with_return(): yield 1 return done # 这个值不会出现在遍历结果中 g gen_with_return() print(next(g)) # 1 try: next(g) except StopIteration as e: print(e.value) # done藏在异常里所以不要在生成器里尝试“return一个结果值”来结束生成器。如果你需要返回值要么yield所有数据要么不用生成器用普通函数返回列表。5.5 常见疑难速查表症状可能原因解决办法第二次遍历生成器没有数据生成器已经耗尽重新创建生成器对象next()抛StopIteration生成器已无元素用next(gen, default)提供默认值len(gen)报TypeError生成器不支持len转list()或改用sum(1 for _ in gen)send()报TypeError首次调用就传了非None值先用next(gen)或send(None)启动函数里加yield后代码不执行误以为调用函数就执行函数体生成器函数调用只创建对象需next()驱动生成器嵌套太深可读性差手动for循环嵌套yield改用yield from委托用了tee后发现内存暴涨两个分支消费速度差异过大控制消费节奏或调整批量大小5.6 一段引以为戒的糟糕实践经验最后说一个我自己的反面教材。有段时间我处理一批订单数据需要按时间区间做多次聚合统计。我图省事只创建了一个生成器然后连续调了三次聚合函数。第二次、第三次聚合结果永远是0排查了半小时才意识到生成器已经被第一个聚合消耗完了。那次的教训总结成一句话就是生成器适合“一次遍历逐条处理”的场景同一个数据源需要反复处理时别贪图省内存该转list就转list。6. 一点个人心得Python的生成器是一个把“思想”落到“语法”层面的特性。它本质上不是“节省内存的语法糖”而是提供了一种描述“流动数据”的思维框架——数据不是一堆静态存在的值而是一个按需涌现的序列。学会了它你写出来的代码会更自然地应对“不知道数据有多大”“不知道数据有没有尽头”这类实际问题。用一句话来概括的话列表是“仓库”生成器是“流水线”。仓库的容量受场地限制流水线的产能取决于传送带速度。工程里你应该根据数据特性在“仓库”和“流水线”之间做取舍——这也正是每一个Python开发者走向成熟的必经之路。
返回列表