做了十多年 Python 开发,带过不少新人,我发现一个特别有意思的现象:很多人学到for循环就觉得自己会遍历了,学到列表推导式就觉得已经“Pythonic”了,但一碰到大文件读取、数据流处理、或者写稍微复杂一点的业务逻辑,代码就卡壳了。问题出在哪?大概率是没真正搞懂迭代器和生成器。今天这个day18,咱们就把这块硬骨头啃下来。这篇文章不是贴文档,而是把我自己从“会写循环”到“理解迭代本质”过程中那些踩过的坑、想通的原理,以及实际项目里真能派上用场的技巧,一次讲清楚。
学完这篇,你至少能明白三件事:for循环底层到底在折腾什么;怎么用生成器写出省内存、可读性还高的代码;以及当面试题里出现“讲讲迭代器和生成器的区别”时,你能从执行机制层面说出一二三,而不是背定义。
1. 为什么day18要讲迭代器——for循环背后的秘密
1.1 迭代协议与for循环的执行顺序
大多数教程讲迭代器时会抛出两个方法:__iter__和__next__。太抽象,我换个说法。你写一个for x in something,Python 解释器其实没干别的,它就做了三件事:
第一,调用something的iter()方法,拿到一个迭代器对象。第二,在一个无限循环里反复调用这个迭代器的__next__(),每调一次,取一个值。第三,一旦__next__()抛出StopIteration异常,就说明没货了,for循环正常结束。
你可以在这张表里看到for循环的伪代码和while版本的对照:
# for 循环内部逻辑的伪代码 iterator = iter(something) while True: try: item = next(iterator) except StopIteration: break # 循环体所以,列表、元组、字典、集合、字符串能被for遍历,不是它们本身有多特殊,而是它们都实现了迭代器协议。写业务代码时你不必天天手动next(),但理解这个执行顺序以后,遇到那些“遍历到一半被修改了”“迭代器只能走一次”的诡异问题,你就有排查方向了。
1.2 手写一个迭代器类的完整过程
光懂协议还不够,我建议你亲手写一个迭代器类,体验会完全不同。下面这个例子是一个“步进式”迭代器,模拟从某个起始值开始,每次按指定步长递增,最多产生指定数量的值:
class StepIterator: def __init__(self, start, step, total): self.current = start self.step = step self.total = total self.count = 0 def __iter__(self): return self def __next__(self): if self.count >= self.total: raise StopIteration value = self.current self.current += self.step self.count += 1 return value # 使用示例 for num in StepIterator(10, 2, 5): print(num) # 10, 12, 14, 16, 18关键在于__iter__返回自身,__next__每次返回一个值并且在结束时抛异常。这里有两个特别容易踩的坑:
第一个坑:__iter__必须返回一个迭代器对象,而不是返回self之外的别的东西,否则iter()时会报错。第二个坑:如果你只实现了__next__而忘了__iter__,在某些场景下把对象当可迭代对象传入iter()时会提示它不是可迭代对象。
不过说实话,日常开发里很少需要自己写迭代器类,因为生成器把这件事简化了太多。但理解这个类的结构,是后面理解生成器的基础,值得花时间亲手敲一遍。
2. 生成器:比迭代器更优雅的惰性求值方案
2.1 生成器函数与yield的执行暂停机制
如果说迭代器类是手动的“状态机管理”,生成器函数就是自动挡。区别就在一个关键字:yield。普通函数遇到return就彻底结束,而生成器函数遇到yield会当场“冻结”:保存当前所有局部变量的状态、当前执行到的行号,然后返回等号右侧的值。
用一个经典例子来看:
def simple_generator(): print("第一次 next 执行") yield 1 print("第二次 next 执行") yield 2 print("第三次 next 执行") yield 3 gen = simple_generator() print(next(gen)) # 第一次 next 执行 然后输出 1 print(next(gen)) # 第二次 next 执行 然后输出 2 print(next(gen)) # 第三次 next 执行 然后输出 3每一步next()实际上会执行函数代码,直到碰到下一个yield又暂停。以前我总是把yield理解成“return 但不断气”,后来我发现更好的类比是把它想象成一台自动贩售机:你看得到货架上有什么(返回值),但投币一次只掉一瓶出来,投一次币机器动一次。
2.2 生成器表达式与列表推导式的差异
列表推导式大家都熟:
squares_list = [x * x for x in range(1000000)]如果你把它改成小括号,就得到生成器表达式:
squares_gen = (x * x for x in range(1000000))两者最终遍历结果没差别,但底层差别非常大。列表推导式是一次性创建一个包含 100 万个元素的列表,内存占用可能是几十 MB。生成器表达式则是懒洋洋的,一个元素一个元素地现算现给,内存占用几乎可以忽略。
我实际统计过,跑一百万级别的平方计算,列表推导式的峰值内存比生成器表达式高出不少,这个差距在数据量继续翻倍时会被放大得很夸张。你不需要做基准测试就能感受到:写代码时能直接改造成生成器表达式的地方,尽量别用列表。唯一的例外是你必须反复遍历同一份数据,或者要做随机访问,那种场景才该用列表。
2.3 惰性求值带来的内存红利
很多人问,生成器省内存到底省在哪?举个例子,读取一个 5GB 的日志文件,如果用read()全部读进内存,机器基本就跪了。如果按行处理,传统方式写:
with open("huge_log.txt", encoding="utf-8") as f: for line in f: process(line)其实这就是惰性求值。文件对象本身就是一个迭代器,它并不是一次把整个文件加载完,而是按需读取下一行。这就是生成器思想在生产环境中常见的形态之一。当你把这种“按需计算”的思路应用在你自建的函数上,就不再受限于外部工具是否提供了惰性接口,自由度和复用性都会提升。
3. yield from:子生成器委托为什么值得掌握
3.1 嵌套循环的痛点和yield from的写法对比
假设你要写一个函数,把多个序列里的元素依次产出。初学者的写法通常是两层循环加yield:
def chain_old(*iterables): for it in iterables: for item in it: yield item这个写法没错,但 Python 3.3 之后提供了yield from,能更直接地表达“让子生成器替我更高效地干活”的意图:
def chain_new(*iterables): for it in iterables: yield from it区别看起来只是少了一层循环,但yield from不只是语法糖。它把“外层生成器”和“内层生成器”建立了真正的委托关系:外层生成器会内联执行内层生成器,直到内层耗尽,再继续外层的代码。更关键的是,yield from还能处理两个生成器之间的双向通信,这一点是普通嵌套写法做不到的。
3.2 yield from与send传值的联动机制
这里是一个不懂yield from就很难想通的现象。生成器的send(value)方法可以把值发回生成器内部,让它成为当前yield表达式的值。普通写法里,外层生成器想替内层生成器接收send的值,得手动处理,但yield from会自动帮你把外面发进来的值传给内层的yield。
举个例子:
def inner(): received = yield "inner ready" print("inner received:", received) yield "inner done" def outer(): yield "outer start" result = yield from inner() yield result gen = outer() print(next(gen)) # outer start print(next(gen)) # inner ready print(gen.send(100)) # inner received: 100 然后输出 inner done你可以清清楚楚地看到,outer里没有做任何转发处理,send(100)发进来的值就直接进了inner的received。这就是“委托”的真正含义。写异步代码时,这种自动传递机制极其重要。
4. 生成器不只是“迭代”——send、throw、close与协程基础
4.1 send方法实现数据双向传递
很多人用生成器只会next(),其实生成器还提供了一套和外部交互的接口。send(value)是把值送进生成器内部。但有一个新手几乎必踩的坑:第一次调用生成器时,不能send一个非None的值进去,因为此时生成器还没执行到第一个yield,没有“当前位置”来接收发进去的数据。
正确的启动顺序是:要么先next(gen),要么先gen.send(None)。看一个示例:
def echo(): while True: received = yield print("echo:", received) gen = echo() next(gen) # 让生成器跑到第一个 yield 处等待接收 gen.send("hello") # echo: hello gen.send(123) # echo: 123这里yield右侧没有值,所以如果只调用next()取到的结果是None,它的主要作用就是作为接收数据的“邮箱口”。这种“在暂停点收信”的模式,就是协程的雏形。
4.2 生成器的状态管理与throw/close使用场景
生成器还有两个方法:throw(type, value, traceback)用来在生成器内部当前暂停点抛出指定异常;close()用来在暂停点抛出GeneratorExit,让生成器有机会做一些释放资源的处理。
close()的典型场景是:一个生成器正在无限循环产出数据,外部决定不再需要了,再继续从内存里挂着它的栈帧就不划算了。调用close()后,生成器进入closed状态,后续再next()会直接抛出StopIteration。你还可以用生成器的gi_running、gi_frame、gi_suspended等属性判断它的状态。调试协程代码时这些属性非常有用。
有一个需要注意的细节:如果生成器函数里有try/finally结构,调用close()时会执行finally中的清理动作。所以如果你在生成器里持有了文件句柄或网络连接,finally里记得关掉。
5. 实战演练:基于生成器的数据管道设计
5.1 读取超大文件时逐行处理的关键设计
讲了那么多机制,最后还是得落到实战。我建议你亲手写一套“数据管道”,因为这是生成器用途最广泛的生产级场景之一。
假设有一个很大的 CSV 文件,你需要读取它,过滤掉空行,去掉表头,再对每一行做字段解析。用生成器把它拆成三段:
def read_lines(file_path): with open(file_path, encoding="utf-8") as f: for line in f: yield line.rstrip("\n") def filter_non_empty(lines): for line in lines: if line.strip(): yield line def parse_columns(lines, delimiter=","): for line in lines: if line.startswith("#"): continue yield line.split(delimiter) for row in parse_columns(filter_non_empty(read_lines("data.csv"))): print(row)这种写法的好处是显而易见的:每个生成器只负责一件事,组合起来以后,数据是“流式”地穿过整个管道。这里不会出现“第一段处理完整个文件,把结果存成列表,再传给下一段”的中间存储膨胀。大数据量下,这是性能和可维护性兼顾的方案。
5.2 无限序列生成器的经典案例
讲了内存红利,我再讲一个“无限序列”的场景。普通列表显然没法表达“所有偶数”,但生成器可以:
def even_numbers(): num = 0 while True: yield num num += 2 evens = even_numbers() for _ in range(5): print(next(evens)) # 0 2 4 6 8无限并不等于没完没了,关键在于“按需取用”。你可以写一个take(iterator, n)工具函数,只取前 n 个然后停下,这样就能安全地使用无限序列了。
我把这个思路用在一个模拟数据生成器里:需要造测试数据时,调用一个可以无限产出“日期+随机值”的生成器,配合take()函数想取多少条就取多少条。这个模式在测试、填充缓存、模拟流式数据时非常常用。
5.3 用生成器重构日志解析程序
再举一个我真实重构过的小程序。早期我写了一个日志解析脚本,逻辑是读取整个日志文件到列表,然后多次遍历列表来提取不同信息。文件小的时候没什么感觉,文件一上 GB 程序就变得很慢。
重构的思路是把“从日志中提取事件”变成一个生成器函数,只产出符合关键字的行,后续的分析动作再从这些行里做。重构前的内存峰值和重构后的差别非常明显。这种重构不需要改变业务逻辑,只是把“全量加载”改成“边读边筛”,程序的可扩展性立马不一样。如果你手头也有类似的“一把梭”读文件脚本,我强烈建议按这个思路改一版试试。
6. 我踩过的生成器坑与调试建议
6.1 误把生成器当列表:一次性消耗问题
生成器最大的认知陷阱就是“它是一次性的”。列表可以反复遍历,生成器走完一遍就空了。刚开始用生成器的时候,我经常这样写:
gen = (x for x in range(10)) print(sum(gen)) # 45 print(sum(gen)) # 0,以为还能再求和第一次sum之后生成器已经耗尽,第二次sum结果自然为 0。这个坑特别隐蔽,尤其是当你的生成器在一段很长的代码里被传递多次,你根本想不起来它已经被“消费”过了。
解决思路有三种:如果你确实需要反复遍历同一批数据,用列表;如果数据太大只能走一次,请把逻辑合并到一次遍历中完成;如果只是调试,可以用itertools.tee()复制出几个独立的迭代器,但要注意它会缓存元素,所以别用在大数据量场景。
6.2 循环内创建生成器的延迟绑定陷阱
另一个常见的坑和 Python 的闭包、延迟绑定有关系。看这个例子:
gens = [] for i in range(3): gens.append((x for x in range(i))) for gen in gens: print(list(gen)) # 期望 [0], [0,1], [0,1,2],实际呢?实际输出会让你怀疑人生,因为生成器表达式里的i是惰性求值的。循环结束后,i的值已经变成了 2,三个生成器全部按range(2)生成。正确写法是在生成器表达式后面或者参数里把i绑定为默认值:
gens.append((x for x in range(n)) for n in [i])或者干脆定义一个工厂函数:
def make_gen(n): return (x for x in range(n)) gens = [make_gen(i) for i in range(3)]这种问题不只是生成器专属,闭包里引用循环变量都会有类似风险,但生成器的惰性特性会让它更难察觉。
6.3 调试生成器暂停点的实用工具
生成器函数里如果出现了 CPU 占用高、执行顺序诡异的问题,别干瞪眼看代码。常见处理手段有几个。
第一个是手动加打印,但要注意打印语句本身可能会改变程序行为,尤其是在生产代码里。第二个是使用日志模块,记录进入生成器、产出每个值、生成器结束等节点。第三个是利用inspect模块的getgeneratorstate(),它会返回GEN_CREATED、GEN_RUNNING、GEN_SUSPENDED、GEN_CLOSED四种状态之一,帮你判断生成器当前到底卡在哪一步。
如果你想快速观察一个生成器的产出序列,别写一堆next(),直接list(gen)只看少量数据时很方便,但要注意这会让生成器耗尽。我通常写一个take(gen, n)工具函数来取前几个元素调试,好用且不破坏后续流程:
from itertools import islice def take(iterable, n): return list(islice(iterable, n))理解了这些坑以后,你再看生成器相关的代码,心态会明显不一样。它不是一个“高级技巧”,而是 Python 里很基础的一种思维方式:数据是流淌的,计算是按需的,程序是分段的。许多年后你可能不记得yield from的语法细节,但你会一直记得“把大问题拆成小管道,让数据流过去”这个思路。这就是 day18 真正值得你带走的东西。