事件的基本模型

CLR 的事件模型是建立在委託之上的。可以先把事件理解成:

  • 一個用來保存處理器列表的委託欄位
  • 再加上一層受限的公開介面

它的目的是讓某個物件在狀態改變或某件事發生時,通知其他已經訂閱它的物件。

public event EventHandler SomeEvent;

事件本身通常包含三件事:

  • 允許外部註冊處理器
  • 允許外部取消註冊處理器
  • 在適當時機通知所有已註冊的處理器

靜態事件負責型別層級的通知,實例事件則負責單一物件層級的通知。

一個典型事件型別怎麼寫

如果一個型別要公開事件,常見做法可以拆成四步。

1. 定義事件參數型別

如果事件需要攜帶額外資料,通常會定義一個繼承自 EventArgs 的型別。

public sealed class TestArgs : EventArgs
{
    public int X { get; }

    public TestArgs(int x)
    {
        X = x;
    }
}

2. 宣告事件成員

最常見的寫法是使用 EventHandlerEventHandler<TEventArgs>

public event EventHandler<TestArgs> NewEvent;

這代表訂閱者的方法簽章必須相容於:

void Handler(object sender, TestArgs e)

3. 提供受保護的引發方法

事件通常不直接讓外部隨便 Invoke,而是由型別自己提供一個專門的引發方法。

protected virtual void Raise(TestArgs args)
{
    NewEvent?.Invoke(this, args);
}

4. 在業務流程裡引發事件

public void InputSimulation(int x)
{
    TestArgs args = new TestArgs(x);
    Raise(args);
}

這樣事件就從「一個語法糖」變成了一個完整的通知模型。

編譯器如何看待事件

表面上你只寫了一行:

public event EventHandler<TestArgs> NewEvent;

但編譯器通常會幫你展開成這幾部分:

  • 一個私有委託欄位
  • 一個 add_NewEvent(...) 方法
  • 一個 remove_NewEvent(...) 方法
  • 一筆 event metadata

概念上會接近這樣:

.event class [System.Runtime]System.EventHandler`1<class CLR_Ch11.TestArgs> NewEvent
{
    .addon instance void CLR_Ch11.Program::add_NewEvent(...)
    .removeon instance void CLR_Ch11.Program::remove_NewEvent(...)
}

也就是說,事件在 CLR 層並不是單一成員,而是一個「欄位 + 存取方法 + metadata」的組合。

+= / -= 真正做了什麼

對事件使用:

publisher.NewEvent += Handler;
publisher.NewEvent -= Handler;

編譯器不會直接操作私有欄位,而是翻譯成對 add_... / remove_... 的呼叫。

因此:

  • += 對應到註冊處理器
  • -= 對應到取消註冊處理器

底層仍然是委託的 Combine / Remove 模型,只是事件把這層操作包起來了。

為什麼事件不等於公開委託欄位

如果你直接公開一個委託欄位:

public EventHandler<TestArgs> NewEvent;

外部程式碼就不只可以 += / -=,還能直接:

  • 整個覆寫掉委託列表
  • 直接設成 null
  • 從類別外部直接呼叫它

這通常不是你想要的封裝邊界。

event 的意義就是把可見操作限制在:

  • 訂閱
  • 取消訂閱

只有事件擁有者自己才能真正決定何時引發事件。

顯式實作事件

如果事件很多,或者你不想讓每個事件都生成一個私有委託欄位,也可以自己手寫 add / remove,這就是顯式事件實作。

private EventHandler<TestArgs> _newEvent;

public event EventHandler<TestArgs> NewEvent
{
    add
    {
        _newEvent += value;
    }
    remove
    {
        _newEvent -= value;
    }
}

這種寫法的好處是你可以完全控制:

  • 處理器要存在哪裡
  • 加入與移除時要不要做額外檢查
  • 是否把多個事件集中進一個事件表

事件表(Event Set)模式

如果一個型別有很多事件,而大多數實例在執行期間只會用到其中少數幾個,為每個事件都配一個委託欄位不一定划算。

這時常見做法是使用事件表:

  • 用一個字典保存事件 key
  • value 則是該事件對應的委託鏈

大方向會像這樣:

public sealed class EventSet
{
    private readonly Dictionary<object, Delegate> _events = new();

    public void Add(object key, Delegate handler)
    {
        _events.TryGetValue(key, out Delegate current);
        _events[key] = Delegate.Combine(current, handler);
    }

    public void Remove(object key, Delegate handler)
    {
        if (_events.TryGetValue(key, out Delegate current))
        {
            Delegate updated = Delegate.Remove(current, handler);

            if (updated is null)
            {
                _events.Remove(key);
            }
            else
            {
                _events[key] = updated;
            }
        }
    }
}

這種模式的核心價值不是語法,而是節省大量「大多數時間都沒被使用的事件欄位」。

記得取消訂閱

事件很常見的一個實務問題,是訂閱者忘了取消訂閱。

只要某個物件還掛在事件委託鏈上,發布者就可能持續引用它,導致:

  • 物件無法被回收
  • 生命週期比預期更長
  • 最後變成記憶體滯留問題

因此只要訂閱者不是跟發布者同生命週期,就應該明確考慮:

  • 何時 -= 取消訂閱
  • 是否用 Dispose 管理
  • 是否需要 weak event pattern

小結

事件這章可以濃縮成幾個核心觀念:

  • 事件建立在委託之上,但不是單純的公開委託欄位。
  • event 真正提供的是受限的訂閱 / 取消訂閱介面。
  • 編譯器會把事件翻成 add / remove 方法與對應 metadata。
  • 顯式事件實作與事件表模式,主要是為了掌控儲存與成本。
  • 忘記取消訂閱,常常就是事件系統最實際的 bug 來源。

參考資料

  • 《CLR via C#》