C++资源管理

对Effective C++在资源管理部分的内容进行总结。

C++运行时内存

C++运行时内存主要分为data、bss(Block Started by Symbol)、stack、heap和常量区(包含init、text和rodata段)。其中bss段存储程序中被初始化的变量(全局变量和static local),它们在程序运行开始前会被初始化为0,对应到C++中就是做了zero initialization,详见我的博文。这么做的目的是为了优化可执行文件的大小,考虑到我们未给这些变量赋值,所以他们在可执行文件并不存在实体,所谓的bss段在可执行文件中只是一个placeholder。data段则相反,存储程序中已经被初始化的变量。stack中的变量由编译器自动分配和清除,所以称作自动变量。heap区由new调用构造函数初始化,由delete调用析构函数回收。在部分文章中还将heap进一步细化出一个自由存储区,指由C系函数malloc等分配并由free等释放的内存。

1
2
3
4
5
6
7
8
// Demo in https://stackoverflow.com/questions/30838144/what-all-local-variables-goto-data-bss-segment
void foo(void)
{
static char array1[256] = ""; // Goes in BSS, probably
static char array2[256] = "ABCDEFXYZ"; // Goes in Data
static const char string[] = "Kleptomanic Hypochondriac"; // Goes in Text, probably
...
}

RAII

可以看出对于动态分配的内存(堆和自由存储)必须能够在适当时机调用delete/free进行释放,否则会造成泄露,也有可能某处代码在先前已经delete/free了,造成悬空指针undefined behaviour。当然可以建立一张表(称为对象池)登记这些指针,当满足一些条件的时候进行删除的手动管理。一个Best Practice是通过RAII借助栈来管理对象。

复制

一般对象之间的复制行为分为4种:

  1. 浅复制:浅复制也是默认复制构造函数的实现,将源对象中的成员复制到新的对象中。因此如果源对象中存在指针,那么实际上源对象和新对象是共享指针指向的对象的,这并不是一个错误的逻辑,但是问题在于新老对象都没有意识到自己和别的对象共享着资源,如果存在析构函数(除非使用手动管理,否则必然要有析构函数用来释放指针指向对象),那么必然会造成悬空指针。
  2. 深复制:需要自定义复制构造函数,在复制行为发生时递归地建立对象成员以及指针指向对象的副本。
  3. 资源控制权转移:资源占用具有排他性,这种有点类似于Rust的语义,其实应当作为移动语义来看。常见的有std::unique_ptr来维护这种语义。
  4. 资源控制权共享:对应起来看就是std::shared_ptr,但这种方案解决了资源释放的问题,对于需要共享的资源设置引用计数,当引用计数变为0时销毁对象,而不再通过构造函数。

深复制

在深复制中可能存在一个问题,假设有若干个派生类继承基类Derived_i : public Base,现在有一个Base * d,但是不知道具体类型,现在希望对这个基类进行深复制。直接调用基类的复制构造函数Base p = new Base(d)显然是行不通的,这是因为C++标准规定了复制构造函数,包括构造函数都不能是虚的。StackOverflow上详细地说明了原因,这是因为C++是静态类型的,所以多态地创建对象没有意义的。常用的办法是自己定义一个clone函数。同样地,对于其他的构造函数,也是不存在多态的,因此如果需要对于不同的参数返回不同的派生类的指针,可以通过定义一个工厂函数来解决。当然对于非继承的类型,当然可以定义三个函数(复制、赋值、析构)进行深复制,假设复制对象obj,可以认为复制了一棵树,树中任何节点(成员)的析构都会导致该节点为树根的子树被删除,所以对于这棵树只能修改树根或叶子,否则会造成内存泄露。

复制构造函数和赋值构造函数

复制构造函数指的形如T(const T &)的构造函数,涉及到C++的复制初始化。其中复制构造函数常被定义为explicit的,此时必须显式地使用该构造函数。在C++11标准之后扩大了范围,将非explicit构造函数都称为转换构造函数。这里要区分转换构造函数T::T(const U &)和类型转换运算符operator T::U(),前者是从U构造T,后者是从自己T构造U。类型转换运算符有很多的作用,常见的是实现将函数返回值加入重载决议。
赋值运算符指的形如T & operator=(const T &)的运算符,指的是赋值而不是初始化操作。