前言
委托和事件是c#基础中两个重要的知识,平时工作中也会经常用到。接下来我会写两篇我对委托和事件的理解,欢迎拍砖。
回调函数是一种非常有用的编程机制,许多语言都对它提供了支持。回调函数是一个通过函数指针调用的函数。通常,我们会把回调函数作为参数传递给另一个函数,当某些事件发生或满足某些条件时,由调用者执行回调函数用于对该事件或条件进行响应。简单来说,实现回调函数有如下步骤:
1. 定义一个回调函数。
2. 将回调函数指针注册给调用者。
3. 在某些事件或条件发生时,调用者通过函数指针调用回调函数对事件进行处理。
回调机制的应用非常多,例如控件事件、异步操作完成通知等等;.net 通过委托来实现回调函数机制。相比其他平台的回调机制,委托提供了更多的功能,例如它确保回调方法是类型安全的,支持顺序调用多个方法,以及调用静态方法和实例方法。
一、初识委托
在开始接触委托前,相信很多人都会感觉它用起来怪怪的,有些别扭。理解它的本质后,就知道许多时候其实是编译器在背后“搞鬼”;编译器做了大量的工作,目的是为了减少代码的编写以及让代码看起来更优雅。接下来就让我们逐步深入理解委托。
先看一段简单的代码:
//1.定义一个委托类型 delegate void TestDelegate(int value); static void Main(string[] args) { //2.传递null ExecuteDelegate(null, 10); //3.调用静态方法 TestDelegate test1 = new TestDelegate(StaticFunction); ExecuteDelegate(test1, 10); //4.调用实例方法 PRogram program = new Program(); TestDelegate test2 = new TestDelegate(program.InstanceFunction); ExecuteDelegate(test2, 10); //5.调用多个方法 TestDelegate test3 = (TestDelegate)Delegate.Combine(test1, test2); ExecuteDelegate(test3, 10); } //静态方法 static void StaticFunction(int value) { Console.WriteLine("Call StaticFunction: " + value.ToString()); } //实例方法 void InstanceFunction(int value) { Console.WriteLine("Call InstanceFunction: " + value.ToString()); } //执行委托 static void ExecuteDelegate(TestDelegate tg, int value) { if (tg != null) { tg(value); } }
第1步,用delegate关键字定义了一个委托类型,名称为TestDelegate。它的签名为:1. 返回值为void 2. 有一个int类型的参数。回调函数的签名必须与之一样,否则编译会报错。
第2步,调用执行委托的方法并传递了null,实际上什么也没做。这里说明了委托可以作为参数,可以为null,似乎与引用类型相似。
第3步,用 new 创建了一个TestDelegate的变量test1, 并将静态方法作为参数,它符合委托的签名。通过new 来创建,我们基本可以推测TestDelegate是一个引用类型。
第4步,与3类似,只不过它传递的参数是一个实例方法,所以需要先创建方法的对象Program。
第5步,调用了Delegate.Combine()方法,通过名称可以指定它用于将多个委托组合起来,调用test3时,会按照它的参数顺序执行所有方法。这种方式有时候非常有用,因为我们很可能在某个事件发生时,要执行多个操作。
通过上面的代码,我们基本可以知道委托是用来包装回调函数的,对回调函数的调用其实是通过委托来实现的,这也是很符合【委托】的称呼。那么委托到底是一种什么样的类型?为什么它可以将函数名称作为参数?为什么可以像tg(value)这样来执行?Delegate.Combine内部的实现机制又是怎样的?接下来让我们一一解答。
二、委托揭秘
上面提到,c#编译器为了简化代码的编写,在背后做了很多处理。委托的确是一种用来包装函数的引用类型,当我们用delegate定义上面的委托时,编译器会为我们生成一个class TestDelegate的类,这个类就是用来包装回调函数的。通过ILDasm.exe查看上面的IL代码可以很清晰看到这个过程:
可以看到,编译器为我们生成了一个 TestDelegate 的class 类型,并且它还继承了MulticastDelegate。实际上,所有的委托都会继承MulticastDelegate,而MulticastDelegate又继承了Delegate。Delegate有2个重要的非公共字段:
1. _target: object类型,当委托包装的是实例方法时,这个字段引用的是实例方法的对象;如果是静态方法,这个字段就是null。
2. _methodPtr: IntPtr类型,一个整数值,用于标识回调方法。
所以对于实例方法,委托就是通过实例对象去调用所包装的方法的。Delegate还公开了两个属性,Target和Method分别表示实例对象(静态方法为null)和包装函数的元信息。
可以看到经过编译器编译后生成的这个类有4个函数,.ctor(构造函数),BeginInvoke, EndInvoke, Invoke。BeginInvoke/EndInvoke 是Invoke的异步版本,所以我们主要关注.ctor和Invoke函数。
.ctor构造函数有两个参数,一个object类型,一个int类型。但当我们new一个委托对象时,传递却是一个方法的名称。实际上,编译器知道我们要构造的是委托对象,所以会分析源代码知道要调用的是哪个对象和方法;对象引用就是作为第一个参数(如果静态就为null),而从元数据获取用于标识函数的特殊值就作为第二个参数,从而调用构造函数。这两个参数分别保存在 _target 和 _methodPth字段中。
Invoke 函数顾名思义就是用来调用函数的,当我们执行tg(value)时,编译器发现tg引用的是一个委托对象,所以生成的代码就是调用委托对象的Invoke方法,该方法的签名与我们签名定义的签名是一致的。生成的IL代码如: callvirt instance void TestDelegate2.Program/TestDelegate::Invoke(int32)。
至此,我们知道定义委托就是定义类,这个类用来包装回调函数。通过该类的Invoke方法执行回调函数。
三、委托链
前面说到所有的委托类型都会继承MulticastDelegate。MulticastDelegate表示多路广播委托,其调用列表可以拥有多个委托,我们称之为委托链。简单的说,它拥有一个委托列表,我们可以顺序调用里面所有方法。通过源码可知,MulticastDelegate有一个_invocationList字段,用于引用一个委托对象数组;我们可以通过Delegate.Combine将多个委托添加到这个数组当中,既然有Combine就会有Remove,对应用来从委托链中移除指定的委托。接下来我们来看这个具体的过程。如下代码:
TestDelegate test1 = new TestDelegate(StaticFunction); //1 TestDelegate test2 = new TestDelegate(StaticFunction); //2 TestDelegate test3 = new TestDelegate(new Program().InstanceFunction); //3 TestDelegate result = (TestDelegate)Delegate.Combine(test1, test2); //4 result = (TestDelegate)Delegate.Combine(result, test3); //5 Delegate.Remove(result, test1); //6
当执行1~3行时,会创建3个TestDelegate对象,如下所示:
执行第4行时,会通过Delegate.Combine创建一个具有委托链的TestDelegate对象,该对象的_target和_methodPtr已经不是我们想关注的了,_invocationList引用了一个数组对象,数组有test1,test2两个元素。如下:
执行第5行代码时,同样会重新创建一个具有委托链的TestDelegate对象,此时_invocationList具有3个元素。需要注意的是,由于Delegate.Combine(或者Remove)每一次都会重新创建委托对象,所以第4行的result引用的对象不再被引用,此时它可以被回收了。如:
执行Remove时,与Combine类似,都会重新创建委托对象,此时从数组移除test1委托对象,这里就不在重复。
通过上面的分析,我们知道调用方法实际就是调用委托对象的Invoke方法,如果_invocationList引用了一个数组,那么它会遍历这个数组,并执行所有注册的方法;否则执行_methodPtr方法。Invoke伪代码看起来也许像下面这样:
public void Invoke(Int32 value) { Delegate[] delegateSet = _invocationList as Delegate[]; if (delegateSet != null) { foreach (var d in delegateSet) { d(value); } } else { _methodPtr.Invoke(value); } }
_invocationList毕竟是内部字段,默认情况下会按顺序调用,但有时候我们想控制这个过程,例如按某些条件执行或者记录异常等。MulticastDelegate有一个GetInvocationList()方法,用于获取Delegate[]数组,有了该数组,我们就可以控制具体的执行过程了。
四、泛型委托
我们可能会在多个地方用到委托,例如在另一个程序集,我们可能会定义一个 delegate void AnotherDelegate(int value); 这个委托的签名和签名的是一样的。实际上.net内部就有许多这样的例子,平时我们也经常看到。例如:
public delegate void WaitCallback(object state); public delegate void TimerCallback(object state); public delegate void ParameterizedThreadStart(object obj);
上面只是这种签名的形式,另外一种形式也可能出现大量的重复,这将给代码维护带来很大的难度。泛型委托就是为了解决这个问题的。
.net 已经定义了三种类型的泛型委托,分别是 Predicate、Action、Func。在使用linq的方法语法中,我们会经常遇到这些类型的参数。
Action 从无参到16个参数共有17个重载,用于分装有输入值而没有返回值的方法。如:delegate void Action<T>(T obj);
Fun 从无参到16个参数共有17个重载,用于分装有输入值而且有返回值的方法。如:delegate TResule Func<T>(T obj);
Predicate 只有一种形式:public delegate bool Predicate<T>(T obj)。用于封装传递一个对象然后判断是否满足某些条件的方法。Predicate也可以用Func代替。
有了泛型委托,我们就不用到处定义委托类型了,除非不满足需求,否则都应该优先使用内置的泛型委托。
五、c#对委托的支持
5.1 +=/-= 操作符
c#编译器自动为委托类型重载了 += 和 -= 操作符,简化编码。例如要添加一个委托对象到委托链中,我们也可以 test1 += test2; 编译器可以理解这种写法,实际上这样写和调用test1 = Delegate.Combine(test1, test2) 生成的 IL 代码是一样的。
5.2 不需要构造委托对象
在一个需要使用委托对象的地方,我们不必每次都new 一个,只传递要包装的函数即可。例如:test1 += StaticFunction; 或者 ExecuteDelegate(StaticFunction, 10);都是直接传递函数。编译器可以理解这种写法,它会自动帮我们new 一个委托对象作为参数。
5.3 不需要定义回调方法
有时候回调方法只有很简单的几行,为了代码更紧凑和方便阅读,我们不想要定义一个方法。这个时候可以使用匿名方法,如:
ExecuteDelegate(delegate { Console.WriteLine("使用匿名方法"); }, 10);
匿名方法也是用delegate关键字修饰的,形式为 delegate(参数){方法体}。匿名方法是c#2.0提供的,c#3.0提供了更优雅的lambda表达式来代替匿名方法。如:
ExecuteDelegate(obj => Console.WriteLine("使用lambda表达式"), 10);
实际上编译器发现方法的形参是一个委托,而我们传递了lambda表达式,编译会尝试随机为我们生成一个外部不可见的特殊方法,本质上还是在源码中定义了一个新的方法,我们可以通过反编译工具看到这个行为。lambda提供的更方便的实现方式,但在方法有重用或者实现起来比较复杂的地方,还是推荐重新定义一个方法。
五、委托与反射
虽然委托类型直接继承了MulticastDelegate,但Delegate提供了许多有用的方法,实际上这两个都是抽象类,只要提供一个即可,可能是.net设计的问题,搞了两个出来。Delegate提供了CreateDelegate 和 DynamicInvoke两个关于反射的方法。CreateDelegate提供了多种重载方式,具体可以查看msdn;DynamicInvoke参数数一个可变的object数组,这就保证了我们可以在对参数未知的情况下对方法进行调用。如:
MethodInfo methodInfo = typeof(Program).GetMethod("StaticFunction", BindingFlags.Static | BindingFlags.NonPublic); Delegate funcDelegate = Delegate.CreateDelegate(typeof(Action<int>), methodInfo); funcDelegate.DynamicInvoke(10);
这里我们只需要知道方法的名称(静态或实例)和委托的类型,完全不用知道方法的参数个数、具体类型和返回值就可以对方法进行调用。
反射可以带来很大灵活性,但效率一直是个问题。有几种方式可以对其进行优化。基本就是:Delegate.DynamicInvoke、Expression(构建委托) 和 Emit。从上面可以看到,DynamicInvoke的方式还是需要知道委托的具体类型(Action<int>部分),而不能直接从方法的MethodInfo元信息直接构建委托。当在知道委托类型的情况下,这种情况下是最简单的实现方式。
使用委托+缓存来优化反射是我比较喜欢的方式,相比另外两种做法,可以兼顾效率和代码的可读性。具体的实现方式大家可以在网上找,或者参考我的Ajax系列(还没写完,囧)后续也会提到。
委托和事件经常会联系在一起,一些面试官也特别喜欢问这个问题。它们之间究竟是一个什么样的关系,下一篇就对事件展开讨论。
新闻热点
疑难解答