C++ 是一门以效率见长的语言(虽然近来越来越多的人“不齿”谈及效率,我深以为不然,在某一次的程序编写中不对效率锱铢必较并不意味意味着我们就不应该追求更多的更好的做法)。总之吧,相比起其它语言,程序员们在使 C++ 的时候会更加有意识地去避免没有效率的做法。在C++ 的程序中,临时对象的产生就是损及效率的“恶因”之一,因此也产生出一些意思的技术和优化手段,这篇文章里我总结一下最近在这些方面学习的一些收获:
返回值优化(RVO)与具命返回值优化(NRVO)
这是一项编译器做的优化,已经是一种很常见的优化手段了,放狗搜一下可以找到很多的资料,在 MSDN 里也有相关的说明。
返回值优化,顾名思义,就是与返回值有关的优化(废话……),是当函数是按值返回(而不是引用啊、指针啊)时,为了避免产生不必要的临时对象以及值拷贝而进行的优化。
先看看下面的代码:
typedef unsigned int UINT32; class MyCla { public: MyCla(UINT32 a_size = 10):size(a_size) { p = new UINT32[size]; } MyCla(MyCla const & a_right):size(a_right.size) { p = new UINT32[size]; memcpy(p, a_right.p, size*sizeof(UINT32)); } MyCla const& operator = (MyCla const & a_right) { size = a_right.size; p = new UINT32[size]; memcpy(p, a_right.p, size*sizeof(UINT32)); return *this; } ~MyCla() { delete [] p; } private: UINT32 *p; UINT32 size; }; MyCla TestFun() { return MyCla(); } int _tmain(int argc, _TCHAR* argv[]) { MyCla a = TestFun(); return 0; }
TestFun() 函数返回了一个 MyCla 对象,而且是按值传递的。
在没有任何“优化”之前,这段代码的行为也许是这样的:return MyCla() 这行代码中,构造了一个 MyCla 类的临时的无名对象(姑且叫它t1),接着把 t1 拷贝到另一块临时对象 t2(不在栈上),然后函数保存好 t2 的地址(放在 eax 寄存器中)后返回,TestFun 的栈区间被“撤消”(这时 t1 也就“没有”了,t1 的生存域在 TestFun 中,所以被析构了),在 MyCla a = TestFun(); 这一句中,a 利用 t2 的地址,可以找到 t2 进行,接着进行构造。这样 a 的构造过程就完成了。然后再把 t2 也“干掉”。
可以看到,在这个过程中,t1 和 t2 这两个临时的对象的存在实在是很浪费的,占用空间不说,关键是他们都只是为a的构造而存在,a构造完了之后生命也就终结了。既然这两个临时的对象对于程序员来说根本就“看不到、摸不着”(匿名对象嘛,你怎么引用?),于是编译器干脆在里面做点手脚,不生成它们!怎么做呢?很简单,编译器“偷偷地”在我们写的fun函数中增加一个参数 A&,然后把 a 的地址传进去(注意,这个时候 a 的内存空间已经存在了,但对象还没有被“构造”,也就是构造函数还没有被调用),然后在函数体内部,直接用 a 来代替原来的“匿名对象”,在函数体内部就完成 a 的构造。这样,就省下了两个临时变量的开销。这就是所谓的“返回值优化”~!在 VC7 里,按值返回匿名对象时,默认都是这么做。
上面说的是“返回值优化(RVO)”,还有一种“具名返回值优化(NRVO)”,是对于按值返回“具名对象”(就是有名字的变量!)时的优化手段,其实道理是一样的,但由于返回的值是具名变量,情况会复杂很多,所以,能执行优化的条件更苛刻,在下面三种情况下(来自MSDN),NRVO 将一定不起作用:
- 不同的返回路径上返回不同名的对象(比如if XXX 的时候返回x,else的时候返回y)
- 引入 EH 状态的多个返回路径(就算所有的路径上返回的都是同一个具名对象)
- 在内联asm语句中引用了返回的对象名。
不过就算 NRVO 不能进行,在上面的描述中的 t2 这个临时变量也不会产生,对于 VC 的 C++ 编译器来说,只要你写的程序是把对象按值返回的,它会有两种做法,来避免 t2 的产生。拿下面这个程序来说明:
MyCla TestFun2() { MyCla x(3); return x; }
一种做法是像 RVO一样,把作为表达式中获取返回值来进行构造的变量 a 当成一个引用参数传入函数中,然后在返回语句之前,用要返回的那个变量来拷贝构造 a,然后再把这个变量析构,函数返回原调用点,a 就构造好了。
还有一种方式,是在函数返回的时候,不析构 x ,而直接把 x 的地址放到 exa 寄存器中,返回调到 TestFun2 的调用点上,这时,a 可以用 exa 中存着的地址来进行构造,a 构造完成之后,再析构原来的变量 x !是的,注意到其实这时,x 的生存域已经超出了 TestFun2,但由于这里 x 所在 TestFun2 的栈虽然已经无效,但是并没有谁去擦写这块存,所以 x 其实还是有效的,当然,一切都在汇编的层面,对于 C++ 语言层面来讲是透明的。
嗯,(具名)返回值引用大约就是这么多,在网上和 MSDN 上还能查到更多的例子和解释,对于在多线程下 (N)RVO 需要注意什么,嗯,我完全没有多线程的经验,不敢乱写误人子弟……
右值引用与 move 语意
“C++ 中临时对象对效率产生的影响一直为人所诟病”(网上流传的说法),NRVO 等手段也只有在一定程度上弥补这个不足(你知道,在很多情况下无法做优化)。在 C++98 确定后的十多年时间后,“Cpper神圣”们终于给出了另一个对付它的法宝——右值引用。
对于右值引用,目前我所见过的最好的讲解是VC开发团队blog中发布的一篇长文(看这里),在CPP blog上飘飘白云的博主进行了全文翻译(译得很棒),建议细读三遍!理解里面每一个例子~这样至少你在右值引用的认识上就有了良好的基础了。(嗯,我只读了两遍,下面说的东西有错误的话请原谅并指出 :) )
简单的说,在C++中的左值,就是能取地址的表达式,比如var、++var之类的,右值就不是能取地址的表达式啦,比如常数 123、x++、x+y等等。
嗯,我们可以看到,右值常常就代表着临时对象,也就常常意味着“被诟病的浪费……”
比如,z = x + y,这里,翻译得更“低层”一点,那么这里将是:
temp = x + y z = temp
这个temp是很尴尬的,不用它将无法实现正确、良好的 operator + 语意,用它就很难避免临时对象产生的不良开销。
我们回到上面 RVO 中的程序例子:
MyCla TestFun() { return MyCla(); }
看,这里返回的 MyCla(),正是一个右值(我们就给它取个名吧,不然不好称呼它,嗯,还叫 t1 吧)。在函数返回后,这个 t1 就被析构,它做的析构动作就是把原来申请的内存还给系统。想想在这之前,a 在干什么?a 在构造的时候向系统申请了一块内存!一个申请,一个还回,一来一回多费事啊,如果能直接把 t1 拥有的内存给 a ,就不省事了吗?反正 t1 马上就要挂了。好,右值引用给了我们这种机会,我们为 MyCla 实现一个 move 语意的拷贝构造函数(不知道什么是 move 拷贝构造?回头看上面链接的文章三遍!):
MyCla(MyCla && a_right):size(a_right.size) { p = a_right.p; a_right.p = NULL; }
当编译器探知用于构造 a 的是一个右值时,就调用这个 move 构造函数,然后我们在这个函数里偷梁换柱,把 t1 的资源窃取过来了。这样,就算不使用 RVO,这个构造的开销也是非常小的。
那么,对于像:
MyCla TestFun2() { MyCla x(3); return x; }
这样的情况呢?是的,这里的 x 是一个左值,不会调用 move 构造函数。可是我们知道这个 x 其实马上也要挂了,它的资源不给白不给啊对不对?所以,我们就想告诉编译器,您就把它当成个右值吧,怎么告诉它呢?用 std::move 来实现这种 move 语意,像下面这样:
MyCla TestFun2() { MyCla x(3); return std::move(x); }
总结一下,作为右值的临时对象,其实它的存在就是充当一个传递的桥梁,一旦表达式过了这个桥,那么这个临时对象的存在就没有意义了,也没有人能再用到它(因为它是个右值,没有名字,又不能取地址)。既然如此,一个无人问津的就要“死”的变量,把它拥的的资源抢过来也不算过份吧……。在 C++0x 之前,我们想这么做,但是没有手段,虽然编译器能分清楚左值右值,但我们无法通过程序告诉编译器,如果这是左值,请用这个方法,这个是右值,嘿嘿,那用另一个方法帮我抢它的资源吧……,。到了 C++0x ,我们有手段了,那就是右值引用,这个右值引用可以参与函数的重载,这样就给了我们机会,针对左右值分别提供不同的操作方法(函数)让编译器帮我们选择一个合适的。
一般来说,可能需要注意右值引用的地方有:
当我们写的类里拥有动态申请的资源时,那么总是应该提供一个move构造函数,这将会带给很多好处,可以让这个类的使用者(一般是我们自己函数,或是SDL等库)利用它来提升效率。
如果我们写的函数需要利用传入的(含有动态申请资源的)对象参数来构造新的变量时,我们可以提供右值引用的重载版本,并在构造新对象时使用std::move来窃取临时对象的资源。
右值引用在泛型编程中也有极为重要的作用(它能实现完美转发),但和本文没多大关系,就不多说了。
总之,右值引用是 C++0x 中非常耀眼的一个新的语言特性,VC2010已经将其列入支持范围(GCC 本人几乎没用过,没了解,不敢妄言[注{ThanksTo OwnWaterloo}:gcc新版本也支持了。 gcc4.4.0 的stl已经加上对move的支持了])。
从实践的角度讲,它能够完美地解决 C++ 中长久以来为人们所诟病的临时对象的效率问题。从语言本身来讲,它健全了 C++ 中引用类型在左值右值方面原先的缺陷,从库的设计者角度讲,它给设计者又带来了一把利器。而对于广大的库使用者而言,不动一兵一卒便能获得“免费”的效率提升。
牛吧!这个特性如此重要如此有用,几乎可以想见在支持右值的编译器一旦实用化,就将产生大量的使用右值引用特性代码和相关的idioms,也可能会遇到和这个相关的bug,一句话,趁早学吧,出来混,总是会碰上的……。