std::any 的性能开销:基于 libstd++ 源码分析
C++17 中引入了 std::any
,可以非常方便地将任意类型的变量放到其中,做到安全的类型擦除。然而万物皆有代价,这种灵活性背后必然伴随着性能取舍。
std::any 的实现本身也并不复杂,本文将基于 libstd++ 标准库源码 深入解析其实现机制与性能开销。
底层存储
std::any 需要解决的核心问题在于:
- 异构数据存储:如何统一管理不同尺寸的对象
- 类型安全访问:如何在擦除类型信息后仍能提供安全的类型查询。例如可以直接通过 std::any 提供的 type() 函数,直接获取到底层数据的类型信息。
从 libstd++ 源码中提取的关键类结构如下
1 | class any { |
可以看到有两个核心变量:
_M_storage
:负责存储数据值本身或者指针。_M_manager
:函数指针,负责指向具体类型 template class 的实现,其中包含了类型信息。
我们先看 _M_storage
的实现:
1 | union _Storage |
_Storage
类是一个 union 实现。里面包含两个属性:_M_ptr
和长度为 sizeof(_M_ptr)
的 char 数组 _M_buffer
。即长度为指针大小,在 64 位机器下,_M_buffer
的长度是 8。
那么,在什么情况下分别使用 _M_ptr
和 _M_buffer
呢?主要通过以下模板变量进行编译期决策。
1 | template<typename _Tp, typename _Safe = is_nothrow_move_constructible<_Tp>, bool _Fits = (sizeof(_Tp) <= sizeof(_Storage)) && (alignof(_Tp) <= alignof(_Storage))> |
简单来说:_Tp 可以无异常移动构造 && _Tp 能完全放入 _Storage 中
。
这是一个非常典型的 SOO(Small Object Optimization 小对象优化)。即:对于小尺寸对象,直接在容器自身的连续内存中 (通常为栈内存) 完成存储,这样可以避免在堆上开辟新的内存。
因此:
- 对于小尺寸对象(≤指针大小),直接在
_M_buffer
中通过 placement new 创建对象。避免堆内存分配带来的性能开销,提升 CPU 缓存局部性(对高频访问的场景尤为重要)。 - 对于大尺寸对象,直接在堆上通过 new 申请内存,
_M_storage
存储对应的指针。
但这个内存结构的设计,也存在着潜在的内存浪费:union 的内存等于最大字段的内存,因此即使在 std::any 中存储 1 字节的 char 类型变量,_M_storage
也需要 8 字节。
另外,我们发现在 _Storage
并未存储任何类型信息。但我们可以通过 std::any 的 type() 函数获取到对应的类型信息。这是如何做到呢?
接下来,我们看 _M_manager
的实现:
std::any 的做法非常巧妙,将所有需要类型信息的操作,都通过一个 template class 的 static 函数来实现。std::any 对象中只存储这个函数的指针,即 void (*_M_manager)(_Op, const any*, _Arg*)
。
1 | template<typename _Tp> |
以 std::any 的 type() 函数实现为例, 代码如下:
1 | const type_info& type() const noexcept |
我们可以看到,通过_M_manager
找到对应template class的具体实现,直接调用typeid(_Tp)
就可以获取到对应的 typeinfo 信息了。
但值得注意的是,在调用 _M_manager
函数的时候,额外传递了一个 enum 值 _Op_get_type_info
。
这是 std::any 的特殊设计,通过枚举值区分不同的逻辑,将所有需要类型信息的操作都整合到一个函数入口。这样做仅用一个函数指针即可,可以节省内存开销。
总结
虽然 std::any 提供了极大的灵活性,且绝大部分场景下性能也够用。但根据我们对源码的深入分析,发现 std::any 的设计特点必然会带来一些额外的开销:
- 内存开销:在 64 位机器下固定占用 16 byte 空间(8 字节的_M_storage 和 8 字节的_M_manager 函数指针)。存储 1 字节数据时空间利用率仅 6.25%;
- 性能开销:小对象直接栈存储,对于大对象会触发堆分配。