0%

C++ Primer - 第 13 章 拷贝控制

  1. 拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。

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

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

13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

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

    1
    2
    3
    4
    5
    6
    7
    class Foo
    {
    public:
    Foo(); // 默认构造函数
    Foo(const Foo &); // 拷贝构造函数
    // ...
    };

    拷贝构造函数的第一个参数必须是一个引用类型。虽然我们可以定义一个接受非 const 引用的拷贝构造函数,但此参数几乎总是一个 const 的引用。拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是 explicit

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

  3. 对某些类来说,合成拷贝构造函数(synthesized copy constructor)用来阻止我们拷贝该类类型的对象。而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中。

  4. 每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。

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

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

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

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

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

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

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

    1
    2
    3
    4
    5
    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 构造函数,就必须显式地使用,像此代码中最后一行那样。

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

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

    改写为

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

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

  13. 练习 13.4: 假定 Point 是一个类类型,它有一个 public 的拷贝构造函数,指出下面程序片段中哪些地方使用了拷贝构造函数:

    1
    2
    3
    4
    5
    6
    7
    8
    Point global;
    Point foo_bar(Point arg)
    {
    Point local = arg, *heap = new Point(global);
    *heap = local;
    Point pa[4] = {local, *heap};
    return *heap;
    }

    1:foo_bar 的形参为传值形式,传入实参时会发生拷贝构造生成临时对象 arg
    2:local 通过临时对象 arg 拷贝构造生成
    3:head 指向的内存初始时通过全局对象 global 拷贝构造生成
    4:pa 数组的 [0] 元素通过局部对象 loacal 拷贝构造生成
    5:pa 数组的 [1] 元素通过 head 指向的内存上的内容拷贝构造生成
    6:foo_bar 返回的临时匿名对象通过 head 指向的内存上的内容拷贝构造生成

  14. 练习 13.5: 给定下面的类框架,编写一个拷贝构造函数,拷贝所有成员。你的构造函数应该动态分配一个新的 string,并将对象拷贝到 ps 指向的位置,而不是 ps 本身的位置。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class HasPtr
    {
    public:
    HasPtr(const std::string &s = std::string())
    : ps(new std::string(s)), i(0) {}

    private:
    std::string *ps;
    int i;
    };

    答:

    1
    HasPtr(const HasPtr &param) : ps(new string(*(param.ps))), i(param.i) {}

13.1.2 拷贝赋值运算符

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

  2. 重载运算符本质上是函数,其名字由 operator 关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为 operator= 的函数。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。

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

  4. 拷贝赋值运算符接受一个与其所在类相同类型的参数,为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。另外值得注意的是,标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。

    Best Practices: 赋值运算符通常应该返回一个指向其左侧运算对象的引用。

  5. 与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符(synthesized copy-assignment operator)。类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非 static 成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

  6. 练习 13.8: 为 13.1.1 节练习 13.5 中的 HasPtr 类编写赋值运算符。类似拷贝构造函数,你的赋值运算符应该将对象拷贝到 ps 指向的位置。

    1
    2
    3
    4
    5
    6
    7
    8
    HasPtr &operator=(const HasPtr &param)
    {
    auto tmp = new string(*(param.ps));
    delete ps;
    ps = tmp;
    i = param.i;
    return *this;
    }

13.1.3 析构函数

  1. 构造函数初始化对象的非 static 数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非 static 数据成员。析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数。 由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。

  2. 如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分。在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。

  3. 通常,析构函数释放对象在生存期分配的所有资源。在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。 成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。

    Note: 隐式销毁一个内置指针类型的成员不会 delete 它所指向的对象。

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

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

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


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

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

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

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

  7. 练习 13.12: 在下面的代码片段中会发生几次析构函数调用?

    1
    2
    3
    4
    5
    bool fcn(const Sales_data *trans, Sales_data accum)
    {
    Sales_data item1(*trans), item2(accum);
    return item1.isbn() != item2.isbn();
    }

    答:3 次。item1、item2 和 accum。函数形参里,accum 传值使用,会拷贝构造一个同名临时对象,离开函数前该临时对象被析构。

13.1.4 三/五法则

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

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

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

  3. 练习 13.14: 假定 numbered 是一个类,它有一个默认构造函数,能为每个对象生成一个唯一的序号,保存在名为 mysn 的数据成员中。假定 numbered 使用合成的拷贝控制成员,并给定如下函数:

    1
    void f(numbered s) { cout << s.mysn << endl; }

    则下面代码输出什么内容?

    1
    2
    3
    4
    numbered a, b = a, c = b;
    f(a);
    f(b);
    f(c);

    答:输出 3 个相同的值(a 的序号)。

  4. 练习 13.15: 假定 numbered 定义了一个拷贝构造函数,能生成一个新的序号。这会改变上一题中调用的输出结果吗?如果会改变,为什么?新的输出结果是什么?

    答:会改变,a 默认构造,b 使用 a 拷贝构造,c 使用 b 拷贝构造,3 次对 f 的调用又会发生 3 次拷贝构造(构造临时对象 s),每次拷贝构造的对象都将得到一个新序号,因此会输出三个新序号。

  5. 练习 13.16: 如果 f 中的参数是 const numbered&,将会怎样?这会改变输出结果吗?如果会改变,为什么?新的输出结果是什么?

    答:会改变,分别输出 a、b、c 的序号,调用 f 时不再拷贝构造临时对象。

13.1.5 使用 =default

  1. 可以通过将拷贝控制成员定义为 =default 来显式地要求编译器生成合成的版本:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Sales_data
    {
    public:
    // 拷贝控制成员;使用 default
    Sales_data() = default;
    Sales_data(const Sales_data &) = default;
    Sales_data &operator=(const Sales_data &);
    ~Sales_data() = default;
    // 其他成员的定义,如前
    };

    Sales_data &Sales_data::operator=(const Sales_data &) = default;

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

    Note: 我们只能对具有合成版本的成员函数使用 =default(即,默认构造函数或拷贝控制成员)。

13.1.6 阻止拷贝

  1. Best Practices: 大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。

  2. 在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上 =delete 来指出我们希望将它定义为删除的:

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

    =delete 通知编译器,我们不希望定义这些成员。

  3. =default 不同,=delete 必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码,因此 =default 直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的,以便禁止试图使用它的操作。=default 的另一个不同之处是,我们可以对任何函数指定 =delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用 =default)。 虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。

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

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

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

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

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

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


    本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,这看起来可能有些奇怪。其原因是,如果没有这条规则,我们可能会创建出无法销毁的对象。对于具有引用成员或无法默认构造的 const 成员的类,编译器不会为其合成默认构造函数,这应该不奇怪。同样不出人意料的规则是:如果一个类有 const 成员,则它不能使用合成的拷贝赋值运算符。毕竟,此运算符试图赋值所有成员,而将一个新值赋予一个 const 对象是不可能的。虽然我们可以将一个新值赋予一个引用成员,但这样做改变的是引用指向的对象的值,而不是引用本身。如果为这样的类合成拷贝赋值运算符,则赋值后,左侧运算对象仍然指向与赋值前一样的对象,而不会与右侧运算对象指向相同的对象。由于这种行为看起来并不是我们所期望的,因此对于有引用成员的类,合成拷贝赋值运算符被定义为删除的。

    Note: 本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。

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

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

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

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

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

13.2 拷贝控制和资源管理

  1. 一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。

  2. IO 类型和 unique_ptr 不允许拷贝或赋值,因此它们的行为既不像值也不像指针。

13.2.1 行为像值的类

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 这样编写赋值运算符是错误的!
    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) 时,就会访问一个指向无效内存的指针,其行为和结果是未定义的。

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

13.2.2 定义行为像指针的类

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

  2. 引用计数的工作方式如下:

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    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; // 将数据从 rhs 拷贝到本对象
    i = rhs.i;
    use = rhs.use;
    return *this; // 返回本对象
    }

13.3 交换操作

  1. 除了定义拷贝控制成员,管理资源的类通常还定义一个名为 swap 的函数。

  2. 如果一个类定义了自己的 swap,那么算法将使用类自定义版本。

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

  4. 可以在我们的类上定义一个自己版本的 swap 来重载 swap 的默认行为。swap 的典型实现如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class HasPtr
    {
    friend void swap(HasPtr &, HasPtr &);
    // 其他成员定义,与此前一样
    };
    inline void swap(HasPtr &lhs, HasPtr &rhs)
    {
    using std::swap;
    swap(lhs.ps, rhs.ps); // 交换指针,而不是 string 数据
    swap(lhs.i, rhs.i); // 交换 int 成员
    }

    我们首先将 swap 定义为 friend,以便能访问 HasPtr 的(private 的)数据成员。由于 swap 的存在就是为了优化代码,我们将其声明为 inline 函数。

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

    在本例中,数据成员是内置类型的,而内置类型是没有特定版本的 swap 的,所以在本例中,对 swap 的调用会调用标准库 std::swap。但是,如果一个类的成员有自己类型特定的 swap 函数,调用 std::swap 就是错误的了。

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

    1
    2
    3
    4
    5
    6
    7
    8
    // 注意 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 表达式。如果真发生了异常,它也会在我们改变左侧运算对象之前发生。

    Tip:使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。

13.4 拷贝控制示例

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

  2. 带有一个默认参数的构造函数会被当做默认构造函数。

13.5 动态内存管理类

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

  2. allocator 分配的内存是未构造的,我们将在需要添加新元素时用 allocatorconstruct 成员在原始内存中创建对象。类似的,当我们需要删除一个元素时,我们将使用 destroy 成员来销毁元素。

  3. 当我们用 allocator 分配内存时,必须记住内存是未构造的。为了使用此原始内存,我们必须调用 construct,在此内存中构造一个对象。传递给 construct 的第一个参数必须是一个指针,指向调用 allocate 所分配的未构造的内存空间。剩余参数确定用哪个构造函数来构造对象。

  4. 移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象。而且我们知道标准库保证“移后源”(moved-from)string 仍然保持一个有效的、可析构的状态。

  5. move 标准库函数定义在 utility 头文件中。我们通常不为 move 提供一个 using 声明。当我们使用 move 时,直接调用 std::move 而不是 move

13.6 对象移动

  1. 新标准的一个最主要的特性是可以移动而非拷贝对象的能力。很多情况下都会发生对象拷贝。在其中某些情况下,对象拷贝后就立即被销毁了。在这些情况下,移动而非拷贝对象会大幅度提升性能。

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

  3. 在旧 C++ 标准中,没有直接的方法移动对象。因此,即使不必拷贝对象的情况下,我们也不得不拷贝。如果对象较大,或者是对象本身要求分配内存空间(如 string),进行不必要的拷贝代价非常高。类似的,在旧版本的标准库中,容器中所保存的类必须是可拷贝的。但在新标准中,我们可以用容器保存不可拷贝的类型,只要它们能被移动即可。

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

13.6.1 右值引用

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

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

  3. 类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。如我们所知,对于常规引用(为了与右值引用区分开来,我们可以称之为左值引用(lvalue reference)),我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:

    1
    2
    3
    4
    5
    6
    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 绑定到乘法结果上

    返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。我们可以将一个左值引用绑定到这类表达式的结果上。返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个 const 的左值引用或者一个右值引用绑定到这类表达式上。

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

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

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


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

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

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

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

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

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

    之我见:能被赋值的都是左值,但左值不一定都能被赋值,例如右值引用。左值指的是持久值,右值指的是临时值。

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

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

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

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

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

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

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

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

  2. 我们为 StrVec 类定义移动构造函数,elementsfirst_freecapStrVec 类中的三个指针成员:

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

    一种通知标准库的方法是在我们的构造函数中指明 noexceptnoexcept是新标准引入的承诺一个函数不抛出异常的一种方法。我们在一个函数的参数列表后指定 noexcept。在一个构造函数中,noexcept 出现在参数列表和初始化列表开始的冒号之间。我们必须在类头文件的声明中和定义中(如果定义在类外的话)都指定 noexcept

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

  4. 我们需要指出一个移动操作不抛出异常,这是因为两个相互关联的事实:首先,虽然移动操作通常不抛出异常,但抛出异常也是允许的;其次,标准库容器能对异常发生时其自身的行为提供保障。例如,vector 保证,如果我们调用 push_back 时发生异常,vector 目身不会发生改变。

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

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

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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
    }

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

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

    除了将移后源对象置为析构安全的状态之外,移动操作还必须保证对象仍然是有效的。一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。另一方面,移动操作对移后源对象中留下的值没有任何要求。因此,我们的程序不应该依赖于移后源对象中的数据。

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

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

    与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。因此,某些类就没有移动构造函数或移动赋值运算符。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作。

    只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 static 数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。 编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 编译器会为 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:只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。

  10. 与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。但是,如果我们显式地要求编译器生成 =default 的移动操作,且编编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。 除了一个重要例外,什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则:

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


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

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

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

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

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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 左值引用——博主注)

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

    Note:如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似。

  13. 新标准库中定义了一种移动迭代器(move iterator)适配器。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。

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

    原迭代器的所有其他操作在移动迭代器中都照常工作。由于移动迭代器支持正常的迭代器操作,我们可以将一对移动迭代器传递给算法。特别是,可以将移动迭代器传递给 uninitialized_copy

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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 来将元素“拷贝”到目的位置。此算法使用迭代器的解引用运算符从输入序列中提取元素。由于我们传递给它的是移动迭代器,因此解引用运算符生成的是一个右值引用,这意味着 construct 将使用移动构造函数来构造元素。

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

  14. 建议:不要随意使用移动操作。 由于一个移后源对象具有不确定的状态,对其调用 std::move 是危险的。当我们调用 move 时,必须绝对确认移后源对象没有其他用户。

    通过在类代码中小心地使用 move,可以大幅度提升性能。而如果随意在普通用户代码(与类实现代码相对)中使用移动操作,很可能导致莫名其妙的、难以查找的错误,而难以提升应用程序性能。

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

  15. 练习 13.51: 虽然 unique_ptr 不能拷贝,但我们在 12.1.5 节(第 418 页)中编写了一个 clone 函数,它以值方式返回一个 unique_ptr。解释为什么函数是合法的,以及为什么它能正确工作。

    答:不能拷贝 unique_ptr 的规则有一个例外:我们可以拷贝或赋值一个将要被销毁的 unique_ptr。最常见的例子就是从函数返回一个 unique_ptr

13.6.3 右值引用和成员函数

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

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

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

    一般来说,我们不需要为函数操作定义接受一个 const X&& 或是一个(普通的)X& 参数的版本。当我们希望从实参“窃取”数据时,通常传递一个右值引用。为了达到这一目的,实参不能是 const 的。类似的,从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个(普通的)X& 参数的版本。

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    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 时,实参类型决定了新元素是拷贝还是移动到容器中:

    1
    2
    3
    4
    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),具体调用哪个版本据此来决定。

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Foo
    {
    public:
    Foo &operator=(const Foo &) &; // 只能向可修改的左值赋值
    // Foo 的其他参数
    };
    Foo &Foo::operator=(const Foo &rhs) &
    {
    // 执行将 rhs 赋予本对象所需的工作
    return *this;
    }

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

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

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

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

    1
    2
    3
    4
    5
    6
    class Foo
    {
    public:
    Foo someMem() & const; // 错误:const 限定符必须在前
    Foo anotherMem() const &; // 正确:const 限定符在前
    };
  3. 就像一个成员函数可以根据是否有 const 来区分其重载版本一样,引用限定符也可以区分重载版本。而且,我们可以综合引用限定符和 const 来区分一个成员函数的重载版本。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    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 版本:

    1
    2
    retVal().sorted(); // retVal() 是一个右值,调用 Foo::sorted() &&
    retFoo().sorted(); // retFoo() 是一个左值,调用 Foo::sorted() const &

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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:如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。

  4. 练习 13.56: 如果 sorted 定义如下,会发生什么:

    1
    2
    3
    4
    5
    Foo Foo::sorted() const &
    {
    Foo ret(*this);
    return ret.sorted();
    }

    答:由于 ret 是左值,所以会无限递归调用左值版本的 sorted 方法 Foo Foo::sorted() const &

  5. 练习 13.57: 如果 sorted 定义如下,会发生什么:

    1
    Foo Foo::sorted() const & { return Foo(*this).sorted(); }

    答:使用 *this 拷贝构造一个 Foo 临时对象(右值),对该临时对象调用右值版本的 sorted 方法 Foo Foo::sorted() && 并返回。

小结

  1. 移动构造函数和移动赋值运算符接受一个(通常是非 const 的)右值引用;而拷贝版本则接受一个(通常是 const 的)普通左值引用。

  2. 如果一个类未声明这些操作,编译器会自动为其生成。如果这些操作未定义成删除的,它们会逐成员初始化、移动、赋值或销毁对象:合成的操作依次处理每个非 static 数据成员,根据成员类型确定如何移动、拷贝、赋值或销毁它。

  3. 分配了内存或其他资源的类几乎总是需要定义拷贝控制成员来管理分配的资源。如果一个类需要析构函数,则它几乎肯定也需要定义移动和拷贝构造函数及移动和拷贝赋值运算符。

术语表

第 13 章术语表(1)

第 13 章术语表(2)


Thank you for your donate!