委托
前言:C#1中就已经有了委托的概念,但是其繁杂的用法并没有引起开发者太多的关注,在C#2中,进行了一些编译器上的优化,可以用匿名方法来创建一个委托。同时,还支持的方法组和委托的转换。顺便的,C#2中增加了委托的协变和逆变。
方法组转换
方法组这个词的含义来自于方法的重载:我们可以定义一堆方法,这堆方法的名称都一样,但是接受的参数不同或者返回类型不同(总之就是签名不同----除了名字),这就是方法的重载。
public static void SomeMethod(object helloworld) { Console.WriteLine(helloworld); } public static void SomeMethod() { Console.WriteLine("hello world"); }
ThreadStart ts = SomeMethod;
ParameterizedThreadStart ps = SomeMethod;
上面显示的两个调用没有问题,编译器能够找到与之匹配的相应方法去实例化相应的委托,但是,问题在于,对于本身已经重载成使用ThreadStart和ParameterizedThreadStart的Thread类来说(这里是举例,当然适用于所有这样的情况),传入方法组会导致编译器报错:
Thread t=new Thread(SomeMethod); //编译器报错:方法调用具有二义性
同样的情况不能用于将一个方法组直接转换成Delegate,需要显式的去转换:
Delegate parameterizedThreadStart = (ParameterizedThreadStart) SomeMethod;
Delegate threadStart = (ThreadStart) SomeMethod;
协变性和逆变性
C#1并不支持委托上面的协变性和逆变性,这意味着要为每个委托定义一个方法去匹配。C#2支持了委托的协变和逆变,这意味着我们可以写下如下的代码:
假定两个类,其中一个继承另一个:
public class BaseClass { } public class DerivedClass : BaseClass { }
C#2支持如下写法:
class Program { delegate BaseClass FirstMethod(DerivedClass derivedClass); static void Main(string[] args) { FirstMethod firstMethod = SomeMethod; Console.ReadKey(); } static DerivedClass SomeMethod(BaseClass derivedClass) { return new DerivedClass(); } }
而在C#4中,支持了泛型类型和泛型委托的协变和逆变:
public class BaseClass{}
public class DerivedClass : BaseClass{}
Func<BaseClass, DerivedClass> firstFunc = delegate(BaseClass baseClass)
{ return new DerivedClass(); };
Func<DerivedClass, BaseClass> secondFunc = firstFunc;
本质上C#4泛型上的协变和逆变只是引用之间的转换,并没有在后面创建一个新的对象。
不兼容的风险
C#2支持了委托协变和逆变后会出现下面的问题:
假设现在BaseClass和DerivedClass改为下面这样的:
public class BaseClass { public void CandidateAction(string x) { Console.WriteLine("Baseclass.CandidateAction"); } } public class DerivedClass : BaseClass { public void CandidateAction(object x) { Console.WriteLine("Derived.CandidateAction"); } }
在DerivedClass中重载了BaseClass中的方法,由于C#2的泛型逆变和协变,写下如下代码:
class Program { delegate void FirstMethod(string x); static void Main(string[] args) { DerivedClass derivedClass=new DerivedClass(); FirstMethod firstMethod = derivedClass.CandidateAction; firstMethod("hello world");//DerivedClass.CandidateAction Console.ReadKey(); } }
输出结果是”DerivedClass.CandidateAction!看到的这个结果肯定是在C#2以及以后的结果,如果在C#1中,那么该结果应该是输出“BaseClass.CandidateAction"
匿名方法
下面这个出场的匿名方法是我们之后学习linq和lambda等等一系列重要概念的始作俑者。
首先他要解决的问题是C#1中的委托调用起来太繁琐的问题。在C#1中,要建立一个委托并使用这个委托的话通常要经历四部,关键是不管你要调用一个多么简单的委托都要写一个专门被委托调用的方法放到类里面,如果没有合适的类的话你还要新建一个类。。。
匿名方法是编译器耍的小把戏,编译器会在后台创建一个类,来包含匿名方法所表示的那个方法,然后和普通委托调用一样,经过那四部。CLR根本不知道匿名委托这个东西,就好像它不存在一样。
如果不在乎参数,可以省略:delegate{...do something..},但涉及到方法重载时,要根据编译器的提示补充相应的参数。
匿名方法捕获的变量
闭包。
delegate void MethodInvoker(); void EnclosingMethod() { int outerVariable = 5; //? 外部变量( 未捕获的变量) string capturedVariable = "captured"; //? 被匿名方法捕获的外部变量 if (DateTime. Now. Hour == 23) { int normalLocalVariable = DateTime. Now. Minute; //? 普通方法的局部变量 Console. WriteLine( normalLocalVariable); } MethodInvoker x = delegate() { string anonLocal = "local to anonymous method"; //? 匿名方法的局部变量 Console. WriteLine( capturedVariable + anonLocal); //? 捕获外部变量 }; x(); }
被匿名方法捕捉到的确实是变量, 而不是创建委托实例时该变量的值。只有在委托被执行的时候才会去采集这个被捕获变量的值:
int a = 4; MethodInvoker invoker = delegate() { a = 5; Console.WriteLine(a); }; Console.WriteLine(a);//4 invoker();//5
要点在于,在整个方法中,我们使用的是同一个被捕获的变量。
捕获变量的好处
简单地说, 捕获变量能简化避免专门创建一些类来存储一个委托需要处理的信息(除了作为参数传递的信息之外)。
捕获的变量的生命周期
对于一个捕获变量, 只要还有任何委托实例在引用它, 它就会一直存在。
delegate void MethodInvoker(); static MethodInvoker CreateMethodInvokerInstance() { int a = 4; MethodInvoker invoker = delegate () { Console.WriteLine(a); a++; }; invoker(); return invoker; }
static void Main(string[] args) { MethodInvoker invoker = CreateMethodInvokerInstance();//4 invoker();//5 invoker();//6 Console.ReadKey(); }
可以看到,CreateDelegateInstance执行完成后,它对应的栈帧已经被销毁,按道理说局部变量a也会随之寿终正寝,但是后面还是会继续输出5和6,原因就在于,编译器为匿名方法创建的那个类捕获了这个变量并保存它的值!CreateDelegateInstance拥有对该类的实例的一个引用,所以它能使用变量a,委托也有对该类的实例的一个引用,所以也能使用变量a。这个实例和其他实例一样都在堆上。
局部变量实例化
每当执行到声明一个局部变量的作用域时, 就称该局部变量被实例化 。
局部变量被声明到栈上,所以在for这样的结构中不必每次循环都实例化。
局部变量多次被声明和单次被声明产生的效果是不一样的。
delegate void MethodInvoker(); static void Main(string[] args) { List<MethodInvoker> methodInvokers=new List<MethodInvoker>(); for (int i = 0; i < 10; i++) { int count = i * 10; methodInvokers.Add(delegate() { Console.WriteLine(count); count++; }); } foreach (var item in methodInvokers) { item(); } methodInvokers[0]();//1 methodInvokers[0]();//2 methodInvokers[0]();//3 methodInvokers[1]();//11 Console.ReadKey(); }
上面的例子中,count在每次循环中都重新创建一次,导致委托捕获到的变量都是新的、不一样的变量,所以维护的值也不一样。
如果把count去掉,换成这样:
delegate void MethodInvoker(); static void Main(string[] args) { List<MethodInvoker> methodInvokers = new List<MethodInvoker>(); for (int i = 0; i < 10; i++) { methodInvokers.Add(delegate () { Console.WriteLine(i); i++; }); } foreach (var item in methodInvokers) { item(); } methodInvokers[0](); methodInvokers[0](); methodInvokers[0](); methodInvokers[1](); Console.ReadKey(); }
这次委托直接捕获的是i这个变量,for循环中的循环变量被认为是声明在for循环外部的一个变量,类似于下面的代码:
int i=0; for(i;i<10;i++) { ..... }
注意,这个例子可以用局部变量只被实例化一次还是多次的道理说服,背后的原理是编译器创建的那个类实例化的地方不一样。第一次用count变量来接受i的值时,在for循环的内部每循环一次编译器都会创建一个新的实例来保存count的值并被委托调用,而把count去掉时,编译器创建的这个类会在for循环外部被创建,所以只会创建一次,捕获的时i的最终的那个值。所以,我猜想,编译器创建的那个类和被捕获的变量的作用域时有关系的,编译器创建的那个类的实例化的位置应该和被捕获的变量的实例化的位置或者说是作用域相同。
看下面的例子:
delegate void MethodInvoker(); static void Main(string[] args) { MethodInvoker[] methods=new MethodInvoker[2]; int outSide = 1; for (int i = 0; i < 2; i++) { int inside = 1; methods[i] = delegate() { Console.WriteLine($"outside:{outSide}inside:{inside}"); outSide++; inside++; }; } MethodInvoker first = methods[0]; MethodInvoker second = methods[1]; first(); first(); first(); second(); second(); Console.ReadKey(); }
这张图说明了上面的问题。
使用捕获变量时, 请参照以下规则。
- 如果用或不用捕获变量时的代码同样简单, 那就不要用。
- 捕获由for或foreach语句声明的变量之前, 思考你的委托是否需要在循环迭代结束之后延续, 以及是否想让它看到那个变量的后续值。 如果需要, 就在循环内另建一个变量, 用来复制你想要的值。( 在 C# 5 中, 你 不必 担心 foreach 语句, 但 仍需 小心 for 语句。) 如果创建多个委托实例(不管是在循环内, 还是显式地创建), 而且捕获了变量, 思考一下是否 希望它们捕捉同一个变量。
- 如果捕捉的变量不会发生改变( 不管是在匿名方法中, 还是在包围着匿名方法的外层方法主体中), 就不需要有这么多担心。
- 如果你创建的委托实例永远不从方法中“ 逃脱”, 换言之, 它们永远不会存储到别的地方, 不会返回, 也不会用于启动线程—— 那么事情就会简单得多。
- 从垃圾回收的角度, 思考任 捕获变量被延长的生存期。 这方面的问题一般都不大, 但假如捕获的对象会产生昂贵的内存开销, 问题就会凸现出来。
[英]Jon Skeet. 深入理解C#(第3版) (图灵程序设计丛书) (Kindle 位置 4363-4375). 人民邮电出版社. Kindle 版本.
本章划重点
- 捕获的是变量, 而不是创建委托实例时它的值。
- 捕获的变量的生存期被延长了, 至少和捕捉它的委托一样 长。
- 多个委托可以捕获同一个变量……
- …… 但在循环内部, 同一个变量声明实际上会引用不同的变量“ 实例”。
- 在for循环的声明中创建的变量仅在循环持续期间有效—— 不会在每次循环迭代时都实例化。 这一情况对 C# 5之前的foreach语句也适用。
- 必要时创建额外的类型来保存捕获变量。 要小心! 简单几乎总是比耍小聪明好。