Python 标准库为许多容器特性提供了抽象基类。它为内置的容器类提供了一致的框架,如list
、dict
、set
。此外,标准库还为数字提供了抽象基类。我们可以使用这些类来扩展 Python 中可用的数值类套件。
在本章中,我们将大致了解collections.abc
模块中的抽象基类。从那里,我们可以集中讨论几个用例,这些用例将在未来的章节中详细讨论。
重用现有类有三种常见的设计策略:wrap、extend 和 invent。我们将了解我们可能要包装或扩展的各种容器和集合背后的一般概念。类似地,我们将了解我们可能想要实现的数字背后的概念。
我们的目标是确保我们的应用程序类与现有 Python 特性无缝集成。例如,如果我们创建一个集合,那么让该集合通过实现__iter__()
来创建迭代器是合适的。实现__iter__()
的集合将与for
语句无缝工作。
本章的代码文件可在中找到 https://git.io/fj2Uz 。
抽象基类(ABC定义)的核心在名为abc
的模块中定义。它包含创建抽象所需的装饰器和元类。其他类依赖于这些定义。collections.abc
模块使用abc
模块创建集中于集合的抽象。我们还将研究numbers
模块,因为它包含用于数字类型的 ABC。io
模块中也有用于 I/O 的 ABC。
抽象基类具有以下特性:
- 抽象意味着这些类不包含完全工作所需的所有方法定义。为了使它成为一个有用的子类,我们需要提供一些方法定义。
- Base表示其他类将使用它作为超类。
- 抽象类为方法提供了一些定义。最重要的是,抽象基类通常为缺少的方法提供签名。子类必须提供正确的方法来创建符合抽象类定义的接口的具体类。
使用抽象基类时请记住以下几点:
- 当我们使用它们来定义类时,它们将与 Python 的内部类保持一致。
- 我们可以使用它们来创建一些通用的、可重用的抽象,以扩展我们的应用程序。
- 我们可以使用它们来支持对类进行适当的检查,以确定它的功能。这使得我们的应用程序中的库类和新类之间能够更好地协作。它有助于从类的正式定义开始,这些类将具有与其他容器或数字类似的行为。
如果我们不使用抽象基类,我们很容易创建一个无法提供抽象基类Sequence
所有特性的类。这将导致一个类成为几乎的序列,我们有时称之为序列,就像一样。对于一个没有完全提供Sequence
类所有特性的类来说,这可能会导致奇怪的不一致和笨拙的解决方法。
对于抽象基类,应用程序的类保证具有抽象基类的公开特性。如果它缺少一个特性,则存在一个未定义的抽象方法将使该类无法用于构建对象实例。
我们将在以下几种情况下使用 ABC:
- 在定义我们自己的类时,我们将使用 ABC 作为超类。
- 我们将在方法中使用 ABCs 来确认操作是可能的。
- 我们将在诊断消息或异常中使用 ABCs 来指示操作无法工作的原因。
对于第一个用例,我们可以使用如下代码编写模块:
import collections.abc
class SomeApplicationClass(collections.abc.Sequence):
pass
我们的SomeApplicationClass
被定义为Sequence
类。然后必须实现Sequence
要求的具体方法,否则我们将无法创建实例。
对于第二个用例,我们可以用如下代码编写方法:
def some_method(self, other: Iterator):
assert isinstance(other, collections.abc.Iterator)
我们的some_method()
要求other
参数是Iterator
的子类。如果other
参数无法通过此测试,我们会得到一个异常。
与assert
语句不同,一种常见的替代方法是使用if
语句来引发TypeError
,这可能比AssertError
更有意义。我们将在下一节中看到这一点。
对于第三个用例,我们可能有如下内容:
try:
some_obj.some_method(another)
except AttributeError:
warnings.warn(f"{another!r} not an Iterator, found {another.__class__.__bases__!r}")
raise
在本例中,我们编写了一个诊断警告,显示给定对象的基类。这可能有助于调试应用程序设计中的问题。
在本节中,我们将讨论非常差的多态性的概念。参数值类型检查是一种 Python 编程实践,应该隔离到一些特殊情况。稍后,当我们研究数字和数字强制时,我们将了解建议检查类型的情况。
好的多态性遵循有时被称为Liskov 替代原则。多态类可以互换使用。每个多态类都有相同的属性套件。欲了解更多信息,请访问http://en.wikipedia.org/wiki/Liskov_substitution_principle 。
过度使用isinstance()
来区分参数的类型可能会导致不必要的复杂(和缓慢)程序。与代码中冗长的类型检查相比,单元测试是发现编程错误的更好方法。
具有大量isinstance()
方法的方法函数可能是多态类设计不佳(或不完整)的症状。与其在类定义之外进行特定于类型的处理,不如扩展或包装类
,使其更适合多态性,并将特定于类型的处理封装在类定义内。
isinstance()
方法的一个潜在用途是提出诊断错误。一种简单的方法是使用assert
语句,如下所示:
assert isinstance(some_argument, collections.abc.Container),
f"{some_argument!r} not a Container"
这将引发一个AssertionError
异常,表明存在问题。它的优点是短小精悍。这个例子有两个缺点:断言可以被沉默,为此可能最好提出一个TypeError
。前面使用的assert
语句没有多大帮助,应该避免使用。
以下示例稍微好一些:
if not isinstance(some_argument, collections.abc.Container):
raise TypeError(f"{some_argument!r} not a Container")
前面的代码的优点是它会引发正确的错误。但是,它的缺点是冗长,并且在对象域上创建了不必要的约束。不是抽象Container
类的适当子类的对象仍然可以提供所需的方法,不应排除在外。
Pythonic 方法总结如下:
"It's better to ask for forgiveness than to ask for permission."
这通常意味着我们应该尽量减少参数的前期测试(请求许可),以确定它们是否是正确的类型。参数类型检查很少有任何实际的好处。相反,我们应该适当地处理例外情况(请求原谅)。
提前检查类型通常被称为“三思而后行”(LBYL编程。这是一项价值相对较小的开销。另一种方法称为比(EAFP)编程更容易请求原谅,并且依赖try
语句从问题中恢复。
最好的方法是将诊断信息与异常结合起来,以防在不太可能的情况下使用不合适的类型,并以某种方式通过单元测试投入运行。
以下通常是最佳方法:
try:
found = value in some_argument
except TypeError:
if not isinstance(some_argument, collections.abc.Container):
warnings.warn(f"{some_argument!r} not a Container")
raise
创建found
变量的赋值语句假定some_argument
是collections.abc.Container
类的正确实例,并将响应in
操作符。
如果有人更改应用程序,并且some_argument
属于无法使用in
运算符的类,则应用程序将写入诊断警告消息,并在TypeError
异常的情况下崩溃。
许多类与in
操作符一起工作。试图用 LBYLif
语句来包装这一点可能会排除一个完全可行的类。使用 EAFP 样式允许使用实现in
运算符的任何类。
Python 对可调用对象的定义包括使用def
语句创建的明显函数定义
Callable
类型提示用于描述__call__()
方法,这是 Python 中的一个常见协议。我们可以在Python 3 面向对象编程中看到几个例子,该例子由 Dusty Phillips 撰写,来自 Packt Publishing。
当我们查看任何 Python 函数时,都会看到以下行为:
>>> def hello(text: str):
... print(f"hello {text}")
>>> type(hello)
<class 'function'>
>>> from collections.abc import Callable
>>> isinstance(hello, Callable)
True
当我们创建一个函数时,它将适合抽象基类Callable
。每个函数都报告自己为Callable
。这简化了对参数值的检查,并有助于编写有意义的调试消息。
我们将在第 6 章使用可调用对象和上下文中更详细地了解可调用对象。
collections
模块在内置容器类之上和之外定义了许多集合。集装箱类别包括namedtuple()
、deque
、ChainMap
、Counter
、OrderedDict
和defaultdict
。所有这些都是基于 ABC 定义的类的示例。
以下是一个快速交互,展示了如何检查集合以查看它们支持的方法:
>>> isinstance({}, collections.abc.Mapping)
True
>>> isinstance(collections.defaultdict(int), collections.abc.Mapping)
True
我们可以检查简单的dict
类,看看它是否遵循Mapping
协议,并支持所需的方法。
我们可以检查defaultdict
集合以确认它也是Mapping
类层次结构的一部分。
在创建一种新的容器时,我们有以下两种通用方法:
- 使用
collections.abc
类正式继承与现有类匹配的行为。这还将支持mypy类型的提示检查,并将提供一些有用的默认行为。 - 依靠类型提示确认方法与
typing
模块中的协议定义匹配。这将仅支持 mypy 类型提示检查。
使用适当的 ABC 作为我们的一个应用程序类的基类更清晰(也更可靠)。额外的手续有以下两个优点:
- 它向阅读(可能使用或维护)我们的代码的人宣传我们的意图。当我们创建
collections.abc.Mapping
的子类时,我们对该类的行为提出了非常强烈的要求。 - 它创建了一些诊断支持。如果我们无法正确地实现所有必需的方法,那么在尝试创建抽象基类的实例时将引发异常。如果我们不能运行单元测试,因为我们不能创建对象的实例,那么这表明一个严重的问题需要解决。
内置容器的整个家族树反映在抽象基类中。下层特征包括Container
、Iterable
和Sized
。这些是更高层次结构的一部分;它们需要一些特定的方法,特别是分别使用__contains__()
、__iter__()
和__len__()
。
更高级别的功能包括以下特征:
Sequence
和MutableSequence
:这是list
和tuple
具体类的抽象。具体序列实现还包括bytes
和str
。MutableMapping
:这是dict
的抽象。它扩展了Mapping
,但没有内置的具体实现。Set
和MutableSet
:这是frozenset
和set
具体类的抽象。
这允许我们构建新类或扩展现有类,并与 Python 的其他内置功能保持清晰正式的集成。
我们将在第 7 章创建容器和集合中详细介绍容器和集合。
当创建新号码(或扩展现有号码)时,我们转向numbers
模块。此模块包含 Python 内置数字类型的抽象定义。这些类型形成了一个高而窄的层次结构,从最简单到最复杂。在这种情况下,简单性(和精细性)指的是可用方法的集合。
有一个名为numbers.Number
的抽象基类,它定义了所有数字类和数字类。通过观察如下交互,我们可以看出这是正确的:
>>> import numbers
>>> isinstance(42, numbers.Number)
True
>>> 355/113
3.1415929203539825
>>> isinstance(355/113, numbers.Number)
True
显然,整数和浮点值是抽象的numbers.Number
类的子类。Number
的子类包括numbers.Complex
、numbers.Real
、numbers.Rational
和numbers.Integral
。这些定义与用于定义各类数字的数学概念大致平行。
然而,decimal.Decimal
类并不很适合这种层次结构。我们可以使用issubclass()
方法检查以下关系:
>>> issubclass(decimal.Decimal, numbers.Number)
True
>>> issubclass(decimal.Decimal, numbers.Integral)
False
>>> issubclass(decimal.Decimal, numbers.Real)
False
>>> issubclass(decimal.Decimal, numbers.Complex)
False
>>> issubclass(decimal.Decimal, numbers.Rational)
False
虽然decimal.Decimal
类似乎与numbers.Real
密切相关,但它在形式上不是这种类型的子类。
有关numbers.Rational
的具体实现,请查看fractions
模块。我们将在第 8 章创建数字中详细介绍各种数字。
我们将看一些其他有趣的 ABC 类,它们的扩展范围较小。并不是因为这些抽象没有得到广泛的应用:而是因为具体的实现很少需要扩展或修改。
我们来看看迭代器,它由collections.abc.Iterator
定义。我们还将研究上下文管理器的无关概念。这与其他 ABC 类的定义形式不同。我们将在第 6 章中使用可调用对象和上下文详细介绍这一点。
在许多情况下,我们将使用生成器函数和yield
语句创建迭代器。对于这些函数,我们将使用显式类型提示typing.Iterator
。
当我们使用带有for
语句的 iterable 容器时,迭代器对象是隐式创建的。我们很少期望看到迭代器对象本身。在大多数情况下,它将是for
语句实现的一个隐藏部分。我们很少关心迭代器对象,很少想扩展或修改类定义。
我们可以通过iter()
函数公开 Python 使用的隐式迭代器。我们可以通过以下方式与迭代器交互:
>>> x = [1, 2, 3]
>>> iter(x)
<list_iterator object at 0x1006e3c50>
>>> x_iter = iter(x)
>>> next(x_iter)
1
>>> next(x_iter)
2
>>> next(x_iter)
3
>>> next(x_iter)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
>>> isinstance(x_iter, collections.abc.Iterator)
True
在前面的代码中,我们在列表对象上创建了一个迭代器,将其分配给x_iter
变量。next()
函数将逐步遍历该迭代器中的值。这显示了迭代器对象是如何有状态的,next()
函数既返回一个值,又更新内部状态。
最后一个isinstance()
表达式确认该迭代器对象是collections.abc.Iterator
的实例。
大多数时候,我们将使用由集合类本身创建的迭代器;然而,当我们分支并构建自己的集合类或扩展集合类时,我们可能还需要构建一个唯一的迭代器。我们将在第 7 章创建容器和集合中介绍迭代器。
上下文管理器与with
语句一起使用。我们在编写以下内容时使用上下文管理器:
with function(arg) as context:
process(context)
在前面的代码中,function(arg)
创建上下文管理器。管理器可用后,可以根据需要使用该对象。在本例中,它是一个函数的参数。上下文管理器类可能具有在上下文范围内执行操作的方法
一个非常常用的上下文管理器是文件。任何时候打开文件时,都应该使用上下文来保证文件也会正确关闭。因此,我们几乎应该始终以以下方式使用文件:
with open("some file") as the_file:
process(the_file)
在with
声明的末尾,我们确信该文件将被正确关闭。这将释放任何操作系统资源,避免在引发异常时出现资源泄漏或处理不完整。
contextlib
模块提供了几种用于构建适当上下文管理器的工具。该库没有提供抽象基类,而是提供了 decorators,它可以将简单函数转换为上下文管理器,还提供了一个contextlib.ContextDecorator
基类,可以扩展该基类来构建一个作为上下文管理器的类。
我们将在第 6 章中使用可调用对象和上下文详细介绍上下文管理器。
创建 ABC 的核心方法在abc
模块中定义。这个模块包括ABCMeta
类,它提供了几个特性。
首先,ABCMeta
类确保抽象类不能被实例化。当一个方法使用@asbtractmethod
装饰器时,无法提供此定义的子类将无法实例化。可以正确实例化为抽象方法提供所有必需定义的子类。
其次,它提供了__instancecheck__()
和__subclasscheck__()
的定义。这些特殊方法实现了isinstance()
和issubclass()
内置功能。它们提供检查以确认对象(或类)属于适当的 ABC。这包括缓存子类以加快测试。
abc
模块还包括许多装饰器,用于创建抽象方法函数,这些函数必须由抽象基类的具体实现提供。其中最重要的是@abstractmethod
装饰器。
如果我们想创建一个新的抽象基类,我们将使用如下内容:
from abc import ABCMeta, abstractmethod
class AbstractBettingStrategy( metaclass =ABCMeta):
@abstractmethod
def bet( self , hand: Hand) -> int :
return 1
@abstractmethod
def record_win( self , hand: Hand) -> None :
pass
@abstractmethod
def record_loss( self , hand: Hand) -> None :
pass
这个类包括ABCMeta
作为它的元类,这表明它将是一个抽象的基类。
此抽象使用abstractmethod
装饰器定义三种抽象方法。任何具体的子类都必须定义这些,才能成为抽象基类的完整实现。对于更复杂的情况,抽象基类可以定义__subclasshook__()
方法,对所需的具体方法定义进行更复杂的测试。
AbstractBettingStrategy
类的抽象子类示例如下:
class Simple_Broken(AbstractBettingStrategy):
def bet( self, hand ):
return 1
前面的代码定义了一个抽象类。无法生成实例,因为该类没有为所有三个抽象方法提供必要的实现
下面是我们尝试构建此类实例时发生的情况:
>>> simple= Simple_Broken()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Simple_Broken with
abstract methods record_loss, record_win
错误消息表示具体类不完整。以下是通过完整性测试的更好的混凝土等级:
class Simple(AbstractBettingStrategy):
def bet(self, hand):
return 1
def record_win(self, hand):
pass
def record_loss(self, hand):
pass
我们可以构建这个类的一个实例,并将其用作模拟的一部分。这种抽象迫使我们用两种未使用的方法将实现弄得一团糟。bet()
方法应该是唯一需要的*抽象方法。其他两个方法应该已经由抽象基类提供了单个pass
状态的默认实现。 *
我们可以使用复杂的覆盖规则定义抽象基类,以创建具体的子类。这是通过实现抽象基类的__subclasshook__()
方法实现的,如下代码所示:
class AbstractBettingStrategy2(ABC):
@abstractmethod
def bet( self , hand: Hand ) -> int :
return 1
@abstractmethod
def record_win( self , hand: Hand) -> None :
pass
@abstractmethod
def record_loss( self , hand: Hand) -> None :
pass
@classmethod
def __subclasshook__( cls , subclass: type ) -> bool :
"""Validate the class definition is complete."""
if cls is AbstractBettingStrategy2:
has_bet = any ( hasattr (B, "bet" ) for B in subclass. __mro__ )
has_record_win = any ( hasattr (B, "record_win" ) for B in subclass. __mro__ )
has_record_loss = any ( hasattr (B, "record_loss" ) for B in subclass. __mro__ )
if has_bet and has_record_win and has_record_loss:
return True
return False
这个类是一个抽象基类,由ABC
超类扩展而成。与前面的示例一样,提供了许多@abstractmethod
定义。这个类的任何子类都类似于前面的AbstractBettingStrategy
类示例。
当试图构建子类的实例时,会调用__subclasshook__()
方法来确定是否可以构建该对象。在这种情况下,有三个单独的检查:has_bet
、has_record_win
和has_record_loss
。如果三个检查都通过,则函数返回True
以允许构建对象;否则,函数返回False
以防止构建不完整具体类的实例。
使用__subclasshook__()
可以对抽象类的子类的有效性做出细微的决策。它还可能导致混淆,因为明显的规则是,实现所有的@abstractmethod
方法没有被使用。
我们还可以使用类型提示和typing
模块对具体方法的实现进行一些管理。mypy 将检查一个具体的类,以确保它与抽象类类型提示匹配。这不像ABCMeta
类所做的检查那么严格,因为它们不会在运行时发生,而只在使用 mypy 时发生。我们可以通过在抽象类的主体中使用raise NotImplementedError
来实现这一点。如果应用程序实际创建抽象类的实例,这将创建运行时错误。
具体的子类通常定义方法。类型提示的存在意味着 mypy 可以确认子类提供了与超类类型提示匹配的正确定义。类型提示之间的比较可能是创建具体子类的最重要部分。考虑下面两个类定义:
from typing import Tuple, Iterator
class LikeAbstract:
def aMethod( self , arg: int ) -> int :
raise NotImplementedError
class LikeConcrete(LikeAbstract):
def aMethod( self , arg1: str , arg2: Tuple[ int , int ]) -> Iterator[Any]:
pass
aMethod()
方法的LikeConcrete
类实现与LikeAbstract
超类明显不同。运行 mypy 时,我们将看到如下错误消息:
Chapter_5/ch05_ex1.py:96: error: Signature of "aMethod" incompatible with supertype "LikeAbstract"
这将确认LikeConcrete
子类不是aMethod()
方法的有效实现。这种通过类型暗示创建抽象类定义的技术是 mypy 的一项功能,可以与ABCMeta
类结合使用,创建一个支持 mypy 和运行时检查的健壮库。
在本章中,我们研究了抽象基类的基本成分。我们看到了每种抽象的一些特性。
我们还了解到,好的类设计的一个规则是尽可能多地继承。我们在这里看到了两大模式。我们还看到了这一规则的常见例外。
有些应用程序类没有与 Python 内部特性重叠的行为。从我们的 21 点示例来看,Card
与数字、容器、迭代器或上下文不太相似:它只是一张扑克牌。在这种情况下,我们通常可以创建一个新类,因为没有任何内置特性可以继承。
然而,当我们看Hand
时,我们可以看到hand
显然是一个容器。正如我们在第 2 章、初始方法和第 3 章、无缝集成基本特殊方法中提到的,以下是三种基本设计策略:
- 包装现有容器
- 扩展现有容器
- 发明一种全新的容器
大多数情况下,我们将包装或扩展现有容器。这符合我们尽可能多继承遗产的原则。
当我们扩展现有的类时,我们的应用程序类将整齐地适应类层次结构。内置list
的扩展已经是collections.abc.MutableSequence
的一个实例。
然而,当我们包装一席现有的类时,我们必须仔细考虑我们想要支持的原始接口的哪些部分以及我们不希望支持的部分。在前面章节的示例中,我们只想从正在包装的列表对象中公开pop()
方法。
因为包装器类不是一个完整的可变序列实现,所以有很多事情它不能做。另一方面,一个扩展类参与了许多用例,这些用例可能会被证明是有用的。例如,扩展了list
的hand
将被证明是可移植的。
如果我们发现扩展类不符合我们的要求,我们可以求助于构建一个全新的集合。ABC 定义提供了大量关于创建可以与 Python 世界其余部分无缝集成的集合所需的方法的指导。我们将在第 7 章创建容器和集合中查看一个创建集合的详细示例。
在大多数情况下,类型提示将帮助我们创建约束具体实现方面的抽象类。当应用程序执行时,将检查抽象基类定义,这可能会带来不必要的开销。mypy 检查与单元测试检查一起在应用程序使用之前进行,从而减少了开销,提高了对最终应用程序的信心。
在接下来的章节中,我们将广泛使用本章中讨论的抽象基类。在第 6 章使用可调用对象和上下文中,我们将了解可调用对象和上下文相对简单的特性。在第 7 章创建容器和集合中,我们将查看可用的容器和集合。在本章中,我们还将介绍如何构建一种独特的新型容器。最后,在第 8 章创建数字中,我们将了解各种数字类型以及如何创建自己的数字类型。