Effective-CSharp-8用null条件运算符调用事件处理程序

刚接触事件的人可能觉得触发事件是很容易的,只需将事件定义好,并在需要触发时调用相关的事件处理程序就可以了,底层的多播委托将会依次执行这些处理程序。实际上触发事件并不是如此简单。若根本没有事件对应的处理程序会怎样?若多个线程都要检测并调用事件处理程序,而线程之间互相争夺,会怎样?C# 6.0 引入的 null 条件运算符 (null-conditional operator,又称 null 传播运算符 (null-propagation operator)) 可用更加清晰的写法来解决这些问题。[1]

1
2
3
4
5
6
7
8
9
10
11
public class EventSource
{
private int counter;
private EventHandler<int> Updated;

public void RaiseUpdates()
{
counter++;
Updated(this, counter);
}
}

这种旧写法有个明显的问题:如在对象上出发 Updated 事件是并没有事件处理程序与之相关,将会发生 NullReferenceException,因为 C# 用 null 值表示这种没有处理程序与之相关的情况。于是,触发事件前,必须先判断事件处理程序是否为 null。[2]

1
2
3
4
5
6
public void RaiseUpdates()
{
counter++;
if(Updated != null)
Updated(this, counter);
}

但这种写法依然有 bug。当程序线程执行完 if 判断了 Updated 不为 null 后,可能会有另一个线程打断该线程,并解除订阅,这样的话依然会引发 NullReferenceException,虽然这种情况很少见。[3]

这个 bug 很难诊断,也很难修复。想重现该错误,必须按照上述线程的执行顺序执行。一些开发老手在此问题上吃过亏,他们知道其危险,改用另一个种写法:

1
2
3
4
5
6
7
public void RaiseUpdates()
{
counter++;
var handler = Updated;
if(handler != null)
handler(this, counter);
}

此方法是可行的,线程是安全的。但是从阅读的角度来看,看代码的人不太明白为何这样改后就能确保线程安全。[4]

var handler = Updated; 这是对赋值号的右侧做 浅拷贝 (shallow copy),也就是创建一个新的引用,指向其事件处理程序。因此,即使是 Updated 被其他线程注销,变为 null。也不会影响 handler,handler 依然保存了原先记录的事件订阅者。这段代码实际上是通过浅拷贝为事件订阅这做了份快照,触发事件时通过快照来触发事件处理程序。[5]

触发事件是一项简单的任务,不该用这么冗长且费解的方式去完成。

有了 null 条件运算符,可以用更清晰的写法来实现:

1
2
3
4
5
public void RaiseUpdates()
{
counter++;
Updated?.Invoke(this, counter);
}

采用了 null 条件运算符 (也就是 ?.) 安全地调用事件处理程序。该运算符首先对左侧内容进行 null 判断,若非 null 执行右侧内容。若为 null 则跳过此语句。[6]

从语义上来说这和 if 类似。但区别在于 ?. 运算符左侧的内容只会计算一次。[7]

由于 C# 不许 ?. 运算符右侧直接出现一对括号,因此必须用 Invoke() 去触发事件。每定义一种委托或事件,编译器都会为此生成类型安全的 Invoke(),这意味着,通过 Invoke 方法触发事件,使得代码篇幅更小,且线程安全。[8]

有了这种简单且清晰的写法后,原来的写法需要改一改了。以后触发事件都应采用此写法。[9]

成员访问运算符和表达式 - C# 参考


  1. Bill Wagner 的 Effective C# (Covers C# 6.0), 3rd Edition Item 8 建议用 null 条件运算符调用事件处理程序;Microsoft Events 指南说明 C# 事件是发布者 / 订阅者模型,多订阅者会在事件触发时同步收到通知:https://www.informit.com/store/effective-c-sharp-covers-c-sharp-6.0-50-specific-ways-9780134579283https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/events/↩︎

  2. Microsoft 的 null 条件运算符文档把 PropertyChanged?.Invoke(...) 作为事件调用示例,背景正是事件没有订阅者时委托引用可能为 null;直接调用 null 委托会落入空引用风险:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-↩︎

  3. Microsoft 文档说明 null 条件运算符左侧最多只求值一次,并把 PropertyChanged?.Invoke(...) 描述为线程安全的委托调用方式;这支撑正文中“先判空再调用”在多线程退订下仍有竞态窗口的判断:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-↩︎

  4. 传统局部变量写法的安全性来自先取得当前委托引用,再对该引用判空和调用;Microsoft 的 null 条件运算符文档将 ?.Invoke 与这种线程安全委托调用模式对应起来:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-↩︎

  5. Microsoft 文档说明委托实例是不可变的;取得委托引用后,后续订阅或退订会产生新的委托引用,而不会修改已经取得的那个委托实例。这就是局部变量保存事件订阅快照的原因:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-↩︎

  6. Microsoft Learn 的 member access operators 文档说明,?.?[] 只在左操作数非 null 时才访问成员或元素,否则返回 null;这是正文“非 null 执行右侧,为 null 跳过”的语言规则来源:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-↩︎

  7. 同一份 Microsoft 文档明确说明 null 条件运算符的左操作数最多只求值一次;用于事件委托调用时,这一点让判空和调用共享同一个委托引用快照:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-↩︎

  8. C# 语言规范的 delegates 章节规定,每个委托类型都有一个 Invoke 方法;委托调用列表中的条目会按顺序同步调用。因此 Updated?.Invoke(this, counter) 既使用了委托的类型安全调用入口,也保留了多播委托的同步调用语义:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/delegates↩︎

  9. ?.Invoke 解决的是事件委托引用的空检查竞态,不等于完整事件并发模型;事件处理器内部状态、慢处理器、异常传播和已取得快照后的退订行为仍需要发布者额外定义策略:https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/events/↩︎