C# 中的委托和事件
委托和事件在 .NET Framework 中的應用非常廣泛,然而,較好地理解委托和事件對很多接觸 C# 時間不長的人來說並不容易。它們就像是一道檻兒,過了這個檻的人,覺得真是太容易了,而沒有過去的人每次見到委托和事件就覺得心里堵得慌,渾身不自在。本章中,我將由淺入深地講述什么是委托、為什么要使用委托、事件的由來、.NET Framework 中的委托和事件、委托中方法異常和超時的處理、委托與異步編程、委托和事件對Observer 設計模式的意義,對它們的編譯代碼也做了討論。
1.1.1 將方法作為方法的參數
我們先不管這個標題如何的繞口,也不管委托究竟是個什么東西,來看下面這兩個最簡單的方法,它們不過是在屏幕上輸出一句問候的話語:
public void GreetPeople(string name)
{
EnglishGreeting(name);
}
public void EnglishGreeting(string name)
{
Console.WriteLine("Good Morning, " + name);
}
暫且不管這兩個方法有沒有什么實際意義。GreetPeople 用於向某人問好,當我們傳遞代表某人姓名的 name 參數,比如說“Liker”進去的時候,在這個方法中,將調用 EnglishGreeting 方法,再次傳遞 name 參數,EnglishGreeting 則用於向屏幕輸出 “Good Morning, Liker”。
現在假設這個程序需要進行全球化,哎呀,不好了,我是中國人,我不明白“Good Morning”是什么意思,怎么辦呢?好吧,我們再加個中文版的問候方法:
public void ChineseGreeting(string name)
{
Console.WriteLine("早上好, " + name);
}
這時候,GreetPeople 也需要改一改了,不然如何判斷到底用哪個版本的 Greeting 問候方法合適呢?在進行這個之前,我們最好再定義一個枚舉作為判斷的依據:
public enum Language
{
English, Chinese
}
public void GreetPeople(string name, Language lang)
{
switch (lang)
{
case Language.English:
EnglishGreeting(name);
break;
case Language.Chinese:
ChineseGreeting(name);
break;
}
}
OK,盡管這樣解決了問題,但我不說大家也很容易想到,這個解決方案的可擴展性很差,如果日后我們需要再添加韓文版、日文版,就不得不反復修改枚舉和GreetPeople() 方法,以適應新的需求。
在考慮新的解決方案之前,我們先看看 GreetPeople 的方法簽名:
public void GreetPeople(string name, Language lang);
我們僅看 string name,在這里,string 是參數類型,name 是參數變量,當我們賦給 name 字符串“Liker”時,它就代表“Liker”這個值;當我們賦給它“李志中”時,它又代表着“李志中”這個值。然后,我們可以在方法體內對這個 name 進行其他操作。哎,這簡直是廢話么,剛學程序就知道了。
如果你再仔細想想,假如 GreetPeople() 方法可以接受一個參數變量,這個變量可以代表另一個方法,當我們給這個變量賦值 EnglishGreeting 的時候,它代表着 EnglsihGreeting() 這個方法;當我們給它賦值ChineseGreeting 的時候,它又代表着 ChineseGreeting() 法。我們將這個參數變量命名為 MakeGreeting,那么不是可以如同給 name 賦值時一樣,在調用 GreetPeople()方法的時候,給這個MakeGreeting 參數也賦上值么(ChineseGreeting 或者EnglsihGreeting 等)?然后,我們在方法體內,也可以像使用別的參數一樣使用MakeGreeting。但是,由於 MakeGreeting 代表着一個方法,它的使用方式應該和它被賦的方法(比如ChineseGreeting)是一樣的,比如:MakeGreeting(name);
好了,有了思路了,我們現在就來改改GreetPeople()方法,那么它應該是這個樣子了:
public void GreetPeople(string name, *** MakeGreeting)
{
MakeGreeting(name);
}
注意到 *** ,這個位置通常放置的應該是參數的類型,但到目前為止,我們僅僅是想到應該有個可以代表方法的參數,並按這個思路去改寫 GreetPeople 方法,現在就出現了一個大問題:這個代表着方法的 MakeGreeting 參數應該是什么類型的?
說明:這里已不再需要枚舉了,因為在給MakeGreeting 賦值的時候動態地決定使用哪個方法,是 ChineseGreeting 還是 EnglishGreeting,而在這個兩個方法內部,已經對使用“Good Morning”還是“早上好”作了區分。
聰明的你應該已經想到了,現在是委托該出場的時候了,但講述委托之前,我們再看看MakeGreeting 參數所能代表的 ChineseGreeting()和EnglishGreeting()方法的簽名:
public void EnglishGreeting(string name)
public void ChineseGreeting(string name)
如同 name 可以接受 String 類型的“true”和“1”,但不能接受bool 類型的true 和int 類型的1 一樣。MakeGreeting 的參數類型定義應該能夠確定 MakeGreeting 可以代表的方法種類,再進一步講,就是 MakeGreeting 可以代表的方法的參數類型和返回類型。
於是,委托出現了:它定義了 MakeGreeting 參數所能代表的方法的種類,也就是 MakeGreeting 參數的類型。
本例中委托的定義:
public delegate void GreetingDelegate(string name);
與上面 EnglishGreeting() 方法的簽名對比一下,除了加入了delegate 關鍵字以外,其余的是不是完全一樣?現在,讓我們再次改動GreetPeople()方法,如下所示:
public delegate void GreetingDelegate(string name);
public void GreetPeople(string name, GreetingDelegate MakeGreeting)
{
MakeGreeting(name);
}
如你所見,委托 GreetingDelegate 出現的位置與 string 相同,string 是一個類型,那么 GreetingDelegate 應該也是一個類型,或者叫類(Class)。但是委托的聲明方式和類卻完全不同,這是怎么一回事?實際上,委托在編譯的時候確實會編譯成類。因為 Delegate 是一個類,所以在任何可以聲明類的地方都可以聲明委托。更多的內容將在下面講述,現在,請看看這個范例的完整代碼:
public delegate void GreetingDelegate(string name);
class Program
{
private static void EnglishGreeting(string name)
{
Console.WriteLine("Good Morning, " + name);
}
private static void ChineseGreeting(string name)
{
Console.WriteLine("早上好, " + name);
}
private static void GreetPeople(string name, GreetingDelegate MakeGreeting)
{
MakeGreeting(name);
}
static void Main(string[] args)
{
GreetPeople("Liker", EnglishGreeting);
GreetPeople("李志中", ChineseGreeting);
Console.ReadLine();
}
}
我們現在對委托做一個總結:委托是一個類,它定義了方法的類型,使得可以將方法當作另一個方法的參數來進行傳遞,這種將方法動態地賦給參數的做法,可以避免在程序中大量使用If … Else(Switch)語句,同時使得程序具有更好的可擴展性。
1.1.2 將方法綁定到委托
看到這里,是不是有那么點如夢初醒的感覺?於是,你是不是在想:在上面的例子中,我不一定要直接在 GreetPeople() 方法中給 name 參數賦值,我可以像這樣使用變量:
static void Main(string[] args)
{
GreetPeople("Liker", EnglishGreeting);
GreetPeople("李志中", ChineseGreeting);
Console.ReadLine();
}
而既然委托 GreetingDelegate 和類型 string 的地位一樣,都是定義了一種參數類型,那么,我是不是也可以這么使用委托?
static void Main(string[] args)
{
GreetingDelegate delegate1, delegate2;
delegate1 = EnglishGreeting;
delegate2 = ChineseGreeting;
GreetPeople("Liker", delegate1);
GreetPeople("李志中", delegate2);
Console.ReadLine();
}
如你所料,這樣是沒有問題的,程序一如預料的那樣輸出。這里,我想說的是委托不同於 string 的一個特性:可以將多個方法賦給同一個委托,或者叫將多個方法綁定到同一個委托,當調用這個委托的時候,將依次調用其所綁定的方法。在這個例子中,語法如下:
static void Main(string[] args)
{
GreetingDelegate delegate1;
delegate1 = EnglishGreeting;
delegate1 += ChineseGreeting;
GreetPeople("Liker", delegate1);
Console.ReadLine();
}
實際上,我們可以也可以繞過GreetPeople 方法,通過委托來直接調用EnglishGreeting 和ChineseGreeting:
static void Main(string[] args)
{
GreetingDelegate delegate1;
delegate1 = EnglishGreeting;
delegate1 += ChineseGreeting;
delegate1("Liker");
Console.ReadLine();
}
說明:這在本例中是沒有問題的,但回頭看下上面 GreetPeople() 的定義,在它之中可以做一些對於 EnglshihGreeting 和 ChineseGreeting 來說都需要進行的工作,為了簡便我做了省略。
注意這里,第一次用的“=”,是賦值的語法;第二次,用的是“+=”,是綁定的語法。如果第一次就使用“+=”,將出現“使用了未賦值的局部變量”的編譯錯誤。我們也可以使用下面的代碼來這樣簡化這一過程:
GreetingDelegate delegate1 = new GreetingDelegate(EnglishGreeting);
delegate1 += ChineseGreeting;
既然給委托可以綁定一個方法,那么也應該有辦法取消對方法的綁定,很容易想到,這個語法是“-=”:
static void Main(string[] args)
{
GreetingDelegate delegate1 = new GreetingDelegate(EnglishGreeting);
delegate1 += ChineseGreeting;
GreetPeople("Liker", delegate1);
Console.WriteLine();
delegate1 -= EnglishGreeting;
GreetPeople("李志中", delegate1);
Console.ReadLine();
}
讓我們再次對委托作個總結:
使用委托可以將多個方法綁定到同一個委托變量,當調用此變量時(這里用“調用”這個詞,是因為此變量代表一個方法),可以依次調用所有綁定的方法。
1.2.1 更好的封裝性
我們繼續思考上面的程序:上面的三個方法都定義在 Programe 類中,這樣做是為了理解的方便,實際應用中,通常都是 GreetPeople 在一個類中,ChineseGreeting 和 EnglishGreeting 在另外的類中。現在你已經對委托有了初步了解,是時候對上面的例子做個改進了。假設我們將 GreetingPeople() 放在一個叫 GreetingManager 的類中,那么新程序應該是這個樣子的:
namespace Delegate
{
public delegate void GreetingDelegate(string name);
public class GreetingManager
{
public void GreetPeople(string name, GreetingDelegate MakeGreeting)
{
MakeGreeting(name);
}
}
class Program
{
private static void EnglishGreeting(string name)
{
Console.WriteLine("Good Morning, " + name);
}
private static void ChineseGreeting(string name)
{
Console.WriteLine("早上好, " + name);
}
static void Main(string[] args)
{
GreetingManager gm = new GreetingManager();
gm.GreetPeople("Liker", EnglishGreeting);
gm.GreetPeople("李志中", ChineseGreeting);
}
}
}
我們運行這段代碼,嗯,沒有任何問題。程序一如預料地那樣輸出了:
// ************************************************************************
Good Morning, Liker
早上好, 李志中
// ************************************************************************
現在,假設我們需要使用上一節學到的知識,將多個方法綁定到同一個委托變量,該如何做呢?讓我們再次改寫代碼:
static void Main(string[] args)
{
GreetingManager gm = new GreetingManager();
GreetingDelegate delegate1;
delegate1 = EnglishGreeting;
delegate1 += ChineseGreeting;
gm.GreetPeople("Liker", delegate1);
}
輸出:
Good Morning, Liker
早上好, Liker
到了這里,我們不禁想到:面向對象設計,講究的是對象的封裝,既然可以聲明委托類型的變量(在上例中是delegate1),我們何不將這個變量封裝到 GreetManager 類中?在這個類的客戶端中使用不是更方便么?於是,我們改寫GreetManager 類,像這樣:
public class GreetingManager
{
/// <summary>
/// 在 GreetingManager 類的內部聲明 delegate1 變量
/// </summary>
public GreetingDelegate delegate1;
public void GreetPeople(string name, GreetingDelegate MakeGreeting)
{
MakeGreeting(name);
}
}
現在,我們可以這樣使用這個委托變量:
static void Main(string[] args)
{
GreetingManager gm = new GreetingManager();
gm.delegate1 = EnglishGreeting;
gm.delegate1 += ChineseGreeting;
gm.GreetPeople("Liker", gm.delegate1);
}
輸出為:
Good Morning, Liker
早上好, Liker
盡管這樣做沒有任何問題,但我們發現這條語句很奇怪。在調用gm.GreetPeople 方法的時候,再次傳遞了gm 的delegate1 字段, 既然如此,我們何不修改 GreetingManager 類成這樣:
public class GreetingManager
{
/// <summary>
/// 在 GreetingManager 類的內部聲明 delegate1 變量
/// </summary>
public GreetingDelegate delegate1;
public void GreetPeople(string name)
{
if (delegate1 != null) // 如果有方法注冊委托變量
{
delegate1(name); // 通過委托調用方法
}
}
}
在客戶端,調用看上去更簡潔一些:
static void Main(string[] args)
{
GreetingManager gm = new GreetingManager();
gm.delegate1 = EnglishGreeting;
gm.delegate1 += ChineseGreeting;
gm.GreetPeople("Liker"); //注意,這次不需要再傳遞 delegate1 變量
}
盡管這樣達到了我們要的效果,但是還是存在着問題:在這里,delegate1 和我們平時用的string 類型的變量沒有什么分別,而我們知道,並不是所有的字段都應該聲明成public,合適的做法是應該public 的時候public,應該private 的時候private。
我們先看看如果把 delegate1 聲明為 private 會怎樣?結果就是:這簡直就是在搞笑。因為聲明委托的目的就是為了把它暴露在類的客戶端進行方法的注冊,你把它聲明為 private 了,客戶端對它根本就不可見,那它還有什么用?
再看看把delegate1 聲明為 public 會怎樣?結果就是:在客戶端可以對它進行隨意的賦值等操作,嚴重破壞對象的封裝性。
最后,第一個方法注冊用“=”,是賦值語法,因為要進行實例化,第二個方法注冊則用的是“+=”。但是,不管是賦值還是注冊,都是將方法綁定到委托上,除了調用時先后順序不同,再沒有任何的分別,這樣不是讓人覺得很別扭么?
現在我們想想,如果delegate1 不是一個委托類型,而是一個string 類型,你會怎么做?答案是使用屬性對字段進行封裝。
於是,Event 出場了,它封裝了委托類型的變量,使得:在類的內部,不管你聲明它是public還是protected,它總是private 的。在類的外部,注冊“+=”和注銷“-=”的訪問限定符與你在聲明事件時使用的訪問符相同。我們改寫GreetingManager 類,它變成了這個樣子:
public class GreetingManager
{
//這一次我們在這里聲明一個事件
public event GreetingDelegate MakeGreet;
public void GreetPeople(string name)
{
MakeGreet(name);
}
}
很容易注意到:MakeGreet 事件的聲明與之前委托變量 delegate1 的聲明唯一的區別是多了一個 event 關鍵字。看到這里,在結合上面的講解,你應該明白到:事件其實沒什么不好理解的,聲明一個事件不過類似於聲明一個進行了封裝的委托類型的變量而已。
為了證明上面的推論,如果我們像下面這樣改寫Main 方法:
static void Main(string[] args)
{
GreetingManager gm = new GreetingManager();
gm.MakeGreet = EnglishGreeting; // 編譯錯誤1
gm.MakeGreet += ChineseGreeting;
gm.GreetPeople("Liker");
}
會得到編譯錯誤:
1.2.2 限制類型能力
使用事件不僅能獲得比委托更好的封裝性以外,還能限制含有事件的類型的能力。這是什么意思呢?它的意思是說:事件應該由事件發布者觸發,而不應該由事件的客戶端(客戶程序)來觸發。請看下面的范例:
using System;
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); // 但可以被這樣直接調用,對委托變量的不恰當使用
}
}
/// <summary>
/// 定義委托
/// </summary>
/// <param name="count"></param>
public delegate void NumberChangedEventHandler(int count);
/// <summary>
/// 定義事件發布者
/// </summary>
public class Publishser
{
private int count;
public NumberChangedEventHandler NumberChanged; // 聲明委托變量
//public event NumberChangedEventHandler NumberChanged; // 聲明一個事件
public void DoSomething()
{
// 在這里完成一些工作 ...
if (NumberChanged != null) // 觸發事件
{
count++;
NumberChanged(count);
}
}
}
/// <summary>
/// 定義事件訂閱者
/// </summary>
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 一樣,給予對象過多的能力並不見得是一件好事,應該是越合適越好。盡管直接使用委托變量通常不會有什么問題,但它給了客戶端不應具有的能力,而使用事件,可以限制這一能力,更精確地對類型進行封裝。
說 明:這里還有一個約定俗稱的規定,就是訂閱事件的方法的命名,通常為“On 事件名”,比如這里的OnNumberChanged。
這時候,我們注釋掉編譯錯誤的行,然后重新進行編譯,再借助 Reflactor 來對 event 的聲明語句做一探究,看看為什么會發生這樣的錯誤:
可以看到,實際上盡管我們在GreetingManager 里將 MakeGreet 聲明為public,但是,實際上MakeGreet 會被編譯成私有字段,難怪會發生上面的編譯錯誤了,因為它根本就不允許在GreetingManager 類的外面以賦值的方式訪問,從而驗證了我們上面所做的推論。
我們再進一步看下MakeGreet 所產生的代碼:
// ************************************************************************
private GreetingDelegate MakeGreet; //對事件的聲明實際是聲明一個私有的委托變量
[MethodImpl(MethodImplOptions.Synchronized)]
public void add_MakeGreet(GreetingDelegate value)
{
this.MakeGreet = (GreetingDelegate) Delegate.Combine(this.MakeGreet, value);
}
[MethodImpl(MethodImplOptions.Synchronized)]
public void remove_MakeGreet(GreetingDelegate value)
{
this.MakeGreet = (GreetingDelegate) Delegate.Remove(this.MakeGreet, value);
}
// ************************************************************************
現在已經很明確了:MakeGreet 事件確實是一個GreetingDelegate 類型的委托,只不過不管是不是聲明為public,它總是被聲明為private。另外,它還有兩個方法,分別是add_MakeGreet和remove_MakeGreet,這兩個方法分別用於注冊委托類型的方法和取消注冊。實際上也就是:“+= ”對應 add_MakeGreet,“-=”對應remove_MakeGreet。而這兩個方法的訪問限制取決於聲明事件時的訪問限制符。
在add_MakeGreet()方法內部,實際上調用了System.Delegate 的Combine()靜態方法,這個方法用於將當前的變量添加到委托鏈表中。
我們前面提到過兩次,說委托實際上是一個類,在我們定義委托的時候:
// ************************************************************************
public delegate void GreetingDelegate(string name);
// ************************************************************************
當編譯器遇到這段代碼的時候,會生成下面這樣一個完整的類:
// ************************************************************************
public class GreetingDelegate:System.MulticastDelegate
{
public GreetingDelegate(object @object, IntPtr method);
public virtual IAsyncResult BeginInvoke(string name, AsyncCallback callback, object @object);
public virtual void EndInvoke(IAsyncResult result);
public virtual void Invoke(string name);
}
// ************************************************************************
1.4.1 范例說明
上面的例子已不足以再進行下面的講解了,我們來看一個新的范例,因為之前已經介紹了很多的內容,所以本節的進度會稍微快一些!
假設我們有個高檔的熱水器,我們給它通上電,當水溫超過95 度的時候:1、揚聲器會開始發出語音,告訴你水的溫度;2、液晶屏也會改變水溫的顯示,來提示水已經快燒開了。
現在我們需要寫個程序來模擬這個燒水的過程,我們將定義一個類來代表熱水器,我們管它叫:Heater,它有代表水溫的字段,叫做 temperature;當然,還有必不可少的給水加熱方法 BoilWater(),一個發出語音警報的方法 MakeAlert(),一個顯示水溫的方法,ShowMsg()。
namespace Delegate
{
/// <summary>
/// 熱水器
/// </summary>
public class Heater
{
/// <summary>
/// 水溫
/// </summary>
private int temperature;
/// <summary>
/// 燒水
/// </summary>
public void BoilWater()
{
for (int i = 0; i <= 100; i++)
{
temperature = i;
if (temperature > 95)
{
MakeAlert(temperature);
ShowMsg(temperature);
}
}
}
/// <summary>
/// 發出語音警報
/// </summary>
/// <param name="param"></param>
private void MakeAlert(int param)
{
Console.WriteLine("Alarm:嘀嘀嘀,水已經 {0} 度了:", param);
}
/// <summary>
/// 顯示水溫
/// </summary>
/// <param name="param"></param>
private void ShowMsg(int param)
{
Console.WriteLine("Display:水快開了,當前溫度:{0}度。", param);
}
}
class Program
{
static void Main()
{
Heater ht = new Heater();
ht.BoilWater();
}
}
}
1.4.2 Observer 設計模式簡介
上面的例子顯然能完成我們之前描述的工作,但是卻並不夠好。現在假設熱水器由三部分組成:熱水器、警報器、顯示器,它們來自於不同廠商並進行了組裝。那么,應該是熱水器僅僅負責燒水,它不能發出警報也不能顯示水溫;在水燒開時由警報器發出警報、顯示器顯示提示和水溫。
這時候,上面的例子就應該變成這個樣子:
/// <summary>
/// 熱水器
/// </summary>
public class Heater
{
private int temperature;
private void BoilWater()
{
for (int i = 0; i <= 100; i++)
{
temperature = i;
}
}
}
/// <summary>
/// 警報器
/// </summary>
public class Alarm
{
private void MakeAlert(int param)
{
Console.WriteLine("Alarm:嘀嘀嘀,水已經 {0} 度了:", param);
}
}
/// <summary>
/// 顯示器
/// </summary>
public class Display
{
private void ShowMsg(int param)
{
Console.WriteLine("Display:水已燒開,當前溫度:{0}度。", param);
}
}
這里就出現了一個問題:如何在水燒開的時候通知報警器和顯示器?
在繼續進行之前,我們先了解一下Observer 設計模式,Observer 設計模式中主要包括如下兩類對象:
Subject:監視對象,它往往包含着其他對象所感興趣的內容。在本范例中,熱水器就是一個監視對象,它包含的其他對象所感興趣的內容,就是 temprature 字段,當這個字段的值快到100 時,會不斷把數據發給監視它的對象。
Observer:監視者,它監視Subject,當 Subject 中的某件事發生的時候,會告知Observer,而Observer 則會采取相應的行動。在本范例中,Observer 有警報器和顯示器,它們采取的行動分別是發出警報和顯示水溫。
在本例中,事情發生的順序應該是這樣的:
1. 警報器和顯示器告訴熱水器,它對它的溫度比較感興趣(注冊)。
2. 熱水器知道后保留對警報器和顯示器的引用。
3. 熱水器進行燒水這一動作,當水溫超過 95 度時,通過對警報器和顯示器的引用,自動調用警報器的MakeAlert()方法、顯示器的ShowMsg()方法。
類似這樣的例子是很多的,GOF 對它進行了抽象,稱為 Observer 設計模式:Observer 設計模式是為了定義對象間的一種一對多的依賴關系,以便於當一個對象的狀態改變時,其他依賴於它的對象會被自動告知並更新。Observer 模式是一種松耦合的設計模式。
1.4.3 實現范例的Observer 設計模式
我們之前已經對委托和事件介紹很多了,現在寫代碼應該很容易了,現在在這里直接給出代碼,並在注釋中加以說明。
namespace Delegate
{
public class Heater
{
private int temperature;
public delegate void BoilHandler(int param);
public event BoilHandler BoilEvent;
public void BoilWater()
{
for (int i = 0; i <= 100; i++)
{
temperature = i;
if (temperature > 95)
{
if (BoilEvent != null)
{
BoilEvent(temperature); // 調用所有注冊對象的方法
}
}
}
}
}
public class Alarm
{
public void MakeAlert(int param)
{
Console.WriteLine("Alarm:嘀嘀嘀,水已經 {0} 度了:", param);
}
}
public class Display
{
public static void ShowMsg(int param) // 靜態方法
{
Console.WriteLine("Display:水快燒開了,當前溫度:{0}度。", param);
}
}
class Program
{
static void Main()
{
Heater heater = new Heater();
Alarm alarm = new Alarm();
heater.BoilEvent += alarm.MakeAlert; // 注冊方法
heater.BoilEvent += (new Alarm()).MakeAlert; // 給匿名對象注冊方法
heater.BoilEvent += Display.ShowMsg; // 注冊靜態方法
heater.BoilWater(); // 燒水,會自動調用注冊過對象的方法
}
}
}
輸出為:
// ************************************************************************
Alarm:嘀嘀嘀,水已經 96 度了:
Alarm:嘀嘀嘀,水已經 96 度了:
Display:水快燒開了,當前溫度:96 度。
// 省略...
// ************************************************************************
1.4.4 .NET 框架中的委托與事件
盡管上面的范例很好地完成了我們想要完成的工作,但是我們不僅疑惑:為什么.NET Framework 中的事件模型和上面的不同?為什么有很多的EventArgs 參數?
在回答上面的問題之前,我們先搞懂 .NET Framework 的編碼規范:
1. 委托類型的名稱都應該以 EventHandler 結束。
2. 委托的原型定義:有一個void 返回值,並接受兩個輸入參數:一個Object 類型,一個EventArgs 類型(或繼承自EventArgs)。
3. 事件的命名為委托去掉 EventHandler 之后剩余的部分。
4. 繼承自 EventArgs 的類型應該以EventArgs 結尾。
再做一下說明:
1. 委托聲明原型中的Object 類型的參數代表了Subject,也就是監視對象,在本例中是Heater(熱水器)。回調函數(比如Alarm 的MakeAlert)可以通過它訪問觸發事件的對象(Heater)。
2. EventArgs 對象包含了Observer 所感興趣的數據,在本例中是temperature。
上面這些其實不僅僅是為了編碼規范而已,這樣也使得程序有更大的靈活性。比如說,如果我們不光想獲得熱水器的溫度,還想在Observer 端(警報器或者顯示器)方法中獲得它的生產日期、型號、價格,那么委托和方法的聲明都會變得很麻煩,而如果我們將熱水器的引用傳給警報器的方法,就可以在方法中直接訪問熱水器了。
現在我們改寫之前的范例,讓它符合.NET Framework的規范:
using System;
using System.Collections.Generic;
using System.Text;
namespace Delegate
{
public class Heater
{
private int temperature;
public string type = "RealFire 001"; // 添加型號作為演示
public string area = "China Xian"; // 添加產地作為演示
public delegate void BoiledEventHandler(Object sender, BoiledEventArgs e);
public event BoiledEventHandler Boiled; // 聲明事件
// 定義 BoiledEventArgs 類,傳遞給 Observer 所感興趣的信息
public class BoiledEventArgs : EventArgs
{
public readonly int temperature;
public BoiledEventArgs(int temperature)
{
this.temperature = temperature;
}
}
// 可以供繼承自 Heater 的類重寫,以便繼承類拒絕其他對象對它的監視
protected virtual void OnBoiled(BoiledEventArgs e)
{
if (Boiled != null)
{
Boiled(this, e); // 調用所有注冊對象的方法
}
}
public void BoilWater()
{
for (int i = 0; i <= 100; i++)
{
temperature = i;
if (temperature > 95)
{
// 建立BoiledEventArgs 對象。
BoiledEventArgs e = new BoiledEventArgs(temperature);
OnBoiled(e); // 調用 OnBolied 方法
}
}
}
public class Alarm
{
public void MakeAlert(Object sender, Heater.BoiledEventArgs e)
{
Heater heater = (Heater)sender; // 這里是不是很熟悉呢?
// 訪問 sender 中的公共字段
Console.WriteLine("Alarm:{0} - {1}: ", heater.area, heater.type);
Console.WriteLine("Alarm: 嘀嘀嘀,水已經 {0} 度了:", e.temperature);
Console.WriteLine();
}
}
public class Display
{
public static void ShowMsg(Object sender, Heater.BoiledEventArgs e) // 靜態方法
{
Heater heater = (Heater)sender;
Console.WriteLine("Display:{0} - {1}: ", heater.area, heater.type);
Console.WriteLine("Display:水快燒開了,當前溫度:{0}度。", e.temperature);
Console.WriteLine();
}
}
class Program
{
static void Main()
{
Heater heater = new Heater();
Alarm alarm = new Alarm();
heater.Boiled += alarm.MakeAlert; //注冊方法
heater.Boiled += (new Alarm()).MakeAlert; //給匿名對象注冊方法
heater.Boiled += new Heater.BoiledEventHandler(alarm.MakeAlert); //也可以這么注冊
heater.Boiled += Display.ShowMsg; //注冊靜態方法
heater.BoilWater(); //燒水,會自動調用注冊過對象的方法
}
}
}
}
輸出為:
Alarm:China Xian - RealFire 001:
Alarm: 嘀嘀嘀,水已經 96 度了:
Alarm:China Xian - RealFire 001:
Alarm: 嘀嘀嘀,水已經 96 度了:
Alarm:China Xian - RealFire 001:
Alarm: 嘀嘀嘀,水已經 96 度了:
Display:China Xian - RealFire 001:
Display:水快燒開了,當前溫度:96 度。
// 省略 ...
1.5.1 為什么委托定義的返回值通常都為 void ?
盡管並非必需,但是我們發現很多的委托定義返回值都為 void,為什么呢?這是因為委托變量可以供多個訂閱者注冊,如果定義了返回值,那么多個訂閱者的方法都會向發布者返回數值,結果就是后面一個返回的方法值將前面的返回值覆蓋掉了,因此,實際上只能獲得最后一個方法調用的返回值。可以運行下面的代碼測試一下。除此以外,發布者和訂閱者是松耦合的,發布者根本不關心誰訂閱了它的事件、為什么要訂閱,更別說訂閱者的返回值了,所以返回訂閱者的方法返回值大多數情況下根本沒有必要。
1.5.2 如何讓事件只允許一個客戶訂閱?
少數情況下,比如像上面,為了避免發生“值覆蓋”的情況(更多是在異步調用方法時,后面會討論),我們可能想限制只允許一個客戶端注冊。此時怎么做呢?我們可以向下面這樣,將事件聲明為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
}
}
}
注意上面,在UnRegister()中,沒有進行任何判斷就使用了NumberChanged -= method 語句。這是因為即使method 方法沒有進行過注冊,此行語句也不會有任何問題,不會拋出異常,僅僅是不會產生任何效果而已。
注意在Register()方法中,我們使用了賦值操作符“=”,而非“+=”,通過這種方式就避免了多個方法注冊。
通常情況下,如果需要異步執行一個耗時的操作,我們會新起一個線程,然后讓這個線程去執行代碼。但是對於每一個異步調用都通過創建線程來進行操作顯然會對性能產生一定的影響,同時操作也相對繁瑣一些。.NET 中可以通過委托進行方法的異步調用,就是說客戶端在異步調用方法時,本身並不會因為方法的調用而中斷,而是從線程池中抓取一個線程去執行該方法,自身線程(主線程)在完成抓取線程這一過程之后,繼續執行下面的代碼,這樣就實現了代碼的並行執行。使用線程池的好處就是避免了頻繁進行異步調用時創建、銷毀線程的開銷。當我們在委托對象上調用BeginInvoke()時,便進行了一個異步的方法調用。
事件發布者和訂閱者之間往往是松耦合的,發布者通常不需要獲得訂閱者方法執行的情況;而當使用異步調用時,更多情況下是為了提升系統的性能,而並非專用於事件的發布和訂閱這一編程模型。而在這種情況下使用異步編程時,就需要進行更多的控制,比如當異步執行方法的方法結束時通知客戶端、返回異步執行方法的返回值等。本節就對 BeginInvoke() 方法、EndInvoke() 方法和其相關的 IAysncResult 做一個簡單的介紹。
我們先看這樣一段代碼,它演示了不使用異步調用的通常情況:
class Program7
{
static void Main(string[] args)
{
Console.WriteLine("Client application started!\n");
Thread.CurrentThread.Name = "Main Thread";
Calculator cal = new Calculator();
int result = cal.Add(2, 5);
Console.WriteLine("Result: {0}\n", result);
// 做某些其它的事情,模擬需要執行3 秒鍾
for (int i = 1; i <= 3; i++)
{
Thread.Sleep(TimeSpan.FromSeconds(i));
Console.WriteLine("{0}: Client executed {1} second(s).", Thread.CurrentThread.Name, i);
}
Console.WriteLine("\nPress any key to exit...");
Console.ReadLine();
}
}
public class Calculator
{
public int Add(int x, int y)
{
if (Thread.CurrentThread.IsThreadPoolThread)
{
Thread.CurrentThread.Name = "Pool Thread";
}
Console.WriteLine("Method invoked!");
// 執行某些事情,模擬需要執行2 秒鍾
for (int i = 1; i <= 2; i++)
{
Thread.Sleep(TimeSpan.FromSeconds(i));
Console.WriteLine("{0}: Add executed {1} second(s).", Thread.CurrentThread.Name, i);
}
Console.WriteLine("Method complete!");
return x + y;
}
}
上面代碼有幾個關於對於線程的操作,如果不了解可以看一下下面的說明,如果你已經了解可以直接跳過:
1. Thread.Sleep(),它會讓執行當前代碼的線程暫停一段時間(如果你對線程的概念比較陌生,可以理解為使程序的執行暫停一段時間),以毫秒為單位,比如Thread.Sleep(1000),將會使線程暫停1 秒鍾。在上面我使用了它的重載方法,個人覺得使用TimeSpan.FromSeconds(1),可讀性更好一些。
2. Thread.CurrentThread.Name,通過這個屬性可以設置、獲取執行當前代碼的線程的名稱,值得注意的是這個屬性只可以設置一次,如果設置兩次,會拋出異常。
3. Thread.IsThreadPoolThread,可以判斷執行當前代碼的線程是否為線程池中的線程。
通過這幾個方法和屬性,有助於我們更好地調試異步調用方法。上面代碼中除了加入了一些對線程的操作以外再沒有什么特別之處。我們建了一個Calculator 類,它只有一個Add 方法,我們模擬了這個方法需要執行2 秒鍾時間,並且每隔一秒進行一次輸出。而在客戶端程序中,我們使用result 變量保存了方法的返回值並進行了打印。隨后,我們再次模擬了客戶端程序接下來的操作需要執行2 秒鍾時間。運行這段程序,會產生下面的輸出:
// ************************************************************************
Client application started!
Method invoked!
Main Thread: Add executed 1 second(s).
Main Thread: Add executed 2 second(s).
Method complete!
Result: 7
Main Thread: Client executed 1 second(s).
Main Thread: Client executed 2 second(s).
Main Thread: Client executed 3 second(s).
Press any key to exit...
// ************************************************************************
如果你確實執行了這段代碼,會看到這些輸出並不是一瞬間輸出的,而是執行了大概5 秒鍾的時間,因為線程是串行執行的,所以在執行完 Add() 方法之后才會繼續客戶端剩下的代碼。
接下來我們定義一個AddDelegate 委托,並使用BeginInvoke()方法來異步地調用它。在上面已經介紹過,BeginInvoke()除了最后兩個參數為AsyncCallback 類型和Object 類型以外,前面的參數類型和個數與委托定義相同。另外BeginInvoke()方法返回了一個實現了IAsyncResult 接口的對象(實際上就是一個AsyncResult 類型實例,注意這里IAsyncResult 和AysncResult 是不同的,它們均包含在.NET Framework 中)。
AsyncResult 的用途有這么幾個:傳遞參數,它包含了對調用了BeginInvoke()的委托的引用;它還包含了BeginInvoke()的最后一個Object 類型的參數;它可以鑒別出是哪個方法的哪一次調用,因為通過同一個委托變量可以對同一個方法調用多次。
EndInvoke()方法接受IAsyncResult 類型的對象(以及ref 和out 類型參數,這里不討論了,對它們的處理和返回值類似),所以在調用BeginInvoke()之后,我們需要保留IAsyncResult,以便在調用EndInvoke()時進行傳遞。這里最重要的就是EndInvoke()方法的返回值,它就是方法的返回值。除此以外,當客戶端調用EndInvoke()時,如果異步調用的方法沒有執行完畢,則會中斷當前線程而去等待該方法,只有當異步方法執行完畢后才會繼續執行后面的代碼。所以在調用完BeginInvoke()后立即執行EndInvoke()是沒有任何意義的。我們通常在盡可能早的時候調用BeginInvoke(),然后在需要方法的返回值的時候再去調用EndInvoke(),或者是根據情況在晚些時候調用。說了這么多,我們現在看一下使用異步調用改寫后上面的代碼吧:
using System.Threading;
using System;
public delegate int AddDelegate(int x, int y);
class Program8
{
static void Main(string[] args)
{
Console.WriteLine("Client application started!\n");
Thread.CurrentThread.Name = "Main Thread";
Calculator cal = new Calculator();
AddDelegate del = new AddDelegate(cal.Add);
IAsyncResult asyncResult = del.BeginInvoke(2, 5, null, null); // 異步調用方法
// 做某些其它的事情,模擬需要執行3 秒鍾
for (int i = 1; i <= 3; i++)
{
Thread.Sleep(TimeSpan.FromSeconds(i));
Console.WriteLine("{0}: Client executed {1} second(s).", Thread.CurrentThread.Name, i);
}
int rtn = del.EndInvoke(asyncResult);
Console.WriteLine("Result: {0}\n", rtn);
Console.WriteLine("\nPress any key to exit...");
Console.ReadLine();
}
}
public class Calculator
{
public int Add(int x, int y)
{
if (Thread.CurrentThread.IsThreadPoolThread)
{
Thread.CurrentThread.Name = "Pool Thread";
}
Console.WriteLine("Method invoked!");
// 執行某些事情,模擬需要執行2 秒鍾
for (int i = 1; i <= 2; i++)
{
Thread.Sleep(TimeSpan.FromSeconds(i));
Console.WriteLine("{0}: Add executed {1} second(s).", Thread.CurrentThread.Name, i);
}
Console.WriteLine("Method complete!");
return x + y;
}
}
此時的輸出為:
// ************************************************************************
Client application started!
Method invoked!
Main Thread: Client executed 1 second(s).
Pool Thread: Add executed 1 second(s).
Main Thread: Client executed 2 second(s).
Pool Thread: Add executed 2 second(s).
Method complete!
Main Thread: Client executed 3 second(s).
Result: 7
Press any key to exit...
// ************************************************************************
現在執行完這段代碼只需要3 秒鍾時間,兩個for 循環所產生的輸出交替進行,這也說明了這兩段代碼並行執行的情況。可以看到Add() 方法是由線程池中的線程在執行, 因為Thread.CurrentThread.IsThreadPoolThread 返回了True,同時我們對該線程命名為了Pool Thread。另外我們可以看到通過EndInvoke()方法得到了返回值。有時候,我們可能會將獲得返回值的操作放到另一段代碼或者客戶端去執行,而不是向上面那樣直接寫在BeginInvoke()的后面。比如說我們在Program 中新建一個方法GetReturn(),此時可以通過AsyncResult 的AsyncDelegate 獲得del 委托對象,然后再在其上調用EndInvoke()方法,這也說明了AsyncResult 可以唯一的獲取到與它相關的調用了的方法(或者也可以理解成委托對象)。所以上面獲取返回值的代碼也可以改寫成這樣:
private static int GetReturn(IAsyncResult asyncResult)
{
AsyncResult result = (AsyncResult)asyncResult;
AddDelegate del = (AddDelegate)result.AsyncDelegate;
int rtn = del.EndInvoke(asyncResult);
return rtn;
}
然后再將int rtn = del.EndInvoke(asyncResult);語句改為int rtn = GetReturn(asyncResult);。注意上面IAsyncResult 要轉換為實際的類型AsyncResult 才能訪問AsyncDelegate 屬性,因為它沒有包含在IAsyncResult 接口的定義中。
BeginInvoke 的另外兩個參數分別是AsyncCallback 和Object 類型,其中AsyncCallback 是一個委托類型,它用於方法的回調,即是說當異步方法執行完畢時自動進行調用的方法。它的定義為:
// ************************************************************************
public delegate void AsyncCallback(IAsyncResult ar);
// ************************************************************************
Object 類型用於傳遞任何你想要的數值,它可以通過IAsyncResult 的AsyncState 屬性獲得。下面我們將獲取方法返回值、打印返回值的操作放到了OnAddComplete()回調方法中:
using System.Threading;
using System;
using System.Runtime.Remoting.Messaging;
public delegate int AddDelegate(int x, int y);
class Program9
{
static void Main(string[] args)
{
Console.WriteLine("Client application started!\n");
Thread.CurrentThread.Name = "Main Thread";
Calculator cal = new Calculator();
AddDelegate del = new AddDelegate(cal.Add);
string data = "Any data you want to pass.";
AsyncCallback callBack = new AsyncCallback(OnAddComplete);
del.BeginInvoke(2, 5, callBack, data); // 異步調用方法
// 做某些其它的事情,模擬需要執行3 秒鍾
for (int i = 1; i <= 3; i++)
{
Thread.Sleep(TimeSpan.FromSeconds(i));
Console.WriteLine("{0}: Client executed {1} second(s).", Thread.CurrentThread.Name, i);
}
Console.WriteLine("\nPress any key to exit...");
Console.ReadLine();
}
static void OnAddComplete(IAsyncResult asyncResult)
{
AsyncResult result = (AsyncResult)asyncResult;
AddDelegate del = (AddDelegate)result.AsyncDelegate;
string data = (string)asyncResult.AsyncState;
int rtn = del.EndInvoke(asyncResult);
Console.WriteLine("{0}: Result, {1}; Data: {2}\n", Thread.CurrentThread.Name, rtn, data);
}
}
public class Calculator
{
public int Add(int x, int y)
{
if (Thread.CurrentThread.IsThreadPoolThread)
{
Thread.CurrentThread.Name = "Pool Thread";
}
Console.WriteLine("Method invoked!");
// 執行某些事情,模擬需要執行2 秒鍾
for (int i = 1; i <= 2; i++)
{
Thread.Sleep(TimeSpan.FromSeconds(i));
Console.WriteLine("{0}: Add executed {1} second(s).", Thread.CurrentThread.Name, i);
}
Console.WriteLine("Method complete!");
return x + y;
}
}
它產生的輸出為:
Client application started!
Method invoked!
Main Thread: Client executed 1 second(s).
Pool Thread: Add executed 1 second(s).
Main Thread: Client executed 2 second(s).
Pool Thread: Add executed 2 second(s).
Method complete!
Pool Thread: Result, 7; Data: Any data you want to pass.
Main Thread: Client executed 3 second(s).
Press any key to exit...
這里有幾個值得注意的地方:
1、我們在調用BeginInvoke()后不再需要保存IAysncResult 了,因為AysncCallback 委托將該對象定義在了回調方法的參數列表中;
2、我們在OnAddComplete()方法中獲得了調用BeginInvoke()時最后一個參數傳遞的值,字符串“Any data you want to pass”;
3、執行回調方法的線程並非客戶端線程Main Thread,而是來自線程池中的線程Pool Thread。另外如前面所說,在調用EndInvoke()時有可能會拋出異常,所以在應該將它放到try/catch 塊中,這里就不再示范了。
我們詳細地討論了C#中的委托和事件,包括什么是委托、為什么要使用委托、事件的由來、.NET Framework 中的委托和事件、委托中方法異常和超時的處理、委托與異步編程、委托和事件對Observer 設計模式的意義。擁有了本章的知識,相信你以后遇到委托和事件時,將不會再有所畏懼。