**在 C++20 中实现安全的类型擦除:std::any、std::variant 与自定义 Eraser 的比较**

在实际项目中,经常需要一种“通用容器”来存放任意类型的数据,同时还能保证一定程度的类型安全与性能。C++20 提供了两种标准化方案——std::anystd::variant,以及可选的自定义类型擦除(Type Erasure)实现。本文将深入比较这三种方案,探讨它们的适用场景、性能特点以及常见陷阱,并给出一套基于自定义 Eraser 的安全实现模板。


1. 需求场景

假设我们在构建一个插件系统,插件之间通过共享一个“事件总线”来传递信息。每个插件可能会发送不同类型的事件:日志事件、网络事件、计时器事件等。我们需要:

  1. 事件能够以通用形式放入队列;
  2. 事件在被消费时能够安全地恢复原始类型;
  3. 对于常见类型(如 intstd::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 特点

  • 高度灵活:可存储任何可拷贝或可移动的对象。
  • 运行时类型信息:需要 typeidany_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(如 CopyConstructibleMoveConstructible);
  • 提供统一的 emplace/get 接口。

4.2 设计思路

  1. 抽象基类 Base 包含虚函数 clonetype_id
  2. 模板派生类 `Holder ` 存储对象 `T`,实现 `clone` 与 `type_id`。
  3. 包装器 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_caststd::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 中实现安全、灵活的类型擦除提供参考。

发表评论