状态机学习笔记

从理论到工程实践,系统梳理有限状态机(FSM)的核心知识。每节讲清"是什么、为什么、怎么实现、有什么坑",配多语言代码示例。


1 · 什么是状态机

1.1 直觉理解

状态机(State Machine)是一种数学模型,用来描述一个系统在任意时刻只处于一个明确的"状态",并且只能在特定"事件"触发下从一个状态跳到另一个状态。

📌 打个比方:红绿灯就是一个状态机。它只有三个状态——红、黄、绿。红灯倒计时结束后跳到绿灯,绿灯倒计时结束后跳到黄灯,黄灯倒计时结束后跳回红灯。不会从红直接跳到黄,也不会"同时是红和绿"。

1.2 有限状态机(FSM)的形式化定义

一个有限状态机(Finite State Machine, FSM)由五元组定义:

M = (Q, Σ, δ, q₀, F)
符号 含义 例子(红绿灯)
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 版(输出挂在转移上):

图 5 \xb7 Mealy 型门禁转移输出图

转移 输出 锁定 + 正确密码 → 解锁 "滴"(短声) 锁定 + 错误密码 → 报警 "滴滴滴"(急促声) 解锁 + 超时 → 锁定 "咔嗒"(锁门声) 报警 + 重置 → 锁定 无声

同样是"锁定 → 解锁",如果输入是"正确密码",输出"滴";但如果设计成"管理员密码"也能解锁,输出可以是"滴滴"(区别于普通用户)。输出跟输入有关。

📌 记忆口诀:Moore = 只看状态(M = Memory,记住当前状态就够);Mealy = 状态 + 输入一起决定(Mealy = More input,还要看输入)。

图 4 \xb7 Mealy 与 Moore 对比


2 · 状态图与状态转移表

2.1 状态图

状态图是最直观的表示方式。节点是状态,箭头是转移,箭头上标注触发事件。

以自动售货机为例:

图 1 \xb7 自动售货机状态图

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

图 3 \xb7 状态转移动画

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 模式的问题是:所有状态的行为挤在一个函数里,状态越多函数越长。状态模式反过来——把每个状态的行为拆到独立的类里,机器类只持有一个"当前状态"指针,把事件转发给它。

用一句话概括:不是"机器根据状态做不同的事",而是"当前状态的对象自己知道该怎么响应"。

结构图

图 6 \xb7 状态模式类图

图中有三个角色:

  • 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,不是状态对象自己的什么变量。

整个过程拆解:

  1. 机器调用 m.refundComplete() → 机器转发 currentState->refundComplete(*this),把自己传进去
  2. RefundingState::refundComplete(VendingMachine& m) 收到的 m 就是机器本身
  3. m.setState(std::make_unique<IdleState>()) → 调用机器的 setState(),把机器的 currentState 换成新状态
  4. setState() 内部 currentState = std::move(s) → 旧状态对象(RefundingState)被销毁,新状态对象(IdleState)上岗

所以状态对象自己不存状态——它只是替机器做决策,然后通过 m.setState() 帮机器换人。状态始终存在机器的 currentState 里,状态对象只是"过客"。

执行流程

以"投币 → 选择商品 → 出货完成"为例,看看事件是怎么在对象之间传递的:

图 7 \xb7 状态模式执行时序图

时序图里的规律一目了然:机器类自始至终只做一件事——转发。每次都是用户调用机器 → 机器转给当前状态 → 状态对象决定切换到谁。真正决策的是当前状态对象,机器只是传话的。这就是状态模式的精髓:决策权在状态对象手里,机器只是传话的。

和 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 的真正价值不在"少写几行",而在三个特定场景:

  1. 信号驱动——Qt 项目里 UI 事件本身就是信号(按钮点击、定时器、网络回调),addTransition 直接连线,不用写"收到事件 → 调函数"的胶水代码。这是 QStateMachine 最实际的价值,因为信号槽是 Qt 的基础设施
  2. 深层嵌套——2 层省 3 行,3 层省 7 行,4 层省 15 行……层次越深,重复越指数增长。但说实话,多数项目 2 层就够了
  3. 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"、"主动关闭"):

图 2 \xb7 TCP 连接状态机

两条路径:

  • 主动连接路径(客户端):已关闭 → 主动打开 → 同步已发送 → 收到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)