在C#中,delegate是一个神奇的关键字,值得拿出来单独作为一个话题。
一.基本内容
调用(invoke)委托,相当于调用委托所绑定的方法,一个委托可以绑定多个方法,使用"+="就可以向委托中添加新的方法,使用"-="可以从委托中删除方法:
public delegate void Print(); class Program{ public static void Main(String[] args){ Print print = PrintMethod1; print += PrintMethod2; print(); print -= PrintMethod1; print(); } public static void PrintMethod1(){ Console.WriteLine("我是第一个委托方法"); } public static void PrintMethod2(){ Console.WriteLine(" 我是第二个委托方法"); } }
如果我们从委托中减去一个它根本未添加过的方法,会怎么样呢?答案是不会报错,但也不会有任何影响。
还有更加现实的问题,就是如果添加的方法中有多个方法都有返回值,那怎么办?只返回最后一个具有返回值的方法的返回值。
如果我们想要详细的研究,就得从CLR(Command Language RunTime,公共语言运行时,相当于java的虚拟机,即运行时环境)下手。
其实,任何委托类型都是继承自System.MulticastDelegate类,但这个类又继承自System.Delegate类。为什么会有两个委托类呢?FCL(Framework Class Library,即Framework类库)应该只有一个委托类才对。这是一个设计上的遗憾。虽然所有的委托都是MulticastDelegate的派生类,但是个别情况下,我们也要使用Delegate,像是Delegate的静态方法Combind()和Remove()就是实现委托方法的添加和删除,"+="和"-="这两个运算符被重载了,里面封装的就是这两个方法的调用。
神奇的是,我们可以忽略参数列表!
先来个例子:
class Program { public static void Main(String[] args) { Action<String> action = PrintMethod; action += delegate { Console.WriteLine("我是没有参数的委托方法"); } ; action(""); } public static void PrintMethod(String str) { Console.WriteLine("我是委托方法"); } }
我们往方法组里面添加一个匿名方法,但是没有参数String!为什么能这样呢?因为该参数并没有被使用,所以可以忽略它。这样的好处就是我们可以不用写一大串参数列表,但既然参数没有用到,为什么我们还要传进来呢?想想事件处理程序,我们就知道为什么会有这样的"语法糖"了。事件处理程序需要我们传递一个事件,判断该事件是否为null,然后调用相应的事件处理动作,标准的事件处理程序的参数列表像是这样:
public event EventHandler(Object sender, EventArgs e);
烦,就是我的第一眼感觉,而且要是使用到这样的方法组,光是参数列表就可以让我想死了。所以,快捷的方法就是忽略参数列表,参数列表的信息只是为了判断事件是否为空,然后调用相应的处理,我们可以一开始就认定事件已经发生了,然后启动处理。事实上,很多时候都是不需要参数列表的信息。
这就是参数通配(parameter wildcarding)。虽然它好用,但也存在问题:因为我们忽略了参数,所以很容易和没有参数的方法的委托混在一块,像是下面这样:
public delegate void Show(); public delegate void Show2(String str); class Program { public static void Main(String[] args) { PrintMethod(delegate { Console.WriteLine("我是第一个委托类型"); } ); } public static void PrintMethod(Show show) { show(); } public static void PrintMethod(Show2 show) { show(""); } }
我们假设Show2该委托并没有使用到参数str。编译器根本不知道该调用哪一个委托,因为它们似乎都可以。这时我们只能显示的指定参数或者将匿名方法强制转换为正确的委托类型。
既然在C#中委托是一个类型,那我们可否将它作为参数呢?答案是可以的:
public delegate void Print(); class Program{ public static void PrintMethod(){ Console.WriteLine("我是委托方法"); } public static void PrintMethod2(Print print){ print(); } public static void Main(String[] args){ Print print = PrintMethod; PrintMethod2(print); } }
从这段代码中我们可以看到,委托类型作为参数,就可以回调它所绑定的方法,实现代码的复用,像是上面的例子,如果没有传递委托类型,我们的PrintMethod2()可能就要继续写Console.WriteLine()。
我们还能将上面的代码进行简化:
public delegate void Print(); class Program{ public static void PrintMethod(){ Console.WriteLine("我是委托方法"); } public static void PrintMethod2(Print print){ print(); } public static void Main(String[] args){ PrintMethod2(PrintMethod); } }
为什么能这样呢?因为我们的编译器知道PrintMethod2()的参数是一个委托,而委托实际上是包装方法的引用,所以,编译器会自己推断,该方法是否是委托所包装的,如果是,就会自己生成一个委托来包装该方法,但我们是看不到的。效率并没有得到提高,只是我们简化了语法而已。
在CLR中,有内置的委托类型:
public delegate void Action(); public delegate void Action<T>(T obj); public delegate void Action<T1, T2>(T1 arg1, T2 arg2); ..... public delegate TResult Func<TResult>(); public delegate TResult Func<T, TResult>(T arg); public delegate TResult Func<T1, T2, TResult>(T1 arg1, T2 arg2); .....
CLR鼓励我们使用这些内置的委托类型,防止系统中出现太多的类型。下面如果没有特别的声明,我都会使用这些内置的类型,像是这样修改前面的例子:
class Program { public static void Main(String[] args) { Action action = PrintMethod; action(); } public static void PrintMethod() { Console.WriteLine("我是委托方法"); } }
但我们有时候也要自定义委托类型,像是上面的内置类型参数超过16个的话(这种情况是不会发生的,能够写出16以上参数方法的人,我都不认为他是在编程),像是使用ref或out参数以便参数按引用的方式传递,因为这些内置的委托的名字都一样,而参数一旦按引用传递,就意味着该方法对该参数的修改都能直接反映在该变量上,这是很不安全的。还有就是委托要通过C#的param关键字获取可变数量的参数,要为委托的任何参数指定默认值,或者要对委托的泛型类型参数进行约束,等等。
二.委托的工作原理
委托是如此神奇,以至于我们甚至根本不相信有这样的东西。但委托这些神奇的特性并不是凭空产生的,我们可以从CLR中找到它们的实现机制。
我们先来个委托类型的声明:
public delegate void Show();
就是这样简单的一句声明,在编译器那边可是一个大事件:
public class Show : System.MulticastDelegate{ //构造器 public Show(Object object, IntPtr method); //与声明一样的方法 public virtual void Invoke(); .... }
编译器会生成一个与委托同名的类,该类有四个方法,这里只关注前两个,剩下两个方法涉及到异步调用,暂且不谈。
所有的委托都有构造器,该构造器接受两个参数:对象引用object和方法标识值method(整数)。对象引用其实就是this引用,而方法标识值是一个特殊的整数,它标识了我们想要回调的方法,它是从MethodDef或MemberRef元数据token获得,至于这两个东西到底是什么,这里不展开,因为我只是初学者,最好不要一开始就纠缠于编译器实现的内容。对于静态方法,object为null,因为静态方法没有this引用。
这两个值是理解委托工作原理最重要的部分。
我们先看看它们保存在哪里。在编译器生成的类中,有三分继承自MulticastDelegate的私有字段:
_target: 对象引用;
_methodPtr:标识要回调的方法的整数值;
_invocationList:构造方法组时,引用一个委托数组。
我们先看前两个,_target和_methodPtr。因为是私有,所以我们无法访问,但是,Delegate却有两个只读的公共属性:Target和Method,分别对应这两个值。
我们可以利用这两个公共属性做很多有趣的事情,比如说,我们可以检查委托对象引用的是否在一个特定类型中定义的方法:
Boolean Check(MulticastDelegate delegateMethod, Type type){ return((delegateMethod.Target != null) && delegateMethod.Target.GetType() == type); }
Boolean是布尔值的包装类型。我们还可以检查回调方法是否有一个特定的名称:
Boolean Check2(MulticastDelegate delegateMethod, String name){ return(delegateMethod.Method.Name == name); }
利用这两个属性,我们可以实现像是反射这种高级特性。
现在该轮到_invocationList。
这个字段非常特殊,一般都是null,但如果我们为委托添加一个方法组(method group, 有些地方称为委托链),该字段就会引用一个委托数组。在我们使用"+="为委托添加方法的时候,其实就是添加方法组。什么叫方法组呢?就是能够被委托实例包装的方法,它们的特点就是匹配委托声明的签名和返回值。当我们添加方法组的时候,编译器其实都在为我们添加的每一个方法生成一个委托,然后将该委托和前一个委托放进一个数组中,该数组的大小刚刚好能够容纳这两个委托。就是因为这样,使得每次添加新的方法(委托)时,编译器都必须重新创建一个新的数组来容纳新的委托,然后将之前的委托和数组都交给垃圾回收器回收。其实从_invocationList中的List就可以知道它的低层是怎样实现的。学过java的人一定对容器类List非常熟悉,它的工作原理也是同样的道理:低层是一个数组,当该数组无法容纳新的元素时,就会自动将数组大小扩为两倍。
从方法组中删除方法也是同样的过程,如果方法组中还有超过一个非null的元素,就会生成一个新的数组来容纳剩下的元素。但删除方法只能一次删除一个,而不是删除所有匹配的方法,事实上方法组中的所有方法都是匹配的。
了解了方法组,我们就不难看出,为什么委托会有上面那样神奇的特性。但神奇归神奇,这样的实现是存在很大的隐患的:如果方法组中的一个方法抛出异常,该怎么办?如果真丝这样,后面的委托就全废了!所以,MulticastDelegate提供了一个实例方法:GetInvocationList用于显示的调用方法组中的每一个委托:
Delegate[] arrayDelegate = delegateMethod.GetInvocationList();
得到该数组后,我们就能做很多事:可以遍历该数组,调用它里面的每一个方法,当然得进行null的判断,因为方法组允许包含null,而且我们也能捕获异常并进行处理。
方法组中的委托不能向上转型为System.Delegate,因为编译器会不知道该具体创建哪个委托(毕竟方法组中的声明是匹配的)。
三.匿名方法
学过java的人一定想起来,在java中,闭包的替代物就是内部类,那么,C#有没有类似的机制呢?
C#中类似的就是匿名方法。我们先来看一个简单的例子:
public delegate void Print(); class Program{ public static void Main(String[] args){ Print print = delegate{ Console.WriteLine("我是一个匿名方法"); }; print(); } }
匿名方法也可以具有参数,在使用匿名方法的时候,要注意,匿名方法可以使用方法外部定义的变量,但是无法使用外部定义的ref参数和out参数,比如说:
public void PrintMethod(ref int number){ Print print = delegate{ Console.WriteLine(number); }; }
这种方法中的匿名方法就不能使用方法的ref参数。为什么呢?ref参数的作用是将值类型当做引用类型传递,使得方法对参数都能反映到该变量中。如果我们允许匿名方法使用ref参数,那么我们的变量就会因为匿名方法而发生变化,这样其他方法所使用的该变量就会发生变化,从而引起错误。想想java的匿名类,它就要求使用到的参数都必须是final,也是同样的道理。
显然,使用匿名方法必须小心,如果发生错误,我们很难定位到该方法,因为它根本就没有名字,所以,在匿名方法中不能访问不安全的代码,像是
指针就不能在匿名方法中使用(它在C#中就认为是不安全的,谢天谢地,我一直都对使用指针心有余悸)。
匿名方法也不能使用跳转语句(break,continue,goto)跳至外部,反之亦然,匿名方法外部的跳转语句也不能跳转到匿名方法内部,因为它们实际上也是一个方法,从来没有见过跳转语句能够跨方法的。
使用匿名方法的最大好处就是减少系统开销,就像java的内部类一样,仅在需要的时候才定义。
匿名方法在CLR中并不是没有名字(就像java一样),但该名字是CLR生成的一串难以解读的字符串,该方法是private的,禁止不在类型内定义的任何代码访问该方法。至于是静态还是非静态的,就得看它里面给引用的成员变量,如果引用实例成员变量,就是非静态方法,其他情况一律为静态方法。
匿名方法更多是作为方法参数使用:
public delegate void Print(); class Program { public static void Main(String[] args) { Action<Print> action = PrintMethod; action(delegate{ Console.WriteLine("我是委托方法");}); } public static void PrintMethod(Print print) { print(); } }
这和java中的匿名内部类的使用时一样的道理,而且特别适合用在事件处理中:等到响应的时候才创建要响应的动作。但我们要注意代码的可读性。也许这样我们的代码会很简单,但正如java的匿名内部类,它使得我们的代码可读性和重构工作变得特别具有挑战性。
比较好的风格是这样的:
public delegate void Print(); class Program { public static void Main(String[] args) { Action<Print> action = PrintMethod; action(delegate { Console.WriteLine("我是委托方法");} ); } public static void PrintMethod(Print print) { print(); } }
这样匿名方法的参数是一行,代码体是一行,结尾的括号和分号表示这是匿名方法(事实上,java的匿名内部类就是这样的写法)。
匿名方法的出现,使得我们的委托机制非常强大,不光上面提到的地方,匿名方法还有一个更加重要的方面:捕获变量。
我们先来了解下闭包。
学习编程的人一定听说过这个词,它在计算机科学领域是一个非常古老的词汇。闭包的基本概念是:一个函数除了能通过提供给它的参数与环境互动之外,还能同环境进行更大程度的互动。这个概念是一个抽象甚至可以说是一个理想的说法,实际上的应用就是我们的匿名方法可以使用外部变量(看到这里,我非常淡定,因为java的匿名内部类也可以做到这点,因为它拥有外部类的this引用)。
所谓的外部变量,就是指其作用域(scope)包括一个匿名方法的局部变量或参数,this引用也是一个外部变量。匿名方法内部使用的外部变量就是被捕获的变量(captured outer variable)。
对应闭包的概念,我们可以这样理解:函数指匿名方法,互动环境指由这个匿名方法捕捉到的变量集。
匿名方法捕捉到的变量就是原变量,而不是它的值(即副本),我们所做的修改都可以反映到该变量上。
匿名方法对外部变量的捕获并不仅仅是使用该变量,甚至可以延长该变量的寿命。我们知道,定义在方法内部的变量一旦离开方法体就会被回收,但如果是被方法体内的匿名方法捕获呢?只要包装匿名方法的委托实例不被回收,该变量就会一直存在。
这种情况就真的是让人感到匪夷所思了。要想明白原理,我们还是要回到CLR中(事实上,要想真正明白C#,CLR的学习是必须的)。编译器会创建额外的类来容纳变量,而之前定义该变量的类的实例拥有该变量的引用,捕获到该变量的匿名方法同样也拥有该变量的引用。所以,方法内的变量并不是我们想象中存储在方法对应的栈帧中。我们知道,所有引用都是在拥有该引用的实例的堆上,除非委托被回收,否则该引用就不会被销毁。
这种情况埋下了隐患:被延长生命周期的变量其实就是明显的内存泄露(leak)。这是我们在编程中要注意的。
对于变量捕获的讨论远非如此。我们来想想这样的情况:
class Program{
public static void Main(String[] args){
List<Action> list = new List<Action>();
for (int i = 0; i < 5; i++){
list.Add(delegate
{ Console.WriteLine(i); }
);
}
foreach (Action action in list){
if (action != null){
action();
}
}
}
}
我们捕获的是循环量i,它同样是外部变量,那么所有捕获到该变量的委托是否都共享同一个i呢?是的,而且所有委托的值都是5,因为在循环结束的时候,i的值已经变成了5。但问题依然没有结束:如果是在循环内部定义的变量呢?像是这样:
class Program { public static void Main(String[] args) { List<Action> list = new List<Action>(); for (int i = 0; i < 5; i++) { int counter = 0; list.Add(delegate { Console.WriteLine(counter); counter += 10; } ); } foreach (Action action in list) { if (action != null) { action(); } } } }
显然每次捕获到的变量都是不一样的,因为每次都是重新创建该变量。
在循环初始部分声明的变量只被实例化一次,所以我们想要捕获一次特定循环中的循环量,方法很简单:定义一个变量,将该循环量赋值给该变量,然后被委托捕获。
于是我们也就知道编译器在编译一个包含匿名方法的类的时候所做的行为:创建一个包含外部变量(共享变量)的额外类,然后再创建另一个额外类,该类包含内部变量(非共享变量)和对第一个额外类的引用。
是否要捕获变量,只有一个原则:捕获变量是否能使我们的代码更加简单,如果不能,请不要捕获变量。
四.Lambda表达式
说到匿名方法,我们就必须提到一个名词:Lambda表达式。Lambda表达式来自于数学,最早用在Lisp中(如果看过《黑客与画家》的同学,一定对该语言很熟悉,因为作者一直在吹捧该语言的强大)。它的来源我们不管,我们只管它与匿名方法的关系。事实上,匿名方法可以用一个Lambda表达式来表示,我们将上面的方法改一下:
public delegate void Print(); class Program{ public static void Main(String[] args){ Print print = () =>{ Console.WriteLine("我是一个匿名方法"); }; print(); } }
很古怪吧!Lambda表达式通常是这样子的:(param)=>expression,param是参数列表,expression可以是表达式,也可以是上面的语句块,也就是要执行的动作。括号在具有唯一一个显式类型参数的情况下可以去除,但参数不唯一时不可以,参数可以显示的指定类型,也可以省略掉,类型推断机制可以判断该参数的类型,如何判断呢?从委托签名中就可以知道具体的参数信息。
Lambda表达式无法向编译器生成的方法应用定制attribute,至于什么是属性定制,这里不详谈。
Lambda表达式的更大好处就是它从代码中移除了间接层。正常情况下,我们必须写一个单独的方法,为该方法命名,然后在需要委托的地方传递这个方法名。方法名提供了引用代码主体的一种方式,如果要在多个地方引用同一个代码主体,单独写一个方法并命名确实是一个理想的方案,但是,如果只需在代码中引用这个主体一次,Lambda表达式允许我们直接内联这些代码,不必为它们分配名称。想起来了吧,这和C++的内联机制是一样的,但内联是在头文件中,这会使所有源文件都会嵌入这段代码,增加额外的负担,而Lambda表达式则没有这样的坏处。
使用Lambda表达式虽然非常便利,但是它和匿名方法存在同样的问题:捕获变量生命周期的延长。
我们很容易大量使用"语法糖果"(语法简化),Lambda表达式就是一个例子。如果代码中大量充斥着这样的代码,可读性是很低的。一般,如果回调方法中需要3行以上的代码,还是老老实实写方法吧。
五.委托的优点
委托除了拥有上面提到的神奇特性外,它本身是安全的,因为委托实例是不可变(immutable)的。我们创建一个委托实例后,有关它的一切就不能改变,所以我们可以安全的传递诶托实例,合并委托实例,同时不必担心一致性,线程安全性或者有人试图修改它。就因为委托是安全的,所以任何非安全的代码都不能出现在委托中,像是指针,它在C#中就是"unsafe"的(谢天谢地,以前学C++的时候,我就总是担心用错了指针)。
当我这个C#的初学者接触委托的时候,我就被它后面隐藏的大量知识点震惊了!也使我深刻明白,要用好C#,委托是必须掌握的。所以,大家学习C#的时候,对委托这部分一定要狠下功夫,学习C3一定会事半功倍。