5.2 有关函数模板的几个问题

正如一个类模板描述了一族类,一个函数模板描述了一族函数。产生每种模板类型的语法本质上是相同的,只是在如何使用上有点区别。当在实例化类模板时总是需要使用尖括号并且提供所有的非默认模板参数。然而,对于函数模板,经常可以省略掉模板参数,甚至根本不允许使用默认模板参数。仔细看一下在<algorithm>头文件中声明的min()函数模板的实现,如下所示:

5.2 有关函数模板的几个问题 - 图1

可以通过提供尖括号里面的参数类型来调用这个模板,正如对类模板的操作一样,如下所示:

5.2 有关函数模板的几个问题 - 图2

这个语法告诉编译器,min()模板需要在参数T的位置使用int来进行特化,这样编译器就会产生相应的代码。依据从类模板中产生类的命名模式,可以认为这个实例化的函数名称是min<int>()。

5.2.1 函数模板参数的类型推断

可以像上面的例子一样,一直使用这样明确的函数模板特化方式,但是不明确指定模板参数类型,而让编译器从函数的参数中推断出它们的类型将会更方便,如下所示:

5.2 有关函数模板的几个问题 - 图3

如果i和j都是int类型,编译器会知道程序需要的是min<int>(),之后它会自动进行实例化。由于在模板定义时指定了惟一的模板类型参数用于函数的两个参数,因此这两个参数的类型必须一致。对于由一个模板参数来限定类型的函数参数,C++系统不能提供标准转换。例如,若想在两个不同类型的参数(一个int型和一个double型)中找出其中的最小值,下面的这种min()调用将会出错:

5.2 有关函数模板的几个问题 - 图4

由于x和j是不同的类型,没有单一的参数与min()定义中的模板参数T匹配,因此这个调用与模板声明也不匹配。要解决这个问题,可以将一个参数的类型转换为另一个参数的类型,或者恢复到完全说明调用语法,如下所示:

5.2 有关函数模板的几个问题 - 图5

该语句告诉编译器产生double版本的min(),之后j通过标准类型转换规则向上转型成double型数据(因为函数min<double>(const double&,const double&)会自动产生转换)。

也可以要求min()的两个参数类型完全独立,如下所示:

5.2 有关函数模板的几个问题 - 图6

这通常会是一个好办法,但由于min()必须返回一个值,却没有一个理想的方式来决定这个返回值的类型到底是T还是U,因此这里的这个“好办法”还是有问题的。

若一个函数模板的返回类型是一个独立的模板参数,当调用它的时候就一定要明确指定它的类型,因为这时已经无法从函数参数中推断出它的类型了。下面的fromString模板就是这样的例子。

5.2 有关函数模板的几个问题 - 图7

这些函数模板提供了std:string与任意类型之间的转换,二者分别给出了一个流类插入符和提取符。下面是一个使用了包含在标准库中复数(complex)类的测试程序:

5.2 有关函数模板的几个问题 - 图8

5.2 有关函数模板的几个问题 - 图9

输出和预期的结果相同:

5.2 有关函数模板的几个问题 - 图10

注意,在每一个fromString()的实例化调用中,都指定了模板参数。如果有一个函数模板,它的模板参数既作为参数类型又作为返回类型,那么一定要首先声明函数的返回类型参数,否则就不能省略掉函数参数表中的任何类型参数。作为一个示例,看看下面这个著名的函数模板:[1]

5.2 有关函数模板的几个问题 - 图11

如果将程序中靠近文件顶部的模板参数列表中的R和P交换一下,这个程序将不能通过编译,这是因为没有指定函数的返回类型(第1个模板参数将作为函数的参数类型)。最后一行(被注解掉的)也是不合法的用法,原因是没有从int到char*的标准类型转换。implicit_cast显示了代码中允许的类型转换。

稍加注意,甚至可以用这种办法推断出数组的维数。下面的例子中有一个数组初始化函数模板(init2),它进行了这样的推断:

5.2 有关函数模板的几个问题 - 图12

5.2 有关函数模板的几个问题 - 图13

数组维数没有被作为函数参数类型的一部分进行传递,除非这个参数是指针或引用。函数模板init2声明了a是一个二维数组的引用,因此它的维数R和C可由模板很容易地推断出来,这样就使得init2可以非常方便地初始化一个任意大小的二维数组。模板init1不是通过引用来传递数组,因此数组的大小必须被明确指定,尽管也可以推断出它的类型参数。

5.2.2 函数模板重载

像普通函数一样,也可以用相同的函数名重载函数模板。编译器在处理程序中的函数调用时,它必须能够知道哪一个模板或普通函数是最适合调用的函数。

接着前面介绍的min()函数模板,在这里再添加几个普通函数:

5.2 有关函数模板的几个问题 - 图14

除了函数模板,这个程序还定义了两个非模板函数:一个C语言风格的字符串版本和一个double版本的min()函数。若这个程序中不存在函数模板,上面主函数第1行的函数调用将会调用double版本的min()函数,这是由于int型可以经标准转换为double型。由于模板能够产生一个int版本的min()函数,这肯定是最佳的匹配,因此事实上就是这样进行的。第2行中的调用是一个double版本的min()函数的准确匹配,第3行也调用了同一个函数,只是在内部将1转变成1.0。第4行中,直接调用了min()函数的const char版本。第5行在函数名后加一对空的尖括号来强迫编译器使用模板,因此编译器从模板中生成它的一个const char版本来使用(从它的错误输出可以证实—这个函数比较了两个字符串的地址![2])。如果想知道为什么在应该用using namespace std的地方使用了几个using声明,这是因为有些编译器在其中包含了std:min()的头文件,这将会与在程序中命名的min()声明发生冲突。

如上所述,只要编译器能够区分开,就可以重载同名的模板。例如可以声明一个包含3个参数的min()函数模板:

5.2 有关函数模板的几个问题 - 图15

这个模板版本仅仅是为了调用带有3个同类型参数的min()函数而生成的。

5.2.3 以一个已生成的函数模板地址作为参数

在很多情况下需要获得一个函数的地址。例如,可以生成一个函数,它的参数是一个指向另一个函数的指针。此处的另一个函数有可能就是由一个函数模板生成的,因此需要以某种方式来处理这种以函数模板的地址做参数的情况:[3]

5.2 有关函数模板的几个问题 - 图16

这个例子说明了如下几个问题。首先,既然使用模板,所有的标识就必须匹配。函数h()有一个指针参数,这个指针指向一个函数—它有一个int*型参数,返回值类型为void。这个函数就是模板f()生成的函数。其次,拥有一个函数指针作参数的函数本身可以是一个模板,如本例中的函数模板g()。

也可以在main()中看到类型推断。第1个对h()的调用明确地给出了f()的模板参数,但由于h()规定只接收具有int*参数的函数地址作参数,因此第2个调用由编译器来推断类型。至于g(),它的情况就更加有趣了,因为它在其中引用了两个模板。如果什么都不给,编译器就推断不出类型;但若说明了一个int,或者赋予f()或者赋予g(),余下的类型编译器自己就能够推断出来。

当想把在<cctype>中声明的tolower或toupper传递给函数做参数时,就会出现一个模糊的问题。例如,在编程中,有可能使用它们和transform算法(将在下一章详细阐述)将一个字符串转变成小写或者大写。必须小心使用,因为存在多个有关这些函数的声明。一个初学者的使用方法可能会像下面这样:

5.2 有关函数模板的几个问题 - 图17

transform算法的第4个参数(在这个例子中就是tolower())作用到字符串s中的每一个字符上,这个算法还把结果写回s中,也就是将s中的每一个字符都用它的小写形式进行重写。作为一条语句把它写在那里,但这个语句有可能执行,也可能根本就没有执行!在下面的情况下它就执行失败了:

5.2 有关函数模板的几个问题 - 图18

即使编译器让这个程序侥幸通过,它也是不合法的。原因是<iostream>头文件中也建造了可利用的具有两个参数的tolower()和toupper()版本:

5.2 有关函数模板的几个问题 - 图19

这两个函数模板的第2个参数是locale类型参数。在上面的程序中,编译器无法得知它应该使用<cctype>中定义的具有一个参数的tolower()版本还是上述的版本。可以(几乎可以!)用transform调用中的类型转换来解决这个问题,如下所示:

5.2 有关函数模板的几个问题 - 图20

(用int代替char后重新调用tolower()和toupper()函数执行。)上面的类型转换很清楚地表达了想要使用具有一个参数的tolower()函数版本的期望。这种做法对某些编译器可能会成功,但并不是所有的编译器都是如此。其原因有点晦涩难懂:在C语言中允许一个库的实现连接“C连接”(意味着函数名称不包含所有的辅助信息[4],而正常的C++函数却包含)到从C语言继承过来的函数。如果是这样话,类型转换则失败:因为transform是一个C++函数模板,它期待它的第4个参数进行C++连接—并且类型转换也不允许改变这种连接。我们又陷入了困境!

解决的办法是在一个语义明确的语境中调用tolower()。例如,可以编写一个名叫strTolower()的函数,将它放在一个不包含<iostream>的独立的文件中,如下所示:

5.2 有关函数模板的几个问题 - 图21

该程序没有包含头文件<iostream>,在这种语境中,程序使用的编译器就不会引入带有两个参数的tolower()版本[5],当然也就不会产生任何问题。经过这样处理之后,就可以正常使用这个函数了:

5.2 有关函数模板的几个问题 - 图22

另一个解决办法是写一个经过封装的函数模板,用来清楚地调用正确的tolower()版本:

5.2 有关函数模板的几个问题 - 图23

这种版本有一个好处,即由于基础字符类型是一个模板参数,因而该模板既可以处理宽字符串,也可以处理窄字符串。C++标准委员会正致力于修订语言,使得第1个例子(没有使用类型转换的)能够执行,也许过不了多久这些外围工作就可以被忽略了(由C++标准来完成)。[6]

5.2.4 将函数应用到STL序列容器中

假设要想获得一个STL序列容器(更多的内容将在后面的章节中学习;现在只用到STL序列容器家族中的vector),并且想将一个成员函数应用到这个容器包含的所有对象中。因为一个vector可以包含任意类型的对象,这就需要一个可以应用到任意类型的vector对象的函数:

5.2 有关函数模板的几个问题 - 图24

上面的apply()函数模板有一个对容器类进行引用的引用参数,还有一个指针参数,它指向容器类中包含的对象的一个成员函数。这个模板使用一个迭代器遍历该序列,并且在每个对象上调用这个成员函数。现在已经重载了const版本的函数模板,这样一来,在const和非const函数中就都可以调用这个成员函数了。

注意,在applySequence.h中没有包含STL头文件(也没有包含其他的头文件),因此它并不局限于只能用于STL容器。然而,它的假设(主要是由于iterator的名称及行为)确实是用于STL序列容器中,而且它也假设容器的元素都是指针类型。

读者可以看到有多个apply()版本,更进一步地说明了函数模板的重载。尽管这些模板允许返回任意类型的值(这点被忽略了,但类型信息要求匹配指向成员函数的指针),每一个版本都带有不同个数的参数,并且由于这是模板,因此它们的参数可以是任意类型。在此惟一的缺陷,就是没有提供一个可以产生模板的“超模板”;读者必须亲自决定究竟需要几个参数来选用合适的模板定义。

为了测试一下apply()的这几个重载版本,这里创建了一个类Gromit[7],它包含几个带有不同个数参数的函数,以及由const和非const的成员函数:

5.2 有关函数模板的几个问题 - 图25

现在,可以用apply()模板函数来调用vector<Gromit*>对象中包含的Gromit的成员函数了,如下所示:

5.2 有关函数模板的几个问题 - 图26

5.2 有关函数模板的几个问题 - 图27

purge()函数是一个小型的实用程序,该函数调用delete来清除序列上的所有元素。读者将会在第7章找到它的定义,并且本教材在许多的地方都会用到它。

尽管apply()的定义有点复杂,而且不像读者所希望的那样使初学者都可以理解,但是它使用起来却非常简单明了,初学者也可以在使用中知道它企图完成什么功能,而不必知道它是如何完成的。这也是所有程序组件应该追求的目标。复杂的细节由程序组件的设计者来完成。而用户只关心完成他们的目标,他们不必看、不必了解、也不依赖那些底层实现的细节。在下一章中要探索将函数应用到序列容器中的更灵活的方式。

5.2.5 函数模板的半有序

前面曾经提到过,使用像min()函数这样的普通函数重载,比使用函数模板更可取。如果一个函数可以匹配某个函数调用,为什么还要再生成另外一个函数呢?在缺少普通函数时,对函数模板进行重载有可能引起二义性(ambiguity)的情况,即不知选择哪个模板。为了将发生这种情况的几率降到最低,系统为这些函数模板定义了次序(ordering),在生成模板函数的时候,编译器将从这些函数模板中选择特化程度最高(most specialized)的模板(如果有这种模板的话)。一个函数模板要考虑多种特化,在这些特化的模板中对于某个特定的函数模板来说,如果每一种可能的参数列表的选择都能够匹配该模板的参数列表,那么,这些可能的参数列表选择也都能够匹配另一个函数模板的参数列表,但反过来却不成立。请看下面的函数模板声明,取自C++标准文档:

5.2 有关函数模板的几个问题 - 图28

任何类型都可以匹配第1个模板。第2个模板比第1个模板的特化程度更高,因为只有指针类型才能够匹配它。换句话说,可以把匹配第2个模板的一组可能的函数调用看做匹配第1个模板的子集。上面的第2个模板和第3个模板的声明也存在类似的关系:第3个仅仅能被指向const的指针匹配调用,但第2个模板包含了任意的指针类型。下面的程序说明了这些规则:

5.2 有关函数模板的几个问题 - 图29

5.2 有关函数模板的几个问题 - 图30

f(&i)调用和第1个模板匹配,但由于第2个模板的特化程度更高,因此这里调用了第2个模板。在此处第3个模板不能被调用,这是因为该指针不是指向const的指针。f(&j)调用匹配了所有这3个模板(比如,在第2个模板中的T就是const int),但是由于同样的原因,第3个模板特化程度更高,因此实际上调用了它。

如果在一组重载的函数模板中没有“特化程度最高”的模板,则会出现二义性,编译器将会报错。这就是为什么把这种特征叫做“半有序(partial ordering)”的缘故—它不可能完全解决所有可能出现的情况。类似的规则同样也存在于类模板中(参见5.3.2节)。

[1]参见Stroustrup,《The C++Programming Language》,第3版,Addison Wesley,第335~336页。

[2]从技术上说,对不在同一个数组中的两个指针的比较是一种不明确的行为,但如今的编译器不再对此做出解释。所有要这样做的理由都认为是正确的。

[3]感谢Nathan Myers提供了这个例子。

[4]例如在一个修饰的名称中被编码的类型信息。

[5]C++编译器能够引入它们想要的任何地方的名称,然而幸运的是,大多数编译器对自己不需要的名称不会进行声明。

[6]若对关于C++语言修改的建议感兴趣,可以参看Core Issue 352。

[7]参考由Nick Park出品的描写Wallace和Gromit英国动画短片。