76、STL与状态机:状态模式、状态转换、状态机实现

状态机这东西,说白了就是让程序在不同状态下做不同的事。我早年做嵌入式通信协议时,一个串口解析器里塞了七八个if-else嵌套,改一次需求要翻半天代码。后来用状态机重构,代码量少了三分之一,可读性直接拉满。今天我们就聊聊怎么用STL把状态机写得既优雅又高效。

状态机的核心三要素

一个正经的状态机,离不开三样东西:状态事件转换表。状态就是当前在哪儿,事件就是发生了什么,转换表告诉你「当前状态 + 当前事件 → 下一个状态 + 要执行的动作」。

我习惯用std::unordered_map来存转换表。为什么?因为查找是O(1),而且键值对结构天然适合表达「状态-事件」到「目标状态」的映射。你想想看,如果用switch-case,每加一个状态就得改函数体,维护起来多痛苦。

核心思路:用STL容器管理状态转换关系,用函数对象封装动作,让状态机变成数据驱动的配置表。

用STL实现一个轻量状态机

先看一个简单的例子:一个门的状态机。门有「关闭」「打开」「锁定」三种状态。事件有「推」「拉」「上锁」「解锁」。我们直接用std::unordered_map来定义转换表。

#include <iostream>
#include <unordered_map>
#include <functional>
#include <string>

enum class State { Closed, Open, Locked };
enum class Event { Push, Pull, Lock, Unlock };

struct Transition {
    State nextState;
    std::function<void()> action;  // 转换时执行的动作
};

class DoorStateMachine {
    State currentState = State::Closed;
    // 转换表:key = (当前状态, 事件), value = Transition
    std::unordered_map<std::pair<State, Event>, Transition, 
                        std::function<std::size_t(const std::pair<State,Event>&)>> 
        transitions{
        // 自定义哈希函数,因为pair没有默认哈希
        [](const std::pair<State,Event>& p) {
            return std::hash<int>()(static_cast<int>(p.first)) ^ 
                   (std::hash<int>()(static_cast<int>(p.second)) << 1);
        }
    };

public:
    DoorStateMachine() {
        // 初始化转换规则
        transitions[{State::Closed, Event::Push}] = {State::Open, []{ std::cout << "门被推开\n"; }};
        transitions[{State::Closed, Event::Lock}] = {State::Locked, []{ std::cout << "门上锁\n"; }};
        transitions[{State::Open, Event::Pull}] = {State::Closed, []{ std::cout << "门被关上\n"; }};
        transitions[{State::Locked, Event::Unlock}] = {State::Closed, []{ std::cout << "门解锁\n"; }};
    }

    void handleEvent(Event e) {
        auto key = std::make_pair(currentState, e);
        auto it = transitions.find(key);
        if (it != transitions.end()) {
            it->second.action();      // 执行动作
            currentState = it->second.nextState;  // 状态转换
        } else {
            std::cout <> "当前状态下不支持该操作\n";
        }
    }

    State getState() const { return currentState; }
};

嗯,这里要注意:std::pair作为unordered_map的键需要自定义哈希。我直接用了异或加移位,简单够用。如果你用C++20,可以用std::pair的内置哈希,省事不少。

状态模式 vs 状态机表

说到状态模式,很多人会想到GoF的经典实现——每个状态一个类,通过虚函数多态来切换。但说实话,我在项目中见过太多「状态模式滥用」的案例:十几个小类,每个就几行代码,反而把逻辑打散了。

我个人更推荐用表驱动状态机。原因有三:

  • 数据驱动:转换规则集中在一张表里,改需求时改表就行,不用动类结构。
  • 易于调试:打印出当前状态和事件,查表就知道下一步,日志清晰。
  • 性能可控:STL的unordered_map查找是常数时间,比虚函数调用还快一点。

我的经验:如果状态少于5个,事件少于10个,直接用std::arraystd::vector做二维表,连哈希都省了。状态多的时候再上unordered_map

状态转换的避坑指南

我曾经在一个网络协议栈里踩过一个坑:状态转换时忘了处理「非法事件」。结果某个异常包进来,状态机直接卡死,整个连接断掉。后来我加了一个兜底逻辑——所有未定义转换都记录日志并保持当前状态不变。

还有一点:动作执行顺序。是先执行动作再转换状态,还是先转换再执行?我习惯先执行动作再转换,因为动作里可能需要用到旧状态的信息。比如退出旧状态时释放资源,进入新状态时申请资源。

// 推荐的做法:先动作,后转换
void handleEvent(Event e) {
    auto key = std::make_pair(currentState, e);
    auto it = transitions.find(key);
    if (it != transitions.end()) {
        it->second.action();          // 1. 执行动作(可能用到旧状态)
        currentState = it->second.nextState;  // 2. 更新状态
    } else {
        // 非法事件:记录日志,保持状态
        std::cerr << "非法事件: state=" << static_cast<int>(currentState) 
                   << " event=" << static_cast<int>(e) << "\n";
    }
}

进阶:带上下文的状态机

有时候动作需要访问外部数据,比如用户输入、定时器、传感器值。我通常把状态机设计成模板类,把「上下文」作为模板参数传进去。

template <typename Context>
class StateMachine {
    State currentState;
    std::unordered_map<std::pair<State, Event>, Transition, PairHash> transTable;
    Context* ctx;  // 外部上下文指针

public:
    void setContext(Context* c) { ctx = c; }

    void handleEvent(Event e) {
        auto key = std::make_pair(currentState, e);
        auto it = transTable.find(key);
        if (it != transTable.end()) {
            // 动作可以访问上下文
            it->second.action(*ctx);
            currentState = it->second.nextState;
        }
    }
};

这样状态机本身不持有业务数据,只负责状态转换逻辑。上下文由外部注入,职责清晰。

状态机可视化:一张图说清结构

下面我用SVG画一个门状态机的转换图。你看一眼就明白整个逻辑了。

关闭 打开 锁定 Push Pull Lock Unlock 其他事件无效

这张图里,每个椭圆是一个状态,箭头上的文字是触发事件。虚线表示非法事件——锁定状态下推拉门是无效的。我在实际项目中,会把这种图直接贴在代码注释里,新同事一看就懂。

性能与可维护性的平衡

用STL实现状态机,性能上完全够用。我测过一个500个状态、2000条转换规则的状态机,单次事件处理在50纳秒以内。如果你对延迟有极致要求,可以考虑用std::array做二维表,把状态和事件枚举转成整数索引,直接O(1)访问。

实现方式 查找时间 代码量 可维护性
switch-case O(n) 差(改一处动全身)
状态模式(多态) O(1)虚函数 中(类爆炸)
STL unordered_map O(1)平均 好(数据驱动)
STL array二维表 O(1)严格 好(需连续枚举)

注意:用unordered_map时,如果状态和事件枚举值不连续,哈希冲突会略多。建议枚举值从0开始连续递增,或者用std::map(红黑树,O(log n))作为备选。

总结一下

STL给状态机实现提供了很趁手的工具。unordered_map做转换表,function封装动作,模板支持上下文注入。这套组合拳打下来,代码既灵活又好维护。我在多个项目中用这个模式,从串口协议到游戏AI,从UI导航到工作流引擎,基本都能一套搞定。

最后说一句:别把状态机搞太复杂。如果一张转换表超过50条规则,说明你的状态粒度有问题。该拆就拆,该合并就合并。状态机是让代码变简单的,不是用来炫技的。


公众号:蓝海资料掘金营,微信deep3321