你有没有想过,for循环到底是怎么把数据一个个取出来的?
我当年刚开始学 Python 的时候,写for i in some_list那叫一个顺手。直到有一天,隔壁组的同事问我:"那如果 some_list 不是列表,换成生成器呢?" 我愣住了。后来翻开源码、看官方文档,又自己手写了个迭代器,才真正明白——原来for循环根本不是"循环",而是一场迭代器和可迭代对象之间的默契配合。
这篇文章,我就用最容易理解的方式,把Python迭代器(Iterator)背后那套机制掰开揉碎讲清楚。从for循环的执行流程、__iter__和__next__的协议细节,到怎么手写迭代器、怎么用生成器偷懒,再到大家踩过最多的坑(包括我自己的黑历史),全都会覆盖到。无论你是刚学 Python 的新手,还是写过一阵子项目的老手,这篇文章都能让你在"循环"这件事上少走弯路。
1. 先搞清楚一件事:for 循环到底在循环什么
1.1 一个让我惊讶的事实:for 循环不是"循环"
很多人跟我以前一样,把for理解成 C 语言里的"循环",觉得它就是在反复执行一段代码、用一个变量去取某个"位置"上的数据。但在 Python 里,这个理解是错的。for的本质是遍历(iterate)——它不是在"循环",而是在消费一个迭代器,直到对方告诉你"没货了"。
我给你看一个最简单的证据。在 Python 里,你可以写出这样的代码:
for i in 5: print(i)结果抛出的异常是TypeError: 'int' object is not iterable。注意,它说的是 "notiterable",不是 "notloopable"。因为在 Python 世界里,能被for遍历的前提,是对象实现了迭代协议。这个协议,就是这整篇文章的核心。
1.2 手动模拟一次 for 循环的执行过程
为了验证我上面的说法,我当初写了一个不带for关键字的"假 for 循环",效果一摸一样:
def my_for(iterable, func): # 第一步:拿到一个迭代器 iterator = iter(iterable) # 第二步:循环调用 next,一次拿一个 while True: try: value = next(iterator) except StopIteration: # 迭代器告诉我们"没货了",结束 break # 第三步:把取到的值交给用户函数处理 func(value) my_for([1, 2, 3], lambda x: print(x))这段代码完美复刻了for的内部行为,一共就三个动作:
iter(iterable)—— 从可迭代对象身上拿到一个迭代器。next(iterator)—— 每轮从迭代器里取出一个元素。StopIteration异常 —— 当取不到元素时,迭代器抛出这个异常,for循环捕获后就乖乖退出。
你平时写一万次for循环,Python 源码里干的事跟我上面这段几乎一模一样。看懂了这段,你就算彻底入门了。
1.3 为什么 Python 要设计成这样
你可能会问:干嘛不直接用i = 0; i < len(list); i += 1这种下标访问?Python 用这个"迭代器协议"到底图什么?
我个人的理解是三个字:统一性。列表有下标可以用索引,那字典呢?集合呢?文件对象呢?要是每个类型都设计一套自己的遍历方式,那for循环就得为每种类型写不同分支了。迭代器协议把这一切统一成一个接口——只要你有__iter__和__next__,我for就能无脑取。这个设计和 Java 的Iterator接口、C++ 里的迭代器模式其实是同一个思路,只是 Python 用协议 + 异常的方式把它藏得更深,用起来更省心。
2. 可迭代对象和迭代器:傻傻分不清的两个核心概念
2.1 一张图讲清两者的关系
这是初学者最容易搞混的地方,我把道理讲透。可迭代对象(Iterable)是"可以被遍历"的对象,迭代器(Iterator)是"负责遍历"的对象。for拿到的是可迭代对象,但真正一个一个取数据的,是迭代器。
用生活类比:可迭代对象是"一本菜单",迭代器是"上菜的服务员"。你可以一直盯着菜单看(反复拿到新的迭代器),但不是菜单本人在给你上菜,服务员才是那个负责一道菜一道菜端到你面前的人。服务员的记性(迭代器状态)在一次上菜过程中是会变的,菜上完了,服务员这一轮的工作就结束了。
2.2 怎么检查一个对象是哪种角色
Python 给出了两个标准方法:isinstance(obj, Iterable)和isinstance(obj, Iterator)。但需要注意,类名容易误导人。
from collections.abc import Iterable, Iterator lst = [1, 2, 3] print(isinstance(lst, Iterable)) # True —— 列表是可迭代的 print(isinstance(lst, Iterator)) # False —— 但列表本身不是迭代器 it = iter(lst) print(isinstance(it, Iterable)) # True —— 迭代器一定是可迭代的 print(isinstance(it, Iterator)) # True —— 但它还是迭代器这引出一个关键结论:迭代器一定是可迭代对象,但可迭代对象不一定是迭代器。列表、字典、集合、字符串都是可迭代对象,但它们都没有__next__方法,所以不是迭代器。真正的迭代器,是通过调用iter()从这些对象身上"提取"出来的。
2.3 为什么列表能反复 for,迭代器只能走一遍
很多人的困惑在于:"为什么for列表可以循环一百遍都没事,for一个生成器,第二遍就空了?" 秘密就在iter()这个方法上。
列表的__iter__每次被调用时,会返回一个全新的迭代器,记录从第 0 个位置开始的遍历状态。所以你每次都从头开始,自然怎么遍历都不嫌腻。而迭代器本身的__iter__方法,返回的是它自己——这就意味着,迭代器一旦被创建,它内部就带有一个"当前位置"的指针,每next一次,指针就往前走一步,绝不回头。走完了,指针到末尾,这个迭代器就彻底没用了。
比如这个经典场景,几乎每个 Python 程序员都踩过:
numbers = [1, 2, 3, 4, 5] it = iter(numbers) print(next(it)) # 1 print(next(it)) # 2 # 此时列表本身还是完整的,可以继续从头遍历 for n in numbers: print(n) # 1 2 3 4 5 # 但迭代器 it 已经走到了一半,继续 next 就是 3、4、5 print(list(it)) # [3, 4, 5]理解了这一点,很多诡异 bug 就已经能躲掉一半了。
3. 手写一个迭代器:从零实现完整协议
3.1 最小流程:把iter和next成套实现
如果你要自己定义一个迭代器,协议要求你必须实现两个方法,缺一不可:
__iter__(self):返回迭代器自身。虽然听起来像废话,但它是协议的一部分,保证迭代器本身可以被for。__next__(self):返回下一个元素。如果没元素了,就抛StopIteration。
我写项目时最常用的是倒计时器,代码短,但五脏俱全:
class CountDown: def __init__(self, start): self.current = start def __iter__(self): # for 循环碰到这个对象时,会调用 iter() return self def __next__(self): # 倒计时值为 0 之前,每次返回值都减 1 if self.current <= 0: raise StopIteration # 没货了,就抛异常让 for 停下 self.current -= 1 return self.current + 1 for n in CountDown(5): print(n) # 输出 5 4 3 2 1这里我特意讲一下异常的设计哲学。Python 是EAFP(Easier to Ask for Forgiveness than Permission,先做再请求原谅)风格,它不流行用"布尔值返回 + 标志位检查"来告诉外面"我到底还有没有下一个",而是直接用异常来做终止信号。这样有个好处:调用方写起来非常干净,一个for加一个try,不用每个next都手动检查条件。
3.2 进阶实操:写一个无限序列的斐波那契迭代器
工作中经常会遇到"不知道到底要算到第几个才算完"的场景。无限序列用迭代器表达特别优雅,因为计算是惰性的:你不next,它就不算。
class Fibonacci: def __init__(self, limit=None): self.limit = limit self.a, self.b = 0, 1 self.count = 0 def __iter__(self): return self def __next__(self): if self.limit is not None and self.count >= self.limit: raise StopIteration self.a, self.b = self.b, self.a + self.b self.count += 1 return self.a fib = Fibonacci(10) for num in fib: print(num) # 1 1 2 3 5 8 13 21 34 55说句踩过坑之后的总结:迭代器内部的状态最好集中维护在少数几个字段里。像这里的a、b、count,一旦你开始给迭代器加各种分支状态判断,代码很快就会乱成一团。如果真觉得维护状态很繁琐,那说明你需要用下一节的生成器了。
3.3 实际应用:用迭代器读大文件,内存不再爆炸
我有个实际项目是在处理几 GB 的日志文件,如果用readlines()把整个文件读取到内存,机器直接卡死。而文件对象本身就是迭代器,我们可以写一个通用的分块读取迭代器,完全按需加载:
class ReadChunks: def __init__(self, file_path, chunk_size=1024): self.file = open(file_path, 'r', encoding='utf-8') self.chunk_size = chunk_size def __iter__(self): return self def __next__(self): chunk = self.file.read(self.chunk_size) if not chunk: # 没内容了就是文件读完了 self.file.close() raise StopIteration return chunk for chunk in ReadChunks('huge.log', 4096): process(chunk) # 每次只处理 4KB 数据注意:很多文档里推荐用
with open()管理文件生命周期,那在正式代码里确实更标准。我上面写的是为了让__next__逻辑紧凑,真用的时候,你可以在StopIteration分支里加一个显式close()兜底,或者用生成器写法让with和作用域更清晰。
4. 生成器:省下 80% 写迭代器的力气
4.1 yield 的本质:函数被暂停了,而不是被中断
如果说迭代器协议是"手动挡",那生成器就是"自动挡"。一个函数只要里面出现了yield关键字,它就不是普通函数了,而是一个生成器函数。你调用它不会立刻执行函数体,而是返回一个生成器对象——这个对象其实就是一个隐式的迭代器。
我用一个例子感受一下"暂停"和"恢复":
def count_down(start): print("开始倒计时") while start > 0: yield start start -= 1 print("倒计时结束") gen = count_down(3) # 没有任何打印发生 print(next(gen)) # 打印"开始倒计时",然后输出 3 print(next(gen)) # 输出 2 print(next(gen)) # 输出 1 print(next(gen)) # 打印"倒计时结束",然后抛 StopIteration注意print(next(gen))的三次调用:第一次进入函数时,代码从第一行执行到第一个yield,然后整个函数被冻结,包括局部变量值、执行位置,全都保留。下一次调用next(gen),函数从上次yield的地方继续,而不是从头开始。这就是我说的"暂停恢复"。
4.2 斐波那契生成器:代码少一半,可读性强一倍
前面那个斐波那契迭代器要写六七行,换成生成器就四行:
def fibonacci(limit=None): a, b = 0, 1 count = 0 while limit is None or count < limit: yield a a, b = b, a + b count += 1每次yield a暂停的时候,Python 会把整个调用栈连同a、b、count的状态全部保存下来,等下一次调用再恢复。这个机制的底层是栈帧冻结,不用管太细,记住"生成器就是带记忆的函数"就够了。
4.3 生成器表达式:列表推导式的一个"节省版"
你肯定写过[x * x for x in range(10)],但你想没想过,如果你只打算遍历一遍,完全没必要把 10 个平方值都存在一个列表里?换成生成器表达式,用圆括号就行:
squares = (x * x for x in range(10)) # 生成器表达式 print(squares) # <generator object ...> for s in squares: print(s)列表推导式会一次性算出所有元素放进内存,生成器表达式则是一个一个算。数据量小看不出差别,数据量大的时候,比如range(10_000_000),你用列表推导式可能直接内存爆掉,用生成器表达式,内存占用几乎为 0。所以我的习惯是:只要我不需要通过下标随机访问数据,一律用生成器表达式。
4.4 send:往生成器里反向塞东西
生成器除了可以往外yield值,还能接收外面传进来的值,这个方法是send(value)。虽然日常用得少,但理解它,你会对"生成器 = 可暂停可恢复的程序"有更透彻的认识。
def axis(): x = 0 while True: received = yield x x = received if received is not None else x + 1 a = axis() print(next(a)) # 0,yield 把 x 传出来 print(a.send(100)) # 100,外部把值传回去,x 被设为 100 print(next(a)) # 101这个能力是协程的雏形,很多异步框架的底层就是靠这种"双向通信"实现的。不过对于普通业务开发来说,理解到"生成器能保存状态"这一步已经够了。
5. 实战演练:for 循环背后机制的完整复刻
5.1 我如何用 while 逼真模拟一个 for 循环
纸上得来终觉浅,我建议你也真的把for手写一遍。这里我做一个更完整的版本,支持break和continue的信号返回:
def simulate_for(iterable, body): it = iter(iterable) while True: try: item = next(it) except StopIteration: break signal = body(item) if signal == "break": break elif signal == "continue": continue # 使用示例:打印 1 到 10,但遇到 5 就提前停 def print_item(x): print(x) if x == 5: return "break" simulate_for(range(1, 11), print_item)虽然官方for是语言层面的语法,不是真的靠"信号返回"实现,但你会发现它和真实for的执行路径几乎一致:初始化迭代器 → 捕获异常 → 执行循环体 → 判断是否继续 → 继续next。理解这个流程,碰到各种"为什么这里多跑了一次""为什么那里少跑了一次"的怪问题时,你就能自己推断了。
5.2 提前退出和无限迭代:最容易被坑的地方
现实中,很多循环数据集是无穷无尽的。比如流式消息、网络包、传感器读数。你没法预知总量,只能一个个往下取。此时迭代器的惰性特性就派上大用场了,配合itertools标准库,能玩出花来。
import itertools # 无限计数器 for i in itertools.count(10, 2): # 10, 12, 14, ... if i > 20: break # 无限重复某个序列 for item in itertools.cycle(['A', 'B', 'C']): # 永不停止,必须用 break break # 取前 N 个有限值 for item in itertools.islice(itertools.count(0), 5): print(item) # 0 1 2 3 4使用itertools写无限数据流是我特别喜欢的用法,它让我们把"边界控制"完全交给调用方去break,而生产方根本不用关心到底有多少数据。这在事件处理、轮询系统的代码里特别干净。
5.3 标准库里到处都是迭代器的身影
写代码久了你会发现,Python 标准库中凡是跟"遍历一条条数据"有关的函数,几乎都是基于迭代器协议的。enumerate返回的是一个枚举对象,zip合并多个序列返回的也是一个可迭代的 zip 对象,range在 Python 3 里并不生成列表,它本身就是一个惰性序列。文件对象open()返回的东西直接就能for line in f,底层就是每次next(f)读取一行。
with open("data.txt") as f: for line in f: # 这行没有 readlines,没有列表,内存友好 print(line.strip())官方文档也写着"fileobjects are their own iterators"——文件对象本身就是迭代器。你越深入越会发现,整个 Python 数据处理的"血液"里流的就是迭代器。
6. 常见问题与排查技巧实录
6.1 明明打印有值,sum / list / max 却返回空
这个坑 99% 的新手都会踩,而且是"排查半天发现是迭代器被消费完了"的典型。看这段:
data = (x * 2 for x in range(5)) print(sum(data)) # 20,第一次迭代,正常 print(sum(data)) # 0,迭代器已经空了为什么第二次是 0?因为sum内部本质上是在做for循环,它会从头到尾next一遍,把整个生成器消费完。第一遍已经到底了,第二遍自然什么都取不到。迭代器不可复用是它的根本属性。解决办法也很直白:如果你真需要重复遍历,就把数据转成列表缓存起来,或者每次从头重新生成一个新迭代器。
6.2 为什么 next() 一直疯狂报 StopIteration
如果你在循环里手动调next(),且没有处理异常,程序就会直接崩溃:
it = iter([1]) print(next(it)) # 1 print(next(it)) # 抛出 StopIteration,程序就挂了这是正常的,StopIteration就是协议的一部分,for循环会捕获它,但你的裸调用不会。想避免崩溃,要么用default_value = next(it, 'fallback')给个默认值,要么主动用try/except包起来。在写"外部要捕捉的迭代器"时,我习惯用next(it, None)做边界处理,但要注意:如果你的数据里本身存在合法的None,这就变得危险了,建议换成哨兵值或异常。
6.3 Python 3 的 range 到底是什么
很多人从 Python 2 转过来后,一直担心range会生成巨大列表。其实 Python 3 的range返回的是一个range对象,它是惰性计算的、支持长度和索引的序列类型,不是迭代器,也不是一次性生成所有元素的列表。不信你可以看range(10**10),定义这一行几乎不占内存。
判断它是不是迭代器:iter(range(10))会返回一个range_iterator,这说明range本身是可迭代对象而不是迭代器。所以你可以反复for i in range(10)一万遍,完全没问题,范围对象不会因为被遍历了一次就枯竭。
6.4 自定义对象放进 for 循环报 TypeError 的排查套路
当你的自定义类for item in my_obj报错TypeError: 'MyClass' object is not iterable时,按照三个步骤排查:
- 确认类里有没有定义
__iter__方法。如果没有,for根本拿不到迭代器。 - 如果定义了
__iter__,确认它return的是一个迭代器,而不是写在return self却又没有__next__的情况。 - 如果
__iter__返回的是其它对象,确认那个对象正确实现了__next__,并且在耗尽时抛StopIteration。
这三条是我在代码 review 中被问得最多的问题。
7. 性能、进阶与我的体会
7.1 迭代器是否比列表更快?内存和速度要分开看
很多人想当然认为"迭代器快",这个说法不够准确。迭代器最大的优势是内存省、响应快——由于它按需计算,你永远不需要一次性把全量数据搬进内存。但在单次取值的 CPU 开销上,迭代器通常不会比列表访问慢多少,甚至在一些场景下略慢,因为它多了next()函数调用和异常处理的开销。
我处理大规模数据分析时的策略是:如果数据量超过内存的 1/10,优先用迭代器或生成器;如果只是几十万的小数据,直接用列表,代码更简单。不要为了"显得高级"而盲目把所有列表推导式改成生成器表达式,性能收益小,还容易给自己后续调试带来麻烦。
另一个值得注意的细节是iter()的拆包引出的惰性特性:
a, b, *rest = iterator # 可以和解包一起用解包本质上也是在消费迭代器,而惰性让你可以优雅地把"前几个重要、后面的兜底"这样的业务逻辑直接表达出来。
7.2 迭代器在框架和库中无处不在
你会发现很多框架的高级功能都离不开迭代器。Django 的 QuerySet 本身是可迭代的,所以你可以直接for post in posts,它会在底层按需从数据库加载记录。pandas 的read_csv提供了chunksize参数,返回的 TextFileReader 是一个迭代器,让你一块一块地读取超大 CSV 而不用装进内存。Spark 的 RDD 操作更是典型的懒执行迭代器思想——整个计算过程都是惰性的,你行动操作的时候才真的开始计算。理解了 Python 的迭代器,你理解这些框架会容易得多。
7.3 我踩过的一个真实大坑
去年有个数据清洗项目,我需要多次过滤同一份日志数据。刚开始图省事,我把日志读取封装成一个生成器函数read_logs(),然后直接filtered_1 = [x for x in read_logs() if ...]、filtered_2 = [x for x in read_logs() if ...]。第一遍没问题,但我为了省事把logs = read_logs()存下来复用,结果第二遍遍历的时候,因为生成器已经耗尽,所有结果为空。当时查了整整半小时,最后发现是这个"一次性"的坑。从那以后我给自己定了个规矩:凡是要多轮遍历的数据,第一步先转成 list 或 tuple 快照。宁可多吃内存,也不在逻辑上埋地雷。
7.4 最后分享一个小技巧
如果你正在写一个"需要逐个处理海量数据"且"处理逻辑比较复杂"的程序,别急着定义__next__类,先试着用生成器函数搭配yield from拆分任务。yield from允许你在一个生成器里"委托"给另一个生成器或迭代器,代码结构可以相当清晰:
def lines_from_files(file_paths): for path in file_paths: with open(path, "r", encoding="utf-8") as f: yield from f这样一个生成器就能串联多个文件逐行输出,内存消耗始终恒定。你在处理日志合并、数据流汇总等场景时,会发现这个模式极其顺手。
我对迭代器的整体体会是:它不只是语法糖,而是 Python 数据抽象的灵魂之一。一旦你从"循环变量"的思维切换到"迭代器消费"的思维,写出来的代码在内存占用、代码复用和抽象层次上都会有质的提升。希望这篇拆解,能帮你在for循环这条路上,看到一个更大的世界。