拷贝控制
当我们定义一个类的时候,一定要注意类的拷贝控制,这就直接关系到类的内存管理。对于一个类的基本操作有:拷贝构造,拷贝赋值,移动构造,移动赋值,析构销毁。拷贝的两种方式:类值和类指针。其实就是深度拷贝和浅拷贝。
编译器做了什么
即使我们不定义任务以上的基本操作,编译器同样会生成他们,包括:拷贝构造函数,析构函数,拷贝赋值,默认构造。如果我们定义了任意构造函数都不会生成默认构造。
先理解拷贝初始化与直接初始化
直接初始化(directed initialization):他的过程其实是调用最优匹配重载构造函数的过程。
官方定义
Initializes an object from explicit set of constructor arguments.
拷贝初始化(copy
initialization):他的过程是类型转化的过程,从一个类型转为另一个类型,并且完成初始化。
官方定义
Initializes an object from another object.
拷贝初始化得以执行的根本条件是构造函数(转化函数)是隐式的,否则将会有语法错误。并且发生在拷贝初始化的隐式转换只能有一次。
class A
{
A(string str){}
string str;
}
void main()
{
A a("abc");//ok
A a = "abc"//error 这里"abc"是char*类型,需要发生char*->string->A
}
另外拷贝构造函数被调用不一定是拷贝初始化,应该从根本定义来区分。拷贝初始化可能调用移动构造也可能调用拷贝构造。
拷贝构造函数
构造函数的第一个参数是自身的引用(必须是自身引用,否则会发生构造函数递归调用),也可以有其他参数,但是其他参数都需要有默认值。一般情况下,引用加上const修饰。
拷贝构造函数一般发生在拷贝初始化的时候。不同的编译器,可能会在执行拷贝初始化的时候不执行拷贝构造函数。
拷贝赋值运算符
class A
{
public:
string str;
double a;
float *ptr;
A& operator=(const A& rhs)
{
str = rhs.str;//调用string的=
a = rhs.a;
ptr = rhs.ptr;//指向右操作数的内存,两个对象共享这一字段的内存
return *this;
}
};
编译器默认生成的赋值运算符有时候会和自己想要的结果有差别。
析构函数
一个类只有一个,不接受参数,所以不能重载。
作用
析构函数的作用就是释放资源,如果类内没有动态的资源则可以不显式的定义析构函数。析构函数真正销毁成员的操作,并不在函数体之中,而是在函数体执行完成之后,隐式执行的。成员销毁顺序和构造顺序相反(整个构造和析构是两个相反的过程)。
析构函数一般不能显式的调用,即使调用也很有可能不会使所期望的结果,析构函数隐式调用除了执行函数内的语句还进行了栈上内存的释放,而显式调用不会释放。
发生时间
- 变量离开作用域。
- delete
- 容器销毁时。(容器里如果是指针,也不会去释放指针指向的对象)
- 表达式的临时变量,在表达式结束的时候
构造函数的构造顺序是成员声明的顺序而析构函数的顺序与之相反
移动构造函数
这是新标准中加入的。他的目的是更加快速的构造一个对象。“移动”,就是把一个对象的所有资源转移到另一个对象中,这个过程结束后目的对象完全获取到源对象的资源,源对象即将要销毁,如果不销毁,我们也不能对源对象的值做任何的假设,当然它可以被赋予新的值。
要理解移动操作,还必须要理解右值引用。右值引用是相对于常规引用(左值引用)。c++premier中的区别左值右值的说法是左值持久,右值短暂,所有右值都是即将要销毁的,且没有其他对象使用。
int a =5;
int &b = a;//正确 左值引用
int &&c=a;//错误 右值引用只能绑定在即将销毁的变量 字面值 临时变量
int &&d = 5;//正确
int &&e=d;//错误 右值引用不能绑定右值引用
int &&f = std::move(a);//正确 move操作可以将一个左值移动到右值引用上用cout输出a和b的地址会发现地址是一样的
//拷贝构造函数的一般写法(c++primer)
StrVec::StrVec(StrVec&& s) noexcept
//初始化参数列表,如果以下成员可以移动也会调用相应的移动构造函数
:elements(s.elements),first_free(s.first_free),cap(s.cap)
{
//为确保移后源对象是可以析构
s.elements = s.first_free = s.cap = nullptr;
}
为什么要使用noexcept关键字呢?因为,在标准库中的很多操作要求构造函数是异常安全的,即使发生了异常,原来的对象都不会改变。如果移动操作发生了异常,那么可能会出现旧的对象部分内存被析构,而新的对象却没有被创建好,所以可能会发生异常的移动构造函数标准库是不会调用的,一般会使用拷贝构造函数来完成。但是,在实际使用中,移动构造函数不分配新的内存,所以一般是不会发生异常的。那么,要想让标准库调用自己写的移动构造函数,就必须将其声明为noexcept。同样地,在移动构赋值运算符中,同样是需要将其声明为noexcept的。
移动赋值运算符
StrVec & StrVec::operator=(StrVec &&rhs) noexcept
{
//检测自赋值------------------------------------1
if(this != &rhs)
{
//释放已有元素------------------------------2
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
//将rhs置于可析构的状态---------------------3
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
关于以上的移动操作,只有当用户没有自定义任何拷贝函数(拷贝构造、拷贝赋值、析构),并且所有他的成员都是可以移动的时候,才会生成移动构造活着移动赋值。
三五法则
c++perimer中说的三五法则的意思是,各种拷贝控制函数之间的依赖关系。
控制拷贝操作的三个函数:拷贝构造函数 赋值操作符 析构函数。再加上新标准引入的:移动构造和移动赋值
- 需要析构函数的类一般也需要显式的声明拷贝构造函数和赋值运算符
析构函数之所以显式存在是因为需要释放动态开辟的内存空间,这也说明了类成员包括指针,如果不显示指定拷贝和复制操作,就会出现浅拷贝,发生内存多次释放的情况
- 需要拷贝构造函数则一定也会需要显式声明赋值运算符
可以看出拷贝构造和赋值操作都对一个对象的拷贝操作,如果只声明其中之一很可能会造成操作不能按预期实现,但析构函数不一定需要
- 上一条反之亦然
一般来说,定义了任意一个拷贝控制成员,最好将五个都显式的定义出来
使用=default delete
使用default关键字可以显式的要求编译器生成合成版本。
使用delete关键字禁用函数,例如拷贝构造和复制等
class A
{
public:
A() = default;//内联
A(const A&) = delete;//禁用拷贝构造
A& operator = (const A&);
A(int v):val(v){}
~A()= delete;//析构函数被delete后就不能定义对象了
int val;
};
A& A::operator = (const A&) = default;//非内联
default只可以使用在编译器会合成的函数上而delete则对任意函数都可以。
在新标准之前,一般都是用private来禁用拷贝操作,如果使类内成员也不能访问则可以只声明而不定义,这样就会发生链接错误。
析构函数最好不要显示调用 显示调用的析构函数不会达到期望的要求
另外,在一些情况下编译器是不会产生一些拷贝操作函数,比如类内的成员本身是不可拷贝,赋值,析构。