C++ Template-1 函数模板
通过大量的例子介绍了C++函数模板。你可以学习到如何使用C++的函数模板以及使用时需要注意的要点。
这是本系列第一篇文章,以下是本系列文章目录。
1. 函数模板
本章介绍了函数模板。函数模板是被参数化的函数,因此他们代表了一组行为相似的函数(a family of functions)。
1.1. 初识函数模板
函数模板提供了适用于不同类型的函数行为。也就是说,函数模板代表了一组行为相似的函数。函数模板看起来几乎就跟普通函数一样,除了某些信息没有被确定以外。我们通过下面一个简单的例子来说明这一问题。
1.1.1 定义模板
一下是一个函数模板,它返回两数中的最大值
1 |
|
这个模板第一了一系列函数,他们都返回了两个参数中值较大的拿一个。这两个参数的类型并没有确定,而是被表示为模板参数(template parameter) T
。
模板参数应该按照如下的语法来声明:
template <由逗号分隔的模板参数>
在本例中,模板参数是 typename T
。关键字 typename
标识了一个类型参数,即 T
是类型参数(type parameter),你可以用任意的标识作为类型参数的参数名,但是习惯上我们使用 T
作为参数名。本例中,类型 T
必须支持小于运算符,因为 a
和 b
比较时运用到了它。另外比较隐秘的一点,为了支持返回值,T
还应该是可拷贝的。
1.1.2 使用模板
下面的程序展现了如何使用 max()
模板:
1 |
|
注意在调用的 max()
模板的时候使用了作用域限制符 ::
。这样保证了程序将会在全局命名空间(namespace)中查找 max()
模板。
在此程序中,max()
被调用了三次:一次是比较两个int
,一次是比较两个double
,还有一次是比较两个 std::string
。每一次都会算出最大值。下面是输出结果:
1 |
|
在编译阶段,模板并不是被编译成一个可以支持多种类型的实体。而是对每一个用于该模板的类型都会产生一个独立的实体。因此在本例中,max()
会被编译出三个实体,因为它被用于三种类型。比如第一次调用时:
1 |
|
函数模板的类型参数 T 是int
。因此在语义上等价于调用如下函数:
1 |
|
以上用具体类型取代模板类型参数的过程叫做实例化(instantiation)。它会产生模板的一个实例。
值得注意的是,模板的实例化不需要程序员做额外的请求,只是简单的使用函数模板就会触发这一实例化过程。
同样的,另外两次调用也会分别为 double
和 std::string
各实例化出一个实例,就像是分别定义了下面两个函数一样:
1 |
|
另外,只要结果是有意义的,void 作为模板参数也是有效的。比如:
1 |
|
1.1.3 二阶段翻译(Two-Phase Translation)
在实例化模板的时候,如果模板参数类型不支持所有模板中用到的操作符,将会遇到编译期错误(compile-time error)。比如:
1 |
|
在编写是不会立刻提醒错误,因为模板是分两步编译的:
在模板定义阶段,模板的检查并不包含类型参数的检查。只包含下面几个方面:
- 语法检查。比如少了分号。
- 检查使用未定义的不依赖于模板参数的未知名称(类型名,函数名,…)。
- 检查不依赖于模板参数的静态断言(static assertion);
在模板实例化阶段,为确保所有代码都是有效的,模板会再次被检查,尤其是那些依赖于类型参数的部分。
例如:
1
2
3
4
5
6
7
8template <typename T>
void foo(T t)
{
undeclared(); // 如果 undeclared() 未定义,第一阶段就会报错
undeclared(t); //如果 undeclared(t) 未定义,第二阶段会报错
static_assert(sizeof(int) > 10,"int too small"); // 总是报错
static_assert(sizeof(T) > 10, "T too small"); //只会在第二阶段报错
}
需要注意的是,有些编译器并不会执行第一阶段中的所有检查。因此如果模板没有被至少实例化一次的话,你可能一直都不会发现代码中的常规错误。
编译和链接
两阶段的编译检查给模板的处理带来了一个问题:当实例化一个模板的时候,编译器需要(一定程度上)看到模板的完整定义。这不同于函数编译和链接分离的思想,函数在编译阶段只需要声明就够了。后面将讨论如何应对这一问题。现在暂时采取最简单的方法:将模板的实现写在头文件里。
1.2. 模板参数推导
当我们调用形如 max()
的函数模板来处理某些变量时,模板参数将由被传递的调用参数决定。如果我们传递两个 int
类型的参数给模板函数,C++编译器会将模板参数 T 推导为 int
。
不过 T 可能只是实际传递的函数参数类型的一部分。比如我们定义了如下接受常量引用作为函数参数的模板:
1 |
|
此时如果我们传递 int
类型的调用参数,由于调用参数和 int const &
匹配,类型参数 T 将被推导为int
。
1.2.1 类型推导中的类型转换
在类型推导的时候自动的类型转换是受限制的:
- 如果调用参数是按引用传递的,任何类型转换都不被允许。通过模板类型参数 T 定义的两个参数,它们实参的类型必须完全一样。
- 如果调用参数是按值传递的,那么只有退化(decay)这一类简单转换是被允许的:
const
和volatile
限制符会被忽略,引用被转换成被引用的类型,数组(raw array)和函数被转换为相应的指针类型。通过模板类型参数 T 定义的两个参数,它们实参的类型在退化后必须一样。
例如:
1 |
|
但是像下面这样是错误的:
1 |
|
有三种办法解决以上错误:
对参数进行类型转换,使参数同时匹配。
1
max(static_cast<double>(4), 7.2); // OK
显示地指出T的类型取阻止编译器进行类型推导。
1
max<double>(4, 7.2); // OK
指明参数可能有多个不同的类型(使用多个模板参数)。
1.2.2 对默认参数的类型推导
需要注意的是,类型推导并不适用于默认参数。例如:
1 |
|
为应对这一情况,你需要给模板类型参数也声明一个默认参数
1 |
|
1.3. 多模板参数
目前我们看到与函数模板有关的两组参数:
模板参数(template parameters),定义在函数模板前的尖括号中:
1
template <typename T> // T 是模板参数
调用参数(call parameters),定义在函数模板名称后的圆括号中:
1
T max(T a, T b) // a 和 b 是调用参数
模板参数可以是一个或者多个。比如,你可以定义这样一个 max()
模板,它可能接受两个不同类型的调用参数:
1 |
|
看上去如你所愿,它是可以接受两个不同类型的调用参数。但是,这也导致了一个问题。如果你使用其中一个类型参数的类型作为返回类型,不管是不是和调用者预期地一样,当应该返回另一个类型的值的时候,返回值会被做类型转换。这将导致返回值的具体类型和参数的传递顺序有关。如果传递 66.66
和 42
给这个函数模板,返回值是 double
类型的 66.66
,但是如果传递 42
和 66.66
,返回值却是 int
类型的 66
。
C++提供了多种方法来解决这一问题:
- 引入第三个模板参数作为返回类型。
- 让编译器找出返回类型
- 将返回类型定义为两个参数类型的公共类型(common type)。
1.3.1 返回类型的模板参数
我们可以像调用普通函数一样条用函数模板,也可以显式地指出模板参数的参数类型。
当模板参数和调用参数直接没有必然联系,且模板参数不能确定是,就要显式地指明模板参数。例如,你可以引入第三个模板参数来指定函数模板的返回类型:
1 |
|
但是模板类型推导不会考虑返回类型,而 RT
不是函数的调用参数的类型。因此 RT
不会被推导。这样就必须显式的指明模板参数的类型。比如:
1 |
|
到目前为止,我们看到的情况是,要么所有模板参数都被显式指定,要么一个都不指定。另一种办法是只指定第一个模板参数的类型,其余参数的类型通过推导获得。通常而言,我们必须显式指定所有模板参数的类型,直到某一个模板参数的类型可以被推导出来为止。因此,如果你改变了上面例子中的模板参数顺序,调用时只需要指定返回值的类型就可以了:
1 |
|
在本例中,调用 max<double>
时,显式的指明了 RT
的类型是 double
,T1
和 T2
则基于传入调用参数的类型被推导为 int
和 double
。
然而改进版的 max()
并没有带来显著的变化。使用单模板参数的版本,即使传入的两个调用参数的类型不同,你依然可以显式的指定模板参数类型(也作为返回类型)。因此为了简洁,我们最好还是使用单模板参数的版本。
1.3.2 推导返回类型
如果返回类型是由模板参数决定的,那么推导返回类型最简单也是最好的办法就是让编译器来做这件事。C++14 开始,这成为可能,而且不需要把返回类型声明为任何模板参数类型(不过你需要声明返回类型为 auto
):
1 |
|
在 C++14 之前,要想让编译器推导出返回类型,就必须让或多或少的函数实现成为函数声明的一部分。在 C++11 中,尾置返回类型(trailing return type)允许我们使用函数的调用参数。也就是说,我们可以基于运算符 ?:
的结果声明返回类型:
1 |
|
在这里,返回类型是由运算符?:
的结果决定的,这虽然复杂但是可以得到想要的结果。
需要注意的是
1 |
|
是一个声明,编译器在编译阶段会根据 ?:
调用参数 a
和 b
的返回结果来决定实际的返回类型。不过具体实现时不一定需要匹配。所以事实上只要使用 true
作为运算符 ?:
的条件就行了:
1 |
|
但是在某些情况下会有一个严重的问题:由于 T
可能是引用类型,返回类型就也可能被推导为引用类型。因此你应该返回的是退化(decay)后的 T
,像下面这样:
1 |
|
这里用到了 type trait std::decay<>
,它的 type
成员返回目标类型,它定义在标准库 <type_traits>
中。因为其 type
成员是结果类型,为了获取其结果,应该用关键字 typename
来修饰这个表达式。
注意,在初始化 auto
类型是总是退化(decay)后的类型。它同样支持返回类型是 auto
。 用 auto
作为返回结果的效果就像下面那样,a
的类型被推导为 i
退化后的类型,即为 int
:
1 |
|
1.3.3 作为通用类型返回
从C++11 开始,标准库提供了一种指定“更一般类型”的方式。std::common_type<>::type
产生的类型是他的两个(或更多)模板参数的公共类型。比如:
1 |
|
同样的,std::common_type<>
也是一个 type trait,定义在标准库 type_traits
中,它返回一个结构体,其 type
成员是结果类型。其主要的应用如下:
1 |
|
在C++14后,你可以简化其用法,仅需在后面加一个 _t
,就可以省掉 typename
和 ::type
,简化后的版本变为:
1 |
|
std::common_type<>
的实现用到了一些比较取巧的模板编程手法(后续会介绍)。它根据运算符 ?:
的语法规则或者对某些类型的特化来决定目标类型。因此 ::max(4, 7.2)
和 ::max(7.2, 4)
都返回 double
类型的 7.2
。需要注意的是,std::common_type<>
的结果也是退化(decay)的。
1.4. 缺省模板参数(Default Template Arguments)
你也可以给函数模板参数指定一个默认值。这些默认值被称为缺省模板参数(default template arguments)【也称为默认模板参数】并且可以用在任何类型的模板中。它们甚至可以通过前面的模板参数来决定自己的类型。
例如,如果你想像前面叙述的那样是返回类型有多个模板参数,你可以定义一个模板参数 RT
,并将其默认类型声明为其他两个模板的公共类型。同样的我们也有多种实现方法:
可以直接使用
?:
运算符。不过由于我们必须在调用参数(call parameters)a
和b
被声明之前使用运算符?:
,我们只能用它们的类型:1
2
3
4
5
6
7#include <type_traits>
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<>
来确保返回的类型不是引用类型。同样值得注意的是,这一实现方法要求我们能够调用两个模板参数的默认构造(default constructor)。还有另一种方法,使用
std::declval
,不过这将使得声明部分更加复杂,以后我们会讲到。我们同样可以使用 type trait
std::decay_t<>
作为返回类型的默认值:1
2
3
4
5
6
7#include <type_traits>
template <typename T1, typename T2,
typename RT = std::common_type_t<T1, T2>>
RT max(T1 a, T2 b)
{
return b < a ? a : b;
}在这里
std::common_type_t<>
也是会做退化的,因此返回类型不会是引用。
在以上两种情况下,你可以使用 RT
的默认值作为返回类型:
1 |
|
也可以显式地指出所有的函数模板参数的类型:
1 |
|
但是,我们再次遇到这样一个问题:为了显式指出返回类型,我们必须显式的指出全部三个模板参数的类型。因此我们希望能够将返回类型作为第一个模板参数,并且依然能够从其它两个模板参数推断出它的类型。
原则上这是可行的,即使后面的模板参数没有默认值,我们依然可以让第一个模板参数有默认值:
1 |
|
基于这个定义,你可以这样调用:
1 |
|
但是只有当模板参数具有一个“天生的”默认值时,这才有意义。我们真正想要的是从前面的模板参数推导出想要的默认值。原则是这也是可行的(后续会讨论到),但是他是基于类型萃取的,并且会使定义变得更加复杂。
基于以上原因,最好也是最简单的办法就是像前面讨论的那样让编译器来推断出返回类型。
1.5. 重载函数模板
像普通函数一样,模板也是可以重载的。也就是说,你可以定义多个有相同函数名的函数,当实际调用的时候,由C++编译器负责决定具体该调用哪一个函数。
下列程序展示了函数模板的重载:
1 |
|
这个例子展示了一个非模板函数可以与其同名的函数模板共存,并且这个函数模板可以被实例化与非模板函数具有相同类型的调用函数。在其他因素相同时,模板解析过程优先选择非模板函数,而不是模板实例化出来的函数。第一个调用就属于这种情况:
1 |
|
如果模板可以实例化出一个更匹配的函数,那么就会选择这个模板。如第二和第三次调用 max()
时:
1 |
|
在此模板更加匹配,因为它不需要把 double
和 char
转换为 int
。
也可以显式指定一个空的模板列表。这表明它会被解析成一个模板调用,其所有的模板参数会被通过调用参数推断出来:
1 |
|
由于在模板参数推断时不允许自动类型转换,而常规函数是允许的,因此最后一个调用会选择非模板参函数( a
和 42.7
都被转换成 int
):
1 |
|
一个有趣的例子是我们可以专门为 max()
显示指定其返回值类型的模板来进行重载:
1 |
|
现在我们可以向这样调用 max()
:
1 |
|
但是想下面这样调用的话:
1 |
|
两个模板都是匹配的,这会导致模板解析过程不知道该调用哪一个模板,从而导致未知错误。因此当重载函数模板的时候,要保证对任意一个调用,都只会有一个模板匹配。
一个比较有用的例子是为指针和 C-strings
重载 max()
模板:
1 |
|
注意上面所有 max()
的重载模板中,调用参数都是按值传递的。通常而言,在重载模板的时候,要尽可能少地做改动。你应该只是改变模板参数的个数或者显式的指定某些模板参数。否则,可能会遇到意想不到的问题。比如,如果你实现了一个按引用传递的 max()
模板,然后又重载了一个按值传递两个 C 字符串作为参数的模板,你不能用接受三个参数的模板来计算三个 C 字符串的最大值:
1 |
|
问题在于当用三个 C 字符串作为参数调用 max()
的时候,
1 |
|
会遇到run-time error,这是因为对C 字符串,max(max(a, b), c)
会创建一个用于返回的临时局部变量,而在返回语句接受后,这个临时变量会被销毁,导致 max()
使用了一个悬空的引用。不幸的是,这个错误几乎在所有情况下都不太容易被发现。
作为对比,在求三个 int
最大值的 max()
调用中,则不会遇到这个问题。这里虽然也会创建三个临时变量,但是这三个临时变量创建在 main()
中,它们的存在时间会持续到语句结束。
这只是模板解析与期望结果不一致的一个例子。另外,要确保函数模板在调用前已经被定义。这是由于我们调用某个函数模板时,不是所有的重载函数都是可见的。比如我们定义了三个参数的 max()
函数,由于它调用适用于两个 int
类型的 max()
时,重载的两个 int
类型的 max()
是不可见的,因此它最终会调用两个参数的模板函数:
1 |
|
1.6. 但是,难道我们不应该?
1.6.1 按值传递还是按应用传递?
我们声明的函数通常都是按值传递,而不是按引用传递。通常而言,建议将按引用传递除简单类型(比如基础类型(fundamental type)和 std::string_view
)以外的类型,这样可以免除不必要的拷贝成本。
但是出于以下原因,按值传递通常更好一些:
- 语法简单。
- 编译器优化更好。
- 移动语意是的拷贝成本较低。
- 某些情况下没有拷贝或者移动。
再有,对于模板,还有一些特殊情况:
- 模板可能用于简单类型也可能由于复杂类型,因此如果选择利于复杂类型的方式,可能会对简单类型产生不利的影响。
- 作为调用者,你可以通过使用
std::ref()
和std::cref()
决定是否按照引用传递参数。 - 尽管传递 string literal 和 raw array 会产生一些问题,按时如果按照引用传递它们会产生更大的问题。
后续会对此进行进一步讨论,除了某些不得不用按引用传递的情况,尽量使用按值传递。
1.6.2 为什么不用 inline 呢?
通常,函数模板不需要声明为 inline
。不像普通的 noninline
函数,我们可以把 noninline
函数模板定义在同文件中,然后再多个编译单元里 include
这个头文件。
唯一一个例外是模板对某些类型进行了全特化(full spcializations),这时的结果代码不再是泛型(generic)的(所有的模板参数都已经被指定了)。后续会详细讨论。
严格地从语言角度来看,inline
只意味着在程序中函数的定义可以出现很多次。不过它也给了编译器一个暗示,在调用该函数的地方函数应该被展开成 inline
的:这样做在某些情况下可以提高效率,但是在另一些情况下也可能降低效率。现代编译器在没有关键字 inline
暗示的情况下,通常也可以很好的决定是否将函数展开成 inline
的。当然,编译器在做决定的时候依然会将关键字 inline
纳入考虑因素。
1.6.3 为什么不用 constexpr 呢?
从 C++11 开始,你可以通过关键字 constexpr
来在编译期进行某些运算。对于很多模板来说,这是有意义的。
比如为了可以在编译期使用求最大值的函数,你需要将函数模板定义如下:
1 |
|
通过此,你可以使用此函数在编译期就求出最大值,例如当定义数列的大小时:
1 |
|
或者指定 std::array<>
的大小:
1 |
|
在这里我们传递的 1000 是 unsigned int
类型,这样可以避免直接比较一个有符号数值和一个无符号数值时产生的警报。
后续也会继续讨论 constexpr
。
1.7. 小结
- 函数模板定义了一组适用于不同类型的函数
- 当向模板函数传递变量时,函数模板会自行推导模板参数的类型,来决定去实例化出那种类型的函数。
- 你也可以显式的指出模板参数的类型。
- 你可以定义模板参数的默认值。这个默认值可以使用该模板参数前面的模板参数的类型,而且其后面的模板参数可以没有默认值。
- 函数模板可以被重载。
- 当定义新的函数模板来重载已有的函数模板时,必须要确保在任何调用情况下都只有一个模板是最匹配的。
- 当你重载函数模板的时候,最好只是显式地指出了模板参数的类型。
- 确保在调用某个函数模板之前,编译器已经看到了相对应的模板定义。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!