我前阵子面试候选人问了一个自以为很基础的问题for循环到底是怎么遍历列表的对方写了一年代码脱口而出“for循环就是循环嘛挨个取元素”但再往下追问迭代器协议、StopIteration、可迭代对象和迭代器的区别时就明显露怯了。这不怪他毕竟for循环这个语法糖太甜了甜到大家根本不关心糖纸下面包的是什么。这篇文章就把这层糖纸撕开聊聊Python迭代器Iterator和for循环背后那点事。这篇文章适合谁看已经写过一段时间Python、但没仔细研究过迭代器机制的人准备面试想把这部分讲清楚的人还有那些看过“生成器”“yield”但只停留在概念层面的朋友。看完你能理解for循环的完整执行链路能自己手写一个迭代器还能避开迭代器常见的那些坑。1. 迭代器到底解决了什么问题1.1 如果没有统一协议遍历会变成一场灾难先想一个问题Python里可遍历的东西太多了列表、元组、字典、集合、字符串、文件对象、range对象甚至你自定义的类。如果没有一个统一的遍历规范那每个容器类型都得自己提供一个遍历方法比如list有自己的get_next()dict有自己的get_next_key()文件有自己的read_next_line()——那调用方就乱了套每换一种数据结构就得改一遍遍历逻辑。迭代器协议就是把“遍历”这件事标准化了。任何对象只要实现了__iter__()方法能返回一个迭代器迭代器实现了__next__()方法每次调用能取出下一个元素取完了就抛出StopIteration。所有的for循环都基于这套协议工作不用关心你遍历的是什么类型代码长一个样。我经常用一个生活化的类比来解释迭代器就像一根“进度条指针”可迭代对象就像一盒磁带。iter()是按下播放键next()是每次前进一格StopIteration是磁带放完了卡的一声弹起。不管这盒磁带里录的是音乐、英语听力还是相声播放器的操作方式都是一模一样的因为你只看进度条的位置。这个抽象的价值非常大。你写出来的函数可以接收“任何可迭代的东西”然后直接放进for循环里跑根本不用管它内部结构是链表、是哈希表还是文件流。这就是协议设计的魅力把变化隔离在协议后面。1.2 可迭代对象和迭代器不是一回事这是最容易混淆的地方也是面试最喜欢挖的坑。可迭代对象是实现了__iter__()方法的对象iter(obj)能返回一个迭代器。迭代器是实现了__next__()方法的对象能用next()逐个取值。一个对象可以先有__iter__再是迭代器吗不完全。迭代器本身通常也实现__iter__并返回自己所以迭代器同时也是可迭代对象但反过来不成立。最典型的例子列表是可迭代对象但列表本身不是迭代器。你直接对一个列表调用next()Python会立刻抛出TypeError: list object is not an iterator。lst [1, 2, 3] # 不行list没有__next__ next(lst)但可以用iter()拿它背后的迭代器lst [1, 2, 3] it iter(lst) print(type(it)) # class list_iterator print(next(it)) # 1 print(next(it)) # 2 print(next(it)) # 3 next(it) # StopIteration日常开发中我们还经常直接和本身就是迭代器的类型打交道最常见的三个是enumerate对象、zip对象和文件对象。它们本身就有__next__也实现了__iter__返回自身所以是真刀真枪的迭代器。判断一个对象到底是可迭代对象还是迭代器最直接的办法from collections.abc import Iterable, Iterator print(isinstance([], Iterable)) # True print(isinstance([], Iterator)) # False print(isinstance(iter([]), Iterator)) # Truecollections.abc标准库里这两个抽象基类建议你记下来面试时随口就能用上平时调试代码判断类型也方便。2. for循环的三幕剧本iter、next、StopIteration2.1 for循环的等价代码长什么样for循环看起来只是几行语法翻译成底层代码其实是一个try/except包裹的while循环。我直接写给你看for x in obj: print(x)上面的代码Python运行时实际是这样执行的# 1. 获得迭代器 _it iter(obj) # 2. 不断调用next直到耗尽 while True: try: x next(_it) except StopIteration: break # 循环体 print(x)第一步iter(obj)就是调用obj.__iter__()拿到迭代器。第二步进入一个死循环反复调用next(_it)也就是调迭代器的__next__()方法。第三步当迭代器内部没货了它会抛出StopIteration异常这个异常被except捕获break跳出循环程序正常结束。很多人第一次看到这个等价代码会惊一下原来for循环的终止机制是用“异常”来传递信号的。这确实是Python风格StopIteration在这里不是错误而是迭代器在说“我这边结束了”。你甚至可以手动模拟一把for循环感受一下这个协议的完整闭环obj [10, 20, 30] it iter(obj) while True: try: value next(it) print(value) except StopIteration: break输出结果和for value in obj: print(value)一模一样。手动写一次你对for循环的理解就完全不一样了。2.2 range对象和它背后的range_iterator很多人有个误解觉得range(1000000)会生成一个百万元素的列表。其实不会。range是一个惰性求值的序列对象它本身也不直接是迭代器但你可以对range调iter()拿到一个range_iterator。r range(1000000) print(hasattr(r, __next__)) # Falserange不是迭代器 ri iter(r) print(type(ri)) # class range_iterator print(next(ri)) # 0 print(next(ri)) # 1这个设计的用意很明显如果写for i in range(10**9)时Python真的先生成十亿个整数塞进内存程序早就爆了。range只保存start、stop、step这三个值每次迭代按公式计算下一个数。这就是惰性求值的好处而迭代器协议是这套机制能够运转的前提。理解了这一点你再看for循环它其实是一个“按需取数”的管道而不是一次把所有数据搬进内存再慢慢数。这也是为什么Python处理超大文件、超长序列时能保持比较低的内存占用。2.3 顺带提一嘴for循环的else分支既然聊for循环就不得不提一个被严重低估的小彩蛋for...else。for x in range(5): if x 3: break else: print(循环没有被break中断)这个else块只在循环没有因为break退出时执行。本质上for循环结束后虚拟机会检查是不是正常跑完的如果是就顺便执行一下else。这个语法在写查找逻辑时特别管用比如在序列里找某个符合条件的元素找不到才走的那个分支放到else里比放flag变量清爽得多。3. 手写一个迭代器把协议走一遍3.1 从零实现一个IntRange类前面看了for循环怎么消费迭代器现在反过来我们自己造一个迭代器。这是理解整个机制最关键的一步因为只有站在生产者的角度你才知道为什么每次next()之后迭代器“变了一个状态”为什么它是“一次性的”为什么for循环第二遍跑会没结果。下面这个IntRange撸一个简版内置range的行为class IntRange: 迭代器从start到stop每次步进step def __init__(self, start, stop, step1): self.current start self.stop stop self.step step def __iter__(self): # 迭代器协议要求__iter__返回自身 return self def __next__(self): if self.step 0 and self.current self.stop: raise StopIteration if self.step 0 and self.current self.stop: raise StopIteration value self.current self.current self.step return value跑一下for i in IntRange(1, 10, 2): print(i) # 输出1 3 5 7 9这个类里有两个关键细节。第一__iter__返回self这是迭代器的标准操作——因为迭代器自己就是自己的迭代器你反复对它iter()得到的还是它自己。第二__next__里必须维护一个游标状态这个类用self.current存当前位置每次调用把游标往前推一步到达边界就抛StopIteration。这就是和普通类最大的区别迭代器是有状态的。普通对象的方法基本上是“输入参数返回结果”而这个类的每次next()调用都会改变对象内部状态。for循环之所以能消费它不是因为它“能取数据”而是因为它能把“取到哪里了”这件事实时记住。3.2 斐波那契迭代器用Python方式解决传统循环问题很多人是学C语言那套for循环长大的比如用循环求斐波那契数列int a 0, b 1; for (int i 0; i n; i) { int t b; b a b; a t; printf(%d , a); }这个逻辑本身没错但它是“命令式地一次性跑完”。换成Python迭代器我们可以把“下一个斐波那契数”变成一个可以随时取、随时停的对象class FibIterator: def __init__(self, max_count): self.max_count max_count self.count 0 self.a 0 self.b 1 def __iter__(self): return self def __next__(self): if self.count self.max_count: raise StopIteration value self.b self.a, self.b self.b, self.a self.b self.count 1 return value用法for v in FibIterator(10): print(v, end ) # 1 1 2 3 5 8 13 21 34 55仔细看这个迭代器整个斐波那契的当前状态——self.a和self.b——都被封存在对象内部。下次再next()它不会从头开始而是接着上次的状态继续往下推。这个特性特别适合解决那些“每一步依赖上一步结果”的递推问题。我之前还看到有人用迭代器解“李白打酒”那道趣味题壶里原本有酒遇店加一倍遇花喝一斗最后刚好喝完。这类题目本质上就是一个状态转移问题——当前状态是“店铺数、花数、剩酒量”每一步按规则更新状态。如果不用迭代器就得写一堆临时变量用迭代器封装之后每个状态都是一个独立的快照代码逻辑会清爽很多。虽然这道题本身用递归或回溯也都能解但迭代器把它们统一成了一套“消费状态”的姿势值得体会一下。3.3 迭代器对象的自我修养iter同样重要刚才两个类都实现了__iter__返回self。有的人写迭代器会偷懒只写__next__但这是不规范的。因为一个对象能用在for循环里的前提是它得能通过iter()拿到迭代器。迭代器自己就是自己的迭代器所以__iter__一定要返回self这样才能保证it FibIterator(3) for v in it: print(v) # 如果只有__next__没有__iter__这里直接就TypeError了另外next()函数也支持传一个默认值迭代器耗尽时不会抛异常而是返回这个默认值it iter([1, 2]) print(next(it, 没了)) # 1 print(next(it, 没了)) # 2 print(next(it, 没了)) # 没了这个技巧在写“不知道数据总量、但想安全尝试取一个”的场景里特别好用。4. 生成器让迭代器一步到位4.1 yield是怎么把函数变成状态机的手写迭代器类虽然可行但有的时候确实麻烦——写一个类维护状态还得处理边界条件。Python早就想到了这一点于是提供了生成器这个语法糖它的本质就是“Python帮你打包好的迭代器”。看这个代码def fib_generator(max_count): a, b 0, 1 for _ in range(max_count): yield b a, b b, a b注意这个函数里带有yield关键字那么调用它时不会执行函数体而是直接返回一个生成器对象。只有当你对它next()时函数体才会开始执行一直执行到yield那一行把值丢出来然后整个函数在这个位置暂停。下次再next()从暂停的地方恢复继续往下走。我把这个过程拆得更直白一点。gen fib_generator(5) print(type(gen)) # class generator v1 next(gen) # 执行到yield b返回1函数暂停 v2 next(gen) # 继续执行返回1函数暂停 v3 next(gen) # 返回2这个“暂停/恢复”机制让函数变成了一台状态机。所有局部变量的值都保存在生成器对象的内部for循环每次从它那儿拿一个值它保存好现场再交出去。对比一下手写的FibIterator类生成器版的代码量少了一半而且更直观、更不易出错。从协议层面上说生成器对象天生就实现了__iter__和__next__所以它也是迭代器。for v in fib_generator(10)完全没问题。4.2 生成器表达式一行代码的惰性迭代除了yield还有一种写法叫生成器表达式用的是圆括号squares (x * x for x in range(100)) print(type(squares)) # class generator print(next(squares)) # 0 print(next(squares)) # 1它和列表推导式[x * x for x in range(100)]的区别非常大列表推导式会立刻计算完全部100个值存进一个列表生成器表达式则是“等你要的时候才算一个”每次都只算当前这一个。我实测过一个对比。在一个百万级数据集上用列表推导式内存占用直接飙到几十MB换成生成器表达式内存基本原地不动。这个差异在数据流水线里是决定性的尤其是你只是要“遍历一遍做处理”的场景没理由把所有中间结果都囤在内存里。不过我得提醒一句如果同一个数据需要反复遍历或者你需要随机访问某个位置的元素那生成器就不方便了还是老老实实转成列表吧。工具没有绝对的优劣只有场景合不合适。5. 常见问题与排查技巧实录5.1 迭代器为什么不能重复遍历这是迭代器最大的隐藏特性也是新手踩得最多的一脚。it iter([1, 2, 3]) print(list(it)) # [1, 2, 3] print(list(it)) # []第二次list(it)得到的是空列表。因为第一次遍历时迭代器已经“走到底”了它的状态就停留在耗尽那一刻没有“倒带”机制。迭代器不是数据库游标它没有缓存历史数据的义务。如果业务上确实需要重复遍历有两个思路一个是最简单粗暴的把迭代器放进列表攒起来相当于做一次快照另一个是数据源本身可重复生成那每次都重新生成一个迭代器。比如data (x for x in range(5)) snapshot list(data) # 如果后续还要用先转列表 for v in snapshot: print(v)5.2 遍历列表时删元素元素会神秘消失讲个真实事故。我有一次写过滤逻辑边遍历边删不满足条件的元素结果发现遍历漏掉了一部分数据而且剩下的元素偏了程序跑出来的结果莫名其妙。my_list [1, 2, 3, 4, 5] for x in my_list: if x 2: my_list.remove(2) print(x) # 输出1 2 4 5看到了吗3不见了。原因就是列表迭代器内部维护了一个自增的索引。删掉2这个元素后后面的元素整体前移但迭代器的索引并没有跟着减于是下一个取得的是原索引2的位置现在是43就被跳过了。更极端一点如果你边遍历边往列表里插入元素那更糟糕很可能导致死循环。正确做法是遍历副本my_list [1, 2, 3, 4, 5] for x in my_list[:]: if x 2: my_list.remove(2)my_list[:]先创建一个原列表的浅拷贝遍历的是副本改的是原列表互不干扰。类似的坑还出现在遍历字典时修改键、遍历集合时修改元素上这些常见操作Python会直接抛RuntimeError: dictionary changed size during iteration。原则就一条不要在迭代过程中直接改容器的结构。5.3 文件对象的游标机制文件对象是Python里最典型的迭代器之一。for line in file能一行一行读底层就是在反复调用next(file)。但有一个坑特别隐蔽你如果用for line in f:遍历了一遍文件然后接着调用f.read()读出来的往往是空字符串。因为文件内部维护了一个游标指向当前读取位置for循环已经把它推到了文件末尾。想重新读就得f.seek(0)把游标搬回去或者干脆重新打开文件。理解了迭代器协议之后这个问题就很好解释了——文件对象本身就是迭代器迭代器用完即耗尽。5.4 用itertools给迭代器做“手术”迭代器是一根只能往前走、不能随机访问的管道很多操作它自己做不了。itertools标准库就是专门来处理这些迭代器操作的。我这边最常用的三个from itertools import islice, takewhile, chain # 跳过前面两个取后面5个 it iter(range(100)) for v in islice(it, 2, 7): print(v) # 只要满足条件的前几个条件一断就停 for v in takewhile(lambda x: x 10, iter(range(100))): print(v) # 把多个迭代器串成一根管道 for v in chain(iter([1, 2]), iter([3, 4])): print(v)islice相当于给迭代器做切片takewhile相当于“条件满足就继续不满足就整个停止”chain把多个流串起来。有了这几个工具迭代器在流水线上才算真正顺手。我处理超大文件时经常用这些函数做分块、抽样、过滤速度快还省内存。6. 一些实操心得与建议写代码这几年我对迭代器有一个逐步深入的体会刚开始觉得它只是一个抽象概念背熟“实现__iter__和__next__”就算了真正写过自定义迭代器、在流水线式处理里用过生成器以后才开始明白这套协议设计得有多巧妙。如果你正处在学习阶段我建议你做三件事。第一把for循环的等价代码手动敲一遍再用while循环自己模拟一遍遍历列表、字典的过程这能帮你把“迭代协议”彻底变成肌肉记忆。第二遇到实际需求时优先用生成器而不是返回一个大列表。比如你写一个处理日志文件的函数如果一下子把几万行全部读进内存再来分析机器很快就扛不住改成生成器逐行产出吞吐量完全不是一个量级。第三调试迭代器时不妨用list()物化一下看看里面到底是什么。很多人绕不明白迭代器的时候一list()就清了只是别忘了物化一次之后这个迭代器就废了再跑一遍又是空的。我个人还习惯在类的设计上主动实现__iter__协议。比如自定义一个数据集合类只要实现了__iter__就能直接放进for循环、能被sum()、min()、max()、list()这些内置函数消化整个类就变得非常“通人性”。你甚至不需要额外提供什么get_all()方法Python内置的遍历方式已经全部兼容。最后再分享一个小技巧如果你不确定一个对象到底是可迭代对象还是迭代器用hasattr(obj, __next__)来判断有__next__就是迭代器如果没有再看hasattr(obj, __iter__)有的话至少可以用iter()包一层。这个方法在你接手别人的代码、搞不清楚传入参数到底是什么类型时特别好使。