C#中的委托和事件(續)
引言
如果你看過了 C#中的委托和事件 一文,我想你對委托和事件已經有了一個基本的認識。但那些遠不是委托和事件的全部內容,還有很多的地方沒有涉及。本文將討論委托和事件一些更為細節的問題,包括一些大家常問到的問題,以及事件訪問器、異常處理、超時處理和異步方法調用等內容。
為什么要使用事件而不是委托變量?
在 C#中的委托和事件 中,我提出了兩個為什么在類型中使用事件向外部提供方法注冊,而不是直接使用委托變量的原因。主要是從封裝性和易用性上去考慮,但是還漏掉了一點,事件應該由事件發布者觸發,而不應該由客戶端(客戶程序)來觸發。這句話是什么意思呢?請看下面的范例:
NOTE:注意這里術語的變化,當我們單獨談論事件,我們說發布者(publisher)、訂閱者(subscriber)、客戶端(client)。當我們討論Observer模式,我們說主題(subject)和觀察者(observer)。客戶端通常是包含Main()方法的Program類。
class Program {
static void Main(string[] args) {
Publishser pub = new Publishser();
Subscriber sub = new Subscriber();
pub.NumberChanged += new NumberChangedEventHandler(sub.OnNumberChanged);
pub.DoSomething(); // 應該通過DoSomething()來觸發事件
pub.NumberChanged(100); // 但可以被這樣直接調用,對委托變量的不恰當使用
}
}
// 定義委托
public delegate void NumberChangedEventHandler(int count);
// 定義事件發布者
public class Publishser {
private int count;
public NumberChangedEventHandler NumberChanged; // 聲明委托變量
//public event NumberChangedEventHandler NumberChanged; // 聲明一個事件
public void DoSomething() {
// 在這里完成一些工作 ...
if (NumberChanged != null) { // 觸發事件
count++;
NumberChanged(count);
}
}
}
// 定義事件訂閱者
public class Subscriber {
public void OnNumberChanged(int count) {
Console.WriteLine("Subscriber notified: count = {0}", count);
}
}
上面代碼定義了一個NumberChangedEventHandler委托,然后我們創建了事件的發布者Publisher和訂閱者Subscriber。當使用委托變量時,客戶端可以直接通過委托變量觸發事件,也就是直接調用pub.NumberChanged(100),這將會影響到所有注冊了該委托的訂閱者。而事件的本意應該為在事件發布者在其本身的某個行為中觸發,比如說在方法DoSomething()中滿足某個條件后觸發。通過添加event關鍵字來發布事件,事件發布者的封裝性會更好,事件僅僅是供其他類型訂閱,而客戶端不能直接觸發事件(語句pub.NumberChanged(100)無法通過編譯),事件只能在事件發布者Publisher類的內部觸發(比如在方法pub.DoSomething()中),換言之,就是NumberChanged(100)語句只能在Publisher內部被調用。
大家可以嘗試一下,將委托變量的聲明那行代碼注釋掉,然后取消下面事件聲明的注釋。此時程序是無法編譯的,當你使用了event關鍵字之后,直接在客戶端觸發事件這種行為,也就是直接調用pub.NumberChanged(100),是被禁止的。事件只能通過調用DoSomething()來觸發。這樣才是事件的本意,事件發布者的封裝才會更好。就好像如果我們要定義一個數字類型,我們會使用int而不是使用object一樣,給予對象過多的能力并不見得是一件好事,應該是越合適越好。盡管直接使用委托變量通常不會有什么問題,但它給了客戶端不應具有的能力,而使用事件,可以限制這一能力,更精確地對類型進行封裝。
NOTE:這里還有一個約定俗稱的規定,就是訂閱事件的方法的命名,通常為“On事件名”,比如這里的OnNumberChanged。
為什么委托定義的返回值通常都為void?
盡管并非必需,但是我們發現很多的委托定義返回值都為void,為什么呢?這是因為委托變量可以供多個訂閱者注冊,如果定義了返回值,那么多個訂閱者的方法都會向發布者返回數值,結果就是后面一個返回的方法值將前面的返回值覆蓋掉了,因此,實際上只能獲得最后一個方法調用的返回值。可以運行下面的代碼測試一下。除此以外,發布者和訂閱者是松耦合的,發布者根本不關心誰訂閱了它的事件、為什么要訂閱,更別說訂閱者的返回值了,所以返回訂閱者的方法返回值大多數情況下根本沒有必要。
class Program {
static void Main(string[] args) {
Publishser pub = new Publishser();
Subscriber1 sub1 = new Subscriber1();
Subscriber2 sub2 = new Subscriber2();
Subscriber3 sub3 = new Subscriber3();
pub.NumberChanged += new GeneralEventHandler(sub1.OnNumberChanged);
pub.NumberChanged += new GeneralEventHandler(sub2.OnNumberChanged);
pub.NumberChanged += new GeneralEventHandler(sub3.OnNumberChanged);
pub.DoSomething(); // 觸發事件
}
}
// 定義委托
public delegate string GeneralEventHandler();
// 定義事件發布者
public class Publishser {
public event GeneralEventHandler NumberChanged; // 聲明一個事件
public void DoSomething() {
if (NumberChanged != null) { // 觸發事件
string rtn = NumberChanged();
Console.WriteLine(rtn); // 打印返回的字符串,輸出為Subscriber3
}
}
}
// 定義事件訂閱者
public class Subscriber1 {
public string OnNumberChanged() {
return "Subscriber1";
}
}
public class Subscriber2 { /* 略,與上類似,返回Subscriber2*/ }
public class Subscriber3 { /* 略,與上類似,返回Subscriber3*/ }
如果運行這段代碼,得到的輸出是Subscriber3,可以看到,只得到了最后一個注冊方法的返回值。
如何讓事件只允許一個客戶訂閱?
少數情況下,比如像上面,為了避免發生“值覆蓋”的情況(更多是在異步調用方法時,后面會討論),我們可能想限制只允許一個客戶端注冊。此時怎么做呢?我們可以向下面這樣,將事件聲明為private的,然后提供兩個方法來進行注冊和取消注冊:
// 定義事件發布者
public class Publishser {
private event GeneralEventHandler NumberChanged; // 聲明一個私有事件
// 注冊事件
public void Register(GeneralEventHandler method) {
NumberChanged = method;
}
// 取消注冊
public void UnRegister(GeneralEventHandler method) {
NumberChanged -= method;
}
public void DoSomething() {
// 做某些其余的事情
if (NumberChanged != null) { // 觸發事件
string rtn = NumberChanged();
Console.WriteLine("Return: {0}", rtn); // 打印返回的字符串,輸出為Subscriber3
}
}
}
NOTE:注意上面,在UnRegister()中,沒有進行任何判斷就使用了NumberChanged-=method語句。這是因為即使method方法沒有進行過注冊,此行語句也不會有任何問題,不會拋出異常,僅僅是不會產生任何效果而已。
注意在Register()方法中,我們使用了賦值操作符“=”,而非“+=”,通過這種方式就避免了多個方法注冊。上面的代碼盡管可以完成我們的需要,但是此時大家還應該注意下面兩點:
1、將NumberChanged聲明為委托變量還是事件都無所謂了,因為它是私有的,即便將它聲明為一個委托變量,客戶端也看不到它,也就無法通過它來觸發事件、調用訂閱者的方法。而只能通過Register()和UnRegister()方法來注冊和取消注冊,通過調用DoSomething()方法觸發事件(而不是NumberChanged本身,這在前面已經討論過了)。
2、我們還應該發現,這里采用的、對NumberChanged委托變量的訪問模式和C#中的屬性是多么類似啊?大家知道,在C#中通常一個屬性對應一個類型成員,而在類型的外部對成員的操作全部通過屬性來完成。盡管這里對委托變量的處理是類似的效果,但卻使用了兩個方法來進行模擬,有沒有辦法像使用屬性一樣來完成上面的例子呢?答案是有的,C#中提供了一種叫事件訪問器(Event Accessor)的東西,它用來封裝委托變量。如下面例子所示:
class Program {
static void Main(string[] args) {
Publishser pub = new Publishser();
Subscriber1 sub1 = new Subscriber1();
Subscriber2 sub2 = new Subscriber2();
pub.NumberChanged -= sub1.OnNumberChanged; // 不會有任何反應
pub.NumberChanged += sub2.OnNumberChanged; // 注冊了sub2
pub.NumberChanged += sub1.OnNumberChanged; // sub1將sub2的覆蓋掉了
pub.DoSomething(); // 觸發事件
}
}
// 定義委托
public delegate string GeneralEventHandler();
// 定義事件發布者
public class Publishser {
// 聲明一個委托變量
private GeneralEventHandler numberChanged;
// 事件訪問器的定義
public event GeneralEventHandler NumberChanged {
add {
numberChanged = value;
}
remove {
numberChanged -= value;
}
}
public void DoSomething() {
// 做某些其他的事情
if (numberChanged != null) { // 通過委托變量觸發事件
string rtn = numberChanged();
Console.WriteLine("Return: {0}", rtn); // 打印返回的字符串
}
}
}
// 定義事件訂閱者
public class Subscriber1 {
public string OnNumberChanged() {
Console.WriteLine("Subscriber1 Invoked!");
return "Subscriber1";
}
}
public class Subscriber2 {/* 與上類同,略 */}
public class Subscriber3 {/* 與上類同,略 */}
上面代碼中類似屬性的public event GeneralEventHandler NumberChanged {add{...}remove{...}}語句便是事件訪問器。使用了事件訪問器以后,在DoSomething方法中便只能通過numberChanged委托變量來觸發事件,而不能NumberChanged事件訪問器(注意它們的大小寫不同)觸發,它只用于注冊和取消注冊。下面是代碼輸出:
Subscriber1 Invoked!
Return: Subscriber1