[C++]C++Primer Chapter 13

拷贝控制

一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。

13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

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

拷贝构造函数的第一个参数必须是一个引用类型,原因我们稍后解释。虽然我们可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是explicit的(参见7.5.4节,第265页)。

合成拷贝构造函数

即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。

13.1.6节(第450页)中所见,对某些类来说,合成拷贝构造函数(synthesized copy constructor)用来阻止我们拷贝该类类型的对象。

虽然我们不能直接拷贝一个数组(参见3.5.1节,第102页),但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。

拷贝初始化

当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配(参见6.4节,第209页)来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化(copy initialization)时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换(参见7.5.4节,第263页)。

拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生

  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员(参见7.5.5节,第266页)

当我们初始化标准库容器或是调用其insert或push成员(参见9.3.1节,第306页)时,容器会对其元素进行拷贝初始化。与之相对,用emplace成员创建的元素都进行直接初始化(参见9.3.1节,第308页)。

参数和返回值

在函数调用过程中,具有非引用类型的参数要进行拷贝初始化(参见6.2.1节,第188页)。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果(参见6.3.2节,第201页)。

拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。

拷贝初始化的限制

如果我们使用的初始化值要求通过一个explicit的构造函数来进行类型转换(参见7.5.4节,第265页),那么使用拷贝初始化还是直接初始化就不是无关紧要的了

编译器可以绕过拷贝构造函数

在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。

但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(例如,不能是private的)。

13.1.2 拷贝赋值运算符

如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。

重载赋值运算符

重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。

某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数(参见7.1.2节,第231页)。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

为了与内置类型的赋值(参见4.4节,第129页)保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。

标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。

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

合成拷贝赋值运算符

对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

13.1.3 析构函数

析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。

析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数

由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。

析构函数完成什么工作

如同构造函数有一个初始化部分和一个函数体(参见7.5.1节,第257页),析构函数也有一个函数体和一个析构部分。在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。

什么时候会调用析构函数

无论何时一个对象被销毁,就会自动调用其析构函数:

  • 变量在离开其作用域时被销毁。
  • 当一个对象被销毁时,其成员被销毁。
  • 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁(参见12.1.2节,第409页)。
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

合成析构函数

类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁(参见13.1.6节,第450页)。如果不是这种情况,合成析构函数的函数体就为空。

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

13.1.4 三/五法则

有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。而且,在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符,我们将在13.6节(第470页)中介绍这些内容。

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

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

无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

13.1.5 使用=default

我们可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本(参见7.1.4节,第237页)

当我们在类内用=default修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default,就像对拷贝赋值运算符所做的那样。

13.1.6 阻止拷贝

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。

对某些类来说,这些操作没有合理的意义。在此情况下,定义类时必须采用某种机制阻止拷贝或赋值。例如,iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。

定义删除的函数

在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的

与=default不同,=delete必须出现在函数第一次声明的时候

与=default的另一个不同之处是,我们可以对任何函数指定=delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default)。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。

析构函数不能是删除的成员

值得注意的是,我们不能删除析构函数。

对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象

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

对某些类来说,编译器将这些合成的成员定义为删除的函数:

  • 如果类的某个成员的析构函数是删除的或不可访问的(例如,是private的),则类的合成析构函数被定义为删除的。
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
  • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器(参见2.6.1节,第65页),或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。

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

private拷贝控制

在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝

声明但不定义一个成员函数是合法的(参见6.1.2节,第186页),对此只有一个例外,我们将在15.2.1节(第528页)中介绍。试图访问一个未定义的成员将导致一个链接时错误。通过声明(但不定义)private的拷贝构造函数,我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误。

13.2 拷贝控制和资源管理

首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。

类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。

行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。

在我们使用过的标准库类中,标准库容器和string类的行为像一个值。而不出意外的,shared_ptr类提供类似指针的行为,就像我们的StrBlob类(参见12.1.1节,第405页)一样,IO类型和unique_ptr不允许拷贝或赋值,因此它们的行为既不像值也不像指针。

13.2.1 行为像值的类

为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。

类值拷贝赋值运算符

赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。但是,非常重要的一点是,这些操作是以正确的顺序执行的,即使将一个对象赋予它自身,也保证正确。而且,如果可能,我们编写的赋值运算符还应该是异常安全的——当异常发生时能将左侧运算对象置于一个有意义的状态(参见5.6.2节,第175页)。

通过先拷贝右侧运算对象,我们可以处理自赋值情况,并能保证在异常发生时代码也是安全的。

关键概念:赋值运算符

当你编写赋值运算符时,有两点需要记住:

  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
  • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。

当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。

13.2.2 定义行为像指针的类

对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。

析构函数不能单方面地释放关联的string。只有当最后一个指向string的HasPtr销毁时,它才可以释放string。

令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。

但是,有时我们希望直接管理资源。在这种情况下,使用引用计数(reference count)(参见12.1.1节,第402页)就很有用了。

引用计数

引用计数的工作方式如下:

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

计数器不能直接作为HasPtr对象的成员。

解决此问题的一种方法是将计数器保存在动态内存中。当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器。

定义一个使用引用计数的类

类指针的拷贝成员“篡改”引用计数

析构函数不能无条件地delete ps——可能还有其他对象指向这块内存。析构函数应该递减引用计数,指出共享string的对象少了一个。如果计数器变为0,则析构函数释放ps和use指向的内存

13.3 交换操作

管理资源的类一般会定义自己的 swap 函数。

对于会重排元素顺序的算法来说,如果类定义了自己的swap函数,则调用该函数,否则使用标准库定义的swap,该函数会进行一次拷贝两次赋值。

编写我们自己的swap函数

与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。

一个典型的实现方法,由于swap的存在就是为了优化代码,我们将其声明为inline函数(参见6.5.2节,第213页)。

需要注意的是,每个swap调用应该都是未加限定的。即,每个调用都应该是swap,而不是std::swap。如果存在类型特定的swap版本,其匹配程度会优于std中定义的版本,原因我们将在16.3节(第616页)中进行解释。

在赋值运算符中使用swap

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

定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换(copy and swap)的技术。

13.4 拷贝控制示例

通常来说分配资源的类更需要拷贝控制,但资源管理并不是一个类需要定义自己的拷贝控制成员的唯一原因。一些类也需要拷贝控制成员的帮助来进行簿记工作或其他操作。

两个类命名为Message和Folder,分别表示电子邮件(或者其他类型的)消息和消息目录。每个Message对象可以出现在多个Folder中。但是,任意给定的Message的内容只有一个副本。这样,如果一条Message的内容被改变,则我们从它所在的任何Folder来浏览此Message时,都会看到改变后的内容。为了记录Message位于哪些Folder中,每个Message都会保存一个它所在Folder的指针的set,同样的,每个Folder都保存一个它包含的Message的指针的set。

Message类会提供save和remove操作,来向一个给定Folder添加一条Message或是从中删除一条Message。为了创建一个新的Message,我们会指明消息内容,但不会指出Folder。为了将一条Message放到一个特定Folder中,我们必须调用save。

当我们拷贝一个Message时,副本和原对象将是不同的Message对象,但两个Message都出现在相同的Folder中。因此,拷贝Message的操作包括消息内容和Folder指针set的拷贝。而且,我们必须在每个包含此消息的Folder中都添加一个指向新创建的Message的指针。

当我们销毁一个Message时,它将不复存在。因此,我们必须从包含此消息的所有Folder中删除指向此Message的指针。

当我们将一个Message对象赋予另一个Message对象时,左侧Message的内容会被右侧Message的内容所替代。我们还必须更新Folder集合,从原来包含左侧Message的Folder中将它删除,并将它添加到包含右侧Message的Folder中。

观察这些操作,我们可以看到,析构函数和拷贝赋值运算符都必须从包含一条Message的所有Folder中删除它。类似的,拷贝构造函数和拷贝赋值运算符都要将一个Message添加到给定的一组Folder中。我们将定义两个private的工具函数来完成这些工作。

拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在private的工具函数中完成。

Folder类也需要类似的拷贝控制成员,来添加或删除它保存的Message。

13.5 动态内存管理类

某些类需要在运行时分配可变大小的内存空间。这种类通常可以(并且如果它们确实可以的话,一般应该)使用标准库容器来保存它们的数据。

但是,这一策略并不是对每个类都适用;某些类需要自己进行内存分配。这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。

StrVec类的设计

使用一个allocator来获得原始内存(参见12.2.2节,第427页)。由于allocator分配的内存是未构造的,我们将在需要添加新元素时用allocator的construct成员在原始内存中创建对象。类似的,当我们需要删除一个元素时,我们将使用destroy成员来销毁元素。

每个StrVec有三个指针成员指向其元素所使用的内存:

  • elements,指向分配的内存中的首元素
  • first_free,指向最后一个实际元素之后的位置
  • cap,指向分配的内存末尾之后的位置

StrVec还有一个名为alloc的静态成员,其类型为allocator<string>

还有4个工具函数:

  • alloc_n_copy会分配内存,并拷贝一个给定范围中的元素。
  • free会销毁构造的元素并释放内存。
  • chk_n_alloc保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存。
  • reallocate在内存用完时为StrVec分配新内存。

StrVec类定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class StrVec
{
public:
StrVec() : // alloc成员默认初始化
elements(nullptr),
first_free(nullptr),
cap(nullptr)
{ }
StrVec(const StrVec &); // 拷贝构造
StrVec &operator=(const StrVec &); // 拷贝赋值
~StrVec(); // 析构函数

void push_back(const std::string &); // 拷贝元素并添加到容器末尾
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:
void chk_n_alloc()
{ if (size() == capacity()) reallocate(); } // 至少保证容器有一个空闲空间
std::pair<std::string *, std::string *> alloc_n_copy // 分配内存并拷贝范围内元素
(const std::string *, const std::string *);
void free(); // 销毁元素并释放内存
void reallocate(); // 重新分配内存并拷贝元素
static std::allocator<std::string> alloc; // 内存分配器
std::string *elements; // 数组首元素
std::string *first_free; // 数组最后一个元素之后的位置
std::string *cap; // 数组分配内存之后的位置
};

使用construct

若想使用原始内存,必须调用construct在此原始内存上构造一个对象。

1
2
3
4
5
6
void StrVec::push_back(const std::string &ele)
{
chk_n_alloc();
alloc.construct(first_free++, ele);
}

push_back首先调用chk_n_alloc确保有足够的原始内存来构造对象,然后用allocator来构造一个对象,construct的第一个参数指向内存的起始地址,其他参数用来决定调用哪个构造函数来构造对象,此例中调用string的拷贝构造函数。

alloc_n_copy成员

1
2
3
4
5
6
std::pair<std::string *, std::string *> StrVec::alloc_n_copy
(const std::string *begin, const std::string *end)
{
auto data = alloc.allocate(end - begin);
return {data, std::uninitialized_copy(begin, end, data)};
}

free成员

1
2
3
4
5
6
7
void StrVec::free()
{
if (elements == nullptr) return;
for (auto p = elements; p != first_free; ++p)
alloc.destroy(p);
alloc.deallocate(elements, cap - elements);
}

注意,deallocate函数不接受空指针,所以必须要先检查指针是否未空。另外,我们必须首先销毁对象,然后再释放内存。

拷贝控制成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
StrVec::StrVec(const StrVec &other)
{
auto data = alloc_n_copy(other.begin(), other.end());
elements = data.first;
first_free = cap = data.second;
}

StrVec &StrVec::operator=(const StrVec &other)
{
auto data = alloc_n_copy(other.begin(), other.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}

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

重载赋值运算符在释放内存之前,先调用alloc_n_copy复制一份数据出来,这样可以处理自赋值问题。

在重新分配内存的过程中移动而不是拷贝元素

在编写reallocate成员函数之前,我们稍微思考一下此函数应该做什么。它应该

  • 为一个新的、更大的string数组分配内存
  • 在内存空间的前一部分构造对象,保存现有元素
  • 销毁原内存空间中的元素,并释放这块内存

string的行为类似值,每个string对构成它的所有字符都会保存自己的一份副本。拷贝一个string必须为这些字符分配内存空间,而销毁一个string必须释放所占用的内存。

拷贝一个string就必须真的拷贝数据,因为通常情况下,在我们拷贝了一个string之后,它就会有两个用户。但是,如果是reallocate拷贝StrVec中的string,则在拷贝之后,每个string只有唯一的用户。一旦将元素从旧空间拷贝到了新空间,我们就会立即销毁原string。因此,拷贝这些string中的数据是多余的。

移动构造函数和std::move

有一些标准库类,包括string,都定义了所谓的“移动构造函数”。移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象。而且我们知道标准库保证“移后源”(moved-from)string仍然保持一个有效的、可析构的状态。

move的标准库函数,它定义在utility头文件中。当reallocate在新内存中构造string时,它必须调用move来表示希望使用string的移动构造函数,原因我们将在13.6.1节(第470页)中解释。如果它漏掉了move调用,将会使用string的拷贝构造函数。其次,我们通常不为move提供一个using声明(参见3.1节,第74页),原因将在18.2.3节(第706页)中解释。当我们使用move时,直接调用std::move而不是move。

reallocate成员

1
2
3
4
5
6
7
8
9
10
11
void StrVec::reallocate()
{
auto new_size = capacity() ? capacity() * 2 : 1;
auto new_elements = alloc.allocate(new_size);
for (size_t i = 0; i != size(); ++i)
alloc.construct(new_elements + i, std::move(*(elements + i)));
free();
cap = new_elements + new_size;
first_free = new_elements + size();
elements = new_elements;
}

注意这里需要处理默认初始化后第一次调用reallocate的情况,需要判断。

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int main()
{
StrVec vec;
vec.push_back("Hello");
vec.push_back("World");
vec.push_back("Test");

auto printVec = [](const StrVec &vec)
{
std::for_each(vec.begin(), vec.end(), [](const std::string &s)
{
std::cout << s << " ";
});
std::cout << std::endl;
};

std::cout << "Original vec: ";
printVec(vec);
std::cout << "Size: " << vec.size() << ", Capacity: " << vec.capacity() << std::endl;

// 测试拷贝构造函数
StrVec vecCopy(vec);
std::cout << "Copied vec: ";
printVec(vecCopy);

// 测试拷贝赋值操作符
StrVec vecAssign;
vecAssign = vec;
std::cout << "Assigned vec: ";
printVec(vecAssign);

// 添加更多元素以触发重新分配
vec.push_back("More");
vec.push_back("Strings");

std::cout << "Original vec after adding more elements: ";
printVec(vec);
std::cout << "Size: " << vec.size() << ", Capacity: " << vec.capacity() << std::endl;

return 0;
}

结果

13.6 对象移动

标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

在重新分配内存的过程中,从旧内存将元素拷贝到新内存是不必要的,更好的方式是移动元素。使用移动而不是拷贝的另一个原因源于IO类或unique_ptr这样的类。这些类都包含不能被共享的资源(如指针或IO缓冲)。因此,这些类型的对象不能拷贝但可以移动。

在旧版本的标准库中,容器中所保存的类必须是可拷贝的。但在新标准中,我们可以用容器保存不可拷贝的类型,只要它们能被移动即可。

13.6.1 右值引用

为了支持移动操作,新标准引入了一种新的引用类型——右值引用(rvalue reference)。所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。

一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。

左值持久;右值短暂

左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。

因为右值引用的对象即将被销毁,且没有其他用户,因此我们可以自由的接管该对象管理的资源。

变量是左值

因此,我们不能将一个右值引用绑定到一个右值引用类型的变量上

标准库move函数

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility中。move函数使用了我们将在16.2.6节(第610页)中描述的机制来返回给定对象的右值引用。

move调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move就意味着承诺:除了对rr1赋值或销毁它外,我们将不再使用它。在调用move之后,我们不能对移后源对象的值做任何假设。

我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。

与大多数标准库名字的使用不同,对move(参见13.5节,第469页)我们不提供using声明(参见3.1节,第74页)。我们直接调用std::move而不是move,其原因将在18.2.3节(第707页)中解释。

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

类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。

除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。

移动操作、标准库容器和异常

由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。我们将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。

一种通知标准库的方法是在我们的构造函数中指明noexcept。noexcept是新标准引入的,我们将在18.1.4节(第690页)中讨论更多细节。目前重要的是要知道,noexcept是我们承诺一个函数不抛出异常的一种方法。我们在一个函数的参数列表后指定noexcept。在一个构造函数中,noexcept出现在参数列表和初始化列表开始的冒号之间

我们必须在类头文件的声明中和定义中(如果定义在类外的话)都指定noexcept。

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

搞清楚为什么需要noexcept能帮助我们深入理解标准库是如何与我们自定义的类型交互的。

我们需要指出一个移动操作不抛出异常,这是因为两个相互关联的事实:首先,虽然移动操作通常不抛出异常,但抛出异常也是允许的;其次,标准库容器能对异常发生时其自身的行为提供保障。例如,vector保证,如果我们调用push_back时发生异常,vector自身不会发生改变。

现在让我们思考push_back内部发生了什么。类似对应的StrVec操作(参见13.5节,第466页),对一个vector调用push_back可能要求为vector重新分配内存空间。当重新分配vector的内存时,vector将元素从旧空间移动到新内存中,就像我们在reallocate中所做的那样(参见13.5节,第469页)。如我们刚刚看到的那样,移动一个对象通常会改变它的值。如果重新分配过程使用了移动构造函数,且在移动了部分而不是全部元素后抛出了一个异常,就会产生问题。旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。在此情况下,vector将不能满足自身保持不变的要求。

另一方面,如果vector使用了拷贝构造函数且发生了异常,它可以很容易地满足要求。在此情况下,当在新内存中构造元素时,旧元素保持不变。如果此时发生了异常,vector可以释放新分配的(但还未成功构造的)内存并返回。vector原有的元素仍然存在。为了避免这种潜在问题,除非vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。

如果希望在vector重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数可以安全使用。我们通过将移动构造函数(及移动赋值运算符)标记为noexcept来做到这一点。

移动赋值运算符

移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept。类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值

我们费心地去检查自赋值情况看起来有些奇怪。毕竟,移动赋值运算符需要右侧运算对象的一个右值。我们进行检查的原因是此右值可能是move调用的返回结果。与其他任何赋值运算符一样,关键点是我们不能在使用右侧运算对象的资源之前就释放左侧运算对象的资源(可能是相同的资源)。

移后源对象必须可析构

除了将移后源对象置为析构安全的状态之外,移动操作还必须保证对象仍然是有效的。一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。另一方面,移动操作对移后源对象中留下的值没有任何要求。因此,我们的程序不应该依赖于移后源对象中的数据。

例如,当我们从一个标准库string或容器对象移动数据时,我们知道移后源对象仍然保持有效。因此,我们可以对它执行诸如empty或size这些操作。但是,我们不知道将会得到什么结果。我们可能期望一个移后源对象是空的,但这并没有保证。

合成的移动操作

与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符。但是,合成移动操作的条件与合成拷贝操作的条件大不相同。

与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。

只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。

与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。但是,如果我们显式地要求编译器生成=default的(参见7.1.4节,第237页)移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。

移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝

如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用move来移动它们时也是如此。

值得注意的是,用拷贝构造函数代替移动构造函数几乎肯定是安全的(赋值运算符的情况类似)。

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

我们的HasPtr版本定义了一个拷贝并交换赋值运算符(参见13.3节,第459页),它是函数匹配和移动操作间相互关系的一个很好的示例。如果我们为此类添加一个移动构造函数,它实际上也会获得一个移动赋值运算符

现在让我们观察赋值运算符。此运算符有一个非引用参数,这意味着此参数要进行拷贝初始化(参见13.1.1节,第441页)。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动。

建议:更新三/五法则

所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作(参见13.1.4节,第447页)。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。

移动迭代器

StrVec的reallocate成员(参见13.5节,第469页)使用了一个for循环来调用construct从旧内存将元素拷贝到新内存中。作为一种替换方法,如果我们能调用uninitialized_copy来构造新分配的内存,将比循环更为简单。但是,uninitialized_copy恰如其名:它对元素进行拷贝操作。标准库中并没有类似的函数将对象“移动”到未构造的内存中。

新标准库中定义了一种移动迭代器(move iterator)适配器(参见10.4节,第358页)。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。

我们通过调用标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。

原迭代器的所有其他操作在移动迭代器中都照常工作。由于移动迭代器支持正常的迭代器操作,我们可以将一对移动迭代器传递给算法。特别是,可以将移动迭代器传递给uninitialized_copy

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

建议:不要随意使用移动操作

13.6.3 右值引用和成员函数

除了构造函数和赋值运算符之外,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式——一个版本接受一个指向const的左值引用,第二个版本接受一个指向非const的右值引用。

例如,定义了push_back的标准库容器提供两个版本:一个版本有一个右值引用参数,而另一个版本有一个const左值引用。假定X是元素类型,那么这些容器就会定义以下两个push_back版本

区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&。

右值和左值引用成员函数

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

有时,右值的使用方式可能令人惊讶:

在旧标准中,我们没有办法阻止这种使用方式。为了维持向后兼容性,新标准库类仍然允许向右值赋值。但是,我们可能希望在自己的类中阻止这种用法。在此情况下,我们希望强制左侧运算对象(即,this指向的对象)是一个左值。

我们指出this的左值/右值属性的方式与定义const成员函数相同(参见7.1.2节,第231页),即,在参数列表后放置一个引用限定符(reference qualifier)

引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。

对于&限定的函数,我们只能将它用于左值;对于&&限定的函数,只能用于右值

一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后

重载和引用函数

就像一个成员函数可以根据是否有const来区分其重载版本一样(参见7.3.2节,第247页),引用限定符也可以区分重载版本。而且,我们可以综合引用限定符和const来区分一个成员函数的重载版本。

当我们定义const成员函数时,可以定义两个版本,唯一的差别是一个版本有const限定而另一个没有。引用限定的函数则不一样。如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加

如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。


[C++]C++Primer Chapter 13
https://erlsrnby04.github.io/2024/09/22/C-C-Primer-Chapter-13/
作者
ErlsrnBy04
发布于
2024年9月22日
许可协议