本系列的第一部分讲述了lambda表达式、auto关键字和static_assert。
本文将描述右值引用,和随它而来的两个新概念:Move语义(move semantics)和完美转发(perfect forwarding)。这篇文章会很长,因为我会详细解释右值引用是如何工作的。最开始的时候你可能会觉得有点乱,因为很少有C++98/03程序员熟悉左值(lvalue)和右值(rvalue)区分的。
但毋须退缩,因为使用右值引用很简单。在你自己的代码中,不管是实现move语义还是完美转发,都可以归结为下面我将要描述的简单模式。并且,学习右值引用是非常值得的,因为move语义可以产生数量级上的性能提升,完美转发使得编写高度通用的代码非常的容易。
C++98/03中的左值和右值
为了理解C++0x中的右值引用,必须首先理解C++98/03中的左值和右值。(译注:按照俺的理解,左值就是能够放在=左边被赋值的东东,右值就是不能放在=左边而只能放在=右边的东东……也不知道对也不对)
术语“左值”和“右值”挺乱的,因为它们的历史就比较乱…… 这些概念最初来源与C,后在C++上弄得比较复杂。为了节约时间,我跳过去它们的历史不说,而直接阐述它们在C++98/03中是怎么工作的。
左值是超越单个表达式而持续存在的。例如,obj,*ptr,ptr[index],++x,它们都属于左值。
右值是临时对象,它们只存活在单个的表达式中,并在该表达式结束后被销毁。例如1729,x + y,std::string("meow")和x++,都是右值。
(译注:lvalue->左值,const lvalue->常量左值,rvalue->右值,const rvalue->常量右值,下文视情况使用中文或英文)
注意 x++ 和 ++x 的区别:如果我们声明 int x = 0,则x是左值,并且语句声明了一个持久的对象。++x 也是一个左值,它修改并且返回了这个持久对象x;但是,x++却是右值,因为它把持久对象x拷贝了一份,修改了x的值,然后返回的是修改之前的拷贝,这份拷贝是临时性质的。x++ 和 ++x 都递增了x,但++x递增返回自增后的x本身,x++返回的是x自增前的一份拷贝,这份拷贝是临时性的。这就是为什么++x是左值,而x++是右值。判断左值和右值不用管这个语句干了什么,而只看该语句命名了什么(是持久性的对象还是临时对象)。
如果你想对建立起判断左右值的直觉,另一种快速的方法就是看“取它的地址是否合法?”如果能取地址,那就是左值,否则就是右值。例如:&obj,&*ptr,&ptr[index]和&++x都是可以的,但是&1729,&(x + y),&std::string("meow")和&x++都是不合法的。为什么可以这样判断呢?因为取地址运算符要求操作对象是左值(C++03 5.3 1/2)。为什么会有这种要求呢?因为取一个持久对象的地址是对的,但是取临时对象的地址是危险的,因为临时对象将很快被销毁。
前面的例子忽略了运算符重载,调用重载的运算符也是一种函数调用。“函数调用是一个左值,当且仅当其返回值是一个引用。”(C++03 5.2.2/10)。因此,给定vector v(10, 1729);,v[0]是一个左值,因为operator [] ()返回一个 int& (并且&v[0]是有效的)。给出string s("foo");和string t("bar");,s + t是右值因为operator + ()返回string类型(并且&(s + t)是非法的)。
左值和右值都可以是变量或者常量,例如:
string one("cute"); |
Type& 与lvalue绑定(并且可以被用于观察和修改它们)。它不能绑定到const lvalue上,因为这违反了常量约束。它不能绑定rvalues上,因为这样可能是极端危险的。意外地修改临时对象,只会导致临时对象和刚刚的修改操作一起消失,会导致一些难以发现的讨厌BUG,因此C++禁止这么干。(我应该提到VC有一个邪恶的扩展允许你这么干,但是如果你通过/W4选项编译,编译器会报警。)还有,它也不能绑定到const rvalue上,因为这样可能会导致双倍的错误(译注:1、非常量引用类型引用常量,2、左值引用引用右值)(细心的读者可能已经注意到了我这里没有谈到模板参数类型推导)
const Type& 可以绑定到任何东西上:lvalue, rvalue, const lvalue, const rvalue(并且可用于观察它们的变化)。
一个引用类型是具名的,因此,一个绑定到右值的引用本身,是左值。(因为只有常量引用才能绑定到右值上,因此它会是一个const lvalue)这个有点拗口,而且会导致一些比较大的问题,因此我来进一步说明一下。给定一个函数签名void observe(const string& str),即便调用observe()时传入像上面的three()这样的右值当作参数,在此函数内部str也是一个const lvalue,其地址是可以取到的,并且在函数返回前都可用。你也可以调用observe("purr"),这将导致一个临时的string对象被构造,并且将str绑定到上面。three()、four()的返回值也是右值,但是在observe函数内部,str就是一个名字,因此它是左值。就像我上面强调的,“左值性或者是右值性是针对于表达式来说的,并不是针对于对象。”当然了,因为str可以被绑定到一个会在未来被销毁的临时对象上,所以保存其地址,在observe()返回后使用是不对的。
你曾经将一个右值绑定到一个const Type&上面并且取过它的地址吗?是的,你肯定这么干过!当你重载一个赋值运算符Foo& operator=(const Foo& other),里面会有自己给自己赋值的检测 if(this != &other){ copy stuff; } return *this;,这样,当你从临时对象赋值时,上述情况就发生了,例如Foo make_foo(); Foo f; f = make_foo();。
这时候,有同学会问了,“我不能将Type&与rvalue绑定,我也不能指派个啥东西与rvalue关联,那我还能修改右值吗?const rvalue和rvalue有啥不同?”这是一个非常好的问题!在C++98/03中,const rvalue和rvalue有轻微的不同:可以在rvalue的对象上调用其非常量成员函数,而const rvalue属性的对象上则不能调用。在C++0x中,答案戏剧性地变化了,导致了move语义的出现。
祝贺你!你现在已经具有了“左值/右值感官”:看到一个表达式就能够辨认出这是左值还是右值的能力。结合const属性,你可以精确的分析给出声明void mutate(string& ref)和上面的变量定义,mutate(one)是OK的,mutate(two), mutate(three()), mutate(four()), mutate("purr")都是非法的。所有的observe(one), observe(three()), observe(four())和observe("purr")都是有效的。如果你是一名C++98/03程序员,你以前是“本能的直觉”告诉你上面哪些调用是非法的,哪些是有效的。但现在,你有了“左值/右值感官”,它可以精确的告诉你为什么mutate(three())通不过编译(因为three()是一个右值,Type&不能绑定到右值上)。这有用吗?对于研究语法的人,很有用,但对于普通的程序员,就没啥大用处了。毕竟,你在没有掌握太多左右值细节的情况下,得到了这项能力。但注意,相对于C++98/03,C++0x需要更加强大的左右值判断能力,你现在具有了这项牛B能力,让我们继续下去吧!
对象复制问题
C++98/03将强大的抽象能力和强大的执行效率完美结合在了一起,但它有一个问题:它过度的依赖于对象的复制了。对象都是具有值语义的,因此复制一个对象不会影响源对象,其副本也是独立的。值语义很棒, 但它有时候会导致像string, vector这样的庞大对象的不必要的复制。有些情况下返回值优化 Return Value Optimization (RVO) 和 具名返回值优化 Named Return Value Optimization (NRVO)可以减轻这个问题,但是它们也没有完全消除所有不必要的复制行为。
最不必要的复制行为就是去复制那些即将销毁的对象。你会在复制了一夜表单后立马将原来的那份扔了吗?这太浪费了吧,你应该保留原有的那一份,而不是复制一份再扔掉原来的。这里有一个我称之为“杀手级”的例子,来源于标准委员会(N1377)。假设你有一坨string:
string s0("my mother told me that"); |
然后你把他们连接起来:
string dest = s0 + " " + s1 + " " + s2 + " " + s3 + " " + s4; |
这句话效率如何?(我们不是担心这个特例的效率问题,它也只运行几微秒而已;我们担心的是整个语言的属于上面例子这一类的效率问题)
每次调用operator + () 都返回一个string的临时对象。上面一共调用了8次operator + (),因此出现了8个临时的string。每次构造临时对象时,其构造函数都会动态分配一些内存然后拷贝之前连接过的所有字符。接着,其析构函数被调用,内存被释放。
实际上,每次字符串的连接都会把之前连接过的字符串的所有字符复制一遍,因此,随着连接次数的增多,复杂度成平方级增长。啊!这真是极度的浪费啊!我们如何避免这种情况的发生呢?
问题出在operator + ()上面,它接受两个const string&或者一个const string&和一个const char*作为参数,它不能判断出接受的参数到底是左值还是右值,因此它就每次创建并且返回一个临时的string。为什么知道左值或是右值很重要呢?
当我们看s0 + " "时,创建一个临时对象是完全必要的。s0是左值,它命名了一个持久性的对象,因此我们不能修改它。但是当我们看(s0 + " ") + s1时,我们应该直接将s1的内容增加进(s0 + " ")创建的临时对象中,而不是创建一个新的临时对象,再把第一个临时对象扔掉。这就是move语义的关键:因为(s0 + " ")是个右值,一个代表临时对象的表达式,整个程序中再没有其它地方观察这个变量了。如果我们能够检测到这是个右值,我们就能任意修改它,而不影响任何其它的地方。operator + ()并不想修改它的参数,但是如果参数是可修改的右值,修改了也无妨吧?使用这种方法,每次调用operator + ()都在那个首次创建的临时string上扩展字符,这完全消掉了不必要的内存管理和复制,带给我们线性的复杂度,哦耶!
技术层面上讲,C++0x中,每次调用operator + () 仍然返回一个独立的临时变量。但是,(s0 + " ") + s1中,第二个+返回的临时变量窃取了第一个+创建的临时变量的内存,并且将s1的内容接在窃取的那段内存后面(可能会导致重新申请更大的内存)。“窃取”还包括指针的修改:第二个临时变量拷贝走第一个临时变量的内存,并将其内存指针设为NULL。当第一个临时变量释放内存时,指针为空,因此它的析构函数啥也不用干。
通常,检查可修改的右值的能力使你成为了“资源小偷”。如果被引用的右值包含任何资源(例如内存),你就可以窃取它的资源而不必拷贝它,因为它马上就会被销毁了。通过窃取右值上面的资源构造对象或者是赋值,通常称之为“move”,可move的对象具有“move语义”。
这在很多地方都很有用,例如vector的重新分配。当一个vector需要更大的容量时,重新分配内存后,它需要将数据对象从老的内存块中移动到新的内存块,而调用它们的拷贝构造函数可能代价很昂贵(一个vector<string>,每个string的拷贝都需要分配内存,拷贝整个字符串)。但是等一等!在旧的内存块中的对象是马上要被销毁的,所以我们可以移动它们,而不是拷贝。在这种情况下,在旧的内存块中的元素是持久性存储的,而像old_ptr[index]这样引用这个元素的表达式是左值。如果把它们看做是右值就好了,这样就允许我们移动它们,消灭掉了拷贝构造函数的调用。(说“我想将这个左值看做右值”等价于说“我知道这是个左值,代表了一个持久性的对象,但我不在乎了,因为我即将销毁它了,或者给它重新复制了,等等。所以如果你能窃取它的资源,那就这么干吧!”)
C++0x的右值引用给了我们检测右值和窃取右值资源的能力,这使move语义成为现实。右值引用也允许我们将左值看做是右值而实施move语义。现在,让我们看看右值语义如何工作吧。
右值引用:初始化
C++0x引入了一个新的引用类型,右值引用:Type&&和const Type&&。当前的C++0x工作草案,N2798 8.3.2/2中说:“通过&声明的引用叫做左值引用,通过&&声明的引用叫做右值引用。左值引用和右值引用是不同的类型。除非明确地指出,它们在语义上是等效的,通常被称为引用。”这意味着你需要学习它们的不同之处。
相对于左值引用,右值引用在初始化和重载时有不同的行为。区别在于它们倾向于绑定什么类型的对象(初始化),什么类型的对象倾向于优先绑定到它们(重载)。让我们先看看初始化:
- 我们已经知道了Type&倾向于绑定lvalue。其它的都不行(const lvalue,rvalue,const rvalue)。
- 我们已经知道了const Type&倾向于绑定所有类型。
- Type&&倾向于绑定lvalue和rvalue,但不能绑定const lvalue和const rvalue(这违反了常量约束)。
- const Type&&倾向于绑定所有类型。
这些规则看着挺神秘,但它们都是从下面两条派生出来的:
- 遵守常量约束,变量引用不能绑定与常量。
- 避免修改临时变量,阻止左值引用绑定到右值上。
如果你更喜欢看编译器给出的错误信息,下面就是一例:
C:/Temp>type initialization.cpp string modifiable_rvalue() { const string const_rvalue() { int main() { string& a = modifiable_lvalue; // Line 16 const string& e = modifiable_lvalue; // Line 21 string&& i = modifiable_lvalue; // Line 26 const string&& m = modifiable_lvalue; // Line 31 C:/Temp>cl /EHsc /nologo /W4 /WX initialization.cpp |
右值绑定到右值引用可以被用来修改临时变量。
即使左值引用和右值引用在初始化时行为是类似的(只有18行和28行有不同),它们在重载中表现得却很不同。
右值引用:重载判定
你已经对参数为变量和常量左值引用的函数重载很熟悉了。在C++0x中,函数可以用常量或非常量右值引用重载。给出一个一元函数的所有四种重载形式,你应该发现了,每个表达式都倾向于绑定到与它对应的引用上:
C:/Temp>type four_overloads.cpp void meow(string& s) { void meow(const string& s) { void meow(string&& s) { void meow(const string&& s) { string strange() { const string charm() { int main() { meow(up); C:/Temp>cl /EHsc /nologo /W4 four_overloads.cpp C:/Temp>four_overloads |
在实践中,对Type&,const Type&,Type&&,const Type&&的重载不是非常的实用。一个更加有趣的重载是集合const Type&和Type&&:
C:/Temp>type two_overloads.cpp void purr(const string& s) { void purr(string&& s) { string strange() { const string charm() { int main() { purr(up); C:/Temp>cl /EHsc /nologo /W4 two_overloads.cpp C:/Temp>two_overloads |
为什么会是这种结果呢?下面是绑定规则:
- 上面初始化相关的规则具有否决权。
- 左值非常强烈的倾向于绑定到左值引用上,右值非常强烈的倾向于绑定到右值引用上。
- 变量表达式倾向于绑定到非常量引用上,但倾向性稍弱。
(对于“否决”,我指的是决定匹配的候选函数时,被断定为不可行的函数将马上被排除在外)让我们根据规则判断一下:
- 对于purr(up),purr(const string&)和purr(string&&)都没有被初始化规则否决掉。up是一个左值,因此它强烈地想绑定到左值引用purr(const string&)上。up是可变的,所以它比较弱地倾向于绑定到可变引用purr(string&&)上。比较强烈的倾向purr(const string&)胜出。
- 对于purr(down),初始化规则根据常量约束否定掉了purr(string&&),因此purr(const string&)胜出。
- 对于purr(strange()),purr(const string&)和purr(string&&)都没有被初始化规则否决掉。strange()是一个右值,因此它强烈地倾向于绑定到右值引用purr(string&&)上。strange()是可变的,因此它比较弱地倾向于绑定到非常量引用purr(string&&)上。强烈地倾向purr(string&&)胜出。
- 对于purr(charm()),初始化规则根据常量约束否决掉了purr(string&&),因此purr(const string&)胜出。
值得注意的是,当你重载const Type&和Type&&时,变量右值绑定到了Type&&上,其它的都绑定到了const Type&上。因此这组重载非常适合move语义。
重要提示:函数按值返回时(而不是返回引用),它应该返回Type(就像strange()那样),而不是返回const Type(像charm()那样)。因为后者几乎没有任何作用(除了禁止非常量成员函数的调用),还妨碍了move语义的优化。
move语义:模型
这里有一个简单的class,remote_integer,它存储了一个指针,指向一个动态分配的int。它的默认构造函数、一元的构造函数、拷贝构造函数、重载赋值运算符和析构函数你都应该比较熟悉了。我又给它增加了move构造函数、move重载赋值运算符,它们被#ifdef MOVABLE条件编译,我用它来演示有这两个函数和没有他们时分别发生了什么,真正的代码不会这么干。
C:/Temp>type remote.cpp class remote_integer { m_p = NULL; explicit remote_integer(const int n) { m_p = new int(n); remote_integer(const remote_integer& other) { if (other.m_p) { #ifdef MOVABLE m_p = other.m_p; remote_integer& operator=(const remote_integer& other) { if (this != &other) { if (other.m_p) { return *this; #ifdef MOVABLE if (this != &other) { m_p = other.m_p; return *this; ~remote_integer() { delete m_p; int get() const { private: remote_integer square(const remote_integer& r) { return remote_integer(i * i); int main() { cout << a.get() << endl; remote_integer b(10); cout << b.get() << endl; b = square(a); cout << b.get() << endl; C:/Temp>cl /EHsc /nologo /W4 remote.cpp C:/Temp>remote C:/Temp>cl /EHsc /nologo /W4 /DMOVABLE remote.cpp C:/Temp>remote |
这里面有几点需要注意:
- 拷贝和move构造函数是重载的,拷贝和move赋值运算符也是重载的。我们已经看到了const Type&和Type&&函数重载时发生的情况。这就是为什么当move赋值运算符可用时,b = square(a)自动的选择了它。
- move拷贝构造函数和move赋值运算符简单的从别的地方窃取内存,而不是动态的申请内存。当窃取别人的内存时,我们直接拷贝了他的内存指针,并且将其置NULL。当那个对象释放时,其析构函数不会重复释放内存。
- move拷贝构造函数和move赋值运算符都需要做自赋值检测。因为像int这样的类型可以无害地做x = x,所以用户自定义类型也应该支持自赋值。自赋值通常不会发生在手写的程序代码中,但经常发生在一些算法中,例如std::sort()。在C++0x中,像std::sort()这样的算法可以移动对象,而不是拷贝它们。这时候,潜在的自赋值就存在了。
这时,你会问move语义是否影响到了自动生成的(“隐式声明的”)构造函数和赋值运算符?
- 编译器从来不自动生成move构造函数和move赋值运算符。
- 包括拷贝构造函数和move构造函数,用户自定义的任何构造函数都会阻止编译器生成默认的构造函数。
- 用户自定义的拷贝构造函数会阻止生成隐式的拷贝构造函数,但自定义的move拷贝构造函数不会阻止生成隐式的拷贝构造函数。
- 同样,用户自定义的move赋值运算符不会阻止生成隐式的赋值运算符。
基本上,自动生成规则不会被move语义影响,除了move构造函数的声明,声明任何构造函数都会阻止编译器生成默认构造函数。