流畅的 Python 第二版(GPT 重译)(四)(2)https://developer.aliyun.com/article/1484437
子类型与一致性
传统的面向对象的名义类型系统依赖于子类型关系。给定一个类T1
和一个子类T2
,那么T2
是T1
的子类型。
考虑这段代码:
class T1: ... class T2(T1): ... def f1(p: T1) -> None: ... o2 = T2() f1(o2) # OK
调用f1(o2)
是对 Liskov 替换原则—LSP 的应用。Barbara Liskov⁷实际上是根据支持的操作定义是子类型:如果类型T2
的对象替代类型T1
的对象并且程序仍然正确运行,那么T2
就是T1
的子类型。
继续上述代码,这显示了 LSP 的违反:
def f2(p: T2) -> None: ... o1 = T1() f2(o1) # type error
从支持的操作的角度来看,这是完全合理的:作为一个子类,T2
继承并且必须支持T1
支持的所有操作。因此,T2
的实例可以在期望T1
的实例的任何地方使用。但反之不一定成立:T2
可能实现额外的方法,因此T1
的实例可能无法在期望T2
的实例的任何地方使用。这种对支持的操作的关注体现在名称行为子类型化中,也用于指代 LSP。
在渐进式类型系统中,还有另一种关系:与一致,它适用于子类型适用的地方,对于类型Any
有特殊规定。
与一致的规则是:
- 给定
T1
和子类型T2
,那么T2
是与T1
一致的(Liskov 替换)。 - 每种类型都与一致
Any
:你可以将每种类型的对象传递给声明为Any
类型的参数。 Any
是与每种类型一致的:你总是可以在需要另一种类型的参数时传递一个Any
类型的对象。
考虑前面定义的对象o1
和o2
,这里是有效代码的示例,说明规则#2 和#3:
def f3(p: Any) -> None: ... o0 = object() o1 = T1() o2 = T2() f3(o0) # f3(o1) # all OK: rule #2 f3(o2) # def f4(): # implicit return type: `Any` ... o4 = f4() # inferred type: `Any` f1(o4) # f2(o4) # all OK: rule #3 f3(o4) #
每个渐进类型系统都需要像Any
这样的通配类型。
提示
动词“推断”是“猜测”的花哨同义词,在类型分析的背景下使用。Python 和其他语言中的现代类型检查器并不要求在每个地方都有类型注释,因为它们可以推断出许多表达式的类型。例如,如果我写x = len(s) * 10
,类型检查器不需要一个显式的本地声明来知道x
是一个int
,只要它能找到len
内置函数的类型提示即可。
现在我们可以探索注解中使用的其余类型。
简单类型和类
像int
、float
、str
和bytes
这样的简单类型可以直接在类型提示中使用。标准库、外部包或用户定义的具体类——FrenchDeck
、Vector2d
和Duck
——也可以在类型提示中使用。
抽象基类在类型提示中也很有用。当我们研究集合类型时,我们将回到它们,并在“抽象基类”中看到它们。
在类之间,一致的定义类似于子类型:子类与其所有超类一致。
然而,“实用性胜过纯粹性”,因此有一个重要的例外情况,我将在下面的提示中讨论。
int 与复杂一致
内置类型int
、float
和complex
之间没有名义子类型关系:它们是object
的直接子类。但 PEP 484声明 int
与float
一致,float
与complex
一致。在实践中是有道理的:int
实现了float
的所有操作,而且int
还实现了额外的操作——位运算如&
、|
、<<
等。最终结果是:int
与complex
一致。对于i = 3
,i.real
是3
,i.imag
是0
。
可选和联合类型
我们在“使用 None 作为默认值”中看到了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
。
Python 3.10 中更好的可选和联合语法
自 Python 3.10 起,我们可以写str | bytes
而不是Union[str, bytes]
。这样打字更少,而且不需要从typing
导入Optional
或Union
。对比show_count
的plural
参数的类型提示的旧语法和新语法:
plural: Optional[str] = None # before plural: str | None = None # after
|
运算符也适用于isinstance
和issubclass
来构建第二个参数:isinstance(x, int | str)
。更多信息,请参阅PEP 604—Union[]的补充语法。
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
是一个简单表达式求值器上下文中合理的用例。
提示
在“双模式 str 和 bytes API”中,我们看到接受str
或bytes
参数的函数,但如果参数是str
则返回str
,如果参数是bytes
则返回bytes
。在这些情况下,返回类型由输入类型确定,因此Union
不是一个准确的解决方案。为了正确注释这样的函数,我们需要一个类型变量—在“参数化泛型和 TypeVar”中介绍—或重载,我们将在“重载签名”中看到。
Union[]
需要至少两种类型。嵌套的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
中放入任何不同类型的混合物。然而,在实践中,这并不是非常有用:如果将对象放入集合中,你可能希望以后对它们进行操作,通常这意味着它们必须至少共享一个公共方法。⁹
可以声明带有类型参数的泛型类型,以指定它们可以处理的项目的类型。
例如,一个 list
可以被参数化以限制其中元素的类型,就像你在 示例 8-8 中看到的那样。
示例 8-8. tokenize
中的 Python ≥ 3.9 类型提示
def tokenize(text: str) -> list[str]: return text.upper().split()
在 Python ≥ 3.9 中,这意味着 tokenize
返回一个每个项目都是 str
类型的 list
。
注释 stuff: list
和 stuff: list[Any]
意味着相同的事情:stuff
是任意类型对象的列表。
提示
如果你使用的是 Python 3.8 或更早版本,概念是相同的,但你需要更多的代码来使其工作,如可选框中所解释的 “遗留支持和已弃用的集合类型”。
PEP 585—标准集合中的泛型类型提示 列出了接受泛型类型提示的标准库集合。以下列表仅显示那些使用最简单形式的泛型类型提示 container[item]
的集合:
list collections.deque abc.Sequence abc.MutableSequence set abc.Container abc.Set abc.MutableSet frozenset abc.Collection
tuple
和映射类型支持更复杂的类型提示,我们将在各自的部分中看到。
截至 Python 3.10,目前还没有很好的方法来注释 array.array
,考虑到 typecode
构造参数,该参数确定数组中存储的是整数还是浮点数。更难的问题是如何对整数范围进行类型检查,以防止在向数组添加元素时在运行时出现 OverflowError
。例如,具有 typecode='B'
的 array
只能容纳从 0 到 255 的 int
值。目前,Python 的静态类型系统还无法应对这一挑战。
现在让我们看看如何注释泛型元组。
元组类型
有三种注释元组类型的方法:
- 元组作为记录
- 具有命名字段的元组作为记录
- 元组作为不可变序列
元组作为记录
如果将 tuple
用作记录,则使用内置的 tuple
并在 []
中声明字段的类型。
例如,类型提示将是 tuple[str, float, str]
,以接受包含城市名称、人口和国家的元组:('上海', 24.28, '中国')
。
考虑一个接受一对地理坐标并返回 Geohash 的函数,用法如下:
>>> shanghai = 31.2304, 121.4737 >>> geohash(shanghai) 'wtw3sjq6q'
示例 8-11 展示了如何定义 geohash
,使用了来自 PyPI 的 geolib
包。
示例 8-11. coordinates.py 中的 geohash
函数
from geolib import geohash as gh # type: ignore # ① PRECISION = 9 def geohash(lat_lon: tuple[float, float]) -> str: # ② return gh.encode(*lat_lon, PRECISION)
①
此注释阻止 Mypy 报告 geolib
包没有类型提示。
②
lat_lon
参数注释为具有两个 float
字段的 tuple
。
提示
对于 Python < 3.9,导入并在类型提示中使用 typing.Tuple
。它已被弃用,但至少会保留在标准库中直到 2024 年。
具有命名字段的元组作为记录
要为具有许多字段的元组或代码中多处使用的特定类型的元组添加注释,我强烈建议使用 typing.NamedTuple
,如 第五章 中所示。示例 8-12 展示了使用 NamedTuple
对 示例 8-11 进行变体的情况。
示例 8-12. coordinates_named.py 中的 NamedTuple
Coordinates
和 geohash
函数
from typing import NamedTuple from geolib import geohash as gh # type: ignore PRECISION = 9 class Coordinate(NamedTuple): lat: float lon: float def geohash(lat_lon: Coordinate) -> str: return gh.encode(*lat_lon, PRECISION)
如“数据类构建器概述”中所解释的,typing.NamedTuple
是tuple
子类的工厂,因此Coordinate
与tuple[float, float]
是一致的,但反之则不成立——毕竟,Coordinate
具有NamedTuple
添加的额外方法,如._asdict()
,还可以有用户定义的方法。
在实践中,这意味着将Coordinate
实例传递给以下定义的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}'
元组作为不可变序列
要注释用作不可变列表的未指定长度元组,必须指定一个类型,后跟逗号和...
(这是 Python 的省略号标记,由三个句点组成,而不是 Unicode U+2026
—水平省略号
)。
例如,tuple[int, ...]
是一个具有int
项的元组。
省略号表示接受任意数量的元素>= 1。无法指定任意长度元组的不同类型字段。
注释stuff: tuple[Any, ...]
和stuff: tuple
意思相同:stuff
是一个未指定长度的包含任何类型对象的元组。
这里是一个columnize
函数,它将一个序列转换为行和单元格的表格,形式为未指定长度的元组列表。这对于以列形式显示项目很有用,就像这样:
>>> 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')] >>> for row in table: ... print(''.join(f'{word:10}' for word in row)) ... drake koala yak fawn lynx zapus heron tahr ibex xerus
示例 8-13 展示了columnize
的实现。注意返回类型:
list[tuple[str, ...]]
示例 8-13. columnize.py返回一个字符串元组列表
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)]
通用映射
通用映射类型被注释为MappingType[KeyType, ValueType]
。内置的dict
和collections
以及collections.abc
中的映射类型在 Python ≥ 3.9 中接受该表示法。对于早期版本,必须使用typing.Dict
和typing
模块中的其他映射类型,如“遗留支持和已弃用的集合类型”中所述。
示例 8-14 展示了一个函数返回倒排索引以通过名称搜索 Unicode 字符的实际用途——这是示例 4-21 的一个变体,更适合我们将在第二十一章中学习的服务器端代码。
给定起始和结束的 Unicode 字符代码,name_index
返回一个dict[str, set[str]]
,这是一个将每个单词映射到具有该单词在其名称中的字符集的倒排索引。例如,在对 ASCII 字符从 32 到 64 进行索引后,这里是映射到单词'SIGN'
和'DIGIT'
的字符集,以及如何找到名为'DIGIT EIGHT'
的字符:
>>> index = name_index(32, 65) >>> index['SIGN'] {'$', '>', '=', '+', '<', '%', '#'} >>> index['DIGIT'] {'8', '5', '6', '2', '3', '0', '1', '4', '7', '9'} >>> index['DIGIT'] & index['EIGHT'] {'8'}
示例 8-14 展示了带有name_index
函数的charindex.py源代码。除了dict[]
类型提示外,这个示例还有三个本书中首次出现的特性。
示例 8-14. charindex.py
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
①
tokenize
是一个生成器函数。第十七章是关于生成器的。
②
局部变量index
已经被注释。没有提示,Mypy 会说:需要为'index'注释类型(提示:“index: dict[, ] = ...”)
。
③
我在if
条件中使用了海象操作符:=
。它将unicodedata.name()
调用的结果赋给name
,整个表达式的值就是该结果。当结果为''
时,为假值,index
不会被更新。¹¹
注意
当将dict
用作记录时,通常所有键都是str
类型,具体取决于键的不同类型的值。这在“TypedDict”中有所涵盖。
抽象基类
在发送内容时要保守,在接收内容时要开放。
波斯特尔法则,又称韧性原则
表 8-1 列出了几个来自 collections.abc
的抽象类。理想情况下,一个函数应该接受这些抽象类型的参数,或者在 Python 3.9 之前使用它们的 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” 中看到的那样。Mypy 会拒绝 UserDict
或从它派生的类的实例,因为 UserDict
不是 dict
的子类;它们是同级。两者都是 abc.MutableMapping
的子类。¹²
因此,一般来说最好在参数类型提示中使用 abc.Mapping
或 abc.MutableMapping
,而不是 dict
(或在旧代码中使用 typing.Dict
)。如果 name2hex
函数不需要改变给定的 color_map
,那么 color_map
的最准确的类型提示是 abc.Mapping
。这样,调用者不需要提供实现 setdefault
、pop
和 update
等方法的对象,这些方法是 MutableMapping
接口的一部分,但不是 Mapping
的一部分。这与 Postel 法则的第二部分有关:“在接受输入时要宽容。”
Postel 法则还告诉我们在发送内容时要保守。函数的返回值始终是一个具体对象,因此返回类型提示应该是一个具体类型,就像来自 “通用集合” 的示例一样—使用 list[str]
:
def tokenize(text: str) -> list[str]: return text.upper().split()
在 typing.List
的条目中,Python 文档中写道:
list
的泛型版本。用于注释返回类型。为了注释参数,最好使用抽象集合类型,如Sequence
或Iterable
。
在 typing.Dict
和 typing.Set
的条目中也有类似的评论。
请记住,collections.abc
中的大多数 ABCs 和其他具体类,以及内置集合,都支持类似 collections.deque[str]
的泛型类型提示符号,从 Python 3.9 开始。相应的 typing
集合仅需要支持在 Python 3.8 或更早版本中编写的代码。变成泛型的类的完整列表出现在 “实现” 部分的 PEP 585—标准集合中的类型提示泛型 中。
结束我们关于类型提示中 ABCs 的讨论,我们需要谈一谈 numbers
ABCs。
数字塔的崩塌
numbers
包定义了在 PEP 3141—为数字定义的类型层次结构 中描述的所谓数字塔。该塔是一种线性的 ABC 层次结构,顶部是 Number
:
Number
Complex
Real
Rational
Integral
这些 ABCs 对于运行时类型检查非常有效,但不支持静态类型检查。PEP 484 的 “数字塔” 部分拒绝了 numbers
ABCs,并规定内置类型 complex
、float
和 int
应被视为特殊情况,如 “int 与 complex 一致” 中所解释的那样。
我们将在 “numbers ABCs 和数字协议” 中回到这个问题,在 第十三章 中,该章节专门对比协议和 ABCs。
实际上,如果您想要为静态类型检查注释数字参数,您有几个选择:
- 使用
int
、float
或complex
中的一个具体类型—正如 PEP 488 建议的那样。 - 声明一个联合类型,如
Union[float, Decimal, Fraction]
。 - 如果想避免硬编码具体类型,请使用像
SupportsFloat
这样的数值协议,详见“运行时可检查的静态协议”。
即将到来的章节“静态协议”是理解数值协议的先决条件。
与此同时,让我们来看看对于类型提示最有用的 ABC 之一:Iterable
。
可迭代对象
我刚引用的 typing.List
文档建议在函数参数类型提示中使用 Sequence
和 Iterable
。
Iterable
参数的一个示例出现在标准库中的 math.fsum
函数中:
def fsum(__seq: Iterable[float]) -> float:
存根文件和 Typeshed 项目
截至 Python 3.10,标准库没有注释,但 Mypy、PyCharm 等可以在 Typeshed 项目中找到必要的类型提示,形式为存根文件:特殊的带有 .pyi 扩展名的源文件,具有带注释的函数和方法签名,但没有实现——类似于 C 中的头文件。
math.fsum
的签名在 /stdlib/2and3/math.pyi 中。__seq
中的前导下划线是 PEP 484 中关于仅限位置参数的约定,解释在“注释仅限位置参数和可变参数”中。
示例 8-15 是另一个使用 Iterable
参数的示例,产生的项目是 tuple[str, str]
。以下是函数的使用方式:
>>> l33t = [('a', '4'), ('e', '3'), ('i', '1'), ('o', '0')] >>> text = 'mad skilled noob powned leet' >>> from replacer import zip_replace >>> zip_replace(text, l33t) 'm4d sk1ll3d n00b p0wn3d l33t'
示例 8-15 展示了它的实现方式。
示例 8-15. 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
①
FromTo
是一个类型别名:我将 tuple[str, str]
赋给 FromTo
,以使 zip_replace
的签名更易读。
②
changes
需要是一个 Iterable[FromTo]
;这与 Iterable[tuple[str, str]]
相同,但更短且更易读。
Python 3.10 中的显式 TypeAlias
PEP 613—显式类型别名引入了一个特殊类型,TypeAlias
,用于使创建类型别名的赋值更加可见和易于类型检查。从 Python 3.10 开始,这是创建类型别名的首选方式:
from typing import TypeAlias FromTo: TypeAlias = tuple[str, str]
abc.Iterable 与 abc.Sequence
math.fsum
和 replacer.zip_replace
都必须遍历整个 Iterable
参数才能返回结果。如果给定一个无限迭代器,比如 itertools.cycle
生成器作为输入,这些函数将消耗所有内存并导致 Python 进程崩溃。尽管存在潜在的危险,但在现代 Python 中,提供接受 Iterable
输入的函数即使必须完全处理它才能返回结果是相当常见的。这样一来,调用者可以选择将输入数据提供为生成器,而不是预先构建的序列,如果输入项的数量很大,可能会节省大量内存。
另一方面,来自示例 8-13 的 columnize
函数需要一个 Sequence
参数,而不是 Iterable
,因为它必须获取输入的 len()
来提前计算行数。
与 Sequence
类似,Iterable
最适合用作参数类型。作为返回类型太模糊了。函数应该更加精确地说明返回的具体类型。
与 Iterable
密切相关的是 Iterator
类型,在 示例 8-14 中用作返回类型。我们将在第十七章中回到这个话题,讨论生成器和经典迭代器。
参数化泛型和 TypeVar
参数化泛型是一种泛型类型,写作 list[T]
,其中 T
是一个类型变量,将在每次使用时绑定到特定类型。这允许参数类型反映在结果类型上。
示例 8-16 定义了sample
,一个接受两个参数的函数:类型为T
的元素的Sequence
和一个int
。它从第一个参数中随机选择的相同类型T
的元素的list
。
示例 8-16 展示了实现。
示例 8-16。sample.py
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]
。
流畅的 Python 第二版(GPT 重译)(四)(4)https://developer.aliyun.com/article/1484439