Day 18 / GoF 行为型模式

观察者模式(Observer Pattern)

定义对象间一对多的依赖关系:当一个对象状态改变时,所有依赖它的对象都会得到通知并自动更新 —— 事件驱动世界的基石

①今日主题

观察者模式(Observer Pattern):定义对象间一种一对多的依赖关系,使得当一个对象(主题 Subject / 被观察者)改变状态时,所有依赖于它的对象(观察者 Observer)都会被自动通知并更新。它属于 GoF 23 种设计模式中的行为型模式,是"发布-订阅"思想在进程内、直接对象引用形态下的经典实现。

一句话记住它:"我不主动找你,我变了就喊一嗓子,谁关心谁就来。" 主题只负责广播"我变了",至于谁在听、听到后干什么,主题一概不关心。

②为什么需要这个模式

先看一段没有模式的"坏代码"。假设我们要做一个气象站系统:气象站采集到新数据后,需要同步刷新三块展示屏——大屏、手机 App、网页端。

// 坏味道:气象站"认识"每一个具体的展示屏
class WeatherStation {
public:
    void setMeasurements(float t, float h, float p) {
        m_temp = t; m_humidity = h; m_pressure = p;
        // 直接调用每个具体展示屏的方法 —— 高耦合!
        m_bigScreen->update(t, h, p);
        m_phoneApp->refresh(t, h, p);
        m_webPage->render(t, h, p);
    }
private:
    BigScreen*   m_bigScreen;   // 具体类
    PhoneApp*    m_phoneApp;    // 具体类
    WebPage*     m_webPage;     // 具体类
};

这段代码的问题非常典型:

💡 问题的本质:变化的方向错了。"有哪些展示端"是易变的部分,"数据变化要通知"是稳定的部分。观察者模式把易变的部分从稳定部分中剥离出去,让稳定部分只依赖一个抽象的"观察者"接口。

③核心思想

观察者模式的核心思想可以概括为四个字:订阅-广播。

生活类比:报刊订阅。出版社(主题)并不知道每个读者的名字和住址,读者只需在邮局登记订阅(attach)。每期报纸印好(状态变化),邮局按订阅名单投递(notify)。读者想停订就退订(detach),出版社完全不需要改动。再比如微信公众号:你关注了某个号(attach),号主一发文(notify),所有粉丝自动收到推送——号主从来不直接调用任何一个粉丝的"阅读"方法。

另一个经典类比是电台广播:电台(主题)对着频率喊话,收音机(观察者)调到该频率就能收到;收音机没开,电台照常广播,互不影响。观察者模式里的"频率"就是那个抽象的观察者接口。

模式还演化出两种通知模型:

Qt 的信号槽本质上是一种"推模型"的观察者:信号把参数打包发给槽函数。

④UML / 角色关系

观察者模式由四个角色组成:

角色名称职责
抽象主题Subject维护观察者列表;提供 attach() / detach() / notify()
具体主题ConcreteSubject持有真实状态;状态变化时调用 notify();可提供 getter 供拉模型使用
抽象观察者Observer定义统一的更新接口 update()(纯虚函数)
具体观察者ConcreteObserver实现 update(),定义收到通知后的具体行为;可持有对具体主题的引用以便拉数据
┌─────────────────┐        ┌──────────────────┐
│    <<interface>>   │        │  <<interface>>    │
│     Subject      │        │     Observer     │
├─────────────────┤        ├──────────────────┤
│ +attach(o)       │        │ +update(data)    │
│ +detach(o)       │        └──────────────────┘
│ +notify()        │                ▲
└───────┬─────────┘                │ 实现
        │ 1 ── * 维护观察者列表     │
        │                          │
┌───────┴─────────┐        ┌───────┴──────────┐
│ ConcreteSubject │        │ ConcreteObserver │
├─────────────────┤        ├──────────────────┤
│ -state           │        │ +update(data)    │
│ +setState(s)     │────────│   { 各自刷新 }   │
└─────────────────┘ 通知    └──────────────────┘

协作流程:具体观察者把自己 attach 到具体主题 → 具体主题状态改变 → 调用 notify() → 遍历观察者列表逐个调用 update() → 各观察者自行刷新。整个过程主题只依赖抽象的 Observer 接口。

⑤最小 C++ 示例

用 C++17 实现一个完整可编译的气象站例子。注意规范:观察者列表用 std::shared_ptr 管理所有权,detach 用 erase-remove 惯用法,杜绝悬空指针与内存泄漏。

// observer_demo.cpp —— C++17 观察者模式最小示例
// 编译:g++ -std=c++17 -Wall -Wextra observer_demo.cpp -o observer_demo
#include <iostream>
#include <vector>
#include <memory>
#include <algorithm>
#include <string>

// ── 抽象观察者:所有展示端实现的统一接口 ──
class IObserver {
public:
    virtual ~IObserver() = default;               // 虚析构,保证多态删除安全
    virtual void update(float temp, float humidity, float pressure) = 0;
};

// ── 具体观察者:手机 App 展示端 ──
class PhoneApp : public IObserver {
public:
    explicit PhoneApp(std::string name) : m_name(std::move(name)) {}
    void update(float temp, float humi, float pres) override {
        std::cout << "[" << m_name << "] 温度 " << temp
                  << "°C | 湿度 " << humi
                  << "% | 气压 " << pres << " hPa\n";
    }
private:
    std::string m_name;
};

// ── 主题(被观察者):气象站 ──
class WeatherStation {
public:
    // 订阅:加入观察者列表
    void attach(std::shared_ptr<IObserver> obs) {
        m_observers.push_back(std::move(obs));
    }
    // 退订:从列表移除(erase-remove 惯用法)
    void detach(const std::shared_ptr<IObserver>& obs) {
        m_observers.erase(
            std::remove(m_observers.begin(), m_observers.end(), obs),
            m_observers.end());
    }
    // 状态变化入口:更新数据并广播
    void setMeasurements(float t, float h, float p) {
        m_temp = t; m_humidity = h; m_pressure = p;
        notify();
    }
private:
    void notify() {
        for (auto& obs : m_observers) {          // 逐个通知,主题不认识具体类型
            obs->update(m_temp, m_humidity, m_pressure);
        }
    }
    std::vector<std::shared_ptr<IObserver>> m_observers;  // 观察者列表
    float m_temp = 0.0f, m_humidity = 0.0f, m_pressure = 0.0f;
};

int main() {
    WeatherStation station;
    auto livingRoom = std::make_shared<PhoneApp>("客厅大屏");
    auto phone      = std::make_shared<PhoneApp>("手机通知");

    station.attach(livingRoom);                    // 两块屏都订阅
    station.attach(phone);

    station.setMeasurements(26.5f, 60.0f, 1013.2f); // 第一次数据更新,双屏都收到

    station.detach(livingRoom);                    // 客厅大屏退订
    station.setMeasurements(27.1f, 58.0f, 1012.8f); // 第二次更新,只剩手机收到

    return 0;
}

程序输出:

[客厅大屏] 温度 26.5°C | 湿度 60% | 气压 1013.2 hPa
[手机通知] 温度 26.5°C | 湿度 60% | 气压 1013.2 hPa
[手机通知] 温度 27.1°C | 湿度 58% | 气压 1012.8 hPa

注意体会:加一块"网页展示屏"只需要新建一个继承 IObserver 的类再 attach 进去,WeatherStation 一行都不用改——这就是 OCP 的胜利。

⑥Qt 实战示例

Qt 本身就是观察者模式的"重度用户":信号与槽(Signals & Slots)机制就是观察者模式在 C++ 框架层面最成功的工程化实现。信号相当于"主题的通知",槽相当于"观察者的 update",connect() 相当于 attach,disconnect() 相当于 detach。相比手写观察者,Qt 的信号槽还额外提供了:

下面把气象站例子用 Qt6 + C++17 重写。三个文件 + CMake:

weatherstation.h —— 主题:气象站(QObject + 信号)

#ifndef WEATHERSTATION_H
#define WEATHERSTATION_H

#include <QObject>

// 主题:气象站。状态变化时通过信号广播给所有连接的观察者
class WeatherStation : public QObject {
    Q_OBJECT
public:
    explicit WeatherStation(QObject* parent = nullptr) : QObject(parent) {}

    // 状态变化入口:更新数据并发射信号(等价于 notify())
    void setMeasurements(double temp, double humidity, double pressure) {
        m_temp = temp; m_humidity = humidity; m_pressure = pressure;
        emit measurementsChanged(m_temp, m_humidity, m_pressure);
    }

signals:
    // 通知信号:参数就是推送给观察者的数据(推模型)
    void measurementsChanged(double temp, double humidity, double pressure);

private:
    double m_temp = 0.0, m_humidity = 0.0, m_pressure = 0.0;
};

#endif // WEATHERSTATION_H

display.h —— 观察者:展示端(QObject + 槽)

#ifndef DISPLAY_H
#define DISPLAY_H

#include <QObject>
#include <QDebug>
#include <QString>

// 观察者:展示端。槽函数等价于 update(),收到通知后自行刷新
class PhoneDisplay : public QObject {
    Q_OBJECT
public:
    explicit PhoneDisplay(const QString& name, QObject* parent = nullptr)
        : QObject(parent), m_name(name) {}

public slots:
    // 观察者的更新逻辑:打印一条日志模拟刷新 UI
    void onMeasurementsChanged(double temp, double humidity, double pressure) {
        qInfo() << "[" << m_name << "] 温度:" << temp
                << "°C 湿度:" << humidity << "% 气压:" << pressure << "hPa";
    }

private:
    QString m_name;
};

#endif // DISPLAY_H

main.cpp —— 组装:订阅与退订

#include <QCoreApplication>
#include "weatherstation.h"
#include "display.h"

int main(int argc, char* argv[]) {
    QCoreApplication app(argc, argv);

    WeatherStation station;                    // 主题
    PhoneDisplay livingRoom("客厅大屏");        // 观察者 1
    PhoneDisplay phone("手机通知");             // 观察者 2

    // attach:把观察者的槽连接到主题的信号
    QObject::connect(&station, &WeatherStation::measurementsChanged,
                     &livingRoom, &PhoneDisplay::onMeasurementsChanged);
    QObject::connect(&station, &WeatherStation::measurementsChanged,
                     &phone, &PhoneDisplay::onMeasurementsChanged);

    station.setMeasurements(26.5, 60.0, 1013.2); // 广播 1:两块屏都刷新

    // detach:断开客厅大屏
    QObject::disconnect(&station, &WeatherStation::measurementsChanged,
                        &livingRoom, &PhoneDisplay::onMeasurementsChanged);

    station.setMeasurements(27.1, 58.0, 1012.8); // 广播 2:只剩手机收到
    return 0;
}

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(observer_demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)   # 自动处理 Q_OBJECT 的 moc

find_package(Qt6 REQUIRED COMPONENTS Core)

add_executable(observer_demo
    main.cpp
    weatherstation.h
    display.h
)
target_link_libraries(observer_demo PRIVATE Qt6::Core)

构建与运行:

mkdir build && cd build
cmake .. -DCMAKE_PREFIX_PATH=/path/to/qt6
cmake --build .
./observer_demo

运行输出(qInfo 格式):

[ "客厅大屏" ] 温度: 26.5 °C 湿度: 60 % 气压: 1013.2 hPa
[ "手机通知" ] 温度: 26.5 °C 湿度: 60 % 气压: 1013.2 hPa
[ "手机通知" ] 温度: 27.1 °C 湿度: 58 % 气压: 1012.8 hPa

在真正的 Qt 项目里,上面的 PhoneDisplay 通常是一个 QWidget/QLabel:把 onMeasurementsChanged 里换成 ui->tempLabel->setText(...) 就是标准的"数据变化驱动界面刷新",这也是 MVC 中 View 监听 Model 变化的基础。

⑦代码执行流程

以最小 C++ 示例为主线,梳理完整执行流程:

  1. 创建并订阅:main 中构造 WeatherStation 和两个 PhoneApp,分别调用 station.attach(...) 把两个观察者加入 m_observers 列表。此时列表长度为 2,主题与观察者之间只有抽象接口的依赖。
  2. 状态更新:调用 station.setMeasurements(26.5, 60.0, 1013.2),函数内部先更新三个成员变量(m_temp/m_humidity/m_pressure),再调用私有方法 notify()。
  3. 广播通知:notify() 用范围 for 遍历 m_observers,对每个元素调用 obs->update(m_temp, m_humidity, m_pressure)——这是多态调用,实际执行的是 PhoneApp::update,打印一行数据。两个观察者依次收到通知,输出两行日志。
  4. 动态退订:station.detach(livingRoom) 用 erase-remove 把客厅大屏从列表中移除,列表长度变 1。再次 setMeasurements(27.1, ...) 时,只有手机收到通知。
  5. 收尾:main 返回,WeatherStation 析构时其 std::shared_ptr<IObserver> 列表自动释放引用;PhoneApp 对象在 main 栈上自动析构。全程无手动 delete,无悬空指针。

Qt 版本的流程本质相同,只是把"调用 update()"换成了"emit 信号 → 由元对象系统查找并调用所有已连接的槽";唯一差异是 disconnect 之后,如果接收者对象被销毁,连接会自动清理,无需手动维护列表。

⑧为什么这样设计

观察者模式的每一个设计决策都指向同一个目标:让"通知的发出方"与"通知的接收方"彻底解耦。拆开来看:

💡 设计精髓:主题说"我变了,谁想知道自己来听",而不是"我挨个告诉你"。把通知的控制权从主题手中交还给"关注者自己",这就是观察者模式与"硬编码轮询/硬编码直调"的本质区别。

⑨不使用会怎样

回到第二节的坏代码,想象系统继续演化的下场:

一句话总结反例的代价:稳定的通知机制被易变的接收方绑架,每一次"新增接收方"都变成对核心代码的一次外科手术。

⑩何时使用

适合使用的场景(3-5 个):

不适合的场景(2-4 个):

过度设计提醒:一对一的"A 变了 B 要更新",用普通调用即可,别为了"以后可能加观察者"提前套模式(YAGNI)。另外注意通知风暴:A 通知 B,B 又通知 A,可能形成环状级联;性能:广播是同步的,观察者里别做重活,否则拖慢主题。

⑪与其他模式的区别

对比模式相似点关键区别
中介者模式(Mediator) 都是"多对象协作",都降低对象间耦合 观察者是一对多广播:主题→观察者单向流动,观察者之间互不认识;中介者是多对多中枢:所有对象(同事)只和中介者通信,由中介者协调转发,形成星型结构。Qt 的 QAbstractItemModel 之于多个 View 是观察者;而对话框里多个控件通过 QDialog 协调联动则更接近中介者。
发布-订阅(Pub/Sub) 思想同源:发布者不直接调用订阅者 观察者模式里主题直接持有观察者引用,是进程内同步的;Pub/Sub 通常通过消息代理/主题通道中转,发布者和订阅者彼此完全无引用,可跨进程、跨机器、异步。观察者是 Pub/Sub 的"进程内简化版"。
命令模式(Command) 都把"行为"封装成对象/接口 命令模式封装的是"一个待执行的动作"(可撤销、可排队),由调用者显式触发;观察者封装的是"对变化的响应",由主题状态变化隐式触发。命令是"主动的执行",观察者是"被动的响应"。

另外常被混淆的是信号槽与回调(Callback):回调是单接收者的函数指针,信号槽是多接收者、类型安全、可自动断连的观察者实现——可以认为 Qt 信号槽是回调的"观察者化升级版"。

⑫Qt 源码中的体现

观察者模式在 Qt 里不是"某个类用到了",而是整个框架的地基。最直接的体现:

可以说:理解观察者模式,就理解了 Qt 事件驱动架构的一半。

⑬面试常见问题

Q1:观察者模式和发布-订阅模式有什么区别?

A:观察者模式中,主题直接持有观察者的引用(或指针),通知是进程内同步调用,属于强引用的一对多;发布-订阅在两者之间引入了消息代理/通道,发布者和订阅者互不感知,支持跨进程、跨线程、异步。观察者模式可视为发布-订阅的进程内简化形态。Qt 信号槽介于两者之间:默认同线程直接调用(观察者风格),跨线程时退化为队列投递(带上了 Pub/Sub 的异步色彩)。

Q2:手写观察者模式时,如何避免悬空指针和内存泄漏?

A:三条策略:① 所有权清晰——观察者由外部持有(栈对象或 shared_ptr),主题只保存非拥有的引用/弱引用(如 std::weak_ptr 或裸指针,裸指针需配合"析构时 detach"的约定);② 观察者析构时必须从主题列表中移除自己(QObject 就是这么做的——析构自动 disconnect);③ 主题析构时清空列表。Qt 方案最省心:用 QObject 父子关系 + 信号槽自动断连,从语言层面消灭悬空通知。

Q3:推模型和拉模型各自优缺点?

A:推模型:主题把数据打包传给观察者,简单直接,但观察者可能收到大量用不上的数据,且主题需知道观察者"可能关心什么"。拉模型:主题只发"我变了",观察者按需调用主题 getter 取数,更灵活省流量,但观察者必须持有主题的具体类型引用,耦合更高。实践中常混合:通知带轻量提示参数,重数据按需拉取。

Q4:Qt 信号槽相比手写观察者,多解决了哪些问题?

A:① 类型安全(编译期检查参数签名);② 接收者析构自动断连,无悬空通知;③ 跨线程自动排队(QueuedConnection),线程安全;④ 支持一个信号连多个槽、一个槽连多个信号、信号连信号(转发);⑤ 断连粒度细(可按发送者/信号/接收者任意组合)。代价是 moc 元对象编译和反射查找的开销,以及信号槽调用比直接虚函数调用略慢。

Q5:观察者模式会不会导致性能问题?怎么优化?

A:会。同步广播下,N 个观察者串行执行,最慢的观察者拖慢主题。优化手段:① 观察者内部做轻量工作,重活异步化(Qt 中可用 Qt::QueuedConnection 或移入工作线程);② 状态高频变化时合并通知(如 Qt 的 layoutAboutToBeChanged 批量信号模式);③ 节流/去抖;④ 必要时按需注册,控制观察者数量。

⑭今日练习(15-30 分钟)

题目:股票价格监控系统

用观察者模式实现一个简单的股票行情监控:

  • Stock 类(主题):持有股票代码和当前价格,提供 setPrice(double) 更新价格并广播。
  • 至少两个观察者:① PriceAlert——当价格涨跌超过设定的阈值时打印"买入/卖出提醒";② TradeLogger——把每次价格变动打印成一行交易日志。
  • 主函数演示:订阅两个观察者 → 更新几次价格(其中一次触发阈值)→ 退订其中一个 → 再更新价格,观察输出变化。

提示 1:观察者接口 update 的签名可以直接传"当前价格",也可以传"价格变化量"——想一想哪种更适合"阈值告警"的需求(提示:告警需要知道新旧价格差)。

提示 2:观察者列表用 std::vector<std::shared_ptr<IObserver>>,退订时用 erase-remove;试着在 Stock 析构时打印一条日志,观察析构顺序,体会所有权管理。

进阶(可选):改用 Qt 实现——Stock 继承 QObject 发射 priceChanged(double) 信号,观察者用槽函数,用 QTimer::singleShot 模拟价格随时间波动,观察信号槽自动断连的优势。

⑮今日总结

一句话记忆:"我变了就广播,谁关心谁订阅"——主题只认抽象的观察者接口,接收方的增删与主题无关。

代码特征信号(看到这些就该想到观察者模式):

  • 一个类里有 attach / detach / register / unregister / subscribe / unsubscribe 之类的方法,内部维护一个"监听者列表";
  • 类中保存 std::vector<Observer*> / std::vector<std::function<...>> / QList<QObject*> 这类"回调集合";
  • 状态 setter 的最后总有一句"遍历列表逐个通知"(notify() / emit xxxChanged(...));
  • 出现 dataChanged / valueChanged / priceChanged / finished 这类"变化即信号"的命名习惯(Qt 命名规范);
  • 一个对象的状态变化能引发多个互不相识的对象各自行动。

明日预告:Day 19 —— 状态模式(State Pattern):当对象的行为随内部状态而改变、if-else 满天飞时,如何用"状态对象"重构,让每个状态自己管自己的行为。状态模式与观察者模式堪称"对象行为双子星"——一个管"状态变了通知谁",一个管"状态变了行为怎么变"。