C++ variant

在实际开发中,我们有时会遇到这样一种需求:同一个变量需要在几种已知的数据类型中 “多选一” 来保存数据,比如某个配置项既可能是整数,也可能是字符串。

以前遇到这种场景,我们可能会想到联合体(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 语言接口交互或严格的底层内存控制场景下才会用到。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号