事件的基本模型
CLR 的事件模型是建立在委託之上的。可以先把事件理解成:
- 一個用來保存處理器列表的委託欄位
- 再加上一層受限的公開介面
它的目的是讓某個物件在狀態改變或某件事發生時,通知其他已經訂閱它的物件。
public event EventHandler SomeEvent;
事件本身通常包含三件事:
- 允許外部註冊處理器
- 允許外部取消註冊處理器
- 在適當時機通知所有已註冊的處理器
靜態事件負責型別層級的通知,實例事件則負責單一物件層級的通知。
一個典型事件型別怎麼寫
如果一個型別要公開事件,常見做法可以拆成四步。
1. 定義事件參數型別
如果事件需要攜帶額外資料,通常會定義一個繼承自 EventArgs 的型別。
public sealed class TestArgs : EventArgs
{
public int X { get; }
public TestArgs(int x)
{
X = x;
}
}
2. 宣告事件成員
最常見的寫法是使用 EventHandler 或 EventHandler<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#》