CodeGym /課程 /C# SELF /與 delegate 和 event 有關的典型錯誤

與 delegate 和 event 有關的典型錯誤

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

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 EventHandlerEventHandler<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;

對於自定事件,幾乎總是使用 EventHandlerEventHandler<T>。你的同事(以及未來的你)會感謝你。

2
任務
C# SELF, 等級 54, 課堂 0
上鎖
在事件處理器中處理例外狀況
在事件處理器中處理例外狀況
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION