3.1 正确的理解偏特化

优质
小牛编辑
119浏览
2023-12-01

3.1.1 偏特化与函数重载的比较

在前面的章节中,我们介绍了偏特化的形式、也介绍了简单的用例。因为偏特化和函数重载存在着形式上的相似性,因此初学者便会借用重载的概念,来理解偏特化的行为。只是,重载和偏特化尽管相似但仍有差异。

我们来先看一个函数重载的例子:

void doWork(int);
void doWork(float);
void doWork(int, int);

void f() {
  doWork(0);
  doWork(0.5f);
  doWork(0, 0);
}

在这个例子中,我们展现了函数重载可以在两种条件下工作:参数数量相同、类型不同;参数数量不同。

仿照重载的形式,我们通过特化机制,试图实现一个模板的“重载”:

template <typename T> struct DoWork;	 // (0) 这是原型

template <> struct DoWork<int> {};     // (1) 这是 int 类型的"重载"
template <> struct DoWork<float> {};   // (2) 这是 float 类型的"重载"
template <> struct DoWork<int, int> {};  // (3) 这是 int, int 类型的“重载”

void f(){
  DoWork<int>    i;
  DoWork<float>  f;
  DoWork<int, int> ii;
}

这个例子在字面上“看起来”并没有什么问题,可惜编译器在编译的时候仍然提示出错了goo.gl/zI42Zv

5 : error: too many template arguments for class template 'DoWork'
template <> struct DoWork<int, int> {}; // 这是 int, int 类型的“重载”
^ ~~~~
1 : note: template is declared here
template <typename T> struct DoWork {}; // 这是原型
~~~~~~~~~~~~~~~~~~~~~ ^

从编译出错的失望中冷静一下,在仔细看看函数特化/偏特化和一般模板的不同之处:

template <typename T> class X    {};
template <typename T> class X <T*> {};
//              ^^^^ 注意这里

对,就是这个<T*>,跟在X后面的“小尾巴”,我们称作实参列表,决定了第二条语句是第一条语句的跟班。所以,第二条语句,即“偏特化”,必须要符合原型X的基本形式:那就是只有一个模板参数。这也是为什么DoWork尝试以template <> struct DoWork<int, int>的形式偏特化的时候,编译器会提示模板实参数量过多。

另外一方面,在类模板的实例化阶段,它并不会直接去寻找 template <> struct DoWork<int, int>这个小跟班,而是会先找到基本形式,template <typename T> struct DoWork;,然后再去寻找相应的特化。

我们以DoWork<int> i;为例,尝试复原一下编译器完成整个模板匹配过程的场景,帮助大家理解。看以下示例代码:

template <typename T> struct DoWork;	    // (0) 这是原型

template <> struct DoWork<int> {};      // (1) 这是 int 类型的特化
template <> struct DoWork<float> {};      // (2) 这是 float 类型的特化
template <typename U> struct DoWork<U*> {};   // (3) 这是指针类型的偏特化

DoWork<int>  i;  // (4)
DoWork<float*> pf; // (5)

首先,编译器分析(0), (1), (2)三句,得知(0)是模板的原型,(1),(2),(3)是模板(0)的特化或偏特化。我们假设有两个字典,第一个字典存储了模板原型,我们称之为TemplateDict。第二个字典TemplateSpecDict,存储了模板原型所对应的特化/偏特化形式。所以编译器在处理这几句时,可以视作

// 以下为伪代码
TemplateDict[DoWork<T>] = {
  DoWork<int>,
  DoWork<float>,
  DoWork<U*>           
};

然后 (4) 试图以int实例化类模板DoWork。它会在TemplateDict中,找到DoWork,它有一个形式参数T接受类型,正好和我们实例化的要求相符合。并且此时T被推导为int。(5) 中的float*也是同理。

{   // 以下为 DoWork<int> 查找对应匹配的伪代码
  templateProtoInt = TemplateDict.find(DoWork, int);  // 查找模板原型,查找到(0)
  template = templatePrototype.match(int);        // 以 int 对应 int 匹配到 (1)
}

{   // 以下为DoWork<float*> 查找对应匹配的伪代码
  templateProtoIntPtr = TemplateDict.find(DoWork, float*) // 查找模板原型,查找到(0)
  template = templateProtoIntPtr.match(float*)      // 以 float* 对应 U* 匹配到 (3),此时U为float
}

那么根据上面的步骤所展现的基本原理,我们随便来几个练习:

template <typename T, typename U> struct X      ;  // 0 
                               // 原型有两个类型参数
                               // 所以下面的这些偏特化的实参列表
                               // 也需要两个类型参数对应
template <typename T>       struct X<T,  T  > {};  // 1
template <typename T>       struct X<T*, T  > {};  // 2
template <typename T>       struct X<T,  T* > {};  // 3
template <typename U>       struct X<U,  int> {};  // 4
template <typename U>       struct X<U*, int> {};  // 5
template <typename U, typename T> struct X<U*, T* > {};  // 6
template <typename U, typename T> struct X<U,  T* > {};  // 7

template <typename T>       struct X<unique_ptr<T>, shared_ptr<T>>; // 8

// 以下特化,分别对应哪个偏特化的实例?
// 此时偏特化中的T或U分别是什么类型?

X<float*,  int>    v0;             
X<double*, int>    v1;             
X<double,  double>   v2;              
X<float*,  double*>  v3;               
X<float*,  float*>   v4;              
X<double,  float*>   v5;              
X<int,   double*>  v6;               
X<int*,  int>    v7;             
X<double*, double>   v8;

在上面这段例子中,有几个值得注意之处。首先,偏特化时的模板形参,和原型的模板形参没有任何关系。和原型不同,它的顺序完全不影响模式匹配的顺序,它只是偏特化模式,如<U, int>U的声明,真正的模式,是由<U, int>体现出来的。

这也是为什么在特化的时候,当所有类型都已经确定,我们就可以抛弃全部的模板参数,写出template <> struct X<int, float>这样的形式:因为所有列表中所有参数都确定了,就不需要额外的形式参数了。

其次,作为一个模式匹配,偏特化的实参列表中展现出来的“样子”,就是它能被匹配的原因。比如,struct X<T, T>中,要求模板的两个参数必须是相同的类型。而struct X<T, T*>,则代表第二个模板类型参数必须是第一个模板类型参数的指针,比如X<float***, float****>就能匹配上。当然,除了简单的指针、constvolatile修饰符,其他的类模板也可以作为偏特化时的“模式”出现,例如示例8,它要求传入同一个类型的unique_ptrshared_ptr。C++标准中指出下列模式都是可以被匹配的:

N3337, 14.8.2.5/8

T是模板类型实参或者类型列表(如 int, float, double 这样的,TT是template-template实参(参见6.2节),i是模板的非类型参数(整数、指针等),则以下形式的形参都会参与匹配:

T, cv-list T, T*, template-name <T>, T&, T&&

T [ integer-constant ]

type (T), T(), T(T)

T type ::*, type T::*, T T::*

T (type ::*)(), type (T::*)(), type (type ::*)(T), type (T::*)(T), T (type ::*)(T), T (T::*)(), T (T::*)(T)

type [i], template-name <i>, TT<T>, TT<i>, TT<>

对于某些实例化,偏特化的选择并不是唯一的。比如v4的参数是<float*, float*>,能够匹配的就有三条规则,1,6和7。很显然,6还是比7好一些,因为能多匹配一个指针。但是1和6,就很难说清楚谁更好了。一个说明了两者类型相同;另外一个则说明了两者都是指针。所以在这里,编译器也没办法决定使用那个,只好报出了编译器错误。

其他的示例可以先自己推测一下, 再去编译器上尝试一番:goo.gl/9UVzje

3.1.2 不定长的模板参数

不过这个时候也许你还不死心。有没有一种办法能够让例子DoWork像重载一样,支持对长度不一的参数列表分别偏特化/特化呢?

答案当然是肯定的。

首先,首先我们要让模板实例化时的模板参数统一到相同形式上。逆向思维一下,虽然两个类型参数我们很难缩成一个参数,但是我们可以通过添加额外的参数,把一个扩展成两个呀。比如这样:

DoWork<int,   void> i;
DoWork<float, void> f;
DoWork<int,   int > ii;

这时,我们就能写出统一的模板原型:

template <typename T0, typename T1> struct DoWork;

继而偏特化/特化问题也解决了:

template <> struct DoWork<int,   void> {};  // (1) 这是 int 类型的特化
template <> struct DoWork<float, void> {};  // (2) 这是 float 类型的特化
template <> struct DoWork<int,  int> {};  // (3) 这是 int, int 类型的特化

显而易见这个解决方案并不那么完美。首先,不管是偏特化还是用户实例化模板的时候,都需要多撰写好几个void,而且最长的那个参数越长,需要写的就越多;其次,如果我们的DoWork在程序维护的过程中新加入了一个参数列表更长的实例,那么最悲惨的事情就会发生 —— 原型、每一个偏特化、每一个实例化都要追加上void以凑齐新出现的实例所需要的参数数量。

所幸模板参数也有一个和函数参数相同的特性:默认实参(Default Arguments)。只需要一个例子,你们就能看明白了goo.gl/TtmcY9

template <typename T0, typename T1 = void> struct DoWork;

template <typename T> struct DoWork<T> {};
template <>       struct DoWork<int> {};
template <>       struct DoWork<float> {};
template <>       struct DoWork<int, int> {};

DoWork<int> i;
DoWork<float> f;
DoWork<double> d;
DoWork<int, int> ii;

所有参数不足,即原型中参数T1没有指定的地方,都由T1自己的默认参数void补齐了。

但是这个方案仍然有些美中不足之处。

比如,尽管我们默认了所有无效的类型都以void结尾,所以正确的类型列表应该是类似于<int, float, char, void, void>这样的形态。但你阻止不了你的用户写出类似于<void, int, void, float, char, void, void>这样不符合约定的类型参数列表。

其次,假设这段代码中有一个函数,它的参数使用了和类模板相同的参数列表类型,如下面这段代码:

template <typename T0, typename T1 = void> struct X {
  static void call(T0 const& p0, T1 const& p1);    // 0
};

template <typename T0> struct X<T0> {
  static void call(T0 const& p0);            // 1
};

void foo(){
  X<int>::call(5);        // 调用函数 1
  X<int, float>::call(5, 0.5f);   // 调用函数 0
}

那么,每加一个参数就要多写一个偏特化的形式,甚至还要重复编写一些可以共享的实现。

不过不管怎么说,以长参数加默认参数的方式支持变长参数是可行的做法,这也是C++98/03时代的唯一选择。

例如,Boost.Tuple就使用了这个方法,支持了变长的Tuple:

// Tuple 的声明,来自 boost
struct null_type;

template <
  class T0 = null_type, class T1 = null_type, class T2 = null_type,
  class T3 = null_type, class T4 = null_type, class T5 = null_type,
  class T6 = null_type, class T7 = null_type, class T8 = null_type,
  class T9 = null_type>
class tuple;

// Tuple的一些用例
tuple<int> a;
tuple<double&, const double&, const double, double*, const double*> b;
tuple<A, int(*)(char, int), B(A::*)(C&), C> c;
tuple<std::string, std::pair<A, B> > d;
tuple<A*, tuple<const A*, const B&, C>, bool, void*> e;

此外,Boost.MPL也使用了这个手法将boost::mpl::vector映射到boost::mpl::vector _n_上。但是我们也看到了,这个方案的缺陷很明显:代码臃肿和潜在的正确性问题。此外,过度使用模板偏特化、大量冗余的类型参数也给编译器带来了沉重的负担。

为了缓解这些问题,在C++11中,引入了变参模板(Variadic Template)。我们来看看支持了变参模板的C++11是如何实现tuple的:

template <typename... Ts> class tuple;

是不是一下子简洁了很多!这里的typename... Ts相当于一个声明,是说Ts不是一个类型,而是一个不定常的类型列表。同C语言的不定长参数一样,它通常只能放在参数列表的最后。看下面的例子:

template <typename... Ts, typename U> class X {};        // (1) error!
template <typename... Ts>       class Y {};        // (2)
template <typename... Ts, typename U> class Y<U, Ts...> {};  // (3)
template <typename... Ts, typename U> class Y<Ts..., U> {};  // (4) error!

为什么第(1)条语句会出错呢?(1)是模板原型,模板实例化时,要以它为基础和实例化时的类型实参相匹配。因为C++的模板是自左向右匹配的,所以不定长参数只能结尾。其他形式,无论写作Ts, U,或者是Ts, V, Us,,或者是V, Ts, Us都是不可取的。(4) 也存在同样的问题。

但是,为什么(3)中, 模板参数和(1)相同,都是typename... Ts, typename U,但是编译器却并没有报错呢?

答案在这一节的早些时候。(3)和(1)不同,它并不是模板的原型,它只是Y的一个偏特化。回顾我们在之前所提到的,偏特化时,模板参数列表并不代表匹配顺序,它们只是为偏特化的模式提供的声明,也就是说,它们的匹配顺序,只是按照<U, Ts...>来,而之前的参数只是告诉你Ts是一个类型列表,而U是一个类型,排名不分先后。

在这里,我们只提到了变长模板参数的声明,如何使用我们将在第四章讲述。

3.1.3 模板的默认实参

在上一节中,我们介绍了模板对默认实参的支持。当时我们的例子很简单,默认模板实参是一个确定的类型void或者自定义的null_type

template <
  typename T0, typename T1 = void, typename T2 = void
> class Tuple;

实际上,模板的默认参数不仅仅可以是一个确定的类型,它还能是以其他类型为参数的一个类型表达式。 考虑下面的例子:我们要执行两个同类型变量的除法,它对浮点、整数和其他类型分别采取不同的措施。 对于浮点,执行内置除法;对于整数,要处理除零保护,防止引发异常;对于其他类型,执行一个叫做CustomeDiv的函数。

第一步,我们先把浮点正确的写出来:

#include <type_traits>

template <typename T> T CustomDiv(T lhs, T rhs) {
  // Custom Div的实现
}

template <typename T, bool IsFloat = std::is_floating_point<T>::value> struct SafeDivide {
  static T Do(T lhs, T rhs) {
    return CustomDiv(lhs, rhs);
  }
};

template <typename T> struct SafeDivide<T, true>{  // 偏特化A
  static T Do(T lhs, T rhs){
    return lhs/rhs;
  }
};

template <typename T> struct SafeDivide<T, false>{   // 偏特化B
  static T Do(T lhs, T rhs){
    return lhs;
  }
};

void foo(){
  SafeDivide<float>::Do(1.0f, 2.0f);	// 调用偏特化A
  SafeDivide<int>::Do(1, 2);      // 调用偏特化B
}

在实例化的时候,尽管我们只为SafeDivide指定了参数T,但是它的另一个参数IsFloat在缺省的情况下,可以根据T,求出表达式std::is_floating_point<T>::value的值作为实参的值,带入到SafeDivide的匹配中。

嗯,这个时候我们要再把整型和其他类型纳入进来,无外乎就是加这么一个参数goo.gl/0Lqywt

#include <complex>
#include <type_traits>

template <typename T> T CustomDiv(T lhs, T rhs) {
  T v;
  // Custom Div的实现
  return v;
}

template <
  typename T,
  bool IsFloat = std::is_floating_point<T>::value,
  bool IsIntegral = std::is_integral<T>::value
> struct SafeDivide {
  static T Do(T lhs, T rhs) {
    return CustomDiv(lhs, rhs);
  }
};

template <typename T> struct SafeDivide<T, true, false>{  // 偏特化A
  static T Do(T lhs, T rhs){
    return lhs/rhs;
  }
};

template <typename T> struct SafeDivide<T, false, true>{   // 偏特化B
  static T Do(T lhs, T rhs){
    return rhs == 0 ? 0 : lhs/rhs;
  }
};

void foo(){
  SafeDivide<float>::Do(1.0f, 2.0f);	              // 调用偏特化A
  SafeDivide<int>::Do(1, 2);                  // 调用偏特化B
  SafeDivide<std::complex<float>>::Do({1.f, 2.f}, {1.f, -2.f}); // 调用一般形式
}

当然,这时也许你会注意到,is_integralis_floating_point和其他类类型三者是互斥的,那能不能只使用一个条件量来进行分派呢?答案当然是可以的:goo.gl/jYp5J2

#include <complex>
#include <type_traits>

template <typename T> T CustomDiv(T lhs, T rhs) {
  T v;
  // Custom Div的实现
  return v;
}

template <typename T, typename Enabled = std::true_type> struct SafeDivide {
  static T Do(T lhs, T rhs) {
    return CustomDiv(lhs, rhs);
  }
};

template <typename T> struct SafeDivide<
  T, typename std::is_floating_point<T>::type>{  // 偏特化A
  static T Do(T lhs, T rhs){
    return lhs/rhs;
  }
};

template <typename T> struct SafeDivide<
  T, typename std::is_integral<T>::type>{      // 偏特化B
  static T Do(T lhs, T rhs){
    return rhs == 0 ? 0 : lhs/rhs;
  }
};

void foo(){
  SafeDivide<float>::Do(1.0f, 2.0f);	// 调用偏特化A
  SafeDivide<int>::Do(1, 2);      // 调用偏特化B
  SafeDivide<std::complex<float>>::Do({1.f, 2.f}, {1.f, -2.f});
}

我们借助这个例子,帮助大家理解一下这个结构是怎么工作的:

  1. SafeDivide<int>
  • 通过匹配类模板的泛化形式,计算默认实参,可以知道我们要匹配的模板实参是SafeDivide<int, true_type>

  • 计算两个偏特化的形式的匹配:A得到<int, false_type>,和B得到 <int, true_type>

  • 最后偏特化B的匹配结果和模板实参一致,使用它。

  1. 针对SafeDivide<complex<float>>
  • 通过匹配类模板的泛化形式,可以知道我们要匹配的模板实参是SafeDivide<complex<float>, true_type>

  • 计算两个偏特化形式的匹配:A和B均得到SafeDivide<complex<float>, false_type>

  • A和B都与模板实参无法匹配,所以使用原型,调用CustomDiv