一个函数模板就是一个公式,用于生成针对类型的函数版本。模板定义以一个关键字template
开始,后跟一个模板参数列表(逗号分隔的一个或多个模板参数的列表)。
// 比较大小的函数模板
template
int compare(const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
当我们调用一个函数模板时,编译器通常用函数实参为我们推断模板实参,然后用推断出来的模板参数来为我们实例化一个特定版本的函数。例如:
// T为int, 实例化出int compare(const int&, const int&)
std::cout << compare(1, 0) << std::endl;
Tips:类型参数必须使用关键字
class
或者typename
,由于typename
是在模板已经广泛使用后才引入C++语言的,某些程序员仍然只使用class
。
一般来说,我们可以将类型参数看做类型说明符,就像内置类型或类类型说明符一样使用。特别是,类型参数可以用来指定返回类型或者函数的参数类型,以及在函数体内用于遍历声明或类型转换:
template
T foo(T *p) {
T tmp = *p;
// ...
return tmp;
}
除了定义类型参数,还可以在模板中定义非类型参数(非类型参数表示一个值而非一个类型)。当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替,这些值必须是常量表达式,从而允许编译器在编译时实例化模板。
举个例子,我们可以编写一个compare
版本处理字符串字面常量:
// 第一个模板参数表示第一个数组的长度, 第二个模板参数表示第二个数组的长度
// 由于不能拷贝数组, 因此我们将自己的参数定义为数组的引用
template
int compare(const char(&p1)[N], const char(&p2)[M]) {
return strcmp(p1, p2);
}
// 实例化出int compare(const char (&p1)[3], const char(&p2)[4])
compare("hi", "cat");
Tips:非类型模板参数的模板实参必须是一个常量表达式。
一个非类型模板参数可以是一个整型,或者是一个指向对象或函数类型的指针(或左值引用):
nullptr
或值为0的常量表达式来实例化)函数模板可以声明为inline
或constexpr
,这两个说明符要放在模板参数列表之后,返回类型之前:
template inline T min(const T&, const T&);
对于函数模板,编译器利用调用中的函数实参来确定其模板参数。从函数实参来确定模板实参的过程被称为模板实参推断(template argument deduction)。在模板实参推断过程中,编译器使用函数调用中的实参类型来寻找模板实参,用这些模板实参生成的函数版本与给定的函数调用最为匹配。
只有很有限的几种类型转换会自动地应用于这些实参,编译器通常不是对实参进行类型转换,而是生成一个新的模板实例。与往常一样,顶层const
无论是在形参中还是在实参中都会被忽略。在其他类型转换中,能在调用中应用于函数模板的包括如下两项:
const
转换:可以将一个非const
对象的引用(或指针)传递给一个const
的引用(或指针)形参Tips:其他类型转换,例如算数转换、派生类向基类的转换以及用户定义的转换都不能用于函数模板。
当函数返回类型与参数列表中任何类型都不相同时,编译器无法推断出模板实参的类型,我们希望用户控制模板实例化。假设我们定义一个sum
函数模板,它接受两个不同类型的参数,我们希望用户指定结果的类型从而控制合适的精度。
// 编译器无法推断T1, 它未出现在函数参数列表中
template
T1 sum(const T2 &v1, const T3& v2) { return v1 + v2; }
在上面例子中,由于没有任何函数实参的类型可以用来推断T1
的类型,因此每次调用sum
时调用者必须为T1
提供一个显式模板实参。
// T1是显式指定的, T2和T3是从函数实参类型推断而来的
// long long sum(int, long);
int i = 10;
long lng = 100;
auto res = sum(i, lng);
需要注意的是显式模板实参按从左到右的顺序与对应的模板参数匹配,只有尾部(最右)参数的显式模板实参才可以忽略,前提是他们可以从函数参数推断出来。注意下面的写法是糟糕的,用户必须制定所有三个模板实参:
// 糟糕的设计: 用户必须制定所有三个模板参数
template
T3 alternative_sum(const T2 &v1, const T1& v2) { return v1 + v2; }
// 错误: 不能推断前几个模板参数
auto res = alternative_sum(i, lng);
// 正确: 显式指定了所有三个参数
auto res = alternative_sum(i, lng);
当我们希望用户确定返回类型时,用显式模板实参表示模板函数的返回类型是很有效的。但是在其他情况下,要求显式指定模板实参会给用户增添额外负担,而且不会带来什么好处。我们可以使用尾置返回类型来指定函数的返回类型:
// 尾置返回类型允许我们在参数列表之后声明返回类型
// 该函数模板接收表示序列的一对迭代器并返回序列中一个元素的引用
template
auto foo(It beg, It end) -> decltype(*beg) {
// ... 处理序列
return *beg;
}
有时候为了获取元素类型,我们可以使用标准库的类型转换模板,这些模板定义在type_traits
中。如果我们用一个引用类型实例化remove_reference
,则type
类型成员将表示被引用的类型。例如我们实例化remove_reference
,则type
成员将是int
。
template
// 为了使用模板参数的成员, 必须用typename
auto foo(It beg, It end) -> typename remove_reference::type {
// ...处理序列
return *beg; // 返回序列中一个元素的拷贝
}
当一个函数参数是模板类型参数的一个普通左值引用时(即形如T&
),绑定规则告诉我们只能传递给它一个左值(如一个变量或一个返回引用类型的表达式)。实参可以是const
类型也可以不是,如果实参是const
的,则T
将被推断为const
类型。
template void f1(T&); // 实参必须是一个左值
f1(i); // i是一个int, 模板参数T是int
f1(ci); // ci是一个const int; 模板参数T是const int
f1(5); // 错误: 传递给一个&参数必须是一个左值
当一个函数参数是模板类型参数的一个const
左值引用(即形如const T&
),绑定规则告诉我们可以传递给它任何类型的实参——一个对象(const
或者非const
的)、一个临时对象或是一个字面常量值。
template void f2(const T&); // 实参可以是任何类型(包括右值)
f2(i); // i是一个int, 模板参数T是int
f2(ci); // ci是一个const int, 模板参数T是int
f2(5); // 字面常量值, 模板参数T是int
当一个函数参数是一个右值引用时(即形如T&&
),绑定规则告诉我们可以传递给它一个右值,推断出的T
类型是该右值实参的类型:
template void f3(T&&);
f3(42); // 实参是一个int类型的右值, 模板参数T是int
假定i
是一个int
对象,我们可能认为像f3(i)
这样的调用是不合法的。毕竟i
是一个左值,而通常我们不能将一个右值引用绑定到一个左值上,但是C++语言在正常绑定规则之外定义了两个例外规则允许这种绑定,这两个规则也是move
这种标准库设施正常工作的基础。
第一个例外规则:当我们将一个左值(如
int
左值i
)传递给函数的右值引用参数,且此右值引用指向模板类型参数(如T&&
)时,编译器推断模板类型参数为实参的左值引用类型。
基于第一个例外规则,当我们调用f3(i)
时,编译器推断T
类型为int &
而非int
。T
被推断为int &
看起来好像意味着f3
的函数参数应该是一个类型为int &
的右值引用,但是基于第二个例外规则它会被折叠成左值引用。
第二个例外规则:如果我们间接创建一个引用的引用(通过类型别名或者模板类型参数间接定义),则这些引用形成了“折叠”。在除右值引用的右值引用之外所有的情况下,引用会折叠成一个普通的左值引用类型。
对于一个给定类型
X
:
X& &
、X& &&
和X&& &
都折叠成X&
X&& &&
折叠成X&&
将两个例外规则组合起来,意味着我们可以对一个左值调用f3
:
f3(i); // i是一个int, 模板参数T是int&
f3(ci); // ci是一个const int, 模板参数T是一个const int&
Tips:上面两个例外规则暗示我们可以将任意类型的实参传递给
T&&
类型的函数模板参数。
前面提到模板参数可以推断为一个引用类型,这一特性对模板内的代码可能有令人惊讶的影响:
template void f3(T&& val) {
T t = val; // 拷贝还是绑定一个引用?
t = fcn(t); // 赋值只改变t还是既改变t又改变val?
if (val == t) { // 若T是引用类型, 则一直为true
// ...
}
}
当我们对一个右值(例如字面值42)调用f3
时,T
被推断为int
。在此情况下局部变量t
的类型为int
,且通过拷贝参数val
的值被初始化。当我们对t
赋值时,参数val
保持不变。
当我们对一个左值i
调用f3
时,则T
被推断为int&
。当我们定义并初始化局部变量t
时,赋予它类型int&
。因此对t
的初始化将其绑定到val
。当我们对t
赋值时,也同时改变了val
的值。在f3
的这个实例化版本中,if
判断将永远为true
。
在实际开发中,右值引用通常用于模板转发其实参或模板被重载两种情况。使用右值引用的函数模板通常以如下方式进行重载:
template void f(T&&); // 绑定到非const右值
template void f(const T&); // 左值和const右值
某些函数需要将其一个或多个实参连同参数不变地转发给其他函数。在此情况下我们需要保持被转发实参的所有性质,包括实参类型是否是const
的一级实参是左值还是右值。
举个例子,我们编写接受一个可调用表达式和两个额外实参的函数。我们的函数将调用给定的可调用对象并将两个额外参数逆序传递给它。下面是我们翻转函数的初步模样:
// 接受一个可调用对象与另外两个参数的模板, 对"翻转"的参数调用给定的可调用对象
// flip1是一个不完整的实现: 顶层const和引用丢失了
template void flip1(F f, T1 t1, T2 t2) {
f(t2, t1);
}
上面的函数一般情况下都能正常工作,但是当我们希望它调用一个接受引用参数的函数时就会出现问题:
#include
// 接受一个可调用对象与另外两个参数的模板, 对"翻转"的参数调用给定的可调用对象
// flip1是一个不完整的实现: 顶层const和引用丢失了
template void flip1(F f, T1 t1, T2 t2) {
f(t2, t1);
}
void f(int v1, int &v2) {
std::cout << v1 << " " << ++v2 << std::endl;
}
int main() {
int i = 10;
f(100, i); // 输出100, 11
std::cout << i << std::endl; // i被修改了, 输出11
int j = 10;
flip1(f, j, 100); // 输出100, 10
std::cout << j << std::endl; // j不会被修改, 输出10(f函数丢失了j的左值引用)
return 0;
}
Tips:如果一个函数参数是指向模板类型参数的右值引用(如
T&&
),它对应实参的const
属性和左值/右值属性将得到保持。
通过将一个函数参数定义为一个指向模板类型参数的右值引用,我们可以保持其对应实参的所有类型信息。而使用引用参数(无论是左值还是右值)使得我们可以保持const
属性,因为在引用类型中const
是底层的。如果我们将函数参数定义为T1&&
和T2&&
,通过引用折叠就可以翻转实参的左值/右值属性:
#include
template void flip2(F f, T1 &&t1, T2 &&t2) {
f(t2, t1);
}
void f(int v1, int &v2) {
std::cout << v1 << " " << ++v2 << std::endl;
}
int main() {
int i = 10;
f(100, i); // 输出100, 11
std::cout << i << std::endl; // i被修改了, 输出11
int j = 10;
flip2(f, j, 100); // j递增: 输出100, 11
std::cout << j << std::endl; // j被修改了, 输出11
return 0;
}
这个版本的flip2
解决了一半问题,它对于接受一个左值引用的函数工作得很好,但不能用于接收右值引用参数的函数:
#include
template void flip1(F f, T1 &&t1, T2 &&t2) {
f(t2, t1);
}
void g(int &&v1, int &v2) {
std::cout << v1 << " " << v2 << std::endl;
}
int main() {
// 错误: 不能从一个左值实例化int&&
// error: cannot bind ‘int’ lvalue to ‘int&&’
int j = 10;
flip1(g, j, 100);
return 0;
}
我们可以使用一个定义在utility
头文件中名为forward
的新标准库设施来传递flip3
的参数,它能保持原始实参的类型。与move
不同,forward
必须通过显式模板实参来调用,它返回该显式实参类型的右值引用,即forward
的返回类型是T&&
。
通常情况下,我们使用forward
传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward
可以保持给定实参的左值/右值属性。
#include
template void flip3(F f, T1 &&t1, T2 &&t2) {
f(std::forward(t2), std::forward(t1));
}
void g(int &&v1, int &v2) {
std::cout << v1 << " " << ++v2 << std::endl;
}
int main() {
int j = 10;
flip3(g, j, 100); // 输出100, 11
std::cout << j << std::endl; // j被修改了, 输出11
return 0;
}
函数模板可以被另一个模板或一个普通非模板函数重载。与普通的重载一样,名字相同的函数必须具有不同数量或类型的参数。
如果涉及函数模板,则函数匹配规则会在以下几方面收到影响:
我们构造一组调试函数debug_rep
,每个函数都返回一个给定对象的string
表示。我们首先编写此函数的最通用版本,将它定义为一个模板,接受一个const
对象的引用:
// 打印任何我们不能处理的类型: 该对象可以是任意具有输出运算符的类型
template string debug_rep(const T &t) {
ostringstream ret;
ret << t;
return ret.str();
}
接下来我们定义打印指针的debug_rep
版本:
// 注意此函数不能用于char*对象, 因为IO库为char*值定义了一个<<版本, 此版本假定指针表示一个空字符结尾的字符数组, 并打印数组的内容而非地址值
template string debug_rep(T *p) {
ostringstream ret;
ret << "pointer: " << p; // 打印指针本身的值
if (p) {
ret << " " << debug(*p); // 打印p指向的值
} else {
ret << " null pointer"; // 或者指出指针p为空
}
return ret.str();
}
我们可以这样使用这些函数:
// 传递的是一个非指针对象, 因此只有第一个版本的debug_rep是可行的
std::string s("hi");
std::cout << debug_rep(s) << std::endl;
如果我们用一个指针调用debug_rep
:
std::cout << debug_rep(&s) << std::endl;
那么两个函数都生成可行的实例:
debug_rep(const string* &)
,由第一个版本的debug_rep
实例化而来,T
被绑定到string*
debug_rep(string*)
,由第二个版本的debug_rep
实例化而来,T
被绑定到string
第二个版本的实例是此调用的精确匹配,第一个版本需要进行普通指针到const
指针的转换,编译器会选择第二个debug_rep
版本。
考虑下面这个例子,它提供了多个可行模板:
const string *sp = &s;
std::cout << debug_rep(sp) << std::endl;
此时两个模板都是可行的,而且都是精确匹配:
debug_rep(const string* &)
,由第一个版本的debug_rep
实例化而来,T
被绑定到string*
debug_rep(const string*)
,由第二个版本的debug_rep
实例化而来,T
被绑定到const string
在此情况下,正常函数匹配规则无法区分这两个函数,我们可能觉得这个调用是有歧义的。但是根据重载函数模板的特殊规则,此调用被解析为debug_rep(T*)
,即更特例化的版本。
Tips:当有多个重载模板对一个调用提供同样好的匹配时,应该选择最特例化的版本。
设计这条规则的原因在于,如果没有它将无法对一个
const
的指针调用指针版本的debug_rep
。问题在于模板debug_rep(const T&)
本质上可以用于任何类型,包括指针类型。此模板比debug_rep(T*)
更通用,后者只能用于指针类型。没有这条规则的话,传递const
指针的调用永远是有歧义的。
Tips:对于一个调用,如果一个非函数模板与一个函数模板提供同样好的匹配,则选择非模板版本。
我们再定义一个普通非模板版本的debug_rep
:
std::string debug_rep(const std::string &s) {
return '""' + s + '""';
}
此时我们对一个string
调用debug_rep
时:
std::string s("hi");
std::cout << debug_rep(s) << std::endl;
有两个同样好的可行函数:
debug_rep(const string&)
,由第一个版本的debug_rep
实例化而来,T
被绑定到string
debug_rep
,普通非模板函数在本例中两个函数具有相同的参数列表,因此显然两者提供同样好的匹配,但是编译器会选择非模板版本。当存在多个同样好的函数模板时,编译器选择最特例化的版本,处于同样的原因,一个非模板函数比一个函数模板好。
对于C风格字符串和字符串字面常量而言,考虑如下调用:
// 调用debug_rep(T*)
std::cout << debug_rep("tomocat") << std::endl;
下面三个debug_rep
版本都是可行的:
debug_rep(const T&)
:T
被绑定到char[10]
debug_rep(T*)
:T
被绑定到const char
debug_rep(const string&)
:要求从const char*
到string
的类型转换对于给定参数而言,前两个函数模板版本都提供精确匹配(虽然第二个模板需要进行数组到指针的转换,但是对于函数匹配而言,这种转换被认为是精确匹配)。非模板版本是可行的,但是需要进行一次用户定义的类型转换,因此它没有精确匹配那么好,所以两个模板成为可能调用的函数。与之前提到的一样,T*
版本更加特例化,因此编译器会选择它。
由于接受T*
指针的函数模板不能正确处理char*
,因此我们定义另外两个非模板重载版本:
template string debug_rep(const T &t);
template string debug_rep(T *p);
// 为了使debug_rep(char*)的定义正确工作, 下面的声明必须在作用域中
string debug_rep(const string&);
// 将字符指针按string处理, 并调用string版本的debug_rep
string debug_rep(char *p) {
return debug_rep(string(p));
}
string debug_rep(const char *p) {
return debug_rep(string(p));
}
Tips:一般情况下如果使用了一个忘记声明的函数,代码将编译失败。但对于重载函数模板的函数而言,如果编译器可以从模板实例化出与调用匹配的版本,则缺少的声明就不重要的。在上面的例子中,如果忘记声明接受
string
参数的debug_rep
版本,编译器会默认地接收const T&
的模板版本。