C++信号槽机制实现:从Qt到标准库的观察者模式实践
1. 项目概述为什么要在标准C中再造Qt的信号与槽在C GUI开发尤其是Qt框架的使用中信号与槽机制几乎是每个开发者绕不开的核心概念。它优雅地解决了对象间通信的问题实现了松耦合的组件交互。然而很多朋友在深入学习C或者尝试脱离Qt框架构建自己的轻量级库时常常会思考一个问题Qt这套机制背后的原理是什么如果不用Qt我能否用标准CSTL自己实现一套类似的机制这不仅仅是一个“炫技”的练习。理解并实现它能让你深刻领悟到观察者模式的灵活变体、C元编程在运行时类型处理上的边界、以及如何设计一个类型安全且松耦合的事件系统。无论是为了在非Qt项目如游戏引擎、服务器框架、自定义UI库中引入类似的事件驱动架构还是为了彻底吃透C高级特性如可变参数模板、完美转发、std::function、std::bind这个实践都价值连城。今天我就以一个十余年老码农的视角带你从零开始手搓一个简化但核心功能完备的“信号与槽”系统。我们将避开Qt的元对象编译器MOC那种重量级的代码生成方案专注于利用现代CC11/14/17的特性实现一个头文件库风格的解决方案。你会看到我们如何用几百行代码实现信号的发射、槽的连接支持成员函数、lambda、自由函数、自动连接管理甚至线程安全等关键特性。2. 核心设计思路我们究竟要造一个什么样的轮子在动手写代码之前我们必须明确目标。Qt的信号槽非常强大但我们也需要认清现实在没有MOC的情况下完全复刻其所有特性如基于字符串的运行时连接、跨线程的队列化连接是极其复杂甚至不切实际的。因此我们的设计需要做出合理的取舍聚焦于核心体验。2.1 核心需求解析类型安全连接时信号的签名参数类型和顺序必须与槽的签名兼容。这是信号槽机制优于原始函数指针和回调的核心。松耦合发送信号的对象完全不需要知道哪些对象槽会接收并处理它。它们之间仅通过connect函数建立联系。一对多与多对一一个信号可以连接多个槽一个槽也可以被多个信号连接。自动连接管理当发送者或接收者对象被销毁时与之相关的所有连接应该自动、安全地断开避免悬空回调导致的崩溃。灵活的接收者槽不仅可以是类的成员函数还应该支持lambda表达式、自由函数、以及仿函数functor。2.2 技术方案选型与取舍信号如何表示我们不能像Qt那样预声明signals:区域。一个自然的想法是将“信号”定义为一个特殊的成员变量它是一个可以存储多个“调用实体”即槽的容器。当“发射”信号时其实就是遍历这个容器并调用其中所有的实体。这个容器的元素类型必须能存储各种可调用对象std::function是最佳选择。类型安全如何保证std::function本身是类型安全的但我们需要让不同的信号拥有不同的函数签名拥有不同类型的std::function容器。这里就需要用到类模板。我们将设计一个Signal模板类其模板参数就是信号的函数签名如void(int, const std::string)。如何连接成员函数这是难点之一。成员函数指针不能直接转换为std::function因为它隐含了this指针。我们需要一种方式将对象实例和其成员函数“绑定”在一起。std::bind或lambda捕获可以完美解决这个问题。在connect时我们会利用它们创建一个可调用对象其内部持有对象实例的弱引用或共享指针以确保对象存活性。如何实现自动连接管理这是另一个关键挑战也是很多自制信号槽库的缺陷所在。我们需要跟踪每一个连接。一个经典的方案是让接收者对象拥有槽的对象继承自一个公共基类例如Object并在其内部维护一个连接列表。当对象析构时遍历这个列表通知所有相关的信号移除对应的槽。我们将采用一种更轻量、更现代的方法利用std::weak_ptr和std::shared_ptr的智能指针语义结合std::enable_shared_from_this来实现生命周期的自动跟踪和连接的自动清理。线程安全考虑在真正的项目里信号可能在一个线程发射而槽在另一个线程执行。为了简单起见我们初版实现将不保证线程安全并明确告知使用者应在同一线程上下文连接和发射信号。在后续的扩展讨论中我们会探讨如何通过互斥锁std::mutex来增加线程安全支持。明确了这些我们的设计蓝图就清晰了一个模板化的Signal类内部用std::vectorstd::function存储槽一个connect函数负责将各种可调用对象包装并存入容器利用智能指针管理接收者生命周期。3. 基础实现打造核心Signal模板类让我们从最核心的Signal类开始。我们将它设计为一个模板类模板参数Args...代表信号所携带的参数列表。// signal.hpp #pragma once #include functional #include vector #include memory #include algorithm namespace my_signal_slot { // 前向声明连接句柄类用于管理单个连接 class Connection; templatetypename... Args class Signal { // 先声明为private稍后我们会提供公共接口 private: // 核心存储槽的容器。每个槽都是一个接受Args...参数的函数对象。 using SlotType std::functionvoid(Args...); std::vectorSlotType slots_; public: Signal() default; ~Signal() default; // 禁止拷贝和赋值因为拷贝信号会导致槽管理的混乱。通常移动语义是允许的。 Signal(const Signal) delete; Signal operator(const Signal) delete; Signal(Signal) default; Signal operator(Signal) default; // 核心方法1连接一个可调用对象槽 templatetypename Callable Connection connect(Callable func); // 核心方法2发射信号 void emit(Args... args) const { // 注意这里发射时可能会触发槽中删除或添加新槽的操作 // 直接遍历容器可能导致迭代器失效。一个常见的技巧是复制槽列表后遍历副本。 auto slots_copy slots_; for (const auto slot : slots_copy) { if (slot) { // 检查function是否有效可能被disconnect置空 slot(args...); } } } // 提供operator()作为emit的别名更符合习惯 void operator()(Args... args) const { emit(std::forwardArgs(args)...); } // 核心方法3断开所有连接 void disconnectAll() { slots_.clear(); } // 获取当前连接数主要用于调试 std::size_t connectionCount() const { return slots_.size(); } private: // 声明为友元让Connection可以访问Signal的私有成员以执行断开操作 friend class Connection; // 一个内部方法用于根据索引断开特定连接。这将是Connection类的工作。 void disconnect(std::size_t id); }; } // namespace my_signal_slot这个基础框架已经具备了信号的存储和发射能力。但有几个关键问题亟待解决connect方法目前直接存储std::function但我们需要返回一个Connection对象以便后续可以断开这个特定的连接。直接遍历slots_在槽函数修改slots_时比如在槽中disconnect自己会导致未定义行为。我们用了复制容器的方法这在大多数情况下安全但非最优。还没有实现自动的生命周期管理。接下来我们实现Connection类和更健壮的connect逻辑。4. 连接管理实现Connection与健壮的connectConnection对象代表一个具体的信号-槽连接。它应该允许用户手动断开这个连接更重要的是当这个Connection对象被销毁时它应该自动断开对应的连接。这可以通过在Connection内部保存一个指向Signal的弱引用和该连接在Signal容器中的标识如索引来实现。然而直接使用索引在slots_被修改如中间有连接断开时会失效。更稳健的做法是为每个连接分配一个唯一的ID例如递增的整数并在Signal内部使用一个std::unordered_mapConnectionId, SlotType来存储槽。但为了初次实现的简洁性我们采用另一种常见策略让std::function自己管理其有效性。我们在connect时不直接存储传入的std::function而是存储一个包装器。当连接需要断开时我们将对应的std::function置为一个空的可调用对象nullptr。发射信号时跳过这些空对象。让我们先定义Connection类// connection.hpp #pragma once #include memory namespace my_signal_slot { class Connection { public: Connection() default; ~Connection() { disconnect(); // 析构时自动断开 } // 允许移动禁止拷贝一个连接只应有一个所有者 Connection(const Connection) delete; Connection operator(const Connection) delete; Connection(Connection other) noexcept : weakSignal_(std::move(other.weakSignal_)) , slotId_(other.slotId_) { other.slotId_ 0; // 移动后置空原对象的ID } Connection operator(Connection other) noexcept { if (this ! other) { disconnect(); weakSignal_ std::move(other.weakSignal_); slotId_ other.slotId_; other.slotId_ 0; } return *this; } // 手动断开连接 void disconnect(); // 检查连接是否仍然有效信号还存在且连接未断开 bool connected() const; private: // 模板类Signal的前向声明无法满足这里的需求我们需要一个非模板的基类。 // 引入一个抽象接口ISignal让模板类Signal继承它。 struct ISignal { virtual ~ISignal() default; virtual void disconnectSlot(std::size_t id) 0; virtual bool isSlotConnected(std::size_t id) const 0; }; std::weak_ptrISignal weakSignal_; // 弱引用指向信号避免延长信号生命周期 std::size_t slotId_ 0; // 该连接在信号中的唯一标识符 // Signal模板类需要能设置这些私有成员 templatetypename... Args friend class Signal; Connection(std::weak_ptrISignal sig, std::size_t id) : weakSignal_(std::move(sig)), slotId_(id) {} }; } // namespace my_signal_slot现在我们需要修改Signal类让它继承自ISignal并实现基于ID的管理。我们将使用一个std::vectorstd::pairstd::size_t, SlotType来存储槽并为每个新连接分配一个递增的ID。// signal.hpp (更新版) #pragma once #include functional #include vector #include memory #include algorithm #include cstddef // for std::size_t #include “connection.hpp” // 包含Connection的定义 namespace my_signal_slot { // 先定义ISignal接口 struct ISignal { virtual ~ISignal() default; virtual void disconnectSlot(std::size_t id) 0; virtual bool isSlotConnected(std::size_t id) const 0; }; templatetypename... Args class Signal final : public ISignal, public std::enable_shared_from_thisSignalArgs... { private: using SlotType std::functionvoid(Args...); using SlotContainer std::vectorstd::pairstd::size_t, SlotType; SlotContainer slots_; std::size_t nextId_ 1; // 从1开始0表示无效ID // 注意此实现非线程安全。多线程环境下访问slots_和nextId_需要加锁。 public: using Ptr std::shared_ptrSignalArgs...; static Ptr create() { return std::make_sharedSignal(); } Signal() default; ~Signal() override default; // 连接一个可调用对象返回一个Connection对象用于管理此连接 templatetypename Callable Connection connect(Callable func) { std::size_t id nextId_; slots_.emplace_back(id, std::forwardCallable(func)); // 返回一个Connection它持有此Signal的弱引用和分配的ID // 注意我们需要将this转换为shared_ptr。这正是继承enable_shared_from_this的原因。 return Connection(this-shared_from_this(), id); } // 发射信号 void emit(Args... args) const { // 遍历并调用所有有效的槽。由于槽可能在其执行过程中断开自身或其他连接 // 我们仍然采用复制ID-函数对列表的策略但这次只复制必要信息。 auto slots_copy slots_; // 这里复制了整个容器包含function对象。 for (const auto pair : slots_copy) { const auto [id, slot] pair; if (slot) { slot(args...); } } } void operator()(Args... args) const { emit(std::forwardArgs(args)...); } // 实现ISignal接口 void disconnectSlot(std::size_t id) override { // 找到对应ID的槽并将其function置为空 auto it std::find_if(slots_.begin(), slots_.end(), [id](const auto pair) { return pair.first id; }); if (it ! slots_.end()) { it-second nullptr; // 置空function标记为断开 // 可选定期清理所有为nullptr的槽避免容器膨胀。这里为了简单先不清理。 } } bool isSlotConnected(std::size_t id) const override { auto it std::find_if(slots_.begin(), slots_.end(), [id](const auto pair) { return pair.first id; }); return it ! slots_.end() static_castbool(it-second); // 存在且非空 } // 工具函数断开所有连接清空容器 void disconnectAll() { slots_.clear(); // 注意nextId_不需要重置因为旧的ID已经全部失效。 } std::size_t connectionCount() const { // 只统计有效的连接function非空 return std::count_if(slots_.begin(), slots_.end(), [](const auto pair) { return static_castbool(pair.second); }); } }; } // namespace my_signal_slot接下来在connection.cpp中实现Connection的方法// connection.cpp #include “connection.hpp” #include “signal.hpp” // 需要知道ISignal的具体实现但Signal是模板这里只需前向声明 // 实际上Connection的实现需要ISignal而ISignal在signal.hpp中定义。 // 因此connection.cpp需要包含signal.hpp。但signal.hpp又包含了connection.hpp。 // 这形成了循环包含。解决方法是将Connection的实现放在头文件里或者将ISignal分离。 // 这里我们选择将Connection的实现也放在头文件中内联避免分离的复杂度。 // 所以我们将connection.hpp改为connection.inl或直接在connection.hpp实现 // 以下是connection.hpp的完整实现 namespace my_signal_slot { void Connection::disconnect() { if (auto sharedSignal weakSignal_.lock()) { sharedSignal-disconnectSlot(slotId_); weakSignal_.reset(); // 断开后释放弱引用 slotId_ 0; } } bool Connection::connected() const { if (auto sharedSignal weakSignal_.lock()) { return sharedSignal-isSlotConnected(slotId_); } return false; } } // namespace my_signal_slot现在我们有了一个具备基本连接管理功能的信号系统。用户可以通过Signal::connect连接一个std::function并获得一个Connection对象。当Connection析构时连接会自动断开。信号发射时会调用所有已连接的、有效的槽。5. 连接各种可调用对象完善connect函数目前的connect函数接受任何可转换为std::functionvoid(Args...)的可调用对象。这已经支持了lambda表达式、自由函数和仿函数。但对于类的成员函数我们需要额外处理因为成员函数需要一个对象实例来调用。我们需要提供一个重载的connect版本专门用于连接对象实例和其成员函数。这里会用到std::bind或者lambda表达式来绑定this指针。// 在Signal类模板的public区域添加以下成员函数 templatetypename T, typename Method Connection connect(T* obj, Method method) { // 使用lambda捕获对象的弱引用或原始指针。 // 注意这里直接捕获原始指针有风险如果对象在信号之前被销毁调用就是未定义的。 // 更安全的做法是让接收者对象继承std::enable_shared_from_this并传递shared_ptr。 // 为了简化我们先使用原始指针并强调使用者需保证生命周期。 // 更安全的版本将在后续讨论。 return connect([obj, method](Args... args) { // 调用成员函数。这里假设method是成员函数指针。 // 我们需要使用std::invoke或直接调用语法。 // 现代C可以使用std::invoke它统一了调用语法。 // 但为了清晰我们使用(obj-*method)(args...)语法。 // 注意Method需要被推导为成员函数指针类型。 (obj-*method)(std::forwardArgs(args)...); }); } // 一个更通用的版本使用std::function风格的签名推断会复杂很多。 // 实际上我们可以利用模板推导和std::bind来简化。 // 另一种更简洁的写法 templatetypename T Connection connect(T* obj, void (T::*method)(Args...)) { return connect([obj, method](Args... args) { (obj-*method)(std::forwardArgs(args)...); }); } // 还需要const成员函数的版本 templatetypename T Connection connect(T* obj, void (T::*method)(Args...) const) { return connect([obj, method](Args... args) { (obj-*method)(std::forwardArgs(args)...); }); }这个实现直接捕获了原始指针obj。如果obj在连接有效时被销毁那么lambda被调用就会访问无效内存导致崩溃。这是目前设计中的一个重大缺陷。Qt通过其父子对象树和事件循环在一定程度上管理了生命周期但我们纯库的实现需要更明确的机制。5.1 引入安全的生命周期管理一个工业级的解决方案是要求槽的拥有者接收者必须由std::shared_ptr管理并在connect时传递std::weak_ptrT或std::shared_ptrT。在槽被调用时尝试将weak_ptr提升lock为shared_ptr如果提升成功说明对象还活着再调用成员函数如果提升失败则自动断开这个连接或者简单地跳过调用。让我们修改connect成员函数版本使其接受std::weak_ptr// 在Signal类中添加 templatetypename T Connection connect(std::weak_ptrT weakObj, void (T::*method)(Args...)) { std::size_t id nextId_; // 创建一个封装了弱引用检查和提升逻辑的槽 SlotType slot [weakObj, method](Args... args) { if (auto sharedObj weakObj.lock()) { // 对象还存在安全调用成员函数 (sharedObj.get()-*method)(std::forwardArgs(args)...); } // 如果对象已销毁则什么也不做。这个槽在未来会被清理掉。 // 注意我们目前没有自动清理无效槽的机制。 }; slots_.emplace_back(id, std::move(slot)); return Connection(this-shared_from_this(), id); } // 同样需要const版本 templatetypename T Connection connect(std::weak_ptrT weakObj, void (T::*method)(Args...) const) { std::size_t id nextId_; SlotType slot [weakObj, method](Args... args) { if (auto sharedObj weakObj.lock()) { (sharedObj.get()-*method)(std::forwardArgs(args)...); } }; slots_.emplace_back(id, std::move(slot)); return Connection(this-shared_from_this(), id); }现在使用这个版本的connect需要接收者对象由std::shared_ptr管理。这增加了使用的复杂性但换来了安全性。对于栈对象或明确知道生命周期更长的对象用户仍然可以使用捕获原始指针的lambda版本但需要自己承担风险。6. 使用示例与测试让我们写一个简单的例子来测试我们的信号槽库。// main.cpp #include “signal.hpp” #include iostream #include memory class Receiver : public std::enable_shared_from_thisReceiver { public: void onNumberChanged(int num, const std::string msg) { std::cout “Receiver got: num“ num “, msg” msg std::endl; } void onNumberChangedConst(int num) const { std::cout “Const Receiver got: “ num std::endl; } }; void freeFunction(int num) { std::cout “Free function got: “ num std::endl; } int main() { using namespace my_signal_slot; // 1. 创建信号建议用shared_ptr管理以便Connection使用weak_ptr auto sig Signalint, const std::string::create(); // 2. 连接各种槽 // 2.1 连接lambda auto conn1 sig-connect([](int x, const std::string s) { std::cout “Lambda: “ x “, “ s std::endl; }); // 2.2 连接自由函数 auto conn2 sig-connect(freeFunction); // 注意自由函数签名需匹配这里只匹配了第一个int参数不需要适配。 // 上面的连接会失败因为freeFunction只接受一个int而信号有两个参数。 // 我们需要一个只接受int的信号的例子。让我们调整一下。 // 为了演示我们创建另一个信号。 auto sig2 Signalint::create(); auto conn2_real sig2-connect(freeFunction); // 正确 // 2.3 连接成员函数安全版本使用weak_ptr auto receiver std::make_sharedReceiver(); auto conn3 sig-connect(receiver-weak_from_this(), Receiver::onNumberChanged); auto conn4 sig-connect(receiver-weak_from_this(), Receiver::onNumberChangedConst); // 3. 发射信号 std::cout “--- Emitting sig ---“ std::endl; sig-emit(42, “Hello Signal Slot!”); // 预期输出 // Lambda: 42, Hello Signal Slot! // Receiver got: num42, msgHello Signal Slot! // Const Receiver got: 42 std::cout “--- Emitting sig2 ---“ std::endl; sig2-emit(100); // 预期输出 Free function got: 100 // 4. 测试手动断开连接 std::cout “--- After disconnecting conn1 ---“ std::endl; conn1.disconnect(); sig-emit(1, “Test disconnect”); // conn1对应的lambda不应再被调用 // 5. 测试自动断开当Connection对象析构时 { auto conn5 sig-connect([](int, const std::string) { std::cout “Temporary connection\n”; }); std::cout “--- Emitting inside scope ---“ std::endl; sig-emit(2, “Inside scope”); } // conn5离开作用域析构自动断开连接 std::cout “--- Emitting outside scope ---“ std::endl; sig-emit(3, “Outside scope”); // conn5的槽不应被调用 // 6. 测试接收者对象销毁 std::cout “--- Before receiver reset ---“ std::endl; sig-emit(4, “Before reset”); receiver.reset(); // 销毁接收者对象 std::cout “--- After receiver reset ---“ std::endl; sig-emit(5, “After reset”); // conn3和conn4对应的槽应被安全跳过因为weak_ptr.lock()失败 return 0; }这个测试涵盖了基本功能连接lambda、成员函数、自由函数手动/自动断开连接以及安全的生命周期管理。运行它你可以验证核心逻辑是否正确。7. 性能优化、线程安全与扩展思考我们的基础实现已经可用但在生产环境中还需要考虑更多。7.1 性能优化槽容器选择我们使用了std::vector。在频繁连接/断开且连接数较多时std::list或std::deque可能在某些操作上更有优势。但std::vector的内存局部性更好遍历发射信号更快。这是一个权衡。可以考虑使用std::vector存储std::unique_ptr到一个包含ID和function的节点结构以便在断开时快速置空而不移动大量元素。发射时的复制开销emit中复制了整个槽容器。如果槽列表很大或槽函数对象很大这会有开销。一个改进方案是使用std::shared_ptrconst SlotContainer并在连接/断开时修改副本通过引用计数来管理。或者使用读写锁如std::shared_mutex保护容器在发射时加读锁并直接遍历。惰性清理断开连接只是将std::function置为空容器会膨胀。可以定期例如在连接时清理空槽或者使用“标记-清除”策略。7.2 线程安全目前的实现不是线程安全的。如果多个线程同时调用connect、disconnect或emit会导致数据竞争。一个简单的改进是为Signal类添加一个std::mutex成员在修改slots_和nextId_即connect,disconnectSlot,disconnectAll时加锁。emit函数在遍历时也需要加锁或至少加读锁以防止在遍历过程中容器被修改。templatetypename... Args class Signal final : public ISignal, public std::enable_shared_from_thisSignalArgs... { private: // ... mutable std::mutex mutex_; // 可变mutex以便在const emit中加锁 public: templatetypename Callable Connection connect(Callable func) { std::lock_guardstd::mutex lock(mutex_); std::size_t id nextId_; slots_.emplace_back(id, std::forwardCallable(func)); return Connection(this-shared_from_this(), id); } void emit(Args... args) const { std::lock_guardstd::mutex lock(mutex_); // 加锁注意这会阻塞连接/断开操作 auto slots_copy slots_; // 解锁后再调用槽不行因为slots_copy是局部变量解锁后调用是安全的。 // 但更好的做法是先复制ID和function然后解锁再调用。 // 这里为了简单先保持锁定状态调用。 for (const auto pair : slots_copy) { if (pair.second) { pair.second(args...); } } } // ... 其他需要加锁的方法 };注意在emit中加锁并调用槽函数是危险的如果槽函数内部又试图连接/断开同一个信号会导致死锁重入。更安全的做法是复制出可调用对象列表后立即释放锁然后再执行调用。7.3 扩展功能一次性连接连接后槽在第一次被调用后自动断开。这可以通过在connect时包装一个特殊的lambda来实现该lambda在调用一次后调用disconnect。连接类型Qt有直接连接同步在当前线程立即调用和队列连接异步将调用事件投递到接收者所在线程的事件队列。实现队列连接需要跨线程的事件队列机制更为复杂。信号到信号的连接我们的Signal本身就是一个可调用对象有operator()所以连接一个信号到另一个信号本质上就是连接一个调用第一个信号emit的lambda或函数对象到第二个信号。8. 常见问题与避坑指南生命周期管理混乱这是自制信号槽最常见的坑。务必明确信号和槽对象的生命周期。如果使用原始指针连接成员函数你必须确保接收者对象的寿命长于信号或者手动在接收者析构前断开所有连接。强烈推荐使用std::shared_ptr/std::weak_ptr方案虽然稍显繁琐但安全得多。在槽中修改信号连接在槽函数内部对正在发射的信号进行connect或disconnect操作很容易导致迭代器失效或死锁。如果必须这样做请确保你的emit实现是安全的例如复制槽列表后再调用。我们的基础实现通过复制容器避免了迭代器失效但在线程安全版本中需要注意死锁。性能瓶颈如果信号非常频繁地发射例如每帧多次且连接的槽很多emit中的容器复制和函数调用开销可能成为瓶颈。此时需要 profiling并考虑使用更高效的数据结构如静态大小的数组、对象池或减少连接数。类型不匹配错误信息晦涩由于大量使用模板当连接的可调用对象签名不匹配时编译器错误信息可能又长又难懂。这可以通过使用C20的Concepts或SFINAE进行更友好的静态检查来改善但会增加代码复杂度。跨平台与编译器兼容性我们大量使用了现代C特性如可变参数模板、lambda、智能指针。确保你的项目编译器支持C11或更高版本。通常MSVC、GCC、Clang的主流版本都支持良好。实现一个生产级的信号槽库绝非易事它涉及C语言特性、内存模型、并发编程等多方面知识。本文带你实现了一个具备核心功能、类型安全且带有基础生命周期管理的版本足以用于许多非性能临界且线程上下文明确的场景。理解这个实现过程本身就是对C高级特性一次极好的实战演练。当你再回到Qt中使用信号槽时你会对connect、emit这些关键字背后的魔法有更深的理解。