流畅的python--第八章 函数中的类型提示
关于渐进式类型
渐进式类型系统具有以下性质:
- 可选
默认情况下,类型检查工具不应对没有类型提示的代码发出警告。
当类型检查工具无法确定对象的类型时,会假定其为Any
类型。Any
类
型与其他所有类型兼容。 - 不在运行时捕获类型错误
类型提示相关的问题由静态类型检查工具、lint
程序和IDE
捕获。
在运行时不能阻止把不一致的值传给函数或分配给变量。 - 不能改善性能
类型注解提供的数据在理论上可以优化生成的字节码,但是据我所
知,截至 2021 年 7 月,任何Python
运行时都没有实现这种优化。
对渐进式类型来说,注解始终是可选的,这个性质最能体现可用性。
渐进式类型实践
接下来,我们逐步为一个简单的函数添加类型提示,使用 Mypy
检查,
实际体验一下渐进式类型系统。
示例8-1 messages.py
:没有注解的show_count
函数
Mypy
初体验
Mypy
严格来说是测试版软件,对messages.py
模块运行mypy
命令,开始做类型检查。
示例8-2 没有类型提示的messages_test.py
模块
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'
参数的默认值
示例8-1的show_count函数只对规则的名词有效。如果复数形式不是直接在后面添加's',则应该让用户提供复数形式,编辑 show_count
函数,添加可选的参数 plural
。
messages.py
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}'
messages_test.py
from messages import show_count
def test_irregular() -> None:
got = show_count(2, 'child', 'children')
assert got == '2 children'
mypy
测试一下:
使用None
表示默认值
在示例 8-3 中,为参数 plural
注解的类型是str
,默认值是 ''
,没有
类型冲突。这是最好的情况,不过有时使用 None
表示默认值则更好.
from typing import Optional
def show_count(count: int, singular: str, plural: Optional[str] = None) ->str:
Optional[str]
表示plural
的值可以是一个str
或None
- 必须显示地提供默认值,即
=None
如果不为plural
分配默认值,则Python
运行时将把它视作必需的参
数。记住,类型提示在运行时会被忽略。
类型由受支持的操作定义
类型是一系列值和一系列可操作这些值的函数。实践中,最好把受支持的操作当作类型的关键特征。
def double(x):
return x*2
x
参数可以是数值(int
、 complex
、 Fraction
、numpy.uint32
等),也可以是N
维numpy.array
,也可以是实现或继承参数为整数的__mul__
方法的其他类型。
from collections import abc
def double(x: abc.Sequence): # 带注解的double函数
return x*2 # 没有返回类型
- 鸭子类型
对象有类型,变量没有类型 - 名义类型
对象和变量都有类型。但是对象只存在于运行时,类型检查工具只关心使用类型提示注解变量(包括参数)的源码。
class Bird:
pass
class Duck(Bird): # Duck是Bird的子类
def quack(self):
print("Quack!")
def alert(birdie):# alert没有类型提示,因此类型检查工具会忽略它
birdie.quack()
def alert_duck(birdie: Duck)->None:# alert_duck接受一个类型为Duck的参数
birdie.quack()
def alert_bird(birdie:Bird)->None:# alert_bird接受一个类型为Bird的参数
birdie.quack()
使用Mypy
对birds.py
进行类型检查,报告一个问题。
仅仅分析源码,Mypy
就发现 alert_bird
有问题:类型提示声明的
birdie
参数是 Bird
类型,但是函数主体中调用了 birdie.quack()
,
而 Bird
类没有该方法。
示例8-5 daffy.py
from birds import *
daffy = Duck()
alert(daffy) # 有效调用,因为 alert 没有类型提示
alert_duck(daffy) # 有效调用,因为 alert_duck 接受的参数为 Duck 类型,而 daffy 是 Duck 对象。
alert_bird(daffy) # 有效调用,因为 alert_bird 接受的参数为 Bird 类型,而 daffy 也
# 是 Bird(Duck 的超类)对象。
运行Mypy
检查daffy.py
,报告的错误与在 birds.py
中定义的
alert_bird
函数内调用 quack
一样。
示例8-6 woody.py
from birds import *
woody = Bird()
alert(woody)
alert_duck(woody)
alert_bird(woody)
使用Mypy检查woody.py, 发现两处错误
第一个错误在 birds.py
中,前面已经见过,即在 alert_bird
中调用
birdie.quack()
。第二个错误在 woody.py
中,woody
是 Bird
实例,
调用 alert_duck(woody)
是无效的,因为 alert_duck
函数的参数类
型应为 Duck
。所有 Duck
都是 Bird
,但不是所有 Bird
都是 Duck
。
示例8-7 运行时错误和Mypy可能提供的帮助
- 错误1:Mypy不能检测到这个错误,因为alert没有类型提示
- 错误2:Mypy能报告这个问题:Argument 1 to "alert_duck" has
incompatible type "Bird"; expected "Duck" - 错误3:从示例 8-4 开始,Mypy 一直报告 alert_bird 函数的主体有问
题:"Bird" has no attribute "quack"。
注解中可用的类型
可用于注解的所有主要类型:
- typing.Any;
- 简单的类型和类;
- typing.Optional 和 typing.Union;
- 泛化容器,包括元组和映射;
- 抽象基类;
- 泛化可迭代对象;
- 参数化泛型和 TypeVar;
- typing.Protocols——静态鸭子类型的关键;
- typing.Callable;
- typing.NoReturn——就此打住比较好。
Any
类型
Any类型是渐进式类型系统的基础,是人们熟知的动态类型。下面是一个没有类型信息的函数:
def double(x):
return x * 2
在类型检查工具看来,假定其具有以下类型信息:
def double(x: Any) -> Any:
return x * 2
也就是说,x
参数和返回值可以是任何类型,二者甚至可以不同。Any
类型支持所有可能的操作。
以下述签名为例,对比一下Any
和Object
。
def double(x: object) -> object:
这个函数也接受每一种类型的参数,因为任何类型都是object的子类型。但是类型检查工具拒绝以下函数:
def double(x: object) -> object:
return x * 2
这是因为object
不支持__mul__
操作。Mypy
报告的错误如下所示。
简单的类型和类
像 int
、float
、str
和 bytes
这样的简单的类型可以直接在类型提示
中使用。标准库、外部包中的具体类,以及用户定义的具体类(例如
FrenchDeck
、Vector2d
和 Duck
),也可以在类型提示中使用。抽象基类在类型提示中也能用到。
🚩 内置类型
int
、float
和complex
之间没有名义上的子类型关
系,它们都是object
的直接子类。
Optional
类型和Union
类型
from typing import Optional
def show_count(count: int, singular: str, plural: Optional[str] = None) ->str:
使用它解决了默认值为 None
的问题。Optional[str]
结构其实是 Union[str, None]
的简写形式,表示plural
的类型可以是str
或 None
。
🚩 从
Python 3.10
开始,Union[str, bytes]
可以写成 ``str |bytes。这种写法输入的内容更少,也不用从
typing中导入
Optional或
Union`。
plural: Optional[str] = None # 旧句法
plural: str | None = None # 新句法
|
运算符还可用于构建 isinstance
和 issubclass
的第二个参数,例如 isinstance(x, int | str)
。
内置函数 ord
的签名就用到了 Union
,其接受 str
或 bytes
类型,并返回一个 int
。
def ord(c: Union[str, bytes]) -> int: ...
下面示例中的函数接受一个str
,但是可以返回一个str
或float
。
from typing import Union
def parse_token(token: str) -> Union[str, float]:
try:
return float(token)
except ValueError:
return token
尽量避免创建返回 Union
类型值的函数,因为这会给用户带来额外的
负担,迫使他们必须在运行时检查返回值的类型,判断该如何处理。但
是,在简单的表达式计算器中可以像上一段代码中的 parse_token
那
样做。
Union[]
至少需要两种类型。嵌套的 Union
类型与扁平的 Union
类型
效果相同。
Union[A, B, Union[C, D, E]]
与下面的类型提示作用一样。
Union[A, B, C, D, E]
Union
所含的类型之间不应相容。例如,Union[int, float]
就“画
蛇添足”了,因为 int
与 float
相容。仅使用 float
注解的参数也接受
int
值。
泛化容器
大多数Python
容器是异构的。例如,在一个 list
中可以混合存放不同
的类型。然而,实际使用中这么做没有什么意义。存入容器的对象往往
需要进一步处理,因此至少要有一个通用的方法。
泛型可以用类型参数来声明,以指定可以处理的项的类型。
示例8-8 带类型提示的tokenize
函数
def tokenize(text: str) -> list[str]:
return text.upper().split()
类型提示的意思是 tokenize
函数返回一
个 list
,而且各项均为 str
类型。
stuff: list
和 stuff: list[Any]
这两个注解的意思相同,都表示
stuff
是一个列表,而且列表中的项可以是任何类型的对象。
下面只列出了可使用最简单的泛化类型提
示形式的容器:
tuple
和映射类型支持更复杂的类型提示.
截至 Python 3.10
,没有什么好方法来注解带 typecode
构造函数参数
(决定数组中存放整数还是浮点数)的 array.array
。更棘手的问题
是,如何检查整数区间,防止在运行时向数组中添加元素而导致
OverflowError
。例如,使用 typecode='B'
创建的数组,只能存放 0
和 255
之间的int
值。目前,Python
的静态类型系统还未解决这个问
题。
示例8-10 带类型提示的tokenize
函数
from typing import List
def tokenize(text: str) -> List[str]:
return text.upper().split()
元组类型
元组类型的注解分3种形式说明:
-
用作记录的元组
元组用作记录时,使用内置类型tuple
注解,字段的类型在[]
内声明。例如:
('Shanghai', 24.28, 'China')
,类型提示为tuple[str, float, str]
假如有一个函数,其接受的参数是一对地理坐标,返回值是一个Geohash
。该函数的用法如下所示。
-
带有具名字段,用作记录的元组
如果想注解带有多个字段的元组,或者代码中多次用到的特定类型
的元组,强烈建议使用typing.NamedTuple
示例8-12coordinates_named.py
:具名元组Coordinates
和geohash
函数from typing import NamedTuple from geolib import geohash as gh # type: ignore PRECISION = 9 class Coordinate(NamedTuple): lat: float lon: float def geohash(lat_lon: Coordinate) -> str: return gh.encode(*lat_lon, PRECISION)
typing.NamedTuple
是tuple
子类的制造工厂,因
此Coordinate
与tuple[float, float]
相容,但是反过来不成
立,毕竟NamedTuple
为Coordinate
额外添加了方法 -
用作不可变序列的元组
如果想注解长度不定、用作不可变列表的元组,则只能指定一个类
型,后跟逗号和...
(Python
中的省略号,3
个点,不是Unicode
字符U+2026
,即HORIZONTAL ELLIPSIS
)
例如,tuple[int, ...]
表示项为int
类型的元组。省略号表示元素的数量≥ 1
。可变长度的元组不能为字段指定不同
的类型。stuff: tuple[Any, ...]
和stuff: tuple
这两个注解的意思
相同,都表示stuff
是一个元组,长度不定,可包含任意类型的对象。
下面的代码使用columnize
函数把一个序列转换成了元组列表
(类似于表格中的行和单元格),列表中的元组长度不定。最后,按列显示各项。
泛化映射
泛化映射类型使用 MappingType[KeyType, ValueType]
形式注解。在 Python 3.9
及以上版本中,内置类型 dict
及 collections
和
collections.abc
中的映射类型都可以这样注解。
示例8-14 charindex.py
name_index
函数的参数是起点和终点两个 Unicode
字符编码,会返回
一个名为 dict[str, set[str]]
的反向索引,把各个单词映射到名称
中含有该词的字符集合上。例如,对于 32
和 64
之间的 ASCII
字符,索
引之后,'SIGN'
和 'DIGIT'
两个词映射的字符集合如下所示。这里还
展示了如何搜索名为 'DIGIT EIGHT'
的字符。
几点说明:
- tokenize是一个生成器函数
- 注解局部变量
index
。如果不加类型提示,则Mypy
将发出提
示:Need type annotation for 'index' (hint: "index:
dict[, ] = ...")。 if
条件中使用了海象运算符:=
。这样做是为了把
unicodedata.name()
调用的结果赋值给name
,并把该结果作为整个
表达式的求解结果。如果结果是表示假值的''
,则不更新index
。
抽象基类
理想情况下,函数的参数应接受那些抽象类型,而不是具体类型,以下述函数签名为例。
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
。特别注意,使用 collections.UserDict
的子类无法
通过类型检查。
因此,一般来说在参数的类型提示中最好使用 abc.Mapping
或
abc.MutableMapping
,不要使用 dict
(也不要在遗留代码中使用
typing.Dict
)。如果 name2hex
函数无须改动传入的 color_map
,则
最准确的类型提示是 abc.Mapping
。如此一来,调用方就不用提供实
现了 setdefault
、pop
和 update
等方法的对象了,因为这些方法属
于 MutableMapping
接口,而不是 Mapping
接口。
Iterable
前文引用的 typing.List
文档推荐使用 Sequence
和 Iterable
注解
函数的参数。
示例8-15 replacer.py
示例 8-15 是另一个使用 Iterable
参数的例子,产生的项是tuple[str, str]
类型。
有两点说明:
FromTo
是类型别名(type alias
)。这里把tuple[str, str]
赋值
给了FromTo
,这样zip_replace
函数签名的可读性会好一些changes
的类型为Iterable[FromTo]
。这与Iterable[tuple[str, str]]
的效果一样,不过签名更短,可读性更高。
从Python3.10中显示使用TypeAlias
。从 Python 3.10 开始,创建类型别名的首选方式如下所
示。
from typing import TypeAlias
FromTo: TypeAlias = tuple[str, str]
参数化泛型和TypeVar
参数化泛型是一种泛型,写作 list[T]
,其中T
是类型变量,每次使用
时会绑定具体的类型。这样可在结果的类型中使用参数的类型。
示例8-16 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]
示例 8-16 定义的 sample
函数接受两个参数,一个是元素类型为 T
的
Sequence
,另一个是 int
。该函数会返回一个 list
,元素的类型也是
T
,具体类型由第一个参数决定。
在 sample
函数中使用类型变量达到的效果通过下面两种情况可以体现。
- 调用时如果传入
tuple[int, ...]
类型(与Sequence[int]
相
容)的元组,类型参数为int
,那么返回值类型为list[int]
。 - 调用时如果传入一个
str
(与Sequence[str]
相容),类型参数
为str
,那么返回值类型为list[str]
。
标准库中的statistics.mode
函数也是一例。该函数会返回一系列值
中出现次数最多的数据点.
mode
经常用于处理int
或float
值,但是Python
还有其他数值类型,
因此返回值类型最好与Iterable
中的元素类型保持一致。使用
TypeVar
可以改进签名。先来看一个看似简单,但是不正确的参数化签名。
静态协议
示例8-19 top
函数,有一个未定义的类型参数T
确实如此,Spam
对象构成的列表可以排序,因为 Spam
实现了支持 <
运
算符的特殊方法 __lt__
。
示例8-20 comparable.py:
定义协议类型SupportsLessThan
from typing import Protocol, Any
class SupportsLessThan(Protocol): # 协议是 typing.Protocol 的子类
def __lt__(self, other: Any) -> bool: ... # 在协议主体中定义一个或多个方法,方法的主体为 ...。
如果类型 T
实现了协议 P
定义的所有方法且类型签名匹配,那么 T
就与P
相容。
Callable
collections.abc
模块提供的 Callable
类型(尚未使用 Python 3.9
的用户在 typing
模块中寻找)用于注解回调参数或高阶函数返回的可
调用对象。Callable
类型可像下面这样参数化。
示例 8-24 说明型变
几点说明:
update
的参数是两个可调用对象。probe
必须是不接受参数并会返回一个float
值的可调用对象。display
接受一个float
参数并会返回None
probe_ok
与Callable[[], float]
相容,因为返回int
值对预期
float
值的代码没有影响display_wrong
不与Callable[[float], None]
相容,因为预期
int
参数的函数不一定能处理float
值。例如,Python
函数hex
接受
int
值,但是拒绝float
值。Mypy
对这一行报错,因为display_wrong
与update
函数的
display
参数的类型提示不相容display_ok
与Callable[[float], None]
相容,因为接受
complex
值的函数也能处理float
参数- 通过了
Mypy
的检查
综上所述,如果预期接受返回float
值的回调,则提供返回int
值的
回调是可以的,因为在预期float
值的地方都能使用int
值。
NoReturn
这个特殊类型仅用于注解绝不返回的函数的返回值类型。这类函数通常
会抛出异常。标准库中有很多这样的函数。例如,sys.exit()
会抛出 SystemExit
,终止 Python
进程。
注解仅限位置参数和变长参数
下面是带完整注解的 tag
函数签名。由于签名较长,因此按照格式化工
具 blue
的方式分成了几行。
from typing import Optional
def tag(
name: str,
/,
*content: str,
class_: Optional[str] = None,
**attrs: str,
) -> str:
注意任意个位置参数的类型提示 *content: str
,这表明这些参数必
须是 str
类型。在函数主体中,局部变量 content
的类型为
tuple[str, ...]
。
类型不完美,测试须全面
大型企业基准代码的维护人员反映,静态类型检查工具能发现很多
bug,而且这个阶段发现的bug
比上线运行之后发现的 bug
修复成本更
低。然而,有必要指出的是,早在引入静态类型之前,自动化测试就已
经是行业标准做法,我熟知的公司均已广泛采用。
虽然静态类型优势诸多,但是也不能保证绝对正确。静态类型很难发现
以下问题。
- 误报
代码中正确的类型被检查工具报告有错误。 - 漏报
代码中不正确的类型没有被检查工具报告有错误。
此外,如果对所有代码都做类型检查,那么我们将失去Python
的一些
表现力。 - 一些便利的功能无法做静态检查,比如像
config(**settings)
这种参数拆包。 - 一般来说,类型检查工具对特性
(property)
、描述符、元类和元
编程等高级功能的支持很差,或者根本无法理解。 - 类型检查工具跟不上
Python
版本的变化(有时落后不止一年),
可能拒绝使用语言新功能的代码,甚至崩溃。