0%

C++ 中的右值与移动语义

接了一个华为的 C++ 项目,一方面是整个 C++ 项目练练手,一方面是督促自己把一些高级特性学完,然后水一下好久不更新的博客。目前准备更新:匿名函数、智能指针、右值和移动语义,内存模型放到之后了,我目前的功力还写不了这个。这篇博客涉及右值和移动语义,甚至可以关联到 explicit 和函数进阶,不得不说 C++ 的水太深了。

左值、右值及左值引用

关于左值和左值引用,可以用一个简单的理解来理解,对于赋值语句,左侧的是左值,右侧的是右值,对左值的引用叫左值引用。由于右值没有地址,没法被修改,所以左值引用无法指向右值。

1
2
3
4
5
int a = 5;
// 左值引用指向左值,编译通过
int &ref_a = a;
// 左值引用指向了右值,会编译失败
int &ref_a = 5;

但是 const 左值引用是可以指向右值的,因为不会修改指向值,创建一个临时变量赋给 const 接受参数就好了。这也是为什么要使用 const & 作为函数参数的原因之一,如 std::vectorpush_back(12)。但是这种方式有局限性,就是不能修改参数,右值引用会避免这一点,我们接着往下看。

右值引用

再看下右值引用,右值引用的标志是 &&,顾名思义,右值引用专门为右值而生,可以指向右值,不能指向左值:

1
2
3
4
5
6
int &&ref_a_right = 5; // ok
int a = 5;
// 编译不过,右值引用不可以指向左值
int &&ref_a_left = a;
// 右值引用的用途:可以修改右值
ref_a_right = 6;

这里需要注意的是:被声明出来的左、右值引用都是左值。

std::move

我们可以通过 std::move 使得右值引用指向左值:

1
2
3
4
5
6
7
// a是个左值
int a = 5;
// 左值引用指向左值
int &ref_a_left = a;
// 通过std::move将左值转化为右值,可以被右值引用指向
int &&ref_a_right = std::move(a);
cout << a;

显然作为函数形参时,右值引用更灵活。因为 const 左值引用也可以做到左右值都接受,但它无法修改。那么将左值转换成右值这么费劲干什么呢?那当然是:实现移动语义,避免拷贝,从而提升程序性能。

移动语义

来看一个简单的例子:

1
2
3
4
5
6
7
vector<string> create_data(const vector<string>& v1) {
vector<string> v2;
/* operate */
// 这里只是举例
return v1;
}
vector<string> v = create_data(v1);

如果 v1 是一个 2000000 这么长的数组,每个数组里面的元素长度是 100,那么我们知道 v2 这个临时变量会被删除,也就是进行了大量的复制、拷贝工作后,这个变量以及函数返回时的临时变量消失了,白白带来了很大的开销。

那么避免无效的复制、拷贝不是更好吗?这个时候就出现了移动语义,让编译器知道:何时复制,什么时候不需要。在没有右值引用之前,一个简单的类可以有这些特殊函数:构造函数、拷贝构造函数、赋值运算符重载和析构函数。出现移动语义后,又新增加了移动构造函数和移动赋值运算符重载来实现上面所说的。区别体现在:拷贝构造函数可以进行深浅拷贝,移动构造函数只是调整数据的指向。来看下面的例子:

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
#include <iostream>

class A{
public:
int* arr;
int b;
A() = default;
A(int c) {
arr = new int[c];
b = c;
for (int i = 0; i < b; i++)
arr[i] = i;
}
A(A const& B) {
std::cout << "copy constructor was called" << std::endl;
this->b = B.b;
this->arr = new int[b];
for (int i = 0; i < b; i++) {
this->arr[i] = B.arr[i];
}
}
A(A&& B) {
std::cout << "move constructor was called" << std::endl;
this->b = B.b;
this->arr = B.arr;
// 防止 B.arr 和 A.arr 指向相同的数据
// 防止 B.arr 析构时出错
B.arr = nullptr;
}
};

int main () {
A a = A(10);
A b = a;
// 左值转化成右值,触发移动构造函数
A c = A(std::move(b));
std::cout << (b.arr == nullptr) << std::endl;
return 0;
}

该类的拷贝构造函数已经通过使用左值引用传参来避免一次多余拷贝了,但是内部实现要深拷贝,拷贝无法避免,对于赋值运算符重载函数也是一样的。但是移动构造函数把被拷贝者的数据移动过来,也就是说调整数据的 owner,被拷贝者后边就不要了,这样就可以避免深拷贝了。

因此,在「需要拷贝且被拷贝者之后不再被需要,但是在其他作用域中仍需要使用被拷贝者,那么与其把被拷贝者拷贝到其他作用域并析构本作用域中的这个对象」这样的场景下,建议使用 std::move 触发移动语义,提升性能,就像上面简单例子的函数。宏观上,看起来像是把这个对象从一处移动到另一处了,本质上,就是移动了这个对象的地址。对指针或者引用的拷贝开销很小,可以忽略不计。

另外,如果类中没有拷贝构造函数,而程序有需要它,编译器会提供一个默认的,移动构造函数也是如此。他们的原型如下:

1
2
3
4
5
6
7
class T{
public:
T(const T&); //拷贝构造
T(T&&); //移动构造
T& operator=(const T&); //拷贝赋值
T& operator=(T&&); //移动赋值
}

一般来说,必须在 = 右侧使用 rvalue 来显式调用移动赋值函数,如果 = 右侧是 lvalue,那么调用拷贝赋值函数。总结一下:左值与左值引用匹配,调用拷贝构造函数;右值与右值引用匹配,调用移动构造函数,文末还会有详细的例子。还有诸如 unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权),不能深拷贝。这个放到下篇的智能指针。

forward

与上面的 move 相比,forward 更强大,move 只能转出来右值,forward 都可以。std::forward<T>(u) 有两个参数:Tu。当 T 为左值引用类型时,u 将被转换为 T 类型的左值;否则 u 将被转换为 T 类型右值。

例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void B(int&& ref_r) {
ref_r = 1;
}

// A、B的入参是右值引用
// 有名字的右值引用是左值,因此ref_r是左值
void A(int&& ref_r) {
// 错误,B的入参是右值引用,需要接右值,ref_r是左值,编译失败
B(ref_r);
// ok,std::move把左值转为右值,编译通过
B(std::move(ref_r));
// ok,std::forward的T是int类型,不是引用类型,因此会把ref_r转为右值
B(std::forward<int>(ref_r));
}

int main() {
int a = 5;
A(std::move(a));
}

例2:

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
void change2(int&& ref_r) {
ref_r = 1;
}

void change3(int& ref_l) {
ref_l = 1;
}

// change的入参是右值引用
// 有名字的右值引用是 左值,因此ref_r是左值
void change(int&& ref_r) {
// 错误,change2的入参是右值引用,需要接右值,ref_r是左值,编译失败
change2(ref_r);
// ok,std::move把左值转为右值,编译通过
change2(std::move(ref_r));
// ok,std::forward的T是右值引用类型(int &&),因此u(ref_r)会被转换为右值,编译通过
change2(std::forward<int &&>(ref_r));

// ok,change3的入参是左值引用,需要接左值,ref_r是左值,编译通过
change3(ref_r);
// ok,std::forward的T是左值引用类型(int &),因此u(ref_r)会被转换为左值,编译通过
change3(std::forward<int &>(ref_r));
}

int main() {
int a = 5;
change(std::move(a));
}
感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章