ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python迭代器与生成器详解:从for循环原理到内存优化实战

Python迭代器与生成器详解:从for循环原理到内存优化实战

做了十多年 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 真正值得你带走的东西。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进