抽象基类

抽象基类的常见用途:

  1. 实现接口时作为超类使用。
  2. 然后,说明抽象基类如何检查具体子类是否符合接口定义,以及如何使用注册机制声明一个类实现了某个接口,而不进行子类化操作。
  3. 如何让抽象基类自动“识别”任何符合接口的类——不进行子类化或注册。

接口在动态类型语言中是怎么运作的呢?

  1. 按照定义,受保护的属性和私有属性不在接口中:
  2. 即便“受保护的”属性也只是采用命名约定实现的(单个前导下划线)
  3. 私有属性可以轻松地访问(参见 9.7 节),原因也是如此。 不要违背这些约定。
  4. 不要觉得把公开数据属性放入对象的接口中不妥,
  5. 因为如果需要,总能实现读值方法和设值方法,把数据属性变成特性,使用 obj.attr 句法的客户代码不会受到影响。

Python喜欢序列

  1. 协议是接口,但不是正式的(只由文档和约定定义),
  2. 因此协议不能像正式接口那样施加限制(本章后面会说明抽象基类对接口一致性的强制)。
  3. 一个类可能只实现部分接口,这是允许的。

看看示例 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

综上,鉴于序列协议的重要性,如果没有 itercontains 方法,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 类上。
这种技术叫猴子补丁:在运行时修改类或模块,而不改动源码。

协议是动态的

  1. random.shuffle 函数不关心参数的类型,只要那个对象实现了部分可变序列协议即可。
  2. 即便对象一开始没有所需的方法也没关系,后来再提供也行

抽象基类使用姿势

有时,为了让抽象基类识别子类,甚至不用注册。
其实,抽象基类的本质就是几个特殊方法。

>>> 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 块硬编码分派逻辑。

鸭子类型 和 类型检查

在框架之外,鸭子类型通常比类型检查更简单,也更灵活。

  1. 本书有几个示例要使用序列,把它当成列表处理。
  2. 我没有检查参数的类型是不是list,而是直接接受参数,立即使用它构建一个列表。
  3. 这样,我就可以接受任何可迭代对象;
  4. 如果参数不是可迭代对象,调用立即失败,并且提供非常清晰的错误消息。

一句话:
看起来像鸭子(如序列),直接用序列的特性方法,(如果爆错就是类型不对),如果可以就是通过

这种做法省去了,用isinstance 做检查的痛苦(有时不知道什么类型)

标准库中的抽象基类急顺序 page 375 376

定义并使用一个抽象基类

重点来了

想象一下这个场景:

你要在网站或移动应用中显示随机广告,但是在整个广告清单轮转一遍之前,不重复显示
广告。

假设我们在构建一个广告管理框架,名为 ADAM。

它的职责之一是,支持用户提供随机挑选的无重复类。

为了让 ADAM 的用户明确理解“随机挑选的无重复”组件是什么意思,我们将定义一个抽象基类。

我将使用现实世界中的物品命名这个抽象基类:宾果机和彩票机是随机从有限的集合中挑选物品的机器,选出的物品没有重复,直到选完为止
Tombola 抽象基类有四个方法,其中两个是抽象方法。
  • .load(...):把元素放入容器。
  • .pick():从容器中随机拿出一个元素,返回选中的元素。

另外两个是具体方法。

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

clipboard.png

代码:

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的虚拟子类

  1. 注册虚拟子类的方式是在抽象基类上调用 register 方法。这么做之后,注册的类会变成抽象基类的虚拟子类,
  2. 而且 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
  1. issubclass 函数确认(isinstance 函数也会得出相同的结论)
  2. Struggle 是abc.Sized 的子类,
  3. 这是因为 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 方法,而我们并不需要这两个方法。


小小梁
23 声望15 粉丝