现代编程语言标准库中使用接口、抽象类和具体类来组成容器和迭代体系,Python
也不例外。本文从Python
容器和迭代的Type Hints
入手,引出生成器Generator
,最后介绍“听上去与迭代毫无关联”的协程Coroutine
是怎么变成生成器Generator
的“儿子”。
从Type Hints
看Python
的容器和迭代设计
Python
是动态类型语言,本身没有严格的类型和接口机制。更多的时候是duck type
:对象身上有使用者需要的方法,那它就是使用者的那只“鸭子”。但在运行时,使用者突然发现手里的货能像鸭子叫,能像鸭子走,但是不能下水,是个旱鸭子,就直接崩掉了。为了避免使用者和制造者对“鸭子”定义不一致引起的麻烦,Python
通过PEP 484引入Type Hints
,方便运行前进行各种更严谨的“确认鸭子”流程,确保运行时“鸭子”不会变成“旱鸭子”。
容器和迭代是任何编程语言标准库的重要部分。Python
在typing
模块(3.9版本开始,该部分类型转移到collections.abc
模块)中支持了所有抽象容器类。例如:Iterable
是有__iter__
方法的基类,基本是可以被遍历的容器类都应该是Iterable
,比如:List
,Dict
等。__iter__
方法返回一个迭代器Iterator
。Iterator
是包含__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
的下一个迭代值)。Iterable
和Iterator
的实现通常如下:
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
。下图展示了容器和迭代体系大部分内容:
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_4
,my_iter_5
和my_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
左侧变量。throw
和close
则提供向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)
去激活生成器,可以创建一个decorator
(autostart
)创建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异常结束
我们把send
,throw
和close
放在一起思考,会发现有了这几个操作之后,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
生成数据,并向消费者发送 - 主流程无法生产更多数据或者异常中断,导致消费者一起结束
同样的一个概念可以有两种相反的模式完成同样的功能!是好事还是坏事?
两种相反的模式是指:
Generator
当生产者的时候,消费者通过for
循环用“拉”模式处理数据Generator
当消费者的时候,消费者通过React
(n = yield
)“推”模式等待数据到达
Coroutine
其它一些语言用async
和await
来声明协程(包括Python 3.3+
),我们将这种Native Coroutine
和Generator
做个对比,看看为什么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
是在等待其它协程返回;Generator
的yield
是在等待调用方返回async
修饰的函数可以通过await
放弃控制权给event loop
;Generator
可以通过yield
放置控制权给调用方
事实上做一些有技巧的包装,我们完全可以用Generator
实现async/await
,请阅读A Curious Course on Coroutines and Concurrency中112页开始的介绍。
坦率的说,我认为Generator
改出Coroutine
这出“戏”,是Python
最ugly的设计之一。一个语法yield (from)
被用于两种场景,或者(在制造这个混乱的人眼里)Generator
和Coroutine
在抽象世界里是协调的,是同一种场景应用(都是执行一段后,交出控制权)。通俗点比喻:信纸(Generator
)本该用来写信(生成数据),可因为它是纸(主动放弃控制权),所以有人生活取暖(协程)必用信纸(Generator
)。逻辑上没毛病,但我们真有必要用信纸去生火吗?
还好,Generator-based Coroutines
在Python 3.10
中被取消掉。从现在起我们要忘记yield from
这个制造协程的怪物;请用async
语法替代所有的asyncio.coroutine
,用await
语法替代所有的yield from
。据说早期用Generator
表达协程的原因是,向Python
中增加新关键字要比重用老关键字复杂很多。可到头来还是增加了async
和await
关键字,且支持这两个新关键字的原因:
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”,是不是啪啪打脸?
引用
- [1] PEP 342,增加
send
,close
,throw
等方法给Generator
以支持协程 - [2] PEP 380,增加
yield from
以实现await
效果,等待另一个协程结束 - [3] PEP 492,增加
async
和await
关键字 - [4] PEP 544,引入
typing.Protocol
支持Structural subtyping
- [5] A Curious Course on Coroutines and Concurrency