前言
委托和事件是c#基礎中兩個重要的知識,平時工作中也會經常用到。接下來我會寫兩篇我對委托和事件的理解,歡迎拍磚。
回調函數是一種非常有用的編程機制,許多語言都對它提供了支持。回調函數是一個通過函數指針調用的函數。通常,我們會把回調函數作為參數傳遞給另一個函數,當某些事件發生或滿足某些條件時,由調用者執行回調函數用於對該事件或條件進行響應。簡單來說,實現回調函數有如下步驟:
1. 定義一個回調函數。
2. 將回調函數指針注冊給調用者。
3. 在某些事件或條件發生時,調用者通過函數指針調用回調函數對事件進行處理。
回調機制的應用非常多,例如控件事件、異步操作完成通知等等;.net 通過委托來實現回調函數機制。相比其他平台的回調機制,委托提供了更多的功能,例如它確保回調方法是類型安全的,支持順序調用多個方法,以及調用靜態方法和實例方法。
一、初識委托
在開始接觸委托前,相信很多人都會感覺它用起來怪怪的,有些別扭。理解它的本質后,就知道許多時候其實是編譯器在背后“搞鬼”;編譯器做了大量的工作,目的是為了減少代碼的編寫以及讓代碼看起來更優雅。接下來就讓我們逐步深入理解委托。
先看一段簡單的代碼:
//1.定義一個委托類型 delegate void TestDelegate(int value); static void Main(string[] args) { //2.傳遞null ExecuteDelegate(null, 10); //3.調用靜態方法 TestDelegate test1 = new TestDelegate(StaticFunction); ExecuteDelegate(test1, 10); //4.調用實例方法 Program program = new Program(); TestDelegate test2 = new TestDelegate(program.InstanceFunction); ExecuteDelegate(test2, 10); //5.調用多個方法 TestDelegate test3 = (TestDelegate)Delegate.Combine(test1, test2); ExecuteDelegate(test3, 10); } //靜態方法 static void StaticFunction(int value) { Console.WriteLine("Call StaticFunction: " + value.ToString()); } //實例方法 void InstanceFunction(int value) { Console.WriteLine("Call InstanceFunction: " + value.ToString()); } //執行委托 static void ExecuteDelegate(TestDelegate tg, int value) { if (tg != null) { tg(value); } }
第1步,用delegate關鍵字定義了一個委托類型,名稱為TestDelegate。它的簽名為:1. 返回值為void 2. 有一個int類型的參數。回調函數的簽名必須與之一樣,否則編譯會報錯。
第2步,調用執行委托的方法並傳遞了null,實際上什么也沒做。這里說明了委托可以作為參數,可以為null,似乎與引用類型相似。
第3步,用 new 創建了一個TestDelegate的變量test1, 並將靜態方法作為參數,它符合委托的簽名。通過new 來創建,我們基本可以推測TestDelegate是一個引用類型。
第4步,與3類似,只不過它傳遞的參數是一個實例方法,所以需要先創建方法的對象Program。
第5步,調用了Delegate.Combine()方法,通過名稱可以指定它用於將多個委托組合起來,調用test3時,會按照它的參數順序執行所有方法。這種方式有時候非常有用,因為我們很可能在某個事件發生時,要執行多個操作。
通過上面的代碼,我們基本可以知道委托是用來包裝回調函數的,對回調函數的調用其實是通過委托來實現的,這也是很符合【委托】的稱呼。那么委托到底是一種什么樣的類型?為什么它可以將函數名稱作為參數?為什么可以像tg(value)這樣來執行?Delegate.Combine內部的實現機制又是怎樣的?接下來讓我們一一解答。
二、委托揭秘
上面提到,c#編譯器為了簡化代碼的編寫,在背后做了很多處理。委托的確是一種用來包裝函數的引用類型,當我們用delegate定義上面的委托時,編譯器會為我們生成一個class TestDelegate的類,這個類就是用來包裝回調函數的。通過ILDasm.exe查看上面的IL代碼可以很清晰看到這個過程:
可以看到,編譯器為我們生成了一個 TestDelegate 的class 類型,並且它還繼承了MulticastDelegate。實際上,所有的委托都會繼承MulticastDelegate,而MulticastDelegate又繼承了Delegate。Delegate有2個重要的非公共字段:
1. _target: object類型,當委托包裝的是實例方法時,這個字段引用的是實例方法的對象;如果是靜態方法,這個字段就是null。
2. _methodPtr: IntPtr類型,一個整數值,用於標識回調方法。
所以對於實例方法,委托就是通過實例對象去調用所包裝的方法的。Delegate還公開了兩個屬性,Target和Method分別表示實例對象(靜態方法為null)和包裝函數的元信息。
可以看到經過編譯器編譯后生成的這個類有4個函數,.ctor(構造函數),BeginInvoke, EndInvoke, Invoke。BeginInvoke/EndInvoke 是Invoke的異步版本,所以我們主要關注.ctor和Invoke函數。
.ctor構造函數有兩個參數,一個object類型,一個int類型。但當我們new一個委托對象時,傳遞卻是一個方法的名稱。實際上,編譯器知道我們要構造的是委托對象,所以會分析源代碼知道要調用的是哪個對象和方法;對象引用就是作為第一個參數(如果靜態就為null),而從元數據獲取用於標識函數的特殊值就作為第二個參數,從而調用構造函數。這兩個參數分別保存在 _target 和 _methodPth字段中。
Invoke 函數顧名思義就是用來調用函數的,當我們執行tg(value)時,編譯器發現tg引用的是一個委托對象,所以生成的代碼就是調用委托對象的Invoke方法,該方法的簽名與我們簽名定義的簽名是一致的。生成的IL代碼如: callvirt instance void TestDelegate2.Program/TestDelegate::Invoke(int32)。
至此,我們知道定義委托就是定義類,這個類用來包裝回調函數。通過該類的Invoke方法執行回調函數。
三、委托鏈
前面說到所有的委托類型都會繼承MulticastDelegate。MulticastDelegate表示多路廣播委托,其調用列表可以擁有多個委托,我們稱之為委托鏈。簡單的說,它擁有一個委托列表,我們可以順序調用里面所有方法。通過源碼可知,MulticastDelegate有一個_invocationList字段,用於引用一個委托對象數組;我們可以通過Delegate.Combine將多個委托添加到這個數組當中,既然有Combine就會有Remove,對應用來從委托鏈中移除指定的委托。接下來我們來看這個具體的過程。如下代碼:
TestDelegate test1 = new TestDelegate(StaticFunction); //1 TestDelegate test2 = new TestDelegate(StaticFunction); //2 TestDelegate test3 = new TestDelegate(new Program().InstanceFunction); //3 TestDelegate result = (TestDelegate)Delegate.Combine(test1, test2); //4 result = (TestDelegate)Delegate.Combine(result, test3); //5 Delegate.Remove(result, test1); //6
當執行1~3行時,會創建3個TestDelegate對象,如下所示:
執行第4行時,會通過Delegate.Combine創建一個具有委托鏈的TestDelegate對象,該對象的_target和_methodPtr已經不是我們想關注的了,_invocationList引用了一個數組對象,數組有test1,test2兩個元素。如下:
執行第5行代碼時,同樣會重新創建一個具有委托鏈的TestDelegate對象,此時_invocationList具有3個元素。需要注意的是,由於Delegate.Combine(或者Remove)每一次都會重新創建委托對象,所以第4行的result引用的對象不再被引用,此時它可以被回收了。如:
執行Remove時,與Combine類似,都會重新創建委托對象,此時從數組移除test1委托對象,這里就不在重復。
通過上面的分析,我們知道調用方法實際就是調用委托對象的Invoke方法,如果_invocationList引用了一個數組,那么它會遍歷這個數組,並執行所有注冊的方法;否則執行_methodPtr方法。Invoke偽代碼看起來也許像下面這樣:
public void Invoke(Int32 value) { Delegate[] delegateSet = _invocationList as Delegate[]; if (delegateSet != null) { foreach (var d in delegateSet) { d(value); } } else { _methodPtr.Invoke(value); } }
_invocationList畢竟是內部字段,默認情況下會按順序調用,但有時候我們想控制這個過程,例如按某些條件執行或者記錄異常等。MulticastDelegate有一個GetInvocationList()方法,用於獲取Delegate[]數組,有了該數組,我們就可以控制具體的執行過程了。
四、泛型委托
我們可能會在多個地方用到委托,例如在另一個程序集,我們可能會定義一個 delegate void AnotherDelegate(int value); 這個委托的簽名和簽名的是一樣的。實際上.net內部就有許多這樣的例子,平時我們也經常看到。例如:
public delegate void WaitCallback(object state); public delegate void TimerCallback(object state); public delegate void ParameterizedThreadStart(object obj);
上面只是這種簽名的形式,另外一種形式也可能出現大量的重復,這將給代碼維護帶來很大的難度。泛型委托就是為了解決這個問題的。
.net 已經定義了三種類型的泛型委托,分別是 Predicate、Action、Func。在使用linq的方法語法中,我們會經常遇到這些類型的參數。
Action 從無參到16個參數共有17個重載,用於分裝有輸入值而沒有返回值的方法。如:delegate void Action<T>(T obj);
Fun 從無參到16個參數共有17個重載,用於分裝有輸入值而且有返回值的方法。如:delegate TResule Func<T>(T obj);
Predicate 只有一種形式:public delegate bool Predicate<T>(T obj)。用於封裝傳遞一個對象然后判斷是否滿足某些條件的方法。Predicate也可以用Func代替。
有了泛型委托,我們就不用到處定義委托類型了,除非不滿足需求,否則都應該優先使用內置的泛型委托。
五、c#對委托的支持
5.1 +=/-= 操作符
c#編譯器自動為委托類型重載了 += 和 -= 操作符,簡化編碼。例如要添加一個委托對象到委托鏈中,我們也可以 test1 += test2; 編譯器可以理解這種寫法,實際上這樣寫和調用test1 = Delegate.Combine(test1, test2) 生成的 IL 代碼是一樣的。
5.2 不需要構造委托對象
在一個需要使用委托對象的地方,我們不必每次都new 一個,只傳遞要包裝的函數即可。例如:test1 += StaticFunction; 或者 ExecuteDelegate(StaticFunction, 10);都是直接傳遞函數。編譯器可以理解這種寫法,它會自動幫我們new 一個委托對象作為參數。
5.3 不需要定義回調方法
有時候回調方法只有很簡單的幾行,為了代碼更緊湊和方便閱讀,我們不想要定義一個方法。這個時候可以使用匿名方法,如:
ExecuteDelegate(delegate { Console.WriteLine("使用匿名方法"); }, 10);
匿名方法也是用delegate關鍵字修飾的,形式為 delegate(參數){方法體}。匿名方法是c#2.0提供的,c#3.0提供了更優雅的lambda表達式來代替匿名方法。如:
ExecuteDelegate(obj => Console.WriteLine("使用lambda表達式"), 10);
實際上編譯器發現方法的形參是一個委托,而我們傳遞了lambda表達式,編譯會嘗試隨機為我們生成一個外部不可見的特殊方法,本質上還是在源碼中定義了一個新的方法,我們可以通過反編譯工具看到這個行為。lambda提供的更方便的實現方式,但在方法有重用或者實現起來比較復雜的地方,還是推薦重新定義一個方法。
五、委托與反射
雖然委托類型直接繼承了MulticastDelegate,但Delegate提供了許多有用的方法,實際上這兩個都是抽象類,只要提供一個即可,可能是.net設計的問題,搞了兩個出來。Delegate提供了CreateDelegate 和 DynamicInvoke兩個關於反射的方法。CreateDelegate提供了多種重載方式,具體可以查看msdn;DynamicInvoke參數數一個可變的object數組,這就保證了我們可以在對參數未知的情況下對方法進行調用。如:
MethodInfo methodInfo = typeof(Program).GetMethod("StaticFunction", BindingFlags.Static | BindingFlags.NonPublic); Delegate funcDelegate = Delegate.CreateDelegate(typeof(Action<int>), methodInfo); funcDelegate.DynamicInvoke(10);
這里我們只需要知道方法的名稱(靜態或實例)和委托的類型,完全不用知道方法的參數個數、具體類型和返回值就可以對方法進行調用。
反射可以帶來很大靈活性,但效率一直是個問題。有幾種方式可以對其進行優化。基本就是:Delegate.DynamicInvoke、Expression(構建委托) 和 Emit。從上面可以看到,DynamicInvoke的方式還是需要知道委托的具體類型(Action<int>部分),而不能直接從方法的MethodInfo元信息直接構建委托。當在知道委托類型的情況下,這種情況下是最簡單的實現方式。
使用委托+緩存來優化反射是我比較喜歡的方式,相比另外兩種做法,可以兼顧效率和代碼的可讀性。具體的實現方式大家可以在網上找,或者參考我的Ajax系列(還沒寫完,囧)后續也會提到。
委托和事件經常會聯系在一起,一些面試官也特別喜歡問這個問題。它們之間究竟是一個什么樣的關系,下一篇就對事件展開討論。