☰
std::variant 源码剖析与手写实现:从 union 到类型安全
2026/9/28 7:57:38 网站建设 项目流程

std::variant 是我在 C++17 标准库里用了几年、直到最近才敢说“摸透了”的东西。前阵子为了搞懂 visit 到底怎么做到在运行时根据当前持有的类型自动分发函数,我扒了 libstdc++ 的实现,又在 MSVC 上翻了半天,最后干脆自己写了个简化版。今天这篇就从源码到自研实现,把 std::variant 从头到尾剥干净,顺便聊聊那些光看文档根本学不到的实战细节。

如果你只用过 union,或者被 std::variant 的编译期错误折磨过,这篇文章会告诉你标准库是怎么设计的,以及你自己为什么也能造一个差不多的轮子。我会把设计动机、标准库实现思路、手写 mini-variant 的完整路径,还有一堆踩坑经验全部摆出来。

1. 为什么需要 variant:union 的伤疤与设计目标

1.1 老 union 的三个硬伤

C 语言时代我们就有了 union,它允许在同一块内存上解释成不同类型。听起来和 variant 的定位很像,但实际上 union 的体验堪称“灾难现场”。

第一,它不记录当前到底是什么类型。你往 union 里写入一个 int,回头却按 double 去读,结果就是得到一串莫名奇妙的二进制垃圾。没有任何机制能告诉你“现在是 int,不是 double”。这是 union 最大的硬伤——类型信息在写入时就被丢掉了。第二,union 不负责析构。如果一个成员是非平凡的类类型,比如 std::string,你在 union 里构造 std::string 后,必须在切换类型前手动调用它的析构函数,否则必然泄漏。第三,复制和移动也完全不可用,你得人肉判断当前类型,再调用对应类型的拷贝/移动逻辑。

我见过太多老项目里用 union + enum 做“万能存储”,最终几乎都沦为内存泄漏和未定义行为的重灾区。不是没人想过办法,而是每次都要手写索引、手写析构、手写拷贝,写一遍错一遍。

1.2 variant 带来的四个核心价值

std::variant 在 C++17 里被引入,本质上就是“安全版的 union”。它在标准库层面解决了我上面说的三个硬伤,并且多给了两个额外能力。

第一是类型安全。variant 内部保存了一个 discriminant(索引),你随时可以知道当前存的是 Ts 里的哪一个类型。get () 会先检查索引,存的是 T 就返回,不是就抛异常,完全不会把 int 读成 double。第二是自动生命周期管理。构造哪个类型,就调用哪个类型的构造函数;切换类型时先析构旧类型,再构造新类型;variant 本身被析构时,也会自动析构当前“活跃”的那个对象。第三是统一的访问接口。std::visit 让对当前类型的所有操作都集中在一个可调用对象里,相当于给类型安全加上了“模式匹配”的能力。第四是组合性。variant 可以嵌进容器,可以作为参数返回“失败或结果”这类可选值,还能搭配 optional、tuple 一起用,代码写起来比裸 union 干净太多。

如果你还没用过 variant,可以用一个生活化类比来理解:普通 union 像公用一张草稿纸,谁都可以在上面乱写,也不管之前写了什么;variant 则像一张带标签的收纳盒,你放进一个东西,盒子会记住是什么,拿的时候也必须先看标签,想放新的就得先把旧的处理干净。

2. 上手 std::variant:API 与日常使用

2.1 最基本的构造与赋值

要玩熟 variant,先从花式构造开始。std::variant<int, double, std::string> 的默认构造会默认构造第一个类型,也就是 int,所以初始值就是 0。这让很多刚接触的人意外,但标准就是这么定的。

最常见的构造其实是直接从值隐式转换:

std::variant<int, double, std::string> v; v = 3.14; // v 当前是 double v = std::string("hi"); // v 当前是 string v.emplace<int>(42); // 原地构造 int

赋值时如果值能匹配到多个候选类型,会有歧义,比如 0 可以同时匹配 int 和 double。这时要么显式写 v = int{0}; 要么用 emplace,要么构造 variant 时显式指定类型 v{std::in_place_type , 0}。in_place_type 这套重载我建议大家都记住,它不仅能消除歧义,还能直接传多个参数给构造函数。

2.2 获取当前值:get 与 get_if

取值最直白的是 std::get (v)。如果 variant 当前存的不是 T,会抛 std::bad_variant_access。这在业务代码里通常意味着你要先判断再取值。于是 std::holds_alternative (v) 就有了大用:

if (std::holds_alternative<int>(v)) { int x = std::get<int>(v); }

不过我更推荐 std::get_if (&v)。它不会抛异常,取不到就返回 nullptr,用起来很舒服:

if (auto p = std::get_if<std::string>(&v)) { std::cout << *p << '\n'; }

这里有个特别容易踩的坑:get 的 T 必须是大括号内的类型之一,而且不能有歧义。比如 variant<long, int>,get 是可以的,但 get 也可以,因为两个类型不同。可如果 variant<signed int, int>,这在同一个变体里根本编译不过,因为标准禁止重复类型。

2.3 访问模式:std::visit 与 overloaded 小技巧

如果真的想优雅地处理变体里的每一种情况,就要用 std::visit。它接受一个可调用对象和若干 variant,然后调用该对象,参数就是当前 variant 中存储的各种值。

早期大家用得最多的是“重载 lambda”技巧。C++17 里没有包展开的 lambda,所以常用一个 overloaded 类型来合并多个 lambda:

template <typename... Ts> struct overloaded : Ts... { using Ts::operator()...; }; template <typename... Ts> overloaded(Ts...) -> overloaded<Ts...>; std::visit(overloaded{ [](int i) { std::cout << "int " << i; }, [](double d) { std::cout << "double " << d; }, [](const std::string& s) { std::cout << "string " << s; } }, v);

这个 overloaded 代码块现在基本是 C++17 的“模板答案”。它的本质就是让多个 lambda 的 operator() 互相成为重载,从而在 visit 内部调用时能匹配到正确类型。C++20 之后可以用“泛型 lambda + if constexpr”来写更少的代码,但在 C++17 环境下,overloaded 还是最可靠的方案。

2.4 特殊状态:monostate 与 valueless_by_exception

当 variant 里的某个类型是空类时,你依然需要给它一个位置。std::monostate 就是标准库提供的“空占位类型”,用来表示“什么都没有”。variant<monostate, T> 就相当于 optional 的另一种写法,好处是 T 本身也可以是引用?不,variant 不能存引用,所以 monostate 适用于需要“空状态”的场景。

更阴间的状态是 valueless_by_exception。只要 variant 正在从一种类型切换成另一种类型的过程中,且新对象的构造函数抛异常,variant 为了自身安全,会把索引设成一个特殊值,然后进入“无值状态”。此时 variant 里没有任何活跃对象,get、get_if 都不好用,唯一能查的是 v.valueless_by_exception() 返回 true。这个状态很反直觉,但理解它之后,你对异常安全的把握会强很多。

3. 源码里到底藏了什么:标准库实现要点

3.1 存储布局:union、对齐、大小计算

标准库实现 variant 的核心是一个“概念上的 union 成员 + 一个索引”。为什么说概念上?因为真正实现时,很多库不会把这个 union 直接写成 Ts... members,而是一块对齐的裸内存,然后在上面原地构造各种各样对象。

这是因为一个 union 如果含有带析构函数、拷贝构造函数的成员,C++ 会自动删掉 union 的隐式特殊成员函数。标准库要自己控制这些操作,所以干脆不去依赖 union 的自动规则。libstdc++ 里的 _Variant_storage 就是在内部持有一块足够大且对齐的内存,外加一个表示当前索引的字段。

那么大小和内存对齐怎么算?很简单:union 的大小,就是要容纳所有成员中最大的那个;对齐要求,就是所有成员中最大的对齐值。variant 的大小实际上可以等于“最大成员大小 + 索引开销”,但很多实现会通过空基类优化把索引塞进对齐空隙里,所以 std::variant<int, char> 的大小很可能和你直觉上“一个 int 加一个 size_t”不一样。千万别到处假设 variant 的 sizeof,Python 没有,C++ 也是不同库不同。

3.2 构造、析构与换型的内部操作

当调用 v = T(...) 或 v.emplace (...) 时,标准库内部会做几件事:

  • 先检查当前索引,如果当前已经持有 T,则直接在原对象上赋值。
  • 如果不是 T,则先调用当前活跃类型的析构函数(如果当前不是 valueless 状态)。
  • 然后在新位置(通常是同一存储地址)构造 T。
  • 最后把索引更新为 T 的索引。

析构的时候,variant 的析构函数会根据索引调用对应的子对象析构函数。这个“根据索引调用析构”通常通过递归模板或函数表完成。libstdc++ 里大量使用“扩展包 + 索引匹配”的模板技巧,本质上就是生成一个类似 switch 的分发函数,只不过 switch 的每个 case 都是编译期生成的。

标准库之所以这样做,是想让析构和构造函数都是“基于索引的直调”,而不是维护一堆虚函数。variant 不是多态类型,它没有虚表,索引就是它的“虚表”。

3.3 get 与 get_if 的实现

get 的逻辑非常纯粹:调用 get_if (&v),如果得到非空指针就解引用,否则抛出 bad_variant_access。get_if 的实现则是在编译期拿到 T 在 Ts... 里的下标,然后判断运行时索引和它是否相等,相等就 reinterpret_cast 取出存储地址,不等就返回 nullptr。

这里的关键点是:get_if 的 T 必须是 Ts... 里的唯一类型,编译期就保证类型唯一,运行时不匹配才返回空。所以 get 的“代价”只是一次整数比较,几乎为零。这种设计让标准库在绝大部分访问路径上都能保持极高性能。

3.4 std::visit 的核心思想:跳转表

std::visit 的实现是理解 variant 的重头戏。假设只有一个 variant,那么 visit 要做的事就是:给定运行时索引 i,调用对应的 lambda 特化。

所有现代编译器的实现方式都不是在生成的机器码里写个大 switch,而是通过模板展开生成一个函数指针数组,然后在运行时用索引取值调用。你可以想象成一个查表:

using Func = void(*)(Visitor&&, Variant&&); static constexpr Func table[] = { &visit_one<T0>, &visit_one<T1>, ... }; table[idx](visitor, variant);

对于多个 variant,std::visit 需要实现的是“多个索引的笛卡尔积分发表”。每一个变体索引组合都对应一个编译期生成的调用。这就是为什么 visit 的代码膨胀可能很严重,尤其当每个 variant 有 10 种类型、再用上 3 个 variant 时,模板实例的规模会指数级膨胀。STL 实现里有很多技巧去消除不必要的实例,但本质上依然要以事件数量为代价。你如果发现编译时间暴涨,多半是 visit 的“表”太大。

3.5 valueless_by_exception 的诞生

这个状态其实不是标准库闲着没事加的,而是异常安全要求的副产物。当 variant 要切换类型时,如果新类型的构造过程抛出异常,此时旧类型的析构已经调用了,而新类型又没构造成功,variant 的存储区里就是一块“半死不活”的内存。标准库可以选择让 variant 保持旧类型,但那样必须保证换型过程是 strong exception safety,对很多多阶段构造来说代价太高。

所以标准库选择了一种保险策略:一旦新类型的构造失败,就把索引置为特殊值(通常把索引设成 npos),让整个 variant 进入 valueless 状态。这样至少不会向外暴露一个未定义的内存对象。任何对当前值的访问都会被明确拒绝。代价是“状态丢失”,但这远比“内存破坏”温和。这个设计也是我后来自己实现 mini-variant 时最想偷懒、又最不能偷懒的部分。

4. 自己实现一个 mini-variant:手把手拆解

4.1 数据结构和基础框架

了解源码之后,最好的巩固方式就是自己实现一个简化版。下文我会写一个 mini_variant,支持存储、获取、销毁、并提供一个单变体的 visit。它不追求 100% 覆盖标准库,但能让你把“带索引的存储”这个模型彻底焊死在脑子里。

先定义数据和辅助工具:

#include <cstddef> #include <cstdlib> #include <memory> #include <optional> #include <type_traits> #include <utility> #include <variant> // 仅用于比较和异常类型 template <typename... Ts> class mini_variant { static constexpr std::size_t npos = static_cast<std::size_t>(-1); std::size_t idx_{npos}; static constexpr std::size_t storage_size = sizeof...(Ts) == 0 ? 1 : std::max({sizeof(Ts)...}); static constexpr std::size_t storage_align = sizeof...(Ts) == 0 ? 1 : std::max({alignof(Ts)...}); alignas(storage_align) unsigned char storage_[storage_size]; public: mini_variant() requires (sizeof...(Ts) > 0) { // 默认构造第一个类型 static_assert(sizeof...(Ts) > 0); new (&storage_) Ts...() ; // 这里要单独展开第一个,直接用 F<0> } // 下面省略完整展开细节,只写核心 };

由于代码篇幅,我不会贴一个能直接通过编译的完整实现,而是把关键模块拆开讲。实际上标准库的展开比这个复杂得多,因为要同时处理空包、默认构造、拷贝构造、移动构造等。

4.2 核心思想:storage + idx

我们的 mini-variant 由两块组成:一块对齐的字符数组 storage_,和一个索引字段 idx_。存储区就是“候选类型的原始内存”,索引字段则记录哪个类型在里面活过。

当你构造 mini_variant 时,要按类型把对象放进 storage_。关键函数是 placement new:

template <typename T> void construct_as(const T& value) { static_assert(std::is_constructible_v<T, const T&>); new (&storage_) T(value); }

如果是移动构造,就在 &storage_ 上调用 T(std::move(value))。注意必须在原始内存上构造,不能直接用 storage_ 转 types,那样是对齐/生命周期的抢劫。

析构时通过 idx_ 决定调用哪个类型的析构:

void destroy_active() { destroy_at_index(idx_); } void destroy_at_index(std::size_t i) { if (i == npos) return; // 用递归或者折叠表达式找到 i 对应的类型并调用析构 ( void((i == I) ? (reinterpret_cast<Ts&>(storage_).~Ts(), 0) : 0), ... ); }

折叠表达式里的 (i == I) ? 表达式 : 0 是一个很常用的“按索引短路”写法。编译期每个 I 对应一个 Ts,运行期用 idx 挑选。这正是库源码里的“switch 分发”精神,只不过模板展开了全部 case。

4.3 做 index() 与 holds_alternative

这个超级简单:

std::size_t index() const { return idx_; } template <typename T> static constexpr std::size_t type_index() { std::size_t result = npos; // 找出 T 在 Ts... 里的坐标 ((std::is_same_v<T, Ts> ? result = static_cast<std::size_t>(I) : void()), ...); return result; } template <typename T> bool holds_alternative() const { return idx_ == type_index<T>(); }

这里的折叠表达式把整个 Ts... 包扫描一遍,只有遇到第一个匹配 T 的类型才更新 result。自己写的时候不用担心“多个 T 相同”的问题,因为标准 variant 本身禁止重复类型,但如果你要在 mini_variant 里支持重复类型,就得额外规定“取第一个还是最后一个”,会让代码复杂一截。

4.4 get 与 get_if

get_if 实现起来和标准库几乎是一个思路:

template <typename T> T* get_if() { if (idx_ == type_index<T>()) return reinterpret_cast<T*>(&storage_); return nullptr; } template <typename T> const T* get_if() const { if (idx_ == type_index<T>()) return reinterpret_cast<const T*>(&storage_); return nullptr; }

reinterpret_cast 在这里是安全的,因为 storage_ 已经以最大对齐对齐,并且我们只用它来读取“当前活跃类型”的对象。接着 get 可以建立在 get_if 之上:

template <typename T> T& get() { if (auto p = get_if<T>()) return *p; throw std::bad_variant_access(); }

C++17 的 std::bad_variant_access 就在 里,懒得再定义异常类型,所以我们直接 include 了 ,但这小节里我们只借用异常类型,不借用实现。实际动手时也可以自定义异常类,影响不大。

4.5 一个简化版 visit:单变体分发

最“迷你但完整”的 visit 实现,核心是生成一个函数指针数组。我们不想写太多手动代码,所以用 fold expression 构造一个静态表:

template <typename F> decltype(auto) visit(F&& f) { using Ret = decltype(f(std::declval<Ts&>()...)); // 理论上的返回类型在单变体时是 decltype(f(declval<Ts&>())) using Func = Ret(*)(F&&, mini_variant&); static const Func table[] = { [](F&& f, mini_variant& v) -> Ret { return f(*v.template get_if<Ts>()); }... }; return table[idx_](std::forward<F>(f), *this); }

这个写法实际上会让返回类型检查变得很苛刻,因为所有 Ts 调用的返回类型必须一致。标准库支持返回类型推导且允许不同类型的返回,只要 visit 的返回值能统一转换即可,但单变体时通常大家也希望返回一致。这个简化实现已经可以让你理解查表机制了。

如果你要多变体 visit,就必须把每组 index 组合都生成一个调用。原理就是从每个 variant 各取一个类型组合,形成一个“类型序列”,然后生成对应的 lambda 调用。实现上可以用 std::index_sequence 和递归模板生成所有组合,工作量会从“单变体”变到“笛卡尔积”。标准库的 push 就在这里。

4.6 异常安全:不实现 valueless 行不行?

自己写 mini_variant 时,很多人在想:干脆不处理换型异常,保持旧类型不就行了吗?听起来简单,做起来很麻烦。为了 retain 旧类型,你需要保证在构造新类型失败时,重新构造回旧类型,或者对旧类型做 move 缓存,这样不仅要求旧类型可移动,还得处理“移动构造本身也可能抛异常”的无限套娃。

标准库选择 valueless 这个简洁方案,我们自研也可以照搬:换型时先 destroy active,再对 target construct;如果构造函数抛异常,就把 idx_ 设为 npos。这会让你的 mini_variant 进入“空状态”,换来的是实现简单和异常安全。代价是用户会在明明还有类型可选的时候丢数据。所以在你的 mini_variant API 里一定要暴露 valueless_by_exception(),否则这个状态就变成隐形的坑。

讲完这些,你应该能自己动手写一个能跑通的 mini-variant 了。写的过程中你会发现,真正麻烦的不是 get 和 visit,而是拷贝构造、移动构造、赋值运算符以及各种 const 版本的组合。这也反过来解释了为什么标准库的 variant 头文件那么长——它在用大量模板元编程去覆盖每一种构造路径。

5. 常见坑与排查经验总结

5.1 坑一:get 抛异常不要乱 catch

很多人习惯把 get 包在 try-catch 里处理“类型不对”的情况。这本身没有错,但问题在于 catch 到 bad_variant_access 之后,你往往已经丢失了“variant 到底持有什么类型”的上下文。更稳妥的方式是用 visit 或 get_if 做分支处理,让代码结构完整体现类型可能性。异常只应该被当作“最后的意外”,而不是正常的流程分支。

我接手过一段代码,里面到处是 try { get () } catch (...) { return defaultValue; }。后来 variant 增加新类型,这些 catch 块全部没有覆盖,导致 defaultValue 被静默返回。改成 get_if 之后,编译器能提示你所有可能类型,漏一种就编译失败,修正起来快得多。

5.2 坑二:variant 的大小和对齐不是你以为的那样

标准库实现可以选择把索引放到 union 的尾部,也可以借助空基类优化(EBO)藏掉一些空成员。所以 std::variant<int, char> 的 sizeof 很可能等于 8 而不是 5,std::variant<std::string, std::monostate> 的大小也可能和 sizeof(std::string) 一样,因为 monostate 是空的,索引可以塞进 string 那个对象尾部的 padding 里。

你在做结构体布局、固定大小消息、或者跨进程序列化时,一定不要用 sizeof(variant) 去猜字段偏移。要么用明确的二进制格式,要么把 variant 中的值取出来单独打包。我自己写过不少 TLV 协议,variant 只做中间状态,绝不当网络包的直接载体。

5.3 坑三:visit 的编译时间炸弹

std::visit 在代码层很漂亮,但它背后是一张庞大的函数表。假如 variant<A, B, C> 和另一个 variant<D, E, F> 一起 visit,编译器要生成 3×3=9 种调用组合。如果某个 variant 有 20 个候选类型,再来三个 variant,组合数就是 20^3=8000,编译时间直接起飞。

遇到这种问题,我的经验是尽量缩小 variant 的类型数量。能用“公共基类”表达的一组类型,不要硬塞进 variant;能拆成两个小 variant 的,就不要合成一个大 variant。std::variant 不是银弹,它适合“少量、明确的类型集合”,不适合大规模“万能类型池”。

5.4 坑四:默认构造的隐蔽行为

std::variant<Ts...> 的默认构造会选择第一个类型构造。如果你让 variant 的第一个类型是 std::string,那么在函数中写一句 std::variant<std::string, int> v; 就会立刻构造一个空字符串。这对大部分场景是合理的,但也因此很多人把 variant 当 optional 用的时候会诧异:我明明没给值,它却默认构造了第一个类型。

如果你要“完全为空”的 variant 状态,最干净的办法是使用 std::monostate 作为第一个类型。这已经是社区共识:variant<monostate, std::string, int>。这个写法在工程里极其常见,也让我躲过了不少“默认构造后忘了检查 holds_alternative”的 bug。

5.5 分配器与性能注意事项

variant 本身不管理堆内存,它只是持有对象。但因为它要原地构造对象,所以如果某个 T 自己带堆内存,比如 std::string,自然会把对象搬到堆上。很多人误以为 variant 一定比 polymorphic wrapper 开销小,其实小的是“索引查询”和“无虚函数”这两部分,不一定是“空间消耗”。

在做性能对比时,用 std::variant<std::string, int> 和用一个拥有虚函数或两字段的 struct 对比,如果数据大量是 string,性能差异就不明显。真正要优化的是避免在 variant 上频繁进行“拷贝整个变体”的操作——因为拷贝一个变体会拷贝里面的整个 std::string,代价很大。能用移动就用移动,能传引用就别传值。

5.6 我自己常用的设计套路

根据这几年的项目经验,我把 variant 用在了这么几个地方,效果都很好:一是解析器的 AST 节点,用 variant<Number, String, List> 来简洁表示表达式;二是 GUI 事件回调,事件类型不多时用 variant 代替继承体系,避免虚函数和动态内存;三是协议消息的分发,用 visit 配合 overloaded 把所有 case 集中在一个函数里,新的消息类型加进来时编译器会强制我检查所有分支。

最后再分享一个很小的细节:当你定义 overloaded 模板时,记得在头文件里加 using Ts::operator()...;,别漏掉。漏了它,visit 时每每会遇到“没有匹配的 operator()”报错,但报错位置又不在 overloaded 内部,排查起来特别费劲。我自己当年在这上面浪费过一下午。

std::variant 是那种“用起来简单、想懂很深”的组件。从普通 union 到标准库实现,再到自己动手写一个 mini-variant,整条链路走下来,你会越发觉得它不是什么魔法,而是“索引 + 类型表 + 原始内存”的组合技。以后看到任何带标签联合体,你都应该条件反射地思考三个问题:索引在哪,类型表怎么生成,异常时怎么保底。想通这三点,无论是读源码还是自己写类似的数据结构,都会通畅得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询