做了一段时间C++业务代码之后,你八成会遇到过这样的情况:功能越加越多,状态切换散落在各种if/else和switch/case里,新增一个“状态”,得翻遍整个调用链才能确认影响范围。状态模式是经典答案,但教科书里的例子往往停在“定义接口、写几个子类、塞进Context”,真放到通信协议、游戏角色、客户端UI逻辑里,完全不够用。这篇文章不打算重复那种入门demo,而是把状态模式在C++里的高级玩法、选型逻辑、踩坑记录一次讲透。适合已经写过一些C++、知道虚函数和多态是什么,但想搞清楚状态机到底怎么落地、怎么扩展、怎么调优的人。
我会从状态模式的本质开始,接着聊设计思路和方案选型,再给三套可以直接抄的实现,最后用一个网络连接状态机的完整案例把前面的东西串起来,顺手把常见问题和性能调优一并整理给你。内容偏工程实践,代码会有,但更重要的是代码背后的取舍理由。
1. 为什么说状态模式在C++里值得玩得更深
1.1 状态模式的本质:把分支逻辑变成对象关系
很多人一开始接触状态模式,理解是“用多态替代switch”。这个理解没错,但太浅。状态模式真正的核心,是把系统当前所处的“情况”显式建模,然后让“每个情况下该怎么反应”这件事,不再散落在调用方的大脑里,而是沉到一个个独立的状态对象里。
举个例子。你写一个游戏角色,角色有站立、跑动、攻击、死亡四个状态。如果你在主循环里写:
cpp复制if (state == IDLE) { /* ... */ }
else if (state == RUN) { /* ... */ }
else if (state == ATTACK) { /* ... */ }
刚开始四个状态还好,等加了“受击硬直”“技能前摇”“翻滚无敌帧”之后,这个函数就会膨胀成一锅粥。而且更麻烦的是,每个状态能触发什么行为、能跳到哪个状态,这些规则全都揉在一起,别人改起来特别容易出错。
用状态模式之后,每个状态是一个类,状态之间的转移关系被封装在每个状态自己的处理函数里。调用方只需要问一句“当前状态,你遇到这个事件该怎么办”,状态自己回答“我转移成谁,顺带做哪些动作”。这就是对象关系替代分支判断的本质。
1.2 状态模式高级应用面对的三大挑战
我见过很多人学完状态模式之后,实际项目里一用就翻车,问题往往不在模式本身,而在三个隐藏挑战上:
第一,状态数量爆炸。真实的通信协议、UI流程、角色AI,状态动辄十几个甚至几十个。如果每个状态写一个类,类的数量和管理成本都会失控。
第二,转移条件复杂。很多状态不是“收到某个事件就转移”,而是要满足多个条件叠加,比如“技能在冷却中、目标在范围内、角色没有受击硬直”才能从站立转到攻击。这种条件逻辑放在哪里,本身就是个设计问题。
第三,执行时序和异步问题。现代C++程序里,网络消息、用户输入、定时器回调往往在不同线程触发,状态机的handleEvent可能被并发调用,状态内部可能还要等待异步结果才能转移。很多手写的状态机在这里直接崩给你看。
另外一个C++特有的问题:状态对象怎么管理资源。一个状态进入时要打开文件/获取锁/订阅事件,退出时要清理,用RAII可以把这个生命周期问题解决得比别的语言优雅得多,但前提是你会用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心设计思路与方案选型
2.1 状态行为接口到底该定义成什么样
这是设计状态机时第一个要拍板的问题。我常用的接口有三个钩子:
onEnter(EventContext& ctx):进入状态时执行,负责初始化、订阅、播放动画等。onExit(EventContext& ctx):离开状态时执行,负责清理、释放资源、取消订阅。handleEvent(EventContext& ctx, const Event& evt):处理具体事件,根据事件类型决定是否转移、是否保持,返回转移意图。
为什么把进入和退出单独拆出来?因为很多bug就是状态的“现场”没收拾干净。比如网络连接状态机里,从“连接中”切到“已连接”,如果在onEnter里注册了超时定时器,那退出时onExit必须把它取消,否则定时器回调会在你切到别的地方之后继续触发,产生幽灵事件。拆出onEnter/onExit,是一种强制约束,逼你在状态切换的边界处理事情。
接口里要不要传Context?我强烈建议传。因为状态机不是一个孤岛,它需要访问外部依赖,比如网络服务、渲染器、配置项。直接把Context传进去,让状态对象自己取自己需要的东西,比给每个状态构造函数塞十几个依赖要干净得多。
2.2 状态转移描述:显式转移还是转移表
状态转移逻辑有两条实现路线:一条是把转移规则写在状态类里,叫显式转移;另一条是抽出一张“当前状态-事件-目标状态”的转移表,交给一个核心引擎去查。
显式转移的优点是规则局部化,代码读起来就像讲故事:
cpp复制void IdleState::handleEvent(Context& ctx, const Event& evt) {
if (evt.is<RunEvent>()) {
ctx.transition<RunState>();
}
}
这样每个状态自己知道自己能跳到哪,改动一个状态的逻辑不会惊动其他状态。缺点是状态特别多的时候,整个系统的全局转移关系被打散了,想一眼看清“所有路径”比较难。
转移表恰恰相反,它把规则集中到一张表里:
- 行是当前状态,
- 列是事件,
- 单元格是目标状态(以及可选的动作)。
我通常在协议解析、通信会话这类状态多、事件类型固定的场景里用转移表,因为这类系统的状态迁移路径需要经常审计。比如“收到FIN包之后,无论当前是ESTABLISHED还是CLOSE_WAIT,都转移到CLOSING”,这种规则用表一查,比翻代码快得多。
转移表的价值不仅在于集中管理,还在于它可以被数据驱动。我曾经把一张状态转移表直接用constexpr数组写死,编译期就能检查有没有“重复绑定”或“未知状态”,这个后面在实现部分展开。
2.3 状态实例的生命周期:单例、常驻还是动态创建
状态对象本身怎么存,是很多新手完全没考虑过的问题。我见过三种做法,各有适用场景。
第一种,全局单例。状态对象不携带自己的私有数据,所有状态量都存在Context里。这种玩法适合纯逻辑状态机,比如角色AI的“攻击”状态,本身不需要保存“连击段数”这种临时值。优点是零分配,进入状态不需要new,性能好。缺点是一旦状态内部需要独立数据,就非常别扭。
第二种,Context内部持有所有状态实例,预先创建好。适用于每个状态都是轻量对象、状态总数有限的情况。进入状态时只是把指针切换一下。这种方法比全局单例安全,每个Context可以有自己的状态实例副本。
第三种,动态创建。每次进入状态时new出对应对象,退出时delete。灵活性最高,因为可以按需给状态构造参数,但代价是要处理内存分配,还要小心异常安全。我一般只在状态数量大且数据彼此独立的场景用。
实际项目里,我常用的是“状态对象模板 + 局部数据放Context”的折中方案。具体来说,State对象本身是常驻的、无状态的逻辑壳,所有随状态变化的数据放在Context的成员里。这样既避免了new的麻烦,又保留了状态对象的封装性。
2.4 事件与参数的类型安全传递
事件是状态机的燃料。最简单的做法是定义一堆事件类,所有事件继承自基类。但这样传递参数很麻烦:std::shared_ptr<Event> 拿到底层类型还得到处dynamic_cast。
C++17之后,我更喜欢用std::variant来表达事件集合。比如:
cpp复制using Event = std::variant<IdleEvent, RunEvent, AttackEvent, HitEvent, DeadEvent>;
状态在handleEvent里通过std::visit或者std::get_if来精确匹配事件。编译器帮你保证你处理的事件都是已知集合里的,不会处理一个莫名其妙的未知事件。这比一堆dynamic_cast安全可靠得多。
同时,参数可以直接放进事件类型里。比如RunEvent里可以带一个speed字段,AttackEvent里带一个targetID字段,类型安全且意图清晰。
顺带说一句,回调函数和状态机的结合也值得留意。状态机内部动作如果依赖某个外部模块的结果,可以用std::function把回调传给状态,在回调里再触发新事件。但这容易陷入“回调套回调”的嵌套地狱,后面协程方案里我会给出更现代的处理方式。
3. 三种主流高级实现与代码实例
3.1 经典多态状态机:适合游戏角色AI
先给一套最经典的多态实现,用一个简单的小游戏角色来演示。这个写法虽然保守,但胜在直观,适合团队协作时所有人都能看懂。
状态接口定义如下:
cpp复制#include <memory>
struct EventContext {
// 角色当前生命值、坐标、所属场景等引用
};
class StateMachine;
class State {
public:
virtual ~State() = default;
virtual void onEnter(EventContext& ctx) {}
virtual void onExit(EventContext& ctx) {}
virtual std::unique_ptr<State> handleEvent(EventContext& ctx, int eventId) = 0;
};
handleEvent返回一个unique_ptr,如果为nullptr表示保持当前状态;如果非空,状态机负责切换。让状态自己返回新状态对象,而不是自己直接改状态机的当前指针,是为了把切换动作集中在状态机里,方便打印日志和做断言检查。
接下来是Context:
cpp复制class StateMachine {
public:
void update(EventContext& ctx, int eventId) {
auto next = current_->handleEvent(ctx, eventId);
if (next) {
current_->onExit(ctx);
current_ = std::move(next);
current_->onEnter(ctx);
}
}
private:
std::unique_ptr<State> current_;
};
注意这段代码里,update的顺序是关键:先处理事件获得下一个状态,再退出旧状态,再进入新状态。很多半吊子实现把onEnter写在handleEvent里,导致进入和退出顺序乱掉,状态里的资源清理就很难受。
实际写角色状态时,我会把“事件id”换成枚举甚至std::variant。枚举简单,variant更安全。我用枚举演示:
cpp复制enum EventId {
EVENT_IDLE,
EVENT_RUN,
EVENT_ATTACK,
EVENT_HIT,
EVENT_DEAD
};
然后每个状态自己决定返回什么:
cpp复制class IdleState : public State {
public:
std::unique_ptr<State> handleEvent(EventContext& ctx, int eventId) override {
switch (eventId) {
case EVENT_RUN:
return std::make_unique<RunState>(/*speed=*/ 10);
case EVENT_ATTACK:
return std::make_unique<AttackState>();
case EVENT_HIT:
return std::make_unique<HitState>();
case EVENT_DEAD:
return std::make_unique<DeadState>();
default:
return nullptr;
}
}
};
这套代码里有一个隐患:RunState的构造参数不能再从事件参数里拿,因为事件id只是个int。改进方式就是改用std::variant传事件对象,这是我在实战里更常用的方式。
3.2 基于 std::variant 的静态分派状态机
多态方案有虚函数开销,且每个状态都要new一个对象,频繁切换时会频繁分配内存。如果你对性能敏感,或者状态机运行在嵌入式、高频游戏循环里,可以试试基于std::variant的实现。
思路是用std::variant<IdleState, RunState, AttackState, ...>表示当前状态,状态不再多态,而是普通值类型。状态切换时通过std::visit分派事件:
cpp复制template <typename... States>
class VariantStateMachine {
public:
template <typename Event>
void handleEvent(EventContext& ctx, const Event& evt) {
std::visit([&](auto& state) {
state.handleEvent(ctx, evt);
}, current_);
}
std::variant<IdleState, RunState, AttackState> current_;
};
这里state.handleEvent是个模板函数,它可以通过std::get获取目标状态并修改current_。因为variant的visit是编译期分派,没有虚函数调用,而且状态对象直接存在栈上,不涉及堆分配,性能很可观。
需要注意,这个方案的状态间转移需要一种“原位替换”的技巧。比如IdleState::handleEvent想切换到RunState,因为当前状态正在被visit,改variant时需要小心自赋值。C++17之后可以用emplace:
cpp复制struct IdleState {
template <typename Event>
void handleEvent(EventContext& ctx, const Event& evt, std::variant<IdleState, RunState, AttackState>& current) {
if constexpr (std::is_same_v<Event, RunEvent>) {
current.emplace<RunState>(evt.speed);
}
}
};
实现细节有点绕,但封一层之后,调用方用起来非常舒服。这个方案最大的问题是代码类型够多时编译期开销大,以及状态间转移的写法比较繁琐。适合状态集固定、后续不太会膨胀的场景。
3.3 协程状态机:把异步状态转移写成同步代码
C++20的协程给状态机带来了一个全新思路,尤其适合需要等待异步结果的状态流。比如“连接中”状态需要等待一个网络回调,传统写法要么把回调扔给状态对象,要么在事件循环里注册监听;协程可以让你把状态过程写得像同步代码。
举个例子,网络连接的状态机:
cpp复制task connect_flow(Connection& conn) {
co_await conn.connect_async();
if (!conn.is_connected()) {
co_await retry_delay();
co_await connect_flow(conn);
}
co_await conn.handshake_async();
state = ConnectionState::ESTABLISHED;
}
协程本质上是一个可暂停的函数,编译器帮你维护状态栈。用协程描述状态机时,“状态”就是执行到的某个断点,“事件”就是协程恢复时的数据。这种写法对异步状态转移特别友好,不需要手动保存一堆回调闭包。
不过协程方案不是万灵药。第一,协程状态机的每个状态不是一个独立的对象,状态内部逻辑写在同一个函数的不同断点里,状态一多,函数会很长。第二,协程恢复有栈帧恢复成本,如果你的状态在同一个循环里高频切换,这种方案未必比variant方案快。第三,调试协程比调试普通函数复杂,至少目前的主流调试器对协程支持还是一般般。
我个人的习惯是:把协程用在流程型状态机(比如登录流程、握手流程、文件上传流程),这些场景天然是顺序执行+等待外部结果。把variant方案用在高频事件驱动的状态机(比如游戏角色、按键输入响应),各取所长,不要指望一个方案通吃。
4. 从理论到落地:一个完整的状态机实操案例
4.1 案例背景:网络连接状态机
为了让前面的设计思路落到代码上,我拿一个实际场景来走一遍整体流程:Linux下用C++做UDP通信的客户端会话管理。虽然UDP本身没有连接概念,但业务上我们往往会模拟一个“连接会话”,包含空闲、连接中、已连接、重连中、关闭五个状态。事件包括:发起连接请求、收到服务端确认、收到数据、连接超时、收到断开通知、用户主动关闭。
这个状态机的难点不是状态定义,而是“超时”这个事件怎么触发。它在状态机的考虑范围内,但触发源来自定时器,意味着状态机的handleEvent会被定时器线程调用,这就要考虑线程安全。为了把问题讲透,我先把状态和转移规则设计出来:
| 当前状态 | 事件 | 目标状态 | 动作 |
|---|---|---|---|
| Idle | ConnectRequest | Connecting | 记录开始时间,发起UDP握手包 |
| Connecting | AckReceived | Established | 记录RTT,通知上层连接成功 |
| Connecting | ConnectTimeout | Reconnecting | 重试次数加1,发重连请求 |
| Established | DataReceived | Established | 交给上层处理数据 |
| Established | PeerClosed | Closing | 发送关闭确认包 |
| Established | UserClose | Closing | 发送关闭请求包 |
| Reconnecting | AckReceived | Established | 记录RTT,恢复会话 |
| Reconnecting | ConnectTimeout | Reconnecting | 重试次数加1,检查是否超限 |
| Reconnecting | CloseRequest | Closing | 放弃重连 |
| Closing | CloseAck | Idle | 清理资源,通知上层会话结束 |
| Closing | CloseTimeout | Idle | 强制结束 |
这张表可以直接用于审计,也可以用代码写成转移表。为了可读性,我倾向于把转移规则放在各个状态的handleEvent里,同时保留一个generateStateDiagram的调试函数,把状态转移关系打印出来。
4.2 状态定义与状态对象设计
先定义事件类型,这里我用std::variant来保证类型安全:
cpp复制struct ConnectRequest {};
struct AckReceived { double rtt; };
struct DataReceived { std::vector<uint8_t> payload; };
struct ConnectTimeout {};
struct PeerClosed {};
struct UserClose {};
struct CloseAck {};
struct CloseTimeout {};
using SessionEvent = std::variant<
ConnectRequest, AckReceived, DataReceived, ConnectTimeout,
PeerClosed, UserClose, CloseAck, CloseTimeout>;
状态接口用前面说的经典方式,但事件改用SessionEvent:
cpp复制class SessionState {
public:
virtual ~SessionState() = default;
virtual void onEnter(SessionContext& ctx) {}
virtual void onExit(SessionContext& ctx) {}
virtual SessionStatePtr handleEvent(SessionContext& ctx, const SessionEvent& evt) = 0;
};
SessionStatePtr是std::unique_ptr<SessionState>的alias。这里不用std::shared_ptr,是因为状态对象的所有权唯一,状态机持有当前状态就够了。
SessionContext存会话级数据,例如重试次数、RTT统计、定时器句柄、UDP socket引用:
cpp复制class SessionContext {
public:
int retryCount = 0;
double rtt = 0.0;
TimerHandle timer{};
UdpSocket& socket;
SessionEventQueue& eventQueue; // 线程安全事件队列
explicit SessionContext(UdpSocket& sock, SessionEventQueue& q)
: socket(sock), eventQueue(q) {}
};
4.3 关键代码实现
下面实现几个关键状态。先看IdleState和ConnectingState:
cpp复制class ConnectingState : public SessionState {
public:
void onEnter(SessionContext& ctx) override {
ctx.retryCount = 0;
// 启动超时定时器,超时后向事件队列投递ConnectTimeout
ctx.timer = startTimer(3000ms, [&queue = ctx.eventQueue] {
queue.push(SessionEvent{ConnectTimeout{}});
});
}
void onExit(SessionContext& ctx) override {
stopTimer(ctx.timer);
}
SessionStatePtr handleEvent(SessionContext& ctx, const SessionEvent& evt) override {
if (const auto* ack = std::get_if<AckReceived>(&evt)) {
ctx.rtt = ack->rtt;
return std::make_unique<EstablishedState>();
}
if (std::get_if<ConnectTimeout>(&evt)) {
ctx.retryCount++;
return std::make_unique<ReconnectingState>();
}
return nullptr;
}
};
这里有个非常重要的点:onEnter里启动定时器,onExit里取消定时器,保证超时事件不会在状态切换之后继续生效。这正好呼应前面说的“状态切换边界处理”。
为了减少重复代码,IdleState::handleEvent可以写得更简洁:
cpp复制class IdleState : public SessionState {
public:
SessionStatePtr handleEvent(SessionContext& ctx, const SessionEvent& evt) override {
if (std::get_if<ConnectRequest>(&evt)) {
return std::make_unique<ConnectingState>();
}
return nullptr;
}
};
状态机Context负责切换,并做基本校验:
cpp复制class SessionStateMachine {
public:
void handleEvent(SessionContext& ctx, const SessionEvent& evt) {
auto next = current_->handleEvent(ctx, evt);
if (next) {
current_->onExit(ctx);
current_ = std::move(next);
current_->onEnter(ctx);
lastEvent_ = &evt;
}
}
private:
SessionStatePtr current_ = std::make_unique<IdleState>();
const SessionEvent* lastEvent_ = nullptr;
};
这里我把lastEvent_保留下来,是为了调试时能打印最近的事件。你还可以加一个visitCount_统计,防止某个状态在循环里疯狂切换导致死循环。
4.4 状态机的扩展:超时重试、日志与自检测
上面的状态机已经能工作,但到了真实项目里,还需要补几个实用功能。
第一个是重试次数限制。ReconnectingState里每次超时要检查ctx.retryCount是否超过上限,如果超了,就转移到ClosingState而不是继续重连。这个逻辑写在哪儿都行,但一定要放在状态内部,别让上层逻辑去判断,否则状态机的封装就没有意义。
第二个是状态日志。状态切换时打印“Idle -> Connecting”,以及携带的事件参数(比如RTT值)。日志的格式要统一,方便事后用grep分析。我在实际项目里会把状态切换日志单独打到独立文件,线上排查时按时间戳过滤非常方便。
第三个是自检测。如果你用的是静态转移表,可以在程序启动时跑一个遍历测试,把所有“状态-事件”组合都喂给状态机,检查是否有未处理事件导致状态机卡死。静态转移表天然方便这种测试,因为每个组合都是可枚举的。
5. 常见问题排查与性能调优实录
5.1 状态泄漏与非法切换如何定位
状态机最经典的错误就是“未处理的事件被吞掉”。比如DataReceived在Connecting状态下到达,没有任何状态处理它,状态机就原地不动,看起来像卡死了。这种问题在枚举+switch的写法里很容易被忽略,因为编译器不会警告你漏了case。
我的排查思路分三步:
- 在状态机基类里加一个默认的
handleEvent实现,返回nullptr之前记录warning日志,把“当前状态+收到的事件”打出来。 - 用单元测试把所有“状态-事件”组合跑一遍,明确哪些组合是合法转移、哪些组合是非法但允许忽略、哪些组合是错误必须报出来。这个测试表本身就是设计文档。
- 在状态切换时用
assert检查新状态是否合法。比如不允许从Closing直接跳到Connecting,如果发生,一定是上层事件投递错了。
用静态转移表的话,可以在编译期做一个合法性枚举。比如用boost::mp11或手写模板遍历,但一般项目里没必要这么卷,运行时断言+日志已经能解决九成问题。
5.2 多线程下的事件投递:锁、队列与无锁
网络程序里,状态机的handleEvent经常不是同一个线程调用的。比如socket线程收到数据包后投递DataReceived,定时器线程投递ConnectTimeout。如果直接在多个线程里调用状态机方法,状态内部的成员变量会有数据竞争。
稳妥的方案是“串行化事件源”。我在前面代码里用了SessionEventQueue,这个队列的职责就是让所有事件先进队列,由一个专门的线程或者事件循环来消费,保证同一时刻只有一个线程在跑handleEvent。这是最常见的做法,即便不追求极致性能,也别直接加一把大锁包住整个状态机内部逻辑,因为锁的粒度太大,很容易把系统的吞吐量拖垮。
如果要追求更高性能,可以考虑无锁队列(比如boost::lockfree::queue)来做事件缓冲。不过无锁队列的ABA问题、内存回收、容错都是额外负担,我在C++项目里默认不会“为了无锁而无锁”。先量一下延迟和吞吐,不够再说。
值得额外提一嘴的是:定时器事件尽量不要在定时器线程里直接驱动状态机,而是投递事件到队列。否则定时器线程可能在状态机处理其他事件的同时修改状态,很难复现、很难调试。
5.3 调试状态机的几个实用技巧
调试状态机比调试普通函数麻烦,因为它“跳来跳去”。我试过比较有效的方法包括:
- 状态切换打印到独立日志文件,附带毫秒级时间戳和事件摘要。线上发生问题,拉日志就能还原“什么时候从什么状态变成了什么状态”。
- 做一个“状态轨迹”记录器,把最近几十次切换记录下来,崩溃时随崩溃日志一起dump出来。这个类似飞机黑匣子,处理诡异bug时价值巨大。
- 如果状态转移规则有可视化需求,可以把状态转移表导出为文本格式,比如图形描述语言,再用工具渲染出来。注意不要直接依赖某一种在线服务,自己写个脚本导出即可。
另外,我强烈建议在状态机里加一个“当前状态名称”的字符串函数,每个状态返回自己的人类可读名称。抛异常、打日志、监控上报都能用上,写起来虽然枯燥,但等你在监控面板上看到一个Unknown状态的报警时,就知道这步有多划算。
5.4 性能细节:虚函数、variant与协程的取舍
最后聊性能。状态机本身不是热点就是边界,但如果你把它写在高频循环里,性能差距会体现出来。
三种实现方式的对比大致如下:
| 实现方式 | 单次事件分派成本 | 内存分配 | 可读性 | 适合场景 |
|---|---|---|---|---|
| 多态状态机 | 虚函数调用 + 状态对象可能new | 状态切换时可能分配 | 好,每状态一个类 | 状态数有限、需多人协作 |
| std::variant方案 | 编译期分派,几乎零开销 | 无堆分配,状态存栈上 | 一般,转移写法繁琐 | 高频事件、状态集合固定 |
| 协程状态机 | 恢复有栈帧成本 | 可能涉及协程帧分配 | 好,流程型代码直白 | 异步等待型流程、低频切换 |
如果你确定状态机的调用频率是每秒几百万次,优先考虑std::variant方案,因为虚函数分支预测失败的开销和内存分配的开销在高频下会被放大。如果你需要的是清晰可维护的异步流程,协程方案是首选。对于不追求极致性能的普通项目,经典多态方案足够,而且更好维护。
我踩过一个具体的坑:一开始在游戏角色状态机里用了std::make_unique来创建状态,每次切换都new/delete,结果在大量角色同时切换攻击状态时,场景帧率直接下降。后来改成“预创建所有状态实例,切换时只换指针”,帧率就回来了。所以哪怕用了多态,也要注意状态对象的分配频率,不能天真地每次都创建新对象。
还有一个小细节:事件对象如果携带大数据(比如DataReceived里面有几百K字节的payload),投递事件时要注意拷贝成本。优先用std::unique_ptr或者std::shared_ptr包一层,避免把payload反复拷贝进队列。实际测试中,这个优化对UDP通信类型的会话状态机尤其明显。
6. 我个人在实际操作中的体会
写状态机这事儿,最容易被低估的是“状态边界”和“事件来源”这两件事。状态边界没理清楚,代码写一半就会开始到处打补丁;事件来源没理清楚,线上稳定性就会飘。我自己的做法是拿到需求先不急着写类,先花一小时把状态、事件、转移规则、资源申请释放四张表列出来,哪怕写在笔记本上,也比直接开写省下后面三天的调试时间。
工具链方面,如果你的项目用的是现代C++17/20,std::variant和协程真的值得纳入常用工具箱;如果项目还停留在C++11,经典多态方案也完全能打,关键是设计得干净。我们团队现在基本形成一个默认共识:高频状态机用variant,异步流程用协程,一般场景用多态,然后针对热点再做剖析,而不是开箱就追求花哨。
后面如果你在实际落地中遇到状态机崩了、状态卡死、或者切换条件各种不对,回头看看这篇文章里的排查清单,大概率能少走不少弯路。状态模式学起来不难,难的是在真实项目里把它用得收放自如,希望这篇总结能给你一点启发。
