在 Python 的学习路上,几乎每个人都写过 for 循环。但如果你仔细观察for i in range(10**8)这样的代码,会不会冒出一个疑问:这个循环真的会生成一个包含一亿个整数的列表吗?如果会,为什么我的电脑没有被撑爆?我第一次认真思考这个问题,是在一次面试被问到“range(108) 和 list(range(108)) 的内存占用差多少”的时候,当场愣了一下。后来我把迭代器(Iterator)协议彻底翻了一遍,才发现 for 循环远不止是一个语法糖,它背后藏着一套相当精巧的设计。
这篇文章就来聊聊 for 循环背后那套被大多数人忽略的故事。我会从iter()和next()这两个函数讲起,带你手写一个迭代器,再用生成器把代码写得更优雅,最后分享几段我在项目里踩过的迭代器相关的坑。无论你是刚学完 Python 基础、想真正理解循环本质的新手,还是准备面试、想补齐这块知识点的同学,这篇都应该能帮到你。
1. for 循环的三件套:iter()、next() 与 StopIteration
很多 Python 学习者写了很久的 for 循环,却不知道 for 循环内部真正做了什么。其实 for 循环并不是什么高深莫测的魔法,你完全可以用 while 循环把它模拟出来。理解了这个等价过程,你就掌握了迭代器协议的入口。
1.1 用 while 手搓一个 for 循环
假设我们有一个列表,想要逐个打印里面的元素。常规写法是:
lst = [1, 2, 3] for item in lst: print(item)但这段代码在解释器内部,做的事情其实可以拆解成下面这三步:
lst = [1, 2, 3] it = iter(lst) # 第一步:调用 iter() 拿到迭代器 while True: try: value = next(it) # 第二步:反复调用 next() 取下一个元素 except StopIteration: # 第三步:耗尽时捕获异常,结束循环 break print(value)看到这里,很多读者会有一个疑惑:为什么循环结束要依靠一个异常?每次取元素之前判断一下“还有没有下一个”不行吗?这恰恰是 Python 风格和 C 风格之间非常典型的一个差异。Python 的设计哲学更偏向“请求原谅比请求许可更容易”(EAFP),与其每次小心翼翼地去查询状态,不如直接取值,取不到就抛一个StopIteration,循环自然结束。for 循环内部把这一整套 try/except 封装得干干净净,你看到的就是一个简单的for item in obj。
真正理解了这段等价代码,很多问题就迎刃而解了。比如为什么 for 循环能遍历文件对象、能遍历生成器、能遍历字典的 key,却不能在整数上循环?因为整型没有实现迭代协议,iter(3)会直接抛出TypeError: 'int' object is not iterable。
1.2 可迭代对象和迭代器,真的是两回事
这里要区分两个高频概念:可迭代对象(Iterable)和迭代器(Iterator)。
- 可迭代对象:能被
iter()函数处理、能返回一个迭代器的对象。比如列表、元组、字符串、字典、集合、range。 - 迭代器:实现了
__next__()方法的对象,调用next()会返回下一个元素,没有元素时抛StopIteration。
概念有点绕,我用一张表帮你理清:
| 对比项 | 可迭代对象 | 迭代器 |
|---|---|---|
| 常见代表 | list、tuple、str、dict、set、range | 文件对象、生成器、iter(lst)的返回值 |
是否支持iter() | 是 | 是 |
是否支持next() | 否 | 是 |
| 能否重复遍历 | 能,每次生成新的迭代器 | 不能,消费完就没了 |
| 是否保存所有数据 | 看类型,list 保存,range 不保存 | 通常惰性生成,不保存全部数据 |
判断标准也特别简单:直接对它调用next(),能正常工作,它就是迭代器;不能,它大概率只是可迭代对象。这里要特别提醒一句,我见过不少同学在写函数参数时把 list 当迭代器传,或者反过来把迭代器当列表用,结果程序运行到一半才发现类型对不上。这个区分不是考试知识点,是日常写代码一定会遇到的。
1.3 细节:iter() 对迭代器调用,返回的还是它自己
还有一个容易被忽略的细节:如果你对一个迭代器再次调用iter(),返回的是它自己。换句话说,迭代器是自己的迭代器。
lst = [1, 2, 3] it = iter(lst) print(it is iter(it)) # True这个特性保证了 for 循环在语法上的统一:无论你传入的是一个列表,还是一个迭代器,for 循环内部执行iter(obj)时,都能稳当地拿到一个迭代器来迭代。这也是为什么很多标准库函数直接接受“可迭代对象”作为参数,而不用关心你传的到底是哪种类型。
理解这几个概念只是热身。要真正说清楚迭代器为什么被设计成这样,还得从内存效率讲起。下一节我用 range 和大文件两个场景来实测一下。
2. 惰性求值:迭代器最值得说的一项设计
2.1 “取一个、吐一个”,绝不提前加班
迭代器最核心的工作方式,是惰性求值(lazy evaluation)。通俗讲就是:你不找它要,它就不给你;你每要一个元素,它才临时算一个出来,算完就忘,绝不囤货。
这跟现实里“食堂打饭”很像:你走到窗口前,阿姨才给你舀一勺,而不是提前把全校几千人的饭都盛好放在窗口等你。如果真把所有元素一次性生成出来再遍历,内存压力会非常大,尤其是处理大文件、数据库查询结果这类数据源时。
也许有读者会想:惰性求值会不会让代码变慢?实际上绝大多数业务场景下,逐次计算的开销远小于一次性生成大量临时对象的开销,而且省下的内存往往比省下的那点 CPU 时间更值钱。尤其是当你的服务器内存本身就紧张的时候,惰性求值是救命级别的设计。
2.2 实测 range(10**8) 的内存占用
我们用 Python 内置的sys.getsizeof做一个直观的对比实验:
import sys big_list = list(range(10**8)) print(sys.getsizeof(big_list)) # list 版本的内存占用 print(sys.getsizeof(range(10**8))) # range 对象的内存占用range 对象保存的只是 start、stop、step 这几个参数,需要取值时现场计算,所以只占几十个字节。而 list 版本在 64 位 Python 中,光是指针数组就已经是 8 字节乘以一亿,差不多 800MB,这还没算整数对象本身的开销。也就是说,range(10**8)本身轻如鸿毛,但如果你手贱在外面包了一层 list,内存就彻底爆了。
注意:
list(range(10**8))在内存小的机器上很可能直接导致内存不足,建议不要在本地随便试。真要遍历这么大的范围,直接for i in range(10**8)就好。
这就是为什么 Python 3 中 range 不再像 Python 2 那样真的构造一个巨型列表。语言层面已经把迭代器思想内化到了内置对象中,只是很多初学者根本没有意识到。
2.3 大文件读取:readlines 和逐行 for 的差别
再看一个更贴近日常工作的工程场景。处理文本日志时,新手很容易写出这样的代码:
with open("app.log", "r") as f: lines = f.readlines() # 一次性把所有行读进内存 for line in lines: process(line)如果文件只有几 MB,问题不大。但生产环境里单日日志动辄几个 GB,readlines()会一次性把整个文件读入内存,很容易把服务器内存吃满,甚至直接把定时任务跑挂。我有段时间负责清理线上日志,就亲眼见过一个几 GB 的 Nginx 日志文件,把同事写的readlines()脚本活活卡死。
正确姿势是利用文件对象本身就是迭代器这一点,直接:
with open("app.log", "r") as f: for line in f: process(line)这两种写法在结果上几乎一致,但内存表现天差地别。逐行遍历时,文件迭代器每次只从缓冲区拉回一行,处理完上一行后,它占用的内存就能被回收再利用。实际线上环境里,我用这个方法处理过几个 GB 的日志文件,内存峰值始终控制在几十 MB 级别。类似地,数据库查询也可以用.fetchmany()分批取出,而不是一次性把几百万行全装进内存再处理。对这些场景而言,迭代器不是“锦上添花”,而是“不做就会出事”。
3. 从 0 到 1:手写你自己的迭代器
理解了迭代器协议之后,就可以自己实现一个迭代器了。这在封装自定义数据结构、做数据管道的时候非常有用。虽然日常开发中直接手写迭代器的频率不算高,但把这个过程走一遍,你会对协议的理解上一个台阶。
3.1 规范:iter和next缺一不可
在 Python 里,一个对象要成为迭代器,必须实现两个方法:
__iter__(self):返回迭代器自身。这样iter()调用它时,就能拿到一个迭代器。__next__(self):返回下一个元素;没有更多元素时,抛StopIteration。
如果你只想让一个对象“可被迭代”,而不是让它成为迭代器,那只需要实现__iter__返回一个迭代器对象即可。但如果这个对象自己就是迭代器(比如下面这个斐波那契例子),那__iter__通常就返回self。
3.2 实例:可定长的斐波那契迭代器
斐波那契数列是特别适合练手迭代器的例子,因为它天然是“按需生成”,根本不需要预先算好所有项。
class FibonacciIterator: """生成斐波那契数列的迭代器,最多生成 max_count 个。""" def __init__(self, max_count=None): self.max_count = max_count self.count = 0 self.a, self.b = 0, 1 def __iter__(self): return self def __next__(self): if self.max_count is not None and self.count >= self.max_count: raise StopIteration result = self.a self.a, self.b = self.b, self.a + self.b self.count += 1 return result调用方式:
fib = FibonacciIterator(10) for num in fib: print(num) # 0 1 1 2 3 5 8 13 21 34注意__next__里最先做的是判断是否到上限,到了就抛StopIteration,这样 for 循环才会正常结束。如果不设max_count,这个迭代器理论上可以无限生成,使用时就要靠外部条件控制何时跳出。
3.3 实例:大日志文件的分块读取迭代器
斐波那契偏向教学,下面这个更贴近实际需求。假设你有一个超大文本文件,想按固定行数分块读取,而不是一次性读入。这个需求在日志分析、批量导入场景里很常见:
class ChunkReader: """按指定的块行数读取文本文件。""" def __init__(self, file_path, chunk_size=1000): self.file_path = file_path self.chunk_size = chunk_size self._file = None def __iter__(self): self._file = open(self.file_path, "r", encoding="utf-8") return self def __next__(self): if self._file is None: raise StopIteration lines = [] for _ in range(self.chunk_size): line = self._file.readline() if line == "": break lines.append(line) if not lines: self._file.close() self._file = None raise StopIteration return lines使用起来非常干净:
for chunk in ChunkReader("huge.log", chunk_size=2000): process(chunk)这里的__iter__在每次重新迭代时打开文件,__next__每次收集 2000 行,直到文件读完了关掉文件并抛StopIteration。调用方完全不关心文件内部怎么读,只需要用 for 循环去消费 chunk 就行,数据管道写起来非常舒服。
提示:如果文件行数恰好是 chunk_size 的整数倍,最后一次循环会在读完最后一批后再次调用
__next__,此时 lines 为空,所以必须正确关闭文件并抛StopIteration。这个边界一定要处理,否则会出现死循环或读到空块。
写到这里相信你已经发现,手写迭代器并不难,但代码量确实不少。如果只是想在函数里临时生成一个可迭代序列,Python 还提供了一种更轻量的写法——生成器。
4. 生成器:更轻量、更优雅的迭代器工厂
4.1 yield 一出现,函数就“变质”了
在 Python 里,任何包含yield关键字的函数,调用后并不会立刻执行函数体,而是返回一个生成器对象。生成器本身就是一个迭代器,它把上一节手写的状态保存、StopIteration 处理等底层细节全部自动搞定。
用生成器重写斐波那契:
def fibonacci(max_count=None): a, b = 0, 1 count = 0 while max_count is None or count < max_count: yield a a, b = b, a + b count += 1 for num in fibonacci(10): print(num)对比手写类版本,代码量几乎砍了一半,而且可读性更强:每次执行到yield就会暂停,把a的值返回给调用方;下一次 for 循环继续时,从暂停的地方往下走,a、b、count这些局部变量都还在。理解到这一层,再看那些用生成器写的代码,就不会觉得是魔法了。
4.2 生成器表达式:列表推导式的“内存友好版”
列表推导式大家很熟悉:
squares = [x * x for x in range(1000000)] # 一次性生成 100 万个元素如果只是遍历一次,完全可以用生成器表达式,把方括号换成圆括号:
squares = (x * x for x in range(1000000)) # 惰性生成的生成器两者的内存差异非常大。前者立即生成一个装满 100 万元素的列表,后者只是一个生成器对象,等遍历时再逐个计算。我经常在需要把数据传给sum()、min()、max()等聚合函数时使用生成器表达式,既能减少临时列表,代码也不难读。比如计算 1 到 1 亿的平方和,用生成器表达式就不会把一个上亿元素的列表放进内存。
4.3 yield from:摊平嵌套迭代
如果生成器内部还要再迭代另一个可迭代对象,可以用yield from简写:
def flatten(nested): for sublist in nested: yield from sublist for x in flatten([[1, 2], [3, 4], [5]]): print(x) # 依次输出 1 2 3 4 5它的效果和下面的代码等价:
def flatten(nested): for sublist in nested: for item in sublist: yield itemyield from省掉了一层 for 嵌套,在递归处理树形结构、流式解析嵌套数据时特别方便。比如从多层嵌套的 JSON 结构中提取所有叶子节点,用yield from递归生成器几乎是最简洁的写法。
4.4 生成器不只是能取值:send、throw、close
生成器还可以接收外部传入的值,这就是send()。它的使用场景不如常规迭代那么频繁,但在写协作式任务、状态机时非常有用。简单示例:
def echo(): while True: received = yield print("收到:", received) gen = echo() next(gen) # 启动生成器,走到第一个 yield gen.send("hello") # 输出: 收到: hellothrow()可以在生成器内部抛出异常,close()则用来手动结束生成器。这些方法在基础的 for 循环里用不到,但如果你理解迭代器协议,再看它们就会觉得很自然:本质上都是调用方和生成器之间的通信与控制。面试中聊到这里,往往能体现出你对 Python 的掌握程度不止于增删改查。
5. 迭代器不只是 for 循环的陪衬:三个值得主动使用的场景
5.1 无限序列 + islice:按需求切片
生成器本身没有长度。当你拥有一个无限序列时,不能直接for i in infinite_seq,那样会永远跑下去。而itertools.islice正好是迭代器的“切片工具”,可以从迭代器中精准取出前 N 个元素:
from itertools import islice def natural_numbers(): n = 1 while True: yield n n += 1 first_ten = list(islice(natural_numbers(), 10)) print(first_ten) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]这个组合在数据流处理里非常实用。比如你有一个不断产生数据的传感器接口,或者一个持续增长的日志流,用islice定期取一截出来分析,而不用把整个流全部加载进来。
5.2 itertools 组合拳:chain、count、cycle
标准库itertools里几乎全是针对迭代器的高频工具:
chain(*iterables):把多个可迭代对象串成一个,避免写嵌套循环。count(start, step):无限计数,配合islice做等差数列。cycle(iterable):无限循环某个序列,做轮询调度时很好用。
from itertools import chain, cycle, islice merged = list(chain([1, 2], ["a", "b"])) # [1, 2, 'a', 'b'] jobs = list(islice(cycle(["worker_a", "worker_b"]), 4)) # ['worker_a', 'worker_b', 'worker_a', 'worker_b']这些小工具写业务代码时不一定天天用得上,但一旦遇到“把多个序列串起来遍历”、“轮流分配任务”这类需求,它们比手写一堆 for 循环和临时变量优雅得多。
5.3 zip 与 enumerate 的“一次性”提醒
zip和enumerate的返回值在 Python 3 中都是可迭代对象,有些还是迭代器。最常见的坑是:把zip的结果重复遍历,第一次能打印,第二次却什么也没有。
pairs = zip(["a", "b"], [1, 2]) print(list(pairs)) # [('a', 1), ('b', 2)] print(list(pairs)) # [] <- 第二次已经空了解决办法很简单:如果需要反复使用,就显式转成 list;如果只遍历一次,用迭代器反而更省内存。
另外一个工程细节是:当两个列表很长时,不要用for i in range(len(a))再通过下标去取b[i],直接用zip(a, b)或enumerate(a)会更清晰,也避免了下标越界问题。这也是把迭代器思想应用到日常编码中的体现。
6. 我在项目里踩过的三个“迭代器”坑
6.1 迭代器被消费后不会回到原点
这是我见过最多的错误。很多人在第一次遍历生成器后,以为还可以像列表一样再遍历一次:
numbers = (x for x in range(5)) print(sum(numbers)) # 10 print(sum(numbers)) # 0sum()内部会遍历并消费整个生成器,第二次调用sum()时它已经空了,所以结果是 0。如果业务逻辑里确实需要多次求和或多次遍历,请用列表保存数据源;如果数据太大不能转列表,就重新创建生成器。这个坑在写数据处理流水线时尤其容易踩,因为你可能在一个函数里把迭代器传进去又传出来,绕了一圈,它已经被上一个调用者“榨干”了。
6.2 遍历字典时千万不要改它的大小
在 for 循环遍历 dict 的同时,如果往字典里添加或删除键,会触发一个RuntimeError: dictionary changed size during iteration。这是因为字典迭代器在迭代时会检查字典的结构版本号。
如果确实需要“边遍历边过滤”,有两种常见做法:一是先拷贝 key 列表再循环;二是用字典推导式,这在绝大多数场景下更优雅:
data = {"a": 1, "b": 2, "c": 3} filtered = {k: v for k, v in data.items() if v > 1}顺带一提,修改字典中已有键对应的值是可以的,因为这不改变字典大小,不会触发运行时错误,但最好也统一用更新方式处理,避免依赖这种隐式行为。
6.3 别动不动就把迭代器转成 list
把迭代器转成 list 虽然方便调试,但会破坏惰性求值带来的红利。一些新手在拿到文件对象、数据库游标后,第一件事就是list(...),结果大文件照样把内存吃满。
调试时可以配合itertools.islice先取一小部分看看:
from itertools import islice first_5 = list(islice(huge_sequence, 5)) print(first_5)这样既看到了数据,又没有一键加载全部内容。我个人的习惯是:迭代器一般都保持它的惰性,只有在确实需要随机访问、反复遍历时才转成 list。
6.4 判断对象是否可迭代的正确姿势
很多初学者用type(obj) == list来判断能不能 for 遍历。实际上只要实现了__iter__或__getitem__的对象都能被 for 循环使用。更稳的判断方式是用collections.abc:
from collections.abc import Iterable, Iterator print(isinstance([1, 2, 3], Iterable)) # True print(isinstance([1, 2, 3], Iterator)) # False it = iter([1, 2, 3]) print(isinstance(it, Iterator)) # True注意 list 是Iterable但不是Iterator,这个判断方法能把概念区分得清清楚楚。配合第一节讲的“能不能next()”来判断,基本就不会搞混了。
写代码这些年,我越来越觉得迭代器看似基础,却贯穿了 Python 的风格:约定协议、惰性求值、轻量优雅。花点时间把 for 循环里的隐式行为拆开看一遍,之后读源码、写自定义数据结构、优化数据处理流程都会顺畅很多。最后再分享一个小技巧:如果你在阅读源码时看到某处返回了一个生成器,不要急着把它转换成列表,先想想调用方到底需要什么,很多时候,保持惰性才是对内存和性能最好的尊重。