(译)C++11中的Move语义和右值引用
郑重声明:本文是笔者网上翻译原文,部分有做添加说明,所有权归原文作者!
地址:http://www.cprogramming.com/c++11/rvalue-references-and-move-semantics-in-c++11.html
C++一直致力于生成快速的程序。不幸的是,直到C++11之前,这里一直有一个降低C++程序速度的顽症:临时变量的创建。有时这些临时变量可以被编译器优化(例如返回值优化),但是这并不总是可行的,通常这会导致高昂的对象复制成本。我说的是怎么回事呢?
让我们一起来看看下面的代码:
1 #include <iostream> 2 #include <vector> 3 using namespace std; 4 5 vector<int> doubleValues (const vector<int>& v) 6 { 7 vector<int> new_values( v.size() ); 8 for (auto itr = new_values.begin(), end_itr = new_values.end(); itr != end_itr; ++itr ) 9 { 10 new_values.push_back( 2 * *itr ); 11 } 12 return new_values; 13 } 14 15 int main() 16 { 17 vector<int> v; 18 for ( int i = 0; i < 2; i++ ) 19 { 20 v.push_back( i ); 21 } 22 v = doubleValues( v ); 23 }
(笔者注:代码中的vector<int> doubleValues (const vector<int>& v)函数是对vector v中的值乘以2,存储到另外一个vector中并返回。如果我们在第22行添加如下代码输出v中的值,会发现v中的值并没有改变,都是0。
for (auto x : v) cout << x << endl;
应该改成这样:
1 #include <iostream> 2 #include <vector> 3 using namespace std; 4 5 vector<int> doubleValues (const vector<int>& v) 6 { 7 vector<int> new_values; 8 for (auto x : v) 9 new_values.push_back(2 * x); 10 return new_values; 11 } 12 13 int main() 14 { 15 vector<int> v; 16 for ( int i = 0; i < 2; i++ ) 17 { 18 v.push_back( i ); 19 } 20 v = doubleValues( v ); 21 }
另外,笔者不建议像原作者这样使用vector,因为push_back会改变原来vector的内存分布和大小,会出现一些无法预料的错误,代码也不健壮。)
如果你已经做了大量高性能优化工作,很抱歉这个顽症给你带来的痛苦。如果你并未做此类优化工作,那好,让我们一起来缕缕为什么这样的代码在C++03是噩梦(接下来的部分是说明为什么C++11在此方面更好)。该问题与复制变量有关,当doubleValues()函数被调用时,它会构造一个临时的vector(即new_values),并填充数据。单独这样做效率并不高,但是若想保持原始vector的纯净,我们就需要另外一份拷贝。想想doubleValues()函数返回发生了什么?
new_values中的所有数据必须被重新复制一遍!理论上,这里可能最多有2次的复制操作:
1、发生在返回的临时变量;
2、发生在v = doubleValues( v );这里。
第一次的复制操作可能会被编译器自动优化掉(即返回值优化),但是第二次在给将临时变量复制给v时是无法避免的,因为这里需要重新分配内存空间,并且需要迭代整个vector。
这里的例子可能有些小题大做。当然,你可以通过其他方法避免这种问题,比如通过指针或者传递一个已经填充的vector。事实这两种编程方法都是合情合理的。此外返回一个指针的方法至少需要一次的内存分配,避免内存分配也是C++设计目标之一。
最糟糕的是,在整个过程中函数doubleValues()返回的值是一个不再需要的临时变量。当执行到v = doubleValues( v )这里时,复制操作一旦完成,v = doubleValues( v )的结果就将被丢弃。理论上是可以避免整个复制过程,仅仅将临时vector的指针保存到v中。实际上,我们为什么不移动对象呢?在C++03中,无论对象是否为临时的,我们都不得不在复制操作符=或复制构造函数中运行相同的代码,不管该值来之哪里,所以这里”偷窃(pilfering)”是不可能的。在C++11这种行为是可以的!
这就是右值和move语义!当你在使用会被丢弃的临时变量时,move语义能为你避免不必要的复制拷贝,并且这些来自临时变量的资源能够被用于其他地方。move语义是C++11新的特性,被称为右值引用,你也想明白这能为程序员们带来怎样的好处。首先我们先来说说什么是右值,然后说说什么是右值引用,最后我们将回到move语义,并看看右值引用是如何实现的。
右值和左值-劲敌还是好友?
在C++中有左值和右值之分。左值就是一个可以获取地址的表达式,即一个内存地址定位器地址-本质上,一个左值能够提供一个半永久的内存。我们可以给左值赋值,例如:
1 int a; 2 a = 1; // here, a is an lvalue
也可以使左值不是变量,如:
1 int x; 2 int& getRef () 3 { 4 return x; 5 } 6 7 getRef() = 4;
这里getRef()返回一个全局变量的引用,所以它的返回值是被存储在内存中的永久位置处。你可以像使用普通的变量一样来使用getRef()。
如果一个表达式返回一个临时变量,则该表达式是右值。例如:
1 int x; 2 int getVal () 3 { 4 return x; 5 } 6 getVal();
这里getVal()是右值,因为返回值x不是全局变量x的引用,仅仅是一个临时变量。如果我们用对象而不是数字,这将有点意思,如:
1 string getName () 2 { 3 return "Alex"; 4 } 5 getName();
getName()返回一个在函数内部构造的string对象,你可以将其赋值给变量:
string name = getName();
此时你正在使用临时变量,getName()是右值。
检测右值引用的临时对象
1 const string& name = getName(); // ok 2 string& name = getName(); // NOT ok
显而易见这里不能使用一个“可变(mutable)”引用,因为如果这么做了,你将可以修改即将销毁的对象,这是相当危险的。顺便提醒一下,将临时对象保存在const引用中可以确保该临时对象不会被立刻销毁。这一个好的C++编程习惯,但是它仍然是一个临时对象,不能够被修改。
然而在C++11中,引进了一种新的引用,即“右值引用”,允许绑定一个可变引用到一个右值,不是左值。换句话说,右值引用专注于检测一个值是否为临时对象。右值使用&&语法而不是&,可以是const和非const的,就像左值引用一样,尽管你很少看到const左值引用。
1 const string&& name = getName(); // ok 2 string&& name = getName(); // also ok - praise be!
到目前为止一切都运行良好,但这是如何实现的?左值引用和右值引用最重要的区别,是用着函数参数的左值和右值。看看如下两个函数:
1 printReference (const String& str) 2 { 3 cout << str; 4 } 5 6 printReference (String&& str) 7 { 8 cout << str; 9 }
这里函数printReference()的行为就有意思了:printReference (const String& str)接受任何参数,左值和右值都可以,不管左值或右值是否为可变。printReference (String&& str)接受除可变右值引用的任何参数。换句话说,如下写:
1 string me( "alex" ); 2 printReference( me ); // calls the first printReference function, taking an lvalue reference 3 printReference( getName() ); // calls the second printReference function, taking a mutable rvalue reference
现在我们应该有一种方法来确定是否对临时对象或非临时对象使用引用。右值引用版本的方法就像进入俱乐部(无聊的俱乐部,我猜的)的秘密后门,如果是临时对象,则只能进。既然我们有方法确定一个对象是否为临时对象,哪我们该如何使用呢?
move构造函数和move赋值操作符
当你使用右值引用时,最常见的模式是创建move构造函数和move赋值操作符(遵循相同的原则)。move构造函数,跟拷贝构造函数一样,以一个实例对象作为参数创建一个新的基于原始实例对象的实例。然后move构造函数可以避免内存分配,因为我们知道它已经提供了一个临时对象,而不是复制整个对象,只是“移动”而已。假如我们有一个简单的ArrayWrapper类,如下:
1 class ArrayWrapper 2 { 3 public: 4 ArrayWrapper (int n) 5 : _p_vals( new int[ n ] ) 6 , _size( n ) 7 {} 8 // copy constructor 9 ArrayWrapper (const ArrayWrapper& other) 10 : _p_vals( new int[ other._size ] ) 11 , _size( other._size ) 12 { 13 for ( int i = 0; i < _size; ++i ) 14 { 15 _p_vals[ i ] = other._p_vals[ i ]; 16 } 17 } 18 ~ArrayWrapper () 19 { 20 delete [] _p_vals; 21 } 22 private: 23 int *_p_vals; 24 int _size; 25 };
注意,这里的复制拷贝构造函数每次都会分配内存和复制数组中的每个元素。对于复制操作是如此庞大的工作量,让我们来添加move拷贝构造函数,获得高效的性能。
1 class ArrayWrapper 2 { 3 public: 4 // default constructor produces a moderately sized array 5 ArrayWrapper () 6 : _p_vals( new int[ 64 ] ) 7 , _size( 64 ) 8 {} 9 10 ArrayWrapper (int n) 11 : _p_vals( new int[ n ] ) 12 , _size( n ) 13 {} 14 15 // move constructor 16 ArrayWrapper (ArrayWrapper&& other) 17 : _p_vals( other._p_vals ) 18 , _size( other._size ) 19 { 20 other._p_vals = NULL; 21 } 22 23 // copy constructor 24 ArrayWrapper (const ArrayWrapper& other) 25 : _p_vals( new int[ other._size ] ) 26 , _size( other._size ) 27 { 28 for ( int i = 0; i < _size; ++i ) 29 { 30 _p_vals[ i ] = other._p_vals[ i ]; 31 } 32 } 33 ~ArrayWrapper () 34 { 35 delete [] _p_vals; 36 } 37 38 private: 39 int *_p_vals; 40 int _size; 41 };
实际上move构造函数比copy构造函数更简单,这是相当不错的。主要注意以下两点:
1、参数是非const的右值引用
2、other._p_vals应置为NULL
以上的第2点是对第1点的解释,即如果我们使用const右值引用,则不能将other._p_vals置为NULL。但为什么要将other._p_vals置为NULL呢?原因在于析构函数,当临时对象离开其作用域,就像所有其他C++对象一样,它们的析构函数都会被调用。当析构函数被调用后, _p_vals将被释放。这里我们只是复制了_p_vals,如果我们不将_p_vals置为NULL,move就不是真正的“移动”,而是复制,一旦我们使用已释放的内存就会引发运行奔溃。move构造函数的意义在于,通过改变原始的临时对象来避免复制操作。
再次重复,重载move构造函数是为了仅当为临时对象时move构造函数才会被调用,只有临时对象才能被修改。这意味着,如果函数的返回值是const对象,将调用copy构造函数,而不是move构造函数,所以不要像这样写:
1 const ArrayWrapper getArrayWrapper (); // makes the move constructor useless, the temporary is const!
有些情况如何在move构造函数中我们还没有讨论,如类中某个字段也是对象。观察如下这个类:
1 class MetaData 2 { 3 public: 4 MetaData (int size, const std::string& name) 5 : _name( name ) 6 , _size( size ) 7 {} 8 9 // copy constructor 10 MetaData (const MetaData& other) 11 : _name( other._name ) 12 , _size( other._size ) 13 {} 14 15 // move constructor 16 MetaData (MetaData&& other) 17 : _name( other._name ) 18 , _size( other._size ) 19 {} 20 21 std::string getName () const { return _name; } 22 int getSize () const { return _size; } 23 private: 24 std::string _name; 25 int _size; 26 };
我们的数组有字段name和size,因此我们应该改变ArrayWrapper的定义,如下:
1 class ArrayWrapper 2 { 3 public: 4 // default constructor produces a moderately sized array 5 ArrayWrapper () 6 : _p_vals( new int[ 64 ] ) 7 , _metadata( 64, "ArrayWrapper" ) 8 {} 9 10 ArrayWrapper (int n) 11 : _p_vals( new int[ n ] ) 12 , _metadata( n, "ArrayWrapper" ) 13 {} 14 15 // move constructor 16 ArrayWrapper (ArrayWrapper&& other) 17 : _p_vals( other._p_vals ) 18 , _metadata( other._metadata ) 19 { 20 other._p_vals = NULL; 21 } 22 23 // copy constructor 24 ArrayWrapper (const ArrayWrapper& other) 25 : _p_vals( new int[ other._metadata.getSize() ] ) 26 , _metadata( other._metadata ) 27 { 28 for ( int i = 0; i < _metadata.getSize(); ++i ) 29 { 30 _p_vals[ i ] = other._p_vals[ i ]; 31 } 32 } 33 ~ArrayWrapper () 34 { 35 delete [] _p_vals; 36 } 37 private: 38 int *_p_vals; 39 MetaData _metadata; 40 };
这样就可以了?仅仅在ArrayWrapper中调用MetaData的move构造函数就可以了,一切都很自然,不是么?问题在于这样做是不行的!原因很简单:move构造函数中的other是右值引用。这里应该是右值,而不是右值引用!如果是左值,则调用copy构造函数,而不是move构造函数。有些奇怪,有点绕,对吧-我知道。这里有种方法可以区分:右值就是一个创建稍后会被销毁的表达式。临时对象即将被销毁时,我们将其传入move构造函数中,就相当于给了它第二次生命,在新的作用域仍然有效。文中右值出现的地方,都是这么做的。在我们的构造函数里,对象有一个name字段,它在函数内部一直有效。换句话说,我们可以在函数中使用它多次,函数内部定义的临时变量在该函数内部一直有效。左值是可以被定位的,我们可以在内存某个位置访问一个左值。实际上,在函数中我们可能想稍后再使用它。如果move构造被调用,这时我们就有一个右值引用对象,就可以使用“移动的”对象了。
1 // move constructor 2 ArrayWrapper (ArrayWrapper&& other) 3 : _p_vals( other._p_vals ) 4 , _metadata( other._metadata ) 5 { 6 // if _metadata( other._metadata ) calls the move constructor, using 7 // other._metadata here would be extremely dangerous! 8 other._p_vals = NULL; 9 }
最后一种情况:左值和右值引用都是左值表达式。不用之处在于,左值引用必须是const绑定到右值,然而右值引用总是可以绑定一个引用到右值上。类似于指针和指针所指向的内容的区别。使用的值来至于右值,但是当我们使用右值本身时,它又成为左值。
std::move
那么有什么技巧可以处理这样的情况?我们可以使用std::move,包含在<utility>中。如果你想将左值转换为右值,可以使用std::move,这里std::move本身并不移动任何东西,它只是将左值转换成右值而已,也可以调用move构造函数来实现。请看如下代码:
1 #include <utility> // for std::move 2 // move constructor 3 ArrayWrapper (ArrayWrapper&& other) 4 : _p_vals( other._p_vals ) 5 , _metadata( std::move( other._metadata ) ) 6 { 7 other._p_vals = NULL; 8 }
同样的,也应该修改MetaData:
1 MetaData (MetaData&& other) 2 : _name( std::move( other._name ) ) // oh, blissful efficiency 3 : _size( other._size ) 4 {}
赋值操作符
如同move构造函数一样,我们也应该有一个move赋值操作符,编写方式跟move构造函数一样。
Move构造函数和隐式构造函数
正如你所知道的,在C++中只要你手动声明了构造函数,编译器就不会再为你产生默认的构造函数了。这里也是如此:为类添加move构造函数要求你定义和声明一个默认构造函数。另外,声明move构造函数并不会阻止编译器为你产生隐式的copy构造函数,声明move赋值操作符也不会阻止编译器创建标准的赋值操作符。
std::move是如何工作的
你或许会疑惑:如何编写一个类似与std::move这样的函数?右值引用转换为左值引用是如何实现的?可能你已经猜到答案了,就是typecasting。std::move的实际声明比较复杂,但其核心思想就是static_cast到右值引用。这就意味着,实际上你并不真的需要使用move——但你应该这样做,这样能够更清楚表达你的意思。实际上转换是必要,是件好事,这样可以防止你意外地将左值转换为右值,因为那样将导致意外的move发生,是相当危险的。你必须显示地使用std::move(或者一个转换)将左值转换为右值引用,右值引用不会绑定它自己的左值上。
函数返回显式的右值引用
什么时候时候你应该写一个返回一个右值引用的函数?函数返回右值引用意味着什么呢?通过值返回对象的函数是不是就已经是右值了?
我们先回答第二个问题:返回显式的右值引用与通过值(by value)返回对象是不同的。让我们看看下面的例子:
1 int x; 2 3 int getInt () 4 { 5 return x; 6 } 7 8 int && getRvalueInt () 9 { 10 // notice that it's fine to move a primitive type--remember, std::move is just a cast 11 return std::move( x ); 12 }
明显在第一种情况里,尽管事实上getInt()是右值,但这里仍然对x执行了copy操作。我们可以写个辅助函数,看看:
1 void printAddress (const int& v) // const ref to allow binding to rvalues 2 { 3 cout << reinterpret_cast<const void*>( & v ) << endl; 4 } 5 6 printAddress( getInt() ); 7 printAddress( x );
运行发现,二者打印的x地址明显不同。另一方面:
1 printAddress( getRvalueInt() ); 2 printAddress( x );
打印的x地址是相同的,这是因为getRvalueInt()显式的返回了一个右值。
所以返回右值引用与不返回右值引用明显是不同的。如果你返回已经存在的对象,而不是在函数内部创建的临时对象(编译器可能会为你做返回值优化,避免copy操作)时,这种不同表现得最为明显。
现在的问题是,你是否需要这么做。答案是:很可能不会。大多数情况下,你最有可能得到一个悬空的(dangling)右值(一种情况是:引用存在,但它引用的临时对象已经销毁了)。这种情况的危险程度类似于被引用的对象已经不存在的左值。右值引用并不总是可以保证对象有效。返回右值引用主要使这种特殊情况有意义:你有一个成员函数,该函数通过std::move返回类中的字段。
Move语义和标准库
回到最开始的例子中,我们正在使用vector,但未控制类vector,也不知道vector是否move构造函数或move赋值操作符。幸运地是标准委员会已经将move语义添加到了标准库中,这意味着你可以高效地返回vectors, maps, strings,以及你想要返回的任何标准库对象,充分利用move语义吧。
STL容器中可移动的对象
事实上,标准库做得更加地好了。如果你在你的对象中通过创建move构造函数和move赋值操作符来使用move语义,当你将这些对象存储在STL容器中,STL将自动使用std::move,充分利用move语义为你避免效率底下的copy操作。