观察者这东西。
比“观察者模式”这个词老得多。
我第一次真正意识到它。
不是在 GoF 的书里。
是在写 GUI。
那会儿还流行“窗口就是世界”。
世界一变。
窗口就得跟着变。
不然用户就会骂你。
而你又没法在每个角落都手工同步。
你会漏。
你一定会漏。
所以很早以前。
Smalltalk 那帮人搞 MVC。
Model 变了。
View 自己刷新。
Controller 负责把手放在哪。
这事听着像常识。
但当时是救命。
因为他们发现了一条规律。
系统里最难收拾的。
不是“怎么计算”。
是“谁该知道”。
你以为你在写业务。 其实你在维护一堆隐形的依赖关系。
Observer 就是把这堆“隐形的知道关系”。
明晃晃地摆到代码里。
让它可读。
也让它可拆。
先别谈模式。
先谈一个很俗的场景。
你做个交易系统。
一条行情更新进来。
很多东西要跟着动。
风控要算。
UI 要刷。
策略要触发。
日志要打。
一开始你可能这么写。
struct Tick {
std::string symbol;
double price;
};
void onTick(const Tick& t) {
riskUpdate(t);
uiUpdate(t);
strategyUpdate(t);
logTick(t);
}
挺直白。
也挺快。
直到某一天。
UI 改了。
风控拆服务了。
策略从同步改异步。
你再回头看这坨 onTick。
它像一张通讯录。
谁想加进来。
都得来改它。
改一次。
你就要重新理解一次“现在有哪些人要知道”。
而且最尴尬的是。
它并不属于任何一个功能模块。
它只是个“通知中心”。
这时候。
Observer 就有意思了。
它说。
你别在一个地方硬连所有人。
你把“变化”抽成一个主题。
把“想知道的人”变成订阅者。
最朴素的 Observer
先上最土的写法。
土得像教科书。
但它很诚实。
struct TickObserver {
virtual ~TickObserver() = default;
virtual void onTick(const Tick& t) = 0;
};
观察者就是一个接口。
你想知道行情。
你就实现它。
主题这边。
只负责保存一份“通讯录”。
class MarketData {
public:
void add(TickObserver* o) { observers_.push_back(o); }
void publish(const Tick& t) {
for (auto* o : observers_) {
o->onTick(t);
}
}
private:
std::vector<TickObserver*> observers_;
};
用起来也简单。
class RiskEngine : public TickObserver {
public:
void onTick(const Tick& t) override {
last_ = t.price;
}
private:
double last_ = 0;
};
MarketData md;
RiskEngine risk;
md.add(&risk);
md.publish(Tick{"AAPL", 189.5});
你看。
行情这边不用知道风控。
风控也不用跑去改行情代码。
依赖方向开始变得干净。
但是。
这版 Observer。
也很容易把你送进坑里。
“我明明析构了,它为什么还回调我”
指针这事。
在 demo 里很优雅。
在工程里很真实。
真实到会崩。
比如 UI 里一个窗口关掉了。
对应的观察者对象析构了。
但是它没来得及 remove。
下一次 publish。
你就对着一块已经被释放的内存说话。
这不是“可能”。
这是“迟早”。
老项目里。
你经常能看到一种很痛苦的纪律。
“销毁之前必须退订”。
写在 wiki 上。
贴在工位上。
然后还是有人会忘。
于是你开始明白。
这事不该靠纪律。
这事应该靠结构。
让订阅像资源一样被管理
C++ 最擅长的。
其实不是 virtual。
是 RAII。
所以现代 C++ 写 Observer。
我更喜欢把“订阅关系”本身做成一个对象。
它活着。
就代表订阅还在。
它死了。
就代表自动退订。
像一个“连接句柄”。
先定义一个轻量的 token。
class MarketData;
class Subscription {
public:
Subscription() = default;
Subscription(std::weak_ptr<MarketData> owner, std::size_t id)
: owner_(std::move(owner)), id_(id) {}
Subscription(const Subscription&) = delete;
Subscription& operator=(const Subscription&) = delete;
Subscription(Subscription&&) noexcept = default;
Subscription& operator=(Subscription&&) noexcept = default;
~Subscription();
private:
std::weak_ptr<MarketData> owner_;
std::size_t id_ = 0;
};
你会注意到。
我没让它直接持有 MarketData*。
因为主题也会死。
你不想出现“主题先死了,句柄析构时再去退订”的二次悬空。
所以这里用 weak_ptr。
主题存在。
我们就退订。
主题不在。
那就当退订早就完成了。
主题这边改成回调式。
更贴近今天的 C++。
class MarketData : public std::enable_shared_from_this<MarketData> {
public:
using Callback = std::function<void(const Tick&)>;
Subscription subscribe(Callback cb) {
const auto id = nextId_++;
callbacks_.emplace(id, std::move(cb));
return Subscription{shared_from_this(), id};
}
void unsubscribe(std::size_t id) {
callbacks_.erase(id);
}
void publish(const Tick& t) {
auto snapshot = callbacks_;
for (auto& [_, cb] : snapshot) {
cb(t);
}
}
private:
std::size_t nextId_ = 1;
std::unordered_map<std::size_t, Callback> callbacks_;
};
inline Subscription::~Subscription() {
if (auto md = owner_.lock()) {
md->unsubscribe(id_);
}
}
这段代码。
有个点很“工程”。
publish 里先复制一份 callbacks_。
不是为了优雅。
是为了活下去。
因为回调里可能会退订。
甚至可能会再订阅。
你一边遍历一边改容器。
那就是未定义行为。
而未定义行为这东西。
很讲缘分。
它通常挑你上线那天出现。
用起来像“把耳朵递过去”
订阅者不需要继承。
也不需要知道主题内部。
它只要给一个函数。
auto md = std::make_shared<MarketData>();
auto sub = md->subscribe([](const Tick& t) {
// UI / 风控 / 策略。
// 谁想听就谁来订。
std::cout << t.symbol << " " << t.price << "\n";
});
md->publish(Tick{"AAPL", 189.5});
当 sub 离开作用域。
退订自动发生。
你不用写“销毁前必须退订”的纪律。
纪律会忘。
析构不会。
弱引用这事。
其实是在还债。
Observer 最常见的 bug。
不是通知没发。
是通知发到了不该去的地方。
对象已经不在了。
回调还在。
所以你在 C++ 里。
经常会看到两种风格。
一种是上面这种。
用订阅句柄管理生命周期。
另一种是保存 weak_ptr 到观察者。
通知时锁一下。
锁不到就跳过。
像这样。
struct TickObserver2 {
virtual ~TickObserver2() = default;
virtual void onTick(const Tick& t) = 0;
};
class MarketData2 {
public:
void add(std::weak_ptr<TickObserver2> o) {
observers_.push_back(std::move(o));
}
void publish(const Tick& t) {
std::vector<std::weak_ptr<TickObserver2>> alive;
alive.reserve(observers_.size());
for (auto& w : observers_) {
if (auto sp = w.lock()) {
sp->onTick(t);
alive.push_back(std::move(w));
}
}
observers_ = std::move(alive);
}
private:
std::vector<std::weak_ptr<TickObserver2>> observers_;
};
这写法不漂亮。
但很实用。
它直接把“观察者可能会死”写进了数据结构。
我喜欢这种诚实。
线程。
以及更难的重入。
Observer 还有一个经典事故。
你以为你只是通知。
结果你在通知里。
又触发了新的变化。
变化又通知。
通知又变化。
最后你在一个下午写出了递归。
还没栈保护。
然后线上爆了。
所以我一般会记两句话。
一是。
不要在持锁状态下调用回调。
你会死锁。
也会把延迟放大到离谱。
二是。
通知里别做重活。
把事丢到队列。
让事件循环慢慢消化。
GUI 框架这么干。
游戏引擎也这么干。
因为他们都被重入教育过。
Observer 和发布-订阅。
像。
但不完全一样。
Observer 多半是进程内。
主题知道自己在叫谁。
哪怕它只知道一个回调。
发布-订阅更像“广播”。
中间通常有个 broker。
消息可能会丢。
也可能会重放。
它们解决的问题相似。
但系统边界不一样。
你别拿着一个 Observer。
硬去替代消息队列。
也别为了“解耦”。
在进程内搞一个小型 MQ。
那种代码。
我见过。
结局通常都不太体面。
小结
Observer 解决的。
不是“怎么通知”。
是“谁该知道”。
它把依赖关系从暗处拉到明处。
让变化的传播路径可见。
可控制。
而在 C++ 里。
你最该多花心思的。
也不是接口设计。
是生命周期。
是退订。
是线程。
这些东西。
GoF 那页 UML 画不出来。
但它们决定你是写了一个模式。
还是埋了一个定时炸弹。