1. 引用折叠
其实在学习并发编程——thread原理的时候,就说过引用折叠这件事,还记得我是怎么说的吗?
引用折叠问题,即
- 左值引用+左值引用->左值引用
- 左值引用+右值引用->左值引用
- 右值引用+右值引用->右值引用
凡是折叠中出现左值引用,优先将其折叠为左值引用
在类型推断中,如果传入的是一个左值,模板类型会自动将其推断为一个左值引用;而传入右值,模板类型会将其推断为右值:
1 | template <class F, class... Args> |
- 对于1:
Args推断m是int&类型,经过折叠后,int& &&->int&,仍然是int&。(注意,不会将其推断为int,虽然m确实是int类型,但是左值的类型在模板参数中会被视为它本身的引用类型) - 对于2:
Args推断m是int类型,经过折叠后,int&&->int&&,是int&&,右值引用。(注意,**右值会被推断为int类型而不是int&&**,int&&是右值引用类型而不是右值类型) - 对于3:
Args推断m是int&类型,并且经过ref包装后,thread和async内部不会对其使用delay解除cv修饰符和引用。 - 对于4:
Args推断m是int&&类型,经过折叠后,int&& &&->int&,仍然是int&&。
综上,我们可以用模板定义一个左值引用:
1 | //接受左值引用的模板函数 |
也可以用模板类型定义一个右值引用时,但是传递给该类型的实参类型,会根据C++标准进行引用折叠:
1 | //接受右值引用的模板函数 |
简而言之,当模板函数(或者模板类)的实参是一个T类型的右值引用:
- 传递给该参数的实参是一个右值(42)时, T就是该右值类型
- 传递给该参数的实参是一个左值(int a = 42)时, T就是该左值引用类型。
我们可以根据这个规律,实现一个类似STL的`move操作:
1 | template<typename T> |
该代码用于定义了一个自定义的 my_move 函数,类似于标准库的 std::move。它将参数 T 强制转换为右值引用,以实现引用折叠。通过 my_move 函数,我们可以将一个左值或右值强制转换为右值引用,从而允许调用方进行移动语义优化。
T&& t:实现引用折叠;remove_reference<T>::type:移除类型T上的引用(如果有的话),无论T是左值引用、右值引用,还是非引用类型,remove_reference<T>::type得到的都是原始的类型;- 例如,如果
T是int&或int&&,那么remove_reference<T>::type都会得到int(类型变成了int,但是值类型并不会改变)
- 例如,如果
typename remove_reference<T>::type&&:移除引用后,将该类型强制转换为右值引用(类型是右值引用),以便允许移动语义的优化static_cast:static_cast<typename remove_reference<T>::type&&>(t)将参数t转换为右值引用。这是核心操作,使得t可以被移动,而不是拷贝。- 它会将参数
t的类型转换为右值引用,但是它的值类型仍然不变。如果t的值类型本来是左值,那么转换后仍然是左值,只不过参数类型是右值引用。
- 它会将参数
若我们在函数中作如下调用:
1 | void use_tempmove() |
2. forward原样转发
我们同样在学习引用折叠的同时,简单了解过forward原样转发。
比如,在thread的模板类声明中:
1 | template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0> |
_Fn&&和_Args&&被称为转发引用,它们会根据传入参数的类型自动推断为左值引用或右值引用- 如果传入的参数是右值(比如使用
std::move),则_Fn&&和_Args&&会因为引用折叠被推导为右值引用;如果是左值,则会被推导为左值引用。- 当传入
_Args的类型是int&(左值)时,后面加&&->int& &&折叠为int&- 如果传递的是左值,类型
T会被推导为左值引用类型,即int&。这是因为左值的类型在模板参数中会被视为它本身的引用类型。
- 如果传递的是左值,类型
- 当传入
_Args的类型是int&(左值引用)时,后面加&&->int& &&折叠为int& - 当传入
_Args的类型是int(右值)时,后面加&&->int&&折叠为int&& - 当传入
_Args的类型是int&&(右值引用)时,后面加&&->int&&折叠为int&&
- 当传入
- 如果传入的参数是右值(比如使用
std::forward<Fn>(Fx)和std::forward<Args>(Ax)...会保留参数的值类别(左值或右值),确保可以进行适当的移动或拷贝。
STL 的
std::move其实是“去引用”的实现,为了将传入参数转换为右值引用,实现移动拷贝。但有时我们也需要保留传入参数的原本类型不变(左值、右值或引用),进行原样转发,std::forward就是为了实现此功能而定义的。
举例说明:
若我们定义如下模板函数:
1 | template <typename F, typename T1, typename T2> |
其中,F是模板函数对传入可调用对象类型自动推断的参数模板类型,f就相当于传入的可调用对象,t1和t2是可调用对象的参数。
如果我们想要将可调用对象传入模板函数flip1,通过对模板函数flip1内部的修改,间接影响到外部传入的值。这样可行吗?尝试一下:
1 | void ftemp(int v1, int &v2) |
假如我们定义了一个ftemp函数,其v2参数是一个左值引用,那我们将参数v2传入ftemp函数后,如果对传入参数v2进行修改,想必一定会影响到外部传入的值吧?
如果我们不将ftemp函数传入模板函数flip1中,那么对传入参数v2进行修改,确实会影响到外部传入的值。但是,如果我们将ftemp函数传入模板函数flip1中,就不会影响了。
我们打印上面代码的输出:
1 | 42 101 |
明明在ftemp函数中,我们将 v2++ 变为101,但为什么在外部的传入形参v2的实参j仍然是100?
因为ftemp的v2参数虽然是引用,但其实是flip1的形参t1的引用。t1只是形参(t1的类型是int而不会推断为int&,这和后面有区别。为什么后面传入左值会将其推断为int&,但这里却推断为了int,为什么?其实这涉及到值传递和引用传递的区别),修改t1并不能影响外边的实参j。想要达到修改实参的目的,需要将flip1的参数修改为引用。我们先实现修改后的版本flip2:
1 | template <typename F, typename T1, typename T2> |
将参数传入测试:
1 | int j = 100; |
输出为:
1 | 42 101 |
我们发现,传入的实参j确实被修改了。因为flip2的t1参数类型为T1的右值引用,当把实参j赋值给flip2时,T1变为int&,t1的类型就是int& &&,通过折叠t1变为int&类型。这样t1就和实参j绑定了,在flip2内部修改t1,就达到了修改j的目的。
但是flip2同样存在一个问题:如果flip2的第一个参数f是一个接受右值引用参数的函数,会出现编译错误。
我们实现一个形参参数为右值引用类型的函数gtemp:
1 | void gtemp(int &&i, int &j) |
如果我们将gtemp作为参数传递给flip2会报错:
1 | int j = 100; |
当我们将实参42传递给flip2的第二个参数时,T2被推断为int类型,t2经过引用折叠变为int&&类型。t2作为参数传递给gtemp的第一个参数时会报错。t2 此时的类型是 int&&,而 gtemp 函数第一个参数的类型也是 int&&,那么为什么会编译报错呢?
因为 42 是一个右值常量,当它被传递到 flip2 时,它的生命周期只存在于 flip2 调用开始的那一瞬间,而在 flip2 内部,42 被传递到 f(t2, t1); 时,已经不能保证其原有的右值特性。这会导致编译器无法确定如何安全地将 42 转换为一个右值引用传递给 gtemp。
具体来说,42 被传递到 flip2 时,T2 会被推导为 int&&,所以 t2 的类型变成 int&&。但当 flip2 内部调用 f(t2, t1); 时,t2 被直接传递给 gtemp 的第一个参数,而 t2 已经不再是一个临时的右值表达式,而是一个左值(因为 t2 是 flip2 的形参)。而gtemp第一个参数为右值引用类型,他需要接收右值,导致失败。
为什么
t2被推断为int&&(右值引用),但它却是左值引用呢?
尽管 t2 的类型是 int&&,但一旦你在 flip2 内部使用 t2,它就会变成了一个左值,因为 t2 是一个有名字的变量。C++ 标准中规定,有名字的右值引用变量会被视为左值(在 C++ 中,只有没有名字的对象才是右值。有名字的对象(无论它是左值引用、右值引用,还是普通的值)都能被当作左值来使用)。因此,在 f(t2, t1); 中传递 t2 时,它实际上是作为一个左值传递的。
也就是说,虽然 t2 的类型是 int&&,但在 f(t2, t1); 中,t2 是一个左值,而 gtemp 的第一个参数期望的是一个右值引用类型(int&&),这导致了编译错误。
举例说明:
1 | int&& x = 5; // x是一个右值引用绑定到右值 5 |
右值引用绑定到右值时,它本身成为了一个左值,可以被引用或传递给其他函数。
在此代码中,x 是一个右值引用,但它有一个名字,可以通过 x 访问它,改变它的值,或传递它给其他函数,它满足左值的特征。因此,尽管x最初是一个绑定到右值的引用,但它现在作为一个有名字的对象,就变成了左值。
上面的错误可以简化为:
1 | int i = 100; |
上面代码会报错,因为m虽然是一个右值引用,并且绑定到了一个右值 200 上,但因为它有了名字,所以成了一个左值。而左值不能绑定到右值引用 k 上。
我们如何解决这个问题?
1 | int i = 100; |
通过 int 强制类型转换,int(m) 创建了一个临时的右值(拷贝了 m 的值),这就相当于将 m 转换成了一个临时对象,自然也就可以将m绑定给k。
注意:m 本身的类型是右值引用,但是它的值类型是左值(有了名字),我们这里也只是通过in(m)构造了一个临时变量(右值)赋值给右值变量k。
- 类型为右值引用的变量,它的值类型可以是左值也可以是右值
- 类型为左值引用的变量,它的值类别只能是左值
当然也可以通过如下方式:
1 | int i = 100; |
总之就是将m转化为右值即可。大家要清楚的是即使m是一个int&&类型,但是它本身是一个左值(值类型是左值)。
综上所述,解决上面问题的办法就是实现一个flip函数,内部实现对T2,T1类型的原样转发。
1 | // 修改前 |
std::forward 用于转发传递给一个函数的参数,并确保它的值类别(是否是左值或右值)在转发时不发生变化(不仅保持参数的值类别,还保持其类型)。std::forward的实现如下:
1 | template <class _Ty> |
std::forward的精髓在配合模板时才能挥发出来,上层函数给个类型T,forward函数返回个T&&,这是个万能类型。作为对比,std::move函数返回remove_reference_t<T>&&,只能是个右值引用类型,而不可能能返回左值引用类型。如果程序员将代码写成std::forward<MyClass>(my_obj)的形式,完美转发是不发挥作用的,无论my_obj的性质是左还是右。forward的返回值是根据_Ty决定的,如果我们std::forward<Args&>(args)那么返回的是Args& &&,最后肯定还是Args&,返回一个左值引用;如果我们std::forward<Args>(args)那么返回的其实是Args&&,最后还是Args&&。所以 forward 才会实现传进来的左值返回也是左值引用,传进来右值返回是右值引用。而且forward 必须配合引用折叠(万能引用)才能实现进来的左值返回也是左值,传进来右值返回是右值。- remove_reference_t 是一个模板类,用于去除变量的引用,它可以接受左值、左值引用、右值引用三类,并将后面两个的引用去除,返回原本的类型:
1 | template <class _Ty> |
很简单,我们使用 remove_reference_t<_Ty>& _Arg 时,其实是返回 remove_reference<_Ty>的成员type,type会将引用去掉,仅返回推断的原本类型:
1 | template <class _Ty> |
std::forward<T>只是单纯的返回一个T&&,但是T的类型需要上层函数传入,如果T是int&,那么返回的其实也是int&;如果T是int&&或者int(右值引用模板参数中,右值会被推断为int,所以这里的int代表右值),返回的其实还是int&&。
3. 模板推导的基本规则
C++ 的模板类型推断在函数调用时通常会根据传入的实参类型来推断模板参数的类型。比如:
1 | template <typename T1, typename T2> |
当你调用:
1 | int m = 4; |
m 是一个 int 类型的左值,而 3.14 是一个 double 类型的右值。T1会被推导为int,而不是int&;3.14会被推导为double。
模板类型推断的规则如下:
- 类型推断:模板推导时,C++ 会选择 按值传递 类型,而不是推断为左值引用(比如int&),除非模板参数明确要求使用引用类型(如通过引用传递参数或者显式声明为引用类型)。。
对于参数
T&&,模板推导的行为有一些特殊之处,因为它同时适用于 左值 和 右值
1 | template <typename T1, typename T2> |
- 在这里,
T1会被推导为int&,因为m是左值,而左值传递给右值引用 (T1&&) 会根据 C++ 引用折叠规则推导出T1为int&(**int& &&->int&**)。 - 在模板
T2&& b中,T2会被推导为double,因为右值传递给右值引用模板会保留原始类型,最后根据引用折叠规则推导出double&&。
这是因为 C++ 中的 右值引用 模板推导规则有点特别,尤其是在传递左值时。
- 当你将一个 左值 传递给
T&&(右值引用)类型的模板时,C++ 的模板推导会选择 右值引用 推导为 左值引用,也就是T会被推导为T&,而不是T。 - 因此,在传递一个左值(如
m)时,T&&会推导为int& &&,而不是int&&,因为左值引用 (int&) 是绑定到左值的,此时形参类型被推断为左值引用 (int&) ,可以用来接收类型为左值的实参。
右值引用模板参数(T&&) 是为了能够接受 左值 和 右值,而对左值使用时,C++ 会根据 引用折叠规则 将 T&& 推导为 T&,这样左值就可以绑定到左值引用上。
但是左值引用模板参数(T&)会推断传入的左值类型为int,因为它本身就可以接受左值,所以不需要推断为int&。但它不能绑定右值,左值引用不会接受一个右值。
总结:
T&(左值引用) 只能绑定 左值,不能绑定右值。T&&(右值引用) 主要用于绑定 右值,并且可以通过引用折叠将左值传递给右值引用类型时变为左值引用(T&)。