跳转至

第十三章 拷贝控制

当定义一个类时,我们显示或隐式地指定在此类型的对象拷贝、移动、赋值、销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。

这些操作称为拷贝控制操作(copy control)

如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。


拷贝、赋值与销毁

拷贝构造函数

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

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

合成拷贝构造函数

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

每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝;如果数组元素是类类型,则使用元素的拷贝构造函数来逐个进行拷贝。

拷贝初始化

拷贝初始化通常使用拷贝构造函数来完成。拷贝初始化不仅在用=定义变量时会发生,在下列情况下也会发生:

  • 将一个对象作为实参传递给一个非引用类型的形参

  • 从一个返回类型为非引用类型的函数返回一个对象

  • 从花括号列表初始化一个数组中的元素或一个聚合类中的成员

某些类类型还会对它们所分配的对象使用拷贝初始化,如调用标准库容器的insert或push成员时。

拷贝赋值运算符

类可以控制其对象如何赋值:

Sales_data trans, accum;
trans = accum;    // 使用Sales_data的拷贝赋值运算符

重载赋值运算符

重载运算符(overloaded operator)本质上是函数,其名字是由operator关键字后接运算符符号组成。因此,赋值运算符就是一个名为operator=的函数。

重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。对于二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

拷贝赋值运算符接受一个与其所在类相同类型的参数:

class Foo {
public:
    Foo& operator=(const Foo&);    // 赋值运算符
};

合成拷贝赋值运算符

如果一个类未定义自己的拷贝赋值运算符,编译器会为它合成一个。合成的版本会将右侧运算对象的每个非static成员赋予左侧运算符对象的对应成员。对于数组类型的成员,逐个赋值数组元素。

析构函数

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

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

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

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

析构函数完成什么工作

在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。

在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序逆序销毁。

通常,析构函数释放对象在生存期分配的所有资源。

析构的部分是隐式的,不存在像构造函数中初始化列表的东西控制成员如何销毁。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。

Note:隐式销毁一个内置类型指针的成员不会delete它所指向的对象。

什么时候会调用析构函数

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

  • 变量在离开其作用域时被销毁

  • 当一个对象被销毁时,其成员被销毁

  • 容器或数组被销毁时,其元素被销毁

  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁

  • 对于临时对象,当创建它的完整表达式结束时被销毁

三/五法则

C++语言不要求为一个类定义所有的拷贝控制操作,但是这些操作通常应该看成一个整体。

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

当要决定一个类是否要定义自己的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。如果需要,那么几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。

原因分析如下:假如有一个类的构造函数中需要动态分配内存,在析构函数中释放动态内存。如果采用合成的拷贝和赋值操作,那么指向动态内存的指针就会被拷贝,当类的对象释放时,此指针指向的内存可能被释放两次,其结果是未定义的。

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

如果一个类为一个对象分配一个独有的、唯一的序号。这个类就需要一个拷贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要定义拷贝赋值运算符来避免将序号赋予目的对象。

此例子引入了第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。

使用=default

可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本。

阻止拷贝

当使用某些拷贝控制操作没有合理意义的情况下,定义类时必须采用某种机制加以阻止。比如iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。

定义删除的函数

在新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝。

删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。

在函数的参数列表后接=delete来通知编译器,将它定义为删除的:

struct NoCopy {
    NoCopy(const NoCopy&) = delete;    // 阻止拷贝
    NoCopy& operator=(const NoCopy&) = delete;    // 阻止赋值
};

还可以对任何函数指定=delete。

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

如果析构函数被删除,就无法销毁此类对象。对于一个删除了析构函数的类型(或者其某个成员删除了析构函数),编译器将不允许定义该类型的变量或创建该类型的临时对象。

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

对于某些类来说,编译器会把一些合成的成员定义为删除的函数。其规则是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

细节见书本。

private拷贝控制

在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private来阻止拷贝。为了阻止友元和成员函数访问私有成员,就不定义这些成员。

拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员。这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。

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

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

类的行为像一个指针,意味着拷贝一个对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。

详细讨论见书本和代码案例。

使用这样的例子解释:

class HasPtr {
public:
     // 准备定义构造函数、拷贝构造函数、拷贝赋值运算符、析构函数
private:
    std::string *ps;    // 管理的类外资源
    int i;
};

行为像值的类

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

为了实现类值的行为,HasPtr需要:

  • 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针

  • 定义一个析构函数来释放string

  • 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string

类值拷贝赋值运算符

一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧对象的现有成员就是安全的了。接着再将数据从临时对象拷贝到左侧运算对象的成员中。

这样就可以正确进行自赋值操作。

定义行为像指针的类

这种情况下,HasPtr仍然需要通过析构函数来释放string。但只有当最后一个指向string的HasPtr对象销毁时,它才可以释放string。

令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。shared_ptr类自己会记录有多少用户共享它所指向的对象,当没有用户使用对象时,shared_ptr类负责释放资源。

但是有时候我们希望直接管理资源,这种情况下,可以使用引用计数(reference count)

引用计数

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

  • 除了初始化对象外,每个构造函数(除了拷贝构造函数)还要创建一个引用计数,用来记录有多少个对象与正在创建的对象共享状态。计数器初始化为1。

  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器。

  • 析构函数递减计数器,如果变为0,则析构函数释放状态。

  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,则销毁状态。

引用计数应该保存在动态内存中,这样才能保证共享引用计数。

交换操作

管理资源的类通常还定义一个名为swap的函数。一些算法会在需要交换两个元素时调用swap。

如果一个类定义了自己的swap,那么算法将使用类自定义的版本。否则算法将使用标准库定义的swap。标准库定义的版本可能像这样:

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

但对于HasPtr这样管理外部资源的类,可以直接交换指针,而不是分配多一个副本。

string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;

swap函数的存在是为了优化代码。详细定义方法见书本。

在赋值运算符中使用swap

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

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

rhs是右侧运算对象的一个副本,它会在赋值运算符结束时被自动销毁。

这种技术自动处理了自赋值的情况且天然就是异常安全的。

对象移动

新标准的一个最主要的特性是可以移动而非拷贝对象的能力。在很多情况下,对象拷贝后就立即销毁了,这种情况下,使用移动而非拷贝会大幅提升性能。

使用移动而不是拷贝的另一个原因源于IO类或unique_ptr这样的类。这些类都包含不能被共享的资源(指针或IO缓冲)。因此,这些类的对象不能拷贝但可以移动。

右值引用

为了支持移动操作,新标准引入了一种新的类型——右值引用(rvalue reference)。右值引用必须绑定到右值——一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源移动到另一个对象中。

int i = 42;
int &r = i;    // 正确:r引用i
int &&rr = i;  // 错误:不能将一个右值引用绑定到一个左值上
int &&r2 = i * 42; // 正确:将rr2绑定到乘法结果上

左值持久,右值短暂

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

变量是左值

变量可以看作只有一个运算对象而没有运算符的表达式。类似其他任何表达式,变量表达式也有左值/右值属性。变量表达式都是左值。

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

int &&rr1 = 42;     // 正确:字面常量是右值
int &&rr2 = rr1;    // 错误:表达式rr1是左值!

标准库move函数

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。方法是通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用。

int &&rr3 = std::move(i); // OK

move调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。调用move就意味着承诺:除了对i赋值或销毁它外,我们将不再使用它。

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

移动的版本从给定对象“窃取”资源而不是拷贝资源。

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

除了完成资源的移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。

StrVec::StrVec(StrVec &&s) noexcpet // 移动构造函数不应抛出异常
 // 成员初始化器接管s中的资源
 : elements(s.elements), first_free(s.first_free), cap(s.cap)
{
    // 令s进入这样的状态——对其运行析构函数是安全的
    s.elements = s.first_free = s.cap = nullptr;
}

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

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

通知的方法是在构造函数中指明noexcept。

详细的解释见书本p474。

移动赋值运算符

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

StrVec& StrVec::operator=(StrVec &&rhs) noexcept
{
    // 直接检测自赋值
    if (this != &rhs) {
        free();    // 释放已有资源
        elements = rhs.elements; // 接管资源
        first_free = rhs.first_free;
        cap = rhs.cap;
        // 将rhs置于可析构状态
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}

移后源对象必须可析构

从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。

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

Warning

在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。

合成的移动操作

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

如果类定义了一个移动构造函数或一个移动赋值运算符,则该类的拷贝版本会被定义为删除的。

移动右值,拷贝左值

如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。

StrVec v1, v2;
v1 = v2;                    // v2是左值,使用拷贝赋值
StrVec getVec(istream&);    // getVec返回一个右值
v2 = getVec(cin);           // getVec(cin)是一个右值;使用移动赋值

更新三/五法则

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

右值引用和成员函数

除了构造函数和赋值运算符之外,成员函数也可以提供拷贝和移动的版本:一个版本有一个右值引用参数,而另一个版本有一个const左值引用。

void push_back(const X&);   // 拷贝:绑定到任意类型的X
void push_back(X&&);        // 移动:只能绑定到类型X的可修改的右值

右值和左值引用成员函数

我们可以强制左侧运算对象是一个左值。

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

class Foo {
public:
    Foo &operator=(const Foo&) &;   // 只能向可修改的左值赋值
};

引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。如果存在const限定符,引用限定符必须跟随在const限定符之后。