0%

右值引用与move

事情起源于做CS144 lab时,为了提升buffer的读写效率,将基于内存拷贝的方式改为基于内存所有权转移的方式大大提高了TCP的吞吐量,但自己一直对这些概念模棱两可,希望此文能做一些梳理。

左值和右值

顾名思义,左值指的是位于赋值号左侧,可以用&取地址,表达式结束后依然存在的持久对象,在内存中占有确定位置的对象;右值位于赋值号右侧,无法取地址,表达式结束后不再存在的临时对象,不在内存中有确定位置。

1
2
3
4
5
6
7
8
9
10
int a = 5;  // a是左值,5是右值

struct A {
A(int a=5) {
a_ = a;
}
int a_;
};

A a = A();

如果你试图&A(),编译器会报错error: taking address of temporary。因为在该行直接用类生成的是一个临时对象,其生命周期在该行创建,在该行销毁,拿到该对象的地址没有意义。

左值引用和右值引用

引用是变量的别名,必须与某个变量绑定,传参时传递引用可以避免拷贝。

普通的左值引用只能指向左值,不能指向右值:

1
2
3
int a = 5;
int& ref_a = a; // a是左值
int& ref_aa = 5; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'
引用可以直接修改原对象,右值无地址也无法被修改,故左值引用无法指向右值。

1
const int& ref_aa = 5;

const左值引用不会修改指向的右值,故可以指向右值。因此经常见到使用const引用作为函数参数,如push_back(const int& val),否则就无法push_back(5)

普通的右值引用只能指向右值,不能指向左值:

1
2
3
4
5
int a = 5;
int&& ref_aa = a; // error: cannot bind rvalue reference of type 'int&&' to lvalue of type 'int'

int&& ref_a = 5; // 5是右值
ref_a = 6;

如果希望右值引用指向左值,需要通过move将左值转换为右值:

1
2
3
int a = 5;
int&& ref_aa = move(a);
cout << a; // 仍然打印5

好像与我们想象的move不太一样,本以为move可以将a中的值移动到ref_aa中,但是a的值却没有变化。所以可以看到move只是完成了强制类型转换static_cast<T&&>(lvalue),使得右值引用可以指向左值,貌似与性能没什么关系。

实质上,右值引用之所以能指向右值,其实也是将右值提升为了左值,再用move将左值强制转为右值:

1
2
3
4
int&& ref_a = 5;  // 等价于下面代码

int tmp = 5;
int&& ref_a = move(tmp);

直接声明出来的左值引用和右值引用都是左值,但是右值引用也可以是右值,如果有名字就是左值,否则是右值(比如函数返回值)。

综上:右值引用既可以指向右值,也可以通过move指向左值,比较灵活;普通的左值引用只能指向左值,虽然const左值引用也可以指向右值,但是无法修改指向的变量,相比于右值引用也比较局限。

1
2
3
4
5
void f1(const int& n) {++n;}  // 编译失败,const左值引用不能修改指向的变量
void f2(int&& n) {++n;}

f1(5);
f2(5);

使用场景

那么认知中的move可以提升性能又是咋回事呢?

如果没有右值引用,通常一个类如下:

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
class Array {
public:
// 构造函数
Array(int size) : size_(size) {
data_ = new int[size_];
}

// 拷贝构造函数 深拷贝
Array(const Array& tmp) {
size_ = tmp.size_;
data_ = new int[size_];
for (int i = 0; i < size_; ++i) {
data_[i] = tmp.data_[i];
}
}

// 赋值运算符重载 深拷贝
Array& operator=(const Array& tmp) {
delete[] data_;

size_ = tmp.size_;
data_ = new int[size_];
for (int i = 0; i < size_; ++i) {
data_[i] = tmp.data_[i];
}
}

// 析构函数
~Array() {
delete[] data_;
}

int* data_;
int size_;
};

传参时已经通过左值引用避免了一次拷贝,但是深拷贝仍然还需要一次拷贝。此时移动构造函数就出来了:将源数据移动到新指针,丢弃源指针。为了和拷贝构造函数区分开,需要多一个参数move

1
2
3
4
5
Array(const Array& tmp, bool move) {
size_ = tmp.size_;
data_ = tmp.data_;
tmp.data_ = nullptr; // 防止tmp析构时删除data_
}
但是const左值引用无法修改tmp.data_,如果改为非const,那么可以去掉参数move(只有引用传递和指针传递可以用是否加const重载,故可以和拷贝构造区分),但是Array a = Array(rvalue)就无法使用,因为左值引用无法指向右值,即无法用右值的Array来移动构造。

所以右值引用就派上用场了:

1
2
3
4
5
Array(Array&& tmp) {
size_ = tmp.size_;
data_ = tmp.data_;
tmp.data_ = nullptr;
}
即无需参数move,也可以使用右值来构造,也可以将左值用std::move转为右值再去构造。STL中的vector等均实现了以右值引用传参的移动构造函数和移动赋值重载函数,传递左值则拷贝,传递右值则移动。

例如:

1
2
3
void push_back(const int& val);
void push_back(int&& val);
void emplace_back(Args&& args);
如果需要拷贝对象,但是拷贝后源不再需要,可以使用std::move触发移动语义,避免不必要的内存拷贝,将对象的所有权从一个对象转移到另一个对象,优化性能。

完美转发

std::forward<T>(u):当T为左值引用时,u被转换为T类型的左值;否则u被转换为T类型右值。

Reference

一文读懂C++右值引用和std::move