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

为什么不用 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::bind或 lambda 封装成匹配签名的可调用对象,再通过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函数模板里用decltype和std::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,调试成本极高。











