先聊点实际的。观察者模式大概是面试里出现频率最高的设计模式之一,但大多数 C++ 开发者对它的理解,还停留在“一个 Subject 维护 Observer 列表,notify 的时候挨个调 update()”这个课本版本上。这个版本本身没错,可一旦你把它放进真实项目——尤其是网络通信、 UI 事件分发、跨模块状态同步这类场景——你会立刻碰上一连串课本里没有回答的问题:观察者生命周期和主题绑定太脆弱怎么办?通知顺序能不能保证?跨线程 notify 谁来管?消息类型一多,是不是要为每一种事件类型都建一套回调接口?
这篇文章不打算再复述一遍教科书例子。我想把我这几轮项目里实际用过的几种观察者模式变体整理出来——包括解决悬垂指针的弱引用绑定、让回调支持 lambda 的 function 聚合式观察者、能扛跨线程的串行派发变体、以及批量聚合事件的性能优化形态。每个变体我都尽量把设计动机、实现思路、以及它引入的新问题都讲清楚。如果你是那种已经会用标准模式、正在为工程里别扭之处头疼的 C++ 开发者,这篇文章应该正好对胃口。
1. 标准观察者模式的固有毛病:三个真实工程痛点
在动手写变体之前,先把标准实现的“病根”分析清楚。我个人认为,观察者模式本身没有设计缺陷,缺陷出在 C++ 这套语言特性加上真实工程环境之后,使用者不得不面对的三个纠缠问题。
第一个是生命周期问题。典型实现里,Subject 持有的是 Observer 的原始指针或引用。一旦 Observer 对象被析构,但忘记从 Subject 的列表里移除自己,那么下一次 Notify() 就是一次野指针调用,程序大概率直接崩掉。你可能觉得“忘了解除订阅”是粗心,但真实项目里观察者经常分布在不同的模块、由不同的 owner 管理,析构路径极其复杂,漏解绑几乎是必然事件。我见过太多次因为这种问题线上崩溃的 case,最后排查出来的原因根本不是业务逻辑,而是某个模块退出时没有反注册。
第二个痛点是回调形态单一。标准模式要求 Observer 继承一个带 virtual Update() 的基类,这就强加了继承关系。但是很多场景下,观察者只是一个业务类,它不想为了收个通知去继承什么 ObserverBase;又或者我们想让某个普通成员函数、甚至 lambda 表达式成为观察者,标准模式完全没法支持。这样一来,标准模式在 C++ 里显得格外笨重——明明 std::function 已经这么灵活了,为什么观察者还非得走虚函数那一套?
第三个痛点是上下文缺失。标准模式的 Notify() 不带参数,或者最多带一个空泛的 Subject* 指针。可实际的业务消息往往需要携带事件相关的上下文信息:文件路径、网络包、进度百分比、状态码。为了解决这个问题,常见的做法是给 Update() 塞一个 EventArg 基类指针,然后在观察者里向下转型。这确实能用,但每加一种事件类型就要新增一个 EventArg 子类,观察者内部还要写一堆 if-else 或 switch 处理不同消息,时间一长代码就变成了“类型分派地狱”。
我列个表格总结一下标准模式的痛点,后面所有变体都是围绕这几项在做文章:
| 痛点 | 典型场景 | 后果 |
|---|---|---|
| 生命周期耦合 | 模块卸载时观察者已析构 | 悬垂指针,崩溃随机出现 |
| 回调形态僵化 | 只想绑定一个现有成员函数 | 被迫引入继承关系或写适配类 |
| 消息上下文缺失 | 需要传递进度值/错误码/事件参数 | EventArg 子类爆炸,类型分派混乱 |
这三个问题如果只是出现在小 Demo 里,确实无伤大雅,可一旦观察者模式接入到长周期运行的服务器进程或者大型客户端软件里,它面对就不再是“好用不好用”,而是“能不能继续稳定运行”。所以,在实际工程里我更愿意把观察者模式当作一个“基座”,在此基础上按场景做调整,变成不同的变体。
2. 弱引用变体:把裸指针换成 weak_ptr,先解决崩溃问题
解决生命周期问题最直接的办法,就是让 Subject 持有 Observer 的弱引用而不是裸指针。这样哪怕某个 Observer 已经析构,它的 weak_ptr 会自动变成空悬状态,在通知阶段做一个 lock() 判断就能安全跳过。我把这种形态称为“弱引用变体”。
有人会说,我在析构函数里 unregister 不就行了?这确实是标准做法,但在项目里不太可靠。原因有两个:一是 Observer 可能根本不知道自己在哪个 Subject 上,模块间互相引用时很难保证反注册调用一定发生在析构之前;二是就算你写了 unregister,也保不齐某个异步路径在对象析构之后还在触达。
用 weak_ptr 的方案就很省心。实现起来大概是这样的思路:让观察者本身继承enable_shared_from_this,在注册的时候把自己的weak_ptr交出去,Subject 内部存储std::list<std::weak_ptr<IObserver>>。通知时先遍历,对每个 weak_ptr 执行lock(),如果成功说明对象还活着,可以安全调用;如果失败就把这个空项从列表里剔除掉。
class IObserver { public: virtual ~IObserver() = default; virtual void Update() = 0; }; class Subject { public: void Attach(const std::weak_ptr<IObserver>& observer) { observers_.push_back(observer); } void Notify() { for (auto iter = observers_.begin(); iter != observers_.end();) { if (auto sp = iter->lock()) { sp->Update(); ++iter; } else { iter = observers_.erase(iter); } } } private: std::list<std::weak_ptr<IObserver>> observers_; };这个写法比裸指针安全得多,但也有两个新的实际问题需要处理。第一个是锁的粒度:lock()本身是有开销的,一次Notify()要调用 N 次锁,如果 Agent 特别多、通知又特别频繁,这个开销会变得不可忽略,后面我会在性能变体里再讲怎么优化。第二个是对象已被销毁但仍排队:weak_ptr 能帮你挡住“已销毁对象”,但它无法阻止“还在活着但逻辑上已经退出”的对象收到最后一轮通知。换句话说,weak_ptr 解决的是内存安全,不解决业务逻辑上的生命周期语义。
我在实际项目里还会配合做一个“代际令牌”的设计,来弥补语义上的缺口。简单说就是给每个观察者注册时分配一个单调递增的令牌号,注销时把令牌号登记到 Subject 的失效集合里。通知时除了检查 weak_ptr,还检查令牌是否在失效集合中。这样既可以做到即时注销生效,也避免了“对象活着但业务已失效却还在收通知”的尴尬。
Subject::ObserverHandle Subject::Attach(const std::weak_ptr<IObserver>& observer) { uint64_t token = next_token_++; observers_.push_back({token, observer}); return token; } void Subject::Detach(ObserverHandle handle) { invalid_tokens_.insert(handle); }这个代际令牌配合 weak_ptr 的方案,我用了很久没出过大问题。它唯一的代价是内存里多了一个失效集合,查询是 O(1) 的,通知时多一次哈希查找,工程上完全可接受。如果你在维护一个生命周期特别复杂的系统——比如插件系统或者运行时热加载系统,这个变体几乎是最稳妥的起点。
3. 回调聚合变体:用 std::function 取代继承体系
第二个痛点,回调形态僵化,用弱引用变体解决不了。在标准模式里,观察者必须继承抽象基类并实现 Update(),这等于强迫所有观察者都进入同一个类型体系。更常见也更灵活的做法是:不再定义 IObserver 接口,而是直接把观察者回调定义为std::function,让任何可调用对象都能成为观察者。
这个变体通常被称为“事件聚合器”或“回调列表”。核心思路是用一个存储回调函数对象的容器代替观察者对象列表。实现上,Subject 只需要暴露两个接口:一个注册回调的Connect,一个触发回调的Notify。注册回调时,自定义一个Connection对象作为令牌,用来管理解除订阅,避免直接用迭代器,因为容器在并发或匿名回调场景下的迭代器很容易失效。
class EventBus { public: using Callback = std::function<void(const std::string&, int)>; Connection Connect(Callback cb) { auto iter = callbacks_.insert(callbacks_.end(), std::move(cb)); return Connection(this, iter); } void Notify(const std::string& event, int value) { for (auto& cb : callbacks_) { if (cb) { cb(event, value); } } } private: std::list<Callback> callbacks_; }; class Connection { public: Connection(EventBus* bus, std::list<EventBus::Callback>::iterator iter) : bus_(bus), iter_(iter) {} void Disconnect() { if (bus_) { bus_->callbacks_.erase(iter_); bus_ = nullptr; } } private: EventBus* bus_; std::list<EventBus::Callback>::iterator iter_; };改成std::function聚合之后,最大的好处是可以用 lambda 就地绑定一段逻辑,或者绑定某个对象的成员函数,完全不需要为了“收个通知”单独建一个类。举个具体的例子,在某个消息处理模块里,我想订阅一个“配置变更”事件,只需要这样:
configBus.Connect([this](const std::string& key, int newValue) { reconfigureInternal(key, newValue); });如果用标准观察者,我得先定义一个ConfigObserver类,继承接口,重写 Update,注册到 Subject 里——代码量差了一个数量级。
这个变体也有坑,而且还挺隐蔽。第一个坑是回调对象生命周期:std::function能安全存储 lambda,但如果 lambda 捕获了this指针,那么 this 指向对象析构时,回调里仍然持有一个悬垂 this。上一节的 weak_ptr 方案还只保护观察者对象,这一节直接掉进“捕获了 this 却没人管”的陷阱。我用过一个非常稳健的补充做法:给Connect增加一个重载,接受std::weak_ptr<void>作为哨兵,在回调函数里先 lock 检查,如果失败就跳过执行:
Connection Connect(std::weak_ptr<void> guard, Callback cb) { auto wrapped = [guard, cb = std::move(cb)](auto&&... args) { if (auto locked = guard.lock()) { std::invoke(cb, std::forward<decltype(args)>(args)...); } }; auto iter = callbacks_.insert(callbacks_.end(), std::move(wrapped)); return Connection(this, iter); }本质上是给 lambda 观察者套了一层“守卫”,把生命周期安全延伸到回调捕获的上下文对象上。第二个坑是通知顺序的一致性。标准模式里你还能隐约感觉到 Observer 容器是 std::list,追加顺序就是通知顺序,但引入 lambda、匿名函数、自动断开之后,顺序维护就没有那么自然了。如果你有“先订阅者先收到通知”这种业务诉求,那你必须在文档里明确这个语义,并且坚决使用具有稳定顺序的容器。
回调聚合变体是目前工程上最常用的形态,官方事件框架、UI 框架几乎全部采用这种设计。它牺牲了标准模式“代码结构一目了然”的优势,但换来了极大的灵活性。如果你特别看重“观察者必须是一个明确的类,方便追踪和测试”,那标准模式可能更适合;但如果你更看重扩展速度和组合能力,回调聚合是更优解。
4. 线程安全变体:串行派发与拷贝快照的取舍
接下来的变体,来自一个很具体的需求:Subject 可能运行在后台线程,Observer 却活在主线程;或者一个事件可能要同时通知多个模块,而其中某个观察者回调里居然会去修改 Subject 的观察者列表。这种“通知过程中增删观察者”的情况,恰恰是标准模式最容易出现隐蔽 bug 的角落。
我遇到过的最典型的崩溃场景是这样:通知完第一个观察者之后,第二个观察者的回调内部触发了解除订阅,把第三个观察者从列表里移除了;然后通知逻辑继续遍历,用到了一个已经失效的迭代器,直接 UB。这类问题用简单的互斥锁能挡住一部分(列表在遍历中不被并发修改),但挡不住“同一个线程里回调重入”造成的迭代器失效。因为你用 mutex 保护列表后,回调里的 Detach 会去拿同一个锁,但这发生在触发循环内部,会导致自死锁;如果不用锁,迭代器又会失效。
我在工程里总结出三个靠谱的方案,按场景选择。
方案一是串行派发队列。这个思路是把真正的通知动作从 Notify 调用线程移到一个专门的事件循环里去执行。所有收回调请求都入队,队列背后有一个线程(或者某个 strand)逐个处理。这样观察者列表的遍历只发生在队列线程内,增删也只在队列线程内完成,天然没有竞争。代价是通知变成异步的,观察者看到事件时,事件已经发生了有一会儿。这个方案特别适合 UI 框架里的界面刷新通知,反正你也不想在后台线程直接操 UI。
方案二是拷贝快照派发。它的做法是在递增互斥锁的保护下,把观察者列表完整拷贝到一份临时容器,然后释放锁,再遍历临时容器。这样即使通知过程中有人修改了原列表,也不会影响当前这一轮派发,迭代器安全,也不会自死锁。代价嘛,自然是每次 Notify 都是一次 O(N) 的拷贝,在观察者数量很大、通知频率又很高的情况下,这个拷贝成本会让人咂舌。
void Notify(int eventId) { std::vector<Callback> snapshot; { std::lock_guard<std::mutex> lock(mutex_); snapshot = callbacks_; // 拷贝 } for (auto& cb : snapshot) { std::invoke(cb, eventId); } }方案三是倒序派发 + 延迟清理,这是我自己常用的一个折中方案。倒序派发处理的是“回调里解除订阅”的安全问题——因为从尾部向前遍历时,删除当前迭代器不会影响前面的迭代器;但是如果删除的是前面的观察者,问题依旧。所以我在实际里再叠加了一个“待删除集合”:回调里如果解除订阅,只是把对应观测者放进一个 pending 集合,本轮 Notify 全部结束后,再把 pending 集合里的节点统一从列表里摘除。这样既不用在回调执行时改链表结构,也避免拷贝快照的 O(N) 开销。
void Notify(int eventId) { // 遍历正序,但删除动作不会立刻生效 for (auto iter = callbacks_.begin(); iter != callbacks_.end(); ++iter) { if (pending_removals_.contains(iter)) { continue; // 本轮仍然触发? 这里看语义 } std::invoke(*iter, eventId); } // 统一清理 for (auto it : pending_removals_) { callbacks_.erase(it); } pending_removals_.clear(); }提醒一下,这个延迟清理的语义值得认真设计:如果在第 3 个回调里解除了第 5 个观察者,那么这一轮该不该让第 5 个观察者继续收到消息?不同系统的预期不一样。我倾向于“本轮不再触发”——既然业务方已经明确解除了订阅,就应该立即生效;但这会造成遍历时跳过某些迭代器,代码里要小心维护。
线程安全变体没有一个通吃的银弹。按我的经验,事件频率低、观察者数量少的场景,拷贝快照派发最省心;事件频率高、观察者数量大但可以接受异步的场景,串行派发队列最稳;既不希望引入异步、又希望保持响应速度的场景,延迟清理配合一定的标记位是值得深入的方向。
5. 性能变体:批量聚合事件与无锁广播
说到性能,就不得不面对观察者模式的另一个尴尬现实:它天生是一对多逐个调用,而且调用的是 std::function 或虚函数,本身就难以内联或向量化。观察者很多、事件很密集时,模式本身的开销会成为瓶颈。
我实际遇到过一个场景:游戏引擎里每帧要广播位置变更事件,场景内有几百个实体订阅了这个事件,每个实体收到之后都去做各自的碰撞检测或者渲染更新。最初的实现就是普通的事件总线,结果 Profiling 显示 Notify 阶段占了每帧 CPU 时间的 15% 以上。这不是业务逻辑慢,纯粹是模式开销。
解决这个问题的思路有两个方向。第一个方向是批量聚合事件:不要把每个微小变化立刻作为单独事件广播,而是把一段时间内(或一帧内)的同类事件合并成一个批处理事件,在帧末统一派发。比如位置变更可以先写到一个待发数组里,一帧结束时合并成“场景内所有实体位置快照”一次性广播。观察者从“被调用 N 次”变成“被调用一次,收一个数组”。这个变体牺牲了实时性,但换来了巨大的派发开销下降。
第二个方向是无锁或有锁但极少冲突的广播。如果观察者列表在运行期间只会在初始化阶段固定好,运行阶段不再增删,那么你就可以用一个固定大小的std::array或std::vector<Callback>,通知时直接遍历,不加锁。因为“没有并发增删”这个前提意味着迭代一致性天然成立。我在实践中把这种前提明确写进接口文档:运行期禁止 Connect/Disconnect,必须先在 setup 阶段注册完毕。这听起来限制很大,但在很多系统里完全成立,比如网络包处理管线,包处理阶段绝不允许动态调整处理函数。
还有一个更底层的性能技巧:空间局部性。标准std::list<Callback>的节点散落在内存各处,遍历时会有严重的 Cache Miss。换成std::vector<Callback>之后,回调地址是连续内存,遍历速度通常能提高 30%~50%。代价是 vector 的插入会移动元素,迭代器稳定性差;但如果你能接受“观察者数量不大、增删不频繁”,vector 是明显更优的选择。
class BroadcastBus { public: void Setup(std::vector<Callback> cbs) { callbacks_ = std::move(cbs); // 固定 } void Notify(const FrameUpdate& frame) { for (const auto& cb : callbacks_) { // 连续内存,cache友好 cb(frame); } } private: std::vector<Callback> callbacks_; };不过要注意,性能优化做到这一步,观察者模式的“动态灵活性”已经被削弱了不少。我发现很多人在做性能优化时容易上头,最后把模式改得已经看不出观察者模式的样子了。我的建议是分阶段:先用 Profiling 确认这个版本真是瓶颈,再考虑性能变体;如果只是怀疑“它可能慢”,我宁愿先保持代码清晰,等其他优化手段用完再看它。
6. 语言特性加成:变参模板和运行时概念约束的变体形态
再往前走一步,C++ 的语言特性还能让观察者模式长出一些更现代、更灵活的形态。我提三种我觉得实践中最有价值的:变参模板泛型化、概念约束、以及泛型事件令牌。
第一种是变参模板泛型化。标准模式和回调聚合都有一个预设约定:事件携带的参数表是固定的(最多两个、三个参数)。而真实系统里,不同事件的参数签名完全不同,有的要传三个参数,有的一个都不传。变参模板可以让我们把事件类型本身参数化,真正做到“一个总线,任意签名”。
template<typename... Args> class EventChannel { public: using Callback = std::function<void(Args...)>; void Publish(Args... args) { for (auto& cb : callbacks_) { cb(args...); } } private: std::vector<Callback> callbacks_; }; // 用法 EventChannel<int, std::string> config_changed; EventChannel<> tick_channel;好处一眼就能看出来:不同事件拥有不同签名,不再需要把所有东西都塞进一个 EventArg 基类,也不用做向下转型。发布和订阅的类型安全是由编译器保证的,传错参数类型在编译期就报错。缺点是实现分布式的事件路由时,所有不同签名的通道各自独立,要统一管理就很麻烦,通常建议用一个上层管理器把这些通道实例都聚合起来,统一注册。
第二种是概念约束。这是 C++20 以后的新能力,我们可以给观察者回调定义一些语义约束,比如“必须是可以无异常调用的”。配合std::is_nothrow_invocable_v这类 type trait,可以静态断言回调不会抛出未捕获异常。因为观察者模式在广播场景下,某个观察者抛异常会导致后续所有观察者收不到消息。我建议把回调标记为noexcept,或者在派发层捕获所有异常,保证一个观察者的失败不拖垮整个广播链路。
template<typename F> concept NoThrowObserver = std::is_nothrow_invocable_v<F, int>; template<NoThrowObserver... Observers> void Broadcast(int value, Observers... obs) { (obs(value), ...); // 折叠表达式,逐个安全调用 }第三种是泛型事件令牌。我们可以用一个枚举、一个字符串 ID 或者一个类型标签作为事件的唯一“钥匙”,将不同类型的事件映射到不同通道或者同一通道的不同回调上。我在项目里见过一个优雅的实现:以事件类型(比如struct FileOpenedEvent这种 tag)作为模板参数,用std::type_index作为事件的 key,订阅者注册type_index -> EventChannel<EventData>的映射。
template<typename EventData> class EventBus { public: template<typename Observer> void AddObserver(Observer&& obs) { callbacks_.push_back(std::forward<Observer>(obs)); } template<typename... Args> void Publish(Args&&... args) { EventData event{std::forward<Args>(args)...}; for (auto& cb : callbacks_) { cb(event); } } private: std::vector<std::function<void(const EventData&)>> callbacks_; };这种泛型事件形态的最大优点是类型安全:发布者与订阅者通过同一个 EventData 类型达成契约,任何一段代码要想知道某一类事件长什么样,看一个 struct 定义就够了。它在需要高可维护性的中大型项目里非常好用,能有效抑制“魔法数字”“魔法字符串”散布在代码里。
模板与约束为观察者模式带来的还不只是效率与类型安全,它本质上是把“消息协议”从运行期约定推到了编译期校验。但代价也突出:模板代码调试难度高,错误信息晦涩。如果你或者你的团队成员模板功底不深,我建议先从简单的回调聚合变体起步,等确实碰到类型不安全问题时再泛化。
7. 写在最后的变体选择建议
写了这么多,到底该怎么选?我个人的习惯是画一张表,先看业务约束再定方案。如果你正在设计一个新的观察者机制,可以参考这个决策路径:
| 约束条件 | 推荐变体 | 理由 |
|---|---|---|
| 观察者生命周期不明确 | 弱引用变体 + 代际令牌 | 防悬垂,防业务语义误触 |
| 观察者回调形态多样、希望免继承 | 回调聚合变体 | std::function 灵活、轻量 |
| 跨线程广播 | 串行派发队列 / 拷贝快照派发 | 兼顾安全与实时性 |
| 高频通知、海量观察者 | 批量聚合事件 + vector 固定列表 | cache 友好、派发次数骤降 |
| 事件类型多、契约要求强 | 变参模板泛型通道 | 编译器保证类型契约 |
如果让我只选一个“万金油”组合,我会首选弱引用哨兵 + std::function 回调聚合 + 延迟清理 + 串行派发选项。它能覆盖绝大多数工程场景,而且每一层都可以按需裁剪。
要知道,观察者模式的变体设计不是越花哨越好。多数时候,标准模式 + 少量保护性修改已经能活得很好;真正驱动变体出现的,是真实工程里“生命周期安全、跨线程通知、类型灵活、性能可控”这四个维度中的至少一个被压到临界点。建议你在动手改造之前,先拿 Profiling 数据和线上崩溃日志说话,让需求驱动设计,而不是为了炫技去堆砌变体。