CSharp 委托(delegate)与事件(event)
前话
面向群体
- C#初学者
- 对委托或者事件分辨不清的同学
目标
- 了解C#语言中的委托与事件
- 分辨C#语言中的委托与事件
- 了解C#语言中的委托与事件部分应用场景
- 了解事件的实现原理
委托(delegate)
委托是什么
A delegate is a type
that represents references to methods with a particular parameter list and return type. When you instantiate a delegate, you can associate its instance with any method
with a compatible signature
and return type
. You can invoke (or call) the method through the delegate instance. ——Delegates (C# Programming Guide)
翻译过来的大致意思(本人英语水平不佳)如下:
委托是一种表示对具有特定参数列表和返回类型的方法的 引用类型
。实例化委托时,可以将其实例与具有 兼容签名
和 返回类型
的任何方法相关联。您可以通过委托实例调用(或调用)方法。
从官网咱们可以看到委托的定义,明确了下面这几点:
- 委托是一种引用类型,这意味着,委托是和类(class)、枚举类型(enum)、结构体(struct)同等级别的存在,其作用都是定义了一种类型。
- 委托实例可以和具有相同签名的
兼容签名
和返回类型
的任何方法
相关联。
这句话可能对于各位来说,包含的信息会多一些,我举一段代码进行说明:
/// 在此我们定义了一个名叫MyDelegate的委托类型
/// 其返回值为int类型,而参数列表中有两个分别名为a和b的int类型的参数
delegate int MyDelegate(int a,int b);
class Test{
/// 在Test类中包含了一个名为mDelelgate的MyDelegate类型实例
MyDelegate mDelegate;
public Test(MyDelegate myDelegate){
/// 在Test类中的构造方法中赋予mDelegate值
mDelegate = myDelegate;
}
/// 通过Test类中的Invoke方法里像调用方法一样使用mDelegate
public int Invoke(int a,int b){
return mDelegate(a,b);
}
public static int Add(int a,int b){
return a+b;
}
/// 为了说明某个要点,构建了一个名为InnerClass的内部类
/// 该类只有一个方法
public class InnerClass{
public int Plus(int a,int b){
return a*b;
}
}
public static void Main(){
/// 使用静态方法Add作为参数构造一个Test实例
Test test1 = new Test(new MyDelegate(Add));
/// 使用InnerClass类的实例方法Plus作为参数构造一个Test实例
Test test2 = new Test(new InnerClass().Plus);
/// 分别输出两个实例Invoke方法的结构
Console.WriteLine(test1.Invoke(1,2));
Console.WriteLine(test2.Invoke(1,2));
Console.ReadKey();
}
}
阅读了这段代码后,咱们再回过头来看那第二个要点:
- 委托实例可以和具有相同签名的
兼容签名
和返回类型
的任何方法
相关联。
首先是关键词兼容签名
这里的签名主要指的是方法的参数列表的参数个数、参数类型要兼容,这里的兼容目前理解为一致即可。(若要深入理解的话,可以了解C#的协变性与逆变性)
再是关键词返回类型
其实与签名一致,也需要是兼容的返回类型。
最后是关键词任何方法
,这里的任何指的意思就是说包括静态方法和实例方法,在上述代码中我们举了Add静态方法和Plus实例方法为例说明了这一要点。
也就是说,当方法满足了这三个条件后,我们便可以将这个方法来当成一个委托实例来使用(事实上是进行了类型转换),或者使用这个方法来生成一个委托实例。
虽然说,委托的定义和一些规矩咱们已经清楚了,但是大家肯定还会有疑问,比如下面: - 我到底为什么要定义一个委托,直接调用一个方法不好吗?
接下来我会对这个疑问来进行说明。
委托的用处
- 现在给你一个任务,实现冒泡排序让一个int数组从小到大排序。
for (int i = 0; i < arr.Length - 1; i++){
for (int j = 0; j < arr.Length - 1 - i; j++) {
if (arr[j] - arr[j + 1]>0){
var temp = arr[j + 1];
arr[j + 1] = arr[j];
arr[j] = temp;
}
}
}
}
- 再给你一个任务,实现冒泡排序让一个int数组从大到小排序。
for (int i = 0; i < arr.Length - 1; i++){
for (int j = 0; j < arr.Length - 1 - i; j++) {
if (arr[j] - arr[j + 1]<0){
var temp = arr[j + 1];
arr[j + 1] = arr[j];
arr[j] = temp;
}
}
}
}
- 再再再给你一个任务,实现冒泡排序让一个int数组按绝对值从小到大排序,你是不是已经有一点厌烦了?
for (int i = 0; i < arr.Length - 1; i++){
for (int j = 0; j < arr.Length - 1 - i; j++) {
if (Math.Abs(arr[j]) - Math.Abs(arr[j + 1])>0){
var temp = arr[j + 1];
arr[j + 1] = arr[j];
arr[j] = temp;
}
}
}
}
- 如果接着让你实现不同要求的冒泡排序,你是不是会有点想打我?
不过为了我的生命安全,暂且就实现到这里。
你可以发现,其实这三段代码大同小异,三个要求的改变之处无非就是if后面的条件语句发生了改变。
并且,如果往后还要提其他要求的话,事实上你也只需要改变一下条件语句,而其他的代码都是可以复用的。
于是我们希望有一种方式能够达到这一要求,此时委托便是解决这个问题的好办法。
首先我们定义一个方法的规则,传入的参数为两个int值a、b。
1. 若a在逻辑上大于b,方法返回一个大于0的数字。
2. 若a在逻辑上小于b,方法则返回一个小于0的数字。
3. 若a在逻辑上等于b,则返回零。
然后请阅读以下代码:
delegate int CMP(int a, int b);
class Test
{
/// 冒泡排序主体
static void Sort(int[] arr, CMP cmp)
{
for (int i = 0; i < arr.Length - 1; i++)
{
for (int j = 0; j < arr.Length - 1 - i; j++)
{
if (cmp(arr[j], arr[j + 1]) > 0)
{
var temp = arr[j + 1];
arr[j + 1] = arr[j];
arr[j] = temp;
}
}
}
}
/// 比较函数1,若a>b则返回大于0的数字
static int CMP1(int a,int b)
{
return a - b;
}
/// 比较函数2,若a<b则返回大于0的数字
static int CMP2(int a, int b)
{
return b-a;
}
/// 比较函数2,若a绝对值>b绝对值则返回大于0的数字
static int CMP3(int a, int b)
{
return Math.Abs(a) - Math.Abs(b);
}
/// 辅助函数,遍历arr并输出显示
static void PrintArr(int[] arr) {
foreach(var v in arr)
{
Console.Write($"{v} ");
}
Console.WriteLine();
}
public static void Main() {
var arr = new[] { 1, -2, 9, 8, 10 };
Sort(arr, CMP1);
PrintArr(arr);
Sort(arr, CMP2);
PrintArr(arr);
Sort(arr, CMP3);
PrintArr(arr);
Console.ReadKey();
}
}
我们发现,定义了一种委托以后,我们在需求发生改变的时候,只需要新写一个比较方法,然后再调用Sort方法的时候将其当成委托实例传入即可。
而如果我们直接调用一个方法的话,我们就要写多个不同的排序方法,从而有大量的重复逻辑。
这样,我们节省了很多的重复代码,而让自己的代码得到了复用。
事实上,我们也可以从面向对象的角度来理解:
- 委托实例即是将一个方法当作一个对象来看待,我们可以通过传递委托实例,来动态的改变某个地方的逻辑,而使得其他的代码复用。
有些同学可能会发现,这和面向对象中的多态会有些类似。
事实上,上述代码完全可以使用多态来实现,但是多态需要继承,也就是说,如果使用多态来实现上述内容的话,那么很明显我们需要再写三个类,很明显这是一个比较麻烦的事情,甚至按照上个例子来说,这三个类中只需要实现各自的CMP方法,而不存在其他的内容。这样很明显是一种很不方便的方式。
而使用委托的话,就不需要再弄几个新的类,而只需要写几个新的方法即可,甚至还有更简单的方式,也就是接下来我们会提到的lambda表达式。
lambda表达式
上文提到,如果要使用委托的话,往往都会使用一个方法来转换成一个委托实例。然而,如果每弄一个别的委托实例都需要新建一个方法的话,存在以下问题:
- 起名字困难,比如上述的CMP1,CMP2,CMP3,很难以起一个形象的好名字。
- 要转换成委托实例的方法不一定会被作为委托实例以外别处调用,一个类会因此多一个无用的方法。
为了解决这些问题,简化编程,有一种语法叫做lambda表达式。
/// 使用lambda表达式的话,就可以使用下面这段代码来代替上述的CMP1方法构造CMP委托的实例
CMP cmp = (a,b)=>a-b;
关于lambda表达式的粗略了解,可以阅读 Lambda 表达式(C# 编程指南) 的表达式lambda与语句lambda部分进行了解。
事件(event)
Delegate.Combine & Delegate.Remove
在了解事件前我们先来了解一下这两个Delegate类的静态方法。
我通过下面的伪代码来进行讲解:
delegate void MyDelegate();
var delegate1 = ()=>{ Console.WriteLine("delegate1!"); };
var delegate2 = ()=>{ Console.WriteLine("delegate2!"); };
/// 使用Combine方法将两个delegate实例结合返回一个新的实例
delegate1 = Delegate.Combine(delegate1,delegate2);
/// 调用delegate3会分别调用delegate1和delegate2
delegate1();
/// 使用Remove方法把delegate3中对delegate2的调用移除
delegate1 = Delegate.Remove(delegate1,delegate2);
delegate1();
/// 另外,C#中重载了委托类型的 +、-、+=、-=操作符
/// +会调用Delegate.Combine方法
/// -会调用Delegate.Remove方法
/// +=、-=依次类推
/// 所以上述代码也可以这样写
var delegate1 = ()=>{ Console.WriteLine("delegate1!"); };
var delegate2 = ()=>{ Console.WriteLine("delegate2!"); };
delegate1 += delegate2;
delegate1();
delegate1 -= delegate2;
delegate1();
事件是什么
Events are a special kind of multicast delegate
that can only be invoked from within the class or struct where they are declared (the publisher class)
. ——event (C# reference)
翻译过来的意思即是:
事件是一种特殊的多播委托
,仅可以从声明事件的类或结构(发布服务器类)中对其进行调用
。
在上述描述中有两处比较指的关注的地方:
- 多播委托:多播委托暂时可以这么理解,即之前使用Delegate.Combine方法,将两个委托合成一个委托,合成的这个委托即是多播委托。多播的意思也就是说,一处调用,就会造成多处影响。如想了解更多可以阅读:C# 委托(二)—— 多播委托与事件
- 仅可以从声明事件的类或结构(发布服务器类)中对其进行调用:这句话会稍难解释一些,我们先来看一下事件是怎么用的:
delegate void MyDelegate();
class Test{
/// 使用event关键词修饰一个委托实例,我们称这个为一个事件
public static event MyDelegate Delegate;
public static void Invoke(){
Delegate?.Invoke();
}
}
class Solution{
public static void Main(){
MyDelegate delegate1 = ()=>Console.WirteLine(1);
MyDelegate delegate2 = ()=>Console.WirteLine(2);
Test.Delegate += delegate1;
Test.Delegate += delegate2;
Test.Invoke();
Console.ReadKey();
}
}
大家可以看到,在Test类里有一个名为Delegate的MyDelegate类型的事件,在Solution类中的Main方法内对其进行了+=操作(称之为注册),在后面通过Test类中的Invoke方法触发了这个事件(称之为通知)。
然鹅,我们发现,这个用法和之前的多播委托来说大同小异。我们再回过头来看那句话:
- 事件是一种特殊的
多播委托
,仅可以从声明事件的类或结构(发布服务器类)中对其进行调用
。
首先,事件是一种特殊的委托,特殊在哪呢?他只可以再声明事件的类或者结构中对其调用。我们发现,在上面例子中,我特意用了两个类,使用Test类去调用Invoke方法去触发这个事件。
各位可以试一试,虽然Test类中的Delegate事件是用public修饰的,但是在Solution类中却不能直接触发这个事件。
而如果使用public的多播委托的话,我们同样可以使用+=、-=进行注册或者取消注册,但是很明显,在能够使用+=、-=注册或者取消注册的同时,我们也开放了调用这个多播委托的权限。
因此我们可以总结: - 使用event修饰一个委托形成事件是给委托加一个调用的限制。
可是,可能很多同学会有这样的疑问,如果直接使用多播委托,能否达到相同的效果呢?事实上这是完全可以的,下面我们来描述下委托的实质。
事件的实质
首先我们使用多播委托来实现一下事件:
class Test{
private MyDelegate mDelegate = null;
private Invoke(){ mDelegate?.Invoke(); }
public Add(MyDelegate d){ mDelegate += d; }
public Remove(MyDelegate d){ mDelegate -= d; }
#region Other Members
///
#endregion
}
利用上述代码,我们便可以使用Add方法来注册事件而使用Remove方法来取消注册事件。
并且,我们将mDelegate的调用权限给了这个类而不公开。
事实上,事件的默认实现就是类似上述的代码实现,当你使用event关键词定义一个事件时,会为他创建一个私有的委托字段,还有两个与该事件的访问权限一致的方法(即类似上述的Add与Remove方法)。
而之所以刚刚强调是事件的默认实现,是因为我们其实可以改变事件的add方法和remove方法的行为,这个形式其实与C#中的属性会很相似,下面通过一段代码来演示一下如何使用自定义的事件。
delegate void MyDelegate();
class Test{
private MyDelegate mDelegate=null;
public event MyDelegate Event{
add{ mDelegate += value; }
remove{ mDelegate -= value; }
}
}
是不是非常类似于属性呢?当然了,其实他们两个的设计思维我觉得很类似,属性的实质其实也类似,其本质都是简化了很多操作的语法糖。
另外,这里还给出一个自定义事件的例子来帮助大家的理解。
默认的事件实现有一个特性,如果一个委托实例被多次注册,事实上在事件发送通知的时候,这个委托会被执行多次。
那么,怎样来让每个委托实例只被执行一次呢?
请阅读下面的代码:
delegate void MyDelegate();
class Test
{
/// 使用一个HashSet容器来保存委托实例,以在容器中,每个委托实例只存在对他的一个引用
static HashSet<MyDelegate> delegates = new HashSet<MyDelegate>();
public static event MyDelegate Delegate
{
add { delegates.Add(value); }
remove { delegates.Remove(value); }
}
public static void Invoke()
{
foreach(var dele in delegates)
{
dele();
}
}
}
class Test2
{
public static void Main()
{
MyDelegate delegate1 = () => Console.WriteLine(1);
Test.Delegate += delegate1;
Test.Delegate += delegate1;
Test.Delegate += delegate1;
Test.Invoke();
Console.ReadKey();
}
}
后话
总结
委托:
- 委托是一种引用类型,其目的是将方法作为对象来对待。
- 委托可以很方便的去替换方法中的部分逻辑。
- 通过lambda表达式可以很方便的创建委托。
事件:
- 事件是类似于属性的存在,其作用是开放委托实例的+=、-=权限但是保留类的调用权限。
- 可以重写事件的add、remove方法来自定义事件的行为。
其他资料
Func与Action
Func与Action是两种官方提供的泛型委托类型,按我的理解来说的话,主要目是简化常去创建新的委托类型的操作。
应用较为简单,各位可以参考这篇博客进行简要了解:C# 之 Action 和 Func 的用法。
观察者模式
观察者模式是一种可以广泛应用事件机制的设计模式,如果想对这种设计模式进行了解的话建议各位去观看这个视频:23个设计模式——观察者模式。
或者阅读《Head First 设计模式》/《设计模式》的相关章节。