C++ Primer CH13 拷贝控制

C++ 的核心概念就是类。C++ 类定义构造函数来控制当类对象初始化时应该做什么。类同样可以定义函数来控制如何进行拷贝、赋值、移动和销毁。在这些方面 C++ 有别于其它语言,很多其它语言并不提供控制这些方面的基础设施。本章将介绍拷贝控制方面的知识,并且将引入新标准的两个概念:右值引用(rvalue reference)和移动操作(move operation)。

十四章则主要讲类类型如何进行操作符重载(operator overloading),使得类可以像内置类型一样使用内置操作符。其中值得注意的是调用操作符(function call operator),这样就可以像调用函数一样,对对象进行调用。并且将介绍新的库设施来简化不同类型的可调用对象,使其以统一的方式书写。最后还会讲特殊的成员函数——转换操作符(conversion operator),将定义从类对象到别的类型的转换。

写自己的面向对象或泛型需要对 C++ 有一个很好的理解,接下来几章的内容将是很高级的主题。

本章将学习类是如何控制当此类对象进行拷贝、赋值、移动和销毁时所做的事。类控制这些动作的成员函数分别是:拷贝构造函数(copy constructor)、移动构造函数(move constructor)、拷贝赋值操作符(copy-assignment operator)、移动赋值操作符(move-assignment operator)和析构函数(destructor)。

当我们定义类时,我们必须定义(不管是显式还是编译器生成)当对象被拷贝、移动、赋值或者销毁时发生的动作。其中拷贝、移动构造函数定义当对象由另外一个相同类型的对象进行初始化时发生的事。拷贝、移动赋值操作符定义当将类对象赋值给同类其它对象时发生的事。析构函数定义当对象消失时发生的事。以上这些操作被称为拷贝控制(copy control)。

如果类没有定义拷贝控制成员函数,编译器会自动定义这些函数。所以很多类可以忽略拷贝控制成员函数。然而,对于某些类来说,依赖于默认定义的拷贝控制会出问题。通常,实现拷贝控制操作最难的部分就是意识到什么时候需要自己定义这些函数。

拷贝控制是定义 C++ 类的非常重要的一部分。C++ 初学者通常对必须定义当发生拷贝、赋值、移动或销毁时的动作很疑惑。这个问题将变得更加复杂,因为,假如不定义的话,编译器会隐式定义这些函数,而且很有可能与我们的期望不符合。

13.1 拷贝、赋值和销毁

先从最基本的拷贝构造函数、拷贝赋值操作符和析构函数开始。

13.1.1 拷贝构造函数

只要是第一个参数是此类对象的引用并且所有其它参数都是默认值就是拷贝构造函数。其第一个参数必须是引用类型,并且几乎总是 const 引用类型,尽管也可以使用非 const 引用。拷贝构造函数将隐式用于不少场景下,因此,拷贝构造函数不应该是 explicit 的。

合成构造函数

当我们不定义拷贝构造函数时,编译器将自动合成一个。与默认构造函数不同的是,拷贝构造函数即使是在定义了其它构造函数时依然会合成。除非拷贝构造函数被定义为 delete 的(此时合成拷贝构造函数(synthesized copy constructor)将阻止拷贝该类的对象,尝试去调用将编译不通过),否则,合成构造函数将执行逐个成员拷贝(memberwiese copies)其参数的所有成员到被创建的对象中去,拷贝的顺序是按照在类中定义的顺序。

成员的类型决定了成员是如何进行拷贝的:类成员将调用其拷贝构造函数进行拷贝;内置类型成员则直接拷贝。尽管程序代码不能直接拷贝数组,编译器合成的拷贝构造函数通过拷贝其每一个元素来拷贝整个数组。类类型元素调用元素的拷贝构造函数进行拷贝。

拷贝初始化

当使用直接初始化时,编译器将使用常规的函数匹配来选择合适的构造函数以匹配程序员提供的参数。当使用拷贝初始化(copy initialization)时,编译器将右边操作数拷贝到正在创建的对象中,当需要时会对操作数进行转换。

拷贝初始化通常使用拷贝构造函数。如果一个函数有移动构造函数(move constructor),拷贝初始化在特定条件下将使用移动构造函数。

拷贝初始化不仅仅发生在定义变量时使用 =,也会出现在一下情形中:

  • 以非引用方式传递对象给函数;
  • 从函数中以非引用方式返回值;
  • 括弧初始化数组中的元素或者聚合类;

有些类会使用拷贝初始化来构建其分配的动态对象,如容器初始化时提供的元素将被拷贝初始化,用 push 和 insert 方式插入的元素亦是拷贝初始化的。相反,用 emplace 的则是直接初始化的。

参数和返回值

函数调用中以非引用形式传递的参数是拷贝初始化的。同样,函数返回非引用类型的值,返回值将被用于初始化调用位置处的结果临时量。拷贝构造函数将被用于初始化非引用类型的参数解释了为何拷贝构造函数本身的参数必须是引用。

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

在拷贝初始化中,编译器允许跳过拷贝/移动构造函数,而直接创建对象,这被称为拷贝消除(copy elision),也叫做返回值优化(Return Value Optimization(RVO)),调用函数直接在栈上给返回值分配内存,然后将其地址传递给被调用者,被调用者直接在这个空间上构建对象,从而消除了从里边往外边的拷贝需求。但是即便是编译器可以消除拷贝/移动构造函数的调用,这两个函数本身必须存在,并且时可访问的。

13.1.2 拷贝-赋值操作符

跟拷贝构造函数一样,如果没有定义拷贝赋值操作符,编译器会自动隐式合成一个。

重载赋值操作符介绍

重载操作符是一个函数,函数名字是 operator 后跟操作符的符号,重载赋值操作符就是 operator= ,操作符函数同样有返回值和参数列表。

重载操作符的参数表示操作符的操作数。其中一些操作符必须被定义为成员函数。当一个操作是成员函数,其做操作数被隐式绑定为 this 指针,而右操作数则作为参数显式传入。拷贝赋值操作符以相同类型的对象做为参数。

为了与内置类型的赋值操作符一致,重载赋值操作符通常返回其左操作数的引用。标准库要求存储在容器中的类对象有一个返回其左操作数的赋值操作符。

合成拷贝赋值操作符

如果类没有重载赋值操作符,编译器会合成拷贝赋值操作符(synthesized copy-assignment operator),与拷贝构造函数一样,一些类的合成拷贝赋值操作符会被定义为 delete ,从而禁用其赋值。否则,将使用每个所有非静态成员的拷贝赋值操作符将其从右边操作数赋值到左边操作数。数组成员以赋值每个成员的方式来赋值。合成的拷贝操作符返回其左操作数的引用。

13.1.3 析构函数

析构函数的作用域构造函数恰好相反:构造函数负责初始化所有非静态数据成员,并做一些别的工作;析构函数负责释放对象使用的资源,并且销毁其所有非静态数据成员。

析构函数的名字是 ~ 后接类名,其没有返回值,没有参数。由于其没有参数,所以不能被重载,一个类只有一个析构函数。

析构函数所做的事

析构函数分为两部份:函数体和析构部分。在构造函数中,成员先于函数体被初始化。在析构函数中,函数体先执行,然后成员按照在类定义中出现的相反顺序进行析构。

析构函数体可以执行类设计者需要对象在生命的最后阶段需要做的事,通常,析构函数体释放一个对象分配的资源。析构函数中没有对应于构造函数的初始化列表的部分来控制成员怎样被销毁。析构部分是隐式的,析构时发生什么将由成员的类型决定,类成员执行其自己的析构函数,内置类型成员则不执行任何析构操作。

特别是,动态分配的对象所返回的内置指针作为成员,在被析构时不会自动 delete 掉其指向的对象。与之不同的是,智能指针是类类型并且有析构函数。所以,智能指针将自动销毁其指向的动态对象。

何时调用析构函数

析构函数将在对象被销毁时自动调用,以下情况下将销毁对象:

  • 变量离开作用域时将被销毁;
  • 对象成员将在对象被销毁时跟着被销毁;
  • 容器或数组中的元素将在其容器被销毁时跟着被销毁;
  • 动态分配的对象将在 delete 时被销毁;
  • 临时量将在其所在的整个表达式结束时被销毁;

由于析构函数是自动运行的,所以,程序可以分配资源并且不必考虑何时应该释放资源。析构函数不会在引用或者指向对象的指针离开作用域时自动运行。

合成析构函数

编译器为所有没有定义析构函数的类合成析构函数。与拷贝构造函数和拷贝赋值操作符一样,有些类的合成析构函数被定义为不允许类对象被析构。否则,合成析构函数的函数体是空的。

所有的成员都在函数体执行后自动销毁。值得指出的是析构函数体是不直接销毁其成员本身的。成员销毁是在析构函数体后的隐式析构部分进行销毁的。析构函数体与逐个成员析构一起构成销毁对象的过程。

13.1.4 三/五法则

以上介绍的是三个基本的拷贝控制的操作:拷贝构造函数、拷贝赋值操作符和析构函数。在新标准下,类还可以定义移动构造函数和移动赋值操作符。并不需要总是定义所有这些操作,可以定义其中一两个。但是通常这些操作需要被当作一个整体,仅仅只需要其中一个的情况很少发生。

需要析构函数的类通常需要拷贝和赋值函数

首要原则是如果一个类需要析构函数,那么几乎可以肯定需要拷贝构造函数和拷贝赋值操作符。通常,考察一个类是否需要析构函数是一个容易的事。通常一个类申请了资源,它就会需要一个析构函数。

需要拷贝构造函数的通常意味着需要拷贝赋值操作符,反之一样

一些类可以只定义拷贝和赋值对象所需要的操作函数,比如:每个对象都有自己的唯一 id 。所以,第二原则就是:如果一个类需要拷贝构造函数,它一定需要拷贝赋值操作符,并且相反是一样的。然而,需要这两者并不一定意味着需要析构函数。

13.1.5 使用 =default

当显式要求编译器为我们合成拷贝控制成员时,将它们定义为 =default 形式。当将 =default 放在类体中的声明处时,合成的函数隐式是内联的。如果不希望合成的成员是内联的,可以将 =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;

13.1.6 禁用拷贝

某些类并不需要拷贝控制函数,如:iostream 类。这些类必须定义成禁用拷贝控制函数。不定义这些函数是不行的,因为,编译器会隐式合成这些函数。

将函数定义为 delete

在新标准下可以通过将函数定义为被删除的函数(deleted functions),来禁用拷贝。被删除的函数是被声明但是不能被使用的函数。通过在函数后放置 = delete 来定义被删除的函数。被删除的函数不是未定义的函数,被删除的函数依然出现在函数匹配的候选函数中。但是,当其被选为最优函数时,将产生编译错误。

=delete 只能放在类定义内的成员函数声明处,不能放在定义处。原因在于,调用成员函数通常需要知道成员函数的声明。而类外的定义处则是生成函数代码的地方。

所有成员函数都可以被定义为被删除的函数。

析构函数不应该被定义为被删除的函数

如果将析构函数定义为被删除的,那么将毫无机会来销毁对象了。编译器将不允许程序定义这种类的变量或者创建临时量。并且,不能定义其成员类型有被删除的析构函数的类的变量或者临时量。尽管不能定义变量或者临时量,但是可以动态分配这种对象,除了不能删除这种动态对象。如:

struct NoDtor {
    NoDtor() = default;
    ~NoDtor() = delete;
};
NoDtor *p = new NoDtor();
delete p; //错误!!不能删除这种对象

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

对于某些类,编译器合成的拷贝控制函数是被删除的函数:

  • 合成的析构函数是被删除的,如果类有一个成员,其析构函数是被删除的或者不可访问(private);
  • 合成的拷贝构造函数是被删除的,如果其成员自己的拷贝构造函数是被删除的或者不可访问。如果其成员的析构函数是被删除的或者不可访问;
  • 合成的拷贝赋值操作符是被删除的,如果其成员的拷贝赋值操作符是被删除的或者不可访问,或者类有一个 const 或引用成员;
  • 合成的默认构造函数是被删除的,如果其成员的析构函数是被删除的或者不可访问,或者有一个引用成员并且没有类内初始值,或者有一个 const 成员其类没有定义默认构造函数,并且没有类内初始值;

一句话概括,如果一个类的成员没有默认构造函数、拷贝、赋值、析构函数,那么对应的成员将是被删除的函数。

也许成员的析构函数被删除或不可访问将导致合成的默认和拷贝构造函数被定义为被删除的是令人诧异的,原因是,如果对象不能被销毁,那么它就不能被创建。

尽管可以给引用赋值,但是这样做将改变引用绑定的对象的值,如果为这样的类合成拷贝赋值操作符,那么左操作数将继续绑定相同的对象,它不会绑定到右操作数所绑定的对象。这种行为通常不是所希望的那样,所以,这种合成的拷贝赋值操作符将被定义为被删除的。

换一句话说,当一个类的成员不能被拷贝、赋值或销毁时,其对应的拷贝控制成员将被合成为被删除的。

private 拷贝控制

在新标准之前,类通过将拷贝构造函数和拷贝赋值操作符定义为 private 来阻止类被拷贝。由于拷贝构造函数和拷贝赋值操作符是 private 的,用户代码不能拷贝此对象。然而,友元和成员函数依然可以进行拷贝。为了阻止友元和成员对其进行拷贝,可以将拷贝控制成员声明为 private 的,并且不提供定义。只声明但是不定义一个成员函数是合法的,尝试去使用这个未定义的成员将导致链接错误。新标准中应当尽量使用 =delete 来阻止拷贝,而不是将成员声明为 private 的。

13.2 拷贝控制和资源管理

通常,如果类有自己管理的资源(动态分配的内存、网络、文件句柄等)肯定需要定义拷贝控制成员。这些类需要定义析构函数来释放资源。一旦其需要析构函数,那就意味着肯定需要拷贝构造函数和拷贝赋值操作符。

拷贝类对象有两个设计决定:以值的方式拷贝,以指针的方式拷贝。行为与值一样的类有其自己的状态,当拷贝这种对象时,拷贝后的对象与原始对象是互相独立的,对任何一方作出改变都不会影响到另外一方。行为与指针类似的类则共享状态,当拷贝这种对象时,原始对象和拷贝后的对象具有相同的底层数据。对任何一样的改变都会影响到另外一方。

通常,类直接拷贝内置类型成员,这些成员就是值,其行为与值是完全一样的。拷贝指针的不同方式将影响对象是值还是指针。

13.2.1 类像值一样

参考代码:use_has_ptr.cc

为了表现的像值一样,每个对象都有自己的一份资源拷贝。在 HasPtr 中,拷贝构造函数将拷贝 string 而不仅仅是字符串;析构函数将释放 string;拷贝赋值操作符将释放掉对象原有的 string 然后从右操作数中拷贝 string 过来。

以值方式进行拷贝赋值操作符

赋值操作符通常结合了析构函数和拷贝构造函数的行为。与析构函数一样,赋值操作将销毁其左边的操作数的资源。与拷贝构造函数一样,赋值将拷贝其右边操作数的数据。最重要的是,即便是对象给自己赋值行为亦必须是正确的。更重要的,应该保证赋值操作即便是在过程中抛出异常,其左操作数的状态是不变的。以先拷贝右边的值,然后再释放左边的数据,然后更新指针指向新分配的字符串,来保证安全进行赋值操作。

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
    auto newp = new string(*rhs.ps);
    delete ps;
    ps = newp;
    i = rhs.i;
    return *this;
}

在赋值操作符中,先执行构造函数的工作:newp 的初始化 HasPtr 的拷贝构造函数中的 ps 的初始化是一样的。与析构函数一样,然后 delete 掉 ps 当前指向的内存。剩下的就是将新的指针和 int 值拷贝到当前对象中。

当写赋值操作符的成员函数时,一个好的模式是先复制右操作数的资源到一个临时量中。在拷贝之后,就可以安全的释放掉左操作数中的数据。最后将临时量中的数据拷贝到左操作数中。

13.2.2 定义类像指针一样

参考代码:use_has_ptr2.cc

最好的将类定义为表现地与指针一样的方式是使用 shared_ptr 来管理资源。

偶尔希望直接管理资源时需要自己定义引用计数(reference count),倾向于将引用计数器放在动态内存中,每个对象保留一个指向这个计数器的指针。

其拷贝赋值操作符同时做了拷贝构造函数和析构函数的工作。赋值操作符将增加右操作数的引用计数(拷贝构造函数的工作)并减少左操作数的引用计数,当引用计数变为 0 的时候删除掉其内存(析构函数的工作)。

13.3 交换

除了定义拷贝控制成员外,需要管理资源的类通常还会定义 swap 函数。定义 swap 函数对于希望改变元素的顺序的算法来说特别重要。这种算法在需要交换元素的顺序时调用 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 函数并不是必须的,然而定义 swap 是对分配了资源的类的重大优化。

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

在 swap 函数中应该调用 swap 函数而不是 std::swap ,原因在于,不加修饰的 swap 可能会调用特定于成员类型的版本。如果有一个类型特定的 swap 函数,那个版本将会优于 std 下的 swap 函数,如此,如果有特定版本将会调用这个特定版本,如果没有将会调用标准版本。

在赋值操作符中使用 swap

定义了 swap 函数的类经常将 swap 用于定义赋值操作符,这种定义方式被称为拷贝-交换(copy and swap),将其左操作数与右操作数的一个拷贝进行交换。如:

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

需要注意的是 rhs 是按值传递的,因而,rhs 是调用拷贝构造函数构建的。而 swap 函数将这个本地变量与左操作数进行交换,最后这个本地变量被销毁,使得旧数据被释放。

使用拷贝交换的赋值操作是自动异常安全的,并且可以正确处理自我赋值。

13.4 拷贝控制实例

参考代码:message.cc

13.5 管理动态内存的类

一些类需要分配不定数量的内存,这种类最好是用库容器来装载数据。然而,这种策略并不适合于所有类,有些类需要自己分配数据。这些类通常定义自己的拷贝控制成员来管理内存分配。

参考代码:StrVec.cc

当调用 reallocate 时,并不需要将就数据中的字符串拷贝到新的数据中,原因是旧数据马上就要被丢弃了,此时应该移动而不是拷贝这个数据,这样就避免了给字符串重新分配内存。

移动构造函数和 std::move

通过移动构造函数可以将给定对象的资源移动到将要被构建的对象中去。移动构造函数将保证被移动的对象,其内部状态是可以被有效的析构的。如:指针变为空指针。

同时标准库中定义了一个新函数 std::move 在 utility 头文件中。使用 move 函数有两点需要注意:1. 如果希望调用移动构造函数需要调用 std::move 来告诉编译器使用移动构造函数,否则,将会使用拷贝构造函数。2. 调用 move 必须加上作用域限定符,显式告诉编译器我们调用的是哪个版本的函数;如:

alloc.construct(dest++, std::move(*elem++));

调用 move 将返回一个结果,这个结果将导致 construct 函数使用 string 的移动构造函数。由于使用了移动构造函数,由这些 string 管理的内存将不会被拷贝。相反,新构建的 string 将获取旧 string 的所有权。在移动元素之后,就可以将旧的元素给 free 掉了,移动构造函数将保证可以安全的将 string 析构掉或者被赋予新的值。

13.6 移动对象

新标准的一个特色就是可以移动对象而不是拷贝对象。当对象一旦拷贝完就销毁时,移动而不是拷贝对象将提供重大的性能提升。有一些类的资源是不可共享的,这种类型的对象可以被移动但不能被拷贝,如:IO 或 unique_ptr

在早期语言版本中,并没有什么方式可以直接移动对象。即便是在不需要拷贝的时候依然会执行拷贝。同样,在之前存储在容器中的对象必须是可拷贝的,在新标准中,可以在容器中使用不可拷贝但是可以移动的对象。

库容器、string 和 shared_ptr 支持拷贝和移动,IO 和 unique_ptr 则只能移动不能拷贝。

13.6.1 右值引用

为了支持移动操作,新标准引入了一种新的引用称之为右值引用(rvalue reference)。右值引用是必须绑定到右值的引用,右值引用使用 && 符号,相较于左值引用的 & 。右值引用有一个特性就是其只能绑定到即将销毁的对象上,因而,可以自由的移动右值引用对象中的资源。

左值表示对象的身份,而右值表示对象的值。不能将左值引用(lvalue reference)绑定到需要转型的值、字面量或者返回右值的表达式上。右值引用则刚好相反:可以将右值引用绑定到以上的值,但不能直接将右值引用绑定到左值。如:

int i = 42;
int &r = i;
int &&rr = i; //错误:不能将右值引用绑定到左值上
int &r2 = i * 42; //错误:不能将左值引用绑定到右值上
//可以将 const 左值引用绑定到任何类型的值上(const/非 const 的左/右值)
const int &r3 = i * 42;
int &&rr2 = i * 42; //将右值引用绑定到右值上

返回左值引用的函数和赋值、下标操作、解引用和前缀自增/自减操作符都是返回左值的表达式,可将左值引用绑定到这些表达式的结果中。

返回非引用类型的函数与算术、关系、位操作和后缀自增/自减的操作符都是返回右值的表达式,可将 const 左值引用和右值引用绑定到这种表达式上。

左值持久;右值短暂

左值具有持久的状态,而右值要么是字面量,要么就是临时量。由于右值引用只能绑定到临时量上,可知其绑定的对象是即将被销毁的,对象是被独占的,没有其它对象使用它。这些事实告诉我们使用右值引用的代码可以自由的移动右值引用所绑定对象的资源。

右值引用绑定即将被销毁的对象,因而,可以从右值引用所绑定对象中自由的移动资源。

变量是左值

一个变量就是一个表达式,其只有一个操作数而没有操作符。变量表达式是左值。因而,不能将右值引用绑定到一个定义为右值引用的变量上。如:

int &&rr1 = 42;
int &&rr2 = rr1; //错误:rr1 是左值,因而不能这样定义

一个变量就是一个左值;不能直接将右值引用绑定到一个变量上,即使这个变量被定义为右值引用类型也不可以。

move库函数

可以显式将左值强转为对应的右值引用类型,也可以通过调用 move 库函数来获取绑定到左值的右值引用,其被定义在 utility 头文件中。如:

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

调用 move 告知编译器,以右值方式对象一个左值。特别需要了解的是调用 move 将承诺:不会再次使用 rr1 ,除非是赋值或者析构。当调用了 move 之后,不能对这个对象做任何值上的假设。可以析构或赋值给移动后的对象,但在此之前不能使用其值。

使用 move 的代码应该使用 std::move ,而不是 move,这样做可以避免潜在的名字冲突。

13.6.2 移动构造函数和移动赋值

为了让我们自己的类可以执行移动操作,需要定义移动构造函数和移动赋值操作符。这些成员类似于对应的拷贝赋值操作,但是他们将从给定对象中偷取资源而不是复制。

与拷贝构造函数一样,移动构造函数也有一个引用类型的初始参数。不同于拷贝构造函数的是,移动构造函数的是右值引用。与拷贝构造函数一样,其它的参数必须有默认实参。

除了移动资源,移动构造函数需要保证移动后的对象的状态是析构无害的。特别是,一旦资源被移动后,原始对象就不在指向移动了的资源,这些所有权被转移给了新创建的对象。如:

StrVec::StrVec(StrVec &&s) noexcept :
    elements(s.elements), first_free(s.first_free), cap(s.cap)
{
    s.elements = s.first_free = s.cap = nullptr;
}

与拷贝构造函数不同,移动构造函数并不会分配新资源;其将攫取参数中的内存,在此之后,构造函数体将参数中的指针都设置为 nullptr,当一个对象被移动后,这个对象依然存在。最后移动后的对象将被析构,意味着析构函数将在此对象上运行。析构函数将释放其所拥有的资源,如果没有将指针设置为 nullptr 的,就会将移动了的资源给释放掉。

移动操作,库容器和异常

移动操作通常不必自己分配资源,所以移动操作通常不抛出任何异常。当我们写移动操作时,由于其不会抛出异常,我们应当告知编译器这个事实。除非编译器知道这个事实,它将必须做额外的工作来满足移动构造操作将抛出异常。

通过在函数参数列表后加上 noexcept ,在构造函数时则,noexcept 出现在参数列表后到冒号之间,来告知编译器一个函数不会抛出异常。如:

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

必须同时在类体内的声明处和定义处同时指定 noexcept。

移动构造函数和移动赋值操作符,如果都不允许抛出异常,那么就应该被指定为 noexcept。

告知移动操作不抛出异常是由于两个不相关的事实:第一,尽管移动操作通常不抛出异常,它们可以这样做。第二,有些库容器在元素是否会在构建时抛出异常有不同的表现,如:vector 只有在知道元素类型的移动构造函数不会抛出异常才使用移动构造函数,否则将必须使用拷贝构造函数;

移动赋值操作符

代码如:

StrVec& StrVec::operator=(StrVec &&rhs) noexcept
{
    if (this == &rhs)
        return *this;
    free();
    elements = rhs.elements;
    first_free = rhs.first_free;
    cap = rhs.cap;

    rhs.elements = rhs.first_free = rhs.cap = nullptr;
    return *this;
}

移动赋值操作符不抛出异常应当用 noexcept 修饰,与拷贝赋值操作符一样需要警惕自赋值的可能性。移动赋值操作符同时聚合了析构函数和移动构造函数的工作:其将释放左操作数的内存,并且占有右操作数的内存,并将右操作数的指针设为 nullptr。

移动后的对象必须是可以析构的

移动对象并不会析构那个对象,有时在移动操作完成后,被移动的对象将被销毁。因而,当我们写移动操作时,必须保证移动后的对象的状态是可以析构的。StrVec 通过将其指针设置为 nullptr 来满足此要求。

除了让对象处于可析构状态,移动操作必须保证对象处于有效状态。通常来说,有效状态就是可以安全的赋予新值或者使用在不依赖当前值的方式下。另一方面,移动操作对于遗留在移动后的对象中的值没有什么特别要求,所以,程序不应该依赖于移动后对象的值。

例如,从库 string 和容器对象中移动资源后,移动后对象的状态将保持有效。可以在移动后对象上调用 empty 或 size 函数,然而,并不保证得到的结果是空的。可以期望一个移动后对象是空的,但是这并不保证。

以上 StrVec 的移动操作将移动后对象留在一个与默认初始化一样的状态。因而,这个 StrVec 的所有操作将与默认初始化的 StrVec 的操作完全一样。其它类,有着更加复杂的内部结构,也许会表现的不一致。

在移动后操作,移动后对象必须保证在一个有效状态,并且可以析构,但是用户不能对其值做任何假设。

合成移动操作

编译器会为对象合成移动构造函数和移动赋值操作符。然而,在什么情况下合成移动操作与合成拷贝操作是十分不同的。

与拷贝操作不同的,对于某些类来说,编译器根本不合成任何移动操作。特别是,如果一个类定义自己的拷贝构造函数、拷贝赋值操作符或析构函数,移动构造函数和移动赋值操作符是不会合成的。作为结果,有些类是没有移动构造函数或移动赋值操作符。同样,当一个类没有移动操作时,对应的拷贝操作将通过函数匹配被用于替代移动操作。

编译器只会在类没有定义任何拷贝控制成员并且所有的非 static 数据成员都是可移动的情况下才会合成移动构造函数和移动赋值操作符。编译器可以移动内置类型的成员,亦可以移动具有对应移动操作的类类型成员。

移动操作不会隐式被定义为删除的,而是根本不定义,当没有移动构造函数时,重载将选择拷贝构造函数。当用 =default 要求编译器生成时,如果编译器无法移动所有成员,将会生成一个删除的移动操作。被删除的函数不是说不能被用于函数重载,而是说当其是重载解析时最合适的候选函数时,将是编译错误。

  • 与拷贝构造函数不同,当类有一个成员定义了自己的拷贝构造函数,但是没有定义移动构造函数时使用拷贝构造函数。当成员没有定义自己的拷贝操作但是编译器无法为其合成移动构造函数时,其移动构造函数被定义为被删除的。对于移动赋值操作符是一样的;
  • 如果类有一个成员其移动构造函数或移动操作符是被删除的或不可访问的,其移动构造函数或移动赋值操作符被定义为被删除的;
  • 与拷贝构造函数一样,如果其析构函数是被删除的或不可访问的,移动构造函数被定义为被删除的;
  • 与拷贝赋值操作符一样,如果其有一个 const 或引用成员,移动赋值操作被定义为删除的;

如果一个类定义自己的移动构造函数或移动赋值操作符,那么合成的拷贝构造函数或拷贝赋值操作符都将被定义为被删除的。

右值移动,左值拷贝

当一个类既有移动构造函数又有拷贝构造函数,编译器使用常规的函数匹配来决定使用哪个构造函数。拷贝构造函数通常使用 const StrVec 引用类型作为参数,因而,可以匹配可以转为 StrVec 类型的对象参数。而移动构造函数则使用 StrVec && 作为参数,因而,只能使用非 const 的右值。如果调用拷贝形式的,需要将参数转为 const 的,而移动形式的却是精确匹配,因而,右值将调用移动形式的。

右值在无法被移动时进行拷贝

如果一个类有拷贝构造函数,但是没有定义移动构造函数,在这种情况下编译不会合成移动构造函数,意味着类只有拷贝构造函数而没有移动构造函数。如果一个类没有移动构造函数,函数匹配保证即便是尝试使用 move 来移动对象时,它们依然会被拷贝。

class Foo {
public:
    Foo() = default;
    Foo(const Foo&); //拷贝构造函数
};
Foo x;
Foo y(x);  //拷贝构造函数;x 是左值
Foo z(std::move(x)); //拷贝构造函数;因为没有移动构造函数

调用 move(x) 时返回 Foo&& ,Foo 的拷贝构造函数是可行的,因为可以将 Foo&& 转为 const Foo& ,因而,使用拷贝构造函数来初始化 z 。

使用拷贝构造函数来替换移动构造函数通常是安全的,对于赋值操作符来说是一样的。拷贝构造符合移动构造函数的先决条件:它将拷贝给定的对象,并且不会改变其状态,这样原始对象将保持在有效状态内。

拷贝和交换赋值操作与移动

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;
    }
};

赋值操作符的参数是非引用类型的,所以参数是拷贝初始化的。根据参数的类型,拷贝初始化可能使用拷贝构造函数也可能使用移动构造函数。左值将被拷贝,右值将被移动。因而,这个移动操作符既是拷贝赋值操作符又是移动赋值操作符。如:

hp = hp2;
hp = std::move(hp2);

所有五个拷贝控制成员应该被当做一个整体:通常,如果一个类定义了其中任何一个操作,它通常需要定义所有成员。有些类必须定义拷贝构造函数,拷贝赋值操作符和析构函数才能正确工作。这种类通常有一个资源是拷贝成员必须拷贝的,通常拷贝资源需要做很多额外的工作,定义移动构造函数和移动赋值操作符可以避免在不需要拷贝的情况的额外工作。

移动迭代器

在新标准中,定义了移动迭代器(move iterator)适配器。移动迭代器通过改变迭代器的解引用操作来适配给定的迭代器。通常,迭代器解引用返回元素的左值引用,与其它迭代器不同,解引用移动迭代器返回右值引用。调用函数 make_move_iterator 将常规迭代器变成移动迭代器,移动迭代器的操作与原始迭代器操作基本一样,因而可以将移动迭代器传给 uninitialized_copy 函数。如:

uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()));

值得一提的是标准库没有说哪些算法可以使用移动迭代器,哪些不可以。因为移动对象会破坏原始对象,所以将移动迭代器传给那些不会在移动后访问其值的算法才合适。

慎用移动操作:由于移动后的对象处于中间状态,在对象上调用 std::move 是很危险的。当调用 move 后,必须保证没有别的用户使用移动后对象。

谨慎克制的在类内使用 move 可以提供重大的性能提升,在用户代码中使用 move 则更可能导致难以定位的 bug,相比较得到的性能提升是不值得的。

在类实现代码外使用 std::move,必须是在确实需要移动操作,并且保证移动是安全的。

13.6.3 右值引用和成员函数

除了构造函数和赋值操作符外提供拷贝和移动版本亦会受益。这种可以移动的成员函数中一个使用 const 左值引用,另一个使用非 const 右值引用。如:

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

可以传递任何可以转换为类型 X 的对象给拷贝版本,这个版本从参数中拷贝数据。只能将非 const 右值传递给移动版本。此版本比拷贝版本更好的匹配非 const 右值(精确匹配),因而,在函数匹配中将是更优的,并且可以自由的从参数中移动资源。

通常上面这种重载方式不会使用 const X&&X& 类型的参数,原因在于移动数据要求对象是非 const 的,而拷贝数据则应该是 const 的。

以拷贝或移动的方式对函数进行重载,常用的做法是一个版本使用 const T& 为参数,另外一个版本使用 T&& 为参数。

右值与左值引用的成员函数

有些成员函数是只允许左值调用的,右值是不能调用的,如:在新标准前可以给两个字符串拼接的结果赋值:s1 + s2 = "wow!"; ,在新标准中可以强制要求赋值操作符的左操作数是左值,通过在参数列表后放置引用修饰符(reference qualifier)可以指示 this 的左值/右值特性。如:

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

引用修饰符可以是 & 或者 && 用于表示 this 指向左值或右值。与 const 修饰符一样,引用修饰符必须出现在非 static 成员函数的声明和定义处。被 & 修饰的函数只能被左值调用,被 && 修饰的函数只能被右值调用。

一个函数既可以有 const 也可以有引用修饰符,在这种情况下,引用修饰符在 const 修复符的后面。如:

class Foo {
public:
    Foo someMem() const &;
};

重载带引用修饰符的成员函数

可以通过函数的引用修饰符进行重载,这与常规的函数重载是一样的,&& 可以在可修改的右值上调用,const & 可以在任何类型的对象上调用。如:

class Foo {
public:
    Foo sorted() &&; //可以在可修改的右值上调用
    Foo sorted() const &; //可以在任何类型的 Foo 上调用
};

当定义具有相同名字和相同参数列表的成员函数时,必须同时提供引用修饰符或者都不提供引用修饰符,如果只在其中一些提供,而另外一些不提供就是编译错误。如:

class Foo {
public:
    Foo sorted() &&;
    Foo sorted() const; //错误:必须提供引用修饰符

    //全不提供引用修饰符是合法的
    using Comp = bool(const int&, const int&);
    Foo sorted(Comp*);
    Foo sorted(Comp*) const;
};

关键术语

  • 拷贝-交换(copy and swap):一种书写赋值操作符的技术,先将右操作数拷贝到参数中,然后调用 swap 将其与左操作数进行交换;
  • 拷贝赋值操作符(copy-assignment operator):拷贝赋值操作符与本类的 const 引用对象作为参数,返回对象的引用。如果类不定义拷贝赋值操作符,编译器将合成一个;
  • 拷贝构造函数(copy constructor):将新对象初始化为本类的另一个对象的副本的构造函数。拷贝构造函数将在以非引用方式传递参数或从函数中返回时默认调用。如果类不定义的话,编译器将合成一个;
  • 拷贝控制(copy control):用于控制对象被拷贝、移动、赋值和销毁时应当做什么的成员函数。如果类不定这些函数,编译器将在合适的时候合成它们;
  • 拷贝初始化(copy initialization):使用 = 形式的初始化,或者当传递参数、按值形式返回值,或者初始化数组或聚合类时,将进行拷贝初始化。拷贝初始化将根据初始值是左值还是右值,使用拷贝构造函数或者移动构造函数;
  • 被删除的函数(deleted function):不被使用的函数,通过 =delete 来删除函数。使用被删除的函数是告知编译器在进行函数匹配时,如果匹配到被删除的函数就报编译器错误;
  • 析构函数(destructor):当对象离开作用域时调用的特殊成员函数来清理对象。编译器自动销毁每个数据成员,类成员通过调用其析构函数进行销毁,内置类型或符合类型将不做任何析构操作,特别是指向动态对象的指针不会被自动 delete;
  • 逐个成员拷贝/赋值(memberwise copy/assign):合成的拷贝/移动构造函数和拷贝/移动赋值操作符的运作方式。依次对所有的数据成员,拷贝/移动构造函数通过从参数中拷贝/移动对应的成员进行初始化;拷贝/移动赋值操作符则依次对右操作数的各个成员进行拷贝/移动赋值;内置类型的成员是直接进行初始化或赋值的。类类型成员则调用对应的拷贝/移动构造函数或拷贝/移动赋值操作符;
  • move 函数(move function):用于将左值绑定到右值引用的库函数。调用 move 将隐式保证不会使用移动后的对象值,唯一的操作是析构或者赋予新值;
  • 移动赋值操作符(move-assignment operator):参数是右值引用的赋值操作符版本。通常移动赋值操作符将其右操作数的数据移动到左操作数。在赋值后,必须保证可以安全的析构掉右操作数;
  • 移动构造函数(move constructor):以右值引用为参数的构造函数。移动构造函数将参数中的数据移动到新创建的对象中。在移动后,必须保证可以安全地析构掉右操作数;
  • 移动迭代器(move iterator):迭代器适配器,包装一个迭代器,当其解引用时返回右值引用;
  • 右值引用(rvalue reference):对即将被销毁的对象的引用;

Leave a Reply

Your email address will not be published. Required fields are marked *