C++17 any 与 variant
在 C++ 中,我们经常需要“用一个变量持有多种可能的类型”。C 风格的 union 和 void* 是最早的答案,但它们既不安全(不记录当前存的是哪种类型),也缺乏面向对象的扩展能力。C++17 引入了 std::any 和 std::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 | |
它的动机是:当你无法在编译期穷举所有可能的类型时,仍能在一个统一接口下传递值。典型场景包括配置系统、插件接口、事件总线上携带任意负载等——这些场景里,类型的集合是开放的,甚至跨越编译边界。
注意一个硬性约束: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 | |
std::any_cast 的三种形态
从 std::any 取回值只能通过 std::any_cast<T>,它有三种重载,失败行为各不相同,这是掌握 std::any 的关键:
1 | |
要点:
- 值/引用版本:要求类型精确匹配(忽略顶层
const),否则抛std::bad_any_cast(继承自std::bad_cast)。引用版本可避免大对象拷贝。 - 指针版本:传入
any*/const any*,返回T*/const T*,类型不匹配返回nullptr,是“查询式”访问的首选。 T会被std::decay:any_cast<int>与any_cast<const int>等价,但any_cast<int&>(引用版本)取回的是引用。
一个常见误用:std::any_cast 既不像 dynamic_cast 那样走继承链,也不做任何隐式转换。存的是 int,就只能用 int 取,用 long 也会失败——这正是它“类型安全”的体现。
类型擦除的实现原理
std::any 之所以能装任意类型,靠的是类型擦除:用一个统一的句柄类型隐藏具体 T,对外只暴露 type() 和拷贝能力。其经典实现是一个多态的占位基类 + 模板派生类:
1 | |
any_cast 的指针版本则是先比较 type_info,匹配后把 placeholder* 向下转为 holder<T>* 取值:
1 | |
由此可以理解几个特性:
type()依赖 RTTI,所以关掉 RTTI(-fno-rtti)的工程里std::any通常不可用。- 拷贝
std::any需要拷贝被存对象,因此才要求CopyConstructible。 - 类型擦除带来一次间接寻址与堆分配开销,这是
any比variant慢的根源。
小对象优化
上面示意把 holder 一律放在堆上。真实实现普遍做了小对象优化(SBO / Small Buffer Optimization):在 std::any 对象内部预留一块固定大小的缓冲区,当被存对象足够小且 noexcept 可移动时,直接内联存储,避免堆分配;否则退化为堆上的 holder。
1 | |
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几乎总是更高效。 - 必须精确匹配:存
int用long取会失败,没有任何隐式转换。 - 不可存不可拷贝类型:
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 | |
与 union 相比,std::variant:
- 自带标签:始终知道当前存的是哪个类型,无需用户手工维护判别标志。
- 析构安全:自动析构当前活跃成员,不会忘记调用非平凡析构函数。
- 类型安全访问:通过
std::get/std::visit取值,编译期与运行时双重保证。
与 std::any 相比,variant 的类型集是封闭的(编译期固定),换来了:
- 无 RTTI 依赖、无堆分配(值直接内联)、无类型擦除间接调用——零开销抽象。
- 编译期可知所有可能类型,从而能用
std::visit做穷尽式(exhaustive)分发。
一个约束:variant 的候选类型不能是引用、不能是数组、不能是(不完全确定的)void;也不能重复出现相同类型(C++17 起允许,但访问时只能用下标,不能用类型名消歧)。
基本接口
1 | |
注意“最佳匹配”的赋值规则:v = 42 时,候选 int/double/string 中,int 是精确匹配(无转换),double 需转换,string 需用户定义转换。若多个候选同等匹配(如 variant<int, long> 赋 short,二者都需转换),则重载决议歧义,编译失败——这时需用 v.emplace<int>(...) 显式指定。
类型安全的访问
取值有两套 API,与 any_cast 高度对称:
1 | |
std::get<T> 要求 T 在候选中唯一,否则编译失败;有重复类型时改用下标 std::get<I>。
访问者模式与 std::visit
std::visit 是 std::variant 的灵魂:它根据当前活跃成员,把对应的值传给一个可调用对象。配合 C++17 的 overloaded 模式,可以写出穷尽式的、编译期检查的“模式匹配”:
1 | |
overloaded 通过多重继承把多个 lambda 的 operator() 聚合到一个类型,using Ts::operator()... 把它们引入作用域。当 variant 的候选类型都有对应的调用重载时,visit 会分派正确;如果漏掉某个类型,编译期即报错——这是“穷尽性”的编译期保证。
std::visit 还能返回值,其返回类型由所有分支的公共类型推导而来:
1 | |
⚠️ 若
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 | |
异常安全
std::variant 的赋值异常安全分两类:
- 同类型赋值(新值与当前活跃成员同类型):直接对活跃成员赋值,异常行为由该类型的
operator=决定。若抛异常,variant仍持有该成员(值可能已部分改变),不会变空。 - 异类型赋值:标准库根据新类型是否
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的穷尽性:漏处理某个候选类型会在编译期报错,这是优点,但重构增删候选时要同步更新访问者。- sizeof:
variant的sizeof约等于最大候选类型大小 + 标签 + 对齐填充,所有候选都内联存储,无堆分配。
std::any 与 std::variant 的对比
| 特性 | std::any | std::variant<Ts...> |
|---|---|---|
| 类型集合 | 开放,任意可拷贝类型 | 封闭,编译期固定 |
| 类型记录 | 运行时(RTTI / type_info) | 运行时下标(无 RTTI) |
| 取回值 | any_cast<T>,精确匹配 | get<T> / get<I> / get_if / visit |
| 失败行为 | bad_any_cast 或 nullptr | bad_variant_access 或 nullptr |
| 内存 | 类型擦除 + SBO,大对象堆分配 | 内联 tagged union,无堆分配 |
| 穷尽式分发 | 不可能 | std::visit 编译期穷尽 |
| 依赖 | 依赖 RTTI | 不依赖 RTTI |
| 存引用/不可拷贝 | 否 | 否(可包 reference_wrapper) |
| 典型开销 | 间接调用 + 可能堆分配 | 接近零开销 |
选择建议
- 类型集可在编译期穷举 →
std::variant:零开销、可visit、编译期穷尽检查,几乎总是首选。 - 类型集开放 / 跨编译边界 / 真正“任意” →
std::any:配置系统、插件接口、事件负载等无法预知类型的场景。 - 需要多态行为且类型层次稳定 → 继承 + 虚函数:当行为而非“持有值”是核心时,面向对象的多态比
variant更自然(见下节)。 - 只是“可能有值” →
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 Problem:variant 偏向“易加行为”,继承偏向“易加类型”。AST 解释器(行为多、类型固定)适合 variant+visit;插件体系(类型多、接口稳定)适合继承。
实际应用场景
std::any:异构配置与事件总线
当一个配置/事件系统需要携带任意类型的负载,且类型集无法预知时,std::any 是天然选择:
1 | |
std::variant:状态机与 AST
std::variant 在“类型集封闭、需穷尽处理”的场景大放异彩。两个经典应用:
1. 状态机——用 variant 表示当前状态,用函数实现状态迁移,编译器会检查你处理了所有状态:
1 | |
新增一个状态时,所有 on_event / show 等访问者会因穷尽性检查而报错,强制你补全逻辑——这是 variant 相比裸 enum + switch 的核心收益。
2. AST(表达式求值)——variant + 递归 visit 是实现解释器的惯用法:
1 | |
这里把 Expr 定义为继承自 std::variant<...> 的类型,是为了让递归成员(Neg/Add 里的 shared_ptr<Expr>)能引用自身类型。新增一种节点(如 Mul)时,eval 中的 if constexpr 链会因为非穷尽而无法编译——除非你补上对应分支,从而避免遗漏。
总结
std::any 和 std::variant 都解决了“一个变量装多种类型”的问题,但出发点相反:
std::any用类型擦除换取开放类型集,灵活但有运行时开销(RTTI、可能堆分配、间接调用),适合无法预知类型的跨边界传递。std::variant用封闭类型集换取零开销与穷尽式分发,靠std::visit实现编译期可检查的模式匹配,适合类型可枚举、需安全处理的场景。
选型口诀:类型能数清用 variant,数不清才用 any;以行为多态为主用继承,只是“可能没值”用 optional。 把这四者(optional / variant / any / 继承)放在同一张选型表上,基本能覆盖 C++ 中“持有可选/多类型值”的全部设计决策。







