1. 项目概述:为什么我们需要 std::variant?
如果你写过一段时间的 C++,尤其是处理过一些需要表示“这个变量可能是 A 类型,也可能是 B 类型”的场景,那你大概率对union又爱又恨。爱的是它节省内存,恨的是它太“原始”了——你得自己手动记录当前存的是哪个类型,类型不安全,析构还得操心。C++17 引入的std::variant,就是为了解决这个痛点而生的“类型安全联合体”。
简单说,std::variant是一个可以持有多种预定义类型中某一种的容器。它和union最大的区别在于,它是类型安全的:它知道当前存储的是哪个类型,并且能确保该类型的对象被正确构造和析构。这听起来像是小事,但在实际项目中,它能帮你避免一大类难以追踪的运行时错误和内存泄漏。我接手过一个遗留的数据解析模块,里面大量使用了裸的union来存储整数、浮点数和字符串指针,每次修改都提心吊胆,生怕破坏了那个手动维护的type标签。后来用std::variant重构后,代码清晰了,内存安全了,调试也轻松多了。
这个特性特别适合那些返回值或数据成员有多种可能类型的场景,比如解析 JSON/XML 后得到的数据(可能是int、double、bool、std::string或null),或者游戏引擎中一个实体(Entity)的组件(Component)数据,再或者网络协议中一个字段的不同编码格式。std::variant不是要替代继承和多态,而是提供另一种更轻量、值语义的“多态”选择,尤其当类型集合在编译期就已知且固定时,它是非常高效的工具。
2. 核心特性与内部机制拆解
2.1 类型安全联合体的实现基石
std::variant的核心目标是提供一个类型安全、易用的联合体。为了实现这一点,标准库实现(比如 GCC 的 libstdc++ 或 Clang 的 libc++)通常采用一种称为“类型擦除”与“本地存储”相结合的策略。它内部会有一个足够大的对齐存储区(类似std::aligned_storage),用来存放所有可能类型中最大的那个对象。同时,它还需要一个索引(通常是std::size_t)来记录当前活跃(即存储有效对象)的类型是哪一个。
当你构造一个std::variant<int, double, std::string> v(“hello”)时,发生了几件事:
- 编译器确定
std::string是候选类型之一,并且是当前要初始化的类型。 - 在
v的内部存储区中,使用 placement new 就地构造一个std::string(“hello”)。 - 将内部索引设置为对应
std::string的序号(比如 2,从0开始计数)。 - 当
v被销毁或重新赋值时,它会根据当前索引,调用对应类型的析构函数,确保资源被正确释放。
这个过程完全由编译器生成的代码和标准库管理,用户无需手动干预类型标签和生命周期,这就是“类型安全”的由来。相比之下,使用union时,如果你存储了一个std::string,离开作用域时必须手动调用它的析构函数,否则就是内存泄漏,而std::variant帮你自动做了这件事。
2.2 关键成员函数与访问方式
std::variant提供了一套丰富的接口来管理其存储的值。
构造与赋值: 你可以用任何可转换到候选类型的值来构造或赋值。这里有一个很重要的特性叫严格弱序。当有多个候选类型都能从输入值构造时(比如用0初始化variant<int, double>,0既能转int也能转double),编译器会选择“最佳匹配”。通常,转换等级更高(不需要转换或仅需标准转换)的类型会被选中。如果存在歧义,代码将无法编译。这是一个安全特性,避免了意外的类型转换。
std::variant<int, double> v1 = 42; // 存储 int,因为 42 是整型字面量 std::variant<int, double> v2 = 3.14; // 存储 double // std::variant<int, long> v3 = 0; // 可能歧义,0 可转为 int 或 long,编译错误访问值:std::get与std::get_if这是最直接的访问方式,但也是“不安全”的(如果访问的不是当前活跃类型,会抛出异常)。
std::get<T>(v)或std::get<I>(v):如果v当前持有类型T或索引I对应的类型,则返回对应值的引用;否则,抛出std::bad_variant_access异常。std::get_if<T>(&v)或std::get_if<I>(&v):这是一个“试探性”访问。如果类型匹配,返回指向值的指针;否则,返回nullptr。这适合在不确定类型时进行条件检查。
std::variant<int, std::string> v = “hello”; try { int i = std::get<int>(v); // 抛出 std::bad_variant_access } catch (const std::bad_variant_access& e) { std::cout << “Wrong type access!\n”; } if (auto* pstr = std::get_if<std::string>(&v)) { std::cout << “Value is a string: ” << *pstr << ‘\n’; // 安全访问 }类型安全访问:std::visit这是std::variant的“王牌”功能,也是实现编译期多态的关键。std::visit接受一个可调用对象(函数、lambda、函数对象)和一个或多个variant。它会根据variant当前存储的实际类型,自动分派到可调用对象对应的重载上。这需要用到访问者模式,但 C++17 让它变得异常简洁,尤其是配合autolambda 和重载集合。
// 定义一个访问者:一个重载的函数对象集合 struct Visitor { void operator()(int i) { std::cout << “int: ” << i << ‘\n’; } void operator()(double d) { std::cout << “double: ” << d << ‘\n’; } void operator()(const std::string& s) { std::cout << “string: ” << s << ‘\n’; } }; std::variant<int, double, std::string> v = 3.14159; std::visit(Visitor{}, v); // 输出:double: 3.14159 // 更现代的方式:使用 overloaded 模式(C++17 起可用) template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; template<class... 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);std::visit的强大之处在于,它将运行时类型的判断(原本可能需要switch-case或if-else链)转换成了编译期的类型分发,通常能生成非常高效的代码,并且是类型安全的,你不可能漏掉某个类型的处理。
其他实用成员:
index():返回当前活跃类型的索引(从0开始)。valueless_by_exception():在极少数情况下(比如在赋值过程中构造函数抛出异常),variant可能处于“无值”状态。这个函数用来检查这种状态。在良好定义的代码中,应尽量避免进入此状态。emplace<I>(args...)/emplace<T>(args...):就地构造指定索引或类型的对象,传入构造参数args...。这会先销毁当前值(如果存在),然后在新存储上构造新对象。swap():与另一个variant交换内容。
2.3 特殊成员与生命周期管理
std::variant的析构函数、移动构造函数、移动赋值运算符都是默认定义的,并且会正确地处理内部存储对象的生命周期。拷贝构造和拷贝赋值要求所有候选类型都是可拷贝的。
一个需要特别注意的细节是析构顺序。当variant被销毁时,它会先调用当前活跃对象的析构函数,然后释放内部存储。如果候选类型中有持有资源(如动态内存、文件句柄)的类型,variant能确保这些资源被正确清理。
另一个细节是空状态。std::variant不允许处于“空”或“未初始化”状态(除非valueless_by_exception)。在构造时,你必须提供一个初始值,或者使用默认构造函数(这会默认构造第一个候选类型)。这意味着std::variant总是包含一个有效对象,这简化了推理,但也意味着你需要一个合理的“第一个类型”作为默认值。如果第一个类型默认构造开销很大或不合理,你可能需要重新排序候选类型列表,或者使用std::monostate(一个空占位符类型)作为第一个类型。
// 如果第一个类型 std::string 默认构造开销大,或者逻辑上不合理 std::variant<std::string, int, double> v1; // 默认构造一个空字符串,可能不是想要的 std::variant<std::monostate, std::string, int, double> v2; // 默认构造为 monostate (空状态) if (std::holds_alternative<std::monostate>(v2)) { std::cout << “Variant is currently empty.\n”; }3. 典型应用场景与实战案例
3.1 替代传统枚举与联合体
这是最直接的用途。假设你有一个配置项,它可以是布尔值、整数、浮点数或字符串。旧式做法可能是一个union加一个enum Type。
// 旧式做法 (易错) struct OldConfigValue { enum Type { BOOL, INT, DOUBLE, STRING } type; union { bool b; int i; double d; char* s; // 手动管理内存,危险! } value; ~OldConfigValue() { if (type == STRING) free(value.s); } // 必须手动管理 }; // std::variant 做法 (安全、简洁) using ConfigValue = std::variant<bool, int, double, std::string>; ConfigValue cv = “localhost”; // 存储字符串 cv = 8080; // 改为存储整数 // 析构自动处理,无需手动释放内存。使用std::variant,内存管理和类型安全都交给了标准库,代码意图更清晰,错误更少。
3.2 实现轻量级、值语义的多态
继承和虚函数是实现运行时多态的传统方式,但它们要求指针或引用语义,可能涉及堆分配,并且类型体系是开放的(可以随时添加新子类)。std::variant提供了一种封闭类型、值语义的多态。
封闭类型意味着所有可能的类型在编译期就完全确定了。这既是限制也是优势:编译器能进行更多的优化,并且能确保所有情况都被处理(配合std::visit)。这在处理诸如“AST节点类型”、“JSON值类型”、“事件类型”等固定集合时非常有用。
值语义意味着对象可以像int、double一样被拷贝、移动、存储在容器中,不需要担心生命周期的复杂管理(与std::any相比,variant的值语义更清晰,因为类型已知)。
// 表示一个数学表达式节点 struct Number { double value; }; struct Add; struct Multiply; // 前向声明 using Expr = std::variant<Number, std::unique_ptr<Add>, std::unique_ptr<Multiply>>; struct Add { Expr lhs, rhs; }; struct Multiply { Expr lhs, rhs; }; // 求值函数 double eval(const Expr& expr) { return std::visit(overloaded { [](const Number& num) { return num.value; }, [](const std::unique_ptr<Add>& add) { return eval(add->lhs) + eval(add->rhs); }, [](const std::unique_ptr<Multiply>& mul) { return eval(mul->lhs) * eval(mul->rhs); } }, expr); } // 使用 Expr e = std::make_unique<Add>(Number{1}, std::make_unique<Multiply>(Number{2}, Number{3})); std::cout << eval(e); // 输出 7在这个例子中,Expr是一个值类型,可以安全地存储在栈或容器里。std::visit提供了清晰、高效的类型分发。
3.3 错误处理与返回多个可能类型
std::variant可以用来模拟类似 Rust 的Result<T, E>类型,表示一个可能成功(返回类型T)也可能失败(返回错误类型E)的操作。这比使用输出参数或异常在某些场景下更清晰。
template<typename T, typename E> using Result = std::variant<T, E>; Result<std::vector<int>, std::string> parse_numbers(const std::string& input) { std::vector<int> numbers; std::istringstream iss(input); int num; while (iss >> num) { numbers.push_back(num); } if (iss.eof() && !numbers.empty()) { return numbers; // 成功,返回数据 } else { return std::string(“Parse error”); // 失败,返回错误信息 } } auto result = parse_numbers(“10 20 30”); std::visit(overloaded { [](const std::vector<int>& nums) { /* 处理成功数据 */ }, [](const std::string& err) { std::cerr << “Error: ” << err; } }, result);这种方式将错误作为返回值的一部分,调用方必须显式处理成功和失败两种情况,促进了更健壮的错误处理逻辑。
3.4 状态机与有限状态表示
对于状态数量有限且每个状态可能关联不同数据的状态机,std::variant是一个优雅的表示工具。每个状态对应一个类型,variant的当前值代表当前状态。
struct Idle { /* 可能包含空闲时间等数据 */ }; struct Connecting { std::string server_addr; int attempt_count; }; struct Connected { int socket_fd; time_t connected_at; }; struct Error { int error_code; std::string message; }; using ConnectionState = std::variant<Idle, Connecting, Connected, Error>; ConnectionState state = Idle{}; // 状态转移 state = Connecting{“127.0.0.1:8080”, 0}; // 处理状态 std::visit(overloaded { [](const Idle&) { std::cout << “Idle.\n”; }, [](const Connecting& conn) { std::cout << “Connecting to ” << conn.server_addr << “\n”; }, [](const Connected& conn) { std::cout << “Connected, fd=” << conn.socket_fd << “\n”; }, [](const Error& err) { std::cerr << “Error [” << err.error_code << “]: ” << err.message << “\n”; } }, state);这种表示法将状态和数据紧密绑定,并且利用std::visit可以确保所有状态都被处理,编译器会在你添加新状态但忘记更新visit调用时发出警告(如果你使用了如-Wswitch等警告标志并配合enum模拟,但variant的方式更直接)。
4. 性能考量、优化技巧与陷阱规避
4.1 内存布局与访问开销分析
std::variant的内存大小至少是所有候选类型大小的最大值,加上一个索引(通常是一个size_t)的开销,并且会进行对齐。例如,std::variant<int, double, std::string>的大小至少是max(sizeof(int), sizeof(double), sizeof(std::string)) + sizeof(size_t),再根据对齐要求调整。
访问开销主要来自两方面:
- 索引检查:使用
std::get时(非visit),运行时需要检查索引是否匹配,不匹配则抛异常。这是一个简单的整数比较,开销很小。 - 类型分发:
std::visit的分发通常通过函数指针表(vtable-like)或编译期生成的跳转表实现,其效率接近于虚函数调用,但因为是编译期确定所有类型,优化器有时能做得更好,比如内联小函数。
优化建议:
- 将小类型、频繁访问的类型放在前面:虽然标准未规定,但一些实现可能对索引检查有微优化。更重要的是,在调试时,默认构造的
variant是第一个类型,选择一个开销小、有意义的默认类型能提升体验。 - 避免存储大对象:如果候选类型中有非常大的对象(如大数组),考虑改用指针(如
std::unique_ptr)存储在variant中,以减少variant本身的大小和移动开销。但这会引入间接访问和堆分配的成本,需要权衡。 - 使用
std::visit而非get/get_if链:对于多分支处理,visit通常比手写if-else或switch链更高效,也更安全。
4.2 与 std::any、继承多态的对比选型
C++中有多种实现“多态”或“存储任意类型”的机制,选择合适的很重要。
std::variantvsstd::any:variant:类型集合编译期已知、封闭、值语义。访问效率高(编译期类型信息),能进行编译期检查(如visit覆盖所有类型)。内存占用确定。any:可以存储任何可拷贝的类型,类型集合运行期开放、类型擦除。每次访问都需要any_cast,有运行时检查开销。通常使用堆分配,开销更大。- 选型:如果类型在编译期能确定,优先用
variant。只有在需要存储真正“任意”的、运行时才知的类型时,才用any。
std::variantvs 继承虚函数:variant:封闭类型,值语义,无虚表指针开销(但可能有索引开销),适合类型集合固定且较小的场景。通过visit实现的分发是“外部”的,添加新操作容易(写新的visit),但添加新类型难(需要修改variant定义和所有visit调用点)。- 继承:开放类型,引用语义,有虚表指针开销,适合需要频繁添加新类型的场景。添加新类型容易(派生新类),但添加新操作难(需要在基类添加虚函数,修改所有派生类)。
- 选型:这就是著名的“表达式问题”。如果你预期操作(算法)比类型变体更常增加,用
variant+visit(Visitor模式)。如果你预期类型比操作更常增加,用继承+虚函数。如果都固定,variant的值语义和性能通常更优。
4.3 常见陷阱与最佳实践
异常安全与
valueless_by_exception: 在variant的赋值或emplace操作中,如果新对象的构造抛出异常,而旧对象已被销毁,则variant会进入valueless_by_exception状态。此时大多数操作(除了析构和赋值)都是未定义的。最佳实践:确保存储在variant中的类型的构造函数和赋值操作提供强异常保证,或者至少不抛出异常。对于可能抛出的操作,可以考虑先在外围构造好对象,然后用emplace或赋值不抛异常的移动操作。默认构造与
std::monostate:variant的默认构造函数会默认构造第一个候选类型。如果第一个类型默认构造不合理(如开销大、不应有默认值),考虑引入std::monostate作为第一个类型来表示“空”或“未设置”状态。访问遗漏: 使用
std::get或get_if时,很容易漏掉某些类型的检查。强烈建议使用std::visit配合overloaded模式,编译器(结合适当的警告标志)能帮助你检查是否覆盖了所有类型。对于简单的两类型variant(如Result<T, E>),也可以使用holds_alternative先检查。递归
variant与内存: 定义递归的variant(如上面的表达式树例子)需要用到std::unique_ptr等智能指针来打破循环依赖,否则类型大小无限。注意这会将值语义变为指针语义,管理稍复杂。移动语义:
std::variant支持移动操作。但要注意,移动一个variant后,源对象仍然有效,但处于“被移动”状态(其内部对象处于有效但未指定的状态)。通常,移动后不应再访问源对象的值,除非重新赋值。与
std::optional结合: 有时一个值可能不存在,也可能是多种类型之一。可以组合使用std::optional<std::variant<Types...>>或std::variant<std::monostate, Types...>。前者更清晰地分离了“有无”和“类型”两个概念。
5. 高级用法与编译期技巧
5.1 使用 std::visit 处理多个 variant
std::visit可以接受多个variant参数,它会根据所有variant的当前活跃类型组合,进行多重分派。这对于需要处理多个动态类型组合的场景非常强大。
using Var1 = std::variant<int, double>; using Var2 = std::variant<std::string, char>; Var1 v1 = 42; Var2 v2 = ‘A’; // 访问者需要处理所有类型组合: (int, string), (int, char), (double, string), (double, char) std::visit(overloaded { [](int i, const std::string& s) { std::cout << “int + string: ” << i << “, ” << s << ‘\n’; }, [](int i, char c) { std::cout << “int + char: ” << i << “, ” << c << ‘\n’; }, [](double d, const std::string& s) { std::cout << “double + string: ” << d << “, ” << s << ‘\n’; }, [](double d, char c) { std::cout << “double + char: ” << d << “, ” << c << ‘\n’; } }, v1, v2);这个特性可以用于实现简单的规则引擎或事件处理系统,其中多个输入对象的不同类型组合触发不同的行为。
5.2 利用模板元编程遍历与操作 variant
有时我们需要对variant的类型列表本身进行操作,比如判断某个类型是否在列表中,或者对所有类型进行某种统一操作(如生成默认值)。这需要用到模板元编程。
判断类型是否在列表中:可以直接用std::holds_alternative<T>(v)在运行时判断,但编译期判断也有用。
template <typename T, typename... Ts> constexpr bool is_one_of = (std::is_same_v<T, Ts> || …); // C++17 折叠表达式 static_assert(is_one_of<int, double, int, std::string>); // true static_assert(!is_one_of<float, double, int, std::string>); // true遍历类型列表:我们可以编写模板函数,对variant的每种类型实例化一个操作。
template<typename... Ts, typename F> void for_each_type_in_variant(const std::variant<Ts...>&, F func) { // 技巧:使用初始化列表展开参数包,对每种类型调用 func (func.template operator()<Ts>(), …); // C++17 折叠表达式 } // 用法:打印 variant 中每种类型的名字 std::variant<int, double, std::string> v; for_each_type_in_variant(v, []<typename T>() { std::cout << typeid(T).name() << ‘\n’; });这种技巧在需要为variant的每种类型生成特化代码时非常有用,比如序列化/反序列化库。
5.3 自定义访问者与重载模式的最佳实践
前面展示了使用overloaded类模板和推导指引来创建内联访问者。这是 C++17 之后最简洁的方式。但在大型项目中,你可能希望重用访问者。一些最佳实践包括:
将复杂的访问者定义为独立的类或结构体,特别是当操作逻辑复杂或需要维护状态时。
使用
if constexpr和auto参数(C++17):在泛型 lambda 中,可以利用if constexpr和std::is_same_v进行类型判断,实现一个 lambda 处理多种类型(但可能不如重载清晰)。std::visit([](auto&& arg) { using T = std::decay_t<decltype(arg)>; if constexpr (std::is_same_v<T, int>) { std::cout << “Processing int: ” << arg; } else if constexpr (std::is_same_v<T, double>) { std::cout << “Processing double: ” << arg; } else if constexpr (std::is_same_v<T, std::string>) { std::cout << “Processing string: ” << arg; } }, v);这种方式在类型处理逻辑有大量共享代码时可能更紧凑,但失去了重载的清晰分派结构,且添加新类型时需要修改同一个函数体。
访问者可以返回一个值,
std::visit的返回值类型是所有可调用对象返回类型的公共类型(遵循重载决议规则)。确保所有重载的返回类型都能隐式转换到某个公共类型,或者使用std::common_type_t。
5.4 与 C++20/23 新特性的结合展望
C++20/23 引入了更多特性,能与std::variant更好地协作:
概念(Concepts):可以编写约束,要求
variant的所有候选类型满足某些概念,使接口更安全。template <std::copyable... Ts> class MyContainer { std::variant<Ts...> data; // 确保所有类型可拷贝 };std::expected(C++23):这是一个专门为错误处理设计的工具,类似于之前提到的Result<T, E>模式,但提供了更丰富的接口(如and_then、transform等链式调用)。在需要专门的错误处理时,std::expected可能比手动的variant更合适。模式匹配(Pattern Matching,提案中):未来 C++ 可能引入原生的模式匹配语法,那将使得访问
variant的代码更加直观和简洁,类似于其他语言中的match表达式。
6. 实战:构建一个简单的 JSON 值类型
让我们用一个完整的例子来巩固:实现一个简化的JsonValue类型。JSON 值可以是null、布尔、数字(整数/浮点数)、字符串、数组或对象。这是一个典型的variant应用场景。
#include <variant> #include <string> #include <vector> #include <unordered_map> #include <iostream> struct JsonNull { // 显式表示 null,比 std::monostate 语义更清晰 bool operator==(const JsonNull&) const { return true; } }; class JsonValue { private: using Array = std::vector<JsonValue>; using Object = std::unordered_map<std::string, JsonValue>; using Value = std::variant<JsonNull, bool, int, double, std::string, Array, Object>; Value value_; public: // 构造器 JsonValue() : value_(JsonNull{}) {} JsonValue(JsonNull) : value_(JsonNull{}) {} JsonValue(bool b) : value_(b) {} JsonValue(int i) : value_(i) {} JsonValue(double d) : value_(d) {} JsonValue(const char* s) : value_(std::string(s)) {} JsonValue(const std::string& s) : value_(s) {} JsonValue(const Array& arr) : value_(arr) {} JsonValue(const Object& obj) : value_(obj) {} // 类型查询 bool is_null() const { return std::holds_alternative<JsonNull>(value_); } bool is_bool() const { return std::holds_alternative<bool>(value_); } bool is_int() const { return std::holds_alternative<int>(value_); } bool is_double() const { return std::holds_alternative<double>(value_); } bool is_string() const { return std::holds_alternative<std::string>(value_); } bool is_array() const { return std::holds_alternative<Array>(value_); } bool is_object() const { return std::holds_alternative<Object>(value_); } bool is_number() const { return is_int() || is_double(); } // 值获取(不安全,演示用) template<typename T> T get() const { return std::get<T>(value_); } // 安全的访问接口 template<typename Visitor> decltype(auto) visit(Visitor&& vis) const { return std::visit(std::forward<Visitor>(vis), value_); } // 简单的字符串表示(用于打印) std::string to_string() const { return std::visit(overloaded { [](JsonNull) -> std::string { return “null”; }, [](bool b) -> std::string { return b ? “true” : “false”; }, [](int i) -> std::string { return std::to_string(i); }, [](double d) -> std::string { return std::to_string(d); }, [](const std::string& s) -> std::string { return “\”” + s + “\””; }, [](const Array& arr) -> std::string { std::string result = “[”; for (size_t i = 0; i < arr.size(); ++i) { result += arr[i].to_string(); if (i != arr.size() - 1) result += “, ”; } result += “]”; return result; }, [](const Object& obj) -> std::string { std::string result = “{”; bool first = true; for (const auto& [k, v] : obj) { if (!first) result += “, ”; result += “\”” + k + “\”: ” + v.to_string(); first = false; } result += “}”; return result; } }, value_); } }; int main() { JsonValue v1 = 42; JsonValue v2 = “hello world”; JsonValue v3 = std::vector<JsonValue>{1, 2, 3}; JsonValue v4 = std::unordered_map<std::string, JsonValue>{ {“name”, “Alice”}, {“age”, 30}, {“scores”, std::vector<JsonValue>{85, 92, 78}} }; std::cout << v1.to_string() << ‘\n’; // 42 std::cout << v2.to_string() << ‘\n’; // “hello world” std::cout << v3.to_string() << ‘\n’; // [42, “hello world”, [1, 2, 3]] std::cout << v4.to_string() << ‘\n’; // {“name”: “Alice”, “age”: 30, “scores”: [85, 92, 78]} // 使用 visit 进行处理 v4.visit([](auto&& arg) { using T = std::decay_t<decltype(arg)>; if constexpr (std::is_same_v<T, JsonValue::Object>) { std::cout << “It‘s an object with ” << arg.size() << “ keys.\n”; } }); }这个例子展示了如何用std::variant作为核心存储,构建一个类型安全、易扩展的复合数据结构。to_string函数利用std::visit递归地处理嵌套结构,代码清晰且健壮。在实际的 JSON 库中,还需要处理数字的统一类型(如std::variant<int, double>还是double)、更高效的内存管理、解析和序列化等功能,但核心设计模式是相似的。
通过这个实战项目,你可以看到std::variant如何将复杂的、可能出错的类型判断和生命周期管理,转化为简洁、声明式的代码。它不仅仅是语法糖,更是一种提升代码安全性和表达力的强大工具。当你下次再遇到需要表示“多选一”数据的场景时,不妨先考虑一下std::variant,它很可能就是你要找的解决方案。