在C++项目中,模块之间往往需要解耦通信,信号槽是一种常见方案。其核心思想是:一个对象发出信号时,所有预先注册的回调函数都被依次调用。最轻量的实现方式,是利用函数指针数组来集中管理这些回调,而不依赖Qt等大型框架。

一、函数指针数组的基础结构
函数指针数组本质上是一个容器,里面每个元素都指向一个具有相同签名的可调用实体。在C++中,如果回调签名固定,例如都是无参无返回值的函数,就可以用原生函数指针来定义数组或向量。
下面的代码展示了一个最基础的信号类,它内部使用std::vector<void(*)()>来保存回调。注意这里使用的是裸函数指针,因此只能绑定普通函数或静态成员函数,无法捕获上下文。
#include <vector>
#include <iostream>
// 定义信号类型:无参无返回
typedef void (*SlotFunc)();
class Signal {
public:
void connect(SlotFunc func) {
slots.push_back(func);
}
void emit() {
for (auto f : slots) {
if (f) f();
}
}
private:
std::vector<SlotFunc> slots;
};
// 普通回调函数
void onSignalA() {
std::cout << "Signal A triggered" << std::endl;
}
void onSignalB() {
std::cout << "Signal B triggered" << std::endl;
}
int main() {
Signal sig;
sig.connect(onSignalA);
sig.connect(onSignalB);
sig.emit();
return 0;
}
这种写法的优点是零开销,没有额外的堆分配(除了vector自身扩容),调用路径非常直接。缺点是类型被死死限定为void(*)(),一旦信号需要传参,就必须修改整个数组定义。
另外,裸函数指针无法绑定带状态的lambda或非静态成员函数。如果业务里需要对象方法作为槽,就必须借助静态中转函数加void*上下文参数,这会让代码变得脆弱且不安全。
二、用std::function实现类型擦除
为了让信号槽支持任意可调用对象,包括lambda、成员函数、仿函数,可以使用std::function来替代裸指针。它内部做了类型擦除,把不同形态的可调用体统一成一种容器元素类型。
此时我们的函数指针数组就变成了std::vector<std::function<void(int)>>,可以接受带一个int参数的各种回调。下面示例演示了如何连接成员函数与lambda。
#include <vector>
#include <functional>
#include <iostream>
class SignalInt {
public:
void connect(std::function<void(int)> f) {
slots.push_back(f);
}
void emit(int value) {
for (auto& f : slots) {
if (f) f(value);
}
}
private:
std::vector<std::function<void(int)>> slots;
};
class Receiver {
public:
void handler(int v) {
std::cout << "Receiver got: " << v << std::endl;
}
};
int main() {
SignalInt sig;
Receiver r;
// 绑定成员函数
sig.connect([&r](int v) { r.handler(v); });
// 绑定lambda
sig.connect([](int v) { std::cout << "Lambda got: " << v * 2 << std::endl; });
sig.emit(10);
return 0;
}
使用std::function之后,信号槽的适用范围大幅拓宽。我们可以在回调里捕获局部变量,也可以把不同对象的成员方法都塞进同一个数组里,由emit统一驱动。
代价是std::function通常带有一次堆分配(小对象优化除外),且调用时有一层间接开销。对于极高频率的信号(如音频采样回调),应评估是否改用裸指针或模板化信号。
三、回调的生命周期与避坑
函数指针数组只保存调用入口,并不管理目标对象的生死。如果某个槽是某个对象的成员函数,而该对象先于信号销毁,那么再次emit就会访问悬空指针,直接崩溃。
一种简单策略是引入连接句柄,允许手动断开。下面代码用整数ID标记每个回调,调用方在对象析构前调用disconnect移除对应项,避免野调用。
#include <vector>
#include <functional>
#include <unordered_map>
#include <iostream>
class SafeSignal {
public:
int connect(std::function<void()> f) {
int id = nextId++;
slots[id] = f;
return id;
}
void disconnect(int id) {
slots.erase(id);
}
void emit() {
for (auto& kv : slots) {
if (kv.second) kv.second();
}
}
private:
int nextId = 1;
std::unordered_map<int, std::function<void()>> slots;
};
int main() {
SafeSignal sig;
int id = sig.connect([]() { std::cout << "temp slot" << std::endl; });
sig.emit();
sig.disconnect(id); // 主动断开
sig.emit(); // 不会再调用已断开的槽
return 0;
}
上述方案把数组换成映射表,虽然查找稍慢,但支持安全删除。实际工程中,若信号与槽同属一个系统且生命周期清晰,也可以约定“信号所有者保证槽对象存活”,从而省去断开逻辑。
另一个常见坑是重复连接:同一回调被connect多次,emit时就会触发多遍。可在connect内先做存在性判断,或要求调用方使用返回的连接令牌做去重管理。
四、线程安全与扩展思考
如果信号在A线程emit,而槽在B线程connect或disconnect,函数指针数组的容器就可能发生竞态。简单做法是给容器加互斥锁,在connect、disconnect、emit处都加锁。
但对于跨线程UI信号,更推荐把emit抛到接收者所在线程的消息循环里执行,而不是在emit瞬间同步调用。这样函数指针数组只负责暂存任务,实际调用由事件系统调度,降低死锁风险。
#include <vector>
#include <functional>
#include <mutex>
class ThreadSafeSignal {
public:
void connect(std::function<void()> f) {
std::lock_guard<std::mutex> lock(mtx);
slots.push_back(f);
}
void emit() {
std::lock_guard<std::mutex> lock(mtx);
for (auto& f : slots) {
if (f) f();
}
}
private:
std::mutex mtx;
std::vector<std::function<void()>> slots;
};
从上面的代码可以看出,加锁版本和最初的函数指针数组版本结构几乎一致,只是多了一层保护。若性能敏感,可改用读写锁,让并发emit获得更高吞吐。
总结来说,用C++实现简单信号槽,核心就是“函数指针数组加注册表”。根据场景在裸指针、std::function、锁策略之间做权衡,就能搭出适合自己项目的轻量通信层,而不必引入庞大依赖。