1

流畅的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的值可以是一个strNone
  • 必须显示地提供默认值,即=None
    如果不为 plural 分配默认值,则 Python 运行时将把它视作必需的参
    数。记住,类型提示在运行时会被忽略。

类型由受支持的操作定义

类型是一系列值和一系列可操作这些值的函数。实践中,最好把受支持的操作当作类型的关键特征。

def double(x):
  return x*2

x参数可以是数值(intcomplexFractionnumpy.uint32等),也可以是Nnumpy.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()


使用Mypybirds.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 中,woodyBird 实例,
调用 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类型支持所有可能的操作。
以下述签名为例,对比一下AnyObject

def double(x: object) -> object:

这个函数也接受每一种类型的参数,因为任何类型都是object的子类型。但是类型检查工具拒绝以下函数:

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

这是因为object不支持__mul__操作。Mypy报告的错误如下所示。

简单的类型和类

intfloatstrbytes 这样的简单的类型可以直接在类型提示
中使用。标准库、外部包中的具体类,以及用户定义的具体类(例如
FrenchDeckVector2dDuck),也可以在类型提示中使用。抽象基类在类型提示中也能用到。

🚩 内置类型 intfloatcomplex之间没有名义上的子类型关
系,它们都是 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 中导入OptionalUnion`。

plural: Optional[str] = None # 旧句法
plural: str | None = None # 新句法

| 运算符还可用于构建 isinstanceissubclass 的第二个参数,例如 isinstance(x, int | str)
内置函数 ord 的签名就用到了 Union,其接受 strbytes 类型,并返回一个 int

def ord(c: Union[str, bytes]) -> int: ...

下面示例中的函数接受一个str,但是可以返回一个strfloat

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]就“画
蛇添足”了,因为 intfloat 相容。仅使用 float 注解的参数也接受
int 值。

泛化容器

大多数Python容器是异构的。例如,在一个 list 中可以混合存放不同
的类型。然而,实际使用中这么做没有什么意义。存入容器的对象往往
需要进一步处理,因此至少要有一个通用的方法。
泛型可以用类型参数来声明,以指定可以处理的项的类型。
示例8-8 带类型提示的tokenize函数

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

类型提示的意思是 tokenize 函数返回一
list,而且各项均为 str 类型。
stuff: liststuff: list[Any] 这两个注解的意思相同,都表示
stuff 是一个列表,而且列表中的项可以是任何类型的对象。
下面只列出了可使用最简单的泛化类型提
示形式的容器:

tuple 和映射类型支持更复杂的类型提示.
截至 Python 3.10,没有什么好方法来注解带 typecode 构造函数参数
(决定数组中存放整数还是浮点数)的 array.array。更棘手的问题
是,如何检查整数区间,防止在运行时向数组中添加元素而导致
OverflowError。例如,使用 typecode='B' 创建的数组,只能存放 0255 之间的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-12 coordinates_named.py:具名元组 Coordinatesgeohash 函数

    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.NamedTupletuple 子类的制造工厂,因
    Coordinatetuple[float, float] 相容,但是反过来不成
    立,毕竟 NamedTupleCoordinate 额外添加了方法

  • 用作不可变序列的元组
    如果想注解长度不定、用作不可变列表的元组,则只能指定一个类
    型,后跟逗号和 ...Python 中的省略号,3 个点,不是 Unicode
    字符 U+2026,即 HORIZONTAL ELLIPSIS
    例如,tuple[int, ...] 表示项为 int 类型的元组。省略号表示元素的数量 ≥ 1。可变长度的元组不能为字段指定不同
    的类型。stuff: tuple[Any, ...] stuff: tuple 这两个注解的意思
    相同,都表示 stuff 是一个元组,长度不定,可包含任意类型的对象。
    下面的代码使用 columnize 函数把一个序列转换成了元组列表
    (类似于表格中的行和单元格),列表中的元组长度不定。最后,按列显示各项。

泛化映射

泛化映射类型使用 MappingType[KeyType, ValueType] 形式注解。在 Python 3.9 及以上版本中,内置类型 dictcollections
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,因此调用方可以提供
dictdefaultdictChainMap 的实例,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。如此一来,调用方就不用提供实
现了 setdefaultpopupdate 等方法的对象了,因为这些方法属
MutableMapping 接口,而不是 Mapping 接口。

Iterable

前文引用的 typing.List 文档推荐使用 SequenceIterable 注解
函数的参数。
示例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 经常用于处理intfloat 值,但是 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_wrongupdate 函数的
    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 版本的变化(有时落后不止一年),
    可能拒绝使用语言新功能的代码,甚至崩溃。
posted @ 2024-06-06 11:23  Bonne_chance  阅读(46)  评论(0编辑  收藏  举报
1