流畅的 Python 第二版(GPT 重译)(七)(1)https://developer.aliyun.com/article/1484634
图 13-6。Exception
类层次结构的一部分。¹¹
①
LookupError
是我们在Tombola.inspect
中处理的异常。
②
IndexError
是我们尝试从序列中获取超出最后位置的索引时引发的LookupError
子类。
③
当我们使用不存在的键从映射中获取项时,会引发KeyError
。
现在我们有了自己的Tombola
ABC。为了见证 ABC 执行的接口检查,让我们尝试用一个有缺陷的实现来愚弄Tombola
,参见示例 13-8。
示例 13-8。一个虚假的Tombola
不会被发现
>>> from tombola import Tombola >>> class Fake(Tombola): # ① ... def pick(self): ... return 13 ... >>> Fake # ② <class '__main__.Fake'> >>> f = Fake() # ③ Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: Can't instantiate abstract class Fake with abstract method load
①
将Fake
声明为Tombola
的子类。
②
类已创建,目前没有错误。
③
当我们尝试实例化Fake
时,会引发TypeError
。消息非常清楚:Fake
被视为抽象,因为它未能实现Tombola
ABC 中声明的抽象方法之一load
。
所以我们定义了我们的第一个 ABC,并让它验证一个类的工作。我们很快将子类化Tombola
ABC,但首先我们必须了解一些 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
警告
堆叠函数装饰器的顺序很重要,在@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): # ① def __init__(self, items): self._randomizer = random.SystemRandom() # ② self._items = [] self.load(items) # ③ def load(self, items): self._items.extend(items) self._randomizer.shuffle(self._items) # ④ def pick(self): # ⑤ try: return self._items.pop() except IndexError: raise LookupError('pick from empty BingoCage') def __call__(self): # ⑥ self.pick()
①
这个BingoCage
类明确扩展了Tombola
。
②
假设我们将用于在线游戏。random.SystemRandom
在os.urandom(…)
函数之上实现了random
API,该函数提供了“适用于加密用途”的随机字节,根据os
模块文档。
③
将初始加载委托给.load(…)
方法。
④
我们使用我们的SystemRandom
实例的.shuffle()
方法,而不是普通的random.shuffle()
函数。
⑤
pick
的实现如示例 7-8 中所示。
⑥
__call__
也来自示例 7-8。虽然不需要满足Tombola
接口,但添加额外的方法也没有坏处。
BingoCage
继承了Tombola
的昂贵loaded
和愚蠢的inspect
方法。都可以用快得多的一行代码重写,就像示例 13-10 中那样。关键是:我们可以懒惰并只继承来自 ABC 的次优具体方法。从Tombola
继承的方法对于BingoCage
来说并不像它们本应该的那样快,但对于任何正确实现pick
和load
的Tombola
子类,它们确实提供了正确的结果。
示例 13-10 展示了Tombola
接口的一个非常不同但同样有效的实现。LottoBlower
不是洗“球”并弹出最后一个,而是从随机位置弹出。
示例 13-10。lotto.py:LottoBlower
是一个具体子类,覆盖了Tombola
的inspect
和loaded
方法。
import random from tombola import Tombola class LottoBlower(Tombola): def __init__(self, iterable): self._balls = list(iterable) # ① def load(self, iterable): self._balls.extend(iterable) def pick(self): try: position = random.randrange(len(self._balls)) # ② except ValueError: raise LookupError('pick from empty LottoBlower') return self._balls.pop(position) # ③ def loaded(self): # ④ return bool(self._balls) def inspect(self): # ⑤ return tuple(self._balls)
①
初始化程序接受任何可迭代对象:该参数用于构建一个列表。
②
random.randrange(…)
函数在范围为空时会引发ValueError
,因此我们捕获并抛出LookupError
,以便与Tombola
兼容。
③
否则,随机选择的项目将从self._balls
中弹出。
④
重写loaded
以避免调用inspect
(就像示例 13-7 中的Tombola.loaded
一样)。通过直接使用self._balls
来工作,我们可以使其更快—不需要构建一个全新的tuple
。
⑤
用一行代码重写inspect
。
示例 13-10 展示了一个值得一提的习惯用法:在__init__
中,self._balls
存储list(iterable)
而不仅仅是iterable
的引用(即,我们并没有简单地赋值self._balls = iterable
,从而给参数起了个别名)。正如在“防御性编程和‘快速失败’”中提到的,这使得我们的LottoBlower
灵活,因为iterable
参数可以是任何可迭代类型。同时,我们确保将其项存储在list
中,这样我们就可以pop
项。即使我们总是得到列表作为iterable
参数,list(iterable)
也会产生参数的副本,这是一个很好的做法,考虑到我们将从中删除项目,而客户端可能不希望提供的列表被更改。¹³
现在我们来到鹅类型的关键动态特性:使用register
方法声明虚拟子类。
一个 ABC 的虚拟子类
鹅类型的一个重要特征——也是为什么它值得一个水禽名字的原因之一——是能够将一个类注册为 ABC 的虚拟子类,即使它没有继承自它。这样做时,我们承诺该类忠实地实现了 ABC 中定义的接口——Python 会相信我们而不进行检查。如果我们撒谎,我们将被通常的运行时异常捕获。
这是通过在 ABC 上调用register
类方法来完成的。注册的类然后成为 ABC 的虚拟子类,并且将被issubclass
识别为这样,但它不会继承 ABC 的任何方法或属性。
警告
虚拟子类不会从其注册的 ABC 继承,并且在任何时候都不会检查其是否符合 ABC 接口,即使在实例化时也是如此。此外,静态类型检查器目前无法处理虚拟子类。详情请参阅Mypy issue 2922—ABCMeta.register support。
register
方法通常作为一个普通函数调用(参见“实践中的 register 用法”),但也可以用作装饰器。在示例 13-11 中,我们使用装饰器语法并实现TomboList
,Tombola
的虚拟子类,如图 13-7 所示。
图 13-7. TomboList
的 UML 类图,list
的真实子类和Tombola
的虚拟子类。
示例 13-11. tombolist.py:类TomboList
是Tombola
的虚拟子类
from random import randrange from tombola import Tombola @Tombola.register # ① class TomboList(list): # ② def pick(self): if self: # ③ position = randrange(len(self)) return self.pop(position) # ④ else: raise LookupError('pop from empty TomboList') load = list.extend # ⑤ def loaded(self): return bool(self) # ⑥ def inspect(self): return tuple(self) # Tombola.register(TomboList) # ⑦ • 1
①
Tombolist
被注册为Tombola
的虚拟子类。
②
Tombolist
扩展了list
。
③
Tombolist
从list
继承其布尔行为,如果列表不为空则返回True
。
④
我们的pick
调用self.pop
,从list
继承,传递一个随机的项目索引。
⑤
Tombolist.load
与list.extend
相同。
⑥
loaded
委托给bool
。¹⁴
⑦
总是可以以这种方式调用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__
的指导——方法解析顺序。它基本上按照 Python 用于搜索方法的顺序列出了类及其超类。¹⁵ 如果你检查TomboList
的__mro__
,你会看到它只列出了“真正”的超类——list
和object
:
>>> TomboList.__mro__ (<class 'tombolist.TomboList'>, <class 'list'>, <class 'object'>)
Tombola
不在Tombolist.__mro__
中,所以Tombolist
不会从Tombola
继承任何方法。
这结束了我们的Tombola
ABC 案例研究。在下一节中,我们将讨论register
ABC 函数在实际中的使用方式。
实践中的 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
检查的方法,以及依赖于issubclass
的isinstance
检查。但有些 ABC 也支持结构化类型。下一节将解释。
带有 ABCs 的结构化类型
ABC 主要与名义类型一起使用。当类Sub
明确从AnABC
继承,或者与AnABC
注册时,AnABC
的名称与Sub
类关联起来—这就是在运行时,issubclass(AnABC, Sub)
返回True
的原因。
相比之下,结构类型是通过查看对象的公共接口结构来确定其类型的:如果一个对象实现了类型定义中定义的方法,则它与该类型一致。动态和静态鸭子类型是结构类型的两种方法。
事实证明,一些 ABC 也支持结构类型。在他的文章“水禽和 ABC”中,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
函数认为是abc.Sized
的子类(因此,也被isinstance
认为是)因为abc.Sized
实现了一个名为__subclasshook__
的特殊类方法。
Sized
的__subclasshook__
检查类参数是否有名为__len__
的属性。如果有,那么它被视为Sized
的虚拟子类。参见示例 13-12。
示例 13-12。来自Lib/_collections_abc.py源代码中Sized
的定义
class Sized(metaclass=ABCMeta): __slots__ = () @abstractmethod def __len__(self): return 0 @classmethod def __subclasshook__(cls, C): if cls is Sized: if any("__len__" in B.__dict__ for B in C.__mro__): # ① return True # ② return NotImplemented # ③
①
如果在C.__mro__
中列出的任何类(即C
及其超类)的__dict__
中有名为__len__
的属性…
②
…返回True
,表示C
是Sized
的虚拟子类。
③
否则返回NotImplemented
以让子类检查继续进行。
注意
如果你对子类检查的细节感兴趣,请查看 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__
。
对于你和我可能编写的 ABCs,__subclasshook__
可能会更不可靠。我不准备相信任何实现或继承load
、pick
、inspect
和loaded
的Spam
类都能保证像Tombola
一样行为。最好让程序员通过将Spam
从Tombola
继承或使用Tombola.register(Spam)
来确认。当然,你的__subclasshook__
也可以检查方法签名和其他特性,但我认为这并不值得。
静态协议
注意
静态协议是在“静态协议”(第八章)中引入的。我考虑延迟对协议的所有覆盖,直到本章,但决定最初在函数中的类型提示的介绍中包括协议,因为鸭子类型是 Python 的一个重要部分,而没有协议的静态类型检查无法很好地处理 Pythonic API。
我们将通过两个简单示例和对数字 ABCs 和协议的讨论来结束本章。让我们首先展示静态协议如何使得我们可以对我们在“类型由支持的操作定义”中首次看到的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
甚至可以与未来的类型一起使用,比如我们将在“为标量乘法重载 *”(第十六章)中看到的增强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 展示了如何实现。
示例 13-13. double_protocol.py: 使用Protocol
定义double
的定义
from typing import TypeVar, Protocol T = TypeVar('T') # ① class Repeatable(Protocol): def __mul__(self: T, repeat_count: int) -> T: ... # ② RT = TypeVar('RT', bound=Repeatable) # ③ def double(x: RT) -> RT: # ④ return x * 2
①
我们将在__mul__
签名中使用这个T
。
②
__mul__
是Repeatable
协议的本质。self
参数通常不会被注释,其类型被假定为类。在这里,我们使用T
来确保结果类型与self
的类型相同。此外,请注意,此协议中的repeat_count
限制为int
。
③
RT
类型变量受Repeatable
协议的约束:类型检查器将要求实际类型实现Repeatable
。
④
现在类型检查器能够验证x
参数是一个可以乘以整数的对象,并且返回值与x
的类型相同。
本示例说明了为什么PEP 544的标题是“协议:结构子类型(静态鸭子类型)”。给定给double
的实际参数x
的名义类型是无关紧要的,只要它呱呱叫,也就是说,只要它实现了__mul__
。
可运行时检查的静态协议
在类型映射中(图 13-1),typing.Protocol
出现在静态检查区域—图表的下半部分。然而,当定义typing.Protocol
子类时,您可以使用@runtime_checkable
装饰器使该协议支持运行时的isinstance/issubclass
检查。这是因为typing.Protocol
是一个 ABC,因此支持我们在“使用 ABC 进行结构化类型检查”中看到的__subclasshook__
。
截至 Python 3.9,typing
模块包含了七个可供直接使用的运行时可检查的协议。以下是其中两个,直接引用自typing
文档:
class typing.SupportsComplex
一个具有一个抽象方法__complex__
的 ABC。
class typing.SupportsFloat
一个具有一个抽象方法__float__
的 ABC。
这些协议旨在检查数值类型的“可转换性”:如果一个对象o
实现了__complex__
,那么您应该能够通过调用complex(o)
来获得一个complex
——因为__complex__
特殊方法存在是为了支持complex()
内置函数。
示例 13-14 展示了typing.SupportsComplex
协议的源代码。
示例 13-14. 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 中使用。
示例 13-15. 在运行时使用SupportsComplex
>>> from typing import SupportsComplex >>> import numpy as np >>> c64 = np.complex64(3+4j) # ① >>> isinstance(c64, complex) # ② False >>> isinstance(c64, SupportsComplex) # ③ True >>> c = complex(c64) # ④ >>> c (3+4j) >>> isinstance(c, SupportsComplex) # ⑤ False >>> complex(c) (3+4j)
①
complex64
是 NumPy 提供的五种复数类型之一。
②
NumPy 的任何复数类型都不是内置的complex
的子类。
③
但 NumPy 的复数类型实现了__complex__
,因此它们符合SupportsComplex
协议。
④
因此,您可以从中创建内置的complex
对象。
⑤
遗憾的是,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
在第一版的流畅的 Python中,我推荐使用numbers
ABCs,但现在这不再是一个好建议,因为这些 ABCs 不被静态类型检查器识别,正如我们将在“数字 ABC 和数值协议”中看到的那样。
在本节中,我想演示一个运行时可检查的协议如何与isinstance
一起工作,但事实证明这个示例并不是isinstance
的一个特别好的用例,因为侧边栏“鸭子类型是你的朋友”解释了这一点。
提示
如果您正在使用外部类型检查器,那么显式的isinstance
检查有一个优点:当您编写一个条件为isinstance(o, MyType)
的if
语句时,那么 Mypy 可以推断在if
块内,o
对象的类型与MyType
一致。
现在我们已经看到如何在运行时使用静态协议与预先存在的类型如complex
和numpy.complex64
,我们需要讨论运行时可检查协议的限制。
运行时协议检查的限制
我们已经看到类型提示通常在运行时被忽略,这也影响了对静态协议进行isinstance
或issubclass
检查。
例如,任何具有__float__
方法的类在运行时被认为是SupportsFloat
的虚拟子类,即使__float__
方法不返回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
转换为float
,但实际上会引发类型错误。
警告
流畅的 Python 第二版(GPT 重译)(七)(3)https://developer.aliyun.com/article/1484636