第十三章 拷贝控制

97 阅读19分钟

类类型对象的拷贝、移动、赋值、销毁可以通过定义特殊的成员函数来控制:拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)、析构函数(destructor)。它们分别应用于同类型对象初始化、同类型对象赋值、对象销毁,这些操作称为拷贝控制操作(copy control)。

拷贝、赋值和销毁

拷贝构造函数

第一个参数是自身类类型的引用,其它参数都有默认值的构造函数称为拷贝构造函数。拷贝构造函数的第一个参数必须是引用类型,而且一般是 const 引用。由于拷贝构造函数有些情况下会隐式使用,因此通常不应该 explicit

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

不管有没有其他构造函数,只要没有为类定义拷贝构造函数,编译器就会生成一个合成拷贝构造函数(synthesized copy constructor),它分两种情况:

  • 一般依次拷贝每个非 static 成员:
    • 类类型成员使用拷贝构造函数来拷贝。
    • 内置类型成员直接拷贝。
    • 数组类型成员逐元素拷贝。
  • 某些类的合成拷贝构造函数用于阻止拷贝。
string dots(10, '.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null_book = "9-999-99-999"; // 拷贝初始化
string nines = string(10, '9'); // 拷贝初始化

直接初始化要求编译器使用相匹配的构造函数。拷贝初始化(copy initialization)要求编译器将右侧对象(可能需要类型转换)拷贝到正在创建的对象。拷贝初始化通常使用拷贝构造函数来完成,有时会使用移动构造函数。以下情况会发生拷贝初始化:

  • 使用 = 定义变量。
  • 将一个对象实参传递给非引用类型形参。
  • 返回类型为非引用类型的函数返回一个对象。
  • 列表初始化一个数组中的元素或一个聚合类的成员。
  • 某些类类型为分配的对象拷贝初始化,比如初始化标准库容器或调用 insertpush,而 emplace 则直接初始化。

拷贝初始化时,编译器可以跳过拷贝/移动构造函数,直接创建对象,但仍然要求拷贝/移动构造函数必须存在且可访问。

string null_book = "9-999-99-999";
// 编译器可能转为
string null_book("9-999-99-999");

拷贝赋值运算符

重载运算符(overloaded operator)本质上就是函数,函数名用 operator 关键字后接运算符符号来表示,比如:operator=。重载运算符的参数表示运算符的运算对象。如果运算符是一个成员函数,其左侧运算对象绑定到隐式 this 上,二元运算符右侧运算对象作为参数显式传递。

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

赋值运算符通常应该返回一个指向左侧运算对象的引用。标准库通常要求保存在容器中的类型具有赋值运算符,返回值是左侧运算对象的引用。如果一个类未定义拷贝赋值运算符,编译器将会生成一个合成拷贝赋值运算符(synthesized copy-assignment operator)。合成拷贝赋值运算符分两种情况:

  • 将右侧运算对象的每个非 static 成员赋值给左侧运算对象相应成员,成员赋值通过成员类型的拷贝赋值运算符完成,数组类型成员逐元素赋值。
  • 对于某些类,用于禁止该类型对象的赋值。

析构函数

析构函数是类的成员函数,负责释放对象使用的资源,并销毁非 static 数据成员,它的名字由 ~ 后接类名构成,无返回值,无参数,不能重载。

class Foo {
public:
  ~Foo(); // 析构函数
};

析构函数由一个函数体和一个析构部分组成,首先执行函数体,然后按初始化逆序销毁成员。析构函数体可执行类设计者希望执行的任何收尾工作,通常释放对象在生存期分配的所有资源。析构部分是隐式的,销毁类类型的成员需要执行成员自身的析构函数,内置类型则直接销毁。隐式销毁一个内置指针类型的成员不会 delete 所指向的对象,但智能指针这种类类型通过析构函数自动销毁所指对象。

对象销毁时会自动调用析构函数:

  • 离开作用域销毁变量。
  • 销毁对象时,也销毁成员。
  • 销毁容器(标准库、内置数组)时,也销毁元素。
  • 对动态分配对象使用 delete 运算符。
  • 临时对象在创建它的表达式结束时销毁。

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

没有定义析构函数时,编译器将会生成一个合成析构函数(synthesized destructor),合成析构函数分两种情况:

  • 函数体为空。
  • 阻止对象销毁。

析构函数体并不直接销毁成员,成员在析构函数体之后的隐式析构阶段被销毁,析构函数体作为成员销毁步骤之外的另一部分进行。

三/五法则

拷贝控制操作除了拷贝构造函数、拷贝赋值运算符、析构函数这三个基本操作之外,还有移动构造函数、移动赋值运算符。这些操作不必全部定义,但应该看作一个整体,只需要其中一个操作的情况比较少见;一般来说,如果一个类定义了拷贝操作就应该定义所有五个操作。

通常对析构函数的需求比对拷贝构造函数、拷贝赋值运算符的需求更明显。如果一个类需要自定义析构函数,几乎可以肯定需要自定义拷贝赋值运算符和拷贝构造函数

class HasPtr {
public:
  HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) {};
  ~HasPtr() {
    delete ps;
  };
  // 使用合成拷贝构造函数和拷贝赋值运算符
private:
  std::string *ps;
  int i;
};

HasPtr f(HasPtr hp) {
  HasPtr ret = hp;
  return ret;
  // ret、hp 的 ps 指向同一块动态内存,退出函数时将被销毁两次
}

HasPtr p("some values");
f(p);
HasPtr q(p); // q、p 的 ps 指向同一内存,且已销毁

有些类只需要拷贝或赋值操作,不需要析构函数。需要拷贝操作的类几乎可以肯定也需要赋值操作,反之亦然

使用 =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

阻止拷贝

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论显式地还是隐式地。但某些情况下,需要阻止拷贝或赋值,比如:iostream

任何成员函数都可以使用 =delete 定义为删除的函数(deleted function)——这种函数虽然声明了但不能以任何方式调用=delete 必须出现在函数第一次声明时,它告诉编译器不定义这些成员,主要用于禁止拷贝控制成员。

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

如果一个类类型或它的某个成员的类型删除了析构函数,就无法销毁此类类型的对象,编译器将不允许定义该类型的变量或创建该类型的临时对象。虽然可以动态分配这种类型的对象,但无法释放。

struct NoDtor {
  NoDtor() = default;
  ~NoDtor() = delete;
};

void f() {
  NoDtor nd; // 错误
  NoDtor *p = new NoDtor(); // 正确
  delete p; // 错误
}

如果一个类的数据成员不能默认构造、拷贝、赋值或销毁,则相应的编译器合成的成员函数被定义为删除的:

  • 如果某个成员的析构函数是删除的或不可访问(比如 private),则类的合成析构函数被定义为删除的。
  • 如果某个成员的拷贝构造函数或析构函数是删除的或不可访问,则类的合成拷贝构造函数被定义为删除的。
  • 如果某个成员的拷贝赋值运算符是删除的或不可访问,或者某个成员是 const 或引用,则类的合成拷贝赋值运算符被定义为删除的。
  • 如果某个成员的析构函数是删除的或不可访问,或有一个无类内初始值的引用成员,或有一个无类内初始值的 const 成员且其类型的 const 对象无法默认初始化——比如某内置类型成员默认初始化相当于未初始化,则类的合成默认构造函数被定义为删除的。

将拷贝构造函数和拷贝赋值运算符声明为 private 但不定义,可以阻止拷贝:用户代码中的拷贝操作在编译时报错,成员函数或友元中的拷贝操作在链接时报错。

阻止拷贝应使用 =delete,而不是 private

class PrivateCopy {
  PrivateCopy(const PrivateCopy&);
  PrivateCopy &operator=(const PrivateCopy&);
public:
  PrivateCopy() = default;
  ~PrivateCopy() = default;
};

拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员。为了定义这些成员,必须先确定此类对象的拷贝语义,一般有两种:

  • 类值行为,副本与原对象的状态独立,比如 string、标准库容器。
  • 类指针行为,副本与原对象共享状态,比如 shared_ptr

IO 类型和 unique_ptr 禁止拷贝或赋值。

赋值运算符应该满足:

  • 将一个对象赋予自身也能正确工作。
  • 大多数赋值运算符是析构函数和拷贝构造函数的组合。

因此,一般先将右侧运算对象拷贝到局部临时对象中,然后销毁左侧运算对象现有成员,再将数据从临时对象拷贝到左侧运算对象的成员中。

赋值运算符应该尽可能保证异常安全,即,异常发生时左侧运算对象的状态仍有意义。

类指针行为的类最好借助 shared_ptr 来管理资源。若要直接管理资源,可以使用引用计数(reference count)。引用计数的工作方式如下:

  • 除拷贝构造函数之外的每个构造函数都要创建一个引用计数,用于记录多少个对象与正在创建的对象共享状态。计数器初始值为 1。
  • 拷贝构造函数拷贝给定对象的、包括计数器在内的数据成员,并递增共享的计数器。
  • 析构函数递减计数器,如果计数器为 0,则析构函数释放状态。
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器,如果左侧运算对象的计数器为 0,则销毁其状态。

计数器可以保存在动态内存中。

资源管理并不是需要自定义拷贝控制成员的唯一原因,有些类需要通过拷贝控制成员帮助进行簿记或其它操作,比如维护对象间的关系。

交换操作

除了拷贝控制成员之外,管理资源的类通常还定义 swap 函数。重排元素顺序的算法会调用 swap 交换两个元素。算法会使用类自定义的 swap,如果没有,则使用标准库定义的 swap。这样的 swap 通常需要进行一次拷贝、两次赋值,一般会引入不必要的内存分配开销。

HasPtr temp = v1;
v1 = v2;
v2 = temp;

为类自定义 swap 函数可以重载默认行为,通过交换指针来减少内存分配开销。

swap 的定义并不是必要的,但可能是一种重要的优化手段。

class HasPtr {
  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);
}

类成员也要通过 swap 直接交换,而不是 std::swap。这对于有自身特定的 swap 的成员类型尤为重要,因为类型特定的 swap 版本的匹配程度优于 std 中定义的版本。

定义 swap 的类通常使用拷贝并交换(copy and swap)技术,通过 swap 定义赋值运算符,即,将左侧运算对象和右侧运算对象的副本交换。

拷贝并交换自动处理了自赋值,而且天然就是异常安全的。

HasPtr &HasPtr::operator=(HasPtr rhs) {
  swap(*this, rhs);
  return *this;
};

对象移动

对象拷贝多出了分配和释放内存的额外开销。利用新标准引入的移动机制可以避免拷贝,移动操作由两部分构成:

  1. 实现移动构造函数和移动赋值运算符,移动而不拷贝给定对象,但需保证移后源(moved-from)仍然保持一个有效的、可析构的状态。
  2. 使用头文件 utility 中的标准库函数 move 生成右值引用,指定移动操作。

使用移动而非拷贝的原因有:

  • 分配内存并拷贝对象会产生大量开销
  • 某些类包含不能被共享的资源,比如 IO 类或 unique_ptr

标准库容器、stringshared_ptr 类支持移动和拷贝,而 IO 类、unique_ptr 类可以移动但不能拷贝。

新标准库容器允许存储可移动但不可拷贝的类型。

右值引用

新标准引入了一种新的引用类型——右值引用(rvalue reference),它也是某个对象的别名,但只能绑定到右值,用 && 表示。左值和右值是表达式的属性,一般而言,左值表达式表示一个对象的身份,右值表达式表示对象的值。与左值引用相反,右值引用可以绑定到要求转换的表达式、字面常量、返回右值的表达式,但不能直接绑定到左值上。

  • 左值引用可以绑定到左值表达式,比如:返回左值引用的函数调用、赋值、下标、解引用、前置递增/递减等。
  • 右值引用和 const 左值引用可以绑定到右值表达式,比如:返回非引用类型的函数调用、算术、关系、位、后置递增/递减等。
int i = 42;
int &r = i;
int &&rr = i; // 错误
int &r2 = i * 42; // 错误
const int &r3 = i * 42;
int &&rr2 = i * 42;

左值是有持久状态的对象,右值是临时对象。因此,右值引用满足:

  • 所引用的对象即将销毁
  • 该对象没有其他所有者

这意味着使用右值引用的代码可以自由地接管所引用的对象资源。变量可以看作是没有运算符、只有一个运算对象的表达式,称为变量表达式。变量表达式都是左值,因此不能把右值引用绑定到右值引用类型的变量上。

对象和引用都是变量。

int &&rr1 = 42;
int &&rr2 = rr1; // 错误

头文件 utility 中的标准库函数 move 可以将一个左值转换为相应的右值引用类型。调用 move 之后,除了对移后源对象赋值或销毁之外,不能再使用移后源对象的值,不能对移后源对象的值做任何假设。

move 应直接通过 std::move 使用,避免潜在的命名冲突。

int &&rr2 = std::move(rr1);

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

为了让自定义类型支持移动操作,需要定义移动构造函数和移动赋值运算符。这两个成员只转移资源的控制权,而不拷贝资源。

移动构造函数(move constructor)的第一个参数是该类类型的右值引用,其余参数都有默认值。除了完成资源移动,移动构造函数还必须确保移后源对象是可销毁的。一旦资源完成移动,所有权就归属新对象,源对象不再指向被移动的资源。

移动操作只负责转移资源所有权,通常不抛出任何异常。当编写不抛出异常的移动操作时,应该将其通知给标准库,否则标准库将为处理这种可能的异常做额外工作。新标准中,将函数声明为 noexcept 可通知标准库——该函数不抛出异常。noexcept 在函数的参数列表后指定;对于构造函数,在参数列表和初始化列表之间指定。

class StrVec {
public:
  StrVec(StrVec&&) noexcept;
};

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

类头文件的声明中和定义中都必须指定 noexcept

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

移动操作虽然通常不会,但也允许抛出异常。异常发生时,标准库容器能对自身的行为提供保障,比如 push_back 失败时 vector 自身不会发生改变,vector 为提供这种保障,除非知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,必须使用拷贝构造函数而不是移动构造函数。

移动赋值运算符(move-assignment operator)应满足以下两个条件;而且,如果不抛出任何异常,应将其标记为 noexcept

  • 可以正确处理自赋值
  • 是析构函数和移动构造函数的组合

移动后不会销毁源对象,但必须保证它处于有效的、析构安全的状态,而移后源对象的值不做任何约定。对象有效指的是可以安全地赋值或者可以不依赖于当前值地、安全地使用。

当类没有自定义任何拷贝控制成员,且每个非 static 数据成员都能移动构造/移动赋值时,比如:

  • 内置类型成员
  • 支持相应移动操作的类类型成员

编译器才会合成移动构造函数/移动赋值运算符。如果有成员无法移动,则移动操作无法合成,比如:

  • 如果某个成员未定义拷贝构造函数且无法合成移动构造函数,则类的移动构造函数无法合成。移动赋值运算符情况类似。
  • 如果某个成员的移动构造函数/移动赋值运算符被定义为删除的或不可访问的,则类的移动构造函数/移动赋值运算符无法合成。
  • 如果某个成员是 const 或引用,则类的移动赋值运算符无法合成。
  • 如果类定义了移动构造函数/移动赋值运算符,则合成拷贝构造函数和合成拷贝赋值运算符被定义为删除的。

    定义了移动构造函数或移动赋值运算符的类必须自定义拷贝操作。

函数匹配规则适用于移动操作和拷贝操作。如果没有相应的移动操作,将执行拷贝,即使右值(比如 move)也是如此。

拷贝操作满足移动操作的要求:拷贝源对象,并将源对象置于有效状态。

class Foo {
public:
  Foo() = default;
  Foo(const Foo&) {
    cout << "Foo(const Foo&)" << endl;
  };
};

Foo x;
Foo y(x); // 拷贝构造
Foo z(std::move(x)); // 拷贝构造

移动/拷贝赋值运算符可以借助拷贝并交换和移动/拷贝构造函数来实现:

class HasPtr {
public:
  HasPtr(HasPtr &&p) noexcept: ps(p.ps), i(p.i) {
    p.ps = nullptr;
    p.i = 0;
  };
  HasPtr &operator=(HasPtr rhs) {
    swap(*this, rhs);
    return *this;
  };
  // ...
};

新标准库提供了移动迭代器(move iterator)适配器。标准库函数 make_move_iterator 将普通迭代器转为移动迭代器。对移动迭代器解引用得到的是右值引用,原迭代器的所有其它操作在移动迭代器上都照常工作。

uninitialized_copy 在目标位置处对输入序列中的每个元素调用 construct 构造新元素,解引用获取右值引用则调用移动构造函数。

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

由于移后源对象的状态不确定,因此必须确认移后源对象没有其他用户后才能调用 move

类代码中小心使用 move 可以大幅提升性能。在移动构造函数和移动赋值运算符这些类实现代码之外的地方,只有当你确定需要进行移动且操作安全时,才能使用 std::move

右值引用和成员函数

除了构造函数和赋值运算符之外,成员函数也能同时定义两个版本并从中受益:

  • 拷贝版本,形参类型为 const T&,接受一个指向 const 的左值引用。
  • 移动版本,形参类型为 T&&,接受一个指向非 const 的右值引用。

    任何能转为类型 T 的实参与移动版本相匹配。

通常,左值和右值都可以调用成员函数。为了向后兼容,新标准库类仍允许向右值赋值,但可以在参数列表后添加引用限定符 &/&&(reference qualifier)来限制成员函数的 this 的左值/右值属性。引用限定符只能用于非 static 成员函数,且必须同时出现在函数的声明和定义中。同时使用 const 和引用限定符时,引用限定符必须放在 const 之后。

class Foo {
public:
  Foo &operator=(const Foo&) & {
    return *this;
  };
};

Foo &retFoo();
Foo retVal();
Foo i, j;
i = j;
retFoo() = i;
retVal() = i; // 错误
i = retVal();

可以借助引用限定符实现重载,但参数列表相同的重载成员函数必须都有或都没有引用限定符。

class Foo {
public:
  Foo sorted() &&;
  Foo sorted() const; // 错误,缺少引用限定符
};