0%

C++ 移动语义和右值引用

本文介绍了C++11中的移动语义和右值引用相关内容,翻译自Move semantics and rvalue references in C++11

C++总是用来编写快速的程序。不幸的是,直到C++11之前,C++程序一直有一个长久存在的降低运行速度的缺陷:临时对象的创建。有些时候编译器会优化掉这些临时对象(例如返回值的优化)。但是总会有其它的情况,这样带来的后果是昂贵的对象复制。什么意思呢?

我们假设有以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

using namespace std;

vector<int> doubleValues (const vector<int>& v)
{
vector<int> new_values;
new_values.reserve(v.size());
for (auto itr = v.begin(), end_itr = v.end(); itr != end_itr; ++itr )
{
new_values.push_back( 2 * *itr );
}
return new_values;
}

int main()
{
vector<int> v;
for ( int i = 0; i < 100; i++ )
{
v.push_back( i );
}
v = doubleValues( v );
}

如果你使用C++完成过很多高性能的工作,那么很抱歉其带来的痛苦。如果没有的话,那么,现在来仔细看看为什么说这是很糟糕的C++03代码(本教程的剩余部分将会讨论为什么它是良好的C++11代码)。问题在于复制。当调用doubleValues时,它会构造一个名为new_valuesvector,并向其中填充数据。单单如此可能并不能达到理想的性能,但是如果我们想保留原有的vector不被修改,我们就需要一份拷贝。然而当我们到达return语句时会发生什么呢?

new_values的全部内容必须被复制一遍!原则上讲,这一过程可能会发生最多两次复制:一次是复制到要返回的临时对象,第二次是运行到v = doubleValues( v );vector的赋值操作。第一次的复制可以被编译器自动优化掉,但是无法避免的是对v的赋值操作会将全部的值再复制一遍,这需要一次新的内存分配以及额外的对整个vector的一轮迭代。

这个示例似乎有点蓄意为之,并且当然你可以想办法避免这种问题,例如存储和返回vector的指针,或者传入一个vector并将其填充。问题是,这两种编程方式都显得很不自然。并且需要返回指针的方法将会引入至少一次内存分配,但C++的一个设计目的就是避免内存分配。

这一示例中最糟糕的部分就在于由doubleValues返回的临时对象再也不会用到。当运行到代码v = doubleValues( v )时,doubleValues( v )的结果仅仅是在它被复制之后就丢弃掉!理论上讲,应该可以跳过整个复制过程并仅仅是窃取临时vector内部的指针并将其存进v。实际上讲就是,为什么我们不能移动(move)这个对象?在C++03中,答案是我们无法确定一个对象是否是临时的,在赋值运算符或拷贝构造函数中你必须运行同样的代码,无论值是从哪来,所以没有窃取的可能。但在C++11中,答案是——可以!

这就是右值引用(rvalue references)和移动语义(move semantics)的目的!移动语义使你在使用即将销毁的临时对象时可以避免不必要的对象复制,并且这些资源可以安全地从临时对象中拿来给其它的对象使用。

移动语义依赖于C++11中名为右值引用的新特性,理解了它之后你才会真正领会后边的内容。所以我们先来讨论什么是右值(rvalue),然后什么是右值引用。最后我们再回到移动语义以及解释它是如何基于右值引用来实现的。

右值和左值——是冤家对头,还是好朋友?

在C++中,有右值和左值。左值是可以获取到地址的表达式,是一个定位值(locator value)。本质上来说,左值提供了(半)永久的内存。你可以对左值进行赋值操作。例如:

1
2
int a;
a = 1; // here, a is an lvalue

也可以有不是变量的左值:

1
2
3
4
5
6
7
int x;
int& getRef ()
{
return x;
}

getRef() = 4;

在这里,getRef返回的是一个全局变量的引用,所以它的返回值是在永久位置存储的(如果你愿意你可以直接写出&getRef()的值,它会给你x的地址)。

右值与左值不同。如果一个表达式的结果是一个临时对象,则它是右值。例如:

1
2
3
4
5
6
int x;
int getVal ()
{
return x;
}
getVal();

这里的 getVal()是一个右值,返回的值并不是x的引用,它仅仅是个临时的值。如果我们用一个对象来取代数字,这一切会变得更有意思:

1
2
3
4
5
string getName ()
{
return "Alex";
}
getName();

这里的getName返回一个在函数内部构造的字符串。你可以把getName的结果赋给一个变量:

1
string name = getName();

但是你是从一个临时对象赋值,而不是一个固定存储的值,getName()是一个右值。

使用右值引用来检测临时对象

重要的一点是右值引用的是临时对象,如doubleValues的返回值。如果我们可以不用犹豫,明确地知道从某个表达式返回的是临时对象,然后以某种形式来写重载的代码,以使其对临时对象有不同的行为,这样会不会很棒?为什么不呢,是的,确实会很棒。这就是右值引用的目的。右值引用是一个绑定给临时对象的引用。什么意思呢?

在C++11之前,如果你有一个临时对象,你可以使用常规(regular)或左值引用来绑定它,但仅限于它是const

1
2
const string& name = getName(); // ok
string& name = getName(); // NOT ok

表面上看,你不能使用可修改的(mutable)引用,因为如果使用的话,你就能够修改一个即将消失的对象,这很危险。顺便注意,持有一个临时对象的常引用(const reference)可以确保该临时对象不会立即被析构。这是C++一个很好的保证机制,但是因为他仍然是一个临时对象,所以你不会修改它。

然而,在C++11中,有一种新的引用类型,叫做右值引用,它允许你对右值绑定一个可变引用,而不是一个左值。换句话说,右值引用可以完美地检测一个值是否是临时对象。右值引用使用&&语法取代了&,并且可以是const也可以不是,就像左值引用一样,虽然你可能很少见到有左值的常引用(就像我们即将看到的一样,可修改的引用有点像此类):

1
2
const string&& name = getName(); // ok
string&& name = getName(); // also ok - praise be!

目前为止一切都很好,但是它会起到什么帮助呢?左值引用和右值引用相比最重要的一点就在你写下一个以左值或右值引用为参数的函数时。假设我们有以下两个函数:

1
2
3
4
5
6
7
8
9
printReference (const String& str)
{
cout << str;
}

printReference (String&& str)
{
cout << str;
}

Now the behavior gets interesting–the printReference function taking a const lvalue reference will accept any argument that it’s given, whether it be an lvalue or an rvalue, and regardless of whether the lvalue or rvalue is mutable or not. However, in the presence of the second overload, printReference taking an rvalue reference, it will be given all values except mutable rvalue-references. In other words, if you write:

现在表现得有意思了,以常左值引用为参数的printReference函数将会接收所提供的任何参数,无论是左值还是右值,也无论左值或右值是否是可修改的。然而,在第二个重载形式中,printReference可以接收的是一个右值引用,它可以接收除了可修改的右值引用(mutable rvalue-references)以外的任何参数。换句话说,如果你这么写:

1
2
3
4
string me( "alex" );
printReference( me ); // calls the first printReference function, taking an lvalue reference

printReference( getName() ); // calls the second printReference function, taking a mutable rvalue reference

现在我们得到了一种判定一个引用变量引用的是临时对象还是永久对象的方法。该方法的右值引用版本像是一个进入只有临时对象才能进入的会所的隐秘的后门(我猜一定是个无聊的会所)。既然我们已经有了判定一个对象是临时还是永久的方法,我们该如何使用它呢?

移动构造函数和移动赋值运算符

你将看到,使用右值引用的最常用的方式是创建移动构造函数(move constructor)和遵循相同原则的移动赋值运算符(move assignment operator)。移动构造函数,与拷贝构造函数相似,使用一个对象实例作为参数,并基于原对象创建一个新的实例。然而,移动构造函数可以避免内存的重分配,因为它避免了临时对象的使用,因此,我们将移动而不是复制对象的各个字段。

移动一个对象的字段的是什么意思?如果字段是基础类型,像int,我们会复制它。当字段是一个指针时就比较有意思了:这里我们不去分配和初始化新的内存,而是可以直接窃取这一指针并将临时对象中的指针置为null!我们知道我们不再需要这个临时对象,所以我们可以从其中取走指针。

想象一下我们有一个简单的ArrayWrapper类,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ArrayWrapper
{
public:
ArrayWrapper (int n)
: _p_vals( new int[ n ] )
, _size( n )
{}
// copy constructor
ArrayWrapper (const ArrayWrapper& other)
: _p_vals( new int[ other._size ] )
, _size( other._size )
{
for ( int i = 0; i < _size; ++i )
{
_p_vals[ i ] = other._p_vals[ i ];
}
}
~ArrayWrapper ()
{
delete [] _p_vals;
}
private:
int *_p_vals;
int _size;
};

注意拷贝构造函数不得不分配内存并且将数组中的值逐个复制。复制的工作量很大。我们添加一个移动构造函数来大大提高效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class ArrayWrapper
{
public:
// default constructor produces a moderately sized array
ArrayWrapper ()
: _p_vals( new int[ 64 ] )
, _size( 64 )
{}

ArrayWrapper (int n)
: _p_vals( new int[ n ] )
, _size( n )
{}

// move constructor
ArrayWrapper (ArrayWrapper&& other)
: _p_vals( other._p_vals )
, _size( other._size )
{
other._p_vals = NULL;
other._size = 0;
}

// copy constructor
ArrayWrapper (const ArrayWrapper& other)
: _p_vals( new int[ other._size ] )
, _size( other._size )
{
for ( int i = 0; i < _size; ++i )
{
_p_vals[ i ] = other._p_vals[ i ];
}
}
~ArrayWrapper ()
{
delete [] _p_vals;
}

private:
int *_p_vals;
int _size;
};

哇哦,移动构造函数确实比拷贝构造函数要简单很多!这绝对是一个壮举。值得注意的要点是:

  1. 参数是一个非常量的右值引用(non-const rvalue reference)
  2. other._p_vals被置为NULL

第二条结论可以解释第一条——如果我们拿到的是一个const的右值引用,我们就不能把other._p_vals设成NULL。但是为什么我们要把other._p_vals设成NULL?原因在于析构函数——当临时对象离开作用域,就像所有其它的C++对象一样,它的析构函数会运行。当它的析构函数运行时,它将会释放_p_vals,同样还有我们刚刚复制的_p_vals!如果我们不把other._p_vals置为NULL,则移动并不是真正的移动——而仅仅是复制,并且会在我们之后使用已释放的内存时引入崩溃。这是移动构造函数的全部要点:通过改变原有的临时对象来避免复制的发生!

再说一遍,重载的规则决定了只有在使用了临时对象且临时对象可以被修改时才会调用移动构造函数。它的一个含义就是如果函数返回了一个常量对象,则会使用拷贝构造函数而不是移动构造函数——所以不要写这样的代码:

1
const ArrayWrapper getArrayWrapper (); // makes the move constructor useless, the temporary is const!

还有一种我们没有讨论过的要在移动构造函数中处理的情形—当有一个字段是对象时。例如,想象一下我们没有size字段而是有一个像这样的metadata字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MetaData
{
public:
MetaData (int size, const std::string& name)
: _name( name )
, _size( size )
{}

// copy constructor
MetaData (const MetaData& other)
: _name( other._name )
, _size( other._size )
{}

// move constructor
MetaData (MetaData&& other)
: _name( other._name )
, _size( other._size )
{}

std::string getName () const { return _name; }
int getSize () const { return _size; }
private:
std::string _name;
int _size;
};

现在我们的数组有了一个name和一个size,所以我们可能得修改ArrayWrapper的定义成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class ArrayWrapper
{
public:
// default constructor produces a moderately sized array
ArrayWrapper ()
: _p_vals( new int[ 64 ] )
, _metadata( 64, "ArrayWrapper" )
{}

ArrayWrapper (int n)
: _p_vals( new int[ n ] )
, _metadata( n, "ArrayWrapper" )
{}

// move constructor
ArrayWrapper (ArrayWrapper&& other)
: _p_vals( other._p_vals )
, _metadata( other._metadata )
{
other._p_vals = NULL;
}

// copy constructor
ArrayWrapper (const ArrayWrapper& other)
: _p_vals( new int[ other._metadata.getSize() ] )
, _metadata( other._metadata )
{
for ( int i = 0; i < _metadata.getSize(); ++i )
{
_p_vals[ i ] = other._p_vals[ i ];
}
}
~ArrayWrapper ()
{
delete [] _p_vals;
}
private:
int *_p_vals;
MetaData _metadata;
};

有效吗?看起来很自然,在ArrayWrapper的移动构造函数中调用MetaData的移动构造函数,难道不是吗?问题是确实不起作用。原因很简单:在移动构造函数中,other的值是一个右值引用。但事实上,右值引用并不是一个右值,它是一个左值,因此调用的是拷贝构造函数而不是移动构造函数。这很奇怪。我了解这让人迷惑。可以用这里的方法来思考。右值是创建了一个即将销毁的对象的表达式。它已到达了生命的最后时刻,或者说它即将完成自己的人生目标。突然,我们把这个临时对象传递给了一个移动构造函数,它在新的作用域内获得了新生。在对右值表达式求值的语境中,临时对象真正地结束了一切。但是在我们的构造函数中,这个对象有了一个名字;它会在我们这个函数的整个期间一直生存。换句话说,我们会在函数中使用变量other不止一次,并且这个临时对象有了一个定义的位置且在整个函数内真正地持久存在。从定位值的角度考虑,他确实是一个左值,我们可以在特定的地址定位到这个对象并且这个地址在整个函数调用期间都是稳定存在的。实际上,我们可以在函数内稍晚的时候继续使用它。如果调用了一个移动构造函数, 无论什么时候我们持有了一个对象的右值引用,我们可以使用这个移动后的对象(moved object),这是一个巧合!

1
2
3
4
5
6
7
8
9
// move constructor
ArrayWrapper (ArrayWrapper&& other)
: _p_vals( other._p_vals )
, _metadata( other._metadata )
{
// if _metadata( other._metadata ) calls the move constructor, using
// other._metadata here would be extremely dangerous!
other._p_vals = NULL;
}

最后总结一下,左值和右值引用都是左值表达式。区别在于左值引用必须是const才能引用一个右值,但是右值引用总是可以对引用一个右值。这就像是一个指针和它指向的内容之间的区别。被指向的内容来自右值,但当我们使用右值引用本身时,会得到一个左值。

std::move

所以该使用什么技巧来应对这种问题呢?我们需要用到<utility>中的std::move。使用std::move意味着你可以说:“OK,我发誓我知道我有的是一个左值,但是我却希望它变成一个右值”。std::move本身不会移动任何东西;他只是把一个左值编程右值,因此你可以调用移动构造函数。我们的代码会是这样:

1
2
3
4
5
6
7
8
9
#include <utility> // for std::move

// move constructor
ArrayWrapper (ArrayWrapper&& other)
: _p_vals( other._p_vals )
, _metadata( std::move( other._metadata ) )
{
other._p_vals = NULL;
}

之后当然我们还要回到MetaData的移动构造函数,对它持有的string也使用std::move

1
2
3
4
MetaData (MetaData&& other)
: _name( std::move( other._name ) ) // oh, blissful efficiency
: _size( other._size )
{}

移动赋值运算符

就像我们有的移动构造函数一样,我们也应该有一个移动赋值运算符。你可以使用与创建移动构造函数同样的技术来很容易地写出来。

移动构造函数和隐式生成的构造函数

如你所知,在C++中,当你声明了任何的构造函数,编译器就不会为你生成默认的构造函数。在这里仍是这样,如果为一个类增加了移动构造函数,那么就需要你声明和定义你的默认构造函数。另一方面,声明了移动构造函数不会避免编译器提供一个隐式生成的拷贝构造函数,以及声明了移动赋值运算符不会禁止标准赋值运算符的创建。

std::move如何运作

你可能会疑惑,如何写出一个像std::move这样的函数?如何才能拥有这种把一个左值转化成右值引用的魔力?可能你已经猜到了,答案就是类型转换(typecasting)。实际的std::move的声明可能涉及到非常多的东西,但是其核心内容就是一个转换为右值引用的static_cast。这表示事实上你可以完全不需要使用move——但是你应该使用,因为这样可以更清楚表明你的意图。也正因如此,需要一次类型转换是一件很好的事情。这表明你不能偶然地将一个左值转化成为一个右值,那样的话可能会很危险,因为可能会有偶然的move发生。你必须显式地使用std::move或一个cast来左值转化为一个右值引用,并且右值引用不会再自己绑定到左值。

使用函数返回一个显式的右值引用

是否会有时候你应该让函数返回一个右值引用?无论如何返回一个右值引用是什么意思?如果对象是右值函数难道不是应该返回对象的值?

我们先来回答第二个问题:返回一个显式的右值引用与返回一个对象的值是不同的。来看看下边的例子:

1
2
3
4
5
6
7
8
9
10
11
12
int x;

int getInt ()
{
return x;
}

int && getRvalueInt ()
{
// notice that it's fine to move a primitive type--remember, std::move is just a cast
return std::move( x );
}

在第一个函数中,很清晰地看到,尽管getInt()是一个右值,还是会对变量x进行复制。我们可以通过写这样一个帮助函数来查看:

1
2
3
4
5
6
7
void printAddress (const int& v) // const ref to allow binding to rvalues
{
cout << reinterpret_cast<const void*>( & v ) << endl;
}

printAddress( getInt() );
printAddress( x );

运行这段程序,你会看到显示两个不同的值。

另一个情况,

1
2
printAddress( getRvalueInt() ); 
printAddress( x );

会显示相同的值,因为我们显式地返回了右值引用。

因此返回右值引用与不返回右值引用是不同的,但是这一不同会在你返回一个之前存在的对象而不是函数中创建的临时对象时(此时编译器可能会为你消除掉一次复制)更加清楚显现。

现在讨论你是否需要这样的做的问题。答案是:很可能不需要。绝大多数情况下,这样做更可能会产生一个悬挂的引用(引用存在,但其引用的临时对象已经被销毁的情况)。这个问题与返回了左值引用但其引用的对象已不存在的危险很相似。右值引用不能很神奇地为你保留一个对象存在。返回一个右值引用很可能在很少的情况下有意义,当你有一个成员函数并且需要在该函数中返回对该类的一个字段调用std::move的结果时——有多大的可能性你会做这些?

移动语义和标准库

回到我们最初的例子——我们使用的是一个vector,并且我们无法控制vector类是否有一个移动构造函数或移动赋值运算符。幸运的是,标准委员会是很聪明的,在标准库中已经加入了移动语义。这表示你现在可以充分利用移动语义的优势,高效地返回vectormapstring以及其他任何的标准库对象。

STL容器中的可移动对象

事实上,标准库更加前进了一步。如果你在自己的对象中通过创建移动赋值运算符和移动构造函数来开启了移动语义,当你向一个容器中存储这些对象时,STL会自动使用std::move,自动利用可移动类的优势来消除低效的复制。

移动语义和右值引用的编译器支持

右值引用被GCC,Intel编译器和MSVC支持。

REFERENCE

http://www.cprogramming.com/c++11/rvalue-references-and-move-semantics-in-c++11.html