前言

抽象类表示接口 ————Bjarne Stroustrup(C++ 之父)

本章讨论的主题是“鸭子类型”:对象的类型无关紧要,只要实现了特定的协议即 可。

Python文化中的接口和协议

Python语言没有interface关键字,而且除了抽象基类,每个类都有接口:类实现或继承的公开属性(方法或数据属性),包括特殊方法,如__getitem____add__

按照定义,受保护的属性私有属性 不在 接口中,即使“受保护的”属性也只是采用命名约定实现的(单个前导下划线);私有属性可以轻松地访问。不要违背这些约定

关于接口,这里有个实用的补充定义:对象公开方法的子集,让对象在系统中扮演特定的角色。接口是实现特定角色的方法集合,这样理解正是Smalltalk程序员所说的协议,其他动态语言社区都借鉴了这个术语。协议与继承没有关系。一个类可能会实现多个接口,从而让实例扮演多个角色。

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

对Python程序员来说,X 类对象X 协议X 接口都是一个意思。

python喜欢序列

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Foo:
    def __getitem__(self, pos):
        return range(0, 30, 10)[pos]
    
f = Foo()
print(f[1])
>>>10

for x in f:
    print(x)
>>>0
>>>10
>>>20

print(20 in f)
>>>True

print(15 in f)
>>>False

虽然没有__iter__方法,但是Foo实例是可迭代的对象,因为发现有__getitem__方法时,Python会调用它,传入从 0 开始的整数索引,尝试迭代对象(这是一种后备机制)。尽管没有实现__contains__方法,但是 Python足够智能,能迭代Foo实例,因此也能使用in运算符

使用猴子补丁在运行时实现协议

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Sequence:
    def __init__(self, seq):
        self.seq = list(seq)
    
    def __len__(self):
        return len(self.seq)
    
    def __getitem__(self, idx):
        return self.seq[idx]

    def __repr__(self):
        return '<Sequence({})>'.format(', '.join(str(x) for x in self.seq))

s = Sequence([1, 2, 3, 4])
print(s)
print(s[1])

from random import shuffle
print(shuffle(s))
>>>TypeError: 'Sequence' object does not support item assignment
print('running...')
def assignment(obj, idx, val):
    obj.seq[idx] = val

Sequence.__setitem__ = assignment
shuffle(s)

要在命令行下编写上述程序?单文件会TypeError: 'Sequence' object does not support item assignment后无法往下运行

Alex Martelli的水禽

鸭子类型:忽略对象的真正类型,转而关注对象有没有实现所需的方法、签名和语义

对 Python 来说,这基本上是指避免使用isinstance检查对象的类型(更别提type(foo) is bar这种更糟的检查方式了,这样做没有任何好处,甚至禁止最简单的继承方式)

Python的抽象基类还有一个重要的实用优势:可以使用register类方法在终端用户的代码中把某个类“声明”为一个抽象基类的“虚拟”子类(为此,被注册的类必须满足抽象基类对方法名称和签名的要求,最重要的是要满足底层语义契约;但是,开发那个类时不用了解抽象基类,更不用继承抽象基类)。这大大地打破了严格的强耦合,与面向对象编 程人员掌握的知识有很大出入,因此使用继承时要小心

有时,为了让抽象基类识别子类,甚至不用注册(如下面这个例子,实现了__len__()即可)

1
2
3
4
5
6
7
class FOO:
    def __len__(self):
        return 2333
    
from collections import abc
print(isinstance(FOO(), abc.Sized))
>>>True

最重要的一点是,句法和语义要保证按照规定,即符合约定

如果实现的类体现了numberscollections.abc或其他框架中抽象基类的概念,要么继承相应的抽象基类(必要时),要么把类注册到相应的抽象基类中。

开始开发程序时,不要使用提供注册功能的库或框架,要自己动手注册;如果必须检查参数的类型(这是最常见的),例如检查是不是“序列”,那就这样做

1
isinstance(the_arg, collections.abc.Sequence)

即便是抽象基类,也不能滥用 isinstance 检查,用得多了可能导致代码异味,即表明面向对象设计得不好。在一连串 if/elif/elif 中使用 isinstance 做检查,然后根据对象的类型执行不同的操作,通常是不好的做法;此时应该使用多态,即采用一定的方式定义类,让解释器把调用分派给正确的方法,

标准库中的抽象基类

IterableContainerSized

各个集合应该继承这三个抽象基类,或者至少实现兼容的协议。Iterable__iter__方法支持迭代,Container通过__contains__方法支持in运算符,Sized通过__len__方法支持len()函数

SequenceMappingSet

这三个是主要的不可变集合类型,而且各自都有可变的子类MutableSequence,MutableMappingMutableSet

MappingView

Python 3中,映射方法.items().keys().values()返回的对象分别是 ItemsViewKeysViewValuesView的实例。前两个类还从Set类继承了丰富的接口

CallableHashable

这两个抽象基类的主要作用是为内置函数isinstance提供支持,以一种安全的方式判断对象能不能调用或散列

Iterator

后续的章节会提到

numbers

python包numbers定义的是“数字塔”(即各个抽象基类的层次结构是线性的),其中Number是位于最顶端的超类,随后Complex子类,依次往下,最底端是Integral类:

  • Number
  • Complex
  • Real
  • Rational
  • Integral

因此,如果想检查一个数是不是整数,可以使用isinstance(x,numbers.Integral)。为了满足检查的需要,你或者你的API的用户始终可以把兼容的类型注册为numbers.Integral的虚拟子类。
如果一个值可能是浮点数类型,可以使用isinstance(x, numbers.Real) 检查。这样代码就能接受boolintfloatfractions.Fraction,或者外部库(如 NumPy,它做了相应的注册)提供的非复数类型。

decimal.Decimal没有注册为numbers.Real的虚拟子类,原因是,如果你的程序需要 Decimal的精度,要防止与其他低精度数字类型混淆,尤其是浮点数。(换言之区分开来,才好计较小数位数)

自己动手实现一个基类

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

xxxxxxxxxxxxxxxx图片

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

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

另外两个是具体方法:

  • .loaded():如果容器中至少有一个元素,返回 True
  • .inspect():返回一个有序元组,由容器中的现有元素构成,不会修改容器的内容(内部的顺序不保留)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import random
from abc import ABC, abstractmethod

class Tombola(ABC):

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

    @abstractmethod
    def pick(self):
        """
        随即删除元素并将其返回;如果实例为空,抛出LookupError
        """
        pass

    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))

class BingoCage(Tombola):
    def __init__(self, items):
        self._randomizer = random.SystemRandom()
        self._items = list()
        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')
        return self._balls.pop(position)
    
    def loaded(self):
        return bool(self._balls)
    
    def inspect(self):
        return tuple(sorted(self._balls))


@Tombola.register
class TomboList(list):
    def pick(self):
        if self:
            position = random.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)

abc.ABCpython 3.4新增的类,之前的版本为:

1
2
class Tombla(metaclass=abc.ABCMeta)
    pass

python 2中:

1
2
class Tombla(object):
    __metaclass__ = abc.ABCMeta

声明抽象类方法的推荐方式

注意叠放顺序

1
2
3
4
5
class MyABC(abc.ABC):
    @classmethod
    @abc.abstractmethod
    def foo(args, kwargs):
        pass

抽象方法可以有实现代码。即便实现了,子类也必须覆盖抽象方法,但是在子类中可以使用super()函数调用抽象方法,为它添加功能,而不是从头开始实现(老生常谈)

使用LookupError异常的原因是,在Python的异常层次关系中,它与IndexErrorKeyError有关,这两个是具体实现Tombola所用的数据结构最有可能抛出的异常。据此,实现代码可能会抛出 LookupErrorIndexErrorKeyError异常

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
BaseException
    ├── SystemExit
    ├── KeyboardInterrupt
    ├── GeneratorExit
    └── Exception
        ├── LookupError 
            
            ├── IndexError
            
            └── KeyError

IndexErrorKeyErrorLookupError的子类

虚拟子类

即便不继承,也有办法把一个类注册为抽象基类的虚拟子类,这样做时,需要保证注册的类忠实的实现了抽象基类定义的接口。
注册虚拟子类的方式是在抽象基类上调用register()方法, 注册为虚拟子类后,issubclass()isinstance()都能识别。

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

__subclasses__()返回类的直接子类列表,不含虚拟子类。
_abc_registry只有抽象基类有这个数据属性,其值是一个 WeakSet 对象,即抽象类注册的虚拟子类的弱引用。