Python - super() 函式與 MRO 詳解

Posted on  Oct 6, 2023  in  Python 程式設計 - 中階  by  Amo Chen  ‐ 6 min read

Python 的物件導向程式設計(OOP)有 2 個一定要懂的東西:

  1. super() 函式
  2. MRO(Method Resolution Order) / 方法解析順序

如果不懂得這 2 個東西,就無法徹底解放類別(class)的力量,甚至可能導致寫出不夠彈性而且冗長的程式碼。

super() + MRO = 超級瑪利歐?(誤

本文將從 super() 函式開始講解,說明 Python 的 MRO(Method Resolution Order) ,並介紹 MRO 的特性在實務上的應用。

如果你無法正確回答以下範例結果的執行結果,那麽推薦你看完本文:

class Parent(object):
    NAME = 'Parent'
    def __str__(self):
        return self.NAME

class Child(Parent):
    NAME = 'Child'
    def __str__(self):
        return super().__str__()

c = Child()
print(c)

正確答案為: Child

本文環境

  • Python 3

super() 函式介紹

我們都知道物件導向程式設計的重要特點之一是「繼承(Inheritance)」。

透過繼承,我們可以部分改寫(override)父類別的某些屬性、方法,提高程式碼的再利用率與彈性,例如下列 Parent 類別,該類別僅提供一個方法 get_inheritance() 列印其繼承關係:

class Parent(object):
    def get_inheritance(self):
        return 'Parent'

如果我們有另 1 個 Child 類別繼承 Parent , 如果要讓 Child 類別正確列印其繼承關係,也就是要讓 get_inheritance() 正確運作的話,那麼我們最毫無技巧的作法就是重寫一遍 get_inheritance() 方法,在裡面寫出 Parent <- Child , 代表 Child 類別繼承 Parent 類別(如下圖):

class Child(Parent):
    def get_inheritance(self):
        return 'Parent <- Child'

inheritance-01.png

萬一有很多類別繼承 Parent 類別,或是繼承 Child 類別甚至是 Child 的子類別呢?前述的做法肯定會讓你寫一堆額外的程式碼,甚至缺乏彈性,改一個類別要跟著改其他相關的類別。

那麼,有沒有更好的辦法?

首先看到前述範例的字串 Parent <- Child 中的 Parent ,該字串很明顯來自父類別所定義的方法,如果我們可以在子類別呼叫父類別的方法,那麼 Parent 字串就不用寫死,子類別的方法就寫得可以更彈性。

所以 Python 提供 super() 函式讓我們可以在子類別中呼叫父類別裡定義的方法(即使子類別裡有同名方法,後續會說明為什麼能做到),例如下列範例中的 super().get_inheritance() , super() 回傳一個 proxy object, 當我們試圖呼叫 1 個方法,它就會代替我們從繼承關係開始向上尋找,直到找到相對應的方法:

class Child(Parent):
    def get_inheritance(self):
        parent = super().get_inheritance()
        return ' <- '.join([parent, 'Child'])

c = Child()
print(c.get_inheritance())

所以上述範例,當我們呼叫 super().get_inheritance() 時,它就會到 Parent 類別尋找有沒有 get_inheritance() 這個方法可以呼叫,並回傳其結果,上述範例執行結果如下,可以看到我們正確呼叫父類別裡的 get_inheritance() ,而且也列印出其正確的結果:

Parent <- Child

如果是 3 layers 的繼承關係,也可以善用 super() 讓每 1 層只要加一點工,例如下列範例同樣在 GrandChild 類別中改寫 get_inheritance() 方法,加上自己獨有的部分即可:

class Parent(object):
    def get_inheritance(self):
        return 'Parent'


class Child(Parent):
    def get_inheritance(self):
        parent = super().get_inheritance()
        return ' <- '.join([parent, 'Child'])


class GrandChild(Child):
    def get_inheritance(self):
        parent = super().get_inheritance()
        return ' <- '.join([parent, 'GrandChild'])


c = GrandChild()
print(c.get_inheritance())

上述範例執行結果如下,從結果可以看到即使到第 3 層繼承,都不用明確寫明 parent 的值,就能夠把每 1 層的關係在 get_inheritance() 中列印出來:

Parent <- Child <- GrandChild

其原因在於 GrandChildsuper().get_inheritance() 會呼叫 Child 類別的 get_inheritance() 方法,而 Child 類別的 super().get_inheritance() 又會去呼叫 Parent 類別的 get_inheritance() 方法,最後這些值會串起來,變成上述的結果,如下圖所示。

inheritance-02.png

super() 函式的應用

看到此處,大家應該可以想到 super() 函式很適合用在再包裝(wrapper)的用途上,例如對父類別的方法執行結果做一些加工,變成新的結果!

例如 Icon 類別,當我們想要讓 Icon 也可以有文字提示時,就用 1 個新類別 IconWithText 搭配 super() 函式進行改寫:

class Icon:
    def __str__(self):
        return '<i class="fa-solid fa-check"></i>'

class IconWithText(Icon):
    def __init__(self, text):
        self.text = text
    def __str__(self):
        return super().__str__() + f'<span>{self.text}</span>'


print(IconWithText('Hello'))

上述範例執行結果如下:

<i class="fa-solid fa-check"></i><span>Hello</span>

這種運用 super() 四兩撥千斤類似的用法在 Python OOP 很常見!

super() 函式的誤區

回到文章開頭的範例,應該不少人疑問為什麼正確答案是 Child :

class Parent(object):
    NAME = 'Parent'
    def __str__(self):
        return self.NAME

class Child(Parent):
    NAME = 'Child'
    def __str__(self):
        return super().__str__()

c = Child()
print(c)     # Child

當我們在子類別透過 super() 呼叫父類別的方法時,它會有 1 個解析的過程,會先從最近的父類別開始找相對應的方法,如果有的話,就完成解析,如果沒有就往父類別的父類別一路找上去。

但是當執行到父類別的 return self.NAME 時,它這邊跟 super() 函式無關,而是 Attribute Lookup 或 Attribute Search 機制, Python 會先查看 Child 類別裡有沒有 NAME 屬性可以用,而 Child 類別剛好有定義 NAME 屬性,就直接拿來用,因此執行結果是 Child

不信的話,可以把 Child 類別的 NAME 屬性刪掉,執行結果就會變為 Parent

所以 super() 只管怎麼找到方法,至於找屬性則是另外的機制!

MRO(Method Resolution Order) / 方法解析順序

呼叫類別裡的方法時,解析的過程會從子類別往父類別往上找,如下列範例會找到 A 類別的 run()

class A:
    def run(self):
        print('run')


class B(A):
    def __init__(self):
        self.run()


B()

不過 Python 是允許多重繼承的,也就是 1 個類別可以繼承多個類別,這種情況下就需要瞭解其解析的規則,這規則稱為 MRO (Method Resolution Order) ,或稱方法解析順序。

所以接下來,談談 MRO 。

首先,以下是 1 個經典的繼承模型:

class A(object):
    def method(self):
        return 'Class A'


class B(A):
    def method(self):
        return 'Class B'


class C(A):
    def method(self):
        return 'Class C'


class D(B, C):
    def method(self):
        m = super().method()
        print(m)
        return 'Class D'


d = D()
d.method()

上述範例執行結果如下:

Class B

繼承關係畫成圖的話,如下所示:

diamond-diagram.png

從範例程式的執行結果可以看到 D 類別就算同時繼承 BC 類別,最後也只有執行到 B 類別定義的方法而已,這邊可以簡單理解為當繼承多個類別時,解析的順序會從左到右開始。

正常的 MRO 解析過程,在不使用 super() 函式的情況下,例如 class D(B, C) 就會從 D 類別自己開始先找,接著找 B 類別,再來找 C 類別,找到就結束尋找,如果都找不到就再往上一層找。

上述範例則是使用 super() 函式的結果,其解析過程是略過 D 類別,先找 B 類別,再來找 C 類別。

但具體是怎麼回事?我們一步一步說來。

首先,當我們呼叫 super().method() 時,其實等同於呼叫:

super(D, self).method()

先談談 super(D, self) 的第 2 個參數 self ,第 2 個參數會提供給 super() 函式 1 個解析順序,也就是 MRO 。

想知道 MRO 的話,可以用以下 2 種方式取得:

  1. <類別名稱>.__mro__
  2. <類別名稱>.mro()

例如:

>>> print(D.__mro__)
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

>>> print(D.mro())
[<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

可以看到 D 類別的 MRO 是 D → B → C → A → object 。

p.s. object 類別是 Python 所有類別之母,所以最後都是 object

super() 有了第 2 個參數,就知道 MRO 是什麼以及怎麼找方法。

至於 super() 第 1 個參數則代表要從 MRO 的哪裡開始找,例如 super(D, self) 就代表要從 B → C → A → object 開始一路找上去,這就是為什麼即使 D 類別裡有個同名的方法時,也不會被執行的原因!

看到這裡,大家應該知道怎麼控制 super() 函式,讓它執行特定父類別的方法囉!也就是控制第 1 個參數,讓 super() 從 MRO 裡的特定位置開始找,舉下列述範例為例, D 類別的 MRO 為 D → B → C → A → object, 在不控制 super() 函式的情況下,如果呼叫 super().method() 會從 B → C → A → object 順藤摸瓜尋找 method() 方法,由於 C 類別有定義 method() , 所以最後執行結果為 Class C :

class A(object):
    def method(self):
        return 'Class A'


class B(A): pass


class C(A):
    def method(self):
        return 'Class C'


class D(B, C):
    def method(self):
        m = super().method()
        print(m)
        return 'Class D'


d = D()
d.method()  # result: Class C

如果我們想要讓 super() 去呼叫 A 類別的方法,只要將 super() 改為 super(C, self) 即可,因為它的解析就會變成 A → object :

class A(object):
    def method(self):
        return 'Class A'


class B(A): pass


class C(A):
    def method(self):
        return 'Class C'


class D(B, C):
    def method(self):
        m = super(C, self).method()
        print(m)
        return 'Class D'


d = D()
d.method()  # Class A

大家可以試看看上述程式碼!

多重繼承與 MRO 的實務應用

如果能正確理解 MRO 的觀念,加上 Python 的多重繼承,就能夠設計稱為 Mixin 的類別,這種類別特點就像樂高一樣,可以用多重繼承的方式賦予類別各式各樣的功能,舉下列範例為例,該範例設計 2 個 Mixin 類別,分別是 FlyMixinDiveMixin , SuperSubmarine 類別則透過多重繼承這 2 個 Mixin 獲得飛行(fly)與潛水(dive)的能力:

class FlyMixin(object):
    def fly(self):
        print('Fly!')


class DiveMixin(object):
    def dive(self):
        print('Dive!')


class SuperSubmarine(FlyMixin, DiveMixin): pass


s = SuperSubmarine()
s.fly()
s.dive()

上述範例執行結果如下:

Fly!
Dive!

因為 MRO, 所以 SuperSubmarine 在呼叫 fly() 時,會先找到 FlyMixin 裡的 fly(), 在呼叫 dive() 時,則因為 FlyMixin 沒有定義 dive() ,所以往後找到 DiveMixin 裡的 dive()

實務上,Django 的 View 就提供很多 Mixin 類別可以使用,其原理就是如此簡單。

同場加映——先呼叫 super().__init__() 還是後呼叫 super().__init__()

下列是先呼叫 super().__init__() 的範例,可以看到 B().name 是正確的 b :


class A:
    def __init__(self):
        self.name = 'a'


class B(A):
    def __init__(self):
        super().__init__()
        self.name = 'b'


print(B().name)  # b

下列是後呼叫 super().__init__() 的範例,可以看到 B().name 是不正確的 a :

class A:
    def __init__(self):
        self.name = 'a'


class B(A):
    def __init__(self):
        self.name = 'b'
        super().__init__()


print(B().name)

這樣先後呼叫的區別,也會造成執行結果不同,有時候腦袋不清楚,就會沒注意到,要特別注意這種情況!

總結

super() 函式的運用是邁向高手的重要關卡,在此之前正確理解 MRO 更是絕對要做的基本功,結合 2 者,將可以在 Python OOP 世界裡更加自在。

以上!

Happy Coding!

References

Built-in Functions - super()

Python’s super() considered super!

Django - Class-based views mixins

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!