关于万能引用/forward/完美转发
目录
转发引用/万能引用
什么是万能引用呢
平常声明的
int &&n = 5
void fun(int &&n);
是普通的右值引用
它们有个特点就是类型是“确定的”。
当类型T需要推导时,T &&n 的形式要被看作“转发引用”,又称万能引用。
转发引用是std::forward的行动基础
如果一个变量或参数的声明有T&&的形式,并且T是需要推导的类型(deduced type T),那么这个变量或参数是个forwarding reference.
万能引用有以下形式:
- 模板形参T
- 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&&
值类别简单介绍
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),此时不隐式推导)
不管如何实例化,可以看到:
- 它们所做的事都是先将_Tp的引用限定去除,再用static_cast转化为类型 _Tp&&(转发引用),
- 返回类型是_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模板实参和返回值的关系如下
模板实参 | 折叠 | 表达式 | 返回 |
---|---|---|---|
int | int && | 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;
}