☰
Python迭代器深度解析:从for循环到yield状态机
2026/9/30 9:26:49 网站建设 项目流程

起因是一次代码评审。团队里一位写了快两年 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 StopIteration,for 循环会安静地接住它然后退出。

这里有一个新手常踩的坑:有人在__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) # 打印: 收到: 42

send最初是协程的核心原语,后来协程概念被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": f"user{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_size=100): 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_size=100): 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.tee:

from 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 组数,实际上第一轮i=0时,内层循环已经把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 循环背后那个故事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询