抽象基类
抽象基类的常见用途:
- 实现接口时作为超类使用。
- 然后,说明抽象基类如何检查具体子类是否符合接口定义,以及如何使用注册机制声明一个类实现了某个接口,而不进行子类化操作。
- 如何让抽象基类自动“识别”任何符合接口的类——不进行子类化或注册。
接口在动态类型语言中是怎么运作的呢?
- 按照定义,受保护的属性和私有属性不在接口中:
- 即便“受保护的”属性也只是采用命名约定实现的(单个前导下划线)
- 私有属性可以轻松地访问(参见 9.7 节),原因也是如此。 不要违背这些约定。
- 不要觉得把公开数据属性放入对象的接口中不妥,
- 因为如果需要,总能实现读值方法和设值方法,把数据属性变成特性,使用 obj.attr 句法的客户代码不会受到影响。
Python喜欢序列
- 协议是接口,但不是正式的(只由文档和约定定义),
- 因此协议不能像正式接口那样施加限制(本章后面会说明抽象基类对接口一致性的强制)。
- 一个类可能只实现部分接口,这是允许的。
看看示例 11-3 中的 Foo 类。它没有继承 abc.Sequence,而且只实现了序列协议
的一个方法: getitem (没有实现 len 方法)
定义 getitem 方法,只实现序列协议的一部分,这样足够访问元
素、迭代和使用 in 运算符了
>>> class Foo:
... def __getitem__(self, pos):
... return range(0, 30, 10)[pos]
...
>>> f = Foo()
>>> f[1]
10
>>> for i in f: print(i)
...
0
10
20
>>> 20 in f
True
>>> 15 in f
False
综上,鉴于序列协议的重要性,如果没有 iter 和 contains 方法,Python 会调
用 getitem 方法,设法让迭代和 in 运算符可用。
使用猴子补丁在运行时实现协议
random.shuffle 函数打乱 FrenchDeck 实例
为FrenchDeck 打猴子补丁,把它变成可变的,让 random.shuffle 函
数能处理
def set_card(deck, position, card): ➊
... deck._cards[position] = card
>>> FrenchDeck.__setitem__ = set_card ➋
>>> shuffle(deck) ➌
>>> deck[:5]
[Card(rank='3', suit='hearts'), Card(rank='4', suit='diamonds'), Card(rank='4',
suit='clubs'), Card(rank='7', suit='hearts'), Card(rank='9', suit='spades')]
❶ 定义一个函数,它的参数为 deck、position 和 card。
❷ 把那个函数赋值给 FrenchDeck 类的 setitem 属性。
❸ 现在可以打乱 deck 了,因为 FrenchDeck 实现了可变序列协议所需的方法。
这里的关键是,set_card 函数要知道 deck 对象有一个名为 _cards 的属性,而且
_cards 的值必须是可变序列。
然后,我们把 set_card 函数赋值给特殊方法__setitem__,从而把它依附到 FrenchDeck 类上。
这种技术叫猴子补丁:在运行时修改类或模块,而不改动源码。
协议是动态的
- random.shuffle 函数不关心参数的类型,只要那个对象实现了部分可变序列协议即可。
- 即便对象一开始没有所需的方法也没关系,后来再提供也行
抽象基类使用姿势
有时,为了让抽象基类识别子类,甚至不用注册。
其实,抽象基类的本质就是几个特殊方法。
>>> class Struggle:
... def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
可以看出,无需注册,abc.Sized 也能把 Struggle 识别为自己的子类,只要实现
了特殊方法 len 即可(要使用正确的句法和语义实现,前者要求没有参数,后
者要求返回一个非负整数,指明对象的长度;
作者建议
如果实现的类体现了 numbers、collections.abc 或其他框架中
抽象基类的概念,
要么继承相应的抽象基类(必要时),要么把类注册到相应的抽象
基类中。
开始开发程序时,不要使用提供注册功能的库或框架,要自己动手注册
一句话:
1.要么继承基类
2.要么自己把类注册到相应的抽象基类中 ,别使用自动注册
isinstance 检查使用姿势
然而,即便是抽象基类,也不能滥用 isinstance 检查,用得多了可能导致代码异味,即表明面向对象设计得不好。
在一连串 if/elif/elif 中使用 isinstance 做检查,然后根据对象的类型执行不同的操作,通常是不好的做法;
此时应该使用多态,即采用一定的方式定义类,让解释器把调用分派给正确的方法,而不使用 if/elif/elif 块硬编码分派逻辑。
鸭子类型 和 类型检查
在框架之外,鸭子类型通常比类型检查更简单,也更灵活。
- 本书有几个示例要使用序列,把它当成列表处理。
- 我没有检查参数的类型是不是list,而是直接接受参数,立即使用它构建一个列表。
- 这样,我就可以接受任何可迭代对象;
- 如果参数不是可迭代对象,调用立即失败,并且提供非常清晰的错误消息。
一句话:
看起来像鸭子(如序列),直接用序列的特性方法,(如果爆错就是类型不对),如果可以就是通过
这种做法省去了,用isinstance 做检查的痛苦(有时不知道什么类型)
标准库中的抽象基类急顺序 page 375 376
定义并使用一个抽象基类
重点来了
想象一下这个场景:
你要在网站或移动应用中显示随机广告,但是在整个广告清单轮转一遍之前,不重复显示
广告。
假设我们在构建一个广告管理框架,名为 ADAM。
它的职责之一是,支持用户提供随机挑选的无重复类。
为了让 ADAM 的用户明确理解“随机挑选的无重复”组件是什么意思,我们将定义一个抽象基类。
我将使用现实世界中的物品命名这个抽象基类:宾果机和彩票机是随机从有限的集合中挑选物品的机器,选出的物品没有重复,直到选完为止
Tombola 抽象基类有四个方法,其中两个是抽象方法。
- .load(...):把元素放入容器。
- .pick():从容器中随机拿出一个元素,返回选中的元素。
另外两个是具体方法。
- .loaded():如果容器中至少有一个元素,返回 True。
- .inspect():返回一个有序元组,由容器中的现有元素构成,不会修改容器的内容 (内部的顺序不保留)。
代码:
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。
根据文档字符串,如果没有元素可选,应该抛出 LookupError。
❹ 抽象基类可以包含具体方法。
❻ 我们不知道具体子类如何存储元素,不过为了得到 inspect 的结果,我们可以不断调
用 .pick() 方法,把 Tombola 清空……
❼ ……然后再使用 .load(...) 把所有元素放回去。
其实,抽象方法可以有实现代码。即便实现了,子类也必须覆盖抽象方法,但
是在子类中可以使用 super() 函数调用抽象方法,为它添加功能,而不是从头开始
实现。
定义Tombola抽象基类的子类
BingoCage 类是在示例 5-8 的基础上修改的,使用了更好的随机发生
器。
BingoCage 实现了所需的抽象方法 load 和 pick,从 Tombola 中继承了 loaded 方
法,覆盖了 inspect 方法,还增加了 call 方法。
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))
import random
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()
❹ 没有使用 random.shuffle() 函数,而是使用 SystemRandom 实例的 .shuffle() 方法。
这里想表达的观点是:我们可以偷懒,直接从抽象基类中继承不是那么理想的具体方法。
从 Tombola 中继承的方法没有BingoCage 自己定义的那么快,不过只要 Tombola 的子类正确实现 pick 和 load 方法,就能提供正确的结果。
LotteryBlower 打乱“数字球”后没有取出最后一个,而是取出一个随机位置上的
球。
❷ 如果范围为空,random.randrange(...) 函数抛出 ValueError,为了兼容
Tombola,我们捕获它,抛出 LookupError。
❹ 覆盖 loaded 方法,避免调用 inspect 方法(示例 11-9 中的 Tombola.loaded 方法是
这么做的)。我们可以直接处理 self._balls 而不必构建整个有序元组,从而提升速
度。
有个习惯做法值得指出:
- 在 init 方法中,self._balls 保存的是list(iterable),而不是 iterable 的引用(即没有直接把iterable 赋值给self._balls)。
- 前面说过, 这样做使得 LotteryBlower 更灵活,因为 iterable 参数可以是任何可迭代的类型。
- 把元素存入列表中还确保能取出元素。
- 就算 iterable 参数始终传入列表,list(iterable)
会创建参数的副本,这依然是好的做法,因为我们要从中删除元素,而客户可能不希望自己提供的列表被修改。
Tombola的虚拟子类
- 注册虚拟子类的方式是在抽象基类上调用 register 方法。这么做之后,注册的类会变成抽象基类的虚拟子类,
- 而且 issubclass 和 isinstance 等函数都能识别,但是注册的类不会从抽象基类中继承任何方法或属性。
3.虚拟子类不会继承注册的抽象基类,为了避免运行时错误,虚拟子类要实现所需的全部方法。
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))
import random
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()
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')
def loaded(self):
return bool(self._balls)
def inspect(self):
return tuple(sorted(self._balls))
from random import randrange
@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 中继承 bool 方法,列表不为空时返回 True。
❹ pick 调用继承自 list 的 self.pop 方法,传入一个随机的元素索引。
注册之后,可以使用 issubclass 和 isinstance 函数判断 TomboList 是不是Tombola的子类:
>>> from tombola import Tombola
>>> from tombolist import TomboList
>>> issubclass(TomboList, Tombola)
True
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True
Tombola子类的测试方法
__subclasses__()
这个方法返回类的直接子类列表,不含虚拟子类。
_abc_registry
只有抽象基类有这个数据属性,其值是一个 WeakSet 对象,即抽象类注册的虚拟子
类的弱引用。
Python使用register的方式
Tombola.register 当作类装饰器使用。在 Python 3.3 之前的版本中不能这
样使用 register
虽然现在可以把 register 当作装饰器使用了,但更常见的做法还是把它当作函数使用,
用于注册其他地方定义的类。
即便不注册,抽象基类也能把一个类识别为虚拟子类
>>> class Struggle:
... def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
>>> issubclass(Struggle, abc.Sized)
True
- issubclass 函数确认(isinstance 函数也会得出相同的结论)
- Struggle 是abc.Sized 的子类,
- 这是因为 abc.Sized 实现了一个特殊的类方法,名为__subclasshook__。
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 的属性……
小结
1.抽象基类的使用姿势
2.定义一个随机抽象基类
3.虚拟子类 只是注册就行,(没继承),必须实现所有方法
4.Tombola 这个自定义的抽象基类多写几次
其他:
非正式接口(称为协议)的高度动态本性,
以及使用 subclasshook 方法动态识别子类。
我们发现 Python 对序列协议的支持十分深入。
如果一个类实现了__getitem__ 方法,此外什么也没做,那么 Python 会设法迭代它,而且 in 运算符也随之可以使用。
显式继承抽象基类的优缺点。
继承abc.MutableSequence 后,必须实现 insert 和 delitem 方法,而我们并不需要这两个方法。