拷贝控制
- 当定义一个类时,我们显式或隐式的指定在此类型的
对象拷贝、移动、赋值和销毁
时做什么,一个类通过定义五种特殊的成员函数来控制这些操作
- [x] `拷贝构造函数`
- [x] `拷贝赋值运算符`
- [x] `移动构造函数`
- [x] `移动赋值运算符`
- [x] `析构函数`
- [x] 拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么
- [x] 拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么
- [x] 析构函数定义了当此类型对象销毁时做什么
- [x] 上述这些操作统称为`拷贝控制操作`
- 如果一个类没有定义上述这些拷贝控制成员,编译器会自动为它定义缺失的操作,但对一些类来说,依赖这些操作的默认定义会导致灾难
拷贝、赋值与销毁
拷贝构造函数
- 如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数为拷贝构造函数
class Foo{
public:
Foo(); //默认构造函数
Foo(const Foo&);//拷贝构造函数
//......
}
- 拷贝构造函数的第一个参数必须是一个
引用类型
- 当我们使用
拷贝初始化
时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换- 拷贝初始化是依靠拷贝构造函数或移动构造函数完成的
- 拷贝初始化在下列情况会发生
- [x] 用`=`定义变量时发生
- [x] 将一个对象作为实参传递给一个非引用类型的形参
- [x] 从一个返回类型为非引用类型的函数返回一个对象
- [x] 用或括号列表初始化一个数组中的元素或一个聚合类中的成员
- 拷贝构造函数自己的参数必须类型
拷贝赋值运算符
- 与类控制其对象如何初始化一样,类也可以控制器对象如何赋值
Sales_data trans,accum;
trans = accum; //使用Sales_data的拷贝赋值运算符
- 与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个
- 如果一个运算符是一个成员函数,则其左侧的运算对象就绑定到隐式的this指针参数
- 赋值运算符通常应该返回一个指向其左侧运算对象的引用
- 标准库通常要求保存在容器中的类型具有赋值运算符
析构函数
- 析构函数执行与构造函数相反的操作
- [x] 构造函数初始化对象的非static数据成员,还可能做一些其他工作
- [x] 析构函数释放对象使用的资源,并销毁对象的非static数据成员
- 析构函数没有返回值,也不接受参数
- 由于析构函数不接受参数,因此它不能被重载,对于一个给定类,只会有唯一一个析构函数
- 在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化
- 在一个析构函数中,首先执行函数体,然后销毁成员,成员按初始化的逆序进行销毁
隐式销毁一个内置指针类型的成员不会delete它所指向的对象
- 与普通指针不同,智能指针是类类型,所以具有析构函数,因此,与普通指针不同,智能指针成员在析构阶段会自动销毁
- 什么时候会调用析构函数(
无论何时一个对象被销毁,都会自动调用其析构函数
)
- [x] 变量离开其作用域时被销毁
- [x] 当一个对象被销毁时,其成员被销毁
- [x] 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁
- [x] 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁
- [x] 对于临时对象,当创建它的完整表达式结束时销毁
- 析构函数自动运行,所以我们的程序可以按需要分配资源,无需担心何时释放这些资源
- 当指向一个对象的引用或指针离开作用域时,析构函数不会执行
- 当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数,类似拷贝构造函数和拷贝赋值运算符
析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段被销毁的
,在整个对象销毁过程中,析构函数整体是作为成员销毁步骤之外的另一部分而进行的
三/五法则
- 如果一个类需要一个析构函数,那么它几乎可以肯定也需要一个拷贝构造函数和一个拷贝赋值运算符
- 如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符,反之亦然
- 无论是需要拷贝构造函数还是需要拷贝赋值运算符都不洗染意味着需要析构函数
使用=default
- 我们可以通过拷贝控制成员定义=default来显式的要求编译器生成合成版本
class Sales_data{
public:
//拷贝控制成员;使用default
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data operator=(const Sales_data &);
~Sales_data()=default;
//其余成员定义如前
}
- 我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)
阻止拷贝
- 大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式的还是显式的
- 当一个类明确不允许拷贝赋值操作时,必须使用=delete将拷贝构造函数与拷贝赋值运算符定义为删除的,显式的阻止该函数被编译器默认生成,如iostream类
- 通过=delete可以将拷贝构造函数和拷贝赋值运算符定义为
删除的函数
来阻止拷贝,删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用,在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的
struct NoCopy{
NoCopy() = default; //使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; //阻止拷贝
NoCopy & operator=(const NoCopy&) = delete; //阻止赋值
};
- =delete通知编译器,我们不希望定义这些成员
- =delete必须出现在函数第一次声明的时候
- 我们可以对任何函数指定=delete,但只能对编译器可以合成默认函数的默认构造函数或拷贝控制成员使用=default
- 虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的
需要注意的是,我们不能删除析构函数
,如果析构函数被删除,我们不能定义该类的变量或临时对象(编译器不允许)- 对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象,但是不能释放这些对象
struct NoDtor{
NoDtor() = default;
~NoDtor() = delete;
};
NoDtor nd; //错误,析构函数删除不能定义变量
NoDtor * p = new NoDtor(); //正确,但不能delete
delete p; //错误,析构函数是删除的
- 对于析构函数已删除出的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针
- 在新标准发布之前(C++11),类是通过将其拷贝构造函数和拷贝赋值运算符声明为
private
的来阻止拷贝- 声明但不定义一个成员函数是合法的
- 希望阻止拷贝的类应用应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private
拷贝控制和资源管理
- 一旦一个类需要析构函数,那么几乎肯定它也需要一个拷贝构造函数和一个拷贝控制运算符
行为像值的类
- 当编写赋值运算符时,以下两点需要记住
- [x] 如果一个对象赋予它自身,赋值运算符必须能正确工作
- [x] 大多数赋值运算符组合了析构函数和拷贝构造函数的工作
- 当你编写一个赋值运算符,一个好的模式是先将右侧的运算对象拷贝到一个局部临时对象中,当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了,一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了
- 对于一个赋值运算符来说,正确工作是非常重要的,即使是将一个对象赋予它自身,也要能正确工作,一个好的方法是在销毁左侧运算对象资源之前拷贝右侧运算对象
定义行为像指针的类
- 对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string,我们的类仍需自己的析构函数来释放接受string参数的构造函数分配的内存
- 一个令类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源
- 当我们希望直接管理资源的时候,就要使用
引用计数
- 引用计数的工作方式如下:
- [x] 除了初始化对象外,每个构造函数(拷贝构造函数除外)还有创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态,当我们创建一个对象时,只有一个对象共享状态,因此计数器初始化为1
- [x] 拷贝构造函数不分配新的计数器,而是拷贝给定的数据成员,包括计数器,拷贝构造函数递增共享的计数器,指出给定对象是的状态又被一个新用户所共享
- [x] 析构函数递减计数器,指出共享状态的用户少了一个,如果计数器变为0,则析构函数释放状态
- [x] 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器,如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态
- 引用计数器保存在动态内存中,当创建一个对象时,我们也分配一个新的计数器,当拷贝或赋值对象时,我们拷贝指向计数器的指针,使用这种方法,副本和原对象都会指向相同的计数器
交换操作
- 除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数,对于哪些与重排元素顺序的算法一起使用的类,定义swap是非常重要的,这类算法在需要交换两个元素时会调用swap
- 如果一个类定义了自己的swap,那么算法将会使用类自定义版本,否则,算法将使用标准库定义的swap
- 如下为swap实现的两种方式
/*交换值空间*/
HasPtr temp = v1; //创建v1的值的一个临时副本
v1 = v2; //将v2的值赋予v1
v2 = temp; //将保存的v1的值赋予v2
/*直接交换指针*/
string * temp = v1.ps; //为v1.ps中的指针创建一个副本
v1.ps = v2.ps; //将v2.ps中的指针赋予v1.ps
v2.ps = temp; //将保存的v1.ps中原来的指针赋予v2.ps
- swap的经典实现如下
class HasPtr{
friend void swap(HasPtr & , HasPtr &);
//其余成员定义
};
inline void swap(HasPtr & 1hs, HasPtr & rhs){
using std::swap;
swap(1hs.ps,rhs.ps);
swap(1hs.i,rhs,i);
}
- 与拷贝控制成员不同,swap并不是必要的,但对于分配了资源的类,定义swap可能是一种很重要的优化手段
swap函数应该调用swap,而不是std::swap
- 如果一个类的成员有自己类型特定的swap函数,调用std::swap就是错误的,反之则正确
动态内存管理类
- 以下将实现标准库vector类的一个简化版本,非模板且仅适用与string,将其命名为StrVec
- 每个StrVec有三个指针成员指向其元素所使用的内存:
- [X] elements,指向分配的内存中的首元素
- [x] first_free,指向最后一个实际元素之和的位置
- [x] cap,指向分配的内存末尾之后的位置
- 上述指针的含义如下图
![Capture.PNG-17.2kB][1]- StrVec定义如下
class StrVec{
public:
StrVec():
elements(nullptr),first_ptr(nullptr),cap(nullptr){}
StrVec(const StrVec &); //拷贝构造函数
StrVec & operator=(const StrVec &); //拷贝赋值运算符
~StrVec(); //析构函数
void push_back(const std::string &); //拷贝元素
size_t size() const {return first_free - elements;}
size_t capacity() const {return cap - elements;}
std::string * begin() const {return elements;}
std::string * end() const {return first_free;}
//...
private:
static std::allocator<std::string> alloc; //分配元素
/*被添加元素的函数所使用*/
void chk_n_alloc(){
if(size() == capacity())
reallocate();
}
/*工具函数,被拷贝构造函数,赋值运算符和析构函数所使用*/
std::pair<std::string* , std::string*>alloc_n_copy(const std::string* , const std::string*);
void free(); //销毁元素并释放内存
void reallocate(); //获得更多内存并拷贝已有元素
std::string * elements; //指向数组首元素的指针
std::string * first_ptr; //指向数组第一个空闲元素的指针
std::string * cap; //指向数组尾后位置的指针
};
- 上述代码解释
- [x] 默认构造函数(隐式的)默认初始化alloc并(显式的)将指针初始化为nullptr,表明没有元素
- [x] size成员返回当前真正在使用的元素的数目,等于first_free --- elements
- [x] capacity成员返回StrVec可以保存的元素的数量,等价于cap --- elements
- [x] 当没有空间容纳新元素,即cap==first_free时,chk_n_alloc会为StrVec重新分配内存
- [x] begin和end成员分别返回指向首元素(即elements)和最后一个构造的元素之和的位置(即first_free)的指针
- 使用alloctor的时候,必须记住内存是
未构造的
,为了使用此原始内存,必须调用construct,在内存中构造一个对象,alloctor的使用如下
allocator<int> alloc; //构建int分配内存的alloctor
int * temp = alloc.allocate(10); //分配10个int对象的空间,而不调用构造函数
alloc.construct(temp); //在第一个对象的内存上通过缺省构造函数构建对象
alloc.construct(temp+1,6); //在第二个对象的内存上通过有参构造函数构建对象
alloc.destroy(temp+1); //调用第二个对象的析构函数(不释放内存)
alloc.destroy(temp); //调用第一个对象的析构函数(不释放内存)
alloc.deallocate(temp,10); //释放内存
对象移动
- 标准容器库,string和shared_ptr类既支持移动也支持拷贝,IO类和unique_ptr类可以移动但不可以拷贝
右值引用
- 右值引用就是必须绑定到右值的引用,我们通过&&而不是&来获得右值的引用
- 右值引用只能绑定到一个将要销毁的对象
- 常规引用被称为左值引用
- 左值引用不能将其绑定到要求转换的表达式/字面常量或是返回右值的表达式
- 右值引用有着完全相反的特性,我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上
int i = 42;
int & r = i; //正确,r引用i
int && rr = i; //错误,不能将一个右值引用绑定到一个左值上
int & r2 = i * 42; //错误,i*42是一个右值
const int & r3 = i * 42; //正确,常量引用可以绑定右值
int && rr2 = i * 42; //正确,将右值引用rr2绑定在乘法结果上
- 返回左值引用的函数,连同赋值/下标/解引用和前置递增/递减运算符,都是返回左值表达式的例子,我们可以将一个左值引用绑定到这类表达式的结果上
- 返回非音乐类型的函数,连同算数/关系/位以及后置递增/递减运算符,都生成右值,我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式上
- 左值有持久的状态,而右值要么是字面常量有,要么是在表达式求值过程中创建的临时对象
- 由右值引用只能绑定到临时对象可知
- [x] 所引用的对象将要被销毁
- [x] 该对象没有其他用户
- 使用右值引用的代码可以自由的接管所引用的对象的资源
- 右值引用指向将要被销毁的对象,因此,我们可以从绑定到右值的对象窃取状态
- 变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行
- 我们可以销毁一个移动后源对象,也可以赋予它新值,但不能使用一个移后源对象的值
- 使用move的代码应该使用std::move而不是move,这样可以避免潜在的名字冲突