Python学习之路30-接口:从协议到抽象基类

《流畅的Python》笔记。

本篇是“面向对象惯用方法”的第四篇,主要讨论接口。本篇内容将从鸭子类型的动态协议,逐渐过渡到使接口更明确、能验证实现是否符合规定的抽象基类(Abstract Base Class, ABC)。

1. 前言

本篇讨论Python中接口的实现问题,主要内容如下:

  • 补充用鸭子协议实现部分接口的一种重要方法:猴子补丁;
  • 说明抽象基类的常见用途,即,实现接口时作为超类使用;
  • 说明抽象基类如何检查具体子类是否符合接口定义,以及如何使用注册机制声明一个类实现了某个接口;
  • 说明如何不通过子类化或注册,也能让抽象基类自动“识别”任何符合接口的类。

补充在正文之前

  • 在Python中,“X类对象”,“X协议”和“X接口”都是一个意思。并且,除了抽象基类,类实现或继承的公开属性(方法或数据属性),包括特殊方法,都可以看做接口。
  • 关于接口,还有一个很实用的补充定义:对象公开方法的子集,让对象在系统中扮演特定的角色。

2. 猴子补丁

猴子补丁并不是Python特有,它指动态语言中,不用修改源代码,在运行时就能对代码的功能进行动态的追加或变更。下面的代码展示了猴子补丁的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 代码2.1
# 在文件中定义
class MyList:
def __init__(self, iterable):
self._data = list(iterable)

def __len__(self):
return len(self._data)

def __getitem__(self, index):
return self._data[index]

# 下面的代码在控制台运行
>>> from random import shuffle
>>> from my_list import MyList
>>> mylist = MyList(range(10))
>>> def set_item(temp, i, item):
... temp._data[i] = item
...
>>> MyList.__setitem__ = set_item
>>> shuffle(mylist)
>>> deck[:]
[6, 3, 0, 1, 5, 4, 2, 7, 9, 8]

解释

  • Python中,交互式控制台中也支持猴子补丁;
  • 要使用random.shuffle函数,对象必须实现__setitem__方法,上述代码在运行时动态添加所需方法;
  • 猴子补丁很强大,但打补丁的代码与要打补丁的程序耦合十分紧密,而且往往要处理隐藏的部分(比如“受保护的”属性)和没有文档的部分。
  • 上述代码中set_item函数的第一个参数并不是self,这是想说明,每个Python方法说到底都是普通函数,把第一个参数命名为self只是一种约定(但别随意打破这种约定)。

这里之所以讲猴子补丁,主要是为了说明协议可以是动态的:即使对象最初没有实现某个协议,当需要时,我们也能为它动态添加。

3. 抽象基类

介绍完动态实现接口后,现在开始讨论抽象基类,它属于静态显示地实现接口。

3.1 基本概要说明

有时候我们需要明确区分“抽象类”(并不是指“抽象基类”)与“接口”:以自然界为例,“抽象类”一般用于同一物种同一行为,而“接口”则用于不同物种同一行为。当然,这两个概念有交叉的部分,某些行为既可以归到“接口“,也可以归到”抽象类“,而最后归到谁就见仁见智了。但这两个概念又有很大的相似之处,它们的实质都是:让某些对象拥有同名的方法或属性,但具体实现不一定相同

Java更注重这两者的特性,而Python、C++则更注重这两者的共性。也因此,Java不支持多重继承(当然,也是为了降低复杂性),用明确的接口类interface来区分与abstract class;而在Python和C++中,则用抽象基类充当接口。所以,在Python中,直接继承自抽象基类,更多表明的是”要实现某种接口或协议“,而非”要新建某个具体类的子类“。

如果要测试是否继承自抽象基类,推荐使用isinstanceissubclass方法,而不是is运算。但也不要滥用这类方法,因为这种代码用多了说明面向对象设计得不好。

说道isinstance,还有个与之相关的概念,相当于“鸭子类型”的强化版:

  • 白鹅类型(goose typing):只要cls是抽象基类,即cls的元素是abc.ABCMeta,就可以使用isinstance(obj, cls)

小插曲:这是书中给出的标准定义,笔者读到这的时候一脸懵逼。“白鹅类型”是个名词,但这定义却是对一个过程的描述,所以“白鹅类型”到底是个啥(这到底是翻译的锅还是作者的锅)?后来谷歌了一下,再自己反复推敲,得出如下总结:鸭子类型是指某个实例实现了某个方法,就可以说它属于某个类型,不一定要继承;而白鹅类型则是指能被判定成某抽象基类的子类的实例,即,能使isinstance(obj, cls)返回Trueobj就是白鹅类型,其中cls是抽象基类。注意,这些子类并不一定是通过继承而来,也可能是通过注册而来,还可能是通过实现某些方法而来。

特别提醒:对于抽象基类(还有元类)的使用,并不建议在生产代码中自行定义新的抽象基类和元类。定义抽象基类和元类的工作一般由比较资深的Python程序员来做,适用于写框架的程序员。而即便是资深Python程序员也不常自己定义抽象基类和元类。

3.2 标准库中的抽象基类

从Python2.6开始,标准库提供了抽象基类。大多数抽象基类在collections.abc模块中定义,numbersio中也有一些。

以下是collections.abc中16个抽象基类的UML图(关于多重继承的内容将在以后的文章中讲解):

有几个抽象基类值得注意:

  • IterableContainerSized:各个集合类应该继承这三个抽象基类,或者至少实现兼容的协议。Iterable通过__iter__方法支持迭代;Container通过__contains__方法支持in运算;Sized通过__len__方法支持len()函数;
  • SequenceMappingSet:这三个是主要的不可变集合类型,而且各自都有可变的子类,即MutableSequenceMutableMappingMutableSet
  • CallableHashable:从图上可以看出,这两个抽象基类在标准库中没有子类。

numbers包中的抽象基类的继承关系则很简单,都是线性的(“数字塔”)。下面5个类从左到右依次派生:

  • NumberComplexRealRationalIntegral

下面我们将自行定义一个抽象基类并继承出它的子类。但这并不是鼓励各位在生产代码中自定义抽象基类!

3.3 自定义抽象基类

我们将模拟一个随机抽奖机,它的抽象基类是Tombola,它的4个方法如下:

  • .load(...):抽象方法,把元素放入容器;
  • .pick():抽象方法,从容器中随机返回一个元素,并从容器中删除该元素;
  • .loaded():当容器不为空是返回True
  • .inspect():返回一个有序元组,由容器中的现有元素构成,不修改容器的内容(容器内部元素顺序不保留)。

它和它的三个子类的UML图如下:

以下是Tombola的定义:

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
# 代码3.1
import abc

class Tombola(abc.ABC):
@abc.abstractmethod
def load(self, iterable):
"""从可迭代对象中添加元素"""

@abc.abstractmethod
def pick(self):
"""随机删除元素,然后将其返回。
如果实例为空,这个方法应该抛出LookupError,
这个异常是IndexError和KeyError的基类"""

def loaded(self): # 比较耗时,子类可重写
"""当容器不为空时返回True"""
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))

解释及补充

  • 导入时,Python并不会检查抽象方法的实现,在运行时才会真正检测;
  • 如果子类并没有实现抽象基类中所有的抽象方法,那么这个子类依然是抽象基类;
  • 抽象方法中可以有实现代码。即便实现了,子类也必须覆盖抽象方法,但可以使用super()函数调用抽象方法,为它添加功能,而不是从头开始写;
  • 抽象基类中的具体方法只能依赖抽象基类定义的接口
  • 标准库中有两个名为abc的模块,一个是前面说的collections.abc,另一个就是这里的abc模块。只有在新定义抽象基类的时候才用得到abc.ABC,每个抽象基类都依赖这个类。

abc模块中本来还有@abstractclassmethod@abstractstaticmethod@abstractproperty三个装饰器,但这三个从Python3.3起被废除了,因为这三个的功能都能在@abstractmethod上堆叠其他装饰器得到,比如实现@abstractclassmethod的功能:

1
2
3
4
5
# 代码3.2
class MyABC(abc.ABC):
@classmethod
@abc.abstractmethod
def an_abstract_classmethod(cls, ...): pass

3.4 定义子类

以下是它的两个子类的实现代码:

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
# # 代码3.3
class BingoCage(Tombola): # loaded()和inspect()延用抽象基类的实现
def __init__(self, items):
self._randomizer = random.SystemRandom() # 它会调用os.urandom()
self._items = []
self.load(items) # 委托给load()方法实现初始加载

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: # 为了兼容Tombola,并不是抛出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))

3.5 虚拟子类

上面两个子类都是直接继承自Tombola,而白鹅类型有一个基本特性:即便不用继承,也能将一个类注册为抽象基类的虚拟子类。下面是TomboList的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 代码3.4
@Tombola.register # 把TomboList注册为Tombola的虚拟子类
class TomboList(list): # 它同时还是list的真实子类,而list其实是MutableSequence的虚拟子类
def pick(self):
if self:
position = random.randrange(len(self))
return self.pop(position)
else:
raise LookupError("pick from empty LotteryBlower")

load = list.extend # 当我看到居然这么实现方法时,感觉自己好肤浅......

def loaded(self):
return bool(self)

def inspect(self):
return tuple(sorted(self))

# Tombola.register(TomboList) 这是register的函数调用版本

下面是这个子类的简单使用:

1
2
3
4
5
6
7
8
9
10
# 代码3.5
>>> issubclass(TomboList, Tombola)
True # TomboList是Tombola的子类
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True # TomboList的实例也是Tombola类型
>>> TomboList.__mro__
(<class 'mytest.TomboList'>, <class 'list'>, <class 'object'>)
>>> TomboList.__subclasses__()
[<class 'mytest.BingoCage'>, <class 'mytest.LotteryBlower'>]

解释及补充

  • 虚拟子类不会继承注册的抽象基类,而且任何时候都不会检查它是否符合抽象基类的接口,即便在实例化时也不会检查(如果你的虚拟子类没有实现抽象方法,在实例化时不会报错,但如果是继承而来的话则会报错),所以为了避免运行时错误,虚拟子类应该实现抽象基类的全部方法;
  • 类的继承关系存储在一个特殊的类属性__mro__中,即方法解析顺序(Method Resolution Order)。它按顺序列出类及其超类,Python则会按照这个顺序搜索方法。从上述结果可以看出,这个属性只存储了“真实的”超类。
  • __subclasses__方法返回类的直接子类列表,不含虚拟子类;
  • 虽然现在register可以当做装饰器用,但更常用的做法还是把它当函数使用。

3.6 另一种虚拟子类

鹅的行为有可能像鸭子。先看如下代码:

1
2
3
4
5
6
7
8
9
# 代码3.6
>>> class Struggle:
... def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
>>> issubclass(Struggle, abc.Sized)
True

这里既没有继承,也没有注册,但Struggle依然被issubclass判断为abc.Sized的子类。之所以会这样,是因为abc.Sized实现了一个特殊的类方法__subclasshook__

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# # 代码3.7,abc.Sized的实现在 _collections_abc.py 中
class Sized(metaclass=ABCMeta):

__slots__ = ()

@abstractmethod
def __len__(self):
return 0

@classmethod
def __subclasshook__(cls, C):
if cls is Sized:
# 源代码中是 return _check_methods(C, "__len__"),这里修改了一下
if any("__len__" in B.__dict__ for B in C.__mro__):
return True
return NotImplemented

这像不像鸭子类型?只要实现了__len__方法,这个类就是abc.Sized的子类。

在自定义的抽象基类中并不一定要实现__subclasshook__方法,因为即使在Python源码中,目前也只见到Sized这一个抽象基类实现了__subclasshook__方法,而且Sized只有一个特殊方法。在决定自行实现__subclasshook__方法之前,请想清楚你一定需要这个方法吗?你的能力能够保证这个方法的可靠性吗?

4. 总结

本篇讨论的话题只有一个,即“接口”。首先我们讨论了鸭子类型的高度动态性,它实现的是动态协议,也是非正式接口;随后我们借助“白鹅类型”,使用抽象基类明确地、显示地声明接口,然后通过子类或注册来实现这些接口。期间,我们自定义了一个抽象基类,并通过继承实现了它的两个子类,还通过注册实现了它的一个虚拟子类。

最后,还是那句话:不要轻易自定义抽象基类,除非你想构件允许用户扩展的框架。日常使用中,我们与抽象基类的联系应该是创建现有抽象基类的子类,或者使用现有的抽象基类注册。自己从头编写新抽象基类的情况非常少。

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~