Effective-CSharp-8用null条件运算符调用事件处理程序
刚接触事件的人可能觉得触发事件是很容易的,只需将事件定义好,并在需要触发时调用相关的事件处理程序就可以了,底层的多播委托将会依次执行这些处理程序。实际上触发事件并不是如此简单。若根本没有事件对应的处理程序会怎样?若多个线程都要检测并调用事件处理程序,而线程之间互相争夺,会怎样?C# 6.0 引入的 null 条件运算符 (null-conditional operator,又称 null 传播运算符 (null-propagation operator)) 可用更加清晰的写法来解决这些问题。[1]
1 | public class EventSource |
这种旧写法有个明显的问题:如在对象上出发 Updated 事件是并没有事件处理程序与之相关,将会发生 NullReferenceException,因为 C# 用 null 值表示这种没有处理程序与之相关的情况。于是,触发事件前,必须先判断事件处理程序是否为 null。[2]
1 | public void RaiseUpdates() |
但这种写法依然有 bug。当程序线程执行完 if 判断了 Updated 不为 null 后,可能会有另一个线程打断该线程,并解除订阅,这样的话依然会引发 NullReferenceException,虽然这种情况很少见。[3]
这个 bug 很难诊断,也很难修复。想重现该错误,必须按照上述线程的执行顺序执行。一些开发老手在此问题上吃过亏,他们知道其危险,改用另一个种写法:
1 | public void RaiseUpdates() |
此方法是可行的,线程是安全的。但是从阅读的角度来看,看代码的人不太明白为何这样改后就能确保线程安全。[4]
var handler = Updated; 这是对赋值号的右侧做 浅拷贝 (shallow copy),也就是创建一个新的引用,指向其事件处理程序。因此,即使是 Updated 被其他线程注销,变为 null。也不会影响 handler,handler 依然保存了原先记录的事件订阅者。这段代码实际上是通过浅拷贝为事件订阅这做了份快照,触发事件时通过快照来触发事件处理程序。[5]
触发事件是一项简单的任务,不该用这么冗长且费解的方式去完成。
有了 null 条件运算符,可以用更清晰的写法来实现:
1 | public void RaiseUpdates() |
采用了 null 条件运算符 (也就是 ?.) 安全地调用事件处理程序。该运算符首先对左侧内容进行 null 判断,若非 null 执行右侧内容。若为 null 则跳过此语句。[6]
从语义上来说这和 if 类似。但区别在于 ?. 运算符左侧的内容只会计算一次。[7]
由于 C# 不许 ?. 运算符右侧直接出现一对括号,因此必须用 Invoke() 去触发事件。每定义一种委托或事件,编译器都会为此生成类型安全的 Invoke(),这意味着,通过 Invoke 方法触发事件,使得代码篇幅更小,且线程安全。[8]
有了这种简单且清晰的写法后,原来的写法需要改一改了。以后触发事件都应采用此写法。[9]
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-9780134579283、https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/events/。 ↩︎
Microsoft 的 null 条件运算符文档把
PropertyChanged?.Invoke(...)作为事件调用示例,背景正是事件没有订阅者时委托引用可能为null;直接调用null委托会落入空引用风险:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-。 ↩︎Microsoft 文档说明 null 条件运算符左侧最多只求值一次,并把
PropertyChanged?.Invoke(...)描述为线程安全的委托调用方式;这支撑正文中“先判空再调用”在多线程退订下仍有竞态窗口的判断:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-。 ↩︎传统局部变量写法的安全性来自先取得当前委托引用,再对该引用判空和调用;Microsoft 的 null 条件运算符文档将
?.Invoke与这种线程安全委托调用模式对应起来:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-。 ↩︎Microsoft 文档说明委托实例是不可变的;取得委托引用后,后续订阅或退订会产生新的委托引用,而不会修改已经取得的那个委托实例。这就是局部变量保存事件订阅快照的原因:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-。 ↩︎
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-。 ↩︎同一份 Microsoft 文档明确说明 null 条件运算符的左操作数最多只求值一次;用于事件委托调用时,这一点让判空和调用共享同一个委托引用快照:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-。 ↩︎
C# 语言规范的 delegates 章节规定,每个委托类型都有一个
Invoke方法;委托调用列表中的条目会按顺序同步调用。因此Updated?.Invoke(this, counter)既使用了委托的类型安全调用入口,也保留了多播委托的同步调用语义:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/delegates。 ↩︎?.Invoke解决的是事件委托引用的空检查竞态,不等于完整事件并发模型;事件处理器内部状态、慢处理器、异常传播和已取得快照后的退订行为仍需要发布者额外定义策略:https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/events/。 ↩︎