委托


前言

  委托和事件是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系列(還沒寫完,囧)后續也會提到。

  委托和事件經常會聯系在一起,一些面試官也特別喜歡問這個問題。它們之間究竟是一個什么樣的關系,下一篇就對事件展開討論。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM