美文网首页
Fluent Python 2nd 笔记——Type hints

Fluent Python 2nd 笔记——Type hints

作者: rollingstarky | 来源:发表于2022-04-15 20:22 被阅读0次

    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

    相关文章

      网友评论

          本文标题:Fluent Python 2nd 笔记——Type hints

          本文链接:https://www.haomeiwen.com/subject/cfxnsrtx.html