C++笔记---右值引用
1. 左值和右值
左值(lvalue)
左值是指可以放在赋值号(=)左边的表达式,它代表一个可以被引用的内存位置。左值通常是变量名、数组元素、解引用的指针等。左值具有持久性,可以被多次引用和修改。
右值(rvalue)
右值是指只能放在赋值号(=)右边的表达式,它代表一个临时的值,通常是字面量、函数返回值、表达式的结果等。右值不具有持久性,一旦被使用就会被丢弃。
左值与右值的核心区别就在于能否取地址。
2. 左值引用与右值引用
Type& r1 = x; 为左值引用,左值引用只能引用左值,但是const左值引用却可以引用右值:
double x = 0.0;
const int& y = (int)x;
在上面这段代码中,y引用的是x强转为int类型产生的临时对象,该临时对象是一个右值,虽然左值引用不能引用右值,但是const左值引用既可以引用左值又可以引用右值。
Type&& r2 = y; 为右值引用,右值引用只能引用右值,但是也可以引用 "move(左值)" :
int x = 0;
int&& y = move(x);
template <class T> typename remove_reference<T>::type&& move (T&& arg);
move是库里面的一个函数模板,作用是将左值转换为右值,本质内部是进行强制类型转换,当然他还涉及一些引用折叠的知识,这个我们后面会细讲。
注意,右值引用虽然引用的是右值,但其本身却是左值,这一点感觉很怪,但却是右值引用存在的意义所在。
3. 右值引用的价值
3.1 消除临时对象的常性
在C++11之前,我们会使用左值引用来减少传参的消耗。
但是,如果在传参过程中发生了隐式类型转换,我们就必须使用const左值引用,这无疑限制了我们对参数的使用,如果我们在函数中需要对参数的内容进行一些修改,我们依然需要在函数内部进行对参数的拷贝。
而右值引用的出现就解决了这个问题,因为相比于const左值引用,右值引用不仅能引用右值,还能修改右值。
3.2 移动语义
移动语义是C++11引入的一项重要特性,它允许对象的资源(如堆上分配的内存)在不进行深度复制的情况下进行转移。
通过移动语义,可以将对象的资源从一个对象转移到另一个对象,从而避免不必要的内存拷贝,提高程序性能和效率。
为了更好地理解什么是移动语义,我们来看下面这一个例子:
class Solution {
public:
// 传值返回需要拷⻉
string addStrings(string num1, string num2) {
string str;
int end1 = num1.size() - 1, end2 = num2.size() - 1;
// 进位
int next = 0;
while (end1 >= 0 || end2 >= 0)
{
int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
int ret = val1 + val2 + next;
next = ret / 10;
ret = ret % 10;
str += ('0' + ret);
}
if (next == 1)
str += '1';
reverse(str.begin(), str.end());
return str;
}
};
int main()
{
string ret = Solution().addStrings("11111", "22222");
cout << ret << endl;
return 0;
}
在函数addString中,我们不能采取返回左值引用的方式来减少拷贝,因为引用本质上是对变量取别名,在函数的栈帧结束之后,即使有别名也没有任何意义,因为变量已经销毁了,所以我们只能传值返回。
在C++11之前,的解决方案是使用输出型参数来减少拷贝,但这样的写法看起来多少有点别扭:
class Solution {
public:
// 传值返回需要拷⻉
string addStrings(string num1, string num2, string& str) {
//...
}
};
int main()
{
string ret;
Solution().addStrings("11111", "22222", ret);
cout << ret << endl;
return 0;
}
既然引用返回行不通,那么我们可不可以在拷贝上下功夫呢?
str对象一定会被销毁,但其实我们需要的只是其资源而非对象本身,根据这一点,C++11提出了移动语义的概念,即直接将str的资源转移到临时对象,再将临时对象的资源移动到ret。
移动语义的实现方式:
移动构造函数:移动构造函数接受一个右值引用参数(如果有其他参数,其他参数必须有缺省值),并从该右值引用中“窃取”资源,而不是进行深度拷贝。通常,在移动构造函数中,将原始对象的资源指针置为空,以确保资源只能由一个对象管理。
移动赋值运算符:移动赋值运算符也接受一个右值引用参数(如果有其他参数,其他参数必须有缺省值),并将原始对象的资源转移给目标对象。通常,移动赋值运算符还会处理自我赋值情况,避免资源泄漏
二者的实现方式类似,所谓的窃取,就是使用swap函数进行资源的交换(空手套白狼)。
在C++11之前,这样的移动语义实现方式是行不通的,因为临时变量具有常性,其资源无法被交换,于是在C++11就提出了右值引用。
实现移动语义,才是右值引用存在的最重要的作用。
C++11更新之后,STL库中几乎所有数据结构都重载了移动构造函数与移动赋值运算符,当参数为右值时,会优先匹配到这两个函数的参数列表,以减少拷贝。
但是,str并不是右值,为什么也会在构造临时对象时调用移动构造呢?
其实,还有一类特殊的右值,叫做 "将亡值" ,顾名思义,就是即将销毁的值。
4. 类型分类
C++11以后,对左右值进行了更加细化的划分,如下图:
相比于C++98,其实就是将原本属于左值的将亡值划分到了右值,因为将要销毁的对象和临时对象的性质是一样的。
在C++官方文档中,对各种值类型是这样定义的:
泛左值:有名称,即可被取地址的值。
左值:有名称,不可被移动(注意是移动语义的移动,不是move)。
将亡值:有名称,可被移动。
纯右值:无名称,可被移动。
上图看上去不太对称,但这样的设计是合理的:
1. 将亡值属于右值而不属于左值,所以在传参时会被优先认定为右值。
2. 将亡值属于泛左值,所以在没有对应的右值引用传参方式时,也可以被认为是左值。
5. 引用折叠
C++中不能直接定义引用的引用如 int& && r = i; ,这样写会直接报错,通过模板或 typedef中的类型操作可以构成引用的引用。
通过模板或 typedef 中的类型操作可以构成引用的引用时,这时C++11给出了一个引用折叠的规则:右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用:
&& + && = &&; && + & = &; & + && = &; & + & = &;
C++11中有万能引用(Universal Reference)的概念:使用T&&类型的形参既能绑定右值,又能绑定左值。但是注意了:只有发生类型推导的时候,T&&才表示万能引用;否则,表示右值引用。
假如我们将函数设计为右值引用传参的模板,那么就可以根据需要分别实例化出左值引用和右值引用的版本。
模板显式实例化的场景:
// 由于引用折叠限定,f1实例化以后总是一个左值引用
template<class T>
void f1(T& x)
{}
// 由于引⽤折叠限定,f2实例化后可以是左值引用,也可以是右值引用
template<class T>
void f2(T&& x)
{}
int main()
{
typedef int& lref;
typedef int&& rref;
int n = 0;
lref& r1 = n; // r1 的类型是 int&
lref&& r2 = n; // r2 的类型是 int&
rref& r3 = n; // r3 的类型是 int&
rref&& r4 = 1; // r4 的类型是 int&&
// 没有折叠->实例化为void f1(int& x)
f1<int>(n);
f1<int>(0); // 报错
// 折叠->实例化为void f1(int& x)
f1<int&>(n);
f1<int&>(0); // 报错
// 折叠->实例化为void f1(int& x)
f1<int&&>(n);
f1<int&&>(0); // 报错
// 折叠->实例化为void f1(const int& x)
f1<const int&>(n);
f1<const int&>(0);
// 折叠->实例化为void f1(const int& x)
f1<const int&&>(n);
f1<const int&&>(0);
// 没有折叠->实例化为void f2(int&& x)
f2<int>(n); // 报错
f2<int>(0);
// 折叠->实例化为void f2(int& x)
f2<int&>(n);
f2<int&>(0); // 报错
// 折叠->实例化为void f2(int&& x)
f2<int&&>(n); // 报错
f2<int&&>(0);
return 0;
}
模板隐式实例化的场景:
template<class T>
void Function(T&& t)
{
int a = 0;
T x = a;
x++;
// 验证T是否为引用类型
cout << &a << endl;
cout << &x << endl << endl;
}
int main()
{
// 10是右值,推导出T为int,模板实例化为void Function(int&& t)
Function(10); // 右值
int a;
// a是左值,推导出T为int&,引⽤折叠,模板实例化为void Function(int& t)
Function(a); // 左值
// std::move(a)是右值,推导出T为int,模板实例化为void Function(int&& t)
Function(std::move(a)); // 右值
const int b = 8;
// b是左值,推导出T为const int&,引⽤折叠,模板实例化为void Function(const int&t)
// 所以Function内部会编译报错,x不能++
Function(b); // const 左值
// std::move(b)右值,推导出T为const int,模板实例化为void Function(const int&&t)
// 所以Function内部会编译报错,x不能++
Function(std::move(b)); // const 右值
return 0;
}
注意:在隐式实例化时,传入右值T会被推导为非引用类型,T&&为右值引用;传入左值T会被推导为左值引用,发生折叠,使T&&变成左值引用。
6. 完美转发
右值引用为左值,这一点虽然是右值引用的重要性质,但是也相应地带来了一些麻烦。
在函数嵌套调用的过程中,通过右值引用传入的参数在传往下一层函数的过程中会被认为是左值:
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<class T>
void Function(T&& t)
{
Fun(t);
}
int main()
{
// 10是右值,推导出T为int,模板实例化为void Function(int&& t)
Function(10); // 右值
return 0;
}
上面这一程序的运行结果为 "左值引用" ,说明 "t" 被认为是左值。
如果希望在下一层的调用中依然保持参数最开始的左右值属性,就需要用到完美转发,实质上就是一个函数 "forward" :
template<class T>
void Function(T&& t)
{
Fun(forward<T>(t));
}
修改之后的运行结果就为 "右值引用"。
forward的底层原理
std::forward的内部实现实际上就是做了一个static_cast的强转,这个转换过程不是发生在函数内部,而是在函数外部就已经判断出来了_Tp是左值还是右值。std::forward必须配合T&&来使用。例如T&&接受左值int&时,T会被推断为int&,而T&&接受右值int&&时,T被推断为int。在std::forward中只是单纯的返回T&&。那么依据外层是左值时,T为int&,那么T&&即int& &&仍为int&,当外层函数参数为右值,T&&为int&&,这样就保证了传进来是左值则还是左值,是右值还是右值。
template <class _Ty>
_Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept
{ // forward an lvalue as either an lvalue or an rvalue
return static_cast<_Ty&&>(_Arg);
}
说白了,就是这样实现的:
template<class T>
void Function(T&& t)
{
Fun((T&&)t);
}
右值引用为左值的本质
这时就有小伙伴要问了,t不本来就是T&&类型吗?
我们前面说过,右值引用是左值,实际上这一点并不准确,我认为这样说更加准确:
声明为右值引用的变量的类型实际上是对应的非右值引用类型。
template < class T>
void Function(T&& t)
{
cout << typeid(t).name() << endl;
}
int main()
{
// 10是右值,推导出T为int,模板实例化为void Function(int&& t)
Function(10); // 右值
return 0;
}
程序运行的结果如下:
也就是说,右值引用类型的值确实是右值(从forward的实现原理可以看出),但是声明为右值引用的变量却是对应的非引用类型。
引用折叠的本质
在发现上面这一点之后博主的脑袋轰的一声炸开了,突然感觉自己理解了一切!
引用折叠的四条公式并非人为规定,而是浑然天成的:
& + & = &:别名的别名还是别名,这一点自不用说
& + && = &:声明为(Type&)&&的变量的类型实质上为Type&
&& + & = &:声明为Type&&的变量的类型实质上为Type,再加上后面的&得到Type&
&& + && = &&:声明为(Type&&)&&的变量的类型实质上为Type,与单次右值引用效果相同