1. 一體兩面
我們已經蠻深入聊過抽象這個概念,也看過 C# 裡兩個超強大的工具:抽象類別 和 介面。你應該已經感覺到它們很像,因為兩者都能定義「行為骨架」,讓具體類別去實作。但說真的,這就像拿錘子跟螺絲起子比:都是工具,但用途完全不同啦。
先來說重點:為什麼要有兩種工具?在寫程式跟人生一樣,沒什麼是「只是剛好」。如果有兩個看起來很像的工具,那一定各有強項和適用場景。
我們先用一張簡單的比較表來看一下,這算是小抄,幫你抓住最重要的差異。
| 特性 | 抽象類別 | 介面 |
|---|---|---|
| 建立實例 | 不能直接建立 (). |
不能直接建立 (). |
| 成員實作 | 可以有: – 完整實作的方法/屬性。 – 抽象方法/屬性(沒實作)。 – 欄位、建構子、靜態方法。 |
可以有預設實作的方法、靜態抽象和靜態非抽象成員。 不能有實例欄位和建構子。 |
| 存取修飾詞 | 可以有任何修飾詞(public、protected、internal、private)。 | 介面成員預設都是 public。不用寫修飾詞(例外:有預設實作的方法和靜態成員)。 |
| 繼承 | 類別只能繼承一個抽象類別。 | 類別可以實作多個介面。 |
| 關係型態 | 「是」(is-a)。定義基礎型態和共通階層。 | 「能做」(has-a 或 can-do)。定義行為/能力契約。 |
| 狀態 | 可以存狀態(實例欄位)。 | 可以有靜態欄位,但不能有實例欄位。 |
| 擴充 | 可以加新實作方法,不會影響繼承類別。 | 可以加預設實作的方法,不會破壞繼承類別。 |
是不是蠻酷的?我們來細講這些重點。
2. 多重繼承
這大概是最基本也最好記的差異。在 C#(還有像 Java 這種語言)裡,一個類別只能繼承一個父類。不管是普通類別還是抽象類別,反正就是只能一個!這是為了避免有名的「菱形問題」(Diamond Problem),就是多重繼承時,兩個父類有同名方法,結果不知道該用哪一個。
但介面你想實作幾個都行!想像你的類別像一個人,他可以是「學生」(繼承 Student),但同時「會煮飯」(ICookable)、「會開車」(IDriveable)、「會唱歌」(ISingable)。超彈性的啦!
abstract class Animal
{
public string Name;
public abstract void MakeSound();
public void Eat() => Console.WriteLine($"{Name} 吃東西。");
}
interface IFlyable { void Fly(); double MaxFlyingAltitude { get; } }
interface ISwimable { void Swim(); }
class Duck : Animal, IFlyable, ISwimable
{
public double MaxFlyingAltitude => 1000;
public override void MakeSound() => Console.WriteLine($"{Name} 呱呱叫!");
public void Fly() => Console.WriteLine($"{Name} 飛起來!");
public void Swim() => Console.WriteLine($"{Name} 游泳!");
}
class Program
{
static void Main()
{
var duck = new Duck { Name = "唐老鴨" };
duck.MakeSound();
duck.Eat();
duck.Fly();
duck.Swim();
// 用不同型態操作物件:
Animal a = duck; a.Eat();
IFlyable f = duck; f.Fly();
ISwimable s = duck; s.Swim();
}
}
如果你的類別是某個階層的一部分(像 Dog 是 Animal),就用類別繼承(抽象或普通都行)。如果你的類別有某種能力(像 Dog 會 跑,Cat 會 跑),但這些能力跟階層沒關係,就用介面。這就是介面的最大優勢——可以給完全沒關係的類別一樣的「能力契約」。
3. 哪裡有資料,哪裡只有承諾?
抽象類別可以放任何東西:
- 普通(非抽象)方法和屬性,直接有實作。
- 抽象方法和屬性(沒實作,要子類去寫)。
- 欄位(實例變數),可以存物件狀態。
- 建構子,初始化狀態用。
- 甚至靜態方法和屬性。
- 還有各種存取修飾詞:public、protected、private 等等。
這讓抽象類別變成定義部分實作行為和共通狀態的超強工具。
abstract class Employee
{
public string FirstName, LastName;
public decimal Salary { get; protected set; }
public Employee(string first, string last) { FirstName = first; LastName = last; }
public void GetPaid(decimal sum)
{
Salary += sum;
Console.WriteLine($"{FirstName} {LastName} 拿到 {sum:C}。薪水:{Salary:C}");
}
public abstract void PerformWork();
public abstract void TakeBreak();
}
class Developer : Employee
{
public Developer(string f, string l) : base(f, l) { }
public override void PerformWork() => Console.WriteLine($"{FirstName} 寫程式。");
public override void TakeBreak() => Console.WriteLine($"{FirstName} 喝咖啡。");
}
class Tester : Employee
{
public Tester(string f, string l) : base(f, l) { }
public override void PerformWork() => Console.WriteLine($"{FirstName} 找 bug。");
public override void TakeBreak() => Console.WriteLine($"{FirstName} 踢足球。");
}
class Program
{
static void Main()
{
Employee[] team = {
new Developer("伊凡", "彼得羅夫"),
new Tester("瑪麗亞", "西多羅娃")
};
foreach (var emp in team)
{
Console.WriteLine($"\n--- {emp.FirstName} {emp.LastName} 的工作日 ---");
emp.PerformWork();
emp.GetPaid(2000);
emp.TakeBreak();
}
}
}
介面就完全不一樣了。C# 8 以前只能有方法、屬性、索引子、事件的宣告,不能有欄位、建構子、方法實作!就是只有「簽名」沒內容。所有成員預設都是public(就算你沒寫 public 也一樣)。這保證介面就是純契約,沒有任何實作細節或隱藏狀態。
C# 8 以後,介面可以有預設實作的方法(Default Interface Methods)和靜態成員。這是為了讓你可以在不破壞舊程式碼的情況下,給介面加新方法。但就算這樣,介面還是不能有實例欄位和建構子。這個限制很重要,讓介面永遠只是「行為契約」,不是「資料倉庫」。
interface ISaveable
{
void Save(string file);
bool IsDirty { get; }
}
interface ILoadable
{
void Load(string file);
}
class GameProgress : ISaveable, ILoadable
{
public int Level { get; set; }
public string PlayerName { get; set; }
bool _isDirty = true;
public bool IsDirty => _isDirty;
public GameProgress(string name, int level)
{
PlayerName = name; Level = level;
}
public void Save(string file)
{
Console.WriteLine($"儲存:{PlayerName},等級 {Level} -> {file}");
_isDirty = false;
}
public void Load(string file)
{
Console.WriteLine($"從 {file} 載入");
PlayerName = "新玩家"; Level = 5; _isDirty = true;
}
public void UpdateProgress(int newLevel)
{
Level = newLevel; _isDirty = true;
Console.WriteLine($"更新到 {Level}。");
}
}
class Program
{
static void Main()
{
var game = new GameProgress("英雄", 1);
game.UpdateProgress(3);
ISaveable saver = game;
if (saver.IsDirty) saver.Save("save.dat");
ILoadable loader = game;
loader.Load("save.dat");
Console.WriteLine($"載入後:{game.PlayerName}, {game.Level}");
}
}
結論:如果你要一個基底類別,除了契約還要有共用邏輯或共用狀態,就用抽象類別。如果你只要「行為清單」或「約定」,完全不需要實作或狀態,那就用介面。
4. 什麼時候該用介面?
介面超適合用來定義能力或行為,讓完全沒關係的物件都能有。例如:
- IDisposable:任何需要「釋放」的物件(檔案、網路連線、資料庫)。
- IEnumerable<T>:任何可以用 foreach 迴圈跑的物件。
- IComparable<T>:任何可以跟同型別物件比較的東西。
重點是,FileStream 跟 SqlConnection 都能實作 IDisposable,List<T> 跟 Dictionary<TKey, TValue> 都能實作 IEnumerable<T>。這些類別完全不同,但有共同能力,就是介面定義的。
舉例:想像你有 汽車 和 飛機。兩者都可以是 交通工具(也許是抽象類別)。但 汽車、飛機,甚至 船(如果你加進來)——都能移動。這個「能移動」就是 IMovable 介面的最佳範例。
interface IMovable
{
void Move(int distance);
}
class Car : IMovable
{
public string Brand;
public Car(string brand) => Brand = brand;
public void Move(int d) => Console.WriteLine($"{Brand} 開 {d} 公里。");
}
class Airplane : IMovable
{
public string Model;
public Airplane(string model) => Model = model;
public void Move(int d) => Console.WriteLine($"{Model} 飛 {d} 公里。");
}
class Human : IMovable
{
public string Name;
public Human(string name) => Name = name;
public void Move(int d) => Console.WriteLine($"{Name} 走 {d} 公尺。");
}
class Program
{
static void Main()
{
IMovable[] movers = { new Car("Toyota"), new Airplane("Boeing 747"), new Human("阿圖爾") };
foreach (var item in movers)
item.Move(100);
}
}
有沒有發現?我們可以做一個 IMovable 清單,然後對每個元素呼叫 Move(),完全不用管它到底是 Car、Airplane 還是 Human。這就是介面多型的威力!
5. 什麼時候該用抽象類別?
抽象類別超適合定義一群緊密相關類別的共用功能,這些類別都是某種東西的變種。它們可以提供大家都需要的共用程式碼,同時強制子類去實作各自的細節。
想像你有一堆不同的銀行帳戶:SavingAccount(儲蓄)、CheckingAccount(活期)、CreditAccount(信用)。它們都是 BankAccount。大家都有餘額(Balance),都能存錢(Deposit)。但提款規則(Withdraw)每種帳戶都不一樣。這時就該用抽象類別 BankAccount!
abstract class BankAccount
{
public string AccountNumber { get; }
public decimal Balance { get; protected set; }
public BankAccount(string acc) { AccountNumber = acc; }
public void Deposit(decimal sum)
{
if (sum > 0)
{
Balance += sum;
Console.WriteLine($"{AccountNumber}: +{sum:C}, 餘額:{Balance:C}");
}
}
public abstract bool Withdraw(decimal sum);
}
class CheckingAccount : BankAccount
{
public CheckingAccount(string acc) : base(acc) { }
public override bool Withdraw(decimal sum)
{
if (Balance >= sum)
{
Balance -= sum;
Console.WriteLine($"{AccountNumber}: -{sum:C}, 餘額:{Balance:C}");
return true;
}
Console.WriteLine($"{AccountNumber}: 餘額不足");
return false;
}
}
class CreditAccount : BankAccount
{
public decimal CreditLimit { get; }
public CreditAccount(string acc, decimal limit) : base(acc) => CreditLimit = limit;
public override bool Withdraw(decimal sum)
{
if (Balance - sum >= -CreditLimit)
{
Balance -= sum;
Console.WriteLine($"{AccountNumber}: -{sum:C}, 餘額:{Balance:C}");
return true;
}
Console.WriteLine($"{AccountNumber}: 超過額度");
return false;
}
}
class Program
{
static void Main()
{
var checking = new CheckingAccount("12345");
checking.Deposit(1000);
checking.Withdraw(300);
checking.Withdraw(800);
Console.WriteLine("\n--- 信用帳戶 ---");
var credit = new CreditAccount("67890", 500);
credit.Deposit(200);
credit.Withdraw(400);
credit.Withdraw(400);
}
}
這裡 BankAccount 給所有帳戶共用的存錢邏輯(Deposit),管理帳號和餘額。但提款邏輯(Withdraw)每種帳戶都不一樣,所以是抽象的。
6. 兩者一起用:完美搭檔
最優雅、最強大的設計,常常是抽象類別和介面一起用。抽象類別本身也可以實作一個或多個介面!
想像一下:你有 abstract Animal,定義基本東西。但有些 Animal 可能是 IMovable、ICarnivore、IPredator 等等。你的 Animal 甚至可以給 IMovable 預設實作(像 Move(int speed)),但像 Lion 或 Fish 這些具體類別會自己改寫這個方法。
public interface ISaveable
{
void SaveState(string path);
bool HasChanges { get; }
}
public class Vector3
{
public float X, Y, Z;
public Vector3(float x, float y, float z) { X = x; Y = y; Z = z; }
public override string ToString() => $"({X}, {Y}, {Z})";
}
public abstract class GameObject
{
public string Id { get; }
public Vector3 Position { get; }
protected GameObject(string id, Vector3 pos) { Id = id; Position = pos; }
public abstract void Update();
public void Destroy() => Console.WriteLine($"{Id} 被銷毀");
}
public class Player : GameObject, ISaveable
{
public int Health { get; private set; }
private bool _hasChanges = true;
public bool HasChanges => _hasChanges;
public Player(string id, Vector3 pos, int health) : base(id, pos) => Health = health;
public override void Update() =>
Console.WriteLine($"{Id} 更新中,HP: {Health}, 位置: {Position}");
public void TakeDamage(int dmg)
{
Health -= dmg;
_hasChanges = true;
Console.WriteLine($"{Id} 受到 {dmg} 傷害。HP: {Health}");
}
public void SaveState(string path)
{
Console.WriteLine($"儲存 {Id} (HP:{Health}) 到 {path}");
_hasChanges = false;
}
}
class GameEngine
{
static void Main()
{
var player = new Player("P1", new Vector3(0, 0, 0), 100);
player.Update();
player.TakeDamage(20);
ISaveable saver = player;
if (saver.HasChanges) saver.SaveState("save.json");
GameObject obj = player;
obj.Update();
player.Destroy();
}
}
這個例子裡 Player 是 GameObject(因為繼承,拿到 Id、Position 和 Destroy 方法)。同時 Player 可以被儲存(因為實作 ISaveable 介面)。這種設計超彈性又強大!
7. 小細節與常見錯誤
想建立抽象類別實例:
Animal myAnimal = new Animal(); ——這是新手最常犯的錯。記住:抽象類別只是模板,不是可以直接用的物件。編譯器會直接報錯。
忘記實作抽象方法: 如果你繼承抽象類別,而且你的類別不是抽象的,你一定要覆寫(override)所有基底類別的抽象方法。不然編譯器也會罵你。
想在介面加欄位: 這是介面最明確的「不行」。C# 8+ 雖然有靜態欄位,但實例欄位(屬於物件本身的)還是不能加。介面就是行為契約,不是資料倉庫。
搞混 virtual 跟 abstract:
- virtual 方法/屬性有預設實作,可以被子類覆寫。
- abstract 方法/屬性沒有實作,一定要在第一個非抽象子類覆寫。
- 如果你用 virtual,但每個子類邏輯都不一樣,會變成大家都寫空方法再自己改,這樣契約就不明確,不如用 abstract。
搞懂這些差異,正確選擇抽象類別還是介面,這是每個 C# 工程師的基本功。這不只是語法問題,更是架構思維。設計系統時,問自己:「這些類別有共同的『是』階層嗎?」、「這些類別有共同的『能力』,但彼此沒關係嗎?」答案會幫你選對工具。
接下來的課我們會繼續深入介面的世界,學習 C# 新版帶來的進階功能。敬請期待!
GO TO FULL VERSION