第十五章:关于类型提示的更多内容
我学到了一个痛苦的教训,对于小程序来说,动态类型很棒。对于大型程序,你需要更加纪律严明的方法。如果语言给予你这种纪律,而不是告诉你“嗯,你可以做任何你想做的事情”,那会更有帮助。
Guido van Rossum,蒙提·派森的粉丝¹
本章是第八章的续集,涵盖了更多关于 Python 渐进类型系统的内容。主要议题包括:
- 重载函数签名
typing.TypedDict
用于对作为记录使用的dicts
进行类型提示- 类型转换
- 运行时访问类型提示
- 通用类型
- 声明一个通用类
- 变异:不变、协变和逆变类型
- 通用静态协议
本章的新内容
本章是《流畅的 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 文件。
正如在“注释位置参数和可变参数”中提到的,__iterable
中的两个下划线是 PEP 484 对位置参数的约定,由 Mypy 强制执行。这意味着你可以调用sum(my_list)
,但不能调用sum(__iterable = my_list)
。
类型检查器尝试将给定的参数与每个重载签名进行匹配,按顺序。调用sum(range(100), 1000)
不匹配第一个重载,因为该签名只有一个参数。但它匹配第二个。
你也可以在普通的 Python 模块中使用@overload
,只需在函数的实际签名和实现之前写上重载的签名即可。示例 15-1 展示了如何在 Python 模块中注释和实现sum
。
示例 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') # ① @overload def sum(it: Iterable[T]) -> Union[T, int]: ... # ② @overload def sum(it: Iterable[T], /, start: S) -> Union[T, S]: ... # ③ def sum(it, /, start=0): # ④ return functools.reduce(operator.add, it, start)
①
我们在第二个重载中需要这第二个TypeVar
。
②
这个签名是针对简单情况的:sum(my_iterable)
。结果类型可能是T
——my_iterable
产生的元素的类型,或者如果可迭代对象为空,则可能是int
,因为start
参数的默认值是0
。
③
当给定start
时,它可以是任何类型S
,因此结果类型是Union[T, S]
。这就是为什么我们需要S
。如果我们重用T
,那么start
的类型将必须与Iterable[T]
的元素类型相同。
④
实际函数实现的签名没有类型提示。
这是为了注释一行函数而写的很多行代码。我知道这可能有点过头了。至少这不是一个foo
函数。
如果你想通过阅读代码了解@overload
,typeshed有数百个示例。在typeshed上,Python 内置函数的存根文件在我写这篇文章时有 186 个重载——比标准库中的任何其他函数都多。
利用渐进类型
追求 100% 的注释代码可能会导致添加大量噪音但很少价值的类型提示。简化类型提示以简化重构可能会导致繁琐的 API。有时最好是务实一些,让一段代码没有类型提示。
我们称之为 Pythonic 的方便 API 往往很难注释。在下一节中,我们将看到一个例子:需要六个重载才能正确注释灵活的内置 max
函数。
Max Overload
给利用 Python 强大动态特性的函数添加类型提示是困难的。
在研究 typeshed 时,我发现了 bug 报告 #4051:Mypy 没有警告说将 None
作为内置 max()
函数的参数之一是非法的,或者传递一个在某个时刻产生 None
的可迭代对象也是非法的。在任一情况下,你会得到像这样的运行时异常:
TypeError: '>' not supported between instances of 'int' and 'NoneType'
max
的文档以这句话开头:
返回可迭代对象中的最大项或两个或多个参数中的最大项。
对我来说,这是一个非常直观的描述。
但如果我必须为以这些术语描述的函数注释,我必须问:它是哪个?一个可迭代对象还是两个或更多参数?
实际情况更加复杂,因为 max
还接受两个可选关键字参数:key
和 default
。
我在 Python 中编写了 max
来更容易地看到它的工作方式和重载注释之间的关系(内置的 max
是用 C 编写的);参见 Example 15-2。
Example 15-2. mymax.py:max
函数的 Python 重写
# 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
常量是一个用作哨兵的唯一 object
实例。它是 default=
关键字参数的默认值,这样 max
可以接受 default=None
并仍然区分这两种情况:
- 用户没有为
default=
提供值,因此它是MISSING
,如果first
是一个空的可迭代对象,max
将引发ValueError
。 - 用户为
default=
提供了一些值,包括None
,因此如果first
是一个空的可迭代对象,max
将返回该值。
为了修复 问题 #4051,我写了 Example 15-3 中的代码。²
Example 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]: ...
我的 Python 实现的 max
与所有那些类型导入和声明的长度大致相同。由于鸭子类型,我的代码没有 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
的单独参数,要么是这些项目的 Iterable
。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
。如果我不得不重新发明min
函数,我可以重构并重用大部分max
的实现。但我必须复制并粘贴所有重载的声明——尽管它们对于min
来说是相同的,除了函数名称。
我的朋友 João S. O. Bueno——我认识的最聪明的 Python 开发者之一——在推特上发表了这篇推文:
尽管很难表达
max
的签名——但它很容易理解。我理解的是,与 Python 相比,注释标记的表现力非常有限。
现在让我们来研究TypedDict
类型构造。一开始我认为它并不像我想象的那么有用,但它有其用途。尝试使用TypedDict
来处理动态结构(如 JSON 数据)展示了静态类型处理的局限性。
TypedDict
警告
使用TypedDict
来保护处理动态数据结构(如 JSON API 响应)中的错误是很诱人的。但这里的示例清楚地表明,对 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: 具有固定键集的字典的类型提示解决了这个问题。示例 15-4 展示了一个简单的TypedDict
。
示例 15-4。books.py:BookDict
定义
from typing import TypedDict class BookDict(TypedDict): isbn: str title: str authors: list[str] pagecount: int
乍一看,typing.TypedDict
可能看起来像是一个数据类构建器,类似于typing.NamedTuple
—在第五章中介绍过。
语法上的相似性是误导的。TypedDict
非常不同。它仅存在于类型检查器的利益,并且在运行时没有影响。
TypedDict
提供了两个东西:
- 类似类的语法来注释每个“字段”的值的
dict
类型提示。 - 一个构造函数,告诉类型检查器期望一个带有指定键和值的
dict
。
在运行时,像BookDict
这样的TypedDict
构造函数是一个安慰剂:它与使用相同参数调用dict
构造函数具有相同效果。
BookDict
创建一个普通的dict
也意味着:
- 伪类定义中的“字段”不会创建实例属性。
- 你不能为“字段”编写具有默认值的初始化程序。
- 不允许方法定义。
让我们在运行时探索一个BookDict
的行为(示例 15-5)。
示例 15-5。使用BookDict
,但并非完全按照预期
>>> from books import BookDict >>> pp = BookDict(title='Programming Pearls', # ① ... authors='Jon Bentley', # ② ... isbn='0201657880', ... pagecount=256) >>> pp # ③ {'title': 'Programming Pearls', 'authors': 'Jon Bentley', 'isbn': '0201657880', 'pagecount': 256} >>> type(pp) <class 'dict'> >>> pp.title # ④ Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'dict' object has no attribute 'title' >>> pp['title'] 'Programming Pearls' >>> BookDict.__annotations__ # ⑤ {'isbn': <class 'str'>, 'title': <class 'str'>, 'authors': typing.List[str], 'pagecount': <class 'int'>}
①
你可以像使用dict
构造函数一样调用BookDict
,使用关键字参数,或传递一个dict
参数,包括dict
文字。
②
糟糕…我忘记了 authors
接受一个列表。但渐进式类型意味着在运行时没有类型检查。
③
调用 BookDict
的结果是一个普通的 dict
…
④
…因此您不能使用 object.field
记法读取数据。
⑤
类型提示位于 BookDict.__annotations__
中,而不是 pp
。
没有类型检查器,TypedDict
就像注释一样有用:它可以帮助人们阅读代码,但仅此而已。相比之下,来自 第五章 的类构建器即使不使用类型检查器也很有用,因为在运行时它们会生成或增强一个自定义类,您可以实例化。它们还提供了 表 5-1 中列出的几个有用的方法或函数。
示例 15-6 构建了一个有效的 BookDict
,并尝试对其进行一些操作。这展示了 TypedDict
如何使 Mypy 能够捕获错误,如 示例 15-7 中所示。
示例 15-6. demo_books.py: 在 BookDict
上进行合法和非法操作
from books import BookDict from typing import TYPE_CHECKING def demo() -> None: # ① book = BookDict( # ② isbn='0134757599', title='Refactoring, 2e', authors=['Martin Fowler', 'Kent Beck'], pagecount=478 ) authors = book['authors'] # ③ if TYPE_CHECKING: # ④ reveal_type(authors) # ⑤ authors = 'Bob' # ⑥ 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 中查看其输出。
⑥
demo
函数的最后三行是非法的。它们会在 示例 15-7 中导致错误消息。
对 demo_books.py 进行类型检查,来自 示例 15-6,我们得到 示例 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]' # ① demo_books.py:14: error: Incompatible types in assignment (expression has type "str", variable has type "List[str]") # ② demo_books.py:15: error: TypedDict "BookDict" has no key 'weight' # ③ demo_books.py:16: error: Key 'title' of TypedDict "BookDict" cannot be deleted # ④ Found 3 errors in 1 file (checked 1 source file)
①
这个注释是 reveal_type(authors)
的结果。
②
authors
变量的类型是从初始化它的 book['authors']
表达式的类型推断出来的。您不能将 str
赋给类型为 List[str]
的变量。类型检查器通常不允许变量的类型更改。³
③
无法为不属于 BookDict
定义的键赋值。
④
无法删除属于 BookDict
定义的键。
现在让我们看看在函数签名中使用 BookDict
,以进行函数调用的类型检查。
想象一下,你需要从书籍记录生成类似于这样的 XML:
<BOOK> <ISBN>0134757599</ISBN> <TITLE>Refactoring, 2e</TITLE> <AUTHOR>Martin Fowler</AUTHOR> <AUTHOR>Kent Beck</AUTHOR> <PAGECOUNT>478</PAGECOUNT> </BOOK>
如果您正在编写要嵌入到微型微控制器中的 MicroPython 代码,您可能会编写类似于 示例 15-8 中所示的函数。⁴
示例 15-8. books.py: to_xml
函数
AUTHOR_ELEMENT = '<AUTHOR>{}</AUTHOR>' def to_xml(book: BookDict) -> str: # ① elements: list[str] = [] # ② for key, value in book.items(): if isinstance(value, list): # ③ elements.extend( AUTHOR_ELEMENT.format(n) for n in value) # ④ else: tag = key.upper() elements.append(f'<{tag}>{value}</{tag}>') xml = '\n\t'.join(elements) return f'<BOOK>\n\t{xml}\n</BOOK>'
①
示例的整个重点:在函数签名中使用 BookDict
。
②
经常需要注释开始为空的集合,否则 Mypy 无法推断元素的类型。⁵
③
Mypy 理解 isinstance
检查,并在此块中将 value
视为 list
。
④
当我将key == 'authors'
作为if
条件来保护这个块时,Mypy 在这一行发现了一个错误:““object"没有属性"iter””,因为它推断出从book.items()
返回的value
类型为object
,而object
不支持生成器表达式所需的__iter__
方法。通过isinstance
检查,这可以工作,因为 Mypy 知道在这个块中value
是一个list
。
示例 15-9(#from_json_any_ex)展示了一个解析 JSON str
并返回BookDict
的函数。
示例 15-9. books_any.py:from_json
函数
def from_json(data: str) -> BookDict: whatever = json.loads(data) # ① return whatever # ②
①
json.loads()
的返回类型是Any
。⁶
②
我可以返回whatever
—类型为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 中那样。
流畅的 Python 第二版(GPT 重译)(八)(2)https://developer.aliyun.com/article/1484690