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 示例
# 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
源代码进行类型检查,没有任何错误:
$ mypy messages.py
Success: no issues found in 1 source file
只有当加上 --disallow-untyped-defs
选项的时候才会检查出错误(函数缺少类型标注):
$ 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
选项,此时检查是通过的:
$ mypy --disallow-incomplete-defs messages.py
Success: no issues found in 1 source file
将函数 show_count
的签名改为 show_count(count, word) -> str
,只为返回值添加类型标注,再次进行检查:
$ 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
,运行类型检查则会报出其他错误(返回值类型不匹配):
$ 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)
但程序的运行不会受任何影响:
$ python messages.py
1 dog
2 dogs
即类型标注可以帮助 IDE 等工具对代码进行静态检查,在程序运行前发现可能的语法错误。但并不会对程序的运行时施加任何影响。
这就是为什么称之为 Gradual。即不具备任何强制性,可以在需要的时候逐步完善任何感兴趣的变量。但加不加标注,程序该怎么跑还是怎么跑。
Type checker in VIM
使用 None
作为默认值
前面的 messages.py
实际上做的事情很简单,就是输出数量和名词。数量为 1 名词用单数,数量大于 1 名词就加 s
变复数。
但很多名词并不是直接加 s
就能成为复数形式,比如 child
-> children
。因此代码可以优化为如下形式:
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
函数的签名改为如下形式即可:
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
函数:
def double(x):
return x * 2
其中 x
参数的类型可以是数值类型(int
、complex
、Fraction
、numpy.uint32
等),但也可能是某种序列类型(str
、tuple
、list
、array
等)、N 维数组 numpy.array
甚至任何其他类型,只要该类型实现或继承了 __mul__
方法且接收 int 作为参数。
但是对于另一个 double
函数:
from collections import abc
def double(x: abc.Sequence):
return x * 2
将 x
参数的类型声明为 abc.Sequence
,此时使用 mypy
检查其类型声明会报出错误:
$ 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
是不支持的操作。但在实际运行时,上述代码支持 x
为 str
、tuple
、list
、array
等等实现了 Sequence
的具体类型,运行不会有任何报错。
原因在于,运行时会忽略类型声明。且类型检查器只会关心显式声明的对象,比如 abc.Sequence
中有没有 __mul__
。
这也是为什么在 Python 中,类型的定义就是其支持的操作。任何作为参数 x
传给 double
函数的对象,Python 运行时都会接受。它可能运行通过,也可能该对象实际并不支持 * 2
操作,报出 TypeError
。
在 gradual type system 中,有两种不同的看待类型的角度:
- Duck typing:Smalltalk 发明的“鸭子类型”,Python、JavaScript、Ruby 等采用此方式。对象有类型,而变量(包括参数)是无类型的。在实践中,对象声明的类型是不重要的,关键在于该对象实际支持的操作。鸭子类型更加灵活,代价就是允许更多的错误出现在运行时。
- Nominal typing:C++、Java、C# 等采用此方式。对象和变量都有类型。但对象只存在于运行时,而类型检查器只关心源代码中标记了类型的变量。比如
Duck
是Bird
的子类,你可以将一个Duck
对象绑定给标记为birdie: Bird
的参数。但是在函数体中,类型检查器会认为birdie.quack()
是非法的(quack()
是Duck
类中实现的方法)。因为Bird
类并没有提供quack()
方法,即便实际的参数Duck
对象已经实现了quack()
。Nominal typing 在静态检查时强制应用,类型检查器只是读取源代码,并不会执行任何一个代码片段。Nominal typing 更加严格,优势就是可以更早地发现某些 bug,比如在 build 阶段甚至代码刚输入到 IDE 中的时候。
参考下面的例子:
# 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()
Duck
是 Bird
的子类;
alert
没有类型标注,会被类型检查器忽略;
alert_duck
接收一个 Duck
类型的参数;
alert_bird
接收 Bird
类型的参数。
用 mypy
检查上述代码会报出一个错误:
$ 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()
方法的调用。
编写如下代码调用前面的函数:
# daffy.py
from birds import *
daffy = Duck()
alert(daffy)
alert_duck(daffy)
alert_bird(daffy)
可以成功运行:
$ python daffy.py
Quack
Quack
Quack
还是那句重复了无数遍的话,在运行时,Python 并不关心声明的变量,它使用 duck typing,只关心实际传入的对象是不是支持某个操作。
因而某些时候即便静态类型检查报出了错误,代码依旧能成功运行。
但是对于下面的例子,静态检查就显得很有用了。
# 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)。
当类型检测器遇到如下未标注类型的代码:
def double(x: abc.Sequence):
return x * 2
会将其视为如下形式:
from typing import Any
def double(x: Any) -> Any:
return x * 2
Any
类型支持所有可能的操作,参数 n: Any
可以接受任意类型的值。
简单类型和类
简单类型比如 int
、float
、str
、bytes
可以直接用在类型标注中。
来自于标准库或者第三方库,以及用户自定义的类也可以作为类型标注的关键字。
虚拟基类在类型标注中也比较常用。
同时还要注意一个重要的原则:子类可以用在任何声明需要其父类的地方(Liskov Substitution Principle)。
Optional
和 Union
类型
Optional[str]
实际上是 Union[str, None]
类型的简写形式,表示某个值可以是 str
或者 None
。
在 Python3.10 中,可以用 str | None
代替 Union[str, None]
。
下面是一个有可能返回 str
或者 float
类型的函数:
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
可以通过参数化来控制自身存储的值的类型:
def tokenize(text: str) -> list[str]:
return text.upper().split()
在 Python 版本不低于 3.9 时,上述代码表示 tokenize
函数会返回一个列表,列表中的每一项都是 str
类型。
类型标注 stuff: list
和 stuf: list[Any]
是等效的,都表示 stuff
这个列表可以同时包含任意类型的元素。
元组
元组作为记录
比如需要保存城市、人口和国家的值 ('Shanghai', 24.28, 'China')
,其类型标注可以写作 tuple[str, float, str]
。
有命名字段的元组
建议使用 typing.NamedTuple
:
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
NamedTuple
与 tuple[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
理想情况下,一个函数应该接收虚拟类型的参数,不使用某个具体的类型。
比如下面的函数签名:
from collections.abc import Mapping
def name2hex(name: str, color_map: Mapping[str, int]) -> str:
使用 abc.Mapping
作为函数参数的类型标注,能够允许调用者传入 dict
、defaultdict.ChainMap
、UserDict
子类或者任意 Mapping
的子类型作为参数。
相反的,使用下面的函数签名:
def name2hex(name: str, color_map: dict[str, int]) -> str:
会使得 color_map
参数必须接收 dict
或者 defaultDict
、OrderedDict
等 dict
的子类型。collections.UserDict
的子类就无法通过 color_map
的类型检查。因为 UserDict
并不是 dict
类型的子类,它俩是兄弟关系,都是 abc.MutableMapping
的子类。
因此,在实践中最好使用 abc.Mapping
或者 abc.MutableMapping
作为参数的类型标注。
有个法则叫做 Postel's law,也被称为鲁棒性原则。简单来说就是对发送的内容保持谨慎,对接收的内容保持自由。
拿列表举例来说,在标注函数的返回值类型时,最好使用 list[str]
这种具体的类型;在标注函数的参数时,则使用 Sequence
或 Iterable
这类抽象的集合类型。
Iterable
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
其中 FromTo
是 type alias。
参数化通用类型与 TypeVar
参数化通用类型是一种通用类型,比如 list[T]
中的 T
可以绑定任意指定类型,但是之后再次出现的 T
则会表示同样的类型。
参考下面的 sample.py
代码:
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
from decimal import Decimal
from fractions import Fraction
from typing import TypeVar
NumberT = TypeVar('NumberT', float, Decimal, Fraction)
表示类型参数 T
只能是声明中提到的有限的几个类型之一。
Bounded TypeVar
from collections.abc import Hashable
from typing import TypeVar
HashableT = TypeVar('HashableT', bound=Hashable)
表示类型参数 T
只能是 Hashable
类型或者其子类型之一。
Static Protocols
Protocol
类型与 Go 中的接口很相似。它的定义中会指定一个或多个方法,类型检查器则会确认对应的类型是否实现了这些方法。
比如下面的例子:
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]
。
网友评论