C++ At Work 专栏...
事件编程(二)
原著:Paul DiLascia
翻译:NorthTibet
下载源代码:CAtWork0603.exe (3,178KB)
原文出处:Event Programming, Part 2
在本文的第一部分(事件编程一),我回答了一个关于用 C++
实现本机事件的问题。讨论了一般意义上的事件并示范了如何用接口为你的类定义事件处理器,事件的处理必须在客户机实现。我的实现有一些缺陷,我承诺过最终要解决掉,本文就来完成这件事情。
在开始之前,先简单回顾一下前面写的那个程序,PrimeCalc。如 Figure 1 所示:
Figure 1 计算素数
程序中使用了一个计算素数的类 CPrimeCalculator,这个类发起两个事件:Progress 和 Done。当搜索到素数时,该类触发
Progress 事件以报告目前发现了多少素数。完成处理后触发 Done 事件。这两个事件都是由接口 IPrimeEvents 定义的:
class IPrimeEvents {
public:
virtual void OnProgress(UINT nPrimes) = 0;
virtual void OnDone() = 0;
};
客户机要想处理事件必须得从 IPrimeEvents 派生,实现事件处理函数,并调用 CPrimeCalculator::Register
来注册其接口。CPrimeCalculator::Register 会将客户机对象/接口添加到其内部列表。当触发了一个 Progress
事件时,CPrimeCalculator 便调用辅助函数 NotifyProgress:
void CPrimeCalculator::NotifyProgress(UINT nFound)
{
list::iterator it;
for (it=m_clients.begin(); it!=m_clients.end(); it++) {
(*it)-OnProgress(nFound);
}
}
NotifyProgress 遍历客户机列表,调用每个客户机的 OnProgress 处理函数。当某个程序员使用 CPrimeCalculator
时,编写事件处理代码很容易——只要从 IPrimeEvents 派生并实现处理器即可。但是在实现这种触发事件的 CPrimeCalculator
类机制时冗长乏味。你必须得为每个事件(如 Foo)实现诸如 NotifyFoo
这样的函数,即使处理模式一模一样。事件触发代码被划分在两个类中,事件接口 IPrimeEvents 和 事件源 CPrimeCalculator。如果你想将同样的事件接口用于不同的事件源那该怎么办?IPrimeEvents
是很通用,我可能将它改名为 IProgressEvents 并将它用于任何以整数形式报告处理进度的类并在完成处理时用 Done。但每个触发
Progess 事件的类必须重新实现触发事件的通知函数。理想情况下,所有事件代码都应该放在单个类中。
既然通知函数在本文中是一种实验模型,那么自然会问这样的问题:它们有没有某种通用的实现方法?我能将整个事件机制封装到单个的类、模板或宏,或者任何事件源能使用的其它什么类型中吗?答案是肯定中的肯定。我将示范如何创建一个使用宏和模板的事件系统,以便将事件处理的代码量降至最低限度。我们的旅程需要借助一些高境界的
C++ 操作,比如嵌套模板以及仿函数类(functor class)。
我将分几个步骤实现这个系统。目的是编写一个实现通知函数 NotifyProgress 以及 NotifyDone
的模板。每个函数都具备相似而又不完全一样的模型:
// NotifyFoo — raise Foo event
list<IPrimeEvents*>::iterator it;
for (it=m_clients.begin(); it!=m_clients.end(); it++) {
(*it)->OnFoo(/*args*/);
}
也就是说迭代客户机列表,并针对每个客户机调用 OnFoo,传递事件参数。如何把它写成一个模板呢?可以将接口 IPrimeEvents
参数化为一个类型 T,但如何参数化事件处理函数 OnFoo,程序员可能选择的任何名字和签名。
任何时候你参数化某个函数时,都应该考虑:仿函数,也叫做 functor。仿函数是 C++
语言中将函数转换为类的一种机制,它代替了给回调函数传递指针的做法,而是传递仿函数类的实例。在标准模板库 STL 中包含有丰富的 Functor,并实现了一些使用
functor 的算法,尤其是 for_each 算法,在本文中很有用:for_each(m_clients.begin(), m_clients.end(),
NotifyProgress(nFound));
for_each 算法从头到尾迭代容器元素,并对每个元素调用函数对象 NotifyProgress。这里说的“函数对象”到底是指的什么呢?不是一个函数,它是一个对象。这个类看起来像下面这个样子:
class NotifyProgress {
protected:
UINT m_nFound;
public:
NotifyProgress(UINT n) : nFound(n) { }
void operator()(IPrimeEvents* obj)
{
obj-OnProgress(nFound);
}
};
NotifyProgress 实现函数 operator()(IPrimeEvents*),它是 for_each
算法需要的东西。一般来讲,如果你具备一个类型为 T 对象集合,for_each 会需要一个实现 operator()(T) 的仿函数(functor)。它调用该集合中
T 对象的这个操作符。所以这里函数 operator 有一个 IPrimeEvents 指针参数并返回 void —— 因为客户机列表是一个 IPrimeEvents
指针列表。为了传递附加参数,构造函数将它们保存在数据成员里。NotifyProgress(nFound) 调用构造函数以创建一个用 m_nFound=nFound
初始化的堆栈实例。所以,任何触发 Foo 事件的 Foo 仿函数的一般模式是这样的:
class NotifyFoo {
protected:
ARG1 m_arg1; // whatever, as many as needed
public:
NotifyProgress(ARG1 a1, ...) : m_arg1(a1) { }
void operator()(IMyEvents* obj)
{
obj-OnFoo(m_arg1, ...);
}
};
构造函数将事件参数作为数据成员来保存,函数 operator 将它们传递到对象事件处理函数。对于所有仿函数来说,最终结果是——将函数 OnFoo 转换为类 NotifyFoo。这样做为什么会有用呢?因为我能编写一个模板。在我开始做之前,有一件事我必须得提一下。那就是你必须从一个叫 unary_function
的 STL 类派生你的仿函数类:
class NotifyProgress :
public unary_function
{
.
. // as before
.
};
也就是说,NotifyProgress 是一个一元函数,其函数 operator 带一个参数,IPrimeEvents 指针并返回
void。该一元函数使你的仿函数类“可适配”,使你能将它与 STL 适配器,如:not1、bind2nd
等等进行结合。但是即使你从来都没有打算使用适配器,就像我的事件处理例程,unary_function
仍然不失为一个好主意,因为它向这个世界宣告:“这是一个函数类。”它是一种将代码文档化的方式。有关适配器的详细讨论,参见 Effective STL:50
Specific Ways to Improve Your Use of the Standard Template Library
(Addison-Wesley, 2001) by Scott Meyers
STL 的高手们也许会问:为什么我不使用 mem_fun 适配器直接将 IPrimeEvents::OnProgress
转换为函数对象。因为 OnProgress 是虚拟函数,我不能适配一个虚拟函数。如果这样做,要触及到基类。如果你使用 Boost
库,可以用其捆绑适配器直接将 OnFoo
这样的虚拟事件处理器转换为仿函数,不用编写仿函数。如果你不明白我所讲的这些内容,不用害怕,不看这些内容好了。
当然,我还需要一个 Done 事件的 NotifyDone。由于 Done 没有参数,构造函数也没有:
class NotifyDone : public unary_function
{
public:
NotifyDone() { }
void operator()(IPrimeEvents* obj)
{
obj-OnDone();
}
};
现在我有了自己的仿函数类,我可以用 for_each
代替手工迭代客户机列表。可我把它们放在哪呢?仿函数属于与事件说明有关的范畴,所以我把它们放在 IPrimeEvents
接口中,用嵌套类的形式。代码如 Figure 2 所示。细心的读者会注意到我在两个地方还做了细小的恶修改。仿函数的命名没有用
NotifyProgress,而是叫做
Progress。稍后你会明白这样做使代码更易读;还有就是我没有把事件处理器都声明为纯虚拟函数,而是将它们定义为空实现。IPrimeEvents
只有两个事件,但对于一般的事件机制来说,如果程序员感兴趣的的处理并不多,但要让他们实现每一个事件处理器似乎不是很友好。所以这里每个处理器默认实现什么也不做。为了使基类抽象化,我声明了一个纯虚拟析构函数。当你想抽象化一个没有任何纯虚函数的基类时,这是一个标准的C++技巧。唯一的要做的是你必须定义一个析构函数。纯虚拟函数没有定义——除非它是析构函数。既然每一个派生类的析构都调用其基类的析构函数,那么基类需要一个实现,即便它是纯虚拟的:
inline IPrimeEvents::~IPrimeEvents() { }
有了我的仿函数定义,CPrimeCalculator 是这样触发 Progress 事件的:
// in CPrimeCalculator:
void NotifyProgress(UINT nFound)
{
for_each(m_clients.begin(), m_clients.end(),
IPrimeEvents::Progress(nFound));
}
到这里,我已经介绍了仿函数类 Progress 和 Done,同时,NotifyProgress 和 NotifyDone 都能用 STL
的 for_each 算法。下一步该做什么?记住,我的目的是完全摆脱 NotifyFoo
函数——或者说得更具体一点,就是把它们实现为模板,以便程序员在创建事件时不必为他们定义的每个事件编写千篇一律的函数。将 for 循环转化为
for_each 算法只是万里长征的第一步。
通过将虚拟成员函数 OnFoo 转换为 Foo 仿函数类型,从而为模板化创造条件。(仿函数在这里有点像 .NET
中的委托。)现在我的通知函数根据类型的不同而不同,替代了函数名,我可以将它们参数化。这样一来,我便可以将整个事件实现移出 CPrimeCalculator
,把它们放入新的模板类 CEventMgr 中,这是一个完全通用的类。如 Figure 3 所示。CEventMgr<I> 保存 I*
指针列表。它具备 Register 和 Unregister 方法以便添加元素和从其列表中删除元素,此外它还有一个模板成员函数
Raise 用于触发事件:
template
class CEventMgr
{
...
template
void Raise(F fn)
{
for_each(m_clients.begin(), m_clients.end(), fn);
}
};
很难相信,平时几乎碰不到的模板套模板的情况?在此处派上用场了。现在触发事件我们可以这样做:void NotifyProgress(UINT nFound)
{
m_eventmgr.Raise(IPrimeEvents::Progress(nFound));
}
没有 for 循环,甚至都没有 for_each,所有细节都被封装在 CEventMgr
之中,事件的触发使用一行代码。我甚至可以完全省略掉 NotifyProgress,每当想要触发事件时仅仅调用 CEventMgr::Raise
即可——然而,好的编码规范促使我宁愿将 Raise 封装在某个函数中,以防万一我要修改 CEventMgr 或将事件触发函数暴露给客户机。既然 NotifyProgress
是内联函数,就不会有幸能丢失。
如果模板使你伤脑筋,我就再讲清楚一些吧。CEventMgr 是一个参数化的模板类,其参数是事件接口 I。因此 CEventMgr<IPrimeEvents>
根据 IPrimeEvents 实例化一个事件管理器。它保存数据成员 m_clients,该成员是一个 IPrimeEvents
指针列表:list<IPrimeEvents*>。CEventMgr 中是一个模板成员函数:Raise<F>,它将仿函数参数 F 传递给
for_each。所以当你写下面这条语句时:
m_eventmgr.Raise(IPrimeEvents::Progress(nFound));
编译器明白你试图以 IPrimeEvents::Progress 类型参数调用 CEventMgr::Raise,于是它用模板产生成员函数 CEventMgr::Raise(IPrimeEvents::Progress)。实现代码将仿函数实例传递给
for_each,它为客户机列表中的每个 I* 对象调用仿函数的 operator()。仿函数调用对象的 OnProgress
处理例程——这就是我想要的!模板不是很酷吗?
我们已经快到终点了。仿函数让我参数化事件方法并使用 for_each,但它们还是太长,我讨厌敲入太多的东西。所以最后一步是引入一些宏来解决这个问题。下面就是 IPrimeEvents
最终的浓缩定义。
class IPrimeEvents {
DECLARE_EVENTS(IPrimeEvents);
public:
DEFINE_EVENT1(IPrimeEvents, Progress, UINT /*nFound*/);
DEFINE_EVENT0(IPrimeEvents, Done);
};
IMPLEMENT_EVENTS(IPrimeEvents);
完整的源代码参见 Figure 4 ——
从代码中你可以体会到我竭尽全力进行精简和浓缩。只留下最基本的信息:每个事件处理器的名字和签名。宏假设 Foo 的事件处理器是 OnFoo。一些编程的唯美主义者不喜欢宏,但我不那样。有工具为什么不使用呢?DECLARE_EVENTS
声明构造函数和析构函数;IMPLEMENT_EVENTS 实现内联析构。宏 DEFINE_EVENT0,DEFINE_EVENT1 以及
DEFINE_EVENT2 分别声明和定义了 OnFoo 事件处理器以及不带参数,带一个参数和带两个参数的 Foo
事件仿函数。如果你需要更多的参数,可以定义一个结构,用一个事件参数来传递此结构的指针:MumbleArgs args;
args.a = 1;
args.b = 2;
// etc.
m_eventMgr.Raise(IMyEvents::Mumble(&args));
还有一种选择,你可以实现
DEFINE_EVENT3。但是记住:仿函数对象通过值传递的,所以它们应该很小。当可以传递指针时,为什么要在堆区和栈区来回拷贝一大堆参数呢?如果事件处理器需要返回值,也可以借助结构。为了简单起见,我让事件处理器返回
void。
经常有程序员会问仿函数会不会带来太大的额外开销。事实上,仿函数通常比函数更有效率。理由是它是内联的。当你在 C++
中传递指向函数的指针时,即使你将函数定义为内联,它就是一个指针。你不能通过传值的方式来传递一个函数。但是当你传递一个对象实例到某个模板函数时,如果你象那样定义函数,编译器产生的所有东西都是内联的。对于事件来说,通过指针仍然只有一个函数调用,它发生在函数
operator 调用虚拟 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 的
March 2006 期刊,可通过当地报摊获得,或者最好是
本文由 VCKBASE MTT 翻译