1. 项目概述为什么C开发者绕不开观察者模式如果你用C写过稍微复杂一点的系统比如一个带UI的桌面应用、一个游戏引擎的事件系统或者一个网络服务中的状态监控模块那你大概率遇到过这样的场景一个对象的状态发生了改变另外几个、几十个甚至上百个对象需要立刻知道这个变化并做出相应的反应。最直接的写法是什么可能是让状态改变的对象去硬编码调用所有关心它的对象的更新方法。代码大概长这样class Subject { void updateState() { // ... 状态改变的逻辑 ... objA.onUpdate(); objB.onUpdate(); objC.onUpdate(); // ... 如果以后要增加objD就得回来改这里 ... } };这种写法的问题显而易见紧耦合。Subject类严重依赖于objA、objB这些具体的类一旦依赖关系发生变化比如要新增或删除一个观察者你就必须回头修改Subject类的源代码。这违反了开闭原则也让单元测试变得异常困难。想象一下一个游戏里的主角Subject血量变化时需要通知UI血条、成就系统、声音管理器、自动存档点等多个模块用这种硬编码的方式Subject类的updateState函数会迅速膨胀成一团乱麻。而观察者模式Observer Pattern正是为了解决这种“一对多”的依赖关系而生的经典设计模式。它有时也被称为发布-订阅模式Publish/Subscribe其核心思想是解耦让主题Subject或称被观察者和观察者Observer之间通过抽象的接口进行交互而不是具体的实现。主题不需要知道具体有哪些观察者它只负责维护一个观察者列表并在状态变化时通知列表中的所有观察者。观察者则实现统一的更新接口以便接收通知。在C的语境下实现观察者模式有其独特的挑战和技巧。C没有像Java或C#那样的语言级事件委托机制内存管理需要手动或借助智能指针精心设计还要考虑多线程环境下的线程安全问题。网络上关于C观察者模式的讨论很多但往往要么过于简单停留在UML图层面要么陷入某个具体实现细节比如用std::function还是虚函数缺乏一个从设计思想到工业级实现、再到避坑经验的完整指南。这正是本文试图填补的空白。无论你是正在学习设计模式的在校学生还是需要在项目中处理复杂事件交互的资深工程师理解并能在C中稳健地实现观察者模式都是一项极具价值的基本功。2. 模式核心深入理解观察者模式的设计哲学在动手写代码之前我们必须先吃透观察者模式的设计哲学。这不仅仅是记住“一个主题多个观察者”这么简单而是要理解它如何通过抽象来管理变化以及这种设计带来的深远影响。2.1 设计意图与UML结构解析观察者模式的设计意图在《设计模式可复用面向对象软件的基础》一书中被定义为定义对象间的一种一对多的依赖关系当一个对象的状态发生改变时所有依赖于它的对象都得到通知并被自动更新。这个定义里有几个关键词“一对多”、“依赖”、“自动更新”。它描述了一种松耦合的协作方式。让我们通过经典的UML类图来拆解其结构Subject主题/被观察者它知道它的观察者。通常会用一个列表如std::vector来保存所有观察者的引用或指针。提供attach注册和detach注销方法允许观察者动态地订阅或取消订阅。提供notify通知方法当自身状态改变时遍历观察者列表并调用每个观察者的更新方法。Observer观察者为所有具体的观察者定义一个更新接口。在C中这通常是一个纯虚函数比如virtual void update() 0。这个接口是主题与具体观察者之间唯一的耦合点也是解耦的关键。ConcreteSubject具体主题继承或实现Subject接口。它维护自身具体的状态例如温度传感器的当前读数、股票的最新价格。当它的状态发生值得关注的变化时会调用继承来的notify方法。ConcreteObserver具体观察者实现Observer接口。在update方法中它会获取具体主题的状态可能需要通过传入主题指针或引用并据此更新自身例如更新UI显示、记录日志、触发另一个动作。这种结构的精妙之处在于ConcreteSubject和ConcreteObserver之间没有直接的依赖。ConcreteSubject只知道它有一个Observer列表它调用的是抽象的update()。ConcreteObserver只知道它实现了update()来响应通知并通过某种方式如传入的Subject指针去获取所需数据。新增一种观察者类型只需要实现一个新的ConcreteObserver类然后注册到主题即可完全不需要修改主题的代码。这就是“对扩展开放对修改关闭”的开闭原则的完美体现。2.2 推模型 vs. 拉模型数据传递的艺术在Observer::update()方法被调用时观察者如何知道主题发生了什么变化这里有两种主流的设计称为“推模型”和“拉模型”它们决定了数据流动的方式。推模型 (Push Model) 主题在通知观察者时主动将变更的数据作为参数传递给update方法。class Observer { public: virtual void update(const std::string stockSymbol, double newPrice) 0; };优点观察者直接获得所需数据简单直接。对于所有观察者都需要相同数据的情况效率高。缺点不够灵活。如果主题传递的数据不是某个观察者关心的或者观察者需要更多上下文信息这个接口就可能变得臃肿需要传递大量参数或者需要频繁修改接口以适应新的数据需求。拉模型 (Pull Model) 主题在通知观察者时只传递自身的引用或指针。观察者需要什么数据自己通过这个引用去“拉取”。class Observer { public: virtual void update(Subject* theChangedSubject) 0; }; // 在具体观察者的update实现中 void ConcreteObserver::update(Subject* sub) { ConcreteSubject* cs static_castConcreteSubject*(sub); // 可能需要向下转型 double price cs-getPrice(); // ... 使用price ... }优点非常灵活。观察者可以按需获取任何它能访问的主题数据。主题的接口notify保持稳定。缺点观察者需要知道主题的公开接口如getPrice并可能需要进行危险的向下转型如果Subject类型不明确。这在一定程度上增加了观察者对主题具体类的依赖但比直接调用更新方法耦合度低得多。在实际的C项目中拉模型更为常见和推荐。因为它更好地维护了接口的稳定性符合面向接口编程的原则。主题可以自由地扩展其内部状态和获取方法而无需修改观察者接口。为了安全地进行向下转型可以使用dynamic_cast如果启用了RTTI或设计更精细的类型系统。有时也会采用一种混合模型即传递一个包含了常用数据的轻量级“事件”或“上下文”对象观察者既可以从中直接获取关键数据推也可以通过它包含的主题指针去拉取更多信息。注意在推模型中如果传递复杂数据要特别注意对象的生命周期和拷贝开销。传递const或std::shared_ptr通常是更安全高效的选择。3. C实现详解从基础版本到生产级代码理解了设计哲学我们就可以开始用C实现了。我们将从最基础、最教科书式的版本开始逐步迭代加入现代C特性、内存安全管理和线程安全考量最终形成一个可用于实际项目的稳健实现。3.1 基础实现教科书式的起点我们先实现一个最经典的拉模型观察者模式用于监控一个简单的“数据源”DataSource的数值变化。#include iostream #include vector #include algorithm // 前向声明 class Subject; // 观察者基类 class Observer { public: virtual ~Observer() default; // 基类虚析构函数至关重要 virtual void update(Subject* theChangedSubject) 0; }; // 主题基类 class Subject { public: virtual ~Subject() default; void attach(Observer* o) { observers_.push_back(o); } void detach(Observer* o) { // 使用erase-remove惯用法删除特定观察者 observers_.erase( std::remove(observers_.begin(), observers_.end(), o), observers_.end() ); } void notify() { for (Observer* o : observers_) { o-update(this); } } private: std::vectorObserver* observers_; // 裸指针容器 }; // 具体主题数据源 class DataSource : public Subject { public: int getValue() const { return value_; } void setValue(int newValue) { if (value_ ! newValue) { value_ newValue; notify(); // 值改变通知所有观察者 } } private: int value_ 0; }; // 具体观察者数值显示器 class ValueDisplay : public Observer { public: explicit ValueDisplay(const std::string name) : name_(name) {} void update(Subject* theChangedSubject) override { // 安全地向下转型 DataSource* ds dynamic_castDataSource*(theChangedSubject); if (ds) { std::cout [ name_ ] DataSource value changed to: ds-getValue() std::endl; } } private: std::string name_; }; // 具体观察者数值记录器模拟记录到文件或数据库 class ValueLogger : public Observer { public: void update(Subject* theChangedSubject) override { DataSource* ds dynamic_castDataSource*(theChangedSubject); if (ds) { // 这里模拟记录日志 std::cout LOG: Value updated to ds-getValue() std::endl; } } }; int main() { DataSource source; ValueDisplay display(Screen); ValueLogger logger; // 注册观察者 source.attach(display); source.attach(logger); // 改变主题状态观察者自动更新 source.setValue(10); source.setValue(20); // 注销一个观察者 source.detach(logger); source.setValue(30); // 只有display会收到通知 return 0; }这个基础版本清晰地展示了模式的结构但它存在几个明显的问题内存管理Subject使用裸指针std::vectorObserver*存储观察者。谁负责Observer对象的生命周期如果Observer先于Subject被销毁Subject的列表中就会留下一个悬空指针dangling pointer调用update会导致未定义行为通常是崩溃。线程安全attach,detach,notify以及Observer::update的调用都不是线程安全的。如果在多线程环境中一个线程正在遍历观察者列表进行通知另一个线程同时进行注册或注销操作会导致迭代器失效或访问冲突。类型安全dynamic_cast虽然安全但需要RTTI运行时类型识别支持并且有性能开销。如果观察者订阅了错误类型的主题dynamic_cast会失败但至少程序不会崩溃。3.2 进阶实现引入智能指针与std::function为了解决内存管理问题现代C首选智能指针。我们可以使用std::shared_ptr和std::weak_ptr来安全地管理观察者的生命周期。方案一使用std::shared_ptr和std::weak_ptr主题持有观察者的std::weak_ptr观察者自身由std::shared_ptr管理。weak_ptr不会增加引用计数因此不会阻止观察者被销毁。在通知时主题尝试将weak_ptr提升lock为shared_ptr如果成功说明观察者还活着可以安全调用。#include memory #include vector class ObserverSP : public std::enable_shared_from_thisObserverSP { public: virtual ~ObserverSP() default; virtual void update(const std::shared_ptrSubjectSP subject) 0; }; class SubjectSP { public: void attach(const std::shared_ptrObserverSP o) { observers_.push_back(o); } // 注意detach需要找到对应的weak_ptr并移除略复杂有时直接不提供detach依靠Observer自动注销。 void notify() { // 先清理已经失效的weak_ptr observers_.erase( std::remove_if(observers_.begin(), observers_.end(), [](const std::weak_ptrObserverSP wp) { return wp.expired(); }), observers_.end() ); // 通知有效的观察者 for (auto wp : observers_) { if (auto sp wp.lock()) { sp-update(shared_from_this()); // Subject也需要继承enable_shared_from_this } } } private: std::vectorstd::weak_ptrObserverSP observers_; };这个方案很安全但引入了shared_ptr的循环引用风险如果Observer也持有Subject的shared_ptr并且weak_ptr的lock()操作有一定开销。同时要求所有对象都必须是shared_ptr管理限制了对象栈上分配的可能性。方案二使用std::function和唯一标识符更现代、更灵活我们不再要求观察者必须继承自某个基类。任何可调用对象函数、lambda表达式、bind表达式、函数对象只要签名符合要求都可以成为观察者。这极大地提高了灵活性。#include functional #include unordered_map #include cstdint class SubjectFunc { public: using ObserverID uint64_t; using Callback std::functionvoid(int); // 假设传递int新值 ObserverID attach(Callback cb) { ObserverID id nextID_; observers_[id] std::move(cb); return id; } bool detach(ObserverID id) { return observers_.erase(id) 0; } void notify(int newValue) { // 注意遍历时如果回调函数内调用了detach会导致迭代器失效。 // 一种方法是先复制一份回调列表。 auto observersCopy observers_; for (auto [id, cb] : observersCopy) { if (cb) { cb(newValue); // 直接调用推模型 } } } private: std::unordered_mapObserverID, Callback observers_; ObserverID nextID_ 0; }; // 使用示例 int main() { SubjectFunc source; // 使用lambda表达式作为观察者 auto id1 source.attach([](int v) { std::cout Lambda sees: v std::endl; }); // 使用普通函数 auto id2 source.attach(someGlobalFunction); // 使用成员函数和std::bind或lambda MyClass obj; auto id3 source.attach([obj](int v) { obj.onValueChanged(v); }); source.notify(42); source.detach(id1); source.notify(100); }这种基于std::function的实现是当前C项目中的主流选择。它完全解耦了主题和观察者的类型非常灵活。但它也有缺点失去了观察者的公共基类难以对观察者进行统一管理比如批量禁用std::function有一定的构造和调用开销需要自己处理观察者的生命周期确保回调被调用时其捕获的对象仍然有效例如上面例子中obj的生命周期必须长于source。3.3 线程安全改造应对并发环境在多线程程序中观察者模式的通知可能发生在任何线程。我们必须保证attach、detach和notify操作的原子性。#include mutex #include shared_mutex // C17 class ThreadSafeSubject { public: using Callback std::functionvoid(int); using ObserverID uint64_t; ObserverID attach(Callback cb) { std::unique_lock lock(mutex_); // 写锁 ObserverID id nextID_; observers_[id] std::move(cb); return id; } bool detach(ObserverID id) { std::unique_lock lock(mutex_); // 写锁 return observers_.erase(id) 0; } void notify(int newValue) { // 关键在遍历调用回调时我们需要读锁。 // 但为了防止死锁回调内部可能再次调用attach/detach // 通常先复制回调列表然后在锁外执行回调。 decltype(observers_) observersCopy; { std::shared_lock lock(mutex_); // 读锁 (C17 shared_mutex) observersCopy observers_; // 复制 } // 锁在这里释放 for (auto [id, cb] : observersCopy) { if (cb) { try { cb(newValue); } catch (...) { // 必须捕获异常防止一个观察者的异常影响其他观察者 // 生产环境中应记录日志 } } } } private: mutable std::shared_mutex mutex_; // 读写锁 std::unordered_mapObserverID, Callback observers_; ObserverID nextID_ 0; };这里有几个至关重要的细节锁的选择使用std::shared_mutex读写锁。attach/detach需要独占写锁而notify中的列表复制只需要共享读锁这提高了并发读的性能。复制回调列表在锁的保护下复制列表然后在锁外执行回调。这是避免死锁的黄金法则。因为观察者的回调函数cb(newValue)可能执行任意代码包括再次调用attach或detach。如果我们在持有锁的情况下执行回调而回调又试图获取同一个锁就会导致死锁如果是非递归锁。异常安全回调可能抛出异常。必须用try-catch块包裹每个回调调用确保一个观察者的失败不会阻止其他观察者被通知。性能考量每次notify都复制整个unordered_map可能有开销如果观察者数量巨大且notify调用频繁需要评估。另一种方案是使用不可变数据结构或线程安全的容器但实现更复杂。4. 实战应用场景与代码剖析观察者模式在C项目中的应用无处不在。下面我们通过两个典型的场景看看如何将上述实现落地。4.1 场景一GUI框架中的事件处理系统在Qt、MFC或自研的GUI框架中按钮点击、鼠标移动、窗口重绘等都是事件。这些事件天然就是“主题”而事件处理函数就是“观察者”。// 一个简化的事件系统示例 class Event { public: virtual ~Event() default; virtual std::string type() const 0; }; class ButtonClickEvent : public Event { public: std::string type() const override { return ButtonClick; } int buttonId() const { return id_; } // ... 其他事件数据 private: int id_; }; class EventDispatcher { public: using EventHandler std::functionvoid(const Event); void subscribe(const std::string eventType, EventHandler handler) { std::lock_guard lock(mutex_); handlers_[eventType].push_back(std::move(handler)); } void post(const Event event) { std::vectorEventHandler handlersToCall; { std::lock_guard lock(mutex_); auto it handlers_.find(event.type()); if (it ! handlers_.end()) { handlersToCall it-second; // 复制 } } for (auto handler : handlersToCall) { handler(event); } } private: std::unordered_mapstd::string, std::vectorEventHandler handlers_; std::mutex mutex_; }; // 使用 EventDispatcher dispatcher; // 订阅按钮点击事件 dispatcher.subscribe(ButtonClick, [](const Event e) { const auto clickEvent dynamic_castconst ButtonClickEvent(e); std::cout Button clickEvent.buttonId() clicked! std::endl; }); // 在某个地方触发事件 ButtonClickEvent clickEvent; dispatcher.post(clickEvent);在这个场景中EventDispatcher是主题它维护了一个事件类型到处理函数列表的映射。这种设计允许任何模块订阅它关心的事件实现了UI逻辑与业务逻辑的彻底解耦。4.2 场景二游戏引擎中的实体组件系统ECS与事件通信在现代游戏引擎的ECS架构中观察者模式常用于系统System之间的通信。例如一个物理系统检测到碰撞后需要通知音效系统播放碰撞声音、粒子系统生成火花、成就系统记录碰撞次数。// 一个简化的游戏内事件总线 class CollisionEvent { public: Entity entityA; Entity entityB; Vector3 collisionPoint; // ... }; class GameEventBus { // 类似上面的EventDispatcher但通常是单例或全局可访问 }; // 物理系统发布者 class PhysicsSystem { public: void update() { // ... 物理模拟 ... if (collisionHappened) { CollisionEvent event{entityA, entityB, point}; GameEventBus::getInstance().post(event); } } }; // 音效系统订阅者 class AudioSystem { public: AudioSystem() { GameEventBus::getInstance().subscribeCollisionEvent( [this](const CollisionEvent e) { this-onCollision(e); } ); } private: void onCollision(const CollisionEvent e) { // 根据碰撞体材质、速度等决定播放什么音效 playSound(collision.wav, e.collisionPoint); } };这种基于事件总线的观察者模式变体在大型、模块化的系统中非常流行。它允许系统之间完全不知道彼此的存在仅仅通过事件进行通信极大地提高了代码的模块化和可维护性。5. 性能优化、陷阱与最佳实践将观察者模式用于高性能或大型系统时必须谨慎处理性能和资源管理问题。5.1 性能关键考量内存与缓存局部性std::vectorObserver*比std::list或std::unordered_map有更好的缓存局部性遍历通知更快。但如果频繁在中间插入删除vector效率低。需要根据attach/detach与notify的频率权衡。虚函数调用开销通过基类指针调用虚函数update()有间接跳转的开销。在极端性能敏感的场景如每帧调用数万次这可能成为瓶颈。可以考虑使用std::function但其内部也可能涉及间接调用。一种激进优化是使用函数指针数组或类型擦除的轻量级回调但会牺牲灵活性。锁的粒度如前所述在notify中持有锁执行回调是危险的。复制列表的方案安全但增加了内存分配和拷贝开销。对于高频事件可以考虑使用无锁队列如moodycamel::ConcurrentQueue来传递事件由专门的消费者线程处理实现生产者和消费者的解耦。通知频率控制避免在紧密循环中或状态每帧微小变化时都触发notify。可以引入“脏标记”或节流机制只在状态确实发生有意义的变化时或者累积到一定程度、在特定时间点如帧末进行批量通知。5.2 常见陷阱与解决方案悬空指针与生命周期管理陷阱观察者被销毁后未从主题中注销主题通知时访问非法内存。解决方案强引用手动管理要求观察者在析构前必须调用detach。这依赖于程序员纪律容易出错。弱引用主题持有std::weak_ptr如上文所述。这是推荐做法。观察者持有令牌attach返回一个令牌如ObserverID观察者将其作为成员变量。在观察者析构函数中自动使用该令牌调用detach需要能访问到主题。这需要观察者知道主题或使用中介者。使用作用域守卫auto connection subject.attach(handler);connection对象在析构时自动调用detach。在回调中修改观察者列表陷阱在notify遍历观察者列表时某个观察者的update回调中调用了detach或attach导致当前正在使用的迭代器失效引发崩溃。解决方案如前所述始终在锁外执行回调。先复制列表然后遍历副本。这是最安全的方法。回调中抛出异常陷阱一个观察者的回调抛出异常导致后续观察者无法被通知甚至整个程序崩溃。解决方案在notify的循环内用try-catch包裹每个回调调用。至少捕获...并记录错误日志。确保异常不会扩散出去。循环通知与栈溢出陷阱观察者A的更新导致主题状态再次改变触发新一轮通知如果A还在观察者列表中可能形成无限递归或深度递归导致栈溢出。解决方案在notify或状态设置函数中引入“通知中”标志位防止重入。或者确保观察者的更新逻辑不会触发它所观察的同一主题的同一状态变化。线程安全问题陷阱非线程安全的实现在多线程环境下使用数据竞争导致未定义行为。解决方案如3.3节所示使用适当的锁读写锁和“复制-回调”策略。或者将事件发布到线程安全的队列由专属线程处理。5.3 C17/20下的现代实现技巧std::variant和std::visit用于类型安全事件如果事件类型有限可以使用std::variant定义一个所有可能事件的联合类型然后用std::visit来处理。这比dynamic_cast更高效、类型更安全。using Event std::variantCollisionEvent, DamageEvent, PickupEvent; std::functionvoid(const Event) handler [](const Event e) { std::visit(overloaded { [](const CollisionEvent ce) { /* ... */ }, [](const DamageEvent de) { /* ... */ }, [](const PickupEvent pe) { /* ... */ }, }, e); };编译期观察者注册利用模板和CRTP可以在编译时将观察者注册到主题完全避免运行时开销。但这牺牲了动态性适用于观察关系固定的场景。无锁设计对于超高性能场景可以考虑使用原子操作和环形缓冲区实现单生产者-单消费者SPSC或无锁多生产者-多消费者MPMC队列来传递事件消息。观察者模式是C中构建松耦合、响应式系统的基石。从经典实现到现代C的演进反映了我们对安全性、性能和灵活性不断增长的追求。理解其原理看清其陷阱并能够根据项目需求选择合适的实现变体是一名C工程师设计能力的重要体现。记住没有一种实现是完美的最好的模式实现永远是那个最适合你当前项目约束团队习惯、性能要求、代码库现状的实现。