编写更多 pythonic 代码(十三)——Python类型检查
一、概述
在本文中,您将了解 Python 类型检查。传统上,类型由 Python 解释器以灵活但隐式的方式处理。最新版本的 Python 允许您指定显式类型提示,这些提示可由不同的工具使用,以帮助您更有效地开发代码。
在本文中,你将了解:
- 类型批注和类型提示
- 向代码添加静态类型,包括您的代码和其他代码
- 运行静态类型检查器
- 在运行时强制实施类型
您将了解类型提示在 Python 中的工作原理,并了解类型检查是否是要在代码中使用的内容。如果您想了解更多信息,可以查看本课程中将链接到的资源。
二、动态类型与静态类型
所有编程语言都包含某种类型系统,该系统形式化了它可以处理哪些对象类别以及如何处理这些类别。
1.动态类型
Python是一种动态类型语言。这意味着 Python 解释器仅在代码运行时进行类型检查,并且允许变量的类型在其生命周期内更改。以下是演示这些想法的几个示例:
>>> if False:
... 1 + "two" # This line never runs, so no TypeError is raised
... else:
... 1 + 2
...3
>>> 1 + "two" # Now this is type checked
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()返回一个对象的类型。
2.静态类型
与动态类型化相反的是静态类型化。静态类型检查是在不运行程序的情况下进行的。在大多数静态类型语言中,例如C和Java,这是在你的程序被编译的时候进行的。变量的类型不允许在其生命周期内改变。
在这个Java的Hello World例子中,请看中间部分,String thing;被静态地定义为String类型,然后被赋值为thing = "Hello World";:
public class HelloTypes {
public static void main(String[] args) {
String thing;
thing = "Hello World";
System.out.println(thing);
}
}
这不是一个关于Java的课程,所以不要担心如何创建Java代码的具体细节。这个例子的目的是告诉你,在大多数静态类型的语言中都有额外的步骤。
在接下来的这个例子中,你将使用javac命令来编译程序。这将创建一个名称相同的新文件,但扩展名为.class而不是.java。这就是可以使用java filename.class命令运行的文件:
$ javac HelloTypes.java
$ java HelloTypes.class
Hello World
如果你试图将东西重新赋值给一个不同类型的值,最初你不会得到一个错误。只有当代码被编译时,你才会看到错误:
public class HelloTypes {
public static void main(String[] args) {
String thing;
thing = "Hello World";
thing = 42;
System.out.println(thing);
}
}
这一行 thing = 42;正试图将 thing 的类型从字符串改为 int。如果你编译这段代码,你会看到这个错误:
$ javac Hellotypes.java
HelloTypes.java:8: error: incompatible types: int cannot be converted to String
thing = 42;
1 error
Python将始终保持动态类型语言。但是,PEP 484 引入了类型提示,这使得也可以对 Python 代码进行静态类型检查。
三、Duck Typing
这个术语来自于 "如果它走起来像鸭子,叫起来像鸭子,那么它一定是鸭子"。(还有其他变化)。
鸭子类型是一个与动态类型有关的概念,其中对象的类型或类别不如它定义的方法重要。当你使用鸭子类型时,你根本就不检查类型。相反,你检查一个给定的方法或属性是否存在。
例如,你可以在任何定义了 .__len__() 方法的 Python 对象上调用 len() :
>>> class TheHobbit:
... def __len__(self):
... return 95022
...
...
>>> the_hobbit = TheHobbit()
>>> the_hobbit
<__main__.TheHobbit object at 0x108deeef0>
>>> len(the_hobbit)
95022
>>> my_str = "Hello World"
>>> my_list = [34, 54, 65, 78]
>>> my_dict = {"one": 123, "two": 456, "three": 789}
>>> len(my_str)
11
>>> len(my_list)
4
>>> len(my_dict)
3
>>> len(the_hobbit)
95022
>>> my_int = 7
>>> my_float = 42.3
>>> len(my_int)
Traceback (most recent call last):
File "<input>", line 1, in <module>
len(my_int)
TypeError: object of type 'int' has no len()
>>> len(my_float)
Traceback (most recent call last):
File "<input>", line 1, in <module>
len(my_float)
TypeError: object of type 'float' has no len()
为了使你能够调用 len(obj),对 obj 的唯一真正限制是它必须定义一个 .__len__() 方法。否则,对象的类型可以是 str, list, dict, 或 TheHobbit。
四、类型提示
类型提示是一种正式的解决方案,用于静态指示 Python 代码中的值类型。它在 PEP 484 中指定,并在 Python 3.5 中引入。
下面是向函数添加类型信息的示例。注释参数和返回值:
def greet(name: str) -> str:
return "Hello, " + name
name: str 语法表示name参数应该是str类型。-> 语法表示greet()函数将返回一个字符串。
以下示例函数通过添加适当的大写和装饰行将文本字符串转换为标题:
>>> def headline(text, align=True):
... if align:
... return f"{text.title()}\n{'-' * len(text)}"
... else:
... return f" {text.title()} ".center(50, "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:
... if align:
... return f"{text.title()}\n{'-' * len(text)}"
... else:
... return f" {text.title()} ".center(50, "o")
...
...
>>> headline
<function headline at 0x105b81268>
>>> print(headline("python type checking", align="left"))
Python Type Checking
--------------------
>>> print(headline("python type checking", align="center"))
Python Type Checking
--------------------
在风格方面,PEP 8建议如下:
- 使用正常的冒号规则,即冒号之前没有空格,之后有一个空格(text: str)。
- 当把一个参数注解和一个默认值结合起来时,在=号周围使用空格(对齐:bool = True)。
- 在->箭头周围使用空格(def headline(..) -> str)。
五、使用 mypy 进行类型检查
Mypy 是进行类型检查的最常见工具:
Mypy是Python的一个可选的静态类型检查器,旨在结合动态(或“鸭子”)类型和静态类型的优点。
Mypy是由Jukka Lehtosalo在2012年左右在剑桥攻读博士学位期间创立的。最初,Mypy最初是作为Python的独立变体开始的,具有无缝的动态和静态类型。参见Jukka在2012年PyCon Finland 的幻灯片,了解Mypy最初愿景的例子。
根据Guido van Rossum的建议,Mypy被重写为使用注释,使其成为常规Python代码的静态类型检查器。
如果您的系统上没有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):
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))
在这里,你把对齐方式改为居中,并在调用headline()时正确地使用了一个布尔值来表示居中。该代码现在通过了Mypy:
$ mypy headlines.py
$
Mypy 没有输出意味着没有检测到任何类型错误。此外,当您运行代码时,您会看到预期的输出:
$ python headlines.py
Python Type Checking
--------------------
oooooooooooooooooooo Use Mypy oooooooooooooooooooo
第一个标题向左对齐,而第二个标题居中。
六、类型提示的优缺点
以下是类型提示的一些优点:
- 类型提示有助于捕获某些错误,如上一课所示。
- 类型提示有助于记录代码。传统上,如果要记录函数参数的预期类型,则可以使用文档字符串。这是有效的,但由于文档字符串没有标准(尽管 PEP 257),它们不能轻易用于自动检查。
- 类型提示改进了 IDE 和 linters。它们使静态推理代码变得更加容易。
- 类型提示可帮助您构建和维护更简洁的体系结构。编写类型提示的行为迫使您考虑程序中的类型。虽然 Python 的动态特性是其重要资产之一,但有意识地依赖鸭子类型、重载方法或多种返回类型是一件好事。
当然,静态类型检查并不全是桃子和奶油。您还应该考虑一些缺点:
- 添加类型提示需要开发人员花费时间和精力。尽管花更少的时间调试可能会得到回报,但您将花费更多的时间输入代码。
- 类型提示在现代 Python 中效果最好。Python 3.0 中引入了注释,并且可以在 Python 2.7 中使用类型注释。尽管如此,变量注释和推迟类型提示评估等改进意味着您将在使用 Python 3.6 甚至 Python 3.7 进行类型检查时获得更好的体验。
- 类型提示会在启动时间中引入轻微的损失。如果您需要使用键入模块,则导入时间可能会很长,尤其是在短脚本中。
你应该在自己的代码中使用静态类型检查吗?这不是一个全有或全无的问题。Python 支持渐进式类型的概念。这意味着您可以逐步将类型引入代码中。静态类型检查器将忽略没有类型提示的代码。因此,您可以开始向关键组件添加类型,只要它为您增加价值,就可以继续添加类型。
下面是有关是否向项目添加类型的一些经验法则:
- 如果你刚刚开始学习 Python,你可以安全地等待类型提示,直到你有更多的经验。
- 类型提示在简短的一次性脚本中几乎没有增加价值。
- 在其他人将使用的库中,尤其是在 PyPI 上发布的库中,类型提示增加了很多价值。使用库的其他代码需要对这些类型提示进行正确的类型检查。有关使用类型提示的项目示例,请参阅cursive_re,black,我们自己的Real Python Reader和Mypy本身。
- 在较大的项目中,类型提示可帮助您了解类型如何在代码中流动,强烈建议使用,尤其是在与他人合作的项目中。
Bernát Gábor在他的优秀文章The State of Type Hints in Python中建议“每当单元测试值得编写时,都应该使用类型提示。事实上,类型提示在代码中扮演着与测试类似的角色:它们可以帮助您作为开发人员编写更好的代码。
七、注释
注解最初是在Python 3.0中引入的,没有任何特定的目的。它们只是将任意表达式与函数参数和返回值相关联的一种方式。
几年后,PEP 484 定义了如何向 Python 代码添加类型提示,这是基于 Jukka Lehtosalo 在他的博士项目 Mypy 上所做的工作。添加类型提示的主要方法是使用批注。随着类型检查变得越来越普遍,这也意味着注释应该主要保留给类型提示。
1.函数注释
对于函数,您可以批注参数和返回值。这是按如下方式完成的:
def func(arg: arg_type, optarg: arg_type = default) -> return_type:
...
对于参数,语法是参数:注解,而返回类型则使用->注解。注意,注释必须是一个有效的Python表达式。
当运行代码时,你也可以检查注解。它们被保存在函数上一个特殊的 .__annotations__ 属性中:
>>> import math
>>> def circumference(radius: float) -> float:
... return 2 * math.pi * radius
...
...
>>> circumference.__annotations__
{'radius': <class 'float'>, 'return': <class 'float'>}
>>> circumference(1.23)
7.728317927830891
有时你可能会对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
记住,表达式 reveal_type() 和 reveal_locals() 是为了在 Mypy 中排除故障。如果你要运行Python脚本解释器,它将以NameError崩溃:
$ python3 reveal.py
Traceback (most recent call last):
File "reveal.py", line 4, in <module>
reveal_type(math.pi)
NameError: name 'reveal_type' is not defined
2.变量注释
在上一节的circumference()的定义中,你只注释了参数和返回值。你没有在函数体中添加任何注释。很多时候,这就足够了。
然而,有时类型检查器也需要帮助来弄清变量的类型。变量注解是在 PEP 526 中定义的,在 Python 3.6 中引入。其语法与函数参数注释相同。变量的注解被保存在模块级的 annotations 字典中:
>>> pi: float = 3.142
>>> def circumference(radius: float) -> float:
>>> return 2 * pi * radius
>>> circumference.__annotations__
{'radius': <class 'float'>, 'return': <class 'float'>}
>>> __annotations__
{'pi': <class 'float'>}
>>> circumference(1)
6.284
>>> nothing: str
>>> nothing
Traceback (most recent call last):
File "<input>", line 1, in <module>
nothing
NameError: name 'nothing' is not defined
>>> __annotations__
{'pi': <class 'float'>, 'nothing':<class 'str'>}
八、键入注释
如您所见,注释是在 Python 3 中引入的,它们还没有向后移植到 Python 2。这意味着,如果你正在编写需要支持传统 Python 的代码,那么你就不能使用注释。
相反,你可以使用类型注释。这些是特殊格式的注释,可以用来添加与旧代码兼容的类型提示。类型注释在 annotations 字典中不可用。要向一个函数添加类型注释,你要这样做:
def func(arg):
# type:(str) -> str
...
对于变量,在同一行添加类型注释:
my_variable = 42 # type: int
注释类型只是注释,因此它们可以在任何版本的 Python 中使用。尝试向上一课中的函数添加类型注释:
>>> import math
>>> def circumference(radius):
... # type: (float) -> float
... return 2 * math.pi * radius
...
...
>>> circumference(4.5)
28.274333882308138
>>> circumference.__annotations__
{}
类型注释必须以type:字头开始,并与函数定义在同一行或下一行。如果你想为一个有多个参数的函数做注释,你可以用逗号把每个类型分开来写。你也可以把每个参数单独写在一行,并有自己的注释:
headlines.py
def headline1(text, width=80, fill_char="-"):
# type: (str, int, str) -> str
return f" {text.title()} ".center(width, fill_char)
print(headline1("type comments work", width=40))
def headline2(
text, # type: str
width=80, # type: int
fill_char='-', # type: str
): # type: (...) -> str
return f" {text.title()} ".center(width, fill_char)
print(headline2("these type comments also work", width=70))
pi = 3.142 # type: float
通过 Python 和 Mypy 运行示例:
$ mypy headlines.py
$ python3 headlines.py
---------- Type Comments Work ----------
------------------- These Type Comments Also Work -------------------
如果你有错误,例如,如果你碰巧在第7行以67作为第一个参数调用headline1(),在第16行以width="normal "调用headline2(),那么Mypy会告诉你以下情况:
$ mypy headlines.py
headlines.py:7: error: Argument 1 to "headline1" has incompatible type "int"; expected "str"
headlines.py:16: error: Argument "width" to "headline2" has incompatible type "str"; expected "int"
在向自己的代码添加类型提示时,是否应该使用注释或类型注释?如果可以,请使用批注,如果必须,请使用类型注释。
批注提供更简洁的语法,使类型信息更接近代码。它们也是官方推荐的编写类型提示的方式,并将在未来得到进一步发展和适当的维护。
类型注释更详细,可能与代码中的其他类型的注释(如 linter 指令)冲突。但是,它们可以在不支持注释的代码库中使用。
九、玩转Python类型
到目前为止,您只在类型提示中使用了基本类型如str、float和bool。Python 类型系统非常强大,支持多种更复杂的类型。这是必要的,因为它需要能够合理地模拟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):
"""Create a new deck of 52 cards"""
deck = [(s, r) for r in RANKS for s in SUITS]
if shuffle:
random.shuffle(deck)
return deck
def deal_hands(deck):
"""Deal the cards in the deck into four hands"""
return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
def play():
"""Play a 4-player card game"""
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()
每张牌都是一个表示花色和等级的字符串的元组。create_deck()创建一副由52张扑克牌组成的普通牌,并可选择洗牌。 deal_hands()把这副牌发给四个玩家。
最后,play()进行游戏。到目前为止,它只为纸牌游戏做准备,即构建一副洗好的牌并向每个玩家发牌。下面是一个典型的输出:
$ python3 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", "Thomas", "Bobby"]
>>> version: tuple = (3, 7, 1)
>>> options: dict = {"centered": False, "capitalize": True}
>>> type(names[2])
<class 'str'>
>>> __annotations__
{'name': <class 'str'>, 'pi': <class 'float'>, 'centered': <class 'bool'>, 'names': <class 'list'>, 'version': <class 'tuple'>, 'options': <class 'dict'>}
然而,这并不能真正说明问题。names[2]、version[0]和options["centered"]的类型将是什么?在这个具体案例中,你可以看到它们分别是str、int和bool。然而,类型提示本身并没有给出这方面的信息。
相反,你应该使用类型化模块中定义的特殊类型。这些类型增加了用于指定复合类型元素类型的语法。你可以这样写:
>>> from typing import Dict, List, Tuple
>>> names: List[str] = ["Guido", "Thomas", "Bobby"]
>>> version: Tuple[int, int, int] = (3, 7, 1)
>>> options: Dict[str, bool] = {"centered": False, "capitalize": True}
>>> __annotations__
{'name': <class 'str'>, 'pi': <class 'float'>, 'centered': <class 'bool'>, 'names': typing.List[str], 'version': typing.Tuple[int, int, int], 'options': typing.Dict[str, bool]}
请注意,这些类型中的每一个都以大写字母开头,并且它们都使用方括号来定义项目类型:
- names是一个字符串的列表。
- 版本是一个由三个整数组成的3元组。
- options是一个将字符串映射到布尔值的字典。
类型化模块包含更多的复合类型,包括Counter、Deque、FrozenSet、NamedTuple和Set。此外,该模块还包括其他种类的类型,你将在后面的章节中看到。
让我们回到纸牌游戏。一张牌由两个字符串组成的元组来表示。你可以把它写成Tuple[str, str],所以这副牌的类型就变成了List[Tuple[str, str]]。因此,你可以对create_deck()做如下注释:
def create_deck(shuffle: bool = False) -> List[Tuple[str, str]]:
"""Create a new deck of 52 cards"""
deck = [(s, r) for r in RANKS for s in SUITS]
if shuffle:
random.shuffle(deck)
return deck
当你在处理嵌套类型时,类型提示可能会变得很模糊,比如扑克牌。你可能需要盯着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]],
]:
"""Deal the cards in the deck into four hands"""
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]:
"""Deal the cards in the deck into four hands"""
return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
类型别名非常适合使代码及其意图更清晰。
十、摘要
Python 中的类型提示是一个非常有用的功能,你可以愉快地生活。类型提示不会使你能够编写任何不使用类型提示就无法编写的代码。相反,使用类型提示可以更轻松地推理代码、查找细微的 bug 并维护干净的体系结构。
在本课程中,您了解了 Python 中的类型提示的工作原理,以及渐进式类型如何使 Python 中的类型检查比许多其他语言更灵活。您已经了解了使用类型提示的一些优点和缺点,以及如何使用注释或类型注释将它们添加到代码中。最后,您看到了 Python 支持的许多不同类型,以及如何执行静态类型检查。
有许多资源可以帮助您了解有关 Python 中的静态类型检查的更多信息。PEP 483 和 PEP 484 提供了很多关于如何在 Python 中实现类型检查的背景知识。Mypy文档有一个很好的参考部分,详细介绍了所有可用的不同类型。