C++系列:拷贝控制操作
这篇文章介绍对象拷贝、移动、赋值和销毁相关的内容。
基本概念
对象的拷贝、移动、赋值和销毁通过定义五种特殊的成员函数来完成,包括:拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作(copy control)。
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。不过,这些默认定义的函数能否正常工作,取决于具体场景,在某些场景下可能会出现错误。
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
1 | class Foo { |
拷贝构造函数的第一个参数必须是一个引用类型。参数也可以是非 const 的引用,但建议使用 const 引用。另外,该函数会隐式地使用,因此,拷贝构造函数不能定义为 explicit。
合成拷贝构造函数
如果没有为一个类定义拷贝构造函数,编译器会自动生成一个。与合成默认构造函数不同,即使定义了其它构造函数,编译器也会合成一个拷贝构造函数(没有任何一个构造函数,编译器才会合成默认构造函数)。
一般情况下,合成的拷贝构造函数会将参数的成员逐个拷贝到正在创建的对象中。如何拷贝,取决于成员的类型:
- 类类型:成员为类类型,使用其拷贝函数来来拷贝;
- 内置类型:直接拷贝;
- 数组:逐元素拷贝数组类型的成员,如果数组类型是类类型,则使用元素的拷贝构造函数来进行拷贝。
以 Sales_data 为例:
1 | class Sales_data { |
拷贝初始化
直接初始化和拷贝初始化之间的差异:
1 | string dots(10, '.'); // 直接初始化 |
当使用直接初始化时,实际上是要求编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数。当使用拷贝初始化时,要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化通常使用拷贝构造函数来完成,但是,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。
拷贝初始化不仅在 = 定义变量进会发生,在下列情况也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参;
- 从一个返回类型为非引用类型的函数返回一个对象;
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员;
拷贝赋值运算符
与类控制其对象如何初始化,类也可以控制其对象如何赋值:
1 | Sales_data trans, accum; |
与拷贝构造函数一样,如果类未定义自己的拷贝赋值运行符,编译器会为它合成一个。
重载赋值运算符
重载运算符本质上是函数,其名字由 operator 关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为 operator= 的函数。类似于任何其他函数,运算符函数也有一个返回类型的参数列表。
重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的 this 参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。
拷贝赋值运算符接受一个与其所在类相同类型的参数:
1 | class Foo { |
为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。另外值得注意的是, 标准库通常要求保存到容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。
合成拷贝赋值运算符
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符(synthesized copy-assignment operator). 这默认为执行以下操作:它会将右侧运算符对象的每个非 static 成员赋予左侧运算对象的对应成员,对于数组类型的成员,逐个赋值数组元素。
作为一个例子,下面的代码等价于 Sales_data 的合成赋值运算符:
1 | // 等价于合成拷贝运算符 |
析构函数
析构函数执行与构造函数相反的操作:构造函数初始化对象的非 static 数据成员,还可能做一些其它工作;析构函数释放对象使用的资源,并销毁对象的非 static 数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数:
1 | class Foo { |
由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。
析构函数完成什么工作
如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分。在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后再销毁成员。成员按初始化顺序的逆序销毁。
在一个析构函数中,析构部分是隐式的,成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
隐式销毁一个内置指针的成员不会 delete 它所指向的对象。
与普通指针不同,智能指针是类类型,所有具有析构函数。因此,与普通指针不同,智能指针成员在析构阶段会被自动销毁。
什么时候会调用析构函数
无论何时销毁一个对象,就会调用其析构函数:
- 变量在离开其作用域是被销毁;
- 当一个对象被销毁时,其成员被销毁;
- 容器(无论是标准库还是数组)被销毁时,其元素被销毁;
- 对于动态分配的对象,当对指向它的指针应用 delete 运算符时被销毁;
- 对于临时对象,当创建它的完整表达式结束时被销毁。
由于析构函数自动运行,我们的程序可以按需要分配资源,而(通常)无须担心何时释放这些资源。
例如,下面的代码定义了四个 Sales_data 对象:
1 | { // 新作用域 |
每一个 Sales_data 对象都包含一个 string 成员,它分配动态内存来保存 bookNo 成员中的字符。但是,我们的代码唯一需要直接管理的内存就是我们直接分配的 Sales_data 对象。我们的代码只需直接释放绑定到 p 的动态对象。
其他 Sales_data 对象会在离开作用域时被自动销毁。当程序块结束时,vec, p2 和 item 都离开了作用域,意味着在这些对象上分别执行 vector, shared_ptr 和 Sales_data 的析构函数。vector 的析构函数会销毁我们添加到 vec 的元素。shared_ptr 的析构函数会递减 p2 指向的对象的引用计数。在本例中,引用计数会变为 0,因此 shared_ptr 的析构函数会 delete p2 分配的 Sales_data 对象。
在所有情况下,Sales_data 的析构函数都会隐式地销毁 bookNo 成员,销毁 bookNo 会调用 string 的析构函数,它会释放用来保存 ISBN 的内存。
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数(synthesized destructor)。类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析板函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数的函数体就为空。
例如,下面的代码片断等价于 Sales_data 的合成析构函数:
1 | class Sales_data { |
在(空)析构函数体执行完毕后,成员会被自动销毁。特别的,string 的析构函数会被调用,它将释放 bookNo 成员所用的内存。
认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析析函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的的另一部分而进行的。
三/五法则
在新标准下,有五个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符、析构函数、移动构造函数和移动赋值运算符,我们先讲前三个,后面二个在后续内容讲述。
C++ 语言并不要求我们定义所有这些操作:可以只定义其中一个或两个,而不必定义所有。但是,这些操作通常应该被看作一个整体。通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的。
需要析构函数的类也需要拷贝和赋值操作
在前面说到,当指向一个对象的引用或指针离开作用域时,析构函数不会执行。在类中,如果有指针的数据成员,也需要通过析构函数来删除,以 HasPtr 例:
1 | class HasPtr { |
在 HasPtr 类中,构造函数中分配的内存将在对象销毁时被释放。不过,这个版本的拷贝构造函数和拷贝赋值运算符使用默认的合成版本,将会引入一个严重的错误。这些函数简单拷贝指针成员,这意味着多个 HasPtr 对象可能指向相同的内存:
1 | HasPtr f(HasPtr hp) // HasPtr 是传值参数,所以将被拷贝 |
当 f 返回时,hp 和 ret 都被销毁,在两个对象上都会调用 HasPtr 的析构函数。此析构函数会 delete ret 和 hp 中的指针成员。但这两个对象包含相同的指针值。此代码会导致此指针被 delete 两次。这会引发未知的错误。
所以,如果一个类需要自定义析构函数,几乎可以肯定它需要自定义拷贝赋值运算符和拷贝构造函数。
需要拷贝拷贝操作的类也需要赋值操作,反之亦然
default 关键字
我们可以通过将拷贝控制成员定义为 =default 来显式地要求编译器生成合成的版本:
1 | class Sales_data { |
当我们在类内使用 =default 修饰成员的声明时,合成的函数将隐式地声明为内联的。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用 = default, 就像对拷贝赋值运算符所做的那样。
我们只能对具有合成版本的成员函数使用 =default (即,默认构造函数或拷贝控制成员)。
阻止拷贝(delete)
在某些场景下,需要某种机制阻止拷贝和赋值。例如,iostream 类阻止了拷贝,以避免多个对象写入或读取相同的 IO 缓冲。
定义删除的函数
在新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted fuction)来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上 =delete 来指定我们希望将它定义为删除的:
1 | class NoCopy { |
=delete 通知编译器我们不希望定义这些成员。
与 =default 不同的是,可以对任何函数指定 =delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用 =default)。虽然删除函数的主要用途是禁止拷贝控制成员,但当月我们希望引导函数匹配过程是时,删除函数有时也是有用的。
另外,析构函数不能是删除的成员,否则无法销毁此类型的对象。
拷贝控制和资源管理
通常,管理类外资源如指针的类必须定义拷贝控制成员。一般来说,有两种选择:1)当值处理;2)当指针处理。
类的行为像一个值,当我们拷贝一个对象时,副本和原对象是完全独立的,改变副本不会对原对象有任何影响,反之亦然。
类的行为像一个指针则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象,反之亦然。
在标准库中,标准容器类和 string 类的行为像一个值。shared_ptr 类提供类似指针的行为,而 IO 类型和 unique_ptr 不允许拷贝赋值,因此它们的行为既不像值也不像指针。
以 HasPtr 为例,展示这两种行为的差异。
行为像值的类
为了提供类值的行为,要求每一个对象都应该拥有一份自己的拷贝。这意味着于 ps 指向的 string,每个 HasPtr 对象都必须有自己的拷贝。为了实现类值的行为, HasPtr 需要:
- 定义一个拷贝构造函数,完成 string 的拷贝,不是拷贝指针;
- 定义一个析构函数来释放 string;
- 定义一个拷贝赋值运算符来释放对象当前的 string, 并从右侧运算对象拷贝 string.
类似版本的 HasPtr 如下所示:
1 | class HasPtr { |
类值拷贝赋值运算符
赋值运算符通常结合了析构函数和构造函数的操作,即先 delete 当前的指针再拷贝内容。
1 | HasPtr& HasPtr::operator=(const HasPtr &rhs) |
行为像指针的类
对于行为类似指针的类,需要定义拷贝构造函数和拷贝运算符,来拷贝指针成员本身而不是它指向的 string. 在这种情况下,使用引用计数(reference count)技术来决定是否释放指针指向的内容。
引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数初始化为 1;
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享;
- 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为 0,则析构函数释放状态;
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
计数器一般保存在动态内存中,当拷贝对象时直接拷贝计数器指针就行,使用这种方法,副本和原对象都会指向相向的计数器。
定义一个使用引用计数的类
1 | class HasPtr { |
在此,我们添加了一个名为 use 的数据成员,它记录有多少对象共享相同的 string。
修改引用计数
当拷贝或赋值一个 HasPtr 对象时,我们希望副本和原对象都指向相同的 string. 即当拷贝一个 HasPtr 时,我们将拷贝 ps 本身,而不是 ps 指向的 string. 当我们进行拷贝时,还会递增该 string 关联的计数器。
析构函数需要根据计数器的值来决定是否释放状。
1 | HasPtr::~HasPtr() |
拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作,即,它必须递增右侧运算对象的引用计数(拷贝构造函数的工作),并递减左侧运算对象的引用计数,在必要时释放使用的内存(析构函数的工作)。
1 | HasPtr& HasPtr::operator=(const HasPtr &rhs) |
对象移动
在 C++ 新标准中加入了移动对象的能力。在一些场景下,对象拷贝之后就立即被销毁了,此时,移动而非拷贝对象会大幅度提升性能。
右值引用
为了支持移动操作,新标准引入了一种新的引用类型————右值引用(rvalue reference)。所谓右值引用就是必须绑定到右值的引用。通过 && 而不是 & 来获得右值引用。
一般而言,一左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。如我们所知,对于常规引用(左值引用),不能并其绑定到要求转换的表达式、字面常量和返回右值的表达式。右值引用着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:
1 | int i = 42; |
返回左值引用的函数、连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。我们可以将一个左值引用绑定到这类表达式的结果上。
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个 const 的左值引用或者一个右值引用绑定到这类表达式上。
左值持久:右值短暂
左值与右值的区别:左值有持久的状态,而右值要么是字面常量,要么是表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,我们得知:
- 所引用的对象将要被销毁
- 该对象没有其他用户
这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
变量是左值
变量是一个左值,带来的结果就是,我们不能将一个右值引用绑定到一个右值引用类型的变量上:
1 | int &&rr1 = 42; // 正确:字面常量是右值 |
标准库 move 函数
标准库 move 函数可以将一个左值转换为对应的右值引用类型,如下例:
1 | int &&rr3 = std::move(rr1); // ok |
move 调用告诉编译器:我们有一个左值,但我们期望像一个右值一样处理它。我们必须认识到,调用 move 就意味着承诺:除了对 rr1 赋值或销毁它外,我们将不再使用它。在调用 move 之后,我们不能对移后源对象的值作做任何假设。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
使用 move 的代码应该使用 std::move 而不是 move,这样可以避免潜在的名字冲突。
移动构造函数和移动赋值运算符
移动构造函数
类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态————销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源————这些资源在所有权已经归属新创建的对象。
以 StrVec 为例,其中三个数据成员 elements, first_free 和 cap 都是指针类型:
1 | StrVec::StrVec(StrVec &&s) noexcept // 移动操作不应该抛出任何异常 |
与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的 StrVec 中的内存。在接管内存之后,它将给定对象中指针都置为 nullptr。
noexcept 是新标准引入的,它承诺一个函数不抛出异常。
不抛出异常的移动构造函数和移动赋值运算符必须记为 noexcept。
移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作,与移动构造函数一样,如果我们的移动赋值符不抛出任何异常,我们就应该将它标记为 noexcept。
1 | StrVec &StrVec::operator=(StrVec &&rhs) noexcept |
移后源对象必须可析构
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。我们的 StrVec 的移动操作满足这一要求,这是通过将移后源对象的指针成员置为 nullptr 来实现的。
合成的移动操作
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数和移动赋值运算符。
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
右值移动,右值拷贝
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。在拷贝构造函数中接受一个 const 的引用,它可以既可以接受左值的引用,也可以接受右值的引用。
如果类没有移动构造函数,右值也被拷贝。
更新三/五法则
所有五个拷贝控制成员函数应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作。这些类通常拥有一个资源,而拷贝成员函数必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。
右值引用和成员函数
除了构造函数和赋值运算符之外,一个成员函数也可以同时提供拷贝和移动两个版本。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式————一个版本接受一个指向 const 的左值引用,第二个版本接受一个指向非 const 的右值引用。
例如,定义了 push_back 的标准库容器提供两个版本。假定 X 是元素类型,两个版本的定义如下:
1 | void push_back(const X&); // 拷贝:绑定到任意类型的 X |
右值和左值引用成员函数
引用限定符(reference qualifier)指出左侧运算对象(this 指向的对象)的左值/右值属性,引用限定符可以是 & 或 &&,分别指出 this 可以指向一个左值或右值。类似 const 限定符,引用限定符只能用于(非 static)成员函数。且必须同时在函数的声明和定义中。
1 | class Foo { |
对于 & 限定的函数,我们只能用于左值;对于 && 限定的函数,只能用于右值。
一个函数可以同时用 const 和引用限定。在些情况下,引用限定符必须跟随在 const 限定符之后:
1 | class Foo { |