函数模板
本节将介绍函数模板
初识函数模板
函数模板不是函数,只有实例化函数模板,编译器才能生成实际的函数定义。不过在很多时候,它看起来就像普通函数一样。
定义模板
下面是一个函数模板,返回两个对象中较大的那个:
这应该很简单,即使我们还没有开始讲述函数模板的语法。
如果要声明一个函数模板,我们通常要使用:
我们前面示例中的形参列表是 typename T
,关键字 typename 顾名思义,引入了一个类型形参。
类型形参是 T,也可以使用其他标识符作为类型形参名(T 或 Ty 等,是约定的惯例),你也可以在需要的时候自定义一些有明确意义的名字。在调用函数模板 max
时,根据传入参数,编译器可以推导出类型形参的类型,实例化函数模板。我们需要传入支持函数模板操作的类型,如 int 或 重载了 >
运算符的类。注意 max
的 return
这意味着我们的模板形参 T 还需要是可复制/移动的,以便返回。
C++17 之前,类型 T 必须是可复制或移动才能传递参数。C++17 以后,即使复制构造函数和移动构造函数都无效,因为 C++17 强制的复制消除,也可以传递临时纯右值。
因为一些历史原因,我们也可以使用 class 关键字来定义模板类型形参。所以先前的模板 max
可以等价于:
但是与类声明不同,在声明模板类型形参时,不能使用 struct。
使用模板
下面展示了如何使用函数模板 max()
看起来的确和调用普通函数没区别,那么这样调用和普通函数相比,编译器会做什么呢?
编译器会实例化两个函数,也就是生成了一个参数为 int 的 max 函数,一个参数为 Test 的函数。
我们可以使用 cppinsights 验证我们的想法。
用一句非常不严谨的话来说:
模板,只有你“用”了它,才会生成实际的代码。
这里的“用”,其实就是指代会隐式实例化,生成代码。
并且需要注意,同一个函数模板生成的不同类型的函数,彼此之间没有任何关系。
除了让编译器自己去推导函数模板的形参类型以外,我们还可以自己显式的指明:
模板参数推导
当使用函数模板(如 max())时,模板参数可以由传入的参数推导。如果类型 T 传递两个 int 型参数,那编译器就会认为 T 是 int 型。
然而,T 可能只是类型的“一部分”。若声明 max() 使用 const&
:
如果我们 max(1, 2)
或者说 max<int>(x,x)
,T 当然会是 int,但是函数形参类型会是 const int&
。
不过我们需要注意,有不少情况是没有办法进行推导的:
那么我们如何处理这种错误呢?可以使用前面提到的显式指定函数模板的(T)类型。
又或者说显式类型转换:
但是 std::string 没有办法如此操作,我们可以显式的构造一个无名临时对象:
此时就不是我们的 T
不明确了,而是函数模板 max
不明确,它会和标准库的 std::max
产生冲突,虽然我们没有使用 std::
,但是根据 C++ 的查找规则,(实参依赖查找)ADL,依然可以查找到。
那么我们如何解决呢?很简单,进行有限定名字查找,即使用 ::
或 std::
说明,你到底要调用 “全局作用域”的 max,还是 std 命名空间中的 max。
万能引用与引用折叠
所谓的万能引用(又称转发引用),即接受左值表达式那形参类型就推导为左值引用,接受右值表达式,那就推导为右值引用。
比如:
被推导为
f<int&>
涉及到了特殊的推导规则:如果 P 是到无 cv 限定模板形参的右值引用(也就是转发引用)且对应函数的调用实参是左值,那么将到 A 的左值引用类型用于 A 的位置进行推导。
通过模板或 typedef 中的类型操作可以构成引用的引用,此时适用引用折叠(reference collapsing)规则:
右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用。
有默认实参的模板类型形参
就如同函数形参可以有默认值一样,模板形参也可以有默认值。当然了,这里是“类型形参”(后面会讲非类型的)。
#25 GCC 编译器有 BUG,自行注意。
以上这个示例你可能有很多疑问,我们第一次使用了多个模板类型形参,并且第三个模板类型形参给了默认值,但是这个值似乎有点难以理解,我们后面慢慢讲解。
max(const T1& a, const T2& b)
让 max 函数模板接受两个参数的时候不需要再是相同类型,那么这自然而然就会引入另一个问题了,如何确定返回类型?
我们从最里面开始看:
这是一个三目运算符表达式。然后外面使用了 decltype 获取这个表达式的类型,那么问题是,为什么是 true 呢?以及为什么需要 T1{},T2{} 这种形式?
我们为什么要设置为 true?
其实无所谓,设置 false 也行,true 还是 false 不会影响三目表达式的类型。这涉及到了一些复杂的规则,简单的说就是三目表达式要求第二项和第三项之间能够隐式转换,然后整个表达式的类型会是 “公共”类型。
比如第二项是 int 第三项是 double,三目表达式当然会是 double。
T 和 T2 都是 double 类型。
为什么需要
T1{}
,T2{}
这种形式?没有办法,必须构造临时对象来写成这种形式,这里其实是不求值语境,我们只是为了写出这样一种形式,让 decltype 获取表达式的类型罢了。
模板的默认实参的和函数的默认实参大部分规则相同。
decltype(true ? T1{} : T2{})
解决了。事实上上面的写法都十分的丑陋与麻烦,我们可以使用 auto 简化这一切。
这是 C++11 后置返回类型,它和我们之前用默认模板实参 RT
的区别只是稍微好看了一点吗?
不,它们的返回类型是不一样的,如果函数模板的形参是类型相同 true ? a : b
表达式的类型是 const T&
;如果是 max(1, 2)
调用,那么也就是 const int&
;而前面的例子只是 T
即 int
(前面都是用模板类型参数直接构造临时对象,而不是有实际对象,自然如此,比如 T{}
)。
假设以
max(1,1.0)
调用,那么自然返回类型不是const T&
下面的
max
定义也类似,涉及的规则不再强调。
使用 C++20 简写函数模板,我们可以直接再简化为:
效果和上面使用后置返回类型的写法完全一样;C++14 引入了两个特性:
返回类型推导(也就是函数可以直接写 auto 或 decltype(auto) 做返回类型,而不是像 C++11 那样,只是后置返回类型。
decltype(auto)
“如果返回类型没有使用 decltype(auto),那么推导遵循模板实参推导的规则进行”。我们上面的max
示例如果不使用 decltype(auto),按照模板实参的推导规则,是不会有引用和 cv 限定的,就只能推导出返回T
类型。
大家需要注意后置返回类型和返回类型推导的区别,它们不是一种东西,后置返回类型虽然也是写的
auto
,但是它根本没推导,只是占位。
模板的默认实参无处不在,比如标准库的 std::vector,std::string,当然了,这些都是类模板,我们以后会讲到。
非类型模板形参
既然有”类型模板形参“,自然有非类型的,顾名思义,也就是模板不接受类型,而是接受值或对象。
非类型模板形参有众多的规则和要求,目前,你简单认为需要参数是“常量”即可。
非类型模板形参当然也可以有默认值:
后续我们会有更多详细讲解和应用。
重载函数模板
函数模板与非模板函数可以重载。
这里会涉及到非常复杂的函数重载决议,即选择到底调用哪个函数。
我们用一个简单的示例展示一部分即可:
通常优先选择非模板的函数。
可变参数模板
和其他语言一样,C++ 也是支持可变参数的,我们必须使用模板才能做到。
老式 C 语言的变长实参有众多弊端,参见。
同样的,它的规则同样众多繁琐,我们不会说太多,以后会用到的,我们当前还是在入门阶段。
我们提一个简单的需求:
我需要一个函数 sum,支持 sum(1,2,3.5,x,n...) 即函数 sum 支持任意类型,任意个数的参数进行调用,你应该如何实现?
首先就要引入一个东西:形参包
本节以 C++14 标准进行讲述。
模板形参包是接受零个或更多个模板实参(非类型、类型或模板)的模板形参。函数形参包是接受零个或更多个函数实参的函数形参。
这样一个函数,就可以接受任意类型的任意个数的参数调用,我们先观察一下它的语法和普通函数有什么不同。
模板中需要 typename 后跟三个点 Args,函数形参中需要用模板类型形参包后跟着三个点 再 args。
args 是函数形参包,Args 是类型形参包,它们的名字我们可以自定义。
args 里,就存储了我们传入的全部的参数,Args 中存储了我们传入的全部参数的类型。
那么问题来了,存储很简单,我们要如何把这些东西取出来使用呢?这就涉及到另一个知识:形参包展开。
sum 的 Args...args
被展开为 const char * args0, int args1, double args2
。
这里我们需要定义一个术语:模式。
后随省略号且其中至少有一个形参包的名字的模式会被展开 成零个或更多个逗号分隔的模式实例。
&args...
中 &args
就是模式,在展开的时候,模式,也就是省略号前面的一整个表达式,会被不停的填入对象并添加 &
,然后逗号分隔。直至形参包的元素被消耗完。
那么根据这个,我们就能写出一些有意思的东西,比如一次性把它们打印出来:
一步一步看:(std::cout << args << ' ' ,0)...
是一个包展开,那么它的模式是:(std::cout << args << ' ' ,0)
,实际展开的时候是:
很明显是为了打印,对,但是为啥要括号里加个逗号零呢?这是因为逗号表达式是从左往右执行的,返回最右边的值作为整个逗号表达式的值,也就是说:每一个 (std::cout << arg0 << ' ' ,0)
都会返回 0,这主要是为了符合语法,用来初始化数组。我们创建了一个数组 int _[]
,最终这些 0 会用来初始化这个数组,当然,这个数组本身没有用,只是为了创造合适的包展开场所。
为了简略,我们不详细说明有哪些展开场所,不过我们上面使用到的是在花括号包围的初始化器中展开。
只有在合适的形参包展开场所才能进行形参包展开。
我们再给出一个数组的示例:
在函数形参列表中展开。
我们复用了之前写的 print 函数,我们看新的 f 函数即可。
const T(&array)[N]
注意,这是一个数组引用,我们也使用到了非类型模板形参 N
;加括号,(&array)
只是为了区分优先级。那么这里的 T
是 int,N
是 10,组成了一个数组类型。
不必感到奇怪,内建的数组类型,其 size 也是类型的一部分,这就如同 int[1]
和 int[2]
不是一个类型一样,很正常。
print(array[index]...);
其中 array[index]...
是包展开,array[index]
是模式,实际展开的时候就是:
array[arg0], array[arg1], array[arg2]
到此,如果你自己写了,理解了这两个示例,那么你应该就能正确的使用形参包展开,那就可以正确的使用基础的可变参数函数。
那么回到最初的需求,实现一个 sum
:
std::common_type_t
的作用很简单,就是确定我们传入的共用类型,说白了就是这些东西都能隐式转换到哪个,那就会返回那个类型。
RT _[]{ static_cast<RT>(args)... };
创建一个数组,形参包在它的初始化器中展开,初始化这个数组,数组存储了我们传入的全部的参数。
因为窄化转换禁止了列表初始化中 int 到 double 的隐式转换,所以我们需要显式的转换为“公共类型”
RT
。
至于 sizeof...
很简单,单纯的获取形参包的元素个数。
其实也可以不写这么复杂,我们不用手动写循环,直接调用标准库的求和函数。
我们简化一下:
RT{}
构造一个临时无名对象,表示初始值,std::begin 和 std::end 可以获取数组的首尾地址。
当然了,非类型模板形参也可以使用形参包,我们举个例子:
这很合理,无非是让模板形参存储的不再是类型形参包,而是参数形参包罢了。
在后面的内容,我们还会向你展示新的形参包展开方式:C++17 折叠表达式。不用着急。
模板分文件
新手经常会有一个想法就是,对模板进行分文件,写成 .h
.cpp
这种形式。
这显然是不可以的,我们给出了一个项目示例。
后续会讲如何处理
在聊为什么不可以之前,我们必须先从头讲解编译链接,以及 #include
的知识,不然你将无法理解。
include 指令
先从预处理指令 #include
开始,你知道它会做什么吗?
很多人会告诉你,它就是简单的替换,的确,没有问题,但是我觉得不够明确,我给你几个示例:
g++ main.cpp -o main
./main
直接编译运行,会打印出 1 2 3 4 5
。
#include"array.txt"
直接被替换为了 1,2,3,4,5
,所以 arr 是:
或者我们可以使用 gcc 的 -E
选项来查看预处理之后的文件内容:
去除头文件打印之类的是因为,iostream 的内容非常庞大,不利于我们关注数组 arr。
g++ -E main2.cpp
# 0
# 1
这些是 gcc 的行号更改指令。不用过多关注,不是当前的重点,明白 #include 会进行替换即可。
分文件的原理是什么?
我们通常将函数声明放在 .h
文件中,将函数定义放在 .cpp
文件中,我们只需要在需要使用的文件中 include
一个 .h
文件;我们前面也说了,include
就是复制,事实上是把函数声明复制到了我们当前的文件中。
test.h
只是存放了函数声明,函数定义在 test.cpp
中,我们编译的时候是选择编译了 main.cpp
与 test.cpp
这两个文件,那么为什么程序可以成功编译运行呢?
是怎么找到函数定义的呢?明明我们的 main.cpp 其实预处理过后只有函数声明而没有函数定义。
这就是链接器做的事情,如果编译器在编译一个翻译单元(如 main.cpp)的时候,如果发现找不到函数的定义,那么就会空着一个符号地址,将它编译为目标文件。期待链接器在链接的时候去其他的翻译单元找到定义来填充符号。
我们的 test.cpp
里面存放了 f
的函数定义,并且具备外部链接,在编译成目标文件之后之后,和 main.cpp
编译的目标文件进行链接,链接器能找到函数 f
的符号。
不单单是函数,全局变量等都是这样,这是编译链接的基本原理和步骤。
类会有所不同,总而言之后续视频会单独讲解的。
那么不能模板不能分文件的原因就显而易见了,我们在讲使用模板的时候就说了:
模板,只有你“用”了它,才会生成实际的代码。
你单纯的放在一个 .cpp
文件中,它不会生成任何实际的代码,自然也没有函数定义,也谈不上链接器找符号了。
所以模板通常是直接放在
.h
文件中,而不会分文件。或者说用.hpp
这种后缀,这种约定俗成的,代表这个文件里放的是模板。
总结
事实上函数模板的各种知识远不止如此,但也足够各位目前的学习与使用了。
不用着急,后面会有更多的技术和函数模板一起结合使用的,本节所有的代码示例请务必全部理解和自己亲手写一遍,通过编译,有任何不懂一定要问,提出来。
Last updated