C++中的拷贝控制与资源管理

概述

管理类外资源的类必须定义拷贝控制成员,这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它也几乎需要拷贝构造函数和拷贝赋值运算符。
为了定义这些成员,首先必须确定此类型对象的拷贝语义。一般来说有两种选择:可以定义拷贝操作使类的行为看起来像一个值或者一个指针。

行为像值的类。

类的行为像一个值,意味着它应该也有自己的状态。当拷贝一个像值的对象时,副本和原对象时完全独立的。改变其中一个不会对另一个产生任何影响。
用一个例子来说明问题:

  • 定义一个拷贝构造函数,完成string的拷贝,而不是指针的拷贝。
  • 定义一个析构函数,释放string。
  • 定义一个拷贝赋值运算符来释放当前对象的string,并从右侧运算对象拷贝string。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class HasPtr{
public:
//带参构造函数
HasPtr(const string &s = string()):ps(new string(s)),i(0){ }
//拷贝构造函数,对ps指向的string,每一个HasPtr对象都有一份自己的拷贝。
//重点关注ps的初始化值(new string(s))
HasPtr(const HasPtr &p):ps(new string(*p.ps)),i(p.i){ }
//拷贝赋值运算符
HasPtr &operator=(const HasPtr &);
~HasPtr(delete ps);
private;
string *ps;
int i;
};

赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧对象拷贝数据。需要注意的使用,即使一个对象拷贝自身,也应该保证正确。一种典型做法是先拷贝右侧运算对象,这样可以处理自赋值的情况并保证异常发生时也是安全的,然后释放左侧运算对象的资源,并更新指针指向新分配的string

1
2
3
4
5
6
7
HasPtr & HasPtr::operator=(const HasPtr &p){
auto newp = new string(*p.ps);
delete ps; //释放旧内存
ps = newp; //从右侧运算对象拷贝数据到本对象
i = p.i;
return *this; //返回本对象
}

总结下,编写赋值运算符时需要注意两点:

  1. 如果一个对象赋予它自身,赋值运算符必须能正确工作。
  2. 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。

如果没有先拷贝右侧对象,直接delete ps会释放*this和p指向的同一个string,下面的ps = new string(*(p.ps))会访问到无效内存,产生未定义的结果。

行为像指针的类

行为像指针的类即共享状态。当拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变其中一个会影响到另一个。
还是用上面的例子,需要定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。析构函数也不能单方面的释放关联的string,因为可能有多个对象指向同一块内存,必须当最后一个指向string的HasPtr销毁时才可以释放string,否则会导致同一块内存释放多次。

可以使用引用计数来管理类中的资源,其工作方式为:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还有创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当创建一个对象时,只有一个对象共享状态,因此将其初始化为1。
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器,然后递增共享的计数器,指出给定对象的状态又被一个新用户共享。
  • 析构函数递减计数器,当减为0时,表明没有对象共享状态,可以释放资源。
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器,如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
    计数器一般保存在动态内存中。当创建一个对象时,分配一个新的计数器;当拷贝或赋值对象时,拷贝指向计数器的指针,这样副本对象和原对象就会指向相同额计数器。
1
2
3
4
5
6
7
8
9
10
11
12
13
class HasPtr{
public:
HasPtr(const string &s = string()):ps(new string(s)),i(0),use(new size_t(1)){ }
//拷贝三个数据成员,递增计数器。
//注意ps的初始化,与值行为的拷贝不同
HasPtr(const HasPtr &p):ps(p.ps),i(p.i),use(p.use){ ++*use;}
HasPtr &operator=(const HasPtr&);
~HasPtr();
private:
string *ps;
int i;
size_t *use; //用来记录有多少个对象共享*ps成员
};

当拷贝或赋值一个HasPtr对象时,我们希望副本和原对象都指向相同的string。即拷贝ps本身,而不是ps指向的string。use成员的递增表示ps和p.ps指向的string又有了一个新的用户。

析构函数不能无条件的delete ps。因为可能还有其他对象指向这块内存。应该递减计数器,当值为0时,表示没有对象再指向该内存了,此时可以delete。

1
2
3
4
5
HasPtr::~HasPtr(){
if(--*use == 0)
delete ps;
delete use;
}

拷贝赋值运算符一样执行析构和构造的操作,递增右侧运算对象的计数器,递减左侧运算对象的计数器,在必要时释放内存。
为了处理自赋值,可以先递增右侧运算对象的计数器,递减左侧运算对象的计数器来达到这目的。通过使用这种方法,当两个对象相同时,在检查ps是否应该释放之前,计数器就已经被递增过了:

1
2
3
4
5
6
7
8
9
10
11
HasPtr & HasPtr:: operator=(const HasPtr &p){
++*p.use;
if(--*use == 0){
delete use;
delete ps;
}
i = p.i;
ps = p.ps;
use = p.use;
return *this;
}


参考资料:
《C++ primer 5th》