做了十几年C++,状态模式是我在项目里用得最多、也见过最多花式跑偏的设计模式。很多人一提到状态模式,脑子里就是抽象类State、两个子类、Context里放个SetState,然后就没有然后了。这不叫高级应用,这只是把教材里的例题换了种语言抄了一遍。状态模式真正值钱的部分,是后面这些——状态对象和上下文怎么分权、转换关系由谁管理、并发事件进来怎么办、状态要不要带数据、状态能不能保存和恢复。这篇文章我想按工程落地的顺序把这些东西拆开,从设计边界讲到现代C++里基于std::variant的另一种写法,再放到真实项目里看它是怎么帮我们处理自动空调报文解析、设备重连、状态持久化的。中间穿插一些我个人踩过的坑和调试技巧,希望对你正在写的C++业务状态机有帮助。
1. 状态模式解决什么问题:从一堆if-else到可扩展的对象模型
1.1 这不是“把switch换成虚函数”这么简单
状态模式的核心意图,用一句话讲就是:允许对象在内部状态改变时改变自身行为,看起来就像换了类一样。这个描述很多人背得滚瓜烂熟,但用到实际工程里就容易失真。
最常见的错误理解是:既然有多个状态,那么用虚函数动态分派替代switch就行了。比如一个网络连接对象有Disconnected、Connecting、Connected三个状态,每个状态下收到连接事件的行为不同,于是就给每个状态建一个类。这个做法没错,但它只解决了“行为分派”,没有解决“状态迁移的合法性”“状态数据的归属”“多个事件源的并发排序”。项目一复杂,你很快就会遇到:状态类之间互相new来new去,A状态直接new B状态,C状态又能跳回A,整个状态关系变成一张蜘蛛网,新增一个状态要改五六个文件。
所以我在决定使用状态模式之前,一定会先画一张状态图。哪怕是用纸笔画,都会逼自己把状态、事件、迁移条件列清楚。状态模式本质上就是有限状态机(FSM)的面向对象映射。适用它的场景有几个硬指标:系统有有限且可数的状态集合;每个状态下对相同事件的响应不同;状态迁移由显式事件驱动;状态数量未来有增长可能。如果只有两三个状态而且几乎不变,直接用枚举加函数表就够了,不要为了设计模式而设计模式。
1.2 C++为状态模式提供了更宽的选型空间
C++这门语言最麻烦也最有趣的地方,就是同一个问题通常有好几种体面的解法。状态模式也不例外。
经典做法是继承多态,State基类定义事件处理接口,派生类实现各自行为,Context持有当前状态指针。这是教科书路线,胜在直观。
现代C++里还有另一条路线:用std::variant把状态存成值语义,通过std::visit编译期分派。这样没有虚表、没有堆分配,状态可以放在Context对象内,拷贝和移动都符合值语义,对并发场景反而更友好。
如果状态类型需要运行时动态注册,比如做成一个可配置的状态机引擎,我们还可以用函数指针表、std::function或者类型擦除来做分派。它不属于传统状态模式,但解决的是同一个问题。
这三条路线不是谁替代谁的关系。我的选择标准是这样:状态类型少且固定,用std::variant;状态类型多且高度可扩展,用继承多态;状态机退化成二维转换表但行为都集中处理,用函数表或转换表。下文展开的很多内容,本质上就是讨论这三种选型各自的边界和注意点。
2. 状态对象怎么设计才不算demo:状态与上下文的职责边界
2.1 先分清“上下文的接口”和“状态要触发的事件”
一个很容易写砸的写法,是让State类直接依赖具体的Context类。比如:
class AutoWndState { public: virtual void Handle(BigContext& ctx, const Event& evt) = 0; };这个抽象类一出来,BigContext里所有成员基本都得是public,否则状态类没法访问。结果就是状态类和上下文完全藕断丝连,改一个字段会牵连所有状态类,状态模式就沦为了“数据粉碎机”。
我实际项目里的做法,是给Context定义一个窄接口,状态只依赖这个接口,不碰内部存储。比如一个自动空调控制器,模式状态要切温度、切风量、切空气循环,那Context就暴露一组语义化接口:
class IContext { public: virtual ~IContext() = default; virtual void SetTargetTemperature(double deg) = 0; virtual void SetFanLevel(int level) = 0; virtual void SetAirCirculationMode(AirMode mode) = 0; virtual ModeId CurrentMode() const = 0; };状态类的Handle只操作这个接口,它既不知道目标温度存哪个成员,也不关心风扇驱动怎么实现。这样做的最大好处是状态和上下文可以独立测试,状态逻辑用MockContext就能跑通,不需要拉起一个完整的设备模型。代价是接口可能膨胀得比较快,所以要把接口按语义聚合,否则就又回到了一堆Getter/Setter。
状态对象应该做三件事:判断当前事件是否是自己关心的、触发上下文提供的业务操作、请求状态迁移。它不应该做的是:直接读写上下文内部成员、实现业务数据的具体存储、替其他状态决定下一步该干什么。
2.2 共享状态实例与带数据状态对象的取舍
一旦开始用状态模式,第一个生命周期问题就是:每个状态对象是new一个,还是复用同一个对象?
有些状态是“无记忆”的,它们不携带任何实例数据,行为只靠参数事件决定。这种状态完全可以用单例模式共享。比如自动空调的OffState,它不需要记住任何切换历史,所有上下文里的OffState行为都是一样的。每次迁移都重新new一个OffState纯属浪费。
共享状态实例的写法很常见:
class OffState final : public IState { public: static OffState& Instance() { static OffState inst; return inst; } void Handle(IContext& ctx, const Event& evt) override; };Context里保存IState*,切到OffState时就赋值为&OffState::Instance()。智能指针在这里反而多余,因为单例的生命周期是整个程序级。
但另一类状态必须带实例数据。比如重连状态RetryWaitState,它需要记录当前第几次重试、退避时间算到多少秒。这类状态就不能共享,必须每次迁移时创建一个带参数的新对象。我一般用unique_ptr持有当前状态,切换时:
ctx.ChangeState(std::make_unique<RetryWaitState>(ctx.RetryCount()));有人担心这样频繁new对象性能不行。实际上状态切换的频率远低于事件处理频率,绝大多数场景不需要过度优化。真到了性能敏感的时候,可以用对象池,但先测量再优化,别凭空焦虑。
还有一个容易忽视的点:如果上下文要保存不止一个状态(比如主状态加子状态),要明确所有权边界。我的经验是,同一时刻只有一个“当前状态”被Context持有,其他临时状态对象一旦被替换就销毁。复杂的子状态机制建议拆成嵌套状态机,不要在同一个Context里放一摞状态指针,那会让转移条件变成一团乱麻。
3. 转换逻辑放在哪里:转换表、守卫条件与并发控制
3.1 集中式转换表 vs 状态内部自己跳转
GoF经典写法里,状态迁移动作常常放在状态类内部。比如ConnectingState处理Connect事件时,调用context.TransitionTo(new ConnectedState)。这种写法的好处是直观,但坏处也很明显:状态类之间产生了直接依赖,A状态要知道B状态存在,B状态要知道C状态存在;未来加一个状态,要回头修改所有能跳到它的旧状态。
我更推荐在状态数量超过五个之后,引入集中式转换表。把“当前状态 + 事件 -> 目标状态 + 守卫条件 + 动作”放到一张表里,状态类只负责业务动作,迁移路由统一由状态机引擎处理。
转换表的数据结构可以用最简单的方式表达:
enum class StateId { Off, Auto, Manual, Defrost }; enum class Event { AutoPressed, ManualPressed, DefrostPressed, SensorFault }; struct Transition { StateId target; bool (*guard)(const IContext&) = nullptr; void (*action)(IContext&) = nullptr; };之后用一个查找表:
const std::map<std::pair<StateId, Event>, Transition> kTransitions = { {{StateId::Off, Event::AutoPressed}, {StateId::Auto, nullptr, &EnterAutoMode}}, {{StateId::Manual, Event::SensorFault}, {StateId::Off, &FaultGuard, &NotifyFault}}, };这种集中式的表有一个非常实际的好处:状态迁移关系一目了然,代码审查时扫一眼表就知道有没有非法迁移。配合日志,每一次事件到底走没走、为什么被拦,都能对得上。它还能让你把转换表本身抽出来,从配置文件加载,甚至用脚本工具校验“有没有某个状态永远无法到达”“有没有事件在某状态下被静默吞掉”。
集中式表不等于禁止状态类内部判断。某些局部逻辑非常明显时,状态内部直接处理并不违反原则。但涉及跨状态跳转,我尽量都收敛到转换表里。
3.2 并发状态转换:先锁状态还是先锁上下文
状态机一旦进了多线程环境,问题就变了。最常见的是两个线程同时向状态机投递事件:用户操作线程来一个“连接”指令,超时线程来一个“连接超时”。如果不做并发控制,两个Event可能同时进入转换逻辑,产生竞态条件。
我推荐的模式是单事件泵。所有外部事件先丢进一个队列,由一个专用状态机线程依次取出处理。这样状态机内部完全不用考虑锁,所有迁移天然串行。生产者只需要往队列里push,消费者循环等待事件。
std::queue<Event> eventQueue; std::mutex queueMutex; std::condition_variable queueCv;事件泵的好处不只是并发安全,它还能自然实现“事件延迟到空闲时处理”的效果。比如界面线程发来一个耗时操作,状态机线程可以先处理完当前迁移,再处理新的操作,避免重入。
如果某个系统实在无法引入专用线程,非要多个线程直接调用状态机方法,那就在Context层面加锁。加锁只有一个原则:持锁期间绝不调用外部业务回调,否则外部代码反调回状态机接口就会死锁。更稳妥的做法是持锁只做状态指针替换,动作都放到锁外,但这又会让迁移不是原子的。所以我的底线是:能用事件泵就用事件泵,不能就用粗粒度互斥锁并且严格约定回调不掉锁。用std::atomic做一个StateId标志只能解决“当前是哪个状态”的查询,解决不了迁移过程中的多步动作一致性问题,不要把它当成万能药。
4. std::variant 方案:没有继承的现代转译
4.1 用variant存储状态,用visit分发事件
C++17的std::variant让状态模式多了一个非常优雅的实现方式:把状态集合定义成一个variant类型,每个状态是一个结构体,事件通过std::visit匹配当前状态的类型。
一个典型例子,设备连接状态机:
struct Disconnected {}; struct Connecting { int retryCount; }; struct Connected { std::string sessionId; }; using ConnectionState = std::variant<Disconnected, Connecting, Connected>;Context里直接存ConnectionState,不再需要基类指针。迁移就是给variant赋一个新对象:
ctx.state = Connecting{0};处理事件时,可以用重载lambda逐状态处理:
std::visit(overloaded{ [](Disconnected& s, const ConnectCmd& e) { /* 启动连接 */ }, [](Connecting& s, const ConnectAck& e) { /* 建立会话 */ }, [](Connected& s, const ConnectAck& e) { /* 忽略 */ }, ... }, ctx.state, evt);这个写法有几个明显优势。第一,状态是有名字的类型,天然携带数据。第二,没有堆分配,没有虚函数,整个状态机可以完全内联。第三,编译器强制你覆盖所有“状态 + 事件”组合。如果你忘了一个组合,std::visit的重载解析会直接编译失败,而不是留到运行时。
但注意,std::visit要求所有组合都在同一个lambda集合里处理,组合数量等于状态数乘事件数。数量一多,代码就会膨胀。我在实际使用中一般只把variant方案用于状态数在五个以内的小型状态机,否则维护成本反而高。
4.2 variant状态的迁移、拷贝与存储
用variant之后,Context直接持有状态值:
class ConnectionContext { public: ConnectionState state = Disconnected{}; };因为variant是值语义,拷贝Context就会拷贝当前状态,这对状态保存和恢复特别友好。迁移也不需要管理裸指针,不存在悬挂问题。
但与继承方案对比,variant也有自己的短板。用表格总结一下:
| 维度 | 继承多态 | std::variant |
|---|---|---|
| 分派机制 | 虚表动态分派 | 编译期索引分派 |
| 状态扩展 | 新增子类,已有状态无需改动 | 要改variant定义和所有visit点 |
| 状态带数据 | 成员变量 | 成员变量 |
| 堆分配 | 通常需要new/unique_ptr | 无,值存储 |
| 空状态 | 可以用nullptr表示 | 用std::monostate |
| 强制组合处理 | 不强制 | 编译期强制 |
| 可读性 | 经典,团队易理解 | 需要理解overloaded技巧 |
所以两种路线不是谁取代谁的竞争关系。我的项目里经常混用:小状态机用variant,大状态机用继承加转换表。架构没有标准的唯一解,关键是清楚每种写法在何种数据规模和变化速率下会变得难维护。
5. 真实项目里的状态机:空调报文解析、重连状态机与状态持久化
5.1 解析U280187这类自动空调控制报文时的状态建模
热词里有个“U280187来自AC的自动空调控制状态”,这个对应的是车载或楼宇自动空调系统里的一种控制报文场景。空调控制报文除了温度、风速、空气循环模式这些字段以外,通常还有一个明确的状态字段,表示系统当前处于待机、自动、手动、除霜、故障保护等模式。如果拿到报文就写一串if去判断模式字段,字段一多,逻辑就糊在解析函数里了。
我处理这类问题时,会把报文处理拆成两个状态机。第一层是协议帧状态机,负责从字节流里识别帧头、累积长度、校验和,这一层解决“报文是不是完整且合法”。第二层是业务状态机,对应空调本身的模式状态。帧状态机输出“收到一帧完整报文”之后,解析出模式字段和空气循环字段,再作为事件投递给业务状态机。这样即使报文里同时携带温度、风速、循环模式,业务状态机也可以根据当前模式和事件做出控制决策。
两个状态机组合在一起,代码反而比一个大解析函数清晰。因为协议层状态机只关心帧格式,业务状态机只关心控制策略。后面如果需要支持新的空调型号,只需要扩展业务状态机的转换表或状态枚举,协议层解析不用动。
5.2 自动重连与设备握手失败:连接状态机的稳态与暂态
凡是做过设备对接的人,基本都遭遇过“连接设备失败”“与sahara模式握手失败”这类弹窗。这些场景就是状态机的主场。一个稳定的连接生命周期,我通常建模成几个状态:Idle(空闲)、Connecting(连接中)、Handshaking(握手中)、Connected(已连接)、RetryWait(等待重试)。
连接失败或者握手失败,不是无脑跳回Idle,而是进入RetryWait。RetryWaitState要记录重试次数,并根据退避算法算出下一次连接时机。退避通常用指数递增加随机抖动,比如第一次等1秒,第二次等2秒,第三次等4秒,最多到30秒。
状态机的超时事件是一个容易出问题的点。如果超时回调来自另外的线程,直接调用Context的迁移接口就会引入数据竞争。我的做法是让计时器到期后只往事件泵里塞一个Timeout事件,状态机线程收到事件后再做判断。这样连接失败产生的“失败”事件和超时产生的“超时”事件,都会放在同一个队列里排队处理,谁先谁后由事件到达顺序决定,逻辑不打架。
RetryWait不能无限重试,通常达到最大次数后进入一个Failure终态或者Idle,并触发“需要人工干预”的通知。这个终态的转换守卫条件就是retryCount >= maxRetries。转换表把这个条件写清楚,比在每个状态里写if更容易维护。
5.3 状态持久化与恢复:不能只存一个枚举
很多状态机项目跑着跑着都会遇到“断电重启后能不能恢复之前状态”的需求。比如空调控制器,用户上次设的是Auto模式、24度、内循环,下次开机希望记住这些参数。
有人图省事,只把当前模式ID存成枚举发到EEPROM,恢复时直接SetState(savedMode)。问题在于,模式ID只是状态对象的一个索引,状态里可能还有累积字段、重试次数、加解密上下文、协议缓冲区。只恢复索引会让设备从错误的状态继续工作。
我的习惯是给每个带数据的状态实现serialize/deserialize方法,Context保存一个“状态ID + 状态数据 + 版本号”的结构。恢复的时候不走“直接恢复到历史状态”这条路,而是更保守一点:先把设备恢复到离历史状态最近的合法初态,再把历史数据作为事件逐步喂回去,让状态机自己迁移到正确位置。
举个例子,系统断电前状态是Connected,但重启后网卡还没就绪,直接恢复Connected会让上层误以为链路在线。正确的做法是恢复到Connecting,然后主动触发一个Connect事件,等握手成功后再进入Connected。这个过程看起来多绕了一步,但能避免很多“恢复之后状态不一致”的线上问题。
6. 实操中反复踩到的坑与测试策略
6.1 状态爆炸与类数量失控,以及怎么压回去
状态模式用久了会过度建模。一个空调模式,本来“Auto + 内循环”和“Auto + 外循环”只是Auto状态里的一个空气循环子参数,有人却建了两个状态类:AutoInnerState和AutoOuterState。这么搞,状态数量会成倍增长,类文件爆掉不说,转换表也变得异常庞大。
应对方式很直接:把“真正的状态”和“状态内部的参数化差异”分开。AutoInner和AutoOuter不应该平铺成两个状态,而应该是一个AutoState,内部持有AirMode成员。事件触发时改成员,不切换状态类。还有一种情况是主从状态嵌套,比如“运行”状态下面还有“稳定运行”和“异常降级运行”,这时候用嵌套状态机比平铺所有组合更清晰。
判断标准只有一条:当两个“状态”的所有事件响应模式都相同、只是某些参数不同时,它们是参数化的同一个状态,不要拆成两个类。
6.2 转换动作的执行顺序与异常安全
状态迁移的代码执行顺序,我见过很多版本。有些团队喜欢“先切状态指针,再执行进入动作”,理由是动作可以拿到新状态。但这个顺序会让观察者或者重入事件看到不一致的中间状态。
我推荐一个固定顺序:先串行化(队列或锁),再查找转换表,执行离开旧状态的回调,检查守卫条件,执行迁移动作,更新当前状态,最后执行进入新状态的回调。
守卫条件为什么要放在离开动作之后?因为有些守卫条件需要依赖旧状态的一些现场数据,比如当前温度、当前重试次数,离开动作可能会先清理这些数据。当然这是设计取舍,但无论如何,顺序必须稳定,并且写进团队规范。
异常安全更要提前约定。C++的状态机回调一旦抛异常,状态指针可能已经更新,也可能没有,很容易处于不一致状态。我的做法是:状态机边界统一捕获异常,把异常转成一个Failure事件,再走正常迁移路线。这样能保证不变量不被破坏。如果项目允许异常跨越状态机边界,至少在迁移前把所有可能失败的准备动作都执行完,再提交状态变更,相当于“事务式迁移”。
6.3 测试状态机的正确姿势:状态覆盖、事件覆盖和随机模糊
状态机测试不能只测主路径。我见过太多项目只测“顺利连接成功”一条路,结果连接失败、超时重试、非法状态下乱按按键这些分支,全要看运行时运气。
更靠谱的方法是拿转换表生成测试矩阵。每一条合法迁移必须有一条对应测试,断言目标状态正确、动作执行了、进入回调被调。每一条非法迁移也应该测一次,断言状态没有变化、没有执行动作、错误事件被吞掉或有明确日志。守卫条件要单独测边界值,比如retryCount == maxRetries和retryCount == maxRetries - 1都测。
我还会给状态机写一个简单的模糊测试:随机生成事件序列灌进去,跑几百轮后检查不变量。不变量包括“状态始终是合法状态”“资源没有泄漏”“离开回调次数和进入回调次数匹配”。这套组合拳在协议栈和工业控制器项目里帮我抓过很多只在特定交错顺序下才出现的bug。
调试方面,我会在状态机引擎里加一个开关,把每条“当前状态/事件/守卫结果/目标状态”打印到日志。排查问题的时候直接看日志,不需要打断点就能复现迁移路径。如果某次迁移异常导致状态错乱,日志也能定位最后是从哪一步开始不对的。
做状态模式没有银弹。我这些年最大的体会是:状态图一定要先画清楚,再动手写代码。画图阶段多花一个小时,能省掉实现阶段两个晚上的调试。状态越多,越要把转换关系收敛到集中的表或结构里,别让状态类自己乱跳。至于用继承还是variant,看规模的稳定程度和团队习惯,两者都能很优雅,关键是别把状态机的边界揉进业务代码里。