Python高级主题:Python 多继承和MRO算法

    对于支持继承的编程语言来说,其方法(属性)可能定义在当前类,也可能来自于基类,所以在方法调用时就需要对当前类和基类进行搜索以确定方法所在的位置。而搜索的顺序就是所谓的「方法解析顺序」(Method Resolution Order,或MRO)。
    对于只支持单继承的语言来说,MRO 一般比较简单;而对于 Python 这种支持多继承的语言来说,MRO 就复杂很多。最典型的就是菱形继承问题
如果 x 是 D 的一个实例,那么 x.show() 到底会调用哪个 show 方法呢?
    如果按照 [D, B, A, C] 的搜索顺序,那么 x.show() 会调用 A.show();
    如果按照 [D, B, C, A] 的搜索顺序,那么 x.show() 会调用 C.show()。
由此可见,MRO 是把类的继承关系线性化的一个过程,而线性化方式决定了程序运行过程中具体会调用哪个方法。

既然如此,那什么样的 MRO 才是最合理的?Python 中又是如何实现的呢?
Python 至少有两种不同的 MRO:
  • 经典类(classic class)的深度遍历。
  • Python 2.2 的新式类 MRO
  • Python 2.3 的新式类的C3 算法。它也是 Python 3 唯一支持的方式。



经典类的 MRO
    Python 有两种类:经典类(classic class)和新式类(new-style class)。两者的不同之处在于 新式类继承自 object在 Python 2.1 以前,经典类是唯一可用的形式;Python 2.2 引入了新式类,使得类和内置类型更加统一;在 Python 3 中,新式类是唯一支持的类。
    经典类采用了一种很简单的 MRO 方法:从左至右的 深度优先遍历。以上述「菱形继承」为例,其查找顺序为 [D, B, A, C, A],如果只保留重复类的第一个则结果为 [D,B,A,C]。这种深度优先遍历对于简单的情况还能处理的不错,但是对于上述「菱形继承」其结果却不尽如人意: 虽然 C.show() 是 A.show() 的 更具体化版本 (显示了更多的信息),但我们的x.show() 没有调用它,而是调用了 A.show()。这就是“不能重写的问题 ”,即父子类同时定义的方法,子类没有能够重写父类。这显然不是我们希望的结果。
     对于新式类而言,所有的类都继承自 object,所以「菱形继承」是非常普遍的现象,因此不可能采用这种 MRO 方式。

    
Python 2.2 的新式类 MRO
    Python 2.2 的新式类 MRO 计算方式和经典类 MRO 的计算方式非常相似:它仍然采用从左至右的深度优先遍历,但是如果遍历中出现重复的类,只保留最后一个。  
    这时有两种MRO的方法
          1. 如果是经典类MRO为DFS(深度优先搜索(子节点顺序:从左到右))。
          2. 如果是新式类MRO为BFS(广度优先搜索(子节点顺序:从左到右))。
     由于新式类都是继承自object,所以上述的继承关系变成了:
广度优先就能解决经典MRO中的问题了。

D, B, C, A, object

但是,下面这种特殊情况
首先进行 广度优先, 结果为 [C, A, B, Y, X, object]。

对于 B,其搜索顺序为 [B, Y, object, X, object] -> [B, Y, X, object];
对于 C,其搜索顺序为 [C, A, X, object, Y, object, B, Y, object, X, object] - >[C, A, B, X, Y, object]。

我们会发现,B 和 C 中 X、Y 的搜索顺序是相反的!也就是说,当 B 被继承时,查找父类方法行为竟然也发生了改变,这很容易导致不易察觉的错误。

这就是说,这种算法设计在该例子中违反了「 单调性原则 」。单调性指:
如果一个类C从父类C1和C2中派生出来,在MRO中,如此C1早于C2,那么在C的任何子类中都应该保持这个次序。

显然在本例中单调性被破坏了。B从X和Y中派生出来MRO中Y早于X,B的子类C的MRO中X却是早于Y的,即子类的MRO的次序被改变了。

注:B继承链中,是先继承Y再继承X的。
在 Python 2.2 的 MRO 算法中并不能保证单调性,它也不会阻止程序员写出上述具有二义性的继承关系,因此很可能成为错误的根源。
除了单调性之外,Python 2.2 及 经典类的 MRO 也可能违反继承的「 本地优先级 」,其中 本地优先级:指声明时父类的顺序,比如C(A,B),如果访问C类对象属性时,应该根据声明顺序,优先查找A类,然后再查找B类。


Python3 C3 MRO
     Python 2.3以后采用了 C3 方法来确定方法解析顺序。
    C3算法解决了单调性问题和只能继承无法重写问题,在很多技术文章包括官网中的C3算法,都只有那个merge list的公式法,想看的话网上很多,自己可以查。但是从公式很难理解到解决这个问题的本质。
    首先假设继承关系是一张图(事实上也是),我们按类继承是的顺序(class A(B, C)括号里面的顺序B,C),子类指向父类,构一张图。官网例子【 https://www.python.org/download/releases/2.3/mro/#bad-method-resolution-orders
    
我们要解决两个问题:不能重写的问题 和 单调性问题。
那么对于只能继承,不能重写的问题呢?先分析这个问题的本质原因,主要是因为先访问了子类的父类导致的,也就是子类的复写并没有被调用到,这其实也就是深度优先算法在菱形继承问题中表现出来的。
那么怎么解决只能先访问子类再访问父类的问题呢?如果熟悉图论的人应该能马上想到拓扑排序,这里引用一下百科的的定义
对一个有向无环图(Directed Acyclic Graph简称DAG) G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若边(u,v)∈E(G),则u在线性序列中出现在v之前。通常,这样的线性序列称为满足拓扑次序(Topological Order)的序列,简称拓扑序列。简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。
可以看到,首先继承链就是有向无环图。那么按照拓扑排序的定义,恰好是是一个全序序列,并且也能够保证偏序存在,即保证了子类一定先于父类被访问到。

那么模拟一下例子的拓扑排序:首先找入度为0的点,只有一个A,把A拿出来,把A相关的边剪掉,再找下一个入度为0的点,有两个点(B,C),取最左原则,拿B,这是排序是AB,然后剪B相关的边,这时候入度为0的点有E和C,取最左。这时候排序为ABE,接着剪E相关的边,这时只有一个点入度为0,那就是C,取C,顺序为ABEC。剪C的边得到两个入度为0的点(DF),取最左D,顺序为ABECD,然后剪D相关的边,那么下一个入度为0的就是F,然后是object。那么最后的排序就为ABECDFobject。

保证偏序关系继承的,只有拓扑排序,深度和广度优先都是不可以的。
深度优先在菱形继承中无法保证偏序。
广度优先在以下例子中无法保证 C(A, B), B(A)。

而C3算法就是在拓扑排序的基础上构建的。实际上,C3算法会对图中的每一个节点计算排序,一旦发现了逆序的则报错产生一个异常,禁止创建具有二义性的继承关系。
针对上面一节的例子:
Traceback (most recent call last):
  File "<ipython-input-8-01bae83dc806>", line 1, in <module>
    class C(A, B): pass
TypeError: Error when calling the metaclass bases
     Cannot create a consistent method resolution order (MRO) for bases X, Y

实际上C3算法,就是把子类的MRO次序基于父类的MRO次序,并在不断迭代过程中不停的测试是否存在逆序的情况。
C3算法描述
我们把类 C 的线性化序列(MRO)记为 L[C] = [C1, C2,…,Cn]。其中第一个元素 C1 称为 L[C] 的头,其余元素 [C2,…,Cn] 称为尾。
按照这个记号,则[A]表示只有一个元素A的序列。

如果一个类 C 继承自基类 C1, C2,…,Cn,那么我们可以根据以下两步计算出 L[C]:
L[object] = [object] // 基础假设,找到了最后一个没有入度的点。
L[C(C1, C2, … Cn)] = [C] + merge(L[C1], L[C2]…L[Cn], [C1,C2,…Cn]) // L[Cx] 又代表了 另外一个序列, 例如[B1,B2,...Bm]。也就是说,merge的参数是一系列序列。 

其中merge方法定义为:
    1.检查第一个序列的头元素(此处是 L[C1] 的头,假设是[H, H1...Ht], 则头为H),记作 H。
    2. 若 H 未出现在其它列表的尾部,则将其输出,并将其从所有列表中删除,然后回到步骤1;否则,取出下一个列表的头部记作 H,继续该步骤。
这个步骤,相当于拓扑排序中的查找并删除入度为0的节点。
    3.重复上述步骤,直至列表为空或者 不能再找出可以输出的元素。如果是前一种情况,则算法结束;如果是后一种情况, 说明无法构建继承关系(存在二义性继承),Python 会抛出异常

L[X] = [X, object] 其头为X
L[Y] = [Y, object] 其头尾Y

例如:
L(A(X,Y)) = A + merge( [X,object] ,[Y,object] , [X,Y] )
= [A, X] + merge( [object], [Y,object] , [Y] )   #列表[X,object]的表头是X,没有出现在其它表([Y,object] 、[X,Y] )的尾部, 取出X, 然后删除需要Merge的列表中所有X
= [A, X, Y] + merge( [object] , [object] ) #列表[Y,object]的表头是Y,没有出现在其它表([object] 、[Y] )的尾部 ,注意 [Y] 这个列表只有表头,没有尾部
= [A, X, Y, object]
对于刚才那个我们已知存在二义性的例子
L[A] = [A] + merge(L[X], L[Y], [X, Y]) = [A, X, Y, object]
L[B] = [B] + merge(L[Y], L[X], [Y, X]) = [B, Y, X, object]
L[C] = [C] + merge(L[A], L[B], [A, B]) 
       = [C] + merge([A,X,Y,object], [B,Y,X,object], [A,B]) 
       = [C,A] + merge([X,Y,object], [Y,X,object], [B]) 
        = [C,A,B] + merge([X,Y,object],[X,Y,object]) #此处出现矛盾点


C3算法和拓扑排序的关系:
可以看到,实际上可以按照拓扑排序来得到各个节点的序列,然后用merge算法来校验是否具有二义性继承。
实际上,最核心的矛盾在于两个MRO [A,X,Y,object] 和 [B,Y,X,object]。这个通过merge方法就能校验出来。 merge实际上是检测多个序列组合起来之后是否有环的方法。
拓扑方法能够保证偏序关系的继承,而merge方法能保证环被检测出来。




参考:
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值