左值、右值、左值引用、右值引用和std::move
1. 什么是左值、右值
左值: 可以取地址、位于等号左边 – 表达式结束后依然存在的持久对象(代表一个在内存中占有确定位置的对象)
右值: 没法取地址、位于等号右边 – 表达式结束时不再存在的临时对象(不在内存中占有确定位置的表达式)
int val;
val = 4; // 正确 ①
4 = val; // 错误 ②
上述例子中,由于在之前已经对变量val进行了定义,故在栈上会给val分配内存地址,运算符=要求等号左边是可修改的左值,4是临时参与运算的值,一般在寄存器上暂存,运算结束后在寄存器上移除该值,故①是对的,②是错的
2. 什么是左值引用、右值引用
引用本质是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝,其实现原理和指针类似。
左值引用:指向左值的引用,称为左值引用
int a = 5;
int &ref_a = a; // 左值引用指向左值,编译通过
int &ref_a = 5; // 左值引用指向了右值,会编译失败
右值引用:右值引用的标志是&&
,可以指向右值,不可以指向左值。
int &&ref_a_right = 5; // ok
int a = 5;
int &&ref_a_left = a; // 编译不过,右值引用不可以指向左值
ref_a_right = 6; // 右值引用的用途:可以修改右值
那么左值引用、右值引用本身是左值还是右值?
被声明出来的左值引用和右值引用都是左值,因为他们都是有地址的,也位于等号左边,这符合我们刚刚的定义。
右值引用既可以是左值也可以是右值,如果有名称则为左值,否则是右值。作为函数返回值的 && 是右值,直接声明出来的 && 是左值
左右值引用的区别:
- 从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
- 右值引用可以直接指向右值,也可以通过std::move指向左值;而左值引用只能指向左值(const左值引用也能指向右值)。
- 作为函数形参时,右值引用更灵活。虽然const左值引用也可以做到左右值都接受,但它无法修改,有一定局限性。
3. 右值引用和std::move的应用场景
按上文分析,std::move
只是类型转换工具,不会对性能有好处;右值引用在作为函数形参时更具灵活性,看上去还是挺鸡肋的。他们有什么实际应用场景吗?
3.1 实现移动语义
在实际场景中,右值引用和std::move被广泛用于在STL和自定义类中实现移动语义,避免拷贝,从而提升程序性能。 在没有右值引用之前,一个简单的数组类通常实现如下,有构造函数
、拷贝构造函数
、赋值运算符重载
、析构函数
等。
class Array {
public:
Array(int size) : size_(size) {
data_ = new int[size_];
}
// 深拷贝构造->当代码中有指针开辟堆内存时,
// 必须显式定义拷贝构造函数,开辟新的堆内存,存储拷贝后的指针数据,
// 否则两个对象的指针会指向同一个堆内存地址,当某一个对象析构后,
// 相应的堆内存就会释放掉,导致另一个对象内的指针成为悬浮指针!!!
// 浅拷贝->不涉及指针的拷贝
Array(const Array& temp_array) {
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}
// 深拷贝赋值 const引用避免了传参拷贝,但是堆内存仍然需要深拷贝,所以需要用到std::move实现移动赋值
Array& operator=(const Array& temp_array) {
delete[] data_;
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}
~Array() {
delete[] data_;
}
public:
int *data_;
int size_;
};
该类的拷贝构造函数、赋值运算符重载函数已经通过使用左值引用传参来避免一次多余拷贝了,但是内部实现要深拷贝,无法避免。 这时,有人提出一个想法:是不是可以提供一个移动构造函数
,把被拷贝者的数据移动过来,被拷贝者后边就不要了,这样就可以避免 深拷贝 了,
如:
class Array {
public:
Array(int size) : size_(size) {
data_ = new int[size_];
}
// 深拷贝构造
Array(const Array& temp_array) {
...
}
// 深拷贝赋值
Array& operator=(const Array& temp_array) {
...
}
// 移动构造函数(重载深拷贝构造函数),可以浅拷贝-> 形参是const& 构造函数内,对temp_array赋值,编译不通过~
Array(const Array& temp_array, bool move) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时delete data,提前置空其data_
temp_array.data_ = nullptr;
}
~Array() {
delete [] data_;
}
public:
int *data_;
int size_;
};
这么做有2个问题:
- 不优雅,表示移动语义还需要一个额外的参数(或者其他方式)。-> 重载拷贝构造函数
- 无法实现!
temp_array
是个const左值引用,无法被修改,所以temp_array.data_ = nullptr;
这行会编译不过。当然函数参数可以改成非const:Array(Array& temp_array, bool move){...}
,这样也有问题,由于左值引用不能接右值,Array a = Array(Array(), true);
这种调用方式就没法用了。
可以发现左值引用真是用的很不爽,右值引用的出现解决了这个问题,在STL的很多容器中,都实现了以 右值引用为参数的移动构造函数
和移动赋值重载函数
,或者其他函数,最常见的如std::vector的push_back
和emplace_back
。参数为左值引用意味着拷贝,为右值引用意味着移动。
class Array {
public:
......
// 优雅
Array(Array&& temp_array) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时delete data,提前置空其data_
temp_array.data_ = nullptr;
}
public:
int *data_;
int size_;
};
3.2 实例:vector::push_back使用std::move提高性能
// std::move会调用到移动语义函数,避免了深拷贝。
int main() {
std::string str1 = "aacasxs";
std::vector<std::string> vec;
vec.push_back(str1); // 传统方法,copy
vec.push_back(std::move(str1)); // 调用移动语义的push_back方法,避免拷贝,str1会失去原有值,变成空字符串
vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1会失去原有值
vec.emplace_back("axcsddcas"); // 当然可以直接接右值
}
// std::vector方法定义
void push_back (const value_type& val);
void push_back (value_type&& val); // 内部调用了emplace_back
void emplace_back (Args&&... args);
可移动对象在<需要拷贝且被拷贝者之后不再被需要>的场景,建议使用std::move
触发移动语义,提升性能。
moveable_objecta = moveable_objectb;
改为:
moveable_objecta = std::move(moveable_objectb);
还有些STL类是move-only
的,比如unique_ptr
,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝):
std::unique_ptr<A> ptr_a = std::make_unique<A>();
std::unique_ptr<A> ptr_b = std::move(ptr_a); // unique_ptr只有‘移动赋值重载函数‘,参数是&& ,只能接右值,因此必须用std::move转换类型
std::unique_ptr<A> ptr_b = ptr_a; // 编译不通过
std::move本身只做类型转换,对性能无影响。 我们可以在自己的类中实现移动语义,避免深拷贝,充分利用右值引用和std::move的语言特性。
4. 完美转发 std::forward
和std::move
一样,它的兄弟std::forward
也充满了迷惑性,虽然名字含义是转发,但他并不会做转发,同样也是做类型转换.
与move相比,forward更强大,move只能转出来右值,forward都可以。
举个例子,有main,A,B三个函数,调用关系为:main->A->B
,建议先看懂2.3节对左右值引用本身是左值还是右值的讨论再看这里:
void B(int&& ref_r) {
ref_r = 1;
}
// A、B的入参是右值引用
// 有名字的右值引用是左值,因此ref_r是左值
void A(int&& ref_r) {
B(ref_r); // 错误,B的入参是右值引用,需要接右值,ref_r是左值,编译失败
B(std::move(ref_r)); // ok,std::move把左值转为右值,编译通过
B(std::forward<int>(ref_r)); // ok,std::forward的T是int类型,属于条件b,因此会把ref_r转为右值
}
int main() {
int a = 5;
A(std::move(a));
}
例2:
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是左值,编译失败
change2(std::move(ref_r)); // ok,std::move把左值转为右值,编译通过
change2(std::forward<int &&>(ref_r)); // ok,std::forward的T是右值引用类型(int &&),符合条件b,因此u(ref_r)会被转换为右值,编译通过
change3(ref_r); // ok,change3的入参是左值引用,需要接左值,ref_r是左值,编译通过
change3(std::forward<int &>(ref_r)); // ok,std::forward的T是左值引用类型(int &),符合条件a,因此u(ref_r)会被转换为左值,编译通过
// 可见,forward可以把值转换为左值或者右值
}
int main() {
int a = 5;
change(std::move(a));
}
上边的示例在日常编程中基本不会用到,std::forward
最主要运于模版编程的参数转发中,想深入了解需要学习万能引用(T &&)
和引用折叠(eg:& && → ?)
等知识,本文就不详细介绍这些了。
5. Reference
https://zhuanlan.zhihu.com/p/374392832
https://zhuanlan.zhihu.com/p/335994370
https://www.cnblogs.com/shadow-lr/p/Introduce_Std-move.html