介绍

在本文中,将了解 Python 类型检查 (Type-Checking) 。

在本教程中,将了解以下内容:

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

类型系统

所有编程语言都包含某种类型系统,该系统形式化了它可以使用哪些类别的对象以及如何处理这些类别。

例如,一个类型系统可以定义一个数字类型,42作为一个数字类型对象的例子。

动态类型 (Dynamic Typing)

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

1
2
3
4
5
6
7
8
9
>>> if False:
... 1 + "two" # This line never runs, so no TypeError is raised
... else:
... 1 + 2
...
3

>>> 1 + "two" # Now this is type checked, and a TypeError is raised
TypeError: unsupported operand type(s) for +: 'int' and 'str'

在第一个示例中,分支1 + "two"从不运行,因此从不检查类型。

第二个示例显示,当1 + "two"被评估时,它会引发一个 TypeError ,因为在Python中整数和字符串不能相加。

接下来,让我们看看变量是否可以改变类型:

1
2
3
4
5
6
7
>>> thing = "Hello"
>>> type(thing)
<class 'str'>

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

type()返回对象的类型。

这些例子证实了事物的类型是允许更改的,Python在类型更改时正确地推断出它的类型。

静态类型 (Static Typing)

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

对于静态类型,通常不允许变量更改类型,尽管可能存在将变量强制转换为其他类型的机制。

让我们来看一个来自静态类型语言的快速示例。下面的 Java 片段:

1
2
String thing;
thing = "Hello";

第一行声明变量名thing在编译时绑定到字符串类型。这个名字永远不可能换成另一种类型。在第二行,事物被赋值。它永远不能被分配一个不是字符串对象的值。例如,如果后来说 thing=28.1f,编译器会因为不兼容的类型而引发错误。

Python将始终是一种动态类型的语言。然而,PEP 484引入了类型提示,这使得也可以对Python代码进行静态类型检查。

与大多数其他静态类型语言中的类型工作方式不同,类型提示本身不会导致 Python 强制执行类型。正如名字所说,类型提示只是建议类型。还有其他一些工具,将在后面看到,它们使用类型提示执行静态类型检查。

鸭子类型 (duck typing)

在谈论Python时经常使用的另一个术语是鸭子类型

“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”

我们并不关心对象是什么类型,到底是不是鸭子,只关心行为。

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

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

1
2
3
4
5
6
7
>>> class TheHobbit:
def __len__(self):
return 95022

>>> the_hobbit = TheHobbit()
>>> len(the_hobbit)
95022

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

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

为了调用 len(obj),对 obj 的唯一真正约束是它必须定义一个 .__len__() 方法。

否则,对象的类型可以与 str、list、dict 或 TheHobbit 等不同。

在使用结构子类型对 Python 代码进行静态类型检查时,一定程度上支持 Duck 类型。 稍后您将了解有关鸭子类型的更多信息。

Hello Types

在本节中,将看到如何向函数添加类型提示。

下面的函数通过添加适当的大写字母和装饰线将文本字符串转换为标题:

1
2
3
4
5
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 居中:

1
2
3
4
5
6
>>> print(headline("python type checking"))
Python Type Checking
--------------------

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

要向函数添加有关类型的信息,只需注释其参数和返回值,如下所示:

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

text: str 语法表明 text 参数应该是 str 类型

可选参数 align 指定其类型为 bool 并给定默认值 True

最后, -> str 表示函数 headline() 返回值类型为 str

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

  • 对冒号使用常规规则,即冒号前没有空格,冒号后面有一个空格:text: str

  • 将参数注释与默认值组合时,在 = 符号周围使用空格:align: bool = True

  • 在 -> 箭头周围使用空格:def headline(...) - > str

像这样添加类型提示不会产生运行时影响:它们只是提示,不会自行强制执行。

例如,如果我们对 align 参数使用了错误的类型(即使命名错误),代码仍然可以运行而没有任何问题或警告:

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

NOTE:
这看似有效的原因是字符串“left”比较为真。使用 align=“center” 不会产生预期的效果,因为 “center” 也是真

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

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

但是如果传入的参数类型不是指定的参数类型,程序不会出现错误,此时可以使用类型检查模块通过提示内容确定是否类型输入正确,如Mypy

可以通过 pip安装:

1
$ pip install mypy

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

1
2
3
4
5
6
7
8
9
# 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"))

然后通过Mypy运行上面的文件:

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

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

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

1
2
3
4
5
6
7
8
9
10
# 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))

在这里,已将对齐更改为居中,并在调用 header() 时正确使用了布尔值作为居中。

代码现在通过 Mypy:

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

当运行下面代码时,会看到预期的输出:

1
2
3
4
5
$ python headlines.py
Python Type Checking
--------------------
oooooooooooooooooooo Use Mypy oooooooooooooooooooo

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

优缺点

在代码中添加类型的优势之一是类型提示有助于捕获某些错误。其他优点包括:

  • 类型提示有助于记录代码

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

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

当然,静态类型检查还会有一些缺点:

  • 类型提示需要开发人员时间和精力来添加。尽管花费更少的调试时间可能会有所回报,但将花费更多的时间输入代码。

  • 类型提示在现代 Python 中效果最好。注释是在 Python 3.0 中引入的,并且可以在 Python 2.7中使用类型注释。尽管如此,变量注释类型提示的延迟评估等改进意味着您将拥有更好的使用 Python 3.6 甚至Python 3.7进行类型检查的体验。

  • 类型提示会在启动时间上带来轻微的损失。如果您需要使用该typing模块,则导入时间可能很长,尤其是在短脚本中。

那么,应该在自己的代码中使用静态类型检查吗?嗯,这不是一个全有或全无的问题。幸运的是,Python 支持渐进类型的概念。这意味着可以逐渐将类型引入代码中。

静态类型检查器将忽略没有类型提示的代码。因此,可以开始向关键组件添加类型,并在它增加价值的情况下继续。

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

  • 如果您刚开始学习Python,可以安全地等待类型提示,直到您有更多经验。

  • 类型提示在简短的一次性脚本中几乎没有什么价值。

  • 在其他人使用的库中,尤其是在PyPI上发布的库中,类型提示会增加很多价值。使用库的其他代码需要这些类型提示才能正确地进行类型检查。

  • 在较大的项目中,类型提示可以帮助理解类型是如何在代码中流动的,强烈推荐。在与他人合作的项目中更是如此。

查看上面的优缺点列表,会注意到添加类型不会影响您正在运行的程序或程序的用户。类型检查旨在让开发人员更方便

注释

PEP 484 根据Jukka Lehtosalo博士项目Mypy所做的工作,定义了如何向Python代码添加类型提示。添加类型提示的主要方法是使用注释。随着类型检查变得越来越普遍,这也意味着注释应该主要保留给类型提示。

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

函数注释

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

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

对于参数,语法是 argument :annotation,而返回类型使用 -> annotation 进行注释。请注意,注释必须是有效的 Python 表达式。

以下简单示例为计算圆的周长的函数添加注释:

1
2
3
4
import math

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

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

1
2
3
4
5
>>> circumference(1.23)
7.728317927830891

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

有时可能会对 Mypy 如何解释类型提示感到困惑。

对于这些情况,有特殊的 Mypy 表达式:reveal_type()reveal_locals()

可以在运行 Mypy 之前将这些添加到代码中,Mypy 将报告它推断出的类型。 例如,将以下代码保存到reveal.py

1
2
3
4
5
6
7
# reveal.py
import math
reveal_type(math.pi)

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

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

1
2
3
4
5
6
$ 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 的类型,以及我们的局部变量半径和周长。

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

如果 Mypy 说 “Name ‘reveal_locals‘ is not defined” ,可能需要更新 Mypy 安装。

Reveal_locals() 表达式在 Mypy 0.610 及更高版本 中可用。

变量注释

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

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

1
2
3
4
pi: float = 3.142

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

该变量pi已使用float类型提示进行注释。

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

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

1
2
3
4
5
>>> circumference(1)
6.284

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

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

1
2
3
4
5
6
>>> nothing: str
>>> nothing
NameError: name 'nothing' is not defined

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

由于没有为 nothing 赋值,所以还没有定义名称 nothing。

Type Comments

如前所述,注解是在 Python 3 中引入的,它们并没有被向后移植到 Python 2。这意味着如果正在编写需要支持旧版 Python 的代码,则不能使用注释。

相反,可以使用类型注释。 这些是特殊格式的注释,可用于添加与旧代码兼容的类型提示。 要向函数添加类型注释,可以执行以下操作:

1
2
3
4
5
import math

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

类型注释只是注释,因此可以在任何版本的 Python 中使用。

__annotations__类型注释由类型检查器直接处理,因此这些类型在字典中不可用:

1
2
>>> circumference.__annotations__
{}

类型注释必须以 type: literal 开头,并且与函数定义位于同一行或下一行。如果你想用多个参数注释一个函数,你可以编写每个类型,用逗号分隔:

1
2
3
4
5
def headline(text, width=80, fill_char="-"):
# type: (str, int, str) -> str
return f" {text.title()} ".center(width, fill_char)

print(headline("type comments work", width=40))

还可以使用自己的注释将每个参数写在单独的行上:

1
2
3
4
5
6
7
8
9
# headlines.py
def headline(
text, # type: str
width=80, # type: int
fill_char="-", # type: str
): # type: (...) -> str
return f" {text.title()} ".center(width, fill_char)

print(headline("type comments work", width=40))

通过 Python 和 Mypy 运行示例:

1
2
3
4
5
$  python headlines.py
---------- Type Comments Work ----------

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

如果你有错误,例如你碰巧在第 10 行调用了 width=“full” 的 header(),Mypy 会告诉你:

1
2
3
$ mypy headline.py
headline.py:10: error: Argument "width" to "headline" has incompatible
type "str"; expected "int"

还可以向变量添加类型注释。这与向参数添加类型注释的方式类似:

1
2
pi = 3.142  # type: float
#在本例中,pi 将作为浮点变量进行类型检查。

结论

Python 中的类型提示是一个非常有用的功能。

类型提示不会让您编写任何不使用类型提示就无法编写的代码。
相反,使用类型提示可以更轻松地推理代码、发现细微的错误并维护干净的架构。

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

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