在本教程中,您将了解 Python 类型检查。传统上,Python 解释器以灵活但隐式的方式处理类型。近期版本的 Python 允许指定可由不同工具使用的显示类型提示,以帮助更有效地开发代码。
在本教程中,您将学习以下内容:
- 类型注解与类型提示
- 将静态类型添加到代码中,包括您的代码和其他人的代码
- 运行静态类型检查器
- 在运行时强制使用类型
这是一个涵盖很多领域的综合指南。如果您只想快速了解类型提示在 Python 中是如何工作的,并了解类型检查是否为您将在自己的代码使用中的,则无需阅读全文。Hello Types 与优点与缺点两节将让您了解类型检查的工作方式,以及何时适用的建议。
类型系统
所有的编程语言都包含某种类型系统,它形式化了可以使用哪些类别的对象以及如何处理这些类别。例如,一个类型系统可以定义一个数值类型,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__()
方法。否则,对象的类型可能与 str
、list
、dict
或 TheHobbit
等不同。
使用 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()
将返回一个字符串。
- 对冒号使用常规规则,即冒号前没有空格,后面有一个空格:
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 会立即给出一个警告:
PyCharm 标记了一个类型错误
不过,最常用的类型检查工具是 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 的动态特性是它最重要的财产之一,但有意识地依靠鸭子类型、重载方法或多个返回类型是一件好事。
当然,静态类型检查并非尽善尽美。还应该考虑一些缺点:
- 类型提示需要开发者花时间精力来添加。尽管可能带来减少调试时间的回报,但需要花费更多时间输入代码。
- 类型提示在现代 Python 中效果最佳。注解是在 Python 3.0 中引入的,并且可以在 Python 2.7 中使用类型注释。尽管如此,变量注解和类型提示的延迟评估等改进意味着,在 Python 3.6 甚至 Python 3.7 中进行类型检查会有更好的体验。
- 类型提示会在启动时间上带来轻微的损失。如果需要使用
typing
模块,导入时间可能很长,在短脚本中尤是。
那么,您应该在自己的代码中使用静态类型检查吗?事实上,这不是一个全是或全非的问题。幸运的是,Python 支持渐进类型的概念。这意味着可以逐渐将类型引入到代码中。没有类型提示的代码将会被静态类型检查器忽略。因此,可以从向关键组件添加类型开始,然后在它提供价值的情况下继续。
查看上面的优缺点表,会注意到添加类型不会影响正在运行的程序或程序的用户。类型检查旨在让您作为开发者的生活更美好、更方便。
关于是否向项目添加类型的一些经验法则是:
- 如果刚开始学习 Python,在有更多的经验之前,可以放心地暂不考虑类型提示。
- 类型提示在简短的一次性脚本中几乎没什么价值。
- 在将被他人使用的库中,尤其是那些在 PyPI 上发布的,类型提示添加了很多价值。其他使用您的库的代码需要这些类型提示才能正确地进行类型检查。有关使用类型提示的项目示例,参见
cursive_re
、black
、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
的类型,以及局部变量 radius
和 circumference
的。
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 部分
到目前为止,只在类型提示中使用了诸如 str
、float
和 bool
之类的基本类型。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()
添加注解。第一个挑战是注解复合类型,例如用于表示卡牌组的列表和用于表示卡牌本身的元组。
对于像 str
、float
和 bool
这样的简单的类型,添加类型提示就像使用类型本身一样简单:
>>> 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"]
的类型是什么?在这个具体案例中,可以看到它们分别是 str
、int
和 bool
。但是类型提示本身没有提供有关于此的信息。
作为替代,应该使用 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
模块包含更多的复合类型,包括 Counter
、Deque
、FrozenSet
、NamedTuple
和 set
。此外,该模块还包含将在后面的节中看到的其他类型。
让我们回到纸牌游戏。每张卡牌用一个由两个字符串构成的元组表示。可以把它写成 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 表达式。这意味着可以通过将它们分配给新变量来定义自己的类型别名。例如,可以创建 Card
和 Deck
类型别名:
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)。形式上,如果满足以下两个条件,我们说类型 T
是 U
的子类型:
T
中的每个值也在U
类型的值集中。U
类型的每个函数也在T
类型的函数集中。
这两个条件保证即使 T
类型与 U
不同,T
类型的变量也总是可以伪装成 U
。
举个具体的例子,考虑 T = bool
并且 U = int
。bool
类型只接受两个值。通常被表示为 True
和 False
,但这些名称分别只是整数值 1
和 0
的别名:
>>> int(False)
0
>>> int(True)
1
>>> True + True
2
>>> issubclass(bool, int)
True
由于 0 和 1 都是整数,所以第一个条件成立。从上面看到布尔值可以相加,但它们也可以做其他整型可以做到的任何事情。这是上面的第二个条件。换句话说,bool
是 int
的子类型。
子类型的重要性在于,子类型总是可以伪装成它的超类型。例如,以下代码类型检查为正确:
def double(number: int) -> int:
return number * 2
print(double(True)) # Passing in bool instead of int
子类型与子类有些相关。事实上,所有的子类都对应着子类型,而因为 bool
是 int
的子类,所以 bool
是 int
的子类型。但也有不对应子类的子类型。例如 int
是 float
的子类型,但 int
不是 float
的子类。
协变、逆变与不变
当在复合类型中使用子类型时会发生什么?例如,Tuple[bool]
是 Tuple[int]
的子类型吗?答案取决于复合类型,以及该协议是协变的、逆变的还是不变的。这很快涉及到技术,所以让我们举几个例子:
Tuple
是协变的(covariant)。这意味着它保留了其他项目类型的层次结构:因为bool
是int
的子类型,所以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
的行为又好像它是其他任何类型的子类型。在它上面按定义考察子类型是不可能的。相反,我们讨论一致类型。
如果 T
是 U
的子类型,或 T
和 U
中有一个是 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])
前两个示例应该是 str
和 int
,但后两个呢?各个列表项有不同的类型,在这种情况下,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*'
正如上文已经提到的,bool
是 int
的子类型,而 int
又是 float
的子类型。所以在第三个例子中,choose()
的返回值保证是可以被视为 float
的东西。在最后一个例子中,str
和 int
之间没有子类型关系,所以关于返回值的最好说法是它是一个对象(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
只能是 str
或 float
,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
仅限于字符串和浮点数,而 int
是 float
的子类型。
在我们的纸牌游戏中,我们希望限制 choose()
用于 str
和 Card
:
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
模块中定义的其他协议示例包括 Container
、Iterable
、Awaitable
和 ContextManager
。
还可以定义自己的协议。这是通过从 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 告知没有处理 start
为 None
的情况:
$ 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
表示可选参数,即使类型提示没有明确说明亦是如此。可以使用以下代码:
$ mypy player_order.pyplayer_order.py:8: error: Argument 1 to "index" of "list" has incompatible type "Optional[str]"; expected "str"
如果不想让 Mypy 做这个假设,可以使用 --no-implicit-optional
命令行选项来关闭它。
例子:面向对象版本的游戏
让我们重写纸牌游戏,让其更加面向对象。这将有助于我们讨论如何正确注解类和方法。
将纸牌游戏或多或少直译为使用 Card
、Deck
、Player
和 Game
类的代码如下所示:
# 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 能够将在注解中使用的 Card
与 Card
类的定义联系起来。
然而,在需要引用当前正在定义的类时,这并不能正常工作。例如,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"
。
返回 self
或 cls
如前所述,通常不应注解 self
或 cls
参数。这是非必需的,因为 self
指向类的实例,因此它将具有类的类型。在 Card
示例中,self
具有隐式类型 Card
。此外,由于尚未定义该类,因此显示添加此类型会很麻烦。必须使用字符串文字语法,self: "Card"
。
但是,在一种情况下,可能想要注释 self
或者 cls
。考虑如果有一个会被其他类继承的超类,且它的方法返回 self
或 cls
会发生什么:
# 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
的实例类型。这可以使用类型变量来完成,这些变量跟踪实际传递给 self
和 cls
的内容:
# 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
的子类的实例。 - 我们指定
Animal
是TAnimal
的上限。指定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]
表示具有三个参数的函数,其类型分别为 A1
、A2
和 A3
。函数返回值的类型为 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 trickn")
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()
示例。
开始游戏时,您控制第一个玩家。输入数字以选择要出的牌。以下是一个游戏示例,突出显示的行显示了玩家做出选择的位置:
$ 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
包在第 14 行被调用,以便尝试根据第 7~11 行列出的模式之一来查找名称。
# 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!")
该程序可按如下方式使用:
$ python parse_name.pyWhat is your name? I am Geir ArneHi 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.pyparse_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__
字典中使用,如果需要,可以使用它们进行类型检查。在离开并编写自己的包来执行类型之前,应该知道已经有几个包为您做了这件事。看看 Enforce、Pydantic 或 Pytypes 以获取一些示例。
类型提示的另一个用途是将 Python 代码转换为 C 并编译它以进行优化。流行的 Cython 项目使用混合 C/Python 语言编写静态类型的 Python 代码。但是从 0.27 版本开始,Cython 也支持类型注解。最近 Mypyc 项目已经上线。虽然尚未准备好用于一般用途,但它可以将一些具有类型注解的 Python 代码编译为 C 扩展。
总结
Python 中的类型提示是一个非常有用的特性(当然也完全可以不使用它)。类型提示不会让您具有写某种离开类型提示即无法存在的代码的能力。而是,使用类型提示可以让您更轻松地推理代码、发现细微的错误并维护干净的架构。
在本教程中,您了解了 Python 中的类型提示如何工作,以及渐进式类型如何使 Python 中的类型检查比许多其他语言更灵活。您已经看到了使用类型提示的一些优点和缺点,以及如何使用注解或类型注释来将它们添加到代码中。最后,您看到了 Python 支持的许多不同类型,还有如何执行静态类型检查。
有许多资源可以了解更多关于 Python 中的类型检查的信息。PEP 483 和 PEP 484 提供了很多关于如何在 Python 中实现类型检查的背景知识。Mypy 文档 有一个很棒的参考部分详细介绍了所有可用的不同类型。
本站部分资源来源于网络,仅限用于学习和研究目的,请勿用于其他用途。
如有侵权请发送邮件至1943759704@qq.com删除
码农资源网 » Python 类型系统与类型检查