「生活可以更简单, 欢迎来到我的开源世界」
  1. 13.1 拷贝、赋值与销毁
    1. 13.1.1 拷贝构造函数
    2. 13.1.2 拷贝赋值运算符
    3. 13.1.3 析构函数
    4. 13.1.4 三/五法则
    5. 13.1.5 使用=default
    6. 13.1.6 阻止拷贝
  2. 13.2 拷贝控制和资源管理
    1. 13.2.1 行为像值的类
    2. 13.2.2 行为像指针的类
  3. 13.3 交换操作
  4. 13.5 动态内存管理类
  5. 13.6 对象移动
    1. 13.6.1 右值引用
    2. 13.6.2 移动构造函数和移动赋值运算符
      1. 更新三/五法则
    3. 13.6.3 右值引用和成员函数
C++ Primer 第13章 拷贝控制
2018-06-25
」 「

当定义一个类时,我们显示地或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时的操作。

一个类通过定义五种特殊的成员函数来控制这些操作,我们称这些操作为拷贝控制操作

如果一个类没用定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。

在定义任何C++类的时候,拷贝控制操作都是必要的部分。

13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

如果一个构造函数的第一个参数自身类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。

class Foo{
public:
Foo(); //默认构造函数
Foo(const Foo&);//拷贝构造函数
}

拷贝构造函数的第一个参数必须是引用类型:如果参数不是引用类型,拷贝构造函数会被用来初始化非引用类型参数,则调用永远不会成功——为了调用拷贝构造函数,必须拷贝它的实参而执行拷贝构造函数,无限循环。

如果一个类没有定义拷贝构造函数,编译器会提供一个默认的。与合成默认构造函数不同的是,即使我们定义了其它构造函数,编译器也会为我们合成一个拷贝构造函数。

对于某些类来说,合成拷贝构造函数用来阻止我们拷贝该类型的对象,一般情况下,合成的拷贝构造函数会依次将其参数对象的每个非static成员逐个拷贝到正在创建的对象中。

每个成员的类型决定了它如何拷贝(不能直接拷贝一个数组)

当使用直接初始化时,编译器使用普通的函数匹配选择最匹配的构造函数;当使用拷贝初始化时,编译器将右侧的运算对象拷贝到正在创建的对象中,如果需要还要进行类型转换。

拷贝初始化发生的情况:

拷贝初始化通常使用拷贝构造函数来完成,如果一个类有移动构造函数,则拷贝初始化有时会使用移动构造函数来完成。

拷贝构造函数在几种情况下都会被隐式地使用,所以通常不应该是explicit的。

在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。即,编译器被允许将下面代码改写:

string null_book = "9-999-99999-9";//拷贝初始化
//改写为
string null_book("9-999-99999-9");//编译器略过了拷贝构造函数

即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须存在且可访问(不能是private的)。

13.1.2 拷贝赋值运算符

与类控制其对象如何初始化一样,类也可以控制其对象如何赋值。

如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。

运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成,运算符函数也有一个返回类型和一个参数列表,运算符的参数表示运算符的运算对象。

某些运算符,包括拷贝赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数,对于一个二元运算符,其右侧对象作为显示参数传递。

与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用,这样就可以连续赋值。

如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型的对象赋值,否则,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员。

13.1.3 析构函数

析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其它工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。

析构函数是类的一个成员函数,名字由波浪号连接类名构成,没有返回值,不接受参数。

由于不接受参数,因此不能被重载,一个给定的类,只会有唯一一个析构函数。

如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分:

析构函数在对象被销毁时自动调用:

当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数:对于某些类来说,合成析构函数被用来阻止该类型的对象被销毁,如果不是这种情况,合成析构函数的函数体为空。

13.1.4 三/五法则

有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。新标准下,一个类还可以定义一个移动构造函数和移动赋值运算符。

C++语言并不要求我们定义所有这些操作:可以只定义其中一个或两个,而不必定义所有。但是,这些操作通常应该被看作一个整体而全部定义。

一般情况下,需要析构函数的类也需要拷贝和赋值操作:构造函数中分配动态内存,合成的析构函数不会delete一个指针数据成员,因此需要析构函数。

class HasPtr{
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0){}
~HasPtr(){delete ps;}
};

HasPtr f(HasPtr hp){ //传值参数,发生了拷贝
HasPtr ret = hp; //执行合成的拷贝初始化
return ret; //ret和hp将被释放,执行析构函数
}

当f返回时,hp和ret都被销毁,在两个对象上都会执行析构函数,析构函数会delete对象中的指针成员,但是,这两个对象包含相同的指针值,致使此指针被delete两次,这是一个错误,将要发生什么也是未定义的。而作为f函数的实参的对象,此时实参对象中的指针成员已经被delete了,此时实参对象的指针成员指向无效内存,而且,实参对象在结束生命周期时又会调用析构函数,又delete了一次。

需要拷贝操作的类也需要赋值操作,反之亦然:虽然很多类需要定义所有(或是不定义任何)拷贝控制成员。但是某些类所要完成的工作只需要拷贝或赋值操作,不需要析构函数:如一个类为每个对象分配一个独有的、唯一的序号。

13.1.5 使用=default

我们可以通过将拷贝控制成员定义为=default来显示地要求编译器生成合成的版本,合成的函数将隐式地生命为内联的。如果我们不希望合成的成员函数是内联函数,应该只对成员的类外定义使用=default。

=default只能使用在具有合成版本的成员函数中,如默认构造函数或拷贝控制成员。

13.1.6 阻止拷贝

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是显示地还是隐式地。有时候拷贝操作对某些类没有意义(如iostream类阻止了拷贝,避免多个对象写入或读取相同的IO缓冲),然而就算没有显示定义拷贝操作编译器也会生成合成的版本。

在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是一种函数:我们虽然声明了它们,但不能以任何方式使用它们。

在函数的参数列表后面加上=delete来指出我们希望定义为删除的函数。

=delete通知编译器,我们不希望定义这些成员。与=default不同的是:

对于某些类来说,编译器将这些合成的成员定义为删除的函数:

本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的

一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,原因是:为了避免创建出无法销毁的对象。

具有引用成员或无法默认构造的const成员的类,编译器不会为其合成默认构造函数。

如果一个类有const成员,则它不能使用合成的拷贝赋值运算符,const对象不可能重新赋值。

将一个新值赋予引用成员,改变的是引用指向的对象的值,而不是将引用指向新对象,结果不符合预期,所以对有引用成员的类,合成拷贝赋值运算符被定义为删除的。

C++新标准之前,通过将拷贝构造函数和拷贝赋值运算符声明为private,且不定义它们,来阻止拷贝。试图访问一个未定义的成员将导致一个链接时错误。

希望阻止拷贝的类应该用=delete来定义它们的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private

13.2 拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员。

13.2.1 行为像值的类

类的行为像一个值,意味着他应该有自己的状态,当拷贝一个像值的对象时,副本和元对象是完全独立的,互不影响。

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

当编写赋值运算符时,有两点需要记住;

赋值运算符应该是异常安全的——当异常发生时能将左侧运算对象置于一个有意义的状态,一个好的方法是销毁左侧运算对象资源之前拷贝右侧运算对象到一个临时对象中,一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中。

13.2.2 行为像指针的类

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

令一个类展现类似指针的行为的最好方法是使用shareed_ptr来管理类中的资源。但是有时候,我们希望直接管理资源,在这种情况下,使用引用计数就非常有用了。

引用计数的工作方式:

引用计数不能直接作为类对象的成员,一种方法是将计数器存放在动态内存中。当创建一个对象时,分配一个新的计数器;当拷贝或赋值对象时,拷贝指向计数器的指针。

//赋值运算时,先递增右侧,再递减左侧,通过这种方法,当两个运算对象相同时
//在我们简称ps(及use)是否应该释放之前,计数器就已经被递增过了
HasPtr& HasPtr::operator=(const HasPtr &rhs){
++*rhs.use; //递增右侧运算对象的引用计数
if(--*use == 0){ //然后递减左侧运算对象的引用计数
delete ps; //如果没有其它用户
delete use; //释放本对象分配的成员
}
ps = rhs.ps; //将数据从rhs拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; //返回本对象
}

13.3 交换操作

对于那些与重排元素顺序的算法一起使用的类,定义swap非常重要。

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

HasPtr temp = v1;
v1 = v2;
v2 = temp;
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;

swap为了交换两个对象,需要进行一次拷贝和两次赋值,但是有时候不需要整个对象交换,整个对象交换也会花费更多的内存。

按需要定义自己版本的swap重载swap的默认行为:

class HasPtr{
friend void swap(HasPtr&, HasPtr&);
}
inline
void swap(HasPtr &lhs, HasPtr &rhs){
using std::swap;
swap(lhs.ps, rhs.ps);
swap(lhs.i, rhs.i);
}

非常重要的一点:在swap函数中调用swap函数而不std::swap,如果数据成员是内置类型,内置类型没有特定版本的swap,则会调用标准库swap;如果数据成员有自己类型特定的swap函数,则会调用类型特定的swap。

定义swap的类通常用swap来定义它们的赋值运算符,使用了拷贝并交换技术:

//注意rhs是按值传递的,意味着HasPtr的拷贝构造函数
//将右侧运算对象中的string拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs){
//交换左侧运算对象和局部变量rhs的内容
swap(*this, rhs);
return *this; //rhs被销毁,从而delete了rhs中的指针
}

这个技术是异常安全的,且能正确处理自赋值,在swap之前就已经创建了临时对象,代码中唯一可能抛出异常的是拷贝构造函数的new表达式,它也会在改变左侧运算对象之前发生。

13.5 动态内存管理类

某些类需要在运行时分配可变大小的内存空间,这种类通常可以(并且如果它们确实可以的话,一般应该)使用标准库容器来保持它们的数据。

但是,某些类需要自己进行内存分配,这些类一般来说必须定义自己的拷贝控制成员来管理内存分配。

13.6 对象移动

C++11新标准一个最主要的特性是可以移动而非拷贝对象的能力:在某些情况下,对象拷贝完后立即被销毁了,在这些情况下,移动而非拷贝对象会大幅度提升性能。

使用移动而非拷贝的另一个原因源于IO类或unique_ptr这样的类,包含了不能被共享的资源(如指针或IO缓冲),这些类型不能拷贝但可以移动。

在旧C++标准中,没有直接移动对象的方法,很多时候不得不拷贝,浪费资源。

C++11新标准中,容器可以保存不可拷贝的类型,只要它们能够被移动即可。

标准库容器、string和share_ptr类既指出移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

13.6.1 右值引用

为了支持移动操作,C++11新标准引入了新的引用类型——右值引用(&&):就是必须绑定到右值的引用。

左值和右值是表达式的属性:

左值引用:常规引用

右值引用:与左值引用相反的绑定特性,将引用绑定到字面常量或是返回右值的表达式,不能将右值引用绑定到一个左值上。

int i = 42;
int &r = i; //正确:r引用i
int &&rr = i; //错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42; //错误:i*42是一个右值
const int &r3 = i * 42; //正确:可以将一个const的引用绑定到一个右值上
int &&rr2 = i * 42; //正确:将rr2绑定到乘法结果上

返回左值表达式:返回左值引用的函数,赋值、下标、解引用、前置递增/递减运算符

返回右值表达式:返回非引用类型的函数,算术、关系、位、后置递增/递减运算符

左值具有持久状态,右值要么是字面常量,要么是表达式求值过程中创建的临时对象。

由于右值引用只能绑定到临时对象,所以具有如下特性:

这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源

右值引用指向将要被销毁的对象,我们可以从绑定到右值引用的对象“窃取”状态

变量可以看作只有一个运算对象且没有运算符的表达式,变量表达式都是左值,变量是左值。

int &&rr1 = 42;		//正确:字面常量是右值
int &&rr2 = rr1; //错误:表达式rr1是左值!

不能将一个右值引用直接绑定到一个左值上,但可以通过标准库提供的move函数,显示地将一个左值转换为对应的右值引用类型。

#include <utility>
int &&r3 = std::move(rr1); //ok

move告诉编译器:我们希望像处理右值一样处理一个左值。调用move后,除了对rr1赋值或销毁它之外,将不能再使用它。

我们可以销毁一个移后源对象,也可以赋予它新值,但是不能使用一个移后源对象的值。

13.6.2 移动构造函数和移动赋值运算符

类似拷贝构造函数,移动构造函数的第一个参数是该类型的一个引用,不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。

与拷贝构造函数一样,任何额外的参数都必须有默认实参。

除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。

StrVec::StrVec(StrVec &&s) noexcept	//移动操作不应该抛出任何异常
//成员初始化器接管s中的资源
: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
//令s进入这样的状态——对其运行析构函数是安全的
s.elements = s.first_free = s.cap= = nullptr;
}

StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
//直接简称自赋值
if(this != &rhs){
free(); //释放已有元素
elements = rhs.elements; //从rhs接管
first_free = rhs.first_free;
cap = rhs.cap;
//将rhs置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
}

移动构造函数不分配任何新的内存:它接管给定的StrVec中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr。最终,移后源对象会被销毁。

由于移动操作“窃取”资源,它通常不分配任何资源,因此通常不会抛出任何异常,当编写一个不抛出异常的的移动操作时,应该通过noexcept关键字将此事通知标准库,否则它会做一些额外的工作。

类头文件的声明和定义中(如果定义在类外的话)都指定noexcept。

为什么需要noexcept:

例如,vector保证,如果调用push_back时发生异常,vector自身不会改变。push_back过程中可能重新分配内存空间,需要将元素从旧内存移动到新内存,除非vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。

移后源对象不仅是析构安全的状态,而且还是一个有效的对象:

合成的移动操作:与拷贝操作不同,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,则编译器不会为它合成移动构造函数和移动赋值运算符。

只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。

与拷贝操作不同,移动操作永远不会隐式地定义为删除的函数,但是如果我们显示的要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。

除了一个重要例外:什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则(参见13.1.6):

移动操作和合成的拷贝控制成员间有一个相互作用关系:一个类是否定义了自己的移动操作对拷贝操作如何合成有影响。如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的(除非用户自己定义)。

移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝

class HasPtr{
public:
//添加的移动共组函数
HasPtr(HasPtr &&p)noexcept : ps(p.ps), i(p.i){
p.ps = 0;
}
//赋值运算符:既是移动赋值运算符,也是拷贝赋值运算符
HasPtr operator=(HasPtr rhs){
swap(*this, rhs);
return *this;
}
}

赋值运算符使用非引用参数,意味着该参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动。

更新三/五法则

所有五个拷贝控制成员应该看作一个整体,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。

新标准库定义了一种移动迭代器适配器,一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般迭代器的解引用返回一个左值,移动迭代器的解引用生成一个右值引用。

标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器,此函数接受一个迭代器参数,返回一个移动迭代器。

移动迭代器支持正常的迭代器操作,可以将移动迭代器传递给算法,特别是uninitialized_copyd

void StrVec::reallocate(){
auto newcapacity = size() ? 2 * size() : 1;
auto first = alloc.allocate(newcapacity);
//移动元素
auto last = uninitialized_copy(make_move_iterator(begin()),
make_move_iterator(end()),
first);
free(); //释放旧空间
elements = first; //更新指针
first_free = last;
}

标准库不保证哪些算法适用移动迭代器,哪些不适用。

13.6.3 右值引用和成员函数

区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&

通常我们在一个对象上调用成员函数而不管该对象是一个左值还是一个右值:

string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a'); //在右值上调用find成员
s1 + s2 = "wow!"; //对一个右值进行了赋值

为了维持像后兼容性,新标准类仍然允许向右值赋值。但是,我们可能希望阻止这种用法,此情况下,我们希望强制左侧运算对象(即,this指向的对象)是一个左值。

我们指出this的左值/右值属性的方式与定义const成员函数相同,即在参数列表后放置一个引用限定符

class Foo{
public
Foo &operator=(const Foo&) &; //只能向可修改的左值赋值
}
Foo &Foo::operator=(const Foo &rhs) &
{
//赋值工作
return *this;
}

引用限定符可以是&&&,分别指出this可以指向一个左值或右值。类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中

一个函数可以同时使用const和引用限定,引用限定必须跟随在const限定符之后:

class Foo{
public:
Foo someMem() & const; //error
Foo someMem() const &; //true
};

const限定符可以区分成员函数的重载版本,引用限定符同样也可以区分重载版本。

当我们定义const成员函数时,可以定义两个版本,唯一的差别是一个版本有const限定而另一个没有。引用限定的函数则不一样,如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数加上引用限定符,或者都不加:

class Foo{
public:
Foo sorted() &&;
Foo sorted() const; //错误:必须加上引用限定符
//函数类型别名,此函数可用来比较int值
using Comp = bool(const int&, const int&);
Foo sorted(Comp*); //正确:不同的参数列表
Foo sorted(Comp*) const; //正确:两个版本都没有引用限定符
};

如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符

<⇧>