1. 介紹
在 C# 中使用 delegate 和 event 很舒服也很方便——語言替你處理了很多細節。但在這層便利的背後藏著不少地雷:看不見的記憶體洩漏、來自重複訂閱的奇怪 bug、處理器不同步甚至在通知廣播時突然拋出異常。 delegate 和 event 很強大,但需要你細心管理物件生命週期、了解多執行緒問題,以及知道呼叫和取消訂閱的實際行為。 如果你的事件「大多時候」都正常,但有時候不觸發或丟出奇怪錯誤——你並不孤單!我們來看看即便是有經驗的工程師最常踩到的地雷,以及如何避免它們。
常見錯誤總表
| 錯誤 | 後果 | 如何避免 |
|---|---|---|
| 重複訂閱 | 處理器會被呼叫多次 | 留意訂閱位置,在加入前先移除 |
| 未取消訂閱(記憶體洩漏) | 訂閱者留在記憶體中,變成「殭屍」 | 務必取消訂閱,使用 IDisposable |
| 處理器內拋出異常 | 剩下的處理器不會被呼叫 | 在處理器內用 try/catch 或手動遍歷 |
| 在事件廣播時修改訂閱清單 | 可能跳過或重複呼叫 | 遍歷處理器的複本 (GetInvocationList()) |
| 當 MyEvent == null 呼叫事件 | NullReferenceException | 檢查 null,使用 ?.Invoke |
| 處理器簽名不符合 | 編譯錯誤 | 檢查簽名 |
| 從外部呼叫事件 | 編譯錯誤 | 只能透過 OnEventName 呼叫 |
| static 事件放錯地方 | 訂閱混淆 | 沒有必要不要把事件做成 static |
| lambda 的 closure 問題 | 出現意外的值 | 對變數做複本 |
2. 多次訂閱與多重呼叫
錯誤本質
如果你多次把相同的處理器訂閱到同一個事件,每次執行 += 都會把方法加入 delegate 的隊列。結果是處理器會被呼叫多少次,就加入多少次。
如何表現出來?
想像你有個按鈕和一個按鈕事件處理器:
Button btn = new Button();
btn.Click += OnButtonClick; // 訂閱一次
btn.Click += OnButtonClick; // 又訂閱一次!
現在每次按下按鈕時,方法 OnButtonClick 會被呼叫兩次。如果處理器內更新計數或寫 log,就會看到結果變成兩倍。
如何修復?
重複訂閱通常是程式結構問題——比如把 += 放在可能被呼叫多次的方法(例如不同生命週期階段)中。
- 注意訂閱發生的位置。
- 不要把 += 放到可能重複執行的生命週期階段。
- 有時候使用「唯一訂閱」策略會有用——在加入之前先移除處理器:
myEvent -= MyHandler; // 保險起見先移除
myEvent += MyHandler; // 再訂閱
這樣是安全的:如果處理器還沒訂閱過,-= 不會有副作用。
3. 殭屍訂閱者
錯誤本質
如果訂閱者訂閱了長壽命的發佈者卻沒有取消訂閱,垃圾回收器就不能回收它:發佈者仍然持有對處理器 delegate 的參考,也就持有整個訂閱者物件。結果是記憶體洩漏。
典型範例
public class TemporaryPopup : IDisposable
{
private Window _hostWindow;
public TemporaryPopup(Window window)
{
_hostWindow = window;
_hostWindow.Closed += OnHostClosed;
}
private void OnHostClosed(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_hostWindow.Closed -= OnHostClosed; // 別忘了取消訂閱!
}
}
如果忘了呼叫 Dispose() 或不去觸發它,即使你移除了所有對 TemporaryPopup 的參考,物件也不會被回收——視窗仍然參考著它的處理器。
如何避免?
- 在訂閱者實作 IDisposable,如果它的生命週期短於發佈者。
- 使用 using 模式或明確呼叫 Dispose():
using (var popup = new TemporaryPopup(mainWindow))
{
// ...
} // 這裡會自動呼叫 Dispose
在 GUI 應用程式中,應在視窗/表單關閉時取消訂閱(例如在關閉處理器或在表單的 Dispose() 中)。
4. 在處理器內處理異常
錯誤本質
當事件呼叫許多處理器時,如果其中一個拋出異常,剩下的處理器就不會被執行。
示範
public event EventHandler MyEvent;
public void Raise()
{
MyEvent?.Invoke(this, EventArgs.Empty);
}
如果某個訂閱在 MyEvent 的方法中發生異常,其他處理器將不會被呼叫——委託鏈會中斷。
如何處理?
- 在事件處理器內,要麼自行處理異常(用 try/catch),要麼有意地將異常向外拋。
- 在更複雜的場景中,手動遍歷訂閱者並對每個處理器隔離異常:
var handlers = MyEvent?.GetInvocationList();
foreach (var handler in handlers)
{
try
{
handler.DynamicInvoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
// 記錄、嘗試恢復
}
}
5. 在廣播期間修改訂閱清單
錯誤本質
如果某個處理器在事件執行時取消自己或其他處理器,可能會影響呼叫順序:有的處理器會被跳過或重複呼叫。
如何避免?
- 不要在處理器內修改訂閱清單。
- 如果必須修改,請遍歷 delegate 的複本:
var handlers = MyEvent?.GetInvocationList();
foreach (EventHandler handler in handlers)
{
handler(this, EventArgs.Empty);
}
6. delegate 為 null 的混淆(沒有訂閱者)
錯誤本質
如果沒有人訂閱事件,對應的 delegate 會是 null。沒有檢查就呼叫會導致 NullReferenceException。
壞範例
public event EventHandler MyEvent;
public void Raise()
{
MyEvent(this, EventArgs.Empty); // 沒有訂閱者會拋出異常!
}
正確做法
- 使用安全呼叫: MyEvent?.Invoke(this, EventArgs.Empty)。
- 或使用傳統的執行緒安全方法:把 delegate 複製到區域變數再呼叫它。
7. 混用不同簽名的 delegate/event
錯誤本質
delegate 是強型別的。處理器方法的簽名與事件的 delegate 不匹配會導致編譯錯誤。
範例
public event EventHandler<string> TextChanged;
void WrongHandler(object sender, int number) { /* ... */ }
TextChanged += WrongHandler; // 編譯錯誤!
使用標準 delegate EventHandler 和 EventHandler<T>,並確保簽名完全一致。
8. 嘗試在發佈者外部呼叫事件
錯誤本質
event 是封裝過的 delegate:外部程式只能新增/移除處理器,不能直接呼叫它。
範例
public class MyPublisher
{
public event EventHandler SomethingHappened;
}
var publisher = new MyPublisher();
publisher.SomethingHappened?.Invoke(publisher, EventArgs.Empty); // 錯誤!
正確做法
由發佈者自己提供受保護或公開的方法來觸發事件——通常命名為 OnEventName。外部只能使用 +=/-=。
9. 自訂 add/remove 存取子的錯誤
錯誤本質
自訂事件的存取子可以讓你控制訂閱流程,但也很容易破壞正確的呼叫序或執行緒安全性。
範例
public event EventHandler MyEvent
{
add { /* ... */ }
remove { /* ... */ }
}
不確定時就用預設事件實作。若要手動實作,務必參考文件並考慮同步化問題。
10. 從 static / instance 上存取事件的問題
錯誤本質
很容易不小心把事件宣告成 static,但它原本應該屬於實例。這會讓所有物件共用相同的訂閱清單。
範例
public static event EventHandler GlobalEvent; // 哎呀!
// 實例失去個別性,訂閱合併到同一堆
如何避免?
只有在需要全域級別時(例如全域 logging)才使用 static 事件。大多數情況下,事件該被封裝在實例層級。
11. lambda 捕獲變數的問題
錯誤本質
lambda 會以引用捕獲變數。在迴圈中常見會捕獲最後一個值的情況。
範例
for (int i = 0; i < 5; i++)
{
button.Click += (s, e) => Console.WriteLine(i);
}
// 所有處理器都會印出 "5"
正確做法
for (int i = 0; i < 5; i++)
{
int copy = i; // 局部複本
button.Click += (s, e) => Console.WriteLine(copy);
}
12. 強/弱參考的混用:進階的 “Weak Events”
在大型應用(例如 WPF)會使用「弱事件」機制,讓發佈者持有對訂閱者的弱參考,避免阻止垃圾回收。弱事件可以減少洩漏,但訂閱者也可能被回收而收不到事件。
詳細資訊: Weak Event Patterns (MSDN)
13. 缺乏命名與簽名標準
錯誤本質
遵守標準的簽名與命名:事件名稱用過去式(像 Changed, Closed, Completed),事件參數繼承自 EventArgs。
「錯誤」範例:
public delegate void SomethingHappens(int what);
// ...
public event SomethingHappens Something;
「正確」範例:
public event EventHandler<EventArgs> SomethingHappened;
對於自定事件,幾乎總是使用 EventHandler 或 EventHandler<T>。你的同事(以及未來的你)會感謝你。
GO TO FULL VERSION