流畅的 Python 第二版(GPT 重译)(七)(1)

简介: 流畅的 Python 第二版(GPT 重译)(七)

第十三章:接口、协议和 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 之类的网络协议指定了客户端可以发送给服务器的命令,例如GETPUTHEAD。我们在“协议和鸭子类型”中看到,对象协议指定了对象必须提供的方法以履行某种角色。第一章中的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 解释器和内置序列如 liststr 等根本不依赖于该 ABC。我只是用它来描述一个完整的 Sequence 预期支持的内容。

图 13-2. Sequence ABC 和 collections.abc 中相关抽象类的 UML 类图。继承箭头从子类指向其超类。斜体字体的名称是抽象方法。在 Python 3.6 之前,没有 Collection ABC——SequenceContainerIterableSized 的直接子类。
提示

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,我们假设它已经是一个名称的可迭代对象。

为了确保它是可迭代的并保留我们自己的副本,将我们拥有的内容创建为一个元组。tuplelist更紧凑,还可以防止我的代码误改名称。

使用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 而不是具体类作为isinstanceissubclass的第二个参数进行类型检查。

Alex 指出,从 ABC 继承不仅仅是实现所需的方法:开发人员的意图也是明确声明的。这种意图也可以通过注册虚拟子类来明确表示。

注意

使用register的详细信息在“ABC 的虚拟子类”中有介绍,本章后面会详细介绍。现在,这里是一个简短的示例:给定FrenchDeck类,如果我希望它通过类似issubclass(FrenchDeck, Sequence)的检查,我可以通过以下代码将其作为Sequence ABC 的虚拟子类

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

如果你检查 ABC 而不是具体类,那么使用isinstanceissubclass会更加可接受。如果与具体类一起使用,类型检查会限制多态性——这是面向对象编程的一个重要特征。但是对于 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:FrenchDeck2collections.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 的一个抽象方法。

我们还需要实现insertMutableSequence的第三个抽象方法。

Python 在导入时不会检查抽象方法的实现(当加载和编译 frenchdeck2.py 模块时),而是在运行时当我们尝试实例化 FrenchDeck2 时才会检查。然后,如果我们未实现任何抽象方法,我们将收到一个 TypeError 异常,其中包含类似于 "Can't instantiate`` abstract class FrenchDeck2 with abstract methods __delitem__, insert" 的消息。这就是为什么我们必须实现 __delitem__insert,即使我们的 FrenchDeck2 示例不需要这些行为:因为 MutableSequence ABC 要求它们。

如 图 13-3 所示,SequenceMutableSequence ABCs 中并非所有方法都是抽象的。

图 13-3. MutableSequence ABC 及其来自 collections.abc 的超类的 UML 类图(继承箭头从子类指向祖先类;斜体名称是抽象类和抽象方法)。

要将 FrenchDeck2 写为 MutableSequence 的子类,我必须付出实现 __delitem__insert 的代价,而我的示例并不需要这些。作为回报,FrenchDeck2Sequence 继承了五个具体方法:__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 模块中定义,但也有其他的。例如,您可以在 ionumbers 包中找到 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 中添加了它,以便更容易从 IterableContainerSized 继承。

SequenceMappingSet

这些是主要的不可变集合类型,每种类型都有一个可变的子类。MutableSequence 的详细图表在 图 13-3 中;对于 MutableMappingMutableSet,请参见 第三章 中的图 3-1 和 3-2。

MappingView

在 Python 3 中,从映射方法 .items().keys().values() 返回的对象分别实现了 ItemsViewKeysViewValuesView 中定义的接口。前两者还实现了 Set 的丰富接口,其中包含我们在 “集合操作” 中看到的所有运算符。

Iterator

请注意,迭代器子类 Iterable。我们在 第十七章 中进一步讨论这一点。

CallableHashable

这些不是集合,但 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()抛出的LookupErrorself.pick()可能引发LookupError也是其接口的一部分,但在 Python 中无法明确表示这一点,除非在文档中(参见示例 13-7 中抽象pick方法的文档字符串)。

我选择了LookupError异常,因为它在 Python 异常层次结构中与IndexErrorKeyError的关系,这是实现具体Tombola时最有可能引发的异常。因此,实现可以引发LookupErrorIndexErrorKeyErrorLookupError的自定义子类以符合要求。参见图 13-6。

流畅的 Python 第二版(GPT 重译)(七)(2)https://developer.aliyun.com/article/1484635

相关文章
|
4月前
|
存储 NoSQL 索引
Python 金融编程第二版(GPT 重译)(一)(4)
Python 金融编程第二版(GPT 重译)(一)
61 2
|
3月前
|
人工智能 API Python
Openai python调用gpt测试代码
这篇文章提供了使用OpenAI的Python库调用GPT-4模型进行聊天的测试代码示例,包括如何设置API密钥、发送消息并接收AI回复。
|
4月前
|
存储 算法 数据可视化
Python 金融编程第二版(GPT 重译)(一)(1)
Python 金融编程第二版(GPT 重译)(一)
89 1
|
4月前
|
数据库 开发者 Python
异步编程不再难!Python asyncio库实战,让你的代码流畅如丝!
【7月更文挑战第10天】Python的asyncio库简化了异步编程,提高并发处理能力。async定义异步函数,await等待结果而不阻塞。示例展示了如何用aiohttp进行异步HTTP请求及使用asyncio.gather并发处理任务。通过asyncio,Python开发者能更高效地处理网络I/O和其他并发场景。开始探索异步编程,提升代码效率!**
60 0
|
4月前
|
存储 算法 数据建模
Python 金融编程第二版(GPT 重译)(一)(5)
Python 金融编程第二版(GPT 重译)(一)
34 0
|
4月前
|
安全 Shell 网络安全
Python 金融编程第二版(GPT 重译)(一)(3)
Python 金融编程第二版(GPT 重译)(一)
25 0
|
4月前
|
算法 Linux Docker
Python 金融编程第二版(GPT 重译)(一)(2)
Python 金融编程第二版(GPT 重译)(一)
46 0
|
6天前
|
机器学习/深度学习 人工智能 TensorFlow
人工智能浪潮下的自我修养:从Python编程入门到深度学习实践
【10月更文挑战第39天】本文旨在为初学者提供一条清晰的道路,从Python基础语法的掌握到深度学习领域的探索。我们将通过简明扼要的语言和实际代码示例,引导读者逐步构建起对人工智能技术的理解和应用能力。文章不仅涵盖Python编程的基础,还将深入探讨深度学习的核心概念、工具和实战技巧,帮助读者在AI的浪潮中找到自己的位置。
|
6天前
|
机器学习/深度学习 数据挖掘 Python
Python编程入门——从零开始构建你的第一个程序
【10月更文挑战第39天】本文将带你走进Python的世界,通过简单易懂的语言和实际的代码示例,让你快速掌握Python的基础语法。无论你是编程新手还是想学习新语言的老手,这篇文章都能为你提供有价值的信息。我们将从变量、数据类型、控制结构等基本概念入手,逐步过渡到函数、模块等高级特性,最后通过一个综合示例来巩固所学知识。让我们一起开启Python编程之旅吧!
|
6天前
|
存储 Python
Python编程入门:打造你的第一个程序
【10月更文挑战第39天】在数字时代的浪潮中,掌握编程技能如同掌握了一门新时代的语言。本文将引导你步入Python编程的奇妙世界,从零基础出发,一步步构建你的第一个程序。我们将探索编程的基本概念,通过简单示例理解变量、数据类型和控制结构,最终实现一个简单的猜数字游戏。这不仅是一段代码的旅程,更是逻辑思维和问题解决能力的锻炼之旅。准备好了吗?让我们开始吧!