如果你写过C++项目,早晚会遇到这种场景:某个模块的数据变了,其他几个模块都想第一时间知道,但又不想让它们直接黏在一起,甚至接口互相依赖。观察者模式就是为这个问题准备的。这里先给读者一个画像:你已经有C++基础,能写类、碰过模板、用过智能指针,但可能被教科书里的UML图坑过,或者在项目里因为回调生命周期的问题炸过几次。这篇文章不打算重复讲一遍观察者模式的教科书定义,而是从一个C++开发者的真实经验角度,聊聊为什么经典写法在C++里这么别扭,以及我后来用现代标准库组件做的落地写法。文中会给出可直接使用的代码,也会讲清楚每一步设计背后的取舍。
1. 从项目现场说起:我为什么把观察者模式重写了好几遍
某个模拟项目X的早期版本里,我们做一个实时数据监控界面。数据采集线程不断产生新数据,右边的几个信息面板要实时刷新,底部还要画趋势曲线。最初为了赶进度,做法很直接:把各个面板的指针塞进数据源,数据更新时挨个调用。加第三个面板时勉强能忍,加到第五个时,代码已经乱到每次改动都要检查调用顺序,新增一个面板要去数据源里改一长串逻辑。
于是我想到观察者模式:数据源作为主题,各个面板作为观察者,数据变化时自动广播。第一次按教科书实现了一个抽象基类接口加一个管理类,数据源只管发布事件。刚开始确实清爽,但问题很快暴露。接口的update方法参数写的是int,后来需求改成了结构体,所有观察者的签名都要跟着改;某个面板在析构时忘记调用退订方法,程序在退出时不定时崩溃。那段时间我几乎每个星期都在处理“通知时对象已经没了”这类问题。后来我干脆推翻重写,先梳理清楚C++里到底哪些地方和教科书方案对不上,再用现代写法替换掉。
这个项目经历让我意识到一件事:观察者模式的核心不是写一个类,而是想清楚类型、生命周期、并发这三个问题。很多人说C++的观察者模式难写,难不是难在概念上,而是难在这些细节上。后面几节我会把这几个问题一个一个拆开讲,并且给出可复现的代码。
2. 经典GoF写法在C++里最尴尬的三个时刻
经典的观察者模式在UML里很简洁:主题持有一组观察者指针,观察者是一个抽象接口,派生类实现更新方法。在C++里通常会写出类似这样的代码:
class IObserver { public: virtual ~IObserver() = default; virtual void update(int value) = 0; }; class Subject { public: void attach(IObserver* observer) { observers_.push_back(observer); } void detach(IObserver* observer) { observers_.erase(std::remove(observers_.begin(), observers_.end(), observer), observers_.end()); } void notify(int value) { for (IObserver* observer : observers_) { observer->update(value); } } private: std::vector<IObserver*> observers_; };这段代码能跑,但它把三个麻烦问题留到了后面。这三个问题不是语法问题,而是设计层面和对象生命周期管理层面的问题,只要规模稍大,就会一个接一个冒出来。
2.1 类型擦除:观察者被一个共同基类绑死了
第一个尴尬:类型擦除。所有观察者必须继承同一个接口,而且update的参数类型写死了。如果我想让一个自由函数、一个lambda、或者某个与这个接口无关的类的成员函数也能订阅事件,这条路会被直接堵死。为了传递不同类型的数据,通常只能准备多套观察者接口,或者用void*来回转,严重了就干脆把整个主题模板化。
举个例子:一开始监控面板只需要接收int类型的数值,后来需要同时接收温度、湿度、报警级别三种不同类型的消息。如果用经典写法,要么给每种消息定义一套Observer接口,要么把update改成一个带很多参数的万能函数。无论哪种,代码都会快速胀大。观察者模式本来是为了解耦,结果类型系统把耦合又从另一个方向拉了回来。
2.2 生命周期:裸指针把双方都吊在悬崖边
第二个尴尬,也是我在项目里遇到最多的:生命周期。主题持有观察者的裸指针,观察者并不清楚主题什么时候会把指针当垃圾处理。最典型的崩溃现场是:一个面板对象先被销毁,而主题里还保存着它的裸指针;下一次notify时,程序走进一块已经释放的内存,轻则随机崩溃,重则静默写乱堆内存。
反过来也一样:主题先销毁,观察者持有的主题指针同样悬垂。传统C++里所谓“在析构函数里记得detach”,实际上是靠人肉纪律。写过大型项目的人都知道,人肉纪律是最不可靠的东西。漏一次,就会出现一个只在退出时才出现的偶发崩溃,排错排到怀疑人生。
2.3 通知期间修改订阅表:迭代器失效的隐蔽变体
第三个尴尬:容器被修改。notify遍历期间,如果某个观察者的update方法里又调用了attach或detach,vector可能发生realloc或元素搬移,正在进行的遍历就会失效。等到崩溃时去定位,往往看不出来跟观察者模式有什么关系。它看起来就是一个普通的迭代器失效问题,但发生时机极其隐蔽,因为你很难想到回调内部会去改订阅表。
如果你只是在单线程环境里写一个小的测试程序,经典写法完全够用。但一旦进入产品代码,就要额外扛住生命周期、类型退化和容器一致性这三座大山。这也是为什么我会建议你用现代C++组件把它们逐一拆掉。
3. 用标准库组件重写一个现代版Event
既然经典写法的问题出在类型擦除、生命期和容器一致性上,那就逐个对症下药。我用标准库里的std::function承担“类型擦除”,用模板让事件携带任意类型的数据,用一个RAII令牌处理退订。这样既不用观察者继承基类,也能在析构时自动解除订阅。
先看代码,然后我会解释每一块设计为什么会是现在这个样子。
#include <functional> #include <vector> #include <mutex> #include <memory> #include <algorithm> class Subscription { public: Subscription() = default; Subscription(const Subscription&) = delete; Subscription& operator=(const Subscription&) = delete; Subscription(Subscription&& other) noexcept : alive_(std::move(other.alive_)) {} Subscription& operator=(Subscription&& other) noexcept { if (this != &other) { unsubscribe(); alive_ = std::move(other.alive_); } return *this; } ~Subscription() { unsubscribe(); } void unsubscribe() noexcept { if (auto sp = alive_.lock()) { *sp = false; } alive_.reset(); } private: explicit Subscription(std::shared_ptr<bool> alive) : alive_(alive) {} std::weak_ptr<bool> alive_; template <typename... Args> friend class Event; }; template <typename... Args> class Event { public: using Callback = std::function<void(Args...)>; Subscription subscribe(Callback callback) { auto alive = std::make_shared<bool>(true); { std::lock_guard<std::mutex> lock(mtx_); callbacks_.emplace_back(alive, std::move(callback)); } return Subscription(alive); } void publish(Args... args) { std::vector<Entry> snapshot; { std::lock_guard<std::mutex> lock(mtx_); snapshot = callbacks_; } for (auto& [alive, callback] : snapshot) { if (*alive) { callback(args...); } } } void prune() { std::lock_guard<std::mutex> lock(mtx_); callbacks_.erase( std::remove_if(callbacks_.begin(), callbacks_.end(), [](const Entry& entry) { return !*entry.alive; }), callbacks_.end()); } private: struct Entry { std::shared_ptr<bool> alive; Callback callback; }; mutable std::mutex mtx_; std::vector<Entry> callbacks_; };3.1 为什么用std::function而不是抽象基类
这里最关键的一个选择是:让事件保存std::function<void(Args...)>,而不是一个抽象接口指针。std::function所以能做到这一点,是因为C++11之后我们可以捕获任意可调用对象:普通函数、函数对象、成员函数绑定、lambda,统统可以。这样,订阅方不需要继承任何类,自然就解决了第一个尴尬。
我们说这叫“面向调用约束编程”,而不是“面向接口继承编程”。接口继承把类型锁死,而std::function只约束“能被调用,并且参数匹配”。这个宽泛的约束足够让开发者自由组合各种场景,同时编译器还会在subscribe调用处做类型检查,传递错误参数类型时编译直接报错。
3.2 订阅令牌为什么要做成RAII
我在设计里让subscribe返回一个Subscription对象。它最大的特点就是析构时自动调用unsubscribe,这就把一个非常关键的生命周期动作,从“人肉记得调用”变成“对象销毁时自动触发”。
回调内部保存的shared_ptr<bool>,是这份订阅的“存活标记”。Subscription保存的是同一个标记的weak_ptr。当Subscription析构时,它通过weak_ptr把自己能接触到的那份标记改成false。这样一来,即使主题的事件表里仍然保存着这条回调,publish时也会因为标记为false而跳过。这相当于把退订操作从“物理删除”变成“逻辑失效”,再配合后面说的prune做清理。逻辑失效的好处是无需在退订时立刻改动主题内部容器,降低了数据结构并发修改风险。
3.3 为什么还要一个prune清理函数
因为退订时只做了逻辑失效,那条回调仍然留在callbacks_里。如果高频率订阅和退订,容器里会积累大量已失效条目,每次publish都要遍历它们,白白浪费CPU。
所以prune方法负责真正从容器里清除已失效条目,用remove_if配合erase,把标记为false的元素一次性清理掉。在实际项目中,可以在订阅时顺手检查是否需要清理,比如每订阅100次就调用一次prune。不需要在每个退订瞬间立刻物理删除,因为那反而会带来容器移动和锁竞争的新问题。
3.4 一个业务侧的使用示例
用在业务代码里会很简洁,比如一个数据面板:
class DataPanel { public: explicit DataPanel(Event<DataChanged>& event) : subscription_(event.subscribe([this](const DataChanged& data) { refresh(data); })) {} ~DataPanel() { subscription_.unsubscribe(); } private: Subscription subscription_; void refresh(const DataChanged& data) { // 更新面板界面 } };这里有个细节:DataPanel的析构函数一开始就调用unsubscribe。这么做不是因为Subscription析构时不会调用unsubscribe,而是为了抢在其它成员销毁之前让事件表里的回调立即失效。如果只依赖成员析构顺序,那么当sub_在成员销毁阶段析构时,对象里其它成员可能已经被析构了,万一此刻回调正好在另一个线程执行,就可能访问到已销毁的成员。所以我在实际项目中养成了习惯:析构函数的第一步先退订,再析构其它东西。
4. 生命周期与线程安全:避开来自身边最常见的一枪
写观察者模式,逃不开两个词:生命周期和并发。这两个问题我都在真实项目里摔过跟头。把它们拆开讲清楚,你就能省掉好几天排查时间。
4.1 两种悬垂场景和对应的处理策略
第一种场景:观察者先销毁。假设某个订阅回调里捕获了this,而观察者对象被提前销毁,主题却仍然保留这个回调。下一次publish时,callback执行就会访问已经失效的this。很多C++开发者会直接用lambda捕获this,却忘了设置退订时机,结果程序在偶发时间点崩溃。
解决途径是把退订提权和自动化。在我的Event设计里,观察者把自己的Subscription作为成员保存,析构时自动退订。但正如上文说的,最稳妥的做法还是在析构函数体内最先调用unsubscribe。如果你想把回调也做一个安全兜底,可以不再捕获this,而是捕获一个weak_ptr,每次回调执行时先尝试提升为shared_ptr,提升成功才继续。这个做法的代价是每次回调多一次弱引用锁操作,但能换来非常可观的析构安全性。对于某些服务型组件,我认为值得。
第二种场景:主题先销毁。如果观察者持有的Subscription在主题销毁后还调用unsubscribe,因为它保存的是weak_ptr,而主题内部那个shared_ptr在其析构时已经被销毁,weak_ptr会自然失效,unsubscribe就成了一个安全的空操作。这一点正是我在设计中使用weak_ptr而不是shared_ptr的原因:不让Subscription逆向维持Event的生命。事件都已经没了,退订本身就不该再产生副作用。
4.2 多线程发布时的数据竞争
项目里更常见的情况是:某个工作线程在产生数据,UI线程在订阅或退订,此时publish又正在遍历订阅表。这个场景有三个位置的访问可能并发碰撞:订阅表本身、共享的存活标记、回调要访问的业务数据。
对订阅表的保护,我在Event里用mutex实现。subscribe和prune都会持锁操作callbacks_,publish则先持锁拷贝一份快照,然后再到锁外执行回调。这里有两个刻意为之的设计。
第一,拷贝快照后及时释放锁,是为了避免在持锁状态下去执行用户回调。如果回调内部又触发了同一个Event的subscribe或publish,而外层的锁还没释放,就会产生死锁。就算不死锁,用户回调里万一有耗时操作,也会把整个发布链卡住,让所有订阅者一起等待。
第二,快照里保存的是shared_ptr和std::function的拷贝,而不是会变成悬垂的裸指针。因此,在遍历的过程中,即使某个订阅者退订了,快照里的回调依然安全存在。我们只需在调用前检查存活标记,标记为false就跳过。这其实是一种“读时快照”策略,牺牲一点点内存分配和拷贝成本,换取用户回调时可以自由订阅和退订的灵活性。
4.3 锁粒度与无锁选择
如果订阅和发布都非常频繁,对订阅表每次做整体拷贝也是不便宜的。一个几百个观察者的表,每次publish都复制一遍,在性能敏感的路径上并不可取。这时有两个值钱的方向。
一个方向是锁粒度做完再减小。比如改用读写锁,publish时持读锁直接遍历,subscribe和退订时持写锁修改容器。但要小心,如果在持锁状态下执行回调,回调里再去订阅同一事件,读锁上锁会失败或等待,可能触发死锁。所以即使用了读写锁,也常常需要通过“先拷贝再通知”来规避递归修改。
另一个方向是彻底无锁化。如果你的系统里所有事件都发生在同一个线程,根本不考虑跨线程并发,比如在窗口消息循环里统一派发,那就完全不需要mutex。把Event里的锁去掉,订阅直接push_back,publish直接遍历原容器就行。但要注意,这种情况下回调里修改订阅表仍然会造成迭代器失效,所以至少要把当前正在遍历的容器拷贝一份,或者对所有在回调里修改容器的行为作统一约束。
我实际给出的建议是:默认先用mutex加快照方案,因为它最容易证明正确;当性能测试明确告诉你性能瓶颈出现在事件派发上,再根据线程模型尝试读写锁或单线程无锁。不要一上来就优化,观察者模式里多一个锁的代价通常远小于一次偶发崩溃的排查代价。
4.4 死锁的另一个来源:回调自身递归
除了锁粒度问题,还有一个很容易忽略的死锁来源:回调内部再次publish同一个事件。如果你在publish期间持有锁,而回调中又触发publish,锁被同一个线程再次请求,非递归锁立刻死锁。这也是设计里“先拷贝快照、放出锁再回调”的深层原因。它牺牲了一点性能,但换来了很可贵的递归容错。我们实际代码里曾有一个模块在回调里改变了数据源,数据源又发出了新事件,如果没有快照策略,那个线程直接卡死。
5. 调试、测试与性能:项目实战里的三条经验
一个观察者模式代码写完了,不等于能稳定交付。我在实战中积累了一些调试和测试的经验,以及性能上的取舍判断,这里整理成三条,希望对你有参考价值。
5.1 给订阅者一个可观测的ID
一旦项目里事件的种类多起来,排查“为什么这个面板没刷新”就成了体力活。我后来在业务回调外层包裹日志时,给每个订阅者分配了一个字符串ID或整数ID,然后在订阅、退订、被回调三个位置都打印一行带时间戳的日志。这样当某个面板漏了更新,翻日志能直接看到它有没有订阅、有没有被回调、订阅是什么时候退订的。
这条经验听起来简单,但作用很大。没有可见性的时候,你只能猜是谁没有订阅;有了日志之后,你一下就能定位到是订阅时机错了,还是回调内部逻辑错了。对生产环境,可以只在启动和退出时打订阅表全量快照,线上定位偶发问题再临时开详细日志。
5.2 异常策略:一个回调抛出异常会不会拖垮所有人
如果某个订阅回调内部抛出异常,而publish没有捕获,异常会顺着publish调用点往上传。这意味着一个订阅者的错误会导致后续所有订阅者无法收到本次通知,极端情况下还会把事件发布线程直接中断。C++默认不做异常拦截,所以这个问题很容易被忽略。
两条处理思路。第一,在业务项目里约定回调不允许向外抛异常,所有回调自己try/catch。第二,在Event内部给每个回调套一层防护,捕获异常后打错误日志,继续通知下一个订阅者。第二种对容错更友好,但会增加std::function包装的成本和代码复杂度。我通常在核心业务模块上采取第二条,在边缘场景采取第一条约定,两者配合,既安全又不至于过度包裹。
5.3 性能:std::function到底有多贵
最后聊性能。很多人担心std::function比裸函数指针慢很多,于是拒绝这种设计。实际上std::function的调用开销通常只有几纳秒到几十纳秒的量级,视平台和实现而定。对于UI刷新、网络回包、配置文件变更这类低频事件,完全不是瓶颈。真正需要担心的是高频数据流,比如音频采样、高频行情推送,每秒几十万次以上的回调派发,这时需要考虑专门优化。
优化思路不外乎三种:减少事件分发频率,把多个信号合并成一个批量事件;用直接函数指针数组替代std::function;或者针对单线程场景用无锁队列加预分配槽位来发布。但在我做过的绝大多数业务系统里,先保证正确性才是关键,过早对事件派发做优化,往往收益有限。
我还想提醒一个与性能无关但影响很大的点:回调顺序。订阅者经常会隐式假设自己是最后一个被通知的,或者最先被通知的。在Event里,默认顺序就是订阅顺序。一旦有人依赖这个顺序,就会形成隐性契约。后来我干脆在模块文档里写明“不应该依赖通知顺序”,因为依赖顺序会让系统的扩展性迅速下降。
6. 一些我自己的选型体会
到这里,观察者模式的经典问题、现代实现、生命周期、线程安全、调试性能都已经梳理了一遍。最后说一点我个人的选型体会。
如果项目只需要在单线程里做解耦,类型又相对固定,经典的抽象基类方案其实没有多糟糕,只要严格约束观察者析构前退订即可。而如果你已经用上了C++11以上标准,我更推荐先用本文这种基于std::function和RAII令牌的Event方案。它去掉了一层抽象继承关系,让业务模块更自由,也让退订动作自动融入对象生命周期,是一种在正确性、灵活性和代码量之间比较均衡的解法。
对于更高要求的场景,比如一个项目里有几十种事件、上百个订阅者,还涉及线程切换和异步派发,直接使用成熟的信号槽实现是合理选择。那种库为你处理了队列派发、线程安全、连接管理以及异常隔离等大量细节,不必自己从零维护。但不管用什么库,核心思路依然相同:让事件的产生方和消费方不互相持有指针,让退订动作发生在对象销毁之前。
我在重写那几遍之后得到的最深体会是:观察者模式在C++里的难点从来不是“怎么画UML”,而是“怎么让对象的生命周期和通知时机对齐”。只要抓住这一点,哪怕代码写得不够优雅,也不会在收尾阶段被偶发崩溃折磨。希望这篇文章能帮你少走一点弯路。