What & How & Why

拷贝控制

C++ Primer 笔记 第十三章


当一个类被定义的时候,有几种针对于该类型的操作需要被指定:

  • 该类型如何被拷贝copy
  • 该类型如何被赋值assign
  • 该类型如何被移动move
  • 该类型如何被销毁destroy

我们通过如下类型的构造函数来决定这些操作如何进行:

  • 拷贝构造函数copy constructor),决定对象如何通过对同类型对象进行拷贝的方式进行构造
  • 移动构造函数move constructor),决定对象如何通过对同类型对象进行移动的方式进行构造
  • 拷贝赋值运算符copy-assignment operator ),决定对象赋值操作(以拷贝的方式)
  • 移动赋值运算符move-assignment operator),决定对象赋值操作(以移动的方式)
  • 析构函数(destructor),决定对象如何被销毁。

定义以上这些操作的过程,被称为拷贝控制Copy Control)。这些操作通常由编译器隐式的实现。但在某些类中,编译器的实现是无法满足我们的要求的;这些情况下需要显式的定义如上的操作。

拷贝,赋值与析构

拷贝构造函数

拷贝构造函数copy constructor)具有如下的特点:

  • 该函数的第一个 parameter 类型是对应 class type 的引用
  • 其余的 parameter 都有默认值

class Foo {
public:
   Foo();             // default constructor
   Foo(const Foo&);   // copy constructor
   // ...
};
为了同时支持常量 / 非常量应用,绝大部分情况下,第一个 parameter 的类型是 const classType&。由于拷贝构造函数经常用于不同的情况,因此不应该是 explicit 类型。

拷贝构造函数之所以接收 const classType& 类型作为参数,是为了避免陷入逻辑上的死循环。如果拷贝构造函数接收 const classType 作为参数,那么就会出现先有鸡还是先有鸡蛋的问题:拷贝构造函数需要类对象的拷贝进行初始化;但类对象反过来又需要拷贝构造函数进行初始化。

合成拷贝构造函数

编译器会在拷贝构造函数没有被显式定义的情况下提供一个合成拷贝构造函数synthesized copy constructor)。合成拷贝构造函数使用 memberwise copies 的策略对类对象进行初始化,也就是:

  • 以成员为单位,以拷贝的方式依次进行初始化
  • 初始化的方式取决于成员类型:
    • class type:初始化方式取决于其自身的拷贝构造函数
    • built-in type:直接拷贝
    • array:按元素进行拷贝

Sales_data 类为例,其拷贝构造函数实际上等同于:

class Sales_data {
public:
    // other members and constructors as before
    // declaration equivalent to the synthesized copy constructor
    Sales_data(const Sales_data&);
private:
    std::string bookNo;
    int units_sold = 0;
    double revenue = 0.0;
};
// equivalent to the copy constructor that would be synthesized for Sales_data
Sales_data::Sales_data(const Sales_data &orig):
    bookNo(orig.bookNo),         // uses the string copy constructor
    units_sold(orig.units_sold), // copies orig.units_sold
    revenue(orig.revenue)        // copies orig.revenue
    {    }                       // empty body

拷贝初始化

拷贝初始化与直接初始化的区别

  • 直接初始化Direct initialization):使用最匹配 argument 的构造函数进行初始化。
  • 拷贝初始化Copy initialization):使用 = 拷贝右边算子的内容对对象进行初始化。该过程会有潜在的类型转换

拷贝初始化存在两种方式

Copy-assignmentMove-assignment。在存在移动构造函数的情况下,拷贝初始化会使用移动构造函数取代拷贝构造函数。

拷贝初始化应用的场景

  • 使用 = 进行初始化
  • 使用对象本身作为 argument, 传递给一个非引用类型的 parameter
  • 从某个函数返回非引用类型的对象
  • 使用初始化列表初始化数组或者聚合类的成员
  • 某些容器空间分配操作也使用拷贝的方式初始化元素,比如 insert(), push() 等。
拷贝初始化的限制

直接初始化与拷贝初始化是否合法是根据构造函数是否是 explicit 来决定的。下面的例子中,vector 中单参数的构造函数是 explicit 的,因此该构造函数不能被用于带有隐式转换的过程。比如 f(10),将 int 类型的参数传递给 vector<int> 的 parameter 存在隐式转换,因此不合法。

vector<int> v1(10);  // ok: direct initialization
vector<int> v2 = 10; // error: constructor that takes a size is explicit

void f(vector<int>); // f's parameter is copy initialized
f(10); // error: can't use an explicit constructor to copy an argument
f(vector<int>(10));  // ok: directly construct a temporary vector from an int

编译器会绕过拷贝构造函数

当然也存在一种情况,编译器会优先选择对对象进行直接初始化,而不是使用拷贝/移动构造函数。比如:

string null_book = "9-999-99999-9"; // copy initialization
结果被编译器转换为:
string null_book("9-999-99999-9"); // compiler omits the copy constructor
不过,即便是编译器绕过了拷贝构造函数,我们也需要保证拷贝构造函数是存在并可访问的

拷贝赋值运算符

拷贝赋值运算符copy-assignment operator)定义了类的拷贝方式。其核心是一个名叫 operator= 的,可被重载的函数。该函数获取两个参数:this 和 右边的 operand ,作为该函数的与运算对象。

需要注意的是:

  • 拷贝赋值运算符需要被定义为成员函数
  • 拷贝赋值运算符作为成员函数时,其左算子(第一个参数)与 this 绑定,其右算子需要显式指定,并与运算符所在类的类型一致,比如:

class Foo {
public:
    Foo& operator=(const Foo&); // assignment operator
   // ...
};

为了与默认的赋值运算保持一致,拷贝赋值运算符通常被定义为:接收一个 const classtype& 的参数,并返回当前类(左算子)的引用

合成拷贝赋值运算符

合成拷贝赋值运算符的功能与合成拷贝构造函数类似,也是使用 memberwise coping 的策略来进行赋值;并返回左算子的引用。还是以 Sales_data 为例:

// equivalent to the synthesized copy-assignment operator
Sales_data&
Sales_data::operator=(const Sales_data &rhs)
{
    bookNo = rhs.bookNo;          // calls the string::operator=
    units_sold = rhs.units_sold;  // uses the built-in int assignment
    revenue = rhs.revenue;        // uses the built-in double assignment
    return *this;                 // return a reference to this object
}

析构函数

析构函数Destructor)与构造函数的操作正好相反:

  • 析构函数负责释放被对象使用的资源
  • 析构函数负责摧毁对象的非静态成员

析构函数没有参数,也不可以被重载。一个类只能有一个析构函数。其写法如下:

~DSTR();

析构函数的作用

析构函数的作用分两部分诠释:

  • 函数体:首先执行。函数体属于程序最后执行的收尾内容,由类设计者自行设计。通常情况下用于释放动态分配的资源。
  • 析构部分destruction part):这部分负责类成员的销毁。销毁过程会以成员的构造顺序为参照,反向进行销毁。

析构的部分是隐式的进行的。由于 bulit-in type 无需析构,因此该部分主要用于销毁类型为 class type 的成员。销毁的过程通过调用该成员自身的析构函数进行。

析构部分不会对普通指针类型成员指向的对象进行释放。该部分必须手动释放(通常放到函数体中处理)。

析构函数什么时候被调用
  • 变量离开作用域时
  • 对象被销毁会调用析构函数,使得其成员一并被销毁
  • 容器被销毁时会调用析构函数,使其元素一并被销毁
  • 使用 delete 释放动态分配的对象时(delete 其对应指针)
  • 创建临时对象的表达式结束时

下面是一些详细的例子:

{ // new scope
    // p and p2 point to dynamically allocated objects
    Sales_data  *p = new Sales_data;     // p is a built-in pointer
    auto p2 = make_shared<Sales_data>(); // p2 is a shared_ptr
    Sales_data item(*p);     // copy constructor copies *p into item
    vector<Sales_data> vec;  // local object
    vec.push_back(*p2);      // copies the object to which p2 points
    delete p;                // destructor called on the object pointed to by p
} // exit local scope; destructor called on item, p2, and vec
  // destroying p2 decrements its use count; if the count goes to 0, the object is freed
  // destroying vec destroys the elements in vec
上述的所有的对象都包含了一个动态分配的 string 成员,但只有 p 这个直接进行动态内存申请的对象需要手动释放。这是因为:

  • itemvec 在销毁的过程中调用了自身的析构函数,该析构函数负责销毁了其所有的成员。
  • p2 是 shared_ptr,在引用计数归零时会自动 delete。

指针和引用对象离开作用域时不会调用析构函数。

合成析构函数

当我们没有自定义析构函数的时候,编译器会提供一个合成析构函数synthesized destructor)。合成析构函数的函数体为空:

class Sales_data {
public:
   // no work to do other than destroying the members, which happens automatically
    ~Sales_data() { }
   // other members as before
};
由于函数体为空,合成析构函数只会执行析构的部分,也就是调用类成员自身的析构函数对其销毁并释放。

函数体中的内容与析构的部分是分开的,属于额外的操作。析构的部分通过 member-wise 的方式来执行成员的销毁与释放。

三 / 五规则

三规则The Rule of Three)指控制拷贝行为的三种操作:拷贝构造函数,拷贝赋值运算符和析构函数(Big three)应该以绑定的形式出现,也就是:

  • 考虑某个类是否需要 copy-control 成员的时候,需要首先考虑该类是否需要析构函数
  • 如果某个类需要拷贝构造函数,绝大部分情况下该类也需要拷贝赋值运算符

该规则在 C++ 11 中被延伸到了 “五”,因为移动构造函数与移动赋值运算符也加入到了 copy-control 成员中。

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

通常情况下,类对析构函数的要求更明显。如果析构函数是必要的,那么拷贝构造函数与拷贝赋值运算符也是必要的。

比如我们之前的习题例子 HasPtr,由于该类在构造函数中有分配动态内存,因此需要在析构函数中自定义释放。来看看下面的实现,以及不提供配套的其他两样会造成什么效果:

class HasPtr {
public:
    HasPtr(const std::string &s = std::string()):
        ps(new std::string(s)), i(0) { }
    ~HasPtr() { delete ps; }
    // WRONG: HasPtr needs a copy constructor and copy-assignment operator
    // other members as before
};
如果使用合成版本的构造函数
HasPtr f(HasPtr hp)  // HasPtr passed by value, so it is copied
{
    HasPtr ret = hp; // copies the given HasPtr
    // process ret
    return ret;      // ret and hp are destroyed
}
很明显,合成构造函数并不能处理拷贝初始化的需求。由于 ps 成员是指针,此时 rethp 实际上指向的是同一个对象。当 f() 返回, ret 会被首先销毁;此时自定义的析构函数会使用 delete ret。由于 hp 也是局部变量,因此也会被销毁;此时自定义的析构函数会再次被调用,并使用 delete hp。由于 rethp 指向同一份内存,因此上述的程序实际上对同一片内存进行了双重释放,结果是未定义的。

不仅如此,即便不考虑 f 内部的 ret,指向 Hasptr 对象的指针 p 也能作为参数传递给 f()p 指向的资源会在 f 调用完毕时被释放掉。而接下来是可以利用 p 进行构造新的对象的;但此时无论是 p 还是 q,都是悬挂指针。
HasPtr p("some values");
f(p);        // when f completes, the memory to which p.ps points is freed
HasPtr q(p); // now both p and q point to invalid memory!

需要定义拷贝的类也需要赋值,反过来也一样

一个例子:假设我们有个产品类;其他的成员都相同,唯独序列号不同。可知:

  • 独特的序列号需要通过自定义的拷贝构造函数生成
  • 拷贝构造的过程中需要自定义拷贝赋值运算符避免拷贝到独特的序列号

可见拷贝构造函数与拷贝赋值运算符是一定会配套出现的。

需要拷贝构造函数与拷贝赋值运算符的地方不一定需要析构函数。

使用 = default

C++ 11 中允许我们使用 = default 来显式告诉编译器生成对应函数的合成版本:

// copy control; use defaults
    Sales_data() = default;
    Sales_data(const Sales_data&) = default;
    Sales_data& operator=(const Sales_data &);
    ~Sales_data() = default;
需要注意的是,在类中使用 = default 的函数都隐含了 inline 的意思。如果不希望合成的函数是 inline,可以对该函数进行类外定义,并在类外定义的时候使用 = default

= default 只能应用于有合成版本的成员函数。

阻止拷贝

大多数类都(显式或隐式)的定义了拷贝三大件。但有些情况下也需要禁止其使用,比如某些无法被拷贝的类( iostream 类)。由于编译器会自动的为我们合成这三个函数,我们需要显式的禁止这些函数的使用。

将函数定义为 delete

C++11 中提供了一种函数的种类:被删除的函数deleted function)。该类函数只能被声明,而不能用于其他地方。我们通过 = delete 的形式来声明 deleted 函数。

truct NoCopy {
    NoCopy() = default;    // use the synthesized default constructor
    NoCopy(const NoCopy&) = delete;            // no copy
    NoCopy &operator=(const NoCopy&) = delete; // no assignment
    ~NoCopy() = default;   // use the synthesized destructor
    // other members
};

= deleted 与 = default 的区别
  • =deleted 必须出现在第一次声明中,因为编译器需要第一时间知道哪些函数需要被禁止使用;而 =default 可以出现在任意位置的声明中,因为其只影响编译器生成的结果。
  • = deleted 可以应用于任意函数=deleted 不仅适用于 copy-control members, 也适用于引导函数的匹配。而 =default 只能应用于可以被合成的默认构造函数和拷贝控制成员。
析构函数不能是被删除函数

析构函数不能是被删除函数。如果析构函数被删除,那么

  • 对应的类对象将无法被删除
  • 该类型的变量或者临时对象将无法被定义:
    • 成员中拥有被删除的析构函数也将导致该结果
    • 且成员自身无法被销毁会导致该类型对象无法被销毁

需要注意的是,动态分配对象的创建不受被删除的析构函数的限制,但是释放该对象受限制:

struct NoDtor {
    NoDtor() =  default;  // use the synthesized default constructor
    ~NoDtor() = delete;  // we can't destroy objects of type NoDtor
};
NoDtor nd;  // error: NoDtor destructor is deleted
NoDtor *p = new NoDtor();   // ok: but we can't delete p
delete p; // error: NoDtor destructor is deleted

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

在如下情形中,合成拷贝控制成员会被定义为被删除的函数:

  • 如果类成员的析构函数是被删除的(不可访问的),则类自身的合成析构函数是被删除的
  • 如果类成员的拷贝构造函数或析构函数是被删除的(不可访问的),则类自身的合成拷贝构造函数是被删除的
  • 如果类成员的拷贝赋值运算符是被删除的(不可访问的),或类中有 const / 引用成员,则类自身的拷贝赋值运算符是被删除的
  • 如果类成员的析构函数是被删除的(不可访问的),或者
    • 拥有不能进行类内初始化的引用成员
    • 拥有 const 成员,该成员无法进行类内初始化,且未显式的定义默认构造函数

那么该类的默认构造函数是被删除的。

这样做是基于逻辑上的原因:

  • 被删除的析构函数会导致拷贝三大件被删除,这是为了防止被创建的对象无法被删除
  • 引用成员和 const 成员无法被默认初始化,隐含的意思则是默认构造函数无法使用
  • 类中有 const 成员代表该类无法被赋值,因此拷贝赋值运算符无法使用
  • 对于带有引用成员的类,将右边的引用赋值给左边的引用成员的结果是,该成员引用的对象永远不变。这与我们期望的拷贝赋值运算的结果不符,因此将拷贝赋值运算符定义为被删除。

总的来说,如果某个类的类成员不能被默认初始化,拷贝,赋值,销毁,则其对应类的拷贝控制成员则为被删除的

private 与拷贝控制

= delete 提出之前,C++ 通过将拷贝构造函数与拷贝赋值运算符定义为私有成员防止拷贝:

class PrivateCopy {
    // no access specifier; following members are private by default; see § 7.2 (p. 268)
    // copy control is private and so is inaccessible to ordinary user code
    PrivateCopy(const PrivateCopy&);
    PrivateCopy &operator=(const PrivateCopy&);
    // other members
public:
    PrivateCopy() = default; // use the synthesized default constructor
    ~PrivateCopy(); // users can define objects of this type but not copy them
};
这个结构中:

  • 析构函数是公有成员,因此可以正常创建类对象
  • 其他的拷贝控制成员是私有的,因此不能进行类拷贝赋值操作
  • 只声明而不定义这些拷贝控制成员,是为了防止友元函数以及类成员对其的访问

这种实现方式中,声明但不定义某个成员是合法的。这样的声明会有以下的效果:

  • 任何对未定义成员的访问都会导致链接期的错误(防止成员函数以及友元函数的访问)
  • 任何对类的拷贝与赋值都会导致编译器的错误

推荐使用新标准 = delete 来完成该类需求。

拷贝控制与资源管理

某些获取了动态资源的类(通常带指针)需要通过析构函数来进行资源释放。根据三/五规则,我们需要同时定义其他的拷贝控制成员。根据拷贝成员的定义方式,类会呈现出两种行为方式:

  • 类的行为像一个值,即类拷贝的数据与原来的类相互独立,比如标准库 string
  • 类的行为像一个指针,即类拷贝的数据与原来的类共享,比如 shared_ptr

某些不能拷贝的类,比如 uniuqe_ptr 和 stream 类,都不属于这两种类。

行为像值的类(值类)

当一个类的行为与值相同时,其拷贝控制成员需要保证每个类对象都有一份独立的资源。比如之前提到 HasPtr

  • 其拷贝构造函数需要拷贝整个 string,而不仅仅是指针
  • 其拷贝赋值运算符需要将新的 string 拷贝进来,因此需要首先释放旧的内容,再添加新的内容。
  • 由于涉及动态资源,析构函数中需要定义相关的释放操作。



实现如下:
class HasPtr {
public:
    HasPtr(const std::string &s = std::string()):
        ps(new std::string(s)), i(0) { }
    // each HasPtr has its own copy of the string to which ps points
    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;
};

值类的拷贝赋值运算符

值类中的拷贝赋值运算符的功能是将 = 右边对象中的内容拷贝到左边对象中。与拷贝构造函数不同的是,左边的对象中很可能已经分配了资源,并存在着数据。因此,拷贝赋值运算符的运行逻辑应该如下:

  1. 将左边对象中的资源释放掉(也就是清空)
  2. 申请新的资源,并将右边对象中的资源拷贝到左边对象中

鉴于这样的运行顺序,我们可以写出一个实现版本:

HasPtr&
HasPtr::operator=(const HasPtr &rhs)
{
    delete ps;   // frees the string to which this object points
    // if rhs and *this are the same object, we're copying from deleted memory!
    ps = new string(*(rhs.ps));
    i = rhs.i;
    return *this;
}
但这样的实现有一个很严重的问题。如果我们使用该运算符进行对象的自我赋值的话:

  • delete ps 释放了 ps 指向的内存
  • 由于是自我赋值,rhs 同样也指向这该片内存,因此 rhs 此时称为了悬挂指针
  • 之后再使用 ps = new string(*(rhs.ps)),会使 ps 指向一片无效的内存

因此,我们会得到一个未定义的结果。

解决这个问题的办法是,先分配空间进行拷贝,再释放掉原有的空间:

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
    auto newp = new string(*rhs.ps);   // copy the underlying string
    delete ps;       // free the old memory
    ps = newp;       // copy data from rhs into this object
    i = rhs.i;
    return *this;    // return this object
}
上述代码可以很好的处理自我赋值的问题。我们通过创建新的空间进行拷贝,避免了释放原有空间导致指针失效的问题。

可以看出来的是,设计拷贝赋值运算符有几个要点:

  • 拷贝赋值运算符需要考虑自我赋值的情况
  • 拷贝赋值运算符通常是析构函数拷贝构造函数的结合体。
  • 实现拷贝赋值运算符的时候,首先要考虑的是将需要拷贝的内容存储到新的空间中。

行为像指针的类(指针类)

与值类不同,指针类的拷贝意味着共享;因此,拷贝成员的操作对象应该是指向资源的指针,而不是资源本身。当前最好的办法是使用 shared_ptr 代为管理资源,拷贝工作只要基于 shared_ptr 实现即可。如果需要直接管理,那么保险的解决方案是仿照 shared_ptr 的原理来进行资源管理,也就是说,使用引用计数

引入引用计数

根据 shared_ptr 的原理,我们应该对类中的成员做如下设计:

  • 所有普通的构造函数的默认计数都是 1;也就是说,一个对象创建时,自身计数为 1
  • 拷贝构造函数会使默认计数 1
  • 析构函数会使默认计数 1,当计数为 0 的时候对资源进行释放
  • 拷贝赋值运算符:
    • 由于是将右边的对象拷贝到左边,因此右边的对象需要加 1 的计数;左边的对象被覆盖,因此需要减 1 的计数。
    • 当左边的对象计数为 0 时,意味着该对象没有别的使用者,此时释放该对象。
引用计数实现方式

由于引用计数需要在所有的拷贝中共享,因此引用计数的存储需要放置到动态内存中。引用计数的分配与创建对象同时进行,而拷贝的时候只需要拷贝引用计数的指针即可实现当前计数的共享。

指针类的实现

还是以之前的 HasPtr 作为例子。首先是整个类的实现:

class HasPtr {
public:
    // constructor allocates a new string and a new counter, which it sets to 1
    HasPtr(const std::string &s = std::string()):
      ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
    // copy constructor copies all three data members and increments the counter
    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;   // member to keep track of how many objects share *ps
};
这里 use 作为计数成员加入了类中,而拷贝构造函数则添加了计数加一的功能。

然后是析构函数;由于采用了计数的设计,因此析构函数需要在当前计数为 0,也就是对象没有用户的情况下才能进行资源的释放:
HasPtr::~HasPtr()
{
    if (--*use == 0) {    // if the reference count goes to 0
        delete ps;        // delete the string
        delete use;       // and the counter
    }
}
接下来是拷贝赋值运算符。根据之前的设计,拷贝赋值运算符需要同时对右边的对象进行计数自增,并且对左边的对象进行计数自减,并在左边对象计数为 0 时释放该资源:
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
    ++*rhs.use;  // increment the use count of the right-hand operand
    if (--*use == 0) {  // then decrement this object's counter
        delete ps;      // if no other users
        delete use;     // free this object's allocated members
    }
    ps = rhs.ps;        // copy data from rhs into this object
    i = rhs.i;
    use = rhs.use;
    return *this;       // return this object
}

Swap

如果类会使用到包含排序的算法,那么定义一个交换函数 swap() 是非常重要的(比如快速排序就会用到交换的手法)。

标准库中已经定义了 std::swap() 函数。如果类中没有定义自己的 swap(),则排序算法会默认使用标准库的版本

默认情况下,swap() 可以通过值类的拷贝方式实现:

HasPtr temp = v1; // make a temporary copy of the value of v1
v1 = v2;          // assign the value of v2 to v1
v2 = temp;        // assign the saved value of v1 to v2
不过,这样做开销很大。上例中, 单单 v1 中的 string 被拷贝了两次:从 v1temp,再从 tempv2。实际上,通过交换指针来代替交换值,会极大的提高交换的效率:
string *temp = v1.ps; // make a temporary copy of the pointer in v1.ps
v1.ps = v2.ps;        // assign the pointer in v2.ps to v1.ps
v2.ps = temp;         // assign the saved pointer in v1.ps to v2.ps

外部 swap 的实现

HasPtr 类为例子,自定义的 swap() 的实现如下:

class HasPtr {
    friend void swap(HasPtr&, HasPtr&);
    // other members as in § 13.2.1 (p. 511)
};
inline
void swap(HasPtr &lhs, HasPtr &rhs)
{
    using std::swap;
    swap(lhs.ps, rhs.ps); // swap the pointers, not the string data
    swap(lhs.i, rhs.i);   // swap the int members
}
有两点要注意:

  • 由于需要交换私有成员,因此要将 swap() 声明为友元函数
  • swap() 在此的作用是优化代码,因此声明为 inline

优先调用类成员 swap

上面的例子中,我们在外部的 swap() 中调用了另外的 swap() 实现了交换。但需要注意的是,外部 swap() 函数中调用的 swap() 实际上是 std::swap()。这是由于我们交换的成员是 built-in type, 而类中并没有处理对应类型的成员 swap()

如果我们在类中实现了针对类成员类型的 swap(),则应该避免显式的使用 std::swap。这是因为 std::swap 通常不会针对特殊的类型进行优化(比如使用 std::swap 交换 string,交换的过程是直接拷贝 string)。

下面是一个比较好的写法:

void swap(Foo &lhs, Foo &rhs)
{
    using std::swap;
    swap(lhs.h, rhs.h); // uses the HasPtr version of swap
    // swap other members of type Foo
}
这种写法下,如果类中存在着更匹配的 swap(),则该版本会被优先选择。比如我们在 HasPtr 类中定义了针对 string h 的指针交换版本,上面的 swap() 调用会优先选择我们定义的版本。而对于其他的类型,如果类中没有对应的 swap(),由于存在 std::swapusing 声明,这些类型的交换会调用 std::swap

上面的例子中,using std::swap 并不会隐藏类成员 swap() 的声明。

赋值运算符 与 swap

赋值运算符有另外一个版本:该版本结合 swap 来定义赋值。这种技术被称为 Copy and Swap,也就是将左边算子的内容与右边算子内容的拷贝进行交换:

// note rhs is passed by value, which means the HasPtr copy constructor
// copies the string in the right-hand operand into rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
    // swap the contents of the left-hand operand with the local variable rhs
    swap(*this, rhs); // rhs now points to the memory this object had used
    return *this;     // rhs is destroyed, which deletes the pointer in rhs
}
这个版本的赋值运算符的实现逻辑如下:

  • 首先以拷贝的形式传递对象 rhs,由于 rhs 的 string 成员 ps 是存储在动态内存里的,因此使用值传递相当于为 rhs.ps 分配了新的动态内存。
  • 其次我们使用 swap 对左边的对象(也就是 *this)和 rhs 进行交换。交换完毕之后,rhs.ps 指向了 *this 中的 string,而 this.ps 指向了 rhs.ps 的 string。
  • 当 swap 完成之后,由于 rhs 是传递到赋值运算函数中的临时对象,因此会调用析构函数进行内存释放。由于此时 rhs.ps 指向了之前左边对象的 string,因此通过 rhs.ps 释放操作实际上就是释放了之前左边的对象。
  • 释放完毕之后,赋值操作完成。



这种实现实际上也能处理自我赋值:对象的自我赋值实际上实在与自己的拷贝做交换。

实例:拷贝控制类

实例需求分析

本实例需要实现邮件投递的功能。这种投递的关系是多对多的,比如一个文件夹可以有多封不同的邮件,而一封邮件可以投递到多个不同的文件夹中。下面是一个简单的两封邮件与两个文件夹多对多对应的关系图:



清楚结构以后,我们还需要对整个系统设计一些功能:

  • 邮件存储 / 删除功能:将邮件存储到目标文进夹中,或是从目标文件夹中删除
  • 邮件与文件夹的拷贝功能
  • 邮件与文件夹的赋值功能:赋值后左边的邮件/文件夹将与右边的邮件/文件夹的内容和位置一致
  • 邮件之间的交换功能

上述的需求可以简单的分析一下:

  • 首先,投递的关系可以用指针与 set 的组合来实现。如果我们将指向的关系视作为邮件与文件夹之间的位置关系,那么邮件在文件夹中的位置(或是文件夹中有哪些邮件)则可以使用元素为指针的 set 来实现。可见的是,邮件与文件夹都需要有自己的 set 来维护相互的关系。
  • 其次,邮件的存储/删除功能可以视作单个邮件与文件夹建立/断开联系的过程,因此可以通过互相添加 / 删除指针的过程来实现。
  • 再次,针对类中的拷贝成员,由于拷贝 / 析构意味着建立 / 释放了一个对象,该对象在整个邮件和文件夹的关系网中的位置信息应该及时的更新。因此,拷贝成员需要附带更新对应对象位置的操作。
  • 最后,针对交换功能的位置更新,根据 copy-swap idiom 的流程,交换功能需要预先清理旧的关系网,再使用新对象中的关系信息建立新的关系网。

Message 类的实现

Message 类用于实现邮件。大致的实现如下:

class Message {
    friend class Folder;
public:
    // folders is implicitly initialized to the empty set
    explicit Message(const std::string &str = ""):
        contents(str) { }
    // copy control to manage pointers to this Message
    Message(const Message&);            // copy constructor
    Message& operator=(const Message&); // copy assignment
    ~Message();                         // destructor
    // add/remove this Message from the specified Folder's set of messages
    void save(Folder&);
    void remove(Folder&);
private:
    std::string contents;      // actual message text
    std::set<Folder*> folders; // Folders that have this Message
    // utility functions used by copy constructor, assignment, and destructor
    // add this Message to the Folders that point to the parameter
    void add_to_Folders(const Message&);
    // remove this Message from every Folder in folders
    void remove_from_Folders();
};
Message 中包含了两个成员:

  • contents:用于存储邮件的内容
  • folders: 存储了一系列指向 Folder 对象的指针的 set。该 set 用于表示当前邮件处于哪些文件夹内。
save / remove 成员函数

saveremove 实现的是邮件的存储于删除功能。按照之前的分析,存储于删除的过程实际上就是邮件与文件夹建立相互的,一一对应的过程。实现上,则是双方互相添加 / 删除对方的指针:



假设文件夹类 Folder 中使用类型为 set<Message*> 的 set msgs 来存储对应邮件的信息,那么:
void Message::save(Folder &f)
{
    folders.insert(&f); // add the given Folder to our list of Folders
    f.msgs.insert(this); // add this Message to f's set of Messages
}
void Message::remove(Folder &f)
{
    folders.erase(&f); // take the given Folder out of our list of Folders
    f.msg.earse(this);    // remove this Message to f's set of Messages
}
上述的过程可以通过定义私有成员 addMsg(), remMsg()addFdr()remFdr() 进行简化:
/* In Message class*/
void addFdr(Folder *f) { folder.insert(f); }  // equivalent to folder.insert(this);
void remFdr(Folder *f) { folder.insert(f); }  // equivalent to msg.earse(this);
/* In Folder class */
void addMsg(Message *m) { msgs.insert(m); }  // equivalent to msgs.insert(this);
void remMsg(Message *m) { msgs.insert(m); }  // equivalent to msg.earse(this);
虽然 save()remove() 成员需要同时建立双边的关系,但从字面上是对邮件的操作,因此定义于 Message() 类中即可。至于实现指针添加删除功能的私有成员,则定义于对应指针 set 的所在类中。比如 Message 类中的 set 管理的是 Folder*,则对 Folder* 操作的 addFdr() 成员应该在 Folder 类的内部实现。

方便实现拷贝控制的函数

根据之前的分析,所有的拷贝控制成员都需要批量的的更新某个添加或者移除的类对象。比如我拷贝了一份邮件,那么被拷贝的邮件之前在哪些文件夹,该邮件也应该在;文件夹同理:拷贝后的文件夹应该拥有拷贝前的文件夹一样的文件。因此,设计批量建立关系的成员函数可以让关系更新工作更加方便。

Message 类为例。之间我们为单个邮件关联到目标文件夹的功能设计了私有函数 addMsg()remMsg()。这对函数在 Folder 类中实现,会接收一个 Message*,并存储到 Folder 的指针 set 中。我们可以通过以下步骤来实现遍历当前所有文件夹,并依次添加该指针到对应的 Folder 对象中:

  • 遍历当前 Message 对象中的 folders 成员,获得邮件所在的(当前)文件夹的指针
  • 使用该指针访问 addMsg() 函数,添加当前邮件(Message 对象的指针)到对应的文件夹 (Folder 类 set) 中



实现如下:
// add this Message to Folders that point to m
void Message::add_to_Folders(const Message &m)
{
    for (auto f : m.folders) // for each Folder that holds m
        f->addMsg(this); // add a pointer to this Message to that Folder
}
同理,批量删除邮件关系的成员函数也可以以相同的方式实现:
void Message::remove_from_Folders()
{
    for (auto f : folders) // for each pointer in folders
        f->remMsg(this);   // remove this Message from that Folder
    folders.clear();       // no Folder points to this Message
}

需要注意上述最后一行,对 folders 进行了清空操作。该操作逻辑上是很清晰的,一个即将被销毁的邮件不但需要自身从文件夹中移除,也需要清理自身与对应文件夹的关系。但该操作会对拷贝赋值运算符的实现产生很大的影响,之后我们会看见。

Message 类的拷贝构造函数

通过之前的分析,Message 类的拷贝构造函数只需要做两件事:

  • 拷贝邮件的内容,以及之前邮件所在文件夹的信息
  • 将当前邮件作为新的邮件,注册到 folders 包含的文件夹中

这里我们使用之前定义的 add_to_Folders 函数进行批量的邮件注册:

Message::Message(const Message &m):
    contents(m.contents), folders(m.folders)
{
    add_to_Folders(m); // add this Message to the Folders that point to m
}

Message 类的析构函数

析构函数 ~Message() 除了对邮件对象的内容进行销毁,还需要从当前文件夹中挨个把该邮件的信息清理掉。这里我们使用之前定义的 remove_from_Folders() 进行清理:

Message::~Message()
{
    remove_from_Folders();
}

Message 类的拷贝赋值运算符

由于 Message 中,类的成员都是自动存储对象,因此无需像在堆上的对象一样申请一块临时空间进行先行存放。但拷贝赋值的过程中,我们需要对当前的对象进行类似的关系更新。按照 copy-assignment 的 idiom,应该先将旧的邮件从各个文件夹中删除,然后使用新的邮件内容对其进行覆盖,最后在注册新的邮件到各个文件夹:

Message& Message::operator=(const Message &rhs)
{
    // handle self-assignment by removing pointers before inserting them
    remove_from_Folders();   // update existing Folders
    contents = rhs.contents; // copy message contents from rhs
    folders = rhs.folders;   // copy Folder pointers from rhs
    add_to_Folders(rhs);     // add this Message to those Folders
    return *this;
}
需要注意的是,书上声称这样的顺序解决了自我赋值的问题,但实际上,由于 folder.clear() 的存在,自我赋值依然会出现严重的问题。设想一下如果 thisrhs 是同一个地址,则 remove_from_Folders(); 上来就会将 rhs 中的文件夹信息全部清空;这会导致我们失去之后注册该邮件到新文件夹的所有信息。

比较好的解决方法是直接判断当前是否存在自我赋值,如果是的话则直接返回当前对象:
Message& Message::operator=(const Message &rhs)
{
    // handle self-assignment 
    if(this == &rhs) 
        return *this;
        
    remove_from_Folders();   // update existing Folders
    contents = rhs.contents; // copy message contents from rhs
    folders = rhs.folders;   // copy Folder pointers from rhs
    add_to_Folders(rhs);     // add this Message to those Folders
    return *this;
}

用于 Message 的 swap()

根据之前的分析,swap() 函数需要做四件事:

  • 拷贝一个 Message 类对象的副本作为交换对象
  • 将被交换对象与交换对象在文件夹中的信息全部清空
  • 交换当前对象的内容与其对应的文件夹信息
  • 根据交换后的文件夹信息更新各自在文件夹中的位置

也就是说,交换邮件,不但交换了邮件的内容,也交换了邮件所处的文件夹位置

void swap(Message &lhs, Message &rhs)
{
    using std::swap; // not strictly needed in this case, but good habit
    // remove pointers to each Message from their (original) respective Folders
    for (auto f: lhs.folders)
        f->remMsg(&lhs);
    for (auto f: rhs.folders)
        f->remMsg(&rhs);
    // swap the contents and Folder pointer sets
    swap(lhs.folders, rhs.folders);     // uses swap(set&, set&)
    swap(lhs.contents, rhs.contents);   // swap(string&, string&)
    // add pointers to each Message to their (new) respective Folders
    for (auto f: lhs.folders)
        f->addMsg(&lhs);
    for (auto f: rhs.folders)
        f->addMsg(&rhs);
}
除此之外,swap() 需要同时成为 Message 类与 Folder 类的友元函数,因为其实现同时使用了两个类的成员函数。

Folder 类的一些延伸

Folder 类实际上与 Message 类基本上相同,唯一的不同有两点:

  • Folder 类不需要存储内容
  • Folder 类不需要实现存储与删除功能(逻辑上是从文件夹中删除文件,而不是反着来)

实例:动态内存管理类

本节的实例的内容是实现一个元素为 string 的 vector: StrVec 类。该类与 vector 的功能基本一致。大致的设计如下:

  • 数据成员:
    • 指针 element,指向 StrVec 的头部位置
    • 指针 first_free, 指向 StrVec 当前元素的 off-the-end 位置
    • 指针 cap,指向 StrVec 当前已分配内存的 off-the-end 位置
    • allocator 对象 alloc,存储 StrVec 的数据。该成员为 static 类成员。



  • 私有功能函数:
    • alloc_n_copy:分配空间,并将指定范围的元素拷贝到当前空间。
    • reallocate:当 StrVec 对象空间不足时,重新分配空间的策略。
    • chk_n_alloc:检查当前空间是否足够,不够则调用 reallocate() 函数获取更多空间。

除此之外还会一些常用的功能函数。

StrVec 类的定义

一个简单的 StrVec 类的定义如下:

// simplified implementation of the memory allocation strategy for a vector-like class
class StrVec {

public:

    StrVec(): // the allocator member is default initialized
      elements(nullptr), first_free(nullptr), cap(nullptr) { }
    StrVec(const StrVec&);            // copy constructor
    StrVec &operator=(const StrVec&); // copy assignment
    ~StrVec();                        // destructor
    void push_back(const std::string&);  // copy the element
    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; // allocates the elements
    void chk_n_alloc()     // used by functions that add elements to a StrVec
        { if (size() == capacity()) reallocate(); }

    // utilities used by the copy constructor, assignment operator, and destructor
    std::pair<std::string*, std::string*> alloc_n_copy
        (const std::string*, const std::string*);
    void free();             // destroy the elements and free the space
    void reallocate();       // get more space and copy the existing elements
    std::string *elements;   // pointer to the first element in the array
    std::string *first_free; // pointer to the first free element in the array
    std::string *cap;        // pointer to one past the end of the array

};

// alloc must be defined in the StrVec implmentation file
allocator<string> StrVec::alloc;

拷贝控制成员的设计

alloc_n_copy() 私有函数

alloc_n_copy() 函数的目标是完成两个功能:分配一块新的空间,并将指定的内容拷贝过来。设计上,该函数会接收表示被拷贝内容的头尾指针,并返回新创建内容的头尾指针。该函数的逻辑如下:

  1. 计算被拷贝内容需要多少空间,并分配足够的空间
  2. 将被拷贝的内容复制到新分配的空间中,并返回该内容的头指针与 off-the-end 指针

由于我们需要同时返回两个指针,因此需要使用 std::pair 类型作为返回类型。整个函数的实现代码如下:

pair<string*, string*>
StrVec::alloc_n_copy(const string *b, const string *e)
{
    // allocate space to hold as many elements as are in the range
    auto data = alloc.allocate(e - b);
    // initialize and return a pair constructed from data and
    // the value returned by uninitialized_copy
    return {data, uninitialized_copy(b, e, data)};
}
两个要点:

  • e - b 确定了被拷贝内容需要的空间长度
  • uninitialized_copy 会返回被拷贝元素的 off-the-end 指针
StrVec 拷贝构造函数

拷贝构造函数的逻辑与 alloc_n_copy() 的实现是完全一致的;因此只需要调用该函数,并使用其返回值更新指针内容即可:

StrVec::StrVec(const StrVec &s)
{
    // call alloc_n_copy to allocate exactly as many elements as in s
    auto newdata = alloc_n_copy(s.begin(), s.end());
    elements = newdata.first;
    first_free = cap = newdata.second;
}

free() 私有函数

free() 函数用于清理 alloc 分配的空间。根据 allocator 的清理顺序,free() 函数的逻辑如下:

  1. StrVec 中已有的成员依次摧毁
  2. alloc 分配的空间(capacity,不是使用的空间)进行释放。

需要注意的是,销毁的顺序与构造的顺序是相反的;因此需要从 off-the-end 的位置开始销毁,到头元素的位置结束。实现代码:

void StrVec::free()
{
    // may not pass deallocate a 0 pointer; if elements is 0, there's no work to do
    if (elements) {
        // destroy the old elements in reverse order
        for (auto p = first_free; p != elements; /* empty */)
            alloc.destroy(--p);
        alloc.deallocate(elements, cap - elements);
    }
}
如果使用算法 for_each(),可以在不需要考虑顺序的情况下对已有元素进行销毁:
for_each(element, first_free,
		[](cosnt string &s)
		{ alloc.destroy(&s); };

两个要点:

  • 由于从 off-the-end 指针开始,因此需要使用 –p 先往左移动一位,在进行元素的销毁。
  • deallocate 必须使用之前 allocate 使用过的指针 进行内存空间的释放;因此要求检测参数指针是否为空
StrVec 析构函数

析构函数只需要对 alloc 成员进行释放,因此直接调用 free() 函数即可。

StrVec::~StrVec() { free(); }

StrVec 拷贝赋值运算符

根据拷贝赋值运算 的 idiom,可知拷贝赋值运算符通过组合使用 alloc_n_copy()free() 即可实现。其中:

  • alloc_n_copy 负责申请空间并拷贝传递的 StrVec 对象
  • free() 负责释放被赋值的对象

实现如下:

StrVec &StrVec::operator=(const StrVec &rhs)
{
    if(this == &rhs)
		return *this;
    // call alloc_n_copy to allocate exactly as many elements as in rhs
    auto data = alloc_n_copy(rhs.begin(), rhs.end());
    free();
    
    elements = data.first;
    first_free = cap = data.second;
    return *this;
}
当然,只要分配了新的空间,指针是一定要更新的。同时,自我赋值的检测也不能忘记。

Reallocate() 函数 与 Move

reallocate() 函数的主要的功能之一将旧的 StrVec 对象中的 string 拷贝到新分配的空间中。由于 std::string 是值类,该拷贝的过程是拷贝方式,并且会产生两份相同且独立的数据。由于此时只有调用者在使用数据,因此存在两份数据是没有必要的;也就是说,此处的拷贝操作是不必要的。

我们可以通过 “移动” 而不是拷贝的方式来优化该过程的性能。C++11 提供了移动构造函数Moving Constructor)以及其配套的 std::move 函数。与拷贝不同的是,该构造函数会以 “移动” 的方式来处理之前目标拷贝对象与源拷贝对象之间的数据转移问题。这两者被定义于 <utility> 头文件内。

对于 string, 这里的 “移动” 可以想象成使用拷贝 const char* 取代直接拷贝 string 的过程。

需要注意的是:

  • 普通的移动构造函数只会std::move 的参数生效。没有被 std::move 修饰的表达式,会默认调用拷贝构造函数
  • 使用 std::move 的时候,不需要要提供 using。调用 move 会直接调用 std::move
reallocate 私有函数

reallocate() 实现了 StrVec 的空间增长功能。其逻辑如下:

  1. 检查当前 StrVec 的对象大小,如果为 0 则设置为 1,如果不为 0 则按 size() 翻倍,并将翻倍后的值作为新的 capacity。
  2. 按照新的 capacity 分配空间
  3. 以旧(左手边)的 StrVec 对象的 size() 为范围,循环的移动所有的元素
  4. 释放旧对象
  5. 更新指针到新的空间



实现如下:
void StrVec::reallocate()

{
     // we'll allocate space for twice as many elements as the current size
     auto newcapacity = size() ? 2 * size() : 1;
     
     // allocate new memory
     auto newdata = alloc.allocate(newcapacity);
     
     // move the data from the old memory to the new
     auto dest = newdata;  // points to the next free position in the new array
     auto elem = elements; // points to the next element in the old array
     for (size_t i = 0; i != size(); ++i)
         alloc.construct(dest++, std::move(*elem++));
     free();  // free the old space once we've moved the elements

     // update our data structure to point to the new elements
     elements = newdata;
     first_free = dest;
     cap = elements + newcapacity;
}

push_back() 成员

由于 reallocate() 实现了 StrVec 的空间增长功能,我们可以将其用于 push_back() 的空间判断中。鉴于 push_back() 是按位进行空间的申请与构造,我们只需要判断当前 size()capacity() 是否相等来决定是否进行空间增长。我们将这一切全部打包到 chk_n_alloc() 中:

void StrVec::chk_n_alloc()
{
    if (size() == capacity()) 
        reallocate(); 
}
保证了足够空间后,我们只需要在 off-the-end 位置进行新元素构造,即可达到 push_back() 的效果:
void StrVec::push_back(const string& s)
{
    chk_n_alloc(); // ensure that there is room for another element
    // construct a copy of s in the element to which first_free points
    alloc.construct(first_free++, s);
}
这里需要先构造再位移,因此需要使用后置版本的自增。不然 off-the-end 位置的元素将不会被构造。

移动对象

之前在实现 reallocate() 函数时,我们已经使用了 “移动” 代替了拷贝操作。总的来说,在某个对象即将失效(在拷贝后就会失效)时,可以通过使用 “移动” 的方式取代拷贝,来获得更好的性能。

有两种典型的情况可以使用移动:

  • 将对象从旧内存移动到新的内存
  • 转移不可拷贝对象的内容,比如 IO 类或 unique_ptr 管理的内容

C++ 11 之前,并没有提供直接移动的功能。传递很多较大的对象,或是需要分配内存的对象时,都会进行非常多的,但又不必要的拷贝。

右值引用

为了支持移动操作,新标准提供了一种被称为右值引用Rvalue Reference)的新类型引用。右值引用指与 Rvalue 绑定的引用,使用 &&Double Ampersands)进行定义。总的来说,右值引用只能与即将销毁的对象绑定,这也满足哟东操作的使用前提。

左值与右值

为了说明右值引用的功能,这里需要再次回顾一下左值Lvalue)与右值Rvalue)。总的来说:

  • 左值强调的是 identity。通常情况下,左值指的是对象,可以被取地址。
  • 右值强调的是 value。通常情况下,右值指的是临时对象,不能被取地址。

左值引用只能绑定与自身类型相同的对象,不能绑定需要转换类型的对象,literal 和返回右值的表达式。右值引用可以绑定上述的这些表达式,但不能直接绑定左值

int i = 42;
int &r = i;             // ok: r refers to i
int &&rr = i;           // error: cannot bind an rvalue reference to an lvalue
int &r2 = i * 42;       // error: i * 42 is an rvalue
const int &r3 = i * 42; // ok: we can bind a reference to const to an rvalue
int &&rr2 = i * 42;     // ok: bind rr2 to the result of the multiplication
常见的左值与右值有:

  • 左值
    • 变量(变量的生存周期由 scope 决定;很明显是左值)
    • string literal(字符串占用内存)
    • 赋值操作的结果( = 左边的对象)
    • 下标操作的结果
    • 解引用的结果
    • 前置递增 / 递减的结果
  • 右值
    • 除了 string literal 以外的 Literal
    • 函数的非引用返回
    • 算术 / 关系运算的结果
    • 位运算的结果
    • 后置递增 / 递减的结果

需要注意的是,除了常量引用可以绑定右值的例外,左值引用只能绑定左值,而右值引用同理

const &T 是种例外。该引用是左值引用,但可以绑定能转换到 T 类型的右值。一般来说,该类引用绑定的是一个临时对象,临时对象中装载了对应的右值。

右值引用与右值的特性

从生存周期上来说,左值是长期持续的,而右值是临时(短暂)的。由于右值引用只能与右值(临时对象)绑定,因此右值引用指向的是一个将要被销毁的,不能再被重复利用的对象。利用这种性质,我们可以使用接管(或者说 steal)的方式来获取右值引用指向的资源,而不是拷贝。也就是说,右值引用可以使用在任何具有移动意义的数据传递中使用。由于该过程使用接管代替了拷贝,因此效率会大大提升。

变量是左值

变量可以被视作表达式,因此也具有左右值的属性。总的来说,变量是左值:

int &&rr1 = 42;    // ok: literals are rvalues
int &&rr2 = rr1;   // error: the expression rr1 is an lvalue!
可以注意到,上例中,即便是变量被定义为了右值引用,而其自身仍然是一个左值。因此,该变量不能绑定到另外一个右值引用上。

std::move 函数

std::move 函数实际上将指定的参数(通常是左值)转换为了对应的右值引用

int &&rr1 = 42;    // ok: literals are rvalues
int &&rr3 = std::move(rr1);   // ok
注意第一行,rr1 是右值引用;但由于 rr1 是变量,因此是左值。为了继续将其作为右值引用使用,我们使用 std::move 对其进行转换。但这里需要注意,std::move 的一层隐含意思是不再使用 rr1;因此,在调用 std::move 之后,被移动的对象中的值将不再可知。

移动构造与移动赋值

std::move() 只是一个辅助函数。为了获得右值引用带来的性能提升,我们需要定义移动构造函数Move Constructor)与移动赋值运算符(Move-Assignment Operator)。新标准下,这两者与 Big Three 被统称为 Five Rule,被视作 C++ 11 下的拷贝控制标准。

移动构造函数

移动构造函数具有以下特点:

  • 接收参数的类型为自身 class type 的右值引用
  • 额外的参数必须有默认初始值
  • 被移动的对象需要确保能安全销毁,也就是移动后,原有的(被移动的)对象不能再指向被移动的资源,管理全交给了移动后的新对象
  • 移动构造函数不会分配任何新的内存

典型的移动构造函数的操作步骤:

  1. 移动参数对象的内容到局部对象(比如使用当前对象成员的指针指向参数对象的内容)
  2. 解除参数对象对被移动内容的控制(将所有参数对象的指针设为 nullptr

一个 StrVec 的移动构造函数定义如下:

StrVec::StrVec(StrVec &&s) noexcept   // move won't throw any exceptions
  // member initializers take over the resources in s
  : elements(s.elements), first_free(s.first_free), cap(s.cap)
{
    // leave s in a state in which it is safe to run the destructor
    s.elements = s.first_free = s.cap = nullptr;
}

移动操作与异常

由于移动操作并不参与分配空间,理论上来说该操作不应该抛出分配异常。但在实际操作中,我们需要“通知”标准库我们的移动构造函数不会抛出异常,从而避免移动构造函数的异常处理带来的额外开销。

通常,我们使用 noexcept 关键字来指定我们的移动构造函数不会抛出异常;需要注意,如果构造函数存在于类外定义, noexcept 不能省略

StrVec(StrVec&&) noexcept; 
StrVec::StrVec(StrVec &&s) noexcept { /* constructor body   */ }
为什么需要手动的阻止移动构造函数抛出异常?

首先需要明确两点:

  • 虽然从逻辑上来说,移动操作不参与分配内存,也就不会抛出异常,但所有的移动操作都被默认允许抛出异常。
  • 标准库容器有自己解决分配异常的方案

vector 作为例子。如果调用 push_back() 成员时发生异常, vector 的异常管理会将该 vector 对象会回滚到未改变的状态。假设这个过程发生在 reallocate() 的阶段;reallocate() 会新申请一片内存空间,之后会将原有的元素“移动”到该新区域中,并使用新的 vector 对象进行管理。如果此过程中抛出异常,那么:

  • 由于移动操作异常,新创建的 vector 并没有成功获取之前的所有元素
  • 而此时原来的对象已经被释放掉了

当然我们可以通过拷贝而不是移动的方式来确保原来的对象的安全性;但如果希望仍然使用移动操作的话,我们必须确保移动操作不会抛出任何异常。因此,这种情况下必须手动的添加 noexcept 关键字来确保这一点。

移动赋值运算符

移动赋值运算符也是接收一个当前类对象的右值引用,返回当前类类型的引用。其逻辑与拷贝赋值运算符类似,也需要首先清空当前对象:

  • 释放当前(左手边)对象所占资源
  • 接管参数(右手边临时)对象内容
  • 解除(右手边临时)参数对象对内容的控制(空置)

StrVec 的实现:

StrVec &StrVec::operator=(StrVec &&rhs) noexcept

{
    // direct test for self-assignment
    if (this != &rhs) {
        free();                  // free existing elements1
        
        elements = rhs.elements; // take over resources from rhs
        first_free = rhs.first_free;
        cap = rhs.cap;
        
        // leave rhs in a destructible state
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}
如果是自我赋值,则直接返回当前对象。

被移动的对象必须是可以析构的

我们注意到在移动版本的拷贝控制成员中,最后都将被移动对象(右手边)所有的指针设为了 nullptr。这样做是为了保证被移动的对象可以被析构。该做法是基于逻辑上的原因:被移动的对象从逻辑上来说是即将被销毁的,因此必须确保其处于可以被析构的状态。这种状态隐含着两个事实:

  • 即便对象所管理的资源可能被接管了,但该对象的状态依然是有效的,也就是仍然可以被赋予新值,或是任何不依赖当前内容的操作
  • 移动的操作目标是对象,而不是对象管理的资源。因此,任何移动操作都不会依赖于对象的值(管理的资源)

诸如此类的原因,我们需要将被移动的对象空置,保证该对象处于一个 “可以被析构” 的状态;但该对象所管理的资源(值)是不可控的,因此不应该对这些资源做任何假设。以 string 为例,在左边的对象接管了资源后,右边的对象任然可以使用 std::string 的成员函数;但其结果是无法保证的。

合成的移动操作

编译器在某些情况下也会为我们合成移动成员。但该合成的条件与 Big Three 有些不同:

  • 即便是定义了 Big Three ,编译器也不会合成移动成员。这种情况下,所有的移动操作都会使用拷贝成员代替。
  • 只有满足以下两个前提:
    • 类没有定义任何拷贝成员
    • 所有非静态成员都可以被移动,或是定义了的移动操作

编译器才会合成移动拷贝控制成员,比如 :

// the compiler will synthesize the move operations for X and hasX

struct X 
{
    int i;         // built-in types can be moved
    std::string s; // string defines its own move operations
};

struct hasX 
{
    X mem;  // X has synthesized move operations
};

X x, x2 = std::move(x);       // uses the synthesized move constructor
hasX hx, hx2 = std::move(hx); // uses the synthesized move constructor

如何判断合成移动成员是否被删除

默认情况下,移动成员不会被隐式的定义为被删除的函数。只有在以下条件下,才会被定义为被删除的移动成员

  1. 显式的使用 = default 修饰了移动成员,但类中存在无法移动的成员(存在例外,见 p508)
  2. 类中定义了拷贝构造函数,但没有定义移动构造函数
  3. 类中的成员没有定义自身的拷贝操作,且编译器无法为该类合成移动构造函数
  4. 类中的成员拥有被删除的,或无法访问的移动构造函数 / 移动赋值运算符
  5. 类中的析构函数是被删除的,或是无法访问的
  6. 类中有 const 或是引用类型的成员

// assume Y is a class that defines its own copy constructor but not a move constructor
// Y has a deleted move constructor (rule 2)

struct hasY {
    hasY() = default;
    hasY(hasY&&) = default;
    Y mem; // hasY will have a deleted move constructor (rule 4)
};

hasY hy;
hasY hy2 = std::move(hy); // error: move constructor is deleted
除此之外,定义移动成员也对拷贝成员有影响。如果类中定义了任意一个移动成员(移动构造函数或移动赋值运算符),则合成的拷贝成员则是被删除的

定义了移动成员的类也应该定义其拷贝成员。

移动右值,拷贝左值

当类中同时存在移动版本与拷贝版本的拷贝控制成员时,编译器会通过函数匹配来决定构造函数的使用。比如下面的例子:

StrVec v1, v2;
v1 = v2;                  // v2 is an lvalue; copy assignment
StrVec getVec(istream &); // getVec returns an rvalue
v2 = getVec(cin);         // getVec(cin) is an rvalue; move assignment
上例中:

  • v2 是左值,且右值引用无法绑定到左值上,因此选择拷贝构造函数
  • v2 = getVec(cin); ,由于 getVec(cin); 返回一个右值,此处的操作是将右值赋值给一个左值。此时拷贝版本与移动版本都是候选:
    • 如果是拷贝赋值运算,需要将该返回值转换为 reference to const 的形式
    • 如果是移动赋值运算,则是完美匹配。因此此处选择移动赋值运算符。

copy-swap 赋值运算符也使用右值作为参数。如果类中有移动赋值运算符,会导致二义性。该版本通常使用移动 + 拷贝成员替代,后文有介绍。

未定义移动参数的情况

当类中只有拷贝构造函数,而没有移动构造函数时:

  • 类不会合成移动构造函数
  • 右值引用将按值的方式将返回的右值绑定到 reference to const 类型的参数上,因此会调用拷贝构造函数:

class Foo {
public:
    Foo() = default;
    Foo(const Foo&);  // copy constructor
    // other members, but Foo does not define a move constructor
};

Foo x;
Foo y(x);            // copy constructor; x is an lvalue
Foo z(std::move(x)); // copy constructor, because there is no move constructor
上例中完成了 Foo&&const Foo& 的转换。

使用拷贝构造函数取代移动构造函数进行构造是安全的;因为拷贝构造的过程不会改变被拷贝对象的状态;这与移动构造的要求(被拷贝对象是有效的)是一致的。

拷贝交换赋值运算符与移动

如果类中定义了拷贝交换赋值运算符,同时定义移动构造函数也可以很好的提升该运算符的性能,比如下例:

class HasPtr {

public:
    // added move constructor
    HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {p.ps = 0;}
    // assinment operator is both the move- and copy-assignment operator
    HasPtr& operator=(HasPtr rhs)
                   { swap(*this, rhs); return *this; }
    // other members as in § 13.2.1 (p. 511)
};
上例中,我们为 HasPtr 类添加了一个移动构造成员。来看看下面的使用:
hp = hp2; //  hp2 is an lvalue; copy constructor used to copy hp2
hp = std::move(hp2); // move constructor moves hp2
可见,当参数类型是右值时,参数的传递会使用移动构造函数进行构造;当参数是左值时,则会调用拷贝构造函数进行构造。因此该 copy-swap 运算符实际上可以同时接收左值与右值参数,即左值拷贝构造,右值移动构造

本例中的移动构造函数通过 p.ps = 0; 来确保被移动对象处于可以被析构的状态。

推荐使用拷贝赋值运算符 + 移动赋值运算符的重载组合来代替 copy-swap。copy-swap 赋值运算符在将参数传递至内部 swap() 函数的过程中会不可避免的使用一次拷贝,这使得其效率会大大降低。课后题 13.53 为专门针对该问题的讨论。

新版本下的五个拷贝控制成员应该被视为一个整体来设计。如果某个类定义了拷贝操作,则应该定义所有的五个操作。

实例:Message 类的移动成员

Message 类中包含了 string 类型的类容和用于存储文件夹关系的 set,因此实现移动成员可以有效的避免拷贝带来的额外开销。与其拷贝成员一致,移动成员的设计也需要维护当前邮件与文件夹的关系网。我们使用 move_Folders() 私有成员来完成该功能,实现如下:

// move the Folder pointers from m to this Message

void Message::move_Folders(Message *m)
{
    folders = std::move(m->folders); // uses set move assignment
    for (auto f : folders) 
    {  // for each Folder
        f->remMsg(m);    // remove the old Message from the Folder
        f->addMsg(this); // add this Message to that Folder
    }
    m->folders.clear();  // ensure that destroying m is harmless
}
该函数的逻辑为:

  1. 移动参数邮件对象所在的文件夹信息到当前邮件对象
  2. 按文件夹为单位,依次更新当前邮件对象的位置
  3. 清空参数邮件对象的文件夹信息,确保其处于可以被析构的状态

几个要点:

  • 该函数使用了移动的方式来转移 set 成员
  • move_Folders() 属于移动成员功能的一部分。但由于该函数需要通过更新关系 set 中的元素来维护关系网,我们需要允许该过程中存在异常的抛出。因此,该实现中的移动成员不能被声明为 noexcept
  • 该函数在最后使用了 clear() 成员来清空参数邮件对象的关系网

对应的移动构造函数定义如下:

Message::Message(Message &&m): contents(std::move(m.contents))
{
    move_Folders(&m); // moves folders and updates the Folder pointers
}
对应的移动赋值运算符定义如下:
Message& Message::operator=(Message &&rhs)
{
    if (this != &rhs) {       // direct check for self-assignment
        remove_from_Folders(); //destroy the left-hand operand
        contents = std::move(rhs.contents); // move assignment
        move_Folders(&rhs); // reset the Folders to point to this Message
    }
    return *this;
}

移动迭代器

StrVec 类中,我们使用 reallocate() 函数实现 vector 的自我增长。其中,我们使用了 allocator 的成员 uninitialized_copy 将旧的 vector 拷贝到新申请的 vector 中。可以发现的是,该过程也可以用移动操作取代;但问题在于,uninitialized_copy 并没有移动的功能。

比起使用 uninitialized_copy 来实现移动功能,我们可以利用算法的迭代器来实现按位的移动。C++11 标准中提供了移动迭代器Move Iterator)来支持这种操作。对该类型的迭代器的解引用会返回一个右值引用

实现上,我们使用标准库函数 make_move_iterrator() 来将普通迭代器转化为移动迭代器,再将其作为 uninitialized_copy 的参数即可:

void StrVec::reallocate()

{
    // allocate space for twice as many elements as the current size
    auto newcapacity = size() ? 2 * size() : 1;
    auto first = alloc.allocate(newcapacity);

    // move the elements
    auto last = uninitialized_copy(make_move_iterator(begin()),
                                   make_move_iterator(end()),
                                   first);

    free();             // free the old space

    elements = first;   // update the pointers
    first_free = last;
    cap = elements + newcapacity;
}
uninitialized_copy 会按元素进行构造。而在构造时,由于我们使用了移动迭代器,构造得到的参数是右值引用类型;因此 construct 成员会使用移动构造函数进行元素的构造。

需要注意的是,标准库不能保证是否会对对被移动的元素进行复用。在使用算法之前,我们需要确保该算法不会复用被移动的对象

std::move 隐含了参数对象即将消亡的意思。确保该对象在移动后不会被用于其他地方是符合逻辑的,也是必要的。

右值引用与成员函数

除了拷贝成员之外,成员函数也可以通过重载接收右值引用的版本来获得移动的性能提升。设计的方法与拷贝成员类似:

  • 获取左值的版本的参数类型为 const T&
  • 获取右值的版本的参数类型为 T&&

T 为对应的 class type。以 push_back() 为例:

void push_back(const T&); // copy: binds to any kind of T
void push_back(T&&);      // move: binds only to modifiable rvalues of type T
该设计中,任何可以修改的右值参数都可以使用移动构造函数来完成 push_back 的操作;任意 non-const rvalue 对于移动版本的 push_back() 都是最佳匹配。

需要注意的是,我们不需要定义接收 const T&&T& 类型参数的的函数版本:

  • 移动构造函数需要从被移动对象中“获取”资源,因此被移动对象(parameter)必须可以被修改,也就是必须使用 T&&
  • 逻辑上,拷贝构造函数不能修改参数对象。因此需要使用 const T& 类型作为参数
实例:StrVec 类中 push_back 的移动版本

简单的实现如下:

class StrVec
{
public:
	void push_back(const std::string&); //copy construct element
	void push_back(std::string&&); //move construct element
	//....
};

void 
StrVec::push_back(const std::string &s)
{
	chk_n_alloc();
	alloc.construct(first_free++, s);
}

void
StrVec::push_back(std::string &&s)
{
	chk_n_alloc();
	alloc.construct(first_free++, std::move(s));
}
上述两种重载的实现中,唯一不同在于 s 的传递方式不同。以移动方式传递的 s 使用了 std::move 进行修饰。根据以上实现,当使用右值作为 push_back() 的参数时,会以移动的方式构造元素:
StrVec vec;  // empty StrVec
string s = "some string or another";
vec.push_back(s);      // calls push_back(const string&)
vec.push_back("done"); // calls push_back(string&&)

右值与成员函数

早前版本的 C++ 中,成员函数的调用并不会考虑类对象是左值还是右值,比如如下的写法,右值对象既可以调用成员函数,也可以放到等号的左边:

string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');
s1 + s2 = "wow!";
这种用法在 C++11 之前是没有办法禁止的。因此,C++11 中提供了引用限定符 Reference Qualifier,通过强制 this 的指向类型为左值,来避免这样的用法:
class Foo {
public:
    Foo &operator=(const Foo&) &; // may assign only to modifiable lvalues
    // other members of Foo
};

Foo &Foo::operator=(const Foo &rhs) &

{
    // do whatever is needed to assign rhs to this object
    return *this;
}
除此之外,引用限定符也提供了右值的选项,来限定 this 指向的是右值。对右值的修饰时,在参数列表的后面添加 &&。如果需要添加 const,则 const 需要添加到引用限定符与参数列表之间。需要注意的是,引用限定符修饰的对象需要满足以下要求:

  • 引用限定符只能修饰非静态的成员函数
  • 引用限定符必须在函数的声明和定义的时候都出现

class Foo {

public:
    Foo rvalueMem() &&; //may assign only to modifiable rvalues
    Foo someMem() & const;    // error: const qualifier must come first
    Foo anotherMem() const &; // ok: const qualifier comes first

};
根据之前的设定,& 限定符应该只用于限定接受左值参数的函数;而 && 则应该只应用于右值的函数版本:
Foo &retFoo();  // returns a reference; a call to retFoo is an lvalue
Foo retVal();   // returns by value; a call to retVal is an rvalue

Foo i, j;       // i and j are lvalues

i = j;          // ok: i is an lvalue
retFoo() = j;   // ok: retFoo() returns an lvalue
retVal() = j;   // error: retVal() returns an rvalue
i = retVal();   // ok: we can pass an rvalue as the right-hand operand to assignment

基于引用限定符的重载

类成员函数可以根据加载的引用限定符的不同,以及是否为 const 进行重载;比如下例:

class Foo {
public:
    Foo sorted() &&;         // may run on modifiable rvalues
    Foo sorted() const &;    // may run on any kind of Foo
    // other members of Foo

private:
    vector<int> data;
};

// this object is an rvalue, so we can sort in place

Foo Foo::sorted() &&
{
    sort(data.begin(), data.end());
    return *this;
}

// this object is either const or it is an lvalue; either way we can't sort in place

Foo Foo::sorted() const & 
{
    Foo ret(*this);                         // make a copy
    sort(ret.data.begin(), ret.data.end()); // sort the copy
    return ret;                             // return the copy
}
第一个版本的 sorted() 限定为被右值对象调用,因为直接修改右值是安全的,逻辑正确的;而第二个版本的 sorted() 被定义为接收 const T& 类型,根据隐式转换:

  • value 可以转换为 reference to const
  • plain reference 可以转换为 reference to const

这意味着该版本可以接收所有类型的数据。这也很符合逻辑:因为拷贝版本的 sorted() 并不会修改任何原有的数据:

retVal().sorted(); // retVal() is an rvalue, calls Foo::sorted() &&
retFoo().sorted(); // retFoo() is an lvalue, calls Foo::sorted() const &
需要注意的是,如果引用限定符与 const 在某个函数中出现,则所有名称 & 参数列表与该函数相同合法重载版本都必须提供引用限定符。也就是说,以 const 区分带引用限定符的重载版本,都需要带上引用限定符:
class Foo {

public:

    Foo sorted() &&;
    Foo sorted() const; // error: must have reference qualifier

    // Comp is type alias for the function type (see § 6.7 (p. 249))
    // that can be used to compare int values
    using Comp = bool(const int&, const int&);
    Foo sorted(Comp*);        // ok: different parameter list(comparing with Foo sorted() &&)
    Foo sorted(Comp*) const;  // ok: neither version is reference qualified
};
上例中:

  • 参数列表相同的 sorted() 必须有同样的引用限定符
  • 名字相同但参数列表不同的则不用