本节目标:彻底分清「可迭代对象」与「迭代器」这组最常被混淆的概念,掌握
for循环的等价展开,能自己实现迭代器类与生成器,并用内存实测理解生成器为什么省内存。
适用版本:Python 3.12+(实测 3.14.6)
8.1 可迭代协议、迭代器与生成器
你在第 4 章写过 for 循环,在第 3 章用过列表推导式。它们背后共享同一套机制——可迭代协议(iterable protocol)。把这套机制讲清楚,是理解生成器、itertools、乃至后面异步代码的前提。
可迭代对象与迭代器不是一回事
先看一组最容易被搞混的定义:
| 概念 | 必须实现的方法 | 语义 |
|---|---|---|
| 可迭代对象(iterable) | __iter__ | 能返回一个迭代器 |
| 迭代器(iterator) | __next__(以及 __iter__ 返回自己) | 能逐个吐出元素,耗尽时抛 StopIteration |
关键点:可迭代对象不一定能直接 next(),迭代器一定可迭代。列表、元组、字典、字符串、文件对象都是可迭代对象,但它们都不是迭代器:
from collections.abc import Iterable, Iterator
print(isinstance([1, 2, 3], Iterable)) # True
print(isinstance([1, 2, 3], Iterator)) # False
try:
next([1, 2, 3])
except TypeError as e:
print("TypeError:", e) # TypeError: 'list' object is not an iterator
列表要「变成」迭代器,得先调用内建函数 iter():
nums = [1, 2, 3]
it = iter(nums)
print(next(it)) # 1
print(next(it)) # 2
print(next(it)) # 3
反过来,迭代器一定是可迭代的,而且 iter(iterator) 返回它自己:
g = (x for x in range(3))
print(isinstance(g, Iterable), isinstance(g, Iterator)) # True True
print(iter(g) is g) # True
这条「iter(迭代器) is 迭代器」的性质很重要:它让迭代器既能被 for 消费,也能安全地传给任何「先 iter() 再循环」的代码。
for 循环的等价展开
for 做的事,就是「取迭代器 → 反复 next() → 捕获 StopIteration」:
nums = [1, 2, 3]
# 等价展开
it = iter(nums)
while True:
try:
x = next(it)
except StopIteration:
break
print(x)
# 依次输出 1 2 3
理解这个展开有两个直接好处:第一,你知道为什么任何可迭代对象都能用 for——只要它给出 __iter__;第二,你知道 for 是主动捕获 StopIteration 的,所以如果你在循环体里手动抛 StopIteration,循环会被提前结束(这是常见的隐蔽 bug)。
自己实现一个迭代器类
把 __iter__ 和 __next__ 分开写,就能看清两者的分工。下面实现一个从 start 倒数到 1 的序列:
from collections.abc import Iterable, Iterator
class Countdown:
"""可迭代对象:只负责返回迭代器。"""
def __init__(self, start):
self.start = start
def __iter__(self):
return CountdownIterator(self.start)
class CountdownIterator:
"""迭代器:持有遍历状态,负责逐个吐出元素。"""
def __init__(self, start):
self.current = start
def __iter__(self): # 迭代器也必须可迭代
return self
def __next__(self):
if self.current <= 0:
raise StopIteration # 耗尽信号
self.current -= 1
return self.current + 1
print(isinstance(Countdown(3), Iterable)) # True
print(isinstance(Countdown(3), Iterator)) # False
print(list(Countdown(3))) # [3, 2, 1]
注意 Countdown 本身不保存遍历状态,每次 iter() 都给出一个全新的迭代器。因此同一个可迭代对象可以被反复遍历:
cd = Countdown(3)
print(list(cd)) # [3, 2, 1]
print(list(cd)) # [3, 2, 1] —— 又能从头来一遍
如果让 Countdown 自己实现 __next__(也就是自己既是可迭代对象又是迭代器),那它就只能遍历一次了。这个差别正是生成器的行为。
生成器函数与生成器表达式
只要函数体里出现 yield,这个函数就变成了生成器函数:调用它不会执行函数体,而是立刻返回一个生成器对象(它同时是迭代器和可迭代对象)。
def gen():
yield 1
yield 2
g = gen()
print(type(g)) # <class 'generator'>
print(isinstance(g, Iterator)) # True
print(list(g)) # [1, 2]
生成器表达式是它的紧凑写法,语法像列表推导式,但用圆括号:
gexp = (x * 2 for x in range(3))
print(type(gexp)) # <class 'generator'>
print(list(gexp)) # [0, 2, 4]
两者的区别在于:生成器函数能写多行逻辑、带 try/finally、可接收 send();生成器表达式只适合单行表达式。性能上二者几乎一样,别用生成器表达式硬凑复杂逻辑。
生成器是惰性的
生成器最本质的特性是惰性求值(lazy):只有你 next() 它时,它才推进到下一个 yield。看这个带副作用的例子:
def noisy():
print(" start")
for i in range(3):
print(" before yield", i)
yield i
print(" after yield", i)
print("create gen")
n = noisy() # 只创建,不执行函数体
print("first next ->", next(n))
print("second next ->", next(n))
输出是:
create gen
start
before yield 0
first next -> 0
after yield 0
before yield 1
second next -> 1
「create gen」后面什么都没发生,直到第一次 next() 才跑进函数体。这种「按需推进」的能力,让生成器可以表示无限序列:
import itertools
counter = itertools.count(1) # 1, 2, 3, ... 无限
print([next(counter) for _ in range(5)]) # [1, 2, 3, 4, 5]
itertools.count 是一个迭代器,永远不会耗尽。你无法把它变成列表,但可以按需取用——这正是生成器相对列表的根本优势。
生成器是一次性的
生成器把遍历状态保存在自己身上,所以只能遍历一次:
g = (i for i in range(3))
print(list(g)) # [0, 1, 2]
print(list(g)) # [] —— 已经耗尽
如果你需要多次遍历,要么重新创建生成器,要么退回到列表。判断标准很简单:数据要复用就存列表,只用一遍就用生成器。
生成器与列表的内存对比
用 sys.getsizeof 看对象自身大小,用 tracemalloc 看真实分配的内存:
import sys
nums_list = list(range(1_000_000))
nums_gen = (i for i in range(1_000_000))
print("list sizeof:", sys.getsizeof(nums_list)) # 8000056
print("gen sizeof:", sys.getsizeof(nums_gen)) # 200
sys.getsizeof 只统计容器本身,列表要装下 100 万个指针,而生成器只是一个状态机对象。再看 tracemalloc 的实测分配峰值:
import tracemalloc
tracemalloc.start()
data = [i * i for i in range(1_000_000)]
print("list:", tracemalloc.get_traced_memory()[0]) # 40447520(约 38.6 MiB)
del data
tracemalloc.stop()
tracemalloc.start()
data = (i * i for i in range(1_000_000))
print("gen :", tracemalloc.get_traced_memory()[0]) # 776
tracemalloc.stop()
差了四个数量级以上:列表必须一次性把所有平方数算出来并保存,生成器只保存「算到哪了」。数据量大、又只需要顺序处理一遍时,生成器的内存优势非常明显。
itertools 常用工具
itertools 提供的都是返回迭代器的惰性工具,最常用的几个:
import itertools
# islice:对任意迭代器切片(count 无限也能切)
c = itertools.count(10)
print(list(itertools.islice(c, 3))) # [10, 11, 12]
print(list(itertools.islice(c, 3))) # [13, 14, 15] —— 接着上次的位置
# chain:串接多个可迭代对象
print(list(itertools.chain([1, 2], "ab", (3, 4)))) # [1, 2, 'a', 'b', 3, 4]
# takewhile:一直取到条件第一次为假
print(list(itertools.takewhile(lambda x: x < 5, [1, 3, 5, 2, 7]))) # [1, 3]
# groupby:把相邻的相同 key 分组(注意必须先排序)
data = [("a", 1), ("a", 2), ("b", 3), ("a", 4)]
for k, grp in itertools.groupby(data, key=lambda p: p[0]):
print(k, list(grp))
# a [('a', 1), ('a', 2)]
# b [('b', 3)]
# a [('a', 4)]
groupby 最容易踩的坑是「只合并相邻元素」——上面 a 出现了两组,因为它中间隔着 b。想按 key 完整分组,得先 sorted(data, key=...)。
Python 3.12 新增了 itertools.batched,把迭代器按固定长度切块,处理「分批请求 API」「按页写文件」时非常顺手:
import itertools
print(list(itertools.batched(range(7), 3))) # [(0, 1, 2), (3, 4, 5), (6,)]
try:
list(itertools.batched("ABCDEFG", 3, strict=True))
except ValueError as e:
print("strict error:", e) # strict error: batched(): incomplete batch
strict=True 会在最后一块不足时抛 ValueError,适合「数据条数必须被整除」的场景。
小结
- 可迭代对象只需实现
__iter__(返回迭代器),迭代器还要实现__next__并在耗尽时抛StopIteration;列表是可迭代对象但不是迭代器。 for循环等价于「iter()→ 反复next()→ 捕获StopIteration」。- 生成器函数(含
yield)和生成器表达式都返回生成器对象,它既是迭代器又是可迭代对象。 - 生成器是惰性的,可表示无限序列;也是一次性的,用完即空。
- 内存上生成器远优于列表(实测差四个数量级以上),但代价是不能复用、不能随机访问。
itertools提供islice/chain/takewhile/groupby/batched(3.12 起)等惰性工具。
生成器的 yield 目前只会「吐出值」。但它还能接收值、能被 throw() 注入异常、还能用 yield from 委托给子生成器——把这几件事串起来,就能搭出真正的惰性数据管道。下一节 8.2 生成器进阶:send / yield from 与惰性管道
就接着往下讲。
阅读导航:上一节:7.3 上下文管理器与 with · 下一节:8.2 生成器进阶:send / yield from 与惰性管道 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。