共同行為與 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__
方法,Magician
與 SwordsMan
中相對應的程式碼重複了。
重複在程式設計上,就是不好的訊號。舉個例子來說,如果要將 name
、level
、blood
更改為其他名稱,就要修改 SwordsMan
與 Magician
兩個類別,如果有更多類別具有重複的程式碼,就得修改更多類別,造成維護上的不便。
可以思考一下,會造成這樣的不便,Magician
與 SwordsMan
是否具有 is a 的關係?共用了相同的屬性?劍士與魔法師是否都是一種角色?如果是的話,可以把相同的程式碼提昇(Pull up)至父類別 Role
,並讓 SwordsMan
與 Magician
類別都繼承自 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
類別沒什麼特別之處,不過是將先前的 SwordsMan
與 Magician
重複的程式碼,都定義在 Role
類別之中。
接著 SwordsMan
類別在定義時,類別名稱旁邊多了個括號,並指定了 Role
,這在 Python 代表著,SwordsMan
繼承了 Role
已定義的程式碼,兩者具有 is a 的關係,SwordsMan
是一種 Role
,接著 SwordsMan
定義了自己的 fight
方法。
類似地,Magician
也繼承了 Role
類別,並且定義了自己的 fight
與 cure
方法。
如何看出確實有繼承了呢?以下簡單的程式可以看出:
swordsman = SwordsMan('Justin', 1, 200)
print('SwordsMan', swordsman)
magician = Magician('Monica', 1, 100)
print('Magician', magician)
在執行 print('劍士', swordsman)
與 print('魔法師', magician)
時,會呼叫 swordsman
與 magician
的 __str__
方法,雖然在 SwordsMan
與 Magician
類別的定義中,沒有看到定義了 __str__
方法,但是它們都從 Role
繼承下來了,執行的結果如下:
SwordsMan ('Justin', 1, 200)
Magician ('Monica', 1, 100)
可以看到,__str__
傳回的字串描述,確實就是 Role
類別中定義的結果。繼承的好處之一,就是若要將 name
、level
、blood
改為其他名稱,只需修改 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
函式中,直接呼叫了 role
的 fight
方法,如果是 fight(swordsman)
,那麼 role
就是參考了 swordsman
參考的實例,這時 role.fight()
就相當於 swordsman.fight()
,同樣地,如果是 fight(magician)
,role.fight()
就相當於 magician.fight()
了。執行結果如下:
('Justin', 1, 200)揮劍攻擊
('Monica', 1, 100)魔法攻擊
從繼承的角度來看,這種行為稱為多型,或者進一步地說,是次型態多型(subtype polymorphism)的一種形式,就 draw_fight
的角度來看,不管是換成 SwordsMan
或 Magician
的實例,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
函式若傳入 SwordsMan
或 Magician
實例時,各自會顯示 ('Justin', 1, 200)
揮劍攻擊或 ('Monica', 1, 100)
魔法攻擊,如果想顯示 SwordsMan('Justin', 1, 200)
揮劍攻擊或 Magician('Monica', 1, 100)
魔法攻擊的話,要怎麼做呢?
你也許會想判斷傳入的物件,到底是 SwordsMan
或 Magician
的實例,然後分別顯示劍士或魔法師的字樣,在 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
類別而繼承下來,那麼可否分別重新定義 SwordsMan
與 Magician
的 __str__
行為,讓它們各自能增加劍士或魔法師的字樣如何?
可以這麼做,不過,並不用單純地在 SwordsMan
或 Magician
定義以下的 __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
中,abc
或 ABC
這個字樣,是指 Abstract Base Class,也就是抽象基礎類別,通常這些類別已實作了一些基礎行為,開發者可根據需求,使用不同的 ABC
來實作出想要的功能,不用一切從無到有親手打造。
如上定義了 Role
類別,就不能使用 Role
來建構物件了,否則執行時期會發生 TypeError
,如果有個類別繼承了 Role
類別,沒有實作 fight
方法,執行時期在實例化時也會發生 TypeError
。
然而,先前的 SwordsMan
與 Magician
,由於已經實作了 fight
方法,因此可以順利地拿來建構物件。
Python 3.8 以後,如果想限定某類別在繼承體系中是最後一個,不能再有子類別,也就是不能被繼承,可以透過 typing
模組的 final
裝飾器,定義方法時也可以使用 final
裝飾器,表示最後一次定義該方法,子類別不可以重新定義該方法。