资讯动态

Python迭代器与生成器实战:彻底搞懂for循环与惰性求值

发布时间:2026/10/13 18:24:09 来源:尧图企业网站定制
说实话用Python写了几年代码我见过不少人能把列表推导式、装饰器、闭包聊得头头是道但一被问到for循环到底是怎么遍历一个字典的就突然卡壳了。不是他们水平不行而是我们平时太习惯把for x in something当成一个原子语法就像空气一样自然自然到没人会去琢磨它背后还有什么机关。但迭代器恰恰是Python里最值得花半小时搞明白的机制之一。你理解了它就同时理解了生成器、推导式、map/filter甚至文件读取、zip、enumerate这些日常操作因为它们在底层全部走的是同一条路。这篇文章我就把这个隐藏的发动机拆开给你看。1. 从一个反直觉的问题说起for循环凭什么能遍历一切先做个实验。下面这几种数据形态完全不同但都能丢进同一个for循环里for x in [1, 2, 3]: print(x) for ch in hello: print(ch) for key in {name: 张三, age: 18}: print(key) for line in open(data.txt, encodingutf-8): print(line)列表是连续的内存块字符串是字符序列字典是哈希表文件是磁盘上的字节流。它们的数据结构天差地别为什么Python能用一套语法统一处理答案是for循环根本不关心你是什么类型它只关心你能不能提供一个迭代器。更准确地说for循环要求目标对象要么本身是迭代器要么能被内置函数iter()转换成一个迭代器。只要满足了这一点不管是列表、字符串、字典、集合、文件对象、range对象还是你自己写的类统统都能进for循环。你可以用dir()快速验证一下这个协议的存在 dir([1, 2, 3]) # 输出里包含 __iter__ dir(iter([1, 2, 3])) # 输出里包含 __iter__ 和 __next__看出来了吗列表本身只有__iter__而经过iter()处理之后得到的对象同时有__iter__和__next__。这两个魔术方法就是迭代器协议的全部秘密。1.1 可迭代对象和迭代器大多数人没分清初学者最容易踩的第一个概念坑就是混淆可迭代对象iterable和迭代器iterator。这俩词长得像其实是两个层面的东西。我用自助餐厅来打个比方。可迭代对象相当于自助餐厅本身。它提供了取餐的入口你知道能从这里拿到食物但自助餐厅不等于某个具体的取餐过程。迭代器相当于你手里那个餐盘和夹子它是正在取餐这个动作本身。每用一次夹子餐盘里就多一样东西而且夹子记住你夹到哪了不会重复也不会跳漏。用代码说lst [1, 2, 3] # lst 是可迭代对象因为它有 __iter__ it iter(lst) # it 是迭代器因为 iter(lst) 返回了一个带 __next__ 的对象大多数容器类型列表、元组、字符串、字典、集合都是可迭代对象但它们本身不是迭代器。用一个更直白的判断标准一个对象如果能被 for 循环直接用它是可迭代对象如果你把一个对象喂给next()函数它能正常推进那它是迭代器。1.2 为什么文件对象特殊文件对象是少数既是可迭代对象、又是迭代器的类型因为它天生带__next__每次next(f)就返回一行文本。这也是为什么你能直接for line in f却从来不需要先iter(f)——文件自己就是迭代器。这个设计是刻意的文件可能非常大逐行读取时只要维护一个当前读到哪一行的指针就够了不需要把整个文件先装进内存。这个思想在后面谈生成器时还会反复出现。2. iter() 和 next() 两个函数把迭代器的底牌翻给你看理解了协议之后我们看两个幕后主角iter()和next()。iter(obj)做的事情是调用obj.__iter__()拿到一个迭代器。如果obj本身就是一个迭代器已经实现了__iter__和__next__直接返回它自己。next(iterator)做的事情是调用iterator.__next__()把当前指针往前推进一个位置并返回新位置上的值。如果已经没值可返回了就抛一个StopIteration异常。手动摸一遍这个流程你会对迭代器产生肌肉记忆lst [10, 20, 30] it iter(lst) print(next(it)) # 10 print(next(it)) # 20 print(next(it)) # 30 print(next(it)) # 这里会抛 StopIteration最后一个next(it)执行后迭代器内部已经没有可返回的值了所以它抛出StopIteration。注意看清楚迭代器不会越界返回None而是直接抛异常。这是迭代器协议和普通索引读取最大的区别之一。2.1 为什么迭代器不直接返回 None 表示结束这是很多从别的语言转过来的朋友会问的问题都到底了为什么不返回个None就完事原因在于None有可能是一个合法数据。假如你正在迭代的序列里真的有一个值是None那接收方根本无法区分这次拿到了一个正常的 None和迭代结束了。用异常来表示终止逻辑上更干净——异常意味着这个状态根本不是正常数据流的一部分。for循环正是通过捕获StopIteration来判断该停下的我们下一节就还原这个过程。2.2 字符串、字典、集合的迭代器长什么样不同可迭代对象产生的迭代器内部行为也不一样这一点很有意思。字符串的迭代器每次返回一个字符it iter(abc) print(next(it)) # a print(next(it)) # b字典的迭代器每次返回一个键不是值也不是键值对d {name: 张三, age: 18} it iter(d) print(next(it)) # name print(next(it)) # age集合的迭代器每次返回一个元素但因为集合本身无序所以迭代顺序不固定s {1, 2, 3} for x in s: print(x) # 打印顺序可能是 1 2 3也可能 3 1 2看到这里你应该明白为什么for key in dict拿到的是键而不是值了——因为字典的__iter__就是按键迭代的。如果你想让 for 循环拿值得用for value in d.values()想拿键值对得用for item in d.items()。这不是for循环做了什么特殊处理纯粹是__iter__实现决定的。3. for 循环的完整执行链路用 while 循环还原它现在终于可以回答最初的问题了for循环到底是怎么工作的它能自己知道什么时候该停不是因为循环头里写了个边界条件而是因为它内部默默创建了一个迭代器然后反复调用next()并在收到StopIteration时安静地退出。你可以用一段 while 循环把 for 循环的原型完全复刻出来def my_for(iterable, func): # 1. 拿到迭代器 iterator iter(iterable) while True: try: # 2. 反复调用 next() value next(iterator) except StopIteration: # 3. 迭代器耗尽跳出循环 break # 4. 把拿到的值交给循环体 func(value) my_for([1, 2, 3], lambda x: print(x))这四步就是 for 循环的完整生命周期。如果映射到真实的for x in data语法# 真实代码 for x in data: print(x) # 等价展开 _iterator iter(data) while True: try: x next(_iterator) except StopIteration: break print(x)注意两点。第一iter(data)只调用一次。也就是说循环开始前迭代器就已经确定了循环过程中无论你怎么修改data本身for循环用的始终是那个一开始就创建好的迭代器。第二每次迭代都会重新赋值给循环变量x然后执行循环体。这解释了为什么你在for循环里修改x并不会影响数据源——x只是迭代器吐出来的一个引用而已。3.1 一个经典的副产品循环窥探迭代器内部理解了上面的原理你就知道一个经典问题为什么是坑了在for循环里如果循环体同时对同一个可迭代对象再开一个迭代器并消费它会发生什么data [1, 2, 3, 4, 5] for x in data: print(外层拿到, x) inner iter(data) print(内层拿到, next(inner))iter(data)每次都会创建一个新的迭代器所以内层每次都是从头开始取第一个元素。这段代码的输出是外层不断1 2 3 4 5内层每次都打1。反过来如果你外层迭代的是迭代器本身而不是列表就会出大问题data [1, 2, 3, 4, 5] it iter(data) for x in it: print(外层拿到, x) print(内层又拿一次, next(it)) # 直接在同一个迭代器上再取这时候内层的next(it)会把迭代器推进到下一个位置而外层for循环的下一次next(it)又继续往后。结果就是外层每处理一个值内层就偷走一个值最终很多元素被悄悄跳过。这类问题一旦出现在生产代码里定位起来非常痛苦因为看起来逻辑完全没问题。3.2 range 对象为什么特别省内存聊到迭代器绕不开range。你可能会奇怪range(100000000)一个一亿的序列为什么Python还能秒开因为range也是走迭代器协议来遍历的但它不预先创建一亿个整数而是每次__next__时当场算出当前值。你可以自己看一眼r range(3) it iter(r) print(type(it)) # range_iterator print(next(it)) # 0 print(next(it)) # 1 print(next(it)) # 2range_iterator内部只保存当前的数值和步长、边界每次next()做一次简单算术就返回。这也是为什么range比list(range(...))省内存得多——它把生成下一个值的动作延后到了被读取的那一刻。这个延后计算的思想就是下一节的主角。4. 生成器是迭代器的亲儿子yield 背后那台自动状态机如果说手写__iter__和__next__是迭代器的手动挡那么生成器就是自动挡。所谓生成器就是任何一个包含yield关键字的函数。这个函数被调用的时候函数体不会立刻执行而是返回一个生成器对象。这个生成器对象是迭代器的一种同样支持next()同样会用StopIteration表示结束。我当初学这块时最不理解的就是函数明明写着return为什么调用后没执行看个例子就懂了。def count_up_to(n): print(生成器开始工作) i 0 while i n: yield i i 1 print(生成器结束) gen count_up_to(3) print(函数调用完成但上面什么都没打印) print(next(gen)) # 这会打印生成器开始工作然后返回 0 print(next(gen)) # 返回 1 print(next(gen)) # 返回 2 print(next(gen)) # 打印生成器结束然后抛 StopIteration看到区别了吗count_up_to(3)这行代码真的只是一次登记函数体一行都没执行。直到第一个next(gen)被调用代码才真正开始跑而且只跑到第一个 yield 就暂停把i的值交给外面。下一次next(gen)再从暂停处继续恢复i运行到下一个yield再暂停。这个暂停与恢复的机制就是状态机。Python在底层帮我们保存了函数内部所有局部变量的状态包括i当前的值、程序执行到的字节码位置每次yield把控制权交出去每次next()又把控制权收回来。4.1 yield 与 return 的边界感有人会把生成器里的yield和return混为一谈这里说清楚。在一个生成器函数里return有两个作用一是结束整个生成器二是它后面的值会被放进StopIteration异常里正因如此通常没人用 return 返回值。而yield才是真正的吐数据。def gen(): yield 1 yield 2 return 结束标记这段代码运行时前两次next()会返回 1 和 2第三次会抛StopIteration。如果你用 Python3 的生成器返回值语法去捕获会发现异常对象里带着字符串结束标记。这个设计让return在生成器里的定位非常鸡肋但知道它存在还是有用的——至少以后看到StopIteration里挂着值你不会懵。4.2 生成器表达式的优雅与陷阱除了生成器函数Python还提供了生成器表达式。它长得跟列表推导式几乎一样只是方括号换成圆括号squares (x * x for x in range(1000000))这里squares是一个生成器不是列表。你遍历它的时候每个x * x是当场计算的不需要一次性建出一百万个整数组成的列表。内存占用天差地别。但有个新手常踩的雷生成器表达式是一次性的。如果你先跑了一遍sum(squares)它已经被消费完了再跑for x in squares会发现一个元素都不剩。列表推导式则不会有这个问题因为列表是实实在在的容器。用哪个取决于你是只想遍历一次还是需要反复访问。4.3 一个经典的无限序列案例生成器最常见的用法之一就是表达无限的东西。迭代器协议天生适合这种场景因为它是按需取用的范式根本没有边界判断这回事。经典案例是斐波那契数列def fib(): a, b 0, 1 while True: yield a a, b b, a b这个函数永远不会抛StopIteration因为while True永不停歇。如果你想取前10个斐波那契数不需要自己数着取用itertools.islice控制消费数量即可from itertools import islice for x in islice(fib(), 10): print(x)islice(fib(), 10)的本质是再包一层迭代器限定它最多只取 10 次next()多的不碰。这种无限数源 按需截断的组合拳写起来极其爽快而且内存占用恒定不变——无论你取10个还是一百万个同一时间都只需要保留a、b两个变量。5. 动手写一个自定义迭代器斐波那契与逐行读文件的实战光看不练等于白学。你以为你已经懂了迭代器协议来动手实现一个。5.1 用类实现斐波那契迭代器一个自定义迭代器必须同时实现__iter__和__next__两个方法。__iter__通常直接返回self因为迭代器自己就是自己的迭代器__next__负责推进和抛异常。class FibIterator: def __init__(self): self.a 0 self.b 1 def __iter__(self): return self def __next__(self): result self.a self.a, self.b self.b, self.a self.b return result试一试fib_it FibIterator() for i, v in enumerate(fib_it): print(v) if i 10: break这段代码会不断打印斐波那契数直到break。因为FibIterator本身就是迭代器for循环对它调用iter()时返回的就是它自己然后循环体内的next()持续推进。注意一个细节我没有在__next__里抛StopIteration因为这个类是设计成无限序列的。如果你写一个有限的自定义迭代器别忘了在取完所有值后抛异常class CountDown: def __init__(self, start): self.current start def __iter__(self): return self def __next__(self): if self.current 0: raise StopIteration value self.current self.current - 1 return value for x in CountDown(3): print(x) # 输出 3 2 1 05.2 用生成器实现同样的功能明显更顺手对比一下同样的事情用生成器怎么写def count_down(start): current start while current 0: yield current current - 1只有4行有效逻辑效果一模一样。这也是为什么在实际业务代码里手写__next__的场景非常少——生成器几乎能覆盖所有需求。但当你要维护一个复杂状态、需要给迭代器对象提供额外方法比如reset()、peek()或者你需要实现多个遍历方式的时候类迭代器依然有它不可替代的价值。5.3 逐行读取大文件迭代器协议的现实效益文件逐行读取是迭代器协议在生产环境最典型的应用。我之前处理过几个 GB 级别的日志文件如果一次性readlines()全部读进内存机器直接卡死。而以下写法几乎不占内存def read_lines(path): with open(path, r, encodingutf-8) as f: for line in f: yield line这里for line in f能工作正因为文件对象实现了迭代器协议。你在函数里用yield line把它暴露出去后外面调用方就能这样消费for line in read_lines(huge.log): if ERROR in line: process(line)整个过程内存里同时只有一行文本。这就是惰性求值的威力——我不需要等所有数据都准备好才开始处理而是来一条、处理一条把峰值内存从几个GB降到几KB。5.4 类迭代器的常见反模式iter返回 self 的陷阱自写迭代器最容易被忽略的细节是可迭代对象和迭代器是两种角色。如果你写一个容器类希望它能被 for 循环多次遍历那它的__iter__不能简单地return self。因为一旦返回 self这个容器对象本身就成了一个迭代器第一次遍历就把自己消费完了第二次遍历会出现看起来什么都没了的怪现象。正确的做法是让__iter__返回一个新的迭代器实例class RepeatedList: def __init__(self, data): self.data data def __iter__(self): return iter(self.data)iter(self.data)每次调用都生成一个全新的列表迭代器所以你想遍历几次就遍历几次。而如果这个类本身设计成一次性迭代器比如上面那个斐波那契类那return self就没问题。关键是你得清楚自己的对象是什么角色。6. 迭代器使用中我见得最多的五个坑与性能经验最后这部分我把开发中真正碰到过的迭代器问题集中梳理一遍。每个都源于真实场景而不是教科书上的假设。6.1 迭代器是一次性的别当容器用这个前面提过但值得单独再强调一次。迭代器被消费完以后就空了没有任何方法让它从头再来。很多人把iter(some_list)的返回值存起来反复用第一次没问题第二次 for 循环直接空转半天找不到原因。一个最简单的判断法能用len()的是容器不能len()的是迭代器。列表、字符串、字典都是前者iter(x)的返回值、生成器、文件对象都是后者。6.2 for 循环内外嵌套消费同一个迭代器我在 3.1 已经展示过这个坑的场景。如果你确实需要在遍历过程中再取下一个元素优先用独立的迭代器或者干脆改成while配合显式next()把每个next()的调用点暴露出来这样代码阅读者不会误以为每个循环都是独立的从头遍历。6.3 函数参数默认值里藏迭代器见过一个让我排查已久的怪 Bug一个函数的默认参数是iter([])结果第二次调用时缓存列表有数据函数却什么都不处理。原因就是参数的默认值在函数定义时只计算一次迭代器第一次被消费后后续所有调用拿到的都是那个已经耗尽的迭代器。遇到这种情况正确写法是默认参数用None函数内部再按需创建迭代器def process(dataNone): if data is None: data [] for x in data: ...这也是 Python 官方推荐的「可变默认参数陷阱」的迭代器版本。凡是默认参数里放容易改变状态的东西列表、字典、迭代器、生成器都要警惕。6.4 zip、map、filter 返回的也是迭代器用之前想清楚很多刚从别的语言转过来的朋友会默认zip()返回一个元组列表。实际上zip()返回的是 zip 对象本身是迭代器。同理map()、filter()返回的都是迭代器。这意味着你只能遍历它们一次。我自己的习惯是如果这段数据后面还可能复用就立刻转成列表pairs list(zip(names, scores))如果数据量不大这种转换几乎无成本。但如果zip的数据源本身是海量数据硬要转成列表就反而违背了惰性求值的初衷。所以核心思路是单次消费用迭代器多次复用转容器。6.5 用迭代器思维检查你的循环性能有几个常见的性能优化建议本质上都在利用迭代器思想for x in range(n)比for x in list(range(n))省内存因为 range 每次现算。sum(x * x for x in data)比sum([x * x for x in data])省内存因为列表推导式会先构建一个完整列表而生成器表达式直接边算边加。for line in f比f.readlines()快且省内存因为不需要分配一个巨大的列表。特别是第三点处理日志、CSV、数据库导出的文本时readlines()和for line in f的性能差异在几百 MB 的文件上极其显著。你把readlines换成逐行迭代大概率内存直接下降一个数量级。聊到最后分享一个我自己的习惯现在写循环之前会先问一句我要遍历的东西是一次性管道还是需要反复访问的容器这个问题想清楚了选迭代器还是列表、选生成器表达式还是列表推导式根本不需要死记自然就有答案。迭代器这个设计说的直白一点就是一次拿一个说走就走绝不回头。它把数据的获取和数据的存储彻底分开让 Python 在处理无限序列、超大文件、流式数据时都能举重若轻。你越往后写越会发现这个看似简单的协议几乎嵌进了 Python 每一条常用语法的骨髓里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑