资讯动态

Python迭代器与生成器:一次看懂for循环底层机制与实战

发布时间:2026/10/15 5:59:09 来源:尧图企业网站定制
我至今记得第一次被迭代器“摆了一道”的场景。当时我在写一个数据处理脚本把几个列表用 zip 合并之后准备先遍历一遍看看字段对没对上再遍历第二遍做统计。结果第二次 for 循环拿出来的全是空数据列表推导式规规矩矩地返回了 []。我第一反应是数据源读错了排查了半天才发现zip 返回的根本不是列表而是一个迭代器Iterator这玩意儿一次性消费用完就没。这件事让我把每天都要写的 for 循环重新审视了一遍。列表、字典、集合、range、文件对象、生成器这些能放在 for item in data 位置上的东西看起来只是“恰好都能循环”实际上背后共享着一套统一的迭代器协议。这篇文章就想把这层窗户纸捅破for 循环在底层如何一次次调用 next() 取值迭代器和可迭代对象到底差在哪生成器又凭什么能省下那么多内存。无论你是刚接触 Python 的新手还是写了好几年业务代码想补一补底层细节的开发者这篇文章应该都能给你一些平时文档里看不到的角度。1. 从报错现场开始什么样的对象才有资格出现在 for 后面1.1 TypeError 背后的真相iterable 与 iterator 一字之差几乎每个 Python 初学者都会在某一天撞见这么一行红字TypeError: int object is not iterable。当你兴冲冲写下 for x in 123 然后回车解释器会毫不留情地摔出这条报错。第一次看到它的人多半是懵的什么叫 not iterable123 为什么不行列表为什么就行报错信息里的 iterable 正是 Python 迭代体系中的第一个关键概念——可迭代对象。在 Python 的语境里“可迭代”意味着这个对象知道自己如何把内部元素一个一个地交出来。整数不知道因为 123 没法拆成 123 个独立的元素而列表知道字符串知道字典知道range 也知道。for x in 123: print(x)TypeError: int object is not iterable到这里大部分教程会画一张图告诉你“iterable 包含 iterator”然后让你把两个词背下来。但我更想把话说透可迭代对象iterable和迭代器iterator根本不是包含关系而是两个完全不同的身份。可迭代对象描述的是“能力”——这个东西可以被遍历迭代器描述的是“状态”——这个遍历的进度走到哪了。一个对象可以具备可迭代的能力但它不一定就是迭代器反过来迭代器也未必需要是一个容器。搞清楚这层关系后面所有诡异现象基本都能找到解释。1.2 用“书和书签”理解这对概念我给团队新人讲这块的时候常拿书和书签打比方。一本书放在书架上任何人随时可以拿起来从头读这个“随时可以被从头阅读”的性质就是可迭代。而你此刻手头夹着书签、读到第 180 页的那个状态才是一个具体的迭代器。也就是说list 是书本身而 iter(list) 返回的那个“带着书签的阅读状态”才是真正的迭代器。这个比喻精准地解释了一个常见困惑为什么一本书能读一百遍因为每次你都能重新拿一本新的、夹上新书签从头开始。而迭代器只能前进不能后退书签一旦走到最后一页这段阅读旅程就到此为止除非你回去再找这本书、再造一个新书签。维度可迭代对象iterable迭代器iterator本质一种能力声明一个携带遍历状态的对象判断方式能被 iter() 接受实现了next()能否多次遍历通常可以每次 iter() 得到新迭代器不行自身就是一次性的遍历器for 循环里的角色最初的入口真正被 for 每轮消耗的东西这块如果理解不到位后面很多现象都会让你一头雾水比如 zip 为什么用一次就空文件对象为什么不能 for 两遍。根子其实都埋在这里。2. 迭代器协议拆解iter与next两份契约的玩法2.1 手写一个迭代器从 CountDown 说起Python 并不要求你非得继承某个“迭代器基类”。只要一个类实现两个特殊方法它就自动获得了迭代器身份。这两份契约分别是iter() 告诉解释器“我可以被迭代”在迭代器身上通常直接返回 selfnext() 负责交出下一个元素交不出来时必须抛出 StopIteration。我习惯用一个倒计时类来解释因为它足够简单又能覆盖大部分细节class CountDown: 从 start 向下数到 1每次交出一个数。 def __init__(self, start): self.current start def __iter__(self): return self def __next__(self): if self.current 1: raise StopIteration value self.current self.current - 1 return value把这个类直接丢进 for 循环输出和想象中完全一致for n in CountDown(3): print(n, end ) # 输出3 2 1注意next的实现方式每被调用一次内部状态 self.current 就往前走一步。这正是迭代器带“器”字的原因——它是一个有状态的机器不是一桶存好的数据。你每次从它嘴里抠出一个数它内部就少一口气直到最后吐出 StopIteration 表示油尽灯枯。2.2 StopIteration 不是错误是“故事讲完”的信号很多新手第一次看到 StopIteration名字里带着 Exception 后缀下意识觉得程序出错了。其实在迭代体系里StopIteration 更像讲评书的人说到“今天到这儿下回没有了”——它是一个控制流信号不是异常事故。你完全可以在代码里手动扮演 for 循环的角色去观察它cd CountDown(2) print(next(cd)) # 2 print(next(cd)) # 1 print(next(cd)) # 这里会抛 StopIteration前两次 next() 正常取值第三次再要倒计时已经归零它只能礼貌地告诉你“没了”。有意思的是for 循环天然认识这个信号会在内部悄悄接住它然后结束循环不会让它炸到你的屏幕前。所以如果你在 for 外面手动调用 next()就需要自己准备 try/except 来接住而这段 catch 逻辑正是 for 循环替你做的脏活之一。2.3 一次性用光迭代器为什么不存历史另一个高频疑问是迭代器能反复遍历吗答案是基本不能。倒计时类的next每次都让 current 递减而 current 就是它唯一的记忆一旦归零就永远归零。它不会像列表那样把历史元素都装在肚子里回头再给你重放一遍。这正是迭代器和可迭代对象最本质的分工可迭代对象负责生成新的迭代器迭代器负责边走边扔。列表能够反复遍历靠的是每次 for 都调用 iter(list)而 list 每次都会生成一个全新的列表迭代器zip、map、文件对象这些则不同它们的iter直接返回 self遍历来遍历去用的都是同一个“带书签的阅读状态”自然只能从头到尾走一遍。看个直观的例子lst [1, 2, 3] it iter(lst) print(list(it)) # [1, 2, 3] print(list(it)) # []同一个迭代器已经用完如果业务上要求“重复遍历”正解不是在迭代器上反复 for而是回到可迭代对象这个源头重新调用一次 iter()让它生成一个全新的书签。3. for 循环执行链路iter()、next() 与异常的三角配合3.1 用 while 循环模拟 for 的每一步现在可以揭开 for 循环的真面目了。for item in data 这行看似简单的代码解释器实际做了四步调用 iter(data) 得到迭代器循环调用 next(迭代器) 取下一个元素取到元素就执行循环体遇到 StopIteration 就跳出整个循环。完整等价逻辑用 while 循环可以写出来我第一次手写完这段代码的时候对 for 循环的敬畏感直接拉满data [apple, banana, cherry] iterator iter(data) while True: try: item next(iterator) except StopIteration: break print(f拿到的元素{item})两个容易被忽略的细节iter() 在整个流程里只调用一次不是每一轮循环都调用StopIteration 由 for 内部消化所以你在循环体里正常写代码时完全感觉不到它的存在。理解这条链路还有一个实际好处——你可以“手动取几步再交给 for”流式处理里这种能力非常好用lst [10, 20, 30, 40] it iter(lst) print(next(it)) # 先手动取一个拿到 10 for x in it: # 剩下的交给 for从 20 开始 print(x, end ) # 20 30 403.2 for 循环为什么能兼容这么多种类型既然所有循环都统一从 iter() 这个入口开始for 的“兼容性”就有了理论依据。字符串、列表、元组、集合、字典、文件、range、生成器……只要 iter() 能返回一个迭代器for 就能正常工作。这也是 Python 设计哲学里很有代表性的一点语法层面只认协议不认具体类型。比如 for key in d 拿到的是字典的 key 视图迭代器for line in f 是文件对象按行读取的迭代器for i in range(10) 则是 range 对象生成的惰性迭代器。同一个 for 关键字面对完全不同的数据源走的却是同一套执行链路。把这个机制装进脑子里之后你面对“这个对象能不能 for”这类问题时根本不需要背类型清单只需要问一句它能不能被 iter()能就放心大胆地写。3.3 range() 省内存的底牌惰性求值很多刚转 Python 的人都会疑惑range(10000000) 表示一千万个数内存怎么不爆炸答案在于 range 返回的根本不是列表而是一个惰性求值的可迭代对象。它内部只记住了 start、stop、step 三个数字真正算第 n 个值发生在 next() 被调用的那一刻。同理for i in range(10 ** 8) 走完内存占用几乎恒定而 list(range(10 ** 8)) 等于把这一亿个数一次性全部装进列表内存直接起飞。用 sys.getsizeof 就能直观对比import sys r range(1000000) print(sys.getsizeof(r)) # 很小的固定值几十字节量级 lst list(range(1000000)) print(sys.getsizeof(lst)) # 百万个 int 的列表几十 MB 起步这就是惰性求值的威力把“计算”推迟到真正需要的时刻。迭代器、生成器、range 之所以能在海量数据场景下所向披靡靠的全是这一条底层逻辑。理解了它你就同时理解了为什么有些写法“省内存”、有些写法“一次吃完”。4. 生成器让迭代器从“取数据”变成“讲故事”4.1 yield 的神奇之处函数的暂停与恢复手写带next的类虽然能实现迭代器但啰嗦维护成本也高。Python 为此提供了一个更优雅的语法生成器函数。任何一个函数体中出现 yield 关键字这个函数就不再是普通函数了——调用它不会立刻执行函数体而是返回一个生成器对象而这个生成器对象本身就是一个迭代器。看个和之前 CountDown 相同功能的写法def countdown(start): while start 0: yield start start - 1 for n in countdown(3): print(n, end ) # 输出3 2 1yield 到底干了什么它把函数执行流程“暂停”在当前位置把后面的值交出去下一次 next() 到来时函数从暂停的地方继续往下走直到再次遇到 yield 或函数自然结束。这种暂停-恢复的能力让生成器极适合表达需要逐步产出的数据流。可以这样理解普通函数是一次性把所有菜做完端上桌生成器是边做边端你吃一道它做一道你不催它就不往下做。4.2 生成器表达式与 yield from生成器不止写函数这一种形式。把列表推导式的方括号换成圆括号你就得到了生成器表达式squares (x * x for x in range(10)) print(next(squares)) # 0 print(next(squares)) # 1它和列表推导式的关键区别是列表推导式立刻算出全部结果并占用内存生成器表达式只保存一个“计算配方”用到一个算一个。给 sum()、max()、all() 这类函数传参时我通常直接传生成器表达式而不是列表推导式效果完全一致内存却省一大截。如果要在生成器之间串联可以用 yield from 把子生成器的产出原样透传出去省去手动 for 循环转发def countdown(start): while start 0: yield start start - 1 def countdown_twice(start): yield from countdown(start) yield from countdown(start) print(list(countdown_twice(2))) # [2, 1, 2, 1]4.3 手写迭代器和生成器怎么选两条路线各有适用场景。如果需要一个“独立存在的对象”要保存复杂状态、要支持额外方法比如暂停、重置、统计消费次数手写类实现iter/next更合适。如果只是想让一段计算过程可以被逐步取用生成器几乎是零成本的最优解。我写数据处理代码的经验是生成器是默认选项。只要一个函数返回的是需要逐步加工的长序列我几乎一律写成生成器函数。调用方拿到的是一个惰性的“流”可以在它之上继续穿插过滤、变换、切片全程不需要把中间结果落成列表。这种写法在管道式数据处理里特别顺手也是我接手的项目里最容易被重构优化掉的一类代码。5. 三个高频踩坑现场外加一个 itertools 工具箱讲完了生成器的优雅该聊聊它和迭代器在实际项目中给我上过的那些课了。以下三个坑每一个我都见过不止一次有的来自我带的新人有的来自我自己深夜的抓狂。5.1 坑一zip 的迭代器被消费完第二次 for 拿到空这个坑在文章开头就出现过是我自己踩了半天的真实案例。zip 返回的是迭代器第一次 for 已经把元素全部取完第二次 for 自然拿到空。不止 zipmap、filter、enumerate 的返回值全是同一类一次性角色。很多初学者在写“先预览数据再实际处理”的流程时都会撞上这个莫名其妙的空结果a [1, 2, 3] b [4, 5, 6] pairs zip(a, b) first [(x, y) for x, y in pairs] # [(1, 4), (2, 5), (3, 6)] second list(pairs) # []pairs 已经枯竭解决方案两个方向要么在第一次消费前用 list() 把结果固化要么在需要重复遍历的位置重新调用 zip(a, b)。我的习惯是只要数据量不至于大到吃内存短期要被反复使用的中间结果就直接固化成列表代码意图更清楚只有确定“只过一次”的场景才让迭代器一路裸奔。5.2 坑二一边遍历一边删除RuntimeError 找上门另一个高频翻车现场是在迭代过程中修改容器本身。你正在 for 循环里遍历字典循环体里顺手 del 了一个键解释器立刻抛 RuntimeError: dictionary changed size during iteration。这个异常不是 bug是保护机制——迭代器在工作时握有容器的结构版本信息容器中途变了形迭代器就不知道下一个元素该是谁索性拒绝继续。它相当于提醒你目录正在翻的时候别一边翻一边撕页。d {a: 1, b: 2, c: 3} for k in d: del d[k] # RuntimeError: dictionary changed size during iteration正经做法是遍历副本或者先收集再统一删除for k in list(d.keys()): if d[k] % 2 0: del d[k]5.3 坑三无限迭代器没有刹车itertools.count() 会永不疲倦地往上数itertools.cycle() 会无限循环一组元素。直接拿它们 for程序就会陷入真正的死循环卡到你以为电脑坏了。其实无限迭代器并不可怕只要记得给它配一道闸门。配合 islice 切片或 takewhile 条件就可以优雅地限制产出from itertools import count, islice for n in islice(count(10), 5): print(n, end ) # 10 11 12 13 14记住一条心法凡是被无限迭代器喂数据的循环必须另设一个出口否则程序永远等不到结束的那天。5.4 itertools 常驻嘉宾一览itertools 标准库模块几乎每一个函数都在和迭代器打交道。这里整理几个我用得最频繁的配合速查表方便大家直接抄函数作用典型场景islice(iterable, n)给迭代器做切片只取前 n 条日志takewhile(pred, iterable)条件满足时持续取值读取一段连续的错误记录chain(*iterables)把多个迭代器串成一个合并多个文件的逐行内容cycle(iterable)无限循环一组元素轮流分配任务groupby(iterable, key)按 key 分组按日期聚合日志groupby 有个坑它只对相邻且 key 相同的元素分组。想按某个字段做整体分组得先 sorted 一下两个操作连用才能得到符合直觉的分组结果。这点我在实际项目里帮人排查过好几次几乎每次都是同一根原因。6. 实战把日志文件变成可重复遍历的可迭代对象6.1 需求分析与设计取舍原理聊到这里最后用一个我近期实际写过的例子把知识点串成一条线。需求很简单读取一个很大的结构化日志文件按行解析成字典同时跳过空行和 # 注释行。最初用列表做open 后 readlines 全量读进来再过滤文件一上 500 MB内存直接拉满。后来改造为迭代器方案内存占用降到了几乎可以忽略的程度。设计时有三个考量第一日志文件每次解析的结果应该一致所以这个对象必须是“可重复遍历”的可迭代对象而不是一次性迭代器第二解析过程必须惰性不能把整个文件加载进内存第三调用方希望能直接 for x in obj体感上像遍历一个普通列表一样简单。这三个限制放到一起答案其实已经呼之欲出了让iter返回一个生成器。6.2 用iter生成器实现结构化日志解析最终实现出奇地简洁class StructuredLog: def __init__(self, file_path): self.file_path file_path def __iter__(self): with open(self.file_path, encodingutf-8) as f: for raw_line in f: line raw_line.strip() if not line or line.startswith(#): continue level, message line.split(|, 1) yield {level: level.strip(), message: message.strip()} for entry in StructuredLog(app.log): print(entry[level], entry[message])核心机制全在iter里它打开文件逐行读取跳过噪音yield 解析后的字典。调用方每取一个元素文件才往前读一行这就是惰性的全部含义。文件对象本身就是迭代器因此 for raw_line in f 天然一行一行地读不会一次性把文件灌进内存。6.3 这个设计为什么能跑多个 for把同一个 StructuredLog 对象放进三个 for 循环里各跑一遍每一次都能拿到完整数据而且任意时刻只占用一份文件流的打开状态。奥秘在于每次调用iter都会新建一个生成器这个新生成器内部会重新打开文件、重新从第一行读起。“带书签的阅读状态”因此每次都是全新的旧状态用完就丢互不干扰。反过来想如果你在类里保存一个文件句柄并让iter返回 self第一次 for 之后文件指针就到了末尾第二次 for 就什么都读不到了zip 的一次性悲剧又会上演一遍。判断一个自定义对象能不能反复 for核心标准就这么一条它的iter是不是每次都返回一个新迭代器。把这条标准记牢你在设计任何可遍历数据结构时都能清楚地决定“我要可重复遍历还是只要一个一次性流”。项目做完之后我反而觉得迭代器这套机制真正厉害的地方不在语法而在它逼你想清楚一个数据源的生命周期是每次都能从头再来还是只能消费一次。想明白了代码设计就稳了一半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑