拷贝控制
拷贝构造函数:如果一个拷贝构造函数的第一个参数是自身类类型的引用,并且其他参数都有默认值,则此构造函数为拷贝构造函数。拷贝构造函数的使用主要存在以下三个场景:
- 将一个对象作为一个实参,传递给一个 非引用类型的形参时。
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或者一个聚合类中的成员时。
另外在使用“=”进行对象初始化时也是拷贝构造函数进行拷贝初始化,一般的直接初始化使用的形如:
Sales_data sales(“9999-888”,20,3)
当一个类没有拷贝构造函数时,编译器会自动生成一个合成拷贝构造函数,这种会自动进行非static数据成员的拷贝,是最一般的拷贝,有的情况下并不符合我们的预期,因此有时是需要自己定义的。只需要定义和合成的不同的部分。
拷贝赋值运算符:定义类似于Foo& operator=(const Foo&);
,一般拷贝赋值运算符返回的是一个对其左边类型的引用。如果自己没有进行定义,编译器也会自动生成一个合成拷贝赋值运算符,在使用合成拷贝赋值运算符时,对于一个对象的拷贝,会拷贝其除static类型之外的数据成员到左边的对象,具体的拷贝使用其内置数据成员自身的拷贝函数进行。
析构函数:与构造函数相反,析构函数负责销毁,析构函数体自身是不会直接销毁成员的,成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数是作为成员销毁步骤之外的另一部分而进行的。
关于以上三个的关系:如果一个类需要自定义析构函数,那很大程度上也是需要自定义拷贝构造函数和拷贝赋值函数的;合成的析构函数是不能自动销毁一个指针数据成员的,因此需要自定析构函数来释放构造函数分配的内存。如果一个类需要拷贝赋值运算符,那它也肯定需要一个拷贝构造函数,反之亦然。但是这两种情况下则未必一定需要一个析构函数。
####使用=default
我们可以显式的使用“=default”(默认构造函数或者拷贝控制成员)来要求编译器生成合成版本的拷贝控制。
阻止拷贝:如果我们不希望我们的对象被拷贝可以在参数列表后添加“=delete”来表明,这种声明和“=default”不同的是,必须在第一次声明时就进行声明,通知编译器,我们不希望这个成员被定义。形如
Nocopy(const &Nocopy)=delete //阻止拷贝
。在一般情况下,这种声明被用于拷贝控制的成员上,但是有时为了引导函数的匹配也可以用在普通的成员函数上。一般不能将析构函数定义为删除的,因为如果这样做的话将不能够对该类的对象进行删除及销毁内存操作,
当编写一个赋值运算符时,比较好的设计模式是首先把右侧的运算对象拷贝到一个局部临时变量中,再删除左侧的指针指向的资源,最后就只剩把临时对象中的资源拷贝到左侧对象中了。如果不首先进行保存,可能会导致在自身给自身赋值时出现错误,如下:
HasPtr& HasPtr::operator=(const HasPtr &rhs){
delete ps;//释放对象指向的string
//如果rhs和*this是同一个对象,我们就将从已释放的内存中拷贝数据!
ps = new string(*(rhs.ps));
i=rhs.i; return *this;
}
拷贝控制及资源管理
类值拷贝与类指针拷贝:类值,说明它应该有自己的状态,拷贝前后,是两个独立的个体,改变其中一个不影响另一个的值与状态,类似于string及各种内置类型以及容器;类指针,则是拷贝前后的对象是相互关联的,类似于shared_ptr;而IO类和unique_ptr不允许拷贝,因此既不是类值的也不是类指针的。
引用计数:计数器保存在动态内存中,当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝或赋值指向计数器的指针,使用这种方法,副本和原对象都会指向相同的计数器。
交换操作:定义自己的交换操作,有助于优化代码。当我们没有定义自己的swap函数时,编译器会自动调用标准库中定义的swap,而在一些情况下我们并不想完全的对对象进行二次拷贝再进行交换,我们只希望交换相关指针如下所示:
//一般的交换
HasPtr temp=v1;
v1=v2;
v2=temp;//在该过程中会对HasPtr中的一个string进行两次拷贝,
//而我们只希望对他们的指针进行操作
//我们希望的
string* temp=v1.ps;
v1.ps=v2.ps;
v2.ps=temp;
//定义自己的swap函数,swap被声明为友元friend,便于访问HasPtr中的数据成员
inline
void swap(HasPtr& v1,HasPtr& v2){
//每个swap的调用都应该是未加限定的,虽然此处
//使用using进行声明,但是在调用时不能显示指定,这里只是说明没有特定的swap
//可以匹配时,使用标准库中的swap;
use std::swap;
swap(v1.ps,v2.ps);
swap(v1.i,v2.i);
}
###动态内存管理类(…)
##对象移动
有的时候从一个旧内存拷贝对象到一个新内存是不必要的,且例如unique_ptr、IO类对象是不允许拷贝的,此时使用移动(move)更能满足需求。另外在旧版本中,容器中保存的必须是可进行拷贝的元素,但是在新标准中我们可以用容器保存不可拷贝的对象,只要允许移动就可以。
左值与右值:在c++11中对于左值和右值的定义,如下:
Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: if it has a name, then it is an lvalue. Otherwise, it is an rvalue.
也就是说如果一个值是有名字的,那它就是左值,反之则为右值。
左值引用与右值引用:一般情况下形如T&
为左值引用,const T&
可以是左值引用也可以是右值引用。T&&
为右值引用。
- 左值持久,右值短暂:左值的生存周期更长,而右值一般是一些即将被销毁的量,生存期较短;变量是左值,我们不能将一个右值引用直接绑定到一个变量上,即使该变量是右值引用类型也不行。如下:
int &&rr1=42;//正确,字面值常量是右值
int &&rr2=rr1;//错误,是变量,表达式rr1是左值(有名字);
- 标准库函数move:我们可以使用move函数将一个左值转换为对应的右值引用类型,但是转换后,也就说明之后对该左值只存在销毁或赋值两种操作,在使用上直接使用std::move,而不是move,以避免名字冲突。
int &&rr2=std::move(rr1);//正确
移动构造函数和移动赋值操作符
与拷贝构造函数类似,其第一个参数必须是自身类型的右值引用。
需要声明为不抛出任何异常的使用关键字 noexcept,一个具体使用的例子如下:
StrVec(StrVec&& rri)
//内存进行接管
noexcept :elements(rri.elements),first_free(rri.first_free),cap(rri.cap){
//令其进入此状态,运行析构函数是安全的
rri.elements=rri.first_free=rri.cap=nullptr;
}
在声明和具体定义时都需要加上noexcept关键字,告诉编译器它是不会抛出任何异常的。
合成移动构造与合成移动赋值:仅仅当类没有任何自定的拷贝控制成员,且其所有数据成员是支持移动操作时,编译器才会自动生成移动构造函数与移动赋值操作符。
定义了一个移动构造和或移动赋值运算符的类也必须定义自己的拷贝版本,否则会默认将其设置为删除的。
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则用拷贝构造来代替移动构造是安全可行的,在运算符上也是类似的。
三/五法则的更新:一般情况下一个类如果定义了拷贝构造函数,那通常也需要定义相应的拷贝赋值运算符合析构函数才能实现类的正常工作,但是有时又会因为拷贝引起一些不必要的开销,因此最好也定义相应的移动构造函数和移动赋值运算符,这样会精简类的使用。五个看做一个一体化的定义使用。
一些建议关于移动:
- 对于一些成员函数也可以定义拷贝版本和移动版本,对于函数重载是一种类型,根据传入参数是一个左值还是右值决定是采用拷贝还是移动。
- 右值和左值引用成员函数:在一般的应用中我们是允许向一个右值进行赋值操作的,也就是我们允许将一个右值表达式放到左侧,然后对其进行赋值,如
s1+s2="wow"
,但是有的时候在我们自己的类中我们不想允许这种操作,则只需要在操作符定义时在其参数列表后加上引用限定符&
即可。
形如StrBl operator=(const StrBl&) &{}//只允许向可修改的左值赋值
或者是&&
限定只能用于右值。
对于const限定的函数我们在重载时,可以是一个有const限定(只能读取,不能修改类的数据成员),一个没有(可读可写,可以修改);但是对于引用限定而言,要么所有的重载都加上引用限定,要么都没有引用限定。