在实际项目中,经常需要一种“通用容器”来存放任意类型的数据,同时还能保证一定程度的类型安全与性能。C++20 提供了两种标准化方案——std::any 和 std::variant,以及可选的自定义类型擦除(Type Erasure)实现。本文将深入比较这三种方案,探讨它们的适用场景、性能特点以及常见陷阱,并给出一套基于自定义 Eraser 的安全实现模板。
1. 需求场景
假设我们在构建一个插件系统,插件之间通过共享一个“事件总线”来传递信息。每个插件可能会发送不同类型的事件:日志事件、网络事件、计时器事件等。我们需要:
- 事件能够以通用形式放入队列;
- 事件在被消费时能够安全地恢复原始类型;
- 对于常见类型(如
int、std::string)保持轻量化。
2. std::any
2.1 基本用法
std::any a = 42; // 存储 int
std::any b = std::string("msg"); // 存储 std::string
if (a.type() == typeid(int))
std::cout << std::any_cast<int>(a) << '\n';
2.2 特点
- 高度灵活:可存储任何可拷贝或可移动的对象。
- 运行时类型信息:需要
typeid和any_cast,出现错误时抛出bad_any_cast。 - 内存布局:小型对象(Small Object Optimization)实现不确定,可能导致额外拷贝。
2.3 性能瓶颈
- 运行时类型检查:每次取值都涉及
type_info对比。 - 动态分配:大多数实现会在堆上分配,导致堆栈切换。
- 缺乏编译期检查:错误只能在运行时捕获。
3. std::variant
3.1 基本用法
using Event = std::variant<int, std::string, std::chrono::system_clock::time_point>;
Event ev = std::chrono::system_clock::now();
std::visit([](auto&& e){
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, int>) std::cout << "int: " << e;
else if constexpr (std::is_same_v<T, std::string>) std::cout << "string: " << e;
else if constexpr (std::is_same_v<T, std::chrono::system_clock::time_point>) std::cout << "time: " << std::chrono::system_clock::to_time_t(e);
}, ev);
3.2 特点
- 编译时类型安全:类型集合在编译期确定,避免运行时错误。
- 值语义:无须动态分配,内存布局为联合 + 标记。
- 多态性限制:只能存储已知类型集合,无法动态添加。
3.3 性能优势
- 无堆分配:适合高频率操作的事件队列。
std::visit在大多数实现中使用switch语句,开销极低。
4. 自定义 Type Eraser(类型擦除)
4.1 目标
- 兼具
std::any的灵活性和std::variant的性能; - 在编译期验证类型可移动且满足
Concept(如CopyConstructible、MoveConstructible); - 提供统一的
emplace/get接口。
4.2 设计思路
- 抽象基类
Base包含虚函数clone、type_id。 - 模板派生类 `Holder ` 存储对象 `T`,实现 `clone` 与 `type_id`。
- 包装器
AnySafe仅在构造/赋值时检查类型满足Concept。
4.3 代码实现
#include <typeinfo>
#include <memory>
#include <iostream>
#include <concepts>
class AnySafe {
struct Base {
virtual ~Base() = default;
virtual Base* clone() const = 0;
virtual const std::type_info& type() const = 0;
};
template<typename T>
struct Holder : Base {
T value;
explicit Holder(T&& v) : value(std::forward <T>(v)) {}
Base* clone() const override { return new Holder <T>(value); }
const std::type_info& type() const override { return typeid(T); }
};
std::unique_ptr <Base> ptr;
public:
AnySafe() = default;
template<std::movable T>
AnySafe(T&& v) : ptr(std::make_unique<Holder<std::remove_cvref_t<T>>>(std::forward<T>(v))) {}
AnySafe(const AnySafe& other) : ptr(other.ptr ? other.ptr->clone() : nullptr) {}
AnySafe(AnySafe&&) noexcept = default;
AnySafe& operator=(AnySafe other) noexcept { swap(*this, other); return *this; }
template<std::movable T>
T get() const {
if (!ptr || ptr->type() != typeid(T))
throw std::bad_cast();
return static_cast<Holder<T>*>(ptr.get())->value;
}
bool empty() const noexcept { return !ptr; }
friend void swap(AnySafe& a, AnySafe& b) noexcept { std::swap(a.ptr, b.ptr); }
};
int main() {
AnySafe a = 42;
AnySafe b = std::string("hello");
std::cout << a.get<int>() << '\n';
std::cout << b.get<std::string>() << '\n';
}
4.4 优点
- 编译期检查:只有满足
std::movable的类型才能存放。 - 无需堆分配:通过
unique_ptr指向堆,但对象本身可放入栈(若大小已知可改为variant内部实现)。 - 统一异常处理:
bad_cast与std::any一致,易于迁移。
4.5 性能评估
- 取值速度:与
std::variant相当,但比std::any更快(因为不涉及typeid比较)。 - 内存占用:额外的虚表指针 + 对象指针,适用于类型数目不多的场景。
5. 何时选择哪种方案?
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 需要存储已知有限种类且频繁访问 | std::variant |
编译时安全、无堆分配 |
| 需要动态类型集合,类型未知 | std::any |
灵活但性能较低 |
| 需要编译期类型约束,且兼顾性能 | 自定义 Eraser | 兼顾安全与速度,适用于插件/消息系统 |
6. 小结
std::any适合最灵活的需求,但代价是运行时检查和潜在的堆分配。std::variant在类型已知且不频繁变动时提供最佳性能与编译时安全。- 自定义类型擦除实现可以在两者之间折衷,提供编译期约束并保持较低的运行时成本。
在实际项目中,可以根据需求做权衡;若对性能要求极高且事件类型固定,首选 std::variant;若插件系统需要动态扩展,建议使用自定义 Eraser 并结合 std::any 的接口。希望本文能为你在 C++20 中实现安全、灵活的类型擦除提供参考。