委托与事件,这是一个老生常谈的话题,很多人在讲,很多人在用,但似乎它是一个永远也说不完道不尽的东西。那么,到底什么是委托?什么是事件?委托链又是怎么回事?为什么使用事件时常常用到+=/-=?委托又是如何支持协变和逆变的呢?你喜欢使用Action和Func
回调函数是Windows编程语言中一种常见而有用的编程实践,在C/C++中,它指的是函数调用的指针,通过这个指针可以方便地对函数进行调用,当然这个指针也是可以被传递给别的函数使用。在.NET Framework中,回调是通过委托来实现的,当然在这里,比起非托管的C/C++,.NET中的委托提供了更丰富的功能,比如同步和异常调用、委托链等等。
委托其实是一种类型,是一种定义方法签名的类型,它支持以new的方式来实例化。委托是使用关键字delegate进行定义的,它其实是对方法的包装和聚集。既然它也是一种类型,所以能定义类的地方都可以定义委托,如下是一个委托的声明:
public delegate void ShowMessage(string msg);
任何与委托签名匹配的方法都可以分配给委托,这就要求该方法的返回值类型与参数列表必须与委托的签名相匹配,方法可以是静态的,也可以是对象级的,通过委托可以对分配给委托的方法进行调用。我们来看一下编译器来干了什么事:
通过上图我们可以看出,编译器让这个委托类型继承了System.MulticastDelegate类,System.MuticastDelegate类又继承了Delegate类,如下:
public abstract class MulticastDelegate : Delegate
{
//...
}
同时还生成了三个方法,其中BeginInvoke()和EndInvoke()两个方法是供异步调用,Invoke()方法是供同步调用。其实通过IL更能明确的看出委托最终经过编译器生成的是一个类:
在Delegate类中有两个非常重要的字段:
internal object _target; 当委托包装的是一个静态方法时,该字段为null;当委托包装的是一个对象方法时,该字段引用的是该对象。该字段可以通过属性Target获取。
internal IntPtr _methodPtr; 保存着一个方法的IntPtr值,属性Method 获取一个标识了该回调方法的对象(MethodInfo)的引用,在类的内部,这个MethodInfo对象是通过方法GetMethodImpl()运算生成来的。
我们分别定义一个实例级方法ShowString()和一个静态方法StaticShowString(),代码:
void ShowString(String str)
{
Console.WriteLine("ShowString:" + str);
}
public class Code_05_01
{
public static void StaticShowString(string str)
{
Console.WriteLine("StaticShowString:" + str);
}
}
现在我们来看一下运行时的这两个属性,如下图:
上图中显示了Target指向的是对象,下图中由于绑定了一个静态方法,所以Target是null。
MulticastDelegate类有两个私有字段:
private IntPtr _invocationCount; 保存了委托链中方法个数。
private object _invocationList; 保存的即是委托链(方法集合)
MulticastDelegate类重写了Delegate的一个虚方法public virtual Delegate[] GetInvocationList(),来获取委托的调用列表。
对委托的调用也有两种方式,可以同步调用也可以异步调用,同步调用有两种方法:直接调用和使用委托对象的Invoke方法,如下:
void TestCall()
{
ShowMessage callShow = new ShowMessage(ShowString);
callShow("abc");
}
void TestInvoke()
{
ShowMessage invokeShow = new ShowMessage(ShowString);
invokeShow.Invoke("abc");
}
这两种调用有什么区别呢?我们来看一下它们的IL。
TestCall.IL:
.method private hidebysig instance void TestCall() cil managed { // 代码大小 27 (0x1b) .maxstack 3 .locals init ([0] class ConsoleApp.Example05.ShowMessage callShow) IL_0000: nop IL_0001: ldarg.0 IL_0002: ldftn instance void ConsoleApp.Example05.Code_05::ShowString(string) IL_0008: newobj instance void ConsoleApp.Example05.ShowMessage::.ctor(object, native int) IL_000d: stloc.0 IL_000e: ldloc.0 IL_000f: ldstr "abc" IL_0014: callvirt instance void ConsoleApp.Example05.ShowMessage::Invoke(string) IL_0019: nop IL_001a: ret } // end of method Code_05::TestCall
TestInvoke.IL:
.method private hidebysig instance void TestInvoke() cil managed { // 代码大小 27 (0x1b) .maxstack 3 .locals init ([0] class ConsoleApp.Example05.ShowMessage invokeShow) IL_0000: nop IL_0001: ldarg.0 IL_0002: ldftn instance void ConsoleApp.Example05.Code_05::ShowString(string) IL_0008: newobj instance void ConsoleApp.Example05.ShowMessage::.ctor(object, native int) IL_000d: stloc.0 IL_000e: ldloc.0 IL_000f: ldstr "abc" IL_0014: callvirt instance void ConsoleApp.Example05.ShowMessage::Invoke(string) IL_0019: nop IL_001a: ret } // end of method Code_05::TestInvoke
其实两者的内部调用实现基本一样,都是对委托对象的方法Invoke进行调用。
委托链 也叫多路广播委托,是在委托内部由委托对象构成的一个委托对象集合,可以通过委托来调用委托链内的所有委托包装的方法。Delegate有两个静态方法,Combine()用于创建委托链和委托链添加新的委托,我们假设有一个委托委托A,来模拟向委托链追加委托的过程:
(1) 委托A对象在实例化的时候已经包装了一个方法,
(2) 当调用Delegate.Combine()方法向委托A追加新委托B对象时,在内部会重新创建一个委托对象C,并且用新追加的委托(方法)初始化_target和_methodPtr字段;
(3) 将委托C的_invocationList初始化为一个委托对象数组,并将委托A放到这个数组的第1项(索引为0)位置,然后将新的委托B对象放到数据的第2项位置(这里会根据委托的个数依次递增,新增加的那个委托对象总是在这个数组的最后位置),最后返回这个新创建的委托对象C。
当再次向新委托C追加委托成员时,会重复(1)-(3)的步骤,每次最终都会返回一个新创建的委托,并且字段_target和_methodPtr总是根据新增加的委托对象来实例化,不过此时的这两个字段好像用处已经不大了,它只是保存了是最后进来的一个委托对象的部分数据。很显然,如果一个委托只包装了一个方法,并没有因追加新的委托而创建委托链,那么在这种情况下,这两个字段_target和_methodPtr是非常有意义的。
与方法Combine()对应的有一个方法public static Delegate Remove(Delegate source, Delegate value);很显然它是从委托链中移除一个委托对象。为了方便书写,C#为委托类型的实例重载了两个操作符+=和-=分别对应于方法Combine和方法Remove。通过下图我们可以看一下追加委托的过程。继续对上面的代码进行改造,增加一个对象级方法:
void ShowString2(String str) { Console.WriteLine("ShowString2:" + str); }
然后实例化一个委托ShowMessage show3 = new ShowMessage(ShowString);,如下图:
可以看到此时show3的Method指向的方法是ShowString(System.String),并且字段_invocationCount的值为0,_invocationList是null。
接下来我们向show3追加一个委托对象(事实上是向委托链追加),show3 += new ShowMessage(ShowString2);,也可以使用简写:show3 += ShowString2;这样不用手写代码来创建委托对象,但在编译的过程中,编译器还是会识别出这是一个创建委托对象的过程并向IL中写入创建委托对象的代码。如下图:
此时,我们已经看到Method指向的是新的方法ShowString2(System.String),_invocationCount的值为2,_invocationList已经是一个拥有两个元素的集合。
对委托对象有委托链且不为空的时候,又是如何调用委托链内的各个回调函数的呢?通过上面对Invoke的讨论,我们知道当调用一个委托对象的回调函数时,在内部CLR实际上是调用了Invoke方法,而在调用invoke方法时,该委托会发现字段_invocationList不为null,接着就会遍历该数组中的所有委托对象依次对委托方法进行调用。
协变性与逆变性
委托的协变性 是指委托方法能返回从对应委托的返回类型派生的一个类型。
委托的逆变性 是指方法获取的参数类型可以是委托的参数类型的基类。
这里的描述的有点绕口,我们来年如下代码:
public class Code_05_02
{
public string Name { get; set; }
}
public class Code_05_03 : Code_05_02
{
public int Age { get; set; }
}
//定义一个委托MyDel
public delegate Code_05_02 MyDel(Code_05_03 para);
//定义一个与委托MyDel 相匹配的方法
private Code_05_03 GetData(Code_05_02 para)
{
return new Code_05_03();
}
//以下的实例化及调用是可行的。
MyDel del = new MyDel(GetData);
del(new Code_05_03());
Code_05_03类继承于Code_05_02类,委托MyDel的返回类型是Code_05_02,方法GetData的返回类型是Code_05_03,这体现的协变性;方法GetData的参数类型是Code_05_02,委托的参数类型是Code_05_03,这体现了逆变性。
需要说明一点的是:协变性和逆变性不能用于值类型(包括void)。
在我们开发的过程中,可能经常要使用委托,但委托的定义都大同小异,很幸运的是.NET Framework为我们预定义了很多的常用泛型委托。
无返回值的Action系列:
public delegate void Action<in T>(T obj) public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2) //共有16个,另外还有一个无参的非泛型委托: public delegate void Action()
使用非常简单,代码示例:
void TestAction() { Action<string> act = new Action<string>(ShowString); act("Action"); }
有返回值的Func
public delegate TResult Func<in T, out TResult>(T arg) public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2) //共有16个,另外还有一个无参的泛型委托: public delegate TResult Func<out TResult>()
一般的时候,我们使用这些委托已经足够了。详细内容可以查询MSDN: Action
C#的lambda表达式为委托的简化使用显示出了很不错的编程体验。可以参考MSDN的相关章节:Lambda 表达式(C# 编程指南) 。
这一章的上半部分,我们主要讲解了与委托相关的内容,后一篇的下半部分将主要讲解什么是事件及委托如何与事件共事