2.3. 模板特化

模板特化可以为某些类型生成不同的模板代码 —— 不仅仅是用不同类型替换的相同代码,而是完全不同的代码。C++中有两种模板特化 —— 全(或显式)特化和偏特化。让我们先从前者开始。

2.3.1 全特化

全模板特化定义了针对特定类型集的模板的特殊版本。在全模板特化中,所有泛型类型都替换为特定的具体类型。由于全模板特化不是泛型类或函数,之后无需再进行实例化。出于同样的原因,有时也称为“完全特化”。如果泛型类型完全替换,就不再有泛型成分。全模板特化不应与显式模板实例化混淆 —— 尽管两者都为给定的类型参数集创建了模板的实例化,但显式实例化创建的是泛型代码的实例化,其中泛型类型会被具体类型替换。而全模板特化创建的是同名函数或类的实例化,其会覆盖实现,生成的代码可以完全不同。

让我们从一个类模板开始。假设当Ratio的分子和分母都是double类型时,希望计算该比率并将其存储为单个数值。通用的Ratio代码应保持不变,但对于某一特定类型组合,希望该类的结构完全不同。可以通过全模板特化来实现:

template <> class Ratio<double, double> {
public:
  Ratio() : value_() {}

  template <typename N, typename D>
  Ratio(const N& num, const D& denom) :
    value_(double(num)/double(denom)) {}

  explicit operator double() const { return value_; }

private:
  double value_;
};

两个模板类型参数都指定为double,该类的实现与通用版本完全不同 —— 只有一个数据成员,而不是两个;转换操作符直接返回值;构造函数现在计算分子和分母的比率。但这甚至不是同一个构造函数 —— 提供了一个模板构造函数,可以接受任意类型的两个参数,只要可转换为double,而不是通用版本在用两个double模板参数实例化时,会有的非模板构造函数Ratio(const double&, const double&)。

有时,不需要特化整个类模板,大部分通用代码仍然适用。然而,我们希望更改一个或几个成员函数的实现,也可以对成员函数进行全特化:

template <> Ratio<float, float>::operator double() const {
  return num_/denom_;
}

模板函数也可以进行全模板特化。与显式实例化不同,可以编写函数体,并以我们想要的方式进行实现:

template <typename T> T do_something(T x) {
  return ++x;
}

template <> double do_something<double>(double x) {
  return x/2;
}

do_something(3); // 4
do_something(3.0); // 1.5

但不能更改参数的数量或类型,也不能更改返回类型 —— 必须与泛型类型替换后的结果相匹配,所以以下代码无法编译:

template <> long do_something<int>(int x) { return x*x; }

全模板特化必须在同一类型泛型模板隐式实例化的模板首次使用之前声明 —— 隐式实例化会创建一个与全模板特化同名且类型相同的类或函数。所以,程序中就会存在同一类或函数的两个版本,这违反了单一定义规则,导致程序不符合规范(具体规则可在标准[basic.def.odr]下找到)。

当有一两种类型需要模板表现出截然不同的行为时,全模板特化非常有用。然而,这并不能解决关于指针比率的问题 —— 需要一个具有一定泛型性的特化,即可以处理指向类型的指针,而不能是其他类型。这可以通过偏特化来实现,接下来我们将对此进行探讨。

2.3.2 偏特化

现在,进入C++模板编程真正有趣的部分 —— 偏特化模板。当一个类模板偏特化时,其仍然是泛型代码,但比原始模板的泛型程度更低。偏特化模板最简单的形式是:其中一些泛型类型会被具体类型替换,而其他类型仍然保持泛型:

template <typename N, typename D> class Ratio {
.....
};

template <typename D> class Ratio<double, D> {
public:
  Ratio() : value_() {}
  Ratio(const double& num, const D& denom) :
  value_(num/double(denom)) {}

  explicit operator double() const { return value_; }

private:
  double value_;
};

如果分子是double类型,无论分母是什么类型,都会将Ratio转换为double值。可以为同一个模板定义多个偏特化。也可以对分母是double类型,而分子是任意类型的情况进行特化:

template <typename N> class Ratio<N, double> {
public:
  Ratio() : value_() {}

  Ratio(const N& num, const double& denom) :
    value_(double(num)/denom) {}

  explicit operator double() const { return value_; }

private:
  double value_;
};

当模板实例化时,会为给定的类型集选择最匹配的特化版本。如果分子和分母都不是double类型,必须实例化通用模板 —— 没有其他选择。如果分子是double类型,第一个偏特化版本比通用模板更匹配(更具体)。如果分母是double类型,第二个偏特化版本更匹配。但如果两个参数都是double类型会发生什么?在这种情况下,两个偏特化版本是等价的;没有一个比另一个更具体。这种情况视为有歧义,实例化失败。

注意,只有Ratio<double, double>这个特定的实例化会失败 —— 定义这两个特化本身并不是错误(至少不是语法错误),但请求一个无法唯一确定到最具体特化版本的实例化则是错误的。为了允许模板的实例化,必须消除这种歧义,唯一的方法是提供一个比另外两个更具体的特化版本。例子中,只有一个选择 —— 为Ratio<double, double>提供一个全特化:

template <> class Ratio<double, double> {
public:
  Ratio() : value_() {}

  template <typename N, typename D>
  Ratio(const N& num, const D& denom) :
    value_(double(num)/double(denom)) {}

  explicit operator double() const { return value_; }

private:
  double value_;
};

现在,偏特化在实例化Ratio<double, double>时存在歧义这一事实不再重要 —— 有了一个比它们都更具体的模板版本,该版本优先于另外两个。

偏特化不必完全指定某些泛型类型,可以保持所有类型为泛型,但对其施加一些限制。例如,仍然希望有一个特化版本,其中分子和分母都是指针。它们可以是指向类型的指针,可以是泛型类型,但比通用模板的类型的限制更多:

template <typename N, typename D> class Ratio<N*, D*> {
public:
  Ratio(N* num, D* denom) : num_(num), denom_(denom) {}
  explicit operator double() const {
    return double(*num_)/double(*denom_);
  }
private:
  N* const num_;
  D* const denom_;
};

int i = 5; double x = 10;
auto r(make_ratio(&i, &x)); // Ratio<int*, double*>
double(r); // 0.5
x = 2.5;
double(r); // 2

这个偏特化仍然有两个泛型类型,但它们都是指针类型,即任意类型N和D的N和D。其实现与通用模板完全不同。当用两个指针类型实例化时,该偏特化比通用模板更具体,是更优的匹配项。

例子中,分母是double。那么为什么没有考虑针对double分母的偏特化呢?尽管从程序逻辑上看分母是double,但技术上是double*,一个完全不同的类型,而我们并没有为此定义特化版本。

要定义一个特化版本,必须首先声明一个通用模板。然而,通用模板并不需要被定义 —— 可以特化一个在通用情况下并不存在的模板。必须先前向声明通用模板,然后定义我们需要的所有特化版本:

template <typename T> class Value; // 声明
template <typename T> class Value<T*> {
public:
  explicit Value(T* p) : v_(*p) {} private:
  T v_;
};

template <typename T> class Value<T&> {
public:
  explicit Value(T& p) : v_(p) {}
private:
  T v_;
};

int i = 5; int* p = &i; int& r = i;
Value<int*> v1(p); // T* 特化
Value<int&> v2(r); // T& 特化

没有通用的Value模板,但有针对任意指针或引用类型的偏特化。如果尝试用其他类型(如int)来实例化该模板,将会得到一个错误,提示Value<int>类型不完整 —— 这与仅用类的前向声明就试图定义对象并无不同。

目前,看到的只是类模板偏特化的例子。与之前讨论的全特化不同,这里还没有看到一个函数特化,因为C++中不存在函数模板的偏特化。有时不正确地称为偏特化的东西,实际上不过是模板函数的重载。另一方面,模板函数的重载可能变得相当复杂,接下来就进行介绍。

2.3.3 模板函数重载

我们习惯于重载普通函数或类方法 —— 多个同名函数具有不同的参数类型。每次调用都会调用参数类型与调用实参最匹配的函数,如下例所示:

// Example 07
void whatami(int x) {
  std::cout << x << " is int" << std::endl;
}

void whatami(long x) {
  std::cout << x << " is long" << std::endl;
}

whatami(5); // 5 是 int
whatami(5.0); // 编译错误

如果参数与给定名称的某个重载函数完全匹配,则调用该函数。否则,编译器会考虑将实参转换为可用函数的参数类型。如果其中一个函数提供了更好的转换,则选择该函数。否则,调用存在歧义,就像前面示例的最后一行那样。关于何种转换构成最佳转换的精确定义,可以在标准中找到(参见“重载”部分,更具体地说是[over.match]小节)。通常,代价最低的转换是诸如添加const或移除引用之类的转换;然后是内置类型之间的转换、从派生类到基类指针的转换等。在多个参数的情况下,所选函数的每个参数都必须具有最佳转换。不存在投票机制 —— 如果一个函数有三个参数,第一个重载版本与其中两个参数完全匹配,而第二个重载版本与第三个参数完全匹配,即使其余参数可以隐式转换为其对应的参数类型,该重载调用也有歧义。

模板的存在使得重载解析变得复杂得多。除了非模板函数外,还可以定义多个同名且参数数量可能相同的函数模板。所有这些函数都是重载函数调用的候选者,但函数模板可以生成具有不同参数类型的函数,那么如何确定实际的重载函数呢?其确切规则比非模板函数的规则更为复杂,但基本思想是:如果存在一个与调用实参近乎完全匹配的非模板函数,则选择该函数。当然,标准使用的术语比“近乎完全”要精确得多,但诸如添加const之类的简单转换属于此类 —— 这些转换无代价。如果没有这样的函数,编译器将尝试使用模板参数推导,将所有同名的函数模板实例化为与调用近乎完全匹配的形式。如果恰好有一个模板实例化,则调用该实例化生成的函数。否则,重载解析将在非模板函数之间以通常的方式继续进行。

这是对一个非常复杂过程的简化描述,但有两个重要要点:第一,如果对模板函数和非模板函数的匹配程度相同,则优先选择非模板函数;第二,编译器不会尝试将函数模板实例化为可能转换为所需类型的某种形式。在参数类型推导之后,模板函数必须完全匹配调用,否则根本不会调用。在之前的示例中添加一个模板:

void whatami(int x); // 同上
void whatami(long x); // 同上

template <typename T> void whatami(T* x) {
  std::cout << x << " is a pointer" << std::endl;
}

int i = 5;
whatami(i); // 5 是 int
whatami(&i); // 0x???? 是一个指针

在这里,看似是函数模板的偏特化,但实际上并非如此 —— 它只是一个函数模板 —— 并不存在一个通用模板使其成为特化版本。相反,它仅仅是一个函数模板,其类型参数从相同的参数中推导而来,但使用了不同的规则。只要参数是任意类型的指针,该模板的类型就可以推导出来。这包括指向const的指针 —— T可以是const类型,如果调用whatami(ptr),其中ptr是const int*,当T为const int时,第一个模板重载就完全匹配。如果推导成功,则由该模板生成的函数(即模板实例化)将添加到重载集合中。

对于int*参数,这是唯一可行的重载,因此会调用。但如果多个函数模板都能匹配调用,并且两个实例化都是有效的重载,会发生什么情况?让我们再添加一个模板:

void whatami(int x); // 同上
void whatami(long x); // 同上
template <typename T> void whatami(T* x); // 同上

template <typename T> void whatami(T&& x) {
  std::cout << "Something weird" << std::endl;
}

class C { };
C c;

whatami(c); // 输出Something weird
whatami(&c); // 0x???? 是一个指针

这个模板函数通过万能引用接受其参数,可以为带有一个参数的whatami()调用进行实例化。第一次调用whatami(c)很简单 —— 只有最后一个带有&&的重载可以调用。c无法转换为指针或整数。但第二次调用比较棘手 —— 有两个模板实例化都能完美匹配该调用,且无需转换。这为什么这不构成歧义重载呢?因为解析重载函数模板的规则不同于非模板函数的规则,并且类似于选择类模板偏特化的规则(这也是函数模板重载常误认为偏特化的原因之一),更具体的模板是更好的匹配项。

例子中,第一个模板更具体 —— 可以接受指针参数,但仅限指针。第二个模板可以接受参数,每当第一个模板可以匹配时,第二个模板也可以匹配,但反过来则不行。如果一个更具体的模板可用于实例化一个有效的重载函数,则使用该模板。

否则,必须退回到更通用的模板。重载集合中非常通用的模板函数有时会导致意外的结果。假设有以下三个重载,分别用于int、double和任意类型:

void whatami(int x) {
  std::cout << x << " is int" << std::endl;
}

void whatami(double x) {
  std::cout << x << " is double" << std::endl;
}

template <typename T> void whatami(T&& x) {
  std::cout << "Something weird" << std::endl;
}

int i = 5;
float x = 4.2;

whatami(i); // i 是 int
whatami(x); // 输出 Something weird
whatami(1.2); // 1.2 是 double

第一次调用的参数是int,因此whatami(int)是完美匹配。第二次调用如果没有模板重载,将会调用whatami(double) —— 从float到double的转换是隐式的(从float到int的转换也是隐式的,但到double的转换更受青睐)。但这仍然是一个转换,当函数模板实例化为与whatami(float&&)完美匹配时,它就成了最佳匹配并选中。最后一次调用的参数是double,再次有一个与非模板函数whatami(double)的完美匹配,因此它优于其他选项。

需要注意的是,对同一参数类型重载按值传递和按引用传递的函数通常会在重载解析中产生歧义。例如,这两个函数几乎总是存在歧义:

template <typename T> void whatami(T&& x) {
  std::cout << "Something weird" << std::endl;
}

template <typename T> void whatami(T x) {
  std::cout << "Something copyable" << std::endl;
}

class C {};
C c;
whatami(c);

只要函数的参数可以被复制(而对象c可复制),重载就会产生歧义,调用将无法编译。当一个更具体的函数重载一个更通用的函数时,不会出现此问题(之前的所有示例中,whatami(int) 使用按值传递都没有问题),但对于通用性相似的函数混合使用这两种参数传递方式是不可取的。

最后,还有一种函数在重载解析顺序中具有特殊地位 —— 可变参数函数。

可变参数函数使用...而不是具体参数来声明,可以接受任意数量、任意类型的参数(printf 就是这样一个函数)。这种函数是最后的选择 —— 只有在没有其他重载可用时才会调用:

void whatami(...) {
  std::cout << "It's something or somethings" << std::endl;
}

只要有重载 whatami(T&& x) 可用,可变参数函数永远不会成为首选的重载,至少对于带一个参数的 whatami() 调用来说是如此。如果没有该模板,对于非数字或非指针的参数,将调用 whatami(...)。可变参数函数自C语言时代就已存在,不应与C++11引入的可变参数模板混淆,接下来我们将讨论后者。