首页 / 资讯中心 / 文章详情

Python迭代器详解:从__iter__与__next__到for循环底层原理

Python迭代器详解:从__iter__与__next__到for循环底层原理 ★ FEATURED ARTICLE
前几天有人问我你写了这么多年Python能不能不用for循环把列表里的数字一个一个手动取出来这个问题看着简单但他真正想问的是——for循环背后到底发生了什么。很多人学了列表、字典、元组却不知道它们之所以能用在for循环里靠的是一套统一的“迭代协议”而这套协议的核心恰恰是两个看起来很神秘的魔法方法__iter__和__next__。今天借这个“魔法方法迭代器习题复习”的整理我把迭代器这条线从头到尾捋一遍顺便带上习题和排错记录保证你读完能真正弄懂迭代器而不是停留在“会用for循环”的层面。1. 迭代器到底是什么先把三个名字的关系理清楚1.1 可迭代对象、迭代器、迭代协议别再用混了不少初学Python的人会有一个典型误区认为“能用for循环遍历的东西”就是迭代器。这句话错在哪错在把“可迭代对象”和“迭代器”混成了一回事。严格来说三者是这么区分的可迭代对象Iterable实现了__iter__()方法的对象可以被iter()函数处理返回一个迭代器。列表、元组、字典、集合、字符串都属于这一类。迭代器Iterator同时实现了__iter__()和__next__()方法的对象。__iter__()返回自己__next__()每次返回下一个值没有值可返回时抛出StopIteration异常。迭代协议就是这两个魔法方法组合起来的一套规则任何对象只要遵守这套规则就能被for循环、列表推导式、in操作符等语法“通吃”。我习惯用一个生活化的类比可迭代对象相当于一个图书馆迭代器相当于你手里的借书单。图书馆本身不是书但你可以通过借书单一本一本地把书拿出来借书单上的进度记录了你看到哪一本了。每次调用next()就好比“翻到下一页”看到最后一页之后再翻就会报错——这就是StopIteration。1.2 for循环的底层流程一行代码背后的三步走当你在Python里写for item in my_list: print(item)解释器并不会“神奇地知道”my_list有哪几个元素它实际执行的是下面这套动作调用iter(my_list)尝试调用对象的__iter__()方法拿到一个迭代器对象。反复调用next(iterator)也就是调用迭代器的__next__()方法拿到一个值赋给item。当__next__()抛出StopIteration时for循环捕获这个异常安静地结束循环。如果想亲眼验证这个过程可以直接用内置函数模拟my_list [10, 20, 30] it iter(my_list) while True: try: value next(it) print(value) except StopIteration: break这段代码跟for value in my_list的输出完全一致。理解了这一步你就明白为什么迭代器是“一次性”的它内部维护着当前进度走完就不会回头。列表这种可迭代对象可以重新生成新的迭代器所以可以反复遍历而迭代器本身一旦被next()走到头再想循环就空了。注意以后看到“可迭代对象”和“迭代器”先在心里问一句这东西传给iter()会返回什么如果返回的是它自己那就是迭代器如果返回的是一个新对象那它只是可迭代对象。2. 魔法方法的核心细节__iter__与__next__的规矩2.1 方法签名与返回值不是随便写就能跑魔法方法之所以叫“魔法”不是因为有什么黑科技而是因为解释器在特定语法场景下会自动调用它们。你不需要显式写obj.__iter__()写iter(obj)就够了你不需要手动捕获StopIterationfor循环已经帮你兜底了。但方法本身的实现要守规矩__iter__(self)必须返回一个迭代器对象。常见做法是return self前提是这个类自己也实现了__next__()。__next__(self)必须返回序列中的下一个元素。当没有元素可返回时必须抛出StopIteration异常而不是返回None或返回一个默认值。__iter__和__next__都要定义。如果只定义__next__、忘了__iter__那这个对象就不能被iter()识别for循环会直接报TypeError: xxx object is not iterable。有一个常见的误区有人会在__next__里用return None表示结束。这看起来“程序没报错”但后果很严重——for循环会继续调用__next__然后变成无限循环因为它永远等不到StopIteration。记住StopIteration不是说“返回一个空值”而是“迭代到此结束”的信号两者完全不是一回事。2.2 一个容易被忽略的退路只实现__getitem__也能迭代很多人不知道迭代协议其实有两条路。除了__iter____next__这条“主路”解释器还保留了一条“老路”如果你只实现了__getitem__并且它支持从0开始的整数索引那么iter()函数也能工作。它会在内部生成一个“老式迭代器”按0、1、2、3...的顺序不断调用__getitem__直到索引越界抛出IndexError为止。我见过不少老代码、甚至某些第三方库里的类只写了__getitem__没写__iter__却依然能用在for循环里原因就在这里。但我要提醒你这只是为了向后兼容的退路新写的代码不要依赖它。原因很简单__getitem__的语义是“按索引访问”这种访问方式天然要求数据支持随机存取而迭代器的核心价值是“按顺序逐个产出”两者思维模型不一样。如果你实现的是流式数据、生成序列老老实实按主路走会更清晰。2.3 迭代器的“一次性”特性为什么同一个迭代器不能循环两次这个坑几乎每个Python开发者都踩过。请看这段代码nums [1, 2, 3] it iter(nums) print(list(it)) # [1, 2, 3] print(list(it)) # []第一次list(it)把迭代器从头到尾消费完了第二次自然什么都得不到。但如果你写成nums [1, 2, 3] print(list(nums)) print(list(nums))两次输出都是[1, 2, 3]因为列表每次都会创建一个新的迭代器。这个特性在生产代码里经常引发“诡异的bug”。我有一个印象很深的案例某同事用sorted(reader)处理一个大文件的读取迭代器第一次读取时数据正常第二次处理同一个迭代器时sorted返回了空列表。排查半天才发现问题不是数据没了而是迭代器被第一次的排序操作消费掉了。解决方案也很简单要么每次都新建迭代器要么把结果先存成列表。3. 亲手写一个迭代器从零到能跑3.1 实现一个按步长取数的迭代器类光看不练假把式我们直接写一个自定义迭代器。下面这个StepRange类功能类似一个“支持自定义步长、且生成过程完全可控”的数字序列class StepRange: 每次从start跳到stop步长为step的迭代器 def __init__(self, start, stop, step1): self.current start self.stop stop self.step step def __iter__(self): return self def __next__(self): if self.current self.stop: raise StopIteration result self.current self.current self.step return result测试一下sr StepRange(0, 5, 1) for x in sr: print(x, end ) # 0 1 2 3 4 print(list(StepRange(0, 10, 2))) # [0, 2, 4, 6, 8]代码很简单但有几个细节值得琢磨为什么__next__里先判断current stop再返回值因为我们要保证“当前值达到边界时不输出”这与range的右开区间行为一致。为什么要先把result保存下来再更新current因为一旦更新了current原有的值就丢了。顺序写反输出就会错位。为什么__iter__return self因为迭代器本身就是“能产生下一个值的对象”它就是自己的迭代器。3.2 斐波那契迭代器理解“可暂停”和“可恢复”接下来写一个稍微实用一点的例子斐波那契数列迭代器。斐波那契数列天然适合迭代器因为它的生成过程是“从前面两项推后面一项”不需要一次性算几千项存进列表。这个特性叫“惰性求值”需要多少就算多少。class Fibonacci: def __init__(self, max_countNone): 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.b self.a, self.b self.b, self.a self.b self.count 1 return result这个类最值得体会的一点是“状态保存”。每次__next__()被调用它只做三件事保存当前值、推进状态、计数。调用next()之后对象内部的状态已经变了但外部看起来就像“暂停了一下”下次再调用时从暂停的地方继续。这跟生成器函数的工作方式本质相同。顺便对比一下生成器的写法def fibonacci_gen(max_countNone): a, b 0, 1 count 0 while max_count is None or count max_count: yield b a, b b, a b count 1能看到yield关键词把函数变成了生成器你要做的事情被大幅简化。很多人在学迭代器时会问那我还需要手写迭代器类吗答案是大多数场景不需要生成器是更优先的选择但当你需要实现一个同时具备“迭代”和“其他协议”的复杂类时理解手写迭代器仍然很关键。3.3 可迭代对象与迭代器为什么要分开设计前面写的StepRange和Fibonacci都是“自己就是自己的迭代器”这在简单场景下没问题。但有一个明显的缺陷它们是“一次性”的同一个实例不能被重复遍历。如果业务上需要“同一个容器对象可以反复for循环”那就得采用“容器和迭代器分离”的设计。来看这个例子自定义一个单词列表类class WordList: def __init__(self, words): self.words words def __iter__(self): return WordListIterator(self) class WordListIterator: def __init__(self, word_list): self.words word_list.words self.index 0 def __iter__(self): return self def __next__(self): if self.index len(self.words): result self.words[self.index] self.index 1 return result raise StopIteration使用wl WordList([hello, world, python]) print(list(wl)) # [hello, world, python] print(list(wl)) # [hello, world, python]可以重复遍历 it1 iter(wl) it2 iter(wl) print(next(it1)) # hello print(next(it2)) # hello两个迭代器互不干扰这种设计的好处是可迭代对象负责“描述数据容器长什么样”迭代器负责“记录当前遍历到哪个位置”。多个迭代器同时存在时它们各自维护各自的进度互不干扰。很多标准库对象就是这么设计的。你回想一下一个文件对象既是可迭代对象也是迭代器所以读取文件时如果两个地方同时用同一个文件对象迭代会互相影响而enumerate每次都会生成一个新的迭代器。4. 习题复习从易到难带答案解析4.1 基础判断题分辨哪些会报错下面这几个表达式哪些能正常运行哪些会抛异常# 场景1 nums [1, 2, 3] it iter(nums) next(it) # ? next(it) # ? next(it) # ? next(it) # ? # 场景2 s abc it iter(s) next(it) # ? # 场景3 n 10 iter(n) # ?答案解析场景1中前三个next(it)依次返回1、2、3第四个next(it)抛出StopIteration。很多初学者会误以为“取完最后一个元素后再取会返回None”这是错的。迭代器讲究“到了尽头就明确告诉你没有了”而不是静默返回一个值。场景2能正常运行第一次next(it)返回a。字符串也是可迭代对象因为字符串实现了__iter__。这一点常被忽略但在处理字符序列、正则匹配时非常有用。场景3直接报TypeError。整数不是可迭代对象它没有__iter__方法也不具备__getitem__老式迭代的条件。这个判断方法很有用如果某个对象既没有__iter__也没有适配整数索引的__getitem__那它一定不能迭代。4.2 动手写代码题实现一个返回前n个偶数的迭代器类这道题是面试官很爱出的热身题考察的是你能否区分“方法实现”和“业务逻辑”。class EvenNumbers: def __init__(self, n): self.n n self.count 0 self.current 0 def __iter__(self): return self def __next__(self): if self.count self.n: raise StopIteration self.current 2 self.count 1 return self.current测试e EvenNumbers(4) print(list(e)) # [2, 4, 6, 8]这里有个小陷阱self.count self.n的判断放在最前面是为了保证“如果n是0一次都不产出”。如果把它放在更新之后那n0时也会返回一个2逻辑就错了。写迭代器时边界条件的判断顺序很关键顺序错了会多出一个元素。4.3 陷阱排查题为什么第一个for循环正常第二个什么都没打印来看这段代码class Mystery: def __init__(self, data): self.data data def __iter__(self): return iter(self.data) m Mystery([1, 2, 3]) for x in m: print(x) for x in m: print(x)运行结果1 2 3第二次循环什么都没输出。为什么因为Mystery.__iter__返回的是iter(self.data)——它每次都会创建一个新的列表迭代器。等等如果是每次创建新的那第二次应该也能正常输出才对问题出在我上面这段代码的__iter__确实是返回新迭代器所以理论上两次循环都应该正常。但实际很多人在写__iter__时会写成类似下面这种错误版本def __iter__(self): return iter(self)这会导致无限递归因为iter(self)又会调用__iter__本身。而如果类里有个属性叫self.iterator并且在__iter__里return self.iterator那就会“一次性”问题。其实这类题目真正的坑点在于迭代器对象被保存成实例属性。看这个版本class Mystery: def __init__(self, data): self.data data self.iterator iter(self.data) def __iter__(self): return self.iterator第一次for循环把self.iterator消费完了第二次自然输出为空。这个案例再次说明可迭代对象与迭代器分离设计不是学院派建议而是真实工程里的必要手段。4.4 进阶思考题如何实现一个“可重置”的迭代器日常业务里有这么一种需求迭代器被消费到一半因为某个条件要回溯到开头重来。但迭代器本质是“向前走不回头”所以直接改造__next__没有意义。更合理的做法是让类既提供迭代协议又提供一个reset方法class ResettableIterator: def __init__(self, data): self.data data self.index 0 def __iter__(self): return self def __next__(self): if self.index len(self.data): raise StopIteration result self.data[self.index] self.index 1 return result def reset(self): self.index 0用法ri ResettableIterator([5, 6, 7, 8]) print(list(ri)) # [5, 6, 7, 8] ri.reset() print(next(ri)) # 5这个设计比“重新创建一个新迭代器”更灵活的地方是它保留了对象上的其他状态和数据。比如你正在遍历一个日志文件想从头再读一遍就可以保留文件句柄只重置游标。不过要注意如果迭代器内部状态非常复杂reset会很难维护这时不如直接丢掉旧迭代器、创建新的。5. 常见问题与排查技巧实录5.1 迭代器报错的五个典型场景下面这五个场景我基本每一两个季度就会遇到一次整理成速查表方便收藏场景现象原因解决方案for循环拿不到值循环体一次都没执行迭代器已被之前某处消费完检查是否复用了同一个迭代器出现无限循环程序卡死不动CPU占用飙升__next__里用了return None而不是raise StopIteration必须显式抛出停止异常报错xxx object is not iterable把不可迭代对象传给iter()或for循环类没有实现__iter__也没有__getitem__退路给类添加__iter__方法遍历列表时修改列表RuntimeError: dictionary changed size during iteration在遍历过程中增删元素先复制副本list(origin)再遍历或使用filter__iter__返回了Nonefor循环报TypeError: iter() returned non-iterator方法里忘记return self或返回了错误对象检查返回值是否实现了__iter__和__next__5.2 排查技巧怎么快速确认一个对象是“可迭代对象”还是“迭代器”在实际调试中我经常要用两个小检查from collections.abc import Iterable, Iterator # 检查是否为可迭代对象 isinstance(obj, Iterable) # 检查是否为迭代器 isinstance(obj, Iterator)但请注意Iterator继承自Iterable所以一个迭代器一定同时满足两个检查。反过来一个列表是可迭代对象但不是迭代器。拿内置类型验证一下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另外一个排查经验是当一段代码“第一次运行正常第二次结果为空”时优先怀疑迭代器被复用。特别是涉及文件对象、生成器、map、filter、zip这类惰性对象时它们都是迭代器只能消费一次。如果一定要跑两遍数据最笨也最稳的办法是第一遍消费完后把结果list()保存到内存后续操作都基于列表进行。5.3 生成器函数与迭代器类怎么选才顺手生成器yield本质是“用函数语法快速创建迭代器”。它比手写迭代器类省事太多所以大部分场景我推荐优先用生成器。那什么时候该用手写迭代器类需要同时实现多个协议不只是迭代比如还要支持__len__、__getitem__、__enter__/__exit__。需要精细控制内部状态或者状态字段很多函数式写法难以表达。需要实现reset这类非标准操作生成器没法直接做到。需要可重复遍历的容器类分离设计这时候生成器只能生成“一次性迭代器”。从多年实战看80%的需求用生成器就够但20%的复杂类还是要手写迭代器。如果面试遇到手写迭代器题可以先写生成器版本再说明哪种场景必须手写类这个回答会让人对你刮目相看。6. 迭代器在真实项目里的三个延伸方向6.1 大文件流式处理一次只读一行内存不爆生产环境里经常要处理几个GB的日志文件。如果直接read()全部读进内存服务器分分钟挂给你看。正确姿势是利用文件对象本身就是迭代器这个特性with open(big_log.txt, r, encodingutf-8) as f: for line in f: # 处理这一行 process(line)这里的for line in f之所以不会把整个文件加载进内存就是因为文件对象内部实现了按行读取的迭代逻辑。每次next()只从磁盘读一小块数据处理完就丢弃内存占用始终保持在一个很小的范围内。这个特性看起来平淡但它解决的是“大数据量下程序能不能跑起来”的生死问题。6.2 与itertools配合无限序列的“可控消费”itertools是Python标准库里的迭代器工具箱专门为迭代器提供各种加工函数。比如islice可以“切出”一个无限迭代器的前几个值from itertools import islice def count_from(n): while True: yield n n 1 for value in islice(count_from(10), 5): print(value, end ) # 10 11 12 13 14不懂迭代器的人看到while True会以为程序死循环了实际上这里有个关键的逻辑无限生成器本身是惰性的谁也不会主动去取它的值只有islice要求取5个时它才产生5个数据。这是“惰性求值”的典型应用。工作中处理流水号、请求重试序号、分页游标时这种组合非常实用。6.3 自定义分页器把迭代协议用到业务代码里我之前处理过一个分页查询需求数据库里有大量记录希望按每页50条逐页取数同时支持按业务时间范围过滤。如果用列表一次性取出全部数据数据一多就会导致接口响应变慢。于是我把分页逻辑封装成一个迭代器类核心思路是每次__next__从数据库取下一页处于“取完为止”的惰性计算状态。这个类没有特别复杂但它把“业务中的分页状态”和“迭代协议”结合了起来调用方只需要写for page in Paginator(...):就能一路翻页。这种“让业务对象自己具备迭代能力”的写法比起在业务代码里到处写while offset total清晰得多。7. 踩坑实录与最终经验我自己在迭代器上栽过最狠的一次跟前面说的“迭代器被提前消费”有关。当时在一个批处理脚本里我把filter(func, data)的结果传给两个下游函数用第一个函数先遍历了一遍第二个函数拿到的是空集合。折腾了一个下午最后发现问题出在filter返回的是迭代器不是列表。从那以后我养成一个习惯只要把一个生成器/迭代器的结果传给多个消费者先问一句“这个东西会被消费几次”不确定时就先转成列表。还有一个小技巧值得分享调试迭代器时别在循环里打日志更别在__next__里打日志。因为__next__每被调用一次就打印一次数据一多日志直接泛滥而且for循环会提前尝试获取下一个元素来判断是否结束日志顺序会跟你预期的完全不一样。我一般用list(it)先把迭代器转成列表再整体观察输出。最后一个建议学迭代器的时候一定动手做一遍习题。纸上谈兵回答“我知道迭代器是一次性的”和实际调通一个自定义迭代器类是两种完全不同的掌握程度。迭代器是Python里为数不多“用一个魔法方法就能改变整个对象行为模式”的机制吃透它你再去看生成器、协程、上下文管理器都会觉得顺理成章。今天写到这里如果你也遇到过什么迭代器的诡异问题欢迎在评论区留下你的案例一起排查。
阅读完成 · 觉得有帮助?
咨询建站