1. 介紹
在 C# 裡事件不是可以直接賦值一個 delegate 的一般變數。它是一個受保護的處理器清單,只有事件的擁有者能觸發它執行;其他人只能新增(+=)或移除(-=)自己的反應。
例如,這樣訂閱一個事件:
worker.WorkCompleted += Worker_WorkCompleted;
乍看像是一般的相加,但實際運作不同。事件底層保存一個 invocation list(呼叫鏈)——一組在事件觸發時要呼叫的 delegates。當你寫 +=,一個新的處理器就被加入這個清單。
我們來更詳細看看,內部發生了什麼,這個呼叫鏈怎麼形成,以及訂閱時可能出現的細節。
什麼是 delegate-chain?
回顧一下,C# 的 delegates 是「multicast」:可以包含多個方法,當呼叫 delegate 時會依序執行。事件利用這個機制:事件的值本質上就是一個包含處理器清單的 delegate。
用程式碼來說:
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
當有人訂閱時:
worker.WorkCompleted += MyHandler;
底層 C# 大概會做類似這樣的事:
- 取出目前的 delegate(處理器清單)。
- 對它呼叫 Delegate.Combine(將處理器合併)。
- 把更新後的鏈寫回事件變數。
示意:
| 操作 | 事件的內部處理器清單 |
|---|---|
| 之前 | null 或 [Handler1] |
| 之後 += Handler2 | [Handler1, Handler2] |
| 再之後 += H3 | [Handler1, Handler2, Handler3] |
有趣的事實:multicast-delegate 的機制不是「魔術」,而是具體實作:delegate 底層包含一個要呼叫的方法陣列。
2. 訂閱如何工作:通俗說明
我們用一個具體範例來看。假設有一個發布者(Worker)和訂閱者(Logger, Notifier):
public class Worker
{
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
public void DoWork()
{
// ... 工作 ...
OnWorkCompleted("任務完成!");
}
protected virtual void OnWorkCompleted(string message)
{
WorkCompleted?.Invoke(this, new WorkCompletedEventArgs { Message = message });
}
}
public class Logger
{
public void LogWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("日誌: " + e.Message);
}
}
public class Notifier
{
public void ShowNotification(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("通知: " + e.Message);
}
}
在 Main:
var worker = new Worker();
var logger = new Logger();
var notifier = new Notifier();
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += notifier.ShowNotification;
// 啟動工作
worker.DoWork();
當呼叫 OnWorkCompleted 時,事件會先呼叫 logger.LogWorkCompleted,接著呼叫 notifier.ShowNotification(順序就是你訂閱的順序)。
3. 有用的細節
視覺化:事件如何儲存處理器
+---------------------+
| Worker |
|---------------------|
| WorkCompleted Event | ---> [ LogWorkCompleted, ShowNotification ]
+---------------------+
每次訂閱都會把新的處理器「掛」到既有清單上。事件被觸發時,delegate 會依序呼叫所有訂閱的方法。
同一方法的重複訂閱
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += logger.LogWorkCompleted; // 兩次!
在這種情況下,處理器會被呼叫與它被訂閱的次數相同——這裡會連續被呼叫兩次。
用 lambda 訂閱
worker.WorkCompleted += (sender, e) => Console.WriteLine("匿名處理器: " + e.Message);
如果把同一個 lambda 訂閱多次——同樣會在每次事件發生時被多次呼叫。然而要注意:每個 lambda 都是不同的 delegate 物件,不能簡單地用相同的表達式退訂(詳情見講義 260 以後的章節)。
訂閱在底層如何運作:低層次解析
事件實際上是一個特殊的屬性,有兩個存取方法(add/remove),當使用 += 和 -= 時會呼叫它們。簡化來說,編譯器會產生類似下面的程式碼:
// 大致上是這樣(簡化)
public event EventHandler<WorkCompletedEventArgs> WorkCompleted
{
add { /* 新增處理器的程式碼 */ }
remove { /* 移除處理器的程式碼 */ }
}
預設會用標準實作:使用 Delegate.Combine 來合併 delegate,用 Delegate.Remove 來移除。
這樣可以保護事件:外部不能直接觸發事件(不能寫 worker.WorkCompleted(...);),只能訂閱或退訂。
訂閱機制:畫個圖
+----------------------+
| |
v v
+--------------------+ +----------------------+
| LogWorkCompleted | | ShowNotification |
+--------------------+ +----------------------+
^ ^
\______________________/
^
|
WorkCompleted Event
這個所謂的 "Invocation List" 就是呼叫鏈。
為什麼這很重要:實務意義
了解訂閱機制是管理物件之間關係的關鍵。你可以建立複雜系統,讓元件動態訂閱與退訂事件,而不會產生硬性依賴。這在 UI 框架、遊戲引擎、伺服器應用,甚至現代微服務架構中都很常見(雖然微服務裡通常是用佇列或訊息中介,但概念相似)。
面試常會問事件模型如何運作、為什麼事件讓系統更有彈性,以及如何正確管理訂閱的生命週期。
4. 類外可以(和不可以)做的事
可以
- 訂閱事件(+=)
- 退訂(-=)
不可以
- 直接觸發事件
- 直接將 delegate 賦值給事件(worker.WorkCompleted = ... — 會錯誤!)
這些限制是透過關鍵字 event 實現的。如果你把 delegate 當普通欄位宣告:
public EventHandler<WorkCompletedEventArgs> WorkCompleted; // 不是 event!
— 任何人都能做任何事,包括清空處理器,這會造成混亂和潛在錯誤。所以幾乎總是應該只用 event!
能否用同一個方法訂閱多個事件?
可以!這叫做 "multisubscribe"。例如:
worker.WorkCompleted += logger.LogWorkCompleted;
anotherWorker.WorkCompleted += logger.LogWorkCompleted;
如果你把相同的方法訂閱到不同物件的事件,該處理器會對兩個事件都生效。在處理器裡可以透過 sender 參數判斷是哪個物件觸發的事件。
真實案例:動態訂閱
想像一個應用允許使用者同時啟動多個任務。每個任務建立一個 Worker 物件,並把同一個處理器訂閱到它的事件——例如 logger 的 logger.LogWorkCompleted 方法。
var allWorkers = new List<Worker>();
for (int i = 0; i < 10; i++)
{
var w = new Worker();
w.WorkCompleted += logger.LogWorkCompleted;
allWorkers.Add(w);
}
結果是,當任何一個任務完成時,logger 都會收到通知並記錄發生了什麼與何時發生。
實務要點:如何查看事件的訂閱者
在一般程式碼中無法直接得知事件有多少個處理器(事件封裝了 delegate)。但是在發布者類別內你可以直接使用事件的 delegate,例如呼叫它的 GetInvocationList():
// 只有在發布者類別內
var handlers = WorkCompleted?.GetInvocationList();
if (handlers != null)
Console.WriteLine($"訂閱者數量: {handlers.Length}");
這在實作自訂廣播邏輯或除錯時有用(不過除非教學或除錯,否則盡量不要常態這樣做)。
5. 常見錯誤與注意事項
處理器沒被加進來?就不會被呼叫!
如果你沒訂閱,處理器永遠不會被呼叫。呼叫事件前通常要檢查是否為 null(否則會拋出 NullReferenceException)。
重複訂閱
如果你對同一個事件多次用同一個方法訂閱——處理器會被呼叫相同次數。有時這是陷阱:不小心多次呼叫 += 會把一次通知變成好幾次相同的通知。
靜態/實例方法
你可以訂閱靜態或實例方法。重點是簽名要正確。
public static void StaticHandler(object? sender, WorkCompletedEventArgs e) { /* ... */ }
worker.WorkCompleted += StaticHandler;
lambda 與在迴圈中訂閱
如果你在迴圈裡用 lambda 訂閱,要注意 lambda 捕獲的變數行為。可能會不小心捕獲到跟你預期不一樣的值。
GO TO FULL VERSION