2009/9/22

[入門] ASP.NET 事件與委派詳論

在 .Net Framework 的基礎領域中, 事件處理模型一直是令人頭痛的一環。倒不是因為它真的有什麼難度, 而是因為 .Net Framework 稍嫌麻煩的處理方式, 以及它的一些難懂的特殊用語, 有時候還真的會讓人搞得眼花潦亂, 甚至退避三舍。

在這裡, 我要試圖使用最簡單、最白話的方式把 .Net Framework 對於事件與委派的設計原理重新描述一遍。我相信如果你很仔細的看過一遍, 就再也不會對這個主題心存疑惑了。

事件 (Events)

當你做了什麼事情 (例如按下某個鍵、按下滑鼠) 或者發生了什麼事情 (例如每隔一百毫秒、或程式發生錯誤) 的時候, 如果你期待能夠做些什麼事情來針對上述狀況進行處理, 那麼這就叫做一個事件。我們通常會說「滑鼠按下左鍵」、「使用者從下拉式選單中選取了某個項目」等等, 這就是事件。

你可以宣告一個事件, 其語法和宣告其它物件時不太一樣:

VB -

Public Event ErrorFound(ErrorCode As Integer)

在 C# 中事件無法單獨宣告, 而必須和委派一起宣告; 所以我們等到後面再來看看範例。

在上面範例中, 我們宣告了一個稱為 ErrorFound 的事件。如果你還無法理解這是什麼意思, 那麼你不妨想像一下, 當你在程式中按下滑鼠左鍵時, 系統不是會發出一個 Click 事件嗎? 在這裡, 我們宣告了一個 ErrorFound 事件, 意思是當有錯誤發生時, 會有一個 ErrorFound 事件被激發。

不過, 如果你光是在程式中宣告了一個事件, 這個事件在任何時候都還是不會被激發, 因為還有一些配套的措施尚未完成。我們繼續看下去。

事件處理函式 (Events Handler)

當事件發生時, 你要使用什麼方式去回應? 例如, 如果使用者按下了 F1 功能鍵, 你是不是會寫程式來因應這種狀況? 例如開啟一個視窗, 然後把說明畫面開出來顯示。又例如, 如果程式突然連不到資料庫, 你又應該採取什麼緊急應變描施 (比方說改連另一個備份資料庫)? 像這種用來因應事件的程式, 就叫做事件處理函式。

事件處理函式就是一個普通的方法 (Method), 可以是 Sub 或是 Function。隨便任何一個方法 (Method) 都可以被當作事件處理函式 (當然, 傳入的參數必須正確), 所以在這裡我就不另外作範例了。

委派 (Delegate)

我個人認為就是「委派」這個翻譯令人覺得疑惑。「委派」從字面上看起來就像個動詞, 但實際上在 .Net 事件處理上, 這個字通常是以名詞的方式被使用。雖然嚴格的講, 「委派」這個字並沒有翻錯, 但是我倒是希望能額外賦予它更豐富的含意, 例如「委任」、「委任方法」、「委任函式」、「委託」、「代理人」、「代理函式」... 等等。或許我把它以更多的詞彙來描述之後, 你能夠更明白它的內在本質。

如果以最白話的方式來講, 那麼你可以把這個「委派」想像是事件與事件處理函式中間的一個「跳板」。怎麼說呢? 當一個事件發生時, 你必須知道, 事件處理函式不見得永遠是那一個。即使是同一個事件 (例如使用者按了滑鼠鍵), 你可能視情況把事件交給 A() 處理, 也可能交給 B() 處理...。那麼到底是交給 A() 還是 B() 還是 C()... 你可以讓「委派」(Delegate) 來決定 (經由這個解釋, 我相信你已經更明白為什麼它被稱之為「委派」)。

範例程式  (VB)

以下我們就來看看完整的範例程式:

(第一種做法) -

Public Delegate Sub myDelegate(ByVal Code As Integer) ' 宣告委派
Dim SomeDelegate As New myDelegate(AddressOf ErrorHandler) ' 建立委派的實體 (Instance)
Public Sub ErrorHandler(ByVal Code As Integer) ' 建立事件處理函式
    ' 在這裡處理事件的因應方法
End Sub
Sub Page_Load(...) Handles Me.Load
SomeDelegate(1) ' 當有錯誤發生時直接呼叫委派實體
End Sub

(第二種做法) -

Public Event ErrorFound(ErrorCode As Integer) ' 宣告事件
Public Sub ErrorHandler(ByVal Code As Integer)
    ' 在這裡處理事件的因應方法
End Sub
Sub Page_Load(...) Handles Me.Load
    AddHandler Me.ErrorFound, AddressOf ErrorHandler ' 將事件和事件處理函式之間建立關聯
    RaiseEvent ErrorFound(1) ' 當有錯誤發生時發動事件
End Sub

以上兩個程式可以達成完全相同的結果, 只是第一個做法使用委派而第二個做法不使用委派。

在第一個範例程式中, 我首先是使用了 Delegate 宣告和實體化的動作來建立委派物件, 並寫好事件處理函式:

Public Delegate Sub myDelegate(ByVal Code As Integer) 
Dim SomeDelegate As New myDelegate(AddressOf ErrorHandler)

宣告 Delegate 類型的這種語法確實是 .Net 裡面少見的。你不但給予 Delegate 類型的名稱 (myDelegate), 還得同時指出事件處理函式的種類 (Sub 或 Function), 以及事件處理函式所要使用的參數 (ByVal Code As Integer) 或參數陣列。而在實體化這個 Delegate 類型的時候, 其參數則變成要給予事件處理函數的位址 (AddressOf ErrorHandler)。

幸好, 我們不必去深究為什麼 Delegate 需要使用這種語法 (其實也沒什麼道理可言, 它就是這麼設計; 下面會再解釋), 你只要記得它的語法如此, 知道怎麼用就可以了。

接著, 我們就可以直接呼叫 Delegate 實體 (instance) 以發動委派 (將它當作一個方法來使用, 雖然實際上它只是個指向事件處理函式的指標而已) 了:

SomeDelegate(1)

當然, 你必須選擇在最適當的時機去下上面這道指令 (在我們的情境中, 是發生錯誤的時候才發動), 然後事件處理函式 (ErrorHandler) 就會被呼叫。

在第二個範例程式中, 我不使用委派, 而是使用以下語法來宣告一個事件:

Public Event ErrorFound(ErrorCode As Integer)

在事件 (ErrorFound) 的宣告中, 必須指定要傳入對應的事件處理函式的參數或參數陣列。

接著, 我們使用 AddHandler 指令建立起事件和事件處理函式之間的關係, 然後再使用 RaiseEvent 指令來發動事件:

AddHandler Me.ErrorFound, AddressOf ErrorHandler
RaiseEvent ErrorFound(1)

我們使用 AddHandler 指令把事件 (ErrorFound) 和事件處理函式 (ErrorHandler) 關聯在一起, 然後我們在使用 RaiseEvent 來發動事件時, 事件處理函式就會自動被呼叫。

以上兩種做法的結果都是一樣的。當你看到這裡, 你或許會認為, 既然我們根本不需要用到委派也可以宣告跟發動事件, 那麼我們為什麼需要委派? 或許我們根本連委派是什麼都不需要知道。事實上, 只有 VB 可以這麼做。而且在 VB 中也並不是不必用到委派, 而是 VB 在背地裡幫你把委派的宣告和實體化的動作做掉了 (就是當我們在第二個範例中使用 AddHandler 指令的時候)。

所以, 是的, 恭喜 VB 的愛用者, 你可以在不需要理會委派的情況下, 依然很方便的加入跟處理事件 (採用以上第二種做法)。當然, 你仍然可以選擇採用委派來處理事件 (亦即採用以上第一種做法)。

不過, 很不幸的, 如果你不透過委派, 你就沒辦法使用到委派所提供的一些功能。我在下面「Delegate 和 Event 到底是什麼東西?」一節裡會再做說明。

範例程式  (C#)

(第一種做法) -

public delegate void myDelegate(int Code);  // 宣告委派
public void ErrorHandler(int Code) // 建立事件處理函式
{
    // 在這裡處理事件的因應方法
}
protected void Page_Load(object sender, EventArgs e)
{
    myDelegate someDelegate = new myDelegate(ErrorHandler); // 建立委派的實體 (Instance)
    someDelegate(5); // 當有錯誤發生時直接呼叫委派實體
}

(第二種做法) -

public delegate void myDelegate(int Code);
public event myDelegate ErrorFound; // 宣告事件
public void ErrorHandler(int Code);
{
    // 在這裡處理事件的因應方法
}
protected void Page_Load(object sender, EventArgs e)
{
ErrorFound = new myDelegate(ErrorHandler); // 透過委派在事件與事件處理函式間建立關聯
ErrorFound(1); // 當有錯誤發生時發動事件
}

C# 在事件處理方面和 VB 略有不同。在以上兩個範例中, 結果也是完全相同的。不過, 和 VB 不一樣, 在 C# 中你沒有辦法完全棄委派於不顧, 因為 C# 並不會背地裡幫你把委派偷偷做好, 所以你仍然要自己宣告。

在第一個範例中, 我們宣告並建立委派實體:

public delegate void myDelegate(int Code);
public void ErrorHandler(int Code);

和 VB 不同, 在 C# 中我們使用 void 來表示事件處理函式是一個不帶回傳值的方法 (即 VB 的 Sub)。如果它有帶回傳值 (即 VB 的 Function), 那麼你得宣告它的回傳型別 (例如 bool 或 int 等等)。

之後, 我們就可以對委派進行呼叫以發動它。這與 VB 的第一個範例基本上並沒有不同, 只是在 C# 中你不能在方法外面進行委派的實體化動作, 所以在範例中我們把將 Delegate 實體化的動作寫在 Page_Load() 事件函式裡面。當然, 這個動作並不一定非得寫在 Page_Load() 不可, 你只要確定你在發動事件之前確實有做過實體化的動作, 而且可以呼叫得到它就行了。

在第一個範例中, 除了委派的實體化和 VB 的第一個範例略有不同之外, 其它地方大致上是一樣的。然而, 在第二個範例中, C# 和 VB 就有一個地方是完全不一樣的了, 請特別留意。

在第二種方法中, 我們可以宣告事件並在必要時發動事件, 然後事件處理函式就能被自動呼叫。在這裡, 我們可以看出 C# 和 VB 有很大的差異, 主要在於事件與委派之間的依存關係, 還有, C# 在事件的宣告語法也不一樣, 請注意:

public event myDelegate ErrorFound;

雖然我們在這裡不直接以執行委派實體的方式發動事件, 但是你仍然必須宣告並建立一個委派實體之後, 再把事件指派給它, 然後才能發動一個事件:

ErrorFound = new myDelegate(ErrorHandler);
ErrorFound(1);

看到這裡, 我希望你並沒有被搞混。不過如果你到現在還是不清楚怎麼寫這個程式的話, 那麼我建議你就直接採用第二種做法吧! 如果你寫 VB, 就採用 VB 的第二種做法; 如果你寫 C#, 就採用 C# 的第二種做法。總共就幾行程式罷了, 照著寫就行了。

Delegate 和 Event 到底是什麼東西?

在上面的範例中, 我們遇到了 Delegate (VB) 或者 delegate (C#) 這個關鍵字, 我們必須使用它來建立自訂的委派實體。但是, 相對於 C#/VB 的其它語法, 這個 delegate 的使用方式實在難免讓人產生丈二金剛摸不著頭腦的感覺。到底這 Delegate 是什麼東西?

講白了, delegate 它還真不是什麼東西, 它是單純的一種「語法」, 只是一個關鍵字, 它能讓編譯器把跟在它後面的東西解釋成一個繼承 MulticastDelegate 的類別。以下面這行程式為例:

public delegate void myDelegate(int Code);

當我們在程式中如此宣告之後, myDelegate 會變成一個繼承 MulticastDelegate 的類別。然後, 這個 myDelegate 的用法就和一般的類別沒什麼兩樣了, 因為它的確是一個類別。

如果在 Visual Studio 的類別檢視中搜尋上述範例中的 myDelegate, 你會發現它的基底類別就是 MulticastDelegate, 而 MulticastDelegate 的基底類別是 Delegate; 如下圖所示:

myDelegate 類別檢視

千萬別把這裡的 Delegate 類別和宣告委派的那個 Delegate/delegate 搞混了! 這裡的 Delegate 是 MulticastDelegate 的基底類別, 也是一個抽象類別。我們可以說, 雖然它也叫做 Delegate, 可是它跟上面範例中用來宣告委派的那個 Delegate/delegate 關鍵字可是一點關係也沒有。

至於 MulticastDelegate, 它事實上是一個套用了設計樣式的類別, 這使得我們可以把該類別的實體一個一個疊加上去。例如, 假設我們以相同的方法產生了 del1 和 del2 這兩個委派 (假設採用 C# 範例的第一種做法), 讓它們對應不同的 Event Handler, 那麼我們可以使用以下這道指令:

del1 += del2;

如此, 每當 del1 被呼叫, 它就會順便帶出 del2 來執行。換句話說, 我們可以為一個事件指定多個委派, 當事件發生時, 所有透過上述方式註冊的事件處理函式就會通通被依次呼叫。不過, 如果你使用上述方法去疊加委派, 那麼, 如果有參數的話, 這些委派會通通套用相同的參數。所以, 如果你想為每個委派賦予不同參數的話, 就不應該使用這個方法, 而應該一個一個呼叫。

由於如上所述委派可以疊加的這種特性, 我想, 你應該不難理解 MulticastDelegate 的命名由來了吧?

以上我已經把 Delegate 解釋了一遍。那麼, Event 又是什麼?

由於程式中的 ErrorFound 並不是類別, 我們在類別檢視器裡查不到什麼有用的資訊。不過, 我們倒是可以從方案總管中搜尋得到 (或者在監看式裡看到), 這個 ErrorFound 事實上是一個型別為 myDelegate 的 instance, 和 C# 範例裡的第一種做法中的 someDelegate 一樣。正規地講, 我們只會說 ErrorFound 就是一個「Event 物件」。和其它物件不同 (包括 someDelegate), 在 Visual Studio 中, Event 物件總是以一個閃電形狀的小圖示標示。然而, 和 Delegate 一樣, Event (VB) 和 event (C#) 雖然看起來它使用於「宣告」, 它卻並不是什麼類別; 它純粹只是個關鍵字, 用來告訴編譯程式這個字後面跟著一個將被用做「Event 物件」的一個 myDelete 的 instance 而已。

那麼, 若採用 C# 範例中的第二種做法, 委派可以疊加到事件上嗎? 可以! 方法也很簡單:

ErrorFound = new myDelegate(ErrorHandler1);
ErrorFound += new myDelegate(ErrorHandler2);

說穿了, 在上述程式中, ErrorFound 是個 event, 它也是使用 myDelegate 類別實作的 instance, 所以這個 ErrorFound 物件和 C# 第一種做法中的 someDelegate 難道不是同一種東西嗎? 差別只在於 ErrFound 被稱為 event 而已, 而 someDelegate 被稱為 delegate 而已。

經由這番說明, 我想你應該可以理解為什麼 Event 的宣告方式要那麼寫, 以及為什麼它可以使用 new myDelegate 來建立, 還有為什麼 VB 可以不必宣告 Event 物件 (VB 的第一種做法) 了吧? 同時, 如果你再回頭去看一下上面 VB 和 C# 範例中的第一種做法和第二種做法, 其實二者根本就是同一回事。

此外, 或許你對於 Delegate 可以疊加的做法感到好奇。其實, 你可以在 Visual Studio 中在上面程式中的 del1 += del2 這一行中間的 "+=" 符號上按下 F12, 你會被導到 Delegate 類別的靜態方法 Combine() 上面。同理, "-=" 則會被導到 Delegate.Remove() 上面。換句話說, 編譯程式會在背地裡幫你寫一些程式碼, 為 myDelegate 加上 "+" (add) 和 "–" (remove) 兩個運算子, 而實際上就是去呼叫 Delegate.Combine() 和 Delegate.Remove() 方法, 中間並沒有太大的學問。

使用時機

我想, 除非你只是為了應付考試, 否則我相信如果你真的對事件與委派有興趣, 一定是因為你希望能把它應用在什麼地方。坦白的說, 在 .Net 領域中, 有太多東西恐怕是你一輩子用不到的。雖然說「一招半式闖江湖」似乎是用來形容學藝不精的的人, 但由於 .Net 太龐大了, 相較之下只懂得一招半式, 卻能成功勝任手頭上工作的人卻比比皆是。

然而, 就好像物件導向的程式設計法則雖然是 .Net 的核心, 但是你也可以完全不理會什麼物件導向的原理, 仍然可以把程式寫出來一樣; 事件和委派也是 .Net 領域中不可不熟悉的一部份, 你雖然可以對它完全不了解, 或許也還是可以勝任目前手頭上的工作, 但是你還是永遠會有面臨這個主題的時候, 那麼既然你已經看到這篇文章, 何不乾脆趁這個機會把它搞清楚呢?

老實說, 如果你從來不寫使用者控制項 (User Controls) 或自訂控制項 (Custom Controls) 的話, 你恐怕還真的完全可以不踫事件與委派。因為不管是網頁或是視窗應用程式, 你只需要藉由既有控制項的事件處理函式 (例如 Button.Click 處理函式) 就足以應付大多數情況了。但是只要你開始寫使用者控制項或自訂控制項, 我相信你很快就會遇到如何處理自訂事件的問題。

在這裡的情境是, 假設你寫了一個 Web 使用者控制項 ucSelectCountry.ascx 供客戶選擇國家, 然後你在網頁 A.aspx 中把這個使用者控制項加入頁面。接著, 如果使用者從使用者控制項的選單中選擇了某個國家 (例如台灣), 然後你必須立刻針對這個國家, 在網頁進行某些動作 (例如把人口、國民年所得等資訊顯示出來, 或者變更該國家的對應國碼 (例如 +886) 等等)。

範例

在上述的狀況中, 你可以在程式中加入如下的程式碼:

VB -

Public Event SelectedIndexChanged(ByVal sender As Object, ByVal e As System.EventArgs)
Protected Sub ddl_SelectedIndexChanged(ByVal sender As Object, ByVal e As System.EventArgs) Handles ddl.SelectedIndexChanged
    RaiseEvent SelectedIndexChanged(Me, e)
End Sub

C# -

public event EventHandler selectedIndexChanged;
protected void ddl_SelectedIndexChanged(...) {
   if (selectedIndexChanged != null) 
      selectedIndexChanged(sender, e);
}

或者再更簡單一點:

ddl.SelectedIndexChanged += new EventHandler(selectedIndexChanged); // 必須選擇在某個方法內宣告 (例如 Page_Load)

上面這個 C# 簡捷語法, 很抱歉, 在 VB 裡面是沒有的。VB 使用者只能使用 AddHandler。

還有, 若使用 ... += new EventHandler ... 方法來建立事件處理函式, 同樣的老問題仍然存在, 那就是你必須把這段指令找個適當的網頁事件並且放在裡面 (例如 Page_Load)。我想寫過視窗應用程式的朋友對這種寫法應該不會陌生才對, 因為在視窗應用程式中, 事件都是由系統使用這種方法幫你建立起來的。

在這裡所使用的 EventHandler 本身是一個系統所提供的預設委派型別之一。在這種情況下, 你可以使用這個委派物件來取代自訂的委派 (換句話說, 你不必自已宣告及實體化另一個委派)。誠實地說, 如果偷懶一點, C# 程式設計師未來大可以把前面一大堆囉哩囉嗦的章節忘掉, 然後光靠一個 += new EventHandler 就可以應付絕大多數需要自訂事件的場合, 真的達到一招半式闖天下的境界。但話說回來, 如果你在 EventHandler 這個字上面按下 F12, 你就會看到這個委派的參數型式是固定的。你一定只能採用 (object sender, EventArgs e) 這樣的參數型式。如果你的自訂事件剛好不適合這種參數型式, 或者你需要更大的靈活度, 那麼這一招就不能闖天下了。

回到主題。當你如上範例這麼做之後, 你在網頁中的那個使用者控制項就多了一個稱為 OnselectedIndexChanged 可以使用。換句話說, 你可以在 A.aspx.vb 或 A.aspx.cs 程式中撰寫對應的事件處理函式:

.aspx -

<%@ Register Src="../UserControls/ucSelectCountry.ascx" TagName="ucSelectCountry" TagPrefix="uc1" %>
...
<uc1:ucSelectCountry ID="UcSelectCountry1" runat="server" OnselectedIndexChanged="UcSelectCountry1_SelectedIndexChanged" />

VB -

Protected Sub UcSelectCountry1_SelectedIndexChanged(ByVal sender As Object, ByVal e As System.EventArgs) Handles UcSelectCountry1.SelectedIndexChanged
    ' 在這裡撰寫程式
End Sub

C# -

protected void UcSelectCountry1_SelectedIndexChanged(object sender, EventArgs e)
{
    // 在這裡撰寫程式
}

如果你不在這個使用者控制項建立一個自訂事件, 那麼我不曉得還有什麼更簡單的做法可以來處理類似的情況。當然, 你或許會認為你可以在 .ascx 程式中撰寫 DropDownList 的 SelectedIndexChanged 事件處理函式而不在 .aspx 程式裡撰寫 (若真的如此, 我也服了你)。但是 Web 使用者控制項本來就是寫來給各個不同網頁使用的, 所以如果你建立了事件, 那麼在 A.aspx 可以使用這個事件, 在 B.aspx 也可以使用這個事件... 依此類推。

我希望我舉這個 Web 使用者控制項的例子, 有助於讓你明白事件與委派的使用時機。

沒有留言:

張貼留言