Menu

C# 事件(event):event 关键字、EventHandler、订阅与触发

C# 中的事件是怎么工作的:用 EventHandler 和 EventHandler<T> 声明事件,自定义 EventArgs 类,用 += 和 -= 订阅和取消订阅,用 ?.Invoke 安全地触发事件,为什么事件比公共委托字段更好,以及忘记取消订阅导致的内存泄漏。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

事件让一个类可以宣布某件事发生了,而不需要知道谁在监听。其他对象用 += 订阅,当这个类触发事件时,每个订阅者的方法都会运行。

输出:

Order A-17 saved
  receipt emailed for A-17
Order A-18 saved
  receipt emailed for A-18
  free gift added to A-18

Shop 并不知道收据或赠品的存在。它触发 OrderPlaced,订阅了的代码各自做出反应。要增加新的反应(积分、数据分析),只需添加一个订阅者,而不必修改 PlaceOrder。

声明事件

事件声明是一个带有 event 关键字、类型为委托的成员:

public event EventHandler<OrderPlacedEventArgs> OrderPlaced;

委托类型决定了处理程序的签名。.NET 代码几乎总是使用两个内置类型之一,遵循这个约定能让其他 .NET 开发者一眼看懂你的事件:

  • EventHandler:void (object sender, EventArgs e),用于不携带数据的事件。触发时传入 EventArgs.Empty。
  • EventHandler<TEventArgs>:void (object sender, TEventArgs e),其中 TEventArgs 是你携带数据的类。

与之相关的约定:

  • sender 是触发事件的对象,通常是 this。
  • 数据类命名为 {Something}EventArgs,派生自 EventArgs(从 .NET 4.5 起可以不派生,但仍是惯例),并公开只读属性。
  • 事件按发生的事情命名:OrderPlaced、Closed、PriceChanged。以 ...ing 结尾的名称(Closing)用于在动作发生之前触发的事件,通常还提供取消动作的方法。
  • 在打算被继承的类中,触发事件要通过一个 protected virtual void OnOrderPlaced(OrderPlacedEventArgs e) 方法,这样派生类就能介入。

订阅与取消订阅

+= 添加处理程序,-= 移除处理程序。处理程序可以是方法,也可以是 lambda。如果之后要移除 lambda,就把它存在变量里,因为再写一遍同样的 lambda 会创建一个不同的委托,-= 找不到它:

输出:

set to 32
  display shows 32 C
  alarm: too hot
set to 35
  display shows 35 C
set to 20
  display shows 20 C

移除一个从未添加过的处理程序不是错误,它什么也不做。EventHandler<int> 说明在现代 .NET 中,类型参数不一定要派生自 EventArgs,不过专门的类可以留出余地,以后添加字段也不会破坏订阅者。

处理程序是同步运行的,按订阅顺序,在触发事件的线程上执行。如果某个处理程序抛出异常,剩下的处理程序不会运行,异常会传到触发事件的代码。

安全地触发事件

没有订阅者的事件持有的是 null。直接调用会抛出 NullReferenceException,所以要用空条件运算符来触发:

OrderPlaced?.Invoke(this, args);

在 C# 6 之前,常用的写法是把字段复制到局部变量,检查它,再调用这个副本。在多线程代码中,这个副本很重要:先检查 OrderPlaced != null 再调用 OrderPlaced(...) 会读取字段两次,另一个线程可能在这两次之间移除最后一个处理程序。?. 只读取一次,所以既更短又正确。

为什么用 event 而不是公共委托字段

不加 event 关键字,公共委托字段也能用来订阅,但它把全部控制权交给了每一个订阅者:

输出:

analytics ping
analytics ping

把 += 打成了 =,就悄无声息地移除了前面两个处理程序,而外部代码也可以在没有任何点击的情况下触发“点击”。把字段标记为 event,这两行就都会变成编译错误:

error CS0070: The event 'Button.Clicked' can only appear on the left hand side of += or -= (except when used from within the type 'Button')

在 Button 内部,事件的行为仍然和普通委托字段一样,所以类本身可以调用它,也可以检查它是否为 null。

忘记取消订阅导致的泄漏

订阅时,事件会保存一个委托,而委托持有对订阅者对象的引用。只要发布者还活着、处理程序还挂着,订阅者就无法被垃圾回收,而且在你不再需要它之后,它还会继续接收事件:

输出:

  closed widget shows 101.5
  open widget shows 101.5
subscribers left: 1
  closed widget shows 99.0

把 leaky 设为 null 对 feed 没有任何作用:它的处理程序列表仍然引用着那个控件,所以“已关闭”的控件还在不断更新,并一直留在内存中。using 块中的控件在 Dispose 中取消了订阅,于是不再接收事件。这是 .NET 桌面和服务器代码中最常见的内存泄漏之一,尤其是静态事件和全应用范围的服务,它们会一直存活到进程结束。

规则是:谁订阅了一个比自己活得更久的对象上的事件,谁就必须取消订阅,通常放在 Dispose 中(参见 using 语句)。如果发布者和订阅者的生命周期相同,比如窗体和它自己的按钮,就不需要取消订阅。

自定义 add 和 remove 访问器

事件可以定义 += 和 -= 做什么,就像属性的 get 和 set。这种用法很少见,但框架正是用它把处理程序存进共享字典,或者转发给另一个对象:

private EventHandler closed;

public event EventHandler Closed
{
    add    { Console.WriteLine("subscriber added");   closed += value; }
    remove { Console.WriteLine("subscriber removed"); closed -= value; }
}

使用自定义访问器时,类通过后备字段触发事件(closed?.Invoke(this, EventArgs.Empty)),因为事件本身已经没有存储了。

常见问题

C# 中的事件是什么?

事件是一种类成员,它让其他对象可以请求在某件事发生时得到通知。事件背后是一个多播委托,但外部代码只能添加处理程序(+=)和移除处理程序(-=)。只有声明事件的类才能触发它。

C# 中事件和委托有什么区别?

委托是一种引用方法的类型。事件是用委托类型加上 event 关键字声明的成员,这个关键字限制了访问:在类的外部,你不能调用它、读取它的处理程序列表,也不能用 = 给它赋值,只能订阅和取消订阅。公共委托字段则允许所有这些操作,所以任何一个订阅者都可能清掉其他订阅者,或者擅自触发事件。

如何通过 C# 事件传递数据?

从 EventArgs 派生一个类,把数据作为只读属性(public class OrderPlacedEventArgs : EventArgs { public decimal Total { get; } }),再把事件声明为 EventHandler<OrderPlacedEventArgs>。用 OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(total)); 触发它。

C# 中如何安全地触发事件?

使用 MyEvent?.Invoke(this, args);。没有订阅者的事件是 null,直接调用会抛出 NullReferenceException。?. 运算符只读取一次字段,这也避免了竞态条件:另一个线程恰好在空值检查和调用之间移除了最后一个处理程序。

C# 事件会导致内存泄漏吗?

会。订阅时会保存一个引用了订阅者的委托,所以只要发布者还活着,订阅者就会一直存活。一个长期存在的发布者(静态事件、全应用范围的服务),配上从不取消订阅的短期订阅者,会把每一个订阅者都留在内存里。订阅者用完后,用 -= 取消订阅,通常放在 Dispose 中。

Coddy 编程语言插图

和 Coddy 一起学编程

开始