1. 先搞清楚yield到底干了什么
1.1 一个把内存吃光的例子
写Python写久了,你会发现一个很有意思的规律:凡是能用列表拿走的东西,迟早会在内存上还回来。我最早接触yield,就是因为一段处理日志的脚本把服务器内存直接打满了。
当时的需求很简单,统计一个2GB的访问日志文件里每个接口被调用了多少次。第一版代码特别“直觉”:
lines = open("access.log", encoding="utf-8").readlines()就这一行,服务器内存蹭蹭往上涨,然后进程被系统杀掉。原因不难理解:readlines()会把整个文件的所有行塞进一个列表,2GB的日志文件变成内存里的列表之后,内存占用远远超过2GB。后来改成用for line in open(...)循环逐行读,问题立刻消失了。文件对象本身就是一个迭代器,它会按行惰性地吐数据。但如果你需要在这条数据流上做更复杂的处理,比如过滤、转换、汇总、多段组合,yield就派上用场了。
1.2 yield其实是在“存档”
很多人第一次看生成器代码会觉得别扭,因为函数执行顺序看起来“断断续续”。拿一个最简单的例子来说:
def count_up_to(n): i = 0 while i < n: yield i i += 1调用count_up_to(3)的时候,函数体其实一行都没执行,它只是返回了一个生成器对象。直到你调用next(),函数才开始跑。第一次next(gen),函数执行到yield i,把0交出来,然后整个函数停在那一行,状态全部被保存——包括i等于几、下一步该执行哪条指令。第二次next(gen),函数从刚才停下的位置继续,跑完i += 1,回到循环头部,又一次执行到yield i,把1交出来。这个过程可以一直重复下去。
可以把它理解为游戏存档:打到某个关卡,保存当前进度,交一关给外部,下次接着打的时候从存档点继续,而不是从头再来。普通函数用return是“打完收工,进程结束”,生成器用yield是“暂停一下,我先把当前结果给你,你用完叫我,我再接着算”。这两种语义的差别,是所有生成器应用的基础。
用next()手动驱动比较啰嗦,所以日常更常见的做法是直接for i in count_up_to(3)。for循环内部会自动不断调用next(),直到捕获到StopIteration异常才知道“数据吐完了,到此为止”。
1.3 惰性求值:不到最后不真正算
真正理解生成器,绕不开“惰性求值”这四个字。什么概念?就是表达式不会在创建的时候立刻计算,而是等到你真正需要结果的那一刻才去算。生活化一点说:自助餐厅不会一次性把一年后的菜都做好囤着,而是你拿一个盘子,它炒一个菜给你。
对比一下:
squares_list = [x * x for x in range(1000000)] # 立刻算出100万个平方数,放入内存 squares_gen = (x * x for x in range(1000000)) # 先记着怎么算,一个数都还没算第一行会先把100万个整数全部算出来,存进列表。第二行只是创建了一个生成器表达式,它保存了“算法公式”,但还没有执行。真正触发计算的是后面遍历它的时候。一次只算一个,算完丢给你,自己不留任何历史包袱。
这种“算得慢、用得快”的模式,在处理大文件、无限序列、数据流、管道式处理时,几乎是不可替代的。你不需要为了一个暂时用不上的中间结果,提前付出时间和内存成本。
2. 惰性求值的三个实战场景
2.1 大文件逐行处理:10GB日志不用读进内存
回到日志处理的场景。改用生成器之后,代码变成了这样:
def read_log_lines(path): with open(path, "r", encoding="utf-8") as f: for line in f: yield line def parse_access_log(rows): for row in rows: parts = row.split(" ") yield { "ip": parts[0], "path": parts[6], "status": parts[8], }外层可以这样组合:
for entry in parse_access_log(read_log_lines("access.log")): print(entry["path"], entry["status"])注意整个链条里,任何时候内存里只存在一行日志。read_log_lines从文件里吐出一行,parse_access_log接住这一行,解析成一个字典交给外层打印,然后继续下一行。就算文件有10GB,内存占用也基本恒定在几MB的量级,不会随着文件变大而暴涨。
这也是生成器特别适合做ETL的原因之一。数据从源头到出口,是一条流动的管道,而不是一堆拷来拷去的快照。如果你用列表理解来做同样的事,中间会生成一个10GB的解析结果列表,那还不如暴力readlines()。
2.2 无限序列与实时数据流:生成器天生适合“永不停歇”
普通列表没法表达“无限”的概念,因为它的长度必须是有限的。但生成器可以——它只在每次next()的时候算出一个值,只要你不主动停,它就能一直吐。经典的斐波那契例子:
def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b注意里面是一个while True无限循环。如果这段逻辑写在一个普通函数里,函数会永远跑不完,直接死循环。但放在生成器里,它每执行到yield a就暂停,把当前斐波那契数交出来等下一次召唤。你可以用islice切出前20个,也可以用于实时行情、传感器数据、股价变化流等无限推送的场景。
有些同学会在量化策略里碰到类似需求:实时行情是一条永不停止的数据流,策略需要每来一个新价格就算一次指标。如果用列表保存所有历史价格,内存迟早撑不住,而且每次算指标都要把全量数据重新扫一遍。换成生成器思路,你可以维护一个滑动窗口,数据一边进来一边算,窗口外的东西直接被丢弃。
2.3 数据处理流水线:让每一条数据“游”过全程
生成器最常见的组合玩法,是把多个生成器串成一条流水线。每个生成器只做一件事,数据经过它的时候被过滤、被变形,然后继续往下游走。
比如我们要从一堆日志里找出所有404请求对应的IP:
def filter_status(rows, target): for row in rows: if row["status"] == target: yield row def extract_ip(rows): for row in rows: yield row["ip"]串起来写:
ips = extract_ip(filter_status(parse_access_log(read_log_lines("access.log")), "404"))数据在管道里是一行一行走的:第一行日志从文件里读出,解析成字典,被过滤,提取IP,返回给调用方;处理完第一行,再处理第二行。这非常像Unix的管道命令cat xxx | grep 404 | awk '{print $1}',前一个命令的输出直接喂给后一个命令,没有任何中间整表落盘。
如果一切正常,这段代码可以跑很长时间都不卡死。但有一类隐藏问题:如果管道中某个环节偷懒,从生成器里只取了一部分数据就半途停止后续处理,上游数据没人继续追问,也就不会继续生成。这时候如果上游是文件句柄,文件就不会正常读到结尾。我在第五节会专门讲这个问题,操作不当会导致文件没关、资源泄漏。
3. 深入生成器协议:send、close、yield from
3.1 生成器不只是“一只单向的管子”
很多人以为生成器就是“只能往外吐值”,其实它的协议远不止单向。Python的生成器对象自带四个方法:next()、send()、throw()、close()。其中send()是逆向通道,允许调用方向生成器内部传数据。生成器可以在yield的位置接收这个数据,并根据它改变后续行为。
看一个例子,这个生成器会累加每次传入的值:
def accumulator(): total = 0 while True: value = yield total total += value gen = accumulator() print(next(gen)) # 启动生成器,执行到第一个yield,返回0 print(gen.send(10)) # 把10传给yield,total变成10,下一次循环yield返回10 print(gen.send(5)) # 把5传给yield,total变成15,下一次循环yield返回15第一次调用不能用send(),必须先用next()让生成器运行到第一个yield挂起。如果你新手直接上gen.send(10),Python会报TypeError: can't send non-None value to a just-started generator。
send()最常见的用途是协程雏形。在async/await成熟之前,Python有一段时期就是靠生成器和send()实现协作式多任务的。现在写异步代码虽然几乎不用手写生成器了,但很多第三方库内部依然依赖这套协议。理解它,对阅读源码和排查“为什么我这个生成器没法接收外部输入”这类问题很有帮助。
3.2 用throw和close做资源清理
生成器挂起的时候,你可以从外部向它抛一个异常进去:
gen.throw(ValueError("数据异常"))如果生成器内部没有捕获这个异常,它会在yield挂起的位置把这个异常抛出来,然后生成器直接停止。这用于中断正在进行的生成流程比较有效,比如管道中游发现上游数据格式不对,不想再继续处理了,可以主动throw进去让生成器退出。
close()则是温和一点的关闭方式,它在生成器内部相当于在yield挂起点抛出一个GeneratorExit异常。如果生成器里有finally块,此时会执行到。这个特性可以用来保证资源释放:
def worker(): try: while True: yield finally: print("生成器被关闭,释放资源") gen = worker() next(gen) gen.close() # 输出:生成器被关闭,释放资源很多线上问题都出在“生成器被部分消费后,没人再继续迭代它”,导致finally迟迟不执行、文件句柄一直开着。合理调用close(),或者在with块里包裹生成器生命周期,能让资源管理干净很多。
3.3 yield from:优雅地委托给子生成器
当你在一个生成器里希望“把另一个生成器或可迭代对象的所有值都吐出去”时,朴素写法是:
def chain(*iters): for it in iters: for v in it: yield vyield from能把这个写法压缩得更直观:
def chain(*iters): for it in iters: yield from it它做的事情不止是“遍历子生成器”,它还会把子生成器内部的yield直接对接给外层调用方。双向通道都能走通。甚至当子生成器用return返回一个值时,这个值会被放进StopIteration异常里,外层可以用value = yield from sub_gen捕获。
yield from让生成器有了“组合子生成器”的能力。你可以把一个大任务拆成多个子生成器,再用一个父生成器把它们串起来,代码可读性提升一个台阶。管道式的写法在第三章就展示过了,实际项目里把chain和groupby配合使用,能处理不少棘手的嵌套数据。
4. 生成器表达式与高频踩坑
4.1 一行代码的取舍:生成器表达式 vs 列表推导式
生成器表达式长这样(x * 2 for x in range(100)),和列表推导式只差一个中括号vs圆括号。这一个小括号的差异,背后是完全不同的内存策略。
我做过一个粗测,[x * 2 for x in range(1000000)]内存占用大约80MB左右(不同系统和Python版本有浮动),而等价生成器表达式(x * 2 for x in range(1000000))内存占用只有一两百字节。空间省了几个数量级,代价是你要for循环去逐个取值,无法像列表那样按下标随机访问或反复遍历。
所以取舍标准很简单:如果只需要一次性遍历完,用生成器表达式;如果需要多次使用、随机访问、求长度、切片这类“名单式”操作,老老实实用列表。生成器里没有len()的概念,因为它根本没把全部数据存起来,也回答不了“你一共有多长”这个问题。
4.2 坑一:生成器只能遍历一次
这是我见过最多人踩的坑,也是最容易踩的。有人写出了这样的代码:
gen = (x * x for x in range(5)) print(list(gen)) # [0, 1, 4, 9, 16] print(list(gen)) # []第二次变成空列表了。因为生成器是“一次性消耗品”,它已经从头到尾吐完了,内部指针走到末尾,再次遍历什么都不会给。这不是bug,这就是惰性求值的自然结果:它不保存历史值,吐一个丢一个。
要绕过这个问题,要么用列表把结果固定住,要么重新创建生成器。很多业务代码里因为没注意这一点,第二次遍历时得到空白数据,查半天才发现生成器早已“燃烧殆尽”。调试方法也简单:看到某个变量明明是生成器但第二次用变成空,先怀疑是不是已经被遍历过。
4.3 坑二:外部变量在迭代时才算数
生成器表达式和嵌套函数一样,有个“晚绑定”特性。它捕获的变量不是在创建那一刻取当前值,而是在真正迭代那一刻才去取值。
nums = [1, 2, 3] multiplier = 2 gen = (n * multiplier for n in nums) multiplier = 10 print(list(gen)) # [10, 20, 30],不是 [2, 4, 6]看到没?multiplier在迭代前被改成10了,生成器就按10来算。这对“配置后生效”这类场景是友好的,但对不熟悉的人就是个隐形炸弹。如果你希望它在创建那一刻就固化某个常量值,可以把它作为默认参数传进去:
gen = (n * multiplier for n in nums, multiplier) # 实际上要这样写: gen = (n * multiplier for n in nums) # 无法直接固化,更可靠的做法是把逻辑包进函数其实想固化,最干净的方式是定义一个接收参数的函数再返回生成器,这样参数变成局部变量,不再受外部后续修改影响。这点在写实时数据处理代码时尤其重要:外部变量被多次赋值,生成器却只认迭代那一刻的值,容易产生“我明明改了,结果没变”的错觉。
4.4 坑三:盲目追求生成器的“快”
聊性能前得先声明一点:生成器的优势主要是省内存,不是跑得快。每轮迭代,生成器都要经历一次“状态保存→恢复执行→再挂起”的协议开销,这比直接遍历列表要慢一些。如果数据量不大,几十万个元素,列表推导式可能比生成器表达式还快,因为省去了yield/resume的调度成本。
所以我的习惯是:数据量小、一次性用完后还要继续用名单的情况下,放心用列表;数据量大、只遍历一次、对内存敏感的场景,果断用生成器;实时流、无限序列,只能是生成器。不要听信“生成器永远更快”这种话,性能问题要拿数据说话,而不是靠感觉。
5. 实战:用生成器重写一条数据处理管道
5.1 场景设定与需求分析
假设有这样一个场景:手头有一份结构化的订单日志,每一行是CSV格式,包含订单号、用户ID、商品ID、支付金额、下单时间。需要从中筛选出金额大于100元的订单,然后按用户ID汇总总金额,最后输出消费最高的5个用户。如果文件只有几千行,用列表怎么折腾都行。但把文件换成几十GB的离线订单数据,一次性读入内存就不可能了。
这是典型的生成器管道场景。我们需要的不是一个把全量数据加载进来的大函数,而是层层过滤、逐条传递的数据流。
5.2 完整代码与逐段解读
def read_csv(path): with open(path, "r", encoding="utf-8") as f: for line in f: yield line.rstrip("\n") def parse_orders(lines): for line in lines: fields = line.split(",") if len(fields) < 5: continue yield { "order_id": fields[0], "user_id": fields[1], "product_id": fields[2], "amount": float(fields[3]), "time": fields[4], } def filter_large_orders(orders, threshold=100): for order in orders: if order["amount"] > threshold: yield order def aggregate_by_user(orders): summary = {} for order in orders: uid = order["user_id"] summary[uid] = summary.get(uid, 0) + order["amount"] return summary主流程:
orders = filter_large_orders(parse_orders(read_csv("orders.csv"))) summary = aggregate_by_user(orders) top5 = sorted(summary.items(), key=lambda x: x[1], reverse=True)[:5] for uid, total in top5: print(uid, total)虽然aggregate_by_user最终还是要维护一个全量字典,但注意它只是汇总每个用户的总金额,用户数量远远小于订单数量。几十GB的订单可能只有几万个用户,这个字典完全能放进内存。真正会爆内存的是“所有订单对象先全部存入列表”,而不是“按用户聚合”。
read_csv、parse_orders、filter_large_orders三个函数全部是生成器,数据从文件流出来后,解析一条、过滤一条、累加一条,全程不会出现一个装着全部订单的列表。文件读取也使用了缓冲流式读取,磁盘IO一批一批地读,速度并不慢。
5.3 运行效果与调优备注
我自己跑过一份800MB的模拟订单数据,生成器管道版本内存占用稳定在50MB以内,耗时大约十几秒;如果用readlines()加列表推导式,内存峰值直接到几个GB,时间也没快多少,因为GC开销反而拖了后腿。对于几十GB的离线数据,前者能跑完,后者大概率直接OOM被杀。
有一个细节值得提:当管道中任何一环被中断——比如外层只取前1000个结果就退出——上游生成器不会自动感知。文件句柄可能一直开着,直到进程退出才释放。如果这个管道运行在长驻服务里,文件被反复打开不关,很快就会把文件描述符耗尽。解决办法是用contextlib.closing或者把整个处理逻辑放进with结构化上下文里,保证异常和早退时资源能被清理。
另一种常见优化是把parse_orders直接改成返回元组而不是字典,能省不少对象创建开销。数据量大的时候,几百万个小字典的创建和GC成本也是实打实的。这个取舍得评估可读性和性能,一般项目里保持字典足够用,但压测发现瓶颈在对象分配时再考虑优化。
6. 生成器的更多玩法与我的实战体会
6.1 异步生成器:惰性在异步世界里同样好用
Python 3.6之后引入了异步生成器,配合async for,可以在异步代码里同样实现惰性求值。它的语法就是把yield放到async def函数里:
async def fetch_pages(urls): for url in urls: page = await fetch_page(url) yield page调用方用async for page in fetch_pages(urls)就能逐页消费,不需要一次性把全部页面都放到内存。这在爬虫、批量抓取API、逐帧处理视频流的场景里非常实用。注意异步生成器必须在事件循环里运行,不能像普通生成器那样直接next(),日常开发中别把它和普通生成器混用。
异步生成器和普通生成器的底层机制是相通的——都是通过保存执行状态来实现“暂停/恢复”,只不过异步版的多了一个 await 挂起点。理解好普通生成器的yield,再看async for就会觉得很自然。
6.2 itertools:生成器的最佳拍档
Python标准库itertools是专门为生成器和迭代器设计的工具包,里面不少函数可以直接和yield组合。islice可以从无限生成器里切出前N项,takewhile按条件截断数据流,chain把多个迭代器首尾相连,groupby对排序后的数据做连续分组。写量化策略或者处理时序数据时,itertools基本可以代替一大票手写循环。
from itertools import islice for idx, val in enumerate(islice(fibonacci(), 10)): print(idx, val)无限斐波那契数列只拿前10个,就这么简单。如果没有islice,你就得自己写计数器,然后手动break,代码冗长且容易出错。
组合itertools和生成器表达式,常常能写出教科书里没有但实际特别好用的简洁代码。比如计算滑动窗口均值,用一个生成器不断产出窗口内元素,外层用sum()加除法即可。这种组合方式也会显著提升你对Python数据流的掌控感。
6.3 个人习惯与最后建议
我写Python这么多年,遇到“要不要用生成器”的抉择时,基本遵循三条经验:第一,函数返回的是一个“大集合”而不是“单个值”的时候,先评估这个集合是否会被一次性完整消费;第二,如果只是取前几个、做流式加工、给下游逐个处理,默认用生成器,永远不会错;第三,如果集合需要反复遍历、随机索引、计算长度,再考虑转成列表。
还有一个小技巧,生成器函数里的调试打印往往让人头大,因为打印会在迭代时才执行。想确认生成器内部执行到哪一步了,直接list(generator)是不错的办法,但不建议拿它处理大文件。小范围调试时很好用,大文件调试请用islice截取一部分再list,不然内存又爆了。
惰性求值是我觉得Python里最被低估的特性之一。它不只是语法糖,更是一种思考方式:先定义“是什么”,把“什么时候算”交给真正需要结果的时刻。写复杂数据处理逻辑的时候,用生成器把步骤拆开、串起来,代码往往比堆砌中间变量清晰得多。下次你一看到for row in read_log_lines()这种写法,就能明白背后“随用随取、流水作业”的优雅了。