首页 / 资讯中心 / 文章详情

现代C++观察者模式:std::function与weak_ptr实战

现代C++观察者模式:std::function与weak_ptr实战 ★ FEATURED ARTICLE
1. 观察者模式到底解决了什么问题从轮询到订阅的依赖反转做C后端或者客户端开发的朋友大概率都遇到过这种场景某个核心对象状态一变一堆下游模块都要跟着动。比如一个交易系统里的行情源价格一跳风控要更新界面要刷新日志要记录策略引擎要重新计算。最直白的做法是什么在价格更新那个函数里挨个调用risk-update()、ui-refresh()、logger-write()、engine-recalc()。写起来倒是简单但问题马上就来行情源这个类居然要认识所有下游模块的类型。今天加一个指标模块你要改行情源的头文件明天把日志库换掉你还是得改行情源。这就不叫设计这叫把所有东西焊死在一块钢板上。观察者模式Observer Pattern解决的就是这个耦合问题。它的核心思路是依赖反转下游模块不直接把自己塞进上游的逻辑里而是声明我关心这个事件上游只负责在自己状态变化时喊一嗓子我变了所有关心的人自己过来响应。这样一来上游不用知道下游是谁下游也不用反向依赖上游的实现细节两边都只依赖一个抽象的事件接口。1.1 核心角色划分Subject、Observer与通知协议在C语境下观察者模式通常分成三个角色Subject主题/被观察者维持状态保存一份观察者列表状态变化时负责广播通知。Observer观察者订阅自己关心的主题收到通知后执行自己的逻辑。通知协议就是双方约定好的那个喊话暗号在C里最古典的表现形式是一个纯虚函数比如virtual void update(const Event) 0。这里有个经常被初学者忽略的点观察者模式的本质是回调机制的面向对象封装。所谓观察者本质上就是注册到Subject手里的一个回调对象Subject在合适的时机反向调用它。C里实现回调有一万种方式函数指针、std::function、Lambda、信号槽、具体函数名约定观察者模式只是给这套回调机制套上了一个更规范、更容易扩展的壳。1.2 一个最小可用的骨架Demo先让模式跑起来很多人学设计模式最大的认知误区是觉得模式是架构层面的东西离自己很遥远。其实恰恰相反观察者模式在工程里最常出现的形态就是几行代码的事。我们先写一个绝对最小的骨架目标只有一个跑起来看到通知到达理解角色的边界。#include iostream #include vector #include string // 1. 先定义观察者接口这是双方唯一的约定 class IObserver { public: virtual ~IObserver() default; virtual void update(const std::string event) 0; }; // 2. 定义主题它只认识 IObserver 这个抽象 class Subject { public: void attach(IObserver* observer) { observers_.push_back(observer); } void detach(IObserver* observer) { // 注意这里先不处理迭代器失效后面专门讲 for (auto it observers_.begin(); it ! observers_.end(); it) { if (*it observer) { observers_.erase(it); return; } } } void notify(const std::string event) { for (auto observer : observers_) { observer-update(event); } } // 业务方法模拟状态改变 void setState(const std::string s) { state_ s; notify(state changed to s); // 状态一改立刻广播 } private: std::vectorIObserver* observers_; // 裸指针数组经典写法 std::string state_; }; // 3. 写两个具体的观察者各自干各自的事 class UIObserver : public IObserver { public: void update(const std::string event) override { std::cout [UI] 收到通知: event std::endl; } }; class LogObserver : public IObserver { public: void update(const std::string event) override { std::cout [Log] 记录日志: event std::endl; } }; int main() { Subject subject; UIObserver ui; LogObserver logger; subject.attach(ui); subject.attach(logger); subject.setState(Hello, Observer!); return 0; }编译运行输出应该是[UI] 收到通知: state changed to Hello, Observer! [Log] 记录日志: state changed to Hello, Observer!这个骨架从功能上讲已经完整了上游只有一个notify调用新增观察者不需要改Subject的代码下游实现IObserver接口就能参与广播。但如果你直接拿这个代码进生产环境我敢说你会在很短的时间内遇到崩溃、漏通知、死锁等一系列问题。我最早在项目里用观察者模式就是被这个经典写法坑到怀疑人生——后来才明白设计模式给的是思想骨架工程落地需要的是C特有的生命周期和并发控制手段。2. 经典写法必崩的三种场景裸指针、迭代器失效与数据竞争上面的骨架代码几乎是所有教程的标配但现实世界不是单线程的、不是内存永远有效的、也不是所有对象都按顺序安然退场的。我梳理了自己实际踩过、也看同事踩过的三类典型崩溃场景每一个都能完美复现每一个都能用经典写法触发。2.1 场景一悬垂指针——观察者先死了Subject还在喊这是最经典、最致命的一种。假设一个窗口程序里用户关闭了一个面板UIObserver对象被析构但之前attach进去的裸指针还留在Subject的observers_里。此时如果行情源恰好来了一条新数据Subject::notify遍历列表对着已经失效的UIObserver调用update——直接未定义行为常规表现就是崩溃。模拟代码// 假设有这样一个作用域 { UIObserver* ui new UIObserver(); subject.attach(ui); delete ui; // ui 已死 } // 但 subject.observers_ 里还躺着一个指向已释放内存的地址 subject.notify(tick); // BOOM!在这个地方很多人第一反应是那我析构的时候记得调detach不就行了。理想确实如此但现实里观察者的析构时机往往不受Subject控制尤其在复杂对象图里你根本没法保证析构顺序。这也是后面引入weak_ptr的根本原因裸指针没法表达这个观察者可能已经不在了的信息我们需要一个能感知对象死活的东西。2.2 场景二迭代器失效——观察者在通知过程中取消订阅第二种坑更隐蔽。假设某个观察者收到通知后在回调里把自己从observers_中detach掉。而notify正在遍历observers_这个vectorSubject* s nullptr; // 假设全局能拿到Subject class SelfRemovingObserver : public IObserver { public: void update(const std::string) override { // 在回调里把自己移除 s-detach(this); } }; // notify 内部 for (auto observer : observers_) { observer-update(event); // 此时 vector 被 detach 修改了 }std::vector在erase之后当前位置之后的所有迭代器全部失效。如果使用了下标循环或者基于范围的for轻则漏掉一部分观察者重则直接使用失效迭代器崩溃。在遍历容器过程中修改同一容器永远是C的禁忌之一。2.3 场景三数据竞争——两个线程同时操作观察者列表最后一个坑来自多线程环境。真实业务里往往一个线程负责处理外部输入、修改Subject状态另一个线程负责把状态变化推送给观察者或者干脆多个生产者线程都在调notify/attach。经典骨架里的std::vector在两个线程同时读写时会产生数据竞争表现出来的问题非常随机observers_.push_back和observers_.erase并发可能导致内存损坏两个线程同时遍历可能在扩容时一个线程读到半个vector。2.4 三种场景的共性总结我把这三种坑的关键点放在一起对比方便记忆崩溃/问题类型根因经典写法为什么躲不过悬垂指针观察者生命周期短于Subject裸指针不携带生命周期信息析构后无人告知Subject迭代器失效通知期间容器被修改notify遍历时无法感知观察者回调中的副作用数据竞争多线程并发操纵观察者列表std::vector非线程安全锁缺失导致UB想通这一点你就会明白观察者模式本身没有错错的是用C98时代的裸指针思维去实现一个需要管理生命周期的交互模型。经典教程写的骨架只能用于教学演示工程实现必须引入现代C的工具来补齐这三个短板。3. 现代C改造std::function、weak_ptr与RAII订阅管理我在实际项目里重构观察者模式经历了三个阶段。第一阶段是给经典骨架加锁、加detach属于打补丁第二阶段是拥抱std::function去掉观察者必须继承接口的约束第三阶段是引入weak_ptrRAII的订阅管理这才算是真正解决生命周期问题。下面把这三个阶段的思考完整讲一遍。3.1 为什么用std::function替代纯虚接口纯虚接口的观察者模式有一个隐含成本每个想接收通知的类都必须继承自IObserver并且必须把自己的回调逻辑封装成一个独立的类哪怕是Lambda能干的事你也得写一个类。这在大型代码库里会导致大量一次性观察者类它们的存在纯粹是为回调准备的非常笨重。std::function本质上是一种类型擦除的回调包装器可以持有普通函数、Lambda表达式、函数对象、甚至成员函数指针绑定后的结果。用它替换纯虚接口后观察者不再需要继承任何东西订阅代码变成std::vectorstd::functionvoid(const std::string) observers_;然后调用方直接传Lambdasubject.attach([](const std::string event) { std::cout 收到事件: event std::endl; });这个改造最大的收益是降低了参与门槛下游模块不用为了实现一个接口而改变自己的类继承结构只需要提供一个可调用对象。耦合从类型层面降到了行为层面系统的扩展性反而更强。代价是调试时栈帧里多了一些std::function的间接调用但在绝大多数场景下这个代价完全值得。3.2 解决悬垂引用用weak_ptr登记观察者光用std::function还解决不了悬垂问题——如果Lambda捕获了this而这个this对应的对象已经析构调用时照样崩溃。真正的解药是把观察者用shared_ptr管理起来然后在Subject里存weak_ptr。思路是这样观察者的生命周期不再由Subject负责而是由Shared所有权管理Subject存储的是弱引用在通知时先尝试lock()升级为强引用如果成功说明对象还活着如果失败说明对象已析构顺手把这个残留项清掉。#include memory #include functional #include vector class Subject { public: // 观察者从一个继承接口的类变成一个受 shared_ptr 管理的可调用对象 void attach(std::shared_ptrstd::functionvoid(const std::string) observer) { observers_.emplace_back(observer); } void notify(const std::string event) { for (auto it observers_.begin(); it ! observers_.end();) { if (auto observer it-lock()) { (*observer)(event); it; } else { // 观察者已经销毁清理悬垂项 it observers_.erase(it); } } } private: std::vectorstd::weak_ptrstd::functionvoid(const std::string) observers_; };但这种写法有个很不爽的地方调用方要持有shared_ptrstd::function...语法上别扭而且调用方还得记得把Lambda包装进shared_ptr。所以我一般在实践中封装一个辅助函数templatetypename T auto make_shared_callback(T fn) { return std::make_sharedstd::functionvoid(const std::string)(std::forwardT(fn)); }到了这一步悬垂引用问题算是从机制上被堵死了。观察者就算先析构Subject在lock()时拿不到有效的强引用自然不会调用到死地址。3.3 RAII订阅管理Connection对象的正确姿态weak_ptr解决了观察者死了怎么办但还有一个问题没解决订阅关系的生命周期。假设有一个模块只希望在某段时间内接收通知过了时间段就要退订。如果用纯手写detach就得保证每一条attach都能配对的detach一旦漏掉要么泄漏内存要么收到不该收到的通知。RAII在这里大展身手。我的做法是设计一个Connection对象——它保存着如何退订的闭包析构时自动执行退订逻辑。订阅者持有的是这个Connection只要Connection活着订阅就有效当持有者不再需要订阅时直接让其析构或者显式调用disconnect()。class Connection { public: // 构造时传入退订回调 explicit Connection(std::functionvoid() disconnect) : disconnect_(std::move(disconnect)), connected_(true) {} Connection(Connection other) noexcept : disconnect_(std::move(other.disconnect_)), connected_(other.connected_) { other.connected_ false; } ~Connection() { disconnect_(); // 析构自动退订 } void disconnect() { if (connected_) { disconnect_(); connected_ false; } } Connection(const Connection) delete; Connection operator(const Connection) delete; private: std::functionvoid() disconnect_; bool connected_; };有了Connection之后Subject的attach返回一个Connection调用方用auto conn subject.attach(...)保存。当conn析构时就自动完成退订完美契合资源获取即初始化的原则。3.4 合并之后的现代C完整实现把std::function、weak_ptr和Connection三件事合起来才是我在生产环境里真正落地的观察者模式骨架#include functional #include memory #include unordered_map #include mutex class EventBus { // 用事件总线的形式承载观察者模式 public: using Callback std::functionvoid(const std::string); // attach 返回 Connection管理订阅生命周期 Connection attach(Callback cb) { std::lock_guardstd::mutex lock(mutex_); auto id next_id_; callbacks_[id] std::make_sharedCallback(std::move(cb)); auto weak callbacks_[id]; // 存 weak_ptr 让回调自己管理生命周期 return Connection([this, id, weak]() { std::lock_guardstd::mutex lock(mutex_); callbacks_.erase(id); // 从注册表移除强引用 }); } void notify(const std::string event) { // 先快照防止回调中修改容器 std::vectorstd::shared_ptrCallback snapshot; { std::lock_guardstd::mutex lock(mutex_); for (auto [id, cb] : callbacks_) { snapshot.push_back(cb); } } for (auto cb : snapshot) { (*cb)(event); } } private: std::unordered_mapuint64_t, std::shared_ptrCallback callbacks_; std::mutex mutex_; uint64_t next_id_ 0; };这个版本兼顾了生命周期安全观察者用shared_ptr托底、自动退订RAII的Connection、基本线程安全notify和attach用同一把锁做保护。在这个骨架之上我可以通过调整底层容器和锁粒度来适配不同场景的性能要求。4. 线程安全、异常安全与通知顺序工程落地的进阶取舍有了第3章节的骨架观察者模式已经能应付很多常规业务了。但真实系统里还有三个绕不开的工程问题多线程并发、回调抛异常、通知嵌套与顺序。这三个问题不解决生产环境中迟早出幺蛾子。4.1 线程安全设计锁粒度、死锁与快照技巧观察者模式天然是读多写少的场景——notify的调用频率远高于attach/disconnect。如果每调notify一次就持锁遍历整个回调表在高频场景下锁竞争很严重。我的做法是前面代码里的快照模式attach/disconnect加锁修改回调表操作时间极短。notify快速锁住、把当前所有回调的shared_ptr拷贝到本地快照然后立刻解锁在无锁状态下逐个调用。快照模式最大的好处是回调执行期间锁是释放的所以观察者回调里可以安全地执行attach或disconnect不会导致同一个锁被递归获取避免死锁。代价是每次notify有一次vector拷贝但回调表规模一般都不大几十个上限拷贝成本几乎可以忽略。如果你对性能极度敏感还可以用无锁队列来做事件异步投递但那就不是观察者模式本身的责任了——那已经演化成了事件总线异步处理器架构我会在第5章里展开讨论。4.2 异常安全一个观察者挂了其他人还要不要收通知这是我在实际项目中踩过的一个闷坑。某次一个观察者的回调里抛出了一个未捕获的异常紧接着整个notify循环直接退出后面排队的观察者全部收不到通知。排查半天业务方的反馈是行情偶尔跳变时日志里莫名缺了一部分监控记录。后来我把notify改成这样void notify(const std::string event) { auto snapshot getSnapshot(); for (auto cb : snapshot) { try { (*cb)(event); } catch (const std::exception e) { // 记录日志并继续通知其他观察者 std::cerr observer callback exception: e.what() std::endl; } catch (...) { std::cerr observer callback unknown exception std::endl; } } }这里要做一个设计决策异常发生后是继续通知剩余观察者终止整个广播我的经验是观察者模式本身是广播机制一个观察者的失败不应该阻塞其他人。正确做法是捕获、记录、继续广播并把异常信息收集起来事后统一上报。当然如果你的业务语义是任一观察者失败即视为本次事件处理失败那你应该把notify返回一个失败标志但至少别让异常直接穿透notify让调用方去面对一堆半处理状态。4.3 通知顺序与嵌套通知同步遍历的安全边界观察者模式的默认执行模型是同步调用Subject调用notify观察者回调在调用者的线程里立即执行执行完才会返回。这种模型有几个隐含后果观察者回调的执行时间会阻塞Subject主流程。如果某个观察者回调做了很重的同步I/O所有其他观察者都跟着等待——这本质上是一个性能隐患。观察者回调里如果再次触发notify嵌套通知理论上应该处理重入问题。前面快照模式已经把回调表拷贝出来了所以第二个notify不会篡改正在遍历的列表但你还是可能得到一组乱序且重复的副作用执行。关于顺序业务上很多场景关心的不是通知顺序而是确定性。因为观察者的执行顺序取决于注册顺序和容器内部布局你永远不应该依赖观察者回调的调用顺序进行业务逻辑设计。如果确实有依赖关系比如风控必须先于交易执行正确的做法是把这种依赖关系显式建模为一个观察者内部顺序调用而不是祈祷vector的遍历顺序恰好符合业务需求。我在重构过几次后把通知顺序不可依赖直接写进了部门的防御性编程规范里。4.4 高频场景下的性能视角观察者模式在高频场景下最大的性能瓶颈是遍历回调表的次数与函数调用开销。我之前把一个每秒触发上万次事件的行情系统从继承式观察者改成std::function版本后单次notify的耗时从大约几百纳秒降到了几十纳秒主要收益来自避免了动态类型分发虚函数调用相比直接std::function调用还是有额外开销。快照模式减少了锁持有时间。使用连续内存容器std::vector替代链表结构缓存友好。如果单订阅者数量超过几百个可以考虑按事件类型建索引类似信号槽的connection分组让单次事件只遍历关心它的观察者子集这通常是性能优化的下一步。5. 观察者模式与信号槽、事件总线的选型我在项目里的判断标准很多刚开始接触观察者模式的读者会疑惑Boost.Signals2有信号槽Qt也有自己的信号槽机制还有各种事件总线库为什么还要自己手写观察者模式我的看法是观察者模式更像一种协议规范而信号槽/事件总线是其具体实现或扩展形态。在真实的C工程里选型判断远比会用一种写法重要。5.1 四类机制的横向对比机制同步/异步生命周期处理适用场景代表库手写观察者模式同步调用线程执行需要自己用weak_ptrRAII处理少量观察者、低延迟场景、对依赖包没要求无Boost.Signals2同步为主支持组合器内置scoped_connection处理退订需要返回值汇总、复杂组合策略BoostQt信号槽支持跨线程队列连接QObject父子机制兜底界面框架内交互Qt分布式事件总线通常异步通常是消息序列化消费者组跨进程/跨服务解耦自研、ebus等5.2 什么时候不要用观察者模式观察者模式也不是万能的。我见过不少团队把观察者模式用得很拧巴典型的反面案例有两种。第一种是为全局通知而注册的滥用。你有一个对象A全局只有一个A持有观察者列表很多不相干的模块都来订阅。时间一长观察者列表膨胀每次notify要唤醒几十个对象排查问题的时候无从下手到底是谁订阅的谁漏掉了退订这种场景更适合引入明确的事件总线让事件携带类型信息订阅者按类型精确订阅而不是全部广播。第二种是共享可变状态的陷阱。观察者模式擅长单向通知不擅长多方同时修改共享数据。如果多个观察者都在尝试修改同一个全局状态最终结果取决于执行顺序而这个顺序恰恰是不可依赖的——最终你就收获一个极其难排查的并发bug。5.3 调试与监控技巧如何定位收不到通知的问题观察者模式下最气人的Bug是明明订阅了就是收不到通知。排查三次之后我把经验总结成固定套路先确认订阅是否生效检查attach返回的Connection是否还活着。常见错误是临时对象析构导致自动退订// 错误示范 subject.attach([](...){...}); // Connection 是临时对象立即析构订阅立即失效 // 正确做法 auto conn subject.attach([](...){...});确认通知是否真的发出在notify里临时加日志或者断点区分是没通知还是通知了但观察者没执行。确认线程模型是否匹配如果观察者是在另一个线程里创建的而notify在主线程执行请检查回调里的线程切换逻辑必要时用队列投递而不是直接同步调用。检查异常吞掉如果notify里catch了异常并且只打了日志别忘在日志里搜索observer callback exception那往往是收不到通知的直接原因。5.4 我在实际项目里的落地方案说点个人偏好。我现在维护的几个C服务里通信模块内部用的是手写观察者模式因为延迟敏感不希望引入Boost的复杂度对外暴露事件时用的是自研的轻量事件总线带事件类型和异步队列UI层如果有Qt组件直接用Qt信号槽。三套机制各管一摊中间通过适配层转换不追求一个方案统治一切。如果你是在一个全新的C项目里要引入观察者模式我的建议是先用最简单的std::functionweak_ptrConnection骨架跑通业务不要一上来就上重型框架。等哪天确实出现了返回值汇总或跨线程复杂调度的需求再平滑迁移到Boost.Signals2或者更重的事件总线机制。过早引入重型依赖只会让原本几十行能解决的问题变成几百行的配置和概念负担。最后分享一个实际调试中的小技巧我在Connection类里加了一个debugName_字段attach时可以传入订阅者的模块名断开时打一条日志module X disconnected。这个字段不参与任何业务逻辑纯粹为排查服务。有一次一个诡异的不通知问题就是靠这行日志发现某个模块的Connection被意外析构了——你要是提前在attach的注释里写清楚回调的调用线程和生命周期约定能省下更多时间。设计模式的价值从来不是在PPT上讲得多花哨而是你能否在关键时刻用对工具、看透问题。
阅读完成 · 觉得有帮助?
咨询建站