【转】std::unique_ptr 删除器的秘密
std::unique_ptr 删除器的秘密-阿里云开发者社区
TL;DR
- 使用无状态的函数对象作为 std::unique_ptr 的删除器不会占用额外的内存空间;而使用函数指针或有状态的函数对象则会增加 std::unique_ptr 对象的大小,其中 std::function 的内存开销最大,应尽量避免使用。
- MSVC 使用 compressed pair 来存储 std::unique_ptr 的原始指针和删除器,利用 Empty Base Class Optimisation (EBO) 技术来消除空类对象所占用的空间。很多其他厂商也有类似的实现。
- 在 C++20 中,可以通过
no_unique_address
attribute 大幅简化 EBO 的应用。
Motivation
最近因为遇到一些问题查阅《Effective Modern C++》的时候,发现了一个之前没有仔细推敲的细节。在 Item 18 中,作者提到:
I remarked earlier that, when using the default deleter (i.e., delete), you can reasonably assume that std::unique_ptr objects are the same size as raw pointers. When custom deleters enter the picture, this may no longer be the case. Deleters that are function pointers generally cause the size of a std::unique_ptr to grow from one word to two. For deleters that are function objects, the change in size depends on how much state is stored in the function object. Stateless function objects (e.g., from lambda expressions with no captures) incur no size penalty, and this means that when a custom deleter can be implemented as either a function or a captureless lambda expression, the lambda is preferable.
翻译过来就是说,使用默认删除器的 std::unique_ptr 对象大小和裸指针是一样的,但对于自定义删除器来说:
- 如果这个删除器是一个函数指针,那么 std::unique_ptr 对象的大小会增大 1~2 word。
- 如果删除器是一个函数对象,那么 std::unique_ptr 对象的大小取决于这个函数对象中存储着多少状态。对于无状态的函数对象(例如不带捕获的 lambda 表达式),是不会导致额外内存开销的。
第一点好理解,显然增加的大小来源于函数指针所占的空间。
而第二点是令我比较疑惑的地方。我们知道,即使是一个不带任何数据成员的空类,其对象也至少要占一个字节,用以相互区别。而作者却说使用无状态的函数对象作为删除器,不会增加 std::unique_ptr 的大小。
笔者写了个 case 验证了下,在 gcc 8.3.0 和 clang 13.1.6 下输出结果都一样:
void Delete(int* p) { delete p; }
auto delLambda = [](int* p) { delete p; };
std::function<void (int*)> delFunc = delLambda;
int main()
{
std::unique_ptr<int> p1(nullptr);
std::unique_ptr<int, decltype(Delete)*> p2(nullptr, Delete);
std::unique_ptr<int, decltype(delLambda)> p3(nullptr, delLambda);
std::unique_ptr<int, decltype(delFunc)> p4(nullptr, delFunc);
std::cout << "Default deleter: " << sizeof(p1) << std::endl;
std::cout << "FuncPtr deleter: " << sizeof(p2) << std::endl;
std::cout << "Lambda deleter: " << sizeof(p3) << std::endl;
std::cout << "Function deleter: " << sizeof(p4) << std::endl;
return 0;
}
// Default deleter: 8
// FuncPtr deleter: 16
// Lambda deleter: 8
// Function deleter: 40
可以看到,使用函数指针作为删除器产生的 std::unique_ptr 对象大小为 16 字节,使用 std::function 则为 40 字节,而使用无状态 lambda 和默认删除器均为 8 字节,果真没有引入额外的内存开销。
这究竟是如何做到的呢?作者在书中没有详细解释,笔者查阅资料后终于弄清了其背后的秘密:Empty Base Class Optimisation。
Empty Base Class Optimisation (EBO)
简单来说,EBO 就是通过继承空基类来避免增加对象大小。
前文提到,一个空类对象也需要占用至少 1 个字节,但这其实不完全对。事实上,如果该对象是以基类部分的形式存在的,就不会额外占用空间。也就是说,如果一个类继承了一个空基类,那么前者的对象大小不会因此增加。
举个例子:
struct Empty { };
struct EmptyEmpty : Empty { };
std::cout << "Empty: " << sizeof(Empty) << std::endl;
std::cout << "EmptyEmpty: " << sizeof(EmptyEmpty) << std::endl;
// Empty: 1
// EmptyEmpty: 1
Empty 的大小是 1,而 EmptyEmpty 也是 1,并不是 1+1 = 2。std::unique_ptr 就是充分利用了这项优化,将无状态的删除器作为这里的 Empty 类。
How does std::unique_ptr apply EBO?
我们来具体分析一下 Microsoft STL 中 std::unique_ptr 的实现。在其源码中可以找到这样一个 helper 类:
_Compressed_pair<_Dx, pointer> _Mypair;
std::unique_ptr 内部使用 _Compressed_pair
来存储和使用它所管理的原始指针及其对应的删除器。例如其析构函数长这样:
~unique_ptr() noexcept {
if (_Mypair._Myval2) {
_Mypair._Get_first()(_Mypair._Myval2); // call deleter
}
}
那么这个 _Compressed_pair
到底是个啥?我们进一步到 xmemory 头文件中一探究竟。实际上,这玩意就是个模板类,和 std::pair 的概念类似,但特殊之处在于它有两个特化版本。
第一个是:
// store a pair of values, deriving from empty first
template <class _Ty1, class _Ty2, bool = is_empty_v<_Ty1> &&
!is_final_v<_Ty1>>
class _Compressed_pair final : private _Ty1 {
public:
_Ty2 _Myval2;
// ... the rest of impl
}
第二个是:
// store a pair of values, not deriving from first
template <class _Ty1, class _Ty2>
class _Compressed_pair<_Ty1, _Ty2, false> final {
public:
_Ty1 _Myval1;
_Ty2 _Myval2;
// ... the rest of impl
}
先看第二个版本,这个很直观,基本就是普通的 std::pair 的定义。
而当 _Ty1
是一个空类时,则会特化为第一个版本。这里 _Ty2
依然作为一个普通的成员,但 _Ty1
却通过继承的方式内嵌到 _Compressed_pair
中。乍一看这好像有点不伦不类,毕竟从概念上来说 _Compressed_pair
和 _Ty1
似乎不应该是继承的关系。但注意这里用的是 private 继承,相较于 public 继承表达的 is-a
关系,private 继承隐含的意思其实是 is-implemented-in-terms-of
,即「由…实现出」。这就说得通了,_Ty1
是组成 _Compressed_pair
的一部分,反过来 _Compressed_pair
是由 _Ty1
实现的。这也是为什么很多情况下,组合和 private 继承这两种设计可以互换的原因,详细内容可以参阅 《Effective C++》Item 38。
回到正题,这里的关键在于,通过 private 继承(而不是作为成员进行组合),我们既获得了空基类 _Ty2
的接口,同时又做到了不因此增加 _Compressed_pair
的大小。简直就是成年人我都要!
另外,细心的读者可能注意到了,由于模板中 _Ty1
和 _Ty2
的顺序是固定的,这意味着该实现只考虑 _Ty1
是空类的情况。有兴趣的话还可以研究一下 boost 库实现的 compressed_pair
,此版本中只要二者任意一个是空类,就可以应用 EBO 技术:Compressed_Pair - Boost 1.73.0
One More Thing
通过上文 _Compressed_pair
我们可以看到,想要应用 EBO 技术还是挺麻烦的,又要用到模板偏特化又要用到继承,还要通过 traits 判断某个类是不是空类。如果要自己实现的话,需要对 metaprogramming 比较熟悉。然而到了 C++20 这儿,基本上就没你什么事儿了。
C++20 专门提供了一个叫做 no_unique_address
的 attribute,让程序员能够一行代码启用 EBO:
template <typename T, typename U>
struct compressed_pair_cpp20 {
[[no_unique_address]] T _val1;
[[no_unique_address]] U _val2;
};
That's all,是不是瞬间清爽多了 : )
通过将该 attribute 应用于非静态数据成员(除 bit-field 外),编译器能够自动检测其对应的类是否为空类。如果是的话,则该成员可以与其他非静态数据成员共用相同的地址,从而实现复用内存空间的目的。
例如:
struct Empty { };
compressed_pair_cpp20<int, Empty> p;
std::cout << std::addressof(p._val1) << std::endl;
std::cout << std::addressof(p._val2) << std::endl;
// 0x16fdff318
// 0x16fdff318
输出的两行地址应该是一样的。
当然,除了删除器之外, no_unique_address
还能被应用于许多类似的空类对象,例如 allocator、predicate 等无状态的自定义对象。
References
- Custom Deleters for C++ Smart Pointers - C++ Stories
- Empty Base Class Optimisation, no_unique_address and unique_ptr - C++ Stories
- https://en.cppreference.com/w/cpp/language/ebo
- https://en.cppreference.com/w/cpp/language/attributes/no_unique_address
- Effective C++
- Effective Modern C++