资讯动态

std::variant 完全指南:类型安全的 union 与 std::visit 用法

发布时间:2026/10/7 2:41:49 来源:尧图企业网站定制
裸union是 C 语言留下来的老物件它能把不同类型的值叠在同一块内存上但没人记得住当前存的是哪个类型读错成员就是未定义行为undefined behavior, UB编译器一声不吭。C17 的std::variantA, B, C把「联合体」和「类型标签」绑成了一个类型从此读错类型会得到明确的异常或nullptr而不是一份随机数据。1. 引子union 的三个坑// 片段反例不要这么写 —— 裸 union 不记录「当前是哪个类型」 union Raw { int i; double d; }; Raw r; r.i 42; // 现在 r 里是 int std::printf(%f\n, r.d); // ✗ 读错成员UB编译器不会拦运行结果随机三个坑依次是不记录类型union只提供一块共享内存谁写的、写的是哪个成员全靠程序员脑内维护。上面那行r.d在多数平台上不会崩只会打印一个垃圾值这是最坏的情况因为它看起来是正常运行的。装不了非平凡类型std::string、std::vector这类有构造/析构函数的类型放进union会让union的默认构造被隐式删除非要放就得自己做 placement new 构造、自己记得调析构函数。析构要手写union不知道自己哪个成员是活着的所以它不会帮你析构。// 片段union 想装 std::string就必须全程手工管理生命周期 union RawStorage { std::string s; // 允许写这个声明但 union 的默认构造已被隐式删除 int i; }; // RawStorage x; // ✗ 编译失败默认构造被删除 // 想用只能 // RawStorage x; // new (x.s) std::string(hi); // placement new 手工构造 // x.s.~basic_string(); // 手工析构而且必须记得std::variant把这些活全部接管了它带一个索引记录当前类型切换类型时自动析构旧的、构造新的。官方文档std::variant — cppreference2. variant 是什么带类型标签的 unionstd::variantTs...是一个永远是 Ts... 之一的类型安全联合体它要么持有第 0 个类型要么第 1 个……永远不会「两个都有」也不会「读错」。和裸union的差别一张表说清维度裸unionstd::variant记录当前类型不记录靠人记内置索引index()可查读错类型UB静默编译器不报getT抛std::bad_variant_accessget_if返回nullptr装非平凡类型std::string默认构造被删除要 placement new 手动析构直接支持全自动生命周期管理手动极易漏切换 alternative 时自动析构 构造大小max(sizeof(Ts)...)max(sizeof(Ts)...) 索引含对齐填充适用场景POD、C 互操作、极致省内存绝大多数 C 业务代码索引不是免费的。实测一下它到底多占多少#include cstdio #include string #include variant int main() { std::printf(sizeof(char)%zu sizeof(int)%zu sizeof(double)%zu\n, sizeof(char), sizeof(int), sizeof(double)); std::printf(sizeof(std::variantchar, int, double) %zu\n, sizeof(std::variantchar, int, double)); std::printf(sizeof(std::string)%zu\n, sizeof(std::string)); std::printf(sizeof(std::variantstd::string, int) %zu\n, sizeof(std::variantstd::string, int)); std::printf(sizeof(std::variantchar) %zu只有一个 alternative\n, sizeof(std::variantchar)); }sizeof(char)1 sizeof(int)4 sizeof(double)8 sizeof(std::variantchar, int, double) 16 sizeof(std::string)32 sizeof(std::variantstd::string, int) 40 sizeof(std::variantchar) 2只有一个 alternative怎么读variantchar, int, double 16 字节 8最大成员double 8索引 对齐填充。裸union{char;int;double;}只有 8 字节 —— 多出来的 8 字节买的就是「知道自己是谁」。variantstd::string, int 40 32string 8索引 填充。注意它没有堆分配string自己就躺在variant里换类型时是原地析构 原地构造。variantchar 2 字节只有一个 alternative 理论上不需要索引但实现仍留了 1 字节索引 1 字节填充。std::variantchar, int, double 的内存布局16 字节 ────────────────────────────────────────────────────────── ┌──────────────────────────────┬───────────────────────┐ │ 存储区union { char; int; │ 索引0/1/2 表示当前 │ │ double; }取最大的 8 字节 │ 活跃的是哪个 alternative│ │ 同一时刻只构造其中一个 │ 含对齐填充 │ └──────────────────────────────┴───────────────────────┘ 8 字节 sizeof(double) 8 字节 切类型时发生什么 ① 析构当前 alternative按索引调用正确的析构函数 ② 在原存储区原地构造新的 alternative ③ 更新索引 —— 全程零堆分配除非新 alternative 自己内部用了堆 ──────────────────────────────────────────────────────────一句话variant用「索引 最大成员大小的存储」换来了类型安全与自动生命周期代价可控、无间接跳转。3. 访问方式总览visit 是首选get 是补充取值有四组入口职责完全不同入口类型不对时返回说明std::visit(visitor, v)visitor 无法调用编译期报错visitor 的返回类型推荐一次处理所有分支编译期强制覆盖std::getT(v)/std::getI(v)抛std::bad_variant_accessT/T确定类型时用T不唯一会编译失败std::get_ifT(v)返回nullptr不抛异常T*无异常场景-fno-exceptions或不想付异常代价v.index()/holds_alternativeT(v)安全索引 /bool先查再取或做日志最常用的三个操作查索引、holds_alternative、get#include cstdio #include string #include variant int main() { std::variantint, std::string, double v 42; std::printf(index %zu\n, v.index()); std::printf(holds_alternativeint %d\n, std::holds_alternativeint(v) ? 1 : 0); std::printf(getint %d\n, std::getint(v)); std::printf(get0 %d\n, std::get0(v)); v std::string(hello); // 换类型析构 int、构造 string std::printf(换类型后 index %zu\n, v.index()); std::printf(getstd::string %s\n, std::getstd::string(v).c_str()); if (auto p std::get_ifdouble(v)) { // 指针形式不抛异常 std::printf(是 double: %.1f\n, *p); } else { std::printf(get_ifdouble 返回 nullptr当前不是 double\n); } v 3.5; std::printf(再换类型后 index %zu\n, v.index()); if (auto p std::get_ifdouble(v)) std::printf(是 double: %.1f\n, *p); std::variantint, std::string fresh; std::printf(默认构造的 index %zu取第一个类型\n, fresh.index()); }index 0 holds_alternativeint 1 getint 42 get0 42 换类型后 index 1 getstd::string hello get_ifdouble 返回 nullptr当前不是 double 再换类型后 index 2 是 double: 3.5 默认构造的 index 0取第一个类型三个容易忽略的细节getT与getI二选一类型在variant里唯一时用getT更可读不唯一就只能getI。get_if收的是指针std::get_ifdouble(v)传引用编不过 —— 因为它要能返回nullptr。默认构造取第一个类型且该类型必须可默认构造std::variantint, std::string v;是int{0}如果第一个类型不可默认构造这个variant就不能默认构造std::variantNoDefault, int需要显式初始化。官方文档std::visit、std::get(std::variant)、std::holds_alternative4. std::visit推荐的访问方式std::getT要求你提前知道类型这在「我想按当前类型做不同处理」的场景下就不够用了。这时该用std::visit(visitor, v)它把variant当前持有的类型「喂」给一个可调用对象由编译器挑出匹配的重载。C17 里没有现成的「多 lambda 打包器」惯用做法是自己写一个OverloadedOverloaded 惯用法C17 ────────────────────────────────────────────────────── template typename... Ts struct Overloaded : Ts... { using Ts::operator()...; }; template typename... Ts Overloaded(Ts...) - OverloadedTs...; ↑ 推导指引deduction guideC17 作用把 N 个 lambda 打包成一个可调用对象各自成为重载 Overloadedλ0, λ1, λ2 ├─ operator()(int) ← 来自 λ0 ├─ operator()(const std::string) ← 来自 λ1 └─ operator()(bool) ← 来自 λ2 std::visit 按 variant 里「实际活跃的类型」选中对应重载 漏写某个类型的处理 → 编译期直接报 no matching function 不会像 if/else 链那样漏到运行时才发现 ──────────────────────────────────────────────────────对比一下「visitOverloaded」和「get_if逐类型试探」两种写法#include cstdio #include string #include variant #include vector template typename... Ts struct Overloaded : Ts... { using Ts::operator()...; }; template typename... Ts Overloaded(Ts...) - OverloadedTs...; using Value std::variantint, std::string, bool; // 方式一std::visit Overloaded编译期强制覆盖所有 alternative std::string dumpByVisit(const Value v) { return std::visit(Overloaded{ [](int i) { return std::string(int: ) std::to_string(i); }, [](const std::string s) { return std::string(string: ) s; }, [](bool b) { return std::string(bool: ) (b ? true : false); }, }, v); } // 方式二get_if 逐个试探显式处理「都不是」但漏写不会报错 std::string dumpByHand(const Value v) { if (auto p std::get_ifint(v)) return int: std::to_string(*p); if (auto p std::get_ifstd::string(v)) return string: *p; if (auto p std::get_ifbool(v)) return bool: std::string(*p ? true : false); return (无值); } int main() { const std::vectorValue values {42, std::string(hi), true}; for (const Value v : values) { std::printf(%-12s | %s\n, dumpByVisit(v).c_str(), dumpByHand(v).c_str()); } }int: 42 | int: 42 string: hi | string: hi bool: true | bool: true对比项std::visitOverloadedstd::get_if串行试探漏写某个类型编译期报错必须补全静默走 fallback可能漏到运行时分支逻辑每个 lambda 一个分支天然隔离一串if容易写错顺序返回值各分支返回类型会做统一推导每处return各自返回新增 alternative 时立刻编译失败提示你去补分支什么都不发生危险适合场景状态机、AST、表达式求值、消息分发只想处理其中一两种其余按默认走结论很清晰只想处理其中一两种类型时用get_if要穷举所有可能时用visit后者的最大价值是「加了新类型就编不过」强制你更新所有分发点。5. 边界情况重复类型与默认构造三个容易踩的点// 片段variant 的三个边界规则 #include variant // ① 重复类型getT 与 holds_alternativeT 都会编译失败 std::variantint, int dup; // ✓ 默认构造取第一个 alternativeindex 0 dup.emplace1(42); // ✓ 按下标就地构造第二个 int // std::getint(dup); // ✗ 编译错误int 不唯一编译器不知道取哪个 // std::holds_alternativeint(dup); // ✗ 同样编不过 int second std::get1(dup); // ✓ 只能按下标取 // ② 默认构造取第一个类型且该类型必须可默认构造 std::variantint, std::string ok; // ✓ index 0即 int{0} // std::variantNoDefaultCtor, int bad; // ✗ 第一个类型不可默认构造 → variant 不可默认构造 // ③ variantA, B 与 A / B 之间的转换是「非显式」的可隐式构造与赋值 std::variantint, std::string v 7; // ✓ 隐式构造走 int v std::string(hi); // ✓ 隐式赋值析构 int、构造 string官方文档std::variant 的模板参数与构造6. 冷知识valueless_by_exception ——「无值」的 variantvariant有一个其他容器都没有的状态它可能谁都不持有。这个状态叫valueless_by_exception()。成因是这样的切换 alternative 时variant必须先析构旧的类型再构造新的类型。如果新类型的构造拷贝/移动抛了异常旧的已经被销毁新的没建成。variant就落入了「无值」状态。variant 从「持有 A」切换到「持有 B」的三种结局 ────────────────────────────────────────────────────── ① A 析构成功 → B 构造成功 → index 指向 B一切正常 ② A 析构抛异常 → 是 UB 级的事故析构绝不该抛见 CG E.16 ③ A 析构成功 → B 构造抛异常 → 旧的没了、新的没建成 → variant 进入 valueless_by_exception() 状态 · index() std::variant_nposlibstdc 下等于 SIZE_MAX · 用 std::visit 访问 → 抛 std::bad_variant_access · 用 getT 访问 → 抛 std::bad_variant_access 但仍可以重新赋值「救回来」v 7; 之后就恢复正常 ──────────────────────────────────────────────────────实测一下这个状态ThrowOnMove是故意写坏的反例移动操作抛异常违反 Core Guidelines 的 C.66#include cstdio #include stdexcept #include variant // 反例不要这么写移动构造会抛CG C.66 要求移动操作标 noexcept // 这里故意这么写只为演示 variant 的「无值」状态 struct ThrowOnMove { int id; explicit ThrowOnMove(int v) : id(v) {} ThrowOnMove(const ThrowOnMove other) : id(other.id) {} ThrowOnMove operator(const ThrowOnMove) default; ThrowOnMove(ThrowOnMove) { throw std::runtime_error(移动构造抛了); } ThrowOnMove operator(ThrowOnMove) { throw std::runtime_error(移动赋值抛了); } }; int main() { std::variantint, ThrowOnMove v 42; std::printf(初始: index%zu valueless%d\n, v.index(), v.valueless_by_exception() ? 1 : 0); try { v ThrowOnMove(9); // 换 alternative先析构 int再构造 ThrowOnMove } catch (const std::runtime_error e) { std::printf(赋值抛异常: %s\n, e.what()); } std::printf(异常后 valueless_by_exception %d\n, v.valueless_by_exception() ? 1 : 0); std::printf(异常后 index() %zu\n, v.index()); std::printf(index() std::variant_npos: %d\n, (v.index() std::variant_npos) ? 1 : 0); v 7; // 从「无值」恢复可以重新赋值 std::printf(重新赋值后 index%zu valueless%d getint%d\n, v.index(), v.valueless_by_exception() ? 1 : 0, std::getint(v)); }初始: index0 valueless0 赋值抛异常: 移动构造抛了 异常后 valueless_by_exception 1 异常后 index() 18446744073709551615 index() std::variant_npos: 1 重新赋值后 index0 valueless0 getint7index()打印出的18446744073709551615就是SIZE_MAX即std::variant_npos。实践建议只有一条所有移动/拷贝操作都标noexcept《异常安全三级保证》讲的规则做到之后valueless_by_exception在正常代码里永远不会为true但如果你要写库、要处理别人的类型判断一下它总没错。官方文档std::variant::valueless_by_exception、std::variant_npos7. variant 与 any 怎么选这两个是 C17 里最容易混的一对。核心区别只有一句类型集合是编译期已知还是运行时才知道。维度std::variantA, B, Cstd::any类型集合确定时机编译期固定运行时任意存储位置栈上内嵌大小 最大 alternative 索引通常堆分配可能做小对象优化类型安全强编译期枚举visit必须覆盖全部分支弱any_cast类型不符才在运行时报错取值方式visit/getT/get_ifany_castT「空」状态只有valueless_by_exception这种异常态有正常空状态默认构造即空典型场景状态机、AST、解析结果、消息分发、JSON值插件系统、异构容器、类型真的要到运行时才知道选型顺序类型集合编译期已知 →variant只是「可能没有值」→optional类型真的到运行时才确定 → 才轮到any。std::any的细节在《C std::any 完全指南》两篇的选型表可以对照看。8. 完整示例用 variant visit 写一个状态机需求一个任务的状态机吃三种事件Open/Close/Reset在三种状态Idle/Active/Faulted之间迁移。用variant表达事件和状态用std::visit一次性对「状态 × 事件」做模式匹配枚举所有组合编译期保证没有遗漏。#include cstdio #include variant #include string struct Open { std::string id; }; struct Close {}; struct Reset {}; struct Idle {}; struct Active { int value; }; struct Faulted { std::string reason; }; using Event std::variantOpen, Close, Reset; using State std::variantIdle, Active, Faulted; template typename... Ts struct Overloaded : Ts... { using Ts::operator()...; }; template typename... Ts Overloaded(Ts...) - OverloadedTs...; // 状态 × 事件 → 新状态两个 variant 一起 visit写成一串「模式」 State transition(const State state, const Event event) { return std::visit(Overloaded{ [](const Idle, const Open e) { return State{Active{static_castint(e.id.size())}}; }, [](const Active, const Close) { return State{Idle{}}; }, [](const Active, const Reset) { return State{Idle{}}; }, [](const Faulted, const Reset) { return State{Idle{}}; }, [](const auto s, const auto) { return State{s}; }, // 其余组合保持不变 }, state, event); } std::string describe(const State s) { return std::visit(Overloaded{ [](const Idle) { return std::string(Idle); }, [](const Active a) { return Active( std::to_string(a.value) ); }, [](const Faulted f) { return Faulted( f.reason ); }, }, s); } int main() { State s Idle{}; std::printf(初始状态: %s\n, describe(s).c_str()); const Event events[] {Open{job-1}, Close{}, Open{job-2}, Reset{}}; for (const Event e : events) { s transition(s, e); std::printf(事件后状态: %s\n, describe(s).c_str()); } s transition(Idle{}, Open{job-9}); if (auto p std::get_ifActive(s)) { // 已知期望类型时get_if 更直接 std::printf(Active.value %d\n, p-value); } std::printf(holds_alternativeIdle %d, index %zu\n, std::holds_alternativeIdle(s) ? 1 : 0, s.index()); }初始状态: Idle 事件后状态: Active(5) 事件后状态: Idle 事件后状态: Active(5) 事件后状态: Idle Active.value 5 holds_alternativeIdle 0, index 1这个写法有三个好处零堆分配状态和事件都内嵌在variant里、零虚函数visit在编译期展开成跳转表没有间接调用开销、穷举性由编译器保证以后往Event里加一个Pausetransition里的 fallback lambda 会兜住它但describe那样的穷举写法会立刻编译失败并提醒你补分支。9. 延伸阅读std::variant — cppreferencevariant的完整接口与所有valueless_by_exception相关说明std::visit — cppreference包括「访问 valueless 的 variant 会抛bad_variant_access」这一条款std::variant::valueless_by_exception — cppreference无值状态的定义与触发条件std::bad_variant_access — cppreferenceget/visit在类型不符或无值时抛的异常C Core Guidelines · C.66 Make move operations noexcept想彻底避开valueless_by_exception就靠这一条本知识库内的相关篇目《C std::any 完全指南一个能装任何类型的容器》 —— std::any 是 C17 引入的类型擦除容器能在运行时持有任意可拷贝类型。《std::optional 完全指南别再用 -1 和 nullptr 表达「没有值」》 —— std::optionalC17表示「可能有一个 T《noexcept 为什么移动构造必须标从 vector 扩容退化成拷贝说起》 —— noexcept 有两种用法——修饰函数和当运算符用它不代表「不会抛异常」10. 一句话总结std::variantA, B, C是「带索引的 union」读错类型抛bad_variant_access而不是 UB切换 alternative 时自动析构旧的、构造新的代价约等于最大成员的sizeof加一个索引访问优先用std::visitOverloaded漏写分支编译期就报错需要无异常或只关心个别类型时用get_ifgetT适合已知类型重复类型只能按下标取默认构造取第一个类型类型集合编译期已知就用它只有类型真到运行时才确定时才用std::any。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑