更新时间: 2026-08-27**
C 里有个让人又爱又恨的东西叫联合体(union):一块内存,多种类型解释,省内存但极容易踩错。C++17 的 std::variant<T1, T2, T3> 是它的现代替代——一个变量可以装多种类型之一,但装的是哪种,由类型系统记录并保证。
本文要回答:variant 和 union 的本质区别是什么?怎么安全地读它(index/get/visit)?以及它到底解决什么问题?
一、union 的痛
先回顾 C 的 union 为什么危险:
union Data {
int i;
float f;
char buf[4];
};
union Data d;
d.i = 42; // 现在 d 按 int 解释
// 但如果后面有人读 d.f —— 得到的是按 float 解释的 42 的位模式,垃圾值union 只分配"最大成员"的内存,不记录当前存的是哪个类型。读错类型不会报错,只会得到无意义的值。这是 C 里一堆诡异 bug 的来源。C++ 传统上用"union + 标志位"补救,但要程序员自己保证标志和实际类型一致。
std::variant 把"当前是哪个类型"变成内部状态:
#include <variant>
#include <iostream>
std::variant<int, float, std::string> v;
v = 42; // 当前是 int
v = std::string("hi"); // 当前是 string(自动切换,旧的 int 被销毁)
// 安全取值:get<T>,类型不对会抛 std::bad_variant_access
try {
int x = std::get<int>(v); // 现在 v 装的是 string,抛异常
} catch (const std::bad_variant_access&) {
std::cout << "不是 int!\n";
}
// 先看 index 再取
switch (v.index()) {
case 0: /* int */ break;
case 1: /* float */ break;
case 2: /* string */ break;
}二、variant 的三种访问方式
variant 本身只是"存",读取是另一门学问。三种方式从粗到细:
1. index() 查询当前是第几个类型
std::size_t idx = v.index(); // 0、1、2……2. get<T> 按类型取 / get<idx> 按下标取
int a = std::get<int>(v); // 按类型
std::string s = std::get<2>(v); // 按下标
// 类型/下标不匹配 → 抛 std::bad_variant_access3. std::visit:把"访问器"应用到当前值上(最优雅)
std::variant<int, float, std::string> v;
std::visit([](const auto& value) {
// 泛型 lambda:不管当前是什么类型,都能处理
std::cout << value << "\n";
}, v);std::visit 配合泛型 lambda 是 variant 的黄金组合:lambda 的参数写 auto,编译器为每个可能类型都生成一个分支,当前是哪个类型就走哪个分支,且编译期就能检查所有分支都合法(类型列表是编译期已知的)。
@startuml
left to right direction
skinparam nodeFontSize 13
skinparam backgroundColor #FFFFFF
rectangle "std::variant<int, float, string>" as var #E8F1FF {
node "当前值(三选一)" as cur #D9FFE2
node "类型标记(当前是哪个)" as tag #DCE9FF
}
rectangle "std::visit(访问器, var)" as visit #FFF3D6 {
node "int 分支" as b1 #FFF3D6
node "float 分支" as b2 #FFF3D6
node "string 分支" as b3 #FFF3D6
}
var --> visit : "按类型标记分派"
@enduml三、variant 与 union 的对比
| 维度 | C union | C++ std::variant |
|---|---|---|
| 内存 | 最大成员大小 | 最大成员 + 类型标记 |
| 类型跟踪 | 无 | 内部标记,自动维护 |
| 读错类型 | 静默得到垃圾值 | 抛 bad_variant_access |
| 切换类型 | 手动(旧值丢弃) | 自动构造/析构 |
| 非平凡类型 | 不能放(含 string 不行) | 可以(variant<string, ...> 合法) |
| 安全 | 完全靠自觉 | 类型系统保证 |
最关键的提升:union 放不下 std::string 这类"非平凡类型"(有构造/析构的),variant 放得下——因为切换类型时它自动调用旧类型的析构和新类型的构造。这意味着"字符串 / 数字 / 对象三选一"这种现实需求,在 C 里根本没法用 union 表达,而 variant 是日常用品。
四、什么时候用 variant
该用的场景:
- 一个值可能是几种类型之一:解析器的 token(
int/double/string/bool)、配置文件的值 - 错误处理:
variant<Value, Error>表达"要么成功要么失败"(std::expected的简化形态) - 替代"继承 + 虚函数"的轻量多态:类型不多、行为不复杂时,variant + visit 比类层级更直白
不该用的场景:
- 类型很多/会扩展:visit 的分支会爆炸,不如继承
- 大部分时间只用一种类型:variant 的大小是"最大成员 + 标记",如果各类型大小悬殊,内存浪费明显
- 递归类型(variant 里有 variant 的引用)需要
std::variant+std::unique_ptr的组合包装,属于进阶
五、C 对照
// C:union + 标志位(手动保证一致)
union Token {
int num;
double fnum;
char str[32];
};
struct TokenBox {
int kind; // 0=num 1=fnum 2=str
union Token val;
};| 需求 | C(手写 kind + union) | C++(std::variant) |
|---|---|---|
| 记录当前类型 | 手动维护 kind | 自动 |
| 读值前检查 | 手动 switch kind | index / get / visit |
| 忘记同步 kind | 垃圾值 | 抛异常 / 编译期检查 |
| 切换类型 | 手动初始化 | 自动析构+构造 |
C 的手写方案是"可行性"级别——能用,但每个环节都是漏洞。variant 是"正确性"级别——类型标记、生命周期、访问检查全由库保证。
六、与本站主线衔接
- 51 篇(optional):
optional<T>可看作variant<nullopt_t, T>的特例 - 49 篇(lambda):
visit+ 泛型 lambda 是 lambda 的最强应用之一 - 26-28 篇(继承/多态):variant 是"编译期多态"的替代思路,与虚函数的运行期多态对照
- 高手层:
std::visit的编译期分派机制、variant 的异常安全,在templates.md延伸
七、一句话总结
std::variant<T1, T2, ...> 是类型安全的联合体——内部记录当前类型,get 取错会抛异常,visit + 泛型 lambda 优雅分派;它取代 C union"自己记类型"的危险模式,适合"一个值是多种类型之一"的场景,轻量替代继承多态。