【设计模式精讲】24.观察者模式(Observer)
【摘要】:传感器每来一个新样本,当前值面板、统计面板、告警模块都该刷新------直觉的写法是在
onSample里逐个调用三个面板,订阅者从此焊死在发布者体内,加一块面板改一次传感器。本文从 RG 的经典气象站讲起,给出观察者的 GoF 意图:一对多依赖、状态一变、全体自动通知------发布者对订阅者的认知降到「一个接口」为止;C++98 版覆盖 notify 的重入副本、推与拉两种模型,现代版给出 std::function 槽位、RAII 订阅令牌与 weak_ptr 三件套,把「观察者悬垂」这个 C++ 最著名的坑逐一封死。文末对照 Boost.Signals2、C++20 的 std::stop_callback 与 AOSP 传感器事件队列。这是 23 种模式里知名度最高的一个,也是生命周期陷阱最多的一个。【关键词】:观察者、发布订阅、事件通知、回调、悬垂指针、信号槽
【代码基准】:C++17
1. 每加一块面板,就改一次传感器
工控面板上接着一个温度传感器,新样本到来时要刷新三处:当前值显示、历史统计、超温告警。第一版代码顺手就这样写:
cpp
// 说明性片段
// ❌ 订阅者名单焊死在发布者体内
void Sensor::onSample(double t) {
temp_ = t;
currentPanel_->update(t); // 认识面板一
statsPanel_->update(t); // 认识面板二
alertModule_->update(t); // 认识面板三
}
这是 Refactoring Guru 讲观察者时用的气象站场景的工控版,病灶也如出一辙:传感器被迫认识每一块面板------每加一个订阅者(比如「数据落盘」),就要改一次传感器;面板想换个数据源(接压力传感器),也要改面板自己;「谁关心温度变化」这个本该运行时增减的名单,被编译进了发布者的类型里。反方向的方案是轮询:各面板定时来拉------白白浪费了不变化时的全部开销,又牺牲了实时性。
结构性诊断:状态的所有者与状态的消费者被直接焊死。传感器(拥有状态)与面板(消费状态)之间缺一份运行时可增减的订阅关系。观察者模式补的正是这份关系------顺带把发布者对订阅者的认知压缩到极限:一个接口。
2. 模式意图与定义
- 一句话定义 :定义对象间的一种一对多依赖,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
解决的问题 :状态变化需要通知一组动态增减、互不相识的关心者,而状态的所有者不想认识他们中的任何一个。 - GoF 原文意图 :Define a one-to-many dependency between objects so that when an object changes state, all its dependents are notified and updated automatically. (定义对象间一对多的依赖关系。)关键词是 one-to-many 与 automatically------前者区别于责任链的一对一传递(第 18 篇),后者划清与轮询的界限。
- Refactoring Guru 的表述:观察者模式允许你定义一种订阅机制,可在对象事件发生时通知多个「观察」该对象的其他对象------RG 用气象站做例子:气象站(发布者)拥有数据,各种展示面板(订阅者)各取所需,增删面板互不打扰。
三条定性,条条对应后面的工程要点:
- 发布者的认知降到「一个接口」 ------
Sensor只认Panel*,不知道面板是谁、有几个、会做什么。这是第 2 篇依赖倒置的运行时形态:抽象(接口)拥有实现(订阅者名单),实现反过来依赖抽象。 - 推与拉 :通知可以推 (
update(temp)把数据带过去,快、省一次往返,但接口被数据形状绑死),也可以拉 (update(subject)只递名片,观察者自己按需取------subject->temp())。粒度小且稳定用推,字段多且各取所需用拉。 - C++ 的特有难题是生命周期------通知本质是一次回调,回调的对象可能已经析构。悬垂、重入、注销遗漏,三坑贯穿第 4、5 两节,也是 C++ 观察者与垃圾回收语言观察者的分水岭。
3. UML 图 + 结构说明
#mermaid-svg-SXn0C3JS0zbb2awx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SXn0C3JS0zbb2awx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SXn0C3JS0zbb2awx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SXn0C3JS0zbb2awx .error-icon{fill:#552222;}#mermaid-svg-SXn0C3JS0zbb2awx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SXn0C3JS0zbb2awx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SXn0C3JS0zbb2awx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SXn0C3JS0zbb2awx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SXn0C3JS0zbb2awx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SXn0C3JS0zbb2awx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SXn0C3JS0zbb2awx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SXn0C3JS0zbb2awx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SXn0C3JS0zbb2awx .marker.cross{stroke:#333333;}#mermaid-svg-SXn0C3JS0zbb2awx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SXn0C3JS0zbb2awx p{margin:0;}#mermaid-svg-SXn0C3JS0zbb2awx g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-SXn0C3JS0zbb2awx g.classGroup text .title{font-weight:bolder;}#mermaid-svg-SXn0C3JS0zbb2awx .cluster-label text{fill:#333;}#mermaid-svg-SXn0C3JS0zbb2awx .cluster-label span{color:#333;}#mermaid-svg-SXn0C3JS0zbb2awx .cluster-label span p{background-color:transparent;}#mermaid-svg-SXn0C3JS0zbb2awx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SXn0C3JS0zbb2awx .cluster text{fill:#333;}#mermaid-svg-SXn0C3JS0zbb2awx .cluster span{color:#333;}#mermaid-svg-SXn0C3JS0zbb2awx .nodeLabel,#mermaid-svg-SXn0C3JS0zbb2awx .edgeLabel{color:#131300;}#mermaid-svg-SXn0C3JS0zbb2awx .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-SXn0C3JS0zbb2awx .label text{fill:#131300;}#mermaid-svg-SXn0C3JS0zbb2awx .labelBkg{background:#ECECFF;}#mermaid-svg-SXn0C3JS0zbb2awx .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-SXn0C3JS0zbb2awx .classTitle{font-weight:bolder;}#mermaid-svg-SXn0C3JS0zbb2awx .node rect,#mermaid-svg-SXn0C3JS0zbb2awx .node circle,#mermaid-svg-SXn0C3JS0zbb2awx .node ellipse,#mermaid-svg-SXn0C3JS0zbb2awx .node polygon,#mermaid-svg-SXn0C3JS0zbb2awx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SXn0C3JS0zbb2awx .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx g.clickable{cursor:pointer;}#mermaid-svg-SXn0C3JS0zbb2awx g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-SXn0C3JS0zbb2awx g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-SXn0C3JS0zbb2awx .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-SXn0C3JS0zbb2awx .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-SXn0C3JS0zbb2awx .dashed-line{stroke-dasharray:3;}#mermaid-svg-SXn0C3JS0zbb2awx .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-SXn0C3JS0zbb2awx #compositionStart,#mermaid-svg-SXn0C3JS0zbb2awx .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx #compositionEnd,#mermaid-svg-SXn0C3JS0zbb2awx .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx #dependencyStart,#mermaid-svg-SXn0C3JS0zbb2awx .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx #dependencyStart,#mermaid-svg-SXn0C3JS0zbb2awx .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx #extensionStart,#mermaid-svg-SXn0C3JS0zbb2awx .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx #extensionEnd,#mermaid-svg-SXn0C3JS0zbb2awx .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx #aggregationStart,#mermaid-svg-SXn0C3JS0zbb2awx .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx #aggregationEnd,#mermaid-svg-SXn0C3JS0zbb2awx .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx #lollipopStart,#mermaid-svg-SXn0C3JS0zbb2awx .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx #lollipopEnd,#mermaid-svg-SXn0C3JS0zbb2awx .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SXn0C3JS0zbb2awx .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-SXn0C3JS0zbb2awx .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SXn0C3JS0zbb2awx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SXn0C3JS0zbb2awx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SXn0C3JS0zbb2awx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 只认接口的订阅名单
Sensor
-panels_ vector
-temp_ double
+attach(p) : void
+detach(p) : void
+setTemp(t) : void
-notify() : void
<<interface>>
Panel
+update(temp) : void
CurrentPanel
+update(temp) : void
StatsPanel
+update(temp) : void
AlertModule
+update(temp) : void
四个参与者(GoF 命名):
- 主题(Subject) :
Sensor------持有一份「只认接口」的观察者名单,提供attach/detach增减订阅,状态变化时notify; - 观察者(Observer)接口 :
Panel,声明更新通道update------推模型带数据,拉模型带主题指针; - 具体观察者:三块面板,各自对同一事件做出互不相同、互相不知道的反应;
- 具体主题 :状态真正变化的地方,何时 通知由它定,通知之后发生什么它一无所知。
结构定性:观察者是无意志的广播台 ------喊一嗓子「温度变了」,听众是谁、听完做什么,台里一概不管。这个定性是与中介者(第 22 篇)辨析的锚点:中介者听到报告要决策并指挥 多个对象,观察者只负责送达 。也因此观察者的耦合是所有「中间人模式」里最松的:主题与观察者之间只有一条 update 通道,谁也不指挥谁。
4. 传统 C++ 写法(C++11 之前)
GoF 原书时代的形态:观察者名单存裸指针、notify 遍历副本抗重入、推模型递数据:
cpp
// C++98/03 写法
#include <cstdio>
#include <vector>
// ---- 观察者接口 ----
class Panel {
public:
virtual ~Panel() {}
virtual void update(double temp) = 0;
protected:
Panel() {}
private:
Panel(const Panel&);
Panel& operator=(const Panel&);
};
// ---- 主题 ----
class Sensor {
public:
Sensor() : temp_(0) {}
void attach(Panel* p) {
panels_.push_back(p); // 只借不拥
}
void detach(Panel* p) {
for (size_t i = 0; i < panels_.size();
++i) {
if (panels_[i] == p) {
panels_.erase(
panels_.begin() + i);
return;
}
}
}
void setTemp(double t) {
temp_ = t;
notify(); // 状态变化即广播
}
private:
void notify() {
// 遍历副本:允许观察者在回调里
// 增删订阅而不失效迭代
std::vector<Panel*> snap = panels_;
for (size_t i = 0; i < snap.size();
++i) {
snap[i]->update(temp_); // 推模型
}
}
std::vector<Panel*> panels_;
double temp_;
};
// ---- 三个观察者:对同一事件各做各事 ----
class CurrentPanel : public Panel {
public:
void update(double t) {
printf("当前温度:%.1f\n", t);
}
};
class StatsPanel : public Panel {
public:
StatsPanel() : sum_(0), n_(0) {}
void update(double t) {
sum_ += t;
++n_;
printf("平均温度:%.1f\n",
sum_ / n_);
}
private:
double sum_;
int n_;
};
class AlertModule : public Panel {
public:
void update(double t) {
if (t > 100.0)
printf("告警:温度过高!\n");
}
};
int main() {
Sensor sensor;
CurrentPanel cur;
StatsPanel stats;
AlertModule alert;
sensor.attach(&cur);
sensor.attach(&stats);
sensor.attach(&alert);
sensor.setTemp(25.0); // 三处各刷各的
sensor.setTemp(120.0); // 含一条告警
return 0;
}
对照第 1 节:加「数据落盘」订阅者 = 写一个 Panel 派生类加一行 attach,传感器零改动;换数据源 = 面板 attach 到新传感器,面板零改动。
三条传统写法的铁律,全是拿事故换来的:notify 遍历副本 ------某个观察者在回调里 detach 自己甚至 attach 新面板,正在遍历的容器立即被「边走边抽地板」弄崩溃,副本是 C++98 时代最朴素的护栏(更严格的方案是延迟应用增删,见第 5 节);名单只借不拥 ------panels_ 存裸指针,传感器不负责面板生死;析构必须退订 ------面板销毁前忘了 detach,传感器的下一次 notify 就是对亡魂的调用,这是 C++ 观察者最著名的悬垂坑,第 5 节的两件现代工具专为封它而来。
5. 现代 C++ 进阶写法
升级零:std::function 槽位,订阅匿名化。观察者连接口都不用实现了------一个可调用对象就是一个订阅:
cpp
// 节选:槽位化的发布总线
#include <algorithm>
#include <functional>
#include <utility>
#include <vector>
class TempBus {
public:
using Slot = std::function<void(double)>;
using Id = int;
Id subscribe(Slot s) {
slots_.emplace_back(next_,
std::move(s));
return next_++;
}
void unsubscribe(Id id) {
slots_.erase(
std::remove_if(
slots_.begin(), slots_.end(),
[id](const auto& p) {
return p.first == id;
}),
slots_.end());
}
void publish(double v) {
auto snap = slots_; // 副本抗重入
for (auto& [id, s] : snap)
if (s)
s(v); // 逐槽回调
}
private:
std::vector<std::pair<Id, Slot>> slots_;
int next_ = 0;
};
订阅者从「实现接口的类」降格为「一段可调用代码」------subscribe 返回的 Id 是唯一的退订凭证,弄丢它就再也退不了(下一件工具解决)。
改进一:RAII 订阅令牌------注销不再靠自觉。把「退订凭证」做成析构即退订的移动专用对象,第 4 节「析构必须退订」的铁律从注释升格为类型保证:
cpp
// 节选:与 TempBus 配套的订阅令牌
class Subscription {
public:
Subscription(TempBus& bus, TempBus::Id id)
: bus_(&bus), id_(id) {}
~Subscription() {
if (bus_)
bus_->unsubscribe(id_); // 忘退订?
// 不存在
}
Subscription(Subscription&& o) noexcept
: bus_(o.bus_), id_(o.id_) {
o.bus_ = nullptr; // 移交后失能
}
Subscription& operator=(
Subscription&&) = delete;
Subscription(const Subscription&) = delete;
private:
TempBus* bus_;
TempBus::Id id_;
};
// 用法:面板把令牌存成成员
// class StatsPanel {
// Subscription sub_;
// ...构造时 sub_{bus, bus.subscribe(...)}
// 面板析构 → 令牌析构 → 自动退订
这是 GoF 没能写进模式图里的 C++ 补丁:订阅关系本身也该有生命周期。
改进二:weak_ptr 观察------通知前确认活着 。观察者由 shared_ptr 管理时,总线改存 weak_ptr,第 16 篇 onTimer 的门卫模式整体迁入:
cpp
// 节选:weak 观察名单
#include <memory>
#include <vector>
class Sensor2 {
public:
void attach(std::shared_ptr<Panel> p) {
panels_.push_back(std::move(p));
}
void publish(double v) {
for (auto& wp : panels_) {
if (auto p = wp.lock()) // 活着才通知
p->update(v);
}
// 顺带清理过期的空壳
panels_.erase(
std::remove_if(
panels_.begin(),
panels_.end(),
[](const auto& wp) {
return wp.expired();
}),
panels_.end());
}
private:
std::vector<std::weak_ptr<Panel>> panels_;
};
三件套(槽位、令牌、weak)不必全上:小项目槽位 + 手工退订即可;跨模块长生命周期总线,令牌或 weak 至少其一------悬垂回调的排查成本,永远高于多写一个包装类的成本。
展望 :跨进程的观察者就是发布订阅消息队列 ------经 broker 中转、按主题过滤、收发双方互不知晓,意图与本篇同源、基础设施完全不同,别把进程内的 vector<Panel*> 硬改成进程间的;C++ 没有反射,图形界面框架的「信号槽」要靠元对象代码生成(Qt 的 moc)或宏------那是观察者加上一层字符串级联表,本质未变。
6. 优缺点与适用场景
- ✅ 优点(GoF 后果清单):运行时建立/解除的抽象耦合 ------主题只认一个接口,观察者随时增减,双方互不拖累;广播通信 ------一次状态变化自动达达所有关心者,无需轮询;支持事件级联------观察者的响应可以是修改另一个主题,事件沿依赖链传播,形成反应式结构。
- ❌ 缺点:通知风暴与级联失控 ------正是上一条优点的暗面:A 通知 B、B 又通知 C,几跳之后没人知道这次更新从哪来、会到哪去,观察者的「面条」比调用的面条更难调试(断点跳进一串匿名槽位);通知顺序无保证 ------依赖特定顺序的观察者逻辑,等于在图上埋了看不见的边;生命周期三坑(悬垂、重入、注销遗漏)在 C++ 里全靠自己------三件套之外别无银弹;每次通知一层虚调用/间接调用,热路径高频事件要掂量开销。
- 🎯 适用场景:一对多且订阅者动态增减的进程内事件------GUI 事件、属性绑定、缓存失效、配置热更新、遥测埋点;主题不关心(也不该关心)订阅者的行为;变化频率低于「每变化都全量轮询」的成本线。
〔辨析〕观察者 vs 中介者(第 22 篇,正面回收):同是星型,无意志的广播台 vs 有意志的协调者 ------观察者送达即结束,中介者听完还要决策指挥;「事件后要不要指挥多个对象」是判别问题;两者常合体(中介者订阅同事的事件)。观察者 vs 责任链(第 18 篇):一对多广播不终止 vs 一对一传递认领即停;广播说「我变了」,传递问「归你管吗」。观察者 vs 发布订阅:同源异构------观察者是进程内、点对点名单、收发双方经接口相识;发布订阅是跨进程/跨模块、经 broker、按主题过滤、收发互不知晓;工程上常把前者当后者的进程内退化形态。观察者 vs 迭代器(第 21 篇):迭代器「拉」式逐个取,观察者「推」式批量送------数据流向相反的两类协议。
7. 开源项目中的身影
Boost.Signals2:C++ 信号槽的工业完全体 。signal 是主题、connect 是订阅,而它真正值钱的是连接管理:scoped_connection 就是第 5 节订阅令牌的成品,track 系列把 weak 观察做成了声明式配置:
cpp
// 说明性片段(需包含 boost/signals2)
#include <boost/signals2/signal.hpp>
namespace sig2 = boost::signals2;
sig2::signal<void(double)> tempChanged;
// RAII 连接:作用域结束自动断开
sig2::scoped_connection c =
tempChanged.connect(
[](double t) { /* 刷新面板 */ });
tempChanged(25.0); // signal 本身可调用
点评:Signals2 把本文第 5 节三件套全部内建,还处理了重入(block/递归连接控制)与线程安全(mutex 互斥集)。代价是每个 signal 的体积与连接开销------游戏引擎里它常被手写轻量总线替换,取舍点还是那三坑的成本。
标准库:std::stop_callback,一次性观察者的现身。C++ 标准库一直没有通用信号槽,却在 jthread 的停止协议里内置了一个微型观察者------「停止请求」是事件,回调在事件发生时被触发恰好一次:
cpp
// C++20 片段
#include <thread>
std::stop_source src;
std::stop_callback cb(
src.get_token(),
[] { /* 收到停止请求,收摊 */ });
// 某处调用 src.request_stop()
// → 回调触发一次,cb 析构自动注销
点评:麻雀虽小,主角齐全:订阅令牌(cb 析构即注销)、单次语义、线程安全送达。标准库的选择很说明问题------通用信号槽留给库生态,协议级的通知才进标准。
AOSP:传感器事件队列,气象站的工业版 。Android 的传感器框架就是本篇主例的放大:应用注册监听器,传感器数据经 InputDispatcher 同族的事件机制回调 onSensorChanged;native 侧 NDK 的 ASensorEventQueue 把「订阅 + 事件回调 + Looper 唤醒」打包成一个队列:
cpp
// 说明性片段(节选自 NDK sensor API,简化)
ASensorEventQueue* q =
ASensorManager_createEventQueue(
mgr, looper, LOOPER_ID_USER,
nullptr /*回调*/, nullptr);
ASensorEventQueue_enableSensor(
q, accelerometer); // attach
// Looper 被数据唤醒后逐条分发事件,
// 等价于 notify() 的循环
点评:注意它与第 19 篇的会师------事件队列里流动的既可以说是命令(MessageHandler),也可以说是通知(唤醒回调);发布订阅与命令物化在系统底层本就是同一套基础设施的两面。
三份代码合看:Signals2 把三坑全封、stop_callback 小而严谨、AOSP 把观察者做进系统唤醒机制------「一对多、送达到、别管他」这九个字,撑起了从桌面 GUI 到传感网络的整片事件世界。
本篇小结
状态的所有者不必认识状态的消费者:一份只认接口的订阅名单、一条 update 通道,通知自动送达所有关心者,名单运行时随意增删------这是依赖倒置最日常的运行时形态。C++ 把这个「知名度最高」的模式同时变成了「陷阱最多」的模式:重入要副本护栏、注销要 RAII 令牌、悬垂要 weak 门卫,三件套按规模选配;跨进程时换成消息队列,别硬撑进程内结构。与中介者的分水岭再念一遍:无意志的广播台只管送达,有意志的协调者还要指挥。下一篇状态模式,镜头转回被通知的那一侧:当对象自己的行为随状态大变、满屏 switch 开始疯长时,把每个状态变成一个对象。
本文模式定义与角色划分参考了 Refactoring Guru《设计模式》中文版「观察者」一章,意图译文、推拉模型与后果清单参考了 GoF《Design Patterns》第 5 章 Observer 一节。