拷贝控制
拷贝控制操作:
- 拷贝构造函数和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。
- 拷贝赋值运算符和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。
- 析构函数定义了当此类型对象销毁时做什么。
13.1拷贝、赋值与销毁
13.1.1拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数:
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo &); // 拷贝构造函数
// ...
};
拷贝构造函数在几种情况下都会被隐式地使用,因此,通常不应该是
explicit
的。
合成拷贝构造函数
对于某些类来说,合成拷贝构造函数用来阻止拷贝该类类型的对象。而一般情况下,合成的拷贝构造函数会使编译器从给定对象中依次将每个非
static
成员拷贝到正在创建的对象中。
拷贝初始化
string dots(10, '.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化,将右侧运算对象拷贝到正在创建的对象中。
string null_book = "9-999-99999-9"; // 拷贝初始化,将右侧运算对象拷贝到正在创建的对象中。
string nines = string(100, '9'); // 拷贝初始化,将右侧运算对象拷贝到正在创建的对象中。
直接初始化实际上是要求编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数。拷贝初始化则要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化通常使用拷贝构造函数来完成。但是,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数来完成。
拷贝初始化不仅在用
=
定义变量时会发生,其他情况:
- 将一个对象作为实参传递给一个非引用类型的形参。
- 从一个返回类型为非引用类型的函数返回一个对象。
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
某些类类型还会对它们所分配的对象使用拷贝初始化。例如,当初始化标准库容器或是调用其
insert
或push
成员时,容器会对其元素进行拷贝初始化。与之相对,用emplace
成员创建的元素都进行直接初始化。
参数和返回值
拷贝构造函数被用来初始化非引用类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功:为了调用拷贝构造函数,必须拷贝它的实参,但为了拷贝实参,又需要调用拷贝构造函数,如此无限循环。
编译器可以绕过拷贝构造函数
在拷贝初始化的过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。即,编译器被允许将下面的代码:
string null_book = "9-999-99999-9"; // 拷贝初始化
改写为:
string null_book("9-999-99999-9"); // 编译器略过了拷贝构造函数
但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的。
13.1.2拷贝赋值运算符
重载赋值运算符
重载运算符本质上是函数,其名字由
operator
关键字后接表示要定义的运算符的符号组成。
重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this
参数。对于一个二元运算符,其右侧运算对象作为显式参数传递。
class Foo {
public:
// 为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
Foo &operator=(const Foo &);
// ...
};
合成拷贝赋值运算符
对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。而一般情况下,它会将右侧运算对象的每个非
static
成员赋予左侧运算对象的对应成员。
13.1.3析构函数
析构函数释放对象使用的资源,并销毁对象的非
static
数据成员。
析构函数完成什么工作
在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
隐式销毁一个内置指针类型的成员不会
delete
它所指向的对象。与普通指针不同,智能指针是类类型,所以具有析构函数。因此,智能指针成员在析构阶段会被自动销毁。
什么时候会调用析构函数
{ // 新作用域
// p和p2指向动态分配的对象
Sales_data *p = new Sales_data; // p是一个内置指针
auto p2 = make_shared<Sales_data>(); // p2是一个shared_ptr
Sales_data item(*p); // 拷贝构造函数将*p拷贝到item中
vector<Sales_data> vec; // 局部对象
vec.push_back(*p2); // 拷贝p2指向的对象
delete p; // 对p指向的对象执行析构函数
}
// 退出局部作用域:对item、p2和vec调用析构函数。
// 销毁p2会递减其引用计数,如果引用计数变为0,对象被释放。
// 销毁vec会销毁它的元素。
合成析构函数
对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数的函数体就为空。
认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
13.1.4三/五法则
需要析构函数的类也需要拷贝和赋值操作
类如果分配了动态内存,合成的析构函数不会
delete
一个指针数据成员。因此,需要定义一个析构函数来释放分配的内存。通常,如果一个类需要一个析构函数,几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
class HasPrt {
public:
HasPrt(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
~HasPtr() {
// 错误:HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符。
// 构造函数中分配的内存将在对象销毁时被释放,但是由于使用了合成的拷贝构造函数和
// 拷贝赋值运算符,这些函数简单拷贝指针成员,这意味着多个对象可能指向相同的内存。
delete ps;
}
};
// HasPtr是传值参数,所以将被拷贝。
HasPtr f(HasPtr hp) {
HasPtr ret = hp; // 拷贝给定的HasPtr
// 处理ret
// ret和hp被销毁,由于包含相同的指针值,会导致此指针
// 被delete两次,会造成未定义的行为。
return ret;
}
需要拷贝操作的类也需要赋值操作,反之亦然
虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。例如,一个类为每个对象分配一个独有的、唯一的序号。
这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是,这个类不需要自定义析构函数。
13.1.5使用=default
可以通过将拷贝控制成员定义为
=default
来显式地要求编译器生成合成的版本。
当在类内用=default
修饰成员的声明时,合成的函数将隐式地声明为内联的。如果不希望合成的成员是内联函数,应该只对成员的类外定义使用=default
。
13.1.6阻止拷贝
iostream
类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。
定义删除的函数
在新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。虽然声明了它们,但不能以任何方式使用它们。
与=default
不同,=delete
必须出现在函数第一次声明的时候。这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码,因此=default
直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的,以便禁止试图使用它的操作。
另一个不同之处是,可以对任何函数指定=delete
,在希望引导函数匹配过程时,删除函数有时也是有用的。
析构函数不能是删除的成员
如果析构函数被删除,就无法销毁此类型的对象了。对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类型的临时对象。而且,如果一个类有某个成员的类型删除了析构函数,也不能定义该类的变量或临时对象。
对于删除了析构函数的类型,可以动态分配这种类型的对象,但是,不能释放这些对象。
合成的拷贝控制成员可能是删除的
对某些类来说,编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或不可访问的(例如,是
private
的),则类的合成析构函数被定义为删除的。- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个
const
的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。虽然可以将一个新值赋予一个引用成员,但这样做改变的是引用指向的对象的值,而不是引用本身。如果为这样的类合成拷贝赋值运算符,则赋值后,左侧运算对象仍然指向与赋值前一样的对象,而不会与右侧对象指向相同的对象。因此,这种行为并不是所期望的。- 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个
const
成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数将被定义为删除的。
private拷贝控制
在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为
private
的来阻止拷贝。
class PrivateCopy {
// 无访问说明符,接下来的成员默认为private的。拷贝控制成员是private的,因此普通用户代码无法访问。
// 但是,友元和成员函数仍旧可以拷贝对象,为了阻止其拷贝,此时不定义拷贝控制成员。
PrivateCopy(const PrivateCopy &);
PrivateCopy &operator=(const PrivateCopy &);
// ...
public:
PrivateCopy() = default;
~PrivateCopy(); // 用户可以定义此类型的对象,但无法拷贝它们。
};
声明但不定义一个成员函数是合法的,对此只有一个例外,后面会介绍。试图访问一个未定义的成员将导致一个链接时错误。通过声明(但不定义)
private
的拷贝构造函数,可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误。
13.2拷贝控制和资源管理
通常,管理类外资源的类必须定义拷贝控制成员。
为了定义这些成员,首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
类的行为像一个值,意味着它应该也有自己的状态。当拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
行为像指针的类则共享状态。当拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
13.2.1行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。
class HasPtr {
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
// 对ps指向的string,每个HasPtr对象都有自己的拷贝。
HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) {}
HasPtr &operator=(const HasPtr &);
~HasPtr() {
delete ps;
}
private:
std::string *ps;
int i;
};
类值拷贝赋值运算符
赋值运算符通常组合了析构函数和构造函数的操作:赋值操作会销毁左侧运算对象的资源,会从右侧运算对象拷贝数据。但是,这些操作是以正确的顺序执行的,即使将一个对象赋予它自身,也保证正确。而且,如果可能,编写的赋值运算符还应该是异常安全的:当异常发生时能将左侧运算对象置于一个有意义的状态。
// 通过先拷贝右侧运算对象,可以处理自赋值的情况,并能保证在异常
// 发生时代码也是安全的。
HasPtr &HasPtr::operator=(const HasPtr &rhs) {
auto newp = new std::string(*rhs.ps); // 拷贝底层string
delete ps; // 释放旧内存
ps = newp; // 从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; // 返回本对象
}
为了说明防范自赋值操作的重要性,考虑如下代码:
HasPtr &HasPtr::operator=(const HasPtr &rhs) {
delete ps; // 释放对象指向的string
// 如果rhs和*this是同一个对象,就将从已释放的内存中拷贝数据。
ps = new std::string(*rhs.ps);
i = rhs.i;
return *this;
}
好的方法是在销毁左侧运算对象资源之前拷贝右侧运算对象。
13.2.2定义行为像指针的类
令一个类展现类似指针的行为的最好方法是使用
shared_ptr
来管理类中的资源。
但是,有时希望直接管理资源。在这种情况下,使用引用计数就很有用了。
引用计数
引用计数的工作方式如下:
- 每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
- 析构函数递减计数器。如果计数器变为0,则析构函数释放状态。
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,拷贝赋值运算符就必须销毁状态。
定义一个使用引用计数的类
class HasPtr {
public:
// 构造函数分配新的string和新的计数器,将计数器置为1。
HasPtr(const std::string &s = std::string()) :
ps(new std::string(s)), i(0), use(new std::size_t(0)) {}
// 拷贝构造函数拷贝所有三个数据成员,并递增计数器。
HasPtr(const HasPtr &p) :
ps(p.ps), i(p.i), use(p.use) {
++*use;
}
HasPtr& operator=(const HasPtr &);
~HasPtr();
private:
std::string *ps;
int i;
// 计数器不能直接作为对象的成员,否则无法正确更新。
std::size_t *use; // 用来记录有多少个对象共享*ps的成员
};
// 析构函数不能无条件地delete ps,可能还有其他对象指向这块内存。
HasPtr::~HasPtr() {
if (--*use == 0) { // 如果引用计数变为0
delete ps; // 释放string内存
delete use; // 释放计数器内存
}
}
// 赋值运算符必须处理自赋值。通过先递增rhs中的计数然后再递减左侧运算对象中的
// 计数来实现这一点。通过这种方法,当两个对象相同时,在检查ps(及use)是否应该
// 释放之前,计数器就已经被递增过了。
HasPtr &HasPtr::operator=(const HasPtr &rhs) {
++*rhs.use; // 递增右侧运算对象的引用计数
if (--*use == 0) { // 然后递减本对象的引用计数
delete ps; // 如果没有其他用户
delete use; // 释放本对象分配的成员
}
ps = rhs.ps; // 将数据从rhs拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}
13.3交换操作
管理资源的类通常还定义一个名为
swap
的函数。对于那些与重排元素顺序的算法一起使用的类,定义swap
是非常重要的。这类算法在需要交换两个元素时会调用swap
。
如果未定义,那么算法将使用标准库定义的swap
。虽然不知道具体实现,但是理论上,为了交换两个对象需要进行一次拷贝和两次赋值:
// 交换两个类值HasPtr的对象:将原来v1中的string拷贝了两次。
HasPtr temp = v1;
v1 = v2;
v2 = temp;
// 理论上,这些内存分配都是不必要的。更希望swap交换指针,而不是
// 分配string的新副本:
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;
编写自己的swap函数
class HasPtr {
// 定义为friend,以便能访问private数据成员
friend void swap(HasPtr &, HasPtr &);
// 其他成员和类值的HasPtr中的一样
};
// 由于swap的存在就是为了优化代码,因此声明为inline函数。
inline
void swap(HasPtr &lhs, HasPtr &rhs) {
using std::swap;
swap(lhs.ps, rhs.ps); // 交换指针,而不是string数据。
swap(lhs.i, rhs.i); // 交换int成员
}
swap函数应该调用swap,而不是std::swap
一般情况下,
swap
函数中调用的swap
不是std::swap
。在上面的例子中,数据成员是内置类型的,而内置类型是没有特定版本的swap
的,因此,对swap
的调用会调用标准库std::swap
。
但是,如果一个类的成员有自己类型特定的swap
函数,调用std::swap
就是错误的了。
// 假定有一个命名为Foo的类,它有一个类型为HasPtr的成员h。
void swap(Foo &lhs, Foo &rhs) {
// 错误:这个函数使用了标准库版本的swap,而不是HasPtr版本,因此进行了不必要的拷贝。
std::swap(lhs.h, rhs.h);
// 交换类型Foo的其他成员
}
// 正确的swap函数:
void swap(Foo &lhs, Foo &rhs) {
using std::swap;
swap(lhs.h, rhs.h);
// 交换类型Foo的其他成员
}
每个
swap
调用应该都是未加限定的。即,每个调用都应该是swap
,而不是std::swap
。如果存在类型特定的swap
版本,其匹配程度会优于std
中定义的版本。
在赋值运算符中使用swap
定义
swap
的类通常用swap
来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换。
// 注意rhs是按值传递的,意味着HasPtr的拷贝构造函数
// 将右侧运算对象中的string拷贝到rhs。
HasPtr &HasPtr::operator=(HasPtr rhs) {
// 交换左侧运算对象和局部变量rhs的内容
swap(*this, rhs); // rhs现在指向本对象曾经使用的内存
return *this; // rhs被销毁,从而delete了rhs中的指针
}
这个技术的有趣之处是它自动处理了自赋值情况且天然就是异常安全的。它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确。它保证异常安全的方法也与原来的赋值运算符实现一样。代码中唯一可能抛出异常的是拷贝构造函数中的
new
表达式。如果真发生了异常,它也会在改变左侧运算对象之前发生。
13.5动态内存管理类
某些类需要在运行时分配可变大小的内存空间。这种类通常可以(并且如果它们确实可以的话,一般应该)使用标准库容器来保存它们的数据。
但是,这一策略并不是对每个类都使用:某些类需要自己进行内存分配。这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。
例如,将实现标准库vector
类的一个简化版本。
// StrVec.h
#include <string>
#include <memory>
#include <utility>
class StrVec {
public:
// allocator成员进行默认初始化
StrVec() : 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:
// 分配元素
static std::allocator<std::string> alloc;
// 指向数组首元素的指针
std::string *elements;
// 指向数组第一个空闲元素的指针
std::string *first_free;
// 指向数组尾后位置的指针
std::string *cap;
// 被添加元素的函数所使用
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();
};
// StrVec.cpp
#include "StrVec.h"
void StrVec::push_back(const std::string &s) {
chk_n_alloc(); // 确保有空间容纳新元素
// 在first_free指向的元素中构造s的副本
alloc.construct(first_free++, s);
}
std::pair<std::string *, std::string *>
StrVec::alloc_n_copy(const std::string *b, const std::string *e) {
// 分配空间保存给定范围中的元素
auto data = alloc.allocate(e - b);
// 初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成。
return {data, std::uninitialized_copy(b, e, data)};
}
void StrVec::free() {
// 不能传递给deallocate一个空指针,如果elements为0,函数什么也不做。
if (elements) {
// 按逆序销毁旧元素
for (auto p = first_free; p != elements; /* 空 */) {
alloc.destroy(--p);
}
alloc.deallocate(elements, cap - elements);
}
}
StrVec::StrVec(const StrVec &s) {
// 调用alloc_n_copy分配空间以容纳与s中一样多的元素
auto newdata = alloc_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}
StrVec::~StrVec() {
free();
}
StrVec &StrVec::operator=(const StrVec &rhs) {
// 调用alloc_n_copy分配内存,大小与rhs中元素占用空间一样多。
auto data = alloc_n_copy(rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
void StrVec::reallocate() {
// 将分配当前大小两倍的内存空间
auto newcapacity = size() ? 2 * size() : 1;
// 分配新内存
auto newdata = alloc.allocate(newcapacity);
// 将数据从旧内存移动到新内存
auto dest = newdata; // 指向新数组中下一个空闲位置
auto elem = elements; // 指向旧数组中下一个元素
for (size_t i = 0; i != size(); ++i) {
// 通过移动构造函数将资源从给定对象移动而不是拷贝到正在创建的对象。
// 而且标准库保证移后源string仍然保持一个有效的、可析构的状态。
// 后面的章节会详细介绍。
alloc.construct(dest++, std::move(*elem++));
}
free(); // 一旦移动完元素就释放旧内存空间
// 更新数据结构,执行新元素。
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
13.6对象移动
在某些情况下,对象拷贝后就立即被销毁了。在这些情况下,移动而非拷贝对象会大幅度提升性能。
使用移动而不是拷贝的另一个原因源于IO类或unique_ptr
这样的类。这些类都包含不能被共享的资源(如指针或IO缓冲)。因此,这些类型的对象不能拷贝但可以移动。
13.6.1右值引用
为了支持移动操作,新标准引入了一种新的引用类型:右值引用,即必须绑定到右值的引用,可以通过
&&
来获得。
右值引用有一个重要的性质:只能绑定到一个将要销毁的对象。因此,可以自由地将一个右值引用的资源移动到另一个对象中。
可以将一个右值引用绑定到要求转换的表达式、字面常量或是返回右值的表达式,但不能将一个右值引用直接绑定到一个左值上:
int i = 42;
int &r = i; // 正确:r引用i。
int &&rr = i; // 错误:不能将一个右值引用绑定到一个左值上。
int &r2 = i * 42; // 错误:i * 42是一个右值。
const int &r3 = i * 42; // 正确:可以将一个const的引用绑定到一个右值上。
int &&rr2 = i * 42; // 正确:将rr2绑定到乘法结果上。
左值持久,右值短暂
左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,因此得知:
- 所引用的对象将要被销毁。
- 该对象没有其他用户。
这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
变量是左值
变量表达式都是左值,因此,不能将一个右值引用绑定到一个右值引用类型的变量上:
int &&rr1 = 42; // 正确:字面常量是右值。
int &&rr2 = rr1; // 错误:表达式rr1是左值。
毕竟,变量是持久的,直至离开作用域时才被销毁。
标准库move函数
虽然不能将一个右值引用直接绑定到一个左值上,但可以显式地将一个左值转换为对应的右值引用类型。可以通过调用
move
新标准库函数来获得绑定到左值上的右值引用。
// 告诉编译器:有一个左值,但是希望像一个右值一样处理它。必须认识到,
// 调用move就意味着承诺:除了对rr1赋值或销毁它外,将不再使用它。在
// 调用move之后,不能对移后源对象的值做任何假设。
int &&rr3 = std::move(rr1); // ok
可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
13.6.2移动构造函数和移动赋值运算符
为了让自己的类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符。
移动构造函数的第一个参数是该类类型的一个右值引用且任何额外的参数都必须有默认实参。除了完成资源移动,移动构造函数还必须确保移后源对象处于销毁它是无害的状态。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源,这些资源的所有权已经归属新创建的对象。
// 与拷贝构造函数不同,移动构造函数不分配任何新内存:它接管给定的StrVec中的内存。
StrVec::StrVec(StrVec &&s) noexcept // 移动构造函数不应抛出任何异常
// 成员初始化器接管s中的资源
: elements(s.elements), first_free(s.first_free), cap(s.cap) {
// 将给定对象中的指针都置为nullptr,这样就完成了从给定对象的移动操作。最终,移后源对象会
// 被销毁,意味着将在其上运行析构函数。StrVec的析构函数在first_free上调用deallocate。
// 如果忘记了改变s.first_free,则销毁移后源对象就会释放掉刚刚移动的内存。
s.elements = s.first_free = s.cap = nullptr;
}
移动操作、标准库容器和异常
由于移动操作窃取资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,应该将此事通知标准库,否则,它会认为移动类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。
一种通知标准库的方法是在构造函数中指明noexcept
,且必须在声明和定义中都指定。
搞清楚为什么需要
noexcept
能帮助深入理解标准库是如何与自定义的类型交互的。需要指出一个移动操作不抛出异常,这是因为两个互相关联的事实:
- 虽然移动操作通常不抛出异常,但抛出异常也是允许的。
- 标准库容器能对异常发生时其自身的行为提供保障。例如,
vector
保证,如果调用push_back
时发生异常,vector
自身不会发生改变。移动一个对象通常会改变它的值。如果重新分配过程使用了移动构造函数,且在移动了部分而不是全部元素后抛出了一个异常,就会产生问题。旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。在此情况下,例如
vector
将不能满足自身保持不变的要求。
另一方面,如果使用了拷贝构造函数且发生了异常,它可以很容易地满足要求。在此情况下,当在新内存中构造元素时,旧元素保持不变。如果此时发生了异常,可以释放新分配的(但还未成功构造的)内存并返回。
为了避免这种潜在问题,除非知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。如果希望在重新分配内存这类情况下对自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库移动构造函数可以安全使用。
移动赋值运算符
移动赋值运算符必须正确处理自赋值:
StrVec &StrVec::operator=(StrVec &&rhs) noexcept {
// 直接检测自赋值,原因在于此右值可能是move调用的返回结果。与其他任何赋值运算符一样,
// 关键点是不能在使用右侧对象的资源之前就释放左侧运算对象的资源(可能是相同的资源)。
if (this != &rhs) {
free(); // 释放已有元素
elements = rhs.elements; // 从rhs接管资源
first_free = rhs.first_free;
cap = rhs.cap;
// 将rhs置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
移后源对象必须可析构
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。例如
StrVec
的移动操作满足这一要求,这是通过将移后源对象的指针成员置为nullptr
来实现的。
除了将移后源对象置为析构安全的状态之外,移动操作还必须保证对象仍然是有效的。一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。另一方面,移动操作对移后源对象中留下的值没有任何要求。因此,程序不应该依赖于移后源对象中的数据。
例如,StrVec
的移动操作将移后源对象置于与默认初始化的对象相同的状态。因此,可以继续对移后源对象执行所有的StrVec
操作,与任何其他默认初始化的对象一样。
合成的移动操作
如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。因此,某些类就没有移动构造函数或移动赋值运算符。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static
数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
// 编译器会为X和hasX合成移动操作
struct X {
int i; // 内置类型可以移动
std::string s; // string定义了自己的移动操作
};
struct hasX {
X mem; // X有合成的移动操作
};
X x, x2 = std::move(x); // 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx); // 使用合成的移动构造函数
移动操作永远不会隐式定义为删除的函数。但是,如果显式地要求编译器生成
=default
的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数:
- 移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
- 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。
- 如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
- 如果有类成员是
const
的或是引用,则类的移动赋值运算符被定义为删除的。- 如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符被定义为删除的。
移动右值,拷贝左值
// StrVec的拷贝构造函数接受一个const StrVec的引用
StrVec v1, v2;
v1 = v2; // v2是左值,使用拷贝赋值。
StrVec getVec(istream &); // getVec返回一个右值
// 此时,两个运算符的参数都是允许的。但是,调用拷贝赋值运算符需要进行一次到const
// 的转换,而StrVec&&则是精确匹配。因此,会使用移动赋值运算符。
v2 = getVec(cin);
但如果没有移动构造函数,右值也被拷贝
如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使试图通过调用
move
来移动它们时也是如此:
class Foo {
public:
Foo() = default;
Foo(const Foo &); // 拷贝构造函数
// 其他成员定义,但Foo未定义移动构造函数。
};
Foo x;
Foo y(x); // 拷贝构造函数,x是一个左值。
Foo z(std::move(x)); // 拷贝构造函数,因为未定义移动构造函数。
值得注意的是,用拷贝构造函数代替移动构造函数几乎肯定是安全的(赋值运算符的情况类似)。一般情况下,拷贝构造函数满足对应的移动构造函数的要求:它会拷贝给定对象,并将原对象置于有效状态。实际上,拷贝构造函数甚至都不会改变原对象的值。
拷贝并交换赋值运算符和移动操作
class HasPtr {
public:
// 添加的移动构造函数
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {
p.ps = nullptr;
}
// 由于是非引用参数,因此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用
// 拷贝构造函数,要么使用移动构造函数:左值被拷贝,右值被移动。因此,单一的赋值运算符
// 就实现了拷贝赋值运算符和移动赋值运算符两种功能。
HasPtr &operator=(HasPtr rhs) {
swap(*this, rhs);
return *this;
}
// 其他成员的定义和之前一样
};
// 假定hp和hp2都是HasPtr对象:
hp = hp2; // hp2是一个左值,因此通过拷贝构造函数来拷贝。
hp = std::move(hp2); // 移动构造函数移动hp2
建议:更新三/五法则
所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。
移动迭代器
新标准库中定义了一种移动迭代器适配器,其通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。
通过调用标准库的make_move_iterator
函数将一个普通迭代器转换为一个移动迭代器。原迭代器的所有其他操作在移动迭代器中都照常工作。由于移动迭代器支持正常的迭代器操作,可以将一对移动迭代器传递给算法:
// 之前的版本使用for循环来调用construct从旧内存将元素拷贝到新内存中。如果能调用
// uninitialized_copy来构造新分配的内存,将比循环更为简单。但是,它对元素进行拷贝
// 操作,标准库中没有类似的函数将对象移动到未构造的内存中,因此需要移动迭代器:
void StrVec::reallocate() {
// 分配大小两倍于当前规模的内存空间
auto newcapacity = size() ? 2 * size() : 1;
auto first = alloc.allocate(newcapacity);
// 移动元素:uninitialized_copy对输入序列中的每个元素调用construct来将元素拷贝到目的位置。
// 此算法使用迭代器的解引用运算符从输入序列中提取元素。由于传递给它的是移动迭代器,因此解引用
// 运算符生成的是一个右值引用,这意味着construct将使用移动构造函数来构造元素。
auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);
free(); // 释放旧空间
elements = first; // 更新指针
first_free = last;
cap = elements + newcapacity;
}
值得注意的是,标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁原对象,因此只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。
建议:不要随意使用移动操作
由于一个移后源对象具有不确定的状态,对其调用std::move
是危险的。当调用move
时,必须绝对确认移后源对象没有其他用户。
通过在类代码中小心地使用move
,可以大幅度提升性能。而如果随意在普通用户代码(与类实现代码相对)中使用移动操作,很可能导致莫名其妙的、难以查找的错误,而难以提升应用程序性能。
13.6.3右值引用和成员函数
如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式:一个版本接受一个指向
const
的左值引用,第二个版本接受一个指向非const
的右值引用。
// 拷贝:绑定到任意类型的X,从其参数拷贝数据。
void push_back(const X &);
// 移动:只能绑定到类型X的可修改的右值,从其参数窃取数据。
void push_back(X &&);
一般来说,不需要为函数操作定义接受一个
const X &&
或是一个(普通的)X &
参数的版本。当希望从实参窃取数据时,通常传递一个右值引用。为了达到这一目的,实参不能是const
的。类似的,从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个(普通的)引用参数的版本。
class StrVec {
public:
void push_back(const std::string &); // 拷贝元素
void push_back(std::string &&); // 移动元素
// 其他成员的定义和之前一样
};
void StrVec::push_back(const std::string &s) {
chk_n_alloc(); // 确保有空间容纳新元素
// 在first_free指向的元素中构造s的一个副本
alloc.construct(first_free++, s);
}
void StrVec::push_back(std::string &&s) {
chk_n_alloc(); // 如果需要的话为StrVec重新分配内存
// 调用move来将其参数传递给construct,由于返回的是一个右值引用,因此,
// 会使用string的移动构造函数来构造新元素。
alloc.construct(first_free++, std::move(s));
}
// 当调用push_back时,实参类型决定了新元素是拷贝还是移动到容器中:
StrVec vec; // 空StrVec
string s = "some string or another";
vec.push_back(s); // 调用push_back(const string &)
vec.push_back("done"); // 调用push_back(string &&);
右值和左值引用成员函数
通常,在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值:
string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');
有时,右值的使用方式可能令人惊讶:
// 对一个右值进行了赋值
s1 + s2 = "wow!";
在旧标准中,没有办法阻止这种使用方式。为了维持向后兼容性,新标准库类仍然允许向右值赋值。但是,可能希望在自己的类中阻止这种用法。在此情况下,希望强制左侧运算对象(即,
this
指向的对象)是一个左值。可以使用引用限定符:
class Foo {
public:
Foo &operator=(const Foo &) &; // 只能向可修改的左值赋值
// Foo的其他参数
};
Foo &Foo::operator=(const Foo &rhs) & {
// 执行将rhs赋予本对象所需的工作
return *this;
}
引用限定符可以是
&
或&&
,分别指出this
可以指向一个左值或右值。引用限定符只能用于(非static
)成员函数,且必须同时出现在函数的声明和定义中。
Foo &retFoo();
Foo retVal();
Foo i, j; // i和j是左值
i = j; // 正确:i是左值。
retFoo() = j; // 正确:retFoo()返回一个左值。
retVal() = j; // 错误:retVal()返回一个右值。
i = retVal(); // 正确:可以将一个右值作为赋值操作的右侧运算对象。
一个函数可以同时使用
const
和引用限定。在此情况下,引用限定符必须跟随在const
限定符之后:
class Foo {
public:
Foo someMem() & const; // 错误:const限定符必须在前。
Foo anotherMem() const &; // 正确:const限定符在前。
};
重载和引用函数
引用限定符也可以区分重载版本。而且,可以综合引用限定符和
const
来区分一个成员函数的重载版本。
class Foo {
public:
Foo sorted() &&; // 可用于可改变的右值
Foo sorted() const &; // 可用于任何类型的Foo
// Foo的其他成员的定义
private:
vector<int> data;
};
// 本对象为右值,意味着没有其他用户,因此可以原值排序。
Foo Foo::sorted() && {
sort(data.begin(), data.end());
return *this;
}
// 本对象是const或是一个左值,哪种情况都不能对其进行原值排序。
Foo Foo::sorted() const & {
Foo ret(*this); // 拷贝一个副本
sort(ret.data.begin(), ret.data.end()); // 排序副本
return ret; // 返回副本
}
retVal().sorted(); // retVal()是一个右值,调用Foo::sorted() &&。
retFoo().sorted(); // retFoo()是一个左值,调用Foo::sorted() const &。
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符:
class Foo {
public:
Foo sorted() &&;
Foo sorted() const; // 错误:必须加上引用限定符。
// Comp是函数类型的类型别名,此函数类型可以用来比较int值。
using Comp = bool(const int &, const int &);
Foo sorted(Comp *); // 正确:不同的参数列表。
Foo sorted(Comp *) const; // 正确:两个版本都没有引用限定符。
};