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::array或std::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画一个门状态机的转换图。你看一眼就明白整个逻辑了。
这张图里,每个椭圆是一个状态,箭头上的文字是触发事件。虚线表示非法事件——锁定状态下推拉门是无效的。我在实际项目中,会把这种图直接贴在代码注释里,新同事一看就懂。
性能与可维护性的平衡
用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