
起因是一次代码评审。团队里一位写了快两年 Python 的同事在实现数据批量加载的时候手动维护一个while循环、一个游标变量、再加一串边界判断代码写了三十多行还总是出越界问题。我建议他用迭代器解决他反问了一句“迭代器不就是 for 循环背后那个隐藏变量吗反正都要遍历直接 for 不就完了”我没有当场辩论只是给他看了一个现象同一个列表用 for 遍历两遍完全正常可一旦把数据包进某些“看起来也能 for 的对象”里第二遍就什么都拿不到了。他盯着空结果看了很久才反应过来原来 for 循环背后并不是列表本身在“走路”而是另一个角色在一步步地递值。这篇文章就是围绕那个角色写的——迭代器Iterator。它适合三类人一是写 Python 一阵子、能上手但没深挖过语法糖的人二是在用生成器、写yield但说不上来底层怎么回事的人三是准备做数据批量加载、量化回测、日志流处理这类“边读边算”场景的人。读完你会搞清楚什么是可迭代对象、什么是迭代器、两者的协议到底约定了什么以及你自己踩过的那些“遍历一次就被掏空”的坑究竟是从哪里冒出来的。1. 一次代码评审引发的追问for循环到底“驱动”了什么很多人学 Python 的第一课就是for x in list学了三四年脑子里对 for 的印象仍然停留在“把列表里的元素一个一个取出来”。这个理解不能算错但漏掉了最重要的一层for 循环本身并不会取元素它只是反复向一个“取值的对象”索要下一个值要不到就停。1.1 字节码里的三件事想知道 for 真正做了什么最直接的办法是看它被编译成什么。在 Python 3.10 的环境下对for x in xs: print(x)反编译得到的核心指令大致是LOAD_NAME xs GET_ITER FOR_ITER 12 (to 18) STORE_NAME x LOAD_NAME print LOAD_NAME x CALL_FUNCTION 1 POP_TOP JUMP_ABSOLUTE 4这里面真正“干活”的指令只有三条GET_ITER调用iter(xs)拿到一个迭代器。FOR_ITER每次循环调用一次next()把结果存下来如果抛出了StopIteration就跳转到循环体之后的指令。JUMP_ABSOLUTE跳回FOR_ITER继续下一轮。也就是说for 循环的完整执行过程可以翻译成等价的手写代码xs [1, 2, 3] it iter(xs) while True: try: x next(it) except StopIteration: break print(x)GET_ITER和FOR_ITER就是 for 语法糖背后真实的执行逻辑。你可以把迭代器想象成一根待拆的纸券for 循环是那台自动撕券的机器而StopIteration相当于机器检测到“纸券已撕完”自动停机。整个过程里循环体根本不知道数据是什么结构它只依赖一个统一的接口——给一个再给一个直到没有。1.2 没有__iter__也能迭代序列协议兜底一个常见的误解是对象必须实现了__iter__才能被 for 循环使用。实际上 Python 还有一条古老的兜底路径如果对象没有__iter__但实现了__getitem__解释器会退而求其次创建一个“按下标取值”的临时迭代器从obj[0]开始依次取obj[1]、obj[2]直到抛出IndexError才停止。class Box: def __init__(self, *items): self.items items def __getitem__(self, index): return self.items[index] box Box(a, b, c) for x in box: # 没有 __iter__ 也能遍历 print(x)这段代码能跑靠的就是序列协议兜底。很多老式类当时只实现了__getitem__Python 为了让它们也能被遍历就保留了这个机制。但注意兜底只是兼容设计它的迭代语义是被解释器“硬凑”出来的既不能表达复杂的暂停恢复逻辑性能也不如真正的__iter__。新写的类老老实实实现__iter__才是正道。1.3 我们平时说的“可迭代对象”其实是三类东西结合上面的内容可以把能被 for 的东西分成三类理解它们的区别是后面所有讨论的基础类型例子能否重复遍历说明容器类型list、tuple、set、dict、str能每次 for 都会生成一个全新的迭代器迭代器iter([1,2,3])的返回值、生成器对象不能自己消耗自己遍历一次就空序列兜底只实现了__getitem__的类能但语义受限解释器按下标逐步取值“可迭代对象”这个词其实是这三类的统称只要它能被iter()转换出迭代器就算可迭代。而“迭代器”只是其中特殊的一类它是那个真正被 for 反复next()的对象。2. 迭代器协议不是两个魔法方法那么简单iter和next的分工一个类要被当作迭代器使用需要同时具备__iter__和__next__。这件事文档里一句话就带过了但实际写的时候很多人根本分不清这两个方法该怎么配合尤其是搞不明白为什么有的__iter__返回self有的又返回新对象。2.1 可迭代对象与迭代器的身份差异先看一个最典型的迭代器实现一个能数到指定上限的计数器class Counter: def __init__(self, limit): self.limit limit self.n 0 def __iter__(self): return self def __next__(self): if self.n self.limit: raise StopIteration value self.n self.n 1 return value写完之后测一下c Counter(3) print(iter(c) is c) # True说明它本身就是迭代器 print(list(c)) # [0, 1, 2] print(list(c)) # []第二次已经空了__iter__返回self意味着“我这个对象就是一个迭代器你们别再造新的了直接消耗我吧”。所以它天然只有一次遍历机会。再看另一种写法class CounterFactory: def __init__(self, limit): self.limit limit def __iter__(self): return Counter(self.limit) # 每次都造一个新迭代器 cf CounterFactory(3) print(iter(cf) is cf) # False print(list(cf)) # [0, 1, 2] print(list(cf)) # [0, 1, 2]这次又有全新迭代器这个类的实例是可迭代对象但它自己不是迭代器。它的职责是“每次for来了我发一个新迭代器给你”。两者分工完全不同可迭代对象负责提供迭代器迭代器负责被消耗。这个区分是理解“为什么有的东西可以反复遍历、有的只能遍历一次”的钥匙。2.2 StopIteration 与 next() 的边界StopIteration是迭代器与调用方之间唯一的“终止信号”。在__next__里判定“没有更多数据”时必须raise StopIterationfor 循环会安静地接住它然后退出。这里有一个新手常踩的坑有人在__next__越界时偷懒写return None结果循环根本不会结束只会不断拿到None看起来就像程序卡死了一样。原因很简单for 只认StopIteration你把 None 当普通值返回它就真的当普通值继续循环。还有一个小细节值得记住next(迭代器, 默认值)这种两参数形式在迭代器耗尽时不会抛异常而是返回默认值。这在写“尝试取值取不到就怎么办”的逻辑时非常顺手it iter([1, 2, 3]) print(next(it, None)) # 1 print(next(it, None)) # 2 print(next(it, None)) # 3 print(next(it, None)) # None而不是抛 StopIteration注意默认值只在“已经耗尽”时才有效。如果迭代器中途抛了别的异常它不会帮你想办法。2.3 iter(callable, sentinel) 的冷门用法iter()还有一个被低估的两参数形态iter(callable, sentinel)。它会反复调用callable直到返回值等于sentinel才停止。最经典的场景是读文件直到读到空行with open(access.log) as f: for line in iter(f.readline, ): process(line)这里iter(f.readline, )的意思是不断调用f.readline()每次拿到一行直到返回空字符串。比手写while True加条件判断干净得多。凡是“反复调用一个函数直到碰到某个终止标记”的场景都值得考虑这个写法。3. yield 不是“返回值”是把函数切成了一段可恢复的状态机如果说迭代器协议是 for 循环的地基那生成器Generator就是地基上最好用的一块预制板。它本质上是一个“由函数写出来的迭代器”但理解它不能停留在“yield 会返回一个值”这个层面。3.1 首次 next() 之前函数体一行都没执行看这段代码def counting(limit): print(开始运行) for i in range(limit): print(f准备产出 {i}) yield i print(运行结束) g counting(3) print(这一行会在函数体执行之前打印)g counting(3)这一行函数体一行代码都不会跑。直到你第一次next(g)或开始 for 遍历它才执行到第一个yield i把i交给外部然后立刻原地暂停。第二次next(g)再从暂停点继续打印“准备产出 1”再交出一个1再暂停。这就好像你看书看到第 100 页夹了个书签去忙别的回来之后翻开书签那页继续往下读。生成器内部保存了局部变量、指令位置、异常状态等完整的“执行现场”.send()、.throw()、.close()之所以能工作全依赖这套保留机制。理解到 state machine 这一层你才会明白为什么生成器能表达这么复杂的迭代逻辑——它本来就是一个被切片执行的小程序。3.2 send、throw、close生成器的三个扩展通道生成器不只是单向吐出数据它还能和外部双向通信。三个扩展方法分别是send(value)把value送进生成器内部作为当前yield表达式的结果。throw(exc)把异常抛进生成器内部在暂停点触发。close()让生成器退出之后它会被标记为关闭。send的最小例子def echo(): while True: received yield print(收到:, received) g echo() next(g) # 跑到第一个 yield 之前必须先用 next 启动 g.send(hello) # 打印: 收到: hello g.send(42) # 打印: 收到: 42send最初是协程的核心原语后来协程概念被async/await全面接管send在日常业务里用得少了但理解它有助于看懂生成器的本质它不是一个只出数据的管道而是一个可以被人从外部拨动开关的自动化机器。3.3 生成器表达式一种“随用随取”的惰性求值列表推导式谁都会写但把方括号换成圆括号含义就完全不同了squares_list [x * x for x in range(10)] # 立刻算出 10 个值存在列表里 squares_gen (x * x for x in range(10)) # 惰性边要边算后者就是生成器表达式。它最大的价值在于可以串成一条“没有任何中间列表”的流水线nums (x for x in range(1000000)) squares (x * x for x in nums) evens (x for x in squares if x % 2 0)这一串代码执行完内存里依然只有三个生成器对象一个数字都没算出来。直到某处真正 for 它才一个接一个地流过去。对于百万级、千万级的数据这种“延迟到最后一刻才算”的思路往往就是程序能不能跑起来的分界线。4. 手写一个分页API批量加载迭代器把游标管理交给协议前面讲的都是概念这一节上一个我在实际项目里反复用到的模板分页 API 的批量加载。很多人在跑监控任务、同步数据、做量化回测的行情补充时都遇到过这种需求——从一个接口按页拉数据拉完一页处理一页。最常见的写法是while套for维护page和游标代码又臭又容易错。用迭代器可以把这些脏活全部收进协议内部。4.1 先从最直白的类写法开始假设有一个接口函数传入页码和页大小就返回一页数据为了演示我直接本地模拟def fetch_user_page(page, page_size): 模拟从远程接口取一页数据真实场景里这里通常是 requests.get(...) all_users [{id: i, name: fuser{i}} for i in range(250)] start page * page_size return all_users[start:start page_size] class PagedUserIterator: def __init__(self, fetch_page, page_size100): self.fetch_page fetch_page self.page_size page_size self.page 0 self.buffer [] self.index 0 self.has_more True def __iter__(self): return self def __next__(self): if self.index len(self.buffer): self._load_next_page() if self.index len(self.buffer): raise StopIteration user self.buffer[self.index] self.index 1 return user def _load_next_page(self): if not self.has_more: return self.buffer self.fetch_page(self.page, self.page_size) self.page 1 self.index 0 if len(self.buffer) self.page_size: self.has_more False调用方短得惊人for user in PagedUserIterator(fetch_user_page): print(user[name])页码自增、缓冲区切换、什么时候该继续拉、什么时候该结束全部封装在迭代器内部。外面的人唯一要做的事就是 for完全不需要知道分页的存在。这里的结束条件是“拿到的数据不足一页”这在实际接口里是最通用的终止判定。4.2 换成 yield 写代码量直接减半如果觉得类写法太啰嗦用生成器重写一遍你会直观感受到 yield 的威力def paged_user_generator(fetch_page, page_size100): page 0 while True: page_data fetch_page(page, page_size) if not page_data: return # return 相当于发出 StopIteration for user in page_data: yield user page 1同样的功能代码量少了一半还不止。为什么能这么省因为生成器替你维护了执行状态——page是局部变量每次yield后暂停下次next()从暂停点继续循环页码、缓冲、终止判断全由控制流天然表达不需要你人工保存。这正好呼应第 3 节说的yield 把函数变成了状态机所以“游标”这种东西就不再需要显式存在了。4.3 真实场景中迭代器带来的边际收益如果只是本地小数据封装不封装差别不大。但放到真实环境里差距会立刻显现。第一是内存。250 条数据放列表里无所谓但如果是 500 万条订单记录每一页 1000 条用生成器方案任何时刻内存里最多只有一页数据加一个生成器对象。这是“流式处理”和“全量加载”的本质区别。第二是调用时机。生成器在被遍历之前不会真的发网络请求。这意味着你可以先搭建一条数据管道把“怎么取数”“怎么转换”“怎么入库”描述清楚然后在合适的时机统一触发。处理任务、写调度系统的时候这种“定义与执行分离”非常有用。第三是组合能力。迭代器可以一层套一层分页生成器外面套一个清洗生成器再套一个异常重试生成器各层各司其职互不污染。类写法也能做到但生成器省掉大量模板代码读起来更顺。5. 迭代器只走一趟先认清这些坑再谈封装迭代器最大的性格特点就是“一次性”。它不像列表那样数据摆在那里随你怎么翻。认识不到这一点你会在各种意想不到的地方踩坑。我见过的踩坑现场几乎全都集中在这几类。5.1 同一个迭代器被 for 两次第二次是空这是最基础也最常见的坑it iter([1, 2, 3]) for x in it: print(x) # 第一次正常打印 1 2 3 for x in it: print(x) # 第二次什么都不打印迭代器已经空了如果你拿it iter([1, 2, 3])只是做一个实验觉得很直观但真实项目里这个迭代器往往藏在一个对象的属性里、一个函数的返回值里外部人根本不知道它已经被某段逻辑消费过了。这也是为什么前面反复强调可迭代对象负责“每次生成新迭代器”迭代器负责“被消耗”。想设计成可以反复遍历的组件就应该让__iter__返回新对象而不是返回self。5.2 列表包裹是“一次性快照”别指望复制很多人在需要同时处理两遍数据时会下意识写一个list(iterator)nums (i for i in range(3)) snapshot list(nums) print(list(nums)) # []问题在于list(nums)执行完之后nums这个生成器已经被彻底消费掉了。你拿到的snapshot是那一瞬间的快照原数据源不会因为你复制了一份就“原地复活”。如果确实需要两条独立的数据流应该用itertools.teefrom itertools import tee nums (i for i in range(3)) a, b tee(nums, 2) print(list(a)) # [0, 1, 2] print(list(b)) # [0, 1, 2]但注意tee也不是免费的它会用缓存保存两个分支之间“尚未被消耗”的元素如果两个分支的消费速度差距过大内存开销可能会超出你的预期。能不用就不用用了就要清楚它的成本。5.3 内层循环偷偷消耗外层迭代器更隐蔽的坑出现在双层循环里nums (i for i in range(3)) for i in nums: for j in nums: print(i, j)你以为会打出 9 组数实际上第一轮i0时内层循环已经把nums里剩下的1和2全部消费光了。第二轮外层循环拿不到任何值直接退出。输出结果远少于预期。这个问题在写矩阵遍历、笛卡尔积、两两比较逻辑时特别容易出现。解决办法很简单外层如果还要复用就先把数据转成列表或者用tee复制一份再分别遍历。5.4 itertools.tee 和 islice处理单次遍历的两根救命稻草和无限生成器打交道时itertools.islice是我用得最多的工具。它可以在不消耗整个迭代器的情况下切出一段来from itertools import islice def naturals(): n 0 while True: yield n n 1 first_five list(islice(naturals(), 5)) # [0, 1, 2, 3, 4]没有islice想从无限序列里取前 N 个你只能手动写计数器然后 break。有了它一句搞定。类似的zip组合多个生成器时也要留心zip会推进它接收到的所有迭代器长度相同的两个生成器 zip 完之后两边都会被耗尽。6. 选型经验什么时候该交付迭代器什么时候该交列表把迭代器讲得这么透彻是不是所有场景都应该用迭代器当然不是。我曾经在代码评审里见过有人为了“炫技”把只有几十个元素的配置列表也包成生成器结果代码变难读调试变麻烦性能一点没提升。选型的关键是想清楚数据的使用方式。6.1 一张表看清列表、可迭代对象、迭代器的分工特性列表 list可迭代对象容器迭代器 / 生成器可以重复遍历能能不能支持下标obj[i]能取决于实现不能能len()能取决于实现不能数据存放方式全部驻留内存由对象决定边算边出只留当前值典型用途小数据、需要多次访问语义化封装大数据流、懒加载如果一个数据你要来回访问、要切片、要数长度、要拿去排序老老实实用列表。如果数据量大到一次装进内存有压力或者数据本身的产生成本很高比如网络请求、复杂计算或者数据分析流只经过一次那么迭代器和生成器就是更优的选择。6.2 itertools 的常用工具组合itertools标准库是迭代器生态的兵器库几个高频工具的搭配值得专门记一下islice从迭代器里切片解决“无限序列取前 N 个”。cycle把一个有限序列变成无限循环做轮询调度、环形队列遍历都靠它。比如三台机器轮流处理任务for machine in cycle([A, B, C])就是最简写法。chain把多个迭代器串成一个。数据分散在多个日志文件时chain(iter(f1), iter(f2))一条管道处理完。takewhile/dropwhile按条件“一直取”或“一直丢”。适合处理已经有序但想提前终止的数据。groupby对连续出现的元素分组。注意它只对连续值有效用之前一般要先排序。这几个工具和生成器搭配基本能覆盖日常八成以上的“流式数据处理”需求。6.3 给后来者的三条选型判断我在数据批量加载和日志流处理里反复用了几年迭代器最后沉淀出三条非常朴素的判断标准分享给后来的人第一这个数据会被遍历几次一次以上就选列表或者把一个类设计成“每次返回新迭代器”的可迭代对象刚好一次放心用迭代器和生成器。第二数据规模能不能直接塞进内存心里没底就用生成器。等你因为一个几 GB 的集合把机器内存打满过一次就会对“全量加载”产生本能的敬畏。第三数据生产是否有代价接口调用、文件解析、复杂计算都属于“有代价”。用生成器把计算推迟到真正消费的那一刻可以避免大量无用功。这些标准不是我坐在电脑前拍脑袋想出来的是踩过坑、看过凌晨四点监控报警之后才总结出来的。迭代器不是什么高深概念它只是把“给出下一个值”这件事变成了一种纪律给你一个就往前走一步给不出就举起StopIteration的牌子。下次写 for 循环的时候脑子里可以有一个画面——Python 正站在一台机器前一次又一次接过机器吐出来的值直到机器不再动弹。理解到这一层你才算真正看完了 for 循环背后那个故事。