C++如何实现基于可变参数模板的信号与槽系统?(事件驱动机制)

2次阅读

因std::function会丢失编译期类型检查,导致信号-槽签名不匹配时无法在编译期报错;必须用模板推导+虚基类带参call实现类型安全擦除,并配合weak_ptr防悬空、参数包转发避免tuple拷贝。

C++如何实现基于可变参数模板的信号与槽系统?(事件驱动机制)

为什么不用 std::function + std::vector 直接存槽函数?

因为可变参数模板的核心价值不是“能传参”,而是让连接时的类型检查发生在编译期——connect(&obj, &Obj::method, &receiver, &Receiver::onData) 这种写法,如果签名不匹配(比如 onData(int, std::String) 但信号发的是 int),编译器立刻报错,而不是运行时崩溃或静默丢弃。

std::function<void></void> 装所有槽,等于主动放弃类型安全;而靠 void* + 手动 cast 更危险,容易误调用或破坏。

实操建议:

  • 每个信号类模板参数必须包含完整函数签名,例如 signal<void const char></void>
  • 槽函数注册时,用 std::bindLambda 封装成匹配签名的可调用对象,再通过 std::function 存储——但封装动作必须在 connect 内完成,不能暴露裸 std::function 接口给用户
  • 禁止把 std::function 当信号基类成员直接存;应为每组参数组合生成独立特化类,靠模板推导保证调用一致性

connect 怎么做类型擦除又不丢签名信息?

关键在两层包装:外层用模板函数接收任意可调用体并推导其参数,内层用类型擦除容器(如 std::unique_ptr 指向虚基类)存具体调用逻辑,但虚基类接口本身带模板参数约束。

立即学习C++免费学习笔记(深入)”;

常见错误现象:写一个通用 SlotBase,只留 virtual void call() = 0,结果所有槽都变成无参调用,信号发来的参数全丢了。

实操建议:

  • 定义 template<typename... args> Struct SlotBase { virtual void call(Args&&... args) = 0; };</typename...> —— 注意,call 必须带参数包,且是转发引用
  • 特化实现类 SlotImpl<functor args...></functor> 继承它,内部保存 Functor 并在 call 中完美转发
  • connect 函数模板里用 decltypestd::is_invocable_v 静态断言 Functor 是否可被信号参数调用,不满足直接编译失败

如何避免重复触发和悬空指针

信号触发时若槽函数内部又调用 disconnect 或析构了 receiver,后续槽列表遍历就可能访问已释放内存。这不是线程问题,单线程也会出事。

使用场景:GUI 中按钮点击触发业务逻辑,逻辑中途删掉自己所在的窗口对象,然后信号继续往后调用其他槽——第二个槽拿到的就是野指针。

实操建议:

  • 槽容器不存裸指针,改用 std::weak_ptr 包裹 receiver 对象(要求 receiver 继承自 std::enable_shared_from_this
  • 触发前先对每个 weak_ptr 调用 lock(),返回空则跳过该槽,不崩溃也不报错
  • 禁止在槽函数里修改当前正在遍历的槽列表(如边调用边 disconnect);如需动态管理,改用“延迟删除”队列,在本次信号结束之后统一清理

为什么不能直接用 std::tuple 存参数?

可以存,但会引入不必要的拷贝和生命周期管理负担。信号发射时参数通常是临时值或局部变量std::tuple 默认按值存储,意味着每次 emit 都要构造 tuple、再逐个解包——对高频信号(如鼠标移动)就是性能黑洞。

性能影响:一次 emit<int std::string>(42, "hello")</int> 若走 tuple 路径,至少触发两次字符串拷贝(构造 tuple 一次,调用槽时解包再一次);而直接参数包展开,字符串可完美转发为右值引用,零拷贝。

实操建议:

  • 信号类内部不保存参数,emit 是纯转发函数:template<typename... args> void emit(Args&&... args) { for (auto& slot : slots_) slot->call(std::forward<args>(args)...); }</args></typename...>
  • 如果真需要延迟发射(如 post 到事件循环),才考虑用 std::make_tuple(std::forward<args>(args)...)</args> + std::apply,但这是例外路径,不是默认设计
  • 别为了“看起来统一”强行把即时调用和延迟调用塞进同一套 tuple 存储逻辑里——它们的优化目标根本不同

最易被忽略的一点:信号对象本身的生命周期管理。它通常作为成员变量挂在 sender 上,但 sender 析构顺序不确定,如果 sender 成员中还有其他依赖该信号的对象(比如某个策略类持有了 connect 返回的 connection handle),析构顺序错位就会导致未定义行为。这类问题不会报错,只会偶发 crash,调试成本极高。

text=ZqhQzanResources