一个类可以通过定义五种特殊的成员函数来控制此类型的对象拷贝、移动、赋值和销毁时的具体实现:
如果一个构造函数的第一个参数是自身类类型的引用(如果不是引用,在传递参数时会调用拷贝构造函数即本身),且额外任何参数都有默认值,此构造函数为拷贝构造函数。如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。对于某些类,合成拷贝构造函数用来禁止该类型对象的拷贝。
拷贝初始化通常使用拷贝构造函数来完成,但也会使用移动构造函数来完成。
拷贝初始化将在如下情况下发生:
如果使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化有比较大的区别:
vector v1(10); //正确:直接初始化
vector v2 = 10; //错误:接受大小参数的构造函数是explicit的
void f(vector); //f的参数进行拷贝初始化
f(10); //错误:不能用一个explicit的构造函数拷贝一个实参
f(vector(10)); //正确:从int直接构造一个临时vector
如果我们没有为一个类定义拷贝赋值运算符,编译器会为我们定义一个。对于某些类,合成拷贝拷贝赋值运算符用来禁止该类型对象的赋值。
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
class Foo{
public:
Foo& operator=(const Foo&);
//……
};
析构函数释放对象使用的对象,并销毁对象的非static数据成员。没有返回值,不接受参数。由于不接受参数,所以不能被重载。因此,对于一个给定类,只会有唯一一个析构函数。
当我们在类内使用=default修饰成员的声明时,合成的海曙将隐式声明为内联的。如果不希望合成的成员是内联函数,应该只对创建的类外定义使用=default。只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)
struct Nocopy{
NoCopy() = default; //使用合成的默认构造函数
NoCopy( const NoCopy&) = delete; //阻止拷贝
NoCopy &operator=(const NoCopy&) = delete; //阻止赋值
~NoCopy() = default; //使用合成的析构函数
};
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的数据成员函数将被定义删除的。
对于某些类来说,编译器将这些合成的成员定义为delete的函数:
通常,管理类外资源的类必须定义拷贝控制成员,这中类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它肯定也需要一个拷贝函数和一个拷贝赋值运算符。一般来说有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
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 std::string( *rhs.ps );
delete ps; //delete this->ps
ps = newp;
i = rhs.i;
return *this;
}
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的而对象。最好的方法是使用shared_ptr来管理类中的资源。
引用计数
class HasPtr{
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)),i(0), use(new std::size_t(1){}
//
HasPtr(const HasPtr &p):
ps(new std::string(*p.ps), i(p.i), use( p.use ){ ++*use;}
HasPtr& operator=(const HasPtr&);
~HasPtr(){ delete ps;}
private:
std::string *ps;
int i;
std::size_t *use;
};
HasPtr& HasPtr::operator=(const HasPtr& rhs)
{
++*rhs.use;
if( --*use == 0)
{
delete ps;
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}
~HasPtr::HasPtr()
{
if( --*use == 0)
{
delete ps;
delete use;
}
}
除了定义拷贝控制成员,管理资源的类通常还定义一个名叫swap的函数。对于那些与重排元素顺序的算法一起使用的类,定义swap是非常重要的。
如果一个类定义了自己的swap,那么算法将不使用标准库定义的swap。通常交换需要一次拷贝和两次赋值。理论上,我们希望swap交换指针而不是分配string的新副本
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);
}
swap函数应该调用swap,而不是std::swap
每个swap调用都应该是未加限定的。即,每个调用都应该是swap,而不是std::swap。如果存在类型特定的swap版本,其匹配程度会优于std中定义的版本。因此,如果存在类型特定的swap版本,swap调用会与之匹配。如果不存在类型特定的版本,则会使用std的版本(假定作用域中有using声明)
void swap(Foo &lhs, Foo &rhs)
{
using std::swap;
swap( lhs.h, rhs.h); //使用HasPtr版本的swap
}
在赋值运算符中使用swap
定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用一种名叫拷贝并交换的技术
HasPtr& HasPtr::operator=(HasPtr rhs)
{
swap( *this, rhs );
return *this;
}
通过使用新标准库引入的两种机制,可以避免string的拷贝:
class StrVec{
public:
StrVec():element(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 alloc;
void chk_n_alloc()
{ if(size() == capacity()) reallocate();}
std::pair alloc_n_copy
(const std::string*, const std::string*);
void free();
void reallocate();
std::string elements;
std::string first_free;
std::string cap;
};
void StrVec::StrVec( const StrVec &s)
{
auto newdata = alloc_n_copy( s.begin(), s.end());
elements = newdata.first;
first_free = newdata.second;
}
StrVec& StrVec::operator=(const StrVec &rhs)
{
auto data = alloc_n_copy( rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = data.second;
return *this;
}
StrVec::~StrVec()
{
free();
}
void StrVec::push_back(const string &s)
{
chk_n_alloc(); //确保有空间容纳所有元素
alloc.construct(first_free++, s);
}
pair
StrVec::alloc_n_copy(const std::string *b, const std::string *e)
{
auto data = alloc.allocate(e - b);
return {data, uninitialized_copy(b, e, data)};
}
void StrVec::free()
{
//
if(element)
{
for( auto p = first_free; p != elements; )
alloc.destroy(--p);
alloc.deallocate( elements, cap - elements);
}
}
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;
}
标准库容器、string和shared_ptr类即支持移动也支持拷贝。unique_ptr和IO类只可以移动不支持拷贝。
为了支持移动操作,新标准引入一种新的引用类型——右值引用(rvalue reference)。所谓右值引用就是必须绑定到右值的引用,通过&&而不是&来获得右值引用。右值引用有一个重要的性质:只能绑定到一个将要销毁的对象.因此,可以自由将右值引用资源“移动”到另一个对象中。一般来说,一个左值表达式表示的是一个对象的身份,而一个右值表达式表达的是对象的值。
返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。
int i = 42;
int &&rr = i; //错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42; //错误:i*42是一个右值
const int &r3 = i * 42; //正确:可以将一个const引用绑定到一个右值上
int &&rr2 = i * 42; //正确
int &&rr1 = 42; //正确
int &&rr2 = rr1; //错误:表达式rr1是左值
int &&r3 = std::move(rr1); //正确
move调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move就意味着承诺:除了对rr1赋值或销毁它之外,我们将不再使用它。
与大多数标准库名字的使用不同,对move我们不提供using声明,直接调用std::move而不是move
移动构造函数的第一参数是该类类型的一个右值引用,且其他任何额外的参数必须有默认实参。
StrVec::StrVec(StrVec &&s) noexcept
: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
s.elements = nullptr;
s.first_free = nullptr;
s.cap = nullptr;
}
noexcept通知标准库,构造函数不抛出任何异常。我们必须在类头文件的声明中和定义中(如果定义在类外的话)都指定noexcept
class StrVec{
public:
StrVec(StrVec &&) noexcept; //移动构造函数
};
StrVec::Strvec(StrVec &&s) noexcept: /*成员初始化器*/
{/*构造函数体*/}
一个移动操作不抛出异常,这是因为两个相关联的事实:
为了避免在重新分配内存的过程中使用拷贝构造函数而不是移动构造函数,需要显示的告诉标准库我们的移动构造函数可以安全使用。(不知道为什么,总感觉这种说法是一种类似“为了告诉客户选择我们而不是其他公司,所以要显示的告诉甲方我们非常安全,而实际情况未可知”)
移动赋值运算符
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;
}
进行检查的原因是此右值可能是move调用的返回结果。关键点在于不能在使用右侧运算对象的资源之前就是否左侧运算对象的资源(可能是相同的资源)
移后源对象必须可析构
在移动操作之后,移后源对象必须保持有效的、可析构的状态,但用户能对其值进行任何假设
合成的移动操作
如果我们不声明自己的拷贝构造函数或拷贝赋值运算符,编译器总会为我们合成这些操作。拷贝操作要么被定义成逐成员拷贝,要么被定义为对象赋值,要么定义为delete的函数。
与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值函数或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符。因此,某些类就没有移动构造函数或 移动赋值运算符。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来带起移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非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); //使用合成的移动构造函数
只有当一个类没有定义任何自己版本的拷贝控制成员,且所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
移动操作永远不会隐式定义为delete的函数。但是,如果我们显示地要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为delete的函数。除了一个例外,其他delete的移动操作均遵循如下原则:
移动操作和合成的拷贝控制成员间还有一个最后一个相互关系:一个类是否定义了自己的移动操作对拷贝操作如何合成有影响。即,如果一个类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符将被定义为delete的。即,定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则拷贝操作将默认被定义为delete的。
移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝
移动操作接受一个右值引用,因此只能用于实参是(非static)右值的情形:
StrVec v1, v2;
v1 = v2; //拷贝赋值:因为v2是左值
StrVec getVec(istream &); //返回右值
v2 = getVec( cin ); //移动赋值:getVec(cin)返回右值
对于第二个赋值的情况,两个赋值运算符都是可行的。调用拷贝赋值运算符要进行一次到const的转换,而StrVec&&精准匹配。
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; //拷贝构造函数拷贝:hp2是左值
hp = std::move(hp2); // 移动构造函数移动hp2
赋值运算符有一个非引用参数,意味此参数要进行拷贝初始化。依赖实参的类型,拷贝初始要么使用拷贝构造函数要么使用移动构造函数(左值拷贝,右值移动)。因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。
三(拷贝构造函数、拷贝赋值运算符、析构函数)
五(拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数)
所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。
移动迭代器
新标准库汇总定义了一种移动迭代器(move iterator)适配器。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器,与其他迭代器不同的是移动迭代器的解引用运算符生成一个右值引用。
可以通过调用标准库的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;
}
此算法使用迭代器的解引用运算符从输入序列中提取元素。由于传递的是移动迭代器,一次解引用运算符生成一个右值引用,意味construct将使用移动构造函数来构造元素。
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); //push_back(const string &)
vec.push_back( "done"); //push_back( string &&)
通常我们在一个对象上调用成员函数,不管对象是左值还是右值,但由于老标准库允许向右值赋值:
s1 + s2 = "wow";
为了维持向后兼容性,新的标准库仍允许向右值赋值。但我们希望在自己的类中阻止这种用法,可以强制左侧运算符对象(即,this指向的对象)是一个左值,即通过在参数列表后放置一个引用限定符:
class Foo{
public:
Foo &opretor=(const Foo &) &;
};
Foo& Foo::operator=(const Foo &rhs) &
{
return *this;
}
同时使用const限定符和引用限定符时,const限定符必须在前。而且可以综合使用引用限定符和const来区分一个成员函数的重载版本
重载和引用函数
我们定义const成员函数时,可以定义两个版本,唯一的差别是一个版本有const限定而另一个没有。
但如果定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加(即,如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符):
class Foo{
public:
Foo sorted() &&;
Foo sorted() const; //错误:必须加上引用限定符,&或&&
using Comp = bool(const int&, const int&);
Foo sorted( Comp*); //正确:不同参数列表
Foo sorted( Comp*) const; //正确:两个版本都没有引用限定符
};