Python 类型系统与类型检查(翻译)

翻译信息

原文链接:Python Type Checking (Guide)

作者:Geir Arne Hjelle

译者:muzing

翻译时间:2022.4

允许转载,转载必须保留全部翻译信息,且不能对正文有任何修改

在本教程中,您将了解 Python 类型检查。传统上,Python 解释器以灵活但隐式的方式处理类型。近期版本的 Python 允许指定可由不同工具使用的显示类型提示,以帮助更有效地开发代码。

在本教程中,您将学习以下内容:

  • 类型注解与类型提示
  • 将静态类型添加到代码中,包括您的代码和其他人的代码
  • 运行静态类型检查器
  • 在运行时强制使用类型

这是一个涵盖很多领域的综合指南。如果您只想快速了解类型提示在 Python 中是如何工作的,并了解类型检查是否为您将在自己的代码使用中的,则无需阅读全文。Hello Types 与优点与缺点两节将让您了解类型检查的工作方式,以及何时适用的建议。

要获取您将用于编写本教程中的示例的文件和存档,请点击此链接。

Python 类型系统与类型检查(翻译)_第1张图片

类型系统

所有的编程语言都包含某种类型系统,它形式化了可以使用哪些类别的对象以及如何处理这些类别。例如,一个类型系统可以定义一个数值类型,42 就是数值类型对象的一个例子。

动态类型

Python 是一种动态类型语言。这意味着 Python 解释器仅在代码运行时进行类型检查,并且允许变量的类型在其生命周期内更改。以下虚拟示例演示 Python 具有动态类型:

>>> if False:
...     1 + "two"  # 这一行永远不会运行,所以不会引发 TypeError
... else:
...     1 + 2
...
3

>>> 1 + "two"  # 现在会运行类型检查,并引发 TypeError
TypeError: unsupported operand type(s) for +: 'int' and 'str'

在第一个例子中,1 + "two" 分支永远不会运行,故永远不会进行类型检查。第二个例子显示,当 1 + "two" 被计算时会引发 TypeError ,因为不能在 Python 中将整型和字符串相加。

接下来,看一下变量是否可以改变类型:

>>> thing = "Hello"
>>> type(thing)
<class 'str'>

>>> thing = 28.1
>>> type(thing)
<class 'float'>

type() 返回对象的类型。这些例子证实了 thing 的类型是允许改变的,并且 Python 能正确推断出它改变时的类型。

静态类型

与动态类型相反的是静态类型。静态类型检查在程序非运行时执行。在大多数静态类型语言,如 C 和 Java 中,这是在编译程序时完成的。

对于静态类型,尽管可能存在将变量转换为另一种类型的机制,但通常不允许变量改变类型。

看一个来自静态类型语言的快速示例。考虑以下 Java 片段:

String thing;
thing = "Hello";

第一行声明变量名 thing 在编译时绑定到 String 类型。这个名称永远不能被重新绑定到另一种类型。在第二行中,thing 被赋值。它永远不能被赋一个非 String 对象的值。例如,如果稍后说 thing = 28.1f,编译器会因为类型不兼容而引发一个错误。

Python 将始终保持为一个动态类型语言。然而,PEP 484 引入了类型提示,这使得对 Python 代码进行静态类型检查成为可能。

与其他大多数静态类型语言中的类型的工作方式不同,类型提示本身不会导致 Python 强制执行类型。顾名思义,类型提示只是建议类型。还有其他工具(会在稍后介绍)使用类型提示执行静态类型检查。

鸭子类型

谈论 Python 时经常使用的另一个术语是鸭子类型。这个绰号来自短语“如果它像鸭子一样走路,并且它像鸭子一样嘎嘎叫,那么它一定是鸭子”(或其各种变体)。

鸭子类型是一个与动态类型相关的概念,其中对象的类型或类不如它定义的方法重要。使用鸭子类型时根本不检查类型。而是检查给定方法或属性是否存在。

例如,可以在任何定义了 .__len__() 方法的 Python 对象上调用 len()

>>> class TheHobbit:
...     def __len__(self):
...         return 95022
...
>>> the_hobbit = TheHobbit()
>>> len(the_hobbit)
95022

注意对 len() 的调用给出了 .__len__() 方法的返回值。事实上,len() 的实现本质上等价于以下内容:

def len(obj):
    return obj.__len__()

为了调用 len(obj),对 obj 唯一真正的限制为它必须定义一个 .__len__() 方法。否则,对象的类型可能与 strlistdictTheHobbit 等不同。

使用 structural subtyping 对 Python 代码进行静态类型检查时,一定程度上支持鸭子类型。稍后将介绍关于鸭子类型的更多内容。

Hello Types

在本节中,您将看到如何向函数添加类型提示。以下函数通过添加适当的大写和装饰线,将文本字符串转换为标题:

def headline(text, align=True):
    if align:
        return f"{text.title()}\n{'-' * len(text)}"
    else:
        return f" {text.title()} ".center(50, "o")

默认情况下,该函数返回与下划线左对齐的标题。通过将 align 标志设置为 False,也可以让标题以围绕线的 o 居中:

>>> print(headline("python type checking"))
Python Type Checking
--------------------

>>> print(headline("python type checking", align=False))
oooooooooooooo Python Type Checking oooooooooooooo

是时候写第一个类型提示了!要将有关类型的信息添加到函数中,只需注释其参数和返回值,如下所示:

def headline(text: str, align: bool = True) -> str:
    ...

text: str 语法表明 text 参数应该是 str 类型。同样的,可选的 align 参数的类型应为 bool,默认值为 True。最后,-> str 表示法指定 headline() 将返回一个字符串。

在风格方面,PEP 8 建议如下:

  • 对冒号使用常规规则,即冒号前没有空格,后面有一个空格: text: str
  • 将参数注解和默认值组合时,在 = 符号两侧使用空格:align: bool = True
  • -> 箭头两侧使用空格:def headline(...) -> str

像这样添加类型提示不会对运行时产生影响:它们只是提示,不会自强制执行。例如,如果为 align 参数(不可否认,这个命名不佳)使用了错误的类型,代码依然可以运行而没有任何问题或警告:

>>> print(headline("python type checking", align="left"))
Python Type Checking
--------------------

Note: 这似乎能工作的原因是,字符串"left" 被视为真。使用 align="center" 不会产生预期的效果,因为 "center" 也是真值。

要捕获此类错误,可以使用静态类型检查器。即,一种无需传统意义上实际运行代码,就可以检查代码类型的工具。

可能已经在编辑器中内置了这样的类型检查器。例如 PyCharm 会立即给出一个警告:

Python 类型系统与类型检查(翻译)_第2张图片

不过,最常用的类型检查工具是 Mypy。即将简要介绍 Mypy,而稍后可以了解关于它的工作原理的更多内容。

如果系统上还没有 Mypy,可以使用 pip 安装它:

pip install mypy

将以下代码放入名为 headlines.py 的文件中:

# headlines.py

def headline(text: str, align: bool = True) -> str:
    if align:
        return f"{text.title()}\n{'-' * len(text)}"
    else:
        return f" {text.title()} ".center(50, "o")

print(headline("python type checking"))
print(headline("use mypy", align="center"))

这与您之前看到的代码基本相同:headline() 的定义和两个使用它的示例。

现在在此代码上运行 Mypy:

$ mypy headlines.py
headlines.py:10: error: Argument "align" to "headline" has incompatible
                        type "str"; expected "bool"

基于类型提示,Mypy 能够告诉我们在第 10 行使用了错误的类型。

要解决代码中的问题,应该更改传入 align 参数的值。还可以将 align 标志重命名为不那么容易混淆的名称:

# headlines.py

def headline(text: str, centered: bool = False) -> str:
    if not centered:
        return f"{text.title()}\n{'-' * len(text)}"
    else:
        return f" {text.title()} ".center(50, "o")

print(headline("python type checking"))
print(headline("use mypy", centered=True))

在这里,已经将 align 改为 centered,并在调用 headline() 时正确使用了 centered 的布尔值。代码现在可以通过 Mypy:

$ mypy headlines.py
Success: no issues found in 1 source file

Success 信息确认了没有检测到类型错误。旧版本的 Mypy 曾经通过根本不显示任何输出来表明这一点。此外,当运行代码时,会看到预期的输出:

$ python headlines.py
Python Type Checking
--------------------
oooooooooooooooooooo Use Mypy oooooooooooooooooooo

第一个标题左对齐,而第二个居中。

优点与缺点

上一节简单展示了 Python 中类型检查是什么样子。还展示了在代码中添加类型的益处之一的例子:类型提示有助于捕获某些错误。其他优点包括:

  • 类型提示有助于文档化代码。传统地,如果想记录函数参数的预期类型,会使用文档字符串。这是可行的,但由于没有文档字符串的标准(尽管有 PEP 257 ),它们不能简单地用于自动检查。

  • 类型提示提升 IDE 和 linter。它们使静态推断代码变得更加容易。这反过来又允许 IDE 提供更好的代码补全及类似功能。通过类型注解,PyCharm 知道 text 是一个字符串,并可以基于此给出具体建议:

    Python 类型系统与类型检查(翻译)_第3张图片

  • 类型提示可以帮助构建和维护更整洁的架构。编写类型提示的行为迫使您考虑程序中的类型。虽然 Python 的动态特性是它最重要的财产之一,但有意识地依靠鸭子类型、重载方法或多个返回类型是一件好事。

当然,静态类型检查并非尽善尽美。还应该考虑一些缺点:

  • 类型提示需要开发者花时间精力来添加。尽管可能带来减少调试时间的回报,但需要花费更多时间输入代码。
  • 类型提示在现代 Python 中效果最佳。注解是在 Python 3.0 中引入的,并且可以在 Python 2.7 中使用类型注释。尽管如此,变量注解和类型提示的延迟评估等改进意味着,在 Python 3.6 甚至 Python 3.7 中进行类型检查会有更好的体验。
  • 类型提示会在启动时间上带来轻微的损失。如果需要使用 typing 模块,导入时间可能很长,在短脚本中尤是。

那么,您应该在自己的代码中使用静态类型检查吗?事实上,这不是一个全是或全非的问题。幸运的是,Python 支持渐进类型的概念。这意味着可以逐渐将类型引入到代码中。没有类型提示的代码将会被静态类型检查器忽略。因此,可以从向关键组件添加类型开始,然后在它提供价值的情况下继续。

查看上面的优缺点表,会注意到添加类型不会影响正在运行的程序或程序的用户。类型检查旨在让您作为开发者的生活更美好、更方便。

关于是否向项目添加类型的一些经验法则是:

  • 如果刚开始学习 Python,在有更多的经验之前,可以放心地暂不考虑类型提示。
  • 类型提示在简短的一次性脚本中几乎没什么价值。
  • 在将被他人使用的库中,尤其是那些在 PyPI 上发布的,类型提示添加了很多价值。其他使用您的库的代码需要这些类型提示才能正确地进行类型检查。有关使用类型提示的项目示例,参见 cursive_reblack、Real Python Reader 和 Mypy 本身。
  • 在更大的项目中,类型提示可以帮助了解类型在代码中如何流动,并且是强烈推荐使用的。在与他人合作的项目中更是如此。

在他的优秀文章 The State of Type Hints in Python 中,Bernát Gábor 建议“只要值得编写单元测试,就应该使用类型提示。”事实上,类型提示在代码中扮演着与 tests 相似的角色:它们可以帮助您作为开发者编写更好的代码。

希望您现在对 Python 中的类型检查如何工作,以及您是否想在自己的项目中使用它所有了解。

在本指南的其余部分,我们将详细介绍 Python 类型系统,包括如何运行静态类型检查器(特别关注 Mypy)、如何使用没有类型提示的库的类型检查代码,以及如何在运行时使用注解。

注解

注解在 Python 3.0 中被引入,最初没有任何特定目的。它们只是将任意表达式与函数参数和返回值相关联的一种方式。

多年后,基于 Jukka Lehtosalo 在他的博士项目—— Mypy 中做的工作,PEP 484 定义了如何在 Python 代码中添加类型提示。添加类型提示的主要方法是使用注解。随着类型检查变得越来越普遍,这也意味着注解应该主要保留用于类型提示。

下一节将解释注解如何在类型提示的上下文中工作。

函数注解

对于函数,可以注解参数和返回值。这是按如下方式完成的:

def func(arg: arg_type, optarg: arg_type = default) -> return_type:
    ...

对于参数,语法为 参数名: 注解,而返回值使用 -> 注解 进行注解。注意,注解必须是有效的 Python 表达式。

下面这个简单示例为计算圆的周长的函数添加注解:

import math

def circumference(radius: float) -> float:
    return 2 * math.pi * radius

运行代码时,还可以考察注解。它们存储在函数的特殊属性 .__annotations__ 中:

>>> circumference(1.23)
7.728317927830891

>>> circumference.__annotations__
{'radius': <class 'float'>, 'return': <class 'float'>}

有时可能会对 Mypy 如何解释类型提示感到困惑。对于这些情况,有特殊的 Mypy 表达式:reveal_type()reveal_locals()。可以在运行 Mypy 之前将这些添加到代码中,Mypy 将尽职尽责地报告它推断出的类型。例如,将以下代码保存到 reveal.py 中:

# reveal.py

import math
reveal_type(math.pi)

radius = 1
circumference = 2 * math.pi * radius
reveal_locals()

接下来,通过 Mypy 运行这段代码:

$ mypy reveal.py
reveal.py:4: error: Revealed type is 'builtins.float'

reveal.py:8: error: Revealed local types are:
reveal.py:8: error: circumference: builtins.float
reveal.py:8: error: radius: builtins.int

即使没有任何注解,Mypy 也正确推断了内置 math.pi 的类型,以及局部变量 radiuscircumference 的。

Note: reveal 表达式仅用作辅助添加类型和调试类型提示的工具。如果尝试将 reveal.py 文件作为 Python 脚本运行,它将因 NameError 崩溃,因为 reveal_type() 不是 Python 解释器已知的函数。

如果 Mypy 提示 “Name ‘reveal_locals’ is not defined”,可能需要更新安装的 Mypy。reveal_locals() 表达式在 Mypy version 0.610 或更新的版本可用。

变量注解

在上一节 circumference() 的定义中,只注解了参数和返回值。没有在函数体内添加任何注解。通常,这已经足够了。

然而,有时类型检查器也需要帮助来确定变量的类型。变量注解在 PEP 526 中被定义,并在 Python 3.6 中被引入。语法与函数参数注解的相同:

pi: float = 3.142

def circumference(radius: float) -> float:
    return 2 * pi * radius

变量 pi 已经被 flot 类型提示注解。

Note: 静态类型检查器能确定 3.142 是一个浮点数,所以在这个例子中 pi 的注解是不必要的。随着您对 Python 类型系统了解更多,将看到更多相关的变量注解示例。

变量的注解存储在模块级的 __annotations__ 字典中:

>>> circumference(1)
6.284

>>> __annotations__
{'pi': <class 'float'>}

可以在不给变量赋值的情况下对变量进行注解。这会将注解添加到 __annotations__ 字典中,而变量保存未定义:

>>> nothing: str
>>> nothing
NameError: name 'nothing' is not defined

>>> __annotations__
{'nothing': <class 'str'>}

因为没有为 nothing 分配任何值,因此名称 nothing 尚未定义。

类型注解与类型注释

译者注:由于 Python 2 已停止维护多年,故翻译时略过原文中适用于 Python 2 的“类型注释”一节。

在向自己的代码中添加类型提示时,该使用注解还是类型注释?简而言之:优先使用注解,被迫使用类型注释

注解提供了更简洁的语法,使类型信息更接近代码。这也是编写类型提示的官方推荐方式,未来会进一步开发和妥善维护。

类型注释更冗长,并且可能与代码中的其他类型的注释冲突,例如 linter directives。但是它们可以在不支持注解的代码库中使用。

还有隐藏的第三个选项:存根文件。稍后在讨论向第三方库中添加类型时,将介绍这些内容。

存根文件可以在任何版本的 Python 中工作,但代价是必须维护第二组文件。通常,只在无法更改原始源代码时,才使用存根文件。

玩转 Python 类型,第 1 部分

到目前为止,只在类型提示中使用了诸如 strfloatbool 之类的基本类型。Python 类型系统非常强大,支持多种更复杂的类型。这是必要的,因为它需要能够合理地建模 Python 的动态鸭子类型性质。

在本节中,将介绍关于此类型系统的更多信息,同时实现一个简单的纸牌游戏。您将看到如何指定:

  • 元组、列表和字典等序列和映射的类型
  • 使代码更易阅读的类型别名
  • 不返回任何东西的函数和方法
  • 对象可能是 Any 类型

在简要了解一些类型理论之后,您将看到更多在 Python 中指定类型的方法。可以在此处找到本节中的代码示例。

例子:一副纸牌

以下示例显示了常规(法国)纸牌的实现:

# game.py

import random

SUITS = "♠ ♡ ♢ ♣".split()
RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()

def create_deck(shuffle=False):
    """创建一副 52 张牌的新牌组"""
    deck = [(s, r) for r in RANKS for s in SUITS]
    if shuffle:
        random.shuffle(deck)
    return deck

def deal_hands(deck):
    """将牌组中的牌分成四手"""
    return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])

def play():
    """玩 4 人纸牌游戏"""
    deck = create_deck(shuffle=True)
    names = "P1 P2 P3 P4".split()
    hands = {n: h for n, h in zip(names, deal_hands(deck))}

    for name, cards in hands.items():
        card_str = " ".join(f"{s}{r}" for (s, r) in cards)
        print(f"{name}: {card_str}")

if __name__ == "__main__":
    play()

每张牌都用一个代表花色和等级的字符串元组(tuple of strings)表示。牌组表示为一个牌的列表(list)。create_deck() 创建一个由 52 张扑克牌组成的常规牌组,并可以选择洗牌。deal_hands() 将一副牌发给四个玩家。

最后,play() 进行游戏。到目前为止,它只是通过构建一个洗好的牌组并向每个玩家发牌来为纸牌游戏做准备。下面是一个典型的输出:

$ python game.py
P4: ♣9 ♢9 ♡2 ♢7 ♡7 ♣A ♠6 ♡K ♡5 ♢6 ♢3 ♣3 ♣Q
P1: ♡A ♠2 ♠10 ♢J ♣10 ♣4 ♠5 ♡Q ♢5 ♣6 ♠A ♣5 ♢4
P2: ♢2 ♠7 ♡8 ♢K ♠3 ♡3 ♣K ♠J ♢A ♣7 ♡6 ♡10 ♠K
P3: ♣2 ♣8 ♠8 ♣J ♢Q ♡9 ♡J ♠4 ♢8 ♢10 ♠9 ♡4 ♠Q

随着我们的深入,将看到如何将此示例扩展为更有趣的游戏。

序列和映射

让我们在纸牌游戏中添加类型提示。换句话说,为函数 create_deck()deal_hands()play() 添加注解。第一个挑战是注解复合类型,例如用于表示卡牌组的列表和用于表示卡牌本身的元组。

对于像 strfloatbool 这样的简单的类型,添加类型提示就像使用类型本身一样简单:

>>> name: str = "Guido"
>>> pi: float = 3.142
>>> centered: bool = False

对于复合类型,也可以这样做:

>>> names: list = ["Guido", "Jukka", "Ivan"]
>>> version: tuple = (3, 7, 1)
>>> options: dict = {"centered": False, "capitalize": True}

然而,这并不能说明全部情况。names[2]version[0]options["centered"] 的类型是什么?在这个具体案例中,可以看到它们分别是 strintbool。但是类型提示本身没有提供有关于此的信息。

作为替代,应该使用 typing 模块中定义的特殊类型。这些类型添加了用于指定复合类型中的元素的类型的语法。

>>> from typing import Dict, List, Tuple

>>> names: List[str] = ["Guido", "Jukka", "Ivan"]
>>> version: Tuple[int, int, int] = (3, 7, 1)
>>> options: Dict[str, bool] = {"centered": False, "capitalize": True}

注意这些类型中的每一个都以大写字母开头,并且它们都用方括号来定义项目类型:

  • names 是一个字符串的列表
  • version 是一个由三个整型组成的 3 元组
  • options 是一个将字符串映射到布尔值的字典

typing 模块包含更多的复合类型,包括 CounterDequeFrozenSetNamedTupleset。此外,该模块还包含将在后面的节中看到的其他类型。

让我们回到纸牌游戏。每张卡牌用一个由两个字符串构成的元组表示。可以把它写成 Tuple[str, str],所以牌组的类型变成了 List[Tuple[str, str]]。因此,可以如下注解 create_deck()

def create_deck(shuffle: bool = False) -> List[Tuple[str, str]]:
    """创建一副 52 张牌的新牌组"""
    deck = [(s, r) for r in RANKS for s in SUITS]
    if shuffle:
        random.shuffle(deck)
    return deck

除了返回值之外,还添加了 bool 类型给可选参数 shuffle

Note: 元组和列表以不同方式注解。

元组是一个不可变的序列,通常由固定数量的、可能类型不同的元素组成。例如,我们将卡牌表示为花色和等级的元组。通常,将 n 元组写为 Tuple[t_1, t_2, ..., t_n]

列表是一种可变序列,通常由未知数量的相同类型元素组成,例如卡牌列表。无论列表中有多少元素,注解中只有一种类型:List[t]

在许多情况下,函数期望某种序列,而并不真正关系它是列表还是元组。在这些情况下,应该在注解函数参数时使用 typing.Sequence

from typing import List, Sequence

def square(elems: Sequence[float]) -> List[float]:
    return [x**2 for x in elems]

使用 Sequence 是使用鸭子类型的一个例子。Sequence 是任何支持 len().__getitem__()的东西,与它的实际类型无关。

类型别名

当使用像牌组这样的嵌套类型时,类型提示可能会变得非常隐晦。在确定它是否与我们对一副纸牌的表示相匹配之前,可能需要先看一下 List[Tuple[str, str]]

现在考虑如何注解 deal_hands()

def deal_hands(
    deck: List[Tuple[str, str]]
) -> Tuple[
    List[Tuple[str, str]],
    List[Tuple[str, str]],
    List[Tuple[str, str]],
    List[Tuple[str, str]],
]:
    """将牌组中的牌分成四手"""
    return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])

这真是太可怕了!

回想一下,类型注解是常规的 Python 表达式。这意味着可以通过将它们分配给新变量来定义自己的类型别名。例如,可以创建 CardDeck 类型别名:

from typing import List, Tuple

Card = Tuple[str, str]
Deck = List[Card]

Card 现在可以用于类型提示或新类型别名的定义中,例如上面示例中的 Deck

使用这些别名,deal_hands() 的注解变得更具可读性:

def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]:
    """将牌组中的牌分成四手"""
    return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])

类型别名非常适合使得代码及其意图更加清晰。同时,可以检查这些别名以查看它们所代表的内容:

>>> from typing import List, Tuple
>>> Card = Tuple[str, str]
>>> Deck = List[Card]

>>> Deck
typing.List[typing.Tuple[str, str]]

注意当打印 Deck 时,显示它是 2 元组字符串列表的别名。

没有返回值的函数

您可能知道没有显式返回的函数仍然返回 None

>>> def play(player_name):
...     print(f"{player_name} plays")
...

>>> ret_val = play("Jacob")
Jacob plays

>>> print(ret_val)
None

虽然这些函数在技术上会返回一些东西,但返回值是没有用的。应该通过使用 None 作为返回类型来添加类型提示:

# play.py

def play(player_name: str) -> None:
    print(f"{player_name} plays")

ret_val = play("Filip")

注解有助于捕获尝试使用无意义的返回值的各种细微错误。Mypy 会给出一个实用的警告:

$ mypy play.py
play.py:6: error: "play" does not return a value

注意,明确说明函数不返回任何内容,与不添加关于返回值的类型提示有区别:

# play.py

def play(player_name: str):
    print(f"{player_name} plays")

ret_val = play("Henrik")

在后一种情况下,Mypy 没有关于返回值的信息,因此它不会产生任何警告:

$ mypy play.py
Success: no issues found in 1 source file

作为一种更奇异的情况,注意还可以注释永远不会正常返回的函数。这是使用 NoReturn 完成的:

from typing import NoReturn

def black_hole() -> NoReturn:
    raise Exception("There is no going back ...")

由于 black_hole() 总是引发异常,它永远不会正确返回。

例子:速来玩牌

让我们回到我们的纸牌游戏示例。在这个游戏的第二个版本中,我们像以前一样向每个玩家发一手牌。然后选择一名起始玩家,各玩家轮流出牌。游戏中并没有真正的规则,所以玩家只会随机出牌:

# game.py

import random
from typing import List, Tuple

SUITS = "♠ ♡ ♢ ♣".split()
RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()

Card = Tuple[str, str]
Deck = List[Card]

def create_deck(shuffle: bool = False) -> Deck:
    """创建一副 52 张牌的新牌组"""
    deck = [(s, r) for r in RANKS for s in SUITS]
    if shuffle:
        random.shuffle(deck)
    return deck

def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]:
    """将牌组中的牌分成四手"""
    return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])

def choose(items):
    """选择并返回一个随机项目"""
    return random.choice(items)

def player_order(names, start=None):
    """轮换玩家顺序,以便首先开始"""
    if start is None:
        start = choose(names)
    start_idx = names.index(start)
    return names[start_idx:] + names[:start_idx]

def play() -> None:
    """玩 4 人纸牌游戏"""
    deck = create_deck(shuffle=True)
    names = "P1 P2 P3 P4".split()
    hands = {n: h for n, h in zip(names, deal_hands(deck))}
    start_player = choose(names)
    turn_order = player_order(names, start=start_player)

    # 随机从每个玩家的手牌中打出,直到为空
    while hands[start_player]:
        for name in turn_order:
            card = choose(hands[name])
            hands[name].remove(card)
            print(f"{name}: {card[0] + card[1]:<3}  ", end="")
        print()

if __name__ == "__main__":
    play()

注意除了更改 play() 之外,还添加了两个需要类型提示的新函数:choose()player_order()。在讨论如何向它们添加类型提示之前,这里有一个运行游戏的示例输出:

$ python game.py
P3: ♢10  P4: ♣4   P1: ♡8   P2: ♡Q
P3: ♣8   P4: ♠6   P1: ♠5   P2: ♡K
P3: ♢9   P4: ♡J   P1: ♣A   P2: ♡A
P3: ♠Q   P4: ♠3   P1: ♠7   P2: ♠A
P3: ♡4   P4: ♡6   P1: ♣2   P2: ♠K
P3: ♣K   P4: ♣7   P1: ♡7   P2: ♠2
P3: ♣10  P4: ♠4   P1: ♢5   P2: ♡3
P3: ♣Q   P4: ♢K   P1: ♣J   P2: ♡9
P3: ♢2   P4: ♢4   P1: ♠9   P2: ♠10
P3: ♢A   P4: ♡5   P1: ♠J   P2: ♢Q
P3: ♠8   P4: ♢7   P1: ♢3   P2: ♢J
P3: ♣3   P4: ♡10  P1: ♣9   P2: ♡2
P3: ♢6   P4: ♣6   P1: ♣5   P2: ♢8

在这个例子中,玩家 P3 被随机选择为起始玩家。依次,每个玩家打出一张牌:首先是 P3,然后是 P4,然后是 P1,最后是 P2。只要手上还有剩余的牌,玩家就会继续打牌。

Any 类型

choose() 同时适用于名称列表和卡片列表(以及与此相关的任何其他序列)。为此添加类型提示的一种方法如下:

import random
from typing import Any, Sequence

def choose(items: Sequence[Any]) -> Any:
    return random.choice(items)

或多或少地,像其字面意思那样:items 是一个可以包含任何类型项目的序列,而 choose() 将返回一个任何类型的此类项目。不幸的是,这不是很有用。考虑以下示例:

# choose.py

import random
from typing import Any, Sequence

def choose(items: Sequence[Any]) -> Any:
    return random.choice(items)

names = ["Guido", "Jukka", "Ivan"]
reveal_type(names)

name = choose(names)
reveal_type(name)

虽然 Mypy 会正确推断 names 是一个字符串列表,但由于使用了 Any 类型,在调用 choose() 后该信息会丢失:

$ mypy choose.py
choose.py:10: error: Revealed type is 'builtins.list[builtins.str*]'
choose.py:13: error: Revealed type is 'Any'

您将很快看到更好的方法。不过首先,让我们从理论上看一下 Python 的类型系统,以及 Any 所扮演的特殊角色。

类型理论

本教程主要是一个实用指南,我们只会触及支承 Python 类型提示的理论的表面。有关更多详细信息,PEP 483 是一个很好的起点。如果想回到实际示例,可以随时跳到下一节。

子类型

一个重要的概念是子类型(subtypes)。形式上,如果满足以下两个条件,我们说类型 TU 的子类型:

  • T 中的每个值也在 U 类型的值集中。
  • U 类型的每个函数也在 T 类型的函数集中。

这两个条件保证即使 T 类型与 U 不同,T 类型的变量也总是可以伪装成 U

举个具体的例子,考虑 T = bool 并且 U = intbool 类型只接受两个值。通常被表示为 TrueFalse,但这些名称分别只是整数值 10 的别名:

>>> int(False)
0

>>> int(True)
1

>>> True + True
2

>>> issubclass(bool, int)
True

由于 0 和 1 都是整数,所以第一个条件成立。从上面看到布尔值可以相加,但它们也可以做其他整型可以做到的任何事情。这是上面的第二个条件。换句话说,boolint 的子类型。

子类型的重要性在于,子类型总是可以伪装成它的超类型。例如,以下代码类型检查为正确:

def double(number: int) -> int:
    return number * 2

print(double(True))  # Passing in bool instead of int

子类型与子类有些相关。事实上,所有的子类都对应着子类型,而因为 boolint 的子类,所以 boolint 的子类型。但也有不对应子类的子类型。例如 intfloat 的子类型,但 int 不是 float 的子类。

协变、逆变与不变

当在复合类型中使用子类型时会发生什么?例如,Tuple[bool]Tuple[int] 的子类型吗?答案取决于复合类型,以及该协议是协变的、逆变的还是不变的。这很快涉及到技术,所以让我们举几个例子:

  • Tuple 是协变的(covariant)。这意味着它保留了其他项目类型的层次结构:因为 boolint 的子类型,所以 Tuple[bool]Tuple[int] 的子类型。
  • List 是不变的(invariant)。不变类型不保证子类型。虽然 List[bool] 的所有值都是 List[int] 的值,但可以将 int 追加到 List[int] 中,而不能追加到 List[bool] 中。换句话说,子类型的第二个条件不成立,并且 List[bool] 不是 List[int] 的子类型。
  • Callable 的参数是逆变的(contravariant)。这意味着它颠倒了类型层次结构。稍后将展示 Callable 是如何工作的,但现在将 Callable[[T], ...] 视为一个函数,其唯一的参数是 T 类型。Callable[[int], ...] 的一个例子是上面定义的 double() 函数。逆变意味着,如果期望一个在 bool 上运行的函数,那么一个在 int 上运行的函数是可以接受的。

一般来说,不需要保持这些表达式直截了当。但是应该意识到子类型和复合类型可能并不简单和直观。

渐进类型和一致类型

前面我们提到 Python 支持渐进类型,可以在其中逐渐向 Python 代码添加类型提示。Any 类型使渐进式输入基本成为可能。

不知何故,Any 同时位于子类型的类型层次结构的顶部和底部。Any 类型的行为就好像它是 Any 的子类型,同时 Any 的行为又好像它是其他任何类型的子类型。在它上面按定义考察子类型是不可能的。相反,我们讨论一致类型

如果 TU 的子类型,或 TU 中有一个是 Any,则 T 类型与 U 类型一致。

类型检查器只抱怨不一致的类型。因此重点是永远不会看到由 Any 类型引起的类型错误。

这意味着可以使用 Any 来显式回退到动态类型,描述在 Python 类型系统中过于复杂而无法描述的类型,或者描述复合类型中的项目。例如,具有字符串键、可以接受任何类型作为其值的字典,可以注解为 Dict[str, Any]

但请记住,如果使用 Any,则静态类型检查器实际上不会进行对任何类型的任何检查。

玩转 Python 类型,第 2 部分

让我们回到实际例子。回想一下,正在尝试注解一般的 choose() 函数:

import random
from typing import Any, Sequence

def choose(items: Sequence[Any]) -> Any:
    return random.choice(items)

使用 Any 的问题是会不必要地丢失类型信息。可以知道,如果将字符串列表传递给 choose(),它将返回一个字符串。下面将展示如何用类型变量来表达这一点,以及如何使用:

  • 鸭子类型与协议
  • None 作为默认值的参数
  • 类方法
  • 自己的类的类型
  • 可变数量的参数

类型变量

类型变量(Type Variables)是一种特殊变量,可以根据情况接受任何类型。

让我们创建一个类型变量,它将有效地封装 choose() 的行为:

# choose.py

import random
from typing import Sequence, TypeVar

Choosable = TypeVar("Choosable")

def choose(items: Sequence[Choosable]) -> Choosable:
    return random.choice(items)

names = ["Guido", "Jukka", "Ivan"]
reveal_type(names)

name = choose(names)
reveal_type(name)

必须使用 typing 模块中的 TypeVar 定义类型变量。使用时,类型变量涵盖所有可能的类型,并尽可能采用最具体的类型。在示例中,name 现在是 str

$ mypy choose.py
choose.py:12: error: Revealed type is 'builtins.list[builtins.str*]'
choose.py:15: error: Revealed type is 'builtins.str*'

考虑其他几个例子:

# choose_examples.py

from choose import choose

reveal_type(choose(["Guido", "Jukka", "Ivan"]))
reveal_type(choose([1, 2, 3]))
reveal_type(choose([True, 42, 3.14]))
reveal_type(choose(["Python", 3, 7])

前两个示例应该是 strint,但后两个呢?各个列表项有不同的类型,在这种情况下,Choosable 类型变量会尽可能适应:

$ mypy choose_examples.py
choose_examples.py:5: error: Revealed type is 'builtins.str*'
choose_examples.py:6: error: Revealed type is 'builtins.int*'
choose_examples.py:7: error: Revealed type is 'builtins.float*'
choose_examples.py:8: error: Revealed type is 'builtins.object*'

正如上文已经提到的,boolint 的子类型,而 int 又是 float 的子类型。所以在第三个例子中,choose() 的返回值保证是可以被视为 float 的东西。在最后一个例子中,strint 之间没有子类型关系,所以关于返回值的最好说法是它是一个对象(object)。

注意这些示例都没有引发类型错误,有没有办法告诉类型检查器 choose() 应该接受字符串和数字,但不能同时接受?

可以通过列出可接受的类型来约束类型变量:

# choose.py

import random
from typing import Sequence, TypeVar

Choosable = TypeVar("Choosable", str, float)

def choose(items: Sequence[Choosable]) -> Choosable:
    return random.choice(items)

reveal_type(choose(["Guido", "Jukka", "Ivan"]))
reveal_type(choose([1, 2, 3]))
reveal_type(choose([True, 42, 3.14]))
reveal_type(choose(["Python", 3, 7]))

现在 Choosable 只能是 strfloat,Mypy 会注意到最后一个例子是错误的:

$ mypy choose.py
choose.py:11: error: Revealed type is 'builtins.str*'
choose.py:12: error: Revealed type is 'builtins.float*'
choose.py:13: error: Revealed type is 'builtins.float*'
choose.py:14: error: Revealed type is 'builtins.object*'
choose.py:14: error: Value of type variable "Choosable" of "choose"
                     cannot be "object"

另请注意,在第二个示例中,即使输入列表仅包含 int 对象,该类型仍被视为 float。这是因为 Choosable 仅限于字符串和浮点数,而 intfloat 的子类型。

在我们的纸牌游戏中,我们希望限制 choose() 用于 strCard

Choosable = TypeVar("Choosable", str, Card)

def choose(items: Sequence[Choosable]) -> Choosable:
    ...

我们简单地提到了 Sequence 可以同时表示列表和元组。正如我们指出的,Sequence 可以被认为是一种鸭子类型,因为它可以是任何实现了 .__len__().__getitem__() 的对象。

鸭子类型与协议

回顾一下引言中的以下示例:

def len(obj):
    return obj.__len__()

len() 可以返回任何实现了 .__len__() 方法的对象的长度。如何向 len(),特别是 obj 参数添加类型提示?

答案就藏在学术气息浓厚的术语结构子类型(structural subtyping)之中。对类型系统进行分类的一种方法是根据它们是名义的(nominal)的还是结构的(structural)

  • 名义的系统中,类型之间的比较基于名称和声明。Python 类型系统大部分是名义的,这里可以因其子类型关系,而使用 int 替代 float
  • 结构的系统中,类型之间的比较是基于结构的。可以定义一个结构的类型 Sized,其中包括所有定义了 .__len__() 的实例,无论它们的名义类型如何。

正在进行的工作是通过 PEP 544 为 Python 带来一个成熟的结构的类型系统,这旨在添加一个称为协议的概念。不过,PEP 544 的大部分已经在 Mypy 中实现了。

协议指定必须实现的一种或多种方法。例如,所有定义 .__len__() 的类都满足 typing.Sized 协议。因此,可以如下注解 len()

from typing import Sized

def len(obj: Sized) -> int:
    return obj.__len__()

typing 模块中定义的其他协议示例包括 ContainerIterableAwaitableContextManager

还可以定义自己的协议。这是通过从 Protocol 继承并定义协议期望的函数签名(带有空函数体)来实现的。以下示例展示了如何实现 len()Sized

from typing_extensions import Protocol

class Sized(Protocol):
    def __len__(self) -> int: ...

def len(obj: Sized) -> int:
    return obj.__len__()

在撰写本文时,对自定义协议的支持仍处于试验阶段,只能通过 typing_extensions 模块获得。该模块必须通过执行 pip install typing-extensions 从 PyPI 显示安装。

Optional 类型

Python 中的一个常见模式是使用 None 作为参数的默认值。这通常是为了避免因为可变默认值出问题,或者为了让哨兵值标记特殊行为。

在卡牌示例中,player_order() 函数使用 None 作为 start 的哨兵值,表示如果没有给出起始玩家,则应随机选择:

def player_order(names, start=None):
    """轮换玩家顺序,以便首先开始"""
    if start is None:
        start = choose(names)
    start_idx = names.index(start)
    return names[start_idx:] + names[:start_idx]

这给类型提示带来的挑战是,通常 start 应该是一个字符串。但它也可能接受特殊的非字符串值 None

为了注解这些参数,可以使用 Optional 类型:

from typing import Sequence, Optional

def player_order(
    names: Sequence[str], start: Optional[str] = None
) -> Sequence[str]:
    ...

Optional 类型简单表示一个变量要么具有指定的类型,要么为 None。指定同样类型的等效方法是使用 Union 类型:Union[None, str]

注意当使用 Optional 或者 Union 时,必须注意变量在操作时具有正确的类型。在示例中,这是通过测试 start is None 来完成的。不这样做会导致静态类型错误,且可能导致运行时错误:

# player_order.py

from typing import Sequence, Optional

def player_order(
    names: Sequence[str], start: Optional[str] = None
) -> Sequence[str]:
    start_idx = names.index(start)
    return names[start_idx:] + names[:start_idx]

Mypy 告知没有处理 startNone 的情况:

$ mypy player_order.py
player_order.py:8: error: Argument 1 to "index" of "list" has incompatible
                          type "Optional[str]"; expected "str"

Note: 对可选参数使用 None 非常普遍,以至于 Mypy 会自动处理它。Mypy 假定默认参数 None 表示可选参数,即使类型提示没有明确说明亦是如此。可以使用以下代码:

def player_order(names: Sequence[str], start: str = None) -> Sequence[str]:
    ...

如果不想让 Mypy 做这个假设,可以使用 --no-implicit-optional 命令行选项来关闭它。

例子:面向对象版本的游戏

让我们重写纸牌游戏,让其更加面向对象。这将有助于我们讨论如何正确注解类和方法。

将纸牌游戏或多或少直译为使用 CardDeckPlayerGame 类的代码如下所示:

# game.py

import random
import sys

class Card:
    SUITS = "♠ ♡ ♢ ♣".split()
    RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()

    def __init__(self, suit, rank):
        self.suit = suit
        self.rank = rank

    def __repr__(self):
        return f"{self.suit}{self.rank}"

class Deck:
    def __init__(self, cards):
        self.cards = cards

    @classmethod
    def create(cls, shuffle=False):
        """创建一副 52 张牌的新牌组"""
        cards = [Card(s, r) for r in Card.RANKS for s in Card.SUITS]
        if shuffle:
            random.shuffle(cards)
        return cls(cards)

    def deal(self, num_hands):
        """将牌组中的牌分成几手"""
        cls = self.__class__
        return tuple(cls(self.cards[i::num_hands]) for i in range(num_hands))

class Player:
    def __init__(self, name, hand):
        self.name = name
        self.hand = hand

    def play_card(self):
        """从玩家手中打出一张牌"""
        card = random.choice(self.hand.cards)
        self.hand.cards.remove(card)
        print(f"{self.name}: {card!r:<3}  ", end="")
        return card

class Game:
    def __init__(self, *names):
        """设置牌组并向 4 名玩家发牌"""
        deck = Deck.create(shuffle=True)
        self.names = (list(names) + "P1 P2 P3 P4".split())[:4]
        self.hands = {
            n: Player(n, h) for n, h in zip(self.names, deck.deal(4))
        }

    def play(self):
        """玩纸牌游戏"""
        start_player = random.choice(self.names)
        turn_order = self.player_order(start=start_player)

        # 从每个玩家的手牌打出直到为空
        while self.hands[start_player].hand.cards:
            for name in turn_order:
                self.hands[name].play_card()
            print()

    def player_order(self, start=None):
        """轮换玩家顺序,以便首先开始"""
        if start is None:
            start = random.choice(self.names)
        start_idx = self.names.index(start)
        return self.names[start_idx:] + self.names[:start_idx]

if __name__ == "__main__":
    # 从命令行读取玩家姓名
    player_names = sys.argv[1:]
    game = Game(*player_names)
    game.play()

现在让我们为这段代码添加类型。

方法的类型提示

首先,方法的类型提示与函数的类型提示的工作方式非常相似。唯一的区别在于 self 参数不需要注解,因为它总是一个类实例。Card 类的类型很容易添加:

class Card:
    SUITS = "♠ ♡ ♢ ♣".split()
    RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()

    def __init__(self, suit: str, rank: str) -> None:
        self.suit = suit
        self.rank = rank

    def __repr__(self) -> str:
        return f"{self.suit}{self.rank}"

注意,.__init__() 方法的返回值类型应始终为 None

类作为类型

类和类型之间存在对应关系。例如,Card 类的所有实例一起构成 Card 类型。要将类用作类型,只需使用类的名称。

例如,Deck 本质上由 Card 对象的列表组成。可以对此进行注解如下:

class Deck:
    def __init__(self, cards: List[Card]) -> None:
        self.cards = cards

Mypy 能够将在注解中使用的 CardCard 类的定义联系起来。

然而,在需要引用当前正在定义的类时,这并不能正常工作。例如,Deck.create() 类方法返回一个类型为 Deck 的对象。但并不能简单地添加 -> Deck,因为 Deck 类尚未完全定义。

相反,可以在注解中使用字符串文字。这些字符串稍后仅由类型检查器评估,因此可以包含自引用和前向引用。.create() 方法应该为其类型使用这样的字符串文字:

class Deck:
    @classmethod
    def create(cls, shuffle: bool = False) -> "Deck":
        """创建一副 52 张牌的新牌组"""
        cards = [Card(s, r) for r in Card.RANKS for s in Card.SUITS]
        if shuffle:
            random.shuffle(cards)
        return cls(cards)

注意,Player 类也将引用 Deck 类。但这没有问题,因为 Deck 是在 Player 之前定义的:

class Player:
    def __init__(self, name: str, hand: Deck) -> None:
        self.name = name
        self.hand = hand

通常在运行时不使用注解。这为推迟注解评估的想法提供了支持。建议不是将注解评估为 Python 表达式并存储它们的值,而是存储注解的字符串表示形式,并仅在需要时对其进行评估。

这样的功能计划在仍然神秘的 Python 4.0 中成为标准。然而,在 Python 3.7 及更高版本中,前向引用可通过 __future__ 导入获得:

from __future__ import annotations

class Deck:
    @classmethod
    def create(cls, shuffle: bool = False) -> Deck:
        ...

通过 __future__ 导入,甚至可以在 Deck 被定义之前使用 Deck,而不是 "Deck"

返回 selfcls

如前所述,通常不应注解 selfcls 参数。这是非必需的,因为 self 指向类的实例,因此它将具有类的类型。在 Card 示例中,self 具有隐式类型 Card。此外,由于尚未定义该类,因此显示添加此类型会很麻烦。必须使用字符串文字语法,self: "Card"

但是,在一种情况下,可能想要注释 self 或者 cls。考虑如果有一个会被其他类继承的超类,且它的方法返回 selfcls 会发生什么:

# dogs.py

from datetime import date

class Animal:
    def __init__(self, name: str, birthday: date) -> None:
        self.name = name
        self.birthday = birthday

    @classmethod
    def newborn(cls, name: str) -> "Animal":
        return cls(name, date.today())

    def twin(self, name: str) -> "Animal":
        cls = self.__class__
        return cls(name, self.birthday)

class Dog(Animal):
    def bark(self) -> None:
        print(f"{self.name} says woof!")

fido = Dog.newborn("Fido")
pluto = fido.twin("Pluto")
fido.bark()
pluto.bark()

代码运行没有问题,但 Mypy 会标记一个问题:

$ mypy dogs.py
dogs.py:24: error: "Animal" has no attribute "bark"
dogs.py:25: error: "Animal" has no attribute "bark"

问题在于,虽然继承的 Dog.newborn()Dog.twin() 方法将返回 Dog,但注解说它们会返回 Animal

在这种情况下,需要更加小心以确保注解正确。返回类型应该匹配 self 的类型或 cls 的实例类型。这可以使用类型变量来完成,这些变量跟踪实际传递给 selfcls 的内容:

# dogs.py

from datetime import date
from typing import Type, TypeVar

TAnimal = TypeVar("TAnimal", bound="Animal")

class Animal:
    def __init__(self, name: str, birthday: date) -> None:
        self.name = name
        self.birthday = birthday

    @classmethod
    def newborn(cls: Type[TAnimal], name: str) -> TAnimal:
        return cls(name, date.today())

    def twin(self: TAnimal, name: str) -> TAnimal:
        cls = self.__class__
        return cls(name, self.birthday)

class Dog(Animal):
    def bark(self) -> None:
        print(f"{self.name} says woof!")

fido = Dog.newborn("Fido")
pluto = fido.twin("Pluto")
fido.bark()
pluto.bark()

这个例子有几点需要注意:

  • 类型变量 TAnimal 用于表示返回值可能是 Animal 的子类的实例。
  • 我们指定 AnimalTAnimal 的上限。指定 bound 意味着 TAnimal 只会是 Animal 或其子类之一。这是正确限制允许的类型所必须的。
  • typing.Type[] 结构体是 type() 的类型等效项。需要注意类方法需要一个类,并返回该类的实例。

注解 *args**kwargs

在面向对象版本的游戏中,添加了在命令行中为玩家命名的选项。这是通过在程序名称之后列出玩家名称来完成的:

$ python game.py GeirArne Dan Joanna
Dan: ♢A   Joanna: ♡9   P1: ♣A   GeirArne: ♣2
Dan: ♡A   Joanna: ♡6   P1: ♠4   GeirArne: ♢8
Dan: ♢K   Joanna: ♢Q   P1: ♣K   GeirArne: ♠5
Dan: ♡2   Joanna: ♡J   P1: ♠7   GeirArne: ♡K
Dan: ♢10  Joanna: ♣3   P1: ♢4   GeirArne: ♠8
Dan: ♣6   Joanna: ♡Q   P1: ♣Q   GeirArne: ♢J
Dan: ♢2   Joanna: ♡4   P1: ♣8   GeirArne: ♡7
Dan: ♡10  Joanna: ♢3   P1: ♡3   GeirArne: ♠2
Dan: ♠K   Joanna: ♣5   P1: ♣7   GeirArne: ♠J
Dan: ♠6   Joanna: ♢9   P1: ♣J   GeirArne: ♣10
Dan: ♠3   Joanna: ♡5   P1: ♣9   GeirArne: ♠Q
Dan: ♠A   Joanna: ♠9   P1: ♠10  GeirArne: ♡8
Dan: ♢6   Joanna: ♢5   P1: ♢7   GeirArne: ♣4

这是通过在实例化时解包并将 sys.argv 传递给 Game() 来实现的。.__init__() 方法使用 *names 将给定的名称打包到一个元组中。

对于类型注解:即使 names 会是一个字符串元组,也应该只注解每个名称的类型。换句话说,应该使用 str 而不是 Tuple[str]

class Game:
    def __init__(self, *names: str) -> None:
        """设置牌组并向 4 名玩家发牌"""
        deck = Deck.create(shuffle=True)
        self.names = (list(names) + "P1 P2 P3 P4".split())[:4]
        self.hands = {
            n: Player(n, h) for n, h in zip(self.names, deck.deal(4))
        }

同样,如果有一个接受 **kwargs 的函数或方法,那么应该只注解每个可能的关键字参数的类型。

可调用对象

函数是 Python 中的一等对象。这意味着可以将函数用作其他函数的参数。这也意味着需要能够添加表示函数的类型提示。

函数、lambda、方法和类,由 typing.Callable 表示。通常还表示参数类型和返回值。例如,Callable[[A1, A2, A3], Rt] 表示具有三个参数的函数,其类型分别为 A1A2A3。函数返回值的类型为 Rt

在以下示例中,函数 do_twice() 调用给定函数两次并打印返回值:

# do_twice.py

from typing import Callable

def do_twice(func: Callable[[str], str], argument: str) -> None:
    print(func(argument))
    print(func(argument))

def create_greeting(name: str) -> str:
    return f"Hello {name}"

do_twice(create_greeting, "Jekyll")

注意第 5 行对 do_twice()func 参数的注解。它说 func 应该是一个带有一个字符串参数、也返回一个字符串的可调用对象。这种可调用对象的第一个示例是在第 9 行定义的 create_greeting()

大多数可调用类型都可以以类似的方式进行注解。然而,如果需要更大的灵活性,请查看回调协议和扩展的可调用类型。

例子:Hearts

让我们以 Hearts 游戏的完整示例结束。您可能已经从其他计算机模拟中了解了这款游戏。下面是规则的快速回顾:

  • 四名玩家,每人手中 13 张牌。
  • 持有 ♣2 的玩家开始第一轮,必须出 ♣2。
  • 玩家轮流出牌,尽可能跟随领先的花色。
  • 在领先花色中打出最高牌的玩家获胜,并在下一回合成为起始玩家。
  • 除非在更早的技巧中已经使用了 ♡,否则玩家不能以 ♡ 领先。
  • 打完所有牌后,如果玩家拿走某些牌,就会获得积分:
    • ♠Q 13 分
    • 每个 ♡ 1 分
  • 一场比赛持续几轮,直到一名玩家获得100分或更多。得分最少的玩家获胜。

更多细节可以在网上找到。

在这个例子中,没有很多新出现的类型概念。因此不会详细介绍此代码,而是将其作为带注解的代码的示例。

# hearts.py

from collections import Counter
import random
import sys
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from typing import overload

class Card:
    SUITS = "♠ ♡ ♢ ♣".split()
    RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()

    def __init__(self, suit: str, rank: str) -> None:
        self.suit = suit
        self.rank = rank

    @property
    def value(self) -> int:
        """The value of a card is rank as a number"""
        return self.RANKS.index(self.rank)

    @property
    def points(self) -> int:
        """Points this card is worth"""
        if self.suit == "♠" and self.rank == "Q":
            return 13
        if self.suit == "♡":
            return 1
        return 0

    def __eq__(self, other: Any) -> Any:
        return self.suit == other.suit and self.rank == other.rank

    def __lt__(self, other: Any) -> Any:
        return self.value < other.value

    def __repr__(self) -> str:
        return f"{self.suit}{self.rank}"

class Deck(Sequence[Card]):
    def __init__(self, cards: List[Card]) -> None:
        self.cards = cards

    @classmethod
    def create(cls, shuffle: bool = False) -> "Deck":
        """Create a new deck of 52 cards"""
        cards = [Card(s, r) for r in Card.RANKS for s in Card.SUITS]
        if shuffle:
            random.shuffle(cards)
        return cls(cards)

    def play(self, card: Card) -> None:
        """Play one card by removing it from the deck"""
        self.cards.remove(card)

    def deal(self, num_hands: int) -> Tuple["Deck", ...]:
        """Deal the cards in the deck into a number of hands"""
        return tuple(self[i::num_hands] for i in range(num_hands))

    def add_cards(self, cards: List[Card]) -> None:
        """Add a list of cards to the deck"""
        self.cards += cards

    def __len__(self) -> int:
        return len(self.cards)

    @overload
    def __getitem__(self, key: int) -> Card: ...

    @overload
    def __getitem__(self, key: slice) -> "Deck": ...

    def __getitem__(self, key: Union[int, slice]) -> Union[Card, "Deck"]:
        if isinstance(key, int):
            return self.cards[key]
        elif isinstance(key, slice):
            cls = self.__class__
            return cls(self.cards[key])
        else:
            raise TypeError("Indices must be integers or slices")

    def __repr__(self) -> str:
        return " ".join(repr(c) for c in self.cards)

class Player:
    def __init__(self, name: str, hand: Optional[Deck] = None) -> None:
        self.name = name
        self.hand = Deck([]) if hand is None else hand

    def playable_cards(self, played: List[Card], hearts_broken: bool) -> Deck:
        """List which cards in hand are playable this round"""
        if Card("♣", "2") in self.hand:
            return Deck([Card("♣", "2")])

        lead = played[0].suit if played else None
        playable = Deck([c for c in self.hand if c.suit == lead]) or self.hand
        if lead is None and not hearts_broken:
            playable = Deck([c for c in playable if c.suit != "♡"])
        return playable or Deck(self.hand.cards)

    def non_winning_cards(self, played: List[Card], playable: Deck) -> Deck:
        """List playable cards that are guaranteed to not win the trick"""
        if not played:
            return Deck([])

        lead = played[0].suit
        best_card = max(c for c in played if c.suit == lead)
        return Deck([c for c in playable if c < best_card or c.suit != lead])

    def play_card(self, played: List[Card], hearts_broken: bool) -> Card:
        """Play a card from a cpu player's hand"""
        playable = self.playable_cards(played, hearts_broken)
        non_winning = self.non_winning_cards(played, playable)

        # Strategy
        if non_winning:
            # Highest card not winning the trick, prefer points
            card = max(non_winning, key=lambda c: (c.points, c.value))
        elif len(played) < 3:
            # Lowest card maybe winning, avoid points
            card = min(playable, key=lambda c: (c.points, c.value))
        else:
            # Highest card guaranteed winning, avoid points
            card = max(playable, key=lambda c: (-c.points, c.value))
        self.hand.cards.remove(card)
        print(f"{self.name} -> {card}")
        return card

    def has_card(self, card: Card) -> bool:
        return card in self.hand

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self.name!r}, {self.hand})"

class HumanPlayer(Player):
    def play_card(self, played: List[Card], hearts_broken: bool) -> Card:
        """Play a card from a human player's hand"""
        playable = sorted(self.playable_cards(played, hearts_broken))
        p_str = "  ".join(f"{n}: {c}" for n, c in enumerate(playable))
        np_str = " ".join(repr(c) for c in self.hand if c not in playable)
        print(f"  {p_str}  (Rest: {np_str})")
        while True:
            try:
                card_num = int(input(f"  {self.name}, choose card: "))
                card = playable[card_num]
            except (ValueError, IndexError):
                pass
            else:
                break
        self.hand.play(card)
        print(f"{self.name} => {card}")
        return card

class HeartsGame:
    def __init__(self, *names: str) -> None:
        self.names = (list(names) + "P1 P2 P3 P4".split())[:4]
        self.players = [Player(n) for n in self.names[1:]]
        self.players.append(HumanPlayer(self.names[0]))

    def play(self) -> None:
        """Play a game of Hearts until one player go bust"""
        score = Counter({n: 0 for n in self.names})
        while all(s < 100 for s in score.values()):
            print("\nStarting new round:")
            round_score = self.play_round()
            score.update(Counter(round_score))
            print("Scores:")
            for name, total_score in score.most_common(4):
                print(f"{name:<15} {round_score[name]:>3} {total_score:>3}")

        winners = [n for n in self.names if score[n] == min(score.values())]
        print(f"\n{' and '.join(winners)} won the game")

    def play_round(self) -> Dict[str, int]:
        """Play a round of the Hearts card game"""
        deck = Deck.create(shuffle=True)
        for player, hand in zip(self.players, deck.deal(4)):
            player.hand.add_cards(hand.cards)
        start_player = next(
            p for p in self.players if p.has_card(Card("♣", "2"))
        )
        tricks = {p.name: Deck([]) for p in self.players}
        hearts = False

        # Play cards from each player's hand until empty
        while start_player.hand:
            played: List[Card] = []
            turn_order = self.player_order(start=start_player)
            for player in turn_order:
                card = player.play_card(played, hearts_broken=hearts)
                played.append(card)
            start_player = self.trick_winner(played, turn_order)
            tricks[start_player.name].add_cards(played)
            print(f"{start_player.name} wins the trick\n")
            hearts = hearts or any(c.suit == "♡" for c in played)
        return self.count_points(tricks)

    def player_order(self, start: Optional[Player] = None) -> List[Player]:
        """Rotate player order so that start goes first"""
        if start is None:
            start = random.choice(self.players)
        start_idx = self.players.index(start)
        return self.players[start_idx:] + self.players[:start_idx]

    @staticmethod
    def trick_winner(trick: List[Card], players: List[Player]) -> Player:
        lead = trick[0].suit
        valid = [
            (c.value, p) for c, p in zip(trick, players) if c.suit == lead
        ]
        return max(valid)[1]

    @staticmethod
    def count_points(tricks: Dict[str, Deck]) -> Dict[str, int]:
        return {n: sum(c.points for c in cards) for n, cards in tricks.items()}

if __name__ == "__main__":
    # Read player names from the command line
    player_names = sys.argv[1:]
    game = HeartsGame(*player_names)
    game.play()

以下是代码中需要注意的几点:

  • 对于使用 Union 或类型变量难以表达的类型关系,可以使用 @overload 装饰器。参阅 Deck.__getitem__() 示例,阅读文档获取更多信息。
  • 子类对应于子类型,因此可以在任何需要 Player 的地方使用 HumanPlayer
  • 当子类重新实现超类的方法时,类型注解必须匹配。参阅 HumanPlayer.play_card() 示例。

开始游戏时,您控制第一个玩家。输入数字以选择要出的牌。以下是一个游戏示例,突出显示的行显示了玩家做出选择的位置:

译者注:由于 Markdown 并不原生支持高亮代码段中的特定行,故此处省略高亮,请读者前往源站查看。

$ python hearts.py GeirArne Aldren Joanna Brad

Starting new round:
Brad -> ♣2
  0: ♣5  1: ♣Q  2: ♣K  (Rest: ♢6 ♡10 ♡6 ♠J ♡3 ♡9 ♢10 ♠7 ♠K ♠4)
  GeirArne, choose card: 2
GeirArne => ♣K
Aldren -> ♣10
Joanna -> ♣9
GeirArne wins the trick

  0: ♠4  1: ♣5  2: ♢6  3: ♠7  4: ♢10  5: ♠J  6: ♣Q  7: ♠K  (Rest: ♡10 ♡6 ♡3 ♡9)
  GeirArne, choose card: 0
GeirArne => ♠4
Aldren -> ♠5
Joanna -> ♠3
Brad -> ♠2
Aldren wins the trick

...

Joanna -> ♡J
Brad -> ♡2
  0: ♡6  1: ♡9  (Rest: )
  GeirArne, choose card: 1
GeirArne => ♡9
Aldren -> ♡A
Aldren wins the trick

Aldren -> ♣A
Joanna -> ♡Q
Brad -> ♣J
  0: ♡6  (Rest: )
  GeirArne, choose card: 0
GeirArne => ♡6
Aldren wins the trick

Scores:
Brad             14  14
Aldren           10  10
GeirArne          1   1
Joanna            1   1

静态类型检查

到目前为止,您已经了解了如何在代码中添加类型提示。在本节中,您将了解有关如何执行 Python 代码静态类型检查的更多信息。

Mypy 项目

Mypy 是由 Jukka Lehtosalo 于 2012 年左右在剑桥攻读博士学位期间创建的。Mypy 最初被设想为可以无缝衔接动态和静态类型的 Python 变体。关于 Mypy 最初愿景的实例,请参阅 Jukka 在 2012 芬兰 PyCon 的幻灯片。

这些原始想法中的大多数仍然在 Mypy 项目中发挥着重要作用。事实上,“Seamless dynamic and static typing” 的口号仍然在 Mypy 的主页上醒目可见,并且很好地描述了在 Python 中使用类型提示的动机。

自 2012 年以来最大的变化是,Mypy 不再是 Python 的变体。在其第一个版本中,除了它的类型声明,Mypy 是一种与 Python 兼容的独立语言。根据 Guido van Rossum 的建议,Mypy 被重写为使用类型注解。现在,Mypy 是常规 Python 代码的静态类型检查器。

运行 Mypy

在第一次运行 Mypy 之前,必须安装该程序。最容易的方式是使用 pip

pip install mypy

安装 Mypy 后,可以将其作为常规命令行程序运行:

mypy my_program.py

my_program.py Python 文件上运行 Mypy,将检查其类型错误,而不实际运行代码。

对代码进行类型检查时有许多可选项。由于 Mypy 仍处于非常活跃的开发阶段,命令行选项可能会在版本之间发生变化。应该参考 Mypy 的帮助来查看您的版本中哪些设置为默认值:

$ mypy --help
usage: mypy [-h] [-v] [-V] [more options; see below]
            [-m MODULE] [-p PACKAGE] [-c PROGRAM_TEXT] [files ...]

Mypy is a program that will type check your Python code.

[... The rest of the help hidden for brevity ...]

此外,在线的 Mypy 命令行文档有很多信息。

让我们来看一些最常见的选项。首先,如果使用了没有类型提示的第三方包,可能希望消除 Mypy 对此的警告。这可以通过 --ignore-missing-imports 选项来完成。

以下示例使用 Numpy 计算并打印多个数字的余弦:

# cosine.py

import numpy as np

def print_cosine(x: np.ndarray) -> None:
    with np.printoptions(precision=3, suppress=True):
        print(np.cos(x))

x = np.linspace(0, 2 * np.pi, 9)
print_cosine(x)

注意 np.printoptions() 仅在 Numpy 1.15 及更高版本中可用。运行此示例会在控制台中打印一些数字:

$ python cosine.py
[ 1.     0.707  0.    -0.707 -1.    -0.707 -0.     0.707  1.   ]

这个例子的实际输出并不重要。而应该注意的是,因为要打印完整数字数组的余弦,参数 x 在第 5 行用 np.ndarray 注解。

可以像往常一样在这个文件上运行 Mypy:

$ mypy cosine.py 
cosine.py:3: error: No library stub file for module 'numpy'
cosine.py:3: note: (Stub files are from https://github.com/python/typeshed)

这些警告目前来看可能没有多大意义,但很快将介绍存根和 typeshed。基本上可以将警告解读为 Mypy 说 Numpy 包不包含类型提示。

在大多数情况下,并不想被第三方包中缺少类型提示这样的事打扰,因此可以关闭这些信息:

$ mypy --ignore-missing-imports cosine.py 
Success: no issues found in 1 source file

如果使用 --ignore-missing-import 命令行选项,Mypy 将不会尝试跟踪或警告任何缺失的导入。不过这可能有点笨拙,因为它也忽略了实际的错误,比如拼错了包的名称。

处理第三方包的两种侵入性较小的方式是使用类型注释或配置文件。

在上面的一个简单示例中,可以通过在包导入的行中添加类型注释来消除 numpy 警告:

import numpy as np  # type: ignore

文字 # type: ignore 告诉 Mypy 忽略 Numpy 的导入。

如果有多个文件,则可能更容易跟踪配置文件中要忽略的导入。Mypy 会在当前目录中读取名为 mypy.ini 的文件(如果该文件存在)。此配置文件必须包含一个名为 [mypy] 的节,并且可能包含 [mypy-module] 形式的模块的特定节。

译者注: pyproject.toml 文件是目前主流 Python 项目配置文件,Mypy 亦支持从该文件中读取配置。更多信息可参考另一篇译文:使用 Python Poetry 进行依赖项管理。

以下配置文件将忽略 Numpy 缺少类型提示:

# mypy.ini

[mypy]

[mypy-numpy]
ignore_missing_imports = True

可以在配置文件中指定许多选项。也可以指定一个全局配置文件。请参阅文档以获得更多信息。

添加存根

Python 标准库中的所有包都可以使用类型提示。但是,如果使用的是第三方软件包,您已经看到情况可能会有所不同。

以下示例使用 Parse 包进行简单的文本解析。安装 Parse 以继续:

pip install parse

Parse 可用于识别简单的模式。这是一个小程序,它会全力以赴找出您的名字:

# parse_name.py

import parse

def parse_name(text: str) -> str:
    patterns = (
        "my name is {name}",
        "i'm {name}",
        "i am {name}",
        "call me {name}",
        "{name}",
    )
    for pattern in patterns:
        result = parse.parse(pattern, text)
        if result:
            return result["name"]
    return ""

answer = input("What is your name? ")
name = parse_name(answer)
print(f"Hi {name}, nice to meet you!")

最后三行定义了主要流程:询问您的姓名、解析答案和打印问候语。parse 包在第 14 行被调用,以便尝试根据第 7~11 行列出的模式之一来查找名称。

该程序可按如下方式使用:

$ python parse_name.py
What is your name? I am Geir Arne
Hi Geir Arne, nice to meet you!

注意即使我回答 I am Geir Arne,程序也会发现 I am 不是我名字的一部分。

让我们在程序中添加一个小错误,看看 Mypy 是否能帮助我们检测它。将第 16 行从 return result["name"] 改为 return result。这将返回一个 parse.Result 对象而不是包含名称的字符串。

接下来在程序上运行 Mypy:

$ mypy parse_name.py 
parse_name.py:3: error: Cannot find module named 'parse'
parse_name.py:3: note: (Perhaps setting MYPYPATH or using the
                       "--ignore-missing-imports" flag would help)

Mypy 打印出与上一节中出现过的类似的错误:它不知道 parse 包。可以尝试忽略导入:

$ mypy parse_name.py --ignore-missing-imports
Success: no issues found in 1 source file

不幸的是,忽略导入意味着 Mypy 无法发现我们程序中的错误。更好的解决方案是向 Parse 包本身添加类型提示。由于 Parse 是开源的,实际上可以向源代码添加类型并发送 pull request。

或者,可以在存根文件中添加类型。存根文件是一个文本文件,其中包含方法和函数的签名,但不包括它们的实现。它们的主要功能是向(由于某种原因您无法更改的)代码中添加类型提示。为了展示它是如何工作的,我们将为 Parse 包添加一些存根。

首先,应该将所有存根文件放在一个公共目录中,并将 MYPYPATH 环境变量设置为指向该目录。在 Mac 和 Linux 上,可以按如下方式设置 MYPYPATH

export MYPYPATH=/home/gahjelle/python/stubs

可以通过将该行添加到 .bashrc 文件来永久设置变量。在 Windows 上,可以单击开始菜单并搜索环境变量以设置 MYPYPATH

接下来,在存根目录中创建一个名为 parse.pyi 的文件。它必须以要为其添加类型提示的包命名,并带有 .pyi 后缀名。暂且将此文件留空。然后再次运行 Mypy:

$ mypy parse_name.py
parse_name.py:14: error: Module has no attribute "parse"

如果您已经正确设置所有内容,应该会看到这条新的错误消息。Mypy 使用新的 parse.pyi 文件来确定 parse 包中可用的函数。由于存根文件为空,Mypy 假定 parse.parse() 不存在,然后给出上面的错误。

以下示例没有为整个 parse 包添加类型。而是显示了您需要添加的类型提示,以便 Mypy 对您对 parse.parse() 的使用进行类型检查:

# parse.pyi

from typing import Any, Mapping, Optional, Sequence, Tuple, Union

class Result:
    def __init__(
        self,
        fixed: Sequence[str],
        named: Mapping[str, str],
        spans: Mapping[int, Tuple[int, int]],
    ) -> None: ...
    def __getitem__(self, item: Union[int, str]) -> str: ...
    def __repr__(self) -> str: ...

def parse(
    format: str,
    string: str,
    evaluate_result: bool = ...,
    case_sensitive: bool = ...,
) -> Optional[Result]: ...

省略号 ... 是文件的一部分,应该完全按照上面的方式编写。存根文件应该只包含变量、属性、函数和方法的类型提示,因此应该省略实现,并用 ... 标记替换。

最后,Mypy 能够发现我们引入的错误:

$ mypy parse_name.py
parse_name.py:16: error: Incompatible return value type (got
                         "Result", expected "str")

这直接指向第 16 行以及我们返回一个 Result 对象而不是 名称字符串的事实。将 return result 改回 return result["name"],再次运行 Mypy 看看它是否满意。

Typeshed

您已经了解了如何在不更改源代码本身的情况下使用存根添加类型提示。在上一节中,我们向第三方 Parse 包添加了一些类型提示。如果每个人都需要为他们正在使用的所有第三方包创建自己的存根文件,那将不是非常高效。

Typeshed 是一个 Github 存储库,其中包含 Python 标准库的类型提示及许多第三方包。Typeshed 包含在 Mypy 中,因此如果您使用的包已经在 Typeshed 中定义了类型提示,则类型检查将正常工作。

还可以向 Typeshed 提供类型提示。不过,请确保首先获得包所有者的许可,特别是因为他们可能正在努力将类型提示添加到源代码本身中——那是首选方法。

其他静态类型检查器

在本教程中,我们主要关注使用 Mypy 进行类型检查。然而,Python 生态系统中还有其他静态类型检查器。

PyCharm 编辑器具有自己的类型检查器。如果使用 PyCharm 编写 Python 代码,它将自动进行类型检查。

Facebook 开发了 Pyre。其既定目标之一是快速和高性能。虽然存在一些差异,但 Pyre 的功能大多与 Mypy 类似。如果有兴趣试用 Pyre,请参阅文档。

此外,Google 还创建了 Pytype。这种类型检查器的工作方式也与 Mypy 基本相同。除了检查具有注解的代码之外,Pytype 还支持对未注解的代码运行类型检查,甚至自动为代码添加注解。更多详细信息,请参阅 quickstart 文档。

在运行时使用类型

最后一点,在 Python 程序执行期间,也可以在运行时使用类型提示。Python 可能永远不会原生支持运行时类型检查(runtime type checking)。

然而,类型提示在运行时可以在 __annotations__ 字典中使用,如果需要,可以使用它们进行类型检查。在离开并编写自己的包来执行类型之前,应该知道已经有几个包为您做了这件事。看看 EnforcePydanticPytypes 以获取一些示例。

类型提示的另一个用途是将 Python 代码转换为 C 并编译它以进行优化。流行的 Cython 项目使用混合 C/Python 语言编写静态类型的 Python 代码。但是从 0.27 版本开始,Cython 也支持类型注解。最近 Mypyc 项目已经上线。虽然尚未准备好用于一般用途,但它可以将一些具有类型注解的 Python 代码编译为 C 扩展。

总结

Python 中的类型提示是一个非常有用的特性(当然也完全可以不使用它)。类型提示不会让您具有写某种离开类型提示即无法存在的代码的能力。而是,使用类型提示可以让您更轻松地推理代码、发现细微的错误并维护干净的架构。

在本教程中,您了解了 Python 中的类型提示如何工作,以及渐进式类型如何使 Python 中的类型检查比许多其他语言更灵活。您已经看到了使用类型提示的一些优点和缺点,以及如何使用注解或类型注释来将它们添加到代码中。最后,您看到了 Python 支持的许多不同类型,还有如何执行静态类型检查。

有许多资源可以了解更多关于 Python 中的类型检查的信息。PEP 483 和 PEP 484 提供了很多关于如何在 Python 中实现类型检查的背景知识。Mypy 文档 有一个很棒的参考部分详细介绍了所有可用的不同类型。

附:翻译对照表

本文涉及到的专业术语较多,加之某些中文译文非常相似,相比英文更容易造成混淆。故特列出本翻译对照表,明确本文中关键词的英文对应关系。表中词语顺序与正文中该词首次出现顺序基本一致。

中文译文 英文原文
class
类型 type
类别 categories
动态类型 dynamic typing
静态类型 static typing
鸭子类型 duck typing
类型提示 type hint
文档字符串 docstrings
注释 comment
注解 annotation
存根 stub
序列 sequence
子类型 subtype
超类型 supertype
协变 covariant
逆变 contravariant
不变 invariant
渐进类型 gradual typing
一致类型 consistent types
类型变量 type variables
结构的 structural
名义的 nominal
哨兵值 sentinel value
(方法和函数的)签名 signatures
可调用对象 callables
回调协议 callback protocols

你可能感兴趣的:(python)