一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(moveconstructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。称这些操作为拷贝控制操作(copy control)。
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo{
public:
Foo();//默认构造函数
Foo(const Foo&);//拷贝构造函数
// ...
}
拷贝构造函数通常不应该是 explicit
的。
如果没有为一个类定义拷贝构造函数,编译器会自动定义一个。但即使定义了其他构造函数,编译器也会合成一个拷贝构造函数。
合成拷贝构造函数用来阻止我们拷贝该类类型的对象。一般情况下,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非 static
成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。
不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。
string dots(10,'.');//直接初始化
string s(dots);//直接初始化
string s2 = dots;//拷贝初始化
string nullbook = "9-999-99999-9";//拷贝初始化
string nines = string(100,'9');//拷贝初始化
使用直接初始化时,实际上是要求编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数。
使用拷贝初始化时,要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话,还需要进行类型转换。
拷贝初始化通常要使用构造函数来完成。
拷贝初始化会发生的情况:
=
定义变量时参数和返回值
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝构造函数被用来初始化非引用类类型参数。
拷贝初始化自己的参数必须是引用类型。
拷贝初始化的限制
vector<int> v1(10); //正确:直接初始化
vector<int> v2= 10; //错误:接受大小参数的构造函数是explicit的
void f(vector<int>);//f的参数进行拷贝初始化
f(10); //错误:不能用一个 explicit的构造函数拷贝一个实参
f(vector<int>(10));//正确:从一个int直接构造一个临时vector
当传递一个实参或从函数返回一个值时,不能隐式使用一个 explicit
构造函数。如果想要使用 explicit
构造函数,就必须显式使用。
编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。
string null_book = "9-999-99999-9";//拷贝初始化
//改写为:
string null_book ("9-999-99999-9");//编译器略过了拷贝构造函数
即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(例如,不能是private的)。
与类控制其对象初始化一样,类也可以控制其对象如何赋值:
Sales_data trans, accum;
trans = accum;//使用 Sales data的拷贝赋值运算符
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
合成拷贝赋值运算符
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。
如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static
成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
//等价于合成拷贝赋值运算符
Sales_data&
Sales_data::operator=(const Sales_data &rhs){
bookNo = rhs.bookNo;//调用string: :operator=
units_sold = rhs.units sold;//使用内置的int赋值
revenue = rhs.revenue;//使用内置的 double赋值
return *this;//返回一个此对象的引用
}
析构函数执行与构造函数相反的操作:构造函数初始化对象的非 static
数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非 static
数据成员。
析构函数是类的一个成员函数,名字由波浪号~
接类名构成。它没有返回值,也不接受参数。
class Foo{
public:
~Foo();//析构函数
//...
};
析构函数不能被重载。对一个给定类,只会有唯一 一个析构函数。
在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。 通常,析构函数释放对象在生存期分配的所有资源。
隐式销毁一个内置指针类型的成员不会 delete
它所指向的对象。
与普通指针不同,智能指针是类类型,所以具有析构函数。智能指针成员在析构阶段会被自动销毁。
调用析构函数:
1.变量在离开其作用域时被销毁。当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
2.当一个对象被销毁时,其成员被销毁。
3.容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
4.对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
5.对于临时对象,当创建它的完整表达式结束时被销毁。
有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。
而且,一个类还可以定义一个移动构造函数和一个移动赋值运算符。
通常情况下,只需要其中一个操作,而不需要定义所有操作的情况是很少的,因为这些操作通常应该被看作一个整体。
确定类是否需要自己版本的拷贝控制成员的两个基本原则:
1.判断它是否需要一个析构函数。如果它需要自定义一个析构函数,几乎可以肯定它也需要一个拷贝构造函数和一个拷贝复制运算符。
2.如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符,反之亦然。但是需要拷贝构造函数不意味着一定需要析构函数。
需要拷贝操作的类也需要赋值操作,反之亦然
当需要定义析构函数,一般意味着在类内有指向动态内存的指针成员。因为合成析构函数只会销毁指针成员而不会 delete
,所以需要定义析构函数。
这种情况下,如果使用合成的拷贝和赋值操作,它们会直接复制该指针,这就导致可能有多个指针指向相同的一块动态内存,当有一个类对象执行了析构函数,该内存就会被释放,其他指针就变成了悬空指针。
所以需要定义拷贝和复制操作。
可以通过将拷贝控制成员定义为=default
来显式地要求编译器生成合成的版本。
如果在类内使用 =default
,合成的函数将隐式地声明为内联函数。如果不希望合成内联函数,则应该在类外定义处使用 =default
。
只能对默认构造函数或拷贝构造成员这些具有合成版本的函数使用 =default
。
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。
但是有一些例外需要阻止类进行拷贝或赋值:
如 iostream
类阻止了拷贝,以避免多个对象写入或读取相同的 IO
缓冲,
可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。
删除的函数的性质:虽然声明了它们,但是不能以任何方式使用它们。
struct NoCopy {
NoCopy()=default;//使用合成的默认构造函数
NoCopy(const NoCopy&) = delete;//阻止拷贝
NoCopy &operator=(const NoCopy&)= delete;// 阻止赋值
~NoCopy() = default;//使用合成的析构函数
//其他成员
}
与 =default
不同,=delete
必须出现在函数第一次声明的时候。=default
直到编译器生成代码时才需要。
与=default
的另一个不同之处是,我们可以对任何函数指定=delete
(只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default
)。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。
析构函数不能是删除的成员。
对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针,但可以动态分配这种类型的对象。
合成的拷贝控制成员可能是删除的:
private
的),则该类的合成析构函数、合成拷贝构造函数和默认构造函数被定义为删除的。本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
声明但不定义一个函数是合法的,试图访问一个未定义的成员将导致一个链接时错误。
希望阻止拷贝的类应该使用=delete
来定义它们自己的拷贝构造函数和拷贝赋值运算,而不应该将它们声明为 private
的。
通常,管理类外资源的类必须定义拷贝控制成员。因为它们需要定义析构函数来释放对象所分配的资源。
通过定义不同的拷贝操作可以实现两种效果:
1.使类的行为看起来像一个值。这种情况拷贝类时副本和原对象是完全独立的。
2.使类的行为看起来像一个指针。这种情况拷贝类时副本和原对象使用相同的底层数据。改变副本也会改变原对象。如 shared_ptr
看起来像指针。
还有一些其他的类,如 IO
类型和 unique_ptr
不允许拷贝和赋值,所以它们的行为既不像值也不像指针。
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。
类值拷贝运算符
赋值运算符通常组合析构函数和构造函数的操作,它会销毁左侧运算对象的资源并从右侧运算对象拷贝数据。
当编写赋值运算符时,需要记住:
当编写赋值运算符时,一般先将右侧运算对象拷贝到一个局部临时对象中。象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
对于行为类似指针的类,需要定义拷贝构造函数和拷贝赋值运算符来拷贝指针成员本身而不是它指向的值。还需要析构函数来释放分配的内存,但是注意不能简单地直接释放关联的内存,应确保最后一个指向该内存的指针也销毁掉后才释放内存。
令一个类展现类似指针的行为的最好方法是使用shared_ptr
来管理类中的资源。
拷贝(或赋值)一个 shared_ptr
会拷贝(赋值)shared_ptr
所指向的指针。shared_ptr
类自己记录有多少用户共享它所指向的对象。当没有用户使用对象时,shared_ptr
类负责释放资源。
直接管理资源时,使用引用计数很有用:
计数器应该保存在动态内存中,当拷贝或赋值对象时,拷贝指向计数器的指针。
class HasPtr {
public:
HasPtr(const std::string& s = new std::string()): ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
HasPtr(const HasPtr& p): ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr& operator=(const HasPtr& rhs) {
++*rhs.use; // 递增右侧运算对象的引用计数
if(--*use == 0) { // 递减本对象的引用计数
delete ps; // 如果引用计数变为0,释放本对象的资源。
delete use; // 释放计数器内存
}
ps = rhs.ps; // 将数据从 rhs 拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}
~HasPtr() { if(--*use == 0) { delete ps; delete use; } }
private:
std::string *ps;
int i;
std::size_t *use; // 引用计数器,用来记录有多少个对象共享 *ps 的成员
}
管理资源的类通常会定义 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);//交换指针,而不是string数据
swap(lhs.i,rhs.i);//交换int成员
}
与拷贝控制成员不同,swap
并不是必要的。但是,对于分配了资源的类,定义swap
可能是一种很重要的优化手段。
swap
函数应该调用 swap
,而不是 std::swap
。使用 using std::swap
的目的是保证当某个成员没有自定义的 swap
版本时,能够执行标准库版本。
定义了 swap
的类通常用 swap
来定义赋值运算符,注意这时参数要使用值传递而非引用传递。
//注意rhs是按值传递的,意味着HasPtr的拷贝构造函数
//将右侧运算对象中的string拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs){
//交换左侧运算对象和局部变量rhs的内容
swap (*this, rhs) ;//rhs现在指向本对象曾经使用的内存
return *this;//rhs被销毁,从而delete 了 rhs中的指针
}
使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值
某些类需要在运行时分配可变大小的内存空间,这种类通常可以使用标准库容器来保存它们的数据。但有时某些类需要自己分配内存,必须定义自己的拷贝控制成员来管理所分配的内存。
C++11 的一个最主要的特性是可以移动而非拷贝对象的能力。
在重新分配内存的过程中,从旧内存将元素拷贝到新内存不是必要的,更好的方式是移动元素。
使用移动而不是拷贝的另一个原因源于IO
类或unique_ptr
这样的类。这些类都包含不能被共享的资源(如指针或IO
缓冲)。因此,这些类型的对象不能Kao贝但可以移动。
标准库容器、string
和 shared_ptr
类既支持移动也支持拷贝。IO
类和unique_ptr
类可以移动但不能拷贝。
为了支持移动操作,引入了右值引用。所谓右值引用就是必须绑定到右值的引用。通过&&
而不是&
来获得右值引用。
右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。因此,可以自由地将一个右值引用的资源“移动”到另一个对象中。
一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
为了将右值引用区分,将常规引用称之为左值引用。
可以将一个左值引用绑定到返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符上。
可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:
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 &&rrl=42;//正确:字面常量是右值
int &&rr2 = rr1; //错误:表达式rr1是左值!
变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
标准库 move 函数
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。还可以通过调用一个名为move
的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility
中。
int &&rr3=std::move(rr1);//ok
move
告诉编译器:希望向一个右值一样处理一个左值。调用 move
之后,不能对移后源对象的值做任何假设。
使用 std::move
函数的程序员要保证:对一个左值调用 move
后,不再使用该左值的值,除非销毁它或对它重新赋值。
注意:使用 move
函数的代码应该是 std::move
而非直接用 move
,避免名字冲突。
类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
与拷贝构造函数不同,移动构造函数不分配任何新内存。因此,引动操作通常不会抛出任何异常。
不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept
。
class Strvec{
public:
StrVec(Strvec&&) noexcept;//移动构造函数
//其他成员的定义
};
StrVec::StrVec(StrVec &&s) noexcept :/*成员初始化器*/
{/*构造函数体*/ }
移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept
。
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。
在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符。但是,合成移动操作的条件与合成拷贝操作的条件大不相同。
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
定义了一个移动构造函数或移动赋值运算符的类必须也定义自已的拷贝操作。否则,这些成员默认地被定义为删除的。
区分移动和拷贝的重载函数通常有一个版本接受一个 const T&
,而另一个版本接受一个T&&
。
对于&
限定的函数,我们只能将它用于左值;对于&&
限定的函数,只能用于右值:
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。
拷贝并交换:涉及赋值运算符的技术,首先拷贝右侧运算对象,然后调用swap
来交换副本和左侧运算对象。
拷贝赋值运算符:接受一个本类型对象的赋值运算符版本。通常,拷贝赋值运算符的参数是一个const
的引用,并返回指向本对象的引用。如果类未显式定义拷贝赋值运算符,编译器会为它合成一个。
拷贝构造函数:一种构造函数,将新对象初始化为同类型另个对象的副本。当向函数传递对象,或以传值方式从函数返回对象时,会隐式使用拷贝构造函数。如果我们未提供拷贝构造函数,编译器会为我们合成一个。
拷贝控制:特殊的成员函数,控制拷贝、移动、赋值及销毁本类类型对象时发生什么。如果类未定义这些操作,编译器会为它合成恰当的定义。
拷贝初始化:一种初始化形式,当我们使用=
为一个新创建的对象提供初始化器时,会使用拷贝初始化。如果我们向函数传递对象或以传值方式从函数返回对象,以及初始化一个数组或一个聚合类时,也会使用拷贝初始化。
逐成员拷贝/赋值:合成的拷贝与移动构造函数及拷贝与移动赋值运算符的工作方式。合成的拷贝或移动构造函数依次处理每个非static
数据成员,通过从给定对象拷贝或移动对应成员来初始化本对象成员;拷贝或移动赋值运算符从右侧运算对象中将每个成员拷贝赋值或移动赋值到左侧运算对象中。内置类型或复合类型的成员直接进行初始化或赋值。类类型的成员通过成员对应的拷贝/移动构造函数或拷贝/移动赋值运算符进行初始化或赋值。
移动赋值运算符:接受一个本类型右值引用参数的赋值运算符版本。通常,移动赋值运算符将数据从右侧运算对象移动到左侧运算对象。赋值之后,对右侧运算对象执行析构函数必须是安全的。
移动构造函数:一种构造函数,接受一个本类型的右值引用。通常,移动构造函数将数据从其参数移动到新创建的对象中。移动之后,对给定的实参执行析构函数必须是安全的。
合成赋值运算符:编译器为未显式定义赋值运算符的类创建的(合成的)拷贝或移动赋值运算符版本。除非定义为删除的,合成赋值运算符会逐成员地将右侧运算对象赋予(移动到)左侧运算对象。
合成拷贝/移动构造函数:编译器为未显式定义对应的构造函数的类生成的拷贝或移动构造函数版本。除非定义为删除的,合成拷贝或移动构造函数分别通过从给定对象拷贝或移动成员来逐成员地初始化新对象。
合成析构函数:编译器为未显式定义析构函数的类创建的(合成的)版本。合成析构函数的函数体为空。