流畅的 Python 第二版(GPT 重译)(九)(3)https://developer.aliyun.com/article/1484707
示例 17-31. tree/step4/tree.py 中的 sub_tree 生成器
 
    def sub_tree(cls): for sub_cls in cls.__subclasses__(): yield sub_cls.__name__, 1 for sub_sub_cls in sub_cls.__subclasses__(): yield sub_sub_cls.__name__, 2 for sub_sub_sub_cls in sub_sub_cls.__subclasses__(): yield sub_sub_sub_cls.__name__, 3
在 示例 17-31 中有一个明显的模式。我们使用 for 循环获取第 N 级的子类。每次循环,我们产出第 N 级的一个子类,然后开始另一个 for 循环访问第 N+1 级。
在 “重新发明链” 中,我们看到如何用相同的生成器上的 yield from 替换驱动生成器的嵌套 for 循环。如果我们使 sub_tree 接受一个 level 参数,并递归地 yield from 它,将当前子类作为新的根类和下一个级别编号传递。参见 示例 17-32。
示例 17-32. tree/step5/tree.py: 递归的 sub_tree 走到内存允许的极限
 
    def tree(cls): yield cls.__name__, 0 yield from sub_tree(cls, 1) def sub_tree(cls, level): for sub_cls in cls.__subclasses__(): yield sub_cls.__name__, level yield from sub_tree(sub_cls, level+1) def display(cls): for cls_name, level in tree(cls): indent = ' ' * 4 * level print(f'{indent}{cls_name}') if __name__ == '__main__': display(BaseException)
示例 17-32 可以遍历任意深度的树,仅受 Python 递归限制。默认限制允许有 1,000 个待处理函数。
任何关于递归的好教程都会强调有一个基本情况以避免无限递归的重要性。基本情况是一个有条件返回而不进行递归调用的条件分支。基本情况通常使用 if 语句实现。在 示例 17-32 中,sub_tree 没有 if,但在 for 循环中有一个隐式条件:如果 cls.__subclasses__() 返回一个空列表,则循环体不会执行,因此不会发生递归调用。基本情况是当 cls 类没有子类时。在这种情况下,sub_tree 不产出任何内容。它只是返回。
示例 17-32 按预期工作,但我们可以通过回顾我们在达到第 3 级时观察到的模式来使其更简洁(示例 17-31):我们产生一个带有级别N的子类,然后开始一个嵌套的循环以访问级别N+1。在示例 17-32 中,我们用yield from 替换了该嵌套循环。现在我们可以将tree 和sub_tree 合并为一个单一的生成器。示例 17-33 是此示例的最后一步。
示例 17-33. tree/step6/tree.py:tree 的递归调用传递了一个递增的level 参数
 
    def tree(cls, level=0): yield cls.__name__, level for sub_cls in cls.__subclasses__(): yield from tree(sub_cls, level+1) def display(cls): for cls_name, level in tree(cls): indent = ' ' * 4 * level print(f'{indent}{cls_name}') if __name__ == '__main__': display(BaseException)
在“使用 yield from 的子生成器”开头,我们看到yield from 如何将子生成器直接连接到客户端代码,绕过委托生成器。当生成器用作协程并且不仅产生而且从客户端代码消耗值时,这种连接变得非常重要,正如我们将在“经典协程”中看到的那样。
在第一次遇到yield from 后,让我们转向对可迭代和迭代器进行类型提示。
通用可迭代类型
Python 标准库有许多接受可迭代参数的函数。在您的代码中,这些函数可以像我们在示例 8-15 中看到的zip_replace 函数一样进行注释,使用collections.abc.Iterable(或者如果必须支持 Python 3.8 或更早版本,则使用typing.Iterable,如“遗留支持和已弃用的集合类型”中所解释的那样)。参见示例 17-34。
示例 17-34. replacer.py 返回一个字符串元组的迭代器
from collections.abc import Iterable FromTo = tuple[str, str] # ① def zip_replace(text: str, changes: Iterable[FromTo]) -> str: # ② for from_, to in changes: text = text.replace(from_, to) return text
①
定义类型别名;虽然不是必需的,但可以使下一个类型提示更易读。从 Python 3.10 开始,FromTo 应该具有类型提示typing.TypeAlias,以阐明此行的原因:FromTo: TypeAlias = tuple[str, str]。
②
注释changes 以接受FromTo 元组的Iterable。
Iterator 类型出现的频率不如Iterable 类型高,但编写起来也很简单。示例 17-35 展示了熟悉的斐波那契生成器,并加上了注释。
示例 17-35. fibo_gen.py:fibonacci 返回一个整数生成器
 
    from collections.abc import Iterator def fibonacci() -> Iterator[int]: a, b = 0, 1 while True: yield a a, b = b, a + b
注意,类型Iterator 用于使用yield编写的函数生成器,以及手动编写的作为类的迭代器,具有__next__。还有一个collections.abc.Generator 类型(以及相应的已弃用typing.Generator),我们可以用它来注释生成器对象,但对于用作迭代器的生成器来说,这显得冗长。
示例 17-36,经过 Mypy 检查后,发现Iterator 类型实际上是Generator 类型的一个简化特例。
示例 17-36. itergentype.py:注释迭代器的两种方法
from collections.abc import Iterator from keyword import kwlist from typing import TYPE_CHECKING short_kw = (k for k in kwlist if len(k) < 5) # ① if TYPE_CHECKING: reveal_type(short_kw) # ② long_kw: Iterator[str] = (k for k in kwlist if len(k) >= 4) # ③ if TYPE_CHECKING: # ④ reveal_type(long_kw)
①
生成器表达式,产生长度小于5个字符的 Python 关键字。
②
Mypy 推断:typing.Generator[builtins.str*, None, None]。¹¹
③
这也产生字符串,但我添加了一个明确的类型提示。
④
显式类型:typing.Iterator[builtins.str]。
abc.Iterator[str] 与abc.Generator[str, None, None] 一致,因此 Mypy 在示例 17-36 的类型检查中不会发出错误。
Iterator[T] 是 Generator[T, None, None] 的快捷方式。这两个注释都表示“生成器产生类型为 T 的项目,但不消耗或返回值。” 能够消耗和返回值的生成器是协程,我们下一个主题。
经典协程
注意
PEP 342—通过增强生成器实现协程 引入了 .send() 和其他功能,使得可以将生成器用作协程。PEP 342 使用的“协程”一词与我在此处使用的含义相同。
不幸的是,Python 的官方文档和标准库现在使用不一致的术语来指代用作协程的生成器,迫使我采用“经典协程”限定词以与较新的“本机协程”对象形成对比。
Python 3.5 之后,使用“协程”作为“本机协程”的同义词成为趋势。但 PEP 342 并未被弃用,经典协程仍按最初设计的方式工作,尽管它们不再受 asyncio 支持。
在 Python 中理解经典协程很令人困惑,因为它们实际上是以不同方式使用的生成器。因此,让我们退一步考虑 Python 中另一个可以以两种方式使用的特性。
我们在 “元组不仅仅是不可变列表” 中看到,我们可以将 tuple 实例用作记录或不可变序列。当用作记录时,预期元组具有特定数量的项目,并且每个项目可能具有不同的类型。当用作不可变列表时,元组可以具有任意长度,并且所有项目都预期具有相同的类型。这就是为什么有两种不同的方式使用类型提示注释元组的原因:
# A city record with name, country, and population: city: tuple[str, str, int] # An immutable sequence of domain names: domains: tuple[str, ...]
与生成器类似的情况也发生在生成器上。它们通常用作迭代器,但也可以用作协程。协程 实际上是一个生成器函数,在其主体中使用 yield 关键字创建。协程对象 在物理上是一个生成器对象。尽管在 C 中共享相同的底层实现,但在 Python 中生成器和协程的用例是如此不同,以至于有两种方式对它们进行类型提示:
# The `readings` variable can be bound to an iterator # or generator object that yields `float` items: readings: Iterator[float] # The `sim_taxi` variable can be bound to a coroutine # representing a taxi cab in a discrete event simulation. # It yields events, receives `float` timestamps, and returns # the number of trips made during the simulation: sim_taxi: Generator[Event, float, int]
使人困惑的是,typing 模块的作者决定将该类型命名为 Generator,而实际上它描述了旨在用作协程的生成器对象的 API,而生成器更常用作简单的迭代器。
typing 文档描述了 Generator 的形式类型参数如下:
Generator[YieldType, SendType, ReturnType] 
         当生成器用作协程时,SendType 才相关。该类型参数是调用 gen.send(x) 中的 x 的类型。在对被编码为迭代器而不是协程的生成器调用 .send() 是错误的。同样,ReturnType 仅对协程进行注释有意义,因为迭代器不像常规函数那样返回值。将生成器用作迭代器的唯一明智操作是直接或间接通过 for 循环和其他形式的迭代调用 next(it)。YieldType 是调用 next(it) 返回的值的类型。
Generator 类型具有与 typing.Coroutine 相同的类型参数:
Coroutine[YieldType, SendType, ReturnType] 
         typing.Coroutine 文档实际上说:“类型变量的方差和顺序与 Generator 相对应。”但 typing.Coroutine(已弃用)和 collections.abc.Coroutine(自 Python 3.9 起为通用)旨在仅注释本机协程,而不是经典协程。如果要在经典协程中使用类型提示,您将遭受将它们注释为 Generator[YieldType, SendType, ReturnType] 的困惑。
David Beazley 创建了一些关于经典协程的最佳演讲和最全面的研讨会。在他的 PyCon 2009 课程手册 中,有一张幻灯片标题为“Keeping It Straight”,内容如下:
- 生成器产生用于迭代的数据
- 协程是数据的消费者
- 为了避免大脑爆炸,请不要混淆这两个概念。
- 协程与迭代无关。
- 注意:在协程中使用
yield产生一个值是有用的,但它与迭代无关。¹²
现在让我们看看经典协程是如何工作的。
示例:计算移动平均值的协程
在讨论闭包时,我们在第九章中研究了用于计算移动平均值的对象。示例 9-7 展示了一个类,而示例 9-13 则展示了一个返回函数的高阶函数,该函数在闭包中跨调用保留total和count变量。示例 17-37 展示了如何使用协程实现相同的功能。¹³
示例 17-37. coroaverager.py:计算移动平均值的协程。
from collections.abc import Generator def averager() -> Generator[float, float, None]: # ① total = 0.0 count = 0 average = 0.0 while True: # ② term = yield average # ③ total += term count += 1 average = total/count
①
此函数返回一个生成器,产生float值,通过.send()接受float值,并不返回有用的值。¹⁴
②
这个无限循环意味着只要客户端代码发送值,协程就会继续产生平均值。
③
这里的yield语句暂停协程,向客户端产生一个结果,然后—稍后—接收调用者发送给协程的值,开始无限循环的另一个迭代。
在协程中,total和count可以是局部变量:不需要实例属性或闭包来在协程在等待下一个.send()时保持上下文。这就是为什么协程在异步编程中是回调的有吸引力替代品——它们在激活之间保持本地状态。
示例 17-38 运行 doctests 以展示averager协程的运行情况。
示例 17-38. coroaverager.py:运行平均值协程的 doctest,参见示例 17-37。
>>> coro_avg = averager() # ① >>> next(coro_avg) # ② 0.0 >>> coro_avg.send(10) # ③ 10.0 >>> coro_avg.send(30) 20.0 >>> coro_avg.send(5) 15.0
①
创建协程对象。
②
启动协程。这会产生average的初始值:0.0。
③
现在我们可以开始了:每次调用.send()都会产生当前的平均值。
在示例 17-38 中,调用next(coro_avg)使协程前进到yield,产生average的初始值。您也可以通过调用coro_avg.send(None)来启动协程——这实际上就是next()内置函数的作用。但是您不能发送除None之外的任何值,因为协程只能在yield行处暂停时接受发送的值。调用next()或.send(None)以前进到第一个yield的操作称为“激活协程”。
每次激活后,协程都会在yield关键字处精确地暂停,等待发送值。coro_avg.send(10)这一行提供了该值,导致协程激活。yield表达式解析为值 10,并将其赋给term变量。循环的其余部分更新total、count和average变量。while循环中的下一次迭代会产生average,协程再次在yield关键字处暂停。
细心的读者可能急于知道如何终止 averager 实例(例如 coro_avg)的执行,因为它的主体是一个无限循环。通常我们不需要终止生成器,因为一旦没有更多有效引用,它就会被垃圾回收。如果需要显式终止它,请使用 .close() 方法,如 示例 17-39 中所示。
示例 17-39. coroaverager.py:从 示例 17-38 继续
>>> coro_avg.send(20) # ① 16.25 >>> coro_avg.close() # ② >>> coro_avg.close() # ③ >>> coro_avg.send(5) # ④ Traceback (most recent call last): ... StopIteration
①
coro_avg 是在 示例 17-38 中创建的实例。
②
.close() 方法在挂起的 yield 表达式处引发 GeneratorExit。如果在协程函数中未处理,异常将终止它。GeneratorExit 被包装协程的生成器对象捕获,这就是我们看不到它的原因。
③
对先前关闭的协程调用 .close() 没有任何效果。
④
尝试在已关闭的协程上使用 .send() 会引发 StopIteration。
除了 .send() 方法,PEP 342—通过增强生成器实现协程 还介绍了一种协程返回值的方法。下一节将展示如何实现。
从协程返回一个值
现在我们将学习另一个用于计算平均值的协程。这个版本不会产生部分结果,而是返回一个包含项数和平均值的元组。我将列表分成两部分:示例 17-40 和 示例 17-41。
示例 17-40. coroaverager2.py:文件顶部
from collections.abc import Generator from typing import Union, NamedTuple class Result(NamedTuple): # ① count: int # type: ignore # ② average: float class Sentinel: # ③ def __repr__(self): return f'<Sentinel>' STOP = Sentinel() # ④ SendType = Union[float, Sentinel] # ⑤
①
示例 17-41 中的 averager2 协程将返回一个 Result 实例。
②
Result 实际上是 tuple 的一个子类,它有一个我不需要的 .count() 方法。# type: ignore 注释防止 Mypy 抱怨有一个 count 字段。¹⁵
③
一个用于创建具有可读 __repr__ 的哨兵值的类。
④
我将使用的哨兵值来使协程停止收集数据并返回结果。
⑤
我将用这个类型别名作为协程 Generator 返回类型的第二个类型参数,即 SendType 参数。
SendType 定义在 Python 3.10 中也有效,但如果不需要支持早期版本,最好在导入 typing 后像这样写:
SendType: TypeAlias = float | Sentinel 
         使用 | 而不是 typing.Union 如此简洁易读,以至于我可能不会创建该类型别名,而是会像这样编写 averager2 的签名:
def averager2(verbose: bool=False) -> Generator[None, float | Sentinel, Result]: 
         现在,让我们研究协程代码本身(示例 17-41)。
示例 17-41. coroaverager2.py:返回结果值的协程
def averager2(verbose: bool = False) -> Generator[None, SendType, Result]: # ① total = 0.0 count = 0 average = 0.0 while True: term = yield # ② if verbose: print('received:', term) if isinstance(term, Sentinel): # ③ break total += term # ④ count += 1 average = total / count return Result(count, average) # ⑤
①
对于这个协程,yield 类型是 None,因为它不产生数据。它接收 SendType 的数据,并在完成时返回一个 Result 元组。
②
像这样使用yield只在协程中有意义,它们被设计用来消耗数据。这里产生None,但从.send(term)接收一个term。
③
如果term是一个Sentinel,就从循环中退出。多亏了这个isinstance检查…
④
…Mypy 允许我将term添加到total中,而不会出现错误,即我无法将float添加到可能是float或Sentinel的对象中。
⑤
只有当Sentinel被发送到协程时,这行才会被执行。
现在让我们看看如何使用这个协程,从一个简单的例子开始,实际上并不产生结果(示例 17-42)。
示例 17-42. coroaverager2.py:展示.cancel()
 
    >>> coro_avg = averager2() >>> next(coro_avg) >>> coro_avg.send(10) # ① >>> coro_avg.send(30) >>> coro_avg.send(6.5) >>> coro_avg.close() # ②
①
请记住averager2不会产生部分结果。它产生None,Python 控制台会忽略它。
②
在这个协程中调用.close()会使其停止,但不会返回结果,因为在协程的yield行引发了GeneratorExit异常,所以return语句永远不会被执行。
现在让我们在示例 17-43 中使其工作。
示例 17-43. coroaverager2.py:展示带有Result的StopIteration的 doctest
 
    >>> coro_avg = averager2() >>> next(coro_avg) >>> coro_avg.send(10) >>> coro_avg.send(30) >>> coro_avg.send(6.5) >>> try: ... coro_avg.send(STOP) # ① ... except StopIteration as exc: ... result = exc.value # ② ... >>> result # ③ Result(count=3, average=15.5)
①
发送STOP标记使协程退出循环并返回一个Result。包装协程的生成器对象然后引发StopIteration。
②
StopIteration实例有一个value属性,绑定到终止协程的return语句的值。
③
信不信由你!
将返回值“偷运”出协程并包装在StopIteration异常中的这个想法是一个奇怪的技巧。尽管如此,这个奇怪的技巧是PEP 342—通过增强生成器实现协程的一部分,并且在StopIteration异常和Python 语言参考第六章的“Yield 表达式”部分有记录。
一个委托生成器可以直接使用yield from语法获取协程的返回值,如示例 17-44 所示。
示例 17-44. coroaverager2.py:展示带有Result的StopIteration的 doctest
 
    >>> def compute(): ... res = yield from averager2(True) # ① ... print('computed:', res) # ② ... return res # ③ ... >>> comp = compute() # ④ >>> for v in [None, 10, 20, 30, STOP]: # ⑤ ... try: ... comp.send(v) # ⑥ ... except StopIteration as exc: # ⑦ ... result = exc.value received: 10 received: 20 received: 30 received: <Sentinel> computed: Result(count=3, average=20.0) >>> result # ⑧ Result(count=3, average=20.0)
①
res将收集averager2的返回值;yield from机制在处理标记协程终止的StopIteration异常时检索返回值。当True时,verbose参数使协程打印接收到的值,以便使其操作可见。
②
当这个生成器运行时,请留意这行的输出。
③
返回结果。这也将被包装在StopIteration中。
④
创建委托协程对象。
⑤
这个循环将驱动委托协程。
⑥
第一个发送的值是None,用于启动协程;最后一个是停止它的标志。
⑦
捕获StopIteration以获取compute的返回值。
⑧
在averager2和compute输出的行之后,我们得到Result实例。
尽管这里的示例并没有做太多事情,但代码很难理解。使用.send()调用驱动协程并检索结果是复杂的,除非使用yield from—但我们只能在委托生成器/协程内部使用该语法,最终必须由一些非平凡的代码驱动,如示例 17-44 所示。
前面的示例表明直接使用协程是繁琐和令人困惑的。添加异常处理和协程.throw()方法,示例变得更加复杂。我不会在本书中涵盖.throw(),因为—就像.send()一样—它只对手动驱动协程有用,但我不建议这样做,除非你正在从头开始创建一个基于协程的新框架。
注意
如果您对经典协程有更深入的了解—包括.throw()方法—请查看fluentpython.com伴随网站上的“经典协程”。该文章包括类似 Python 的伪代码,详细说明了yield from如何驱动生成器和协程,以及一个小的离散事件模拟,演示了在没有异步编程框架的情况下使用协程实现并发的形式。
在实践中,与协程一起进行有效的工作需要专门框架的支持。这就是 Python 3.3 中asyncio为经典协程提供的支持。随着 Python 3.5 中本地协程的出现,Python 核心开发人员正在逐渐淘汰asyncio中对经典协程的支持。但底层机制非常相似。async def语法使本地协程在代码中更容易识别,这是一个很大的好处。在内部,本地协程使用await而不是yield from来委托给其他协程。第二十一章就是关于这个的。
现在让我们用一个关于协变和逆变的类型提示对协程进行总结。
经典协程的通用类型提示
回到“逆变类型”,我提到typing.Generator是少数几个具有逆变类型参数的标准库类型之一。现在我们已经学习了经典协程,我们准备理解这种通用类型。
这是typing.Generator在 Python 3.6 的typing.py模块中是如何声明的的:¹⁶
T_co = TypeVar('T_co', covariant=True) V_co = TypeVar('V_co', covariant=True) T_contra = TypeVar('T_contra', contravariant=True) # many lines omitted class Generator(Iterator[T_co], Generic[T_co, T_contra, V_co], extra=_G_base):
通用类型声明意味着Generator类型提示需要我们之前看到的那三个类型参数:
my_coro : Generator[YieldType, SendType, ReturnType] 
         从形式参数中的类型变量中,我们看到YieldType和ReturnType是协变的,但SendType是逆变的。要理解原因,考虑到YieldType和ReturnType是“输出”类型。两者描述了从协程对象—即作为协程对象使用时的生成器对象—输出的数据。
这是合理的,因为任何期望一个产生浮点数的协程的代码可以使用一个产生整数的协程。这就是为什么Generator在其YieldType参数上是协变的。相同的推理也适用于ReturnType参数—也是协变的。
使用在“协变类型”中介绍的符号,第一个和第三个参数的协变性由指向相同方向的:>符号表示:
float :> int Generator[float, Any, float] :> Generator[int, Any, int]
YieldType和ReturnType是“方差法则的基本原则”的第一个规则的例子:
- 如果一个形式类型参数定义了对象中出来的数据的类型,它可以是协变的。
另一方面,SendType是一个“输入”参数:它是协程对象的.send(value)方法的value参数的类型。需要向协程发送浮点数的客户端代码不能使用具有int作为SendType的协程,因为float不是int的子类型。换句话说,float不与int一致。但客户端可以使用具有complex作为SendType的协程,因为float是complex的子类型,因此float与complex一致。
:>符号使得第二个参数的逆变性可见:
float :> int Generator[Any, float, Any] <: Generator[Any, int, Any]
这是第二个方差法则的一个例子:
- 如果一个形式类型参数定义了对象在初始构造之后进入的数据的类型,它可以是逆变的。
这个关于方差的欢快讨论完成了本书中最长的章节。
章节总结
迭代在语言中是如此深入,以至于我喜欢说 Python 理解迭代器。[¹⁷] 在 Python 语义中集成迭代器模式是设计模式在所有编程语言中并非都适用的一个主要例子。在 Python 中,一个经典的手动实现的迭代器,如示例 17-4,除了作为教学示例外,没有实际用途。
在本章中,我们构建了几个版本的一个类,用于迭代可能非常长的文本文件中的单词。我们看到 Python 如何使用iter()内置函数从类似序列的对象创建迭代器。我们构建了一个经典的迭代器作为一个带有__next__()的类,然后我们使用生成器使得Sentence类的每次重构更加简洁和可读。
然后我们编写了一个算术级数的生成器,并展示了如何利用itertools模块使其更简单。随后是标准库中大多数通用生成器函数的概述。
然后我们在简单生成器的上下文中研究了yield from表达式,使用了chain和tree示例。
最后一个主要部分是关于经典协程的,这是在 Python 3.5 中添加原生协程后逐渐失去重要性的一个主题。尽管在实践中难以使用,经典协程是原生协程的基础,而yield from表达式是await的直接前身。
还涵盖了Iterable、Iterator和Generator类型的类型提示—其中后者提供了一个具体且罕见的逆变类型参数的例子。
进一步阅读
生成器的详细技术解释出现在Python 语言参考中的“6.2.9. Yield expressions”。定义生成器函数的 PEP 是PEP 255—Simple Generators。
由于包含了所有示例,itertools模块文档非常出色。尽管该模块中的函数是用 C 实现的,但文档展示了如何用 Python 编写其中一些函数,通常是通过利用模块中的其他函数。用法示例也很棒;例如,有一个片段展示如何使用accumulate函数根据时间给定的付款列表摊销贷款利息。还有一个“Itertools Recipes”部分,其中包含使用itertools函数作为构建块的其他高性能函数。
除了 Python 标准库之外,我推荐使用More Itertools包,它遵循了itertools传统,提供了强大的生成器,并附带大量示例和一些有用的技巧。
David Beazley 和 Brian K. Jones(O’Reilly)合著的第三版Python Cookbook的第四章“迭代器和生成器”涵盖了这个主题的 16 个配方,从许多不同角度着重于实际应用。其中包括一些使用yield from的启发性配方。
Sebastian Rittau,目前是typeshed的顶级贡献者,解释了为什么迭代器应该是可迭代的,正如他在 2006 年指出的那样,“Java:迭代器不可迭代”。
“Python 3.3 中的新功能”部分在PEP 380—委托给子生成器的语法中用示例解释了yield from语法。我在fluentpython.com上的文章“经典协程”深入解释了yield from,包括其在 C 中实现的 Python 伪代码。
David Beazley 是 Python 生成器和协程的最高权威。他与 Brian Jones 合著的第三版*Python Cookbook*(O’Reilly)中有许多关于协程的示例。Beazley 在 PyCon 上关于这个主题的教程以其深度和广度而闻名。第一个是在 PyCon US 2008 上的“系统程序员的生成器技巧”。PyCon US 2009 看到了传奇的“协程和并发的奇特课程”(所有三部分的难以找到的视频链接:part 1,part 2和part 3)。他在 2014 年蒙特利尔 PyCon 的教程是“生成器:最终前沿”,其中他处理了更多并发示例,因此实际上更多关于第二十一章中的主题。Dave 无法抵制在他的课堂上让大脑爆炸,因此在“最终前沿”的最后部分,协程取代了经典的访问者模式在算术表达式求值器中。
协程允许以新的方式组织代码,就像递归或多态(动态分派)一样,需要一些时间来适应它们的可能性。一个有趣的经典算法被用协程重写的例子在 James Powell 的文章“使用协程的贪婪算法”中。
Brett Slatkin 的Effective Python,第 1 版(Addison-Wesley)有一章标题为“考虑使用协程并发运行多个函数”的精彩短章。该章节不在Effective Python的第二版中,但仍然可以作为在线示例章节获得。Slatkin 提供了我见过的最好的使用yield from驱动协程的示例:约翰·康威的生命游戏的实现,其中协程管理游戏运行时每个单元格的状态。我重构了生命游戏示例的代码——将实现游戏的函数和类与 Slatkin 原始代码中使用的测试片段分开。我还将测试重写为文档测试,这样您就可以查看各个协程和类的输出而无需运行脚本。重构后的示例发布在GitHub gist上。
¹ 来自“书呆子的复仇”,一篇博客文章。
² 我们首次在“向量 Take #1:Vector2d 兼容”中使用了reprlib。
³ 感谢技术审阅员 Leonardo Rochael 提供这个很好的例子。
⁴ 在审查这段代码时,Alex Martelli 建议这个方法的主体可以简单地是return iter(self.words)。他是对的:调用self.words.__iter__()的结果也将是一个迭代器,正如应该的那样。然而,在这里我使用了一个带有yield的for循环来介绍生成器函数的语法,这需要使用yield关键字,我们将在下一节中看到。在审查本书第二版时,Leonardo Rochael 建议__iter__的主体还有另一个快捷方式:yield from self.words。我们稍后也会介绍yield from。
⁵ 有时在命名生成器函数时我会添加gen前缀或后缀,但这不是一种常见做法。当然,如果您正在实现一个可迭代对象,那么您不能这样做:必需的特殊方法必须命名为__iter__。
⁶ 感谢 David Kwast 提出这个例子。
⁷ 在 Python 2 中,有一个名为coerce()的内置函数,但在 Python 3 中已经消失了。这被认为是不必要的,因为数值强制转换规则在算术运算符方法中是隐含的。因此,我能想到的将初始值强制转换为与系列其余部分相同类型的最佳方法是执行加法并使用其类型来转换结果。我在 Python-list 中询问了这个问题,并从 Steven D’Aprano 那里得到了一个很好的回答。
⁸ 流畅的 Python代码库中的17-it-generator/目录包含了文档测试和一个名为aritprog_runner.py的脚本,该脚本针对aritprog.py*脚本的所有变体运行测试。
⁹ 这里,“映射”一词与字典无关,而是与map内置函数有关。
¹⁰ chain和大多数itertools函数是用 C 编写的。
¹¹ 截至版本 0.910,Mypy 仍在使用已弃用的typing类型。
¹² “关于协程和并发的一门奇特课程”中的幻灯片 33,“保持直线”。
¹³ 这个例子受到 Python-ideas 列表中 Jacob Holm 的一段代码片段的启发,标题为“Yield-From: Finalization guarantees”。稍后的线程中出现了一些变体,Holm 在消息 003912中进一步解释了他的想法。
¹⁴ 实际上,除非某个异常中断循环,否则它永远不会返回。Mypy 0.910 接受 None 和 typing.NoReturn 作为生成器返回类型参数,但它还接受 str 在该位置,因此显然它目前无法完全分析协程代码。
¹⁵ 我考虑过更改字段的名称,但 count 是协程中局部变量的最佳名称,并且在书中的类似示例中我也使用了这个变量的名称,因此在 Result 字段中使用相同的名称是有道理的。我毫不犹豫地使用 # type: ignore 来避免静态类型检查器的限制和烦恼,当提交到工具时会使代码变得更糟或不必要复杂时。
¹⁶ 自 Python 3.7 起,typing.Generator 和其他与 collections.abc 中的 ABCs 对应的类型被重构,使用了对应 ABC 的包装器,因此它们的泛型参数在 typing.py 源文件中不可见。这就是为什么我在这里引用 Python 3.6 源代码的原因。
¹⁷ 根据Jargon 文件,grok 不仅仅是学习某事,而是吸收它,使其“成为你的一部分,成为你身份的一部分”。
¹⁸ Gamma 等人,《设计模式:可复用面向对象软件的元素》,第 261 页。
¹⁹ 代码是用 Python 2 编写的,因为其中一个可选依赖项是名为 Bruma 的 Java 库,我们可以在使用 Jython 运行脚本时导入它——而 Jython 尚不支持 Python 3。
²⁰ 用于读取复杂的 .mst 二进制文件的库实际上是用 Java 编写的,因此只有在使用 Jython 解释器(版本为 2.5 或更新版本)执行 isis2json.py 时才能使用此功能。有关更多详细信息,请参阅存储库中的 README.rst 文件。依赖项是在需要它们的生成器函数内导入的,因此即使只有一个外部库可用,脚本也可以运行。
