委派與事件是 .NET 開發者必修的核心觀念,但許多初學者對其感到困惑:「委派到底是什麼?為什麼要把方法當參數傳?事件又是怎麼封裝委派的?」本文透過兩個循序漸進的範例——從簡單的多語系問候,到完整的熱水器觀察者模式——帶您徹底理解委派本質、事件封裝機制、IL 層面實作細節,以及如何套用至 Observer 設計模式與 .NET Framework 標準事件模型。
public void GreetPeople(string name) {
EnglishGreeting(name); // 寫死呼叫英文版
}
public void EnglishGreeting(string name) => Console.WriteLine("Morning, " + name);
需求變更:支援中文問候。直覺做法加 enum + switch,但每新增一種語言就得修改枚舉與方法,違反開放封閉原則。
若 GreetPeople 能接受一個「代表問候方法」的參數,呼叫端決定用中文還是英文,擴充性瞬間解放。關鍵在於:這個參數的型別必須能描述「方法的簽名」——這就是委派的由來。
// 定義:回傳 void、接受一個 string 參數的方法型別
public delegate void GreetingDelegate(string name);
編譯器會將其編譯為繼承 System.MulticastDelegate 的密封類別,擁有 Invoke、BeginInvoke/EndInvoke 等方法。
public void GreetPeople(string name, GreetingDelegate makeGreeting) {
makeGreeting(name); // 像呼叫一般方法一樣呼叫委派
}
// 呼叫端
GreetPeople("Jimmy", EnglishGreeting);
GreetPeople("張子陽", ChineseGreeting);
委派可串聯多個方法(調用清單),以 += 累加、-= 移除:
GreetingDelegate d = EnglishGreeting;
d += ChineseGreeting; // 現在 d 代表兩個方法
d("Jimmy"); // 依序輸出 Morning... / 早上好...
d -= EnglishGreeting; // 移除英文版
d("張子陽"); // 僅輸出 早上好...
⚠️ 首次指派必須用 =(或 new Delegate(method)),直接用 += 會編譯錯誤「使用未指派的區域變數」。
public class GreetingManager {
public GreetingDelegate OnGreet; // public 欄位
}
問題:
= 直接覆寫,抹除先前訂閱者。manager.OnGreet("X"),觸發不應被外部觸發的邏輯。= 是指派,+= 是訂閱,混用易誤。
public class GreetingManager {
public event GreetingDelegate OnGreet; // 加上 event
public void GreetPeople(string name) => OnGreet?.Invoke(name);
}
編譯器產生:
private GreetingDelegate OnGreet;+=/-=,內部呼叫 Delegate.Combine/Remove,並以 MethodImplOptions.Synchronized 保證執行緒安全。這實現了「發布-訂閱」的語意邊界:類別內部決定何時觸發,外部只能訂閱/退訂。
Heater,擁有 temperature,燒水過程中溫度變化。Alarm、Display,關心溫度超過 95°C 時的通知。
public class Heater {
public delegate void BoiledHandler(int temp);
public event BoiledHandler Boiled;
public void BoilWater() {
for (int i = 0; i <= 100; i++) {
if (i > 95) Boiled?.Invoke(i); // 觸發事件
}
}
}
public class Alarm {
public void MakeAlert(int temp) => Console.WriteLine($"Alarm: 水已 {temp} 度!");
}
public class Display {
public static void ShowMsg(int temp) => Console.WriteLine($"Display: 當前 {temp} 度。");
}
// 組裝
var heater = new Heater();
var alarm = new Alarm();
heater.Boiled += alarm.MakeAlert; // 實例方法
heater.Boiled += new Alarm().MakeAlert; // 匿名實例
heater.Boiled += Display.ShowMsg; // 靜態方法
heater.BoilWater();
為了統一性與擴充性(如傳遞 Sender 讓觀察者反查來源、傳遞 EventArgs 攜帶豐富資料),框架定義了慣例:
EventHandler 結尾:BoiledEventHandlervoid Handler(object sender, EventArgs e)EventHandler:BoiledEventArgs 類別攜帶資料,命名以 EventArgs 結尾。
public class Heater {
public string Model = "RealFire 001";
public string Area = "China Xian";
public class BoiledEventArgs : EventArgs {
public readonly int Temperature;
public BoiledEventArgs(int t) => Temperature = t;
}
public delegate void BoiledEventHandler(object sender, BoiledEventArgs e);
public event BoiledEventHandler Boiled;
protected virtual void OnBoiled(BoiledEventArgs e) => Boiled?.Invoke(this, e);
public void BoilWater() {
for (int i = 0; i <= 100; i++) {
if (i > 95) OnBoiled(new BoiledEventArgs(i));
}
}
}
public class Alarm {
public void MakeAlert(object sender, Heater.BoiledEventArgs e) {
var h = (Heater)sender;
Console.WriteLine($"{h.Area} - {h.Model}: 水已 {e.Temperature} 度!");
}
}
優勢:觀察者可透過 sender 存取完整 Heater 物件(型號、產地等),無需修改委派簽名即可擴充資料。
| 觀念 | 關鍵點 |
|---|---|
| 委派 | 型別安全的方法參考,支援多重繫結,編譯為 MulticastDelegate 衍生類別 |
| 事件 | 委派的封裝:外部只能 +=/-=,內部決定觸發時機,編譯產生 add/remove 存取器 |
| 觀察者模式 | Subject 定義事件,Observer 訂閱,達成鬆耦合;.NET 事件模型即是內建實作 |
| 標準模式 | sender + EventArgs,利於擴充、符合框架慣例、工具支援度高 |
EventHandler<TEventArgs> 泛型委派(.NET 2.0+),減少自訂委派宣告。?.Invoke 防禦性檢查,避免無訂閱者時拋出 NullReferenceException。protected virtual(OnXxx),允許衍生類別覆寫或抑制事件。Task.Run。Dispose 或 Unload 時 -= 移除。