|
关键字:C++ signal/slot 设计模式 耦合
一.绪论
首先应声明这里的信号不是Linux ,Unix 中的信号。这里的信号和槽用于对象间的通讯。在现代图形用户界面编程中,我们经常希望一个窗口部件的变化或一个事件被通知给另一个窗口部件或引发另一个事件,如数据库的更新可以及时地反映到用户界面上。更一般地,我们希望任何一类的对象可以和其它对象进行通讯。例如,如果我们正在解析一个XML文件,当我们遇到一个新的标签时,我们也许希望通知列表视图我们正在用来表达XML文件的结构。较老的工具包使用一种被称作回调的通讯方式来实现同一目的,而现在我们可以采用信号和槽来替代回调的技术。本论文便是对信号和槽的设计模式及实现机制做一些探讨。
二.传统技术比较
1. 回调函数(CallBack)
很多GUI工具包中,窗口小部件(widget)都有一个回调函数用于响应它们能触发的每个动作,这个回调函数通常是一个指向某个函数的指针。所以如果你希望一个处理函数通知你一些事件,你可以把另一个函数(回调)的指针传递给处理函数。处理函数在适当的时候调用回调。
对于Win32程序总是从WinMain开始执行。主要是实现三个功能:一是注册
窗口类,二是显示窗口,三是实现消息循环。消息循环的作用便是从应用程序队列中取出操作系统放入的消息,从而实现用户和程序之间的交互.应用程序不定期的在消息循环中等待消息的到来。如下:
while( GetMessage(&msg,NULL,0,0) ){
TranslateMessage(&msg);
DispatchMessage(&msg);
}
出现一条消息后,GetMessage()将取出该消息,并存储在一个MSG数据结构中。紧接着TranslateMessage()对msg进行处理并修改该数据块的内容。DispatchMessage()负责查找应调用哪一个窗口过程。为了对关系的消息作出处理,窗口在创建时一定要提供一个消息回调函数,用户应在回调函数中对每一个关心的消息作出判断与处理。典型的便是一个函数——接受四个参数,返回一个
LRESULT值,一个switch语句在过程中完成各个动作。
回调有两个主要缺点。首先他们不是类型安全的。我们从来都不能确定处理函数是否使用了正确的参数来调用回调。其次回调和处理函数是非常强有力地联系在一起的,因为处理函数必须知道要调用哪个回调,因为这样的紧耦合性,回调丧失了许多灵活性。
当然我们也可以用函数指针动态的设置处理函数。比如我们要响应一个点击button的事件,非常简单,就是一个函数指针。可以写成这样:
class Button
{
public:
void OnClicked() {
if( OnClickedHandle!=NULL )
OnClickedHandle();
}
void (*OnClickedHandle) ();//函数指针
};
客户程序只需要编写一个函数,并且赋值给OnClickedHandle就可以了
Button button;
button.OnClickedHandle = myOnClicked; //设置
button.OnClicked(); //触发
而MFC便是在此基础上定义了许多宏,制作了一张精致的消息映射表
来方便编程。
2. 继承+多态
库中的类把响应处理函数设置为虚函数,客户程序可以继承这个类并且重载响应函数。还是以Buttton类为例,可以提供一个OnClicked函数用来响应点击时作出的处理。客户程序只需要重载OnClicked并进行自己的处理就可以了。
class Button {
virtual void OnClicked();
};
class MyButton : public Button { // your event-handle class
virtual void OnClicked() { /* do sth here ... */ }
}
但是很多时候这样做实在很麻烦,很多时候根本不必要继承整个类;又或者某些类只提供一个接口而不是具体的类又或者需要多重继承,处理都有一定麻烦;最麻烦的莫过于有时候需要改变响应处理。而且过多的虚表也是对空间和时间的极大浪费。
只是想着能省事一点,希望能像那些脚本语言一样快速绑定消息响应,比如在vim或emacs中用map绑定快捷键。即外部条件改变时,我们不需改变内部实现,便可作出相应的想应,而不是以继承开始工作。
3. 委托(Delegation)
一个委托模型
现代语言大多都提供委托或类似的技术,我觉得委托最本质的是提供一种类型安全的动态消息响应转移机制。你可以把刚才讨论过的函数指针封装一下弄一个类封装起来,不过,这直接导致某个消息的响应只能是固定死的函数指针类型,甚至不能是c++中的Functor或者是某个类的成员函数。在C#中你可以写一个通用函数,主程序在调用时会转成适当的函数调用,但是语言越是方便,限制也会越多.C# 中的delegate需要与它所调用的函数类型精确匹配.而且也不支持返回值.也许会你会想到在C++中可以用template来绕过限制,那么我们来看一个例子.
假设某个委托类 Delegation 拥有一个成员函数用来连接处理函数 template<class T> void Delegation::connect(T _f); 没错,_f可以不一定函数指针,也可以是Functor,我们利用_f()来呼叫响应函数,一切看起来是很不错——但是,很不幸,这个_f无法保存下来供消息产生的时候呼叫,因为这个template<class T>,你无法在 Delegation内定义一个T类型的变量或者指针来保存_f。退一步说,你把T作为整个Application的模版,还是避免不了在模版实例化的时候定死类型。于是,整个Delegation的通用性大打折扣。
实际上,我们希望有这么一种Delegation,他可以把消息响应动态绑定到任何一个类的成员函数上只要函数类型一致。注意,这里说的是任何一个类。这就要求我们屏蔽信号发生器和响应类之间的耦合关系,即,让他们相互都不知道对方是谁甚至不知道对方的类型信息。这也是软件工程中的重要设计原则。
三. 信号和槽的设计模式
1. 信号和槽的小例子
例a. (摘自boost.signals库)
struct HelloWorld
{
void operator()()
const{ std::cout << "Hello, World!" << std::endl; }
};
signal<void ()> sig; // 定义一个没有返回值的无参数信号
HelloWorld hello;
sig.connect(hello); // 把信号连接到一个槽上
// 发射信号引起槽的处理
sig(); // 输出: Hello, World!
当然也可以很轻松的连接多个槽,接上:
struct LoveLife
{
void operator()()
const{ std::cout << "Love, life!" << std::endl; }
};
LoveLife love;
sig.connect(love);
sig(); // 输出: Hello, World!\nLove, life!\n
// 或: Love, life!\nHello, World!\n
有意思的是信号并不保证引发槽的顺序.如要设置引发顺序,须这么写:
sig.connect(0, hello); sig.connect(1, love);
sig(); // 输出: Hello, World!\nLove, life!\n
sig.connect(1, hello); sig.connect(0, love);
sig(); // 输出: Love, life!\nHello, World!\n
例b.
class Button
{
typedef signal<void (int x, int y)> OnClick;
public:
void doOnClick(const OnClick::slot_type& slot);
private:
OnClick onClick;
};
void Button::doOnClick(const OnClick::slot_type& slot)
{ onClick.connect(slot); }
void printCoordinates(long x, long y)
{ std::cout << "(" << x << ", " << y << ")\n"; }
Button button;
button.doOnClick(&printCoordinates);
当button接收用户点击时,可用onClick(int,int)引发处理事件(这里是printCoordinates(long x, long y)).这个例子说明了对象之间可以在互相不知道的情况下一起工作,只要在最初的时在它们中间建立连接.
信号和槽取代了这些凌乱的函数指针,语义上的直观,使得我们编写这些通信程序更为简洁明了。 信号和槽能携带任意数量和任意类型的参数,他们是类型完全安全的,不会像回调函数那样容易产生异常。
2. 设计思想
泛型桥式委托(Generic Bridge Delegation)
名词只是我暂时的叫法,听起来很吓人,其实就是设计模式的一种组合而以,实现这么一个东西真的比较好玩,其实,像gtk+/qt很多需要信号/槽的系统都是这么实现的。Slot本身可以是函数或函数对象。而这种模式的组合也已经形成自己特有的风格,故也可以叫做signal/slot模式。
泛型桥式委托模型
我们搭建一个Delegation/Implementation的桥用来连接Singal和Slot,这样就可以有效隔开双方的直接耦合。注意Implementaton是模板类。
用以前我们的Button类来演示如下:
class Button
{
public:
Signal OnClick;
};
一个Slot可以是一个function也可以是一个Functor:
struct HelloWorld;
struct LoveLife;
void NoProblem()
{ std::cout<< "No Problem!"<<endl; }
我们可以这样使用这个泛型桥式委托:
HelloWorld hello;
LoveLife love;
Button button;
button.OnClick.connect(hello);
button.OnClick.connect(love);
button.OnClick.connect(test);
button.OnClick();// 输出: Hello, World!\nLove, life!\nNo problem!\n
当消息产生调用OnClick()的时候,用户指定的slot就会响应我们来看看如何实现这个桥。
首先是一个抽象类:
class DelegationInterface {
public:
virtual void slot() = 0;
virtual ~DelegationInterface() {};
};
然后才是模版类Impl:
template<class Slot>
class DelegationImpl : public DelegationInterface {
public:
DelegationImpl(Slot p) :pfun(p) { }
virtual void slot() { pfun(); }
private:
Slot pfun;
};
注意我们上面的图示,这个DelegationImpl类是跟Slot相关联的,也就是说这个Impl类知道所有的Slot细节,于是他可以从容地调用slot()。再次留意这个继承关系,对了,一个virutal的slot函数!利用多态性质,我们可以根据Slot来实例化DelegationImpl类,却可以利用提供一致的访问slot的接口,这就是整座桥的秘密所在——利用多态来隔离下层细节,实现槽的多样性!
再看看我们的Signal类:
class Signal {
public:
vector< DelegationInterface* > vp;
Signal(){ }
~Signal() {
for(vector< DelegationInterface* >::iterator it(vp.begin()),
end(vp.end()); it!=end; ++it)
delete *it;
}
void operator()() {
for(vector< DelegationInterface* >::iterator it(vp.begin()),
end(vp.end()); it!=end; ++it)
(*it)->slot(); // 引发槽的调用
}
template<class T>
void connect(T Slot) {
vp.push_back(new DelegationImpl<T>(Slot));
}
};
显然,Signal类利用了类型为 DelegationInterface 指针来呼叫响应函数。而完成这一切连接操作的正是这个奇妙的connect()函数。上次讨论模版函数的时候就说了这个T类型无法保存,但是这里用桥避开了这个问题。利用模版函数的T做为DelegationImpl的实例化参数,一切就这么简单地解决了
你也许会认为绕了一大圈回到一开始的继承/多态上面来了。其实,我们会发现,这个Singal/Bridge Delegation/Slot的体系是固定的一套东西(这也是我认为可以称作signal/slot模式的原因),你在实际使用中并不需要自己去继承然后去处理重载,你只需要connect到正确的Slot就可以了。这也可以算是一种局部隐含的继承吧。其实看过标准库的源码就会知道它的实现中有很多的局部的继承。
接下来我们要讨论一下这个桥式委托的性能消耗以及扩展。
看过上面的桥式委托之后,可能会有点怀疑他的性能,需要一个DelegationInterface 指针和一个functor类/函数指针,调用的时候需要查看一次vtable,然后再一次做operator()调用。其实,这些消耗都不算很大的,整个类结构是简单的,相对于前面说的继承整个类之类的做法开销还是比较小的,而且又比函数指针通用而且类型安全。在vc6 及 g++编译器的测试下, 10000次调用几乎没有什么差别.对于空函数的调用也不过慢了不到百分之一。(其实空函数的情况是很少见的)
我们刚才实现的桥式委托只能接收函数指针和functor,不能接收另外一个类的成员函数,有时候这是非常有用的动作。比如设置一个按钮Button的OnClick事件的响应为一个View的draw方法。当然,View还有其他非常多的方法,这样就可以不用局限于把View当成一个functor了。我们要改写刚才的整个桥来实现这个功能,在这里只需改写DelegationImpl中的指针类型及相应的生成及调用部分。
// 新版的桥式委托,可以接收类的成员函数作为响应
template<class T>
class DelegationImpl : public DelegationInterface {
public:
typedef void (T::* PMF)(); // 指向类T成员函数的指针类型
DelegationImpl(T* _P, PMF _pmemfun) :p(_P), pmemfun(_pmemfun) {}
virtual void slot() {
if(p) { (p->*pmemfun)(); } // 调用成员函数
}
private:
T* p; // 类指针
PMF pmemfun; // 指向类的某个成员函数
};
class Signal {
public:
vector< DelegationInterface* > vp;
Signal(){}
~Signal()
{
for(vector< DelegationInterface* >::iterator it(vp.begin()),
end(vp.end()); it!=end; ++it)
delete *it;
}
void operator()() {
for(vector< DelegationInterface* >::iterator it(vp.begin()),
end(vp.end()); it!=end; ++it)
(*it)->slot();
}
template<class T>
void connect(T& Slot, void (T::*pf)()) {
vp.push_back(new DelegationImpl<T>(&Slot,pf));
}
};
Connect方法的pF参数类型非常复杂,也可以简化如下,即把这个类型检测推到DelegationImpl类去完成,而不在Connect这里进行么?编译器可以正确识别。对于模板来说,很多复杂的参数类型都可以用一个简单的类型代替,不用关心细节,就象上面用一个F代替void (T::*)()。有时候能改善可读性,有时候相反。
行了让我们来用这个新版Signal,很简单的。比如你的View类有一个成员函数draw( ),你可以把这个作为响应函数:
View view;
Button button;
button.OnClick.connect(view,&View::draw);
我们可以进一步,把新版的Signal和旧版的Signal结合一下,你就可以获得一个比较完善的signal系统了。但这时我们会发现:上面的桥式委托无法给相应操作传递参数! 你必须自己实现带一个参数的桥、自己实现带2个参数的桥……就像stl的functor一样,你无法做到参数通用处理,必须区分
unary_functor,binary_functor……
要自己写很多桥。当然了,你可以用别的办法来实现,比如用宏定义来处理各种情况,一般的函数参数不会超过十个,所以我们实现时可不用定义的太多,也好在这些桥比较类似,形成不了多少困难。在多平台Qt 图形库实现时,由于当时的C++编译器在使用高级模板特性的时候还是有问题。于是Qt的moc(元对象编译器)提供了另一种的方式。它可以生成任何一个标准的C++编译器都能编译的额外的C++代码。元对象编译器读取C++源文件。如果它发现其中有一个或多个类的声明中含有“Q_OBJECT”这个宏,它就会为这些类生成另外一个包含元对象代码的C++源文件。这个由元对象编译器生成的C++源文件必须和它的类实现一起编译和连接(或者它也可以被#included到这个类的源文件中)。详情可参考Qt帮助文档。
幸运的是,在C++标准委员会不断的努力之下,这些情况开始有所改善。boost库之中的signal库可以直接支持可变参数的委托;同时,越来越多的元编程技术也引入了C++之中。而且编译器技术的不断提高,使我们有理由相信
这一模式的更加完善。
3. 泛型桥式委托的进一步研究
当然事物越强大,人们的要求便也会越高;语言越是丰富,我们便会索取的越多。在平衡的原则下,我们自然要求在有连接的情况下,能断开不必要的连接,这就要求提供断开连接的操作。当然你也许认为这很简单,只要新建一个接口对Signal中的vector直接进行操作便可以了。但是如果我们想自动在signal
或slot析构时自动断开连接。以上模式便无能为力了。因为,如果我们建立signal时传进去的是动态生成的类,当我们删除它时,调用时仍会引发slot的执行。因为我们并没有删除Signal中对slot的引用!这时情况便无法预测了。当然,C++的强大使我们很容易另辟蹊径,使用如图的方法使Signal或slot析构时,都能自动的断开连接。
断开连接的方法示意图
在进一步我们便就会有更高的要求,如支持多线程,毕竟现代的GUI的程序,单线程的已经不多了。当然,最好是单线程,多线成一同支持,由用户自己选择何种版本。。在我的实现中是相同的类来实现的,而用signal/slot系统继承之继承时用一个模板参数来加以区别。当然以上只是原理式的解释,忽略了很多实现的细节,为的是方便说理。
四.应用领域及实际作品
在绝大多数现代GUI工具包中,都看得到signal/slot的身影。如linux 下的两大桌面环境GNOME的gtk工具包中,KDE的Qt工具包中,都离不开signal/slot机制。而大名鼎鼎的boost库中也有signal/slot的一席之地。而各种现代程序语言中也都有signal/slot模式。
在了解掌握了这种技术之后,我也写了一个小的实作,提供连接及断开操作,
并且可由用户自由选择是否支持多线程。这一技术里包含了数种经典模式,因此可以看出模式的重要性。而在实践中,我们如果能灵活的组合应用,便会发出更大的威力,创造出更好更新的模式!
参考文献
1.《设计模式》 Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides
著,机械工业出版社。
2.《设计模式解析》 Alan Shalloway, James R.Trott 著, 中国电力出版社。
3. boost 文档。
4. Qt 文档。[/code] |
|