拷贝控制
当定义一个类时,我们显示地或隐式地指定此类型对象 拷贝、移动、赋值和销毁时做什么。
一个类通过定义五种特殊的成员函数来控制这些操作,称为拷贝控制操作。
- 拷贝构造函数:用同类型对象初始化时的操作
- 移动构造函数:用同类型对象初始化时的操作
- 拷贝赋值运算符:用同类型对象赋值时的操作
- 移动赋值运算符:用同类型对象赋值时的操作
- 析构函数:此类型对象销毁时的操作
在定义任何C++类时,拷贝控制操作都是必要部分。如果我们不显示定义这些操作,编译器会为我们定义(但常常不是我们想要的)。
一、拷贝 赋值 销毁
1. 拷贝构造函数
如果一个构造函数,第一个参数是自身类型的引用,其他参数都有默认值,则这个构造函数是拷贝构造函数。
由于拷贝构造函数用来初始化非引用类型的参数,拷贝构造函数的第一个参数必须为引用类型,否则将进入死循环(调用拷贝构造函数,非引用形参需拷贝),且几乎都是const修饰的。
class Fancy{
Fancy();
Fancy(const Fancy & …);
};
合成拷贝构造函数
与合成默认构造函数不同,不管我们有没有定义拷贝构造函数,编译器都会为我们合成一个拷贝构造函数。
对于某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象。
一般情况下,合成的拷贝构造函数会将给定对象中每个非static成员依次拷贝到正在创建的对象中。对类类型成员,使用拷贝构造函数来拷贝;对内置类型成员则直接拷贝。
拷贝初始化
现在,我们可以完全理解 直接初始化 和 拷贝初始化 之间的差异了。
string dots(10,'.'); //直接
string s(dots); //直接
string s2 = dots; //拷贝
string num_bool = "2-4232-454"; //拷贝
string nai = string(10,'2'); //拷贝
直接初始化:要求编译器使用普通的函数匹配,选择与我们提供的参数最匹配的构造函数。
拷贝初始化: 要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要还要进行类型转换。
拷贝初始化通常使用拷贝构造函数来完成。但是如果一个类有移动构造函数,也有可能使用移动构造函数来完成。
那么,拷贝初始化会在何时发生呢?
- 用 = 定义变量时
- 将一个对象作为实参传给非引用类型的形参
- 从返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素
- 某些类类型还会对它们所分配的对象使用拷贝初始化,如初始化标准库容器或调用insert和push时,容器会对其元素进行拷贝初始化。
拷贝初始化的限制
当我们以explicit修饰构造函数时,只能直接初始化,不可拷贝初始化。
vector<int> v1(10); //正确
vector<int> v2 = 10; //错误
void func(vector<int>);
func(10); //错误
func(vector<int>(10)); //正确
vector的接受单一大小参数的构造函数是explicit的。
2. 拷贝赋值运算符
与类控制其对象如何初始化一样,类也可以控制其对象如何赋值。
Person m,f;
m = f; //使用拷贝赋值运算符
重载赋值运算符
重载运算符本质上是一个函数,参数为运算对象。
有些重载运算符(如赋值运算符),需定义为类的成员函数,其左侧运算对象就绑定到隐式的this上。
有些重载运算符(如输入输出运算符),一般定义为类的非成员函数。
class Foo{
public:
Foo& operator=(const Foo&);
};
合成拷贝赋值运算符
若类未定义自己的拷贝赋值运算符,编译器会为它合成一个合成拷贝赋值运算符。
类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。
如果不是出于禁止的目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员。
3. 析构函数
析构函数执行与构造函数相反的工作,构造函数初始化对象的非static数据成员,还可能做一些其他工作。析构函数释放对象使用的资源,并销毁对象的非static数据成员。
class Foo{
public:
~Foo(); //析构函数,无参数无返回值
};
由于析构函数不接受参数,因此不能被重载,一个类的析构函数是唯一的。
析构函数的工作
构造函数:先对成员初始化,再执行函数体,且按成员在类内定义的先后顺序进行初始化。
析构函数:先执行函数体,再销毁成员,成员按初始化的逆序销毁。
销毁类类型的成员需要执行类本身的析构函数,而内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
销毁一个内置指针类型的成员不会删除它所指向的对象,但是与普通指针不同,智能指针是类类型,所以具有析构函数,因此智能指针成员在析构阶段会被自动销毁。
什么时候调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数。
- 变量在离开作用域时
- 当一个对象被销毁时,其成员被销毁
- 容器被销毁时,其元素被销毁
- 对于动态分配内存的对象,当对指向它的指针应用delete运算符时被销毁
- 对于临时对象,当创建完它的完整表达式结束时被销毁
合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。
类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁。
如果不是阻止,合成析构函数的函数体就为空。在空析构函数体执行完成后,成员会被自动销毁。
由此,我们可以看出,析构函数体自身并不直接销毁成员,成员是在析构函数体之外的析构阶段中被销毁的。
4. 三/五法则
如前所述,有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符、析构函数。而且在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。
C++并不要求我们定义所有这些操作,但是这些操作通常看做一个整体,只需要其中一个而不需要定义所有操作的情况是很少见的。
需要析构函数的类也需要拷贝和赋值操作
当我们决定一个类是否要定义自己版本的拷贝控制成员时,一个基本原则是确定这个类是否需要一个析构函数。
通常,对析构函数的需求比对拷贝控制成员的需求成为明显。并且,如果一个类需要析构函数,那么也一定需要拷贝控制成员。
当我们需要自己管理内存时,需要析构函数。例如:类的数据成员中有一个指针,我们释放对象时希望将指针所指对象释放,而合成析构函数不会做到这一点,因此我们需要自定义析构函数。
而对于有了析构函数的类而言,必须要有自定义的拷贝构造函数、拷贝赋值运算符。因为,合成拷贝操作只会进行简单的数据拷贝,在拷贝指针时,会使多个对象指向同一内存,只要进行对象拷贝就极容易出错。
HasPtr func(HasPtr hp){
HasPtr ret = hp;
return ret;
}
在上述代码中,当函数调用结束,会调用hp和ret的析构函数,而析构函数会删除指针所指向的内存,即一块内存要被删除两次,是非常错误的。
合成拷贝构造函数、合成拷贝赋值运算符进行的拷贝是浅拷贝,即只拷贝一级元素的内容,不拷贝子元素的内容。于指针而言,就是只拷贝指针,不拷贝指针所指向的内存。
而我们需要自定义的拷贝构造函数、拷贝赋值运算符需要进行的拷贝是深拷贝,即拷贝所有元素的内容。与指针而言,就是既拷贝指针,又拷贝指针所指向的内存。
需要拷贝操作的类也需要赋值操作,反之亦然
当我们需要自定义拷贝构造函数的时候,也需要自定义拷贝赋值运算符,反之亦然。但是两种情况下,都不一定需要自定义析构函数。
例如:我们需要定义一个类,为类里的每个对象分配一个唯一标识。那么在进行拷贝初始化时,就不可以简单的拷贝对象的唯一标识;同时进行拷贝赋值时,也不可将对象的标识赋值到另一对象。
5. 阻止拷贝
虽然大多数类应该定义拷贝构造函数和拷贝赋值运算符,但对于某些类而言,这些操作没有合理的意义。因此必须组织拷贝或赋值。如:iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。
我们自然而然会想到,不定义拷贝操作来阻止,但是此时编译器会自动生成合成的,因此这是无效的。
定义删除的函数
在新标准下,我们可以通过将拷贝构造函数、拷贝赋值运算符定义为删除的函数来阻止拷贝。
什么是删除的函数呢?我们虽然声明了它们,但不能以任何方式使用它们。
class Person{
Person() = default; //默认构造函数
Person(const Person& ) = delete; //阻止拷贝
Person& operate=(const Person& ) = delete; //阻止赋值
~Person() = default; //默认析构函数
};
=default与=delete有两个不同之处,“=delete”必须出现在函数第一次声明的时候,并且“=delete”可以用于任何函数。
析构函数不能是删除的成员
我们不能删除析构函数,如果析构函数被删除,就无法销毁此类型的对象。对于一个删除了析构函数的类型,编译器不允许定义该类型变量或创建该类的临时对象。
对于删除了析构函数的类型,我们不能定义这种类型的变量,但是可以动态分配这种类型的对象,但是没有办法释放对象。
class Person{
public:
Person() = default;
~Person() = delete;
};
Person p1; //错误,析构函数删除
Person *p2 = new Person(); //正确
delete p2; //错误,析构函数删除
合成的拷贝控制成员可能是删除的
- 如果类的 某个成员的析构函数 是删除的或不可访问的(如private),则类的合成析构函数、合成拷贝构造函数是删除的。
- 如果类的 某个成员的拷贝构造函数 是删除的或不可访问的,则类的合成拷贝构造函数是删除的。
- 如果类的 某个成员的拷贝赋值运算符 是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符是删除的。
- 如果类的 某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显示定义默认构造函数,则该类的默认构造函数被定义为删除的。
本质上,这些规则含义是:如果一个类有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数将被定义为删除的。
二、拷贝控制和资源管理
对于拷贝构造函数和拷贝赋值运算符,一般来说,可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
- 类的行为像一个值:意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的,改变原对象不会改变副本,反之亦然。
- 类的行为像一个指针:意味着共享状态。当我们拷贝一个像指针的对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象,反之亦然。
为了说明这两种方式,我们会为HashPtr类定义这两种不同行为的拷贝控制成员。
HashPtr类有两个成员,一个int和一个string指针。
1. 行为像值的类
为了提供类值的行为,HashPtr需要
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
- 定义一个拷贝赋值运算符,释放对象当前的string,并拷贝新的string
- 定义一个析构函数,释放string
class HasPtr{
int i;
string *ps;
public:
HasPtr(const string &s = string()):ps(new string(s)),i(0){}
HasPtr(const HasPtr &p):ps(new string(*p.ps)),i(p.i){}
HasPtr& operator=(const HasPtr &p);
~HasPtr(){ delete p; }
};
类值拷贝赋值运算符
赋值运算符通常组合了析构函数、构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源;类似构造函数,赋值操作会从右侧运算对象拷贝数据。
HasPtr & HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层string
delete ps; //释放旧内存
ps = newp;
i = rhs.i;
return *this;
}
编写赋值运算符时,有两点需要记住:
- 当一个对象赋予它本身时,是否可以正常工作
大多数赋值运算符组合了析构函数、拷贝构造函数的工作
一个好的模式是,先 将运算符右侧的对象拷贝到临时变量,再 释放左侧对象的现有成员,然后 将临时变量拷贝到左侧对象中。
HasPtr& HasPtr::operator=(const HasPtr& rhs)
{
delete ps;
ps = new string(*rhs.ps);
i = rhs.i;
return this;
//这个函数在拷贝自身时无法正确使用,因为ps指向的内存已被释放
}
2. 定义行为像指针的类
行为像指针的类,在拷贝指针数据成员时,仅拷贝指针而不拷贝所指向的对象。 因此会有多个指针指向同一内存,我们只有在最后一个指向string的HasPtr销毁后,才可以释放string。
实现它的最好方法是利用智能指针,但是当我们希望直接管理资源时,使用引用计数就很有用了。
引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(除拷贝构造函数)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此计数器初始化为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员(包括计数器)。拷贝构造函数递增共享的计数器。
- 析构函数递减计数器,若计数器为0,则释放。
- 拷贝赋值运算符,递增右侧运算对象的计数器,递减左侧运算对象的计数器,若计数器为0,则释放。
唯一的难题就是确定 在哪里存放引用计数?
HasPtr p1("Fancy");
HasPtr p2(p1);
HasPtr p3(p2);
如果我们将引用计数作为类的数据成员,那么每个计数器都是独立的。当我们构造p3时,无法修改p1的计数器。
因此,我们可以将计数器保存在动态内存中。当创建一个对象时,我们分配一个计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。这样副本和原对象都会指向相同的计数器。
class HasPtr{
string *ps;
int i;
size_t *share;
public:
HasPtr(const string &s = new string()):
ps(new string(s)),i(0),share(new size_t(1)){ }
HasPtr(const HasPtr &p):
ps(p.ps),i(p.i),share(p.share){ ++*share; }
HasPtr& operator=(const HasPtr &rhs);
~HasPtr();
};
HasPtr::~HasPtr(){
if(--*share == 0){
delete ps;
delete share;
}
}
HasPtr& HasPtr::operator=(const HasPtr& rhs){
++*rhs.share;
if(--*share == 0){
delete ps;
delete share;
}
ps = rhs.ps;
i = rhs.i;
share = rhs.share;
return *this;
}
三、交换操作
除了定义拷贝控制成员,管理资源的类通常还定义swap函数。因为对于标准库中的swap函数,需要进行一次拷贝和两次赋值。
HasPtr temp = v1;
v1 = v2;
v2 = temp;
在这个过程中我们会产生内存的消耗,理论上这些消耗是不必要的。我们更希望swap交换指针。
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;
因此我们为HasPtr类定义更优化的swap函数。
class HasPtr{
friend void swap(HasPtr&,HasPtr&); //友元函数,可访问private
……
};
inline void swap(HasPtr &lhs, HasPtr&rhs) {
//swap的存在就是为了优化代码,因此声明为内联函数
using std::swap;
swap(lhs.ps, rhs.ps);
swap(lhs.i, rhs.i);
}
从上述代码中我们可以看出,我们调用的是swap,而不是std::swap。当我们定义了一个类的swap函数时,其匹配程度会优于std中定义的版本。若没有定义才会使用std的版本。
class Foo{
HasPtr h;
……
};
void swap(Foo &lhs,Foo &rhs){
std::swap(lhs.h,rhs.h); //调用标准库swap
}
void swap(Foo &lhs,Foo &rhs){
using std::swap; //声明一下我在std空间定义了swap函数
swap(lhs.h,rhs,h); //调用HasPtr的swap
}
在赋值运算符中使用swap
定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换。
HasPtr& HasPtr::operator=(HasPtr rhs){
//rhs是右侧运算对象的一个副本
swap(*this,rhs);
return *this;
}