From Generator to Coroutine

Posted on | 3922 words | ~8 mins
Python

现代编程语言标准库中使用接口、抽象类和具体类来组成容器和迭代体系,Python也不例外。本文从Python容器和迭代的Type Hints入手,引出生成器Generator,最后介绍“听上去与迭代毫无关联”的协程Coroutine是怎么变成生成器Generator的“儿子”。

Type HintsPython的容器和迭代设计

Python是动态类型语言,本身没有严格的类型和接口机制。更多的时候是duck type:对象身上有使用者需要的方法,那它就是使用者的那只“鸭子”。但在运行时,使用者突然发现手里的货能像鸭子叫,能像鸭子走,但是不能下水,是个旱鸭子,就直接崩掉了。为了避免使用者和制造者对“鸭子”定义不一致引起的麻烦,Python通过PEP 484引入Type Hints,方便运行前进行各种更严谨的“确认鸭子”流程,确保运行时“鸭子”不会变成“旱鸭子”。

容器和迭代是任何编程语言标准库的重要部分。Pythontyping模块(3.9版本开始,该部分类型转移到collections.abc模块)中支持了所有抽象容器类。例如:Iterable是有__iter__方法的基类,基本是可以被遍历的容器类都应该是Iterable,比如:ListDict等。__iter__方法返回一个迭代器IteratorIterator是包含__next____iter__方法,负责具体迭代策略的对象。理论上对Iterable.__iter__的每次调用都应该返回全新的迭代器Iterator,启动一个重新开始的遍历;Iterator本身也有__iter__函数,但返回自身,即一个已经被使用过(或正在被使用)的迭代器对象实例。

 1my_iterable = [3, 2, 1]
 2
 3my_iter_1 = iter(my_iterable)
 4print(next(my_iter_1))  # 输出3
 5
 6my_iter_2 = iter(my_iterable)
 7print(next(my_iter_2))  # 输出3
 8
 9my_iter_3 = iter(my_iter_2)
10print(next(my_iter_3))  # 输出2

观察可知,两次在my_iterable上执行iter会生成两个不相干的新的Iterator,所以在每个my_iter上执行next的输出都是初始值3;而在已经执行过一次next的迭代器(my_iter_2)上获取Iterator迭代器(执行Iterator.__iter__),会得到当前迭代器自身(my_iter_2),执行next会得到2(my_iter_2的下一个迭代值)。IterableIterator的实现通常如下:

 1from typing import Iterable, Iterator
 2
 3
 4class MyIterator(Iterator[int]):
 5    def __init__(self, n: int) -> None:
 6        super().__init__()
 7        self._i = n
 8
 9    def __iter__(self) -> Iterator[int]:
10        return self
11
12    def __next__(self) -> int:
13        if self._i == 0:
14            raise StopIteration()
15        self._i -= 1
16        return self._i
17
18
19class MyIterable(Iterable[int]):
20    def __init__(self, n: int) -> None:
21        super().__init__()
22        self._n = n
23
24    def __iter__(self) -> Iterator[int]:
25        return MyIterator(self._n)

除了Iterable能生成Iterator之外,Python还有另一个迭代生成器Reversible,会返回一个逆序的Iterator。下图展示了容器和迭代体系大部分内容:

classDiagram Iterable <|-- Iterator Iterable <|-- Reversible Container <|-- Collection Sized <|-- Collection Iterable <|-- Collection Collection <|-- Set Set <|-- MutableSet Collection <|-- Mapping Mapping <|-- MutableMapping Collection <|-- Sequence Reversible <|-- Sequence Sequence <|-- MutableSequence Iterator <|-- Generator class Container{ +__contains__() } class Iterable{ +__iter__() } class Reversible{ +__reversed__() } class Iterator{ +__next__() +__iter__() } class Generator{ +send() +throw() +close() +__iter__() +__next__() } class Sized{ +__len__() } class Sequence{ +__getitem__() +__getitem__() +__len__() +__contains__() +__reversed__() +index() +count() } class MutableSequence{ +__setitem__() +__delitem__() +insert() +append() +reverse() +extend() +pop() +remove() +__iadd__() } class Set{ +__contains__() +__iter__() +__len__() +__le__() +__lt__() +__eq__() +__ne__() +__gt__() +__ge__() +__and__() +__or__() +__sub__() +__xor__() +isdisjoint() } class MutableSet{ +discard() +clear() +pop() +remove() +__ior__() +__iand__() +__ixor__() +__isub__() } class Mapping{ +__contains__() +__iter__() +__len__() +__contains__() +keys() +items() +values() +get() +__eq__() +__ne__() } class MutableMapping{ +__setitem__() +__delitem__() +pop() +popitem() +clear() +update() +setdefault() }

Generator

Generator是一个“方法”,它的返回值是一个Iterator(官方文档称为Generator Iterator)。Generator很像__iter__方法:调用后,不会立刻返回结果,而是返回一个迭代器;在迭代器上执行next才会返回一个结果。

 1def generator():
 2    yield 3
 3    yield 2
 4    yield 1
 5
 6
 7my_iter_4 = generator()
 8print(my_iter_4)  # <generator object generator at 0x7f88427bbba0>
 9print(next(my_iter_4))  # 输出3
10
11my_iter_5 = generator()
12print(next(my_iter_5))  # 输出3
13
14my_iter_6 = iter(my_iter_5)
15print(next(my_iter_6))  # 输出2

执行以上结果,会发现my_iter_4my_iter_5my_iter_6的行为和前一节中Iterator完全一致。

Python还提供yield from关键字,支持嵌套生成器(或者叫子生成器)

 1def child_gen():
 2    yield 3
 3    yield 2
 4
 5
 6def parent_gen_1():   # 没有yield from之前子生成器嵌套使用
 7    for n in child_gen():
 8        yield n
 9
10
11def parent_gen_2():   # 有了yield from之后
12    yield from child_gen()

Generator不止是单向返回结果,也支持从调用端接收输入(包含异常和结束信号)

 1def full_gen(a):
 2    b = yield a
 3    c = yield a + b
 4    yield a + b + c
 5
 6
 7g = full_gen(1)
 8print(next(g))  # 激活迭代器到yield a这句,得到返回值1
 9print(g.send(2))   # 传递b = 2,得到yield a + b = 1 + 2 = 3
10print(g.send(3))   # 传递c = 3,得到yield c + a + b = 1 + 2 + 3 = 6
11print(g.send(4))   # 没有更多的yield语句,触发StopIteration

send可以替换next,获得下一个yield右侧表达式值的同时,将一个值赋给yield左侧变量。throwclose则提供向Generator发送异常和终止信号的能力:

 1def autostart(func):
 2    def start(*args, **kwargs):
 3        g = func(*args, **kwargs)
 4        g.send(None)
 5        return g
 6    return start
 7
 8
 9@autostart
10def full_gen():
11    try:
12        a = yield  # yield右侧没有表达式返回给调用者,只负责从调用端接收数据,这让yield很像`input()`
13        print(a)
14        b = yield
15        print(b)
16    except ValueError as e:
17        print(str(e))
18    except GeneratorExit:
19        print("close")

为了省去每次显式调用send(None)去激活生成器,可以创建一个decoratorautostart)创建Generator之后立刻调用send(None)推动脚本执行到第一个yield

1g_1 = full_gen()
2g_1.send(1)  # 输出1
3g_1.throw(ValueError(2))  # 在`Generator`内部抛出ValueError
4
5g_2 = full_gen()
6g_2.send(2)  # 输出2
7g_2.close()  # 触发`Generator`内部抛出GeneratorExit异常结束

我们把sendthrowclose放在一起思考,会发现有了这几个操作之后,Generator生成器从一个数据生产者摇身一变,成了数据消费者,整个范式颠倒了!这句话有些复杂,让我们通过代码来解释一下:

 1def produce():
 2    yield 3
 3    yield 2
 4    raise ValueError(1)
 5
 6
 7def consume(n):
 8    print(n)
 9
10
11producer = produce()
12for i in producer:
13    consume(i)

这是Generator作为生产者时的流程:

  • 通过yield将数据生产出来(yield右侧表达式)并返回给主流程
  • 主流程通过for循环读取生产出的数据并消费
  • 生产者遇到异常中断或者无法生成更多数据时,主流程终止
 1def consume():
 2    while True:
 3        n = yield
 4        print(n)
 5
 6
 7consumer = consume()
 8consumer.send(3)
 9consumer.send(2)
10consumer.throw(ValueError(1))  # or consumer.close()

这是Generator作为消费者时的流程:

  • 通过yield(左侧表达式)从主流程获取数据
  • 主流程通过send生成数据,并向消费者发送
  • 主流程无法生产更多数据或者异常中断,导致消费者一起结束

同样的一个概念可以有两种相反的模式完成同样的功能!是好事还是坏事?

两种相反的模式是指:

  1. Generator当生产者的时候,消费者通过for循环用“拉”模式处理数据
  2. Generator当消费者的时候,消费者通过Reactn = yield)“推”模式等待数据到达

Coroutine

其它一些语言用asyncawait来声明协程(包括Python 3.3+),我们将这种Native CoroutineGenerator做个对比,看看为什么Python要通过改造Generator来实现Coroutine

async/await版本:

 1import asyncio
 2
 3
 4async def produce():
 5    await asyncio.sleep(1)
 6    return "Hello"
 7
 8
 9async def consume():
10    r = await produce()
11    print(r)
12
13
14f_1 = consume()
15print(f_1)  # 输出<coroutine object consume at 0x7fb962975140>
16asyncio.run(f_1)  # 输出Hello

Generator版本

 1def produce():
 2    sleep(1)
 3    yield "Hello"
 4
 5
 6def consume():
 7    while True:
 8        n = yield
 9        print(n)
10
11
12def run_until_complete():
13    p_1 = produce()
14    print(p_1)  # <generator object produce at 0x7f0c564bd660>
15    
16    c_1 = consume()
17    print(c_1)  # <generator object consume at 0x7f0c564eb820>
18    c_1.send(None)
19
20    while True:
21        try:
22            n = p_1.send(None)
23        except StopIteration:
24            c_1.close()
25            break
26        else:
27            c_1.send(n)
28
29
30run_until_complete()  # 输出Hello

的确有些像:

  • async函数被调用后,返回一个协程,而不会立刻执行;Generator函数调用后,返回一个generator,也不会立刻执行
  • async函数会被asyncio.run进行触发;Generator需要执行send(XXX)触发执行
  • await是在等待其它协程返回;Generatoryield是在等待调用方返回
  • async修饰的函数可以通过await放弃控制权给event loopGenerator可以通过yield放置控制权给调用方

事实上做一些有技巧的包装,我们完全可以用Generator实现async/await,请阅读A Curious Course on Coroutines and Concurrency中112页开始的介绍。

坦率的说,我认为Generator改出Coroutine这出“戏”,是Python最ugly的设计之一。一个语法yield (from)被用于两种场景,或者(在制造这个混乱的人眼里)GeneratorCoroutine在抽象世界里是协调的,是同一种场景应用(都是执行一段后,交出控制权)。通俗点比喻:信纸(Generator)本该用来写信(生成数据),可因为它是纸(主动放弃控制权),所以有人生活取暖(协程)必用信纸(Generator)。逻辑上没毛病,但我们真有必要用信纸去生火吗?

还好,Generator-based CoroutinesPython 3.10中被取消掉。从现在起我们要忘记yield from这个制造协程的怪物;请用async语法替代所有的asyncio.coroutine,用await语法替代所有的yield from。据说早期用Generator表达协程的原因是,Python中增加新关键字要比重用老关键字复杂很多。可到头来还是增加了asyncawait关键字,且支持这两个新关键字的原因

Current Python supports implementing coroutines via generators (PEP 342), further enhanced by the yield from syntax introduced in PEP 380. This approach has a number of shortcomings:

  • It is easy to confuse coroutines with regular generators, since they share the same syntax; this is especially true for new developers.
  • Whether or not a function is a coroutine is determined by a presence of yield or yield from statements in its body, which can lead to unobvious errors when such statements appear in or disappear from function body during refactoring.
  • Support for asynchronous calls is limited to expressions where yield is allowed syntactically, limiting the usefulness of syntactic features, such as with and for statements.

It is easy to confuse coroutines with regular generators, since they share the same syntax”,是不是啪啪打脸?

引用