一、左值与右值
什么是左值和右值?
- 左值(lvalue):在内存中有确定存储地址、有变量名,表达式结束后依然存在的值。简单来说,左值就是非临时对象。
- 右值(rvalue):在内存中没有确定存储地址、没有变量名,表达式结束后就会被销毁的值。简单来说,右值就是临时对象。
int a = 0; // 在这条语句中,a 是左值,0 是临时值(右值)。
左值可以分为两类:非常量左值 和 常量左值
int a = 10; //a 为非常量左值(有确定存储地址,也有变量名)
const int a1 = 10; //a1 为常量左值(有确定存储地址,也有变量名)
const int a2 = 20; //a2 为常量左值(有确定存储地址,也有变量名)
右值也可以分为两类:纯右值(prvalue) 和 将亡值(xvalue)
- 纯右值:基本类型的字面量、临时对象(如函数返回的临时对象)等
- 将亡值:通过
std::move
转换的对象、返回对象的右值引用的函数调用等
int a = 10;
const int a1 = 10;
const int a2 = 20; // 10,20为纯右值(prvalue)
a1 + a2 // (a1 + a2) 为纯右值(prvalue)
std::move(a) // 将非常量左值 a 转换为将亡值(xvalue)
二、左值引用于右值引用
什么是左值引用与右值引用?
- 左值引用:绑定到左值的引用,通过
&
来声明。 - 右值引用:绑定到右值的引用,通过
&&
来声明。
左值引用示例
int a = 10;
const int a1 = 10;
const int a2 = 20;
//非常量左值引用
int &b1 = a; //正确,a是非常量左值,可以绑定到非常量左值引用
int &b2 = a1; //错误, a1是常量左值,不能绑定到非常量左值引用
int &b3 = 10; //错误,10是纯右值,不能绑定到非常量左值引用
int &b4 = a1+a2; //错误,(a1+a2)是纯右值,不能绑定到非常量左值引用
//常量左值引用
const int &c1 = a; //正确,a是非常量左值,可以绑定到常量左值引用
const int &c2 = a1; //正确, a1是常量左值,可以绑定到常量左值引用
const int &c3 = a+a1; //正确,(a+a1)是纯右值,可以绑定到常量左值引用
const int &c4 = a1+a2; //正确,(a1+a2)是纯右值,可以绑定到常量左值引用
总结:
- 非常量左值引用只能绑定到非常量左值
- 常量左值引用可以绑定到所有类型的值(非常量左值、常量左值、右值)
右值引用示例
int a = 10;
const int a1 = 10;
const int a2 = 20;
//非常量右值引用
int &&b1 = a; //错误,a是非常量左值,不能绑定到非常量右值引用
int &&b2 = a1; //错误, a1是常量左值,不能绑定到非常量右值引用
int &&b3 = 10; //正确,10是纯右值,可以绑定到非常量右值引用
int &&b4 = a1+a2; //正确,(a1+a2)是纯右值,可以绑定到非常量右值引用
//常量右值引用
const int &&c1 = a; //错误,a是非常量左值,不能绑定到常量右值引用
const int &&c2 = a1; //错误, a1是常量左值,不能绑定到常量右值引用
const int &&c3 = a+a1; //正确,(a+a1)是纯右值,可以绑定到常量右值引用
const int &&c4 = a1+a2; //正确,(a1+a2)是纯右值,可以绑定到常量右值引用
总结:
- 右值引用(无论是否为常量)只能绑定到右值
- 右值引用主要用于移动语义和完美转发
std::move
的使用
从上述可以发现,常量左值引用可以绑定到右值上,但右值引用不能绑定任何类型的左值,若想利用右值引用绑定左值该怎么办呢?
- C++11中提供了一个标准库move函数获得绑定到左值上的右值引用,即直接调用std::move告诉编译器将左值像对待同类型右值一样处理,但是被调用后的左值将不能再被使用。
std::move
用于将左值强制转换为右值引用,从而可以调用移动构造函数或移动赋值运算符
int a = 10;
const int a1 = 10;
//非常量右值引用
int &&d1 = std::move(a); //正确,将非常量左值a转换为将亡值(xvalue),可以绑定到非常量右值引用
int &&d2 = std::move(a1); //错误,将常量左值a1转换为将亡值(xvalue),不能绑定到非常量右值引用
//常量右值引用
const int &&c1 = std::move(a); //正确,将非常量左值a转换为将亡值(xvalue),可以绑定到常量右值引用
const int &&c2 = std::move(a1); //正确,将常量左值a1转换为将亡值(xvalue),可以绑定到常量右值引用
最后可以发现,编译器利用std::move将左值强制转换为相同类型的右值之后,引用情况跟右值是一模一样的。
三、右值引用与左值引用的区别
- 绑定对象:
- 左值引用绑定到有持久存储的左值
- 右值引用绑定到临时对象(右值)
- 生命周期:
- 左值引用延长对象的生命周期
- 右值引用通常用于 “窃取” 临时对象的资源
- 修改能力:
- 非常量左值引用允许修改绑定的对象
- 右值引用绑定的对象通常是临时的,可以被修改(除非是常量右值引用)
四、引入右值引用的原因
1. 实现移动语义,提高效率
传统的拷贝构造函数需要复制资源,而移动构造函数可以直接转移资源的所有权,避免不必要的拷贝。
某些情况下,需要拷贝一个对象然后将其销毁,如:临时类对象的拷贝就要先将旧内存的资源拷贝到新内存,然后释放旧内存,引入右值引用后,就可以让新对象直接使用旧内存并且销毁原对象,这样就减少了内存和运算资源的使用,从而提高了运行效率
2. 移动含有不能共享资源的类对象
对于管理独占资源(如文件句柄、网络连接、智能指针等)的类,移动语义允许资源的安全转移。
像IO、unique_ptr这样的类包含不能被共享的资源(如:IO缓冲、指针),因此,这些类对象不能拷贝但可以移动。这种情况,需要先调用std::move将左值强制转换为右值,再进行右值引用。
例子如下:
class MyString {
private:
char* _data;
size_t _len;
void _init_data(const char *s) {
_data = new char[_len + 1];
memcpy(_data, s, _len);
_data[_len] = '\0';
}
public:
// 构造函数和拷贝构造函数保持不变
// 移动构造函数
MyString(MyString&& str) noexcept {
std::cout << "Move Constructor is called! source: " << str._data << std::endl;
_len = str._len;
_data = str._data;
str._len = 0;
str._data = NULL; // 防止在析构函数中将内存释放掉
}
// 移动赋值运算符
MyString& operator=(MyString&& str) noexcept {
std::cout << "Move Assignment is called! source: " << str._data << std::endl;
if (this != &str) {
delete[] _data; // 释放当前对象的资源
_len = str._len;
_data = str._data;
str._len = 0;
str._data = NULL; // 防止在析构函数中将内存释放掉
}
return *this;
}
// 析构函数保持不变
};
int main() {
MyString a;
a = MyString("Hello"); // 调用移动赋值运算符
std::vector<MyString> vec;
vec.push_back(MyString("World")); // 调用移动构造函数
}
运行结果:
Copy Assignment is called! source: Hello
Destructor is called!
Copy Constructor is called! source: World
Destructor is called!
Destructor is called!
Destructor is called!
总共执行了2次拷贝,MyString(“Hello”)和MyString(“World”)都是临时对象,临时对象被使用完之后会被立即析构,在析构函数中free掉申请的内存资源。 如果能够直接使用临时对象已经申请的资源,并在其析构函数中取消对资源的释放,这样既能节省资源,有能节省资源申请和释放的时间。 这正是定义转移语义的目的。
通过加入定义转移构造函数和转移赋值操作符重载来实现右值引用(即复用临时对象):
MyString(MyString&& str) {
std::cout << "Move Constructor is called! source: " << str._data << std::endl;
_len = str._len;
_data = str._data;
str._len = 0;
str._data = NULL; // ! 防止在析构函数中将内存释放掉
}
MyString& operator=(MyString&& str) {
std::cout << "Move Assignment is called! source: " << str._data << std::endl;
if (this != &str) {
_len = str._len;
_data = str._data;
str._len = 0;
str._data = NULL; // ! 防止在析构函数中将内存释放掉
}
return *this;
}
运行结果:
Move Assignment is called! source: Hello
Move Constructor is called! source: World
Destructor is called!
Destructor is called!
需要注意的是:右值引用并不能阻止编译器在临时对象使用完之后将其释放掉的事实,所以转移构造函数和转移赋值操作符重载函数 中都将_data赋值为了NULL,而且析构函数中保证了_data != NULL才会释放。
关键点总结
- 左值与右值:核心区别在于生命周期和是否有名称
- 左值引用与右值引用:
- 左值引用绑定到左值
- 右值引用绑定到右值
- 移动语义:通过右值引用实现资源的高效转移
**std::move**
:将左值转换为右值引用,触发移动语义
移动语义是 C++11 最重要的特性之一,它显著提高了处理临时对象的效率,特别是在容器操作和资源管理方面。