21. C++17 std::variant:语法详解、与联合体/继承的对比、访问模式(std::visit)、实际应用场景

好,咱们今天聊聊 std::variant。这东西在 C++17 里一出现,我就觉得——嗯,早该有了。说白了,它就是一个类型安全的联合体。你想想看,C 语言里的 union 用了多少年?省内存是真省,但坑也是真多。我自己就踩过不少 union 的雷,后面慢慢聊。

21.1 为什么需要 std::variant?

先说说联合体的问题。C 风格的 union 允许你在同一块内存里存放不同类型的数据,但编译器不会帮你记住当前存的是哪个类型。你得自己维护一个判别标志,比如这样:

union Value {
    int i;
    double d;
    const char* s;
};

struct TaggedValue {
    enum { INT, DOUBLE, STRING } tag;
    Value val;
};

这种写法我见过太多次了。每次访问前都要检查 tag,一不小心就写错。更麻烦的是,如果 union 里放了有构造函数的类型(比如 std::string),那事情就复杂了——你得手动管理构造和析构。

那用继承和多态呢?也可以,但代价不小。每个对象都要有虚表指针,内存占用上去了。而且你得定义一堆派生类,写起来啰嗦。

std::variant 就是来解决这个问题的。它既保留了联合体的紧凑内存布局,又提供了类型安全。说白了,就是「既要又要还要」的典型代表。

21.2 std::variant 基本语法

先看个最简单的例子:

#include <variant>
#include <iostream>
#include <string>

int main() {
    std::variant<int, double, std::string> v;
    
    v = 42;                    // 存 int
    std::cout << std::get<int>(v) << '\n';
    
    v = 3.14;                  // 存 double
    std::cout << std::get<double>(v) << '\n';
    
    v = std::string("hello");  // 存 string
    std::cout << std::get<std::string>(v) << '\n';
    
    return 0;
}

这里要注意,std::variant 默认会用第一个类型的默认构造函数初始化。如果第一个类型没有默认构造函数,那就得显式初始化。我个人习惯是尽量把有默认构造的类型放前面,省事。

21.2.1 访问值的方式

访问 variant 里的值,主要有三种方式:

  1. std::get<T>:按类型获取。如果类型不匹配,抛 std::bad_variant_access 异常。
  2. std::get<index>:按索引获取。索引从 0 开始。
  3. std::get_if<T>:返回指针,失败返回 nullptr。

看代码:

std::variant<int, double, std::string> v = 42;

// 方式1:按类型
try {
    int val = std::get<int>(v);
    std::cout << val << '\n';
} catch (const std::bad_variant_access& e) {
    std::cerr << "类型不匹配: " << e.what() << '\n';
}

// 方式2:按索引
double val = std::get<1>(v);  // 如果 v 存的是 double

// 方式3:安全指针方式
if (auto* p = std::get_if<int>(&v)) {
    std::cout << "值是: " << *p << '\n';
}

我个人比较推荐 std::get_if,因为它不会抛异常,性能更好。你想想看,在性能敏感的代码里,异常的开销可不小。

21.3 与联合体和继承的对比

咱们来做个对比表格,一目了然:

特性 std::variant C union 继承/多态
类型安全 ✅ 是 ❌ 否 ✅ 是
内存占用 低(最大类型大小 + 判别标志) 最低(最大类型大小) 高(虚表指针 + 对齐)
支持非平凡类型 ✅ 是 ❌ C++11 起有限支持 ✅ 是
访问速度 快(O(1)) 最快 较慢(虚函数调用)
代码可维护性
扩展性 中(需修改 variant 类型) 高(新增派生类即可)

这里我想多说一句。我在项目中遇到过用 union 存 std::string 的情况,那叫一个惨。手动调用构造析构不说,稍不注意就内存泄漏。换成 std::variant 后,世界清净了。

21.4 std::visit:统一的访问模式

这才是 std::variant 的杀手锏。std::visit 让你用一个函数对象去处理 variant 里所有可能的类型。不用写一堆 if-else 或者 switch-case。

看个例子:

#include <variant>
#include <iostream>
#include <string>
#include <vector>

struct Visitor {
    void operator()(int i) const {
        std::cout << "整数: " << i << '\n';
    }
    void operator()(double d) const {
        std::cout << "浮点数: " << d << '\n';
    }
    void operator()(const std::string& s) const {
        std::cout << "字符串: " << s << '\n';
    }
};

int main() {
    std::vector<std::variant<int, double, std::string>> vec = {
        42, 3.14, std::string("hello"), 100, 2.71
    };
    
    for (const auto& v : vec) {
        std::visit(Visitor{}, v);
    }
    
    return 0;
}

输出:

整数: 42
浮点数: 3.14
字符串: hello
整数: 100
浮点数: 2.71

你看,代码干净多了。而且 std::visit 是编译期展开的,性能很好。编译器会根据 variant 的可能类型生成跳转表,不会比手写 switch 慢。

21.4.1 用 lambda 简化

如果不想定义函数对象,可以用 auto lambda 配合 if constexpr

std::visit([](auto&& arg) {
    using T = std::decay_t<decltype(arg)>;
    if constexpr (std::is_same_v<T, int>) {
        std::cout << "整数: " << arg << '\n';
    } else if constexpr (std::is_same_v<T, double>) {
        std::cout << "浮点数: " << arg << '\n';
    } else if constexpr (std::is_same_v<T, std::string>) {
        std::cout << "字符串: " << arg << '\n';
    }
}, v);

这种写法在 C++17 里很流行。我个人觉得,如果处理逻辑简单,用 lambda 更方便;如果逻辑复杂,还是定义专门的 visitor 类更清晰。

21.5 实际应用场景

说了这么多,到底什么时候用 std::variant?我总结几个常见场景:

21.5.1 解析器/解释器

写一个简单的表达式解析器,每个 token 可能是数字、运算符、变量名等。用 variant 存这些不同类型的值,再合适不过了。

using Token = std::variant<
    double,                    // 数字
    char,                      // 运算符 + - * /
    std::string                // 变量名
>;

std::vector<Token> tokens = {
    3.14, '+', std::string("x"), '*', 2.0
};

21.5.2 配置系统

配置文件里的值可能是整数、浮点数、字符串、布尔值。用 variant 统一表示:

using ConfigValue = std::variant<
    int, double, bool, std::string
>;

std::map<std::string, ConfigValue> config = {
    {"timeout", 30},
    {"rate", 0.85},
    {"enabled", true},
    {"name", std::string("server1")}
};

我曾经在一个项目里用 union 做配置系统,结果因为类型判断错误,导致一个布尔值被当成整数解析,排查了半天。换成 variant 后,这种问题再没出现过。

21.5.3 错误处理

函数返回多种类型的错误信息:

using Result = std::variant<
    int,                        // 成功,返回结果
    std::error_code,            // 系统错误
    std::string                 // 自定义错误信息
>;

Result process(int input) {
    if (input < 0) {
        return std::make_error_code(std::errc::invalid_argument);
    }
    if (input > 100) {
        return std::string("输入值过大");
    }
    return input * 2;
}

21.6 避坑指南

嗯,这里说几个我踩过的坑:

⚠️ 注意:
  • 重复类型问题:variant 里不能有重复类型,比如 std::variant<int, int> 编译不过。因为 std::get<int> 不知道该返回哪个。
  • 空 variantstd::variant 不能为空。如果确实需要「空」状态,可以用 std::monostate 作为占位类型。
  • 异常安全:赋值时如果类型变了,旧值会先析构再构造新值。如果构造抛异常,variant 会回退到 valueless_by_exception 状态。访问这种状态的 variant 会抛异常。

我曾经在代码里写了 std::variant<int, int, double>,编译报错时我还愣了半天。后来才反应过来——variant 是按类型区分的,不是按值。

21.7 知识结构图

下面这张图帮你理清本章的核心脉络:

std::variant 基本语法与访问方式 std::get<T> / std::get<index> std::get_if<T>(安全指针) 默认构造与赋值 与 union / 继承对比 类型安全 vs 内存效率 std::visit 访问模式 函数对象 / lambda 访问 实际应用场景 解析器 / 解释器 Token 配置系统多类型值 错误处理 / 多返回值 💡 核心优势:类型安全 + 紧凑内存 + 统一访问

21.8 小结

std::variant 是 C++17 里我最喜欢的特性之一。它解决了 C 风格 union 的类型安全问题,又比继承多态更轻量。配合 std::visit,代码写起来既安全又优雅。

如果你还在用 union 或者手写 tag dispatch,我建议你试试 std::variant。刚开始可能有点不习惯,但用顺手了就会发现——嗯,真香。

💡 小提示: 如果 variant 里的类型比较多(比如超过 10 个),编译时间可能会增加。这时候可以考虑用 std::any 或者重新设计数据结构。没有银弹,选最合适的。

公众号:蓝海资料掘金营,微信deep3321