接口:从协议到抽象基类
此笔记记录于《流畅的 python》,大部分为其中的摘要,少部分为笔者自己的理解;笔记为 jupyter 转的 markdown,原始版 jupyter 笔记在这个仓库
python 文化中的接口和协议
基本的事实是,Python 语言没有 interface 关键字,而且除了抽象基类,每个类都有接口:类实现或继承的公开属性(方法或数据属性),包括特殊方法,如__getitem__
或__add__
。
按照定义,受保护的属性和私有属性不在接口中:即便“受保护的”属性也只是采用命名约定实现的(单个前导下划线);私有属性可以轻松地访问(参见 9.7 节),原因也是如此。不要违背这些约定。
协议是接口,但不是正式的(只由文档和约定定义),因此协议不能像正式接口那样施加限制(本章后面会说明抽象基类对接口一致性的强制)。一个类可能只实现部分接口,这是允许的。
python 喜欢序列
Python 数据模型的哲学是尽量支持基本协议。对序列来说,即便是最简单的实现,Python 也会力求做到最好。
# 这里只实现序列协议中的一部分,即__getitem__方法
class Foo:
def __getitem__(self, pos):
return range(0, 30, 10)[pos]
f = Foo()
print(f[1]) # 10
10
for i in f:
print(i)
0
10
20
20 in f # True
True
15 in f
False
虽然没有__iter__
方法,但是 Foo 实例是可迭代的对象,因为发现有__getitem__
方法时,Python 会调用它,传入从 0 开始的整数索引,尝试迭代对象(这是一种后备机制)。尽管没有实现__contains__
方法,但是 Python 足够智能,能迭代 Foo 实例,因此也能使用 in 运算符:Python 会做全面检查,看看有没有指定的元素。
综上,鉴于序列协议的重要性,如果没有__iter__
和__contains__
方法,Python 会调用__getitem__
方法,设法让迭代和 in 运算符可用。
使用猴子补丁在运行时实现协议
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]
如果我们想进行洗牌操作,我们可以使用random.shuffle
来打乱,但是由于这涉及到赋值操作,我们还需要设置__setitem__
方法。
```python
def set_card(deck, position, card):
deck._cards[position] = card
FrenchDeck.__setitem__ = set_card
from random import shuffle
deck = FrenchDeck()
shuffle(deck)
deck[:5]
[Card(rank='6', suit='hearts'),
Card(rank='4', suit='clubs'),
Card(rank='7', suit='clubs'),
Card(rank='2', suit='spades'),
Card(rank='9', suit='spades')]
这种技术叫猴子补丁:在运行时修改类或模块,而不改动源码。
Alex Martelli 的水禽
- 对 Python 来说,鸭子类型基本上是指避免使用 isinstance 检查对象的类型(更别提 type(foo) is bar 这种更糟的检查方式了,这样做没有任何好处,甚至禁止最简单的继承方式)
- 总的来说,鸭子类型在很多情况下十分有用;但是在其他情况下,随着发展,通常有更好的方式。事情是这样的……
- 近代,属和种(包括但不限于水禽所属的鸭科)基本上是根据表型系统学(phenetics)分类的。表征学关注的是形态和举止的相似性……主要是表型系统学特征。因此使用“鸭子类型”比喻是贴切的。
然而,平行进化往往会导致不相关的种产生相似的特征,形态和举止方面都是如此,但是生态位的相似性是偶然的,不同的种仍属不同的生态位。编程语言中也有这种“偶然的相似性”
class Artist:
def draw(self): ...
class Gunslinger:
def draw(self): ...
class Lottery:
def draw(self): ...
显然,只因为 x 和 y 两个对象刚好都有一个名为 draw 的方法,而且调用时不用传入参数。显然,只因为 x 和 y 两个对象刚好都有一个名为 draw 的方法,而且调用时不用传入参数
生物(和其他学科)遇到的这个问题,迫切需要(从很多方面来说,是催生)表征学之外的分类方式解决,即支序系统学(cladistics)。这种分类学主要根据从共同祖先那里继承的特征分类,而不是单独进化的特征。(近些年,DNA 测序变得便宜又快,这使支序学的实用地位变得更高。)
知道这些有什么用呢?视情况而定!比如,逮到一只水禽后,决定如何烹制才最美味时,显著的特征(不是全部,例如一身羽毛并不重要)主要是口感和风味(过时的表征学),这比支序学重要得多。但在其他方面,如对不同病原体的抗性(圈养水禽还是放养),DNA 接近性的作用就大多了……
因此,参照水禽的分类学演化,我建议在鸭子类型的基础上增加白鹅类型(goose typing)。白鹅类型指,只要 cls 是抽象基类,即 cls 的元类是abc.ABCMeta
,就可以使用isinstance(obj, cls)
。
Python 的抽象基类还有一个重要的实用优势:可以使用 register 类方法在终端用户的代码中把某个类“声明”为一个抽象基类的“虚拟”子类(为此,被注册的类必须满足抽象基类对方法名称和签名的要求,最重要的是要满足底层语义契约;但是,开发那个类时不用了解抽象基类,更不用继承抽象基类)。这大大地打破了严格的强耦合,与面向对象编程人员掌握的知识有很大出入,因此使用继承时要小心。
class Struggle:
def __len__(self):
return 23
from collections import abc
isinstance(Struggle(), abc.Sized) # True
True
可以看出,无需注册,abc.Sized
也能把 Struggle 识别为自己的子类,只要实现了特殊方法__len__
即可
最后我想说的是:如果实现的类体现了 numbers、collections.abc 或其他框架中抽象基类的概念,要么继承相应的抽象基类(必要时),要么把类注册到相应的抽象基类中。开始开发程序时,不要使用提供注册功能的库或框架,要自己动手注册;如果必须检查参数的类型(这是最常见的),例如检查是不是“序列”,那就这样做:
isinstance(the_arg, collections.abc.Sequence)
此外,不要在生产代码中定义抽象基类(或元类)……如果你很想这样做,我打赌可能是因为你想“找茬”,刚拿到新工具的人都有大干一场的冲动。如果你能避开这些深奥的概念,你(以及未来的代码维护者)的生活将更愉快,因为代码会变得简洁明了。再会!
多态:然而,即便是抽象基类,也不能滥用 isinstance 检查,用得多了可能导致代码异味,即表明面向对象设计得不好。在一连串 if/elif/elif 中使用 isinstance 做检查,然后根据对象的类型执行不同的操作,通常是不好的做法;此时应该使用多态,即采用一定的方式定义类,让解释器把调用分派给正确的方法,而不使用 if/elif/elif 块硬编码分派逻辑。
要抑制住创建抽象基类的冲动。滥用抽象基类会造成灾难性后果,表明语言太注重表面形式,这对以实用和务实著称的 Python 可不是好事。
定义抽象基类的子类
# 把 FrenchDeck2 声明为 collections.MutableSequence 的子类。
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,所以需要实现其对应的抽象方法
del self._cards[position]
def insert(self, position, value): # 同上
self._cards.insert(position, value)
导入时(加载并编译 frenchdeck2.py 模块时),Python 不会检查抽象方法的实现,在运行时实例化 FrenchDeck2 类时才会真正检查。
标准库中的抽象基类
collectiond.abc
numbers 包
numbers 包定义的是“数字塔”(即各个抽象基类的层次结构是线性的),其中 Number 是位于最顶端的超类,随后是 Complex 子类,依次往下,最底端是 Integral 类:Number > Complex > Real > Rational > Integral。
因此,如果想检查一个数是不是整数,可以使用isinstance(x, numbers.Integral)
,这样代码就能接受 int、bool(int 的子类),或者外部库使用 numbers 抽象基类注册的其他类型。为了满足检查的需要,你或者你的 API 的用户始终可以把兼容的类型注册为numbers.Integral
的虚拟子类。
定义并使用一个抽象基类
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 True:
try:
items.append(self.pick())
except LookupError:
break
self.load(items)
return tuple(sorted(items))
白鹅类型的一个基本特性(也是值得用水禽来命名的原因):即便不继承,也有办法把一个类注册为抽象基类的虚拟子类。这样做时,我们保证注册的类忠实地实现了抽象基类定义的接口,而 Python 会相信我们,从而不做检查。如果我们说谎了,那么常规的运行时异常会把我们捕获。
虚拟子类不会继承注册的抽象基类,而且任何时候都不会检查它是否符合抽象基类的接口,即便在实例化时也不会检查。为了避免运行时错误,虚拟子类要实现所需的全部方法。
如下,omboList 是 Tombola 的虚拟子类
from random import randrange
@Tombola.register # ➊
class TomboList(list): # ➋
def pick(self):
if self: # ➌
position = 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) # ➐
Tombola 子类的测试方法
__subclasses__()
: 返回指定类的所有直接子类,不包括虚拟子类_abc_registry
: 一个字典,包含已经注册的虚拟子类
鹅的行为可能像鸭子
class Struggle:
def __len__(self): return 23
from collections import abc
isinstance(Struggle(), abc.Sized) # True
True
issubclass(Struggle, abc.Sized) # True
True
经 issubclass 函数确认(isinstance 函数也会得出相同的结论),Struggle 是abc.Sized
的子类,这是因为 abc.Sized 实现了一个特殊的类方法,名为__subclasshook__
,可以用来动态识别子类。
# 其中的源码如下
class Sized(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def __len__(self):
return 0
@classmethodm
def __subclasshook__(cls, C): # 动态识别子列
if cls is Sized:
if any("__len__" in B.__dict__ for B in C.__mro__): # ➊
return True # ➋
return NotImplemented # ➌
在自己定义的抽象基类中要不要实现__subclasshook__
方法呢?
在你我自己编写的抽象基类中实现__subclasshook__
方法,可靠性很低。我可不相信随便一个实现或继承了 load、pick、inspect 和 loaded 的类(如 Spam)的行为一定像 Tombola。程序员最好让 Spam 继承 Tombola,至少也要注册(Tombola.register(Spam))
,从而确保这一点。当然,自己实现的__subclasshook__
方法还可以检查方法签名和其他特性,但我觉得不值得这么做。