- 前言
- 目标读者
- 非目标读者
- 本书的结构
- 以实践为基础
- 硬件
- 杂谈:个人的一点看法
- Python 术语表
- Python 版本表
- 排版约定
- 使用代码示例
- 第一部分 序幕
- 第 1 章 Python 数据模型
- 第二部分 数据结构
- 第 2 章 序列构成的数组
- 第 3 章 字典和集合
- 第 4 章 文本和字节序列
- 第三部分 把函数视作对象
- 第 5 章 一等函数
- 第 6 章 使用一等函数实现设计模式
- 第 7 章 函数装饰器和闭包
- 第四部分 面向对象惯用法
- 第 8 章 对象引用、可变性和垃圾回收
- 第 9 章 符合 Python 风格的对象
- 第 10 章 序列的修改、散列和切片
- 第 11 章 接口:从协议到抽象基类
- 第 12 章 继承的优缺点
- 第 13 章 正确重载运算符
- 第五部分 控制流程
- 第 14 章 可迭代的对象、迭代器和生成器
- 14.1 Sentence 类第1版:单词序列
- 14.2 可迭代的对象与迭代器的对比
- 14.3 Sentence 类第2版:典型的迭代器
- 14.4 Sentence 类第3版:生成器函数
- 14.5 Sentence 类第4版:惰性实现
- 14.6 Sentence 类第5版:生成器表达式
- 14.7 何时使用生成器表达式
- 14.8 另一个示例:等差数列生成器
- 14.9 标准库中的生成器函数
- 14.10 Python 3.3 中新出现的句法:yield from
- 14.11 可迭代的归约函数
- 14.12 深入分析 iter 函数
- 14.13 案例分析:在数据库转换工具中使用生成器
- 14.14 把生成器当成协程
- 14.15 本章小结
- 14.16 延伸阅读
- 第 15 章 上下文管理器和 else 块
- 第 16 章 协程
- 第 17 章 使用期物处理并发
- 第 18 章 使用 asyncio 包处理并发
- 第六部分 元编程
- 第 19 章 动态属性和特性
- 第 20 章 属性描述符
- 第 21 章 类元编程
- 结语
- 延伸阅读
- 附录 A 辅助脚本
- Python 术语表
- 作者简介
- 关于封面
11.7 定义并使用一个抽象基类
为了证明有必要定义抽象基类,我们要在框架中找到使用它的场景。想象一下这个场景:你要在网站或移动应用中显示随机广告,但是在整个广告清单轮转一遍之前,不重复显示广告。假设我们在构建一个广告管理框架,名为 ADAM。它的职责之一是,支持用户提供随机挑选的无重复类。8 为了让 ADAM 的用户明确理解“随机挑选的无重复”组件是什么意思,我们将定义一个抽象基类。
8客户可能要审查随机发生器,或者代理想作弊……谁知道呢!
受到“栈”和“队列”(以物体的排放方式说明抽象接口)启发,我将使用现实世界中的物品命名这个抽象基类:宾果机和彩票机是随机从有限的集合中挑选物品的机器,选出的物品没有重复,直到选完为止。
我们把这个抽象基类命名为 Tombola,这是宾果机和打乱数字的滚动容器的意大利名。9
9牛津英语词典对 tombola 的定义是“像对号游戏(lotto)那样的彩票(lottery)”。
Tombola 抽象基类有四个方法,其中两个是抽象方法。
.load(...):把元素放入容器。
.pick():从容器中随机拿出一个元素,返回选中的元素。
另外两个是具体方法。
.loaded():如果容器中至少有一个元素,返回 True。
.inspect():返回一个有序元组,由容器中的现有元素构成,不会修改容器的内容(内部的顺序不保留)。
图 11-4 展示了 Tombola 抽象基类和三个具体实现。
图 11-4:一个抽象基类和三个子类的 UML 类图。根据 UML 的约定,Tombola 抽象基类和它的抽象方法使用斜体。虚线箭头用于表示接口实现,这里它表示 TomboList 是 Tombola 的虚拟子类,因为 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)
12见 https://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 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论