0%

流畅的 Python 第 2 版(8):函数中的类型提示

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
2
3
4
5
6
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'
1
2
3
4
5
6
>>> show_count(1, "bird")
'1 bird'
>>> show_count(5, "bird")
'5 birds'
>>> show_count(0, "bird")
'no birds'

对该代码运行 mypy 静态类型检查工具,对于没有注解的函数签名,Mypy 默认忽略,除非另有配置。因此如下代码没有检查出错误:

1
2
3
4
pip install mypy

# mypy show_count.py
Success: no issues found in 1 source file

为这段代码可以添加 pytest 测试,保存为 show_count_test.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pytest import mark

from messages import show_count

@mark.parametrize('qty, expected', [
(1, '1 part'),
(2, '2 parts'),
])
def test_show_count(qty, expected):
got = show_count(qty, 'part')
assert got == expected

def test_show_count_zero():
got = show_count(0, 'part')
assert got == 'no parts'
1
2
3
4
5
6
7
8
9
# pip install pytest

# 运行 pytest 测试
# pytest -v
show_count_test.py::test_show_count[1-1 part] PASSED [ 33%]
show_count_test.py::test_show_count[2-2 parts] PASSED [ 66%]
show_count_test.py::test_show_count_zero PASSED [100%]

============================================ 3 passed in 0.01s ============================================

让 mypy 严格要求

指定命令行选项 --disallow-untyped-defs,Mypy 报告没有为参数和返回值添加类型提示的函数定义。

1
2
3
# mypy --disallow-untyped-defs show_count.py
show_count.py:1: error: Function is missing a type annotation [no-untyped-def]
Found 1 error in 1 file (checked 1 source file)

开始使用渐进式类型时,可以指定 --disallow-incomplete-defs 选项,此时不会有报错

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

现在,只在 messages.py 中添加返回类型:

1
def show_count(count, word) -> str:
1
2
3
# mypy --disallow-incomplete-defs show_count.py
show_count.py:1: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
Found 1 error in 1 file (checked 1 source file)

现在,可以依次为函数添加类型提示,不让 Mypy 再报告关于函数没有注解的错误。下面是带完整注解的签名,能让 Mypy 满意

1
def show_count(count: str, word: str) -> str:

Mypy 还支持以配置文件的形式进行配置,设置既可以针对全局,也可以针对单个模块。例如:

1
2
3
4
[mypy]
python_version = 3.9
warn_unused_configs = True
disallow_incomplete_defs = True

参数的默认值

如果参数有默认值,可以按照如下方式编写代码:

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,除参数默认值前面的 = 之外,其他 = 两侧不加空格

请使用 flake8blue 等工具。flake8 会报告代码风格等问题,blue 则会根据代码格式化工具 black 内置的(大多数)规则重写源码。在统一代码风格方面,blue 比 black 好,因为 blue 遵守 Python 自身的风格,默认使用单引号,将双引号作为备选。

使用 None 表示默认值

有时使用 None 表示默认值则更好。如果可选的参数是可变类型,那么 None 是唯一合理的默认值(避免可变默认对象带来的潜在代码 bug)。如果想把 plural 参数的默认值设为 None,则函数签名要改成下面这样:

1
2
3
from typing import Optional

def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:
  • Optional[str] 表示 plural 的值可以是一个 str 或 None
  • 必须显式地提供默认值,即这里的 = None

如果不为 plural 分配默认值,则 Python 运行时将把它视作必需的参数(虽然类型提示里有 Optional)。记住,类型提示在运行时会被忽略

Optional 并不是一个好名称,因为注解不能让参数变成可选的,分配默认值的参数才是可选的Optional[str] 的意思很简单,表明参数的类型可以是 strNoneType

需要从 typing 模块中导入 Optional。导入类型时,建议使用 from typing import X 句法,缩短函数签名的长度。

类型由受支持的操作定义

各种文献对类型概念的定义不一。这里,假定类型是一系列值和一系列可操作这些值的函数。实践中,最好把受支持的操作当作类型的关键特征

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

这里 x 可以是数值、序列,也可以是实现或继承参数为整数的 __mul__ 方法的其他类型。但是如下代码,mypy 将报错:

1
2
3
4
from collections import abc

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

因为抽象基类 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 实例并不重要,因为名义类型会在静态检查阶段检查

类型检查工具不运行程序的任何部分,只读取源码。名义类型比鸭子类型更严格,优点是能在构建流水线中,甚至是在 IDE 中输入代码的过程中更早地捕获一些 bug。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 的参数,mypy 检查 ok
  • alert_bird 接受一个类型为 Bird 的参数,mypy 会给出错误:类型提示声明的 birdie 参数是 Bird 类型,但是函数主体中调用了 birdie.quack(),而 Bird 类没有该方法(时刻牢记,静态检查工具不实际运行运行代码,只关注显式的类型声明)

但实际运行时,如下调用都是合法的,在运行时,Python 不关注声明的类型,仅使用鸭子类型。虽然 Mypy 报告 alert_bird 有一个错误,但是在运行时使用 daffy 调用完全没问题

1
2
3
4
5
6
from birds import *

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

如果用 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
2
3
4
5
6
from birds import *

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

根据里氏替换原则(LSP),子类继承了父类的所有操作,因此,在任何预期父类实例的地方都可以使用子类类实例。但是反之则不成立。子类可能实现了自己的专有方法,因此在预期子类实例的地方不一定能安全地使用父类实例。

这个小实验表明,鸭子类型更容易上手,也更灵活,但是无法阻止不受支持的操作在运行时导致错误。名义类型在运行代码之前检测错误,但有时会拒绝实际能运行的代码,例如上文上中 alsert_bird(duck)

类型提示的价值很难通过这种小示例体现出来。基准代码体量越大,好处体现得就越明显。

注解中可用的类型

Any 类型

Any 类型是渐进式类型系统的基础,是人们熟知的动态类型。对于如下没有类型信息的函数:

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

在类型检查工具看来,假定其具有以下类型信息:

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

也就是说,x 参数和返回值可以是任何类型,二者甚至可以不同。Any 类型支持所有可能的操作。对比如下代码:

1
2
def double(x: object) -> object:
return x * 2

虽然这个函数也接受每一种类型的参数,因为任何类型都是 object 的子类型,但是类型检查工具拒绝该代码,因为 object 不支持 __mul__ 操作。

越一般的类型,接口越狭窄,即支持的操作越少。但是,Any 是一种魔法类型,位于类型层次结构的顶部和底部。Any 既是最一般的类型(使用 n: Any 注解的参数可接受任何类型的值)​,也是最特定的类型(支持所有可能的操作)​。至少,在类型检查工具看来是这样。

当然,没有任何一种类型可以支持所有可能的操作,因此使用 Any 不利于类型检查工具完成核心任务,即检测潜在的非法操作,防止运行时异常导致程序崩溃

这里再介绍下渐进式类型系统中的相容(consistent-with)规则:

  • 对 T1 及其子类型 T2,T2 与 T1 相容(里氏替换)​
  • 任何类型都与 Any 相容:声明为 Any 类型的参数接受任何类型的对象
  • Any 与任何类型都相容:始终可以把 Any 类型的对象传给预期其他类型的参数

说白了,类型分析中所说的 推导 就是 推测Python 和其他语言的现代化类型检查工具不强求类型注解完整无缺,很多表达式的类型是可以推导出来的。例如,对 x = len(s) * 10 来说,类型检查工具不需要显式类型注解就知道 xint 类型,因为内置函数 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
2
plural: Optional[str] = None    # 旧句法
plural: str | None = None # 新句法

| 运算符还可用于构建 isinstanceissubclass 的第二个参数,例如 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
2
def tokenize(text: str) -> list[str]:
return text.upper().split()

stuff: liststuff: list[Any] 这两个注解的意思相同,都表示 stuff 是一个列表,而且列表中的项可以是任何类型的对象。

  • 对于 Python 3.7 和 Python 3.8,需要从 __future__ 中导入 annotations,才能在内置容器(例如list)后面使用 [​] 表示法
  • 对于 Python3.6 及之前版本,则要导入 from typing import List 并使用 List[str] 注解
  • 为了支持泛化类型提示,PEP 484 的作者在 typing 模块中创建了几十种泛型,例如 typing.Listtyping.Set

最终 typing 模块下这些冗余的泛型会被移除(可能会在 Python3.14)。

元组类型

元组类型的注解分 3 种形式说明:

  • 用作记录的元组:使用内置类型 tuple 注解,字段的类型在 [​] 内声明(对于 Python 3.9 以下的版本,要在类型提示中使用 typing.Tuple
  • 带有具名字段,用作记录的元组:如果想注解带有多个字段的元组,或者代码中多次用到的特定类型的元组,强烈建议使用 typing.NamedTuple
  • 用作不可变序列的元组:如果想注解长度不定、用作不可变列表的元组,则只能指定一个类型,后跟逗号和 ...
    • 例如,tuple[int, ...] 表示项为 int 类型的元组。省略号表示元素的数量 >=1可变长度的元组不能为字段指定不同的类型
    • stuff: tuple[Any, ...]stuff: tuple 这两个注解的意思相同,都表示 stuff 是一个元组,长度不定,可包含任意类型的对象
1
2
3
4
5
6
from geolib import geohash as gh #type: ignore

PRECISION = 9

def geohash(lat_lon: tuple[float, float]) -> str:
return gh.encode(*lat_lon, PRECISION)
1
2
3
4
5
from typing import NamedTuple

class Coordinate(NamedTuple):
lat: float
lon: float
1
2
3
4
5
6
7
8
9
10
from collections.abc import Sequence

def columnize(
sequence: Sequence[str], num_columns: int = 0
) -> list[tuple[str, ...]]:
if num_columns == 0:
num_columns = round(len(sequence) ** 0.5)
num_rows, reminder = divmod(len(sequence), num_columns)
num_rows += bool(reminder)
return [tuple(sequence[i::num_rows]) for i in range(num_rows)]
  • 通过 # type: ignore 忽略 mypy 报告 geolib 包没有类型提示
  • typing.NamedTuple 是 tuple 子类的制造工厂,因此 Coordinatetuple[float, float] 相容,但是反过来不成立:毕竟 NamedTuple 为 Coordinate 额外添加了方法(用户也可以自定义方法)

泛化映射

泛化映射类型使用 MappingType[KeyType, ValueType] 形式注解。

  • 在 Python3.9 及以上版本中,内置类型 dict 及 collections 和 collections.abc 中的映射类型都可以这样注解
  • 更早的版本必须使用 typing.Dict 和 typing 模块中的其他映射类型

当把 dict 用作记录时,一般来说,所有键都使用 str 类型,对应的值是什么类型则取决于键的含义。

抽象基类

根据发送时要保守,接收时要大方的法则,理想情况下,函数的参数应接受那些抽象类型,而不是具体类型。这样对调用方来说更加灵活。

1
2
3
from collections.abc import Mapping

def name2hex(name: str, color_map: Mapping[str, int]) -> str:
  • 由于注解的类型是 abc.Mapping,因此调用方可以提供 dictdefaultdictChainMap 的实例,UserDict 子类的实例,或者 Mapping 的任何子类型

因此,一般来说在参数的类型提示中最好使用 abc.Mappingabc.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
2
3
4
5
6
7
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
  • FromTo 是类型别名(type alias)。这里把 tuple[str, str] 赋值给了 FromTo,这样 zip_replace 函数签名的可读性会好一些
  • changes 的类型为 Iterable[FromTo]​。这与 Iterable[tuple[str, str]​] 的效果一样,不过签名更短,可读性更高

从 Python 3.10开始,创建类型别名的首选方式是使用 TypeAlias,它可以让创建类型别名的赋值操作更加明显,也让类型检查更加简单:

1
2
3
from typing import TypeAlias

FromTo: TypeAlias = tuple[str, str]

如果需要通过 len() 来计算长度的话,则使用 Iterable 则无法满足,此时可以使用 Sequence

Iterable 与 Sequence 一样,最适合注解参数的类型。用来注解返回值类型的话则太过含糊。函数的返回值类型应该具体、明确。

参数化泛型和 TypeVar

参数化泛型是一种泛型,写作 list[T]​,其中 T 是类型变量,每次使用时会绑定具体的类型,这样可在结果的类型中使用参数的类型

1
2
3
4
5
6
7
8
9
10
11
12
13
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 函数中使用 类型变量(TypeVar)达到的效果通过下面两种情况可以体现:

  • 调用时如果传入 tuple[int, ...]类型(与 Sequence[int] 相容)的元组,类型参数为 int,那么返回值类型为 list[int]​
  • 调用时如果传入一个 str(与 Sequence[str] 相容)​,类型参数为 str,那么返回值类型为 list[str]

TypeVar 还接受一些位置参数,以对类型参数施加限制,例如:

1
2
3
4
5
6
from decimal import Decimal
from fractions import Fraction

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

def mode(data: Iterable[NumberT]) -> NumberT:

只要 Iterable 中的元素是可哈希的,就能满足要求的话,可能会想这样定义:

1
2
3
from collections.abc import Hashable

def mode(data: Iterable[Hashable]) -> Hashable:

现在的问题是,返回的项是 Hashable 类型。Hashable 是一个抽象基类,只实现了 __hash__ 方法。因此,除了调用 hash(),类型检查工具不会允许对返回值做其他任何操作。所以,这么做没什么实际意义

解决方法是使用 TypeVar 的另一个可选参数,即关键字参数 bound。这个参数会为可接受的类型设定一个上边界。使用 bound=Hashable 指明,类型参数可以是 Hashable 或它的任何子类型:

1
2
3
4
5
6
7
8
from collections import Counter
from collections.abc import Iterable, Hashable
from typing import TypeVar

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

def mode(data: Iterable[HashableT]) -> HashableT:
......

总结一下:

  • 受限的类型变量会把类型设为 TypeVar 声明中列出的某个类型
  • 有界的类型变量会把类型设为根据表达式推导出的类型,但前提是推导的类型与 TypeVarbound= 关键字参数声明的边界相容

typing.TypeVar 构造函数还有两个可选参数,即 covariantcontravariant,后续文章会介绍。

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
2
3
def top(series: Iterable[T], length: int) -> list[T]:
ordered = sorted(series, reverse=True)
return ordered[:length]

该如何约束 T 呢?T 不能是 Any 或 object,因为 series 必须支持使用 sorted 函数排序。类型参数 T 应该被限定为实现了 __lt__ 的类型。之前对于需要实现了 __hash__ 的类型参数,可以把类型参数的上边界设为 typing.Hashable。但是,对目前遇到的问题,typing 或 abc 中没有合适的类型,需要自己创建

通过 Protocol 定义了一个新类型 SupportsLessThan。

1
2
3
4
from typing import Protocol, Any

class SupportsLessThan(Protocol):
def __lt__(self, other: Any) -> bool: ...
  • 协议是 typing.Protocol 的子类
  • 在协议主体中定义一个或多个方法,方法的主体为 ...
  • 如果类型 T 实现了协议 P 定义的所有方法且类型签名匹配,那么 T 就与 P 相容

现在,可以使用 SupportsLessThan 定义 top 函数:

1
2
3
4
5
6
7
8
9
10
from collections.abc import Iterable
from typing import TypeVar

from comparable import SupportsLessThan

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

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

如下代码是一段测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from collections.abc import Iterator
from typing import TYPE_CHECKING

import pytest
from top import top

def test_top_tuples() -> None:
fruit = 'mango pear apple kiwi banana'.split()
series: Iterator[tuple[int, str]] = (
(len(s), s) for s in fruit)
length = 3
expected = [(6, 'banana'), (5, 'mango'), (5, 'apple')]
result = top(series, length)
if TYPE_CHECKING:
reveal_type(series)
reveal_type(expected)
reveal_type(result)
assert result == expected

# 有意测试类型错误
def test_top_objects_error() -> None:
series = [object() for _ in range(4)]
if TYPE_CHECKING:
reveal_type(series)
with pytest.raises(TypeError) as excinfo:
top(series, 3)
assert "'<' not supported" in str(excinfo.value)
  • typing.TYPE_CHECKING 常量在运行时始终为 False,不过类型检查工具在做类型检查时会假装值为 True
  • reveal_type() 不能在运行时调用,因为它不是常规函数,而是 Mypy 提供的调试设施,因此无须使用import 导入。Mypy 每遇到一个伪函数调用 reveal_type() 就输出一个调试消息,显示参数的推导类型
1
2
3
4
5
6
7
8
9
10
# pytest test_top.py
=========================================== test session starts ===========================================
platform linux -- Python 3.10.12, pytest-8.4.2, pluggy-1.6.0
rootdir: /root/code/private/python/learn/fluent_python_v2/08
plugins: anyio-4.10.0
collected 2 items

test_top.py .. [100%]

============================================ 2 passed in 0.01s ============================================
1
2
3
4
5
6
7
# mypy test_top.py
test_top.py:15: note: Revealed type is "typing.Iterator[tuple[builtins.int, builtins.str]]"
test_top.py:16: note: Revealed type is "builtins.list[tuple[builtins.int, builtins.str]]"
test_top.py:17: note: Revealed type is "builtins.list[tuple[builtins.int, builtins.str]]"
test_top.py:24: note: Revealed type is "builtins.list[builtins.object]"
test_top.py:26: error: Value of type variable "LT" of "top" cannot be "object" [type-var]
Found 1 error in 1 file (checked 1 source file)

上述测试能通过,而我们的目的是使用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from collections.abc import Callable

def update(
probe: Callable[[], float],
display: Callable[[float], None]
) -> None:
temperature = probe()
display(temperature)

def probe_ok() -> int:
return 42

def display_wrong(temperature: int) -> None:
print(hex(temperature))

update(probe_ok, display_wrong)

def display_ok(temperature: complex) -> None:
print(temperature)

update(probe_ok, display_ok)
  • update 的参数是两个可调用对象
  • probe 必须是不接受参数并会返回一个 float 值的可调用对象
  • display 接受一个 float 参数并会返回 None
  • probe_okCallable[​[​], float] 相容,因为返回 int 值对预期 float 值的代码没有影响
  • display_wrong 不与 Callable[​[float], None] 相容,因为预期 int 参数的函数不一定能处理 float 值
  • display_okCallable[​[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
2
3
4
5
6
7
8
9
from typing import Optional

def tag(
name: str,
/,
*content: str,
class_: Optional[str] = None,
**attrs: str,
) -> str:
  • *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
2
3
4
from typing import Optional

def tag(__name: str, *content: str, class_: Optional[str] = None,
**attrs: str) -> str:

类型并不完美

大型企业基准代码的维护人员反映,静态类型检查工具能发现很多 bug,而且这个阶段发现的 bug 比上线运行之后发现的 bug 修复成本更低。虽然静态类型优势诸多,但是也不能保证绝对正确。静态类型很难发现以下问题。

  • 误报:代码中正确的类型被检查工具报告有错误
  • 漏报:代码中不正确的类型没有被检查工具报告有错误

此外,如果对所有代码都做类型检查,那么我们将失去 Python 的一些表现力。

  • 一些便利的功能无法做静态检查,比如像 config(**settings) 这种参数拆包
  • 一般来说,类型检查工具对特性 property、描述符、元类和元编程等高级功能的支持很差,或者根本无法理解
  • 类型检查工具跟不上 Python 版本的变化
  • 常见的数据约束在类型系统中无法表达,即使是简单的约束,通常,类型提示对捕获业务逻辑中的错误没有帮助

考虑到这些缺点,类型提示不能作为软件质量的保障支柱,而且盲目使用只会放大缺点。建议把静态类型检查工具纳入现代 CI 流水线,与测试运行程序、lint 程序等结合在一起使用。CI 流水线的目的是减少软件故障,自动化测试可以捕获许多超出类型提示能力范围的 bug。