【C++基础】第七十课:[拷贝控制]拷贝控制和资源管理

行为像值的类,行为像指针的类

Posted by x-jeff on May 2, 2023

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员。如前所述,这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。

为了定义这些成员,我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。

类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。

行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。

在我们使用过的标准库类中,标准库容器和string类的行为像一个值。而不出意外的,shared_ptr类提供类似指针的行为,就像我们的StrBlob类一样,IO类型和unique_ptr不允许拷贝或赋值,因此它们的行为既不像值也不像指针。

为了说明这两种方式,我们会为HasPtr类定义拷贝控制成员。首先,我们将令类的行为像一个值;然后重新实现类,使它的行为像一个指针。

我们的HasPtr类有两个成员,一个int和一个string指针。通常,类直接拷贝内置类型(不包括指针)成员;这些成员本身就是值,因此通常应该让它们的行为像值一样。我们如何拷贝指针成员决定了像HasPtr这样的类是具有类值行为还是类指针行为。

2.行为像值的类

为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。这意味着对于ps指向的string,每个HasPtr对象都必须有自己的拷贝。为了实现类值行为,HasPtr需要:

  • 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针。
  • 定义一个析构函数来释放string。
  • 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string。

类值版本的HasPtr如下所示:

1
2
3
4
5
6
7
8
9
10
11
class HasPtr {
public:
	HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) { }
	//对ps指向的string,每个HasPtr对象都有自己的拷贝
	HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) { }
	HasPtr& operator=(const HasPtr &);
	~HasPtr() { delete ps; }
private:
	std::string *ps;
	int i;
};

2.1.类值拷贝赋值运算符

赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。但是,非常重要的一点是,这些操作是以正确的顺序执行的,即使将一个对象赋予它自身,也保证正确。而且,如果可能,我们编写的赋值运算符还应该是异常安全的——当异常发生时能将左侧运算对象置于一个有意义的状态。

在本例中,通过先拷贝右侧运算对象,我们可以处理自赋值情况,并能保证在异常发生时代码也是安全的。在完成拷贝后,我们释放左侧运算对象的资源,并更新指针指向新分配的string:

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

为了说明防范自赋值操作的重要性,考虑如果赋值运算符如下编写将会发生什么:

1
2
3
4
5
6
7
8
9
//这样编写赋值运算符是错误的!
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	delete ps; //释放对象指向的string
	//如果rhs和*this是同一个对象,我们就将从已释放的内存中拷贝数据!
	ps = new string(*(rhs.ps));
	i = rhs.i;
	return *this;
}

如果rhs和本对象是同一个对象,delete ps会释放*this和rhs指向的string。接下来,当我们在new表达式中试图拷贝*(rhs.ps)时,就会访问一个指向无效内存的指针,其行为和结果是未定义的。

3.定义行为像指针的类

对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。我们的类仍然需要自己的析构函数来释放接受string参数的构造函数分配的内存。但是,在本例中,析构函数不能单方面地释放关联的string。只有当最后一个指向string的HasPtr销毁时,它才可以释放string。

令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。拷贝(或赋值)一个shared_ptr会拷贝(赋值)shared_ptr所指向的指针。shared_ptr类自己记录有多少用户共享它所指向的对象。当没有用户使用对象时,shared_ptr类负责释放资源。

但是,有时我们希望直接管理资源。在这种情况下,使用引用计数(reference count)就很有用了。为了说明引用计数如何工作,我们将重新定义HasPtr,令其行为像指针一样,但我们不使用shared_ptr,而是设计自己的引用计数。

3.1.引用计数

引用计数的工作方式如下:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
  • 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。

唯一的难题是确定在哪里存放引用计数。计数器不能直接作为HasPtr对象的成员。下面的例子说明了原因:

1
2
3
HasPtr p1("Hiya!");
HasPtr p2(p1); //p1和p2指向相同的string
HasPtr p3(p1); //p1、p2和p3都指向相同的string

如果引用计数保存在每个对象中,当创建p3时我们应该如何正确更新它呢?可以递增p1中的计数器并将其拷贝到p3中,但如何更新p2中的计数器呢?

解决此问题的一种方法是将计数器保存在动态内存中。当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器。

3.2.定义一个使用引用计数的类

通过使用引用计数,我们就可以编写类指针的HasPtr版本了:

1
2
3
4
5
6
7
8
9
10
11
12
13
class HasPtr {
public:
	//构造函数分配新的string和新的计数器,将计数器置为1
	HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0), use(new std::size_t(1)) { }
	//拷贝构造函数拷贝所有三个数据成员,并递增计数器
	HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }
	HasPtr& operator=(const HasPtr&);
	~HasPtr();
private:
	std::string *ps;
	int i;
	std::size_t *use; //用来记录有多少个对象共享*ps的成员
};

3.3.类指针的拷贝成员“篡改”引用计数

当拷贝或赋值一个HasPtr对象时,我们希望副本和原对象都指向相同的string。即,当拷贝一个HasPtr时,我们将拷贝ps本身,而不是ps指向的string。当我们进行拷贝时,还会递增该string关联的计数器。

(我们在类内定义的)拷贝构造函数拷贝给定HasPtr的所有三个数据成员。这个构造函数还递增use成员,指出ps和p.ps指向的string又有了一个新的用户。

析构函数不能无条件地delete ps——可能还有其他对象指向这块内存。析构函数应该递减引用计数,指出共享string的对象少了一个。如果计数器变为0,则析构函数释放ps和use指向的内存:

1
2
3
4
5
6
7
HasPtr::~HasPtr()
{
	if(--*use == 0) { //如果引用计数变为0
		delete ps; //释放string内存
		delete use; //释放计数器内存
	}
}

拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作。即,它必须递增右侧运算对象的引用计数(即,拷贝构造函数的工作),并递减左侧运算对象的引用计数,在必要时释放使用的内存(即,析构函数的工作)。

而且与往常一样,赋值运算符必须处理自赋值。我们通过先递增rhs中的计数然后再递减左侧运算对象中的计数来实现这一点。通过这种方法,当两个对象相同时,在我们检查ps(及use)是否应该释放之前,计数器就已经被递增过了:

1
2
3
4
5
6
7
8
9
10
11
12
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	++*rhs.use; //递增右侧运算对象的引用计数
	if (--*use == 0) { //然后递减本对象的引用计数
		delete ps; //如果没有其他用户
		delete use; //释放本对象分配的成员
	}
	ps = rhs.ps; //将数据从rhs拷贝到本对象
	i = rhs.i;
	use = rhs.use;
	return *this; //返回本对象
}