在 C++ 中,我们经常需要“用一个变量持有多种可能的类型”。C 风格的 unionvoid* 是最早的答案,但它们既不安全(不记录当前存的是哪种类型),也缺乏面向对象的扩展能力。C++17 引入了 std::anystd::variant,从两个不同方向填补了这块空白:

  • std::any类型擦除(type erasure)——持有一个任意类型的值,但“忘记”了它的静态类型,取回时必须显式说明。
  • std::variant类型安全的联合体(type-safe union)——持有一个来自固定类型集合的值,并在运行时记录当前是哪一种。

二者看似都在“装不同类型的东西”,但设计哲学截然不同:any 用灵活性换取了运行时开销和类型不透明,variant 用类型集的封闭性换取了编译期类型安全和零开销访问。本文将分别讲解它们的接口、底层原理与注意事项,最后给出选型建议与实战场景。

💡 std::any 依赖 RTTI(typeid),如果你对背后的类型擦除机制感兴趣,可参考 C++ 类型擦除C++ RTTI

std::any

定义与动机

std::any(C++17,<any>)是一个只能持有可拷贝构造类型的、类型擦除的容器。它通过“擦除”被存储值的静态类型,使得同一个 std::any 对象在不同时刻可以持有完全不同的类型:

1
2
3
std::any a = 42;          // 持有 int
a = std::string("hi"); // 现在持有 std::string
a = 3.14; // 现在持有 double

它的动机是:当你无法在编译期穷举所有可能的类型时,仍能在一个统一接口下传递值。典型场景包括配置系统、插件接口、事件总线上携带任意负载等——这些场景里,类型的集合是开放的,甚至跨越编译边界。

注意一个硬性约束:std::any 要求被存储类型满足 CopyConstructible。因此 std::unique_ptr 这类不可拷贝的类型无法直接存入 std::any

基本接口

操作说明
std::any a;默认构造,状态(has_value() == false
std::any a = x;x 擦除类型构造,存储 std::decay_t<decltype(x)>
std::make_any<T>(args...)原地构造一个 T,等价于 std::any{std::in_place_type<T>, args...}
a.emplace<T>(args...)销毁当前值并原地构造 T
a.reset()销毁当前值,回到空状态
a.has_value()是否持有值
a.type()返回当前值的 const std::type_info&,空时为 typeid(void)
a.swap(b)交换两个 any

type() 配合 typeid 是不抛异常的类型探测手段:

1
2
3
4
std::any a = 42;
if (a.type() == typeid(int)) {
std::cout << "it's int\n"; // 命中
}

std::any_cast 的三种形态

std::any 取回值只能通过 std::any_cast<T>,它有三种重载,失败行为各不相同,这是掌握 std::any 的关键:

1
2
3
4
5
6
7
8
9
10
std::any a = 42;

// 1) 按值/引用提取:类型不匹配抛 std::bad_any_cast
int i = std::any_cast<int>(a); // OK
const int& ri = std::any_cast<const int&>(a); // 按引用,不拷贝
// std::any_cast<double>(a); // 抛 bad_any_cast

// 2) 指针版本:传入 any 的地址,类型不匹配返回 nullptr(不抛异常)
int* p = std::any_cast<int>(&a); // p 指向内部值
double* q = std::any_cast<double>(&a); // q == nullptr

要点:

  • 值/引用版本:要求类型精确匹配(忽略顶层 const),否则抛 std::bad_any_cast(继承自 std::bad_cast)。引用版本可避免大对象拷贝。
  • 指针版本:传入 any*/const any*,返回 T*/const T*,类型不匹配返回 nullptr,是“查询式”访问的首选。
  • T 会被 std::decayany_cast<int>any_cast<const int> 等价,但 any_cast<int&>(引用版本)取回的是引用。

一个常见误用:std::any_cast 既不像 dynamic_cast 那样走继承链,也不做任何隐式转换。存的是 int,就只能用 int 取,用 long 也会失败——这正是它“类型安全”的体现。

类型擦除的实现原理

std::any 之所以能装任意类型,靠的是类型擦除:用一个统一的句柄类型隐藏具体 T,对外只暴露 type() 和拷贝能力。其经典实现是一个多态的占位基类 + 模板派生类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 简化示意,展示原理
class any {
struct placeholder {
virtual ~placeholder() = default;
virtual const std::type_info& type() const noexcept = 0;
virtual std::unique_ptr<placeholder> clone() const = 0;
};
template <class T>
struct holder : placeholder {
T value;
const std::type_info& type() const noexcept override { return typeid(T); }
std::unique_ptr<placeholder> clone() const override {
return std::make_unique<holder>(value);
}
};
std::unique_ptr<placeholder> content_; // 实际还叠加 SBO
public:
template <class T>
any(T&& v) : content_(std::make_unique<holder<std::decay_t<T>>>(std::forward<T>(v))) {}

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

any_cast 的指针版本则是先比较 type_info,匹配后把 placeholder* 向下转为 holder<T>* 取值:

1
2
3
4
5
6
7
template <class T>
T* any_cast(const any* a) noexcept {
if (a && a->type() == typeid(T)) {
return std::addressof(static_cast<typename any::holder<T>*>(a->content_.get())->value);
}
return nullptr;
}

由此可以理解几个特性:

  • type() 依赖 RTTI,所以关掉 RTTI(-fno-rtti)的工程里 std::any 通常不可用。
  • 拷贝 std::any 需要拷贝被存对象,因此才要求 CopyConstructible
  • 类型擦除带来一次间接寻址与堆分配开销,这是 anyvariant 慢的根源。

小对象优化

上面示意把 holder 一律放在堆上。真实实现普遍做了小对象优化(SBO / Small Buffer Optimization):在 std::any 对象内部预留一块固定大小的缓冲区,当被存对象足够小且 noexcept 可移动时,直接内联存储,避免堆分配;否则退化为堆上的 holder

1
2
std::any a = 42;            // int 很小,大概率内联存储,无堆分配
std::any b = std::string(10'000, 'x'); // 大 string,落在堆上

SBO 的具体阈值、对齐策略由实现定义(libstdc++/libc++/MSSTL 各不相同),所以 sizeof(std::any) 在不同标准库下并不一致。这意味着:

  • 不要sizeof(std::any) 做任何假设。
  • 存小对象时 any 的开销其实不大(一次构造 + 内联缓冲),存大对象才有堆分配与拷贝成本。

下图对比 std::any 在两种典型场景下的内存布局:小对象(int)直接内联存储在 std::any 内部缓冲区,大对象(std::string)则通过指针指向堆上分配的 holder

flowchart TB
    classDef any fill:#e1f5fe,stroke:#0288d1,color:#01579b
    classDef buffer fill:#fff8e1,stroke:#f9a825,color:#f57f17
    classDef heap fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c
    classDef ptr fill:#eceff1,stroke:#90a4ae,color:#455a64
    classDef value fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

    subgraph "小对象(SBO)"
        A["std::any\n(栈上)"]:::any
        A -->|"内部缓冲区"| B["int: 42\n(内联存储)"]:::buffer
    end

    subgraph "大对象(堆分配)"
        C["std::any\n(栈上)"]:::any
        C -->|"ptr"| D["holder<T>\n(堆上)"]:::heap
        D -->|"value"| E["std::string\n'hello world'\n(堆上)"]:::value
    end

    style A fill:#e1f5fe,stroke:#0288d1
    style C fill:#e1f5fe,stroke:#0288d1

注意事项

  • 性能:类型擦除带来堆分配(大对象时)、RTTI 查询和间接寻址。若类型集可在编译期穷举,std::variant 几乎总是更高效。
  • 必须精确匹配:存 intlong 取会失败,没有任何隐式转换。
  • 不可存不可拷贝类型std::unique_ptr、含 std::promise 的对象等无法存入。需要存只移动类型时,可包一层 std::shared_ptr 或换用 std::variant
  • 依赖 RTTI:禁用 RTTI 的环境需谨慎。
  • 生命周期any_cast<T&> 取出的引用,其有效性跟随 std::any 本身——any 析构或 emplace/reset/重新赋值后引用即失效。

std::variant

定义与动机

std::variant(C++17,<variant>)是一个类型安全的联合体:它持有来自一个固定、封闭类型集合中的一个值,并在运行时记录当前活跃成员的下标。它本质上是带标签的联合体(tagged union):

1
2
3
4
std::variant<int, double, std::string> v;
v = 42; // index() == 0,持有 int
v = 3.14; // index() == 1,持有 double
v = "hello"s; // index() == 2,持有 std::string

union 相比,std::variant

  • 自带标签:始终知道当前存的是哪个类型,无需用户手工维护判别标志。
  • 析构安全:自动析构当前活跃成员,不会忘记调用非平凡析构函数。
  • 类型安全访问:通过 std::get / std::visit 取值,编译期与运行时双重保证。

std::any 相比,variant 的类型集是封闭的(编译期固定),换来了:

  • 无 RTTI 依赖、无堆分配(值直接内联)、无类型擦除间接调用——零开销抽象
  • 编译期可知所有可能类型,从而能用 std::visit 做穷尽式(exhaustive)分发。

一个约束:variant 的候选类型不能是引用、不能是数组、不能是(不完全确定的)void;也不能重复出现相同类型(C++17 起允许,但访问时只能用下标,不能用类型名消歧)。

基本接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::variant<int, double, std::string> v;

// 构造与赋值:值会被分配到“最佳匹配”的候选类型
v = 42; // int
v = 2.71; // double
v = std::string("hi"); // string
v.emplace<2>("world"); // 原地按下标 2 构造 string

// 查询
v.index(); // 当前活跃成员下标(0/1/2),或 variant_npos
std::holds_alternative<std::string>(v); // 是否持有某类型

// 元信息(编译期)
std::variant_size_v<decltype(v)>; // 3
std::variant_alternative_t<1, decltype(v)>; // double

注意“最佳匹配”的赋值规则:v = 42 时,候选 int/double/string 中,int 是精确匹配(无转换),double 需转换,string 需用户定义转换。若多个候选同等匹配(如 variant<int, long>short,二者都需转换),则重载决议歧义,编译失败——这时需用 v.emplace<int>(...) 显式指定。

类型安全的访问

取值有两套 API,与 any_cast 高度对称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::variant<int, double, std::string> v = "hi"s;

// 1) 抛异常版本:失败抛 std::bad_variant_access
std::string& s = std::get<std::string>(v); // OK
// int& i = std::get<int>(v); // 抛 bad_variant_access
std::get<2>(v); // 按下标取

// 2) 指针版本:失败返回 nullptr(不抛异常)
if (auto* p = std::get_if<std::string>(&v)) {
std::cout << *p << '\n';
}
if (auto* p = std::get_if<int>(&v)) { // p == nullptr
/* ... */
}

std::get<T> 要求 T 在候选中唯一,否则编译失败;有重复类型时改用下标 std::get<I>

访问者模式与 std::visit

std::visitstd::variant 的灵魂:它根据当前活跃成员,把对应的值传给一个可调用对象。配合 C++17 的 overloaded 模式,可以写出穷尽式的、编译期检查的“模式匹配”:

1
2
3
4
5
6
7
8
9
10
11
12
13
// C++17 经典的 overload 辅助类
template <class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

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

std::visit(overloaded{
[](int i) { std::cout << "int: " << i << '\n'; },
[](double d) { std::cout << "double: " << d << '\n'; },
[](const std::string& s) { std::cout << "string: " << s << '\n'; }
}, v);

overloaded 通过多重继承把多个 lambda 的 operator() 聚合到一个类型,using Ts::operator()... 把它们引入作用域。当 variant 的候选类型都有对应的调用重载时,visit 会分派正确;如果漏掉某个类型,编译期即报错——这是“穷尽性”的编译期保证。

std::visit 还能返回值,其返回类型由所有分支的公共类型推导而来:

1
2
3
4
5
auto len = std::visit(overloaded{
[](int) { return 0; },
[](double) { return 0; },
[](const std::string& s) { return static_cast<int>(s.size()); }
}, v); // len 类型为 int

⚠️ 若 variant 处于 valueless 状态(见下文),std::visit 会抛 std::bad_variant_access

never-empty 保证与 valueless_by_exception

std::variant 提供了 never-empty 保证:一个构造完成的 variant 几乎总是持有某个候选值。这与 union(可能处于“没存任何东西”的状态)形成鲜明对比。

具体而言:

  • 默认构造variant<Ts...> 默认构造为第一个候选类型 T0 的默认值(要求 T0 可默认构造)。所以 variant<int, std::string> v; 一定持有一个 int{0}
  • 拷贝/移动:直接拷贝/移动源 variant 的活跃值,目标始终有值。

唯一例外是 valueless_by_exception 状态:当给 variant 赋一个不同类型的值,而该值的构造抛出异常时,variant 可能进入“无值”状态。此时 index() 返回 variant_npos(通常为 size_t(-1)),valueless_by_exception() 返回 true

标准库实现会尽力避免这一状态(例如先用临时对象构造好再切换,借助 noexcept 移动构造实现强异常安全),因此实践中极罕见,但代码不能假装它不存在:

1
2
3
4
5
6
7
try {
v = ThrowingType{}; // 若构造抛异常,v 可能进入 valueless
} catch (...) {
if (v.valueless_by_exception()) {
// 需要处理:可 v.emplace<0>() 恢复
}
}

异常安全

std::variant 的赋值异常安全分两类:

  1. 同类型赋值(新值与当前活跃成员同类型):直接对活跃成员赋值,异常行为由该类型的 operator= 决定。若抛异常,variant 仍持有该成员(值可能已部分改变),不会变空。
  2. 异类型赋值:标准库根据新类型是否 noexcept 可构造选择策略,尽量做到强异常保证——要么成功切换到新值,要么保持原值不变。只有在无法提供强保证时(新类型不可 noexcept 构造且不满足退路条件)才可能进入 valueless_by_exception

推论:把 noexcept 移动构造的类型放进 variant,能获得更好的异常安全保证。这也解释了为何“可 noexcept 移动”的类型在 variant/容器里更受优待。

下图展示 std::variant<int, double, std::string> 的内存布局:所有候选类型共享同一块对齐到最大成员的联合体内存,外加一个记录当前活跃成员下标的标签字节。无论当前持有哪种类型,内存占用始终固定,无堆分配。

flowchart TB
    classDef variant fill:#e1f5fe,stroke:#0288d1,color:#01579b
    classDef union fill:#fff8e1,stroke:#f9a825,color:#f57f17
    classDef tag fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c
    classDef active fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
    classDef inactive fill:#eceff1,stroke:#90a4ae,color:#455a64

    subgraph "std::variant<int, double, std::string>"
        V["variant\n(栈上)"]:::variant
        V --> U["联合体内存\n(max(sizeof(int), sizeof(double),\nsizeof(string)) + 对齐)"]:::union
        V --> T["标签\nindex = 2"]:::tag

        U -->|"index=0"| I0["int: 42\n(当前活跃)"]:::active
        U -->|"index=1"| I1["double: 3.14\n(未激活)"]:::inactive
        U -->|"index=2"| I2["string: 'hi'\n(未激活)"]:::inactive
    end

    style V fill:#e1f5fe,stroke:#0288d1

注意事项

  • 候选类型应可默认构造或注意默认构造语义:默认构造用的是 T0,把最“便宜且不出错”的类型放第一个是好习惯。
  • 避免重复类型:重复类型让 get<T> 不可用,徒增心智负担。
  • 不可存引用variant<int&> 在 C++17 不合法(C++26 有望引入 std::variant 存引用的相关提案,但当前不行)。需要引用语义可用 std::reference_wrapper
  • visit 的穷尽性:漏处理某个候选类型会在编译期报错,这是优点,但重构增删候选时要同步更新访问者。
  • sizeofvariantsizeof 约等于最大候选类型大小 + 标签 + 对齐填充,所有候选都内联存储,无堆分配。

std::any 与 std::variant 的对比

特性std::anystd::variant<Ts...>
类型集合开放,任意可拷贝类型封闭,编译期固定
类型记录运行时(RTTI / type_info运行时下标(无 RTTI)
取回值any_cast<T>,精确匹配get<T> / get<I> / get_if / visit
失败行为bad_any_castnullptrbad_variant_accessnullptr
内存类型擦除 + SBO,大对象堆分配内联 tagged union,无堆分配
穷尽式分发不可能std::visit 编译期穷尽
依赖依赖 RTTI不依赖 RTTI
存引用/不可拷贝否(可包 reference_wrapper
典型开销间接调用 + 可能堆分配接近零开销

选择建议

  1. 类型集可在编译期穷举 → std::variant:零开销、可 visit、编译期穷尽检查,几乎总是首选。
  2. 类型集开放 / 跨编译边界 / 真正“任意” → std::any:配置系统、插件接口、事件负载等无法预知类型的场景。
  3. 需要多态行为且类型层次稳定 → 继承 + 虚函数:当行为而非“持有值”是核心时,面向对象的多态比 variant 更自然(见下节)。
  4. 只是“可能有值” → std::optional<T>:那是单类型的问题,别用 any/variant

与其他方案的比较

“持有多种类型”并非只有 any/variant 两条路:

方案类型集合类型安全开销适合场景
void* + 手工判别开放最低C 接口、遗留代码,不建议新代码使用
union封闭手工维护最低需要完全控制布局、无非平凡成员
std::any开放运行时中(类型擦除)任意类型、跨边界传递
std::variant封闭编译期+运行时类型集封闭、需穷尽处理
继承 + 虚函数开放编译期虚调用以行为为中心的多态
std::tuple<Ts...>固定全部编译期内联同时持有所有类型,而非“之一”

variant vs 继承 + 虚函数 是最常被比较的一对:

  • variant 把“数据”与“行为”分离:数据是值语义的 variant,行为是外部 visit 的访问者。新增类型要改 variant 定义和所有访问者;新增行为只需加一个新访问者。
  • 继承 + 虚函数把“数据”与“行为”绑定在类层次里:新增类型只需派生一个类并实现虚函数;新增行为要改基类接口(或用 visitor 模式反向模拟)。

这就是经典的 Expression Problemvariant 偏向“易加行为”,继承偏向“易加类型”。AST 解释器(行为多、类型固定)适合 variant+visit;插件体系(类型多、接口稳定)适合继承。

实际应用场景

std::any:异构配置与事件总线

当一个配置/事件系统需要携带任意类型的负载,且类型集无法预知时,std::any 是天然选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <any>
#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>

// 异构配置表:同一张表里既有 int、bool,也有 string
std::unordered_map<std::string, std::any> config = {
{"timeout", 30},
{"retries", 3},
{"host", std::string("127.0.0.1")},
{"verbose", true},
};

// 用指针版本安全查询,不抛异常
template <class T>
bool get(const std::unordered_map<std::string, std::any>& m,
const std::string& key, T& out) {
if (auto it = m.find(key); it != m.end()) {
if (auto* p = std::any_cast<T>(&it->second)) {
out = *p;
return true;
}
}
return false;
}

// 事件总线:携带任意负载分发
struct Event {
std::string name;
std::any payload; // 可能是 int / string / 自定义结构
};

void dispatch(const std::vector<Event>& events) {
for (const auto& e : events) {
if (e.name == "resize") {
if (auto* p = std::any_cast<std::pair<int,int>>(&e.payload)) {
std::cout << "resize to " << p->first << "x" << p->second << '\n';
}
}
// 其它事件类型在此分发……
}
}

std::variant:状态机与 AST

std::variant 在“类型集封闭、需穷尽处理”的场景大放异彩。两个经典应用:

1. 状态机——用 variant 表示当前状态,用函数实现状态迁移,编译器会检查你处理了所有状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <variant>
#include <iostream>
#include <string>

struct Idle {};
struct Connecting { std::string host; };
struct Connected { int session_id; };
struct Error { std::string message; };

using State = std::variant<Idle, Connecting, Connected, Error>;

// 事件 → 新状态
State on_event(Idle, std::string host) { return Connecting{host}; }
State on_event(Connecting host, int session_id) { return Connected{session_id}; }
State on_event(Connected, std::string err) { return Error{err}; }
State on_event(Error, std::string) { return Idle{}; } // 复位

void show(const State& s) {
std::visit([](const auto& st) {
using T = std::decay_t<decltype(st)>;
if constexpr (std::is_same_v<T, Idle>) std::cout << "idle\n";
else if constexpr (std::is_same_v<T, Connecting>) std::cout << "connecting\n";
else if constexpr (std::is_same_v<T, Connected>) std::cout << "connected\n";
else if constexpr (std::is_same_v<T, Error>) std::cout << "error\n";
}, s);
}

新增一个状态时,所有 on_event / show 等访问者会因穷尽性检查而报错,强制你补全逻辑——这是 variant 相比裸 enum + switch 的核心收益。

2. AST(表达式求值)——variant + 递归 visit 是实现解释器的惯用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <variant>
#include <memory>
#include <string>
#include <map>
#include <cmath>

struct Number { double value; };
struct Var { std::string name; };
struct Neg { std::shared_ptr<struct Expr> operand; };
struct Add { std::shared_ptr<struct Expr> lhs, rhs; };

struct Expr : std::variant<Number, Var, Neg, Add> {
using base = std::variant<Number, Var, Neg, Add>;
using base::base; // 继承构造函数
};

double eval(const Expr& e, const std::map<std::string,double>& env) {
return std::visit([&](const auto& node) -> double {
using T = std::decay_t<decltype(node)>;
if constexpr (std::is_same_v<T, Number>) return node.value;
else if constexpr (std::is_same_v<T, Var>) return env.at(node.name);
else if constexpr (std::is_same_v<T, Neg>) return -eval(*node.operand, env);
else if constexpr (std::is_same_v<T, Add>) return eval(*node.lhs, env) + eval(*node.rhs, env);
}, e);
}

这里把 Expr 定义为继承自 std::variant<...> 的类型,是为了让递归成员(Neg/Add 里的 shared_ptr<Expr>)能引用自身类型。新增一种节点(如 Mul)时,eval 中的 if constexpr 链会因为非穷尽而无法编译——除非你补上对应分支,从而避免遗漏。

总结

std::anystd::variant 都解决了“一个变量装多种类型”的问题,但出发点相反:

  • std::any类型擦除换取开放类型集,灵活但有运行时开销(RTTI、可能堆分配、间接调用),适合无法预知类型的跨边界传递。
  • std::variant封闭类型集换取零开销与穷尽式分发,靠 std::visit 实现编译期可检查的模式匹配,适合类型可枚举、需安全处理的场景。

选型口诀:类型能数清用 variant,数不清才用 any;以行为多态为主用继承,只是“可能没值”用 optional 把这四者(optional / variant / any / 继承)放在同一张选型表上,基本能覆盖 C++ 中“持有可选/多类型值”的全部设计决策。