第5章 深入理解模板
C++模板应用的便利性远远超出了它只是一种“T类型容器”(containers of T)的范畴。尽管其最初的设计动机是为了能产生类型安全的通用容器,但在现代C++中,模板也用来生成自定义代码,这些代码通过编译时的程序设计构造来优化程序的执行。
本章将从实用的角度来看看现代C++中利用模板编程的强大能力(以及缺陷)。对于与模板相关的C++语言的优点和缺陷的更完备的分析,推荐读者阅读由Daveed Vandevoorde和Nico Josuttis[1]所著的那本极棒的书。
5.1 模板参数
正如在第1卷中描述的那样,模板有两类:函数模板和类模板。二者都是由它们的参数来完全地描绘模板的特性。每个模板参数描述了下述内容之一:
1)类型(或者是系统固有类型或者是用户自定义类型)。
2)编译时常数值(例如,整数、指针和某些静态实体的引用,通常是作为无类型参数的引用)。
3)其他模板。
第1卷中所举的例子都属于第1种情况,也是最常用的。现在作为简单的类似容器模板的典型示例似乎就是Stack类。作为容器,Stack对象与容器中存储的对象的类型毫无关联;持有对象的逻辑独立于所持有的对象的类型。基于这个原因,可以用一个类型参数来代表所包含的类型:
某个特定的Stack实例所使用的实际类型,由参数T的实参类型来决定:
编译器通过用int替代T生成相应的代码,从而提供了一个Stack的int版。在这个例子中,由模板生成类的实例的名字是Stack<int>。
5.1.1 无类型模板参数
一个无类型模板参数必须是一个编译时所知的整数值。举个例子,可以创建一个固定长度的Stack,指定一个无类型参数作为其中基础数组的大小,如下所示:
当需要这个模板的一个实例时,必须为参数N提供一个编译时常数值,例如:
由于N的值在编译时是已知的,内含的数组(data)可以被置于运行时堆栈而不是动态存储空间。这种方式避免了与动态内存分配的高层关联,从而提高了运行性能。根据之前提过的模式,上述模板的实例化类名字是Stack<int,100>。这意味着任何一个N的不同取值都会产生一个惟一的类类型。例如,Stack<int,99>与Stack<int,100>就是两个不同的类。
将在第7章详细讨论的bitset类模板,是标准C++库中惟一使用了无类型模板参数(它指定了bitset对象所持有的位的数目)的类。下面的随机数生成器的例子使用了bitset来跟踪这些数,这样在随机数生成器下一次工作周期重新开始之前,所有在允许范围内的数都将无重复地按照随机顺序返回。这个例子也重载了运算符operator(),用来产生一个熟悉的功能调用语法。
由Urand生成的数全是独一无二的,这是因为bitset used跟踪了随机空间中(上限设置成模板参数)所有可能产生的数,并且设置相应的状态位来记录每一个使用过的数。当这些数全都用完了之后,bitset被清空以便为下一次工作重新开始做准备。下面是一个描述如何使用Urand对象的简单的驱动程序:
正像将在本章后面解释的那样,无类型模板参数在优化数值的计算方面也是很重要的。
5.1.2 默认模板参数
在类模板中,可以为模板参数提供默认(缺省)参数,但是在函数模板中却不行。作为默认的模板参数,它们只能被定义一次,编译器会知道第1次的模板声明或定义。一旦引入了一个默认参数,所有它之后的模板参数也必须具有默认值。例如,为了使前面介绍的固定大小的Stack模板更友好一些,可以加入一个默认参数,如下所示:
现在,如果在声明一个Stack对象时省略了第2个模板参数,N的值将默认为100。
也可以为所有参数提供默认值,但当声明一个实例时必须使用一对空的尖括号,这样编译器就知道说明了一个类模板。
默认参数大量用于标准C++库中。比如vector类模板声明如下:
注意,在最后两个右尖括号字符之间有空格。这就避免了编译器将那两个字符(>>)解释为右移运算符。
这个声明说明了vector有两个参数:一个参数表示它持有的包含对象的类型,另一个参数代表vector所使用的分配器。任何时候只要省略了第2个参数,就会使用标准allocator模板,它的参数由第1个模板参数来确定。这个声明也说明,可以在随后的次一级模板的参数中使用该模板参数,就像在这里使用T一样。
尽管不能在函数模板中使用默认的模板参数,却能够用模板参数作为普通函数的默认参数。下面的函数模板在参数列表中加入了一个元素:
sum()的第3个参数是作为对这些元素进行累积运算的初始值。由于省略了它,这个参数就默认为是T(),在这里是int或其他系统固有的类型,它调用了一个伪构造函数执行零初始化操作。
5.1.3 模板类型的模板参数
模板可以接受的第3种模板参数类型是另一个类模板。如果想在代码中将一个模板类参数用作另一个模板,编译器首先需要知道这个参数是一个模板。下面的例子说明了一个模板类型的模板参数:
Array类模板是个很平常的序列容器。Container模板包含两个参数:一个参数是它持有的类对象类型,还有一个参数是它持有的类对象类型的序列数据结构。在Container类的实现中下面一行语句通知编译器,Seq是一个模板:
如果还没有声明Seq是一个模板类型的模板参数,编译器就不会在这里将Seq解释为一个模板,尽管已经如此使用了它。在main()中使用了一个持有整数的Array将一个Container实例化,因此本例中的Seq代表Array。
注意,在本例Container的声明中对Seq的参数进行命名不是必需的。所讨论的这一行是:
尽管可以这样写:
无论什么地方参数U都不是必需的。加上这个参数仅仅是为了说明Seq是一个持有单一类型参数的类模板。这种情况类似于某些时候省略函数参数的名称,当不需要它们的时候就可以省略掉。例如当重载自增(增1)运算符++时:
这里的int仅仅是一个占位符,并不需要有变量名称。
下面的程序使用了一个固定大小的数组,它有一个特别的模板参数表示数组的长度:
再说明一次,在Container的声明内部,Seq的声明中参数名称不是必需的,但是需要有两个参数来声明数据成员seq,所以无类型参数N出现在模板型参数前面。
结合一下默认参数和模板型的模板参数就会发现一些细微的深一层问题。当编译器看到模板型模板参数的内部参数时,无法顾及到默认参数,因此为了得到一个确切的匹配,必须重复声明默认参数。下面的例子中,在固定大小的Array模板中使用了一个默认参数,这个例子也显示了如何在C++语言中适应这个古怪的举动。
在下面这行语句中默认值的大小为10是必须的:
不管是在Container中seq的定义,还是在main()中container的定义都使用了默认值。本例与TempTemp2.cpp惟一的不同点,就是使用了默认值。这也是与前面所陈述的规则—即默认参数在一个编辑单元内仅能出现一次—惟一的例外。
由于标准序列容器(vector、list和deque,它们将在第7章中深入讨论)都有一个默认的分配器参数,上面讲解到的技术能帮助实现我们曾经有过的一个想法:传递这些序列容器中的一个作为模板参数。下面的程序分别传递vector模板类型参数和list模板类型参数创建了Container的两个实例:
这里命名了内部模板Seq的第1个参数(使用名字U),这是因为标准序列容器的分配器必须使用与序列容器中所包含对象的类型相同的类型对自己进行参数化。同时,还由于默认的allocator参数是已知的,就可以像在前述程序中一样,在随后引用的Seq<T>中省略掉它。然而,要想彻底地解释清楚这个例子,还必须讨论一下typename这个关键字的语义:
5.1.4 typename关键字
考虑下面的程序:
这个模板定义假定,处理的类T必须拥有某种称为id的嵌套标识符。id也可以是一个T的静态数据成员,这样就可以直接对id进行操作,但却不能“创建类型id”的“某个对象”,在这个例子中,标识符id被当作T内的一个嵌套类型处理。至于类Y, id本来就是它的一个嵌套类型(没有typename关键字),但编译器在编译类X的时候却根本不知道这些。
当模板中出现一个标识符时,若编译器可以在把这个标识符当作一个类型,或把它当作一个除类型之外的其他元素之间进行选择的话,则编译器将不会认为这个标识符是一个类型。也就是说,它会认为这个标识符指的是一个对象(其中包括那些基本类型的变量),或者是一个枚举,或者是其他什么。但是它绝不会—也不可能—认为它是一个类型。
由于在上述两种情况下,编译器默认的行为不会认为一个标识符名称是一个类型,因此必须对嵌套的名称使用typename关键字进行说明(除了在构造函数的初始化列表中,这时它的出现既不是必要的也不是允许的)。在上例中,当编译器看到typename T:id,它就会明白(由于关键字typename)id指的是一个嵌套类型,之后它就可以创建一个这个类型的对象了。
这个规则的简化叙述就是:若一个模板代码内部的某个类型被模板类型参数所限定,则必须使用关键字typename作为前缀进行声明,除非它已经出现在基类的规格说明中,或者它出现在同一作用域范围内的初始化列表中(这种情况下一定不要使用typename关键字)。
上面解释了关键字typename在程序TempTemp4.cpp中的使用。没有它,编译器就不会认为Seq<T>:iterator表达式是一个类型,而在程序中却要用它来定义成员函数begin()和end()的返回类型。
下面的例子定义了一个函数模板,它能够打印任意标准C++序列容器中的数据,这个例子使用了与typename类似的一种用法:
同前面一样,若没有typename关键字,编译器就会把iterator看作是Seq<T>的一个静态数据成员,这是一个语法错误,因为这里要求它是一个类型。
1.创建一个新类型
有一点很重要:一定不能认为关键字typename创建了一个新类型名。它确实没有。它的目的就是要通知编译器,被限定的那个标识符应该被解释为一个类型。请看下面这行语句:
它产生一个名为It的变量,该变量被声明为Seq<T>:iterator类型。若想创建一个新类型名,通常应该使用关键字typedef,如下所示:
2.用typename代替class
关键字typename的另一个作用是,可以在模板定义的模板参数列表中选择使用typename代替class:
对大多数程序而言,这种描述方式使得代码更加一目了然。
5.1.5 以tempIate关键字作为提示
当一个类型标识符不是预期的标识符时,正好typename关键字可以帮助编译器识别它们,但编译器却还存在一些潜在的困难,比如‘<’字符和‘>’字符,它们不是标识符而是标记号(token)。有时它们代表小于号或大于号,而有时它们又作为模板参数列表的界定符。在这里,再次用bitset类作为例子来说明这个问题:
类bitset通过它的to_string成员函数支持向字符串对象的转换。为了支持向多种字符串类的转换,to_string本身就做成了一个模板,它是根据第3章讨论的basic_string模板模式创建的。bitset中的to_string的声明如下所示:
上面的bitsetToString()函数模板可以要求用不同类型的字符串表示bitset。例如,若想获得一个宽字符串,可以改写成如下的调用形式:
注意,basic_string使用了默认的模板参数,这样在返回值中就不必重复char_traits和allocator参数。遗憾的是,bitset:to_string没有使用默认参数。使用bitsetToString<char>(bs)比每次都写出长长的完全地限制调用bs.template to_string<char, char_traits, allocator<char>>()要方便得多。
bitsetToString()的返回语句中包含了template关键字,有趣的是,它位于作用在bitset对象bs上的点运算符之后的右边位置。使用这个关键字的原因是,如果解析这个模板,to_string标记之后的“<”字符就会被解释为一个小于号而不是一个模板参数列表的开始标记。这里的template关键字的使用会告诉编译器,紧接着的是一个模板名称,这样“<”字符就会被正确地解释出来。基于同样的原因,这种用法也会用在运用于模板中的->和:的运算符上。与typename关键字一样,这种模板解析技术仅仅能用于模板代码[2]中。
5.1.6 成员模板
bitset:to_string()函数模板是一个成员模板的例子:在另一个类或者类模板中声明的一个模板。它允许一些独立的模板参数结合,以便组合使用。标准C++库中的complex类模板就是一个有用的例子。complex模板有一个类型参数,它代表一个拥有复数的实部和虚部的基础浮点类型。下面的代码片断是从标准库中摘录出来的,它说明了在complex类模板中的成员模板构造函数:
标准的complex模板使用已有的类型如float、double和long double等对参数T进行特化。上面的成员模板构造函数创建了一个新复数,这个复数使用了另外一个浮点类型作为它的基类型,如下所示:
在w的声明中,complex模板参数T是double类型,X是float类型。成员模板使得这种灵活的变换更加容易。
在模板中定义另一个模板是一种嵌套操作,如果想在外部类的定义之外定义成员模板,那么作为引入模板的前缀必须能够反映这种嵌套。例如,如果要实现complex类的模板,还想在complex模板类定义之外定义成员模板构造函数,可以如下定义:
标准库中成员函数模板的另一个应用是在容器的初始化中。假设有一个int型的vector,要想用它初始化一个新的double型的vector,如下所示:
只要v1中的元素与v2中的元素类型兼容(这里就是double型和int型)即可。vector类模板有如下成员模板构造函数:
这个构造函数在vector声明中使用了两次。v1用int型数组进行初始化时,InputIterator的类型是int*。v2使用v1进行初始化时,使用了成员模板构造函数的一个实例,用InputIterator表示vector<int>:iterator。
成员模板也可以是类(不一定必须是函数)。下面的例子说明了一个外部类模板内的成员类模板:
在第8章中将会详细阐述typeid运算符,它只有一个参数并返回一个type_info对象,这个对象的name()函数生成一个表示参数类型的字符串。例如,typeid(int).name()返回字符串“int”(实际的返回值与具体的操作平台有关)。typeid运算符也可以用一个表达式作参数,返回一个代表这个表达式类型的type_info对象,例如,对于int i, typeid(i).name()返回的内容类似“int,”而typeid(&i).name()返回的内容类似“int*”。
上述程序的输出应该如下所示:
主程序中变量inner的声明同时实例化了Inner<bool>和Outer<int>。
成员模板函数不能被声明为virtual类型。当今的编译器技术在解析一个类时,希望知道这个类的虚函数表的大小。如果允许虚成员模板函数的存在,则需要提前知道程序中所有这些成员函数的调用在什么位置。这是很不灵活的,尤其是在多文件项目中。
[1]Vandevoorde和Josuttis所著的《C++Templates:The complete Guide》Addison Wesley,2003。注意“Daveed”有时会写作“David”。
[2]C++标准协会正在考虑解除这些解析提示仅仅适用于模板中的规则的限制,有一些编译器已经允许将它们用于非模板代码中。