资讯动态

C++状态机实现之道:从if-else到状态模式与std::variant

发布时间:2026/10/13 11:31:58 来源:尧图企业网站定制
1. 先聊一个真实的翻车现场状态逻辑全写在if-else里做C服务端和客户端开发的朋友十有八九都写过这样的代码一个对象有几种运行模式然后在每个方法入口用一串if/else判断当前处于什么状态决定能不能继续往下走。我早年在做一个通信模块时就写过这么一坨。// 伪代码回忆一下那种酸爽 void Connection::send(const Packet pkt) { if (status_ Status::kDisconnected) { log(未连接不能发送); return; } else if (status_ Status::kConnecting) { pendingPackets_.push_back(pkt); // 排队等连接完成 } else if (status_ Status::kConnected) { if (handshakeDone_) { socket_.write(pkt); } else { log(握手没完成先缓存); cache_.push_back(pkt); } } else if (status_ Status::kClosing) { log(正在关闭拒绝新消息); } }刚开始只有kDisconnected和kConnected两个状态一切都很美好。后来需求加了一个kConnecting再加一个kClosing再后来每个状态又套上了子条件比如handshakeDone_文件就开始失控。这时候最典型的感觉是每个成员函数都在重复回答同一个问题——“当前状态允许我做什么” 答案是分散的没有一处集中管理。想改一条状态转移规则得把所有方法的判断全翻一遍漏掉一处就是个隐蔽bug。更麻烦的是你根本没法从代码结构上看清整个对象一共有几种状态、哪些转移是合法的、每种转移触发什么动作。代码能跑但没人敢动。这种场景就是状态模式State Pattern的典型适用面。它不是C特有的东西但C写起来有一些非常值得注意的细节——多态、内存所有权、std::variant的取舍、状态转移时机。这篇文章我就用自己实际做过的东西来拆一拆从最傻的if-else版本开始走到经典状态模式再对比现代C的std::variant和表驱动方案最后聊几个实战中躲不开的坑。如果你正在维护一个状态多到开始失控的模块或者你想在下一个项目里把状态机写得更干净这篇应该能帮上忙。2. 状态机的基本盘状态、事件、动作和那张转移表进代码之前先把概念理一遍。我说“状态机”指的是状态机里最常用的那张图有限状态机FSM。一个FSM有四样东西绕不开状态State对象处于的稳定阶段。比如一个订单系统里的待支付、已支付、已发货、已取消。事件Event外部或内部触发的东西。比如用户点击“立即支付”、支付回调返回成功、超时等。动作Action状态转移发生时执行的逻辑。比如支付成功以后扣库存、发通知。转移Transition在某个状态下收到某事件满足某条件后从状态A进入状态B。在动手写代码前我强烈建议先把转移表画出来。不要嫌这个步骤啰嗦——我见过太多人跳过这一步直接开写结果写到一半发现转移关系理不清。当前状态事件条件目标状态动作待支付用户发起支付订单未过期支付中生成支付单支付中支付成功回调金额校验通过已支付减库存、发通知支付中支付失败回调-待支付记录失败原因待支付超时未支付-已取消释放库存锁已支付用户申请退款未发货退款中发起退款流程这张表的价值在于它把“规则”和“实现”分离了。评审代码的时候大家聊的是这张表落到代码里每个转移就是一目了然的实现单元。后续想加一个“已发货但用户拒收”的分支先在表里加一行再改代码比直接在if-else堆里插条件清晰一个量级。除了这套基本盘还有两个容易被新手忽略的概念进入动作entry action和离开动作exit action。我再举个例子。一个媒体播放器有停止、播放中、暂停三个状态。每次进入播放中你要做的事情是“开始计时统计、恢复音频输出、把UI按钮置灰”每次离开播放中你要做的是“记一下当前播放位置、停掉音频线程”。如果你把这些逻辑写在状态内部的普通方法里很容易出现“状态都切走了清理动作却没执行”的bug。把进入动作放在状态切换的公共入口里保证“进了这个状态一定执行这些动作离开这个状态一定执行那些清理”这是状态模式最值钱的好处之一。理解这张表是后面所有代码的基础。接下来我直接上代码。3. 经典实现用多态把判断逻辑拆到各自的类里状态模式的GoF经典做法很简单把“每个状态下的行为”抽象成一个接口每个状态一个类对象本身持有当前状态的指针把事件转发给当前状态对象去处理。我用一个订单模块来做例子。先定义事件和上下文。#include memory #include string #include iostream // 前置声明避免循环依赖 class OrderContext; // 对外的事件抽象。不同的状态关心不同的事件。 struct Event { enum class Type { Submit, // 用户提交订单 PaySuccess, // 支付成功 PayFailed, // 支付失败 Timeout, // 超时 Refund, // 申请退款 }; Type type; std::string payload; // 附加数据比如支付流水号 }; // 状态接口 class OrderState { public: virtual ~OrderState() default; // 处理事件。返回值表示是否发生了状态转移方便上层记日志。 virtual bool handle(OrderContext ctx, const Event ev) 0; virtual const char* name() const 0; };然后是上下文类它持有状态指针并对外提供统一的入口。class OrderContext { public: explicit OrderContext(std::unique_ptrOrderState state) : state_(std::move(state)) {} void handleEvent(const Event ev) { bool changed state_-handle(*this, ev); if (changed) { std::cout 状态切换: prevStateName_ - state_-name() \n; } } // 状态切换的唯一入口 void changeState(std::unique_ptrOrderState newState) { prevStateName_ state_-name(); state_ std::move(newState); } const char* currentName() const { return state_-name(); } private: std::unique_ptrOrderState state_; const char* prevStateName_ none; };这里有个关键设计changeState是状态对象调用的但状态对象不能持有“下一个状态”的直接引用而是返回给上下文由上下文来换。为什么因为状态切换往往要夹带动作——比如离开当前状态时的清理、进入新状态时的初始化。如果每个状态自己偷偷改掉ctx的状态指针这些动作就没地方安放了。把切换集中到changeState这一个点后面加日志、加审计、加网络同步都方便。接下来写三个具体状态。为了篇幅我只写待支付、支付中、已支付已取消和退款中是同理的。class PendingState; // 待支付 class PayingState; // 支付中 class PaidState; // 已支付 // 待支付状态下能做的事 class PendingState : public OrderState { public: bool handle(OrderContext ctx, const Event ev) override { switch (ev.type) { case Event::Type::Submit: { std::cout 订单提交成功进入支付流程\n; ctx.changeState(std::make_uniquePayingState()); return true; } case Event::Type::Timeout: { std::cout 订单超时自动取消\n; // ... 释放库存锁、写审计日志 ctx.changeState(std::make_uniqueCancelledState()); return true; } default: std::cout 待支付状态忽略事件\n; return false; } } const char* name() const override { return Pending; } };handle里用switch分事件每个事件分支做的事很纯粹判断合法性、执行动作扣库存、发消息、记日志、调用changeState转移。这个结构比最开始的 if-else 强在哪我自己的体会是三点每个状态的合法行为被圈在了一个类里。你再也不用从一个对象的几十个方法里翻状态判断了想知道支付中能干嘛直接看这个类的handle。新增状态不需要改老状态。加一个已冻结状态只需要新建一个类然后在需要转移的地方调用changeState其他状态类完全不用动。这符合开闭原则。状态内可以放自己的临时数据。比如支付中可以存一个payFlowId_成员这个数据只在支付中阶段有意义放上下文里反而浪费且混乱。当然经典状态模式也有让人头疼的地方。最直接的是类的数量爆炸状态一多每个状态一个类每个类一个handle文件数量蹭蹭涨。另外就是转移关系分散在各个状态类里——你没法从一个地方看到完整的转移表。想review状态机全貌得把每个状态的handle全读一遍才能拼出来。所以做项目时我一般看情况状态少五六个以内、每个状态的逻辑复杂各状态行为差异大时用经典状态模式很舒服状态多、转移关系复杂、但每个状态的行为很简单时我倾向用下面的表驱动方案。4. 更符合现代C口味的方案std::variant 表驱动C17 以后std::variant给了我们另一种看待状态的视角用类型本身表示状态。每个状态是一个类型当前状态就是一个存着这些类型之一的对象。配合std::visit我们可以把“当前状态下如何响应事件”写成对一组重载的函数对象。先看用std::variant怎么定义状态。#include variant #include string struct Pending { std::string orderId; }; struct Paying { std::string orderId; std::string payFlowId; }; struct Paid { std::string orderId; std::string payFlowId; }; struct Cancelled { std::string orderId; }; struct Refunding { std::string orderId; }; using OrderState std::variantPending, Paying, Paid, Cancelled, Refunding;注意每个状态可以携带自己的数据Paying有支付流水号Paid有支付完成时间戳或支付流水号。这些数据天然属于对应状态不需要塞进一个巨大的上下文结构体里。这一点和经典模式是相通的但写法更直白不需要为每个状态写一个类。然后是事件。事件也可以是一个std::variant但我习惯先用一个简单的结构体枚举因为事件往往带负载订单号、支付流水号、错误码。struct Event { enum class Type { Submit, PaySuccess, PayFailed, Timeout, Refund }; Type type; std::string payload; };接下来是核心用一个“转移表 主循环”的架构。转移表用一维数组或std::vector存每行表示一条合法转移。#include vector #include functional struct Transition { int from; // 用编译期索引表示来源状态 Event::Type event; int to; // 目标状态索引 }; // 用编译期索引帮我们拿 variant 的替代项序号 template typename... Ts constexpr size_t index_of() { // 用 C20 的 variant 下标辅助这里简化为手工定义 }为了让代码可读性更好我通常给每个状态定义常量索引// 0: Pending, 1: Paying, 2: Paid, 3: Cancelled, 4: Refunding constexpr size_t kPending 0; constexpr size_t kPaying 1; constexpr size_t kPaid 2; constexpr size_t kCancelled 3; constexpr size_t kRefunding 4;转移表const std::vectorTransition kTransitions { {kPending, Event::Type::Submit, kPaying}, {kPending, Event::Type::Timeout, kCancelled}, {kPaying, Event::Type::PaySuccess, kPaid}, {kPaying, Event::Type::PayFailed, kPending}, {kPaid, Event::Type::Refund, kRefunding}, };有了这张表处理事件的逻辑就变成查表 驱动动作class OrderMachine { public: OrderMachine() : state_(Pending{PO-001}) {} void dispatch(const Event ev) { size_t curIdx state_.index(); // 1. 查表当前状态 事件 找到目标状态 auto it std::find_if(kTransitions.begin(), kTransitions.end(), [](const Transition t) { return t.from curIdx t.event ev.type; }); if (it kTransitions.end()) { std::cout 非法转移被拦截: 状态 curIdx 遇到事件 static_castint(ev.type) \n; return; } // 2. 执行动作这一步可以把动作函数挂到 Transition 上 onAction(ev); // 3. 执行状态切换 state_ makeNextState(it-to, ev); } private: OrderState makeNextState(size_t target, const Event ev) { // 再调用一个辅助函数按目标索引构造新状态 } void onAction(const Event ev) { std::cout 动作: static_castint(ev.type) \n; } OrderState state_; };看到这里你可能会问那怎么调用状态特有逻辑经典模式是obj-doSomething()variant方案是std::visit(visitor, state_)。比如我想判断“当前订单能不能取消并发起退款”bool canRefund(const OrderState s) { return std::visit([](const auto st) - bool { using T std::decay_tdecltype(st); if constexpr (std::is_same_vT, Paid) { return st.payFlowId.size() 0; } else if constexpr (std::is_same_vT, Pending) { return true; } else { return false; // 其他状态不允许 } }, s); }std::visit的玩法很多配合继承式 lambda 能写得很干净核心思想是用类型匹配替代字符串或整数比较。编译期就能帮你检查状态类型省掉一堆运行时判断。我把经典多态方案和 variant 方案做个对比方便选型维度经典多态状态模式std::variant 表驱动状态数量适合少而复杂每个状态行为差异大适合多而简单转移关系复杂转移表可见性分散在每个状态类里不易全局查看一张表集中可见评审友好状态自带数据成员变量清晰variant 的替代类型更简洁编译器辅助虚函数调用类型安全一般类型安全最强不存在的状态组合编译不过新增状态新增类改相关转移新增类型改转移表心智负担类多要维护继承关系需要熟悉 std::variant / std::visit说实话这两个方案我都在生产项目里用过。如果是嵌入式或老项目C标准可能停在 C11/14那经典多态几乎是必须的新项目可以随便上 C17/20我会优先用 variant 方案它的类型安全太香了。5. 实战里最容易踩的四个坑代码写对了不代表能上线。状态机在实战中翻车通常不是状态模式本身的问题而是周围细节没处理好。我把这几年踩过的坑挑最重要的四个说一下。5.1 状态切换时机先执行动作还是先切状态这是新手最容易写错的地方而且后果很隐蔽。我举个例子支付成功事件进来正确顺序应该是校验事件合法性比如金额对得上执行进入新状态前的清理动作离开旧状态的动作执行转移把当前状态改为目标状态执行目标状态的进入动作发通知、写审计不少人把第4步写在第3步之前导致新状态还没建立动作已经开始访问新状态的数据轻则拿到空值重则崩溃。反过来有人把第2步漏了旧状态的资源没释放问题会在状态反复切换几次后集中爆发。我的经验是把所有状态切换统一收口到一个函数里进出动作放在这个函数内部固定执行不要在每个状态类里自己乱调。这样顺序永远正确也便于加日志。5.2 内存所有权谁拥有状态对象谁能替换它经典状态模式里最绕的是unique_ptr的所有权。我的例子中上下文持有一个unique_ptrOrderState状态的handle方法收到上下文的引用然后调用changeState把新状态 move 进去。这个设计要求状态对象自己不能持有上下文的裸指针或引用太久——否则状态被销毁后那个引用就悬空了。实战中我建议状态对象内部不保存上下文的引用只在handle(OrderContext, const Event)参数里临时使用。转移用的新状态在handle里构造好通过changeState转交所有权。如果状态确实需要回调上下文比如异步完成后再触发转移用std::weak_ptrOrderContext别用裸指针。5.3 并发事件可能在多个线程到达状态机的并发问题是重灾区。订单模块如果同时来了“支付成功回调”和“用户超时取消”两个线程同时读当前状态都认为自己合法然后都去转移状态就乱了。基础防护是给dispatch加锁void dispatch(const Event ev) { std::lock_guardstd::mutex lock(mutex_); doDispatch(ev); }但锁不是万能的。如果你在处理事件时执行了耗时操作比如发 HTTP 回调锁会被长时间占用其他事件全堵住。我的做法是状态转移和事件处理分离动作尽量异步。事件来了先校验、转移、把待执行动作放进队列由专门的线程去跑动作。这样锁的粒度小系统也响应快。另一个思路更彻底干脆单线程跑所有事件做成 actor 模型。C 里可以用队列加一个 worker 线程所有事件入队worker 串行处理天然无锁。代价是复杂度上来了小项目慎用。5.4 日志和可观测性无线索排查就是大海捞针状态机一旦出问题光靠“它崩了”这句话没法排查。你必须知道上一个事件是什么、当前状态是什么、它试图转移到哪、被谁拦截了。我在每个转移点都加了结构化日志至少包含EVENT orderIdPO-001 typePaySuccess payloadflow-123 TRANS from1(Paying) to2(Paid) allowedtrue ACTION deduct_stock skuA001 qty2日志格式统一后面接上监控系统就能自动生成状态流转图。很多线上问题靠这个一眼就定位了——比如“用户反复发起支付但状态一直停在支付中”看日志发现支付回调被支付中状态自己的超时判断给拦截了。6. 什么时候该用什么时候别硬上我自己的选型清单最后聊点掏心窝的话。状态模式不是银弹我在实际工作中见过不少强行上状态模式把简单代码写复杂的案例。先说什么时候该上状态数量 ≥ 4而且不同状态对同一事件的行为差异明显状态转移规则需要被统一评审、审计比如支付、订单这类强流程业务新增状态或新事件是高频需求不改老代码的要求很强一个事件的处理逻辑横跨多个状态用if-else已经出现“判断套判断”。再说什么时候别硬上状态只有两个比如开/关、连接/断开两三个布尔变量就能表达清楚状态很多但转移非常规整比如都是“下一步”线性推进那一个switch就够了你的同事都没接触过std::variant接手的维护成本高于当前的混乱成本——这是很现实的问题。说到底状态模式解决的是“状态判断逻辑分散”的问题而不是“状态本身复杂”的问题。把转移表先画出来看看状态和事件的关系再决定用什么实现。如果一个状态机连表都画不清楚用什么模式都是白搭。拿我这几年做订单、连接管理和播放器的经验总结一个值得长期维护的状态机项目从第一天就该有的三样东西一份集中的转移表、一个统一的状态切换入口、一套可检索的转移日志。有了这三样不管最后选了多态还是std::variant都不会歪到哪去。

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

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

免费获取报价 →
↑