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来彻底避免。

posted @ 2020-10-25 10:29  Kit_L  阅读(266)  评论(0编辑  收藏  举报