在C++11之前,是没有右值引用的概念的,在C++11之后才新增了右值引用。其实无论是左值引用还是右值引用都是给对象取别名。
认识左值和右值
什么是左值?
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。
// 常见的左值
// 以下的p、b、c、*p、s都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
*p = 10;
string s("xxxxxx");
什么是右值?
右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址,都是一些临时创建的对象。
// 常见的右值 (临时对象)
10; // 常量
x + y;
string("xxxxx"); // 匿名对象
左值引用与右值引用
其实左值引用和右值引用非常好理解:左值引用就是给左值的引用,给左值取别名。右值引用就是对右值的引用,给右值取别名。
// 这里的b、p、*p、10、x+y都是上文代码中的
// 左值引用给左值取别名
int& r1 = b;
int*& r2 = p;
int& r3 = *p;
// 右值引用给右值取别名
int&& rr1 = 10;
int&& rr2 = x+y;
我们再来思考一下,左值引用能不能给右值取别名呢?或者说右值引用能不能给左值取别名呢?我们来看下面的代码 ps. &代表左值引用、&&代表右值引用
int& rx1 = 10; // 报错
int& rx2 = x + y; // 报错
const int& rx3 = 10; // 正确
const string& rx4 = string("xxxxx"); // 正确
结论:左值引用不能直接给右值取别名,但是const左值引用可以(也间接说明了右值不可被修改)。
int&& rrx1 = b; // 报错
int*&& rrx2 = p; // 报错
int&& rrx3 = move(*p); // 正确
string&& rrx4 = move(s); // 正确
ps. 其实move()函数的本质就是强制类型转换,类似string&& rrx5 = (string&&)s; 也是可以编译通过的。想要深入了解move()函数的,请点击 move()文档
结论:右值引用不能直接给左值取别名,但是move(左值)之后就可以。
右值引用的作用(重点)
文章开头就已经说过,右值引用是C++11之后才引入的,左值引用是早已存在的。既然引用的意义是为了减少拷贝,那么左值引用已经存在了,为什么还要引入右值引用的概念呢?为了增加我们的学习难度?当然不是!其实左值引用只能解决 引用传参/引用传返回值 的场景,但有时候我们会遇到 传返回值 的场景,这个时候左值引用就管不了了,所以引入了右值引用来解决该问题。
左值引用的局限性
// 整形转字符串函数
string to_string(int value)
{
bool flag = true;
if (value < 0){
flag = false;
value = 0 - value;
}
string str; // 临时对象
while (value > 0)
{
int x = value % 10;
value /= 10;
str += ('0' + x);
}
if (flag == false) str += '-';
reverse(str.begin(), str.end());
return str; // 返回临时对象,出了作用域就会被销毁
}
int main()
{
// 在string to_string(int value)函数中可以看到,这里
// 只能使用传值返回,传值返回会导致至少1次拷贝构造
string ret1 = to_string(1234);
return 0;
}
所以,上面代码的执行过程会至少调用一次拷贝构造(VS2022编译环境下),下面我们来看看为什么会调用拷贝构造。
右值引用和移动语义
有了上面的理解,我们知道如果没有移动构造,就会调用拷贝构造,那么,什么是移动构造呢?移动构造本质就是将参数右值的资源窃取过来,占位已有,那么就不用做深拷贝了,所以它叫做移动构造,就是窃取别人的资源来构造自己。
大家再来思考一个问题:为什么右值的资源可以被窃取呢?被窃取资源后的右值的结果又会怎么样呢?在回答这个问题之前,先补充一个小知识,右值的分类
右值分为两种:
- 纯右值(prvalue):内置类型右值。比如:10
- 将亡值(xvalue):类类型的右值。比如:匿名对象、类型转换产生的临时对象
上例中函数to_string中的str属于左值,但中间产生的临时对象就是右值中的将亡值,将亡值的意思就是很快就要死亡的值。既然你马上就要消亡了,你的这些资源留着也是浪费,正好我也正需要这样的资源,不如把他们直接给我。就好比一个将亡的病人,在死亡之前签订了器官捐赠协议...(吧啦吧啦,你们自己脑补吧)看到这里相信上面的问题你们心里已经有了答案。所以说 “成为右值” 是一件非常危险的事,说不准你就已经被别人盯上了......
// 在上述代码中添加移动构造
string(string&& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
cout << "string(string&& s) -- 移动语义" << endl;
swap(s);
}
// 再运行上面to_string的两个调用,我们会发现,这里没有调用深拷贝的拷贝构造,
// 而是调用了移动构造,移动构造中没有新开空间和拷贝数据,所以效率提高了。
to_string的返回值产生的临时对象是一个右值,用这个右值构造ret2,如果既有拷贝构造,又有移动构造,调用就会匹配移动构造调用,因为编译器会选择最匹配的参数调用。那么这里就是一个移动语义。
是每个类都必须有移动构造吗?不是的,只有深拷贝的类才有意义。举个例子:日期类可以有移动构造,但是没有意义。
不仅有移动构造,还有移动赋值
// 移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动语义" << endl;
swap(s);
return *this;
}
int main()
{
string ret1;
ret1 = to_string(1234);
return 0;
}
// 运行结果:
// string(string&& s) -- 移动语义
// string& operator=(string&& s) -- 移动语义
这里运行后,我们看到调用了一次移动构造和一次移动赋值。因为如果是用一个已经存在的对象接收,编译器就没办法优化了。to_string函数中会先用str生成构造生成一个临时对象,但是我们可以看到,编译器很聪明的在这里把str识别成了右值,调用了移动构造。然后在把这个临时对象做为to_string函数调用的返回值赋值给ret1,这里调用的移动赋值。
另外,右值引用的作用不仅仅体现在传值返回上,像STL中的容器相关接口,如:push系列、insert系列,都提供了右值引用的版本,大大起到了提效的作用(减少了拷贝)。
补充知识
// r1(右值引用本身)的属性是左值还是右值?
string&& r1 = string("xxxxxxx");
分析上述代码可知,string("xxxxxxx") 是一个右值,而r1引用了它,问r1本身是一个什么属性的值?这里直接说答案:左值。这里发生了一个退化,可能大家会觉得比较怪,我们看下图
完美转发
模板中的&& 万能引用
我们先来看一段代码
template<typename T>
void PerfectForward(T&& t)
{}
问题:函数PerfectForward的形参类型是右值引用吗?
根据上文中的相关知识,我们会非常肯定的回答是的,是右值引用。但是在这里却不是,模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
void Fun(int &x){ cout << "左值引用" << endl; }
void Fun(const int &x){ cout << "const 左值引用" << endl; }
void Fun(int &&x){ cout << "右值引用" << endl; }
void Fun(const int &&x){ cout << "const 右值引用" << endl; }
template<typename T>
void PerfectForward(T&& t)
{
Fun(t);
}
int main()
{
PerfectForward(10); // 右值
int a;
PerfectForward(a); // 左值
PerfectForward(std::move(a)); // 右值
const int b = 8;
PerfectForward(b); // const 左值
PerfectForward(std::move(b)); // const 右值
return 0;
}
如果上述代码中传入形参 t 的值是一个左值,那么Fun函数就会调用其左值版本,进而输出“左值引用”;如果传入形参 t 的值是一个右值,那么Fun函数中参数的属性还是左值(上文中有讲,右值退化成了左值),还是会调用其左值版本,进而输出“左值引用”。
所以,模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值,我们希望能够在传递过程中保持它的左值或者右值的属性,就需要用到完美转发。
// 将上述代码中的PerfectForward函数修改为
void PerfectForward(T&& t)
{
Fun(std::forward<T>(t));
}
其中std::forward<T>(t)就是完美转发,std::forward 完美转发在传参的过程中保留对象原生类型属性。这样我们就可以得到我们想要的答案
std::forward VS std::move
相同点:
- 本质上都是强制类型转换
不同点:
- std::move一定会将一个左值转换为一个右值,使用std::move时不需要指定模板实参,模板实参是由函数调用推导出来的。
- std::forward会根据左值和右值的实际情况进行转发,在使用的时候需要指定模板实参。