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