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

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

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

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

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

拷贝、赋值与销毁

拷贝构造函数

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

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

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

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

合成拷贝构造函数

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

一般情况, 合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员

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

扫描二维码关注公众号,回复: 12025095 查看本文章
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)时, 我们要求编译器将右侧运算对象拷贝到正在创建的对象中, 如果需要的话还要进行类型转换

拷贝初始化通常使用拷贝构造函数来完成。但是, 如我们将在13.6.2节所见, 如果一个类有一个移动构造函数, 则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成

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

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

拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型, 则调用永远也不会成功:为了调用拷贝构造函数, 我们必须拷贝它的实参, 但为了拷贝实参, 我们又需要调用拷贝构造函数, 如此无限循环

拷贝初始化的限制

如果我们使用的初始化值要求通过一个explicit 的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:

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

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

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

string null_book = "9-999-99999-9"; //拷贝初始化

改写为

string null_book("9-999-99999-9"); // 编译器略过了拷贝构造函数

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

拷贝赋值运算符

类可以控制其对象如何赋值:

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

重载赋值运算符

重载运算符(overloaded operator)本质上是函数, 其名字由operator 关键字后接表示要定义的运算符的符号组成。因此, 赋值运算符就是一个名为operator=的函数

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

拷贝赋值运算符接受一个与其所在类相同类型的参数:

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

为了与内置类型的赋值保持一致, 赋值运算符通常返回一个指向其左侧运算对象的引用

值得注意的是, 标准库通常要求保存在容器中的类型要具有赋值运算符, 且其返回值是左侧运算对象的引用

合成拷贝赋值运算符

如果一个类未定义自己的拷贝赋值运算符, 编译器会为它生成一个合成拷贝赋值运算符(synthesized copy-assignment operator)

类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员(过程类似拷贝构造函数)

作为一个例子, 下面的代码等价于Sales_data的合成拷贝赋值运算符:

// 等价于合成拷贝赋值运算符
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() {
    
    }
};

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

三/五法则

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 对象可能指向相同的内存:

HasPtr f(HasPtr hp)		// HasPtr是传值参数, 所以将被拷贝
{
    
    
	HasPtr ret = hp;	// 拷贝给定的HasPtr
	// 处理ret
	return ret;			// ret和hp被销毁
}

f 返回时, hpret都被销毁, 在两个对象上都会调用HasPtr 的析构函数。此析构函数会delete rethp 中的指针成员。但这两个对象包含相同的指针值。此代码会导致此指针被delete 两次

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

某些类所要完成的工作, 只需要拷贝或赋值操作, 不需要析构函数

作为一个例子, 考虑一个类为每个对象分配一个独有的、唯一的序号。这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外, 这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是, 这个类不需要自定义析构函数。

这个例子引出了第二个基本原则:如果一个类需要一个拷贝构造函数, 几乎可以肯定它也需要一个拷贝赋值运算符反之亦然

使用=default

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

阻止拷贝或赋值

对某些类来说, 拷贝构造函数和拷贝赋值运算符没有合理的意义。在此情况下, 定义类时必须采用某种机制阻止拷贝或赋值

例如, iostream 类阻止了拷贝, 以避免多个对象写入或读取相同的缓冲

定义删除的函数

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

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

struct NoCopy {
    
    
	NoCopy() = default; 
	NoCopy(const NoCopy&) = delete; // 阻止拷贝
	NoCopy &operator= (const NoCopy&) = delete; // 阻止赋值
	~NoCopy() = default; 
};

=default不同, =delete必须出现在函数第一次声明的时候, 这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码, 因此=default直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的, 以便禁止试图使用它的操作

=default的另一个不同之处是, 我们可以对任何函数指定=delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default)

虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时, 删除函数有时也是有用的

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

值得注意的是, 我们不能删除析构函数

对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。而且, 如果一个类有某个成员的类型删除了析构函数, 我们也不能定义该类的变量或临时对象。但可以动态分配这种类型的对象。但是, 不能释放这些对象

struct NoDtor {
    
    
	NoDtor() = default; 
	~NoDtor() = delete; 
};
NoDtor nd; // 错误: NoDtor的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确:但我们不能delete p
delete p; // 错误: NoDtor的析构函数是删除的

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

如果我们未定义拷贝控制成员,编译器会为我们定义合成的版本。类似的,如果一个类未定义构造函数,编译器会为其合成一个默认构造函数。对某些类来说, 编译器将这些合成的成员定义为删除的函数

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

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

private 拷贝控制

在新标准发布之前, 类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝。但是,友元和成员函数仍旧可以拷贝对象。为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为private的, 但并不定义它们

通过声明(但不定义) private 的拷贝构造函数, 我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误:成员函数或友元函数中的拷贝操作将会导致链接时错误

声明但不定义一个成员函数是合法的,对此只有一个例外,我们将在15.2.1节(第528页)中介绍

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

拷贝控制和资源管理

通常, 管理类外资源的类必须定义拷贝控制成员。这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数, 那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符

为了定义这些成员, 我们首先必须确定此类型对象的拷贝语义。一般来说, 有两种选择: 可以定义拷贝操作, 使类的行为看起来像一个值或者像一个指针

  • 当我们拷贝一个像值的对象时,副本和原对象是完全独立的
  • 行为像指针的类则共享状态。当我们拷贝一个这种类的对象时, 副本和原对象使用相同的底层数据

行为像值的类

class HasPtr {
    
    
public:
	HasPtr(const std::string &s = std::string()) :
		ps(new std::string(s)), i(0) {
    
    }
	// 对ps指向的string, 每个HasPtr对象都有自己的拷贝
	HasPtr(const HasPtr &p):
		ps(new std::string(*p.ps)), i(p.i) {
    
    }
	HasPtr& operator=(const HasPtr &);
	~HasPtr() {
    
     delete ps; }
private:
	std::string *ps;
	int i;
}

类值拷贝赋值运算符

赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数, 赋值操作会销毁左侧运算对象的资源类似拷贝构造函数, 赋值操作会从右侧运算对象拷贝数据。但是,非常重要的一点是, 这些操作是以正确的顺序执行的, 即使将一个对象赋予它自身, 也保证正确

在本例中, 通过先拷贝右侧运算对象, 我们可以处理自赋值情况, 并能保证在异常发生时代码也是安全的。在完成拷贝后, 我们释放左侧运算对象的资源, 并更新指针指向新分配的string:

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

当你编写一个赋值运算符时, 一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后, 销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁, 就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了

定义行为像指针的类

对于行为类似指针的类, 我们需要为其定义拷贝构造函数和拷贝赋值运算符, 来拷贝指针成员本身而不是它指向的string。我们的类仍然需要自己的析构函数来释放接受string 参数的构造函数分配的内存。但是, 在本例中, 析构函数不能单方面地释放关联的string。只有当最后一个指向stringHasPtr 销毁时, 它才可以释放string

令一个类展现类似指针的行为的最好方法是使用shared_ptr 来管理类中的资源。拷贝(或赋值) 一个shared_ptr 会拷贝(赋值) shared_ptr 所指向的指针。当没有用户使用对象时,shared_ptr 类负责释放资源

引用计数

但是, 有时我们希望直接管理资源。在这种情况下, 使用引用计数(reference count)就很有用了。引用计数的工作方式如下:

  • 除了初始化对象外, 每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态, 因此将计数器初始化为 1
  • 拷贝构造函数不分配新的计数器, 而是拷贝给定对象的数据成员, 包括计数器。拷贝构造函数递增共享的计数器, 指出给定对象的状态又被一个新用户所共享
  • 析构函数递减计数器, 指出共享状态的用户少了一个。如果计数器变为0, 则析构函数释放状态
  • 拷贝赋值运算符递增右侧运算对象的计数器, 递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0, 意味着它的共享状态没有用户了, 拷贝赋值运算符就必须销毁状态

唯一的难题是确定在哪里存放引用计数。一种方法是将计数器保存在动态内存中。当创建一个对象时, 我们也分配一个新的计数器。当拷贝或赋值对象时, 我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器

定义一个使用引用计数的类

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

补充:练习13.28

交换操作

除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap 的函数。对于那些与重排元素顺序的算法一起使用的类, 定义swap是非常重要的。这类算法在需要交换两个元素时会调用swap。如果一个类定义了自己的swap, 那么算法将使用类自定义版本。否则, 算法将使用标准库定义的swap

对于分配了资源的类, 定义swap可能是一种很重要的优化手段

为了交换两个对象我们需要进行一次拷贝和两次赋值。例如,交换两个类值HasPtr对象(的代码可能像下面这样:

HasPtr temp = v1;
v1 = v2;
v2 = temp;

拷贝一个类值的HasPtr会分配一个新string并将其拷贝到HasPtr指向的位置。但理论上, 这些内存分配都是不必要的。我们更希望swap 交换指针, 而不是分配string 的新副本。即, 我们希望这样交换两个HasPtr:

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

编写我们自己的swap 函数

swap的典型实现如下:

class HasPtr {
    
    
	friend void swap(HasPtr&, HasPtr&);
	// 其他成员定义, 与之前一样
};

// 由于swap的存在就是为了优化代码, 我们将其声明为inline函数
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不是std::swap

在本例中, 数据成员是内置类型的, 而内置类型是没有特定版本的swap的, 所以在本例中, 对swap的调用会调用标准库std::swap

但是, 如果一个类的成员有自己类型特定的swap函数, 调用std::swap就是错误的了

例如, 假定我们有另一个命名为Foo的类, 它有一个类型为HasPtr的成员。我们可以为Foo编写一个swap函数, 来避免使用标准库版本的swap带来的额外的拷贝。但是,如果这样编写:

void swap(Foo &lhs, Foo &rhs)
{
    
    
	// 错误: 这个函数使用了标准库版本的swap, 而不是HasPtr 版本
	std::swap(lhs.h, rhs.h);
	// 交换类型Foo 的其他成员
}

使用此版本与简单使用默认版本的swap并没有任何性能差异。正确的swap函数如下所示:

void swap(Foo &lhs, Foo &rhs)
{
    
    
	using std::swap;
	swap(lhs.h, rhs.h); //使用HasPtr 版本的swap
	// 交换类型Foo 的其他成员
}

每个swap调用应该都是未加限定的。即,每个调用都应该是swap, 而不是std::swap。如果存在类型特定的swap版本, 其匹配程度会优于std 中定义的版本,swap调用会与之匹配。如果不存在类型特定的版本,则会使用std中的版本

在赋值运算符中使用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表达式。如果真发生了异常, 它也会在我们改变左侧运算对象之前发生

动态内存管理类

某些类需要在运行时分配可变大小的内存空间。这种类通常可以使用标准库容器来保存它们的数据。但是, 某些类需要自已进行内存分配。这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存

例如, 我们将实现标准库vector类的一个简化版本。该类只用于string。因此, 它被命名为StrVec

StrVec 类的设计

我们在StrVec类中使用一个allocator来获得原始内存。因此,在需要添加新元素时用allocatorconstruct成员在原始内存中创建对象。删除一个元素时, 使用destroy成员来销毁元素

每个StrVec有三个指针成员指向其元素所使用的内存:

  • elements, 指向分配的内存中的首元素
  • first_free, 指向最后一个实际元素之后的位置
  • cap, 指向分配的内存末尾之后的位置

在这里插入图片描述
除了这些指针之外, StrVec 还有一个名为alloc 的静态成员, 其类型为allocator<string>。我们的类还有4个工具函数:

  • alloc_n_copy会分配内存, 并拷贝一个给定范围中的元素
  • free会销毁构造的元素并释放内存
  • chk_n_alloc保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素, 则调用reallocate来分配更多内存
  • reallocate在内存用完时为StrVec分配新内存

StrVec 类定义

class StrVec {
    
    
public:
	StrVec(): // allocator成员进行默认初始化
		elements(nullptr), first_free(nullptr), cap(nullptr) {
    
    }
	StrVec(std::initializer_list<std::string> il);
	StrVec(const StrVec&); 
	StrVec &operator=(const StrVec&); 
	~StrVec(); 
	void push_back(const std::string&); 
	size_t size() const {
    
     return first_free - elements; }
	size_t capacity() const {
    
     return cap - elements; }
	std::string *begin() const {
    
     return elements; }
	std::string *end() const {
    
     return first_free; )
	// ...
private:
	static std::allocator<std::string> alloc; // 分配元素
	// 被添加元素的函数所使用
	void chk_n_alloc()
		{
    
     if (size() == capacity()) reallocate(); }
	// 工具函数, 被拷贝构造函数、赋值运算符和析构函数所使用
	std::pair<std::string*, std::string*> alloc_n_copy
		(const std::string*, const std::string*);
	void free(); 				// 销毁元素并释放内存
	void reallocate(); 			// 获得更多内存并拷贝已有元素
	std::string *elements; 		// 指向数组首元素的指针
	std::string *first_free; 	// 指向数组笫一个空闲元素的指针
	std::string *cap; 			// 指向数组尾后位置的指针
};
void StrVec::push_back(const string& s)
{
    
    
	chk_n_alloc(); // 确保有空间容纳新元素
	// 使用原始内存,必须先调用construct构造对象
	// 这里使用拷贝构造函数进行初始化
	alloc.construct(first_free++, s);
}

alloc_n_copy成员会分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。此函数返回一个指针的pair, 两个指针分别指向新空间的开始位置和拷贝的尾后的位置:

pair<string*, string*>
StrVec::alloc_n_copy(const string *b, const string *e)
{
    
    
	// 分配空间保存给定范围中的元素
	auto data = alloc.allocate(e - b);
	// 初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成
	return {
    
    data, uninitialized_copy(b, e, data)};
}
inline
StrVec::StrVec(std::initializer_list<std::string> il)
{
    
    
	auto newdata = alloc_n_data(il.begin(), il.end());
	elements = newdata.first;
	first_free = cap = newdata.second;
}

free成员有两个责任: 首先destroy 元素, 然后释放StrVec自己分配的内存空间:

void StrVec::free()
{
    
    
	// 不能传递给deallocate 一个空指针, 如果elements为0, 函数什么也不做
	if (elements) {
    
    
		// 逆序销毁旧元素
		for (auto p = first_free; p != elements; )
			alloc.destroy(--p);
		alloc.deallocate(elements, cap - elements);
	}
}

free中的for循环也可以用for_eachlambda表达式来代替:

for_each(elements, first_free,
	[](std::string &s) {
    
     alloc.destroy(&s); });
StrVec::StrVec(const StrVec &s)
{
    
    
	// 调用alloc_n_copy分配空间以容纳与s中一样多的元素
	auto newdata = alloc_n_copy(s.begin(), s.end());
	elements = newdata.first;
	first_free = cap = newdata.second;
}
StrVec::~StrVec() {
    
     free(); }
StrVec &StrVec::operator=(const StrVec &rhs)
{
    
    
	// 调用alloc_n_copy分配内存, 大小与rhs中元素占用空间一样多
	auto data = alloc_n_copy(rhs.begin(), rhs.end());
	free();
	elements = data.first;
	first_free = cap = data.second;
	return *this;
}

在重新分配内存的过程中移动而不是拷贝元素:

在编写reallocate成员函数之前, 我们稍微思考一下此函数应该做什么。它应该

  • 为一个新的、更大的string 数组分配内存
  • 在内存空间的前一部分构造对象, 保存现有元素
  • 销毁原内存空间中的元素, 并释放这块内存

观察这个操作步骤,我们可以看出,为一个StrVec重新分配内存空间会引起从旧内存空间到新内存空间逐个拷贝string。当拷贝一个string时, 新string和原string是相互独立的。但是, 如果是reallocate拷贝StrVec中的string, 则一旦将元素从旧空间拷贝到了新空间,我们就会立即销毁原string。因此, 拷贝这些string中的数据是多余的。在重新分配内存空间时, 如果我们能避
免分配和释放string的额外开销, StrVec的性能会好得多

移动构造函数和std::move

通过使用新标准库引入的两种机制,我们就可以避免string的拷贝。首先, 有一些标准库类, 包括string, 都定义了所谓的“移动构造函数” 。移动构造函数通常是将资源从给定对象“ 移动” 而不是拷贝到正在创建的对象

对于string, 我们可以想象每个string 都有一个指向char 数组的指针。可以假定string的移动构造函数进行了指针的拷贝, 而不是为字符分配内存空间然后拷贝字符

我们使用的第二个机制是标准库函数move, 它定义在utility头文件中。当reallocate在新内存中构造string时, 它必须调用move来表示希望使用string的移动构造函数。其次,我们通常不为move提供一个using声明。当我们使用move 时, 直接调用std::move而不是move

了解了这些知识, 现在就可以编写reallocate成员了。首先调用allocate 分配新内存空间。我们每次重新分配内存时都会将StrVec 的容量加倍。如果StrVec为空,我们将分配容纳一个元素的空间:

void StrVec::reallocate()
{
    
    
	// 我们将分配当前大小两倍的内存空间
	auto newcapacity = size() ? 2 * size() : 1;
	// 分配新内存
	auto newdata = alloc.allocate(newcapacity);
	// 将数据移动到新内存
	auto dest = newdata; 		// 指向新数组中下一个空闲位置
	auto elem = elements; 		// 指向旧数组中下一个元素
	for (size_t i = 0; i != size(); ++i)
		alloc.construct(dest++, std::move(*elem++));
	free(); // 一旦我们移动完元素就释放旧内存空间
	// 更新我们的数据结构, 执行新元素
	elements = newdata;
	first_free = dest;
	cap = elements + newcapacity;
}

对象移动

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

  • 很多情况下都会发生对象拷贝。在其中某些情况下, 对象拷贝后就立即被销毁了。在这些情况下, 移动而非拷贝对象会大幅度提升性能
  • 使用移动而不是拷贝的另—个原因源于 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 函数

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility

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

我们必须认识到, 调用move就意味着承诺:除了对rr1赋值或销毁它外, 我们将不再使用它。在调用move之后, 我们不能对移后源对象的值做任何假设。我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值

move 我们不提供using声明。我们直接调用std: :move以避免潜在的名字冲突。具体原因将在18.2.3节中解释

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

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

类似拷贝构造函数, 移动构造函数的第一个参数是该类类型的一个右值引用,且任何额外的参数都必须有默认实参

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

作为一个例子, 我们为StrVec类定义移动构造函数,实现从一个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来承诺函数不抛出异常。在一个构造函数中, noexcept出现在参数列表和初始化列表开始的冒号之间:

class StrVec {
    
    
public:
	StrVec(StrVec&&) noexcept; //移动构造函数
	// 其他成员的定义, 如前
};
StrVec::StrVec(StrVec &&s) noexcept : /* 成员初始化器*/
{ /* 构造函数体*/ }

我们必须在类头文件的声明中和定义中都指定noexcept

不抛出异常的移动构造函数和移动赋值运算符必须标记为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的移动操作, 且编译器不能移动所有成员, 则编译器会将移动操作定义为删除的函数。除了一个重要例外,什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则:

  • 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似
  • 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的
  • 如果类的析构函数被定义为删除的或不可访问的, 则类的移动构造函数被定义为删除的
  • 如果有类成员是const的或是引用, 则类的移动赋值运算符被定义为删除的

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

移动右值, 拷贝左值……

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

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; // 正确: 两个版本都没有引用限定符
}

猜你喜欢

转载自blog.csdn.net/weixin_42437114/article/details/108713186
今日推荐