类型安全的联合体 -- std::variant

1. std::variant 概述

想象你有一个魔法盒子,这个盒子同时只能装一种东西,但这个东西可以是不同类型的,比如:今天装一个整数,明天装一个字符串,后天装一个浮点数。这个魔法盒子就是 std::variantstd::variant 是 C++17 引入的类型安全的联合体。与传统的 union 不同,它有以下特点:

  • 多选一:在定义时必须告诉它,它只能存哪些类型(比如 int, double, string)。不能存这三种类型以外的东西。
  • 类型安全:它永远知道自己当前存的是哪个类型。如果试图用错误的类型去取数据,编译器会在编译期帮我们检查,或者直接抛出异常,绝不会产生未定义行为。
  • 高效:大多数情况下,std::variant 的大小就是其所有成员中最大的那个大小(加上一点点开销),它通常存储在栈上,不需要像 std::any 那样动态分配内存。

std::variant类的头文件和基础声明如下:

1
2
3
#include <variant>
// 定义一个 variant,它可以存 int,或者 double,或者 std::string
std::variant<int, double, std::string> v;

注意std::variant 不允许是空的(默认情况下),它至少必须列出一个类型。如果你声明了它但没有赋值,默认会调用第一个类型的默认构造函数。所以上面 v 默认存的是一个 int 0

std::variant 类 API 在线查询

2. std::variant 操作

2.1 赋值与访问

我们可以像使用普通变量一样赋值。访问时,主要使用 index()(索引)和 get()(获取)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <variant>
#include <string>

int main()
{
// 定义:可以是 int, double 或 string
std::variant<int, double, std::string> v;

// 1. 默认状态:由于 int 是第一个类型,v 初始化为 0
std::cout << "Index: " << v.index() << ", Value: " << std::get<int>(v) << std::endl;

// 2. 赋值一个 double
v = 3.14;
std::cout << "Index: " << v.index() << ", Value: " << std::get<double>(v) << std::endl;

// 3. 赋值一个 string
v = "Hello C++";
// 隐式转换:const char* -> std::string
std::cout << "Index: " << v.index() << ", Value: " << std::get<std::string>(v) << std::endl;

return 0;
}

注意index() 返回当前激活类型在模板参数列表中的位置(从 0 开始)。

2.1.1 std::get 的危险与 std::get_if 的温柔

如果我们使用 std::get 去取一个当前不存在的类型,程序会直接抛出 std::bad_variant_access 异常。这对调试很有用,但在生产环境可能导致崩溃。更安全的做法是使用 std::get_if,它返回一个指针。如果类型不对,它返回 nullptr,而不会抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <variant>
#include <string>

int main()
{
std::variant<int, double> v = 42; // 现在存的是 int

// --- 危险示范 ---
// try {
// double d = std::get<double>(v); // 错误!v 里是 int,不是 double
// } catch (const std::bad_variant_access& e) {
// std::cout << "捕获异常: " << e.what() << std::endl;
// }

// --- 安全示范 ---
if (auto ptr = std::get_if<double>(&v))
{
std::cout << "获取到 double: " << *ptr << std::endl;
}
else
{
std::cout << "当前不是 double,指针为空" << std::endl;
}

if (auto ptr = std::get_if<int>(&v))
{
std::cout << "获取到 int: " << *ptr << std::endl;
}

return 0;
}

2.1.2 进阶访问 - std::visit

想象一下,如果我们想打印 variant 里的值,必须得写一堆 if-else 或者 switch这太麻烦了!如果 variant 定义了 10 种类型,你得写 10 个分支。

C++17 提供的std::visit 允许我们传入一个可调用对象(函数、Lambda),它会自动识别 variant 当前存的是什么类型,然后调用对应的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <variant>
#include <string>
#include <vector>

// 定义一个可调用对象
struct Printer
{
void operator()(int i) { std::cout << "Int: " << i << std::endl; }
void operator()(double d) { std::cout << "Double: " << d << std::endl; }
void operator()(const std::string& s) { std::cout << "String: " << s << std::endl; }
};

int main()
{
std::variant<int, double, std::string> v;

v = 100;
std::visit(Printer{}, v); // 自动调用 operator()(int)

v = 3.14;
std::visit(Printer{}, v); // 自动调用 operator()(double)

v = "Test";
std::visit(Printer{}, v); // 自动调用 operator()(string)

// --- 更强大的泛型 lambda (C++17) ---
// 如果不想手写三个重载,可以用泛型 lambda 自动推导
auto generic_printer = [](auto&& arg) {
std::cout << "Value: " << arg << ", Type size: " << sizeof(arg) << std::endl;
};

v = 42;
std::visit(generic_printer, v);

return 0;
}

使用 std::visit 甚至可以同时传入多个 variant进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <variant>
#include <typeinfo>

int main()
{
std::variant<int, double> v1 = 10;
std::variant<int, double> v2 = 2.0;

std::visit([](auto&& a, auto&& b) {
// 结果类型推导
auto result = a + b;

std::cout << a << " + " << b << " = " << result << std::endl;

// 打印结果类型名称 (输出 'd' 代表 double, 'i' 代表 int)
std::cout << "结果类型是: " << typeid(result).name() << std::endl;
}, v1, v2);

return 0;
}

虽然 v1int,但因为它加上了 double 类型的 v2,最终结果被提升为了 double

2.2 特殊情况与异常处理

前面说过 variant 不能为空。但如果你确实需要表示没有值的状态怎么办?(比如数据库查询结果为空)。C++17 提供了一个特殊的空类型 std::monostate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <variant>
#include <iostream>

int main()
{
// 第一个类型是 monostate,默认不再初始化为 0,而是空
std::variant<std::monostate, int, std::string> v;

if (std::holds_alternative<std::monostate>(v))
{
std::cout << "Variant 目前是空的" << std::endl;
}

v = 10;
if (std::holds_alternative<int>(v))
{
std::cout << "Variant 现在有一个整数" << std::endl;
}
return 0;
}

std::holds_alternative<variant> 头文件中的一个非常实用的辅助函数。它的作用是检查 std::variant 对象当前是否持有特定类型的值。函数原型为:

1
2
template <class T, class... Types>
constexpr bool holds_alternative(const std::variant<Types...>& v) noexcept;
  • 参数
    • 第一个模板参数 T:想要检查的类型(比如 intstring,甚至是 std::monostate)。
    • 参数 v:要检查的那个 variant 对象。
  • 返回值
    • true:如果 v 存储的值为 T 类型。
    • false:如果 v 存储的是其他类型。

std::holds_alternative 实际上是 index() 的一个更方便、更易读的封装版本。

方法 写法 优点 缺点
index() v.index() == 1 性能极高(直接查索引) 可读性差(需要记住 1 代表 int),如果类型顺序变了,代码容易挂。
holds_alternative holds_alternative<int>(v) 可读性极好(直接看代码就知道在查 int 类型),维护方便 编译器需要知道类型索引,可能会增加一点点编译时间(运行时性能通常没区别)。
  • 如果需要遍历所有可能的情况(比如 switch 语句),或者追求极致的编译期已知逻辑,用 index()
  • 如果只是想在运行前做判断(比如:如果存的是 string 我就去解析,如果是 int 我就去计算),强烈推荐使用 std::holds_alternative,代码更安全、更清晰。

说完了std::variant中的取值和类型判断,我们再来思考另外一个问题:如果你给 variant 赋值,但在构造新值的过程中抛出了异常(比如内存不足),variant 会发生什么呢?

C++17 保证如果赋值失败,旧值不会被破坏,variant 依然保持旧值有效,或者变成空(异常安全保证)。

3. std::variant 实践

下面是一个模拟简单计算器的综合示例,展示了 variant 的实际威力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include <iostream>
#include <variant>
#include <string>
#include <stdexcept>

// 定义计算错误的异常
struct DivideByZeroError {};

// 我们的结果可能是:一个数字,或者是除以零错误
using CalcResult = std::variant<double, DivideByZeroError>;

// 定义操作符类型
struct OpAdd {};
struct OpSub {};
struct OpMul {};
struct OpDiv {};

// 所有可能的操作
using Operator = std::variant<OpAdd, OpSub, OpMul, OpDiv>;

// 计算逻辑
CalcResult calculate(double a, Operator op, double b)
{
// 使用 std::visit 自动分发到不同的操作逻辑
return std::visit([a, b](auto&& op_tag) -> CalcResult {
using T = std::decay_t<decltype(op_tag)>;

if constexpr (std::is_same_v<T, OpAdd>)
{
return a + b;
}
else if constexpr (std::is_same_v<T, OpSub>)
{
return a - b;
}
else if constexpr (std::is_same_v<T, OpMul>)
{
return a * b;
}
else if constexpr (std::is_same_v<T, OpDiv>)
{
if (b == 0.0)
{
return DivideByZeroError{};
}
return a / b;
}
}, op);
}

int main()
{
double x = 10.0;
double y = 2.0;

// 尝试除法
Operator op = OpDiv{};
auto result = calculate(x, op, y);

// 使用 std::visit 处理结果(打印结果 或 错误信息)
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, double>)
{
std::cout << "计算结果是: " << arg << std::endl;
}
else if constexpr (std::is_same_v<T, DivideByZeroError>)
{
std::cout << "错误:除数不能为零!" << std::endl;
}
}, result);

// 尝试除以零
y = 0.0;
result = calculate(x, op, y);
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, double>)
{
std::cout << "计算结果是: " << arg << std::endl;
}
else if constexpr (std::is_same_v<T, DivideByZeroError>)
{
std::cout << "错误:除数不能为零!" << std::endl;
}
}, result);

return 0;
}

std::variant 是 C++17 引入的最重要的数据结构之一,它让类型安全的联合体成为现实。通过配合 std::visitif constexpr,我们可以写出既高效又优雅的代码,完全摆脱老旧的 switch-case 和不安全的 void*