
1. 项目概述为什么要在C里造一个“信号与槽”如果你用过Qt肯定对它的信号与槽机制印象深刻。简单来说它提供了一种对象间通信的优雅方式一个对象发送者发出一个“信号”另一个或多个对象接收者的“槽”函数会自动被调用。这种机制解耦了对象间的依赖让代码结构更清晰事件驱动编程更直观。但问题是Qt的信号与槽是它框架的一部分严重依赖其元对象系统MOC和宏离开了Qt环境就用不了。那么如果我们想在一个纯C项目比如一个游戏引擎、一个高性能服务器、或者一个不想引入Qt这种重型框架的桌面应用里也享受到这种松耦合通信的便利该怎么办这就是“纯C实现信号与槽机制”这个项目的核心价值。它不依赖任何第三方库仅使用现代C主要是C11及以上的标准特性重新打造一套类型安全、高性能、易用的信号与槽系统。这不仅仅是“重新发明轮子”。在嵌入式环境、对启动时间敏感、或者需要极致性能控制的场景下一个轻量级、零外部依赖的实现至关重要。通过自己实现你能彻底掌控其内存管理、线程安全策略和性能开销这是使用大型框架无法比拟的灵活性。接下来我会带你从设计思路到代码实现一步步拆解如何构建这样一个系统。2. 核心设计思路与架构拆解在动手写代码之前我们必须想清楚几个核心问题信号是什么槽是什么它们如何连接连接信息如何存储调用时如何保证类型安全线程安全怎么考虑这些都是设计阶段必须回答的。2.1 信号的本质可调用对象的容器在C的语境下一个“信号”本质上是一个事件发射器。当某个特定事件发生时比如按钮被点击、数据加载完成它需要通知所有对此事件感兴趣的监听者。因此信号内部需要维护一个列表里面存放了所有连接到它的“槽”——也就是一系列可调用对象函数、成员函数、Lambda表达式等。所以我们的Signal类模板至少需要两个核心能力连接Connect将一个可调用对象槽注册到列表中。发射Emit / Operator()当事件发生时遍历列表并调用所有已注册的可调用对象。这里的关键是类型安全。信号必须知道它要发射的参数类型。因此Signal必须是一个模板类模板参数就是其参数列表。例如Signal表示一个不接收参数也不返回值的信号Signal表示接收一个int和一个string参数的信号。2.2 槽的抽象统一的可调用对象包装器槽可以是任何可调用对象一个普通函数指针、一个类的成员函数需要绑定对象实例、一个函数对象Functor、或者一个Lambda表达式。我们需要一种方式能把这些形态各异的东西统一存储在一个容器里。C11 的std::function几乎就是为此而生的。std::function是一个通用的、类型擦除的可调用对象包装器。我们可以用std::function来存储一个具体的槽函数。例如对于无参无返回值的槽其类型就是std::function。但是直接存储std::function会带来一个问题如何断开连接Disconnect当某个对象不再需要接收信号时我们需要能从信号的列表中精准地移除对应的槽。如果只存储std::function我们无法区分两个签名相同的不同槽。解决方案是引入一个中间层连接令牌Connection Token。每个连接在建立时都生成一个唯一的标识比如一个整数ID或一个指针并将这个标识与对应的std::function一起存储。当需要断开时提供这个标识即可。2.3 连接的生命周期管理智能指针与弱引用这是实现中最容易出错的部分。考虑一个典型场景对象A的信号连接到了对象B的成员函数槽。如果对象B被销毁了比如界面关闭而信号A随后被发射这时调用一个指向已销毁对象的成员函数会导致未定义行为通常是程序崩溃。因此对于对象成员函数槽我们必须提供一种机制在对象生命周期结束时自动断开连接。一个常见的做法是使用std::weak_ptr。让槽函数持有对目标对象的弱引用。在调用槽函数之前先尝试将弱引用提升lock()为std::shared_ptr。如果提升成功说明对象还活着可以安全调用如果失败说明对象已销毁则自动从连接列表中移除该槽。这就要求槽函数的调用者对象B必须由std::shared_ptr管理。虽然这增加了一些约束但它为资源管理提供了强大的安全保障是现代C推荐的做法。对于不需要此安全特性的场景如连接静态函数或全局函数我们可以提供轻量级的连接方式。2.4 线程安全考量信号可能在多线程环境中被发射。如果连接列表在遍历时被另一个线程修改连接或断开会导致迭代器失效引发严重问题。因此我们必须考虑线程同步。最简单的做法是在Signal类内部使用一个互斥锁如std::mutex在修改连接列表connect/disconnect和遍历调用列表emit时加锁。但这会带来性能开销尤其是在信号被频繁发射的场合。更高级的设计可以提供不同的线程安全策略比如无锁队列或者将“发射”操作设计为线程安全的而将连接/断开操作限制在对象初始化阶段单线程。在我们的基础实现中为了简洁和通用性会先采用互斥锁方案并指出其性能影响和优化方向。3. 基础实现一个简易可用的信号类基于以上思路我们先实现一个基础版本。这个版本支持连接任意可调用对象支持断开连接并包含简单的线程安全。#include functional #include vector #include memory #include mutex #include algorithm namespace my_signal_slot { // 前向声明 class Connection; template typename... Args class Signal { private: // 定义槽函数的类型 using SlotType std::functionvoid(Args...); // 连接条目包含唯一ID和实际的槽函数 struct ConnectionSlot { uint64_t id; // 连接ID SlotType slot; ConnectionSlot(uint64_t i, SlotType s) : id(i), slot(std::move(s)) {} }; std::vectorConnectionSlot slots_; // 存储所有连接 std::mutex mutex_; // 保护slots_的互斥锁 uint64_t next_id_ 1; // 用于生成唯一ID的计数器 public: Signal() default; ~Signal() default; // 禁止拷贝和赋值因为连接管理很复杂 Signal(const Signal) delete; Signal operator(const Signal) delete; // 移动语义可以支持 Signal(Signal other) noexcept { std::lock_guardstd::mutex lock(other.mutex_); slots_ std::move(other.slots_); next_id_ other.next_id_; } Signal operator(Signal other) noexcept { if (this ! other) { std::lock_guardstd::mutex lock_this(mutex_); std::lock_guardstd::mutex lock_other(other.mutex_); slots_ std::move(other.slots_); next_id_ other.next_id_; } return *this; } // 连接函数返回一个Connection对象用于管理连接生命周期 template typename Callable Connection connect(Callable func) { std::lock_guardstd::mutex lock(mutex_); uint64_t new_id next_id_; slots_.emplace_back(new_id, std::forwardCallable(func)); return Connection(this, new_id); } // 发射信号调用所有连接的槽 void emit(Args... args) { std::vectorSlotType slots_to_call; { // 复制槽函数列表避免在调用时持有锁 std::lock_guardstd::mutex lock(mutex_); slots_to_call.reserve(slots_.size()); for (const auto conn_slot : slots_) { slots_to_call.push_back(conn_slot.slot); } } // 在无锁状态下调用槽函数 for (auto slot : slots_to_call) { slot(args...); } } // 断开特定ID的连接内部使用由Connection类调用 bool disconnect(uint64_t id) { std::lock_guardstd::mutex lock(mutex_); auto it std::find_if(slots_.begin(), slots_.end(), [id](const ConnectionSlot cs) { return cs.id id; }); if (it ! slots_.end()) { slots_.erase(it); return true; } return false; } // 清空所有连接 void clear() { std::lock_guardstd::mutex lock(mutex_); slots_.clear(); } }; // 连接类用于管理单个连接的生存期RAII class Connection { private: using Disconnector std::functionbool(uint64_t); SignalBase* signal_; // 指向所属信号的基类指针实际需要多态这里简化 uint64_t id_; Disconnector disconnector_; bool connected_; public: Connection() : signal_(nullptr), id_(0), connected_(false) {} Connection(SignalBase* sig, uint64_t id, Disconnector d) : signal_(sig), id_(id), disconnector_(std::move(d)), connected_(true) {} ~Connection() { disconnect(); } // 禁止拷贝允许移动 Connection(const Connection) delete; Connection operator(const Connection) delete; Connection(Connection other) noexcept : signal_(other.signal_), id_(other.id_), disconnector_(std::move(other.disconnector_)), connected_(other.connected_) { other.connected_ false; } Connection operator(Connection other) noexcept { if (this ! other) { disconnect(); signal_ other.signal_; id_ other.id_; disconnector_ std::move(other.disconnector_); connected_ other.connected_; other.connected_ false; } return *this; } void disconnect() { if (connected_ disconnector_) { disconnector_(id_); connected_ false; } } bool isConnected() const { return connected_; } }; // 需要一个非模板的SignalBase来让Connection引用简化版实际需更精细设计 class SignalBase { public: virtual ~SignalBase() default; }; // 让Signal继承自SignalBase template typename... Args class Signal : public SignalBase { // ... 上述Signal的实现 ... // 在connect函数中需要将this和disconnect方法包装给Connection template typename Callable Connection connect(Callable func) { std::lock_guardstd::mutex lock(mutex_); uint64_t new_id next_id_; slots_.emplace_back(new_id, std::forwardCallable(func)); // 使用lambda捕获this提供断开连接的方法 auto disconnector [this](uint64_t id) - bool { return this-disconnect(id); }; return Connection(this, new_id, std::move(disconnector)); } }; } // namespace my_signal_slot基础版要点解析RAII连接管理Connection对象利用析构函数自动断开连接防止遗忘断开导致的内存泄漏或悬空回调。这是现代C资源管理的核心思想。发射时复制列表在emit函数中我们先复制一份槽函数列表然后在无锁状态下进行调用。这样做有两个好处一是缩短持有锁的时间提高并发性能二是避免槽函数内部再次调用connect或disconnect修改原列表导致的死锁或迭代器失效。线程安全使用std::mutex保护对slots_的所有写操作和读操作复制列表时的读。这是一个保守但安全的策略。注意这个基础版本尚未解决“对象生命周期”问题。如果槽是一个指向已销毁对象的成员函数调用emit会导致崩溃。接下来我们就解决这个核心痛点。4. 进阶实现支持自动生命周期管理为了安全地连接对象成员函数我们需要引入std::shared_ptr和std::weak_ptr。我们将提供一个更方便的connect重载版本。首先定义一个辅助工具用于将成员函数和对象指针绑定成一个std::function。// 工具函数将成员函数绑定到weak_ptr管理的对象上 template typename T, typename... Args std::functionvoid(Args...) bind_to_weak( std::weak_ptrT weak_obj, void (T::*member_func)(Args...)) { // 返回一个lambda该lambda在调用时尝试锁定weak_ptr return [weak_obj, member_func](Args... args) { if (auto shared_obj weak_obj.lock()) { // 对象还存在安全调用成员函数 ((*shared_obj).*member_func)(args...); } // 如果对象已销毁则什么也不做连接应在信号端被清理见下文优化 }; }然后在Signal类中添加对应的connect成员函数template typename... Args class Signal : public SignalBase { // ... 其他成员 ... public: // 连接一个由shared_ptr管理对象的成员函数 template typename T Connection connect(std::shared_ptrT obj_ptr, void (T::*member_func)(Args...)) { // 使用weak_ptr避免延长对象生命周期 std::weak_ptrT weak_obj obj_ptr; auto bound_func bind_to_weak(weak_obj, member_func); // 这里需要一个技巧当weak_ptr失效时我们希望自动从slots_中移除这个连接。 // 我们可以包装一层在调用时检查。 auto tracked_slot [bound_func, weak_obj, this, member_func](Args... args) { if (weak_obj.expired()) { // 对象已销毁这个连接应该被移除。 // 但是我们现在在槽函数内部没有ID信息。需要更复杂的设计。 // 方案A在bind_to_weak返回的lambda里捕获连接ID复杂。 // 方案B定期清理不实时。 // 我们先实现一个简化版不自动清理只是调用时跳过。 return; } bound_func(args...); }; return connect(std::move(tracked_slot)); } };上面的简化版只是避免了崩溃但失效的连接会一直留在列表里造成内存泄漏和无效遍历。一个更完善的方案是让Signal能感知weak_ptr的失效并自动清理。这通常需要让ConnectionSlot不仅存储std::function还存储一个用于检查有效性的函数。我们重新设计ConnectionSlottemplate typename... Args class Signal { private: using SlotType std::functionvoid(Args...); using Validator std::functionbool(); // 检查连接是否还有效 struct TrackedSlot { uint64_t id; SlotType slot; Validator validator; // 可能为空对于普通函数连接 TrackedSlot(uint64_t i, SlotType s, Validator v) : id(i), slot(std::move(s)), validator(std::move(v)) {} bool isValid() const { // 如果没有验证器默认有效 if (!validator) return true; return validator(); } }; std::vectorTrackedSlot tracked_slots_; // ... mutex, next_id_ 等 ... public: // ... 构造函数等 ... // 通用的连接函数 template typename Callable Connection connect(Callable func) { return connect_impl(std::forwardCallable(func), nullptr); } // 连接shared_ptr对象成员函数的特化版本 template typename T Connection connect(std::shared_ptrT obj_ptr, void (T::*member_func)(Args...)) { std::weak_ptrT weak_obj obj_ptr; // 创建验证器检查weak_ptr是否还有效 auto validator [weak_obj]() - bool { return !weak_obj.expired(); }; // 创建槽函数通过weak_ptr调用 auto slot [weak_obj, member_func](Args... args) { if (auto shared_obj weak_obj.lock()) { ((*shared_obj).*member_func)(args...); } }; return connect_impl(std::move(slot), std::move(validator)); } private: // 内部实现连接的核心函数 template typename Callable Connection connect_impl(Callable func, Validator validator) { std::lock_guardstd::mutex lock(mutex_); uint64_t new_id next_id_; tracked_slots_.emplace_back(new_id, std::forwardCallable(func), std::forwardValidator(validator)); auto disconnector [this](uint64_t id) - bool { return this-disconnect(id); }; return Connection(this, new_id, std::move(disconnector)); } void emit(Args... args) { std::vectorSlotType valid_slots; { std::lock_guardstd::mutex lock(mutex_); // 在遍历时检查有效性并移除失效的连接 for (auto it tracked_slots_.begin(); it ! tracked_slots_.end(); ) { if (it-isValid()) { valid_slots.push_back(it-slot); it; } else { // 连接已失效例如对象已销毁移除之 it tracked_slots_.erase(it); } } } for (auto slot : valid_slots) { slot(args...); } } // ... disconnect, clear 等 ... };进阶版核心改进TrackedSlot结构体除了槽函数和ID新增一个validator函数。对于绑定到weak_ptr的成员函数槽validator用于检查目标对象是否存活。emit时自动清理在每次发射信号、复制有效槽函数列表之前先遍历tracked_slots_调用每个槽的isValid()方法。如果返回false意味着目标对象已销毁则立即将该连接从列表中移除。这样实现了失效连接的自动垃圾回收。双重重载connect提供了两种连接方式。通用的connect(Callable)用于函数、静态函数、lambda等特化的connect(shared_ptr, member_pointer)用于对象成员函数自动处理生命周期。实操心得自动清理虽然方便但增加了emit的开销每次都要检查有效性。在信号发射极其频繁且对性能要求苛刻的场景下可以提供一个“手动管理”模式或者让用户自己负责在对象析构时断开连接。但在绝大多数应用场景下这种“发射时清理”的代价是可以接受的它换来了巨大的安全性提升。5. 性能优化与高级特性探讨基础版本和生命周期管理版本已经可以满足大部分需求。但对于高性能场景我们还可以进行更多优化和功能扩展。5.1 线程安全策略优化我们之前使用了单一的std::mutex这在多线程竞争激烈时可能成为瓶颈。优化思路读写锁Read-Write Lockemit读操作的频率远高于connect/disconnect写操作时可以使用std::shared_mutex(C17)。emit获取共享锁允许多个线程同时读修改操作获取独占锁。无锁队列将槽函数的调用转化为任务推入一个无锁队列。由一个或多个工作线程消费队列并执行槽函数。这完全解耦了信号发射线程和槽函数执行线程适用于UI主线程与后台线程通信等场景。但实现复杂度显著增加。线程局部存储如果能确定某些信号只会在特定线程被连接和发射可以完全去掉锁换取极致性能。5.2 连接类型与一次性连接Qt的信号槽支持多种连接类型例如直接连接DirectConnection在发射信号的线程立即调用槽函数我们目前的实现就是这种。队列连接QueuedConnection将调用请求封装成事件放入接收者对象所在线程的事件队列由该线程稍后处理。这对于跨线程通信非常有用。实现队列连接需要信号系统知道“接收者对象”和其所属的“事件循环”。在纯C中我们可以要求槽函数接收者继承自一个特定的Object基类该类拥有一个postEvent虚方法。或者更通用地我们可以让connect时传入一个Executor执行器指定槽函数在哪个上下文如特定线程池、事件循环中被调用。此外我们还可以实现“一次性连接”SingleShot Connection即槽函数在被调用一次后自动断开。这可以通过在ConnectionSlot中添加一个single_shot标志并在emit调用后检查并移除来实现。5.3 信号与信号的连接有时我们希望一个信号发射时自动触发另一个信号。这可以通过连接一个Lambda表达式来实现该Lambda内部调用另一个信号的emit方法。我们的框架天然支持这种用法。Signalint signalA; Signalstd::string signalB; // 当signalA发射时也发射signalB附带转换 auto conn signalA.connect([signalB](int x) { signalB.emit(Value is: std::to_string(x)); });5.4 性能基准与容器选择我们使用std::vector存储连接。它的优点是内存连续遍历速度快。缺点是中间插入删除disconnect是O(n)操作。如果连接数量巨大且频繁断开std::list或std::unordered_map以连接ID为键可能更合适。但通常连接数不会太多几十到几百个vector在遍历emit时的性能优势更重要。可以通过性能测试Profiling来根据实际使用情况做选择。6. 完整示例与使用指南让我们用一个完整的例子来演示如何使用这个自制的信号槽库。#include signal_slot.h // 假设我们的实现放在这个头文件里 #include iostream #include memory using namespace my_signal_slot; // 1. 一个普通的类 class Button { public: Signal clicked; // 无参信号 Signalint, int moved; // 带参数的信号 }; // 2. 一个由shared_ptr管理的接收者类 class Logger : public std::enable_shared_from_thisLogger { public: void onButtonClicked() { std::cout [Logger] Button clicked! std::endl; } void onButtonMoved(int x, int y) { std::cout [Logger] Button moved to ( x , y ) std::endl; } }; // 3. 一个全局函数 void globalOnMoved(int x, int y) { std::cout [Global] Moved: x , y std::endl; } int main() { Button button; auto logger std::make_sharedLogger(); // 连接方式1连接成员函数自动生命周期管理 auto conn1 button.clicked.connect(logger, Logger::onButtonClicked); auto conn2 button.moved.connect(logger, Logger::onButtonMoved); // 连接方式2连接Lambda表达式 int lambdaCallCount 0; auto conn3 button.clicked.connect([lambdaCallCount]() { std::cout [Lambda] Clicked! Count: lambdaCallCount std::endl; }); // 连接方式3连接全局函数 auto conn4 button.moved.connect(globalOnMoved); // 发射信号 std::cout --- Emitting clicked --- std::endl; button.clicked.emit(); // 会触发logger-onButtonClicked 和 lambda std::cout \n--- Emitting moved --- std::endl; button.moved.emit(100, 200); // 会触发logger-onButtonMoved 和 globalOnMoved // 手动断开一个连接 std::cout \n--- Disconnecting lambda --- std::endl; conn3.disconnect(); std::cout --- Emitting clicked again --- std::endl; button.clicked.emit(); // 现在只会触发logger-onButtonClicked // 测试对象生命周期销毁logger std::cout \n--- Destroying logger --- std::endl; logger.reset(); // 释放logger对象 std::cout --- Emitting signals after logger destroyed --- std::endl; button.clicked.emit(); // 无效连接会在emit时被自动清理无输出 button.moved.emit(300, 400); // 同上globalOnMoved仍会被调用 // conn1和conn2在析构时会自动尝试断开但连接早已被自动清理 return 0; }使用指南与注意事项头文件依赖确保你的项目支持C11及以上标准因为大量使用了std::function,std::shared_ptr,std::weak_ptr,std::mutex, lambda等特性。连接管理尽量利用Connection对象的RAII特性。将其作为类的成员变量在类析构时Connection析构会自动断开连接。避免将Connection对象存放在可能比信号对象生命周期更短的地方。线程安全我们的基础实现是线程安全的但注意槽函数本身的线程安全性。如果槽函数访问共享数据需要自行加锁。性能考量emit调用会触发所有槽函数。如果槽函数执行很慢会阻塞发射线程。连接的自动清理发生在emit时。如果某个信号永远不再被发射那么连接到它的、已失效的对象所占用的连接资源就永远不会被释放。在极端情况下可以考虑定期调用一个cleanup()方法如果有暴露的话或者使用std::weak_ptr的另一种模式在weak_ptr过期时通过回调通知信号端实现更复杂。信号对象生命周期信号对象本身最好由比所有连接对象生命周期更长的对象持有例如主应用程序对象。如果信号对象先于连接对象被销毁那么持有的Connection对象在析构时调用disconnect将会访问已释放的内存导致未定义行为。一种更健壮的设计是让Connection持有对信号对象的weak_ptr。7. 与现有库的对比及选择建议自己实现信号槽机制是一个很好的学习项目能让你深入理解回调、事件系统、模板编程和资源管理。但在实际项目中你可能需要考虑使用成熟的开源库Boost.Signals2功能极其强大支持线程安全、连接分组、合并返回值等高级特性是工业级的选择。但Boost库体积较大。sigslot一个经典的、轻量级的C信号槽库代码简洁但可能不如现代C库功能丰富。FastDelegate或各种Event库针对特定场景做了高度优化。选择建议学习/轻量级项目自己实现或使用我们上面构建的迷你版本完全可控依赖为零。需要复杂特性如跨线程队列、连接类型使用Boost.Signals2。极致性能简单需求可以考虑sigslot或类似轻量级库。嵌入式或受限环境自己实现一个裁剪版只保留最核心的功能避免动态内存分配可以使用固定容量数组存储连接。实现一个纯C的信号与槽机制就像打造一把属于自己的瑞士军刀。你清楚地知道每一个零件的用途和极限。从最基础的函数容器到支持生命周期的自动管理再到思考线程安全和性能优化这个过程本身就是对C面向对象、模板、智能指针和并发编程的一次深度实践。最终得到的不仅是一个工具更是一套适用于特定场景的、高度定制化的解决方案。当你下次再看到Qt的SIGNAL和SLOT宏时你就能会心一笑因为你知道在标准的C世界里你同样可以构建出如此优雅的通信桥梁。