18、观察者模式:模式动机与定义、角色与结构、C++代码实现、发布-订阅模型

模式动机:为什么需要观察者?

我先问大家一个问题:你写代码的时候,有没有遇到过这种场景?

一个对象状态变了,然后你得手动去通知一堆其他对象。比如一个UI窗口,用户点了“保存”,你得更新状态栏、刷新列表、还要弹个提示。一开始你直接在按钮回调里挨个调用。后来需求多了,每次加一个新模块,你都得回去改那个回调函数。改着改着,你就发现这个函数越来越长,耦合越来越紧。

我几年前维护过一个老项目,里面有个核心数据类,每次数据一变,它要调用十几个不同的更新函数。新同事加功能,第一件事就是去那个类里加一行调用。有一次有人不小心删了一行,结果整个报表模块三天没更新数据,排查了一整天才找到原因。

说白了,这种“一对多”的依赖关系,如果硬编码在调用方,维护成本会指数级上升。观察者模式就是为了解决这个问题——让被观察者(Subject)和观察者(Observer)之间解耦。被观察者只管发通知,不关心谁在听。观察者只管收通知,不关心谁发的。

核心动机:定义一种一对多的依赖关系,当一个对象状态发生变化时,所有依赖于它的对象都得到通知并自动更新。

模式定义与角色结构

观察者模式的定义其实很直白:

观察者模式(Observer Pattern):定义对象间的一种一对多的依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。

这个模式里,主要有这么几个角色:

角色 名称 职责
Subject(目标) 被观察者 维护观察者列表,提供注册、注销、通知接口
Observer(观察者) 观察者接口 定义更新接口,收到通知后执行具体逻辑
ConcreteSubject(具体目标) 具体被观察者 存储状态,状态变化时通知所有观察者
ConcreteObserver(具体观察者) 具体观察者 实现更新接口,保持与目标状态一致

嗯,这里要注意:观察者模式的核心是“订阅-通知”机制。目标对象不知道观察者的具体类型,只知道它们实现了同一个接口。这就是依赖倒置原则的体现——抽象不应该依赖细节。

SVG 结构图:观察者模式核心逻辑

Subject(抽象目标) + attach(Observer*) + detach(Observer*) + notify() ConcreteSubject - state: int + getState(): int + setState(int) 继承 Observer(观察者接口) + update(Subject*) ConcreteObserver - observerState: int + update(Subject*) 继承 聚合(观察者列表) notify() → update() 拉取状态

C++ 代码实现:从零搭建观察者框架

理论说完了,咱们直接上代码。我个人习惯先写抽象接口,再写具体实现。

先看观察者接口:

// observer.h
#pragma once
#include <string>

class Subject; // 前向声明

class Observer {
public:
    virtual ~Observer() = default;
    virtual void update(Subject* subject) = 0;
    virtual std::string name() const = 0;
};

然后是目标基类:

// subject.h
#pragma once
#include <vector>
#include <algorithm>
#include <memory>
#include "observer.h"

class Subject {
public:
    virtual ~Subject() = default;

    void attach(Observer* obs) {
        if (obs) {
            observers_.push_back(obs);
        }
    }

    void detach(Observer* obs) {
        auto it = std::remove(observers_.begin(), observers_.end(), obs);
        observers_.erase(it, observers_.end());
    }

    void notify() {
        for (auto* obs : observers_) {
            obs->update(this);
        }
    }

private:
    std::vector<Observer*> observers_;
};

这里有个细节:我用了原始指针。为什么不用 shared_ptr?因为观察者的生命周期通常由外部管理,目标对象不负责销毁观察者。用原始指针更轻量,也符合“非拥有”的语义。

避坑指南:我曾经在一个项目里用 shared_ptr 管理观察者,结果不小心造成了循环引用——目标持有观察者的 shared_ptr,观察者又持有目标的 shared_ptr。最后内存泄漏了。后来我改成原始指针 + 确保观察者在析构前调用 detach,问题就解决了。

接下来是具体目标——一个天气数据类:

// weather_data.h
#pragma once
#include "subject.h"

class WeatherData : public Subject {
public:
    void setMeasurements(float temp, float humidity, float pressure) {
        temperature_ = temp;
        humidity_ = humidity;
        pressure_ = pressure;
        measurementsChanged();
    }

    float getTemperature() const { return temperature_; }
    float getHumidity() const { return humidity_; }
    float getPressure() const { return pressure_; }

private:
    void measurementsChanged() {
        notify(); // 通知所有观察者
    }

    float temperature_ = 0.0f;
    float humidity_ = 0.0f;
    float pressure_ = 0.0f;
};

具体观察者——显示面板:

// display_panel.h
#pragma once
#include "observer.h"
#include "weather_data.h"
#include <iostream>

class CurrentConditionsDisplay : public Observer {
public:
    explicit CurrentConditionsDisplay(const std::string& name)
        : name_(name) {}

    void update(Subject* subject) override {
        // 向下转型获取具体数据
        auto* wd = dynamic_cast<WeatherData*>(subject);
        if (wd) {
            temperature_ = wd->getTemperature();
            humidity_ = wd->getHumidity();
            display();
        }
    }

    std::string name() const override { return name_; }

    void display() const {
        std::cout << "[" << name_ << "] "
                  << "温度: " << temperature_
                  << "°C, 湿度: " << humidity_
                  << "%" << std::endl;
    }

private:
    std::string name_;
    float temperature_ = 0.0f;
    float humidity_ = 0.0f;
};

客户端使用示例:

// main.cpp
#include "weather_data.h"
#include "display_panel.h"

int main() {
    WeatherData weatherStation;

    CurrentConditionsDisplay display1("客厅面板");
    CurrentConditionsDisplay display2("卧室面板");

    weatherStation.attach(&display1);
    weatherStation.attach(&display2);

    weatherStation.setMeasurements(25.3f, 65.0f, 1013.0f);
    // 输出:
    // [客厅面板] 温度: 25.3°C, 湿度: 65%
    // [卧室面板] 温度: 25.3°C, 湿度: 65%

    weatherStation.detach(&display1);
    weatherStation.setMeasurements(26.1f, 70.0f, 1012.0f);
    // 只有卧室面板收到通知

    return 0;
}

发布-订阅模型:观察者模式的进阶形态

你想想看,观察者模式有一个局限:目标对象和观察者之间还是存在直接依赖——观察者需要知道目标的具体类型才能转型获取数据。在大型系统里,这种依赖还是不够灵活。

发布-订阅模型(Pub-Sub)就是观察者模式的升级版。它引入了一个“事件通道”或“消息代理”作为中间层。发布者只管往通道里扔消息,订阅者只管从通道里收消息,双方完全不知道对方的存在。

我参与过一个实时交易系统,就是用发布-订阅模型来解耦各个模块。订单模块发布“订单成交”事件,风控模块、清算模块、通知模块各自订阅自己关心的事件。加一个新模块,只需要注册一个新订阅者,完全不用改现有代码。

下面是一个简单的 C++ 事件总线实现:

// event_bus.h
#pragma once
#include <functional>
#include <map>
#include <vector>
#include <string>
#include <any>

class EventBus {
public:
    using Handler = std::function<void(const std::any&)>;

    void subscribe(const std::string& eventType, Handler handler) {
        handlers_[eventType].push_back(std::move(handler));
    }

    void publish(const std::string& eventType, const std::any& data) {
        auto it = handlers_.find(eventType);
        if (it != handlers_.end()) {
            for (const auto& handler : it->second) {
                handler(data);
            }
        }
    }

private:
    std::map<std::string, std::vector<Handler>> handlers_;
};

使用方式:

EventBus bus;

// 订阅者A
bus.subscribe("temperature_change", [](const std::any& data) {
    float temp = std::any_cast<float>(data);
    std::cout << "订阅者A: 温度变为 " << temp << std::endl;
});

// 订阅者B
bus.subscribe("temperature_change", [](const std::any& data) {
    float temp = std::any_cast<float>(data);
    std::cout << "订阅者B: 记录温度 " << temp << std::endl;
});

// 发布者
bus.publish("temperature_change", 26.5f);

注意:发布-订阅模型虽然解耦更彻底,但调试起来也更困难。因为事件流是隐式的,你很难一眼看出某个事件被哪些订阅者处理了。我建议在关键事件上加上日志,或者用唯一ID追踪事件链路。

总结一下

观察者模式的核心就是“通知-更新”。它让目标对象和观察者之间从硬编码变成抽象依赖。发布-订阅模型则更进一步,通过中间层实现了完全解耦。

在实际项目中,我通常这样选择:

  • 如果观察者和目标在同一个进程内,且数量不多,直接用观察者模式,代码更简单直接。
  • 如果系统模块较多,或者需要跨模块、跨线程通信,我会选择发布-订阅模型,配合事件总线。
  • 如果涉及分布式系统,那就得上消息队列(如 RabbitMQ、Kafka)了,那是另一个话题。

嗯,观察者模式就讲到这里。记住一点:解耦是手段,不是目的。别为了用模式而用模式,合适才是最好的。


公众号:蓝海资料掘金营,微信deep3321