定义并使用一个抽象基类
为了证明必要定义抽象基类,要在框架中找到使用它的场景。
想象一个场景:网站或移动应用中显示随机光告,但是在整个广告清单轮转一遍之前,不重复显示广告。假设我们在构建一个广告管理框架,名为ADAM。它的职责之一是,支持用户提供随机挑选的无重复类。为了让ADAM的用户明确理解“随机挑选的无重复”组件是什么意思,我们将定义一个抽象基类。
将使用现实世界中的物品命名这个抽象基类:宾果机和彩票机是随机从有限的集合中挑选物品的机器,选出的物品没有重复,直到选完为止。把这个抽象基类命名为Tombola,这是宾果机和打乱数字的滚动容器的意大利名。
Tombola抽象基类有四个方法,其中两个是抽象方法。
• .load(…):把元素放入容器。
• .pick():从容器中随机拿出一个元素,返回选中的元素。
另外两个是具体方法。
• .loaded():如果容器中至少有一个元素,返回True。
• .inspect():返回一个有序元组,由容器中的现有元素构成,不会修改容器的内容(内部的顺序不保留)。
一个抽象基类和三个子类的UML类图。根据UML的约定,Tombola抽象基类和它的抽象方法使用斜体。虚线箭头用于表示接口实现,这里它表示TomboList是Tombola的虚拟子类,因为TomboList是注册的。
Tombola抽象基类的定义如示例1:
#示例1:Tombola是抽象基类,有两个抽象方法和两个具体方法
import abc
class Tombola(abc.ABC): #➊
@abc.abstractmethod
def load(self,iterable): #➋
"""从可迭代对象中添加元素"""
@abc.abstractmethod
def pick(self): 3➌
"""随机删除元素,然后将其返回。
如果示例为空,这个方法应该抛出‘LookupError`。
"""
def loaded(self): #➍
"""如果至少有一个元素,返回`True`,否则返回`False`。"""
return bool(self.inspect()) #➎
def inspect(self):
istems = []
while True: #➏
try:
times.append(self.pick())
except LookError:
break
self.load(items) #➐
return tuple(sorted(items))
➊ 自己定义的抽象基类要继承abc.ABC。
➋ 抽象方法使用@abstractmethod装饰器标记,而且定义体中通常只有文档字符串。
➌ 根据文档字符串,如果没有元素可选,应该抛出LookupError。
➍ 抽象基类可以包含具体方法。
➎ 抽象基类中的具体方法只能依赖抽象基类定义的接口(即只能使用抽象基类中的其他具体方法、抽象方法或特性)。
➏ 不知道具体子类如何存储元素,不过为了得到inspect的结果,可以不断调用.pick()方法,把Tombola清空……
➐ ……然后再使用.load(…)把所有元素放回去。
#示例2:异常类的部分层次结构
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。
#示例3:不符合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抽象基类声明的抽象方法之一。
抽象基类句法详解
声明抽象基类最简单的方式是继承abc.ABC或其他抽象基类。
abc.ABC是Python 3.4新增的类,因此如果你使用的是旧版Python,那么无法继承现有的抽象基类。此时,必须在class语句中使用**metaclass=**关键字,把值设为abc.ABCMeta(不是abc.ABC)。在示例1中可以写成:
class Tombola(metaclass=abc.ABCMeta):
# …
metaclass=关键字参数是Python 3引入的。在Python 2中必须使用__metaclass__类属性:
class Tombola(object): # 这是Python 2!!!
metaclass = abc.ABCMeta
# …
定义Tombola抽象基类的子类
定义好Tombola抽象基类之后,要开发两个具体子类,满足Tombola规定的接口。
#示例4:ingoCage是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。
➌ 委托.load(…)方法实现初始加载。
➍ 没有使用random.shuffle()函数,而是使用SystemRandom实例的.shuffle()方法。
➎ 如果self._items为空,抛出异常,并设定错误消息。
➏ __call__没必要满足Tombola接口,添加额外的方法没有问题。
示例5:是Tombola接口的另一种实现,虽然与之前不同,但完全有效。LotteryBlower打乱“数字球”后没有取出最后一个,而是取出一个随机位置上的球。
#示例5: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方法(示例1中的Tombola.loaded方法是这么做的)。我们可以直接处理self._balls而不必构建整个有序元组,从而提升速度。
➎ 使用一行代码覆盖inspect方法。
Tombola的虚拟子类
白鹅类型
白鹅类型对接口有明确定义,比如不可变序列(Sequence),需要实现__contains__,iter,len,getitem,reversed,index,count
对于其中的抽象方法,子类在继承时必须具体化,其余非抽象方法在继承时可以自动获得,Sequence序列必须具体化的抽象方法是__len__和__getitem__。
白鹅类型的一个基本特性(也是值得用水禽来命名的原因):即便不继承,也有办法把一个类注册为抽象基类的虚拟子类。
注册虚拟子类的方式是在抽象基类上调用register方法。
#示例6: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函数。
➐ 如果是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会按照这个顺序搜索方法。查看TomboList类的__mro__属性,你会发现它只列出了“真实的”超类,即list和object:
>>> TomboList.__mro__
(<class 'tombolist.TomboList'>, <class 'list'>, <class 'object'>)
Tombolist.__mro__中没有Tombola,因此Tombolist没有从Tombola中继承任何方法。