我爱电脑技术论坛's Archiver

麦迪 发表于 2008-4-21 13:03

《C++0x漫谈》系列之:右值引用

右值引用(及其支持的Move语意和完美转发)是C++0x将要加入的最重大语言特性之一,这点从该特性的提案在C++ - State of the Evolution列表上高居榜首也可以看得出来。从实践角度讲,它能够完美解决C++中长久以来为人所诟病的临时对象效率问题。从语言本身讲,它健全了C++中的引用类型在左值右值方面的缺陷。从库设计者的角度讲,它给库设计者又带来了一把利器。从库使用者的角度讲,不动一兵一卒便可以获得“免费的”效率提升… p ka&R&W

1[9{y'q5s\ _P.E6t   Move语意
iPM3T)Q[6YM(o \Q X2} d$J9q
  返回值效率问题——返回值优化((N)RVO)——mojo设施——workaround——问题定义——Move语意——语言支持5S\(f8O/dV} _|5Zkc
2M)OM/XC m-FW
  大猴子Howard Hinnant写了一篇挺棒的tutorial(a.k.a. 提案N2027),此外最初的关于rvalue-reference的若干篇提案的可读性也相当强。因此要想了解rvalue-reference的话,或者去看C++标准委员会网站上的系列提案(见文章末尾的参考文献)。或者阅读本文。
8e2|N%c} }u1Fb{4H.P9Rn[ u2n
  源起0pm0wo ^.B \
h3R$fgf!D` bp;u
  《大史记》总看过吧?kB#|%B1ZpcV

m T~qH9|x   故事,素介个样子滴…一天,小嗖风风的吹着,在一个伸手不见黑夜的五指…
Y|LCy
*l)Z}n/I-T   我用const引用来接受参数,却把临时变量一并吞掉了。我用非const引用来接受参数,却把const左值落下了。于是乎,我就在标准的每个角落寻找解决方案,我靠!我被8.5.3打败了!…p2e5Xw1wx

~dh"[pQ:L   设想这样一段代码(既然大同小异,就直接从Andrei那篇著名的文章里面拿来了):
6@T6zK6u4hi
j s3IUMT{ std::vector<int> v = readFile();
_H;c@i9t/uN4|u-K#i
,~ R}%{*^"e/F   readFile()的定义是这样的:9F"u.k"re/O3x.[ f
r&qd-[v7AL1v
std::vector<int> readFile()&y'^:Ap0S#U D
{A1]!\#l:V$L
std::vector<int> retv;
^ c"~F1Oq{%UwIN … // fill retvH;u4TL}4Pa
return retv;
$Y!HP;S8Q#z } Y?7C6}6p$f&K~
's{I1ZA2V%UFcj%u
  这段代码低效的地方在于那个返回的临时对象。一整个vector得被拷贝一遍,仅仅是为了传递其中的一组int,当v被构造完毕之后,这个临时对象便烟消云散。6d:~6KR Zc,C?;B6N
_4s hflDV
  这完全是公然的浪费!
n,b Qn}
XJRbR0H7A   更糟糕的是,原则上讲,这里有两份浪费。一,retv(retv在readFile()结束之后便烟消云散)。二,返回的临时对象(返回的临时变量在v拷贝构造完毕之后也随即香消玉殒)。不过呢,对于上面的简单代码来说,大部分编译器都已经能够做到优化掉这两个对象,直接把那个retv创建到接受返回值的对象,即v中去。^}u6a }UK

]9X*{hRU \   实际上,临时对象的效率问题一直是C++中的一个被广为诟病的问题。这个问题是如此的著名,以至于标准不惜牺牲原本简洁的拷贝语意,在标准的12.8节悍然下诏允许优化掉在函数返回过程中产生的拷贝(即便那个拷贝构造函数有副作用也在所不惜!)。这就是所谓的“Copy Elision”。p$m+]h0}OK;`3S
p"y1}yh;p$h`])\
  为什么(N)RVO((Named) Return Value Optimization)几乎形同虚设
kXJ6EA OmyqF"S~ S0IUu
  还是按照Andrei的说法,只要readFile()改成这样:
%qL(pW,d@
{|$M"v}?O2Fn7^ … readFile()
0^_*XK!|L1I {
4i?/^qi+M if(/* err condition */) return std::vector<int>();
"rDkP8S0f0~ if(/* yet another err condition */) return std::vector<int>(1, 0); j"`#[)]qv$rj
std::vector<int> retv;K^k.v:H?;nB
… // fill retv'E+a1]Rs@3~+V@
return retv;Z7N2b&k%cS
}
3w,{u:d!`[;U t 0e9| d|$f}
  出现这种情况,编译器一般都会乖乖放弃优化。 Y*[9[&Ix
4EhJ9OlErn!k'[
  但对编译器来说这还不是最郁闷的一种情况,最郁闷的是:
,?^,mppA pe~&j;a j \lR
std::vector<int> v;(o0tm%\R2K
v = readFile(); // assignment, not copy construction
'PJ!L-n`R!r2W 5e'D5DIu
  这下由拷贝构造,变成了拷贝赋值。眼睛一眨,老母鸡变鸭。编译器只能缴械投降。因为标准只允许在拷贝构造的情况下进行(N)RVO。W1RF o,^ fJ

+{&lOrE/O}!e   为什么库方案也不是生意经Nt5\Hv\)u a;|

b C k| WbY.F;C3v   C++鬼才Andrei Alexandrescu以对C++标准的深度挖掘和利用著名,早在03年的时候(当时所谓的临时变量效率问题已经在新闻组上闹了好一阵子了,相关的语言级别的解决方案也已经在02年9月份粉墨登场)就在现有标准(C++98)下硬是折腾出了一个能100%解决问题的方案来。 F\C|n] x~

"t]S {4p"n`:i7r4Z$Z   Andrei把这个框架叫做mojo,就像一层爽身粉一样,把它往现有类上面一洒,嘿嘿…猜怎么着,不,不是“痱子去无踪”:P,是该类型的临时对象效率问题就迎刃而解了!
5}-VI[ Y7euF T 9UA#^[4C+]3g VZ c
  Mojo的唯一的问题就是使用方法过于复杂。这个复杂度,很大程度上来源于标准中的一个措辞问题(C++标准就是这样,鬼知道哪个角落的一句话能够带出一个brilliant的解决方案来,同时,鬼知道哪个角落的一句话能够抹杀一个原本简洁的解决方案)。这个问题就是我前面提到过的8.5.3问题,目前已经由core language issue 391解决。| Xj.\P%j

j%AU&J1J!R%W-E z   对于库方案来说,解决问题固然是首要的。但一个侵入性的,外带使用复杂性的方案必然是走不远的。因此虽然大家都不否认mojo是一个天才的方案,但实际使用中难免举步维艰。这也是为什么mojo并没有被工业化的原因。
$BJqwG(A| #?o L8o(Oak4sg\
  为什么改用引用传参也等于痴人说梦
,G1gl(OW
1O omjP void readFile(vector<int>& v){ … // fill v } ?)?|%U&|%X^7p u

"R3b/a b;T1EA5[r   这当然可以。
|pA3P@0i ZI(O"b+K
  但是如果遇到操作符重载呢?O!K%Y8h0X*_rm m
M4@Iwh$GK j-C
string operator+(string const& s1, string const& s2); za,sJDn?W&}H

;S n7dv nL }0I   而且,就算是对于readFile,原先的返回vector的版本支持L9{@K8v&I"Jy6H

lf baqq BOOST_FOREACH(int i, readFile()){
MuR1[ o6Ct(|-j … // do sth. with i
*M o8AD+I-\kl-Q.I{ }
T,Q%ir&c'?4]B R!x$I
0i1R:kE_5\?   改成引用传参后,原本优雅的形式被破坏了,为了进行以上操作不得不引入一个新的名字,这个名字的存在只是为了应付被破坏的形式,一旦foreach操作结束它短暂的生命也随之结束:
.pB)g)D BK*l
C)dVbQ;_9Ul vector<int> v;HS vU"Gq f
readFile(v);
h8s&]W!C|)A BOOST_FOREACH(int I, v){ }3L3~T)ia"v
}Fa{,^&vS rw
ia%R'U}
// v becomes useless here
;l M2{cvv8n r0nb#cswI
  还有什么问题吗?自己去发现吧。总之,利用引用传参是一个解决方案,但其能力有限,而且,其自身也会带来一些其它问题。终究不是一个优雅的办法。8y,c(B9k)|7]lvp

XL7ln M`z[   问题是什么
'W-ic%yD&Ws
E6s w kX#XR"l   《你的灯亮着吗?》里面漂亮地阐述了定义“问题是什么”的重要性。对于我们面临的临时对象的效率问题,这个问题同样重要。
)l%n8[-?b7O o i8Tbb#a*SX `
  简而言之,问题可以描述为:
}A4h[q$q4s H5U#chF"L
  C++没有区分copy和move语意。
A3l+T#RjVZ^$s
ZrS7j!M   什么是move语意?记得auto_ptr吗?auto_ptr在“拷贝”的时候其实并非严格意义上的拷贝。“拷贝”是要保留源对象不变,并基于它复制出一个新的对象出来。但auto_ptr的“拷贝”却会将源对象“掏空”,只留一个空壳——一次资源所有权的转移。c}P)zj,_
rXoE,Gw$R#P1C
  这就是move。
heqEF a5Xz?Q ~
  Move语意的作用——效率优化
@m*X)Zy[ \N
|"W_&fc(A)m6J J   举个具体的例子,std::string的拷贝构造函数会做两件事情:一,根据源std::string对象的大小分配一段大小适当的缓冲区。二,将源std::string中的字符串拷贝过来。
%ADz/U ` !Ea#rCe"h3^
// just for illustrating the idea, not the actual implementation
0J?7Wj ^K/P
9S t'x/_W@9xcN string::string(const string& o):kdc|8VvZ T?Q
{
w#~%@:l3s h W9{I this->buffer_ = new buffer[o.length() + 1];
aBt]vLZ8qI { copy(o.begin(), o.end(), buffer_);;C~SOEXB9eTu
} W2e"i_`7dc\
7R%zl9a'u8o G4U"v/~
  但是假设我们知道o是一个临时对象(比如是一个函数的返回值),即o不会再被其它地方用到,o的生命期会在它所处的full expression的结尾结束的话,我们便可以将o里面的资源偷过来:@+`t/?7A

ap^2^diQ string::string(temporary string& o)tQc?;ZD|
{ iKo? ?
// since o is a temporary, we can safely steal its resources without causing any problem
9?;UeBMF^*Z,{f this->buffer_ = o.buffer_;
5y Z V+DkZ-[` o.buffer_ = 0;
lH,^S,v,wtQE'PCg"_ }
iLd E\1xq.? "j2~,]QL v$]%T
  这里的temporary是一个捏造的关键字,其作用是使该构造函数区分出临时对象(即只有当参数是一个临时的string对象时,该构造函数才被调用)。{C0X?n3^
t z6eQ$o
  想想看,如果存在这样一个move constructor(搬移式构造函数)的话,所有源对象为临时对象的拷贝构造行为都可以简化为搬移式(move)构造。对于上面的string例子来说,move和copy construction之间的效率差是节省了一次O(n)的分配操作,一次O(n)的拷贝操作,一次O(1)的析构操作(被拷贝的那个临时对象的析构)。这里的效率提升是显而易见且显著的。 G {#M*T:jR7? AQ
"io!nn Se"{_m R
  最后,要实现这一点,只需要我们具有判断左值右值的能力(比如前面设想的那个temporary关键字),从而针对源对象为临时对象的情况进行“偷”资源的行动。5hw!i$oz%HC4]
&y%b(t`@#X
  Move语意的作用——使能(enabling) p a"`/x1]

(kf&fDMO~-Bh   再举一个例子,std::fstream。fstream是不可拷贝的(实际上,所有的标准流对象都是不可拷贝的),因而我们只能通过引用来访问一开始建立的那个流对象。但是,这种办法有一个问题,如果我们要从一个函数中返回一个流对象出来就不行了:
c|.X E3U9^5C1giy*Y
] [8i A/U'UE&m BH // how do we make this happen?({[4P MnRE
std::fstream createStream()
7B `A)NWi*D7FN { … } 3@\b!QRs1z'edi

KWK0YL(dcO   当然,你可以用auto_ptr来解决这个问题,但这就使代码非常笨拙且难以维护。*a|%t2i'N;SM(`(V(D
yr'|\&n6v8c2D
  但如果fstream是moveable的,以上代码就是可行的了。所谓“moveable”即是指(当源对象是临时对象时)在对象拷贝语法之下进行的实际动作是像auto_ptr那样的资源所有权转移:源对象被掏空,所有资源都被转移到目标对象中——好比一次搬家(move)。move操作之后,源对象虽然还有名有姓地存在着,但实际上其“实质”(内部拥有的资源)已经消失了,或者说,源对象从语意上已经消失了。
)NK6N)O%Hxa wVrN f#Y6X_h ko/HR^
  对于moveable但并非copyable的fstream对象来说,当发生一次move时(比如在上面的代码中,当一个局部的fstream对象被move出createStream()函数时),不会出现同一对象的两个副本,取而代之的是,move的源对象的身份(Identity)消失了,这个身份由返回的临时fstream对象重新持有。也就是说,fstream的唯一性(不可拷贝性——non-copyable)得到了尊重。
XV!o!_ q
7f3q)@pY"~e   你可能会问,那么被搬空了的那个源对象如果再被使用的话岂不是会引发问题?没错。这就是为什么我们应该仅当需要且可以去move一个对象的时候去move它,比如在函数的最后一行(return)语句中将一个局部的vector对象move出来(return std::move(v)),由于这是最后一行语句,所以后面v不可能再被用到,对它来说所剩下的操作就是析构,因此被掏空从语意上是完全恰当的。

麦迪 发表于 2008-4-21 13:04

 最初的例子——完美解决方案
'L(TX]BKv /IK!]XY/Z r'`q
  在先前的那个例子中
CQz0AtC F_*ucB 2fu&iQd]Nq/^
vector<int> v = readFile(); 0p8n%b}AVL*jc
u0|'Jr by
  有了move语意的话,readFile就可以简单的改成:
6pi3y Z^s*i]]} 'xw,e/Q(i1Gxb)F _
std::vector<int> readFile() S7dJ*gzM?D
{
3h3_ nXd9U.TH)K std::vector<int> retv;Ki] OE
… // fill retv
.KE(Z9w(x.Y return std::move(retv); // move retv out
-xA"Lp5a9PE } `\\6LD gTM
?Z9kJ+J!y&jC"i.t-k
  std::move以后再介绍。目前你只要知道,std::move就可以把retv掏空,即搬移出去,而搬家的最终目的地是v。这样的话,从内存分配的角度讲,只有retv中进行的内存分配,在从retv到返回的临时对象,再从后者到目的地v的“move”过程中,没有任何的内存分配(我是指vector内的缓冲区分配),取而代之的是,先是retv内的缓冲区被“转移”到返回值临时对象中,然后再从临时对象中转移到v中。相比于以前的两次拷贝而言,两次move操作节省了多少工作量呢?节省了两次new操作两次delete操作,还有两次O(n)的拷贝操作,这些操作整体的代价正比于retv这个vector的大小。难怪人们说临时对象效率问题是C++的肿瘤(wart)之一,难怪C++标准都要不惜代价允许(N)RVO。 r4M1S(q9ML/G
)Gzn!Y0m-t;j
  如何支持move语意s0EI$vRR ss,Wl
H[.ILl
  根据前面的介绍,你想必已经知道。实现move语意的最关键环节在于能够在编译期区分左值右值(也就是说识别出临时对象)。1Q1fu,l6u Lg

8u"iC:v2@b   现在,回忆一下,在文章的开头我曾经提到:0`q\#f}d/lL!X _O

:M_;h'uw"C B   我用const引用来接受参数,却把临时变量一并吞掉了。我用非const引用来接受参数,却把const左值落下了。于是乎,我就在标准的每个角落寻找解决方案,我靠!我被8.5.3打败了!…%o^)Vd!lFY0V
4}?2g#[G6e
  为什么这么说?
x(U0J#V5_0C:_mY h)S#s c p$I}
  现行标准(C++03)下的方案
(U1OvLDD t6w
tW ji;J}A1g9rm   要想区分左值右值,只有通过重载:
S9XDK5qP ^7J$J _wA)kUI
void foo(X const&);
/RJ[E0RsW't void foo(X&);
3p"Eck'O&Vu7K/^/M &U)wulC-w
  这样的重载显然是行不通的。因为X const&会把non-const临时对象一并吞掉。
6e/p:UM @}MV
6Sqb^Ux0WSc   这种做法的问题在于。X&是一个non-const引用,它只能接受non-const左值。然而,C++里面的值一共有四种组合:4fJ cOE DN
l'MYF3jTp+mm
  const non-const
6xsdu#x0x&p   lvalue8poD'rD'm Cj
  rvalue4q8aU/PZ"sK-m LF:z
ZxwE+X
  常量性(const-ness)与左值性(lvalue-ness)是正交的。zL:sR|XE3B
vd{8dJ6PF%Ua
  non-const引用只能绑定到其中的一个组合,即non-const lvalue。还剩下const左值,const右值,以及我们最关心的——non-const右值。而只有最后一种——non-const右值——才是可以move的。
-Hv'\ dx3T2A ]2d|f.JE'Q5rE/b#J
  剩下的问题便是如何设计重载函数来搞定const左值和const右值。使得最后只留下non-const右值。
8y`/@/N:K \
uZ1z.Z9A@   所幸的是,我们可以借助强大的模板参数推导机制:[ RPL!G

_9|RJ:@&L // catch non-const lvalues
{] v vG void foo(X&);
dK:D KR&Dn // catch const lvalues and const rvalues'}t1ha9k
template<typename T>
#`m"u~`N#Tg!w void foo(T&, enable_if_same<T, const X>::type* = 0);4I4WQMU|ai3wR
void foo( /* what goes here? */); 6wXU1F7P}2A

uW p_n(I   注意,第二个重载负责接受const左值和const右值。经过第一第二个foo重载之后剩下来的便是non-const rvalue了。f[\[ j+y uzn

*g*r ^"e(s_   问题是,我们怎么捕获这些non-const rvalue呢?根据C++03,const-const rvalue只能绑定到const引用。但如果我们用const引用的话,就会越俎代庖把const左右值一并接受了(因为在模板函数(第二个重载)和非模板函数(第三个重载)之间编译器总是会偏好非模板)。S6bcU9DRG
!\8HMd%mRo cf&{VE
  那除了用const引用,难道还有什么办法来接受一个non-const rvalue吗?v!yt#U.nl@.w+O

%^#uB1V-x1c:T q V#I   有。:hw[$h4UB
2g?k0r+C5}"CU
  假设你的类型为X,那么只要在X里面加入一点料: Um@D+r}H

&XZ}x\ struct ref_xL#{!FZ^1Ez9[.V
{
co1z$DIq ref_x(X* p) : p_(p) {}Y7T9cD6|9l&p$sS
X* p_;
9~|W |mV_Q };
Fun"V5U ict$N struct X
'I7{}V3u` {6qb iSl {
// original stuff9Sdzm9}nGXh
@3h\ m|#@;Fi
// added stuff, for move semantic U8pW6V!lV
operator ref_x()T }H_ L$E
{ O6``Z g}{5b)P B4J
return ref_x(this);
v2^isjI}M!z }#T1o%|'Cc/D
}; O/x"GF*iE
o)\I'j2hgiRi
  这样,我们的第三个重载函数便可以写成:
;~5_:gbN;al6Q u v-W Cn F${$F
void foo(ref_x rx); // accept non-const temporaries only!
xV~"hO0m 7^6Qr_^,J#Q$BJ ~
  Bang! 我们成功地在C++03下识别出了moveable的non-const临时对象。不过前提是必须得在moveable的类型里加入一些东西。这也正是该方案的最大弊病——它是侵入式的(姑且不说它利用了语言的阴暗角落,并且带来了很大的编码复杂度)。R;N/J!gR o.c

Ml9{^%v   C++09的方案:[p ~!o8q,YJv

1U"rW$kz8n   实际上,刚才讲的这个利用重载的方案做成库便是Andrei的mojo框架。mojo框架固然精巧,但复杂性太大,使用成本太高,不够优雅直观。所以语言级别的支持看来是必然选择(后面你还会看到,为了支持move语意而引入的新的语言特性同时还支持了另一个广泛的问题——完美转发)。
Sy:AH[l Zp!OL8@
  C++03之所以让人费神就是因为它没有一个引用类型来绑定到右值,而是用const左值引用来替代,事实证明这个权宜之计并不是长远之道,时隔10年,终归还是要健全引用的左右值语意。
+E4D%B$Hj8{2H
&y^+Zb] rol   C++09加入一个新的引用类型——右值引用。右值引用的特点是优先绑定到右值。其语法是&&(注意,不读作“引用的引用”,读作“右值引用”)。有了右值引用,我们前面的方案便可以简单的修改为:8n-L9V2Qm t
)H3qv.M/Dsn
void foo(X const& x);+XKC\ LM0^
void foo(X&& x); fF"~)[Y;r
0W2l$xg g{k
  这样一来,左值以及const右值都被绑定到了第一个重载版本。剩下的non-const右值被绑定到第二个重载版本。
3o yt$},HV a
$[)U&R#p{ TE   对于你的moveable的类型X,则是这样:6kA"b:E ]&g8w
8\#y \ogT*[Vv
struct X
+ec ^!l BC {
eK#T:ma1L X(); mP!ywIj3Qh
X(X const& o); // copy constructor
O#~S6X/Q;[2a9wW X(X&& o); // move constructor
9R3o3yg,L,J8x };
(A$M.H"O*YH0q
]!VC&DMG|H X source();4Cu7F-oz2u:b`c
X x = source(); // #1
#p-T s*H_&p
:U#XPd t   在#1处,调用的将会是X::X(X&& o),即所谓的move constructor,因为source()返回的是一个临时对象(non-const右值),重载决议会选中move constructor。

页: [1]
   

Powered by Discuz! Archiver 6.1.0  © 2001-2007 Comsenz Inc.