应优先用 enum class,仅当各状态携带互不兼容、共享字段少于50%的数据时才用 std::variant;需严格遵循状态 struct 的三个硬约束,并始终用 std::visit 分发、避免 std::get。

直接用 std::variant 实现有限状态机,只在状态携带**互不兼容的数据结构**时才真正值得;否则用 enum class 更轻、更清晰。
什么时候该用 std::variant 而不是 enum class
核心看字段是否“按状态分裂”:如果每个状态要带的数据类型和数量差异大,且共享字段少于 50%,std::variant 才发挥价值。
-
Connecting需要std::string address和int retry_count -
Connected需要int sockfd和std::chrono::seconds timeout -
Idle真的什么也不需要 —— 定义为struct Idle {}; - 硬塞进一个
struct FSM+enum class State,就得堆一堆std::optional<int></int>和std::optional<:string></:string>,访问前必须反复检查has_value() - 新增状态(比如
Reconnecting)时,enum class方案要手动改所有switch、所有判空逻辑、所有 assert;而std::variant漏写分支直接编译失败,强制你补全
std::visit 必须覆盖所有状态,且必须用 if constexpr
漏掉任一状态分支,编译器会报错——这不是缺陷,是保护机制。但新手常因编译失败删分支、加 else,结果又退化成运行时错误。
- lambda 参数必须是
const auto&,避免意外触发移动语义(尤其状态里含std::string或std::vector) - 必须用
std::decay_t<decltype></decltype>提取真实类型,否则引用类型不匹配,std::is_same_v判定失败 - 必须用
if constexpr,不用的话,哪怕当前s是Connected,编译器仍会尝试编译对Connecting::address的访问,直接报错 - 示例写法:
std::visit([](const auto& s) { using T = std::decay_t<decltype(s)> if constexpr (std::is_same_v<T, Idle>) { // do nothing } else if constexpr (std::is_same_v<T, Connecting>) { std::cout << "Connecting to " << s.address << "\n"; } else if constexpr (std::is_same_v<T, Connected>) { std::cout << "Active on fd " << s.sockfd << "\n"; } }, current_state);
状态 struct 设计的三个硬约束
每个状态 struct 不是容器,而是该状态的“数据契约”。违反任一约束,就会引入字段误访问、默认构造无意义或生命周期失控等隐患。
立即学习“C++免费学习笔记(深入)”;
- 禁止出现“可能为空”的成员:比如在
Connecting里塞std::optional<int> sockfd</int>,而它本该由构造函数强制传入 —— 正确签名是Connecting(int sock, std::string ip) - 空状态(如
Idle)必须定义为struct Idle {};,而非std::monostate:std::monostate语义模糊,后续加字段(如Idle{bool was_recently_active})会破坏二进制兼容性 - 禁止放入裸指针(如
std::variant<a b></a>):这等于放弃std::variant自动析构的优势,资源管理立刻失控
别用 std::get<t>(v)</t>,除非你 100% 确认当前类型
std::get<connecting>(state)</connecting> 是运行时断言:一旦 state 当前实际是 Idle 或 Error,程序立刻抛 std::bad_variant_access —— 这是线上才暴露的崩溃点,测试难覆盖。
-
std::get只适合极少数场景,比如刚执行完state = Connecting{...}后临时调试 - 若真需要轻量判断,改用
std::get_if<connecting>(&state)</connecting>,返回nullptr表示不匹配,安全但需手动判空 - 主逻辑中一律用
std::visit分发,把副作用(如日志、资源释放、超时重置)封装进独立transition函数,避免多处重复逻辑 - 状态更新必须原子赋值:
current_state = Connected{new_id, now};,而不是先改字段再切换状态,否则中间状态不一致
最易被忽略的是:状态 struct 的字段命名必须体现语义隔离——不同状态的 id 含义不同,就该叫 session_id、error_id、retry_id,不能共用一个名字;否则 std::visit 分支里看似在读 s.id,实际访问的是完全不同的内存布局。



















