裸 union 是 C 语言留下来的老物件:它能把不同类型的值叠在同一块内存上,但没人记得住当前存的是哪个类型 ,读错成员就是未定义行为(undefined behavior, UB),编译器一声不吭。C++17 的 std::variant<A, B, C> 把「联合体」和「类型标签」绑成了一个类型,从此读错类型会得到明确的异常或 nullptr,而不是一份随机数据。
1. 引子:union 的三个坑
cpp
// 片段:反例,不要这么写 ------ 裸 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不知道自己哪个成员是活着的,所以它不会帮你析构。
cpp
// 片段: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 把这些活全部接管了:它带一个索引记录当前类型,切换类型时自动析构旧的、构造新的。
2. variant 是什么:带类型标签的 union
std::variant<Ts...> 是一个永远是 Ts... 之一的类型安全联合体 :它要么持有第 0 个类型,要么第 1 个......永远不会「两个都有」,也不会「读错」。和裸 union 的差别,一张表说清:
| 维度 | 裸 union |
std::variant |
|---|---|---|
| 记录当前类型 | 不记录,靠人记 | 内置索引,index() 可查 |
| 读错类型 | UB(静默,编译器不报) | get<T> 抛 std::bad_variant_access;get_if 返回 nullptr |
装非平凡类型(std::string) |
默认构造被删除;要 placement new + 手动析构 | 直接支持,全自动 |
| 生命周期管理 | 手动,极易漏 | 切换 alternative 时自动析构 + 构造 |
| 大小 | max(sizeof(Ts)...) |
max(sizeof(Ts)...) + 索引(含对齐填充) |
| 适用场景 | POD、C 互操作、极致省内存 | 绝大多数 C++ 业务代码 |
索引不是免费的。实测一下它到底多占多少:
cpp
#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::variant<char, int, double>) = %zu\n",
sizeof(std::variant<char, int, double>));
std::printf("sizeof(std::string)=%zu\n", sizeof(std::string));
std::printf("sizeof(std::variant<std::string, int>) = %zu\n",
sizeof(std::variant<std::string, int>));
std::printf("sizeof(std::variant<char>) = %zu(只有一个 alternative)\n",
sizeof(std::variant<char>));
}
text
sizeof(char)=1 sizeof(int)=4 sizeof(double)=8
sizeof(std::variant<char, int, double>) = 16
sizeof(std::string)=32
sizeof(std::variant<std::string, int>) = 40
sizeof(std::variant<char>) = 2(只有一个 alternative)
怎么读:
variant<char, int, double>= 16 字节 = 8(最大成员double)+ 8(索引 + 对齐填充) 。裸union{char;int;double;}只有 8 字节 ------ 多出来的 8 字节买的就是「知道自己是谁」。variant<std::string, int>= 40 = 32(string)+ 8(索引 + 填充)。注意它没有堆分配 :string自己就躺在variant里,换类型时是原地析构 + 原地构造。variant<char>= 2 字节:只有一个 alternative 理论上不需要索引,但实现仍留了 1 字节索引 + 1 字节填充。
text
std::variant<char, 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::get<T>(v) / std::get<I>(v) |
抛 std::bad_variant_access |
T& / T&& |
确定类型时用;T 不唯一会编译失败 |
std::get_if<T>(&v) |
返回 nullptr,不抛异常 |
T* |
无异常场景(-fno-exceptions 或不想付异常代价) |
v.index() / holds_alternative<T>(v) |
安全 | 索引 / bool |
先查再取,或做日志 |
最常用的三个操作 ------ 查索引、holds_alternative、get:
cpp
#include <cstdio>
#include <string>
#include <variant>
int main() {
std::variant<int, std::string, double> v = 42;
std::printf("index = %zu\n", v.index());
std::printf("holds_alternative<int> = %d\n", std::holds_alternative<int>(v) ? 1 : 0);
std::printf("get<int> = %d\n", std::get<int>(v));
std::printf("get<0> = %d\n", std::get<0>(v));
v = std::string("hello"); // 换类型:析构 int、构造 string
std::printf("换类型后 index = %zu\n", v.index());
std::printf("get<std::string> = %s\n", std::get<std::string>(v).c_str());
if (auto p = std::get_if<double>(&v)) { // 指针形式:不抛异常
std::printf("是 double: %.1f\n", *p);
} else {
std::printf("get_if<double> 返回 nullptr(当前不是 double)\n");
}
v = 3.5;
std::printf("再换类型后 index = %zu\n", v.index());
if (auto p = std::get_if<double>(&v)) std::printf("是 double: %.1f\n", *p);
std::variant<int, std::string> fresh;
std::printf("默认构造的 index = %zu(取第一个类型)\n", fresh.index());
}
text
index = 0
holds_alternative<int> = 1
get<int> = 42
get<0> = 42
换类型后 index = 1
get<std::string> = hello
get_if<double> 返回 nullptr(当前不是 double)
再换类型后 index = 2
是 double: 3.5
默认构造的 index = 0(取第一个类型)
三个容易忽略的细节:
get<T>与get<I>二选一 :类型在variant里唯一时用get<T>(更可读);不唯一就只能get<I>。get_if收的是指针 :std::get_if<double>(&v),传引用编不过 ------ 因为它要能返回nullptr。- 默认构造取第一个类型 ,且该类型必须可默认构造:
std::variant<int, std::string> v;是int{0};如果第一个类型不可默认构造,这个variant就不能默认构造(std::variant<NoDefault, int>需要显式初始化)。
官方文档:std::visit、std::get(std::variant)、std::holds_alternative
4. std::visit:推荐的访问方式
std::get<T> 要求你提前知道 类型,这在「我想按当前类型做不同处理」的场景下就不够用了。这时该用 std::visit(visitor, v) ------ 它把 variant 当前持有的类型「喂」给一个可调用对象,由编译器挑出匹配的重载。
C++17 里没有现成的「多 lambda 打包器」,惯用做法是自己写一个 Overloaded:
text
Overloaded 惯用法(C++17)
──────────────────────────────────────────────────────
template <typename... Ts>
struct Overloaded : Ts... { using Ts::operator()...; };
template <typename... Ts> Overloaded(Ts...) -> Overloaded<Ts...>;
↑ 推导指引(deduction guide,C++17)
作用:把 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 链那样漏到运行时才发现
──────────────────────────────────────────────────────
对比一下「visit + Overloaded」和「get_if 逐类型试探」两种写法:
cpp
#include <cstdio>
#include <string>
#include <variant>
#include <vector>
template <typename... Ts>
struct Overloaded : Ts... {
using Ts::operator()...;
};
template <typename... Ts>
Overloaded(Ts...) -> Overloaded<Ts...>;
using Value = std::variant<int, 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_if<int>(&v)) return "int: " + std::to_string(*p);
if (auto p = std::get_if<std::string>(&v)) return "string: " + *p;
if (auto p = std::get_if<bool>(&v)) return "bool: " + std::string(*p ? "true" : "false");
return "(无值)";
}
int main() {
const std::vector<Value> values = {42, std::string("hi"), true};
for (const Value& v : values) {
std::printf("%-12s | %s\n", dumpByVisit(v).c_str(), dumpByHand(v).c_str());
}
}
text
int: 42 | int: 42
string: hi | string: hi
bool: true | bool: true
| 对比项 | std::visit + Overloaded |
std::get_if 串行试探 |
|---|---|---|
| 漏写某个类型 | 编译期报错,必须补全 | 静默走 fallback,可能漏到运行时 |
| 分支逻辑 | 每个 lambda 一个分支,天然隔离 | 一串 if,容易写错顺序 |
| 返回值 | 各分支返回类型会做统一推导 | 每处 return 各自返回 |
| 新增 alternative 时 | 立刻编译失败,提示你去补分支 | 什么都不发生(危险) |
| 适合场景 | 状态机、AST、表达式求值、消息分发 | 只想处理其中一两种,其余按默认走 |
结论很清晰:只想处理其中一两种类型时用 get_if,要穷举所有可能时用 visit ------ 后者的最大价值是「加了新类型就编不过」,强制你更新所有分发点。
5. 边界情况:重复类型与默认构造
三个容易踩的点:
cpp
// 片段:variant 的三个边界规则
#include <variant>
// ① 重复类型:get<T> 与 holds_alternative<T> 都会编译失败
std::variant<int, int> dup; // ✓ 默认构造:取第一个 alternative,index = 0
dup.emplace<1>(42); // ✓ 按下标就地构造第二个 int
// std::get<int>(dup); // ✗ 编译错误:int 不唯一,编译器不知道取哪个
// std::holds_alternative<int>(dup); // ✗ 同样编不过
int second = std::get<1>(dup); // ✓ 只能按下标取
// ② 默认构造取第一个类型,且该类型必须可默认构造
std::variant<int, std::string> ok; // ✓ index = 0,即 int{0}
// std::variant<NoDefaultCtor, int> bad; // ✗ 第一个类型不可默认构造 → variant 不可默认构造
// ③ variant<A, B> 与 A / B 之间的转换是「非显式」的(可隐式构造与赋值)
std::variant<int, std::string> v = 7; // ✓ 隐式构造:走 int
v = std::string("hi"); // ✓ 隐式赋值:析构 int、构造 string
6. 冷知识:valueless_by_exception ------「无值」的 variant
variant 有一个其他容器都没有的状态:它可能谁都不持有 。这个状态叫 valueless_by_exception()。
成因是这样的:切换 alternative 时,variant 必须先析构旧的类型,再构造新的类型 。如果新类型的构造(拷贝/移动)抛了异常,旧的已经被销毁,新的没建成 ------ variant 就落入了「无值」状态。
text
variant 从「持有 A」切换到「持有 B」的三种结局
──────────────────────────────────────────────────────
① A 析构成功 → B 构造成功
→ index 指向 B,一切正常
② A 析构抛异常
→ 是 UB 级的事故(析构绝不该抛,见 CG E.16)
③ A 析构成功 → B 构造抛异常
→ 旧的没了、新的没建成
→ variant 进入 valueless_by_exception() 状态
· index() == std::variant_npos(libstdc++ 下等于 SIZE_MAX)
· 用 std::visit 访问 → 抛 std::bad_variant_access
· 用 get<T> 访问 → 抛 std::bad_variant_access
但仍可以重新赋值「救回来」:v = 7; 之后就恢复正常
──────────────────────────────────────────────────────
实测一下这个状态(ThrowOnMove 是故意写坏的反例 :移动操作抛异常,违反 Core Guidelines 的 C.66):
cpp
#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::variant<int, 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 get<int>=%d\n",
v.index(), v.valueless_by_exception() ? 1 : 0, std::get<int>(v));
}
text
初始: index=0 valueless=0
赋值抛异常: 移动构造抛了
异常后 valueless_by_exception = 1
异常后 index() = 18446744073709551615
index() == std::variant_npos: 1
重新赋值后 index=0 valueless=0 get<int>=7
index() 打印出的 18446744073709551615 就是 SIZE_MAX,即 std::variant_npos。实践建议只有一条:所有移动/拷贝操作都标 noexcept(《异常安全三级保证》讲的规则) ,做到之后 valueless_by_exception 在正常代码里永远不会为 true;但如果你要写库、要处理别人的类型,判断一下它总没错。
7. variant 与 any 怎么选
这两个是 C++17 里最容易混的一对。核心区别只有一句:类型集合是编译期已知还是运行时才知道。
| 维度 | std::variant<A, B, C> |
std::any |
|---|---|---|
| 类型集合确定时机 | 编译期固定 | 运行时任意 |
| 存储位置 | 栈上内嵌(大小 = 最大 alternative + 索引) | 通常堆分配(可能做小对象优化) |
| 类型安全 | 强:编译期枚举,visit 必须覆盖全部分支 |
弱:any_cast 类型不符才在运行时报错 |
| 取值方式 | visit / get<T> / get_if |
any_cast<T> |
| 「空」状态 | 只有 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 一次性对「状态 × 事件」做模式匹配 ------ 枚举所有组合,编译期保证没有遗漏。
cpp
#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::variant<Open, Close, Reset>;
using State = std::variant<Idle, Active, Faulted>;
template <typename... Ts>
struct Overloaded : Ts... {
using Ts::operator()...;
};
template <typename... Ts>
Overloaded(Ts...) -> Overloaded<Ts...>;
// 状态 × 事件 → 新状态:两个 variant 一起 visit,写成一串「模式」
State transition(const State& state, const Event& event) {
return std::visit(Overloaded{
[](const Idle&, const Open& e) { return State{Active{static_cast<int>(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_if<Active>(&s)) { // 已知期望类型时,get_if 更直接
std::printf("Active.value = %d\n", p->value);
}
std::printf("holds_alternative<Idle> = %d, index = %zu\n",
std::holds_alternative<Idle>(s) ? 1 : 0, s.index());
}
text
初始状态: Idle
事件后状态: Active(5)
事件后状态: Idle
事件后状态: Active(5)
事件后状态: Idle
Active.value = 5
holds_alternative<Idle> = 0, index = 1
这个写法有三个好处:零堆分配 (状态和事件都内嵌在 variant 里)、零虚函数 (visit 在编译期展开成跳转表,没有间接调用开销)、穷举性由编译器保证 (以后往 Event 里加一个 Pause,transition 里的 fallback lambda 会兜住它,但 describe 那样的穷举写法会立刻编译失败并提醒你补分支)。
9. 延伸阅读
- std::variant --- cppreference:
variant的完整接口与所有valueless_by_exception相关说明 - std::visit --- cppreference:包括「访问 valueless 的 variant 会抛
bad_variant_access」这一条款 - std::variant::valueless_by_exception --- cppreference:无值状态的定义与触发条件
- std::bad_variant_access --- cppreference:
get/visit在类型不符或无值时抛的异常 - C++ Core Guidelines · C.66 Make move operations noexcept:想彻底避开
valueless_by_exception,就靠这一条
10. 一句话总结
std::variant<A, B, C> 是「带索引的 union」:读错类型抛 bad_variant_access 而不是 UB,切换 alternative 时自动析构旧的、构造新的,代价约等于最大成员的 sizeof 加一个索引;访问优先用 std::visit + Overloaded(漏写分支编译期就报错),需要无异常或只关心个别类型时用 get_if,get<T> 适合已知类型;重复类型只能按下标取,默认构造取第一个类型;类型集合编译期已知就用它,只有类型真到运行时才确定时才用 std::any。