后台开发工程师技术能力体系之编程语言8——拷贝控制

拷贝控制

  一个类通过定义五种特殊的成员函数来控制对象拷贝、移动、赋值和销毁等操作:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。因此很多类会忽略这些拷贝控制操作,但是对于一些类来说,依赖这些操作的默认定义会导致灾难。

1.拷贝构造函数

  如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。拷贝构造函数的第一个参数必须是引用类型,虽然我们可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。拷贝构造函数在几种情况下都会被隐式地使用,因此拷贝构造函数通常不应该是explicit。
  如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,编译器从给定对象中一次将每个非static成员拷贝到正在创建的对象中。每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员
  拷贝初始化在下列情形下会发生:1、用=定义变量时2、将一个对象作为实参传递给一个非引用类型的形参3、从一个返回类型为非引用类型的函数返回一个对象4、用花括号列表初始化一个数组中的元素或一个聚合类中的成员
  拷贝构造函数被用来初始化非引用类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。
  如果我们使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么我们就不能隐式使用一个explicit构造函数,必须显式使用。

vector<int> v1(10); //正确,直接初始化
vector<int> v2 = 10; //错误
void f(vector<int>); //f的参数进行拷贝初始化
f(10); //错误,不能用一个explicit的构造函数拷贝一个实参
f(vector<int>)(10); //正确,从一个int直接构造一个临时vector

2.拷贝赋值运算符

  与类通过拷贝构造函数控制其对象如何初始化一样,类也可以通过拷贝赋值运算符来控制其对象如何赋值。与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个
  重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此赋值运算符就是一个名为operator=的函数。类似于其他函数,运算符函数也有一个返回类型和一个参数列表。拷贝赋值运算符接受一个与其所在类相同类型的参数,为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。

class Foo{
public:
	Foo& operator=(const Foo&); //赋值运算符
};

  类似合成的拷贝构造函数,合成的拷贝赋值运算符会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。

3.析构函数

  析构函数执行与构造函数相反的操作构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非staitc数据成员。如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分。在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化在一个析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁
  在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型,销毁类类型的成员需要执行成员自己的析构函数;内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。注意,隐式销毁一个内置指针类型的成员不会delete它所指向的对象,智能指针是类类型,所以具有析构函数。
  无论何时一个对象被销毁,就会自动调用其析构函数1、变量离开其作用域时被销毁2、当一个对象被销毁时,其成员被销毁3、容器(无论是标准库容器还是数组)被销毁时,其元素被销毁4、对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁5、对于临时对象,当创建它的完整表达式结束时被销毁
  当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。在析构函数体执行完毕之后,成员会被自动销毁。认识到析构函数体自身并不直接销毁成员是非常重要的,成员是在析构函数体之后隐含的析构阶段被销毁的,在整个对象销毁过程中,析构函数体只是作为成员销毁步骤之外的另一部分而进行的。

4.三/五法则

  当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数,如果需要,那么几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符
  虽然很多类需要定义所有的拷贝控制成员,但某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符,反之亦然。然而无论是否需要拷贝构造函数或拷贝赋值运算符都不必然意味着需要析构函数。

5.阻止拷贝

  我们可以通过将拷贝控制成员定义为=default来显式要求编译器生成合成的版本,一般我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)。
  虽然大多数类应该定义拷贝控制函数和拷贝赋值运算符,但对于某些类来说,这些操作没有合理的意义,在此情况下,定义类时必须采用某种机制阻止拷贝或赋值。例如,iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们,在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的。值得注意的是,析构函数不能是删除的,否则就无法销毁此类型的对象了
  如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应合成的成员函数将被定义为删除的:1、如果类的某个成员的析构函数是删除的或不可访问的,则类的合成析构函数是删除的;2、如果类的某个成员的拷贝构造函数是删除的或不可访问的,或者该成员的析构函数时删除的或不可访问的,则类的合成拷贝构造函数是删除的;3、如果类的某个成员的拷贝赋值运算符时删除的或不可访问的,或者类有一个const的或引用成员,则类的合成赋值运算符是删除的;4、如果类的某个成员的析构函数是删除的或不可访问的,或者类有一个引用成员且没有类内初始化器,或者类有一个const成员但它没有类内初始化器且类型未显示定义默认构造函数,则该类的默认构造函数是删除的。
  一个成员有删除或不可访问的析构函数会导致合成的默认和拷贝构造函数是删除的,其原因是,如果没有这条规定,我们可能创建出无法销毁的对象类有一个const成员会导致合成的拷贝赋值运算符时删除的,其原因是,合成的赋值运算符试图赋值所有成员,但将一个新值赋予一个const对象是不可能的类有一个引用成员会导致合成的拷贝赋值运算符是删除的,其原因是,将一个新值赋予一个引用成员并不会改变引用的指向,只是改变了指向的对象的值,即赋值后,左侧运算对象仍然指向之前的对象,而不会和右侧运算对象指向相同的对象,但这并不是我们所期望的
  在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝,但这样做只能阻止普通用户代码进行拷贝,友元和成员函数仍然可以拷贝对象,为了阻止友元和成员函数进行拷贝,除了将这些拷贝控制成员声明为private,还不能定义他们,即只声明不定义。

6.拷贝控制和资源管理

  通常,管理类外资源的类必须定义拷贝控制成员,为了定义这些成员,我们首先必须确定此类型对象的拷贝语义:类的行为像一个值;类的行为像一个指针。
  类的行为像一个值,意味着它应该有自己的状态,当我们拷贝一个像值的对象时,副本和原对象完全独立,改变副本不会对原对象有任何影响,反之亦然,例如标准库容器和string类。
  类的行为像一个指针,意味着类的对象共享状态,当我们拷贝一个像指针的对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象,反之亦然,例如shared_ptr类。
  定义行为像值的类时需要特别注意赋值运算符,赋值操作会从右侧运算对象拷贝数据并销毁左侧运算对象的资源,但需要考虑两种特殊情形:1、将一个对象赋予它自身也要保证正确;2、即使在赋值时发生异常也能将左侧运算对象置于一个有意义的转态。当编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中,这样既可以处理自赋值情况,也能保证在异常发生时代码也是安全的

class HasPtr{
private:
	std::string *ps;
	int i;
};
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	auto newp = new string(*rhs.ps); //拷贝底层string
	delete ps; //释放旧内存
	ps = newp; //从右侧对象拷贝数据到本对象
	i = rhs.i;
	return *this; //返回本对象
}

  定义行为像指针的类型最好的方法是使用shared_ptr来管理类中的资源,拷贝(或赋值)一个shared_ptr会拷贝(赋值)shared_ptr所指向的指针,shared_ptr类制剂记录有多少用户共享它所指向的对象,当没有用户使用对象时,shared_ptr类负责释放资源。但有时我们希望直接管理资源,在这种情况下,就需要使用引用计数器,但计数器不能直接作为类的成员,而是将计数器保存在动态内存中,当拷贝或赋值对象时,我们拷贝指向计数器的指针,这样副本和原对象都会指向相同的计数器

class HasPtr{
public:
	//构造函数分配新的string和新的计数器,将计数器置为1
	HasPtr(const string &s = string()):ps(new string(s)),i(0),use(new size_t(1)) { }
	//拷贝构造函数拷贝所有三个数据成员,并递增计数器
	HasPtr(const HasPtr &p):ps(p.ps),i(p.i),use(p.use) { ++*use; }
	HasPtr& operator=(const HasPtr&);
	~HasPtr(){
		if(--*use == 0){
			delete ps; //释放string内存
			delete use; //释放计数器内存
		}
	}
private:
	string *ps;
	int i;
	size_t *use; //用来记录有多少个对象共享*ps的成员	
}

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	++*rhs.use; //递增右侧运算对象的引用计数
	if(--*use == 0){ //递减本对象的引用计数,如果为0则释放原对象
		delete ps;
		delete use;
	}
	ps = rhs.ps;
	i = rhs.i;
	use = rhs.use;
	return *this; //返回本对象
}

7.交换操作

  除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数,swap函数在重排元素顺序的算法中非常重要。如果一个类定义了自己的swap,那么算法将使用类自定义版本,否则,算法将使用标准库定义的swap
  为了交换两个对象,我们需要进行一次拷贝和两次赋值,例如交换两个类值的HasPtr对象的代码可能像下面这样:

HasPtr temp = v1; //创建v1的值的一个临时副本
v1 = v2; //将v2的值赋予v1
v2 = temp; //将保存的v1的值赋予v2

  如上所示,无论拷贝和赋值操作,都会分配一个新的string,但理论上这些内存分配都是不必要的,我们更希望swap交换指针,而不是分配string的新副本。即,我们希望这样交换两个HasPtr:

string *temp = v1.ps; //为v1.ps中的指针创建一个副本
v1.ps = v2.ps; //将v2.ps中的指针赋予v1.ps
v2.ps = temp; //将保存的v1.ps中原来的指针赋予v2.ps

  与拷贝控制成员不同,swap并不是必要的,但是对于分配了资源的类,定义swap可能是一种很重要的优化手段。可以在我们的类上定义一个自己版本的swap来重载swap的默认行为,swap的典型实现如下:

class HasPtr{
	friend void swap(HasPtr&,HasPtr&); //定义成friend,以便能访问HasPtr的数据成员。
};
inline void swap(HasPtr &lhs,HasPtr &rhs) //由于swap的存在就是为了优化代码,因此将其声明为inline函数
{
	using std::swap; //因为内置类型没有特定版本的swap,所以这里才会调用标准库std::swap,
					//如果成员有自己类型特定的swap函数,那调用std::swap就是错误的,并不会有任何性能差异。
	swap(lhs.ps,rhs.ps); //交换指针而不是string数据
	swap(lhs.i,rhs.i); //交换int成员
}

  定义swap的类通常用swap来定义它们的赋值运算符,这些运算符使用了一种名为拷贝并交换的技术,这种技术将左侧运算对象与右侧运算对象的一个副本进行交换:

// rhs不是按引用传递,而是按值传递,将右侧运算对象中的string拷贝到rhs,这里调用了拷贝构造函数
HasPtr& HasPtr::operator=(HasPtr rhs)
{
	swap(*this,rhs); //交换左侧运算对象和局部变量rhs的内容,rhs现在指向本对象曾经使用的内存
	return *this; //rhs被销毁,从而deletel了rhs中的指针
}

  这个技术的好处是,它自动处理了自赋值情况且天然就是异常安全的。它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确;代码中唯一可能抛出异常的是拷贝构造函数中的new表达式,如果真的发生了异常,它也会在我们改变左侧运算对象之前发生,这样就保证了异常安全的。

8.对象移动

  新标准的一个最主要的特性就是可以移动而非拷贝对象的能力。在某些情况下,对象拷贝后就立即被销毁了,例如表达式中的临时对象,在这些情况下,移动而非拷贝对象会大幅度提升性能。此外,还有一个原因是源于IO类或unique_ptr这样的类,这些类都包含不能被共享的资源(如指针或IP缓冲),虽然这些类型的对象不能拷贝但是可以移动。
  在旧标准中,没有直接的方法移动对象,容器中所保存的类必须是可拷贝的,但在新标准中,我们可以用容器保存不可拷贝的类型,只要它们能被移动即可
  为了支持移动操作,新标准引入了一种新的引用类型——右值引用,所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用右值引用有一个重要的性质——只能绑定到一个将要销毁的对象上,因此我们可以自由地将一个右值引用的资源“移动”到另一个对象中。右值引用和左值引用一样,也不过是某个对象的别名。我们不能将左值引用绑定到字面常量或是返回右值的表达式,但可以将右值引用绑定到这类表达式,类似,我们也不能将一个右值引用直接绑定到一个左值上

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

  虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型,我们可以通过move函数来获得绑定到左值上的右值引用。move函数告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。一个左值在调用move之后,我们不能对该左值的值做任何假设,除了对它进行赋值或销毁外,将不能再使用它的值使用move的代码应该使用std::move而不是move,这样做是为了避免潜在的名字冲突。

int &&rr3 = std::move(rr1); //正确

  为了让我们自己的类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符,这两个成员类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用,但却是一个右值引用。除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个转态——销毁它是无害的,特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。

// 移动构造函数,实现从一个StrVec到另一个StrVec的元素移动而不是拷贝
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中的内存,在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。
  由于移动操作“窃取”资源,它通常不分配任何资源,因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。一种通知标准库的方法是在我们的构造函数中指明noexcept,且我们必须在类头文件的声明中和定义中都指定noexcept。
  虽然移动操作通常不抛出异常,但抛出异常也是允许的;标准库容器能对异常发生时其自身的行为提供保障,例如vector保证,如果我们调用push_back时发生异常,vector自身不会发生改变。除非vector知道运算类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数,因为拷贝构造函数即使发生异常也能保证vector自身不会发生改变,但移动构造函数发生异常时则不行。如果希望在vector重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数可以安全使用(通过将移动构造函数或移动赋值运算符)标记为noexcept来做到这一点。
  和移动构造函数一样,如果我们的移动赋值运算符不抛出异常,也应该将它标记为noexcept:

StrVec& StrVec::operatpr=(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;
	}
	return *this;
}

  在移动操作之后,移后源对象必须保持有效的、可析构的转态,但是用户不能对其值进行任何假设。一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖当前值。另一方面,移动操作对以后源对象中留下的值没有任何要求,因此我们的程序不应该依赖于以后源对象中的数据。
  编译器也会合成移动构造函数和移动赋值运算符,当合成移动操作的条件与合成拷贝操作的条件大不相同。编译器根本不会为某些类合成移动操作,特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符编译器可以移动内置类型的成员;如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员。

// 编译器会为X和hasX合成移动操作
struct X{
	int i; //内置类型移动
	std::string s; //string定义了自己的移动操作
};
struct hasX{
	X mem; // X有合成的移动操作
};
X x,x2 = std::move(x): //使用合成的移动构造函数
hasX hs,hx2 = std::move(hx); //使用合成的移动构造函数

  与拷贝操作不同,移动操作永远不会隐式定义为删除的函数但是,如果我们显式地要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数定义了移动操作的类也必须定义自己的拷贝操作,否则,这些成员函数默认地被定义为删除的
  如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数,赋值操作的情况类似。StrVec的拷贝构造函数接受一个const StrVec引用,因此它可以用于任何可以转换为StrVec的类型;而移动构造函数接受一个StrVec&&,因此只能用于实参是(非static)右值的情形。

StrVec v1,v2; 
v1 = v2; //v2是左值,使用拷贝赋值
StrVec getVec(istream &); //getVec返回一个右值
v2 = getVec(cin); //getvec(cin)是一个右值:使用移动赋值

  在第一个赋值中,我们将v2传递给赋值运算符,v2的类型是StrVec,表达式v2是一个左值,因此移动版本的赋值运算符是不可行的,因为我们不能隐式地将一个右值引用绑定到一个左值,因此这个赋值语句使用拷贝赋值运算符。
  在第二个赋值中,我们赋予v2的是getVec调用的结果,此表达式是一个右值。在此情况下,两个赋值运算符都是可行的:将getVec的结果绑定到两个运算符的参数都是允许的。调用拷贝赋值运算符需要进行一次到const的转换,而StrVec&&则是精确匹配 ,因此,第二个赋值会使用移动赋值运算符。
  我们可以通过拷贝并交换技术同时实现拷贝和移动赋值运算符

class HasPtr{
pulbic:
	// 添加移动构造函数
	HasPtr(HasPtr &&p) noexpect : ps(p.ps),i(p.i) { p.ps = nullptr;}
	//赋值运算符即使移动赋值运算符,也是拷贝赋值运算符
	HasPtr& operator=(HasPtr rhs)
	{
		swap(*this,rhs);
		return *this;
	}
};

  赋值运算符有一个非引用参数,这意味着此参数要进行拷贝初始化,但由于同时定义了拷贝构造函数和移动构造函数,因此依赖于实参的类型,:左值被拷贝,右值被移动。这样,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。

HasPtr hp,hp2;
hp = hp2; //hp2是一个左值,用过拷贝构造函数来拷贝
hp = std::move(hp2); //移动构造函数移动hp2
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值