流畅的 Python 第二版(GPT 重译)(四)(3)

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

流畅的 Python 第二版(GPT 重译)(四)(2)https://developer.aliyun.com/article/1484437

子类型与一致性

传统的面向对象的名义类型系统依赖于子类型关系。给定一个类T1和一个子类T2,那么T2T1子类型

考虑这段代码:

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有特殊规定。

与一致的规则是:

  1. 给定T1和子类型T2,那么T2T1一致的(Liskov 替换)。
  2. 每种类型都与一致Any:你可以将每种类型的对象传递给声明为Any类型的参数。
  3. Any与每种类型一致的:你总是可以在需要另一种类型的参数时传递一个Any类型的对象。

考虑前面定义的对象o1o2,这里是有效代码的示例,说明规则#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内置函数的类型提示即可。

现在我们可以探索注解中使用的其余类型。

简单类型和类

intfloatstrbytes这样的简单类型可以直接在类型提示中使用。标准库、外部包或用户定义的具体类——FrenchDeckVector2dDuck——也可以在类型提示中使用。

抽象基类在类型提示中也很有用。当我们研究集合类型时,我们将回到它们,并在“抽象基类”中看到它们。

在类之间,一致的定义类似于子类型:子类与其所有超类一致。

然而,“实用性胜过纯粹性”,因此有一个重要的例外情况,我将在下面的提示中讨论。

int 与复杂一致

内置类型intfloatcomplex之间没有名义子类型关系:它们是object的直接子类。但 PEP 484声明 intfloat一致,floatcomplex一致。在实践中是有道理的:int实现了float的所有操作,而且int还实现了额外的操作——位运算如&|<<等。最终结果是:intcomplex一致。对于i = 3i.real3i.imag0

可选和联合类型

我们在“使用 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的类型可以是strNone

Python 3.10 中更好的可选和联合语法

自 Python 3.10 起,我们可以写str | bytes而不是Union[str, bytes]。这样打字更少,而且不需要从typing导入OptionalUnion。对比show_countplural参数的类型提示的旧语法和新语法:

plural: Optional[str] = None    # before
plural: str | None = None       # after

|运算符也适用于isinstanceissubclass来构建第二个参数:isinstance(x, int | str)。更多信息,请参阅PEP 604—Union[]的补充语法

ord内置函数的签名是Union的一个简单示例——它接受strbytes,并返回一个int:⁸

def ord(c: Union[str, bytes]) -> int: ...

这是一个接受str但可能返回strfloat的函数示例:

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”中,我们看到接受strbytes参数的函数,但如果参数是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] 是多余的,因为 intfloat 是一致的。如果只使用 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: liststuff: 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 Coordinatesgeohash 函数
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.NamedTupletuple子类的工厂,因此Coordinatetuple[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]。内置的dictcollections以及collections.abc中的映射类型在 Python ≥ 3.9 中接受该表示法。对于早期版本,必须使用typing.Dicttyping模块中的其他映射类型,如“遗留支持和已弃用的集合类型”中所述。

示例 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 允许调用者提供 dictdefaultdictChainMapUserDict 子类或任何其他是 Mapping子类型的类型的实例。

相比之下,考虑这个签名:

def name2hex(name: str, color_map: dict[str, int]) -> str:

现在 color_map 必须是一个 dict 或其子类型之一,比如 defaultDictOrderedDict。特别是,collections.UserDict 的子类不会通过 color_map 的类型检查,尽管这是创建用户定义映射的推荐方式,正如我们在 “子类化 UserDict 而不是 dict” 中看到的那样。Mypy 会拒绝 UserDict 或从它派生的类的实例,因为 UserDict 不是 dict 的子类;它们是同级。两者都是 abc.MutableMapping 的子类。¹²

因此,一般来说最好在参数类型提示中使用 abc.Mappingabc.MutableMapping,而不是 dict(或在旧代码中使用 typing.Dict)。如果 name2hex 函数不需要改变给定的 color_map,那么 color_map 的最准确的类型提示是 abc.Mapping。这样,调用者不需要提供实现 setdefaultpopupdate 等方法的对象,这些方法是 MutableMapping 接口的一部分,但不是 Mapping 的一部分。这与 Postel 法则的第二部分有关:“在接受输入时要宽容。”

Postel 法则还告诉我们在发送内容时要保守。函数的返回值始终是一个具体对象,因此返回类型提示应该是一个具体类型,就像来自 “通用集合” 的示例一样—使用 list[str]

def tokenize(text: str) -> list[str]:
    return text.upper().split()

typing.List 的条目中,Python 文档中写道:

list 的泛型版本。用于注释返回类型。为了注释参数,最好使用抽象集合类型,如 SequenceIterable

typing.Dicttyping.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,并规定内置类型 complexfloatint 应被视为特殊情况,如 “int 与 complex 一致” 中所解释的那样。

我们将在 “numbers ABCs 和数字协议” 中回到这个问题,在 第十三章 中,该章节专门对比协议和 ABCs。

实际上,如果您想要为静态类型检查注释数字参数,您有几个选择:

  1. 使用 intfloatcomplex 中的一个具体类型—正如 PEP 488 建议的那样。
  2. 声明一个联合类型,如 Union[float, Decimal, Fraction]
  3. 如果想避免硬编码具体类型,请使用像 SupportsFloat 这样的数值协议,详见“运行时可检查的静态协议”。

即将到来的章节“静态协议”是理解数值协议的先决条件。

与此同时,让我们来看看对于类型提示最有用的 ABC 之一:Iterable

可迭代对象

我刚引用的 typing.List 文档建议在函数参数类型提示中使用 SequenceIterable

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.fsumreplacer.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

相关文章
|
6天前
|
数据采集 存储 人工智能
【Python+微信】【企业微信开发入坑指北】4. 企业微信接入GPT,只需一个URL,自动获取文章总结
【Python+微信】【企业微信开发入坑指北】4. 企业微信接入GPT,只需一个URL,自动获取文章总结
20 0
|
12天前
|
机器学习/深度学习 人工智能 自然语言处理
总结几个GPT的超实用之处【附带Python案例】
总结几个GPT的超实用之处【附带Python案例】
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(3)
JavaScript 权威指南第七版(GPT 重译)(七)
32 0
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(1)
JavaScript 权威指南第七版(GPT 重译)(七)
60 0
|
12天前
|
存储 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(六)(4)
JavaScript 权威指南第七版(GPT 重译)(六)
90 2
JavaScript 权威指南第七版(GPT 重译)(六)(4)
|
12天前
|
前端开发 JavaScript API
JavaScript 权威指南第七版(GPT 重译)(六)(3)
JavaScript 权威指南第七版(GPT 重译)(六)
55 4
|
12天前
|
JSON 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(五)(2)
JavaScript 权威指南第七版(GPT 重译)(五)
35 5
|
12天前
|
JSON JavaScript 前端开发
JavaScript 权威指南第七版(GPT 重译)(四)(4)
JavaScript 权威指南第七版(GPT 重译)(四)
67 6
|
12天前
|
Web App开发 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(四)(1)
JavaScript 权威指南第七版(GPT 重译)(四)
35 2
|
12天前
|
存储 JavaScript 前端开发
JavaScript 权威指南第七版(GPT 重译)(三)(3)
JavaScript 权威指南第七版(GPT 重译)(三)
41 1