ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

Python生成器详解:从yield原理到内存优化实战 做Python开发这几年生成器这个东西我是越用越觉得香。刚开始学的时候教程里只写了一句生成器是一种一边循环一边计算的机制当时没当回事直到后来做数据清洗一个几个GB的日志文件差点把服务器内存撑爆才老老实实回头把这块啃透。今天这篇就把Python生成器从原理到实战到踩坑一次性讲清楚。生成器Generator是Python里一种特殊的迭代器核心价值就八个字延时计算按需取值。它不一次性把数据全部装进内存而是每次要一个、算一个、给一个。适合谁看刚学完列表推导式、开始碰真实数据的新手以及写了几年代码但遇到大数据量依然靠列表硬扛的老手都值得花半小时把这套机制整明白。下面全部用我实际跑过的代码说话。1. 生成器到底解决了什么问题1.1 从迭代器说起为什么需要懒加载要理解生成器得先搞清楚迭代器Iterator这层关系。Python里凡是能用for循环遍历的东西比如列表、元组、字典、字符串都叫可迭代对象Iterable。但列表这类容器是急性子你创建它的那一刻所有元素就已经全部躺在内存里了。举个真实例子。我有一次要处理一份电商订单明细文件大概2.3GB用常见的做法先读进列表orders [] with open(orders.log, r, encodingutf-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, size100): 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 from3.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/finallyfinally块会在关闭时执行这正好用来释放文件句柄、关闭数据库连接等资源。这个特性在我写带状态的长连接处理时特别有用比在外部手动记状态干净得多。顺带提醒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, encodingutf-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, encodingutf-8) as f: for line in f: line line.strip() if line: yield line def filter_error(lines, keywordERROR): 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时显式指定encodingutf-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_size8192): 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节的代码亲手跑一遍观察每一步的输出比看十篇文章都管用。
返回列表