C# 委托与事件的关系-上(委托)
参考资料:
《C# 7.0本质论》14
《C# 7.0核心技术指南》4.2
一个发布订阅模式的例子
定义订阅者
定义发布者
连接订阅者和发布者
调用委托
检查空值
存在的其他问题
事件与委托在C#的大部分书籍中都是放在一起讲的,要理解事件,首先要理解委托。本篇是从委托到事件的过度。
委托是Publish-Subscribe(发布——订阅)或者Observer(观察者)模式的基本单位。该模式可以只通过委托实现,但事件提供额外的封装,使该模式更容易实现且更不容易出错。
当使用委托时,一般会有广播者(broadcaster)和订阅者(subscriber)两种角色。广播者是包含委托字段的类型,它通过调用委托决定何时进行广播。而订阅者是方法的目标接收者(不太容易理解)。订阅者通过在广播者的委托上调用+=和-=来决定何时开始监听而何时监听结束。订阅者不知道也不会干涉其他的订阅者。
我在介绍委托的博客中写的“多播委托”概念对理解事件是重要的,单一事件(比如对象状态的改变)的通知可以使用多播委托发布给多个订阅者。
使用事件的主要目的在于保证订阅者之间不互相影响。
一个发布订阅模式的例子
本例子来自《C# 7.0本质论》14.1。
来考虑一个温度控制的例子。一个加热器(Heater)和一个冷却器(Cooler)连接到同一个恒温器(Thermostat)。控制设备开关需要向它们通知温度变化。恒温器将温度变化发布给多个订阅者——也就是加热器和冷却器。
定义订阅者
定义Heater和Cooler对象。
class Cooler
{
public Cooler(float temperature)
{
Temperature = temperature;
}
// 启动冷却器所需的温度
public float Temperature { get; }
public void OnTemperatureChanged(float newTemperature)
{
// 一旦新温度大于启动冷却器所需的温度,就启动冷却器,否则关闭冷却器
if (newTemperature > Temperature)
{
Console.WriteLine("Cooler: On");
}
else
{
Console.WriteLine("Cooler: Off");
}
}
}
class Heater
{
public Heater(float temperature)
{
Temperature = temperature;
}
// 启动加热器所需的温度
public float Temperature { get; }
public void OnTemperatureChanged(float newTemperature)
{
// 一旦新温度小于启动加热器所需的温度,就启动加热器,否则关闭加热器
if (newTemperature < Temperature)
{
Console.WriteLine("Heater: On");
}
else
{
Console.WriteLine("Heater: Off");
}
}
}
Cooler和Heater中的Temperature分别是启动冷却器和启动加热器的温度临界点。一旦新温度大于启动冷却器所需的温度,就启动冷却器,否则关闭冷却器。一旦新温度小于启动加热器所需的温度,就启动加热器,否则关闭加热器。
调用OnTemperatureChanged()方法的目的是向Heater和Cooler类指出温度已发生改变。在方法的实现中,用newTemperature同存储好的触发温度进行比较,从而决定是否让设备启动。两个OnTemperatureChanged()方法都是订阅者(或侦听者)方法,其参数和返回类型必须与来自我们即将定义的Thermostat类的委托匹配。
定义发布者
定义一个Thermostat类负责向heater和cooler对象实例报告温度变化。
public class Thermostat
{
public Action<float> OnTemperatureChanged { get; set; }
public float CurrentTemperature { get; set; }
}
OnTemperatureChanged是一个Action<float>
类型的属性,它存储了订阅者列表。一个委托字段即可存储所有订阅者。CurrentTemperature负责设置和获取Thermostat类报告的当前温度值。
连接订阅者和发布者
class Program
{
public static void Main()
{
Thermostat thermostat = new Thermostat();
Heater heater = new Heater(60); // 设定Heater的触发温度为60
Cooler cooler = new Cooler(80); // 设定Cooler的触发温度为80
string temperature;
thermostat.OnTemperatureChanged += heater.OnTemperatureChanged; // 向thermostat.OnTemperatureChanged注册订阅者heater.OnTemperatureChanged
thermostat.OnTemperatureChanged += cooler.OnTemperatureChanged; // 向thermostat.OnTemperatureChanged注册订阅者cooler.OnTemperatureChanged
Console.Write("Enter temperature: ");
temperature = Console.ReadLine();
// 从控制台接收到新CurrentTemperature后,赋值给发布者thermostat的CurrentTemperature属性
thermostat.CurrentTemperature = int.Parse(temperature);
}
}
调用委托
目前还无法从发布者那里把温度变化发布给订阅者。我们期望Thermostat类的CurrentTemperature属性每次发生变化,都调用委托向订阅者通知温度的变化。需要修改CurrentTemperature属性来保存新值,并向每个订阅者发出通知:
public class Thermostat
{
public Action<float> OnTemperatureChanged { get; set; }
// 新增私有_CurrentTempetature字段
private float _CurrentTempetature;
public float CurrentTemperature
{
get => _CurrentTempetature;
set
{
if (value != CurrentTemperature)
{
_CurrentTempetature = value;
// 通知订阅者
OnTemperatureChanged(value);
}
}
}
}
修改之后,Thermostat对象的CurrentTemperature属性接收到新值,就执行set代码块。如果新值不等于CurrentTemperature,就更新_CurrentTempetature字段,并且调用Thermostat对象的OnTemperatureChanged委托,把新值传给添加到该委托对象的目标方法们(订阅者们)。现在这个发布者-订阅者模型就勉强可以用了:
可以看到,我们使用多播委托,执行了一个调用,向多个订阅者发布了通知。Amazing!
检查空值
可以看到《C# 7.0本质论》的作者是高水平、用心且认真的,一个简单的demo也做到面面俱到,提醒我们不忘检查订阅者是否为空。
假如当前没有订阅者注册接收通知,则OnTemperatureChange为null,执行OnTemperatureChange(value)语句会抛出NullReferenceException异常。因此需在触发事件之前检查空值。
public float CurrentTemperature
{
get => _CurrentTempetature;
set
{
if (value != CurrentTemperature)
{
_CurrentTempetature = value;
// 通知订阅者
OnTemperatureChanged?.Invoke(value);
}
}
}
使用?.
空条件操作符。它采用特殊逻辑防范在执行空检查后,订阅者调用一个过时的处理程序(空检查后有新变化)造成委托再度为空。
注意,OnTemperatureChanged(value)等价于OnTemperatureChanged.Invoke(value)。
虽然一个OnTemperatureChange()调用造成每个订阅者都收到通知,但它们仍然是顺序调用的,而不是同时,因为它们全都在一个执行线程上调用。通常,委托按它们添加的顺序调用,但CLI规范并未对此做出规定,而且该顺序可能被覆盖,所以程序员不应依赖特定调用顺序。
存在的其他问题
顺序通知存在一些潜在的问题。一个订阅者引发异常,链中的后续订阅者就收不到通知。可能需要用try-catch进行一些复杂的处理。
还有一种情形需要遍历委托调用列表而非直接激活一个通知。这种情形涉及的委托要么返回非void类型,要么具有ref或out参数。调用委托可能将一个通知发送给多个订阅者。如每个订阅者都返回值,就无法确定应该使用哪个订阅者的返回值。这样就需要用Func委托。由于所有订阅者方法都要使用和委托一样的方法签名,所以都必须返回同类型值。可能还要遍历所有的订阅者的返回值。类似地,使用ref和out参数的委托类型也需特别对待。虽然极少数情况下需采取这样的做法,但一般原则是通过只返回void来彻底避免。