
先聊点实际的。观察者模式大概是面试里出现频率最高的设计模式之一但大多数 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::liststd::weak_ptrIObserver。通知时先遍历对每个 weak_ptr 执行lock()如果成功说明对象还活着可以安全调用如果失败就把这个空项从列表里剔除掉。class IObserver { public: virtual ~IObserver() default; virtual void Update() 0; }; class Subject { public: void Attach(const std::weak_ptrIObserver 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::liststd::weak_ptrIObserver observers_; };这个写法比裸指针安全得多但也有两个新的实际问题需要处理。第一个是锁的粒度lock()本身是有开销的一次Notify()要调用 N 次锁如果 Agent 特别多、通知又特别频繁这个开销会变得不可忽略后面我会在性能变体里再讲怎么优化。第二个是对象已被销毁但仍排队weak_ptr 能帮你挡住“已销毁对象”但它无法阻止“还在活着但逻辑上已经退出”的对象收到最后一轮通知。换句话说weak_ptr 解决的是内存安全不解决业务逻辑上的生命周期语义。我在实际项目里还会配合做一个“代际令牌”的设计来弥补语义上的缺口。简单说就是给每个观察者注册时分配一个单调递增的令牌号注销时把令牌号登记到 Subject 的失效集合里。通知时除了检查 weak_ptr还检查令牌是否在失效集合中。这样既可以做到即时注销生效也避免了“对象活着但业务已失效却还在收通知”的尴尬。Subject::ObserverHandle Subject::Attach(const std::weak_ptrIObserver 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::functionvoid(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::listCallback callbacks_; }; class Connection { public: Connection(EventBus* bus, std::listEventBus::Callback::iterator iter) : bus_(bus), iter_(iter) {} void Disconnect() { if (bus_) { bus_-callbacks_.erase(iter_); bus_ nullptr; } } private: EventBus* bus_; std::listEventBus::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_ptrvoid作为哨兵在回调函数里先 lock 检查如果失败就跳过执行Connection Connect(std::weak_ptrvoid guard, Callback cb) { auto wrapped [guard, cb std::move(cb)](auto... args) { if (auto locked guard.lock()) { std::invoke(cb, std::forwarddecltype(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::vectorCallback snapshot; { std::lock_guardstd::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::vectorCallback通知时直接遍历不加锁。因为“没有并发增删”这个前提意味着迭代一致性天然成立。我在实践中把这种前提明确写进接口文档运行期禁止 Connect/Disconnect必须先在 setup 阶段注册完毕。这听起来限制很大但在很多系统里完全成立比如网络包处理管线包处理阶段绝不允许动态调整处理函数。还有一个更底层的性能技巧空间局部性。标准std::listCallback的节点散落在内存各处遍历时会有严重的 Cache Miss。换成std::vectorCallback之后回调地址是连续内存遍历速度通常能提高 30%50%。代价是 vector 的插入会移动元素迭代器稳定性差但如果你能接受“观察者数量不大、增删不频繁”vector 是明显更优的选择。class BroadcastBus { public: void Setup(std::vectorCallback cbs) { callbacks_ std::move(cbs); // 固定 } void Notify(const FrameUpdate frame) { for (const auto cb : callbacks_) { // 连续内存cache友好 cb(frame); } } private: std::vectorCallback callbacks_; };不过要注意性能优化做到这一步观察者模式的“动态灵活性”已经被削弱了不少。我发现很多人在做性能优化时容易上头最后把模式改得已经看不出观察者模式的样子了。我的建议是分阶段先用 Profiling 确认这个版本真是瓶颈再考虑性能变体如果只是怀疑“它可能慢”我宁愿先保持代码清晰等其他优化手段用完再看它。6. 语言特性加成变参模板和运行时概念约束的变体形态再往前走一步C 的语言特性还能让观察者模式长出一些更现代、更灵活的形态。我提三种我觉得实践中最有价值的变参模板泛型化、概念约束、以及泛型事件令牌。第一种是变参模板泛型化。标准模式和回调聚合都有一个预设约定事件携带的参数表是固定的最多两个、三个参数。而真实系统里不同事件的参数签名完全不同有的要传三个参数有的一个都不传。变参模板可以让我们把事件类型本身参数化真正做到“一个总线任意签名”。templatetypename... Args class EventChannel { public: using Callback std::functionvoid(Args...); void Publish(Args... args) { for (auto cb : callbacks_) { cb(args...); } } private: std::vectorCallback callbacks_; }; // 用法 EventChannelint, std::string config_changed; EventChannel tick_channel;好处一眼就能看出来不同事件拥有不同签名不再需要把所有东西都塞进一个 EventArg 基类也不用做向下转型。发布和订阅的类型安全是由编译器保证的传错参数类型在编译期就报错。缺点是实现分布式的事件路由时所有不同签名的通道各自独立要统一管理就很麻烦通常建议用一个上层管理器把这些通道实例都聚合起来统一注册。第二种是概念约束。这是 C20 以后的新能力我们可以给观察者回调定义一些语义约束比如“必须是可以无异常调用的”。配合std::is_nothrow_invocable_v这类 type trait可以静态断言回调不会抛出未捕获异常。因为观察者模式在广播场景下某个观察者抛异常会导致后续所有观察者收不到消息。我建议把回调标记为noexcept或者在派发层捕获所有异常保证一个观察者的失败不拖垮整个广播链路。templatetypename F concept NoThrowObserver std::is_nothrow_invocable_vF, int; templateNoThrowObserver... Observers void Broadcast(int value, Observers... obs) { (obs(value), ...); // 折叠表达式逐个安全调用 }第三种是泛型事件令牌。我们可以用一个枚举、一个字符串 ID 或者一个类型标签作为事件的唯一“钥匙”将不同类型的事件映射到不同通道或者同一通道的不同回调上。我在项目里见过一个优雅的实现以事件类型比如struct FileOpenedEvent这种 tag作为模板参数用std::type_index作为事件的 key订阅者注册type_index - EventChannelEventData的映射。templatetypename EventData class EventBus { public: templatetypename Observer void AddObserver(Observer obs) { callbacks_.push_back(std::forwardObserver(obs)); } templatetypename... Args void Publish(Args... args) { EventData event{std::forwardArgs(args)...}; for (auto cb : callbacks_) { cb(event); } } private: std::vectorstd::functionvoid(const EventData) callbacks_; };这种泛型事件形态的最大优点是类型安全发布者与订阅者通过同一个 EventData 类型达成契约任何一段代码要想知道某一类事件长什么样看一个 struct 定义就够了。它在需要高可维护性的中大型项目里非常好用能有效抑制“魔法数字”“魔法字符串”散布在代码里。模板与约束为观察者模式带来的还不只是效率与类型安全它本质上是把“消息协议”从运行期约定推到了编译期校验。但代价也突出模板代码调试难度高错误信息晦涩。如果你或者你的团队成员模板功底不深我建议先从简单的回调聚合变体起步等确实碰到类型不安全问题时再泛化。7. 写在最后的变体选择建议写了这么多到底该怎么选我个人的习惯是画一张表先看业务约束再定方案。如果你正在设计一个新的观察者机制可以参考这个决策路径约束条件推荐变体理由观察者生命周期不明确弱引用变体 代际令牌防悬垂防业务语义误触观察者回调形态多样、希望免继承回调聚合变体std::function 灵活、轻量跨线程广播串行派发队列 / 拷贝快照派发兼顾安全与实时性高频通知、海量观察者批量聚合事件 vector 固定列表cache 友好、派发次数骤降事件类型多、契约要求强变参模板泛型通道编译器保证类型契约如果让我只选一个“万金油”组合我会首选弱引用哨兵 std::function 回调聚合 延迟清理 串行派发选项。它能覆盖绝大多数工程场景而且每一层都可以按需裁剪。要知道观察者模式的变体设计不是越花哨越好。多数时候标准模式 少量保护性修改已经能活得很好真正驱动变体出现的是真实工程里“生命周期安全、跨线程通知、类型灵活、性能可控”这四个维度中的至少一个被压到临界点。建议你在动手改造之前先拿 Profiling 数据和线上崩溃日志说话让需求驱动设计而不是为了炫技去堆砌变体。