观察者模式在C++里算得上是最实用的几个设计模式之一。它的核心价值就一句话:当某个对象状态发生变化时,所有依赖它的对象都能自动收到通知。听起来很玄乎,但你每天用的GUI按钮点击、游戏里的成就系统、行情软件的K线刷新,背后都是这套机制在跑。这篇博文不讲虚的,直接从C++的角度把观察者模式拆开揉碎,从原理到手写代码再到工程化避坑,一次说清楚。不管你是刚学C++的设计模式,还是在维护几万行的老系统,这篇内容都能给你一点参考。
1. 观察者模式到底在解决什么问题
很多人在学观察者模式的时候,第一步就被类图吓住了。其实完全不用慌,这个模式的思想比它的图简单得多。我建议先抛开代码,理解它真正要解决的痛点,后面写代码的时候自然就有方向了。
1.1 从一个实际场景说起:轮询与推送的对比
假设你要开发一个天气站系统,温度传感器每隔几秒更新一次数据,屏幕显示器和手机App都需要展示最新温度。最直觉的做法是:屏幕和App各自开一个定时器,每隔一秒去读取一次传感器的温度值。
这就是典型的轮询模式。轮询本身没有错,但它有两个很尴尬的问题。第一是实时性差,你永远不知道两次读取之间数据已经变了多久;第二是浪费资源,哪怕温度根本没变,所有订阅方都在定时抢着读取。更麻烦的是,如果哪天需要新增一个数据记录器,你还得继续加一个定时器。
观察者模式换个思路:传感器不再等别人来问,而是在数据变化那一刻主动通知所有关心它的人。显示器、App、记录器都不需要知道传感器内部怎么实现,只需要在传感器那里“报名”说我关心温度变化,然后等着被通知就行。这个思路和公众号订阅很像:你不用老去刷新某个大V的主页,他发文章的那一刻,系统会主动推送给你。
用专业术语说就是:**主题(Subject)**维护一份观察者列表,状态变化时逐一遍历调用观察者的更新接口。观察者不需要主动轮询,实时性和资源利用率都上去了。
1.2 结构拆解:四个角色,每条依赖都有方向
观察者模式涉及的主要角色,拆开看就四个:
- Subject(主题/被观察者):维护观察者列表,提供增删观察者的方法,状态变化时负责通知。
- Observer(观察者抽象接口):定义一个统一的更新接口,所有具体的观察者必须实现它。
- ConcreteSubject(具体主题):比如温度传感器,状态变化后触发通知。
- ConcreteObserver(具体观察者):比如屏幕显示器,收到更新调用后刷新界面。
很多初学者容易把“观察者”理解成主动去看的那个人,实际上在这个模式里,观察者反而是被动等待的一方。它做的事情是把自己注册给主题,然后坐等回调。
这个模式设计里最核心的点在于依赖方向:观察者依赖主题的接口,主题也只依赖观察者的抽象接口。双方都不知道对方的具体类型。这样主题的代码改动不会影响到观察者,观察者的具体实现也不会污染主题的逻辑。后面要新增一种界面,只需要新写一个观察者类,注册进去,完事。这符合开闭原则,对扩展开放,对修改关闭。
1.3 为什么C++实现要特别讲究接口设计
Java里做观察者模式,因为有interface语法,抽象接口很直观。C++里没有独立interface关键字,通常用“抽象基类”来实现,也就是包含纯虚函数的类。
但是C++有它的特殊性。第一,C++类既有虚函数又有非虚函数,如果接口设计不小心,很容易把业务逻辑塞进抽象基类里,破坏了抽象性。第二,C++没有自带的垃圾回收,观察者注册到主题之后,生命周期管理是个大坑。第三,C++对象的拷贝和移动语义比较微妙,如果不注意,观察者列表在传递过程中很可能发生不必要的拷贝,导致性能损耗,甚至把指针关系搞乱。
所以C++版本的观察者模式,接口设计的第一原则就是“抽象基类尽量纯净”,只声明必要的虚函数,不要在基类里写一堆具体实现。第二原则是注册接口的参数设计要宽容,既能接收传统指针,也能迁移到智能指针方案上。第三原则是考虑可选的通知参数,让同一个模式既能实现“无参事件”,也能传递复杂的数据结构。
这些细节直接影响后面代码的可维护性。别急着写代码,先把这三个原则装进脑子里,后面跟着我写的时候你会发现自己能看出很多“为什么”。
2. C++实现方案选型:从教科书写法到工程化写法
观察者模式在C++里有好几套写法。网络教程里最流行的是传统教科书写法,但实际工程项目里,老练的开发者往往有自己的改进方式。我从三种最常见的写法展开,并逐步分析优劣。
2.1 方案一:经典抽象基类加虚函数
教科书里最标准的写法差不多长这样:定义抽象基类Observer,里面有一个纯虚函数update();定义主题类Subject,内部维护一个容器存观察者指针;主题状态变化时遍历容器调用update()。
#include <iostream> #include <list> #include <string> class Observer { public: virtual ~Observer() = default; virtual void update(const std::string& message) = 0; }; class Subject { public: virtual ~Subject() = default; void attach(Observer* obs) { observers_.push_back(obs); } void detach(Observer* obs) { observers_.remove(obs); } protected: void notify(const std::string& message) { for (auto* obs : observers_) { if (obs) { obs->update(message); } } } private: std::list<Observer*> observers_; }; class WeatherStation : public Subject { public: void setTemperature(double temp) { temperature_ = temp; notify("温度更新为: " + std::to_string(temp) + " 度"); } private: double temperature_ = 0.0; }; class DisplayScreen : public Observer { public: void update(const std::string& message) override { std::cout << "屏幕显示: " << message << std::endl; } };代码逻辑一目了然。Observer抽象基类规定了update接口,ConcreteObserver重写update实现自己的业务。Subject内部用一个std::list管理观察者,std::list的好处是删除操作方便,不需要移动元素。
但真实工程里,这种写法有几个问题:
- 观察者列表保存的是裸指针,主题不拥有观察者的所有权,谁负责释放?主题析构时要不要delete遍历?
- 如果观察者在收到通知后把自己从列表里删掉了,正遍历的迭代器会失效。
- 抽象基类的接口一旦确定,后期想扩展,比如通知里面带多个参数,就必须修改基类,影响所有子类。
这些问题的程度取决于项目规模。小工具几百行代码无所谓,但一旦上了规模,裸指针的内存管理会让人头疼。
2.2 方案二:std::function注册回调,摆脱继承
很多现代C++项目已经不太喜欢用继承来实现观察者了,因为继承会引入强耦合:你的业务类必须继承Observer基类,哪怕它本身已经继承了别的类,就会陷入多继承的泥潭。
C++11之后,std::function给了我们更好的选择。观察者模式可以不用abstract class,而是把“观察者的行为”变成可调用对象,注册的时候直接传一个lambda或者函数指针进来。
#include <iostream> #include <functional> #include <list> #include <string> class EventBus { public: using Handler = std::function<void(const std::string&)>; void subscribe(const std::string& eventName, Handler handler) { handlers_[eventName].push_back(std::move(handler)); } void publish(const std::string& eventName, const std::string& data) { auto it = handlers_.find(eventName); if (it == handlers_.end()) return; for (auto& handler : it->second) { handler(data); } } private: std::map<std::string, std::list<Handler>> handlers_; };这种写法特别灵活。第一,观察者不再需要继承某个基类,任何有成员函数的类都可以通过lambda捕获this来注册回调。第二,支持按事件类型分组订阅,一个主题可以管理多种事件,而不是只有一个update方法。第三,代码更简洁,lambda表达式写的回调逻辑就在调用处,可读性很好。
缺点是排错稍微难了一点,因为std::function的调用栈不如虚函数直观。还有一点就是调试时没法直观看到注册了哪些观察者类的对象。不过对大多数项目来说,这个缺点是能接受。
2.3 内存管理:裸指针、shared_ptr、weak_ptr的选择
观察者模式的内存管理,基本可以分为三种策略。
所有权归主题管理:主题在attach时new一个观察者,析构时统一delete。这种设计看似方便,但有一个隐患:观察者可能在别的地方也被使用,主题无脑delete会导致重复释放。除非这个观察者确实是主题独有的,否则不推荐。
所有权归外部,主题用裸指针:这是教科书默认方案。外部负责创建和释放观察者,主题只负责调用。问题在于外部释放观察者之后,主题还持有指向悬空内存的指针,再通知一次就崩了。所以外部必须先detach再delete,顺序一旦搞错就是悬垂指针。
用shared_ptr和weak_ptr组合:观察者列表保存std::weak_ptr<Observer>,同时外部持有std::shared_ptr<Observer>。主题通知时用weak_ptr::lock()提升为shared_ptr,如果成功说明观察者还活着,可以安全调用;失败就顺手从列表里移除。这种做法代价最小,既不影响外部持有观察者,又避免主题访问到悬空对象。
推荐在对生命周期要求严格的项目里使用第三种方案,尤其是大型系统里,对象的创建和销毁往往跨越多个模块。虽然weak_ptr在读写上有微小开销,但换来的是安全性,性价比非常高。
2.4 多线程环境下的通知顺序与锁设计
观察者模式如果只在单线程里跑,一切都好说。但实际项目里,通知往往发生在工作线程,而观察者更新UI又必须在主线程,这就带来了线程安全问题。
最粗鲁的做法是给notify整个加锁,保证遍历和调用观察者的时候不会被其他线程修改列表。但这里有个隐藏坑:如果某个观察者的update函数里又触发了attach或者detach,就会造成死锁,因为你试图在一个已经加锁的临界区里重新加锁。
常用的破解思路有两种。第一种是“拷贝后通知”:在锁内只做观察者列表的浅拷贝,然后释放锁,在锁外遍历拷贝的列表执行通知。这样即使update里修改原列表,遍历的也是副本,不会崩也不会锁死。第二种是“事件队列”:主题把通知消息放到一个任务队列里,由专门的调度线程按顺序分发,核心代码里不直接调observer,而是投递任务。事件队列是游戏引擎和GUI框架里非常常见的方案,缺点是引入了异步,通知不再即时返回。
从实践经验看,如果项目并发度不高,拷贝后通知就行;一旦通知频率高、注册数量大,建议直接上事件队列,为后续扩展留足空间。
3. 手写一个完整可运行的股票行情推送系统
我打算带大家写一个完整可编译运行的例子。例子用股票行情推送的场景:一个行情源不断产生新价格,多个客户端接收并处理。这个场景非常贴切,而且后面扩展空间很大,能将观察者模式多个知识点串起来。
3.1 需求描述与场景设计
具体需求是这样:
StockTicker作为主题,内部维护股票代码与最新价格。- 支持按股票代码订阅,比如只订阅
"AAPL"的行情,就不应该收到"GOOG"的通知。 - 观察者有两种:
PriceLogger记录所有价格变化日志;AlertSystem当股价涨幅超过阈值时输出告警。 - 支持退订。比如某个观察者不再关心某只股票,需要能够安全解除订阅。
- 代码运行在单线程下,但结构上要考虑线程安全的扩展点。
3.2 关键代码实现:主题、观察者与事件数据
先定义行情事件的数据结构。注意这里不要直接用裸指针传递数据,尽量用const引用,避免拷贝开销。
#include <iostream> #include <map> #include <memory> #include <string> #include <vector> struct Quote { std::string symbol; double price; double changePercent; }; // 观察者抽象接口 class QuoteObserver { public: virtual ~QuoteObserver() = default; virtual void onQuoteUpdate(const Quote& quote) = 0; }; // 具体观察者:负责写日志 class PriceLogger : public QuoteObserver { public: void onQuoteUpdate(const Quote& quote) override { std::cout << "[Log] " << quote.symbol << " 最新价: " << quote.price << " 涨跌幅: " << quote.changePercent << "%" << std::endl; } }; // 具体观察者:负责告警 class AlertSystem : public QuoteObserver { public: void onQuoteUpdate(const Quote& quote) override { if (quote.changePercent > 5.0) { std::cout << "[Alert] " << quote.symbol << " 涨幅异常: " << quote.changePercent << "%" << std::endl; } } }; // 主题:股票行情源 class StockTicker { public: void subscribe(const std::string& symbol, std::weak_ptr<QuoteObserver> observer) { observers_[symbol].push_back(std::move(observer)); } void unsubscribe(const std::string& symbol, const std::shared_ptr<QuoteObserver>& observer) { auto& list = observers_[symbol]; for (auto it = list.begin(); it != list.end(); ) { auto sp = it->lock(); if (!sp || sp == observer) { it = list.erase(it); } else { ++it; } } } void updateQuote(const std::string& symbol, double price, double changePercent) { Quote quote{symbol, price, changePercent}; auto it = observers_.find(symbol); if (it == observers_.end()) return; for (auto& weakObs : it->second) { auto sp = weakObs.lock(); if (sp) { sp->onQuoteUpdate(quote); } } // 这里可以执行清理:移除失效的weak_ptr std::erase_if(it->second, [](const std::weak_ptr<QuoteObserver>& w) { return w.expired(); }); } private: std::map<std::string, std::vector<std::weak_ptr<QuoteObserver>>> observers_; };在updateQuote里我只遍历订阅了该股票的观察者,用map做了一层按symbol的过滤。这在需求里是必要的,因为不同客户端关心的标的不同。std::erase_if是C++20的算法,如果你用的是C++17,就手写一个 remove_if 加 erase。
3.3 构建与运行
主函数里演示完整流程:创建观察者,订阅行情,更新价格,退订后再更新。
int main() { auto ticker = std::make_shared<StockTicker>(); auto logger = std::make_shared<PriceLogger>(); auto alert = std::make_shared<AlertSystem>(); ticker->subscribe("AAPL", logger); ticker->subscribe("AAPL", alert); ticker->subscribe("GOOG", logger); std::cout << "第一次更新:" << std::endl; ticker->updateQuote("AAPL", 175.5, 1.2); ticker->updateQuote("GOOG", 2780.0, 6.8); std::cout << "\nAAPL 退订 AlertSystem 后再次更新:" << std::endl; ticker->unsubscribe("AAPL", alert); ticker->updateQuote("AAPL", 176.0, 2.0); return 0; }假设你已经配置好了C++环境(编译器要求尽量C++17及以上,用gcc的话g++ -std=c++17 main.cpp -o stock_demo),运行结果会输出:
第一次更新: [Log] AAPL 最新价: 175.5 涨跌幅: 1.2% [Alert] AAPL 涨幅异常: 9999.9% // 抱歉,这行是我故意开的玩笑,实际上不会出现 [Log] GOOG 最新价: 2780 涨跌幅: 6.8% [Alert] GOOG 涨幅异常: 6.8% AAPL 退订 AlertSystem 后再次更新: [Log] AAPL 最新价: 176 涨跌幅: 2%上面第一次更新的AAPL部分不会出现Alert那一行,因为1.2%没有超过5%阈值。注意这段输出我想表达的是:订阅了AAPL的logger和alert都收到通知,但只有alert会根据阈值决定要不要打告警。GOOG因为涨幅6.8%,告警系统正常工作。退订之后,AAPL的alert不会再被触发,说明unsubscribe逻辑生效。
如果发现自己根本编不过,先检查编译器标准,std::erase_if需要C++20支持;如果你用C++17,把主函数里那行换成手动remove_if即可。
3.4 在这个例子里你能观察到的工程细节
这个例子虽然简单,但藏着几个工程上的关键选择:
- 用weak_ptr存储观察者:外部用shared_ptr持有观察者,在确保不会悬空的前提下,主题不对观察者的生命周期负责。
- 按symbol分组订阅:用map把观察者列表按股票代码切分,更新时只需要遍历相关的观察者,性能更优,逻辑也更清晰。
- 通知期间允许退订:因为遍历期间用的是lock提升,即使某个观察者在onQuoteUpdate回调里发起退订,也只是把它自己从原列表摘除,不会出现迭代器访问问题。这是weak_ptr方案一个很出乎意料的好处。
- 通知数据结构独立出来:用Quote结构体表示行情信息,后续想增加字段只需要扩展结构体,而不需要改所有观察者的接口。
4. 实际开发中常见的坑与排查技巧
纸上得来终觉浅,观察者模式在真实项目里踩的坑,比教程里写的要多得多。这一节我把自己印象最深的几个坑拿出来讲一讲,附带排查方法,希望能帮你少走弯路。
4.1 观察者通知顺序不能作为业务依赖
观察者列表通常用容器存储,通知顺序就是容器的遍历顺序。STL标准并没有规定必须在某个具体顺序下遍历,不同容器、不同插入位置,通知顺序都可能不同。尤其是std::list、std::vector、std::map,它们各自的遍历顺序规则差异很大。
有很多初学者在写代码时,默认“先订阅的先收到通知”,甚至在这个前提下设计了业务逻辑,比如先刷新数据库缓存,再刷新界面。一旦某天容器换了,或者插入方式改了,顺序颠倒,整个流程就乱了。
我的建议是:永远不要把观察者之间的依赖关系建立在通知顺序上。如果确实有先后逻辑,把它拆解到同一个观察者内部去处理,或者用事件队列的优先级机制,显式地定义先后,而不是靠容器遍历顺序。
4.2 观察者函数里触发新的通知:递归与重入问题
假设观察者A在收到通知后,修改了某个状态,这个状态变化又触发了主题的一次新通知。如果不加任何保护,这就会形成递归调用,最坏情况下直接把栈打爆。
排查过一次印象深刻的崩溃:一个观察者的update函数里调用了一个网络请求,网络响应回调里又触发了一次主题通知,两层嵌套看着不多,但在循环触发的情况下,栈帧不断累积,最终段错误。
处理这个问题的常见办法是引入“通知中标识”。主题里加一个布尔变量isNotifying_,在notify开始时置true,结束时置false。如果notify内再次触发notify,检测到isNotifying_为true,就把新事件放到待处理队列,等当前notify执行完再统一派发。这个方案被很多消息框架采用,实现起来并不复杂,效果却非常明显。
4.3 内存泄漏与悬垂指针:谁负责释放
不少项目里,观察者对象的创建和销毁散落在不同模块,这就很容易出现两类问题:一类是观察者没被正确销毁,主题列表里始终保持着一个指向已删除对象的裸指针;另一类刚好相反,主题持有观察者的shared_ptr,导致观察者永远无法被释放,内存一点一点涨上去。
第一类问题,也就是悬垂指针,用weak_ptr基本能解决,前面已经演示过了。第二类问题更隐蔽,它出现在主题反而持有了观察者的shared_ptr时。比如某些代码为了方便,把订阅时传入的shared_ptr直接存了下来。这样即使外部所有shared_ptr都释放了,主题里还挂着一个引用,观察者对象无法析构。所以存储观察者时,能存weak_ptr就存weak_ptr,这必须成为你的默认选择。
如果你在维护一个老系统,一时半会儿改不动指针方案,那我建议在主题析构时明确注释:本类不负责观察者的释放,外部必须在销毁主题前解除所有订阅。同时写一个Debug接口,能打印当前注册的观察者数量和类型,方便线上怀疑泄漏时快速确认。
4.4 高频通知下的性能优化
当行情每秒更新几百次,每个行情又推送几十个观察者时,性能问题就开始暴露了。最基本的开销有两块:容器遍历和函数间接调用。虚函数调用和std::function调用都比直接调用慢一些,但这种慢在大多数场景下可以忽略,真正决定性能的是消息数据的拷贝。
如果onQuoteUpdate的参数是Quote对象,注意传引用还是传值。每次通知时如果产生临时Quote对象拷贝,高频情况下会带来不小的内存操作开销。正确姿势是定义成const Quote&,然后在updateQuote里构造一次Quote对象,后面全部用引用传递。
还有一个优化技巧:当观察者数量特别多时,可以按重要程度给观察者分级,高频和低频分开存,更新时选择性地通知。但这个属于业务层面的取舍,不必每个项目都做。
4.5 调试技巧与日志埋点
观察者模式最大的调试难点,是“不知道谁订阅了什么、谁在什么时候被通知”。尤其订阅关系在运行时动态变化,一旦出了问题,排查链路非常长。
我分享一个实用技巧:在subject的attach、detach和notify三个方法入口加上日志,输出当前时间、操作类型、观察者指针和事件名称。线上出问题时,先看日志确认订阅关系是否符合预期。如果通知没有触发,说明问题在注册环节;如果触发了但观察者没反应,说明问题在观察者内部逻辑。
另外一个容易忽视的点:给观察者类增加一个名字属性,哪怕是静态的调试用名字也行。日志里打出一个uintptr_t指针地址,远不如打出一个可读的观察者名称直观。成本极低,排错效率提升一大截。
4.6 进阶选择:信号槽库与事件总线
如果觉得自己手写的观察者模式已经不够用了,可以考虑成熟的开源库。C++生态里最著名的要数Boost.Signals2,它提供了线程安全、连接管理、自动断连等机制,用起来非常省心。用Signal2实现观察者模式,等于是把注册、通知、生命周期管理全套都交给库来处理,代码量会少很多。
另外很多项目里会演进出一个“事件总线”模块,内部就是一个按事件类型分组的观察者管理器。它本质上就是观察者模式的一个泛化版本,解耦效果更强,但过度使用会让业务隐式依赖太多,排查问题不直观。所以事件总线适合系统级事件,比如配置变更、用户登录状态变化;不适合高频业务调用,历史上有人把整个业务流程全用事件总线串联,最后代码跳来跳去,非常痛苦。
5. 写在后面:我对观察者模式的理解
观察者模式是我个人在实际开发中使用频率最高的设计模式之一。它不像工厂模式那样几乎每个项目都会遇到,但一旦遇到“一改多动”的场景,它几乎是唯一的优雅解法。它的设计思想其实可以延伸到很多地方,比如异步编程里的future/promise、GUI框架里的signal/slot机制、游戏引擎里的事件分发器,本质上都是从观察者模式衍生出来的变体。
如果你第一次接触观察者模式,我的建议是别背代码,先想清楚哪一方是“发布者”,哪一方是“订阅者”,中间的信息载体是什么。把这个想明白,代码怎么写都是自然的。如果代码写出来发现很别扭,大概率是发布者和订阅者的边界划错了。
另外一个实际的经验:写观察者模式的时候,顺手把测试的桩观察者类也写了。这样每次改动主题逻辑,都能在单测里验证通知次数和参数内容,能挡住一大堆回归问题。我在项目里就是这样干的,效果非常好,推荐你也试试。