1. 为什么迭代器是绕不过去的坎:三个真实场景
先抛一个反直觉的问题:range(100)到底算不算迭代器?写了两三年 Python 的人,十有八九会在这个问题上卡壳。不是大家不努力,而是网络上讲迭代器的教程大多停留在“迭代器就是有__next__方法的对象”这个层面,背完就忘,遇到实际代码照样懵。
我在带团队和面试候选人的过程中,发现迭代器这个知识点几乎每次都会成为分水岭。问“列表和元组有什么区别”人人都会,一问“for 循环是怎么把列表里的元素一个个取出来的”,一半人开始含糊。这不怪大家,因为迭代器这个机制藏在语言底层,平时写业务根本碰不到,一旦碰到,又往往是在最不该出问题的时候出问题。
我先说三个真实场景,你感受一下它到底重不重要。
1.1 场景一:面试官的问题链
面试官问:“Python 里 for 循环的工作原理是什么?”如果你只会回答“就是遍历一个可迭代对象”,那等于没答。真正完整的问题链是这样的:
- 什么样的对象可以被 for 循环?
- 字符串、列表、字典、集合、文件对象,它们都能被 for,它们是迭代器吗?
iter()函数做了什么?next()函数做了什么?- 为什么对列表调用
iter()返回的不是列表本身,而是list_iterator? - 自定义一个类,怎么让它支持 for 循环?两种做法分别是什么?
这一串问题,每一个都指向迭代协议本身。面试官不是想刁难你,而是想通过这个问题判断你对自己天天使用的语言有没有底层层面的理解。能答上来的,说明代码出了问题有能力往深处挖;答不上来的,遇到诡异 bug 大概率只能靠 print 大法碰运气。
1.2 场景二:框架源码里的迭代器模式
你用过 Django 的QuerySet吧?for user in User.objects.all()这个写法,看起来就是遍历一个列表,实际上QuerySet是个惰性求值的可迭代对象,它直到被真正迭代时才去查数据库,而且每迭代一次可能只取一批数据。如果你不理解迭代器的惰性特征,你就永远想不通为什么明明查了数据库,logger 里却看不到 SQL。
再看爬虫场景。你用 requests 拿一个大响应,response.iter_content(chunk_size=1024)返回的是一个生成器,边下载边处理,不会把整个文件一次性读进内存。想写出这种高效代码,不掌握迭代器和生成器,连 API 都看不懂。
1.3 场景三:海量数据处理时的内存危机
这是我最常举的例子。给你一个 5GB 的日志文件,要把里面包含“ERROR”的行全部找出来。新手的第一反应是:
with open("app.log", "r", encoding="utf-8") as f: lines = f.readlines() # 5GB 直接塞进内存,程序当场崩掉老手会这样写:
with open("app.log", "r", encoding="utf-8") as f: for line in f: # 文件对象本身就是迭代器,逐行读取 if "ERROR" in line: handle(line)同样一个任务,第一种写法内存占用好几个 GB,第二种写法内存占用稳定在几 KB 到几 MB。区别在哪儿?就在文件对象是否以迭代器的方式工作。这不是炫技,这是能不能接住任务的差距。
理解了这三个场景,你应该能认同:迭代器不是面试八股,它是 Python 里面向数据流编程的基础设施。接下来我带你把它彻底吃透。
2. 先把概念掰开揉碎:别再把可迭代对象和迭代器混为一谈
我见过太多人把“可迭代对象”和“迭代器”当成一回事,这是所有混乱的根源。实际上它们是两个不同的概念,关系是:迭代器一定是可迭代对象,但可迭代对象不一定是迭代器。这句话你品一下,然后看下面的拆解。
2.1 可迭代对象是"可以拿去循环的东西"
可迭代对象(Iterable)指的是:可以被 for 循环遍历的对象。列表、元组、字符串、字典、集合、文件对象、range()的返回值,这些都是可迭代对象。
判断一个对象是不是可迭代对象,最直接的方法是用iter()函数:
iter([1, 2, 3]) # <list_iterator object at 0x...> iter("hello") # <str_iterator object at 0x...> iter(123) # TypeError: 'int' object is not iterable能传给iter()并成功返回的,就是可迭代对象。iter()函数内部做的事情,其实就是去查找对象有没有实现__iter__方法,或者在老式协议里有没有实现__getitem__方法(这个后面细说)。
用生活化的类比来解释:可迭代对象就像一叠放在桌上的扑克牌,你知道里面有 54 张牌,可以一张一张翻看,但牌本身不会告诉你“你翻到第几张了”。每次你从头开始翻,都会从第一张开始。
2.2 迭代器是"记住了进度的一条通道"
迭代器(Iterator)是:实现了迭代协议、能记住遍历位置的对象。它有两个核心方法:
__iter__():返回迭代器自身。这个方法让迭代器本身也是可迭代对象,所以它也能被 for 循环。__next__():返回下一个值。没有更多值时,抛出StopIteration异常。
继续用扑克牌做类比:迭代器不是那叠牌,而是一只插在牌堆里的手指。它不仅知道下一张牌是什么,还知道你翻到哪里了。每调用一次next(),手指就往下移一张;牌翻完了,它就喊一声 "StopIteration"。
关键区别就在这里:可迭代对象是“静态的资源”,迭代器是“带状态的通道”。
2.3 迭代协议:__iter__与__next__的契约
Python 的迭代协议本质上就是一份契约:
- 一个对象如果实现了
__iter__(),它就是可迭代对象; - 一个对象如果同时实现了
__iter__()和__next__(),它就是迭代器; - 用
iter(obj)调用时,Python 自动去调用obj.__iter__(),拿到一个迭代器; - 用
next(it)调用时,Python 自动去调用it.__next__(),拿到下一个值,或者捕获StopIteration。
用代码直观地展示这两层关系:
# 列表是可迭代对象,但不是迭代器 my_list = [1, 2, 3] print(hasattr(my_list, "__iter__")) # True print(hasattr(my_list, "__next__")) # False,列表没有 __next__ # 对列表调用 iter(),得到一个真正的迭代器 list_iter = iter(my_list) print(hasattr(list_iter, "__iter__")) # True print(hasattr(list_iter, "__next__")) # True # 手动驱动迭代器 print(next(list_iter)) # 1 print(next(list_iter)) # 2 print(next(list_iter)) # 3 print(next(list_iter)) # StopIteration这就是为什么list本身不是迭代器,而iter(list)的返回值是迭代器。这个区别是理解一切后续内容的基石,务必先消化掉。
3. 把 for 循环的遮羞布掀开:它到底是怎么工作的
很多教程告诉你“for 循环就是语法糖”,但没说清楚糖衣里面裹的是什么。这一节我直接把 for 循环的底裤扒干净。
3.1 手动模拟 for 循环的执行过程
当你写下:
for item in [1, 2, 3]: print(item)Python 实际执行的是下面这个过程:
_iter = iter([1, 2, 3]) # 第1步:拿到迭代器 while True: try: item = next(_iter) # 第2步:逐个取下一个值 except StopIteration: # 第3步:没有值了,跳出循环 break print(item) # 第4步:执行循环体也就是说,for 循环的完整语义等价于“获取迭代器 + 循环调 next + 捕获 StopIteration 异常退出”三件事。你平时写的每一行 for 循环,底层都在走这条链路。
理解这个机制之后,下面几个结论就水到渠成了:
- for 循环并不要求对象是列表,只要它是可迭代对象就行;
- for 循环之所以能遍历文件对象,因为文件对象实现了
__iter__()和__next__(),它本身就是迭代器; - 字典 for 循环默认遍历的是 key,因为字典的迭代器按 key 产出。
3.2iter()函数的两副面孔:正经协议和旧式协议
iter()函数其实有两种用法,很多人只见过第一种:
第一种:iter(iterable),把一个可迭代对象变成迭代器。
这是最常见的形式,背后的查找顺序是:先找__iter__方法,调它拿到迭代器;如果对象没有__iter__,再找__getitem__这种旧式协议。
第二种:iter(callable, sentinel),反复调用一个可调用对象,直到返回哨兵值。
这个用法相当冷门,但实际效率极高。最常见的例子是逐块读取文件:
with open("data.bin", "rb") as f: for chunk in iter(lambda: f.read(4096), b""): process(chunk)iter(lambda: f.read(4096), b"")的意思是:每次迭代都调用一次f.read(4096),如果返回值是b""(空字节串),就停止。这样写比手写while True加 break 简洁得多,而且逻辑一眼清晰。面试时如果你能顺手甩出这个写法,观感会很不一样。
3.3 为什么列表可以反复 for,迭代器只能走一次
这个问题是我让学生做的第一个小实验,效果非常震撼:
my_list = [1, 2, 3] for i in my_list: print(i, end=" ") # 1 2 3 for i in my_list: print(i, end=" ") # 1 2 3,列表可以反复遍历my_iter = iter([1, 2, 3]) for i in my_iter: print(i, end=" ") # 1 2 3 for i in my_iter: print(i, end=" ") # 什么都不打印,迭代器已经"耗尽"了为什么?因为列表只是可迭代对象,每次 for 循环都会调用iter(my_list)生成一个全新的迭代器,所以可以无限次从头遍历。而迭代器本身带有状态,第一次 for 循环已经把它推到了StopIteration,第二次 for 时哪怕再调iter(my_iter),返回的还是它自己,指针已经在末尾,自然什么都取不出来。
这个特性用得好是省内存的利器,用不好就是“那个折磨我一小时的 bug”。
4. 生成器:写迭代器最偷懒的方式,没有之一
每次我讲完手写__iter__和__next__,都会看到有人表情痛苦。别急,Python 早就给你准备了捷径——生成器。它本质就是一个用yield关键字写出来的迭代器,但写起来像普通函数,行为却完全符合迭代器协议。
4.1 yield 生成器的行为分析
看一个最经典的斐波那契生成器:
def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b fib = fibonacci() print(next(fib)) # 0 print(next(fib)) # 1 print(next(fib)) # 1 print(next(fib)) # 2fibonacci()是个函数,但函数体内只要出现了yield,它就不再是普通函数,而是生成器函数。调用fibonacci()不会执行函数体任何一行代码,只是返回一个生成器对象。每次next()时,函数执行到yield a暂停,把a的值交出去;再次next()时,从暂停的地方继续往下走。
这就是生成器和普通函数最大的区别:普通函数是一次性执行完返回结果,生成器是分段执行、随时暂停、随时恢复。你不需要手动维护状态变量,函数内的局部变量在暂停期间都会被保留,这是生成器对开发者最友好的一点。
4.2 生成器表达式:列表推导式的"省内存版"
Python 还有一种更紧凑的生成器写法——生成器表达式。长得跟列表推导式几乎一样,只是把方括号换成圆括号:
# 列表推导式:一次性生成全部数据,占用内存 squares_list = [x * x for x in range(10_000_000)] # 约 320MB 内存 # 生成器表达式:惰性求值,逐个生成,几乎不占内存 squares_gen = (x * x for x in range(10_000_000)) # 几乎 0 内存注意一个小坑:生成器表达式是一次性的,遍历完就没了。如果你需要反复用同一批数据,要么转成列表,要么重新创建生成器。
4.3 生成器函数还是生成器表达式?我的选择标准
- 逻辑简单,比如
(x * x for x in data),用生成器表达式,一行搞定; - 逻辑复杂,比如需要多步处理、异常处理、状态保持,用生成器函数,可读性更强;
- 需要反复遍历,不要用生成器,老老实实返回列表或元组。
4.4 协程与 yield from:生成器的高级姿势
yield不仅能产出值,还能接收值,这就是协程的基础。看这个例子:
def echo(): while True: received = yield print(f"收到: {received}") e = echo() next(e) # 启动生成器,走到 yield 处暂停 e.send("hello") # 收到: hello e.send("world") # 收到: worldsend()方法可以把值发送进生成器内部,yield表达式的返回值就是send()传来的值。这个机制是现代异步编程的基石之一。
yield from则是生成器之间的“委托”。它可以让你在一个生成器里直接迭代另一个可迭代对象或生成器,把子生成器的产出“透传”给调用方:
def chain(*iterables): for it in iterables: yield from it for x in chain([1, 2], "ab", (3, 4)): print(x, end=" ") # 1 2 a b 3 4写自定义拼接逻辑时,yield from比手写嵌套循环干净太多。
5. 自定义迭代器实战:手写一个可"回放"的 CSV 行读取器
理论知识讲了半天,不动手永远记不牢。这一节我们从零实现一个自定义迭代器,场景选得非常实际:读取 CSV 文件,但要跳过无效的注释行(以 # 开头),并且能记录当前行号。这两个需求用现成的csv模块不一定好处理,但自定义迭代器可以轻松覆盖。
5.1 需求与设计
- 输入:CSV 文件的路径;
- 行为:每次迭代返回一个
(行号, 字段列表)元组; - 规则:以
#开头的行视为注释,跳过不返回; - 附加:统计有效数据行数。
5.2 第一版实现:用生成器函数
先看用生成器怎么实现,代码少,思路直观:
def read_csv_with_comments(file_path): line_number = 0 valid_count = 0 with open(file_path, "r", encoding="utf-8") as f: for raw_line in f: line_number += 1 stripped = raw_line.strip() if not stripped or stripped.startswith("#"): continue fields = [field.strip() for field in stripped.split(",")] valid_count += 1 yield line_number, fields print(f"总行数: {line_number}, 有效数据行: {valid_count}")用的时候:
data = read_csv_with_comments("data.csv") for line_no, fields in data: print(line_no, fields)这已经完整体现了“惰性 + 状态保持”的威力。line_number和valid_count是生成器函数的局部变量,每次yield暂停时它们都被保存在生成器内部,不需要额外的类来管理状态。
5.3 第二版实现:用类实现完整迭代器协议
如果需求还要增加更多能力,比如需要单独的方法来获取统计信息、需要支持反复从头读取,那么用类实现更合适:
class CSVLineReader: def __init__(self, file_path): self.file_path = file_path self.total_lines = 0 self.valid_lines = 0 self._file = None def __iter__(self): # 每次 __iter__ 都重新打开文件,保证可以反复迭代 if self._file: self._file.close() self._file = open(self.file_path, "r", encoding="utf-8") self.total_lines = 0 self.valid_lines = 0 return self def __next__(self): if not self._file: raise StopIteration for raw_line in self._file: self.total_lines += 1 stripped = raw_line.strip() if not stripped or stripped.startswith("#"): continue self.valid_lines += 1 fields = [field.strip() for field in stripped.split(",")] return self.total_lines, fields # 文件读完,关闭并结束迭代 self._file.close() self._file = None raise StopIteration def stats(self): return self.total_lines, self.valid_lines注意__iter__里我做了两件事:关闭上一次可能未读完的文件、重置计数器。这样同一个对象可以被 for 循环多次,每次都是全新遍历。这不只是为了演示而写,是实际项目里踩过坑之后总结出来的经验——如果一个迭代器还打算被重复使用,一定不要在__init__里打开资源,要在__iter__里处理。
5.4 协议实现的细节校验
写完之后建议做两件事:
第一,手动测试连续 next 的行为,确认最后一行的StopIteration正确抛出:
reader = CSVLineReader("data.csv") it = iter(reader) print(next(it)) # 第一行有效数据 # ... 一路 next 到结尾,最终抛 StopIteration第二,确认对象能直接用于 for 循环:
reader = CSVLineReader("data.csv") for line_no, fields in reader: print(line_no, fields) print(reader.stats())这个类实现中最容易出的 bug 是:__next__里没有正确处理文件读完的边界,导致抛出的不是StopIteration而是ValueError: I/O operation on closed file。写的时候可以刻意留一个坑,让读者自己想清楚为什么__next__返回后要立刻判断文件状态。这种边界条件,恰恰是面试官最爱挖的地方。
6. 那些年我踩过的迭代器坑:四个高频翻车现场
迭代器机制本身不复杂,但它在实际工程里的表现经常让人抓狂。下面四个坑,每个都是我或我带的同事在生产环境里真实遇到过的,单独列出来给你避雷。
6.1 “我的列表怎么被消费掉了”——把迭代器误当列表
某次数据清洗的脚本,同事把csv.reader(f)的返回值直接存成变量,第一段代码遍历完后,第二段代码再用这个变量,发现是空的。他百思不得其解,最后打印类型才发现问题:csv.reader返回的不是列表,而是迭代器,只能消费一次。
排查方法:不确定一个对象是不是迭代器时,用两个快捷方式之一:
from collections.abc import Iterator, Iterable print(isinstance(data, Iterator)) # True 说明是一次性的 print(isinstance(data, Iterable)) # True 仅说明可迭代,可能还保留数据应对手段:如果确实需要反复使用,在第一次迭代时就转成列表,或者重新构建可迭代对象。
6.2 在同一个迭代器上嵌套循环:死循环之外还有更隐蔽的
新手容易犯的错误是对同一个迭代器嵌套 for。比如想对生成器做“两两比较”:
data = (i for i in range(5)) for a in data: for b in data: # 内层循环直接耗尽 data print(a, b)结果只会输出(0, 1) (0, 2) ...里很少几项,然后 data 就被内层循环消费光了,外层随之退出,整个输出残缺不全。这不是死循环,但比死循环更隐蔽,因为它能跑,只是结果不对。
应对手段:需要二次遍历的数据,先缓存成列表:
data = list(i for i in range(5)) for a in data: for b in data: print(a, b)6.3next()的默认值陷阱:不要指望它自动返回 None
next(iterator, default)的第二参数是默认值,很多人以为传了默认值就不会抛异常。是这样没错,但很多人不知道:只有迭代器真正耗尽时才返回 default,迭代过程中如果抛出其他异常,default 是救不了你的。
def bad_generator(): yield 1 raise ValueError("boom") it = bad_generator() print(next(it, "fallback")) # 1 print(next(it, "fallback")) # ValueError: boom,default 不生效这个坑在写健壮性代码时尤其重要。如果你预计迭代过程中数据可能损坏,别指望 default 兜底,要用 try/except 包住next()调用。
6.4 自定义类既没__iter__也没__next__,但有__getitem__也能被迭代
这是一个冷知识:老式迭代协议里,只要类实现了__getitem__,并且索引越界时抛IndexError,它也能被 for 循环遍历。
class OldStyleIterable: def __init__(self, items): self.items = items def __getitem__(self, index): if index >= len(self.items): raise IndexError return self.items[index] for x in OldStyleIterable(["a", "b", "c"]): print(x) # a b c这个机制保证了兼容 Python 2 时代的老代码。日常写新代码我强烈建议不要依赖它,用标准的__iter__协议,但看到别人代码里只有__getitem__却能 for 时,别觉得是魔法,知道来路就行。
7. 实战进阶:用 itertools 把迭代器玩出花来
itertools是 Python 标准库里专门为迭代器设计的工具箱,里面的函数全部返回迭代器,天然惰性、天然省内存。多数教程只讲chain和count,但实际工作中还有很多高频且好用的工具,我按使用频率给你列一下。
7.1 无限迭代器与"限流"搭配:count、cycle、repeat
不知道你有没有想过生成无限序列?itertools.count(start=0, step=1)就是这样一个无限计数器:
import itertools for i in itertools.count(10, 2): if i > 20: break print(i) # 10 12 14 16 18 20注意无限迭代器必须配合 break 条件或takewhile使用,否则死循环:
from itertools import count, takewhile evens = takewhile(lambda x: x <= 20, count(10, 2)) print(list(evens)) # [10, 12, 14, 16, 18, 20]cycle可以让一个序列无限循环,适合做轮流分配任务:
from itertools import cycle nodes = cycle(["node1", "node2", "node3"]) for i in range(10): print(next(nodes)) # node1 node2 node3 node1 ...7.2 组合迭代器:chain、zip_longest、tee、islice
chain把多个可迭代对象串成一个,我经常用来组装多个配置源:
from itertools import chain defaults = {"host": "localhost", "port": 8080} env_config = {"port": 9090} merged = dict(chain(defaults.items(), env_config.items())) print(merged) # {'host': 'localhost', 'port': 9090}zip_longest处理长度不一致的多个迭代器,用默认值补齐缺失项:
from itertools import zip_longest names = ["Alice", "Bob"] scores = [95, 87, 76] for name, score in zip_longest(names, scores, fillvalue="N/A"): print(name, score) # Alice 95 # Bob 87 # N/A 76tee把一个迭代器“复制”成多个独立副本,注意它是用内存换取多次迭代能力,复制越多占内存越多:
from itertools import tee source = (x * x for x in range(5)) a, b = tee(source) # 生成两个可以独立遍历的迭代器 print(list(a)) # [0, 1, 4, 9, 16] print(list(b)) # [0, 1, 4, 9, 16]islice对迭代器做切片,注意它不接受负数索引,但可以用None表示起点:
from itertools import islice data = iter(range(100)) head = list(islice(data, 5)) # 取前5个 mid = list(islice(data, 10, 20)) # 从当前状态再取第10到第19个 print(head) # [0, 1, 2, 3, 4] print(mid) # [10, 11, 12, 13, 14, 15, 16, 17, 18, 19]7.3 实战:用 itertools 处理实时日志流
把上面几个工具组合起来,可以实现一个非常小巧的日志监控脚本:持续读取日志文件新增行,过滤出 ERROR,每 10 条打一个摘要。
import itertools import time def follow(file_path): with open(file_path, "r", encoding="utf-8") as f: f.seek(0, 2) # 跳到文件末尾 while True: line = f.readline() if not line: time.sleep(0.1) # 没有新行,等待 continue yield line def extract_errors(lines): for line in lines: if "ERROR" in line: yield line.strip() def batch(iterable, size): return iter(lambda: list(itertools.islice(iterable, size)), []) for batch_lines in batch(extract_errors(follow("app.log")), 10): print(f"--- 发现 {len(batch_lines)} 条错误 ---") for line in batch_lines: print(line)follow函数模拟 Unix 的tail -f,就是一个无限生成器;extract_errors负责过滤;batch用之前讲的iter(callable, sentinel)把输出按 10 条分组成批次。整个脚本没有一行复杂的循环嵌套,读起来像流水线一样清晰。这就是迭代器思维带来的代码组织方式的改变。
8. 性能与取舍:什么时候该用迭代器,什么时候别硬用
说了迭代器这么多好处,我必须客观点:不是所有场景都适合迭代器。盲目追求“惰性”反而会让代码变复杂。我给自己定了几条实践准则,供你参考。
8.1 迭代器占内存小的本质:并不是"没有占用"
很多人理解成“迭代器不占用内存”,这是错的。生成器不一次性把全部数据放进内存,但它每次产出数据时,数据本身占用的内存还是存在的,只是被及时释放了。文件迭代器读一行处理一行,那行字符串在内存中的生命周期极短。本质是空间换时间的对立面——它用算力(逐次执行)换内存(不批量持有)。
8.2 小数据量别硬上迭代器
如果你要操作的数据只有几十个元素,用列表推导式生成完整列表,代码更简洁、调试更方便,性能和内存几乎没有差别。这时候硬生生搞一个多层生成器,纯粹是给自己找麻烦。
我的选择标准:
- 数据量明确很小(比如配置文件里的几行、固定枚举值):用列表/元组;
- 数据量可能很大或者不确定(日志、用户上传、分页结果):用迭代器;
- 需要一个接一个处理流式数据(网络流、实时数据):必须用迭代器/生成器;
- 需要随机访问、反复遍历、求长度、做切片:优先转成列表。
8.3 迭代器的调试成本
迭代器有一个非常讨厌的缺点:你无法直接查看它的内部内容。print(list(my_iter))虽然能看,但看一次就把迭代器消费光了,调试完原逻辑就废了。
我的调试技巧是用itertools.tee复制一份来打印,原迭代器不受影响:
from itertools import tee debug_iter, real_iter = tee(original_iter) print(list(debug_iter)) # 打印第一份,方便检查 for item in real_iter: # 用第二份继续正常业务 process(item)8.4 嵌套生成器的可读性问题
生成器一层套一层,很容易写出“一眼看不懂在干嘛”的代码。我见过有人写五层嵌套生成器来处理数据流,优雅是优雅,维护的人差点崩溃。
处理办法是给每层生成器起一个“动词性”的函数名,让它像流水线工位一样清晰:raw_lines -> normalize_lines -> filter_valid -> extract_fields。迭代器的威力不是靠嵌套深度体现的,是靠清晰的边界和职责划分体现的。
最后分享一个我实际操作中的小习惯:每学一个新的容器或工具,我都会先用hasattr和isinstance把它在迭代器协议中的位置查一遍,比如“range 对象是不是迭代器”“dict 的 values 视图能不能重复遍历”。查完随手写下结论,久而久之迭代器的概念就内化成直觉了。遇到诡异 bug 时,先问自己一句“这里的数据到底是可迭代对象还是迭代器”,往往一句话就能定位问题根源。