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。
GO TO FULL VERSION