简介
C++ 中移动语义的用法和实现原理 中我整理了右值引用以及移动语义的相关概念。在不涉及类型推断的适时候,移动语义一般能满足需求。但是在涉及类型推断时常有不足,因此需要转发概念的引入。这篇博客先从引用折叠引入,然后结合类型推断判断移动语义满足不了的场景,最后整理完美转发的概念。
引用折叠
通常来说,我们不能声明一个引用的引用,其中的道理是,当我们初始化了一个引用之后。由于引用本身不是一个对象,它只是引用对象的一个别名。所以我们没有办法判断一个符号是引用还是对象(引用的表现和对象完全一致)。因此对引用的引用是不合理的,但是在涉及到类型推导或者类型别名时,编译器有可能会生成“引用的引用”的代码,因此此时会触发引用折叠现象。如下所示:
int main() {
using lrint = int&;
using rrint = int&&;
int a = 1;
lrint b = a;
// (int&)& c = b; // error!
lrint& c = a;
lrint&& d = a;
rrint&& e = 1;
return 0;
}
上述程序能编译通过,我们虽然不能显式的声明一个引用的引用如((int&)& c = b
),但是通过 using
, typedef
或者模板时会生成类似的类型,先通过 C++ insights 看一下编译器会如何理解这种情况:
可以看到,编译器会将这些自动生成的“引用的引用”推导为左值引用和右值引用的一种,根据 C++ 标准,当引用的引用被类型操作,如模板,typedef
是生成时,会触发引用折叠现象,标准给定,只要组合情况包含左值引用,结果会被折叠为左值引用,否则一律为右值引用,即只有右值引用的右值引用(rvalue reference to rvalue reference),会被折叠为右值引用。
引用折叠现象导致会出现一个问题,当我们看到一个涉及类型推导的右值引用时,它是右值引用还是左值引用呢?如下所示:
template <typname T>
void func(T&& t) {}
在不了解引用折叠时,我们会认为这就是一个左值引用。当如果 T = int&
时,根据引用折叠的规则,T&&
会被折叠为 int&
是一个左值引用,因此上述函数实际上可以接收左值或者右值,如下所示:
#include <iostream>
template <typename T>
void print(T&& t) {
std::cout << t << std::endl;
}
int main() {
int a = 1;
print(a);
print(10);
return 0;
}
通过 C++ insight 的结果可以看到,编译器会为我们自动生成 int&
和 int&&
两个版本的模板函数。
模板中的参数转发
考虑以下场景:
#include <iostream>
void showValue(const int& val) { std::cout << "lvalue: " << val << std::endl; }
void showValue(int&& val) { std::cout << "rvalue: " << val << std::endl; }
template <typename T>
void print(T&& t) {
showValue(t);
}
int main() {
int a = 1;
print(a);
print(std::move(a));
print(10);
return 0;
}
例子中,print
为一个模板函数可以同时接收左值或者右值。当接收参数为左值时,如 a
,T
会被推导为 int&
,因此 T&&
为左值引用;当接收参数为右值时,如:std::move(a)
(将亡值),10
(纯右值)时,T
会被推导为 int
,因此 T&&
为右值引用。在 print
函数内部,我们调用了另一个外部函数,该函数同时有左值引用和右值引用重载版本,我们当然是希望当传入参数为左值时调用左值引用版本,右值时调用右值引用版本。但程序运行结果如下所示:
$ /home/xt/code_collections/cpp/build/cpp-forward/forward_example3
lvalue: 1
lvalue: 1
lvalue: 10
可以看到,三种传参的情况都被识别为左值传入。其原因在于我们传入的参数为 t
,而 t
本身作为一个形参(也许是左值引用或者右值阴影类型) 是一个左值(有名字)。因此这里直接传入 t
都会调用左值引用版本的 showValue()
函数。因此这里我们需要使用完美转发。
完美转发
在上面的例子中,如果我们需要编译器按我们传入参数的值类型(而不是形参 t
的值类型)调用相应的函数重载版本,我们需要使用 std::forward
,如下所示:
#include <iostream>
void showValue(const int& val) { std::cout << "lvalue: " << val << std::endl; }
void showValue(int&& val) { std::cout << "rvalue: " << val << std::endl; }
template <typename T>
void print(T&& t) {
showValue(std::forward<T>(t));
}
int main() {
int a = 1;
print(a);
print(std::move(a));
print(10);
return 0;
}
代码中,std::forward<T>(t)
可以按 t
的传入类型相应地将其转化为左值或者右值,因此达到一个完美转发的效果。根据我们之前的推导有:
- 当传入类型为右值时,
T = int
,因此调用std::forward<int>(t)
可以将t
转为右值 - 当传入类型为左值时,
T = int&
,因此调用std::forward<int&>(t)
可以将t
转为左值
std::move 和 std::forward 的实现
要理解 std::move
和 std::forward
的功能,最好还是通过阅读源码的实现来理解。以下源码基于 GCC 9.3.0。
std::move
的实现如下所示:
/**
* @brief Convert a value to an rvalue.
* @param __t A thing of arbitrary type.
* @return The parameter cast to an rvalue-reference to allow moving it.
*/
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }
可以看到,std::move
只做了一件事情,即对传入类型进行静态类型转换为其对应去除引用后的类型(int&
,int
,int&&
通过 std::remove_reference<_Tp>::type
都会得到 int
)的右值引用,即将其转换为右值。
std::forward
的实现如下所示:
/**
* @brief Forward an lvalue.
* @return The parameter cast to the specified type.
*
* This function is used to implement "perfect forwarding".
*/
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }
/**
* @brief Forward an rvalue.
* @return The parameter cast to the specified type.
*
* This function is used to implement "perfect forwarding".
*/
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
" substituting _Tp is an lvalue reference type");
return static_cast<_Tp&&>(__t);
}
可以看到,std::forward
有两个重载版本,分别用来转发左值和右值。我们来看一下怎么通过模板参数来起作用。当传入参数的类型为 int
那么无论入参本身是左值引用还是右值引用,typename std::remove_reference<_Tp>::type = int
。
因此,当传入参数为右值引用时(参数本身可以是左值),即我们上面推导得到 _Tp = int
(_Tp = int&&
也不影响)。则会对应上 int&&
即:forward(typename std::remove_reference<_Tp>::type&& __t)
重载版本,因此会被静态类型转换为 int&&
即右值引用。
如果传入参数为左值引用时,我们上面推导得到 _Tp = int&
,则会对应上 forward(typename std::remove_reference<_Tp>::type& __t
的重载版本,同样通过静态类型转换返回 (int&) &&
,由于引用折叠的原因,被折叠为 int&
的左值引用。因此通过这两个重载函数,我们实现了无视入参本身的值类型,而是根据其初始化对象的类型进行转换。
因此,可以看出无论是 std::move
或者 std::forward
本质上都只是静态类型转换,不会在运行时有任何行为。
而由于带类型推断的 T&&
既可以接收左值引用也可以接收右值引用,一般称其为通用引用 (Universal Reference)或者转发引用 Forward Reference
。