☰
现代C++观察者模式:std::function与weak_ptr实战
2026/10/5 4:39:56 网站建设 项目流程

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::vector<IObserver*> 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_这个vector:

Subject* 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

想通这一点,你就会明白:观察者模式本身没有错,错的是用C++98时代的裸指针思维去实现一个需要管理生命周期的交互模型。经典教程写的骨架只能用于教学演示,工程实现必须引入现代C++的工具来补齐这三个短板。

3. 现代C++改造:std::function、weak_ptr与RAII订阅管理

我在实际项目里重构观察者模式,经历了三个阶段。第一阶段是给经典骨架加锁、加detach,属于打补丁;第二阶段是拥抱std::function,去掉"观察者必须继承接口"的约束;第三阶段是引入weak_ptr+RAII的订阅管理,这才算是真正解决生命周期问题。下面把这三个阶段的思考完整讲一遍。

3.1 为什么用std::function替代纯虚接口

纯虚接口的观察者模式有一个隐含成本:每个想接收通知的类,都必须继承自IObserver,并且必须把自己的回调逻辑封装成一个独立的类,哪怕是Lambda能干的事,你也得写一个类。这在大型代码库里会导致大量"一次性观察者类",它们的存在纯粹是为回调准备的,非常笨重。

std::function本质上是一种类型擦除的回调包装器,可以持有普通函数、Lambda表达式、函数对象、甚至成员函数指针绑定后的结果。用它替换纯虚接口后,观察者不再需要继承任何东西,订阅代码变成:

std::vector<std::function<void(const std::string&)>> observers_;

然后调用方直接传Lambda:

subject.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_ptr<std::function<void(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::vector<std::weak_ptr<std::function<void(const std::string&)>>> observers_; };

但这种写法有个很不爽的地方:调用方要持有shared_ptr<std::function<...>>,语法上别扭,而且调用方还得记得把Lambda包装进shared_ptr。所以我一般在实践中封装一个辅助函数:

template<typename T> auto make_shared_callback(T&& fn) { return std::make_shared<std::function<void(const std::string&)>>(std::forward<T>(fn)); }

到了这一步,悬垂引用问题算是从机制上被堵死了。观察者就算先析构,Subject在lock()时拿不到有效的强引用,自然不会调用到死地址。

3.3 RAII订阅管理:Connection对象的正确姿态

weak_ptr解决了"观察者死了怎么办",但还有一个问题没解决:订阅关系的生命周期。假设有一个模块只希望在某段时间内接收通知,过了时间段就要退订。如果用纯手写detach,就得保证每一条attach都能配对的detach,一旦漏掉,要么泄漏内存,要么收到不该收到的通知。

RAII在这里大展身手。我的做法是设计一个Connection对象——它保存着"如何退订"的闭包,析构时自动执行退订逻辑。订阅者持有的是这个Connection,只要Connection活着,订阅就有效;当持有者不再需要订阅时,直接让其析构,或者显式调用disconnect()。

class Connection { public: // 构造时传入退订回调 explicit Connection(std::function<void()> 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::function<void()> 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::function<void(const std::string&)>; // attach 返回 Connection,管理订阅生命周期 Connection attach(Callback cb) { std::lock_guard<std::mutex> lock(mutex_); auto id = next_id_++; callbacks_[id] = std::make_shared<Callback>(std::move(cb)); auto weak = callbacks_[id]; // 存 weak_ptr 让回调自己管理生命周期 return Connection([this, id, weak]() { std::lock_guard<std::mutex> lock(mutex_); callbacks_.erase(id); // 从注册表移除强引用 }); } void notify(const std::string& event) { // 先快照,防止回调中修改容器 std::vector<std::shared_ptr<Callback>> snapshot; { std::lock_guard<std::mutex> lock(mutex_); for (auto& [id, cb] : callbacks_) { snapshot.push_back(cb); } } for (auto& cb : snapshot) { (*cb)(event); } } private: std::unordered_map<uint64_t, std::shared_ptr<Callback>> 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_ptr+RAII处理少量观察者、低延迟场景、对依赖包没要求无
Boost.Signals2同步为主,支持组合器内置scoped_connection处理退订需要返回值汇总、复杂组合策略Boost
Qt信号槽支持跨线程队列连接QObject父子机制兜底界面框架内交互Qt
分布式事件总线通常异步通常是消息序列化+消费者组跨进程/跨服务解耦自研、ebus等

5.2 什么时候不要用观察者模式

观察者模式也不是万能的。我见过不少团队把观察者模式用得很拧巴,典型的反面案例有两种。

第一种是"为全局通知而注册"的滥用。你有一个对象A,全局只有一个;A持有观察者列表,很多不相干的模块都来订阅。时间一长,观察者列表膨胀,每次notify要唤醒几十个对象,排查问题的时候无从下手:到底是谁订阅的?谁漏掉了退订?这种场景更适合引入明确的事件总线,让事件携带类型信息,订阅者按类型精确订阅,而不是全部广播。

第二种是"共享可变状态"的陷阱。观察者模式擅长单向通知,不擅长多方同时修改共享数据。如果多个观察者都在尝试修改同一个全局状态,最终结果取决于执行顺序,而这个顺序恰恰是不可依赖的——最终你就收获一个极其难排查的并发bug。

5.3 调试与监控技巧:如何定位"收不到通知"的问题

观察者模式下最气人的Bug是"明明订阅了,就是收不到通知"。排查三次之后,我把经验总结成固定套路:

  1. 先确认订阅是否生效:检查attach返回的Connection是否还活着。常见错误是临时对象析构导致自动退订:
// 错误示范 subject.attach([](...){...}); // Connection 是临时对象,立即析构,订阅立即失效! // 正确做法 auto conn = subject.attach([](...){...});
  1. 确认通知是否真的发出:在notify里临时加日志或者断点,区分是"没通知"还是"通知了但观察者没执行"。
  2. 确认线程模型是否匹配:如果观察者是在另一个线程里创建的,而notify在主线程执行,请检查回调里的线程切换逻辑,必要时用队列投递而不是直接同步调用。
  3. 检查异常吞掉:如果notify里catch了异常并且只打了日志,别忘在日志里搜索"observer callback exception",那往往是收不到通知的直接原因。

5.4 我在实际项目里的落地方案

说点个人偏好。我现在维护的几个C++服务里,通信模块内部用的是手写观察者模式(因为延迟敏感,不希望引入Boost的复杂度),对外暴露事件时用的是自研的轻量事件总线(带事件类型和异步队列),UI层如果有Qt组件,直接用Qt信号槽。三套机制各管一摊,中间通过适配层转换,不追求"一个方案统治一切"。

如果你是在一个全新的C++项目里要引入观察者模式,我的建议是:先用最简单的std::function+weak_ptr+Connection骨架跑通业务,不要一上来就上重型框架。等哪天确实出现了"返回值汇总"或"跨线程复杂调度"的需求,再平滑迁移到Boost.Signals2或者更重的事件总线机制。过早引入重型依赖,只会让原本几十行能解决的问题变成几百行的配置和概念负担。

最后分享一个实际调试中的小技巧:我在Connection类里加了一个debugName_字段,attach时可以传入订阅者的模块名,断开时打一条日志"module X disconnected"。这个字段不参与任何业务逻辑,纯粹为排查服务。有一次一个诡异的不通知问题,就是靠这行日志发现某个模块的Connection被意外析构了——你要是提前在attach的注释里写清楚回调的调用线程和生命周期约定,能省下更多时间。设计模式的价值,从来不是在PPT上讲得多花哨,而是你能否在关键时刻用对工具、看透问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询