详解C++模板的实参推断和move/forward

94 阅读7分钟

左值和右值

左值就是可以取地址的值,而右值包括两种:将亡值(使用move转化而来)和临时变量。无论是将亡值还是临时变量,我们可以放心的使用移动语义来“窃取”它们的资源,从而避免了拷贝/析构的开销。 在讲到模板之前,我们先讨论一下,在普通的函数中,我们常使用的一种函数传参方法:

class A;
void foo(A a);

这种类型的传参采用的是值语义,会调用类A的构造函数和析构各一次,为了避免拷贝/析构的开销,故我们多采用引用的方法,如下:

void foo(A& a);

采用引用类型可以减少传参过程中的开销,但它只能用来传递非常量左值,且无法处理右值,也即对于const A和字面值常量无法处理。故我们多采用指向常量的引用:

void foo(const A& a);

这种类型的形参不仅可以传递常量和非常量,还可以处理常量右值。这样说有点绕,我举个例子:

void foo(const string& str);

int main() {
    string s("he");
    const string cs("she");
    foo(s);
    foo(cs);
    foo("world");  //字面值常量,也即常量右值
}

以上传参都是合法的。

如何处理未知的返回类型

在模板中,类模板需要显示的指定模板实参,而函数模板可以由编译器帮我们推导出参数类型。 例如对于函数sum

template <typename T>
T sum(T a, T b) {
    return a + b;
}

int main() {
    int i = 10;
    float f = 12.9;
    double d = 19.3;
    sum(i, i);    //这里实例化为sum(int, int);
    sum(f, f);    //实例化为sum(float, float);
    sum(d, d);    //实例化为sum(double, double);
}

编译器会根据我们传递的参数类型,在使用到的时候为我们生成相应的函数版本。当编译器遇到一个模板定义时,它并不生成代码,只有当我们实例化出一模板的一个特定版本时,编译器才会生成代码。为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。 对于上面的函数而言,当我们想要指定返回类型的时候,需要再定义一个形参:

template <typename T1, typename T2, typename T3>
T1 sum(T1 a, T2 b) {
    return a + b;
}

这样的函数模板可以让不同类型的参数相加,同时返回一个指定的类型。但需要用户显示指定模板实参,参数类型在尖括号中给出。

int i = 10;
float f = 4.5;
auto d = sum<double>(i, f); //i和f由编译器推导出来,而返回值则在尖括号中指定

当我们希望用户确定返回类型时,用显式模板实参表示函数的返回类型是很有效的。但在其它情况下,会给用户带来负担。例如,我们编写一个函数,接收一对迭代器和返回序列中的一个引用。我们可以使用decltype来推导出返回类型。

template <typename T>
auto foo(T beg, T end) -> decltype(*beg) {
	//处理序列
	return *beg;
}

如果想要返回值而不是引用的话,则需要使用标准库的类型转换模板。

template <typename T>
//这里使用typename关键字是告诉编译器这是一个类型成员,而不是一个静态数据成员
auto foo(T beg, T end) -> typename remove_reference<decltype(*beg)>::type
{
	//处理序列
	return *beg;
}

模板实参推断和引用

  1. 当一个函数参数是模板参数类型的一个普通左值引用时候,它只能绑定一个左值。
template <typename T>
void f1(T&);
f1(i);    //i是int,则模板参数T是int
f1(ci);   //i是const int,模板参数T是const int
f1(5);    //错误,必须是一个左值

template <typename T>
void f2(const T&);
f2(i);   //i是int,模板参数T是int
f2(ci);  //ci是const int,模板参数T是int
f2(5);   //指向常量的引用可以绑定一个右值,T是int
//能绑定右值其实是因为根据这个右值生成了一个临时变量
  1. 从右值引用函数参数推断类型
template <typename T>
void f3(T&&);

f3(5)     //实参是一个int类型的右值,T是int
f3(i)     //实参是int,模板参数T经过引用折叠后变为int&
f3(ci)    //实参是const int,模板参数T经过引用折叠后变为const int&

可以发现:我们可以将任意类型的实参传递给T&&类型的函数参数如果一个函数参数是指向模板参数类型的右值引用(如,T&&),则可以传递给它任意类型的实参。如果将一个左值传递给这样的参数,则函数参数将被实例化一个普通的左值引用(T&)。

template <typename T>
void f3(T&& val) {
	T t = val; //是拷贝还是一个引用?
	t = fcn(t);   //赋值只改变t还是既改变t又改变val?
	if(val == t)  //若T是引用类型,则一直为true;
}
  • 当我们对一个右值调用f3时,例如字面值常量42,T为int。在此情况下,局部变量t的类型为int,且通过拷贝参数val的值被初始化。当我们对t赋值时,参数val保持不变。
  • 当我们对一个左值i调用f3时,则T为int&。当我们定义并初始化局部变量t时,赋予它类型int&。因此,对t的初始化将其绑定到val。当我们对t赋值时,也同时改变了val的值。在f3的这个实例化版本中,if判断永远得到true。
    这种情况令人困惑。所以使用右值引用的函数模板通常使用以下两个函数重载。
template <typename T> void f(T&&);       //绑定到非const右值;
template <typename T> void f(const T&);  //绑定到const右值和左值

remove_reference

remove_reference是标准库提供了一个函数模板,它可以帮我们去除参数的引用。具体实现原理可以参考如下:

template <typename T>
class remove_reference
{
public:
   typedef T type;
};

template<typename T>
class remove_reference<T&>
{
public:
   typedef T type;
};

template<typename T>
class remove_reference<T&&>
{
public:
   typedef T type;
}

move和forward

move是C++11表述库提供的一个模板函数,它可以将左值转化为右值。具体实现如下:

template <typename T>
typename remove_reference<T>::type&& move(T&& t) {
    return static_cast<typename remove_reference<T>::type&&>(t);
}

具体原理不再赘述。而forward则可以在模板函数的参数传递之间,保持原有的参数类型。例如

template<typename T>
void process(const T& lval);  // 传入的是左值,process以左值处理
template<typename T>
void process(T&& rval);       // 传入的是右值,process以右值处理

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

我们希望经过process函数可以对传入的不同参数调用不同的版本。我们先不考虑forward函数,把它去掉。

fun(8);
//此时模板参数T为int,会调用左值引用版本
int i = 10;
fun(i);
//此时模板参数T为int&,依然调用左值引用版本

forward函数实现如下:

// FUNCTION TEMPLATE forward
template <class _Ty>
// forward an lvalue as either an lvalue or an rvalue
constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept { 
    return static_cast<_Ty&&>(_Arg);
}

template <class _Ty>
// forward an rvalue as an rvalue
constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept { 
    static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
    return static_cast<_Ty&&>(_Arg);
}

forward函数右两个重载版本,当传入forward的参数为左值时,使用第一个版本,当传入的参数为右值时,使用第二个重载版本。

int i = 100;
fun(i);

传入一个左值,此时fun中T的类型为int&,param经过引用折叠后变为int&,在forward函数模板中,_Ty被推断出int&,。因此,在std::forward模板函数中,推断出_Ty的类型为int&,std::remove_reference用int& 进行实例化,std::remove_reference的type成员是int,在forward源码中有static_cast<int& &&>,则forward会返回左值引用类型

fun(9);

对于fun(9),传入的是一个右值, 那么fun中T的类型将是int,param类型是int&&。因此,在std::forward模板函数中,推断出_Ty的类型为int,std::remove_reference用int进行实例化,std::remove_reference的type成员是int,在forward源码中有static_cast<int&&>,则forward会返回右值引用类型