[C++ Primer] : 第13章: 拷贝控制

拷贝, 赋值与销毁

当定义一个类时, 我们显示地或隐式地指定在此类型的对象拷贝, 移动, 赋值和销毁时做什么. 一个类通过定义5种特殊的成员函数来控制这些操作, 包括: 拷贝构造函数, 拷贝赋值运算符, 移动构造函数, 移动赋值运算符析构函数. 编译器可以合成这些成员函数, 其他的函数不能合成.
拷贝和移动构造函数定义了当用同类型的一另一个对象初始化本对象时做什么, 拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么. 这些操作称为拷贝控制操作.
如果我们不显式地定义这些操作, 编译器也会为我们定义, 但编译器定义的版本的行为可能并非我们所想.

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

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

拷贝构造函数也可以接受一个非const引用, 但通常来说是const的.
拷贝构造函数在多种情况下会被隐式的调用, 所以拷贝构造函数通常不应该是explicit的.
如果自定义拷贝构造函数并且将其定义为private的, 则明确告诉编译器不能使用拷贝构造函数, 所有拷贝初始化均不能使用.
如果没有显式地定义拷贝构造函数, 则编译器会为我们定义一个.
合成拷贝构造函数使如何拷贝成员的?
一般情况, 合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中. 编译器从给定对象中依次将每一个非static成员拷贝到正在创建的对象中. 对类类型成员, 使用其拷贝构造函数来拷贝, 内置类型的成员使用直接拷贝. 对数组则会逐个元素地拷贝.

拷贝初始化与直接初始化的区别:
直接初始化要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数.
拷贝初始化要求编译器将右侧运算对象拷贝到正在创建的对象中, 如果需要的话还需要进行类型转换. 它通常调用拷贝构造函数, 首先用指定的构造函数创建一个临时对象, 然后拷贝构造函数将其拷贝到正在创建的对象上. 如果一个类有移动构造函数, 则拷贝初始化有时用移动构造函数而非拷贝构造函数来完成.

string dots(10, ',');                // 直接初始化
string s(dots);                      // 直接初始化
string s2 = dots;                    // 拷贝初始化
string null_book = "9-9999-9999-9";  // 拷贝初始化
string nines = string(100, '9');     // 拷贝初始化

发生拷贝初始化的情况:

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

拷贝构造函数的参数必须是引用类型的原因: 如果参数是非引用类型, 则为了传递参数需要调用拷贝构造函数来拷贝实参, 但为了拷贝实参又反过来需要调用拷贝构造函数, 如此无限循环.

编译器可以绕过拷贝构造函数:
拷贝初始化过程中, 编译器可以(但非必须)跳过拷贝/移动构造函数, 直接创建对象. 即, 编译器被允许将下面的代码
string null_book = "9-999-99999-99"; // 拷贝初始化
改写成
string null_book("9-999-99999-99"); // 编译器略过了拷贝构造函数而直接调用匹配的构造函数, 即string接受一个字符串字面常量值的构造函数.
虽然编译器略过了拷贝/移动构造函数, 但是在这个程序点上, 拷贝/构造函数必须是存在且可访问的.
在这个例子中, 编译器发现拷贝构造函数是可访问的, 即允许对象间的赋值, 此时它发现可以直接调用相应的构造函数而非拷贝构造函数而达到相同的效果, 故优化成了第二种写法.

13.1.2 拷贝赋值运算符
即重载 = 运算符. 如果没有定义自己的拷贝赋值运算符, 则编译器会帮我们合成一个.

重载运算符本质上是函数, 其名字有operator关键字后接表示要定义的运算符的符号组成.
某些运算符, 包括赋值运算符, 必须定义为成员函数, 如果一个运算符是一个成员函数, 其左侧运算对象就绑定到隐式的this参数.
拷贝赋值运算符接受一个与其所在类相同类型的参数, 将右侧运算对象的每一个非static成员赋予左侧运算对象的对应成员, 返回指向其左侧运算对象的引用.
= 运算符既可以调用构造函数(包括拷贝构造函数和移动构造函数), 也可以调用赋值运算符(包括拷贝赋值运算符和移动赋值运算符). 区别在于: 如果有新对象定义, 则一定有一个构造函数被调用, 不可能直接调用赋值操作, 则此时会调用拷贝或移动构造函数. 如果没有新对象被定义, 就不会有构造函数被调用, 那么当然就是赋值操作被调用.

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

13.1.3 析构函数
构造函数初始化对象的非static数据成员, 析构函数释放并销毁非static数据成员.
没有返回值, 也不接受参数, 由于不接受参数, 故不可被重载. 一个类只能有一个析构函数.
如同构造函数有一个初始化部分和一个函数体, 析构函数也有一个函数体和一个析构部分. 在一个析构函数中, 首先执行函数体, 然后销毁成员, 成员按初始化顺序的逆序销毁.
析构部分是隐式的, 销毁类类型的成员需要执行成员自己的析构函数, 内置类型没有析构函数, 因此销毁内置类型的成员什么也不需要做.
隐式销毁一个内置指针类型的成员不会delete它所指的对象, 因为内置类型没有析构函数, 不会主动执行delete. 与普通指针不同, 智能指针是类类型, 有析构函数, 所以智能指针成员在析构阶段会被自动销毁.

析构函数的调用时机:

  • 变量离开其作用域时被销毁
  • 一个对象被销毁时, 其成员被销毁
  • 容器被销毁时, 其元素被销毁
  • 对于动态分配的对象, 当对指向它的指针应用delete运算符时, 该对象(执行析构函数)被销毁.
  • 对于临时对象, 当创建它的完整表达式结束时被销毁.

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

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

13.1.4 三/五法则

什么是三五法则?
三指的是: 拷贝构造函数, 拷贝赋值运算符和析构函数这三个基本操作. 五指的是新标准下增加的移动构造函数和移动赋值运算符.
一, 需要析构函数的类也需要拷贝和赋值操作, 对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显.
通常需要定义析构函数的类包含有动态管理的内存(new分配的内存), 合成析构函数并不会delete一个指针数据成员, 因此需要定义一个析构函数来释放构造函数的内存. 默认合成构造函数和赋值函数只会执行浅拷贝, 即简单地拷贝指针, 而不会拷贝动态内存中的内容, 需要自己定义来执行深拷贝.
二, 需要拷贝操作的类也需要赋值操作, 反之亦然. 但是无论是拷贝构造函数还是赋值运算符都不必然意味着也需要析构函数.
如一个类为每个对象分配一个独一无二的序号, 这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的, 独有的序号, 除此之外, 拷贝所有其他成员. 这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象. 但是这个类不需要自定义析构函数.
添加移动操作的对三/五法则的更新:
所有的5个拷贝控制成员(拷贝构造函数, 拷贝赋值运算符, 移动构造函数, 移动赋值运算符, 析构函数)应该看做一个整体: 一般来说, 如果一个类定义了任何一个拷贝操作, 它就应该定义所有的五个操作.

13.1.5 使用=default
=default是显式地要求编译器生成合成的版本.
在类内部使用=default修饰成员的声明时, 合成的函数将显式地声明为内联的. 如果不希望合成的成员是内联函数, 应该只对成员的类外定义使用=default.

class Sales_data {
public:
    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完全是说明由编译器生成我们成员函数内的其它代码, 所以它只是声明而非定义.

13.1.6 阻止拷贝

定义为删除的函数, 在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的.
新标准下, 可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝. 对于函数的函数, 我们虽然声明了他们, 但是不能以任何方式使用它们. = delete通知编译器个读者, 我们不希望定义这些成员.

struct NoCopy {
public:
    NoCopy() = delete;
    NoCopy(const NoCopy&) = delete;
    NoCopy &operator=(const NoCopy&) = delete;
    ~NoCopy() = default; // 使用合成的析构函数
    // 其他成员
}

=delete必须出现在函数第一次声明的时候, 编译器需要知道一个函数是删除的, 以便禁止试图使用它的操作. 但是=default则没有此限制.
=delete可以对任何函数指定, 而=default只能对编译器可以默认合成的构造函数和拷贝控制成员使用. 删除函数的主要用途是禁止拷贝控制成员.
=delete有一个跟=default不同的地方, 即=delete必须定义在类的声明中, 以确保编译器能尽早知道那个函数是我们指定删除的. 而类是否合成默认版本, 则是编译器在类定义时决定的. 所以=default和=delete这种差异, 并不与C++的原本逻辑相悖.
析构函数不能是删除的成员. 如果析构函数被删除, 就无法销毁此类型的对象了. 对于一个删除了析构函数的类型, 编译器将不允许定义该类型的变量或创建该类型的临时变量. 而且如果一个类有类类型的成员且该成员的类型删除了析构函数, 我们也不能定义该类的变量或临时对象.
对于删除析构函数的类型, 虽然不能定义这种类型的变量或成员, 但是可以动态分配这种类型的对象(即new出一个该类型的对象), 但是不能delete这些对象.

struct NoDtor{
    NoDtor() = default;     // 使用合成默认构造函数
    ~NoDtor() = delete;     // 删除了析构函数, 不能销毁该类型的对象
};
NoDtor nd;                  // 错误, NoDtor的析构函数是删除的
NoDtor *p = new NoDtor();   // 正确, 动态分配一个NoDtor类型的对象, 用默认构造函数初始化. 但是我们不能delete p
delete p;                   // 错误, NoDtor的析构函数是删除的

编译器将一些合成的成员定义为删除的函数(隐式地):

  • 如果类的某个成员的析构函数是删除的或不可访问的(比如是private的), 则类的合成析构函数将是被定义为删除的.
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的, 则类的合成拷贝构造函数被定义为删除的.
  • 如果类的某个成员的析构函数是删除的或不可访问的, 则类合成的拷贝构造函数也被定义为删除的. (如果没有这条规则, 我们可能创建出无法销毁的对象)
  • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的, 或是类有一个const的或引用成员, 则类的合成拷贝赋值运算符被定义为删除的.
    虽然可以将一个新值赋予一个引用成员, 但是这样做改变的是引用指向的对象的值, 而不是引用本身. 而且改变引用指向的对象是不允许的. 如果为这样的类合成拷贝赋值运算符, 则赋值后, 左侧运算对象仍然指向与赋值前一样的对象, 而不会与右侧运算对象指向相同的对象, 这种行为看起来不是我们期望的, 因此对于有引用成员的类, 合成拷贝赋值运算符被定义为删除的.
  • 如果类的某个成员的析构函数是删除的或不可访问的, 或是类有一个引用成员且它没有类内初始化器, 或是类有一个const的成员, 它没有类内初始化器且类型未显示定义默认构造函数, 则类的默认构造函数被定义为删除的.

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

private拷贝控制: C++11新标准发布前, 控制拷贝的方法是将拷贝控制成员声明为private但不定义它们: 试图拷贝对象的用户代码将在编译阶段被标记为错误, 成员函数或者友元函数中的拷贝操作将会导致链接时错误(因为只有声明但没有定义). 声明但不定义一个成员是合法的, 试图访问一个没有定义的成员将导致链接时错误.

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

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

拷贝控制与资源管理

有两种拷贝语义来定义拷贝操作一个使类的行为看起来像一个值, 一个使类的行为像一个指针.

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

13.2.1 行为像值的类
为了提供类值的行为, 对于类管理的资源, 每个对象都应该拥有一份自己的拷贝.
类值拷贝赋值运算符:
编写赋值运算符时有两点需要注意:

  • 赋值运算符通常组合了析构函数和构造函数的操作, 类似析构函数, 赋值操作会销毁左侧运算对象的资源; 类似拷贝构造函数, 赋值操作会从右侧运算对象拷贝数据.
  • 如果将一个对象赋予它自身, 即自赋值情况, 赋值运算符必须能正常工作. 一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中, 然后再销毁左侧对象.
class HasPtr
{
public:
    HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) { }
    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;
};

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

13.2.1 定义行为像指针的类
析构函数不能单方面的释放动态内存, 只有当最后一个指向动态内存的类销毁时, 它才可以释放动态内存.

让一个类展现类似指针的行为的最好的方法是使用shared_ptr来管理类中的资源. 如果我们想自己管理, 那么就要用到引用计数.
引用计数的工作方式如下:

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

using namespace std;

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(new std::string(*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;   // 返回本对象
}

交换操作

管理资源的类通常还定义一个名为swap的函数. 对于那些与重排元素顺序的算法一起使用的类, 定义swap是非常重要的, 这类算法在需要交换两个元素时会调用swap.
如果一个类定义了自己的swap, 那么算法将使用类自定义的版本, 否则将使用标准库定义的swap.
swap直接交换指针而不是动态内存的副本, 这样速度更快.

class HasPtr
{
    friend void swap(HasPtr&, HasPtr&);
public:
    HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) { }
    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;
};

inline void swap(HasPtr &lhs, HasPtr &rhs)
{
    using std::swap;
    swap(lhs.ps, rhs.ps);  // 交换指针, 而不是string数据
    swap(lhs.i, rhs.i);    // 交换int成员
    // 内置类型没有特定版本的swap, 因此会调用标准库std::swap
}

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
    auto newp = new std::string(*rhs.ps);
    delete ps;
    ps  = newp;
    i   = rhs.i;
    return *this;
}

// 假设有一个Foo类, 它有一个HasPtr类型的成员
class Foo
{
    friend void swap(Foo&, Foo&);
private:
    HasPtr h;
};

inline void swap(Foo &lhs, Foo &rhs)
{
    using std::swap; // using成名并没有隐藏HasPtr版本的swap声明
    swap(lhs.h, rhs.h); // 使用的是HasPtr版本的swap而不是std::swap
    // std::swap(lhs.h, rhs.h); // 调用了标准库版本的swap, 错误, 造成了不必要的拷贝, 虽然可以编译通过且可以正常运行, 但是性能不好
}

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

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

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

使用拷贝和交换的赋值运算符自动就是异常安全的, 且能正确处理自赋值. 它通过改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确. 代码中唯一可能抛出异常的是拷贝构造函数中的new表达式, 如果真的发生了异常, 它也会在我们改变左侧运算对象之前发生.

拷贝控制示例

动态内存管理类

对象移动

在某些情况下, 对象拷贝后就立即被销毁了, 此时, 移动而非拷贝对象会大幅度提升性能.
标准库容器, string, 和shared_ptr类既支持移动也支持拷贝. IO类和unique_ptr类也可以移动但不能拷贝.
标准库函数move定义在utility头文件中. 通常不为move提供using声明, 当我们使用move时, 直接调用std::move而不是move.

13.6.1 右值引用
右值引用就是只能绑定到右值的引用, 通过&&而不是&来获取右值引用.
右值引用有一条重要的性质———只能绑定到一个将要销毁的对象.
一般而言, 一个左值表达式表示的是一个对象的身份(在内存中的位置), 而一个右值表达式表示的是对象的值. 类似任何引用, 一个右值引用也不过是某个对象的另一个名字而已.
变量是左值: 变量可以看做是只有一个运算对象(即它自身), 但是没有运算符的表达式. 类似任何表达式, 表达式也有左值/右值属性, 变量表达式都是左值, 带来的结果就是不能将一个右值引用绑定到一个右值引用类型的变量上.

int i = 42;
int &r = i;  // 正确
int &&rr = i;// 错误, 不能将右值引用绑定到一个左值上
int &r2 = i * 42; // 错误, 不能将左值引用绑定到一个右值上
const int &r3 = i * 42; // 正确, 可以将一个const引用绑定到右值上
int &&rr2 = i * 42; // 正确
int &&rr3 = rr2; // 错误, rr2是左值, 具名的右值引用被当做一个左值看待, 不具名的右值引用则仍然是右值.

左值持久, 右值短暂
右值只能绑定到临时对象: 所引用的对象将要被销毁; 该对象没有其他用户. 使用右值引用的代码可以自由的接管所引用的对象的资源.
右值引用指向将要被销毁的对象. 因此我们可以从绑定的右值引用的对象"窃取"状态.
变量是左值, 因此不能将一个右值引用直接绑定到一个变量上, 即使这个变量是右值引用类型也不行.

标准库move函数: move函数可以获得绑定到左值上的右值引用, 函数定义在utility中. 如:
int &&rr2 = std::move(rr1);
move调用告诉编译器: 我们有一个左值, 但我们希望像一个右值一样来处理它. 必须认识到, 调用move就意味着承诺: 处理对rr1赋值或销毁它之外, 我们将不在使用它. 调用move之后, 不能对移后源对象做任何假设.
我们可以销毁一个移后源对象, 也可以赋予它新值, 但不能使用一个移后源对象的值.
使用move的代码应该使用std::move而不是move, 以避免潜在的名字冲突.

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

StrVec::StrCVec(StrVec &&s) noexcept // 移动操作不应该抛出任何异常
    : elements(s.element), first_free(s.first_free), cap(s.cap)
{
    // 确保对s运行析构函数是安全的
    s.element = s.first_free = s.cap = nullptr;
}

由于移动操作"窃取"资源, 他通常不分配任何资源. 因此, 移动操作通常不会抛出任何异常. 当编写一个不抛出异常的移动操作时, 我们应该将此事通知标准库. 除非标准库知道我们的移动构造函数不会抛出异常, 否则它会认为移动我们的类对象时可能会抛出异常, 并且为了处理这种可能性而做一些额外的工作.
noexpect通知标准库我们的构造函数不抛出任何异常. 在一个构造函数中, noexpect出现在参数列表和初始化列表开始的冒号之间.
不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept.

class StrVec {
public:
    StrVec(StrVec&&) noexcept; // 移动构造函数
}

StrVec::StrVec(StrVec&&) noexcept : /*成员初始化器*/
{ /*构造函数体*/ }

我们需要指出一个移动构造函数不抛出异常的原因基于以下两点:

  • 虽然移动操作通常不抛出异常, 但抛出异常通常也是允许的.
  • 标准库能对异常发生时其自身的行为提供保障.

异常发生时, 移动了部分元素而非全部, 就空间中的元素已被改变, 而新空间中的元素可能尚不存在. 对于拷贝构造函数, 很容易满足标准库在异常发生是保持自己不变的要求. 因此除非标准库知道移动构造函数不会抛出异常, 否则就必须使用拷贝构造函数而非移动构造函数. 如果希望使用移动构造函数, 就必须显示的告诉标准库我们的移动构造函数是安全的.
在C++11中如果noexcept修饰的函数抛出了异常, 编译器可以选择直接调用std::terminate()函数来终止程序的运行, 这比基于异常机制的throw()在效率上会高一些. 这是noexcept声明的原因.

移动赋值运算符:

StrVec& StrVec::operator=(StrVec &&rhs) noexpect
{
    if(this != &rhs)                    // 检查自赋值情况
    {
        free();                         // 释放已有元素(左侧对象)
        elements = rhs.elements;        // 从rhs接管资源
        first_free = rhs.first_free;
        cap = rhs.cap;
        rhs.elements = rhs.first_free = rhs.cap = nullptr; // 将rhs置于可析构状态
    }
    return *this;
}

移动操作必须保证对象仍然是有效的. 一般来说, 对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值. 另一方面, 移动操作对移后源对象中留下的值没有任何要求. 因此程序不应该依赖于移后源对象中的数据.
比如, 从一个标准库string或容器中移动数据时, 我们知道移后源对象仍然有效, 因而可以对它执行诸如empty或size的操作, 但是我们不知道会得到什么结果, 我们可能会期望移后源对象是空的, 但这并没有保证.
在移动操作之后, 移后源对象必须保持有效的, 可析构状态, 但是用户不能对其值进行任何假设.
移动语义一定是要修改临时变量的值, 因此在实现移动语义时一定要排除不必要的const关键字.

合成的移动操作:
编译器合成移动操作的条件与合成拷贝操作的条件不相同:
如果一个类定义了自己的拷贝构造函数, 拷贝赋值运算符或者析构函数, 编译器就不会为它合成移动构造函数和移动赋值运算符了. 因此, 某些类就没有移动构造函数或移动赋值运算符,
如果一个类没有移动操作, 通过正常的函数匹配, 类会使用对应的拷贝操作来代替移动操作.
只有当一个类没有定义任何自己版本的拷贝控制成员, 且类的每一个非static数据成员都可以移动时, 编译器才会为它合成移动构造函数或移动赋值运算符. 编译器可以移动内置类型, 如果一个成员是类类型, 且该类有对应的移动操作. 编译器也能移动这个成员.

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

为什么我们定义了自己的拷贝构造, 拷贝赋值运算符, 析构函数后, 编译器不会帮我们合成移动构造函数? 可能是因为, 如果我们定义了这些操作往往表示类内含有指针成员需要动态分配内存, 如果需要为类定义移动操作, 那么应该确保移动后源对象是安全的, 但是默认的移动构造函数不会帮我们把指针成员置空, 移后源不是可析构的安全状态, 如果这样, 当离开移动构造后, 源对象被析构, 对象内的指针成员空间被回收, 转移之后对象内的指针成员出现悬垂现象, 程序将引起致命的错误. 所以当我们定义了自己的拷贝操作和析构函数时, 编译器是不会帮我们合成默认移动构造函数的. 对于存在拷贝构造函数和拷贝赋值运算符, 我们可能比较容易理解, 因为当我们定义了这两个函数后, 就算没有移动操作函数, 我们的类仍然能工作得很好: 对于临时变量, 我们可以把临时变量的堆数据完整拷贝过来. 说到底, 移动操作类函数, 只是为了让类工作得更快, 而非不可缺.

移动操作永远不会隐式定义为删除的函数. 但是如果我们显式地要求编译器生成=default的移动操作, 但是编译器又不能移动所有成员, 则编译器会将移动操作定义为删除的函数.
移动构造函数被定义成删除的函数的情况:

  • 有类成员定义了自己的拷贝构造函数且未定义移动构造函数, 或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数.
  • 有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的, 则类的移动构造函数被定义为删除的.
  • 类似拷贝构造函数, 如果类的析构函数被定义为删除的或是不可访问的, 则类的移动构造函数被定义为删除的.
  • 类似拷贝赋值运算符, 如果有类的成员是const的或是引用, 则类的移动赋值运算符被定义为删除的.
// 假设Y是一个类, 它定义了拷贝构造函数但是没有定义移动构造函数.
struct hasY {
    hasY() = default;
    hasY(hasY&&) = default; // 显示要求, 但编译器无法为其生成, 因此会有一个删除的移动构造函数. 但如果没有这句, 编译器不会为hasY合成移动构造函数
    Y mem; // hasY有一个删除的移动构造函数.
};

移动操作和合成的拷贝控制成员间的相互作用关系: 定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作, 否则这些成员默认地被定义为删除的.

移动右值, 拷贝左值, 但如果没有移动构造函数, 则右值也被拷贝
如果一个类既有移动构造函数, 又有拷贝构造函数, 编译器使用普通的函数匹配规则来确定使用那个构造函数.
拷贝构造函数接受一个const的左值引用, 它可以绑定到任何类型上, 包括右值, 而移动构造函数接受一个右值引用, 只能绑定到右值. 因此如果传进来的参数是右值, 则优先匹配移动构造函数, 若没有移动构造函数, 则匹配拷贝构造函数.
如果一个类定义了拷贝构造函数, 但是未定义移动构造函数, 则此时编译器不会合成移动构造函数. 如果一个类没有移动构造函数, 函数匹配规则保证该类型的对象被拷贝, 即使我们试图通过调用move来移动它们也是如此.

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

使用拷贝构造函数代替移动构造函数几乎可以肯定是安全的.
如果一个类有一个可用的拷贝构造函数而没有移动构造函数, 则其对象是通过拷贝构造函数来"移动"的. 拷贝赋值运算符与移动赋值运算符的情况类似.

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

class HasPtr
{
    friend void swap(HasPtr&, HasPtr&);
public:
    HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) { }
    // 拷贝构造函数
    HasPtr(const HasPtr &p): ps(new std::string(*p.ps)), i(p.i) { }
    // 移动构造函数
    HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = nullptr; }
    // 赋值运算符既是移动赋值运算符, 也是拷贝赋值运算符
    HasPtr &operator=(HasPtr rhs)
    { swap(*this, rhs); return *this; }
    ~HasPtr(){ delete ps; }
private:
    std::string *ps;
    int i;
};

inline void swap(HasPtr &lhs, HasPtr &rhs)
{
    using std::swap;
    swap(lhs.ps, rhs.ps);  // 交换指针, 而不是string数据
    swap(lhs.i, rhs.i);    // 交换int成员
}

HasPtr hp, hp2;
hp = hp2; // hp2是左值, hp2通过拷贝构造函数拷贝
hp = std::move(hp2); // 移动构造函数(移动赋值运算符)

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

13.6.3 右值引用和成员函数
区分移动和拷贝的重载函数通常有一个版本接受一个const T&, 而另一个版本接受一个T&&.
左值和右值引用成员函数:
通常我们在一个对象上调用成员函数, 而不管该对象是一个左值还是一个右值, 如:
string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');
此例中, 我们在一个string右值上调用find成员, 该string右值通过连接两个string而得到的. 甚至还可以这样用:
s1 + s2 = "wow!";
此处我们对两个string的连接结果———一个右值进行了赋值.
有时候我们希望强制成员函数的左侧运算对象(即, this指向的对象)是一个左值.
我们指出this的左值/右值属性的方式与定义const成员函数相同, 即在参数列表后放置一个引用限定符.
引用限定符可以是&或&&, 分别指出this可以指向一个左值或右值. 类似与const, 引用限定符只能用于(非static)成员函数, 且必须同时出现在函数的声明和定义中. 如果还有const限定符, 则引用限定符必须在const之后.

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

就像成员函数可以根据是否有const来区分重载一样, 引用限定符也可用来区分重载版本. 如

class Foo{
public:
    Foo sorted() &&;                  // 用于可改变的右值
    Foo sorted() const &;             // 可用于任何类型的Foo
    Foo &retFoo();                    // 返回一个引用, retFoo调用是一个左值
    Foo retVal();                     // 返回一个值, retVal调用是一个右值
    //其他成员的定义
private:
    vector<int> data;
};
Foo Foo::sorted() &&                  // 本对象为右值, 因此可以原址排序
{
    sort(data.begin(), data.end());
    return *this;
}
Foo Foo::sorted() const &             // 本对象是const或是一个左值, 不能对其进行原址排序
{
    Foo ret(*this);                   // 拷贝一个副本
    sort(ret.begin(), ret.end());     // 排序副本
    return *ret;                      // 返回副本
}

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

当我们对一个右值执行sorted时, 由于是右值, 意味着它没有其他用户, 因此我们可以改变对象, 可以安全地直接对data成员进行原址排序.
当我们对一个const右值或一个左值执行sorted时, 它可能还有其他用户, 所以我们不能改变对象, 因此需要在排序前拷贝data.
如果一个成员函数有引用限定符, 那么所有具有相同参数列表的所有版本都必须有引用限定符.

posted @ 2017-09-11 22:41  moon1992  阅读(437)  评论(0编辑  收藏  举报