C++templates第一章函数模板
C++templates第一章函数模板
Hoshea Zhang为什么要使用模板?
C++要求我们要用特定的类型来声明变量,函数以及其他一些内容。这样很多代码可能就只是处理的变量类型有所不同。比如对不同的数据类型,quicksort 的算法实现在结构上可能完全一样,不管是对整形的 array,还是字符串类型的 vector,只要他们所包含的内容之间可以相互比较。
如果你所使用的语言不支持这一泛型特性,你将可能只有如下糟糕的选择:
- 你可以对不同的类型一遍又一遍的实现相同的算法。
- 你可以在某一个公共基类(commonbasetype,比如 Object 和 void*)里面实现通用的算法代码。
- 你也可以使用特殊的预处理方法。
如果你是从其它语言转向 C++,你可能已经使用过以上几种或全部的方法了。然而他们都各有各的缺点:
- 如果你一遍又一遍地实现相同算法,你就是在重复地制造轮子!你会犯相同的错误,而 且为了避免犯更多的错误,你也不会倾向于使用复杂但是很高效的算法。
- 如果在公共基类里实现统一的代码,就等于放弃了类型检查的好处。而且,有时候某些 类必须要从某些特殊的基类派生出来,这会进一步增加维护代码的复杂度。
- 如果采用预处理的方式,你需要实现一些“愚蠢的文本替换机制”,这将很难兼顾作用域和类型检查,因此也就更容易引发奇怪的语义错误。
而模板这一方案就不会有这些问题。模板是为了一种或者多种未明确定义的类型而定义的函数或者类。在使用模板时,需要显式地或者隐式地指定模板参数。由于模板是 C++的语言特 性,类型和作用域检查将依然得到支持。
函数模板初探
函数模板提供了适用于不同数据类型的函数行为,也就是说,函数模板代表的是一组函数,除了某些信息未被明确指定之外,他们看起来很像普通函数,这些没被指定的信息就是被参数化的信息。
定义模板
1 | template<typename T> |
这个模板定义了一组函数,他们都返回函数的两个参数中值较大的那一个。这两个参数的类型并没有被明确指定,而是被表示为模板参数T。模板参数必须按照如下语法声明:
template<由逗号分隔的模板参数>
在我们的例子中,模板参数是typename T。类型参数可以代表任何类型,他在模板被调用的时候决定。但是这个类型(可以是基础类型、类或其他类型)应该支持模板用到的运算符,在本例中,类型T必须支持<
运算符。
typename可以用class代替,但是应该优先使用typename。
使用模板
1 |
|
在这段代码中,max()被调用了三次:一次是比较两个 int,一次是比较两个 double,还有一次是比较两个 std::string。每一次都会算出最大值。
下面是输出结果:
max(7,i): 42
max(f1,f2): 3.4
max(s1,s2): mathematics
注意在调用 max()模板的时候使用了作用域限制符::。这样程序将会在全局作用域中查找 max()模板。否则的话,在某些情况下标准库中的 std::max()模板将会被调用,或者有时候不太容易确定具体哪一个模板会被调用。
在编译阶段,模板并不是被编译成一个可以支持多种类型的实体。而是对每一个用于该模板 的类型都会产生一个独立的实体。因此在本例中,max()会被编译出三个实体,因为它被用 于三种类型。比如第一次调用时,等效于如下函数:
1 | int max (int a, int b) |
模板参数推断
当我们调用形如 max()的函数模板来处理某些变量时,模板参数将由被传递的调用参数决定。 如果我们传递两个 int 类型的参数给模板函数,C++编译器会将模板参数 T 推断为 int。
不过 T 可能只是实际传递的函数参数类型的一部分。比如我们定义了如下接受常量引用作为 函数参数的模板:
1 | template<typename T> |
此时如果我们传递 int 类型的调用参数,由于调用参数和 intconst&匹配,类型参数 T 将被推断为int
类型推断中的类型转换
在类型推断的时候自动的类型转换是受限制的:
- 如果调用参数是按引用传递的,任何类型转换都不被允许。通过模板类型参数 T 定义的 两个参数,它们实参的类型必须完全一样
- 如果调用参数是按值传递的,那么只有退化(decay)这一类简单转换是被允许的: const 和 volatile 限制符会被忽略,引用被转换成被引用的类型,rawarray 和函数被转换为相应的指针类型。通过模板类型参数 T 定义的两个参数,它们实参的类型在退化(decay) 后必须一样
1 | template<typename T> |
但是这样就是错误的:
但是像下面这样是错误的:
1 | max(4, 7.2); // ERROR: 不确定T该被推断为int还是double |
有三种办法解决以上错误:
- 对参数做类型转换 max(static_cast
(4), 7.2); // OK - 显式地指出类型参数 T 的类型,这样编译器就不再会去做类型推导。 max
(4, 7.2); // OK - 指明调用参数可能有不同的类型(多个模板参数)。
对默认调用参数的类型推断
需要注意的是,类型推断并不适用于默认调用参数,例如:
1 | template<typename T> |
为应对这一情况,你需要给模板类型参数也声明一个默认参数,1.4 节会介绍这一内容:
1 | template<typename T = std::string> |
多个模板参数
目前我们学习到了与函数模板相关的两组参数:
模板参数,定义在函数模板前面的尖括号里
template<typename T>
调用参数,定义在函数模板名称后面的圆括号里
T max(T a,T b)
参数参数可以是一个或者多个,如下所示:
1 | template<typename T1, typename T2> |
看上去如你所愿,它可以接受两个不同类型的调用参数。
但是如示例代码所示,这也导致了一个问题。如果你使用其中一个类型参数的类型作为返回类型,不管是不是和调用者预期地一样,当应该返回另一个类型的值的时候,返回值会被做类型转换。这将导致返回值的具体类型和参数的传递顺序有关。
如果传递 66.66 和 42 给这个函数模板,返回值是 double 类型 的 66.66,但是如果传递 42 和 66.66,返回值却是 int 类型的 66。
- C++提供了多种应对这一问题的方法:
- 引入第三个模板参数作为返回类型。
- 让编译器找出返回类型。
- 将返回类型定义为两个参数类型的“公共类型”
作为返回类型的模板参数
按照之前的讨论,模板类型推断允许我们像调用普通函数一样调用模板函数模板,我们可以不去显式的指出模板参数的类型
但是也提到,我们也可以显式地指出模板参数的类型:
1 | template<typename T> |
当模板参数和调用参数没有必然的联系且模板参数不能确定的时候,我们可以显式指明模板参数,可以引入第三个模板来指定函数模板的返回类型:
1 | template<typename T1, typename T2, typename RT> |
这样做是可以的,但是太繁琐。
目前我们看到的情况是,要么所有模板参数都被显式指定,要么一个都不指定,另一个办法是只指定第一个模板参数的类型,其余参数的类型通过推断获得,通常而言,我们必须显式指定所有模板参数的类型,因此我们调换一下模板参数顺序,就可以调用时只指定返回值的类型:
1 | template<typename RT, typename T1, typename T2> |
返回类型推断
如果返回类型是由模板参数决定的,那么推断返回类型最简单也是最好的办法就是让编译器 来做这件事。从 C++14 开始,这成为可能,而且不需要把返回类型声明为任何模板参数类型 (不过你需要声明返回类型为 auto):
1 | template<typename T1, typename T2> |
事实上,在不使用 尾 置 返 回 类 型 ( trailingreturntype ) 的情况下将 auto 用于返回类型,要求返回类型必须能够通过函数体中的返回语句推断出来。当然,这首先要求返回类型能够从函数体中推断出来。因此,必须要有这样可以用来推断返回类型的返回语句,而且多个返回语句之间的推断结果必须一致。
在 C++14 之前,要想让编译器推断出返回类型,就必须让或多或少的函数实现成为函数声明的一部分。在 C++11 中,尾置返回类型(trailingreturntype)允许我们使用函数的调用参数。 也就是说,我们可以基于运算符?:的结果声明返回类型:
1 | template<typename T1, typename T2> |
但是在某些情况下会有一个严重的问题:由于 T 可能是引用类型,返回类型就也可能被推断 为引用类型。因此你应该返回的是 decay 后的 T,像下面这样:
1 |
|
把返回类型声明为公共类型
从 C++11 开始,标准库提供了一种指定“更一般类型”的方式。std::common_type<>::type
产生的类型是他的两个模板参数的公共类型。比如:
1 |
|
同样的,std::common_type 也是一个类型萃取(typetrait),定义在
typename std::common_type<T1,T2>::type //since C++11
不过从 C++14 开始,你可以简化“萃取”的用法,只要在后面加个_t,就可以省掉 typename 和::type(参见 2.8 节),简化后的版本变成:
std::common_type_t<T1,T2> // equivalent since C++14
std::common_type<>的实现用到了一些比较取巧的模板编程手法,具体请参见 25.5.2 节。它 根据运算符?:的语法规则或者对某些类型的特化来决定目标类型。因此::max(4, 7.2) 和::max(7.2,4)都返回 double 类型的 7.2. 需要注意的是,std::common_type<>的结果也是退化的。
默认模板参数
你也可以给模板参数指定默认值。这些默认值被称为默认模板参数并且可以用于任意类型的模板。它们甚至可以根据其前面的模板参数来决定自己的类型。
比如如果你想将前述定义返回类型的方法和多模板参数一起使用,你可以为返回类型引入一 个模板参数 RT,并将其默认类型声明为其它两个模板参数的公共类型。同样地,我们也有多种实现方法:
我们可以直接使用运算符?:。不过由于我们必须在调用参数 a 和 b 被声明之前使用运算 符?:,我们只能像下面这样:
1
2
3
4
5
6
template<typename T1, typename T2, typename RT = std::decay_t<decltype(true ? T1() : T2())>>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}请注意在这里我们用到了 std::decay_t<>来确保返回的值不是引用类型。
同样值得注意的是,这一实现方式要求我们能够调用两个模板参数的默认构造参数。还有另 一种方法,使用 std::declval,不过这将使得声明部分变得更加复杂我们也可以利用类型萃取 std::common_type<>作为返回类型的默认值:
1
2
3
4
5
6
template<typename T1, typename T2, typename RT = std::common_type_t<T1,T2>>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}我们希望能够将返回类型作为第一个模板参数,并且依然能够从其它两个模板参数推断出它的类型:
1
2
3
4
5template<typename RT = long, typename T1, typename T2>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}1
2
3
4
5int i;
long l;
// ...
max(i, l); // 返回值类型是long (RT 的默认值)
max<int>(4, 42); //返回int,因为其被显式指定
函数模板的重载
像普通函数一样,模板也是可以重载的。也就是说,你可以定义多个有相同函数名的函数, 当实际调用的时候,由 C++编译器负责决定具体该调用哪一个函数。即使在不考虑模板的时候,这一决策过程也可能异常复杂。
下面几行程序展示了函数模板的重载:
1 | // maximum of two int values: |
测试:
1 | int main() |
在其他因素相同的情况下,模板解析过程将优先选择非模板函数,而不是从模板实例化出来的函数。
由于在模板参数推断时不允许自动类型转换,而常规函数是允许的,因此最后一个调用会选择非模板参函数(‘a’和 42.7 都被转换成 int):
要保证对任意一个调用,都只会有一个模板匹配
讨论
按值传递还是按引用传递
你可能会比较困惑,为什么我们声明的函数通常都是按值传递,而不是按引用传递。通常而言,建议将按引用传递用于除简单类型(比如基础类型和 std::string_view)以外的类型,这样可以免除不必要的拷贝成本。
不过出于以下原因,按值传递通常更好一些:
- 语法简单。
- 编译器能够更好地进行优化。
- 移动语义通常使拷贝成本比较低。
- 某些情况下可能没有拷贝或者移动。
再有就是,对于模板,还有一些特有情况:
- 模板既可以用于简单类型,也可以用于复杂类型,因此如果默认选择适合于复杂类型可 能方式,可能会对简单类型产生不利影响。
- 作为调用者,你通常可以使用 std::ref()和 std::cref()(参见 7.3 节)来按引用传递参数。
- 虽然按值传递 stringliteral 和 rawarray 经常会遇到问题,但是按照引用传递它们通常只 会遇到更大的问题。第 7 章会对此做进一步讨论。在本书中,除了某些不得不用按引用传递的地方,我们会尽量使用按值传递。
为什么不适用inline
通常而言,函数模板不需要被声明成 inline。不同于非 inline 函数,我们可以把非inline的函数模板定义在头文件里,然后在多个编译单元里 include 这个文件。
唯一一个例外是模板对某些类型的全特化,这时候最终的 code 不在是“泛型”的(所有的 模板参数都已被指定)。详情请参见 9.2 节。
严格地从语言角度来看,inline只意味着在程序中函数的定义可以出现很多次 。不过它也给了编译器一个暗示,在调用该函数的地方函数应该被展开成 inline的:这样做在某些情况下可以提高效率,但是在另一些情况下也可能降低效率。现代编译器在没有关键字inline暗示的情况下,通常也可以很好的决定是否将函数展开成 inline 的。当然,编译器在做决定的时候依然会将关键字 inline 纳入考虑因素。
总结
- 函数模板定义了一组适用于不同类型的函数。
- 当向模板函数传递变量时,函数模板会自行推断模板参数的类型,来决定去实例化出那 种类型的函数。
- 你也可以显式的指出模板参数的类型。
- 你可以定义模板参数的默认值。这个默认值可以使用该模板参数前面的模板参数的类型,而且其后面的模板参数可以没有默认值。
- 函数模板可以被重载。
- 当定义新的函数模板来重载已有的函数模板时,必须要确保在任何调用情况下都只有一个模板是最匹配的。
- 当你重载函数模板的时候,最好只是显式地指出了模板参数的类型。
- 确保在调用某个函数模板之前,编译器已经看到了相对应的模板定义。