0%

流畅的 Python(7):接口与继承

这篇文章将讨论接口:从鸭子类型的代表特征-动态协议,到使用接口更明确、能验证实现是否符合规定的抽象基类(Abstract Base Class,ABC)。抽象基类与描述符和元类一样,是用于构建框架的工具,一般都不需要自己编写抽象基类,因为很容易过度设计。

Python 文化中的接口和协议

引入抽象基类之前,Python 就已经很成功了,即便现在也很少有代码使用抽象基类。协议可以定义为非正式的接口,而接口在 Python 这种动态类型语言中是按照如下方式运作的:

  • 虽然 Python 没有 interface 关键字,但是每个类都有接口,这就是类实现或继承的公开属性(方法或数据属性),包括特殊方法,例如 __getitem____add__ 等等
  • 受保护的属性(单个前导线)和私有属性(两个前导线)不在接口中,即便我们可以轻松地访问受保护的属性、通过一些技巧来访问到私有属性,我们也不应该去访问它们,不要违背约定
  • 另外,不要觉得把公开数据属性放入对象的接口中有何不妥,因为需要的话,总能实现读值和设值方法,把数据属性变为特性,而不影响客户代码

接口还可以这样定义:对象公开方法的子集,让对象在系统中扮演特定的角色。接口是实现特定角色(例如可迭代对象)的方法集合,而协议就是提前约定好这些非正式的接口。协议与继承没有关系,一个类可能会实现多个接口,从而让实例扮演多个角色。

协议是接口,但是不是正式的,协议的接口集合只由文档和约定定义,因此协议不能像正式接口那样添加限制。一个类可能只实现部分接口,这是允许的。对 Python 程序员来说:X 类对象、X 协议和 X 接口都是一个意思。协议风格的接口与继承完全没有关系,实现同一个协议的各个类是相互独立的。

序列协议是 Python 最基础的协议之一。即便对象只实现了那个协议最基本的一部分,解释器也会负责地处理。

Python 序列

Python 数据模型的哲学是尽量支持基本协议,对序列来说,即便是最简单的实现,Python 也会力求做到最好。例如如下是定义为抽象基类的 Sequence 正式接口:

接下来实现一个 Foo 类,它没有继承 abc.Sequence,而且只实现了序列协议的一个方法 __getitem__

1
2
3
4
5
6
7
8
9
class Foo:
def __int__(self):
self.items = [0, 10, 20]

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

def __getitem__(self, position):
return self.items[position]
1
2
3
4
5
6
7
8
9
10
11
12
13
>>> f = Foo()
>>> f[1]
10
>>> for i in f:
... print(i)
...
0
10
20
>>> 20 in f
True
>>> 15 in f
False
  • 虽然没有 __iter__ 方法,Foo 实例仍然是可迭代对象,因为发现有 __getitem__ 方法时,Python 会调用它。传入从 0 开始的整数索引,尝试迭代对象(这是一种后备机制)
  • 尽管没有 __contains__ 方法,Python 也能通过迭代实例来完成检查,因此能使用 in 运算符

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

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

Python 是动态语言,我们可以在运行时随时为实例添加属性,从而实现某个接口,使对象符合某个协议的要求,这也是协议的动态本性。例如为了让 Foo 类支持就地随机排序,其需要实现可变的序列协议,也就是说其必须提供 __setitem__ 方法:

1
2
3
4
5
6
7
>>> from random import shuffle
>>> shuffle(f)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/local/Cellar/python@3.9/3.9.1_3/Frameworks/Python.framework/Versions/3.9/lib/python3.9/random.py", line 362, in shuffle
x[i], x[j] = x[j], x[i]
TypeError: 'Foo' object does not support item assignment

Python 是动态语言,因此可以在运行时修复该问题:

1
2
3
4
5
6
7
>>> def set_foo_item(foo, position, value):
... foo.items[position] = value
...
>>> Foo.__setitem__ = set_foo_item
>>> shuffle(f)
>>> f.items
[10, 0, 20]

这里把我们定义的 set_foo_item 函数赋值给 Foo 类的 __setitem__ 属性。这样 Foo 类就实现了可变序列协议所需要的方法。这种技术称为 猴子补丁:在运行时修改类或模块,而不改动源码。猴子补丁很强大,但是打补丁的代码与要打补丁的程序耦合十分紧密。

这个例子也说明了协议的动态性,random.shuffle 函数不关心参数的类型,只要那个对象实现了部分可变序列协议即可。即便对象一开始没有所需的方法也没有关系,后来再提供也行。对象的类型无关紧要,只要实现了特定的协议即可。

抽象基类

前面介绍的是 Python 常规的协议风格接口,接下来将讨论抽象基类。除了鸭子类型(即忽略对象的真正类型,转而关注对象有没有实现所需的方法、签名和语义),这里引入了一种新的类型风格,即白鹅类型(goose typing):白鹅类型指的是只要 cls 是抽象基类,即 cls 的元类是 abc.ABCMeta,就可以使用 isinstance(obj, cls)

与具体类相比,抽象基类有很多理论上的优点,Python 的抽象基类还有一个重要的实用优势:可以使用 register 类方法在终端用户代码中把某个类声明为一个抽象基类的 虚拟子类(为此,被注册的类必须满足抽象基类对方法名称和签名的要求,最重要的是满足底层语义契约,但是开发那个类时不需要了解抽象基类,更不用继承抽象基类)。

有时,为了让抽象基类识别子类,甚至不用注册。其实抽象基类的本质就是几个特殊方法:

1
2
3
4
5
6
7
>>> class Bar:
... def __len__(self):
... return 23
...
>>> from collections import abc
>>> isinstance(Bar(), abc.Sized)
True

关于抽象基类的使用,最好遵守以下建议:

  • 如果实现的类体现了 numbers、collections.abc 或其他框架中抽象基类的概念,要么继承相应的抽象基类(必要时),要么把类注册到相应的抽象基类中
  • 开发程序时,不要使用提供注册功能的库或框架,要自己动手注册
  • 不要在生产代码中定义抽象基类(或元类),因为你一般都不会真的需要
  • 如果必须要检查参数的类型,可以通过 isinstance 或 issubclass 测试抽象类型

即便是抽象基类,也不能滥用 isinstance 检查,用多了可能导致代码异味,即表明面对对象设计得不好。在一连串 if/elif/else 中使用 isinstance 做检查,然后根据对象的类型执行不同的操作,通常不是好的做法,此时应该使用多态,即采用一定的方式定义类,让解释器把调用分派给正确的方法,而不是靠 if/else 块硬编码分派逻辑。另一方面,如果必须强制执行 API 契约,通常可以使用 isinstance 检查抽象基类。而在框架之外,鸭子类型通常比类型检查更简单,也更灵活。

以 collections.namedtuple 处理 field_names 参数的方式为例,field_names 的值可以是单个字符串(以空格或逗号分隔标识符),也可以是一个标识符序列。此时可能你会使用 isinstance 判断,但是也可以使用鸭子类型:首先假设输入的是单个字符串,然后把逗号替换成空格,然后拆分成名称列表,如果不是则直接认为名称已经是可迭代对象了:

1
2
3
4
5
try:
field_names = field_names.replace(',', ' ').split()
except AttributeError:
pass
field_names = tuple(field_names)

定义抽象基类的子类

抽象基类是用于封装框架引入的一般性概念和抽象的,例如一个序列、一个确切的数。一般都不需要自己编写新的抽象基类,只需要正确使用现有的抽象基类,就能获得大量好处,而且不用冒着设计不当导致的巨大风险。

下面的例子将 FrenchDeck 声明为 collections.MutableSequece 的子类:

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
#!/usr/bin/env python3

import collections
from random import choice

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

class FrenchDeck(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 rank in self.ranks for suit in self.suits]

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

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

def __setitem__(self, index, value):
self._cards[index] = value

def __delitem__(self, index):
del self._cards[index]

def insert(self, index, value):
self._cards.insert(index, value)

继承 MutableSequence 的类必须实现 __delitem__insert 方法,它们是 MutableSequence 的抽象方法。导入时(加载并编译模块),Python 不会检查抽象方法的实现,而在实例化该类型的对象时才会真正检查。因此如果没有正确实现某个抽象方法,Python 会抛出 TypeError 异常。也就是说,即使 FrechDeck 类不需要 __delitem__insert 的行为,也必须实现他们,因为它继承自 MutableSequence 抽象基类。

抽象基类的方法也不全是抽象的,子类可以直接继承抽象基类的具体方法。或者子类也可以覆盖从抽象基类中继承的方法,以更高效的方式重新实现他们。

标准库中的抽象基类

大多数抽象基类在 collections.abc 模块中定义,当然其他地方也有,例如 numbers 和 io 包中也有一些抽象基类。但是 collections.abc 中的抽象基类最为常用。

Python 官方文档 中介绍了 collections.abc 模块中定义的抽象基类,说明了它们之间的相互关系,以及各个基类提供的抽象方法和具体方法。比较重要的抽象基类如下:

  • Iterable、Container 和 Sized:分别通过 iter 方法支持迭代、通过 contains 方法支持 in 运算符、通过 len 方法支持 len() 函数
  • Sequence、Mapping 和 Set:不可变集合类型
  • MutableSequece、MutableMapping、MutableSet:可变的集合类型
  • MapppingView:.items().keys().values() 返回的对象分别是 ItemView、KeysView 和 ValuesView 的实例
  • Callable 和 Hashable:主要为内置函数 isinstance 提供支持,以一种安全的方式判断对象能不能调用或散列
  • Iterator,它是 Iterable 的子类

除了 collections.abc 之后,标准库中最有用的抽象基类包是 numbers,它定义了数字塔。其中 Number 是位于最顶端的超类,随后是 Complex 子类,依次往下,最底端是 Integral:

  • Number
  • Complex
  • Real
  • Rational
  • Integral

定义并使用一个抽象基类

接下来定义一个抽象基类 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
29
30
31
#!/usr/bin/env python3

import abc


class Tombola(abc.ABC):
@abc.abstractmethod
def load(self, iterable):
"""
load elements from iterable
"""

@abc.abstractmethod
def pick(self):
"""
pick element randomly
raise LookupError when no elements
"""

def loaded(self):
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))
  • 自己定义的类要继承 abc.ABC
  • 抽象方法使用 @abcstractmethod 装饰器,而且定义体中通常只有文档字符串。当然抽象方法是可以有实现代码的,但是即便实现了,子类也必须覆盖抽象方法。但是子类可以使用 super() 函数调用抽象方法,为它添加功能,而不是从头开始实现
  • 抽象基类也可以包含具体方法,抽象基类的具体方法只能依赖抽象基类定义的接口
1
2
3
4
5
6
7
8
9
>>> from tombola import Tombola
>>> class Fake(Tombloa):
... def pick(self):
... return 13
...
>>> f = Fake()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Fake with abstract method load
  • 可以看到创建子类的时候没有报错
  • 当尝试实例化 Fake 时抛出了 TypeError。错误信息显示:Fake 类是一个抽象类,因为它没有实现 load 方法

声明抽象基类最简单的方式是继承 abc.ABC 或其他抽象基类。abc.ABC 是 Python3.4 新增的类,如果你使用旧版本的 python,那么无法继承现有的抽象基类,此时可以在 class 语句中使用 metaclass= 关键字,把值设置为 abc.ABCMeta。metaclass 关键字参数是 Python3 引入的,在 Python2 中必须使用 __metaclass__ 类属性。

@abstractmethod 可以和 @classmethod@staticmethod@property 组合起来使用。与其他方法描述符一起使用时,@abstractmethod` 应该放在最里层。

定义抽象基类的子类

如下 BingoCase 类是 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
#!/usr/bin/env python3

import random

from tombola import Tombola


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(items)

def pick(self):
try:
return self._items.pop()
except IndexError:
raise LookupError('pick from empty BingoCage')

def __call__(self):
self.pick()

BingoCase 类直接从 Tombola 继承了耗时的 loaded 和 inspect 方法。这两个方法都可以覆盖,但是这里并没有。我们可以偷懒,直接从抽象基类中继承不是那么理想的具体方法

而下面定义的子类 LotteryBlower 则覆盖了 loaded 和 inspect 方法:

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
#!/usr/bin/env python3

import random

from tombola import Tombola


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:
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__ 方法,保存的是 list(iterable),而不是 iterable 的引用。这样更灵活,因为 iterable 参数可以是任何可迭代的类型,而把元素存入列表中还确保能取出元素。即使 list(iterable) 会创建参数的副本,这依然是一个好的做法,因为我们会从中删除元素,而客户可能可能不希望自己提供的列表被修改。

Tombola 的虚拟子类

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/usr/bin/env python3

# Copyright (C) fuchencong.com

from random import randrange
from tombola import Tombola


@Tombola.register
class TomboList(list):
def pick(self):
if self:
position = randrange(len(self))
return self.pop(position)
raise LookupError('pop from empty TomboList')

load = list.extend

def loaded(self):
return bool(self)

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

如果是 Python3.3 或之前的版本,不能把 .register 当做类装饰器使用,必须使用标准的调用语法 Tombola.register(TomboList)

1
2
3
4
5
6
7
>>> from tombola import Tombola
>>> from tombolist import TomboList
>>> issubclass(TomboList, Tombola)
True
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True

可以看到 TomboList 是 Tombola 的子类。但是,之前介绍过类的继承关系在一个特殊的类属性中指定 __mro__,即方法解析顺序(Method Resolution Order),它会按照顺序列出类及其超类,Python 会按照这个顺序搜索方法。它只会列出真实的超类:

1
2
>>> TomboList.__mro__
(<class 'tombolist.TomboList'>, <class 'list'>, <class 'object'>)

由于 __mro__ 不会包含虚拟的超类,因此虚拟子类不会从其虚拟超类中继承任何方法。

另外,可以通过 __subclasses__() 获取某个类的直接子类列表,不包含虚拟子类。而通过 _abc_registery 这个 WeakSet 对象,可以获取抽象类注册的虚拟子类的弱引用。只有抽象基类有这个数据属性。

鹅的行为有可能像鸭子

即便不注册,抽象基类也能把一个类识别为虚拟子类,例如:

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

这是因为 abc.Sized 实现了一个特殊的类方法,名为 __subclasshook__。它的是实现如下:

1
2
3
4
5
6
7

@classmethod
def __subclasshook__(cls, C):
if cls is Sized:
if any("__len__" in B.__dict__ for B in C.__mro__):
return True
return NotImplemented

对 C.__mro__(即 C 及其超类)中所列的类来说,如果类的 __dict__ 属性中有名为 __len__ 的属性,则返回 True,否则返回 NotImplemented。__subclasshook__ 在白鹅类型中添加了一些鸭子类型的踪迹,在自己定义的抽象基类中要不要实现 __subclasshook__ 方法呢?可能不需要。在我们自己编写的抽象基类中实现 __subclasshook__ 可靠性很低。

尽管抽象基类使得类型检查变得更容易,但是不应该在程序中过度使用它。Python 的核心在于它是一门动态语言,它带来了极大的的灵活性。如果处处都强制实行类型约束,那么会使代码变得更加复杂。我们应该拥抱 Python 的灵活性。

继承的优缺点

接下来将探讨继承和子类化,重点是说明对 Python 而言尤为重要的两个细节:

  • 子类化内置类型的缺点
  • 多重继承和方法解析顺序

子类化内置类型很麻烦

内置类型可以被子类化,但是有一个重要的注意事项:内置类型(使用 C 语言编写)不会调用用户定义的类覆盖的特殊方法。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> class Double(dict):
... def __setitem__(self, key, value):
... super().__setitem__(key, [value] * 2)
...
>>> dd = Double(one=1)
>>> dd
{'one': 1}
>>> dd['two'] = 2
>>> dd
{'one': 1, 'two': [2, 2]}
>>> dd.update(three=3)
>>> dd
{'one': 1, 'two': [2, 2], 'three': 3}
  • 继承自 dict 的 __init__ 方法忽略了我们覆盖的 __setitem__ 方法
  • [] 运算符会调用我们覆盖的 __setitem__ 方法
  • 继承自 dict 的 update 方法也没有使用我们覆盖的 __setitem__ 方法

原生类型的这种行为违背了面对对象编程的基本原则:始终应该从实例(self)所属的类开始搜索方法,即使在超类实现的类中调用也是如此。不只实例内部的调用有这个问题,内置类型的方法调用其他类的方法,如果被覆盖了,也不会被调用。如下例子汇总,dict.update 方法忽略了 AnswerDict.getitem 方法。:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> class AnswerDict(dict):
... def __getitem__(self, key):
... return 42
...
>>> ad = AnswerDict(a='foo')
>>> ad['a']
42
>>> d = {}
>>> d.update(ad)
>>> d['a']
'foo'
>>> d
{'a': 'foo'}

直接子类化内置类型容易出错,因为内置类型的方法通常会忽略用户覆盖的方法。不要子类化内置类型,用户自己定义的类应该继承 collections 模块中的类,这些类做了特殊设计,因此易于扩展。

多重继承和方法解析顺序

与多重继承有关的另一个问题是:如果同级别的超类定义了同名属性,Python 如何确定使用哪个?任何实现多重继承的语言都要处理潜在的命名冲突,这种冲突由不想关的祖先类实现同名方法引起。

如下展示了一个例子:

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
#!/usr/bin/env python3

# Copyright (C) fuchencong.com


class A:
def ping(self):
print('ping: ', self)


class B(A):
def pong(self):
print('pong: ', self)


class C(A):
def pong(self):
print('PONG: ', self)


class D(B, C):
def ping(self):
super().ping()
print('post-ping: ', self)

def pingpong(self):
self.ping()
super().ping()
self.pong()
super().pong()
C.pong(self)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> from pingpong import *
>>> d = D()
>>> d.pong()
pong: <pingpong.D object at 0x10135f820>
>>> C.pong(d)
PONG: <pingpong.D object at 0x10135f820>
>>> hex(id(d))
'0x10135f820'
>>> d.ping()
ping: <pingpong.D object at 0x10135f820>
post-ping: <pingpong.D object at 0x10135f820>
>>> d.pingpong()
ping: <pingpong.D object at 0x10135f820>
post-ping: <pingpong.D object at 0x10135f820>
ping: <pingpong.D object at 0x10135f820>
pong: <pingpong.D object at 0x10135f820>
pong: <pingpong.D object at 0x10135f820>
PONG: <pingpong.D object at 0x10135f820>
  • 可以看到直接调用 d.pong(),直接运行的是 B 类中的版本
  • 超类中的方法都可以直接调用,此时要把实例作为显示参数传入

Python 会按照特定的顺序遍历继承图,这个顺序叫方法解析顺序(Method Resolution Order,MRO)。类都有一个名为 mro 的属性,它是一个元祖,按照方法解析顺序列出各个超类,从当前类一直向上,直到 object 类:

1
2
>>> D.__mro__
(<class 'pingpong.D'>, <class 'pingpong.B'>, <class 'pingpong.C'>, <class 'pingpong.A'>, <class 'object'>)

如果想把方法调用委托给超类,推荐的方式是使用内置的 super() 函数。使用 super() 调用方法时,会遵守方法解析顺序。也可以直接在类上调用实例方法,此时必须显式传入 self() 参数。

从上面的例子也可以看出,方法解析顺序不仅考虑继承图,还考虑子类声明中列出的超类顺序。

处理多重继承

在 Python 标准库中,最常使用多重继承的是 collections.abc 包。继承有很多用途,而多重继承增加了可选方案和复杂度。使用多重继承容易得出令人费解和脆弱的设计,下面是避免把类图搅乱的一些建议:

    1. 把接口继承和实现继承区分开来:继承接口,创建子类型,实现 是什么 关系;继承实现,通过重用避免代码重复。一定要明确意图,通过继承重用代码是实现细节,通常可以换用组合和委托模式,而接口继承则是框架的支柱
    1. 使用抽象基类显式表示接口:现代 Python 中,如果类的作用是定义接口,应该明确它定义为抽象基类。
    1. 通过混入重用代码:如果一个类的作用是为多个不相关的子类提供方法实现,从而实现重用,但不体现 是什么 关系,应该把那个类明确定义为混入类(mixin class)。从概念上讲,混入不定义新类型,只是打包方法,便于重用。混入类绝对不能实例化,而且具体类不能只继承混入类。混入类应该提供某方面的特定行为,只实现少量关系非常紧密的方法
    1. 在名称中明确指明混入:通过在名称中加入 Mixin 后缀,把类标明为混入类
    1. 抽象基类可以作为混入,反过来则不成立:抽象基类可以实现具体方法,因此可以作为混入使用。不过抽象基类会定义类型,而混入则做不到
    1. 不要子类化多个具体类:具体类可以没有,或最多只有一个具体超类。
    1. 为用户提供聚合类:如果抽象基类或混入的组合对客户代码非常有用,那就提供一个类,使用于理解的方式把它们结合起来。这种类可以称为聚合类
    1. 优先使用对象组合,而不是类继承:优先使用组合能让设计更灵活。组合和委托可以替代混入,把行为提供给不同的类,但是不能取代接口继承去定义类型层次结构