第04章 C++语言专题(一.04)拷贝控制

声明:仅为个人学习总结,还请批判性查看,如有不同观点,欢迎交流。

摘要

本文整理了拷贝控制成员函数相关知识,即拷贝构造函数、拷贝赋值运算符、析构函数、移动构造函数和移动赋值运算符。具体包括函数定义、使用方式和场景、合成版本和删除的函数等内容。


拷贝控制(copy control)操作,是类的对象在拷贝、移动(C++11)、赋值和销毁时所进行的操作。一个类通过五种特殊的成员函数来控制这些操作,包括:

  • 拷贝构造函数、移动构造函数,定义了当用同类型的另一个对象初始化本对象时做什么;
  • 拷贝赋值运算符、移动赋值运算符,定义了将一个对象赋予同类型的另一个对象时做什么;
  • 析构函数,定义了当此类型对象销毁时做什么。

如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。但是,对于一些类来说,依赖这些操作的默认定义会导致灾难。

1、拷贝、赋值与销毁

1.1 拷贝构造函数

拷贝构造函数(copy constructor),“第一个参数是自身类类型的引用,其它参数(如果有)都有默认值” 的构造函数。

1.1.1 合成拷贝构造函数

合成拷贝构造函数(synthesized copy constructor),如果我们没有为一个类定义拷贝构造函数,编译器会为我们合成一个。一般情况下(非删除的函数),合成的拷贝构造函数会将其参数的每个非 static 成员逐个拷贝到正在创建的对象中。

成员的类型决定了它的拷贝方式:

  • 对于类类型的成员,使用其拷贝构造函数来拷贝;
  • 对于内置类型的成员,直接拷贝;
  • 对于数组类型的成员,逐元素地进行拷贝(元素拷贝方式同前)。
class Sales_data
{
public:
  Sales_data(const Sales_data &); // 拷贝构造函数的声明

private:
  string bookNo;
  int units_sold = 0;
  double revenue = 0.0;
};

// 拷贝构造函数的定义,当前定义与合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data &orig) : 
            bookNo(orig.bookNo),         // 使用 string 的拷贝构造函数
            units_sold(orig.units_sold), // 直接拷贝 int 值
            revenue(orig.revenue)        // 直接拷贝 double 值
            {}                           // 空函数体

1.1.2 拷贝初始化

直接初始化 vs 拷贝初始化:

// 直接初始化(direct initialization),不使用等号(=)初始化一个变量
// 编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数
string dots(10, '.');
string s(dots);

// 拷贝初始化(copy initialization),使用等号(=)初始化一个变量
// 编译器将右侧运算对象拷贝到左侧正在创建的对象中,如果需要的话还会进行类型转换
string s2 = dots;
string null_book = "9-999-99999-9";
string nines = string(100, '9');

拷贝初始化通常使用拷贝构造函数来完成,有时使用移动构造函数来完成(左值被拷贝,右值被移动)。

发生拷贝初始化的情况包括:

  • = 定义变量;
  • 将一个对象作为实参传递给一个非引用类型的形参;
    • 拷贝构造函数自己的参数必须是引用类型,避免无限循环
  • 从一个返回类型为非引用类型的函数返回一个对象;
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员;
  • 某些类类型会对它们所分配的对象使用拷贝初始化,例如,在初始化标准库容器时,或调用其 insertpush 成员时。

如果初始化值需要类型转换,对于使用 explicit 声明的构造函数,就不能够使用拷贝初始化:

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

1.2 拷贝赋值运算符

拷贝赋值运算符(copy-assignment operator),右侧运算对象是自身类类型的参数,左侧运算对象绑定到隐式的 this 参数,通常返回左侧运算对象的引用。

1.2.1 合成拷贝赋值运算符

合成拷贝赋值运算符(synthesized copy-assignment operator),如果我们没有为一个类定义拷贝赋值运算符,编译器会为我们合成一个。

  • 一般情况下(非删除的函数),合成拷贝赋值运算符会将右侧运算对象的每个非 static 成员赋予左侧运算对象的对应成员。
  • 每个成员的赋值通过成员类型的拷贝赋值运算符来完成,对于数组类型的成员,会逐个赋值数组元素。
  • 合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
class Sales_data
{
public:
  Sales_data &operator=(const Sales_data &rhs); // 拷贝赋值运算符的声明

private:
  string bookNo;
  int units_sold = 0;
  double revenue = 0.0;
};

// 拷贝赋值运算符的定义,当前定义与合成拷贝赋值运算符等价
Sales_data &Sales_data::operator=(const Sales_data &rhs)
{
  bookNo = rhs.bookNo;         // 调用 string::operator=
  units_sold = rhs.units_sold; // 使用内置的 int 赋值
  revenue = rhs.revenue;       // 使用内置的 double 赋值
  return *this;                // 返回一个本对象的引用
}

1.3 析构函数

析构函数(destructor),释放对象使用的资源,销毁对象的非 static 数据成员。它没有返回值,也不接受参数,不能被重载。一个类只有一个析构函数。

构造函数析构函数
有一个初始化部分和一个函数体有一个函数体和一个析构部分
先初始化成员,再执行函数体先执行函数体,再销毁成员
按照成员在类中出现的顺序进行初始化按照成员初始化顺序的逆序进行销毁
通过初始化列表显示初始化析构部分是隐式的,类类型成员执行各自的析构函数

调用析构函数(对象被销毁)的情况包括:

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

1.3.1 合成析构函数

合成析构函数(synthesized destructor),如果我们没有为一个类定义析构函数,编译器会为我们合成一个。一般情况下(非删除的函数),合成析构函数的函数体为空。

class Sales_data
{
public:
  // 析构函数的定义,当前定义与合成析构函数等价
  ~Sales_data() {}

private:
  string bookNo;
  int units_sold = 0;
  double revenue = 0.0;
};

1.4 三/五法则

对于类的三个(拷贝构造函数、拷贝赋值运算符、析构函数),或五个(加上 C++11 移动构造函数、移动赋值运算符)拷贝控制操作,C++ 语言并不要求我们同时进行定义。但是,这些操作通常应该被看作一个整体,如果定义了其中的一个操作,一般也需要定义其他操作。

判断一个类是否需要自定义拷贝控制成员:

  1. 首先确定是否需要自定义析构函数(更容易判断);
  2. 如果一个类需要自定义析构函数,几乎可以肯定,它也需要自定义拷贝构造函数和拷贝赋值运算符;
  3. 如果一个类需要自定义拷贝构造函数,几乎可以肯定,它也需要自定义拷贝赋值运算符,反之亦然;
  4. 如果资源的拷贝是非必要的,定义移动操作可以避免这种额外的开销。
需要
需要
需要
需要
需要
拷贝构造函数
拷贝赋值运算符
析构函数
移动构造函数
移动赋值运算符

示例:需要析构函数的类也需要拷贝和赋值操作

class HasPtr
{
public:
  // 在构造函数中分配动态内存
  HasPtr(const string &s = string()) : ps(new string(s)), i(0) {}
  // 需要定义一个析构函数来释放构造函数分配的内存(合成析构函数不会 delete 一个指针数据成员)
  ~HasPtr() { delete ps; }

  // 错误:HasPtr 需要一个拷贝构造函数和一个拷贝赋值运算符
  // 使用合成的函数,会简单拷贝指针成员 ps,这意味着多个 HasPtr 对象可能指向同一块内存空间
  // 这些对象在销毁时,就会通过析构函数多次 delete 同一块内存空间

private:
  string *ps;
  int i;
};

HasPtr f(HasPtr hp) // HasPtr 是传值参数,所以会被拷贝
{
  HasPtr ret = hp; // 拷贝传入的 HasPtr
  // process ret
  return ret; // ret 和 hp 都被销毁,导致相同的指针被 delete 两次
}

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

class HasUniqueID
{
public:
  HasUniqueID(int d = 0) : data(d), id(nextId++) {}

  // 拷贝构造函数,为每个新创建的对象生成一个新的、独一无二的 id
  HasUniqueID(const HasUniqueID &rhs) : data(rhs.data), id(nextId++) {}

  // 拷贝赋值运算符,避免复制 id
  HasUniqueID &operator=(const HasUniqueID &rhs)
  {
    data = rhs.data;
    return *this;
  }

private:
  int data;
  int id;

  static int nextId;
};

int HasUniqueID::nextId = 0;

1.5 使用 =default(C++11)

将拷贝控制成员定义为 =default ,可以显式地要求编译器生成合成的版本。
只能对具有合成版本的成员函数(默认构造函数、拷贝控制成员)使用 =default

class Sales_data
{
public:
  // 对默认构造函数、拷贝控制成员使用 =default
  // 在类内使用 =default,合成的函数将隐式地声明为内联函数
  Sales_data() = default;
  Sales_data(const Sales_data &) = default;
  Sales_data &operator=(const Sales_data &); //需要合成非内联函数
  ~Sales_data() = default;
};

// 如果不希望合成的成员是内联函数,那么只对成员的类外定义使用 =default
Sales_data &Sales_data::operator=(const Sales_data &) = default;

1.6 阻止拷贝

虽然,大多数类应该显式地或隐式地定义拷贝构造函数和拷贝赋值运算符。但是,对于某些类(如 iostream)来说,这些操作没有合理的意义。在定义类时,就需要采用某种机制(将其定义为删除的函数或私有的函数),来阻止拷贝或赋值。

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

1.6.1 使用 =delete(C++11)

删除的函数(deleted function),在函数的形参列表后面加上 =delete 将其定义为删除的。这样,虽然声明了函数,但是不能以任何方式使用它们。

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

对比 =default

  • =delete 必须出现在函数的第一次声明中;
  • 可以对任何函数指定 =delete

通常,析构函数不能定义为删除的成员,因为,对于具有删除的析构函数的类型:

  • 无法销毁此类型的对象;
  • 不能定义该类型的变量或创建该类型的临时对象;
  • 如果是某个类的成员的类型,那么不能定义该类的变量或临时对象;
  • 可以动态分配此类型的对象(但不能释放)。
struct NoDtor
{
  NoDtor() = default; // 使用合成默认构造函数
  ~NoDtor() = delete; // 不能销毁 NoDtor 类型的对象
};
NoDtor nd;                // 错误:NoDtor 的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确:但是我们不能 delete p
delete p;                 // 错误:NoDtor 的析构函数是删除的

对于某些类,编译器将合成的拷贝控制成员、合成的默认构造函数定义为删除的函数。

情况1: 如果类的某个数据成员不能默认构造、拷贝、赋值或销毁,那么类对应的成员函数将被定义为删除的。

如果,类的某个成员那么,类的
析构函数是删除的或不可访问的(如 private)合成析构函数被定义为删除的
拷贝构造函数是删除的或不可访问的合成拷贝构造函数被定义为删除的
析构函数是删除的或不可访问的
拷贝赋值运算符是删除的或不可访问的合成拷贝赋值运算符被定义为删除的
const 或引用
析构函数是删除的或不可访问的合成默认构造函数被定义为删除的
是 引用,但没有类内初始化器
const,但没有类内初始化器,并且没有显式定义默认构造函数

情况2: 如果类定义了一个移动构造函数和/或一个移动赋值运算符,那么该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。

情况3: 将移动操作定义为删除的函数

情况4: 某些定义基类的方式可能导致有的派生类成员成为被删除的函数。

基类派生类
默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是删除的或不可访问的对应的成员将是被删除的(因为不能执行基类部分的构造、赋值或销毁操作)
析构函数是删除的或不可访问的合成的默认和拷贝构造函数将是被删除的(因为无法销毁基类部分)
移动构造函数或移动赋值运算符是删除的或不可访问的对应的成员将是被删除的(因为基类部分不可移动)
析构函数是删除的或不可访问的移动构造函数也将是被删除的(因为无法销毁基类部分)

情况5: 含有类类型成员的 union(C++11)

  • union 包含的是内置类型的成员时,编译器将按照成员的次序依次合成默认构造函数或拷贝控制成员。
  • 如果 union 含有类类型的成员,并且该类型自定义了默认构造函数或拷贝控制成员,那么编译器将为 union 合成对应的版本并将其声明为删除的。
    • 例如,string 类定义了五个拷贝控制成员以及一个默认构造函数。如果 union 含有 string 类型的成员,并且没有自定义默认构造函数或某个拷贝控制成员,那么编译器将合成缺少的成员并将其声明为删除的。
  • 如果在某个类中含有一个 union 成员,并且该 union 含有删除的拷贝控制成员,则该类与之对应的拷贝控制成员也将是删除的。

1.6.2 private 拷贝控制

在 C++11 之前,类通过将其拷贝构造函数和拷贝赋值运算符声明为 private 来阻止拷贝。

class PrivateCopy
{
  // 拷贝控制成员是 private 的,普通用户代码无法访问(产生编译错误)
  // 拷贝控制成员“仅声明不定义”,成员函数和友元也不能进行访问(产生链接时错误)
  // 如果不使用某个函数,声明但不定义是合法的(虚函数除外)
  PrivateCopy(const PrivateCopy &);
  PrivateCopy &operator=(const PrivateCopy &);

public:
  PrivateCopy() = default;
  ~PrivateCopy() = default; // 析构函数为 public,用户可以定义此类型的对象,但是无法拷贝它们
};

2、拷贝控制和资源管理

通常,管理类外资源的类都需要定义拷贝控制成员,因为它们需要定义析构函数来释放对象所分配的资源,同时也就会需要拷贝构造函数和拷贝赋值运算符。

一般来说,有两种选择来定义这些成员的拷贝操作:

  1. 使类的行为看起来像一个值:
    • 在拷贝对象时,副本和原对象是完全独立的,改变副本不会改变原对象;
    • 例如标准库容器和 strnig 类。
  2. 使类的行为看起来像一个指针:
    • 在拷贝对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象;
    • 例如 shared_ptr 类。

2.1 行为像值的类

对于类管理的资源,每个对象都应该有一份自己的拷贝。

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

private:
  string *ps;
  int i;
};

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

  • 类似析构函数,赋值操作会销毁左侧运算对象的资源;
  • 类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。
  • 这些操作要以正确的顺序执行,确保即使将一个对象赋予它自身也能够正确工作;
  • 如果可能,在异常发生时,要将左侧运算对象置于一个有意义的状态。
HasPtr &HasPtr::operator=(const HasPtr &rhs)
{
  // 首先将右侧运算对象资源拷贝到一个局部临时对象中
  // 这样可以处理自赋值情况,并能保证在异常发生时代码也是安全的
  auto newp = new string(*rhs.ps);
  delete ps;    // 释放左侧运算对象(本对象)的资源
  ps = newp;    // 将资源从临时对象拷贝到左侧运算对象的成员中
  i = rhs.i;    // 内置类型(除指针)成员本身就是值,直接拷贝
  return *this; // 返回左侧对象
}

2.2 行为像指针的类

令一个类展现“类似指针的行为”的最好方法是使用 shared_ptr 来管理类内的资源。
如果我们希望直接管理资源,可以使用引用计数(reference count),其工作方式如下:

  • 每个构造函数(拷贝构造函数除外)都要创建一个共享的计数器(初始值为 1),用来记录有多少个用户使用本对象创建的共享资源;
  • 拷贝构造函数递增计数器,指出共享资源的用户多了一个;
  • 析构函数递减计数器,指出共享资源的用户少了一个,如果计数器变为 0,则释放资源;
  • 拷贝赋值运算符
    • 递增右侧运算对象的计数器;
    • 递减左侧运算对象的计数器,如果计数器变为 0,则释放其资源。
class HasPtr
{
public:
  // 构造函数,分配新的 string 和新的计数器,并将计数器初始化为 1,指出当前有一个用户使用本对象的 string 成员
  HasPtr(const string &s = string()) : ps(new string(s)), i(0), use(new size_t(1)) {}
  // 拷贝构造函数,拷贝所有三个数据成员,包括 ps 指针本身,并递增计数器,指出 ps 和 p.ps 指向的 string 又多了一个用户
  HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }
  HasPtr &operator=(const HasPtr &);
  ~HasPtr();

private:
  string *ps;
  int i;
  size_t *use; // 计数器成员,用来记录有多少个用户共享 *ps
};

HasPtr::~HasPtr()
{
  if (--*use == 0) // 递减引用计数,指出共享 string 的用户少了一个
  {                // 如果引用计数变为 0
    delete ps;     // 释放 string 内存
    delete use;    // 释放计数器内存
  }
}

HasPtr &HasPtr::operator=(const HasPtr &rhs)
{
  ++*rhs.use;      // 递增右侧运算对象的引用计数
  if (--*use == 0) // 递减左侧运算对象的引用计数(本对象)
  {                // 如果引用计数变为 0,代表没有用户使用
    delete ps;     // 释放本对象的原有资源
    delete use;
  }
  ps = rhs.ps; // 将数据从右侧运算对象拷贝到本对象
  i = rhs.i;
  use = rhs.use;
  return *this; // 返回本对象
}

3、交换操作

除了定义拷贝控制成员,管理资源的类通常还会定义一个名为 swap 的函数,作为一种优化手段。

3.1 在重排算法中使用交换

如果一个类定义了自己的 swap,算法将使用类自定义的版本,否则将使用标准库定义的 swap

class HasPtr
{
public:
  HasPtr(const string &s = string()) : ps(new string(s)), i(0) {}
  HasPtr(const HasPtr &p) : ps(new string(*p.ps)), i(p.i) {}
  HasPtr &operator=(const HasPtr &rhs)
  {
    auto newp = new string(*rhs.ps);
    delete ps;
    ps = newp;
    i = rhs.i;
    return *this;
  }
  ~HasPtr() { delete ps; }

private:
  string *ps;
  int i;

  friend void swap(HasPtr &, HasPtr &); // 声明为友元
};

inline void swap(HasPtr &lhs, HasPtr &rhs) // 声明为 inline 优化代码
{
#if 0 // 通常的 swap

  HasPtr temp = lhs; // 一次拷贝
  lhs = rhs;         // 两次赋值
  rhs = temp;

#else // 自定义的 swap

  using std::swap;      // 对于内置类型,swap 会调用 std::swap
  swap(lhs.ps, rhs.ps); // 交换指针,而不是 string 数据
  swap(lhs.i, rhs.i);   // 交换 int 成员

#endif
}

swap 函数应该调用 swap,而不是 std::swap

struct Foo
{
  HasPtr h;
  // 其他成员
};

void swap(Foo &lhs, Foo &rhs)
{
  // std::swap(lhs.h, rhs.h); // 错误:这个函数使用了标准库版本的 swap,而不是 HasPtr 版本

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

3.2 在赋值运算符中使用交换

定义了 swap 的类通常使用 swap 来定义其赋值运算符。
通过一种名为拷贝和交换(copy and swap)的技术,将左侧运算对象与右侧运算对象的一个副本进行交换。

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

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

  • 通过在改变左侧运算对象之前拷贝右侧运算对象,保证了自赋值的正确;
  • 如果拷贝构造函数中的 new 表达式抛出异常,也并不会影响到左侧运算对象。

4、对象移动(C++11)

我们在 1.1.2 拷贝初始化 中提到,很多情况下会发生拷贝初始化,拷贝初始化通常使用拷贝构造函数来完成,有时使用移动构造函数来完成。

  • 如果对象在拷贝后就立即被销毁,那么移动对象而非拷贝对象会大幅度提升性能;
  • 另外,对于 IO 类或 unique_ptr 这样的类,它们都包含不能被共享的资源(如指针或 IO 缓冲),这些类型的对象不能拷贝但可以移动;
  • 而标准库容器、stringshared_ptr 类就既支持拷贝,也支持移动。

4.1 右值引用

右值引用(rvalue reference),为了支持移动操作,引入的新的引用类型。

  • 仍然是某个对象的别名;
  • 通过 && 来获得;
  • 只能被绑定到右值(一个将要被销毁的、没有其他用户的临时对象),因此可以自由“移动”其资源。

4.1.1 左值和右值

C++ 的表达式要不然是右值(rvalue),要不然是左值(lvalue)。

  • 右值表达式,表示的是对象的值(内容)
    • 示例:返回非引用类型的函数,以及算术、关系、位、后置递增/递减运算符的返回值,要求转换的表达式,字面常量;
    • 要么是字面常量,要么是在表达式求值过程中创建的临时对象。
  • 左值表达式,表示的是对象的身份(存放内容的位置)
    • 示例:返回左值引用的函数,以及赋值、下标、解引用、前置递增/递减运算符的返回值,变量;
    • 有持久的状态(非临时)。

使用原则:

  • 左值可以代替右值(使用"位置"中的"内容");
  • 右值不能代替左值。

4.1.2 左值引用 vs 右值引用

常规引用,或称左值引用(lvalue reference)

  • 可以绑定到左值表达式
  • 不能绑定到右值表达式(const 左值引用可以)

右值引用

  • 可以绑定到右值表达式
  • 可以将一个左值显示转换成其对应的右值引用类型
  • 可以通过 move 函数(谨慎使用),获取绑定到左值的右值引用
int i = 42;
int &r = i;                 // 正确:r 引用 i
int &&rr = i;               // 错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42;           // 错误:i*42 是一个右值
const int &r3 = i * 42;     // 正确:可以将一个 const 的引用绑定到一个右值上
int &&rr2 = i * 42;         // 正确:将 rr2 绑定到乘法结果上
int &&rr3 = 42;             // 正确:字面常量是右值
int &&rr4 = rr3;            // 错误:变量 rr3 是左值
int &&rr5 = std::move(rr3); // 正确:move 返回给定对象的右值引用
// move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它
// 调用 move 意味着承诺:除了对 rr3 赋值或销毁它之外,我们将不再使用它

4.2 移动操作

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

4.2.1 移动构造函数

移动构造函数(move constructor),“第一个参数是自身类类型的右值引用,其它参数(如果有)都有默认值” 的构造函数。第一个参数是“右值引用”,是其不同于拷贝构造函数的地方。移动构造函数需要:

  1. 实现资源移动;
  2. 确保移后源对象是可析构的状态,不再指向被移动的资源;
  3. 确保移后源对象是有效的状态,可以安全地为其赋予新值,或者不依赖原值地被使用;
  4. 不需要对移后源对象中留下的值做保证(因此,用户不能对它的值进行任何假设)。
class StrVec
{
public:
  StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) {}
  StrVec(const StrVec &);            // 拷贝构造函数
  StrVec &operator=(const StrVec &); // 拷贝赋值运算符
  ~StrVec() { free(); }              // 析构函数

  // 移动构造函数
  // 由于移动操作“移动”资源,通常不分配任何资源,因此通常也不会抛出任何异常
  // 使用 noexcept(C++11)指明函数不抛出任何异常,从而免去用户额外的处理异常的工作
  // 如果函数定义在类外,必须在声明中和定义中都指定 noexcept
  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;
  }

  // ...
private:
  void free() { /*...*/ } // 销毁元素并释放内存
  string *elements;
  string *first_free;
  string *cap;
};

4.2.2 移动赋值运算符

移动赋值运算符(move-assignment operator),右侧运算对象是自身类类型的右值引用,左侧运算对象绑定到隐式的 this 参数,通常返回左侧运算对象的引用。

类似“移动构造函数与拷贝构造函数”的关系,相比于拷贝赋值运算符,移动赋值运算符的右侧运算对象是一个“右值引用”。

移动赋值运算符执行与“析构函数”和“移动构造函数”相同的工作。类似 拷贝赋值运算符,移动赋值运算符也必须正确处理自赋值。

class StrVec
{
public:
  StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) {}
  ~StrVec() { free(); }                             // 析构函数
  StrVec &StrVec::operator=(StrVec &&rhs) noexcept; // 移动赋值运算符
  
  // ...
private:
  void free() { /*...*/ } // 销毁元素并释放内存
  string *elements;
  string *first_free;
  string *cap;
};

StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
  // 直接检测自赋值,因为右侧运算对象的右值可能是 move 调用的返回结果,
  // 这样右侧运算对象与左侧运算对象是同一份资源,不能够先释放再使用
  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;
}

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

4.2.3 合成的移动操作

与合成拷贝操作不同,只有在满足如下条件时,编译器才会合成移动构造函数或移动赋值运算符:

  1. 类没有定义任何自定义版本的拷贝控制成员;
  2. 类的每个非 static 数据成员都可以移动:
    • 对于内置类型成员,可以直接移动
    • 对于类类型成员,需要有移动构造函数或移动赋值运算符,才可以移动

因此,有些类并没有移动构造函数和移动赋值运算符,这时,通过正常的函数匹配,会使用对应的拷贝操作(如果存在)来代替移动操作。

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

将移动操作定义为删除的函数:

  • 与拷贝操作不同,移动操作永远不会被隐式地定义为删除的函数;
  • 在“使用 =default 显示地要求编译器生成移动操作,而编译器又不能移动所有成员”时,编译器会将移动操作定义为删除的函数,并不会进行合成。

将合成的移动操作定义为删除的函数,遵循与 定义删除的合成拷贝操作 类似的原则

如果,类的某个成员那么,类的
定义了自己的拷贝构造函数,但没有定义移动构造函数合成移动构造函数被定义为删除的
没有定义自己的拷贝构造函数,且编译器不能为它合成移动构造函数
定义了自己的拷贝赋值运算符,但没有定义移动赋值运算符合成移动赋值运算符被定义为删除的
没有定义自己的拷贝赋值运算符,且编译器不能为它合成移动赋值运算符
移动构造函数是删除的或不可访问的合成移动构造函数被定义为删除的
析构函数是删除的或不可访问的
移动赋值运算符是删除的或不可访问的合成移动赋值运算符被定义为删除的
是 const 或引用
// Y 定义了自己的拷贝构造函数,未定义自己的移动构造函数,编译器不会为 Y 合成移动操作
// 编译器可以拷贝类型为 Y 的对象,但是不能移动它们
struct Y
{
  Y() = default;
  Y(const Y &) = default; // 定义拷贝构造函数
  string s;
};

struct hasY
{
  hasY() = default;
  hasY(hasY &&) = default; // 显式要求一个移动构造函数,但编译器不能移动所有成员,无法生成
  Y mem;                   // 成员 Y 不能被移动,hasY 将有一个删除的移动构造函数
};
hasY hy, hy2 = std::move(hy); // 错误:移动构造函数是删除的

4.2.4 交换版移动赋值运算符

3.2 在赋值运算符中使用交换 部分,我们为 HasPtr 定义了一个拷贝并交换赋值运算符,如果我们再添加一个移动构造函数,那么就会同时获得一个移动赋值运算符。

class HasPtr
{
public:
  // 添加移动构造函数,接管给定实参的值,并确保可以安全销毁移后源对象
  HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }

  // 赋值运算符,既是拷贝赋值运算符,也是移动赋值运算符
  // 赋值运算符有一个非引用形参,意味着形参需要进行拷贝初始化
  // 根据实参的类型,拷贝初始化要么使用拷贝构造函数(左值被拷贝),要么使用移动构造函数(右值被移动)
  HasPtr &operator=(HasPtr rhs)
  {
    swap(*this, rhs);
    return *this;
  }

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

private:
  string *ps;
  int i;

  friend void swap(HasPtr &, HasPtr &);
};

inline void swap(HasPtr &lhs, HasPtr &rhs)
{
  using std::swap;
  swap(lhs.ps, rhs.ps);
  swap(lhs.i, rhs.i);
}

int main()
{
  HasPtr hp, hp2;
  hp = hp2;            // hp2 是一个左值,通过拷贝构造函数来初始化
  hp = std::move(hp2); // 将一个右值引用绑定到 hp2 上,通过移动构造函数来初始化
  return 0;
}

4.3 右值引用和成员函数

4.3.1 成员函数的拷贝和移动版本

除了构造函数和赋值运算符,其他成员函数也可以同时提供拷贝和移动两个版本,并且通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式:

  • 一个版本接受一个指向 const 的左值引用
    • 从传入对象拷贝数据时,不应该改变该对象,因此,通常不需要定义一个接受一个非 const 参数的版本。
  • 一个版本接受一个指向非 const 的右值引用
    • 从传入对象移动数据时,就会改变该对象,因此,通常需要一个右值引用,并且不能是 const 的。

例如,标准库容器定义 push_back 的两个版本(假定 X 是元素类型)

// 可以传递能转换为类型 X 的任何对象,此版本从其参数拷贝数据
void push_back(const X&); // 拷贝:绑定到任意类型的 X

// 只可以传递非 const 的右值,此版本从其参数移动数据
void push_back(X&&);      // 移动:只能绑定到类型 X 的可修改的右值

4.3.2 左值和右值引用成员函数

在一个对象上调用成员函数时,有时需要对象是一个左值,有时需要对象是一个右值。

通过为成员函数添加引用限定符(reference qualifier),我们可以指定 this 指向对象的左值/右属值性,使用方式与定义 const 成员函数相同:

  • 在形参列表后放置一个引用限定符 &&&
    • 对于 & 限定的函数,只能将它用于左值(this 指向左值)
    • 对于 && 限定的函数,只能将它用于右值(this 指向右值)
  • 引用限定符只能用于(非 static)成员函数,并且必须同时出现在函数的声明和定义中。
class Foo
{
public:
  Foo &operator=(const Foo &) &; // 只能向可修改的左值赋值

  // 如果同时是 const 和引用限定,引用限定符必须在 const 限定符后面
  Foo someMem() const &;
  // ...
};
Foo &Foo::operator=(const Foo &rhs) &
{
  // 执行将 rhs 赋予本对象所需的工作
  return *this;
}

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

4.3.3 重载和引用函数

可以根据 const 和 引用,来区分成员函数的重载版本:

  • 根据是否有 const 来区分
    • 定义 const 成员函数时,可以定义两个版本,唯一的差别是一个版本有 const 限定,而另一个没有。
  • 根据是否有引用限定符来区分
    • 定义两个或以上具有相同名字和相同参数列表的成员函数,只能要么都加引用限定符,要么都不加。
  • 综合引用限定符和 const 来区分
class Foo
{
public:
  Foo() = default;
  Foo sorted() &&;       // 用于可改变的右值
  Foo sorted() const &;  // 用于任何类型的 Foo
  // Foo sorted() const; // 错误:引用限定符需保持一致(都有/都没有)
  // ...
private:
  vector<int> data;
};

// this 指向对象为右值,意味着没有其他用户,因此可以改变对象,进行原址排序
Foo Foo::sorted() && {
  sort(data.begin(), data.end());
  return *this;
}

// this 指向对象是 const 和 左值,无论哪种情况,都不能进行原址排序
Foo Foo::sorted() const & {
  Foo ret(*this);                         // 拷贝一个副本
  sort(ret.data.begin(), ret.data.end()); // 对副本进行排序
  return ret;                             // 返回副本
}

// 编译器会根据调用 sorted 的对象的左值/右值属性来确定使用哪个 sorted 版本
retVal().sorted(); // retVal() 是一个右值,调用 Foo::sorted() &&
retFoo().sorted(); // retFoo() 是一个左值, 调用 Foo::sorted() const &

参考

  1. [美] Stanley B.Lippman著.C++ Primer 中文版(第5版).电子工业出版社.2013.
  2. [美] Stephen Prata著.C++ Primer Plus(第6版)中文版.人民邮电出版社.2012.

宁静以致远,感谢 Vico 老师。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值