《Python编程入门》8.1 可迭代协议、迭代器与生成器

很多人把可迭代对象和迭代器当成同一件事,这恰恰是理解 for 循环与生成器的第一道坎。本节先用一张表把两者的协议差异讲透:可迭代对象只需实现 __iter__,迭代器还要实现 __next__ 并在耗尽时抛出 StopIteration。接着展开 for 循环的等价写法、手写迭代器类、生成器函数与生成器表达式的区别、生成器的惰性与一次性,并实测生成器比列表省下多少内存。

本节目标:彻底分清「可迭代对象」与「迭代器」这组最常被混淆的概念,掌握 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,适合「数据条数必须被整除」的场景。

小结

  1. 可迭代对象只需实现 __iter__(返回迭代器),迭代器还要实现 __next__ 并在耗尽时抛 StopIteration;列表是可迭代对象但不是迭代器。
  2. for 循环等价于「iter() → 反复 next() → 捕获 StopIteration」。
  3. 生成器函数(含 yield)和生成器表达式都返回生成器对象,它既是迭代器又是可迭代对象。
  4. 生成器是惰性的,可表示无限序列;也是一次性的,用完即空。
  5. 内存上生成器远优于列表(实测差四个数量级以上),但代价是不能复用、不能随机访问。
  6. 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 与惰性管道 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时