2

Fluent Python 2nd 笔记——Type hints(类型标注)介绍

 2 years ago
source link: https://rollingstarky.github.io/2022/04/15/fluent-python-2nd-reading-notes-type-hints/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Fluent Python 2nd 笔记——Type hints(类型标注)介绍

发表于 2022-04-15

|

| 阅读次数:

字数统计: 11k

|

阅读时长 ≈ 0:11

PEP 484—Type Hints 在 Python 中引入了显式的类型标注,可以为函数参数、返回值、变量等添加类型提示。主要目的在于帮助开发工具通过静态检查发现代码中的 Bug。

gradual typing

PEP 484 引入的是一种 gradual type system(渐进式类型系统),支持同样类型系统的语言还有微软的 TypeScript、Google 的 Dart 等。该系统具有以下特征:

  • 可选的。默认情况下,类型检查器不应该警告没有标注类型的代码。当无法确认某个对象的类型时,假设其为 Any 类型
  • 在运行时不捕获类型错误。Type hints 主要用来帮助类型检查器、linter 和 IDE 输出警告信息,不会在运行时阻止不匹配的类型传递给某个函数
  • 对性能没有提升。理论上讲,类型标注提供的信息能够帮助解释器对生成的字节码进行优化。目前 Python 还没有相关的实现

类型标注在任何层面上都是可选的
简单来说,用户可以选择任何一个自己感兴趣的参数或返回值进行类型标注,不用管其它的。在没有配置 IDE 进行严格检查的时候,不会有任何报错出现。
即便用户错误地标注了类型,对程序的运行也不会产生任何影响。最多只是 IDE 会有报错提示。

gradual typing 示例
# messages.py
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'

print(show_count(1, 'dog'))
# => 1 dog
print(show_count(2, 'dog'))
# => 2 dogs

安装 mypy 类型检查工具:pip install mypy

使用 mypy 命令对 messages.py 源代码进行类型检查,没有任何错误:

$ mypy messages.py
Success: no issues found in 1 source file

只有当加上 --disallow-untyped-defs 选项的时候才会检查出错误(函数缺少类型标注):

$ mypy --disallow-untyped-defs messages.py
messages.py:1: error: Function is missing a type annotation
Found 1 error in 1 file (checked 1 source file)

修改一下检查的严格程度,使用 --disallow-incomplete-defs 选项,此时检查是通过的:

$ mypy --disallow-incomplete-defs messages.py
Success: no issues found in 1 source file

将函数 show_count 的签名改为 show_count(count, word) -> str,只为返回值添加类型标注,再次进行检查:

$ messages.py:1: error: Function is missing a type annotation for one or more arguments
Found 1 error in 1 file (checked 1 source file)

--disallow-incomplete-defs 不会去管完全没有类型标注的函数,而是会确保,只要某个函数添加了类型标注,则其类型标注必须完整应用到该函数的所有参数和返回值。

假如将函数 show_count 的签名改为 show_count(count: int, word: str) -> int,运行类型检查则会报出其他错误(返回值类型不匹配):

$ mypy --disallow-incomplete-defs messages.py
messages.py:3: error: Incompatible return value type (got "str", expected "int")
messages.py:5: error: Incompatible return value type (got "str", expected "int")
Found 2 errors in 1 file (checked 1 source file)

程序的运行不会受任何影响

$ python messages.py
1 dog
2 dogs

即类型标注可以帮助 IDE 等工具对代码进行静态检查,在程序运行前发现可能的语法错误。但并不会对程序的运行时施加任何影响。
这就是为什么称之为 Gradual。即不具备任何强制性,可以在需要的时候逐步完善任何感兴趣的变量。但加不加标注,程序该怎么跑还是怎么跑。
Type checker in VIM

使用 None 作为默认值

前面的 messages.py 实际上做的事情很简单,就是输出数量和名词。数量为 1 名词用单数,数量大于 1 名词就加 s 变复数。
但很多名词并不是直接加 s 就能成为复数形式,比如 child -> children。因此代码可以优化为如下形式:

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}'

print(show_count(2, 'dog'))
# => 2 dogs
print(show_count(2, 'child', 'children'))
# => 2 children

上面的代码可以很好的工作。函数中加了一个参数 plural 表示名词的复数形式,默认值是空字符串 ''。但从语义的角度看,默认值用 None 更符合一些。
即某个名词要么有特殊的复数形式,要么没有。但这会导致 plural 参数的类型声明不适合使用 str,因为其取值可以是 None,而 None 不属于 str 类型。

show_count 函数的签名改为如下形式即可:

from typing import Optional
def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:

其中 Optional[str] 就表示该类型可以是 str 或者 None
此外,默认值 =None 必须显式地写在声明里,否则 Python 运行时会将 plural 视为必须提供的参数。
在类型声明里注明了某个参数是 Optional,并不会真的将其变为可选参数。记住对于运行时而言,类型标注总是会被忽略掉

Types are defined by supported operations

引用 PEP 483 中的定义,类型就是一组值的集合,这些值有一个共同的特点,就是一系列特定的函数能够应用到这些值上。即某种类型支持的一系列操作定义了该类型的特征

比如下面的 double 函数:

def double(x):
return x * 2

其中 x 参数的类型可以是数值类型(intcomplexFractionnumpy.uint32 等),但也可能是某种序列类型(strtuplelistarray 等)、N 维数组 numpy.array 甚至任何其他类型,只要该类型实现或继承了 __mul__ 方法且接收 int 作为参数。

但是对于另一个 double 函数:

from collections import abc

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

x 参数的类型声明为 abc.Sequence,此时使用 mypy 检查其类型声明会报出错误:

$ mypy double.py
double.py:4: error: Unsupported operand types for * ("Sequence[Any]" and "int")
Found 1 error in 1 file (checked 1 source file)

因为 Sequence 虚拟基类并没有实现或者继承 __mul__ 方法,类型检查器认为 x * 2 是不支持的操作。但在实际运行时,上述代码支持 xstrtuplelistarray 等等实现了 Sequence 的具体类型,运行不会有任何报错。
原因在于,运行时会忽略类型声明。且类型检查器只会关心显式声明的对象,比如 abc.Sequence 中有没有 __mul__

这也是为什么在 Python 中,类型的定义就是其支持的操作。任何作为参数 x 传给 double 函数的对象,Python 运行时都会接受。它可能运行通过,也可能该对象实际并不支持 * 2 操作,报出 TypeError

在 gradual type system 中,有两种不同的看待类型的角度:

  • Duck typing:Smalltalk 发明的“鸭子类型”,Python、JavaScript、Ruby 等采用此方式。对象有类型,而变量(包括参数)是无类型的。在实践中,对象声明的类型是不重要的,关键在于该对象实际支持的操作。鸭子类型更加灵活,代价就是允许更多的错误出现在运行时
  • Nominal typing:C++、Java、C# 等采用此方式。对象和变量都有类型。但对象只存在于运行时,而类型检查器只关心源代码中标记了类型的变量。比如 DuckBird 的子类,你可以将一个 Duck 对象绑定给标记为 birdie: Bird 的参数。但是在函数体中,类型检查器会认为 birdie.quack() 是非法的(quack()Duck 类中实现的方法)。因为 Bird 类并没有提供 quack() 方法,即便实际的参数 Duck 对象已经实现了 quack()。Nominal typing 在静态检查时强制应用,类型检查器只是读取源代码,并不会执行任何一个代码片段。Nominal typing 更加严格,优势就是可以更早地发现某些 bug,比如在 build 阶段甚至代码刚输入到 IDE 中的时候。

参考下面的例子:

# birds.py
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()

DuckBird 的子类;
alert 没有类型标注,会被类型检查器忽略;
alert_duck 接收一个 Duck 类型的参数;
alert_bird 接收 Bird 类型的参数。

mypy 检查上述代码会报出一个错误:

$ mypy birds.py
birds.py:15: error: "Bird" has no attribute "quack"
Found 1 error in 1 file (checked 1 source file)

Bird 类没有 quack() 方法,但函数体中却有对 quack() 方法的调用。

编写如下代码调用前面的函数:

# daffy.py
from birds import *

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

可以成功运行:

$ python daffy.py
Quack
Quack
Quack

还是那句重复了无数遍的话,在运行时,Python 并不关心声明的变量,它使用 duck typing,只关心实际传入的对象是不是支持某个操作。
因而某些时候即便静态类型检查报出了错误,代码依旧能成功运行。

但是对于下面的例子,静态检查就显得很有用了。

# woody.py
from birds import *

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

此时运行 woody.py 会报出 AttributeError: 'Bird' object has no attribute 'quack' 错误。因为实际传入的 woody 对象是 Bird 类的实例,它确实没有 quack() 方法。
有了静态检查,就可以在程序运行前发现此类错误。

上面的几个例子表明,duck typing 更灵活更加容易上手,但同时会允许不支持的操作在运行时触发错误;Nominal typing 会在运行时之前检测错误,但有些时候会阻止本可以运行的代码
在实际的环境中,函数有可能非常臃肿,有可能 birdie 参数被传递给了更多函数,birdie 还有可能来自于很长的函数调用链,会使得运行时错误很难被精确定位到。类型检查器则会阻止很多这类错误在运行时发生。

Type hints 中用到的类型

Any 类型

gradual type system 的基础就是 Any 类型,也被叫做动态类型(dynamic type)。
当类型检测器遇到如下未标注类型的代码:

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

会将其视为如下形式:

from typing import Any

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

Any 类型支持所有可能的操作,参数 n: Any 可以接受任意类型的值。

简单类型和类

简单类型比如 intfloatstrbytes 可以直接用在类型标注中。
来自于标准库或者第三方库,以及用户自定义的类也可以作为类型标注的关键字。
虚拟基类在类型标注中也比较常用。

同时还要注意一个重要的原则:子类可以用在任何声明需要其父类的地方(Liskov Substitution Principle)。

OptionalUnion 类型

Optional[str] 实际上是 Union[str, None] 类型的简写形式,表示某个值可以是 str 或者 None
在 Python3.10 中,可以用 str | None 代替 Union[str, None]

下面是一个有可能返回 str 或者 float 类型的函数:

from typing import Union

def parse_token(token: str) -> Union[str, float]:
try:
return float(token)
except ValueError:
return token

Union 在相互之间不一致的类型中比较有用,比如 Union[str, float]。对于有兼容关系的类型比如 Union[int, float] 就不是很有必要,因为声明为 float 类型的参数也可以接收 int 类型的值。

通用集合类型

Python 中的大多数集合类型都是不均匀的。不均匀的意思就是,比如 list 类型的变量中可以同时存放多种不同类型的值。但是,这种做法通常是不够实用的。
通常用户将一系列对象保存至某个集合中,这些对象一般至少有一个共同的接口,以便用户稍后用一个函数对所有这些对象进行处理。

Generic types 可以在声明时加上一个类型参数。比如 list 可以通过参数化来控制自身存储的值的类型:

def tokenize(text: str) -> list[str]:
return text.upper().split()

在 Python 版本不低于 3.9 时,上述代码表示 tokenize 函数会返回一个列表,列表中的每一项都是 str 类型。

类型标注 stuff: liststuf: list[Any] 是等效的,都表示 stuff 这个列表可以同时包含任意类型的元素。

元组作为记录
比如需要保存城市、人口和国家的值 ('Shanghai', 24.28, 'China'),其类型标注可以写作 tuple[str, float, str]

有命名字段的元组
建议使用 typing.NamedTuple

from typing import NamedTuple

class Coordinate(NamedTuple):
lat: float
lon: float

def display(lat_lon: tuple[float, float]) -> None:
lat, lon = lat_lon
ns = 'N' if lat >= 0 else 'S'
ew = 'E' if lon >= 0 else 'W'
print(f'{abs(lat):0.1f}°{ns}, {abs(lon):0.1f}°{ew}')

display(Coordinate(120.20, 30.26))
# => 120.2°N, 30.2°E

NamedTupletuple[float, float] 兼容,因而 Coordinate 对象可以直接传递给 display 函数。

元组作为不可变序列
当需要将元组作为不可变列表使用时,类型标注需要指定一个单一的类型,后面跟上逗号和 ...
比如 tuple[int, ...] 表示一个元组包含未知数量的 int 类型的元素。
stuff: tuple[Any, ...] 等同于 stuff: tuple,表示 stuff 对象可以包含未指定数量的任意类型的元素。

Generic mappings

Generic mapping 类型使用 MappingType[KeyType, ValueType] 形式的标注。比如内置的 dict 和其他 collections/collections.abc 库中的 Map 类型。

Abstract Base Class

理想情况下,一个函数应该接收虚拟类型的参数,不使用某个具体的类型。
比如下面的函数签名:

from collections.abc import Mapping
def name2hex(name: str, color_map: Mapping[str, int]) -> str:

使用 abc.Mapping 作为函数参数的类型标注,能够允许调用者传入 dictdefaultdict.ChainMapUserDict 子类或者任意 Mapping 的子类型作为参数。

相反的,使用下面的函数签名:

def name2hex(name: str, color_map: dict[str, int]) -> str:

会使得 color_map 参数必须接收 dict 或者 defaultDictOrderedDictdict 的子类型。collections.UserDict 的子类就无法通过 color_map 的类型检查。因为 UserDict 并不是 dict 类型的子类,它俩是兄弟关系,都是 abc.MutableMapping 的子类。
因此,在实践中最好使用 abc.Mapping 或者 abc.MutableMapping 作为参数的类型标注。

有个法则叫做 Postel’s law,也被称为鲁棒性原则。简单来说就是对发送的内容保持谨慎,对接收的内容保持自由

拿列表举例来说,在标注函数的返回值类型时,最好使用 list[str] 这种具体的类型;在标注函数的参数时,则使用 SequenceIterable 这类抽象的集合类型。

Iterable
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

l33t = [('a', '4'), ('e', '3'), ('i', '1'), ('o', '0')]
text = 'mad skilled noob powned leet'
print(zip_replace(text, l33t))
# => m4d sk1ll3d n00b p0wn3d l33t

其中 FromTotype alias

参数化通用类型与 TypeVar

参数化通用类型是一种通用类型,比如 list[T] 中的 T 可以绑定任意指定类型,但是之后再次出现的 T 则会表示同样的类型。

参考下面的 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]

假如传给 sample 函数的参数类型是 tuple[int, ...],该参数与 Sequence[int] 通用,因此类型参数 T 就代表 int,从而返回值类型变成 list[int]
假如传入的参数类型是 str,与 Sequence[str] 通用,则 T 代表 str,因而返回值类型变成 list[str]

Restricted TypeVar

from decimal import Decimal
from fractions import Fraction
from typing import TypeVar

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

表示类型参数 T 只能是声明中提到的有限的几个类型之一。

Bounded TypeVar

from collections.abc import Hashable
from typing import TypeVar

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

表示类型参数 T 只能是 Hashable 类型或者其子类型之一。

Static Protocols

Protocol 类型与 Go 中的接口很相似。它的定义中会指定一个或多个方法,类型检查器则会确认对应的类型是否实现了这些方法。

比如下面的例子:

from collections.abc import Iterable
from typing import TypeVar, Protocol, Any

class SupportLessThan(Protocol):
def __lt__(self, other: Any) -> bool: ...


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

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

print(top([4, 1, 5, 2, 6, 7, 3], 3))
# => [7, 6, 5]
l = 'mango pear apple kiwi banana'.split()
print(top(l, 3))
# => ['pear', 'mango', 'kiwi']
l2 = [(len(s), s) for s in l]
print(top(l2, 3))
# => [(6, 'banana'), (5, 'mango'), (5, 'apple')]

如果 top 函数中 series 参数的类型标注是 Iterable[T],没有任何其他限制,意味着该类型参数 T 可以是任意类型。但将 Iterable[Any] 传给函数体中的 sorted 函数,并不总是成立,必须确保 Iterable[Any] 是可以被直接排序的类型。
因而需要先创建一个 SupportLessThan protocol 指定 __lt__ 方法,再用该 protocol 来绑定类型参数 LT,从而限制 series 参数必须为可迭代对象,且其中的元素都实现了 __lt__ 方法,使得传入的 series 参数支持被 sorted 直接排序。

当类型 T 实现了 protocol P 中定义的所有方法时,则说明该类型 T 与 protocol P 通用。

Callable

Callable 主要用于标注高阶函数中作为参数或者返回值的函数对象。其格式为 Callable[[ParamType1, ParamType2], ReturnType]

Fluent Python, 2nd Edition


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK