C++观察者模式实战:从气象站案例解析解耦与事件通知机制
2026/7/19 10:21:31 网站建设 项目流程

1. 项目概述:从气象站到代码的桥梁

最近在重构一个老项目的数据推送模块,看着里面各种if-else和硬编码的回调函数,头疼得不行。这让我想起了几年前用C++写的一个小型气象数据监控系统,当时为了解耦数据源和多个显示终端,果断上了观察者模式。今天就把这个“气象站实战”的里里外外拆开揉碎了讲清楚,这不仅是设计模式的学习,更是解决“一对多”依赖问题的经典思路。

简单说,观察者模式要解决的核心问题是:当一个对象(比如气象站)的状态发生改变时,如何自动通知所有依赖它的对象(比如手机App、网页大屏、数据库记录器),并且让这些对象能各自做出响应,而气象站本身完全不用关心通知了谁、对方具体干了什么。这就像微信群里发了个公告,所有群成员都能立刻收到,但有人选择阅读,有人选择保存,还有人选择转发,发公告的人并不需要知道这些细节。

在C++里实现这个模式,你会深刻体会到面向对象设计中“针对接口编程,而非实现编程”和“松耦合”两大原则的魅力。下面,我们就从最核心的设计思路开始,一步步搭建一个可运行、可扩展的气象站Demo。

2. 核心设计思路与模式解析

2.1 为什么是观察者模式?

假设我们要构建的系统叫WeatherStation(气象站),它核心的工作是监测温度、湿度和气压。我们需要将这三项数据实时展示到至少三个地方:一个当前状况显示屏(CurrentConditionsDisplay)、一个气象统计显示屏(StatisticsDisplay)和一个简单的预警装置(ForecastDisplay)。

最直接的写法可能是这样:

class WeatherStation { private: float temperature_; float humidity_; float pressure_; CurrentConditionsDisplay* current_display_; StatisticsDisplay* stats_display_; ForecastDisplay* forecast_display_; // ... 可能还有更多Display public: void measurementsChanged() { float temp = getTemperature(); float humidity = getHumidity(); float pressure = getPressure(); // 硬编码调用每一个显示器的更新方法 current_display_->update(temp, humidity, pressure); stats_display_->update(temp, humidity, pressure); forecast_display_->update(temp, humidity, pressure); // ... 每增加一个显示器,就要在这里加一行代码 } };

这种写法的问题显而易见:

  1. 紧耦合WeatherStation类直接依赖于具体的显示器类。一旦要增加或删除一个显示器,就必须修改WeatherStation的源代码,违反了“开闭原则”。
  2. 难以维护:所有通知逻辑都堆在一个方法里,显示器越多,这个方法越臃肿。
  3. 运行时动态性差:无法在程序运行时动态地添加或移除观察者。

观察者模式通过引入“主题”(Subject)和“观察者”(Observer)两个抽象角色来解决这些问题。主题负责维护一个观察者列表,并在状态改变时遍历这个列表去通知每一个观察者。观察者则实现一个统一的更新接口。这样,主题和具体的观察者之间就只剩下抽象的依赖关系。

2.2 模式中的角色与C++映射

在气象站这个例子里,角色映射如下:

  • 主题(Subject):对应我们的WeatherData类(或WeatherStation的核心数据部分)。它负责管理(注册、删除)观察者,并在数据更新时通知它们。
  • 观察者(Observer):对应各种显示屏,如CurrentConditionsDisplay。它们实现一个统一的接口,以便主题能以统一的方式通知它们。
  • 具体主题(ConcreteSubject):就是WeatherData类的具体实现。它持有实际的状态(温度、湿度、气压),当这些状态改变时,调用通知方法。
  • 具体观察者(ConcreteObserver):就是各个显示屏的具体实现。它们注册到主题上,并在收到通知后,从主题那里“拉取”或接收“推送”来的数据,更新自己的显示。

这种结构带来的最大好处是,WeatherData类之后可以这样写:

void WeatherData::measurementsChanged() { notifyObservers(); // 只需要调用这一个方法,所有注册的观察者都会被通知到。 }

至于notifyObservers()内部怎么实现,以及观察者列表如何管理,就是我们接下来要深入的核心。

3. 核心细节解析与C++实现要点

3.1 接口设计:松耦合的关键

接口是解耦的基石。在C++中,我们通常用纯虚类(即抽象基类)来定义接口。

1. 观察者接口 (Observer)这个接口极其简单,只声明一个更新方法。关键在于这个方法的参数设计,主要有两种流派:“推”模型和“拉”模型。

// “推”模型:主题将数据直接推送给观察者 class Observer { public: virtual ~Observer() = default; // 基类虚析构函数必不可少 virtual void update(float temperature, float humidity, float pressure) = 0; }; // “拉”模型:主题将自己作为参数传递给观察者,观察者按需拉取数据 // class Observer { // public: // virtual ~Observer() = default; // virtual void update(const Subject& subject) = 0; // Subject是主题接口 // };

在气象站的例子里,数据字段明确且固定(温度、湿度、气压),使用“推”模型更直观。如果观察者需要的数据差异很大,或者主题数据结构复杂,“拉”模型能避免推送不必要的数据,更灵活。我们本次采用“推”模型。

2. 主题接口 (Subject)主题接口需要提供注册、移除和通知观察者的能力。

class Subject { public: virtual ~Subject() = default; virtual void registerObserver(Observer* o) = 0; virtual void removeObserver(Observer* o) = 0; virtual void notifyObservers() = 0; };

注意:这里registerObserverremoveObserver接收的是Observer*原始指针。这在简单的示例中没问题,但在实际项目中,这引入了对象生命周期管理的难题。主题必须非常小心,不能通知一个已经被销毁的观察者(悬垂指针),否则会导致未定义行为(崩溃)。更健壮的做法是使用std::weak_ptr<Observer>,但这要求观察者本身由std::shared_ptr管理。为了示例清晰,我们先使用原始指针,但会在后面的“注意事项”中重点讨论这个问题。

3.2 具体主题的实现:WeatherData

WeatherData类需要实现Subject接口,并管理气象数据和观察者列表。

#include <vector> #include <algorithm> class WeatherData : public Subject { private: std::vector<Observer*> observers_; // 观察者列表 float temperature_; float humidity_; float pressure_; public: WeatherData() : temperature_(0.0f), humidity_(0.0f), pressure_(1013.25f) {} // 实现Subject接口 void registerObserver(Observer* o) override { observers_.push_back(o); } void removeObserver(Observer* o) override { // 使用STL算法移除指定指针。注意:这要求同一个观察者不要重复注册。 auto it = std::find(observers_.begin(), observers_.end(), o); if (it != observers_.end()) { observers_.erase(it); } } void notifyObservers() override { // 遍历所有观察者,调用其update方法,推送最新数据 for (Observer* observer : observers_) { observer->update(temperature_, humidity_, pressure_); } } // 气象站自身的业务方法 void setMeasurements(float temperature, float humidity, float pressure) { this->temperature_ = temperature; this->humidity_ = humidity; this->pressure_ = pressure; measurementsChanged(); // 数据设置好后,触发通知 } // 其他getter方法,供“拉”模型或外部使用 float getTemperature() const { return temperature_; } float getHumidity() const { return humidity_; } float getPressure() const { return pressure_; } private: void measurementsChanged() { notifyObservers(); // 核心:状态改变,通知所有观察者 } };

关键点解析

  • observers_使用std::vector<Observer*>存储,这是最简单的容器选择。如果对移除操作性能要求高,可以考虑std::list
  • removeObserver中使用了std::find来定位要删除的观察者指针。这里隐含了一个约定:同一个观察者对象指针不应该被重复注册到列表中,否则remove只会移除第一个找到的实例。
  • setMeasurements是改变主题状态的外部入口。一旦调用,它内部会调用measurementsChanged(),进而触发notifyObservers()。这是典型的“状态改变-通知”流程。
  • notifyObservers()中的遍历顺序,就是观察者被通知的顺序。有时这个顺序很重要,需要根据业务需求来设计注册或通知逻辑。

3.3 具体观察者的实现:各式显示屏

观察者需要实现Observer接口,并在构造时通常需要订阅(注册到)主题,在析构时取消订阅。

1. 当前状况显示屏

#include <iostream> class CurrentConditionsDisplay : public Observer { private: float temperature_; float humidity_; // 通常需要持有主题的引用或指针,以便在update时拉取数据(如果用拉模型)或用于取消注册。 Subject* weather_data_; public: // 构造函数中完成注册 explicit CurrentConditionsDisplay(Subject* weather_data) : weather_data_(weather_data), temperature_(0.0f), humidity_(0.0f) { weather_data_->registerObserver(this); } // 析构函数中完成移除,防止主题通知一个已销毁的对象 ~CurrentConditionsDisplay() override { if (weather_data_) { weather_data_->removeObserver(this); } } // 实现Observer接口 void update(float temperature, float humidity, float pressure) override { this->temperature_ = temperature; this->humidity_ = humidity; display(); } void display() const { std::cout << "当前状况: 温度 " << temperature_ << "°C, 湿度 " << humidity_ << "%" << std::endl; } };

2. 气象统计显示屏这个显示器需要记录历史数据并计算统计值,比如平均温度。

#include <vector> class StatisticsDisplay : public Observer { private: std::vector<float> temperature_history_; Subject* weather_data_; float max_temp_; float min_temp_; float avg_temp_; public: explicit StatisticsDisplay(Subject* weather_data) : weather_data_(weather_data), max_temp_(-200.0f), min_temp_(200.0f), avg_temp_(0.0f) { weather_data_->registerObserver(this); } ~StatisticsDisplay() override { if (weather_data_) { weather_data_->removeObserver(this); } } void update(float temperature, float humidity, float pressure) override { temperature_history_.push_back(temperature); // 更新统计值 if (temperature > max_temp_) max_temp_ = temperature; if (temperature < min_temp_) min_temp_ = temperature; float sum = 0.0f; for (float temp : temperature_history_) { sum += temp; } avg_temp_ = sum / temperature_history_.size(); display(); } void display() const { std::cout << "温度统计: 最高 " << max_temp_ << "°C, 最低 " << min_temp_ << "°C, 平均 " << avg_temp_ << "°C" << std::endl; } };

3. 简单预警显示屏这个显示器根据气压变化做一个非常简单的“天气预报”。

class ForecastDisplay : public Observer { private: float last_pressure_; float current_pressure_; Subject* weather_data_; public: explicit ForecastDisplay(Subject* weather_data) : weather_data_(weather_data), last_pressure_(1013.25f), current_pressure_(1013.25f) { weather_data_->registerObserver(this); } ~ForecastDisplay() override { if (weather_data_) { weather_data_->removeObserver(this); } } void update(float temperature, float humidity, float pressure) override { last_pressure_ = current_pressure_; current_pressure_ = pressure; display(); } void display() const { std::cout << "天气预报: "; if (current_pressure_ > last_pressure_) { std::cout << "天气可能转好!"; } else if (current_pressure_ < last_pressure_) { std::cout << "注意,可能下雨或刮风。"; } else { std::cout << "天气情况基本不变。"; } std::cout << std::endl; } };

4. 完整实战流程与代码整合

现在我们把所有部件组装起来,看看这个气象站系统如何工作。

4.1 主程序模拟气象数据更新

#include <iostream> #include <memory> // 为了使用std::unique_ptr管理动态对象 int main() { // 1. 创建主题(气象数据中心) WeatherData weather_data; // 2. 创建观察者(各种显示屏),并让它们订阅主题 // 使用智能指针管理,避免手动delete的麻烦和内存泄漏风险。 auto current_display = std::make_unique<CurrentConditionsDisplay>(&weather_data); auto stats_display = std::make_unique<StatisticsDisplay>(&weather_data); auto forecast_display = std::make_unique<ForecastDisplay>(&weather_data); std::cout << "=== 模拟气象站数据更新 ===" << std::endl; // 3. 模拟第一次数据更新 std::cout << "\n[更新1] 设置数据: 温度=25.0, 湿度=65, 气压=1015" << std::endl; weather_data.setMeasurements(25.0f, 65.0f, 1015.0f); // 输出: // 当前状况: 温度 25°C, 湿度 65% // 温度统计: 最高 25°C, 最低 25°C, 平均 25°C // 天气预报: 天气可能转好! // 4. 模拟第二次数据更新 std::cout << "\n[更新2] 设置数据: 温度=26.5, 湿度=70, 气压=1013" << std::endl; weather_data.setMeasurements(26.5f, 70.0f, 1013.0f); // 输出: // 当前状况: 温度 26.5°C, 湿度 70% // 温度统计: 最高 26.5°C, 最低 25°C, 平均 25.75°C // 天气预报: 注意,可能下雨或刮风。 // 5. 模拟移除一个观察者(例如,关闭统计显示屏) std::cout << "\n[操作] 移除统计显示屏观察者..." << std::endl; // 由于StatisticsDisplay在析构时会自动调用removeObserver,我们直接销毁它即可。 stats_display.reset(); // 释放stats_display指向的对象,触发其析构函数 // 6. 模拟第三次数据更新,此时只有两个观察者会收到通知 std::cout << "\n[更新3] 设置数据: 温度=24.0, 湿度=90, 气压=1010" << std::endl; weather_data.setMeasurements(24.0f, 90.0f, 1010.0f); // 输出: // 当前状况: 温度 24°C, 湿度 90% // 天气预报: 注意,可能下雨或刮风。 // (不再有温度统计输出) // main函数结束,current_display和forecast_display也会自动析构并取消注册。 return 0; }

4.2 编译与运行

你可以将上述所有类的定义和实现放在一个或多个.hpp.cpp文件中。一个简单的单文件编译命令如下(假设文件名为weather_station.cpp):

# 使用g++编译,需要支持C++11或更高标准(因为使用了std::make_unique,C++14引入) g++ -std=c++14 -o weather_station weather_station.cpp ./weather_station

运行后,你将看到清晰的输出,展示了数据更新如何自动触发所有注册的显示屏更新,以及移除观察者后通知列表的变化。

5. 进阶议题与性能考量

5.1 线程安全:当观察者模式遇上多线程

我们上面的实现是非线程安全的。想象一下这个场景:一个线程正在遍历observers_向量进行通知(notifyObservers),而另一个线程同时注册或移除一个观察者(修改了observers_向量),这会导致迭代器失效,引发崩溃(如std::vector的重新分配)。

解决方案

  1. 互斥锁(Mutex):在registerObserverremoveObservernotifyObservers方法内部使用std::mutex进行同步。这是最直接的方法。

    #include <mutex> class WeatherData : public Subject { private: mutable std::mutex mtx_; // mutable允许在const成员函数中加锁 std::vector<Observer*> observers_; // ... 其他成员 public: void registerObserver(Observer* o) override { std::lock_guard<std::mutex> lock(mtx_); observers_.push_back(o); } void notifyObservers() override { std::vector<Observer*> observers_copy; { std::lock_guard<std::mutex> lock(mtx_); observers_copy = observers_; // 复制列表,减少锁持有时间 } for (Observer* observer : observers_copy) { observer->update(temperature_, humidity_, pressure_); } } // ... removeObserver也需要加锁 };

    注意:在notifyObservers中,我们先复制观察者列表,然后在没有锁保护的情况下进行通知。这是为了避免在调用观察者的update方法时(该方法可能执行很长时间或尝试获取其他锁)仍然持有mtx_,从而导致死锁或降低并发性能。但复制操作有开销,且复制后原列表的变更(如新注册的观察者)不会在这次通知中生效,这需要根据业务逻辑判断是否可接受。

  2. 并发容器:使用如Intel TBBfolly库提供的并发向量,但C++标准库目前没有现成的线程安全容器直接适用于此场景。

  3. 无锁编程或RCU:对于高性能场景,可以考虑更复杂的无锁数据结构或Read-Copy-Update机制,但这大大增加了实现复杂度。

核心建议:对于大多数应用,使用互斥锁并小心管理锁的粒度(如复制列表后通知)是平衡安全性和复杂度的合理选择。务必在文档中明确说明该主题类的线程安全属性。

5.2 内存管理与生命周期

这是我们示例中留下的一个“坑”:使用原始指针管理观察者生命周期风险很高。

问题:如果观察者对象在尚未从主题中注销的情况下就被销毁(例如,局部对象超出作用域,或者被手动delete),主题持有的指针就变成了“悬垂指针”。下次通知时,通过这个指针调用update就是未定义行为,通常导致程序崩溃。

解决方案

  1. 使用std::shared_ptrstd::weak_ptr(推荐)

    • 观察者对象由std::shared_ptr<Observer>管理。
    • 主题内部存储std::weak_ptr<Observer>。弱指针不会增加引用计数,因此不会阻止观察者被销毁。
    • notifyObservers时,遍历弱指针列表,尝试将每个weak_ptr提升(lock())为shared_ptr。如果提升成功,说明观察者还活着,可以安全调用update;如果失败(返回空),说明观察者已销毁,则可以从列表中移除这个失效的弱指针。
    #include <memory> #include <vector> class Subject { public: virtual void registerObserver(std::weak_ptr<Observer> o) = 0; // removeObserver可能不再需要,因为可以通过检查weak_ptr是否过期来清理。 virtual void notifyObservers() = 0; }; class WeatherData : public Subject { private: std::vector<std::weak_ptr<Observer>> observers_; // ... void notifyObservers() override { auto it = observers_.begin(); while (it != observers_.end()) { if (auto observer_sp = it->lock()) { // 观察者还活着,安全通知 observer_sp->update(temperature_, humidity_, pressure_); ++it; } else { // 观察者已销毁,从列表中移除失效的弱指针 it = observers_.erase(it); } } } };

    这种方式自动处理了生命周期问题,但引入了智能指针的开销和稍微复杂一些的语法。

  2. 使用观察者令牌(Token):让registerObserver返回一个唯一的令牌(如整数ID或std::string),观察者或管理观察者的代码需要保存这个令牌。当观察者要注销时,或在其析构函数中,必须使用这个令牌调用主题的removeObserver。这要求观察者或其所有者有明确的注销意识。

  3. 主题拥有观察者的所有权:在某些设计下,主题负责创建和销毁所有观察者。这适用于观察者生命周期完全由主题控制的场景,耦合度较高。

实操心得:在新项目中,如果条件允许,我强烈建议采用weak_ptr方案。它虽然看起来复杂一点,但一劳永逸地解决了生命周期管理的核心痛点,让代码更健壮。对于老项目或性能极其敏感的模块,如果使用原始指针,则必须建立严格的编程规范,确保观察者在销毁前一定注销,并考虑使用辅助类(如RAII包装器)来自动化注册/注销过程。

5.3 通知的顺序与性能

  • 通知顺序std::vector的遍历顺序是注册顺序。有时业务可能要求特定的通知顺序(如优先级)。你可以改用std::list并自定义插入逻辑,或者在观察者接口中增加一个优先级字段,主题使用优先队列(如std::multiset)来存储。
  • 性能考量
    • notifyObservers是O(N)操作,N是观察者数量。如果N非常大(成千上万),且update操作很轻量,通知过程可能成为瓶颈。可以考虑批量更新、异步通知或将观察者分组。
    • update方法内,观察者应避免执行耗时操作或调用可能阻塞的函数,否则会拖慢整个通知流程,影响其他观察者和主题本身的响应性。对于耗时任务,应考虑在观察者内部将其抛到另一个线程或线程池中执行。

6. 常见问题排查与设计模式变体

6.1 实战中踩过的坑

  1. 通知循环或过频通知:在观察者的update方法中,如果又调用了主题的某个方法,而该方法会再次触发setMeasurementsnotifyObservers,就可能形成无限递归或意料之外的多重通知。务必检查update方法中的逻辑,确保不会间接导致主题状态被再次修改。有时需要在主题中设置一个“更新中”的标志位来防止重入。

  2. 观察者update方法抛出异常:如果某个观察者的update方法抛出异常,会导致整个通知循环中断,后面的观察者收不到通知。这可能是灾难性的。一种做法是在notifyObservers内部对每个观察者的调用进行try-catch,记录错误但继续通知其他观察者。但这需要谨慎设计异常处理策略。

  3. 忘记在析构函数中取消注册:这是使用原始指针时最常见的错误。务必在观察者析构函数中调用subject_->removeObserver(this)。使用RAII思想,将注册操作放在构造函数,注销操作放在析构函数,可以很大程度上避免这个问题。

  4. “拉”模型中的数据竞争:如果使用“拉”模型(update(Subject&)),观察者在update方法内部调用subject.getTemperature()等方法。在多线程环境下,如果主题的数据在notifyObservers调用之后、观察者拉取数据之前被另一个线程修改,观察者可能读到不一致的快照。这需要结合主题数据的线程安全设计来考虑。

6.2 观察者模式的变体与相关模式

  • 发布-订阅模式:观察者模式常与发布-订阅模式混淆。两者核心思想相似,但发布-订阅模式通常引入了一个“事件通道”或“消息代理”作为中介,发布者和订阅者完全不知道对方的存在,耦合度更低。我们的简单气象站是标准的观察者模式。如果气象站将数据发送到一个消息队列(如RabbitMQ、Kafka),各个显示屏从队列订阅数据,那就是发布-订阅。
  • 事件监听器:在GUI编程(如Qt、MFC)或JavaScript中非常常见,本质上是观察者模式的应用。按钮(主题)拥有点击事件,多个处理函数(观察者)可以监听这个事件。
  • 反应式编程:像RxCpp这类库,将观察者模式的思想推向极致,提供了强大的数据流组合、变换和异步处理能力。你可以把气象数据看作一个数据流(Observable),各个显示屏是订阅这个流的观察者(Subscriber)。

6.3 何时使用与何时避免

使用场景

  • 当一个对象的改变需要同时改变其他对象,并且你不知道具体有多少对象有待改变时。
  • 当一个对象必须通知其他对象,但又希望避免这些对象之间紧密耦合时。
  • 跨层或跨模块通信,例如业务逻辑层需要通知多个UI组件更新。

避免场景

  • 如果观察者数量极少且固定,直接调用可能更简单。
  • 如果通知链过于复杂,可能导致难以调试的级联更新。
  • 对于性能要求极其苛刻的实时系统,遍历观察者列表的开销可能需要评估。

最后,观察者模式是工具箱里一件非常趁手的兵器。理解其原理和实现中的各种细节(尤其是生命周期和线程安全),能让你在面临对象间复杂的动态依赖关系时,写出更清晰、更灵活、更易于维护的C++代码。这个气象站的小例子,几乎包含了模式最核心的骨架,你可以在此基础上,根据实际项目需求,填充血肉,应对更复杂的场景。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询