news 2026/9/26 17:54:10

Python生成器详解:从yield原理到内存优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python生成器详解:从yield原理到内存优化实战

做Python开发这几年,生成器这个东西我是越用越觉得香。刚开始学的时候,教程里只写了一句"生成器是一种一边循环一边计算的机制",当时没当回事,直到后来做数据清洗,一个几个GB的日志文件差点把服务器内存撑爆,才老老实实回头把这块啃透。今天这篇就把Python生成器从原理到实战到踩坑一次性讲清楚。

生成器(Generator)是Python里一种特殊的迭代器,核心价值就八个字:延时计算,按需取值。它不一次性把数据全部装进内存,而是每次要一个、算一个、给一个。适合谁看?刚学完列表推导式、开始碰真实数据的新手,以及写了几年代码但遇到大数据量依然靠列表硬扛的老手,都值得花半小时把这套机制整明白。下面全部用我实际跑过的代码说话。

1. 生成器到底解决了什么问题

1.1 从迭代器说起:为什么需要"懒加载"

要理解生成器,得先搞清楚迭代器(Iterator)这层关系。Python里凡是能用for循环遍历的东西,比如列表、元组、字典、字符串,都叫可迭代对象(Iterable)。但列表这类容器是"急性子":你创建它的那一刻,所有元素就已经全部躺在内存里了。

举个真实例子。我有一次要处理一份电商订单明细,文件大概2.3GB,用常见的做法先读进列表:

orders = [] with open('orders.log', 'r', encoding='utf-8') as f: for line in f: orders.append(line.strip())

数据量一大,内存占用直接飙到3GB以上,云服务器4G内存当场报警。为什么会这样?因为列表把每一行字符串都完整地保留在内存里了,你后面可能只用其中1%的数据,但99%的存储成本已经付出去了。

迭代器的出现就是为了解决这类问题。它像一个"游标"或者说"书签",只记录当前读到哪、下一个是什么,并不保存所有数据。但你如果手动写一个迭代器类,得实现__iter__和__next__两个方法,还要自己维护状态,代码又啰嗦又容易出错。生成器就是Python提供的一种"偷懒"的迭代器写法——你不用手动管理状态,函数会自动帮你记着上次执行到哪一行。

1.2 yield 关键字:生成器函数的核心

先看一个最经典的对比。同样是返回一个平方数序列:

# 普通函数:一次性算完,返回完整列表 def square_list(n): result = [] for i in range(n): result.append(i * i) return result # 生成器函数:算一个给一个 def square_gen(n): for i in range(n): yield i * i

区别就在return和yield上。普通函数执行到return就结束,把整个列表交出去;生成器函数遇到yield会"暂停",把当前值交出去,然后函数的所有状态——包括循环变量i的值、下一次循环的位置——全部被冻结保存。等你下次调用next(),它从暂停的地方继续往下走。

我第一次看到这个行为时觉得特别像"存档点":单机游戏里存档,退出游戏,下次读档接着打,角色位置、背包状态全都在。生成器就是Python函数的存档读档机制,只不过这个"档"只能往后读,不能回退。

这里有个新手特别容易误解的地方:生成器函数一旦被调用,函数体并不会立即执行。你调用square_gen(10)得到的不是数值,而是一个生成器对象。只有当你开始迭代它,函数体里面的代码才真正一行行跑起来。这个"延迟"就是后面所有性能优势的根源。

1.3 两种创建方式:函数写法与表达式写法

创建生成器有两条路。上面yield的写法叫生成器函数,另一种是生成器表达式,长得跟列表推导式很像,但把方括号换成圆括号:

# 列表推导式:一次生成完整列表 lst = [i * i for i in range(10000)] # 生成器表达式:产生一个生成器对象 gen = (i * i for i in range(10000))

这里有个细节值得记一下:列表推导式执行完,lst是实实在在的列表,内存已经分配好了;生成器表达式执行完,gen只是一个生成器对象,一个元素都还没算。真正遍历gen的时候才逐个计算。两者在range(10000)这样的小数据量下看不出差别,但把范围换成range(10_000_000),差别就是几百MB内存和几十KB内存的区别。

我平时选型有个朴素的判断标准:如果这个序列我只遍历一次,而且后续不需要按下标随机访问,优先写生成器;如果中间结果要被反复使用、需要切片、需要len()、需要多次遍历,才老老实实用列表。记住这句口诀:一次遍历,用生成器;多次访问,用列表。后面所有实战例子基本都是这个原则的延伸。

2. 生成器的执行流程与状态机

2.1 生成器函数的生命周期

为了把"暂停-继续"这个机制看透彻,我建议你亲手跑一遍下面这个带打印的代码:

def gen_demo(): print('第一次进入函数') yield '第一站' print('第一次恢复执行') yield '第二站' print('第二次恢复执行,函数结束') g = gen_demo() print('调用 gen_demo() 后:') print(f'生成器对象: {g}') print('第一次 next:', next(g)) print('第二次 next:', next(g)) print('第三次 next:', next(g))

输出顺序会很有意思:

调用 gen_demo() 后: 生成器对象: <generator object gen_demo at 0x...> 第一次进入函数 第一次 next: 第一站 第一次恢复执行 第二次 next: 第二站 第二次恢复执行,函数结束 第三次 next: 报错 StopIteration

注意看:调用后那行打印早于第一次进入函数,证明函数体确实没执行;每次next()触发后,代码从上一个yield的下一行接着跑。第三次next()时函数已经走完,Python会抛出一个StopIteration异常,表示生成器耗尽。for循环之所以能自动处理生成器,就是因为它内部捕获了这个异常然后正常退出,所以你平时写for完全感觉不到这个异常的存在。

这个状态机的概念搞懂之后,再看生成器就没有任何神秘感了:它不过是一个带"断点续传"能力的函数。Python官方文档管这叫"generator-iterator",生成器对象本身就是自己的迭代器,你不需要像自定义迭代器类那样区分iter()和next()。

2.2 next() 与 for 循环:两种消费方式

消费生成器有两种主流姿势。一种是手动调next(g),适合需要精确控制节奏的场景,比如从生成器里只取前几个元素就停:

g = (i * i for i in range(10_000_000)) first = next(g) # 0 second = next(g) # 1 third = next(g) # 4

另一种就是最常见的for循环,把迭代细节都交给Python处理。大多数人日常写代码用for就够了,真正需要手动next()的场合,往往是在实现更高层的工具时,比如你要写一个批量取数据的函数:

def batch_iter(gen, size=100): while True: batch = [] try: for _ in range(size): batch.append(next(gen)) except StopIteration: if batch: yield batch break yield batch

这段代码就是"每次从生成器里取100条",最后不足100条也照样吐出来。这种批量消费模式在写数据库批量写入、批量调用接口时非常实用,比把数据一次性拉出来再切分内存友好得多。

另外值得提一句的是内置函数iter()配合next()的默认值写法:next(g, default)可以在生成器耗尽时返回你给定的默认值而不是抛异常。这个特性在写递归遍历、状态机之类的代码时能省掉不少 try/except。

2.3 生成器是单向的一次性管道

生成器这条管道有一个天然特性:单向、不可逆、用过即焚。你不可能回到已经消费过的位置重新取值。这一点和列表有本质区别,列表你随时可以lst[0]回头访问,生成器一旦取过了就没了。

这带来一个常见的坑:很多人把生成器传给一个函数,函数内部遍历了一遍,外面还想再遍历一遍,结果发现第二次遍历拿到的是空的。我在第5节会专门讲这个问题的排查方法,这里先记住结论——生成器不是存储结构,是数据流。需要重复使用数据,要么重新创建生成器,要么老老实实转成列表。

这个特性也决定了生成器特别适合"一次性流水线":数据从源头进来,经过一道道处理,最终被消费掉,中间任何环节都不需要完整存档。后文第4节的数据管线就是这个思路的具体应用。

3. 进阶玩法:send、throw、close 与 yield from

3.1 send():给生成器递话

基本用法聊完,来点进阶的。yield不只能向外吐数据,还能接收外部传入的数据,靠的是send(value)方法。

def echo_gen(): while True: received = yield print(f'收到外部消息: {received}') g = echo_gen() next(g) # 先"启动"生成器,让它运行到第一个 yield g.send('你好') g.send('Python 生成器')

这里有个很容易踩的坑:使用send之前必须先调用一次next(g)或者g.send(None),让生成器先运行到yield语句处挂起。因为send(值)的本质是"恢复执行,并且把值赋给yield表达式的结果",如果生成器还没启动,这个值就没地方放。

你还可以把yield的表达式的值用起来,变成双向通信:

def calc_avg(): total = 0 count = 0 while True: value = yield total / count if count else 0 total += value count += 1 avg = calc_avg() next(avg) # 启动 print(avg.send(10)) # 输出 10.0 print(avg.send(20)) # 输出 15.0 print(avg.send(30)) # 输出 20.0

这段代码实现了"边输入边计算平均值"的小工具。send进去的数字被yield表达式接住,累加到total里,下一次循环又通过yield吐出来。这种双向通信能力让生成器远远超出了"懒加载列表"的范畴,为后面协程铺了路。

3.2 throw() 和 close():异常与清理

生成器还提供两个不太常用但关键时刻救命的方法:throw和close。

g.throw(异常类型, 值)可以在生成器暂停的地方抛一个异常进去。典型场景是外部想终止一个无限循环生成器:

def endless(): n = 0 while True: try: yield n n += 1 except GeneratorExit: print('生成器被关闭,执行清理') raise gen = endless() print(next(gen)) # 0 print(next(gen)) # 1 gen.close() # 触发 GeneratorExit,打印清理信息

close()会在生成器暂停处抛一个GeneratorExit异常。如果你在生成器里用了try/finally,finally块会在关闭时执行,这正好用来释放文件句柄、关闭数据库连接等资源。这个特性在我写带状态的长连接处理时特别有用,比在外部手动记状态干净得多。

顺带提醒:GeneratorExit异常不应当被随意吞掉,你在except GeneratorExit里做完清理之后要raise或者直接让函数自然结束,否则可能导致close()行为异常。这个细节官方文档有明确说明,实际踩过的人不多,但踩到就是一脸懵。

3.3 yield from:把子生成器接进来

yield from是Python 3.3引入的语法,专门用来解决"一个生成器里嵌套另一个生成器"的展开问题。看这个对比:

def chain_manual(): for item in [1, 2, 3]: yield item for item in ['a', 'b', 'c']: yield item def chain_yield_from(): yield from [1, 2, 3] yield from ['a', 'b', 'c']

两种写法结果完全一样,但yield from更简洁,而且在嵌套生成器场景下有一个隐藏优势:yield from会把send、throw、close这些操作也"传导"给子生成器。这意味着你用子生成器实现一个小的状态机时,外部可以直接驱动它,而不必关心中间层的存在。

举一个组合多个文件读取的实用例子:

def read_lines(path): with open(path, 'r', encoding='utf-8') as f: yield from f def multi_file_lines(paths): for path in paths: yield from read_lines(path)

yield from f直接把文件对象的迭代行为委托出去,不用自己写for line in f: yield line。处理多文件合并流时,这段代码就是我常用的骨架。再配合第2.2节写的batch_iter,多文件逐行读取、批量处理一条龙就齐了。

3.4 协程的雏形

说到send和yield from,就绕不开协程这个话题。Python协程的演进路径其实很清楚:生成器(yield)→ 带send的生成器(可以双向通信)→yield from委托 →asyncio。从某种意义上说,你现在掌握的双向通信生成器,就是协程的最原始形态。

不过这里要给你一句忠告:如果你想用send写业务逻辑复杂的协程,建议直接学习asyncio或用@contextmanager这类成熟设施,不要自己硬造轮子。生成器的send模式适合做数据管道、状态机这类轻量级场景,不适合当完整的异步框架用。我自己早期试图拿生成器模拟异步IO,写了半天,最后还是换成了asyncio,省下的时间够看三部电影。

4. 实战:把生成器用在刀刃上

4.1 大文件逐行处理:几个GB的日志不再可怕

这是生成器最广为人知的应用,也是我实际受益最大的场景。先给个完整可跑的示例:

def read_large_file(file_path): with open(file_path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if line: yield line def filter_error(lines, keyword='ERROR'): for line in lines: if keyword in line: yield line log_path = 'app.log' error_gen = filter_error(read_large_file(log_path)) for error_line in error_gen: # 每处理一行,这一行在内存里只存在这一次 print(error_line)

这个例子的精妙之处在于管道化:read_large_file每次只从磁盘读一行,filter_error只处理经过的那一行,两个生成器之间几乎不占额外内存。就算日志文件有5GB,内存占用基本恒定在几MB级别。对比开头那段把所有行塞进列表的写法,差距是数量级的。

实际操作中还有两个小经验。第一,文件对象本身在Python里就是迭代器,直接for line in f已经按行读取了,并不需要把所有行读进内存;但如果你在循环里把line攒进列表再处理,内存就上去了。第二,编码统一很重要,我处理旧系统导出的日志时被GBK编码坑过不止一次,建议在open时显式指定encoding='utf-8',遇到乱码再按实际编码调整。

4.2 无限序列与流式场景:斐波那契、滚动ID、心跳数据

生成器和while True简直是天生一对。因为有yield机制,你完全可以定义一个"无穷序列"而不担心内存爆炸。经典斐波那契:

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=' ') # 输出: 0 1 1 2 3 5 8 13 21 34

拿前10个就停,后面无穷无尽的数字一个都不会多算。这个模式还能用在很多实际场景:生成永不重复的滚动ID、读取传感器实时上报的心跳数据、从消息队列里持续拉取任务。核心套路都一样——while True负责无限供给,yield负责按需交付,外部想取多少取多少,想停就停。

还有一种流式处理的典型用法:分块读取二进制文件。比如要逐块计算一个大文件的MD5,用列表显然不现实,生成器分块读取就很稳:

def read_chunks(file_path, chunk_size=8192): with open(file_path, 'rb') as f: while True: chunk = f.read(chunk_size) if not chunk: break yield chunk

每块8KB,读一块、算一块、丢一块,内存占用和文件大小完全解耦。做断点续传、大文件上传、边下边校验这类功能时,这个模式可以直接抄。

4.3 数据管线:把处理步骤串成流水线

把多个生成器串联起来,就构成一条数据处理流水线,这是生成器最优雅的用法。比如一个日志清洗分析的任务,读文件、过滤、解析、聚合四步各写一个生成器,再用for把它们接起来:

def parse_lines(lines): for line in lines: parts = line.split(',') if len(parts) == 3: yield {'time': parts[0], 'level': parts[1], 'msg': parts[2]} def count_level(records): counter = {} for record in records: level = record['level'] counter[level] = counter.get(level, 0) + 1 yield level, counter[level] pipeline = count_level(parse_lines(read_large_file('app.log'))) for level, count in pipeline: print(f'{level}: {count}')

每一步只负责一件事,读不管解析,解析不管统计,改动任何一环都不影响其他环节。想加一个"只统计ERROR"的过滤,往中间插一个生成器就行。这种模块化的好处,用久了你自然体会得到。

我这几年写数据处理脚本的一个心得是:先别急着写一个大函数,试着把流程拆成三五个生成器,再用一层薄薄的for循环串起来。拆出来的每个生成器都能独立测试,出了问题定位很快。相比之下,一个几百行的处理函数里,变量散落各处,调试时只能靠打印加断点,效率完全不在一个层级。

4.4 内存占用实测对比表

为了不让你觉得我是在空谈理论,放一组我之前跑过的对比数据。环境是普通的8G内存笔记本,Python 3.10,目标是对1000万元素求平方和:

实现方式内存占用耗时结论
列表推导式sum([i*i for i in range(10_000_000)])约320MB约1.2秒内存峰值高,速度尚可
生成器表达式sum((i*i for i in range(10_000_000)))约0.5MB约1.0秒内存占用极小,速度反而略快
生成器函数 + for 循环约0.5MB约1.1秒与生成器表达式相当

看到没,生成器不仅省内存,在求和这类场景下速度甚至不输列表。原因是省去了列表反复扩容、分配内存的开销。当然,这不是说生成器在所有场景都更快,如果这个列表后续要被遍历10次,每次现算的开销就大了,那时候用列表缓存更划算。省内存不一定牺牲速度,关键看你选对场景。

5. 常见问题与排查技巧实录

5.1 生成器只能迭代一次,第二次是空的

这是生成器新手翻车率最高的问题。现象是这样的:

gen = (x * 2 for x in range(10)) print(sum(gen)) # 90,正确 print(sum(gen)) # 0,因为生成器已经耗尽

第一次遍历把生成器"烧完"了,第二次遍历到的自然是空。排查思路很简单:在把生成器传给函数之前,先问自己"这个生成器会被遍历几次"。如果只遍历一次,直接传;如果多次,要么在上游用列表包一层,要么每次用的时候重新创建生成器。

我在实际工作中处理过类似的线上故障:一个统计接口第一次调用正常,第二次返回全零。查了半天,最后定位到是有人把生成器存在类属性里,多个请求共享同一个生成器对象,第一个请求消费完,后面的请求全拿到空。修复方式就是把生成器的创建挪到每个请求内部,各用各的。

5.2 生成器耗尽后不报错,也不用重新赋值

配合上一条,很多新手会误以为生成器耗尽后能自动"重新充电"。并不会。它不像文件对象有seek(0)这样的重置方法,一旦耗尽就是终结。如果你确实需要反复读,一个简单的习惯是:把"创建生成器"的代码封装成一个函数,需要数据时就调用函数获取新的生成器。这是最不容易出错的做法。

5.3 生成器内部的状态查看与调试

生成器是"黑盒",外部看不到它内部暂停在哪,调试起来确实比普通函数麻烦。我常用的几个手段:

第一,加打印是最直接的。在yield前后打印日志,能清楚看到每次恢复执行的位置。第二,用第三方库yield_debug或者手写一个包装生成器的装饰器来追踪取值,但说实话,日常调试加几个print就够了。第三,排查内存泄漏时,可以用gc.get_objects()配合inspect.isgenerator看看有没有生成器对象被无意中长时间持有——这通常意味着某个无限生成器没有及时close(),一直在后台空转。

还有一个容易被忽略的点:不要在生成器里包一层没有必要的列表操作。我曾经优化过一段性能问题代码,发现有人在生成器表达式外面又套了一个list(),内存直接回到解放前。生成器的意义就是保持惰性,中途转列表等于前功尽弃。

5.4 别把生成器跟这三个概念搞混:可迭代对象、迭代器、生成器

这三个概念在面试里经常被连环问,实际工作里混淆了也会写出隐性bug。用一张表帮你理清:

概念核心特点典型代表能否重复遍历
可迭代对象(Iterable)实现了__iter__,能被for遍历列表、元组、字典、字符串可以,每次iter()返回新的迭代器
迭代器(Iterator)实现了__iter__和__next__,有状态iter([1,2,3])的返回值、文件对象一般不行,迭代器本身单向
生成器(Generator)用yield或生成器表达式创建,是特殊的迭代器生成器函数返回值、(x for x in ...)不行,用一次就完

判断方法也简单:isinstance(x, Iterator)和isinstance(x, Generator)是两个经常用到的检查函数;isinstance(x, Iterable)要小心,因为实现了__getitem__的旧式类也能算可迭代对象,但判断范围特别宽,容易误判。实际写代码时,记住这条就够了:能用next()的就是迭代器,用yield造出来的迭代器就是生成器。

最后分享一个我自己的习惯:凡是函数返回一堆数据,我默认先写生成器版本,只有明确需要随机访问或多次遍历时才改成列表。这个习惯帮我挡掉了不知道多少次"数据量一上来就内存报错"的尴尬。生成器的学习曲线初期有点陡,但只要把"暂停-恢复"这个状态机想明白,后面所有进阶玩法都是水到渠成。你可以拿第2节的代码亲手跑一遍,观察每一步的输出,比看十篇文章都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 17:53:53

Java大富翁源码:面向对象设计与Swing实战工程

简介&#xff1a;这是一份面向Java初学者与移动开发入门者的经典游戏项目源码&#xff0c;完整实现J2ME平台下的大富翁手机游戏逻辑&#xff0c;涵盖地图渲染、角色移动、地产买卖、骰子判定等核心机制。资源包共89个文件&#xff0c;含16个Java源文件&#xff08;含详细中文注…

作者头像 李华
网站建设 2026/9/26 17:53:50

172页数字化转型蓝图怎么读:从流程、数据到系统落地的完整指南

手里拿着一份172页的PPT&#xff0c;大多数人第一反应是先翻到第40页看流程图长什么样&#xff0c;或者直接拉到最后一页看结论。我做企业数字化咨询和集团管控方案落地这块有些年头了&#xff0c;这两年接触了不少类似的规划文档——其中一份很典型的&#xff0c;是某大型集团…

作者头像 李华
网站建设 2026/9/26 17:53:26

Linux进程管理核心:fork、进程退出与exec函数详解

做Linux系统编程的&#xff0c;一定会撞上这“三座大山”&#xff1a;进程怎么来的、进程怎么没的、进程怎么“变脸”。标题里这组关键词——进程管理、进程结束、exec函数&#xff0c;说白了就是Linux进程从生到死、从A程序变成B程序的完整故事线。我最初啃这块的时候也绕了不…

作者头像 李华
网站建设 2026/9/26 17:53:23

ASP+SQL Server源码合集:从环境搭建到改造排错全攻略

简介&#xff1a;这是一套面向ASPSQL Server开发学习者的实例程序源码合集&#xff0c;涵盖72个常见Internet应用系统与模块&#xff0c;如商城管理、用户注册、数据库连接等&#xff0c;适合新手入门及有一定经验的开发人员参考借鉴。包内共840个文件&#xff0c;主体为382个a…

作者头像 李华
网站建设 2026/9/26 17:52:56

Python实现PSO-KNN光伏功率预测:粒子群自动寻优K与P的完整工程实战 从数据清洗、时间特征、严格时序验证,到PSO参数搜索、KNN回归、误差诊断与可部署改进

Python实现PSO-KNN光伏功率预测&#xff1a;粒子群自动寻优K与P的完整工程实战从数据清洗、时间特征、严格时序验证&#xff0c;到PSO参数搜索、KNN回归、误差诊断与可部署改进Python 光伏功率预测 粒子群优化 PSO K近邻 KNN 机器学习 时间序列预测 新能源 智能电网光…

作者头像 李华