惨痛的教训告诉我,对于小型程序,动态类型就够了,而大型程序则需要更规范的方式。如果语言能做出规范,那么当然比 放任自流 要好。这篇文章我们将继续讲解 Python 的渐进式类型系统。
重载的签名
Python 函数可以接受不同的参数组合。这些不同的参数组合使用 @typing.overload 装饰器注解。如果函数的返回值类型取决于两个或以上参数的类型,那么这个功能就十分必要。
例如内置函数 sum 是用 C 语言编写的,typeshed 项目在 builtins.pyi 文件中为其提供了重载的类型提示:
1 |
|
__iterable中的两个前导下划线是PEP 484制定的约定,表示仅限位置参数,供 Mypy 检查- 类型检查工具根据参数按顺序匹配各个重载的签名。例如
sum(range(100), 1000)调用不匹配第一个重载的签名,因为该签名只有一个参数,但是它匹配第二个签名
在常规的 Python 模块中也可以使用 @overload,重载的签名放在函数具体的签名和实现前面。如下是个例子:
1 | import functools |
- 定义了两个类型变量
T和S - 第一个重载签名针对
start=0的情况,此时结果的类型既可能是 T,也可能是 int(迭代器对象为空) - 第二个重载签名针对
start参数不为默认值的情况,此时结果的类型是 T 或 S。因为提供的 start 参数可以是任何类型 S,这也是为什么要定义类型变量 T 的原因 - 函数具体实现中的签名没有类型提示
追求注解全覆盖可能导致代码充斥太多噪声,有价值的信息不多。为了简化类型提示而重构也会导致 API 烦琐难用。有时,我们应该务实一点儿,部分代码没有类型提示也没关系。符合 Python 风格的 API 往往难以注解。
重载 max 函数
利用 Python 强大动态功能的函数往往难以添加类型提示。如下使用 python 重新实现了 max 函数:
1 | MISSING = object() |
- 这里重点是 MISSING 是常量,该常量的值是一个独特的 object 实例,用作哨符
- MISSING 是
default=关键字参数的默认值,这样可以让 max 函数接受 default=None,而且区分以下两种情况
如下是为这个 max 函数添加类型注解:
1 | from collections.abc import Callable, Iterable |
@overload 的关键优势是,可以根据参数的类型尽量准确声明返回值的类型。下面将以一个或两个为一组,深入研究 max 函数重载的签名。
-
参数实现了SupportsLessThan,但是没有提供 key 和 default:此时输入的是一个个单独的参数,类型为实现了 SupportsLessThan 协议的 LT,或者是一个可迭代对象,项的类型也是 LT。max 函数的返回值类型与实参或项的类型相同。
key: None = ...的含义是 key 参数的类型在此上下文中是 None,并且它有一个默认值(即实际运行时 key 可以不传)
-
提供了 key,但未提供 default:输入可以是一个个单独的值,类型为 T,或者是可迭代对象,类型为
Iterable[T],而且key=必须是可调用对象,接受同为 T 类型的参数,返回实现了SupportsLessThan协议的值 -
提供了 default,但未提供 key:输入值是一个可迭代对象,项的类型为实现了 SupportsLessThan 协议的 LT。当可迭代对象为空时,
default=是返回值。因此,max 函数的返回值类型必须是 LT 类型和 default 参数类型的联合 -
提供了 key 和 default:
- 输入是一个可迭代对象,项的类型为任意类型 T
- 一个可调用对象,该对象接受类型为 T 的参数,返回实现了
SupportsLessThan协议的 LT 类型值 - 一个默认值,类型为任意类型 DT
- max 函数的返回值类型必须是 T 类型和 default 参数类型的联合
这些类型注解隐含了,如果参数是一个个单独的值,是不能提供 default 参数的。
有了类型提示,对于 max([None, None]) 之类的调用,Mypy 将输出以下错误消息:
1 | mymax_demo.py:109: error: Value of type variable "_LT" of "max" |
为了给类型检查工具提供支持,要编写这么多行注解可能会让人打消念头,不愿编写 max 这样简便灵活的函数。如果还要重新实现 min 函数,我肯定会重用 max 函数的大多数实现,在此基础上适当改动。min 函数重载的签名基本没有变化,唯有函数名称变了,我要复制并粘贴所有重载的签名。
max 函数的签名虽然难以表达,但是也没有超出人类的理解能力,注解标记的表现能力十分有限,跟 Python 没法比。
TypedDict
处理动态数据结构(例如JSON API的响应)时容易误用TypedDict来避免错误。通过本节的示例,你会发现,必须在运行时才能正确处理 JSON,不能依靠静态类型检查。在运行时使用类型提示检查 JSON 等结构时,可以借助 PyPI 中的 pydantic 包。
Python 字典有时被当作记录使用,以键表示字段名称,字段的值可以是不同的类型。例如如下描述一本书的记录:
1 | {"isbn": "0134757599", |
在 Python 3.8 之前,没有什么好方法可以注解这样的记录,因为映射类型中的所有值必须是同种类型。对于上述JSON对象,下面两个注解都不完美。
1 | # 值可以是任何类型 |
PEP 589:TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys 解决了这个问题。如下是一个简单的 TypedDict 示例:
1 | from typing import TypedDict |
乍一看,typing.TypedDict 好像是一个数据类构建器,这是句法类似引起的误会。TypedDict 与数据类构建器千差万别。TypedDict 仅为类型检查工具而生,在运行时没有作用。TypedDict 有以下两个作用:
- 使用与类相似的句法注解字典,为各个
字段的值提供类型提示 - 通过一个构造函数告诉类型检查工具,字典应具有指定的键和指定类型的值
在运行时,TypedDict 构造函数(例如BookDict)相当于一种安慰剂,其实作用与使用同样的参数调用 dict 构造函数相同。BookDict 创建的是普通字典,这也就意味着:
- 伪类声明中的
字段不创建实例属性 - 不能通过初始化方法为
字段指定默认值 - 不允许定义方法
1 | class BookDict(TypedDict): |
- 由于是普通的字典,因此不能使用
object.field表示法读取数据。 - 由于不会进行运行时类型检查,因此
pp['authors']="mark"这样的赋值没有报错
没有类型检查工具,TypedDict 充其量算是注释,可以为阅读代码的人提供些许帮助,仅此而已。而数据类构建器即使不使用类型检查工具,也是十分有用的,因为类构建器可在运行时生成或增强自定义的类,而且可以实例化。另外,类构建器还提供了多个有用的方法或函数。
但是 TypedDict 能协助 Mypy 捕获错误。它能发现无限的类型赋值、不能为定义中没有的键赋值、不能删除 TypedDict 定义的键等。
在如下代码中,定义了一个解析 JSON 字符串并返回 BookDict 的函数。
1 | def from_json(data: str) -> BookDict: |
- json.loads()的返回值类型为 Any,可以返回 whatever(Any类型),因为Any与任何类型都相容,包括声明的返回值类型 BookDict
- 运行 Mypy 时,如果指定了
--disallow-any-expr,则 from_json 函数主体中的两行都会报错。因为使用了 Any 类型
为了解决这种错误,可以在 whatever 变量的初始化语句中添加一个类型提示:
1 | .../typeddict/ $ mypy books_any.py --disallow-any-expr |
- 把类型为 Any 的表达式赋值给带有类型提示的变量,即使加上
--disallow-any-expr,也不报错 - 现在,whatever 的类型是 BookDict,即声明的返回值类型
一定还是要注意,类型检查工具无法预测 json.loads() 一定会返回类似 BookDict 结构的数据,只有运行时验证能确保这一点。静态类型检查无法避免本身就具有不确定性的代码出现错误。json.loads() 就是这样的代码,它在运行时可能会构建不同类型的 Python 对象。
处理具有动态结构的数据(例如 JSON 或 XML)时,TypedDict 根本不能取代运行时数据验证。真想做数据验证,可以使用 pydantic。
这也告诉我们,从动态的映射中获取静态的结构化记录时,运行时检查和错误处理是不可避免的。
类型校正
任何类型系统都不完美,静态类型检查工具、typeshed 项目中的类型提示,以及第三方包中的类型提示也是如此。typing.cast() 是一个特殊函数,可用于处理不受控制的代码中存在的类型检查问题或不正确的类型提示。
类型校正用于消除类型检查工具发出的虚假警告,在类型检查工具无法完全理解事态时提供些许帮助。在运行时,typing.cast 什么也不做。PEP 484 要求类型检查工具务必 听信 cast 中所述的类型。如下是一个例子:
1 | from typing import cast |
- 在生成器表达式上调用
next()函数时,要么返回一个字符串项,要么抛出 StopIteration - 没有异常抛出时,
find_first_str始终返回一个字符串,而且 str 是声明的返回值类型 - 然而,如果最后一行只有
return a[index],那么 Mypy 推导出的返回值类型将是 object,因为 a 参数声明的类型是list[object]。所以,必须通过cast()指引 Mypy
不要过于依赖使用 cast 静默 Mypy 报错,Mypy 报错肯定是有原因的。经常使用 cast 也是一种 代码异味,可能意味着你的团队使用的类型提示有误,或者你的基准代码使用了低质量的依赖。尽管有缺点,但是 cast 也有合理的用途:偶尔调用 cast(),偶尔编写 # type: ignore 注释,何错之有?
彻底禁止使用 cast 显然是不明智的,主要原因是其他变通方法更糟。
# type: ignore提供的信息量更少- 使用 Any 有连锁反应。Any 与所有类型相容,一旦滥用,类型推导可能导致级联效应,破坏类型检查工具对代码中其他部分的检错能力
当然,不是所有类型问题都能使用 cast 纠正。有时需要使用 # type: ignore,偶尔还要使用 Any,甚至可以不为函数添加类型提示。
在运行时提取类型提示
Python 会在导入时读取函数、类和模块中的类型提示,把类型提示存储在 __annotations__ 属性中。
1 | def clip(text: str, max_len: int = 80) -> str: |
注意,与参数的默认值一样,注解在导入时由解释器求解。因此,注解中用的值是 Python 类 str 和 int,而不是字符串 ‘str’ 和 ‘int’。
注解在运行时的问题
类型提示使用量的增加会引起两个问题:
- 如果类型提示很多,那么导入模块使用的 CPU 和内存会更多
- 引用尚未定义的类型需要使用字符串,而不是真正的类型
鉴于 向前引用 问题(类型提示需要引用同一模块后部定义的类),有时必须以字符串形式存储注解。然而,源码中经常见到的一种现象看起来根本不像向前引用:方法返回同一类的新对象。由于类对象直到 Python 完全求解类主体之后才被定义,因此类型提示必须使用类名的字符串形式。但是随着 PEP563 变成标准行为,则情况有所变化。
1 | class Rectangle: |
截至 Python 3.10,涉及向前引用的类型提示必须使用字符串,这是标准做法。静态类型检查工具从一开始就考虑到了这个问题。
但是,在运行时,当获取 stretch 方法的 return 注解时,得到的是字符串 Rectangle,而不是真正的类型(Rectangle类)。因此,要设法确定得到的字符串是什么意思。typing 模块中有 3 个函数和一个类被归类为内省辅助工具,其中最重要的是 typing.get_type_hints 函数。该函数的文档做了如下说明:
1 | get_type_hints(obj, globals=None, locals=None, include_extras=False) |
-
得到的结果通常与
obj.__annotations__相同。不过,以字符串字面量表示的向前引用会放在globals 命名空间和locals 命名空间中求解 -
从 Python3.10 开始,应使用新增的
inspect.get_annotations(...)函数代替typing.get_type_hints
而现在,PEP 563—Postponed Evaluation of Annotations 已经通过,无须再使用字符串编写注解了,而且类型提示的运行时开销也减少了。这个 PEP 的主要目的在 摘要 中表达得很清楚,即下面这句话:
1 | 本 PEP 提议更改函数注解和变量注解,不在定义函数时求解,在注解中保留字符串形式。 |
从 Python3.7 开始,开头有以下 import 语句的模块都按上述方式处理注解。
1 | from __future__ import annotations |
如下展示了使用 from __future__ import annotations 的之后的变化:
1 | from __future__ import annotations |
可以看到,现在所有类型提示都是普通的字符串,尽管事实上在定义 clip 函数时并没有使用括在引号内的字符串。而调用 get_type_hints 得到的是真正的类型,即便有时候原始类型提示是括在引号内的字符串。这是在运行时读取类型提示的推荐方式:
1 | from typing import get_type_hints |
PEP 563 的行为计划在 Python 3.10 中变成默认行为,无须导入 __future__。但是由于这个变化可能会导致在运行时依赖类型提示的代码失效,因此这个 PEP563 定位默认行为的计划有所推迟。
鉴于目前不稳定的局势,如果需要在运行时读取注解,可以参考以下两点建议。
- 不要直接读取
__annotations__属性,使用inspect.get_annotations或typing.get_type_hints - 自己编写一个函数,简单包装
inspect.get_annotations或typing.get_type_hints。在基准代码中调用自己编写的那个函数,这样当以后行为有变时,只需修改一个函数即可
实现一个泛化类
如下实现了一个泛化的彩票摇奖机类(基于之前代码实现的 LottoBlower 类):
1 | from generic_lotto import LottoBlower |
- 实例化泛化类需要提供具体的类型参数,例如这里的 int
- Mypy 能正确推导出 first 是一个 int 值,以及 remain 是一个 int 元组
另外,如果与参数化类型相悖,则 Mypy 还会报告详细的消息。例如:
1 | from generic_lotto import LottoBlower |
这个泛化类的实现如下所示:
1 | import random |
- 泛化类声明通常使用多重继承,因为需要子类化
Generic,以声明形式类型参数(这里的 T) __init__方法的 items 参数是Iterable[T]类型。如果使用LottoBlower[int]实例化,则类型是Iterable[int]tuple[T, ...]表示一个元组,其中的元素类型是 T。这里的...表示任意长度
现在,我们知道如何实现泛化类了。下面定义与泛化有关的术语。
- 泛型:泛型具有一个或多个类型变量的类型,例如
LottoBlower[T]和abc.Mapping[KT, VT] - 形式类型参数:泛型声明中出现的类型变量,例如
abc.Mapping[KT, VT]中的KT和VT - 参数化类型:使用具体类型参数声明的类型,例如
LottoBlower[int]和abc.Mapping[str, float] - 具体类型参数:声明参数化类型时为参数提供的具体类型。例如
LottoBlower[int]中的int
型变
型变概念抽象难懂,而且需要严谨的表述。其实,真正需要关注型变的基本上是代码库作者,因为只有他们才需要支持新的泛化容器类型,或者提供基于回调的 API。不过,为了降低复杂度,可以仅支持不变容器——Python 标准库基本上就是这么做的。
假设一所学校的食堂规定,只允许安装果汁自动售货机。不允许安装一般的饮料自动售货机,以防止售卖学校董事会禁止的苏打水。
一个不变的自动售货机
下面试着为食堂的规定建模:定义一个泛化的 BeverageDispenser 类,参数化饮料的类型。
1 | from typing import TypeVar, Generic |
- BeverageDispenser 参数化了饮料的类型
- install 是模块全局函数。该函数的类型提示会执行只能安装果汁自动售货机的规定
按照定义,如下代码是有效的:
1 | juice_dispenser = BeverageDispenser(Juice()) |
但是如下代码则是无效的:
1 | beverage_dispenser = BeverageDispenser(Beverage()) |
1 | beverage.py:30: error: Argument 1 to "install" has incompatible type "BeverageDispenser[Beverage]"; expected "BeverageDispenser[Juice]" [arg-type] |
不接受可售卖任何饮料(Beverage)的自动售货机,因为食堂要求自动售货机只能售卖果汁(Juice)。但是令人费解的是,如下代码也是无效的:
1 | orange_juice_dispenser = BeverageDispenser(OrangeJuice()) |
1 | beverage.py:30: error: Argument 1 to "install" has incompatible type "BeverageDispenser[OrangeJuice]"; expected "BeverageDispenser[Juice]" [arg-type] |
使用 OrangeJuice 特化的自动售货机也不允许安装,只允许安装 BeverageDispenser[Juice], BeverageDispenser[OrangeJuice] 与 BeverageDispenser[Juice] 不兼容(尽管 OrangeJuice是 Juice 的子类型),按照类型相关的术语,我们说 BeverageDispenser(Generic[T]) 是不变的。诸如 list 和 set 之类 Python 可变的容器类型都是不变的。
一个协变的自动售货机
如果想灵活一些,把自动售货机建模为可接受某些饮料类型及其子类型的泛化类,则必须让它支持协变。
1 | T_co = TypeVar('T_co', covariant=True) |
- 声明类型变量时,设置
covariant=True。_co后缀是 typeshed 项目采用的一种约定,表明这是协变的类型参数 - 使用 T_co 参数化特殊的 Generic 类
以下代码能正常运行,因为现在对可协变的 BeverageDispenser 来说,Juice 和 OrangeJuice 都是有效的自动售货机。
1 | juice_dispenser = BeverageDispenser(Juice()) |
但是,不接受售卖任何饮料的 Beverage 自动售货机:
1 | beverage_dispenser = BeverageDispenser(Beverage()) |
这就是协变,参数化自动售货机子类型关系的变化方向与类型参数子类型关系的变化方向相同。
一个逆变的垃圾桶
在看一个垃圾桶的建模例子:
- Refuse 是最一般的垃圾类型。所有垃圾都是废弃物
- Biodegradable 是特殊的垃圾类型,可被生物体降解
- Compostable 是特殊的可生物降解垃圾(Biodegradable),可在堆肥箱或堆肥设施中转化为有机肥料
1 | from typing import TypeVar, Generic |
- T_contra 表示逆变类型变量
- TrashCan 对废弃物的类型实行逆变
按照上述定义,以下类型的垃圾桶是可接受的:
1 | bio_can: TrashCan[Biodegradable] = TrashCan() |
更一般的 TrashCan[Refuse] 是可接受的,因为它可以存放任何废弃物,包括可生物降解的废弃物(Biodegradable),然而,TrashCan[Compostable] 不可接受,因为它不能存放可生物降解的废弃物(Biodegradable)。
1 | compost_can: TrashCan[Compostable] = TrashCan() |
型变总结
型变是一种难以描述的性质。下面我们会总结不变类型、协变类型和逆变类型等概念,并提供一些经验法则,用于推断型变种类。
不变类型
不管实参之间是否存在关系,当两个参数化类型之间不存在超类型或子类型关系时,泛型 L 是不变的。也就是说,如果 L 是不变的,那么 L[A] 就不是 L[B] 的超类型或子类型。两个方向都是不相容的。
Python 中的可变容器默认是不可变的。list 类型就是一例:list[int] 与 list[float] 不相容,反之亦然。
一般来说,如果一个形式类型参数既出现在方法参数的类型提示中,又出现在方法的返回值类型中,那么该参数必须是不可变的,因为要确保更新容器和从容器中读取时的类型安全性。
协变类型
即对于 A :> B(A 是 B 的超类型),当满足 C[A] :> C[B] 时,泛型 C 是可协变的。在前后两种情况中,:> 符号的方向是相同的,A 在 B 的左边。协变的泛型遵循具体类型参数的子类型关系。
不可变容器可以是协变的。例如,文档中使用约定的命名方式 T_co 指明 typing.FrozenSet 有一个可协变的类型变量。
1 | class FrozenSet(frozenset, AbstractSet[T_co]): |
使用 :> 表示参数化类型,如下所示。
1 | float :> int |
迭代器也可以是协变的。迭代器不是 frozenset 这种只读的容器,而是只产生输出。只要预期产出浮点数的 abc.Iterator[float],就可以放心使用产出整数的 abc.Iterator[int]。基于同样的原因,Callable 类型的返回值类型也可以是协变的。
逆变类型
对于 A :> B(A 是 B 的超类型),当满足 K[A] <: K[B] 时,泛型 K 是可逆变的。可逆变的泛型可以逆转具体类型参数的子类型关系。TrashCan 类就是一例:
1 | Refuse :> Biodegradable |
可逆变的容器通常是只写的数据结构(也叫“接收器”,sink)。标准库中没有这样的容器,不过一些类型有可逆变的类型参数。
Callable[[ParamType, ...], ReturnType] 中的参数类型是可逆变的,不过 ReturnType 是可协变的。另外,Generator、Coroutine 和 AsyncGenerator 都有一个可逆变的类型参数。
以上关于型变的讨论,主要是想告诉你,可逆变的形式参数可以定义用于调用或向对象发送数据的参数的类型,而可协变的形式参数可以定义对象产生的输出的类型——根据对象的不同,可以是产出值的类型或返回值的类型。
根据 输出可协变,输入可逆变 的结论,可以得出一些有用的指导方针:
- 如果一个形式类型参数定义的是从对象中获取的数据类型,那么该形式类型参数可能是协变的
- 如果一个形式类型参数定义的是对象初始化之后向对象中输入的数据类型,那么该形式类型参数可能是逆变的
- 如果一个形式类型参数定义的是从对象中获取的数据类型,同时也是向对象中输入的数据类型,那么该形式类型参数必定是不变的
- 为保险起见,形式类型参数最好是不变的
Callable[[ParamType, ...], ReturnType] 体现了第 1 条和第 2 条:ReturnType 是协变的,各个 ParamType 是逆变的。默认情况下,TypeVar 创建的形式参数是不变的,标准库中的可变容器都是这样注解的。
虽然协变和逆变是通过 TypeVar 声明(而不是在泛化类上进行声明),但是协变或逆变不是类型变量的性质,而是使用类型变量定义的泛化类的性质。
实现泛化静态协议
Python3.10 标准库提供了几个泛化静态协议。typing 模块中的 SupportsAbs 就是一个,实现方式如下所示:
1 | T_co = TypeVar('T_co', covariant=True) |
1 | import math |
- 由于
SupportsAbs的定义中有@runtime_checkable,因此issubclass(Vector2d, SupportsAbs)是一个有效的运行时检查 - int 类型也与 SupportsAbs 相容。根据 typeshed 项目,
int.__abs__返回一个 int 值,而 int 与 is_unit 函数中 v 参数声明的 float 类型参数相容
如下代码则实现了泛化的 RandomPicker 协议,pick 方法的返回值类型可协变:
1 | from typing import Protocol, runtime_checkable, TypeVar |
- 声明可协变的 T_co,使用可协变的形式类型参数泛化
RandomPicker - 使用 T_co 作为返回值类型
- 泛化的 RandomPicker 协议可以协变,因为在返回值类型中使用了唯一的形式参数
类型无底洞
使用类型检查工具,有时迫不得已要导入不需要知道的类,而除了编写类型提示外,这些类在代码中根本用不到。这些类没有文档记录,可能是因为包的作者认为它们是实现细节。仅仅为了编写一个类型提示,就要花几个小时寻找该导入的类,而且文档中没有线索。为此,直接写上 # type: ignore 就可以搞定(有时,这是唯一合算的方案)。