当年我刚开始接触设计模式的时候,对“观察者模式”的理解停留在《设计模式》书里那句“定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新”。背概念很容易,真到 C++ 项目里落地才发现,这句话背后到处是坑:生命周期谁管理?通知时加不加锁?观察者回调里又触发了同一主题的变更怎么办?这篇文章,我就想把这些年踩过的坑和沉淀下来的做法,一次性说清楚。
不论你是在做 UI 组件联动、后台数据同步、插件系统事件分发,还是单纯的跨模块状态广播,只要碰到“状态一变,多个模块都要跟着响应”的场景,观察者模式就是最自然的选择。我会从最基础的设计讲起,再逐步深入线程安全、生命周期、性能优化这些工程层面的问题,让你看完之后既能快速写出可用的原型,也能知道如何把它打磨成能上生产环境的东西。
1. 为什么需要观察者模式
1.1 硬编码依赖带来的麻烦
先设想一个非常常见的场景:某个模块持有一份核心状态,比如一个设备管理系统中“设备连接状态”,从断连切换为已连接。这时候可能有三处要联动:界面上的状态指示灯要变绿、日志模块要记录一条连接成功事件、资源管理器要开始加载该设备的配置信息。
不引入观察者模式时,最常见的做法是:在状态变化的地方,把三处逻辑一个一个写出来。
void DeviceManager::setConnected(bool connected) { this->connected_ = connected; if (connected) { uiPanel_->setStatus(UI_STATUS_OK); logger_->log("device connected"); resourceLoader_->startLoading(deviceId_); } }这段代码最大的问题不是“它跑不起来”,而是“它只能活一次”。第二天产品经理过来说:加一个声音提示。第三天运维说:连接之后要同步上报云端。第四天前端说:界面指示灯改一下颜色。你会发现setConnected这个函数开始无休止地膨胀,而且每次改动都要动DeviceManager本身,核心逻辑被一堆边缘逻辑淹没。
更难受的是,模块之间的关系变成了“公开处刑”:DeviceManager要认识所有的下游模块,需要 include 一堆头文件,编译依赖乱成一团。任何一个下游模块改变接口,上游就要跟着改。这种紧耦合在当前 C++ 工程里是致命的,特别是现代大型项目动辄几十个模块并行开发。
1.2 轮询方案为什么不可取
有朋友会说:“我不想改DeviceManager,那我让 UI、日志、资源加载这几个模块自己去查状态不就行了吗?”
这就是轮询方案。比如 UI 每隔 100 毫秒调用一次DeviceManager::isConnected(),发现状态变了就刷新界面。
轮询的问题也很明显:
- 时效性不可控。轮询间隔设短了,CPU 白白空转;设长了,界面反应迟钝,用户体验差。
- 每个下游模块都要写一套“检查变化”的逻辑,重复代码无处可藏。
- 大量模块同时轮询同一状态时,系统负载被无意义地放大,问题在性能测试时才浮现。
观察者模式 = 把“下游主动拉”变为“上游主动推”,状态一变,通知立即送达。通知对象精确列表,不浪费资源在无关模块上。
1.3 观察者模式的核心思路
观察者模式其实可以用一个很生活化的比喻来讲:你把一个公众号“设为星标”,公众号发一篇新文章,你马上就能在订阅列表里看到它;你没有关注它,它发多少篇文章都与你无关。
用技术语言翻译一下就是:
- 主题(Subject):被观察的目标对象,它维护一份订阅者列表,并在状态变更时向所有订阅者广播通知。
- 观察者(Observer):订阅了主题的对象,它定义了一个接收通知的更新接口。
- 订阅关系:观察者可以随时加入(attach)或退出(detach),主题完全不需要知道观察者的具体类型,只依赖其抽象接口。
这带来的直接收益就是松耦合。发布者不依赖任何一个具体的下游实现,下游也能独立变化。新增一个模块只需要实现观察者接口,然后注册到主题上,主题一行代码都不用改。
2. 从零手写一个可用的 C++ 观察者模式
2.1 定义观察者接口
在 C++ 里,观察者模式最基础的设计通常包含两个抽象的部分:一个Observer接口(纯虚类),一个Subject主题类(或者接口)。
先说Observer:
#include <memory> class Event { public: virtual ~Event() = default; }; class ConnectionEvent : public Event { public: explicit ConnectionEvent(bool connected, int deviceId) : connected_(connected), deviceId_(deviceId) {} bool isConnected() const { return connected_; } int deviceId() const { return deviceId_; } private: bool connected_; int deviceId_; }; class Observer { public: virtual ~Observer() = default; // 接收通知的入口 virtual void update(const Event& event) = 0; };我把Event抽象成了一个基类,而不是直接在update里传一个bool或int。这么做是有原因的:实战中,通知事件通常会携带多种类型的参数,而且事件种类还会不断扩展。如果update接口写死成void update(bool flag),将来要传deviceId、timestamp、errorMessage就全得改接口,那依赖关系又变硬了。
用一个Event基类包一层,主题只需发布事件对象,观察者根据自己的需求向下转型获取具体事件内容。虽然多了一次虚函数调用,但换来的是极其稳定的接口定义。
不过这里要提醒一句:不要过度抽象。如果你只是在小范围内同步一个数据值,非得让所有观察者都去dynamic_cast一个Event对象,反而增加开发和阅读成本。接口的抽象级别要根据项目的实际复杂度来决定,我后面会给出一个更轻量的std::function版方案。
2.2 主题类的基本骨架
接下来是Subject。它要做的核心事情只有三件:管理订阅者、遍历通知、接收状态变化。
#include <vector> #include <functional> #include <algorithm> #include <mutex> class DeviceManager { public: void attach(Observer* observer) { observers_.push_back(observer); } void detach(Observer* observer) { observers_.erase( std::remove(observers_.begin(), observers_.end(), observer), observers_.end()); } void notify(const Event& event) { for (Observer* observer : observers_) { if (observer) { observer->update(event); } } } void setConnected(int deviceId, bool connected) { ConnectionEvent event(connected, deviceId); notify(event); } private: std::vector<Observer*> observers_; };这个版本大概有十个正常 work 的实现,是它最基础的形态,但也隐藏着几个工程问题,我逐个说。
2.3 几个隐蔽的雷点
雷点一:detach里的迭代器操作
std::remove配合erase是 vector 的标准删除姿势,逻辑上没问题。但如果notify正在遍历observers_,另一个线程或该观察者自己在update回调里调用了detach,vector 的元素可能被搬动,当前迭代器直接失效,程序轻则漏通知,重则崩溃。
处理思路我放第 3 节细讲,这里先记住一个结论:在遍历通知期间,要保证订阅者列表不被修改。
雷点二:观察者的指针可能悬空
基础版本用的是裸指针Observer*。如果某个观察者在销毁时忘了调用detach,observers_里还留着它的地址,下次notify就会通过一个悬空指针调用虚函数,这是一个极其恶性的 bug,崩溃不一定立刻发生,可能要到下一个对《观察者列表》操作的时候才露出马脚。
解决这个问题的方案一般有两种:
- 用
std::weak_ptr跟踪观察者对象,notify 时先lock()判断是否还活着。 - 强约束“先 detach 再销毁”的规范,配合断言在调试期暴露问题。
我自己的项目里经常是两种方案同时用:核心基础设施用weak_ptr保证兜底,业务代码层用 detach 规范保证性能。
雷点三:观察者的执行顺序问题
observers_是 vector,遍历顺序天然是 attach 的顺序。很多人会默认“先注册的先收到通知”,这确实如此。但你的设计不能依赖这个顺序——如果观察者 A 的更新逻辑依赖观察者 B 已经处理完事件,那这本身就是设计缺陷,应该通过数据依赖而不是注册顺序来表达。
雷点四:一个观察者的异常会中断整个通知链
notify里是一个循环调用。如果某个observer->update()抛了异常,后面的观察者就收不到通知了。在这里,我强烈建议在遍历时根据业务情况包一层try/catch,保证异常不会切断链路。
注意:如果你写的是嵌入式或性能敏感的代码,catch 的成本也要考虑,常常是在
update接口本身约定“不得向外抛异常”,然后用 RAII 统一处理。但普通业务系统中,try/catch 保链路往往更值得。
3. 工程化进阶:生命周期、线程安全与变更管理
3.1 用回调函数取代抽象基类
纯虚类Observer在 C++ 工程里其实并不一定是最好的选择。特别是当你观察者只是一个 lambda 表达式,或者一个只处理单个事件的轻量对象时,为了接收通知专门写一个子类,代码啰嗦又难维护——这种情况在 C++11 之后非常普遍,主要因为std::function的到来。
于是我看到很多现代 C++ 实现直接使用回调注册的方式:
class DeviceManager { public: using EventCallback = std::function<void(const Event&)>; int subscribe(EventCallback cb) { std::lock_guard<std::mutex> lock(mutex_); callbacks_.push_back(std::move(cb)); return nextId_++; } void unsubscribe(int handle) { std::lock_guard<std::mutex> lock(mutex_); callbacks_.erase( std::remove_if(callbacks_.begin(), callbacks_.end(), [handle](const auto& item) { return item.handle == handle; }), callbacks_.end()); } void notify(const Event& event) { // 加锁期间只做拷贝,不在锁内执行回调 std::vector<EventCallback> snapshot; { std::lock_guard<std::mutex> lock(mutex_); for (const auto& item : callbacks_) { snapshot.push_back(item.cb); } } for (const auto& cb : snapshot) { cb(event); } } private: struct CallbackEntry { int handle; EventCallback cb; }; std::vector<CallbackEntry> callbacks_; std::mutex mutex_; int nextId_ = 0; };这个版本比抽象基类版本友好不少:调用方可以传入std::bind、lambda、函数指针,甚至是一整个仿函数对象。订阅接口返回一个int handle,取消订阅时不需要传对象指针,只要记着这个编号就行,非常适合基于控件或模块的系统。
但要注意一个细节:不要把EventCallback直接存成裸的std::function,后来再按值比较来反注册。std::function的比较没有明确语义,想通过“传一个等值的函数对象去取消订阅”往往等不上。用句柄(handle)的方式是最稳的。
3.2 线程安全问题
真实系统里,发布状态变化的线程,和观察者执行副作用的线程,往往不是同一个。例如:网络收包线程把数据放进缓存并触发通知,UI 线程的观察者收到通知后要刷新界面。DeviceManager::notify可能被多个线程同时调用,观察者在通知期间也可能被其它线程注册或注销。
线程安全的经典处理策略是两步:
- 第一步:在修改订阅者列表时加锁,保证
subscribe/unsubscribe与notify之间互斥。 - 第二步:
notify时先把订阅者列表拷贝到一个局部快照,在锁外执行回调,避免长时间持锁导致其它线程阻塞。
为什么“在锁内执行回调”是绝对的反模式?因为回调里用户不知道会做什么事情,如果回调里调用了同一主题的subscribe,而notify还持着锁,那subscribe会尝试获取同一把非递归锁,只有一个互斥量的话直接std::mutex死锁。或者反过来,回调里调一个很慢的接口,所有想注册观察者的线程都排长队,性能雪崩。
拷贝快照的方法虽然每次通知都有轻微的内存分配开销,但在大多数业务系统中完全可接受。如果对性能极度敏感,可以尝试将通知列表做成读写锁:注册和注销用写锁,遍历通知用读锁,这样能避免快照的内存拷贝,不过实现复杂度会显著上升。我的建议是先在快照版本上优化,实测不了再加复杂度,不要一开始就上“唯美设计”。
3.3 可重入问题的应对
还有一种隐蔽的问题:观察者的回调内部触发了同一个主题的状态变化,从而递归调用notify。例如:
- 观察者 A 收到“设备连接”事件,在回调里立刻调用
deviceManager.setConnected(false)断开连接。 setConnected再触发一次通知。- 如果观察者 A 也被注册了,那 A 的
update可能再次进入,甚至无限递归。
这种情况下,快照拷贝的方案能缓解列表被修改的问题,但无法阻止递归调用。应对策略通常有:
- 通过事件类型区分:在回调内部检测重复事件,直接返回。
- 设置 notify 重入标志:递归调用时外层循环直接跳过或排队处理。
void notify(const Event& event) { if (notifying_) { // 如果有重入,可以选择把事件加入待处理队列,或者直接返回 return; // 这是最简单粗暴的策略 } notifying_ = true; // ... 正常通知流程 notifying_ = false; }当然,“直接返回”往往不是最好的选择,它在某些场景下会丢事件。更稳妥的是用队列,把重入的通知攒起来,等外圈通知结束后再统一处理。复杂度会高一些,但逻辑完整。
3.4 批量更新与事务性通知
在某些场景下,一个状态变更会引发一连串小变化。比如数据同步时,表内一次新增 100 条记录。如果每条记录都调用一次notify,观察者会被唤醒 100 次,界面刷新 100 次,日志刷 100 次,资源浪费严重。
工程上常见的办法是引入“批处理标志”:
class DeviceManager { public: void beginBatch() { batchLevel_++; } void endBatch() { if (--batchLevel_ == 0) { // 批量变更结束,统一通知一次 ConnectionEvent event(/*汇总后的最新状态*/); notify(event); } } void applyChange(std::function<void()> change) { beginBatch(); change(); endBatch(); } };或者把接口设计成“收集变更、统一 commit”。这类设计需要结合业务实际,但思路是共通的:观察者模式适合通知“状态快照”,不适合通知“过程细节”。如果事件过程本身很重要,应当考虑专门设计增量事件类型,而不是把每次微小变化都广播出去。
4. 实际项目中的落地陷阱与经验
4.1 典型问题速查表
我在自己的 C++ 项目里维护过包含十几个订阅主题、上百个观察者的模块,踩过的坑汇集起来就是下面这张表。新接入观察者模式,遇到诡异问题的,优先按这张表排查。
| 典型症状 | 可能原因 | 解决方向 |
|---|---|---|
崩溃发生在notify的 for 循环内 | 观察者对象已销毁,列表中还存有悬空指针 | 使用weak_ptr跟踪,或在析构时强制 detach |
| 观察者收不到通知 | 注册和发布不在同一个对象实例/线程,或 detach 后未重新 attach | 检查订阅句柄、注册时机,确认主题对象唯一 |
| 死锁 | 回调里反向调用了同一主题的注册/注销接口,且通知时持有锁 | 将回调执行放到锁外(快照/宽拷贝方案) |
| 通知顺序出错 | 依赖 vector 的注册顺序,但新观察者提前 attach | 业务逻辑不要依赖顺序;如必须,调整注册时机或在通知前显式排序 |
| 同一事件重复触发 | 不自洽的状态循环更新 | 增加事件去重机制或重入标志 |
| 高频小通知拖慢系统 | 频繁 notify 导致观察者反复执行重逻辑 | 引入批量更新、合并周期、延迟通知机制 |
| 动态库边界崩溃 | 跨动态库传入Event对象时 RTTI/异常处理不匹配 | 保持模块边界内传递,避免跨模块直接传复杂异常对象,优先传二进制兼容的数据 |
4.2 调试观察者模式的心得
观察者模式一旦出现问题,最讨厌的地方是它的调用链被拆成了“触发—通知—回调—回调内再触发”的结构,普通断点方式很难一眼看清事件从哪儿来。
我的调试习惯一般是:
给每个订阅句柄起名字。
subscribe时传入一个const char*或std::string调试名,notify 时记录“当前正在通知谁”,这样日志里能直观看到列表。生产代码里可以编译期用#ifdef把调试名剥离,不影响运行时性能。维护
notify的调用栈历史。在异常场景下,把最近几笔通知的事件类型、时间戳、观察者数量写进一个环形缓冲区,崩溃后配合日志能快速定位是不是某次回调又触发了新通知。用事件日志代替断点。在
notify入口打印一行“[Event] DeviceManager::notify connected=true, observers=3”,在观察者回调入口打印“[Callback] UIWidget::update received”。这比条件断点好用得多,尤其是在多线程场景下。重复触发问题:如果一个事件被同时触发两次,留意是不是出现了一张“环”:A 状态变化通知 B,B 根据状态变化再改 A,A 又通知 B…… 这种环状同步逻辑在观察者模式里非常容易出现,调试时优先打印完整的事件链路。
4.3 我推荐的完整落地模板
最后放一个我比较常用的、兼顾可读性和工程强度的观察者模板。它把一个主题抽象成一个小类,支持事件对象、句柄注册、线程安全快照、批量更新、安全性检查,适合 90% 的业务系统。它是一个骨架,你可以按自己项目的情况裁剪。
#include <functional> #include <memory> #include <mutex> #include <vector> #include <algorithm> #include <map> template <typename EventT> class EventDispatcher { public: using Handler = std::function<void(const EventT&)>; int subscribe(Handler handler) { std::lock_guard<std::mutex> lock(mutex_); int handle = nextHandle_++; handlers_[handle] = std::move(handler); return handle; } void unsubscribe(int handle) { std::lock_guard<std::mutex> lock(mutex_); handlers_.erase(handle); } void publish(const EventT& event) { std::vector<Handler> snapshot; { std::lock_guard<std::mutex> lock(mutex_); snapshot.reserve(handlers_.size()); for (const auto& [handle, handler] : handlers_) { snapshot.push_back(handler); } } for (const auto& handler : snapshot) { try { handler(event); } catch (const std::exception& e) { // 记录日志,但保证不中断链路 // log_exception(e.what()); } } } private: std::mutex mutex_; std::map<int, Handler> handlers_; int nextHandle_ = 0; };使用方式非常简洁:
EventDispatcher<ConnectionEvent> dispatcher; int logHandle = dispatcher.subscribe([](const ConnectionEvent& e) { // 处理连接事件,记录日志 }); dispatcher.publish(ConnectionEvent(true, 1001)); dispatcher.unsubscribe(logHandle);这段模板有一个隐含的取舍:handlers_用的是std::map,而不是std::vector,原因是 map 的 key 是句柄,在unsubscribe时的删除是稳定的 O(log N),不会出现 vector 删除导致句柄索引失效的问题;代价是遍历时要访问 map 节点,缓存不友好,但在业务回调函数的开销面前,这通常可以忽略。
如果你偏好性能,也可以用std::vector<std::pair<int, Handler>>和惰性删除,把被标记删除的项在 notify 后再真正清除,那是性能敏感场景的优化路线,一般系统不必一上来就这么做。
5. 写在最后的经验小结
我做过一个跨平台的配置同步模块,最初用抽象基类风格做了一版观察者,上线后几个版本迭代下来,最大感受是:观察者模式真正的难点从来不是“写出来”,而是把注册/注销的生命周期管理好,把通知的并发模型定清楚。
那套系统在早期经常出现两个问题:一是模块销毁时忘了 detach,导致随机崩溃;二是跨线程访问同一个主题对象,导致数据竞争。后来我把第三版的模板落到代码里,不但在设计上强制规范化注册句柄和销毁策略,还把调试日志全部引入,这些坑才算被系统性地堵住。
所以如果你正准备在 C++ 项目里引入观察者模式,我的建议是:
- 第一步,先把最小可运行的版本写出来,跑通状态→通知→回调的主链路。
- 第二步,立刻处理生命周期——无论是
weak_ptr还是有纪律的 detach,必须有一个兜底策略。 - 第三步,再考虑线程安全模型——先问自己,事件在哪个线程产生,回调在哪个线程执行,这两个答案会决定你的锁和快照方案。
- 第四步,加日志、加保护,把抗风险能力做上去,然后才能谈上线。
这套流程我自己实践下来,比每一次都从零去设计要省心得多。观察者模式不是一个需要背下来的类图,而是一个需要结合业务上下文不断打磨的工程工具,把它用顺了,代码的可维护性会发生肉眼可见的变化。