25、C++17 核心特性:std::variant,类型安全的联合体

说到联合体,老派的C程序员肯定不陌生。union能让我们在同一块内存里存放不同类型的数据,但代价是什么?你得自己记住当前存的是哪个类型。一旦记错了,读出来的数据就是一团乱码。我早年做嵌入式开发时,就因为这个bug排查了整整两天——嗯,从那以后我对union就多了几分敬畏。

C++17带来的std::variant,说白了就是一个“类型安全的union”。它帮你记住了当前存的是什么类型,还提供了安全访问的接口。你想想看,这不就是我们一直想要的吗?

基本用法:告别裸union

先看一个最简单的例子:

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

int main() {
    // 定义一个可以存int、double或std::string的variant
    std::variant<int, double, std::string> v;
    
    // 默认构造,第一个类型会被值初始化
    // 这里v持有int(0)
    std::cout << std::get<int>(v) << '\n';  // 输出 0
    
    // 赋值double
    v = 3.14;
    std::cout << std::get<double>(v) << '\n';  // 输出 3.14
    
    // 赋值string
    v = "hello";
    std::cout << std::get<std::string>(v) << '\n';  // 输出 hello
    
    return 0;
}

看到没?std::variant自动管理了当前存储的类型。你不用再像union那样,额外搞一个枚举来标记当前类型。我个人习惯用std::get<T>来取值,但要注意:如果当前存储的类型和请求的不匹配,它会抛出std::bad_variant_access异常。

⚠️ 避坑指南
我曾经在一个线上服务里用std::get取值,结果因为某个分支逻辑没覆盖到,程序直接崩溃了。后来我改用std::get_if,先检查再访问,稳多了。

安全访问:get_if 和 visit

为了避免异常,C++17提供了两种更安全的访问方式。

方式一:std::get_if

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

// 返回指针,如果类型不匹配则返回nullptr
if (auto* p = std::get_if<int>(&v)) {
    std::cout << "int: " << *p << '\n';
} else if (auto* p = std::get_if<double>(&v)) {
    std::cout << "double: " << *p << '\n';
} else if (auto* p = std::get_if<std::string>(&v)) {
    std::cout << "string: " << *p << '\n';
}

方式二:std::visit(推荐)

这才是真正的现代C++风格。用一个访问者模式,一次性处理所有可能的情况:

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

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

这里用了if constexpr,编译期就能确定分支,没有运行时开销。我建议你在项目里优先用std::visit,代码更整洁,也更容易扩展。

variant的底层原理

为了让你更直观地理解variant的工作方式,我画了一张图:

std::variant 内部结构示意图 底层存储:对齐的union内存块 int (4字节) double (8字节) string (32字节) 同一时刻只存储其中一个类型 discriminator (索引标记) 记录当前存储的是第几个类型(0: int, 1: double, 2: string) 访问方式:std::get<T> / std::get_if<T> / std::visit 内部检查 discriminator,确保类型安全

说白了,std::variant就是在union的基础上加了一个“类型标签”(discriminator)。每次赋值时,它自动更新这个标签;每次取值时,它先检查标签是否匹配。这就是“类型安全”的由来。

常用操作一览

操作 代码 说明
构造 std::variant<int, float> v{42}; 直接初始化
赋值 v = 3.14f; 自动切换类型
取值(异常) std::get<int>(v) 类型不匹配抛异常
取值(安全) std::get_if<int>(&v) 返回指针,失败返回nullptr
访问者 std::visit(visitor, v) 推荐方式,编译期多态
查询类型 v.index() 返回当前类型的索引
判空 v.valueless_by_exception() 是否因异常变为无值状态

实际应用场景

我在项目中用std::variant最多的场景是解析配置文件。比如一个JSON字段可能是数字、字符串或布尔值:

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

class ConfigParser {
public:
    ConfigValue get(const std::string& key) const {
        auto it = data_.find(key);
        if (it != data_.end()) {
            return it->second;
        }
        return std::string{};  // 默认返回空字符串
    }
    
private:
    std::unordered_map<std::string, ConfigValue> data_;
};

// 使用
ConfigParser parser;
auto val = parser.get("timeout");
std::visit([](auto&& arg) {
    using T = std::decay_t<decltype(arg)>;
    if constexpr (std::is_same_v<T, int64_t>) {
        std::cout << "timeout (int): " << arg << '\n';
    } else if constexpr (std::is_same_v<T, std::string>) {
        std::cout << "timeout (string): " << arg << '\n';
    }
    // 其他类型...
}, val);

另一个常见场景是状态机。用std::variant表示不同的状态,每个状态携带不同的数据:

struct IdleState {};
struct RunningState { int progress; };
struct ErrorState { std::string message; int code; };

using State = std::variant<IdleState, RunningState, ErrorState>;

void handleState(const State& s) {
    std::visit([](const auto& state) {
        using T = std::decay_t<decltype(state)>;
        if constexpr (std::is_same_v<T, IdleState>) {
            std::cout << "系统空闲\n";
        } else if constexpr (std::is_same_v<T, RunningState>) {
            std::cout << "运行中,进度: " << state.progress << "%\n";
        } else if constexpr (std::is_same_v<T, ErrorState>) {
            std::cout << "错误: " << state.message 
                      << " (代码: " << state.code << ")\n";
        }
    }, s);
}
💡 小技巧
如果你需要频繁地判断variant的当前类型,可以用v.index()配合switch语句。但说实话,std::visit更优雅,而且编译器会帮你检查是否覆盖了所有类型。

性能与注意事项

关于性能,我直接说结论:

  • 内存占用std::variant的大小等于最大类型的大小加上对齐开销,再加上一个整数索引(通常1-2字节)。比union多了一点点,但换来的是类型安全,值了。
  • 运行时开销:赋值和访问时会有一次类型检查,但这是O(1)的。相比你手动维护union+枚举的方案,性能几乎一样。
  • 异常安全:如果赋值过程中抛出异常,variant会进入valueless_by_exception()状态。这时候访问它会抛异常。我建议你在关键路径上检查这个状态。
⚠️ 避坑指南
我曾经在代码里用std::variant<std::string, std::vector<int>>,结果发现每次赋值都会触发拷贝构造。后来改成std::variant<std::unique_ptr<std::string>, std::unique_ptr<std::vector<int>>>,移动语义就生效了。记住:variant内部会存储完整的对象,大对象要考虑用智能指针包装。

最后说一句:std::variant不是万能的。如果你的类型列表很长(超过10个),或者类型之间差异巨大,考虑用继承+多态可能更合适。但大多数场景下,variant是比union更现代、更安全的选择。


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