拷贝控制和资源管理
通常,管理类外资源的类必须定义拷贝控制成员。为了定义这些成员,我们首先确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或像一个指针。
类的行为像一个值,意味着它有自己的状态 (如 string 类)。当我们拷贝一个像值的对象时,副本和原对象完全独立。变副本不会影响原对象,反之亦然。
**行为像指针的类则共享状态 **(如 shared_ptr 类)。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本会改变原对象,反之亦然。
当然,也存在既不像值也不像指针的类。如 IO 类,unique_str 等。因为它们都不支持拷贝或赋值。
这里,我们将实现一个 HasPtr 类,首先将令其行为像一个值。然后重新实现,使它的行为像一个指针。
HasPtr 类有两个成员,一个 int 和一个 string 指针。通常,类直接拷贝内置类型 (不包括指针) 成员;这些成员本身就是值,因此通常应该让它们的行为像值一样。我们如何拷贝指针成员决定了像 HasPtr 这样的类是具有类值行为还是类指针行为。
行为像值的类(如 string)
这里将 HasPtr 作为类值行为来实现,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。所以,类值版本的 HasPtr 如下:
class HasPtr {
public:
HasPtr(const std::string &s = std::string()) :
ps(new std::string(s)), i(0) {}
// 对 ps 指向的 string,每个 HasPtr 对象都有自己的拷贝,所以我
// 们拷贝构造函数拷贝的是 string,并不是拷贝指针
HasPtr(const HasPtr &sour):
ps(new std::string(*sour.ps)), i(sour.i) { }
HasPtr &operator=(const HasPtr &sour);
~HasPtr() { delete(ps); }
private:
std::string *ps;
int i;
};
其次,析构函数肯定是必要的,因为我们要释放 ps 指向的动态内存。由于定义了析构函数,我们肯定会定义拷贝构造函数与拷贝赋值运算符。
类值拷贝赋值运算符
我们知道,拷贝赋值运算符即 =,将右侧对象拷贝到左侧对象。而且,拷贝赋值运算符会销毁左侧运算对象的资源,所以对于对象中含动态内存时,我们需要手动释放。同时,我们应该支持当一个对象赋予它自身时,赋值运算符也应该能正常执行。这两点很重要,都是易忽略的点。
-
赋值运算符重载 错误版本一:
HasPtr& HasPtr::operator=(const HasPtr &sour) { ps = new std::string(*(sour.ps)); i = sour.i; return *this; }
**分析:**很显然,这里的函数并没有销毁左侧运算对象所拥有的 string 类型指针指向的动态内存。会造成内存泄漏。
-
赋值运算符重载 错误版本二:
HasPtr& HasPtr::operator=(const HasPtr &sour) { delete(ps); // 释放左侧对象指向的 string // 如果 sour 和 *this 是同一对象,我们就将从已释放的内存中拷贝数据!!! ps = new std::string(*sour.ps); i = sour.i; return *this; }
**分析:**我们可以看见,代码中第一个便是 delete,解决了版本一的问题。但是当我们的右侧运算对象与左侧运算对象是同一个时,我们就会从已释放的内存中拷贝数据!!!这是显然错误的。
-
正确的赋值运算符重载:
HasPtr& HasPtr::operator=(const HasPtr &sour) { auto newp = new std::string(*(sour.ps)); // 拷贝底层 string delete(ps); // 释放旧内存 ps = newp; // 从右侧运算对象拷贝到本对象 i = sour.i; return *this; // 返回本对象 }
**分析:**从代码中可以看出,我们首先拷贝了右侧运算对象,这样可以处理自赋值的情况,并能保证在异常发生时代码也是安全的。完成拷贝后,我们再释放左侧运算对象的资源,然后从右侧运算对象拷贝到左侧运算对象。
当重载赋值运算符时,需要注意两点:
- 拷贝赋值运算符会销毁左侧运算对象的资源,所以对于对象中含动态内存时,我们需要手动释放。
- 我们应该支持当一个对象赋予它自身时,赋值运算符也应该能正常执行。
所以,好的方法是在销毁左侧对象之前先拷贝右侧运算对象。
所有代码见同目录HasPtr_likeValue.cc文件
定义行为像指针的类(如 shared_ptr)
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它所指向的对象 string。
我们的类仍然需要自己的析构函数来释放接受 string 参数的构造函数分配的内存。当然,我们不能简单的直接释放,因为有可能还有其他的对象也指向这个内存。所以这种时候,我们使用引用计数来解决这个问题。(可以发现,这个类的行为甚至有一点像 shared_ptr)
引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)都要创建一个引用计数。当我们创建一个对象时,引用计数初始化为1。
- 拷贝构造函数不分配新的计数器。显然它应该是递增共享的计数器。
- 析构函数递减计数器。如果计数器变为 0,则释放资源
- 拷贝赋值运算符递增右侧对象计数器,递减左侧对象计数器。如果左侧对象计数器变为 0,则拷贝赋值运算符就必须销毁状态。
唯一的难题就是确定哪里存放引用计数。因为计数器不能直接作为 HasPtr 的成员。下面例子说明了原因:
HasPtr p1("hihi");
HasPtr p2(p1);
HasPtr p3(p1);
当我们创建 p3 时,可以很简单的更新 p1 与 p3 中的计数器,但是 p2 如何更新呢?
解决的唯一方法就是将计数器保存在动态内存中。当创建一个对象时,我们分配一个新的计数器。当拷贝或者赋值对象时,我们拷贝指向计数器的指针。
定义一个使用引用计数的类
通过引用计数,我们可以编写类指针的 HasPtr 版本:
class HasPtr_Point {
private:
std::string *ps;
int i;
std::size_t *use; // 用来记录有多少个对象共享 *ps 成员
public:
HasPtr_Point() : ps(new std::string()), i(0), use(new std::size_t(1)) {}
HasPtr_Point(const HasPtr_Point &sour) :
ps(sour.ps), i(sour.i), use(sour.use) { ++*use; }
HasPtr_Point &operator=(const HasPtr_Point &);
~HasPtr_Point();
};
类指针的拷贝成员“篡改”引用计数
当赋值或拷贝一个 HasPtr 对象时,我们希望副本和原对象都指向相同的 string。即,当拷贝一个 HasPtr 时,我们将拷贝 ps 本身,而不是 ps 指向的 string。当我们进行拷贝时,还会递增该 string 关联的计数器。
同样的,析构函数不能无条件的 delete ps,因为可能还有其他对象指向这块内存。析构函数应该递减引用计数,指出共享 string 的对象有多少个。如果计数器变为 0,则析构函数释放 ps 和 use 指向的内存:
HasPtr_Point::~HasPtr_Point() {
if (-- *use == 0) {
delete (use);
delete (ps);
}
}
对于拷贝赋值运算符,我们需要解决相同的问题。
在这里,我们就不需要拷贝右侧对象了,我们可以先递增右侧对象的引用计数,再递减左侧对象的引用计数。这样做,即使左右两侧的运算对象是同一对象,也不会出现向已释放的内存拷贝数据的情况。同样,在递减左侧引用计数时,我们相当于是在做“销毁”左侧对象的事情。
HasPtr_Point &HasPtr_Point::operator=(const HasPtr_Point &rhs) {
++*rhs.use; // 先增
if (-- *use == 0) { // 后减。避免左右两侧是同一个对象可能出现的问题。
delete (use);
delete (ps);
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}