13.1 拷贝、赋值与销毁
一个类通过定义五种特殊的成员函数来控制对象的拷贝、移动、赋值和销毁操作。这些操作称为拷贝控制操作。如果一个类没有定义这些操作,编译器会自动合成缺失的操作。 拷贝和移动构造函数:定义了当用同类型的另一个对象初始化本对象时做什么。 拷贝和移动赋值运算符:定义了将一个对象赋予同类型的另一个对象时做什么。 析构函数:定义了当此类型对象销毁时做什么。 通常,实现拷贝控制操作最困难的地方是首先认识到什么时候需要定义这些操作。
13.1.1 拷贝构造函数
拷贝构造函数:第一个参数是自身类类型的引用,且任何额外参数都有默认值。 拷贝构造函数在几种情况下都会被隐式地使用。因此拷贝构造函数通常不应该是explicit的。
class Foo {
pblic:
Foo ( ) { cout << "默认构造!" << endl; } ;
Foo ( const Foo& ) { cout << "拷贝构造!" << endl; } ;
} ;
Foo f ( Foo f) { return f; } ;
对某些类来说,合成拷贝构造函数用来阻止拷贝该类类型的对象。 拷贝构造函数用来初始化非引用类类型参数,所以自己的参数必须是引用类型。 直接初始化和拷贝初始化 直接初始化:要求编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数。 拷贝初始化:要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
string dots ( 10 , '.' ) ;
string s ( dots) ;
string s2 = dots;
string null_book = "9-999-99999-9" ;
string nines = string ( 100 , '9' ) ;
拷贝初始化依靠拷贝构造函数或移动构造函数来完成。 拷贝初始化何时发生: 用=定义变量。 将一个对象作为实参传递给一个非引用类型的形参。 从一个返回类型为非引用类型的函数返回一个对象。 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。 拷贝初始化的限制:
explicit vector ( size_type n) ;
vector ( const vector& x) ;
vector< int > v1 ( 10 ) ;
vector< int > v2 = 10 ;
void f ( vector< int > ) ;
f ( 10 ) ;
f ( vector < int > ( 10 ) ) ;
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝、移动构造函数,直接创建对象。
string null_book = "9-999-99999-9" ;
string null_book ( "9-999-99999-9" ) ;
13.1.2 拷贝赋值运算符
如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。 赋值运算符通常应该返回一个指向其左侧运算对象的引用。
Sales_data trans, accum;
trans = accum;
class Foo {
public :
Foo& operator = ( const Foo& ) ;
}
Sales_data& Sales_data:: operator = ( const Sales_data & rhs)
{
bookNo = rhs. bookNo;
units_sold = rhs. units_sold;
revenue = rhs. revenue;
return * this ;
}
13.1.3 析构函数
析构函数释放对象使用的资源,并销毁对象的非static数据成员。 析构函数没有返回值,不接受参数,不能被重载。 析构函数先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。 隐式销毁一个内置指针类型的成员不会delete它所指向的对象。 智能指针是类类型,所以具有析构函数。 无论何时一个对象被销毁,就会自动调用其析构函数: 变量在离开其作用域时被销毁。 当一个对象被销毁时,其成员被销毁。 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。 对于临时对象,当创建它的完整表达式结束时被销毁。
{
Sales_data * p = new Sales_data;
auto p2 = make_shared < Sales_data> ( ) ;
Sales_data item ( * p) ;
vector< Sales_data> vec;
vec. push_back ( * p2) ;
delete p;
}
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。 析构函数体自身并不直接销毁成员。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁的过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
13.1.4 三/五法则
如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。
class HasPtr {
public :
HasPtr ( const std:: string & s = std:: string ( ) ) :
ps ( new std:: string ( s) ) , i ( 0 ) { }
~ HasPtr ( ) { delete ps; }
} ;
HasPtr f ( HasPtr hp)
{
HasPtr ret = hp;
return ret;
}
HasPtr p ( "some values" ) ;
f ( p) ;
HasPtr q ( p) ;
需要拷贝操作的类也需要赋值操作,反之亦然。
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来定义为删除的。 =delete与=default的不同: =delete必须出现在函数第一次声明的时候。 可以对任何函数指定=delete。
struct NoCopy {
NoCopy ( ) = default ;
NoCopy ( const NoCopy& ) = delete ;
NoCopy & operator = ( const NoCopy& ) = delete ;
~ NoCopy ( ) = default ;
} ;
当希望引导函数匹配过程时,删除函数有时也是有用的。 析构函数不能是删除的成员。如果析构函数被删除,就无法销毁此类型的对象了。 对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
struct NoDtor {
NoDtor ( ) = default ;
~ NoDtor ( ) = delete ;
} ;
NoDtor nd;
NoDtor * p = new NoDtor ( ) ;
delete p;
合成的拷贝控制成员可能是删除的。 本质上,如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。 希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。
13.2 拷贝控制和资源管理
通常,管理类外资源的类必须定义拷贝控制成员,这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。 两种选择:定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
13.2.1 行为像值的类
赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。 当编写赋值运算符时,需要记住: 如果将一个对象赋予它自身,赋值运算符必须能正确工作。 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。 一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中,当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。
class HasPtr {
public :
HasPtr ( const std:: string & s = std:: string ( ) ) :
ps ( new std:: string ( s) ) , i ( 0 ) { }
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 string ( * rhs. ps) ;
delete ps;
ps = newp;
i = rhs. i;
return * this ;
}
HasPtr& HasPtr:: operator = ( const HasPtr & rhs) {
delete ps;
ps = new string ( * rhs. ps) ;
i = rhs. i;
return * this ;
}
13.2.2 定义行为像指针的类
令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。
13.3 交换操作
与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。 使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。
13.4 拷贝控制示例
拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在private的工具函数中完成。
13.5 动态内存管理类
13.6 对象移动
新标准的一个最主要的特性是可以移动而非拷贝对象的能力。 标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
13.6.1 右值引用
为了支持移动操作,新标准引入了右值引用,通过&&而不是&来获得右值引用。 右值引用重要性质:只能绑定到一个将要销毁的对象。 一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。 常规引用(左值引用):不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。 左值持久,右值短暂。 使用右值引用的代码可以自由地接管所引用的对象的资源。 右值引用指向将要被销毁的对象,因此,可以从绑定到右值引用的对象“窃取”状态。
int i = 42 ;
int & r = i;
int && rr = i;
int & r2 = i* 42 ;
const int & r3 = i* 42 ;
int && rr2 = i* 42 ;
变量是左值,因此不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
int && rr1 = 42 ;
int && rr2 = rr1;
可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。 使用move的代码应该使用std::move而不是move。这样做可以避免潜在的名字冲突。
int && rr3 = std:: move ( rr1) ;
13.6.2 移动构造函数和移动赋值运算符
类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。 与拷贝构造函数不同,移动构造函数不分配任何新内存。因此,移动操作通常不抛出任何异常,需要通知标准库。其中一个方法是在构造函数中指明noexcept,它出现在参数列表和初始化列表开始的冒号之间。 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
StrVec :: StrVec ( strVec && s) noexcept
: elements ( s. elements) , first_free ( s. first_free) , cap ( s. cap)
{
s. elements = s. first_free = s. cap = nullptr ;
}
在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
StrVec & StrVec:: operator = ( StrVec && rhs) noexcept {
if ( this != & rhs) {
free ( ) ;
elements = rhs. elements;
first_free = rhs. first_free;
cap = rhs. cap;
rhs. elements = rhs. first_free = rhs. cap = nullptr ;
}
return * this ;
}
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
struct X {
int i;
std:: string s;
} ;
struct hasX {
X mem;
} ;
X x, x2 = std:: move ( x) ;
hasX hx, hx2 = std:: move ( hx) ;
struct hasY {
hasY ( ) = default ;
hasY ( hasY&& ) = default ;
Y mem;
} ;
hasY hy, hy2 = std:: move ( hy) ;
与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。 定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。 如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。
StrVec v1, v2;
v1 = v2;
StrVec getVec ( istream & ) ;
v2 = getVec ( cin) ;
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似。
class Foo {
public :
Foo ( ) = default ;
Foo ( const Foo& ) ;
} ;
Foo x;
Foo y ( x) ;
Foo z ( std:: move ( x) ) ;
拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数—左值被拷贝,右值被移动。
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) ;
所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。 一个移动迭代器(C++11)通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。 通过调用标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。
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;
}
建议 :不要随意使用移动操作。由于一个移后源对象具有不确定的状态,对其调用std::move是危险的。当调用move时,必须绝对确认移后源对象没有其他用户。在移动构造函数和移动赋值运算符这些类实现的代码之外的地方,只有当确信需要进行移动操作且移动操作是安全的,才可以使用std::move。
13.6.3 右值引用和成员函数
区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&。
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) ) ;
}
StrVec vec;
string s = "some string or another" ;
vec. push_back ( s) ;
vec. push_back ( "done" ) ;
右值和左值引用成员函数,引用限定符可以是&或&&。
class Foo {
public :
Foo & operator = ( const Foo& ) & ;
} ;
Foo & Foo:: operator = ( const Foo & rhs) & {
return * this ;
}
Foo & retFoo ( ) ;
Foo retVal ( ) ;
Foo i, j;
i= j;
retFoo ( ) = j;
retVal ( ) = j;
i = retVal ( ) ;
class Foo {
public :
Foo sorted ( ) && ;
Foo sorted ( ) const & ;
private :
vector< int > data;
} ;
Foo Foo :: sorted ( ) &&
{
sort ( data. begin ( ) , data. end ( ) ) ;
return * this ;
}
Foo Foo :: sorted ( ) const & {
Foo ret ( * this ) ;
sort ( ret. data. begin ( ) , ret. data. end ( ) ) ;
return ret;
}
retVal ( ) . sorted ( ) ;
retFool ( ) . sorted ( ) ;
指出this的左值/右值属性的方式与定义const成员函数相同,即在参数列表后放置一个引用限定符。(C++11)引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。 一个函数可以同时用const和引用限定,在此情况下,引用限定符必须跟随在const限定符之后。
class Foo {
public :
Foo someMem ( ) & const ;
Foo anotherMem ( ) const & ;
} ;
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。
class Foo {
public :
Foo sorted ( ) && ;
Foo sorted ( ) const ;
using Comp = bool ( const int & , const int & ) ;
Foo sorted ( Comp* ) ;
Foo sorted ( Comp* ) const ;
} ;