C#復習筆記(3)--C#2:解決C#1的問題(進入快速通道的委托)
委托
前言: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語句也適用。
- 必要時創建額外的類型來保存捕獲變量。 要小心! 簡單幾乎總是比耍小聰明好。
C#復習筆記(3)--C#2:解決C#1的問題(進入快速通道的委托)