variant:C++17 带来的类型安全联合体

说实话,我第一次看到 std::variant 的时候,心里想的是:「这不就是个升级版的 union 吗?」后来用了一段时间才发现,这玩意儿远比我想象的强大。它解决了 C++ 里一个长期存在的痛点——你想让一个变量在不同时刻存储不同类型的值,但又不想自己手动管理类型标记和内存。

今天我们就来聊聊 variant 的概念、用法,以及它跟多态到底有什么关系。

variant 是什么?

简单说,std::variant 是一个类型安全的联合体。它可以在同一时刻存储一组固定类型中的某一个值。跟传统的 union 相比,variant 会记住当前存储的是哪个类型,而且不会让你误读。

我记得有一次维护老代码,看到这样的 union:

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

然后代码里到处都是 if (type == 1)if (type == 2) 这样的判断。一旦某个地方忘记检查类型,或者类型标记被意外修改,bug 就来了。variant 就是来治这个病的。

核心特点:

  • 同一时刻只存储一个值
  • 自动管理类型标记
  • 访问时进行类型检查
  • 不会发生未定义行为(不像 union)

variant 的基本用法

先看一个最简单的例子:

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

int main() {
    // 定义一个可以存储 int、double 或 std::string 的 variant
    std::variant<int, double, std::string> v;
    
    // 默认构造:存储第一个类型的默认值(int 的 0)
    std::cout << std::get<int>(v) << std::endl;  // 输出 0
    
    // 赋值为 double
    v = 3.14;
    std::cout << std::get<double>(v) << std::endl;  // 输出 3.14
    
    // 赋值为 string
    v = "hello";
    std::cout << std::get<std::string>(v) << std::endl;  // 输出 hello
    
    return 0;
}

嗯,这里要注意:std::get<T> 如果访问的类型跟当前存储的类型不匹配,会抛出 std::bad_variant_access 异常。所以别乱用。

安全访问:std::get_if 和 std::visit

我个人习惯用 std::get_if 来安全地尝试访问:

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

// 安全访问:如果类型匹配,返回指针;否则返回 nullptr
if (auto* p = std::get_if<int>(&v)) {
    std::cout << "整数: " << *p << std::endl;
} else if (auto* p = std::get_if<double>(&v)) {
    std::cout << "浮点数: " << *p << std::endl;
} else if (auto* p = std::get_if<std::string>(&v)) {
    std::cout << "字符串: " << *p << std::endl;
}

但说实话,这种 if-else 链写多了也挺烦的。更优雅的方式是用 std::visit

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

std::variant<int, double, std::string> v = 3.14;
std::visit(Visitor{}, v);  // 输出:浮点数: 3.14

用 lambda 也可以,更简洁:

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

这种写法在编译期就展开了所有分支,性能很好。我在项目中经常用这种模式来处理不同类型的消息。

variant 与多态:两种不同的思路

很多人会问:「variant 跟多态有什么区别?不都是让一个东西表现出不同的行为吗?」

这个问题问得好。我来画个图帮你理清思路:

variant vs 多态:两种多值处理方案 std::variant 编译期确定类型集合 std::variant<int, double, string> 值语义,栈上存储 无动态内存分配 std::visit 编译期分发 无虚函数开销 虚函数多态 运行期动态扩展 通过继承添加新类型 引用/指针语义,堆上存储 需要动态内存分配 虚表(vtable)运行期分发 有间接调用开销 适用场景:类型集合固定、性能敏感 → variant | 类型可扩展、需要运行时多态 → 虚函数

说白了,variant 是编译期多态,虚函数是运行期多态。两者各有各的用武之地。

什么时候用 variant?

我在项目中遇到过这样一个场景:需要解析多种类型的配置文件——整数、浮点数、布尔值、字符串数组。这些类型是固定的,不会变。用 variant 就很合适:

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

std::map<std::string, ConfigValue> config;

config["timeout"] = 30;
config["rate"] = 0.85;
config["enabled"] = true;
config["hosts"] = std::vector<std::string>{"server1", "server2"};

// 读取时安全访问
auto it = config.find("timeout");
if (it != config.end()) {
    if (auto* val = std::get_if<int>(&it->second)) {
        set_timeout(*val);
    }
}

如果用虚函数来做,你得定义一个基类、一堆子类、还要用指针管理生命周期。太麻烦了。

什么时候用虚函数多态?

反过来,如果你的类型集合是开放的——比如插件系统,用户可以在运行时加载新的类型——那 variant 就无能为力了。因为 variant 的类型列表在编译期就固定了。

我曾经写过一个图形渲染引擎,不同的形状(圆形、矩形、三角形)需要各自实现自己的 draw() 方法。而且用户可能会自定义新的形状。这种情况只能用虚函数:

class Shape {
public:
    virtual void draw() const = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    void draw() const override { /* 画圆 */ }
};

class Rectangle : public Shape {
public:
    void draw() const override { /* 画矩形 */ }
};

variant 的避坑指南

我曾经踩过的坑:

  • 默认构造问题:variant 默认构造会初始化第一个类型。如果第一个类型没有默认构造函数,编译会报错。可以用 std::monostate 作为占位类型。
  • 赋值歧义v = 0 可能匹配多个类型(int、double、甚至 bool)。建议显式转换:v = static_cast<int>(0)
  • 异常安全:variant 在赋值时如果发生异常,会回滚到之前的状态(如果可能),否则会变成 valueless_by_exception 状态。访问这种状态会抛异常。

小技巧:

std::monostate 作为 variant 的第一个类型,可以模拟「空状态」:

std::variant<std::monostate, int, std::string> v;
// 默认是 monostate,表示「无值」
if (std::holds_alternative<std::monostate>(v)) {
    std::cout << "当前无值" << std::endl;
}

性能对比

特性 std::variant 虚函数多态
类型集合 编译期固定 运行期可扩展
内存分配 栈上(无动态分配) 通常需要堆分配
调用开销 直接调用(可内联) 间接调用(虚表)
异常安全 有 valueless 状态风险 取决于实现
代码复杂度 较低(值语义) 较高(需管理生命周期)

你想想看,如果你的类型集合是固定的,而且对性能有要求,variant 几乎是零开销的抽象。虚函数虽然灵活,但每次调用都要走一遍虚表,而且编译器很难内联。

好了,关于 variant 的内容就聊到这里。记住一句话:类型固定用 variant,类型可变用多态。选对了工具,代码写起来顺手,跑起来也快。


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