写这篇文章之前,我先说个自己的感受:C++17 里新增的std::variant,看起来就是个“类型安全的 union”,但真正读源码、手写一遍之后,才发现里面藏着不少设计上的取舍和坑。我这次把标准库的实现思路拆开看了一遍,又自己动手写了一个 mini 版本,整个过程对“为什么标准库这样设计”有了很直观的理解。如果你也在啃std::variant的源码,或者准备 C++ 面试,这篇文章应该能帮你省不少时间。
1. 从 union 说起:std::variant 治的三个老毛病
先说清楚std::variant到底在解决什么问题。很多 C++ 程序员最早接触“一个变量能存多种类型”的需求,都是从 C 的union开始的:
union Value { int i; double d; const char* s; }; Value v; v.i = 42;这段代码能跑,问题也随即而来:当你想读回这个Value时,怎么知道当前存的是int、double还是const char*?C 的 union 不会告诉你,它只负责“在同一块内存上解释不同的类型”。如果你在v.i = 42之后直接去读v.d,得到的是一堆毫无意义的字节。这就是 union 的第一个老毛病:没有类型记忆。
于是大家很自然会想到给 union 加一个标记:
struct TaggedValue { enum class Type { Int, Double, Str } type; union { int i; double d; const char* s; } data; };这就是所谓的 discriminated union(可辨识联合),也是std::variant核心概念的雏形。但手写 discriminated union 会很快撞上另外两个坑。
第二个坑是析构。如果你的 union 成员里有std::string这种自带析构函数的类型,事情立刻变复杂。union 本身在析构时不会自动调用当前活动成员的析构函数,你必须自己写switch (type),逐个 case 去手动str.~string()。漏一个就是内存泄漏,多一个就是未定义行为。
第三个坑是访问逻辑分散。每次读值都要写一遍 switch,所有用到这个类型的地方都得重复同一套判断。代码里这种散落的if (type == Type::Int) return data.i;一旦多起来,维护成本直线上升。
std::variant把这三件事一次解决:它在构造时记录当前索引,析构时自动调用对应类型的析构,又提供get_if、std::visit这类类型安全的访问入口。你可以把std::variant<int, double, std::string>理解成一个“带类型标签且会自动管理的 union”。
顺带说一句,很多人会拿std::variant、std::optional、std::any三个类型做对比,它们确实容易混淆,但在定位上有清晰边界:
| 类型 | 解决的问题 | 典型场景 |
|---|---|---|
std::optional<T> | 要么有值,要么无值 | 可能失败的返回值、可空参数 |
std::variant<Ts...> | 在同一时刻,恰好持有 Ts 中的一种类型 | 多种类型的数据表示、状态机、解析器 |
std::any | 运行时持有任意单一类型,类型擦除更彻底 | 属性表、插件系统,不关心类型编译期集合 |
具体到实际项目,我最常把variant用在这几种地方:
- 解析器/AST 节点:一个节点可能是数字字面量、字符串字面量、二元表达式,用
variant表达“不同种类的节点”比继承体系直观得多。 - 配置项的多种格式:同一个设置项,可能是布尔值、整数、字符串,用
variant<bool, int, std::string>承载配置解析结果。 - 状态机的事件:不同状态携带不同类型的数据,用
variant表示“当前事件是哪一种”。
理解了它的定位,再去看源码,你才知道每一处设计都是为了解决上面某个痛点。接下来我按自己的阅读路径,讲几个源码层面最关键的机制。
2. 标准库源码的底层设计:存储、销毁与访问的三件事
真正进入std::variant的源码,第一件事是找它的数据成员。以我在 GCC libstdc++ 和 LLVM libc++ 里看到的结构为例,核心就两个部件:一块能容纳所有候选类型的内存区域,加上一个记录当前类型的整数索引。前者在实现上通常直接就是一个 union 或者足够大的对齐缓冲,后者是一个int(或者实现里自定义的整数类型)。
用伪码描述,大概是这种感觉:
template <typename... Ts> class variant { union Storage { /* 这里展开所有 Ts */ }; Storage storage_; int index_ = -1; // -1 表示无值 };这就是std::variant存储设计的核心。索引-1不是随手选的,它对应valueless_by_exception()这个特殊状态。标准里规定:当variant在emplace或赋值过程中抛异常,且无法保证原来的值仍然完好时,variant可以进入valueless状态。在这个状态下,get_if返回空指针,std::visit直接抛bad_variant_access。这个“可以”给了标准库实现很大的自由,也是我们不自己手写就很容易忽略的细节。
第二个关键机制是销毁分派。因为variant的析构函数必须针对“当前活动类型”调用正确的析构函数。标准库的实现通常用模板递归展开,把所有类型的析构调用生成到一个函数里,运行时根据index_选一条路径执行。libc++ 里能看到通过__variant_detail一层层做递归展开的结构,本质上和下面这段教学代码是同一个思路:
void destroy(std::index_sequence<Is...>) { ((void)(index_ == (int)Is ? (getAs<type_at<Is, Ts...>>()->~type_at<Is, Ts...>(), 0) : 0), ...); }用std::index_sequence把所有分支在编译期展开,运行时只执行匹配的那一个。这种“编译期生成分支、运行时查索引”的模式,在variant的拷贝构造、移动构造、赋值里会反复出现。
第三个关键机制是访问分派。get_if的逻辑相对简单:编译期确定你请求的类型T对应的索引I,运行时检查index_ == I,相等就返回对应指针,否则返回nullptr。std::visit则是另一个思路,它把所有分支打包成一张函数指针表,每个索引对应一个函数指针,运行时直接用table[index_]完成一次间接调用。
为什么std::visit要用函数指针表?因为visit的返回值类型在编译期就要确定,但具体执行哪个分支要运行时才知道。C++ 没有“运行时类型到编译期类型”的直接桥接,只能通过“为每个候选类型生成一个代码实例、运行时根据索引挑一个实例”来实现。这张函数指针表本质上就是一个小型虚表。一个有趣的事实是,标准库variant通常设定sizeof(variant)等于最大成员大小加一个整数(存储索引),再加上对齐带来的 padding。对std::variant<int, double, std::string>来说,sizeof大约是sizeof(std::string)加上 4 到 8 字节,不会把三种类型的大小都加起来。这是它比“同时持有所有可能性”的聚合体更省内存的根本原因。
标准库实现里还有一个容易忽略的设计:默认构造函数会默认构造第一个类型,比如std::variant<int, double> v;会把int初始化为 0。这是因为标准要求默认构造variant时激活第一个替代类型。这也意味着第一个类型必须是默认可构造的。如果你不想默认构造第一个类型,C++20 里可以用std::monostate占位,再把默认构造函数写出来。
从源码角度能学到的其实不是某个具体技巧,而是这几个问题的统一解法:存储布局要紧凑、销毁要按运行期索引分派、访问要把运行期索引映射到编译期类型。把这三点想透,自己实现variant就只剩工作量问题。
3. 手写第一步:存储层与类型索引的元编程
我决定自己写一个教学版MiniVariant。目标明确:不追求和标准库完全等价,但要跑通构造、析构、拷贝、移动、获取、访问这些核心场景,让读者能看到真正的实现载体。
存储层的第一件事是确定缓冲区。为了容纳Ts...中的任意一种类型,缓冲区大小必须是所有类型大小的最大值,对齐方式也必须是所有类型对齐值的最大值。C++17 里可以直接用std::max的参数包版本:
template <typename... Ts> class MiniVariant { private: static constexpr std::size_t kSize = std::max({sizeof(Ts)...}); static constexpr std::size_t kAlign = std::max({alignof(Ts)...}); alignas(kAlign) unsigned char buf_[kSize]; int index_ = -1;这里有个很关键的点:为什么用unsigned char数组而不是 union?原因有两个。第一,unsigned char数组是我们自己能完全控制对齐的唯一简单方式;第二,后面placement new构造、手动析构都更直观。标准库实现里用 union 是为了让编译器对“同一块内存反复承载不同类型”的场景做更自然的别名分析,但教学版用字节数组加reinterpret_cast配合launder完全可以工作。考虑到placement new返回的指针,实际上我们直接持有new的返回值会更好,但为了展示字节数组的布局理解,我保留reinterpret_cast,同时提醒一句:在正式代码里请用std::launder防止编译器在过度激进的优化下搬起石头砸我们的脚。
接下来是类型索引元编程,这是整个实现的地基。我们需要三个编译期工具:
contains_v<T, Ts...>:判断T是否在Ts...中。index_of<T, Ts...>:找出T在Ts...里的索引。type_at<I, Ts...>:取出Ts...中第I个类型。
这三个工具都属于典型的递归模板元编程。以index_of为例:
template <typename T, typename... Rest> struct index_of_impl {}; template <typename T, typename... Rest> struct index_of_impl<T, T, Rest...> : std::integral_constant<int, 0> {}; template <typename T, typename U, typename... Rest> struct index_of_impl<T, U, Rest...> : std::integral_constant<int, 1 + index_of_impl<T, Rest...>::value> {}; template <typename T, typename... Ts> using index_of = index_of_impl<T, Ts...>;递归逻辑很朴素:如果T恰好是列表第一个类型,索引就是 0;否则在剩余列表里继续找,找到后加 1。这个递归在编译期完成,不会产生运行时代码。
type_at的实现同理:
template <std::size_t I, typename... Ts> struct type_at_impl; template <typename T, typename... Ts> struct type_at_impl<0, T, Ts...> { using type = T; }; template <std::size_t I, typename T, typename... Ts> struct type_at_impl<I, T, Ts...> : type_at_impl<I - 1, Ts...> {}; template <std::size_t I, typename... Ts> using type_at = typename type_at_impl<I, Ts...>::type;以及contains_v:
template <typename T, typename... Rest> struct contains : std::false_type {}; template <typename T, typename U, typename... Rest> struct contains<T, U, Rest...> : std::conditional_t<std::is_same_v<T, U>, std::true_type, contains<T, Rest...>> {}; template <typename T, typename... Ts> inline constexpr bool contains_v = contains<T, Ts...>::value;这三个元编程工具是后面几乎所有功能的基础。有了它们,构造函数就可以写了。这里有一个看起来简单、实际容易踩坑的地方:模板构造函数和拷贝构造函数会互相干扰。比如这样写:
template <typename T, typename U = std::decay_t<T>, typename = std::enable_if_t<contains_v<U, Ts...>>> MiniVariant(T&& val) { using W = std::decay_t<T>; new (as<W>()) W(std::forward<T>(val)); index_ = index_of<W, Ts...>::value; }enable_if保证这个模板构造函数只在T去掉引用和 cv 限定后确实是候选类型之一时才参与重载决议。否则当你试图拷贝一个MiniVariant时,编译器可能把这个模板构造函数当作候选,导致诡异的错误。
默认构造函数的标准语义是“激活第一个类型”,所以需要额外取第一个类型并默认构造它:
template <typename T, typename...> struct first_type_impl; template <typename T, typename... Rest> struct first_type_impl<T, Rest...> { using type = T; }; template <typename... Ts> using first_type = typename first_type_impl<Ts...>::type; MiniVariant() { new (as<first_type<Ts...>>()) first_type<Ts...>(); index_ = 0; }到这里,存储层和构造入口已经铺好了。接下来最关键的部分是析构、拷贝、移动。因为索引在运行时才知道,所以所有“按类型分派”的操作都要用到前面那个index_sequence展开技巧。
4. 拷贝、移动与赋值:最容易翻车的地方
析构函数是第一个用到分派技巧的地方。前面那段destroy代码已经展示过,我再完整写一遍:
template <std::size_t... Is> void destroy(std::index_sequence<Is...>) { if (index_ == -1) return; ((void)(index_ == static_cast<int>(Is) ? (as<type_at<Is, Ts...>>()->~type_at<Is, Ts...>(), 0) : 0), ...); } ~MiniVariant() { destroy(std::make_index_sequence<sizeof...(Ts)>()); }这是 C++17 fold expression 的典型用法。std::make_index_sequence<sizeof...(Ts)>()会在编译期生成一个形如std::index_sequence<0, 1, 2, ...>的序列,展开后变成一串index_ == 0 ? 析构类型0 : 0, index_ == 1 ? 析构类型1 : 0, ...的表达式。运行时只有index_匹配的那个分支会真正执行析构,其余分支都是 0。这样看起来像循环,其实完全没有循环指令。
拷贝构造和移动构造也是同一个套路,只是把“调用析构”换成了“调用拷贝构造”或“移动构造”:
MiniVariant(const MiniVariant& rhs) : index_(rhs.index_) { copyCtor(rhs, std::make_index_sequence<sizeof...(Ts)>()); } template <std::size_t... Is> void copyCtor(const MiniVariant& rhs, std::index_sequence<Is...>) { if (index_ == -1) return; ((void)(index_ == static_cast<int>(Is) ? (new (as<type_at<Is, Ts...>>()) type_at<Is, Ts...>(*rhs.as<type_at<Is, Ts...>>()), 0) : 0), ...); }移动构造无非是把*rhs.as<T>()换成std::move(*const_cast<type_at<Is, Ts...>*>(rhs.as<...>()))。这里提醒一下,不要试图用一个“通用指针”来规避类型问题,因为每种类型在源码里都必须是一个真实的、编译期确定的类型,才能调用对应的构造函数。
真正的重头戏是赋值运算符。这地方我翻车翻得很惨,也最有话说。
朴素思路是:如果当前index_不变(新旧类型相同),直接调用该类型的赋值运算符;如果类型变了,就先析构旧值,再placement new构造新值。这个逻辑看起来顺理成章,但有个异常安全的大坑:假设当前存的是std::string,要赋值成std::vector<int>,如果vector的拷贝构造抛异常了,此时旧值已经被析构,MiniVariant就处于一个“什么都没有”的中间状态。标准库的做法是有条件地要么保持原值,要么进入valueless状态,但为了简化教学,我们完全可以绕开这个复杂度——用 copy-and-swap。
MiniVariant& operator=(const MiniVariant& rhs) { if (this != &rhs) { MiniVariant tmp(rhs); swap(tmp); } return *this; } MiniVariant& operator=(MiniVariant&& rhs) { if (this != &rhs) { MiniVariant tmp(std::move(rhs)); swap(tmp); } return *this; }然后 swap 的实现也很简单:
void swap(MiniVariant& other) noexcept { MiniVariant tmp(std::move(*this)); *this = std::move(other); other = std::move(tmp); }为什么这样能保证强异常安全?关键在于tmp的构造过程:如果拷贝成功,tmp就是rhs的完整副本;随后交换,*this变成副本,旧值安全释放;如果拷贝过程抛异常,*this根本还没被动过,原值原封不动。这是我非常喜欢的一个模式,虽然性能上比标准库的“原地赋值”多了构造和析构开销,但正确性和代码可读性高了一个数量级。
当然,真实生产环境里,如果variant经常在同类型间赋值,copy-and-swap 的性能劣势会被放大。标准库的做法是:先比较前后类型是否一致,一致就走底层类型的operator=,不一致再考虑 emplace。教学版追求的是把“异常安全”这个概念讲清楚,所以直接用最稳妥的方案。
这个部分我学到的最重要一句话是:手写一个容器或封闭类型时,首先想清楚异常安全保证级别,再去写每个运算符。先保证正确,再谈优化。
slice出来说,赋值运算符还有一个细节:类型相同时,标准库不重新构造,而是直接调用当前类型的拷贝赋值。这是一个重要的性能优化。我在自己的 mini 版本里用 copy-and-swap 绕过了这个分支,但在阅读源码时,你要能看到__assign_alt这类函数专门处理这种“同类型赋值”的情况。
5. 自制 get_if 与 mini_visit:从手工分派到函数指针表
存储和构造搞定了,接下来是访问机制。这是整个variant对外最直观的接口,也是源码中最能体现“运行期与编译期结合”的部分。
我选择了把get_if做成成员函数模板,这比友元自由函数简单,也不会引入繁琐的友元模板声明:
template <typename T> T* get_if() noexcept { static_assert(contains_v<T, Ts...>, "T not in MiniVariant"); if (index_ == index_of<T, Ts...>::value) { return as<T>(); } return nullptr; } template <typename T> const T* get_if() const noexcept { static_assert(contains_v<T, Ts...>, "T not in MiniVariant"); if (index_ == index_of<T, Ts...>::value) { return as<T>(); } return nullptr; }static_assert会在类型不在MiniVariant候选列表里时直接编译报错,这个编译期检查非常值得保留。get_if的运行时开销就是一次整数比较,几乎为零。
真正的高级操作是visit。标准库的std::visit会把 visitor 应用在当前活动类型上,返回所有分支中公共的结果类型。我的教学版先把目标缩小:只支持单variant的 visit,返回所有分支的std::common_type_t。
实现的关键挑战,仍然是把运行时的index_变成编译期的类型分支。这次不用if展开,而是用函数指针表:
template <typename Visitor, typename... Us, std::size_t... Is> auto mini_visit_impl(Visitor&& vis, MiniVariant<Us...>& var, std::index_sequence<Is...>) -> std::common_type_t<decltype(vis(std::declval<Us&>()))...> { using Ret = std::common_type_t<decltype(vis(std::declval<Us&>()))...>; using Func = Ret (*)(Visitor&&, MiniVariant<Us...>&); static const Func table[] = { [](Visitor&& visitor, MiniVariant<Us...>& v) -> Ret { return std::forward<Visitor>(visitor)(*v.template get_if<type_at<Is, Us...>>()); }... }; return table[var.index()](std::forward<Visitor>(vis), var); } template <typename Visitor, typename... Us> auto mini_visit(Visitor&& vis, MiniVariant<Us...>& var) -> std::common_type_t<decltype(vis(std::declval<Us&>()))...> { return mini_visit_impl(std::forward<Visitor>(vis), var, std::make_index_sequence<sizeof...(Us)>()); }这段代码的核心是那个 lambda 数组。table里的每个元素都是一个 lambda 实例化后的函数指针,每个 lambda 都“固化”了一个编译期具体的类型type_at<Is, Us...>。运行时通过table[var.index()]选到正确分支,执行 visitor。相当于我们手动构造了一张小型虚表。
我实际测试过,这段代码的编译开销和运行开销都不赖。函数的间接跳转通常一个周期就能完成,比 switch 展开的性能差异小到可以忽略。它最大的好处是:不管variant有多少种类型,写出来的visit逻辑都是同一套,不会出现手工 switch 那种“漏写一个 case”的风险。
这里插一句,标准库的std::visit还支持多variant同时访问,比如std::visit(f, v1, v2)。那意味着要展开一个 N 维的分派表,或者递归嵌套调用visit。真实实现里,libc++ 采用先展开一个 variant 的类型,再递归去 visit 剩余 variant 的方式,代码比我们这版复杂得多。不过作为理解和面试,单 variant 的 visit 已经足够说明核心机制。
写一个简单的测试:
MiniVariant<int, double, std::string> v{42}; mini_visit([](auto&& val) { using T = std::decay_t<decltype(val)>; if constexpr (std::is_same_v<T, int>) std::cout << "int: " << val << '\n'; else if constexpr (std::is_same_v<T, double>) std::cout << "double: " << val << '\n'; else if constexpr (std::is_same_v<T, std::string>) std::cout << "string: " << val << '\n'; }, v);这个 visitor 是泛型 lambda,里面用if constexpr区分类型。注意if constexpr的编译期分支可以避免所有分支都被实例化,这里每个分支里都调用了std::cout,但如果某个类型的operator<<不存在,也不会报错,因为其他分支根本不会被实例化。这是 C++17 里和variant配合度极高的语法。
6. 工程实践观察与几个常见误区
自己动手实现一遍之后,再看std::variant在工程里的使用,就有很多地方会和只读文档时感受不同。
第一个观察是性能。std::variant的内存占用约等于“最大成员大小 + 索引大小 + 对齐 padding”,访问耗时主要是分支判断或一次间接跳转。相比虚继承、dynamic_cast这类运行时机制,它没有堆分配,也没有类型擦除带来的额外开销。我实测过一个解析器里用variant替代原来的std::shared_ptr<Base>多态方案,内存占用下降明显,因为不再需要每个节点单独堆分配对象,省掉的不只是堆开销,还有 shared_ptr 的控制块。当然,如果节点生命周期管理本来就复杂,不一定非要替换,但这个方向值得试。
第二个观察是valueless_by_exception。这是标准库里真实存在的状态,但很多开发者完全不知道。如果你的业务代码里,variant的emplace或赋值可能抛异常,那么访问之前最好检查一下valueless_by_exception(),否则直接std::get会抛bad_variant_access。我自己踩过一次:在一个配置解析模块里,用一个variant<int, std::string>承接字段值,当时赋值自定义类型时抛了异常,后续访问直接 panic。后来加了valueless_by_exception检查,程序行为才稳定下来。
第三个是“不要让variant存不完整类型或引用类型”。标准库对引用成员的支持是有限且容易出错的,variant<int&>这种写法会导致各种奇怪的行为。真想存“引用语义”的东西,应该存std::reference_wrapper或指针。另一个常见坑是递归式variant:一个节点类型包含自身作为成员,在结构上不可能展开,需要外层用std::unique_ptr或std::shared_ptr做间接。类似:
struct JsonNode; using JsonValue = std::variant< std::nullptr_t, bool, double, std::string, std::vector<JsonNode> >; struct JsonNode { std::string key; JsonValue value; };递归结构一定要加间接层,这是我写 JSON 解析器时反复踩过才记牢的教训。
第四个是面试高频考点。面 C++ 岗位如果被问到std::variant,典型问题无非这几类:和 union 的区别、sizeof大小、get_if与get的差异、std::visit原理、valueless_by_exception是什么、为什么不建议放引用类型。这篇文章的核心都在上面覆盖了。面试时能随口说出“visit本质是一张函数指针表,每次调用相当于一次虚调用”,通常就能让面试官点头。
最后分享一个调试小技巧:当我需要快速看一个variant当前激活的类型时,经常写一个小工具函数:
template <typename... Ts> void debug_print(const std::variant<Ts...>& v) { std::visit([](const auto& val) { using T = std::decay_t<decltype(val)>; std::cout << "current type: " << typeid(T).name() << '\n'; std::cout << val << '\n'; }, v); }配合typeid(...).name()输出类型名,排查“为什么get_if返回空指针”“为什么visit走进了意外分支”这类问题非常快。调试完再删掉就好。
我自己写完这个 mini 版本之后,最大的体会是:std::variant不是一个需要膜拜的黑魔法,它只是把“类型安全的 tagged union”这个需求做得足够极致。当你把存储、分派、访问这三件事拆开看,每个部分的难度都不高,难的是组合在一起时对异常安全和边界情况的把握。如果你想彻底吃透它,强烈建议也像我一样从空文件开始写一轮,不需要覆盖所有标准库特性,只要跑通核心的构造、析构、拷贝、访问,你对variant的理解就能超过大部分只刷文档的程序员。