共同行為與 is a

April 28, 2022

物件導向中,子類別繼承(inherit)父類別,可避免重複的行為與實作定義,不過並非為了避免重複定義行為與實作就得使用繼承,濫用繼承而導致程式維護上的問題時有所聞,如何正確判斷使用繼承的時機,以及繼承之後如何活用多型(polymorphism),才是學習繼承時的重點。

繼承共同行為

繼承基本上是為了避免多個類別間重複實作相同行為。以實際的例子來說明比較清楚,假設你在正開發一款 RPG(Role-playing game)遊戲,一開始設定的角色有劍士與魔法師。首先你定義了劍士類別:

class SwordsMan:
    def __init__(self, name, level, blood):
        self.name = name   # 角色名稱
        self.level = level # 角色等級
        self.blood = blood # 角色血量

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

    def __str__(self):
        return "('{name}', {level}, {blood})".format(**vars(self))

    def __repr__(self):
        return self.__str__()

劍士擁有名稱、等級與血量等屬性,可以揮劍攻擊,為了方便顯示劍士的屬性,定義了 __str__ 方法,並讓 __repr__ 的字串描述直接傳回了 __str__ 的結果。

接著你為魔法師定義類別:

class Magician:
    def __init__(self, name, level, blood):
        self.name = name   # 角色名稱
        self.level = level # 角色等級
        self.blood = blood # 角色血量

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

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

    def __str__(self):
        return "('{name}', {level}, {blood})".format(**vars(self))

    def __repr__(self):
        return self.__str__() 

你注意到什麼呢?只要是遊戲中的角色,都會具有角色名稱、等級與血量,也定義了相同的 __str____repr__ 方法,MagicianSwordsMan 中相對應的程式碼重複了。

重複在程式設計上,就是不好的訊號。舉個例子來說,如果要將 namelevelblood 更改為其他名稱,就要修改 SwordsManMagician 兩個類別,如果有更多類別具有重複的程式碼,就得修改更多類別,造成維護上的不便。

可以思考一下,會造成這樣的不便,MagicianSwordsMan 是否具有 is a 的關係?共用了相同的屬性?劍士與魔法師是否都是一種角色?如果是的話,可以把相同的程式碼提昇(Pull up)至父類別 Role,並讓 SwordsManMagician 類別都繼承自 Role 類別:

class Role:
    def __init__(self, name, level, blood):
        self.name = name   # 角色名稱
        self.level = level # 角色等級
        self.blood = blood # 角色血量

    def __str__(self):
        return "('{name}', {level}, {blood})".format(**vars(self))

    def __repr__(self):
        return self.__str__()

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

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

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

Role 類別沒什麼特別之處,不過是將先前的 SwordsManMagician 重複的程式碼,都定義在 Role 類別之中。

接著 SwordsMan 類別在定義時,類別名稱旁邊多了個括號,並指定了 Role,這在 Python 代表著,SwordsMan 繼承了 Role 已定義的程式碼,兩者具有 is a 的關係,SwordsMan 是一種 Role,接著 SwordsMan 定義了自己的 fight 方法。

類似地,Magician 也繼承了 Role 類別,並且定義了自己的 fightcure 方法。

如何看出確實有繼承了呢?以下簡單的程式可以看出:

swordsman = SwordsMan('Justin', 1, 200)
print('SwordsMan', swordsman)

magician = Magician('Monica', 1, 100)
print('Magician', magician)

在執行 print('劍士', swordsman)print('魔法師', magician) 時,會呼叫 swordsmanmagician__str__ 方法,雖然在 SwordsManMagician 類別的定義中,沒有看到定義了 __str__ 方法,但是它們都從 Role 繼承下來了,執行的結果如下:

SwordsMan ('Justin', 1, 200)
Magician ('Monica', 1, 100)

可以看到,__str__ 傳回的字串描述,確實就是 Role 類別中定義的結果。繼承的好處之一,就是若要將 namelevelblood 改為其他名稱,只需修改 Role 類別的程式碼,繼承 Role 的子類別無需修改。

多型/鴨子定型

現在有個需求,請設計一個函式,可以播放角色屬性與攻擊動畫,這可以如下撰寫程式:

def draw_fight(role):
    print(role, end = '')
    role.fight()

swordsman = SwordsMan('Justin', 1, 200)
draw_fight(swordsman)

magician = Magician('Monica', 1, 100)
draw_fight(magician)

這邊的 draw_fight 函式中,直接呼叫了 rolefight 方法,如果是 fight(swordsman),那麼 role 就是參考了 swordsman 參考的實例,這時 role.fight() 就相當於 swordsman.fight(),同樣地,如果是 fight(magician)role.fight() 就相當於 magician.fight() 了。執行結果如下:

('Justin', 1, 200)揮劍攻擊
('Monica', 1, 100)魔法攻擊

從繼承的角度來看,這種行為稱為多型,或者進一步地說,是次型態多型(subtype polymorphism)的一種形式,就 draw_fight 的角度來看,不管是換成 SwordsManMagician 的實例,draw_fight 的行為都是放角色屬性與攻擊動畫,符合里氏替換(Liskov substitution)原則。

由於 Python 是動態定型語言,若想透過變數操作物件的某個方法,只要確認該物件確實有該方法即可,從這個角度來看,這就是鴨子定型罷了,也就是說,只要物件上擁有 fight 方法就可以傳入 draw_fight 函式。例如:

>>> class Duck:
...     pass
...
>>> duck = Duck()
>>> duck.fight = lambda: print('呱呱')
>>> draw_fight(duck)
<__main__.Duck object at 0x00000211E00C92E0>呱呱
>>>

可以看到,在這邊隨便定義了 Duck 類別,建立一個實例,臨時指定一個 lambda 函式給 fight 屬性,仍然可以傳給 draw_fight 函式執行,因為 Duck 並沒有定義 __str__,因此使用的是預設的 __str__ 實作,因而看到了 <__main__.Duck object at 0x00000211E00C92E0> 的結果。

重新定義方法

方才的 draw_fight 函式若傳入 SwordsManMagician 實例時,各自會顯示 ('Justin', 1, 200) 揮劍攻擊或 ('Monica', 1, 100) 魔法攻擊,如果想顯示 SwordsMan('Justin', 1, 200) 揮劍攻擊或 Magician('Monica', 1, 100) 魔法攻擊的話,要怎麼做呢?

你也許會想判斷傳入的物件,到底是 SwordsManMagician 的實例,然後分別顯示劍士或魔法師的字樣,在 Python 中,確實有個 isinstance 函式,可以進行這類的判斷。例如:

def draw_fight(role):
    if isinstance(role, rpg.SwordsMan):
        print('SwordsMan', end = '')
    elif isinstance(role, rpg.Magician):
        print('Magician', end = '')

    print(role, end = '')
    role.fight()

isinstance 函式可用來進行執行時期型態檢查,不過每當想要 isinstance 函式時,要再多想一下,有沒有其他的設計方式。

以這邊的例子來說,若是未來有更多角色的話,勢必要增加更多型態檢查的判斷式,在多數的情況下,檢查型態而給予不同的流程行為,對於程式的維護性有著不良的影響,應該避免。

確實在某些特定的情況下,還是免不了要判斷物件的種類,並給予不同的流程,不過多數情況下,應優先選擇思考物件的行為。 那麼該怎麼做呢?print(role, end = '') 時,既然實際上是取得 role 參考實例的 __str__ 傳回的字串並顯示,目前 __str__ 的行為是定義在 Role 類別而繼承下來,那麼可否分別重新定義 SwordsManMagician__str__ 行為,讓它們各自能增加劍士或魔法師的字樣如何?

可以這麼做,不過,並不用單純地在 SwordsManMagician 定義以下的 __str__


    def __str__(self):
        return "SwordsMan('{name}', {level}, {blood})".format(**vars(self))

    def __str__(self):
        return "Magician('{name}', {level}, {blood})".format(**vars(self))

因為實際上,Role__str__ 傳回的字串,只要各自在前面附加上劍士或魔法師就可以了,在繼承後若打算基於父類別的方法實作,來重新定義某個方法,可以使用 super 來呼叫父類別方法。例如:

class Role:

    def __str__(self):
        return '({name}, {level}, {blood})'.format(**vars(self))

    def __repr__(self):
        return self.__str__()

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

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

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

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

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

在重新定義 SwordsMan__str__ 方法時,呼叫了 super().__str__(),這會執行父類別 Role 中定義的 __str__ 方法並傳回字串,這個字串與 'SwordsMan' 串接,就會是想要的結果,同樣地,在重新定義 Magician__str__方法時,也是使用 super().__str__() 取得結果,然後串接 'Magician' 字串。

從方才的範例也可以看到,如果父類別定義了 __init__,子類別沒有定義 __init__,那麼建立子類別實例時,指定的引數會自動呼叫父類別的 __init__;如果子類別定義了 __init__,就得明確地決定要不要呼叫父類別的 __init__ 方法,預設是不會呼叫。

可以使用 super 來呼叫父類別定義的 __init__ 方法,例如,若 SwordsMan 定義了 __init__,可以如下呼叫 Role__init__ 方法:

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

抽象類別/方法

有時候,希望提醒或說是強制,子類別一定要實作某個方法,也許是怕其他開發者在實作時打錯了方法名稱,像是 fight 打成了 figth,也許是有太多行為必須實作,怕不小心遺漏了其中一兩個。

如果希望子類別在繼承之後,一定要實作的方法,可以繼承 abc 模組的 ABC 類別,並在指定的方法上標註 abc 模組的 @abstractmethod 來達到需求。例如,若想強制 Role 的子類別一定要實作 fight 方法,可以如下:

from abc import ABC, abstractmethod

class Role(ABC):
    def __init__(self, name, level, blood):
        self.name = name   # 角色名稱
        self.level = level # 角色等級
        self.blood = blood # 角色血量

    @abstractmethod
    def fight(self):
        ...

    def __str__(self):
        return "('{name}', {level}, {blood})".format(**vars(self))

    def __repr__(self):
        return self.__str__()


接著,在 fight 方法上標註了 @abstractmethod,由於 Role 只是個通用的父類別,並不知道具體的各個角色會如何進行攻擊,也就不用有相關的程式碼實作,因此直接在 fight 方法的本體中使用 ...,表示本體省略(也可以使用 pass),必要時也可以使用 raise NotImplementedError,這代表著拋出錯誤,除了在程式碼上清楚地表示這是個未實作的方法,也表示子類別不能透過 super 來呼叫這個方法。

Python 中,abcABC 這個字樣,是指 Abstract Base Class,也就是抽象基礎類別,通常這些類別已實作了一些基礎行為,開發者可根據需求,使用不同的 ABC 來實作出想要的功能,不用一切從無到有親手打造。

如上定義了 Role 類別,就不能使用 Role 來建構物件了,否則執行時期會發生 TypeError,如果有個類別繼承了 Role 類別,沒有實作 fight 方法,執行時期在實例化時也會發生 TypeError

然而,先前的 SwordsManMagician,由於已經實作了 fight 方法,因此可以順利地拿來建構物件。

Python 3.8 以後,如果想限定某類別在繼承體系中是最後一個,不能再有子類別,也就是不能被繼承,可以透過 typing 模組的 final 裝飾器,定義方法時也可以使用 final 裝飾器,表示最後一次定義該方法,子類別不可以重新定義該方法。

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