咱们写 Python 写了这么久,几乎每天都在用for循环遍历列表、字典、文件、range,但有没有一瞬间好奇过:for循环凭什么能遍历这么多长得完全不一样的东西?它内部到底是怎么知道“下一个元素”是什么的?又是怎么判断“已经到最后了”的?
这个问题往深挖,就是迭代器(Iterator)。说白了,Python 里的for循环只是一个语法糖,真正干活的是迭代器协议。今天这篇就围绕“迭代器”把for循环背后的故事讲透——我会从最基础的概念讲起,手写一个迭代器,再聊到生成器、itertools进阶玩法,最后还会分享一个实战级的大批量数据加载器写法。整个过程我会把踩过的坑和查问题时的小技巧一并写出来,适合刚学 Python 不久、想搞懂底层机制的人,也适合写了不少代码但一直被“迭代器一次性消费”这类问题折磨的老手。
1. 迭代器是什么:先搞清楚三个概念
1.1 可迭代对象、迭代器、生成器别再混淆
很多教程会把“可迭代对象”“迭代器”“生成器”混着说,但它们在 Python 里其实是三个不同的东西。我刚学的时候被绕晕过,后来用一句话记:可迭代对象是“能提供迭代器的东西”,迭代器是“负责一个一个往外掏东西的东西”,生成器是“用yield写出来的迭代器”。
Python 官方的定义更严格:
- 可迭代对象(iterable):实现了
__iter__()方法,调用它返回一个迭代器。比如列表、字典、集合、字符串、文件对象。 - 迭代器(iterator):实现了
__iter__()和__next__()两个方法的对象。__next__()每次返回下一个元素,没有元素时抛StopIteration异常。 - 生成器(generator):是迭代器的一种,由包含
yield的函数调用后产生,内部自动实现了__iter__()和__next__()。
用生活里的例子比划一下:列表是一本纸质书,书本身是一本完整的书(可迭代对象),但它不能自己翻页;iter(列表)相当于给你一个书签(迭代器),书签记录当前读到哪,每次next()就是翻一页;而生成器像是一个自动翻页的电子书签,它自己记得读到哪,还能临时掐断、下次继续。
写代码验证就更直观了:
lst = [1, 2, 3] print(hasattr(lst, "__iter__")) # True,列表是可迭代对象 print(hasattr(lst, "__next__")) # False,列表不是迭代器 it = iter(lst) print(hasattr(it, "__iter__")) # True,迭代器也是可迭代对象 print(hasattr(it, "__next__")) # True这里有个容易忽略的细节:迭代器本身也是可迭代对象,它的__iter__()通常返回自己。这意味着你可以在迭代器上继续调用iter(),得到的还是同一个迭代器。
1.2 for循环到底做了什么
for item in iterable这行代码,拆开看等价于下面的while循环:
it = iter(iterable) while True: try: item = next(it) except StopIteration: break # 循环体 print(item)也就是说,for循环做了三件事:
- 调用
iter(iterable)拿到一个迭代器对象; - 不断调用
next(iterator)取出下一个元素; - 捕获
StopIteration异常后正常结束循环。
所以,任何对象只要实现了__iter__()或者__getitem__(),理论上都可以被for循环遍历。Python 的老式双协议设计到现在还保留着:如果一个类没有__iter__(),Python 解释器会退回去走“索引协议”,也就是从下标 0 开始不断调用obj[0]、obj[1],直到抛出IndexError才结束。这就是为什么某些老式对象也能被for遍历。
理解了这一点,你就明白为什么字符串能遍历、为什么int不能遍历:int既没有__iter__(),也不能按下标访问,所以for i in 123会直接抛TypeError。
我建议你以后写自定义类时,别偷懒只实现__getitem__,尽量显式实现__iter__和__next__,因为很多时候代码要兼容、要判断,isinstance(obj, Iterable)这种检查在只有__getitem__的对象上会得到False,容易埋坑。
2. 手写一个迭代器:迭代器协议到底怎么实现
2.1iter和next的配合规则
既然for依赖迭代器协议,那我们自己造一个迭代器就顺理成章了。规则不复杂:
__iter__(self)返回一个迭代器对象。如果类本身是迭代器,通常return self;__next__(self)返回下一个元素。如果没元素了,必须抛StopIteration。
来看一个最低限度的例子,模拟从start数到end:
class RangeIterator: def __init__(self, start, end): self.current = start self.end = end def __iter__(self): return self def __next__(self): if self.current >= self.end: raise StopIteration value = self.current self.current += 1 return value for i in RangeIterator(1, 5): print(i, end=" ") # 输出:1 2 3 4每次调用next(),current自增,状态保存在实例属性里。当current越界时抛StopIteration,for循环收到后正常退出。
这里有一个写类迭代器时非常常见的问题:把__iter__写成return self会导致“同一个迭代器只能完整遍历一次”。如果你想要一个可迭代对象支持多次遍历,应该让__iter__()每次返回一个新的迭代器对象。常见的做法是拆成两个类:容器类只实现__iter__,迭代器类单独实现__next__。举个例子:
class MultiRange: def __init__(self, start, end): self.start = start self.end = end def __iter__(self): return RangeIterator(self.start, self.end) for i in MultiRange(1, 4): print(i) for i in MultiRange(1, 4): print(i) # 正常第二次遍历如果直接在RangeIterator里返回 self,第二次遍历就什么都拿不到了。这个“迭代器是一次性物品”的特性后面还会再讲。
2.2 完整示例:斐波那契迭代器
写一个斐波那契迭代器是练习迭代器协议最经典的题目,因为斐波那契数列天然是“按需生成、永不回头”的流。
class FibonacciIterator: def __init__(self, max_count=None): self.a, self.b = 0, 1 self.max_count = max_count self.count = 0 def __iter__(self): return self def __next__(self): if self.max_count is not None and self.count >= self.max_count: raise StopIteration value = self.a self.a, self.b = self.b, self.a + self.b self.count += 1 return value fib = FibonacciIterator(10) for num in fib: print(num, end=" ") # 0 1 1 2 3 5 8 13 21 34如果不指定max_count,那这个迭代器是无穷的,直接for会把机器跑死。所以用迭代器的时候一定要想清楚终止条件。这也是为什么很多迭代器设计都要带“上限”参数,不是没用的。
如果你只想取前几个,可以配合itertools.islice:
from itertools import islice fib = FibonacciIterator() first_20 = list(islice(fib, 20))2.3 为什么用迭代器模式而不是直接给列表加索引
有人会问:for直接用下标i = 0; while i < len(lst): lst[i]不就行了?为什么还要搞出一套__iter__和__next__?
核心原因有三个:内存、延迟、统一接口。
以range(10**9)为例,如果把它变成列表,内存瞬间爆炸;但迭代器只需要存一个current值,随取随丢。这就是“惰性计算”的思想。再比如文件对象,本质上就是一个“生成行的迭代器”,如果先readlines()读成全量列表,一个大几 GB 的日志文件能把电脑卡死;如果逐行迭代,内存占用几乎是零。
统一接口也极其重要。Python 里的map、filter、zip、enumerate都返回迭代器,它们可以被随意组合、流水线处理,而底层的实现五花八门,全靠同一个协议往上对接。这就是面向协议编程的威力,和接口多态是一样的道理。
3. 生成器:偷懒又强大的迭代器
3.1 yield 让普通函数变成生成器
手写一个类实现__next__虽然不难,但代码还是啰嗦。Python 给了我们一个更偷懒的武器:yield。只要一个函数内部出现了yield,调用这个函数就不会执行函数体,而是返回一个生成器对象。生成器自动实现了迭代器协议。
def countdown(n): while n > 0: yield n n -= 1 gen = countdown(3) print(gen) # <generator object countdown at 0x...> print(next(gen)) # 3 print(next(gen)) # 2 print(next(gen)) # 1 # 再 next(gen) 会抛 StopIterationyield的作用是把当前函数的状态冻结住,包括局部变量、指令指针、甚至执行到一半的表达式,等下次调用next()的时候再恢复。你可以把它想象成一个可以随时暂停和恢复的电影,yield就是暂停键。
这里要强调一个新手特别容易踩的坑:生成器函数一旦包含yield,函数体不会立刻执行。所以如果你想在函数一开始做检查、打日志,指望调用函数时就触发,那是不行的。得等第一个next()才会真正开始执行。调试的时候这一点特别容易让人懵。
与传统迭代器类相比,生成器省去了__iter__、__next__和StopIteration的手工管理,代码短得惊人,非常适合表达“流”概念。比如逐行读文件:
def read_lines(path): with open(path, "r", encoding="utf-8") as f: for line in f: yield line这段代码已经是很标准的写法了,大文件也能扛得住。
3.2 生成器表达式:列表推导式的低配版
生成器表达式和列表推导式长得几乎一样,只是把中括号换成小括号:
squares_list = [x * x for x in range(10)] # 列表,立刻算出全部 squares_gen = (x * x for x in range(10)) # 生成器,惰性求值 print(type(squares_list)) # <class 'list'> print(type(squares_gen)) # <class 'generator'>如果只是把它传给sum()、max()这种“消费一次”的函数,生成器表达式非常省内存。比如算前 1000 万个数的平方和:
total = sum(x * x for x in range(10_000_000))换成列表推导式会先创建一个巨大的列表再求和,内存峰值会高很多。生成器表达式一路流式算完,基本不占内存。
但要注意:生成器不能重复使用。你要把同一个生成器传给两个函数,第二个函数会拿到空数据。所以如果一组数据要多次遍历、或者要随机访问,老老实实转成列表或用列表推导式,别硬用生成器。
3.3 yield from 与生成器委托
yield from是 Python 3.3 引入的语法,用来把子生成器的产出“透传”给外层生成器。一个典型例子是自己实现chain函数:
def chain(*iterables): for it in iterables: yield from it for item in chain([1, 2], "ab"): print(item) # 输出 1 2 a b如果你写的是嵌套的for ... in ...: yield xxx,很多时候可以直接换成yield from,代码会清爽不少。它还能把返回值从子生成器传回来,不过日常用到不多。
生成器还有send(value)、throw(exc)、close()方法,可以双向通信。send是协程的基础,当你看到yield出现在赋值右边时,说明写的不是简单迭代器,而是协程了。这篇文章先点到为止,等以后写协程专题再展开。
4. 内置迭代器工具与进阶玩法
4.1 itertools 里最常用的几个函数
itertools是 Python 迭代器的“军火库”,里面每个函数返回的都是迭代器。我最常用的几个列在下面:
| 函数 | 作用 | 示例 |
|---|---|---|
itertools.count(start, step) | 无限等差数列 | islice(count(2, 3), 4)得到2 5 8 11 |
itertools.cycle(iterable) | 无限循环遍历 | islice(cycle("AB"), 5)得到A B A B A |
itertools.repeat(obj, times) | 重复对象 | repeat("x", 3)得到x x x |
itertools.chain(*iterables) | 串联多个迭代器 | chain([1,2], [3])得到1 2 3 |
itertools.islice(it, stop) | 对迭代器切片/截取 | islice(range(100), 10)取前 10 个 |
itertools.takewhile(pred, it) | 只要条件满足就产出 | takewhile(lambda x: x < 3, [1,2,3,1])得到1 2 |
itertools.compress(data, selectors) | 按布尔序列筛选 | compress("ABCD", [1,0,1,0])得到A C |
itertools.groupby(it, key=None) | 连续分组 | 用于按 key 分组相邻元素 |
几个使用场景一起说。配合无限迭代器取前几个值:
from itertools import islice, count for i in islice(count(10, 5), 3): print(i) # 10 15 20合并多个数据源:
from itertools import chain for item in chain([1, 2], {"x": 1, "y": 2}, "ab"): print(item) # 1 2 x y a bgroupby有个典型注意事项:它只能对已经按相同 key 排好序的连续元素分组,不是全局分组。如果数据没有排好序,要先sorted()再分组。
4.2 反向迭代、多迭代器并行与无限迭代器
reversed(obj)可以反向迭代,但它要求对象有__reversed__()或支持序列协议(有长度和下标)。reversed(range(5))没问题,reversed(iter([1,2,3]))不行,因为普通迭代器没有长度。zip(it1, it2, ...)返回一个迭代器,每次从每个传入的迭代器里各取一个,得到元组。zip在其中一个迭代器耗尽时就会停,不需要手动判断长度。enumerate(iterable, start=0)本质上是给迭代器“编号”。- Python 3 的
map()和filter()也返回迭代器,不是列表,这一点和 Python 2 不一样,写代码时别默认能直接len()。
无限迭代器是迭代器模式的野蛮体现。比如itertools.count()无限递增,如果不加终止条件,list(count())会直接内存溢出。安全写法是配islice或手写break。
from itertools import count, islice for i in islice(count(), 5): print(i) # 0 1 2 3 44.3 自建迭代器与 iter() 的第二参数用法
iter()除了常规用法,还有一个隐藏技能:iter(callable, sentinel)。第一个参数是无参函数,第二个参数是哨兵值。每次迭代会调用函数取返回值,当返回值等于sentinel时马上停止。
这个在按块读文件时特别好用。例如每次读 16 字节,读到空字节就停:
with open("data.bin", "rb") as f: for chunk in iter(lambda: f.read(16), b""): process(chunk)也可以用来从输入里读行,直到遇到结束标记:
def read_until_end(): return input("输入 exit 退出: ") for line in iter(read_until_end, "exit"): print(f"处理: {line}")新手看到这种写法会觉得很怪,但它确实比 while 循环更像 Python。等你会用iter(callable, sentinel)之后,很多“排队轮询”“分批拉取”的循环代码都能写得非常干净。
5. for循环背后的常见坑与调试技巧
5.1 迭代器是一次性消耗品
列表是可迭代对象,重复for很多次都没问题,但迭代器是“单程票”。比如:
it = iter([1, 2, 3]) print(list(it)) # [1, 2, 3] print(list(it)) # [],因为迭代器已经耗尽这个坑最常见的场景是:把迭代器存成属性或变量,第一次用完后忘了重新生成,第二次循环发现是空的,还以为是数据丢了。解决办法是,如果你要让一个对象支持多次遍历,不要在__iter__里return self,而是每次返回一个新的迭代器;如果你已经有一个迭代器并且需要多次使用,请提前list()保存,或者重新调用能生成新迭代器的函数。
5.2 迭代时修改集合会翻车
在for循环里直接修改正在遍历的列表,是很多新手必踩的雷。看这段:
lst = [1, 2, 3, 4, 5] for item in lst: if item % 2 == 0: lst.remove(item) print(lst) # 预期 [1,3,5],实际 [1, 3, 5]? 不一定,看运气因为迭代器内部用下标或游标记录位置,你删除元素后,后面的元素会往前移,循环可能会跳过某个元素。更糟糕的是遍历字典时增删元素,会直接抛RuntimeError: dictionary changed size during iteration。
正确做法:
- 遍历副本:
for item in lst[:]或for item in list(lst); - 先收集要删除的条件,循环结束后统一删除;
- 如果只是生成新列表,用列表推导式或
filter。
遍历副本虽然多占一点内存,但安全第一。对超大型列表,如果内存吃紧,可以倒着遍历删除,或者用标志位延迟删除。
5.3 StopIteration与next默认值
手动调用next(it)时,迭代器一旦耗尽就会抛StopIteration。很多时候我们不想处理异常,可以直接传默认值:
it = iter([1, 2]) print(next(it)) # 1 print(next(it, None)) # 2 print(next(it, 0)) # 0,没有元素时返回默认值注意,用next(it, default)时,如果迭代器确实没有下一个元素,它会返回默认值,而不会抛异常。这在消费“可变长数据流”时特别有用。
还有一个和生成器相关的坑来自 PEP 479:在生成器内部如果StopIteration未被捕获直接冒泡,会被转换成RuntimeError。也就是说,你不能在生成器里用“让迭代器自然耗尽”来作为结束信号,如果有这种逻辑,要显式return。这是 Python 3.7 之后的规则,老项目升级后容易遇到。
5.4 调试迭代器:用 tee、list、length_hint检查状态
迭代器没法直接看“内容”,所以调试时我一般用这几种手段。
- 最简单:
list(it)把剩余部分全部变成列表,直接打印。代价是可能会改变迭代器所处状态(它会消费掉迭代器)。 - 想看“当前剩余量”,可以用
it.__length_hint__(),返回一个估计值,不保证准确,但能帮你判断迭代器是不是快空了。 - 想并行处理多个消费者,可以用
itertools.tee(it, n)把一个迭代器“复制”成 n 个独立迭代器。注意tee会缓存数据,内存上不是免费的,别滥用。 - 想确认生成器执行到哪一行,可以在关键位置加
print(f"yield {value}"),或者干脆用next(gen)一步步调试。
我调试生成器时也常用debug参数开关日志:
def gen_data(data, debug=False): for item in data: if debug: print(f"yield {item}") yield item写代码时加一两行日志,排查问题能省很多时间。
6. 实战案例:写一个批量数据加载迭代器
6.1 背景:大文件/数据库分批读取
真实业务里经常要处理“大得不能一次性读进内存”的数据:几百万行的 CSV、几千万条数据库记录、训练集图片路径等。如果统统read()或list(db.query(...)),轻则内存紧张,重则直接卡死。
通用的优化思路是分批(batch)处理:每次从数据源取一小批,处理完一批再取下一批。这个模式很适合用迭代器封装。我们要实现一个BatchIterator,它包装任意可迭代对象,按照指定batch_size批量产出列表。
6.2 实现BatchIterator
直接上代码:
class BatchIterator: def __init__(self, iterable, batch_size): self._it = iter(iterable) self.batch_size = batch_size def __iter__(self): return self def __next__(self): batch = [] for _ in range(self.batch_size): try: batch.append(next(self._it)) except StopIteration: break if not batch: raise StopIteration return batch用法很简单:
for batch in BatchIterator(range(10), 3): print(batch)输出:
[0, 1, 2] [3, 4, 5] [6, 7, 8] [9]最后的批次只有 1 个元素,这是允许的。如果你希望最后一笔不足时也丢弃,可以在__next__里对len(batch) == batch_size做校验。我通常选择保留尾批,因为数据处理时不想丢数据。
这段代码里有几个设计细节值得说:
- 构造时传入
iterable,但不要在构造时立刻iter(),也可以推迟到第一次__next__。我给的是立即iter(),好处是可以尽早发现传入对象不可迭代的问题。 try ... except StopIteration是必须的,因为底层迭代器很可能在批次中间耗尽。__iter__返回 self,所以这个批次迭代器只能用一次。有些框架中数据需要 repeat,那应该在批量外层再加一层“每次重新创建迭代器”的逻辑。
更偷懒的写法是利用iter(callable, sentinel)和islice:
from itertools import islice def batch_loader(iterable, batch_size): it = iter(iterable) return iter(lambda: list(islice(it, batch_size)), [])这个写法很简洁,但异常处理和可读性不如类。两种都记住,按团队风格选。
6.3 结合文件读取与缓存优化
现在把BatchIterator用在大文件读取上。假设有一个 CSV 文件,每行是一个样本,你想批量读取:
import csv def read_csv_rows(path): with open(path, "r", encoding="utf-8") as f: reader = csv.reader(f) yield from reader for batch in BatchIterator(read_csv_rows("data.csv"), 1000): # 每批最多1000行 process(batch)这里csv.reader本身返回迭代器,yield from把它透传出去。内存里永远只保存当前 1000 行,不会把整个文件装进内存。
如果数据来自数据库,很多数据库驱动的游标本身支持迭代,可以直接把游标包进BatchIterator。例如:
cursor = connection.cursor() cursor.execute("SELECT ...") for batch in BatchIterator(cursor, 500): save_batch(batch)如果cursor不可重复迭代,每次组件重新查询后再包一层生成器即可。
还可以在批量迭代器里加入缓存、预处理、异常重试等逻辑。比如对某条数据解析失败,可以记录日志继续,不中断整个流。因为BatchIterator只是把数据处理管道的一部分,它不关心上游数据从哪来,这样代码的复用性会非常好。
最后分享一点我的经验
我自己在实际项目里踩过不少迭代器的坑,最有价值的一条经验是:把数据处理管道想成一串迭代器,而不是一节一节的列表。数据从文件、数据库、网络来,经过map、filter、batch等变换,最后交给消费端,中间全程惰性求值。这样写出来的程序内存可控、结构清晰,也更容易做单元测试。另一个小技巧是,如果你在写一个自定义容器类,先别急着写__len__、__getitem__,先想想迭代器协议能不能解决问题。很多时候一个生成器函数比一整类简化得多。希望这篇把for循环背后的故事讲清楚后,你再看 Python 里的循环和数据流,会有完全不一样的感觉。