迭代是数据处理的基石。扫描内存中放不下的数据集时,我们要找到一种惰性获取数据项的方式,即按需一次获取一个数据项。这就是迭代器模式(Iterator pattern)。为此,Python 语言实现了“迭代器”和“生成器”。
在 Python 中,所有集合都可以迭代。在 Python 语言内部,迭代器用于支持:
- for 循环
- 构建和扩展集合类型
- 逐行遍历文本文件
- 列表推导、字典推导和集合推导
- 元组拆包
- 调用函数时,使用 * 拆包实参
本章涵盖以下话题:
- 语言内部如何使用 iter(...) 内置函数处理可迭代对象
- 如何使用 Python 实现经典的迭代器模式
- 详细说明生成器函数的工作原理
- 如何使用生成器函数或生成器表达式代替经典的迭代器
- 如何使用 yield from 语句合并生成器
-
为什么生成器和协程看似相同,实则差别很大,不能混淆
14.1 可迭代对象:iter() 函数如何把序列变得可以迭代
序列可以迭代的原因:iter 函数,解释器需要迭代对象 x 时,会隐式自动调用 iter(x),iter(x) 有一下几个作用:
- 检查对象是否实现了 __iter__ 方法,如果实现了就调用它,获取一个迭代器。
- 如果没有实现 __iter__ 方法,但是实现了 __getitem__ 方法, Python 会创建一个迭代器,尝试按顺序(从索引 0 开始)获取元素。
- 如果尝试失败,Python 抛出 TypeError 异常,通常会提示“C object is not iterable”(C 对象不可迭代),其中 C 是目标对象所属的类。
任何 Python 序列都可迭代的原因是,它们都实现了 __getitem__ 方法。 其实,标准的序列也都实现了 __iter__ 方法,之所以对 __getitem__ 方法做特殊处理,是为了向后兼容。
综上所属,我们可以给“可迭代对象”一个定义:
- 任何实现了 __iter__ 方法且返回一个迭代器的对象;
- 或者是实现了 __getitem__ 方法且索引从 0 开始的对象;
示例 14-1-1 自定义一个序列:
class Sentence:
def __init__(self, text: str):
self.words = text.split()
# 实现 __getitem__ 方法,且索引从 0 开始
def __getitem__(self, item):
print('call __getitem__ ...')
return self.words[item]
# 实现 __iter__ 方法
def __iter__(self):
print('call __iter__ ...')
return iter(self.words)
# 为了完善序列协议,所以实现了 __len__ 方法;不过,为了对象可以迭代,没必要实现这个方法。
def __len__(self):
return len(self.words)
s = Sentence('Hello Python')
# for 隐式调用 iter() 函数迭代序列
for i in s:
print(i)
# 结果输出:
# call __iter__ ...
# Hello
# Python
从调用结果上看,Python 是调用了 __iter__ 来迭代 Sentence 序列,如果注释掉 __iter__ 的话,那么就会调用 __getitem__ 来迭代序列!
14.2 可迭代的对象与迭代器的对比
首先,我们要明确“可迭代对象”和“迭代器”之间的关系:Python 从可迭代的对象中获取迭代器。
例如下面这个示例:
s = 'ABC'
for i in s:
print(i)
这是一个简单的 for 循环,迭代一个字符串,字符串 'ABC' 是可迭代对象。背后是有迭代器的,只不过我们看不到。
执行这段代码,Python 进行了以下工作:
-
使用可迭代的对象 s 构建迭代器 it;
-
不断在迭代器上调用 next 函数,获取下一个字符;
-
如果没有字符了,迭代器会抛出 StopIteration 异常(for 内部会处理 StopIteration 异常);
-
释放对 it 的引用,即废弃迭代器对象;
-
退出循环;
从上可见,可迭代对象是 s,迭代器(不可见)是 it,可迭代对象 s 通过它的 __iter__ 方法来获取一个迭代器 it,然后迭代器 it 通过 __next__ 方法返回自身的下一个元素,如果没有元素了就抛出 StopIteration 异常,而且迭代器还实现了 __iter__ 方法,因此迭代器也可以迭代。
标准的迭代器接口必须实现两个方法:
-
__next__:返回下一个可用的元素,如果没有元素了,抛出 StopIteration 异常。
-
__iter__:返回 self,以便在应该使用可迭代对象的地方使用迭代器,例如在 for 循环中。
可迭代对象和迭代器的区别:
可迭代的对象有个 __iter__ 方法,每次都实例化一个新的迭代器;而迭代器要实现 __next__ 方法,返回单个元素,此外还要实现 __iter__ 方法,返回迭代器本身。
14.3 实现典型的迭代器
⚠️ 注意,下面的示例并不符合 Python 的习惯做法,后面重构时会说明原因。不过,通过这一版能明确可迭代对象和迭代器对象之间的关系。
示例 14-3-1 中定义的 Sentence 类可以迭代,因为它实现了特殊的 __iter__ 方法,构建并返回一个 SentenceIterator 实例。
class SentenceIterator:
"""迭代器"""
def __init__(self, words):
# 1. 引用单词列表
self.words = words
# 2. self.index 用于确定下一个要获取的单词
self.index = 0
def __next__(self):
try:
# 3. 获取 self.index 索引位上的单词
word = self.words[self.index]
# 4-1. 如果 self.index 索引位上没有单词,那么抛出 StopIteration 异常。
except IndexError:
raise StopIteration()
# 4-2. 获取值之后,递增 self.index,以便下次调用 __next__ 获取下一个元素。
self.index += 1
return word
# 实现 self.__iter__ 方法,返回自身
def __iter__(self):
return self
class Sentence:
"""可迭代对象"""
def __init__(self, text: str):
self.words = text.split()
# 根据可迭代协议,__iter__ 方法实例化并返回一个迭代器。
def __iter__(self):
return SentenceIterator(self.words)
def __len__(self):
return len(self.words)
s = Sentence('Hello World')
for i in s:
print(i)
# 输出结果:
# Hello
# World
注意,对这个示例来说,其实没必要在 SentenceIterator 类中实现 __iter__ 方法(不实现也可以达到相同的效果),不过这么做是对的,因为迭代器应该实现 __next__ 和 __iter__ 两个方法,如果不实现 __iter__ 那么迭代器自身便不可迭代了,例如这样:
st = SentenceIterator(['Hello', 'World'])
for i in st:
print(i)
# 运行报错:
# Traceback (most recent call last):
# File "/Users/PycharmProjects/FluentPython/demo.py", line 40, in <module>
# for i in st:
# TypeError: 'SentenceIterator' object is not iterable
14.4 生成器函数
示例 14-4-1:更符合 Python 习惯的做法,用生成器函数重构 Sentence 类,不用再单独定义一个迭代器类;
class Sentence:
"""可迭代对象"""
def __init__(self, text: str):
self.words = text.split()
def __iter__(self):
# 迭代 self.words
for word in self.words:
# 产出当前的 word
yield word
def __len__(self):
return len(self.words)
s = Sentence('Hello World')
for i in s:
print(i)
# 输出结果:
# Hello
# World
在示例 14-3-1 定义的 Sentence 类中,__iter__ 方法调用 SentenceIterator 类的构造方法创建一个迭代器并将其返回。本节示例中,迭代器其实是生成器对象,每次调用 __iter__ 方法都会自动创建,因为这里的 __iter__ 方法是生成器函数。
生成器函数的工作原理:
生成器函数的定义:Python 函数的定义体中有 yield 关键字,该函数就是生成器函数;
普通的函数与生成器函数在句法上唯一的区别是:在后者的定义体中有 yield 关键字。
原理:
- 生成器函数会创建一个生成器对象,包装生成器函数的定义体。
- 把生成器对象传给 next() 函数时,生成器对象会向前执行函数定义体中的下一个 yield 语句,返回产出的值,并在函数定义体的当前位置暂停;
- 执行外部的代码,然后再回到函数内暂停的位置执行后续的代码,直到下一个 yield 语句。
- 最终,生成器函数的定义体执行完毕,外层的生成器对象会抛出 StopIteration 异常——这一点与迭代器协议一致。
示例 14-4-2 使用 for 循环清楚地说明了生成器函数定义体的执行过程
def gen_demo():
print('start...')
yield 'A'
print('continue...')
yield 'B'
print('continue...')
yield 'C'
print('end!')
g = gen_demo()
print(next(g)) # 第一次调用 next() 函数时,会打印 'start...',然后停在第一个 yield 语句,生成值 'A';
# start...
# A
print(next(g)) # 第二次调用 next() 函数时,会打印 'continue...',然后停在第二个 yield 语句,生成值 'B';
# continue...
# B
print(next(g)) # 依此类推...
# continue...
# C
print(next(g)) # 三个 yield 执行完毕后,再回到生成器函数的定义体中执行后续代码(如有),代码执行完毕后由生成器对象抛出 StopIteration 异常
# end!
# Traceback (most recent call last):
# File "/Users/PycharmProjects/FluentPython/demo.py", line 31, in <module>
# print(next(g))
# StopIteration
14.5 惰性实现
“惰性求值”和“及早求值” 是编程语言理论方面的技术术语。Python 在设计 Iterator 接口时考虑到了惰性求值:next(my_iterator) 一次生成一个元素。
目前实现的几版 Sentence 类都不具有惰性,因为 __init__ 方法急迫地构建好了文本中的单词列表,然后将其绑定到 self.words 属性上。 这样就得处理整个文本,列表使用的内存量可能与文本本身一样多(或许更多,这取决于文本中有多少非单词字符)。
re.finditer 函数是 re.findall 函数的惰性版本,返回的不是列表,而是一个生成器,按需生成 re.MatchObject 实例。如果有很多匹配,re.finditer 函数能节省大量内存。我们要使用这个函数让 Sentence 类变得懒惰,即只在需要时才生成下一个单词。
示例 14-5-1
import re
RE_WORD = re.compile(r'\w+')
class Sentence:
def __init__(self, text):
self.text = text
def __iter__(self):
# finditer 函数构建一个迭代器,包含 self.text 中匹配 RE_WORD 的单词,产出 MatchObject 实例
for match in RE_WORD.finditer(self.text):
# match.group() 方法从 MatchObject 实例中提取匹配正则表达式的具体文本
yield match.group()
14.6 生成器表达式
简单的生成器函数,如上面的 Sentence 类中使用的那个(见示例 14-5-1),可以替换成生成器表达式。
生成器表达式可以理解为列表推导的惰性版本:不会迫切地构建列表, 而是返回一个生成器,按需惰性生成元素。也就是说,如果列表推导是制造列表的工厂,那么生成器表达式就是制造生成器的工厂。
示例 14-6-1 演示了一个简单的生成器表达式,并且与列表推导做了对比;
# 列表推导
l1 = [x * 3 for x in gen()]
print(l1) # ['AAA', 'BBB', 'CCC']
# 生成器表达式
g = (x * 3 for x in gen())
print(g) # <generator object <genexpr> at 0x107eadd68>
print(next(g)) # AAA
print(next(g)) # BBB
print(next(g)) # CCC
示例 14-6-2 使用生成器表达式实现 Sentence 类
import re
RE_WORD = re.compile(r'\w+')
class Sentence:
def __init__(self, text):
self.text = text
def __iter__(self):
return (match.group() for match in RE_WORD.finditer(self.text))
与示例 14-5-1 唯一的区别是 __iter__ 方法,这里不是生成器函数了 (没有 yield),而是使用生成器表达式构建生成器,然后将其返回。 不过,最终的效果一样调用:__iter__ 方法会得到一个生成器对象。
14.7 yield from
如果生成器函数需要产出另一个生成器生成的值,传统的解决方法是使用嵌套的 for 循环,例如下面我们实现的生成器:
def chain(*iterables):
for it in iterables:
for i in it:
yield i
s = 'ABC'
l = [1, 2, 3]
print(list(chain(s, l))) # ['A', 'B', 'C', 1, 2, 3]
chain 生成器函数把操作依次交给接收到的各个可迭代对象处理。
然而,在 Python3.3 版本中引入了 yield from 语法,它可以从另一个可迭代对象或者迭代器获取元素,例如:
def chain(*iterables):
for it in iterables:
yield from it # 直接从 it 中依次取值
s = 'ABC'
l = [1, 2, 3]
print(list(chain(s, l))) # ['A', 'B', 'C', 1, 2, 3]
可以看出,yield from it 完全代替了内层的 for 循环。在这个示例中使用 yield from 是对的,而且代码读起来更顺畅,不过感觉更像是语法糖。除了代替循环之外,yield from 还会创建通道,把内层生成器直接与外层生成器的客户端联系起来。把生成器当成协程使用时,这个 通道特别重要,不仅能为客户端代码生成值,还能使用客户端代码提供的值。第 16 章会深入讲解协程,其中有几页会说明为什么 yield from 不只是语法糖而已。
14.8 深入分析 iter 函数
如前所述,在 Python 中迭代对象 x 时会调用 iter(x)。
可是,iter 函数还有一个鲜为人知的用法:传入两个参数,使用常规的函数或任何可调用的对象创建迭代器。这样使用时,第一个参数必须是可调用的对象,用于不断调用(没有参数),产出各个值;第二个参数是哨符,这是个标记值,当可调用的对象返回这个值时,触发迭代器抛出 StopIteration 异常,停止产出值。
from random import randint
def demo():
"""返回一个随机值"""
return randint(1, 5)
# 第一个参数必须是可调用对象,传方法名不要加()
# 第二个参数是哨符,iter 会不断调用 demo 返回随机值,知道随机值返回为 1 时,停止调用,并抛出 StopIteration 异常
d = iter(demo, 1)
print(d) # <callable_iterator object at 0x10652fc50>
for i in d: # 让 d 不断迭代,直到 demo 返回的值 == 1,并且隐式处理 StopIteration 异常;
print(i)
# 结果输出:
# 3
# 5
# 5
# 3
# 4
14.9 把生成器当成协程
Python 2.2 引入了 yield 关键字实现的生成器函数,大约五年后, Python 2.5 实现了“PEP 342”,这个提案为 生成器对象添加了额外的方法和功能,其中最值得关注的是 .send() 方法。
与 .__next__() 方法一样,.send() 方法致使生成器前进到下一个 yield 语句。不过,.send() 方法还允许使用生成器的客户把数据发给自己,即不管传给 .send() 方法什么参数,那个参数都会成为生成器函数定义体中对应的 yield 表达式的值。也就是说,.send() 方法允许在客户代码和生成器之间双向交换数据。而 .__next__() 方法只允许客户从生成器中获取数据。
这是一项重要的“改进”,甚至改变了生成器的本性:像这样使用的话, 生成器就变身为协程。但是生成器不是协程,两者不能混为一谈,所以在 PyCon US 2009 期间举办的一场著名的课程中 (http://www.dabeaz.com/coroutines/),David Beazley提醒道:
-
生成器用于生成供迭代的数据
-
协程是数据的消费者
-
为了避免脑袋炸裂,不能把这两个概念混为一谈
-
协程与迭代无关
-
虽然在协程中会使用 yield 产出值,但这与迭代无关
请务必遵循以上建议;至此本章结束,不涉及把生成器当成协程使用的 send() 方法和其他特性,这些会在第16章协程中讨论。