Cloud_Shy 陪你解读《Effective Python 3rd Edition》:从练气到老魔

第六章 Comprehensions and Generators(推导式和生成器)
许多程序都是围绕处理列表 、字典键值 对和集合而构建的。Python 提供了一种特殊的语法结构,称为 "推导式",用于简洁地遍历这些类型并创建衍生数据结构。推导式能够显著提升执行这些常见任务的代码的可读性,并带来诸多其他益处。
这种处理方式也适用于具备生成器功能的函数,这种功能使得函数能够逐次返回一系列值。调用生成器函数的结果可在任何适合使用迭代器的地方加以利用(例如 for 循环、带星号的解包表达式等)。生成器能够提升性能、减少内存使用量、提高可读性并简化实现过程。
Item 44:考虑大型列表推导式的生成器表达式
列表推导式(参见 Item 40:"使用推导式而非 map 和 filter")的一个问题在于,它们会创建新的列表实例,而这些实例可能为输入序列中的每个值都包含一个元素。对于较小的输入数据而言,这并无问题;然而,对于较大的输入数据而言,这种行为可能会消耗大量内存并导致程序崩溃。
例如,假设我想要读取一个文件并返回每行字符的数目。在此情况下,我使用列表推导式来实现这一逻辑:
import random
with open("my_file.txt", "w") as f:
for _ in range(10):
f.write("a" * random.randint(0, 100))
f.write("\n")
value = [len(x) for x in open("my_file.txt")]
print(value)
>>>
[100, 57, 15, 1, 12, 75, 5, 86, 89, 11]

提示:由于是随机数,因此运行得到的列表中的数可能不尽相同。
这段代码需要将文件每一行的长度都保持在内存中。如果文件极其庞大或者可能是一个永无止境的网络套接字,那么它就无法正常运行。为了解决这一问题,Python 提供了生成器表达式,它基于列表推导式的语法和生成器的特性构建而成。
生成器表达式在运行时并不会立即生成整个输出序列。相反,生成器表达式会评估为一个迭代器,该迭代器会从表达式中逐次提供一项内容。你可以通过在 () 字符之间加入类似列表推导式的语法结构来创建一个生成器表达式。这里我使用的是一个与上述代码等价的生成器表达式。然而,生成器表达式会立即解析为一个迭代器,并不会进行任何向前推进的操作,且占用内存资源较少:
it = (len(x) for x in open("my_file.txt"))
print(it)
>>>
<generator object <genexpr> at 0x104f37510>

返回的迭代器可按需要逐次向前推进一步,以生成生成器表达式中的下一项输出(使用内置的 next 函数)。我可以按需消耗生成器表达式中的所有内容,而不会面临内存使用量激增的风险:
print(next(it))
print(next(it))
>>>
100
57

生成器表达式另一个强大的特性在于它们可以相互组合。在此处,我取用上文所述生成器表达式返回的迭代器,并将其作为另一个生成器表达式的输入 :
roots = ((x, x**0.5) for x in it)
每次我推进这个迭代器时,它也会同时推进内部迭代器,从而产生一种连锁反应,涉及循环、评估表达式以及传递输入和输出等环节,整个过程力求最大程度地节省内存:
print(next(roots))
>>>
(15, 3.872983346207417)

像这样将生成器串联在一起的代码在 Python 中执行速度非常快。当你需要寻找一种方式来组合处理大量输入流的功能时,生成器表达式是最佳的选择工具(更多示例请参见 Item 23 以及 Item 24)。唯一的难点在于,由生成器表达式返回的迭代器具有状态属性,因此必须谨慎操作,避免对同一迭代器进行重复使用(参见 Item 21:"在遍历参数时应保持谨慎")。
注意:
- 列表推导式若使用过多内存,则可能会给大规模输入带来问题。
- 生成器表达式通过以迭代器的方式逐次生成输出结果,从而避免了内存问题。
- 生成器表达式可通过将迭代器从一个生成器表达式传递到另一个生成器表达式的 for 子表达式中进行组合。
- 生成器表达式在串联使用时执行速度非常快,且具有很好的内存效率。
Item 45:组合多个具有 yield 功能的生成器
生成器提供了多种益处(参见 Item 43:"考虑使用生成器而非返回列表")以及解决常见问题的方案(参见 Item 21:"在遍历参数时应保持谨慎")。生成器如此实用,以至于许多程序开始呈现出由层层生成器串联而成的形态。
例如,假设我有一个图形程序,它正利用生成器来动态展现屏幕上图像的运动效果。为了达到我所期望的视觉效果,我需要这些图像首先快速移动,短暂停顿,然后再以较慢的速度继续运动。在此情况下,我定义了两个生成器,它们会为动画的各个部分生成预期的屏幕变化量:
def move(period, speed):
for _ in range(period):
yield speed
def pause(delay):
for _ in range(delay):
yield 0
为了制作最终的动画效果,我需要将移动与暂停功能结合起来,以生成一系列连续的屏幕变化序列。为此,我通过为动画的每一步调用一个生成器、依次遍历每个生成器,然后依次输出来自所有生成器的变化量来完成这一过程:
def animate():
for delta in move(4, 5.0):
yield delta
for delta in pause(3):
yield delta
for delta in move(2, 3.0):
yield delta
现在,我能够将这些变化在屏幕上实时呈现出来,因为这些变化是由单一的动画生成器产生的:
def render(delta):
print(f"Delta: {delta:.1f}")
# Move the images onscreen
def run(func):
for delta in func():
render(delta)
run(animate)
>>>
Delta: 5.0
Delta: 5.0
Delta: 5.0
Delta: 5.0
Delta: 0.0
Delta: 0.0
Delta: 0.0
Delta: 3.0
Delta: 3.0

这段代码的问题是 animate 函数的重复性。每个生成器的 for 语句和 yield 表达式的冗余增加了噪音并降低了可读性。这个例子只包含三个嵌套生成器,而且它已经损害了清晰度;具有十几个阶段或更多阶段的复杂动画将非常难以理解。
解决这个问题的方法是使用 yield from 表达式。这种高级生成器功能允许您在将控制返回到父生成器之前从嵌套生成器生成所有值。在这里,我使用 yield from 重新实现动画函数:
def animate_composed():
yield from move(4, 5.0)
yield from pause(3)
yield from move(2, 3.0)
run(animate_composed)
>>>
Delta: 5.0
Delta: 5.0
Delta: 5.0
Delta: 5.0
Delta: 0.0
Delta: 0.0
Delta: 0.0
Delta: 3.0
Delta: 3.0

结果和以前一样,但现在代码更清晰、更直观。从本质上讲,yield from 指示 Python 解释器为您执行嵌套的 for 循环和 yield 表达式,从而导致执行速度稍快一些。如果您发现自己正在编写生成器,我强烈建议您尽可能使用yield from。
注意:
- yield from 表达式允许您将多个嵌套生成器组合成一个组合生成器。
- yield from 消除了手动迭代嵌套生成器并生成其输出所需的样板。
Item 46:将迭代器作为参数传递到生成器中,而不是调用 send 方法
yield 表达式为生成器函数提供了一种简单的方法来生成一系列可迭代的输出值(请参阅 Item 43)。然而,这个通道似乎是单向的:没有立即明显的方法可以在生成器运行时同时将数据传入和传出生成器。这种双向通信在多种情况下都很有价值。
例如,假设我正在编写一个程序来使用软件定义的无线电传输信号。在这里,我使用一个函数来生成具有给定点数的正弦波的近似值:
import math
def wave(amplitude, steps):
step_size = 2 * math.pi / steps
for step in range(steps):
radians = step * step_size
fraction = math.sin(radians)
output = amplitude * fraction
yield output

现在,我可以通过迭代波发生器以单个指定幅度传输波信号:
def transmit(output):
if output is None:
print(f"Output is None")
else:
print(f"Output: {output:>5.1f}")
def run(it):
for output in it:
transmit(output)
run(wave(3.0, 8))
>>>
Output: 0.0
Output: 2.1
Output: 3.0
Output: 2.1
Output: 0.0
Output: -2.1
Output: -3.0
Output: -2.1

这对于产生基本波形效果很好,但它不能用于根据单独的输入不断改变波的幅度(即,根据广播 AM 无线电信号的要求)。我需要一种方法来调制生成器每次迭代的幅度。
Python 生成器支持 send 方法,它将 yield 表达式升级为双向通道。send 方法可用于在生成器产生输出的同时向生成器提供流输入。通常,当迭代生成器时,yield 表达式的值为 None:
def my_generator():
received = yield 1
print(f"{received=}")
it = my_generator()
output = next(it) # Get first generator output
print(f"{output=}")
try:
next(it) # Run generator until it exits
except StopIteration:
pass
>>>
output=1
received=None

当我调用 send 方法而不是使用 for 循环或 next 内置函数迭代生成器时,当生成器恢复时,提供的参数将成为 yield 表达式的值。然而,当生成器第一次启动时,还没有遇到 yield 表达式,因此最初调用 send 的唯一有效值是None。(任何其他参数都会在运行时引发异常。)在这里,我运行与上面相同的生成器,但使用 send 而不是 next 来推进它们:
it = my_generator()
output = it.send(None) # Get first generator output
print(f"{output=}")
try:
it.send("hello!") # Send value into the generator
except StopIteration:
pass
>>>
output=1
received='hello!'

我可以利用这种行为来根据输入信号调制正弦波的幅度。首先,我需要更改波形生成器以保存 yield 表达式返回的幅度并使用它来计算下一个生成的输出:
def wave_modulating(steps):
step_size = 2 * math.pi / steps
amplitude = yield # Receive initial amplitude
for step in range(steps):
radians = step * step_size
fraction = math.sin(radians)
output = amplitude * fraction
amplitude = yield output # Receive next amplitude
然后,我需要更新 run 函数,以便在每次迭代时将调制幅度流式传输到 wave_modulating 生成器 中。send 的第一个输入必须是 None,因为生成器中还没有出现 yield 表达式:
def run_modulating(it):
amplitudes = [None, 7, 7, 7, 2, 2, 2, 2, 10, 10, 10, 10, 10]
for amplitude in amplitudes:
output = it.send(amplitude)
transmit(output)
run_modulating(wave_modulating(12))
>>>
Output is None
Output: 0.0
Output: 3.5
Output: 6.1
Output: 2.0
Output: 1.7
Output: 1.0
Output: 0.0
Output: -5.0
Output: -8.7
Output: -10.0
Output: -8.7
Output: -5.0

这是有效的;其根据输入信号适当地改变了输出幅度。正如预期的那样,第一个输出为 None,因为生成器直到初始 yield 表达式之后才收到幅值。
这段代码的一个问题是,新读者很难理解:在赋值语句的右侧使用 yield 并不直观,而且在不了解这一高级生成器功能的细节的情况下,很难看出 yield 和 send 之间的联系。
现在,想象一下该程序的要求变得更加复杂。我需要使用由多个序列信号组成的复杂波形,而不是使用简单的正弦波作为载波。实现此行为的一种方法是将多个生成器与 yield from 表达式组合在一起(请参阅 Item 45)。再次,可以确定的是在振幅固定的更简单的情况下,这可以按预期生效:
def complex_wave():
yield from wave(7.0, 3)
yield from wave(2.0, 4)
yield from wave(10.0, 5)
run(complex_wave())
>>>
Output: 0.0
Output: 6.1
Output: -6.1
Output: 0.0
Output: 2.0
Output: 0.0
Output: -2.0
Output: 0.0
Output: 9.5
Output: 5.9
Output: -5.9
Output: -9.5

鉴于 yield from 表达式可以处理更简单的情况,您可能期望它也能与生成器send方法一起正常工作。在这里,我尝试使用 yield from 来组成对 wave_modulating 生成器(使用 send)的多个调用:
def complex_wave_modulating():
yield from wave_modulating(3)
yield from wave_modulating(4)
yield from wave_modulating(5)
run_modulating(complex_wave_modulating())
>>>
Output is None
Output: 0.0
Output: 6.1
Output: -6.1
Output is None
Output: 0.0
Output: 2.0
Output: 0.0
Output: -10.0
Output is None
Output: 0.0
Output: 9.5
Output: 5.9

这在某种程度上有效,但结果包含一个很大的意外:输出中有很多 None 值!为什么会出现这种情况?当每个yield from 表达式完成对嵌套生成器的迭代时,它会移至下一个。每个嵌套生成器都以一个裸 yield 表达式(没有值)开始,以便从生成器发送方法调用接收初始幅度。这会导致父生成器在子生成器之间转换时输出 None 值。
这意味着,如果你尝试将它们一起使用,那么你对 yield from 和 send 特性如何独自表现的假设将被打破。虽然可以通过增加 run_modulating 函数的复杂性来解决这个 None 问题,但不必这么麻烦。对于代码的新读者来说,理解 send 的工作原理已经很困难了。这个令人惊讶的 yield from 问题让情况变得更糟。我的建议是完全避免使用 send 方法并采用更简单的方法。
最简单的解决方案是将迭代器传递到波函数中。每次调用 next 内置函数时,迭代器都应返回输入幅度。这种安排确保每个生成器在处理输入和输出时级联进行(有关其他示例,请参阅 Item 44:"考虑大型列表推导式的生成器表达式"和 Item 23 条:"将迭代器传递给 any 和 all 以实现高效短路逻辑"):
def wave_cascading(amplitude_it, steps):
step_size = 2 * math.pi / steps
for step in range(steps):
radians = step * step_size
fraction = math.sin(radians)
amplitude = next(amplitude_it) # Get next input
output = amplitude * fraction
yield output
我可以将相同的迭代器传递到我尝试与 yield from 组合在一起的每个生成器函数中。迭代器是有状态的,因此每个嵌套生成器都会从前一个生成器停止的地方继续(有关信息,请参阅 Item 21:"迭代参数时保持防御性"):
def complex_wave_cascading(amplitude_it):
yield from wave_cascading(amplitude_it, 3)
yield from wave_cascading(amplitude_it, 4)
yield from wave_cascading(amplitude_it, 5)
现在,我可以通过简单地从振幅列表中传入迭代器来运行组合生成器:
def run_cascading():
amplitudes = [7, 7, 7, 2, 2, 2, 2, 10, 10, 10, 10, 10]
it = complex_wave_cascading(iter(amplitudes)) # Supplies iterator
for amplitude in amplitudes:
output = next(it)
transmit(output)
run_cascading()
>>>
Output: 0.0
Output: 6.1
Output: -6.1
Output: 0.0
Output: 2.0
Output: 0.0
Output: -2.0
Output: 0.0
Output: 9.5
Output: 5.9
Output: -5.9
Output: -9.5

这种方法的好处是输入迭代器可以来自任何地方并且可以是完全动态的(例如,使用生成器函数实现或组合;请参阅 Item 24:"考虑使用迭代器和生成器使用 itertools")。唯一的缺点是此代码假设输入生成器是线程安全的,但情况可能并非如此。如果您需要跨线程边界,async 函数可能更适合(请参阅 Item 77 条:"混合线程和协程以轻松过渡到 asyncio")。
注意:
- send 方法可用于通过为 yield 表达式提供一个可分配给变量的值来将数据注入到生成器中。
- 在 yield from 表达式中使用 send 可能会导致令人意外的结果,例如在生成器输出中意外出现 None 值。
- 为一组组合生成器提供输入迭代器是比使用 send 方法更好的方法,应该避免使用 send 方法。
Item 47:使用类而不是生成器 throw 方法来管理迭代状态转换
除了 yield from 表达式(参见 Item 45)和 send 方法(参见 Item 46)之外,另一个高级生成器功能是用于在生成器函数中重新引发异常实例的 throw 方法。 throw 运行的方式很简单:当调用该方法时,生成器内下一次出现的 yield 表达式会在接收到其输出后重新引发所提供的 Exception 实例,而不是正常继续。在这里,我展示了此行为的一个简单示例:
class MyError(Exception):
pass
def my_generator():
yield 1
yield 2
yield 3
it = my_generator()
print(next(it)) # Yields 1
print(next(it)) # Yields 2
print(it.throw(MyError("test error"))) # Raises
>>>
1
2
Traceback ...
MyError: test error

当你调用 throw 时,生成器函数可能会使用标准 try/ except compound 语句捕获注入的异常,该语句包围最后执行的 yield 表达式(有关异常处理的更多信息,请参阅 Item 80:"利用 try/ except/else/finally 中的每个块"):
def my_generator():
yield 1
try:
yield 2
except MyError:
print("Got MyError!")
else:
yield 3
yield 4
it = my_generator()
print(next(it)) # Yields 1
print(next(it)) # Yields 2
print(it.throw(MyError("test error"))) # Yields 4
>>>
1
2
Got MyError!
4

此功能在生成器及其调用者之间提供了双向通信通道,这在某些情况下非常有用。例如,假设我需要一个支持偶发重置的计时器程序。在这里,我通过定义一个生成器来实现此行为,该生成器依赖于在计算 yield 表达式时引发的 Reset 异常:
class Reset(Exception):
pass
def timer(period):
current = period
while current:
try:
yield current
except Reset:
print("Resetting")
current = period
else:
current -= 1
每当使用 Reset 异常在生成器上调用 throw 方法时,计数器就会在 except 块中重新启动。在这里,我定义了一个驱动程序函数,它迭代计时器生成器,宣布每个步骤的进度,并注入可能由外部轮询输入(例如按钮)引起的重置事件:
ORIGINAL_RESETS = [
False,
False,
False,
True,
False,
True,
False,
False,
False,
False,
False,
False,
False,
False,
]
RESETS = ORIGINAL_RESETS[:]
def check_for_reset():
# Poll for external event
return RESETS.pop(0)
def announce(remaining):
print(f"{remaining} ticks remaining")
def run():
it = timer(4)
while True:
try:
if check_for_reset():
current = it.throw(Reset())
else:
current = next(it)
except StopIteration:
break
else:
announce(current)
run()
>>>
4 ticks remaining
3 ticks remaining
2 ticks remaining
Resetting
4 ticks remaining
3 ticks remaining
Resetting
4 ticks remaining
3 ticks remaining
2 ticks remaining
1 ticks remaining

这段代码按预期工作,但阅读起来比必要的要困难得多。捕获停止迭代异常或决定调用哪个函数所需的各种级别的嵌套会使代码变得嘈杂。实现此功能的一种更简单的方法是创建一个基本类来管理计时器的状态并启用状态转换。 在这里,我定义了一个类,其中包含用于步进计时器的tick方法、用于重新启动时钟的 reset 方法以及用于检查计时器是否已过的__bool__ 特殊方法(有关信息,请参阅 Item 57:"从 collections.abc 类继承自定义容器类型"):
class Timer:
def __init__(self, period):
self.current = period
self.period = period
def reset(self):
print("Resetting")
self.current = self.period
def tick(self):
before = self.current
self.current -= 1
return before
def __bool__(self):
return self.current > 0
现在,run 方法可以使用 Timer 对象作为 while 语句中的测试表达式;由于嵌套级别的减少,循环体中的代码更容易理解:
RESETS = ORIGINAL_RESETS[:]
def run():
timer = Timer(4)
while timer:
if check_for_reset():
timer.reset()
announce(timer.tick())
run()
>>>
4 ticks remaining
3 ticks remaining
2 ticks remaining
Resetting
4 ticks remaining
3 ticks remaining
Resetting
4 ticks remaining
3 ticks remaining
2 ticks remaining
1 ticks remaining

输出与使用 throw 的早期版本相匹配,但这种实现更容易理解,特别是对于代码的新读者而言。如果您需要这种类型的异常行为,我建议您完全避免使用 throw,而是使用有状态类(出于另一个原因,请参阅 Item 89:"始终将资源传递到生成器并让调用者在外部清理它们")。否则,如果您确实需要类似生成器的函数之间进行更高级的协作,那么值得考虑 Python 的异步功能(请参阅 Item 75:"使用协程实现高度并发 I/O ")。
注意:
- throw 方法可用于在生成器内最近执行的 yield 表达式的位置重新引发异常。
- 使用 throw 会损害可读性是因为它需要额外的嵌套和样板才能引发和捕获异常。
- 更好的方法是简单地定义一个有状态类,该类提供迭代和状态转换的方法。