引言
这是《流畅的Python第二版》抢先版的读书笔记。Python版本暂时用的是python3.10。为了使开发更简单、快捷,本文使用了JupyterLab。
本章关注于Python在函数签名中的类型注解(Type Hints)。在Python中引入了显式的类型注解,可以为函数参数、返回值、变量等添加类型提示。主要目的在于帮助开发工具通过静态检查发现代码中的Bug。
新内容简介
全新内容。
由于静态类型系统的限制,PEP 484 引入了渐进类型系统(gradual type system)。
什么是渐进类型
PEP 484 引入了渐进类型系统。一个渐进类型系统:
- 是可选的 默认情况下,类型检查器不应该为没有类型注解的代码发出告警。当类型检查器不能决定对象的类型时,会假设它为
Any 类型。Any 类型被认为与所有其他类型兼容。 - 不在运行时捕获类型错误 类型注解由静态类型检查器使用,IDE会发出告警。但它们不会防止不一致的值传递到函数或在运行时赋值给变量。
- 不增强性能 类型注解提供的数据理论上允许在生成的字节码上优化,但这种优化未在任何Python运行时中实现。
渐进类型最有用的特征在于注解总是可选的。
在静态类型系统中,大多数类型约束很容易表达,但还有一些是笨重的、一些是难的,还有一小部分是不可能的。你很可能会编写一段优秀的Python代码,具有良好的测试覆盖率和测试通过率,但仍无法增加使类型检查器满意的类型注解。
类型注解在所有级别上都是可选的:你的整个包可以没有类型注解,当你引入该包到使用类型注解的模块时可以静默类型检查器,也可以增加特殊的评论来类型检查器忽略代码中特定的行。
渐进类型实战
由一个简单的函数开始,然后逐渐增加类型注解。 messages.py
def show_count(count, word):
if count == 1:
return f'1 {word}'
count_str = str(count) if count else 'no'
return f'{count_str} {word}s'
def show_count(count, word):
if count == 1:
return f'1 {word}'
count_str = str(count) if count else 'no'
return f'{count_str} {word}s'
show_count(99, 'bird')
'99 birds'
show_count(1, 'bird')
'1 bird'
show_count(0, 'bird')
'no birds'
由Mypy开始
在messages.py 上运行mypy 命令来开始类型检查:
! pip install mypy
Requirement already satisfied: mypy in d:\program_tool\py39\lib\site-packages (0.982)
Requirement already satisfied: typing-extensions>=3.10 in d:\program_tool\py39\lib\site-packages (from mypy) (3.10.0.2)
Requirement already satisfied: tomli>=1.1.0 in d:\program_tool\py39\lib\site-packages (from mypy) (2.0.1)
Requirement already satisfied: mypy-extensions>=0.4.3 in d:\program_tool\py39\lib\site-packages (from mypy) (0.4.3)
! mypy ./messages/no_hints/messages.py
Success: no issues found in 1 source file
此时Mypy没有发现任何问题。
如果一个函数签名没有任何注解,Mypy默认会忽略它。下面,增加pytest 单元测试,该代码在messages_test.py 中:
from pytest import mark
from messages import show_count
@mark.parametrize('qty, expected', [
(1, '1 part'),
(2, '2 parts'),
])
def test_show_count(qty, expected):
got = show_count(qty, 'part')
assert got == expected
def test_show_count_zero():
got = show_count(0, 'part')
assert got == 'no parts'
下面开始增加类型注解。
让Mypy更严格
命令行可选项--disallow-untyped-defs 使Mypy标记任何对其所有参数和返回值没有类型注解的函数定义。
使用--disallow-untyped-defs 在测试文件上产生三个error和一个note:
! mypy --disallow-untyped-defs ./messages/no_hints/messages_test.py
messages\no_hints\messages.py:1: error: Function is missing a type annotation
messages\no_hints\messages_test.py:9: error: Function is missing a type annotation
messages\no_hints\messages_test.py:13: error: Function is missing a return type annotation
messages\no_hints\messages_test.py:13: note: Use "-> None" if function does not return a value
Found 3 errors in 2 files (checked 1 source file)
对于渐进类型的第一步,我倾向于使用另一个可选项:--disallow-incomplete-defs 。初始时,它不会抱怨任何问题:
! mypy --disallow-incomplete-defs ./messages/no_hints/messages_test.py
Success: no issues found in 1 source file
接着,我可以仅为message.py 中的show_count 增加返回类型:
def show_count(count, word) -> str:
这足以让Mypy查看它了。使用前面同样的命令行去检查messages_test.py 会让Mypy再次查看messages.py :
! mypy --disallow-incomplete-defs ./messages/no_hints/messages_test.py
messages\no_hints\messages.py:1: error: Function is missing a type annotation for one or more arguments
Found 1 error in 1 file (checked 1 source file)
现在我们可以逐渐为函数增加类型注解,而不会得到我们没有标注的告警。下面是能满足Mypy的完整带注解签名:
def show_count(count: int, word: str) -> str:
! mypy --disallow-incomplete-defs ./messages/no_hints/messages_test.py
Success: no issues found in 1 source file
除了在命令行中使用可选项之外,还可以把你想要的可选项写到Mypy配置文件中。可以有全局设定和模块设定。 下面是一个简单的mypy.ini 例子:
[mypy]
python_version = 3.9
warn_unused_configs = True
disallow_incomplete_defs = True
默认的参数值
示例中的show_count 函数只用于(英文)常规名词。如果复数不能通过附加’s’来拼写,我们应该让用户提供复数形式,像这样:
>>> show_count(3, 'mouse', 'mice')
'3 mice'
让我们做一点"类型驱动开发"。首先,我们添加一个使用第三个参数的测试。不要忘记向测试函数中添加返回类型提示,否则Mypy将不会检查它。
def test_irregular() -> None:
got = show_count(2, 'child', 'children')
assert got == '2 children'
! mypy ./messages/hints_2/messages_test.py
messages\hints_2\messages_test.py:21: error: Too many arguments for "show_count"
Found 1 error in 1 file (checked 1 source file)
现在我们编辑show_count ,增加可选的plural 参数:
def show_count(count: int, singular: str, plural: str = '') -> str:
if count == 1:
return f'1 {singular}'
count_str = str(count) if count else 'no'
if not plural:
plural = singular + 's'
return f'{count_str} {plural}'
! mypy ./messages/hints_2/messages_test.py
Success: no issues found in 1 source file
现在Mypy就不会报错了。
使用None作为默认值
在上面,参数plural 被注解为str ,它的默认值为'' ,所以没有类型冲突。
这种方法不错,但在其他情况下,None 是更好的默认值。如果可选的参数需要一个可变类型,那么None 是唯一合理的默认值。
为了让None 作为plural 参数的默认值,新的签名变成:
from typing import Optional
def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:
我们拆开来看:
Optional[str] 意味着plural 可以为str 或None - 你必须显示地提供默认值
= None
如果没有为plural 赋予默认值,Python运行时会将它看成必传参数。记住:在运行时,忽略类型注解。
注意我们需要从typing 引入Optional 。在导入类型时,最好使用from typing import X 的语法来减少函数签名的长度。
Optional 并不是一个很好的名称,因为它不会使参数成为可选的。使参数成为可选的原因是为该参数分配一个默认值。Optional[str] 仅表示:此参数的类型可以是str 或None T类型。
类型被定义来支持操作
实际上,将受支持的操作集合看成一种类型的定义特征更有用。
比如,从可用的操作角度来看,在下面的函数中,x 的有效类型是什么?
def double(x):
return x * 2
x 参数类型可能是数值(int 、complex 、Fraction 和numpy.uint32 等),但也可能是一个序列(str 、tuple 、list 和array ),一个N维的numpy.array ,或其他实现或继承了__mul__ 方法能接受int 参数的类型。
然而,考虑下面注解的double 方法。先忽略返回类型,我们关注于参数类型:
from collections import abc
def double(x: abc.Sequence):
return x * 2
类型检查器会拒绝该代码。如果你告诉Mypy x 是类型abc.Sequence ,它会标记x * 2 为错误因为abc.Sequence 没有实现或继承__mul__ 方法。在运行时,该代码可以在具体的序列,比如str 、tuple 、list 、array 等,以及数值上正常运行,因为运行时忽略类型注解。但类型检查器只关心显示声明的,而abc.Sequence 没有__mul__ 。
这就是本小节的标题是“类型被定义来支持操作”的原因。Python运行时接收任意类型作为x 参数。x * 2 可能正常执行,或抛出TypeError (如果x 不支持该操作)。相比之下,Mypy会认为注解版本下doulbe 中的x * 2 是错误的,因为x 的类型abc.Sequence 不支持该操作。
在渐进类型系统中,我们有两种不同的观点相互作用:
- 鸭子类型(Duck typing) 这种观点被Smalltalk——首创的面向对象语言——以及Pyhton,JS,Ruby采纳。对象必须指定类型,但变量(包括参数)是无类型的。实际上,对象定义的类型不重要,重要的是它实际支持的操作。如果我们能调用
birdie.quack() ,那么birdie 在该上下文中是鸭子。根据定义,只有在运行时尝试对对象进行操作时才强制鸭子类型。这比名义类型更灵活,但代价是在运行时允许更多的错误。 - 名义类型(Nominal typing) 该观点被C++、Java和C#采纳,也被注解的Python支持。对象和变量都有类型。但对象只在运行时存在,然后类型检查器只关心由类型注解标注的变量(和参数)的源码。如果
Duck 是Bird 的子类,你可以分配一个Duck 实例到标记为Bird 的参数birdie 。但在函数体中,类型检查器认为调用birdie.quack() 是非法的,因为birdie 名义上是Bird ,而该类没有提供.quack() 方法。在运行时的实际参数是否为Duck 并不重要,因为名义类型化是静态强制的。类型检查器不会运行任何程序,只会读取源代码。这比鸭子类型更严格,鸭子类型可以早在构建pipeline时就可以捕获一些bug,甚至在IDE中输入代码时。
下面的示例故意同时对比鸭子类型和名义类型,以及静态类型检查和运行时行为。 birds.py :
class Bird:
pass
class Duck(Bird):
def quack(self):
print('Quack!')
def alert(birdie):
birdie.quack()
def alert_duck(birdie: Duck) -> None:
birdie.quack()
def alert_bird(birdie: Bird) -> None:
birdie.quack()
用Mypy进行类型检查,我们可以看到一个错误:
! mypy ./birds/birds.py
birds\birds.py:15: error: "Bird" has no attribute "quack"
Found 1 error in 1 file (checked 1 source file)
我们来分析源码,Mypy发现alert_bird 是有问题的:类型注解声明birdie 参数的类型为Bird ,但函数体调用的是birdie.quack() ——Bird 类没有这样的方法。
现在我们试着在daffy.py 中使用birds 模块: daffy.py :
from birds import *
daffy = Duck()
alert(daffy)
alert_duck(daffy)
alert_bird(daffy)
在daffy.py 上运行Mypy抛出同样的错误:
! mypy ./birds/daffy.py
birds\birds.py:15: error: "Bird" has no attribute "quack"
Found 1 error in 1 file (checked 1 source file)
但是Mypy认为daffy.py 本身并没有什么问题:这三个函数调用没问题。 现在,如果你运行daffy.py ,你会得到:
! python ./birds/daffy.py
Quack!
Quack!
Quack!
所有方法都没问题。 在运行时,Python不会关心声明类型。它只用鸭子类型。Mypy在alert_bird 上标记了一个错误,但通过daffy 在运行时调用它却没问题。 刚开始让很多Pythonistas惊讶:一个静态类型检查器有时会在我们知道可以执行的程序中发现错误。
然而,如果几个月后你的任务是扩展这个bird 的例子,你可能会感激Mypy。考虑一下这个woody.py 模块,它也使用了birds : woody.py :
from birds import *
woody = Bird()
alert(woody)
alert_duck(woody)
alert_bird(woody)
! mypy ./birds/woody.py
birds\birds.py:15: error: "Bird" has no attribute "quack"
birds\woody.py:5: error: Argument 1 to "alert_duck" has incompatible type "Bird"; expected "Duck"
Found 2 errors in 2 files (checked 1 source file)
Mypy发现了两个错误,第一个是在birds.py 中:alert_bird 内的birdie.quack() ,我们之前见过。 第二个是在woody.py 中:woody 是Bird 的实例,所以调用alert_duck(woody) 是无效的,因为该函数需要的是Duck 。每个Duck 都是Bird ,但并不是每个Bird 都是Duck 。
在运行时,woody.py 中没有一个成功执行的。
from birds.birds import *
woody = Bird()
alert(woody)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In [11], line 3
1 from birds.birds import *
2 woody = Bird()
----> 3 alert(woody)
File D:\workspace\fluent-python-2nd\08-def-type-hints\birds\birds.py:9, in alert(birdie)
8 def alert(birdie):
----> 9 birdie.quack()
AttributeError: 'Bird' object has no attribute 'quack'
alert_duck(woody)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In [13], line 1
----> 1 alert_duck(woody)
File D:\workspace\fluent-python-2nd\08-def-type-hints\birds\birds.py:12, in alert_duck(birdie)
11 def alert_duck(birdie: Duck) -> None:
---> 12 birdie.quack()
AttributeError: 'Bird' object has no attribute 'quack'
alert_bird(woody)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In [14], line 1
----> 1 alert_bird(woody)
File D:\workspace\fluent-python-2nd\08-def-type-hints\birds\birds.py:15, in alert_bird(birdie)
14 def alert_bird(birdie: Bird) -> None:
---> 15 birdie.quack()
AttributeError: 'Bird' object has no attribute 'quack'
这个小实验表明,鸭子类型更容易理解,也更灵活,但允许不受支持的操作在运行时导致错误。标称输入在运行前检测错误,但有时会拒绝实际运行的代码——例如上面示例中的调用allalert_bird(daffy) 。即使它有时可以工作,alert_bird 函数的命名也是错误的:它的主体确实需要一个支持.quack() 方法的对象,而Bird 没有这个方法。
在这个例子中,函数是单行的。但在实际代码中,它们可以更长;他们可以将birdie 参数传递给更多的函数,而birdie 参数的起源可能是许多函数调用,这使得很难查明运行时错误的原因。类型检查器可以防止在运行时发生许多这样的错误。
注解可用的类型
有很多Python类型可用在类型注解上,但这里有一些约束和建议。除此之外,typing 模块引入了令人惊讶的特殊的具有语义的结构。
Any类型
任何渐进类型系统的基石是Any 类型,也被称为动态类型。当类型检查器看到未注解的函数:
def double(x):
return x * 2
它会假设为:
def double(x: Any) -> Any:
return x * 2
这意味着x 和返回值可以是任何类型,包括不同的类型。Any 被认为可以支持任何操作。
Any 与object 对比,考虑下面的签名:
def double(x: object) -> object:
该函数也支持任何类型的参数,因为所有类型都是object 子类。
然而,类型检查器会拒绝该函数。因为object 不支持__mul__ 操作:
! mypy ./double/double_object.py
double\double_object.py:2: error: Unsupported operand types for * ("object" and "int")
Found 1 error in 1 file (checked 1 source file)
更通用的类型具有更窄(narrower)的接口,比如,它们支持更少的操作。object 比abc.Sequence 实现的操作更少,而后者比abc.MutableSequence 实现的更少。
但Any 是一个魔法类型,它位于类型层次结构的顶部和底部。它同时也是最通用的类型——所以一个参数n: Any 支持任何类型的值——同时最具体的类型,支持任何可能的操作。至少,这是类型检查器理解的Any 。
当然,没有类型可以支持所有可能的操作,所以使用Any 可以阻止类型检查器完成其核心任务:在程序崩溃和运行时异常之前检测潜在的非法操作。
子类型vs一致
传统的面向对象名义类型系统依赖子类型关系。给定一个类T1 和一个子类T2 ,那么T2 是T1 的子类型。 考虑下面的代码:
class T1:
pass
class T2(T1):
pass
def f1(p: T1) -> None:
pass
o2 = T2()
f1(o2)
f1(o2) 是里氏替代原则的应用。Barbara Liskov实际上定义的是关于支持的操作的子类型:如果T2 类型的对象替换了T1 类型的对象,并且该程序仍然正确地行为,则T2 是T1 的子类型。
继续前面的代码,下面显示违背了里氏替代原则:
def f2(p: T2) -> None:
pass
o1 = T1()
f2(o1)
从支持操作的角度来看,这是完全有意义的:作为子类,T2 继承且必须支持T1 的所有操作。所以一个T2 的实例能用在任何期望为T1 实例的地方。但反过来不一定成立:T2 可能实现了额外的方法,所以T1 的实例就可能无法应用到T2 能用的地方。
在渐进类型系统中,有另外一种关系:一致(consistent-with),适用于子类型的适用范围,并对Any 类型有特殊规定。
一致的规则是:
- 给定类型
T1 和子类型T2 ,那么T2 与T1 是一致的。 - 所有的类型与
Any 都是一致的:你可以传递任何类型的对象到声明为Any 类型的参数。 Any 与所有类型一致:你总可以传递Any 类型到期望其他类型的参数。
下面是解释规则2,3的代码:
def f3(p: Any) -> None:
...
o0 = object()
o1 = T1()
o2 = T2()
f3(o0)
f3(o1)
f3(o2)
def f4():
...
o4 = f4()
f1(o4)
f2(o4)
f3(o4)
所有的渐进类型系统都需要一个像Any 这样的通配符类型。
简单类型和类
简单类型像int ,float ,str 和bytes 可以直接用于类型注解。标准库中的具体类、外部包或用户定义的类也能用于类型注解。
抽象类可用在类型注解。
在类中,一致(consistent-with)像子类型那样定义:一个子类与所有它的父类一致。
然而,实用优于纯粹,所以有一个重要的例外,如下所述。
** int 与complex 一致
内置类型int 、float 、complex 类型之间不存在名义的子类型关系:它们是object 的直接子类。但是PEP 484声明int 与float 是一致的,float 与complex 是一致的。它在实践中是有意义的:int 实现了所有float 执行的操作,且int 实现其他操作——位操作,如& 、| 、<< 等。最终的结果是: int 与complex 是一致的。对于i = 3 ,i.rea l是3 ,i.imag 是0 。
Optional和Union类型
Optional 解决了None 作为默认值的问题:
from typing import Optional
def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:
Optional[str] 实际上是Union[str, None] 的简写,后者一位置plural 的类型可以是str 或None 。
在Python3.10中比Optional 和Union 更好的语法
在Python3.10中,我们可以写str | bytes 而不是Union[str, bytes] 。它更简介,且不需要从typing 中引入Optional 或Union 。 对比旧语法和新语法:
plural: Optional[str] = None
plural: str | None = None
| 操作符也能与isintance 和issubclass 一起用来构建第二个参数:isinstance(x, int | str) 。
内置函数ord 的签名是Union 的简单例子——它接收str 或bytes ,然后返回int :
def ord(c: Union[str, bytes]) -> int: ...
还可以以str 为参数,接收str 或float :
from typing import Union
def parse_token(token: str) -> Union[str, float]:
try:
return float(token)
except ValueError:
return token
可以的话,不要创建返回Union 类型的函数,因为它们给用户带来额外的负担——强制用户在运行时检查返回值类型。但上面的parse_token 是一个合理的用例。
Union[] 至少需要两个类型。嵌套的Union 类型与扁平的一样:
Union[A, B, Union[C, D, E]]
与
Union[A, B, C, D, E]
效果相同。
Union 对于它们之间不一致的类型更有用。比如:Union[int, float] 是多余的,因为int 与float 是一致的。如果你只使用float 来注释参数,那么它也会接受int 值。
泛型
大多数Python集合都是异构的。例如,你可以在一个列表中混合不同类型的。但是,在实际中,这并不是很有用:如果你将对象放在集合中,你可能希望稍后对它们进行操作,通常这意味着它们必须至少共享一个常见的方法。
泛型可以用类型参数来声明,以指定它们可以处理的元素的类型。
比如,list 能约束其中元素的类型,比如:
def tokenize(text: str) -> list[str]:
return text.upper().split()
在Python≥3.9中,这意味着tokenize 返回一个列表,其中的元素是str 类型。 注解stuff: list 和stuff: list[Any] 是一样的:stuff 是任意类型的列表。
PEP 585列出了标准库中的接收泛型注解的集合。下面的列表仅显示使用最简单的泛型类型注解,container[item] 目录:
- list
- collections.deque
- abc.Sequence
- abc.MutableSequence
- set
- abc.Container
- abc.Set
- abc.MutableSet
- frozenset
- abc.Collection
元组和映射支持更复杂的类型注解,我们后面再探讨。
从Python3.10开始,没有好的方式去标注array.array ,考虑typecode 构造函数参数,它决定数组中存储的是整数还是浮点数。一个更难的问题是当为数组添加元素时如何检查整数的范围以防止溢出。比如,typecode='B' 的数组只能存储从0到255的int 值。因此,Python的静态类型系统并不能满足这个挑战。
元组类型
有三种方法来注解元组类型:
- 元组作为记录(record)
- 元组作为带有命名字段的记录
- 元组作为不可变序列
元组作为记录
如果你把元组当成记录使用,使用元组内置并通过[] 定义字段类型。
比如,类型注解为tuple[str, float, str] 来接收具有城市名、人口和国家的元组: ('Shanghai', 24.28, 'China') 。
考虑一个函数,它接受一对地理坐标并返回一个地理散列,使用如下:
>>> shanghai = 31.2304, 121.4737
>>> geohash(shanghai)
'wtw3sjq6q'
下面显示geohash 是如何定义的:
from geolib import geohash as gh
PRECISION = 9
def geohash(lat_lon: tuple[float, float]) -> str:
return gh.encode(*lat_lon, PRECISION)
元组作为带有命名字段的记录
为了注解一个具有很多字段,或指定类型的元组,强烈建议使用typing.NamedTuple 。
from typing import NamedTuple
from geolib import geohash as gh
PRECISION = 9
class Coordinate(NamedTuple):
lat: float
lon: float
def geohash(lat_lon: Coordinate) -> str:
return gh.encode(*lat_lon, PRECISION)
typing.NamedTuple 是用于元组子类的工厂,所以Coordinate 与tuple[float, float] 一致,但反过来不成立。毕竟,Coordinate 有NamedTuple 添加的额外方法,像._asdict() 。
实际上,这意味着传递一个Coordiante 实例到下面定义的display 函数是类型安全的:
def display(lat_lon: tuple[float, float]) -> str:
lat, lon = lat_lon
ns = 'N' if lat >= 0 else 'S'
ew = 'E' if lon >= 0 else 'W'
return f'{abs(lat):0.1f}°{ns}, {abs(lon):0.1f}°{ew}'
元组作为不可变序列
为了注解未指定长度作为不可变列表的元组,你必须指定单个类型,然后是, 和... 。
比如,tuple[int, ...] 是一个包含int 元素的元组。
... (省略)表示任何数量的(>=1)元素是可接受的。但没有办法为任意长度的元组指定不同类型的字段。
注解stuff: tuple[Any, ...] 和stuff: tuple 是一样的:stuff 是一个未定长度包含Any 类型的元组。
下面是一个columnize 函数,将一个序列转换成未定长度的元组列表,作为表格中的rows和cells:
from collections.abc import Sequence
def columnize(
sequence: Sequence[str], num_columns: int = 0
) -> list[tuple[str, ...]]:
if num_columns == 0:
num_columns = round(len(sequence) ** 0.5)
num_rows, reminder = divmod(len(sequence), num_columns)
num_rows += bool(reminder)
return [tuple(sequence[i::num_rows]) for i in range(num_rows)]
这非常适合于展示列中的项,比如:
animals = 'drake fawn heron ibex koala lynx tahr xerus yak zapus'.split()
table = columnize(animals)
table
[('drake', 'koala', 'yak'),
('fawn', 'lynx', 'zapus'),
('heron', 'tahr'),
('ibex', 'xerus')]
泛型映射
泛型映射类型被标注为MappingType[KeyType, ValueType] 。在Python≥3.9中,内置的dict 和collections 以及collections.abc 中的映射类型接受该注解。对于早期版本,你必须使用typing.Dict 和其他typing 模块中的映射类型。
下面显示了一个返回反向索引的函数来按名称搜索Unicode字符的实际用法。
import sys
import re
import unicodedata
from collections.abc import Iterator
RE_WORD = re.compile(r'\w+')
STOP_CODE = sys.maxunicode + 1
def tokenize(text: str) -> Iterator[str]:
"""return iterable of uppercased words"""
for match in RE_WORD.finditer(text):
yield match.group().upper()
def name_index(start: int = 32, end: int = STOP_CODE) -> dict[str, set[str]]:
index: dict[str, set[str]] = {}
for char in (chr(i) for i in range(start, end)):
if name := unicodedata.name(char, ''):
for word in tokenize(name):
index.setdefault(word, set()).add(char)
return index
给定开始和结束Unicode字符码,name_index 返回一个dict[str, set[str]] ,这是一个反向索引映射每个单词到一组名称中有该单词的字符。 比如在索引ASCII字符从32到64后,下面是映射到单词’SIGN’和’DIGIT’的字符集,以及如何找到名为’DIGIT EIGHT’的字符。
index = name_index(32, 65)
index['SIGN']
{'#', '$', '%', '+', '<', '=', '>'}
index['DIGIT']
{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}
index['DIGIT'] & index['EIGHT']
{'8'}
抽象基类
Collection | 相应类型注解 |
---|
list | typing.List | set | typing.Set | frozenset | typing.FrozenSet | collections.deque | typing.Deque | collections.abc.MutableSequence | typing.MutableSequence | collections.abc.Sequence | typing.Sequence | collections.abc.Set | typing.AbstractSet | collections.abc.MutableSet | typing.MutableSet |
上表是一些集合类型和它们对应的类型注解。 理论上,一个函数应该接收这些抽象类型——或它们相等的typing 类型注解,这会给调用者更多的灵活性。
考虑下面的签名:
from collections.abc import Mapping
def name2hex(name: str, color_map: Mapping[str, int]) -> str:
使用abc.Mapping 允许调用者使用dict 、defaultdict 、ChainMap 、一个UserDict 的子类,或任何其他继承了Mapping 的子类。
相反地,看下面这个签名:
def name2hex(name: str, color_map: dict[str, int]) -> str:
现在color_map 必须为dict 或它的子类,比如defaultDict 或OrderedDict 。实际上,collections.UserDict 子类不会通过color_map 的类型检查。因为UserDict 不是dict 的子类。它们是兄弟关系,都继承自abc.MutableMapping 。
所以,通常来说在类型注解中使用abc.Mapping 或abc.MutableMapping ,而不是dict 或typing.Dict 更好。如果name2hex 函数不需要修改给定的color_map ,那么最准确的注解类型是abc.Mapping 。这样,调用者不需要提供实现了像setdefault 、pop 和update 方法的对象,它们仅来自MutableMapping 接口。
一个函数的返回值通常是具体对象,所以返回类型注解应该是具体类型:
def tokenize(text: str) -> list[str]:
return text.upper().split()
Python文档关于typing.List 描述的是:
list 的泛型版本。用于注解返回类型。为了注解参数,最好使用抽象集合类型,如Sequence 或Iterable 。
同样的描述出现在typing.Dict 和typing.Set 中。
请记住,从Python 3.9开始,大多数来自collections.abc 的抽象类和collections 中的具体类,以及内置集合,都支持泛型类型注解标记,比如collections.deque[str] 。
我们下面讨论数字抽象类。
数字塔的倒塌
numbers 包定义了所谓的数字塔(numeric tower),该塔是抽象类的线性层次结构,Number 位于顶层:
- Number
- Complex
- Real
- Rational
- Integral
这些抽象类非常适用于运行时类型检查,但它们不支持静态类型检查。PEP 484的"Numeric Tower"小节拒绝数字抽象类,并指出内置类型complext 、float 、int 应该看成特例,如我们上面讨论的。
实际上,如果你想标注数字参数用于静态类型检查,这里有一些选择:
- 使用具体类
int 、float 或complex ——如PEP 488建议的 - 定义一个联合类型,像
Union[float, Decimal, Fraction] - 如果你不想硬编码具体类,使用数字协议如
SupportsFloat
Iterable
上面引用的typing.List 文档推荐Sequence 和Iterable 作为函数参数类型注解。
一个Iteralbe 参数的例子出现在math.fsum 函数:
def fsum(__seq: Iterable[float]) -> float:
下面是另一个使用Iterable 参数的例子,产生是tuple[str, str] 的项:
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
l33t = [('a', '4'), ('e', '3'), ('i', '1'), ('o', '0')]
text = 'mad skilled noob powned leet'
zip_replace(text, l33t)
'm4d sk1ll3d n00b p0wn3d l33t'
Python3.10中显示的类型别名(TypeAlias) PEP613引入了一个特殊类型,TypeAlias 使创建类型别名的分配更可读,更容易类型检查。
abc.Iterable vs abc.Sequence
math.fsum 和zip_replace 必须迭代整个可迭代参数来返回一个结果。给定一个无限的可迭代对象,比如itertools.cycle 生成器作为输入,这些函数会消耗所有的内存并导致Python进程崩溃。尽管存在这种潜在的危险,但现在在Python中,提供接受可迭代输入的函数是相当常见的,即使它们必须完全处理它才能返回结果。这使调用者可以选择提供输入数据作为生成器而不是一个预建的序列,如果输入项的数量很大,那么可能会节省大量的内存。
另一方面,上面例子中的columnize 函数需要一个Sequence 参数而不是Iterable ,因为它必须调用输入的len() 。
像Sequence 、Iterable 是最适合用于参数类型的。但作为返回类型太模糊了。一个函数应该更精确地描述它所返回的具体类型。
与Iterable 紧密关联的是Iterator 类型,用于上面例子中的返回类型。
参数化泛型和TypeVar
一个参数化泛型(parameterized generic)是一种泛型类,写作list[T] ,其中T 是类型变量,会绑定到具体类型。这允许在结果类型上反映一个参数类型。
下面定义了sample ,一个函数接收两个参数:类型T 的Sequence ,和int 。它返回同样T 类型的元素列表。
from collections.abc import Sequence
from random import shuffle
from typing import TypeVar
T = TypeVar('T')
def sample(population: Sequence[T], size: int) -> list[T]:
if size < 1:
raise ValueError('size must be >= 1')
result = list(population)
shuffle(result)
return result[:size]
下面是两个为什么在sample 中使用类型变量的例子:
- 如果基于类型元组
tuple[int, ...] 调用——它与Sequence[int] 一致——那么类型参数是int ,所以返回类型是list[int] - 如果基于
str 调用——它与Sequence[str] 一致——那么类型参数为str ,所以返回类型是list[str]
为什么需要TypeVar PEP 484的作者希望通过添加typing 模块而不改变语言中的任何其他东西来引入类型注解。通过聪明的元编程,他们可以使[] 操作符处理像Sequence[T] 这样的类。但是括号内的T 变量的名称必须在某个地方定义——否则Python解释器需要深度更改以支持[] 作为泛型类型符号的特殊使用。这就是为什么要typing.TypeVar 构造函数:在当前命名空间中引入变量名。像Java、C#和TypeScript这样的语言不需要预先声明类型变量的名称,因此它们没有类似于Python的TypeVar类。
另一个例子来自statistics.mode 函数,返回序列中最常见的数据。
from statistics import mode
mode([1, 1, 2, 3, 3, 3, 3, 4])
3
如果没使用TypeVar ,mode 可能会有下面这样的签名:
from collections import Counter
from collections.abc import Iterable
def mode(data: Iterable[float]) -> float:
pairs = Counter(data).most_common(1)
if len(pairs) == 0:
raise ValueError('no mode for empty data')
return pairs[0][0]
很多使用mode 的值都是int 或float ,但Python还有其他的数值类型。并且希望返回类型遵循给定的Iterable 的元素类型。我们可以使用TypeVar 来改进该签名。让我们从一个简单但错误的参数化签名开始:
from collections.abc import Iterable
from typing import TypeVar
T = TypeVar('T')
def mode(data: Iterable[T]) -> T:
当类型参数T 首次出现在签名中时,它可以是任何类型。当它第二次出现时,它将意味着与第一个相同的类型。
因此,每个可迭代对象都与Iterable[T] 一致,包括collections.Counter 不能处理的不可哈希类型的迭代对象。我们需要限制赋予给T 的可能类型。我们将在接下来的两个小节中看到两种这样做的方法。
受限的TypeVar
TypeVar 接受额外的位置参数来限制类型参数。我们可以改进mode 的签名,以接受特定的数字类型,就像这样:
from collections.abc import Iterable
from decimal import Decimal
from fractions import Fraction
from typing import TypeVar
NumberT = TypeVar('NumberT', float, Decimal, Fraction)
def mode(data: Iterable[NumberT]) -> NumberT:
这比之前那个好多了,然而statistics.mode 的文档包含这样的例子:
>>> mode(["red", "blue", "blue", "red", "green", "red", "red"])
'red'
很快,我们就可以将str 添加到NumberT 的定义中来适配:
NumberT = TypeVar('NumberT', float, Decimal, Fraction, str)
这当然有效,但如果NumberT 接受str ,它的名字就严重错了。更重要的是,我们不能不断列出认识到的mode 可以处理的新类型。我们可以利用TypeVar 的另一个特性做得更好。
绑定的TypeVar
查看前面mode 示例中的方法体,我们可以看到用Counter 类进行排序的。Counter 是基于dict的,因此可迭代的data 的元素类型必须是可哈希的。
首先,这样的签名看起来可以工作:
from collections.abc import Iterable, Hashable
def mode(data: Iterable[Hashable]) -> Hashable:
但现在的问题是返回项的类型是Hashable :一个只实现了__hash__ 方法的抽象类。所以类型检查器除了允许让我们对返回值调用hash() 方法外,不能调用其他任何方法。这非常不好用。
解决方法是使用另一个TypeVar 的可选参数:bound 关键字参数。它设定了可接受类型的上界。在下面的例子中,我们有bound=Hashable ,这意味着类型参数可以是Hashable 或它的任何子类。
from collections import Counter
from collections.abc import Iterable, Hashable
from typing import TypeVar
HashableT = TypeVar('HashableT', bound=Hashable)
def mode(data: Iterable[HashableT]) -> HashableT:
pairs = Counter(data).most_common(1)
if len(pairs) == 0:
raise ValueError('no mode for empty data')
return pairs[0][0]
AnyStr预定义的类型变量
typing 模块包括一个名为AnyStr 的预定义TypeVar 。它的定义是这样的:AnyStr=TypeVar("AnyStr",bytes,str) 。AnyStr 用于许多接受字节或str 的函数,并返回给定类型的值。
静态协议
Protocol 类型,如PEP 544所描述,类似于Go中的接口:协议类型通过指定一个或多个方法来定义,并且类型检查器验证是否实现了协议类型需要的方法。
在Python中,协议定义被写成一个typing.Protocol 的子类。但是,实现协议的类不需要继承、注册或声明与定义该协议的类的任何关系。由类型检查器来查找可用的协议类型并强制它们的使用。
这里有一个问题,可以通过Protocol 和TypeVar 来解决。假设你想要创建一个函数top(it ,n) ,它返回可迭代it 的最大的n 个元素:
>>> top([4, 1, 5, 2, 6, 7, 3], 3)
[7, 6, 5]
>>> l = 'mango pear apple kiwi banana'.split()
>>> top(l, 3)
['pear', 'mango', 'kiwi']
>>>
>>> l2 = [(len(s), s) for s in l]
>>> l2
[(5, 'mango'), (4, 'pear'), (5, 'apple'), (4, 'kiwi'), (6, 'banana')]
>>> top(l2, 3)
[(6, 'banana'), (5, 'mango'), (5, 'apple')]
一个参数化的泛型top 实现可能如下所示:
def top(series: Iterable[T], length: int) -> list[T]:
ordered = sorted(series, reverse=True)
return ordered[:length]
问题在于如何限制T ,它不能是Any 或object ,因为series 必须能在sorted 中工作。sorted 内置项实际上接受Iterable[Any] ,但这是因为可选参数key 接受一个函数,能从每个元素计算任意排序键。 如果你给sorted 一个普通对象列表,但不提供key 参数,会发生什么?让我们尝试一下:
l = [object() for _ in range(4)]
l
[<object at 0x2932d1e84b0>,
<object at 0x2932d1e83e0>,
<object at 0x2932d1e8410>,
<object at 0x2932d1e83a0>]
sorted(l)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In [13], line 1
----> 1 sorted(l)
TypeError: '<' not supported between instances of 'object' and 'object'
错误消息显示sorted 在可迭代的元素上使用< 操作符进行排序。这就够了吗?让我们来做另一个的实验:
class Spam:
def __init__(self, n):
self.n = n
def __lt__(self, other):
return self.n < other.n
def __repr__(self):
return f'Spam({self.n})'
l = [Spam(n) for n in range(5, 0, -1)]
l
[Spam(5), Spam(4), Spam(3), Spam(2), Spam(1)]
sorted(l)
[Spam(1), Spam(2), Spam(3), Spam(4), Spam(5)]
这证实了这一点:我可以对Spam 列表进行排序,因为Spam 实现了支持< 操作符的__lt__ 方法。
因此,实现中的T 类型参数应该限制于实现__lt__ 的类型。在前面,我们需要一个实现__hash__ 的类型参数,所以我们能够使用类型Hashable 为类型参数的上界。但是现在在typing 或abc 中没有合适的类型来使用,所以我们需要创建它。先忙显示了新的SupportsLessThan ,一个Protocol :
from typing import Protocol, Any
class SupportsLessThan(Protocol):
def __lt__(self, other: Any) -> bool:
...
一个类型T 与一个协议P 一致,如果T 实现了P 中定义的所有方法,以及匹配的类型签名。
给定SupportsLessThan ,我们就可以定义可工作版的top 。
from collections.abc import Iterable
from typing import TypeVar
LT = TypeVar('LT', bound=SupportsLessThan)
def top(series: Iterable[LT], length: int) -> list[LT]:
ordered = sorted(series, reverse=True)
return ordered[:length]
让我们测试驱动top ,下面实现了测试代码。首先基于生成器表达式调用top ,然后用object 列表。基于object 列表,期望一个TypeError 异常。
from collections.abc import Iterator
from typing import TYPE_CHECKING
import pytest
from top import top
@pytest.mark.parametrize('series, length, expected', [
((1, 2, 3), 2, [3, 2]),
((1, 2, 3), 3, [3, 2, 1]),
((3, 3, 3), 1, [3]),
])
def test_top(
series: tuple[float, ...],
length: int,
expected: list[float],
) -> None:
result = top(series, length)
assert expected == result
def test_top_tuples() -> None:
fruit = 'mango pear apple kiwi banana'.split()
series: Iterator[tuple[int, str]] = (
(len(s), s) for s in fruit)
length = 3
expected = [(6, 'banana'), (5, 'mango'), (5, 'apple')]
result = top(series, length)
if TYPE_CHECKING:
reveal_type(series)
reveal_type(expected)
reveal_type(result)
assert result == expected
def test_top_objects_error() -> None:
series = [object() for _ in range(4)]
if TYPE_CHECKING:
reveal_type(series)
with pytest.raises(TypeError) as excinfo:
top(series, 3)
assert "'<' not supported" in str(excinfo.value)
前面的测试通过了——但是无论top.py 中有没有类型提示都会通过。更重要的是,如果我用Mypy检查该测试文件,我将看到TypeVar 正在按照预期进行工作。请参见mypy命令输出:
! mypy ./comparable/top_test.py
comparable\top_test.py:29: note: Revealed type is "typing.Iterator[Tuple[builtins.int, builtins.str]]"
comparable\top_test.py:30: note: Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]"
comparable\top_test.py:31: note: Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]"
comparable\top_test.py:38: note: Revealed type is "builtins.list[builtins.object]"
comparable\top_test.py:40: error: Value of type variable "LT" of "top" cannot be "object"
Found 1 error in 1 file (checked 1 source file)
comparable\top_test.py:29: note:
Revealed type is "typing.Iterator[Tuple[builtins.int, builtins.str]]"
comparable\top_test.py:30: note:
Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]"
comparable\top_test.py:31: note:
Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]"
comparable\top_test.py:38: note:
Revealed type is "builtins.list[builtins.object]"
comparable\top_test.py:40: error: Value of type variable "LT" of "top" cannot be "object"
Found 1 error in 1 file (checked 1 source file)
在抽象类上的协议类型的关键优势是类型不需要任何特殊声明而可以与一个协议类型一致。这允许我们可以创建一个利用预先存在类型的协议。在需要使用SupportsLessThan 时,我们不需要继承或注册str ,tuple ,float ,set 等,只需要实现__lt__ 。而且类型检查器仍然能够完成它的工作,因为支持SupportsLessThan 被显式地定义为一个协议——与鸭子类型中常见的隐式协议相反,后者对类型检查器是不可见的。
可调用
为了标注可回调参数或由高阶函数返回的可调用(callable)对象,collections.abc 提供了Callable 类型:
Callable[[ParamType1, ParamType2], ReturnType]
参数列表[ParamType1, ParamType2] 可以有0个或多个类型。下面是repl 函数的例子:
def repl(input_fn: Callable[[Any], str] = input]) -> None:
在使用时,repl 函数使用Python的内建input 读取用户的表达式。然而,但是,对于自动化测试或与其他输入源的集成,repl 接收一个可选参数input_fn :一个具有同样参数和返回类型作为输入的可调用对象。
def input(__prompt: Any = ...) -> str: ...
input 签名与Callable 类型注解一致:
Callable[[Any], str]
没有可以用来注释可选参数类型或关键字参数类型的语法。typing.Callable 文档说这些函数类型几乎捕获作为可回调类型。如果你需要一个类型注解来匹配具有灵活签名的函数,那么可以将整个参数列表用... 替代:
Callable[..., ReturnType]
泛型类型参数与类型层次结构的交互引入了一个新的类型概念:variance。
可调用类型中的Variance
有一个温度控制系统,具有一个简单的update 函数。它通过调用probe 函数来获取当前的温度,然后调用display 显示温度。probe 和display 都作为参数传递。本示例的目的是对比两个Callable 注解:一个有返回类型,另一个有参数类型。
from collections.abc import Callable
def update(
probe: Callable[[], float],
display: Callable[[float], None]
) -> None:
temperature = probe()
display(temperature)
def probe_ok() -> int:
return 42
def display_wrong(temperature: int) -> None:
print(hex(temperature))
update(probe_ok, display_wrong)
def display_ok(temperature: complex) -> None:
print(temperature)
update(probe_ok, display_ok)
NoReturn
这是一个用于标记函数不会有返回的特殊类型。通常,它们的存在是为了抛出异常。在标准库中有几十个这样的函数。
比如sys.exit() 抛出SystemExit 异常来终止Python进程。
它在typeshed的签名如下:
def exit(__status: object = ...) -> NoReturn: ...
__status 是一个仅位置参数,并且有默认值。Stub文件不会写出默认值,而使用... 替代。__status 的类型是object ,这意味着可能为None 。
注解仅位置参数和可变参数
我们之前看到最后版本的tag 如下:
def tag(name, /, *content, class_=None, **attrs):
下面完全注释的,用几行编写——这是长签名的常见约定,用blue格式化程序的方式分隔:
from typing import Optional
def tag(
name: str,
/,
*content: str,
class_: Optional[str] = None,
**attrs: str,
) -> str:
注意类型注释*content: str 表示任意位置参数,同时这些参数的所有类型必须为str 。函数体中content 局部变量类型会是tuple[str, ...] 。
表示任意关键字参数的类型注解是**attrs: str ,因此函数中attrs 的类型是dict[str, str] 。而对于类型注解**attrs: float ,函数中的attrs 的类型会是dict[str, float] 。
|