C++ At Work 专栏...
事件编程(一)
原著:Paul DiLascia
翻译:NorthTibet
下载源代码:CAtWork00602.exe (175KB)
原文出处:Event Programming
在微软 .NET 框架中可以定义托管类事件并用委托和 += 操作符处理这些事件。这种机制似乎很有用,那么在本机 C++
中有没有办法做同样的事情?
Several Readers
确实如此!Visual C++ .NET 具备所谓统一事件模型(UnifiedEvent 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 函数做法类似,它没有参数,如
所示。你也许觉得 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
处理器,并传递任何需要的参数。有没有什么方法实现这个一般过程,比如用宏或者模板来封装这种繁琐而固定的样板代码,将自己从重复性劳动中解放出来呢?实际上是有的。下个月的专栏文章我们将讨论这个问题。记住在同一时间,同一频道,咱们再见——顺祝编程愉快!
您的提问和评论可发送到 Paul 的信箱:cppqa@microsoft.com.
作者简介
Paul DiLascia 是一名自由作家,软件咨询顾问以及大型 Web/UI 的设计师。他是《Writing Reusable
Windows Code in C++》书(Addison-Wesley, 1992)的作者。业余时间他开发 PixeLib,这是一个 MFC
类库,从 Paul 的网站 http://www.dilascia.com 可以获得这个类库。
.
本文出自 MSDN Magazine 的
February 2006 期刊,可通过当地报摊获得,或者最好是
本文由 VCKBASE MTT 翻译