信号和槽:Qt中最差劲的创造
2019-11-29

不要被这个标题唬住了,实际上我是非常认可Qt的。在C++实现的开源产品中没有哪一个的API风格比得上Qt,拥有高度一致性,符合常识,符合直觉,几乎不用学就可以直接上手。或许是由于我们摆脱不了马太效应的控制,赞誉已经给到了Qt的缺陷。Qt的最大问题就是提供了uic和moc。界面设计器生成xml文件,需要uic编译它生成C++代码;组织界面的C++代码其实一点都不复杂,完全可以由界面设计器直接生成。可以给Qt找到需要uic的理由——实现了分工,可以并行,为设计器开发团队屏蔽了C++语法的复杂性。然而,uic相对于界面设计器来说,工作量几乎可以忽略不记,在管理实践上如此不平衡的分工没有任何意义,并行也就说不过去了。组织界面的C++代码完全掌控在Qt团队手里,完全可以用最简单的方式实现(uic生成的c++代码也确实非常简单),这样一来也没有什么“C++语法的复杂性”需要屏蔽了。如果uic对用户来说没有坏处,仅仅给Qt团队增加了工作量,也就无可非议了。但是,uic集成到第三方开发工具中时,导致设计器创建的界面,不能及时生成为C++代码,必须手动执行一下uic。

uic跟moc比起来,就是小巫见大巫了。提供moc的原因,很大一部分是因为信号和槽机制。每每听到有人带着无比崇敬的态度布道Qt的信号和槽机制,真希望他们能知道信号和槽到底为了什么而存在。还是先来看一段Qt的代码吧。

class QDataSourceWidget : public QTreeView{Q_OBJECTpublic:explicit QDataSourceWidget(QWidget *parent = 0);~QDataSourceWidget();signals:void LayerAdded(IMapPtr, ILayerPtr, ILayerProviderPtr);protected: virtual void LayerAddEvent(IMapPtr map, ILayerPtr layer, ILayerProviderPtr provider) { emit LayerAdded(map,layer,layerProvider); }private slots:void NodeDoubleClicked(const QModelIndex &index) { ... LayerAddEvent(map,layer,provider); }};

这段代码要完成这样一个功能:当表示数据源的QTreeView的节点双击时,打开数据,为数据创建一个可视化图层添加到map中,然后对外发布一个已经添加新图层的消息。很简单的一个功能,看看为了实现它Qt提供了些什么?4个扩展关键字——Q_OBJECT、signals、slots、emit;3个需要注册到QMetaTypes的自定义类型IMapPtr、ILayerPtr、ILayerProviderPtr(这3种类型实际上是另外3种类型的指针,但是必须得typedef才能注册到QMetaTypes中);1个元编译器moc。

非常代价高昂的解决方案,连编译器这种重型武器都上场了。为什么需要编译器?这段代码已经不可以再被称作C++了,就像.NET平台下的c++ cli一样,已经基于C++扩展出了一门新语言。众所周知C++的编译器非常难写,通常新标准发布10年之后都不被完全支持,这跟C++语义重载过多、语法自相矛盾、机制过于复杂不无关系。元编译器没有直接生成机器码,而是将“Qt c++”编译成了能够实现信号和槽机制的标准C++,再由C++编译器编译成机器码。这种方案确实避免了面对不同架构不同版本CPU的麻烦,但是仍然需要面对“Qt c++”中的C++成分。这就是偶尔会遇到元编译器执行失败、元编译器生成的C++代码编译不过的原因。这些现象在新版本Qt中确有很大改观,但是C++标准委员会并没有浪子回头的意思,元编译器即将面对的是更多语义重载、更多语法矛盾、更复杂的机制。

其实“Qt c++”也没怎么扩展标准C++,就多了4个关键字而已,而这4个关键字就是要派重武器——编译器——上场的罪魁祸首之一。这4个关键字还起了另外一个坏作用,让针对标准C++的代码自动格式化、代码自动完成失效了。对于需要注册QMetaTypes倒没有什么好抱怨的,毕竟带来了其它好处。

那么Qt以如此之高代价实现的信号和槽机制到底是什么高档玩意呢?说白了就是一种发布/订阅机制而已,对于没有从语言层面上支持调用栈上的发布/订阅机制的编程语言来说,一般通过两种方式来实现——Listener模式和回调函数。java swing就是典型的Listener模式,这个很显然;如果说MFC的Message Map是回调函数,可能会遇到争议。Message Map提供了消息码到消息处理函数指针的映射,消息循环从Message Map中查找到处理某个消息的所有函数指针,然后依次调用。消息循环是框架提供的,只是通过Message Map的形式传进去一个函数指针而已,虽然没有直接调用SetXXXCallBack,不影响它仍然是回调函数。

Qt为何弃这两种方式不用呢?确实有说得过去的理由。首先,C++没有匿名类,没有垃圾回收机制。如果采用Listener模式,必须得为每个不同签名的消息至少定义一个类,必须得合理地管理这些Listener的生命周期。MFC的Message Map方式,需要在代码中写很多宏,在不考虑代码自动完成时,显然只写下signals和slots两个关键字更为方便。从而,Qt便仓促地选择了由GTK发明的信号和槽的概念。(注:这是笔者帮Qt想的理由,是否还有其它理由笔者没有深入了解。)MFC的Message Map还是有他的拥趸的,wxWidgets便是其中较为知名的一员。

其实完全可以通过C++实现比Message Map更好的回调机制。在提出实现方式之前先明确一下需求和约束。

首先是需求,第一,订阅方可以是成员函数、静态函数、C函数、仿函数;第二,发布方可以支持多个订阅者同时订阅。如果满足这两条需求就已经比Qt的信号和槽机制要强大了。

当然也有一些约束,第一,既然发布方可以支持多个订阅者同时订阅,那么发布方若要采纳订阅者的返回值的话到底应该采纳哪一个的,这是个问题,所以干脆让订阅者全都返回void(Qt目前支持返回非void类型,但是有什么卵用他们内部仍然有争议);第二,轻量级,不用stl,不用boost(不用boost还说得过去,stl毕竟是c++的标准库。我有我的理由,C++的缺陷导致编译器特别难写,可以说在模板方面找不到实现地完全正确的编译器。一些编译器不能正确的为静态的或者全局的模板类变量生成构造代码,这应该是Google代码规范禁止这么做的原因,全局的和静态的类只能声明为指针,由程序员确保其被正确地构造出来。)。加上这两条约束,仍然不影响满足前两条需求的发布/订阅机制比Qt的信号和槽机制强大。

接下来给出完全通过C++实现的比Message Map和信号/槽机制更强大更轻量级的回调实现机制。

首先给出返回值是void类型可以代表成员函数、静态函数、C函数、仿函数的订阅者接口定义。这里用到了C++11的可变模板参数机制,只是为了方便而已。要支持C++98,可以用typelist机制或者直接多定义几个不同参数数量的模板。推荐用后者,typelist可能有些编译器支持不了。

template<typename ...Args>struct IEventHandler{virtual void operator()(Args&... args) = 0;IEventHandler() {};virtual ~IEventHandler() {};private:IEventHandler(const IEventHandler &) = delete;IEventHandler &operator=(const IEventHandler &) = delete;};

接下来支持静态函数、C函数、仿函数的订阅者实现。

template<typename Callable, typename ...Args>class CallableEventHandler : public IEventHandler<Args...>{public:CallableEventHandler(Callable handler){_handler = handler;}virtual ~CallableEventHandler() {};public:void operator()(Args&... args){_handler(args...);}private:Callable _handler;};

然后,支持成员函数的订阅者实现。

template<typename T, typename ...Args>class EventHandler : public IEventHandler<Args...>{public:typedef void(T::*Handler)(Args...);EventHandler(T* receiver, Handler handler){_receiver = receiver;_handler = handler;}virtual ~EventHandler() {};public:void operator()(Args&... args){(_receiver->*_handler)(args...);}private:Handler _handler;T* _receiver;};

最后,发布方实现。

template<typename ...Args>class Event{public:typedef IEventHandler<Args...>* Callable;public:Event() {_valid = false;_event = nullptr;};Event(const Callable& h){_handler = h;_valid = true;_event = nullptr;}~Event() {if (_event != nullptr) {delete _event;}};const Event<Args...>& operator = (const Callable& h){_handler = h;_valid = true;if (_event != nullptr){delete _event;_event = nullptr;}return *this;}Event(const Event<Args...> & e){this->Add(e);}Event<Args...> &operator=(const Event<Args...> & e){_valid = false;if (_event != nullptr){delete _event;_event = nullptr;}this->Add(e);return *this;}public:void Raise(Args&... args){if (_valid){(*_handler)(args...);}if (_event != nullptr){_event->Raise(args...);}}void operator()(Args&... args){this->Raise(args...);}public:void Add(const Callable& h){if (_valid){if (_event != nullptr){_event->Add(h);}else{_event = new Event<Args...>(h);}}else{_handler = h;_valid = true;}}void Remove(const Callable& h){if (_valid && _handler == h){if (_event == nullptr){_valid = false;}else{Event<Args...>* event_ = _event;_valid = _event->_valid;_handler = _event->_handler;_event = _event->_event;event_->_event = nullptr;delete event_;}}else if (_event != nullptr){_event->Remove(h);}}void Add(const Event<Args...>& e){Event<Args...>* event_ = const_cast<Event<Args...>*>(&e);while (event_ != nullptr){if (event_->_valid){this->Add(event_->_handler);}event_ = event_->_event;}}void Remove(const Event<Args...>& e){Event<Args...>* event_ = const_cast<Event<Args...>*>(&e);while (event_ != nullptr){if (event_->_valid){this->Remove(event_->_handler);}event_ = event_->_event;}}public:const Event<Args...>& operator += (const Callable& h){this->Add(h);return *this;}const Event<Args...>& operator -= (const Callable& h){this->Remove(h);return *this;}const Event<Args...>& operator += (const Event<Args...>& e){this->Add(e);return *this;}const Event<Args...>& operator -= (const Event<Args...>& e){this->Remove(e);return *this;}private:bool _valid;Callable _handler;Event<Args...>* _event;};

仅此而已,加上很多空白行才有不到300行代码。这是我在开源项目tGis实现的发布/订阅机制,可以采用如下方式使用。

Event SomeEvent;EventHandler handler;SomeEvent += &handler; // 绑定订阅者到发布者方式一SomeEvent.Add(&handler); // 绑定订阅者到发布者方式二SomeEvent += new EventHandler; // 要绑定订阅者到发布者方式三,暂不支持,会导致内存泄漏SomeEvent(); // 触发事件方式一SomeEvent.Raise(); //触发事件方式二

当然,这个实现方式仍然有些不足。第一,参数不能是右值;触发事件的函数参数不能直接作为事件的参数,不能在事件参数上直接构造对象,而需要先声明个变量接收一下。这个不足解决起来也很简单,加个接收右值参数的重载就行了。(这个不足只是对C++11而言的)

Event SomeEvent;SomeEvent(SomeClass()); // C++11中,这样触发事件是错误的,编译不过SomeClass some; SomeEvent(some); // 这样一定是可以的

第二,对订阅者进行了包装,但是没有提供生命周期管理机制。这只是需求和代码量的权衡,我不想就为支持“+= new”式的语法搞出一套生命周期管理机制来。简单的实现方式是auto_ptr,如果愿意,读者可以自己实现。在这里,我要善意的提醒一句,c++里的堆是开发库提供的,很可能不同的链接库以及执行文件中的堆不是一个堆,用一个堆的delete去删除另一个堆里的对象将会收获一个程序崩溃

希望这里提到的缺陷不要影响到读者的技术选型。优势就是优势,缺陷就是缺陷;不能优势大于缺陷之后缺陷也成为了优势,技术人应该有客观的技术态度。要知道我是在用Qt的,参考开源项目tGis。其实信号和槽机制没有给用户带来什么麻烦,仅仅是给Qt团队带来了巨大的麻烦而已。