我用了好几年 Python,for循环闭着眼睛都能写。直到有一天,我把同一个变量放进两个for循环里,第一次跑得好好的,第二次循环体里一片空白。当时同事说了一句话:“你拿到的是一个迭代器,迭代器是一次性的。”我愣了半天,回头一查才真正理解:原来for循环背后藏着一整套迭代器协议,而for item in items这种写法,只不过是一层语法糖衣。
Python 迭代器(Iterator)如果只看表面,就是一个能挨个吐出元素的对象;但往深里看,它和列表、字符串这些“可迭代对象”有着微妙又不容混淆的关系。很多人踩过同样的坑:写好一个生成器函数,传给两层循环,第二层循环没数据;想“回退一个元素”,死活找不到方案;遍历字典过程中删了个键,直接抛RuntimeError。这些问题的根源几乎都指向同一个地方——迭代器协议。
这篇文章适合正在学习 Python 的同学,也适合已经写了两三年 Python、但遇到上面这种问题会卡壳的开发者。我会从协议本身讲起,把for循环在解释器层面到底做了什么一步步展开,再带你看清生成器的执行机制、常见的迭代器陷阱,以及真正生产环境中怎么用迭代器做流式数据处理。尽量少讲抽象理论,多给能直接写进代码里的做法。
1. 迭代器协议:for 循环真正的“幕后契约”
想搞清楚迭代器,第一个动作是忘掉for循环的舒服写法,回到两个最原始的函数:iter()和next()。
1.1 三个容易混淆的概念:可迭代对象、迭代器、迭代协议
在很多人的口语里,“可迭代对象”和“迭代器”是混着用的,但这在 Python 里面是两个完全不同的角色。
可迭代对象(Iterable),指实现了__iter__方法的对象。调用iter()会返回一个迭代器。列表、字符串、字典、集合、文件对象、range对象,都属于可迭代对象。
迭代器(Iterator),指实现了__iter__和__next__两个方法的对象。__iter__返回自身,__next__每次被调用时吐出一个元素,元素耗尽时抛StopIteration。
我们来做一个最简单的实验:
numbers = [1, 2, 3] it = iter(numbers) # list 本身不是迭代器,iter() 才返回迭代器 print(type(it)) # <class 'list_iterator'> print(next(it)) # 1 print(next(it)) # 2 print(next(it)) # 3 # 这时候再调用 next,就会抛 StopIteration # print(next(it))注意,numbers是一个可迭代对象,但它自己不是迭代器;it才是迭代器。这就像一家餐厅的菜单是“可读的”,但一份份端上来的菜,才真正对应着“被消耗掉”的东西。
还有一个容易被忽略的边界情况:如果某个对象没有__iter__,但实现了__getitem__,那么iter()会按序号0, 1, 2, ...依次尝试取元素,直到抛出IndexError为止。这种“准可迭代”是 Python 为了兼容老代码保留的机制,但千万别依赖它,因为你无法在没有__iter__的情况下实现真正的“无限流”。
1.2 把迭代器想象成什么才准确
很多人爱把迭代器比作“书签”,我试过这个类比,但它容易误导。书签可以回翻、可以重看,迭代器不行。
更贴切的比喻是“排队取餐的窗口”:你排在一个队伍前面,一次只能从窗口拿一份餐,窗口不会因为你后悔就把上一个人的餐还给你。你只知道前面还有人,但不知道还剩多少个;你也不可能跳到后面去看还没做出来的东西。
还有一个关键点,也是日常最容易出问题的地方:同一个可迭代对象,可以做多次独立的迭代。for循环每次执行时,都会重新调用一次iter(),所以对同一个列表循环两遍完全没问题。
data = [1, 2, 3] for item in data: print(item) # 输出 1 2 3 for item in data: print(item) # 再次输出 1 2 3可如果直接把迭代器循环两遍,第二遍什么都拿不到。
it = iter([1, 2, 3]) for item in it: print(item) # 输出 1 2 3 for item in it: print(item) # 没有任何输出这就是我开头说的那个诡异现象。要把这个行为彻底搞明白,得看看for循环在底层究竟是怎么跑的。
2. for 循环展开后的真实执行路径
你把for item in data:这行代码交给 CPython,它实际上会把整个循环转换成一段类似下面这样的逻辑:
# 原始的 for 循环: # for item in data: # process(item) # # 等效的 while 循环: it = iter(data) while True: try: item = next(it) except StopIteration: break process(item)就这么简单,没有更多魔法。
2.1 逐步把 for 循环翻译成 while 循环
第一行iter(data)是启动阶段。列表、字符串、生成器,都会返回一个各自的迭代器。这一行的作用,是把“可迭代的数据源”冻结成一个“当前状态”的游标。
接下来进入无限while循环,每次next(it)都会让迭代器往前推进一位。如果没有元素了,迭代器会抛出StopIteration,这个异常不是什么“错误”,而是正常的结束信号,for循环接收到以后就静默退出。C 语言背景的人可能会觉得奇怪:为什么正常结束要用异常?因为这样可以让任意嵌套的迭代器在遇到结束条件时,直接把这个信号往上抛,由最外层的for捕获。这也意味着自定义迭代器里不要轻易捕获StopIteration再吞掉它,否则可能让外层循环误判结束条件。
来看一个能直观感受到“推进”过程的例子:
from collections.abc import Iterator data = [10, 20, 30] it = iter(data) print(isinstance(it, Iterator)) # True print(hasattr(data, "__next__")) # False,说明 list 本身不是迭代器 print(next(it)) # 10 print(next(it)) # 20 # 正常情况下,到这里 for 循环还会继续取第三个元素如果你在循环内部用iter()手动取元素,就会破坏循环自身的节奏。我踩过一次很实的坑:在一个for row in reader:里,需要跳到下一行继续读,于是手写了一个next(reader),但忘记处理StopIteration,结果 CSV 最后一行出现时,程序直接崩了,而不是正常结束。
2.2 StopIteration 不只是一个“信号”,还是一种控制流
StopIteration是迭代协议的核心控制流,它的特殊性在于:for循环、list()、tuple()、sum()、*解包,这些构造全都会吸收它。只要你用一个接受可迭代对象的内置函数,它在内部帮你就处理好了结束条件。
但要注意一个冷门坑:在生成器里,如果捕获了外部传入的StopIteration却不当回事,会触发RuntimeError: generator raised StopIteration。
def bad_generator(): yield 1 try: next(iter([])) except StopIteration: pass # 在生成器里捕获 StopIteration 是危险的 # next(bad_generator()) # 在某些情况下会 RuntimeError正确的做法是,如果你真的需要在生成器内部处理“数据源耗尽”,用return结束生成器,而不是吞掉StopIteration。
2.3 for 循环结束以后,迭代器去哪了
循环结束后,it其实还活着,只是它已经停在“耗尽”的位置。如果你保留了这个变量,后续对它调用next(),依然会抛StopIteration。for循环不会帮你把迭代器重置。
it = iter([1, 2, 3]) for x in it: pass # 此时 it 已经耗尽 print(list(it)) # []在大型代码里,如果允许一个已经用过的迭代器穿过函数边界到处传,很容易出现“数据怎么没了”的诡异现象。排查这类问题,第一步就是检查:这个变量到底是列表,还是生成器,还是数据流对象的迭代器。
3. 自定义迭代器与生成器:由浅入深的两种写法
如果你没有写过自定义迭代器,你对迭代器协议的理解就始终隔着一层纱。这里从类写到生成器,两种路线都走一遍。
3.1 手写一个迭代器类:iter和next的配合
假设我们需要一个倒计时的迭代器,每次调用吐出下一个整数,减到 1 为止,再调用就抛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 n in Countdown(5): print(n) # 输出:5 4 3 2 1这个类同时实现了__iter__和__next__,所以它既是可迭代对象,也是自身的迭代器。for循环先调用iter(Countdown(5)),由于__iter__返回自身,所以循环体内直接调用的next()就是__next__。
在实际项目里,这种写法适合“迭代逻辑比较重”的场景,比如遍历一棵二叉树的前序节点、分页读取远程接口、按批次拉取日志。你可以在__next__里维护复杂的状态机。
另一个比较常见的需求是:想让for循环无限迭代,直到外部条件结束。可以用一个哨兵值来控制:
class DataSource: def __iter__(self): return self def __next__(self): line = fetch_from_remote() # 假设每次拉取一条 if line is None: raise StopIteration return line这里的关键思路是:迭代器自己知道“什么时候停下来”。
3.2 生成器:最省事的迭代器实现
手写类虽然好懂,但平时根本不需要这么麻烦。只要函数体里出现yield,这个函数就会变成一个生成器函数,调用它不执行函数体,而是返回一个生成器对象,这个生成器对象本身就是迭代器。
def countdown(n): while n > 0: yield n n -= 1 gen = countdown(5) print(type(gen)) # <class 'generator'> print(next(gen)) # 5 print(list(gen)) # [4, 3, 2, 1]yield的底层行为可以这样理解:函数执行到yield时,把当前所有局部变量、执行位置、调用栈状态全部封存起来,返回右边的值给调用方。下一次next()进来时,从封存的位置继续执行,而不是从头开始。
所以生成器不只是迭代器,它还是一种“可暂停的函数”。这个能力让它特别适合表达“惰性计算”和“协程”思想,只是我们日常用法里,更多把它当作流式数据来源。
3.3 什么时候选生成器,什么时候老老实实用列表
我见过不少同事把所有“需要遍历一遍”的逻辑全部塞进生成器,反而把代码搞复杂。这里有一个简单的判断标准:
如果数据只会在内存中被完整使用一次,或者数据很大无法一次性载入,用生成器。如果需要反复遍历、需要随机访问、需要知道长度、需要调试时一遍遍查看内容,用列表。
来看一个对比表:
| 维度 | 列表 | 生成器/迭代器 |
|---|---|---|
| 内存占用 | 一次性载入所有元素 | 每次只保留当前元素 |
| 可重复遍历 | 可以多次遍历 | 通常只能遍历一次 |
| 支持索引 | 支持data[0] | 不支持索引 |
| 知道长度 | len(data) | 没有长度,只能边取边数 |
| 创建时机 | 创建时全部计算 | 迭代时才逐个计算 |
有一个经典坏代码:把所有日志行读进列表再逐行处理。如果日志文件有几十 GB,内存直接爆掉。而用文件对象本身迭代,或者外层包一层生成器表达式,就能在常数级内存下处理。
# 内存危险版 with open("huge.log", encoding="utf-8") as f: lines = f.readlines() # 所有行全进内存 for line in lines: process(line) # 流式安全版 with open("huge.log", encoding="utf-8") as f: for line in f: process(line)第二种写法,文件对象每次只读一行,用完即丢。这不是微优化,是能决定程序生死的结构性差异。
4. 现实开发里最常见的迭代器陷阱与排查思路
这一节的内容,全是我在项目里实际撞过墙、或给人 review 代码时反复遇到过的问题。每个场景都给出“为什么会这样”的解释,以及正确的修复方式。
4.1 同一个迭代器循环两次,第二次数据消失了
这个其实就是第 2 节讲的自然结果。但如果发生在一个复杂的函数调用链里,表现会非常隐蔽。
假设有人在函数 A 里接收一个参数data,函数 A 内部先做了一次统计,再交给函数 B 做第二遍处理:
def analyze(data): first_pass = sum(data) second_pass = [x * 2 for x in data] return first_pass, second_pass result = analyze(iter([1, 2, 3, 4]))这段代码里,sum(data)会把迭代器耗尽,第二行的列表推导式的data已经空掉,于是second_pass变成[]。表面上没有任何异常,但结果就是不对。
排查这类问题,最有效的手段是在函数入口处快速判断类型:
from collections.abc import Iterator def analyze(data): if isinstance(data, Iterator): # 先物化成列表,或者明确设计为一次遍历 data = list(data)注意,把所有Iterator都转成列表也不是银弹,它会把无限流直接卡死。更合理的设计是:先明确“这个参数是允许被重复遍历的”,然后在文档或类型注解里写清楚。如果数据源天然只支持一次遍历,那就要调整算法,改成单遍完成。
4.2 遍历列表或字典的同时修改它们
这是个很经典的问题,新手几乎都会遇到,但老手偶尔也会犯。
items = [1, 2, 3, 4, 5] for item in items: if item % 2 == 0: items.remove(item)直觉上以为会删掉 2 和 4,但你实际拿到的是 [1, 3, 4, 5] 或者出现更诡异的跳过。原因是列表迭代器内部维护着一个索引,每次删除元素,后面元素会往前挪一位,导致跳过一个元素。
字典更严格,直接抛错:
d = {"a": 1, "b": 2} for k in d: d[k + "_new"] = d[k] # RuntimeError: dictionary changed size during iteration这是 Python 的自我保护:字典在迭代时记录了自己的大小和哈希种子,一旦尺寸变化,立刻爆异常。修复思路有三种:
第一种,遍历副本:
for k in list(d.keys()): d[k + "_new"] = d[k]第二种,先收集待删除或待新增的键,循环结束后再统一操作:
to_add = {} for k in d: to_add[k + "_new"] = d[k] d.update(to_add)第三种,用字典推导或列表推导产生新结构,完全避免原地修改。大多数场景下,这个方案最清晰。
4.3 生成器内部持有文件句柄,使用时机要格外小心
以文件对象作为底层数据源的生成器,有一个隐蔽的生命周期问题。
def read_lines(): with open("huge.log", encoding="utf-8") as f: for line in f: yield line.strip()这个生成器函数返回后,with块还没有执行,直到第一次next()时才真正打开文件。看似优雅,但如果你获取生成器后,迟迟没有迭代它,文件句柄就一直悬着。如果一个循环里创建了几百个这样的生成器但都没消费完,文件描述符可能会被打满。
更好的做法是让生成器独占资源生命周期,并且尽早把“获取生成器”和“消费生成器”放在同一个with上下文里完成,或者干脆不用生成器,直接让调用方拿文件对象去迭代。
再补充一个冷门知识:生成器对象持有的是闭包里的局部变量,如果你在外层已经关闭了文件,生成器内部依然持有旧的文件对象引用,它可能会继续读取。但在跨平台或者版本升级时,这种行为并不保证稳定。所以不要依赖这种“侥幸行为”。
4.4 共享可变默认值,迭代器之间互相污染
有时候一个包装类里缓存了迭代器,却把它当作“每次都可以从头来”的容器:
class Cache: def __init__(self): self.source = iter([1, 2, 3]) def get_all(self): return list(self.source) cache = Cache() print(cache.get_all()) # [1, 2, 3] print(cache.get_all()) # []第一次调用后,内部迭代器已经耗尽。修复方式是重新创建迭代器,或者把原始数据源保存为列表。
这种问题在团队协作里很容易出现,因为写get_all的人并没有意识到self.source不是可以反复遍历的数据。
5. 用 itertools 和惰性求值写出更高级的迭代器模式
当你开始接手真实项目,你很快会发现:只靠for循环和生成器,有些场景还是不够优雅。itertools模块就是为迭代器准备的标准工具箱。
5.1 把多个数据源串起来:chain、tee、islice
如果要把两组日志合并成一组流式数据,最简单的写法是:
import itertools log1 = open("app1.log", encoding="utf-8") log2 = open("app2.log", encoding="utf-8") for line in itertools.chain(log1, log2): process(line)chain会先耗尽第一个迭代器,再自动切换第二个。它不创建列表,只完成一个接一个地转发。
想要把一个迭代器分成多路,可以用tee:
it = iter([1, 2, 3, 4]) it1, it2 = itertools.tee(it, 2) print(list(it1)) # [1, 2, 3, 4] print(list(it2)) # [1, 2, 3, 4]注意,tee不是复制数据,它内部会维护一个队列来缓冲已经生成但另一路还没消费的元素。如果你把it和it1同时拿着,数据从it里取出来,会被tee暂时缓存。因此两路速度差距过大时,内存会缓胀。tee适合两路接续消费很快的场景,不适合拿来做长生命周期缓存。
islice用来截取迭代器的一部分,非常适合需要“取前 N 个元素”的场景:
from itertools import islice def infinite_numbers(): n = 0 while True: yield n n += 1 first_ten = islice(infinite_numbers(), 10) print(list(first_ten)) # [0, 1, 2, ..., 9]没有islice的话,要自己写count和break逻辑,代码会啰嗦不少。
5.2 滑动窗口与成对处理
处理时间序列、传感器数据、股票行情时,经常需要“这一个和上一个”一起算。逐个手写下标非常痛苦。一个常见的工具函数:
def sliding_window(iterable, size=2): iters = itertools.tee(iterable, size) for i, sub_iter in enumerate(iters): for _ in range(i): next(sub_iter, None) return zip(*iters)成对处理:
pairs = sliding_window([1, 3, 5, 7], 2) for prev, curr in pairs: print(prev, curr) # (1, 3) # (3, 5) # (5, 7)这类工具很适合做增量差值计算,比如在流式日志里找连续两次请求的时间差。写代码的时候,优先找itertools是否已有现成方案,再考虑自己造轮子。
5.3 用迭代器做真正的流式聚合
我处理过一个大文件统计的任务:文件里每行一个数字,总共有 2 亿行。要求算出总和、平均值、方差。如果用readlines(),内存会直接崩溃;用迭代器加手动聚合,内存占用恒定,代码也不复杂。
import math total = 0 count = 0 sum_sq = 0 with open("numbers.txt", encoding="utf-8") as f: for raw in f: value = float(raw.strip()) total += value sum_sq += value * value count += 1 mean = total / count variance = sum_sq / count - mean * mean std = math.sqrt(variance)这段代码跑起来,内存占用不会随着文件变大而增长。核心原因就是f这个文件对象本身是迭代器,每次next()只读一行。
再进阶一点,如果你手里是一个无限流(比如实时采集的传感器数据),要算最近 5 个元素的均值,可以用deque配迭代器:
from collections import deque def moving_average(iterable, window_size=5): window = deque(maxlen=window_size) for item in iterable: window.append(item) if len(window) == window_size: yield sum(window) / window_size这种模式下,无论数据流多长,内存只维护一个固定大小的窗口。
6. 迭代能力不等于万能:什么时候该拒绝迭代器
讲了这么多迭代器的好处,但我也想把另一面讲透。迭代器不是银弹,很多时候强制用迭代器反而会把简单问题复杂化。
6.1 先看清迭代器的三个天然缺陷
第一,不能回溯。迭代器只能向前走,想回到上一个元素,除非你自己实现一个缓存栈。
def with_lookback(iterable): buffer = [] for item in iterable: buffer.append(item) yield item, buffer[-2] if len(buffer) >= 2 else None每次都要手动维护历史,成本不低。
第二,不能随机访问。iterator[n]不存在。如果你需要频繁按下标取数据,迭代器会逼着你反复next(),非常低效。
第三,没有长度。很多算法依赖len(),用迭代器之后你只能自己计数或放弃这种写法。
所以,正确的姿势是:在进入复杂算法前,把数据物化成列表,除非数据量大到让你无法承受。
6.2 实际项目里的选型建议
我给自己定了几条规矩,分享给你参考:
- 数据量小于几万条,要反复遍历、要索引、要调试、要切片?直接列表,不要为了“优雅”硬上生成器。
- 数据是外部流、每秒钟都在新增、只能消费一次?迭代器/生成器,没得选。
- 函数需要计算汇总结果,比如和、最大值、方差?可以接收迭代器,因为遍历一次就够了。
- 函数需要多次遍历入参?要么接受列表/元组,要么在入口处明确
list(data)并注释“本函数需要重复遍历”。 - 你的 API 返回的是筛选后的序列,但不希望立即执行全部逻辑?返回生成器,让调用方决定何时消费。
6.3 我看迭代器的终极理解
从底层视角再往前一步,你会发现迭代器并不是 Python 特有的概念,它本质上是一种“惰性拉取”的抽象。列表代表“已经拥有的过去”,生成器代表“不断到来的未来”。写普通业务代码时,我强烈建议把这两种心智模式分开:能拿到完整数据的,大部分时候用列表;拿不到完整数据或者不想占内存的,才用迭代器。
判断标准就一条:你手里的东西,是“已经等在那里的数据”,还是“需要你一次次去取的数据”?前者用列表,后者用迭代器。把这条原则刻进脑子里,以后再遇到循环取样、数据流、函数接口设计,就不会纠结了。
最后再分享一个实战经验。写完一个生成器函数或自定义迭代器之后,如果它只被一个for循环消费,问题还不大;可一旦你要把它传给其他函数,建议顺手写一个“是否允许重复遍历”的注释,或者用类型注解标明参数需要Iterable还是Iterator。这两者在类型层面虽然经常被混用,但语义差别巨大。我在 recent 项目里,因为同事没有区分Iterable和Iterator,把一个原本可以复用的数据源传进了一个消费两次的函数,结果整整排查了一下午。做人肉缓存很累,不如从一开始就把协议讲清楚。我希望这篇文章,能帮你省下这个下午。