首页/新闻资讯/正文详情

C++设计模式之观察者模式(Observer)

发布时间:2026/11/3 20:57:15 来源:云帆数科 栏目:资讯中心
C++设计模式之观察者模式(Observer)
前言观察者模式Observer定义一种一对多的依赖关系当主题subject的状态改变时所有登记过的观察者observer都会收到通知并自行更新。它在 C 里最常见的落地形态是事件总线、信号槽、模型与视图的联动。模式本身不复杂难点几乎全在工程细节上观察者什么时候注销、通知过程中有人注销怎么办、观察者对象先于主题销毁怎么办、多线程下谁来加锁。一个典型误解是「观察者模式就是保存一堆回调然后挨个调用」。如果只做到这一步程序在单线程、观察者生命周期覆盖主题的场景下确实能跑一旦观察者在回调里注销自己或者主题比观察者活得久就会出现迭代器失效和悬垂指针。另一类误解是把「通知」当成同步调用一定安全——回调里如果再次触发同一个主题的通知就可能无限递归。本文先讲角色划分与两种实现风格接口继承与std::function再逐条处理注销、生命周期、线程安全与重入最后给出一个完整可读的示例。代码基准为 C17。一、角色与两种实现风格观察者模式有三个角色角色职责Subject主题维护观察者列表提供注册、注销、通知接口Observer观察者定义更新接口由具体观察者实现ConcreteSubject / ConcreteObserver保存具体状态、实现具体响应风格一接口继承最传统的写法是定义一个抽象观察者主题保存Observer*// 适用于 C17#include vectorclass Observer {public:virtual ~Observer() default;virtual void on_event(int value) 0;};class Subject {public:void subscribe(Observer* observer) { observers_.push_back(observer); }void unsubscribe(Observer* observer) {for (auto it observers_.begin(); it ! observers_.end(); it) {if (*it observer) {observers_.erase(it);return;}}}void notify(int value) {for (Observer* observer : observers_) {observer-on_event(value);}}private:std::vectorObserver* observers_;};这种风格类型明确观察者必须实现固定签名。缺点是每加一种事件类型就要改接口或者定义多个观察者基类同时裸指针不表达所有权谁负责注销全靠约定。风格二std::function 回调现代 C 更常用std::function存储回调主题不关心接收者是什么类型// 适用于 C17#include cstdint#include functional#include utility#include vectorstruct Event {int sensor_id 0;double value 0.0;};class EventBus {public:using Handler std::functionvoid(const Event);using Token std::uint64_t;Token subscribe(Handler handler) {const Token token next_token_;handlers_.push_back(Entry{token, std::move(handler)});return token;}bool unsubscribe(Token token) {const auto it std::remove_if(handlers_.begin(), handlers_.end(),[token](const Entry entry) { return entry.token token; });if (it handlers_.end()) {return false;}handlers_.erase(it, handlers_.end());return true;}void publish(const Event event) const {const auto snapshot handlers_; // 拷贝一份再回调for (const Entry entry : snapshot) {entry.handler(event);}}private:struct Entry {Token token;Handler handler;};std::vectorEntry handlers_;Token next_token_ 1;};Token是注销凭据。相比直接传函数指针令牌的好处是同一个可调用对象注册两次也能分别注销相比传std::function本身令牌比较是 O(1)不依赖可调用对象的相等比较std::function不支持。publish里先拷贝handlers_再遍历是为了让回调中的unsubscribe安全即使原容器被修改遍历的是快照不会迭代器失效。代价是每次通知都有一次容器拷贝观察者很多时开销明显另一种做法是「标记删除」通知结束后统一清理。二、注销、重入与通知顺序注销发生在通知过程中假设通知正在遍历[A, B, C]B 在回调里注销了自己。如果直接遍历原容器erase会让迭代器失效后续访问是未定义行为。三种处理方式快照通知前拷贝列表回调操作原列表。实现简单缺点是本次通知中已注销的观察者仍会收到当前事件。延迟删除注销只把条目标记为dead通知结束后统一erase。语义更精确但需要额外的标志位和清理时机。索引遍历加小心只在删除当前元素时调整下标容易写错不推荐。选择哪种取决于语义要求。多数事件总线接受「本轮通知仍会到达」快照就够了如果要求「注销立即生效」就必须用延迟删除并在遍历时跳过dead条目。重入与递归如果观察者的回调里再次调用publish就会形成递归。若两次事件相同且没有终止条件栈会不断增长直到溢出。防御手段有两种一是主题内部维护「正在通知」标志重入时把事件放入队列等外层通知结束后再依次派发二是明确规定回调中不得再次发布写进接口契约。前者更健壮后者更简单。通知顺序标准容器不保证观察者的回调顺序有业务含义。std::vector按插入顺序遍历是实现使然但一旦引入快照、延迟删除或线程调度顺序就可能变化。如果业务依赖「先通知 A 再通知 B」应当显式建模优先级而不是依赖注册顺序。三、生命周期与线程安全谁比谁活得久裸指针或引用捕获的回调要求观察者比主题活得久或者在使用前注销。这个约定很容易被违反局部对象注册后离开作用域主题仍然持有指向它的指针下一次通知就是悬垂访问属于未定义行为。用std::weak_ptr可以检测对象是否还活着// 适用于 C17#include memory#include vectorclass Observer {public:virtual ~Observer() default;virtual void on_event(int value) 0;};class Subject {public:void subscribe(const std::shared_ptrObserver observer) {observers_.push_back(observer);}void notify(int value) {for (auto it observers_.begin(); it ! observers_.end(); ) {if (auto observer it-lock()) {observer-on_event(value);it;} else {it observers_.erase(it); // 对象已销毁顺手清理}}}private:std::vectorstd::weak_ptrObserver observers_;};这里主题只持有弱引用不延长观察者寿命lock()成功说明对象仍然存在。注意lock()返回的shared_ptr只在本次循环迭代内有效回调期间对象不会被销毁。代价是观察者必须由shared_ptr管理且lock()有一次原子操作开销。多线程下的通知如果订阅、注销、发布可能发生在不同线程std::vector的并发读写是数据竞争属于未定义行为。需要互斥量保护容器。关键原则是不要在持锁时调用回调回调可能耗时、可能再次进入主题、可能申请其他锁持锁调用会拉长临界区并带来死锁风险。正确模式是「锁内取快照锁外回调」// 适用于 C17#include mutex#include vectorclass ThreadSafeBus {public:void subscribe(int id) {std::lock_guardstd::mutex lock(mutex_);ids_.push_back(id);}void publish() {std::vectorint snapshot;{std::lock_guardstd::mutex lock(mutex_);snapshot ids_;}for (int id : snapshot) {handle(id); // 锁已释放}}private:void handle(int id) { (void)id; }std::mutex mutex_;std::vectorint ids_;};快照方案在并发下是「弱一致」的通知期间新增的订阅者不会收到本轮事件期间注销的仍可能收到。这通常是可接受的语义但必须写清楚不能假设强一致。异常与回调失败某个观察者抛异常时后面的观察者是否还能收到通知publish默认会让异常向外传播循环中断。若要求「一个失败不影响其他」需要在循环内try/catch并决定异常如何上报——吞掉异常通常不是好主意至少应记录。异常安全策略要在接口文档里明确不能靠调用方猜。四、完整示例温度传感器下面的例子用接口继承加weak_ptr实现一个温度传感器和两个观察者。显示器在温度超过阈值时打印告警日志器记录所有读数其中一个观察者在收到若干次事件后自行注销。// 适用于 C17#include iostream#include memory#include string#include vectorclass TemperatureObserver {public:virtual ~TemperatureObserver() default;virtual void on_temperature(double celsius) 0;};class TemperatureSensor {public:void subscribe(const std::shared_ptrTemperatureObserver observer) {observers_.push_back(observer);}void set_temperature(double celsius) {temperature_ celsius;notify();}private:void notify() {for (auto it observers_.begin(); it ! observers_.end(); ) {if (auto observer it-lock()) {observer-on_temperature(temperature_);it;} else {it observers_.erase(it);}}}std::vectorstd::weak_ptrTemperatureObserver observers_;double temperature_ 0.0;};class Display : public TemperatureObserver {public:explicit Display(double threshold) : threshold_(threshold) {}void on_temperature(double celsius) override {if (celsius threshold_) {std::cout alarm: celsius \n;}}private:double threshold_;};class Logger : public TemperatureObserver {public:void on_temperature(double celsius) override {std::cout log: celsius \n;if (count_ 2) {std::cout logger stops after two records\n;}}private:int count_ 0;};int main() {TemperatureSensor sensor;{auto display std::make_sharedDisplay(30.0);auto logger std::make_sharedLogger();sensor.subscribe(display);sensor.subscribe(logger);sensor.set_temperature(25.0);sensor.set_temperature(31.5);logger.reset(); // 观察者提前销毁sensor.set_temperature(35.0); // 弱引用失效自动清理}sensor.set_temperature(20.0); // 列表已空安全}这段代码演示了三件事weak_ptr让主题不必关心观察者的销毁时机lock()失败时顺手清理列表避免失效条目越积越多notify中先取shared_ptr再回调保证回调期间对象不被销毁。Logger::count_只用于演示状态不涉及注销逻辑——若要演示注销应在观察者里持有主题引用和自身令牌这会让示例明显变长本文从略。一个容易忽视的细节sensor.subscribe(display)接受的是const std::shared_ptrTemperatureObserver实参shared_ptrDisplay需要先转换成shared_ptrTemperatureObserver。这个转换是允许的派生到基的智能指针转换会生成一个临时shared_ptr函数内push_back拷贝成weak_ptr临时对象随后销毁不影响原对象的引用计数。常见坑点❌ 主题保存Observer*观察者先于主题销毁后仍被通知。✅ 用std::weak_ptr加lock()检查或建立明确的注销契约并在析构中执行。❌ 通知时直接遍历std::vector回调里调用unsubscribe。✅ 先拷贝快照再遍历或采用标记删除直接erase会使迭代器失效。❌ 持有互斥量时调用观察者回调。✅ 锁内取快照锁外回调持锁回调容易死锁并拉长临界区。❌ 认为观察者一定按注册顺序收到通知并在业务里依赖这个顺序。✅ 顺序不是接口契约的一部分需要优先级就显式建模。❌ 回调里再次触发同一主题的通知没有终止条件。✅ 用「通知中」标志加队列或明确规定回调不得重入。❌ 某个观察者抛异常导致后续观察者收不到通知且异常被静默吞掉。✅ 明确异常策略需要隔离时逐个try/catch并记录或上报失败。❌ 多线程同时subscribe和publish以为std::vector自带同步。✅ 用互斥量保护数据竞争是未定义行为通知在锁外进行。❌ 用std::function捕获局部对象的引用后注册到长生命周期主题。✅ 捕获shared_ptr/weak_ptr或按值捕获确保回调执行时对象仍有效。总结主题结论核心结构主题维护观察者集合状态变化时逐个通知实现风格接口继承类型明确std::function更灵活注销令牌或指针标识通知中注销要用快照或延迟删除生命周期裸指针靠契约weak_ptr可检测失效线程安全互斥量保护容器回调在锁外执行重入用队列或明确禁止避免无限递归异常明确「中断」还是「隔离」不要静默吞掉顺序不保证需要时显式建模优先级观察者模式把「变化」与「响应」解耦代价是把生命周期和调用时序的复杂度转移到了主题内部。写好它的关键不在模式结构而在三条契约观察者何时失效、通知过程中列表能否修改、回调里能否重入。把这三条写进注释和测试比多写几个类更有价值。

相关推荐

MySQL本地安装全流程:从版本选择到配置初始化与排错
MySQL本地安装全流程:从版本选择到配置初始化与排错

本地装一套 MySQL,说起来是每个后端、数据分析、甚至做毕业设计的同学都绕不开的一步。但真到动手的时候,你会发现网上教程一半是复制粘贴的旧版本,一半是让人点“下一步”点到最后连密码设成啥都不知道的安装器流程。我自己这些年从 Windows… · 2026/11/3 20:57:15

Linux上SQL Server中文乱码的分层排查与字符集配置
Linux上SQL Server中文乱码的分层排查与字符集配置

上周又被叫去看一个线上问题:一套服务跑在 Linux 上,数据库用的是 SQL Server,业务页面上一切正常,但运维同事从终端连上去查数据,订单一栏的备注全是问号,导出的报表里中文变成了方块。这就是典型的Linux … · 2026/11/3 20:57:15

Ubuntu 22.04安装MySQL 8.0指南与优化实践
Ubuntu 22.04安装MySQL 8.0指南与优化实践

1. 为什么选择Ubuntu 22.04 MySQL组合作为LTS(长期支持)版本,Ubuntu 22.04 Jammy Jellyfish提供了5年的官方维护周期,这使其成为生产环境部署的理想选择。而MySQL 8.0(Ubuntu 22.04默认仓库版本)相较于前代… · 2026/11/3 20:56:15

Selenium自动化测试:电子忏悔室与赎罪券生成实践
Selenium自动化测试:电子忏悔室与赎罪券生成实践

1. 项目背景与核心思路 这个看似荒诞的项目标题"用Selenium操控教堂:电子忏悔室自动生成赎罪券"实际上是一个极具创意的技术实验。它巧妙地将自动化测试工具Selenium的应用场景扩展到了宗教领域,构建了一个能够模拟人类操作、自动完成电子忏悔… · 2026/11/3 22:42:25

多人同时编辑Excel:共享地址、权限与协作规范实战
多人同时编辑Excel:共享地址、权限与协作规范实战

1. 多人同时编辑Excel,真正的拦路虎不在软件功能每到季度末、项目节点、盘点周期,办公室里最容易出现的一幕就是:五个人分头填同一张表,收上来五个附件,文件名依次是"汇总表.xlsx""汇总表-修改.xlsx&qu… · 2026/11/3 22:42:25

88E1512千兆以太网PHY调试:RGMII/SGMII、EEE与VCT排查
88E1512千兆以太网PHY调试:RGMII/SGMII、EEE与VCT排查

简介:Marvell 88E1512 芯片数据手册,面向以太网PHY选型、硬件原理图设计及嵌入式网络调试人员,用于快速掌握这款集成10/100/1000 Mbps节能以太网收发器的功能定位、性能边界与设计约束。压缩包仅含1个PDF文件,大小约1.05MB&#x… · 2026/11/3 22:42:25

一人公司运营实战:政策、成本与效率优化
一人公司运营实战:政策、成本与效率优化

1. 一人公司的政策背景与市场环境2023年新修订的《公司法》正式实施后,一人有限责任公司(自然人独资)的注册门槛显著降低。最明显的变化是取消了注册资本实缴要求,改为完全的认缴制。这意味着创业者可以用1元注册资本成立公司&… · 2026/11/3 22:42:25

EDID 128字节字段解析与HDMI扩展块生成校验
EDID 128字节字段解析与HDMI扩展块生成校验

简介:本资源是一份面向显示驱动开发、硬件调试与显示器固件工程师的EDID编程规范文档,聚焦扩展显示识别数据(EDID)这一显示终端与信号源之间的信息交换结构。内容从数据结构层面拆解128字节基本块(可扩展至256字节&… · 2026/11/3 22:42:24

条码标签打印软件流水号跳号设置与漏号排查指南
条码标签打印软件流水号跳号设置与漏号排查指南

条码标签打印软件里最容易被做成“事故”的一个功能,就是流水号。我这些年帮不少仓库、产线、门店处理过标签打号的问题,其中问得最集中的一类就是——为什么我的流水号打出来是跳号的。有意思的是,“跳号”这个词在条码标签打印软件里其实是… · 2026/11/3 22:41:24

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46

STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59

ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59

基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码