恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C++状态模式实战:三种实现方式与常见坑
首页
资讯中心
/
C++状态模式实战:三种实现方式与常见坑
C++状态模式实战:三种实现方式与常见坑
发布时间:2026/10/15 7:40:45
先抛一个很多 C 项目里都见过的场景函数内部堆了一长串if (state kConnecting) ... else if (state kConnected) ...看着好像能跑但每次新增一个状态就要把好几处地方同时改。业务逻辑叠得越高分支之间的顺序越难判断改到最后甚至没人敢动。我这几年在某个设备管理模块里反复撞上同一堵墙后来把状态判断收敛成状态模式代码才算真正松了绑。这篇文章就从状态爆炸、C 里的几种实现方式、完整示例和最常见的坑几个方向把状态模式一次性讲透。无论你是刚开始接触设计模式还是已经写过不少状态机想找点避坑经验应该都能用得上。1. 状态模式到底在治什么病从状态爆炸说起1.1 一个让代码迅速变形的实际场景有段时间我维护某设备管理模块的连接链路状态包含未初始化、初始化中、连接中、已连接、重连中、已断开。一开始状态少代码写成这样还能忍if (state kInitDone event kStartConnect) { state kConnecting; // 发起连接... } else if (state kConnecting event kSuccess) { state kConnected; } else if (state kConnecting event kTimeout) { state kReconnecting; // 设置重连定时器... }六个状态、五个事件的时候这个 switch 勉强能看。等到需求开始叠加认证失败要回初始化、心跳超时要区分重连次数、UI 上取消连接和底层断线同时发生……每个函数内部的判断逻辑开始互相纠缠。最典型的问题是某个事件到底在哪个状态有效完全没有一个集中的地方告诉你加一个新状态要翻遍所有函数万一漏改某个分支就可能出现“连接已断开了还在重连”这类诡异行为。这不是代码风格问题而是这个局面本身就不适合用集中分支来承担。每个状态有不同的业务规则、不同的资源需要管理如果还靠着外层if/else来做全局仲裁条件复杂度迟早会超出人能维护的极限。1.2 状态模式的核心思想把状态行为塞回状态自己内部状态模式的做法很直接把每个状态本身变成一个对象这个对象知道自己收到某个事件时该怎么处理、是否允许转移到另一个状态、转移前后要做什么。而原本那个“大法官”Context 只负责两件事保存当前状态把外部事件转交给当前状态处理。你可以把状态模式理解成开会换主持人。Context 不决定谁能发言也不决定下一个环节谁上它只是把话筒交给当前的主讲人。主讲人说“请下一位”Context 才把话筒交给新的人。这样整个会议的规则被分散到每个主讲人自己的流程里协调层的复杂度就降下来了。在软件上就是这么回事新增状态时你只需要写一个新的状态类再修改个别状态类里“从这个状态可以迁到哪个状态”的定义不需要动 Context 里所有分支。这才是状态模式治好的“病”。2. C 里落地状态模式的三条路枚举分支、经典多态、std::variant2.1 枚举 switch状态模式的朴素形态很多教程会把“枚举 switch”也和状态模式混淆。实际上它只是状态模式要解决的问题本身算不上完整的模式。但在 C 项目里它不是不能用而是有很强的适用边界。如果你只有两三个状态每个状态收到的事件也只有一两个并且后续扩展可能性很低那用枚举加 switch 反而最直接。你不需要搭建类层次不需要考虑状态对象的生命周期一个函数写完性能还好。问题是这种方案天然把所有规则集中在一个函数里状态一旦多起来阅读和修改都会很痛苦。判断“要不要换状态模式”最简单的方法就是数一下状态数 × 事件数超过 10 种组合的时候switch里的命中分支已经开始让人头疼了。另外还有一个隐藏问题集中分支里的状态转移逻辑很难复用。两个事件如果最终都走向同一个状态你通常只能靠写重复的赋值代码或者小心设计函数流程而这些重复最终会变成 Bug 温床。2.2 经典多态实现接口统一、行为内聚这是教科书里标准的做法。抽象出一个State基类定义所有事件的处理接口然后每个状态写一个派生类。上下文保存一个std::unique_ptrState每次收事件就调用state_-OnEvent(*this, event)。class CallState { public: virtual ~CallState() default; virtual void OnEvent(CallContext ctx, Event e) 0; }; class DialingState final : public CallState { public: void OnEvent(CallContext ctx, Event e) override; };这里的关键点在于DialingState::OnEvent不是“应付式”地写一个空壳它内部可以完整地表达“呼叫中状态下哪些事件是合法的哪些要忽略哪些会触发转移”。整个状态的行为内聚在同一个类里以后改需求不需要去 Context 的代码海里捞分支。2.3 用 std::variant 做静态多态现代 C 还有一种做法不建虚函数而是用std::variant来装状态对象再用std::visit在编译期分发using CallState std::variantIdleState, DialingState, ConnectedState; void Dispatch(CallState state, Event e) { std::visit([e](auto s) { s.Handle(e); }, state); }这种写法的优点很明确没有虚函数调用开销不需要堆上分配状态对象状态集合是编译期写死的切状态时直接赋值一个新的 variant 实例就行。但它也不是免费的午餐。每个状态类如果想保留自己的成员数据variant 在切换状态时会把旧状态析构、新状态构造遇到持有复杂资源的状态要在构造/析构里小心处理好。而且std::visit的泛型 lambda 写起来对团队里不太熟悉现代 C 的同事不太友好编译错误也不是很直观。三条路怎么选我一般这样判断状态少于 4 个且今后不太会变枚举 switch少造轮子。状态较多每个状态的行为差异大团队成员对面向对象更熟经典多态。状态集合固定、追求极致性能、不喜欢虚函数variant。实际上 90% 的场景我会推荐经典多态因为它的调试栈帧最清晰、扩展方式最直观也最符合状态模式的经典语义。variant 的优势要在非常具体的性能指标下才能体现如果项目不是高频状态切换的底层模块不值得为了那点虚函数开销牺牲可读性。3. 完整示例一个可复跑的通话状态机3.1 需求定义直接上一个能复跑的通话状态机示例方便你理解和跑通。状态有五个空闲Idle、呼叫中Dialing、通话中Connected、保持中OnHold、结束Ended。事件有六种发起呼叫、对端接通、保持、恢复、主动挂断、对端挂断。先列一张状态转移表当前状态事件下一状态备注IdleStartCallDialing发起呼叫DialingPeerAnsweredConnected对端接听DialingHangupEnded呼叫未接通就挂断ConnectedHoldOnHold进入保持ConnectedPeerHungupEnded对端先挂ConnectedHangupEnded本端挂断OnHoldResumeConnected恢复通话OnHoldHangupEnded保持中挂断其它组合一律忽略并打日志说明“当前状态下该事件无效”。3.2 状态基类和上下文定义先是抽象状态接口// call_state.h #pragma once #include memory class CallContext; class CallState { public: virtual ~CallState() default; virtual void StartCall(CallContext ctx) 0; virtual void PeerAnswered(CallContext ctx) 0; virtual void Hold(CallContext ctx) 0; virtual void Resume(CallContext ctx) 0; virtual void Hangup(CallContext ctx) 0; virtual void PeerHungup(CallContext ctx) 0; virtual const char* Name() const 0; protected: void ChangeState(CallContext ctx, std::unique_ptrCallState next); };为了实现这个基类里受保护的ChangeState需要引用完整的CallContext// call_state.cpp #include call_state.h #include call_context.h void CallState::ChangeState(CallContext ctx, std::unique_ptrCallState next) { ctx.ChangeState(std::move(next)); }上下文类// call_context.h #pragma once #include memory #include string #include call_state.h class CallContext { public: explicit CallContext(std::unique_ptrCallState init); ~CallContext() default; void StartCall(); void PeerAnswered(); void Hold(); void Resume(); void Hangup(); void PeerHungup(); void ChangeState(std::unique_ptrCallState next); const std::string StateName() const; private: std::unique_ptrCallState state_; std::string state_name_; };这里有一个设计细节容易被忽略CallState的ChangeState是受保护成员只有派生状态类能调用。CallContext的ChangeState是公开成员但它的实际使用者不是外部业务代码而是状态内部。外部业务代码应该只调StartCall()、PeerAnswered()这些语义化方法不要直接操作状态指针。这样调用方不需要知道状态机内部有哪些类。3.3 具体状态类的实现思路状态类都写在一个文件里时要注意避免重复和遗漏。我建议每个状态类文件只负责自己的声明与实现但示例里为了方便展示把结构写在一起看效果更好。#include call_state.h #include call_context.h #include iostream #include memory class IdleState final : public CallState { /* ... */ }; class DialingState final : public CallState { /* ... */ }; class ConnectedState final : public CallState { /* ... */ }; class OnHoldState final : public CallState { /* ... */ }; class EndedState final : public CallState { /* ... */ };重点看两个状态的实现。DialingState收到PeerAnswered时切到通话中void DialingState::PeerAnswered(CallContext ctx) { std::cout [Dialing] peer answered, moving to Connected\n; ChangeState(ctx, std::make_uniqueConnectedState()); } void DialingState::Hangup(CallContext ctx) { std::cout [Dialing] local hangup, moving to Ended\n; ChangeState(ctx, std::make_uniqueEndedState()); } void DialingState::Hold(CallContext ctx) { std::cout [Dialing] hold is not allowed in dialing\n; }ConnectedState收到保持、对端挂断时同样明确各自行为void ConnectedState::Hold(CallContext ctx) { std::cout [Connected] hold call\n; ChangeState(ctx, std::make_uniqueOnHoldState()); } void ConnectedState::PeerHungup(CallContext ctx) { std::cout [Connected] peer hung up, moving to Ended\n; // 这里还可以做媒体资源清理等业务动作 ChangeState(ctx, std::make_uniqueEndedState()); }ChangeState统一负责状态替换和名称更新// call_context.cpp #include call_context.h CallContext::CallContext(std::unique_ptrCallState init) : state_(std::move(init)), state_name_() { if (state_) { state_name_ state_-Name(); } } void CallContext::StartCall() { state_-StartCall(*this); } void CallContext::PeerAnswered() { state_-PeerAnswered(*this); } void CallContext::Hold() { state_-Hold(*this); } void CallContext::Resume() { state_-Resume(*this); } void CallContext::Hangup() { state_-Hangup(*this); } void CallContext::PeerHungup() { state_-PeerHungup(*this); } void CallContext::ChangeState(std::unique_ptrCallState next) { if (!next) { return; } state_ std::move(next); state_name_ state_-Name(); std::cout [ChangeState] now in state_name_ \n; }用unique_ptr的原因很明显所有权明确状态切换时旧状态自动析构不需要手工delete。你可能会问为什么状态类里不直接用new分配而是用make_unique”因为我需要让“状态对象生命周期”这个最容易出错的地方遵循 RAII。一旦切状态上一个状态对象就在栈上析构不会出现忘了删导致的内存泄漏。3.4 为什么转移权要放在状态内部而不是上下文很多人问过我既然转移表很清楚为什么不让CallContext直接根据状态枚举做统一转移那样逻辑不是更集中吗我的经验是状态内部的转移权是状态模式真正的价值所在。它让“每个状态知道自己下一步能去哪”这件事变成了可扩展的设计而不是硬编码在上下文中。比如以后要在ConnectedState里加入“通话时长超过 10 分钟自动保持”这种业务规则你只需要改ConnectedState的某个定时器处理逻辑外部完全不用动。但如果转移规则全部集中在 Context加这种规则就要在 Context 里写一个全局定时器逻辑还要判断当前状态是不是 Connected慢慢地 Context 就变回那个大熔炉了。当然也要注意不是所有状态转移都适合放进状态对象。跨状态共享的公共规则比如全局性的连接断开、强制下线更适合在 Context 层先做统一检查。重要的是别搞成“每个状态各自为政没有任何全局约束”这会让状态机彻底失控。4. 状态模式最常见的坑和避坑经验4.1 状态对象生命周期new 与 delete 的纠缠用经典多态实现时最容易被忽略的就是状态对象生命周期。初版实现常见这样写void CallContext::ChangeState(CallState* next) { delete state_; state_ next; }看起来没什么问题但一旦状态转移发生在回调链里就麻烦了如果next恰好是当前状态实例的别名或者某个事件处理函数里触发了两次ChangeState第二个next可能是已经释放的指针。而且裸指针没有任何所有权约束很容易在delete之后继续被访问。我的建议是直接上std::unique_ptr所有权清晰转移时state_ std::move(next)天然完成析构。如果确实对性能有极致要求可以用 static 单例状态对象因为状态类本身通常没有内部可变数据状态对象是可以共享的。但要注意如果用了 static 单例状态类里绝对不能持有依赖具体调用实例的成员数据否则多实例环境下会互相污染。4.2 OnEnter / OnExit 钩子缺失资源和状态不同步很多状态机问题不是出在状态转移本身而是出在“进入状态时申请的资源离开状态时没有释放”。比如DialingState开始时注册了一个重连定时器正常情况下切到ConnectedState时会把它注销。但如果对端在一瞬间既发了超时又发了成功定时器可能没被取消导致连接已经成功建立重连逻辑还在后台反复触发。解决这类问题最通用的做法是给状态基类加上OnEnter和OnExit钩子让ChangeState统一调用void CallContext::ChangeState(std::unique_ptrCallState next) { if (!next) return; state_-OnExit(*this); // 退出当前状态先清资源 state_ std::move(next); state_-OnEnter(*this); // 进入新状态再分配资源 }这个顺序之所以重要是因为资源清理工作必须发生在“新状态接管之前”。如果你先切状态再调用旧状态的清理回调旧状态可能已经析构了一半回调里访问成员就直接悬垂了。很多莫名其妙的内存错误就是这么来的。4.3 多线程并发下的状态竞态网络模块里状态机通常要被多个线程触发网络线程上报连接结果UI 线程发出用户操作心跳线程上报超时。如果状态检查和转移不是原子的就会出现“两个线程同时读到同一个旧状态然后各自向不同新状态转移”的经典竞态。一个基本思路是给CallContext的公开事件入口加锁然后把真正的状态切换函数做成“无锁内部实现”void CallContext::PeerAnswered() { std::lock_guard lock(mutex_); state_-PeerAnswered(*this); }但这里有一个很隐蔽的死锁陷阱。如果state_-PeerAnswered()内部调用了ChangeState()而ChangeState()又试图加锁就会造成对同一个 mutex 的非递归加锁直接死锁。所以我的做法是锁只加在公开事件入口上ChangeState内部不再加锁并明确注释说明“调用方必须已经持有锁”。再激进一点也可以用任务队列把外部事件统一串行化状态机只在一个线程里运行完全不需要锁。这在高并发高要求场景下反而是更稳的方案。4.4 状态转移日志要集中不要在状态类里乱打状态机的调试和普通代码不太一样。普通代码出了问题可以看变量值状态机出问题你必须能回答“刚才从哪个状态收到了什么事件经过什么判断走到了哪个状态”。如果每个状态类各自打日志格式五花八门排查时根本拼不出完整链。我在实际项目里的做法十分简单所有状态切换必须经过ChangeState所以在ChangeState里统一打日志记录from - to、触发事件、理由字段。日志里尽量带上业务 ID比如通话 ID、设备 ID这样能快速定位是哪个实例的状态机出问题。日志格式参考[StateMachine] id789012 fromDialing toConnected eventPeerAnswered reasonnormal不要小看这行日志。状态机的问题很多时候就是“多个事件几乎同时到达状态转移顺序和预期不符”没有中心化的日志你连重现都无从谈起。4.5 头文件循环依赖C 代码里初写状态模式很容易陷入循环 includeCallState的接口需要CallContext的引用CallContext的成员需要持有CallState对象两个头文件互相 include 就会编译失败。解决方式很简单状态基类头文件里只前置声明CallContext不要在状态头文件里 include 上下文头文件。CallState::ChangeState的实现放到.cpp文件里那时再 include 上下文头文件。这样上下文可以 include 状态头文件状态头文件只认class CallContext;依赖方向就理顺了。继承状态类要在实现里调用ChangeState它们 include 状态基类头文件就够了也不需要 include 上下文头文件。5. 状态模式不是银弹什么时候该换别的状态模式确实好但它不是万能钥匙。随着项目演进你会遇到一些场景状态模式反而不是最优解。5.1 表驱动状态机更简洁的替代方案有一种情况我特别想提醒状态转移规则非常规律、基本是“查表”就能得出的场景。比如协议解析、通信握手状态和事件组合成一张完整的二维转移表每条表项还带着对应动作。此时用状态模式写 10 个类不如直接用一张静态表清晰。表驱动状态机的基本形态是这样的struct Transition { State from; Event event; State to; void (*action)(Context); }; Transition kTransitions[] { {State::Idle, Event::StartCall, State::Dialing, OnStartCall}, {State::Dialing, Event::PeerAnswered, State::Connected, OnPeerAnswered}, {State::Dialing, Event::Hangup, State::Ended, OnHangup}, // ... };查表的代码简洁规则一目了然修改转移规则只需要改一张表。它的弱点是每个状态如果都有非常复杂的独立逻辑放不进一个函数指针里表驱动就会很别扭。表适合“状态多但行为可函数化”的场景状态模式适合“状态多且每个状态都是深度业务逻辑”的场景。5.2 状态模式真正适合的场景打个比方状态模式适合的是“每个状态本身就是一套小型业务规则”的情况。比如播放器空闲状态只管加载播放中状态要管进度条、缓冲、倍速暂停状态要管缓存策略每个状态内部的行为差异非常大。这种情况下状态对象能承载更多的业务方法和成员数据你可以在状态类里写出“播放中状态收到缓冲事件时启动抖动检测”这种复杂逻辑。如果每个状态收到事件后都是“简单转移一下不做额外处理”那状态模式就有点杀鸡用牛刀了表驱动或者最简单的if/else反而更直观。我的个人选择依据大致是状态数量 5事件数量 5且状态之间存在明显的“行为差异化”使用状态模式状态转移规则可以被一张表干净地描述优先表驱动状态数量是 2 到 3 个未来也不会有扩展直接if/else别为了模式而模式。5.3 一个真实教训为了用模式而用模式我有一次接手一个跨平台客户端模块里面把所有业务都塞进了状态模式。仔细一看十几个状态类每个类的接口几乎都是空实现只有一两个方法有意义剩下的全在打日志。代码看着很“设计模式”实际改起来比switch还累。这给我的教训是状态模式的本质是“状态行为内聚”而不是“必须有 N 个状态类”。如果一个状态类的绝大多数事件处理函数都是空壳那它其实不是一个真正的状态而是一个没必要的过度设计。该合并的合并该用表驱动的换表驱动别让代码架构拖慢业务迭代。最后再分享一个我常年用的判断口诀当你在ChangeState里打出的日志能像讲故事一样说出“这个设备从连接中到已连接是因为收到了成功事件”那你的状态模式设计基本就是健康的。如果连你自己都要翻代码才能想清楚这一步为什么发生那不管用了什么模式架构都已经开始生病了。