接著要介紹的是繼承 aka 物件導向三大特性之王 aka 濫用榜 Ko.1 ,繼承的強大幾乎和它的惡名一樣可怕,給一個從聊聊程式的這篇 [心得整理] c# 物件導向程式 - 2.封裝、繼承、多型的三大特性 摘過來的例子就可以略知一二了:

什麼也不做,僅僅只是繼承而已,就取得了繼承對象(C# 中稱為基底類別)近乎全部的內容,真是太可怕了。在 C# 中,繼承可以取得基底類別除了 Private 以外所有的內容,例如 Protected 更是表明就是只給繼承使用的。

由此可見,在減少重複程式碼的路上,繼承無疑達到了全新的高度。

那麼繼承代表的是什麼意思呢?大多的網站都能直接說明:繼承是一種「is-a」的關係。當你能說出A是一個B的時候,就代表你認為A可以繼承自B

最直覺的繼承例子就是物種的分類。舉例來說,狗跟貓都是哺乳類,因此他們都可以繼承到一些哺乳類共通的特徵(例如哺乳、用肺呼吸)。藉由繼承,我們可以把這些哺乳類共有的特徵全部放在哺乳類這個物件,再由狗和貓分別去繼承哺乳類,藉此讓他們都能得到哺乳類的特徵,再進一步發展出自己的特徵和行為,甚至重新定義基底類別的方法為自己所用。因此,像大話設計模式就將繼承說明如:繼承者是對於被繼承者的一種特殊化。

如此一來,當我們需要修改哺乳類的定義的時候,只需要修改一個地方,而繼承了哺乳類的這些物件(C# 中稱為衍生類別)全都能夠一起修改到,大大地減少了跑來跑去修改的次數,也讓程式碼的重複大幅度地減少

然而也因為如此,繼承最大的惡名出現了:繼承享受了取用基底類別內容的好處,卻也必須背負牽一髮動全身的風險

繼承的特性和封裝有天生的衝突。為了從封裝好的物件之中取得內容,減少程式碼的重複,我們有了繼承,然而這樣無疑破壞了基底類別的封裝,完整地暴露給了衍生類別,兩者之間形成了強耦合的關係。

對於衍生類別而言,它必須依賴著基底類別,倘若哪天基底類別的屬性變更了,例如型別或名稱有變動,那麼所有衍生類別使用到的地方都會受到影響,這時候在程式碼裡的修改規模,將會隨著繼承的濫用程度提升,達到一個相當龐大的地步。

事實上,這是相當好理解的。我們藉由哺乳類去繼承出了狗科跟貓科兩個類別;那麼假設我們時光回溯,重新改變了哺乳類的演化過程,今天的哺乳類變成了三隻腳而且還有翅膀,那麼後面演化出來的狗跟貓又會怎麼樣呢?直接修改源頭,對後續的衍生者而言無疑是相當大的災難

同時由於繼承的方便和概念實在相當廣泛,因此也經常被胡亂使用。我個人就遇過專案之中,前人為了讓某個類別擁有各式各樣的方法,先後繼承了數學運算、連線至資料庫、畫面上的資料處理等等數個類別,形成一條既長又龐大的繼承鏈,最終達到了無法修改的地步。

沒有人知道這個合成怪獸是來做什麼的,這種神之物件搖身一變就變成滅世主宰,實在是相當恐怖。

因此對於繼承,前輩們通常只有一種叮囑:謹慎使用,或是乾脆不要用

對於繼承的概念,這邊推薦可以看看,到底誰該去繼承誰? 物件導向初學者應該要知道的事情(三) 這篇從圓和橢圓的各種繼承方式切入,很仔細地講解了不同思路使用繼承遇到的問題,尤其是示範完直覺的做法之後展示經典的段落相當重要。

另外,我們在後續的里氏替換原則也會提到繼承需要注意的一些問題,此處暫且按下不表。

那麼我們就回到卡牌的例子:

假使我們的卡牌現在有了功能卡,這類卡牌在遊戲王叫做魔法卡,而在爐石稱之為法術,雖然這也是一種卡片,但和前面提過的戰士和怪獸等等顯然完全不同。

public class MagicCard
{
    public string Name;
    public int Cost; // 資源花費
    public Magic Effect; // 法術效果
    private string _description;

    public MagicCard() { /* 建構式 */}
    public string Description { /* set; get; */} 
}

魔法卡並沒有攻擊力和生命值,只有對應的法術效果。同時,我們發現卡牌有資源花費的需要,像是爐石戰記或是殺戮尖塔這類有資源的遊戲,打出卡片的時候會需要花費水晶等資源,藉此限制玩家一回合內能使用的策略。

現在我們明顯可以發現兩個問題:這兩個種類的卡片,都是卡片呀!而且,內容有一半都是重複的。這是我們該使用繼承的時機了。

首先我們將原本的卡片更改為 怪獸卡。

public class MonsterCard
{
    public string Name;
    public int Cost;
    public int Attack;
    public int Health;
    private string _description;

    public MonsterCard() { /* 建構式 */}
    public string Description { /* set; get; */} 
    public void Hit(MonsterCard target) { /* 一些痛揍其他怪獸卡的方法 */ }
}

接著我們開始設計基底類別:卡片。我們可以觀察到,怪獸卡和魔法卡相同的部分有:名稱、敘述和卡片花費。

public class Card
{
    public int Cost;
    private string _description;

    public string Description { /* set; get; */} 
}

將原本的怪獸卡和魔法卡改成繼承自卡片類別,並且將重複的部份移除,直接取用基底類別的內容就好。

public class MonsterCard : Card
{
    public int Attack;
    public int Health;
    public MonsterCard() { /* 建構式 */ }
    public void Hit(MonsterCard target) { /* 一些痛揍其他怪獸卡的方法 */ }
}

public class MagicCard : Card
{
    public int Cost;
    public Magic Effect;
    public MagicCard() { /* 建構式 */}
}

可以看到我們在 C# 的繼承方式是使用 類別 : 基底類別 的方式來宣告。並且也能發現,怪獸卡的內容變簡潔了。

var goblin = new MonsterCard(name: "哥布林", attack: 3, health: 2);
var warrior = new MonsterCard(name: "戰士", attack: 4, health: 3);
warrior.Description = "他是一個專殺哥布林的戰士!";
warrior.Hit(goblin);

並且在使用上也沒有任何差錯,我們仍然能給予怪獸卡名字和敘述。

當然在實際的卡牌遊戲中,魔法卡還能細分出更多種類,因此魔法卡類別還能再被一些更細的分類,例如指向法術等等去繼承,形成如同樹狀的繼承關係,如同物種演化一般。

繼承的段落也快結束了,這邊再次叮嚀一番:除非你很確定,否則請不要使用繼承

繼承帶來了相當大的好處,減少的重複程式碼量號稱三特性之冠;但同時他帶來的後果也是最嚴重的,堪稱三特性中的擊墜之王,鏖殺了數以萬計濫用和誤用的工程師…和維護他們系統的工程師,不可不慎。

但如果已經看到了這裡,還請你先記著繼承的概念,在不遠處的將來你將會遇到他那不太像又有點像的兄弟:介面。這邊就先打住。下一篇就讓我們繼續來看三特性的末席:多型吧。

本系列下一篇:菜雞與物件導向 (5): 多型

參考資料

推薦系列文

同系列文章