本章讨论的话题是接口,从鸭子类型代表特征动态协议,到使接口更明确,能验证是否符合规定的抽象基类(Abstract Base Class,ABC)

在 Python 中 上章所说的鸭子类型是接口的常规方式,新只是是抽象基类和类型检查。Python 语言诞生 15 年之后,Python 2.6 才引入抽象基类。

本章先说明 Python 社区以往对接口的不严谨理解:部分实现接口通常被认为是可接受的。我们通过几个示例强调鸭子类型的动态本性,从而澄清这一点

接着,通过 Alex Martelili 写的一篇短文,对抽象基类作介绍,还为 Python 编程下一个新趋势下定义。本章余下的内容专门讲解抽象基类。首先,本章说明抽象基类的常见用途:实现接口时作为超类使用。

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

我们将实现一个新抽象基类,看看它的运作方式。但是,作者和 Alex Martelli 都不建议你自己编写抽象基类,因为容易过度设计

抽象基类与描述符和元类一样,是用于构建框架的工具。因此,只有少数 Python 开发者编写的抽象基类不会对用户施加不必要的限制,让他们做无用功

下面从 Python 风格探索接口

Python 文化中的接口和协议

在引入抽象基类之前,Python 已经非常成功了,即使现在也很少有代码使用抽象基类。在第一章就讨论了鸭子类型和协议,在上一章,我们将协议定义为非正式的接口,是让 Python 这种动态类型语言实现多态的方式。

接口在动态类型语言是怎么运作的呢?首先,基本的事实是,Python 语言没有 interface 关键字,而且除了抽象基类,每个类都有接口:类实现或继承的公开属性(方法或数据的属性),包括特殊方法,如 __getitem____add__

按照定义,受保护的属性和私有属性不在接口中:即便有“受保护”属性也只是采用命名约定实现的(单个前导下划线)私有属性也可以轻松的访问(第 9 章),原因也是如此,不要违背这些约定

另一方面,不要觉得把公开数据属性放入对象接口中不妥,因为如果需要,总能实现读值方法和设值方法,把数据属性变成特性,使用 obj.attr 语法的客户代码不会受到影响。Vector2d 类就是这么做的

下面的例子 x,y 是公开属性


In [1]:
class Vector2d:
    typecode = 'd'
    
    def __init__(self, x, y):
        self.x = float(x)
        self.y = float(y)
        
    def __iter__(self):
        return (i for i in (self.x, self.y))

在第 9 章中,我们将其变成了只读属性,这是重大的重构,但是 Vector2d 的接口基本没变,用户仍然能读取 my_vector.x 和 my_vector.y。下面是使用特性实现 x,y(第 9 章的代码)


In [3]:
class Vector2d:
    typecode = 'd'
    
    def __init__(self, x, y):
        self._x = float(x)
        self._y = float(y)
        
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
    
    def __iter__(self):
        return (i for i in (self.x, self.y))

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

协议是接口,但不是正式的(只由文档和约定定义),因此协议不能像正式接口那样施加限制(本章后面会说明抽象基类对接口一致性的强制)。一个类可能只实现部分接口,这是允许的。有时,某些 API 只要求 “文件类对象” 返回字节序列 .read() 方法。在特定的上下文中可能需要其他文件操作方法,也可能不需要

作者写书时候,Python 3 中的 memoryview 的文档说,它能处理“支持缓冲协议的对象“,不过缓冲协议的文档是 C API 的。 bytearray 的构造方法接受”一个符合缓冲接口的对象”。如今,文档正在改变用词,使用“字节序列类对象”这样更加友好的表述。我指出这一点是为了强调,对 Python 程序员来说,'X' 类对象 和 'X' 协议和 'X' 接口都是一个意思

序列协议是 Python 是最基础的协议之一。即使对象只实现了那个协议的最基本的一部分,解释器也能负责任的处理,如下一节所示。

Python 喜欢序列

Python 数据模型的哲学是尽量支持基本协议。对序列来说,即便是最简单的实现,Python 也会力求做的最好

抽象基类 Sequence 的正式接口如下:__getitem__, __contains__, __iter__, __reversed__, index, count

下面的例子 Foo 类没有继承 abc.Sequence,而且只实现了序列协议的一个方法: __getitem__(没有实现 __len__ 方法),看到这样足够访问元素,迭代和使用 in 运算符了


In [4]:
class Foo:
    def __getitem__(self, pos):
        return range(0, 30, 10)[pos]
    
f = Foo()
f[1]


Out[4]:
10

In [5]:
for i in f: print(i)


0
10
20

In [6]:
20 in f


Out[6]:
True

In [7]:
15 in f


Out[7]:
False

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

综上,鉴于序列协议的重要性,如果没有 __iter____contains__ 方法,Python 会调用 __getitem__ 方法,设法让迭代和 in 运算符可用

第一章定义的 FrenchDeck 类也没有继承 abc.Sequence,但是实现了序列协议的两个方法: __getitem____len__。第一章那些示例之所以能用,大部分是由于 Python 会特殊对待看起来像序列的对象。Python 中迭代是鸭子类型的一种极端形式:为了迭代对象,解释器会尝试调用两个不同的方法


In [8]:
import collections

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()
    
    def __init__(self):
        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]
        
    def __len__(self):
        return len(self._cards)
    
    def __getitem__(self, position):
        return self._cards[position]

下面分析一个示例,着重强调协议的动态本性

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

在上面 FrenchDeck 类有一个重大的缺陷:无法洗牌。几年前,第一次编写 FrenchDeck 示例时,我发现了 shuffle 方法。后来。我对 Python 风格有了深刻理解,我发现如果 FrenchDeck 实例的行为像序列,那么就不需要 shuffle 方法,因为已经有 random.shuffle 函数可用,文档中说它的作用就是就地打乱序列


In [10]:
from random import shuffle
l = list(range(10))
shuffle(l)
l


Out[10]:
[0, 6, 3, 4, 8, 5, 1, 9, 2, 7]

然而,要打乱 FrenckDeck 函实例,会出现异常,如下所示:


In [11]:
from random import shuffle
deck = FrenchDeck()
shuffle(deck)


---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-11-910415cc5ddb> in <module>()
      1 from random import shuffle
      2 deck = FrenchDeck()
----> 3 shuffle(deck)

~/anaconda2/envs/py36/lib/python3.6/random.py in shuffle(self, x, random)
    272                 # pick an element in x[:i+1] with which to exchange x[i]
    273                 j = randbelow(i+1)
--> 274                 x[i], x[j] = x[j], x[i]
    275         else:
    276             _int = int

TypeError: 'FrenchDeck' object does not support item assignment

错误消息很明确,FrenchDeck 对象不支持为元素赋值。这个问题的原因是,FrenchDeck 只实现了不可变的序列协议,可变序列还需要提供 __setitem__ 方法。

Python 是动态语言,因此我们可以在运行时修正这个问题,甚至还可以在交互式控制台中,修正方法如下所示:


In [12]:
def set_card(deck, position, card):
    deck._cards[position] = card
    
FrenchDeck.__setitem__ = set_card
shuffle(deck) # 现在可以打乱顺序了,因为实现了可变序列协议所需要的方法
deck[:5]


Out[12]:
[Card(rank='7', suit='spades'),
 Card(rank='Q', suit='diamonds'),
 Card(rank='J', suit='spades'),
 Card(rank='K', suit='diamonds'),
 Card(rank='3', suit='spades')]

__setitem__ 在语言参考中使用的参数是 self, key, value,我们这里用的是 deck, position 和 card,这是为了提醒我们,每个 Python 方法说到底就是一个普通的函数,把第一个参数命名为 self 是一种约定。在控制台会话使用那几个参数没问题,但是在 Python 源码文件中最好按照文档那样使用 self, key, value

这里的关键是,set_card 函数要知道 deck 对象有一个名为 _cards 的属性,而且 _cards 必须是可变序列,然后吧 setcard 方法赋给特殊方法 __setitem__,从而把它依附到 FrenchDeck 类上。这种技术叫猴子补丁:在运行时修改类或模块,而不改动源码,猴子补丁很强大,但是打补丁的代码与要补丁的程序耦合十分紧密,而且往往要处理隐藏和没有文档的部分

除了举例的猴子补丁之外,上面例子还强调了协议是动态的:random.shuffle() 函数不关心参数类型,只要那个对象实现了部分可变序列协议即可。即便对象一开始没有所需的方法也没关系,后来再提供也行

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

前面给出的抽象基类 Sequence 是为了展示协议与抽象基类的文档中所说的接口之间的关系,但是目前为止还没有真正的实现抽象基类

在下面的几节中,我们将直接使用抽象基类,而不是将其视为文档

Alex Martelli 的水禽

Alex Martelli 讲故事:

wiki 说我协助传播了“鸭子类型”这种言简意赅的说法(即斛律对象的真正类型,转而关注对象有没有实现所需的方法,签名和语义)

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

总的来说,鸭子类型很有用,但是有的时候继承的方式更好。

在生物学中,平行进化会导致不相关的种类产生相似的属性,形态和举止方面都是如此,但是生态位的相似性是偶然的,不同的种类仍然属于不同的生态位。编程语言也有这种“偶然的相似性”,比如下面经典的面向对象编程示例:


In [3]:
class Aritist:
    def draw(self):
        pass
        
class Gunslinger:
    def draw(self): 
        pass
        
class Lottery:
    def draw(self):
        pass

显然,因为 x,y 两个对象刚好都有一个名为 draw 的方法,而且调用时候不用传入参数,即 x.draw() 和 y.draw(),远远不能确保二者可以相互调用,或者具有相同的抽象。也就是说,从这样的调用中不能推导出语义的相似性。相反,我们需要一位渊博的程序员主动把这种等价维持在一定层次上。

例如,草雁(以前认为与其他鹅类比较类似)和麻鸭(以前认为与其他鸭类比较类似)现在被分到 Tadornidae 亚科(表明二者相似性比鸭科其他动物高,因为他们的共同祖先比较接近)。此外,DNA 分析表明,白翅木鸭与美洲家鸭(属于麻鸭)不是很像,至少没有形态和举止看起来那么像,因此把木鸭单独分成了一组,完全不再 Tadornidae 亚科中。

知道这些有什么用呢?视情况而定,比如,逮到一直水禽之后,决定如何烹制才最美味时,显著的特征(不是全部,例如一身羽毛并不重要)主要是口感和风味(过时的表征学),这比支序学重要的多。但是其他方面,如对不同病原体的抗性(圈养水禽还是放养),DNA 接近性的作用就大多了。

因此,参照水禽的分类学演化,我建议在鸭子类型基础上增加白鹅类型(goose typing)。

白鹅类型是指,只要 cls 是抽象基类,即 cls 元类是 abcABCMeta 就可以使用 isinstance(obj, cls)。

colections.abc 中有很多有用的抽象基类(Python 标准库的 numbers 模块还有一些)。

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

有时,为了让抽象基类识别子类,甚至不用注册。

其实,抽象基类的本质就是几个特殊方法。例如:


In [4]:
class Struggle:
    def __len__(self): return 23
    
from collections import abc
isinstance(Struggle(), abc.Sized)


Out[4]:
True

可以看出,无需注册,abc.Sized 也能把 Struggle 识别为自己的子类,只要实现了特殊方法 __len__ 即可(要使用正确的语法和语义实现,前者要求没有参数,后者要求返回一个非负整数,指明对象的长度;如果不使用规定的语法和语义实现 __len__ 方法,会导致非常严重的问题)

最后要说的是:如果实现的类具体实现了 numbers, collections.abc 或者其他框架中的抽象基类的概念,要么继承相应的抽象基类(必要时),要么类注册到相应的抽象基类中。开始开发程序时,不要使用提供注册功能的库或框架,要自己动手注册,如果必须检查参数的类型(这是最常见的),例如检查是不是 “序列”,那么就这么做:

isinstance(the_arg, collections.abc.Sequence)

此外,不要在生产代码中定义抽象基类(或元类)。。。如果你很想这样做,我打赌你可能要找茬(= =),刚拿到新工具的人都有大干一场的冲动。如果你能避开这些深奥的概念,你(以及未来的代码维护者)的生活将更加愉快,因为代码会变得简洁明了,讲完啦~

除了提出 ”白鹅类型“ 之外,Alex 还指出,继承抽象基类很简单,只需要实现所需的方法,这样也能明确表明开发者意图,这一意图还能通过注册虚拟子类实现。

此外,使用 isinstance 和 issubclass 测试抽象基类更为人接受,过去,这两个函数用来测试鸭子类型,但用于抽象基类会更加灵活。毕竟,如果某个组件没有继承抽象基类,事后还可以注册,让显式类型检查通过

然而即使是抽象基类,也不能滥用 isinstance 检查,用多了可能导致代码很糟糕。在一连串 if/elif/elif 中使用 isinstance 做检查,然后根据对象类型做不同操作,是十分糟糕的做法,此时应该使用堕胎,即使用一定的方式定义类,让解释器把调用分派给正确的方法,而不是采用 if/elif/elif 块硬编码分派逻辑

具体使用时,上述建议有一个常见的例外:有些 Python API 接受一个字符串或字符串序列,如果只有一个字符串,可以把它放到列表中,从而简化处理。因为字符串是序列类型,所以为了把他和其它不可变序列区分,最简单的方式是使用 isinstance(x, str) 检查

可惜,Python 3.4 没有能把字符串和元组或者其他不可变序列区分开的抽象基类,因此必须测试 str。在 Python 2 中,basestr 类型可以协助这样的测试。basestr 不是抽象基类,但是他是 str 和 unicode 的超类。然而,Python 3 却把 basestr 去掉了,奇怪的是,Python 3 中有个 collections.abc.ByteString 类型,但是只能检测 byte 和 bytearray 类型

另一方面,如果必须强制执行 API 契约,通常可以使用 isinstance 检查抽象基类。“老兄,如果你想调用我,必须实现这个”,正如本书审校 Lennart Regebro 所说。这对采用插入式架构的系统来说特别有用。在框架之外,鸭子类型通常比类型检查更简单灵活

例如,本书有几个示例要使用序列,把他当成列表,我没有检查参数的类型是不是 list,而是直接接受参数,立即使用它构建一个列表。这样,我就可以接受任何可迭代对象,如果参数不是可迭代对象,调用立即失败,并提供清晰的错误信息,本章后面有一个这样的例子。当然,如果序列太长或者需要就地修改序列而导致无法复制参数,就不能采用这种方式,此时,使用 isinstance(x, abc.MutableSequence) 更好,如果可以接受任何可迭代对象,也可以调用 iter(x) 函数获得一个迭代器,第 14 章详细讲

模仿 collections.namedtuple 处理 field_names 参数的方式也是一例,field_names 的值可以是单个字符串,以空格或者逗号分隔符,也可以是一个标识符序列,此时可能想使用 isinstance,但是我会使用鸭子类型,如下所示:

使用鸭子类型处理单个字符串或者由字符串组成的可迭代对象:


In [7]:
field_names = ['kaka,hello,world', "test, test2"]
try:
    field_names = field_names.replace(',', ' ').split() # 逗号换成空格并拆分成列表
except AttributeError:
    pass

field_names = tuple(field_names) # 为了确保传进去的是可迭代对象,也为了创建一个备份,使用所得值创建一个元组
field_names


Out[7]:
('kaka,hello,world', 'test, test2')

最后再 Alex Martelli 强调一下建议尽量不要自己实现抽象基类,容易造成灾难性后果,下面通过实例讲解白鹅类型:

定义抽象基类的子类

我们按照 Martelli 的建议,先利用现有抽象基类(collections.MutableSequence),然后再自己定义。在下面例子,我们将 FrenchDeck2 声明为collections.MutableSequence 的子类


In [8]:
import collections

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck2(collections.MutableSequence):
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()
    
    def __init__(self):
        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]
    
    def __len__(self):
        return len(self._cards)
    
    def __getitem(self, position):
        return self._cards[position]
    
    def __setitem(self, position, value): # 为了支持洗牌
        self._cards[position] = value
        
    def __delitem__(self, position):      # 继承 MutableSequence 必须实现 __delitem__ 方法,这是它的一个抽象方法
        del self._cards[position]
        
    def insert(self, position, value):    # insert 方法也是 MutableSequence 类的第三个方法
        self._cards.insert(position, value)

Python 不会在模块导入时候检查抽象方法的实现,而是在实例化 FrenchDeck2 类时才会真正的检查。因此,如果没有正确实现某个抽象方法,Python 会抛出 TypeError 异常,并把错误消息设为 “Can't instantiate abstract class FrenchDeck2 with abstract methods __delitem__, insert"。正是这个原因,即使 FrenchDeck2 类不需要 __delitem__ 和 insert 提供的行为,但是因为继承 MutableSequence 抽象基类,必须实现他们。

FrenchDeck2 从 Sequence(因为 MultableSequence 继承了 Sequence) 继承了几个拿来即用的具体方法 __contains__, __iter__, __reversed__, index 和 count。FrenchDeck2 从 MutableSequence 继承了 append,extend,pop,remove 和 __iadd__

在 collections.abc 中,每个抽象基类的具体方法都是作为类的公开接口实现的,因此不用知道实例的内部接口

要想实现子类,可以覆盖抽象基类中继承的方法,以更高效的方式实现,例如,__contains__ 方法会全面扫描序列,如果面门定义的序列按顺序保存元素,就可以重新实现 __contain__ 方法,使用 bisect 函数做二分查找,提高速度

标准库中的抽象基类

Python 2.6 开始,标准库提供了抽象基类,大多数抽象基类在 collections.abc 模块中定义,不过其他地方也有,例如 numbers 和 io 包中包含一些,但是 collections.abc 中的抽象基类最常用,我们看看这个模块有那些抽象基类

标准库有两个 abc 模块,我们讨论的是 collections.abc 为了减少加载时间, Python 3.4 在 collections 包之外实现了这个模块,因此要与 cloections 分开导入。另一个 abc 模块就是 abc,这里定义的是 abc.ABC 类,每个抽象基类都依赖这个类,但是不用导入他,除非重新定义新的抽象基类

下面介绍几个抽象基类:

Iterable, Container, Sized

每个集合都应该继承这 3 个抽象基类,或者至少要实现兼容协议。Iterable 通过 __iter__ 方法支持迭代,Ccontainer 通过 __contains__ 方法支持 in 运算符,Sized 通过 __len__ 方法支持 len 函数

Sequence, Mapping 和 Set

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

MappingView

在 Python 3 中,映射方法 .items(), .keys(), .values() 返回的对象分别时 ItemsView,KeysView 和 ValuesView 的实例。前两个类还从 Set 类继承了丰富的接口,包含第 3 章所述的所有运算符

Callable 和 Hashable

这两个抽象基类和集合没有太大关系,只不过因为 collections.abc 是标准库中定义抽象基类的第一个模块,而他们又太重要了,所以才放到 collections.abc 模块中。我从未见过 Callable 或 Hashable 的子类。这两个抽象基类的主要作用是为内置函数 isinstance 提供支持提供支持,以一种安全的方式判断对象能不能调用或三裂

若检查是否能调用,可以使用内置的 callable() 函数,但是没有类似的 hashable() 函数,因此测试对象是否可散列,最好使用 isinstance(my_obj, Hashable)

Iterator

注意它是 Iterable 的子类,我们在第 14 章继续讨论

继 collections.abc 之后,标准库中最有用的抽象基类包是 numbers,我们来介绍一下

抽象基类的数字塔

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

  • Number
  • Complex
  • Real
  • Rational
  • Integral

因此,要检查一个数是不是整数,可以使用 isinstance(x, numbers.Integral),这样代码就能接受 int, bool(inti 的子类),或者外部库使用 numbers 抽象基类注册的其他类型。为了满足检查需要,你或者你的 API 用户始终可以把兼容的类型注册为 numbers.Integral 的虚拟子类

与之类似,如果一个值可能是浮点数类型,可以使用 isinstance(x, numbers.Real) 检查。这样代码就能接受 bool,int,float,fractions.Fraction 或者外部库(如 Numpy,它做了相应注册)提供非复数的类型

decimal.Decimal 没有注册为 numbers.Real 的虚拟子类,这有写奇怪。没注册的原因时,如果你的程序需要 Decimal 的精度,要防止与其他低精度的数字类型混淆,尤其是浮点数

了解一些现有的抽象基类之后,我们将从零开始实现一个抽象基类,然后开始使用,以此实现白鹅类型。这么做的目的不是鼓励每个人都立即定义抽象基类,而是教你怎么阅读标准库和其他包中的抽象基类源码。

定义并使用一个抽象基类

假如我们要在网站随机显示广告,但是在整个广告清单轮转一遍,不重复显示广告。我们正在构建一个广告管理框架,名叫 ADAM,它的职责之一是,支持用户随机挑选无重复类,我们将为此定义一个抽象基类

收到栈和队列启发,我们将使用现实中物体命名这个抽象基类:宾果机和彩票机是随机从有限集合挑选物品的机器,选出的物品没有重复,直到选完为止

我们将这个抽象基类命名为 Tombola,这是宾果机和打乱数字的滚动容器的意大利名字

Tombla 抽象基类有四个方法,其中两个是抽象方法。

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

下面两个是具体方法:

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

抽象基类的定义如下所示:


In [1]:
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 1:  # 我们不知道具体子类如何存储元素,为了得到 inspect 结果,不断调用 pick 方法,把 Tombola 清空
            try:
                items.append(self.pick())
            except LookupError:
                break
        self.load(items)  # 再加回去元素
        return tuple(sorted(items))

其实,抽象方法也可有实现代码,即使实现了,子类也必须覆盖抽象方法,但是在子类中可以用 super() 函数调用抽象方法,为它添加功能,而不是从头实现。@abstractmethod 装饰器用法参见 abc 文档

上面 inspect 方法实现的比较笨拙,不过却表明,有了 pick(),和 load(...) 方法,如果想查看 Tombola 内容,可以先把所有元素挑出,再放回去。这个例子的目的是强调抽象基类可以提供具体方法,只要依赖接口中其他方法就行。Tombola 具体子类知晓内部数据结构,可以覆盖 inspect() 方法,使用更聪明的方式实现

上面的 loaded() 方法看起来不笨,但是耗时间,调用 inspect() 方法构建有序元组只是为了看看序列是不是空。这样做可以,但是在子类做会更好,后文会讲到

注意,实现 inspect() 方法采用的是迂回方式捕获 pick() 方法抛出的 LookupError。pick() 抛出 LookupError 也是接口的一部分,但是在 Python 中无法声明,只能在文档说明

选择使用 LookupError 的原因是,在 Python 异常关系层次中,它是 IndexError 和 KeyError 的父类,这两个是具体实现 Tombola 所有的数据结构最有可能抛出的异常

为了看看抽象基类对接口做的检查,下面我们尝试使用一个有缺陷的实现糊弄 Tombola:


In [2]:
class Fake(Tombola):
    def pick(self):
        return 13

Fake


Out[2]:
__main__.Fake

In [3]:
f = Fake()  #实例化的时候会报错


---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-3-22e31e42214b> in <module>()
----> 1 f = Fake()  #实例化的时候会报错

TypeError: Can't instantiate abstract class Fake with abstract methods load

抽象基类语法简介

声明抽象基类的最简单方式是继承 abc.ABC 或其他抽象基类。

然而,abc.ABC 是 Python 3.4 增加的新类,如果使用旧版 Python,无法继承现有抽象基类,必须用 metaclass= 关键字,把值设为 abc.ABCMeta(不是 abc.ABC)。

写成下面这样:


In [4]:
#class Tombola(metaclass=abc.ABCMeta):
#    pass

metaclass= 关键字是 Python 3 引入的。在 Python 2 中必须使用 __metaclass__ 类属性:


In [5]:
#class Tombola(object): # Python 2
#    __metaclass__ = abc.ABCMeta
#    pass

元类将在 21 章讲解,我们先将其理解为一种特殊的类,同样也把抽象基类理解为一种特殊的类。例如:“常规的”类不会检查子类,因此这是抽象基类的特殊行为

除了 @abstractmethod 之外,abc 模块还定义了 @abstractclassmethod, @abstractstaticmethodm, @abstractproperty 三个装饰器。然而,后 3 个装饰器从 Python 3.3 废弃了,因为装饰器可以在 @abstractmethod 上对叠,那三个就显得多余了。例如,生成是抽象类方法的推荐方式是:


In [6]:
# import abc
# class MyABC(abc.ABC):
#     @classmethod
#     @abc.abstractmethod
#     def an_abstract_classmethod(cls, ...):
#         pass

在函数上堆叠装饰器的顺序非常重要,@abstractmethod 文档就特别指出:

  • 与其他描述符一起使用时,abstractmethod() 应该放在最里层.

也就是说,在 @abstractmethod 和 def 语句之间不能有其它装饰器

定义 Tombola 抽象基类的子类

定义好 Tombola 抽象基类之后,我们要开发两个具体子类,满足 Tombola 规定的接口。

下面的 BingoCage 类例子是根据第五章例子修改的,使用了更好的随机发生器。BingoCage 实现了所需的首相方法 load 和 pick

从 Tombola 中继承了 loaded 方法,覆盖了 inspect 方法,增加了 __call__ 方法。


In [7]:
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.SystemRandom 使用 os.urandom(...) 函数实现 random API,根据 os 模块文档,这个函数生成“适合用于加密”的随即字节序列

BingoCage 从 Tombola 继承了耗时的 loaded 方法和笨拙的 inspect 方法。这两个方法都可以覆盖,变成下面例子中更快的方法,这里想表达的观点是:我们可以偷懒,直接从抽象基类中继承不是那么理想的具体方法。从 Tombola 中继承的方法没有 BingoCage 自己定义的那么快,不过只要 Tbombola 子类能正确的实现 pick 和 load 方法,就能提供正确的结果

下面是 Tombola 接口的另一种实现,虽然与之前不同,但是完全有效。LotteryBlower 打乱“数字球”后没有提取最后一个,而是提取一个随即位置上的球


In [8]:
import random

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:
            # 为了兼容 Tombola,我们抛出 LookupError
            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))

有个习惯做法值得指出,在 __init__ 方法中,self._balls 保存的是 list(iterable),而不是 iterable 的引用,这样会 LotterBlower 更灵活,因为 iterable 参数可以是任意可迭代的类型。把元素存入列表中还确保能取出元素。就算 iterable 参数始终传入列表,list(iterable) 会创建参数副本,这依然是好的做法,因为用户可能不希望自己提供的数据被改变

Tombola 的虚拟子类

白鹅类型的一个基本特性(也是值得用水禽来命名的原因):即使不继承,也有办法把一个类注册为抽象基类的虚拟子类。这样做时,我们保证注册的类忠实地实现了抽象基类定义的接口,而 Python 会相信我们从不做检查。如果我们说谎了,那么常规运行时异常会把我们捕获

注册虚拟子类的方式是在抽象基类上调用 register 方法。这么做之后,注册的类会变成抽象基类的虚拟子类,而且 issubclass 和 isinstance 等函数都能识别,但是注册的类不会从抽象基类中继承任何方法或属性。

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

register 方法通常作为普通的函数调用,不过也可以作为装饰器使用。在下面的例子,我们使用装饰器语法实现了 TomboList 类,这是 Tombola 的一个虚拟子类


In [11]:
from random import randrange

@Tombola.register #注册虚拟子类
class TomboList(list): #继承 list
    def pick(self):
        if self: # 从 list 继承 __bool__ 方法,列表不为空时候返回 True
            position = randrange(len(self))
            return self.pop(position)    #调用继承自 list 的 pop 方法
        else:
            raise LooupError('pop from empty TomboList')
            
    load = list.extend # Tombolist.load  和 list.extend 一样
    
    def loaded(self):
        return bool(self)
    
    def inspect(self):
        return tuple(sorted(self))
    
#Tombola.register(TomboList) # Python 3.3 之前不能把 register 当做类装饰器使用,必须使用标准的调用语法

注册之后,可以使用 issubclass 和 isinstance 函数判断 TomboList 是不是 Tombola 的子类


In [13]:
issubclass(TomboList, Tombola)


Out[13]:
True

In [14]:
t = TomboList(range(100))
isinstance(t, Tombola)


Out[14]:
True

然而,类的继承关系在一个特殊的类中指定 -- __mro__,即方法解析顺序(Method Resolution Order)。这个属性的作用域很简单,按顺序列出类及超类,Python 会按照这个顺序搜索方法。查看 TomboList 类的 __mro__ 属性,你会发现它只列出了 “真实的” 超类,即 list 和 object:


In [15]:
TomboList.__mro__


Out[15]:
(__main__.TomboList, list, object)

Python 使用 register 的方式

Python 3.3 之前的版本不能将 register 当做装饰器使用,必须定义类以后像普通函数那样调用。

虽然现在可以当装饰器使用了,但是更常见的做法还是当函数,例如 collections.abc 模块源码中:

Sequence.register(tuple)
Sequence.register(str)
Sequence.register(range)
Sequence.register(memoryview)

鹅的行为可能像鸭子

Alex 讲故事时候说过,即使不注册,抽象基类也能把一个类识别成虚拟子类,下面是他举得一个例子,我添加了一些代码,用 issubclass 来测试:


In [17]:
class Struggle:
    def __len__(self): return 23
    
from collections import abc

isinstance(Struggle(), abc.Sized)


Out[17]:
True

In [18]:
issubclass(Struggle, abc.Sized)


Out[18]:
True

经过 issubclass 函数确认(isinstance 也会得到相同的结论),Struggle 是 abc.Sized 的子类,这是因为 abc.Sized 实现了一个特殊的方法, __subclasshook__。看下面 Sized 类的源码:


In [20]:
# class Sized(metaclass = ABCMeta):
    
#     __slots__ = ()
    
#     @abstractmethod
#     def __len__(self):
#         return 0
    
#     @classmethod
#     def __subclasshook__(cls, C):
#         if cls is Sized:
#             # 对于 C 类以及其超类,如果 `__dict__` 属性中名为 `__len__` 的属性。。。
#             if any("__len__" in B.__dict__ for B in C.__mro__): 
#                 return True # 返回 True,表明 C 是 Sized 的虚拟子类
#         return NotImplemented #否则,返回 NotImplement,让子类检查

__subclasshook__ 在白鹅类型添加了一些鸭子类型的踪迹,我们可以使用抽象基类定义正式接口,可以始终使用 isinstance 检查,也可以完全使用不相关的类,只要实现特定的方法即可(或者做某些事让 __subclasshook__ 信服)。当然,只有提供 __subclasshook__ 方法的抽象基类才能这么做

我们一般不需要在自己定义的抽象基类实现 __subclasshook__