拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C++观察者模式实战:从原理、代码到工程化避坑指南

观察者模式在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::listObserver* 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基类哪怕它本身已经继承了别的类就会陷入多继承的泥潭。C11之后std::function给了我们更好的选择。观察者模式可以不用abstract class而是把“观察者的行为”变成可调用对象注册的时候直接传一个lambda或者函数指针进来。#include iostream #include functional #include list #include string class EventBus { public: using Handler std::functionvoid(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::mapstd::string, std::listHandler 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_ptrObserver同时外部持有std::shared_ptrObserver。主题通知时用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_ptrQuoteObserver observer) { observers_[symbol].push_back(std::move(observer)); } void unsubscribe(const std::string symbol, const std::shared_ptrQuoteObserver 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_ptrQuoteObserver w) { return w.expired(); }); } private: std::mapstd::string, std::vectorstd::weak_ptrQuoteObserver observers_; };在updateQuote里我只遍历订阅了该股票的观察者用map做了一层按symbol的过滤。这在需求里是必要的因为不同客户端关心的标的不同。std::erase_if是C20的算法如果你用的是C17就手写一个 remove_if 加 erase。3.3 构建与运行主函数里演示完整流程创建观察者订阅行情更新价格退订后再更新。int main() { auto ticker std::make_sharedStockTicker(); auto logger std::make_sharedPriceLogger(); auto alert std::make_sharedAlertSystem(); 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环境编译器要求尽量C17及以上用gcc的话g -stdc17 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需要C20支持如果你用C17把主函数里那行换成手动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机制、游戏引擎里的事件分发器本质上都是从观察者模式衍生出来的变体。如果你第一次接触观察者模式我的建议是别背代码先想清楚哪一方是“发布者”哪一方是“订阅者”中间的信息载体是什么。把这个想明白代码怎么写都是自然的。如果代码写出来发现很别扭大概率是发布者和订阅者的边界划错了。另外一个实际的经验写观察者模式的时候顺手把测试的桩观察者类也写了。这样每次改动主题逻辑都能在单测里验证通知次数和参数内容能挡住一大堆回归问题。我在项目里就是这样干的效果非常好推荐你也试试。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门