Fluent Python 2nd 笔记——Type hints(类型标注)介绍

PEP 484—Type Hints 在 Python 中引入了显式的类型标注,可以为函数参数、返回值、变量等添加类型提示。主要目的在于帮助开发工具通过静态检查发现代码中的 Bug。

gradual typing

PEP 484 引入的是一种 gradual type system(渐进式类型系统),支持同样类型系统的语言还有微软的 TypeScript、Google 的 Dart 等。该系统具有以下特征:

  • 可选的。默认情况下,类型检查器不应该警告没有标注类型的代码。当无法确认某个对象的类型时,假设其为 Any 类型
  • 在运行时不捕获类型错误。Type hints 主要用来帮助类型检查器、linter 和 IDE 输出警告信息,不会在运行时阻止不匹配的类型传递给某个函数
  • 对性能没有提升。理论上讲,类型标注提供的信息能够帮助解释器对生成的字节码进行优化。目前 Python 还没有相关的实现

类型标注在任何层面上都是可选的
简单来说,用户可以选择任何一个自己感兴趣的参数或返回值进行类型标注,不用管其它的。在没有配置 IDE 进行严格检查的时候,不会有任何报错出现。
即便用户错误地标注了类型,对程序的运行也不会产生任何影响。最多只是 IDE 会有报错提示。

gradual typing 示例
1
2
3
4
5
6
7
8
9
10
11
# messages.py
def show_count(count, word):
if count == 1:
return f'1 {word}'
count_str = str(count) if count else 'no'
return f'{count_str} {word}s'

print(show_count(1, 'dog'))
# => 1 dog
print(show_count(2, 'dog'))
# => 2 dogs

安装 mypy 类型检查工具:pip install mypy

使用 mypy 命令对 messages.py 源代码进行类型检查,没有任何错误:

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

只有当加上 --disallow-untyped-defs 选项的时候才会检查出错误(函数缺少类型标注):

1
2
3
$ mypy --disallow-untyped-defs messages.py
messages.py:1: error: Function is missing a type annotation
Found 1 error in 1 file (checked 1 source file)

修改一下检查的严格程度,使用 --disallow-incomplete-defs 选项,此时检查是通过的:

1
2
$ mypy --disallow-incomplete-defs messages.py
Success: no issues found in 1 source file

将函数 show_count 的签名改为 show_count(count, word) -> str,只为返回值添加类型标注,再次进行检查:

1
2
$ messages.py:1: error: Function is missing a type annotation for one or more arguments
Found 1 error in 1 file (checked 1 source file)

--disallow-incomplete-defs 不会去管完全没有类型标注的函数,而是会确保,只要某个函数添加了类型标注,则其类型标注必须完整应用到该函数的所有参数和返回值。

假如将函数 show_count 的签名改为 show_count(count: int, word: str) -> int,运行类型检查则会报出其他错误(返回值类型不匹配):

1
2
3
4
$ mypy --disallow-incomplete-defs messages.py
messages.py:3: error: Incompatible return value type (got "str", expected "int")
messages.py:5: error: Incompatible return value type (got "str", expected "int")
Found 2 errors in 1 file (checked 1 source file)

程序的运行不会受任何影响

1
2
3
$ python messages.py
1 dog
2 dogs

即类型标注可以帮助 IDE 等工具对代码进行静态检查,在程序运行前发现可能的语法错误。但并不会对程序的运行时施加任何影响。
这就是为什么称之为 Gradual。即不具备任何强制性,可以在需要的时候逐步完善任何感兴趣的变量。但加不加标注,程序该怎么跑还是怎么跑。
Type checker in VIM

使用 None 作为默认值

前面的 messages.py 实际上做的事情很简单,就是输出数量和名词。数量为 1 名词用单数,数量大于 1 名词就加 s 变复数。
但很多名词并不是直接加 s 就能成为复数形式,比如 child -> children。因此代码可以优化为如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
def show_count(count: int, singular: str, plural: str = '') -> str:
if count == 1:
return f'1 {singular}'
count_str = str(count) if count else 'no'
if not plural:
plural = singular + 's'
return f'{count_str} {plural}'

print(show_count(2, 'dog'))
# => 2 dogs
print(show_count(2, 'child', 'children'))
# => 2 children

上面的代码可以很好的工作。函数中加了一个参数 plural 表示名词的复数形式,默认值是空字符串 ''。但从语义的角度看,默认值用 None 更符合一些。
即某个名词要么有特殊的复数形式,要么没有。但这会导致 plural 参数的类型声明不适合使用 str,因为其取值可以是 None,而 None 不属于 str 类型。

show_count 函数的签名改为如下形式即可:

1
2
from typing import Optional
def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:

其中 Optional[str] 就表示该类型可以是 str 或者 None
此外,默认值 =None 必须显式地写在声明里,否则 Python 运行时会将 plural 视为必须提供的参数。
在类型声明里注明了某个参数是 Optional,并不会真的将其变为可选参数。记住对于运行时而言,类型标注总是会被忽略掉

Types are defined by supported operations

引用 PEP 483 中的定义,类型就是一组值的集合,这些值有一个共同的特点,就是一系列特定的函数能够应用到这些值上。即某种类型支持的一系列操作定义了该类型的特征

比如下面的 double 函数:

1
2
def double(x):
return x * 2

其中 x 参数的类型可以是数值类型(intcomplexFractionnumpy.uint32 等),但也可能是某种序列类型(strtuplelistarray 等)、N 维数组 numpy.array 甚至任何其他类型,只要该类型实现或继承了 __mul__ 方法且接收 int 作为参数。

但是对于另一个 double 函数:

1
2
3
4
from collections import abc

def double(x: abc.Sequence):
return x * 2

x 参数的类型声明为 abc.Sequence,此时使用 mypy 检查其类型声明会报出错误:

1
2
3
$ mypy double.py
double.py:4: error: Unsupported operand types for * ("Sequence[Any]" and "int")
Found 1 error in 1 file (checked 1 source file)

因为 Sequence 虚拟基类并没有实现或者继承 __mul__ 方法,类型检查器认为 x * 2 是不支持的操作。但在实际运行时,上述代码支持 xstrtuplelistarray 等等实现了 Sequence 的具体类型,运行不会有任何报错。
原因在于,运行时会忽略类型声明。且类型检查器只会关心显式声明的对象,比如 abc.Sequence 中有没有 __mul__

这也是为什么在 Python 中,类型的定义就是其支持的操作。任何作为参数 x 传给 double 函数的对象,Python 运行时都会接受。它可能运行通过,也可能该对象实际并不支持 * 2 操作,报出 TypeError

在 gradual type system 中,有两种不同的看待类型的角度:

  • Duck typing:Smalltalk 发明的“鸭子类型”,Python、JavaScript、Ruby 等采用此方式。对象有类型,而变量(包括参数)是无类型的。在实践中,对象声明的类型是不重要的,关键在于该对象实际支持的操作。鸭子类型更加灵活,代价就是允许更多的错误出现在运行时
  • Nominal typing:C++、Java、C# 等采用此方式。对象和变量都有类型。但对象只存在于运行时,而类型检查器只关心源代码中标记了类型的变量。比如 DuckBird 的子类,你可以将一个 Duck 对象绑定给标记为 birdie: Bird 的参数。但是在函数体中,类型检查器会认为 birdie.quack() 是非法的(quack()Duck 类中实现的方法)。因为 Bird 类并没有提供 quack() 方法,即便实际的参数 Duck 对象已经实现了 quack()。Nominal typing 在静态检查时强制应用,类型检查器只是读取源代码,并不会执行任何一个代码片段。Nominal typing 更加严格,优势就是可以更早地发现某些 bug,比如在 build 阶段甚至代码刚输入到 IDE 中的时候。

参考下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# birds.py
class Bird:
pass

class Duck(Bird):
def quack(self):
print('Quack')

def alert(birdie):
birdie.quack()

def alert_duck(birdie: Duck) -> None:
birdie.quack()

def alert_bird(birdie: Bird) -> None:
birdie.quack()

DuckBird 的子类;
alert 没有类型标注,会被类型检查器忽略;
alert_duck 接收一个 Duck 类型的参数;
alert_bird 接收 Bird 类型的参数。

mypy 检查上述代码会报出一个错误:

1
2
3
$ mypy birds.py
birds.py:15: error: "Bird" has no attribute "quack"
Found 1 error in 1 file (checked 1 source file)

Bird 类没有 quack() 方法,但函数体中却有对 quack() 方法的调用。

编写如下代码调用前面的函数:

1
2
3
4
5
6
7
# daffy.py
from birds import *

daffy = Duck()
alert(daffy)
alert_duck(daffy)
alert_bird(daffy)

可以成功运行:

1
2
3
4
$ python daffy.py
Quack
Quack
Quack

还是那句重复了无数遍的话,在运行时,Python 并不关心声明的变量,它使用 duck typing,只关心实际传入的对象是不是支持某个操作。
因而某些时候即便静态类型检查报出了错误,代码依旧能成功运行。

但是对于下面的例子,静态检查就显得很有用了。

1
2
3
4
5
6
7
# woody.py
from birds import *

woody = Bird()
alert(woody)
alert_duck(woody)
alert_bird(woody)

此时运行 woody.py 会报出 AttributeError: 'Bird' object has no attribute 'quack' 错误。因为实际传入的 woody 对象是 Bird 类的实例,它确实没有 quack() 方法。
有了静态检查,就可以在程序运行前发现此类错误。

上面的几个例子表明,duck typing 更灵活更加容易上手,但同时会允许不支持的操作在运行时触发错误;Nominal typing 会在运行时之前检测错误,但有些时候会阻止本可以运行的代码
在实际的环境中,函数有可能非常臃肿,有可能 birdie 参数被传递给了更多函数,birdie 还有可能来自于很长的函数调用链,会使得运行时错误很难被精确定位到。类型检查器则会阻止很多这类错误在运行时发生。

Type hints 中用到的类型

Any 类型

gradual type system 的基础就是 Any 类型,也被叫做动态类型(dynamic type)。
当类型检测器遇到如下未标注类型的代码:

1
2
def double(x: abc.Sequence):
return x * 2

会将其视为如下形式:

1
2
3
4
from typing import Any

def double(x: Any) -> Any:
return x * 2

Any 类型支持所有可能的操作,参数 n: Any 可以接受任意类型的值。

简单类型和类

简单类型比如 intfloatstrbytes 可以直接用在类型标注中。
来自于标准库或者第三方库,以及用户自定义的类也可以作为类型标注的关键字。
虚拟基类在类型标注中也比较常用。

同时还要注意一个重要的原则:子类可以用在任何声明需要其父类的地方(Liskov Substitution Principle)。

OptionalUnion 类型

Optional[str] 实际上是 Union[str, None] 类型的简写形式,表示某个值可以是 str 或者 None
在 Python3.10 中,可以用 str | None 代替 Union[str, None]

下面是一个有可能返回 str 或者 float 类型的函数:

1
2
3
4
5
6
7
from typing import Union

def parse_token(token: str) -> Union[str, float]:
try:
return float(token)
except ValueError:
return token

Union 在相互之间不一致的类型中比较有用,比如 Union[str, float]。对于有兼容关系的类型比如 Union[int, float] 就不是很有必要,因为声明为 float 类型的参数也可以接收 int 类型的值。

通用集合类型

Python 中的大多数集合类型都是不均匀的。不均匀的意思就是,比如 list 类型的变量中可以同时存放多种不同类型的值。但是,这种做法通常是不够实用的。
通常用户将一系列对象保存至某个集合中,这些对象一般至少有一个共同的接口,以便用户稍后用一个函数对所有这些对象进行处理。

Generic types 可以在声明时加上一个类型参数。比如 list 可以通过参数化来控制自身存储的值的类型:

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

在 Python 版本不低于 3.9 时,上述代码表示 tokenize 函数会返回一个列表,列表中的每一项都是 str 类型。

类型标注 stuff: liststuf: list[Any] 是等效的,都表示 stuff 这个列表可以同时包含任意类型的元素。

元组

元组作为记录
比如需要保存城市、人口和国家的值 ('Shanghai', 24.28, 'China'),其类型标注可以写作 tuple[str, float, str]

有命名字段的元组
建议使用 typing.NamedTuple

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from typing import NamedTuple

class Coordinate(NamedTuple):
lat: float
lon: float

def display(lat_lon: tuple[float, float]) -> None:
lat, lon = lat_lon
ns = 'N' if lat >= 0 else 'S'
ew = 'E' if lon >= 0 else 'W'
print(f'{abs(lat):0.1f}°{ns}, {abs(lon):0.1f}°{ew}')

display(Coordinate(120.20, 30.26))
# => 120.2°N, 30.2°E

NamedTupletuple[float, float] 兼容,因而 Coordinate 对象可以直接传递给 display 函数。

元组作为不可变序列
当需要将元组作为不可变列表使用时,类型标注需要指定一个单一的类型,后面跟上逗号和 ...
比如 tuple[int, ...] 表示一个元组包含未知数量的 int 类型的元素。
stuff: tuple[Any, ...] 等同于 stuff: tuple,表示 stuff 对象可以包含未指定数量的任意类型的元素。

Generic mappings

Generic mapping 类型使用 MappingType[KeyType, ValueType] 形式的标注。比如内置的 dict 和其他 collections/collections.abc 库中的 Map 类型。

Abstract Base Class

理想情况下,一个函数应该接收虚拟类型的参数,不使用某个具体的类型。
比如下面的函数签名:

1
2
from collections.abc import Mapping
def name2hex(name: str, color_map: Mapping[str, int]) -> str:

使用 abc.Mapping 作为函数参数的类型标注,能够允许调用者传入 dictdefaultdict.ChainMapUserDict 子类或者任意 Mapping 的子类型作为参数。

相反的,使用下面的函数签名:

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

会使得 color_map 参数必须接收 dict 或者 defaultDictOrderedDictdict 的子类型。collections.UserDict 的子类就无法通过 color_map 的类型检查。因为 UserDict 并不是 dict 类型的子类,它俩是兄弟关系,都是 abc.MutableMapping 的子类。
因此,在实践中最好使用 abc.Mapping 或者 abc.MutableMapping 作为参数的类型标注。

有个法则叫做 Postel’s law,也被称为鲁棒性原则。简单来说就是对发送的内容保持谨慎,对接收的内容保持自由

拿列表举例来说,在标注函数的返回值类型时,最好使用 list[str] 这种具体的类型;在标注函数的参数时,则使用 SequenceIterable 这类抽象的集合类型。

Iterable
1
2
3
4
5
6
7
8
9
10
11
12
13
from collections.abc import Iterable

FromTo = tuple[str, str]

def zip_replace(text: str, changes: Iterable[FromTo]) -> str:
for from_, to in changes:
text = text.replace(from_, to)
return text

l33t = [('a', '4'), ('e', '3'), ('i', '1'), ('o', '0')]
text = 'mad skilled noob powned leet'
print(zip_replace(text, l33t))
# => m4d sk1ll3d n00b p0wn3d l33t

其中 FromTotype alias

参数化通用类型与 TypeVar

参数化通用类型是一种通用类型,比如 list[T] 中的 T 可以绑定任意指定类型,但是之后再次出现的 T 则会表示同样的类型。

参考下面的 sample.py 代码:

1
2
3
4
5
6
7
8
9
10
11
12
from collections.abc import Sequence
from random import shuffle
from typing import TypeVar

T = TypeVar('T')

def sample(population: Sequence[T], size: int) -> list[T]:
if size < 1:
raise ValueError('size must be >= 1')
result = list(population)
shuffle(result)
return result[:size]

假如传给 sample 函数的参数类型是 tuple[int, ...],该参数与 Sequence[int] 通用,因此类型参数 T 就代表 int,从而返回值类型变成 list[int]
假如传入的参数类型是 str,与 Sequence[str] 通用,则 T 代表 str,因而返回值类型变成 list[str]

Restricted TypeVar

1
2
3
4
5
from decimal import Decimal
from fractions import Fraction
from typing import TypeVar

NumberT = TypeVar('NumberT', float, Decimal, Fraction)

表示类型参数 T 只能是声明中提到的有限的几个类型之一。

Bounded TypeVar

1
2
3
4
from collections.abc import Hashable
from typing import TypeVar

HashableT = TypeVar('HashableT', bound=Hashable)

表示类型参数 T 只能是 Hashable 类型或者其子类型之一。

Static Protocols

Protocol 类型与 Go 中的接口很相似。它的定义中会指定一个或多个方法,类型检查器则会确认对应的类型是否实现了这些方法。

比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from collections.abc import Iterable
from typing import TypeVar, Protocol, Any

class SupportLessThan(Protocol):
def __lt__(self, other: Any) -> bool: ...


LT = TypeVar('LT', bound=SupportLessThan)

def top(series: Iterable[LT], length: int) -> list[LT]:
ordered = sorted(series, reverse=True)
return ordered[:length]

print(top([4, 1, 5, 2, 6, 7, 3], 3))
# => [7, 6, 5]
l = 'mango pear apple kiwi banana'.split()
print(top(l, 3))
# => ['pear', 'mango', 'kiwi']
l2 = [(len(s), s) for s in l]
print(top(l2, 3))
# => [(6, 'banana'), (5, 'mango'), (5, 'apple')]

如果 top 函数中 series 参数的类型标注是 Iterable[T],没有任何其他限制,意味着该类型参数 T 可以是任意类型。但将 Iterable[Any] 传给函数体中的 sorted 函数,并不总是成立,必须确保 Iterable[Any] 是可以被直接排序的类型。
因而需要先创建一个 SupportLessThan protocol 指定 __lt__ 方法,再用该 protocol 来绑定类型参数 LT,从而限制 series 参数必须为可迭代对象,且其中的元素都实现了 __lt__ 方法,使得传入的 series 参数支持被 sorted 直接排序。

当类型 T 实现了 protocol P 中定义的所有方法时,则说明该类型 T 与 protocol P 通用。

Callable

Callable 主要用于标注高阶函数中作为参数或者返回值的函数对象。其格式为 Callable[[ParamType1, ParamType2], ReturnType]

参考资料

Fluent Python, 2nd Edition