《C++Primer 第五版》——第十三章 拷贝控制

《C++Primer 第五版》——第十三章 拷贝控制


当定义一个类时,我们会显式或隐式地指定在此类型的对象的拷贝、移动、赋值和销毁的时候做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括: 拷贝构造函数(copy constructor)拷贝赋值运算符(copy-assignment operator)移动构造函数(move constructor)移动赋值运算符(move-assignment operator)析构函数(destructor)

拷贝构造函数和移动构造函数定义了当用同类型的一个对象初始化另一个对象时做什么;拷贝运算符和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么;析构函数定义了当此类型对象销毁时做什么。——这些操作统称为 拷贝控制操作(copy control)

Note:

如果用户不显式定义这些操作,编译器也会为我们定义,与构造函数同样,编译器定义的版本的行为可能并非用户希望的。


13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

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

class Foo {
public:
    Foo();  // 默认构造函数
    
    // 它在几种情况下都会被隐式地使用,因此,拷贝构造函数通常不应该是explicit的。
    Foo(const Foo&);    // 拷贝构造函数
    // ...
};

拷贝构造函数的第一个参数必须是一个引用类型,否则编译器会报错原因在后面。虽然可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是接收一个自身类类型的const引用。 拷贝构造函数在几种情况下都会被隐式地使用,因此拷贝构造函数通常不应该是explicit的。

合成拷贝构造函数

如果用户没有为一个类定义拷贝构造函数(和其它构造函数是否定义无关),编译器就会自动定义一个。

对某些类来说, 合成拷贝构造函数(synthesized copy constructor) 会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。

类的每个成员的类型决定了它如何拷贝

  • 对类类型的成员,会使用其拷贝构造函数来拷贝;
  • 内置类型的成员则直接拷贝。

虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。

拷贝初始化

当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用 拷贝初始化(copy constructor) 时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。

类类型的拷贝初始化 通常使用拷贝构造函数 来完成。但是, 如果一个类有定义移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成

在以下几种常见情况下会发生拷贝初始化:

  • =来定义变量;
  • 将一个对象作为实参传递给一个非引用类型的形参,拷贝初始化形参;
  • 从一个返回类型为非引用类型的函数返回一个对象,拷贝初始化临时量;
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。(参见7.5.5节)

某些类类型还会对它们所分配的对象使用拷贝初始化。 (例如:初始化标准库容器或调用其 insert / push 操作时,容器会对其元素进行拷贝初始化。与之相对,用emplace成员函数创建的元素都进行直接初始化)

参数和返回值

在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。

拷贝构造函数被用来初始化非引用类类型参数 ,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型,因为如果其参数不是引用类型,则调用永远也不会成功

为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。

拷贝初始化的限制

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

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

直接初始化 v1 是合法的,但看起来与之等价的拷贝初始化 v2 则是错误的,因为 vector 的接受单一大小参数的构造函数是 explicit 的。出于同样的原因,当传递一个实参或从函数返回一个值时,我们不能隐式使用一个 explicit 构造函数。如果我们希望使用一个 explicit 构造函数,就必须显式地使用,像此代码中最后一行那样。

在上面的例子中,如果初始值不需要通过一个 explicit 的构造函数来进行类型转换,那么它将会隐式使用转换构造函数生成一个临时量,然后再调用拷贝构造函数进行拷贝初始化

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

在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝初始化,选择直接初始化的形式,直接创建对象。 即,编译器被允许将下面的代码

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

改写为

string null_book("9");  // 编译器以直接初始化形式调用拷贝构造函数——是创建新对象,而不是生成临时量

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

注意:拷贝初始化不一定会调用拷贝构造函数,直接初始化有时会调用拷贝构造函数

在底层实现中,可以看出编译器的思想是能不用临时对象就不用临时对象。因此对于下面这些拷贝初始化,都不会生成临时对象再进行拷贝或移动到目标对象,而是直接通过函数匹配调用相应的构造函数。

ClassTest ct2 ="ab"; // 相当于ClassTest ct2("ab");
ClassTest ct5 =ClassTest("ab"); // 相当于ClassTest ct5("ab")

下面的语句,在visual studio的编译器中才会生成一个无名的临时对象(位于main函数的栈中),注意:f1的返回值类型是非引用的,f2的形参类型是非引用的。

f1(); // 临时对象用于存储f1的返回值
f2(ct1); // 临时对象用于拷贝实参,并传入函数

而下面则是直接传入赋值表达式左边对象地址,然后再对该对象进行移动拷贝,注意f1返回值类型是非引用的,如果是引用的,则会调用拷贝构造函数。

ClassTest ct6 = f1();

13.1.2 拷贝赋值运算符

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

重载赋值运算符

重载运算符(overloaded operator)本质上是函数,其名字由 operator 关键字 后接表示要定义的运算符号组成。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表

因此,赋值运算符就是一个名为 operator= 的函数。

重载运算符的参数——表示运算符的运算对象

某些运算符,包括赋值运算符,必须定义为成员函数(不是全部的运算符都必须定义为成员函数,比如输入输出运算符)。
在这里插入图片描述

如果一个运算符是一个成员函数,其左侧运算对象绑定到隐式的 this 参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

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

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

为了与内置类型的赋值保持一致, 赋值运算符通常返回一个指向其左侧运算对象的引用 。另外值得注意的是,标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用(因为某些操作继续用到赋值运算符)。

合成拷贝赋值运算符

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

类似拷贝构造函数,对于某些类,合成的拷贝赋值运算符会禁止赋值操作(即编译器合成了一个=delete的拷贝赋值运算符)(参见13.1.6)。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非 static 成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。

合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

13.1.3 析构函数

  1. 构造函数负责:初始化对象的非 static 数据成员,还可能做一些其他工作;
  2. 析构函数负责:释放对象使用的资源,并销毁对象的非 static 数据成员。
class Foo
{
public:
    ~Foo();
};

析构函数是类的一个成员函数,名字由波浪号~接类名构成。它没有返回值,也不接受参数。 由于析构函数不接受参数,因此它不能被重载。 对一个给定类,只会有唯一一个析构函数。

析构函数完成什么工作

析构函数都有一个函数体和一个析构部分。

构造函数和析构函数的数据成员初始化和销毁的顺序:

  • 在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化
  • 在一个析构函数中,首先执行函数体,然后销毁成员,其中成员按初始化顺序的逆序销毁

通常,析构函数释放对象在生存期分配的所有资源。

在析构函数中,不存在类似构造函数的初始化列表的东西来控制成员如何销毁, 析构部分是隐式生成的

成员销毁时发生什么完全依赖于成员的类型:销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做 (包括内置指针类型)

Note:

隐式销毁一个内置指针类型的成员不会 delete 它所指向的对象——此时如果没有其他指针指向它,则会造成内存泄漏

与普通指针不同,智能指针是类类型,所以它具有析构函数。因此,与普通指针不同,智能指针成员在析构阶段会被自动销毁。

什么时候会调用析构函数

无论何时只要一个对象被销毁,就会自动调用其析构函数:

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

由于析构函数会自动执行,我们的程序可以按需要分配资源,而(通常)无须担心何时释放这些资源。

Note:

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

合成析构函数

当一个类未定义自己的析构函数时,编译器会为它定义一个 合成析构函数(synthesized destructor) 。类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁(即编译器合成了一个=delete的析构函数)如果不是这种情况(编译器合成=delete的析构函数),合成析构函数的函数体就为空。

注意:
析构函数体自身并不直接销毁成员,数据成员是在析构函数体之后隐式生成的析构部分中被销毁的。 在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。

13.1.4 三/五法则

在C++中,有三个基本操作可以控制类的拷贝操作: 拷贝构造函数、拷贝赋值运算符和析构函数。且C++11起,一个类还可以定义一个移动构造函数和一个移动赋值运算符。C++并不要求用户定义所有上面这些操作:可以定义一部分,而不必定义全部。

当我们决定一个类是否要定义它自己版本的拷贝控制成员时,基本原则如下:

  1. 首先确定这个类是否需要一个自定义的析构函数。如果这个类需要一个自定义的析构函数,我们几乎可以肯定它也需要一个自定义的拷贝构造函数和一个自定义的拷贝赋值运算符,反之则不一定;
  2. 如果一个类需要一个自定义的拷贝构造函数,几乎可以肯定它也需要一个自定义的拷贝赋值运算符,反之亦然。

因为如果一个类需要一个自定义的析构函数,一般都是需要主动执行释放内存操作(即 delete ),所以需要自定义拷贝构造函数和拷贝赋值运算符,以避免不同对象的相同指针成员(内置指针)指向同一动态内存进而导致析构时 delete 相同内存两次。

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

  • 三(拷贝构造函数、拷贝赋值运算符、析构函数)
  • 五(拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数)

三之法则:

  • 如果某个类需要用户定义的析构函数、复制构造函数或复制赋值运算符,那么它几乎肯定需要全部三者。

五之法则:

  • 因为用户定义的析构函数、复制构造函数或复制赋值运算符的存在会阻止移动构造函数和移动赋值运算符的隐式定义,所以任何想要移动语义的类必须声明全部五个特殊成员函数。

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

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

Tip:

如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。 (因为类中可能有内置指针成员,而拷贝构造或拷贝赋值时需要进行深拷贝)

例子如下:

假设我们定义了一个Array类,这个类在构造函数中动态地分配了一块内存,并用一个成员变量(内置指针)指向它,那么合成析构函数将不会释放这块内存(因为内置指针是内置类型,析构函数什么都不做),所以我们需要显式地定义一个析构函数来释放内存。


应该怎么做可能还是有点不清晰,但基本原则告诉我们,Array 类也需要自定义的一个拷贝构造函数和一个赋值运算符,避免指针成员指向同一个动态内存。
如果我们定义了一个析构函数,但却使用默认的拷贝构造函数和赋值运算符,那么将导致不同对象之间相互干扰,修改一个对象的数据会影响另外的对象。此外还可能会导致内存操作错误,请看下面的代码:

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

上面代码中,这个类使用了合成的拷贝构造函数和拷贝赋值运算符。这些函数简单拷贝指针成员,意味着多个Array对象可能指向相同的内存:

Array f(Array arr){  // 按值传递,将发生拷贝
   Array ret = arr;	// 发生拷贝
   return ret;		// ret和arr将被销毁
}

当f()返回时,对象arr和ret都会被销毁,所以在两个对象上都会调用析构函数,此用户定义的析构函数会 delete 指针成员 ps 所指向的动态内存。但是,这两个对象的 ps 成员指向的是同一块内存, 导致同一块内存会被 delete 两次,这显然是一个错误,将要发生什么是未知的。
此外,f() 的调用者还会继续使用传递给 f() 的对象:

Array arr1(10);
f(arr1);  	// 当 f() 调用结束时,arr1.ps 指向的内存被释放
Array arr2 = arr1;  // 现在 arr2 和 arr1 都指向无效内存

arr2(以及 arr1)指向的内存不再有效,在arr和ret被销毁时系统已经归还给操作系统了。

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

如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然——如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

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

13.1.5 使用 =default

可以通过将运算符、构造函数和析构函数定义为 =default 来显式地要求编译器生成合成的版本 ,例如:

class Sales_data
{
public:
    // 拷贝控制成员;使用 default
    Sales_data() = default;	// 合成的默认构造函数
    Sales_data(const Sales_data &) = default;	// 合成的拷贝构造函数
    Sales_data &operator=(const Sales_data &);	// 不希望合成的是内联函数,就在类外使用 =default
    ~Sales_data() = default;	// 合成的析构函数
    // 其他成员的定义,如前
};
Sales_data &Sales_data::operator=(const Sales_data &) = default;	// 内联的合成的拷贝赋值运算符

如何获取内联的合成成员函数:

当我们在类内用 =default 修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他在类内定义的成员函数一样)。
如果我们不希望合成的成员是内联函数,应该只对成员函数的类外定义使用 =default

Note:

我们只能对具有合成版本的成员函数使用 =default

13.1.6 阻止拷贝

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

为了阻止拷贝,看起来应该不定义拷贝控制成员。但是这种策略是无效的:因为如果我们的类未定义这些操作,编译器为它生成合成的版本。

定义删除(=delete)的函数

C++11起,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为 删除的函数(deleted function) 来阻止拷贝操作。

删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上 =delete 来指出我们希望将它定义为删除的。

注意:将函数定义为删除的(=delete)。虽然没有函数体,但这也是定义了函数,只是编译器不会允许该函数被调用

在这里插入图片描述

比如:

struct NoCopy
{
    NoCopy() = default;                         // 使用合成的默认构造函数
    NoCopy(const NoCopy &) = delete;            // 阻止拷贝
    NoCopy &operator=(const NoCopy &) = delete; // 阻止赋值
    ~NoCopy() = default;                        // 使用合成的析构函数
    // 其他成员
};

=delete 告诉编译器不需要合成这些函数。

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

  2. =default 的另一个不同之处是,可以对任何函数指定 =delete (我们只能对编译器可以合成的成员函数使用 =default。 虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。

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

值得注意的是,我们不应该删除析构函数。 如果析构函数被删除,就无法销毁此类型的对象了。

  1. 对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象
  2. 而且,如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象
    因为如果一个成员的析构函数是删除的,则该成员无法被销毁。而如果一个成员无法被销毁,则对象整体也就无法被销毁了。

对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员但可以动态分配这种类型的对象。但是,不能释放这些对象:

struct NoDtor
{
    NoDtor() = default; // 使用合成默认构造函数
    ~NoDtor() = delete; // 我们不能销毁 NoDtor 类型的对象
};
NoDtor nd;                // 错误:NoDtor 的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确:但我们不能 delete p
delete p;                 // 错误:NoDtor 的析构函数是删除的

WARNING:

对于析构函数已删除的类型,不能定义该类型的对象或 delete 指向该类型动态分配对象的指针。

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

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

  • 如果类的某个成员的析构函数是删除的或不可访问的(例如,是 private 的),则 类的合成析构函数被定义为删除的
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的 合成拷贝构造函数被定义为删除 的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,则类的 合成拷贝构造函数也被定义为删除 的。
  • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个 const 的成员或类有一个引用成员(即,必须用户显式初始化的类型),则类的 合成拷贝赋值运算符被定义为删除 的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个 const 成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的 合成默认构造函数被定义为删除 的。

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

一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,这看起来可能有些奇怪。其原因是,如果没有这条规则,我们可能会创建出一个无法销毁的对象。


对于具有引用成员或无法默认构造的 const 成员的类,编译器不会为其合成默认构造函数,这应该不奇怪。同样不出人意料的规则是:如果一个类有 const 成员,则它不能使用合成的拷贝赋值运算符。毕竟,此运算符试图赋值所有成员,而将一个新值赋予一个 const 对象是不可能的。


虽然我们可以将一个新值赋值一个引用成员,但这样做改变的是引用指向的对象的值,而不是引用本身(赋值不等于初始化)。如果为这样的类合成拷贝赋值运算符,则赋值后,左侧运算对象仍然指向与赋值前一样的对象,而不会与右侧运算对象指向相同的对象。由于这种行为看起来并不是我们所期望的,因此对于有引用成员的类,合成拷贝赋值运算符被定义为删除的。

private 拷贝控制

在 C++11 新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为 private 的来阻止拷贝:

class PrivateCopy
{
    // 无访问说明符;接下来的成员默认为 private 的
    // 拷贝控制成员是 private 的,因此普通用户代码无法访问
    PrivateCopy(const PrivateCopy &);
    PrivateCopy &operator=(const PrivateCopy &);
    // 其他成员
public:
    PrivateCopy() = default; // 使用合成的默认构造函数
    ~PrivateCopy();          // 用户可以定义此类型的对象,但无法拷贝它们
};

由于析构函数是 public 的,用户可以定义 PrivateCopy 类型的对象。但是,由于拷贝构造函数和拷贝赋值运算符是 private 的,用户代码将不能拷贝这个类型的对象。但是,友元和成员函数仍旧可以拷贝对象。为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为 private 的,但并不定义它们。

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

Best Practices:

从C++11开始,希望阻止拷贝的类应该使用 =delete 来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为 private 的。


13.2 拷贝控制和资源管理

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

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

  • 类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。 比如标准库容器和 string 。
  • 行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。 比如标准库智能指针类 shared_ptr 。
  • 如果既不像指针,又不像一个值,该类则不允许拷贝或赋值。 IO 类型和 unique_ptr 不允许拷贝或赋值,因此它们的行为既不像值也不像指针。

13.2.1 定义行为像值的类

为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。 这意味着对于ps指向的string,每个HasPtr对象都必须有自己的拷贝。为了实现类值行为,HasPtr需要:

  • 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针;
  • 定义一个析构函数来释放string;
  • 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string。

类值版本的HasPtr如下所示:

class HasPtr{
public:
	HasPtr(const string &s=string()):ps(new string(s)),i(0){}
	HasPtr(const HasPtr &p):ps(new string(*p.ps)),i(p.i){}
	HasPtr& operator=(const HasPtr &);
	~HasPtr(){delete ps;}
private:
	string *ps;
	int i;
}

类值拷贝赋值运算符

如果要自定义赋值运算符,那么自定义的赋值运算符通常组合了析构函数和构造函数的操作。

为了说明防范自赋值操作的重要性,考虑如果赋值运算符如下编写将会发生什么:

// 这样编写赋值运算符是错误的!
HasPtr &HasPtr::operator=(const HasPtr &rhs)
{
    delete ps; // 释放对象指向的 string
    // 如果 rhs 和 *this 是同一个对象,我们就将从已释放的内存中拷贝数据!
    ps = new string(*(rhs.ps));
    i = rhs.i;
    return *this;
}

如果 rhs 和本对象是同一个对象,delete ps 会释放 *this 和 rhs 指向的 string。接下来,当我们在 new 表达式中试图拷贝 *(rhs.ps) 时,就会访问一个指向无效内存的指针,其行为和结果是未定义的。

关键概念:赋值运算符

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

  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作;
  • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。

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

Note:

对于一个赋值运算符来说,正确工作是非常重要的,即使是将一个对象赋予它自身,也要能正确工作。一个好的方法是在销毁左侧运算对象资源之前拷贝右侧运算对象。

13.2.2 定义行为像指针的类

对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的对象。同时,我们还需要为该类自定义析构函数,安全且正确地释放指针成员所指向的内存。

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

但是,有时我们希望直接管理资源。这种情况下,使用 引用计数(reference count) (参见12.1.1节)就很有用了。

引用计数

共享状态(状态换句话说就是对象或动态内存),引用计数的工作方式如下:

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

引用计数器 不应该直接作为类的成员 。否则在计数器变化时,关联对象的计数器无法及时更新。

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

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

通过使用引用计数,我们就可以编写类指针的 HasPtr 版本了:

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对象时。我们希望副本原对象都指向相同的string,即,当拷贝一个HasPtr时,我们将拷贝ps本身,而不是ps指向的string。当我们进行拷贝时,还会递增该string关联的计数器。

(我们在类内定义的)拷贝构造函数拷贝给定的HasPtr的所有三个数据成员。这个构造函数还会递增use成员。指出ps和p.ps指向的string又有了一个新的用户。

析构函数不能无条件地delete ps——因为可能还会有其他对象指向这块内存,析构函数应该递减引用计数器,指出共享string的对象少了一个。如果计数器变为0,则析构函数释放ps和use指向的内存:

HasPtr::~HasPtr()
{
    if (--*use == 0) // 如果引用计数变为 0
    {
        delete ps;  // 释放 string 内存
        delete use; // 释放计数器内存
    }
}

拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作。即,它必须递增右侧运算对象的引用计数,并递减左侧运算对象的引用计数,在必要时释放使用的内存(即,析构函数的工作)。

而且与往常一样,赋值运算符必须支持处理自赋值。 我们通常先递增rhs中的计数然后我们再递减左侧运算对象中的计数来实现这一点。通过这种方法,当两个对象相同时,在我们检查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 的函数(比如标准库容器类,参见9.2.5节)。 对于那些与重排元素顺序的算法一起使用的类,定义swap是非常重要的。如果一个类定义了自己的 swap,那么算法将使用类自定义版本。否则,标准库算法将使用标准库定义的 std::swap 。

swap函数是如何实现的:

为了交换两个对象,我们需要进行一次拷贝和两次赋值

例如,交换两个值HasPtr对象的代码可能像下面这样:

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

这段代码将原来的v1中的string拷贝了两次—第一次是HasPtr的拷贝狗杂函数将v1创建给v1拷贝为temp,第二次是赋值运算将temp赋予v2。将v2赋予v1的语句还拷贝了原来v2中的string。如我们所见,拷贝一个值为HasPtr会分配一个新的string并将其拷贝到HasPtr指向的位置。

理论上这些内存分配都是不必要的,我们更希望swap函数交换指针,而不是分配对象的新副本。 即,我们希望这样交换两个HasPtr:

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

编写我们自己的swap函数

class HasPtr{
	// 其它代码与之前一致
	friend void swap(HasPtr&,HasPtr&);
};
inline void swap(HasPtr &lhs,HasPtr &rhs)
{
	using std::swap;
	// 因为内置指针和int都是内置类型,所以只能调用标准库的swap函数,即 std::swap 
	swap(lhs.ps,rhs.ps);// 交换指针而不是string
	swap(lhs.i,rhs.i);	// 交换int成员
}

我们首先将swap定义为friend,以便能访问HasPtr的(private的)数据成员,由于swap的存在就是为了优化代码,我们将其声明为inline函数、swap的函数体对给定对象的每个数据成员调用swap。我们首先swap绑定到rhs和lhs的对象的指针成员,然后是int成员。

Note:

与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。

swap 函数应该调用 swap ,而不是加上限定 std:: 来调用 std::swap

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

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

例如,假定我们有个名为 Foo 的类,它有一个类型为 HasPtr 的成员 h。如果我们未定义 Foo 版本的 swap,那么就会使用标准库版本 stad::swap 。如我们所见,标准库 swap 对 HasPtr 管理的 string 进行了不必要的拷贝。

我们可以为 Foo 编写一个 swap 函数,来避免这些拷贝。但是,如果这样编写 Foo 版本的 swap:

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

使用此版本与简单使用默认版本的 swap 并没有任何性能差异。虽然我们显示地调用了标准库版本的 swap。但是,我们不希望使用 std 中的版本,我们希望调用为 HasPtr 对象定义的版本。因此, Foo 类定义的正确的 swap 函数如下所示:

inline void swap(Foo& lh, Foo& rh) {
   using std::swap;
   swap(lh.h, rh.h);	// 使用了HasPtr版本的swap函数
   // 交换其他成员
}

至于为什么上面例子中的 using 声明没有隐藏 HasPtr 版本 swap 的声明(参见6.4.1节)。
后面将在18.2.3节中解释——对于命名空间中名字隐藏规则有一个重要例外:当我们给函数传递一个类类型的对象时,除了在常规作用域查找外,还会查找实参类所属的命名空间。这一例外对于传递类的引用或指针的调用同样有效。
在上面例子中,除了查询std::swap外,还会查找传递给inline void swap(Foo& lh, Foo& rh)的参数类型Foo的所属命名空间。并且因为非模板函数优先于模板函数的规则,所以调用 Foo::swap 。

每个 swap 调用应该都是未加限定的。即,每个调用的都应该是类型自定义的 swap ,而不是标准库的 std::swap 。如果存在类型特定的 swap 版本,其匹配程度会优先于 std 中定义的版本 (原因将在16.3节中解释)。

  • 因此,如果存在类型特定的 swap 版本, swap 调用会与之匹配。
  • 如果不存在类型特定的版本,则会使用 std 的版本(这里假定作用域中有 std 命名空间或 std::swap 的 using 声明)。

在赋值运算符中使用 swap

之前有提到过当类有指针成员时使用合成的拷贝或赋值操作,很有可能导致同一块内存会被 delete 两次,这是一个错误,将要发生什么是未知的。下面将介绍一种新的技术可用于解决这个问题。

定义 swap 的类通常用 swap 来定义它们的赋值运算符。这些运算符使用了一种名为 拷贝并交换 (copy and swap) 的技术(《Effective C++》有提到过)。——这种技术将左侧运算对象与右侧运算对象的一个副本进行交换:

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

在这个版本的赋值运算符中,参数并不是一个引用,我们将右侧运算对象以传值方式传递给了赋值运算符。因此, rhs 是右侧运算对象的一个副本。参数传递时拷贝 HasPtr 的操作会分配该对象的 string 的一个新副本。

在赋值运算符的函数体中,我们调用 swap 来交换 rhs 和 *this 中的数据成员。这个调用将左侧运算对象中原来保存的指针存入 rhs 中,并将 rhs 中原来的指针存入 *this 中。因此,在 swap 调用之后,*this 中的指针成员将指向新分配的 string——右侧运算对象中 string 的一个副本。

当赋值运算符结束时,rhs 被销毁,HasPtr 的析构函数将执行。此析构函数 delete rhs 现在指向的内存,即,释放掉左侧运算对象中原来的内存。

这个技术的有趣之处是:它自动处理了自赋值情况且天然就是异常安全的。

  • 它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确,这与我们在原来的赋值运算符中使用的方法是一致的。
  • 它保证异常安全的方法也与原来的赋值运算符实现一样。代码中唯一可能抛出异常的是拷贝构造函数中的 new 表达式。如果真发生了异常,它也会在我们改变左侧运算对象之前发生。

13.4 拷贝控制示例

Best Practices:

拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在 private 的工具函数中完成。


13.5 动态内存管理类

  • 某些类需要在运行时分配可变大小的内存空间,通常可以使用标准库容器来保存它们的数据。

  • 但是,这一方法(标准库容器)并不是对每个类都适用,某些类需要自己进行内存分配,这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。

下面,我们将实现一个vector类的简化版本,它不使用模板,只用于string,我们将其命名为StrVec。

StrVec类的设计

在第9章中我们提到:vector将其元素保存在连续的内存中。为了获得可接受的性能,vector预先分配足够的内存来保存可能需要的更多元素。vector的每个添加元素的成员函数会检查是否有空间容纳更多的元素。如果有,成员函数会在下一个可用位置构造一个对象。如果没有可用空间,vector就会重新分配空间:它获得新的空间,将已有元素移动到新空间中,释放旧空间,并添加元素。

我们在StrVec类中采用类似的策略,用模板类allocator来获得原始内存。由于allocator的成员函数allocate分配的内存是未构造的,我们将需要添加新元素时用allocator的成员函数construct在原始内存中创建对象,当需要删除元素时,用destory成员来销毁。

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

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

StrVrc还有一个名为alloc的静态成员,其类型为allocator<string>。alloc成员会分配StrVec使用的内存,我们的类还有4个工具函数:

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

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

虽然不知道string的具体实现细节,但是string具有类值行为。这代表当拷贝一个string时,必须为这些字符分配内存空间,而销毁一个string必须释放所占有的内存。

假如有一个类,一旦将另一对象成员的旧string元素拷贝到新对象后,就立即销毁旧对象。那么,拷贝这些string中的数据是多余的,将旧数据移动到新对象中即可(不发生内存删除和分配)。对于这个类,在重新分配内存空间时,如果我们能避免分配和释放string的额外开销,它的性能会好很多。

移动构造函数和 std::move

移动构造函数通常将资源“移动”而不是拷贝到正在创建的对象。而且标准库保证 移后源(moved-drom) string仍然保持一个有效的、可析构的状态。对于string,我们可以想象每个string都有一个指向char数组的指针。可以假定string的移动构造函数进行了指针拷贝,而不是为字符分配内存然后拷贝字符。

标准库函数 std::move 的定义在头文件 utility 中 ,现在有两个关键点需要了解:

  1. 可以调用move来表示希望使用对象的移动构造函数。如果漏掉了move调用,将会使用对象的拷贝构造函数;
  2. 通常不为move提供一个 using 声明(原因参见18.2.3节)。当使用move时,直接调用 std::move 而不是move

13.6 对象移动

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

为什么添加这个特性(使用移动而不是拷贝)?

  1. 减少内存开销:很多时候都会发生对象拷贝,如果对象拷贝完之后就被立即销毁,那么移动对象而非拷贝对象会大幅度提升性能。
  2. 保护不能被共享的资源:另一个原因在于IO类或者unique_ptr这样的类,这些类都包含不能被共享的资源(如指针或者IO缓冲),因此这些类型的对象不能被拷贝但是可以被移动。

Note:

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

13.6.1 右值引用

为了支持移动操作,新标准引入了一种新的引用类型—— 右值引用(rvalue reference)

  • 所谓右值引用就是必须绑定到右值的引用。
  • 通过 && 而不是 & 来获得右值引用。
  • 右值引用有一个重要的性质——右值引用只能绑定到一个将要销毁的对象。 因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。

左值和右值是表达式的属性 (参见4.1.1节),一些表达式生成或要求左值,而另外一些则生成或要求右值。一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。

类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。 为了与右值引用区分开来,我们可以称之为 左值引用(lvalue reference)常规引用=左值引用

  • 对于常规引用(左值引用),我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。
  • 右值引用有着完全相反的绑定特性: 我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上。
  • 返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。 我们可以将一个左值引用绑定到这类表达式的结果上。
  • 返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值 虽然我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个 const 的左值引用 或者一个右值引用绑定到这类表达式上。

例子如下:

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 绑定到乘法结果上

左值持久;右值短暂

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

由于右值引用只能绑定到临时对象,我们得知

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

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

Note:

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

变量是左值

变量可以看作只有一个运算对象而没有运算符的表达式,类似其他任何表达式,变量表达式也有左值/右值属性。变量表达式都是左值。 带来的结果就是,我们不能将一个右值引用绑定到一个右值引用类型的变量上,这有些令人惊讶:

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

其实有了右值表示临时对象这一观察结果,变量是左值这一特性并不令人惊讶。毕竟,变量是持久的,直至离开作用域时才被销毁。

Note:

变量是左值,因此我们 不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。

标准库move函数

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

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

move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用 move 就意味着承诺:除了对 rr1 赋值或销毁它外,我们将不再使用它。在调用 move 之后,我们不能对移后源对象的值做任何假设。

在这里插入图片描述

Note:

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

与大多数标准库名字的使用不同,对 move 我们不提供 using 声明。我们一般直接调用 std::move 而不是 move。

WARNING: 使用 move 的代码应该使用 std::move 而不是 move。这样做可以避免潜在的名字冲突。

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

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

类似拷贝构造函数, 移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。

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

我们为 StrVec 类定义移动构造函数,elements、first_free 和 cap 是 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;
}

要注意三点:

  1. 移动操作不应该抛出任何异常,所以使用关键字 noexcept 进行指定;
  2. 使用构造函数初始值列表,以接管移后源对象的资源;
  3. 令移后源对象进入有效的,可析构的状态——令其指针成员值为nullptr。

移动构造函数的执行流程:

与拷贝构造函数不同,移动构造函数不分配任何新内存:它接管给定对象中的内存。在接管内存之后,它将给定对象中的指针成员都置为nullptr——如果我们忘记了,则后面销毁移后源对象就会释放掉刚刚移动的内存(通过移后源对象的指针成员旧值释放内存,导致新对象的指针成员变成悬挂指针)。

这样就 完成了给定对象的移动操作,移后源对象将继续存在 。最终,移后源对象会被销毁,这意味着将执行其析构函数。

移动操作、标准库容器和异常

由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。 当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。我们将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。

一种通知标准库的方法是在我们的构造函数中指明 noexcept 。关键字 noexcept 是C++11引入的承诺一个函数不抛出异常的一种方法。

  • 在一个函数的参数列表后指定 noexcept ;
  • 在一个构造函数中,noexcept 出现在参数列表和初始化列表开始的冒号之间;
  • 必须在类头文件的声明中和定义中(如果定义在类外的话)都指定 noexcept 。

Note:

不抛出异常的移动构造函数和移动赋值运算符必须标记为 noexcept。

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

  • 首先,虽然移动操作通常不抛出异常,但抛出异常是允许的 (所以需要使用 noexcept 指出移动操作不抛出异常);
  • 其次,标准库容器能对异常发生时其自身的行为提供保障 。例如,标准库容器 vector 保证,如果我们调用 push_back 时发生异常, vector 目身不会发生改变。

对一个 vector 调用 push_back 可能要求为 vector 重新分配内存空间。当重新分配 vector 的内存时,vector 将元素从旧空间移动到新内存中。

如我们刚刚看到的那样,移动一个对象通常会改变它的值。 如果重新分配过程使用了移动构造函数,且在移动了部分而不是全部元素后抛出了一个异常,就会产生问题。旧空间(移动源对象)中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。 在此情况下,vector 将不能满足自身保持不变的要求。

另一方面,如果 vector 使用了拷贝构造函数且发生了异常,它可以很容易地满足要求。在此情况下,当在新内存中构造元素时,旧元素保持不变。如果此时发生了异常,类可以释放新分配的(但还未成功构造的)内存并返回。移动源对象原有的元素仍然存在。

为了避免这种潜在问题,除非 vector 知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。如果希望在 vector 重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数可以安全使用。我们通过将移动构造函数(及移动赋值运算符)标记为 noexcept 来做到这一点。

移动赋值运算符

移动赋值运算符执行与析构函数和移动构造函数相同的工作。 与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为 noexcept 。类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值

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}

在此例中,我们直接检查this指针与rhs的地址是否相同。如果相同,右侧和左侧运算对象指向相同的对象,我们不需要做任何事情。否则,我们释放左侧运算对象所使用的内存,并接管给定对象的内存。与移动构造函数一样,我们将rhs中的指针置为nullptr。

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

移后源对象必须可析构

从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。

除了将移后源对象置为析构安全的状态之外,移动操作还必须保证对象仍然是有效的。 一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。 比如将移后源对象的指针成员全都置为nullptr,然后就可以安全地为其赋新值或安全使用而不需要担心影响获取该指针成员旧值的对象。

另一方面,移动操作对移后源对象中留下的值没有任何要求。因此,我们的程序不应该依赖于移后源对象中的数据。

WARNING:

在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设,因为这没有任何保证。

合成的移动操作

与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符。但是,合成移动操作的条件与合成拷贝操作的条件大不相同。

如果不声明用户自己的拷贝构造函数和拷贝赋值运算符,编译器总会为我们合成这些操作。合成拷贝操作要么被定义为逐成员操作,要么被定义为对象赋值,要么被定义为删除的函数。

与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是:

  • 如果一个类自定义了自己的拷贝构造函数拷贝赋值运算符或者析构函数编译器就不会为它合成移动构造函数和移动赋值运算符。(注意:定义为delete的函数也会导致拷贝操作不会被合成,因为这不是函数声明而是函数定义)
  • 如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作。

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 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); // 使用合成的移动构造函数

Note:

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

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

  • 例外:与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
  • 如果有类成员的移动构造函数或移动赋值运算符被显式定义为删除(=delete)的或是不可访问(private)的,则类的移动构造函数或移动赋值运算符被定义为删除的。
  • 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
  • 类似拷贝赋值运算符,如果有类成员是 const 的或是引用,则类的移动赋值运算符被定义为删除的。
  • 如果显式地要求编译器生成 =default 的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。

例如,假定 Y 是一个类,它定义了自己的拷贝构造函数但未定义自己的移动构造函数:

// 假定 Y 是一个类,它定义了自己的拷贝构造函数但未定义自己的移动构造函数
struct hasY
{
   hasY() = default;
   hasY(hasY &&) = default;
   Y mem; // hasY 将有一个删除的移动构造函数
};
hasY hy, hy2 = std::move(hy); // 错误:移动构造函数是删除的

编译器可以拷贝类型为 Y 的对象,但不能移动它们。类 hasY 显式地要求一个移动构造函数,但编译器无法为其生成。因此,hasY 会有一个删除的移动构造函数。如果 hasY 忽略了移动构造函数的声明,则编译器根本不能为它合成一个。如果移动操作可能被定义为删除的函数,编译器就不会合成它们。

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

Note:

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

移动右值,拷贝左值……

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

假设某类有一个拷贝赋值运算符形参类型为 const 引用,一个移动赋值运算符形参类型为右值引用,还创建了一个对象右值A。那么这两个运算符都可以接受右值A,但是移动赋值运算符的形参类型是精确匹配,所以会优先调用它。

……但如果没有移动构造函数,右值也可以被拷贝(const 左值引用)

如果一个类有一个拷贝构造函数但未定义移动构造函数,会发生什么呢?在此情况下,编译器不会合成移动构造函数,这意味着此类将有拷贝构造函数但不会有移动构造函数。如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用 move 来移动它们时也是如此:

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

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

值得注意的是:用拷贝构造函数代替移动构造函数几乎肯定是安全的(赋值运算符的情况类似)。一般情况下,拷贝构造函数满足对应的移动构造函数的要求:它会拷贝给定对象,并将原对象置于有效状态。实际上,拷贝构造函数甚至都不会改变原对象的值。

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

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;}
	//其他成员的定义,同13.2.1节
}

对于赋值运算符,如果有一个非引用参数,这意味着此参数要进行拷贝初始化,这依赖于实参的类型,如果实参是左值则使用拷贝构造函数;若是右值,则使用移动构造函数——左值被拷贝,右值被移动。因此单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。

假定hp和hp2都是 HasPtr 对象:

hp = hp2;
hp = std::move(hp2);	//移动构造函数移动 hp2

在第一个赋值中,右侧对象hp2是一个左值,因此移动构造函数是不可行的,拷贝赋值运算符参数rhs将使用拷贝构造函数来初始化,拷贝构造函数将分配一个新string,并拷贝hp2指向的string。
在第二个赋值中,我们调用 std::move 将一个右值引用绑定到hp2。在此情况下,拷贝构造函数和移动构造函数都是可行的。但是,由于实参是一个右值引用,移动构造函数时精确匹配的,移动构造函数从hp2拷贝指针,而不会分配任何内存。
不管使用的是拷贝构造函数还是移动构造函数,赋值运算符的函数体都swap两个运算对象的状态。交换HasPtr会交换两个对象的指针及int成员。在swap后,rhs中的指针将指向原来左侧运算对象所拥有的string,并且当rhs离开其作用域时,这个string将被销毁。

建议:更新三/五法则

三之法则:
如果某个类需要用户定义的析构函数、复制构造函数或复制赋值运算符,那么它几乎肯定需要全部三者。
五之法则:
因为用户定义的析构函数、复制构造函数或复制赋值运算符的存在会阻止移动构造函数和移动赋值运算符的隐式定义,所以任何想要移动语义的类必须声明全部五个特殊成员函数。


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

  • 如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正常工作(参见13.1.4节)。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。
  • 一般来说,拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。

类的移动操作

与任何赋值运算符一样,移动赋值运算符必须销毁左侧运算对象的旧状态

移动迭代器

新标准库中定义了一种 移动迭代器(move iterator)适配器 (参见10.4节)。

一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。
一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用

我们可以通过调用标准库函数 make_move_iterator 将一个普通迭代器转换为一个移动迭代器。此函数接受一个普通迭代器参数,返回一个移动迭代器。

原迭代器的所有其他操作在移动迭代器中都照常工作。由于移动迭代器支持正常的迭代器操作,我们可以将一对移动迭代器传递给标准库算法 特别是,传入一个移动迭代器给标准库算法 uninitialized_copy 会发生有趣的事情:

特别是,将移动迭代器传递给标准库算法 uninitialized_copy

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; // 更新指针
   cap = elements + newcapacity;
}

uninitialized_copy 对输入序列中的每个元素调用 construct 来将元素“拷贝”到目的位置。算法 uninitialized_copy 使用迭代器的解引用运算符从输入序列中提取元素。 由于在这里我们传递给它的是移动迭代器,因此解引用运算符生成的是一个右值引用,这意味着 construct 将使用移动构造函数来构造元素。

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

建议:不要随意使用移动操作。

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


Best Practice:
在移动构造函数和移动赋值运算符这些类实现代码之外的地方(即内存空间的“移动”),只有当你确信需要进行移动操作且移动操作是安全的,才可以使用 std::move。

13.6.3 右值引用和成员函数

​​  定义了 push_back 的标准库容器提供两个版本:一个版本有一个右值引用参数,而另一个版本有一个 const 左值引用。假定 X 是元素类型,那么这些容器就会定义以下两个 push_back 版本:

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

​​  如果同时存在上面两个重载函数的情况下,我们可以将能转换为类型 X 的任何对象传递给第一个版本的 push_back。此版本从其参数拷贝数据。对于第二个版本,我们只可以传递给它非 const 的右值。此版本对于非 const 的右值是精确匹配(也是更好的匹配)的,因此当我们传递一个可修改的右值时,编译器会选择运行这个版本。此版本会从其参数窃取数据。

一般来说,我们不需要为函数操作定义接受一个 const && 或是一个(普通的) & 参数的版本。

当我们希望从实参“窃取”数据时,通常传递一个右值引用,并将其设置为有效的、可析构的状态。为了达到这一目的,实参不能是 const 的。
类似的,从一个对象进行拷贝的操作不应该改变被拷贝对象。因此,通常不需要定义一个接受(普通的) & 参数的版本。

Note:

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

作为一个更具体的例子,我们将为 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。如前所述,construct 函数使用其第二个和随后的实参的类型来确定使用哪个构造函数。由于 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&&)

这些调用的差别在于实参是一个左值还是一个右值(从”done”创建的临时 string),具体调用哪个版本据此来决定。

右值和左值引用成员函数

通常我们在一个对象上调用成员函数,不管对象是左值还是右值。 但旧标准允许向右值赋值 ,比如:

s1 + s2 = "wow";

在旧标准中,我们无法阻止这种使用方式。而为了维持向后兼容性,新的标准库仍允许向右值赋值。但如果我们希望在自己的类中阻止这种用法,可以强制左侧运算符对象(即,隐式形参this指向的对象)是一个左值。

我们指出 this 的左值/右值属性的方式与定义 const 成员函数相同(参见7.1.2节),即,在参数列表后放置一个 引用限定符(reference qualifier) 。例如:

class Foo
{
public:
    Foo &operator=(const Foo &) &; // 只能向可修改的左值赋值
    // Foo 的其他参数
};
Foo &Foo::operator=(const Foo &rhs) &
{
    // 执行将 rhs 赋予本对象所需的工作
    return *this;
}

引用限定符可以是 & 或 &&,分别指出 this 可以指向一个左值或右值。

  • 类似 const 限定符,引用限定符 只能用于(非 static)成员函数
  • 必须同时出现在函数的声明和定义中

对于 & 限定的函数,我们只能将它用于左值;对于 && 限定的函数,只能用于右值:

Foo &retFoo(); // 返回一个引用;retFoo 调用是一个左值
Foo retVal();  // 返回一个值;retVal 调用是一个右值
Foo i, j;      // i 和 j 是左值
i = j;         // 正确:i 是左值
retFoo() = j;  // 正确:retFoo() 返回一个左值
retVal() = j;  // 错误:retVal() 返回一个右值
i = retVal();  // 正确:我们可以将一个右值作为赋值操作的右侧运算对象

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

class Foo
{
public:
    Foo someMem() & const;     // 错误:const 限定符必须在前
    Foo anotherMem() const &;  // 正确:const 限定符在前
};

重载和引用函数

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

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;                             // 返回副本
}

当我们对一个右值执行 sorted 时,它可以安全地直接对 data 成员进行排序。对象是一个右值,意味着没有其他用户,因此我们可以改变对象。 当对一个 const 右值或一个左值执行 sorted 时,我们不能改变对象,因此就需要在排序前拷贝 data。
编译器会根据调用 sorted 的对象的左值/右值属性来确定使用哪个 sorted 版本:

retVal().sorted(); // retVal() 是一个右值,调用 Foo::sorted() &&
retFoo().sorted(); // retFoo() 是一个左值,调用 Foo::sorted() const &
  • 当我们定义 const 成员函数时,可以定义两个版本,唯一的差别是一个版本有 const 限定而另一个没有
  • 定义引用限定的函数则不一样。如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加
class Foo
{
public:
   Foo sorted() &;
   Foo sorted() const; // 错误:必须加上引用限定符
   // Comp 是函数类型的类型别名
   // 此函数类型可以用来比较 int 值
   using Comp = bool(const int &, const int &);
   Foo sorted(Comp *);       // 正确:不同的参数列表
   Foo sorted(Comp *) const; // 正确:两个版本都没有引用限定符
};

本例中声明了一个没有参数的 const 版本的 sorted,此声明是错误的。因为 Foo 类中还有一个无参的 sorted 版本,它有一个引用限定符,因此 const 版本也必须有引用限定符。另一方面,接受一个比较操作指针的 sorted 版本是没问题的,因为两个函数都没有引用限定符。

Note:

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值