最近有一次处理线上日志,我差点被内存打爆。脚本本身并不复杂:读一个很大的日志文件,按正则过滤后统计Top IP。我图省事,先用列表推导式把所有行全塞进内存,结果进程跑到一半被系统杀掉了。后来改成生成器表达式,同样的逻辑内存占用直接掉了一个量级。今天这篇Python从入门到精通day46,我想把背后的迭代器和生成器彻底讲透。读完你会明白 for 循环为什么能遍历 list、dict 和自定义类,yield 究竟保存了什么,以及 send、throw、yield from 这些进阶技能在真实工程里的价值。这篇适合已经掌握 Python 基础语法、但对“可迭代对象和迭代器”一直一知半解的读者,读完后你会有一种打通任督二脉的感觉。
1. 迭代协议:for 循环凭什么“万物皆可遍历”
1.1 可迭代对象与迭代器:两个名字,两种责任
Python 里有个常见的误会:很多人觉得 list、dict、set 都是迭代器。不对,它们是可迭代对象。这两个词在语言规范里有明确分工。
- 可迭代对象(iterable):实现了
__iter__(),调用它返回一个迭代器;或者实现了__getitem__()支持按索引取元素,也会被iter()识别。 - 迭代器(iterator):实现了
__next__(),每次调用返回“下一个值”,没有更多值时抛出StopIteration。同时迭代器通常也实现了__iter__(),但它的__iter__()返回自身。
你可以把可迭代对象理解成“一个装了很多球的袋子”,迭代器则是“伸进袋子里一个一个往外掏球的手”。袋子只负责提供这只手,掏球的动作全在手这里。
我经常给别人看这个自定义类:
class NumberRange: def __init__(self, start, end): self.start = start self.end = end def __iter__(self): return NumberRangeIterator(self.start, self.end) class NumberRangeIterator: def __init__(self, start, end): self.current = start self.end = end def __iter__(self): return self def __next__(self): if self.current >= self.end: raise StopIteration value = self.current self.current += 1 return value这样写:
for i in NumberRange(1, 5): print(i)输出 1 到 4。看起来和内置range差不多,但关键是:NumberRange是容器,它自己不记录“当前走到哪了”;真正记录进度的是NumberRangeIterator。两份职责分开,容器可以反复__iter__()生成新的迭代器,而迭代器自身有状态,一遍跑完就废了。
Python 之所以用“协议”而不是强制继承某个抽象基类,是因为它更信任人的自觉:你只要把这个两个魔法方法实现到位,不管你的类从哪继承、是不是标准库类型,for循环都认。这就是鸭子类型的好处。
1.2 用 while 手写一个 for:看穿循环执行顺序
很多人看 for 循环,以为它在“遍历容器”。其实 for 循环的底层动作是这样的:
- 调用
iter(obj),拿到一个迭代器; - 不断调用
next(iterator)取下一个值; - 捕获
StopIteration后结束。
用 while 还原一下,你会看得更清楚:
def manual_for(iterable, func): iterator = iter(iterable) while True: try: item = next(iterator) except StopIteration: break func(item) manual_for([10, 20, 30], lambda x: print(x * 2))这个函数看起来只是教学演示,但它解释了为什么很多靠“位置索引”遍历的写法不是 Pythonic 的。比如你写:
i = 0 while i < len(lst): print(lst[i]) i += 1这只适用于支持索引访问的类型。如果传给它的是一台设备产生的数据流、一个数据库游标、一个网络 socket 的读结果,根本没有len,也没有下标,这样写立刻崩。而for item in something只依赖iter(),通用性高得多。
还有个容易被忽略的点:iter()对实现了旧版本序列协议的类也能工作。你只要在类里写出__getitem__,Python 会自动生成一个默认迭代器,不断从 0 开始按索引取元素,直到抛出IndexError为止。
class OldStyleSeq: def __init__(self, data): self.data = data def __getitem__(self, index): return self.data[index]for x in OldStyleSeq("abc"):同样能跑。这是历史包袱带来的兼容能力,也说明迭代协议真的不挑剔。
2. 生成器函数:一个 yield 如何保存整个执行现场
2.1 函数里出现 yield,一切就不一样了
普通函数返回一个值,执行完就结束了。但如果你的函数体里出现yield关键字,Python 会把这个函数变成一个生成器函数。注意,生成器函数被调用时不执行任何函数体代码,它只是返回一个生成器对象。
def countdown(n): print("启动倒计时") while n > 0: yield n n -= 1 print("倒计时结束") gen = countdown(3)上面这行gen = countdown(3)不会打印“启动倒计时”。只有当你执行next(gen)时,代码才会真的运行,直到撞上第一个yield,函数暂停并把n的值交出来。
print(next(gen)) # 打印 启动倒计时,然后输出 3 print(next(gen)) # 输出 2 print(next(gen)) # 输出 1 print(next(gen)) # 打印 倒计时结束,然后抛 StopIteration暂停究竟暂停了什么?是整个函数执行现场:当前执行到哪一行、所有局部变量的值、内部异常状态、循环迭代到的位置,全都被保存在生成器对象里。你下一次next时,不是重新执行一遍,而是从上次yield的下一行继续走。
这有点像打游戏时的存档:不是从开头重玩,而是从存档点继续。Python 在 C 层面维护了生成器的gi_frame,里面挂着完整的栈帧信息。甚至你可以在外部观察:
frame = gen.gi_frame print(frame.f_locals) # 能看到当前局部变量字典不过别在生产代码里依赖这个,它是调试用的底层能力。
2.2 惰性求值:省下的不是一点点内存
生成器最核心的意义是惰性求值:它不会提前把整个序列算好,而是你每次向它要一个值,它才计算一个。
最容易对比的例子是列表推导式和生成器表达式:
# 列表推导式:一次性生成 100 万个整数,全在内存里 nums_list = [x * x for x in range(1000000)] # 生成器表达式:什么都不计算,只产生一个生成器对象 nums_gen = (x * x for x in range(1000000))你想想这中间的差异。nums_list会立即创建 100 万个平方整数,占用的内存随着 n 线性增长;而nums_gen的生成器对象几乎只占几十字节,因为此刻一个平方数都还没算。直到你next它,才懒洋洋地算出第一个0。
我用sys.getsizeof看过同一条数据在两边的占用差异。拿 1000 万个整数来说,列表对象本身可能已经到上百兆字节(具体看编译环境),而生成器永远是个常数级别的身材。当然生成器并不总是免费的:每一次取值的函数切换有开销,如果序列很短,直接用列表反而更快。关键权衡是这样——数据规模越大、中间环节越多,生成器越值得用。
还有一个很多人踩过的误区:生成器是有状态的,它只能被完整遍历一次。一旦跑到底,它就空了,再for一遍什么都不会得到。后面我会专门展开讲这个坑。
3. send、throw、close 与 yield from:生成器不止是“惰性列表”
3.1 send:把数据送进挂起点
先看一个简化版累加器。它用一个无限循环不断接收外部传入的数,并返回当前总和:
def accumulator(): total = 0 while True: value = yield total if value is None: continue total += value注意yield total这一句:先产出total给调用方,然后函数挂起。调用方除了可以next,还能通过send(value)把值送进去,这个值会成为yield表达式的返回值,也就是赋给value。
acc = accumulator() next(acc) # 必须先“启动”到第一个 yield print(acc.send(10)) # total 变成 10,输出 10 print(acc.send(20)) # total 变成 30,输出 30第一次调用时千万不要直接send(100),因为生成器还没运行到yield挂起点,没有可以接收数据的表达式,Python 会抛TypeError: can't send non-None value to a just-started generator。要么先next(acc),要么用acc.send(None)完成预启动。
用生活场景打比方:yield就像一个双向窗口,生成器往外递出产品,同时等待调用方从同一窗口塞进原材料。
这个能力让生成器变成了“协程的雏形”。你不用创建线程,也不需要额外的消息队列,就能在单线程内交替执行两个任务。很多老项目里的简易生产者-消费者模型就是靠send写出来的。
3.2 throw 与 close:异常和资源清理的出口
如果调用方发现生成器产出的数据不对劲,可以向生成器内部扔一个异常:
gen = countdown(3) next(gen) try: gen.throw(ValueError("数据出错了")) except ValueError as e: print("在外部捕获:", e)throw会在生成器当前挂起点抛出异常。如果生成器函数体内没有try/except处理它,异常会顺着挂起点传给调用方。如果生成了器内部捕获并处理了,生成器还可以继续运行。
close则是另一种“温柔”的结束方式:
gen.close()它不会抛StopIteration,而是在挂起点抛一个GeneratorExit。这个异常目的就是让生成器有机会执行finally清理资源。正确用法是配合try/finally:
def watch_file(): try: while True: line = yield print(line) finally: print("清理资源:关闭文件句柄、释放锁")当你close()时,finally一定会跑。这比不管不顾地让生成器被垃圾回收再清理要更可控。尤其是生成器里握着文件、网络连接、锁等对象时,记得给close()一个明确出口。
3.3 yield from:把子生成器委托出去
yield from是 Python 3.3 引入的语法,解决一个非常常见的需求:你写了一个生成器,里面要遍历另一个生成器的全部值,最直接的写法是:
def outer(): for x in inner(): yield x但手写这种转发又啰嗦又容易漏掉异常传播。yield from直接把整个迭代过程委托给子生成器:
def inner(): for i in range(3): yield i * 10 def outer(): yield from inner() yield -1list(outer())会得到[0, 10, 20, -1]。yield from不只是普通的削糖,它还会自动处理子生成器的StopIteration,并且把子生成器return的返回值带回来:
def sub(): yield 1 return 99 def wrapper(): result = yield from sub() print("子生成器返回:", result) list(wrapper())调用方通过send()传进来的数据,也会自动传递到子生成器的当前挂起点;反过来子生成器里的异常也会直接抛到调用方。这就像给两个生成器之间开了一条双向直达通道。
yield from写文件合并逻辑特别舒服。假设你有多个日志文件要按顺序读取所有行:
def read_all_lines(*paths): for path in paths: with open(path, encoding="utf-8") as f: yield from f这个生成器把“打开一个文件、逐行产出、自动关闭”整个流程封装在子区块里,外部for line in read_all_lines(a, b, c):就能一口气遍历完所有文件。这里用yield from f而不是for line in f: yield line,代码更短,异常和资源交接也更规整。
4. 高频实战:大文件、无限流与 itertools 组合拳
4.1 大文件逐行读取:从内存危机到管道式处理
回到开头那个日志脚本。最危险但是我见过很多人写的写法是:
all_lines = open("access.log", encoding="utf-8").readlines()readlines()会把整个文件按行读进一个列表,文件多大,内存就涨多大。遇到一个几 GB 的日志文件,救不救得回来全看运气。
正确的核心其实很简单:文件对象本身就是一个可迭代对象,for line in f已经是逐行惰性读取,文件系统会按需把内容读到内存里。真正破坏它的是你后面又用了集合、列表、字典去囤积中间结果。
举例,我通常会把处理写成一段段生成器,像管道一样串起来:
def read_log_lines(path): with open(path, encoding="utf-8") as f: for line in f: yield line def filter_error(lines): for line in lines: if "ERROR" in line: yield line def extract_fields(lines): for line in lines: parts = line.split() if len(parts) >= 6: yield parts[3], parts[0] # 时间、IP path = "access.log" for ts, ip in extract_fields(filter_error(read_log_lines(path))): print(ts, ip)每一层接收一个可迭代对象,产出一个“加工后”的可迭代对象。全程没有一个完整的中间列表,所有数据按行流经管道,内存占用和文件总大小无关,只和单个处理行相关。我再强调一遍:真正的内存杀手往往不是源文件本身,而是你为了“方便处理”构建的中间容器。生成器管道的核心原则是“每一层只消费一次,产出一个新的一次性可迭代对象”。
4.2 无限序列与 itertools 的黄金搭档
生成器没有“必须有限”的约束,所以你可以写出无限序列:
def fib(): a, b = 0, 1 while True: yield a a, b = b, a + b直接for x in fib():会永远跑下去。想要取前 10 个,用itertools.islice截断:
from itertools import islice first_10 = list(islice(fib(), 10)) print(first_10)itertools标准库专门为这种玩法准备了一堆工具。我偶尔会用到这些:
count(start, step):从 start 开始无限递增;cycle(iterable):无限循环重复一个可迭代对象;chain(*iterables):把多个可迭代对象拼成一个;tee(iterable, n):把一个生成器复制成 n 个互相独立的迭代器;groupby(iterable, key):按 key 对相邻元素分组。
有一个常见需求是“从无限流里取滑动窗口”,islice加上count就能做:
from itertools import count, islice for i in islice(count(10, 3), 5): print(i) # 10 13 16 19 22islice是无限序列的刹车片,没有它谁敢用无限生成器?还有一个我强烈建议掌握的是tee。有人问:“同一份生成器数据,我又想统计总数,又想看前 5 行样本,怎么办?”如果先遍历一遍统计总数,生成器就空了;先取样本,后面数量又不对。tee可以复制出两个一摸一样的迭代器,你分别消费就行:
from itertools import tee src = (x for x in range(10)) a, b = tee(src) print(sum(1 for _ in a)) print(list(b))但要小心,tee内部会为未消费的数据排队缓存,如果原始数据量巨大、两个副本遍历速度差很多,缓存可能把内存吃掉。它不是万能克隆,只是延迟缓冲区。
4.3 性能对比:什么时候必须换列表
有人看了这么多生成器优点后,开始把所有地方都改成生成器。我先泼一盆冷水:生成器不是免费午餐,数据量小时它不一定更快。下面这张表是我自己跑过的直观感受,不代表所有环境,但方向是一致的:
| 做法 | 内存占用 | 创建速度 | 适用场景 |
|---|---|---|---|
| 列表推导式 | 随元素个数线性增长 | 快,但要一次性构造完整列表 | 需要多次遍历、随机访问、取长度 |
| 生成器表达式 | 基本恒定 | 创建瞬间完成 | 只消费一次、数据规模很大 |
| 生成器函数 | 基本恒定 | 创建瞬间完成 | 逻辑复杂需要多段处理、无限序列 |
如果你只需要把 100 个数字过滤一遍,列表推导式显然更方便:可以len(result)、可以反复用、看代码更直观。但如果输入源是几千万元组,生成器在内存上的优势就是决定性的。
我自己的筛选标准很主观,但很好用:问自己“这个数据源我会不会遍历第二次?”如果答案是不会,并且数据规模大——生成器;如果我会反复查询、切片、取长度——列表。还有第三类情况:数据太大,即使想存成列表也存不下——只能生成器,然后用tee或者落盘文件来做临时副本。
5. 生成器调试与常见坑:踩过以后才记住的细节
5.1 一个生成器被消费两次,第二次为什么是空的
这是我在真实代码 review 里见到最多的问题:
gen = (x for x in range(5)) print(list(gen)) # [0, 1, 2, 3, 4] print(list(gen)) # []第一遍输出完整列表,第二遍什么都没有。原因很直接:生成器内部指针已经走到结尾,StopIteration被 for 循环静默处理掉了。可问题在于,很多人的代码里,这个生成器并不是同一个变量连续用两次,而是经过函数参数传递、多层管道后,某个中间环节把“同一条流”消费掉了,导致后面环节拿到的为空。
排查这种问题有个笨但有效的办法:在下游消费前,把生成器看成一个一次性资源,不要把它当成可以重复播放的磁带。如果确实需要两遍消费,要么在每个函数调用时重新创建生成器,要么用tee复制,要么一开始就返回列表。
我重构过的项目里就有这个例子:原本一个函数把“过滤后的日志行生成器”传给两个统计函数,第一个统计函数消费完,第二个统计结果永远是 0。后来把生成器改成每个统计函数内部各自从原始日志源重新构建管道,问题立刻消失。这不算程序 bug,更像是“资源生命周期”设计错误。
5.2 没有 len、不要随便 next、for 里 break 后别忘了 close
三个高频小毛病,一起说。
第一,生成器没有len。常常有人对着生成器写if len(gen) > 10:然后看见TypeError: object of type 'generator' has no len()。想统计生成器有多少项,只能用sum(1 for _ in gen),但注意这会消耗它。如果之后还需要元素,记得先复制或用列表。
第二,next()的边界。当生成器耗尽后继续next(gen)会抛StopIteration。如果只是想安全地取第一个值,可以给默认值:
first = next(gen, None)这在解析外部输入时特别有用,比如:某个配置流可能为空,也可能有值。
第三,for循环里break退出并不会自动close()。比如你准备遍历一个无限序列,找到目标后break,此时生成器还活得好好的,内部可能持有文件句柄。建议把它包在try/finally里,或者break后手动gen.close()。否则 C 层面可能直到垃圾回收才清理,资源占用会拖累整个程序。
举个真实例子:有人写了一个定时从设备读取数据的生成器,内部持有网络连接。某次跑批任务中途break后,连接没被关闭,进程没有退出,反复重试时端口全被占满。后来在finally里补了gen.close(),问题才消失。这不算底层 bug,而是很多人对生成器的生命周期认识太浅。
5.3 一次真实的排查过程
我印象很深的一次问题:一个数据采集任务,某天开始统计结果突然少了一部分,而且不是固定少的,每天漏的数据都不一样。
追查过程很有意思。先看统计逻辑,发现数据源是一个生成器管道,分三步:读取原始行、按关键词过滤、把结果喂给统计函数。统计函数内部处理完一批后,还会调用一次len(cache)查看缓存条数,cache 是从同一个生成器的islice(gen, 100)里取出来的片断。
问题就出在islice上。islice(gen, 100)不是把生成器复制一份,而是基于同一个生成器继续往后取。也就是说,统计函数第一次取 100 条样本时,原始生成器已经被往前推了 100 条;后续全量统计从第 101 条开始,前面的 100 条数据永远不会进入统计结果。每天漏的数量不固定,是因为被break或next推进的步数不一样。最后我让统计函数改成维护自己的独立生成器,或者先tee复制,数据就完全对齐了。
这件事给我的教训是:生成器不是“只读的序列”,你每一次消费它,状态都在变化。把它当变量传来传去,尤其要小心。
最后分享一个我自己的判断习惯:先确认数据会不会被遍历第二次。只消费一次且规模大的,优先生成器;需要随机访问、需要 len、需要来回看,就用列表。别为了展示“我会用生成器”就把简单问题复杂化。如果你看完这篇觉得意犹未尽,强烈建议在 REPL 里把send和yield from的例子各跑一遍,再打断点看gi_frame里的局部变量,那种“原来如此”的感觉比我解释十遍都管用。