1. 项目概述与核心动机
1.1 为什么突然想写 std::variant
先说个背景。我在平时工作中维护几个大型 C++ 服务,老代码里到处是union、enum + struct的搭配,甚至有直接用void*传参数的野路子。时间一长,维护的人就苦不堪言:你永远不知道当前这个字节到底代表什么类型,改一处逻辑要翻遍所有调用点。
直到后来,我在一次代码评审里看到同事用了std::variant,第一反应是这个东西不就是类型安全的union吗?真的上手用了一阵子之后,发现它远不止这么简单——它解决的不只是“能不能存多种类型”的问题,而是“能不能安全、优雅、可扩展地管理多种类型状态”的问题。
这篇博文我会从std::variant的日常用法讲起,然后直接扒它的源码实现思路,最后带你手写一个可用的迷你版本。适合正在用 C++17 写业务代码、对模板元编程有点基础、但又没深挖过标准库内部原理的人。如果你只是听说这个名字,也不怕,前面部分会用大实话讲清楚它到底解决了什么问题。
1.2 它到底解决了什么痛点
在没有std::variant的年代,想在同一个位置存不同类型的数据,大概有以下几种方案,但各有各的坑:
union:类型不安全,你存了一个int,如果你误读成float,程序不会报错,只会给你一个莫名其妙的数值。而且有非平凡构造/析构的类型(比如std::string)根本塞不进去。void* + type tag:要么自己维护一个枚举变量记录当前类型,要么靠约定。释放内存、拷贝、比较,全部手写,一旦漏了某条路径就是内存泄漏或未定义行为。- 继承多态:能解决问题,但要求类型之间必须有继承关系,而且有虚函数开销。对“一组不相关类型”的组合场景很别扭。
std::variant的核心价值,就是把“类型安全”和“类型集合”这两件事焊死在一起。它保证任何时刻你读取出来的类型,一定是你写进去的那个类型;你访问的方式错了,运行时给你抛出异常,而不是让你在一个早已失效的内存上做 UB 操作。同时,它天然支持非平凡类型的存储,因为它在内部帮你管理了构造、析构、拷贝、移动的完整生命周期。
用一句话概括:std::variant是“带类型标签的、生命周期安全的、可访问的联合体”。
2. 日常使用:先把基础姿势练对
2.1 构造、赋值与类型索引
我们先从声明和构造说起,最常见的几个用法我直接写在下面:
#include <variant> #include <string> #include <iostream> int main() { std::variant<int, double, std::string> v; // 默认构造:第一个类型(int)做零初始化 std::cout << std::get<0>(v) << std::endl; // 0 // 赋值 string v = "hello"; std::cout << std::get<std::string>(v) << std::endl; // hello // 赋值 double v = 3.14; std::cout << std::get<double>(v) << std::endl; // 3.14 // 当前活跃类型的索引 std::cout << v.index() << std::endl; // 1(double 是第 1 个类型) return 0; }有两点很容易踩坑,我这里先打预防针:
v = "hello"看起来是把字符串字面量赋给std::string,实际上会走一次隐式转换。如果variant里同时有std::string和const char*,那优先级博弈就会变得微妙,结果未必是你期望的。v = 3.14是 double,没问题。但如果variant里同时有float和double,你要小心,赋值3.14f才会得到float,赋值3.14默认是 double。类型选择遵循“精确匹配优先”的原则,很容易被忽略。
如果想知道当前活跃类型,用v.index()。如果你想把值取出来,有两个主流方式:std::get<T>()和std::get<I>()。前者按类型取,后者按下标取。
// 按下标取,类型错误会抛 std::bad_variant_access try { auto& d = std::get<1>(v); } catch (const std::bad_variant_access& e) { std::cout << "类型不匹配" << std::endl; } // 按类型取,等价写法 auto& d2 = std::get<double>(v);std::get如果类型不对,会抛异常。但请注意,这个异常是“运行时”的,也就是说,错误代码可能逃过编译器的静态检查。想要完全编译期安全,可以用std::visit。
2.2 std::visit:把“分派”交给编译器
std::visit是variant家族里最优雅的工具。它接收一个可调用对象和一个或多个 variant,然后根据当前活跃类型自动调用对应重载。
std::variant<int, double, std::string> v; v = "hello"; std::visit([](auto&& value) { std::cout << value << std::endl; }, v);lambda 用了auto&&,会被实例化成多个版本的函数体,每个对应 variant 里的一种类型。有人可能觉得,这不就是编译器帮我写了switch (index)吗?本质上确实是。但std::visit更狠的一点是:它在编译期就能穷举所有组合,然后生成一张跳转表,运行时只做一次索引,效率非常高。
如果可调用对象是一个泛型 lambda,那么所有分支都是同一个函数体。如果你想针对不同分支做不同的事,就要用到“重载集”(overload pattern)。这是 C++17 里我最喜欢的技巧:
template <typename... Ts> struct overload : Ts... { using Ts::operator()...; }; template <typename... Ts> overload(Ts...) -> overload<Ts...>; // 然后这样写 std::visit( overload( [](int i) { std::cout << "int: " << i << std::endl; }, [](double d) { std::cout << "double: " << d << std::endl; }, [](const std::string& s) { std::cout << "string: " << s << std::endl; } ), v );这个overload结构体看起来只有几行,但因为继承了一组 lambda,并且做了using Ts::operator()...的显式引入,最终所有 lambda 的operator()都成为它的重载集。用起来就像在写一个类型安全的 switch。
2.3 其他常用接口与注意事项
std::variant还有一些很实用的接口,我挑几个高频的列出来:
valueless_by_exception():当 variant 内部操作抛出异常、导致值丢失时,返回 true。这种状态非常少见,但一旦出现,就不能再取当前值了。比如移动赋值时,目标类型的构造函数抛异常,就可能进入这种状态。emplace<T>(args...):原地构造,比先赋值一个临时对象更高效。swap:交换两个 variant,会对应交换类型和值,要求所有类型都是可交换的。
有一点特别容易被忽略:std::variant不允许void、不允许数组类型、不允许引用类型作为可选项。同时,不允许重复的类型,比如std::variant<int, int>是编译错误。
如果你确实想让同一类型出现多次,但要用不同的语义区分,就需要包一层结构体或 tag。这一点在源码实现里也会体现——因为内部的索引机制是按“类型序号”来的,重复类型会破坏索引的唯一性。
3. 源码视角:标准库是怎么实现 std::variant 的
3.1 存储层:先解决“空间够用”的问题
std::variant的底层存储,本质上是一个足够容纳所有类型的内存块。标准库通常会用aligned_union_t或者自制的__variant_storage来做这件事。
核心思路是这样的:
template <typename... Ts> union VariantStorage { unsigned char dummy_; // 用于触发析构 };等等,这个不是最终的方案。我们先把思路拆开。真正的标准库实现,会把存储抽象成一个专门的 storage。比如 libstdc++ 的实现里,有__variant_storage这样的内部结构,它基于union实现了对每个类型的构造、析构、拷贝、移动。
这里有一个关键问题:如果直接用union<Ts...>,那么其中任何一个类型如果有非平凡的构造/析构,整个union的默认构造/析构就被删除了。你必须手动写构造和析构逻辑,这正是标准库实现要处理的脏活累活。
如果我们自己实现,可以这样定义存储:
template <typename... Ts> union Storage { // 用一个足够大、对齐足够的字节数组占位 unsigned char data_[sizeof...(Ts) > 0 ? std::max({sizeof(Ts)...}) : 1]; };但union里的大数组本身对构造/析构没帮助,因为数组元素是unsigned char,没有副作用。真正构造类型时,要用 placement new:
template <typename T, typename... Args> void construct(Args&&... args) { new (reinterpret_cast<T*>(data_)) T(std::forward<Args>(args)...); }析构同理:
template <typename T> void destroy() { reinterpret_cast<T*>(data_)->~T(); }3.2 类型索引与约束
存储层之上,还需要一个整数值记录当前活跃类型的索引。最简单的思路:
template <typename... Ts> class Variant { Storage<Ts...> storage_; size_t index_ = 0; };构造函数里,如果一个T args恰好能匹配到Ts...中的某一个,就把这个类型对应的下标记到index_里。赋值操作等同理。
这就解释了为什么std::variant不允许重复类型:内部选择算法会退化成“二义性”。如果没有重复类型,那么给定一个候选类型 T,我可以这样找到它的下标:
template <typename T, typename... Ts> struct IndexOf; template <typename T, typename... Rest> struct IndexOf<T, T, Rest...> : std::integral_constant<size_t, 0> {}; template <typename T, typename U, typename... Rest> struct IndexOf<T, U, Rest...> : std::integral_constant<size_t, 1 + IndexOf<T, Rest...>::value> {};如果包含重复类型,这个查找就会匹配到多个,编译器就会报“不唯一”的错误。原版标准库正是利用这种编译期约束来禁止重复类型。
3.3 std::get 和 std::visit 的原理
std::get<T>(variant)的底层逻辑是:先编译期确定 T 在 Ts... 里的下标 I,然后调用variant.get<I>(),里面检查运行时index_ == I,如果不等,抛std::bad_variant_access,否则返回对应存储的引用。
std::visit的原理更有意思,它对每个可能的“类型组合”生成一个函数调用分支,然后根据 index 跳转。在 libstdc++/libc++ 的实现里,会构造一个二维函数指针表(如果只有一个 variant,就是一维表)。假设 variant 有 N 个类型,std::visit会生成一个std::array<void (*)(void*, void*), N>,运行时用index()去查表调用。
我们来模拟一下这个思路,写一个简化版的 visit:
template <typename Visitor, typename... Variants> auto visit(Visitor&& vis, Variants&&... vars) { // 1. 编译期枚举所有 index 组合 // 2. 对每个组合生成调用:vis(multivisit(vars.get<Idx>())...) // 3. 运行时根据 vars.index() 查表 }这个看着简单,实际实现里要用到std::index_sequence、std::integer_sequence、多维数组递归展开等技术。但这些都不重要,重要的是你要理解它的核心思想:把运行时分支转换成查表,把类型分派提前到编译期。
3.4 为什么不允许引用类型
std::variant<int&, double>是不允许的。原因很实在:variant 要管理值的生命周期,引用不是独立的对象,它只表示“指向某个已存在的对象”。如果允许引用,那移动、拷贝、析构语义都会变得极其混乱——到底要不要跟着引用目标的生命周期?标准委员会考虑到这个语义负担,直接一刀切禁止引用类型。
如果确实需要“引用语义”,推荐用std::reference_wrapper<T>包一层,它本质上就是个指针,但能参与 variant 的组合。
4. 手写一个迷你 std::variant
4.1 基础模板骨架
接下来我们动手实现一个极简但可用的variant。目标不是完全复刻标准库,而是把你前面理解的源码原理落地成一个能跑、能编译、具备核心功能的类型。
先定义基础模板:
#include <iostream> #include <type_traits> #include <stdexcept> #include <utility> template <typename... Ts> class variant; template <> class variant<> { public: variant() = delete; };空包没有可构造的类型,直接 delete 掉。接下来定义存储 union。这里为了简化,我直接用unsigned char数组 + 对齐控制。
template <typename... Ts> union Storage { unsigned char data_[std::max({sizeof(Ts)...})]; alignas(Ts...) unsigned char aligned_data_[std::max({sizeof(Ts)...})]; };等等,完整的写法应该是同时兼顾大小和对齐。不过为了简洁,也可以这样做:
template <typename... Ts> union Storage { unsigned char data_[std::max({sizeof(Ts)...})]; };问题在于,如果Ts...里有double或struct要求 8 字节对齐,而unsigned char的数组默认对齐只有 1,那么reinterpret_cast<T*>(data_)就可能产生未对齐访问的 UB。所以需要显式对齐。
真正的做法是用alignas(Ts...):
template <typename... Ts> union Storage { alignas(Ts...) unsigned char data_[std::max({sizeof(Ts)...})]; };默认new表达式已经保证了对齐到__STDCPP_DEFAULT_NEW_ALIGNMENT__,但Storage作为 union 成员,如果以栈对象方式存在,也必须保证对齐。alignas(Ts...)会取所有类型对齐值的最大值,正好。
但这里有个小细节:std::max({sizeof(Ts)...})是std::initializer_list版本的 max,需要包含<algorithm>。更优雅的方式是用std::max_element或std::max的模板展开。我这里为了教学就直接用std::max({...})。
4.2 实现构造函数与析构
我们在variant模板里,需要知道每个类型 T 在Ts...中的下标。写一个编译期工具IndexOf:
template <typename T, typename... Ts> struct IndexOf; template <typename T, typename... Rest> struct IndexOf<T, T, Rest...> : std::integral_constant<size_t, 0> {}; template <typename T, typename U, typename... Rest> struct IndexOf<T, U, Rest...> : std::integral_constant<size_t, 1 + IndexOf<T, Rest...>::value> {};然后variant的主体:
template <typename... Ts> class variant { Storage<Ts...> storage_; size_t index_ = 0; // 默认激活第一个类型 template <typename T> static constexpr size_t index_of = IndexOf<std::decay_t<T>, Ts...>::value; public: variant() = default; template <typename T, typename = std::enable_if_t< !std::is_same_v<std::decay_t<T>, variant> && (std::is_constructible_v<Ts, T> || ...) >> variant(T&& value) { using D = std::decay_t<T>; constexpr size_t I = IndexOf<D, Ts...>::value; new (&storage_) D(std::forward<T>(value)); index_ = I; } ~variant() { destroy_current(); } };这里有一系列问题要处理:
std::is_constructible_v<Ts, T> || ...是一个折叠表达式,用来判断“Ts 中是否存在一个可用 T 构造的类型”。但仅仅判断“存在”还不够,还需要保证“不二义”,如果多个 Ts 都能被 T 构造,会产生歧义。我们为了简化,直接假设使用者传入的 T 能精确匹配一个 Ts。如果想要更精确,可以写一个unique_constructible判断,这里先不展开。std::decay_t<T>可以去掉引用和 cv 限定,让const char*变成const char*,而std::string变成std::string,不会出现引用类型的问题。- 析构函数要判断当前 index 并调用对应析构。
4.3 实现 get 和 index
index()就返回index_。get<I>()需要写一个 const 和非 const 版本:
template <size_t I> decltype(auto) get() { if (index_ != I) { throw std::bad_variant_access(); } return *reinterpret_cast<std::tuple_element_t<I, std::tuple<Ts...>>*>(&storage_); }这里用std::tuple_element_t<I, std::tuple<Ts...>>获取第 I 个类型。但要注意:reinterpret_cast从Storage的地址转换为T*,如果 Storage 数组对齐足够,访问是合法的。我们已经在 Storage 上加了alignas(Ts...),所以没问题。
我们需要一个非 const 版本:
template <size_t I> decltype(auto) get() const { if (index_ != I) { throw std::bad_variant_access(); } return *reinterpret_cast<const std::tuple_element_t<I, std::tuple<Ts...>>*>(&storage_); }再来一个按类型的版本。最直接的方式是:
template <typename T> T& get() { return get<IndexOf<T, Ts...>::value>(); }但这有个问题:如果 T 不在 Ts 里,IndexOf会编译失败,这没问题。但如果 T 和另一个类型有相同的判断,也不行。
4.4 实现 emplace 与赋值
emplace<T>(args...)的本质是:先销毁当前值,再在 storage 里原地构造 T,并更新 index。但如果构造 T 抛出异常,variant 就会进入“无值”状态。标准库会用valueless_by_exception()来标记这种状态。
我们简化处理:不用 valueless 机制,直接让异常传播,但这会导致析构函数无法判断当前 index 对应的类型是否真实构造成功。为了教学,我们可以先假设构造不会抛异常,或者用try-catch做简化处理:
template <typename T, typename... Args> void emplace(Args&&... args) { destroy_current(); try { new (&storage_) T(std::forward<Args>(args)...); index_ = IndexOf<T, Ts...>::value; } catch (...) { index_ = std::variant_npos; // 表示无值 throw; } }这个std::variant_npos是个特殊值,在 C++17 中它的定义是static_cast<size_t>(-1),表示“无当前活跃类型”。我们的迷你版本也可以仿照它。
赋值操作,最简单的方式是用临时对象拷贝/移动:
template <typename T> variant& operator=(T&& value) { using D = std::decay_t<T>; constexpr size_t I = IndexOf<D, Ts...>::value; if (index_ == I) { // 直接对已有对象赋值 get<I>() = std::forward<T>(value); } else { emplace<D>(std::forward<T>(value)); } return *this; }这里有个小坑:如果I和当前index_相同,直接用get<I>() = value,要求该类型可赋值。如果不相同,就需要销毁旧对象再构造新对象。这个分支是必不可少的,否则会出现“先销毁再构造失败导致整个对象不可用”的问题。
4.5 实现一个简化版的 visit
我们的迷你版本要支持visit,可以用递归展开的方式。思路是:给定一个 visitor 和 当前 variant,我们生成一个函数,它根据 index 分派到对应的get<I>()。最简单的写法是展开成一个 if-else 链:
template <typename Visitor, typename Variant> decltype(auto) visit(Visitor&& vis, Variant&& var) { using result_type = decltype(vis(std::get<0>(var))); return visit_impl(vis, var, std::index_sequence_for<Ts...>()); }但这样的写法需要知道所有的Ts...。我们换个思路,直接写一个递归 if-else:
template <typename Visitor, typename Variant, size_t... Is> decltype(auto) visit_impl(Visitor&& vis, Variant&& var, std::index_sequence<Is...>) { constexpr size_t N = sizeof...(Is); size_t idx = var.index(); // 这里需要运行时跳转 // 写法一:if-else 链 // 写法二:函数指针表 // 我们选函数指针表 }函数指针表的思路是:预先构造一个std::array<result_type (*)(Visitor&&, Variant&&), N>,每个函数指针对应一个vis(std::get<I>(var))的调用。
但在这里会遇到一个麻烦:result_type可能是一个引用类型,而函数指针返回引用不好构造。而且 lambda 模板是泛型的,同一套 visitor 在实例化不同get<I>时,函数体不同。
更简单的写法是直接用 if-else 链,虽然代码冗长,但容易理解:
template <typename Visitor, typename Variant, typename... Ts> decltype(auto) visit_impl(Visitor&& vis, Variant&& var) { constexpr size_t N = sizeof...(Ts); size_t idx = var.index(); if (idx == 0) { if constexpr (N > 0) { return vis(std::get<0>(var)); } } // ... }这样一个个手写太蠢了,我们用std::initializer_list的展开技巧来构造函数指针表:
template <typename Visitor, typename Variant, typename... Ts> decltype(auto) visit_impl(Visitor&& vis, Variant&& var, std::index_sequence<Is...>) { using Result = std::common_type_t<decltype(vis(std::get<Is>(var)))...>; static const Result (*table[])(Visitor&&, Variant&&) = { +[](Visitor&& v, Variant&& var) -> Result { return v(std::get<Is>(var)); }... }; return table[var.index()](std::forward<Visitor>(vis), std::forward<Variant>(var)); }注意:std::common_type_t不是标准库为这种场景准备的,它不会处理引用类型。为了教学,我们简化假设 visitor 返回的都是同一类型。标准库的std::visit对返回类型的处理更宽松。
上面的展开非常巧妙:通过Is...的展开,我们为每个下标 I 生成一个 lambda,捕获 I,编译期std::get<Is>(var)取出对应类型的引用。最后用var.index()查表调用。这就是std::visit的核心思想。
4.6 完整代码与编译测试
我把上面的代码拼起来,一个可用的迷你 variant 就有了。这里再贴一下完整的实现,方便你直接抄写运行:
#include <iostream> #include <type_traits> #include <tuple> #include <algorithm> #include <stdexcept> #include <utility> template <typename... Ts> union Storage { alignas(Ts...) unsigned char data_[std::max({sizeof(Ts)...})]; }; template <typename T, typename... Ts> struct IndexOf; template <typename T, typename... Rest> struct IndexOf<T, T, Rest...> : std::integral_constant<size_t, 0> {}; template <typename T, typename U, typename... Rest> struct IndexOf<T, U, Rest...> : std::integral_constant<size_t, 1 + IndexOf<T, Rest...>::value> {}; template <typename... Ts> class variant { Storage<Ts...> storage_; size_t index_ = 0; template <typename T> static constexpr size_t index_of = IndexOf<std::decay_t<T>, Ts...>::value; template <size_t I> using type_at = std::tuple_element_t<I, std::tuple<Ts...>>; void destroy_current() { if (index_ == std::variant_npos) return; destroy_current_impl(std::index_sequence_for<Ts...>()); } template <size_t... Is> void destroy_current_impl(std::index_sequence<Is...>) { // 使用折叠表达式,只会执行 index 匹配的那个分支 ((index_ == Is ? (reinterpret_cast<type_at<Is>*>(&storage_)->~type_at<Is>(), void()) : void()), ...); } public: variant() = default; template <typename T, typename = std::enable_if_t< !std::is_same_v<std::decay_t<T>, variant> && !std::is_same_v<std::decay_t<T>, std::in_place_t> >> variant(T&& value) { using D = std::decay_t<T>; constexpr size_t I = index_of<D>; new (&storage_) D(std::forward<T>(value)); index_ = I; } variant(variant&& other) { // 简化:只拷贝当前值,但这里省略移动构造细节 } variant(const variant& other) { // 简化:只拷贝当前值 } ~variant() { destroy_current(); } size_t index() const noexcept { return index_; } template <size_t I> type_at<I>& get() { if (index_ != I) [[unlikely]] { throw std::bad_variant_access(); } return *reinterpret_cast<type_at<I>*>(&storage_); } template <size_t I> const type_at<I>& get() const { if (index_ != I) [[unlikely]] { throw std::bad_variant_access(); } return *reinterpret_cast<const type_at<I>*>(&storage_); } template <typename T, typename... Args> void emplace(Args&&... args) { destroy_current(); try { new (&storage_) T(std::forward<Args>(args)...); index_ = index_of<T>; } catch (...) { index_ = std::variant_npos; throw; } } template <typename T> variant& operator=(T&& value) { using D = std::decay_t<T>; constexpr size_t I = index_of<D>; if (index_ == I) { get<I>() = std::forward<T>(value); } else { emplace<D>(std::forward<T>(value)); } return *this; } }; template <typename... Ts> decltype(auto) visit(auto&& vis, variant<Ts...>& var) { using Result = std::common_type_t<decltype(vis(var.template get<Is>()))...>; static const Result (*table[])(decltype(vis)&, variant<Ts...>&) = { +[](decltype(vis)& v, variant<Ts...>& var) -> Result { return v(var.template get<Is>()); }... }; return table[var.index()](vis, var); }上面这段代码为了展示主体思想,做了不少省略和简化,比如拷贝构造和移动构造没有完全实现原语义。我建议你把拷贝构造和移动构造用“拷贝/移动当前活跃类型”的方式补全。具体做法是:遍历所有可能类型,根据 index 调用对应的拷贝构造/移动构造。
4.7 实测:使用我们的迷你 variant
int main() { variant<int, double, std::string> v; v = 3.14; std::cout << v.index() << std::endl; // 1 std::cout << v.get<1>() << std::endl; // 3.14 v.emplace<std::string>("hello"); std::cout << v.get<std::string>() << std::endl; visit([](auto&& value) { std::cout << value << std::endl; }, v); return 0; }这个迷你版本能跑,但和标准库相比差距非常大。它不处理in_place_type_t、不处理递归引用、没有做 SFINAE 约束、没有实现完整的valueless_by_exception机制。不过它已经把最重要的骨架撑起来了,剩下的部分,本质上就是在这个骨架上加边界检查和更多的模板约束。
5. 常见问题与排查技巧实录
5.1 “为什么赋值给了错误的类型?”
这个问题我在代码评审里见过好多次。比如:
std::variant<int, long> v; v = 10; // int期望是 long,结果是 int,因为字面量 10 的类型是 int,精确匹配优先。如果你想要 long,应写v = 10L;或者用emplace<long>(10)。
再比如:
std::variant<float, double> v; v = 1.0f; // float v = 1.0; // double这看着直观,但一旦牵扯到隐式转换,比如把char赋给variant<int, long>,char 会优先匹配 int。如果 variant 里没有 int,只有 long,char 才会隐式转 long。这种“默认优先顺序”很容易造成 bug,尤其是从旧代码迁移到 variant 的时候。
5.2 “std::visit 的编译错误也太难读了”
如果你在使用std::visit时写出编译错误,报错信息能绕地球三圈。常见的原因有:
- visitor 的所有
operator()返回值类型不一致。标准库要求所有分支返回类型相同,编译器会报std::common_type相关的错误。 - visitor 是重载函数名而不是可调用对象。你必须用
overload模式包装,或者直接传 lambda。 - 多个 variant 参数时,visitor 的参数数量不匹配。
我的经验是:使用overload模式时,给每个 lambda 显式写出参数类型和返回类型,报错定位会快得多。
5.3 “什么时候会进入 valueless_by_exception?”
很多初学者会忽略这点。简单说:当你执行emplace<T>(args...)时,如果 T 的构造函数抛出异常,variant 为了保证不处于“坏状态”,会把内部标记为 valueless。之后你不能再通过 get 访问任何值,只能重新赋值或 emplace。
举一个例子:
struct MayThrow { MayThrow() { throw std::runtime_error("boom"); } }; std::variant<MayThrow, int> v; try { v.emplace<MayThrow>(); } catch (...) { std::cout << v.valueless_by_exception() << std::endl; // 1 }这个特性在某些容错场景里很有用,但也非常容易踩坑。一旦检测到 valueless,就得做好重新初始化的准备,别再尝试读取。
5.4 “variant 的内存占用怎么比预期大?”
很多人好奇sizeof(std::variant<int, double, std::string>)到底是多少。它本质上是max(sizeof(Ts)...)加上索引整数,可能再补一些对齐填充。这也就意味着,即使你只存一个 int,整个 variant 的尺寸也可能达到最大的那个类型的大小,再加上 padding。
如果空间非常敏感,可以考虑用std::variant还是换个方案。比如用指针组合、或者用boost::variant的自定义存储策略。不过绝大多数场景下,这个空间开销是完全可接受的——换来的是类型安全和代码可维护性。
5.5 手写 variant 的坑:别忘了对齐
我在自己实现时踩过最大的坑就是“对齐”。
只说alignas(Ts...) unsigned char data_[...]还不够?当然不够。因为Storage作为一个 union,本身的对齐可能被编译器降级。比如当Ts...里只有int和float,alignas(int, float)等价于alignas(4),没问题。但如果Ts...里有一个struct { double d; int i; },它的对齐是 8,你直接用数组,只有alignas还不够,因为数组存在对象内部的data_起始地址,必须得保证data_那个成员本身的偏移是 8 的倍数。
在实际标准库实现里,用的是带alignas的 union 成员,或者用aligned_storage_t。我们手写的时候,最简单的做法:
struct alignas(std::max({alignof(Ts)...})) Storage { unsigned char data_[std::max({sizeof(Ts)...})]; };把对齐加到 struct 上,而不是只加到成员上,这样才能保证栈上对象的起始地址对齐。
5.6 为什么拷贝赋值要单独处理“相同类型”分支
如果一个 variant 当前是 int,你赋一个 int,那直接get<I>() = value就行,不需要销毁重建。但如果当前是 double,你要赋 int,就得先析构 double,再在存储里构造 int。这两个路径缺一不可。
很多人手写时试图统一走“析构→构造”的路径,结果在自赋值或异常安全上出问题。比如std::string作为构造类型时,赋值过程中抛异常,就可能让旧数据丢失。标准库对这个做了异常安全保证,我们手写版本也应当至少考虑拷贝赋值的可靠性。
6. 经验总结:我对 std::variant 的真实看法
6.1 该用的时候大胆用,该换的时候果断换
我的实际体会是,std::variant不是万能药。它最适合的场景是“类型有限且可枚举”。比如一个网络协议包里可能有四种消息类型,用 variant 比用enum + switch + void*好维护得多。它不适合的场景是“类型无限增长”,因为每次新增类型都要改 visitor、改所有 get 分支,编译器会提醒你,但也意味着你必须改动很多处代码。
如果你发现一个 variant 的类型列表开始超过五六个,并且还在持续加,那就该停下来想想这是不是该用虚函数或继承体系了。
6.2 性能表现
std::variant的运行时开销基本等于一个整数比较或查表跳转,比手写 switch 并不差。std::visit因为生成了函数指针表,实际性能往往比手写 if-else 还稳定——尤其当分支很多时,查表的指令周期是稳定的,而 if-else 链的平均成本随分支位置变化。所以如果你手写代码里有大量“类型标签 + switch”的写法,换成 variant 经常能带来小幅但真实的性能提升。
6.3 和现代 C++ 生态的搭配
std::variant和std::optional、std::expected(C++23)各有分工。optional 是“一个值或者无值”,variant 是“多个类型中选一个”。在错误处理方面,expected 更适合“返回值或错误码”。实际项目中,组合使用能让代码表达力上一个台阶。比如函数返回std::variant<Data, Error>,调用方用 visit 统一处理成功与失败,比到处写 if 判断舒服得多。
6.4 后续扩展建议
如果你对手写 variant 感兴趣,可以继续往这几个方向扩展:
- 支持
in_place_type_t<T>和in_place_index_t<I>标签,允许精确指定要构造的类型。 - 实现完整的拷贝构造/移动构造/拷贝赋值/移动赋值。
- 支持多个 variant 参数的 visit。
- 实现
std::variant_size、std::variant_alternative的 trait。
这几个点做完,你的实现就非常接近一个可用的工业级组件了。
最后分享一个小技巧:调试 variant 相关代码时,不要只看一个类型和值,要把index()和valueless_by_exception()一起打出来。三个信息同时观察,基本能定位九成以上的问题。