move和转发

总的来说C++09C++98相比的变化是极其重大的。这个变化体现在三个方面,一个是形式上的变化,即在编码形式层面的支持,也就是对应我们所谓的编程范式(paradigm)C++09不会引入新的编程范式,但在对泛型编程(GP)这个范式的支持上会得到质的提高:conceptsvariadic-templatesauto/decltypetemplate-aliasesinitializer-lists皆属于这类特性。另一个是内在的变化,即并非代码组织表达方面的,memory-modelGC属于这一类。最后一个是既有形式又有内在的,r-value references属于这类。

 

这个系列如果能够写下去,会陆续将C++09的新特性介绍出来。鉴于已经有许多牛人写了很多很好的tutor这里这里,还有C++标准主页上的一些introductiveproposals,如这里,此外C++社群中老当益壮的Lawrence Crowl也在google做了非常漂亮的talk)。所以我就不作重复劳动了:),我会尽量从一个宏观的层面,如特性引入的动机,特性引入过程中经历的修改,特性本身的最具代表性的使用场景,特性对编程范式的影响等方面进行介绍。至于细节,大家可以见每篇介绍末尾的延伸阅读。

 

 

右值引用导言

 

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

 

Move语意

 

返回值效率问题——返回值优化((N)RVO)——mojo设施——workaround——问题定义——Move语意——语言支持

 

大猴子Howard Hinnant写了一篇挺棒的tutorial(a.k.a. 提案N2027),此外最初的关于rvalue-reference的若干篇提案的可读性也相当强。因此要想了解rvalue-reference的话,或者去看C++标准委员会网站上的系列提案(见文章末尾的参考文献)。或者阅读本文。

 

源起

《大史记》总看过吧?

 

故事,素介个样子滴一天,小嗖风风的吹着,在一个伸手不见黑夜的五指(哎哟,谁人扔滴板砖?!%$@

 

我用const引用来接受参数,却把临时变量一并吞掉了。我用非const引用来接受参数,却把const左值落下了。于是乎,我就在标准的每个角落寻找解决方案,我靠!我被8.5.3打败了!

 

设想这样一段代码(既然大同小异,就直接从Andrei那篇著名的文章里面拿来了):

 

std::vector<int> v = readFile();

 

readFile()的定义是这样的:

 

std::vector<int> readFile()

{

  std::vector<int> retv;

  … // fill retv

  return retv;

}

 

这段代码低效的地方在于那个返回的临时对象。一整个vector得被拷贝一遍,仅仅是为了传递其中的一组int,当v被构造完毕之后,这个临时对象便烟消云散。

 

这完全是公然的浪费!

 

更糟糕的是,原则上讲,这里有两份浪费。一,retv(retv在readFile()结束之后便烟消云散)。二,返回的临时对象(返回的临时变量在v拷贝构造完毕之后也随即香消玉殒)。不过呢,对于上面的简单代码来说,大部分编译器都已经能够做到优化掉这两个对象,直接把那个retv创建到接受返回值的对象,即v中去。

 

实际上,临时对象的效率问题一直是C++中的一个被广为诟病的问题。这个问题是如此的著名,以至于标准不惜牺牲原本简洁的拷贝语意,在标准的12.8节悍然下诏允许优化掉在函数返回过程中产生的拷贝(即便那个拷贝构造函数有副作用也在所不惜!)。这就是所谓的“Copy Elision”。

 

为什么(N)RVO((Named) Return Value Optimization)几乎形同虚设

还是按照Andrei的说法,只要readFile()改成这样:

 

… readFile()

{

if(/* err condition */) return std::vector<int>();

if(/* yet another err condition */) return std::vector<int>(1, 0);

std::vector<int> retv;

… // fill retv

return retv;

}

 

出现这种情况,编译器一般都会乖乖放弃优化。

 

但对编译器来说这还不是最郁闷的一种情况,最郁闷的是:

 

std::vector<int> v;

v = readFile(); // assignment, not copy construction

 

这下由拷贝构造,变成了拷贝赋值。眼睛一眨,老母鸡变鸭。编译器只能缴械投降。因为标准只允许在拷贝构造的情况下进行(N)RVO。

 

为什么库方案也不是生意经

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

 

Andrei把这个框架叫做mojo,就像一层爽身粉一样,把它往现有类上面一洒,嘿嘿…猜怎么着,不,不是“痱子去无踪”:P,是该类型的临时对象效率问题就迎刃而解了!

 

Mojo的唯一的问题就是使用方法过于复杂。这个复杂度,很大程度上来源于标准中的一个措辞问题(C++标准就是这样,鬼知道哪个角落的一句话能够带出一个brilliant的解决方案来,同时,鬼知道哪个角落的一句话能够抹杀一个原本简洁的解决方案)。这个问题就是我前面提到过的8.5.3问题,目前已经由core language issue 391解决。

 

对于库方案来说,解决问题固然是首要的。但一个侵入性的,外带使用复杂性的方案必然是走不远的。因此虽然大家都不否认mojo是一个天才的方案,但实际使用中难免举步维艰。这也是为什么mojo并没有被工业化的原因。

 

为什么改用引用传参也等于痴人说梦

void readFile(vector<int>& v){ … // fill v }

 

这当然可以。

 

但是如果遇到操作符重载呢?

 

string operator+(string const& s1, string const& s2);

 

而且,就算是对于readFile,原先的返回vector的版本支持

 

BOOST_FOREACH(int i, readFile()){

  … // do sth. with i

}

 

改成引用传参后,原本优雅的形式被破坏了,为了进行以上操作不得不引入一个新的名字,这个名字的存在只是为了应付被破坏的形式,一旦foreach操作结束它短暂的生命也随之结束:

 

vector<int> v;

readFile(v);

 

BOOST_FOREACH(int I, v){

}

 

// v becomes useless here

 

还有什么问题吗?自己去发现吧。总之,利用引用传参是一个解决方案,但其能力有限,而且,其自身也会带来一些其它问题。终究不是一个优雅的办法。

 

问题是什么

《你的灯亮着吗?》里面漂亮地阐述了定义“问题是什么”的重要性。对于我们面临的临时对象的效率问题,这个问题同样重要。

 

简而言之,问题可以描述为:

 

C++没有区分copymove语意。

 

什么是move语意?记得auto_ptr吗?auto_ptr在“拷贝”的时候其实并非严格意义上的拷贝。“拷贝”是要保留源对象不变,并基于它复制出一个新的对象出来。但auto_ptr的“拷贝”却会将源对象“掏空”,只留一个空壳——一次资源所有权的转移。

 

这就是move。

 

Move语意的作用——效率优化

举个具体的例子,std::string的拷贝构造函数会做两件事情:一,根据源std::string对象的大小分配一段大小适当的缓冲区。二,将源std::string中的字符串拷贝过来。

 

// just for illustrating the idea, not the actual implementation

string::string(const string& o)

{

this->buffer_ = new buffer[o.length() + 1];

copy(o.begin(), o.end(), buffer_);

}

 

但是假设我们知道o是一个临时对象(比如是一个函数的返回值),即o不会再被其它地方用到,o的生命期会在它所处的full expression的结尾结束的话,我们便可以将o里面的资源偷过来:

 

string::string(temporary string& o)

{

// since o is a temporary, we can safely steal its resources without causing any problem

 

this->buffer_ = o.buffer_;

o.buffer_ = 0;

}

 

这里的temporary是一个捏造的关键字,其作用是使该构造函数区分出临时对象(即只有当参数是一个临时的string对象时,该构造函数才被调用)。

 

想想看,如果存在这样一个move constructor(搬移式构造函数)的话,所有源对象为临时对象的拷贝构造行为都可以简化为搬移式(move)构造。对于上面的string例子来说,move和copy construction之间的效率差是节省了一次O(n)的分配操作,一次O(n)的拷贝操作,一次O(1)的析构操作(被拷贝的那个临时对象的析构)。这里的效率提升是显而易见且显著的。

 

最后,要实现这一点,只需要我们具有判断左值右值的能力(比如前面设想的那个temporary关键字),从而针对源对象为临时对象的情况进行“偷”资源的行动。

 

Move语意的作用——使能(enabling)

再举一个例子,std::fstream。fstream是不可拷贝的(实际上,所有的标准流对象都是不可拷贝的),因而我们只能通过引用来访问一开始建立的那个流对象。但是,这种办法有一个问题,如果我们要从一个函数中返回一个流对象出来就不行了:

 

// how do we make this happen?

std::fstream createStream()
{ … }

 

当然,你可以用auto_ptr来解决这个问题,但这就使代码非常笨拙且难以维护。

 

但如果fstream是moveable的,以上代码就是可行的了。所谓“moveable”即是指(当源对象是临时对象时)在对象拷贝语法之下进行的实际动作是像auto_ptr那样的资源所有权转移:源对象被掏空,所有资源都被转移到目标对象中——好比一次搬家(move)。move操作之后,源对象虽然还有名有姓地存在着,但实际上其“实质”(内部拥有的资源)已经消失了,或者说,源对象从语意上已经消失了。

 

对于moveable但并非copyable的fstream对象来说,当发生一次move时(比如在上面的代码中,当一个局部的fstream对象被move出createStream()函数时),不会出现同一对象的两个副本,取而代之的是,move的源对象的身份(Identity)消失了,这个身份由返回的临时fstream对象重新持有。也就是说,fstream的唯一性(不可拷贝性——non-copyable)得到了尊重。

 

你可能会问,那么被搬空了的那个源对象如果再被使用的话岂不是会引发问题?没错。这就是为什么我们应该仅当需要且可以去move一个对象的时候去move它,比如在函数的最后一行(return)语句中将一个局部的vector对象move出来(return std::move(v)),由于这是最后一行语句,所以后面v不可能再被用到,对它来说所剩下的操作就是析构,因此被掏空从语意上是完全恰当的。

 

最初的例子——完美解决方案

在先前的那个例子中

 

vector<int> v = readFile();

 

有了move语意的话,readFile就可以简单的改成:

 

std::vector<int> readFile()

{

std::vector<int> retv;

… // fill retv

return std::move(retv); // move retv out

}

 

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。

 

如何支持move语意

根据前面的介绍,你想必已经知道。实现move语意的最关键环节在于能够在编译期区分左值右值(也就是说识别出临时对象)。

 

现在,回忆一下,在文章的开头我曾经提到:

 

我用const引用来接受参数,却把临时变量一并吞掉了。我用非const引用来接受参数,却把const左值落下了。于是乎,我就在标准的每个角落寻找解决方案,我靠!我被8.5.3打败了!

 

为什么这么说?

 

现行标准(C++03)下的方案

要想区分左值右值,只有通过重载:

 

void foo(X const&);

void foo(X&);

 

这样的重载显然是行不通的。因为X const&会把non-const临时对象一并吞掉。

 

这种做法的问题在于。X&是一个non-const引用,它只能接受non-const左值。然而,C++里面的值一共有四种组合:

 

        const    non-const

lvalue

rvalue

 

常量性(const-ness)与左值性(lvalue-ness)是正交的。

 

non-const引用只能绑定到其中的一个组合,即non-const lvalue。还剩下const左值,const右值,以及我们最关心的——non-const右值。而只有最后一种——non-const右值——才是可以move

 

剩下的问题便是如何设计重载函数来搞定const左值和const右值。使得最后只留下non-const右值。

 

所幸的是,我们可以借助强大的模板参数推导机制:

 

// catch non-const lvalues

void foo(X&);

 

// catch const lvalues and const rvalues

template<typename T>

void foo(T&, enable_if_same<T, const X>::type* = 0);

 

void foo( /* what goes here? */);

 

注意,第二个重载负责接受const左值和const右值。经过第一第二个foo重载之后剩下来的便是non-const rvalue了。

 

问题是,我们怎么捕获这些non-const rvalue呢?根据C++03,const-const rvalue只能绑定到const引用。但如果我们用const引用的话,就会越俎代庖把const左右值一并接受了(因为在模板函数(第二个重载)和非模板函数(第三个重载)之间编译器总是会偏好非模板)。

 

那除了用const引用,难道还有什么办法来接受一个non-const rvalue吗?

 

有。

 

假设你的类型为X,那么只要在X里面加入一点料:

 

struct ref_x

{

ref_x(X* p) : p_(p) {}

X* p_;

};

 

struct X

{

// original stuff

 

// added stuff, for move semantic

operator ref_x()

{

return ref_x(this);

}

};

 

这样,我们的第三个重载函数便可以写成:

 

void foo(ref_x rx); // accept non-const temporaries only!

 

Bang! 我们成功地在C++03下识别出了moveable的non-const临时对象。不过前提是必须得在moveable的类型里加入一些东西。这也正是该方案的最大弊病——它是侵入式的(姑且不说它利用了语言的阴暗角落,并且带来了很大的编码复杂度)。

 

C++09的方案

实际上,刚才讲的这个利用重载的方案做成库便是Andrei的mojo框架。mojo框架固然精巧,但复杂性太大,使用成本太高,不够优雅直观。所以语言级别的支持看来是必然选择(后面你还会看到,为了支持move语意而引入的新的语言特性同时还支持了另一个广泛的问题——完美转发)。

 

C++03之所以让人费神就是因为它没有一个引用类型来绑定到右值,而是用const左值引用来替代,事实证明这个权宜之计并不是长远之道,时隔10年,终归还是要健全引用的左右值语意。

 

C++09加入一个新的引用类型——右值引用。右值引用的特点是优先绑定到右值。其语法是&&(注意,不读作“引用的引用”,读作“右值引用”)。有了右值引用,我们前面的方案便可以简单的修改为:

 

void foo(X const& x);

void foo(X&& x);

 

这样一来,左值以及const右值都被绑定到了第一个重载版本。剩下的non-const右值被绑定到第二个重载版本。

 

对于你的moveable的类型X,则是这样:

 

struct X

{

X();

X(X const& o); // copy constructor

X(X&& o); // move constructor

};

 

X source();

 

X x = source(); // #1

 

在#1处,调用的将会是X::X(X&& o),即所谓的move constructor,因为source()返回的是一个临时对象(non-const右值),重载决议会选中move constructor。

 

 

完美转发

 

完美转发问题——不完美解决方案——模板参数推导规则——完美转发

 

动机

关于“完美转发”这个特性,其实提案N1385已经讲得非常清楚了,诸位可以直接去看N1385,如果实在还是觉得迷糊就再回来听我唠叨吧:-)

 

在泛型编码中经常出现的一个问题是(这个问题在实际中出现的场景很多,我们留到文章末尾再提,目前我们将这个特定的问题先提取孤立出来考虑):

 

如何将一组参数原封不动地转发给另一个函数

 

注意,这里所谓“原封不动”就是指,如果参数是左值,那么转发给的那个函数也要接受到一个左值,如果参数是右值,那么后者要接受到一个右值;同理,如果参数是const的,那么转发给的那个函数也要接受到一个const的值,如果是non-const的,那么后者也要接受到一个non-const的值。

 

总之一句话:

 

保持参数的左值/右值、const/non-const属性不变

 

听上去很简单吗?不妨试试看。

 

(不完美的)解决方案

假设我们要写一个泛型转发函数f,f要将它的参数原封不动地转发给g(不管g的参数类型是什么):

 

template<typename T>

void f(/*what goes here?*/ t)

{

g(t);

}

 

上面的代码中,f的参数t的类型是什么?T?T&?const T&?

 

我们一个个来分析。

 

Value

如果t的类型是T,即:

 

// take #1

template<typename T>

void f(T t)

{

g(t);

}

 

那么很显然,不能满足如下情况:

 

void g(int& i) { ++i; }

 

int myInt = 0;

 

f(myInt); // error, the value g() has incremented is a local value(a.k.a. f’s argument ‘t’)

 

即,不能将左值转发为左值。

 

Const&

如果t的类型为const T&,即:

 

// take #2

template<typename T>

void f(const T& t)

{

g(t);

}

 

则刚才的情况还是不能满足。因为g接受的参数类型为non-const引用。

 

Non-const&

那如果t的类型是T&呢?

 

// take #3

template<typename T>

void f(T& t)

{

g(t);

}

 

我们惊讶地发现,这时,如果参数是左值,那么不管是const左值还是non-const左值,f都能正确转发,因为对于const左值,T将会被推导为const U(U为参数的实际类型)。并且,对于const右值,f也能正确转发(因为const引用能够绑定到右值)。只有对non-const右值不能完美转发(因为这时T&会被推导为non-const引用,而后者不能绑定到右值)。

 

即四种情况里面有三种满足了,只有以下这种情况失败:

 

void g(const int& i);

 

int source();

 

f(source()); // error

 

如果f是完美转发的话,那么f(source())应该完全等价于g(source()),后者应该通过编译,因为g是用const引用来接受参数的,后者在面对一个临时的int变量的时候应该完全能够绑定。

 

而实际上以上代码却会编译失败,因为f的参数是T&,当面对一个non-const的int型右值(source()的返回值)时,会被推导为int&,而non-const引用不能绑定到右值。

 

好,现在的问题就变成,如何使得non-const右值也被正确转发,用T&作f的参数类型是行不通的,唯一能够正确转发non-const右值的办法是用const T&来接受它,但前面又说过,用const T&行不通,因为const T&不能正确转发non-const左值。

 

Const& + non-const&

那两个加起来如何?

 

template<typename T>

void f(T& t)

{

g(t);

}

 

template<typename T>

void f(const T& t)

{

g(t);

}

 

一次重载。我们来分析一下。

 

对于non-const左值,重载决议会选中T&,因为绑定到non-const引用显然优于绑定到const引用(const T&)。

 

对于const左值,重载决议会选中const T&,因为显然这是个更specialized的版本。

 

对于non-const右值,T&根本就行不通,因此显然选中const T&。

 

对于const右值,选中const T&,原因同第二种情况。

 

可见,这种方案完全保留了参数的左右值和const/non-const属性。

 

值得注意的是,对于右值来说,由于右值只能绑定到const引用,所以虽然const T&并非“(non-)const右值”的实际类型,但由于C++03只能用const T&来表达对右值的引用,所以这种情况仍然是完美转发。

 

组合爆炸

你可能会觉得上面的这个方案(const& + non-const&)已经是完美解决方案了。没错,对于单参的函数来说,这的确是完美方案了。

 

但是如果要转发两个或两个以上的参数呢?

 

对于每个参数,都有const T&和T&这两种情况,为了能够正确转发所有组合,必须要2的N次方个重载

 

比如两个参数的:

 

template<typename T1, typename T2>

void f(T1& t1, T2& t2) { g(t1, t2); }

 

template<typename T1, typename T2>

void f(const T1& t1, T2& t2) { g(t1, t2); }

 

template<typename T1, typename T2>

void f(T1& t1, const T2& t2) { g(t1, t2); }

 

template<typename T1, typename T2>

void f(const T1& t1, const T2& t2) { g(t1, t2); }

 

(完美的)解决方案

理想情况下,我们想要:

 

template<typename T1, typename T2, … >

void f(/*what goes here?*/ t1, /**/ t2, … )

{

  g(t1, t2);

}

 

填空处应该填入一些东西,使得当t1对应的实参是non-const/const的左/右值时,t1的类型也得是non-const/const的左/右值。目前的C++03中,non-const/const属性已经能够被正确推导出来(通过模板参数推导),但左右值属性还不能。

 

明确地说,其实问题只有一个:

 

对于non-const右值来说,模板参数推导机制不能正确地根据其右值属性确定T&的类型(也就是说,T&会被编译器不知好歹地推导为左值引用)。

 

修改T&对non-const右值的推导规则是可行的,比如对这种情况:

 

template<typename T>

void f(T& t);

 

f(1);

 

规定T&推导为const int&。

 

但这显然会破坏既有代码。

 

很巧的是,右值引用能够拯救世界,右值引用的好处就是,它是一种新的引用类型,所以对于它的规则可以任意制定而不会损害既有代码,设想:

 

template<typename T >

void f(T&& t){ g(t); }

 

我们规定:

 

如果实参类型为右值,那么T&&就被推导为右值引用。

如果实参类型为左值,那么T&&就被推导为左值引用。

 

Bingo!问题解决!为什么?请允许我解释。

 

f(1); // T&& 被推导为 int&&,没问题,右值引用绑定到右值。

f(i); // T&& 被推导为 int&,没问题,通过左值引用完美转发左值。

 

等等,真没问题吗?对于f(1)的情况,t的类型虽然为int&&(右值引用),但那是否就意味着t本身是右值呢?既然t已经是具名(named)变量了,因此t就有被多次move(关于move语意参考上一篇文章)的危险,如:

 

void dangerous(C&& c)

{

C c1(c); // would move c to c1 should we allow treating c as a rvalue

c.f(); // disaster

}

 

在以上代码中,如果c这个具名变量被当成右值的话,就有可能先被move掉,然后又被悄无声息的非法使用(比如再move一次),编译器可不会提醒你。这个邪恶的漏洞是因为c是有名字的,因此可以被多次使用。

 

解决方案是把具名的右值引用作为左值看待

 

但这就使我们刚才的如意算盘落空了,既然具名的右值引用是左值的话,那么f(1)就不能保持1的右值属性进行转发了,因为f的形参t的类型(T&&)虽然被推导为右值引用(int&&),但t却是一个左值表达式,也就是说f(1)把一个右值转发成了左值。

 

最终方案

通过严格修订对于T&&的模板参数推导原则,以上问题可以解决。

 

修订后的模板参数推导规则为:

 

如果实参是左值,那么T就被推导为U&(其中U为实参的类型),于是T&& = U& &&,而U& &&则退化为U&(理解为:左值引用的右值引用仍然是左值引用)。

 

如果实参是右值,那么T就被推导为U,于是T&& = U&&(右值引用)。

 

如此一来就可以这样解决问题:

 

template<typename T>

void f(T&& t)

{

  g(static_cast<T&&>(t));

}

 

想想看,如果实参为左值,那么T被推导为U&,T&&为U& &&,也就是U&,于是static_cast<T&&>也就是static_cast<U&>,转发为左值。

 

如果实参为右值,那么T被推导为U,T&&为U&&,static_cast<T&&>也就是static_cast<U&&>,不像t这个具名的右值引用被看作左值那样,static_cast<U&&>(t)这个表达式由于产生了一个新的无名(unnamed)值,因而是被看作右值的。于是右值被转发为了右值。

你可能感兴趣的:(move)