C++观察者模式:解耦对象通信的现代实现与实战指南

📅 2026/7/21 5:52:34
C++观察者模式:解耦对象通信的现代实现与实战指南
1. 项目概述为什么我们需要观察者模式在C项目里尤其是那些涉及复杂UI交互、游戏事件系统或者分布式服务状态同步的场景我们经常会遇到一个经典问题一个对象我们称之为“目标”或“主题”的状态发生了变化其他一系列对象我们称之为“观察者”需要立刻知道这个变化并做出相应的反应。最直接的写法是什么你可能会在“目标”对象里硬编码调用所有“观察者”对象的更新方法。比如一个游戏里的角色Subject血量变了UI血条Observer1、伤害数字弹出Observer2、成就系统Observer3都需要更新。新手可能会这样写class GameCharacter { public: void takeDamage(int damage) { hp_ - damage; if (hp_ 0) hp_ 0; // 硬编码通知所有依赖模块 uiHealthBar_-update(hp_); damagePopup_-show(damage); achievementSystem_-onCharacterHurt(); // 如果以后要加一个音效系统又得来这里改代码 // soundSystem_-playHurtSound(); } private: int hp_; UIHealthBar* uiHealthBar_; DamagePopup* damagePopup_; AchievementSystem* achievementSystem_; };这种写法的问题显而易见紧耦合。GameCharacter这个类严重依赖后面三个具体的类。它就像一个发布通知的老板必须亲自记住每一个需要通知的员工并且亲自去敲门。一旦有新员工新的观察者入职或者有老员工离职老板就必须修改自己的日常工作流程修改takeDamage函数。这违反了设计模式中非常重要的开闭原则——对扩展开放对修改关闭。观察者模式就是为了优雅地解决这个问题而生的。它的核心思想是解耦让“目标”和“观察者”之间不要直接认识。目标只负责维护一个观察者列表并在状态改变时向这个列表里的所有观察者发送一个通用的通知消息至于每个观察者具体是谁、接到通知后要做什么目标一概不管。这就好比老板设置了一个公司广播系统观察者列表有事情要宣布时只需要对着广播喊一嗓子调用通知方法所有订阅了广播的员工观察者都会自动听到并处理老板完全不需要知道具体有哪些员工在听。在C中实现观察者模式我们不仅要理解这个思想还要处理C特有的一些问题比如内存安全裸指针还是智能指针、线程安全、以及如何设计一个类型安全的接口。接下来我们就深入拆解如何用现代C打造一个健壮、灵活的观察者模式实现。2. 核心设计思路与接口定义观察者模式的结构非常清晰主要包含两个核心角色Subject主题和Observer观察者。它们之间是一种一对多的依赖关系。我们的目标是定义一个松耦合的接口让Subject和Observer能够独立变化。2.1 定义观察者Observer接口首先从观察者开始。观察者需要提供一个方法当主题状态变化时这个方法会被调用。这个方法通常被命名为update。在C中我们将其定义为一个抽象基类接口类。// Observer.h #ifndef OBSERVER_H #define OBSERVER_H #include memory // 为std::shared_ptr做准备 // 前向声明Subject避免循环依赖 class Subject; class Observer { public: virtual ~Observer() default; // 基类虚析构函数确保正确释放派生类资源 // 更新接口 // 参数通常为指向Subject的指针或引用方便观察者获取更详细的状态 virtual void update(Subject* theChangedSubject) 0; // 另一种常见设计由Subject将相关数据作为参数传递 // virtual void update(const SomeData data) 0; }; #endif // OBSERVER_H关键点解析虚析构函数 (virtual ~Observer() default;): 这是C多态基类的“黄金法则”。如果通过基类指针比如Observer*来删除一个派生类对象而没有虚析构函数会导致派生类的析构函数不被调用可能发生资源泄漏。 default让编译器生成一个默认实现既简洁又安全。纯虚函数 (virtual void update(...) 0;): 0使得Observer成为一个抽象类无法直接实例化。任何想成为观察者的具体类如ConcreteObserver都必须继承自Observer并实现这个update方法。这强制了接口的统一。参数设计: 这里采用了传递Subject*指针的方式。这样做的好处是观察者在update方法内部可以反向查询主题对象以获取它需要的任何特定状态信息保持了灵活性。缺点是观察者需要知道主题的公共接口并可能执行类型转换如果观察多个不同主题。另一种方式是主题将变化的数据打包成一个通用或特定的数据对象如EventData传递给update耦合度更低但需要提前定义好数据协议。2.2 定义主题Subject基类主题需要管理一个观察者列表并提供注册添加、注销移除和通知所有观察者的方法。// Subject.h #ifndef SUBJECT_H #define SUBJECT_H #include vector #include memory #include algorithm #include Observer.h class Subject { public: virtual ~Subject() default; // 注册观察者 void attach(std::shared_ptrObserver observer) { // 简单的实现先不考虑线程安全和重复添加 observers_.push_back(observer); } // 注销观察者 void detach(std::shared_ptrObserver observer) { // 使用标准库算法移除指定元素 // 注意这里比较的是shared_ptr本身要求传入的是同一个智能指针对象 // 更健壮的实现可能需要比较Observer对象的地址 auto it std::find(observers_.begin(), observers_.end(), observer); if (it ! observers_.end()) { observers_.erase(it); } } // 通知所有观察者 void notifyObservers() { // 在遍历过程中观察者可能会detach自己这可能导致迭代器失效。 // 一种简单防御先复制列表然后遍历副本。 auto observersCopy observers_; for (auto obs : observersCopy) { if (obs) { // 检查指针有效性 obs-update(this); } } } protected: // 观察者列表。使用shared_ptr管理生命周期避免悬空指针。 std::vectorstd::shared_ptrObserver observers_; }; #endif // SUBJECT_H关键点解析与避坑指南智能指针的使用 (std::shared_ptrObserver): 这是现代C管理动态对象生命周期的推荐方式。它避免了手动new/delete带来的内存泄漏和悬空指针问题。主题持有观察者的shared_ptr只要主题还活着或者还有其他地方持有这个指针观察者对象就不会被意外销毁。这里的选择是shared_ptr而不是weak_ptr或裸指针是因为我们默认观察者的生命周期可能独立于主题主题需要“拥有”观察者的一份引用计数以确保在通知时观察者仍然有效。注意: 使用shared_ptr也可能导致循环引用如果Observer也持有Subject的shared_ptr。在这种情况下需要仔细设计所有权关系或者使用weak_ptr来打破循环。在经典的观察者模式中观察者通常不会“拥有”主题所以这里用shared_ptr指向Observer是安全的。detach方法的实现: 我们使用了std::find来查找并移除观察者。但这里有一个潜在的坑std::find比较的是shared_ptr本身即它们是否指向同一个控制块而不是它们所指向的Observer对象。这意味着如果你创建了两个独立的shared_ptrConcreteObserver但它们指向同一个ConcreteObserver对象虽然这种情况不常见用其中一个attach用另一个detach会失败。更健壮的做法可能是存储weak_ptr或者在Observer接口中添加一个唯一的ID进行比较。但对于大多数场景当前实现已足够。notifyObservers中的迭代器失效问题: 这是一个非常重要且容易出错的地方在遍历observers_列表并调用obs-update(this)时update方法的具体实现完全有可能调用this-detach(...)来将自己从观察者列表中移除。这会直接导致我们正在遍历的observers_向量发生修改从而使当前迭代器it失效后续行为未定义通常导致程序崩溃。解决方案: 如代码所示在通知前先创建观察者列表的一个副本observersCopy然后遍历这个副本。这样即使原始列表在回调中被修改也不会影响当前的遍历过程。代价是每次通知都有一次容器拷贝的开销。对于性能极度敏感的场景可以考虑使用std::list删除元素不会使其他迭代器失效或其他更精细的锁机制。空指针检查 (if (obs)): 虽然我们使用shared_ptr但在极端情况下比如多线程环境指针有可能为空。进行检查是一个好习惯。3. 具体实现一个简单的天气站示例为了将上述抽象接口具体化我们来实现一个经典例子一个天气数据站WeatherStation作为主题多个显示设备如当前状况显示CurrentConditionsDisplay、统计显示StatisticsDisplay作为观察者。3.1 具体主题WeatherStation首先我们的具体主题需要继承自Subject并管理具体的状态温度、湿度、气压。// WeatherStation.h #ifndef WEATHERSTATION_H #define WEATHERSTATION_H #include Subject.h #include string class WeatherStation : public Subject { public: // 设置测量值并触发通知 void setMeasurements(float temperature, float humidity, float pressure) { temperature_ temperature; humidity_ humidity; pressure_ pressure; measurementsChanged(); // 数据变化通知观察者 } // 提供给观察者获取状态的接口 float getTemperature() const { return temperature_; } float getHumidity() const { return humidity_; } float getPressure() const { return pressure_; } private: void measurementsChanged() { notifyObservers(); // 调用基类的通知方法 } float temperature_ 0.0f; float humidity_ 0.0f; float pressure_ 0.0f; }; #endif // WEATHERSTATION_H设计要点WeatherStation自身不关心谁在观察它它只负责在数据更新后setMeasurements调用measurementsChanged进而通过基类的notifyObservers()广播消息。将状态获取方法getTemperature等设为public这样观察者在update回调中就能通过传入的Subject*指针实际是WeatherStation*来查询所需数据。这里需要进行安全的向下转型。3.2 具体观察者CurrentConditionsDisplay现在实现一个显示当前天气状况的观察者。// CurrentConditionsDisplay.h #ifndef CURRENTCONDITIONSDISPLAY_H #define CURRENTCONDITIONSDISPLAY_H #include Observer.h #include WeatherStation.h // 需要知道具体主题以获取数据 #include iostream class CurrentConditionsDisplay : public Observer { public: // 构造函数中注册自己到主题 explicit CurrentConditionsDisplay(WeatherStation* weatherStation) : weatherStation_(weatherStation) { if (weatherStation_) { // 注意这里需要将this指针包装成shared_ptr。 // 这要求CurrentConditionsDisplay对象本身也是由shared_ptr管理的。 // 一种常见模式是让主题/外部代码用shared_ptr来创建和管理观察者。 weatherStation_-attach(std::shared_ptrObserver(this)); // 危险见下方解释 } } ~CurrentConditionsDisplay() override { if (weatherStation_) { // 析构时尝试注销自己但同样面临指针转换问题 // weatherStation_-detach(...); } } void update(Subject* theChangedSubject) override { // 安全地向下转型 auto* ws dynamic_castWeatherStation*(theChangedSubject); if (ws ws weatherStation_) { // 确认是我们关心的主题 temperature_ ws-getTemperature(); humidity_ ws-getHumidity(); display(); } } void display() const { std::cout 当前天气状况: temperature_ °C, humidity_ % 湿度 std::endl; } private: WeatherStation* weatherStation_ nullptr; // 使用裸指针因为我们不拥有WeatherStation float temperature_ 0.0f; float humidity_ 0.0f; }; #endif // CURRENTCONDITIONSDISPLAY_H注意上面的构造函数实现有一个严重问题weatherStation_-attach(std::shared_ptrObserver(this));这行代码是极其危险的。它从一个裸指针this临时创建了一个shared_ptrObserver。这个临时shared_ptr在attach函数调用结束后其引用计数会变为0从而立即删除当前对象this这会导致未定义行为很可能使程序崩溃。正确的生命周期管理方案观察者模式中主题和观察者的生命周期管理需要仔细考量。通常有两种模式主题拥有观察者主题负责创建和销毁观察者。观察者作为主题的内部组件存在。外部代码拥有主题和观察者主题和观察者由更高层次的代码如main函数或一个管理类通过shared_ptr创建和管理并在两者之间建立关联。对于第二种更通用的模式我们应该修改设计避免在观察者构造函数中自动注册。改为由外部代码显式注册。改进后的CurrentConditionsDisplay和用法// CurrentConditionsDisplay.h (改进版) class CurrentConditionsDisplay : public Observer { public: // 不再在构造函数中attach CurrentConditionsDisplay() default; // 可以提供一个设置主题的方法 void setSubject(WeatherStation* ws) { weatherStation_ ws; } void update(Subject* theChangedSubject) override { auto* ws dynamic_castWeatherStation*(theChangedSubject); if (ws) { // 现在可以观察任意WeatherStation不一定是构造时那个 temperature_ ws-getTemperature(); humidity_ ws-getHumidity(); display(); } } // ... display() 和其他成员不变 private: WeatherStation* weatherStation_ nullptr; float temperature_ 0.0f; float humidity_ 0.0f; };// main.cpp 示例 #include memory #include WeatherStation.h #include CurrentConditionsDisplay.h int main() { // 1. 创建主题天气站 auto weatherStation std::make_sharedWeatherStation(); // 2. 创建观察者显示设备 auto currentDisplay std::make_sharedCurrentConditionsDisplay(); // 如果需要观察者可以知道主题例如用于主动拉取数据 currentDisplay-setSubject(weatherStation.get()); // 3. 将观察者注册到主题 weatherStation-attach(currentDisplay); // 安全传递shared_ptr // 4. 模拟天气数据更新 weatherStation-setMeasurements(25.0f, 65.0f, 1013.0f); // 输出当前天气状况: 25°C, 65% 湿度 weatherStation-setMeasurements(26.5f, 70.0f, 1012.5f); // 输出当前天气状况: 26.5°C, 70% 湿度 // 5. 动态移除观察者 // weatherStation-detach(currentDisplay); return 0; }这种由外部控制注册和生命周期的模式更加清晰和安全也是更推荐的做法。4. 高级议题与C特色实现基础的观察者模式已经能解决很多问题但在实际C项目中我们还需要考虑更多。4.1 使用std::function与 Lambda 实现轻量级观察者有时我们不想为一个小小的回调函数去专门定义一个继承自Observer的类。C11的std::function和lambda表达式提供了完美的解决方案。我们可以修改Subject使其能接受一个可调用对象作为观察者。// AdvancedSubject.h #ifndef ADVANCEDSUBJECT_H #define ADVANCEDSUBJECT_H #include vector #include functional #include memory class AdvancedSubject { public: using ObserverFunc std::functionvoid(); // 无参版本 // 或者 using ObserverFunc std::functionvoid(const EventData); // 注册一个可调用对象返回一个令牌token用于后续注销 size_t attach(ObserverFunc observer) { observers_.push_back(observer); return observers_.size() - 1; // 返回索引作为简单令牌脆弱 // 更好的做法是返回一个不透明的ID或weak_ptr到某个包装器。 } // 通过令牌注销示例不健壮 void detach(size_t token) { if (token observers_.size()) { // 不能直接erase会改变后面元素的索引令牌失效 // 一种方法置空在notify时跳过 observers_[token] nullptr; } } void notifyObservers() { for (auto func : observers_) { if (func) { // 跳过被detach的置为空 func(); // 调用可调用对象 } } // 可选清理所有空函数对象 observers_.erase( std::remove(observers_.begin(), observers_.end(), nullptr), observers_.end() ); } private: std::vectorObserverFunc observers_; }; // 使用示例 void exampleUsage() { AdvancedSubject subject; int localCounter 0; // 使用lambda注册观察者 auto token1 subject.attach([localCounter]() { std::cout Lambda observer called! Counter: localCounter std::endl; localCounter; }); // 使用普通函数 auto token2 subject.attach([]() { std::cout Another observer! std::endl; }); subject.notifyObservers(); // 输出: // Lambda observer called! Counter: 0 // Another observer! subject.detach(token2); subject.notifyObservers(); // 输出: // Lambda observer called! Counter: 1 }优势与局限优势极其灵活无需定义新类特别适合一次性、简单的回调。局限detach操作变得复杂因为需要管理函数对象的身份。上面的索引令牌方法非常脆弱一旦中间有元素被移除索引就错乱了。生产环境通常需要更复杂的令牌系统如UUID、std::function的地址比较不可靠或直接不提供detach让观察者生命周期等于其持有的std::function对象例如将其存储在std::shared_ptr中主题持有weak_ptr。4.2 线程安全考虑如果主题和观察者可能在不同的线程中被访问和修改例如一个后台线程更新天气数据UI线程更新显示那么我们的简单实现就是非线程安全的。attach、detach、notifyObservers以及观察者的update方法都可能并发执行导致数据竞争。简单的线程安全改造使用互斥锁std::mutex保护观察者列表。// ThreadSafeSubject.h #include mutex #include shared_mutex // C17 用于读写锁 class ThreadSafeSubject : public Subject { public: void attach(std::shared_ptrObserver observer) override { std::lock_guardstd::mutex lock(mutex_); observers_.push_back(observer); } void detach(std::shared_ptrObserver observer) override { std::lock_guardstd::mutex lock(mutex_); auto it std::find(observers_.begin(), observers_.end(), observer); if (it ! observers_.end()) { observers_.erase(it); } } void notifyObservers() override { std::vectorstd::shared_ptrObserver observersCopy; { std::lock_guardstd::mutex lock(mutex_); observersCopy observers_; // 复制时加锁 } // 锁在这里释放避免在持有锁时调用用户代码可能死锁或降低性能 for (auto obs : observersCopy) { if (obs) { obs-update(this); } } } private: mutable std::mutex mutex_; // 使用 std::shared_mutex 可以优化attach/detach用写锁notifyObservers复制列表用读锁。 };重要警告锁的粒度在notifyObservers中我们先复制列表再遍历并且在复制完成后就释放了锁。这是至关重要的。如果在持有锁的情况下调用obs-update(this)而update方法内部又试图调用attach或detach这需要获取同一个锁就会导致死锁。此外长时间持有锁会严重降低并发性能。观察者update方法的线程安全即使主题端做到了线程安全观察者自身的update方法以及其内部状态也必须考虑线程安全。如果多个主题同时通知同一个观察者或者主题通知与观察者内部状态读取同时发生都可能需要同步。4.3 处理观察者更新顺序与优先级有时观察者的更新顺序很重要。例如一个日志观察者应该在所有其他观察者之前被通知或者某个观察者必须在另一个之后更新。基础实现中std::vector的顺序就是注册顺序。我们可以通过引入优先级字段来扩展。struct PrioritizedObserver { std::shared_ptrObserver observer; int priority; // 数字越小优先级越高或反之 // 可以加上ID、名称等 }; class PrioritizedSubject : public Subject { public: void attach(std::shared_ptrObserver observer, int priority 0) { prioritizedObservers_.push_back({observer, priority}); // 按优先级排序 std::sort(prioritizedObservers_.begin(), prioritizedObservers_.end(), [](const auto a, const auto b) { return a.priority b.priority; }); } // 需要重写detach和notifyObservers来操作prioritizedObservers_ private: std::vectorPrioritizedObserver prioritizedObservers_; };5. 模式变体、常见问题与实战心得5.1 “推”模型 vs “拉”模型我们之前实现的是典型的“拉”模型主题在通知时只发送一个“我变了”的信号或一个通用的Subject*指针观察者需要自己通过这个指针去“拉取”具体需要的数据。优点主题不知道观察者需要什么数据接口通用耦合度低。缺点观察者可能需要向下转型并且可能拉取到不需要的数据效率稍低。“推”模型则相反主题在通知时直接将变化的数据作为参数传递给观察者。virtual void update(const WeatherData data) 0;优点观察者直接获得数据无需查询和转型效率高。缺点主题需要知道观察者需要什么数据或者传递一个可能很大的通用数据对象。如果观察者需求不同主题接口可能变得笨重或需要频繁修改。实战选择在C中如果观察者类型相对统一且数据量小用“推”模型更高效。如果观察者差异很大或者你想保持主题接口的纯粹和稳定“拉”模型更灵活。也可以混合使用提供重载的update方法。5.2 观察者析构时忘记注销这是一个非常常见的bug。如果一个观察者对象被销毁了但没有从主题的观察者列表中移除detach那么当下次主题通知时就会尝试调用一个已销毁对象的update方法导致程序崩溃。解决方案RAII管理注册在观察者的构造函数中注册在析构函数中注销。但如前所述这需要小心处理shared_ptr从this创建的问题。一个技巧是让观察者继承std::enable_shared_from_this并在构造函数中使用shared_from_this()来获取自身的shared_ptr。但这要求对象必须由shared_ptr管理且不能在构造函数中调用shared_from_this()因为此时shared_ptr尚未完全构造好。通常需要一个两段式初始化。使用弱引用主题存储观察者的std::weak_ptr。在通知时尝试将weak_ptr提升lock()为shared_ptr如果提升成功说明观察者还活着则调用。这样即使观察者被销毁主题这里也只有一个空的weak_ptr不会造成崩溃。这是更现代和安全的做法。class SafeSubject { std::vectorstd::weak_ptrObserver observers_; void notifyObservers() { auto it observers_.begin(); while (it ! observers_.end()) { if (auto sp it-lock()) { sp-update(this); it; } else { // 观察者对象已失效从列表中移除 it observers_.erase(it); } } } };5.3 性能考量与事件风暴如果一个主题有成千上万个观察者每次状态变化都通知所有人可能会成为性能瓶颈“事件风暴”。优化1合并通知。如果状态在短时间内频繁变化可以设置一个“脏”标志或者将通知操作异步化放入队列由另一个线程统一处理避免高频的同步回调。优化2细分事件。不要总是通知“我变了”而是定义具体的事件类型如TemperatureChanged,HumidityChanged。观察者只订阅它们关心的事件。这演变成了更复杂的“发布-订阅”模式主题发布者和观察者订阅者之间多了一个“事件通道”或“消息总线”。5.4 在真实项目中的体会在我参与过的一个大型C UI框架项目中观察者模式是基石。但纯经典的实现很少见更多是它的变体和组合。信号与槽Signals and SlotsQt框架的信号槽机制是观察者模式的强力演进。它通过元对象系统moc实现了类型安全、线程间通信、自动连接管理解决了裸指针和手动detach的诸多问题。如果你的项目能用Qt强烈推荐直接使用它的信号槽。避免过度设计对于简单的回调直接用std::function和lambda往往比实现完整的观察者类层次更简洁。不要为了模式而模式。注意循环引用当主题和观察者互相持有shared_ptr时就产生了循环引用会导致内存泄漏。务必理清所有权关系通常观察者不应该拥有主题。使用weak_ptr打破循环。调试困难由于调用是间接的通过观察者模式调试程序有时会比较麻烦因为你可能不容易一眼看出某个update调用来自哪里。良好的日志记录和观察者命名可以帮助定位问题。观察者模式是解耦的利器理解其核心思想——定义对象间的一种一对多的依赖关系当一个对象的状态发生改变时所有依赖于它的对象都得到通知并被自动更新——并在C的语境下处理好内存、线程、生命周期等细节你就能在合适的场景中优雅地应用它让代码的各部分既能独立工作又能协同响应。