第3章 类模板特化

一个类模板可以具有一个或者多个模板参数。当这组模板参数被设定为一组具体类型时,实例化后的类就能够处理这组具体类型的数据。模板参数可以被设定为任意类型,使得这个类模板可以处理任意类型的数据,这是泛型编程思想所要达到的目标。然而,在某些场合下,当模板参数被设定为某些特定类型时,我们希望实例化后的类具有特定的行为,这就需要使用类模板特化技术。

3.1节用一个具体例子阐述类模板特化的概念,之后的几节介绍它的应用。其中,3.2节介绍使用类模板特化,以一个统一的编程接口描述各种数据类型的具体信息,比如float、double类型的表示范围与精度,这种方法被称为traits技术。3.3节介绍一个与traits技术相似的类型分类(type classifciation)技术,该技术侧重于描述数据类型的基本信息。使用类模板技术的一个潜在问题是代码膨胀:每当一个类模板被实例化一次,实例化的代码段就会被添加到程序代码空间,可能导致程序代码段变得很长。使用类模板特化技术可以有效地减少代码膨胀的程序,3.4节介绍这种方法的基本思路。

本章仅阐述类模板特化技术的概念和基本应用,本书第6章介绍如何使用traits技术来描述iostream中单字节字符以及宽字节字符的类型信息,如何使用类模板特化技术来产生能分别处理单字节字符、宽字节字符的两套类。而第9章9.3节将介绍如何使用类型分类技术来优化QList的性能。

类模板特化技术只是C++模板技术以及泛型编程(generic programming)思想的一个典型应用。如果读者希望全面而系统地学习模板技术,可以参考文献[14]。如果读者希望学习并使用泛型编程思想设计一个C++库,或者想令一个软件系统具有更好的可扩展性,可以参考文献[15]。

3.1 类模板特化

代码段3-1使用STL中的容器vector构建了一个类模板Stack,实现栈的功能。栈的所有操作都在该容器的尾部进行。这个类模板并没有使用类模板特化技术,因此,无论模板参数T是什么类型,Stack都具有相同的行为。类模板特化将在它的基础上进行,因而这个类模板被称为主类模板(primary class template)。

代码段3-1,类模板Stack,引自z:\examples\template_specialization

      template <typename T>
      class Stack {
        private:
            std::vector<T> elems;
        public:
            void push(T const&elem) {
              elems.push_back(elem);
            }
            void pop(){
            if (elems.empty()) return;
            elems.pop_back();
            }
            T top() const{
            if (elems.empty()) return;
            return elems.back();
            }
      }

假设当模板参数是string类型时,我们需要在该类模板的内部使用STL的deque而不是vector来存放栈元素。这需要对类模板Stack进行特化处理,如代码段3-2所示。行②表示当模板参数为特定的std::string时,应该使用该行之后的代码对类模板Stack进行实例化,而不是使用前文的主类模板进行实例化。实例化后的类被称为特化模板类(specialized template class)。在这个代码段中,特化模板类只针对一个具体的类型(std::string),这种特化被称为完全特化(fully specialization)。对于完全特化,行①尖括号中的内容应该为空。行③定义deque的一个对象,其余代码使用这个对象完成栈的所有操作,与主类模板的行为完全不同,这正是使用模板特化的原因。

代码段3-2,完全特化的类模板Stack,引自examples\template_specialization

      template<>                    ①
      class Stack<std::string> {   ②
        private:
            std::deque<std::string> elems;  ③
        public:
            void push(std::string const & elem) {
                elems.push_back(elem);
            }
            void pop(){
                if (elems.empty()) return;
                elems.pop_back();
            }
            std::string top() const{
                if (elems.empty()) return NULL;
                return elems.back();
            }
      }

完全特化只处理某一个具体类型。有些情况下,当模板参数为一些具有某种特征的类型时,我们希望类模板具有特定的行为,这就需要使用部分特化(partially specialization),如代码段3-3所示。行①声明T为一个模板参数,行②表示当模板参数具有T*的特征,也就是在一个指针时,用该行之后的代码对类模板Stack进行实例化,而不是采用主类模板的代码。行③定义STL中list的一个对象,其余代码使用这个对象完成栈的所有操作,与主类模板的行为不同,这是需要使用模板特化的原因。

代码段3-3,部分特化的类模板Stack,引自examples\template_specialization

      template <typename T>        ①
      class Stack<T*>  {            ②
        private:
            std::list<T*> list;     ③
        public:
            void push(T* & elem) {
            list.push_front(elem);
            }
            void pop(){
            if (list.empty()) return;
            list.pop_front();
            }
            T* top() const{
            if (list.empty()) return NULL;
            return list.front();
            }
      }

一般情况下,设有一个主类模板:

      template<typename T1, typename T2, … typename Tn>
      class C {
              ……
      }

特化的类模板应该具有的形式是:

      template<typename P1, typename P2, … typename Pk>      ①
        class C <type1, type2, …, typen> {                  ②
      ……
      }

表示当主类模板的n个模板参数T1,T2,…,Tn分别具有行②中的类型type1,type2,…,typen时,编译器将使用特化类模板(而不是主类模板)中的代码来进行实例化操作。行②中的typei可以是一个确定的类型比如int,也可以是一个模板参数。对于后一种情形,需要在行①使用关键字typename说明typei这个标识符是一个类型的名字。例如,设有一个主类模板:

      template <typename T1, typename T2>
      class MyClass {
        …
      };

则以下代码定义了一个特化类模板,用来处理T2为int的情形:

      template <typename T>
      class MyClass<T,int> {
        …
      };

而以下代码用来处理T1和T2都为指针的情形:

      template <typename T1, typename T2>
      class MyClass<T1*,T2*> {
        …
      };

总之,当模板参数满足一些特殊条件时,我们使用特化类模板来进行实例化。对于其他一般情形,仍然使用主类模板进行实例化。

3.2 Traits技术

Traits技术以一个统一的编程接口,描述各种数据类型的基本特征。例如,对于基本类型float,如果将这种类型所能表示的最大浮点数写为以2为底的指数形式,其指数部分的最大值为128。在float.h中这个常量被定义为FLT_MAX_EXP。而对于基本类型double,这个最大值为1024,被定义为DBL_MAX_EXP。类似地,float和double所能够表示的最小正数也不一致,分别被定义为常量FLT_EPSILON以及DBL_EPSILON。设想我们要设计一个数值分析库,待处理的数值可以被表示为float、double或者long double。由于这几种类型具有不同的指数部分最大值、最小正数等特征,如果不采用traits技术,程序的多个地方需要查询数值的类型并依据该类型的特征做相应的处理。使用traits技术,不同数据类型的特征被封装在一个类模板中,程序其他模块可以使用这个类模板的接口,获得每个数据类型的特征信息。

代码段3-4使用traits技术封装float及double类型的特征信息,向上层软件模块——类模板matrix提供统一的编程接口。我们使用类模板fp_traits(行②)来描述浮点数(floating point)的特征。由于此时的模板参数numT是一个抽象的类型,无法给出具体的特征,因而该类模板的代码为空。接下来的代码使用类模板特化技术分别描述float及double类型的特征,行①~②定义的其实是一个主类模板。行③~④表示当模板参数numT特化为float时,将采用此后的4行代码来实例化该类模板。这段代码描述了float类型的特征,比如最大指数值、最小正数等信息。类似地,行⑤~⑥表示模板参数numT特化为double时类型double的各种特征。

代码段3-4,使用traits技术封装float及double类型的特征,取自z:\examples\float_traits\main.cpp

      template <typename numT>     ①
      struct fp_traits { };        ②
      template<>                    ③
      struct fp_traits<float> {    ④
            typedef float fp_type;
            enum { max_exponent = FLT_MAX_EXP };
            static inline fp_type epsilon()
            { return FLT_EPSILON; }
      };
      template<>                    ⑤
      struct fp_traits<double> {   ⑥
            typedef double fp_type;
            enum { max_exponent = DBL_MAX_EXP };
            static inline fp_type epsilon()
            { return DBL_EPSILON; }
      };
      template <typename numT>
      class matrix {
        public:
            typedef numT num_type;
            typedef fp_traits<num_type> num_type_info;
            inline num_type epsilon()
            {return num_type_info::epsilon();}      ⑦
            ......
      };
      int main()
      {
            matrix <float>  fm;
            matrix <double> dm;
            cout << "float  matrix: " << fm.epsilon() << endl;
            cout << "double matrix: " << dm.epsilon() << endl;
      }

这两个特化的类模板都向上层软件提供了统一的编程接口:类型名fp_type表示其类型,枚举常量max_exponent表示最大指数值,而静态成员函数epsilon()返回最小正数。而上层软件模块——类模板matrix通过这个统一的接口获取类型的信息(行⑦)。这样,各种数据类型的差异性被封装在类模板中,减少了上层软件模板对这个差异性的依赖性,降低了软件模块之间的耦合度。

3.3 类型分类(Type Classification)技术

设一个模板具有模板参数T,表示C++中的某种类型。有些情况下,随着T所取类型的不同,该模板会做不同的处理。在设计该模板时,我们需要了解T的具体信息。

在编写一个具有模板参数T的类模板时,有时需要了解T所取类型的具体信息,以做不同的处理。这是一个棘手的问题,因为C++语言只有很少几个运算符能够处理“类型”:sizeof、dynamic_cast以及typeid。和我们想要获取的类型信息相比,这些运算符提供的信息太少。运算符sizeof只能够求取一个类型的对象所占用的内存空间。对于含有虚函数的类,dynamic_cast可被用来判断一个对象是否具有某个类型,但是这个运算符只能施加到一个指针(或引用),不能施加到一个类型。运算符typeid倒是适用于所有类型(或者这些类型的对象),但是它只能获得一个类型的名字信息。关于最后两个运算符的详细信息,请参见17.1节。

既然C++本身不能够提供足够详细的信息,我们可以使用类模板特化技术,设计专门的类模板来提供所需信息,这种方法被称为类型分类(type classification)技术。我们以一个具体的例子来说明这种技术的原理。

设想这样一个任务:模板参数T是指针类型、引用类型或者数组类型中的某一种,我们需要判断T究竟是其中的哪一个。如果是指针类型,需要知道该指针所指的类型,并将其命名为baseT。还需要知道该类型最终涉及哪个C++基本类型,并将其命名为bottomT。比如,对于指针类型int * *,baseT为int *,而bottomT为int。

类似地,对于引用类型,baseT表示所引用的类型,bottomT表示该引用类型最终涉及哪个C++基本类型。比如,对于类型double * &,baseT为double *,bottomT为double。对于数组类型,baseT表示数组元素的类型,bottomT表示该数组类型最终涉及哪个C++基本类型。比如,设有数组类型typedef int * arrayType[100],baseT为int *,而bottomT为int。

类模板特化技术可解决这个问题。如代码段3-5所示,行①定义的主类模板定义了一组枚举常量,每个枚举常量的取值表示模板参数T是否具有对应的类别。比如,IsPtrT为1则表示T是指针类型,为0则表示T不是指针类型。

代码段3-5,类型分类技术,取自z:\examples\type_classification\main.cpp

      template<typename T>     ①
      class TypeInfo {
        public:
          enum { IsPtrT = 0, IsRefT = 0, IsArrayT = 0 };
          typedef T baseT;
          typedef T bottomT;
      };
      template<typename T>     ②
      class TypeInfo<T*> {
        public:
          enum { IsPtrT = 1, IsRefT = 0, IsArrayT = 0 };
          typedef T baseT;
          typedef typename TypeInfo<T>::bottomT bottomT;        ③
      };
      template<typename T>             ④
      class TypeInfo<T&> {
        public:
          enum { IsPtrT = 0, IsRefT = 1, IsArrayT = 0 };
          typedef T baseT;
          typedef typename TypeInfo<T>::bottomT bottomT;
      };
      template<typename T, size_t N>   ⑤
      class TypeInfo <T[N]> {
        public:
          enum { IsPtrT = 0, IsRefT = 0, IsArrayT = 1 };
          typedef T baseT;
          typedef typename TypeInfo<T>::bottomT bottomT;
      };

行②的特化模板表示指针类型的信息。行③中的关键字typename表示其后所接的标识符bottomT是一个类型名字,而不是TypeInfo<T>中定义的一个枚举常量、静态数据成员或者其他东西。注意该行可能会递归地实例化类模板TypeInfo。比如,对于类型int * *,该行被编译器转换为

        typedef typename TypeInfo< int* >::bottomT bottomT;

为了求解下画线部分,由于其中的模板参数“int *”仍然是指针类型,编译器会再次实例化行②的类模板,再次进行到行③,求解出这个下画线部分等价于以下的波浪线部分:

        typedef typename TypeInfo<int>::bottomT bottomT;

为了求解波浪线部分,编译器会实例化行①的主类模板,得到结果int。类似地,行④的特化模板处理引用类型,行⑤的处理引用数组类型。

这种使用模板特化技术来描述模板参数T的类型信息的技术即被称为类型分类(type classification)。类型分类技术和traits技术很相似:都是利用模板特化技术描述一个类型的具体信息。但是二者的侧重点有所不同,类型分类侧重于描述一个类型的类别信息,比如它是否是指针、数组等,而traits技术侧重于描述一个类型的具体信息,比如浮点数的表示范围、精度等。

早在20世纪90年代中期,SGI公司在实现STL时就用了这一技术来优化某些算法的性能。本书第9章9.3介绍了Qt如何使用这个技术来优化容器QList的性能。关于这种技术更加全面的讨论,见参考文献[19]。

3.4 降低代码膨胀

C++的模板可以被用来描述抽象的算法,这种抽象的算法能够处理多种类型的数据,包括C++中的标准类型以及程序员定义的新类型。每当我们使用一个类型作为模板参数来实例化一个类模板或者函数模板时,C++编译器将生成一个和该类型对应的类或者函数。由于这个操作是在编译阶段完成的,实例化的类或者函数在运行阶段拥有普通类或者函数的性能,没有丝毫运行速度的损失。但是,一个潜在的问题是,每当一个模板被实例化一次,模板的代码就会被重复一次,导致程序代码段的增加,这个现象被称为代码膨胀(code bloat)。当实例化的次数较多时,膨胀的程度会较严重。在一些应用场合,比如嵌入式系统中,我们应该采取一些措施,来降低代码膨胀的程度。

为了降低代码膨胀,我们可以将一个类模板中与模板参数无关的代码隔离出来,形成一个确定的类(而非类模板),并令其成为基类,使该类模板实例化生成的类能够共享该基类的代码。由于该基类是确定的类,无论该类模板被实例化多少次,可执行代码中只含有该基类的一份代码,因而可以降低代码膨胀。3.4节给出了实现细节。

被共享的代码不一定要被封装到一个基类中,我们也可以将它们封装在一个独立的、确定的类中,然后在类模板中定义该类的一个对象,形成复合关系(composition),通过该对象来共享确定类中的代码,达到降低代码膨胀的目的。第9章9.3节给出了实现细节。

我们用一个简单的例子来说明这种设计思路。代码段3-6定义了一个类模板Vector,实现一个长度固定的容器。如果不采取其他措施,每当这个类模板被实例化一次,其代码就会被重复一次。

代码段3-6,应用类模板特化降低代码膨胀,摘自examples\reduce_code_bloat

      template <class T>
      class Vector {
            T * v;
            int sz;
      public:
            Vector(int size=100) {
                v = new T[size]; sz=size;
            };
            ~Vector() {
            delete [] v;
      }
      T& operator[](int i) {
          return v[i];
      };
      };

为了避免代码膨胀,我们要对Vector做些分析。作为一个容器,Vector中可以存放指针类型的元素和非指针类型的元素。对这两种类型的元素有着不同的处理方法。设我们要为Vector新增一个成员函数insert(),该函数能够在容器某个位置插入一个元素,其后元素被依次后移,最后一个元素被移出丢弃。如果元素类型为指针,我们可以使用标准函数memcpy()来快速地完成这个后移操作。如果元素类型为非指针,就必须调用适用于该类型的赋值运算符,逐个后移。进一步分析发现,只要元素类型为指针,类模板Vector的代码几乎是完全相同的。因而,我们可以设计一个能够处理void*的容器类VPVector,令元素类型为指针的Vector类都能共享其代码,如代码段3-7所示。

代码段3-7,应用类模板特化降低代码膨胀,摘自z:\examples\reduce_code_bloat

      typedef Vector<void*> VPVector;      ①
      template<class T>
      class Vector <T*>: public VPVector{  ②
      public:
      T*& operator[](int i) {
          return (T*&)(VPVector::operator[](i));
      };
      };
      int main()
      {
      Vector<int*>  v1;            ③
      Vector<double*> v2;         ④
      int   i = 3;   v1[0] = &i;
      double d = 3.14; v2[0] = &d;
      }

行①用一个具体的类型void*作为参数对类模板Vector进行实例化,产生一个确定的类VPVector。行②使用类模板特化技术,处理元素类型为指针的情形。特化的类模板以VPVector为基类。当用户以行③、④的方式使用特化的类模板时,VPVector的构造函数、析构函数被复用,避免了代码膨胀。