Figure 1 自頂向下和自底向上
在每個 Windows 程序的核心——不論是直接用 C 語言編寫的還是使用 MFC 或 .NET 框架類編寫——都是一個處理消息的窗口過程,這些消息如:WM_PAINT, WM_SETFOCUS 和 WM_ACTIVATE。你(MFC 或 .NET)實現窗口過程并將它傳遞給 Windows。到了該畫窗口,改變輸入焦點以及激活窗口的時候,Windows 用相應的消息代碼調用你的過程。這個消息就是事件。窗口過程就是事件處理器。
如果過程化編程是自頂向下的,事件編程是自底向上。在典型的軟件系統中,函數的調用流是從較高級部分到低級部分進行的;而事件是以相反的方向過濾的,如 Figure 1 所示。當然,在現實的開發中層次關系并不總是這么清晰。許多軟件系統看起來更像 Figure 2 所示的情況:
Figure 2 混合模型
那么到底什么叫事件?其實,事件就是回調。而不是在編譯時就已知名字的函數調用,組件調用在運行時調用你提供的函數。在 Windows 中,它是一個窗口過程。在 .NET 框架中,它叫做委托。不管術語怎么叫,事件提供了一種軟件組件調用函數的方式,這種調用方式直到運行時才知道要調用什么函數?;卣{被稱為事件處理器。發生或觸發一個事件意味調用這個事件處理器。為此,事件接收部分首先得給事件源提供一個事件處理器的指針,這個過程叫注冊。
通常在以下幾種場合下我們要使用事件:
一些讀者問:異常和事件之間有什么差別?主要差別是:異常表示不應該發生的意外情況。例如,你的程序運行耗盡內存,或者遇到被零除。這些都是你并不希望發生的異常情況,并且一旦出現這些情況,你的程序必須要做出相應的處理。另一方面,事件則是每天常規操作的部分并且完全是預期的。用戶移動鼠標或按下某個鍵。瀏覽器導航到一個新頁面。從控制流的角度看,事件是一次函數調用,而異常則是堆棧的突然跳躍,用展開的語義銷毀丟失的對象。
有關事件常見的概念誤解是認為它們是異步的。雖然事件常常被用于處理用戶輸入和其它異步發生的行為 ,但事件本身是以同步方式發生的。觸發一個事件與調用該事件處理器是同一件事情。用偽碼表示就像如下的代碼段:
// raise Foo event for (/* each registered object */) { obj->FooHandler(/* args */); }
控制立即傳到事件處理器,并且不會返回,除非處理完成。某些系統提供某種以異步觸發事件的方式,例如,在 Windows 中,你可以用 PostMessage 代替 SendMessage??刂茣?PostMessage 立即返回,該消息是后來才處理的。但是 .NET 框架中的事件以及我在這里討論的事件是在觸發時被立即處理的。當然,你總是可以觸發來自運行在單獨的線程中的消息代碼事件,或者使用異步委托調用在線程池中執行每個事件處理器,在這種情況下,相對于主線程來說,事件是異步發生的。
Windows 處理事件的方式完全是通過窗口過程以及一成不變的 WPARAM/LPARAM 參數,按照現代編程標準來說,簡陋而粗糙。即便是在今天,每個 Windows 程序仍然在使用這種機制。有些程序員為了傳遞事件,甚至創建 不可見窗口。窗口過程并不是真正意義上的事件機制,因為在 Winodows 中每個窗口只允許有一個窗口過程,雖然也可以鏈接多個過程,比如每個過程都調用其前面的過程,也就是眾所周知的子類化過程。在真正的事件系統中,相同的事件可以不分等級地注冊多個接收者。
在 .NET 框架中,事件是很成熟的機制。任何對象都可以定義事件,并且多個對象可以偵聽這些事件。.NET 中的事件使用委托來實現,委托是 .NET 中的術語,它實際上就是以前說所的回調。最重要的是,委托是類型安全的。不再使用 void* 或者 WPARAM/LPARAM。
為了用托管擴展定義一個事件,你得用 __event 關鍵字。例如,Windows::Forms 中的 Button 類有一個 Click 事件:
// in Button class public: __event EventHandler* Click;
這里 EventHandler 是某個函數的委托,該函數帶有參數:Object (也就是 sender) 和 EventArgs:
public __delegate void EventHandler( Object* sender, EventArgs* e );
為了接收事件,你必須用正確的簽名實現處理器成員函數并創建一個委托來包裝該函數,然后調用事件的 += 操作符注冊你的處理器/委托。對于上面的 Click 事件,代碼應該像這樣:
// event handler void CMyForm::OnAbort(Object* sender, EventArgs *e) { ... } // register my handler m_abortButton->Click += new EventHandler(this, OnAbort);
注意該處理器函數必須具備由委托定義的簽名。這是托管擴展的基本原則。但是你的問題涉及的不是托管事件,你問的是本機事件——如何實現本機 C++ 事件?C++ 本身沒有內建的事件機制,那么該怎么實現呢?你可以用 typedef 來定義一個回調并讓客戶機來提供這個回調,這種做法有些類似 qsort——但那樣太老土了。更不用說處理多個事件時的繁瑣。相對于靜態外部函數來說,用成員函數作為事件處理器是最丑陋的做法。
一種比較好的方法是創建一個定義事件的接口。那是 COM 的做法。但你不需要用 C++ 編寫沉重的 COM 代碼;你可以用一個簡單的類。我寫了一個類來做示范:CPrimeCalculator;這個類的功能是查找素數。代碼如 Figure 3 所示。CPrimeCalculator::FindPrimes(n) 查找開始的 n 個素數。其工作原理是這樣的,CPrimeCalculator 觸發兩種事件:Progress 事件和 Done 事件。這些事件都定義在 IPrimeEvents 接口中。IPrimeEvents 接口不是 .NET 和 COM 意義上的接口;它是一個純粹的 C++ 抽象基類,它為每個事件處理器定義
簽名(參數和返回類型)。處理 CPrimeCalculator 的客戶機必須實現 IPrimeEvents,然后調用 CPrimeCalculator::Register 來注冊它們的惡接口。CPrimeCalculator 將對象/接口添加到其內部列表(list)中。由于它會對每個整數進行素數檢查,CPrimeCalculator 則周期性地報告到目前為止找到了多少個素數:
// in CPrimeCalculator::FindPrimes for (UINT p=2; p<max; p++) { // figure out if p is prime if (/* every now and then */) NotifyProgress(GetNumberOfPrimes()); ... } NotifyDone();
CPrimeCalculator 調用內部輔助函數 NotifyProgress 和 NotifyDone 來觸發事件。這些函數遍歷客戶機對象列表,為每個客戶機調用相應的事件處理器。代碼如下:
void CPrimeCalculator::NotifyProgress(UINT nFound) { list<IPrimeEvents*>::iterator it; for (it=m_clients.begin(); it!=m_clients.end(); it++) { (*it)->OnProgress(nFound); } }
如果你對 STL 不熟悉,去看看有關迭代器反引用操作符的內容,它返回當前指向的對象,上面代碼段中,for 循環里的代碼等同于:
IPrimeEvents* obj = *it; obj->OnProgress(nFound);
觸發 Done 事件的 NotifyDone 函數做法類似,它沒有參數,如 Figure 3 所示。你也許覺得 Done 事件是多余的,因為當 FindPrimes 返回控制時,客戶機已經知道 CPrimeCalculator 完成了工作。沒錯——但有一種情況除外,那就是多個客戶機注冊接收的事件,并且調用 CPrimeCalculator::FindPrimes 的對象可能不是同一個。Figure 4 是我的測試程序 PrimeCalc。該程序為素數事件實現了兩個不同的事件處理器。第一個處理器是主對話框本身,CMyDlg,它利用多繼承實現 IPrimeEvents。該對話框處理 OnProgress 和 OnDone,并在對話窗口顯示進度,完成后發出蜂鳴聲。其它的事件處理器,如 CTracePrimeEvents 也實現了 IPrimeEvents,這個實現顯示診斷(TRACE)流中的信息。如 Figure 6 所示。
Figure 5 運行中的 PrimeCalc
從使用 CPrimeCalculator 來編寫應用的程序員角度看,處理事件簡單而直白。從 IPrimeEvents 派生,實現處理器函數,然后調用 Register。從編寫觸發事件的類的程序員看來,這個過程有些冗長乏味。首先你得定義事件接口。這并沒有什么不好。但接著你得編寫 Register 和 Unregister 函數,每個 Foo 事件都得有一個相應的 NotifyFoo 函數。如果有 15 個事件的話,那就十分令人不爽了,尤其是每個 NotifyFoo 函數的模式都相同:
void CMyClass::NotifyFoo(/* args */) { list<IPrimeEvents*>::iterator it; for (it=m_clients.begin(); it!=m_clients.end(); it++) { (*it)->OnFoo(/* args */); } }
Figure 6 PrimeCalc 在 TraceWin 中的輸出
NotifyFoo 迭代客戶機列表,為每個注冊的客戶機調用相應的 OnFoo 處理器,并傳遞任何需要的參數。有沒有什么方法實現這個一般過程,比如用宏或者模板來封裝這種繁瑣而固定的樣板代碼,將自己從重復性勞動中解放出來呢?實際上是有的。下個月的專欄文章我們將討論這個問題。記住在同一時間,同一頻道,咱們再見——順祝編程愉快!