0%

流畅的 Python 第 2 版(14):继承:瑕瑜互见

这篇文章将探讨继承和子类化,重点讲解 super()多重继承和方法解析顺序混入类 等 Python 特色功能。

super() 函数

坚持使用内置函数 super() 是确保面向对象的 Python 程序可维护性的基本要求。子类中覆盖超类的方法通常要调用超类中相应的方法。调用被覆盖的 __init__ 方法尤其重要,可以让超类完成它负责的初始化任务。

1
2
3
4
5
6
class LastUpdatedOrderedDict(OrderedDict):
"""按照更新顺序存储项"""

def __setitem__(self, key, value):
super().__setitem__(key, value)
self.move_to_end(key)

你或许见过不使用super()函数而是直接在超类上调用方法的代码,如下所示:

1
2
3
4
5
6
class NotRecommended(OrderedDict):
"""这是一个反例!"""

def __setitem__(self, key, value):
OrderedDict.__setitem__(self, key, value)
self.move_to_end(key)

这种方式不是推荐做法:

  • 硬编码了基类。OrderedDict 名称不仅出现在 class 语句中,还出现在 __setitem__ 方法内。如果代码更换了基类(或者增加了基类),可能会忘记更新 __setitem__ 方法,从而导致 bug
  • super 实现的逻辑能处理多重继承涉及的类层次结构

旧语法的 super() 调用接受如下两个参数:

1
2
3
4
5
6
class LastUpdatedOrderedDict(OrderedDict):
"""在Python 2和Python 3中都能正常运行"""

def __setitem__(self, key, value):
super(LastUpdatedOrderedDict, self).__setitem__(key, value)
self.move_to_end(key)
  • type:从哪里开始搜索实现所需方法的超类
  • object_or_type:接收方法调用的对象(调用实例方法时)或类(调用类方法时)​。在实例方法中调用super()时,默认为 self

现在,super 的两个参数都是可选的。Python 3 字节码编译器通过 super() 调用周围的上下文自动提供那两个参数。

无论是我们自己还是编译器提供这两个参数,super() 调用都返回一个动态代理对象,在 type 参数指定的超类中寻找一个方法(例如这里的 __setitem__)​,把它绑定到 object_or_type 上,因此调用那个方法时不用显式传入接收者(self)。

无论是我们自己还是编译器提供这两个参数,super() 调用都返回一个动态代理对象,在 type 参数指定的超类中寻找一个方法(例如这里的 __setitem__)​,把它绑定到 object_or_type 上,因此调用那个方法时不用显式传入接收者(self)。

子类化内置类型很麻烦

在 Python 的早期版本中,内置类型(例如 list 或 dict)不能子类化。从Python2.2开始,内置类型可以子类化了,但是有一个重要的注意事项:内置类型(使用 C 语言编写)通常不调用用户定义的类覆盖的方法。

关于内置类型的子类覆盖的方法会不会隐式调用,CPython 没有制定官方规则。基本上,内置类型的方法不会调用子类覆盖的方法。例如,如下例子展示了内置类型 dict 的 __init__ 方法和 __update__ 方法会忽略我们覆盖的 __setitem__ 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> class DoppeDict(dict):
... def __setitem__(self, key, value):
... super().__setitem__(key, [value] * 2)
...
>>> dd = DoppeDict(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}
  • [​] 运算符 调用我们覆盖的 __setitem__ 方法,按预期那样工作
  • 继承自 dict 的 __init__update 方法显然忽略了我们覆盖的 __setitem__ 方法

内置类型的这种行为违背了面向对象编程的一个基本原则:应始终从实例(self)所属的类开始搜索方法,即使在超类实现的类中调用也是如此。这种行为叫作 晚期绑定(late binding)。对于 x.method() 形式的调用,具体调用的方法必须在运行时根据接收者 x 所属的类确定。

不只实例内部的调用有这个问题(self.get() 不调用 self.__getitem__())​,内置类型的方法调用的其他类(这个类继承了内置类)的方法如果被覆盖了,则也不会被调用。如下例子中,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'}

以上例子说明了直接子类化内置类型(例如 dict、list 或 str)容易出错,因为内置类型的方法通常会忽略用户覆盖的方法。不要子类化内置类型,用户自己定义的类应该继承 collections 模块中的类,例如 UserDict、UserList 和 UserString。这些类做了特殊设计,因此易于扩展。

内置类型的原生方法使用 C 语言实现,不调用子类中覆盖的方法,唯有极少数例外。因此,当需要定制 list、 dict 或 str 类型时,子类化 UserList、UserDict 或 UserString 更简单。这些类都在 collections 模块中定义,它们其实是对内置类型的包装,并把操作委托给内置类型——这是标准库中优先选择组合而不使用继承的 3 个例子。

综上所述,本节所述的问题只发生在 C 语言实现的内置类型内部的方法委托上,而且只影响直接继承内置类型的类。如果子类化使用 Python 编写的类(例如 UserDict 或 MutableMapping)​,则不会受此影响。

多重继承和方法解析顺序

任何实现多重继承的语言都要处理潜在的命名冲突,这种冲突由超类实现同名方法时引起,我们称之为 菱形问题(diamond problem)。

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
class Root:
def ping(self):
print(f'{self}.ping() in Root')

def pong(self):
print(f'{self}.pong() in Root')

def __repr__(self):
cls_name = type(self).__name__
return f'<instance of {cls_name}>'


class A(Root):
def ping(self):
print(f'{self}.ping() in A')
super().ping()

def pong(self):
print(f'{self}.pong() in A')
super().pong()


class B(Root):
def ping(self):
print(f'{self}.ping() in B')
super().ping()

def pong(self):
print(f'{self}.pong() in B')


class Leaf(A, B):
def ping(self):
print(f'{self}.ping() in Leaf')
super().ping()
1
2
3
4
5
6
7
8
9
10
>>> from diamond import Leaf
>>> leaf1 = Leaf()
>>> leaf1.ping()
<instance of Leaf>.ping() in Leaf
<instance of Leaf>.ping() in A
<instance of Leaf>.ping() in B
<instance of Leaf>.ping() in Root
>>> leaf1.pong()
<instance of Leaf>.pong() in A
<instance of Leaf>.pong() in B

这个例子展示了唤醒过程由以下两个因素决定:

  • Leaf类的方法解析顺序
  • 各方法中使用的 super()

每个类都有名为 __mro__ 的属性,它的值是一个元组,按照方法解析顺序列出各个超类,从当前类一直到 object 类。

1
2
>>> Leaf.__mro__
(<class 'diamond.Leaf'>, <class 'diamond.A'>, <class 'diamond.B'>, <class 'diamond.Root'>, <class 'object'>)

方法解析顺序使用公开发布的 C3 算法计算。Michele Simionato 写的 The Python2.3 Method Resolution Order 一文对该算法在 Python 中的运用做了详细说明。除非大量使用多重继承,或者继承关系不同寻常,否则无须了解 C3 算法。

方法解析顺序只决定唤醒顺序,至于各个类中相应的方法是否被唤醒,则取决于实现方法时有没有调用 super()。以实验中的 pong 方法为例:

  • 由于 Leaf 类没有覆盖该方法,因此调用 leaf1.pong() 唤醒的是 Leaf.__mro__ 中下一个类(A类)实现的 pong 方法
  • A.pong 方法调用了 super().pong()方法解析顺序的下一个类是 B,因此 B.pong 被唤醒
  • B.pong 方法实现中没有调用 super().pong(),所以唤醒过程到此结束

方法解析顺序不仅考虑继承图,还考虑子类声明罗列超类的顺序。因此 Leaf(A, B)Leaf(B, A) 会有不同的方法解析顺序。

调用 super() 的方法叫协作方法(cooperative method)。利用协作方法可以实现协作多重继承。Python中的多重继承涉及多个方法的协作,在 B 类中,ping 是协作方法,而 pong 则不是。非协作方法可能导致不易发现的 bug。鉴于此,才建议非根类中的每一个方法 m 都调用 super().m()

协作的方法必须具有兼容的签名,因为你永远不知道 A.ping 是在 B.ping 之前还是之后调用。同时继承 A 和 B 的类,其唤醒过程取决于子类声明罗列 A 和 B 的顺序。

Python 是动态语言,因此 super() 与方法解析顺序的交互也是动态的。这种动态行为可能导致出人意料的结果:

1
2
3
4
5
6
7
8
9
10
11
from diamond import A

class U():
def ping(self):
print(f'{self}.ping() in U')
super().ping()

class LeafUA(U, A):
def ping(self):
print(f'{self}.ping() in LeafUA')
super().ping()
  • 直接创建一个 U 实例,再调用 ping 将报错,因为 super() 返回的 object 没有 ping 方法
  • 但是通过 LeafUA 创建的实例,调用 ping 方法则是这样工作的:LeafUA 中的 super().ping() 调用唤醒了 U.ping,而 U.ping 也调用 super().ping() 唤醒了 A.ping,最终唤醒了 Root.ping

注意,LeafUA 的基类是 (U, A)(按此顺序)​。如果基类是 (A, U),那么 leaf2.ping() 肯定不会唤醒 U.ping,因为 A.ping 中的 super().ping() 将唤醒 Root.ping,而 Root.ping 没有调用 super()

在真实的程序中,U 这样的类可以作为混入类使用。混入类意在与多重继承中的其他类结合在一起使用,提供额外的功能。下图展示了 Python 标准库中 GUI 工具包 Tkinter 复杂的多重继承图。

混入类

混入类在多重继承中会连同其他类一起被子类化。混入类不能作为具体类的唯一基类,因为混入类不为具体对象提供全部功能,而是增加或定制子类或同级类的行为。在 Python 中,混入类只是一种约定,语言层面没有显式支持。

如下定义的 UpperCaseMixin 类在增加或查询键时会把字符串键转换成大写形式,实现一种键不区分大小写的映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import collections

def _upper(key):
try:
return key.upper()
except AttributeError:
return key

class UpperCaseMixin:
def __setitem__(self, key, item):
super().__setitem__(_upper(key), item)

def __getitem__(self, key):
return super().__getitem__(_upper(key))

def get(self, key, default=None):
return super().get(_upper(key), default)

def __contains__(self, key):
return super().__contains__(_upper(key))
  • 这个混入类实现了映射的 4 个基本方法,总是调用 super(),传入尽量转换成大写形式的 key

由于 UpperCaseMixin 中的每个方法都调用了 super(),因此这个混入类会依赖一个同级类,该类实现或继承了签名相同的方法。为了让混入类发挥作用,在子类的方法解析顺序中,它要出现在其他类前面。也就是说,在类声明语句中,混入类必须出现在基类元组的第一位。

1
2
3
4
class UpperDict(UpperCaseMixin, collections.UserDict):
pass

class UpperCounter(UpperCaseMixin, collections.Counter):

多重继承的实际运用

在 Python 中,多重继承也不常用,不过在实际中也会用到。在 Python 标准库中,多重继承最明显的用途是用于 collections.abc 包。Python 官方文档 collections.abc 使用 混入方法 来称呼很多容器抽象基类中实现的具体方法。提供混入方法的抽象基类扮演两个角色,既是接口定义,也是混入类

每个混入类都有明确的作用,而且名称都以 ...Mixin 结尾。

应对多重继承

程序员在实践中没有通用的继承理论。我们只能从经验法则、设计模式、​最佳实践​、巧妙的缩写词、禁忌等之中寻找一些方向,却没有一应俱全的准则。使用继承(即使避开多重继承)容易得出令人费解和脆弱的设计。我们还没有完整的理论,下面是避免把类图搅乱的一些建议。

利于组合的设计更灵活。即便是单一继承,这个原则也能提高灵活性,因为子类化是一种紧密耦合,而且 参天大继承树 更容易被风吹倒。组合和委托可以取代混入,把行为带给不同的类,但是不能取代接口继承来定义类型的层次结构。

使用多重继承时,一定要明确一开始为什么创建子类。主要原因如下。

  • 继承接口,创建子类型,实现 是什么 关系。这种情况最好使用抽象基类
  • 继承实现,通过重用避免代码重复。这种情况可以利用 混入

通过继承重用代码是实现细节,通常可以换用组合和委托。而接口继承则是框架的支柱。如果可能,接口继承只应使用抽象基类作为基类。

  • 在现代的 Python 中,如果类的作用是定义接口,则应该显式地把它定义为抽象基类或者 typing.Protocol 的子类。抽象基类只能子类化 abc.ABC 或其他抽象基类。继承多个抽象基类不是问题。

  • 如果一个类的作用是提供方法实现以供多个不相关的子类重用,但不体现 是什么 关系,那么就应该把那个类明确地定义为混入类。从概念上讲,混入不定义新类型,只是打包方法,便于重用。混入类绝对不能实例化,而且具体类不能只继承混入类。混入类应该提供某方面的特定行为,只实现少量关系非常紧密的方法。混入类不应保持任何内部状态,即不应有实例属性。Python 中没有表明一个类是混入类的正式方式,因此强烈建议在名称后面加上 Mixin。

如果一个类的结构主要继承自混入类,自身没有添加结构或行为,那么这样的类称为聚合类。如果抽象基类或混入类的某种组合对客户代码非常有用,那么就提供一个类,以易于理解的方式把它们结合起来。聚合类的主体不一定为空,只是经常为空而已。

子类化复杂的类并覆盖类的方法容易出错,因为超类中的方法可能在不知不觉中忽略子类覆盖的行为。应尽量避免覆盖方法,至少要抑制冲动,再容易扩展的类也不轻易子类化。如果必须子类化,则还要看原类是不是为了扩展而设计的。但是怎么知道一个类是不是为了扩展而设计的呢?

  • 有的实现在给类命名时会使用 BaseXXX 的形式,从名称中就能看出来,这个类是为了子类化而设计的。而且,文档和源码中的文档字符串也都明确指出了,类中的方法应由子类覆盖

  • 在 Python3.8 及以上版本中,有一种新方式可以明确表明这种设计约束,即 PEP 591—Adding a final qualifier to typing 引入的 @final 装饰器。这个装饰器可以应用到类或方法上,IDE 或类型检查工具见到它就知道不应该子类化类或覆盖方法

子类化具体类比子类化抽象基类和混入类还危险,因为具体类的实例通常有内部状态,覆盖依赖内部状态的方法时很容易破坏状态。如果子类化是为了重用代码,那么想要重用的代码应该放入抽象基类的混入方法中,或者放入名称可明确表明意图的混入类中

现在的趋势是摒除继承(甚至是单一继承)​。21 世纪出现的语言中,Go 是最成功的一个。Go语言没有 结构,不过可以通过封装字段的结构体构建类型,而且可以为结构体依附方法。Go 语言中的接口由编译器通过结构类型(静态鸭子类型)检查,这与 Python3.8 引入的协议类型非常相似。Go 语言为构建类型和组合接口提供了特殊句法,但是不支持继承(甚至接口之间也不能继承)​。

因此,关于继承,最好的建议或许是:尽可能避免使用。而现实中,我们往往没有选择,因为我们使用的框架有自己的设计选择。