第十三章——拷贝控制
当定义一个类时,我们显式地或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、 拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。 拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作。
13.1 拷贝、赋值与销毁
13.1.1 拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo{
Foo(); // 默认构造函数
Foo(const Foo&); // 拷贝构造函数
};
虽然我们可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是explicit(声明为explicit的构造函数,不能用于对象的隐式创建)的。
合成拷贝构造函数
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。
例如,Sales_data类的合成拷贝构造函数等价于:
class Sales_data {
public:
// ...
// 与合成的拷贝构造函数等价的拷贝构造函数声明
Sales_data(const Sales_data&);
private:
std:string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
// 与Sales_data的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data &orig):
bookNo(orig.bookNo), // 使用string的拷贝构造函数
units_sold(orig.units_sold), // 拷贝orig.units_sold
revenue(orig.revenue) // 拷贝orig.revenue
{} // 空函数体
拷贝初始化
现在,我们可以完全理解直接初始化和拷贝初始化之间的差异了
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化(copy initialzation)时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
如果一个类有移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。
拷贝初始化不仅在我们使用=定义变量时会发生,在下列情况也会:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
某些类类型还会对它们所分配的对象使用拷贝初始化。例如,当我们初始化标准库容器或是调用其insert或push成员时,容器会对其元素进行拷贝初始化。与之相对,用emplace成员创建的元素都进行直接初始化。
参数和返回值
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。
拷贝初始化的限制
如前所述,如果我们使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:
vector<int> v1(10); // 正确:直接初始化
vector<int> v2 = 10; // 错误接受大小参数的构造函数是explicit的
void f(vvector<int>); // f的参数进行拷贝初始化
f(10); // 错误:不能用一个explicit的构造函数拷贝一个实参
f(vector<int>(10)); // 正确:从一个int直接构造一个临时vector
直接初始化v1是合法的,但看起来与之等价的拷贝初始化v2则是错误的,因为vector的接受单一大小参数的构造函数是explicit的。出于同样的原因,当传递一个实参或从函数返回一个值时,我们不能隐式使用一个explicit构造函数(先隐式的构造一个vector再通过拷贝构造传递过去)。如果我们希望使用一个explicit构造函数,就必须显式地使用,像此代码中最后一行那样。
编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。即,编译器被允许将下面的代码:
string null_book = "9-999-99999-9"; // 拷贝初始化
改写为
string null_bool("9-999-99999-9"); // 编译器略过了拷贝构造函数
但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(例如,不能是private的)。
13.1.2 拷贝赋值运算符
与类控制其对象如何初始化一样,类也可以控制其对象如何赋值:
Sales_data trans, accum;
trans = accum;
与拷贝构造函数-一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。
合成拷贝赋值运算符
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符( synthesized copy-assignment operator)。类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
例如,下面的代码等价于Sales_data的合成拷贝赋值运算符:
Sales_data& Sales_data::operator=(const Sales_data &rhs) {
bookNo = rhs.bookNo; // 调用string::operator=
units_sold = rhs.units_sold; // 使用内置的int赋值
revennue = rhs.revenue; // 使用内置的double赋值
return *this; // 返回一个此对象的引用
}
13.1.3 析构函数
析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。
析构函数不接受参数,因此不能被重载
析构函数完成什么任务
在构造函数中,成员的初始化是在函数体执行之前完成的,按照它们在类中出现的顺序进行初始化
在析构函数中,首先执行函数体,然后销毁成员,按照初始化顺序逆序销毁
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
与普通指针不同,智能指针是类类型,所以具有析构函数。因此,与普通指针不同,智能指针成员在析构阶段会被自动销毁。
【Note】隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
由于析构函数自动运行,我们的程序可以按需要分配资源,而(通常)无须担心何时释放这些资源
合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义–个合成析构函数。类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数的函数体就为空。
例如,下面的代码等价于Sales_data的合成析构函数:
class Sales_data{
public:
// 成员会被自动销毁,除此之外不需要做其他事情
~Sales_data() {}
// ...
};
13.1.4 三 / 五法则
如前所述,有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。而且,在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。
C++语言并不要求我们定义所有这些操作:可以只定义其中一个或两个,而不必定义所有。但是,这些操作通常应该被看作一个整体。通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的。
需要析构函数的类也需要拷贝和赋值操作
当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。如果这个类需要-个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
如一个类HasPtr有一个内置指针作为成员,需要在构造函数中分配动态内存。析构函数不会delete一个指针数据成员,因此需要析构函数。
假设使用合成拷贝构造函数和合成拷贝赋值运算符:
class HasPtr{
public:
HasPtr(const str::string &s = std::string()):
ps(new std::string(s)), i(0) {}
~HasPtr() {delete ps;}
private:
std::string *ps;
int i;
};
如果只是简单地拷贝指针,则意味着可能多个HasPtr对象指向相同的内存,这显然有重大风险。
需要拷贝操作的类也需要赋值操作,反之亦然
虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。
作为一个例子,考虑一个类为每个对象分配一个独有的、 唯一的序号。 这个类需要一个拷贝构造函数为每个新创建的对象生成-一个新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是,这个类不需要自定义析构函数。
这个例子引出了第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然——如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
13.1.5 使用=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 类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。
定义删除的函数
删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的:
struct NoCopy{
NoCopy() = default;
NoCopy(const NoCopy&) = delete;
NoCopy &operator=(const NoCopy&) = delete;
~NoCopy() = default;
};
与=default不同,=delete必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码,因此=default直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的,以便禁止试图使用它的操作。
与=default的另一个不同之处是,我们可以对任何函数指定=delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default)。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。
析构函数不能使删除的成员
合成的拷贝控制成员可能是删除的
如前所述,如果我们未定义拷贝控制成员,编译器会为我们定义合成的版本。类似的,如果一个类未定义构造函数,编译器会为其合成一个默认构造函数。对某些类来说,编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或不可访问的(例如,是private的),则类的合成析构函数被定义为删除的。
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
- 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
private拷贝控制
在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝
class PrivateCopy{
// 无访问说明符,接下来的成员默认为private
PrivateCopy(const PrivateCopy&);
PrivateCopy &operator=(const PrivateCopy&);
public:
PrivateCopy() = default;
~PrivateCopy();
};
由于析构函数是public的,用户可以定义PrivateCopy类型的对象。但是,由于拷贝构造函数和拷贝赋值运算符是private的,用户代码将不能拷贝这个类型的对象。但是,友元和成员函数仍旧可以拷贝对象。为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为private的,但并不定义它们。
声明但不定义一个成员函数是合法的,对此只有一个例外。试图访问一个未定义的成员将导致一个链接时错误。通过声明(但不定义)private的拷贝构造函数,我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误:成员函数或友元函数中的拷贝操作将会导致链接时错误。
13.2 拷贝控制和资源管理
我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
在我们使用过的标准库类中,标准库容器和string类的行为像一个值。 而不出意外的,shared_ptr类提供类似指针的行为。
13.2.1 行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。这意味着对于ps指向的string,每个HasPtr对象都必须有自己的拷贝。为了实现类值行为,HasPtr需要
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
- 定义一个析构函数来释放string
- 定义一个拷贝赋值运算符来释放对象当前的string, 并从右侧运算对象拷贝string
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() { delete ps; }
private:
std::string *ps;
int i;
};
类值拷贝赋值运算符
赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。但是,非常重要的一点是, 这些操作是以正确的顺序执行的,即使将一个对象赋予它自身,也保证正确。而且,如果可能,我们编写的赋值运算符还应该是异常安全的——当异常发生时能将左侧运算对象置于一个有意义的状态。
在本例中,通过先拷贝右侧运算对象,我们可以处理自赋值情况,并能保证在异常发生时代码也是安全的。在完成拷贝后,我们释放左侧运算对象的资源,并更新指针指向新分配的string:
HasPtr& HasPtr::operator=(const HasPtr &rhs){
auto newp = new string(*rhs.ps); // 拷贝底层string
delete ps; // 释放旧内存
ps = newp; // 从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; // 返回本对象
}
赋值运算符:
当编写赋值运算符是,有两点需要记住
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作
- 大多数赋值运算符组合了析构函数和拷贝构造函数
当编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
13.2.2 定义行为像指针的类
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。 我们的类仍然需要自己的析构函数来释放接受string参数的构造函数分配的内存。但是,在本例中,析构函数不能单方面地释放关联的string。只有当最后一个指向string的HasPtr销毁时,它才可以释放string。
令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。拷贝(或赋值)一个shared_ptr会拷贝(赋值)shared_ptr所指向的指针。shared_ptr类自己记录有多少用户共享它所指向的对象。当没有用户使用对象时,shared_ptr类负责释放资源。
但是,有时我们希望直接管理资源。在这种情况下,使用引用计数(reference count)就很有用了。为了说明引用计数如何工作,我们将重新定义HasPtr,令其行为像指针一样,但我们不使用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(1)) {}
// 拷贝构造函数拷贝所有单个数据成员,并递增计数器
HasPtr& operator=(const HasPtr&);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; // 用来记录有多少个对象共享*ps的成员。
};
类指针的拷贝成员“篡改”引用计数
当拷贝或赋值一个HasPtr对象时,我们希望副本和原对象都指向相同的string。即,当拷贝一个HasPtr时,我们将拷贝ps本身,而不是ps指向的string。当我们进行拷贝时,还会递增该string关联的计数器。
(我们在类内定义的)拷贝构造函数拷贝给定HasPtr的所有三个数据成员。这个构造函数还递增use成员,指出ps和p.ps指向的string又有了一个新的用户。
析构函数不能无条件地delete ps——可能还有其他对象指向这块内存。析构函数应该递减引用计数,指出共享string的对象少了一个。如果计数器变为0,则析构函数释放ps和use指向的内存:
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,那么算法将使用类自定义版本。否则,算法将使用标准库定义的swap。虽然与往常一样我们不知道swap是如何实现的,但理论上很容易理解,为了交换两个对象我们需要进行一次拷贝和两次赋值。例如,交换两个类值HasPtr对象的代码可能像下面这样:
HasPtr temp = v1;
v1 = v2;
v2 = temp;
理论上,这些内存分配都是不必要的。我们更希望swap交换指针,而不是分配string的新副本。即,我们希望这样交换两个HasPtr:
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;
编写我们自己的swap函数
可以在我们的类上定义一个自己版本的swap来重载swap的默认行为:
class HasPtr{
friend void swap(HasPtr&, HasPtr&);
// ...
};
inline void swap(HasPtr& &lhr, HasPtr &rhs) {
using std::swap;
swap(lhs.ps, rhs.ps); // 交换指针,而不是string数据
swap(lhs.i, rhs.i); // 交换int成员
}
我们首先将swap定义为friend,以便能访问HasPtr的(private的)数据成员。由于swap的存在就是为了优化代码,我们将其声明为inline函数。swap的函数体对给定对象的每个数据成员调用swap。我们首先swap绑定到rhs和lhs的对象的指针成员,然后是int成员。
值得注意的是,这里的swap函数调用了std::swap,而不是swap。因为他的成员函数都是内置数据类型。但如果是自己定义的类型的话,则必须使用那个类型所定义的swap。如果类Foo中含有一个类型为HasPtr的成员h:
void swap(Foo &lhs, Foo &rhs) {
// 错误:这个函数使用了标准库版本的swap,而不是HasPtr版本
std::swap(lhs.h, rhs.h);
// 其他swap
}
与拷贝控制成员不同,swap 并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。
在赋值运算符中使用swap
定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换:
// 注意rhs是按值传递的,意味着HasPtr的拷贝构造函数将右侧运算对象中的string拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs) {
swap(*this, rhs); // rhs现在指向本对象曾经使用的内存
return *this; // rhs被销毁,从而delete了rhs中的指针
}
在这个版本的赋值运算符中,参数并不是一个引用,我们将右侧运算对象以传值方式传递给了赋值运算符。因此,rhs是右侧运算对象的一个副本。参数传递时拷贝HasPtr的操作会分配该对象的string的一个新副本。
在赋值运算符的函数体中,我们调用swap来交换rhs和*this中的数据成员。这个调用将左侧运算对象中原来保存的指针存入rhs中,并将rhs中原来的指针存入*this中。因此,在swap调用之后,*this 中的指针成员将指向新分配的string——右侧运算对象中string的一个副本。当赋值运算符结束时,rhs被销毁,HasPtr的析构函数将执行。此析构函数deleterhs现在指向的内存,即,释放掉左侧运算对象中原来的内存。
这个技术的有趣之处是它自动处理了自赋值情况且天然就是异常安全的。它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确,这与我们在原来的赋值运算符中使用的方法是一致的。它保证异常安全的方法也与原来的赋值运算符实现一样。代码中唯一可能抛出异常的是拷贝构造函数中的new表达式。如果真发生了异常,它也会在我们改变左侧运算对象之前发生。
使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。
14.3 拷贝控制示例
虽然通常来说分配资源的类更需要拷贝控制,但资源管理并不是一个类需要定义自己的拷贝控制成员的唯一原因。一些类也需要拷贝控制成员的帮助来进行簿记工作或其他操作。
作为类需要拷贝控制来进行簿记操作的例子,我们将概述两个类的设计,这两个类可能用于邮件处理应用中。两个类命名为Message和Folder吗,分别表示电子邮件消息和消息目录。每个Message对象可以出现在多个Folder中。但是,任意给定的Message的内容只有一个副本。这样,如果一条Message的内容被改变,则我们从它所在的任何Folder来浏览此Message时,都会看到改变后的内容。
为了记录Message位于哪些Folder中,每个Message都会保存一个它所在Folder的指针的set,同样的,每个Folder都保存一-个它包含的Message的指针的set。

当我们拷贝一个Message时,副本和原对象将是不同的Message 对象,但两个Message都出现在相同的Folder中。因此,拷贝Message的操作包括消息内容和Folder指针set的拷贝。而且,我们必须在每个包含此消息的Folder中都添加一个指向新创建的Message的指针。
当我们将一个Message对象赋予另一个Message对象时,左侧Message的内容会被右侧Message的内容所替代。我们还必须更新Folder集合,从原来包含左侧Message的Folder中将它删除,并将它添加到包含右侧Message的Folder中。
观察这些操作,我们可以看到,析构函数和拷贝赋值运算符都必须从包含一条Message的所有Folder中删除它。类似的,拷贝构造函数和拷贝赋值运算符都要将一个Message添加到给定的一组Folder中。我们将定义两个private的工具函数来完这些工作。
Message类
class Message {
friend class Folder;
public:
// folders被隐式初始化为空集合
explicit Message(const std::string& str = "") :
contents(str) {}
// 拷贝控制成员,用来管理指向本Message的指针
Message(const Message&);
Message& operator=(const Message&);
~Message();
// 从给定Folder集合中添加、删除本Message
void save(Folder&);
void remove(Folder&);
private:
std::string contents;
std::set<Folder*> folders;
// 拷贝构造函数、拷贝赋值运算符和析构函数所使用的工具函数
// 将本Message添加到指向参数的Folder中
void add_to_Folders(const Message&);
// 从folder中的每个Folder中删除本Message
void remove_from_Folders();
};
save和remove成员
void Message::save(Folder& f) {
folders.insert(&f); // 将Folder添加到Folder列表
f.addMsg(this); // 将Message添加到f的Message集合
}
void Message::remove(Folder& f) {
folders.erase(&f); // 将Folder从Folder列表删除
f.remMsg(this); // 将Message从Message集合删除
}
Message类的拷贝控制成员
void Message::add_to_Folders(const Message& m) {
for (auto f : m.folders) // 对每个包含m的Folder
f->addMsg(this); // 向该Folder添加一个指向本Message的指针
}
Message::Message(const Message& m) :
contents(m.contents), folders(m.folders) {
add_to_Folders(m); // 将本Message添加到指向m的Folder中
}
Message的析构函数
// 从对应的Folder中删除本Message
void Message::remove_from_Folders() {
for (auto f : folders)
f->remMsg(this);
}
Message::~Message() {
remove_from_Folders();
}
Message的拷贝赋值运算符
与大多数赋值运算符相同,我们的Message类的拷贝赋值运算符必须执行拷贝构造函数和析构函数的工作。与往常一样,最重要的是我们要组织好代码结构,使得即使左侧和右侧运算对象是同一个Message,拷贝赋值运算符也能正确执行。
在本例中,我们先从左侧运算对象的folders中删除此Message的指针,然后再将指针添加到右侧运算对象的folders中,从而实现了自赋值的正确处理:
Message& Message::operator=(const Message& rhs) {
// 通过先删除指针再插入它们来处理自赋值情况
remove_from_Folders(); // 更新已有Folder
contents = rhs.contents; // 从rhs拷贝消息内容
folders = rhs.folders; // 从rhs拷贝Folder指针
add_to_Folders(rhs); // 将本Message添加到那些Folder中
return *this;
}
如果左侧和右侧运算对象是相同的Message,则它们具有相同的地址。如果我们在add_to_Folders之后调用remove_from_Folders,就会将此Message从它所在的所有Folder中删除。
Message的swap函数
标准库中定义了string和set的swap版本。因此,如果为我们的Message类定义它自己的swap版本,它将从中受益。通过定义一个Message特定版本的swap,我们可以避免对contents和folders成员进行不必要的拷贝。
但是,我们的swap函数必须管理指向被交换Message的Folder指针。在调用swap(m1,m2)之后,原来指向m1的Folder现在必须指向m2,反之亦然。
我们通过两遍扫描folders中每个成员来正确处理Folder指针。第一遍扫描将Message从它们所在的Folder中删除。接下来我们调用swap来交换数据成员。最后对folders进行第二遍扫描来添加交换过的Message:
void swap(Message& lhs, Message& rhs) {
using std::swap; // 严格说本利并需不要,但这是一个好习惯
// 将每个消息的指针从它(原来)所在的Folder中删除
for (auto f : lhs.folders)
f->remMsg(&lhs);
for (auto f : rhs.folders)
f->remMsg(&rhs);
// 交换contents和Folderzhizhenset
swap(lhs.folders, rhs.folders); // 使用swap(set&, set&)
swap(lhs.contents, rhs.contents); // 使用swap(string&, string&)
// 将每个Message的指针添加到它的(新)Folder中
for (auto f : lhs.folders)
f->addMsg(&lhs);
for (auto f : rhs.folders)
f->addMsg(&rhs);
}
13.5 动态内存管理类
某些类需要在运行时分配可变大小的内存空间。这种类通常可以使用标准库容器来保存它们的数据。
但是,这一策略并不是对每个类都适用:某些类需要自己进行内存分配。这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。
例如,我们将实现标准库vector类的一个简化版本。我们所做的一个简化是不使用模板,我们的类只用于string。因此,它被命名为strVec
StrVec类的设计
回忆一下, vector类将其元素保存在连续内存中。为了获得可接受的性能,vector预先分配足够的内存来保存可能需要的更多元素。vector的每个添加元素的成员函数会检查是否有空间容纳更多的元素。如果有,成员函数会在下一个可用位置构造一个对象。如果没有可用空间,vector就会重新分配空间:它获得新的空间,将已有元素移动到新空间中,释放旧空间,并添加新元素。
我们在strVec类中使用类似的策略。我们将使用一个allocator来获得原始内存。由于allocator分配的内存是未构造的,我们将在需要添加新元素时用allocator的construct成员在原始内存中创建对象。类似的,当我们需要删除一个元素时,我们将使用destroy成员来销毁元素。
每个StrVec有三个指针成员指向其元素所使用的内存:
elements,指向分配的内存中的首元素first_free,指向最后一个实际元素之后的位置cap,指向分配的内存末尾之后的位置

除了这些指针之外,StrVec还有一个名为alloc的静态成员,其类型为allocator<string>。alloc成员会分配StrVec使用的内存。我们的类还有4个工具函数:
alloc_n_copy会分配内存,并拷贝一个给定范围中的元素。free会销毁构造的元素并释放内存。chk_n_alloc保证strVec至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存。reallocate在内存用完时为StrVec分配新内存。
StrVec类定义
class StrVec{
public:
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; // 分配元素
// 被添加元素的函数所使用
void chk_n_aoolc() {
if (size() == capacity()) reallocate();
}
// 工具函数,被拷贝构造函数、赋值运算符和析构函数
std::pair<std::string*, std::string*> alloc_n_copy
(const std::string*, const std::string*);
void free(); // 销毁being释放内存
void reallocate(); // 获得更多内存并拷贝已有元素
std::string* elements; // 指向数组首元素的指针
std::string* first_free; // 指向数组第一个空闲元素的指针
std::string* cap; // 指向数组尾后位置的指针
};
类体定义了多个成员:
- 默认构造函数(隐式地)默认初始化alloc并(显式地)将指针初始化为
nullptr,表明没有元素。 size成员返回当前真正在使用的元素的数目,等于first_free-elements。capacity成员返回StrVec可以保存的元素的数量,等价于cap-elements。- 当没有空间容纳新元素,即
cap==first_ free时,chk_n_alloc会为StrVec重新分配内存。 - begin和end成员分别返回指向首元素(即
elements)和最后一个构造的元素之后位置(即first_free)的指针。
使用construct
函数push_ back调用chk_n_alloc确保有空间容纳新元素。如果需要,chk_n_alloc会调用reallocate。当chk_n_alloc返回时,push_back知道必有空间容纳新元素。它要求其allocator成员来construct新的尾元素:
void StrVec::push_back(const std::string& s) {
chk_n_aoolc(); // 确保有空间容纳新元素
// 在first_free指向的元素中构造s的副本
alloc.construct(first_free++, s);
}
当我们用allocator分配内存时,必须记住内存是未构造的。为了使用此原始内存,我们必须调用construct,在此内存中构造一个对象。 传递给construct的第一个参数必须是一个指针,指向调用allocate所分配的未构造的内存空间。剩余参数确定用哪个构造函数来构造对象。在本例中,只有一个额外参数,类型为string,因此会使用string的拷贝构造函数。
值得注意的是,对construct的调用也会递增first_free,表示已经构造了一个新元素。它使用前置递增,因此这个调用会在first_ free当前值指定的地址构造一个对象,并递增first_free指向下一个未构造的元素。
alloc_n_copy成员
alloc_n_copy成员会分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。此函数返回一个指针的pair,两个指针分别指向新空间的开始位置和拷贝的尾后的位置:
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, uninitialized_copy(b, e, data) };
}
uninitialized_ copy的返回值是一个指针,指向最后一个构造元素之后的位置。
free成员
free成员有两个责任:首先destroy元素,然后释放StrVec自己分配的内存空间。for循环调用allocator的destroy成员,从构造的尾元素开始,到首元素为止,逆序销毁所有元素:
void StrVec::free() {
// 不能传递给deallocate一个空指针,如果elements为0,函数什么也不做
if (elements) {
// 逆序销毁旧元素
for (auto p = first_free; p != elements; ) {
alloc.destroy(--p);
}
alloc.deallocate(elements, cap - elements);
}
}
destroy函数会运行string的析构函数。string的析构函数会释放string自己分配的内存空间。一旦元素被销毁,我们就调用deallocate来释放本StrVec对象分配的内存空间。我们传递给deallocate的指针必须是之前某次allocate调用所返回的指针。因此,在调用deallocate之前我们首先检查elements是否为空。
拷贝控制成员
拷贝构造函数调用alloc_n_copy:
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;
}
并将返回结果赋予数据成员。alloc_n_copy的返回值是一个指针的pair其first成员指向第一个构造的元素,second 成员指向最后一个构造的元素之后的位置。由于alloc_n_copy分配的空间恰好容纳给定的元素,cap也指向最后一个构造的元素之后的位置。
析构函数调用free:
StrVec::~StrVec() {
free();
}
拷贝赋值运算符在释放已有元素之前调用alloc_n_copy,这样就可以正确处理自赋值了:
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;
}
类似拷贝构造函数,拷贝赋值运算符使用alloc_n_copy 的返回值来初始化它的指针。
在重新分配内存的过程中移动而不是拷贝元素
在编写reallocate成员函数应:
- 为一个新的、更大的string数组分配内存
- 在内存空间的前一部分构造对象,保存现有元素
- 销毁原内存空间中的元素,并释放这块内存
由于string的行为类似值,我们可以得出结论,每个string对构成它的所有字符都会保存自己的一份副本。 拷贝一个string必须为这些字符分配内存空间,而销毁一个string必须释放所占用的内存。
拷贝一个string就必须真的拷贝数据,因为通常情况下,在我们拷贝了一个string之后,它就会有两个用户。但是,如果是reallocate拷贝StrVec中的string,则在拷贝之后,每个string只有唯一的用户。一旦将元素从旧空间拷贝到了新空间,我们就会立即销毁原string。
因此,拷贝这些string中的数据是多余的。在重新分配内存空间时,如果我们能避免分配和释放string的额外开销,StrVec的性能会好得多。
移动构造函数和std::move
通过使用新标准库引入的两种机制,我们就可以避免string的拷贝。首先,有一些标准库类,包括string,都定义了所谓的“移动构造函数”。关于string的移动构造函数如何工作的细节,以及有关实现的任何其他细节,目前都尚未公开。但是,我们知道,移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象。而且我们知道标准库保证“移后源" (moved-from) string仍然保持一个有效的、可析构的状态。对于string,我们可以想象每个string都有一个指向char数组的指针。可以假定string的移动构造函数进行了指针的拷贝,而不是为字符分配内存空间然后拷贝字符。
我们使用的第二个机制是一个名为move的标准库函数,它定义在utility头文件中。目前,关于move我们需要了解两个关键点。首先,当reallocate在新内存中构造string时,它必须调用move来表示希望使用string的移动构造函数。如果它漏掉了move调用,将会使用string的拷贝构造函数。其次,我们通常不为move提供一个using声明。当我们使用move时,直接调用std::move而不是move。
reallocate成员
新内存空间。我们每次重新分配内存时都会将StrVec的容量加倍。如果StrVec为空,我们将分配容纳一个元素的空间:
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++)
alloc.construct(dest++, std::move(*elem++));
free();
// 更新我们的数据结构,执行新元素
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
13.6 对象移动
新标准的一个最主要的特性是可以移动而非拷贝对象的能力。在其中某些情况下,对象拷贝后就立即被销毁了。在这些情况下,移动而非拷贝对象会大幅度提升性能。
如我们已经看到的,我们的StrVec类是这种不必要的拷贝的一个很好的例子。在重新分配内存的过程中,从旧内存将元素拷贝到新内存是不必要的,更好的方式是移动元素。使用移动而不是拷贝的另一个原因源于IO类或unique_ptr 这样的类。这些类都包含不能被共享的资源(如指针或IO缓冲)。因此,这些类型的对象不能拷贝但可以移动。
在旧C++标准中,没有直接的方法移动对象。因此,即使不必拷贝对象的情况下,我们也不得不拷贝。如果对象较大,或者是对象本身要求分配内存空间(如string),进行不必要的拷贝代价非常高。类似的,在旧版本的标准库中,容器中所保存的类必须是可拷贝的。但在新标准中,我们可以用容器保存不可拷贝的类型,只要它们能被移动即可。
标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
13.6.1 右值引用
为了支持移动操作,新标准引入了一种新的引用类型——右值引用(rvalue reference)。所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。如我们将要看到的,右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。
回忆一下,左值和右值是表达式的属性。一些表达式生成或要求左值,而另外一些则生成或要求右值。一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。如我们所知,对于常规引用(为了与右值引用区分开来,我们可以称之为左值引用(lvalue reference)),我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:
int i = 42;
int &r = i; // 正确
int &&r1 = 1; // 正确
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; // 错误
其实有了右值表示临时对象这一观察结果, 变量是左值这一-特性并不令 人惊讶。毕竟,变量是持久的,直至离开作用域时才被销毁。
标准库move函数
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定左值上的右值引用,此函数定义在头文件utility中。move函数使用了我们将在中描述的机制来返回给定对象的右值引用。
int &&rr3 = std::move(rr1); // 正确
move调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。 我们必须认识到,调用move就意味着承诺:除了对rr1赋值或销毁它外,我们将不再使用它。在调用move之后,我们不能对移后源对象的值做任何假设。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值
如前所述,与大多数标准库名字的使用不同,对move我们不提供using声明。我们直接调用std::move而不是move。
使用move的代码应该使用std::move而不是move。这样做可以避免潜在的名字冲突。
13.6.2 移动构造函数和移动赋值运算符
类似string类(及其他标准库类),如果我们自己的类也同时支持移动和拷贝,那么也能从中受益。为了让我们自己的类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符。这两个成员类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。
类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源一这些资源的所有权已经归属新创建的对象。
StrVec::StrVec(StrVec &&s) noexcept // 移动操作不应抛出任何异常
// 成员初始化器接管s中的资源
: elements(s.elements), first_free(s.first_free), cap(s.cap) {
// 令s进入这样的状态——对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}
noexpect它通知标准库我们的构造函数不抛出任何异常
与拷贝构造函数不同,移动构造函数不分配任何新内存:它接管给定的StrVec中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr。 这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。StrVec的析构函数在first_free上调用deallocate。如果我们忘记了改变s.first_free,则销毁移后源对象就会释放掉我们刚刚移动的内存。
移动操作、标准库容器和异常
由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。我们将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。
一种通知标准库的方法是在我们的构造函数中指明noexcept。noexcept是新标准引入的。目前重要的是要知道,noexcept是我们承诺一个函数不抛出异常的一种方法。我们在一个函数的参数列表后指定noexcept。在一个构造函数中,noexcept出现在参数列表和初始化列表开始的冒号之间:
class StrVec {
public:
StrVec(StrVec&&) noexcept; // 移动构造函数
};
移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept。类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值:
StrVec &StrVec::operator=(StrVec &&rhs) noexcept{
// 直接检测自赋值
if (this != rhs) {
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = ths.cap;
// 将rhs置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
移后源对象必须可析构
合成的移动操作
与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符。但是,合成移动操作的条件与合成拷贝操作的条件大不相同。
与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。因此,某些类就没有移动构造函数或移动赋值运算符。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员:
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); // 使用合成的移动构造函数
以下情况移动构造函数将被定义为删除的函数:
- 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
- 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。
- 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
- 类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的。
如,Y是一个类,它定义了自己的拷贝构造函数但为定义自己的移动构造函数:
struct hasY{
hasY() = default;
hasY(hasY&&) = default;
Y mem;
};
hasY hy, hy2 = std::move(hy); // 错误:移动构造函数是删除的
编译器可以拷贝类型为Y的对象,但不能移动它们。类hasY显式地要求一个移动构造函数,但编译器无法为其生成。因此,hasY会有一个删除的移动构造函数。如果hasY忽略了移动构造函数的声明,则编译器根本不能为它合成一个。如果移动操作可能被定义为删除的函数,编译器就不会合成它们。
移动操作和合成的拷贝控制成员间还有最后一个相互作用关系:一个类是否定义了自己的移动操作对拷贝操作如何合成有影响。如果类定义了一个移动构造函数和或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。
【Note】
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
移动右值,拷贝左值……
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。例如,在我们的StrVec类中,拷贝构造函数接受一个const StrVec&。因此,它可以用于任何可以转换为StrVec的类型。而移动构造函数接受一个StrVec&&,因此只能用于实参是(非static)右值的情形:
StrVec v1, v2;
v1 = v2; // v2是左值;使用拷贝赋值
StrVec getVec(istream &); // getVec返回一个右值
v2 = getVec(cin); // getVec(cin)是一个右值;使用移动赋值
在第一个赋值,我们将v2传递给赋值运算符。v2的类型是StrVec,表达式v2是一个左值。因此移动版本的赋值运算符是不可行的,因为我们不能隐式地将一个右值引用绑定到一个左值。因此,这个赋值语句使用拷贝赋值运算符。
在第二个赋值中,我们赋予v2的是getVec调用的结果。此表达式是一个右值。在此情况下,两个赋值运算符都是可行的——将getVec的结果绑定到两个运算符的参数都是允许的。调用拷贝赋值运算符需要进行一次到const的转换,而StrVec&&则是精确匹配。因此,第二个赋值会使用移动赋值运算符。
……但如果没有移动构造函数,右值也被拷贝
如果一个类有一个拷贝构造函数但未定义移动构造函数,会发生什么呢?在此情况下,编译器不会合成移动构造函数,这意味着此类将有拷贝构造函数但不会有移动构造函数。如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷0贝,即使我们试图通过调用move来移动它们时也是如此:
class Foo {
public:
Foo() = default;
Foo(const Foo&); // 拷贝构造函数
// 其他
};
Foo x;
Foo y(x); // 拷贝构造函数;x是一个左值
Foo z(std::move(x)); // 拷贝构造函数;因为未定义自动构造函数
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似。
拷贝并交换赋值运算符和移动操作
我们的HasPtr版本定义了一个拷贝并交换赋值运算符,它是函数匹配和移动操作间相互关系的一个很好的示例。如果我们为此类添加一个移动构造函数,它实际上也会获得一个移动赋值运算符:
class HasPtr{
public:
// 添加的移动构造函数
HasPtr(HasPtr &&r) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }
// 赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
HasPtr& operator=(HasPtr rhs){
swap(*this, rhs);
return *this;
}
// 其他
};
现在让我们观察赋值运算符。此运算符有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动。因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。
例如,假定hp和hp2都是HasPtr对象:
hp = hp2; // hp2是一个左值;hp通过拷贝构造函数来拷贝
hp = std::move(hp2); // 移动构造函数移动hp2
不管使用的是拷贝构造函数还是移动构造函数,赋值运算符的函数体都swap两个运算对象的状态。交换HasPtr会交换两个对象的指针(及int)成员。在swap之后,rhs中的指针将指向原来左侧运算对象所拥有的string。当rhs离开其作用域时,这个string将被销毁(如果右侧运算对象是是右值的话,rhs被销毁后,右侧运算对象无法被正常使用;如果是左值的话,rhs校会的相当于一个副本,右侧运算对象不受影响)。
建议:更新三 / 五法则
所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作。这些类通常拥有一个资源, 而拷贝成员必须拷贝此资源。一般来说, 拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。
Message类的移动操作
定义了自己的拷贝构造函数和拷贝赋值运算符的类通常也会从移动操作受益。例如,我们的Message和Folder类就应该定义移动操作。通过定义移动操作,Message类可以使用string和set的移动操作来避免拷贝contents和folders成员的额外开销。
但是,除了移动folders成员,我们还必须更新每个指向原Message的Folder我们必须删除指向旧Message的指针,并添加一指向新Message的指针。
移动构造函数和移动赋值运算符都需要更新Folder指针,因此我们首先定义一个操作来完成这一共同的工作:
// 从本Message移动Folder指针
void Message::move_Folder(Message *m) {
folders = std::move(m->folders); // 使用set的移动赋值运算符
for (auto f : folders) { // 对每个Folder
f->remMsg(m); // 从Folder中删除旧Message
f->addMsg(this); // 将本Message添加到Folder中
}
m->folders.clear(); // 确保销毁m是无害的
}
Message的移动构造函数调用move来移动contents,并默认初始化自己的folders成员:
Message::Message(Message &&m): contents(std::move(m.contents)) {
move_Folders(&m); // 移动folders并更新Folder指针
}
移动辅助运算符直接检查自赋值情况:
Message& Message::operator=(Message &&rhs) {
if (this != rhs) { // 直接检查自赋值情况
remove_from_Folders();
contents = std::move(rhs.constents); // 移动赋值运算符
move_Folders(&rhs); // 重置Folders指向本Message
}
return *this;
}
移动迭代器
StrVec的reallocate成员使用了一个for循环来调用construct从旧内存将元素拷贝到新内存中。作为一种替换方法,如果我们能调用uninitialized_copy来构造新分配的内存,将比循环更为简单。但是,uninitialized_copy恰如其名:它对元素进行拷贝操作。标准库中并没有类似的函数将对象“移动”到未构造的内存中。
新标准库中定义了一种移动迭代器(move iterator)适配器。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。
我们通过调用标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。
原迭代器的所有其他操作在移动迭代器中都照常工作。由于移动迭代器支持正常的迭代器操作,我们可以将一对移动迭代器传递给算法。特别是,可以将移动迭代器传递给uninitialized_copy:
void StrVec::reallocate() {
// 分配大小两倍于当前规模的内存空间
auto newcapacity = size() " 2 * size() : 1;
auto first = alloc.allocate(newcapacity);
// 移动元素
auto last = uninitialized_copy(make_move_iterator(begin()),
make_move_iterator(end()),
first);
free();
elements = first;
first_free = last;
cap = elements + newcapacity;
}
uninitialized_copy对输入序列中的每个元素调用construct来将元素“拷贝”到目的位置。此算法使用迭代器的解引用运算符从输入序列中提取元素。由于我们传递给它的是移动迭代器,因此解引用运算符生成的是一个右值引用,这意味着construct将使用移动构造函数来构造元素。
值得注意的是,标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁掉原对象,因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。
在移动构造函数和移动赋值运算符这些类实现代码之外的地方,只有当你确信需要进行移动操作且移动操作是安全的,才可以使用std::move。
13.6.3 右值引用和成员函数
除了构造函数和赋值运算符之外,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式一个版本接受一个指向const的左值引用,第二个版本接受一个指向非const的右值引用。
例如,定义了push_back的标准库容器提供两个版本:一个版本有一个右值引用参数,而另一个版本有一个const左值引用。假定x是元素类型,那么这些容器就会定义以下两个push_back版本:
void push_back(const X&); // 拷贝
void push_back(X&&); // 移动
【Note】
区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&。
我们为StrVec类定义另一个版本的push_back:
class StrVec {
public:
void push_back(const std::string&); // 拷贝
void push_back(std::string&&); // 移动
// 其他
};
void StrVec::push_back(const string& s) {
chk_n_alloc();
alloc.construct(first_free++, s);
}
void StrVec::push_back(string &&s) {
chk_n_alloc();
alloc.construct(first_free++, std::move(s));
}
右值和左值引用成员函数
通常,在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值:
string s1 = "a value", s2 = " another";
auto n = (s1 + s2).find('a');
这里,我们在一个string右值上调用find成员,该string右值使用过连接两个string而得到的。
有时右值的使用方式令人惊讶:
s1 + s2 = "wow";
这里,我们对两个string的连接结果——一个右值,进行了赋值
但cout<<s1+s2<< endl;并得不到wow

有时我们希望强制左侧运算对象是一个左值,我们指出this的左值/右值属性的方式与定义const成员函数相同,即,在参数列表后放置一个引用限定符(reference qualifer):
class Foo{
public:
Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值
// 其他
};
Foo &Foo::operator=(const Foo &rhs) & {
// 执行将rhs赋予本对象所需的工作
return *this;
}
引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。
对于&限定的函数,我们只能将它用于左值;对于&&限定的函数,只能用于右值
一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后:
class Foo {
public:
Foo some(Mem) & const; // 错误:const限定符必须在前
Foo anotherMem() const &; // 正确
};
重载和引用函数
就像一个成员函数可以根据是否有const来区分其重载版本一样,引用限定符也可以区分重载版本。而且,我们可以综合引用限定符和const来区分一个成员函数的重载版本。例如,我们将为Foo定义一个名为data的vector成员和一个名为sorted的成员函数,sorted 返回一个Foo对象的副本,其中vector已被排序:
class Foo {
public:
Foo sorted() &&; // 可用于可改变的右值
Foo sorted() const &; // 可用于任何类型的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; // 返回副本
}
当我们对一个右值执行sorted时,它可以安全地直接对data成员进行排序。对象是一个右值,意味着没有其他用户,因此我们可以改变对象。当对一个const右值或一个左值执行sorted时,我们不能改变对象,因此就需要在排序前拷贝data。
当我们定义const成员函数时,可以定义两个版本,唯一的差别是一个版本有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; // 正确:两个版本都没有引用限定符
};
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符
本文深入探讨了C++中的对象拷贝和移动操作,包括拷贝构造函数、拷贝赋值运算符、析构函数以及新标准下的移动构造函数和移动赋值运算符。拷贝构造函数用于初始化新对象,拷贝赋值运算符用于对象间的赋值,析构函数则负责清理资源。在资源管理类中,这些成员至关重要,以确保正确处理动态内存。移动操作通过右值引用提高效率,避免不必要的拷贝。文章还介绍了如何使用std::move和右值引用来优化拷贝过程,并展示了如何为类定义拷贝控制和移动操作,以及如何使用默认和删除操作符。
818

被折叠的 条评论
为什么被折叠?



