流畅的 Python 第二版(GPT 重译)(七)(2)https://developer.aliyun.com/article/1484635
Python 3.10.0b4 中修复了complex类型的特定问题,移除了complex.__float__方法。
但总体问题仍然存在:isinstance/issubclass检查只关注方法的存在或不存在,而不检查它们的签名,更不用说它们的类型注释了。而且这不太可能改变,因为这样的运行时类型检查会带来无法接受的性能成本。¹⁹
现在让我们看看如何在用户定义的类中实现静态协议。
支持静态协议
回想一下我们在第十一章中构建的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) # ①
①
这假设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: # ① return math.hypot(self.x, self.y) def __complex__(self) -> complex: # ② return complex(self.x, self.y) @classmethod def fromcomplex(cls, datum: SupportsComplex) -> Vector2d: # ③ c = complex(datum) # ④ return cls(c.real, c.imag)
①
需要float返回注释,否则 Mypy 推断为Any,并且不检查方法体。
②
即使没有注释,Mypy 也能推断出这返回一个complex。根据您的 Mypy 配置,注释可以避免警告。
③
这里SupportsComplex确保datum是可转换的。
④
这种显式转换是必要的,因为SupportsComplex类型没有声明.real和.imag属性,这在下一行中使用。例如,Vector2d没有这些属性,但实现了__complex__。
如果在模块顶部出现from __future__ import annotations,fromcomplex的返回类型可以是Vector2d。这个导入会导致类型提示被存储为字符串,而不会在导入时被评估,当函数定义被评估时。没有__future__导入annotations,此时Vector2d是一个无效的引用(类尚未完全定义),应该写成字符串:'Vector2d',就好像它是一个前向引用一样。这个__future__导入是在PEP 563—注解的延迟评估中引入的,实现在 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: ...
注意
pick方法返回Any。在“实现通用静态协议”中,我们将看到如何使RandomPicker成为一个带有参数的通用类型,让协议的使用者指定pick方法的返回类型。
示例 13-19。randompick_test.py:RandomPicker的使用
import random from typing import Any, Iterable, TYPE_CHECKING from randompick import RandomPicker # ① 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) • 1
①](#co_interfaces__protocols__and_abcs_CO14-1)
定义实现它的类时,不需要导入静态协议。这里我只导入RandomPicker是为了稍后在test_isinstance中使用它。
②](#co_interfaces__protocols__and_abcs_CO14-2)
SimplePicker实现了RandomPicker——但它并没有继承它。这就是静态鸭子类型的作用。
③](#co_interfaces__protocols__and_abcs_CO14-3)
Any是默认返回类型,因此此注释并不是严格必要的,但它确实使我们正在实现示例 13-18 中定义的RandomPicker协议更清晰。
④](#co_interfaces__protocols__and_abcs_CO14-4)
如果你希望 Mypy 查看测试,请不要忘记为你的测试添加-> None提示。
⑤](#co_interfaces__protocols__and_abcs_CO14-5)
我为popper变量添加了一个类型提示,以显示 Mypy 理解SimplePicker是与之一致的。
⑥](#co_interfaces__protocols__and_abcs_CO14-6)
这个测试证明了SimplePicker的一个实例也是RandomPicker的一个实例。这是因为@runtime_checkable装饰器应用于RandomPicker,并且SimplePicker有一个所需的pick方法。
⑦](#co_interfaces__protocols__and_abcs_CO14-7)
这个测试调用了SimplePicker的pick方法,验证它是否返回了给SimplePicker的项目之一,然后对返回的项目进行了静态和运行时检查。
⑧
这行代码会在 Mypy 输出中生成一个注释。
正如我们在示例 8-22 中看到的,reveal_type是 Mypy 识别的“魔术”函数。这就是为什么它不被导入,我们只能在typing.TYPE_CHECKING保护的if块内调用它,这个块只有在静态类型检查器的眼中才是True,但在运行时是False。
示例 13-19 中的两个测试都通过了。Mypy 在该代码中没有看到任何错误,并显示了pick返回的item上reveal_type的结果:
$ mypy randompick_test.py randompick_test.py:24: note: Revealed type is 'Any'
创建了我们的第一个协议后,让我们研究一些相关建议。
协议设计的最佳实践
在使用 Go 中的静态鸭子类型 10 年后,很明显,窄协议更有用——通常这样的协议只有一个方法,很少有超过两个方法。Martin Fowler 撰写了一篇定义角色接口的文章,在设计协议时要记住这个有用的概念。
有时候你会看到一个协议在使用它的函数附近定义——也就是说,在“客户端代码”中定义,而不是在库中定义。这样做可以轻松创建新类型来调用该函数,这对于可扩展性和使用模拟进行测试是有益的。
窄协议和客户端代码协议的实践都避免了不必要的紧密耦合,符合接口隔离原则,我们可以总结为“客户端不应被迫依赖于他们不使用的接口”。
页面“贡献给 typeshed”推荐了这种静态协议的命名约定(以下三点引用原文):
- 对于代表清晰概念的协议,请使用简单名称(例如,
Iterator,Container)。 - 对于提供可调用方法的协议,请使用
SupportsX(例如,SupportsInt,SupportsRead,SupportsReadSeek)。²¹ - 对于具有可读和/或可写属性或 getter/setter 方法的协议,请使用
HasX(例如,HasItems,HasFileno)。
Go 标准库有一个我喜欢的命名约定:对于单方法协议,如果方法名是动词,可以添加“-er”或“-or”以使其成为名词。例如,不要使用SupportsRead,而是使用Reader。更多示例包括Formatter,Animator和Scanner。有关灵感,请参阅 Asuka Kenji 的“Go(Golang)标准库接口(精选)”。
创建简约协议的一个好理由是以后可以根据需要扩展它们。我们现在将看到创建一个带有额外方法的派生协议并不困难。
扩展协议
正如我在上一节开始时提到的,Go 开发人员在定义接口时倾向于保持最小主义——他们称之为静态协议。许多最广泛使用的 Go 接口只有一个方法。
当实践表明一个具有更多方法的协议是有用的时候,与其向原始协议添加方法,不如从中派生一个新协议。在 Python 中扩展静态协议有一些注意事项,正如示例 13-20 所示。
示例 13-20. randompickload.py: 扩展RandomPicker
from typing import Protocol, runtime_checkable from randompick import RandomPicker @runtime_checkable # ① class LoadableRandomPicker(RandomPicker, Protocol): # ② def load(self, Iterable) -> None: ... # ③
①
如果希望派生协议可以进行运行时检查,必须再次应用装饰器——其行为不会被继承。²²
②
每个协议必须明确将typing.Protocol命名为其基类之一,除了我们正在扩展的协议。这与 Python 中继承的方式不同。²³
③
回到“常规”面向对象编程:我们只需要声明这个派生协议中新增的方法。pick方法声明是从RandomPicker继承的。
这结束了本章中定义和使用静态协议的最终示例。
为了结束本章,我们将讨论数字 ABCs 及其可能被数字协议替代的情况。
数字 ABCs 和数字协议
正如我们在“数字塔的崩塌”中看到的,标准库中numbers包中的 ABCs 对于运行时类型检查效果很好。
如果需要检查整数,可以使用isinstance(x, numbers.Integral)来接受int、bool(它是int的子类)或其他由外部库提供并将其类型注册为numbers ABCs 虚拟子类的整数类型。例如,NumPy 有21 种整数类型——以及几种浮点类型注册为numbers.Real,以及以不同位宽注册为numbers.Complex的复数。
提示
令人惊讶的是,decimal.Decimal并未注册为numbers.Real的虚拟子类。原因是,如果您的程序需要Decimal的精度,那么您希望受到保护,以免将精度较低的浮点数与Decimal混合。
遗憾的是,数字塔并不适用于静态类型检查。根 ABC——numbers.Number——没有方法,因此如果声明x: Number,Mypy 将不允许您在x上进行算术运算或调用任何方法。
如果不支持numbers ABCs,那么还有哪些选项?
寻找类型解决方案的好地方是typeshed项目。作为 Python 标准库的一部分,statistics模块有一个对应的statistics.pyi存根文件,其中包含了对typeshed上几个函数进行类型提示的定义。在那里,您会找到以下定义,用于注释几个函数:
_Number = Union[float, Decimal, Fraction] _NumberT = TypeVar('_NumberT', float, Decimal, Fraction)
这种方法是正确的,但有限。它不支持标准库之外的数字类型,而numbers ABCs 在运行时支持这些数字类型——当数字类型被注册为虚拟子类时。
当前的趋势是推荐typing模块提供的数字协议,我们在“可运行时检查的静态协议”中讨论过。
不幸的是,在运行时,数字协议可能会让您失望。正如在“运行时协议检查的限制”中提到的,Python 3.9 中的complex类型实现了__float__,但该方法仅存在于引发TypeError并附带明确消息“无法将复数转换为浮点数”:同样的原因,它也实现了__int__。这些方法的存在使得在 Python 3.9 中isinstance返回误导性的结果。在 Python 3.10 中,那些无条件引发TypeError的complex方法被移除了。²⁴
另一方面,NumPy 的复数类型实现了__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 的复数类型,如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)]
这表明isinstance检查对SupportsComplex的转换表明这些转换为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__。
提示
当我尝试将数值Supports*协议与complex进行转换时,使用isinstance检查时我只发现了意外结果。如果你不使用复数,你可以依赖这些协议而不是numbers ABCs。
本节的主要要点是:
numbersABCs 适用于运行时类型检查,但不适用于静态类型检查。- 数值静态协议
SupportsComplex、SupportsFloat等在静态类型检查时效果很好,但在涉及复数时在运行时类型检查时不可靠。
现在我们准备快速回顾本章内容。
章节总结
键盘映射(图 13-1)是理解本章内容的关键。在简要介绍了四种类型方法后,我们对比了动态和静态协议,分别支持鸭子类型和静态鸭子类型。这两种类型的协议共享一个基本特征,即类永远不需要明确声明支持任何特定协议。一个类通过实现必要的方法来支持一个协议。
接下来的主要部分是“编程鸭子”,我们探讨了 Python 解释器为使序列和可迭代动态协议工作所做的努力,包括部分实现两者。然后我们看到一个类如何通过动态添加额外方法来在运行时实现一个协议,通过猴子补丁。鸭子类型部分以防御性编程的提示结束,包括使用try/except检测结构类型而无需显式的isinstance或hasattr检查,并快速失败。
在 Alex Martelli 介绍鹅类型之后“水禽和 ABCs”,我们看到如何对现有的 ABCs 进行子类化,调查了标准库中重要的 ABCs,并从头开始创建了一个 ABC,然后通过传统的子类化和注册来实现。为了结束这一部分,我们看到__subclasshook__特殊方法如何使 ABCs 能够通过识别提供符合 ABC 中定义接口的方法的不相关类来支持结构类型。
最后一个重要部分是“静态协议”,我们在这里恢复了静态鸭子类型的覆盖范围,这始于第八章,在“静态协议”中。我们看到@runtime_checkable装饰器如何利用__subclasshook__来支持运行时的结构化类型,尽管最佳使用静态协议的方式是与静态类型检查器一起使用,这样可以考虑类型提示以使结构化类型更可靠。接下来,我们讨论了静态协议的设计和编码以及如何扩展它。本章以“数字 ABCs 和数字协议”结束,讲述了数字塔的荒废状态以及提出的替代方案存在的一些缺陷:Python 3.8 中添加到typing模块的数字静态协议,如SupportsFloat等。
本章的主要信息是我们在现代 Python 中有四种互补的接口编程方式,每种方式都有不同的优势和缺点。在任何规模较大的现代 Python 代码库中,您可能会发现每种类型方案都有适用的用例。拒绝这些方法中的任何一种都会使您作为 Python 程序员的工作变得比必要的更加困难。
话虽如此,Python 在仅支持鸭子类型的情况下取得了广泛的流行。其他流行的语言,如 JavaScript、PHP 和 Ruby,以及 Lisp、Smalltalk、Erlang 和 Clojure 等不那么流行但非常有影响力的语言,都通过利用鸭子类型的力量和简单性产生了巨大影响。
进一步阅读
要快速了解类型的利弊,以及typing.Protocol对于静态检查代码库健康的重要性,我强烈推荐 Glyph Lefkowitz 的帖子“我想要一个新的鸭子:typing.Protocol和鸭子类型的未来”。我还从他的帖子“接口和协议”中学到了很多,比较了typing.Protocol和zope.interface——一种早期用于在松散耦合的插件系统中定义接口的机制,被Plone CMS、Pyramid web framework和Twisted异步编程框架等项目使用,这是 Glyph 创建的一个项目。²⁸
有关 Python 的优秀书籍几乎可以定义为对鸭子类型的广泛覆盖。我最喜欢的两本 Python 书籍在Fluent Python第一版之后发布了更新:Naomi Ceder 的The Quick Python Book第 3 版(Manning)和 Alex Martelli、Anna Ravenscroft 和 Steve Holden(O’Reilly)的Python in a Nutshell第 3 版。
有关动态类型的利弊讨论,请参阅 Guido van Rossum 与 Bill Venners 的访谈“Python 中的合同:与 Guido van Rossum 的对话,第四部分”。Martin Fowler 在他的帖子“动态类型”中对这场辩论进行了深入而平衡的探讨。他还写了“角色接口”,我在“最佳协议设计实践”中提到过。尽管这不是关于鸭子类型的,但这篇文章对 Python 协议设计非常相关,因为他对比了狭窄的角色接口与一般类的更广泛的公共接口。
Mypy 文档通常是与 Python 中静态类型相关的任何信息的最佳来源,包括他们在“协议和结构子类型”章节中讨论的静态鸭子类型。
剩下的参考资料都是关于鹅类型的。Beazley 和 Jones 的*Python Cookbook*,第 3 版(O’Reilly)有一节关于定义 ABC(Recipe 8.12)。这本书是在 Python 3.4 之前编写的,所以他们没有使用现在更受欢迎的通过从abc.ABC子类化来声明 ABC 的语法(相反,他们使用了metaclass关键字,我们只在第二十四章中真正需要它)。除了这个小细节,这个配方很好地涵盖了主要的 ABC 特性。
Doug Hellmann 的Python 标准库示例(Addison-Wesley)中有一章关于abc模块。它也可以在 Doug 出色的PyMOTW—Python 本周模块网站上找到。Hellmann 还使用了旧式的 ABC 声明方式:PluginBase(metaclass=abc.ABCMeta),而不是自 Python 3.4 起可用的更简单的PluginBase(abc.ABC)。
在使用 ABCs 时,多重继承不仅很常见,而且几乎是不可避免的,因为每个基本集合 ABCs—Sequence、Mapping和Set—都扩展了Collection,而Collection又扩展了多个 ABCs(参见图 13-4)。因此,第十四章是本章的一个重要后续。
PEP 3119—引入抽象基类 提供了 ABC 的理由。PEP 3141—数字类型的类型层次结构 展示了numbers模块的 ABC,但在 Mypy 问题#3186 “int is not a Number?”的讨论中包含了一些关于为什么数字塔不适合静态类型检查的论点。Alex Waygood 在 StackOverflow 上写了一个全面的答案,讨论了注释数字类型的方法。我将继续关注 Mypy 问题#3186,期待这个传奇的下一章有一个让静态类型和鹅类型兼容的美好结局——因为它们应该是兼容的。
¹ 设计模式:可复用面向对象软件的元素,“介绍”,p. 18。
² Wikipedia 上的“猴子补丁”文章中有一个有趣的 Python 示例。
³ 这就是为什么自动化测试是必要的。
⁴ Bjarne Stroustrup, C++的设计与演化, p. 278 (Addison-Wesley)。
⁵ 检索日期为 2020 年 10 月 18 日。
⁶ 当然,你也可以定义自己的 ABCs,但我会劝阻除了最高级的 Pythonista 之外的所有人这样做,就像我会劝阻他们定义自己的自定义元类一样……即使对于那些拥有对语言的每一个折叠和褶皱深度掌握的“最高级的 Pythonista”来说,这些都不是经常使用的工具。这种“深度元编程”,如果适用的话,是为了那些打算由大量独立开发团队扩展的广泛框架的作者而设计的……不到“最高级的 Pythonista”的 1%可能会需要这个! — A.M.
⁷ 多重继承被认为是有害的,并且在 Java 中被排除,除了接口:Java 接口可以扩展多个接口,Java 类可以实现多个接口。
⁸ 或许客户需要审计随机器;或者机构想提供一个作弊的随机器。你永远不知道……
⁹ “注册”和“虚拟子类”不是标准的 UML 术语。我使用它们来表示一个特定于 Python 的类关系。
¹⁰ 在抽象基类存在之前,抽象方法会引发NotImplementedError来表示子类负责实现它们。在 Smalltalk-80 中,抽象方法体会调用subclassResponsibility,这是从object继承的一个方法,它会产生一个带有消息“我的子类应该重写我的消息之一”的错误。
¹¹ 完整的树在《Python 标准库》文档的“5.4. 异常层次结构”部分中。
¹² @abc.abstractmethod在abc模块文档中的条目。
¹³ 第六章中的“使用可变参数进行防御性编程”专门讨论了我们刚刚避免的别名问题。
¹⁴ 我用load()的相同技巧无法用于loaded(),因为list类型没有实现__bool__,我必须绑定到loaded的方法。bool() 内置不需要__bool__就能工作,因为它也可以使用__len__。请参阅 Python 文档的“内置类型”章节中的“4.1. 真值测试”。
¹⁵ 在“多重继承和方法解析顺序”中有一个完整的解释__mro__类属性的部分。现在,这个简短的解释就够了。
¹⁶ 类型一致性的概念在“子类型与一致性”中有解释。
¹⁷ 好吧,double() 并不是很有用,除了作为一个例子。但是在 Python 3.8 添加静态协议之前,Python 标准库有许多函数无法正确注释。我通过使用协议添加类型提示来帮助修复了 typeshed 中的一些错误。例如,修复“Mypy 是否应该警告可能无效的 max 参数?”的拉取请求利用了一个 _SupportsLessThan 协议,我用它增强了 max、min、sorted 和 list.sort 的注释。
¹⁸ __slots__ 属性与当前讨论无关—这是我们在“使用 slots 节省内存”中讨论的优化。
¹⁹ 感谢 PEP 544(关于协议)的合著者伊万·列夫基夫斯基指出,类型检查不仅仅是检查x的类型是否为T:它是关于确定x的类型与T是一致的,这可能是昂贵的。难怪 Mypy 即使对短小的 Python 脚本进行类型检查也需要几秒钟的时间。
²⁰ 阅读 Python Steering Council 在 python-dev 上的决定。
²¹ 每个方法都是可调用的,所以这个准则并没有说太多。也许“提供一个或两个方法”?无论如何,这只是一个指导方针,不是一个严格的规则。
²² 有关详细信息和原理,请参阅 PEP 544 中关于@runtime_checkable的部分—协议:结构子类型(静态鸭子类型)。
²³ 再次,请阅读 PEP 544 中关于“合并和扩展协议”的详细信息和原理。
²⁴ 请参阅Issue #41974—删除 complex.__float__、complex.__floordiv__ 等。
²⁵ 我没有测试 NumPy 提供的所有其他浮点数和整数变体。
²⁶ NumPy 的数字类型都已注册到相应的numbers ABCs 中,但 Mypy 忽略了这一点。
²⁷ 这是 typeshed 的一种善意的谎言:截至 Python 3.9,内置的complex类型实际上并没有__complex__方法。
²⁸ 感谢技术审阅者 Jürgen Gmach 推荐“接口和协议”文章。
第十四章:继承:是好是坏
[…] 我们需要一个更好的关于继承的理论(现在仍然需要)。例如,继承和实例化(这是一种继承)混淆了实用性(例如为了节省空间而分解代码)和语义(用于太多任务,如:专门化、泛化、种类化等)。
Alan Kay,“Smalltalk 的早期历史”¹
本章讨论继承和子类化。我假设你对这些概念有基本的了解,你可能从阅读Python 教程或从其他主流面向对象语言(如 Java、C#或 C++)的经验中了解这些概念。在这里,我们将重点关注 Python 的四个特点:
- super()函数
- 从内置类型继承的陷阱
- 多重继承和方法解析顺序
- Mixin 类
多重继承是一个类具有多个基类的能力。C++支持它;Java 和 C#不支持。许多人认为多重继承带来的麻烦不值得。在早期 C++代码库中被滥用后,Java 故意将其排除在外。
本章介绍了多重继承,供那些从未使用过的人,并提供了一些关于如何应对单一或多重继承的指导,如果你必须使用它。
截至 2021 年,对继承的过度使用存在明显的反对意见,不仅仅是多重继承,因为超类和子类之间紧密耦合。紧密耦合意味着对程序的某一部分进行更改可能会在其他部分产生意想不到的深远影响,使系统变得脆弱且难以理解。
然而,我们必须维护设计有复杂类层次结构的现有系统,或者使用强制我们使用继承的框架——有时甚至是多重继承。
我将通过标准库、Django 网络框架和 Tkinter GUI 工具包展示多重继承的实际用途。
本章新内容
本章主题没有与 Python 相关的新功能,但我根据第二版技术审阅人员的反馈进行了大量编辑,特别是 Leonardo Rochael 和 Caleb Hattingh。
我写了一个新的开头部分,重点关注super()内置函数,并更改了“多重继承和方法解析顺序”中的示例,以更深入地探讨super()如何支持协作式 多重继承。
“Mixin 类”也是新内容。“现实世界中的多重继承”已重新组织,并涵盖了标准库中更简单的 mixin 示例,然后是复杂的 Django 和复杂的 Tkinter 层次结构。
正如章节标题所示,继承的注意事项一直是本章的主要主题之一。但越来越多的开发人员认为这是一个问题,我在“章节总结”和“进一步阅读”的末尾添加了几段关于避免继承的内容。
我们将从神秘的super()函数的概述开始。
super()函数
对于可维护的面向对象 Python 程序,一致使用super()内置函数至关重要。
当子类重写超类的方法时,通常需要调用超类的相应方法。以下是推荐的方法,来自collections模块文档中的一个示例,“OrderedDict 示例和配方”部分:²
class LastUpdatedOrderedDict(OrderedDict): """Store items in the order they were last updated""" def __setitem__(self, key, value): super().__setitem__(key, value) self.move_to_end(key)
为了完成其工作,LastUpdatedOrderedDict重写了__setitem__以:
- 使用
super().__setitem__调用超类上的该方法,让其插入或更新键/值对。 - 调用
self.move_to_end以确保更新的key位于最后位置。
调用重写的__init__方法特别重要,以允许超类在初始化实例时发挥作用。
提示
如果你在 Java 中学习面向对象编程,可能会记得 Java 构造方法会自动调用超类的无参构造方法。Python 不会这样做。你必须习惯编写这种模式:
def __init__(self, a, b) : super().__init__(a, b) ... # more initialization code
你可能见过不使用super()而是直接在超类上调用方法的代码,就像这样:
class NotRecommended(OrderedDict): """This is a counter example!""" def __setitem__(self, key, value): OrderedDict.__setitem__(self, key, value) self.move_to_end(key)
这种替代方法在这种特定情况下有效,但出于两个原因不建议使用。首先,它将基类硬编码了。OrderedDict的名称出现在class语句中,也出现在__setitem__中。如果将来有人更改class语句以更改基类或添加另一个基类,他们可能会忘记更新__setitem__的内容,从而引入错误。
第二个原因是,super实现了处理具有多重继承的类层次结构的逻辑。我们将在“多重继承和方法解析顺序”中回顾这一点。为了总结这个关于super的复习,回顾一下在 Python 2 中我们如何调用它,因为旧的带有两个参数的签名是具有启发性的:
class LastUpdatedOrderedDict(OrderedDict): """This code works in Python 2 and Python 3""" def __setitem__(self, key, value): super(LastUpdatedOrderedDict, self).__setitem__(key, value) self.move_to_end(key)
现在super的两个参数都是可选的。Python 3 字节码编译器在调用方法中的super()时会自动检查周围的上下文并提供这些参数。这些参数是:
type
实现所需方法的超类的搜索路径的起始位置。默认情况下,它是包含super()调用的方法所属的类。
object_or_type
对象(例如方法调用)或类(例如类方法调用)作为方法调用的接收者。默认情况下,如果super()调用发生在实例方法中,接收者就是self。
无论是你还是编译器提供这些参数,super()调用都会返回一个动态代理对象,该对象会在type参数的超类中找到一个方法(例如示例中的__setitem__),并将其绑定到object_or_type,这样在调用方法时就不需要显式传递接收者(self)了。
在 Python 3 中,你仍然可以显式提供super()的第一个和第二个参数。³ 但只有在特殊情况下才需要,例如跳过部分 MRO 进行测试或调试,或者解决超类中不希望的行为。
现在让我们讨论对内置类型进行子类化时的注意事项。
对内置类型进行子类化是棘手的
在 Python 的最早版本中,无法对list或dict等内置类型进行子类化。自 Python 2.2 起,虽然可以实现,但有一个重要的警告:内置类型的代码(用 C 编写)通常不会调用用户定义类中重写的方法。关于这个问题的一个简短描述可以在 PyPy 文档的“PyPy 和 CPython 之间的区别”部分中找到,“内置类型的子类”。
官方上,CPython 没有明确规定子类中重写的方法何时会被隐式调用或不会被调用。作为近似值,这些方法永远不会被同一对象的其他内置方法调用。例如,在
dict的子类中重写的__getitem__()不会被内置的get()方法调用。
示例 14-1 说明了这个问题。
示例 14-1. 我们对__setitem__的重写被内置dict的__init__和__update__方法所忽略。
>>> class DoppelDict(dict): ... def __setitem__(self, key, value): ... super().__setitem__(key, [value] * 2) # ① ... >>> dd = DoppelDict(one=1) # ② >>> dd {'one': 1} >>> dd['two'] = 2 # ③ >>> dd {'one': 1, 'two': [2, 2]} >>> dd.update(three=3) # ④ >>> dd {'three': 3, 'one': 1, 'two': [2, 2]}
①
DoppelDict.__setitem__在存储时会复制值(没有好理由,只是为了有一个可见的效果)。它通过委托给超类来实现。
②
从dict继承的__init__方法明显忽略了__setitem__的重写:'one'的值没有复制。
③
[]操作符调用我们的__setitem__,并按预期工作:'two'映射到重复的值[2, 2]。
④
dict的update方法也没有使用我们的__setitem__版本:'three'的值没有被复制。
这种内置行为违反了面向对象编程的一个基本规则:方法的搜索应始终从接收者的类(self)开始,即使调用发生在一个由超类实现的方法内部。这就是所谓的“后期绑定”,Smalltalk 之父 Alan Kay 认为这是面向对象编程的一个关键特性:在任何形式为x.method()的调用中,要调用的确切方法必须在运行时确定,基于接收者x的类。⁴ 这种令人沮丧的情况导致了我们在“标准库中 missing 的不一致使用”中看到的问题。
问题不仅限于实例内的调用——无论self.get()是否调用self.__getitem__()——还会发生在其他类的覆盖方法被内置方法调用时。示例 14-2 改编自PyPy 文档。
示例 14-2. AnswerDict的__getitem__被dict.update绕过。
>>> class AnswerDict(dict): ... def __getitem__(self, key): # ① ... return 42 ... >>> ad = AnswerDict(a='foo') # ② >>> ad['a'] # ③ 42 >>> d = {} >>> d.update(ad) # ④ >>> d['a'] # ⑤ 'foo' >>> d {'a': 'foo'}
①
AnswerDict.__getitem__总是返回42,无论键是什么。
②
ad是一个加载了键-值对('a', 'foo')的AnswerDict。
③
ad['a']返回42,如预期。
④
d是一个普通dict的实例,我们用ad来更新它。
⑤
dict.update方法忽略了我们的AnswerDict.__getitem__。
警告
直接对dict、list或str等内置类型进行子类化是容易出错的,因为内置方法大多忽略用户定义的覆盖。不要对内置类型进行子类化,而是从collections模块派生你的类,使用UserDict、UserList和UserString,这些类设计得易于扩展。
如果你继承collections.UserDict而不是dict,那么示例 14-1 和 14-2 中暴露的问题都会得到解决。请参见示例 14-3。
示例 14-3. DoppelDict2和AnswerDict2按预期工作,因为它们扩展了UserDict而不是dict。
>>> import collections >>> >>> class DoppelDict2(collections.UserDict): ... def __setitem__(self, key, value): ... super().__setitem__(key, [value] * 2) ... >>> dd = DoppelDict2(one=1) >>> dd {'one': [1, 1]} >>> dd['two'] = 2 >>> dd {'two': [2, 2], 'one': [1, 1]} >>> dd.update(three=3) >>> dd {'two': [2, 2], 'three': [3, 3], 'one': [1, 1]} >>> >>> class AnswerDict2(collections.UserDict): ... def __getitem__(self, key): ... return 42 ... >>> ad = AnswerDict2(a='foo') >>> ad['a'] 42 >>> d = {} >>> d.update(ad) >>> d['a'] 42 >>> d {'a': 42}
为了衡量子类化内置类型所需的额外工作,我将示例 3-9 中的StrKeyDict类重写为子类化dict而不是UserDict。为了使其通过相同的测试套件,我不得不实现__init__、get和update,因为从dict继承的版本拒绝与覆盖的__missing__、__contains__和__setitem__合作。UserDict子类从示例 3-9 开始有 16 行,而实验性的dict子类最终有 33 行。⁵
明确一点:本节只涉及内置类型的 C 语言代码中方法委托的问题,只影响直接从这些类型派生的类。如果你子类化了一个用 Python 编写的基类,比如UserDict或MutableMapping,你就不会受到这个问题的困扰。⁶
现在让我们关注一个在多重继承中出现的问题:如果一个类有两个超类,当我们调用super().attr时,Python 如何决定使用哪个属性,但两个超类都有同名属性?
多重继承和方法解析顺序
任何实现多重继承的语言都需要处理当超类实现同名方法时可能出现的命名冲突。这称为“菱形问题”,在图 14-1 和示例 14-4 中有所说明。
图 14-1. 左:leaf1.ping()调用的激活顺序。右:leaf1.pong()调用的激活顺序。
示例 14-4. diamond.py:类Leaf、A、B、Root形成了图 14-1 中的图形
class Root: # ① def ping(self): print(f'{self}.ping() in Root') def pong(self): print(f'{self}.pong() in Root') def __repr__(self): cls_name = type(self).__name__ return f'<instance of {cls_name}>' class A(Root): # ② def ping(self): print(f'{self}.ping() in A') super().ping() def pong(self): print(f'{self}.pong() in A') super().pong() class B(Root): # ③ def ping(self): print(f'{self}.ping() in B') super().ping() def pong(self): print(f'{self}.pong() in B') class Leaf(A, B): # ④ def ping(self): print(f'{self}.ping() in Leaf') super().ping()
①
Root提供ping、pong和__repr__以使输出更易于阅读。
②
类A中的ping和pong方法都调用了super()。
③
类B中只有ping方法调用了super()。
④
类Leaf只实现了ping,并调用了super()。
现在让我们看看在Leaf的实例上调用ping和pong方法的效果(示例 14-5)。
流畅的 Python 第二版(GPT 重译)(七)(4)https://developer.aliyun.com/article/1484637