思考并回答以下问题:
- Action\
是什么意思? - 事件提供了额外的“封装性”,使publish-subscribe模式更容易实现,更不容易出错怎么理解?
- 单一事件(比如对象状态的改变)的通知可以发布给多个订阅者通过什么实现?
- 为什么要再把一个委托变量再次赋一个新值?
- 为什么可以调用thermostat.OnTemperatureChange += heater.OnTemperatureChanged;而不引发异常(即使thermostat.OnTemperatureChange尚未赋值)?
- 一个订阅者引发了异常,链中的后续订阅者还能收到通知吗?怎么处理?
- OnTemperatureChange.GetInvocationList())是干嘛用的?
- 赋值操作符会引发bug怎么理解?
- 事件确保只有包容类才能触发事件通知是怎么实现的?
- 订阅者订阅多个发布者,如何知道是哪个发布者发送的消息?
- 第一个参数sender应包含调用委托的那个类的实例,为什么要这样规定?
本章涵盖:
- 使用多播委托来编码Observer模式
- 定义订阅者方法
- 定义发布者
- 连接发布者和订阅者
- 调用委托
- 检查null值
- 委托操作符
- 顺序调用
- 错误处理
- 方法返回值和传引用
- 事件
- 事件的作用
- 事件的声明
- 编码规范
- 泛型和委托
- 自定义事件的实现
- 小结
上一章讲述了如何用委托类型的实例引用方法,并通过委托来调用那个方法。委托本身又是一个更大的模式(pattern)的基本单位,这个模式称为publish-subscribe(发布—订阅)。委托的使用及其对publish-subscribe模式的支持是本章的重点。本章描述的所有内容几乎都可以单独使用委托来实现。然而,本章所着眼的事件构造提供了额外的“封装性”,使publish-subscribe模式更容易实现,更不容易出错。
在上一章中,所有委托都只引用一个方法。然而,一个委托值是可以引用一系列方法的,这些方法将顺序调用。这样的委托称为多播委托(multicast delegate)。这样一来,单一事件(比如对象状态的改变)的通知就可以发布给多个订阅者。
虽然事件在C# 1.0中就有了,但C# 2.0对泛型的引入极大地改变了编码规范,因为使用泛型委托数据类型意味着不再需要为每种可能的事件签名声明一个委托。所以,本章的最低起点是C# 2.0。但是,仍在使用C# 1.0的读者也不是不能使用事件,只是必须声明自己的委托数据类型(参见第12章)
使用多播委托来编码Observer模式
来考虑一个温度控制的例子。在这个假想的情形中,一个加热器(Heater)和一个冷却器(Cooler)连接到同一个自动调温器。为了控制加热器和冷却器的打开和关闭,要向它们通知温度的变化。自动调温器将温度的变化发布给多个订阅者——也就是加热器和冷却器。在C#中,多播委托的实现是一个通用的模式,目的是避免大量的手工编码。这个模式称为Observer(观察者)或者publish-subscribe模式,它要应对的是这样一种情形:需要将单一事件的通知(如对象状态发生的一个变化)广播给多个订阅者(subscriber)。
定义订阅者方法
首先定义Heater和Cooler对象,如代码清单1所示。
代码清单1 Heater和Cooler事件订阅者的实现
1 | using System; |
1 | using System; |
除了温度比较,两个类几乎完全一致(事实上,如果在OnTemperatureChanged方法中使用对一个比较方法的委托,两个类还可以再减少一个)。每个类都存储了启动设备所需的温度。此外,两个类都提供了OnTemperatureChanged()方法。调用OnTemperatureChanged()方法的目的是向Heater和Cooler类指出温度已发生改变。在方法的实现中,用newTemperature同存储好的触发温度进行比较,从而决定是否让设备启动。
两个OnTemperatureChanged()方法都是订阅者方法。作为订阅者方法,很重要的一点在于,它们的参数和返回类型必须与来自Thermostat类的委托匹配。
定义发布者
Thermostat类负责向heater和cooler对象实例报告温度变化。代码清单2展示了Thermostat类。
代码清单2 定义事件发布者Thermostat
1 | using System; |
Thermostat包含一个名为OnTemperatureChange的属性,它具有Action\
Thermostat的最后一个成员是CurrentTemperature属性。它负责设置和获取由客户端类报告的当前温度值。
连接发布者和订阅者
最后,将所有这些东西都放到一个Main()方法中。代码清单3展示了一个示例Main()。
代码清单3 连接发布者和订阅者
1 | using System; |
上述代码使用+=操作符来直接赋值,向OnTemperatureChange委托注册了两个订阅者即heater.OnTemperatureChanged和cooler.OnTemperatureChanged。
通过获取用户输入的温度值,可以设置thermostat(自动调温器)的CurrentTemperature(当前温度)。然而,目前还没有写任何代码将温度变化发布给订阅者。
调用委托
Thermostat类的CurrentTemperature属性每次发生变化时,你都希望调用委托来通知订阅者(heater和cooler)温度的变化。为此,需要修改CurrentTemperature属性来保存新值,并向每个订阅者发出一个通知,如代码清单4所示。
代码清单4 调用委托(不带有检查null值部分)
1 | class Thermostat |
现在,对CurrentTemperature的赋值包含了一些特殊的逻辑,可以向订阅者通知CurrentTemperature发生的变化。为了向所有订阅者发出通知,只需执行一个简单的C#语句,即OnTemperatureChange(value);。这个语句将温度的变化发布给cooler和heater对象。在此,只需执行一个调用,即可向多个订阅者发出通知——这正是将委托更明确地称为“多播委托”的原因。
检查null值
在代码清单4中,遗漏了事件发布代码的一个重要部分。假如当前没有订阅者注册接收通知,则OnTemperatureChange为null,执行OnTemperatureChange(value)语句就会引发一个NullReferenceException异常。为了避免这个问题,有必要在触发事件之前检查null值。代码清单5演示了具体如何做。
代码清单5 调用一个委托
1 | class Thermostat |
我们并不是一开始就检查null值,而是首先将OnTemperatureChange赋给另一个委托变量localonChange。这个简单的修改可确保在检查null值和发送通知之间,假如所有OnTemperatureChange订阅者都(由一个不同的线程)移除,那么不会引发Nul1ReferenceException异常。
规范
要在调用委托前检查它的值是不是null值。
高级主题:将“-=”操作符应用于委托会返回一个新实例
既然委托是引用类型,那么肯定有人会觉得奇怪:为什么赋值给一个局部变量,再用那个局部变量就可以保证null检查的线程安全性?由于localOnChange指向的位置就是OnTemperatureChange指向的位置,所以很自然的结论就是:OnTemperatureChange中发生的任何变化都会在localOnChange中反映出来。
但实情并非如此。事实上,对OnTemperatureChange-=\
高级主题:线程安全的委托调用
如前所述,由于订阅者可以由不同的线程从委托中增加或删除,所以在进行null值检查前有必要将委托引用复制到一个局部变量中。但是,这虽然能防止调用空委托,却不能防止所有可能的竞态条件。例如,一个线程进行复制,另一个线程将这个委托重置为null,然后原始线程可以调用委托的前一个值,借此通知一个已经不再在订阅者列表中的订阅者。在多线程程序中,订阅者应确保在这种情况下的健壮性。一个“过气”的订阅者随时都可能被调用。
Unity为什么避免使用多线程?
不只Unity,大多数游戏引擎都是单线程的,因为大多数引擎都是主循环结构,逻辑更新和画面更新的时间点要求有确定性,如果在逻辑更新和画面更新中引入多线程,就需要做同步而这加大了游戏性程序员(编程关卡的程序员)的开发难度。所以需要异步功能的时候,游戏引擎总是倾向于使用time slicing的策略而不是使用多线程,Unity中的协程(coroutine)yield语法本质就是time slicing。但是多线程也是有好处的,如果不是画面更新,也不是常规的逻辑更新(指包括AI、物理碰撞、角色控制这些),而是一些其他后台任务,比如网络传输,则可以将这个独立出来做成一个工作线程,这需要写Unity游戏的Native扩展。
委托操作符
为了合并Thermostat例子中的两个订阅者,要使用+=操作符。这样会获取第一个委托,并将第二个委托添加到委托链中。第一个委托的方法返回后,会调用第二个委托。从委托链中删除委托,则要使用-=操作符,如代码清单6所示。
代码清单6 使用+=和-=委托操作符
1 | // ... |
代码清单6的输出结果如下:1
2
3
4
5Invoke both delegates:
Heater: Off
Cooler: On
Invoke only delegate2
Cooler: Off
除此之外,还可以使用+和-操作符来合并委托,如代码清单7所示。
代码清单7 使用+和-委托操作符
1 | Thermostat thermostat = new Thermostat(); |
代码清单7的输出结果如下:1
2
3
4
5Combine delegates using + operator:
Heater: Off
Cooler: Off
Uncombine delegates using - operator:
Cooler: Off
使用赋值操作符会清除之前的所有订阅者,并允许用新订阅者替换它们。这是委托很容易让人犯错的一个设计,因为在本来应该使用+=操作符的时候,很容易就会错误地写成=。解决这个问题的良方是事件,详情将在稍后讲述。
应注意的是,无论+、-还是它们的复合赋值版本(+=和-=),在内部都是使用静态方法System.Delegate.Combine()和System.Delegate.Remove()来实现的。两个方法都获取delegate类型的两个参数。第一个方法Combine()会连接两个参数,将两个委托的调用列表按顺序连接到一起。第二个方法Remove()则搜索由第一个参数指定的委托链,删除由第二个参数指定的委托。
对于Combine()方法,一个有趣的地方在于,它的两个参数都可以为null,如果其中任何一个参数为null,Combine()就返回非空的那个。如果两个都为null,则Combine()返回null。这就解释了为什么可以调用thermostat.OnTemperatureChange += heater.OnTemperatureChanged;而不引发异常(即使thermostat.OnTemperatureChange尚未赋值)。
顺序调用
图1展示了heater和cooler的顺序通知。
虽然代码中只包含一个简单的OnTemperatureChange()调用,但这个调用会广播给两个订阅者,使cooler和heater都会收到温度发生变化的通知。假如添加更多的订阅者,它们也会收到OnTemperatureChange()的通知。
虽然一个OnTemperatureChange()调用造成每个订阅者都收到通知,但它们仍然是顺序调用的,而不是同时调用,因为它们全都在一个执行线程上调用。
高级主题:多播委托的内部机制
为了理解事件是如何工作的,你需要回顾第12章中我们第一次探讨System.Delegate类型的内部机制的部分。delegate关键字是派生自System.MulticastDelegate的一个类型的别名。System. MulticastDelegate则是从System. Delegate派生的,后者由一个对象引用(以满足非静态方法的需要)和一个方法引用构成。创建委托时,编译器自动使用System.MulticastDelegate类型而不是System. Delegate类型。MulticastDelegate类包含一个对象引用和一个方法引用,这和它的Delegate基类一样。但除此之外,它还包含对另一个System.MulticastDelegate对象的引用。
向多播委托添加方法时, MulticastDelegate类会创建委托类型的一个新实例,在新实例中为新增的方法存储对象引用和方法引用,并在委托实例列表中添加新的委托实例作为下一项。实际上, MulticastDelegate类维护着一个Delegate对象链表。从概念上讲,可以像图13-2那样表示Thermostat的例子。
错误处理
错误处理凸显了顺序通知可能造成的问题。假如一个订阅者引发了异常,链中的后续订阅者就收不到通知。例如,假定修改Heater的OnTemperatureChanged()方法,使它引发异常,那么会发生什么?如代码清单8所示。
代码清单8 OnTemperatureChanged()引发异常
1 | class Program |
图3是更新过的顺序图。
虽然cooler和heater已进行了订阅来接收消息,但Lambda表达式异常会使链发生中断,造成cooler对象收不到通知。
为了避免这个问题,使所有订阅者都能收到通知(不管之前的订阅者有过什么行为) ,必须手动遍历订阅者列表,并单独调用它们。代码清单9展示了需要在CurrentTemperature属性中进行的更新。
图3 委托调用顺序图(已添加异常)
代码清单9 处理来自订阅者的异常
1 | class Thermostat |
这个代码清单演示了你可以从委托的GetInvocationList()方法获得一份订阅者列表。枚举该列表中的每一项,可以返回给单独的订阅者。如果随后将每个订阅者调用都放到一个try/catch块中,就可以先处理好任何出错的情形,再继续循环迭代。在这个例子中,尽管委托侦听者(delegate listener)引发了一个异常,但cooler仍会接收到温度发生改变的通知。所有通知都发送完毕之后,代码清单9通过引发一个AggregateException来报告所有已发生的异常。AggregateException包装了一个异常集合。集合中的异常可以通过InnerExceptions属性访问。用这种方法,所有异常都得到报告,同时所有订阅者都不会错过通知。
方法返回值和传引用
还有一种值得注意的情形,在这种情形下,也有必要遍历委托调用列表,而非直接激活一个通知。这种情形涉及的委托要么不返回void,要么有一个ref或out参数。在Thermostat例子中,OnTemperatureChange委托是Action\
假如修改OnTemperatureChange,让它不是返回void,而是返回枚举值,指出设备是否因温度的改变而启动,那么新委托就是Func\
事件
到目前为止,使用的委托都存在两个关键的问题。C#使用关键字event(事件)来解决这些问题。本节描述了如何使用事件,以及它们是如何工作的。
事件的作用
本章前面已全面描述了委托是如何工作的。然而,委托结构中存在的缺陷可能造成程序员在不经意中引入一个bug。这个问题和封装有关,无论事件的订阅还是发布,都不能得到充分的控制。
1.封装订阅
如前所述,可以使用赋值操作符将一个委托赋给另一个。遗憾的是,这同时可能造成bug。来看看代码清单10的例子。
代码清单10 错误地使用赋值操作符=而不是+=
1 | class Program |
代码清单10的输出结果如下:1
2
3Enter temperature:
// 输入20
Cooler: Off
代码清单10和代码清单6如出一辙,只是它不是使用+=操作符,而是使用一个简单赋值操作符。其结果就是,当代码将cooler.OnTemperatureChanged赋给OnTemperatureChange时,heater.OnTemperatureChanged会被清除,因为一个全新的委托链替代了之前的链。在本该使用+=操作符的地方使用了赋值操作符“=”,由于这是一个十分容易犯的错误,所以最好的解决方案就是仅为包容类内部的对象提供对赋值操作符的支持。event关键字的作用就是提供额外的封装,避免不小心地取消其他订阅者。(加了event后如果发现“=”给事件赋值会直接报错,如下图所示。而不加event不会报错,这样强制报错可以避免把+=写成=)。
2.封装发布
委托和事件的第二个重要区别在于,事件确保只有包容类才能触发事件通知。来看看代码清单11的例子。
代码清单11 从事件包容者的外部触发事件
1 | class Program |
代码清单11的输出结果如下:1
2Heater: On
Cooler: Off
在代码清单11中,即使thermostat的CurrentTemperature没有发生改变,Program也能调用OnTemperatureChange委托。因此,Program触发了对所有thermostat订阅者的一个通知,告诉它们温度已发生改变,而实际上thermostat的温度并没有变化。和之前一样,委托的问题在于封装不充分。Thermostat应禁止其他任何类调用OnTemperatureChange委托。
事件的声明
C#用event关键字解决了上述两个问题。虽然看起来像是一个字段修饰符,但event定义的是一个新的成员类型,如代码清单12所示。
代码清单12 为Event-Coding(事件-编码)模式使用event关键字
1 | public class Thermostat |
这个新的Thermostat类进行了4处修改。首先,OnTemperatureChange属性被移除了。OnTemperatureChange被声明为一个public字段。从表面看,这似乎并不是在解决早先描述的封装问题。现在需要的是增强封装,而不是让一个字段变成public字段来削弱封装。然而,我们进行的第二处修改是在字段声明之前添加event关键字。这一处简单的修改提供了所需的全部封装。添加event关键字后,会禁止为一个public委托字段使用赋值操作符(比如thermostat.OnTemperaturechange = cooler.OnTemperatureChanged)。除此之外,只有包容类才能调用向所有订阅者发出通知的委托(例如,不允许在类的外部执行thermostat.OnTemperatureChange(42))。换言之,event关键字提供了必要的封装来防止任何外部类发布一个事件或者取消之前不是由其添加的订阅者。这样,就完美地解决了普通委托存在的两个问题,这是在C#中提供event关键字的关键原因之一。
普通委托的另一个不利之处在于,很容易忘记在调用委托之前检查null值。这会引发一个非预期的NullReferenceException异常。幸好,通过event关键字提供的封装,可以在声明时(或者在构造器中)采用一个替代方案,如代码清单12所示。注意在声明事件时,我们赋的值是delegate{},它是一个空委托,代表由零个侦听者构成的一个集合。通过赋值一个空委托,就可以引发事件而不必检查是否有任何侦听者。(这个行为类似于向变量赋一个包含零个元素的数组。然后,在调用一个数组成员时,就不必先检查变量是否为null。)当然,如果委托存在被重新赋值为null的任何可能,那么仍需进行null值检查。不过,由于event关键字限制赋值只能在类的内部发生,所以要重新对委托进行赋值,只能在类中进行。如果从未在类中赋过null值,就不必在代码每次调用委托时检查null。
编码规范
为了获得希望的功能,唯一要做的就是将原始委托变量声明更改为字段,然后添加event关键字。进行了这两处修改之后,就可以提供全部必要的封装。与此同时,其他所有功能都和以前一样。然而,在代码清单12中,委托声明还进行了另一处修改。为了遵循标准的C#编码规范,要将Action\
代码清单13 声明一个泛型委托类型
1 | public delegate void EventHandler<TEventArgs> (object sender, TEventArgs e) where TEventArgs: EventArgs; |
结果是Action
第一个参数sender应包含调用委托的那个类的实例。假如一个订阅者方法注册了多个事件,这个参数就尤其有用。例如,假定两个Thermostat实例都订阅了heater.OnTemperatureChanged事件。在这种情况下,任何一个Thermostat实例都可能触发对heater.OnTemperatureChanged的调用。为了判断具体是哪个Thermostat实例触发了事件,要在Heater.OnTemperatureChanged()内部利用sender参数进行判断。如果事件是静态的,就无法做这种判断,所以要为sender传递null值作为实参。
第二个参数TEventArgse是Thermostat.TemperatureArgs类型。关于TemperatureArgs,一个重点在于它是从System.EventArgs派生的。(事实上,一直到.NET Framework 4.5,都通过一个泛型约束来强制从System.EventArgs派生。)System.EventArgs唯一重要的属性是Empty,它用于指出不存在事件数据。然而,从System.EventArgs派生出TemperatureArgs时添加了一个额外的属性,名为NewTemperature,用于将温度从自动调温器传递给订阅者。
这里简单总结一下事件的编码规范:第一个参数sender是object类型的,它包含对调用委托的那个对象的一个引用(静态事件则为null)。第二个参数是System.EventArgs类型的(或者从System.EventArgs派生,但包含了事件的附加数据)。调用委托的方式和以前几乎完全一样,只是要提供附加的参数。代码清单14展示了一个例子。
代码清单14 触发事件通知
1 | class Thermostat |
通常将sender指定为容器类(this),因为它是唯一个能为事件调用委托的类。
在这个例子中,订阅者可以将sender参数转型为Thermostat,并以那种方式访问当前温度,或者通过TemperatureArgs实例来访问。然而,Thermostat实例上的当前温度可能由一个不同的线程改变。在由于状态改变而发生事件的时候,连同新值传递前一个值是常见的编程模式,它可以控制哪些状态变化是允许的。
规范
要在调用委托前检查它的值不为null。
不要为非静态事件的sender传递null值。
要为静态事件的sender传递nul1值。
不要为eventArgs传递null值。
要为事件使用EventHandler委托类型。
要为TEventArgs使用5ystem.EventArgs类型或者它的派生类型。
考虑使用System.EventArgs的子类作为事件的实参类型(TEventArgs),除非完全确定事件永远不需要携带任何数据。
泛型和委托
上一节指出,为事件定义类型的规范是使用EventHandler\
代码清单15 使用一个自定义委托类型
1
2
onTemperatureChange()。其中, OnTemperatureChange后缀是从原始事件名称中截取的。这两个方法分别负责实现“+=”和”—“赋值操作符。如代码清单13-17所示,这两个方法是使用本章前面讨论的静态方法system. Delegate. Combine ()和System. Delegate.Remove()来实现的。传给方法的第一个参数是私有的EventHandler<TemperatureArgs)委托实例OnTemperatureChange。
在从event关键字生成的代码中,或许最奇怪的就是最后一部分。其语法与属性的取值和赋值方法非常相似,只是方法名变成了add和remove,其中, add块负责处理”+=”操作符,将调用传给addonTemperaturechange()。类似地, remove块处理“-”操作符,将调用传给remove OnTemperatureChange()。
必须重视这段代码与属性代码的相似性。本书前面讲过, C#在实现一个属性时,会创建get_
另外要注意,在最终的CIL代码中,仍然保留了event关键字。换言之,事件是CIL代码能够显式识别的一样东西,并非只是一个C#构造。在CIL代码中保留一个等价的event关键字之后,所有语言和编辑器都能将事件识别为一个特殊的类成员,并正确地处理它。
自定义事件的实现
编译器为+=和-=生成的代码是可以自定义的。例如,假定改变OnTemperatureChange委托的作用域,使它成为protected而不是private。这样一来,从Thermostat派生的类也能直接访问委托,而无需受到和外部类一样的限制。为此,C#允许使用和代码清单15一样的属性语法。换言之,C#允许添加自定义的add和remove块,为事件封装的各个组成部分提供你自己的实现。代码清单18展示了一个例子。
代码清单18 自定义add和remove处理程序
1 |
在这个例子中,存储每个订阅者的委托OnTemperatureChange变成了protected。除此之外,在add块的实现中,我们交换了两个委托存储的位置,使添加到链中的最后一个委托是接收通知的第一个委托。
小结
本章描述了事件,值得一提的是,通常,方法引用是唯一可以在事件上下文的外部用到委托变量的情况。换句话说,由于事件提供了额外的封装性,而且允许在必要时对实现进行自定义,所以最佳做法就是始终为Observer模式使用事件。
可能需要一段时间的练习,才能脱离示例代码,熟练地进行事件的编程。然而,事件编程正是以后要讲述的异步、多线程编程的重要基础。