26. std::variant 与 std::any:类型安全联合体,类型擦除的模板实现

好,咱们今天聊两个非常实用的工具:std::variantstd::any

说实话,刚接触 C++17 那会儿,我第一反应是:这不就是升级版的 union 和 void* 吗?用了一段时间才发现,事情没那么简单。它们背后藏着两种完全不同的设计哲学——一个追求类型安全,一个追求类型擦除。

我个人习惯把 variant 叫做「带标签的联合体」,把 any 叫做「万能容器」。你想想看,它们解决的问题其实很相似:让一个变量能存多种类型。但实现思路和适用场景,差别还挺大的。

26.1 std::variant:编译期就知道有哪些类型

std::variant 说白了就是一个类型安全的 union。它会在编译期就确定好自己能存哪几种类型,然后通过一个索引来标记当前存的是哪一个。

核心特点:

  • 所有可能的类型在编译期就确定
  • 运行时通过索引判断当前值类型
  • 不会发生隐式类型转换,安全得多
  • 支持访问者模式(std::visit)

来看个最简单的例子:

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

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

v = 42;                    // 存 int
v = 3.14;                  // 存 double
v = std::string("hello");  // 存 string

// 怎么读出来?
if (std::holds_alternative<int>(v)) {
    std::cout << "int: " << std::get<int>(v) << "\n";
} else if (std::holds_alternative<double>(v)) {
    std::cout << "double: " << std::get<double>(v) << "\n";
} else if (std::holds_alternative<std::string>(v)) {
    std::cout << "string: " << std::get<std::string>(v) << "\n";
}

嗯,这里要注意:std::get<T> 如果类型不对,会抛 std::bad_variant_access 异常。我在项目中遇到过几次这种崩溃,后来就养成了习惯——访问之前先用 holds_alternative 检查一下。

26.2 std::visit:优雅地处理 variant

每次都用 if-else 判断类型,说实话有点丑。C++17 提供了 std::visit,配合 lambda 表达式,写起来就优雅多了。

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,编译期就能确定走哪个分支,不会有运行时开销。我个人特别喜欢这种写法——既安全又高效。

小技巧:如果你有多个 variant 需要同时处理,std::visit 也支持多个参数:

std::visit([](auto a, auto b) {
    // 处理 a 和 b 的所有组合
}, v1, v2);

26.3 std::any:运行时才知道类型

variant 不同,std::any 可以在运行时存储任意类型。它背后用的是类型擦除技术——说白了就是用一个模板基类指针,加上一个类型信息记录。

#include <any>

std::any a;

a = 42;                    // 存 int
a = 3.14;                  // 存 double
a = std::string("hello");  // 存 string

// 读出来需要用 any_cast
try {
    std::cout << std::any_cast<std::string>(a) << "\n";
} catch (const std::bad_any_cast& e) {
    std::cerr << "类型不匹配: " << e.what() << "\n";
}

注意:std::any_cast<T> 如果类型不匹配,会抛异常。如果你不想用异常,可以用指针版本:

if (auto p = std::any_cast<std::string>(&a)) {
    std::cout << *p << "\n";
} else {
    std::cout << "类型不对\n";
}

26.4 类型擦除是怎么实现的?

我曾经花了一个周末去读 std::any 的源码实现。说白了,核心思路就是:

  1. 定义一个非模板基类 Handler,提供虚函数接口
  2. 用模板派生类 HandlerImpl<T> 存储具体类型
  3. 通过基类指针操作,实现类型擦除

简化版实现大概长这样:

class Any {
    struct Handler {
        virtual ~Handler() = default;
        virtual const std::type_info& type() const = 0;
        virtual Handler* clone() const = 0;
    };

    template<typename T>
    struct HandlerImpl : Handler {
        T value;
        HandlerImpl(T v) : value(std::move(v)) {}
        const std::type_info& type() const override {
            return typeid(T);
        }
        Handler* clone() const override {
            return new HandlerImpl(value);
        }
    };

    Handler* ptr_ = nullptr;

public:
    template<typename T>
    Any(T value) : ptr_(new HandlerImpl<T>(std::move(value))) {}

    ~Any() { delete ptr_; }

    const std::type_info& type() const {
        return ptr_ ? ptr_->type() : typeid(void);
    }
};

嗯,这里要注意:真正的 std::any 实现会做小对象优化(SBO),对于小型类型直接在栈上存储,避免堆分配。我建议你在实际项目中,如果类型比较小,可以考虑用 variant 替代 any,性能会好很多。

26.5 什么时候用 variant,什么时候用 any?

这个问题我经常被问到。我的建议很简单:

场景 推荐 原因
类型集合固定且已知 std::variant 编译期检查,性能好,无堆分配
类型在运行时才能确定 std::any 灵活性高,但注意性能开销
需要频繁访问值 std::variant std::visitany_cast 快得多
需要存储用户自定义类型 两者都行 variant 需要提前注册,any 更灵活

避坑指南:我曾经在一个配置解析模块里用了 std::any 来存储所有配置项的值。结果发现性能瓶颈全在 any_cast 上——每次读取都要做类型检查和转换。后来改成 std::variant<int, double, std::string, bool>,性能提升了 3 倍多。

26.6 知识体系结构图

下面这张图帮你理清 variantany 的核心区别与实现思路:

std::variant vs std::any 核心对比 std::variant ✅ 编译期确定类型集合 ✅ 类型安全,无隐式转换 ✅ 支持 std::visit 访问者模式 ✅ 无堆分配(小对象优化) ❌ 类型集合固定,无法扩展 实现原理 带标签的联合体 编译期生成所有分支代码 std::any ✅ 运行时任意类型 ✅ 灵活性极高 ✅ 可存储任何可拷贝类型 ❌ 运行时类型检查开销 ❌ 可能触发堆分配 实现原理 类型擦除(虚函数 + 模板) 基类指针 + 类型信息记录 选择建议:类型固定用 variant,类型动态用 any,性能敏感优先 variant

26.7 实际项目中的选择策略

最后,我分享一点实际经验。在项目中做技术选型时,我通常会问自己三个问题:

  1. 类型集合会不会变?——如果不会变,用 variant;如果会变,用 any
  2. 性能要求高不高?——高的话用 variantstd::visit 的编译期分发比 any_cast 的运行时检查快很多。
  3. 代码可读性重要吗?——variant + std::visit 的代码更清晰,any 用多了容易变成「类型黑洞」。

个人习惯:我一般优先用 variant,只有在确实需要运行时动态类型时才退而求其次用 any。比如在插件系统、脚本引擎、配置解析这些场景下,any 确实无可替代。

好了,关于 std::variantstd::any 就聊这么多。记住一句话:能用 variant 解决的问题,别用 any。这样你的代码既安全又高效,维护起来也省心。


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