PEP 484—Type Hints 为函数参数、返回值和变量的显式类型声明规定了句法和语义,目标是协助开发者工具通过静态分析发现 Python 基准代码中的 bug。但是,不是所有 Python 用户都能从类型提示中受益。因此,必须把这作为一种可选的功能。
应该强调的是,Python 仍是一门动态类型语言,作者并不意图强制使用类型提示,这只是一种约定。类型提示的主要受益者是使用 IDE(Integrated Development Environment,集成开发环境)和 CI(Continuous Integration,持续集成)的专业软件工程师。
关于渐进式类型
Python 3.5 中才出现类型提示。考虑到静态类型系统的局限性,PEP 484 只能引入一种渐进式类型系统(gradual type system)。其他语言也使用渐进式类型系统的,例如 Microsoft 的 TypeScript 等。
类型检查工具 Mypy 最初也是一门语言,是 Python 的一种方言,有自己的解释器,支持渐进式类型。后经 Guido van Rossum 的劝说,Mypy 的创建者 Jukka Lehtosalo 把它改造成了一个检查 Python 代码注解的工具。
渐进式类型系统具有以下性质:
- 是可选的:默认情况下,类型检查工具不应对没有类型提示的代码发出警告。当类型检查工具无法确定对象的类型时,会假定其为Any类型。Any 类型与其他所有类型兼容
- 不在运行时捕获类型错误:类型提示相关的问题由静态类型检查工具、lint 程序和 IDE 捕获。在运行时不能阻止把不一致的值传给函数或分配给变量
- 不能改善性能:类型注解提供的数据在理论上可以优化生成的字节码,但实际上任何 Python 运行时都没有实现这种优化
对渐进式类型来说,注解始终是可选的,这个性质最能体现可用性。在静态类型系统中,大多数类型约束很容易表达,但是也有许多类型约束很难表达,有些是不易表达,有些则根本表达不出来。类型提示有瑕疵就随它去吧,不影响产品发布。
类型提示在所有层面上均是可选的,一整个包都可以没有类型提示,即便有类型提示,导入模块时也可以让类型检查工具保持静默,另外还可以通过特殊的注释让类型检查工具忽略代码中指定的行。
100% 的类型提示覆盖率太过激进,只是一味追求指标,不现实,也有碍团队充分利用 Python 的强大功能和灵活性。应该坦然接受没有类型提示的代码,防止注解扰乱 API,增加实现难度。
渐进式类型实践
兼容 PEP 484 的 Python 类型检查工具很多,比如谷歌的 pytype、微软的 Pyright、Facebook 的 Pyre,以及 PyCharm 等IDE内置的类型检查器。这里我们选择最出名的 Mypy。
假设存在如下代码:
1 | def show_count(count, word): |
1 | show_count(1, "bird") |
对该代码运行 mypy 静态类型检查工具,对于没有注解的函数签名,Mypy 默认忽略,除非另有配置。因此如下代码没有检查出错误:
1 | pip install mypy |
为这段代码可以添加 pytest 测试,保存为 show_count_test.py
1 | from pytest import mark |
1 | # pip install pytest |
让 mypy 严格要求
指定命令行选项 --disallow-untyped-defs,Mypy 报告没有为参数和返回值添加类型提示的函数定义。
1 | # mypy --disallow-untyped-defs show_count.py |
开始使用渐进式类型时,可以指定 --disallow-incomplete-defs 选项,此时不会有报错
1 | # mypy --disallow-incomplete-defs show_count.py |
现在,只在 messages.py 中添加返回类型:
1 | def show_count(count, word) -> str: |
1 | # mypy --disallow-incomplete-defs show_count.py |
现在,可以依次为函数添加类型提示,不让 Mypy 再报告关于函数没有注解的错误。下面是带完整注解的签名,能让 Mypy 满意
1 | def show_count(count: str, word: str) -> str: |
Mypy 还支持以配置文件的形式进行配置,设置既可以针对全局,也可以针对单个模块。例如:
1 | [mypy] |
参数的默认值
如果参数有默认值,可以按照如下方式编写代码:
1 | def show_count(count: int, singular: str, plural: str = '') -> str: |
对于如下代码,有一个不明显的错误,color 参数的类型提示应该是 color: str。我写的 color=str 不是注解,而是把 color 的默认值设为 str 了
1 | def hex2rgb(color=str) -> tuple[int, int, int]: |
编写类型提示时建议遵守以下代码风格:
- 参数名称和
:之间不留空格,:后加一个空格 - 参数默认值前面的 = 两侧加空格
- 根据
PEP 8,除参数默认值前面的=之外,其他 = 两侧不加空格
请使用 flake8 和 blue 等工具。flake8 会报告代码风格等问题,blue 则会根据代码格式化工具 black 内置的(大多数)规则重写源码。在统一代码风格方面,blue 比 black 好,因为 blue 遵守 Python 自身的风格,默认使用单引号,将双引号作为备选。
使用 None 表示默认值
有时使用 None 表示默认值则更好。如果可选的参数是可变类型,那么 None 是唯一合理的默认值(避免可变默认对象带来的潜在代码 bug)。如果想把 plural 参数的默认值设为 None,则函数签名要改成下面这样:
1 | from typing import Optional |
Optional[str]表示 plural 的值可以是一个 str 或 None- 必须显式地提供默认值,即这里的
= None
如果不为 plural 分配默认值,则 Python 运行时将把它视作必需的参数(虽然类型提示里有 Optional)。记住,类型提示在运行时会被忽略。
Optional 并不是一个好名称,因为注解不能让参数变成可选的,分配默认值的参数才是可选的。Optional[str] 的意思很简单,表明参数的类型可以是 str 或 NoneType。
需要从 typing 模块中导入 Optional。导入类型时,建议使用 from typing import X 句法,缩短函数签名的长度。
类型由受支持的操作定义
各种文献对类型概念的定义不一。这里,假定类型是一系列值和一系列可操作这些值的函数。实践中,最好把受支持的操作当作类型的关键特征。
1 | def double(x): |
这里 x 可以是数值、序列,也可以是实现或继承参数为整数的 __mul__ 方法的其他类型。但是如下代码,mypy 将报错:
1 | from collections import abc |
因为抽象基类 Sequence 没有实现或继承__mul__方法。在运行时,这段代码既能成功处理 str、tuple、list、array 等具体的序列,也能处理数值,因为类型提示在运行时会被忽略。但是,类型检查工具只关注显式声明的类型,而 abc.Sequence 没有 __mul__ 方法。
在渐进式类型系统中,以下两种对类型的解读相互影响着彼此:
-
鸭子类型:该类型是 Smalltalk 以及 Python、JavaScript 和 Ruby 采用的解读视角。对象有类型,但是变量(包括参数)没有类型。在实践中,为对象声明的类型无关紧要,重要的是对象具体支持什么操作。根据定义,只有在运行时尝试操作对象时,才会施行鸭子类型相关的检查。这比名义类型(nominal typing)更灵活,但代价是运行时潜在的错误更多
- 如果能调用
birdie.quack(),那么在当前上下文中 birdie 就是鸭子
- 如果能调用
-
名义类型:该类型是 C++、Java 和 C#采用的解读视角,带注解的 Python 支持这种类型。对象和变量都有类型。但是,对象只存在于运行时,类型检查工具只关心使用类型提示注解变量(包括参数)的源码
- 如果 Duck 是 Bird 的子类,那么就可以把 Duck 实例赋值给注解为
birdie: Bird的参数 - 可是在函数主体中,类型检查工具认为
birdie.quack()调用是非法的,因为 birdie 名义上是 Bird 对象,而该类没有提供.quack()方法 - 在运行时,实参是不是 Duck 实例并不重要,因为名义类型会在静态检查阶段检查
- 如果 Duck 是 Bird 的子类,那么就可以把 Duck 实例赋值给注解为
类型检查工具不运行程序的任何部分,只读取源码。名义类型比鸭子类型更严格,优点是能在构建流水线中,甚至是在 IDE 中输入代码的过程中更早地捕获一些 bug。
1 | class Bird: |
- Duck 类型继承自 Bird 类型
- alert 没有类型提示,因此类型检查工具会忽略它
- alert_duck 接受一个类型为 Duck 的参数,mypy 检查 ok
- alert_bird 接受一个类型为 Bird 的参数,mypy 会给出错误:类型提示声明的 birdie 参数是 Bird 类型,但是函数主体中调用了
birdie.quack(),而 Bird 类没有该方法(时刻牢记,静态检查工具不实际运行运行代码,只关注显式的类型声明)
但实际运行时,如下调用都是合法的,在运行时,Python 不关注声明的类型,仅使用鸭子类型。虽然 Mypy 报告 alert_bird 有一个错误,但是在运行时使用 daffy 调用完全没问题。
1 | from birds import * |
如果用 mypy 检查这段调用代码(忽略 birds 模块里的上述错误),本身是能正常通过的。这就说明,将 daffy 赋值给 alert_bird 的参数时,是能通过 mypy 的检查的:alert_bird 期待一个 Bird 类型的参数,但实际传入的是 Duck 类型的参数,由于 duck 类型继承自 bird 类型,因此可以 mypy 认为可以安全赋值,因此这个调用本身是不会报错。
但是如下的调用,mypy 则会报错,不能将 woody 赋值给 alert_duck 的参数。因为 alert_duck 函数的参数类型应为 Duck。所有 Duck 都是 Bird,但不是所有 Bird 都是 Duck。
1 | from birds import * |
根据里氏替换原则(LSP),子类继承了父类的所有操作,因此,在任何预期父类实例的地方都可以使用子类类实例。但是反之则不成立。子类可能实现了自己的专有方法,因此在预期子类实例的地方不一定能安全地使用父类实例。
这个小实验表明,鸭子类型更容易上手,也更灵活,但是无法阻止不受支持的操作在运行时导致错误。名义类型在运行代码之前检测错误,但有时会拒绝实际能运行的代码,例如上文上中 alsert_bird(duck)。
类型提示的价值很难通过这种小示例体现出来。基准代码体量越大,好处体现得就越明显。
注解中可用的类型
Any 类型
Any 类型是渐进式类型系统的基础,是人们熟知的动态类型。对于如下没有类型信息的函数:
1 | def double(x): |
在类型检查工具看来,假定其具有以下类型信息:
1 | def double(x: Any) -> Any: |
也就是说,x 参数和返回值可以是任何类型,二者甚至可以不同。Any 类型支持所有可能的操作。对比如下代码:
1 | def double(x: object) -> object: |
虽然这个函数也接受每一种类型的参数,因为任何类型都是 object 的子类型,但是类型检查工具拒绝该代码,因为 object 不支持 __mul__ 操作。
越一般的类型,接口越狭窄,即支持的操作越少。但是,Any 是一种魔法类型,位于类型层次结构的顶部和底部。Any 既是最一般的类型(使用 n: Any 注解的参数可接受任何类型的值),也是最特定的类型(支持所有可能的操作)。至少,在类型检查工具看来是这样。
当然,没有任何一种类型可以支持所有可能的操作,因此使用 Any 不利于类型检查工具完成核心任务,即检测潜在的非法操作,防止运行时异常导致程序崩溃。
这里再介绍下渐进式类型系统中的相容(consistent-with)规则:
- 对 T1 及其子类型 T2,T2 与 T1 相容(里氏替换)
- 任何类型都与 Any 相容:声明为 Any 类型的参数接受任何类型的对象
- Any 与任何类型都相容:始终可以把 Any 类型的对象传给预期其他类型的参数
说白了,类型分析中所说的 推导 就是 推测。Python 和其他语言的现代化类型检查工具不强求类型注解完整无缺,很多表达式的类型是可以推导出来的。例如,对 x = len(s) * 10 来说,类型检查工具不需要显式类型注解就知道 x 是 int 类型,因为内置函数 len 的类型提示是已知的。
简单的类型和类
像 int、float、str 和 bytes 这样的简单的类型可以直接在类型提示中使用。标准库、外部包中的具体类,以及用户定义的具体类也可以在类型提示中使用。抽象基类在类型提示中也能用到。
- 对类来说,相容的定义与子类型相似:子类与所有超类相容
- 虽然内置类型 int、float和 complex 之间没有名义上的子类型关系,但是 int 与 float 相容,float与 complex 相容。从实用角度来看,这是合理的(int 实现了 float 的所有操作)
Optional 类型和 Union 类型
上文已经介绍过 Optional 了,Optional[str] 其实就是 Union[str, None] 的简写形式,表示类型可以是 str 或者 None。从 Python3.10 开始,Union[str, bytes] 可以写成 str | bytes。这种写法输入的内容更少,也不用从 typing 中导入 Optional 或 Union。
1 | plural: Optional[str] = None # 旧句法 |
| 运算符还可用于构建 isinstance 和 issubclass 的第二个参数,例如 isinstance(x, int |str)。
关于 Union 类型,有一些最佳实践:
- 尽量避免创建返回 Union 类型值的函数,因为这会给用户带来额外的负担,迫使他们必须在运行时检查返回值的类型,判断该如何处理。
- 对于返回值类型由输入值类型决定,不适合使用 Union。为了正确注解这样的函数,需要使用类型变量或重载
Union[] 至少需要两种类型,而且 Union 所含的类型之间不应相容。嵌套的 Union 类型与扁平的 Union 类型效果相同。因此,下面的类型提示:
1 | Union[A, B, Union[C, D, E]] |
等同于:
1 | Union[A, B, C, D, E] |
泛化容器
大多数 Python 容器是异构的。例如,在一个 list 中可以混合存放不同的类型。然而,实际使用中这么做没有什么意义。存入容器的对象往往需要进一步处理,因此至少要有一个通用的方法。泛型可以用类型参数来声明,以指定可以处理的项的类型。
如下代码参数化一个 list,约束元素的类型:
1 | def tokenize(text: str) -> list[str]: |
stuff: list 和 stuff: list[Any] 这两个注解的意思相同,都表示 stuff 是一个列表,而且列表中的项可以是任何类型的对象。
- 对于 Python 3.7 和 Python 3.8,需要从
__future__中导入 annotations,才能在内置容器(例如list)后面使用[]表示法 - 对于 Python3.6 及之前版本,则要导入
from typing import List并使用List[str]注解 - 为了支持泛化类型提示,PEP 484 的作者在 typing 模块中创建了几十种泛型,例如
typing.List、typing.Set等
最终 typing 模块下这些冗余的泛型会被移除(可能会在 Python3.14)。
元组类型
元组类型的注解分 3 种形式说明:
- 用作记录的元组:使用内置类型 tuple 注解,字段的类型在
[]内声明(对于 Python 3.9 以下的版本,要在类型提示中使用typing.Tuple) - 带有具名字段,用作记录的元组:如果想注解带有多个字段的元组,或者代码中多次用到的特定类型的元组,强烈建议使用
typing.NamedTuple - 用作不可变序列的元组:如果想注解长度不定、用作不可变列表的元组,则只能指定一个类型,后跟逗号和
...:- 例如,
tuple[int, ...]表示项为 int 类型的元组。省略号表示元素的数量>=1。可变长度的元组不能为字段指定不同的类型 stuff: tuple[Any, ...]和stuff: tuple这两个注解的意思相同,都表示 stuff 是一个元组,长度不定,可包含任意类型的对象
- 例如,
1 | from geolib import geohash as gh #type: ignore |
1 | from typing import NamedTuple |
1 | from collections.abc import Sequence |
- 通过
# type: ignore忽略 mypy 报告 geolib 包没有类型提示 typing.NamedTuple是 tuple 子类的制造工厂,因此Coordinate与tuple[float, float]相容,但是反过来不成立:毕竟 NamedTuple 为 Coordinate 额外添加了方法(用户也可以自定义方法)
泛化映射
泛化映射类型使用 MappingType[KeyType, ValueType] 形式注解。
- 在 Python3.9 及以上版本中,内置类型 dict 及 collections 和 collections.abc 中的映射类型都可以这样注解
- 更早的版本必须使用
typing.Dict和 typing 模块中的其他映射类型
当把 dict 用作记录时,一般来说,所有键都使用 str 类型,对应的值是什么类型则取决于键的含义。
抽象基类
根据发送时要保守,接收时要大方的法则,理想情况下,函数的参数应接受那些抽象类型,而不是具体类型。这样对调用方来说更加灵活。
1 | from collections.abc import Mapping |
- 由于注解的类型是
abc.Mapping,因此调用方可以提供dict、defaultdict和ChainMap的实例,UserDict 子类的实例,或者 Mapping 的任何子类型
因此,一般来说在参数的类型提示中最好使用 abc.Mapping 或 abc.MutableMapping,不要使用 dict(也不要在遗留代码中使用 typing.Dict)。根据发送时要保守,函数的返回值始终应该是一个具体对象,即返回值的类型提示应当是具体类型。
- 泛化版 list,可用于注解返回值类型。如果想注解参数,推荐使用抽象容器类型,例如 Sequence 或Iterable
- typing.Dict 和 typing.Set 的文档也有类似的说明
- 从 Python3.9 开始,collections.abc 中的大多数抽象基类和 collections 中的具体类,以及内置的容器,全都支持泛化类型提示
- 使用 Python3.8 或之前的版本编写代码时才需要使用 typing 模块中对应的容器类型
Iterable
标准库中的 math.fsum 函数,其参数的类型提示用的就是 Iterable。截至 Python3.10,标准库不含注解,但是 Mypy、PyCharm 等可在 Typeshed 项目中找到所需的类型提示。**这些类型提示位于一种存根文件(stub file)中,这是一种特殊的源文件,扩展名为 .pyi,文件中保存带注解的函数和方法签名,没有实现(有点儿类似于 C 语言的头文件)。
如下是是一个使用 Iterable 的例子:
1 | from collections.abc import Iterable |
- FromTo 是类型别名(type alias)。这里把
tuple[str, str]赋值给了 FromTo,这样zip_replace函数签名的可读性会好一些 - changes 的类型为
Iterable[FromTo]。这与Iterable[tuple[str, str]]的效果一样,不过签名更短,可读性更高
从 Python 3.10开始,创建类型别名的首选方式是使用 TypeAlias,它可以让创建类型别名的赋值操作更加明显,也让类型检查更加简单:
1 | from typing import TypeAlias |
如果需要通过 len() 来计算长度的话,则使用 Iterable 则无法满足,此时可以使用 Sequence。
Iterable 与 Sequence 一样,最适合注解参数的类型。用来注解返回值类型的话则太过含糊。函数的返回值类型应该具体、明确。
参数化泛型和 TypeVar
参数化泛型是一种泛型,写作 list[T],其中 T 是类型变量,每次使用时会绑定具体的类型,这样可在结果的类型中使用参数的类型。
1 | from collections.abc import Sequence |
在 sample 函数中使用 类型变量(TypeVar)达到的效果通过下面两种情况可以体现:
- 调用时如果传入
tuple[int, ...]类型(与Sequence[int]相容)的元组,类型参数为 int,那么返回值类型为 list[int] - 调用时如果传入一个 str(与
Sequence[str]相容),类型参数为 str,那么返回值类型为list[str]
TypeVar 还接受一些位置参数,以对类型参数施加限制,例如:
1 | from decimal import Decimal |
只要 Iterable 中的元素是可哈希的,就能满足要求的话,可能会想这样定义:
1 | from collections.abc import Hashable |
现在的问题是,返回的项是 Hashable 类型。Hashable 是一个抽象基类,只实现了 __hash__ 方法。因此,除了调用 hash(),类型检查工具不会允许对返回值做其他任何操作。所以,这么做没什么实际意义。
解决方法是使用 TypeVar 的另一个可选参数,即关键字参数 bound。这个参数会为可接受的类型设定一个上边界。使用 bound=Hashable 指明,类型参数可以是 Hashable 或它的任何子类型:
1 | from collections import Counter |
总结一下:
- 受限的类型变量会把类型设为
TypeVar声明中列出的某个类型 - 有界的类型变量会把类型设为根据表达式推导出的类型,但前提是推导的类型与
TypeVar的bound=关键字参数声明的边界相容
typing.TypeVar 构造函数还有两个可选参数,即 covariant 和 contravariant,后续文章会介绍。
typing 模块提供了一个预定义的类型变量,名为 AnyStr。这个类型变量的定义如下所示。
1 | AnyStr = TypeVar('AnyStr', bytes, str) |
很多接受 bytes 或 str 的函数会使用 AnyStr,返回值也是二者之一。
静态协议
对类型提示来说,协议指的是 typing.Protocol 的子类,定义接口供类型检查工具核查。PEP 544—Protocols: Structural subtyping (static duck typing) 提出的 Protocol 类型类似于 Go 语言中的接口:定义协议类型时指定一个或多个方法,在需要使用协议类型的地方,类型检查工具会核查有没有实现指定的方法。
在 Python 中,协议通过 typing.Protocol 的子类定义。然而,实现协议的类不会与定义协议的类建立任何关系,不继承,也不用注册。类型检查工具负责查找可用的协议类型,施行用法检查。
对于如下函数,返回可迭代对象 it 中排位靠前的 n 个元素:
1 | def top(series: Iterable[T], length: int) -> list[T]: |
该如何约束 T 呢?T 不能是 Any 或 object,因为 series 必须支持使用 sorted 函数排序。类型参数 T 应该被限定为实现了 __lt__ 的类型。之前对于需要实现了 __hash__ 的类型参数,可以把类型参数的上边界设为 typing.Hashable。但是,对目前遇到的问题,typing 或 abc 中没有合适的类型,需要自己创建。
通过 Protocol 定义了一个新类型 SupportsLessThan。
1 | from typing import Protocol, Any |
- 协议是
typing.Protocol的子类 - 在协议主体中定义一个或多个方法,方法的主体为
... - 如果类型 T 实现了协议 P 定义的所有方法且类型签名匹配,那么 T 就与 P 相容
现在,可以使用 SupportsLessThan 定义 top 函数:
1 | from collections.abc import Iterable |
如下代码是一段测试代码:
1 | from collections.abc import Iterator |
typing.TYPE_CHECKING常量在运行时始终为 False,不过类型检查工具在做类型检查时会假装值为 Truereveal_type()不能在运行时调用,因为它不是常规函数,而是 Mypy 提供的调试设施,因此无须使用import 导入。Mypy 每遇到一个伪函数调用reveal_type()就输出一个调试消息,显示参数的推导类型
1 | # pytest test_top.py |
1 | # mypy test_top.py |
上述测试能通过,而我们的目的是使用 Mypy 检查测试文件,确认 TypeVar 的声明是正确的。可以看到,对于没有实现 __lt__ 的对象,Mypy 会报类型错误。
可以这样认为,静态协议为使用 Python 鸭子类型的代码提供了静态类型检查的方案。
- 与抽象基类相比,
协议类型的关键优势在于,类型无须做任何特殊的声明就可以与协议类型相容 - 如此一来,协议可以利用现有的类型或者不受我们控制的代码实现的类型创建
- 而与单纯的鸭子类型相比,使用
协议类型后,类型检查工具仍能发挥作用,例如SupportsLessThan是显式定义的协议,这与单纯的鸭子类型正好相反,鸭子类型隐含的协议对类型检查工具是不可见的
特殊的类 Protocol 由 PEP 544—Protocols: Structural subtyping (static duck typing) 引入,上面的例子展示了这个功能为什么属于 静态鸭子类型 范畴:top 函数的 series 参数采用的注解方式想表达的意思是:series 的名义类型无关紧要,只要实现 __lt__ 方法即可。单纯的 Python 的鸭子类型并不阻止我们隐含这层意思,只是静态类型检查工具无从知晓。
而通过静态鸭子类型,可以让静态类型检查工具明确知晓鸭子类型的深意。typing.Protocol 实现的是静态鸭子类型。
Callable
collections.abc 模块提供的Callable类型(尚未使用 Python3.9 的用户在typing模块中寻找)用于注解回调参数或高阶函数返回的可调用对象。Callable 类型可像下面这样参数化:
1 | Callable[[ParamType1, ParamType2], returnType] |
- 参数列表,即这里的
[ParamType1, ParamType2],可以包含零或多个类型
可选或关键字参数类型没有专门的注解句法。正如 typing.Callable 文档所述:“这种函数类型很少用作回调类型。如果想让类型提示匹配的签名灵活一些,可以把整个参数列表替换成 ...:
1 | Callable[..., ReturnType] |
泛化类型参数与类型层次结构的交互引入了一个新类型概念:型变(variance)。
1 | from collections.abc import Callable |
- update 的参数是两个可调用对象
- probe 必须是不接受参数并会返回一个 float 值的可调用对象
- display 接受一个 float 参数并会返回 None
probe_ok与Callable[[], float]相容,因为返回 int 值对预期 float 值的代码没有影响display_wrong不与Callable[[float], None]相容,因为预期 int 参数的函数不一定能处理 float 值display_ok与Callable[[float], None]相容,因为接受 complex 值的函数也能处理 float 参数
正式地说,Callable[[], int] 是 Callable[[], float] 的子类型,因为 int 是 float 的子类型。这意味着,那个 Callable 的返回值类型经历了协变(covariant),因为 int 和 float 之间具有子类型关系,而且变化方向与 Callable 类型中返回值的类型变化方向相同。
反过来,如果回调预期处理 float 值,却提供接受 int 参数的回调,则会导致类型错误。正式地说, Callable[[int], None] 不是 Callable[[float], None] 的子类型,虽然 int 是 float 的子类型,但是在参数化 Callable 类型中,关系是相反的,即 Callable[[float],None] 是 Callable[[int], None] 的子类型。因此我们说,那个 Callable 声明的参数类型经历了逆变(contravariant)。
目前,可以认为大多数参数化泛型是不变的,这样更容易理解:
- 如果声明
scores: list[float],就只能把list[float]值赋给 scores,不能使用声明为list[int]或list[complex]的对象 - 不接受
list[int]对象的原因是,scores 中不能存放 float 值,而代码可能需要这么做 - 不接受
list[complex]对象的原因是,代码可能需要排序 scores,找出中位数,而complex未提供__lt__,所以list[complex]不可排序
NoReturn
这个特殊类型仅用于注解绝不返回的函数的返回值类型。这类函数通常会抛出异常。标准库中有很多这样的函数。
1 | def exit(__status: object = ...) -> NoReturn: ... |
- 参数
__status: object = ...的注解表示该参数具有默认,但具体值未知或不重要。__status的类型是 object,因此也可以是 None,所以没必要注解为Optional[object] - NoReturn 返回值类型注解则表示函数绝不返回
注解仅限位置参数和变长参数
如下是一个对包含仅限位置参数、变长参数函数的注解:
1 | from typing import Optional |
*content: str,这表明这些参数必须是 str 类型。在函数主体中,局部变量 content 的类型为tuple[str, ...]。- 任意个关键字参数的类型提示是
**attrs: str,因此在函数主体中,attrs 的类型为dict[str, str],如果类型提示是**attrs: float,那么在函数主体中,attrs 的类型为dict[str, float] - 如果 attrs 参数接受不同类型的值,则需要使用
Union[]或Any,注解为**attrs: Any
针对仅限位置参数的 / 表示法只可在 Python3.8 及以上版本中使用。在 Python3.7 或以下版本中,这会导致句法错误。PEP 484 约定,在仅限位置参数的名称前加两个下划线。例如:
1 | from typing import Optional |
类型并不完美
大型企业基准代码的维护人员反映,静态类型检查工具能发现很多 bug,而且这个阶段发现的 bug 比上线运行之后发现的 bug 修复成本更低。虽然静态类型优势诸多,但是也不能保证绝对正确。静态类型很难发现以下问题。
- 误报:代码中正确的类型被检查工具报告有错误
- 漏报:代码中不正确的类型没有被检查工具报告有错误
此外,如果对所有代码都做类型检查,那么我们将失去 Python 的一些表现力。
- 一些便利的功能无法做静态检查,比如像
config(**settings)这种参数拆包 - 一般来说,类型检查工具对特性
property、描述符、元类和元编程等高级功能的支持很差,或者根本无法理解 - 类型检查工具跟不上 Python 版本的变化
- 常见的数据约束在类型系统中无法表达,即使是简单的约束,通常,类型提示对捕获业务逻辑中的错误没有帮助
考虑到这些缺点,类型提示不能作为软件质量的保障支柱,而且盲目使用只会放大缺点。建议把静态类型检查工具纳入现代 CI 流水线,与测试运行程序、lint 程序等结合在一起使用。CI 流水线的目的是减少软件故障,自动化测试可以捕获许多超出类型提示能力范围的 bug。