《流畅的Python》笔记。
本篇是“面向对象惯用方法”的第四篇,主要讨论接口。本篇内容将从鸭子类型的动态协议,逐渐过渡到使接口更明确、能验证实现是否符合规定的抽象基类(Abstract Base Class, ABC)。
1. 前言
本篇讨论Python中接口的实现问题,主要内容如下:
- 补充用鸭子协议实现部分接口的一种重要方法:猴子补丁;
- 说明抽象基类的常见用途,即,实现接口时作为超类使用;
- 说明抽象基类如何检查具体子类是否符合接口定义,以及如何使用注册机制声明一个类实现了某个接口;
- 说明如何不通过子类化或注册,也能让抽象基类自动“识别”任何符合接口的类。
补充在正文之前:
- 在Python中,“X类对象”,“X协议”和“X接口”都是一个意思。并且,除了抽象基类,类实现或继承的公开属性(方法或数据属性),包括特殊方法,都可以看做接口。
- 关于接口,还有一个很实用的补充定义:对象公开方法的子集,让对象在系统中扮演特定的角色。
2. 猴子补丁
猴子补丁并不是Python特有,它指动态语言中,不用修改源代码,在运行时就能对代码的功能进行动态的追加或变更。下面的代码展示了猴子补丁的用法:
# 代码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中,直接继承自抽象基类,更多表明的是”要实现某种接口或协议“,而非”要新建某个具体类的子类“。
如果要测试是否继承自抽象基类,推荐使用isinstance
和issubclass
方法,而不是is
运算。但也不要滥用这类方法,因为这种代码用多了说明面向对象设计得不好。
说道isinstance
,还有个与之相关的概念,相当于“鸭子类型”的强化版:
- 白鹅类型(goose typing):只要
cls
是抽象基类,即cls
的元素是abc.ABCMeta
,就可以使用isinstance(obj, cls)
。
小插曲:这是书中给出的标准定义,笔者读到这的时候一脸懵逼。“白鹅类型”是个名词,但这定义却是对一个过程的描述,所以“白鹅类型”到底是个啥(这到底是翻译的锅还是作者的锅)?后来谷歌了一下,再自己反复推敲,得出如下总结:鸭子类型是指某个实例实现了某个方法,就可以说它属于某个类型,不一定要继承;而白鹅类型则是指能被判定成某抽象基类的子类的实例,即,能使isinstance(obj, cls)
返回True
的obj
就是白鹅类型,其中cls
是抽象基类。注意,这些子类并不一定是通过继承而来,也可能是通过注册而来,还可能是通过实现某些方法而来。
特别提醒:对于抽象基类(还有元类)的使用,并不建议在生产代码中自行定义新的抽象基类和元类。定义抽象基类和元类的工作一般由比较资深的Python程序员来做,适用于写框架的程序员。而即便是资深Python程序员也不常自己定义抽象基类和元类。
3.2 标准库中的抽象基类
从Python2.6开始,标准库提供了抽象基类。大多数抽象基类在collections.abc
模块中定义,numbers
和io
中也有一些。
以下是collections.abc
中16个抽象基类的UML图(关于多重继承的内容将在以后的文章中讲解):
有几个抽象基类值得注意:
Iterable
、Container
和Sized
:各个集合类应该继承这三个抽象基类,或者至少实现兼容的协议。Iterable
通过__iter__
方法支持迭代;Container
通过__contains__
方法支持in
运算;Sized
通过__len__
方法支持len()
函数;Sequence
、Mapping
和Set
:这三个是主要的不可变集合类型,而且各自都有可变的子类,即MutableSequence
、MutableMapping
和MutableSet
。Callable
和Hashable
:从图上可以看出,这两个抽象基类在标准库中没有子类。
在numbers
包中的抽象基类的继承关系则很简单,都是线性的(“数字塔”)。下面5个类从左到右依次派生:
Number
,Complex
,Real
,Rational
,Integral
下面我们将自行定义一个抽象基类并继承出它的子类。但这并不是鼓励各位在生产代码中自定义抽象基类!
3.3 自定义抽象基类
我们将模拟一个随机抽奖机,它的抽象基类是Tombola
,它的4个方法如下:
.load(...)
:抽象方法,把元素放入容器;.pick()
:抽象方法,从容器中随机返回一个元素,并从容器中删除该元素;.loaded()
:当容器不为空是返回True
;.inspect()
:返回一个有序元组,由容器中的现有元素构成,不修改容器的内容(容器内部元素顺序不保留)。
它和它的三个子类的UML图如下:
以下是Tombola
的定义:
# 代码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
的功能:
# 代码3.2
class MyABC(abc.ABC):
@classmethod
@abc.abstractmethod
def an_abstract_classmethod(cls, ...): pass
复制代码
3.4 定义子类
以下是它的两个子类的实现代码:
# # 代码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
的实现:
# 代码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的函数调用版本
复制代码
下面是这个子类的简单使用:
# 代码3.5
>>> issubclass(TomboList, Tombola)
True # TomboList是Tombola的子类
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True # TomboList的实例也是Tombola类型
>>> TomboList.__mro__
('mytest.TomboList'>, 'list'>, 'object'>)
>>> TomboList.__subclasses__()
['mytest.BingoCage'>, 'mytest.LotteryBlower'>]
复制代码
解释及补充:
- 虚拟子类不会继承注册的抽象基类,而且任何时候都不会检查它是否符合抽象基类的接口,即便在实例化时也不会检查(如果你的虚拟子类没有实现抽象方法,在实例化时不会报错,但如果是继承而来的话则会报错),所以为了避免运行时错误,虚拟子类应该实现抽象基类的全部方法;
- 类的继承关系存储在一个特殊的类属性
__mro__
中,即方法解析顺序(Method Resolution Order)。它按顺序列出类及其超类,Python则会按照这个顺序搜索方法。从上述结果可以看出,这个属性只存储了“真实的”超类。 __subclasses__
方法返回类的直接子类列表,不含虚拟子类;- 虽然现在
register
可以当做装饰器用,但更常用的做法还是把它当函数使用。
3.6 另一种虚拟子类
鹅的行为有可能像鸭子。先看如下代码:
# 代码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__
:
# # 代码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. 总结
本篇讨论的话题只有一个,即“接口”。首先我们讨论了鸭子类型的高度动态性,它实现的是动态协议,也是非正式接口;随后我们借助“白鹅类型”,使用抽象基类明确地、显示地声明接口,然后通过子类或注册来实现这些接口。期间,我们自定义了一个抽象基类,并通过继承实现了它的两个子类,还通过注册实现了它的一个虚拟子类。
最后,还是那句话:不要轻易自定义抽象基类,除非你想构件允许用户扩展的框架。日常使用中,我们与抽象基类的联系应该是创建现有抽象基类的子类,或者使用现有的抽象基类注册。自己从头编写新抽象基类的情况非常少。
迎大家关注我的微信公众号"代码港" & 个人网站 www.vpointer.net ~