编译期状态机的核心是将每个状态和转移建模为独立类型,通过模板特化与类型推导实现零运行时开销;禁止使用运行时变量、std::variant或std::optional,所有逻辑必须在编译期确定并内联。

编译期状态机的核心不是“写个状态机”,而是把转移逻辑全压进类型系统
能真正做到零运行时开销的状态机,必须让每个状态、每次转移都对应一个独立类型,而非 enum 或 int 变量。模板参数就是你的状态标识符——比如用 struct StateA {} 和 struct StateB {} 作为模板参数,而不是传值。一旦你开始用 constexpr int state = 1; 去“模拟”状态,就已放弃编译期确定性,后续所有优化(如分支消除、内联展开)都会打折。
常见错误是试图在模板里藏一个运行时变量:比如 template<int s> struct FSM { static constexpr int state = S; };</int> ——这看似编译期,但若 S 来自用户输入或配置文件,实际根本无法实例化;更糟的是,有人接着在成员函数里写 if (state == 1) {...},这会让编译器保留分支,失去内联机会。
- 正确做法:每个状态是一个空类型,转移通过模板特化实现,例如
template<typename s> struct next_state;</typename>并为next_state<statea></statea>显式特化为StateB - 触发动作也应是模板函数重载,而非
switch:定义void handle(Events::Click, StateA)和void handle(Events::Click, StateB),靠 ADL + 重载解析分发 - 避免任何虚函数、
std::function、std::any,它们会引入间接跳转或堆分配
如何让状态转移不产生指令——靠 constexpr if + 类型擦除的反模式
很多人卡在“怎么根据事件决定下一个状态”,误以为要写 constexpr if (event == Events::Start) return StateB{};。问题在于:如果 event 是运行时值,constexpr if 根本不生效;如果硬塞 constexpr 变量,又丧失灵活性。
解法是彻底分离“决策点”和“执行点”:把事件也建模为类型,比如 struct EventStart {};,然后用模板参数包展开+偏特化匹配。这样,transition<statea eventstart></statea> 的结果类型在编译期唯一确定,编译器可直接内联整个转移链。
立即学习“C++免费学习笔记(深入)”;
- 不要写
template<typename s typename e> constexpr auto transition() { ... }</typename>返回值——返回类型应由特化决定,而非运行时计算 - 若需支持动态事件(如网络包解析),应在解析完成瞬间(比如
parse_header()返回后)立即进入模板实例化路径,即用if constexpr分支调用不同模板实例,而非在模板内部再做判断 - Clang/GCC 在 -O2 下对这种深度嵌套特化的内联非常激进;但 MSVC 2019 以前版本对超过 7 层的特化链可能放弃内联,需实测
objdump或perf annotate
性能陷阱:别让 std::variant 或 std::optional 悄悄拖慢你
有人想“保险起见”给状态机加个 std::optional<currentstate></currentstate>,或者用 std::variant<statea stateb statec></statea> 存当前状态——这等于主动引入运行时 tag 检查和内存布局不确定性。哪怕你只用一个状态,std::variant 仍需 1 字节 tag,且每次访问都要检查,编译器无法证明其恒定,分支预测器也帮不上忙。
真正零开销的做法是:当前状态不存储,只存在于模板实例的类型参数中;所有操作都是纯函数式推导,无状态变量。就像 std::tuple 的 get<i></i> 不需要运行时索引检查,因为 I 是非类型模板参数。
- 若必须持有一个“可变状态句柄”,用
std::integral_constant包装整型状态码,并确保它始终是constexpr,例如using current = std::integral_constant<int>;</int> -
std::visit对std::variant的调用永远有间接跳转开销,即使只有一种活跃类型;而模板特化是直接 call 指令,无 indirection - 调试时可用
static_assert(std::is_same_v<decltype fsm>>)</decltype>验证类型是否按预期推导,比打印日志更可靠
运行时极限性能的关键:让状态机成为 CPU 流水线的“透明层”
最终目标不是“看起来快”,而是让 CPU 几乎感觉不到状态机存在——指令缓存不跳转、分支预测器永不失败、寄存器分配完全连续。这意味着:所有状态处理函数必须被内联,且不能有跨函数的依赖(如修改全局变量、调用外部库)。
典型成功信号是:在 perf report 中,handle_event 相关函数消失,只剩业务逻辑的指令;objdump 显示从事件输入到下一个状态动作之间没有 call/jmp,只有 mov/add/cmp/test/jne 这类底层指令流。
- 强制内联不是加
[[gnu::always_inline]]就行——要确保函数体足够小(通常 - 避免模板参数过多导致实例爆炸:比如 5 个状态 × 4 种事件 × 3 种数据类型 = 60 个实例,LLVM 可能放弃优化某些冷路径;用
enable_if约束有效组合 - 最容易被忽略的一点:CPU 的 store-to-load forwarding 在状态转移中极易失效——如果你在转移函数末尾写
data_.x = 1;,下个状态立即读data_.x,但两者属于不同模板实例,编译器可能不认为它们共享同一内存位置,导致额外 cycle。解决方法是把共享数据抽成外部引用传入,而非藏在模板类内部



















