返回介绍

11.7 定义并使用一个抽象基类

发布于 2024-02-05 21:59:47 字数 13121 浏览 0 评论 0 收藏 0

为了证明有必要定义抽象基类,我们要在框架中找到使用它的场景。想象一下这个场景:你要在网站或移动应用中显示随机广告,但是在整个广告清单轮转一遍之前,不重复显示广告。假设我们在构建一个广告管理框架,名为 ADAM。它的职责之一是,支持用户提供随机挑选的无重复类。8 为了让 ADAM 的用户明确理解“随机挑选的无重复”组件是什么意思,我们将定义一个抽象基类。

8客户可能要审查随机发生器,或者代理想作弊……谁知道呢!

受到“栈”和“队列”(以物体的排放方式说明抽象接口)启发,我将使用现实世界中的物品命名这个抽象基类:宾果机和彩票机是随机从有限的集合中挑选物品的机器,选出的物品没有重复,直到选完为止。

我们把这个抽象基类命名为 Tombola,这是宾果机和打乱数字的滚动容器的意大利名。9

9牛津英语词典对 tombola 的定义是“像对号游戏(lotto)那样的彩票(lottery)”。

Tombola 抽象基类有四个方法,其中两个是抽象方法。

.load(...):把元素放入容器。

.pick():从容器中随机拿出一个元素,返回选中的元素。

另外两个是具体方法。

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

.inspect():返回一个有序元组,由容器中的现有元素构成,不会修改容器的内容(内部的顺序不保留)。

图 11-4 展示了 Tombola 抽象基类和三个具体实现。

图 11-4:一个抽象基类和三个子类的 UML 类图。根据 UML 的约定,Tombola 抽象基类和它的抽象方法使用斜体。虚线箭头用于表示接口实现,这里它表示 TomboListTombola 的虚拟子类,因为 TomboList 是注册的,本章后面会说明这一点 10

10«registered» 和«virtual subclass» 不是标准的 UML 词汇。我们使用二者表示 Python 类之间的关系。

Tombola 抽象基类的定义如示例 11-9 所示。

示例 11-9 tombola.py:Tombola 是抽象基类,有两个抽象方法和两个具体方法

import abc

class Tombola(abc.ABC):  ➊

  @abc.abstractmethod
  def load(self, iterable):  ➋
    """从可迭代对象中添加元素。"""

  @abc.abstractmethod
  def pick(self):  ➌
    """随机删除元素,然后将其返回。

    如果实例为空,这个方法应该抛出`LookupError`。
    """

  def loaded(self):  ➍
    """如果至少有一个元素,返回`True`,否则返回`False`。"""
    return bool(self.inspect())  ➎


  def inspect(self):
    """返回一个有序元组,由当前元素构成。"""
    items = []
    while True:  ➏
      try:
        items.append(self.pick())
      except LookupError:
        break
    self.load(items)  ➐
    return tuple(sorted(items))

❶ 自己定义的抽象基类要继承 abc.ABC。

❷ 抽象方法使用 @abstractmethod 装饰器标记,而且定义体中通常只有文档字符串。11

11在抽象基类出现之前,抽象方法使用 raise NotImplementedError 语句表明由子类负责实现。

❸ 根据文档字符串,如果没有元素可选,应该抛出 LookupError。

❹ 抽象基类可以包含具体方法。

❺ 抽象基类中的具体方法只能依赖抽象基类定义的接口(即只能使用抽象基类中的其他具体方法、抽象方法或特性)。

❻ 我们不知道具体子类如何存储元素,不过为了得到 inspect 的结果,我们可以不断调用 .pick() 方法,把 Tombola 清空……

❼ ……然后再使用 .load(...) 把所有元素放回去。

 其实,抽象方法可以有实现代码。即便实现了,子类也必须覆盖抽象方法,但是在子类中可以使用 super() 函数调用抽象方法,为它添加功能,而不是从头开始实现。@abstractmethod 装饰器的用法参见 abc 模块的文档

示例 11-9 中的 .inspect() 方法实现的方式有些笨拙,不过却表明,有了 .pick() 和 .load(…) 方法,若想查看 Tombola 中的内容,可以先把所有元素挑出,然后再放回去。这个示例的目的是强调抽象基类可以提供具体方法,只要依赖接口中的其他方法就行。Tombola 的具体子类知晓内部数据结构,可以覆盖 .inspect() 方法,使用更聪明的方式实现,但这不是强制要求。

示例 11-9 中的 .loaded() 方法没有那么笨拙,但是耗时:调用 .inspect() 方法构建有序元组的目的仅仅是在其上调用 bool() 函数。这样做是可以的,但是具体子类可以做得更好,后文见分晓。

注意,实现 .inspect() 方法采用的迂回方式要求捕获 self.pick() 抛出的 LookupError。self.pick() 抛出 LookupError 这一事实也是接口的一部分,但是在 Python 中没办法声明,只能在文档中说明(参见示例 11-9 中抽象方法 pick 的文档字符串)。

我选择使用 LookupError 异常的原因是,在 Python 的异常层次关系中,它与 IndexError 和 KeyError 有关,这两个是具体实现 Tombola 所用的数据结构最有可能抛出的异常。据此,实现代码可能会抛出 LookupError、IndexError 或 KeyError 异常。异常的部分层次结构如示例 11-10 所示(完整的层次结构参见 Python 标准库文档中的“5.4. Exception hierarchy”一节。12

12https://docs.python.org/dev/library/exceptions.html#exception-hierarchy。——编者注

示例 11-10 异常类的部分层次结构

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
    ├── StopIteration
    ├── ArithmeticError
    │  ├── FloatingPointError
    │  ├── OverflowError
    │  └── ZeroDivisionError
    ├── AssertionError
    ├── AttributeError
    ├── BufferError
    ├── EOFError
    ├── ImportError
    ├── LookupError  ➊
    │  ├── IndexError  ➋
    │  └── KeyError  ➌
    ├── MemoryError
    ... etc.

❶ 我们在 Tombola.inspect 方法中处理的是 LookupError 异常。

❷ IndexError 是 LookupError 的子类,尝试从序列中获取索引超过最后位置的元素时抛出。

❸ 使用不存在的键从映射中获取元素时,抛出 KeyError 异常。

我们自己定义的 Tombola 抽象基类完成了。为了一睹抽象基类对接口所做的检查,下面我们尝试使用一个有缺陷的实现来糊弄 Tombola,如示例 11-11 所示。

示例 11-11 不符合 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 methods load

❶ 把 Fake 声明为 Tombola 的子类。

❷ 创建了 Fake 类,目前没有错误。

❸ 尝试实例化 Fake 时抛出了 TypeError。错误消息十分明确:Python 认为 Fake 是抽象类,因为它没有实现 load 方法,这是 Tombola 抽象基类声明的抽象方法之一。

我们的第一个抽象基类定义好了,而且还用它实际验证了一个类。稍后我们将定义 Tombola 抽象基类的子类,在此之前必须说明抽象基类的一些编程规则。

11.7.1 抽象基类句法详解

声明抽象基类最简单的方式是继承 abc.ABC 或其他抽象基类。

然而,abc.ABC 是 Python 3.4 新增的类,因此如果你使用的是旧版 Python,那么无法继承现有的抽象基类。此时,必须在 class 语句中使用 metaclass= 关键字,把值设为 abc.ABCMeta(不是 abc.ABC)。在示例 11-9 中,可以写成:

class Tombola(metaclass=abc.ABCMeta):
  # ...

metaclass= 关键字参数是 Python 3 引入的。在 Python 2 中必须使用 __metaclass__ 类属性:

class Tombola(object):  # 这是Python 2!!!
  __metaclass__ = abc.ABCMeta
  # ...

元类将在第 21 章讲解。现在,我们暂且把元类理解为一种特殊的类,同样也把抽象基类理解为一种特殊的类。例如,“常规的”类不会检查子类,因此这是抽象基类的特殊行为。

除了 @abstractmethod 之外,abc 模块还定义了 @abstractclassmethod、@abstractstaticmethod 和 @abstractproperty 三个装饰器。然而,后三个装饰器从 Python 3.3 起废弃了,因为装饰器可以在 @abstractmethod 上堆叠,那三个就显得多余了。例如,声明抽象类方法的推荐方式是:

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

在函数上堆叠装饰器的顺序通常很重要,@abstractmethod 的文档就特别指出:

与其他方法描述符一起使用时,abstractmethod() 应该放在最里层,……13

也就是说,在 @abstractmethod 和 def 语句之间不能有其他装饰器。

13出自 abc 模块文档中的 @abc.abstractmethod 词条

说明抽象基类的句法之后,我们要通过实现几个功能完善的具体子代来使用 Tombola。

11.7.2 定义Tombola抽象基类的子类

定义好 Tombola 抽象基类之后,我们要开发两个具体子类,满足 Tombola 规定的接口。这两个子类的类图如图 11-4 所示,图中还有将在下一节讨论的虚拟子类。

示例 11-12 中的 BingoCage 类是在示例 5-8 的基础上修改的,使用了更好的随机发生器。 BingoCage 实现了所需的抽象方法 load 和 pick,从 Tombola 中继承了 loaded 方法,覆盖了 inspect 方法,还增加了 __call__ 方法。

示例 11-12 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 模块的文档,os.urandom(...) 函数生成“适合用于加密”的随机字节序列。

❸ 委托 .load(...) 方法实现初始加载。

❹ 没有使用 random.shuffle() 函数,而是使用 SystemRandom 实例的 .shuffle() 方法。

❺ pick 方法的实现方式与示例 5-8 一样。

❻ __call__ 也跟示例 5-8 中的一样。它没必要满足 Tombola 接口,添加额外的方法没有问题。

BingoCage 从 Tombola 中继承了耗时的 loaded 方法和笨拙的 inspect 方法。这两个方法都可以覆盖,变成示例 11-13 中速度更快的一行代码。这里想表达的观点是:我们可以偷懒,直接从抽象基类中继承不是那么理想的具体方法。从 Tombola 中继承的方法没有 BingoCage 自己定义的那么快,不过只要 Tombola 的子类正确实现 pick 和 load 方法,就能提供正确的结果。

示例 11-13 是 Tombola 接口的另一种实现,虽然与之前不同,但完全有效。LotteryBlower 打乱“数字球”后没有取出最后一个,而是取出一个随机位置上的球。

示例 11-13 lotto.py:LotteryBlower 是 Tombola 的具体子类,覆盖了继承的 inspect 和 loaded 方法

import random

from tombola import Tombola


class LotteryBlower(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 LotteryBlower')
    return self._balls.pop(position) ➌

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

  def inspect(self):  ➎
    return tuple(sorted(self._balls))

❶ 初始化方法接受任何可迭代对象:把参数构建成列表。

❷ 如果范围为空,random.randrange(...) 函数抛出 ValueError,为了兼容 Tombola,我们捕获它,抛出 LookupError。

❸ 否则,从 self._balls 中取出随机选中的元素。

❹ 覆盖 loaded 方法,避免调用 inspect 方法(示例 11-9 中的 Tombola.loaded 方法是这么做的)。我们可以直接处理 self._balls 而不必构建整个有序元组,从而提升速度。

❺ 使用一行代码覆盖 inspect 方法。

示例 11-13 中有个习惯做法值得指出:在 __init__ 方法中,self._balls 保存的是 list(iterable),而不是 iterable 的引用(即没有直接把 iterable 赋值给 self._balls)。前面说过,14 这样做使得 LotteryBlower 更灵活,因为 iterable 参数可以是任何可迭代的类型。把元素存入列表中还确保能取出元素。就算 iterable 参数始终传入列表,list(iterable) 会创建参数的副本,这依然是好的做法,因为我们要从中删除元素,而客户可能不希望自己提供的列表被修改。15

14我在 Martelli 写的“水禽和抽象基类”短文之后以此为例说明鸭子类型。

15.4.2 节专门讨论了这种防止混淆别名的问题。

接下来要讲白鹅类型的重要动态特性了:使用 register 方法声明虚拟子类。

11.7.3 Tombola的虚拟子类

白鹅类型的一个基本特性(也是值得用水禽来命名的原因):即便不继承,也有办法把一个类注册为抽象基类的虚拟子类。这样做时,我们保证注册的类忠实地实现了抽象基类定义的接口,而 Python 会相信我们,从而不做检查。如果我们说谎了,那么常规的运行时异常会把我们捕获。

注册虚拟子类的方式是在抽象基类上调用 register 方法。这么做之后,注册的类会变成抽象基类的虚拟子类,而且 issubclass 和 isinstance 等函数都能识别,但是注册的类不会从抽象基类中继承任何方法或属性。

 虚拟子类不会继承注册的抽象基类,而且任何时候都不会检查它是否符合抽象基类的接口,即便在实例化时也不会检查。为了避免运行时错误,虚拟子类要实现所需的全部方法。

register 方法通常作为普通的函数调用(参见 11.9 节),不过也可以作为装饰器使用。在示例 11-14 中,我们使用装饰器句法实现了 TomboList 类,这是 Tombola 的一个虚拟子类,如图 11-5 所示。

图 11-5:TomboList 的 UML 类图,它是 list 的真实子类和 Tombola 的虚拟子类

TomboList 能像它宣称的那样使用,doctest 能证明这一点,详情参见 11.8 节。

示例 11-14 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(sorted(self))

# Tombola.register(TomboList)  # ➐

❶ 把 Tombolist 注册为 Tombola 的虚拟子类。

❷ Tombolist 扩展 list。

❸ Tombolist 从 list 中继承 __bool__ 方法,列表不为空时返回 True。

❹ pick 调用继承自 list 的 self.pop 方法,传入一个随机的元素索引。

❺ Tombolist.load 与 list.extend 一样。

❻ loaded 方法委托 bool 函数。16

16loaded 方法不能采用 load 方法的那种方式,因为 list 类型没有实现 loaded 方法所需的 __bool__ 方法。而内置的 bool 函数不需要 __bool__ 方法,因为它还可以使用 __len__ 方法。参见 Python 文档中“Built-in Types”一章中的“4.1. Truth Value Testing”。

❼ 如果是 Python 3.3 或之前的版本,不能把 .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 会按照这个顺序搜索方法。17 查看 TomboList 类的 __mro__ 属性,你会发现它只列出了“真实的”超类,即 list 和 object:

1712.2 节会专门讲解 __mro__ 类属性,现在知道这个简单的解释就行了。

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

Tombolist.__mro__ 中没有 Tombola,因此 Tombolist 没有从 Tombola 中继承任何方法。

我编写了几个类,实现了相同的接口,现在我需要一种编写 doctest 的方式来涵盖不同的实现。下一节说明如何利用常规类和抽象基类的 API 编写 doctest。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文