这篇文章将探讨继承和子类化,重点讲解 super()、多重继承和方法解析顺序 和 混入类 等 Python 特色功能。
super() 函数
坚持使用内置函数 super() 是确保面向对象的 Python 程序可维护性的基本要求。子类中覆盖超类的方法通常要调用超类中相应的方法。调用被覆盖的 __init__ 方法尤其重要,可以让超类完成它负责的初始化任务。
1 | class LastUpdatedOrderedDict(OrderedDict): |
你或许见过不使用super()函数而是直接在超类上调用方法的代码,如下所示:
1 | class NotRecommended(OrderedDict): |
这种方式不是推荐做法:
- 硬编码了基类。OrderedDict 名称不仅出现在 class 语句中,还出现在
__setitem__方法内。如果代码更换了基类(或者增加了基类),可能会忘记更新__setitem__方法,从而导致 bug - super 实现的逻辑能处理多重继承涉及的类层次结构
旧语法的 super() 调用接受如下两个参数:
1 | class LastUpdatedOrderedDict(OrderedDict): |
- 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 | class DoppeDict(dict): |
[] 运算符调用我们覆盖的__setitem__方法,按预期那样工作- 继承自 dict 的
__init__、update方法显然忽略了我们覆盖的__setitem__方法
内置类型的这种行为违背了面向对象编程的一个基本原则:应始终从实例(self)所属的类开始搜索方法,即使在超类实现的类中调用也是如此。这种行为叫作 晚期绑定(late binding)。对于 x.method() 形式的调用,具体调用的方法必须在运行时根据接收者 x 所属的类确定。
不只实例内部的调用有这个问题(self.get() 不调用 self.__getitem__()),内置类型的方法调用的其他类(这个类继承了内置类)的方法如果被覆盖了,则也不会被调用。如下例子中,dict.update 方法会忽略 AnswerDict.__getitem__ 方法:
1 | class AnswerDict(dict): |
以上例子说明了直接子类化内置类型(例如 dict、list 或 str)容易出错,因为内置类型的方法通常会忽略用户覆盖的方法。不要子类化内置类型,用户自己定义的类应该继承 collections 模块中的类,例如 UserDict、UserList 和 UserString。这些类做了特殊设计,因此易于扩展。
内置类型的原生方法使用 C 语言实现,不调用子类中覆盖的方法,唯有极少数例外。因此,当需要定制 list、 dict 或 str 类型时,子类化 UserList、UserDict 或 UserString 更简单。这些类都在 collections 模块中定义,它们其实是对内置类型的包装,并把操作委托给内置类型——这是标准库中优先选择组合而不使用继承的 3 个例子。
综上所述,本节所述的问题只发生在 C 语言实现的内置类型内部的方法委托上,而且只影响直接继承内置类型的类。如果子类化使用 Python 编写的类(例如 UserDict 或 MutableMapping),则不会受此影响。
多重继承和方法解析顺序
任何实现多重继承的语言都要处理潜在的命名冲突,这种冲突由超类实现同名方法时引起,我们称之为 菱形问题(diamond problem)。
1 | class Root: |
1 | from diamond import Leaf |
这个例子展示了唤醒过程由以下两个因素决定:
- Leaf类的方法解析顺序
- 各方法中使用的
super()
每个类都有名为 __mro__ 的属性,它的值是一个元组,按照方法解析顺序列出各个超类,从当前类一直到 object 类。
1 | Leaf.__mro__ |
方法解析顺序使用公开发布的 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 | from diamond import A |
- 直接创建一个 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 | import collections |
- 这个混入类实现了映射的 4 个基本方法,总是调用
super(),传入尽量转换成大写形式的 key
由于 UpperCaseMixin 中的每个方法都调用了 super(),因此这个混入类会依赖一个同级类,该类实现或继承了签名相同的方法。为了让混入类发挥作用,在子类的方法解析顺序中,它要出现在其他类前面。也就是说,在类声明语句中,混入类必须出现在基类元组的第一位。
1 | class UpperDict(UpperCaseMixin, collections.UserDict): |
多重继承的实际运用
在 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 语言为构建类型和组合接口提供了特殊句法,但是不支持继承(甚至接口之间也不能继承)。
因此,关于继承,最好的建议或许是:尽可能避免使用。而现实中,我们往往没有选择,因为我们使用的框架有自己的设计选择。