一、重载的签名
、 Python 函数可以接受不同的参数组合。这些不同的参数组合使用 @typing.overload
装饰器注解。如果函数的返回值类型取决于两个或以上参数的类型,那么这个功能就十分必要。
以内置函数 sum
为例。help(sum)
输出的内容如下所示。
在常规的 Python 模块中也可以使用 @overload
,重载的签名放在函数具体的签名和实现前面。在一个 Python 模块中,sum
函数的注解和实现方式如下例所示
import functools
import operator
from collections.abc import Iterable
from typing import overload, Union, TypeVar
T = TypeVar('T')
# 第二个重载的签名需要这个类型变量
S = TypeVar('S')
@overload
def sum(it: Iterable[T]) -> Union[T, int]:
# 这个签名针对简单的情况,即 sum(my_iterable)。结果的类型既可能是 T
pass
@overload
def sum(it: Iterable[T], /, start: S) -> Union[T, S]:
# 提供的 start 参数可以是任何类型 S,因此结果的类型是 Union[T, S]。这就是需要 S 的原因。如果继续使用 T,那么 start 的类型就要与 Iterable[T] 的元素类型相同
pass
def sum(it, /, start=0): # 函数具体实现中的签名没有类型提示
return functools.reduce(operator.add, it, start)
1.1 重载max函数
利用 Python 强大动态功能的函数往往难以添加类型提示。
max 函数的文档以下面这句话开头: 返回一个可迭代对象中最大的项,或者两个或以上参数中最大的那一个。
在我看来,这句话再明白不过了。 但是,如果按照这样的说法注解函数,那我不禁有如下疑问:注解什么?是一个可迭代对象还是两个或以 上参数?
现实情况更加复杂,因为 max 函数还接受两个可选的关键字参数,即 key 和 default。
为了让你看清 max 函数的工作机制与重载的注解之间的关系,我用 Python 重新实现了 max 函数;代码如下
MISSING = object()
EMPTY_MSG = 'max() arg is an empty sequence'
# 省略重载的类型提示,详见示例15-3
def max(first, *args, key=None, default=MISSING):
if args:
series = args
candidate = first
else:
series = iter(first)
try:
candidate = next(series)
except StopIteration:
if default is not MISSING:
return default
raise ValueError(EMPTY_MSG) from None
if key is None:
for current in series:
if candidate < current:
candidate = current
else:
candidate_key = key(candidate)
for current in series:
current_key = key(current)
if candidate_key < current_key:
candidate = current
candidate_key = current_key
return candidate
这个示例的关注点不是 max 函数的逻辑,因此我不会过多解释具体实现。这里的重点是 MISSING 常量。 该常量的值是一个独特的 object 实例,用作哨符。MISSING 是 default=
关键字参数的默认值,让 max 函数接受 default=None
,而且区分以下两种情况。
-
用户没有为
default=
提供值,也就是缺失该参数。此时,如果 first 是一个空可迭代对象,那么 max 函数就会抛出 ValueError。 -
用户为
default=
提供了值,包括 None。此时,如果 first 是一个空可迭代对象,那么 max 函数就 会返回提供的值。
二、 TypedDict
处理动态数据结构(例如 JSON API 的响应)时容易误用 TypedDict 来避免错误。通过本节 的示例,你会发现,必须在运行时才能正确处理 JSON,不能依靠静态类型检查。在运行时使用类型提 示检查 JSON 等结构时,可以借助 PyPI 中的 pydantic 包。
Python 字典有时被当作记录使用,以键表示字段名称,字段的值可以是不同的类型。
{"isbn": "0134757599",
"title": "Refactoring, 2e",
"authors": ["Martin Fowler", "Kent Beck"],
"pagecount": 478}
在 Python 3.8 之前,没有什么好方法可以注解这样的记录,因为映射类型中的所有值必须是同种类型 。
对于上述 JSON 对象,下面两个注解都不完美。
Dict[str, Any]
值可以是任何类型。Dict[str, Union[str, int, List[str]]]
难以理解,而且没有体现字段名称与对应的字段类型之间的关系:title
的值应为一个str
,不能是int
或List[str]
。
from typing import TypedDict
class BookDict(TypedDict):
isbn: str
title: str
authors: list[str]
pagecount: int
乍一看,typing.TypedDict
好像是一个数据类构建器,类似于typing.NamedTuple
。
这是句法类似引起的误会。**TypedDict**
与数据类构建器千差万别。**TypedDict**
仅为类型检查工具而生, 在运行时没有作用。
TypedDict 有以下两个作用。
-
使用与类相似的句法注解字典,为各个“字段”的值提供类型提示。
-
通过一个构造函数告诉类型检查工具,字典应具有指定的键和指定类型的值。
在运行时,TypedDict 构造函数(例如 BookDict)相当于一种安慰剂,其实作用与使用同样的参数调用 dict 构造函数相同。
**BookDict**
创建的是普通字典,这也就意味着: -
伪类声明中的“字段”不创建实例属性;
-
不能通过初始化方法为“字段”指定默认值;
-
不允许定义方法。
from typing import TypedDict
class BookDict(TypedDict):
isbn: str
title: str
authors: list[str]
pagecount: int
if __name__ == "__main__":
# 可以像 dict 构造函数那样调用 BookDict,传入关键字参数;也可以传入一个包含字典字面量的字典参数。
pp = BookDict(title='Programming Pearls', authors='Jon Bentley', isbn='0201657880', pagecount=256)
print(pp) # {'title': 'Programming Pearls', 'authors': 'Jon Bentley', 'isbn': '0201657880', 'pagecount': 256}
print(type(pp)) # <class 'dict'>
# …因此,不能使用 object.field 表示法读取数据。
# print(pp.title) # 报错 'dict' object has no attribute 'title'
print(pp['title']) # Programming Pearls
# 类型提示在 BookDict.__annotations__ 中,不在 pp 中。
print(
BookDict.__annotations__) # {'isbn': <class 'str'>, 'title': <class 'str'>, 'authors': list[str], 'pagecount': <class 'int'>}
没有类型检查工具,TypedDict 充其量算是注释,可以为阅读代码的人提供些许帮助,仅此而已。
三、类型校正
任何类型系统都不完美,静态类型检查工具、typeshed 项目中的类型提示,以及第三方包中的类型提示也是如此。 `typing.cast()`是一个特殊函数,可用于处理不受控制的代码中存在的类型检查问题或不正确的类型提示。
在运行时,`typing.cast`什么也不做。
from typing import cast
def find_first_str(a: list[object]) -> str:
index = next(i for i, x in enumerate(a) if isinstance(x, str))
# 至少有一个字符串才能执行到这里
return cast(str, a[index])
在生成器表达式上调用 next()
函数时,要么返回一个字符串项,要么抛出 StopIteration。因此,没 有异常抛出时,find_first_str
始终返回一个字符串,而且 str 是声明的返回值类型。 然而,如果最后一行只有 return a[index]
,那么 Mypy 推导出的返回值类型将是 object,因为 a 参数声明的类型是 list[object]
。所以,必须通过 cast()
指引 Mypy。
四、 在运行时读取类型提示
Python 会在导入时读取函数、类和模块中的类型提示,把类型提示存储在__annotations__
属性中
def clip(text: str, max_len: int = 80) -> str:
类型提示以字典形式存储在该函数的 __annotations__
属性中。
from clip_annot import clip
>>> clip.__annotations__
{'text': <class 'str'>, 'max_len': <class 'int'>, 'return': <class 'str'>}
‘return’ 键对应 ->
符号后面的返回值类型提示。
五、实现一个泛化类
5.1 泛型基本术语
泛型
-
具有一个或多个类型变量的类型。
-
示例:
LottoBlower[T]
和abc.Mapping[KT, VT]
。
形式类型参数
-
泛型声明中出现的类型变量。
-
示例:
abc.Mapping[KT, VT]
中的KT
和VT
。参数化类型
-
使用具体类型参数声明的类型。
-
示例:
LottoBlower[int]
和abc.Mapping[str, float]
。
具体类型参数
-
声明参数化类型时为参数提供的具体类型。
-
示例:
LottoBlower[int] 中的 int
。
5.2 实现一个泛型类
import random
from collections.abc import Iterable
from typing import TypeVar, Generic
from tombola import Tombola
T = TypeVar('T')
# 泛化类声明通常使用多重继承,因为需要子类化 Generic,以声明形式类型参数(这里的 T)。
class LottoBlower(Tombola, Generic[T]):
# __init__ 方法的 items 参数是 Iterable[T] 类型。如果使用 LottoBlower[int] 实例化,则类型是 Iterable[int]。
def __init__(self, items: Iterable[T]) -> None:
self._balls = list[T](items)
# load 方法也有同样的约束
def load(self, items: Iterable[T]) -> None:
self._balls.extend(items)
# 对于 LottoBlower[int],返回值类型 T 会变成 int
def pick(self) -> T:
try:
position = random.randrange(len(self._balls))
except ValueError:
raise LookupError('pick from empty LottoBlower')
return self._balls.pop(position)
# 这个方法没有使用类型变量
def loaded(self) -> bool:
return bool(self._balls)
# 最后,使用 T 设置返回的元组中项的类型
def inspect(self) -> tuple[T, ...]:
return tuple(self._balls)
六、型变
6.1 一个不变的自动售货机
下面试着为食堂的规定建模:定义一个泛化的 BeverageDispenser 类,参数化饮料的类型。
from typing import TypeVar, Generic
# Beverage、Juice 和 OrangeJuice 构成了一种类型层次结构
class Beverage:
"""任何饮料"""
class Juice(Beverage):
"""任何果汁"""
class OrangeJuice(Juice):
"""使用巴西橙子制作的美味果汁"""
# 简单的 TypeVar 声明
T = TypeVar('T')
# BeverageDispenser 参数化了饮料的类型
class BeverageDispenser(Generic[T]):
"""一个参数化饮料类型的自动售货机"""
def __init__(self, beverage: T) -> None:
self.beverage = beverage
def dispense(self) -> T:
return self.beverage
# install 是模块全局函数。该函数的类型提示会执行只能安装果汁自动售货机的规定
def install(dispenser: BeverageDispenser[Juice]) -> None:
"""安装一个果汁自动售货机"""
以下代码是有效的
juice_dispenser = BeverageDispenser(Juice())
install(juice_dispenser)
但是,以下代码无效。
beverage_dispenser = BeverageDispenser(Beverage())
install(beverage_dispenser)
## mypy: Argument 1 to "install" has
## incompatible type "BeverageDispenser[Beverage]"
## expected "BeverageDispenser[Juice]"
不接受可售卖任何饮料(Beverage)的自动售货机,因为食堂要求自动售货机只能售卖果汁(Juice)。 让人不解的是,以下代码也无效。
orange_juice_dispenser = BeverageDispenser(OrangeJuice())
install(orange_juice_dispenser)
## mypy: Argument 1 to "install" has
## incompatible type "BeverageDispenser[OrangeJuice]"
## expected "BeverageDispenser[Juice]"
使用 OrangeJuice 特化的自动售货机也不允许安装,只允许安装 BeverageDispenser[Juice]
。BeverageDispenser[OrangeJuice]
与 BeverageDispenser[Juice]
不兼容(尽管 OrangeJuice 是 Juice 的子类型),按照类型相关的术 语,我们说 BeverageDispenser(Generic[T])
是不变的。
6.2 一个协变的自动售货机
如果想灵活一些,把自动售货机建模为可接受某些饮料类型及其子类型的泛化类,则必须让它支持协 变。BeverageDispenser 类的声明如下
# 声明类型变量时,设置 covariant=True。_co 后缀是 typeshed 项目采用的一种约定,表明这是协变的类型参数。
T_co = TypeVar('T_co', covariant=True)
# 使用 T_co 参数化特殊的 Generic 类
class BeverageDispenser(Generic[T_co]):
def __init__(self, beverage: T_co) -> None:
self.beverage = beverage
def dispense(self) -> T_co:
return self.beverage
def install(dispenser: BeverageDispenser[Juice]) -> None:
"""安装一个果汁自动售货机"""
以下代码能正常运行,因为现在对可协变的 BeverageDispenser 来说,Juice 和 OrangeJuice 都是 有效的自动售货机。
juice_dispenser = BeverageDispenser(Juice())
install(juice_dispenser)
orange_juice_dispenser = BeverageDispenser(OrangeJuice())
install(orange_juice_dispenser)
这就是协变,参数化自动售货机子类型关系的变化方向与类型参数子类型关系的变化方向相同。
6.3 一个逆变的垃圾桶
Refuse 是最一般的垃圾类型。所有垃圾都是废弃物。
Biodegradable 是特殊的垃圾类型,随着时间的推移,可被生物体降解。某些废弃物 (Refuse)不是可生物降解垃圾(Biodegradable)。
Compostable 是特殊的可生物降解垃圾(Biodegradable),可在堆肥箱或堆肥设施中转化为 有机肥料。根据我们的定义,不是所有可生物降解垃圾都是可制作成肥料的垃圾 (Compostable)。
为了对食堂可接受的垃圾桶规则进行建模,需要引入“逆变”的概念
from typing import TypeVar, Generic
# 一种废弃物类型层次结构:Refuse 是最一般的类型,Compostable 是最具体的类型。
class Refuse:
"""任何废弃物"""
class Biodegradable(Refuse):
"""可生物降解的废弃物"""
class Compostable(Biodegradable):
"""可制成肥料的废弃物"""
T_contra = TypeVar('T_contra', contravariant=True) # 按约定,T_contra 表示逆变类型变量
class TrashCan(Generic[T_contra]): # TrashCan 对废弃物的类型实行逆变
def put(self, refuse: T_contra) -> None:
"""在倾倒之前存放垃圾"""
def deploy(trash_can: TrashCan[Biodegradable]):
"""放置一个垃圾桶,存放可生物降解的废弃物"""
按照上述定义,以下类型的垃圾桶是可接受的。
bio_can: TrashCan[Biodegradable] = TrashCan()
deploy(bio_can)
trash_can: TrashCan[Refuse] = TrashCan()
deploy(trash_can)
更一般的 TrashCan[Refuse] 是可接受的,因为它可以存放任何废弃物,包括可生物降解的废弃物 (Biodegradable)。然而,TrashCan[Compostable] 不可接受,因为它不能存放可生物降解的废弃 物(Biodegradable)。
compost_can: TrashCan[Compostable] = TrashCan()
deploy(compost_can)
## mypy: Argument 1 to "deploy" has
## incompatible type "TrashCan[Compostable]"
## expected "TrashCan[Biodegradable]"
七、 型变总结
7.1 不变类型
不管实参之间是否存在关系,当两个参数化类型之间不存在超类型或子类型关系时,泛型 `L`是不变的。也就是说,如果 `L`是不变的,那么` L[A]` 就不是 `L[B]` 的超类型或子类型。两个方向都是不相容的。
前文说过,Python 中的可变容器默认是不可变的。list 类型就是一例:list[int]
与 list[float]
不相容,反之亦然。
一般来说,如果一个形式类型参数既出现在方法参数的类型提示中,又出现在方法的返回值类型中, 那么该参数必须是不可变的,因为要确保更新容器和从容器中读取时的类型安全性。
举个例子,下面是 typeshed 项目中内置类型 list 的部分类型提示。
class list(MutableSequence[_T], Generic[_T]):
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, iterable: Iterable[_T]) -> None: ...
# ...省略部分行...
def append(self, __object: _T) -> None: ...
def extend(self, __iterable: Iterable[_T]) -> None: ...
def pop(self, __index: int = ...) -> _T: ...
# ...
注意,_T
既出现在了__init__
、append
和 extend
等方法的参数中,也是 pop
方法的返回值类 型。如果_T
可以协变或逆变,则无法保障这种类的类型安全性。
7.2 协变类型
给定两个类型 A 和 B,B 与 A 相容,而且均不是 Any。有些作者使用符号 <:
和 :>
表示类型之间的关 系,如下所示。
-
A :> B
A 是 B 的超类型,或者 A 与 B 类型相同。 -
B <: A
B 是 A 的子类型,或者 B 与 A 类型相同。对于
A :> B
,当满足C[A] :> C[B]
时,泛型 C 是可协变的。
7.3 逆变类型
对于 A :> B
,当满足 K[A] <: K[B]
时,泛型 K
是可逆变的。 可逆变的泛型可以逆转具体类型参数的子类型关系。
原文始发于微信公众号(Python之家):Python深入-12-类型提示
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/198374.html