super 與 mro

May 17, 2022

在〈共同行為與 is a〉看過 super 的使用,實際上 super 可以指定引數,只不過絕大多數的情況下,只需要也建議以無引數的方式使用 super

在〈多重繼承與 mixin〉談過 __mro__super 真正的作用,是用來簡化多重繼承時尋找方法的一個方案,借用 __mro__ 來控制解析的順序。

不使用 super 的話

在其他語言中,特別是只能單一繼承的語言中,super 常作為關鍵字存在,作用就是呼叫父類別方法,看到 super,直接想成是「父類別」就可以了。

在 Python 中,super 經常被用來呼叫父類別方法,如果撰寫程式時僅使用單一繼承,以無引數方式呼叫 super,看到 super,也可以直接想成是「父類別」。

其實不使用 super,也可以呼叫父類別的方法,例如〈共同行為與 is a〉中 SwordsManMagician 使用 super 的範例,也可以寫成:

class SwordsMan(Role):
    def fight(self):
        print('揮劍攻擊')

    def __str__(self):
        return f'SwordsMan{Role.__str__(self)}'

class Magician(Role):
    def fight(self):
        print('魔法攻擊')

    def cure(self):
        print('魔法治療')

    def __str__(self):
        return f'Magician{Role.__str__(self)}'

初始化時的範例,也可以如下撰寫:

class SwordsMan(Role):
    def __init__(self, name, level, blood):
        Role.__init__(self, name, level, blood)
        ...其他程式碼

Python 的方法,基本上可視為以類別為名稱空間的函式,只要記得第一個參數傳入實例本身就可以了,只不過以上的方式,在子類別中寫死了父類別名稱,如果父類別更名了,子類別中相對應的名稱就得修改,對維護上是個麻煩。

在多重繼承的時候,這個方法看似有個優點,可以自行決定父類方法的呼叫順序,例如〈多重繼承與 mixin〉談過多重繼承時,若繼承的多個父類別有同名方法,也依 __mro__ 的順序尋找方法:

>>> class P1:
...     def mth(self):
...         print('P1 mth')
...
>>> class P2:
...     def mth(self):
...         print('P2 mth')
...
>>> class S(P1, P2):
...     pass
...
>>> s = S()
>>> s.mth()
P1 mth
>>>

問題來了,如果 S 重新定義了 mth,想呼叫父類別方法 mth 時,該呼叫 P1 還是 P2 的呢?如果兩個都想呼叫,那要先呼叫 P1 還是 P2 的呢?

雖然 P1P2 沒有定義實例的屬性,看來像是個 Mixin 的情境,不過 Mixin 確實會遇到這類問題,解決的方式之一是,自行指定父類別:

>>> class P1:
...     def mth(self):
...         print('P1 mth')
...
>>> class P2:
...     def mth(self):
...         print('P2 mth')
...
>>> class S(P1, P2):
...     def mth(self):
...         P1.mth(self)
...         P2.mth(self)
...
>>> s = S()
>>> s.mth()
P1 mth
P2 mth
>>>

以上範例 S 的呼叫順序,基本上是按照多重繼承時 P1P2 的指定順序,這種方式也可以用於 __init__,也就是子類實例建立時,可自行指定父類的 __init__ 與順序,來定義實例的初始化流程。

有些語言確實是這麼做的,例如 Java 的類別在實現多個介面時,若介面有同名的預設方法,類別必須明確地實作該方法,並且於方法中指定要使用哪個介面的預設方法。

只不過這種方式依賴在開發者,可能會造成每個開發者面對多重繼承時,呼叫父類方法的方式都不相同,另一方面,多重繼承的情況可能更複雜,像是形成菱形繼承:

>>> class Base:
...     def mth(self):
...         print('base mth')
...
>>> class P1(Base):
...     def mth(self):
...         Base.mth(self)
...         print('P1 mth')
...
>>> class P2(Base):
...     def mth(self):
...         Base.mth(self)
...         print('P2 mth')
...
>>> class S(P1, P2):
...     def mth(self):
...         P1.mth(self)
...         P2.mth(self)
...
>>> s = S()
>>> s.mth()
base mth
P1 mth
base mth
P2 mth
>>>

P1P2mth 方法中,同樣以 Base 呼叫了父類別的 mth 方法,就最後執行結果來看,Basemth 被呼叫了兩次,這並不正確。

另一方面,多重繼承下,S(P1, P2) 其實呈現了繼承的順序關係,父類方法的執行順序,是要以 BaseP1P2 的順序,還是以 BaseP2P1 的順序呢?如果這種順序關係仍然依賴在開發者各自的定義上,那麼在維護時,確認執行順序,將會是一種艱難的任務。

實例方法與 super

若使用無引數的 super 來實作方才的範例,會有什麼結果呢?

>>> class Base:
...     def mth(self):
...         print('base mth')
...
>>> class P1(Base):
...     def mth(self):
...         super().mth()
...         print('P1 mth')
...
>>> class P2(Base):
...     def mth(self):
...         super().mth()
...         print('P2 mth')
...
>>> class S1(P1, P2):
...     def mth(self):
...         super().mth()
...
>>> class S2(P2, P1):
...     def mth(self):
...         super().mth()
...
>>> s1 = S1()
>>> s1.mth()
base mth
P2 mth
P1 mth
>>>
>>> s2 = S2()
>>> s2.mth()
base mth
P1 mth
P2 mth
>>>

使用無引數的 superBasemth 只會被執行一次,至於順序上,S1(P1, P2) 的話,順序會是 BaseP2P1,若是 S2(P2, P1) 的話,順序會是 BaseP1P2,這個順序是?

這個順序其實就是 __mro__ 的順序,可以使用類別方法 mro 來得知順序:

>>> S1.mro()
[<class '__main__.S1'>, <class '__main__.P1'>, <class '__main__.P2'>, <class '__main__.Base'>, <class 'object'>]
>>> S2.mro()
[<class '__main__.S2'>, <class '__main__.P2'>, <class '__main__.P1'>, <class '__main__.Base'>, <class 'object'>]
>>>

為什麼是 __mro__ 的順序呢?來看看 super 的簽署:

>>> help(super)
Help on class super in module builtins:

class super(object)
 |  super() -> same as super(__class__, <first argument>)
 |  super(type) -> unbound super object
 |  super(type, obj) -> bound super object; requires isinstance(obj, type)
 |  super(type, type2) -> bound super object; requires issubclass(type2, type)
 ...略

可以看到,無引數方式呼叫 super,相當於 super(__class__, <first argument>)__class__ 就是指當時正在定義的類別,而 <first argument> 是指 super 呼叫時所在方法的第一個引數。

如果你在實例方法方法中以無引數呼叫 super<first argument> 就是 self,也就是相當於 super(__class__, self),這時就是 super(type, obj) 的形式,self 是當時所在類別 __class__ 的實例,符合 isinstance(obj, type) 的要求。

super(type, obj) 的形式呼叫時,其實是使用 type(obj)__mro__ 作為尋找方法的依據,type 必須在 __mro__ 上,而 super(type, obj) 的意思是,在 type(obj)__mro__ 上,尋找 type 的上一個類別(super 也有 over、above 之意)。

因此回過頭來看方才的範例:

class Base:
    def mth(self):
        print('base mth')

class P1(Base):
    def mth(self):
        super().mth()
        print('P1 mth')

class P2(Base):
    def mth(self):
        super().mth()
        print('P2 mth')

class S1(P1, P2):
    def mth(self):
        super().mth()

s1 = S1()
s1.mth()

S1super().mth(),會尋找 type(self)__mro__ 中,S1 的上一個類別的 mth,也就是 P1mth,因為 self 就是 s1 參考的實例,也就是使用 type(s1)__mro__,如果找到上一個類別的 mth,會以綁定方法傳回,綁定的對象 super 指定的第二個引數,也就是 s1 參考的實例,因此執行時,方法的 self 就是 s1 參考的實例。

接續方才的解析,P1super().mth(),會尋找 type(s1)__mro__ 中,P1 的上一個類別的 mth,也就是 P2,而 P2super().mth(),會尋找 type(s1)__mro__ 中,P2 的上一個類別的 mth,也就是 Base

因此最後,Basemth 只會被執行一次,也才會看到 base mth、P2 mth、P1 mth 的執行順序,依照同樣的方式,你可以自行解析 s2.mth() 時,為何會是 base mth、P1 mth、P2 mth 的順序。

簡單來說,super 提供了一個標準方式,解決多重繼承下方法的尋找順序問題,而不是依賴在開發者的各自實作,這個標準方式,就是基於 super 第二個引數可提供的 __mro__,尋找第一個引數的上一個類別。

類別方法與 super

那麼 super(type, type2) 是怎麼回事呢?來看看以下的範例:

>>> class Base:
...     @classmethod
...     def mth(clz):
...         print('base mth')
...
>>> class P1(Base):
...     @classmethod
...     def mth(clz):
...         super().mth()
...         print('P1 mth')
...
>>> class P2(Base):
...     @classmethod
...     def mth(clz):
...         super().mth()
...         print('P2 mth')
...
>>> class S1(P1, P2):
...     @classmethod
...     def mth(clz):
...         super().mth()
...
>>> class S2(P2, P1):
...     @classmethod
...     def mth(clz):
...         super().mth()
...
>>> S1.mth()
base mth
P2 mth
P1 mth
>>> S2.mth()
base mth
P1 mth
P2 mth
>>>

這次在 mth 上,都標註了 @classmethod,也就是 mth 是個類別方法,第一個參數接受類別,mth 中使用無引數的 super 呼叫,相當於 super(__class__, <first argument>),現在 <first argument> 會是第一個參數接受的類別,也就是相當於 super(type, type2),以 S1.mth() 來說,就是 super(S1, S1) 的意思,這還是會讓 issubclass(type2, type) 成立,因為 issubclass(S1, S1)True

其實 super 的作用就如方才提到的,基於 super 第二個引數可提供的 __mro__,尋找第一個引數的上一個類別,就類別方法而言,無引數呼叫 super,就是 super(type, type2) 形式,也就是基於 super 第二個引數 type2__mro__,尋找第一個引數 type 的上一個類別。

因此解析 S1.mth() 的方式,與方才解析 s1.mth() 就類似了,只不過 super 綁定的對象,是 super 指定的第二個引數,就類別方法而言,就是傳入的類別,因此解析過程中,clz 都是 S1,也就是看 S1__mro__,尋找 type 的上一個類別,S2.mth() 的話,就都是看 S2__mro__ 了。

絕大多數的情況下,你應該使用無引數的方式呼叫 super,以標準方式解析屬性,也就是基於 __mro__ 的順序來尋找屬性。

然而,確實仍然可以透過 super 有引數的呼叫方式,來決定該怎麼解析屬性,甚至是直接指定父類別名稱,決定該怎麼呼叫父類方法,雖然這是不建議的做法。

super 綁定/未綁定

方才看到還有個 super(type) 的使用方式,說明上說是 unbound super object,未綁定?這會讓人以為是不是與靜態方法有關,不過 super(type) 的使用方式,跟靜態方法沒有關係。

就 Python 而言,靜態方法第一個參數不會綁定物件,靜態方法只是將類別作為名稱空間罷了,沒有繼承的概念,你要明確指定是使用 A 類別的 xxx 靜態方法或 B 類別的 xxx 方法,這就跟你會指定要 a 模組 xxx 函式或 b 模組的 xxx 函式,是一樣的道理。

首先要知道的是,super 其實是個類別,以 super(type, obj)super(type, type2) 形式(super() 看是在實例方法或類別方法中呼叫,會涵蓋這兩者)呼叫的話,會建立 super 的實例,如果在第二個引數可提供的 __mro__ 上找到 type 上一個類別,後續呼叫的屬性存在時,會傳回綁定方法,方法的第一個參數,會綁定為 super 第二個引數指定的物件。

例如,實例方法的情況會是:

>>> class Base:
...     def mth(self):
...         print('base mth')
...
>>> class P(Base):
...     pass
...
>>> p = P()
>>> super(P, p).mth
<bound method Base.mth of <__main__.P object at 0x00000157A0910B20>>
>>>

類別方法的情況會是:

>>> class Base:
...     @classmethod
...     def mth(clz):
...         print('base mth')
...
>>> class P(Base):
...     pass
...
>>> super(P, P).mth
<bound method Base.mth of <class '__main__.P'>>
>>>

這就是以 super(type, obj)super(type, type2) 形式呼叫,傳回的會是 bound super object 的意思;相對地,super(type) 形式,因為缺少第二個引數,也就沒有物件可以綁定:

>>> super(P)
<super: <class 'P'>, NULL>
>>>

從傳回的 super 實例描述字串可以看到,NULL 代表未綁定任何物件,這就是 unbound super object 的意思,暫時也就沒有 __mro__ 可以作為尋找屬性的依據,問題在於,這個 unbound super object 可以做什麼呢?呃!幾乎沒什麼可應用的場合,基本上,你可以忽略這個使用方式!

如果真要進一步探究的話,這個 unbound super object 是可以進一步指定要綁定的物件,方式是透過它的 __get__ 方法,來回到實例方法的情境看看:

>>> class Base:
...     def mth(self):
...         print('base mth')
...
>>> class P(Base):
...     pass
...
>>> s = super(P)
>>> s
<super: <class 'P'>, NULL>
>>> p = P()
>>> s.__get__(p, P)
<super: <class 'P'>, <P object>>
>>> s.__get__(p, P).mth
<bound method Base.mth of <__main__.P object at 0x00000157A04A8400>>
>>> s.__get__(p, P).mth()
base mth
>>>

s.__get__(p, P) 時,可以看到傳回的物件成了 bound super object,進一步地,s.__get__(p, P).mth 就可以傳回綁定方法,s.__get__(p, P).mth() 也能順利執行。

也就是說,super 建立的物件是個描述器,如果要用得比較像個描述器:

>>> class Base:
...     def mth(self):
...         print('base mth')
...
>>> class P(Base):
...     def mth(self):
...         print('P mth')
...
>>> class S(P):
...     SUPER = super(P)
...
...     def mth(self):
...         self.SUPER.mth()
...         print('S mth')
...
>>> s = S()
>>> s.mth()
base mth
S mth
>>>

簡單來說,super(type) 傳回的 super 實例,可以在後續才綁定物件,如果你想到真有什麼特別的場合,非得有這種機制才能實現,還請告訴我…XD

模擬 super(type, obj)

super 其實是個類別,以 super(type, obj) 形式呼叫的話,會建立 super 的實例,如果在第二個引數可提供的 __mro__ 上找到 type 上一個類別,後續呼叫的屬性存在時,會傳回綁定方法,方法的第一個參數,會綁定為 super 第二個引數指定的物件。

那麼來試著自行實作 Super 吧!

from functools import partial

class Super:
    def __init__(self, clz, obj):
        if isinstance(obj, clz):
            mro = type(obj).mro()
        else:
            raise TypeError('super(clz, obj): obj must be an instance or subtype of clz')
            
        self.__lookup = mro[mro.index(clz) + 1:]
        self.__obj = obj

    def __getattr__(self, name):
        for cls in self.__lookup:
            if not hasattr(cls, name):
                continue
                
            attr = getattr(cls, name)
            
            return partial(attr, self.__obj)

        raise AttributeError(name)
        
class Base:
    def mth(self):
        print('base mth')

class P1(Base):
    def mth(self):
        Super(__class__, self).mth()
        print('P1 mth')

class P2(Base):
    def mth(self):
        Super(__class__, self).mth()
        print('P2 mth')

class S1(P1, P2):
    def mth(self):
        Super(__class__, self).mth()

s1 = S1()
s1.mth()

分享到 LinkedIn 分享到 Facebook 分享到 Twitter