信号槽机制的简陋实现

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 的信号挂钩,从而实现信号触发机制。

Conection类中关于成员的探讨

因此,Connection 类的定义有:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Connection {
// callback func and itself
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;

// for all slots connected with signal
// must callback at once
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;
// case 1:
// another slot()
// 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
// Old Connection
class Connection {
// callback func and itself
function<void()> m;
void callback() {
if (m)
m();
}
};

// Now Connection
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;
}
}

// In Class A
void signal1(int a) {
void *arg[] = {reinterpret_cast<void*>(&a)};
activate(0, arg);
}

信号函数中的多个参数,需要为它们一一构建好 void* 指针。在 Slot() 中,再将它们一一拆解开。

小结

通过自己不断思考尝试,实现了 Qt 信号槽机制的简陋版。在失去了 MOC 机制后,编写自己的槽机制会有点丑陋,并且有很多地方需要改进,先勉强用上吧。


信号槽机制的简陋实现
https://dingfen.github.io/2021/11/15/2021-11-15-Qt-signal-slot/
作者
Bill Ding
发布于
2021年11月15日
更新于
2024年4月9日
许可协议