资讯动态

C++状态模式从入门到实战:对象模型、切换安全与选型指南

发布时间:2026/10/15 7:57:02 来源:尧图企业网站定制
我最近重读设计模式里的状态模式脑子里浮现的是之前维护过的一个设备控制程序。那个程序的连接、配置、运行、错误几个状态全写在一个大类里肉眼可见的只有一堆枚举和 if-else每次加一个协议分支都要把整个文件重新审一遍。后来我把它完整改成状态模式才对这套模式在 C 里的落地方式有了真实体感。这篇文章就把我从“枚举加 switch”重构到状态对象再到解决切换线程安全和状态类爆炸问题的全过程写出来适合那些已经能画状态图、但一落到 C 代码就纠结“状态放哪、切换由谁触发、状态对象怎么管理”的读者。读完你不仅能直接抄到一个可编译的状态机骨架还能在真正动手前判断这套模式到底适不适合你的场景。1. 从一段失控的代码说起状态模式到底消灭了什么1.1 一个真实项目的膨胀过程最初那版代码看起来非常“简单”。一个枚举成员变量一个入口处理函数里面是巨大的 switchenum class DeviceStatus { Idle, Connecting, Configuring, Running, Error }; class DeviceController { public: void handleEvent(Event e) { switch (status_) { case DeviceStatus::Idle: if (e Event::StartConnect) { status_ DeviceStatus::Connecting; startConnect(); } break; case DeviceStatus::Connecting: if (e Event::Connected) { status_ DeviceStatus::Configuring; loadProfile(); } else if (e Event::Timeout) { status_ DeviceStatus::Error; notifyError(ErrorCode::ConnectTimeout); } break; // 后面还有 Configuring、Running、Error 一大段 } } private: DeviceStatus status_; int retryCount_; Timer connectTimer_; // 每个状态都有自己的临时变量全部堆在这里 };前两个版本确实快新增一个事件只需要多加一个 case 分支。但状态数涨到五个之后问题就出现了每个 case 里做什么操作、切换后要清理哪些临时变量只有在完整读过整个函数后才能判断。最难受的是那些“当前状态下不会出现、但别人不敢删”的分支因为它们在其他状态里可能是合法的。每个状态需要的临时数据也被迫平铺在控制器里连接超时计时器只在 Connecting 有用重试计数只在 Error 恢复时有用但它们全都在同一个作用域里导致构造函数里要逐个初始化析构时还要小心清理顺序。这就是状态模式要解决的核心问题让状态自己拥有自己的行为和数据而不是把所有的状态逻辑揉在同一个类里。一旦“当前处于什么状态”变成一个可以被替换的对象事件处理就从“遍历所有状态的 switch”变成了“让当前状态对象自己回答该怎么办”。1.2 “把状态变成对象”的第一次重构重构后的结构很直白一个状态接口几个具体状态类一个持有当前状态指针的上下文类。状态接口声明这个上下文在某个状态下收到事件时应该做什么。具体状态实现该状态下的事件响应并且决定下一个状态是谁。上下文只做两件事转发外部请求给当前状态以及提供一个“更换当前状态”的入口。设备控制器里的DeviceController::handleEvent从几十行 switch 缩成了三行转发void DeviceController::handleEvent(Event e) { state_-handle(*this, e); }而原来各 case 里的业务逻辑被分散到ConnectingState::handle、ConfiguringState::handle等各自的状态类中。第一次这样改完我的感觉是“原来那些纠缠在一起的分支现在终于有了明确的家”。状态模式并没有消灭逻辑它消灭的是“所有逻辑共享一个作用域”的混乱感。2. 一个可以跑的播放器示例教科书实现里没说的内存陷阱2.1 三个角色和它们的分工先说清楚每个角色的职责后面代码才不会看晕。PlayerState状态接口定义play、pause、stop三个操作。StoppedState/PlayingState/PausedState具体状态自己决定收到操作后做什么、要不要切换。Player上下文保存当前状态指针把外部请求转发给当前状态。教科书画这种类图时通常很轻松但落到 C 就会遇到一个绕不开的问题状态对象用什么所有权模型管理这一节我先把最容易想到的写法写出来然后告诉你它为什么会在实战中咬人。2.2 看起来最简单的写法在切换瞬间的析构风险先看一个常见的“朴素实现”#include iostream #include memory class Player; class PlayerState { public: virtual ~PlayerState() default; virtual void play(Player p) 0; virtual void pause(Player p) 0; virtual void stop(Player p) 0; virtual const char* name() const 0; }; class Player { public: Player(); void setState(std::unique_ptrPlayerState next) { state_ std::move(next); } void play() { state_-play(*this); } void pause() { state_-pause(*this); } void stop() { state_-stop(*this); } void showCurrentState() const { std::cout current: state_-name() std::endl; } private: std::unique_ptrPlayerState state_; };然后是三个状态类class StoppedState : public PlayerState { public: void play(Player p) override; void pause(Player p) override; void stop(Player p) override; const char* name() const override { return Stopped; } }; class PlayingState : public PlayerState { public: void play(Player p) override; void pause(Player p) override; void stop(Player p) override; const char* name() const override { return Playing; } }; class PausedState : public PlayerState { public: void play(Player p) override; void pause(Player p) override; void stop(Player p) override; const char* name() const override { return Paused; } };状态实现如下void StoppedState::play(Player p) { std::cout [Stopped] 开始播放 std::endl; p.setState(std::make_uniquePlayingState()); } void StoppedState::pause(Player) { std::cout [Stopped] 未在播放忽略暂停 std::endl; } void StoppedState::stop(Player) { std::cout [Stopped] 已经停止 std::endl; } void PlayingState::play(Player) { std::cout [Playing] 正在播放中 std::endl; } void PlayingState::pause(Player p) { std::cout [Playing] 暂停 std::endl; p.setState(std::make_uniquePausedState()); } void PlayingState::stop(Player p) { std::cout [Playing] 停止 std::endl; p.setState(std::make_uniqueStoppedState()); } void PausedState::play(Player p) { std::cout [Paused] 继续播放 std::endl; p.setState(std::make_uniquePlayingState()); } void PausedState::pause(Player) { std::cout [Paused] 已经暂停 std::endl; } void PausedState::stop(Player p) { std::cout [Paused] 停止 std::endl; p.setState(std::make_uniqueStoppedState()); }最后补上构造函数并跑一下Player::Player() : state_(std::make_uniqueStoppedState()) {} int main() { Player player; player.play(); player.pause(); player.play(); player.stop(); player.showCurrentState(); return 0; }这段代码在大多数编译器上能正常跑出预期输出。但请注意PlayingState::stop内部这一行p.setState(std::make_uniqueStoppedState());setState会执行state_ std::move(next)也就是说当前正在执行stop的那个PlayingState对象在stop返回前就被unique_ptr销毁了。这就是一个隐形的delete this场景。只要你在stop里切换状态之后还访问了this的任何一个成员或者再来一次虚函数调用就是未定义行为。我在自己的项目里第一次踩到这个坑时表现是状态切换后下一帧偶尔打印出“当前状态: 未知对象”。排查很久才发现切换后析构顺序和执行栈完全取决于编译器优化。教科书很少提醒这一点的原因是很多例子里状态对象是静态共享的而一旦把状态做成带数据的、每次进入都 new 出来的对象这个问题就避不开。2.3 工程里我更推荐的两套变形第一套延迟提交切换。外部接口调用状态方法时只记录“想切到哪个新状态”等状态方法完整返回后再真正替换指针。这样旧状态的析构发生在调用栈回到外部入口之后绝对不会在状态成员函数体内被销毁。class Player { public: Player(); void changeState(std::unique_ptrPlayerState next) { pendingState_ std::move(next); } void play() { state_-play(*this); commitPendingState(); } void pause() { state_-pause(*this); commitPendingState(); } void stop() { state_-stop(*this); commitPendingState(); } private: void commitPendingState() { if (pendingState_) { state_ std::move(pendingState_); } } std::unique_ptrPlayerState state_; std::unique_ptrPlayerState pendingState_; };状态内部就把setState改成changeState逻辑上几乎无差别但旧状态的析构被推迟到了安全点。对于“每个状态都有自己的私有数据”的场景这套写法最省心。第二套状态完全无状态化用静态实例避免内存问题。如果状态类本身不携带数据只负责行为逻辑那么让它成为单例是非常好的选择class StoppedState : public PlayerState { public: static const StoppedState instance() { static StoppedState s; return s; } void play(Player p) override; void pause(Player p) override; void stop(Player p) override; const char* name() const override { return Stopped; } };此时上下文可以改成保存裸指针class Player { public: Player() : state_(StoppedState::instance()) {} void changeState(const PlayerState next) { state_ next; } // play/pause/stop 转发逻辑不变 private: const PlayerState* state_; };这套写法的最大好处是切换状态只是一次指针赋值无分配、无析构、绝对没有delete this风险。缺点也很明显状态类不能保存“这次进入状态时的瞬时数据”。所以继续往下之前你需要想清楚数据到底放在状态里还是放在上下文里。3. 状态迁移逻辑放哪边内部迁移还是外部调度状态模式看起来只是三个角色的结构但真正设计时最容易吵起来的问题是两个状态之间怎么切换这段逻辑写在谁那里。3.1 方案A迁移动作全放在上下文让状态对象只暴露基础操作比如onPlay、onPause、onStop状态类内部完全不调用changeState。上下文收到事件后先问当前状态“你是什么状态”再根据一张迁移表决定新状态。void Player::play() { if (state_ StoppedState::instance()) { state_ PlayingState::instance(); } else if (state_ PausedState::instance()) { state_ PlayingState::instance(); } state_-onPlay(*this); }这种集中式写法的优点是迁移关系一目了然整个状态机的所有跳转都摆在上下文同一个函数里评审代码时对着状态图一行行核对即可。缺点是上下文要认识每个具体状态类状态越多这个分派函数就越臃肿而且状态对事件的响应不再是纯粹的“自己决定”封装性会弱一些。3.2 方案B迁移动作放在状态内部前面播放器例子属于这种每个状态自己决定“收到 play 后我切到哪个状态、收到 stop 后我切到哪个状态”。void PlayingState::pause(Player p) { p.changeState(std::make_uniquePausedState()); }优点是状态的内聚性最强新增一个状态时只需要写它自己的行为不太需要改动上下文。缺点是状态多了以后全局迁移关系被分散到十几个状态类里只看某个状态类的代码可以理解“它离开后去哪”但很难一眼看出整个状态机长什么样。3.3 我的选择标准我的经验法则很简单状态数量少于 5 个时用集中式状态数量超过 5 个且每个状态里都有独立的业务逻辑时用内部迁移。集中式适合“状态关系像一个固定表格”的产品内部迁移适合“同一个状态对不同事件响应差异巨大”的系统。还有一个现实考量如果团队里维护状态图的文档内部迁移反而更好用。因为状态图本身就是“从状态A到状态B”的箭头集合而每个状态类恰好就是图里的一个节点改一个节点只需要打开一个文件。反过来如果团队没有状态图迁移逻辑散在各处会变得很难梳理这时候宁可牺牲部分封装也要把迁移表集中到一棵树上。4. 实战前必须处理的三个问题4.1 对象爆炸与状态内存管理状态模式最大的代价是类数量激增。一个业务里如果有十个状态哪怕每个状态只有三四个方法也要写十个类、三四十个函数。很多“状态”其实只有行为差异、没有任何私有数据这种状态应该做成静态单例。我见过一些项目为了省事把所有状态都用new创建然后让上下文持有结果整个运行期到处是动态分配不仅慢还要小心翼翼地处理释放。实际上无数据状态用静态实例就够了上下文切换只需要比价一个指针。只有那些需要携带私有数据的“有状态状态”才值得用unique_ptr加延迟提交管理。4.2 状态之间怎么传数据状态 A 想去状态 B但 B 进入时需要 A 的一些计算结果最直接的做法是构造 B 时把数据传进去p.changeState(std::make_uniqueFailedState(errorCode));这很好但有代价如果这些数据反过来后续还要被上下文或其他状态读取最自然的方式其实是把共享数据放到上下文里。比如游戏 AI 里敌人坐标属于所有状态都能读取的信息那它就应该是上下文的一个成员。我的建议是一次性的、状态启动时需要的参数用构造函数注入跨状态共享的、随时会被外部更新的数据放上下文。这样既不会让构造函数参数列表失控也不会让上下文膨胀成一个上帝对象。4.3 用现代 C 给状态类减负状态类里大量重写虚函数很容易写出样板代码。几个实用技巧用override明确标记重写函数编译期就能抓住签名不一致的问题。空操作的虚函数可以在基类给默认实现而不是在每个状态里重复写空函数体。如果状态切换后需要做进入动作统一定义一个onEnter由上下文在提交切换后调用避免每个状态在构造函数里做不完整的初始化。这些细节不会改变状态模式的结构但能让十个状态类看起来整洁很多也让后来接手的人更容易找到差异在哪里。5. 和表驱动状态机、std::variant 方案怎么选5.1 表驱动状态机状态模式并不是 C 里状态机的唯一答案。很多项目里你会看到一张“状态-事件-动作”的表struct Transition { State from; Event event; State to; void (*action)(Actor); }; static const Transition transitions[] { { State::Idle, Event::Move, State::Moving, Actor::startMove }, { State::Moving, Event::Stop, State::Idle, Actor::stopMove }, // ... }; void dispatch(Actor actor, State current, Event e) { for (const auto t : transitions) { if (t.from current t.event e) { actor.currentState t.to; t.action(actor); return; } } }表驱动的好处是极其直观所有迁移都在一张表里审计方便甚至在产品需求变化时可以直接改数据文件而不动代码。坏处是如果某个状态的“动作”不是简单函数而是需要访问很多私有数据那要么把逻辑塞进函数指针里要么得单独维护一个动作注册表。它适合状态数量大、事件矩阵固定、动作粒度足够小的场景。5.2 基于 std::variant 的静态状态机C17 以后std::variant也成了状态机的常见武器。每个状态是一个结构体所有状态组成一个variantstruct IdleState {}; struct PlayingState { float startTime; }; struct PausedState { float pauseTime; }; using PlayerState std::variantIdleState, PlayingState, PausedState;每次处理事件时用std::visit对不同状态分支处理需要切换状态就返回一个新的variantvoid Player::play() { state_ std::visit([](auto s) - PlayerState { using T std::decay_tdecltype(s); if constexpr (std::is_same_vT, IdleState) { return PlayingState{ currentTime() }; } else if constexpr (std::is_same_vT, PausedState) { return PlayingState{ currentTime() - s.pauseTime }; } else { return s; } }, state_); }这种写法的最大优势是类型安全编译器保证你不需要虚函数的动态分发读取状态数据就像读普通结构体一样直接。缺点也很明显当一个visit分支里需要处理超过四五个分支时lambda 会变得很臃肿而且每个状态对象都保存在variant的栈空间里如果单个状态携带超大结构体variant的尺寸会取所有成员的最大值内存占用会比指针方案高。5.3 一张选型对照表方案适合状态个数迁移关系可读性每个状态的封装性额外内存/性能成本枚举 switch5 个以内差差零成本状态模式5 到 20 个中到高高虚函数调用 状态对象开销表驱动状态多、事件矩阵固定极高低查找表开销小std::variant5 个左右、逻辑清晰中中无虚函数但可能增大对象尺寸实际操作中我不会把状态模式当成默认选择。小于等于三个状态、逻辑又简单直接 switch 就够了五到十几个状态且行为差异明显状态模式最合适如果迁移关系像一张规整的矩阵表驱动胜出如果恰好每个状态就是几行结构体加一个 lambdavariant 会成为最轻快的选择。这几种方案之间是互补关系不是替代关系。6. 实战案例一个守卫 AI 的巡逻、警戒、攻击、逃跑6.1 需求拆解我用一个简化的守卫 AI 把前面所有的设计点串起来。需求很简单默认巡逻发现敌人进入警戒。警戒时敌人进入射程立刻攻击。攻击时敌人脱离射程回到警戒。攻击时自身血量过低逃跑。逃跑时敌人消失回到巡逻。这里状态之间需要共享的数据只有两个当前血量和敌人是否可见。所有状态都没有私有数据所以采用静态实例的方案最干净。6.2 核心实现先把基类和事件定义出来#include iostream class Guard; enum class GuardEvent { SpottedEnemy, EnemyLost, EnterRange, LeaveRange, HealthLow }; class GuardState { public: virtual ~GuardState() default; virtual void onEnter(Guard) {} virtual void update(Guard, float dt) {} virtual void onEvent(Guard, GuardEvent) {} virtual const char* name() const 0; };再定义四个状态和共享数据的守卫类class PatrolState; class AlertState; class AttackState; class FleeState; class Guard { public: Guard(); void changeState(const GuardState next) { pendingState_ next; } void update(float dt) { state_-update(*this, dt); commitPendingState(); } void handleEvent(GuardEvent e) { state_-onEvent(*this, e); commitPendingState(); } float health() const { return health_; } void setHealth(float h) { health_ h; } const char* currentName() const { return state_-name(); } private: void commitPendingState(); const GuardState* state_ nullptr; const GuardState* pendingState_ nullptr; float health_ 100.0f; };具体状态类每个状态只关心自己该处理的事件class PatrolState : public GuardState { public: static const PatrolState instance() { static PatrolState s; return s; } void onEvent(Guard g, GuardEvent e) override; const char* name() const override { return Patrol; } }; class AlertState : public GuardState { public: static const AlertState instance() { static AlertState s; return s; } void onEnter(Guard g) override; void onEvent(Guard g, GuardEvent e) override; const char* name() const override { return Alert; } }; class AttackState : public GuardState { public: static const AttackState instance() { static AttackState s; return s; } void onEnter(Guard g) override; void update(Guard g, float dt) override; void onEvent(Guard g, GuardEvent e) override; const char* name() const override { return Attack; } }; class FleeState : public GuardState { public: static const FleeState instance() { static FleeState s; return s; } void onEnter(Guard g) override; void update(Guard g, float dt) override; void onEvent(Guard g, GuardEvent e) override; const char* name() const override { return Flee; } };实现部分Guard::Guard() : state_(PatrolState::instance()) { state_-onEnter(*this); } void Guard::commitPendingState() { if (pendingState_ pendingState_ ! state_) { state_ pendingState_; state_-onEnter(*this); } pendingState_ nullptr; } void PatrolState::onEvent(Guard g, GuardEvent e) { if (e GuardEvent::SpottedEnemy) { std::cout [Patrol] 发现敌人进入警戒 std::endl; g.changeState(AlertState::instance()); } } void AlertState::onEnter(Guard g) { std::cout [Alert] 警戒中 std::endl; } void AlertState::onEvent(Guard g, GuardEvent e) { if (e GuardEvent::EnemyLost) { std::cout [Alert] 敌人消失回到巡逻 std::endl; g.changeState(PatrolState::instance()); } else if (e GuardEvent::EnterRange) { std::cout [Alert] 敌人进入射程切换到攻击 std::endl; g.changeState(AttackState::instance()); } } void AttackState::onEnter(Guard g) { std::cout [Attack] 开始攻击 std::endl; } void AttackState::update(Guard g, float dt) { // 省略伤害计算只演示状态流转 } void AttackState::onEvent(Guard g, GuardEvent e) { if (e GuardEvent::LeaveRange) { std::cout [Attack] 敌人脱离射程回到警戒 std::endl; g.changeState(AlertState::instance()); } else if (e GuardEvent::HealthLow) { std::cout [Attack] 血量过低逃跑 std::endl; g.changeState(FleeState::instance()); } } void FleeState::onEnter(Guard g) { std::cout [Flee] 开始撤退 std::endl; } void FleeState::update(Guard g, float dt) { // 撤退回血逻辑省略 } void FleeState::onEvent(Guard g, GuardEvent e) { if (e GuardEvent::EnemyLost) { std::cout [Flee] 成功摆脱敌人回到巡逻 std::endl; g.changeState(PatrolState::instance()); } }main 函数里跑一次int main() { Guard guard; guard.update(1.0f); guard.handleEvent(GuardEvent::SpottedEnemy); guard.handleEvent(GuardEvent::EnterRange); guard.update(1.0f); guard.handleEvent(GuardEvent::HealthLow); guard.handleEvent(GuardEvent::EnemyLost); return 0; }输出会依次是巡逻、发现敌人、警戒、进入射程、攻击、血量过低、逃跑、摆脱敌人、巡逻。整个流转过程里状态对象没有一次new没有一次delete切换只是指针赋值并且commitPendingState保证了每个状态方法返回后才真正替换当前状态。这种轻量方式在游戏客户端里跑一整场对局几乎没有额外开销。6.3 从这套精简实现里得到的实测结论我自己在实际项目里迭代过好几次状态模式最大的体会是初期的痛点大多不是“怎么写状态类”而是“状态对象的所有权”和“数据共享方式”没想清楚。静态实例加裸指针能解决绝大多数无数据状态带私有数据的状态就用延迟提交加unique_ptr跨状态共享的数据一律抬高到上下文。把这三条决策做完状态模式在 C 里的落地就会顺畅很多。另外一个容易忽略的小细节是进入新状态时最好统一走onEnter不要在构造函数里做初始化。因为静态实例模式下构造函数只执行一次而状态切换是无数次把“每次进入状态要做的动作”写进onEnter才能保证切回同一个状态时逻辑正确。我用这个守卫 AI 的例子调试时一开始把“打印进入攻击”放在了AttackState构造函数里结果第二次进入攻击状态完全没有输出才意识到这个问题。这套精简实现让我觉得状态模式的核心其实不是消灭 if-else而是把“会变的那部分”放在你能看见、能修改、能单独测试的地方。如果你也正在设计一个状态多变的功能希望这些细节能帮你少走一段弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑