Python大神必須掌握的技能:多繼承、super與MRO演算法
本文主要以Python3.x為例講解Python多繼承、super以及MRO演算法。
1. Python中的繼承
任何面向物件程式語言都會支援繼承,Python也不例外。但Python語言卻是少數幾個支援多繼承的面向物件程式語言(另一個著名的支援多繼承的程式語言是C++)。本文將深入闡述Python多繼承中經常用到的super,並且會展示一個你所不知道的super。
相信繼承的概念大家一定不會陌生。當類B從類A繼承後,B類就會繼承A類的所有非私有成員(由於Python沒有私有成員的概念,所以B類就會繼承A類的所有成員)。但有時需要在B類中直接訪問A類的成員,也就是子類需要呼叫父類的成員,在這種情況下,有如下兩種方法可以解決:
1. 在子類中直接通過父類名訪問父類中的成員
2. 在子類中通過super訪問父類中的成員
現在先說第一種方法。
inhert.py
class A: def __init__(self): print('Start A') print('End A') def greet(self, name): return f'hello {name}' class B(A): def __init__(self): print('Start B') # 直接通過父類名呼叫父類的構造方法 A.__init__(self) # 呼叫父類的greet方法 print(A.greet(self,'Bill')) # 呼叫當前類的greet方法 print(self.greet('Bill')) print('End B') # 覆蓋父類的greet方法 def greet(self,name): return f'你好 {name}' B() # 建立B類的例項
這段程式碼的執行結果如圖1所示。
圖1
在B類中,通過A. __init__(self)和A.greet(self,'Bill')呼叫了父類(A類)的成員。在Python2.2之前,Python類只支援這種訪問父類成員的方式。儘管這種方式非常直接,但缺點是如果父類名改變,這就意味著所有使用父類名的子類都需要改變,如果某個類的子類非常多,就可能會增加非常多的程式碼維護工作量。所以從Python2.2開始,又增加了一種新的訪問父類的方式,這就是本文主要介紹的super。當然,舊的方式也同樣支援。
2.引入super
為了儘可能避免在子類中直接使用父類的名字,從Python2.2開始支援super。super並不是一個函式或方法,而是一個類。super類的構造方法需要兩個引數:type和instance。其中type就是型別,例如,A、B等,instance就是B類或其子類的例項。至於為什麼要傳遞這個例項。後面會詳細介紹,總之,該例項與本文的另外一個重點MRO演算法有關。
先看下面的程式碼:
super1.py
class A: def __init__(self): print('Start A') print('End A') def greet(self, name): return f'hello {name}' class B(A): def __init__(self): print('Start B') # 通過super呼叫父類的構造方法 super(B, self).__init__() # 通過super呼叫父類的成員方法 print(super(B, self).greet('Bill')) print(self.greet('Bill')) print('End B') def greet(self,name): return f'你好 {name}' B()
執行這段程式碼,會輸出與圖1完全相同的效果。在B類中並沒有直接使用A類的名字,而是使用了super。如果A類的名字變化了,只需要修改B類的父類即可,並不需要修改B類內部的程式碼,這樣將大大減少維護B類的工作量。
可能有的同學會問,super的第2個引數的值為什麼是self呢?我們都知道,在Python中,self表示類本身的例項,那麼為什麼不是B()或是A()呢?首先這個例項要求必須是B或B的子類的例項,所以A()以及其他不相關類的例項自然就排除在外,那麼B()為什麼不行呢?其實從語義上來說,B()沒問題,但問題是這樣將產生無限遞迴的後果。也就是在B類的構造方法又呼叫了B的構造方法(B()表示呼叫B類的構造方法),而且沒有終止條件。所以這麼做的後果就是棧溢位。
儘管不能在B類構造方法內部直接建立B類的例項,但卻可以在外部建立好B類的例項或B類子類的例項,然後通過B類構造方法將該例項傳入,看下面的程式碼。
super2.py
class A: def __init__(self): print('Start A') print('End A') class B(A): def __init__(self,c): print('Start B') # 將外部建立的C類的例項傳入super類的構造方法 super(B, c).__init__() print('End B') class C(B): def __init__(self, c): super(C,self).__init__(c) c = C(None) b = B(None) B(b) B(c)
這段程式碼在建立B例項之前,先建立了一個C類的例項以及一個傳入None的B例項。而在B類的構造方法中多了一個引數,用於傳入這個外部例項,並將這個外部例項作為super類構造方法的第2個引數傳入。由於在建立C類和B類例項時傳入了None,所以super類構造方法的第2個引數值也是None。這樣回就會導致super(B,c)無法呼叫父類(A類)的構造方法,這就相當於一個空操作(什麼都不會做),至於為什麼會這樣,後面講MRO演算法時就會一清二楚。
3. 多繼承,找到親爹好難啊
其實如果Python不支援多繼承,一切都好說,一切都好理解。但問題是,Python支援多繼承,這就使得繼承的問題變得撲朔迷離,尤其是對初學者,更是一頭霧水。對於多繼承來說,一個重要的問題就是:在多個父類擁有同名成員的情況下,在子類中訪問該成員,到底是呼叫哪一個父類的成員呢? 毫無疑問,只有一個父類會為子類提供這個成員,也就是子類的親爹。至於其他擁有同名成員的父類,與該子類毫無關係,儘管名義上都擁有該成員。
現在用一個最簡單的多繼承程式來說明問題:
super3.py
class X1: def __init__(self): print('Start X1') print('End X1') class X2: def __init__(self,c): print('Start X2') print('End X2') class A(X1,X2): def __init__(self): print('Start A') super(A,self).__init__() print('End A') A()
在這段程式碼中,X1和X2都是A的父類,而在A類的構造方法中使用super(A,self).__init__()呼叫了父類的構造方法。任何Python類的構造方法可能都是同名的,都是__init__。如果A類只有一個父類,一切都好說。但如果A類有2個或2個以上的父類,那麼到底呼叫哪一個父類的構造方法呢?
讀者可以先執行這段程式碼,會看到輸出如下的內容:
Start A
Start X1
End X1
End A
很明顯,A呼叫了X1的構造方法。讀者可以再做一個實驗,將X1和X2的順序調換一下,變成A(X2,X1),這時會輸出如下的內容:
Start A
Start X2
End X2
End A
很明顯,這時A呼叫了X2的構造方法。從觀察執行結果可以找出一點規律,就是使用super(A,self),會呼叫A類的父類列表中第1個父類的成員(本例是X1)。那麼結果真是這樣嗎?
下面再看一個更復雜的多繼承案例:
super4.py
class X1: def __init__(self): print('Start X1') print('End X1') class X2: def __init__(self): print('Start X2') print('End X2') class A(X1,X2): def __init__(self): print('Start A') super(A,self).__init__() print('End A') class B: def __init__(self): print('Start B') print('End B') class C: def __init__(self): print('Start C') print('End C') class D: def __init__(self): print('Start D') print('End D') class MyClass1(B,A,C): def __init__(self): print('Start MyClass1') super(MyClass1,self).__init__() print('End MyClass1') class MyClass2(MyClass1,D): def __init__(self): print('Start MyClass2') super(MyClass2,self).__init__() print('End MyClass2') MyClass2()
這段程式碼的繼承關係比較複雜,可以用圖2來表示。
圖2
執行這段程式碼,會輸出如下內容:
Start MyClass2
Start MyClass1
Start B
End B
End MyClass1
End MyClass2
從輸出結果也可以再次驗證前面的推論,也就是super會呼叫父類列表中第一個父類的成員。如MyClass1是MyClass2的第1個父類,所以MyClass2類會呼叫MyClass1類的構造方法,而B類是MyClass1類的第一個父類,所以MyClass1類會呼叫B類的構造方法。
但這裡有一個問題,如果在MyClass2類中想呼叫D類的構造方法,在MyClass1類中想呼叫A類的構造方法,該怎麼辦呢?當然,可以直接使用父類名進行呼叫,那麼使用super應該如何呼叫。
其實Python編譯器在解析類時,會將當前類的所有父類(包括直接和間接父類)按一定的規則進行排序,然後會根據super類構造方法的第一個引數的值決定使用哪一個父類。那麼這個順序是怎樣的呢?
現在先不用管這個順序,先將圖2的繼承關係圖倒過來,變成圖3的多叉樹。
圖3
現在按深度優先遍歷這顆二叉樹,得到的遍歷結果如下:
MyClass2 > MyClass1 > B > A > X1 > X2 > C > D
假設在MyClass2中要呼叫B類的構造方法,那麼可以使用下面的程式碼。
super(MyClass1, self).__init__()
假設在MyClass2中要呼叫X2類的構造方法,那麼可以使用下面的程式碼。
super(X1, self).__init__()
從這個規律可以看出,選擇父類的規則是super類構造方法的第1個引數值在前面深度優先遍歷序列中對應類的下一個類。例如,super(X1,self)就會去尋找X1的下一個類,也就是X2。如果super類構造方法的第1個引數值正好是深度優先遍歷序列的最後一個類,本例是D,那麼super將不會選擇MyClass2的任何父類,也就是super什麼都不會做(相當於一條空語句)。到現在為止,我們好像已經清楚了前面提到的一些疑問的答案。例如,super類構造方法的第1個引數值其實是對繼承樹深度優先遍歷列表搜尋的key,而第2個引數值其實是用來得到這個列表的。但真相真的是這樣嗎?
4. MRO演算法
好像通過多叉樹的深度優先遍歷就可以解決父類的順序問題,但很多時候,類的繼承關係並不是傳統的樹,例如下面這段程式碼的繼承關係就是一個菱形。
super5.py
class Base: def __init__(self): print('Start Base') print('End Base') class A(Base): def __init__(self): print('Start A') super(A,self).__init__() print('End A') class B(Base): def __init__(self): print('Start B') super(B, self).__init__() print('End B') class C(A,B): def __init__(self): print('Start C') super(C, self).__init__() print('End C') C()
執行結果如下:
Start C
Start A
Start B
Start Base
End Base
End B
End A
End C
這段程式碼的繼承關係如圖4所示。
圖4
你就算把圖倒過來,樣子仍然不會變,如圖5所示。
圖5
這壓根不是一顆多叉樹,有點像一個圖。對於圖6所示的繼承關係,是無法用深度優先遍歷得到父類的順序的,所以為了彌補深度優先遍歷的缺陷,有人提出了MRO演算法,MRO是Method Resolution Order三個單詞的縮寫。
那麼什麼是MRO演算法呢?
MRO演算法:
MRO演算法是一個典型的遞迴操作,現在假設有如下兩個函式:
1. mro:用於得到指定類的父類MRO列表。接收一個type引數,表示指定的類,如mro(C)
2. merge:用於合併多個父類列表,合併的規則如下:
如果一個父類列表的第一個元素,在其他父類列表中也是第一個元素,或不在其他父類列表中出現,則從所有待合併父類列表中刪除這個元素(不存在的不需要刪除),合併到當前的mro父類列表中。 現在拿前面的菱形繼承關係為例說明如何得到MRO序列。這個序列的第一個元素就是C。有如下公式:
mro(C) = [C] + merge(mro(A), mro(B) ,[A,B])
其中merge函式有3個引數,分別是mro(A)、mro(B)和[A,B],也就是要將A和B的序列和[A,B]合併。根據前面的規則,有如下推導過程
mro(C) = [C] + merge(mro(A), mro(B) ,[A,B])
= [C] + merge([A,Base],[B,Base] ,[A,B])
= [C,A] + merge([Base],[B,Base],[B])
= [C,A,B] + merge([Base],[Base])
= [C,A,B,Base]
所以最終C類對應的mro序列為[C,A,B,Base],讀者可以執行前面的程式碼,會得到如下的結果:
Start C
Start A
Start B
Start Base
End Base
End B
End A
End C
這也就是為什麼會依次呼叫A、B和Base類構造方法的原因,因為MRO序列就是按這個順序排列的。如果想呼叫B類的構造方法,需要使用super(A,self).__init()__,系統會在MRO序列中搜索A,然後會呼叫A的下一個類(也就是B)的構造方法。對於更復雜的繼承關係,使用MRO演算法自己計算MRO序列非常麻煩,所以可以使用mro方法直接輸出MRO序列,程式碼如下:
print(C.mro())
執行這行程式碼,會輸出如下內容:
[<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class '__main__.Base'>, <class 'object'>]
下載本文完整原始碼,請關注“極客起源”公眾號,並輸入235254獲得下載地址。更多精彩技術文章,請關注“極客起源”公眾號
&n