1. 项目概述为什么我们需要自己实现信号与槽在C GUI编程尤其是Qt框架的日常开发中信号与槽机制是我们再熟悉不过的“基础设施”。它优雅地解决了对象间通信的问题让一个按钮的点击能自动触发一个窗口的关闭或者让一个进度条的值变化实时更新到标签上。然而当我们习惯了Qt提供的QObject::connect、emit、signals、slots这些关键字时是否曾想过如果脱离Qt我们能否在纯C项目中复现这种松耦合、类型安全的通信机制这就是“C信号与槽机制自实现”项目的核心价值。这个项目绝不仅仅是“重新发明轮子”。它的意义在于深度理解。通过亲手构建一套信号与槽系统你将彻底吃透其背后的设计思想元对象系统如何工作连接Connection如何存储和管理线程安全如何保证类型安全又是如何实现的这远比单纯调用connect函数要深刻得多。对于希望深入C元编程、设计模式以及高性能事件驱动架构的开发者来说这是一个绝佳的练手项目。它剥离了Qt的庞大生态让你聚焦于核心机制最终产出的将是一个轻量级、可嵌入任何C项目的通用事件通信库。2. 核心设计思路从Qt中汲取灵感Qt的信号与槽机制之所以强大在于它结合了元对象系统Meta-Object System和模板元编程。我们的自实现版本无法也不必要完全复刻Qt的MOC元对象编译器预处理器但可以借鉴其核心思想用现代CC11/14/17的特性来实现一个简化但功能完备的版本。2.1 核心目标拆解我们的自实现信号与槽系统需要满足以下几个核心目标类型安全连接信号和槽时编译器应能检查参数类型是否匹配避免运行时错误。这是优于传统函数指针和std::functionvoid(void*)方案的关键。松耦合信号的发送者完全不知道也不关心有哪些槽函数接收它。槽函数也不知道信号来自何处。一对多与多对一一个信号可以连接多个槽一个槽也可以接收多个信号。自动连接管理当信号发送者或槽函数所属对象被销毁时相关的连接应能自动断开防止悬空调用。线程安全可选但重要支持跨线程的信号发射与槽执行这是现代应用的基础需求。2.2 关键技术选型与原理为了实现上述目标我们需要组合运用多种C高级特性std::function与std::bind/ Lambda这是实现可调用对象封装的基础。一个“槽”本质上就是一个std::function对象。信号则需要维护一个std::function的列表。变参模板Variadic Templates这是实现类型安全的核心。我们需要定义一个模板类Signal其模板参数就是信号的参数列表如Signalint, std::string。这样在编译期就能确定信号和槽的函数签名。智能指针与弱引用为了实现自动连接管理我们需要跟踪信号源和槽所属对象的生命周期。通常使用std::shared_ptr和std::weak_ptr。连接Connection可以持有槽对象所属实例的std::weak_ptr。当尝试调用槽时先检查weak_ptr是否有效对象是否存活。函数签名擦除与类型擦除虽然我们使用模板来保证类型安全但在存储多个不同类型的槽时例如一个Signalint连接了多个不同的函数对象我们需要一种统一的方式来存储它们。这可以通过定义一个非模板的基类ISlot然后由模板派生类SlotImpl来保存具体的std::function和类型信息。3. 核心类设计与实现细节接下来我们一步步构建核心类。为了清晰我们将创建一个命名空间MyQt。3.1 连接句柄Connection与可调用对象封装SlotBase首先我们需要一个代表“连接”的轻量级对象用于在需要时断开连接。// connection.h #pragma once #include memory namespace MyQt { class Connection { public: Connection() default; virtual ~Connection() default; // 禁用拷贝允许移动 Connection(const Connection) delete; Connection operator(const Connection) delete; Connection(Connection) default; Connection operator(Connection) default; // 断开此连接 virtual void disconnect() 0; // 检查连接是否仍有效例如对应的槽对象是否已被销毁 virtual bool isConnected() const 0; }; using ConnectionPtr std::shared_ptrConnection; }然后定义一个槽的抽象基类。它不关心具体的函数签名只负责提供调用和生命周期查询接口。// slot_base.h #pragma once #include memory #include “connection.h” namespace MyQt { class Object; // 前向声明代表拥有槽的对象 class SlotBase : public std::enable_shared_from_thisSlotBase { public: virtual ~SlotBase() default; // 获取与此槽关联的对象用于生命周期管理 virtual std::weak_ptrObject getOwner() const 0; // 一个标记接口具体调用由派生类实现 }; using SlotBasePtr std::shared_ptrSlotBase; }3.2 信号类Signal的实现这是最核心的部分。我们将实现一个模板类Signal它管理一个SlotBase的列表并提供一个emit或operator()方法来触发所有已连接的槽。// signal.h #pragma once #include vector #include memory #include algorithm #include “slot_base.h” #include “connection.h” namespace MyQt { // 前向声明 class Object; template typename... Args class Signal { public: Signal() default; ~Signal() { // 信号析构时应断开所有连接但通常由Connection对象自己管理 } // 连接一个槽。槽可以是任何可调用对象函数、lambda、成员函数等 template typename Func ConnectionPtr connect(Func func) { // 这里需要一个机制将func包装成SlotBasePtr // 我们稍后实现一个SlotImpl auto slot std::make_sharedSlotImplFunc(std::forwardFunc(func)); slots_.push_back(slot); // 创建一个Connection对象其disconnect()操作需要能从slots_中移除自身 auto conn std::make_sharedConnectionImpl(this, slot); slot-setConnection(conn); return conn; } // 连接一个对象的成员函数这是更常见的用法 template typename T, typename Method ConnectionPtr connect(std::shared_ptrT obj, Method method) { // 使用std::bind或lambda将成员函数绑定到对象实例 auto func [obj, method](Args... args) { // 调用成员函数 // 注意这里需要检查obj是否有效但lambda捕获的shared_ptr保证了对象存在性 // 更安全的做法是捕获weak_ptr在调用前lock() }; return connect(std::move(func)); } // 发射信号触发所有连接的槽 void emit(Args... args) const { // 注意在遍历过程中槽的调用可能会修改slots_列表例如断开连接 // 因此需要先复制一份列表或者使用更安全的方式遍历 auto slotsCopy slots_; for (const auto slot : slotsCopy) { // 这里需要将slot向下转型为具体的SlotImpl...并调用 // 我们先调用一个虚函数接口 if (auto slotPtr slot.lock()) { // 动态调用需要类型擦除和参数转发这是难点 // 我们将在SlotImpl中实现一个纯虚函数 call(Args...) // slotPtr-call(args...); } } } // 仿函数形式发射信号更符合C习惯 void operator()(Args... args) const { emit(std::forwardArgs(args)...); } // 断开所有连接 void disconnectAll() { slots_.clear(); } private: // 存储槽的弱引用避免槽持有信号的引用导致循环引用 // 实际上通常存储的是shared_ptr因为Connection需要管理生命周期。 // 但为了自动清理失效连接更佳实践是存储weak_ptrSlotBase // 并在emit时过滤掉已失效的。 std::vectorstd::weak_ptrSlotBase slots_; // Connection的实现类需要访问Signal的私有成员以从slots_中移除自己 class ConnectionImpl : public Connection { public: ConnectionImpl(Signal* sig, std::shared_ptrSlotBase slot) : signal_(sig), slot_(slot) {} void disconnect() override { if (signal_) { signal_-removeSlot(slot_); signal_ nullptr; slot_.reset(); } } bool isConnected() const override { return signal_ ! nullptr !slot_.expired(); } private: Signal* signal_; std::weak_ptrSlotBase slot_; }; // 从列表中移除特定的槽 void removeSlot(const std::shared_ptrSlotBase slot) { slots_.erase( std::remove_if(slots_.begin(), slots_.end(), [slot](const std::weak_ptrSlotBase wp) { auto sp wp.lock(); return !sp || sp slot; }), slots_.end()); } }; }上面的代码勾勒了框架但最关键的类型安全的调用slotPtr-call(args...)还没有实现。这需要我们在SlotBase中引入一个带变参的纯虚函数但C不允许虚函数模板。因此我们需要一种类型擦除技术。3.3 类型擦除的槽实现SlotImpl为了解决上述问题我们引入一个中间层SlotInvoker。Signal在emit时并不直接调用SlotBase而是通过一个SlotInvoker来转发参数这个Invoker知道具体的参数类型。// slot_impl.h #pragma once #include “slot_base.h” #include functional #include memory namespace MyQt { // 一个辅助的调用器基类它知道如何用一组特定的参数调用某个东西 template typename... Args class SlotInvokerBase { public: virtual ~SlotInvokerBase() default; virtual void invoke(Args... args) 0; }; // 具体的调用器持有一个std::function template typename Func, typename... Args class SlotInvoker : public SlotInvokerBaseArgs... { public: explicit SlotInvoker(Func func) : func_(std::move(func)) {} void invoke(Args... args) override { func_(std::forwardArgs(args)...); } private: Func func_; }; // 槽的实现类它组合了一个SlotInvoker class SlotImpl final : public SlotBase { public: template typename Func, typename... Args SlotImpl(Func func) { // 关键创建一个知道参数类型(Args...)的invoker // 但是SlotImpl本身并不知道Args...是什么它需要被存储为类型擦除的形式。 // 这引出了下一个设计我们需要一个非模板的基类来存储invoker但invoker本身是模板化的。 // 一个常见的做法是使用 std::functionvoid(Args...)但Args在SlotImpl里是未知的。 // 因此我们需要将invoker的存储也类型擦除。 } // ... 其他接口 }; }看来我们遇到了一个核心挑战如何在非模板的SlotBase中存储一个能够以类型安全的方式调用任意函数签名的调用器解决方案是使用双重类型擦除。我们定义一个新的基类AnyInvoker它只有一个纯虚函数invoke(void*)或类似的泛型接口。然后由TypedInvokerArgs...继承它并在内部将void*参数转换回具体的Args...并调用真正的std::function。这需要一些技巧因为参数打包和拆包需要内存操作。一个更简洁、利用C17std::any和std::apply的现代实现思路如下SignalArgs...内部存储一个std::vectorstd::functionvoid(Args...)。这直接保证了类型安全。当连接一个泛型可调用对象时使用std::functionvoid(Args...)进行包装。如果类型不匹配会在编译时std::function构造时报错。为了支持成员函数和自动生命周期管理connect函数需要更智能的包装。让我们调整设计采用更直接的方案最终版Signal核心实现// signal.h (修订版) #pragma once #include functional #include vector #include memory #include algorithm #include mutex // 用于线程安全 namespace MyQt { template typename... Args class Signal { using SlotFunction std::functionvoid(Args...); public: Signal() default; // 连接一个自由函数、静态函数或lambda template typename Func std::shared_ptrvoid connect(Func func) { std::lock_guardstd::mutex lock(mutex_); slots_.emplace_back(std::forwardFunc(func)); // 返回一个标记用于断开连接。这里简单返回slot的迭代器或ID。 // 更健壮的做法是返回一个Connection对象。 // 我们返回一个指向该function在vector中位置的“令牌”。 // 但由于vector可能重分配这不是好主意。改用shared_ptrint作为令牌。 auto token std::make_sharedint(slots_.size() - 1); // 我们需要建立token到slot的映射以便disconnect。这里简化处理在disconnect时遍历查找。 // 更好的结构是 std::list std::shared_ptr 直接管理。 return token; } // 连接一个对象的成员函数并自动管理对象生命周期 template typename T, typename... U std::shared_ptrvoid connect(std::shared_ptrT obj, void (T::*method)(U...)) { // 静态断言检查参数包U...是否与Args...兼容相同或可转换 // 这里简化假设 U... 就是 Args... static_assert(std::is_samestd::tupleArgs..., std::tupleU...::value, “Signal and slot argument types must match exactly for member functions.”); // 捕获对象的weak_ptr避免循环引用并在调用前检查对象是否存活 std::weak_ptrT weakObj obj; auto slotFunc [weakObj, method](Args... args) { if (auto sharedObj weakObj.lock()) { // 调用成员函数 (sharedObj.get()-*method)(args...); } // 如果对象已销毁则什么也不做相当于自动断开连接 }; return connect(std::move(slotFunc)); } // 发射信号 void emit(Args... args) const { std::lock_guardstd::mutex lock(mutex_); // 遍历并调用所有槽函数 for (const auto slot : slots_) { if (slot) { // 检查function是否有效可能被disconnect置空 slot(args...); } } // 清理无效的slot被置空的 // 注意在const成员函数中修改mutable成员是允许的 const_castSignal*(this)-cleanupSlots(); } void operator()(Args... args) const { emit(std::forwardArgs(args)...); } // 断开连接通过token void disconnect(const std::shared_ptrvoid token) { std::lock_guardstd::mutex lock(mutex_); // 我们需要找到这个token对应的slot。由于我们之前简化了没有建立映射。 // 因此这个简化版的disconnect功能有限。一个完整的实现需要Connection类来维护反向引用。 // 这里我们采用另一种常见策略将slot替换为一个空function标记为失效。 // token可以是一个指向std::function的shared_ptr的weak_ptr。 } // 断开所有连接 void disconnectAll() { std::lock_guardstd::mutex lock(mutex_); slots_.clear(); } private: mutable std::mutex mutex_; // mutable允许在const emit中加锁 std::vectorSlotFunction slots_; void cleanupSlots() { slots_.erase( std::remove_if(slots_.begin(), slots_.end(), [](const SlotFunction sf) { return !sf; }), // 移除无效的function slots_.end()); } }; } // namespace MyQt这个简化版本已经可以实现核心功能但缺少健壮的Connection对象来管理生命周期。让我们补全它。4. 完整实现带Connection管理的线程安全信号我们将实现一个更完整的版本包含ScopedConnectionRAII连接和线程安全。4.1 Connection对象的完整实现// connection.h #pragma once #include memory #include functional namespace MyQt { class Connection { public: using Disconnector std::functionvoid(); Connection() default; explicit Connection(Disconnector disconnector) : disconnector_(std::move(disconnector)) {} virtual ~Connection() { disconnect(); } void disconnect() { if (disconnector_) { disconnector_(); disconnector_ nullptr; // 防止重复调用 } connected_ false; } bool isConnected() const { return connected_; } // 允许移动 Connection(Connection other) noexcept : disconnector_(std::move(other.disconnector_)), connected_(other.connected_) { other.connected_ false; } Connection operator(Connection other) noexcept { if (this ! other) { disconnect(); disconnector_ std::move(other.disconnector_); connected_ other.connected_; other.connected_ false; } return *this; } // 禁用拷贝 Connection(const Connection) delete; Connection operator(const Connection) delete; private: Disconnector disconnector_; bool connected_{true}; }; // RAII风格的连接超出作用域自动断开 class ScopedConnection { public: ScopedConnection() default; explicit ScopedConnection(Connection conn) : conn_(std::move(conn)) {} ~ScopedConnection() { if (conn_.isConnected()) conn_.disconnect(); } // 可以接管一个Connection的所有权 ScopedConnection operator(Connection conn) { if (conn_.isConnected()) conn_.disconnect(); conn_ std::move(conn); return *this; } void disconnect() { conn_.disconnect(); } bool isConnected() const { return conn_.isConnected(); } private: Connection conn_; }; } // namespace MyQt4.2 最终版Signal实现带Connection管理// signal.h #pragma once #include “connection.h” #include functional #include vector #include memory #include mutex #include algorithm namespace MyQt { template typename... Args class Signal { public: using SlotType std::functionvoid(Args...); Signal() default; ~Signal() { // 信号析构时断开所有连接 disconnectAll(); } // 连接一个可调用对象返回Connection用于管理生命周期 template typename Func Connection connect(Func func) { std::lock_guardstd::mutex lock(mutex_); // 分配一个唯一的ID可以用slots_的索引但删除后索引会变所以用size_t递增 size_t id nextId_; slots_.emplace_back(SlotInfo{id, std::make_sharedSlotType(std::forwardFunc(func))}); // 返回一个Connection其disconnector会从slots_中移除这个slot return Connection([this, id]() { std::lock_guardstd::mutex lock(mutex_); auto it std::find_if(slots_.begin(), slots_.end(), [id](const SlotInfo info) { return info.id id; }); if (it ! slots_.end()) { // 不直接erase而是标记为nullptr在emit时清理避免迭代器失效 it-slot.reset(); } }); } // 连接成员函数支持智能指针管理对象生命周期 template typename T, typename... U Connection connect(std::shared_ptrT obj, void (T::*method)(U...)) { // 使用std::is_invocable进行更严格的编译期检查 static_assert(std::is_invocabledecltype(method), T*, U...::value, “Member function signature mismatch.”); // 确保U...可转换为Args... // 这里简化假设完全匹配。实际可以使用更复杂的类型traits。 std::weak_ptrT weakObj obj; auto slotFunc [weakObj, method](Args... args) { if (auto sharedObj weakObj.lock()) { // 使用std::invoke以统一语法调用成员函数 std::invoke(method, sharedObj.get(), args...); } }; return connect(std::move(slotFunc)); } // 发射信号 void emit(Args... args) const { std::vectorstd::shared_ptrSlotType validSlots; { std::lock_guardstd::mutex lock(mutex_); // 收集所有有效的slot for (const auto info : slots_) { if (info.slot *info.slot) { validSlots.push_back(info.slot); } } // 清理无效的slot被disconnect标记的 cleanupSlots(); } // 在锁外调用槽函数避免死锁槽函数内部可能再次操作本信号 for (const auto slot : validSlots) { (*slot)(args...); } } void operator()(Args... args) const { emit(std::forwardArgs(args)...); } // 断开所有连接 void disconnectAll() { std::lock_guardstd::mutex lock(mutex_); slots_.clear(); nextId_ 0; } // 获取当前连接数主要用于调试 size_t connectionCount() const { std::lock_guardstd::mutex lock(mutex_); return std::count_if(slots_.begin(), slots_.end(), [](const SlotInfo info) { return info.slot *info.slot; }); } private: struct SlotInfo { size_t id; std::shared_ptrSlotType slot; // 使用shared_ptr便于外部置空来标记断开 }; mutable std::mutex mutex_; std::vectorSlotInfo slots_; size_t nextId_{0}; void cleanupSlots() { slots_.erase( std::remove_if(slots_.begin(), slots_.end(), [](const SlotInfo info) { return !info.slot || !(*info.slot); }), slots_.end()); } }; } // namespace MyQt4.3 使用示例现在我们可以像使用Qt信号槽一样使用我们的自实现版本了。#include “signal.h” #include iostream #include memory class Button : public std::enable_shared_from_thisButton { public: MyQt::Signal clicked; // 无参信号 void simulateClick() { std::cout “Button clicked.” std::endl; clicked.emit(); // 发射信号 } }; class Window : public std::enable_shared_from_thisWindow { public: void onButtonClicked() { std::cout “Window handles button click.” std::endl; } void show() { std::cout “Window shown.” std::endl; } }; int main() { auto button std::make_sharedButton(); auto window std::make_sharedWindow(); // 连接信号和槽成员函数 auto conn1 button-clicked.connect(window, Window::onButtonClicked); // 连接信号和槽Lambda表达式 auto conn2 button-clicked.connect([]() { std::cout “Lambda slot called.” std::endl; }); // 模拟点击 button-simulateClick(); // 输出: // Button clicked. // Window handles button click. // Lambda slot called. // 断开一个连接 conn2.disconnect(); button-simulateClick(); // 输出: // Button clicked. // Window handles button click. // 使用ScopedConnection超出作用域自动断开 { MyQt::ScopedConnection scopedConn button-clicked.connect([]() { std::cout “This connection will auto-dispose.” std::endl; }); button-simulateClick(); // 会调用这个临时槽 } // scopedConn析构自动断开 button-simulateClick(); // 不会调用已断开的槽 // 信号析构时所有连接自动断开 return 0; }5. 关键问题与优化策略在实际使用中你会遇到一些Qt已经解决但我们需要自己处理的问题。5.1 线程安全与事件队列我们的基础实现通过std::mutex保证了slots_容器的线程安全但emit是同步调用。在GUI或异步编程中我们常常需要跨线程发射信号并让槽在目标对象所在的线程例如主线程中执行。这需要引入事件队列和线程上下文的概念。解决方案可以扩展Signal在connect时增加一个参数指定连接类型如DirectConnection同步QueuedConnection异步。对于队列连接emit时将调用请求包装成一个std::function投递到目标线程的事件队列中。这需要与一个全局或线程局部的事件循环EventLoop配合。enum class ConnectionType { Direct, // 直接调用发射线程 Queued // 队列调用目标对象所在线程 }; template typename... Args Connection SignalArgs...::connect(std::shared_ptrObject context, Func func, ConnectionType type) { if (type ConnectionType::Queued) { // 包装func使其通过context对象的事件队列调用 auto queuedFunc [context, func std::forwardFunc(func)](Args... args) { if (auto ctx context.lock()) { ctx-postEvent([func, args...]() { func(args...); }); } }; return connect(std::move(queuedFunc)); } return connect(std::forwardFunc(func)); }5.2 性能考量锁粒度我们的实现在每次connect、emit、disconnect时都锁住了整个slots_向量。在高频信号场景下这可能成为瓶颈。可以考虑使用读写锁std::shared_mutexC17或更细粒度的数据结构如每个槽自带一个原子标志位来表示是否有效。参数传递emit时参数被复制给每个槽。如果参数是大型对象成本很高。可以考虑使用完美转发和引用但要注意生命周期。一种常见模式是信号参数使用const T或值语义由调用者保证参数在槽执行期间有效。动态分配每次connect都涉及动态内存分配std::function、SlotInfo。对于性能极其苛刻的场景可以考虑使用小型缓冲区优化SBO的function实现或对象池。5.3 与Qt信号的差异我们的自实现与Qt信号相比主要简化或缺失了以下特性元对象系统我们没有实现moc那样的预编译步骤因此不支持signals、slots、emit关键字。我们使用普通的成员变量Signal对象和函数调用。自动连接语法Qt的connect可以使用字符串形式的信号和槽名在运行时通过元对象系统查找。我们只支持编译时类型安全的函数指针/std::function连接。信号重载Qt可以区分重载的信号如QComboBox::currentIndexChanged(int)和QComboBox::currentIndexChanged(const QString)。我们的模板类SignalArgs...本身通过不同的参数列表就是不同的类型天然支持“重载”但需要定义多个不同签名的Signal成员变量。信号连接信号我们的Signal::emit返回void而Qt的信号可以连接另一个信号。要实现这个可以让emit返回一个代表此次发射的可调用对象或者让Signal本身也是一个可连接的对象。5.4 一个实用的改进Signal作为成员变量为了让类定义更清晰可以定义一个宏来简化信号的声明类似于Qt的Q_SIGNALS但功能弱很多。// my_signal_macros.h #pragma once #define MY_SIGNAL(name, ...) \ public: \ MyQt::Signal__VA_ARGS__ name; \ private: // 使用示例 class MyWidget { MY_SIGNAL(valueChanged, int, const std::string) MY_SIGNAL(clicked) public: void setValue(int v, const std::string unit) { valueChanged.emit(v, unit); } };这个宏只是语法糖将信号声明为公有成员变量并确保其后的成员是私有的模仿Qt的signals:区段效果。实际项目中可根据需要调整。通过这个从零开始实现信号与槽机制的过程你不仅获得了一个可用的轻量级库更重要的是深入理解了观察者模式、C模板元编程、类型擦除、线程安全以及资源生命周期管理等核心概念。这远比仅仅阅读Qt源码或使用Qt来得深刻。下次当你再写下connect时你会对屏幕背后发生的一切了如指掌。