C++中关于值类别/左值右值/万能引用/forward/完美转发的概念理解 原创

本文详细解释了C++中的万能引用(转发引用)、std::forward的作用以及完美转发的机制,包括值类别、模板实参推导规则和引用折叠,通过实例展示了这些概念在实际编程中的应用。
摘要由CSDN通过智能技术生成

关于万能引用/forward/完美转发

转发引用/万能引用

什么是万能引用呢
平常声明的

int &&n = 5
void fun(int &&n);

是普通的右值引用

它们有个特点就是类型是“确定的”。
当类型T需要推导时,T &&n 的形式要被看作“转发引用”,又称万能引用
转发引用是std::forward的行动基础

如果一个变量或参数的声明有T&&的形式,并且T是需要推导的类型(deduced type T),那么这个变量或参数是个forwarding reference.

万能引用有以下形式:

  1. 模板形参T
  2. auto
template <typename T>
void fun(T&&);

auto&& n = return_a_value();

同时,转发引用是一种特殊的引用,有特殊的模板实参推导规则

根据cppreference 模板实参推导 - cppreference.com

如果模板函数形参是到无 cv 限定右值引用(也就是转发引用)且对应函数的调用实参是左值,那么将到 A 的左值引用类型用于 A 的位置进行推导
(P指将被代换的函数形参,A指代换后产生的推导类型)

template<class T>
int f(T&&);       // 函数形参 是到无 cv 限定类型 T 的右值引用(转发引用)
 
template<class T>
int g(const T&&); // 函数形参 是到有 cv 限定 T 的右值引用(不是转发引用)
 
int main()
{
    int i;
    int n1 = f(i); // 实参是左值:调用 f<int&>(int&) (特殊情况)
    int n2 = f(0); // 实参不是左值:调用 f<int>(int&&)
 
//  int n3 = g(i); // 错误:推导出 g<int>(const int&&),它不能绑定右值引用到左值
}

可以看到,虽然f和g的形参都写了两个&,但是f(i)的T会推导成int&,g(i)的T会推导成const int&&。
这是因为T &&符合转发引用的形态,遵守特殊的推导规则

比如现在有一个

template <typename T>
void t_fun_1(T &&t) { fun(std::forward<T>(t)); }

则传入不同的参数推导出不同类型的情况如下:

int n;
t_fun_1(n);  		  	// void t_fun_1<int &>(int &t) (特殊的)
t_fun_1(std::move(n));	// void t_fun_1<int>(int &&t)
t_fun_1(0); 			// void t_fun_1<int>(int &&t)	(T是int不是int&&)

这个推导规则可以倒回来再看

值类别

要理解完美转发的作用,要先理解一个问题

例子:值类别
void fun(int &&n) { std::cout << " int&&\n"; }
void fun(int &n) { std::cout << " int&\n"; }

现在有两个函数,有左值引用的重载版本,和右值引用的版本

int n     = 0;
int &lref = n;   // 一个左值引用
int &&rref = 5;  // 一个右值引用
fun(lref);		 // 传入左值引用
fun(0);			 // 传入纯右值	
fun(std::move(n)); // 传入?
fun(rref);       // 传入右值引用
// 请问输出什么呢?

显然,lref会调用左值引用的版本,而纯右值0会调用右值引用的版本。你可能也了解了std::move也会调用右值引用的版本

但rref这个右值引用并不会调用右值引用的版本,因为rref的值类别是左值

也就是说 右值引用实际上是左值。

这是一个经典的问题。C++决定重载版本时要考虑传入的表达式的值类别

对于函数的形参,左值引用要绑定到左值上,右值引用要绑定到右值)上,(也有const左值引用能绑定右值这样的例外规则)

至于std::move(n) 为什么是右值,因为它属于返回类型是对象的右值引用的函数调用,是亡值,而亡值属于右值,这是C++标准中的规定,可见cppreference

move源码:

template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }
// 返回值是typename std::remove_reference<_Tp>::type&&
// 也就是去除引用后再添加&&,即强制返回右值引用

顺便一提,转换到 对象的右值引用类型 的 类型转换表达式 是亡值

转换到左值引用类型的转型表达式 是左值

例子:static_cast和左值右值
void fun(int &n) { std::cout << "int&\n"; }
void fun(int &&n) { std::cout << "int&&\n"; }

int main(){
    int n   = 0;
    fun(static_cast<int &>(n)); 		  // 左值表达式
    fun(static_cast<int &&>(n));          // 右值表达式
    fun(0);
    return 0;
}
// 输出:
// int&
// int&&
// int&&
值类别简单介绍

参考值类别 - cppreference.com

C++这个值类别系统和C语言不太一样,并且与 C++ 标准过去的各版本相比经历了显著变更

下面是我学习目前值类别系统的总结

每个 C++ 表达式可按照两种独立的特性加以辨别:类型和值类别

值类别将表达式分为以下三种:

左值、亡值、纯右值

其中前两者统称泛左值,后两者通常右值

如何判断每个表达式的值类别?

有一说,“若表达式可以取地址,则可以看作左值;若表达式不能取地址,则可以看作右值”。

平时常用的右值有,

1; 
1 + 2;
n + 2; // 运算表达式
&n; // 内建取地址

fun(); // 返回类型是非引用的函数调用(纯右值)
std::move(n); //返回类型是对象的右值引用的函数调用(亡值)
std::string{}; //临时创建的对象
static_cast<Type &&>(n) // static_cast,且转化为右值引用
// static_cast<Type &>(n) 是左值

具体的细节和定义还需看文档值类别 - cppreference.com

不使用完美转发时

来看一个不使用万能引用和forward的例子

void fun(int &&n) { std::cout << " int&&\n"; }
void fun(int &n) { std::cout << " int&\n"; }

template <typename T>
void t_fun(T t) { fun(t); }

int main(){
    int n = 0;
    t_fun(n);  // 传入左值
    t_fun(std::move(n)); // 传入右值
}

这里的问题就是不管给t_fun传左值还是右值,都只能调用fun的左值引用版本,因为在模板实例化时,参数T t一定是左值

std::forward

完美转发的作用是保持原来的属性不变,这里的值应该理解为值类别,即是左值还是右值,并非类型

类型和值类别参考值类别 - cppreference.com

为了实现完美转发,需要用到std::forward值类别机制

forward可以指定转发为左值还是右值(顺便一提,move的作用是无条件转化为右值)

// 源码实现的关键部分:
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }

template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
    // 一个静态断言,不重要
    static_assert(!std::is_lvalue_reference<_Tp>::value,
                  "std::forward must not be used to convert an rvalue to an lvalue");
    // 强制类型转换
    return static_cast<_Tp&&>(__t);
}

首先forward不允许隐式推导_Tp,必须在<>中指明(为什么?因为在模板实参推导中,forward的参数typename std::remove_reference<_Tp>::type&& __t 是"不推导语境",因为Tp在另一个模板的<>内(std::remove_reference),此时不隐式推导)

不管如何实例化,可以看到:

  1. 它们所做的事都是先将_Tp的引用限定去除,再用static_cast转化为类型 _Tp&&(转发引用),
  2. 返回类型是_Tp&&

那既然一律变成_Tp&&类型了,还怎么转化成不同的值类别呢?

这里就涉及到引用折叠问题

引用折叠

引用折叠说的是在推导类型过程中发生多个&叠加的时候对它们进行折叠的规则,和万能引用(转发引用)其实不是一码事,只是它们长得很像

// 引用折叠
int n = 0;
using t1 = int &;
using t2 = int&&;
t1& foo = n;
t2&& foo2 = 5;
// 此时t1就像int& & 
// t2就像int&& && 
// 不过直接声明int&& && foo = 0;是不可以的, 引用折叠不过是编译器在类型推导的内部行为,不允许用户这么做

以下是引用折叠的排列组合情况

结果
&&&
&&&&
&&&&
&&&&&&

也就是说,只有在&& &&的情况下折叠成右值引用,其他情况下都是左值引用

以int为例

当_Tp为int& 时, forward返回类型是int& (看作int& &&的折叠)

当_Tp为int&& 时, forward返回类型是int&& (看作int&& &&的折叠)

(别忘了_Tp必须是我们在尖括号里显式指定的)

然后,根据C++对值类别的定义

返回类型是左值引用的函数调用是左值

返回类型是对象的右值引用的函数调用是亡值(也是右值)

这样就可以让std::forward这个表达式有产生左值和右值的能力了。

我们现在了解了值类别和引用折叠

再回到forward,注意到返回类型是_Tp&&

// 源码实现的关键部分:
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }

template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
    static_assert(!std::is_lvalue_reference<_Tp>::value,
                  "std::forward must not be used to convert an rvalue to an lvalue");
    return static_cast<_Tp&&>(__t);
}

以int为例,来看下不同的模板实参会让forward表达式的值类别有什么不同

int n;
// 当<int &>时,实际上执行的是static_cast<int&>(n),返回值也被推导为int&
std::forward<int&>(n);

// 当<int &&>时,return static_cast<int &&>(n),返回值也被推导为int&&
std::forward<int &&>(n);
例子:std::forward与值类别

函数定义同上

int &&p = 0; 
fun(p);                       // 类型是右值引用, 值类别是左值表达式
fun(std::forward<int &>(p));  // 转发为左值引用
fun(std::forward<int &&>(p)); // 转发为右值引用
fun(std::forward<int>(p));    // 转发int被推导为int&&

// 输出:
// int& 
// int& 
// int&&
// int&&

可能会绕的一个点:

第3行

fun(std::forward<int &>(p));  // 转发为左值引用

源码中:

template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
    static_assert(!std::is_lvalue_reference<_Tp>::value,
                  "std::forward must not be used to convert an rvalue to an lvalue");
    return static_cast<_Tp&&>(__t);
}

可以看到第6行有静态断言,右值不能转发为左值。
如果我们指定_Tp = int &了,那么!std::is_lvalue_reference<_Tp>::value就会为假,静态断言失败过不了编译:否则static_cast<_Tp&&>(__t);的值类别会是左值,出现右值转化为左值的错误。
是否矛盾了呢?

这里是不矛盾的,因为fun(std::forward<int &&>(p));中,p是右值引用,值类别为左值,所以其实会调用forward的第一个重载版本

template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }

这里两个不同的重载版本起到了判断分支作用,传入左值调用左值引用版本,传入右值调用右值引用版本

例子中还有一个点

第五行

fun(std::forward<int>(n));    // 转发int被推导为int&&

当显示指定转发到int时,引用折叠实际上不起作用
(T && 推导为int &&,结果变成static_cast<int&&>(n),一个亡值表达式,实际调用了fun的右值引用版。)

这又提醒了我们,右值引用是左值

总的来说,forward模板实参和返回值的关系如下

模板实参折叠表达式返回
intint &&forward<int>(arg)右值
int &int& &&forward<int&>(arg)左值
int &&int&& &&forward<int&&>(arg)右值
完美转发

之前提到了不使用转发的例子

这里举一个直接使用forward的例子

例子:直接转发
void fun(int &&n) { std::cout << " int&&\n"; }
void fun(int &n) { std::cout << " int&\n"; }

// 直接使用T 
template <typename T>
void t_fun_0(T t) { fun(std::forward<T>(t)); }

int main(){
    int n = 0;
    // 直接转发
    t_fun_0(n); // T推导为int,std::forward<int>()左值版本,返回static_cast<int&&>()
    t_fun_0(std::move(n)); // T还是int
    t_fun_0(5); //T依旧是int
}
//输出:
//int &&
//int &&
//int &&

可以看到直接在原来的模板里使用std::forward是有问题的

变得只会调用右值引用版本

这是因为模板都将T推导为了int,然后显示指定forward<int>导致返回int&&

例子:完美转发

std::forward一般要和万能引用一起使用

void fun(int &&n) { std::cout << " int&&\n"; }
void fun(int &n) { std::cout << " int&\n"; }

template <typename T>
void t_fun_1(T &&t) { fun(std::forward<T>(t)); }

int main(){
	// 完美转发
    t_fun_1(n);
    t_fun_1(std::move(n));
    t_fun_1(0);
    std::cout << '\n';
}
// 输出:
// int&
// int&&
// int&&

完美转发的流程是这样:

首先模板函数的形参声明为万能引用,于是根据传入的是左值还是右值进行特殊的推导
若是左值,则推导为<type&>(type&)的形式
若是右值,则推导为<type>(type&&)的形式

接着通过std::forward进行转发,左值的情况,std::forward<type&>(arg),返回类型推导为type& &&,引用折叠成type&,产生左值
右值的情况,std::forward<type>(arg),返回类型推导为type &&,产生右值,

至此实现了完美转发

完整实验代码:

#include <iostream>

void fun(int &&n) { std::cout << n << " int&&\n"; }
void fun(int &n) { std::cout << n << " int&\n"; }

template <typename T>
void t_fun(T t) { fun(t); }

template <typename T>
void t_fun_0(T t) { fun(std::forward<T>(t)); }

template <typename T>
void t_fun_1(T &&t) { fun(std::forward<T>(t)); }


int main(int argc, char const *argv[])
{
    int n   = 0;
    int &&p = 1;
    fun(static_cast<int &>(n));  // 左值表达式
    fun(static_cast<int &&>(n)); // 右值表达式
    fun(0);
    std::cout << '\n';

    fun(p);                       // 类型是右值引用, 值类别是左值表达式
    fun(std::forward<int &>(p));  // 转发为左值引用
    fun(std::forward<int &&>(p)); // 转发为右值引用
    fun(std::forward<int>(p));    // 转发int被推导为int&&
    std::cout << '\n';

    // 不使用转发
    t_fun(n);
    t_fun(std::move(n)); // T推导为int&&, 作为形参仍然是左值
    std::cout << '\n';

    // 直接转发
    t_fun_0(n);
    t_fun_0(std::move(n)); // T被推导为int, std::forward<int>() 类型为int&& ?
    t_fun_0(5);
    std::cout << '\n';

    // 完美转发
    t_fun_1(n);
    t_fun_1(std::move(n));
    t_fun_1(0);
    std::cout << '\n';


    return 0;
}
  • 26
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值