bbsvs

C++的ravalue与reference右值引用用法

作者:bbsvs 时间:2017-08-04

这篇文章主要是详细介绍C++的ravalue与reference右值引用用法

1、右值引用引入的背景 

临时对象的产生和拷贝所带来的效率折损,一直是C++所为人诟病的问题。但是C++标准允许编译器对于临时对象的产生具有完全的自由度,从而发展出了CopyElision、RVO(包括NRVO)等编译器优化技术,它们可以防止某些情况下临时对象产生和拷贝。下面简单地介绍一下CopyElision、RVO,对此不感兴趣的可以直接跳过: 

(1)CopyElision 

CopyElision技术是为了防止某些不必要的临时对象产生和拷贝,例如: 


structA{ 
A(int){} 
A(constA&){} 
}; 
Aa=42;

 


理论上讲,上述Aa=42;语句将分三步操作:第一步由42构造一个A类型的临时对象,第二步以临时对象为参数拷贝构造a,第三步析构临时对象。如果A是一个很大的类,那么它的临时对象的构造和析构将造成很大的内存开销。我们只需要一个对象a,为什么不直接以42为参数直接构造a呢?CopyElision技术正是做了这一优化。 

【说明】:你可以在A的拷贝构造函数中加一打印语句,看有没有调用,如果没有被调用,那么恭喜你,你的编译器支持CopyElision。但是需要说明的是:A的拷贝构造函数虽然没有被调用,但是它的实现不能没有访问权限,不信你将它放在private权限里试试,编译器肯定会报错。 

(2)返回值优化(RVO,ReturnValueOptimization) 

返回值优化技术也是为了防止某些不必要的临时对象产生和拷贝,例如: 


structA{ 
A(int){} 
A(constA&){} 
}; 
Aget(){returnA(1);} 
Aa=get();

理论上讲,上述Aa=get();语句将分别执行:首先get()函数中创建临时对象(假设为tmp1),然后以tmp1为参数拷贝构造返回值(假设为tmp2),最后再以tmp2为参数拷贝构造a,其中还伴随着tmp1和tmp2的析构。如果A是一个很大的类,那么它的临时对象的构造和析构将造成很大的内存开销。返回值优化技术正是用来解决此问题的,它可以避免tmp1和tmp2两个临时对象的产生和拷贝。 

【说明】:a)你可以在A的拷贝构造函数中加一打印语句,看有没有调用,如果没有被调用,那么恭喜你,你的编译器支持返回值优化。但是需要说明的是:A的拷贝构造函数虽然没有被调用,但是它的实现不能没有访问权限,不信你将它放在private权限里试试,编译器肯定会报错。 

b)除了返回值优化,你可能还听说过一个叫具名返回值优化(NamedReturnValueOptimization,NRVO)的优化技术,从程序员的角度而言,它其实跟RVO同样的逻辑。只是它的临时对象具有变量名标识,例如修改上述get()函数为: 


Aget(){ 
Atmp(1);//#1 
//dosomething 
returntmp; 
} 
Aa=get();//#2


想想上述修改后A类型共有几次对象构造?虽然#1处看起来有一次显示地构造,#2处看起来也有一次显示地构造,但如果你的编译器支持NRVO和CopyElision,你会发现整个Aa=get();语句的执行过程,只有一次A对象的构造。如果你在get()函数return语句前打印tmp变量的地址,在Aa=get();语句后打印a的地址,你会发现两者地址相同,这就是应用了NRVO技术的结果。 

(3)CopyElision、RVO无法避免的临时对象的产生和拷贝 

虽然CopyElision和NVO(包括NRVO)等技术能避免一些临时对象的产生和拷贝,但某些情况下它们却发挥不了作用,例如: 


template<typenameT> 
voidswap(T&a,T&b){ 
Ttmp(a); 
a=b; 
b=tmp; 
}


我们只是想交换a和b两个对象所拥有的数据,但却不得不使用一个临时对象tmp备份其中一个对象,如果T类型对象拥有指向(或引用)从堆内存分配的数据,那么深拷贝所带来的内存开销是可以想象的。为此,C++11标准引入了右值引用,使用它可以使临时对象的拷贝具有move语意,从而可以使临时对象的拷贝具有浅拷贝般的效率,这样便可以从一定程度上解决临时对象的深度拷贝所带来的效率折损。 


2、C++03标准中的左值与右值 

要理解右值引用,首先得区分左值(lvalue)和右值(rvalue)。 

C++03标准中将表达式分为左值和右值,并且“非左即右”: 

Everyexpressioniseitheranlvalueoranrvalue. 

区分一个表达式是左值还是右值,最简便的方法就是看能不能够对它取地址:如果能,就是左值;否则,就是右值。 

【说明】:由于右值引用的引入,C++11标准中对表达式的分类不再是“非左即右”那么简单,不过为了简单地理解,我们暂时只需区分左值右值即可,C++11标准中的分类后面会有描述。 


3、右值引用的绑定规则 

右值引用(rvaluereference,&&)跟传统意义上的引用(reference,&)很相似,为了更好地区分它们俩,传统意义上的引用又被称为左值引用(lvaluereference)。下面简单地总结了左值引用和右值引用的绑定规则(函数类型对象会有所例外): 

(1)非const左值引用只能绑定到非const左值; 

(2)const左值引用可绑定到const左值、非const左值、const右值、非const右值; 

(3)非const右值引用只能绑定到非const右值; 

(4)const右值引用可绑定到const右值和非const右值。 

测试例子如下: 

structA{A(){}}; 
Alvalue;//非const左值对象 
constAconst_lvalue;//const左值对象 
Arvalue(){returnA();}//返回一个非const右值对象 
constAconst_rvalue(){returnA();}//返回一个const右值对象 
//规则一:非const左值引用只能绑定到非const左值 
A&lvalue_reference1=lvalue;//ok 
A&lvalue_reference2=const_lvalue;//error 
A&lvalue_reference3=rvalue();//error 
A&lvalue_reference4=const_rvalue();//error 
//规则二:const左值引用可绑定到const左值、非const左值、const右值、非const右值 
constA&const_lvalue_reference1=lvalue;//ok 
constA&const_lvalue_reference2=const_lvalue;//ok 
constA&const_lvalue_reference3=rvalue();//ok 
constA&const_lvalue_reference4=const_rvalue();//ok 
//规则三:非const右值引用只能绑定到非const右值 
A&&rvalue_reference1=lvalue;//error 
A&&rvalue_reference2=const_lvalue;//error 
A&&rvalue_reference3=rvalue();//ok 
A&&rvalue_reference4=const_rvalue();//error 
//规则四:const右值引用可绑定到const右值和非const右值,不能绑定到左值 
constA&&const_rvalue_reference1=lvalue;//error 
constA&&const_rvalue_reference2=const_lvalue;//error 
constA&&const_rvalue_reference3=rvalue();//ok 
constA&&const_rvalue_reference4=const_rvalue();//ok 
//规则五:函数类型例外 
voidfun(){} 
typedefdecltype(fun)FUN;//typedefvoidFUN(); 
FUN&lvalue_reference_to_fun=fun;//ok 
constFUN&const_lvalue_reference_to_fun=fun;//ok 
FUN&&rvalue_reference_to_fun=fun;//ok 
constFUN&&const_rvalue_reference_to_fun=fun;//ok


【说明】:(1)一些支持右值引用但版本较低的编译器可能会允许右值引用绑定到左值,例如g++4.4.4就允许,但g++4.6.3就不允许了,clang++3.2也不允许,据说VS2010beta版允许,正式版就不允许了,本人无VS2010环境,没测试过。 

(2)右值引用绑定到字面值常量同样符合上述规则,例如:int&&rr=123;,这里的字面值123虽然被称为常量,可它的类型为int,而不是constint。对此C++03标准文档4.4.1节及其脚注中有如下说明: 

IfTisanon-classtype,thetypeofthervalueisthecv-unqualifiedversionofT. 

InC++classrvaluescanhavecv-qualifiedtypes(becausetheyareobjects).ThisdiffersfromISOC,inwhichnon-lvaluesneverhavecv-qualifiedtypes. 

因此123是非const右值,int&&rr=123;语句符合上述规则三。 


4、C++11标准中的表达式分类 

右值引用的引入,使得C++11标准中对表达式的分类不再是非左值即右值那么简单,下图为C++11标准中对表达式的分类: 

1.jpg

简单解释如下: 

(1)lvalue仍然是传统意义上的左值; 

(2)xvalue(eXpiringvalue)字面意思可理解为生命周期即将结束的值,它是某些涉及到右值引用的表达式的值(Anxvalueistheresultofcertainkindsofexpressionsinvolvingrvaluereferences),例如:调用一个返回类型为右值引用的函数的返回值就是xvalue。 

(3)prvalue(purervalue)字面意思可理解为纯右值,也可认为是传统意义上的右值,例如临时对象和字面值等。 

(4)glvalue(generalizedvalue)广义的左值,包括传统的左值和xvalue。 

(5)rvalue除了传统意义上的右值,还包括xvalue。 

上述lvalue和prvalue分别跟传统意义上的左值和右值概念一致,比较明确,而将xvalue描述为『某些涉及到右值引用的表达式的值』,某些是哪些呢?C++11标准给出了四种明确为xvalue的情况: 


[Note:Anexpressionisanxvalueifitis: 
--theresultofcallingafunction,whetherimplicitlyorexplicitly,whosereturntypeisanrvaluereferencetoobjecttype, 
--acasttoanrvaluereferencetoobjecttype, 
--aclassmemberaccessexpressiondesignatinganon-staticdatamemberofnon-referencetypeinwhichtheobjectexpressionisanxvalue,or 
--a.*pointer-to-memberexpressioninwhichthefirstoperandisanxvalueandthesecondoperandisapointertodatamember. 
Ingeneral,theeffectofthisruleisthatnamedrvaluereferencesaretreatedaslvaluesandunnamedrvaluereferencestoobjectsaretreatedasxvalues;rvaluereferencestofunctionsaretreatedaslvalueswhethernamedornot.--endnote] 
[Example: 
structA{ 
intm; 
}; 
A&&operator+(A,A); 
A&&f(); 
Aa; 
A&&ar=static_cast<A&&>(a); 
Theexpressionsf(),f().m,static_cast<A&&>(a),anda+aarexvalues.Theexpressionarisanlvalue. 
--endexample]


简单地理解就是:具名的右值引用(namedrvaluereference)属于左值,不具名的右值引用(unamedrvaluereference)就属于xvalue,而引用函数类型的右值引用不论是否具名都当做左值处理。看个例子更容易理解: 

Arvalue(){returnA();} 
A&&rvalue_reference(){returnA();} 
fun();//返回的是不具名的右值引用,属于xvalue 
A&&ra1=rvalue();//ra1是具名右值应用,属于左值 
A&&ra2=ra1;//error,ra1被当做左值对待,因此ra2不能绑定到ra1(不符合规则三) 
A&la=ra1;//ok,非const左值引用可绑定到非const左值(符合规则一)


5、move语意 

现在,我们重新顾到1-(3),其中提到move语意,那么怎样才能使临时对象的拷贝具有move语意呢?下面我们以一个类的实现为例: 

classA{ 
public: 
A(constchar*pstr=0){m_data=(pstr!=0?strcpy(newchar[strlen(pstr)+1],pstr):0);} 
//copyconstructor 
A(constA&a){m_data=(a.m_data!=0?strcpy(newchar[strlen(a.m_data)+1],a.m_data):0);} 
//copyassigment 
A&operator=(constA&a){ 
if(this!=&a){ 
delete[]m_data; 
m_data=(a.m_data!=0?strcpy(newchar[strlen(a.m_data)+1],a.m_data):0); 
} 
return*this; 
} 
//moveconstructor 
A(A&&a):m_data(a.m_data){a.m_data=0;} 
//moveassigment 
A&operator=(A&&a){ 
if(this!=&a){ 
m_data=a.m_data; 
a.m_data=0; 
} 
return*this; 
} 
~A(){delete[]m_data;} 
private: 
char*m_data; 
};

从上例可以看到,除了传统的拷贝构造(copyconstructor)和拷贝赋值(copyassigment),我们还为A类的实现添加了移动拷贝构造(moveconstructor)和移动赋值(moveassigment)。这样,当我们拷贝一个A类的(右值)临时对象时,就会使用具有move语意的移动拷贝构造函数,从而避免深拷贝中strcpy()函数的调用;当我们将一个A类的(右值)临时对象赋值给另一个对象时,就会使用具有move语意的移动赋值,从而避免拷贝赋值中strcpy()函数的调用。这就是所谓的move语意。 


6、std::move()函数的实现 

了解了move语意,那么再来看1-(3)中的效率问题: 


template<typenameT>//如果T是classA 
voidswap(T&a,T&b){ 
Ttmp(a);//根据右值引用的绑定规则三可知,这里不会调用moveconstructor,而会调用copyconstructor 
a=b;//根据右值引用的绑定规则三可知,这里不会调用moveassigment,而会调用copyassigment 
b=tmp;//根据右值引用的绑定规则三可知,这里不会调用moveassigment,而会调用copyassigment 
}

从上例可以看到,虽然我们实现了moveconstructor和moveassigment,但是swap()函数的例子中仍然使用的是传统的copyconstructor和copyassigment。要让它们真正地使用move语意的拷贝和复制,就该std::move()函数登场了,看下面的例子: 


voidswap(A&a,A&b){ 
Atmp(std::move(a));//std::move(a)为右值,这里会调用moveconstructor 
a=std::move(b);//std::move(b)为右值,这里会调用moveassigment 
b=std::move(tmp);//std::move(tmp)为右值,这里会调用moveassigment 
}


我们不禁要问:我们通过右值应用的绑定规则三和规则四,知道右值引用不能绑定到左值,可是std::move()函数是如何把上述的左值a、b和tmp变成右值的呢?这就要从std::move()函数的实现说起,其实std::move()函数的实现非常地简单,下面以libcxx库中的实现(在<type_trait>头文件中)为例: 


template<class_Tp> 
inlinetypenameremove_reference<_Tp>::type&&move(_Tp&&__t){ 
typedeftypenameremove_reference<_Tp>::type_Up; 
returnstatic_cast<_Up&&>(__t); 
}


其中remove_reference的实现如下: 


template<class_Tp>structremove_reference{typedef_Tptype;}; 
template<class_Tp>structremove_reference<_Tp&>{typedef_Tptype;}; 
template<class_Tp>structremove_reference<_Tp&&>{typedef_Tptype;};


从move()函数的实现可以看到,move()函数的形参(Parameter)类型为右值引用,它怎么能绑定到作为实参(Argument)的左值a、b和tmp呢?这不是仍然不符合右值应用的绑定规则三嘛!简单地说,如果move只是个普通的函数(而不是模板函数),那么根据右值应用的绑定规则三和规则四可知,它的确不能使用左值作为其实参。但它是个模板函数,牵涉到模板参数推导,就有所不同了。C++11标准文档14.8.2.1节中,关于模板函数参数的推导描述如下: 

Templateargumentdeductionisdonebycomparingeachfunctiontemplateparametertype(callitP)withthetypeofthecorrespondingargumentofthecall(callitA)asdescribedbelow.(14.8.2.1.1) 
IfPisareferencetype,thetypereferredtobyPisusedfortypededuction.IfPisanrvaluereferencetoacvunqualifiedtemplateparameterandtheargumentisanlvalue,thetype"lvaluereferencetoA"isusedinplaceofAfortypededuction.(14.8.2.1.3)

 

大致意思是:模板参数的推导其实就是形参和实参的比较和匹配,如果形参是一个引用类型(如P&),那么就使用P来做类型推导;如果形参是一个cv-unqualified(没有const和volatile修饰的)右值引用类型(如P&&),并且实参是一个左值(如类型A的对象),就是用A&来做类型推导(使用A&代替A)。 

template<class_Tp>voidf(_Tp&&){/*dosomething*/} 
template<class_Tp>voidg(const_Tp&&){/*dosomething*/} 
intx=123; 
f(x);//ok,f()模板函数形参为非const非volatile右值引用类型,实参x为int类型左值,使用int&来做参数推导,因此调用f<int&>(int&) 
f(456);//ok,实参为右值,调用f<int>(int&&) 
g(x);//error,g()函数模板参数为const右值引用类型,会调用g<int>(constint&&),通过右值引用规则四可知道,const右值引用不能绑定到左值,因此会导致编译错误


了解了模板函数参数的推导过程,已经不难理解std::move()函数的实现了,当使用左值(假设其类型为T)作为参数调用std::move()函数时,实际实例化并调用的是std::move<T&>(T&),而其返回类型T&&,这就是move()函数左值变右值的过程(其实左值本身仍是左值,只是被当做右值对待而已,被人“抄了家”,变得一无所有)。 

【说明】:C++的始祖BjarneStroustrup说:如果move()函数改名为rval()可能会更好些,但是move()这个名字已经被使用了好些年了(Maybeitwouldhavebeenbetterifmove()hadbeencalledrval(),butbynowmove()hasbeenusedforyears.)。 


7、完整的示例 

至此,我们已经了解了不少右值引用的知识点了,下面给出了一个完整地利用右值引用实现move语意的例子: 


#include<iostream> 
#include<cstring> 
#definePRINT(msg)do{std::cout<<msg<<std::endl;}while(0) 
template<class_Tp>structremove_reference{typedef_Tptype;}; 
template<class_Tp>structremove_reference<_Tp&>{typedef_Tptype;}; 
template<class_Tp>structremove_reference<_Tp&&>{typedef_Tptype;}; 
template<class_Tp> 
inlinetypenameremove_reference<_Tp>::type&&move(_Tp&&__t){ 
typedeftypenameremove_reference<_Tp>::type_Up; 
returnstatic_cast<_Up&&>(__t); 
} 
classA{ 
public: 
A(constchar*pstr){ 
PRINT("constructor"); 
m_data=(pstr!=0?strcpy(newchar[strlen(pstr)+1],pstr):0); 
} 
A(constA&a){ 
PRINT("copyconstructor"); 
m_data=(a.m_data!=0?strcpy(newchar[strlen(a.m_data)+1],a.m_data):0); 
} 
A&operator=(constA&a){ 
PRINT("copyassigment"); 
if(this!=&a){ 
delete[]m_data; 
m_data=(a.m_data!=0?strcpy(newchar[strlen(a.m_data)+1],a.m_data):0); 
} 
return*this; 
} 
A(A&&a):m_data(a.m_data){ 
PRINT("moveconstructor"); 
a.m_data=0; 
} 
A&operator=(A&&a){ 
PRINT("moveassigment"); 
if(this!=&a){ 
m_data=a.m_data; 
a.m_data=0; 
} 
return*this; 
} 
~A(){PRINT("destructor");delete[]m_data;} 
private: 
char*m_data; 
}; 
voidswap(A&a,A&b){ 
Atmp(move(a)); 
a=move(b); 
b=move(tmp); 
} 
intmain(intargc,char**argv,char**env){ 
Aa("123"),b("456"); 
swap(a,b); 
return0; 
}

输出结果为: 


constructor 
constructor 
moveconstructor 
moveassigment 
moveassigment 
destructor 
destructor 
destructor


8、幕后花絮 

C++11标准引入右值引用的提案是由HowardHinnant提出的,它的最初提案N1377在02年就提出来了,中间经历了多次修改N1385、N1690、N1770、N1855、N1952、N2118。包括它的最终版本N2118在内,HowardHinnant的提案中都使用了右值引用直接绑定到左值的例子,并且由HowardHinnant、BjarneStroustrup和BronekKozicki三人08年10月共同署名的《ABriefIntroductiontoRvalueReferences》文章中也有右值引用直接绑定到左值的例子,但奇怪的是11年公布的最新的C++11标准文档中却不允许右值引用直接绑定到左值,其中的原因不得而知,但从中不难理解为什么早些编译器版本(如g++4.4.4)对右值引用绑定到左值,不会报出编译错误,而最新的编译器却会报错。 


TAG:
右值   引用