本章将讨论继承和子类化,重点是说明对 Python 而言尤为重要的两个细节:

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

我们将通过两个重要的 Python 项目探讨多重继承,这两个项目是 GUI 工具包 Tkinter 和 Web 框架 Django

我们将首先分析子类化内置类型的问题,然后讨论多重继承,通过案例讨论类层次结构方面好的做法和不好的

子类化内置类型很麻烦

在 Python 2.2 之前内置类型(如 list 和 dict)不能子类化,之后可以了,但是有个重要事项:内置类型(使用 C 语言编写)不会调用用户定义的类覆盖的特殊方法

至于内置类型的子类覆盖的方法会不会隐式调用,CPython 没有官方规定,基本上,内置类型的方法不会调用子类覆盖的方法。例如,dict 的子类覆盖 __getitem__() 方法不会被内置类型的 get() 方法调用,下面说明了这个问题:

内置类型的 dict 的 __init____update__ 方法会忽略我们覆盖的 __setitem__ 方法


In [2]:
class DoppelDict(dict):
    def __setitem__(self, key, value):
        super().__setitem__(key, [value] * 2)

dd = DoppelDict(one=1)
dd # 继承 dict 的 __init__ 方法忽略了我们覆盖的 __setitem__方法,'one' 值没有重复


Out[2]:
{'one': 1}

In [3]:
dd['two'] = 2 # `[]` 运算符会调用我们覆盖的 __setitem__ 方法
dd


Out[3]:
{'one': 1, 'two': [2, 2]}

In [4]:
dd.update(three=3) #继承自 dict 的 update 方法也不会调用我们覆盖的 __setitem__ 方法
dd


Out[4]:
{'one': 1, 'three': 3, 'two': [2, 2]}

原生类型的这种行为违背了面向对象编程的一个基本原则:始终应该从实例(self)所属的类开始搜索方法,即使在超类实现的类中调用也是如此。在这种糟糕的局面中,__missing__ 却能按照预期工作(3.4 节),但这是特例

不止实例内部有这个问题(self.get() 不调用 self.__getitem__()),内置类型的方法调用其他类的方法,如果被覆盖了,也不会被调用。下面是个例子,改编自 PyPy 文档

dict.update 方法会忽略 AnswerDict.__getitem__ 方法


In [5]:
class AnswerDict(dict):
    def __getitem__(self, key):
        return 42

ad = AnswerDict(a='foo')
ad['a']  # 返回 42,与预期相符


Out[5]:
42

In [6]:
d = {}  
d.update(ad) # d 是 dict 的实例,使用 ad 中的值更新 d
d['a']       #dict.update 方法忽略了 AnswerDict.__getitem__ 方法


Out[6]:
'foo'

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

如果子类化的是 collections.UserDict,上面暴露的问题就迎刃而解了,如下:


In [8]:
import collections

class DoppelDict2(collections.UserDict):
    def __setitem__(self, key, value):
        super().__setitem__(key, [value] * 2)

dd = DoppelDict2(one=1)
dd


Out[8]:
{'one': [1, 1]}

In [9]:
dd['two'] = 2
dd


Out[9]:
{'one': [1, 1], 'two': [2, 2]}

In [10]:
dd.update(three=3)
dd


Out[10]:
{'one': [1, 1], 'two': [2, 2], 'three': [3, 3]}

In [13]:
class AnswerDict2(collections.UserDict):
    def __getitem__(self, key):
        return 42

ad = AnswerDict2(a='foo')
ad['a']


Out[13]:
42

In [14]:
d = {}
d.update(ad)
d['a']


Out[14]:
42

In [15]:
d


Out[15]:
{'a': 42}

In [16]:
ad  # 这里是自己加的,感觉还是有点问题,但是调用时候结果符合预期


Out[16]:
{'a': 'foo'}

综上,本节所述的问题只是针对与 C 语言实现的内置类型内部的方法委托上,而且只影响直接继承内置类型的用户自定义类。如果子类化使用 Python 编写的类,如 UserDict 和 MutableMapping,就不会受此影响

多重继承和方法解析顺序

任何实现多重继承的语言都要处理潜在的命名冲突,这种冲突由不相关的祖先类实现同命方法引起,这种冲突称为菱形问题。


In [9]:
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)

B 和 C 都实现了 pong 方法,唯一区别就是打印不一样。在 D 上调用 d.pong 运行的是哪个 pong 方法呢? C++ 中,必须使用类名限定方法调用来避免歧义。Python 也可以,如下:


In [10]:
d = D()
d.pong()  # 直接调用 d.pong() 是调用的 B 类中的版本


pong <__main__.D object at 0x7fd698c96be0>

In [11]:
C.pong(d) #超类中的方法都可以直接调用,此时要把实例作为显式参数传入


PONG <__main__.D object at 0x7fd698c96be0>

Python 能区分 d.pong() 调用的是哪个方法,因为 Python 会按照特定的顺序遍历继承图,这个顺序叫顺序解析(Method Resolution Order,MRO)。类都有一个名为 __mro__ 的属性,它的值是一个元组,按照方法解析顺序列出各个超类。从当前类一直向上,直到 object 类。D 类的 __mro__ 属性如下:


In [12]:
D.__mro__


Out[12]:
(__main__.D, __main__.B, __main__.C, __main__.A, object)

若想把方法调用委托给超类,推荐的方法是使用内置的 super() 函数。在 Python 3 中,这种方式变得更容易了,如上面的 D 类中的 pingpong 方法所示。然而,有时可能幸亏绕过方法解析顺序,直接调用某个类的超方法 -- 这样有时更加方便。,例如,D.ping 方法可以这样写


In [13]:
def ping(self):
    A.ping(self) # 而不是 super().ping()
    print('post-ping', self)

注意,直接在类上调用实例方法时,必须显式传入 self 参数,因为这样访问的是未绑定方法(unbound method)

然而,使用 super() 最安全,也不易过时,调用框架或不受自己控制的类层次结构中的方法时,尤其适合用 super()。使用 super() 调用方法时,会遵循方法解析顺序,如下所示:


In [14]:
d = D()
d.ping() # 输出了两行,第一行是 super() A 类输出,第二行是 D 类输出


ping <__main__.D object at 0x7fd698c96f98>
post-ping: <__main__.D object at 0x7fd698c96f98>

下面看看 D 在实例上调用 pingpong 方法得到的结果,如下所示:


In [17]:
d.pingpong() #最后一个是直接找到 C 类实现 pong 方法,忽略 mro


ping <__main__.D object at 0x7fd698c96f98>
post-ping: <__main__.D object at 0x7fd698c96f98>
ping <__main__.D object at 0x7fd698c96f98>
pong <__main__.D object at 0x7fd698c96f98>
PONG <__main__.D object at 0x7fd698c96f98>

方法解析顺序不仅考虑继承图,还考虑子类声明中列出超类的顺序。也就是说,如果声明 D 类时把 D 声明为 class D(C, B),那么 D 类的 __mro__ 就会不一样,先搜索 C 类,再 搜索 B 类

分析类时,我们需要经常查看 __mro__ 属性,下面是一些常用类的方法搜索顺序:


In [18]:
bool.__mro__


Out[18]:
(bool, int, object)

In [19]:
def print_mro(cls):
    print(', '.join(c.__name__ for c in cls.__mro__))

print_mro(bool)


bool, int, object

In [21]:
import numbers
print_mro(numbers.Integral)


Integral, Rational, Real, Complex, Number, object

In [22]:
import io
print_mro(io.BytesIO)


BytesIO, _BufferedIOBase, _IOBase, object

In [23]:
print_mro(io.TextIOWrapper)


TextIOWrapper, _TextIOBase, _IOBase, object

结束方法解析之前,我们再看看 Tkinter 复杂的多重继承:


In [24]:
import tkinter
print_mro(tkinter.Text)


Text, Widget, BaseWidget, Misc, Pack, Place, Grid, XView, YView, object

研究 GUI 工具包中的 Tinker 时候,要从底部的 Text 类开始,这个类实现了多行可编辑文本小组文件,自身有丰富的功能,又从其他类继承了很多方法。

处理多重继承

继承很强大,但也可能把类图搅乱,下面是一些建议:

  1. 把接口和实现继承区分开 使用多重继承时,一定要明确一开始为什么创建子类,主要原因可能有:

    • 继承接口,创建子类型,实现“是什么”关系
    • 继承实现,通过重用避免代码重复

    其实上面这两条经常同时出现,不过只要可能,一定要明确意图,通过继承重用代码是实现细节,通常可以换用组合和委托模式。而接口继承则是框架支柱

  2. 使用抽象基类显式的表示接口

    现代 Python 中,如果类的作用是定义接口,应该明确把它定义为抽象基类。Python 3.4 以上的版本,我们要创建 abc.ABC 或其他抽象基类的子类(如果想支持较旧的 Python 版本,看第 11 章)

  3. 通过混入重用代码

    如果一个类的作用是为多个不相关的子类提供方法实现,从而实现重用,但不体现"是什么“关系,应该把那个类明确的定义为混入类。从概念上讲,混入不定义新类型,只是打包方法,便于重用。混入类绝对不能实例化,而且具体类不能只继承混入类。混入类应该提供某些方面的特定行为,只实现少量关系非常紧密的方法

  4. 在名称中明确指明混入

    由于 Python 没有把类声明为混入的正式方式,所以强烈推荐在名称中加入 ...Mixin 后缀

  5. 抽象基类可以作为混入,反过来则不成立

    抽象基类可以实现具体方法,因此也可以作为混入使用,不过抽象基类会定义类型,而混入类做不到,此外抽象基类可以作为其他类的唯一基类,而混入决不能作为唯一超类,除非继承另一个更具体的混入 -- 真实代码少有人这么做

  6. 不要子类化多个具体类

    具体类可以没有,或最多只有一个具体超类。也就是说,具体类的超类中除了这一个具体超类之外,其余的都是抽象基类或混入。例如,在下面代码中,如果 Alpha 是具体类,那么 Beta 和 Gamma 必须是抽象基类或混入

    class MyConcreteClass(Alpha, Beta, Gamma):
       '''这是一个具体类,可以实例化'''
  7. 为用户提供聚合类

    如果抽象基类或混入的组合对客户代码非常有用,那就提供一个类,使用易于理解的方式将他们结合起来。这种类称为聚合类

    class Widget(BaseWidthget, Pack, Place, Grid):
       '''Internal class'

    Widget 类是空的,但是这个类提供了有用的服务:把四个超类结合在一起,这样需要创建新小组件的用户无需记住全部混入,也不用担心声明 class 语句时候有没有遵循特定的顺序

  8. 优先使用对象组合,而不是继承

优先使用组合能让设计更灵活,组合和委托可以代替混入,把行为提供给不同的类,但是不能取代接口继承去定义类型层次的结构


In [ ]: