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

裸 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,编译器不会拦,运行结果随机

三个坑依次是:

  1. 不记录类型 :union 只提供一块共享内存,谁写的、写的是哪个成员,全靠程序员脑内维护。上面那行 r.d 在多数平台上不会崩,只会打印一个垃圾值 ------ 这是最坏的情况,因为它看起来是正常运行的。
  2. 装不了非平凡类型 :std::string、std::vector 这类有构造/析构函数的类型放进 union,会让 union 的默认构造被隐式删除;非要放,就得自己做 placement new 构造、自己记得调析构函数。
  3. 析构要手写 :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 把这些活全部接管了:它带一个索引记录当前类型,切换类型时自动析构旧的、构造新的。

官方文档:std::variant --- cppreference

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

官方文档:std::variant 的模板参数与构造

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;但如果你要写库、要处理别人的类型,判断一下它总没错。

官方文档:std::variant::valueless_by_exception、std::variant_npos

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. 延伸阅读

10. 一句话总结

std::variant<A, B, C> 是「带索引的 union」:读错类型抛 bad_variant_access 而不是 UB,切换 alternative 时自动析构旧的、构造新的,代价约等于最大成员的 sizeof 加一个索引;访问优先用 std::visit + Overloaded(漏写分支编译期就报错),需要无异常或只关心个别类型时用 get_if,get<T> 适合已知类型;重复类型只能按下标取,默认构造取第一个类型;类型集合编译期已知就用它,只有类型真到运行时才确定时才用 std::any。

相关推荐
无名猿2 小时前
C++ 迭代器五分类与失效场景汇总:一张表避开所有 UB
c++·stl·标准库·踩坑记录
0+1112 小时前
算法 --模拟
c++·算法·leetcode
hetao17338372 小时前
2026-10-01~02 hetao1733837 的刷题记录
c++·算法
郝学胜-神的一滴3 小时前
AI 编程智能体 03:拆解当下主流智能体能力
开发语言·c++·人工智能·python·程序人生·游戏
宵时待雨4 小时前
linux笔记归纳25:多路转接epoll
linux·服务器·网络·c++
Frank_refuel4 小时前
【C++项目】手撕JRpc框架(单机怎么优化到分布式思想)
开发语言·c++
ShineWinsu4 小时前
对于Redis:Hash类型的解析
c++·redis·分布式·缓存·面试·hash·哈希表
wuminyu4 小时前
JEP491中synchronized关键字引发的平台线程钉住解决方法简介
java·linux·c语言·jvm·c++
All for pursuit.4 小时前
【贪心-4】581.最短无序连续子数组
数据结构·c++·算法·leetcode