C++11——万能模板及完美转发

类型分类

  • C++11以后,进一步对类型进行了划分,右值被划分为纯右值(pure value,简称prvalue)和将亡值(expiring value,简称xvalue)。
  • 纯右值是指那些字面值常量或求值结果相当于字面值或是一个不具名的临时对象。如:truenullptr,或者类似42str.substr(1, 2)str1 + str2、传值返回的函数调用,或者a++a + b等。纯右值和将亡值是C++11中提出的,C++11中的纯右值概念划分等价于C++98中的右值。
  • 将亡值是指返回右值引用的函数的调用表达式和转换为右值引用的转换函数的调用表达式,如move(x)static_cast<X&&>(x)
  • 泛左值(generalized value,简称glvalue),泛左值包含将亡值和左值。
  • 值类别的详细定义可以参考cppreference.comValue categories这两个关于值类别的中文和英文的官方文档,有兴趣可以了解细节。

引用折叠

  • 在C++中,不能直接定义引用的引用如int& && r = i;,这样写会直接报错,通过模板或typedef中的类型操作可以构成引用的引用。
  • 通过模板或typedef中的类型操作可以构成引用的引用时,这时C++11给出了一个引用折叠的规则:右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用。
  • 下面的程序中很好地展示了模板和typedef时构成引用的引用时的引用折叠规则。
  • 像f2这样的函数模板中,T&&x参数看起来是右值引用参数,但是由于引用折叠的规则,他传递左值时就是左值引用,传递右值时就是右值引用,有些地方也把这种函数模板的参数叫做万能引用。
  • Function(T&&t)函数模板程序中,假设实参是int右值,模板参数T的推导int,实参是int左值,模板参数T的推导int&,再结合引用折叠规则,就实现了实参是左值,实例化出左值引用版本形参的Function,实参是右值,实例化出右值引用版本形参的Function。
cpp 复制代码
 //由于引用折叠限定,f1实例化以后总是一个左值引用
template<class T>
void f1(T& x)
{}

// 由于引用折叠限定,f2实例化后可以是左值引用,也可以是右值引用
template<class T>
void f2(T&& x)
{}

int main()
{
	/*int i = 0;
	int&&& r = i;*/
	typedef int& lref;
	//typedef int&& rref;
	using rref = int&&;
	int n = 0;

	// 引用折叠
	lref& r1 = n; // r1 的类型是 int&
	lref&& r2 = n; // r2 的类型是 int&
	rref& r3 = n; // r3 的类型是 int&
	rref&& r4 = 1; // r4 的类型是 int&&

	// 没有折叠->实例化为void f1(int& x)
	f1<int>(n);
	f1<int>(0); // 报错

	// 折叠->实例化为void f1(int& x)
	f1<int&>(n);
	f1<int&>(0); // 报错

	// 折叠->实例化为void f1(int& x)
	f1<int&&>(n);
	f1<int&&>(0); // 报错

	// 折叠->实例化为void f1(const int& x)
	f1<const int&>(n);
	f1<const int&>(0);

	// 折叠->实例化为void f1(const int& x)
	f1<const int&&>(n);
	f1<const int&&>(0);

	// 没有折叠->实例化为void f2(int&& x)
	f2<int>(n); // 报错
	f2<int>(0);

	// 折叠->实例化为void f2(int& x)
	f2<int&>(n);
	f2<int&>(0); // 报错

	// 折叠->实例化为void f2(int&& x)
	f2<int&&>(n); // 报错
	f2<int&&>(0);

	return 0;
}

万能引用

上面代码中的第二个模板其实就是万能引用的模板,模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。比如:

cpp 复制代码
template<class T>
void Function(T&& t)
{
	//...
}

右值引用和万能引用的区别就是,右值引用需要是确定的类型,而万能引用是根据传入实参的类型进行推导,如果传入的实参是一个左值,那么这里的形参t就是左值引用,如果传入的实参是一个右值,那么这里的形参t就是右值引用。

  • 如果传入一个左值 A,模板参数 T 被推导为 A&

  • 如果传入一个右值 A,模板参数 T 被推导为 A

cpp 复制代码
// 万能引用
template<class T>
void Function(T&& t)
{
	int a = 0;
	T x = a;
	//x++;

	cout << &a << endl;
	cout << &x << endl << endl;
}

int main()
{
	// 10是右值,推导出T为int,模板实例化为void Function(int&& t)
	Function(10); // 右值

	int a;
	// a是左值,推导出T为int&,引用折叠,模板实例化为void Function(int& t)
	Function(a); // 左值

	// std::move(a)是右值,推导出T为int,模板实例化为void Function(int&& t)
	Function(std::move(a)); // 右值

	const int b = 8;
	// b是左值,推导出T为const int&,引用折叠,模板实例化为void Function(const int&t)
	// 所以Function内部会编译报错,x不能++
	Function(b); // const 左值

	// std::move(b)右值,推导出T为const int,模板实例化为void Function(const int&&t)
	// 所以Function内部会编译报错,x不能++
	Function(std::move(b)); // const 右值

	return 0;
}

为什么需要转发?

下面重载了四个Fun函数,这四个Fun函数的参数类型分别是左值引用、const左值引用、右值引用和const右值引用。在主函数中调用Function函数时分别传入左值、右值、const左值和const右值,在Function函数中再调用Func函数。如下:

cpp 复制代码
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }
template<class T>
void Function(T&& t)
{
	Fun(t);
}

int main()
{
	int a = 10;
	Fun(a);       //左值
	Fun(move(a)); //右值

	const int b = 20;
	Fun(b);       //const 左值
	Fun(move(b)); //const 右值


	return 0;
}

由于Function函数的参数类型是万能引用,因此既可以接收左值也可以接收右值,而我们在Function函数中调用Fun函数,就是希望调用Function函数时传入左值、右值、const左值、const右值,能够匹配到对应版本的Fun函数。

  • 但实际调用Function函数时传入左值和右值,最终都匹配到了左值引用版本的Fun函数,调用Function函数时传入const左值和const右值,最终都匹配到了const左值引用版本的Fun函数。
  • 根本原因就是,上面的模板中Fun(t)参数中虽然t是右值引用,但别忘了上一篇博客我们说过右值引用变量属于左值。你传进去的参数t是左值,并且不要以为把t改为move(t)就可以解决问题,实际上还是差不多,改变后全变成右值引用而达不到我们期望的那样。

也就是说,右值经过一次参数传递后其属性会退化成左值,如果想要在这个过程中保持右值的属性,就需要用到完美转发。

这就是"转发问题":如何在模板函数中保持参数原始的值类别(左值/右值)和const/volatile属性,并将其原封不动地传递给下一个函数?

完美转发

要想在参数传递过程中保持其原有的属性,需要在传参时调用forward函数。比如:

cpp 复制代码
// 万能引用
template<class T>
void Function(T&& t)
{
	Fun(forward<T>(t));
}
  • Function(T&& t)函数模板程序中,传左值实例化以后是左值引用的Function函数,传右值实例化以后是右值引用的Function函数。
  • 但是结合我们在上篇博客的讲解,变量表达式都是左值属性,也就意味着一个右值被右值引用绑定后,右值引用变量表达式的属性是左值,也就是说Function函数中t的属性是左值,那么我们把t传递给下一层函数Fun,那么匹配的都是左值引用版本的Fun函数。这里我们想要保持t对象的属性,就需要使用完美转发实现。
cpp 复制代码
template <class T> T&& forward (typename remove_reference<T>::type& arg);
  • 完美转发forward本质是一个函数模板,他主要还是通过引用折叠的方式实现,下面示例中传递给Function的实参是右值,T被推导为int,没有折叠,forward内部t被强转为右值引用返回;传递给Function的实参是左值,T被推导为int&,引用折叠为左值引用,forward内部t被强转为左值引用返回。

完美转发的使用场景

cpp 复制代码
// 万能引用
template<class T>
void Function(T&& t)
{
	Fun(forward<T>(t));
}

int main()
{
	// 10是右值,推导出T为int,模板实例化为void Function(int&& t)
	Function(10); // 右值

	int a;
	// a是左值,推导出T为int&,引用折叠,模板实例化为void Function(int& t)
	Function(a); // 左值

	// std::move(a)是右值,推导出T为int,模板实例化为void Function(int&& t)
	Function(std::move(a)); // 右值
	const int b = 8;

	// b是左值,推导出T为const int&,引用折叠,模板实例化为void Function(const int&t)
	Function(b);

	// std::move(b)右值,推导出T为const int,模板实例化为void Function(const int&& t)
	Function(std::move(b)); // const 右值

	return 0;
}

完美转发的原理

cpp 复制代码
template<typename T>
T&& forward(typename std::remove_reference<T>::type& arg) noexcept {
    return static_cast<T&&>(arg);
}
  1. typename std::remove_reference<T>::type& arg: 这一部分确保 arg 总是一个左值引用。

  2. static_cast<T&&>(arg): 这是核心。转换的结果取决于 T 的推导类型。

当传入左值时:

  • T 被推导为 MyType&

  • T&& 经过引用折叠后是 MyType& && -> MyType&

  • static_cast<MyType&>(arg): 将一个左值转换为左值引用,什么也不做,返回的依然是左值。

当传入右值时:

  • T 被推导为 MyType

  • T&& 就是 MyType&&

  • static_cast<MyType&&>(arg): 将一个左值 arg 强制转换为右值引用。这正是 std::move 的行为。

结论std::forward<T>(arg) 做的事情是:如果原始传入的参数是右值,它就将 arg 转换为右值;如果原始传入的参数是左值,它就什么都不做,让 arg 保持为左值。它完美地"转发"了参数的值类别。

扩展思路

理解了完美转发的原理,其实如果你读过我list底层实现这篇博客,你会发现我们在实现list底层const迭代器和非const迭代器时,也是利用相似的原理​。核心思想都是通过模板参数和类型推导,让编译器自动生成不同版本的代码。