☰
Python生成器与yield:用惰性求值解决大文件处理的内存难题
2026/9/30 15:36:45 网站建设 项目流程

1. 生成器到底是什么:不只是一种"省内存的列表"

先从一个场景说起。几个月前我做了一个日志分析脚本,要处理一个大约4GB的文本文件,每行是一段JSON日志,我需要按时间戳过滤出某个时间段的数据再统计接口耗时。最直觉的写法是先用列表把所有行读进来:

with open("app.log", "r", encoding="utf-8") as f: lines = f.readlines()

这段代码刚跑起来机器就没动静了,我一看内存占用直接飙到6个多G,然后进程被系统杀掉了。后来我改成这样:

def read_log_lines(path): with open(path, "r", encoding="utf-8") as f: for line in f: yield line for line in read_log_lines("app.log"): # 逐行处理 pass

同样的数据,内存占用稳定在几十MB。这就是Python生成器(Generator)和yield关键字带来的"惰性求值"威力:数据不是一次性全部加载到内存,而是用一条","一个接一个地生产出来,用到哪算到哪。

这篇文章就是讲清楚生成器的核心原理、实际应用场景、以及我在真实项目中踩过的坑。适合刚入门Python、写过一些循环但还不理解yield为什么能暂停的读者,也适合写了几年Python但对生成器内部的暂停恢复机制一直"感觉会、说不清"的朋友。如果你只是想找个现成的大文件处理模板,看第3节即可;如果你想真正理解"惰性求值之美",建议从头读起。

2. 认识惰性求值:为什么我们愿意用"时间"换"内存"

2.1 普通函数与生成器函数的本质区别

先对比两段代码。普通函数:

def make_numbers(count): result = [] for i in range(count): result.append(i) return result nums = make_numbers(10000000)

这段代码会把1000万个整数全部放进列表返回,内存里同时存在1000万个Python对象。Python里的整数不是4个字节的那种C int,而是一个完整的PyObject结构体,28字节起步,1000万个就是几百MB,堆内存直接爆炸。

再看生成器函数:

def generate_numbers(count): for i in range(count): yield i nums = generate_numbers(10000000)

表面上看,它也是一个函数定义,但函数体里只要有yield关键字,调用时就不会执行函数体,而是返回一个生成器对象。这个对象本身几乎没有占用多少内存,它保存了函数当前的状态:局部变量、指令指针、栈帧。每次调用next(nums),它才往下执行一段,遇到yield就暂停,把yield后面的值返回给调用者,下次再恢复继续跑。

这就是惰性求值的核心:表达式不会立即求值,而是等到真正需要取数据时才去计算。

2.2 惰性求值到底"赚"在哪里

从直观感受上,惰性求值牺牲了一点点单次取值的时间,换来两个大好处:

第一,内存可控。处理大文件、大数据流时,永远只保留当前这一条数据,而不是整个数据全集。程序能跑,机器不崩,这在生产环境里比什么都重要。

第二,启动更快。生成器第一次调用时几乎不做任何计算,马上返回一个对象,真正费时的计算被摊薄到每次next()调用上。如果前端展示需要分批获取数据,这种"按需生产"的节奏天然契合。

第三,可以表达无限序列。列表没法表达"全体正整数",因为内存放不下;但生成器可以,因为它根本不一次性生成全部,而是在你不断向它索取时不断吐出来。

2.3 关于"时间换空间"的真相

经常有人问:用生成器是不是一定比列表慢?我的实测经验是,不一定。在遍历次数不多、单次计算简单的场景下,生成器因为避免了列表拼接和内存分配的开销,反而更快;但在频繁随机访问、需要反复遍历同一个序列的场景下,列表更快,因为生成器取数有函数调用开销,而且只能向前走、不能回退。

所以选择的标准不是"哪个快",而是"你到底需不需要整个数据集同时存在内存里"。如果不需要,就用生成器;如果需要反复读取、随机下标访问,就用列表。这个决策意识比记住任何参数都重要。

3. 两种创建生成器的方式:生成器函数与生成器表达式

3.1 生成器函数的写法与执行流程

生成器函数是普通函数体中出现yield的函数。看这个最简例子:

def simple_gen(): print("第一次调用next,开始执行") yield 1 print("第二次调用next,从上次暂停处恢复") yield 2 gen = simple_gen() print("此时函数体还没执行") print(next(gen)) print(next(gen))

输出结果:

此时函数体还没执行 第一次调用next,开始执行 1 第二次调用next,从上次暂停处恢复 2

关键点在于:simple_gen()被调用时,函数体一个字都没跑,只创建了生成器对象。第一次next(gen)才真正执行到第一个yield 1,把1返回,然后整个函数在yield处冻结,局部变量、代码位置全部保存。第二次next(gen)从yield 1之后那一行继续往下走,执行print,再遇到yield 2再次暂停。

如果函数执行完后没有更多yield,再调用next(gen)就会抛StopIteration异常,这是生成器正常结束的标志,不是错误。

3.2 生成器表达式:一种更紧凑的写法

如果生成器逻辑足够简单,可以用生成器表达式,把列表推导式的方括号改成圆括号:

squares = (x * x for x in range(1000))

注意,这里squares不是元组,是生成器对象。它跟列表推导式的区别非常典型:

list_squares = [x * x for x in range(1000)] # 立即生成1000个元素,内存占用大 gen_squares = (x * x for x in range(1000)) # 惰性生成,几乎不占内存

生成器表达式适合"简单的映射过滤流水线",但它不适合复杂逻辑,比如需要在循环里做多步处理、需要异常处理时,就老老实实写生成器函数。

3.3 实操:用生成器处理GB级日志文件

这是我压箱底的模板,处理大文件时直接抄:

import json def parse_log_lines(file_path): with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: record = json.loads(line) except json.JSONDecodeError: continue yield record def filter_by_path(records, path_prefix): for rec in records: if rec.get("url", "").startswith(path_prefix): yield rec log_records = parse_log_lines("app.log") filtered = filter_by_path(log_records, "/api/v1/orders") for rec in filtered: # 统计耗时 cost = rec.get("cost", 0) ...

这段代码的妙处在于:parse_log_lines一次只从文件里读一行、解析一行;filter_by_path一次只接收一个记录、判断一个记录。整条流水线里任何时刻最多只有一条记录在内存中。这就是把数据流处理变成了"管道",每一步只关心当前这一块数据,而不需要等待全量数据到位。

如果你需要处理的数据能放进内存但量不小,比如100万条字典组成的列表,用生成器处理可以降低峰值内存,但要注意后面小节里讲的"只能迭代一次"陷阱。

3.4 两个容易混淆的内置函数:iter和next

内置函数iter()可以把可迭代对象变成迭代器,next()从迭代器中取下一个元素。生成器本身既是可迭代对象也是迭代器,所以可以直接扔给next()。

对普通列表来说:

lst = [1, 2, 3] it = iter(lst) # 把可迭代对象变成迭代器 print(next(it)) # 1 print(next(it)) # 2

但不能直接对列表调用next(lst)会抛TypeError: 'list' object is not an iterator,原因就是列表没有"记住当前走到哪个位置"的能力,它只是可迭代的,不是迭代器。

for循环的本质就是不断调用next(),遇到StopIteration就自动退出。理解了这层,你就能明白生成器为什么可以放在for循环里,也能明白"生成器可以用一次就没了"。

4. yield的暂停机制:生成器内部到底发生了什么

4.1 栈帧与状态保存

要理解yield为什么能"记住"执行到哪,需要了解CPython的栈帧机制。一个函数在被调用时,Python会创建一个栈帧(frame)对象,里面保存了函数的局部变量、参数、代码对象、指令指针等。普通函数执行完,栈帧销毁;生成器函数执行到yield时,不会销毁栈帧,而是把它连同局部变量一起"挂起"。

所以生成器对象身上背着完整的调用栈状态。你甚至可以把它理解为"一个可以重复进入的函数",每次进入不是从头开始,而是从上次的暂停点继续。这也是协程的前身。

4.2 生成器对象的内部属性

打开一个生成器对象,能看到这些有意思的属性:

def demo(): x = 10 yield x g = demo() print(g.gi_frame.f_locals) # 开始时:{} next(g) # 执行到yield,暂停 print(g.gi_frame.f_locals) # 输出 {'x': 10} print(g.gi_code.co_name) # demo

gi_frame.f_locals能直接看到当前生成器函数内部局部变量的值,这在调试底层问题时有奇效。生产环境里不常用,但能帮你直观理解"状态保存在哪"。

4.3 yield from:把子迭代器交给父生成器

Python 3.3 引入yield from,用来在一个生成器里"委托"另一个可迭代对象:

def chain(*iterables): for it in iterables: yield from it list(chain([1, 2], [3, 4])) # [1, 2, 3, 4]

这个例子用普通循环也一样,但yield from更简洁,最重要的是它把子生成器的send()、throw()、close()等行为透传给了调用方,处理协程、异常传递时必不可少。后面讲协程会用到。

5. 生成器进阶:send、throw、close与协程雏形

5.1 send:不只是取值,还能往生成器里扔值

很多人知道next(gen)从生成器取一个值,但不知道gen.send(value)可以同时往生成器内部传一个值。这个值会成为当前yield表达式的结果。来看一个例子:

def accumulator(): total = 0 while True: v = yield total if v is None: continue total += v acc = accumulator() print(next(acc)) # 必须先启动生成器,执行到第一个yield print(acc.send(10)) # total变为10,yield total为10 print(acc.send(20)) # total变为30,yield total为30

注意v = yield total这行:函数执行到yield total时,把total返回给调用方,然后暂停。下次调用send(10)时,10被赋值给v,然后继续执行total += v,再到下一次循环碰到yield total把新total返回。

用send()时必须注意:生成器第一次启动时要用next(gen)或gen.send(None),不能直接send一个非None值,否则会报TypeError: can't send non-None value to a just-started generator。原因很清晰——第一次调用时还没有任何yield等待接收值,传入值没有地方可落。

5.2 throw:往生成器内部抛异常

def catcher(): try: while True: yield 1 except ValueError as e: yield f"caught: {e}" gen = catcher() print(next(gen)) # 1 print(gen.throw(ValueError("boom"))) # caught: boom

throw()会在生成器当前暂停的yield处抛出传入的异常。如果函数内部捕获了这个异常,生成器会继续执行到下一个yield,把值返回;如果没捕获,异常就会向外传递。这在需要给生成器发送"停止信号"或"特殊指令"时非常有用。

5.3 close:手动关闭生成器

gen = (i for i in range(10)) next(gen) gen.close() try: next(gen) except StopIteration: print("已经关闭")

生成器对象被垃圾回收时也会自动调用close(),在生成器内部会抛GeneratorExit异常。如果你在生成器里写了finally块,可以用它来释放文件句柄、关闭数据库连接等资源。这也是生成器处理文件安全退出的保障之一。

5.4 生成器当协程用:极简事件循环

生成器有了send()、throw()、close()之后,其实已经具备协程的雏形。经典例子是生成器的两个协程互相发送数据:

def ping(): while True: n = yield "ping" if n == "stop": print("ping停止") return def pong(): while True: n = yield "pong" if n == "stop": print("pong停止") return

手动交替调用send()可以模拟协作式调度。当然,现代Python用async/await实现了更完善的协程,生成器协程已经是历史遗留方案。但理解这个演进对理解"yield可以双向通信"很有帮助,很多老代码库里的基于生成器的状态机逻辑仍然依赖这个特性。

6. 常见坑与调试实录:我踩过的生成器雷区

6.1 生成器只能迭代一次

这是新手最容易踩的坑。看这个:

gen = (x * 2 for x in range(5)) print(list(gen)) # [0, 2, 4, 6, 8] print(list(gen)) # []

第二次list(gen)是空的,因为第一次list已经把所有元素都取完了,生成器内部指针走到结尾。而列表不会这样,列表迭代多少次都在那里。处理这个问题有两种思路:

  • 如果你真的需要反复遍历,就直接用列表,不要硬套生成器;
  • 如果数据太大不能全量加载,那就重新创建生成器,或者设计成用一个生成器函数,每次需要遍历时调用函数生成新的生成器对象。

我在生产环境里见过有人把生成器存进字典,后面从字典里取值发现全空了,排查了半天。记住:生成器是一次性消费品,可以随时重新生产,但不可复用。

6.2 提前消耗与作用域泄漏

Python 3.7之前,生成器表达式里循环变量会泄漏到外层作用域;Python 3.8起了变化,不再泄漏。但如果你在代码里捕获了生成器表达式的迭代变量,还是容易出问题。比如:

funcs = [lambda: i for i in range(3)] for f in funcs: print(f())

这段输出三个2,不是0、1、2。这不是生成器独有的问题,是闭包延迟绑定变量导致的。如果你用生成器表达式配合lambda写类似逻辑,同样会踩。修正方法是用默认参数绑定当前值:

funcs = [lambda x=i: x for i in range(3)]

6.3 生成器函数体内不能有return值

在生成器函数里写return value是语法允许的,但value不会像普通函数那样返回给你,而是作为StopIteration异常的一个属性存在。取出来的方式比较别扭:

def gen_with_return(): yield 1 return "done" g = gen_with_return() print(next(g)) try: next(g) except StopIteration as e: print(e.value) # 'done'

实际开发中很少用这种方式取返回值,但理解它有助于你解读报错信息。

6.4 大文件处理中finally的使用

我在用生成器做文件逐行读取时,有人会担心:如果中途退出循环,文件会不会泄漏?比如:

def read_apache_log(path): f = open(path, "r", encoding="utf-8") for line in f: yield line f.close()

如果for循环中途break,生成器可能还没执行到f.close(),文件句柄是不是就一直挂着?实际上,生成器被垃圾回收时会自动close(),但回收时机不受你控制。靠谱的做法是用上下文管理器:

def read_apache_log(path): with open(path, "r", encoding="utf-8") as f: for line in f: yield line

with会保证即使生成器被GC时抛出GeneratorExit,文件也能被正确关闭。这个细节在长驻服务里是真的会救命——我遇到过因为生成器没关闭导致文件句柄耗尽,服务跑几天就报too many open files。所以写生成器,只要有文件或网络资源,务必用with包裹。

6.5 生成器配合异常处理的注意事项

生成器里的异常处理有个反直觉的点:如果生成器在yield处暂停,生成器外面抛出的异常不会自动传到生成器内部,除非你用throw()。反过来,生成器内部抛出的异常会传播到调用方。所以写多级生成器管道时,最上层抛出异常,最好看看是哪一级生成器处理的,别在每一级都塞大段try,否则管道内部的异常很难追踪。

调试技巧:给生成器函数加日志,或者用inspect模块的getgeneratorstate()查看状态:

from inspect import getgeneratorstate g = (x for x in range(3)) print(getgeneratorstate(g)) # GEN_CREATED next(g) print(getgeneratorstate(g)) # GEN_SUSPENDED list(g) print(getgeneratorstate(g)) # GEN_CLOSED

这对分析生成器是否被提前消费、是否卡在某个yield点很有帮助。

7. 实战案例:用生成器搭建数据流水线

7.1 场景:处理CSV并做多级清洗

假设你有一个很大的CSV文件,每行有用户ID、金额、时间三列。你要做的是:过滤掉金额非法的记录、按照日期按小时汇总金额。用生成器把每一步都拆出来:

import csv from collections import defaultdict def read_csv(path): with open(path, "r", encoding="utf-8", newline="") as f: reader = csv.DictReader(f) for row in reader: yield row def clean_records(rows): for row in rows: try: amount = float(row["amount"]) except (ValueError, KeyError): continue if amount <= 0: continue yield { "user_id": row["user_id"], "amount": amount, "time": row["time"] } def group_by_hour(records): hourly = defaultdict(float) for rec in records: hour = rec["time"][:13] # 简化例子:按小时字符串 hourly[hour] += rec["amount"] return dict(hourly) result = group_by_hour(clean_records(read_csv("data.csv"))) print(result)

这条流水线的执行顺序是:group_by_hour先启动,向clean_records要数据,clean_records向read_csv要数据,read_csv才真正打开文件、读取一行。整个过程完全惰性,数据集大也好小也好,内存占用只跟group_by_hour里那个hourly字典有关,而字典大小取决于小时数,跟总行数无关。

7.2 场景:无限序列与take

斐波那契数列是无限序列的经典例子:

def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b fib = fibonacci() for _ in range(10): print(next(fib), end=" ")

用itertools.islice可以截取前N个:

from itertools import islice list(islice(fibonacci(), 0, 10))

无限序列成为可能,完全是因为惰性求值——生成器不会试图提前算出所有项,它只是"守在那里,你问一次,它算一次"。

7.3 场景:求均值、去重、合并多个数据源

生成器不仅用于文件操作,也常用于内存中的大数据集。比如合并多个有序序列:

import heapq def merge_sorted(*iterables): return heapq.merge(*iterables) merged = merge_sorted((x for x in range(0, 10, 2)), (x for x in range(1, 10, 2))) print(list(merged))

heapq.merge内部也是惰性求值,多个生成器交替取出最小值,适合合并多个有序日志流或者有序数据库结果集。

7.4 性能对比实测

用一个简单实验对比列表和生成器处理1000万个整数的耗时和内存(仅示意,实际数据因机器而异):

实现方式峰值内存耗时(遍历+求和)
列表推导[i for i in range(n)]~360MB稍快
生成器(i for i in range(n))~几MB稍慢但可接受

这里"稍慢"来自生成器的逐次next()调用和暂停/恢复开销,但优势是内存占用可以差了2个数量级。在真实生产环境中,3GB文件逐行处理时,列表方式直接OOM,生成器稳如老狗,这才是决定性的对比。

8. 何时应该放弃生成器:选型的边界

8.1 不适合用生成器的情况

生成器不是银弹。如果你反复需要访问序列中的随机元素,比如data[5000],生成器做不了——它没有__getitem__,只能从头往后取。此时必须用列表或numpy数组。

如果你需要把数据存下来以后反复读,也不要为了"优雅"硬套生成器,直接列表归根到底更合适。毕竟"能放进内存"本身就是一种资源判断。

如果数据量不大,比如只有几百个元素,用生成器反而让代码更难读。选型的核心原则是:数据集的规模是否超过内存容量,或者处理方式是否天然适合流式(一遍扫描)。两者都不超过,就用平常的数据结构,不要滥用。

8.2 生成器与map/filter的比较

map和filter在Python 3中也是惰性的,返回迭代器。很多人纠结用哪个。其实map/filter适合简单函数映射,生成器表达式更灵活。例如:

list(map(lambda x: x * 2, range(5))) list(x * 2 for x in range(5))

两者在功能上等价。如果映射逻辑简单、只有一层,用map更紧凑;如果有条件过滤、多层嵌套,生成器表达式更可读。个人建议团队项目里统一用生成器表达式,因为它的表达能力更强,且配合yield的函数形式可以处理更复杂的逻辑。

8.3 生成器与列表推导式的性能权衡

很多人迷信"生成器一定比列表快",其实遍历速度上列表略胜。生成器最大的赢面在内存而不是速度。在Python的for循环里遍历一个已经存在的列表,比对生成器调用next()要快,因为生成器要做函数栈切换。如果你的数据已经以列表形式存在于内存中,就别再包一层生成器了,纯属浪费。

9. 从生成器到async/await:惰性求值的进化

Python 3.5 引入async/await,本质上是一种基于生成器的协程封装。async def函数的调用返回协程对象,内部用await挂起异步操作,其暂停恢复机制与生成器的yield一脉相承。

它们最大的区别是:生成器协程通过send(value)双向通信,而async/await有完整的事件循环、任务调度和异常传播机制。你不需要自己管理send()和StopIteration,框架替你做了。

如果你理解了生成器的暂停/恢复原理,再学async/await会非常顺畅。因为两者都是"执行到某一点挂起,之后从挂起点继续"。惰性求值思想贯穿了Python的很多核心设计:不只是生成器,迭代器、上下文管理器、装饰器、协程都在用类似的方式延迟实际动作。

我在项目中写异步爬虫时,经常把HTML解析设计成生成器,每个解析结果yield出去,再在外层异步队列里消费,天然就把内存占用控制在最小。这种"生成器+异步消费"的模式,在数据处理型服务里是通用法宝。

10. 写在最后:生成器带给我的几点真实体会

做Python这么多年,生成器是少数几个我越用越觉得"设计得真聪明"的语言特性。它并不华丽,但每次处理超大文件、构建数据管道时,它都能稳稳地保住进程不因内存告急而崩溃。你不需要记住每一处底层栈帧细节,但你需要记住一句核心判断:如果数据是一次性消费的流,就让它流过去,别聚起来。

我个人在实际项目中的习惯是:凡是处理超过100MB的文本、日志、CSV、JSON流,默认写生成器;凡是处理数据库查询结果集,默认用生成器逐条取出;凡是需要做多步骤ETL,统统拆成带yield的小函数,一层层套管道。调试时用getgeneratorstate配合日志输出,几乎能一眼看出问题出在哪一级。

最后一个小技巧,值得抄进代码里:当你需要从生成器中取第一批数据做快速预览时,别用list(gen)[:5],那会把整个生成器全部耗尽,应该用itertools.islice(gen, 5)。这个小坑我见过不止一位同事踩过,希望你看完这篇文章之后,能顺手避过去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询