CodeGym /課程 /C# SELF /訂閱的詳細解析 ( +=)

訂閱的詳細解析 ( +=)

C# SELF
等級 53, 課堂 0
開放

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 捕獲的變數行為。可能會不小心捕獲到跟你預期不一樣的值。

2
任務
C# SELF, 等級 53, 課堂 0
上鎖
動態訂閱與檢視訂閱者
動態訂閱與檢視訂閱者
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION