流畅的 Python 第二版(GPT 重译)(四)(3)https://developer.aliyun.com/article/1484438
为什么需要 TypeVar?
PEP 484 的作者希望通过添加typing
模块引入类型提示,而不改变语言的其他任何内容。通过巧妙的元编程,他们可以使[]
运算符在类似Sequence[T]
的类上起作用。但括号内的T
变量名称必须在某处定义,否则 Python 解释器需要进行深层更改才能支持通用类型符号作为[]
的特殊用途。这就是为什么需要typing.TypeVar
构造函数:引入当前命名空间中的变量名称。像 Java、C#和 TypeScript 这样的语言不需要事先声明类型变量的名称,因此它们没有 Python 的TypeVar
类的等价物。
另一个例子是标准库中的statistics.mode
函数,它返回系列中最常见的数据点。
这里是来自文档的一个使用示例:
>>> mode([1, 1, 2, 3, 3, 3, 3, 4]) 3
如果不使用TypeVar
,mode
可能具有示例 8-17 中显示的签名。
示例 8-17。mode_float.py:对float
和子类型进行操作的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:
这比以前好,这是 2020 年 5 月 25 日typeshed
上statistics.pyi
存根文件中mode
的签名。
然而,statistics.mode
文档中包含了这个例子:
>>> mode(["red", "blue", "blue", "red", "green", "red", "red"]) 'red'
匆忙之间,我们可以将str
添加到NumberT
的定义中:
NumberT = TypeVar('NumberT', float, Decimal, Fraction, str)
当然,这样做是有效的,但如果它接受str
,那么NumberT
的命名就非常不合适。更重要的是,我们不能永远列出类型,因为我们意识到mode
可以处理它们。我们可以通过TypeVar
的另一个特性做得更好,接下来介绍。
有界的 TypeVar
查看示例 8-17 中mode
的主体,我们看到Counter
类用于排名。Counter 基于dict
,因此data
可迭代对象的元素类型必须是可哈希的。
起初,这个签名似乎可以工作:
from collections.abc import Iterable, Hashable def mode(data: Iterable[Hashable]) -> Hashable:
现在的问题是返回项的类型是Hashable
:一个只实现__hash__
方法的 ABC。因此,类型检查器不会让我们对返回值做任何事情,除了调用hash()
。并不是很有用。
解决方案是TypeVar
的另一个可选参数:bound
关键字参数。它为可接受的类型设置了一个上限。在示例 8-18 中,我们有bound=Hashable
,这意味着类型参数可以是Hashable
或其任何子类型。¹⁴
示例 8-18。mode_hashable.py:与示例 8-17 相同,但具有更灵活的签名
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]
总结一下:
- 限制类型变量将被设置为
TypeVar
声明中命名的类型之一。 - 有界类型变量将被设置为表达式的推断类型——只要推断类型与
TypeVar
的bound=
关键字参数中声明的边界一致即可。
注意
不幸的是,声明有界TypeVar
的关键字参数被命名为bound=
,因为动词“绑定”通常用于表示设置变量的值,在 Python 的引用语义中最好描述为将名称绑定到值。如果关键字参数被命名为boundary=
会更少令人困惑。
typing.TypeVar
构造函数还有其他可选参数——covariant
和contravariant
——我们将在第十五章中介绍,“Variance”中涵盖。
让我们用AnyStr
结束对TypeVar
的介绍。
预定义的 AnyStr 类型变量
typing
模块包括一个预定义的TypeVar
,名为AnyStr
。它的定义如下:
AnyStr = TypeVar('AnyStr', bytes, str)
AnyStr
在许多接受bytes
或str
的函数中使用,并返回给定类型的值。
现在,让我们来看看typing.Protocol
,这是 Python 3.8 的一个新特性,可以支持更具 Python 风格的类型提示的使用。
静态协议
注意
在面向对象编程中,“协议”概念作为一种非正式接口的概念早在 Smalltalk 中就存在,并且从一开始就是 Python 的一个基本部分。然而,在类型提示的背景下,协议是一个typing.Protocol
子类,定义了一个类型检查器可以验证的接口。这两种类型的协议在第十三章中都有涉及。这只是在函数注释的背景下的简要介绍。
如PEP 544—Protocols: Structural subtyping (static duck typing)中所述,Protocol
类型类似于 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
看起来像示例 8-19 中所示的样子。
示例 8-19。带有未定义T
类型参数的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 object at 0x10fc2fca0>, <object object at 0x10fc2fbb0>, <object object at 0x10fc2fbc0>, <object object at 0x10fc2fbd0>] >>> sorted(l) Traceback (most recent call last): File "<stdin>", line 1, in <module> 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
列表进行sort
,因为Spam
实现了__lt__
——支持<
运算符的特殊方法。
因此,示例 8-19 中的 T
类型参数应该限制为实现 __lt__
的类型。在 示例 8-18 中,我们需要一个实现 __hash__
的类型参数,因此我们可以使用 typing.Hashable
作为类型参数的上界。但是现在在 typing
或 abc
中没有适合的类型,因此我们需要创建它。
示例 8-20 展示了新的 SupportsLessThan
类型,一个 Protocol
。
示例 8-20. comparable.py: SupportsLessThan
Protocol
类型的定义
from typing import Protocol, Any class SupportsLessThan(Protocol): # ① def __lt__(self, other: Any) -> bool: ... # ②
①
协议是 typing.Protocol
的子类。
②
协议的主体有一个或多个方法定义,方法体中有 ...
。
如果类型 T
实现了 P
中定义的所有方法,并且类型签名匹配,则类型 T
与协议 P
一致。
有了 SupportsLessThan
,我们现在可以在 示例 8-21 中定义这个可工作的 top
版本。
示例 8-21. top.py: 使用 TypeVar
和 bound=SupportsLessThan
定义 top
函数
from collections.abc import Iterable from typing import TypeVar from comparable import SupportsLessThan LT = TypeVar('LT', bound=SupportsLessThan) def top(series: Iterable[LT], length: int) -> list[LT]: ordered = sorted(series, reverse=True) return ordered[:length]
让我们来测试 top
。示例 8-22 展示了一部分用于 pytest
的测试套件。首先尝试使用生成器表达式调用 top
,该表达式生成 tuple[int, str]
,然后使用 object
列表。对于 object
列表,我们期望得到一个 TypeError
异常。
示例 8-22. top_test.py: top
测试套件的部分清单
from collections.abc import Iterator from typing import TYPE_CHECKING # ① import pytest from top import top # several lines omitted 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 # intentional type error 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)
①
typing.TYPE_CHECKING
常量在运行时始终为 False
,但类型检查器在进行类型检查时会假装它为 True
。
②
显式声明 series
变量的类型,以使 Mypy 输出更易读。¹⁶
③
这个 if
阻止了接下来的三行在测试运行时执行。
④
reveal_type()
不能在运行时调用,因为它不是常规函数,而是 Mypy 的调试工具—这就是为什么没有为它导入任何内容。对于每个 reveal_type()
伪函数调用,Mypy 将输出一条调试消息,显示参数的推断类型。
⑤
这一行将被 Mypy 标记为错误。
前面的测试通过了—但无论是否在 top.py 中有类型提示,它们都会通过。更重要的是,如果我用 Mypy 检查该测试文件,我会看到 TypeVar
正如预期的那样工作。查看 示例 8-23 中的 mypy
命令输出。
警告
截至 Mypy 0.910(2021 年 7 月),reveal_type
的输出在某些情况下并不精确显示我声明的类型,而是显示兼容的类型。例如,我没有使用 typing.Iterator
,而是使用了 abc.Iterator
。请忽略这个细节。Mypy 的输出仍然有用。在讨论输出时,我会假装 Mypy 的这个问题已经解决。
示例 8-23. mypy top_test.py 的输出(为了可读性而拆分的行)
…/comparable/ $ mypy top_test.py top_test.py:32: note: Revealed type is "typing.Iterator[Tuple[builtins.int, builtins.str]]" # ① top_test.py:33: note: Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]" top_test.py:34: note: Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]" # ② top_test.py:41: note: Revealed type is "builtins.list[builtins.object*]" # ③ top_test.py:43: error: Value of type variable "LT" of "top" cannot be "object" # ④ Found 1 error in 1 file (checked 1 source file)
①
在 test_top_tuples
中,reveal_type(series)
显示它是一个 Iterator[tuple[int, str]]
—这是我明确声明的。
②
reveal_type(result)
确认了 top
调用返回的类型是我想要的:给定 series
的类型,result
是 list[tuple[int, str]]
。
③
在 test_top_objects_error
中,reveal_type(series)
显示为 list[object*]
。Mypy 在任何推断的类型后面加上 *
:我没有在这个测试中注释 series
的类型。
④
Mypy 标记了这个测试故意触发的错误:Iterable
series
的元素类型不能是object
(必须是SupportsLessThan
类型)。
协议类型相对于 ABCs 的一个关键优势是,一个类型不需要任何特殊声明来与协议类型一致。这允许创建一个协议利用预先存在的类型,或者在我们无法控制的代码中实现的类型。我不需要派生或注册str
、tuple
、float
、set
等类型到SupportsLessThan
以在期望SupportsLessThan
参数的地方使用它们。它们只需要实现__lt__
。而类型检查器仍然能够完成其工作,因为SupportsLessThan
被明确定义为Protocol
—与鸭子类型常见的隐式协议相反,这些协议对类型检查器是不可见的。
特殊的Protocol
类在PEP 544—Protocols: Structural subtyping (static duck typing)中引入。示例 8-21 展示了为什么这个特性被称为静态鸭子类型:注释top
的series
参数的解决方案是说“series
的名义类型并不重要,只要它实现了__lt__
方法。”Python 的鸭子类型总是允许我们隐式地说这一点,让静态类型检查器一头雾水。类型检查器无法阅读 CPython 的 C 源代码,或者执行控制台实验来发现sorted
只需要元素支持<
。
现在我们可以为静态类型检查器明确地定义鸭子类型。这就是为什么说typing.Protocol
给我们静态鸭子类型是有意义的。¹⁷
还有更多关于typing.Protocol
的内容。我们将在第四部分回来讨论它,在第十三章中对比结构化类型、鸭子类型和 ABCs——另一种形式化协议的方法。此外,“重载签名”(第十五章)解释了如何使用@typing.overload
声明重载函数签名,并包括了一个使用typing.Protocol
和有界TypeVar
的广泛示例。
注意
typing.Protocol
使得可以注释“类型由支持的操作定义”中提到的double
函数而不会失去功能。关键是定义一个带有__mul__
方法的协议类。我邀请你将其作为练习完成。解决方案出现在“类型化的 double 函数”中(第十三章)。
Callable
为了注释回调参数或由高阶函数返回的可调用对象,collections.abc
模块提供了Callable
类型,在尚未使用 Python 3.9 的情况下在typing
模块中可用。Callable
类型的参数化如下:
Callable[[ParamType1, ParamType2], ReturnType]
参数列表—[ParamType1, ParamType2]
—可以有零个或多个类型。
这是在我们将在“lis.py 中的模式匹配:案例研究”中看到的一个repl
函数的示例:¹⁸
def repl(input_fn: Callable[[Any], str] = input]) -> None:
在正常使用中,repl
函数使用 Python 的input
内置函数从用户那里读取表达式。然而,对于自动化测试或与其他输入源集成,repl
接受一个可选的input_fn
参数:一个与input
具有相同参数和返回类型的Callable
。
内置的input
在 typeshed 上有这个签名:
def input(__prompt: Any = ...) -> str: ...
input
的签名与这个Callable
类型提示一致:
Callable[[Any], str]
没有语法来注释可选或关键字参数类型。typing.Callable
的文档说“这样的函数类型很少用作回调类型。”如果你需要一个类型提示来匹配具有灵活签名的函数,用...
替换整个参数列表—就像这样:
Callable[..., ReturnType]
泛型类型参数与类型层次结构的交互引入了一个新的类型概念:variance。
Callable 类型中的 variance
想象一个简单的温度控制系统,其中有一个简单的update
函数,如示例 8-24 所示。update
函数调用probe
函数获取当前温度,并调用display
显示温度给用户。probe
和display
都作为参数传递给update
是为了教学目的。示例的目标是对比两个Callable
注释:一个有返回类型,另一个有参数类型。
示例 8-24。说明 variance。
from collections.abc import Callable def update( # ① probe: Callable[[], float], # ② display: Callable[[float], None] # ③ ) -> None: temperature = probe() # imagine lots of control code here display(temperature) def probe_ok() -> int: # ④ return 42 def display_wrong(temperature: int) -> None: # ⑤ print(hex(temperature)) update(probe_ok, display_wrong) # type error # ⑥ def display_ok(temperature: complex) -> None: # ⑦ print(temperature) update(probe_ok, display_ok) # OK # ⑧
①
update
接受两个可调用对象作为参数。
②
probe
必须是一个不带参数并返回float
的可调用对象。
③
display
接受一个float
参数并返回None
。
④
probe_ok
与Callable[[], float]
一致,因为返回一个int
不会破坏期望float
的代码。
⑤
display_wrong
与Callable[[float], None]
不一致,因为没有保证一个期望int
的函数能处理一个float
;例如,Python 的hex
函数接受一个int
但拒绝一个float
。
⑥
Mypy 标记这行是因为display_wrong
与update
的display
参数中的类型提示不兼容。
⑦
display_ok
与Callable[[float], None]
一致,因为一个接受complex
的函数也可以处理一个float
参数。
⑧
Mypy 对这行很满意。
总结一下,当代码期望返回float
的回调时,提供返回int
的回调是可以的,因为int
值总是可以在需要float
的地方使用。
正式地说,Callable[[], int]
是subtype-ofCallable[[], float]
——因为int
是subtype-offloat
。这意味着Callable
在返回类型上是协变的,因为类型int
和float
的subtype-of关系与使用它们作为返回类型的Callable
类型的关系方向相同。
另一方面,当需要处理float
时,提供一个接受int
参数的回调是类型错误的。
正式地说,Callable[[int], None]
不是subtype-ofCallable[[float], None]
。虽然int
是subtype-offloat
,但在参数化的Callable
类型中,关系是相反的:Callable[[float], None]
是subtype-ofCallable[[int], None]
。因此我们说Callable
在声明的参数类型上是逆变的。
“Variance”在第十五章中详细解释了 variance,并提供了不变、协变和逆变类型的更多细节和示例。
提示
目前,可以放心地说,大多数参数化的泛型类型是invariant,因此更简单。例如,如果我声明scores: list[float]
,那告诉我可以分配给scores
的对象。我不能分配声明为list[int]
或list[complex]
的对象:
- 一个
list[int]
对象是不可接受的,因为它不能容纳float
值,而我的代码可能需要将其放入scores
中。 - 一个
list[complex]
对象是不可接受的,因为我的代码可能需要对scores
进行排序以找到中位数,但complex
没有提供__lt__
,因此list[complex]
是不可排序的。
现在我们来讨论本章中最后一个特殊类型。
NoReturn
这是一种特殊类型,仅用于注释永远不返回的函数的返回类型。通常,它们存在是为了引发异常。标准库中有数十个这样的函数。
例如,sys.exit()
引发SystemExit
来终止 Python 进程。
它在typeshed
中的签名是:
def exit(__status: object = ...) -> NoReturn: ...
__status
参数是仅位置参数,并且具有默认值。存根文件不详细说明默认值,而是使用...
。__status
的类型是object
,这意味着它也可能是None
,因此标记为Optional[object]
将是多多的。
在第二十四章中,示例 24-6 在__flag_unknown_attrs
中使用NoReturn
,这是一个旨在生成用户友好和全面错误消息的方法,然后引发AttributeError
。
这一史诗般章节的最后一节是关于位置和可变参数。
注释位置参数和可变参数
回想一下从示例 7-9 中的tag
函数。我们上次看到它的签名是在“仅位置参数”中:
def tag(name, /, *content, class_=None, **attrs):
这里是tag
,完全注释,写成几行——长签名的常见约定,使用换行符的方式,就像蓝色格式化程序会做的那样:
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]
。
如果attrs
参数必须接受不同类型的值,你需要使用Union[]
或Any
:**attrs: Any
。
仅位置参数的/
符号仅适用于 Python ≥ 3.8。在 Python 3.7 或更早版本中,这将是语法错误。PEP 484 约定是在每个位置参数名称前加上两个下划线。这里是tag
签名,再次以两行的形式,使用 PEP 484 约定:
from typing import Optional def tag(__name: str, *content: str, class_: Optional[str] = None, **attrs: str) -> str:
Mypy 理解并强制执行声明位置参数的两种方式。
为了结束这一章,让我们简要地考虑一下类型提示的限制以及它们支持的静态类型系统。
不完美的类型和强大的测试
大型公司代码库的维护者报告说,许多错误是由静态类型检查器发现的,并且比在代码运行在生产环境后才发现这些错误更便宜修复。然而,值得注意的是,在我所知道的公司中,自动化测试在静态类型引入之前就是标准做法并被广泛采用。
即使在它们最有益处的情况下,静态类型也不能被信任为正确性的最终仲裁者。很容易找到:
假阳性
工具会报告代码中正确的类型错误。
假阴性
工具不会报告代码中不正确的类型错误。
此外,如果我们被迫对所有内容进行类型检查,我们将失去 Python 的一些表现力:
- 一些方便的功能无法进行静态检查;例如,像
config(**settings)
这样的参数解包。 - 属性、描述符、元类和一般元编程等高级功能对类型检查器的支持较差或超出理解范围。
- 类型检查器落后于 Python 版本,拒绝甚至在分析具有新语言特性的代码时崩溃——在某些情况下超过一年。
通常的数据约束无法在类型系统中表达,甚至是简单的约束。例如,类型提示无法确保“数量必须是大于 0 的整数”或“标签必须是具有 6 到 12 个 ASCII 字母的字符串”。总的来说,类型提示对捕捉业务逻辑中的错误并不有帮助。
鉴于这些注意事项,类型提示不能成为软件质量的主要支柱,强制性地使其成为例外会放大缺点。
将静态类型检查器视为现代 CI 流水线中的工具之一,与测试运行器、代码检查器等一起。CI 流水线的目的是减少软件故障,自动化测试可以捕获许多超出类型提示范围的错误。你可以在 Python 中编写的任何代码,都可以在 Python 中进行测试,无论是否有类型提示。
注
本节的标题和结论受到 Bruce Eckel 的文章“强类型 vs. 强测试”的启发,该文章也发表在 Joel Spolsky(Apress)编辑的文集The Best Software Writing I中。Bruce 是 Python 的粉丝,也是关于 C++、Java、Scala 和 Kotlin 的书籍的作者。在那篇文章中,他讲述了他是如何成为静态类型支持者的,直到学习 Python 并得出结论:“如果一个 Python 程序有足够的单元测试,它可以和有足够单元测试的 C++、Java 或 C#程序一样健壮(尽管 Python 中的测试编写速度更快)。”
目前我们的 Python 类型提示覆盖到这里。它们也是第十五章的主要内容,该章涵盖了泛型类、变异、重载签名、类型转换等。与此同时,类型提示将在本书的几个示例中做客串出现。
章节总结
我们从对渐进式类型概念的简要介绍开始,然后转向实践方法。没有一个实际读取类型提示的工具,很难看出渐进式类型是如何工作的,因此我们开发了一个由 Mypy 错误报告引导的带注解函数。
回到渐进式类型的概念,我们探讨了它是 Python 传统鸭子类型和用户更熟悉的 Java、C++等静态类型语言的名义类型的混合体。
大部分章节都致力于介绍注解中使用的主要类型组。我们涵盖的许多类型与熟悉的 Python 对象类型相关,如集合、元组和可调用对象,扩展以支持类似Sequence[float]
的泛型表示。许多这些类型是在 Python 3.9 之前在typing
模块中实现的临时替代品,直到标准类型被更改以支持泛型。
一些类型是特殊实体。Any
、Optional
、Union
和NoReturn
与内存中的实际对象无关,而仅存在于类型系统的抽象领域中。
我们研究了参数化泛型和类型变量,这为类型提示带来了更多灵活性,而不会牺牲类型安全性。
使用Protocol
使参数化泛型变得更加表达丰富。因为它仅出现在 Python 3.8 中,Protocol
目前并不广泛使用,但它非常重要。Protocol
实现了静态鸭子类型:Python 鸭子类型核心与名义类型之间的重要桥梁,使静态类型检查器能够捕捉错误。
在介绍一些类型的同时,我们通过 Mypy 进行实验,以查看类型检查错误,并借助 Mypy 的神奇reveal_type()
函数推断类型。
最后一节介绍了如何注释位置参数和可变参数。
类型提示是一个复杂且不断发展的主题。幸运的是,它们是一个可选功能。让我们保持 Python 对最广泛用户群体的可访问性,并停止宣扬所有 Python 代码都应该有类型提示的说法,就像我在类型提示布道者的公开布道中看到的那样。
我们的退休 BDFL¹⁹领导了 Python 中类型提示的推动,因此这一章的开头和结尾都以他的话语开始:
我不希望有一个我在任何时候都有道义义务添加类型提示的 Python 版本。我真的认为类型提示有它们的位置,但也有很多时候不值得,而且很棒的是你可以选择使用它们。²⁰
Guido van Rossum
进一步阅读
Bernát Gábor 在他的优秀文章中写道,“Python 中类型提示的现状”:
只要值得编写单元测试,就应该使用类型提示。
我是测试的忠实粉丝,但我也做很多探索性编码。当我在探索时,测试和类型提示并不有用。它们只是累赘。
Gábor 的文章是我发现的关于 Python 类型提示的最好介绍之一,还有 Geir Arne Hjelle 的“Python 类型检查(指南)”。Claudio Jolowicz 的“超现代 Python 第四章:类型”是一个更简短的介绍,也涵盖了运行时类型检查验证。
想要更深入的了解,Mypy 文档是最佳来源。它对于任何类型检查器都很有价值,因为它包含了关于 Python 类型提示的教程和参考页面,不仅仅是关于 Mypy 工具本身。在那里你还会找到一份方便的速查表和一个非常有用的页面,介绍了常见问题和解决方案。
typing
模块文档是一个很好的快速参考,但它并没有详细介绍。PEP 483—类型提示理论包括了关于协变性的深入解释,使用Callable
来说明逆变性。最终的参考资料是与类型提示相关的 PEP 文档。已经有 20 多个了。PEP 的目标受众是 Python 核心开发人员和 Python 的指导委员会,因此它们假定读者具有大量先前知识,绝对不是轻松阅读。
如前所述,第十五章涵盖了更多类型相关主题,而“进一步阅读”提供了额外的参考资料,包括表 15-1,列出了截至 2021 年底已批准或正在讨论的类型 PEPs。
“了不起的 Python 类型提示”是一个有价值的链接集合,包含了工具和参考资料。
¹ PEP 484—类型提示,“基本原理和目标”;粗体强调保留自原文。
² PyPy 中的即时编译器比类型提示有更好的数据:它在 Python 程序运行时监视程序,检测使用的具体类型,并为这些具体类型生成优化的机器代码。
³ 例如,截至 2021 年 7 月,不支持递归类型—参见typing
模块问题#182,定义 JSON 类型和 Mypy 问题#731,支持递归类型。
⁴ Python 没有提供控制类型可能值集合的语法—除了在Enum
类型中。例如,使用类型提示,你无法将Quantity
定义为介于 1 和 1000 之间的整数,或将AirportCode
定义为 3 个字母的组合。NumPy 提供了uint8
、int16
和其他面向机器的数值类型,但在 Python 标准库中,我们只有具有非常小值集合(NoneType
、bool
)或极大值集合(float
、int
、str
、所有可能的元组等)的类型。
⁵ 鸭子类型是一种隐式的结构类型形式,Python ≥ 3.8 也支持引入typing.Protocol
。这将在本章后面—“静态协议”—进行介绍,更多细节请参见第十三章。
⁶ 继承经常被滥用,并且很难在现实但简单的示例中证明其合理性,因此请接受这个动物示例作为子类型的快速说明。
⁷ 麻省理工学院教授、编程语言设计师和图灵奖获得者。维基百科:芭芭拉·利斯科夫。
⁸ 更准确地说,ord
仅接受len(s) == 1
的str
或bytes
。但目前的类型系统无法表达这个约束。
⁹ 在 ABC 语言——最初影响 Python 设计的语言中——每个列表都受限于接受单一类型的值:您放入其中的第一个项目的类型。
¹⁰ 我对typing
模块文档的贡献之一是在 Guido van Rossum 的监督下将“模块内容”下的条目重新组织为子部分,并添加了数十个弃用警告。
¹¹ 在一些示例中,我使用:=
是有意义的,但我在书中没有涵盖它。请参阅PEP 572—赋值表达式获取所有详细信息。
¹² 实际上,dict
是abc.MutableMapping
的虚拟子类。虚拟子类的概念在第十三章中有解释。暂时知道issubclass(dict, abc.MutableMapping)
为True
,尽管dict
是用 C 实现的,不继承任何东西自abc.MutableMapping
,而只继承自object
。
¹³ 这里的实现比 Python 标准库中的statistics
模块更简单。
¹⁴ 我向typeshed
贡献了这个解决方案,这就是为什么mode
在statistics.pyi中的注释截至 2020 年 5 月 26 日。
¹⁵ 多么美妙啊,打开一个交互式控制台并依靠鸭子类型来探索语言特性,就像我刚才做的那样。当我使用不支持它的语言时,我非常想念这种探索方式。
¹⁶ 没有这个类型提示,Mypy 会将series
的类型推断为Generator[Tuple[builtins.int, builtins.str*], None, None]
,这是冗长的但与Iterator[tuple[int, str]]
一致,正如我们将在“通用可迭代类型”中看到的。
¹⁷ 我不知道谁发明了术语静态鸭子类型,但它在 Go 语言中变得更加流行,该语言的接口语义更像 Python 的协议,而不是 Java 的名义接口。
¹⁸ REPL 代表 Read-Eval-Print-Loop,交互式解释器的基本行为。
¹⁹ “终身仁慈独裁者”。参见 Guido van Rossum 关于“BDFL 起源”。
²⁰ 来自 YouTube 视频,“Guido van Rossum 关于类型提示(2015 年 3 月)”。引用开始于13’40”。我进行了一些轻微的编辑以提高清晰度。
²¹ 来源:“与艾伦·凯的对话”。