1 显式析构对象
1.1 需要显式析构的场景
对象的构造和析构是对象生命周期管理中最重要的两个环节,与之对应的是构造函数和析构函数。对象可以有多个构造函数,但是只能有一个析构函数(C++ 20 支持预期析构函数并没有改变这一结构,我们在《C++ 20 的 Prospective destructor》一篇有相关的介绍),并且开发人员很少需要显式调用析构函数。对象在超出作用域的时候被自动析构,动态分配的对象在调用 delete 的时候被自动析构。但是,在某些情况下,还是需要显式调用析构函数,比如这个“带 tag 的 union” 惯用法的例子:
struct ResourceId final {
enum class IdType { String, Integer } _type;
union {
std::string _sid;
uint32_t _uid;
};
void SetId(const char* s) {
if (_type != IdType::String) {
::new (&_sid) std::string(s);
_type = IdType::String;
}
else
_sid = s;
}
void SetId(uint32_t u) {
if (_type == IdType::String) {
_sid.~basic_string<char>();
}
_uid = u;
_type = IdType::Integer;
}
...
};
int main() {
ResourceId rid;
rid.SetId(42);
rid.SetId("9527");
}
动态内存申请和释放需要操作系统内核功能调用,通常比较耗时,在一些性能关键的算法中,应尽量避免频繁申请和释放内存。于是就有了申请一次内存,然后利用显式析构和原位构造在这块内存上实现对象的多次构造和销毁的习惯用法。比如这个 ReConstructDefault() 函数:
void ReConstructDefault(Foo *ptr, uint32_t n) {
for (uint32_t i = 0; i < n; ++i) {
ptr[i].~Foo();
::new (&ptr[i]) Foo(); //default construction
}
//delete[] ptr;
//ptr = new Foo[n];
}
一般情况下,ReConstructDefault() 函数中 for 循环比下面两行代码有更好的性能,
1.2 显式析构的问题
在某些情况下,显式调用析构函数并不是很方便,其实上一节调用 string 的析构函数的代码已经展示了这一点。我们可以通过 std::string 这个类型别名构造 string 类型的对象,但是却不可以这样析构:_sid.~std::string(),因为 string 的析构函数是 string 类的成员,必须加上这个域限定:_sid.std::string::~string()。再或者就是像我们上一节的例子那样,直接指定这个类模板的具体实例的类型,就是 basic_string,这个是类型的实际名字,析构函数跟它同名。
很显然,直接显式调用析构函数需要手工指定类型的名字,就留下了很多犯错的空间,弄错了名字会很麻烦。比如这段代码:
struct Base {
virtual ~Base() {
std::cout << "Base::~Base() invoked!" << std::endl;
}
};
struct Derived : public Base {
~Derived() {
std::cout << "Derived::~Derived() invoked!" << std::endl;
}
};
void DestroyObject(Base* ptr) {
ptr->~Base(); //#1
//ptr->Base::~Base(); //#2
}
Derived d;
DestroyObject(&d);
DestroyObject() 函数中的两行代码是完全不同的两个结果,一不小心写错了就会导致对象被错误的方式销毁。另外,显式调用析构函数一般都会配合原位 new 实现内存地址的复用,这两个语法形式都是低级语法形式,很容易被误解它们的用途。很多第三方的库很早就提供了构造和销毁语义的函数,但是 C++ 标准库一直没有动静。
1.3 std::allocator 和 std::allocator_traits
实际上,C++ 的 std::allocator 提供了 construct 和 destroy 两个成员函数,可用于在分配器分配的存储位置构造和销毁对象,就像这个例子演示的那样:
int main() {
std::allocator<Foo> alloc;
Foo* p = alloc.allocate(1); //分配内存
alloc.construct(p, 42); // 调用 Widget(int) 构造函数
//使用 Foo* p
alloc.destroy(p); // 显式析构
alloc.deallocate(p, 1); //释放内存
}
但是使用 std::allocator 存在两个问题,其一是类型通用性问题,毕竟 std::allocator 和 std::allocator 是两个不同的类型。其二是 C++ 从 17 开始就已经从 std::allocator 中删除了 construct 和 destroy 这两个成员函数。
C++ 从 std::allocator 中删除 construct 和 destroy 的原因是早期 std::allocator 的设计违反了软件接口设计的一般原则。构造和销毁对象本不是分配器的职责,将这组接口放在分配器中,导致实现自定义分配器的时候,也不得不提供这组接口的实现,construct/destroy 的逻辑实际是通用的,这不仅仅是重复代码的问题,更是违反了接口隔离原则。所以从 C++ 11 开始,推荐使用 std::allocator_traits,它提供了一组统一的静态接口,能兼容各种内存分配器的类型。从 std::allocator 中删除的 construct/destroy 的行为由 std::allocator_traits 统一管理。本节的例子使用 std::allocator_traits 改造后是这个样子:
int main() {
std::allocator<Foo> alloc;
auto* p = alloc.allocate(1);
std::allocator_traits<decltype(alloc)>::construct(alloc, p, 42);
//使用 Foo* p
std::allocator_traits<decltype(alloc)>::destroy(alloc, p);
alloc.deallocate(p, 1);
}
2 C++ 的 destroy 函数
实际上,无论是 std::allocator,还是它的继任者 std::allocator_traits,都不能应对非标准分配器所产生的存储空间,比如栈上的一块 buffer,或者是 new 出来的一块堆内存,这种情况就只能使用 placement new 搭配显式调用析构函数的方法。C++ 标准库一直不提供与原位构造和销毁语义对应的更通用的机制,但是当 C++ 17 要提供 std::optional 和 std::variant 的时候,终于意识到,标准库也遇到了需要管理非标准内存分配器所产生的内存缓冲区的问题,就像 std::optional 那样会用到用户自己的内部内存缓冲区[资料 1]。这是个无法回避的问题,于是 C++ 17 准备同步提供 construct_at 和 destroy_at 这样的函数。
因为 construct_at() 需要考虑的因素更多,比如编译期常量函数需要 new 和 delete 支持在编译期运行的能力,所以 C++ 17 本着优先原则,先提供了几个 destroy 函数。首先是 std::destroy_at() 函数,用于支持单个对象的原位销毁(C++ 20 扩展支持数组)。std::destroy_at() 函数可用于对正常对象调用析构函数,比如:
Foo foo;
std::destroy_at(std::addressof(foo));; //相当于 foo.~Foo();
也可用于在某个内存 buffer 上原位构造的对象的销毁,比如:
char buf[sizeof(Foo)];
::new (buf) Foo();
std::destroy_at((Foo *)buf); //相当于 ((Foo *)buf)->~Foo();
因为 std::destroy_at() 函数模板需要根据指针类型推导对象类型,从而正确调用对象的析构函数,所以需要将 buf 地址强转成对象类型的地址。
C++ 17 中的 std::destroy_at() 函数只能销毁内存地址上的单个对象,如果要销毁连续多个对象,可以考虑使用 std::destroy_n() 和 std::destroy() 。std::destroy_n() 可以从给定的起始位置开始,连续进行 n 次销毁动作,每次都进行相应的地址偏移。比如这个例子:
Base ab[8]; // 8 个默认构造的对象
std::destroy_n(ab, 8); // 8 次析构动作
其实 std::destroy_n() 函数的第一个参数是迭代器类型,只要支持向前遍历和 * 提领对象自身,就可以使用 std::destroy_n() 函数。标准库中的迭代器都支持这两个基本操作,比如 vector:
std::vector<Base> vb(8);
std::destroy_n(vb.begin(), 8);
std::destroy() 函数则更一般化,它支持在一组迭代器范围内的对象上做销毁动作:
std::vector<Base> vb(8);
std::destroy(vb.begin(), vb.end());
C++ 17 增加了对并行处理机制的支持,parallelism 技术验证库被证实合入标准。算法库中的很多算法都开始支持带有并行执行策略的算法,std::destroy_n() 和 std::destroy() 函数也提供了带并行策略的版本,在处理大量对象的销毁时,使用适当的并行策略,可以获得相应的性能提升。标准库中支持四种执行策略,分别是:sequenced_policy、parallel_policy、parallel_unsequenced_policy 和 unsequenced_policy。当需要销毁的对象数量比较多的时候, 并且对象的销毁可并行处理的时候,使用 parallel_policy 或 parallel_unsequenced_policy 可以显著提升性能。比如这个例子:
std::vector<Base> vb(800000);
std::destroy_n(std::execution::par, vb.begin(), 800000);
在我的系统上粗略测试了一下,相比串行执行的版本,使用并行策略的执行实现普遍减少 75% - 85% 左右。当然,对象数量比较少的情况不建议使用并行策略,有时候反而会适得其反。
3 C++ 20 的演进
3.1 std::destroy_at 支持数组
C++ 20 之前,std::destroy_at() 只会销毁指针指向的对象,但是从 C++ 20 开始,如果根据指针推导出的函数模板参数 T 是数组类型,std::destroy_at() 会顺序销毁 *p 所指的每个对象。根据 std::destroy_at() 的函数原型:
template< class T >
void destroy_at( T* p );
若要 T 被推导为数组类型,则 p 必须是数组的地址,而不是数组元素的首地址。所以,下面的例子代码中两次调用 std::destroy_at() 函数的效果是不一样的:
Foo fs[8];
std::destroy_at(fs); //#1
std::destroy_at(&fs); //#2
第一行调用 std::destroy_at() 函数时,参数 fs 是数组类型,根据推导规则会先退化成指针,也就是 Foo * 类型的指针,因此推导出的 T 类型是 Foo,不是数组类型,所以函数调用的结果就是只有 fs 中的第一个对象被销毁。第二行调用 std::destroy_at() 函数时,参数类型是 Foo(*)[8],它是个指向数组的指针,不是数组,所以就不退化了,那么直接匹配的结果就是推导出 T 的类型是 Foo[8],所以函数调用的结果就是 fs 数组中的 8 个对象都被销毁。
看看,坑已经挖好了,大家一起来跳啊。当在泛型代码中使用 std::destroy_at() 函数的时候,一定要对指针可能的实际类型有清醒的认识,必要时,可借助 std::is_array 这个类型特征做些判断。
3.2 常量函数
从 C++ 20 开始,std::destroy() 系列函数都是常量函数,这意味着它们可以出现在其他常量函数中,也就是编译期能搞的事情更多了。比如这个 Point 类型是自定义的字面类型,它可以出现在常量函数中。std::destroy() 和 std::construct() 可以配合自定义的字面类型在常量函数中实现更多的编译期计算功能,比如这个测试例子:
struct Point {
int x, y;
constexpr Point(int x, int y) : x(x), y(y) {}
};
constexpr auto create_and_destroy() {
Point p(1, 2);
std::destroy_at(&p);
std::construct_at(&p, 5, 6);
int sum = p.x + p.y;
return sum;
}
static_assert(create_and_destroy() == 11); // 编译期验证
3.3 range 库
C++ 20 的 ranges 库也提供了一组对应的 destroy 函数,提供更现代化的接口,更适用与在一个范围上进行操作。比如 std::ranges::destroy() 函数支持由迭代器和哨兵位定义的一个 range 上销毁对象,比简单的迭代器组更灵活。而 std::ranges::destroy() 函数可直接使用容器或数组做参数,比如:
std::vector<Foo> items = {{1}, {2}, {3}};
// 析构容器中的所有对象
std::ranges::destroy(items);
相对来说,比使用迭代器组更简单,也能更好地避免迭代器范围重叠导致的错误。
4 其他
destroy() 函数的使用都涉及取对象的地址,在使用时建议使用 std::addressof() 函数取对象的地址,而不是简单使用取地址运算符,因为取地址运算符经常被重载为自定义版本,某些情况下可能返回的不是对象自身的实际地址。关于这一点,我们在《C++ 的 addressof() 函数》一篇有具体的介绍,这里就不啰嗦了。
还有就是注意 std::destroy_at() 函数在 C++ 20 的变化,尤其是确定要销毁整个数组内的每个对象的时候,不要传错指针参数,重点关注 3.1 节的说明。
参考资料
[1] ISO/IEC 14882:2017:Memory management library
[2] Bartłomiej Filipek. C++17 In Detail. Leanpub. 2019
[3] Jacek Galowicz. C++17 STL Cookbook. Packtpub. 2017
[4] https://en.cppreference.com/w/cpp/memory/destroy_n
[5] https://en.cppreference.com/w/cpp/memory/destroy_at
[6] http://eel.is/c++draft/memory#specialized.destroy-1
[7] https://en.cppreference.com/w/cpp/algorithm/execution_policy_tag_t
[8] Nicolai M. Josuttis, C++20 - The Complete Guide, http://leanpub.com/cpp20’
[9] https://en.cppreference.com/w/cpp/memory/allocator/destroy
关注作者的算法专栏
https://blog.csdn.net/orbit/category_10400723.html
关注作者的出版物《算法的乐趣(第二版)》
https://www.ituring.com.cn/book/3180