我学到了一个惨痛的教训:对于小程序来说,动态类型是很棒的。但是对于大型程序,您需要更严格的方法。如果语言给你纪律而不是告诉你“好吧,你可以做任何你想做的事”,这样会对你的程序很有帮助。
------------- Guido van Rossum, a fan of Monty Python
本章是第 8 章的续篇,涵盖更多 Python 的渐进类型系统。主要议题是:
- 重载的函数签名;
- Typing.TypedDict 用于用作记录的类型提示dicts;
- 类型转换
- 运行时访问类型提示
- 泛型类型
- 声明一个泛型类;
- Variance:不变、协变和逆变类型;
- 泛型静态协议。
本章是 Fluent Python,第二版中的新增内容。让我们从重载开始。
重载签名Python 函数可以接受不同的参数组合。 使用@typing.overload 装饰器可以注解这些组合。当函数的返回值类型取决于两个或多个参数的类型时,这一点尤为重要。
例如 sum 内置函数。这是help(sum)的帮助文档:
>>> help(sum)
sum(iterable, /, start=0)
Return the sum of a 'start' value (default: 0) plus an iterable of numbers
When the iterable is empty, return the start value.
This function is intended specifically for use with numeric values and may
reject non-numeric types.
sum 内置函数是用 C 编写的,但是 typeshed 在 builtins.pyi 中为它提供了重载方法的类型提示:
@overload def sum(__iterable: Iterable[_T]) -> Union[_T, int]: ... @overload def sum(__iterable: Iterable[_T], start: _S) -> Union[_T, _S]: ...
首先让我们看看重载的整体语法。这就是您可以在存根文件 (.pyi) 中找到的所有关于 sum 的代码。实现存放在另一个文件中。省略号 ... 除了满足函数体的语法要求外不提供其他功能,类似于 pass。所以 .pyi 文件是有效的 Python 文件。
正如 “Annotating positional-only and variadic parameters”中所述,__iterable 中的两个前导下划线是 PEP 484 约定,用于由 Mypy 强制执行的仅限位置参数。这意味着您可以调用 sum(my_list),但不能调用 sum(__iterable = my_list)。
类型检查器尝试将给定的参数与每个重载的签名按顺序进行匹配。调用 sum(range(100), 1000) 与第一个重载方法的参数不匹配,因为该签名只有一个参数。但它匹配第二个重载的方法。
您还可以在常规 Python 模块中使用 @overload,并在函数的实际签名和实现之前编写函数的重载签名。示例 15-1 显示了 sum 如何在 Python 模块中进行注解和实现。
例 15-1。 mysum.py:具有重载签名的 sum 函数的定义:
import functools
import operator
from collections.abc import Iterable
from typing import overload, Union, TypeVar
T = TypeVar('T')
S = TypeVar('S') 1
@overload
def sum(it: Iterable[T]) -> Union[T, int]: ... 2
@overload
def sum(it: Iterable[T], /, start: S) -> Union[T, S]: ... 3
def sum(it, /, start=0): 4
return functools.reduce(operator.add, it, start)
- 我们需要在第二个重载方法中使用第二个 TypeVar。
- 此签名适用于简单情况:sum(my_iterable)。结果类型可能是 T——my_iterable 产生的元素的类型——或者当 iterable 为空时它可能是 int,因为 start 参数的默认值是 0。
- 当给定 start 时,它可以是任何类型 S,因此返回值类型是 Union[T, S]。这就是我们需要 S 的原因。如果我们重复使用 T,那么 start 的类型必须与 Iterable[T] 的元素类型相同。
- 实际函数实现的签名没有类型提示。
注解一个单行函数往往需要多行。这可能存在矫枉过正。可以弄清楚的是至少它不是一个 foo 函数。
如果你想通过阅读代码来了解@overload,typeshed 有数百个例子。在 typeshed 上,Python 内置程序的stub file在我写这篇文章时有 186 个重载——比标准库中的任何其他文件都多。
利用渐进式类型
将注解率提高到100% 的目标可能会导致,类型提示增加了很多噪音却没有什么价值。重构以简化类型提示可能会导致 API 变得繁琐。所有有时最好务实,留下一段没有类型提示的代码。
我们称之为 Pythonic 的方便的 API 通常很难进行注解。在下一节中,我们将看到一个示例:需要六个重载才能正确注解灵活的 max 内置函数。
重载max函数向利用 Python 强大动态特性而实际的函数添加类型提示是困难的。
在研究 typeshed 时,我发现了错误报告 (#4051):Mypy 未能警告将 None 作为参数之一传递给内置 max() 函数是非法的,或者传递在某些时候产生 None 的可迭代对象是非法的。在任何一种情况下,程序都会抛出类似这样的运行时异常:
TypeError: '>' not supported between instances of 'int' and 'NoneType'
max 的文档的开头是这样说明的:
返回可迭代对象中最大的项或者两个或多个参数中最大的项。
对我来说,这是一个非常直观的描述。
但是,如果我必须注解用这段术语描述的函数,我不得不问:参数是什么?一个可迭代的对象还是两个或更多的参数?
实际情况更复杂,因为 max 还接受两个可选的关键字参数:key 和 default。
我在 Python 中对 max 进行了实现,以便更容易地看到它的工作方式与重载注解之间的关系(内置 的max方法是使用C实现的)。
例 15-2。 mymax.py:使用python 重写 max 函数。
# imports and definitions omitted, see next listing
MISSING = object()
EMPTY_MSG = 'max() arg is an empty sequence'
# overloaded type hints omitted, see next listing
def max(first, *args, key=None, default=MISSING):
if args:
series = args
candidate = first
else:
series = iter(first)
try:
candidate = next(series)
except StopIteration:
if default is not MISSING:
return default
raise ValueError(EMPTY_MSG) from None
if key is None:
for current in series:
if candidate < current:
candidate = current
else:
candidate_key = key(candidate)
for current in series:
current_key = key(current)
if candidate_key < current_key:
candidate = current
candidate_key = current_key
return candidate
这个例子的重点不是max的逻辑,所以除了解释MISSING之外,我不会花时间去解释它的实现。MISSING 常量是用作标记的唯一对象实例。它是 default=关键字参数的默认值,因此 max 可以接受 default=None 并且仍然区分这两种情况:
- 用户没有为 default= 提供值,那么default值就是 MISSING,如果 first 是一个空的可迭代对象,max 会抛出 ValueError异常。
- 用户为 default= 提供了一些值,包括 None,因此如果 first 是一个空的可迭代对象,则 max 返回该值。以便用户可以提供 None 作为默认值,并且代码可以将其作为用户定义的 default= 值来处理,而不是按照异常处理
为了修复问题 #4051,我编写了示例 15-3 中的代码。
例 15-3。 mymax.py:模块的顶部,包含导入、定义和重载。
from collections.abc import Callable, Iterable
from typing import Protocol, Any, TypeVar, overload, Union
class SupportsLessThan(Protocol):
def __lt__(self, other: Any) -> bool: ...
T = TypeVar('T')
LT = TypeVar('LT', bound=SupportsLessThan)
DT = TypeVar('DT')
MISSING = object()
EMPTY_MSG = 'max() arg is an empty sequence'
@overload
def max(__arg1: LT, __arg2: LT, *args: LT, key: None = ...) -> LT:
...
@overload
def max(__arg1: T, __arg2: T, *args: T, key: Callable[[T], LT]) -> T:
...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...) -> LT:
...
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT]) -> T:
...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...,
default: DT) -> Union[LT, DT]:
...
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT],
default: DT) -> Union[T, DT]:
...
我的max函数的 Python 实现与所有import和声明的长度大致相同。多亏了鸭子类型,我的代码没有 isinstance 检查,并提供与那些类型提示相同的错误检查——当然,只是在运行时。
@overload 的一个主要好处是根据给定的参数类型尽可能精确地声明返回类型。接下来,我们将通过以一两个为一组研究 max 的重载来看到这种好处。
参数实现了SupportsLessThan ,但未提供key和default参数
@overload
def max(__arg1: LT, __arg2: LT, *_args: LT, key: None = ...) -> LT:
...
# ... lines omitted ...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...) -> LT:
...
在这些情况下,输入要么是实现 SupportsLessThan 的 LT 类型的单独参数,要么是由LT类型实例构成的可迭代对象。max 的返回值类型与实际参数或可迭代对象中的项相同,正如我们在“Bounded TypeVar”中看到的那样。
匹配这些重载的示例调用:
max(1, 2, -3) # returns 2 max(['Go', 'Python', 'Rust']) # returns 'Rust'
提供了参数key,但没有提供default
@overload
def max(__arg1: T, __arg2: T, *_args: T, key: Callable[[T], LT]) -> T:
...
# ... lines omitted ...
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT]) -> T:
...
输入可以是任何类型 T 的单独项或一个 Iterable[T],并且 key= 必须是一个可调用对象,它接受相同类型 T 的参数,并返回一个实现 SupportsLessThan 的值。max 的返回类型与实际参数相同。
匹配这些重载的示例调用:
max(1, 2, -3, key=abs) # returns -3 max(['Go', 'Python', 'Rust'], key=len) # returns 'Python'
提供了参数default,但没有提供key
@overload
def max(__iterable: Iterable[LT], *, key: None = ...,
default: DT) -> Union[LT, DT]:
...
输入是实现 SupportsLessThan 的 LT 类型项的可迭代对象。default= 参数是 Iterable 为空时的返回值。因此,max 的返回类型必须是 LT 类型或default参数的类型的 Union 。
匹配这些重载的示例调用:
max([1, 2, -3], default=0) # returns 2 max([], default=None) # returns None
提供参数key和default
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT],
default: DT) -> Union[T, DT]:
...
输入是:
- 由T类型实例组成的可迭代对象
- 一个可调用对象,它接受一个 T 类型的参数并返回一个实现 SupportsLessThan 的 LT 类型的值;
- 任何类型为 DT 的默认值。
max 的返回类型必须是类型 T 或者或default参数的类型的 Union 。
max([1, 2, -3], key=abs, default=None) # returns -3 max([], key=abs, default=None) # returns None重载max函数的要点
类型提示允许 Mypy 使用以下错误消息标记像 max([None, None]) 这样的调用:
mymax_demo.py:109: error: Value of type variable "_LT" of "max" cannot be "None"
另一方面,不得不写这么多行代码来支持类型检查器可能会让编码人员不想编写像 max.txt 这样方便灵活的函数。如果我也必须重新发明 min 函数,我可以重构和重用 max 的大部分实现。但是我必须复制并粘贴所有重载的声明——即使它们除了函数名称以外对于 min 都是相同的。
我的朋友 João S. O. Bueno——我认识的最聪明的 Python 开发者之一——在推特上写道:
虽然很难表达 max 的签名是什么,但它很容易在一个人的脑海中出现。我的理解是,与 Python 本身相比,注解标记的表达能力非常有限。
现在让我们研究 TypedDict 类型结构。它没有我最初想象的那么有用,但也有独特的使用场景。使用 TypedDict 进行实验演示了静态类型在处理 JSON 数据等动态结构方面的局限性。
TypedDictWarning:
在处理 JSON API 响应等动态数据结构时,很容易使用 TypedDict 来防止错误。但是这里的示例清楚地表明,必须在运行时才能正确处理 JSON,而不是使用静态类型检查。要使用类型提示对类 JSON 结构进行运行时检查,请查看 PyPI 上的 pydantic 包。
Python 字典有时用作记录,键用作不同类型的字段名称和字段值。
例如,考虑用 JSON 或 Python 描述一本书的记录:
{"isbn": "0134757599",
"title": "Refactoring, 2e",
"authors": ["Martin Fowler", "Kent Beck"],
"pagecount": 478}
在 Python 3.8 之前,没有像这样注解记录的好方法,因为我们在“通用映射”中看到的映射类型将所有值限制为具有相同的类型。
以下是对像上面的 JSON 对象这样的记录进行注解的两次尝试:
Dict[str, Any]:字典的值可以是任何类型。
Dict[str, Union[str, int, List[str]]]:难以阅读,并且不保留字段名称与其各自字段类型之间的关系:title 应该是str类型,它不能是 int 或 List[str]。
PEP 589—TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys 解决了这个问题。这是一个简单的 TypedDict:
from typing import TypedDict
class BookDict(TypedDict):
isbn: str
title: str
authors: list[str]
pagecount: int
乍一看,typing.TypedDict 似乎是一个数据类构建器,类似于第 5 章中介绍的 Typing.NamedTuple。
句法相似性具有误导性。 TypedDict 非常不同。它的存在只是为了支持类型检查器,并且没有运行时效果。
TypedDict 提供了两件事:
- 类似类的语法,用每个“字段”的值的类型提示来注解 dict。
- 一个构造函数来告诉类型检查器期望一个具有指定键和值的字典。
在运行时,像 BookDict 这样的 TypedDict 构造函数是只是个安慰:它与使用相同参数调用 dict 构造函数具有相同的效果。
BookDict 创建一个普通 dict 的事实也意味着:
-
伪类定义中的“字段”不创建实例属性。
-
您不能为“字段”编写具有默认值的初始值设定项。
-
不允许定义方法。
让我们探索 BookDict 在运行时的行为。
例 15-5。使用 BookDict,但不完全符合预期。
>>> from books import BookDict
>>> pp = BookDict(title='Programming Pearls', 1
... authors='Jon Bentley', 2
... isbn='0201657880',
... pagecount=256)
>>> pp 3
{'title': 'Programming Pearls', 'authors': 'Jon Bentley', 'isbn': '0201657880',
'pagecount': 256}
>>> type(pp)
>>> pp.title 4
Traceback (most recent call last):
File "", line 1, in
AttributeError: 'dict' object has no attribute 'title'
>>> pp['title']
'Programming Pearls'
>>> BookDict.__annotations__ 5
{'isbn': , 'title': , 'authors': typing.List[str],
'pagecount': }
- 您可以像使用关键字参数的 dict 构造函数一样调用 BookDict构造函数,或者传递一个 dict 参数——包括一个 dict 字面量。
- 哎呀...我忘了authors是一个列表。但是渐进式类型意味着在运行时没有类型检查。
- 调用 BookDict 的结果是一个普通的 dict…
- ...因此您无法使用 object.field 表示法读取数据。
- 类型提示在 BookDict.__annotations__ 中,而不是在 pp 中。
没有类型检查器,TypedDict 和注释一样有用:它可以帮助人们阅读代码,但仅此而已。相比之下,即使您不使用类型检查器,第 5 章中的类构建器也很有用,因为它们在运行时会生成或增强您可以实例化的自定义类。它们还提供了表 5-1 中列出的几种有用的方法或函数。
示例 15-6 构建了一个有效的 BookDict 并尝试对其进行一些操作。这展示了 TypedDict 如何让 Mypy 捕获错误,如例 15-7 所示。
from books import BookDict
from typing import TYPE_CHECKING
def demo() -> None: 1
book = BookDict( 2
isbn='0134757599',
title='Refactoring, 2e',
authors=['Martin Fowler', 'Kent Beck'],
pagecount=478
)
authors = book['authors'] 3
if TYPE_CHECKING: 4
reveal_type(authors) 5
authors = 'Bob' 6
book['weight'] = 4.2
del book['title']
if __name__ == '__main__':
demo()
- 记得添加返回类型,这样 Mypy 就不会忽略该函数。
- 这是一个有效的 BookDict:所有键都存在,具有正确类型的值。
- Mypy 将根据 BookDict 中“authors”键的注解推断authors的类型。
- Typing.TYPE_CHECKING 仅在程序被类型检查时为 True。在运行时,它的值为False。
- 前面的 if 语句不会让reveal_type(authors) 在运行时调用。Reveal_type 不是运行时 Python 函数,而是 Mypy 提供的调试工具。这就是为什么没有导入它的原因。请参阅示例 15-7 中的输出。
-
演示功能的最后三行是非法的。它们将导致示例 15-7 中的错误消息。
例 15-7。对 demo_books.py进行类型检查。
…/typeddict/ $ mypy demo_books.py
demo_books.py:13: note: Revealed type is 'built-ins.list[built-ins.str]' 1
demo_books.py:14: error: Incompatible types in assignment
(expression has type "str", variable has type "List[str]") 2
demo_books.py:15: error: TypedDict "BookDict" has no key 'weight' 3
demo_books.py:16: error: Key 'title' of TypedDict "BookDict" cannot be deleted 4
Found 3 errors in 1 file (checked 1 source file)
- 这个note是reveal_type(authors) 的结果
- author 变量的类型是从初始化它的 book['authors'] 表达式的类型推断出来的。您不能将一个 str 分配给 List[str] 类型的变量。类型检查器通常不允许改变变量的类型。
- 无法指定不属于 BookDict 定义的键。
- 无法删除在 BookDict 中定义的键。
现在让我们看看函数签名中使用的 BookDict,对函数调用进行类型检查。
想象一下,您需要从书籍记录生成 XML,类似于:
0134757599 Refactoring, 2e Martin Fowler Kent Beck478
如果您正在编写要嵌入微型微控制器的 MicroPython 代码,您可能会编写这样的函数:
例 15-8。 book.py: to_xml 函数。
AUTHOR_ELEMENT = '{}'
def to_xml(book: BookDict) -> str: 1
elements: list[str] = [] 2
for key, value in book.items():
if isinstance(value, list): 3
elements.extend(
AUTHOR_ELEMENT.format(n) for n in value) 4
else:
tag = key.upper()
elements.append(f'<{tag}>{value}{tag}>')
xml = 'nt'.join(elements)
return f'nt{xml}n '
- 该示例的重点是:在函数签名中使用 BookDict。
- 通常需要对空集合进行注解,否则 Mypy 无法推断元素的类型。
- Mypy 理解 isinstance 检查,并将 value 视为块中的list。
- 当我使用 key == 'authors' 作为 if 语句中的条件时,Mypy在这一行发现了一个错误:"object" has no attribute "__iter__",因为它将 book.items() 返回的值类型推断为 object,object不支持生成器表达式所需的 __iter__ 方法。使用 isinstance 检查是有效的,因为 Mypy 知道 value 是块中的列表。
这是一个解析 JSON str 并返回 BookDict 的函数:
def from_json(data: str) -> BookDict:
whatever = json.loads(data) 1
return whatever 2
- json.loads() 的返回值类型是 Any
- 我可以返回任何类型的 Any 类型,因为 Any 与每种类型一致,包括声明的返回类型 BookDict。
示例 15-9 的第二点非常重要,要记住:Mypy 不会标记这段代码中的任何问题,但在运行时,whatever 中的值可能不符合 BookDict 结构——事实上,它可能根本就不是一个 dict!
如果您使用 --disallow-any-expr 运行 Mypy,它会对 from_json 正文中的两行发出报警:
…/typeddict/ $ mypy books_any.py --disallow-any-expr books_any.py:30: error: expression has type "Any" books_any.py:31: error: expression has type "Any" Found 2 errors in 1 file (checked 1 source file)
上面提到的第 30 和 31 行是 from_json 函数的主体。我们可以通过在 whatever 变量的初始化中添加类型提示来消除类型错误,如示例 15-10 所示:
例 15-10。 book.py:带有变量注解的 from_json 函数。
def from_json(data: str) -> BookDict:
whatever: BookDict = json.loads(data) 1
return whatever 2
- --disallow-any-expr 在将 Any 类型的表达式立即赋值给具有类型提示的变量时不会抛出错误。
- 现在whatever是 BookDict 类型,也就是声明的返回类型
Warning:
不要认为示例 15-10 的类型安全的是正确的!查看静态代码,类型检查器无法预测 json.loads() 将返回一个表现为 BookDict 的值。只有运行时验证才能保证这一点。
静态类型检查无法防止动态代码的错误,例如 json.loads(),它在运行时构建不同类型的 Python 对象。示例 15-11、示例 15-12 和示例 15-13 演示了这一点。
例 15-11。 demo_not_book.py: from_json 返回无效的 BookDict,to_xml方法接受了这个值。
from books import to_xml, from_json
from typing import TYPE_CHECKING
def demo() -> None:
NOT_BOOK_JSON = """
{"title": "Andromeda Strain",
"flavor": "pistachio",
"authors": true}
"""
not_book = from_json(NOT_BOOK_JSON) 1
if TYPE_CHECKING: 2
reveal_type(not_book)
reveal_type(not_book['authors'])
print(not_book) 3
print(not_book['flavor']) 4
xml = to_xml(not_book) 5
print(xml) 6
if __name__ == '__main__':
demo()
- 此行不会生成正确的 BookDict — 请参阅 NOT_BOOK_JSON 的内容。
- 让我们让 Mypy 揭示一些变量的类型。
- 这应该不是问题:print 函数可以处理object和所有其他类型。
- BookDict 没有 'flavor' 键,但 JSON 源数据有......这会发生什么?
- 记住to_xml的方法签名: def to_xml(book: BookDict) -> str:
- XML 输出会是什么样子?
使用 Mypy 检查 demo_not_book.py:
例 15-12。 demo_not_book.py 的 Mypy 报告,为了清晰起见做了格式化处理。
…/typeddict/ $ mypy demo_not_book.py
demo_not_book.py:12: note: Revealed type is
'TypedDict('books.BookDict', {'isbn': built-ins.str,
'title': built-ins.str,
'authors': built-ins.list[built-ins.str],
'pagecount': built-ins.int})' 1
demo_not_book.py:13: note: Revealed type is 'built-ins.list[built-ins.str]' 2
demo_not_book.py:16: error: TypedDict "BookDict" has no key 'flavor' 3
Found 1 error in 1 file (checked 1 source file)
- 显示的类型是名义类型,而不是 not_book 的运行时内容。
- 同样,这是 not_book['authors'] 的名义类型,如 BookDict 中所定义。不是运行时类型。
- 此错误适用于行 print(not_book['flavor']): 该键在名义类型中并不存在。
现在让我们运行 demo_not_book.py。
例 15-13。运行 demo_not_book.py 的输出。
…/typeddict/ $ python3 demo_not_book.py
{'title': 'Andromeda Strain', 'flavor': 'pistachio', 'authors': True} 1
pistachio 2
3
Andromeda Strain
pistachio
True
- 这不是一个真正的 BookDict。
- not_book['flavor'] 的值。
- to_xml 采用 BookDict 参数,但没有运行时检查:未按预期的输入就会导致未按预期输出。
示例 15-13 显示 demo_not_book.py 的输出是没有意义的,但没有抛出运行时错误。在处理 JSON 数据时使用 TypedDict 并没有提供太多的类型安全。
如果您通过鸭子类型的视角查看示例 15-8 中的 to_xml 代码,那么参数 book 必须提供一个 .items() 方法,该方法返回一个可迭代的元组,如 (key, value) 其中:
- key 必须有一个 .upper() 方法;
- value可以是任何类型
本次演示的重点:在处理具有动态结构的数据时,例如 JSON 或 XML,TypedDict 绝对不能替代运行时的数据验证。为此,请使用 pydantic.
TypedDict 具有更多功能,包括支持可选键、有限形式的继承和替代声明语法。
如果您想了解更多信息,请查看 PEP 589—TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys.
现在让我们把注意力转向一个最好不使用但有时不可避免使用的函数:typing.cast。
类型转换没有任何类型系统是完美的,静态类型检查器、typeshed 项目中的类型提示或包含它们的第三方包中的类型提示也不是完美的。
typing.cast() 特殊函数提供了一种方法来处理我们无法修复的代码中的类型检查故障或错误的类型提示。
Mypy 文档解释了:
强制转换用于消除虚假的类型检查器警告,并在类型检查器不能完全理解正在发生的事情时给它一点帮助。
在运行时,typing.cast 没有任何效用。这是它的实现:
def cast(typ, val):
"""Cast a value to a type.
This returns the value unchanged. To the type checker this
signals that the return value has the designated type, but at
runtime we intentionally don't check anything (we want this
to be as fast as possible).
"""
return val
PEP 484 要求类型检查器“相信”cast中规定的类型。 PEP 484 的 Casts 部分给出了一个类型检查器需要 cast 指导的示例:
from typing import cast
def find_first_str(a: list[object]) -> str:
index = next(i for i, x in enumerate(a) if isinstance(x, str))
# We only get here if there's at least one string
return cast(str, a[index])
对生成器表达式的 next() 调用将返回 类型为str 项的索引或抛出 StopIteration异常。因此,如果没有抛出异常, find_first_str 将始终返回一个 str ,并且 str 是声明的返回类型。
但是如果最后一行返回的是 a[index],Mypy 会将返回类型推断为 object,因为参数a被声明为 list[object]。所以需要使用 cast() 来引导 Mypy。
这是另一个使用 cast 的示例,这次是为了更正 Python 标准库的过时的类型提示。在示例 21-12 中,我创建了一个 asyncio Server 对象,我想获取服务器正在侦听的地址。我写了这行代码:
addr = server.sockets[0].getsockname()
但是Mypy报了这个错误:
Value of type "Optional[List[socket]]" is not indexable
2021 年 5 月 typeshed 上 Server.sockets 的类型提示对 Python 3.6 有效,其中 sockets 属性可以为 None。但是在 Python 3.7 中,sockets变成了一个带有 getter 的属性,它总是返回一个列表——如果服务器没有套接字,它可能是空的。从 Python 3.8 开始,getter 返回一个元组(用作不可变序列)。
由于我现在无法修复 typeshed8,我添加了cast,如下所示:
from asyncio.trsock import TransportSocket
from typing import cast
# ... many lines omitted ...
socket_list = cast(tuple[TransportSocket, ...], server.sockets)
addr = socket_list[0].getsockname()
在这种情况下使用 cast 需要几个小时来理解问题并阅读 asyncio 源代码以找到正确的套接字类型:来自未记录的 asyncio.trsock 模块的 TransportSocket 类。我还不得不添加两个导入语句和另一行代码以提高可读性。但这样做代码更安全。
细心的读者可能会注意到,如果 sockets 为空,sockets[0] 可能会引发 IndexError。但是,据我对asyncio的了解 ,这不会发生在示例 21-12 中,因为在我读取其 sockets 属性时服务器已准备好接受连接,因此它不会为空。无论如何,IndexError 是一个运行时错误。即使在像 print([][0]) 这样的错误的情况下,Mypy 也无法发现问题。
Warning:
不要太过依赖使用强制转换来使 Mypy 静音,因为 Mypy 通常在报告错误时是正确的。如果您经常使用强制转换,那就是代码异味。您的团队可能滥用类型提示,或者您的代码库中可能存在低质量的依赖项。
尽管有缺点,cast函数还是有有效的用途。以下是 Guido van Rossum 写的关于它的内容:
偶尔的 cast() 调用或 在注释中写 type: ignore 又有什么问题?
完全禁止使用 cast 是不明智的,尤其是因为其他解决方法更糟:
- # type: ignore的信息过少
- 使用 Any 是会传染的:由于 Any 与所有类型一致,滥用它可能会通过类型推断产生级联效应,从而削弱类型检查器检测代码其他部分错误的能力。
当然,并不是所有的类型错误都可以通过cast来解决。有时我们需要#type:ignore,偶尔的Any,甚至不带类型提示的函数。
接下来,我们来谈谈在运行时使用注解。
运行时读取类型提示在导入时,Python 读取函数、类和模块中的类型提示,并将它们存储在名为 __annotations__ 的属性中。例如,查看示例 15-14中的clip函数.
例 15-14。 clip_annot.py:clip函数的带注解的签名
def clip(text: str, max_len: int = 80) -> str:
类型提示以字典的形式存储在函数的 __annotations__ 属性中:
>>> from clip_annot import clip
>>> clip.__annotations__
{'text': , 'max_len': , 'return': }
'return' 键映射到示例 15-14 中 -> 符号之后的返回值类型提示。
请注意,注解在导入时由解释器进行计算,就像参数默认值在导入时计算一样。这就是为什么注解中的值是 Python str 和 int类 ,而不是字符串 'str' 和 'int' 的原因。注解的导入时计算是 Python 3.9 甚至 Python 3.10(截至 2021 年 8 月未发布)中的标准,这是在 2006 年引入注释语法时 PEP 3107 中描述的行为。
运行时注解的问题大量使用类型提示带来了两个问题:
- 当使用大量类型提示时,导入模块会耗费更多的 CPU 和内存。
- 引用尚未定义的类型需要使用字符串而不是实际类型。
这两个问题都是相关的。第一个是因为我们刚刚看到的:注解在导入时由解释器计算并存储在 __annotations__ 属性中。现在让我们关注第二个问题。
由于“超前引用”问题,有时需要将注解存储为字符串:当类型提示需要引用在同一模块中还未定义的类时(也就是在类型提示的下方)。但是,源代码中问题的常见表现看起来并不像超前引用:那就是当一个方法返回所在类的一个新对象时。由于在 Python在未计算完成类主体之前不会定义类对象,因此类型提示必须使用类的名称作为字符串。下面是一个例子:
class Rectangle:
# ... lines omitted ...
def stretch(self, factor: float) -> 'Rectangle':
return Rectangle(width=self.width * factor)
从 Python 3.10 开始,将超前引用的类型提示编写为字符串是标准和必需的实践。静态类型检查器从一开始就旨在处理该问题。
但是在运行时,如果您编写代码来读取stretch方法的return的注解,您将获得一个字符串 'Rectangle' 而不是对实际类型 Rectangle 类的引用。现在您的代码需要弄清楚该字符串的含义。
typing模块包括三个函数和一个归类为Introspection helpers的类,最重要的是typing.get_type_hints。它的文档是这样描述的:
get_type_hints(obj, globals=None, locals=None, include_extras=False)
[…] 这通常与 obj.__annotations__ 相同。此外,编码为字符串字面量的超前引用是通过在 globals 和 locals 命名空间中计算出对应的类型来处理的。 […]
Warning:
自 Python 3.10 起,应使用新的 inspect.get_annotations(...) 函数代替typing.get_type_hints。但是,有些读者可能还没有使用 Python 3.10,因此在示例中我将使用 Typing.get_type_hints,它在 Python 3.5 中添加了 Typing 模块后就可以使用。
PEP 563—Postponed evaluation of Annotations已获批准,以便不必将注解编写为字符串,并减少类型提示的运行时成本。它的主要思想在摘要的这两句话中是这样描述的:
此 PEP 建议更改函数注解和变量注解,以便不再在函数定义时计算它们。相反,它们以字符串形式保存在__annotations__中。
从 Python 3.7 开始,这就是以 import 语句开头的模块中处理注解的方式:
from __future__ import annotations
为了演示它的效果,我将示例 15-14 中的相同clip函数的副本放在 clip_annot_post.py 模块顶部的 __future__ 导入行。
在控制台,当您导入该模块并从clip方法中读取注解时,会得到以下信息:
>>> from clip_annot_post import clip
>>> clip.__annotations__
{'text': 'str', 'max_len': 'int', 'return': 'str'}
如您所见,所有类型提示现在都是纯字符串,尽管在clip方法的定义中的类型提示并未写为带引号的字符串(示例 15-14)。
Typing.get_type_hints 函数能够解析许多种类型提示,也包括clip函数中的类型提示:
>>> from clip_annot_post import clip
>>> from typing import get_type_hints
>>> get_type_hints(clip)
{'text': , 'max_len': , 'return': }
调用 get_type_hints 为我们提供了真正的类型——即使在某些情况下,原始类型提示被写为带引号的字符串。这是在运行时读取类型提示的推荐方式。
PEP 563 的行为计划在 Python 3.10 中成为默认行为——不需要再从 __future__ 导入。但是,FastAPI 和 pydantic 的维护者发出警报,表示更改会破坏他们依赖于运行时类型提示的代码,并且无法保证 get_type_hints的可靠性。
在随后对 python-dev 邮件列表的讨论中,Łukasz Langa(PEP 563 的作者)描述了该功能的一些限制:
[...] 事实证明,typing.get_type_hints() 有一些限制,这使得它在运行时的使用成本通常很高,更重要的是不能解析所有类型。最常见的示例处理生成类型的非全局上下文(例如内部类、函数内的类等)。但是超前引用的主要问题示例之一:如果使用类生成器,则Typing.get_type_hints() 不能正确处理具有接受或返回其自身类型对象的方法的类 。我们可以做一些技巧来连接这些点,但总的来说它不是很好。
Python 指导委员会决定推迟在Python 3.11 或更高版本再将 PEP 563 设为默认行为,为开发人员提供更多时间来提出解决 PEP 563 试图解决的问题的解决方案,而不会破坏运行时类型提示的广泛使用。PEP 649—Deferred evaluation Of Annotations Using Descriptors 正在被定为解决方案中,但可能会达成不同的折衷方案。
总结一下:从 Python 3.10 开始,在运行时读取类型提示并不是 100% 可靠的,并且可能会在 2022 年发生变化。
Note:
大规模使用 Python 的公司想要理由静态类型带来的好处,但他们不想在导入时为类型提示的计算付出代价。静态检查在开发人员工作站和专用 CI 服务器上执行,但在生产容器中加载模块的频率和比重要高得多,而且这种成本在规模上是不可忽略的。
这在 Python 社区中造成了那些希望将类型提示仅存储为字符串的人之间的紧张关系——以降低加载阶段的消耗,与那些也想在运行时使用类型提示的人相比,比如 pydantic 和 FastAPI 的创建者和用户,他们宁愿存储类型对象而不必在将来执行这些注解,这是一项具有挑战性的任务。
处理问题鉴于目前不稳定的情况,如果你需要在运行时读取注解我建议:
- 不要直接读取 __annotations__属性;相反,使用inspect.get_annotations(来自Python 3.10)或typing.get_type_hints(自Python 3.5引入)。
- 编写一个您自己的自定义函数作为inspect.get_annotations 或typing.get_type_hints 的包装器,并让您的代码库的其余部分调用这个自定义函数,以便将来的更改可以本地化为单个函数。
为了演示第二点,这里是示例 24-5 中定义的 Checked 类的起始行,我们将在第 24 章中学习。
class Checked:
@classmethod
def _fields(cls) -> dict[str, type]:
return get_type_hints(cls)
# ... more lines ...
Checked._fields 类方法使得模块的其他部分不直接依赖于typing.get_type_hints。如果将来 get_type_hints 引入额外的逻辑,或者您想用 inspect.get_annotations 替换它,那么只需要更改 Checked._fields方法就可以了,这样不会影响程序的其余部分。
Warning:
鉴于对类型提示的运行时检查正在进行的讨论和提议的更改,官方的 Annotations Best Practices 文档是必读的,并且可能会在将来的 Python 3.11 上更新。该指南由 Larry Hastings 撰写,他是 PEP 649—Deferred evaluation Of Annotations Using Descriptors作者,这是解决 PEP 563—Postponed evaluation of Annotations所引发的运行时问题的替代提案。
本章的其余部分涵盖泛型,我们从如何定义可被用户参数化的泛型类开始。
实现泛型类在示例 13-7 中,我们定义了 Tombola ABC:一个像Bingo Cage一样的类的接口。示例 13-10 中的 LottoBlower 类是它的一个具体实现。现在我们将研究 LottoBlower 的泛型版本,如下所示:
例 15-15。 generic_lotto_demo.py:使用泛型的LottoBlower类
from generic_lotto import LottoBlower machine = LottoBlower[int](range(1, 11)) 1 first = machine.pick() 2 remain = machine.inspect() 3
- 为了实例化一个泛型类,我们给它一个实际的类型参数,就像这里的 int。
- Mypy 将正确推断出 first 是一个 int ......
- ……remain的就是一个整数元组。
此外,Mypy 会通过有用的消息报告参数化类型的违规行为,例如:
例 15-16。 generic_lotto_errors.py:Mypy 报告的错误
from generic_lotto import LottoBlower
machine = LottoBlower[int]([1, .2])
## error: List item 1 has incompatible type "float"; 1
## expected "int"
machine = LottoBlower[int](range(1, 11))
machine.load('ABC')
## error: Argument 1 to "load" of "LottoBlower" 2
## has incompatible type "str";
## expected "Iterable[int]"
## note: Following member(s) of "str" have conflicts:
## note: Expected:
## note: def __iter__(self) -> Iterator[int]
## note: Got:
## note: def __iter__(self) -> Iterator[str]
- 在 LottoBlower[int] 实例化时,Mypy 标记浮点数的问题。
- 当调用 .load('ABC') 时,Mypy 解释了为什么 str 不可以作为参数:str.__iter__ 返回一个 Iterator[str],但 LottoBlower[int] 需要一个 Iterator[int]。
示例 15-17 是类的实现。
例 15-17。 generic_lotto.py:泛型的lottery blower类
import random
from collections.abc import Iterable
from typing import TypeVar, Generic
from tombola import Tombola
T = TypeVar('T')
class LottoBlower(Tombola, Generic[T]): 1
def __init__(self, items: Iterable[T]) -> None: 2
self._balls = list[T](items)
def load(self, items: Iterable[T]) -> None: 3
self._balls.extend(items)
def pick(self) -> T: 4
try:
position = random.randrange(len(self._balls))
except ValueError:
raise LookupError('pick from empty LottoBlower')
return self._balls.pop(position)
def loaded(self) -> bool: 5
return bool(self._balls)
def inspect(self) -> tuple[T, ...]: 6
return tuple(self._balls)
- 泛型类声明通常使用多重继承,因为我们需要继承Generic来声明形式类型参数——在这个例子中是T。
-
__init__ 中的 items 参数是 Iterable[T] 类型,当实例声明为 LottoBlower[int] 时,它的类型就变为 Iterable[int]。
-
load方法也有同样的限制。
-
返回类型T现在在 LottoBlower[int] 的上下文中变为 int。
-
这里没有类型变量。
-
最后,T 为返回值元组中项的类型,也就是tuple[int,...]。
TIP:
typing模块文档的User-defined generic types部分很简短,提供了很好的示例,并提供了一些我在此处未涵盖的更多细节。
现在我们已经看到了如何实现泛型类,让我们定义泛型的术语。
泛型类型的基本术语以下是我在研究泛型时发现非常有用的定义。
泛型类型:
使用一个或多个类型变量声明的类型。 示例:LottoBlower[T], abc.Mapping[KT, VT]。
正式类型参数:
出现在泛型类型声明中的类型变量。 示例:前面例子abc.Mapping[KT, VT]中的KT和VT
参数化类型
使用实际类型参数声明的类型。 示例:LottoBlower[int], abc.Mapping[str, float]。
实际类型参数
声明参数化类型时作为参数给出的实际类型。 示例:LottoBlower[int] 中的 int。
下一个主题是关于如何让泛型类型更加灵活,介绍协变、逆变和不变性的概念。
变异Note:
根据您的背景,这可能是本书中最具挑战性的部分。变异的概念是抽象的,严谨的表述会使这部分看起来像数学书的摘录。在实践中,这主要与想要提供新的泛型容器类型的库作者相关。
因此,在第一次阅读时,您可以跳过整个部分或只阅读有关不变类型的部分。即便如此,您也可以避免太多的复杂性并只提供不变的集合——这也是我们现在在 Python 标准库中支持的。因此,在第一次阅读时,您可以跳过整个部分或只阅读有关不变类型的部分。
泛型与类型层次结构的交互引入了一个新的类型概念:变异。我们将通过类比的方式来理解这个抽象的概念。
想象一下,学校食堂有一条规则,只能安装果汁机。不允许使用一般的饮料售卖机,因为它们可能提供被学校董事会禁止的苏打水。请参见示例 15-18。
例 15-18。 invariant.py:类型定义和install函数。
from typing import TypeVar, Generic
class Beverage: 1
"""Any beverage."""
class Juice(Beverage):
"""Any fruit juice."""
class OrangeJuice(Juice):
"""Delicious juice from Brazilian oranges."""
T = TypeVar('T') 2
class BeverageDispenser(Generic[T]): 3
"""A dispenser parameterized on the beverage type."""
def __init__(self, beverage: T) -> None:
self.beverage = beverage
def dispense(self) -> T:
return self.beverage
def install(dispenser: BeverageDispenser[Juice]) -> None: 4
"""Install a fruit juice dispenser."""
- Beverage、Juice 和 OrangeJuice 形成一个类型层次结构。
- 简单的 TypeVar 声明。
- BeverageDispenser 根据饮料类型进行参数化。
- install 是一个模块全局函数。它的类型提示强制执行只有果汁售卖机才能进行安装的规则。
根据示例 15-18 中的定义,以下代码是合法的:
juice_dispenser = BeverageDispenser(Juice()) install(juice_dispenser)
但是,这是不合法的:
beverage_dispenser = BeverageDispenser(Beverage()) install(beverage_dispenser) ## mypy: Argument 1 to "install" has ## incompatible type "BeverageDispenser[Beverage]" ## expected "BeverageDispenser[Juice]"
供应任何Beverage的售卖机是不可以的,因为自助餐厅需要专门用于Juice的饮料机。
有点令人惊讶的是,这段代码也是非法的:
orange_juice_dispenser = BeverageDispenser(OrangeJuice()) install(orange_juice_dispenser) ## mypy: Argument 1 to "install" has ## incompatible type "BeverageDispenser[OrangeJuice]" ## expected "BeverageDispenser[Juice]"
也不允许使用专门用于OrangeJuice的售卖机。只有 BeverageDispenser[Juice] 可以。在类型术语中,当 BeverageDispenser[OrangeJuice] 与 BeverageDispenser[Juice] 不兼容时,我们说 BeverageDispenser(Generic[T]) 是不变的——尽管 OrangeJuice 是 Juice 的一个子类型。
Python 可变集合类型(例如 list 和 set)是不变的。示例 15-17 中的 LottoBlower 类也是不变的。
协变的售卖机如果我们想要更灵活,并将售卖机建模为可以接受某些饮料类型及其子类型的通用类,我们必须使其具有协变。这就是我们声明 BeverageDispenser 的方式:
例 15-19。 covariant.py:类型定义和install函数。
T_co = TypeVar('T_co', covariant=True) 1
class BeverageDispenser(Generic[T_co]): 2
def __init__(self, beverage: T_co) -> None:
self.beverage = beverage
def dispense(self) -> T_co:
return self.beverage
def install(dispenser: BeverageDispenser[Juice]) -> None: 3
"""Install a fruit juice dispenser."""
- 声明类型变量时设置covariant=True; _co 是 typeshed 上协变类型参数的常规后缀。
- 使用 T_co 参数化 Generic 特殊类。
- install的类型提示与示例 15-18 中的相同。
下面代码都是正确的,因为现在 Juice 售卖机和 OrangeJuice 售卖机在协变的 BeverageDispenser 中都是合法的。
juice_dispenser = BeverageDispenser(Juice()) install(juice_dispenser) orange_juice_dispenser = BeverageDispenser(OrangeJuice()) install(orange_juice_dispenser)
但是任意Beverage的售卖机是不可接受的:
beverage_dispenser = BeverageDispenser(Beverage()) install(beverage_dispenser) ## mypy: Argument 1 to "install" has ## incompatible type "BeverageDispenser[Beverage]" ## expected "BeverageDispenser[Juice]"
这就是协变:参数化的售卖机的子类型的关系与类型参数的子类型关系是在同一方向上变化的。
一个逆变的垃圾桶现在,我们将为自助餐厅部署垃圾桶的规则建模。假设食品和饮料采用可生物降解的包装,剩菜和一次性餐具也是可生物降解的。垃圾桶必须适合可生物降解的垃圾。
Note:
为了这个教学示例,让我们做一个简单的假设,即垃圾可以按照整洁的层次结构进行分类:
- Refuse是最通用的垃圾类型。所有的垃圾都是Refuse
- Biodegradable一种特殊类型的垃圾,可以随着时间的推移被生物体分解。有些Refuse不是Biodegradable
- Compostable是一种特定类型的Biodegradable垃圾,可以在堆肥箱或堆肥设施中有效地转化为有机肥料。在我们的定义中,并非所有Biodegradable的垃圾都是Compostable。
为了对自助餐厅中可接受的垃圾桶的规则进行建模,我们需要通过一个使用它的示例来引入“逆变”的概念:
例 15-20。 contravariant.py:类型定义和install函数。
from typing import TypeVar, Generic
class Refuse: 1
"""Any refuse."""
class Biodegradable(Refuse):
"""Biodegradable refuse."""
class Compostable(Biodegradable):
"""Compostable refuse."""
T_contra = TypeVar('T_contra', contravariant=True) 2
class TrashCan(Generic[T_contra]): 3
def put(self, refuse: T_contra) -> None:
"""Store trash until dumped."""
def deploy(trash_can: TrashCan[Biodegradable]):
"""Deploy a trash can for biodegradable refuse."""
- 垃圾的类型层次结构:Refuse是最通用的类型,Compostable是最具体的。
- T_contra 是逆变类型变量的约定的名称。
- TrashCan 在Refuse类型上是逆变的。
根据这些定义,这些类型的垃圾桶是可以接受的:
bio_can: TrashCan[Biodegradable] = TrashCan() deploy(bio_can) trash_can: TrashCan[Refuse] = TrashCan() deploy(trash_can)
更通用的 TrashCan[Refuse] 是可以接受的,因为它包括任何种类的垃圾,包括 Biodegradable。
但是, TrashCan[Compostable] 不行,因为它不包括 Biodegradable:
compost_can: TrashCan[Compostable] = TrashCan() deploy(compost_can) ## mypy: Argument 1 to "deploy" has ## incompatible type "TrashCan[Compostable]" ## expected "TrashCan[Biodegradable]"
让我们总结一下我们刚刚看到的概念。
可变性的总结不变类型
当两个参数化类型之间没有超类型或子类型关系时,泛型类型 L 是不变的,和实际参数之间存在的关系无关。换句话说,如果 L 是不变的,则 L[A] 不是 L[B] 的超类型或子类型。它们在两个方向都是不一致的。
如前所述,Python 的可变集合默认是不变的。列表类型就是一个很好的例子:list[int] 与 list[float] 不一致,反之亦然。
一般来说,如果一个形式类型参数出现在方法参数的类型提示中并且相同的参数出现在方法返回值类型提示中,则该参数必须是不变的,以确保更新和读取集合时的类型安全。
例如,这里是 typeshed 内置列表的类型提示的一部分:
class list(MutableSequence[_T], Generic[_T]):
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, iterable: Iterable[_T]) -> None: ...
# ... lines omitted ...
def append(self, __object: _T) -> None: ...
def extend(self, __iterable: Iterable[_T]) -> None: ...
def pop(self, __index: int = ...) -> _T: ...
# etc...
请注意,_T 出现在 __init__、append 和 extend 的参数中,并作为 pop 的返回类型。如果在 _T 中是协变或逆变的,则保证类型的安全性。
协变类型
考虑两种类型 A 和 B,其中 B 与 A 一致,并且它们都不是 Any。一些作者使用 <: 和 :> 符号来表示这样的类型关系:
A :> B
A 是B的超类型或与 B 相同。
B <: A
B 是A的子类型或与 A 相同。
给定 A :> B,当 C[A] :> C[B] 时,泛型类型 C 是协变的。
注意 :> 符号的方向在 A 位于 B 左侧的两种情况下是相同的。协变泛型类型遵循实际类型参数的子类型关系。
不可变容器可以是协变的。例如,如何将 Typing.FrozenSet 类记录为使用传统名称 T_co 的类型变量的协变:
class FrozenSet(frozenset, AbstractSet[T_co]):
将 :> 符号应用于参数化类型,我们有:
float :> int frozenset[float] :> frozenset[int]
迭代器是协变泛型的另一个例子:它们不像frozenset那样是只读集合,但它们只生成输出。任何需要 abc.Iterator[float] 生成float的代码都可以安全地使用 abc.Iterator[int] 生成整数。
逆变类型
给定 A :> B,如果 K[A] <: K[B],泛型类型 K 是逆变的。
逆变泛型类型反转了实际类型参数的子类型关系。
TrashCan 类举例说明了这一点:
Refuse :> Biodegradable TrashCan[Refuse] <: TrashCan[Biodegradable]
逆变容器通常是只写数据结构,也称为“接收器”。
Python 3.9 标准库中没有具有单个形式类型参数的逆变泛型示例。但是 Generator、Coroutine 和 AsyncGenerator 都有多个形式类型参数,并且每个都有一个逆变形式参数。
这三种泛型类型都与用作协程的生成器相关,而不是简单的迭代器。 Generator 类型在“经典协程的通用类型提示”中描述;协程和 AsyncGenerator,在第 21 章。
对于目前关于变化的讨论,重点是逆变形式参数定义了用于向对象发送数据的唯一参数的类型,而与之不同的协变形式参数定义了对象产生的输出类型——yield类型。 “send”和“yield”的含义在《经典协程》中有解释。
我们可以从协变输出和逆变输入的这些观察中得出有用的指导方针。
差异经验法则- 如果形式类型参数为对象的数据定义了类型,则它可以是协变的。
- 如果形式类型参数为初始构造后进入对象的数据定义了类型,则它可以是逆变的。
- 如果一个形式类型参数为来自对象的数据定义了一个类型,而同一个参数为进入它的数据定义了一个类型,那么它必须是不变的。
- 为了安全起见,让形式参数保持不变。
默认情况下,TypeVar 创建不变的形式参数,这就是标准库中可变集合的注解方式。
泛型 Typing.Generator 是规则 #1 和 #2 的一个很好的例子,只要你理解经典协程是如何工作的——因为这就是该类型所描述的。“经典协程的通用类型提示”继续当前关于差异的讨论。
接下来,让我们看看如何定义泛型静态协议,将协变的概念应用于几个新示例。
实现一个泛型静态协议Python 3.9 标准库提供了几个通用静态协议。其中之一是 SupportsAbs,在typing模块中实现如下:
@runtime_checkable
class SupportsAbs(Protocol[T_co]):
"""An ABC with one abstract method __abs__ that is covariant in its return type."""
__slots__ = ()
@abstractmethod
def __abs__(self) -> T_co:
pass
T_co 根据命名约定声明:
T_co = TypeVar('T_co', covariant=True)
由于 SupportsAbs,Mypy 将这段代码识别为有效:
import math
from typing import NamedTuple, SupportsAbs
class Vector2d(NamedTuple):
x: float
y: float
def __abs__(self) -> float: 1
return math.hypot(self.x, self.y)
def is_unit(v: SupportsAbs[float]) -> bool: 2
"""'True' if the magnitude of 'v' is close to 1."""
return math.isclose(abs(v), 1.0) 3
assert issubclass(Vector2d, SupportsAbs) 4
v0 = Vector2d(0, 1) 5
sqrt2 = math.sqrt(2)
v1 = Vector2d(sqrt2 / 2, sqrt2 / 2)
v2 = Vector2d(1, 1)
v3 = complex(.5, math.sqrt(3) / 2)
v4 = 1 6
assert is_unit(v0)
assert is_unit(v1)
assert not is_unit(v2)
assert is_unit(v3)
assert is_unit(v4)
print('OK')
- 定义 __abs__ 使 Vector2d 与 SupportsAbs 一致。
- 使用 float 进行参数化 SupportsAbs 可以确保……
- ...Mypy 接受 abs(v) 作为 math.isclose 的第一个参数。
- 由于 SupportsAbs 定义中的 @runtime_checkable,这是一个有效的运行时断言。
- 其余代码都通过了 Mypy 检查和运行时断言
- int 类型也与 SupportsAbs 一致。根据typeshed,int.__abs__ 返回一个int,这与v 参数的is_unit 类型提示中声明的float 类型参数一致。
类似地,我们可以编写示例 13-18 中展示的 RandomPicker 协议的泛型版本,该协议使用返回 Any 的单个方法 pick 进行定义。
示例 15-22 展示了如何在 pick 的返回类型上创建一个泛型的 RandomPicker 协变。
示例 15-22。 generic_randompick.py:泛型 RandomPicker 的定义。
from typing import Protocol, runtime_checkable, TypeVar
T_co = TypeVar('T_co', covariant=True) 1
@runtime_checkable
class RandomPicker(Protocol[T_co]): 2
def pick(self) -> T_co: ... 3
- 将 T_co 声明为协变。
- 这使得 RandomPicker 成为具有协变形式类型参数的泛型。
- 使用 T_co 作为返回类型。
泛型RandomPicker 协议是可以协变的,因为它唯一的形式参数用于返回类型。



