一、模板实参推断概述

  • 对于函数模板,编译器利用调用中的函数实参来确定其模板参数。从函数实参来确定模板实参的过程称为“模板实参推断”
  • 在模板实参推断过程中,编译器使用函数调用中的实参类型来寻找模板实参,用这些模板实参生成的函数版本与给定的函数调用最为匹配

二、类型转换与模板类型参数


类型转换

  • 与非模板函数一样,我们再一次调用中传递给函数模板的实参被用来初始化函数的形参。如果一个函数形参的类型使用了模板类型参数,那么它采用特殊的初始化规则。只有很有限的几种类型会自动地应用于这些实参。编译器通常不是堆实参进行类型转换,而是生成一个新的模板实例
  • 与平常一样,顶层const无论是在形参中还是在实参中,都会被忽略
  • 在类型转换中,只有下面两项规则适用于函数模板:
  • const转换:可以将一个非const对象的引用(或指针)传递给一个const的引用(或指针)形参
  • 数组或函数指针转换:如果函数形参不是引用类型,则可以对数组或函数类型的实参应用正常的指针转换
  • 一个数组实参可以转换为一个指向其首元素的指针
  • 类似的,一个函数实参可以转换为一个该函数类型的指针
  • 其他类型的转换全部都不能应用于函数模板:例如算术转换、派生类向基类的转换、用户自定义的转换
  • 有一个特殊例外:见文章下面的“正常类型转换应用于显式指定的实参”
  • 总结:将实参传递给带模板类型的函数形参时,能够自动应用的类型转换只有const转换及数组或函数到指针的转换
  • 演示案例:

template<typename T>
T fobj(T, T);

template<typename T>
T fref(const T&, const T&);

int main()
{
string s1("a value");
const string s2("another value");
fobj(s1, s2); //正确,调用fobj(string,string);s2的const会忽略
fref(s1, s2); //正确,调用fref(const string&,const string&);将s1转换为const是允许的

int a[10], b[42];
fobj(a, b); //调用f(int*,int*)
fref(a, b); //错误,数组类型不匹配

return 0;
}

  • 分析一下上面的fobj(a, b)是对的,而fref(a, b)为什么是错误的:
  • 我们传递了两个数组实参,两个数组大小不同,因此是不同的类型
  • 在fobj()的调用中,因为该函数的参数不是引用,因此两个数组都被转换为指针
  • 在fref()的调用中,因为形参是引用,所以数组不会转换为指针(参阅引用与数组的知识点)。a和b的类型不匹配,因此调用是错误的



使用相同模板参数类型的函数形参

  • 概述:一个模板类型参数可以用作多个函数形参的类型。由于只允许有限的几种类型转换(上面介绍),因此传递给这些形参的实参必须具有相同的类型。如果推断出的类型不匹配,则调用出错

演示案例

  • 下面是一个compare()函数模板的定义,模板要求两个参数的类型必须是一致的

template<typename T>
int compare(const T& r1, const T& r2)
{
if (r1 < r2)
return -1;
if (r1 > r2)
return 1;
return 0;
}

  • 因此下面的调用时错误的:

int main()
{
long lng;
compare(lng, 1024); //报错,不能实例化compare(long,int)

return 0;
}

  • 如果希望允许对函数进行上面的调用,可以为函数模板定义两个类型参数

template<typename A,typename B>
int compare(const A& r1, const B& r2)
{
if (r1 < r2)
return -1;
if (r1 > r2)
return 1;
return 0;
}

int main()
{
long lng;
compare(lng, 1024); //此时就是正确的了
return 0;
}



正常类型转换应用于普通函数实参

  • 上面介绍了模板参数的类型转换规则,但是这些规则对于非模板参数是不影响的

演示案例

  • 下面定义了一个模板,参数1是显式的类型,不是模板参数;参数2是模板参数

template<typename T>
ostream& print(ostream &os, const T& obj)
{
return os << obj;
}

  • 对于下面的调用都是正确的

#include<fstream> //for ofstream

int main()
{
print(cout, 42);

ofstream f("output");
print(f, 10);

return 0;
}


三、函数模板显式实参

  • 在某些情况下,编译器无法推断出模板实参的类型。其他一些情况下,我们希望用户控制模板实例化
  • 常见的使用情景:当函数返回类型化与参数列表中任何类型都不相同时


指定显式模板实参

  • 下面我们定义了一个sum()函数模板,其中有3种类型:

template<typename T1, typename T2, typename T3>
T1 sum(T2, t3)
{
}

  • 上面没有任何函数实参的类型可用来推断T1的类型,因此下面的调用是错误的

sum(1, 1); //错误,无法推断返回值的类型

  • 显式模板实参:所以每次调用sum时调用者都必须为T1提供一个显式模板实参。语法格式是调用是在函数名后面用<>指定

int main()
{
int i;
long lng;

sum<int,int,long>(i, lng); //调用int sum(int,long)

return 0;
}

  • 简化模板实参列表:由于上面的函数只是返回值类型推断不出来,但是两个参数的类型是可以自动推断出来的,因此上面的sum函数调用可以简化为

int main()
{
int i;
long lng;

sum<int>(i, lng); //调用int sum(int,long)

return 0;
}

  • 简化模板实参列表的规则:
  • 显示模板实参按从左到右的顺序与对应的模板实参匹配
  • 只有尾部(最右)参数的显示模板实参才可以忽略
  • 因此下面的代码是正确的,但是是低效的,因为我们需要每次调用时都要给出全部的参数类型

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

int main()
{
int i;
long lng;

sum<int,long,int>(i, lng); //正确,调用int sum(int,long)
sum<int>() //错误,不能推断出全部模板参数类型
sum<int,long>() //错误,不能推断出全部模板参数类型

return 0;
}



正常类型转换应用于显式指定的实参

  • 当我们可以显式指定了函数实参类型的时候,就可以进行正常的类型转换了(不需要遵守上面的转换规则了)

template<typename T>
int compare(const T& r1, const T& r2)
{
if (r1 < r2)
return -1;
if (r1 > r2)
return 1;
return 0;
}

int main()
{
long lng;

compare(lng, 1024); //正确,不能调用compare(long,int)
compare<long>(lng, 1024);//正确,实例化compare(long,long),1024被隐式类型转换了
compare<int>(lng, 1024); //正确,实例化compare(int,int),lng被隐式类型转换了

return 0;
}


四、尾指返回类型与类型转换


尾指返回类型

  • 在“三”中我们介绍了模板显式实参,当用户确定自己使用的模板返回的数据类型时,模板显式实参很有效果,但是却有两个弊端:
  • ①要求用户显式指定模板实参会增加负担
  • ②有时用户不确定模板的返回值类型是什么
  • 例如,下面的模板接受一个迭代器,返回值为迭代器所指元素的引用,但是我们不确定这个迭代器中元素的类型是什么

template<typename It>
??? &fcn(It beg,It end)
{
return *beg; //我们希望返回序列中一个元素的引用,但是不知道其数据类型
}

  • 上面的例子中,虽然我们可以使用decltype()来作用于*beg,尝试获取其表达式的类型,但是编译器在遇到函数模板被实例化之前是不知道*beg的数据类型的,因此下面的代码也是错误的

//错误的,函数被实例化之前,decltype无法获取类型
template<typename It>
decltype(*beg) fcn(It beg,It end)
{
return *beg;
}

  • 尾指返回类型:此时我们可以使用尾指返回类型(尾指返回类型语法可以参阅:
  • 现在我们可以编写下面的代码了

template<typename It>
auto fcn(It beg,It end)->decltype(*beg)
{
return *beg;
}


int main()
{
int arr[] = { 1,2,3 };
vector<int> vec;
list<std::string> _list;

//解引用运算符*返回一个左值,因此返回值类型如下
fcn(arr, arr + 3); //返回值类型为int*
fcn(vec.begin(), vec.end()); //返回值类型为int&
fcn(_list.cbegin(), _list.cend()); //返回值类型为const string&

return 0;
}



进行类型转换的标准库模板类(type_traits)

  • 关于traits机制可以参阅本人的STL源码剖析文章:
  • 在上面的介绍中我们虽然可以使用decltype来获得迭代器beg所指的元素数据类型的引用,但是这是非常局限的,例如某些时候我们希望返回的不是引用,而是非引用
  • 为了获得元素类型,我们可以使用标准库的“类型转换模板”。这些模板定义在头文件type_traits中,这个头文件的类通常用于所谓的模板元程序设计

C++:50---模板实参推断(附加:模板显式实参、type_traits、引用折叠、move()、forward())_右值引用

  • 例如我们可以使用remove_reference来获得元素类型。remove_reference模板有一个模板类型参数和一个名为type的(public)类型成员。如果我们传入一个引用类型实例化remove_reference,则type就是被引用的类型
  • 例如:

#include <type_traits>

int main()
{
//int&用来实例化remove_reference对象,type则返回int数据类型
remove_reference<int&>::type num = 10; //相当于int num=10

//同理
remove_reference<std::string&>::type word; //相当于string word
return 0;
}

  • 现在我们可以修改上面的fcn模板了,并为其声明一个非引用形式的返回值类型:

//这个模板将返回迭代器元素的非引用类型
//注意,因为type是一种数据类型,因此必须使用typename来表明
template<typename It>
auto fcn(It beg,It end)->typename remove_reference<decltype(*beg)>::type
{
return *beg;
}

  • 其他模板与remove_reference的工作原理都是相似的


五、函数指针和实参推断


函数指针与模板的使用

  • 当我们可以使用一个函数模板来初始化一个函数指针或者为一个函数指针赋值时,编译器会使用指针的类型来推断模板实参
  • 例如:

template<typename T>
int compare(const T&, const T&)
{
//...
}

int main()
{
//此时compare会被实例化,并且T为int
//pf1为函数指针
int(*pf1)(const int&, const int&) = compare;
pf1(10, 20); //调用函数
return 0;
}



重载与显式模板实参

  • 下面有两个重载函数,函数的参数都为函数指针类型:

template<typename T>
int compare(const T&, const T&)
{
//...
}

void func(int(*)(const string&, const string&));
void func(int(*)(const int&, const int&));

int main()
{
//错误的,不知道调用哪一版本的compare
func(compare);
return 0;
}

  • 上面代码的问题是通过func的参数类型无法确定模板实参的唯一类型。所以我们需要通过显式模板实参来消除func的调用

int main()
{
//正确,调用int版本的
func(compare<int>);
return 0;
}


六、模板实参推断和引用

  • 非常重要的两点:
  • 编译器会应用正常的引用绑定规则
  • const是底层的,不是顶层的


从左值引用函数参数推断类型

  • 规则如下:
  • 当一个函数参数是一个普通(左值)引用时(形如T&),则规定:只能传递给它一个左值(如一个变量、返回引用类型的表达式等)
  • 实参可以是const的,也可以不是
  • 例如:

template<typename T>
void f1(T&)
{
//...
}

int main()
{
int i;
const int ci=10;

f1(i); //T是int
f1(ci); //T是const int
f1(5); //错误的,f1必须传递一个左值(此处5为右值)

return 0;
}

  • 如果参数的类型是const T&:
  • 可以传递给它任何类型的实参:一个对象(const或非const)、一个临时对象、一个字面值常量值
  • 因为T的类型推断不会是一个const类型,因为const已经是函数参数的一部分了,所以不会是模板参数类型的一部分
  • 例如:

template<typename T>
void f1(const T&)
{
//...
}

int main()
{
int i;
const int ci=10;

f1(i); //T是int
f1(ci); //T还是int
f1(5); //正确,一个const &参数可以绑定到一个右值上,T是int
return 0;
}



从右值引用函数参数推断类型

  • 规则如下:
  • 当一个函数参数是一个右值引用(形如T&&)时,则可以传递给它一个右值
  • 推断过程类似于上面的普通左值引用函数参数的推断过程。推断出的T的类型是该右值实参的类型
  • 例如:

//参数类型为右值引用
template<typename T>
void f1(T&&)
{
//...
}

int main()
{
f1(5); //正确,T为int

return 0;
}



引用折叠、右值引用参数(重要)

  • 对于右值引用,有一些特殊的规则,如下:
  • 第一例外:当我们将一个左值传递给函数的右值引用参数,且此右值引用为模板参数类型(T&&)时,编译器推断模板类型参数为是实参的左值引用类型。因此我们将int i传递给f3()时,编译器会推断T的类型为int&,而非int
  • 第二例外:
  • 我们间接创建一个引用的引用,则这些引用形成了“折叠”
  • 在所有情况下(除第一例外),引用会折叠成一个普通的左值引用类型
  • 在新标准中,折叠规则扩展到右值引用。只在一种特殊情况下引用会折叠成右值引用:右值引用的右值引用
  • 即,对于一个给定类型X,有如下的折叠规则:

C++:50---模板实参推断(附加:模板显式实参、type_traits、引用折叠、move()、forward())_模板实参推断_02

  • 备注:引用折叠只能应用于间接创建的引用的引用,如类型别名或模板参数
  • 有了引用折叠规则和右值引用的特殊类型推断规则,意味着我们可以将一个左值引用传递给模板参数为右值引用的模板。例如:

template<typename T>
void f1(T&&){}

int main()
{
int i;
const int ci = 10;

f1(42); //正确,42为右值,T为int
f1(i); //正确,根据上面的第一例外,T是int&
f1(ci); //正确,根据上面的第一例外,T是const int &

return 0;
}

  • 上面的代码中i和ci的调用为什么会是int&呢?原因在于:
  • 根据上面的第一例外,左值传递给模板参数为右值(&&)的类型会被转换为&,因此最终传入模板参数的类型为 int& &&
  • 再根据上面的第二例外,X& &&类型会被折叠成X&形式,因此最终传入的T是int&类型
  • 上面的两个步骤可以折叠为下面的伪代码格式

//f1(i)调用的伪代码形式
void f1<int&>(int& &&);

//又因为int& &&会被折叠为&,所以最终的调用如下
void f1<int&>(int&);

  • 这两个规则导致了两个重要结果:
  • 如果一个函数参数是一个指向模板类型参数的右值引用(如,T&&),则它可以被绑定到一个左值上
  • 如果实参是一个左值,则推断出的模板实参类型就是一个左值引用,且函数参数将被实例化为一个(普通)左值引用参数(T&)
  • 另外值得注意的是,这两个规则暗示,我们可以将任意类型的实参传递给T&&类型的函数参数。对于这种类型的参数,(显然)可以传递给它右值,也可以传递给它左值(例如上面的演示案例)



编写接受右值引用参数的模板函数

  • 模板参数可以推断为一个引用类型,这一特性对模板内的代码可能产生令人意想不到的结果:

template<typename T>
void f3(T&& val)
{
T t = val; //使用参数为val赋初值(此处会有异议:是拷贝还是绑定引用?)
t = fcn(t); //简介调用fcn函数,可能会改变t

if(val==t)
//...
}

  • 上面代码可能会产生错误的原因:
  • 当传入f3函数一个右值时:例如为42,则T为int,因此在函数内t就是一个局部变量(类型为int),我们将val赋值给t,然后调用fcn操作t,最后将val与t进行比较,这样不会有什么问题
  • 当传入f3函数一个左值时:例如int i,然后将i传入f3()函数,则此时T为int&,那么t将为参数val的引用,因此在if进行比较时,val==t始终相等(因为val与t指向同一对象),随意此处会发生错误
  • 当代码中设计的类型可能是普通(非引用)类型,也可能是引用类型时,编写正确的代码就变得异常困难(虽然上面介绍的traits机制可能会有一些帮助)
  • 在实际中,右值引用通常用于两种情况:模板转发其实参、模板被重载(这两个将在后面介绍)
  • 目前应该注意的是,使用右值引用的函数模板通常使用我们在“右值引用”文章中介绍的方式来进行重载(参阅“右值引用和成员函数”:),代码如下:

template<typename T>
void f(T&&){} //绑定到非const右值

template<typename T>
void f(const T&&) {} //左值和const右值

  • 上面的代码与非模板函数一样:第一版本将绑定到可修改的右值,而第二版本将绑定到左值或const右值


七、理解std::move

  • 在右值引用的文章中我们介绍过move()函数,这个函数也是针对右值引用专门设计的一个函数
  • 在默认情况下,我们不能将一个左值赋值给一个右值引用,但是可以使用move()函数将一个左值赋值给一个右值引用
  • 关于move的基本使用可以参阅“右值引用”的文章​


std::move的定义

  • 标准库中对于std::move()的定义如下:

//下面的type是数据类型,因此全部都需要typename来声明
template<typename T>
typename remove_reference<T>::type&& move(T&& t)
{
//使用static_cast进行强制类型转换
return static_cast<typename remove_reference<T>::type&&>(t);
}

  • 实现原理:
  • ①在上面我们介绍的“引用迭代”以及相关案例可以知道,这个move()模板的参数为&&,因此可以接受任何的数据类型
  • ②使用remove_reference模板的type成员提取出参数的纯数据类型(已经去除了&、*等特性)
  • ③在函数内使用static_cast进行强制类型转换,将传入的参数强制转换为右值类型(&&)
  • ④因此,无论传入什么进来,最后返回的都是一个右值引用



std::move()工作原理的演示案例①

  • 我们将一个右值传入move()函数中:

std::string s1("hi!");

std::string s2 s2 = std::move(string("bye!"));

  • 由于move()函数内传入的是一个string构造函数的右值结果,所以:
  • ①move()推断出的T的类型为string
  • ②因此,remove_reference用string进行实例化
  • ③remove_reference<string>::type返回的是string
  • ④再使用static_cast将t转换为string&&,因此函数最终返回的数据类型就是string&&
  • 所以,上面的调用的实例化就是下面的伪代码形式:

//T为string,返回值类型为string&&
string&& move(string &&t);

std::move()工作原理的演示案例②

  • 我们将一个左值传入move()函数中:

std::string s1("hi!");
std::string s2 = std::move(s1);

  • 由于s1是一个左值,所以有:
  • ①move()推断出的T的类型为string&(根据上面的引用迭代机制)
  • ②因此,remove_reference用string&进行实例化
  • ③remove_reference<string>::type返回的是string
  • ④再使用static_cast将t转换为string&&,因此函数最终返回的数据类型就是string&&
  • 所以,上面的调用的实例化就是下面的伪代码形式:

//T为string,因为左值会被转换为引用(&)传入
string&& move(string& &&t);

//然后根据引用折叠机制,将参数string& &&折叠尾string&,所以有:
string&& move(string &t);



用static_cast将一个左值强制转换为右值引用是允许的

  • static_cast介绍参阅:​
  • 从move()的源码可以看出,static_cast可以将一个左值转换为右值引用,这是允许的
  • 对于操作右值引用的代码来说,将一个右值引用绑定到一个左值的特性允许它们截断左值。有时候这种截断是安全的
  • 虽然,我们可以直接编写这种类型转换代码,但使用标准库move()函数是容易得多的方式。而且,统一使用std::move()使得我们在程序中查找潜在的截断左值的代码变得很容易


八、转发、反转函数

  • 某些函数需要将其一个或多个实参连同“类型不变”地“转发”给其他函数
  • 在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否含有const以及实参是左值还是右值


一个错误演示案例

  • 下面编写了一个函数模板:
  • 其接受三个参数:参数1是一个可调用表达式,参数2、3是两个额外实参
  • 另外在函数模板内,将参数2和3逆序传递给可调用表达式

template<typename F,typename T1, typename T2>
void flip1(F f, T1 t1, T2 t2)
{
//将参数2和3虚拟的传入参数1表达式中
f(t2, t1);
}

  • 现在我们定义一个这样的函数,并且希望改变参数2的值:

//在函数内改变参数2,且参数2为一个引用
void f(int v1, int &v2)
{
std::cout << v1 << " " << ++v2 << std::endl;
}

  • 现在我们运行下面的代码:

int main()
{
int j;

//我们将f函数传入flip1中,并且希望f函数可以改变j的值,但事实上并没有改变j的值
flip1(f, j, 42);

return 0;
}

  • 上面代码会产生意想不到错误的原因:
  • ①我们将f()函数传入flip1()函数中,并在flip1()函数调用f()函数
  • ②由于j是以非引用的方式传入flip1函数中,因此传入flip1()函数中的j是一个拷贝,而不是实际上的j(非引用)
  • ③当我们在flip1()函数内将j传入f()函数中,并且改变j的值其实改变的是j的副本,而不是j实际本身
  • ④因此这个调用违反了我们当初f()函数的设计初衷:我们希望改变f()函数的参数2



定义能保持类型信息的函数参数

  • 为了通过反转函数传递一个引用,我们需要重写函数,使其参数能保持给定实参的左值和右值特性,以及保持const属性
  • 下面我们把上面的flip1()函数的参数2和参数3进行更改,都改为右值引用形式,如下:

template<typename F,typename T1, typename T2>
void flip2(F f, T1 &&t1, T2 &&t2)
{
f(t2, t1);
}

  • 更改了之后flip2()函数可以接受任何数据类型的参数,并且根据上面“六”中的右值引用与引用折叠相关特性,参数可以保留其左值引用/右值引用/const相关属性
  • 现在我们再一次调用f()函数:
  • 由于flip2()的参数2和参数3为&&,所以j是以引用的形式传递给flip2()函数的,因此在flip2()中调用f()的时候是将j的引用传给f()的,因此保留了j的左值引用特性,并且将这个特性也用在了f()函数中,所以这就达到了“转发”的最终目的

int main()
{
int j;

flip2(f, j, 42);

return 0;
}

C++:50---模板实参推断(附加:模板显式实参、type_traits、引用折叠、move()、forward())_右值_03

  • 上面版本的flip2()解决了很多问题。但是如果传入的参数1的函数是下面的格式,那么可能又会产生一些错误。例如:

//这个函数将被传递给flip2()参数1
void g(int &&i, int &j)
{
std::cout << i << " " << j << endl;
}

  • 现在我们将g()函数传递给flip2()函数,就会发生错误,原因如下:
  • 由于flip2()的参数2为&&形式,那么把42传入进函数之后,函数参数与其他任何变量一样,都是左值表达式,因此42在函数内部就是一个左值表达式了
  • 然后我们又把42传递给g()函数的参数1,但是由于g()函数的参数1为右值引用(&&),因此产生错误

int main()
{
int i;

//错误的,42被传入之后会变为一个左值,但是flip2()调用的g()函数的参数1为右值引用,因此会错误
flip2(g, i, 42);

return 0;
}



在调用中使用std::forword保持类型信息

  • 与move()函数相反,forword可以用来返回一个类型的右值引用格式(&&),也定义在头文件utility中
  • 与move()不同的是,forwoard需要显式模板实参来调用
  • forward<T>的返回类型是T&&,forward<T&>的返回类型是T&(根据T& &&折叠而成)
  • 通常情况下,我们使用forward()传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward()可以保持给定实参的左值/右值属性:

template<typename Type> intermediary(Type &&arg)
{
finalFcn(std::forward<Type>(arg);)
}

  • 本例中使用Type作为forward的显式模板实参类型,它获取arg的数据类型:
  • 如果Type是一个普通(非引用)类型,则forward<Type>返回Type&&(右值引用)
  • 如果Type是一个左值,那么通过引用折叠,Type那么本身就是一个左值引用类型(&),然后将左值引用类型传递给forward(),那么forward()将返回Type& &&,然后再根据引用折叠,那么最终该函数还是返回Type&(左值引用)

C++:50---模板实参推断(附加:模板显式实参、type_traits、引用折叠、move()、forward())_右值_04

  • 现在我们重写修改上面的flip1()函数,在内部调用f()函数的时候使用forward(),现在正确了:
  • i将以int&传递给g()的参数2,42将以int&&传递给g()的参数1(而不是以int&传递)

template<typename F, typename T1, typename T2>
void flip2(F f, T1 &&t1, T2 &&t2)
{
f(std::forward<T2>(t2), std::forward<T2>(t1));
}

void g(int &&i, int &j)
{
std::cout << i << " " << j << endl;
}

int main()
{
int i;
flip2(g, i, 42);
return 0;
}