重中之重:委託與事件

周見智發表於2015-06-16

相關文章連結

程式設計之基礎:資料型別(一)

程式設計之基礎:資料型別(二)

高屋建瓴:梳理程式設計約定

動力之源:程式碼中的泵

難免的尷尬:程式碼依賴

可複用程式碼:元件的來龍去脈

物以類聚:物件也有生命

重中之重:委託與事件

委託是.NET程式設計中的重點之一,委託的作用簡單概括起來就是"呼叫方法"。使用委託,我們可以非同步(同步)呼叫方法、一次呼叫多個方法甚至可以將方法作為引數傳遞給別人供別人回撥。程式的執行過程便是方法之間的呼叫過程,所以委託是.NET開發者必須掌握的知識點之一。.NET程式設計中的事件建立在委託的基礎之上,要掌握事件的用法必須先了解委託。

5.1 什麼是.NET中的委託

委託的字面意思為"把什麼什麼東西託付給某某人去做",偏向於一個動作,但是在.NET中,委託卻是一個名詞,表示"代理"或者"中間人"的意思。A本來要找B辦事,但是它沒有直接找B,而是託付給C,讓C去找B把事兒給辦了,如果按照"委託"字面意思去理解,"A找C"的這個行為叫"委託",但是在.NET中,C這個人叫"委託"。

 

圖5-1 .NET中委託含義

圖5-1中A表示請求辦事情的人(請求方),B是最終處理事情的人(應答方),C表示.NET中的委託。既然C是中間人,那麼它肯定包含有B的一些資訊,不然怎麼去找B辦事情?

注:本書中之後出現的所有與"委託"有關的詞彙均指.NET中的委託,也就是圖5-1中的C部分。另外,按照第二章中所講的內容,A可以稱為ClientB則稱為Server

5.1.1 委託的結構

委託的職責就是代替請求方去找應答方辦事情,在程式中,體現為呼叫應答方的方法,換句話說,委託其實就是起到"呼叫方法"的作用。

程式中呼叫一個方法的必備條件是:知道要呼叫的方法,知道這個方法的所有者(如果該方法為例項方法)。因此一個委託中至少要包含圖5-2中的資訊:

圖5-2 委託組成

圖5-2中顯示一個委託的結構組成,它至少包含要呼叫的方法Method和方法的所有者Target(如果方法為靜態方法,Target為null)。也就是說,委託是一種資料結構,我們可以把它看作是一種型別,型別裡面包含一些成員。事實上,.NET中的委託就是一種型別,有著共同的基類Delegate,我們程式中定義的各種各樣的委託都是從該類派生而來。

注:我們使用到的委託型別都派生自MulticastDelegate類,後者再派生自Delegate型別。系統不允許我們像定義普通型別的方式顯式從這兩個型別派生出新的委託,只能使用一種特殊定義型別的方法(後面有講到)。另外,我們平時常說的"委託"是指一個委託型別的物件,本書中可以根據上下文判斷"委託"是指委託型別還是委託型別的物件。

由於每個方法的簽名不一樣,因此一種委託只能負責呼叫一種型別的方法,也就是說,我們在定義委託型別的時候,必須提供它能夠呼叫方法的簽名,因此,.NET中規定,以如下形式去定義一個委託型別:

1 //Code 5-1
2 public delegate void DelegateName(object[] arg1);

像普通宣告一個方法一樣,提供方法名稱、引數、訪問修飾符以及返回值,然後在前面加上delegate關鍵字,這樣就定義了一個委託型別,委託型別名稱為DelegateName,它能夠呼叫返回值為void,帶有一個object[]型別引數的所有方法(包括例項方法和靜態方法)。換句話說,就是所有符合該簽名的方法都可以由DelegateName委託呼叫。注意我們不能顯式在程式碼中這樣去定義一個委託型別:

1 //Code 5-2
2 public class DelegateName:MulticastDelegate
3 {
4     //
5 }

編譯器不允許以上程式碼Code 5-2透過編譯。

注:"方法簽名"指方法的引數個數、引數型別以及返回值等,具有相同簽名的兩個方法引數列表一致,返回值一致(名稱可以不一樣),int fun1(string a,int b)int fun2(string b,int a)兩個方法的簽名相同。

委託型別定義完成後,怎麼去例項化一個委託物件呢?其實很簡單,跟例項化其它型別物件一樣,我們可以透過new關鍵字,

 1 //Code 5-3
 2 class Calculate
 3 {
 4     public Calculate()
 5     {
 6         //
 7     }
 8     public int DoDivision(int first,int second) //NO.1
 9     {
10         return first/second;
11     }
12 }
13 private delegate int DivisionDelegate(int arg1,int arg2); //NO.2
14 class Program
15 {
16     static void Main()
17     {
18         Calculate c = new Calculate();
19         DivisionDelegate d = new DivisionDelegate(c.DoDivision); //NO.3
20         int result = d(10,5); // int result = c.DoDivision(10,5); NO.4
21         Console.WriteLine("the result is " + result);
22     }
23 }

程式碼Code 5-3中我們定義了一個Calculate型別,專門負責除法運算(NO.1處),定義了一個DivisionDelegate委託(NO.2處)。在實際計算的時候,我們並沒有直接呼叫Calculate類的DoDivision方法,而是先新建了一個委託物件d(NO.3處),給d的構造方法傳遞一個引數c.DoDivision。之後,我們透過這個委託d來計算10除以5的值(NO.4處)。整個過程中,我們沒有直接使用物件c,而是透過委託d,這就像本節剛開始所說的:委託的職責就是代替請求方(Program類)去找應答方(c物件)辦事情(除法運算)。程式碼中委託物件d的結構如下圖5-3:

圖5-3 委託物件d內部結構

圖5-3中顯示,委託中的Target指向c物件,Method指向c物件的DoDivision方法,委託物件d就是對c.DoDivision(int,int)的一個封裝。

另外,在我們使用new關鍵字建立委託例項時,會給它的構造方法傳遞了一個引數,該引數為一個方法名稱。如果是例項方法,就應該使用"物件.方法名稱"這樣的格式(注意如果在同一個類中,物件預設為this,可以省略),如果是靜態方法,就應該使用"類名稱.方法名稱"這樣的格式(如果在同一個類中,類名稱可以省略)。給構造方法傳遞的這個引數其實就是用來初始化委託內部的Target和Method兩個成員。使用委託呼叫方法時,我們直接使用"委託物件(引數列表);"這樣的格式即可,它等效於"委託物件.Invoke(引數列表)"。

注:給委託賦值的另外一種方式是:委託物件=方法。程式碼Code 5-3中賦值部分可以換成DivisionDelegate d = c.DoDivision;,含義跟用new關鍵字一樣。另外,每一個自定義委託型別都包含一個Invoke方法,它的作用就是呼叫方法(與BeginInvoke方法對應,詳見本書第六章),"委託物件(引數列表)"只是呼叫方法的一種簡寫方式。

委託內部的Target為Object型別,表示方法的所有者,Method為MethodInfo型別,表示一個方法。透過委託呼叫方法"int result = d(10,5);",委託內部相當於:

1 //Code 5-4
2 int result = (int)Method.Invoke(Target,new Object[]{10,5});

意思就是在指定的物件(Target)上呼叫指定的方法(Method)。

5.1.2 委託連結串列

上一小節中提到的委託都是單委託,它只對一個方法進行封裝,也就是說,使用單委託只能呼叫一個方法。

之前提到過,一個委託應該可以呼叫多個方法,只要這些方法的簽名與該委託一致,那麼怎樣讓一個委託同時呼叫兩個或者兩個以上的方法呢? 我們程式碼中很好實現,直接使用加法賦值運算子(+=)將多個方法附加到委託物件上,

 1 //Code 5-5
 2 class Program
 3 {
 4     static void Fun1(object sender,EventArgs e)
 5     {
 6         //
 7         Console.WriteLine("Call Fun1");
 8     }
 9     static void Fun2(object sender,EventArgs e)
10     {
11         //
12         Console.WriteLine("Call Fun2");
13     }
14     static void Fun3(object sender,EventArgs e)
15     {
16         //...
17         Console.WriteLine("Call Fun3");
18     }
19     static void Main()
20     {
21         EventHandler eh = new EventHandler(Fun1); //NO.1
22         eh += Fun2; //NO.2
23         eh += new EventHandler(Fun3); //NO.3
24         eh -= Fun2; //NO.4
25         eh(null,null); //NO.5
26         // print out:
27         // Call Fun1
28         // Call Fun3
29     }
30 }

程式碼Code 5-5中定義了一個EventHandler委託物件eh(NO.1處),按照先後順序依次使用加法賦值運算子(+=)給它附加Fun2和Fun3方法(NO.2和NO.3處),然後使用減法賦值運算子(-=)移除Fun2方法(NO.4處),最後透過委託呼叫方法,依次輸出"Call Fun1"和"Call Fun3"。由此可以得出三個結論:

(1)一個委託物件確實可以呼叫多個方法;

(2)這些方法可以按照附加順序先後依次呼叫;

(3)可以從委託物件上移除一個方法,不影響其它方法。

注:確切的說,應該是將委託附加到委託物件上,另外程式碼中使用的都是靜態方法,這時候委託內部Targetnull+=-=運算子相當於Delegate類的靜態方法Delegate.CombineDelegate.Remove,專門負責附加或移除委託操作。

根據以上三個結論,我們很有必要了解一下委託內部到底是怎樣管理附加到它上面的方法,換句話說,委託內部到底有怎樣的資料結構來組織和呼叫這些方法?

在學習資料結構中的"連結串列"時我們知道,每一個連結串列節點(Node)的結構都是相同的。連結串列表頭、連結串列表尾以及中間的節點本質上是沒有任何區別,我們可以將任意一個(或一串)節點附加到已有的一個(或一串)節點後面,從而形成一個更長的節點串。我們還能透過連結串列表頭訪問整個連結串列中的每一個節點(透過Next成員)。總之,只要知道了任意一個節點,我們就能訪問該節點後面的所有節點(注意這裡指的是單向連結串列)。單向連結串列結構類似如下圖5-4:

圖5-4 單向連結串列結構

圖5-4中實線矩形方框表示單向連結串列中的一個節點,所有節點都屬於同一型別物件,因此結構相同。節點類Node程式碼類似如下:

 1 //Code 5-6
 2 class Node
 3 {
 4     private string _name; //node's name
 5     private Node _next; // the next node
 6     public string Name     
 7     {
 8         get
 9         {
10             return _name;
11         }
12         set
13         {
14             _name = value;
15         }
16     }
17     public Node Next
18     {
19         get
20         {
21             return _next;
22         }
23         set
24         {
25             _next = value;
26         }
27     }
28     public Node(string name)
29     {
30         _name = name;
31     }
32     public int GetNodesCount() //get the nodes' count from this to the end
33     {
34         int count = 0;
35         Node tmp = this;
36         do
37         {
38             count++;
39             tmp = tmp.Next;
40         }
41         while(tmp != null)
42         return count;
43     }
44     public Node[] GetNodesList() //get all nodes from this to the end
45     {
46         Node[] nodes = new Node[GetNodes()];
47         int index = -1;
48         Node tmp = this;
49         do
50         {
51             index++;
52             nodes[index] = tmp;
53             tmp = tmp.Next;
54         }
55         while(tmp != null)
56         return nodes;
57     }
58     public void ShowMyInfo() //show node's info
59     {
60         Console.WriteLine("My name is " + _name);
61     }
62     public void ShowInfo() //show the all nodes' info from this to the end
63     {
64         ShowMyInfo();
65         if(Next != null)
66         {
67             Next.ShowInfo();
68         }
69     }
70 }
71 class Program
72 {
73     static void Main()
74     {
75         Node node1 = new Node("node1");
76         Node node2 = new Node("node2");
77         Node node3 = new Node("node3");
78         node1.Next = node2; //NO.1
79         node2.Next = node3; //NO.2
80         Console.WriteLine("the count of the nodes from node1 to the end:" + node1.GetNodesCount()); //NO.3
81         Console.WriteLine("the count of the nodes from node2 to the end:" + node2.GetNodesCount());
82         Console.WriteLine("the count of the nodes from node3 to the end:" + node3.GetNodesCount());
83         Node[] nodes = node1.GetNodesList(); //NO.4
84         foreach(Node n in nodes)
85         {
86             n.ShowMyInfo(); //NO.5
87         }
88         node1.ShowInfo(); //NO.6
89         Console.Read();
90     }
91 }
View Code

程式碼Code 5-6中我們可以透過一個節點訪問該節點以及該節點所有的後續節點(NO3、NO.4、NO.5以及NO.6處),之所以能夠這樣,是因為每個節點中都儲存有下一個節點的引用(Next引用)。程式碼中的node1. node2以及node3組成的單向連結串列在堆中的儲存結構如下圖5-5:

    圖5-5 單向連結串列在堆中的結構

透過一個單向連結串列中的節點物件,我們能夠訪問附加到它後面的所有其它節點,委託物件也能夠管理和訪問附加到它上面的其它委託,也能管理一個"連結串列",那麼,我們是否可以按照單向連結串列的結構去理解委託的內部結構呢?答案雖是肯定的,但是委託內部的"連結串列"結構跟單向連結串列的實現原理卻不相同,它並不是透過Next引用與後續委託建立關聯,而是將所有委託存放在一個陣列中,類似如下圖5-6:

    注:準確來講,委託內部結構不應該稱為"連結串列"。

圖5-6 委託結構

圖5-6中顯示委託內部不僅僅有Target和Method成員,還有一個陣列成員,用來儲存附加到該委託物件中的其它委託。委託鏈在堆中的結構如下圖5-7:

圖5-7 委託連結串列在堆中的結構

圖5-7中顯示delegate1中包含delegate2. delegate3以及delegate4的引用,注意delegate2. delegate3以及delegate4中的陣列列表不可能再包含有其它的委託引用,也就是說包含關係最多隻有兩層,具體原因請參見下一小節有關委託的"不可改變"特性。

注:每一個委託型別都有一個公開的GetInvocationList()的方法,可以返回已附加到委託物件上的所有委託,也就是圖5-6中陣列列表。另外,我們平時不區分委託物件和委託連結串列,提到委託物件,它很有可能就表示一個委託連結串列,這跟單向連結串列只包含一個節點時道理類似。

既然現在委託可以呼叫多個方法,那麼它的Invoke方法內部是怎樣實現的呢?假如是一個簡單的單委託,Invoke()方法內部直接呼叫Method.Invoke方法,但如果包含其它委託,那麼它就需要遍歷整個陣列列表。程式碼類似如下(假設委託的簽名為:返回值為null,含一個int型別引數):

 1 //Code 5-7
 2 public void Invoke(int a)
 3 {
 4     Delegate[] ds = GetInvocationList(); //get all delegates in array
 5     if(ds!=null) //contain a delegate chain
 6     {
 7         foreach(Delegate d in ds) // call each delegate
 8         {
 9             DelegateName dn = d as DelegateName;
10             dn(a);
11         }
12     }
13     else //don't contain a delegate chain
14     {
15         Method.Invoke(Target,new Object[]{a}); //call the Method on Target with argument 'a'
16     }
17 }

程式碼Code 5-7中委託的Invoke方法先判斷該委託中是否包含其它委託,如果是,依次遍歷列表呼叫這些委託;否則,說明當前委託是一個單委託,直接呼叫Method.Invoke()方法。

5.1.3 委託的"不可改變"特性

所謂"不可改變"(Immutable),就是指一個物件建立之後,它的內容不能再改變。比如常見的String型別,我們建立的一個String物件之後,之後在該物件上的所有操作都不會影響物件原來的值,

 1 //Code 5-8
 2 Class Program
 3 {
 4     static void Main()
 5     {
 6         string a = "test"; // equal String a = new String("test");
 7         a.ToUpper(); //NO.1
 8         Console.WriteLine("a is " + a);
 9         // print out:
10         // a is test
11     }
12 }

程式碼Code 5-8中a的值並沒有因為呼叫了a.ToUpper()方法而改變,如果想要讓a字串都變為大寫格式,必須使用"a = a.ToUpper();"這樣的程式碼,a.ToUpper()方法會返回一個全新的String物件,a重新指向該新物件。注意這裡的"不可改變"指的是物件例項,而不是物件引用,也就是說我們還是可以將a指向其它物件。如下圖5-8:

圖5-8 String型別的不可變性

委託跟String型別一樣,也是不可改變的。換句話說,一旦委託物件建立完成後,這個物件就不能再被更改,那麼我們前面講到的將一個委託附加到另外一個委託物件上形成一個委託連結串列又是怎麼做到的呢?其實這個跟String.ToUpper()過程類似,我們對委託進行附加、移除等操作都會產生一個全新的委託,這些操作並不會改變原有委託物件。

 1 //Code 5-9
 2 EventHandler eh = new EventHandler(Fun1); //NO.1
 3 EventHandler tmp = eh; //tmp and eh point at the same delegate NO.2
 4 EventHandler eh2 = new EventHandler(Fun2); //NO.3
 5 eh += eh2; //NO.4
 6 // equal eh = Delegate.Combine(eh, eh2) as EventHandler;
 7 EventHandler tmp2 = eh; //tmp2 and eh point at the same delegate //NO.5
 8 EventHandler eh3 = new EventHandler(Fun3); //NO.6
 9 eh += eh3; //NO.7
10 //equal eh = Delegate.Combine(eh,eh3) as EventHandler;

上面程式碼Code 5-9最終會在堆中產生5個委託物件,NO.1處建立第一個,讓eh指向它,NO.2處讓tmp與eh指向同一個委託,NO.3處建立第二個,讓eh2指向它,NO.4處合併了eh和eh2,但並沒有改變原來的eh和eh2,而是新建立了第三個,並且讓eh重新指向了新建立的第三個,NO.5處讓tmp2與eh指向同一個委託,NO.6處建立第四個,讓eh3指向它,NO.7處合併了eh和eh3,但並沒有改變原來的eh和eh3,而是新建立了第五個,並且讓eh重新指向了新建立的第五個。

我們對委託進行的每一個附加(+=或者Delegate.Combine)操作,都會建立一個全新的委託,該新建立委託的陣列列表中包含原來兩個委託陣列列表內容的總和,這個過程並不會影響原來的委託,移除(-=或者Delegate.Remove)操作類似。附加或移除委託過程,見下圖5-9:

圖5-9 附加或移除委託過程

圖5-9中D1、D2、D3、D4、D5、D6以及c、d、e均為委託物件引用。Delegate.Combine(D1,D2)產生了D3,D1並沒改變;Delegate.Combine(D3,D4)產生了D5,D5包含D3和D4中的陣列列表內容之和,D3並沒有改變;Delegate.Remove(D5,D1)產生了D6,D5並沒有改變。由圖5-9可以看出,委託包含關係最多隻有兩層,陣列列表中的委託都屬於單委託,單委託不再包含其它委託。

注:文中的委託物件、單委託、委託連結串列都是指一個委託型別的物件。

5.1.4 委託的作用

委託是一種資料結構,專門用來管理和組織方法,並負責呼叫這些方法。那麼為什麼需要委託來呼叫方法呢?原因有以下三點:

(1)程式設計中無時無刻都存在著"方法呼叫",委託可以更方便更有組織的管理我們需要呼叫的方法,理論上沒有數量限制,只要是符合某一個委託簽名的方法都可以由該委託管理。我們可以使用委託一次性(有先後順序)地呼叫這些方法。在使用委託之前,我們呼叫方法是這樣:

圖5-10 不使用委託呼叫方法

圖5-10中為不使用委託直接呼叫方法的過程,我們每次只能呼叫一個方法。使用委託之後,我們可以呼叫一系列方法,如下圖5-11:

圖5-11 使用委託呼叫方法

上圖5-11為使用委託呼叫方法的過程,使用一個委託我們可以管理多個方法,並且一次性呼叫這些方法。能夠統一管理和組織被呼叫的方法,在程式設計中起到一個非常重要的作用,如後面講到的"事件程式設計"。

(2)使用普通方式呼叫方法只能是同步的(特殊方法除外),也就是說,被呼叫方法返回之前,呼叫執行緒一直處於等待狀態。使用委託呼叫方法時,有兩種方式可供選擇,既可以同步呼叫也可以非同步呼叫,前者和普通呼叫方式一樣,而後者遵循"非同步程式設計模型"的規律:方法的呼叫不會阻塞呼叫執行緒。

注:委託的非同步呼叫關鍵在於它的BeginInvoke方法,該方法是Invoke方法的非同步版本,詳見第六章關於非同步程式設計的介紹。

(3)有了委託,方法可以作為一種引數在程式碼中進行傳遞,這個類似於C++中的函式指標。委託的這種功能在框架中是非常有用的,框架一般由專業技術團隊編寫開發,由於框架的開發者並不知道框架使用者的具體程式碼,那麼框架又是怎樣呼叫使用者編寫的程式碼呢?

框架有兩種方式呼叫框架使用者編寫的程式碼,一種便是面向抽象程式設計。框架中儘量不出現某個具體型別的引用,而是使用抽象化的基類引用或者介面引用代替。只要框架使用者編寫的型別派生自抽象化的基類或實現了介面,框架均可以正確地呼叫它們。我們常見的使用using程式碼塊來釋放物件非託管資源就是一個例子:

1 //Code 5-10
2 using(FileStream fs = new FileStream(…))
3 {
4     //use fs
5 }

程式碼Code 5-10中要求FileStream類必須實現了IDisposable介面(事實上確實如此)。程式碼Code 5-10經過編譯後,與下面程式碼Code 5-11類似:

 1 //Code 5-11
 2 IDisposable dispose_target = new FileStream(…);
 3 try
 4 {
 5     //use filestream
 6 }
 7 finally
 8 {
 9     dispose_target.Dispose();
10 }

如上程式碼Code 5-11所示,無論何時,FileStream物件都能正確地釋放非託管資源。框架認為所有使用using來釋放非託管資源的型別都已實現了IDisposable介面,因為只有這樣,它才能夠提前編寫釋放非託管資源的程式碼(如finally中的dispose_target.Dispose())。沒有實現IDisposable介面的型別不能使用using關鍵字來釋放非託管資源。

    注:關於框架呼叫框架使用者程式碼的過程,可以參見第二章中關於對"協議"的介紹,如圖2-14

框架呼叫框架使用者程式碼的另外一種方式就是使用委託,將委託作為引數(變數)傳遞給框架,框架透過委託呼叫方法。非同步程式設計中的一些方法往往帶有委託型別的引數,比如FileStream.BeginRead、Socket.BeginReceive等等(後續章節有講到)。這些方法都會帶有一個AsyncCallBack委託型別的引數,我們在使用這些方法時,如果給它傳遞一個委託物件,當非同步操作執行完畢後,框架自動會呼叫我們傳遞給它的委託。還有下一節中講到的"事件",框架可以透過事件來呼叫框架使用者編寫的程式碼,如事件釋出者激發事件,呼叫事件註冊者的事件處理程式。

    注:我們使用.NET中預定義的一些型別、方法均可以當作框架中的一部分。

5.2 事件與委託的關係

委託的附加、移除以及呼叫,是沒有範圍限制的。如果一個型別包含一個委託成員,那麼在類外部既可以給它附加或者移除委託,還可以呼叫這個委託。如下面程式碼:

 1 //Code 5-12
 2 public delegate void DelegateName(int a,int b); //define a delegate type
 3 class A
 4 {
 5     public DelegateName MyDelegate; //define a delegate member
 6     Public A()
 7     {
 8         //
 9     }
10     public void DoSomething()
11     {
12         //
13         if(MyDelegate != null)
14         {
15             //… if something happen or if something is OK
16             int arg1 = 1; int arg2 = 2;
17             MyDelegate(arg1,arg2); //then call the delegate
18         }
19     }
20     //
21 }
22 class Program
23 {
24     static void Fun1(int a,int b)
25     {
26         Console.WriteLine("the result is " + (a + b).ToString());
27     }
28     static void Main()
29     {
30         A a = new A();
31         a.MyDelegate += new DelegateName(Fun1); //NO.1
32         a.DoSomething(); //NO.2
33         a.MyDelegate(1,2); //NO.3
34     }
35 }

程式碼Code 5-12中,我們給a物件的MyDelegate附加一個方法後(NO.1處),a物件內部可以呼叫這個委託(NO.2處),a物件外部也可以呼叫這個委託(NO.3處)。也就是說,對MyDelegate委託成員的訪問是沒有限制的,從某種意義上講,這違背了"物件導向"思想,因為類裡面的有些功能不應該對外公開,比如這裡的"委託呼叫",該操作應該只能發生在型別內部。如果我們把MyDelegate定義為private私有變數,那麼我們在類外部就不能給它附加和移除方法,為了解決這個問題,.NET中提出了一種介於public和private之間的另外一種訪問級別:在定義委託成員的時候給出event關鍵字進行修飾,前面加了event關鍵字修飾的public委託成員,只能在類外部進行附加和移除操作,而呼叫操作只能發生在型別內部。如果把程式碼Code 5-12中A類宣告MyDelegate成員的程式碼改為:

1 //Code 5-13
2 public event DelegateName MyDelegate;

按照Code 5-13中的方式定義的委託只能在A類內部呼叫,之前程式碼Code 5-12中的NO.3處編譯通不過。

我們把類中設定了event關鍵字的委託叫作"事件","事件"本質上就是委託物件。事件的出現,限制了委託呼叫只能發生在一個型別的內部,如下圖5-12:

圖5-12 事件在程式呼叫中的位置

圖5-12中server中的委託使用了event關鍵字修飾,只能在server內部呼叫,外部只能進行附加和移除方法操作。當符合某一條件時,server內部會呼叫委託,這個時間不由我們(Client)控制,而是由系統(Server)決定。因此大部分時候,事件在程式中起到了回撥作用(關於呼叫與回撥的區別,參見第二章)。

呼叫加了event關鍵字修飾的委託也稱為"激發事件",呼叫方(圖5-12中的server)稱為"事件釋出者",被呼叫方(圖5-12中的client)稱為"事件註冊者"(或"事件觀察者"、"事件訂閱者"等,本書中統一稱之為"事件註冊者"),附加委託的過程稱之為"註冊事件"(或"繫結事件"、"監聽事件"、"訂閱事件"等,本書中統一稱之為"註冊事件"),移除委託的過程稱之為"登出事件"。透過委託呼叫的方法稱為"事件處理程式"。

注:將只能在型別內部呼叫的委託稱之為"事件",主要是因為這些委託一般是當server中發生某件事件(或符合某個條件)時才被server呼叫。我們所熟知的Button.ClickTextBox.TextChangedForm.FormClosing等事件,都屬於這種情況。

    "事件"在.NET中起到了重要作用,它為框架與框架使用者編寫程式碼之間的互動做出了重大貢獻。

5.3 使用事件程式設計

5.3.1 登出跟註冊事件同樣重要

前面在講到委託結構組成的時候就知道,委託內部包含了要呼叫的方法(Method成員),以及該方法所屬的物件(Target成員)。當我們註冊事件時,其實就是附加委託的過程,將一個新委託附加到委託連結串列中。事件註冊者向事件釋出者註冊事件後,釋出者就會儲存一個註冊者的引用(委託中的Target成員),釋出者激發事件,其實就是透過該引用呼叫註冊者的事件處理程式。當我們登出事件時,其實就是移除釋出者對註冊者的引用。

第四章講到,堆中的物件例項如果存在引用指向它,那麼CLR就不會回收它在堆中佔用的記憶體,哪怕這個物件已經沒有使用價值。註冊事件使一個新的引用指向了事件註冊者,如果我們不及時登出事件,那麼這個引用將會一直存在。

5.3.2 多執行緒中使用事件

在通常程式設計中,我們激發一個事件之前需要先判斷該事件是否為空,如果不為空,我們就可以激發該事件(呼叫委託),類似程式碼如下:

1 //Code 5-14
2 public event MyDelegate SomeEvent;
3 if(SomeEvent != null) //NO.1
4 {
5     //do something
6     SomeEvent(arg1,arg2); //NO.2 call the delegate
7 }

程式碼Code 5-14中NO.1處先檢查SomeEvent是否為空,如果為空,說明沒有人註冊過該事件,就不會執行if塊中的語句;如果不為空,說明已經有人註冊過該事件,就執行if塊中的語句,呼叫委託(圖中NO.2處)。在單執行緒中,上面程式碼沒有任何問題,但是如果在多執行緒中,以上程式碼就有可能丟擲異常:如果在NO.1處if判斷為true,在NO.2執行之前,其它執行緒將SomeEvent改變為null,這時候再回頭執行NO.2時,就會丟擲NullReferenceException的異常。

注:本章前面講到的"委託不可改變特性"指的是委託例項不可改變,類似String型別,委託引用仍然可以改變,所以SomeEvent可以指向其它例項,甚至指向null

為了解決多執行緒中事件程式設計容易引發的異常,我們需要利用"委託不可改變"這一特點。由於我們對一個委託的任何操作都不會改變該委託本身,只會產生新的委託,那麼我們完全可以在if判斷語句之前,使用一個區域性臨時變數來指向委託例項,之後所有的操作都針對該區域性臨時變數。由於區域性變數不可能被其它人修改,所以它永遠都不會指向null。

1 //Code 5-15
2 MyDelegate tmp = SomeEvent;
3 if(tmp != null) //NO.1
4 {
5     //do something
6     tmp(arg1,arg2); //NO.2
7 }

上述程式碼Code 5-15中,先讓tmp和SomeEvent指向同一委託例項,NO.1處if判斷為true,if塊中的tmp在任何時候都不會被其它執行緒修改為null,因為其它執行緒只能修改SomeEvent,並且我們對SomeEvent的任何操作都不會改變它所指向的委託例項。這種解決方法其實跟我們在做一個除法運算時檢測除數是否為零的原理一樣,如果在多執行緒中,我們檢查完除數不為零後,直接進行除法運算,有可能丟擲異常,如下程式碼:

 1 //Code 5-16
 2 class A
 3 {
 4     //
 5     public int x;
 6     public A()
 7     {
 8         //
 9     }
10     public int DoSomething(int y)
11     {
12         if(x != 0) //NO.1
13         {
14             return y/x; //NO.2
15         }
16         else
17         {
18             return 0;
19         }
20     }
21 }

上述程式碼Code 5-16中,如果NO.1處if判斷為true後,在NO.2執行之前x的值被其它執行緒改變為0,那麼程式碼執行到NO.2處時就會丟擲異常。正確的做法是,使用一個臨時變數儲存x的值,之後所有的操作都是針對該臨時變數。Code 5-16中類A的DoSomething方法可以修改為:

 1 //Code 5-17
 2 public int DoSomething(int y)
 3 {
 4     int tmp = x;
 5     if(tmp != 0) //NO.1
 6     {
 7         return y/tmp; //NO.2
 8     }
 9     else
10     {
11         return 0;
12     }
13 }

上述程式碼Code 5-17中,NO.1處if判斷為true後,tmp的值就永遠不會為零,其它執行緒對x的所有操作都不會影響到tmp的值,因此NO.2處不可能再有異常丟擲。這個原理跟我們剛學習程式設計的時候碰到的形參和實參的關係一樣,在值傳遞過程中,形參和實參是相互獨立的,形參改變不會影響到實參。

注:.NET中值型別賦值都是值傳遞,也就是說賦值後會產生一個一模一樣的複製,兩者之間是相互獨立互不影響的。引用型別賦值也是值傳遞,因為它傳遞的是物件引用,賦值後兩個引用指向堆中同一個例項,關於值型別與引用型別賦值請參見第三章。

5.3.3 委託連結串列的分步呼叫

呼叫任何方法都有可能出現異常,因此,透過委託呼叫方法時,我們最好把呼叫程式碼放在try/catch塊中,類似如下:

 1 //Code 5-18
 2 class A
 3 {
 4     //
 5     public event MyDelegate SomeEvent;
 6     public A()
 7     {
 8         //
 9     }
10     public void DoSomething()
11     {
12         //
13         MyDelegate tmp = SomeEvent; //NO.1
14         if(tmp != null)
15         {
16             //
17             try //NO.2
18             {
19                 tmp(arg1,arg2); //NO.3
20             }
21             catch
22             {
23                 
24             }
25         }
26     }
27 }

上述程式碼Code 5-18中,激發事件的程式碼(NO.3處)放在了try/catch塊中,這樣以來,萬一事件註冊者中的事件處理程式丟擲了沒有被處理的異常,try/catch便會捕獲該異常,程式不會異常終止。

呼叫委託鏈時,如果某一個委託對應的方法丟擲了異常,那麼剩下的其它委託將不再呼叫。這個很容易理解,本來是按先後順序依次呼叫方法,如果其中某一個丟擲異常,剩下的肯定被跳過。為了解決這個問題,單單是將激發事件的程式碼放在try/catch塊中是不夠的,我們需要分步呼叫每個委託,將每一步的呼叫程式碼均放在try/catch塊中。類A的DoSomething方法修改為:

 1 //Code 5-19
 2 public void DoSomething()
 3 {
 4     //
 5     MyDelegate tmp = SomeEvent; //NO.1
 6     if(tmp != null)
 7     {
 8         //
 9         Delegate[] delegates = tmp.GetInvocationList(); //NO.2
10         foreach(Delegate d in delegates)
11         {
12             MyDelegate del = d as MyDelegate;
13             try //NO.3
14             {
15                 del(arg1,arg2); //NO.4
16             }
17             catch
18             {
19                 
20             }
21         }
22     }
23 }

上述程式碼Code 5-19中,我們沒有直接使用tmp來呼叫委託連結串列,而是先透過tmp.GetInvocationList方法來獲取委託連結串列中的委託集合(NO.2處),然後再使用foreach迴圈遍歷集合,分步呼叫每個委託(NO.4處),分步呼叫過程均放在了try/catch塊中,這樣一來,任何一個方法丟擲異常都不會影響到其它委託的呼叫。

注:在單執行緒中使用事件時,激發事件之前不需要使用一個臨時委託變數,本小節所有程式碼為了與前一小節一致,都使用了臨時委託。現實程式設計中,要看我們定義的型別是否在多執行緒環境中使用。Winform程式設計中的Control類(及其派生類)在設計之初就只讓它們執行在UI執行緒中,因此它們激發事件時,都沒有考慮多執行緒的情況。

5.3.4 正確定義一個使用了事件的類

前面說到過,.NET中的"事件"在框架與客戶端程式碼互動過程中起到了關鍵作用。那麼平常開發過程中,應該怎樣去定義一個使用了事件的型別,既能夠讓該型別的使用者更容易地去使用它,也能夠讓該型別的開發者更方便地去維護它呢?其實定義一個使用了事件的型別有一套標準方法。下面從命名、激發事件以及組織事件三個方面詳細說明:

(1)命名;

前面講到過,通常情況下,當某件事情發生時,物件內部就會激發事件,通知事件註冊者,呼叫對應的事件處理程式,因此程式碼中事件的命名最好跟這個發生的事情有關係。比如有一個負責收發Email的類,當接收到新的郵件時,應該會激發一個類似叫"NewEmailReceived"的事件,去通知註冊了這個事件的其他人,我們最好不要將這個事件定義為"NewEmailReceive"。除了事件本身的命名,事件所屬委託型別的命名也同樣有標準格式,一般以"事件名+EventHandler"這種格式來給委託命名,前面提到的NewEmailReceived事件對應的委託型別名稱應該是"NewEmailReceivedEventHandler"。激發事件時會傳遞一些引數,這些引數一般繼承自EventArgs型別(後者為.NET框架預定義型別),以"事件名+EventArgs"來命名,比如前面提到的NewEmailReceived事件在激發時傳遞的引數型別名稱應該是"NewEmailReceivedEventArgs"。下面為示例程式碼:

 1 //Code 5-20
 2 private delegate void NewEmailReceivedEventHandler(object sender,NewEmailReceivedEventArgs e); //define a delegate NO.1
 3 class EmailManager
 4 {
 5     //
 6     public event NewEmailReceivedEventHandler NewEmailReceived; //define e event member NO.2
 7     public EmailManager()
 8     {
 9         //
10     }
11 }
12 class NewEmailReceivedEventArgs:EventArgs //define event argument class derived from EventArgs NO.3
13 {
14     //
15     public NewEmailReceivedEventArgs()
16     {
17         //
18     }
19 }

上述程式碼Code 5-20中NO.1處定義一個委託,NO.2處使用該委託定義一個事件,NO.3處定義一個事件引數類,它派生自EventArgs類(通常情況下,EventArgs為所有事件引數類的基類,如果激發一個事件不帶任何引數,那麼可以直接使用EventArgs)。

注:事件的委託簽名一般包含兩個引數,一個object型別,表示事件釋出者(自己),一個為從EventArgs派生出來的子型別,包含激發事件時所帶的引數。

(2)激發事件;

當一個類內部發生某件事情(或者說某個條件成立時),類內部就會激發事件,通知事件的所有註冊者。為了便於型別的使用者能夠擴充套件這個型別,比如改變激發事件的邏輯,我們通常使用虛方法去激發事件,比如前面說到的郵件類EmailManager中激發NewEmailReceived事件應該是這樣編寫程式碼:

 1 //Code 5-21
 2 private delegate void NewEmailReceivedEventHandler(object sender,NewEmailReceivedEventArgs e); //define a delegate NO.1
 3 class EmailManager
 4 {
 5     //
 6     public event NewEmailReceivedEventHandler NewEmailReceived; //define e event member NO.2
 7     public EmailManager()
 8     {
 9         //
10     }
11     private void DoSomething()
12     {
13         //
14         if(/**/) //NO.4
15         {
16             NewEmailReceivedEventArgs e = new NewEmailReceivedEventArgs();
17             OnNewEmailReceived(e); //NO.5
18         }
19     }
20     protected void virtual OnNewEmailReceived(NewEmailReceivedEventArgs e) //NO.6
21     {
22         if(NewEmailReceived != null)
23         {
24             NewEmailReceived(this,e); //NO.7
25         }
26     }
27 }
28 class NewEmailReceivedEventArgs:EventArgs //define event argument class derived from EventArgs NO.3
29 {
30     //
31     public NewEmailReceivedEventArgs()
32     {
33         //
34     }
35 }

上述程式碼Code 5-21中,NO.1、NO.2以及NO.3處含義與之前解釋相同,NO.4處當類中某個條件成立時,並沒有馬上激發事件,而是呼叫了預先定義的一個虛方法OnNewEmailReceived(NO.6處),在該虛方法內部激發事件(NO.7處),之所以要把激發事件的程式碼放在一個單獨的虛方法中,這是為了讓從該型別(EmailManager)派生出來的子類能夠重寫虛方法,從而改變激發事件的邏輯。下面程式碼Code 5-22定義一個EmailManager的派生類EmailManagerEx:

 1 //Code 5-22
 2 class EmailManagerEx:EmailManager
 3 {
 4     //
 5     protected override void OnNewEmailReceived(NewEmailReceivedEventArgs e)
 6     {
 7         //…do something here
 8         if(/**/) //NO.1
 9         {
10             base.OnNewEmailReceived(e); //NO.2
11         }
12         else
13         {
14             // NO.3
15         }
16     }
17 }

如上程式碼Code 5-22所述,派生類中重寫OnNewEmailReceived虛方法後,可以重新定義激發事件的邏輯。如果NO.1處if判斷為true,則正常激發事件(NO.2處);否則,不激發事件(NO.3處)。我們能夠在派生類EmailManagerEx的OnNewEmailReceived虛方法中做許許多多其它的事情,包括示例程式碼中"取消激發事件"。

虛方法的命名一般為"On+事件名",另外該虛方法必須定義為protected,因為派生類中很可能要呼叫基類的虛方法。

(3)組織事件。

事件類似屬性,僅僅只是型別對外公開的一箇中介,透過它可以訪問型別內部的資料。換句話說,無論事件還是屬性,真正儲存資料的成員並沒有對外公開,比如屬性基本都對應有相應的私有欄位,每個事件也對應有相應的私有委託成員。我們透過event關鍵字宣告的公開事件,經過編譯器編譯之後,生成的程式碼類似如下:

 1 //Code 5-23
 2 class EmailManager
 3 {
 4     //
 5     private NewEmailReceivedEventHandler _newEmailReceived; //NO.1
 6     public event NewEmailReceivedEventHandler NewEmailReceived
 7     {
 8         [MethodImpl(MethodImplOptions.Synchronized)] //NO.2
 9         add //NO.3
10         {
11             _newEmailReceived = Delegate.Combine(_newEmailReceived,value) as NewEmailReceivedEventHandler;
12         }
13         [MethodImpl(MethodImplOptions.Synchronized)]
14         remove //NO.4
15         {
16             _newEmailReceived = Delegate.Remove(_newEmailReceived,value) as NewEmailReceivedEventHandler;
17         }
18     }
19 }

如上程式碼Code 5-23所示,編譯器編譯之後,將一個事件分成了兩部分,一個私有委託變數_newEmailReceived(NO.1處)和一個事件訪問器add/remove(NO3和NO.4處),前者類似一個欄位,後者類似屬性訪問器set/get。可以看出,真正儲存事件資料的是私有委託成員_newEmailReceived。

注:程式碼Code 5-23NO.2[MethodImpl(MethodImplOptions.Synchronized)]的作用類似lock(this);,為了解決多執行緒中訪問同步問題,這個是官方給出的預設方法,該方法存在缺陷,因為使用lock加鎖時,鎖物件不應該是對外公開的,this顯然是對外公開的,很有可能出現對this重複加鎖的情況,從而造成死鎖。我們可以自己實現事件訪問器add/remove,在其中新增自己的lock塊,從而避免使用預設的lock(this)

下圖5-13為一個類中屬性和事件的作用:

圖5-13 屬性和事件的作用

有些型別包含的事件非常多,比如.NET3.5中System.Windows.Forms.Control就包含有69個公開事件。一個Control類(或其派生類)物件編譯後,物件內部就會產生幾十個類似程式碼Code 5-23中_newEmailReceived這樣的私有委託成員,這無疑會增加記憶體消耗,為了解決這個問題,我們一般需要自己定義事件訪問器add/remove,並且自己定義資料結構去儲存組織事件資料,不再使用編譯器預設生成的私有委託成員。微軟在.NET中的標準做法是:定義一個類似Dictionary功能的容器型別EventHandlerList,專門用來存放委託。一個型別自定義事件訪問器add/remove後的程式碼類似如下:

 1 //Code 5-24
 2 class EmailManager
 3 {
 4     private static readonly object _newEmailReceived; //NO.1
 5     private EventHandlerList _handlers = new EventHandlerList(); //NO.2
 6     public event NewEmailReceivedEventHandler NewEmailReceived
 7     {
 8         add
 9         {
10             _handlers.AddHandler(_newEmailReceived,value); //NO.3
11         }
12         remove
13         {
14             _handlers.RemoveHandler(_newEmailReceived,value); //NO.4
15         }
16     }
17     protected virtual void OnNewEmailReceived(NewEmailReceivedEventArgs e)
18     {
19         NewEmailReceivedEventHandler newEmailReceived = _handlers[_newEmailReceived] as NewEmailReceivedEventHandler; //NO.5
20         if(newEmailReceived != null)
21         {
22             newEmailReceived(this,e);
23         }
24     }
25 }

如上程式碼Code 5-24所述,自定義事件訪問器add/remove後,使用EventHandlerList來儲存事件資料,編譯器不再生成預設的私有委託成員,所有的事件資料均存放在_handlers容器中(NO.2處),NO.1處定義了訪問容器的key,NO.3以及NO.4處訪問容器,NO.5處在激發事件之前,先判斷容器_handlers中是否有人註冊了該事件。

注:自己定義事件訪問器還有其它很多作用,比如自己實現執行緒同步鎖、給事件標註[NonSerializable]屬性(編譯器生成的私有委託成員預設都是Serializable)等。

上面提到的命名規範、激發事件以及組織事件的方式,這三個是微軟給出官方程式碼中的標準,所有官方原始碼資料中都遵守了這三個規範。我們平時開發過程中,也應該遵守這些原則,編寫出更高質量的程式碼。

5.4 弱委託

5.4.1 強引用與弱引用

前面章節提到過,一個引用型別物件包括"引用"和"例項"兩部分。如果堆中例項至少有一個引用指向它(不管該引用存在於棧中還是堆中),CLR就不能對其進行記憶體回收,同時我們一定能夠透過引用訪問到堆中例項。換句話說,引用與例項是一種"強關聯"關係,我們稱這種引用為"強引用"(Strong Reference),堆中物件例項能否被訪問完全掌握在程式手中。

圖5-14 強引用

圖5-14中a是A的強引用,b是B的強引用,B中又存在一個C的強引用,只要棧中a和b存在,堆中A、B以及C就會一直存在。我們平時程式設計過程中使用new關鍵字建立一個物件時返回的引用便是強引用,比如"A a=new A();"中,a就是強引用。

強引用的優點是程式中只要有強引用的存在,就一定能夠訪問到堆中的物件例項。由於只要有一個強引用存在,CLR就不會回收堆中的物件例項,這就會出現一個問題:如果我們程式中沒有合理地管理好強引用,在該移除強引用的時候沒有移除它們,這便會導致堆中的物件例項大量累積,時間一長,就會出現記憶體不足的情況,尤其當這些物件佔用記憶體比較大的時候。管理好強引用並不是一件容易的事情,通常情況下,強引用在程式執行過程中不斷的傳遞,到最後有些幾乎發現不了它們的存在。雖然有時候開發者認為物件已經使用完畢,但是程式中還是會儲存這些物件的強引用直到很長一段時間,甚至會一直到程式執行結束。在事件程式設計中,委託的Target成員,就是對事件註冊者的強引用,如果事件註冊者沒有登出事件,這個Target強引用便會一直存在,堆中的事件註冊者記憶體就一直不會被CLR回收,這對開發人員來講,幾乎是很難發覺的。

注:像"A a = new A();"中的a稱為"顯式強引用(Explicit Strong Reference)",類似委託中包含的不明顯的強引用,我們稱之為"隱式強引用(Implicit Strong Reference)"。

對於"強引用",有一個概念與之對應,即"弱引用"。弱引用與物件例項之間屬於一種"弱關聯"關係,跟強引用與物件例項的關係不一樣,就算程式中有弱引用指向堆中物件例項,CLR還是會把該物件例項當做回收目標。程式中使用弱引用訪問物件例項之前必須先檢查CLR有沒有回收該物件記憶體。換句話說,當堆中一個物件例項只有弱引用指向它時,CLR可以回收它的記憶體。使用弱引用,堆中物件能否被訪問同時掌握在程式和CLR手中。

圖5-15 弱引用

圖5-15中a是A的弱引用,b是B的弱引用,B中又包含一個C的弱引用,不管a和b是否存在,堆中A、B以及C都有可能成為CLR的回收目標。

建立一個弱引用很簡單,使用WeakReference型別,給它的構造方法傳遞一個強引用作為引數,程式碼如下:

 1 //Code 5-25
 2 class A
 3 {
 4     public A()
 5     {
 6         //
 7     }
 8     public void DoSomething()
 9     {
10         Console.WriteLine("I am OK");
11     }
12     //
13 }
14 class Program
15 {
16     static void Main()
17     {
18         A a = new A();
19         WeakReference wr = new WeakReference(a); //NO.1
20         a = null; //NO.2
21         //do something else
22         A tmp = wr.Target; //NO.3
23         if(wr.IsAlive) //NO.4
24         {
25             tmp.DoSomething(); //NO.5
26             tmp = null;
27         }
28         else
29         {
30             Console.WriteLine("A is dead");
31         }
32     }
33 }

程式碼Code 5-25中建立了一個A物件的弱引用(NO.1處),然後馬上將它的臨時強引用a指向null(NO.2處),此時只有一個弱引用指向A物件。程式執行一段時間後(程式碼中do something處),當需要透過弱引用wr訪問A物件的時候,我們必須先檢查CLR有沒有回收它的記憶體(NO.4處),如果沒有,我們正常訪問A物件;否則,我們不能再訪問A物件。

在程式設計過程中,我們很難管理好強引用,從而造成不必要的記憶體開銷。尤其前面講到的"隱式強引用",在使用過程中不易發覺它們的存在。使用弱引用,CLR回收堆中物件記憶體不再根據程式中是否有弱引用指向它,因此程式中有沒有多餘的弱引用指向某個物件對CLR回收該物件記憶體沒有任何影響。弱引用特別適合用於那些對程式依賴程度不高的物件,也就是那些物件生命期不是主要由程式控制的物件。比如事件程式設計中,事件釋出者對事件註冊者的存在與否不是很關心,如果註冊者在,那就激發事件並通知註冊者;如果註冊者已經被CLR回收記憶體,那麼就不通知它,這完全不會影響程式的執行。

5.4.2 弱委託定義

前面講到過,委託包含兩個部分:一個Object型別Target成員,代表被呼叫方法的所有者,如果方法為靜態方法,Target為null;另一個是MethodInfo型別的Method成員,代表被呼叫方法。由於Target成員是一個強引用,所以只要委託存在,那麼方法的所有者就會一直在堆中存在而不能被CLR回收。如果我們將委託中的Target強引用換成弱引用的話,那麼不管委託存在與否,都不會影響方法的所有者在堆中記憶體的回收。這樣一來,我們在使用委託呼叫方法之前需要先判斷方法的所有者是否已經被CLR回收。我們稱將Target成員換成弱引用之後的委託為"弱委託",弱委託定義如下:

 1 //Code 5-26
 2 class WeakDelegate
 3 {
 4     WeakReference _weakRef; //NO.1
 5     MethodInfo _method; //NO.2
 6     public WeakDelegate(Delegate d)
 7     {
 8         _weakRef = new WeakReference(d.Target);
 9         _methodInfo = d.Method;
10     }
11     public object Invoke(param object[] args)
12     {
13         object obj = _weakRef.Target;
14         if(_weakRef.IsAlive) //NO.3
15         {
16             return _method.Invoke(obj,args); //NO.4
17         }
18         else
19         {
20             return null;
21         }
22     }
23 }

如上程式碼Code 5-26所示,我們定義了一個WeakDelegate弱委託型別,它包含一個WeakReference型別_weakRef成員(NO.1處),它是一個弱引用,指向被呼叫方法的所有者,還包含一個MethodInfo型別_method成員(NO.2處),它表示委託要呼叫的方法。我們在弱委託的Invoke成員方法中,先判斷被呼叫方法的所有者是否還在堆中(NO.3處),如果在,我們呼叫方法,否則返回null。

弱委託將委託與被呼叫方法的所有者之間的關係由"強關聯"轉換成了"弱關聯",方法的所有者在堆中的生命期不再受委託的控制,下圖5-16顯示弱委託的結構:

圖5-16 弱委託結構

如上圖5-16所示,圖中上部分表示一個普通委託的結構,下部分表示一個弱委託的結構,虛線框表示弱引用,堆中例項的記憶體不再受該弱引用影響。

注:本小節示例程式碼中的WeakDelegate型別並沒有提供類似Delegate.Combine以及Delegate.Remove這樣操作委託連結串列的方法,當然也沒有弱委託連結串列的功能,這些功能可以仿照單向連結串列的結構去實現,把每個弱委託都當作連結串列中的一個節點。請參照5.1.2小節中講到的單向連結串列。

5.4.3 弱委託使用場合

我們在使用事件程式設計時,如果一個事件註冊者向事件釋出者註冊了一個事件,那麼釋出者就會對註冊者儲存一個強引用。如果事件註冊者未正確地登出事件,那麼釋出者的委託連結串列中就一直包含一個對該註冊者的強引用,這樣一來,註冊者在堆中的記憶體永遠都不會被CLR回收,如果這樣的註冊者屬於大物件或者數目眾多,很輕易就會造成堆中記憶體不足。弱委託就恰好能夠解決這個問題,我們可以將事件程式設計中用到的委託替換為弱委託,那麼事件釋出者與事件註冊者的關係如下圖5-17:

圖5-17 弱委託在事件程式設計中的應用

如上圖5-17所示,事件釋出者中不再保留對事件註冊者的強引用。當釋出者激發事件時,先判斷註冊者是否存在(堆中記憶體是否被CLR回收),如果存在,就通知註冊者;否則將對應弱委託從連結串列中刪除。

注:弱委託連結串列請讀者自己去實現。

5.5 本章回顧

委託與事件幾乎出現在.NET程式設計的每一個地方,它們是.NET中最重要的知識點之一。程式的執行就是一個個呼叫與被呼叫的過程,而委託的主要作用就是"呼叫方法",它是銜接呼叫者與被呼叫者的橋樑。本章開頭介紹了.NET中委託的概念和組成結構,同時介紹了委託連結串列以及它的"不可改變"特性;之後介紹了委託與事件的關係,我們明白了事件是一種特殊的委託物件;緊接著講到了.NET中使用事件程式設計時需要關注的幾條注意事項,它們是在事件程式設計過程中常遇到的陷阱;章節最後還提到了"弱引用"和"弱委託"的概念以及它們的實現原理,"弱委託"是解決記憶體洩露的一種有效方法。

    本章提到了委託的三個作用:第一,它允許把方法作為引數,傳遞給其它的模組;第二,它允許我們同時呼叫多個具有相同簽名的方法;第三,它允許我們非同步呼叫任何方法。這三個作用奠定了委託在.NET程式設計中的絕對重要地位。

5.6 本章思考

1.簡述委託包含哪兩個重要部分。

A:委託包含兩個重要組成:Method和Target,分別代表委託要呼叫的方法和該方法所屬的物件(如果為靜態方法,則Target為null)。

2.怎樣簡單地說明委託的不可改變特性?

A:對委託的所有操作,均需要將操作後的結果在進行賦值,比如使用"+="、"-="將操作後的結果賦值給原委託變數。這說明對委託的操作均不能改變委託本身。

3."事件是委託物件"是否準確?

A:準確。.NET中的事件是一種特殊的委託物件,即在定義委託物件時,在宣告語句前增加了"event"關鍵字。事件的出現確保委託的呼叫只能發生在型別內部。

4.為什麼說委託是.NET中的"重中之重"?

A:因為程式的執行過程就是方法的不斷呼叫過程,而委託的作用就是"呼叫方法",它不僅能夠將方法作為引數傳遞,還能同時(同步或非同步)呼叫多個具有相同簽名的方法。

5.弱委託的關鍵是什麼?

A:弱委託的關鍵是弱引用,弱委託是透過弱引用實現的。

 

相關文章