CodeGym /课程 /C# SELF /委托和事件的常见错误

委托和事件的常见错误

C# SELF
第 54 级, 课程 0
可用

1. 介绍

在 C# 中使用委托和事件很舒服、很方便——语言替你做了很多事。但在这层便利的背后有不少坑:看不见的内存泄漏、重复订阅导致的奇怪 bug、处理器不同步甚至在广播时突发异常。 委托和事件很强大,但需要注意对象生命周期、线程和对调用与取消订阅机制的理解。 如果你的事件“多数时候”正常,但偶尔不触发或抛奇怪错误——你并不孤单!我们来看看经验丰富的程序员们最常踩的坑,以及怎么避免它们。

主要错误表

错误 后果 如何避免
重复订阅 处理器被多次调用 注意订阅位置,添加前先移除
未取消订阅(内存泄漏) 订阅者被留在内存中,变成“僵尸” 总是取消订阅,使用 IDisposable
处理器中抛出异常 剩下的处理器不会被调用 在处理器里用 try/catch 或手动逐个调用
在事件广播期间修改订阅 可能丢失或重复调用 遍历处理器的副本 (GetInvocationList())
调用事件时 MyEvent == null NullReferenceException 检查 null,使用 ?.Invoke
处理器签名不匹配 编译错误 检查签名
在外部调用事件 编译错误 只能通过 OnEventName 调用
不当使用静态事件 订阅混在一起 非必要别用 static
lambda 的 closure 问题 意外的值 复制变量到局部

2. 重复订阅和多重调用

错误要点

如果你多次把同一个处理器订阅到同一事件,每一次 += 都会把方法加入委托队列。结果是处理器会被调用和添加次数相同的次数。

怎么表现?

想象你有一个按钮和点击处理器:

Button btn = new Button();
btn.Click += OnButtonClick; // 订阅一次
btn.Click += OnButtonClick; // 又一次订阅!

现在每次点击按钮时 OnButtonClick 会被调用两次。如果处理器里更新计数器或写日志,会看到重复的结果。

如何修复?

重复订阅通常是因为代码结构不当——比如把 += 放到会被多次调用的方法(不同生命周期阶段)。

  • 注意订阅发生的位置。
  • 不要把 += 放到可能多次运行的生命周期阶段。
  • 有时可以用“唯一订阅”技巧——在添加前先移除:
myEvent -= MyHandler; // 先移除以防已存在
myEvent += MyHandler; // 再添加

这是安全的:如果之前没有该处理器,-= 不会有副作用。

3. 僵尸订阅者

错误要点

如果订阅者订阅了一个长生命周期的发布者但没有取消订阅,GC 不能回收它:发布者仍然持有对处理器委托的引用,也就间接持有订阅者对象。结果是内存泄漏。

典型示例

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. 在广播期间修改订阅列表

错误要点

如果某个处理器在事件处理中取消订阅自己或其他处理器,可能会改变调用顺序:有的处理器会被跳过或被重复调用。

如何避免?

  • 不要在处理器内部修改订阅集合。
  • 如果必须修改,先遍历委托的副本:
var handlers = MyEvent?.GetInvocationList();
foreach (EventHandler handler in handlers)
{
    handler(this, EventArgs.Empty);
}

6. 委托为 null(无人订阅)的问题

错误要点

如果没有任何订阅者,事件的委托等于 null。不做检查就调用会抛出 NullReferenceException。

坏示例

public event EventHandler MyEvent;

public void Raise()
{
    MyEvent(this, EventArgs.Empty); // 如果没人订阅——异常!
}

怎么做才对?

  • 使用安全调用:MyEvent?.Invoke(this, EventArgs.Empty)。
  • 或者用经典的线程安全写法:把委托复制到局部变量再调用它。

7. 不同签名的委托/事件混用

错误要点

委托是强类型的。处理器方法的签名和事件的签名不匹配会导致编译错误。

示例

public event EventHandler<string> TextChanged;

void WrongHandler(object sender, int number) { /* ... */ }

TextChanged += WrongHandler; // 编译错误!

尽量使用标准委托 EventHandler 和 EventHandler<T>,并确保签名完全匹配。

8. 在发布者类外部尝试调用事件

错误要点

event 是封装的委托:外部代码只能添加/移除处理器,不能直接调用它。

示例

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,但它本该是实例级别的。这样所有实例会共享同一订阅队列。

示例

public static event EventHandler GlobalEvent; // 糟糕!
// 实例的个性化订阅被合并到一起

如何避免?

只有在确实需要全局级别(比如全局日志)时才用静态事件。通常用实例级事件来保持封装。

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. 弱引用与强引用混用:高级“弱事件”

在大型应用(如 WPF)中会用到“弱事件”机制,发布者对订阅者只保持弱引用,避免阻止 GC 回收。弱事件能防止泄漏,但订阅者可能被回收而收不到事件。

详情参考: 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>。你的同事(以及将来的你)会感谢这种统一。

2
任务
C# SELF, 第 54 级, 课程 0
已锁定
在事件处理器中处理异常
在事件处理器中处理异常
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION