在 C++17 之后,标准库新增了两个非常实用的类型擦除工具——std::variant 与 std::any。它们都可以用来存储多种不同类型的值,但在语义、使用场景以及性能表现上有显著差异。本文将从概念、内部实现、典型使用案例以及注意事项四个方面,对这两者进行系统比较,并给出在实际项目中如何选择与使用的建议。
1. 基本概念
std::variant |
std::any |
|
|---|---|---|
| 类型安全 | 是(编译期检查) | 否(运行期检查) |
| 目标 | 受限的多态,类型集合固定 | 任意类型,类型集合可变 |
| 存储 | 对齐内存,按类型排布 | 动态分配(或内部分配) |
| 可移动性 | 所有持有的类型都满足 MoveConstructible |
仅支持可移动类型 |
| 访问 | std::get<>() 或 std::visit() |
std::any_cast<>() |
- std::variant:类似于多态的“静态”版本。你需要在编译时就声明所有可能的类型,使用
std::visit或者 `std::get ()` 获取值。若取不到对应类型,将抛出 `std::bad_variant_access`。 - std::any:类似于
void*的安全封装。任何满足CopyConstructible的类型都可以存储,只能通过 `any_cast ()` 进行取值。若类型不匹配,将抛出 `std::bad_any_cast`。
2. 内部实现细节
2.1 std::variant
- 布局:使用联合(
union)来存储所有可能类型的实例,配合一个std::size_t或std::variant_alternative的枚举值记录当前类型索引。 - 初始化:在构造时,根据模板参数的顺序初始化对应的成员。
- 析构:根据当前索引调用对应类型的析构函数。
- 大小:等于最大类型大小加上索引占用空间。对齐保证不会出现未对齐访问。
2.2 std::any
- 实现策略:典型实现采用“小对象优化”(Small Object Optimization, SOO)。若对象大小小于指针大小,直接存储在内部数组中;否则动态分配内存。
- 类型信息:内部存储
std::type_info const*用于类型检查和析构。 - 复制/移动:复制时会执行
copy(),移动时执行move(),都需要类型擦除的虚函数表。
3. 典型使用场景
| 场景 | 适合的容器 | 原因 |
|---|---|---|
| UI 事件系统(鼠标点击、键盘输入等) | std::variant |
事件类型固定,且需要在编译时知道所有可能 |
| 脚本语言绑定(C++ 调用 Python、Lua) | std::any |
绑定的类型不确定,且在运行时动态决定 |
| 配置项解析(JSON 字符串解析成 bool, int, double, string 等) | std::variant |
解析后类型可预知,方便访问 |
| 任务调度器(异步任务的结果类型多种) | std::any |
结果类型在提交时不一定相同,动态决定 |
| 事件订阅系统(多种回调参数) | std::variant |
订阅者已知回调签名,类型静态 |
3.1 代码示例:std::variant 事件系统
#include <variant>
#include <string>
#include <iostream>
#include <functional>
#include <vector>
struct MouseEvent { int x, y; };
struct KeyEvent { char key; };
using Event = std::variant<MouseEvent, KeyEvent>;
void handleEvent(const Event& e) {
std::visit([](auto&& arg){
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, MouseEvent>)
std::cout << "Mouse at (" << arg.x << "," << arg.y << ")\n";
else if constexpr (std::is_same_v<T, KeyEvent>)
std::cout << "Key pressed: " << arg.key << '\n';
}, e);
}
int main() {
std::vector <Event> events = { MouseEvent{10,20}, KeyEvent{'a'} };
for (const auto& e : events) handleEvent(e);
}
3.2 代码示例:std::any 脚本参数
#include <any>
#include <iostream>
#include <string>
void invokeScript(const std::any& arg) {
try {
if (arg.type() == typeid(int))
std::cout << "int: " << std::any_cast<int>(arg) << '\n';
else if (arg.type() == typeid(std::string))
std::cout << "string: " << std::any_cast<std::string>(arg) << '\n';
else
std::cout << "unknown type\n";
} catch (const std::bad_any_cast&) {
std::cerr << "bad cast\n";
}
}
int main() {
invokeScript(42);
invokeScript(std::string("hello"));
}
4. 性能比较
| 特性 | std::variant | std::any |
|---|---|---|
| 分配 | 只一次内存分配(堆栈) | 可能需要动态分配(SOO 限制) |
| 访问 | 编译期确定(std::get) |
运行时检查 |
| 类型安全 | 编译期 | 运行期 |
| 缓存友好 | 固定布局 | 可能导致多次分配 |
| 移动/拷贝 | 需要所有类型满足对应语义 | 只需 CopyConstructible |
在大多数需要频繁访问并且类型已知的场景,std::variant 通常更高效;而当类型不确定、需要存储任意对象时,std::any 仍然是最方便的选择。
5. 常见坑与注意事项
-
空变体
std::variant必须始终持有某一类型,不能为空。若需要空值,可将std::nullptr_t作为一种可能类型,或者使用std::optional<std::variant<...>>。
-
移动性与异常安全
- 访问
std::variant时若使用std::get,会产生一次复制或移动。若目标类型抛异常,variant保证不变。
- 访问
-
any_cast 的失败
- `any_cast (any)` 在类型不匹配时会抛异常。若你想安全检查,先用 `any.type()` 或 `any_cast(&any)`(返回指针)避免异常。
-
多线程共享
std::any的内部类型信息和对象管理不保证线程安全。若多线程访问,需要自行同步。
-
自定义类型的移动/复制
std::variant会根据模板参数调用对应的构造/移动/拷贝。若你自定义了非平凡类型,记得实现或禁用移动/拷贝构造。
-
使用
std::visit的递归- 当
variant包含另一variant时,std::visit需要嵌套调用。可借助std::apply或递归 lambda 解决。
- 当
6. 结语
std::variant 与 std::any 分别解决了“受限多态”和“任意类型存储”两类典型需求。掌握它们的语义与实现细节,可以帮助你在 C++17+ 的项目中更高效、更安全地管理类型多变的数据。建议:
- 使用
std::variant:当所有可能类型在编译时已知且不需要经常新增类型时。 - 使用
std::any:当类型在运行时动态决定,或需要与脚本、插件系统交互时。
在实践中,往往是两者结合使用:核心业务逻辑使用 std::variant,而插件接口、配置系统则用 std::any。通过合理划分职责,你可以在保持类型安全的同时,获得足够的灵活性。