Modern C++ | 谈谈万能引用以及它的衍生问题:将亡值、引用折叠和完美转发
文章目录
- 前言
- 左右值引用的铺垫
- 万能引用&&引用折叠
- 完美转发
前言
在学习Linux系统编程的过程中,想着得到了新知识,不能把旧知识忘了啊,所以我就读起了以前写的博客,在Modern C++介绍这篇博客中,关于完美转发只是介绍了其用法,感觉差了点什么,于是就是去看了看别人对于完美转发的理解。结果发现这玩意有些复杂,索性以我的知识理解再写一篇博客(网上资料的质量参差不齐,想查清楚一个语法点真的痛苦)。
左右值引用的铺垫
(在讲解万能引用之前,先简单的聊一聊左值引用和右值引用)
在关于引用的理解这篇文章中我说,引用其实是一层软件层,将使用者与语言的底层结构解耦,其实C++的设计者是想让我们多使用引用而少使用指针的,想让我们通过变量名或者引用访问底层的地址,修改地址上存储的数据,而不是直接通过地址访问地址上的数据。具体的理解可以看我的上一篇文章。既然引用只是索引底层地址的一种节点,表示变量名与地址之间的映射,那么就可以有很多变量名映射同一个地址,一个变量可以有很多的引用,但是引用之间可以建立映射关系吗?或者说存在引用的引用这样的类型吗?答案是不存在的,当引用与一个变量建立映射关系,本质是与变量的地址建立映射关系,我们要看到变量后面的地址。所以引用其实和普通变量一样,都指向了底层的相同地址,那什么叫做引用的引用,它们不都指向了底层的地址吗?
int x = 0;
int& y = x;
int& z = y;
上面的demo中,z是y的引用,y又是x的引用,很显然,z是一个引用的引用,但是z和x一样,都指向了x的地址,而不是y的地址,y又没有地址,y只是与x的地址建立了映射关系,只是一个访问x的窗口,并不是一个实体。如果你深刻理解引用的概念,就知道引用的引用是一个不存在的概念,引用的引用不还是和引用一样,指向了底层的地址吗,所以我们不用像指针一样搞那么复杂,理解什么指针的指针,只要记住C++中只有引用。
但是左值引用和右值引用却有着根本上的区别,左值引用是去引用一个已经存储在可写数据区中的变量,而右值引用是去引用存储在只读数据区的变量或数据,但是我们需要通过引用修改其引用地址上的数据,被引用的对象在只读数据区中要怎么修改?当然是不能修改的,所以程序这时会在可写数据区中开辟一块空间,把只读数据区的对象拷贝到可写数据区中。通过上图的代码测试,可以看到右值引用的地址和普通的左值变量地址紧挨着,由此我们可以推断,当引用一个右值时,该右值会被拷贝到可写数据区中,引用的右值变成了一个左值,或者说引用右值可以等价于普通变量的开辟+左值引用的创建,具体可以看我的上篇博客
其实右值引用并不是这样使用的,我们不应该引用这些存储在可读数据区的变量,正常情况下我们也没有引用这些右值的需求。我们应该引用将亡值,什么意思呢,虽然编译器不允许我们直接访问将亡值,因为将亡值的生命周期马上要结束了,资源将要被释放了,我们不能也没有必要去获取一个将亡值。所以编译器就对将亡值进行了限制,在语法层面上对将亡值的访问进行了限制。比如int& x = 1.1;这行表达式产生的中间变量我们无法获取,但是将亡值的存储区域是可写数据区,理论上我们是可以访问将亡值的,因此C++也为我们开了一道口子,我们可以使用右值引用获取一个将亡值,此时将亡值的生命周期并没有延长,出了作用域将亡值就被释放,我们不能再通过将亡值使用它的资源,但是它的资源将被我们自己的左值继承下来,可以说我们延长了将亡值所拥有资源的生命周期。所以,为啥要叫右值引用,叫它将亡值引用不是更贴切吗?
上篇博客的最后,我说右值引用得到的引用不能作为实参调用形参为右值引用的函数,这是无法实现的,因为右值引用得到的引用是一个具名对象,我们通过引用可以访问引用对象上的数据,那么被引用对象就是一个左值,很显然,这时的右值引用对象,引用的不是一个右值而是一个左值。那么我们要怎么调用形参为右值引用的函数呢?一是直接将字面常量作为函数的实参,用纯右值调用形参为右值引用的函数,这时函数的形参会拷贝一份纯右值到可写数据区中,引用拷贝的对象,这个形参就又变为了左值。除此之外,将亡值也可以调用形参为右值引用的函数,这也是移动构造和移动赋值的实现原理,那么现在的问题就是要怎么得到将亡值?我们知道将亡值是一个右值,如果你要右值引用,引用一个将亡值,那么得到的对象其实就是一个左值了,无法调用形参为右值引用的函数,我们只能通过创建匿名对象的表达式以及一些返回右值引用的函数得到将亡值,从而调用形参为右值引用的函数,我们知道函数的返回值在没有被接收之前一直是匿名的,是一个右值,也是一个将亡值。由于这篇博客不讨论移动构造和移动赋值,这里我们不再深入
万能引用&&引用折叠
使用模板参数时,为模板参数加上右值引用的符号(具体见下面的代码),这样的模板参数可不是只能用来接收右值引用的,它还可以接收左值引用,我们称它为万能引用
template <class T>
void test(T&& x);
那么为什么会T&&就是万能引用,T&就不是万能引用?这就要涉及到引用的引用和模板参数的推导了。我们知道,调用函数却不显式的指定模板参数时,编译器会自动根据实参类型推导模板参数,比如
template <class T>
void swap(T left, T right)
int main()
{
int num1 = 10;
int num2 = 20;
swap(num1, num2);
return 0;
}
上面的demo中,由于num1和num2的类型时int,编译器自动推导生成的模板函数就是void swap(int left, int right),可以看到T被推导为实参的类型int了。那么实参的类型是int&或者int&&呢?T就被推导为int&与int&&,假如T也有引用呢?比如一开始举的例子中的test函数,void test(T&& x)
形参的类型为T&&
当实参类型为int&,T被推导为T&,形参就被推导为int& &&
当实参类型为int&&,T被推导为T&&,形参就被推导为int&& &&
这里要注意T的类型和形参的类型,一开始我们就说:没有引用的引用这样的概念,很显然函数的形参被推导成为了引用的引用,编译器会怎样看待引用的引用?虽然我们不能显式的写出引用的引用,但是在实例化模板参数时却会出现引用的引用,编译器将根据
两个引用中只要有一个左值引用,最终的引用类型就是左值引用
如果都是右值引用,最终的引用类型就是右值引用
这样的规则推导最终的引用类型,比如int& &&,因为其中有一个左值引用,所以它最终的类型就是int&。int&& &&,因为两个引用都是右值引用,所以它最终的类型就是int&&。这就是引用折叠,只要出现引用的引用,编译器就会推导最终的引用类型,不可能让我们继续套娃下去。所以啊,根据这个规则T&&接收左值引用,最终推导的引用也是左值引用,接收右值引用,最终推导的引用也还是右值引用,但是T&不论接收左值还是右值的引用,最终的引用都是左值引用,也就是说只有T&&即可以接收左值还可以接收右值,所以将其称之为万能引用
有了引用折叠的理论知识,我们再来看一个例子
template <class T>
void test(T&& x)
{
print(forward<T>(x));
}
int main()
{
int x = 0;
int& y = x;
int&& z = 1;
test<int&&>(z);
return 0;
}
我们知道虽然z是右值引用,但其依然是一个左值,调用test函数时,我们传入模板参数int&&,T被实例化为int&&,根据引用折叠:int&& && --> int&&,x的类型是int&&,是一个右值引用,只能接收右值或者将亡值,z作为一个左值,显然不能调用这样实例化的test函数
修改为模板参数传递的实参,将其修改为int&,T被实例化为int&,根据引用折叠:int& && --> int&,最终x的类型是一个左值引用,可以接收左值和左值引用,所以此时编译通过
完美转发
当一个右值传递给一个函数后,函数肯定是用右值引用接收右值的,所以这个右值就失去了右值属性,如果现在想用这个右值移动构造一个对象呢?显然是做不到的,因为它现在是一个左值,无法调用移动构造函数,但是这个左值原来是一个右值啊,有没有什么方法可以恢复它原来的右值属性并传递给其他函数呢?答案是有的,我们可以通过一个函数,这个函数将返回右值引用,此时的右值引用就是实实在在的右值了,先看代码
void print(int& x)
{
printf("void print(int& x)\n");
}
void print(int&& x)
{
printf("void print(int&& x)\n");
}
template <class T>
void test(T&& x)
{
print(x);
print(forward<T>(x));
}
int main()
{
int x = 0;
int& y = x;
int&& z = 1;
test(1);
return 0;
}
test函数将右值,1作为函数的实参,形参x接收右值1后,成为了左值,这个时候再调用两次print函数,一次是直接将x作为实参调用,一次是将x完美转发后调用
通过结果可以看到,只有完美转发后的x调用了右值引用版本的print函数,没有转发的x是一个左值,调用左值版本的print函数。完美转发函数forward是怎么是实现的?先来看函数原型
template <class _Ty>
_NODISCARD constexpr _Ty&& forward(
remove_reference_t<_Ty>& _Arg) noexcept { // forward an lvalue as either an lvalue or an rvalue
return static_cast<_Ty&&>(_Arg);
}
remove_reference_t<_Ty>的意思是移除参数_Ty的引用属性,如果_Ty是int&&,移除后就是int,我们看到forward函数的返回值是一个右值引用_Ty&&,作为函数返回值,此时的右值引用是可以被形参为右值引用的函数接收的。forward函数的形参是一个左值引用remove_reference_t<_Ty>&,将_Ty的引用属性去除后,再加上了左值属性,也就是说forward函数可以接收实参类型是左值和左值引用的数据,1被test函数接收后,成为了一个左值,并且test函数的模板参数T被推导为int,此时调用forward,test将模板参数T给forward,forward的形参为int&,可以接收变为左值的x。forward函数返回一个右值引用,static_cast<_Ty&&>(_Arg)将形参_Arg强制类型转换为_Ty&&,_Ty是int,最终_Arg被转换为int&&,且作为函数返回值返回。然后再作为print的实参调用print,此时调用的就是右值版本的print。
如果转发的变量是一个左值呢?将一个左值传给test函数,模板参数T推导的结果也是int啊,和右值一样,那么test的形参T&&作为万能引用是怎么引用左值的呢?其实这里比较特殊,T&&最终会是一个引用,当T被推导成右值,比如说int,T&&就是int&&,右值传给右值引用,没有问题。当T被推导成左值呢?如果还是int,最终的int&&不就成为了一个右值引用吗?所以当左值作为万能引用的模板参数时,万能引用会被推导为左值引用,比如说一个int类型的左值,int i = 1; test(i); 这里因为i是左值,T就会被推导为int&,也就是说万能引用把左值和左值引用看出同一个类型了,其实这也是正确的,左值引用与普通的左值不都是通过变量名索引地址吗?从底层的角度讲是没有什么区别的,并且这些做也能解决万能引用的左值问题。
所以当一个左值作为万能引用的模板参数时,编译器会把它当成左值引用
void print(int& x)
{
printf("void print(int& x)\n");
}
void print(int&& x)
{
printf("void print(int&& x)\n");
}
template <class T>
void test(T&& x)
{
print(x);
print(forward<T>(x));
}
int main()
{
int x = 0;
int& y = x;
int&& z = 1;
test(x);
return 0;
}
比如将x作为test的实参,test的模板参数T是一个万能引用,将左值int推导为int&,等价于左值引用,然后调用forward时,用int&将模板参数实例化。return static_cast<_Ty&&>(_Arg),再看forward函数的返回值,_Ty被显式实例化为int&,所以_Ty&& --> int& && --> int&,左值最终被转发成左值引用,与原来的值类别一样。而左值引用和右值引用的情况也是差不多的,只要注意引用折叠,就能理解完美转发是怎样转发对象的值类别的