第十三章 接口、协议和 抽象基类

面向接口编程,不要面向实现编程

                      -----  Gamma, Helm, Johnson, Vlissides, First Principle of Object-Oriented Design

面向对象的编程都是关于接口的。理解 Python 中的类型的最佳方法是了解它提供的方法——也就是这个类的接口——如 “Types are defined by supported operations”(第 8 章)中所述。

根据编程语言,我们至少有一种定义和使用接口的方法。在python中,从 Python 3.8 开始,我们有四种方式定义和使用接口。它们在类型图(图 13-1)中进行了描述。我们可以这样总结它们:

  • 鸭子类型:Python一直在用的类型的默认方法。从第 1 章开始,我们就一直在研究鸭子类型。
  • 天鹅类型:自 Python 2.6 起,由抽象基类 (ABC) 支持的方法,这些方法依赖于基于ABC 实现的对象的运行时检查。天鹅类型 是本章的一个主要主题。
  • 静态类型:C 和 Java 等静态类型语言的传统方法;自 Python 3.5 起由typing模块支持,并由符合  PEP 484—Type Hints的外部类型检查器强制执行。第 8 章和后面的第 15 章的大部分内容都是关于静态类型的。
  • 静态鸭子类型:一种 Go 语言流行的方法;由 Typing.Protocol 的子类支持 - Python 3.8 中的新功能 - 也由外部类型检查器强制执行。我们最初在“静态协议”(第 8 章)中看到了静态鸭子类型。

类型映射

这四种类型方法是补充:它们各有优缺点。不能取消支持他们中的任何一个。

这四种方法中的每一种都依赖于接口来工作,但静态类型可以只使用具体的类型,而不是抽象的接口,如协议和抽象基类。本章是关于鸭子类型、天鹅类型和静态鸭子类型——围绕接口的类型规则。

本章分为四个顶级部分,针对类型映射中四个象限中的三个(图 13-1):

  • “Two kinds of protocols”将两种结构类型协议进行了比较——即类型映射的左侧部分(鸭子类型和静态鸭子类型)。
  • “Programming ducks”深入探讨了 Python 常用的鸭子类型,包括如何使其更安全,同时保持其主要优势:灵活性。
  • “Goose typing” 解释了使用 ABC 进行更严格的运行时类型检查。这是最长的一节,不是因为它更重要,而是因为在本书的其他地方有更多关于鸭子类型、静态鸭子类型和静态类型的部分。
  • “Static protocols”涵盖了typing.Protocol的使用、实现和设计——对于静态和运行时类型检查很有用。

本章的新内容

本章经过大量编辑,比第一版中相应的第 11 章大概增加了24%的内容。虽然有些小节和很多段落是一样的,但有很多新的内容。这些是亮点:

  • 章节介绍和类型映射(图 13-1)是全新的内容。这是本章中大多数新内容的关键——以及与 Python ≥ 3.8 中typing相关的所有其他章节。
  • “两种协议”解释了动态协议和静态协议的异同。
  • “防御性编程和“快速失败”主要复制了第一版的内容,但进行了更新,现在有一个独立的章节来强调其重要性。
  • “静态协议”是全新的。它建立在“静态协议”(第 8 章)中的演示基础上。
  • 更新了图 13-2、图 13-3 和图 13-4 中 collections.abc 的 UML 类图,以包含 Python 3.6 中添加的 Collection ABC。

第一版中有一个部分鼓励使用numbers ABC 的天鹅类型。在“The numbers ABCs and numeric protocols”,中,我解释了如果你打算使用静态类型检查器同时使用天鹅类型风格的运行时检查,为什么你应该使用 Typing 模块提供的数字静态协议。

Python中两种“不同”协议

根据上下文的不同,协议这个词在计算机科学中有不同的含义。网络协议(例如 HTTP)指定客户端可以发送到服务器的命令,例如 GET、PUT 和 HEAD。我们在“协议和鸭子类型”中看到,对象协议指定了对象必须提供的方法来履行角色。第 1 章中的 FrenchDeck 示例演示了一个对象协议,即序列协议:允许 Python 对象表现为序列的方法。

实现一个完整的协议可能需要几种方法,但通常只实现其中的一部分就可以了。例如这个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 参考手册的Sequence Protocol部分查看: 

int PySequence_Check(PyObject *o)

Return 1 if the object provides sequence protocol, and 0 otherwise. Note that it returns 1 for Python classes with a __getitem__() method unless they are dict subclasses […]

如果对象提供序列协议,则返回 1,否则返回 0。请注意,除非它们是 dict 子类,否则它为提供 __getitem__() 方法的 Python 类型返回 1 

我们希望序列也能通过实现 __len__ 来支持 len()。Vowels没有 __len__ 方法,但它在某些上下文中仍然表现为序列。这可能足以满足我们的目的。这就是为什么我喜欢说协议是一个“非正式接口”。这也是 Smalltalk 中对协议的理解方式,Smalltalk是第一个使用该术语的面向对象的编程环境。

除了有关网络编程的文档,Python 文档中“协议”一词的大多数用法都是指这些非正式接口。

现在,随着 PEP 544—Protocols: Structural subtyping (static duck typing) 的采用,“协议”这个词在 Python 中又出现另一个含义——两个含义密切相关,但又有所不同的含义。PEP 544 允许我们继承types.Protocol 来定义一个或多个类必须实现(或继承)包含静态类型检查器的方法。

当我需要区分时,我将采用以下术语:

动态协议:Python 一直拥有的非正式协议。动态协议是隐式的,由约定进行定义并在文档加以描述。Python 最重要的动态协议由解释器本身支持,并记录在 Python 语言参考的 “Data Model” chapter 中。

静态协议: 自 Python 3.8 起,PEP 544—Protocols: Structural subtyping (static duck typing) 定义的协议。静态协议有一个明确的定义:一个 Typing.Protocol 子类。

它们之间有两个主要区别:

  1. 如果一个对象只实现了动态协议的一部分,那么他仍然有可能正常使用;但是要实现静态协议,对象必须提供在协议类中声明的每个方法,即使您的程序不需要它们。
  2. 静态协议可以通过静态类型检查器进行验证,但动态协议不能。

两种协议都有一个基本特征,即类从不需要通过在命名时(即通过继承)声明它支持协议。

除了静态协议之外,Python 还提供了另一种在代码中定义显式接口的方法:抽象基类 (ABC)。

本章的其余部分涵盖动态和静态协议,以及 ABC。

鸭子编程 

让我们从 Python 中最重要的两个协议开始讨论动态协议:序列和可迭代协议。解释器不遗余力地处理那些提供这些协议的最小实现的对象,如下一节所述。

Python 会尽力支持序列

Python 数据模型的理念是尽量支持最基本的协议。对序列来说,即使是最简单的实现,Python 也会力求做到最好。

图 13-2 显示了形式化为 ABC的Sequence 接口。Python 解释器和一些内置的序列类型(如 list、str 等)也不依赖于这些ABC。我仅使用它来描述一个完整的 Sequence 应该支持的内容。

TIP: 

collections.abc 模块中的大多数 ABC 用于形式化由内置对象实现和由解释器隐式支持的接口 - 两者都早于 ABC 本身。ABC 可用作新类的起点,并支持运行时的显式类型检查(也称为天鹅类型)以及静态类型检查器的类型提示。

研究图 13-2,我们看到 Sequence 的正确子类必须实现 __getitem__ 和 __len__(来自 Sized)。Sequence 中的所有其他方法都是具体方法,因此子类可以继承以使用这些方法——或者提供更好的实现。

现在,回忆一下示例 13-1 中的Vowels类。它没有集成 abc.Sequence抽象类并且只实现了 __getitem__方法。

即使没有实现 __iter__ 方法,但Vowels实例是可迭代的,因为作为备选方案​​,如果 Python 找到 __getitem__ 方法,它会从索引0开始,尝试通过调用该方法来迭代对象。尽管没有实现__contains__ 方法,但是由于Python 足够智能,能够迭代Vowels实例,因此可以使用 in 操作符:python会进行顺序扫描以检查项是否存在。

总之,考虑序列协议的重要性,如果没有 __iter__ 和 __contains__ 方法时,Python 设法通过调用 __getitem__ 来使迭代和 in 运算符可用。

例 13-2。作为Card序列的Deck(与示例 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):
        return self._cards[position]

第 1 章中的几个示例之所以有效,是因为 Python 对任何类似序列的对象给予了特殊处理。Python 中的可迭代协议代表了鸭子类型的一种极端形式:解释器尝试使用两种不同的方法来迭代对象。

需要明确的是:我在本节中描述的行为是在解释器本身中实现的,主要是使用 C 语言实现的。它们不依赖于来自 Sequence ABC 的方法。例如,Sequence 类中的具体方法 __iter__ 和 __contains__ 模拟了 Python 解释器的内置行为。如果您好奇,请查看 Lib/_collections_abc.py中这些方法的源代码。

现在让我们研究另一个强调协议动态特性的例子——以及为什么静态类型检查器没有机会处理它们。

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

Note:

Monkey 补丁是在运行时动态更改模块、类或函数,以添加功能或修复错误.因为它不像常规补丁那样更改源代码,所以猴子补丁只影响当前运行程序的实例。gevent 网络库使用猴子补丁修改了Python 标准库的部分内容,以允许没有线程或async/await 的轻量级并发。请注意,猴子补丁取决于修补代码的实现细节,因此当库更新时它们很容易被破坏。

示例13-2 中的 FrenchDeck 类缺少一个基本特征:它不能被洗牌。多年前,当我第一次编写 FrenchDeck 示例时,我确实实现了一个 shuffle 方法。后来我有了 Pythonic 的见解:如果 FrenchDeck 表现得像一个序列,那么它不需要自己的 shuffle 方法,因为已经有 random.shuffle,如documented中的“将序列 x 就地打乱”。

标准库的 random.shuffle 函数是这样使用的:

>>> from random import shuffle
>>> l = list(range(10))
>>> shuffle(l)
>>> l
[5, 2, 9, 7, 8, 3, 1, 4, 0, 6]

TIP:

当您遵循既定的协议时,由于运行时的鸭子类型协议,您可以改进代码以利用现有标准库和第三方代码,避免重复造车。

然而,如果我们尝试打乱一个 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' object does not support item assignment('FrenchDeck' 对象不支持为元素赋值。)”问题是 shuffle 需要通过交换集合内的项目就地操作,而 FrenchDeck 只实现了不可变序列协议。可变序列还必须提供 __setitem__ 方法。

因为 Python 是动态语言,我们可以在运行时修复这个问题,甚至在交互式控制台中也是如此。示例 13-4 显示了如何执行此操作。

例 13-4。 使用猴子补丁修复 FrenchDeck 以使其可变并与 random.shuffle 兼容(从示例 13-3 继续)

>>> def set_card(deck, position, card):  1
...     deck._cards[position] = card
...
>>> FrenchDeck.__setitem__ = set_card  2
>>> shuffle(deck)  3
>>> 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')]
  1. 创建一个函数,将deck、position和card作为参数。
  2. 将该函数分配给 FrenchDeck 类中名为 __setitem__ 的属性。
  3. 现在可以洗牌了,因为我添加了可变序列协议的必要方法。

特殊方法__setitem__ 的签名在 “3.3.6. Emulating container types”中定义.在这里,我将参数命名为deck、position、card——而不是语言参考中的 self、key、value——以表明每个 Python 方法都是作为一个普通函数开始的,命名第一个参数 self 只是一个约定。这在控制台会话中是可以的,但在 Python 源文件中,最好使用文档中的 self、key 和 value。

关键是 set_card 知道deck 对象有一个名为_cards 的属性,而_cards 必须是一个可变序列。然后将 set_card 函数作为 __setitem__ 特殊方法附加到 FrenchDeck 类。这是猴子补丁的一个例子:在运行时改变一个类或模块,而不触及源代码。猴子补丁功能强大,但进行实际修补的代码与要修补的程序非常紧密地结合在一起,通常会处理私有和未文档化的属性。

除了作为猴子补丁的一个例子之外,示例 13-4 还强调了鸭子类型中协议是动态的这一特性:random.shuffle 不关心参数的类型,它只需要对象实现了可变序列协议中的方法。对象是一开始就具备必要的方法还是后来以某种方式获得都无关紧要。

鸭子类型不会非常不安全也不会难以调试。下一节将展示一些有用的代码模式,无需借助显式检查即可检测动态协议。

防御式编程和“快速失败”

防御性编程就像防御性驾驶:一套即使粗心的程序员或司机也能提高安全性的实践。许多异常只能在运行时才能被捕获——即使在主流静态类型语言中也是如此。在动态类型语言中,“快速失败”是使程序更安全、更易于维护的极好建议。快速失败意味着尽快抛出运行时异常,例如,在函数体的最开始的位置就检验参数的合法性。

这是一个示例:当您编写接收一个序列并在内部按照一个列表进行处理的方法时,不要对序列参数进行类型检查。相反,获取参数并立即从中构建一个列表。这种代码模式的一个例子是示例 13-10 中的 __init__ 方法,示例出现在本章后面:

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

这样就可以让你的代码更灵活,因为 list() 构造函数能够接受任意可迭代对象。如果参数不可迭代,调用将立即失败,并在初始化对象时出现抛出明确的 TypeError 异常。如果你想更明确,你可把list() 调用语句用try/except 包装起来,并自定义异常信息——但我只会在调用外部 API 时使用额外的try catch的代码,因为维护代码库的人员很容易定位自己的问题。无论哪种方式,错误的调用都将出现在跟踪信息的末尾附近,从而方便问题修复。如果你没有在类的构造函数中捕获无效参数,那么当类的其他方法需要对 self._balls 进行操作并且它不是一个列表,程序就会在不确定的时候报错。这样的话,根本原因将很难查找。

当然,如果数据不应该被复制,那么对参数调用 list() 会很糟糕,也许是因为它太长了,也许是因为函数在设计上需要就地改变它,比如random.shuffle 就是这样。在这种情况下,像 isinstance(x, abc.MutableSequence) 这样的运行时检查是可行的方法。

如果你不想接收一个无限的生成器——这不是一个常见的问题——你可以在函数的开始部分对参数调用 len() 。这会使迭代器类型快速失败,同时元组、数组和其他完全实现 Sequence 接口的现有或未来才有的类可以安全通过。调用 len() 通常成本很低,如果发现无效的参数会立即抛出异常。

另一方面,如果函数接受任何可迭代对象,则尽快调用 iter(x) 以获取迭代器,正如我们将在 “Why Sequences Are Iterable: The iter Function”中看到的。同样,如果 x 不可迭代,这将很快失败并抛出易于调试的异常。

在我刚刚描述的情况下,静态类型提示可以更早地捕获一些问题,但也不能发现所有问题。回想一下 Any 类型与其他所有类型都是一致的。类型推断可能会将变量标记为 Any 类型。当这种情况发生时,类型检查器就无能为力了。此外,类型提示在运行时不会强制执行。因此,快速失败就是最后一道防线。

利用鸭子类型的防御性代码还可以包含处理不同类型的逻辑,而无需使用 isinstance() 或 hasattr() 测试。

一个例子是我们如何模拟 collections.namedtuple 中的 field_names 参数的处理:field_names 接受单个字符串,标识符以空格或逗号分隔,或者是标识符序列。示例 13-5 展示了我如何使用鸭子类型来做到这一点。

例 13-5。鸭子类型来处理一个字符串或一个可迭代的字符串

    try:  1
        field_names = field_names.replace(',', ' ').split()  2
    except AttributeError:  3
        pass  4
    field_names = tuple(field_names)  5
    if not all(s.isidentifier() for s in field_names):  6
        raise ValueError('field_names must all be valid identifiers')
  1. 假设它是一个字符串(EAFP = 请求宽恕比请求通过更容易 it’s easier to ask forgiveness than permission)。
  2. 将逗号替换为空格并将结果拆分为名称列表。
  3. 抱歉,field_names 不像 str 那样嘎嘎叫:它没有 .replace,或者它被不能被split。
  4. 如果抛出 AttributeError ,那么 field_names 不是一个 str 并且我们假设它已经是字段名称的可迭代序列。
  5. 为了确保它是可迭代的并保留我们自己的副本,我们会根据它创建一个元组。元组比列表更紧凑,还可以防止我的代码错误地更改字段名称。
  6. 使用 str.isidentifier()检查每个名称都是有效的。

示例 13-5 显示了一种情况,其中鸭子类型比静态类型提示更具表现力。因为无法编写出“field_names 必须是由空格或逗号分隔的标识符字符串”的类型提示。这是 typeshed 上 namedtuple 签名的相关部分:(请参阅 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]] ,就目前而言这是没有问题的,但不足以捕获所有可能的问题。在查看了动态协议之后,我们转向了一种更明确的运行时类型检查形式:天鹅类型。

天鹅类型

          一个抽象类代表一个接口。

                                                                        -----Bjarne Stroustrup, Creator of C++

Python 没有 interface 关键字。我们使用抽象基类 (ABC) 来定义用于在运行时进行显式类型检查的接口——而且静态类型检查器同样支持。

abstract base class的 Python Glossary 部分很好地解释了它们给鸭子类型语言带来的价值:        

抽象基类:

        抽象基类通过提供定义接口的方式来补充鸭子类型,而诸如 hasattr() 之类的技术会显得笨拙或有微小的错误(例如使用魔术方法)。ABCs 引入了虚拟子类,虚拟子类不被任何类继承但可以被 isinstance() 和 issubclass() 识别;请参阅 abc 模块文档。Glossary — Python 3.10.0 documentation

天鹅类型是一种利用 ABC 的运行时类型检查方法。我会让 Alex Martelli 在“Waterfowl and ABCs”中解释。


 Alex Martelli的水禽和 ABCs

我在维基百科上因帮助传播言简意赅的“鸭子类型”而受到赞誉(即,忽略对象的实际类型,而是专注于确保对象实现所需的方法、签名和语义)。

在 Python 中,这主要归结为避免使用 isinstance 来检查对象的类型(更不用说更糟糕的检查方法,例如,type(foo)  is bar——这样做没有任何好处,因为它甚至禁止最简单的继承形式!)。

总体来说,鸭子类型的方法论在许多情况下仍然非常有用——然而,在许多其他情况下,随着时间的推移,通常有更好的解决办法。这里有一个故事......

近代以来,属和种(包括但不限于水禽所属的鸭科)基本上是根据表型系统学分类的。表征学关注的是形态和举止的相似---主要是表型系统学特征。 “鸭子类型”的比喻很贴切。

然而,平行进化通常可以在实际上不相关的物种之间产生相似的特征,包括形态特征和行为特征,但是生态位的相似是偶然的。类似的“偶然相似”也发生在编程中——例如,考虑经典的 OOP 示例:

class Artist:
    def draw(self): ...

class Gunslinger:
    def draw(self): ...

class Lottery:
    def draw(self): ...

显然,仅仅存在一个叫做 draw 的方法,可以不传入参数调用,即x.draw()和y.draw(),远远不能确保二者可以相互调用,或者具有相同的抽象。也就是说,我们无法从这类调用推导出语义相似性。相反,我们需要一个知识渊博的程序员以某种方式把这种等价维持在一定层次上!

在生物学(和其他学科)中,这个问题导致了一种替代现象学的方法的出现(并且在许多方面占据主导地位),称为分支学--将分类选择的重点放在由共同祖先继承的特征上,而不是独立进化的特征上。 (近年来,便宜且快速的 DNA 测序可以使分支学在更多情况下变得非常实用。)例如,sheldgeese(曾经被归类为更接近其他鹅)和shelducks(曾经被归类为与其他鸭子更接近)现在被归为Tadornidae亚科(暗示它们比任何其他鸭科更接近彼此,因为它们拥有更接近的共同祖先)。此外,DNA 分析特别表明,白翅木鸭与番鸭(后者是一只鸭子)的相似性并不像长期以来所暗示的那样接近。所以木鸭被重新归入了自己的属,完全脱离了亚科!

这样有什么问题吗?这取决于上下文!出于诸如在装袋后决定如何最好地烹饪水禽等目的,例如,特定的可观察特征(并非所有特征——例如,羽毛在在这种情况并不重要),主要是口感和风味(老式表象!),可能比分支学更相关。但是对于其他问题,例如对不同病原体的易感性(无论您是试图圈养水禽,还是在野外保存它们),DNA 接近性可能更重要……

因此,通过与水禽世界中的这些分类学革命的非常松散的类比,我建议用天鹅类型补充(不完全取代 - 在某些情况下它仍将服务)可用的旧有的鸭子类型!

天鹅类型的意思是:只要 cls 是抽象基类——换句话说,cls 的元类是 abc.ABCMeta, 就可以使用isinstance(obj, cls)。

您可以在 collections.abc(以及 Python 标准库的 numbers 模块中的其他抽象类)中找到许多有用的现有抽象类。

在 ABC 相对于具体类的许多概念优势中(例如,Scott Meyer 的“所有非叶节点类都应该是抽象的”——参见他的书《Effective C++》中的第 33 条),Python 的 ABC 增加了一个主要的实际使用的优点:注册类方法,它让最终用户代码“声明”某个类成为 ABC 的“虚拟”子类(或者出于这个目的,注册的类必须满足ABC的方法名和签名要求,更重要的是底层语义契约,但它不需要在知道ABC的情况进行开发,特别是不需要继承它!)这大大的打破了死板的强耦合,与面向对象编程人员掌握的知识有很大出入,因此使用继承时要格外小心。

有时,为了让抽象基类识别为子类,甚至不需要注册!

ABC 就是这种情况,其本质就是一些特殊方法。例如:

>>> class Struggle:
...     def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True

如您所见,abc.Sized 将 Struggle 识别为“子类”,无需注册,只需实现名为 __len__ 的特殊方法即可(它应该用正确的语法实现——无参数调用——和语义——返回一个表示对象“长度”的非负整数;任何代码如果实现了特殊方法,例如 __len__,具有任意的、不合规的语法和语义,都会导致更严重的问题).

最后我想说的是:如果实现的类体现了numebers,collections.abc或其他框架中的抽象基类的概念,那么要么继承对应的抽象基类(必要时),要么把类注册到相应的抽象基类中。开始开发程序时,不要使用提供注册功能的库或框架,要自己动手注册;如果必须检查参数的类型(这是最常见的),例如检查是不是‘序列’,那就这样做:

isinstance(the_arg, collections.abc.Sequence)

而且,不要在生产代码中定义自定义 的ABC(或元类)……如果你有这样做的冲动,我敢打赌,对于那些只是得到了一个闪亮的新锤子。您(以及您的代码的未来维护者)会更乐意坚持使用简单明了的代码,避开这种深奥的概念。


总而言之,天鹅类型需要:

  • 继承或者注册一个ABC 以明确您正在实现预先定义的接口。
  • 使用 ABC 而不是具体的类型作为 isinstance 和 issubclass 的第二个参数的运行时类型检查。

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

Note:

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

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

如果您对ABC 而不是具体类进行类型检查,则使用 isinstance 和 issubclass 变得更合理。如果传入具体类,类型检查会限制了对象的多态性——这是面向对象编程的一个基本特征。毕竟,如果一个组件没有继承ABC——但确实实现了所需的方法——它总是可以在事后注册,以便通过那些显式类型检查。

但是,即使使用 ABC,您也应该注意,过度使用 isinstance 检查可能会产生代码异味——这是糟糕的 OO 设计的症状。在一连串的if/elif/elif 使用 isinstance 检查,随后根据对象的类型,执行不同的操作通常是不合适的:你应该使用多态。

另一方面,如果您必须强制执行检查API的契约,则可以使用isinstance对 ABC 进行检查:“伙计,如果您想调用我,就必须实现这个”,正如技术审查员 Lennart Regebro 所说。这在具有插入式架构的系统中特别有用。在框架之外,鸭子类型通常比类型检查更简单、更灵活。

最后,在他的文章中,Alex不止一次强调了在创建 ABC 时需要格外小心。过度使用 ABC 会在一种流行的语言中强加不必要的仪式感,这对以实用且务实著称的python来说不是好事。在 Fluent Python 审核过程中,Alex 在一封电子邮件中写道:

        ABC 旨在封装框架引入的非常广泛的概念和抽象,例如“一个序列”和“一个精确数字”。[读者] 很可能不需要编写任何新的 ABC,只需正确使用现有的 ABC,即可获得 99.9% 的收益,而不会出现严重的设计错误导致的风险。

现在让我们看看天鹅类型的实际应用。

子类化 ABC

遵循 Martelli 的建议,我们将利用现有的 ABC----collections.MutableSequence,然后再发明我们自己的ABC。在示例 13-6 中,FrenchDeck2 继承了 collections.MutableSequence

例 13-6。 frenchdeck2.py:FrenchDeck2,collections.MutableSequence 的子类

import collections
from collections.abc import MutableSequence

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

class FrenchDeck2(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):  1
        self._cards[position] = value

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

    def insert(self, position, value):  3
        self._cards.insert(position, value)
  1. __setitem__ 是我们启用洗牌功能所需的功能.....
  2. 但是继承 MutableSequence 的类必须实现 __delitem__方法,这是 ABC 的一个抽象方法。
  3. 我们还需要实现insert方法,MutableSequence 的第三个抽象方法。

Python 不会在导入时(加载和编译 frenchdeck2.py 模块时)检查抽象方法的实现,而只会在运行时尝试实例化 FrenchDeck2 时进行检查。然后,如果我们没有实现部分抽象方法,python会抛出TypeError 异常,其中包含一条消息,例如“Can't instantiate abstract class FrenchDeck2 with abstract methods __delitem__, insert"。这就是为什么我们必须实现 __delitem__ 和insert方法,即使我们的 FrenchDeck2 示例不需要这些行为:MutableSequence ABC 需要它们。

如图 13-3 所示,并非 Sequence 和 MutableSequence ABC 的所有方法都是抽象的。

要将 FrenchDeck2 编写为 MutableSequence 的子类,我必须要实现 __delitem__ 和 insert 方法,而我的示例不需要这些方法。同时,FrenchDeck2 继承了 Sequence 的五个具体方法:__contains__、__iter__、__reversed__、index 和 count。从 MutableSequence 中,它获得了另外六个方法:append、reverse、extend、pop、remove 和 __iadd__——支持 += 运算符进行就地拼接。 

每个 collections.abc ABC 中的具体方法是根据类的公共接口实现的,因此它们可以在不了解实例内部结构的情况下工作。

TIP:

作为具体子类的编码人员,您可以使用更高效的实现来覆盖从 ABC 继承的方法。例如, __contains__ 通过对序列进行顺序扫描来工作,但是如果您的具体序列已经是排序好的,您可以使用 bisect 函数进行二分搜索来编写一个更快的 __contains__ .

要使用好 ABC,你需要知道哪些抽象基类是可用的。接下来我们将回顾集合的 ABCs。

标准库中的 ABC

从 Python 2.6 开始,标准库提供了几个 ABC。大多数都在 collections.abc 模块中定义,但还有其他的ABC。例如,您可以在 io 和 numbers 包中找到 ABC。但最广泛使用的还是 collections.abc。

TIP:

标准库中有两个名为 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 个 ABC 的总结 UML 类图(没有属性名称)。collections.abc 的文档有一个很好的表格(collections.abc — Abstract Base Classes for Containers — Python 3.10.0 documentation),总结了 ABC、它们的关系以及它们的抽象和具体方法(称为“混合方法”)。图 13-4 中有很多多重继承。我们将第 14 章的大部分内容都放在多重继承上,但现在可以说,对 ABC 来说,这通常不是问题。

原书的图看起来画错了,文档中KeysView和ItemsView都继承自Set 和MappingView,ValuesView继承自Collection和MappingView,如下表所示

让我们回顾一下图 13-4 中的ABC族群: 

IterableContainerSized

每个集合都应该从这些 ABC 继承或实现兼容的协议。 Iterable 支持用 __iter__ 迭代,Container 用 __contains__ 支持 in 操作符,Sized 用 __len__ 支持 len()。

Collection

这个 ABC 没有自己独有的方法,而是在 Python 3.6 中添加的,以便更容易的继承Iterable、Container 和 Sized 。

SequenceMappingSet

这些是主要的不可变集合类型,每个类型都有一个可变子类。 MutableSequence 的详细图表如图 13-3 所示;对于 MutableMapping 和 MutableSet,在第 3 章(图 3-1 和 3-2)中有图表。

MappingView

在 Python 3 中,映射方法 .items()、.keys() 和 .values() 返回的对象分别实现了 ItemsView、KeysView 和 ValuesView 中定义的接口。前两个也实现了Set的丰富接口,有我们在“Set操作”中看到的所有操作符。

Iterator

请注意,iterator继承了 Iterable 。我们将在第 17 章进一步讨论这一点。

在查看了一些现有的 ABC 之后,让我们通过从头开始实现 ABC 并将其投入使用来练习天鹅类型。这里的目标不是鼓励每个人开始创建 ABC,而是学习如何阅读标准库和其他包中的 ABC 源代码。

CallableHashable

        这两个抽象基类和集合没有太大的关系,只不过 collections.abc 是第一个在标准库中定义 ABC 的包,并且这两个被认为足够重要,所以包含在内。它们支持了类型检查对象以验证对象必须是可调用或可散列的。

对于可调用检测,callable(obj) 内置函数比insinstance(obj, Callable) 更方便。

如果instance(obj, Hashable) 返回False,则可以确定obj 是不可散列的。但如果返回为 True,则可能是误报。下面就会进行解释。


在isinstance使用Hashable 和 Iterable 可能会产生误导

很容易误解 isinstance 和 issubclass 测试对于 Hashable 和 Iterable 这两个ABC 的结果。

如果 isinstance(obj, Hashable) 返回 True,那仅意味着 obj 的类实现或继承了 __hash__方法。但是如果 obj 是一个不可散列的元组,则 obj 是不可散列的,尽管 isinstance 检查的结果是True。技术评论家 Jürgen Gmach 指出,鸭子类型提供了确定实例是否可散列的最准确方法:调用 hash(obj)。如果 obj 不可散列,则该调用将抛出 TypeError。

另一方面,即使 isinstance(obj, Iterable) 返回 False,Python 仍然可以使用 __getitem__ 和基于 0 的索引来迭代 obj,正如我们在第 1 章和“Python 挖掘序列”中看到的那样。

collections.abc.Iterable 的文档指出:

        确定对象是否可迭代的唯一可靠方法是调用 iter(obj)。

定义并使用 ABC

TIP:

此警告出现在 Fluent Python, First Edition 的 Interfaces 章节中:

        ABC 与描述符和元类一样,是构建框架的工具。因此,只有少数 Python 开发人员可以创建 ABC,而不会对其他程序员施加不合理的限制和不必要的工作。

现在 ABC 在类型提示方面有更多潜在用例来支持静态类型。正如“抽象基类”中所讨论的,在函数参数类型提示中使用 ABC 而不是具体类型为调用者提供了更大的灵活性。

为了证明创建 ABC 的合理性,我们需要提出将其用作框架中的扩展点的上下文。所以这里是我们的上下文:假设您需要以随机顺序在网站或移动应用程序上显示广告,但在显示完整广告库存之前不会重复显示广告。现在让我们假设我们正在构建一个名为 ADAM 的广告管理框架。它的要求之一是支持用户提供的非重复随机挑选类。为了让 ADAM 用户清楚对“非重复随机挑选”组件的期望,我们将定义一个 ABC。

在有关数据结构的文献中,“堆栈”和“队列”根据对象的物理排列来描述抽象接口。我将效仿并使用现实世界的比喻来命名我们的 ABC:宾果游戏笼和抽奖机是旨在从有限集合中随机挑选项目的机器,不会重复,直到集合用完为止。

ABC 将被命名为 Tombola,以宾果游戏的意大利名字和打乱数字的滚动容器来命名。

Tombola ABC 有四种方法。两个抽象方法是:

.load(…):将元素放入容器

.pick():从容器中随机取出一个元素,将其返回。

具体方法是:

.loaded():如果容器中至少有一个元素,则返回 True。

.inspect():返回从当前容器中的元素构建的元组,而不更改其内容(不保留内部顺序)。

图 13-5 显示了 Tombola ABC 和三个具体实现类。

示例 13-7 显示了 Tombola ABC 的定义。

例 13-7。 tombola.py:Tombola 是一个 ABC,有两个抽象方法和两个具体方法

import abc

class Tombola(abc.ABC):  1

    @abc.abstractmethod
    def load(self, iterable):  2
        """Add items from an iterable."""

    @abc.abstractmethod
    def pick(self):  3
        """Remove item at random, returning it.

        This method should raise `LookupError` when the instance is empty.
        """

    def loaded(self):  4
        """Return `True` if there's at least 1 item, `False` otherwise."""
        return bool(self.inspect())  5

    def inspect(self):
        """Return a sorted tuple with the items currently inside."""
        items = []
        while True:  6
            try:
                items.append(self.pick())
            except LookupError:
                break
        self.load(items)  7
        return tuple(items)
  1.  要定义一个ABC,需要继承 abc.ABC。
  2. 抽象方法用@abstractmethod 装饰器标记,通常方法主体只有一个文档字符串。
  3. 如果没有要选择的元素,文档字符串会指示实现者抛出 LookupError异常。
  4. ABC 可以包括具体的方法。
  5. ABC 中的具体方法必须只依赖在 ABC 内定义的接口(即,ABC 的其他具体或抽象方法或属性)。
  6. 我们不知道具体的子类将如何存储元素,但我们可以通过连续调用 .pick() 清空 Tombola 来构建检查结果......
  7. ...然后使用 .load(...) 将所有元素放回去

TIP:
抽象方法实际上可以有一个具体实现。即使这样做,子类仍需要覆盖抽象方法,但它们将能够使用 super() 调用抽象方法,以为子类的方法提供功能,而不是从头开始实现。有关@abstractmethod 用法的详细信息,请参阅 abc module documentation

示例 13-7 中 .inspect() 方法的代码很蠢笨,但它表明我们可以依靠 .pick() 和 .load(...) 来检查 Tombola 中的内容,方法是挑出所有元素,再将它们加载回来——无需了解元素的实际存储方式。这个例子的重点是强调可以在 ABC 中提供具体的方法,只要它们只依赖于接口中的其他方法。了解到它们的内部数据结构后,Tombola 的具体子类可能总是用更聪明的实现覆盖 .inspect() ,但这不是强制要求。

示例 13-7 中的 .loaded() 方法只有一行代码,但开销很大:它调用 .inspect() 来构建元组,只是为了在其上应用 bool()。这样做没有问题,但正如我们将看到的,一个具体的子类实现往往可以更加简洁和高效。

请注意,我们对 .inspect() 的另类实现要求我们捕获由 self.pick() 抛出的 LookupError。self.pick() 可能抛出LookupError 的事实也是其接口的一部分,但在 Python 中除了在文档中说明以外无法明确说明这一点(请参阅示例 13-7 中抽象 pick 方法的文档字符串)。

我选择 LookupError 异常是因为它与 IndexError 和 KeyError 相关,并且处于 Python 异常层次结构中的上层位置,最有可能由用于实现具体 Tombola 的数据结构引发的异常。因此,需要实现可以抛出LookupError 、 IndexError、KeyError 或 自定义的LookupError的子类异常 的方法。请参见图 13-6。

  1.  LookupError 是我们在 Tombola.inspect 中处理的异常;
  2. IndexError 是 LookupError 子类,当我们尝试从序列中获取索引超出最后位置的元素时抛出这个异常;
  3. 当我们使用不存在的键从映射中获取元素时,会抛出 KeyError异常。

我们现在实现了自己的 Tombola ABC。为了证明ABC执行的接口检查,让我们尝试用示例 13-8 中的有缺陷的实现来欺骗 Tombola。

例 13-8。假的 Tombola 不会被解释器发现

>>> from tombola import Tombola
>>> class Fake(Tombola):  1
...     def pick(self):
...         return 13
...
>>> Fake  2
<class '__main__.Fake'>
>>> f = Fake()  3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Fake with abstract method load
  1. Fake继承了Tombola 。
  2. Fake类已经创建,到目前为止没有错误。
  3. 当我们尝试实例化 Fake 时会抛出 TypeError异常。信息非常明确:Fake 被认为是抽象类,因为它未能实现load方法,这是 Tombola ABC 中声明的抽象方法之一。

到此我们定义了第一个 ABC,并将其用于验证了一个类。我们很快就会定义 Tombola ABC 的子类,但首先我们必须介绍一些 ABC 编码规则。

ABC 语法详细信息

声明 ABC 的最佳方法是继承 abc.ABC 或任何其他 ABC。 

除了@abstractmethod,abc 模块还定义了@abstractclassmethod、@abstractstaticmethod 和@abstractproperty 装饰器。然而,这最后三个在 Python 3.3 中被弃用,因为可以在 @abstractmethod 之上堆叠装饰器,使其他的变得多余。例如,声明抽象类方法的首选方法是:

class MyABC(abc.ABC):
    @classmethod
    @abc.abstractmethod
    def an_abstract_classmethod(cls, ...):
        pass

Warning:

堆叠函数装饰器的顺序很重要,在@abstractmethod 的情况下,文档明确说明了:

        当 abstractmethod() 与其他方法描述符结合使用时,它应该作为最内层的装饰器使用,......

换句话说,@abstractmethod 和 def 语句之间不能出现其他装饰器。

现在我们已经涵盖了这些 ABC 语法问题,让我们通过实现Tombola的两个具体子类来使用它 。

子类化 ABC

定义好Tombola ABC之后,我们现在将开发两个满足其接口的具体子类。这些类如图 13-5 所示,以及下一节将讨论的虚拟子类。

示例 13-9 中的 BingoCage 类是示例 7-8 的变体,使用了更好的随机发生器。这个 BingoCage 实现了所需的抽象方法 load 和 pick。

例 13-9。 bingo.py:BingoCage 是 Tombola 的具体子类

import random

from tombola import Tombola


class BingoCage(Tombola):  1

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

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

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

    def __call__(self):  6
        self.pick()
  1. 这个 BingoCage 类显式地继承了 Tombola。
  2. 假设我们将其用于在线游戏。 random.SystemRandom 在 os.urandom(...) 函数之上实现了random API,根据 os 模块文档,它提供“适合加密使用”的随机字节。
  3. 将初始加载委托给 .load(...) 方法。
  4. 我们使用 SystemRandom 实例的 .shuffle() 方法,而不是普通的 random.shuffle() 函数。
  5. pick 的实现如例 7-8 所示。
  6. __call__ 也来自示例 7-8。不需要满足 Tombola 接口,但是添加额外的方法也没有坏处。

BingoCage 从 Tombola 继承了低效的loaded和笨重的inspect方法。两者都可以用更快的单行代码覆盖,如示例 13-10 所示。关键的点是:我们可以偷懒,只是从 ABC 继承的具体方法不会很理想。从 Tombola 继承的方法不如 BingoCage 快,但只要子类正确实现pick和load方法,就能执行出正确的结果。

示例 13-10 展示了一个不同但同样有效的 Tombola 接口实现。 LottoBlower 不是将“球”洗牌并弹出最后一个,而是从随机位置弹出。

例 13-10。 lotto.py:LottoBlower 是一个具体的子类,它覆盖了 Tombola 的inspect和load方法

import random

from tombola import Tombola


class LottoBlower(Tombola):

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

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

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

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

    def inspect(self):  5
        return tuple(self._balls)
  1. 初始化器接受任何可迭代对象:参数用于构建列表。
  2. 如果范围为空,random.randrange(…) 函数会引发 ValueError,因此我们捕获它并抛出 LookupError,以与 Tombola 兼容。
  3. 否则从 self._balls 中取出随机选中的元素
  4. 重写覆盖loaded以避免调用inspect(如 Tombola.loaded 在示例 13-7 中所做的那样)。我们可以通过直接使用 self._balls 使其更快——无需构建一个全新的元组。
  5. 用一行的代码来覆盖inspect。

示例 13-10 说明了一个值得一提的习惯用法:在 __init__ 中,self._balls 存储的是list(iterable)而不是对 iterable 的引用(即,我们不仅分配了 self._balls = iterable,还为参数设置了别名)。正如在“防御性编程和“快速失败”中提到的,这使得我们的 LottoBlower 变得灵活,因为 iterable 参数可以是任何可迭代类型。同时,我们确保将其项目存储在列表中,以便我们可以对其进行pop操作。即使我们总是将列表作为iterable参数, list(iterable) 会生成参数的副本,考虑到我们将从中删除元素并且客户端可能不希望提供的列表会被更改,这是一个很好的做法。

现在我们来看看 天鹅类型的关键动态特性:使用 register 方法声明虚拟子类。

ABC 的虚拟子类

天鹅类型的一个基本特征——也是它值得拥有水禽名称的一个原因——是能够将一个类注册为 ABC 的虚拟子类,即使它没有继承自它。这样做时,我们保证该类忠实地实现了 ABC 中定义的接口——Python 会相信我们而不进行检查。如果我们撒谎,我们将被通常的运行时异常捕获。

这是通过调用 ABC 上的register类方法来完成的。注册的类然后成为 ABC 的虚拟子类,并且会被 issubclass 等方法识别,但它不会从 ABC 继承任何方法或属性。

Warnings:

虚拟子类不会继承其注册的 ABC ,并且不会在任何时候检查与 ABC 接口的一致性,甚至在实例化时也不检查.此外,静态类型检查器目前无法处理虚拟子类。有关详细信息,请参阅 Mypy 问题  Mypy issue 2922—ABCMeta.register support.

register 方法通常作为普通函数调用(参见“实践中的register的 使用”),但它也可以用作装饰器。在示例 13-11 中,我们使用装饰器语法并实现 TomboList,这是图 13-7 中描绘的 Tombola 的虚拟子类。

示例13-11:toombolist.py: 类 TomboList 是 Tombola 的虚拟子类 

from random import randrange

from tombola import Tombola

@Tombola.register  1
class TomboList(list):  2

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

    load = list.extend  5

    def loaded(self):
        return bool(self)  6

    def inspect(self):
        return tuple(self)

# Tombola.register(TomboList)  7
  1. Tombolist 注册为 Tombola 的虚拟子类。
  2. Tombolist 继承了list
  3. Tombolist 从list继承其boolen行为,如果列表不为空,则返回 True。
  4. 我们的 pick 调用 self.pop,继承自 list,传入一个随机元素的索引。
  5. Tomblist.load 与 list.extend 相同。
  6. loaded方法委托给bool函数
  7. 总是可以通过这种方式调用 register ,当您需要注册一个您不维护但确实实现所需要的接口的类时,这样做很有用。

请注意,注册后,函数 issubclass 和 isinstance 就像 TomboList 是 Tombola 的子类一样:

>>> 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:

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

Tombola 不在 Tombolist.__mro__ 中,所以 Tombolist 没有从 Tombola 继承任何方法。

我们的 Tombola ABC 案例研究到此结束。在下一节中,我们将讨论如何在真实的场景使用 register ABC 函数。

Python中使用register的方式

在示例 13-11 中,我们使用 Tombola.register 作为类装饰器。在 Python 3.3 之前, register 不能这样使用——它必须在类定义之后作为普通函数调用,如示例 13-11 末尾的注释所示。然而,即使是现在,它也被更广泛地部署为一个函数来注册在别处定义的类。例如,在 collections.abc 模块的源代码中,内置类型 tuple、str、range 和 memoryview 被注册为 Sequence 的虚拟子类,如下所示:

Sequence.register(tuple)
Sequence.register(str)
Sequence.register(range)
Sequence.register(memoryview)

其他几个内置类型在 _collections_abc.py 中注册为 ABC。这些注册仅在导入该模块时发生,这样是可以的,因为无论如何您都必须导入它才能使用这些 ABC。例如,您需要从 collections.abc 导入 MutableMapping 以执行类似 isinstance(my_dict, MutableMapping) 的检查。

子类化 ABC 或注册 ABC 都是使我们的类通过 issubclass 检查以及 isinstance 检查的显式方法,这也依赖于 issubclass。但是一些 ABC 也支持结构类型。下一节将对此进行解释。

使用 ABC 进行结构类型化

ABC 主要用于名义的类型。当 Sub 类显式继承自 AnABC 或注册到 AnABC 时,AnABC 的名称会链接到 Sub 类——这就是在运行时 issubclass(AnABC, Sub) 返回 True 的方式。

相比之下,结构化类型是查看对象公共接口的结构以确定其类型:如果对象实现了类型中定义的方法,则该对象与这个类型一致。动态鸭子类型和静态鸭子类型是结构类型的两种方法。

事实证明,一些 ABC 也支持结构类型。在他的“Waterfowl and ABCs”文章中,Alex 表明即使没有注册,一个类也可以被识别为 ABC 的子类。这是他的例子,使用 issubclass 添加了一个测试:

>>> class Struggle:
...     def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
>>> issubclass(Struggle, abc.Sized)
True

类 Struggle 被 issubclass 函数(因此也被 isinstance 视为 abc.Sized 的子类),因为 abc.Sized 实现了一个名为 __subclasshook__ 的特殊类方法。

Sized 的 __subclasshook__ 检查类参数是否具有名为 __len__ 的属性。如果是,则将其视为 Sized 的虚拟子类。请参见示例 13-12。

例 13-12。 Sized 的定义来自 Lib/_collections_abc.py 的源代码。

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__):  1
                return True  2
        return NotImplemented  3
  1. 如果在 C.__mro__ 中列出的任何类(即 C 及其超类)的 __dict__ 中有一个名为 __len__ 的属性......
  2. ...返回 True,表明 C 是 Sized 的虚拟子类。
  3. 否则返回 NotImplemented 以让其子类检查。

Note:

如果您对子类检查的详细信息感兴趣,请参阅 Python 3.6 中 ABCMeta.__subclasscheck__ 方法的源代码:Lib/abc.py。当心:它有很多 if 和两个递归调用。在 Python 3.7 中,Ivan Levkivskyi 和 INADA Naoki 用 C 重写了 abc 模块的大部分逻辑,以获得更好的性能.请参阅 Python 问题 #31333。 ABCMeta.__subclasscheck__ 的当前实现只是调用 _abc_subclasscheck。相关的 C 源代码在 cpython/Modules/_abc.c#L605 中。

这就是 __subclasshook__ 允许 ABC 支持结构类型的方式。你可以用 ABC 定义一个接口,并对那个 ABC 进行 isinstance 检查,仍然可以有一个完全不相关的类通过 issubclass 检查,因为它实现了某个方法(或者因为它不惜一切代价说服 __subclasshook__ 为它担保)。

在我们自己的 ABC 中实现 __subclasshook__ 是个好主意吗?可能不是。我在 Python 源代码中看到的 __subclasshook__ 的所有实现都在像 Sized 这样的 ABC 中,它们只声明一种特殊方法,并且它们只是检查该特殊方法名称。鉴于它们的“特殊”状态,您可以非常确定任何名为 __len__ 的方法都能满足您的期望。但即使在特殊方法和基本 ABC 领域,做出这样的假设也是有风险的。例如,映射实现了 __len__、__getitem__ 和 __iter__特殊方法,但它们不应该被视为 Sequence 的子类型,因为您无法使用整数偏移量或切片检索项。这就是 abc.Sequence 类没有实现 __subclasshook__ 的原因。

对于自己编写的 ABC, __subclasshook__ 会更不可靠。没有人可以保证任何实现或继承load,pick,inspect,loaded的名为 Spam 的类都保证表现为 Tombola。最好让程序员使Spam继承Tombola ,或者用 Tombola.register(Spam) 注册它。当然,你的 __subclasshook__ 也可以检查方法签名和其他功能,但我认为不值得这么做。

静态协议

Note:

我在“静态协议”(第 8 章)中介绍了静态协议。我考虑将协议的所有内容推迟到当前第 13 章,但决定函数中类型提示的初始呈现必须包括协议,因为鸭子类型是 Python 的重要组成部分,没有协议的静态类型检查不能很好地处理 Pythonic API。

我们将用两个简单的例子来说明静态协议,并讨论数字类型的 ABC 和协议,从而结束本章。让我们首先展示静态协议如何使我们在“类型由支持的操作定义”中第一次看到的 double() 函数注解和类型检查成为可能。

类型化的 double 函数

在向更习惯于静态类型语言的程序员介绍 Python 时,我最喜欢的例子之一就是这个简单的 double 函数。

>>> def double(x):
...     return x * 2
...
>>> double(1.5)
3.0
>>> double('A')
'AA'
>>> double([10, 20, 30])
[10, 20, 30, 10, 20, 30]
>>> from fractions import Fraction
>>> double(Fraction(2, 5))
Fraction(4, 5)

在引入静态协议之前,没有实用的方法可以在不限制其可能用途的情况下向 double 添加类型提示.由于鸭子类型,double 甚至可以用于未来的类型,例如我们将在“标量乘法的重载 *”(第 16 章)中看到的增强型 Vector 类。

>>> from vector_v7 import Vector
>>> double(Vector([11.0, 12.0, 13.0]))
Vector([22.0, 24.0, 26.0])

Python 中类型提示的最初实现是一个名义类型系统:注解中的类型名称必须与实际参数的类型名称或它的一个超类的名称相匹配。由于不可能通过支持所需的操作来命名实现协议的所有类型,因此在 Python 3.8 之前无法通过类型提示来描述鸭子类型。

现在,通过typing.Protocol,我们可以告诉Mypy double 接受一个支持x * 2 的参数x。方法如下:

例 13-13。 double_protocol.py:使用协议定义double方法。

from typing import TypeVar, Protocol

T = TypeVar('T')  1

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

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

def double(x: RT) -> RT:  4
    return x * 2
  1. 我们将在 __mul__ 签名中使用这个 T
  2. __mul__ 是Repeatable协议的关键。self 参数通常没有注释——它的类型被假定为类本身。这里我们使用 T 来确保结果类型与 self 的类型相同。另外,请注意,在此协议中,repeat_count 被限制为 int类型。
  3. RT 类型变量受Repeatable协议的限制:类型检查器将要求实际类型实现Repeatable
  4. 现在类型检查器能够验证 x 参数是一个可以乘以整数的对象,并且返回值与 x 具有相同的类型。

这个例子说明了为什么 PEP 544 的标题是“协议:结构子类型(静态鸭子类型)”。赋予 double 的实际参数 x 的名义类型是无关紧要的,只要它嘎嘎叫——也就是说,只要它实现了 __mul__。

可以在运行时检查的静态协议

在 Typing Map(图 13-1)中,typing.Protocol 出现在静态检查区域——图表的下半部分。但是,在定义 Typing.Protocol 子类时,您可以使用 @runtime_checkable 装饰器使该协议在运行时支持 isinstance/issubclass 检查。这是有效的,因为 Typing.Protocol 是一个 ABC,因此它支持我们在“Structural typing with ABCs”中看到的 __subclasshook__。

从 Python 3.9 开始,typing 模块包括七个可运行时检查的即用型协议。以下是其中两个,直接从typing的文档中引用:

class typing.SupportsComplex

        An ABC with one abstract method __complex__.

class typing.SupportsFloat

        An ABC with one abstract method __float__.

这些协议旨在检查数字类型的“可转换性”:如果对象 o 实现了 __complex__,​​那么您应该能够通过调用 complex(o) 来获得一个complex类型对象——因为o存在 __complex__ 特殊方法来支持 complex() 内置函数。

这是typing.SupportsComplex 协议的源代码:

@runtime_checkable
class SupportsComplex(Protocol):
    """An ABC with one abstract method __complex__."""
    __slots__ = ()

    @abstractmethod
    def __complex__(self) -> complex:
        pass

关键是__complex__抽象方法.在静态类型检查期间,如果一个对象实现了一个只接受 self 并返回一个complex的 __complex__ 方法,则该对象将被视为与 SupportsComplex 协议一致。

由于@runtime_checkable 类装饰器应用于 SupportsComplex,该协议也可以与 isinstance 检查一起使用:

例 13-15。在运行时使用 SupportsComplex。

>>> from typing import SupportsComplex
>>> import numpy as np
>>> c64 = np.complex64(3+4j)  1
>>> isinstance(c64, complex)   2
False
>>> isinstance(c64, SupportsComplex)  3
True
>>> c = complex(c64)  4
>>> c
(3+4j)
>>> isinstance(c, SupportsComplex) 5
False
>>> complex(c)
(3+4j)
  1. complex64 是 NumPy 提供的五种复数类型之一。
  2. NumPy 的Complex类型都不是内置complex类型的子类
  3. 但是 NumPy 的complex类型实现了 __complex__,​​因此它们遵守了SupportsComplex 协议。
  4. 因此,您可以从它们创建内置的complex对象。
  5. 遗憾的是,complex 内置类型没有实现 __complex__ ,尽管如果 c 是complex, complex(c) 可以正常工作。

作为最后一点的影响,如果您想测试对象 c 是complex或者 SupportsComplex,您可以提供一个类型元组作为 isinstance 的第二个参数,如下所示:

isinstance(c, (complex, SupportsComplex))

另一种方法是使用在 numbers 模块中定义的 Complex ABC。内置的 complex 类型和 NumPy complex64 和 complex128 类型都注册为 numbers.Complex 的虚拟子类,因此这是有效的:

>>> import numbers
>>> isinstance(c, numbers.Complex)
True
>>> isinstance(c64, numbers.Complex)
True

我在 Fluent Python 第一版中建议使用numbers ABC,但现在这不再是好的建议,因为静态类型检查器无法识别这些 ABC,正如我们将在“数字 ABC 和数字协议”中看到的那样。

本节中,我想演示runtime_checkable协议与 isinstance 一起工作,但事实证明,这不是一个特别好的 isinstance 用例,正如侧边栏“鸭子类型是你的朋友”所解释的那样。

TIP:

如果您使用的是外部类型检查器,则显式的 isinstance 检查有一个优点:当您编写条件为 isinstance(o, MyType) 的 if 语句时,Mypy 可以推断在 if 块中 o 对象的类型与 MyType 一致。

鸭子类型是我们的好朋友:

通常在运行时,鸭子类型是类型检查的最佳方法:不要调用 isinstance 或 hasattr,只需尝试对对象执行所需的操作,并根据需要处理异常。这是一个具体的例子。

继续前面的讨论——给定一个我需要用作复数的对象 o,这将是一种方法:

if isinstance(o, (complex, SupportsComplex)):
    # do something that requires `o` to be convertible to complex
else:
    raise TypeError('o must be convertible to complex')

天鹅类型方法是使用 numbers.Complex ABC

if isinstance(o, numbers.Complex):
    # do something with `o`, an instance of `Complex`
else:
    raise TypeError('o must be an instance of Complex')

然而,我更喜欢利用鸭子类型并使用 EAFP 原则来做到这一点——请求宽恕比许可更容易:

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

而且,如果你要做的只是抛出一个 TypeError异常,那么我会省略 try/except/raise 语句,只写这个语句:

c = complex(o)

在最后一种情况下,如果 o 不是可接受的类型,Python 将抛出带有非常明确的消息异常:例如,如果 o 是元组,我会得到以下信息:

TypeError: complex() first argument must be a string or a number, not 'tuple'

在这种情况下,我发现鸭子类型方法要好得多。

既然我们已经了解了如何在运行时使用具有complex和 numpy.complex64 等预先存在的类型的静态协议,我们需要讨论运行时可检查协议的局限性

运行时协议检查的限制

我们已经看到在运行时通常会忽略类型提示,这也会影响对静态协议的 isinstance 或 issubclass 检查的使用。

例如,任何具有 __float__ 方法的类在运行时都被视为 SupportsFloat 的虚拟子类,即使 __float__ 方法不返回浮点数。

查看此控制台会话:

>>> import sys
>>> sys.version
'3.9.5 (v3.9.5:0a7dcbdb13, May  3 2021, 13:17:02) \n[Clang 6.0 (clang-600.0.57)]'
>>> c = 3+4j
>>> c.__float__
<method-wrapper '__float__' of complex object at 0x10a16c590>
>>> c.__float__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can't convert complex to float

Python 3.9中,complex类型确实有一个 __float__ 方法,但它的存在只是为抛出带有显式错误消息的 TypeError。如果 __float__ 方法有注解,返回类型将是 NoReturn——我们在“NoReturn”中看到过。

但是typeshed 上的complex.__float__ 类型提示并不能解决这个问题,因为Python 的运行时通常会忽略类型提示——无论如何都无法访问typeshed 存根文件。

继续之前的 Python 3.9 会话:

>>> from typing import SupportsFloat
>>> c = 3+4j
>>> isinstance(c, SupportsFloat)
True
>>> issubclass(complex, SupportsFloat)
True

所以我们得到了这个误导性的结果:针对 SupportsFloat 的运行时检查建议您可以将complex转换为浮点数,但实际上这会抛出类型错误异常。

Warning:

Complex类型的具体问题在 Python 3.10.0b4 中修复,删除了 complex.__float__ 方法。但问题仍然存在:isinstance/issubclass 检查只查看方法的存在与否,而不检查它们的签名,更不用说它们的类型注解了。这个行为将来也不会改变,因为在运行时进行类型检查会带来不可接受的性能成本。

现在让我们看看如何在用户定义的类中实现静态协议。

支持静态协议

回忆我们在第 11 章中构建的 Vector2d 类。鉴于Complex和 Vector2d 实例都由一对浮点数组成,支持从 Vector2d 到Complex的转换是有意义的。

示例 13-16 展示了 __complex__ 方法的实现,以增强我们在示例 11-11 中看到的 Vector2d 的最后一个版本。为了完整起见,我们可以使用 fromcomplex 类方法支持反向运算,以从一个Complex实例构建 Vector2d。

例 13-16。 vector2d_v4.py:与Complex相互转换的方法。

    def __complex__(self):
        return complex(self.x, self.y)

    @classmethod
    def fromcomplex(cls, datum):
        return cls(datum.real, datum.imag)  1
  1. 这假设datum具有 .real 和 .imag 属性。我们将在示例 13-17 中看到更好的实现。

根据上面的代码,以及 Vector2d 在示例 11-11 中已有的 __abs__ 方法,我们得到了这些特性:

>>> from typing import SupportsComplex, SupportsAbs
>>> from vector2d_v4 import Vector2d
>>> v = Vector2d(3, 4)
>>> isinstance(v, SupportsComplex)
True
>>> isinstance(v, SupportsAbs)
True
>>> complex(v)
(3+4j)
>>> abs(v)
5.0
>>> Vector2d.fromcomplex(3+4j)
Vector2d(3.0, 4.0)

对于运行时类型检查,示例 13-16 没有问题,但为了使用 Mypy 实现更好的静态覆盖和错误报告,__abs__、__complex__ 和 fromcomplex 方法应该添加类型提示,如示例 13-17 所示。

例 13-17。 vector2d_v5.py:为正在研究的方法添加注解。

    def __abs__(self) -> float:  1
        return math.hypot(self.x, self.y)

    def __complex__(self) -> complex:  2
        return complex(self.x, self.y)

    @classmethod
    def fromcomplex(cls, datum: SupportsComplex) -> Vector2d:  3
        c = complex(datum)  4
        return cls(c.real, c.imag)
  1. 需要注解返回值为float,否则Mypy推断返回值为Any,从而不检查方法体。
  2. 即使没有注解,Mypy 也能够推断出这会返回一个complex。根据您的 Mypy 配置,该注解可防止出现警告。
  3. 这里 SupportsComplex 确保datam是可以进行complex转换的。
  4. 这种显式转换是必要的,因为 SupportsComplex 类型没有声明在下一行中使用的 .real 和 .imag 属性。例如,Vector2d 没有这些属性,但实现了 __complex__。

如果 from __future__ import annotations出现在模块顶部,则 fromcomplex 的返回类型可以是 Vector2d。当函数定义中的类型提示执行时,该导入导致类型提示被存储为字符串,而不是在导入时执行。如果没有from  __future__ import annotations ,此时 Vector2d 是一个无效引用(该类尚未完全定义),应该写成一个字符串:'Vector2d'——就好像它是一个前向引用。这个 __future__ 导入是由  PEP 563—Postponed Evaluation of Annotations引入的,在 Python 3.7 中实现。该行为计划在 3.10 中成为默认行为,但更改被推迟到更高版本生效。当修改生效后,导入将是多余但不会导致程序异常。

接下来,让我们看看如何创建——以及扩展——一个新的静态协议。

设计一个静态协议

在研究天鹅类型时,我们在“定义和使用 ABC”中看到了 Tombola ABC。在这里,我们将看到如何使用静态协议定义类似的接口。

Tombola ABC 定义了两个方法:pick 和 load。我们也可以使用这两种方法定义静态协议,但我从 Go 社区了解到,单方法协议使静态鸭子类型更为易用和灵活。Go 标准库有几个接口,比如 Reader——一个只需要一个 read 方法的 I/O 接口。后续如果需要一个更完整的协议,您可以组合两个或多个协议来定义一个新的协议。

使用随机挑选元素的容器不一定需要重新加载容器,但它肯定需要一种方法来进行元素的挑选,因此这就是我将为最小的 RandomPicker 协议选择的方法。该协议的代码在示例 13-18 中,并在示例 13-19 中的测试展示了其用法。

例 13-18。 randompick.py:RandomPicker 的定义。

from typing import Protocol, runtime_checkable, Any

@runtime_checkable
class RandomPicker(Protocol):
    def pick(self) -> Any: ...

Note:

pick 方法返回 Any。在“实现通用静态协议”中,我们将看到如何使 RandomPicker 成为带有参数的泛型类型,以让协议的用户指定 pick 方法的返回类型。

例 13-19。 randompick_test.py:使用 RandomPicker。

import random
from typing import Any, Iterable, TYPE_CHECKING

from randompick import RandomPicker  1

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

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

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

def test_item_type() -> None:  7
    items = [1, 2]
    popper = SimplePicker(items)
    item = popper.pick()
    assert item in items
    if TYPE_CHECKING:
        reveal_type(item)  8
    assert isinstance(item, int)
  1. 没有必要导入静态协议来定义实现该协议的类。在这里,我导入了 RandomPicker 只是为了在下面使用它 test_isintance。
  2. SimplePicker 实现了 RandomPicker协议——但它没有继承RandomPicker。这是静态鸭子类型。
  3. Any 是默认的返回类型,所以这个注解并不是绝对必要的,但它确实让我们更清楚地表明我们正在实现示例 13-18 中定义的 RandomPicker 协议。
  4. 如果您想让 Mypy 查看它们,请不要忘记在您的测试中添加 -> None 提示。
  5. 我为 popper 变量添加了一个类型提示,以表明 Mypy 理解 SimplePicker和RandomPicker 是一致的。
  6. 这个测试证明了 SimplePicker 的一个实例也是 RandomPicker 的一个实例。这是因为 @runtime_checkable 装饰器应用于 RandomPicker协议,并且 SimplePicker 有一个按协议要求的 pick 方法。
  7. 此测试调用SimplePicker的 pick 方法,验证它是否返回提供给 SimplePicker 的项,然后对返回的项进行静态检查和运行时检查。
  8. 此行在 Mypy 的输出中生成一个注释。

正如我们在示例 8-22 中看到的,reveal_type 是 Mypy 识别的“魔法”函数。这就是为什么它没有被导入,我们只能在 Typing.TYPE_CHECKING为True的块内部调用它。

示例 13-19 中的两个测试都通过了。 Mypy 也没有检测到该代码中的任何错误,并在 pick 返回的项上展示了reveal_type 的结果:

$ mypy randompick_test.py
randompick_test.py:24: note: Revealed type is 'Any'

创建了第一个协议后,让我们研究一些关于这个问题的建议。

协议设计的最佳实践

经过 10 年在 Go 中使用静态鸭子类型的经验,很明显窄协议更有用——通常这样的协议只有一个方法;很少超过几种方法。Martin Fowler 写了一篇定义 Role Interface的帖子,这是一个在设计协议时需要牢记的有用想法。

此外,有时您会看到协议定义在使用它的函数附近——也就是说,在“客户端代码”中定义而不是在库中定义。这使得创建新类型来调用该函数变得容易——这有利于扩展性和模拟测试。

窄协议和客户端代码协议的实践都避免了不必要的紧耦合,符合 The Interface Segregation Principle,我们可以将其总结为“不应强迫客户端依赖他们不使用的接口”。

Contributing to typeshed 页面推荐了静态协议的命名约定(以下三点逐字引用):

  • 对代表明确概念的协议使用普通名称(例如Iterator,Container)。
  • 将 SupportsX 用于提供可调用方法的协议(例如 SupportsInt、SupportsRead、SupportsReadSeek)。
  • 将 HasX 用于具有可读和/或可写属性或 getter/setter 方法(例如 HasItems、HasFileno)的协议。

Go 标准库有一个我喜欢的命名约定:对于单一方法协议,如果方法名称是动词,则附加“-er”或“-or”使协议成为名词。例如,协议名称不是 SupportsRead,而是 Reader。更多示例:Formatter、Animator、Scanner。如需灵感,请参阅 Asuka Kenji 的 Go (Golang) Standard Library Interfaces (Selected)。

创建简约协议的一个很好的理由是,如果需要,可以在以后扩展它们。我们现在将看到很容易使用附加方法创建派生协议。

扩展协议

正如我在上一节开始时提到的,Go 开发人员主张在定义接口时采用极简主义——静态协议的名称,在有些情况下这种做法并不适用:许多最广泛使用的 Go 接口都只有一个方法。

当实践表明需要具有更多方法的协议时,与其在原始协议中添加方法,不如从中派生出一个新协议。在 Python 中扩展静态协议有一些注意事项,如示例 13-20 所示。

例 13-20。 randompickload.py:扩展 RandomPicker。

from typing import Protocol, runtime_checkable
from randompick import RandomPicker

@runtime_checkable  1
class LoadableRandomPicker(RandomPicker, Protocol):  2
    def load(self, Iterable) -> None: ...  3
  1. 如果您希望派生协议在运行时可检查,您必须再次应用@runtime_checkable装饰器——它的行为不会被继承
  2. 除了我们正在扩展的协议之外,每个协议都必须显示继承typing.Protocol。这与 Python 中继承的工作方式不同。
  3. 回到“常规”的OOP:我们只需要声明这个派生协议中的新方法。 pick 方法声明继承自 RandomPicker。

本章中定义和使用静态协议的最后一个例子到此结束。

为了结束本章,我们将讨论数字 ABC 及其可能的数字协议替代。

numbers ABC 和数字协议

正如我们在“The Fall of the Numeric Tower”中看到的那样,标准库的 numbers 包中的 ABC 可以很好地用于运行时类型检查。

如果你需要检查是否为整数类型,您可以使用 isinstance(x, numbers.Integral) 来接受 int、bool(它是 int 的子类)或外部库提供的其他整数类型,这些类型将它们的类型注册为numbers ABC 的虚拟子类。例如,NumPy 有 21 种整数类型——以及注册为 numbers.Real 的浮点类型的几种变体,以及注册为 numbers.Complex 的具有各种位宽的复数。

TIP:

有点令人惊讶的是,decimal.Decimal 没有注册为 numbers.Real 的虚拟子类。原因是,如果您在程序中需要 Decimal 的精度,那么您希望避免decimal与不太精确的浮点数意外混合。

遗憾的是,数字塔不是为静态类型检查而设计的。根 ABC——numbers.Number——不包括任何方法,所以如果你声明 x:Number 那么 Mypy 不会检查做算术或调用 x.footnote 上的任何方法:当我在 2021 年 7 月回顾时,Mypy 项目中的未解决问题名为“int is not a Number?”

numbers ABC不支持静态检查,有哪些方式能够支持?

寻找类型解决方案的好地方是 typeshed 项目。作为 Python 标准库的一部分,statistics 模块有一个相应的  statistics.pyi 存根文件,其中包含类型提示。您会在那里找到以下定义,这些定义用于注解多个函数:

_Number = Union[float, Decimal, Fraction]
_NumberT = TypeVar('_NumberT', float, Decimal, Fraction)

这种方法是正确的,但有局限性。因为不支持标准库之外的数字类型,当数字类型注册为虚拟子类时,numbers ABC 在运行时支持类型检查。

目前的趋势是推荐typing模块提供的数字协议,我们在“运行时可检查的静态协议”中讨论过。

不幸的是,在运行时,数字协议可能会让您失望。正如“运行时协议检查的限制”中所述,Python 3.9 中的complex类型实现了 __float__,但该方法的存在只是为了抛出带有显式消息的 TypeError:“can’t convert complex to float“。出于同样的原因,它也实现了 __int__。这些方法的存在使得 isinstance 在 Python 3.9 及以前的版本返回误导性结果。在 Python 3.10 中,python删除了总是抛出 TypeError 的 complex 方法。

另一方面,NumPy 的complex类型实现了 __float__ 和 __int__ 方法,它们只在第一次使用时发出警告:

>>> import numpy as np
>>> cd = np.cdouble(3+4j)
>>> cd
(3+4j)
>>> float(cd)
<stdin>:1: ComplexWarning: Casting complex values to real discards the imaginary part
3.0

相反的问题也会发生:内置的 complex、float 和 int,以及 numpy.float16、numpy.uint8 没有实现 __complex__ 方法,所以 isinstance(x, SupportsComplex) 的结果返回 False。而NumPy的 complex类型,例如 np.complex64 确实实现了 __complex__ 以转换为内置complex类型。

然而,在实践中,complex() 内置构造函数可以处理所有这些类型的实例,且不抛出错误或警告:

>>> import numpy as np
>>> from typing import SupportsComplex
>>> sample = [1+0j, np.complex64(1+0j), 1.0, np.float16(1.0), 1, np.uint8(1)]
>>> [isinstance(x, SupportsComplex) for x in sample]
[False, True, False, False, False, False]
>>> [complex(x) for x in sample]
[(1+0j), (1+0j), (1+0j), (1+0j), (1+0j), (1+0j)]

这表明针对 SupportsComplex 的 isinstance 检查表明这些转换为 complex 会失败,但它们可以转化为Complex类型。在 Typing-sig 邮件列表中,Guido van Rossum 指出内置的complex接受单个参数,这就是这些转换成功的原因。

另一方面,Mypy 在调用 to_complex() 函数时接受所有这六种类型的参数,其定义如下:

def to_complex(n: SupportsComplex) -> complex:
    return complex(n)

在我写这篇文章时,NumPy 没有类型提示,所以它的数字类型都是 Any。另一方面,Mypy 以某种方式“意识到”内置 int 和 float 可以转换为 complex,即使在 typeshed 中只有内置 complex 类具有 __complex__ 方法。

综上所述,虽然数字类型应该不难进行类型检查,但目前的情况是这样的:类型提示 PEP-484 避开了数字塔,并隐式建议类型检查器对内置的 complex、float 和 int 之间的子类型关系进行硬编码以作为特殊情况。Mypy 做到了这一点,并且务实地接受 int 和 float 与 SupportsComplex 一致,即使它们没有实现 __complex__。

TIP:

我只在Complex类型使用带有数字 Supports* 协议的 isinstance 检查时发现了意外的结果。如果您不使用complex,则可以依靠这些协议而不是数字 ABC。

本节的主要内容是:

  • numbers ABC 适合运行时类型检查,但不适用于静态类型检查;
  • 数字静态协议 SupportsComplex、SupportsFloat 等适用于静态类型,但在涉及complex时的运行时类型检查并不可靠。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
因文件超过20M不能上传,所以拆分为两个文件分次上传 第1章 COM背景知识 1.1 COM的起源 1.1.1 软件业面临的挑战 1.1.2 传统解决方案 1.1.3 面向对象程序设计方法 1.1.4 最终解决方案:组件软件 1.1.5 面向对象的组件模型——COM 1.2 COM的发展历程 1.2.1 COM以前的对象技术:DDE、OLE 1、VBX控件 1.2.2 COM首次亮相:OLE2 1.2.3 Microsoft拥抱Internet:ActiveX 1.2.4 更多的新名词:Windows DNA和COM+ 1.2.5 远程对象:ORBs和DCOM 1.2.6 COM的最新版本:COM+ 1.3 COM技术现状 1.3.1 COM与CORBA 1.3.2 COM与Enterprise Java Beans 1.3.3 Windows之外的COM 小结 第2章 从C++到COM 2.1 C++客户重用C++对象——例程DB 2.1.1 C++对象 2.1.2 客户程序 2.2 将C++对象移进DLL中——例程DB_cppdll 2.2.1 成员函数的引出 2.2.2 内存分配 2.2.3 Unicode/ASCII兼容 2.2.4 例程实现 2.2.4.1 修改接口文件 2.2.4.2 修改对象程序 2.2.4.3 修改客户程序 2.3 C++对象使用抽象基类——例程DB_vtbl 2.3.1 问题:私有数据成员被暴露 2.3.2 解决方案:抽象基类 2.3.2.1 什么是抽象基类(Abstract Base Class) 2.3.2.2 实现秘诀:虚函数(Virtual Functions) 2.3.3 使用抽象基类 2.3.4 例程实现 2.3.4.1 修改接口文件 2.3.4.2 修改对象程序 2.3.4.3 修改客户程序 2.4 改由COM库装载C++对象——例程dbalmostcom 2.4.1 COM库 2.4.2 对象创建的标准入口点 2.4.3 标准对象创建API 2.4.4 标准对象注册 2.4.5 例程实现 2.4.5.1 修改接口文件 2.4.5.2 修改对象程序 2.4.5.3 修改客户程序 2.5 将C++对象变成COM对象 2.5.1 引用计数 2.5.2 多接口 2.5.3 IUnknown接口 2.5.4 标准类厂接口:IClassFactory 2.5.5 对象代码的动态卸载 2.5.6 自动注册 2.5.7 例程实现 2.5.7.1 修改接口文件 2.5.7.2 修改对象程序 2.5.7.3 修改客户程序 2.6 为COM对象添加多接口支持 2.6.1 多接口 2.6.2 DEFINE_GUID 2.6.3 例程实现 2.6.3.1 修改接口文件 2.6.3.2 修改对象程序 2.6.3.3 修改客户程序 小结 第3章 COM基础知识 3.1 对象与接口 3.1.1 COM对象 3.1.2 COM接口 3.1.3 IUnknown接口 3.1.3.1 生存期控制:AddRef和Release 3.1.3.2 接口查询:QueryInterface 3.1.4 全球唯一标识符GUID 3.1.5 COM接口定义 3.1.6 接口描述语言IDL 3.2 COM应用模型 3.2.1 客户/服务器模型 3.2.2 进程内组件 3.2.3 进程外组件 3.2.4 COM库 3.2.5 HRESULT返回值 3.2.6 COM与注册表 3.3 COM组件 3.3.1 实现类厂对象 3.3.2 类厂对象的创建 3.3.3 实现自动注册 3.3.4 实现自动卸载 3.4 COM客户 3.4.1 COM对象创建函数 3.4.1.1 CoGetClassObject 3.4.1.2 CoCreateInstance 3.4.1.3 CoCreateInstanceEx 3.4.2 如何调用进程内组件 3.4.3 COM客户调用进程外组件 3.5 进一步认识COM 3.5.1 可重用机制:包容和聚合 3.5.2 进程透明性 3.5.3 安全性机制 小结 第4章 COM扩展技术 4.1 可连接对象机制 4.1.1 客户、接收器与可连接对象 4.1.1.1 接收器 4.1.1.2 可连接对象 4.1.1.3 客户 4.1.2 实现可连接对象 4.1.3 实现接收器 4.1.4 建立接收器与连接点的连接 4.1.5 获得出接口的类型信息 4.2 结构化存储 4.2.1 什么叫结构化存储和复合文件 4.2.2 存储对象和IStorage接口 4.2.2.1 IStorage接口 4.2.2.2 获得IStorage指针 4.2.2.3 释放STATSTG内存 4.2.2.4 枚举存储对象中的元

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值