状态机学习笔记
从理论到工程实践,系统梳理有限状态机(FSM)的核心知识。每节讲清"是什么、为什么、怎么实现、有什么坑",配多语言代码示例。
1 · 什么是状态机
1.1 直觉理解
状态机(State Machine)是一种数学模型,用来描述一个系统在任意时刻只处于一个明确的"状态",并且只能在特定"事件"触发下从一个状态跳到另一个状态。
📌 打个比方:红绿灯就是一个状态机。它只有三个状态——红、黄、绿。红灯倒计时结束后跳到绿灯,绿灯倒计时结束后跳到黄灯,黄灯倒计时结束后跳回红灯。不会从红直接跳到黄,也不会"同时是红和绿"。
1.2 有限状态机(FSM)的形式化定义
一个有限状态机(Finite State Machine, FSM)由五元组定义:
| 符号 |
含义 |
例子(红绿灯) |
| Q |
有限状态集合 |
{RED, GREEN, YELLOW} |
| Σ |
输入事件集合 |
{timer_expired} |
| δ |
状态转移函数 |
δ(RED, timer_expired) = GREEN |
| q₀ |
初始状态 |
RED |
| F |
终止状态集合(可为空) |
{}(红绿灯永不停止) |
1.3 两大分类:DFA 与 NFA
| 维度 |
DFA(确定性) |
NFA(非确定性) |
| 同一状态 + 同一输入 |
只有一条出路 |
可能有多条出路 |
| 实现 |
直接模拟,确定 |
需要回溯或转为 DFA |
| 典型用途 |
词法分析器、协议解析 |
正则表达式引擎、模式匹配 |
📌 为什么正则表达式引擎用 NFA?因为正则的 a* 这样的模式天然有"匹配 0 次还是多次"的分支——同一个输入位置,NFA 可以同时探索多条路径。实际引擎(如 PCRE)会把正则先编译成 NFA,再按需转为 DFA 或直接模拟 NFA。
1.4 Mealy 与 Moore
两种输出方式不同的 FSM:
| 类型 |
输出取决于 |
特点 |
| Moore |
仅当前状态 |
输出在状态保持期间稳定不变 |
| Mealy |
当前状态 + 输入事件 |
输出随输入即时变化,更紧凑但可能毛刺 |
Moore: output = f(state)
Mealy: output = f(state, input)
📌 怎么选?如果你的输出只跟状态有关(比如红绿灯亮什么颜色),用 Moore。如果输出还跟输入的瞬间有关(比如"收到 ACK 就发下一帧"),用 Mealy。硬件设计中 Mealy 用得更少,因为输出对输入的即时敏感容易产生毛刺。
门禁系统实例对比
假设门禁有 3 个状态:锁定、解锁、报警,2 个输入:正确密码、错误密码。
Moore 版(输出挂在状态上):
状态 输出(固定)
锁定 → 红灯亮
解锁 → 绿灯亮
报警 → 蜂鸣响
转移规则:
锁定 + 正确密码 → 解锁
锁定 + 错误密码 → 报警
解锁 + 超时 → 锁定
报警 + 重置 → 锁定
进了"解锁"状态就亮绿灯,不管你是怎么进来的。输出只看状态,不看输入。
Mealy 版(输出挂在转移上):

转移 输出
锁定 + 正确密码 → 解锁 "滴"(短声)
锁定 + 错误密码 → 报警 "滴滴滴"(急促声)
解锁 + 超时 → 锁定 "咔嗒"(锁门声)
报警 + 重置 → 锁定 无声
同样是"锁定 → 解锁",如果输入是"正确密码",输出"滴";但如果设计成"管理员密码"也能解锁,输出可以是"滴滴"(区别于普通用户)。输出跟输入有关。
📌 记忆口诀:Moore = 只看状态(M = Memory,记住当前状态就够);Mealy = 状态 + 输入一起决定(Mealy = More input,还要看输入)。

2 · 状态图与状态转移表
2.1 状态图
状态图是最直观的表示方式。节点是状态,箭头是转移,箭头上标注触发事件。
以自动售货机为例:

[待机] --投币--> [已投币] --选择商品--> [出货中] --出货完成--> [待机]
[已投币] --退币--> [待机]
[出货中] --缺货--> [退款中] --退款完成--> [待机]

2.2 状态转移表
同样的信息可以用表格表达,更适合直接翻译成代码:
| 当前状态 \ 事件 |
投币 |
选择商品 |
出货完成 |
退币 |
缺货 |
| 待机 |
已投币 |
— |
— |
— |
— |
| 已投币 |
— |
出货中 |
— |
待机 |
— |
| 出货中 |
— |
— |
待机 |
— |
退款中 |
| 退款中 |
— |
— |
— |
— |
— |
"—"表示在该状态下不接受此事件(或忽略)。
📌 为什么要画状态图再写代码?因为状态机的 bug 几乎都来自"漏掉了某个状态-事件组合"。先画图、列表,确保每个格子都有定义(要么转移、要么忽略、要么报错),再写代码就不会遗漏。
3 · 实现模式
第 2 节的状态图和状态转移表是设计工具——帮你把"哪些状态、哪些事件、怎么跳转"想清楚。想清楚之后,要把它变成可运行的代码。同一台状态机,有三种常见的代码实现方式:
| 模式 |
核心思路 |
适合场景 |
| switch-case(3.1) |
用 switch 遍历状态,if 遍历事件,转移逻辑全写在代码里 |
状态少、逻辑简单 |
| 状态转移表(3.2) |
把转移规则存进二维数组,代码只管查表执行 |
状态多但行为简单,需要可配置 |
| 状态模式(3.3) |
每个状态封装成一个类,机器类委托给当前状态对象 |
状态多且每个状态行为复杂 |
三种方式实现的是同一台售货机状态机,区别只是代码组织方式不同。下面逐一展开。
3.1 switch-case 模式(最简单)
适合状态少、转移逻辑简单的场景。
enum class State
{
Idle,
CoinInserted,
Dispensing,
Refunding
};
State currentState = State::Idle;
void handleEvent(Event event)
{
switch (currentState)
{
case State::Idle:
if (event == Event::InsertCoin)
{
currentState = State::CoinInserted;
}
break;
case State::CoinInserted:
if (event == Event::SelectProduct)
{
currentState = State::Dispensing;
}
else if (event == Event::Refund)
{
currentState = State::Idle;
}
break;
case State::Dispensing:
if (event == Event::DispenseComplete)
{
currentState = State::Idle;
}
else if (event == Event::OutOfStock)
{
currentState = State::Refunding;
}
break;
case State::Refunding:
if (event == Event::RefundComplete)
{
currentState = State::Idle;
}
break;
}
}
🚧 switch-case 的陷阱:状态一多,嵌套的 if-else 会爆炸。10 个状态 × 8 个事件 = 80 个分支,维护噩梦。超过 5 个状态就该考虑状态表或状态模式。
3.2 状态转移表模式(数据驱动)
核心思想
switch-case 模式是用代码表达转移逻辑——每个 case 分支就是一条规则。状态转移表反过来:用数据表达转移逻辑——一张二维表,行是状态、列是事件、格子里写"跳到哪 + 做什么"。代码只负责查表执行,完全不关心具体规则。
用一句话概括:把"什么状态收到什么事件该做什么"从 if-else 搬到一张表里,代码只管查表。
代码实现
第一步:定义状态和事件的枚举
// 状态枚举:列出所有可能的状态
// enum class 是 C++11 强类型枚举,不会隐式转 int,更安全
enum class State
{
Idle, // 待机:等待投币(初始状态)
CoinInserted, // 已投币:等待选商品或退币
Dispensing, // 出货中:等待出货完成或报缺货
Refunding, // 退款中:等待退款完成
StateCount // 哨兵值:不是真实状态,值为 4,用于确定二维数组的行数
// 放在最后,新增状态只需在它前面插一行,数组自动扩容
};
// 事件枚举:列出所有可能的事件
enum class Event
{
InsertCoin, // 投币:用户投入硬币
SelectProduct, // 选商品:用户按下商品按钮
DispenseComplete, // 出货完成:传感器检测到商品已掉落
Refund, // 退币:用户按下退币按钮
OutOfStock, // 缺货:传感器检测到商品售罄
RefundComplete, // 退款完成:退款机制执行完毕
EventCount // 哨兵值:不是真实事件,值为 6,用于确定二维数组的列数
};
StateCount 和 EventCount 不是真实状态/事件,是哨兵值——用来确定二维数组的大小,这样加新状态只需在枚举里插一行,数组自动扩容。
第二步:定义转移结构体和二维表
struct Transition
{
State next; // 跳到哪个状态
void (*action)(void); // 执行什么动作(函数指针,可为空)
};
Transition table[(int)State::StateCount][(int)Event::EventCount] = {};
table 是一个全局二维数组(不是函数),大小是 4×6 = 24 个格子,每个格子的类型是 Transition。行索引是状态,列索引是事件。末尾的 = {} 把所有格子初始化为零值(next = 0 即 Idle,action = nullptr)。
每个格子是一个 Transition:next 是目标状态,action 是要执行的动作函数指针。如果 action 为 nullptr,表示这条转移只切状态、不执行动作。
💡 语法说明:{ State::CoinInserted, nullptr } 是 C++11 引入的花括号初始化(braced-init-list),可以按声明顺序给结构体成员赋值。enum class 也是 C++11 新增的强类型枚举,比传统 enum 更安全(不会隐式转 int)。本文代码需 C++11 及以上 编译。
第三步:初始化表——填入转移规则
void initTable()
{
// 先把所有格子设为"留在原状态,无动作"(即忽略未定义的组合)
for (int s = 0; s < (int)State::StateCount; s++)
{
for (int e = 0; e < (int)Event::EventCount; e++)
{
table[s][e] = { (State)s, nullptr }; // 花括号初始化(C++11):按顺序给 next 和 action 赋值
}
}
// 然后填入实际的转移规则
table[(int)State::Idle][(int)Event::InsertCoin]
= { State::CoinInserted, nullptr };
table[(int)State::CoinInserted][(int)Event::SelectProduct]
= { State::Dispensing, startMotor };
table[(int)State::CoinInserted][(int)Event::Refund]
= { State::Idle, returnCoin };
table[(int)State::Dispensing][(int)Event::DispenseComplete]
= { State::Idle, nullptr };
table[(int)State::Dispensing][(int)Event::OutOfStock]
= { State::Refunding, nullptr };
table[(int)State::Refunding][(int)Event::RefundComplete]
= { State::Idle, returnCoin };
}
这个函数分两阶段:先全填默认值,再覆盖有效转移。
阶段一:双重循环填默认值
外层循环 s 遍历 4 个状态(0=Idle, 1=CoinInserted, 2=Dispensing, 3=Refunding),内层循环 e 遍历 6 个事件(0~5)。每次执行 table[s][e] = { (State)s, nullptr },意思是:第 s 行第 e 列的格子,填入"跳到自己(第 s 个状态),不执行任何动作"。
循环跑完后,4×6 = 24 个格子全部填好,内容如下:
table[Idle][InsertCoin] = { Idle, nullptr } ← 收到投币,留在待机
table[Idle][SelectProduct] = { Idle, nullptr } ← 收到选商品,留在待机
table[Idle][DispenseComplete] = { Idle, nullptr }
table[Idle][Refund] = { Idle, nullptr }
table[Idle][OutOfStock] = { Idle, nullptr }
table[Idle][RefundComplete] = { Idle, nullptr }
table[CoinInserted][InsertCoin] = { CoinInserted, nullptr } ← 都留在原状态
table[CoinInserted][SelectProduct] = { CoinInserted, nullptr }
...(以此类推,每一行都是"留在自己")
为什么这么做? 因为不是每个状态都关心每个事件——比如"待机"状态下收到"出货完成"事件,根本不该响应。把默认值设成"留在原状态 + 无动作",就等于 switch-case 里的 default: break;:未定义的组合自动忽略,不会误跳转,也不会访问到未初始化的垃圾值。
阶段二:覆盖有效转移
然后逐条覆盖真正需要响应的格子。比如 table[Idle][InsertCoin] = { CoinInserted, nullptr } 把第 0 行第 0 列从 { Idle, nullptr } 改成 { CoinInserted, nullptr }——现在"待机 + 投币"会跳到"已投币"。
覆盖后,24 个格子中有 6 个被改写,其余 18 个保持"留在原状态"。最终效果就是一张完整的状态转移表,每个状态-事件组合都有明确定义。
第四步:事件处理函数——查表执行
State currentState = State::Idle; // 全局当前状态
void handleEvent(Event event)
{
Transition& t = table[(int)currentState][(int)event];
if (t.action)
{
t.action(); // 有动作就执行
}
currentState = t.next; // 切换状态
}
整个引擎就这 5 行。不管状态机有多复杂,这段代码永远不变——变的是表的内容。
执行流程
以"投币 → 选择商品 → 出货完成"为例:
1. 调用 handleEvent(InsertCoin)
→ 查表 table[Idle][InsertCoin] = { CoinInserted, nullptr }
→ action 为空,不执行
→ currentState = CoinInserted
2. 调用 handleEvent(SelectProduct)
→ 查表 table[CoinInserted][SelectProduct] = { Dispensing, startMotor }
→ action 非空,执行 startMotor()(电机启动出货)
→ currentState = Dispensing
3. 调用 handleEvent(DispenseComplete)
→ 查表 table[Dispensing][DispenseComplete] = { Idle, nullptr }
→ action 为空,不执行
→ currentState = Idle(回到起点)
每一步都是:用当前状态 + 事件做索引查表 → 执行动作(如果有)→ 切换状态。没有 if-else,没有 switch-case。
数据视图
这张表本质上就是 2.2 节那张状态转移表,只是从 Markdown 表格变成了 C 数组:
| 状态 \ 事件 |
InsertCoin |
SelectProduct |
DispenseComplete |
Refund |
OutOfStock |
RefundComplete |
| Idle |
CoinInserted |
— |
— |
— |
— |
— |
| CoinInserted |
— |
Dispensing |
— |
Idle |
— |
— |
| Dispensing |
— |
— |
Idle |
— |
Refunding |
— |
| Refunding |
— |
— |
— |
— |
— |
Idle |
"—"就是初始化时填的 { 原状态, nullptr },表示忽略。
📌 状态转移表的好处:转移逻辑和业务动作分离。你可以把表序列化成 JSON/配置文件,运行时动态加载——不需要重新编译就能调整状态机行为。很多协议栈、游戏 AI 都用这种方式。
3.3 状态模式(面向对象)
一个类比
想象一家公司有三个员工:前台、客服、维修工。客户来了,前台说"这事归我管",处理完把客户转给下一个岗位。如果客户直接找维修工,维修工会说"这不是我的事"然后无视。
switch-case 模式就像一个前台同时干三个人的活——客户每提一个需求,前台都要在脑子里过一遍"我现在该扮演谁"。需求一多就崩溃。
状态模式则是每个岗位派一个真人——前台只管接待,客服只管解答,维修工只管修东西。客户来了,当前岗位的人处理完,直接说"你去找某某"。每个人只关心自己那摊事,不管别人。
映射到代码:每个状态 = 一个员工(类),机器 = 公司,事件 = 客户需求,setState() = "你去找某某"。
核心思想
switch-case 模式的问题是:所有状态的行为挤在一个函数里,状态越多函数越长。状态模式反过来——把每个状态的行为拆到独立的类里,机器类只持有一个"当前状态"指针,把事件转发给它。
用一句话概括:不是"机器根据状态做不同的事",而是"当前状态的对象自己知道该怎么响应"。
结构图

图中有三个角色:
- VendingMachine(公司):持有"当前状态"指针,对外暴露事件接口。它自己不做任何决策,只负责把事件转给当前状态对象——就像公司前台不修东西,只负责把你领到对应的人那里
- State(岗位说明书):抽象基类,定义了所有可能的事件接口。每个事件的默认实现是空的——"这不是我的事,忽略",就像维修工不会去接电话
- IdleState / CoinInsertedState / ...(具体员工):只重写自己关心的事件。比如
IdleState 只重写了 insertCoin()("投币归我管"),其余事件走基类空实现("不归我管,无视")
代码实现
以自动售货机为例,4 个状态各为一个类。
第一部分:抽象状态接口——岗位说明书
class VendingMachine; // 前向声明:State 的成员函数需要引用机器类,但此时机器类还没定义
class State
{
public:
virtual ~State() = default; // 虚析构:保证通过基类指针删除派生类对象时能正确析构
// 6 个事件接口,全部给空实现 {}
// 含义:"这个事件不归我管" → 子类只需重写自己关心的事件,其余自动忽略
// 对比 switch-case 的 default: break;,这里基类帮你兜底了
virtual void insertCoin(VendingMachine& m) {} // 投币事件
virtual void selectProduct(VendingMachine& m) {} // 选商品事件
virtual void dispenseComplete(VendingMachine& m) {} // 出货完成事件
virtual void refund(VendingMachine& m) {} // 退币事件
virtual void outOfStock(VendingMachine& m) {} // 缺货事件
virtual void refundComplete(VendingMachine& m) {} // 退款完成事件
// 纯虚函数:子类必须实现,返回状态名(用于调试/打印)
// = 0 表示纯虚,State 是抽象类,不能直接实例化
virtual const char* name() const = 0;
};
注意所有事件函数都有 {} 空实现——这意味着任何状态不关心的事件都自动忽略。对比 switch-case 模式,你不再需要写 default: break;,基类帮你兜底了。
第二部分:机器类——公司
class VendingMachine
{
public:
VendingMachine(); // 构造函数在类外定义(见第三部分),初始状态设为 IdleState
// setState:唯一能切换状态的方法
// 当前状态对象处理完事件后调用它,相当于说"下一个客户去找某某"
// std::move 转移 unique_ptr 所有权,旧状态对象自动析构
void setState(std::unique_ptr<State> s)
{
currentState = std::move(s); // 换人:旧状态销毁,新状态上岗
}
// 对外接口:6 个事件函数,每个只有一行
// 机器类不自作主张,把事件连同自身引用(*this)转交给当前状态对象
// *this 传给状态对象,让它能调用 setState() 来切换状态
void insertCoin() { currentState->insertCoin(*this); } // 用户投币 → 转给当前状态
void selectProduct() { currentState->selectProduct(*this); } // 用户选商品 → 转给当前状态
void dispenseComplete() { currentState->dispenseComplete(*this); } // 出货完成 → 转给当前状态
void refund() { currentState->refund(*this); } // 用户退币 → 转给当前状态
void outOfStock() { currentState->outOfStock(*this); } // 传感器报缺货 → 转给当前状态
void refundComplete() { currentState->refundComplete(*this); } // 退款完成 → 转给当前状态
// 查询当前状态名(调试用)
const char* stateName() const { return currentState->name(); }
private:
std::unique_ptr<State> currentState; // 当前值班的人:指向当前状态对象的智能指针
};
看这个类有多薄——6 个事件函数,每个只有一行,全是 currentState->xxx(*this)。机器类完全不知道任何转移规则,它只做一件事:"有人找我,我转给当前值班的人"。这就是"委托"。
setState() 是唯一能换状态的方法——当前状态对象处理完事件后调用它,相当于说"下一个客户去找某某"。
第三部分:具体状态类——员工
// 待机状态:只关心"投币"这一个事件
class IdleState : public State // 继承 State,获得所有事件的空实现
{
public:
// 只重写 insertCoin,其余 5 个事件走基类空实现(自动忽略)
//
// ⚠️ 关键:参数 m 是什么?
// 回顾机器类的转发代码:void insertCoin() { currentState->insertCoin(*this); }
// *this 就是机器自己 → 所以 m 就是机器本身的引用
// 状态对象自己没有任何成员变量,它通过 m.setState() 改的是【机器的】currentState
void insertCoin(VendingMachine& m) override
{
// 收到投币 → 帮机器切换到"已投币"状态
// 相当于前台说:"投币的事我办完了,你去找已投币那边的客服"
// make_unique 创建新状态对象,setState 接管并销毁旧状态
// 注意:改的是 m(机器)的 currentState,不是 IdleState 自己的什么变量
m.setState(std::make_unique<CoinInsertedState>());
}
const char* name() const override { return "Idle"; } // 返回状态名,调试用
};
IdleState 只重写了 insertCoin()——这是它唯一关心的事件。如果有人在这个状态下调用 selectProduct(),会走基类空实现,什么也不发生。就像待机时你选商品,机器不会理你。
// 已投币状态:关心"选商品"和"退币"两个事件
class CoinInsertedState : public State
{
public:
void selectProduct(VendingMachine& m) override // m 就是机器本身(机器通过 *this 传进来的)
{
// 选了商品 → 帮机器切换到出货中(机器开始出货)
m.setState(std::make_unique<DispensingState>()); // 改的是机器的 currentState
}
void refund(VendingMachine& m) override // m 就是机器本身
{
// 要退币 → 帮机器回到待机(钱退回去,重新等投币)
m.setState(std::make_unique<IdleState>()); // 改的是机器的 currentState
}
// insertCoin?不归我管——已经投过币了,再投一次无效(基类空实现)
// dispenseComplete?不归我管——还没选商品呢(基类空实现)
const char* name() const override { return "CoinInserted"; }
};
CoinInsertedState 重写了两个事件——选商品和退币。投币?不归我管(基类空实现)。出货完成?也不归我管。每个状态类只写自己那几行逻辑,互不干扰。
// 出货中状态:关心"出货完成"和"缺货"两个事件
class DispensingState : public State
{
public:
void dispenseComplete(VendingMachine& m) override // m 就是机器本身
{
m.setState(std::make_unique<IdleState>()); // 出完了 → 帮机器回待机,等下一个客户
}
void outOfStock(VendingMachine& m) override // m 就是机器本身
{
m.setState(std::make_unique<RefundingState>()); // 没货 → 帮机器去退款流程
}
// selectProduct?不归我管——已经在出货了(基类空实现)
const char* name() const override { return "Dispensing"; }
};
// 退款中状态:只关心"退款完成"这一个事件
class RefundingState : public State
{
public:
void refundComplete(VendingMachine& m) override // m 就是机器本身
{
m.setState(std::make_unique<IdleState>()); // 退完了 → 帮机器回待机
}
// 其余事件一律忽略——退款中不接受任何操作(基类空实现)
const char* name() const override { return "Refunding"; }
};
// 机器启动时,初始状态是"待机"
// 构造函数在类外定义(因为 IdleState 的完整定义在上面才能用)
// make_unique<IdleState>() 创建待机状态对象,赋给 currentState
VendingMachine::VendingMachine()
: currentState(std::make_unique<IdleState>())
{
}
注意一个规律:每个具体状态类只有几行代码——它只重写自己关心的事件,其余的交给基类兜底。新增一个状态?写一个新类,不改任何已有代码。这就是开闭原则的体现。
关键问题:子类怎么"改变"机器的状态?
仔细看代码——子类没有自己的成员变量,它改变的是 m 的状态。m 是谁?就是 VendingMachine 的引用。
回顾第二部分机器类的转发代码:
void refundComplete() { currentState->refundComplete(*this); }
*this 就是机器自己。机器把自己的引用传给状态对象,状态对象拿到 m 后调用 m.setState(...),这改的是机器的 currentState,不是状态对象自己的什么变量。
整个过程拆解:
- 机器调用
m.refundComplete() → 机器转发 currentState->refundComplete(*this),把自己传进去
RefundingState::refundComplete(VendingMachine& m) 收到的 m 就是机器本身
m.setState(std::make_unique<IdleState>()) → 调用机器的 setState(),把机器的 currentState 换成新状态
setState() 内部 currentState = std::move(s) → 旧状态对象(RefundingState)被销毁,新状态对象(IdleState)上岗
所以状态对象自己不存状态——它只是替机器做决策,然后通过 m.setState() 帮机器换人。状态始终存在机器的 currentState 里,状态对象只是"过客"。
执行流程
以"投币 → 选择商品 → 出货完成"为例,看看事件是怎么在对象之间传递的:

时序图里的规律一目了然:机器类自始至终只做一件事——转发。每次都是用户调用机器 → 机器转给当前状态 → 状态对象决定切换到谁。真正决策的是当前状态对象,机器只是传话的。这就是状态模式的精髓:决策权在状态对象手里,机器只是传话的。
和 switch-case 的对比
|
switch-case |
状态模式 |
| 转移逻辑在哪 |
全在一个函数里 |
分散在每个状态类里 |
| 加新状态 |
改函数,加 case 分支 |
加一个新类,不改已有代码 |
| 加新事件 |
改函数,每个 case 里加逻辑 |
基类加虚函数,只改关心的状态类 |
| 不关心的事件 |
要写 default: break; |
基类空实现自动忽略 |
| 看全局转移 |
一个函数看全貌 |
要跳转多个类才能拼出全貌 |
| 适合场景 |
状态少、逻辑简单 |
状态多、每个状态行为复杂 |
优缺点
| 优点 |
缺点 |
| 新增状态只需加一个类,不改已有代码(开闭原则) |
状态间跳转关系散落在各个类中,不像状态表一目了然 |
| 每个状态的行为内聚,代码可读性好 |
类数量 = 状态数量,状态多时类爆炸 |
| 可以在状态类中管理该状态的资源(进/出动作) |
需要动态创建/销毁状态对象(或用单例池优化) |
📌 状态模式 vs 状态表:状态多且每个状态行为复杂 → 状态模式(行为内聚到类里,好维护);状态多但行为简单(只是跳转) → 状态表(一张表看全貌,好审计)。两者不是互斥的——可以用状态表定义跳转规则,用状态模式封装每个状态的进/出动作。
3.4 Qt 实现:QState + QStateMachine
Qt 提供了现成的状态机框架(QStateMachine),不需要手写基类和转发逻辑——框架帮你做了委托和状态管理。你只需要:创建状态对象 → 连线 → 启动。
#include <QStateMachine>
#include <QState>
// Qt 状态机:自动售货机示例
// 对比 3.3 的手写状态模式,Qt 帮你把"委托 + 状态切换"的基础设施全做好了
// 你只需声明"有哪些状态"和"谁连谁",不用写 State 基类和 VendingMachine 转发代码
// 机器类:继承 QObject 才能用信号槽
// 对比 3.3:不再需要 State 基类、不再需要 currentState 指针、不再需要 setState()
// QStateMachine 框架把这些全做了
class VendingMachine : public QObject
{
Q_OBJECT // Qt 宏:启用信号槽机制(必须放在类声明中)
public:
VendingMachine(QObject *parent = nullptr);
// 对外接口:用户/传感器调用这些方法来触发事件
// 对比 3.3 的 insertCoin() { currentState->insertCoin(*this); }
// 这里更简单——直接发信号,状态机自动处理转移
void insertCoin() { emit coinInserted(); } // 用户投币
void selectProduct() { emit productSelected(); } // 用户选商品
void dispenseComplete() { emit dispenseDone(); } // 出货完成
void refund() { emit refundRequested(); } // 用户退币
void outOfStock() { emit outOfStockSignal(); } // 传感器报缺货
void refundComplete() { emit refundDone(); } // 退款完成
// 查询当前状态名(调试用)
// activeState() 返回当前活跃状态的 QObject,objectName() 就是状态名
QString stateName() const { return machine.activeState()->objectName(); }
signals:
// 6 个信号:对应 6 个事件
// 信号就是事件——状态机监听这些信号,收到后自动按连线规则切换状态
// 对比 3.3:3.3 用虚函数转发事件,Qt 用信号触发转移
void coinInserted(); // 投币事件
void productSelected(); // 选商品事件
void dispenseDone(); // 出货完成事件
void refundRequested(); // 退币事件
void outOfStockSignal(); // 缺货事件(注意:outOfStock 和成员函数同名,所以信号加 Signal 后缀)
void refundDone(); // 退款完成事件
private slots:
// ★ 执行逻辑:进入每个状态时做什么
// QState 有个 entered() 信号,状态机切到该状态时自动发
// 我们把这些槽函数连到 entered() 上,实现"进入状态 → 执行动作"
// 对比 3.3:3.3 在状态类构造函数里写动作,Qt 用 entered() 信号触发
void onEnterIdle() { qDebug() << "[待机] 请投币"; coinSlot.unlock(); }
void onEnterCoin() { qDebug() << "[已投币] 请选择商品"; coinSlot.lock(); }
void onEnterDispense() { qDebug() << "[出货中] 启动电机"; motor.start(); }
void onEnterRefund() { qDebug() << "[退款中] 执行退款"; coinRefunder.refund(); }
private:
QStateMachine machine; // Qt 状态机——等价于 3.3 的 VendingMachine 自身状态管理
QState *s_idle; // 待机状态
QState *s_coin; // 已投币状态
QState *s_dispense; // 出货中状态
QState *s_refund; // 退款中状态
// 硬件接口(实际项目中这些是驱动层对象)
CoinSlot coinSlot; // 投币口(lock/unlock 控制是否接受硬币)
Motor motor; // 出货电机
CoinRefunder coinRefunder; // 退币器
};
// 构造函数:创建状态、连线、启动——所有状态机逻辑集中在这里
VendingMachine::VendingMachine(QObject *parent)
: QObject(parent)
{
// 1. 创建状态对象(this 作为父对象,Qt 自动管理内存)
// 对比 3.3 的 make_unique<IdleState>()——这里用 QState 代替自定义状态类
s_idle = new QState(); // 待机状态
s_coin = new QState(); // 已投币状态
s_dispense = new QState(); // 出货中状态
s_refund = new QState(); // 退款中状态
// 2. 给状态起名(调试用,等价于 3.3 的 name() 返回值)
s_idle->setObjectName("Idle");
s_coin->setObjectName("CoinInserted");
s_dispense->setObjectName("Dispensing");
s_refund->setObjectName("Refunding");
// 把状态加入状态机
machine.addState(s_idle);
machine.addState(s_coin);
machine.addState(s_dispense);
machine.addState(s_refund);
// 3. 连线:定义转移规则——等价于 3.3 里每个状态类的 if/setState 逻辑
// addTransition 三个参数:信号发送者, 信号地址, 目标状态
// this 是谁?就是 VendingMachine 实例本身(这段代码在构造函数里,this 指向正在构造的机器)
// 意思是:"当 this(机器)发出 coinInserted 信号时,自动从当前状态切到 s_coin"
// 对比手写模式:不用自己调 setState(),Qt 信号一触发就自动切
// 待机 → 投币 → 已投币
s_idle->addTransition(this, &VendingMachine::coinInserted, s_coin); // this=机器, 机器发coinInserted → 切到已投币
// 已投币 → 选商品 → 出货中
s_coin->addTransition(this, &VendingMachine::productSelected, s_dispense); // 机器发productSelected → 切到出货中
// 已投币 → 退币 → 待机
s_coin->addTransition(this, &VendingMachine::refundRequested, s_idle); // 机器发refundRequested → 切回待机
// 出货中 → 出货完成 → 待机
s_dispense->addTransition(this, &VendingMachine::dispenseDone, s_idle); // 机器发dispenseDone → 切回待机
// 出货中 → 缺货 → 退款中
s_dispense->addTransition(this, &VendingMachine::outOfStockSignal, s_refund); // 机器发outOfStockSignal → 切到退款中
// 退款中 → 退款完成 → 待机
s_refund->addTransition(this, &VendingMachine::refundDone, s_idle); // 机器发refundDone → 切回待机
// 4. 连接 entered() 信号到执行槽——进入状态时自动执行动作
// 这是 Qt 状态机的"执行"部分:不光是切换状态,还能在切换时做事
// 对比 3.3:3.3 在 setState() 里 new 新状态对象,构造函数里执行动作
// Qt 更简洁:entered() 信号 → 槽函数,一行 connect 搞定
connect(s_idle, &QState::entered, this, &VendingMachine::onEnterIdle);
connect(s_coin, &QState::entered, this, &VendingMachine::onEnterCoin);
connect(s_dispense, &QState::entered, this, &VendingMachine::onEnterDispense);
connect(s_refund, &QState::entered, this, &VendingMachine::onEnterRefund);
// 5. 设置初始状态并启动
machine.setInitialState(s_idle); // 等价于 3.3 构造函数里的 make_unique<IdleState>()
machine.start(); // 状态机开始运行,等待信号
}
// 使用:
// VendingMachine m;
// m.insertCoin(); // 用户投币 → emit coinInserted() → 状态机自动从 Idle 切到 CoinInserted
// → entered() 触发 onEnterCoin():锁定投币口,显示"请选择商品"
// m.selectProduct(); // 用户选商品 → emit productSelected() → 自动切到 Dispensing
// → entered() 触发 onEnterDispense():启动电机出货
// m.dispenseComplete(); // 出货完成 → emit dispenseDone() → 自动切回 Idle
// → entered() 触发 onEnterIdle():解锁投币口,显示"请投币"
// qDebug() << m.stateName(); // 输出 "Idle"
Qt 状态机 vs 手写状态模式:
|
手写状态模式(3.3) |
Qt QStateMachine |
| 状态定义 |
每个状态一个类,继承 State |
QState 对象,不用继承 |
| 事件触发 |
调用 m.insertCoin() → 转发 |
发信号 emit coinInserted() → 自动转移 |
| 转移规则 |
在状态类的方法里写 m.setState(...) |
addTransition(信号, 目标状态) 一行连线 |
| 状态切换 |
手动调 setState() |
框架自动处理 |
| 进/出动作 |
在构造/析构里写 |
QState::onEntry() / onExit() 信号 |
| 适合场景 |
不依赖框架的纯 C++ 项目 |
Qt 项目,想要信号驱动 + 可视化调试 |
本质上 Qt 做的事和 3.3 手写的一样——状态对象持有转移规则,框架负责委托和切换。只是 Qt 用信号槽代替了手写的虚函数转发,用 addTransition 代替了手写的 setState,省了大量样板代码。
📌 Qt 额外能力:QState 支持 onEntry() / onExit() 信号(进/出状态时自动触发),支持层次状态机(QState 可以嵌套子状态),支持并行状态(同一时刻多个状态同时活跃)。这些在纯 C++ 手写模式里都要自己实现。
什么时候用 QStateMachine,什么时候不用?
上面这个售货机例子其实不该用 QStateMachine——4 个扁平状态、6 个事件,用 switch-case 或状态表几行就搞定了。引入 QObject、Q_OBJECT 宏、moc 元编译、信号槽……一堆 Qt 基础设施,就为了替代几行 switch,确实是画蛇添足。
该用的场景:
- 层次状态:状态有嵌套关系,比如"运行中"包含"正常"和"故障"两个子状态,子状态继承父状态的转移规则。手写层次状态机非常复杂,
QState 天然支持
- 进/出动作:每次进入/退出状态时要执行特定逻辑(如进入"出货中"启动电机,退出"出货中"停止电机)。
onEntry() / onExit() 信号比手写构造/析构更清晰
- 信号驱动:UI 事件(按钮点击、传感器回调)本身就是 Qt 信号,直接
addTransition 连线即可,不用手动调函数
- 状态多、转移复杂:状态超过 5-6 个,转移关系网状交错,手写 switch 或状态表已经难维护
- 需要可视化调试:Qt Creator 可以查看状态机运行时状态
不该用的场景:
- 状态少(≤ 4)、转移简单——switch-case 或状态表更直观
- 非 Qt 项目——
QStateMachine 依赖 QObject 和 moc,纯 C++ 项目引入 Qt 只为状态机不值得
- 状态扁平无层次——层次状态机是 QStateMachine 的核心优势,用不上就浪费了
一句话:QStateMachine 的价值在层次和信号驱动。如果你的状态是扁平的、事件是函数调用,用 switch-case 或状态表就够了。
一个层次状态机的例子:媒体播放器
假设你在做一个 Qt 视频播放器,状态如下:
播放器
├── 停止(Stopped)
├── 播放中(Playing)
│ ├── 正常播放(NormalPlayback)
│ └── 缓冲中(Buffering) ← 网络卡了,等数据
└── 暂停(Paused)
├── 正常暂停(NormalPaused)
└── 搜索中(Seeking) ← 用户拖进度条
关键点:"缓冲中"和"搜索中"是子状态——缓冲时仍然属于"播放中"(缓冲完继续播),搜索时仍然属于"暂停"(搜完继续暂停)。而且父状态有共同规则:不管在哪个子状态,按"停止"都回到 Stopped。
如果用 switch-case 手写:
// 手写:状态必须拍平,组合爆炸
enum State { Stopped, Playing_Normal, Playing_Buffering, Paused_Normal, Paused_Seeking };
void handleEvent(State& state, Event event)
{
switch (state)
{
case Stopped:
if (event == Play) state = Playing_Normal;
break;
case Playing_Normal:
if (event == Pause) state = Paused_Normal;
if (event == Stop) state = Stopped; // 父状态规则
if (event == Buffering) state = Playing_Buffering;
break;
case Playing_Buffering:
if (event == Buffered) state = Playing_Normal;
if (event == Pause) state = Paused_Normal; // 缓冲中也能暂停?
if (event == Stop) state = Stopped; // 父状态规则,重复写
break;
case Paused_Normal:
if (event == Play) state = Playing_Normal;
if (event == Stop) state = Stopped; // 父状态规则,又重复写
if (event == Seek) state = Paused_Seeking;
break;
case Paused_Seeking:
if (event == SeekDone) state = Paused_Normal;
if (event == Play) state = Playing_Normal; // 搜索完直接播?
if (event == Stop) state = Stopped; // 父状态规则,第三次写
break;
}
}
问题一眼可见:
- "Stop → Stopped"写了 4 遍——每个子状态都要重复父状态的规则。加一个子状态就要再写一遍
- 状态是拍平的——
Playing_Buffering 和 Playing_Normal 在 enum 里是平级的,丢失了"缓冲属于播放"的层次关系
- 加一个子状态 = 加一个 case + 所有父规则重写一遍——比如"播放中"再加一个"快进(FastForward)",Stop 规则又要写一遍
用 QStateMachine:
// Qt:层次状态机,父状态规则自动被子状态继承
class Player : public QObject
{
Q_OBJECT
public:
Player(QObject *parent = nullptr);
signals:
void playClicked();
void pauseClicked();
void stopClicked();
void bufferingStarted();
void bufferingDone();
void seekStarted();
void seekDone();
private slots:
// ★ 执行逻辑:进入每个状态时做什么
// 对比售货机例子:这里展示了 onEntry/onExit 的实际用途
void onEnterStopped() { qDebug() << "[停止] 停止播放,重置进度"; mediaPlayer.stop(); }
void onEnterPlaying() { qDebug() << "[播放中] 开始解码"; mediaPlayer.play(); }
void onEnterBuffering() { qDebug() << "[缓冲中] 显示加载动画"; ui.showLoading(); }
void onExitBuffering() { qDebug() << "[缓冲完成] 隐藏加载动画"; ui.hideLoading(); }
void onEnterPaused() { qDebug() << "[暂停] 暂停解码"; mediaPlayer.pause(); }
void onEnterSeeking() { qDebug() << "[搜索中] 跳转到新位置"; ui.showSeekBar(); }
void onExitSeeking() { qDebug() << "[搜索完成] 隐藏搜索条"; ui.hideSeekBar(); }
private:
QStateMachine machine;
// 顶层状态
QState *s_stopped; // 停止
QState *s_playing; // 播放中(父状态)
QState *s_paused; // 暂停(父状态)
// s_playing 的子状态
QState *s_normal_play; // 正常播放
QState *s_buffering; // 缓冲中
// s_paused 的子状态
QState *s_normal_pause; // 正常暂停
QState *s_seeking; // 搜索中
// 硬件/业务接口
MediaPlayer mediaPlayer; // 播放引擎
PlayerUI ui; // 播放器界面
};
Player::Player(QObject *parent)
: QObject(parent)
{
// 1. 创建状态,建立层次关系
s_stopped = new QState();
s_playing = new QState(); // 父状态:播放中
s_paused = new QState(); // 父状态:暂停
// 子状态的构造参数是父状态——这就建立了嵌套关系
// Qt 自动处理:子状态继承父状态的所有转移规则
s_normal_play = new QState(s_playing); // 正常播放 → 属于播放中
s_buffering = new QState(s_playing); // 缓冲中 → 属于播放中
s_normal_pause = new QState(s_paused); // 正常暂停 → 属于暂停
s_seeking = new QState(s_paused); // 搜索中 → 属于暂停
s_playing->setInitialState(s_normal_play); // 进入"播放中"默认是"正常播放"
s_paused->setInitialState(s_normal_pause); // 进入"暂停"默认是"正常暂停"
// 2. 加入状态机
machine.addState(s_stopped);
machine.addState(s_playing);
machine.addState(s_paused);
// 3. 连线——注意:父状态上的规则,子状态自动继承!
// 顶层转移:停止 → 播放
s_stopped->addTransition(this, &Player::playClicked, s_playing);
// 顶层转移:播放 → 暂停(不管在正常播放还是缓冲中,暂停都生效)
s_playing->addTransition(this, &Player::pauseClicked, s_paused);
// 顶层转移:暂停 → 播放
s_paused->addTransition(this, &Player::playClicked, s_playing);
// ★ 关键:在父状态 s_playing 上加 Stop 规则
// 不管当前在 s_normal_play 还是 s_buffering,Stop 都回到 Stopped
// 只写一次!不用在每个子状态上重复!
s_playing->addTransition(this, &Player::stopClicked, s_stopped);
// 同理:暂停状态下 Stop 也只写一次
s_paused->addTransition(this, &Player::stopClicked, s_stopped);
// 子状态之间的转移
s_normal_play->addTransition(this, &Player::bufferingStarted, s_buffering);
s_buffering->addTransition(this, &Player::bufferingDone, s_normal_play);
s_normal_pause->addTransition(this, &Player::seekStarted, s_seeking);
s_seeking->addTransition(this, &Player::seekDone, s_normal_pause);
// 4. 连接 entered()/exited() 信号到执行槽——进入/退出状态时自动执行动作
// entered() = onEntry:进入状态时触发
// exited() = onExit:离开状态时触发
// 对比手写:不用在每个 case 里手动调"进入动作"和"退出动作"
connect(s_stopped, &QState::entered, this, &Player::onEnterStopped);
connect(s_playing, &QState::entered, this, &Player::onEnterPlaying);
connect(s_buffering, &QState::entered, this, &Player::onEnterBuffering);
connect(s_buffering, &QState::exited, this, &Player::onExitBuffering);
connect(s_paused, &QState::entered, this, &Player::onEnterPaused);
connect(s_seeking, &QState::entered, this, &Player::onEnterSeeking);
connect(s_seeking, &QState::exited, this, &Player::onExitSeeking);
// 5. 启动
machine.setInitialState(s_stopped);
machine.start();
}
// 使用:
// Player p;
// emit p.playClicked(); // 停止 → 播放中(自动进入 NormalPlayback)
// → entered() 触发 onEnterPlaying():开始解码
// emit p.bufferingStarted(); // 正常播放 → 缓冲中
// → entered() 触发 onEnterBuffering():显示加载动画
// emit p.bufferingDone(); // 缓冲中 → 正常播放
// → exited() 触发 onExitBuffering():隐藏加载动画
// emit p.stopClicked(); // 缓冲中 → 停止(父状态规则,自动生效!)
// → entered() 触发 onEnterStopped():停止播放,重置进度
// emit p.playClicked(); // 停止 → 播放中
// emit p.pauseClicked(); // 播放中 → 暂停(自动进入 NormalPaused)
// → entered() 触发 onEnterPaused():暂停解码
// emit p.seekStarted(); // 正常暂停 → 搜索中
// → entered() 触发 onEnterSeeking():显示搜索条
// emit p.seekDone(); // 搜索中 → 正常暂停
// → exited() 触发 onExitSeeking():隐藏搜索条
// emit p.stopClicked(); // 搜索中 → 停止(父状态规则,自动生效!)
对比一目了然:
|
switch-case 手写 |
QStateMachine |
| "Stop → Stopped" 写几遍 |
4 遍(每个子状态一遍) |
1 遍(写在父状态上,子状态自动继承) |
| 加子状态"快进" |
加 enum 值 + 新 case + Stop 规则再写一遍 |
new QState(s_playing) 一行,Stop 自动继承 |
| 层次关系 |
拍平了,丢失 |
保留,QState(parent) 直接表达 |
| 状态数 5 → 8 |
case 从 5 涨到 8,每个都要写父规则 |
只加子状态对象,父规则不用动 |
这就是层次状态机的价值:父状态规则写一次,子状态自动继承。状态越多、层次越深,手写越痛苦,QStateMachine 越值。如果只有 4 个扁平状态,这些优势都用不上——所以前面说售货机不该用。
说实话:QStateMachine 真的值得吗?
上面这个例子,5 个状态省了 3 行 Stop 规则——说实话,没省多少工作量。你可能会觉得:switch-case 一眼看到全貌,和脑子里的状态转移表完全对应,反而更直观。这个直觉是对的。
QStateMachine 的真正价值不在"少写几行",而在三个特定场景:
- 信号驱动——Qt 项目里 UI 事件本身就是信号(按钮点击、定时器、网络回调),
addTransition 直接连线,不用写"收到事件 → 调函数"的胶水代码。这是 QStateMachine 最实际的价值,因为信号槽是 Qt 的基础设施
- 深层嵌套——2 层省 3 行,3 层省 7 行,4 层省 15 行……层次越深,重复越指数增长。但说实话,多数项目 2 层就够了
- onEntry / onExit——进/出状态自动触发逻辑,不用在每个 case 里手动调"进入动作"和"退出动作"
但如果你的场景是:
- 状态扁平、层次浅(≤ 2 层)——switch-case 或状态表更直观,一眼看全貌
- 事件是函数调用而非 Qt 信号——QStateMachine 的信号优势用不上
- 非 Qt 项目——引入 Qt 只为状态机,代价远大于收益
结论:大多数项目用 switch-case 或状态表就够了。QStateMachine 不是银弹,它是Qt 项目 + 信号驱动 + 层次状态这三个条件同时满足时的合理选择。别为了"用框架"而用框架。
3.5 Rust 实现:枚举 + match
Rust 的 enum 天生适合做状态机——每个变体就是一个状态,match 做转移,编译器保证穷尽性。
enum State
{
Idle,
CoinInserted,
Dispensing,
Refunding,
}
enum Event
{
InsertCoin,
SelectProduct,
DispenseComplete,
Refund,
OutOfStock,
RefundComplete,
}
impl State
{
fn next(self, event: Event) -> State
{
match (self, event)
{
(State::Idle, Event::InsertCoin) => State::CoinInserted,
(State::CoinInserted, Event::SelectProduct) => State::Dispensing,
(State::CoinInserted, Event::Refund) => State::Idle,
(State::Dispensing, Event::DispenseComplete) => State::Idle,
(State::Dispensing, Event::OutOfStock) => State::Refunding,
(State::Refunding, Event::RefundComplete) => State::Idle,
// 其他组合保持原状态
(state, _) => state,
}
}
}
📌 Rust 的优势:match 必须穷尽所有分支,否则编译报错。新增一个状态变体后,所有 match 都会报"未覆盖"错误——编译器帮你找遗漏,这是 C++ switch-case 做不到的。
3.6 Python 实现
from enum import Enum, auto
from typing import Callable
class State(Enum):
Idle = auto()
CoinInserted = auto()
Dispensing = auto()
Refunding = auto()
class Event(Enum):
InsertCoin = auto()
SelectProduct = auto()
DispenseComplete = auto()
Refund = auto()
OutOfStock = auto()
RefundComplete = auto()
transitions: dict[tuple[State, Event], tuple[State, Callable | None]] = {
(State.Idle, Event.InsertCoin): (State.CoinInserted, None),
(State.CoinInserted, Event.SelectProduct): (State.Dispensing, start_motor),
(State.CoinInserted, Event.Refund): (State.Idle, return_coin),
(State.Dispensing, Event.DispenseComplete): (State.Idle, None),
(State.Dispensing, Event.OutOfStock): (State.Refunding, None),
(State.Refunding, Event.RefundComplete): (State.Idle, return_coin),
}
class VendingMachine:
def __init__(self):
self.state = State.Idle
def handle(self, event: Event):
key = (self.state, event)
if key in transitions:
next_state, action = transitions[key]
if action:
action()
self.state = next_state
4 · 层次状态机(HSM)
4.1 为什么需要层次
普通 FSM 有个痛点:状态爆炸。比如一个设备有"运行中"和"待机"两个顶层状态,各自又有"正常"和"故障"两个子状态,再加"充电中"和"电池供电"——组合就是 2 × 2 × 2 = 8 个扁平状态。
层次状态机(Hierarchical State Machine)允许状态嵌套,子状态继承父状态的转移规则。
[设备]
├── [运行中]
│ ├── [正常]
│ └── [故障]
└── [待机]
├── [正常]
└── [故障]
"收到关机命令"这个事件在父状态"设备"层定义一次,所有子状态都继承。不需要在每个子状态里重复写。
4.2 实现思路
class HierarchicalState
{
public:
virtual ~HierarchicalState() = default;
virtual void handleEvent(Event e) = 0;
// 事件先交给当前子状态处理,子状态不处理才上升到父状态
void handleEventRecursive(Event e)
{
if (subState && subState->handleEventRecursive(e))
{
return; // 子状态处理了
}
handleEvent(e); // 父状态自己处理
}
std::unique_ptr<HierarchicalState> subState;
};
📌 HSM 的本质:事件冒泡机制——类似 DOM 事件冒泡。子状态先处理,处理不了就向上交给父状态。这让你可以在高层统一定义通用事件(如关机、报错),避免在每个子状态里重复。
5 · 状态机与正则表达式
5.1 正则表达式本质就是 NFA
每个正则表达式都可以编译成一个 NFA。例如 ab*c 的 NFA:
→(q0) --a--> (q1) --b--> (q1) --c--> [(q2)]
↑__________|
(ε 跳转,b* 可以匹配 0 次或多次)
5.2 从正则到 NFA 的构造(Thompson 算法)
Thompson 算法把正则表达式递归地翻译成 NFA:
| 正则片段 |
NFA 结构 |
单字符 a |
两个状态 + 一条 a 边 |
连接 AB |
A 的终态 → B 的初态 |
| 选择 `A |
B` |
闭包 A* |
新初态 → A → 回到初态(ε 边)+ 跳过 A(ε 边) |
📌 为什么了解这个有用?因为很多场景你不需要完整的正则引擎,只需要匹配固定模式——手写一个小 NFA 比引入正则库更轻量。比如协议帧的头部校验、词法分析器的 token 识别。
6 · 工程实战场景
6.1 TCP 连接状态机
TCP 协议本身就是一个经典的状态机,11 个状态。图中每条线上标注了触发条件(如"收到SYN"、"主动关闭"):

两条路径:
- 主动连接路径(客户端):
已关闭 → 主动打开 → 同步已发送 → 收到SYN+ACK → 已建立连接 → 主动关闭 → 等待关闭1 → 收到ACK → 等待关闭2 → 收到FIN → 等待超时 → 2MSL超时 → 已关闭
- 被动连接路径(服务端):
已关闭 → 被动打开 → 监听中 → 收到SYN → 同步已收到 → 收到ACK → 已建立连接 → 收到FIN → 等待关闭 → 关闭 → 最后确认 → 收到ACK → 已关闭
📌 为什么 TIME_WAIT 要等 2MSL?因为最后发出的 ACK 可能丢失,对方会重传 FIN。等 2MSL(最大报文生存时间的 2 倍)确保即使 ACK 丢失,重传的 FIN 也能到达并收到新的 ACK。不等待就关闭可能导致下一个复用此端口的连接收到旧 FIN。
6.2 游戏角色状态机
enum class CharacterState
{
Idle,
Moving,
Attacking,
Stunned,
Dead
};
// 角色状态机:每帧 update 驱动
void Character::update(float dt)
{
switch (m_state)
{
case CharacterState::Idle:
if (m_input.moveRequested())
{
m_state = CharacterState::Moving;
}
else if (m_input.attackRequested())
{
m_state = CharacterState::Attacking;
m_attackTimer = 0.5f;
}
break;
case CharacterState::Moving:
moveTowardsTarget(dt);
if (!m_input.moveRequested())
{
m_state = CharacterState::Idle;
}
break;
case CharacterState::Attacking:
m_attackTimer -= dt;
if (m_attackTimer <= 0)
{
m_state = CharacterState::Idle;
}
break;
case CharacterState::Stunned:
m_stunTimer -= dt;
if (m_stunTimer <= 0)
{
m_state = CharacterState::Idle;
}
break;
case CharacterState::Dead:
// 等待复活逻辑
break;
}
}
6.3 订单状态机(电商)
| 当前状态 \ 事件 |
支付成功 |
发货 |
确认收货 |
取消 |
超时未支付 |
| 待支付 |
待发货 |
— |
— |
已取消 |
已取消 |
| 待发货 |
— |
待收货 |
— |
已取消 |
— |
| 待收货 |
— |
— |
已完成 |
— |
— |
| 已完成 |
— |
— |
— |
— |
— |
| 已取消 |
— |
— |
— |
— |
— |
🚧 订单状态机的坑:并发问题。用户点"取消"的同时,支付回调到达"支付成功"——两个线程同时操作状态。必须加锁或用 CAS(compare-and-swap)保证状态转移的原子性。否则可能出现"已取消的订单又变成已发货"的幽灵订单。
6.4 MQTT 协议状态机
MQTT 客户端连接的核心状态流转:
[Disconnected] --connect--> [Connecting] --CONNACK received--> [Connected]
[Connected] --disconnect--> [Disconnected]
[Connected] --network error--> [Reconnecting] --CONNACK received--> [Connected]
[Reconnecting] --timeout--> [Disconnected]
7 · 状态机的测试
7.1 状态覆盖测试
确保每个状态都至少被访问一次。
7.2 转移覆盖测试
确保每条转移(状态图中的每条边)都至少被触发一次。
7.3 N-switch 覆盖
N-switch 指长度为 N+1 的转移序列。0-switch 就是转移覆盖。1-switch 要求所有"状态A → 状态B → 状态C"的连续两步路径都被覆盖。
📌 实际怎么测:写一个测试驱动器,自动枚举所有"状态 × 事件"组合,验证转移结果与状态转移表一致。对于未定义的组合,验证行为是"保持原状态"还是"报错"——取决于你的设计决策。
8 · 常见陷阱与最佳实践
8.1 非法转移
问题:在"待机"状态收到了"出货完成"事件——这不应该发生,但如果发生了怎么办?
方案:
- 忽略(静默丢弃)——适合容错系统
- 断言/崩溃——适合调试阶段,暴露 bug
- 记录日志 + 忽略——生产环境推荐
void handleEvent(Event event)
{
Transition& t = table[(int)currentState][(int)event];
if (t.next == currentState && t.action == nullptr)
{
// 未定义的转移
LOG_WARN("Unexpected event %d in state %d",
(int)event, (int)currentState);
return;
}
if (t.action)
{
t.action();
}
currentState = t.next;
}
8.2 状态泄漏
问题:状态机切换状态时,旧状态的资源没清理。
方案:在每个状态类实现 onEnter() / onExit() 钩子。
class State
{
public:
virtual void onEnter() {}
virtual void onExit() {}
virtual void handleEvent(Event e) = 0;
};
void VendingMachine::setState(std::unique_ptr<State> s)
{
if (currentState)
{
currentState->onExit();
}
currentState = std::move(s);
currentState->onEnter();
}
8.3 状态机 vs 状态变量
🚧 不是所有"有状态"的东西都需要状态机。如果你只有 2 个状态且转移逻辑就是"条件成立就翻面",用普通布尔变量就够了。状态机的价值在于约束转移路径——不是所有状态都能跳到所有状态,只有定义过的路径才合法。如果你的系统没有这种约束需求,硬套状态机只会增加复杂度。
8.4 最佳实践清单
- 先画状态图,再写代码
- 每个状态-事件组合都要有明确定义(转移、忽略、或报错)
- 状态转移要原子化(加锁或 CAS)
- 用
onEnter / onExit 管理状态资源
- 状态名用枚举,不要用字符串(避免拼写错误)
- 超过 5 个状态考虑状态表或状态模式
- 测试时覆盖所有转移路径,不只是正常流程
附录 A · 速查表
FSM 五元组
M = (Q, Σ, δ, q₀, F)
Q = 状态集合
Σ = 事件集合
δ = 转移函数 Q × Σ → Q
q₀ = 初始状态
F = 终止状态集合
实现模式选择
| 状态数 |
事件数 |
推荐模式 |
| 1-3 |
少 |
if-else |
| 3-5 |
少 |
switch-case |
| 5+ |
多 |
状态转移表 |
| 5+ |
每状态行为复杂 |
状态模式 |
| 有层次关系 |
任意 |
层次状态机 |
DFA vs NFA
| 维度 |
DFA |
NFA |
| 同一输入 |
唯一后继 |
可能多个后继 |
| 空转移 (ε) |
不允许 |
允许 |
| 空间 |
可能指数膨胀 |
紧凑 |
| 匹配速度 |
O(n) |
O(nm) 或转 DFA 后 O(n) |