1. 程式人生 > >委託本質

委託本質

前言

  委託和事件是c#基礎中兩個重要的知識,平時工作中也會經常用到。接下來我會寫兩篇我對委託和事件的理解,歡迎拍磚。

  回撥函式是一種非常有用的程式設計機制,許多語言都對它提供了支援。回撥函式是一個通過函式指標呼叫的函式。通常,我們會把回撥函式作為引數傳遞給另一個函式,當某些事件發生或滿足某些條件時,由呼叫者執行回撥函式用於對該事件或條件進行響應。簡單來說,實現回撥函式有如下步驟:

  1. 定義一個回撥函式。

  2. 將回調函式指標註冊給呼叫者。

  3. 在某些事件或條件發生時,呼叫者通過函式指標呼叫回撥函式對事件進行處理。

  回撥機制的應用非常多,例如控制元件事件、非同步操作完成通知等等;.net 通過委託來實現回撥函式機制。相比其他平臺的回撥機制,委託提供了更多的功能,例如它確保回撥方法是型別安全的,支援順序呼叫多個方法,以及呼叫靜態方法和例項方法。

一、初識委託

  在開始接觸委託前,相信很多人都會感覺它用起來怪怪的,有些彆扭。理解它的本質後,就知道許多時候其實是編譯器在背後“搞鬼”;編譯器做了大量的工作,目的是為了減少程式碼的編寫以及讓程式碼看起來更優雅。接下來就讓我們逐步深入理解委託。

  先看一段簡單的程式碼:  

?

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

//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,對應用來從委託鏈中移除指定的委託。接下來我們來看這個具體的過程。如下程式碼:

?

1

2

3

4

5

6

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虛擬碼看起來也許像下面這樣:

?

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

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內部就有許多這樣的例子,平時我們也經常看到。例如:

?

1

2

3

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 不需要定義回撥方法

  有時候回撥方法只有很簡單的幾行,為了程式碼更緊湊和方便閱讀,我們不想要定義一個方法。這個時候可以使用匿名方法,如:

?

1

ExecuteDelegate(delegate { Console.WriteLine("使用匿名方法"); }, 10);

  匿名方法也是用delegate關鍵字修飾的,形式為 delegate(引數){方法體}。匿名方法是c#2.0提供的,c#3.0提供了更優雅的lambda表示式來代替匿名方法。如:

?

1

ExecuteDelegate(obj => Console.WriteLine("使用lambda表示式"), 10);

  實際上編譯器發現方法的形參是一個委託,而我們傳遞了lambda表示式,編譯會嘗試隨機為我們生成一個外部不可見的特殊方法,本質上還是在原始碼中定義了一個新的方法,我們可以通過反編譯工具看到這個行為。lambda提供的更方便的實現方式,但在方法有重用或者實現起來比較複雜的地方,還是推薦重新定義一個方法。

五、委託與反射

  雖然委託型別直接繼承了MulticastDelegate,但Delegate提供了許多有用的方法,實際上這兩個都是抽象類,只要提供一個即可,可能是.net設計的問題,搞了兩個出來。Delegate提供了CreateDelegate 和 DynamicInvoke兩個關於反射的方法。CreateDelegate提供了多種過載方式,具體可以檢視msdn;DynamicInvoke引數數一個可變的object陣列,這就保證了我們可以在對引數未知的情況下對方法進行呼叫。如:

?

1

2

3

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系列(還沒寫完,囧)後續也會提到。

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