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

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

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。

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

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

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

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

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

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

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

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

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

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

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

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

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

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