类设计者的工具(一):拷贝控制

本文为《C++ Primer》的读书笔记

当定义一个类时, 我们显式或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作:

  • 拷贝构造函数(copy constructor)
  • 移动构造函数(move constructor)
    • 拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么
  • 拷贝赋值运算符(copy-assignment operator)
  • 移动赋值运算符(move-assignment operator)
    • 拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么
  • 析构函数(destructor)

在定义任何C++类时,如果我们不显式定义这些拷贝控制操作, 编译器也会为我们定义,但编译器定义的版本的行为可能并非我们所想

拷贝、赋值与销毁

拷贝构造函数

  • 如果一个构造函数只有一个参数,且该参数是自身类类型的引用,则此构造函数是拷贝构造函数

参数类型必须是 引用,否则会产生循环调用 (在之后会具体说明)

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

虽然我们可以定义一个接受非const引用的拷贝构造函数, 但此参数几乎总是一个const的引用

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

合成拷贝构造函数

  • 如果我们没有为一个类定义拷贝构造函数, 编译器会为我们定义一个。与合成默认构造函数不同, 即使我们定义了其他构造函数, 编译器也会为我们合成一个拷贝构造函数
  • 一般情况, 合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。每个成员的类型决定了它如何拷贝:
    • 对类类型的成员,会使用其拷贝构造函数来拷贝
    • 内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员
  • 对于某些类, 合成拷贝构造函数被用来禁止该类型对象的拷贝构造

作为一个例子, 我们的Sales_data类的合成拷贝构造函数等价于:

class Sales_data {
public:
	Sales_data(const Sales_data&);
private:
	std::string bookNo;
	int units_sold = 0;
	double revenue = 0.0;
};

// 与Sales_data的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data &orig):
	bookNo(orig.bookNo), 
	units_sold(orig.units_sold), 
	revenue(orig.revenue) 
	{} 

拷贝初始化

现在,我们可以完全理解直接初始化和拷贝初始化之间的差异了:

string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
  • 当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数
  • 当我们使用拷贝初始化(copy initialization)时, 我们要求编译器将右侧运算对象拷贝到正在创建的对象中, 如果需要的话还要进行类型转换
    • 拷贝初始化通常使用拷贝构造函数来完成。但是, 如果一个类有一个移动构造函数, 则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成

拷贝初始化不仅在我们用=定义变量时会发生, 在下列情况下也会发生:

  • 将一个对象作为实参传递给一个非引用类型的形参
    • 例如:调用标准库容器的insertpush成员 时, 容器会对其元素进行拷贝初始化。与之相对, 用emplace成员创建的元素都进行直接初始化
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型

  • 如果其参数不是引用类型, 则调用永远也不会成功:为了调用拷贝构造函数, 我们必须拷贝它的实参, 但为了拷贝实参, 我们又需要调用拷贝构造函数, 如此无限循环

编译器可以绕过拷贝构造函数

  • 在拷贝初始化过程中, 编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。即, 编译器被允许将下面的代码
string null_book = "9-999-99999-9"; //拷贝初始化
  • 改写为
string null_book("9-999-99999-9"); // 编译器略过了拷贝构造函数
  • 但是, 即使编译器略过了拷贝/移动构造函数, 但在这个程序点上, 拷贝/移动构造函数必须是存在且可访问的(例如, 不能是private的)

拷贝赋值运算符

Sales_data trans, accum;
trans = accum; // 使用 Sales_data 的拷贝赋值运算符

重载赋值运算符

  • 重载运算符 本质上是函数, 其名字由 operator 关键字后接表示要定义的运算符的符号组成。因此, 赋值运算符就是一个名为operator=的函数
  • 重载运算符的参数表示运算符的运算对象。赋值运算符必须定义为成员函数。如果一个运算符是一个成员函数, 其左侧运算对象就绑定到隐式的this指针。对于一个二元运算符, 例如赋值运算符, 其右侧运算对象作为显式参数传递
  • 拷贝赋值运算符接受一个与其所在类相同类型的参数,而且为了与内置类型的赋值保持一致, 赋值运算符通常返回一个指向其左侧运算对象的引用 (非必须)
    • 值得注意的是, 标准库通常要求保存在容器中的类型要具有赋值运算符, 且其返回值是左侧运算对象的引用
class Foo {
public:
	Foo& operator=(const Foo&); //赋值运算符
	// ...
};
  • 特别注意:在自行实现赋值函数时,一定要确保复制对象中的每一个成分;特别是在你向类中新增一个成分时,尤其要注意这点:
    • (1) 复制所有局部成员变量
    • (2) 调用所有基类内适当的拷贝函数

赋值运算符通常组合了析构函数构造函数的操作

  • 类似析构函数, 赋值操作会销毁左侧运算对象的资源
  • 类似拷贝构造函数, 赋值操作会从右侧运算对象拷贝数据
  • 非常重要的一点是, 这些操作是以正确的顺序执行的, 即使将一个对象赋予它自身, 也保证正确 (在自己管理资源的类中,处理自赋值尤其重要)
    • (1) 一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后, 销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁, 就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了
    • (2) 也可以用条件语句先检测是否为自赋值情况,但这样做更加低效且不具备 “异常安全性”
      • E f f e c t i v e   C + + Effective\ C++ Effective C++ 中,推荐使用第一种方法,因为它不仅能处理自赋值,而且也具备 “异常安全性” (new 如果抛出异常,pb 仍将保持原状)
    • (3) 也可以使用一种 拷贝并交换 (copy and swap) 的技术,这个技术的有趣之处是它自动处理了自赋值情况且天然就是异常安全的;但它在效率上并不占优势。详见下面的 交换操作 swap 一节
// 假设 pb 为指向要管理资源的指针
Foo& Foo::operator=(const Foo& rhs)
{
	Bitmap* pOrig = pb;
	pb = new Bitmap(*rhs.pb);
	delete pOrig;
	return *this;
}

// 等价形式
Foo& Foo::operator=(const Foo& rhs)
{
	if(this == &rhs)
	{	// 是自赋值,直接返回自身的引用
		return *this;
	}
	// 不是自赋值,进一步处理
	delete pb;
	pb = new Bitmap(*rhs.pb);
	return *this;
}

合成拷贝赋值运算符

  • 如果一个类未定义自己的拷贝赋值运算符, 编译器会为它生成一个合成拷贝赋值运算符 (synthesized copy-assignment operator)
  • 类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值 (声明为 =delete)。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员(过程类似拷贝构造函数)
// 等价于合成拷贝赋值运算符
Sales_data&
Sales_data::operator= (const Sales_data &rhs)
{
	bookNo = rhs.bookNo; 			// 调用string:: operator=
	units_sold = rhs.units—sold; 	// 使用内置的int赋值
	revenue = rhs.revenue; 			// 使用内置的double赋值
	return *this; 					// 返回一个此对象的引用
}

析构函数

  • 构造函数初始化对象的非static数据成员,还可能做一些其他工作;而析构函数释放对象使用的资源,并销毁对象的非static数据成员
  • 析构函数是类的一个成员函数, 名字由波浪号接类名构成。它没有返回值, 也不接受参数。由于析构函数不接受参数,因此它不能被重载
class Foo {
public:
	~Foo();  // 析构函数
	// ...
};

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

  • 析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁 (总的过程就类似于构造函数的逆过程)
  • 析构函数中, 不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数, 因此销毁内置类型成员什么也不需要做
    • 例如:隐式销毁一个内置指针类型的成员不会delete它所指向的对象。因此,必要时,需要在析构函数体内手动对指针进行delete操作
    • 而与普通指针不同, 智能指针类类型, 所以具有析构函数。因此, 智能指针成员在析构阶段会被自动销毁

认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中, 析构函数体是作为成员销毁步骤之外的另一部分而进行的

什么时候会调用析构函数

一个对象被销毁, 就会自动调用其析构函数:

  • 当一个对象离开其作用域时被销毁
  • 当一个对象被销毁时, 其成员被销毁
  • 容器(无论是标准库容器还是数组) 被销毁时, 其元素被销毁
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁
  • 对于临时对象, 当创建它的完整表达式结束时被销毁

当指向一个对象的引用或指针离开作用域时, 析构函数不会执行

合成析构函数

  • 当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数(synthesized destructor)
  • 类似拷贝构造函数和拷贝赋值运算符, 对于某些类, 合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况, 合成析构函数的函数体就为空
// 等价于`Sales_data`的合成析构函数
class Sales_data {
public:
	~Sales_data() {}
};

别让异常逃离析构函数

参考: E f f e c t i v e   C + + Effective\ C++ Effective C++

  • C++并不禁止析构函数吐出异常,但它不鼓励你这样做
  • 如果程序遭遇一个“析构期间发生的错误”后无法继续执行, “强迫结束程序”是个合理选项。毕竟它可以阻止异常从析构函数传播出去(那会导致不明确的行为;例如一个vector在销毁其成员时,若一个成员的析构函数抛出异常,那后面的成员就销毁不了了)
DBConn::~DBConn()
{
	try { db.close(); }
	catch (...) {
		制作运转记录,记下对 close 的调用失败;
		// std::abort();	// 可以选择直接终止程序,也可以吞下异常
	}
}
  • 如果客户需要对此做出反应的话,DBConn 应该提供一个普通函数执行 close() 操作,把控制权转交给客户,同时析构函数中的 close() 作为双保险:
class DBConn {
public:
	void close()
	{
		db.close();		// 供客户使用的新函数
		closed = true;
	}
	~DBConn()
	{
		if (!closed) {
			try {			// 关闭连接(如果客户不那么做的话)
				db.close();
			}
			catch (...) { // 如果关闭动作失败
				// 制作运转记录,记下对close 的调用失败
				...
			}
		}
	}
private:
	DBConnection db;
	bool closed;
};

三 / 五法则

5个拷贝控制操作通常应该被看作一个整体

  • 一般来说, 如果一个类定义了任何一个拷贝操作, 它就应该定义所有五个操作

需要析构函数的类也需要拷贝和赋值操作

  • 当我们决定一个类是否要定义它自己版本的拷贝控制成员时, 一个基本原则是首先确定这个类是否需要一个析构函数如果这个类需要一个析构函数, 我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符(反之不一定)

  • 例如下面的HasPtr类。这个类在构造函数中分配动态内存。合成析构函数不会delete 一个指针数据成员。因此, 此类需要定义一个析构函数来释放构造函数分配的内存
  • 如果我们为HasPtr定义一个析构函数,但使用合成版本的拷贝构造函数和拷贝赋值运算符, 考虑会发生什么:
class HasPtr {
public:
	HasPtr(const std::string &s = std::string()):
		ps(new std::string(s)), i(0) {}
	~HasPtr() { delete ps; }
	// 错误: HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
private:
	std::string ps;
	int i;
};
  • 在这个版本的类定义中,构造函数中分配的内存将在HasPtr对象销毁时被释放。但不幸的是, 我们引入了一个严重的错误!这个版本的类使用了合成的拷贝构造函数和拷贝赋值运算符。这些函数简单拷贝指针成员,这意味着多个HasPtr 对象可能指向相同的内存
    • 在如下例子中,当f 返回时, hpret都被销毁, 在两个对象上都会调用HasPtr 的析构函数。此析构函数会delete rethp 中的指针成员。但这两个对象包含相同的指针值。此代码会导致此指针被delete 两次
HasPtr f(HasPtr hp)		// HasPtr是传值参数, 所以将被拷贝
{
	HasPtr ret = hp;	// 拷贝给定的HasPtr
	// 处理ret
	return ret;			// ret和hp被销毁
}

需要拷贝操作的类也需要赋值操作, 反之亦然

  • 某些类所要完成的工作, 只需要拷贝或赋值操作, 不需要析构函数
    • 作为一个例子, 考虑一个类为每个对象分配一个独有的、唯一的序号。这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外, 这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是, 这个类不需要自定义析构函数
  • 这个例子引出了第二个基本原则:如果一个类需要一个拷贝构造函数, 几乎可以肯定它也需要一个拷贝赋值运算符反之亦然

=default

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

阻止拷贝或赋值

  • 对某些类来说, 拷贝构造函数和拷贝赋值运算符没有合理的意义。在此情况下, 定义类时必须采用某种机制阻止拷贝或赋值
    • 例如, iostream 类阻止了拷贝, 以避免多个对象写入或读取相同的缓冲

定义删除的函数

删除的函数

  • 在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted C++ function) 来阻止拷贝
struct NoCopy {
	NoCopy() = default; 
	NoCopy(const NoCopy&) = delete; // 阻止拷贝
	NoCopy &operator= (const NoCopy&) = delete; // 阻止赋值
	~NoCopy() = default; 
};

析构函数不能是删除的成员

  • 值得注意的是, 我们不能删除析构函数
  • 对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。而且, 如果一个类有某个成员的类型删除了析构函数, 我们也不能定义该类的变量或临时对象。但可以动态分配这种类型的对象。但是, 不能释放这些对象
struct NoDtor {
	NoDtor() = default; 
	~NoDtor() = delete; 
};
NoDtor nd; // 错误: NoDtor的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确:但我们不能delete p
delete p; // 错误: NoDtor的析构函数是删除的

合成的拷贝控制成员可能是删除的

对某些类来说, 编译器将合成的拷贝控制成员定义为删除的函数

  • 如果类的某个成员的析构函数是删除的或不可访问的(例如, 是private的),则类的合成析构函数被定义为删除的
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的
  • 如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的
    • 一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的, 这看起来可能有些奇怪。其原因是, 如果没有这条规则, 我们可能会创建出无法销毁的对象
  • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个 const的 或 引用 成员, 则类的合成拷贝赋值运算符被定义为删除的
  • 如果类的某个成员的析构函数是删除的或不可访问的, 或是类有一个引用成员, 它没有类内初始化器, 或是类有一个const成员, 它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的

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

private 拷贝控制

新标准下,程序应该使用 =delete 来阻止拷贝

  • 新标准发布之前, 类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝。但是,友元和成员函数仍旧可以拷贝对象。为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为private的, 但并不定义它们
    • 通过声明(但不定义) private 的拷贝构造函数, 我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误
  • (参考 E f f e c t i v e   C + + Effective\ C++ Effective C++) 也可以专门设计一个阻止拷贝动作的基类,该基类将拷贝初始化和拷贝赋值函数声明为 private,之后当其派生类尝试调用拷贝函数时会无法访问进而使编译器报错 (这个方法将链接期错误提前到了编译期):
    • Uncopyable 不含数据,因此符合 empty base class optimization 资格;但因为它总是扮演 base class, 使用这项技术可能导致多重继承,而多重继承有时会阻止 empty base class optimization
class Uncopyable {
protected:
	Uncopyable() { }		// 允许 derived 对象构造和析构
	~Uncopyable() { }
private:
	Uncopyable(const Uncopyable&);	// 阻止 copying
	Uncopyable& operator=(const Uncopyable&);
};

class HomeForSale: private Uncopyable {
	// class 不再声明 copy 构造函数或 copy assign 操作符
	...
};

交换操作 swap

swap 可能是一种很重要的优化手段

  • 标准库提供了 swap 函数来交换两个对象的值:
    • 对于那些与重排元素顺序的算法一起使用的类, 定义swap是非常重要的。这类算法在需要交换两个元素时会调用swap。如果一个类定义了自己的swap, 那么算法将使用类自定义版本。否则, 算法将使用标准库定义的swap
namespace std {
	template<typename T>
	void swap(T& a, T& b)
	{
		T temp(a);
		a = b;
		b - temp;
	}
}
  • 但对于管理资源的类来说,它们可能用一个指针指向它们管理的资源。在这种情况下,我们更希望swap 交换指针, 而不是分配资源的新副本。因此,管理资源的类通常还定义一个名为 swap 的函数,这可能是一种很重要的优化手段

编写自己的 swap 函数

参考 E f f e c t i v e   C + + Effective\ C++ Effective C++

编写 swap 成员函数作为公共接口

  • 这个成员函数将作为公共接口,被之后 std::swap 的特例化版本以及 non-member swap 版本调用
class HasPtr {
public:
	void swap(HasPtr& other);
	...
};

// 先定义一个 `public` 的 swap 成员函数作为公共接口
// 由于 swap 的存在就是为了优化代码, 我们将其声明为 inline 函数
inline
void HasPtr::swap(HasPtr& other)
{
	using std::swap;
	swap(ps, other.ps);
	swap(i, other.i);
}
swap 函数应该调用 swap, 而不是 std::swap
  • 上面的代码中有一个很重要的微妙之处:虽然这一点在这个特殊的例子中并不重要, 但在一般情况下它非常重要:每个swap调用都应该是swap, 而不是std::swap如果存在类型特定的swap版本, 其匹配程度会优于std 中定义的版本,swap调用会与之匹配。如果不存在类型特定的版本,则会使用std中的版本
    • 在本例中, 数据成员是内置类型的, 而内置类型是没有特定版本的swap的, 所以在本例中, 对swap的调用会调用标准库std::swap
    • 但是, 如果一个类的成员有自己类型特定的swap函数, 调用std::swap就是错误的了
      • 例如, 假定我们有另一个命名为Foo的类, 它有一个类型为HasPtr的成员。我们可以为Foo编写一个swap函数, 来避免使用标准库版本的swap带来的额外的拷贝。但是,如果用std::swap就与简单使用默认版本的swap没有任何性能差异
void swap(Foo &lhs, Foo &rhs)
{
	// 错误: 这个函数使用了标准库版本的 swap, 而不是 HasPtr 版本
	std::swap(lhs.h, rhs.h);
	// 交换类型 Foo 的其他成员
}

// 正确
void swap(Foo &lhs, Foo &rhs)
{
	using std::swap;
	swap(lhs.h, rhs.h); //使用 HasPtr 版本的 swap
	// 交换类型 Foo 的其他成员
}
成员版 swap 绝不可抛出异常
  • 成员版 swap 绝不可抛出异常。那是因为 swap 的一个最好的应用是帮助 classes (和 class templates) 提供强烈的异常安全性 (exception-safety) 保障
  • 因此当你写下一个自定版本的 swap, 往往提供的不只是高效置换对象值的办法,而且不抛出异常。一般而言这两个特性是连在一起的,因为高效率的 swap 几乎总是基于对内置类型的操作,而内置类型上的操作绝不会抛出异常

copy and swap 策略常用于构造“异常安全”的代码

特例化 std::swap

// 特例化标准库的 swap 函数
namespace std {
	template<>
	void swap<HasPtr>(HasPtr &a, HasPtr &b)
	{
		a.swap(b);
	}
}
  • 但如果 HasPtr 是一个模板,由于函数不能进行部分特例化,我们也就无法特例化标准库 swap 函数了,而是只能对它进行重载;然而我们可以特例化标准库里的模板,但直接向 std 命名空间里添加新东西尽管可以通过编译,但却是被标准库委员会禁止的,绝不是好习惯
  • 因此,如果你在编写一个模板类,那就不能特例化 std::swap
template<typename T>
class HasPtr { ... };

// 企图部分特例化 swap,无法通过编译
namespace std {
	template<typename T>
	void swap<HasPtr<T>>(HasPtr<T> &a, HasPtr<T> &b)
	{
		a.swap(b);
	}
}

// 只能进行重载,但往 std 命名空间加新东西却是不被建议的
namespace std {
	template<typename T>
	void swap(HasPtr<T> &a, HasPtr<T> &b)
	{
		a.swap(b);
	}
}

使用 non-member swap

  • 作为替代方案,我们可以声明一个 non-member swap,该函数使用公共接口 swap 成员函数
template<typename T>
inline void swap(HasPtr &a, HasPtr &b)
{
	a.swap(b);
}

总结

  • 提供一个 publicswap 成员函数,让它高效地置换你的类型的两个对象值。这个函数绝不该抛出异常
  • 在你的类或模板类所在的命名空间内提供一个 non-member swap,并令它调用上述 swap 成员函数
  • 如果你正编写一个类 (而非模板类),为你的类特例化 std::swap,并令它调用你的 swap 成员函数
    • 这样保证了即使有人企图用 std::swap 的方法调用类专属版本的 swap 时也不会出错,进而让你编写的类专属版本的 swap 在尽可能多的语境下被调用

在赋值运算符中使用 swap

  • 定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为 拷贝并交换 (copy and swap) 的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换:
// 注意 rhs 是按值传递的, 意味着 HasPtr 的拷贝构造函数
// 将右侧运算对象中的 string 拷贝到 rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
	// 交换左侧运算对象和局部变量 rhs 的内容
	swap(*this, rhs); 	// rhs 现在指向本对象曾经使用的内存
	return *this; 		// rhs 被销毁, 从而 delete 了 rhs 中的指针
}
  • 这个技术的有趣之处是它自动处理了自赋值情况且天然就是异常安全的。代码中唯一可能抛出异常的是拷贝构造函数中的new表达式。如果真发生了异常, 它也会在我们改变左侧运算对象之前发生
  • 但显然,它在效率上并不占优势

对象移动

新标准的一个最主要的特性是可以移动而非拷贝对象的能力

  • 很多情况下都会发生对象拷贝。在其中某些情况下, 对象拷贝后就立即被销毁了。在这些情况下, 移动而非拷贝对象会大幅度提升性能
  • 使用移动而不是拷贝的另—个原因源于 IO 类或unique_ptr 这样的类。这些类都包含不能被共享的资源。因此, 这些类型的对象不能拷贝但可以移动

在旧版本的标准库中, 容器中所保存的类必须是可拷贝的。但在新标准中, 我们可以用容器保存不可拷贝的类型, 只要它们能被移动即可

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

右值引用

  • 为了支持移动操作,新标准引入了一种新的引用类型:右值引用(rvalue reference)。所谓右值引用就是必须绑定到右值的引用。我们通过&&来获得右值引用
  • 右值引用有一个重要的性质:只能绑定到一个将要销毁的对象 (一个右值)。因此, 我们可以自由地将一个右值引用的资源” 移动” 到另一个对象中

  • 右值引用与左值引用(即常规引用,为了与右值引用区分开来, 我们可以称之为左值引用(lvalue reference))相比,有着完全相反的绑定特性:
    • 我们可以将一个右值引用绑定到要求转换的表达式、字面常量或是返回右值的表达式上, 但不能将一个右值引用直接绑定到一个左值上
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; 	//错误:表达式rrl是左值!

其实有了右值表示临时对象这一观察结果, 变量是左值这一特性并不令人惊讶

标准库move 函数

#include <utility>
  • 虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型
  • 我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用
    • 在调用move之后, 我们不能对移后源对象的值做任何假设。我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值
// 除了对`rr1`赋值或销毁它外, 我们将不再使用它
int &&rr3 = std::move(rr1); // ok

move 我们不提供using声明,而是直接调用std::move以避免潜在的名字冲突。原因是这个函数的形参为右值引用,具体原因见 命名空间

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

为了让我们自己的类型支持移动操作, 需要为其定义移动构造函数移动赋值运算符。这两个成员类似对应的拷贝操作, 但它们从给定对象” 窃取“ 资源而不是拷贝资源

  • 类似拷贝构造函数, 移动构造函数的第一个参数是该类类型的一个右值引用,且任何额外的参数都必须有默认实参
  • 除了完成资源移动, 移动构造函数还必须确保移后源对象处于这样一个状态:销毁它是无害的。特别是, 一旦资源完成移动, 源对象必须不再指向被移动的资源-----这些资源的所有权已经归属新创建的对象

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来承诺函数不抛出异常。在一个构造函数中, noexcept出现在参数列表和初始化列表开始的冒号之间
    • 必须在类头文件的声明中和定义中都指定noexcept
class StrVec {
public:
	StrVec(StrVec&&) noexcept; //移动构造函数
	// 其他成员的定义, 如前
};
StrVec::StrVec(StrVec &&s) noexcept : /* 成员初始化器*/
{ /* 构造函数体*/ }

我们需要指出一个移动操作不抛出异常,这是因为两个相互关联的事实:

  • 虽然移动操作通常不抛出异常, 但抛出异常也是允许的
  • 标准库容器能对异常发生时其自身的行为提供保障
    例如,vector 保证, 如果调用push_back时发生异常,vector自身不会发生改变
    现在让我们思考push_back内部发生了什么。对一个vector调用push_back可能要求为vector重新分配内存空间。当重新分配vector的内存时,vector将元素从旧空间移动到新内存中。如果重新分配过程使用了移动构造函数, 且在移动了部分而不是全部元素后抛出了一个异常, 就会产生问题。旧空间中的移动源元素已经被改变了, 而新空间中未构造的元素可能尚不存在。在此情况下,vector将不能满足自身保持不变的要求。另一方面, 如果vector使用了拷贝构造函数且发生了异常, 它可以很容易地满足要求。在此情况下, 当在新内存中构造元素时, 旧元素保持不变。如果此时发生了异常,vector 可以释放新分配的(但还未成功构造的)内存并返回。vector原有的元素仍然存在。为了避免这种潜在问题, 除非vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中, 它就必须使用拷贝构造函数而不是移动构造函数。如果希望在vector重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数可以安全使用

移动赋值运算符

  • 类似拷贝赋值运算符, 移动赋值运算符必须正确处理自赋值

我们费心地去检查自赋值情况看起来有些奇怪。毕竟, 移动赋值运算符需要右侧运算对象的一个右值。我们进行检查的原因是此右值可能是move调用的返回结果,我们不能在使用右侧运算对象的资源之前就释放左侧运算对象的资源(可能是相同的资源)

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;
	}
	return *this;
}

移后源对象必须可析构

  • 在移动操作之后, 移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设
    • 从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此, 当我们编写一个移动操作时, 必须确保移后源对象进入一个可析构的状态
    • 除了将移后源对象置为析构安全的状态之外, 移动操作还必须保证对象仍然是有效的。一般来说, 对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值
    • 例如, 当我们从一个标准库string或容器对象移动数据时, 我们知道移后源对象仍然保持有效。因此, 我们可以对它执行诸如emptysize这些操作。但是, 我们不知道将会得到什么结果

合成的移动操作

  • 只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时, 编译器才会为它合成移动构造函数或移动赋值运算符
    • 编译器可以移动内置类型的成员。如果一个成员是类类型, 且该类有对应的移动操作, 编译器也能移动这个成员
    • 如果一个类没有移动操作, 通过正常的函数匹配, 类会使用对应的拷贝操作来代替移动操作
// 编译器会为X和hasX合成移动操作
struct X {
	int i; // 内置类型可以移动
	std::string s; // string定义了自己的移动操作
};
struct hasX {
	X mem; // X有合成的移动操作
};
X x, x2 = std::move(x);			// 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx);	// 使用合成的移动构造函数

  • 与拷贝操作不同, 移动操作永远不会隐式定义为删除的函数
  • 但是, 如果我们显式地要求编译器生成 =default 的移动操作, 且编译器不能移动所有成员, 则编译器会将移动操作定义为删除的函数 (If a Class C explicitly requested a move constructor, which the compiler is unable to generate. The Class C will get a deleted move constructor.)。除了一个重要例外 (用 =delete 显式地将移动操作定义为删除的函数),什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则 (With one important exception, the rules for when a synthesized move operation is defined as deleted are analogous to those for the copy operations):
    • 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似
    • 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的
    • 如果类的析构函数被定义为删除的或不可访问的, 则类的移动构造函数被定义为删除的
    • 如果有类成员是const的或是引用, 则类的移动赋值运算符被定义为删除的
// assume Y is a class that defines its own copy constructor but not a move constructor
struct hasY {
	hasY() = default;
	hasY(hasY&&) = default;
	Y mem; // hasY will have a deleted move constructor
};
// The compiler can copy objects of type Y but cannot move them.
hasY hy, hy2 = std::move(hy); // error: move constructor is deleted

Whether a class defines its own move operations has an impact on how the copy operations are synthesized.

  • 如果类定义了移动构造函数或移动赋值运算符, 则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。因此,定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则, 这些成员默认地被定义为删除的

移动右值, 拷贝左值……

如果一个类既有移动构造函数, 也有拷贝构造函数, 编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似:

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

在第二个赋值中, 我们赋予v2的是一个右值。在此情况下,两个赋值运算符都是可行的。调用拷贝赋值运算符需要进行一次到const的转换, 而StrVec&&则是精确匹配。因此, 第二个赋值会使用移动赋值运算符

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

如果一个类有一个拷贝构造函数但未定义移动构造函数, 那么 编译器不会合成移动构造函数, 函数匹配规则保证该类型的对象会被拷贝, 即使我们试图通过调用move来移动它们时也是如此:

class Foo {
public:
	Foo() = default;
	Foo(const Foo&); // 拷贝构造函数
	// 其他成员定义, 但Foo未定义移动构造函数
};
Foo x;
Foo z(std::move(x)); //拷贝构造函数, 因为未定义移动构造函数

在对z进行初始化时, 我们调用了move(x), 它返回一个绑定到xFoo&&Foo的拷贝构造函数是可行的, 因为我们可以将一个Foo&&转换为一个const Foo&。因此, z的初始化将使用Foo的拷贝构造函数

拷贝并交换赋值运算符和移动操作

我们的HasPtr版本定义了一个拷贝并交换赋值运算符。如果我们为此类添加一个移动构造函数, 它实际上也会获得一个移动赋值运算符:

class HasPtr {
public:
	// 添加的移动构造函数
	HasPtr(HasPtr &&p) noexceptps(p.ps), i(p.i) { p.ps = 0; }
	// 赋值运算符既是移动赋值运算符, 也是拷贝赋值运算符
	HasPtr& operator=(HasPtr rhs)
				{ swap(*this, rhs); return *this; }
	// 其他成员的定义
} ;

注意:如果已经定义了像上面代码中的拷贝并交换赋值运算符,那么再定义移动赋值运算符将会产生编译错误,例如:
对于hp = std::move(hp2)这样的赋值语句,两个运算符匹配的一样好,从而产生了二义性

让我们观察赋值运算符。此运算符有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖千实参的类型, 拷贝初始化要么使用拷贝构造函数, 要么使用移动构造函数:左值被拷贝, 右值被移动。因此, 单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能

例如, 假定hphp2都是HasPtr对象:

hp = hp2; // hp2是一个左值; hp2通过拷贝构造函数来拷贝
hp = std::move(hp2); //移动构造函数移动hp2

然而,这种实现方式的性能并不好:
在进行拷贝赋值时,先通过拷贝构造创建了hp2的拷贝rhs,然后再交换hprhs。这其中rhs作为中间媒介完全是一个冗余操作,效率没有下面的代码高

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	auto newp = new string(*rhs.ps); // 拷贝底层string
	delete ps; // 释放旧内存
	ps = newp;
	i = rhs.i;
	return *this;
}

而在移动赋值时,先从hp2转移到rhs,再交换到hp,也是冗余的,效率没有下面的代码高

inline
HasPtr& HasPtr::operator=(HasPtr &&rhs) noexcept
{
	if(this != &rhs)
	{
		delete ps;
		ps = rhs.ps;
		rhs.ps = nullptr;
		rhs.i = 0;
	}
	return *this;
}

移动迭代器

StrVecreallocate 成员使用了一个for 循环来调用construct 从旧内存将元素拷贝到新内存中。我们能调用uninitialized_copy作为一种替换方法。但是,uninitialized_copy对元素进行拷贝操作。标准库中并没有类似的函数将对象“ 移动” 到未构造的内存中

新标准库中定义了一种移动迭代器(move iterator) 适配器。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。移动迭代器的解引用运算符生成一个右值引用

我们通过调用标准库的make_move_iterator 函数将一个普通迭代器转换为一个移动迭代器:

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;
	cap = elements + newcapacity;
}

值得注意的是, 标准库不保证哪些算法适用移动迭代器, 哪些不适用。由于移动一个对象可能销毁掉原对象, 因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时, 才能将移动迭代器传递给算法

建议:不要随意使用移动操作
由于一个移后源对象具有不确定的状态, 当我们调用move时, 必须绝对确认移后源对象没有其他用户。通过在类代码中小心地使用move, 可以大幅度提升性能。而如果随意在普通用户代码中使用移动操作,很可能导致莫名其妙的、难以查找的错误

右值引用和成员函数

如果一个成员函数同时提供拷贝和移动版本, 它也能从中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式:

  • 第一个版本接受一个指向const 的左值引用
  • 第二个版本接受一个指向非const的右值引用

例如, 定义了push_back的标准库容器提供两个版本: 一个版本有一个右值引用参数, 而另一个版本有一个const左值引用:

void push_back(const X&);	// 拷贝: 绑定到任意类型的X
void push_back(X&&);		// 移动: 只能绑定到类型X的可修改的右值

作为一个更具体的例子, 我们将为StrVec类定义另一个版本的push_back:

class StrVec {
public:
	void push_back(const std::string&); //拷贝元素
	void push_back(std::string&&); //移动元素
	// 其他成员的定义, 如前
};

void StrVec::push_back(const string& s)
{
	chk_n_alloc(); // 确保有空间容纳新元素
	// 在first_free 指向的元素中构造s 的一个副本
	alloc.construct(first_free++, s);
}

void StrVec::push_back(string &&s)
{
	chk_n_alloc(); //如果需要的话为StrVec 重新分配内存
	alloc.construct(first_free++, std::move(s));
	// 由于`move` 返回一个右值引用,传递给`construct` 的实参类型
	// 是`string&&`。因此, 会使用`string`的移动构造函数来构造新元素
}

当我们调用push_back时, 实参类型决定了新元素是拷贝还是移动到容器中:

StrVec vec; //空StrVec
string s = "some string or another";
// 这些调用的差别在于实参是一个左值还是一个右值
vec.push_back(s); //调用push_back(const string&)
vec.push_back("done"); //调用push_back(string&&)

右值和左值引用成员函数

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

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

有时, 右值的使用方式可能令人惊讶:

s1 + s2 = "wow";

此处我们对两个string的连接结果一个右值, 进行了赋值

在旧标准中, 我们没有办法阻止这种使用方式。为了维待向后兼容性, 新标准库类仍然允许向右值赋值。但是, 我们可能希望在自己的类中阻止这种用法。在此情况下, 我们希望强制左侧运算对象(即, this指向的对象)是一个左值:在参数列表后放置一个引用限定符(reference qualifier):

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

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

一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后

class Foo {
public:
	Foo anotherMem() const &;
};

重载和引用函数

就像一个成员函数可以根据是否有const来区分其重载版本一样, 引用限定符也可以区分重载版本。而且, 我们可以综合引用限定符和const来区分一个成员函数的重载版本

例如, 我们将为Foo定义一个名为datavector成员和一个名为sorted 的成员函数, sorted返回一个Foo对象的副本, 其中vector已被排序:

class Foo {
public:
	Foo sorted() && ;		// 可用于可改变的右值
	Foo sorted() const &;	// 可用于任何类型的Foo
	// Foo 的其他成员的定义
private:
	vector<int> data;
};

// 本对象为右值, 因此可以原址排序
Foo Foo::sorted() &&
{
	sort(data.begin(), data.end());
	return *this;
}

// 本对象是const 或是一个左值, 哪种情况我们都不能对其进行原址排序
Foo Foo::sorted() const & {
	Foo ret(*this); //拷贝一个副本
	sort(ret.data.begin(), ret.data.end()); //排序副本
	return ret; //返回副本
}
// 也可以利用右值引用版本的成员函数作如下定义:
Foo Foo::sorted() const & {
	return Foo(*this).sorted();
}

当我们对一个右值执行sorted时, 它可以安全地直接对data成员进行排序。对象是一个右值, 意味着没有其他用户, 因此我们可以改变对象。当对一个const 右值或一个左值执行sorted时, 我们不能改变对象, 因此就需要在排序前拷贝data

当我们定义引用限定的函数时,如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数, 就必须对所有函数都加上引用限定符, 或者所有都不加

class Foo {
public:
	Foo sorted() &&;
	Foo sorted() const; // 错误: 必须加上引用限定符

	using Comp = bool(const int&, const int&);
	Foo sorted(Comp*) ; // 正确: 不同的参数列表
	Foo sorted(Comp*) const; // 正确: 两个版本都没有引用限定符
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值