右值引用与移动语义


左值引用 vs. 右值引用

表达式表现的形式可以分为左值和右值,一般区分方法是左值表达式可以取地址,而右值不可以。

但这不是说右值表达式就不存在于内存中,它们只不过以临时变量的形式短暂存在着,编译器处理了它们,对于一般程序员是透明的。

比如:

1
int a = 1 + 2;

这儿a显然表现为左值,而1 + 2则是右值,编译器可能做的处理大概像这样:

1
2
int temp = 3;
int a = temp;

左值引用就是常见的引用,它表示左值表达式的别名。而既然右值表达式是存在于内存的,那么所谓的右值引用就是右值表达式编译产生的临时变量的别名。比如:

1
2
int &&rval_ref = 1 + 2;
int &lval_ref = rval_ref;

rval_ref就是右值引用,引用编译器为1 + 2创建的临时变量;而另一方面表达式rval_ref可以取地址,所以它是左值,于是可以看到lval_ref左值引用引用了这个变量。这两个变量的内存地址都是相同的。


拷贝语义 vs. 移动语义

其实关于“移动语义”个人认为这就是个字面意思。

所谓“语义”——

数据的含义就是语义(semantic)。简单的说,数据就是符号。数据本身没有任何意义,只有被赋予含义的数据才能够被使用,这时候数据就转化为了信息,而数据的含义就是语义。语义可以简单地看作是数据所对应的现实世界中的事物所代表的概念的含义,以及这些含义之间的关系,是数据在某个领域上的解释和逻辑表示。 —— from 百度百科

所谓的拷贝语义差不多就是将A的内容复制到B,A不变,而B的内容与A一模一样。

所谓的移动语义差不多就是将A的内容移动到B,B的内容变成A,但A的内容没有了。

根据各种常识可以知道,移动一般比拷贝更快。下面将看到什么时候需要用到拷贝,什么时候又需要用到移动,以及它们这两个语义的实现。


拷贝构造函数 vs. 移动构造函数

对象初始化时会调用其构造函数,只有一个参数的构造函数也被称为转换构造函数,可以用来实现各式各样的初始化,比如:

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
class Student {
public:
/* 拷贝构造函数 */
Student(const Student &stu) {
name = new char[strlen(stu.name) + 1];
strcpy(name, stu.name);
}
Student(char *name) {
this->name = new char[strlen(name) + 1];
strcpy(this->name, name);
}
/* 析构函数 */
~Student() {
if (name != nullptr) {
delete[] name;
}
}
char *get_name() {
return name;
}
private:
char *name;
};

上面的类有两个构造函数,利用这两个转换构造函数可以这么初始化一个对象:

1
2
Student A = "Xiaoming"; // 调用Student(char *name);
Student B = A; // 调用Student(const Student &stu);

其中Student(const Student &)函数又被称为拷贝构造函数,它实现了拷贝的语义,在上面代码段里它把A的内容复制给了B

而实现移动语义的构造函数呢?首先先知道什么情况下会用到“移动”。

除了上面那种显式的初始化,还有其他会调用到拷贝构造函数的地方,比如函数调用:

1
2
3
4
5
6
7
Student to_uppercase(Student A) {
char &initch = A.get_name()[0];
if ('a' <= initch && initch <= 'z') {
initch = initch - 'a' + 'A';
}
return A;
}

这个函数是将Student对象的名字首字母改为大写。

调用时由于是值传递,实参拷贝,这时就会调用到拷贝构造函数去初始化副本;而函数返回时,副本生命周期必然要结束,此时返回的不是副本本身,而是副本的副本,这时又会调用拷贝构造函数去初始化新副本。

于是总共调用两次。第一次值传递,拷贝是有意义的,这样就能对副本进行首字母大写的修改而不会影响到实参;而第二次,似乎有点浪费,因为副本又拷贝了一份,随之副本生命周期就结束了,更理想的方案便是“移动”——副本直接移动到新副本上,反正副本都要挂了。

如何移动?事实上对于对象里一般类型的移动与拷贝本质实现是一样的,二者的区别体现在指针类型上。

拷贝分为深拷贝和浅拷贝,在上面例子里是深拷贝;而移动事实上类似浅拷贝,就是交换二者指针即可。于是实现类似如此:

1
2
return_A.name = A.name;
A.name = nullptr;

新副本return_A窃取副本A的指针,然后副本A的指针被设为空,副本A随之结束生命周期销毁时就不会影响到其原本指针指向的空间,这个空间已被新副本接管。

不过新副本对于程序员是透明的,这怎么能直接对它进行操作呢——这时就是移动构造函数登场的时候了。

首先要知道一点,标准规定函数返回值一律表现为右值,也就是执行return AA是一个右值。所以,可以利用构造函数重载来实现:

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
class Student {
public:
/* 移动构造函数 */
Student(Student &&stu) {
this->name = stu.name;
stu.name = nullptr;
}
/* 拷贝构造函数 */
Student(const Student &stu) {
name = new char[strlen(stu.name) + 1];
strcpy(name, stu.name);
}
Student(char *name) {
this->name = new char[strlen(name) + 1];
strcpy(this->name, name);
}
/* 析构函数 */
~Student() {
if (name != nullptr) {
delete[] name;
}
}
char *get_name() {
return name;
}
private:
char *name;
};

可以看到上面代码新定义了一个构造函数Student(Student &&),其参数是一个右值引用,它就是传说中的移动构造函数。

执行到return A时,要拷贝副本A,由于A是右值,新副本就会调用这个移动构造函数进行初始化。相比之前调用拷贝构造函数,显然效率更高了,因为只是窃取指针而没有重新开辟内存空间进行复制。

由于右值一般都是临时变量,生命周期短,所以定义移动构造函数不会造成什么意外后果,反而重复利用了临时创建的堆空间,提升效率。感觉这是非常精巧的设计。

不过,可以通过调用std::move()函数将一个左值转化为右值,这时便可以显式将一个左值传递给移动构造函数,这时就要注意了,被移动的左值后面再使用时其指针已经变为空指针。

此外除了初始化,还有重载赋值运算符实现拷贝赋值函数和移动赋值函数,同理就不多说了。