《C++ Primer》读书笔记 第十三章 拷贝控制

第十三章 拷贝控制

拷贝控制操作(copy control):

  • 拷贝构造函数(copy constructor)
  • 拷贝赋值运算符(copy-assignment operator)
  • 移动构造函数(move constructor)
  • 移动赋值运算符(move-assignment operator)
  • 析构函数(destructor)

13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

第一个参数是自身类型的引用(且几乎总是const、不是explicit的),任何额外参数都有默认值。

如未定义,会有合成版本(即使定义了其他构造函数),依次拷贝类的非静态成员,包括数组。

1
2
3
4
5
string dots(10, '.');               // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null_book = "9-999-9999"; // 拷贝初始化
string nines = string(100, '9'); // 拷贝初始化

拷贝初始化也可能是由移动构造函数完成。

拷贝初始化时机:

  • 使用=定义对象。
  • 将一个对象作为实参传递给一个非引用类型的形参。
  • 返回一个对象(非引用)。
  • 列表初始化一个数组中的元素或一个聚合类中的成员。
  • 类中对所分配对象执行拷贝初始化操作。如 STL 中的insert等。

注意拷贝构造函数是不是explicit的,如:

1
2
vector<int> v1(10);     // 正确
vector<int> v2 = 10; // 错误

编译器可能绕过拷贝/移动构造函数,而直接创建对象。(但拷贝/移动构造函数依然需要是存在且可访问的)

13.1.2 拷贝赋值运算符

如果未定义,会有合成版本,会将右侧运算对象的每个非静态成员赋予左侧运算对象的相应成员,包括数组。

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

13.1.3 析构函数

调用时机:

  • 离开作用域。
  • 当对象被销毁时,其成员被销毁。
  • 容器被销毁时,其元素被销毁。
  • 动态分配的对象,使用delete进行销毁。
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

13.1.4 三/五法则

  • 需要析构函数的类也需要拷贝构造函数和拷贝赋值运算符。
  • 需要拷贝操作的类也需要赋值操作,反之亦然。

13.1.5 使用 = default

显式要求编译器生成合成的版本,只能对编译器可以合成的默认构造函数或拷贝控制成员使用。

在类内使用将声明为内联的,如果不希望这样,应只在类外定义使用。

13.1.6 阻止拷贝

使用= delete,必须出现在第一次声明的时候,可以对任何函数使用,除了析构函数(会造成不能释放这些对象)。

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

  • 合成析构函数:当某成员的析构函数是删除的或者不可访问的。
  • 合成拷贝构造函数:当某成员的拷贝构造函数或析构函数是删除的或者不可访问的。
  • 合成拷贝赋值运算符:当某个成员的拷贝赋值运算符是删除的或不可访问的,或类的成员中有const或引用。
  • 默认构造函数:当某个成员的析构函数是删除的或者不可访问的,或类的成员中有没有类内初始化器的const或引用。

使用private阻止拷贝的缺点是:友元和成员函数依然能够拷贝。对此可以只声明不定义,这样在试图拷贝时会报链接错误。

13.2 拷贝控制和资源管理

(实例)

13.3 交换操作

1
2
3
4
5
6
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, rhs.ps);
swap(lhs.i, rhs.i);
}

注意这里调用的是swap,而不是std::swap,是想让在用户版本和 std 版本中自动匹配合适的函数。

有了交换操作后,可以用它更简单的定义赋值运算符,即拷贝并交换(copy and swap)。它是异常安全的,且能正确处理自赋值。

1
2
3
4
5
HasPtr& HasPtr::operator=(HasPtr rhs)
{
swap(*this, rhs);
return *this;
}

13.4 拷贝控制示例

(实例)

13.5 动态内存管理类

(实例)

引出移动。

13.6 对象移动

很多情况会发生对象拷贝,某些时候对象拷贝后就立刻被销毁了。使用移动可以大幅度提升性能。

13.6.1 右值引用

  • 只能绑定到一个将要销毁的对象(往往意味着也没有其他用户)。
  • 变量是左值,不能将右值引用直接绑定到一个变量上,即使这个变量是右值引用类型。

可以使用<utility>中的std::move将一个左值显式地转换为对应的右值引用类型。告诉编译器:我有一个左值,但我希望像一个右值一样处理它,承诺不再使用它的值,除了对其赋值和销毁,因为移后源对象(moved-from)依然是一个有效的、可析构的状态,但不能对它的值做任何假设。

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

类似拷贝构造函数,只是第一个参数是右值引用类型。

注意确保移动后的对象处在销毁它是无害的的状态,如将指针置为nullptr

移动构造函数一般是noexcept的,由于它窃取资源而不分配资源。该关键字写在定义和声明的参数列表后,分号和冒号前。声明和定义都必须指定。

如果容器的元素是我们的自定义类型,如果不告诉编译器我们的移动构造函数是noexcept的,在进行一些操作时,如对vector进行需要增大内存分配的push_back操作,容器会不敢使用移动构造函数而是使用拷贝构造函数,因为它需要对异常发生时自身的行为提供保障:当异常发生时,vector 自身不会发生变化。

移动赋值运算符与析构函数和移动构造函数执行相同的工作。与移动构造函数一样,如果不抛异常就该标记为noexcept

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

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 static 数据成员都可以移动时,才会合成。

可以移动的成员包括:内置类型可以移动,有移动操作的类类型成员也能移动。

与拷贝不同,移动操作永远不会隐式定义为删除的函数。但是,如果我们使用=default,且编译器不能移动所有成员,它会是删除的。例外是:

(一二三四,我选择不依赖合成而是自己定义= =)

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

如果一个类既有移动构造函数,也有拷贝构造函数。则移动右值,拷贝左值。但如果没有移动构造函数,右值也被拷贝。

引入移动操作后,13.3中的拷贝并交换赋值运算符在定义了移动构造函数之后会兼容移动操作。单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。

更新三/五法则:所有五个拷贝控制成员应该看作一个整体:如果定义了任何一个拷贝操作,就应该定义五个。这些类通常拥有一个资源。

移动迭代器:通过解引用得到返回一个指向元素的右值引用。使用make_move_iterator函数。例子:

1
2
auto first = alloc.allocate(newcapacity);
auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);

13.6.3 右值引用和成员函数

如果一个成员函数同时提供拷贝和移动版本,则一般使用与拷贝/移动构造函数和赋值运算符相同的参数模式:一个版本接受一个指向const的左值引用,一个版本接受一个指向非const的右值引用。

一般来说,我们不需要为函数操作定义接受一个const X&&或一个普通的X&参数的版本。

通常,我们在一个对象上调用成员函数,而不管对象是一个左值还是一个右值:

1
2
3
string s1= "xxxx", s2="xxxx";
auto n = (s1 + s2).find('a');
s1 + s2 = "wow!";

在旧标准中,我们没法阻止这种使用方式,为了向后兼容性,新标准依然允许。但是有方法进行阻止:限定this的左值/右值属性,像限制其const属性那样在参数列表后放置&&&。如果需要同时放置constconst在前。需要同时在声明和定义中指定。

重载方式:

1
2
3
4
5
6
7
class Foo{
public:
Foo sorted() &&; // 可用于可改变的右值
Foo sorted() const &; // 可用于任何类型的 Foo
private:
...
};

注意:定义const成员函数时,可以定义两个版本:差别是有没有const。但引用限定的函数不一样,必须对所有函数都加上引用限定符,或者所有都不加。