C# 事件和事件驱动

C#——事件与event关键字

1.事件和事件驱动

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

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

2.实例:采用事件驱动的好处
来看一个例子。

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

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

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

第一种策略的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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资源的。即使水还远远没有烧开,主循环仍然会片刻不停地反复检测水温。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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回调),提示小明应该关火。

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

1
2
3
4
5
6
7
于是,我们就发现了前面的事件驱动机制存在漏洞。

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

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

public Boiled OnBoilingCallBack;

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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是一个起约束作用的关键字,它作用于一个委托实例,对该委托实例的可调用范围进行限制。

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

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

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

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

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

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

4.event作用:生动的例子

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

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

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