程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> VC >> 關於VC++ >> 事件編程(一)

事件編程(一)

編輯:關於VC++

在微軟 .NET 框架中可以定義托管類事件並用委托和 += 操作符處理這些事件。這種機制似乎很有用,那麼在本機 C++ 中有沒有辦法做同樣的事情?

確實如此!Visual C++ .NET 具備所謂統一事件模型(Unified Event Model),它可以像托管類一樣實現本機事件(用 __event 關鍵字),但是由於本機事件存在一些不明顯的技術問題,而微軟的老大不打算解決這些問題,所以他們要我正式奉勸你不要使用它們。那麼這是不是就是說 C++ 程序員與事件無緣了呢?當然不是!可以通過別的方法實現。本文我將向你展示如何輕松實現自己漂亮的事件系統。
  但是在動手之前,讓我先大體上介紹一下事件和事件編程。它是個重要的主題,當今對事件沒有堅實的理解,你是無法編寫程序的——什麼是事件以及什麼時候使用事件。
  成功的編程完全在於對復雜性的掌控。很久以前,函數被稱為“子程序”(我知道,我這樣說證明我已經老了!)管理復雜性的主要方式之一是自頂向下的編程模式。高層實現類似“宇宙模型”,然後將它劃分為更小的任務如:“銀河系模型”以及“太陽系模型”等等,直到任務被劃分為可以用單個函數實現為止。目前自頂向下的編程模型仍被用於過程化的任務實現當中,但它不適用於發生順序不確定的實時事件響應系統。經典的例子便是 GUI,程序必須響應用戶的某些行為,比如按鍵或是鼠標移動。實際上,事件編程很到程度上源於圖形用戶界面的出現。
  在自頂向下的模型中,在頂部的高級部分對低級的實現各種不同任務的函數——如 DoThis,DoThat 進行食物鏈式的調用。但不久以後,低層部分需要回調(talk back),在 Windows 中,可以調用 Rectangle 或 Ellipse 繪制一個矩形或橢圓,但最終 Windows 需要調用你的應用程序來畫窗口。但應用程序都還不存在,它仍然處於被調用度狀態!那麼 Windows 如何知道要調用哪個函數呢?這就是事件用處之所在。


Figure 1 自頂向下和自底向上

在每個 Windows 程序的核心——不論是直接用 C 語言編寫的還是使用 MFC 或 .NET 框架類編寫——都是一個處理消息的窗口過程,這些消息如:WM_PAINT, WM_SETFOCUS 和 WM_ACTIVATE。你(MFC 或 .NET)實現窗口過程並將它傳遞給 Windows。到了該畫窗口,改變輸入焦點以及激活窗口的時候,Windows 用相應的消息代碼調用你的過程。這個消息就是事件。窗口過程就是事件處理器。
  如果過程化編程是自頂向下的,事件編程是自底向上。在典型的軟件系統中,函數的調用流是從較高級部分到低級部分進行的;而事件是以相反的方向過濾的,如 Figure 1 所示。當然,在現實的開發中層次關系並不總是這麼清晰。許多軟件系統看起來更像 Figure 2 所示的情況:


Figure 2 混合模型

那麼到底什麼叫事件?其實,事件就是回調。而不是在編譯時就已知名字的函數調用,組件調用在運行時調用你提供的函數。在 Windows 中,它是一個窗口過程。在 .NET 框架中,它叫做委托。不管術語怎麼叫,事件提供了一種軟件組件調用函數的方式,這種調用方式直到運行時才知道要調用什麼函數。回調被稱為事件處理器。發生或觸發一個事件意味調用這個事件處理器。為此,事件接收部分首先得給事件源提供一個事件處理器的指針,這個過程叫注冊。

通常在以下幾種場合下我們要使用事件:

通知客戶機實際的事件:用戶按下某個按鍵;午夜時鐘敲響;風扇停止工作造成 CPU 燒毀;

當拷貝文件或搜索巨型數據庫時,報告耗時操作的過程,組件可以周期性地觸發某個事件以報告已拷貝了多少文件或已搜索了多少記錄;

如果你使用 IWebBrowser2 在自己的應用程序中宿主 IE,報告所發生的重要的或引起注意的事件,浏覽器會在導航到某個新頁面之前或之後通知你,或者在創建一個新窗口時通知你。

調用應用程序提供的算法:C 運行時庫函數 qsort 排序對象數組,但你必須提供比較函數。借助許多 STL 容器也能實現同樣的訣竅.大多數程序員不會調用 qsort 回調某個事件,但你沒有理由不考慮那種方式。它是“時間比較”事件。

一些讀者問:異常和事件之間有什麼差別?主要差別是:異常表示不應該發生的意外情況。例如,你的程序運行耗盡內存,或者遇到被零除。這些都是你並不希望發生的異常情況,並且一旦出現這些情況,你的程序必須要做出相應的處理。另一方面,事件則是每天常規操作的部分並且完全是預期的。用戶移動鼠標或按下某個鍵。浏覽器導航到一個新頁面。從控制流的角度看,事件是一次函數調用,而異常則是堆棧的突然跳躍,用展開的語義銷毀丟失的對象。
  有關事件常見的概念誤解是認為它們是異步的。雖然事件常常被用於處理用戶輸入和其它異步發生的行為 ,但事件本身是以同步方式發生的。觸發一個事件與調用該事件處理器是同一件事情。用偽碼表示就像如下的代碼段:

// 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 所示,在我的 TraceWin 程序(參見 2004 年三月的專欄)中顯示的范例輸出。我寫的 CTracePrimeEvents 展示了多個客戶機如何注冊相同的事件。


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 處理器,並傳遞任何需要的參數。有沒有什麼方法實現這個一般過程,比如用宏或者模板來封裝這種繁瑣而固定的樣板代碼,將自己從重復性勞動中解放出來呢?實際上是有的。下個月的專欄文章我們將討論這個問題。記住在同一時間,同一頻道,咱們再見——順祝編程愉快!

本文配套源碼

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved