委托與事件,這是一個老生常談的話題,很多人在講,很多人在用,但似乎它是一個永遠也說不完道不盡的東西。那么,到底什么是委托?什么是事件?委托鏈又是怎么回事?為什么使用事件時常常用到+=/-=?委托又是如何支持協變和逆變的呢?你喜歡使用Action和Func<T,TResult>嗎?由於內容比較多,這一章將分上、下兩部分慢慢為你講解。
回調函數是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<T,TResult>系列:
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<T>系列 和 Func<T,TResult>系列 。
C#的lambda表達式為委托的簡化使用顯示出了很不錯的編程體驗。可以參考MSDN的相關章節:Lambda 表達式(C# 編程指南) 。
這一章的上半部分,我們主要講解了與委托相關的內容,后一篇的下半部分將主要講解什么是事件及委托如何與事件共事。
