更新于:2024-04-09T23:19:32+08:00
Qt 信号槽机制的简陋实现
最近科研中碰到了一个需要实现信号触发机制的场景。我第一时间想到了 Qt 的信号槽机制。但可惜服务器上没有 Qt 的相关环境,因此需要自己实现一个简陋的信号触发机制。
问题需求
假设 A 类的对象 a 与 B 类的对象 b 需要信号连接。即 a 发出某个信号 S 给 b ,b 接收到该信号后,根据自己所处的状态,决定是否响应该信号,响应即调用成员函数 slot()
,不响应即让信号进入等待队列。
具体来说,在构造了对象 a 和 b 后,先使用 connect()
函数连接这两个对象。此后,在程序中任何位置中调用了 a->signal()
函数后,就视为对象 a 发出了信号 signal 给 b。此时,若 b 繁忙,那么该信号会暂时等待,直至空闲后 b 再调用成员函数 slot()
,若 b 空闲,直接调用成员函数 slot()
。与 Qt 机制相同,一个槽函数可以被多个信号相连接,一个信号也可以连接多个槽。
naive 版本
看到上述需求,我的第一反应是需要建立一个 Connection 类来帮助管理所有的连接。具体来说,在 connect()
函数中需要构造 Connection 类对象 c ,它一方面手握 b 的成员函数,另一方面与 a 的信号挂钩,从而实现信号触发机制。
因此,Connection 类的定义有:
1 2 3 4 5 6 7 8 9 10 11 12 13 class Connection { function<void ()> m; void callback () { if (m) m (); } };void A::signal () { if (c) c->callback (); }
对于 connect()
函数,过程就比较简单。构建Connection 类对象后,b 的成员函数传给Connection类中的“函数指针”,另一方面,需要将 a 发出的信号与该函数指针关联起来。
1 2 3 4 5 6 void connect (A* a, B* b, string signal, string slot) { Connection *c = new Connection (); function<void ()> f = bind (&B::slot, b); c->f = f; a->c = &c; }
关联的方案和 Qt 一样,选择使用字符串匹配的方式。即传入的参数使用宏定义包装,转为字符串。但这里的实现显然还没有用上函数后面的两个参数 。
1 2 3 4 5 6 #define SIGNAL(x) "1" #x #define SLOT(x) "2" #x A *a = new A (); B *b = new B ();connect (a, b, SIGNAL (signal1 ()), SLOT (slot ()))
逐步改进
响应函数队列
上述做法很简单,但缺点很多,先是没有使用 connect()
传入的信号和槽,然后是无法支持一个信号对应多个槽函数和多个信号,最后,槽函数的类型被限制为 function<void()>
,这也让人很难受。但总的来说,naive 版本起码说明了,这条路可行。
要想做的更好,需要加上很多东西。首先,对于每个信号,都应该有一个信号槽函数的队列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class A { map<string, vector<Connection *>> connect_map; void A::signal () { for (auto conn : connect_map[SIGNAL (signal ())]) conn->callback (); } void A::setUp (string signal, Connection *c) { connect_map[signal].push_back (c); } }
如此,当信号被调用后(发出后),所有与信号相连的槽函数都会被调用。
信号槽函数的抽象
接下来解决下一个问题:如何使用 connect()
传来的槽函数,而不是硬编码到函数内部。有两个思路:一种是直接将 connect()
的参数类型改为函数指针,因为指针可以直接传入 Connection 类,可方便后续处理,但这会限制槽函数的类型。另一种是仍沿用字符串,但要求内部需要有一个对应表,存放字符串与槽函数的对应关系。参考 Qt 的信号槽机制,我选择使用第二种。
为了让所有类型的函数都可以充当槽函数,又不更改 Connection 类中的变量类型,可以选择抽象包装法 。
1 2 3 4 5 6 7 8 9 10 11 12 void Slot () { switch (func_id) { case 0 : slot1 (); break ; default : break ; } }
直接将上面的 Slot()
函数传给 Connection 类,然后,让对象内的变量通过字符串来确定到底该调用哪一个成员函数。相当于在所有槽函数之上都加了一层抽象。
1 2 3 4 5 6 7 8 9 10 11 12 class B { map<string, int > funcMap; B () { funcMap.insert (pair <string, int >(SLOT (slot1 ()), 0 )); } void findFuncId (string slot) { if (funcMap.find (slot) != funcMap.end ()) func_id = funcMap[slot]; else func_id = -1 ; } }
findFuncId()
将字符串转为内部的函数编号,进而改变了 Slot()
函数要调用的槽函数,但需要我们提取把槽函数和编码都放到表中。为了统一,在信号发送端,我们也希望用整数取代字符串,来唯一编号信号函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 class A { map<string, int > sgMap; A () { sgMap.insert (pair <string, int >(SIGNAL (signal1 ()), 0 )); } int findSetId (string slot) { if (sgMap.find (slot) != sgMap.end ()) signal_id = sgMap[slot]; else signal_id = -1 ; return signal_id; } }
从上面的改进中,可以看出不论槽函数是什么样,在 Connection 类中的函数指针永远指向 Slot()
。对于 Connection 类而言,只需要记住哪个信号发生后(即信号的编码),需要触发哪些对象的哪些槽函数(即对应对象的槽函数编号)就行了。
Object 类
不难看到,A 类和 B 类在有些地方有相似性,可将其抽象出来成为一个基类 Object。另一方面,Object 类也可以替代 Connection 类的功能。所以,需要在这个地方做一个非常大的改动。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Connection { function<void ()> m; void callback () { if (m) m (); } };struct Connection { int id; Object* receiver; };
首先,将 Connection 类做了改变,因为只需要槽函数的编号和类的抽象包装函数即可。
A 类和 B 类中,都有对信号/槽函数的编码表,那么编码表可以放在 Object 类中,取名为 funcMap
,提供函数名字符串返回其编码。
此外,将之前 A 类中的 map<string, vector<Connection *>> connect_map
信号与对应槽函数的对应表关系也放入 Object 类中。把 B 类的抽象槽函数 Slot()
也放进 Object 类。如此,只要继承了 Object 类,再稍加修改(后续介绍),就可以使用我们自己实现的信号槽机制,与 Qt 的非常类似。
1 2 3 4 5 6 class Object {public : map<int , vector<Connection>> connect_map; map<string, int > funcMap; function<void (int )> Slot_; }
最后,在 Object 类中添加之前的函数实现,包括 connect()
函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Object { void activate (int id) { for (auto c : connect_map[id]) c.receiver->Slot_ (c.id); } int findId (const string& s) { int func_id = -1 ; if (funcMap.find (s) != funcMap.end ()) func_id = funcMap[s]; return func_id; } static void connect (Object *sender, Object *receiver, const char * signal, const char * slot) { int sid = sender->findId (signal); int rid = receiver->findId (slot); Connection c (rid, recevier) ; sender->connect_map[sid].push_back (c); } };
A 类和 B 类都直接继承 Object 类。但这样做还不够,需要在各自的构造函数中把自己的信号/槽函数加入 funcMap
中,槽函数也需要被动态绑定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class A : public Object {public : A () { funcMap.insert (pair <string, int >(SIGNAL (signal1 ()), 0 )); } };class B : public Object {public : B () { Slot_ = bind (&B::Slot, this , placeholders::_1); funcMap.insert (pair <string, int >(SLOT (slot1 ()), 0 )); } };
这样改动的好处是,要添加一个槽函数,我们无需更改 Object 类的部分,只需要更改 B 类的构造函数和 Slot()
。新添加一个信号也是如此。
带参数的信号槽
最后的改动,以支持某一些简单的参数可以在信号中传递。首先,在 Object 类中的槽函数,添加一个 void**
的变量,用于传参。随之改动的,就是 activate()
。void **
相当于一个指向所有参数的指针数组。
1 2 3 4 5 6 7 8 9 class Object { function<void (int , void **)> Slot_; void activate (int id, void **arg) { for (auto c : connect_map[id]) { c.receiver->Slot_ (c.id, arg); } } }
在 A 类和 B 类中,为了与 Object 类一致,做如下修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class B { B () { Slot_ = bind (&B::Slot, this , placeholders::_1, placeholders::_2); } void Slot (int id, void **args) { switch (id) { case 0 : slot1 (*reinterpret_cast <int *>(args[0 ])); break ; } } void slot1 (int a) { cout << "Slot : " << a << endl; } } void signal1 (int a) { void *arg[] = {reinterpret_cast <void *>(&a)}; activate (0 , arg); }
信号函数中的多个参数,需要为它们一一构建好 void*
指针。在 Slot()
中,再将它们一一拆解开。
小结
通过自己不断思考尝试,实现了 Qt 信号槽机制的简陋版。在失去了 MOC 机制后,编写自己的槽机制会有点丑陋,并且有很多地方需要改进,先勉强用上吧。