
做了十多年 Python 开发带过不少新人我发现一个特别有意思的现象很多人学到for循环就觉得自己会遍历了学到列表推导式就觉得已经“Pythonic”了但一碰到大文件读取、数据流处理、或者写稍微复杂一点的业务逻辑代码就卡壳了。问题出在哪大概率是没真正搞懂迭代器和生成器。今天这个day18咱们就把这块硬骨头啃下来。这篇文章不是贴文档而是把我自己从“会写循环”到“理解迭代本质”过程中那些踩过的坑、想通的原理以及实际项目里真能派上用场的技巧一次讲清楚。学完这篇你至少能明白三件事for循环底层到底在折腾什么怎么用生成器写出省内存、可读性还高的代码以及当面试题里出现“讲讲迭代器和生成器的区别”时你能从执行机制层面说出一二三而不是背定义。1. 为什么day18要讲迭代器——for循环背后的秘密1.1 迭代协议与for循环的执行顺序大多数教程讲迭代器时会抛出两个方法__iter__和__next__。太抽象我换个说法。你写一个for x in somethingPython 解释器其实没干别的它就做了三件事第一调用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, encodingutf-8) as f: for line in f: process(line)其实这就是惰性求值。文件对象本身就是一个迭代器它并不是一次把整个文件加载完而是按需读取下一行。这就是生成器思想在生产环境中常见的形态之一。当你把这种“按需计算”的思路应用在你自建的函数上就不再受限于外部工具是否提供了惰性接口自由度和复用性都会提升。3. yield from子生成器委托为什么值得掌握3.1 嵌套循环的痛点和yield from的写法对比假设你要写一个函数把多个序列里的元素依次产出。初学者的写法通常是两层循环加yielddef 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, encodingutf-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 真正值得你带走的东西。