Modern C++ | 谈谈万能引用以及它的衍生问题:将亡值、引用折叠和完美转发

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

前言

在学习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此时调用forwardtest将模板参数T给forwardforward的形参为int&可以接收变为左值的x。forward函数返回一个右值引用static_cast<_Ty&&>(_Arg)将形参_Arg强制类型转换为_Ty&&_Ty是int最终_Arg被转换为int&&且作为函数返回值返回。然后再作为print的实参调用print此时调用的就是右值版本的print。

如果转发的变量是一个左值呢将一个左值传给test函数模板参数T推导的结果也是int啊和右值一样那么test的形参T&&作为万能引用是怎么引用左值的呢其实这里比较特殊T&&最终会是一个引用当T被推导成右值比如说intT&&就是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&左值最终被转发成左值引用与原来的值类别一样。而左值引用和右值引用的情况也是差不多的只要注意引用折叠就能理解完美转发是怎样转发对象的值类别的

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: c++

“Modern C++ | 谈谈万能引用以及它的衍生问题:将亡值、引用折叠和完美转发” 的相关文章