c++拷贝控制和资源管理

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

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

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

EX.标准库容器和string类的行为像一个值。shared_ptr类提供类似指针的行为。IO类型和unique_ptr不允许拷贝或赋值,因此它们的行为既不像值也不像指针。

一、行为像值的类

为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。

1
2
3
4
5
6
7
8
9
10
11
12

class HasPtr
{
public:
HasPtr(const std::string &s = std::string()):ps(new std::string(s)),i(0){}
HasPtr(const HasPtr &hp):ps(new std::string(*hp.ps)),i(hp.i){}
HasPtr& operator= (const HasPtr &);
~HasPtr(){delete ps;}
private:
std::string *ps;
int i;
};

定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针

定义一个析构函数来释放string

定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝stirng

类值拷贝复制运算符

赋值运算符通常组合了析构函数和构造函数的操作。会销毁左侧运算对象的资源;会从右侧运算对象拷贝数据。这些操作要按照正确的顺序执行。

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
//错误代码
HasPtr& HasPtr::operator= (const HasPtr &rhs)
{
delete ps;
ps = new string(*(rhs.ps))
i = rhs.i;
return *this;
}

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

如果未定义析构函数,合成版本就不会释放动态内存,会发生内存泄漏。

二、定义行为像指针的类
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。
令一个类展现指针的行为的最好的方法是使用shared_ptr来管理类的资源。拷贝一个shared_ptr会拷贝shared_ptr指向的指针。shared_ptr类自己记录有多少用户共享它所指向的对象。当没有用户使用对象时,shared_ptr类负责释放资源。

引用计数

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

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

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),use(new 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的成员
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
HasPtr::~HasPtr()
{
if(--*use==0)
{
delete ps;
delete use;
}
}

HasPtr& operator=(const HasPtr& rhs)
{
++*rhs.use; //递增右侧运算对象的引用计数,避免自赋值时发生错误
if(--*use==0)
{
delete ps;
delete use;
}

ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}

三、交换操作

对于那些与重排元素顺序的算法一起使用的类,定义swap是非常重要的。这类算法交换两个元素时会调用swap。

如果一个类定义了自己的swap,那么算法讲使用类自定义版本。否则,算法将使用标准库定义的swap。

交换两个类值HasPtr对象的代码可能像下面这样:

1
2
3
HasPtr temp = v1;	//创建v1的值的一个临时副本,分配新的内存空间
v1 = v2; //将v2的值赋予v1
v2 = temp; //将保持的v1的值赋予v2

上面的操作涉及到了string的两次拷贝,一次赋值。可以进行简化。我们更希望swap交换指针,而不是分配string的新副本。

1
2
3
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;

编写自己的swap函数,来重载swap的默认swap。

1
2
3
4
5
6
7
8
9
10
class HasPtr{
friend void swap(HasPtr&, HasPtr&);
//其它成员定义
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, rhs.ps); //交换指针,而不是string数据
swap(lsh.i, rhs.i); //交换int成员
}

swap函数应该调用swap,而不是std::swap。上面的例子中,数据成员是内置类型的,没有特定版本的swap,会调用标准库std::swap。

如果一个类的成员有自己类型特定的swap函数,调用std::swap就是错误的。

假定一个命名为Foo的类,它有一个类型为HasPtr的成员h。如果我们未定义Foo版本的swap,那么就会使用标准库版本的swap。标准库swap对HasPtr管理的string进行了不必要的拷贝。
我们需要为Foo编写一个swap函数,来避免这些拷贝。

1
2
3
4
5
6
void swap(Foo &lhs, Foo &rhs)
{
//错误:这个函数使用标准库版本的swap,而不是HasPtr版本
std::swap(lhs.h, rhs.h);
//交换类型Foo的其他成员
}
1
2
3
4
5
6
void swap(Foo &lhs, Foo &rhs)
{
using std::swap;
swap(lhs.h, rhs.h);
//交换类型Foo的其他成员
}

问题??为什么using声明没有隐藏HasPtr版本swap的声明。

在赋值运算符中使用swap

1
2
3
4
5
HasPtr& HasPtr::operator=(HasPtr rhs)	//传值,非传引用
{
swap(*this, rhs);
return *this; //rhs被销毁,从而delete了rhs中的指针
}

这个技术的有趣之处是它自动处理了自赋值情况且天然就是异常安全的。