0%

流畅的 Python 第 2 版(13):接口、协议和抽象基类

面向对象设计第一原则是 对接口编程,而不是对实现编程。在 Python 中,支撑一个类型的是它提供的方法,也就是接口。在不同的编程语言中,接口的定义和使用方式不尽相同。

Python 3.8 开始,有 4 种方式,如下类型图所示。这 4 种方式概述如下:

  • 鸭子类型:自 Python 诞生以来默认使用的类型实现方式
  • 大鹅类型:由抽象基类支持的方式,该方式会在运行时检查对象是否符合抽象基类的要求
  • 静态类型:C 和 Java 等传统静态类型语言采用的方式。自 Python3.5 开始,由 typing 模块支持,由符合PEP 484—Type Hints 要求的外部类型检查工具实施检查
  • 静态鸭子类型:因 Go 语言而流行的方式。由 typing.Protocol(Python3.8 新增)的子类支持,由外部类型检查工具实施检查
  • 上半部分是只使用 Python 解释器在运行时检查类型的方式;下半部分则要借助外部静态类型检查工具
  • 左边两象限中的类型基于对象的结构(对象提供的方法)​,与对象所属的类或超类无关;右边两象限中的类型要求对象有明确的类型名称:对象所属类的名称,或者超类的名称

这 4 种方式全都依靠接口,不过静态类型可以只使用具体类型实现(效果差)​,而不使用协议和抽象基类等接口抽象。本章涵盖围绕接口实现的 3 种类型:鸭子类型、大鹅类型和静态鸭子类型。

两种协议

之前介绍过,对象协议指明为了履行某个角色,对象必须实现哪些方法。完全实现一个协议可能需要多个方法,不过,通常可以只实现部分协议。所以,经常说协议是 非正式接口。在 Python 文档中,除了有关网络编程的内容,​**协议 一词基本上是指非正式接口**。

Python3.8 通过 PEP 544—Protocols: Structural subtyping (static duck typing) 之后,,​协议 一词在 Python 中多了一种含义,PEP 544 提议通过 typing.Protocol 的子类定义一个类必须实现(或继承)的一个或多个方法,让静态类型检查工具满意

需要区分时,我会使用以下两个术语:

  • 动态协议:Python 一直有的非正式协议。动态协议是隐含的,按约定定义,在文档中描述。Python 大多数重要的动态协议由解释器支持
  • 静态协议:PEP 544—Protocols: Structural subtyping (static duck typing) 定义的协议,自 Python3.8 开始支持。静态协议要使用 typing.Protocol 子类显式定义

二者之间的主要区别如下:

  • 对象可以只实现动态协议的一部分,但是如果想满足静态协议,则对象必须提供协议类中声明的每一个方法,即使程序用不到
  • 静态协议可以使用静态类型检查工具确认,动态协议则不能

两种协议共有一个基本特征:类无须通过名称(例如通过继承)声明支持什么协议。除了静态协议,Python 还提供了另一种定义显式接口的方式,即抽象基类

接下来的内容将涵盖动态协议和静态协议,以及抽象基类。

利用鸭子类型编程

我们以 Python 中两个最重要的协议(序列协议和可迭代协议)为例展开对动态协议的讨论。即使对象只实现了这些协议的最少一部分,也会引起解释器的注意。

如下通过 Python Sequence 抽象类来说明一个功能完善的序列应该支持什么操作。collections.abc 模块中的大多数抽象基类存在的目的是确立由内置对象实现并且由解释器隐式支持的接口。这些抽象基类可作为新类的基础,并为运行时显式类型检查(大鹅类型)和静态类型检查工具用到的类型提示提供支持。

为了确保行为正确,Sequence 的子类必须实现 __getitem____len__(来自 Sized)​。Sequence 中的其他方法都是具体的,因此子类可以继承或者提供更好的实现。

这也是为什么即使没有 __iter__ 或者 __contains__ 方法, in 操作符和迭代操作符 for 循环也能作用在自定义序列对象上。如果没有 __iter__ 方法和 __contains__ 方法,则 Python 会调用 __getitem__ 方法,设法让迭代和 in 运算符可用。

下面的例子 FrenchDeck 类也没有继承 abc.Sequence,但是实现了序列协议的两个方法:__getitem____len__

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import collections

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()

def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]

def __len__(self):
return len(self._cards)

def __getitem__(self, position):
return self._cards[position]

下面再分析一个示例,强调协议的动态本性,并解释静态类型检查工具为什么没机会处理动态协议。

使用猴子补丁在运行时实现协议

猴子补丁在运行时动态修改模块、类或函数,以增加功能或修正 bug。对上面的 FrenchDeck 示例进行 shuffle,会遇到如下错误:

1
2
3
4
5
6
7
8
9
>>> from frenchdeck import FrenchDeck
>>> from random import shuffle
>>> deck = FrenchDeck()
>>> shuffle(deck)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3.10/random.py", line 394, in shuffle
x[i], x[j] = x[j], x[i]
TypeError: 'FrenchDeck' object does not support item assignment

这个问题的原因在于,shuffle 函数会就地操作,调换容器内项的位置,而 FrenchDeck 只实现了不可变序列协议。可变序列还必须提供 __setitem__ 方法。因为 Python 是动态语言,所以可以在运行时修正这个问题,甚至在交互式控制台中就能做到

1
2
3
4
5
>>> def set_card(deck, position, card):
... deck._cards[position] = card
...
>>> FrenchDeck.__setitem__ = set_card
>>> shuffle(deck)

这就是猴子补丁:在运行时修改类或模块,而不改动源码。虽然猴子补丁很强大,但是打补丁的代码与被打补丁的程序耦合十分紧密,而且往往要处理文档没有明确说明的私有属性。另外说明一下,Python 方法说到底就是普通函数,把第一个参数命名为 self 只是一种约定,这里 set_card() 并没有将第一个参数(FrechDeck 对象)命名为 self。

这个例子还强调了动态鸭子类型中的协议是动态的:random.shuffle 函数不关心参数所属的类,只要那个对象实现了可变序列协议的方法即可。即便对象一开始没有所需的方法也没关系,可以之后再提供。

防御性编程和快速失败

许多 bug 只有在运行时才能捕获,即使主流的静态类型语言也是如此。对于动态类型语言,​快速失败 可以提升程序的安全性,让程序更易于维护。快速失败的意思是尽早抛出运行时错误,例如,在函数主体开头就拒绝无效的参数。如果一个函数接受一系列项,在内部按照列表处理,那么就不要通过类型检查强制要求传入一个列表。正确的做法是立即利用参数构建一个列表:

1
2
def __init__(self, iterable):
self._balls = list(iterable)
  • 这样写出的代码更灵活,因为 list() 构造函数能处理任何在内存中放得下的可迭代对象。如果传入的参数不是可迭代对象,那么初始化对象时 list() 调用就会快速失败,抛出意义十分明确的 TypeError 异常。
  • 如果想更明确一些,可以把 list() 调用放在 try/except 结构中,自定义错误消息。我只会在外部 API 中这么做,因为这样方便基准代码维护人员发现问题
  • 无论如何,出错的调用将出现在调用跟踪的末尾,直指根源

当然如果数据过多,或者需要就地修改数据,这种方式就不合适了。遇到这种情况,应该使用 isinstance(x, abc.MutableSequence) 做运行时检查。

  • 如果害怕传入的是无穷生成器(不常见)​,则可以先使用 len() 获取参数的长度,这样可以拒绝迭代器
  • 如果接受任何可迭代对象,那么要尽早调用 iter(x),获得一个迭代器。同样,如果 x 不是可迭代对象,则这也会快速失败,抛出一个易于调试的异常

利用鸭子类型做防御性编程,无须使用 isinstance()hasattr() 测试就能处理不同的类型。如下利用鸭子类型处理一个字符串或由字符串构成的可迭代对象:

1
2
3
4
5
6
7
try:
field_names = field_names.replace(',', ' ').split()
except AttributeError:
pass
field_names = tuple(field_names)
if not all(s.isidentifier() for s in field_names):
raise ValueError('field_names must all be valid identifiers')
  • 假设是一个字符串(EAFP原则:取得原谅比获得许可容易)​,把逗号替换成空格,再拆分成名称列表
  • 如果抛出 AttributeError,说明 field_names 不是字符串,那就假设 field_names 是由名称构成的可迭代对象。
  • 为了确保是可迭代对象,也为了留存一份副本,根据现有数据创建一个元组。元组比列表紧凑,还能防止代码意外改动名称
  • 使用 str.isidentifier 确保每个名称都是有效的标识符

接下来讨论运行时类型检查更为外显的一种形式,即大鹅类型。

大鹅类型

Python 没有 interface 关键字。我们使用抽象基类定义接口,在运行时显式检查类型(静态类型检查工具也支持)。抽象基类是对鸭子类型的补充,提供了一种定义接口的方式。抽象基类引入了虚拟子类,这种类不继承其他类,却能被 isinstance()issubclass() 识别。详见 abc 模块文档。

大鹅类型是一种利用抽象基类实现的运行时检查方式。大鹅类型指的是,只要 cls 是抽象基类(cls 的元类是 abc.ABCMeta)​,就可以使用 isinstance(obj, cls)。与具体类相比,抽象基类有很多理论上的优点。有时,为了让抽象基类识别子类,甚至不用注册(抽象基类的本质就是几个特殊方法)。

1
2
3
4
5
6
>>> class Struggle:
... def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
  • 无须注册,abc.Sized 也能把 Struggle 识别为自己的子类,只要实现了特殊方法 __len__ 即可
  • 如果实现的类体现了 numbers、collections.abc 或其他框架中抽象基类的概念,则要么继承相应的抽象基类(必要时)​,要么把类注册到相应的抽象基类中(开始开发程序时,不要使用提供注册功能的库或框架,要自己动手注册)
  • 如果必须检查参数的类型(例如检查是不是 序列​)​,则可以像下面这样做
1
isinstance(the_arg, collections.abc.Sequence)

综上所述,大鹅类型要求:

  • 定义抽象基类的子类,明确表明你在实现既有的接口
  • 运行时检查类型时,isinstance 和 issubclass的第二个参数要使用抽象基类,而不是具体类。使用 isinstance 和 issubclass 测试抽象基类(而不是具体类)更为人接受。如果用于测试具体类,则类型检查将限制多态—— 面向对象编程的一个重要功能,用于测试抽象基类更加灵活

继承抽象基类其实就是实现必要的方法——这也明确表明了开发人员的意图。这个意图还可以通过注册虚拟子类明确表述。例如对于 FrenchDeck 类,如果想通过 issubclass(FrenchDeck, Sequence) 检查,那么可以使用以下几行代码把 FrenchDeck 注册为抽象基类 Sequence 的虚拟子类:

1
2
from collections.abc import Sequence
Sequence.register(FrenchDeck)

然而,即使是抽象基类,也不能滥用 isinstance 检查,因为用得多了可能导致代码异味,即表明面向对象设计不佳:

  • 在一连串 if/elif/elif 中使用 isinstance 做检查,然后根据对象的类型执行不同的操作,往往是不好的做法
  • 此时应该使用多态,即采用一定的方式定义类,让解释器把调用分派给正确的方法,而不使用 if/elif/elif 块硬编码分派逻辑
  • 另外,如果必须强制执行 API 契约,那么通常可以使用 isinstance 检查抽象基类。这对采用插入式架构的系统来说特别有用。

在框架之外,鸭子类型通常比类型检查更简单且更灵活。要抑制住创建抽象基类的冲动。滥用抽象基类会造成灾难性后果,表明语言太注重表面形式,这对以实用和务实著称的 Python 可不是好事。

抽象基类是用于封装框架所引入的一般性概念和抽象的。基本上不需要自己编写新的抽象基类,只要正确使用现有的抽象基类,就能获得 99.9% 的好处,而不用冒着设计不当导致的巨大风险。

子类化一个抽象基类

如下把 FrenchDeck2 声明为了 collections.MutableSequence 的子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from collections import namedtuple, abc

Card = namedtuple('Card', ['rank', 'suit'])

class FrenchDeck2(abc.MutableSequence):
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()

def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]

def __len__(self):
return len(self._cards)

def __getitem__(self, position):
return self._cards[position]

def __setitem__(self, position, value):
self._cards[position] = value

def __delitem__(self, position):
del self._cards[position]

def insert(self, position, value):
self._cards.insert(position, value)
  • 为了支持洗牌,只需实现 __setitem__ 方法即可
  • 继承 MutableSequence 的类必须实现 __delitem__ 方法,这是 MutableSequence 类的一个抽象方法
  • 还要实现 insert 方法,这是 MutableSequence 类的第三个抽象方法

Python 在导入时(加载并编译 frenchdeck2.py 模块时)不检查抽象方法的实现,在运行时实例化 FrenchDeck2 类时才真正检查。因此:

  • 如果没有正确实现某个抽象方法,那么 Python 就会抛出 TypeError 异常,错误消息为 Can't instantiate abstract class FrenchDeck2 with abstract methods __delitem__, insert
  • 抽象基类 Sequence 和 MutableSequence 的方法不全是抽象的
  • 为了把 FrenchDeck2 声明为 MutableSequence 的子类,我不得不实现例子中用不到的 __delitem__ 方法和 insert 方法。作为回报,FrenchDeck2 从 Sequence 继承了 5 个具体方法,另外,FrenchDeck2 还从 MutableSequence 继承了 6 个方法
  • 作为实现具体子类的人,你可以覆盖从抽象基类继承的方法,以更高效的方式重新实现

为了充分利用抽象基类,要知道有哪些抽象基类可用。接下来介绍 collections 包中的抽象基类。

标准库中的抽象基类

标准库提供了多个抽象基类,大都在 collections.abc 模块中定义,不过其他地方也有,例如,io 包和 numbers 包中就有一些抽象基类。如下是 collections.abc 模块中 17 个抽象基类的 UML 类图:

  • Iterable、Container 和 Sized:每个容器都应该继承这 3 个抽象基类,或者实现兼容的协议:

    • Iterable通过 __iter__ 方法支持迭代,
    • Container通过 __contains__ 方法支持 in 运算符,
    • Sized通过 __len__ 方法支持 len() 函数
  • Collection:这个抽象基类是 Python 3.6 新增的,自身没有方法,目的是方便子类化 Iterable、Container 和 Sized

  • Sequence、Mapping 和 Set:主要的不可变容器类型,而且各自都有可变的子类

  • MappingView:映射方法 .items().keys().values() 返回的对象分别实现了 ItemsView、 KeysView 和 ValuesView 定义的接口

  • Iterator:它是 Iterable 的子类

    • 即使 isinstance(obj, Iterable) 返回 False,Python 依然可以通过 __getitem__(基于 0 的索引)迭代 obj
    • 判断一个对象是否可以迭代,唯一可靠的方式是调用 iter(obj)
  • Callable 和 Hashable:可以在类型检查中用于指定可调用和可哈希的对象

    • 如果 isinstance(obj, Hashable) 返回 True,那么仅仅表示 obj 所属的类实现或继承了 __hash__方法
    • 假如 obj 是包含不可哈希项的元组,那么即便 isinstance 的检查结果为真,obj 仍是不可哈希对象
    • 利用鸭子类型判断一个实例是否可哈希是最准确的,即调用 hash(obj)。如果 obj 不可哈希,那么该调用就会抛出 TypeError

定义并使用一个抽象基类

抽象基类与描述符和元类一样,是用于构建框架的工具。如今,抽象基类的作用更广,可用在类型提示中,支持静态类型,之前说过,把函数参数类型提示中的具体类型换成抽象基类能为调用方提供更大的灵活性。

为了证明有必要定义抽象基类,需要在框架中找到使用它的场景。如下定义了一个抽象基类:抽象基类 Tombola 有 4 个方法,其中两个是抽象方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import abc

class Tombola(abc.ABC):
@abc.abstractmethod
def load(self, iterable):
...

@abc.abstractmethod
def pick(self):
...

def loaded(self):
return bool(self.inspect())

def inspect(self):
items = []
while True:
try:
items.append(self.pick())
except LookupError:
break
self.load(items)
return tuple(items)
  • 继承 abc.ABC,定义一个抽象基类
  • 抽象方法使用 @abstractmethod 装饰器标记,主体通常只有文档字符串
  • 其实,抽象方法可以有实现代码。即便实现了,子类也必须覆盖抽象方法,但是在子类中可以使用 super() 函数调用抽象方法,在此基础上添加功能,而不是从头开始实现
  • 抽象基类可以包含具体方法,抽象基类中的具体方法只能依赖抽象基类定义的接口(只能使用抽象基类中的其他具体方法、抽象方法或特性)
  • inspect 具体方法的实现,通过调用 pick 方法获取元素,然后继续调用 load 方法将元素重新放回去,随意实现有些笨拙,但是它说明了抽象基类可以提供具体方法,只要仅依赖接口中的其他方法就行
  • Tombola 的具体子类知晓内部数据结构,可以使用更聪明的实现覆盖 .inspect() 方法,但这不是强制要求
  • 另外,实现 .inspect() 方法采用的迂回方式要求捕获 self.pick() 抛出的 LookupErrorself.pick() 会抛出 LookupError 这一事实也是接口的一部分,但是在 Python 中没办法明确表明,只能在文档中说明
1
2
3
4
5
6
7
8
9
10
11
>>> from tombola import Tombola
>>> class Fake(Tombola):
... def pick(self):
... return 13
...
>>> Fake
<class '__main__.Fake'>
>>> f = Fake()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Fake with abstract method load

尝试实例化 Fake 时抛出了 TypeError。错误消息十分明确,Python 认为 Fake是抽象类,因为它没有实现抽象基类 Tombola 声明的抽象方法之一 load()

抽象基类句法详解

声明抽象基类的标准方式是继承 abc.ABC 或其他抽象基类。除了 ABC 基类和 @abstractmethod 装饰器,abc 模块还定义了 @abstractclassmethod 装饰器、@abstractstaticmethod 装饰器和 @abstractproperty 装饰器。然而,后 3 个装饰器在 Python3.3 中弃用了,因为现在可以在 @abstractmethod 之上叠放装饰器,那 3 个就显得多余了。

1
2
3
4
5
class MyABC(abc.ABC):
@classmethod
@abc.abstractmethod
def an_abstract_classmethod(cls, ...):
pass
  • 在函数上叠放装饰器的顺序通常很重要,@abstractmethod 的文档就特别指出。与其他方法描述符一起使用时, abstractmethod() 应该放在最里层

子类化抽象基类 Tombola

定义好抽象基类 Tombola 之后,要开发两个具体子类,满足 Tombola 规定的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import random

from tombola import Tombola


class BingoCage(Tombola):

def __init__(self, items):
self._randomizer = random.SystemRandom()
self._items = []
self.load(items)

def load(self, items):
self._items.extend(items)
self._randomizer.shuffle(self._items)

def pick(self):
try:
return self._items.pop()
except IndexError:
raise LookupError('pick from empty BingoCage')

def __call__(self):
self.pick()
  • BingoCage 类会显式扩展 Tombola 类
  • 额外实现了 __call__ 方法,为了满足 Tombola 接口,无须实现这个方法,不过额外增加方法也没有危害
  • 我们可以偷懒,直接从抽象基类中继承不是那么理想的具体方法。从 Tombola 中继承的方法没有 BingoCage 自己定义的那么快,不过只要 Tombola 的子类正确实现 pick 方法和 load 方法,就能提供正确的结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import random

from tombola import Tombola


class LottoBlower(Tombola):

def __init__(self, iterable):
self._balls = list(iterable)

def load(self, iterable):
self._balls.extend(iterable)

def pick(self):
try:
position = random.randrange(len(self._balls))
except ValueError:
raise LookupError('pick from empty LottoBlower')
return self._balls.pop(position)

def loaded(self):
return bool(self._balls)

def inspect(self):
return tuple(self._balls)
  • __init__ 方法中,self._balls 存储的是 list(iterable),而不是 iterable 的引用(没有直接把 iterable 赋值给 self._balls,为参数创建别名)。这样做使得 LottoBlower 更灵活,因为 iterable 参数可以是任何可迭代类型。把元素存入列表中还可以确保能取出元素。就算 iterable 参数始终传入列表,list(iterable) 也会创建参数的副本,这依然是好的做法,因为要从中删除元素,而客户可能不希望自己提供的列表被修改
  • 覆盖 loaded 方法,避免调用 inspect 方法。可以直接处理 self._balls,而不必构建整个元组,从而提升速度
  • 覆盖 inspect 方法,仅用一行代码

抽象基类的虚拟子类

大鹅类型的一个基本特征是,即便不继承,也有办法把一个类注册为抽象基类的虚拟子类。这是大鹅类型的重要动态特性:使用 register 方法声明虚拟子类。这样做时,我们承诺注册的类忠实地实现了抽象基类定义的接口,而 Python 会相信我们,不再检查。如果我们说谎了,那么常规的运行时异常会把我们捕获。

注册虚拟子类的方式是在抽象基类上调用 register 类方法。这么做之后

  • 注册的类就变成了抽象基类的虚拟子类
  • 而且 issubclass 函数能够识别这种关系,但是注册的类不会从抽象基类中继承任何方法或属性
  • 虚拟子类不继承注册的抽象基类,而且任何时候都不检查它是否符合抽象基类的接口,即便在实例化时也不会检查
  • 另外,静态类型检查工具目前也无法处理虚拟子类

register 方法通常作为普通函数调用​,不过也可以作为装饰器使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from random import randrange

from tombola import Tombola

@Tombola.register
class TomboList(list):

def pick(self):
if self:
position = randrange(len(self))
return self.pop(position)
else:
raise LookupError('pop from empty TomboList')

load = list.extend

def loaded(self):
return bool(self)

def inspect(self):
return tuple(self)

# Tombola.register(TomboList)
  • 把 Tombolist 注册为 Tombola 的虚拟子类
  • Tombolist 扩展 list
  • 始终可以这样调用 Tombola.register(TomboList)。如果需要注册不是自己维护的类,却能满足指定的接口,就可以这么做

注册之后,可以使用 issubclass 函数和 isinstance 函数判断 TomboList 是不是 Tombola 的子类:

1
2
3
4
5
6
7
>>> from tombola import Tombola
>>> from tombolist import TomboList
>>> issubclass(TomboList, Tombola)
True
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True

类的继承关系是在一个名为 __mro__(Method Resolution Order,方法解析顺序)的特殊类属性中指定的。这个属性的作用很简单,它会按顺序列出类及其超类,而 Python 会按照这个顺序搜索方法。查看 TomboList 类的 __mro__ 属性,你会发现它只列出了 真实 的超类,即 list 和 object:

1
2
>>> TomboList.__mro__
(<class 'tombolist.TomboList'>, <class 'list'>, <class 'object'>)

Tombolist.__mro__中没有 Tombola,因此 Tombolist 没有从 Tombola 中继承任何方法

register 的实际使用

上面例子中,我们把 Tombola.register 当作一个类装饰器使用。除此之外,仍然经常把 register 当作普通函数调用,注册其他地方定义的类。注册过程仅在导入模块时发生是没有问题的,因为如果想使用抽象基类,则必须导入模块。

子类化抽象基类或者注册到抽象基类上都能让类通过 issubclass 检查和 isinstance 检查(后者依赖前者)​。但是,有些抽象基类还支持结构类型。

使用抽象基类实现结构类型

抽象基类最常用于实现名义类型。假如一个类 Sub 会显式继承抽象基类 AnABC,或者注册到 AnABC 上,那么 AnABC 这个名称就和 Sub 连在了一起,因此在运行时,issubclass(AnABC, Sub) 会返回 True。

相比之下,结构类型通过对象公开接口的结构判断对象的类型,如果一个对象实现了某个类型定义的方法,那么该对象就与该类型相容。动态鸭子类型和静态鸭子类型是实现结构类型的两种方式

其实,某些抽象基类也支持结构类型,之前说过,未注册的类也可能被识别为抽象基类的子类

1
2
3
4
5
6
7
8
>>> class Struggle:
... def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
>>> issubclass(Struggle, abc.Sized)
True

经 issubclass 函数判断,Struggle 类是 abc.Sized 的子类(进而 isinstance 也得出同样的结论)​,因为 abc.Sized 实现了一个名为 __subclasshook__ 的特殊的类方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Sized(metaclass=ABCMeta):

__slots__ = ()

@abstractmethod
def __len__(self):
return 0

@classmethod
def __subclasshook__(cls, C):
if cls is Sized:
if any("__len__" in B.__dict__ for B in C.__mro__):
return True
return NotImplemented
  • 如果 C.__mro__ 列出的某个类(C 及其超类)的 __dict__ 中有名为 __len__ 的属性,就表明 C 是 cls(Sized)的虚拟子类
  • 否则,返回 NotImplemented,让子类检查继续下去

抽象基类对结构类型的支持就是通过 __subclasshook__ 实现的。可以使用抽象基类确立接口,使用 isinstance 检查该抽象基类,一个完全无关的类仍然能通过 issubclass 检查,因为该类实现了特定的方法(或者该类竭力说服了 __subclasshook__ 为它 担保​)​。

对于我们自己编写的抽象基类,一般不应该实现 __subclasshook__,因为可信度并不高,让程序员显式定义子类或者使用 register 方法来注册,这样才能板上钉钉。

静态协议

Python 最初实现类型提示利用的是名义类型系统,注解中的类型名称要与实参的类型名称(或者某个超类的名称)匹配。而我们知道,支持必要操作的类型就算实现了协议,而这样的类型可能很多,无法一一列出,因此在 Python 3.8 之前,类型提示无法描述鸭子类型,对于如下 double 函数,引入静态协议之前,几乎不可能为 double 函数添加完美的类型提示:

1
2
def double(x):
return x * 2

有了 typing.Protocol 之后,现在可以告诉 Mypy,double 函数接受支持 x * 2 运算的参数 x

1
2
3
4
5
6
7
8
9
10
11
from typing import TypeVar, Protocol

T = TypeVar('T')

class Repeatable(Protocol):
def __mul__(self: T, repeat_count: int) -> T: ...

RT = TypeVar('RT', bound=Repeatable)

def double(x: RT) -> RT:
return x * 2
  • T 在 __mul__ 签名中使用,__mul__ 是 Repeatable 协议的核心
  • self 参数通常不注解,因为默认假定为所在的类。这里使用 T 是为了确保返回值的类型与 self 相同
  • 类型变量 RT 的上界由 Repeatable 协议限定,类型检查工具将要求具体使用的类型实现 Repeatable 协议

这样就实现了提供给 double 函数的实参 x 是什么名义类型无关紧要,只要实现了 __mul__ 方法就行——这就是鸭子类型的好处。

运行时可检查的静态协议

虽然 typing.Protocol 属于静态检查,但是定义 typing.Protocol 的子类时,可以借由 @runtime_checkable 装饰器让协议支持在运行时使用 isinstance/issubclass 检查。这背后的原因是,typing.Protocol 是一个抽象基类,因此它支持上文所讲过的 __subclasshook__

从 Python3.9 开始,typing 模块提供了 7 个可在运行时检查的协议,例如:

  • class typing.SupportsComplex:抽象基类,有一个抽象方法 __complex__
  • class typing.SupportsFloat:抽象基类,有一个抽象方法 __float__

另外值得说明的是,对于外部类型检查工具,使用 isinstance 明确检查类型有一个好处:在条件为 isinstance(o, MyType) 的if语句块内,Mypy 可以推导出 o 对象的类型与 MyType 相容

在运行时,鸭子类型本身往往是类型检查的最佳方式。不要调用 isinstance 或 hasattr,直接在对象上尝试执行所需的操作,如果抛出异常,就处理异常。因为取得原谅比获得许可容易(EAFP 原则)​:

1
2
3
4
try:
c = complex(o)
except TypeError as exc:
raise TypeError('o must be convertible to complex') from exc

如果只想抛出 TypeError,就省略 try/except/raise 语句,直接写成如下形式,这时,如果 o 不是可接受的类型,那么 Python 将抛出异常,输出非常明确的消息:

1
c = complex(o)

运行时可检查协议的局限性

如前所述,类型提示在运行时一般会被忽略。使用 isinstance 或 issubclass 检查静态协议有类似的影响。

例如,实现 __float__ 方法的类在运行时都被认定是 SupportsFloat 的虚拟子类,不管 __float__ 方法是否返回一个 float 值。

isinstanceissubclass 只检查有没有特定的方法,不检查方法的签名,更不会检查方法的类型注解。这种行为不会改变,因为在运行时大规模检查类型损耗的性能是不可接受的。

支持静态协议

如下为自己的类实现了 __complex__ 方法,此时对于运行时类型检查 SupportsComplex,该类就满足要求。当然为了让 Mypy 更好地做静态检查和错误报告,__complex__ 方法和 fromcomplex 方法应该有类型提示:

1
2
3
4
5
6
7
def __complex__(self) -> complex:
return complex(self.x, self.y)

@classmethod
def fromcomplex(cls, datum) -> Vector2d:
c = complex(datum)
return cls(c.real, c.imag)
  • 如果该模块的顶部有 from __future__ import annotations,那么 fromcomplex 的返回值类型可以是 Vector2d。有了那个导入语句,类型提示将存储为字符串,在导入时(求解函数定义时)不做求解
  • 不从 __future__ 中导入 annotations,Vector2d 在那一刻(类尚未完整定义)就是无效引用,应该写为字符串 'Vector2d',假装是向前引用
  • 这个 __future__ 导入由 PEP 563—Postponed Evaluation of Annotations 引入,在 Python3.7 中实现
  • 原本计划在 Python3.10 中把这个行为定为默认行为,但是后来推迟到下一个版本了。到那时,这个导入语句就是多余的了,但是也没有危害

设计一个静态协议

Go 语言一般推荐接口的定义中应包含尽量少的方法:单方法协议实现的静态鸭子类型更有用且更灵活。Go 语言标准库中有多个这样的接口,例如 Reader,这是一个 I/O 接口,只要求一个 read 方法。以后,如果觉得需要一个更完整的协议,可以把多个协议合而为一。

之前的抽象基类示例 Tombola 有两个抽象方法:pick 和 load。我们定义对应的静态协议版本,首先是 RandomPicker:

1
2
3
4
5
from typing import Protocol, runtime_checkable, Any

@runtime_checkable
class RandomPicker(Protocol):
def pick(self) -> Any: ...
  • pick 方法的返回值类型是 Any。后面会说明如何让 RandomPicker 支持泛型参数,允许协议的用户指定 pick 方法的返回值类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import random
from typing import Any, Iterable, TYPE_CHECKING


class SimplePicker:
def __init__(self, items: Iterable) -> None:
self._items = list(items)
random.shuffle(self._items)

def pick(self) -> Any:
return self._items.pop()

def test_isinstance() -> None:
popper: RandomPicker = SimplePicker([1])
assert isinstance(popper, RandomPicker)

def test_item_type() -> None:
items = [1, 2]
popper = SimplePicker(items)
item = popper.pick()
assert item in items
if TYPE_CHECKING:
reveal_type(item)
assert isinstance(item, int)
  • 实现协议的类本身不需要 静态协议类。SimplePicker 实现 RandomPicker 协议,但不是后者的子类。这就是静态鸭子类型
  • 默认的返回值类型就是 Any,因此严格来说,不需要这个注解
  • 为 popper 变量添加了类型提示,指出 Mypy 知道 SimplePicker 是相容的
  • 如果想让 Mypy 检查,那么别忘了加上类型提示 -> None
  • SimplePicker 的实例也是 RandomPicker 的实例。背后的原因是,RandomPicker 应用了 @runtime_checkable 装饰器,而且 SimplePicker 有所需的 pick 方法
  • reveal_type 是能被 Mypy 识别的 魔法 函数,无须导入,而且只能在受 typing.TYPE_CHECKING 条件保护的 if 块中调用

协议设计最佳实践

Go 语言 10 年的静态鸭子类型经验表明,窄协议(narrow protocol)更有用。通常,窄协议只有一个方法,很少超过两个。另外,有时你会发现,协议在使用它的函数附近定义,即在 客户代码 中而不是在库中定义。这样方便调用相关函数创建新类型,也有利于扩展和使用 mock 测试。窄协议和客户代码协议都能有效避免紧密耦合,不应强迫客户依赖用不到的接口。​

Contributing to typeshed 页面建议静态协议采用以下命名约定:

  • 使用朴素的名称命名协议,清楚表明概念
  • 使用 SupportsX 形式命名提供可调用方法的协议
  • 使用 HasX 形式命名有可读属性和可写属性,或者有读值方法和设值方法的协议

另外,Go 语言接口的命名也挺不错,例如 Reader、Formatter 等。

扩展一个协议

接口的定义倾向于极简主义,如果实际使用中发现协议需要多个方法,那么不要直接为协议添加方法,最好衍生原协议,创建一个新协议。在 Python 中,扩展静态协议有几个问题需要注意

1
2
3
4
5
6
from typing import Protocol, runtime_checkable
from randompick import RandomPicker

@runtime_checkable
class LoadableRandomPicker(RandomPicker, Protocol):
def load(self, Iterable) -> None: ...
  • 如果希望衍生的协议可在运行时检查,则必须再次应用这个装饰器,因为该装饰器的行为不被继承
  • 每个协议都必须明确把 typing.Protocol 列出来,作为基类。另外,再列出要扩展的协议。这与 Python 中的继承不是一回事
  • 只需要声明衍生协议新增的方法(类似于常规的面对对象编程方式)

小结

在现代的 Python 中,我们有 4 种互补的接口编程方法,它们各有优缺点。对于现代的 Python 基准代码,只要体量够大,4 种类型模式都有用武之地。抛下哪一种类型,作为 Python 程序员,你的日子都不会好过。