事情起源于做CS144 lab时,为了提升buffer的读写效率,将基于内存拷贝的方式改为基于内存所有权转移的方式大大提高了TCP的吞吐量,但自己一直对这些概念模棱两可,希望此文能做一些梳理。
左值和右值
顾名思义,左值指的是位于赋值号左侧,可以用&
取地址,表达式结束后依然存在的持久对象,在内存中占有确定位置的对象;右值位于赋值号右侧,无法取地址,表达式结束后不再存在的临时对象,不在内存中有确定位置。
1 | int a = 5; // a是左值,5是右值 |
如果你试图&A()
,编译器会报错error: taking address of temporary
。因为在该行直接用类生成的是一个临时对象,其生命周期在该行创建,在该行销毁,拿到该对象的地址没有意义。
左值引用和右值引用
引用是变量的别名,必须与某个变量绑定,传参时传递引用可以避免拷贝。
普通的左值引用只能指向左值,不能指向右值: 1
2
3int 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
5int 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
3int 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
4int&& ref_a = 5; // 等价于下面代码
int tmp = 5;
int&& ref_a = move(tmp);
直接声明出来的左值引用和右值引用都是左值,但是右值引用也可以是右值,如果有名字就是左值,否则是右值(比如函数返回值)。
综上:右值引用既可以指向右值,也可以通过move
指向左值,比较灵活;普通的左值引用只能指向左值,虽然const左值引用也可以指向右值,但是无法修改指向的变量,相比于右值引用也比较局限。
1 | void f1(const int& n) {++n;} // 编译失败,const左值引用不能修改指向的变量 |
使用场景
那么认知中的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
35class 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
5Array(const Array& tmp, bool move) {
size_ = tmp.size_;
data_ = tmp.data_;
tmp.data_ = nullptr; // 防止tmp析构时删除data_
}tmp.data_
,如果改为非const,那么可以去掉参数move
(只有引用传递和指针传递可以用是否加const重载,故可以和拷贝构造区分),但是Array a = Array(rvalue)
就无法使用,因为左值引用无法指向右值,即无法用右值的Array来移动构造。
所以右值引用就派上用场了: 1
2
3
4
5Array(Array&& tmp) {
size_ = tmp.size_;
data_ = tmp.data_;
tmp.data_ = nullptr;
}move
,也可以使用右值来构造,也可以将左值用std::move
转为右值再去构造。STL中的vector等均实现了以右值引用传参的移动构造函数和移动赋值重载函数,传递左值则拷贝,传递右值则移动。
例如: 1
2
3void 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类型右值。