贝控制和资源管理
通常,管理类外资源的类必须定义拷贝控制成员,这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它儿乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。
为了定义这些成员,我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择,可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个类值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
在我们使用过的标准库类中,标准库容器和string类的行为像一个值。而不出意外的,shared_ptr类提供类似指针的行为,就像我们的StrBlob类一样,IO类型和unique_ptr不允许拷贝或赋值,因此它们的行为既不像值也不像指针。
为了说明这两种方式,我们会为练习中的HasPtr类定义拷贝控制成员。首先,我们将令类的行为像一个值;然后重新实现类,使它的行为像一个指针。
我们的HasPtr类有两个成员,一个int和一个string指针。通常,类直接拷贝内置类型(不包括指针)成员;这些成员本身就是值,因此通常应该让它们的行为像值一样。我们如何拷贝指针成员决定了像HasPtr这样的类是具有类值行为还是类指针行为。
行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。这意味着对于ps指向的string,每个HasPtr对象都必须有自己的拷贝。为了实现类值行为,HasPtr需要
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
- 定义一个析构函数来释放string
- 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string
类值版本的HasPtr如下所示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
我们的类足够简单,在类内就已定义了除赋值运算符之外的所有成员函数。第一个构造函数接受一个(可选的)string参数。这个构造函数动态分配它自己的string副本,并将指向string的指针保存在ps中。拷贝构造函数也分配它自己的string副本。析构函数对指针成员ps执行delete,释放构造函数中分配的内存。
类值拷贝赋值运算符
赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。但是,非常重要的一点是,这些操作是以正确的顺序执行的,即使将一个对象赋予它自身,也保证正确。而且,如果可能,我们编写的赋值运算符还应该是异常安全的一一当异常发生时能将左侧运算对象置于一个有意义的状态。
在本例中,通过先拷贝右侧运算对象,我们可以处理自赋值情况,并能保证在异常发生时代码也是安全的。在完成拷贝后,我们释放左侧运算对象的资源,并更新指针指向新分配的string:
1 2 3 4 5 6 7 8 |
|
在这个赋值运算符中,非常清楚,我们首先进行了构造函数的工作:newp的初始化器等价于HasPtr的拷贝构造函数中ps的初始化器。接下来与析构函数一样,我们delete当前ps指向的string。然后就只剩下拷贝指向新分配的string的指针,以及从rhs拷贝int值到本对象了。
关键概念:赋值运算征
当你编写赋值运算符时:有两点需要记住:
- 如果将一个对象赋予它自身,贼值运算待必须能正确工作。
- 天多数赋值运算符组各了析构函数和拷贝构造函数的工作。
当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象赋值到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
为了说明防范自赋值操作的重要性,考虑如果赋值运算符如下编写将会发生什么
1 2 3 4 5 6 7 8 9 10 |
|
如果zhs和本对象是同一个对象,delete ps会释放this和rhs指向的string。接下来,当我们在new表达式中试图拷贝(rhs.ps)时,就会访问一个指向无效内存的指针,其行为和结果是未定义的。
这样我们的StrBlobPtr类就仍能使用指向vector的weak_ptr了。你修改后的类将需要一个拷贝构造函数和一个拷贝赋值运算符,但不需要析构函数。解释拷贝构造出数和拷贝赋值运算符必须要做什么。解释为什么不需要析构函数。
定义行为像指针的类
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。我们的类仍然需要自己的析构函数来释放接受string参数的构造函数分配的内存。但是,在本例中,析构函数不能单方面地释放关联的string。只有当最后一个指向string的HasPtr销毁时,它才可以释放string。
令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。拷贝(或赋值)一个shared_ptr会拷贝(赋值)shared_ptr所指向的指针。
shared_ptr类自己记录有多少用户共享它所指向的对象。当没有用户使用对象时,shared_ptr类负责释放资源。
但是,有时我们希望直接管理资源。在这种情况下,使用引用计数(reference count)就很有用了。为了说明引用计数如何工作,我们将重新定义HasPtr,令其行为像指针一样,但我们不使用shared_ptr,而是设计自己的引用计数。
引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。“析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。