ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python迭代器全解析:从for循环底层到生成器实战

Python迭代器全解析:从for循环底层到生成器实战

写Python这么久,for循环大概是每个人用得最顺手的语法之一。但如果你问我,身边有多少同事能一口气说清楚for循环到底对那个“对象”做了什么,答案可能不到三成。

这个现象挺有意思的。大家每天都在用for i in something,却很少有人去追究something到底是个什么东西、循环内部是怎么一步一步取值、又是怎么知道该停下来的。直到某天你在代码里写出一个自定义类,想让它支持for遍历,结果怎么调都不对;或者你想把一个zip对象转成列表,却发现二次使用时里面已经是空的了——这时候你才意识到,迭代器这东西不是“知道就行”,而是“不知道就真会踩坑”。

所以这篇博文,我打算把迭代器这件事彻底讲透。不搞教科书式堆概念,不讲虚的,就从for循环的底层执行逻辑入手,把迭代协议、生成器、惰性求值、常见陷阱一次说清。不管你是刚入门 Python 不久,还是已经写了两三年代码但一直没系统性梳理过这块,这篇文章应该都能帮你把那些模模糊糊的“感觉”钉死成“原理”。

1. for 循环到底做了什么

1.1 一个反直觉的事实:for 循环不认识“元素”

很多人以为for x in 列表是 Python 在“遍历列表里的元素”,这个理解能跑通日常代码,但本质上是错的。

准确说法是——for循环根本不关心你传入的对象里有没有元素、有几个元素、元素长什么样。它只认两件事:iter()函数和一个StopIteration异常。

我把for x in data这行代码拆开,它的真实执行过程是这样的:

# for x in data 的真实展开 iterator = iter(data) # 1. 获取迭代器 while True: try: x = next(iterator) # 2. 不断调用 next except StopIteration: break # 3. 遇到 StopIteration 就退出 # 4. 循环体处理 x

第一次看这段伪代码可能有点懵,但仔细品一下就能发现,for循环本质上是一个while True加上异常控制流的语法糖。Python 的for循环从头到尾没有直接访问任何列表的索引,不管遍历的是列表、元组、字典、集合还是文件对象,它都只做这三步。

这里有一个很容易被忽视的细节:iter(data)这一步,返回值是什么类型,决定了后面next()怎么生效。如果data是个列表,iter()会返回一个list_iterator对象;如果data是个字典,返回的是dict_keyiterator;如果是你自定义的类,返回的就是你自己实现的迭代器对象。

这些对象都具备同一个能力:可以被next()依次取值,直到抛出一个StopIteration。这个“能力”就是迭代协议。

1.2 可迭代对象、迭代器,别再混着说

我刚学 Python 的时候,可迭代对象和迭代器是混着用的,直到某次面试被问了一个刁钻的问题:列表是迭代器吗?

不是。列表是可迭代对象,不是迭代器。

区分这两者的方法很简单——看它能通过什么函数来驱动:

  • 一个对象如果能被iter()调用并返回一个迭代器,那它是可迭代对象(Iterable)。
  • 一个对象如果既能被iter()调用又直接返回自身,并且还能被next()调用逐次取值,那它才是迭代器(Iterator)。

用代码看更清楚:

from collections.abc import Iterable, Iterator 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

列表本身只是一块“容器”,它知道自己有元素,但它不知道怎么“逐个吐出来”——这个功能要调用iter()之后才会生成一个单独的迭代器对象来完成。

而iter()返回的那个迭代器,是一个带状态的对象。它内部会记住“当前位置”,每next()一次,就吐出当前元素并把指针往后挪一位。等它吐完了,再调用next()就抛StopIteration。

这个“带状态”的特性,是迭代器行为里最值得深挖的部分。后面讲陷阱的时候你会看到,它直接导致了很多人在写业务代码时碰到的诡异现象。

1.3 字典遍历的顺序和迭代器有啥关系

顺带说一个实际业务里常出现的困惑——字典的for key in d到底遍历的是什么?

其实在 Python 的for key in d中,iter(d)返回的是dict_keyiterator,它按字典的插入顺序逐次产出键。也就是说,每次循环拿到的key不是 Python 闭着眼睛随便吐的,而是这个迭代器按照底层哈希表顺序推送的。

这里值得留意的是,如果你在遍历字典的过程中修改了字典的大小(比如添加新键),迭代器会直接抛RuntimeError: dictionary changed size during iteration。因为它内部记录了版本号,每次next()都会校验版本是否变更。这个机制设计得相当谨慎——它不允许你在迭代过程中破坏结构,因为那会导致不可预测的结果。

2. 迭代协议的两块基石

2.1iter和next:让对象具备迭代能力

先通过上文的分析可以确认点:想让一个自定义对象能被for循环遍历,你需要给它实现__iter__方法;想让一个类成为真正的迭代器,你需要实现__iter__和__next__两个方法。

这样解释,实际写代码的时候为什么有两种实现风格就很好理解了。

第一种风格:让对象本身既当容器又当迭代器。这种写法需要__iter__返回self,并实现__next__来控制取值:

class CountDown: def __init__(self, start): self.current = start def __iter__(self): return self # 自己就是迭代器 def __next__(self): if self.current <= 0: raise StopIteration result = self.current self.current -= 1 return result

用起来就像这样:

for num in CountDown(5): print(num) # 5 4 3 2 1

这种写法实现简单,但有一个天然的缺陷——同一个对象如果被嵌套遍历两次,会出乱子,因为它的状态是共享的,第二个循环会直接从第一个循环结束的地方开始。所以大多数情况下我建议用第二种风格。

第二种风格:__iter__返回一个独立的迭代器对象。容器负责存储数据,迭代器负责记录遍历位置,各司其职:

class MyList: def __init__(self, data): self.data = data def __iter__(self): return MyListIterator(self.data) class MyListIterator: 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

每次for循环开始都会调用一次iter(),产生一个全新的MyListIterator实例,从零开始遍历,互不干扰。这才是迭代器在生产环境中更合理的形态。

2.2 StopIteration:循环结束的信号,不是错误

StopIteration在真相揭晓的时候会让很多人意外——它其实是一个异常,但在这个场景下它不是“程序出错”的含义,而是迭代器的正常终止信号。

for循环收到StopIteration后并不崩溃,它只是把异常捕获住、然后跳出循环。你可以自己验证一下:

it = iter([1, 2]) print(next(it)) # 1 print(next(it)) # 2 print(next(it)) # StopIteration,直接抛到控制台

一个常见的错误写法是在自定义迭代器里用返回值而不是异常来结束迭代。很多从其他语言转过来的开发者会倾向于返回None或-1表示结束,但在 Python 里这样做无法被for循环识别。你应该并且必须raise StopIteration,这非常重要——for循环就把“没有下一个元素了”理解为一个捕获到的异常,所以你也得用异常来告诉它。

还有一点值得注意:迭代器的__next__一旦抛出了StopIteration,这个迭代器就被视为永久耗尽。你之后再调用next()还是会抛同一个异常,不会从头再来。这一点后面讲陷阱的时候会重点展开。

2.3 用 iter() 包装一个普通的可迭代对象

编写自定义迭代协议的时候,还有一个内置函数值得用好——iter(),它不仅能接受可迭代对象,还能接受一个“无参函数 + 哨兵值”的用法。

正常情况下iter(obj)会去调用obj.__iter__()获取迭代器。但iter()还有一个较少人用的重载形态:

value = iter(read_byte, b'') for block in value: print(block)

第一个参数是一个可调用对象,第二个参数是哨兵值——当这个函数返回值等于哨兵时,迭代器就会停止。这个模式在读取二进制文件的分隔块、或从某种流中逐条读取记录的场景非常实用,可以省掉一个while True加break的判断逻辑。

3. 生成器:迭代器的高级形态

3.1 yield 在背后的作用机制

如果你写过几年代码,一定会碰到这样一种函数——里面没有return,而是用yield来产出值。这类函数调用时不会执行函数体,而是返回一个生成器对象。这个生成器就是一个迭代器。

很多人把“生成器”和“迭代器”当成同义词,其实它们的关系就是“实现方式不同但接口一致”——生成器是迭代器的一种便捷实现方式,它帮你自动处理了__iter__和__next__样板代码。

看一个最直观的例子:

def simple_generator(): yield 1 yield 2 yield 3 gen = simple_generator() print(next(gen)) # 1 print(next(gen)) # 2 print(next(gen)) # 3 print(next(gen)) # StopIteration

你注意观察——yield关键字在这里的作用不是“把值放进去然后结束函数”,而是“暂停当前函数的执行状态,把值返回给调用方;下次next()时从暂停的地方继续执行”。

这个“暂停/恢复”机制,是生成器和普通函数最大的区别。普通函数的执行状态在return之后就全没了,但生成器会在它的内部保存当前执行位置、函数局部变量等状态,每次next()就像按了播放键一样,从暂停处接着跑。

3.2 惰性求值:为什么生成器能省内存

生成器最直接的应用价值就是“懒”。

举个例子,你现在需要处理一亿个整数的累加。如果先创建一个列表把一亿个数装进去,至少需要几百 MB 内存;但如果用一个生成器依次产出这一个个数,内存占用基本是常量级。

# 一次性创建一亿个数——内存爆炸 numbers = [x for x in range(10_000_000)] total = sum(numbers) # 生成器惰性求值,逐个产出 numbers_gen = (x for x in range(10_000_000)) total = sum(numbers_gen)

第二种写法里,numbers_gen不会立即生成一亿个数,它只是被“挂”在那里——等sum()循环调用next()时,才生产下一个数。

想不通这个原理的话,可以用餐馆的厨房来类比:列表好比把一百道菜全部做好端上来摆在桌上,你不吃也在那儿占地方;生成器好比点菜之后厨房一道道地做,你吃一道他炒一道,厨房里最多只放一盘菜的材料。这个“按需生产”的设计,在数据处理量级上来之后,性能和内存的差距是数量级的。

3.3 生成器表达式的懒与不懒

上面示例中已经出现过生成器表达式:(x for x in range(...))。它跟列表推导式在外观上只差一个括号,但行为完全不同——列表推导式立即执行、返回完整列表;生成器表达式返回一个惰性求值的生成器。

写业务代码时,大多数人第一个直觉是用列表推导式,这很正常。但当你只是在做一次性遍历时,完全没必要让中间结果占据内存。

有一个容易出错的场景我在这里提醒一下:生成器表达式拿到的生成器对象天然是一次性的。你把它传给某个函数去遍历,这个函数消费完之后,你再继续用这个生成器,拿到的就是空的:

gen = (x * x for x in range(10)) first_pass = list(gen) second_pass = list(gen) # 空列表,因为 gen 已经被耗尽

你后面会看到,这不仅是生成器表达式的特性,更是所有迭代器对象的通用行为。理解这一点,很多诡异 bug 就一眼能看穿了。

4. 自定义迭代器的几种实现路线

4.1 手动实现迭代协议:什么时候才该这么干

虽然生成器能覆盖绝大多数场景,但手动实现迭代协议依然有它的价值。

举几个真实场景:

第一,你需要一个迭代器对象支持len()或者索引访问,那生成器就不合适了——它既没有长度、也不能随机访问。这时候你可以实现一个带__len__和__getitem__的迭代器。

第二,你的迭代过程中需要精细控制状态(比如遍历树结构、图遍历),在多个方法间共享状态,手动实现可以让代码结构更清晰。

第三,你需要在迭代器中提供send()、throw()或者close()之外的一些自定义方法。虽然生成器也支持send(),但手动实现能让你对流程的控制更精准。

举个实际例子——用一个迭代器来按行读取一个大文件并同时统计行号和行长度:

class LineStats: def __init__(self, file_path): self.file_path = file_path self.line_number = 0 def __iter__(self): return self def __next__(self): return f"line {self.line_number}: {self._read_line()}" def _read_line(self): # 简化逻辑,实际使用时用打开的文件句柄读取 if self.line_number >= 10: raise StopIteration self.line_number += 1 return f"content-{self.line_number}"

这个类在每次next()时同时更新内部统计信息,如果你用生成器来实现也不难,但手动实现时你可以在__next__中方便地调用其他辅助方法、维护更复杂的属性,代码组织上更贴近面向对象的风格。

4.2 用生成器函数快速实现,代码量少一半

如果你不想手动写__iter__和__next__,那生成器函数就是最快的路线。你只需要在函数里用yield把值一个个吐出来,Python 自动帮你把迭代协议补齐:

def unique_words(text): seen = set() for word in text.split(): if word not in seen: seen.add(word) yield word

这个函数看起来像普通函数,但它不是一个普通函数——每次调用它,你拿到的不是返回值,而是一个生成器对象。for循环遍历它时,函数体内的代码才开始真正逐行执行,每执行到yield就把值交出去,等下一次next()再继续。

这里的核心收益是代码可读性。手动实现迭代协议需要写很多样板代码,而生成器函数把“迭代逻辑”集中在一个普通函数中,看起来就像是在顺序地描述一个处理流程。处理流式数据、日志分析、逐行解析等场景,它都是首选。

4.3 更酷的玩法:组合生成器形成流水线

生成器还有一个隐藏技能——可以像管道一样串联组合。

def read_lines(file_path): with open(file_path) as f: for line in f: yield line.strip() def filter_keyword(lines, keyword): for line in lines: if keyword in line: yield line def count_words(lines): for line in lines: yield len(line.split()) # 组合使用 pipeline = count_words(filter_keyword(read_lines("log.txt"), "ERROR")) for count in pipeline: print(count)

这个流水线里,每个生成器只依赖上一个生成器的产出,数据是逐个流动的,不是先收集完一整套再传给下一步。这带来的好处是:你不需要在内存中保存完整的中间结果,每个环节的内存占用都极小。

如果要对同样一套逻辑做非惰性实现,你的代码会变成一堆临时列表、一层层嵌套循环——又难读又占内存。生成器的这个组合方式,是我在日常数据处理中最喜欢的特性之一,后面你写数据处理代码时真的值得有意识地用起来。

5. 迭代器、可迭代对象与惰性求值的边界

5.1 一次性的问题:为什么 zip 对象不能重复用

工作中最常见的迭代器陷阱,就是“一次性”。

zip()、map()、filter()、enumerate()这些内置函数的返回值,全都是迭代器对象。这意味着它们可以被遍历,但一旦遍历完,就没了。

很多人第一次踩坑的场景是这样:

pairs = zip(names, ages) print(list(pairs)) # 正常,有数据 print(list(pairs)) # 空列表!

原因之前提到过:迭代器是有状态的,它不会重置。第二次list(pairs)时,内部的指针已经停在末尾了,再要数据它就吐不出来了——甚至不会报错,只是安静地返回一个空列表。

排查这类问题时,最简单的办法就是意识到:凡是迭代器,都只能完整消费一次。如果代码逻辑需要复用同一份数据两次,你有两个选择:

  • 把迭代器转成列表 / 元组等容器,在内存里存一份;
  • 需要时重新调用zip()等函数再生成一个新的迭代器。

我个人在编写多个函数共享数据时,倾向于立即转成列表,因为这样逻辑更清晰、心智负担小。只有在数据量实在太大、一次性转成列表内存扛不住的时候,才会考虑每次遍历重新生成迭代器。

5.2 可迭代对象不一定是迭代器——别给迭代器起错名字

看待迭代器的时候,还有一个值得特别注意的边界。

str、list、tuple、dict、set、frozenset都是可迭代对象,但都不是迭代器。文件对象比较特殊——它既是可迭代对象,又是迭代器(因为每次next()从它上面读一行)。

容器类型可以被iter()遍历多次,每次都从头开始;迭代器则是一次性消费。我们经常为了符合直觉,给一个返回迭代器的函数变量起名为xxx_list,这是很不合适的命名方式——它误导了你自己:你会忘记它是一次性的。

给这类变量命名,我自己的习惯是用xxx_iter、xxx_gen之类的后缀,每次看到都会条件反射地提醒自己,这是个只能遍历一次的东西。

5.3 惰性求值的意义:大数据量下的唯一选择

惰性求值是迭代器最核心的工程价值。除了之前说的内存优势,它还能让你提前开始处理数据——不需要等所有数据全部加载完就能处理第一批。

举一个业务上的例子。假设有这样一个场景,你需要从一个很大的外部数据源读取记录,一旦读到某条满足条件的记录就立即停止处理(比如找到第一个满足条件的用户)。如果数据源返回的是一个列表,你被迫下载全部数据,无论是否已经找到了目标;但如果它返回的是一个生成器,你可以在产出第一条记录时就开始判断,找到目标后直接丢弃这个生成器——数据下载立刻终止。

这种处理模式在对接外部接口、读取数据库游标、处理流式日志时的优势非常明显。很多搞大数据方向的同事用 Python 处理海量文件时,常见做法就是让每个解析函数都返回生成器,靠一层层惰性管道把整体内存压到极低。

6. 六个真实场景中的迭代器陷阱

6.1 循环内修改被遍历的容器

遍历列表时删元素,是新手最容易犯的错误之一:

numbers = [1, 2, 3, 4, 5] for num in numbers: if num % 2 == 0: numbers.remove(num) print(numbers) # 结果跟预期对不上

这背后的原因是:for循环依赖一个隐式的索引或迭代器状态。当你从列表中移除一个元素后,迭代器就像“跳位”了——前一个元素被删,后一个元素顶上,导致循环跳过了一个本该被检查的元素。

想安全地边遍历边过滤,最稳的方式是遍历原列表的副本,或直接创建新列表:

numbers = [1, 2, 3, 4, 5] numbers = [num for num in numbers if num % 2 != 0]

这句代码一下子就解决了问题。通俗解释是:过滤操作的本质是“生成一份新数据”,而不是“修改正在遍历的结构”。

6.2 迭代器在变量间复制时的迷惑行为

有时候,你明明没有重新赋值,迭代器却在函数间传递时变空了。

def first_half(iterable): result = [] for i, item in enumerate(iterable): if i < 3: result.append(item) else: break return result data = iter([1, 2, 3, 4, 5, 6]) print(first_half(data)) # [1, 2, 3] print(list(data)) # [4, 5, 6]

你看,当first_half函数内部因为break提前停止了循环,那这个迭代器并不是“从头开始”,而是保留在当前位置(4 的位置)。后续再使用它,会从 4 继续。

这是最容易被忽略的迭代器行为之一:提前结束遍历,并不把它恢复到初始状态。它就像一个书签——你看到哪页就停在哪页,下次接着翻,而不是重新回到第一章。

6.3 多个迭代器消费同一个可迭代对象

再来看一个很有意思的场景——如果你拿了同一个可迭代对象,创建了两个独立的迭代器,它们是相互不干扰的:

my_list = [1, 2, 3] it1 = iter(my_list) it2 = iter(my_list) print(next(it1)) # 1 print(next(it1)) # 2 print(next(it2)) # 1 ← 它从自己的位置开始,不受 it1 影响

但如果拿的是同一个迭代器变量赋给两个变量,那它们共享同一个状态:

it = iter(my_list) it_alias = it # 同一个迭代器对象 print(next(it)) # 1 print(next(it_alias)) # 2 —— 因为 it_alias 就是 it

这个知识点在函数传参、多线程/协程并发处理数据源时特别关键。如果你把同一个生成器对象同时传给两个消费者,它们是在抢夺同一批数据,而不是各自拿到一份副本。

6.4 使用无限迭代器而不设停止条件

itertools.count()和itertools.cycle()是很有用的工具,但同时也埋着巨大的坑。

import itertools for i in itertools.count(): # 永远不会停 print(i)

这类无限迭代器没有内置的结束条件,你必须在代码里显式地设置退出逻辑。常见的正确做法是配合break或takewhile:

import itertools for i in itertools.takewhile(lambda x: x < 10, itertools.count()): print(i)

这种“由外部条件控制终止”的模式,在轮询任务、生成序列号、循环状态机等场景中会频繁用到。使用无限迭代器的黄金法则就是:确保每条消费路径上都有终止机制,否则它会成为你服务里的隐形死循环。

6.5 不要直接修改迭代器状态的行为

对大部分迭代器而言,你是没法直接“跳回开头”的。即使你提前停止遍历,也不能保证它支持重置。很多人在排查自己的“分页加载”逻辑时,期望重新遍历一个迭代器能从头开始,但这是不会发生的。

个人建议:如果你确实需要反复遍历同一份数据,就别依赖迭代器了。先把数据物化成列表/元组,然后多次遍历这个容器。这虽然牺牲了一部分内存,但换来了确定性和代码的稳定性。

6.6 生成器中的 return 陷阱

生成器函数中允许使用return,但它的作用和普通函数不一样——return一旦执行,相当于生成器直接走到了StopIteration,后面的yield永远不会执行:

def gen(): yield 1 return yield 2 # 永远不会被产出 print(list(gen())) # [1]

这一点在重构生成器时容易被忽略。你可能会在一个复杂的生成器函数里写return来提前退出,但它的效果不是“返回一个值并继续”,而是“彻底终止整个迭代过程”。不过如果你在return后面跟一个值(比如return 100),这个值不会作为生成器的产出物出现,而是会挂在StopIteration的value属性上——需要try/except捕获异常才能读出来,这个特性在实际业务中极少使用。

7. 用好 itertools 标准库

7.1 三个高频工具:count、cycle、repeat

标准库itertools里的工具,是对迭代器编程的强力补充。

count(start=0, step=1)生成一个无限等差数列。多数场景我会配合zip来实现一个类似枚举的效果,同时控制长度:

import itertools for name, index in zip(users, itertools.count(1)): print(f"{index}: {name}")

cycle(iterable)无限重复一个可迭代对象的内容。适合用在轮询调度、循环排列等场景。

repeat(obj, times=None)重复产出同一个对象若干次。如果times不填,就是无限重复;填了就在指定次数后停。

这三个工具全都是惰性的,产量是基于“需要时才计算”,所以不用担心凭空生成大量数据。

7.2 chain、combinations、product 这类组合工具

itertools的价值不只是基本的无限序列,它在数据组合场景下同样强大。

chain(*iterables)把多个可迭代对象按顺序串成一个,效果相当于把多个序列连在一起遍历:

import itertools for item in itertools.chain([1, 2], ['a', 'b'], (3, 4)): print(item) # 1 2 a b 3 4

combinations(iterable, r)生成所有长度为 r 的组合,不重复;permutations则生成所有排列;product生成笛卡尔积。

这三个工具在测试用例生成、参数组合搜索、路径规划等场景下是救命级别的存在。如果你曾经在代码里写多层嵌套循环来枚举组合,那它们能帮你在一行内解决:

for a, b in itertools.combinations(metrics, 2): compare(a, b)

7.3 takewhile、groupby、islice 处理数据流

在数据处理流中,还有几个工具我也经常使用。

takewhile(predicate, iterable)从可迭代对象中逐个取值,直到某个元素不满足条件,然后停止。

dropwhile(predicate, iterable)则相反,它先丢弃满足条件的元素,一旦遇到第一个不满足条件的,之后的所有元素全部产出。

groupby(iterable, key=None)对连续相同 key 的元素分组。注意这里的“连续”——如果你想让所有相同 key 的元素都归到一组,得先对原始数据按 key 排序,否则分组结果会很奇怪,这也是很多人踩过的坑。

islice(iterable, start, stop[, step])对迭代器做切片——不同之处在于它不会一次性加载全部数据到内存,而是逐个跳过、逐个产出,很适合处理大文件。

import itertools for line in itertools.islice(open("big.log"), 10, 20): print(line.strip()) # 只输出第 10~19 行

8. 迭代器与数据转换的实操对比

8.1 什么时候你该用 list() 包一层

迭代器很好,但好不等于任何时候都要用。

以下情况我通常会立即把迭代器转成 list:

  • 需要多次遍历同一份数据(切片、排序、二次统计);
  • 需要随机访问某个位置的元素;
  • 数据量可预期且内存接受;
  • 需要把数据传给不兼容迭代器的接口。

以下情况我会保持迭代器的惰性:

  • 数据量很大或者无限;
  • 只需要一次遍历;
  • 处理链路很长,需要保持低内存流转。

8.2 嵌套迭代器的扁平化

把多层嵌套的可迭代对象展开成单层,最常见的手段是itertools.chain.from_iterable:

matrix = [[1, 2, 3], [4, 5], [6]] flat = list(itertools.chain.from_iterable(matrix)) print(flat) # [1, 2, 3, 4, 5, 6]

如果嵌套层数不固定,那你可能需要递归处理。chain.from_iterable只展开一层。处理任意深度的嵌套结构时,更好的方案是递归的生成器:

def deep_flatten(items): for item in items: if isinstance(item, (list, tuple, set)): yield from deep_flatten(item) else: yield item

yield from在这里就是“把子迭代器的产出逐个转交出去”的意思,它让生成器委托给另一个生成器变得非常自然。

8.3 生成器 vs 列表推导式的取舍

生成器和列表推导式的取舍,本质是“空间换时间”还是“时间换空间”的取舍。

列表推导式一次构建全部结果,后续多次访问时性能很好,因为数据已在内存中、访问是 O(1);生成器则是逐个计算,每次访问都有创建新对象的开销,但内存占用极低。

当我们考虑函数参数时,还有一种情况是map/filter与推导式的对比。举例说,map(str.strip, lines)返回一个迭代器,[line.strip() for line in lines]返回列表。单从代码清晰度看,推导式更直观;但如果你只是想把这个转换后的结果传入另一个循环,map不产生中间列表,就更有优势。

没有绝对正确的选择,只有结合数据量、访问次数、内存预算做权衡。按我自己的经验,数据量小于十万条、需要多次使用时,直接列表推导式;数据量大到难以估量时,一律生成器。

9. 常见问题速查

现象原因解决方案
zip(a, b)第二次使用为空迭代器已耗尽转成 list 后再复用,或重新调用zip()
遍历列表时删除元素,结果少处理了一个for循环内部索引偏移遍历副本过滤或使用新列表承接结果
自定义类无法用for遍历未实现__iter__实现__iter__返回一个迭代器
无限生成器导致死循环没有停止条件配合takewhile/break使用
生成器内部return后面代码不执行生成器的return即终止迭代不用return,改用条件判断结束
两个变量同时遍历同一个迭代器,数据乱了共享同一个迭代器状态为每个消费者单独调用iter()创建新迭代器
len()对生成器报错生成器没有长度先list()再取长度,或改用手动迭代器

10. 最后再分享一个关键心得

回归到标题的那句话:for循环背后的故事,本质上是一个“协议”而非“实现”。当我彻底理解这一点之后,Python 里的很多行为一下子就连通了——为什么zip是一次性的、为什么map不立即执行、为什么自定义类要写__iter__才能被循环、为什么生成器能处理无限数据流。

关于我个人在实际操作中最深的一条体会,是“控制何时迭代”比“如何迭代”更重要。很多性能问题不是算法不够好,而是数据结构在错误的时间被完全展开;很多诡异的 bug 不是逻辑写错,而是迭代器的状态没有被正确理解。在你设计一个返回数据的函数时,多问自己一句——调用方只需要遍历一次,还是需要反复使用这份数据?这个判断能避免你写出内存爆炸的列表,也能避免你写出让人摸不着头脑的一次性迭代器。

最后一个小建议:你在刷题、写业务代码时,一旦看到一个对象不是 list、tuple、dict、set、str,而是“不知道是什么的东西”,先别急着调试遍历逻辑,先去检查iter()之后它返回的到底是个什么东西。搞清楚了这一点,你会少踩一半以上的 Python 数据操作坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进