PAYATHON 2026

C++11 的 Any 类型擦除实现是否安全,会触发 UB 吗?

数据阿明

对于示例中的 int、std::string 以及按具体类型创建的普通类对象,get() 的指针恢复和删除器的处理符合 C++11 规则,多态和多重继承本身不会导致 UB。原实现没有限制可存储的类型,因此不能认定所有模板实参都安全:数组类型会破坏当前的分配与释放配对,需要明确禁止或单独实现支持。

为什么 get() 的转换成立

以存储 int 为例,指针经历的转换是:

new int(...) → int* → void* → int*

对象指针转换为 void* 后,再转换回原对象类型的指针,可以恢复原来的指针值;恢复时也允许增加 cv 限定。这是语言规则提供的保证,与对象是否为 standard-layout 无关。参见 N3337 的 static_cast 规则。

原实现通过以下条件限制恢复类型:

if (m_type == typeid(T)) {
    return static_cast<T *>(m_value.get());
}

当存储类型限定为无顶层 cv 限定的非数组对象类型时,这个判断足以支持当前的取值方式。类型不匹配时会返回 nullptr,不会进入转换分支。

typeid 会忽略顶层 const、volatile 和引用形式。例如,typeid(int) == typeid(const int) 为真。因此,这里的比较并非保留所有限定符的严格类型身份比较。参见 N3337 的 typeid 规则。

对于实际存储的非 const int,调用 get<const int>() 合法,只是取得一个只读指针。原实现的 make<const int>() 则会编译失败,因为 const int* 不能隐式转换为成员所需的 void*。这是接口限制,不是运行时 UB。

多重继承中的地址差异不会影响当前实现

当前实现没有先将派生类指针转换成基类指针。存储 Derived 时,路径始终是:

Derived* → void* → Derived*

get<Base>() 不会匹配 typeid(Derived),因此返回 nullptr。基类子对象是否位于不同地址,不影响这一流程。

如果需要取得基类指针,应先恢复具体类型,再进行正常的派生类到基类转换:

struct Left {
    int left = 1;
};

struct Right {
    int right = 2;
};

struct Derived : Left, Right {};

Any value = Any::make<Derived>();

Derived* derived = value.get<Derived>();
Right* right = derived; // 编译器负责必要的地址调整
Right* direct = value.get<Right>(); // nullptr

不能只放宽类型检查,就直接把保存的 void* 转成某个基类指针。这样不会执行从 Derived* 到基类指针所需的转换。

自定义删除器是否正确

对于通过 new T(...) 创建的非数组对象,下面的删除器可以恢复具体类型并销毁对象:

[](const void *ptr) {
    const T *self = static_cast<const T *>(ptr);
    delete self;
}

通过 const T* 删除对象是合法的,const 不会阻止析构。存储 Derived 时,删除器也按 Derived 实例化,因此不会因为某个基类没有虚析构函数而产生“通过基类指针删除派生类对象”的问题。相关规则见 N3337 的 delete 说明。

删除器 lambda 没有捕获,可以转换为对应的函数指针,因此能用作这里的 std::unique_ptr 删除器。

数组是需要修正的边界

考虑下面的模板实参:

auto values = Any::make<int[3]>();

当 T 是 int[3] 时,new T() 创建数组,返回首元素指针;删除器却使用普通的 delete self。标准要求数组分配与数组释放方式配对,原实现不能为这种用法提供安全保证。问题来自模板实例化后的实际分配类型,即使源码写的是 new T(),也不能忽略数组情形。参见 N3337 的数组释放要求。

对这个轻量实现来说,直接禁止原生数组更容易保持正确。需要保存固定长度数组时,可以存储 std::array<int, 3>;它是普通类对象,适用当前的单对象分配和删除方式。

如何收紧接口

先显式包含用到的头文件,尤其是声明 std::forward 的 <utility>,不要依赖 <memory> 间接包含它:

#include <typeinfo>
#include <memory>
#include <utility>
#include <type_traits>

然后将原来的 make() 替换为:

template <typename T, typename... Args>
static Any make(Args &&... args) {
    static_assert(
        std::is_object<T>::value && !std::is_array<T>::value,
        "T must be a non-array object type"
    );
    static_assert(
        !std::is_const<T>::value && !std::is_volatile<T>::value,
        "T must not have top-level cv qualifiers"
    );

    return Any(TypeSelector<T>{}, std::forward<Args>(args)...);
}

这会明确排除数组、引用、函数、void 和带顶层 cv 限定的存储类型。T 还需要在实例化处完整,并且能够用传入参数构造、能够被删除;现有的 new T(...) 和删除器会进一步约束这些要求。

使用时还要注意什么

  • get<T>() 返回借用指针。不要自行 delete,也不要在所指对象销毁后解引用。
  • 默认移动构造会转移 m_value 的所有权。移出对象保留原来的类型信息,但其指针为空,因此调用合法类型的 get<T>() 会返回 nullptr。
  • 原实现没有提供复制操作,也没有可用的移动赋值操作。m_type 是引用成员,不能直接依靠默认移动赋值来更新类型信息。
  • 若需要从 const Any 读取,应另外提供返回 const T* 的 const 成员函数重载。

保留现有的类型擦除思路即可:补齐头文件、限制存储类型,并让调用方遵守返回指针的生命周期约束。不必仅因涉及继承就把 static_cast 改成其他转换。

备注:内容仅供参考。