Python “MRO三定律”——关于Python中多继承C3-MRO算法的剖析

Python “MRO三定律”——关于Python中多继承C3-MRO算法的剖析

  • 0.声明
  • 1.关于MRO
    • 1.0 什么是MRO?
    • 1.1 MRO有什么用?
  • 2.关于C3-MRO算法
    • 2.0 什么是C3-MRO算法?
    • 2.1 C3-MRO算法与Python有什么关系?
  • 3.`!!!` C3-MRO算法思路剖析 `!!!`
    • 3.0 C3-MRO算法的具体内容[^1] [^2] [^4] [^8]
        • 让我们来实际计算一下
    • 3.1 理解算法核心思想
          • 个人认为,我们完全可以将C3-MRO算法的核心思想称之为“MRO三定律”
        • Ⅰ. 子类在父类前
        • Ⅱ. 单调性
        • Ⅲ. 局部优先性
    • 3.2 用代码完成算法
    • 3.3 留待思考与评论的问题
          • ~~魔鬼~~三角继承问题:
          • ~~恐怖~~菱形继承问题:
  • 4.参考源

本文将从常见的问题入手,深入刨析C3-MRO算法的历史、思想、实现

0.声明

  • 本文仅代表个人观点,难免有缺漏之处,欢迎指正
  • 本文追求言简意赅 Keep it simple, stupid !
  • 本文假设您已经对Python中面向对象的基础内容有所掌握,对于细节不多做讨论
  • 本文有明确的主题,故默认顶层基类object(不涉及type有关内容)

1.关于MRO


1.0 什么是MRO?

方法解析顺序(Method Resolution Order, MRO
是在面向对象编程中,当某个实例对象应用了继承,进而引发多态特性时,编译/解释器查找并决定具体实例方法的顺序,按照标准的定义,MRO包含以下两种情况:

  • 单继承中的MRO----这种情况比较简单。
  • 多继承中的MRO----这种情况相对复杂,并且随着类继承层次的混乱,复杂程度往往超乎想象。

一般情况下所提到的MRO基本都是指复杂多继承中的MRO,本质是一个顺序,可用具体编程语言中的有序列表表示,本文同一般情况。


1.1 MRO有什么用?

  • 实现方法重载
  • 构成OOP多态
  • 保证继承有效

可以说,MRO是OOP(面向对象中)的一根顶梁柱,没了它,OOP的特性和优势都会大打折扣。

关于MRO的作用,还是有点不清晰是吗?建议你回顾一下关于OOP重载、继承、多态的内容。

2.关于C3-MRO算法


2.0 什么是C3-MRO算法?

C3 superclass linearization(C3超类线性化算法),主要用于获取在存在多继承的情况下的MRO(方法解析顺序)12。其本质是一个排序算法

1996年的OOPSLA会议上,论文"A Monotonic Superclass Linearization for Dylan"1首次提出了C3超类线性化。其后被应用于Python2.3中新式类的MRO解析。
为了凸显其算法的实际应用,便于叙述,本文中称之为C3-MRO算法。

2.1 C3-MRO算法与Python有什么关系?

Python2.2版本向2.3版本过度时34,为了贯彻OOP的语言层级设计,在已有的经典类基础上新增了新式类(Python3中摒弃了经典类,仅留新式类,可以说新式类就是Python3中当前使用的类

关于两个类型的具体内容在此不多做探究,但新式类有一个重要特性就是在未显示声明继承的情况下默认继承自object类,配合Python允许多继承的语法设计,初期为开发者们带来了不小的问题。勾出了不少隐居大佬掏出了珍藏的各种黑魔法现身江湖,但很不幸都没能获得足够的成效:

PEP 253The Python 2.3 Method Resolution Order5
[Python-Dev] perplexed by mro6

最终开发者们发现,其实早有学者研究出了合适的解决方案:1992年苹果推出了 Dylan 语言,1996年相关的论文1提出了C3算法。于是C3算法在2003年临危受命,揽下了解决Python2.3版本中新式类MRO的烂摊子,时至2020年的Python3.9.05a版本3,仍是Python中解决多继承MRO问题的核心算法

事实上,在Python中,你是可以利用cls.__mro__或者cls.mro()直接看到类或对象的MRO列表的(二者的区别在于返回的分别是元组和列表),而目前为止,它就是基于C3-MRO算法的:

class A(object):
	pass
class B(A):
	pass

print(B.__mro__)
# (, , )
print(b.mro())
# [, , ]

#二者的区别在于返回的分别是元组和列表

3.!!! C3-MRO算法思路剖析 !!!


本文核心部分

以下内容仅建立在作者的理解上,带有浓重个人色彩,难免缺乏客观度。但思路相对自然,希望能给您一点启发!

3.0 C3-MRO算法的具体内容3 7 4 8

我们先把思想放一放,看看C3-MRO的具体内容。

首先为了方便讨论,我们规定:

  • 类 C 的MRO亦称为C的线性化,记作 L[C] = [C1, C2, … CN]
  • L[object]=object
  • 在 L[C] = [C1, C2, … CN] 中,称首项C1为L[C]的,记作L[C]head
  • 在 L[C] = [C1, C2, … CN] 中,称L[C]的头以外的后续元素序列(可以为空)为L[C]的,记作L[C]tail
  • 在其他列表的尾中不曾出现的头,我们称之为好头,记作H
  • + 号表示列表合并

如果一个 类 C 继承自基类 B1, B2, …,BN,那么有:

L[C] = [C] + merge( L[B1], L[B2], …, L[BN], [B1, B2, …, BN] )

C3-MRO方法的主式是很清晰明了的,但其中还有一个自定运算merge待解释。

merge是一个特殊的列表合并操作,接受多个列表输入,输出为一个合并后的列表,其过程为:

① 输入中的第一个列表L[B1],取其头L[B1]head
②检查 L[B1]head 是否出现在其他列表的尾中,若未曾出现过,说明L[B1]head为H,将其提取至外层,然后从所有列表中删除该好头,回到步骤① 继续;若出现过,取下一个列表的头L[B2]head,从步骤②继续。

重复上述步骤,直至列表为空或者不能再找出好头。如果列表为空,则算法结束;如果列表不为空,并且无法找出可以输出的元素,那么Python会抛出异常TypeError。


让我们来实际计算一下

本例取自Wikipedia2

↓↓↓ 多继承图形 ↓↓↓

Python “MRO三定律”——关于Python中多继承C3-MRO算法的剖析_第1张图片

↓↓↓ 具体类间关系 ↓↓↓
O=object
class A(O)
class B(O)
class C(O)
class D(O)
class E(O)
class K1(A, B, C)
class K2(D, B, E)
class K3(D, A)
class Z(K1, K2, K3)

不要急,也不要怕,这并不复杂,只是看起来比较长而已
让我们一步一步来

#我们先来试试简单的,从最顶上的O开始怎么样
#这里我们用O表示object,还记得我们的规定吗?这就是第二条
L(O)  := [O]                               


#好我们稍微提升点难度,来看看A

L[A]  := [A] + merge(L[O], [O])            #先展开主式
       = [A] + merge([O], [O])		       #接着展开右边的线性化运算L[O]=O
       = [A, O]                            #头O不在其他列表的尾中,提出O



#找到点感觉了吗?
#试试自行完成下面几个
L(B)  := [B, O]
L(C)  := [C, O]
L(D)  := [D, O]
L(E)  := [E, O]

#有没有觉得上面的计算其实都有点小题大作,
#这主要是因为截至目前都只是单继承,凭借直感观就完全足够了
#不过接下来就是这个算法展现神奇的时候了


#这里就比较复杂
#慢慢来,不要急
L(K1) := [K1] + merge(L(A), L(B), L(C), [A, B, C])            #先展开主式
       = [K1] + merge([A, O], [B, O], [C, O], [A, B, C])      #然后展开其中我们已经得出结果的线性化运算
       = [K1, A] + merge([O], [B, O], [C, O], [B, C])         #从第一个列表开始:A是个好头,把它提出来
       = [K1, A, B] + merge([O], [O], [C, O], [C])            #O不是个好头啊,没关系,从下一个列表开始,B是好头,提取出来
       = [K1, A, B, C] + merge([O], [O], [O])                 #C同上
       = [K1, A, B, C, O]                                     #这里就很明显了,都是甚至没有尾能和头比较(都是空尾),提出O


###接下来就是重复这个过程了

L(K2) := [K2] + merge(L(D), L(B), L(E), [D, B, E])
       = [K2] + merge([D, O], [B, O], [E, O], [D, B, E])
       = [K2, D] + merge([O], [B, O], [E, O], [B, E])
       = [K2, D, B] + merge([O], [O], [E, O], [E])
       = [K2, D, B, E] + merge([O], [O], [O])
       = [K2, D, B, E, O]

L(K3) := [K3] + merge(L(D), L(A), [D, A])
       = [K3] + merge([D, O], [A, O], [D, A])
       = [K3, D] + merge([O], [A, O], [A])
       = [K3, D, A] + merge([O], [O])
       = [K3, D, A, O]

L(Z)  := [Z] + merge(L(K1), L(K2), L(K3), [K1, K2, K3])
       = [Z] + merge([K1, A, B, C, O], [K2, D, B, E, O], [K3, D, A, O], [K1, K2, K3])
       = [Z, K1] + merge([A, B, C, O], [K2, D, B, E, O], [K3, D, A, O], [K2, K3])
       = [Z, K1, K2] + merge([A, B, C, O], [D, B, E, O], [K3, D, A, O], [K3])
       = [Z, K1, K2, K3] + merge([A, B, C, O], [D, B, E, O], [D, A, O])
       = [Z, K1, K2, K3, D] + merge([A, B, C, O], [B, E, O], [A, O])
       = [Z, K1, K2, K3, D, A] + merge([B, C, O], [B, E, O], [O])
       = [Z, K1, K2, K3, D, A, B] + merge([C, O], [E, O], [O])
       = [Z, K1, K2, K3, D, A, B, C] + merge([O], [E, O], [O])
       = [Z, K1, K2, K3, D, A, B, C, E] + merge([O], [O], [O])
       = [Z, K1, K2, K3, D, A, B, C, E, O]

强烈建议您阅读并尝试自行推导此例
这将有助于您理解下文


3.1 理解算法核心思想


MRO本质是一个顺序,也可以理解为Python中的一个list
C3-MRO的本质是一个排序算法

个人是从多属性排序的角度来理解C3-MRO算法的。
多属性排序中的核心问题,其实就是决定属性间的优先级

听说过“机器人三定律”吗?
如果你是第一次看到它。不妨去了解一下。作为程序员,这是我们迟早要面对的问题

个人认为,我们完全可以将C3-MRO算法的核心思想称之为“MRO三定律”

Ⅰ. MRO应保证子类在父类前。

Ⅱ. MRO应维持单调性,但不能因此违反Ⅰ。

Ⅲ. MRO应遵循局部优先性,但不能因此违反Ⅰ或 Ⅱ。

那么,“MRO三定律”是如何体现在具体算法中的呢?

请注意:事实上,思想与算法是完美糅合的,彻底的解耦是难以实现的,故作者在这里只挑选了其中易理解的部分进行说明。更多的内容留待您自行感悟。


Ⅰ. 子类在父类前

这是继承和多态的基本立足点。

例:属于OOP(面向对象编程的基本内容),在此不多做展开

这一定律在算法中主要体现在:

L[C] = [C] + merge( L[B1], L[B2], …, L[BN], [B1, B2, …, BN])
C作为子类优于C的父类,所以在算法主式中首先会将C本身抽出来,然后对父类依序递归调用

这个操作微不足道,貌似有点配不上它作为第一定律的最高身份是吗?
不,恰相反,大道至简!
要注意,算法是递归的,这一个小操作会在层层递归中一步步构建出整体的大秩序。
我们可以思考一下,递归一次又一次的调用自身后,最后一层会递归在object返回,
紧接着外面一层递归会取出当前的类(也就是object的子类),放在最前面,
再外面一层再取出当前的类(object的子类的子类),放在最前面,
再外面一层再取出当前的类(object的子类的子类),放在最前面,
···
······
递归返回与调用的顺序相反; 而每次把当前类放到列表最前面,会构造一个和遍历顺序相反的列表,所以最终生成的列表与递归调用的方向相同(反反得正)。
这个小小的操作,实际上让我们完成了从当前类沿继承结构一路向上的遍历。


L[C] = [C] + merge( L[B1], L[B2], …, L[BN], [B1, B2, …, BN])
merge中寻找好头并提取(不包括在末尾的原生父类列表中的判断)

㈠决定了我们的大方向是沿继承结构一路向上,但还存在一个问题,那就是如何在具体继承产生分岔时选择下一步的方向:
此处我们假设class C(A,B)

A
O
B
C

C->A后接下来我们该怎么做呢,C->A->B-O还是C->A->O->B ?
很明显为了保证子类B在其父类O的前面,我们的决策应该是C->A->B->O
作为当年黑魔法大战的胜者,C3-MRO算法自然也能做出同样聪明的决策。
我们是通过图形的直观观察判断的,那么算法是如何做到的呢?
我们来看看具体推导:

L(C) := [C] + merge(L(A), L(B), [A, B])
      = [C] + merge([A, O], [B, O], [A, B])
      = [C, A] + merge([O], [B, O], [B])      #我们发现O不是好头,所以跳过了它
      = [C, A, B] + merge([O], [O],) 
      = [C, A, B, O]

O作为头出现在其他列表(不包含最后的原生父类列表)的尾中
O仍是其余还未被继承(已继承的都被提取到外面了)的类的父类
↑↑↑↑↑以上两行本质是相同的↑↑↑↑↑
所以merge中通过找好头(不包含最后的原生父类列表)的操作完成了在继承结构分岔处的抉择。
其实我会把这个操作命名为“认亲"2333333


Ⅱ. 单调性

一个类的MRO被保持并包含在其任何子类的MRO中。

例:假设在C的MRO中存在C1、c2,且C1在C2之前,则在C的任何子类的MRO中C1在C2之前。 3

单调性保证了最基础的MRO的稳定性,不会因查询起点不同而变动。
事实上,C3-MRO算法最终能够脱颖而出,主要原因就是在Python2.3版本最初部分候选的 黑魔法 中,只有它表现出了良好的单调性。其余的黑魔法虽然能应对绝大多数的多继承问题,但在特定情况下并不能维持单调性。6

这一定律在算法中主要体现在:
L[C] = [C] + merge( L[B1], L[B2], …, L[BN], [B1, B2, …, BN])
等式的形式+递归遍历

事实上,这就类似于一个数学归纳法,任何后面的计算都是基于前面计算的基础,自然能保持单调性。
当然,merge中具体操作也很重要,但实在是难以形象说明,所以就不在此陈述。


Ⅲ. 局部优先性

在继承列表内越靠前的基类,优先级越高。

例:class C(A,B) #该语句含有的语义:建立类型C 首先继承A 其次继承B

这是Python多继承的基本语法设计,是为了保证程序员对继承的控制权。
无论是什么语言,保证使用者足够且充分的控制权都是必要的。

这一定律在算法中主要体现在:
L[C] = [C] + merge( L[B1], L[B2], …, L[BN], [B1, B2, …, BN])
merge中,与最后的原生父类列表比对

实际上merge最后的原生父类列表仅在部分情况下才会发挥作用。
不信的话你可以回到3.0末尾的例子中,在实际推导的时候除去merge末尾的原生父类列表,最终你会发现结果并未受到影响。
那么什么时候它才会发挥作用呢?我们来看个特殊例子:
此处假定class B(O,A)

B
O
A
L(B) := [B] + merge(L(O), L(A), [O,A])  
      = [B] + merge([O], [A,O], [O,A])      #这里我们首先取O为头,但很明显它出现在了第二个列表的末尾中,不是好头
                                            #我们从第二个列表的头A继续,但我们发现,也不是好头
                                            #再取第三个列表的头O,很遗憾,它也不是。最终,无好头,引发TypeError

这里突然跳出来的TypeError是Python设计者们的苦心,阻止开发者创建这种在OOP概念上逻辑混乱的继承体系,从根源上解决多继承的问题。3

但在这里我们不谈OOP概念的相关问题,但就针对“MRO三定律”来说说理由:
①首先,处于局部优先性的考虑,我们应该选择B->O->A,但这就违反了第Ⅰ定律
②好吧,那我们索性不要管第Ⅲ定律了,走B->A->O得了。然而在一些复杂结构中(鉴于篇幅,在此不做展示,其本质是程序员无法有效控制继承造成的)又会出现单调性被打破,第Ⅱ定律被违反的情况。

在①中,最重要的第Ⅰ定律被破坏;在②中,第Ⅱ定律与第Ⅲ定律同时被破坏。

事实上呢,与“机器人三定律”一样的是,“MRO三定律”会在某型情况下产生定律间的对抗,尽管我们已经给定律间设计了优先级来应对,但出现对抗的这种情况绝对会是一个坏情况(因为它必然至少违反其中某个定律,在我们刚才TypeError的情况中,三条规律都受到了威胁),所以我们要避免这种情况的发生。
值得庆贺的是,在Python语言中,避免这种情况的代价很低,或者应该说完全是利大于弊,所以当时的设计者们就干脆让它抛出TypeError,阻止开发者创造混乱的继承结构。

不得不说,即便是在近20年后的今天回望,这个算法仍是优雅而高效的。


3.2 用代码完成算法

作者根据个人的理解撰写了C3-MRO的Python代码实现
具体说明内容请见注释
Wikipedia上的内容同为作者编辑更新

def c3MRO(cls):
    if cls is object:
    #讨论假设顶层基类为object,递归终止
        return [object]

    #构造C3-MRO算法的总式,递归开始
    mergeList = [c3MRO(baseCls) for baseCls in cls.__bases__]
    mergeList.append(list(cls.__bases__))
    mro = [cls] + merge(mergeList)
    return mro

def merge(inLists):
    if not inLists:
    #若合并的内容为空,返回空list
    #配合下文的排除空list操作,递归终止
        return []
    
    #遍历要合并的mro
    for mroList in inLists:
        #取头
        head = mroList[0]
        #遍历要合并的mro(与外一层相同),检查尾中是否有头
        ###此处也遍历了被取头的mro,严格地来说不符合算法中的具体叙述
        ###但按照多继承中地基础规则(一个类只能被继承一次),
        ###头不可能在自己的尾中,无影响,若去强行实现,反而增加不必要开销
        for cmpList in inLists[inLists.index(mroList) + 1:]:
            if head in cmpList[1:]:
                break
        else:
        #筛选出好头
            nextList = []
            for mergeItem in inLists:
                if head in mergeItem:
                    mergeItem.remove(head)
                if mergeItem:
                #排除空list
                    nextList.append(mergeItem)
            #递归开始
            return [head] + merge(nextList)
    else:
    #无好头,引发类型错误
        raise TypeError

3.3 留待思考与评论的问题


下面我将给出两个开放性问题,如果您感兴趣的话可以在评论区留下您对这两个问题的看法q(≧▽≦q)

魔鬼三角继承问题:

有一个基类O,定义了方法f(),A类继承了O类(的f()方法),B类继承了O类和A类。那么出现一个问题,B类的f()方法该如何调用?

B
O
A
恐怖菱形继承问题:

有一个基类O,定义了方法f(),A类和B类继承了O类(的f()方法),C类继承了A和B类。那么出现一个问题,D类的f()方法应该如何调用?

C
A
B
O

在Python3普及的今天,这都是再正常不过的操作。(把O看作object,Python3所有类默认继承自object)
但在使用Python2.2典型类的21世纪初,这两个问题不知道让多少开发者 聪明绝顶 ! XD
部分人因而生惧,冠以魔鬼和恐怖之名。

当然,C3-MRO已经很好地解决了这两个问题。但如果剥离具体语言实现的情境,纯粹以面向对象设计的视角来看待,这两个问题的回答完全是开放性的。

讨论与交流是知识传播的必要手段,如果您感兴趣的话不妨在评论区留下您对这两个问题的看法 (~ ̄▽ ̄)~



4.参考源


  1. The paper A Monotonic Superclass Linearization for Dylan ↩︎ ↩︎ ↩︎

  2. C3 linearization ↩︎ ↩︎

  3. The Python 2.3 Method
    Resolution Order ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  4. C3 线性化算法与 MRO——理解Python中的多继承 ↩︎ ↩︎

  5. PEP 253 – Subtyping Built-in Types ↩︎

  6. The thread on python-dev started by Samuele Pedroni ↩︎ ↩︎

  7. Python3中的C3算法:多继承查找规则 ↩︎

  8. Python 方法解析顺序MRO-C3算法 ↩︎

你可能感兴趣的:(Python)