C# 委托与事件的关系-下(事件)

2020-12-20 10:34

阅读:778

标签:事件通知   记录   地址   新建   readline   继承   外部类   只读   目标   

参考资料:
《C# 7.0本质论》14.2
《C# 7.0核心技术指南》4.2
官方文档:https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/events/

委托存在的问题

  • 错误使用赋值符号
  • 其它类也能触发事件通知

声明事件
事件的工作机制
事件使用案例
标准事件模式

  • 泛型委托
  • 非泛型委托

事件访问器
事件的修饰符
题外话

委托存在的问题

错误使用赋值符号

如果给一个委托对象追加订阅者,本应用+=号,结果用成了=号,则表面上会清空委托对象的所有目标方法,即清空所有订阅者。内部原理是使用新委托代替旧委托,一个全新的委托链替代了之前的链,新委托链只有刚刚被赋予的一个目标方法。委托+=与=的原理在之前的博客里已经讨论过了。

最好的解决方案是根本不要为包容类外部的对象提供对赋值操作符(=)的支持。event关键字的作用就是提供额外的封装,避免不小心取消了其他订阅者。

事件实现了对订阅的封装

其它类也能触发事件通知

class Program
{
    public static void Main()
    {
        Thermostat thermostat = new Thermostat();
        Heater heater = new Heater(60);
        Cooler cooler = new Cooler(80);
        string temperature;

        thermostat.OnTemperatureChanged += heater.OnTemperatureChanged;
        thermostat.OnTemperatureChanged += cooler.OnTemperatureChanged;

        // 暂时注释掉之前正确的操作
        // Console.Write("Enter temperature: ");
        // temperature = Console.ReadLine();
        // thermostat.CurrentTemperature = int.Parse(temperature);

        // 直接调用thermostat委托对象的OnTemperatureChanged方法
        // 而此时thermostat.CurrentTemperature并没有发生变化,这不符合我们的需求
        thermostat.OnTemperatureChanged(42);
    }
}

结合一下我的上一篇博客或者《C# 7.0本质论》14.1的发布者-订阅者案例,看一下上面的代码,我们实际上期望只有thermostat.CurrentTemperature改变,即当前温度改变的时候才触发thermostat.OnTemperatureChanged(),来通知Heater和Cooler,但我们可以做到直接从Program类中调用thermostat.OnTemperatureChanged()方法来发布事件,通知订阅者,告诉它们温度发生变化。而事实上thermostat的温度没有变化。和之前一样,委托的问题在于封装不充分。Thermostat应禁止其他任何类调用OnTemperatureChange委托。

事件实现了对发布的封装

声明事件

使用event关键字,可以解决上面提到的两个问题。《C# 7.0本质论》中的例子很难让我这种初学者理解,所以这里使用《C# 7.0核心技术指南》中的例子。

声明事件最简单的方式是在委托对象的前面加上event关键字:

// 定义一个委托
public delegate void PriceChangedHandler(decimal oldPrice, decimal newPrice);

public class Broadcaster
{
    // 声明事件
    public event PriceChangedHandler PriceChanged;
}

在字段声明前添加event关键字。这一处简单的修改提供了所需的全部封装。

类Broadcaster中的代码对PriceChanged有完全的访问权限,并可以将其视为委托。而类Broadcaster之外的代码则仅可以在PriceChanged事件上执行+=和-=运算。Awesome!

event关键字提供了必要的封装来防止任何外部类发布一个事件或删除之前不是由其添加的订阅者。这样就完美解决了普通委托存在的两个问题

事件的工作机制

public class Broadcaster
{
    // 声明事件
    public event PriceChangedHandler PriceChanged;
}

声明上面的事件时,首先编译器将事件的声明翻译成如下形式:

PriceChangedHandler priceChanged; // 私有委托

public event PriceChangedHandler PriceChanged
{
    add => priceChanged += value; 
    remove => priceChanged -= value; 
}

add和remove关键字明确了事件的访问器,就像属性的访问器那样。
编译器在Broadcaster类里面找到除了调用+=和-=之外的priceChanged引用点,并将它们重定向到内部的priceChanged委托字段。
最后,编译器对事件上的+=和-=运算符操作相应地调用事件的add或remove访问器。当应用于事件时,+=和-=的行为是唯一的,而不像其他的情况下是+和-运算符与赋值运算符的简写。

到这里我有点看不懂,什么是add和remove访问器?官方文档:https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/add

add 上下文关键字用于定义一个在客户端代码订阅你的事件时调用的自定义事件访问器。 如果提供自定义 add 访问器,还必须提供 remove 访问器。

上面代码的add和remove是只用event关键字来声明事件时,自动生成的事件访问器。我们也可以对时间访问器进行显式自定义,可以在委托的存储和访问上进行更复杂的操作。见自定义事件访问器。

事件使用案例

该案例来自《C# 7.0核心技术指南》4.2,我对代码进行了稍许改动。

在Stock类中,每当Stock的Price属性发生变化时,PriceChanged事件就会触发:

public delegate void PriceChangedHandler(decimal oldPrice, decimal newPrice);

public class Stock
{
    private string symbol;
    private decimal price;
    public Stock(string symbol)
    {
        this.symbol = symbol;
    }

    // 声明事件
    public event PriceChangedHandler PriceChanged;

    public decimal Price
    {
        get => price;
        set
        {
            if (price == value) return;
            decimal oldPrice = price;
            price = value;
            PriceChanged?.Invoke(oldPrice, price);
        }
    }
}

本例中,如果将PriceChanged的event关键字去掉,就变成了普通的委托字段,虽然运行结果是不变的,但是Stock类就没有原来健壮了。因为这时订阅者可以通过以下方式相互影响:

  • 通过重新指派PriceChanged替换其他的订阅者(不用+=运算符,用=运算符等)。
  • 清除所有的订阅者(将PriceChanged设置为null)。
  • 通过调用其委托广播到其他的订阅者。

这三条实际上反映的就是委托的两大问题。

标准事件模式

在.NET类库中,事件基于EventHandler委托和EventArgs基类,它们都属于System命名空间。

.NET为事件编程定义了一个标准模式,目的就是保持框架代码和用户代码的一致性。标准事件模式的核心是System.EventArgs基类,它是一个预定义的没有成员(但是有一个静态的Empty属性)的类。EventArgs是为事件传递信息的基类。

在Stock示例中,我们可以继承EventArgs基类,以便在PriceChanged事件触发时同时传递新的和旧的Price值。我们新建一个继承了EventArgs基类的EventArgs子类,注意子类的命名方式以EventArgs结尾,这是为了复用性:

public class PriceChangedEventArgs: EventArgs
{
    public readonly decimal LastPrice;
    public readonly decimal NewPrice;

    public PriceChangedEventArgs(decimal lastPrice, decimal newPrice)
    {
        LastPrice = lastPrice;
        NewPrice = newPrice;
    }
}

EventArgs子类一般将数据以属性或只读字段的方式暴露给外界,如该类的public的只读属性LastPrice和NewPrice。

EventArgs子类就位后,下一步就是选择或者定义事件的委托了。这一步需要遵循三条规则:

  • 委托返回值类型必须为void。
  • 委托必须接受两个参数,第一个参数是object类型,第二个参数则是EventArgs的子类。第一个object参数表明了事件的广播者,第二个参数则包含了需要传递的额外信息。
  • 委托的名称必须以EventHandler结尾。

上面提到的System命名空间下的EventHandler委托满足这三个条件。下面是该委托的定义:

// 泛型出现之前的自定义委托
public delegate void EventHandler(object? sender, EventArgs e);

// 泛型出现之后的泛型委托
public delegate void EventHandler(object? sender, TEventArgs e) where TEventArgs: EventArgs;

泛型委托

接下来我们在Stock类中定义选定委托类型的事件。使用泛型的EventHandler委托:

public class PriceChangedEventArgs: EventArgs
{
    // ...
}

// public delegate void PriceChangedHandler(decimal oldPrice, decimal newPrice);

public class Stock
{
    private string symbol;
    private decimal price;
    public Stock(string symbol)
    {
        this.symbol = symbol;
    }

    // public event PriceChangedHandler PriceChanged;

    // 使用泛型EventHandler委托定义选定委托类型的事件
    public event EventHandler PriceChanged;

    // ...
}

此时下方public decimal Price中的set方法会报错,先不用管。

我们的标准事件模式需要编写一个protected的虚方法来触发事件。按照规范,也方便后续维护,该方法名必须和事件名称一致,以On作为前缀,并接收唯一的EventArgs参数:

public event EventHandler PriceChanged;

protected virtual void OnPriceChanged (PriceChangedEventArgs e)
{
    PriceChanged?.Invoke(this, e); // ?.是null条件运算符,保证了多线程情形下的线程安全
}

这样就提供了一个子类可以调用或重写事件的关键点(假如不是密封类的话)。

下面是完整代码:

public class PriceChangedEventArgs: EventArgs
{
    public readonly decimal LastPrice;
    public readonly decimal NewPrice;

    public PriceChangedEventArgs(decimal lastPrice, decimal newPrice)
    {
        LastPrice = lastPrice;
        NewPrice = newPrice;
    }
}

public class Stock
{
    private string symbol;
    private decimal price;
    public Stock(string symbol)
    {
        this.symbol = symbol;
    }

    public event EventHandler PriceChanged;

    protected virtual void OnPriceChanged(PriceChangedEventArgs e)
    {
        PriceChanged?.Invoke(this, e); // ?.是null条件运算符,保证了多线程情形下的线程安全
    }

    public decimal Price
    {
        get => price;
        set
        {
            if (price == value) return;
            decimal oldPrice = price;
            price = value;
            // 此处调用我们刚刚实现的用来触发事件的方法即可
            OnPriceChanged(new PriceChangedEventArgs(oldPrice, Price));
        }
    }
}

public class Test
{
    static void stock_PriceChanged(object sender, PriceChangedEventArgs e)
    {
        if ((e.NewPrice - e.LastPrice) / e.LastPrice > 0.1M)
        {
            Console.WriteLine("Alert, 10% stock price increase!");
        }
    }

    public static void Main()
    {
        Stock stock = new Stock("THPW");
        stock.Price = 27.10M;

        // 注册到事件里
        stock.PriceChanged += stock_PriceChanged;
        stock.Price = 31.59M;
    }
}

运行结果:

技术图片

可以看到在Test类里,我们实现了一个stock_PriceChanged方法并注册到了stock.PriceChanged事件里。当stock的Price发生变化时,会调用该方法。该方法内部判断股票涨幅超过10%则提示,否则什么都不做。

这个方法在实际运行的时候在哪里被调用呢?还记得之前的委托吗?就是在Stock类的Price属性的set访问器里的OnPriceChanged(new PriceChangedEventArgs(oldPrice, Price))。oldPrice和Price也不需要我们在使用Stock对象的时候来手动记录,Stock类本身已经帮我们记录,我们使用的时候只管赋予新Price便是。

注意stock_PriceChanged方法的参数,第一个object sender指的是发布者,也就是Stock类对象stock,第二个PriceChangedEventArgs e是我们自定义的继承了EventArgs基类的EventArgs子类。看一下Stock类中的OnPriceChanged方法,这个方法中PriceChanged?.Invoke(this, e),this指的是Stock类对象,e就是OnPriceChanged(PriceChangedEventArgs e)的参数,在Price的set访问器中调用OnPriceChanged()时进行的传参。PriceChanged事件触发的时候,向所有订阅者发送Price被改变的消息,stock_PriceChanged(object sender, PriceChangedEventArgs e)就是一个订阅者,所以stock_PriceChanged()的第二个参数被传进来的也就是前面OnPriceChanged(PriceChangedEventArgs e)的参数e。

非泛型委托

假设不需要传递额外的信息(在本例中,oldPrice就是额外的信息),即我们不需要比较Price的变化,只需要在Price更新时做一些其他的不需要额外信息的操作的时候,就不必使用泛型委托EventHandler,可以使用预定义的非泛型委托EventHandler

重写Stock类,当Price属性发生变化时,触发PriceChanged事件,事件除了传达已发生的消息之外没有必须包含的信息。

没有oldPrice了,所以不必再创建非必要的EventArgs实例,我们使用了EventArgs.Emtpy属性:

public class Stock
{
    private string symbol;
    private decimal price;
    public Stock(string symbol)
    {
        this.symbol = symbol;
    }

    // 使用非泛型委托
    public event EventHandler PriceChanged;

    protected virtual void OnPriceChanged(EventArgs e)
    {
        PriceChanged?.Invoke(this, e);
    }

    public decimal Price
    {
        get => price;
        set
        {
            if (price == value) return;
            price = value;
            // 传入EventArgs.Empty属性
            OnPriceChanged(EventArgs.Empty);
        }
    }
}

事件访问器

C#的默认add和remove访问器在前面已经提过,但是C#还使用了无锁的比较并交换算法,保证了在更新委托时的线程安全性。

官方文档也建议自定义事件访问器时先加锁锁定事件,再添加或删除新的事件处理程序方法。代码如下:

event EventHandler IDrawingObject.OnDraw
{
    add
    {
        lock (objectLock)
        {
            PreDrawEvent += value;
        }
    }
    remove
    {
        lock (objectLock)
        {
            PreDrawEvent -= value;
        }
    }
}

官方文档地址:https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/events/how-to-implement-custom-event-accessors

显式定义事件访问器,可以在委托的存储和访问上进行更复杂的操作。这主要有三种情形:

  • 当前事件访问器仅仅是广播事件的类的中继器。
  • 当类定义了大量的事件,而大部分事件有很少的订阅者,例如Windows控件。在这种情况下,最好在一个字典中存储订阅者的委托实例。这是因为字典比大量的空委托字段的引用的存储开销更少。
  • 当显式实现声明事件的接口时。

事件的修饰符

和方法类似,事件可以是虚的(virtual)、可以重写(overridden)、可以是抽象的(abstract)或者密封的(sealed),也可以是静态的。

public class Foo
{
    public static event EventHandler StaticEvent;
    public virtual event EventHandler virtualEvent;
}

题外话

因为我也是初次了解委托与事件,我的学习笔记写的东一嘴西一嘴乱七八糟,希望读者们海涵。如果您无法理解这些内容,看书和官方文档或许是更好的选择:

参考资料:
《C# 7.0本质论》14.2
《C# 7.0核心技术指南》4.2
官方文档:https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/events/

C# 委托与事件的关系-下(事件)

标签:事件通知   记录   地址   新建   readline   继承   外部类   只读   目标   

原文地址:https://www.cnblogs.com/Kit-L/p/13872442.html

上一篇:TF2目标检测API

下一篇:C#:类的继承


评论


亲,登录后才可以留言!