在这里总结一下我对C++ 11 中引入的右值引用和移动语义的一些理解。

左值 & 右值

首先还是这个老问题,什么是左值,什么是右值?
最原始的区分就是左值是可以出现在赋值符的左边和右边,然而右值只能出现在赋值符的右边。但是在C++中使用前面的方式来区别左值和右值就不合适了,那么在C++中要怎么区别呢?我的理解是:

  1. 左值是一个表示数据的表达式(如变量名或者解除引用的指针),程序可以获取它的地址
  2. 右值是可以出现在表达式的右边的,但不能对其应用取址运算符的值

StackOverflow上面看到一个回答 里面使用了个例子:

1
2
3
std::string a(x); // Line 1
std::string b(x + y); // Line 2
std::string c(some_function_returning_a_string()); // Line 3

上面的这些都是进行字符串的复制。但是只有第一个是真正需要深复制的,因为我们在复制之后还是需要使用变量x(我们会使用x进行运算或者进行取址等),所以如果我们在进行复制到a的时候将x的值进行了改变,这显然不是我们想要的!
第二行和第三行的函数中的参数则不是左值,这些参数并没有名字,我们也不可能在后面的程序中在对这个值进行进一步的操作(连名字都没了让我怎么找)。

因此右值是一种临时的数据对象,这个临时数据对象没有绑定到任何对象/变量,在遇到下一个分号的时候已经销毁掉了!

C++11中对左值和右值的界定中添加了一个新的类型叫xvalue(eXpiring value, 临终值)
具体的关系图如下:

1
2
3
4
5
6
7
8
9
expressions
/ \
/ \
/ \
glvalues rvalues
/ \ / \
/ \ / \
/ \ / \
lvalues xvalues prvalues

lvalue(左值)

代指一个函数或者对象,例如:

  1. E是指针,则*E是lvalue
  2. 一个函数的返回值是左值引用,其返回值是lvalue。例如int& foo();

xvalue(eXpiring value, 临终值)

Xvalues are a new kind of value category for unnamed rvalue references.

xvalue代指一个对象,但是和左值不同,这个对象即将消亡。具体来说,xvalue是包含了右值引用的表达式。例如,一个返回值是右值引用的函数。

1
2
3
int prvalue(); // 纯右值
int & lvalue(); // 左值
int && xvalue(); // 临终值


我对xvalue的理解,就是它是一个中间值,明明一开始一个右值,也就是个临时变量,但是我们将它返回成一个右值引用,右值引用能够使其与特定的地址关联,也就是分配给了一个对象,感觉忽然变成了左值一样,但是如果我们没有将其赋值,这个数据还是没有名字,也会随之消失,还是一个右值
感觉像是纯右值和左值的叠加态一样。(不知道自己理解的对不对)

glvalue(generalized lvalue, 泛左值)

lvalue 和 xvalue 的统称。

rvalue

xvalue和prvalue的统称。因为引入了右值引用,rvalue的定义在C++中被扩大化了。

prvalue (pure rvalue, 纯右值)

prvalue指代一个临时对象、一个临时对象的子对象或者一个没有分配给任何对象的值。prvalue即老标准中的rvalue。例如:

  1. 一个函数的返回值是平常类型,其返回值是rvalue。例如int foo();
  2. 没有分配给任何对象的值。如5.3,true。

右值引用

了解了上面,对于右值引用也就很好理解了。

1
2
3
4
5
int x = 10;
int y = 13;
int && r1 = 13;
int && r2 = x + y;
double && r3 = std::sqrt(2.0);

第3、4、5行等号右边都是右值,他们都是存储在临时位置的数据,正常的话赋值给一个变量会将这个临时变量的数据复制到要赋值的变量中然后将临时变量销毁。但是有了右值引用我们就省掉了中间的步骤,将右值关联到右值引用会导致该右值被存储到了特定的位置,而且可以获取该位置的地址。,也就是说,虽然我们不能将取址运算符&用于纯右值13,但是我们可以将其用于r1,有了右值引用我们就好像将一个马上要消失的变量强制留了下来,然后可以让我们肆意的蹂躏:P

移动语义

移动构造函数

移动语义还是主要应用在构造函数重载赋值运算符中。
在复制构造函数中我们要对有动态内存分配的对象进行深复制,这里我直接用stackoverflow上面的那个string的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <cstring>
#include <algorithm>
class string
{
private:
char* data;
public:
string(const char* p)
{
size_t size = strlen(p) + 1;
data = new char[size];
memcpy(data, p, size);
}
}

下面是string对象的析构函数和复制构造函数:

1
2
3
4
5
6
7
8
9
10
11
~string()
{
delete[] data;
}
string(const string& that)
{
size_t size = strlen(that.data) + 1;
data = new char[size];
memcpy(data, that.data, size);
}

如果我们使用下面的方式初始化一个string:

1
string c(a + b); // a, b都是string对象

这个过程中

  1. 首先a + b会先调用string重载过的+操作运算符函数,生成一个临时string对象temp
  2. 然后调用string对象的复制构造函数进行深赋值,将temp的数据复制到c中
  3. 最后再将temp进行销毁。

有些迟钝的编译器甚至无法直接将生成temp直接复制,而是

  1. 将temp先复制给一个新的临时返回对象temp2
  2. 然后销毁temp
  3. 在将temp2深复制到c
  4. 在销毁temp2
    这样就进行了两次复制和两次销毁,如果对象在堆上的数据很多的话,这将造成大量的资源浪费。

所以这个时候,因为我们在初始化c之后也并不像才操作a + b的数据,所以如果能够直接将生成的temp的数据转交给c,这样省去了复制和删除的开销,将会省了很多的力。那这就是移动语义要做的事情。
这个时候我们就可以利用右值引用,写一个新的移动构造函数:

1
2
3
4
5
string(string&& that) // string&& is an rvalue reference to a string
{
data = that.data;
that.data = nullptr;
}

由于a + b是一个右值,因此能够与上面的函数进行匹配,c就使用移动构造函数进行初始化:

  1. 通过右值引用,将a + b的值绑定到that对象上。
  2. that的数据转移到c中,通过将c指向that的数据。
  3. 通过将that的指针设成NULL来将that“销毁”。

之所以要将that.data设为nullptr是因为如果that.datac.data指向同样的数据,在调用析构函数的时候就会带来麻烦,因为程序不能对同一个地址调用delete []两次,但是对空指针执行delete []却没有问题。
由于在移动构造函数中我们改变了初始的temp对象的内容,所以参数中的右值引用不能使用const关键字。

  • 有些优化比较好的编译器甚至不用调用移动构造函数,而是将生成的临时对象直接转到c的名下。

强制移动

如果我们想对一个左值使用移动构造函数和移动复制函数,我们可以使用强制类型转换运算符static_cast<string &&>,但是C++11提供了一个std::move(),来将一个左值当成一个右值来处理。

总结

  • 复制构造函数会进行深赋值,因为我们不想让原始的变量发生改变,我们想要的是一个副本。
  • 移动构造函数,则移动指针,并将原始对象中的指针设为空,因为我们不不想保留这个原始的数据,以为他只是个临时的值而已。

Comments