新标准的一个最主要的特性是可以移动而非拷贝对象的能力,在某些情况下,移动而非拷贝对象会大幅度提升性能。
右值引用
为了支持移动操作,新标准引入了一种新的引用类型——右值引用。所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。因此我们可以自由地将一个右值引用的资源“移动”到另一个对象中。
一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。对于常规引用,我们可以称之为左值引用,我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式:
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绑定到乘法结果上
左值持久,右值短暂
左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。由于右值引用只能绑定到临时对象,我们得知:
1、所引用的对象将要被销毁
2、该对象没有其他用户
变量是左值
变量可以看作只有一个运算对象而没有运算符的表达式,变量表达式也有左值/右值属性,变量表达式都是左值。意味着我们不能将一个右值引用绑定到一个右值引用类型的变量上:
int &&rr1=42; //正确:字面常量是右值
int &&rrr2=rr1; //错误:表达式rrr1是左值
标准库move函数
我们可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用:
int &&rr3=std::move(rr1); //ok
move告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。调用move就意味着承诺:除了对rr1赋值或销毁它外,我们将不再使用它。在调用move之后,我们不能将对移后源对象的值做任何假设。
移动构造函数和移动赋值运算符
类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
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;
}
与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管StrVec中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在,最终,移后源对象会被销毁,意味着将在其上运行析构函数。
移动操作、标准库容器和异常
由于移动操作“窃取”资源,因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。一种通知标准库的方法是在我们的构造函数中指明noexcept。我们在一个函数的参数列表后指定noexcept,我们必须在类头文件的声明和定义中(如果定义在类外的话)都指定noexcept。
移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept。类似拷贝赋值运算符,移动赋值运算符必须正常处理自赋值:
StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
//直接检测自赋值
if(this!=&rhs) {
free(); //释放已有元素
elements=rhs.elements; //从rhs接管资源
first_free=rhs.first_free;
cap=rhs.cap;
//将rhs置于可析构状态
rhs.elements=rhs.first_free=rhs,cap=nullptr;
}
return *this;
}
移后源对象必须可析构
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。
合成的移动操作
与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符,但是合成移动操作的条件与合成拷贝操作的条件大不相同。
与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
//编译器会为X和hasX合成移动操作
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); //使用合成的移动构造函数
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
移动右值,拷贝左值······
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似:
StrVec v1,v2;
v1=v2; //v2是左值;使用拷贝赋值
StrVec getVec(istream &); //getVec返回一个右值
v2=getVec(cin); //getVec(cin)是一个右值;使用移动赋值
······但如果没有移动构造函数,右值也被拷贝
如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用move来移动它时也是如此:
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都是HasPtr对象:
hp=hp2; //hp2是一个左值;hp2通过拷贝构造函数来拷贝
hp=std::move(hp2); //移动构造函数移动hp2
在第一个赋值中,右侧运算对象是一个左值,因此移动构造函数是不可行的。rhs使用拷贝构造函数来初始化。在第二个赋值中,我们调用std::move将一个右值引用绑定到hp2上。在此情况下,拷贝构造函数和移动构造函数都是可行的。但是由于实参是一个右值引用,移动构造函数是精确匹配的。移动构造函数从hp2拷贝指针,而不会分配任何内存。
移动迭代器
新标准库中定义了一种移动迭代器适配器。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。
我们通过调用标准库的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;
}
uninitialized_copy对输入序列中的每个元素调用construct来将元素“拷贝”到目的位置。此算法使用迭代器的解引用运算符从输入序列中提取元素。由于我们传递给它的是移动迭代器,因此解引用的运算符生成的是一个右值引用,这意味着construct将使用移动构造函数来构造元素。
右值引用和成员函数
如果一个成员函数同时提供拷贝和移动版本,它通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式——一个版本接受一个指向const的左值引用,第二个版本接受一个指向非const的右值引用。例如:
void push_back(const X&); //拷贝:绑定到任意类型的X
void push_back(X&&); //移动:只能绑定到类型X的可修改的右值
右值和左值引用成员函数
s1+s2="wow";
此处我们对两个string的连接结果——一个右值,进行了赋值。在旧标准中,我们没有办法组织这种使用方式,为了维持向后兼容性,新标准库类任然允许向右值赋值。但是,我们可能希望在自己的类中阻止这种用法。在此情况下,我们希望强制左侧运算对象(即this指向的对象)是一个左值。我们指出this的左值/右值属性的方式与定义const成员函数相同,即在参数列表后放置一个引用限定符:
class Foo {
public:
Foo &operator=(const Foo&) &; //只能向可修改的左值赋值
//Foo的其他参数
};
引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。
一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后:
Foo someMem() const &;
重载和引用函数
引用限定符也可以区分重载版本。而且,我们可以综合引用限定符和const来区分一个成员函数的重载版本。
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符,否则是错误的。