委托与事件,作为C#中非常重要的一个特性,在实际的开发中也经常被使用到。举个简单的例子,Unity中给一个按钮绑定一个回调函数就是一个十分简单的应用。这里我想对其概念深入的理解一下。
回调
对于回调函数,最简单的解读,我们可以把它看作被作为参数传递的一类函数。
在正常的开发过程中,通常都是应用程序调用API调用库里所预先准备好的函数,来实现自己的功能。但这带来一个问题,有些库函数出于封装的需要,其功能通常是针对某一常见情境的,因此并不一定满足其它情境的需要。例如,对于一个封装好的排序函数,通常是讲输入的数组按照从小到大或者字典递增序进行排列。如果我们需要的功能是将元素按照一个预先设定好的大小关系进行排序(例如每个元素分别映射到一个整形数值上),则常规的API调用并不能满足我们的需要。
通过回调的机制,函数某种程度上提供了一个新的接口,即我们可以通过传入某个函数(即回调函数),来让API调用我们提供给它的函数,借此来实现特定的功能。例如上述的例子,我们将对应的映射函数作为参数传递到库函数中,告诉库函数调用回调函数来判断大小关系,而不是按照默认的排序机制。这里库函数回过头来调用了程序中的定义的函数,这也是回调这个名字的由来。
这里可以参考一个蛮形象的解释。有一家旅馆提供叫醒服务,但是要求旅客自己决定叫醒的方法。可以是打客房电话,也可以是派服务员去敲门,睡得死怕耽误事的,还可以要求往自己头上浇盆水。这里,“叫醒”这个行为是旅馆提供的,相当于库函数,但是叫醒的方式是由旅客决定并告诉旅馆的,也就是回调函数。而旅客告诉旅馆怎么叫醒自己的动作,也就是把回调函数传入库函数的动作,称为登记回调函数(to register a callback function)。如下图所示(图片来源:维基百科):
参考:回调函数(callback)是什么? - no.body的回答 - 知乎
委托
委托是一个类,它定义了方法的类型,使得可以将方法当作另一个方法的参数来进行传递,这种将方法动态地赋给参数的做法,可以避免在程序中大量使用If … Else(Switch)语句,同时使得程序具有更好的可扩展性。
换句话说,委托即回调函数在C#中的实现,通过定义一个新的变量类型(新的类)来让函数得以作为参数传递。委托的定义实际上就定义了函数的类型(参数类型,参数个数及返回类型)。
这里我们用一个简单的例子: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
43namespace C_
{
public delegate void GreetingDelegate(string name);
class Program
{
private static void EnglishGreeting(string name)
{
Console.WriteLine("Good Morning, " + name);
}
private static void ChineseGreeting(string name)
{
Console.WriteLine("早上好, " + name);
}
private static void GreetPeople(string name, GreetingDelegate MakeGreeting)
{
MakeGreeting(name);
}
static void Main(string[] args)
{
GreetingDelegate gDelegate;
gDelegate = EnglishGreeting;
gDelegate += ChineseGreeting;
// GreetPeople("Liker", gDelegate);
gDelegate("Lin");
gDelegate -= EnglishGreeting;
gDelegate("Lin");
Console.ReadLine();
}
}
}
事件
在上述程序中,我们将委托声明成一个公有变量,因此客户端可以对它进行随意的赋值等操作,这严重破坏了对象的封装性。
而事件封装了委托类型的变量,使得:在类的内部,不管你声明它是public还是protected,它总是private的。在类的外部,注册“+=”和注销“-=”的访问限定符与你在声明事件时使用的访问符相同。
同时,事件还能限制含有事件的类型的能力。一个公有委托变量能被客户端调用,而事件只能被服务端(即定义了该事件的类)调用,客户端调用会发生编译错误。
事件的定义只需要在委托的基础上通过event关键字申明即可:
1 | public delegate void GreetingDelegate(string name); |
委托的编译代码
假设我们在类中定义了一个上述事件,OnGreeting虽然被声明为一个公有变量,但实际上它会被编译成一个私有字段,并生成两个方法add_OnGreeting()和remove_OnGreeting()分别用于注册委托类型的方法和取消注册,这两个方法同时也对应了“+=”和“-=”两个运算符。
在add_OnGreeting()方法内部,实际上调用了System.Delegate 的Combine()静态方法,这个方法用于将当前的变量添加到委托链表中。
.NET 框架中的委托和事件
我们用一个热水壶的例子。
假设我们有个高档的热水器,我们给它通上电,当水温超过95 度的时候:1、扬声器会开始发出语音,告诉你水的温度;2、液晶屏也会改变水温的显示,来提示水已经快烧开了。
现在我们需要写个程序来模拟这个烧水的过程,我们将定义一个类来代表热水器,我们管它叫:Heater,它有代表水温的字段,叫做 temperature;当然,还有必不可少的给水加热方法 BoilWater(),一个发出语音警报的方法 MakeAlert(),一个显示水温的方法,ShowMsg()。
Observe模型
Observer 设计模式中主要包括如下两类对象:
- Subject:监视对象,它往往包含着其他对象所感兴趣的内容。在本范例中,热水器就是一个监视对象,它包含的其他对象所感兴趣的内容,就是 temprature 字段,当这个字段的值快到100 时,会不断把数据发给监视它的对象。
- Observer:监视者,它监视Subject,当 Subject 中的某件事发生的时候,会告知Observer,而Observer 则会采取相应的行动。在本范例中,Observer 有警报器和显示器,它们采取的行动分别是发出警报和显示水温。
.NET框架的编码规范
- 委托类型的名称都应该以 EventHandler 结束。
- 委托的原型定义:有一个void 返回值,并接受两个输入参数:一个Object 类型,一个EventArgs 类型(或继承自EventArgs)。
- 事件的命名为委托去掉 EventHandler 之后剩余的部分。
- 继承自 EventArgs 的类型应该以EventArgs 结尾。
再做一下说明:
- 委托声明原型中的Object 类型的参数代表了Subject,也就是监视对象,在本例中是Heater(热水器)。回调函数(比如Alarm 的MakeAlert)可以通过它访问触发事件的对象(Heater)。
- EventArgs 对象包含了Observer 所感兴趣的数据,在本例中是temperature。
上面这些其实不仅仅是为了编码规范而已,这样也使得程序有更大的灵活性。比如说,如果我们不光想获得热水器的温度,还想在Observer 端(警报器或者显示器)方法中获得它的生产日期、型号、价格,那么委托和方法的声明都会变得很麻烦,而如果我们将热水器的引用传给警报器的方法,就可以在方法中直接访问热水器了。
符合编码规范的代码: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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92using System;
using System.Collections.Generic;
using System.Text;
namespace Delegate
{
public class Heater
{
private int temperature;
public string type = "RealFire 001"; // 添加型号作为演示
public string area = "China Xian"; // 添加产地作为演示
public delegate void BoiledEventHandler(Object sender, BoiledEventArgs e);
public event BoiledEventHandler Boiled; // 声明事件
// 定义 BoiledEventArgs 类,传递给 Observer 所感兴趣的信息
public class BoiledEventArgs : EventArgs
{
public readonly int temperature;
public BoiledEventArgs(int temperature)
{
this.temperature = temperature;
}
}
// 可以供继承自 Heater 的类重写,以便继承类拒绝其他对象对它的监视
protected virtual void OnBoiled(BoiledEventArgs e)
{
if (Boiled != null)
{
Boiled(this, e); // 调用所有注册对象的方法
}
}
public void BoilWater()
{
for (int i = 0; i <= 100; i++)
{
temperature = i;
if (temperature > 95)
{
// 建立BoiledEventArgs 对象。
BoiledEventArgs e = new BoiledEventArgs(temperature);
OnBoiled(e); // 调用 OnBolied 方法
}
}
}
public class Alarm
{
public void MakeAlert(Object sender, Heater.BoiledEventArgs e)
{
Heater heater = (Heater)sender; // 这里是不是很熟悉呢?
// 访问 sender 中的公共字段
Console.WriteLine("Alarm:{0} - {1}: ", heater.area, heater.type);
Console.WriteLine("Alarm: 嘀嘀嘀,水已经 {0} 度了:", e.temperature);
Console.WriteLine();
}
}
public class Display
{
public static void ShowMsg(Object sender, Heater.BoiledEventArgs e) // 静态方法
{
Heater heater = (Heater)sender;
Console.WriteLine("Display:{0} - {1}: ", heater.area, heater.type);
Console.WriteLine("Display:水快烧开了,当前温度:{0}度。", e.temperature);
Console.WriteLine();
}
}
class Program
{
static void Main()
{
Heater heater = new Heater();
Alarm alarm = new Alarm();
heater.Boiled += alarm.MakeAlert; //注册方法
heater.Boiled += (new Alarm()).MakeAlert; //给匿名对象注册方法
heater.Boiled += new Heater.BoiledEventHandler(alarm.MakeAlert); //也可以这么注册
heater.Boiled += Display.ShowMsg; //注册静态方法
heater.BoilWater(); //烧水,会自动调用注册过对象的方法
}
}
}
}
Unity中委托和事件的应用
在游戏开发中,我们经常需要用到观察者模式。比方说,我们有一个炸弹,炸弹在一定时间后会爆炸,将范围内所有的对象杀死。如果用常规的方法,是我们将所有范围内的对象作为成员变量保存在炸弹的GameObject中,在炸弹爆炸时通过对象的引用调用相应函数即可。
但这样会带来几个问题:
- 难以变更,一旦要新增一个Observer就需要更改Subject中的代码。
- 一旦某个GameObject被销毁了,无法从Subject中移除掉这个成员变量,会发生NullReferernceException,需要添加是否为Null的判断语句。
- 在发生事件时,一个个去invoke不同Observer中的相应handle方法的代码变得冗长繁杂。
通过观察者模式,我们可以利用委托的方式来动态地订阅和注销回调函数,这样就解决了上述的问题,并且代码维护起来也更加直观。还是上面的炸弹的例子,我们可以为炸弹声明一个爆炸事件,在炸弹爆炸的时候调用该事件(即调用所有注册了的委托函数)。然后每个对象进入炸弹范围内的时候,会将自己的回调函数注册到该事件上。在离开爆炸范围或者自己被销毁的时候,将注册了的回调函数注销掉即可。
补充一下,这样还有一个好处,就是不同的对象可以有不同的回调函数(即爆炸发生时对应的响应函数)。如果用原来的方式,则需要在Subject中对于不同的对象执行不同的逻辑,而这是不合理的,因为对于观察者模式来说,Subject本身并不关心Observer是如何响应的。
参考:【Unity3D技巧】在Unity中使用事件/委托机制(event/delegate)进行GameObject之间的通信
进阶
为什么委托定义的返回值通常都为 void ?
这是因为委托变量可以供多个订阅者注册,如果定义了返回值,那么多个订阅者的方法都会向发布者返回数值,结果就是后面一个返回的方法值将前面的返回值覆盖掉了,因此,实际上只能获得最后一个方法调用的返回值。除此以外,发布者和订阅者是松耦合的,发布者根本不关心谁订阅了它的事件、为什么要订阅,更别说订阅者的返回值了,所以返回订阅者的方法返回值大多数情况下根本没有必要。
1.5.2 如何让事件只允许一个客户订阅?
少数情况下,比如像上面,为了避免发生“值覆盖”的情况(更多是在异步调用方法时,后面会讨论),我们可能想限制只允许一个客户端注册。此时怎么做呢?我们可以向下面这样,将事件声明为private 的,然后提供两个方法来进行注册和取消注册:
委托与方法的异步调用
通常情况下,如果需要异步执行一个耗时的操作,我们会新起一个线程,然后让这个线程去执行代码。但是对于每一个异步调用都通过创建线程来进行操作显然会对性能产生一定的影响,同时操作也相对繁琐一些。.NET 中可以通过委托进行方法的异步调用,就是说客户端在异步调用方法时,本身并不会因为方法的调用而中断,而是从线程池中抓取一个线程去执行该方法,自身线程(主线程)在完成抓取线程这一过程之后,继续执行下面的代码,这样就实现了代码的并行执行。使用线程池的好处就是避免了频繁进行异步调用时创建、销毁线程的开销。当我们在委托对象上调用BeginInvoke()时,便进行了一个异步的方法调用。
事件发布者和订阅者之间往往是松耦合的,发布者通常不需要获得订阅者方法执行的情况;而当使用异步调用时,更多情况下是为了提升系统的性能,而并非专用于事件的发布和订阅这一编程模型。而在这种情况下使用异步编程时,就需要进行更多的控制,比如当异步执行方法的方法结束时调用EndInvoke()通知客户端、返回异步执行方法的返回值等。
BeginInvoke 方法启动异步调用。 该方法具有与你要异步执行的方法相同的参数,另加两个可选参数。 第一个参数是一个 AsyncCallback 委托,此委托引用在异步调用完成时要调用的方法。 第二个参数是一个用户定义的对象,该对象将信息传递到回调方法。 BeginInvoke 将立即返回,而不会等待异步调用完成。 BeginInvoke 返回可用于监视异步调用的进度的 IAsyncResult。
EndInvoke 方法用于检索异步调用的结果。 它可以在调用 BeginInvoke之后的任意时间调用。 如果异步调用尚未完成,那么 EndInvoke 将阻止调用线程,直到完成异步调用。 EndInvoke 的参数包括要异步执行的方法的 out 和 ref 参数(Visual Basic 中的
1 | using System.Threading; |