在实际开发中,我们有时会遇到这样一种需求:同一个变量需要在几种已知的数据类型中 “多选一” 来保存数据,比如某个配置项既可能是整数,也可能是字符串。
以前遇到这种场景,我们可能会想到联合体(union)。但传统 union 并不知道自己当前存的是哪一种类型,直接存放 std::string 这类对象还很容易出错。因此,C++17 在 <variant> 头文件中引入了 std::variant,你可以直接把它看作 “自带类型记录的安全版 union”。
C++ variant 的使用
在 C++ 中,使用 std::variant 时需要先在尖括号 <> 里把 “允许存放的候选类型” 列出来。在程序运行过程中,一个 std::variant 对象同一时刻只会保存其中某一种类型的值。
语法:
#include <variant>
std::variant<类型1, 类型2, ...> 变量名;
std::variant<类型1, 类型2, ...> 变量名 = 初始值;说明:
如果我们在定义变量时没有赋初值,它会默认按尖括号里的 “第 1 个候选类型” 初始化,比如 std::variant<int, std::string> v; 默认保存整数 0。判断和读取数据时,最基础的配套工具有以下 3 个:
- std::holds_alternative<T>(v):其中 alternative 表示 “备选项”,用来判断对象
v当前保存的是不是候选类型T。如果是则返回true;否则返回false。 - std::get<T>(v):按照类型
T取出对象v里面当前保存的值。 - v.index():返回当前保存的类型在候选列表中的位置序号,从
0开始计数。
示例 1:存储与切换不同类型
#include <iostream>
#include <string>
#include <variant>
int main() {
std::variant<int, std::string> data = 100;
if (std::holds_alternative<int>(data)) {
int num = std::get<int>(data);
std::cout << "当前整数:" << num << '\n';
std::cout << "类型序号:" << data.index() << '\n';
}
data = "Hello";
if (std::holds_alternative<std::string>(data)) {
std::string text = std::get<std::string>(data);
std::cout << "当前文本:" << text << '\n';
std::cout << "类型序号:" << data.index() << '\n';
}
return 0;
}运行结果如下。
当前整数:100
类型序号:0
当前文本:Hello
类型序号:1分析:
在这个例子中,data 的候选列表是 <int, std::string>,其中 int 的序号为 0,std::string 的序号为 1。一开始 data 保存整数 100,std::holds_alternative<int>(data) 返回 true,因此调用 std::get<int>(data) 就能顺利取出 100。
后面我们执行 data = "Hello"; 时,内部保存的数据会自动切换为 std::string 字符串,此时 data.index() 返回的类型序号也变成了 1。
需要注意的是,小伙伴们调用 std::get<T>(data) 取值时,尖括号里的类型 T 必须和当前实际保存的类型一致。下面这种写法在运行阶段就会出错。
std::variant<int, std::string> data = "Hello";
std::cout << std::get<int>(data) << '\n'; // 类型不匹配上面这句代码当前保存的是字符串,却强行按 int 去读取。虽然它能通过编译,但在程序运行时会直接抛出 std::bad_variant_access 异常。
提示 std::variant 并不是 “什么类型都能随便装” 的动态容器。它允许存放哪些类型,必须在定义时就在尖括号里明确列出来。比如往 std::variant<int, double> 里赋字符串 "abc",编译阶段就会直接报错。
使用 std::get_if() 安全读取
如果每次读取前都要先写一遍 std::holds_alternative<T>(data),再写一遍 std::get<T>(data),不仅要把类型写两遍,一旦漏掉判断还会引发运行异常。
在实际开发中,我们更推荐使用 std::get_if<T>(&data)。为什么前面的 std::get() 直接传变量,而这里的 std::get_if() 却要加上取地址符 & 传变量地址呢?
其实道理很简单:C++ 里有空指针 nullptr,但没有 “空引用”。std::get_if() 通过接收变量地址并返回指针,就能把 “检查类型” 和 “读取数据” 合并成一步:
- 类型匹配时:返回指向内部数据的有效指针,我们通过
*指针名就能直接读取或修改数据。 - 类型不匹配时:不会抛出异常,而是直接返回空指针
nullptr。
语法:
目标类型* 指针名 = std::get_if<目标类型>(&variant对象);
if (指针名 != nullptr) {
// 访问数据
}说明:
只要判断返回的指针不等于 nullptr,我们就可以安全地使用 *指针名 访问里面的数据,不用担心程序抛出异常。
示例 2:使用 std::get_if() 取值
#include <iostream>
#include <string>
#include <variant>
int main() {
std::variant<int, std::string> config = 8080;
int* port = std::get_if<int>(&config);
if (port != nullptr) {
std::cout << "端口号:" << *port << '\n';
}
config = "localhost";
std::string* host = std::get_if<std::string>(&config);
if (host != nullptr) {
std::cout << "主机名:" << *host << '\n';
}
return 0;
}运行结果如下。
端口号:8080
主机名:localhost分析:
std::get_if<int>(&config) 这句代码表示传入 config 的内存地址,尝试按 int 类型获取内部数据的指针。当 config 保存整数 8080 时,返回的指针 port 不为空,进入 if 分支后通过 *port 就能读取或修改里面的整数。
后面把 config 改为字符串 "localhost" 后,如果我们再去调用 std::get_if<int>(&config),就会得到 nullptr 并自动跳过整数分支,安全又省心。
使用 std::visit() 访问数据
当一个 std::variant 包含 3 种及以上的候选类型时,写一连串的 if...else 逐个判断还是有些繁琐。这时我们可以使用 std::visit(),它会自动识别当前保存的是哪一种类型,并把里面的值传给我们提供的处理函数。
语法:
std::visit(可调用对象, variant对象);说明:
调用 std::visit() 时,最常见的做法是传入一个参数写为 auto 的泛型 Lambda 表达式,比如 [](const auto& item) { ... }。编译器会自动针对 variant 里的每一种候选类型生成对应的处理代码。一旦有某种候选类型无法处理,编译阶段就会直接报错提醒我们,不用担心漏掉分支。
示例 3:统一打印各类数据
#include <iostream>
#include <string>
#include <variant>
int main() {
std::variant<int, double, std::string> val = 88;
auto printItem = [](const auto& item) {
std::cout << "当前值:" << item << '\n';
};
std::visit(printItem, val);
val = 95.5;
std::visit(printItem, val);
val = "C++";
std::visit(printItem, val);
return 0;
}运行结果如下。
当前值:88
当前值:95.5
当前值:C++分析:
上面代码中,val 允许存放 int、double 和 std::string 这 3 种类型。因为这 3 种类型都支持直接使用 std::cout << 输出,所以我们每次调用 std::visit(printItem, val) 时,它都会自动取出 val 当前保存的值并打印出来。
如果我们在访问时需要对不同类型执行不同的操作,比如遇到整数就做加法、遇到字符串就打印提示,又应该怎么写呢?我们可以分 3 小步配合前面学过的 decltype 与 if constexpr 来实现:
- 先把 Lambda 的形参写成按值接收的
auto item,让auto自动去掉多余的const和引用限定。 - 在函数体内通过
using T = decltype(item);拿到参数item当前干净的类型名T。 - 使用
<type_traits>头文件中的std::is_same_v<T, int>判断T与int是否完全相同,再配合if constexpr按类型分别处理。
示例 4:按类型分别处理数据
#include <iostream>
#include <string>
#include <type_traits> // 提供 std::is_same_v
#include <variant>
int main() {
std::variant<int, std::string> data = 20;
auto handleData = [](auto item) {
using T = decltype(item);
if constexpr (std::is_same_v<T, int>) {
std::cout << "整数加 10:" << item + 10 << '\n';
} else {
std::cout << "字符串内容:" << item << '\n';
}
};
std::visit(handleData, data);
data = "Admin";
std::visit(handleData, data);
return 0;
}运行结果如下。
整数加 10:30
字符串内容:Admin分析:
当我们第 1 次调用 std::visit(handleData, data) 时,data 里存的是整数 20,编译器会将 T 推导为 int。此时 std::is_same_v<int, int> 在编译期为 true,程序只保留第 1 个分支并算出 30。
当我们把 data 改为 "Admin" 并第 2 次调用时,T 会推导为 std::string,编译器就会丢弃加法分支,只保留第 2 个分支打印字符串。
optional、variant 与 union 的区别
与前面介绍的 std::optional 放在一起对比,这三个工具都和 “变化的数据状态” 有关,小伙伴们在实际开发中可以按以下标准选型:
std::optional:表示 “有值或无值”。当结果只有一种确定的类型,只是有时可能查不到或未设置时,我们优先使用 std::optional。
std::variant:表示 “几种类型中选一种”。当一个数据明确可能是几种已知类型中的某一种时,我们使用 std::variant。
union:用于底层内存紧凑复用,本身不具备类型安全检查。在现代 C++ 日常业务开发中基本已被 std::variant 替代,仅在与 C 语言接口交互或严格的底层内存控制场景下才会用到。
