抽象类表示接口
--- Bjarne Stroustrup (C++ 之父)
本章讨论的话题是接口:从鸭子类型的代表特征动态协议, 到使接口更明确,能验证实现是否符合规定的抽象基类(Abstract Base Class, ABC)
我们将实现一个新抽象基类,看看它的运作方式。但是,作者不建议你自己编写抽象基类,因为很容易过度设计。
Python 文化中的接口和协议
关于接口,这里有个实用的补充定义:对象公开方法的子集,让对象在系统中扮演特定的角色。Python 文档中的“文件类对象”或“可迭代对象”就是这个意思,这种说法指的不是特定的类。接口是实现特定角色的方法集合,这种理解正是 Smalltalk 程序员所说的协议,其他动态语言社区都借鉴了这个术语。协议与继承没有关系。一个类可能会实现多个接口,从而让实例扮演多个角色。
协议是接口,但不是正式的(只由文档和约定定义),因此协议不能像正式接口那样施加限制(本章后面会说明抽象基类对接口一致性的强制)。一个类可能只实现部分接口,这是允许的。
- tips:Python3 中 memoryview 的文档(https://docs.python.org/3/library/stdtypes.html#typememoryview) 说,它能处理“支持缓冲协议的对象”,不过缓冲协议的文档是针对C API的。bytearray 的构造方法(https://docs.python.org/3/library/functions.html#bytearray) 接受“一个符合缓冲接口的对象”。如今,文档正在改变用词,使用“字节序列类对象”这样更加友好的表述。我指出这一点是为了强调,对Python程序员来说,“X类对象”“X协议”和“X接口”都是一个意思。
序列协议是Python最基础的协议之一。即便对象只实现了那个协议最基本的一部分,解释器也会负责任地处理。
Python 喜欢序列
Sequence 抽象基类和 collections.abc 中相关抽象类的 UML 类图,箭头由子类指向超类,以斜体显示的是抽象方法
现在,我们来看一个例子
class Foo:def __getitem__(self, pos):return range(0, 30, 10)[pos]f = Foo()
print(f[1])for i in f:print(i)print(20 in f)print(15 in f)""" output: 10 0 10 20 True False """
虽然没有 __iter__
方法,但是 Foo
实例是可迭代的对象,因为发现有 __getitem__
方法时,Python 会调用它,传入从0开始的整数索引,尝试迭代对象(这是一种后备机制)。 尽管没有实现 __contains__
方法,但是 Python 足够智能,能迭代 Foo
实例,因此也能使用 in
运算符:Python 会做权珉阿检查,看看有没有指定的元素。
综上,鉴于序列协议的重要性,如果没有 __iter__
和 __contains__
方法,Python 会调用 __getitem__
方法,设法让迭代和 in
运算符可用。Python 中的迭代是鸭子类型的一种极端形式:为了迭代对象,解释器会尝试调用两个不同的方法。
下面着重强调协议的动态本性。
使用猴子补丁在运行时实现协议
之前的 FrenchDeck
类有个重大缺陷:无法洗牌。因为 FrechDeck
实例的行为像序列,那么它就不需要 shuffle
方法,因为已经有 random.shuffle
函数可用,文档中说它的作用就是“就地打乱序列”
from random import shuffle
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]deck = FrenchDeck()
shuffle(deck)
""" output: Traceback (most recent call last):File "b.py", line 15, in <module>shuffle(deck)File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/random.py", line 307, in shufflex[i], x[j] = x[j], x[i] TypeError: 'FrenchDeck' object does not support item assignment """
因为 shuffle
函数要调换集合中元素的位置,而 FrechDeck
只实现了不可变的序列协议。可变的序列还必须提供 __setitem__
方法。
Python 是动态语言,因此我们可以在运行时修正这个问题。
from random import shuffle
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]def set_card(deck, position, card):deck._cards[position] = cardFrenchDeck.__setitem__ = set_card
deck = FrenchDeck()
shuffle(deck)
print(deck[:5])
""" output: [Card(rank='K', suit='spades'), Card(rank='Q', suit='spades'), Card(rank='3', suit='hearts'), Card(rank='3', suit='spades'), Card(rank='8', suit='diamonds')] """
特殊方法 __setitem__
的签名在Python 语言参考手册中定义(https://docs.python.org/3/reference/datamodel.html?highlight=emulating%20container)。语言参考中使用的参数是 self
, key
和 value
, 而这里使用的是 deck
, position
, card
。这么做是为了告诉你,每个Python 方法说到底都是普通函数,把第一个参数命名为 self
只是一种约定。
这里的关键是,set_card
函数要知道 deck
对象有一个名为 _cards
的属性,而且 _cards
的值必须是可变序列。然后,我们把 set_card
函数赋值给特殊方法 __setitem__
, 从而把它依附到 FrenchDeck
类上。这种技术叫猴子补丁:在运行时修改类或模块,而不改动源码。猴子补丁很强大,但是打补丁的代码与要打补丁的程序耦合十分紧密,而且往往要处理隐藏和没有文档的部分。
除了举例说明猴子补丁之外,该示例还强调了协议是动态的:random.shuffle
函数不关心参数的类型,只要那个对象实现了部分可变序列协议即可。即便对象一开始没有所需的方法也没关系,后来再提供也行。
接下来我们将介绍抽象基类。
Alex Martelli 的水禽
现在有一个问题:我们有了鸭子类型之后,为什么还需要抽象基类呢?
鸭子类型在很多情况下十分有用,但是在其他情况下,随着发展,通常有更好的方式。为此,Alex 举了一个例子。
近代,属和种(包括但不限于水禽所属的鸭科)基本上是根据表型系统学(phenetics) 分类的。也就是关注的是形态和举止的相似性,因此,使用“鸭子类型”比喻是贴切的。
然而,平行进化往往build导致不相关的种产生相似的特征,形态和举止方面都是如此,但是生态位的相似性是偶然的,不同种仍属于不同的生态位。编程语言中也有这种“偶然的相似性”,比如下述经典的面向对象编程示例:
class Artist:def draw(self): ...class Gunslinger:def draw(self): ...class Lottery:def draw(self): ...
显然,只因为 x 和 y 两个对象刚好都有一个名为 draw
的方法,而且调用时不用传入参数,即 x.draw()
和 y.draw()
,远远不能确保二者可以相互调用,或者具有相同的抽象。也就是说,从这样的调用中不能推导出语义相似性。相反,我们需要一位渊博的程序员主动把这种等价维持在一定层次上。
生物(和其他学科) 遇到的这个问题,迫切需要(从很多方面说)表征学之外的分类方式解决,即支序系统学(cladistics)。这种分类学主要根据从共同祖先那里继承的特征分类,而不是单独进化的特征。
知道这些有什么用呢? 视情况而定!比如,逮到一只水禽后,决定如何烹饪才最美味时,显著的特征(不是全部,例如一身羽毛并不重要) 主要是口感和风味(过时的表征学), 这比支序学重要得多。但在其他方面,如对不同病原体的抗性(圈养水禽还是放养), DNA接近性的作用就大多了…
因此参照水禽的分类学演化,我建议在鸭子类型的基础上增加白鹅类型(goose typing)
白鹅类型指,只要 cls
是抽象基类,即 cls
的元类是 abc.ABCMeta
,就可以使用 isinstance(obj, cls)
。
鹅的行为有可能像鸭子
Alex 在他写的“水禽和抽象基类”一文中指出,即便不注册,抽象基类也能把一个类识别为虚拟子类。
class Struggle:def __len__(self): return 23from collections import abc
print(isinstance(Struggle(), abc.Sized))print(issubclass(Struggle, abc.Sized))
""" output: True True """
这是因为 abc.Sized
实现了一个特殊的类方法,名为 __subclasshook__
。
class Sized(metaclass=ABCMeta): __slots__ = ()@abstractmethod def __len__(self):return 0@classmethoddef __subclasshook__(cls, C):if cls is Sized:if any("__len__" in B.__dict__ for B in C.__mro__):return True return NotImplemented
__subclasshook__
在白鹅类型中添加了一些鸭子类型的踪迹。我们可以使用抽象基类定义正式接口,可以始终使用 isinstance
检查,也可以完全使用不相关的类,只要实现特定的方法即可(或者做些事情让 __subclasshook__
信服)。当然,只有提供 __subclasshook__
方法的抽象基类才能这么做。
接下来我们将介绍“继承的优缺点”