ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++状态模式实战:用自动空调控制器重构if-else并排查崩溃

C++状态模式实战:用自动空调控制器重构if-else并排查崩溃 前阵子我在折腾一个自动空调控制端的逻辑模块要接收温度报文根据当前设置的模式去驱动压缩机和风门。第一版图省事全用 if-else 堆写完之后看着还能跑等需求一加我就傻眼了。正好借这个项目把 C 里的状态模式完整重新梳理了一遍代码重构完舒服得多。这篇就聊聊我踩过的坑、用到的套路以及几个真实调试现场希望能帮到正在被状态判断搅得焦头烂额的读者。状态模式不是说背下来四个角色就完事真正的难点在于怎么设计状态接口、怎么管理状态对象生命周期、什么时候不值得用。我会用一个自动空调控制器的例子贯穿全文从 if-else 版本一路发展到状态机版本最后再说说排查运行时崩溃的思路。1. 为什么我把一段 if-else 堆出来的空调控制重构成了状态模式1.1 一个控制器被需求写死的全过程这个空调控制器的逻辑最初很简单收到温度报文之后对照目标温度决定要不要开压缩机。我随手写了几个 if 分支void AirConditioner::onTemperatureReport(float currentTemp) { if (mode Mode::Cool) { if (currentTemp targetTemp hysteresis) { compressorOn(); } else { compressorOff(); } } else if (mode Mode::Heat) { if (currentTemp targetTemp - hysteresis) { heaterOn(); } else { heaterOff(); } } else if (mode Mode::FanOnly) { fanOn(); } }当时看着挺清晰因为只有三种模式每个模式两三个分支。后来需求开始长了要加自动模式自动模式下要根据温差决定走制冷还是制热制热模式下要联动电辅热送风模式要根据室内外温差决定引入新风还是循环通风睡眠模式要逐步上调目标温度还要处理断电恢复后回到之前的模式。这类需求一来函数内部就开始疯狂膨胀。每个新需求都要在所有分支里找一圈看看哪里加了、哪里漏了。最痛苦的还不是自己写是过了一个月回来看代码要猜当时某个布尔变量为什么在某个分支里被置位。我记得某次同事加了个bBoostMode参考了三个分支却漏改了另一个分支压缩机就一直不启动找了一整天才定位到问题本质上是系统的行为随着“状态”不同而完全分岔但我没有用状态来组织代码。1.2 状态模式能省掉的到底是什么那段时间我反复想一个问题代码里最核心的复杂度来源是什么答案是同一类事件在不同状态下处理逻辑完全不一样。比如说“温度到了”这个事件在制冷状态下要关压缩机在制热状态下要开电辅热在待机状态下什么都不做。if-else 把这个“状态维度的分岔”展开成了线性的条件判断状态一多判断就指数级膨胀。状态模式的思路很朴素把“当前处于什么状态”变成一个个对象每个对象知道自己在当前状态下如何响应所有事件。事件来了直接丢给当前状态对象处理不用自己去做一大堆分支判断。用生活里的话说一个人开会时和休息时收到同样一条消息反应完全不同你不会每次都在大脑里写“如果我正在开会就怎样如果我正在休息就怎样”你会直接基于当前角色做出反应——角色就是状态对象。所以我当时的重构目标就定为空调的每个运行模式对应一个状态类所有模式对事件的处理集中到各自的类里。新加一种模式只新建一个类不用再去读那些又长又绕的 if 分支。2. 状态模式的 C 骨架怎么搭才能少踩析构的坑2.1 状态接口的设计决定了你能省多少心先定义状态基类。我在实际项目中习惯把AirConditioner作为上下文Context传入每个事件函数因为状态对象需要回调上下文的开关方法也需要触发状态切换。// 状态基类 class AirConditioner; // 前置声明 enum class ACMode { Off, Idle, Cooling, Heating, FanOnly, Auto }; class ACState { public: virtual ~ACState() default; // 收到模式切换指令 virtual void setMode(AirConditioner* ac, ACMode mode) 0; // 收到温度报文 virtual void onTemperatureReport(AirConditioner* ac, float currentTemp) 0; // 收到下电指令 virtual void powerOff(AirConditioner* ac) 0; // 返回当前模式便于日志和界面显示 virtual ACMode getMode() const 0; };有几个细节值得认真说。第一析构函数必须是virtual。因为后面所有状态类都是通过基类指针被delete的如果析构不虚就是一个明确的未定义行为表现为状态对象里的资源没释放甚至内存崩溃。这里不用override因为析构函数的写法略有不同写成virtual ~ACState() default;就够了。第二状态接口的方法设计应该“以事件为粒度”而不是“以功能为粒度”。我之前见过有人把状态接口设计成turnOnCompressor()、turnOffCompressor()、setFanSpeed()这种细粒度集合结果每个状态类里都要空实现一大堆用不到的方法又臭又长。正确做法是只暴露少量的高层事件比如onTemperatureReport、setMode、powerOff具体那些压缩机、风门操作由状态对象在内部调用上下文完成。2.2 上下文对象怎么持有状态怎么切换上下文对象是状态模式的“壳”。它对外接收消息对内委托给当前状态对象。#include memory class AirConditioner { public: AirConditioner(); void setMode(ACMode mode); void onTemperatureReport(float currentTemp); void powerOff(); ACMode getCurrentMode() const; private: friend class ACState; void transitionTo(std::unique_ptrACState nextState); std::unique_ptrACState currentState_; float targetTemp_ 26.0f; float hysteresis_ 0.5f; };注意transitionTo接收的参数是std::unique_ptrACState这意味着状态对象在创建并转移所有权时会明确表达“只有上下文拥有这个状态对象”。状态切换的本质就是替换currentState_void AirConditioner::transitionTo(std::unique_ptrACState nextState) { if (!nextState) { return; } if (currentState_-getMode() nextState-getMode()) { // 相同状态不重复切换 return; } currentState_ std::move(nextState); }这里有个很关键的工程问题当前状态对象的成员函数里触发另一个状态切换会发生什么假设CoolingState::onTemperatureReport内部调用了ac-transitionTo(std::make_uniqueIdleState())。transitionTo里把currentState_赋值为新对象旧对象这时候this指向的那个CoolingState实例在unique_ptr的赋值过程中被析构。函数返回后如果CoolingState::onTemperatureReport里还有任何读取this-xxx的语句那就是典型的悬空 this 访问表现就是 Windows 下常见的access violation c0000005崩溃或者 Linux 下莫名的段错误。这个坑非常隐蔽因为它不在你写代码的那一瞬间触发而是取决于状态对象成员函数的尾部有没有访问成员。我的做法是状态对象里所有成员访问尽量集中在函数开头需要切换状态时最后再调transitionTo切换之后的代码一概不访问this。如果实在没法保证那就做一个“延迟切换”——把下一个状态存到上下文里等当前事件函数返回后由上下文的执行框架统一切换。后面第 5 节再展开讲这个崩溃的定位过程。2.3 智能指针、裸指针还是表驱动各有什么取舍状态对象数量少、生命周期固定用裸指针也能写。但状态切换频繁裸指针一个不小心就 double free。我一开始也想用共享所有权后来发现根本没有两个对象需要同时拥有状态所以unique_ptr是最合适的语义最清晰。有一个替代方案也可以提一下状态转移表。它的核心是std::mapstd::pairState, Event, State用表驱动状态转移代码量少适合“转移逻辑简单、行为差异不大”的场景。但状态模式强调的是“每个状态对事件响应的逻辑也不一样”比如制冷状态收到温度报文后要计算迟滞、控制压缩机送风状态收到同样报文却要控制风门角度。这些行为差异在状态转移表里很难表达得额外挂回调函数反倒不如状态类里写虚函数直观。3. 自动空调状态机的完整实现状态、事件和转移表落地3.1 需求拆解把“状态、事件、行为”三个维度列出来开始写代码前先别急着建类。我跟团队的习惯是把系统里所有运行模式列成状态把自己的所有输入列成事件把“当前状态下收到事件后的处理”填进一张表。这张表是后续写状态类的地图。自动空调控制器最终定了 6 个状态Off、Idle、Cooling、Heating、FanOnly、Auto。4 类事件setMode、onTemperatureReport、powerOff、powerOn。其中powerOn在上下文的构造函数里直接触发不算状态接口里的方法。我整理过一张简化的转移表当前状态事件下一状态实际要做的动作OffpowerOnIdle初始化传感器进入待机IdlesetMode(Cooling)Cooling启动压缩机IdlesetMode(Heating)Heating启动加热器IdlesetMode(FanOnly)FanOnly启动风机IdlesetMode(Auto)Auto先进入自动判断流CoolingonTemperatureReport(温度到达目标)Idle关闭压缩机CoolingsetMode(Heating)Heating无条件切换HeatingonTemperatureReport(低于目标减迟滞)Idle关闭加热器FanOnlysetMode(Cooling)Cooling切换到制冷AutoonTemperatureReport(温差阈值)Cooling实际执行制冷逻辑AutoonTemperatureReport(温差-阈值)Heating实际执行制热逻辑任意状态powerOffOff关闭所有负载保存设置表格的好处是评审时一眼能看出漏掉了哪些转移。比如最开始我就漏了从Cooling直接切到FanOnly的行为测试阶段才发现——用户从 app 上切模式app 侧是直接下发模式报文的不是先回 Idle 再切换。所以在状态接口里保留setMode后每个状态都必须显式处理“外部模式切换”这个事件。3.2 状态类的实现细节以 Off 和 Cooling 为例OffState的实现相对简单但有个小细节收到温度报文时什么都不做不是忽略而是要有日志。#include iostream class OffState : public ACState { public: void setMode(AirConditioner* ac, ACMode mode) override { if (mode ACMode::Off) { return; } // 从关机进入待机再让待机状态去处理模式 ac-transitionTo(std::make_uniqueIdleState()); ac-setMode(mode); } void onTemperatureReport(AirConditioner* ac, float currentTemp) override { // 关机状态下温度报文没有意义但保留一个入口打日志 std::cout [OffState] ignore temperature report: currentTemp std::endl; } void powerOff(AirConditioner* ac) override { // 已经是关机无需处理 } ACMode getMode() const override { return ACMode::Off; } };注意setMode里我调用了transitionTo(IdleState)后又调用了ac-setMode(mode)。这里有个过程先把状态切到 IdleState再让 IdleState 去接管后续的模式处理这比在 OffState 里重复实现一套“模式切换”逻辑要干净得多。这就是状态模式的一个轻松之处——分析移交上下文。CoolingState是文章的核心逻辑所在class CoolingState : public ACState { public: CoolingState(float targetTemp, float hysteresis) : targetTemp_(targetTemp), hysteresis_(hysteresis) {} void setMode(AirConditioner* ac, ACMode mode) override { switch (mode) { case ACMode::Cooling: break; case ACMode::Heating: ac-transitionTo(std::make_uniqueHeatingState()); break; case ACMode::FanOnly: ac-transitionTo(std::make_uniqueFanOnlyState()); break; case ACMode::Auto: ac-transitionTo(std::make_uniqueAutoState()); break; default: // 切到待机 ac-transitionTo(std::make_uniqueIdleState()); break; } } void onTemperatureReport(AirConditioner* ac, float currentTemp) override { // 1. 如果已经达到目标进入待机关闭压缩机 if (currentTemp targetTemp_ - hysteresis_) { ac-transitionTo(std::make_uniqueIdleState()); return; } // 2. 没达到就继续制冷温度越高压缩机的开度越大简化为比例控制 float delta currentTemp - targetTemp_; int duty static_castint(std::clamp((delta / 3.0f) * 100.0f, 20.0f, 100.0f)); ac-setCompressorDuty(duty); } void powerOff(AirConditioner* ac) override { ac-transitionTo(std::make_uniqueOffState()); } ACMode getMode() const override { return ACMode::Cooling; } private: float targetTemp_; float hysteresis_; };这里把比例控制放进了状态类内部。你可能注意到状态不是纯粹只负责切换它也可以携带自己的参数。比如制冷的目标温度、迟滞都是CoolingState构造时传进来的。状态类不只是“我是制冷”它还知道自己这轮制冷要干什么。这比在 Context 里维护一堆currentState加stateParams的散装结构更内聚。HeatingState和FanOnlyState的结构几乎一样核心就是onTemperatureReport分支不同制热是低于目标温度才开加热送风则基本不管温度只根据室内外温差调风门。真正复杂的是AutoState。3.3 AutoState 的组合式处理避免状态爆炸AutoState不能写成“又制冷又制热”的四不像否则就是把所有 if-else 又塞回了一个状态类里。我的做法是AutoState内部根据温差决定“委托”给CoolingState或HeatingState处理具体执行自己只做一个裁决者。class AutoState : public ACState { public: void setMode(AirConditioner* ac, ACMode mode) override { if (mode ACMode::Auto) { return; } ac-transitionTo(std::make_uniqueIdleState()); ac-setMode(mode); } void onTemperatureReport(AirConditioner* ac, float currentTemp) override { float target ac-getTargetTemp(); if (currentTemp target 1.0f) { // 应该制冷 ac-transitionTo(std::make_uniqueCoolingState()); ac-onTemperatureReport(currentTemp); // 复用制冷状态的处理 } else if (currentTemp target - 1.0f) { ac-transitionTo(std::make_uniqueHeatingState()); ac-onTemperatureReport(currentTemp); } else { // 温度接近目标待机 ac-transitionTo(std::make_uniqueIdleState()); } } ACMode getMode() const override { return ACMode::Auto; } };这里其实出现了一个小小的“递归决策”AutoState判断完温度后切到具体模式再重新调用一次温度事件处理。好处是制冷、制热的行为细节不用在自动状态里复制一份坏处是如果切换后的状态又立刻判断条件不满足可能会产生抖动所以我在代码里用1.0f和-1.0f做了滞回避免温度在一度附近反复横跳。这个案例想说明的是状态模式可以组合使用。状态之间不是只能兄弟关系一个聚合状态可以持有“实际执行策略”的引用或临时切换到那些策略状态。真实项目里状态模式往往用于表达宏观状态组合模式或者策略模式用于表达微观手法两层叠起来用。3.4 代码落地时的工程细节状态类的构造和析构要轻量。状态切换非常频繁如果每次切换都在堆上 new 一个对象内存分配器压力会很大。我有一次在嵌入式环境里跑发现 mcu 上的堆碎片化严重排查下来就是状态对象频繁 new/delete。后来改成构造时传参已有状态对象并尽量缓存复用或者使用对象池问题就缓解了。尽量把每个状态类放进单独一个.h/.cpp避免在一个巨型的state_impl.cpp里堆十个类。我是按状态拆文件编译的增删状态时改动范围更小。上下文和状态之间双向引用要防止循环依赖。我用前置声明class AirConditioner;放在状态头文件里真正的功能依赖放在.cpp中。4. 状态模式真正能落地的场景以及三个容易翻车的点4.1 游戏、协议连接、UI 流程——三个高契合场景除了空调控制器我在其他几个项目里也用状态模式重构过效果都不错。游戏角色是一个经典例子。待机、移动、攻击、受击、死亡每个状态下同一帧输入的处理完全不同。我在一个 2D 小游戏里用状态类去管理角色动画状态每次按键事件只委托给当前状态释放技能、位移、碰撞检测都在对应状态类里实现比在游戏主循环里堆if (state ATTACK ...)清晰太多了。协议连接则是另一个收益高的场景。设备端连接服务器存在“未连接、连接中、已握手、运行中、重试中、失败”等多个状态。握手失败后不是直接回到未连接而是需要进入一个“重试”状态重试次数耗尽才进入“失败”状态且允许用户手动重置回初始态。这里如果把“握手失败”处理成“回到未连接”逻辑上是错的因为你可能永远不知道这次重试失败和上次失败的区别。状态对象可以让‘失败’状态知道自己已经失败了几次保存重试计数从而决定是继续重试还是进入不可恢复的错误态。UI 导航流程里状态模式也常见主菜单、设置页、播放页、暂停页。每个页面下按下返回键的行为都不一样切页就是一个状态切换。这类场景用状态模式的负载很轻写起来也最轻松。4.2 别为了模式而模式什么时候真的不如 if-else我得诚实说状态模式不是万能药有几种场景用它纯属白费功夫。一种是状态数量少于 3且事件类型只有两三种。比如只是判断“开关”两态ifisOn就够了强行建两个类再加上下文增加理解成本。第二种是状态之间几乎不会迁移各状态的事件响应也都各管各的那其实就是普通的多态分发不需要状态机语义。第三种是事件处理的行为差异极小比如不同状态下都只是打印不同的日志用查表法更轻。我判断是否值得用状态模式的标准很简单拿一张纸把“状态数 × 事件数”算一算。如果乘积差不多等于代码分支的数量且以后状态还会稳定扩展那状态模式是划算的如果状态就是三五个、事件永远就两个那写枚举加 switch 反而更直观。4.3 状态模式和策略模式的区别我的区分方法面试和代码评审中经常有人把状态模式跟策略模式搞混。我自己的判断方式策略模式解决的是“同一件事的不同做法换着用”。比如排序算法一个Sorter持有SortStrategy下游可以动态切换快排或归并。这时候根本没有“状态迁移”的概念策略对象之间也不存在谁能触发谁变成谁。状态模式则天然带着“当前状态会演化”的概念。一个状态对象的成员函数里往往会触发上下文把当前状态变成另一个状态。策略模式里Context 选择策略策略不会让 Context 变成另一种 Context状态模式里当前状态对象可以直接改变它自己所属的那个壳的身份。有个直接的代码层面判断技巧如果把这套类的转移逻辑删掉代码还能正常自洽那大概率是策略模式如果删掉状态转移代码就没法工作那才是状态模式。5. 一次访问违例的定位实录状态日志是第一破案工具5.1 现象收到温度报文后程序崩溃项目是用 VSCode CMake 搭的 C20 工程跑在 x86 Linux 上做半实物仿真。一切正常跑了几分钟某次收到一个外部温度报文后程序直接崩溃。控制台打出一堆信息隐约看到Segmentation fault。当时我的第一反应是检查数组越界但仔细看崩溃消息栈顶指向的是CoolingState::onTemperatureReport这是个纯逻辑函数里面没有任何裸数组访问。为了能把崩溃现场看得更清楚我打开了 core dump然后gdb里 bt 一看完全傻眼栈帧里的this指针已经指向一个被释放的堆地址。这意味着我在状态对象已析构的代码路径里还在执行它的成员函数。5.2 第一步打开状态日志画一条非法转移路径排查这种问题我的第一个操作永远不是断点而是给状态切换和关键事件入口加上日志。我用的日志库是 spdlog配一个最简单的同步日志器就够了#include spdlog/spdlog.h void AirConditioner::transitionTo(std::unique_ptrACState nextState) { if (!nextState) { spdlog::warn(transitionTo with null state); return; } auto oldMode currentState_ ? currentState_-getMode() : ACMode::Off; auto newMode nextState-getMode(); spdlog::info(state transition: {} - {}, modeToString(oldMode), modeToString(newMode)); if (oldMode newMode) { return; } currentState_ std::move(nextState); }加完日志重启复现输出序列是这样的[14:02:33.112] state transition: Cooling - Idle [14:02:33.113] CoolingState::onTemperatureReport看到没有问题来了日志显示程序已经从Cooling切换到了Idle可紧接着又执行了CoolingState::onTemperatureReport。这说明那次温度报文的事件分发是异步或者延迟的事件到达时状态已经变成 Idle但它仍然拿着旧状态的指针去调用了CoolingState的方法。这个场景在我的实现里更具体我在CoolingState::onTemperatureReport开头判断温度已经达标就调用了transitionTo(IdleState)然后这个函数返回。按理说函数返回后不应该再访问 this。但实际代码里我在状态切换后还有一行spdlog::info(cooling now stop, currentTemp{}, currentTemp_)正是因为这一行读取了成员变量才把悬垂 this 的崩溃暴露了出来。5.3 根因状态对象生命周期管理失误这个 bug 的根源有几层。第一层是代码写法上我在状态对象成员函数内部触发状态切换切换一瞬间自己的内存就被释放了函数后续代码继续用 this 就是典型悬垂指针。这其实是一个设计约定问题不算编译器层面的错误所以运行时才会时好时坏。第二层是日志打印的位置太靠后。如果我在onTemperatureReport一进入就打印入口日志调试时能更早看到“进入 CoolingState”和“切出 CoolingState”的顺序。现在日志只在 transitionTo 里打看不到“成员函数入口”这个标记所以一开始我误以为是异步事件重复分发。最终的修复我选择的是“延迟切换”方案。在上下文里增加一个待切换状态变量状态对象成员函数里只发出切换请求真正执行transitionTo的时机放到事件循环总控里class AirConditioner { // ... void requestTransition(std::unique_ptrACState nextState); void executePendingTransition(); private: std::unique_ptrACState currentState_; std::unique_ptrACState pendingState_; }; void AirConditioner::requestTransition(std::unique_ptrACState nextState) { pendingState_ std::move(nextState); } void AirConditioner::executePendingTransition() { if (!pendingState_) { return; } transitionTo(std::move(pendingState_)); }然后在onTemperatureReport入口先执行executePendingTransition()再进入当前状态的事件分发。这样任何状态对象内部发起的切换请求都会攒到下一轮事件进入之前统一执行彻底消除了状态对象在成员函数执行期间被析构的可能。代价是多了一次间接调用但对空调控制器这种低频事件场景完全够用。6. 状态模式在面试里怎么答才能透出实战经验6.1 和策略模式、状态机的本质区别怎么说面试官问“谈谈状态模式”时很多人会把概念背得滚瓜烂熟但一追问就跟策略模式混了。我给一个清晰的对比话术策略模式是替换算法状态模式是表达对象的行为随状态迁移而改变的机制。一个排序类换排序算法是策略模式因为Sorter对象本身身份不会变一个空调从制冷切到制热是状态模式因为‘当前状态’从制冷对象切到制热对象后续所有温度响应行为都跟着变。再补充一层“状态机”和“状态模式”不是一回事。状态机关注“在某个状态收到某事件应该转移到哪个状态”描述的是转移表状态模式关注“在某个状态下收到某事件应该执行什么行为”描述的是多态分发。工程上两者经常一起用状态转移表负责决策状态模式负责执行。这个回答展开到位面试官基本能确认你不是只背了八股。6.2 一个面试应答示例从‘我会用’到‘我踩过坑’如果面试官让我举个例子我会把空调控制器这个项目摆出来完整讲述一遍“我做过一个自动空调控制器六个状态四类事件。最初用 if-else后来状态多了重构成状态模式。状态基类定义setMode、onTemperatureReport、powerOff三个事件接口每个具体状态实现自己的响应逻辑。我遇到过状态对象成员函数内部触发状态切换切换后旧对象被析构函数后续访问 this 导致访问违例的坑这个我用延迟切换解决——状态对象只请求切换上下文在下一轮事件入口统一执行。实际收益是加新状态时只需要新增状态类并补充转移表不用动既有代码。”这段话里包含了我实际做过的事、我遇到的具体问题、我的解决方案、我的收益评估。比“状态模式有四个角色上下文保存状态状态对象定义行为”这种干巴巴定义有说服力得多。技术面试还喜欢问 C 层面的细节为什么状态接口析构要虚、为什么用unique_ptr而不是shared_ptr、状态模式下虚函数开销可不可以接受。我的答案是析构虚函数保证多态删除安全unique_ptr表达独占所有权状态对象不会被共享虚函数开销在这个场景下可以忽略毕竟事件频率远低于函数指针间接调用带来的收益。最后再分享一点个人落地习惯状态模式我用了几年最大的体会是它省的是“以后加状态时的脑袋负担”而不是“现在写代码的手指负担”。前期建类、写接口确实比写 if-else 麻烦但项目一旦进入维护期收益很快就体现出来了。我个人还有一个习惯状态转换一律打印到日志并把日志格式统一成“状态 - 状态原因”这样一行后续排查任何偶发问题都有据可查。这套日志在我定位那轮访问违例时帮了大忙否则光靠断点很难复现偶发崩溃。另外一个建议是扩展时别贪多。状态模式很容易被过度设计——我看到过有人为了一个开关状态建了四个文件四个类维护成本直线上升。先用简单的枚举加 switch 撑住当分支开始成倍增长、你在 switch 里反复寻找“到底漏了哪个状态没处理”的时候再迁移到状态模式也不迟。知道什么时候不用比知道怎么用更宝贵。
返回列表