CodeGym /課程 /C# SELF /多型與抽象的問題

多型與抽象的問題

C# SELF
等級 25, 課堂 3
開放

1. 多型不總是魔法

如果只看最基本的例子,多型在 C# 看起來就像是常識的勝利:繼承、覆寫、用基底型別呼叫——一切都很順。但實際上有不少細節。來看看這些細節吧。

方法隱藏與 new 關鍵字

假設我們有一個基底類別和一個衍生類別,兩個都有同名的方法,但沒有用 override。如果在衍生類別裡重新定義這個方法但沒用 override,那它只是隱藏了基底方法的實作,並沒有覆寫。編譯器就像老媽一樣馬上跳出警告,建議你明確標註 new:

class Animal
{
    public void Speak()
    {
        Console.WriteLine("動物發出聲音。");
    }
}

class Cat : Animal
{
    public new void Speak()
    {
        Console.WriteLine("喵!");
    }
}

// 使用
Animal animal = new Cat();
animal.Speak(); // 會輸出:"動物發出聲音。"

哇!即使我們建立一個 Cat 物件,放進 Animal 型別的變數裡,呼叫的還是基底類別的方法。為什麼?因為這個方法沒有宣告成虛擬的!第一個陷阱:如果你想用多型,別忘了 virtual 和 override。只有在你真的想隱藏(而不是覆寫)方法時才用 new(其實這種情況很少見,除非你有很好的理由)。

建構子的呼叫與多型

另一個不太直觀的點:建構子不是虛擬的。如果你在基底類別宣告建構子,在衍生類別也有自己的建構子,它們不會有多型效果。舉例:

class Animal
{
    public Animal()
    {
        Console.WriteLine("Animal 建構子");
    }
}

class Cat : Animal
{
    public Cat()
    {
        Console.WriteLine("Cat 建構子");
    }
}

// 使用
Animal animal = new Cat();
// 會輸出:
// Animal 建構子
// Cat 建構子

但如果你在基底類別的建構子裡呼叫可能被覆寫的方法,結果可能很意外——虛擬方法會在衍生類別初始化前就被呼叫!所以千萬別在建構子裡呼叫虛擬/抽象方法。

override 造成封裝破壞的問題

虛擬方法很棒,但如果你在基底類別假設某個方法一定會有特定行為,結果衍生類別覆寫後破壞了邏輯——就會出現奇怪的 bug。

class Animal
{
    public virtual void Eat()
    {
        Console.WriteLine("動物在吃東西。");
    }

    public void Live()
    {
        Eat(); // 可能呼叫任何覆寫過的版本!
    }
}

class Cat : Animal
{
    public override void Eat()
    {
        Console.WriteLine("貓在吃魚。");
    }
}

Animal a = new Cat();
a.Live(); // 會輸出 "貓在吃魚。"

如果在基底類別 Animal 的 Eat() 只會輸出「動物在吃東西」,但衍生類別覆寫 Eat() 加了危險的東西,可能會讓整個類別壞掉。這就是Liskov 替換原則違反(Liskov Substitution Principle, LSP)。設計時一定要想清楚,子類的行為是否還合乎基底類別的邏輯。

型別轉換與轉型問題

多型讓你可以把不同物件放在同一個「堆」裡:例如一個基底型別的清單,裡面有狗也有貓(都繼承 Animal)。但如果你想呼叫某些特定功能:

List<Animal> pets = new List<Animal> { new Cat(), new Dog() };

foreach (var pet in pets)
{
    if (pet is Cat cat)
    {
        cat.Purr();
    }
}

如果你忘了檢查型別就直接轉型,會遇到 InvalidCastException 的錯誤。有時候這會讓你寫一堆型別檢查,讓程式碼變複雜,也說明你的設計可能還需要調整。

2. 抽象的問題

抽象是讓使用者簡化操作、限制內部狀態存取的好工具。但這裡也有不少坑!

抽象層級過多(Over-Abstraction)

有些新手(甚至老手)太熱衷「正統 OOP」,結果搞出一堆基底類別、介面、抽象層,像千層蛋糕一樣。最後連自己都搞不懂怎麼運作。

interface IAnimal
{
    void Speak();
}

abstract class Feline : IAnimal
{
    public abstract void Speak();
}

class Cat : Feline
{
    public override void Speak()
    {
        Console.WriteLine("喵!");
    }
}

你會想,這個中間抽象類別到底有什麼用?純粹為了抽象而抽象,反而讓維護和架構更混亂。

不合理的繼承階層

想想看,如果你把某個動作放到繼承階層最上面,但其實不是每個子類都適合:

abstract class Animal
{
    public abstract void Fly();
}
class Cat : Animal
{
    public override void Fly()
    {
        throw new NotImplementedException("貓不會飛!");
    }
}

你要嘛在子類裡丟例外,要嘛接受你的介面根本不貼近現實。這就是典型的「錯誤繼承階層」反模式。這種情況最好把這種方法獨立成介面(例如 IFlyable)。

建議:不要想做一個「萬用」的抽象。

抽象類別與 API 變更的問題

只要抽象類別進了 production,開始被繼承,任何變更都很危險。加一個新的抽象方法,所有子類都得實作——不然根本編譯不過。這會讓維護 library 和 public API 變得很麻煩。

這種情況下就要用有預設實作的介面(Default Interface Methods——參考第 116 講):這樣你可以擴充介面而不用馬上改所有現有程式碼。

抽象時破壞封裝

當你把類別做成抽象時,常常要把成員宣告成 protected,讓子類可以存取。這很容易讓內部邏輯外洩,其實應該藏起來才對。結果子類可以碰到本來不該碰的資料和操作,可能會破壞基底類別的完整性。

3. 實務錯誤場景

別以為這些只會出現在作業裡,來看看真實世界的例子——有時候連老鳥都會踩雷。

沒宣告成 virtual 的方法範例

假設我們要擴充我們的學習用 logging app(參考第 24 天)。有個基底 logger:

class BaseLogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

class FileLogger : BaseLogger
{
    public void Log(string message)
    {
        // 寫進檔案
        Console.WriteLine("寫入檔案: " + message);
    }
}

// 使用:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // 預期:"寫入檔案: Hello!",實際:"Hello!"

FileLogger 的作者以為自己覆寫了方法,但忘了加 override,而且基底方法也沒宣告成虛擬的。結果呼叫到的是基底版本。

建議:要讓方法可覆寫,基底類別一定要加 virtual,衍生類別要加 override。

錯誤抽象範例:「彈性」動物

繼續動物主題!我們抽出 IFlyable 介面,這樣就不用強迫所有動物都實作 Fly 方法:

interface IFlyable
{
    void Fly();
}

class Bird : IFlyable
{
    public void Fly() => Console.WriteLine("鳥在飛!");
}

class Cat
{
    // 貓不實作 IFlyable
}

現在你可以寫一個只處理「會飛」的 function,不用理會貓:

void MakeItFly(object creature)
{
    if (creature is IFlyable flyingThing)
    {
        flyingThing.Fly();
    }
    else
    {
        Console.WriteLine("這個動物不會飛。");
    }
}

這種做法就不會因為虛假的抽象方法搞壞架構。

「硬」抽象類別造成擴充困難

假設你發佈了一個 library,裡面有這樣的抽象類別:

public abstract class Creature
{
    public abstract void DoAction();
}

你的 library 使用者開始繼承這個類別。一年後你想擴充 API,加了:

public abstract class Creature
{
    public abstract void DoAction();
    public abstract void Sleep(); // 新方法!
}

現在所有使用者的類別都編譯不過,因為都得實作新抽象方法。這就是設計抽象時要很小心,盡量用有預設實作的介面。

4. 避免常見陷阱的建議

讓你的 app 像體操選手一樣靈活,但別把自己摔斷腿!

  • 別濫用繼承:能用組合(把一個物件嵌進另一個)就用組合。
  • 只有真的需要覆寫時才把方法宣告成虛擬。
  • 不要宣告沒意義的抽象類別,也不要為了未來擴充搞一堆繼承。
  • 覆寫方法時要檢查邏輯正確性:不要破壞基底類別的規則。
  • library 發佈後不要再加新的抽象方法到 public 基底類別或介面。
  • 用介面來讓程式各部分低耦合。
  • 要擴充 API 就用 Default Interface Methods。
2
任務
C# SELF, 等級 25, 課堂 3
上鎖
使用關鍵字 `new` 來隱藏方法
使用關鍵字 `new` 來隱藏方法
2
任務
C# SELF, 等級 25, 課堂 3
上鎖
使用抽象類別和專業化原則
使用抽象類別和專業化原則
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION