流畅的 Python 第二版(GPT 重译)(八)(1)

简介: 流畅的 Python 第二版(GPT 重译)(八)

第十五章:关于类型提示的更多内容

我学到了一个痛苦的教训,对于小程序来说,动态类型很棒。对于大型程序,你需要更加纪律严明的方法。如果语言给予你这种纪律,而不是告诉你“嗯,你可以做任何你想做的事情”,那会更有帮助。

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函数。

如果你想通过阅读代码了解@overloadtypeshed有数百个示例。在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 还接受两个可选关键字参数:keydefault

我在 Python 中编写了 max 来更容易地看到它的工作方式和重载注释之间的关系(内置的 max 是用 C 编写的);参见 Example 15-2。

Example 15-2. mymax.pymax 函数的 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 的逻辑,所以我不会花时间解释它的实现,除了解释 MISSINGMISSING 常量是一个用作哨兵的唯一 object 实例。它是 default= 关键字参数的默认值,这样 max 可以接受 default=None 并仍然区分这两种情况:

  1. 用户没有为 default= 提供值,因此它是 MISSING,如果 first 是一个空的可迭代对象,max 将引发 ValueError
  2. 用户为 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 的单独参数,要么是这些项目的 Iterablemax 的返回类型与实际参数或项目相同,正如我们在 “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的返回类型必须是类型Tdefault参数的类型的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,不能是一个intList[str]

PEP 589—TypedDict: 具有固定键集的字典的类型提示解决了这个问题。示例 15-4 展示了一个简单的TypedDict

示例 15-4。books.pyBookDict定义
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

相关文章
|
13天前
|
存储 机器学习/深度学习 安全
流畅的 Python 第二版(GPT 重译)(一)(4)
流畅的 Python 第二版(GPT 重译)(一)
39 3
|
安全 测试技术 程序员
流畅的 Python 第二版(GPT 重译)(三)(2)
流畅的 Python 第二版(GPT 重译)(三)
31 11
|
13天前
|
存储 IDE 开发工具
流畅的 Python 第二版(GPT 重译)(三)(1)
流畅的 Python 第二版(GPT 重译)(三)
149 54
|
13天前
|
安全 程序员 API
流畅的 Python 第二版(GPT 重译)(一)(1)
流畅的 Python 第二版(GPT 重译)(一)
97 5
|
13天前
|
存储 安全 测试技术
流畅的 Python 第二版(GPT 重译)(四)(3)
流畅的 Python 第二版(GPT 重译)(四)
6 1
|
13天前
|
人工智能 安全 程序员
流畅的 Python 第二版(GPT 重译)(一)(3)
流畅的 Python 第二版(GPT 重译)(一)
13 2
|
13天前
|
存储 设计模式 缓存
流畅的 Python 第二版(GPT 重译)(五)(2)
流畅的 Python 第二版(GPT 重译)(五)
29 1
|
Linux 数据库 iOS开发
流畅的 Python 第二版(GPT 重译)(二)(4)
流畅的 Python 第二版(GPT 重译)(二)
48 5
|
13天前
|
存储 缓存 Java
流畅的 Python 第二版(GPT 重译)(六)(2)
流畅的 Python 第二版(GPT 重译)(六)
58 0
|
13天前
|
Java 测试技术 Go
流畅的 Python 第二版(GPT 重译)(四)(4)
流畅的 Python 第二版(GPT 重译)(四)
22 1

热门文章

最新文章