☰
C++状态模式实战:三种实现方式与常见坑
2026/10/15 7:40:34 网站建设 项目流程

先抛一个很多 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::variant

2.1 枚举 + switch:状态模式的朴素形态

很多教程会把“枚举 + switch”也和状态模式混淆。实际上它只是状态模式要解决的问题本身,算不上完整的模式。但在 C++ 项目里,它不是不能用,而是有很强的适用边界。

如果你只有两三个状态,每个状态收到的事件也只有一两个,并且后续扩展可能性很低,那用枚举加 switch 反而最直接。你不需要搭建类层次,不需要考虑状态对象的生命周期,一个函数写完,性能还好。问题是这种方案天然把所有规则集中在一个函数里,状态一旦多起来,阅读和修改都会很痛苦。判断“要不要换状态模式”最简单的方法就是数一下:状态数 × 事件数,超过 10 种组合的时候,switch里的命中分支已经开始让人头疼了。

另外还有一个隐藏问题:集中分支里的状态转移逻辑很难复用。两个事件如果最终都走向同一个状态,你通常只能靠写重复的赋值代码或者小心设计函数流程,而这些重复最终会变成 Bug 温床。

2.2 经典多态实现:接口统一、行为内聚

这是教科书里标准的做法。抽象出一个State基类,定义所有事件的处理接口,然后每个状态写一个派生类。上下文保存一个std::unique_ptr<State>,每次收事件就调用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::variant<IdleState, 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_ptr<CallState> next); };

为了实现这个基类里受保护的ChangeState,需要引用完整的CallContext:

// call_state.cpp #include "call_state.h" #include "call_context.h" void CallState::ChangeState(CallContext& ctx, std::unique_ptr<CallState> 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_ptr<CallState> init); ~CallContext() = default; void StartCall(); void PeerAnswered(); void Hold(); void Resume(); void Hangup(); void PeerHungup(); void ChangeState(std::unique_ptr<CallState> next); const std::string& StateName() const; private: std::unique_ptr<CallState> 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_unique<ConnectedState>()); } void DialingState::Hangup(CallContext& ctx) { std::cout << "[Dialing] local hangup, moving to Ended\n"; ChangeState(ctx, std::make_unique<EndedState>()); } 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_unique<OnHoldState>()); } void ConnectedState::PeerHungup(CallContext& ctx) { std::cout << "[Connected] peer hung up, moving to Ended\n"; // 这里还可以做媒体资源清理等业务动作 ChangeState(ctx, std::make_unique<EndedState>()); }

ChangeState统一负责状态替换和名称更新:

// call_context.cpp #include "call_context.h" CallContext::CallContext(std::unique_ptr<CallState> 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_ptr<CallState> 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_ptr<CallState> 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] id=789012 from=Dialing to=Connected event=PeerAnswered reason=normal

不要小看这行日志。状态机的问题很多时候就是“多个事件几乎同时到达,状态转移顺序和预期不符”,没有中心化的日志,你连重现都无从谈起。

4.5 头文件循环依赖

C++ 代码里初写状态模式很容易陷入循环 include:CallState的接口需要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里打出的日志能像讲故事一样说出“这个设备从连接中到已连接,是因为收到了成功事件”,那你的状态模式设计基本就是健康的。如果连你自己都要翻代码才能想清楚这一步为什么发生,那不管用了什么模式,架构都已经开始生病了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询