C#学习笔记(第3期):事件与事件驱动

C#——事件与event关键字

1.事件和事件驱动

“事件”不是C#中的功能,而是源于一种程序架构:事件驱动
事件驱动指的是这样一种程序模式:
【当某种事件发生时,自动触发并执行该事件的响应程序,而不需要一直观测并判断该事件是否发生。】

为什么程序中需要引入事件驱动模式?


2.实例:采用事件驱动的好处

来看一个例子。

假设小明正使用一个水壶来烧水,他需要知道水何时烧开,并在水烧开后及时关火。
要实现这一需求,有两种不同的策略:

第一种策略:小明揭开壶盖,观察壶中的水是否沸腾。不断重复这一动作,直至观察到水烧开为止,然后关火;
第二种策略:小明为水壶加装一个蜂鸣器,它会在水温达到100℃时发出"嘀嘀嘀"提示音。之后,小明可以躺在沙发上看电视,无需关注水壶,只要在听到提示音时关火即可。

很明显,第二种策略是好策略,而第一种策略则是笨拙、低效的,会产生大量不必要的判断劳动。


第一种策略的代码如下:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Boiling
{
    class MainClass
    {
        public class Boiler //定义水壶(Boiler)类 
        {
            public int temp = 90;  //水壶的初始水温为90℃

           //指令:开始烧水
           //水温初始为90℃,每秒上升1℃,直至到达100℃
            public async void StartBoiling()
            {
                Console.WriteLine("开始烧水");
                await Task.Run(() =>
                {
                    while (temp < 100)
                    {
                        Thread.Sleep(1000);
                        temp += 1;
                        Console.WriteLine("水温--" + temp.ToString() + "℃");
                    }
                }); 
            }
        }

        static void Main(string[] args)
        {
            Boiler boiler = new Boiler();//创建水壶
            boiler.StartBoiling();//开始烧水,水温开始以1℃/秒的速率上升

            //主循环
            //不断对水温进行检测;如果水温达到100度,则关火
            while(true)
            {
                if (boiler.temp >= 100)
                {
                    Console.WriteLine("关火!");
                    break;
                }

                Thread.Sleep(10); 
                //这里设定每两次主循环之间有10毫秒的间隔,也就是说主循环的检测率是100Hz
                //这句话不能省略!如果不设置此间隔,CPU将倾尽全力,无数次地执行while(true)循环,这将导致死机
            }

            Console.ReadLine();
        }        
    }
}

运行结果如下:

-------------------------------------------------------

在上述代码中,while(true)与Thread.Sleep(10)(即等待10毫秒)组合使用,形成了一个主循环结构:在MainClass中,主循环(小明)会以100Hz的频率来反复检测水壶,判定水温是否达到100℃。
为了测试方便,上面的程序中设定水只需要10秒就能烧开;
但很明显,如果水烧开的时间较为漫长,那么此种策略是极度浪费CPU资源的。即使水还远远没有烧开,主循环仍然会片刻不停地反复检测水温。


下面,来看第二种策略的代码。

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Boiling
{
    public class Boiler//定义水壶(Boiler)类
    {
        public delegate void Boiled();
        public Boiled OnBoilingCallBack = null;//这是水烧开时将会触发的事件

        private int _temp = 90;//初始水温为90℃
        public int temp
        {
            get { return _temp; }

            //为水温temp添加写入事件,类似于为水壶加装“蜂鸣器”
            set
            {
                _temp = value;
                //每当水温temp的数值发生改变时,都会执行一个判断;如果水温已达到或超过100℃,则播发"嘀嘀嘀"提示,然后触发OnBoilingCallBack事件
                if (_temp >= 100)
                {
                    Console.WriteLine("嘀嘀嘀");
                    OnBoilingCallBack();
                }
            }
        }

        //指令:开始烧水
        //水温初始为90℃,每秒上升1℃,直至到达100℃
        public async void StartBoiling()
        {
            Console.WriteLine("开始烧水");
            await Task.Run(() =>
            {
                while (temp < 100)
                {
                    Thread.Sleep(1000);
                    temp += 1;
                    Console.WriteLine("水温--" + temp.ToString() + "℃");
                }
            });
        }
    }

    class MainClass
    {
        static void Main(string[] args)
        {
            Boiler boiler = new Boiler();
            boiler.OnBoilingCallBack += (() => { Console.WriteLine("关火!"); });//对水壶的OnBoilingCallBack事件进行解释:触发这个事件时打印出“关火”

            boiler.StartBoiling();
            Console.WriteLine("小明去看电视啦!");
            Console.ReadLine();
        }        
    }
}

输出:

-------------------------------------------------------

与第一种策略相比,第二种策略带来的区别显而易见;主程序中的while(true)主循环消失了,这意味着小明不再因为反复检查水壶而疲于奔命;
相反,小明去看电视之后,主程序内的指令已经全部执行完毕,可是当10秒后水被烧开时,小明仍然作出了正确的"关火"响应。这是因为,水壶boiler本身已经具备了自我检查能力,使得自身水温到达100℃时,立即播发自带的OnBoilingCallBack事件。
而主线程Main方法内订阅了OnBoilingCallBack这个事件(为这个事件添加了具体的执行内容),从而使得水壶一旦播发该事件,对应的执行内容将立刻得到执行。

至此我们看到,由于使用了事件驱动架构,程序可以随时对水烧开的事件作出响应,而无需一刻不停地对水壶进行观察判断。

根据上面的例子,我们总结一下,事件驱动机制由以下6个要素来实现。
1.事件的拥有者
2.事件本身
3.事件的回调
4.事件的订阅者
5.订阅者的事件处理器(称为"监听方法"或"监听器(listener)")
6.订阅事件(订阅者为事件的回调添加监听器的过程)

下面我们梳理一下这个“烧水”案例的工作流程,来方便你理解这些要素。

(在后面的两个描述片段中,每一阶段里相同颜色的字代表相同的概念)

烧水工作流程的通俗语言描述是这样的:

(1)水壶具有蜂鸣器,可以对水温进行检测,在水温到达100℃时播发“嘀嘀嘀”声

(2)小明知道,如果听到了水壶的“嘀嘀嘀”声,就应当去关火

(3)水壶中的水温到达100℃

(4)蜂鸣器发出“嘀嘀嘀”声;

(5)小明听到“嘀嘀嘀”声并作出响应,执行关火操作。

将其转换为事件驱动的工作流程来描述,就变成了这样:

(1)在事件的拥有者内部,我们需要写入对事件是否发生的检测机制,该机制能够在事件发生时播发事件的回调;(一般来说,事件回调是一个委托实例,不确定具体的执行内容。它的具体执行方式由订阅者提供的监听函数决定)

(2)事件的订阅者为事件的回调添加监听方法,开始对该事件的收听;

(3)在事件拥有者的类内部,发生了我们关注的事件

(4)事件的拥有者自行检测到事件发生,并播发事件的回调

(5)由于事件的回调已经被解释为事件订阅者的监听方法,因此订阅者会立即对事件作出响应。

好啦,"事件"的含义、作用和实现方法到这里就讲完了,但是还留下了一个安全性隐患;这就引出了我们接下来要讲的event关键字。

3.event关键字

事件虽好,但还有一个隐患存在。当一个事件回调被播发出来的时候,我们有一个疑问:
【事件回调被播发出来,是否证明事件一定发生了呢?】

我们继续扩展前面的情境,来解释为何会有这样的疑问。


前面我们知道,当水壶的水烧开时,蜂鸣器发出"嘀嘀嘀"提示音(或者说触发了OnBoilingCallBack回调),提示小明应该关火。

现在假设,小明有一个调皮的女儿小红,她在水还没有烧开时,按下了蜂鸣器的电钮,强制它发出了"嘀嘀嘀"提示音。
这时,小明就受到了误导,他跑过来关火,结果发现水并没有烧开。 

于是,我们就发现了前面的事件驱动机制存在漏洞。

水壶的蜂鸣器具有电钮(public属性),这意味着它除了可以自动检测水温并发出提示音,也可以被强制控制发出提示音。

类似地,程序中水壶内部存在以下的回调函数:

public Boiled OnBoilingCallBack; 

这个回调函数除了在水温到达100℃时自动触发外,也具有普通函数的性质——它可以被外部指令随意调用。
而一旦这个回调函数在水未烧开时就被恶意调用,程序还是会傻乎乎地输出"关火",这就意味着程序受到了误导。

我们修改一下前面的MainClass,来反映这种情况:

    class MainClass
    {
        static void Main(string[] args)
        {
            Boiler boiler = new Boiler();
            boiler.OnBoilingCallBack += (() => { Console.WriteLine("关火!"); });//对水壶的OnBoilingCallBack事件进行解释:触发这个事件时打印出“关火”

            boiler.StartBoiling();
            Console.WriteLine("小明去看电视啦!");

            Thread.Sleep(2000);//在小明开始看电视2秒之后
            boiler.OnBoilingCallBack();//小红按下了蜂鸣器电钮。此时,小明必然遭到误导,从而在水未烧开时就输出"关火"

            Console.ReadLine();
        }        
    }

输出结果如下:

可以看出,小明受到了小红写入的“恶作剧代码”的误导,从而在错误的时机执行了关火操作。

如何避免这种情况呢:这时候就需要event关键字出场了。
event是一个起约束作用的关键字,它作用于一个委托实例,对该委托实例的可调用范围进行限制。

我们为了避免小红按电钮的情况,将原先的回调函数修改如下:

public Boiled OnBoilingCallBack = null;    //修改前
 =>
public event Boiled OnBoilingCallBack = null;    //修改后

这时再对原程序进行编译,发现有报错;
由"熊孩子"小红打出的恶作剧指令 boiler.OnBoilingCallBack();  编译无法通过!

现在,你能猜到event关键字的含义了吗?

【event关键字作用于一个委托实例,使得该委托实例无法在其所处类的外部被调用。】

通过给类似OnBoilingCallBack()这样的事件回调函数加上关键字event,我们就可以极大地确保这些事件回调的可信性。
当回调函数有关键字event约束时,一旦该回调函数被调用,调用它的指令必然来自事件拥有者的类内部。
这意味着,回调函数所代表的事件真真切切地发生了,而不必担心这个回调是被程序内某个地方的恶意指令“伪造”出来的。
开发者如果在程序内的某处"不小心"强制调用了事件回调函数,编译就会不通过,以此提醒开发者修改代码,以防止事件回调被伪造。

4.event作用:生动的例子

如果这样讲还是显得很晦涩,现在我用一个更好理解的例子再讲一下,这次保证你一定能听懂。

假如小红觉得自己很冷(发生了事件),她就会自己穿上棉袄(触发回调函数)

但是,小红没有觉得冷的时候,不代表她一定不会穿上棉袄——因为小红的妈妈也可以给她穿上(被伪造的回调函数)

“世界上有一种冷,叫你妈觉得你冷。”

现在,小红穿着厚厚的棉袄出门上学了,她穿上棉袄的原因可能有两种:

1.小红自己觉得冷;

2.小红的妈妈觉得她冷。

这时,我们看到小红穿着棉袄(回调函数被播发),并不能知道“小红觉得冷”这个事件是否真的发生了。因为我们没有办法核实,这个回调函数是不是从小红(事件拥有者)的类内部播发出来的。

以上情境的代码如下:

using System;

namespace Winter
{
    public class Child
    {
        private bool _cold;
        public bool FeelCold
        {
            get
            {
                return _cold;
            }
            set
            {
                _cold = value;
                if(value == true)
                {
                    JacketCallBack();
                }
            }
        }

        public Action JacketCallBack = null;//回调函数:穿棉袄

    }

    public class MainClass
    {
        static void Main(string[] args)
        {
            Child XiaoHong = new Child();
            XiaoHong.JacketCallBack += (() => { Console.WriteLine("小红穿上了棉袄"); });

            Console.WriteLine("情境1:小红觉得冷");
            XiaoHong.FeelCold = true;

            Console.WriteLine("情境2:小红的妈妈觉得她冷");
            XiaoHong.FeelCold = false;
            XiaoHong.JacketCallBack();

            Console.ReadLine();
        }
    }
}

在情境2中,小红的妈妈使用一句XiaoHong.JacketCallBack()指令,绕过了小红所属Child类的自我检测,实现了对回调函数JacketCallBack的强制播发,或者说实现了对回调函数的伪造

这样做的结果就是,在小红没有觉得冷的情况下,小红还是穿上了棉袄。

再过几年,小红长成了大孩子,自理能力变强了;现在她比原来更有主见,不需要再由妈妈帮忙决定穿什么。

于是,小红给自己的事件回调函数加上了event关键字:

public Action JacketCallBack = null;//修改前

public event Action JacketCallBack = null;//修改后

修改之后,不经小红判断而为小红强制穿衣的指令无法再编译通过了。

Console.WriteLine("情境1:小红觉得冷");
XiaoHong.FeelCold = true;

Console.WriteLine("情境2:小红的妈妈觉得她冷");
XiaoHong.JacketCallBack();//错误:无法执行本条指令,因为event不能从小红的类外部被调用

新加入的event关键字为小红的事件回调函数JacketCallBack(穿棉袄)提供了访问保护,阻止了她在小红所属的Child类外部被调用。

此时,我们就可以知道,长大后的小红出门穿什么,完全出于自己的判断(回调函数具有可信性,足以证明事件的发生)。如果再看到小红穿着棉袄,那就不会是“妈妈觉得她冷”,而一定是小红觉得冷无疑了。

猜你喜欢

转载自blog.csdn.net/qq_35587645/article/details/106628990