第十三章:接口、协议和 ABCs
针对接口编程,而不是实现。
Gamma、Helm、Johnson、Vlissides,《面向对象设计的第一原则》¹
面向对象编程关乎接口。在 Python 中理解类型的最佳方法是了解它提供的方法——即其接口——如 “类型由支持的操作定义”(第八章)中所讨论的。
根据编程语言的不同,我们有一种或多种定义和使用接口的方式。自 Python 3.8 起,我们有四种方式。它们在 类型映射(图 13-1)中有所描述。我们可以总结如下:
鸭子类型
Python 从一开始就采用的类型化方法。我们从 第一章 开始学习鸭子类型。
鹅式类型
自 Python 2.6 起由抽象基类(ABCs)支持的方法,依赖于对象与 ABCs 的运行时检查。鹅式类型 是本章的一个重要主题。
静态类型
类似 C 和 Java 这样的静态类型语言的传统方法;自 Python 3.5 起由 typing
模块支持,并由符合 PEP 484—类型提示 的外部类型检查器强制执行。这不是本章的主题。第八章的大部分内容以及即将到来的 第十五章 关于静态类型。
静态鸭子类型
由 Go 语言推广的一种方法;由 typing.Protocol
的子类支持——Python 3.8 中新增——也由外部类型检查器强制执行。我们首次在 “静态协议”(第八章)中看到这一点。
类型映射
图 13-1 中描述的四种类型化方法是互补的:它们各有优缺点。不应该否定其中任何一种。
图 13-1。上半部分描述了仅使用 Python 解释器进行运行时类型检查的方法;下半部分需要外部静态类型检查器,如 MyPy 或 PyCharm 这样的 IDE。左侧象限涵盖基于对象结构的类型化——即对象提供的方法,而不考虑其类或超类的名称;右侧象限依赖于对象具有明确定义的类型:对象的类名或其超类的名称。
这四种方法都依赖于接口来工作,但静态类型可以通过仅使用具体类型而不是接口抽象,如协议和抽象基类,来实现——这样做效果不佳。本章讨论了鸭子类型、鹅式类型和静态鸭子类型——围绕接口展开的类型学科。
本章分为四个主要部分,涵盖了类型映射中四个象限中的三个:图 13-1。
- “两种类型协议” 比较了两种结构类型与协议的形式——即类型映射的左侧。
- “编程鸭子” 深入探讨了 Python 的常规鸭子类型,包括如何使其更安全,同时保持其主要优势:灵活性。
- “鹅式类型” 解释了使用 ABCs 进行更严格的运行时类型检查。这是最长的部分,不是因为它更重要,而是因为书中其他地方有更多关于鸭子类型、静态鸭子类型和静态类型的部分。
- “静态协议” 涵盖了
typing.Protocol
子类的用法、实现和设计——对于静态和运行时类型检查很有用。
本章的新内容
本章经过大幅编辑,比第一版《流畅的 Python》中对应的第十一章长约 24%。虽然有些部分和许多段落是相同的,但也有很多新内容。以下是亮点:
- 本章的介绍和类型映射(图 13-1)是新内容。这是本章和所有涉及 Python ≥ 3.8 中类型的其他章节中大部分新内容的关键。
- “两种类型的协议”解释了动态协议和静态协议之间的相似之处和不同之处。
- “防御性编程和‘快速失败’” 主要复制了第一版的内容,但进行了更新,现在有一个部分标题以突出其重要性。
- “静态协议”是全新的。它在“静态协议”(第八章)的初始介绍基础上进行了扩展。
- 在图 13-2、13-3 和 13-4 中更新了
collections.abc
的类图,包括 Python 3.6 中的Collection
ABC。
《流畅的 Python》第一版中有一节鼓励使用numbers
ABCs 进行鹅式类型化。在“数字 ABC 和数值协议”中,我解释了为什么如果您计划同时使用静态类型检查器和鹅式类型检查器的运行时检查,应该使用typing
模块中的数值静态协议。
两种类型的协议
根据上下文,计算机科学中的“协议”一词有不同的含义。诸如 HTTP 之类的网络协议指定了客户端可以发送给服务器的命令,例如GET
、PUT
和HEAD
。我们在“协议和鸭子类型”中看到,对象协议指定了对象必须提供的方法以履行某种角色。第一章中的FrenchDeck
示例演示了一个对象协议,即序列协议:允许 Python 对象表现为序列的方法。
实现完整的协议可能需要多个方法,但通常只实现部分也是可以的。考虑一下示例 13-1 中的Vowels
类。
示例 13-1。使用__getitem__
部分实现序列协议
>>> class Vowels: ... def __getitem__(self, i): ... return 'AEIOU'[i] ... >>> v = Vowels() >>> v[0] 'A' >>> v[-1] 'U' >>> for c in v: print(c) ... A E I O U >>> 'E' in v True >>> 'Z' in v False
实现__getitem__
足以允许按索引检索项目,并支持迭代和in
运算符。__getitem__
特殊方法实际上是序列协议的关键。查看Python/C API 参考手册中的这篇文章,“序列协议”部分。
int PySequence_Check(PyObject *o)
如果对象提供序列协议,则返回1
,否则返回0
。请注意,对于具有__getitem__()
方法的 Python 类,除非它们是dict
子类[…],否则它将返回1
。
我们期望序列还支持len()
,通过实现__len__
来实现。Vowels
没有__len__
方法,但在某些情况下仍然表现为序列。这对我们的目的可能已经足够了。这就是为什么我喜欢说协议是一种“非正式接口”。这也是 Smalltalk 中对协议的理解方式,这是第一个使用该术语的面向对象编程环境。
除了关于网络编程的页面外,Python 文档中“协议”一词的大多数用法指的是这些非正式接口。
现在,随着 Python 3.8 中采纳了PEP 544—协议:结构子类型(静态鸭子类型),在 Python 中,“协议”一词有了另一层含义——与之密切相关,但又不同。正如我们在“静态协议”(第八章)中看到的,PEP 544 允许我们创建typing.Protocol
的子类来定义一个或多个类必须实现(或继承)以满足静态类型检查器的方法。
当我需要具体说明时,我会采用这些术语:
动态协议
Python 一直拥有的非正式协议。动态协议是隐式的,按照约定定义,并在文档中描述。Python 最重要的动态协议由解释器本身支持,并在《Python 语言参考》的“数据模型”章节中有详细说明。
静态协议
由 PEP 544—协议:结构子类型(静态鸭子类型) 定义的协议,自 Python 3.8 起。静态协议有明确的定义:typing.Protocol
的子类。
它们之间有两个关键区别:
- 一个对象可能只实现动态协议的一部分仍然是有用的;但为了满足静态协议,对象必须提供协议类中声明的每个方法,即使你的程序并不需要它们。
- 静态协议可以被静态类型检查器验证,但动态协议不能。
这两种协议共享一个重要特征,即类永远不需要声明支持某个协议,即通过继承来支持。
除了静态协议,Python 还提供了另一种在代码中定义显式接口的方式:抽象基类(ABC)。
本章的其余部分涵盖了动态和静态协议,以及 ABC。
编程鸭
让我们从 Python 中两个最重要的动态协议开始讨论:序列和可迭代协议。解释器会尽最大努力处理提供了即使是最简单实现的对象,下一节将解释这一点。
Python 探究序列
Python 数据模型的哲学是尽可能与基本的动态协议合作。在处理序列时,Python 会尽最大努力与即使是最简单的实现一起工作。
图 13-2 显示了 Sequence
接口如何被正式化为一个 ABC。Python 解释器和内置序列如 list
、str
等根本不依赖于该 ABC。我只是用它来描述一个完整的 Sequence
预期支持的内容。
图 13-2. Sequence
ABC 和 collections.abc
中相关抽象类的 UML 类图。继承箭头从子类指向其超类。斜体字体的名称是抽象方法。在 Python 3.6 之前,没有 Collection
ABC——Sequence
是 Container
、Iterable
和 Sized
的直接子类。
提示
collections.abc
模块中的大多数 ABC 存在的目的是为了正式化由内置对象实现并被解释器隐式支持的接口——这两者都早于 ABC 本身。这些 ABC 对于新类是有用的起点,并支持运行时的显式类型检查(又称为 鹅式类型化)以及静态类型检查器的类型提示。
研究 图 13-2,我们看到 Sequence
的正确子类必须实现 __getitem__
和 __len__
(来自 Sized
)。Sequence
中的所有其他方法都是具体的,因此子类可以继承它们的实现——或提供更好的实现。
现在回想一下 示例 13-1 中的 Vowels
类。它没有继承自 abc.Sequence
,只实现了 __getitem__
。
没有 __iter__
方法,但 Vowels
实例是可迭代的,因为——作为后备——如果 Python 发现 __getitem__
方法,它会尝试通过调用从 0
开始的整数索引的方法来迭代对象。因为 Python 足够聪明以迭代 Vowels
实例,所以即使缺少 __contains__
方法,它也可以使 in
运算符正常工作:它会进行顺序扫描以检查项目是否存在。
总结一下,鉴于类似序列的数据结构的重要性,Python 通过在 __iter__
和 __contains__
不可用时调用 __getitem__
来使迭代和 in
运算符正常工作。
第一章中的原始FrenchDeck
也没有继承abc.Sequence
,但它实现了序列协议的两种方法:__getitem__
和__len__
。参见示例 13-2。
示例 13-2。一叠卡片的序列(与示例 1-1 相同)
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):
第一章中的几个示例之所以有效,是因为 Python 对任何类似序列的东西都给予了特殊处理。Python 中的可迭代协议代表了鸭子类型的极端形式:解释器尝试两种不同的方法来迭代对象。
为了明确起见,我在本节中描述的行为是在解释器本身中实现的,主要是用 C 语言编写的。它们不依赖于Sequence
ABC 中的方法。例如,Sequence
类中的具体方法__iter__
和__contains__
模拟了 Python 解释器的内置行为。如果你感兴趣,请查看Lib/_collections_abc.py中这些方法的源代码。
现在让我们研究另一个例子,强调协议的动态性,以及为什么静态类型检查器无法处理它们。
Monkey Patching:在运行时实现协议
Monkey patching 是在运行时动态更改模块、类或函数,以添加功能或修复错误。例如,gevent 网络库对 Python 的标准库的部分进行了 monkey patching,以允许轻量级并发而无需线程或async
/await
。²
来自示例 13-2 的FrenchDeck
类缺少一个重要特性:它无法被洗牌。几年前,当我第一次编写FrenchDeck
示例时,我确实实现了一个shuffle
方法。后来我有了一个 Pythonic 的想法:如果FrenchDeck
像一个序列一样工作,那么它就不需要自己的shuffle
方法,因为已经有了random.shuffle
,文档中描述为“原地洗牌序列x”。
标准的random.shuffle
函数的使用方式如下:
>>> from random import shuffle >>> l = list(range(10)) >>> shuffle(l) >>> l [5, 2, 9, 7, 8, 3, 1, 4, 0, 6]
提示
当遵循已建立的协议时,你提高了利用现有标准库和第三方代码的机会,这要归功于鸭子类型。
然而,如果我们尝试对FrenchDeck
实例进行洗牌,就会出现异常,就像示例 13-3 中一样。
示例 13-3。random.shuffle
无法处理FrenchDeck
>>> from random import shuffle >>> from frenchdeck import FrenchDeck >>> deck = FrenchDeck() >>> shuffle(deck) Traceback (most recent call last): File "<stdin>", line 1, in <module> File ".../random.py", line 265, in shuffle x[i], x[j] = x[j], x[i] TypeError: 'FrenchDeck' object does not support item assignment
错误消息很明确:'FrenchDeck'对象不支持项目赋值
。问题在于shuffle
是原地操作,通过在集合内部交换项目,而FrenchDeck
只实现了不可变序列协议。可变序列还必须提供__setitem__
方法。
因为 Python 是动态的,我们可以在运行时修复这个问题,甚至在交互式控制台中也可以。示例 13-4 展示了如何做到这一点。
示例 13-4。Monkey patching FrenchDeck
使其可变并与random.shuffle
兼容(继续自示例 13-3)
>>> def set_card(deck, position, card): # ① ... deck._cards[position] = card ... >>> FrenchDeck.__setitem__ = set_card # ② >>> shuffle(deck) # ③ >>> deck[:5] [Card(rank='3', suit='hearts'), Card(rank='4', suit='diamonds'), Card(rank='4', suit='clubs'), Card(rank='7', suit='hearts'), Card(rank='9', suit='spades')]
①
创建一个以deck
, position
, 和card
为参数的函数。
②
将该函数分配给FrenchDeck
类中名为__setitem__
的属性。
③
现在deck
可以被洗牌了,因为我添加了可变序列协议的必要方法。
__setitem__
特殊方法的签名在Python 语言参考中的“3.3.6. 模拟容器类型”中定义。这里我将参数命名为deck, position, card
,而不是语言参考中的self, key, value
,以显示每个 Python 方法都是作为普通函数开始的,将第一个参数命名为self
只是一种约定。在控制台会话中这样做没问题,但在 Python 源文件中最好使用文档中记录的self
, key
, 和value
。
诀窍在于set_card
知道deck
对象有一个名为_cards
的属性,而_cards
必须是一个可变序列。然后,set_card
函数被附加到FrenchDeck
类作为__setitem__
特殊方法。这是猴子补丁的一个例子:在运行时更改类或模块,而不触及源代码。猴子补丁很强大,但实际打补丁的代码与要打补丁的程序非常紧密耦合,通常处理私有和未记录的属性。
除了是猴子补丁的一个例子,示例 13-4 突显了动态鸭子类型协议的动态性:random.shuffle
不关心参数的类,它只需要对象实现可变序列协议的方法。甚至不用在意对象是否“出生”时就具有必要的方法,或者后来某种方式获得了这些方法。
鸭子类型不需要非常不安全或难以调试。下一节将展示一些有用的代码模式,以检测动态协议,而不需要显式检查。
防御性编程和“快速失败”
防御性编程就像防御性驾驶:一套增强安全性的实践,即使面对粗心的程序员或驾驶员。
许多错误只能在运行时捕获——即使在主流的静态类型语言中也是如此。³在动态类型语言中,“快速失败”是更安全、更易于维护的程序的极好建议。快速失败意味着尽快引发运行时错误,例如,在函数体的开头立即拒绝无效参数。
这里有一个例子:当你编写接受要在内部处理的项目序列的代码时,不要通过类型检查强制要求一个list
参数。相反,接受参数并立即从中构建一个list
。这种代码模式的一个例子是本章后面的示例 13-10 中的__init__
方法:
def __init__(self, iterable): self._balls = list(iterable)
这样可以使你的代码更灵活,因为list()
构造函数处理任何适合内存的可迭代对象。如果参数不可迭代,调用将立即失败,并显示一个非常清晰的TypeError
异常,就在对象初始化时。如果想更明确,可以用try/except
包装list()
调用以自定义错误消息——但我只会在外部 API 上使用这些额外的代码,因为问题对于代码库的维护者来说很容易看到。无论哪种方式,有问题的调用将出现在回溯的最后,使得修复问题变得直截了当。如果在类构造函数中没有捕获无效参数,程序将在稍后的某个时刻崩溃,当类的其他方法需要操作self._balls
时,而它不是一个list
。那么根本原因将更难找到。
当数据不应该被复制时,例如因为数据太大或者函数设计需要在原地更改数据以使调用者受益时,调用list()
会很糟糕。在这种情况下,像isinstance(x, abc.MutableSequence)
这样的运行时检查将是一个好方法。
如果你担心得到一个无限生成器——这不是一个常见问题——你可以先调用len()
来检查参数。这将拒绝迭代器,同时安全地处理元组、数组和其他现有或将来完全实现Sequence
接口的类。调用len()
通常非常便宜,而无效的参数将立即引发错误。
另一方面,如果任何可迭代对象都可以接受,那么尽快调用iter(x)
以获得一个迭代器,正如我们将在“为什么序列可迭代:iter 函数”中看到的。同样,如果x
不可迭代,这将快速失败,并显示一个易于调试的异常。
在我刚刚描述的情况下,类型提示可以更早地捕捉一些问题,但并非所有问题。请记住,类型Any
与其他任何类型都是一致的。类型推断可能导致变量被标记为Any
类型。当发生这种情况时,类型检查器就会一头雾水。此外,类型提示在运行时不会被强制执行。快速失败是最后的防线。
利用鸭子类型的防御性代码也可以包含处理不同类型的逻辑,而无需使用isinstance()
或hasattr()
测试。
一个例子是我们如何模拟collections.namedtuple
中的field_names
参数处理:field_names
接受一个由空格或逗号分隔的标识符组成的单个字符串,或者一组标识符。示例 13-5 展示了我如何使用鸭子类型来实现它。
示例 13-5. 鸭子类型处理字符串或字符串可迭代对象
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 = 宁愿请求原谅,也不要事先获准)。
②
将逗号转换为空格并将结果拆分为名称列表。
③
抱歉,field_names
不像一个str
那样嘎嘎叫:它没有.replace
,或者返回我们无法.split
的东西。
④
如果引发了AttributeError
,那么field_names
不是一个str
,我们假设它已经是一个名称的可迭代对象。
⑤
为了确保它是可迭代的并保留我们自己的副本,将我们拥有的内容创建为一个元组。tuple
比list
更紧凑,还可以防止我的代码误改名称。
⑥
使用str.isidentifier
来确保每个名称都是有效的。
示例 13-5 展示了一种情况,鸭子类型比静态类型提示更具表现力。没有办法拼写一个类型提示,说“field_names
必须是由空格或逗号分隔的标识符字符串”。这是namedtuple
在 typeshed 上的签名的相关部分(请查看stdlib/3/collections/init.pyi的完整源代码):
def namedtuple( typename: str, field_names: Union[str, Iterable[str]], *, # rest of signature omitted
如您所见,field_names
被注释为Union[str, Iterable[str]]
,就目前而言是可以的,但不足以捕捉所有可能的问题。
在审查动态协议后,我们转向更明确的运行时类型检查形式:鹅式类型检查。
鹅式类型检查
抽象类代表一个接口。
C++的创始人 Bjarne Stroustrup⁴
Python 没有interface
关键字。我们使用抽象基类(ABCs)来定义接口,以便在运行时进行显式类型检查,同时也受到静态类型检查器的支持。
Python 术语表中关于抽象基类的条目对它们为鸭子类型语言带来的价值有很好的解释:
抽象基类通过提供一种定义接口的方式来补充鸭子类型,当其他技术(如
hasattr()
)显得笨拙或微妙错误时(例如,使用魔术方法)。ABCs 引入虚拟子类,这些子类不继承自一个类,但仍然被isinstance()
和issubclass()
所识别;请参阅abc
模块文档。⁵
鹅式类型检查是一种利用 ABCs 的运行时类型检查方法。我将让 Alex Martelli 在“水禽和 ABCs”中解释。
注
我非常感谢我的朋友 Alex Martelli 和 Anna Ravenscroft。我在 2013 年的 OSCON 上向他们展示了Fluent Python的第一个大纲,他们鼓励我将其提交给 O’Reilly 出版。两人后来进行了彻底的技术审查。Alex 已经是本书中被引用最多的人,然后他提出要写这篇文章。请开始,Alex!
总结一下,鹅打字包括:
- 从 ABC 继承以明确表明你正在实现先前定义的接口。
- 运行时使用 ABC 而不是具体类作为
isinstance
和issubclass
的第二个参数进行类型检查。
Alex 指出,从 ABC 继承不仅仅是实现所需的方法:开发人员的意图也是明确声明的。这种意图也可以通过注册虚拟子类来明确表示。
注意
使用register
的详细信息在“ABC 的虚拟子类”中有介绍,本章后面会详细介绍。现在,这里是一个简短的示例:给定FrenchDeck
类,如果我希望它通过类似issubclass(FrenchDeck, Sequence)
的检查,我可以通过以下代码将其作为Sequence
ABC 的虚拟子类:
from collections.abc import Sequence Sequence.register(FrenchDeck)
如果你检查 ABC 而不是具体类,那么使用isinstance
和issubclass
会更加可接受。如果与具体类一起使用,类型检查会限制多态性——这是面向对象编程的一个重要特征。但是对于 ABCs,这些测试更加灵活。毕竟,如果一个组件没有通过子类化实现 ABC,但确实实现了所需的方法,那么它总是可以在事后注册,以便通过这些显式类型检查。
然而,即使使用 ABCs,你也应该注意,过度使用isinstance
检查可能是代码异味的表现——这是 OO 设计不佳的症状。
通常情况下,使用isinstance
检查的if/elif/elif
链执行不同操作取决于对象类型通常是不可以的:你应该使用多态性来实现这一点——即,设计你的类使解释器分派调用到正确的方法,而不是在if/elif/elif
块中硬编码分派逻辑。
另一方面,如果必须强制执行 API 契约,则对 ABC 执行isinstance
检查是可以的:“伙计,如果你想调用我,你必须实现这个”,正如技术审查员 Lennart Regebro 所说。这在具有插件架构的系统中特别有用。在框架之外,鸭子类型通常比类型检查更简单、更灵活。
最后,在他的文章中,Alex 多次强调了在创建 ABCs 时需要克制的必要性。过度使用 ABCs 会在一门因其实用性和实用性而流行的语言中引入仪式感。在流畅的 Python审查过程中,Alex 在一封电子邮件中写道:
ABCs 旨在封装由框架引入的非常一般的概念、抽象概念——诸如“一个序列”和“一个确切的数字”。[读者]很可能不需要编写任何新的 ABCs,只需正确使用现有的 ABCs,就可以获得 99.9%的好处,而不会严重风险设计错误。
现在让我们看看鹅打字的实践。
从 ABC 继承
遵循 Martelli 的建议,在大胆发明自己之前,我们将利用现有的 ABC,collections.MutableSequence
。在示例 13-6 中,FrenchDeck2
明确声明为collections.MutableSequence
的子类。
示例 13-6. frenchdeck2.py:FrenchDeck2
,collections.MutableSequence
的子类
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__
,该 ABC 的一个抽象方法。
③
我们还需要实现insert
,MutableSequence
的第三个抽象方法。
Python 在导入时不会检查抽象方法的实现(当加载和编译 frenchdeck2.py 模块时),而是在运行时当我们尝试实例化 FrenchDeck2
时才会检查。然后,如果我们未实现任何抽象方法,我们将收到一个 TypeError
异常,其中包含类似于 "Can't instantiate`` abstract class FrenchDeck2 with abstract methods __delitem__, insert"
的消息。这就是为什么我们必须实现 __delitem__
和 insert
,即使我们的 FrenchDeck2
示例不需要这些行为:因为 MutableSequence
ABC 要求它们。
如 图 13-3 所示,Sequence
和 MutableSequence
ABCs 中并非所有方法都是抽象的。
图 13-3. MutableSequence
ABC 及其来自 collections.abc
的超类的 UML 类图(继承箭头从子类指向祖先类;斜体名称是抽象类和抽象方法)。
要将 FrenchDeck2
写为 MutableSequence
的子类,我必须付出实现 __delitem__
和 insert
的代价,而我的示例并不需要这些。作为回报,FrenchDeck2
从 Sequence
继承了五个具体方法:__contains__
, __iter__
, __reversed__
, index
, 和 count
。从 MutableSequence
中,它还获得了另外六个方法:append
, reverse
, extend
, pop
, remove
, 和 __iadd__
—它支持用于原地连接的 +=
运算符。
每个 collections.abc
ABC 中的具体方法都是根据类的公共接口实现的,因此它们可以在不了解实例内部结构的情况下工作。
提示
作为具体子类的编码者,您可能能够用更高效的实现覆盖从 ABCs 继承的方法。例如,__contains__
通过对序列进行顺序扫描来工作,但如果您的具体序列保持其项目排序,您可以编写一个更快的 __contains__
,它使用标准库中的 bisect
函数进行二分搜索。请查看 fluentpython.com 上的 “使用 Bisect 管理有序序列” 了解更多信息。
要很好地使用 ABCs,您需要了解可用的内容。接下来我们将回顾 collections
中的 ABCs。
标准库中的 ABCs
自 Python 2.6 起,标准库提供了几个 ABCs。大多数在 collections.abc
模块中定义,但也有其他的。例如,您可以在 io
和 numbers
包中找到 ABCs。但最常用的在 collections.abc
中。
提示
标准库中有两个名为 abc
的模块。这里我们谈论的是 collections.abc
。为了减少加载时间,自 Python 3.4 起,该模块是在 collections
包之外实现的—在 Lib/_collections_abc.py—因此它是单独从 collections
导入的。另一个 abc
模块只是 abc
(即 Lib/abc.py),其中定义了 abc.ABC
类。每个 ABC 都依赖于 abc
模块,但我们不需要自己导入它,除非要创建全新的 ABC。
图 13-4 是在 collections.abc
中定义的 17 个 ABCs 的摘要 UML 类图(不包括属性名称)。collections.abc
的文档中有一个很好的表格总结了这些 ABCs,它们之间的关系以及它们的抽象和具体方法(称为“mixin 方法”)。在 图 13-4 中有大量的多重继承。我们将在 第十四章 中专门讨论多重继承,但现在只需说当涉及到 ABCs 时,通常不是问题。⁷
图 13-4. collections.abc
中 ABCs 的 UML 类图。
让我们回顾一下 图 13-4 中的聚类:
Iterable
, Container
, Sized
每个集合应该继承这些 ABC 或实现兼容的协议。Iterable
支持 __iter__
迭代,Container
支持 __contains__
中的 in
运算符,Sized
支持 __len__
中的 len()
。
Collection
这个 ABC 没有自己的方法,但在 Python 3.6 中添加了它,以便更容易从 Iterable
、Container
和 Sized
继承。
Sequence
、Mapping
、Set
这些是主要的不可变集合类型,每种类型都有一个可变的子类。MutableSequence
的详细图表在 图 13-3 中;对于 MutableMapping
和 MutableSet
,请参见 第三章 中的图 3-1 和 3-2。
MappingView
在 Python 3 中,从映射方法 .items()
、.keys()
和 .values()
返回的对象分别实现了 ItemsView
、KeysView
和 ValuesView
中定义的接口。前两者还实现了 Set
的丰富接口,其中包含我们在 “集合操作” 中看到的所有运算符。
Iterator
请注意,迭代器子类 Iterable
。我们在 第十七章 中进一步讨论这一点。
Callable
、Hashable
这些不是集合,但 collections.abc
是第一个在标准库中定义 ABC 的包,这两个被认为是足够重要以被包含在内。它们支持对必须是可调用或可哈希的对象进行类型检查。
对于可调用检测,内置函数 callable(obj)
比 insinstance(obj, Callable)
更方便。
如果 insinstance(obj, Hashable)
返回 False
,则可以确定 obj
不可哈希。但如果返回值为 True
,可能是一个误报。下一个框解释了这一点。
在查看一些现有的 ABC 后,让我们通过从头开始实现一个 ABC 并将其投入使用来练习鹅子打字。这里的目标不是鼓励每个人开始左右创建 ABC,而是学习如何阅读标准库和其他包中找到的 ABC 的源代码。
定义和使用 ABC
这个警告出现在第一版 Fluent Python 的“接口”章节中:
ABC,就像描述符和元类一样,是构建框架的工具。因此,只有少数 Python 开发人员可以创建 ABC,而不会对其他程序员施加不合理的限制和不必要的工作。
现在 ABC 在类型提示中有更多潜在用途,以支持静态类型。如 “抽象基类” 中所讨论的,使用 ABC 而不是具体类型在函数参数类型提示中给调用者更多的灵活性。
为了证明创建一个 ABC 的合理性,我们需要为在框架中使用它作为扩展点提供一个上下文。因此,这是我们的背景:想象一下你需要在网站或移动应用程序上以随机顺序显示广告,但在显示完整广告库之前不重复显示广告。现在让我们假设我们正在构建一个名为 ADAM
的广告管理框架。其要求之一是支持用户提供的非重复随机选择类。⁸ 为了让 ADAM
用户清楚地知道“非重复随机选择”组件的期望,我们将定义一个 ABC。
在关于数据结构的文献中,“栈”和“队列”描述了抽象接口,以物理对象的实际排列为基础。我将效仿并使用一个现实世界的隐喻来命名我们的 ABC:宾果笼和彩票吹风机是设计用来从有限集合中随机挑选项目,直到集合耗尽而不重复的机器。
ABC 将被命名为 Tombola
,以宾果的意大利名称和混合数字的翻转容器命名。
Tombola
ABC 有四个方法。两个抽象方法是:
.load(…)
将项目放入容器中。
.pick()
从容器中随机移除一个项目,并返回它。
具体方法是:
.loaded()
如果容器中至少有一个项目,则返回True
。
.inspect()
返回一个从容器中当前项目构建的tuple
,而不更改其内容(内部排序不保留)。
图 13-5 展示了Tombola
ABC 和三个具体实现。
图 13-5. ABC 和三个子类的 UML 图。Tombola
ABC 的名称和其抽象方法以斜体书写,符合 UML 约定。虚线箭头用于接口实现——这里我用它来显示TomboList
不仅实现了Tombola
接口,而且还注册为Tombola
的虚拟子类—正如我们将在本章后面看到的。⁹
示例 13-7 展示了Tombola
ABC 的定义。
示例 13-7. tombola.py:Tombola
是一个具有两个抽象方法和两个具体方法的 ABC
import abc class Tombola(abc.ABC): # ① @abc.abstractmethod def load(self, iterable): # ② """Add items from an iterable.""" @abc.abstractmethod def pick(self): # ③ """Remove item at random, returning it. This method should raise `LookupError` when the instance is empty. """ def loaded(self): # ④ """Return `True` if there's at least 1 item, `False` otherwise.""" return bool(self.inspect()) # ⑤ def inspect(self): """Return a sorted tuple with the items currently inside.""" items = [] while True: # ⑥ try: items.append(self.pick()) except LookupError: break self.load(items) # ⑦ return tuple(items)
①
要定义一个 ABC,需要继承abc.ABC
。
②
抽象方法使用@abstractmethod
装饰器标记,通常其主体除了文档字符串外是空的。¹⁰
③
文档字符串指示实现者在没有项目可挑选时引发LookupError
。
④
一个 ABC 可以包含具体方法。
⑤
ABC 中的具体方法必须仅依赖于 ABC 定义的接口(即 ABC 的其他具体或抽象方法或属性)。
⑥
我们无法知道具体子类将如何存储项目,但我们可以通过连续调用.pick()
来构建inspect
结果来清空Tombola
…
⑦
…然后使用.load(…)
将所有东西放回去。
提示
抽象方法实际上可以有一个实现。即使有,子类仍将被强制重写它,但他们可以使用super()
调用抽象方法,为其添加功能而不是从头开始实现。有关@abstractmethod
用法的详细信息,请参阅abc
模块文档。
.inspect()
方法的代码在示例 13-7 中有些愚蠢,但它表明我们可以依赖.pick()
和.load(…)
来检查Tombola
中的内容——通过挑选所有项目并将它们加载回去,而不知道项目实际上是如何存储的。这个示例的重点是强调在抽象基类(ABCs)中提供具体方法是可以的,只要它们仅依赖于接口中的其他方法。了解它们的内部数据结构后,Tombola
的具体子类可以始终用更智能的实现覆盖.inspect()
,但他们不必这样做。
示例 13-7 中的.loaded()
方法只有一行,但很昂贵:它调用.inspect()
来构建tuple
,然后对其应用bool()
。这样做是有效的,但具体子类可以做得更好,我们将看到。
注意,我们对.inspect()
的绕道实现要求我们捕获self.pick()
抛出的LookupError
。self.pick()
可能引发LookupError
也是其接口的一部分,但在 Python 中无法明确表示这一点,除非在文档中(参见示例 13-7 中抽象pick
方法的文档字符串)。
我选择了LookupError
异常,因为它在 Python 异常层次结构中与IndexError
和KeyError
的关系,这是实现具体Tombola
时最有可能引发的异常。因此,实现可以引发LookupError
、IndexError
、KeyError
或LookupError
的自定义子类以符合要求。参见图 13-6。
流畅的 Python 第二版(GPT 重译)(七)(2)https://developer.aliyun.com/article/1484635