7.3. 模板函数中的类型替换

我们必须仔细区分实例化模板函数以匹配特定调用时的两个步骤:

当函数有多个参数时,这两个步骤的区别会更加明显。

7.3.1 类型推导与替换

类型推导和类型替换密切相关,但并不完全相同。推导 是一个“推测”的过程:为了匹配这次函数调用,模板参数的类型应该是什么?当然,编译器并非真正地“猜测”,而是应用标准中定义的一系列规则来进行推导。

// Example 07
template <typename T>
void f(T i, T* p) { std::cout << "f(T, T*)" << std::endl; }

int i;
f(5, &i); // T == int
f(5l, &i); // ?

第一次调用时,可以从第一个参数推导出模板参数 T 应该是 int。因此,int 会代入到函数的两个参数中。模板实例化为 f(int, int*),这与实参类型完全匹配。

第二次调用时,从第一个参数可以推导出 T 应该是 long;而从第二个参数则可以推导出 T 应该是 int。这种歧义导致了类型推导过程的失败。如果这是唯一可用的重载,编译器不会选择一个,程序将无法编译。如果存在其他重载函数,编译器会依次考虑它们,包括可能作为最后选择的可变参数函数 f(...)。

在推导模板类型时,不会考虑类型转换。虽然将 T 推导为 int 会得到 f(int, int*),而调用 f(long, int*) 时可以通过转换第一个参数来实现,但这种可能性在类型推导阶段根本不会被考虑,所以类型推导会因歧义而失败。

这种歧义的推导,可以通过显式指定模板类型来解决,这消除了类型推导的需要:

f<int>(5l, &i); // T == int

现在,不需要进行类型推导:从函数调用中已经明确知道 T 是什么,已经显式指定。然而,类型替换仍然必须发生 —— 第一个参数的类型是 int,第二个参数的类型是 int*。函数调用成功,并对第一个参数进行类型转换。

我们也可以强制以另一种方式进行推导(通过其他手段,例如使用 const T& 或指针类型来影响推导,或者使用 std::type_identity 等辅助工具来阻止对某个参数的推导):

f<long>(5l, &i); // T == long

由于我们已经通过显式指定知道了 T 的值,因此不再需要进行类型推导。类型替换过程直接进行,最终得到 f(long, long*)。然而,这个函数无法用 int* 作为第二个参数来调用,所以不存在从 int* 到 long* 的有效转换。因此,该程序无法编译。

通过显式指定模板参数类型,也指明了必须调用的是模板函数 f()。因此,所有非模板的 f() 重载函数将不再考虑。另一方面,如果存在多个名为 f() 的模板函数,这些模板重载会像往常一样纳入重载集进行考虑,但此时每个模板的参数推导结果,已经由显式指定所强制确定。

模板函数和非模板函数一样,也可以拥有默认参数。然而,这些默认参数的值不会用于模板参数的类型推导(在 C++11 及以后标准中,模板参数本身可以拥有默认值,这提供了一种替代机制):

// Example 08
void f(int i, int j = 1) { // 1
  cout << "f(int2)" << endl;
}

template <typename T> void f(T i, T* p = nullptr) { // 2
  cout << "f(T, T*)" << endl;
}

int i;
f(5); // 1
f(5l); // 2

第一次调用与非模板函数 f(int, int) 完全匹配,其中第二个参数使用了默认值 1。即使将该函数声明为 f(int i, int j = 1L),即默认值是 long 类型的 1L,结果也完全一样。默认参数的类型本身并不重要 —— 只要能转换为参数所声明的类型(int),转换后的值就会使用;否则,程序在第1行声明时就会因转换失败而无法编译。

第二次调用与模板函数 f(T, T*) 完全匹配,其中 T 推导为 long,第二个参数使用了默认值 NULL。同样,NULL 的类型(通常是整数 0)不是 long* 也完全无关紧要。在函数调用时,NULL 会隐式转换为 long* 类型的空指针。

现在,理解了类型推导和类型替换之间的区别:

然而,类型替换过程也可能失败,但失败的原因与歧义不同。替换失败通常发生在替换后产生的类型或表达式是非法的、未定义的或违反了某些约束(例如,SFINAE的场景中,替换失败只是将该模板从重载集中移除,而不是直接导致编译错误)。

7.3.2 替换失败

当我们推导出了模板参数的类型,类型替换就是一个机械性过程:

// Example 09
template <typename T> T* f(T i, T& j) {
  j = 2*i;
  return new T(i);
}

int i = 5, j = 7;
const int* p = f(i, j);

T 类型可以从第一个参数推导为 int,也可以从第二个参数推导为 int,函数的返回类型不会用于模板参数的类型推导。由于 T 只有一种可能的推导结果(int),现在可以将函数定义中所有出现的 T 都替换为 int:

int* f(int i, int& j) {
  j = 2*i;
  return new int(i);
}

然而,并非所有类型都具有相同的“自由度”,有些类型在推导和替换时允许更多的灵活性:

// Example 10
template <typename T>
void f(T i, typename T::t& j) {
  std::cout << "f(T, T::t)" << std::endl;
}

template <typename T>
void f(T i, T j) {
  std::cout << "f(T, T)" << std::endl;
}

struct A {
  struct t { int i; }; t i;
};

A a{5};
f(a, a.i); // T == A
f(5, 7); // T == int

当考虑第一次调用时,编译器从第一个和第二个参数推导出模板参数 T 为 A 类型 —— 第一个参数是 A 类型的值,第二个参数是对 A::t 嵌套类型的值的引用,如果坚持将 T 推导为 A,那么它就与 T::t 相匹配。第二个重载函数从两个参数推导出的 T 值相互冲突,无法使用,并调用第一个重载函数。

现在,仔细观察第二次调用。对于两个重载函数,T 类型都可以从第一个参数推导为 int。然而,将 int 代入 T 后,第一个重载函数的第二个参数就变得奇怪了 —— int::t。这当然无法编译 —— int 不是类,也没有嵌套类型。可以预期,对于不是类或没有名为 t 的嵌套类型的 T,第一个模板重载都会编译失败。尝试将 int 代入第一个模板函数中的 T 会因第二个参数的类型无效而失败,但这种替换失败并不意味着整个程序无法编译。相反,会被忽略,那个本应格式错误的重载函数会从重载集中移除。重载解析随后照常继续。当然,会发现没有重载函数匹配该函数调用,程序仍然无法编译,但错误信息不会提及 int::t 无效;只会说没有可以调用的函数。

再次强调,区分类型推导失败和类型替换失败非常重要,可以完全排除前者:

f<int>(5, 7); // T == int

推导不再是必需的,但仍必须将 int 代入 T,而这种代入在第一个重载中会产生一个无效的表达式。同样,这种替换失败会将这个 f() 候选函数从重载集中移除,重载解析会继续(在本例中,成功地)使用剩下的候选函数。

通常情况下,关于重载的讨论到此就结束了:模板产生了无法编译的代码,因此整个程序也应该无法编译。幸运的是,C++ 在这种特定情况下更为宽容,并为此设有一个特殊的例外规则,我们需要对其进行了解。

7.3.3 替换失败不是错误(SFINAE)

由指定或推导出的类型导致表达式无效而产生的替换失败,不会使整个程序无效,这一规则称为“替换失败并非错误”。这一规则对于在 C++ 中使用模板函数至关重要;如果没有 SFINAE,许多原本完全有效的程序都将无法编写。

考虑以下模板重载,用于区分普通指针和成员指针:

// Example 11
template <typename T> void f(T* i) { // 1
  std::cout << "f(T*)" << std::endl;
}

template <typename T> void f(int T::* p) { // 2
  std::cout << "f(T::*)" << std::endl;
}

struct A { int i; };

A a;
f(&a.i); // 1
f(&A::i); // 2

到目前为止,一切顺利 —— 第一次调用时,函数传入的是指向特定变量 a.i 的指针,T 推导为 int。第二次调用传入的是指向 A 类数据成员的指针,此时 T 推导为 A。

现在,用指向另一种类型的指针来调用 f():

int i;
f(&i); // 1

第一个重载仍然可以正常工作,也正是希望调用的那个。但第二个重载不仅仅是不太合适,而是完全无效的 —— 如果尝试将 T 替换为 int,将会导致语法错误(因为 int 没有成员 member)。

编译器会发现这个语法错误,但会将其静默忽略,同时将这个重载本身也从重载集中移除。

// Example 12
template <size_t N>
void f(char(*)[N % 2] = nullptr) { // 1
  std::cout << "N=" << N << " is odd" << std::endl;
}

template <size_t N>
void f(char(*)[1 - N % 2] = nullptr) { // 2
  std::cout << "N=" << N << " is even" << std::endl;
}

f<5>();
f<8>();

模板参数是一个值,而不是一个类型。我们有两个模板重载,都接受指向字符数组的指针,而数组大小的表达式仅对某些 N 的值是有效的。具体来说,在 C++ 中零大小的数组是无效的。因此,第一个重载仅在 N % 2 非零(即 N 为奇数)时有效,第二个重载仅在 N 为偶数时有效。函数没有提供参数,因此我们打算使用默认参数。如果不是因为在这两次调用中,其中一个重载在模板参数替换期间失败并静默移除,则这两个重载在情况下都会产生歧义。

前面的例子非常紧凑 —— 通过显式指定禁用了模板参数值的推导,这类似于类型参数的推导。可以恢复推导机制,并且根据表达式是否有效,替换仍然可能成功或失败:

// Example 13
template <typename T, size_t N = T::N>
void f(T t, char(*)[N % 2] = NULL) {
  std::cout << "N=" << N << " is odd" << std::endl;
}

template <typename T, size_t N = T::N>
void f(T t, char(*)[1 - N % 2] = NULL) {
  std::cout << "N=" << N << " is even" << std::endl;
}

struct A { enum {N = 5}; };
struct B { enum {N = 8}; };

A a;
B b;

f(a);
f(b);

现在,编译器必须从第一个参数推导出类型。对于第一次调用 f(a),可以轻松推导出类型 A。而对于第二个模板参数 N,则无法进行推导,因此使用其默认值(我们现在处于 C++11 及更高版本的语境下)。在推导出两个模板参数后,进入替换阶段,将 T 替换为 A,将 N 替换为 5。这种替换对第二个重载会失败(因为 A::t 存在,但 5 不是其成员),但对第一个重载则成功。由于重载集中只剩下这一个候选函数,重载解析得以完成。同样,第二次调用 f(b) 最终会调用第二个重载。

这个例子与之前,有如下函数的例子之间存在着区别:

template <typename T> void f(T i, typename T::t& j);

这个模板中,替换失败是“自然的”:可能导致失败的参数是必需的,并且其设计本意就是用于成员指针类型。之前的情况中,模板参数 N 是多余的:除了人为地制造替换失败和禁用某些重载之外,别无他用。

为什么会想要人为地制造替换失败呢?已经看到了一个原因:强制选择那些原本会产生歧义的重载。更普遍的原因则与这样一个事实有关:类型替换有时可能会导致错误。

人为引入替换失败(通常通过 SFINAE 技术)的主要目的包括:

简而言之,人为的替换失败是一种强大的元编程技术,允许开发者基于类型的属性来控制代码的生成和重载解析过程,从而编写出更灵活、更健壮的泛型代码。尽管 C++20 的 概念 提供了更清晰、更易用的替代方案,但理解 SFINAE 对于阅读和维护现有代码以及深入理解 C++ 模板机制仍然至关重要。

7.3.4 替换失败仍会成为错误的情况

SFINAE 并不能保护我们免受模板实例化期间,可能出现的和所有语法错误。如果模板参数能推导出来,并且模板参数替换,我们仍然可能得到一个格式错误的模板函数:

// Example 14
template <typename T> void f(T) {
  std::cout << sizeof(T::i) << std::endl;
}
void f(...) { std::cout << "f(...)" << std::endl; }
f(0);

这段代码片段与之前讨论的那些代码非常相似,只有一个例外 —— 直到检查函数体时,才会发现该模板重载要求类型 T 是一个类,并且拥有名为 T::i 的数据成员。但此时已经太晚了,因为重载决议仅基于函数声明进行 —— 即参数、默认参数和返回类型(后者不用于类型推导或选择更优的重载,但仍会进行类型替换并受 SFINAE 保护)。当模板实例化并重载决议选中,语法错误,例如函数体中出现无效表达式,都不会忽略 —— 这种失败确实会导致错误。标准中定义了哪些上下文中的替换失败会忽略,哪些不会;这个列表在 C++11 中得到了显著扩展,后续标准又进行了一些调整。

还有一种情况是,试图使用 SFINAE 反而会导致错误。以下是一个示例:

// Example 15a
template <typename T> struct S {
  typename T::value_type f();
};

这里,有一个类模板。如果类型 T 没有名为 value_type 的嵌套类型,类型替换就会导致错误,而且这是一个真正的错误,不会忽略。甚至无法使用没有 value_type 的类型,来实例化这个类模板。将函数变成模板也无法解决这个问题:

template <typename T> struct S {
  template <typename U> typename T::value_type f();
};

必须记住 SFINAE 仅在模板函数的模板类型推导替换过程中,发生错误时才适用。在最后一个例子中,替换错误并不依赖于模板类型参数 U,因此它总会是一个错误。如果确实需要绕过这个问题,就必须使用一个成员函数模板,并利用一个模板类型参数来触发替换错误。由于我们并不需要模板参数,可以将其默认设置为与类模板的类型参数 T 相同:

// Example 15b
template <typename T> struct S {
  template <typename U = T>
  std::enable_if_t<std::is_same_v<U, T>
  typename U::value_type f();
};

现在,如果发生替换错误,那将是因为类型 U::value_type 依赖于模板类型参数 U。不需要显式指定类型 U,默认为 T,并且由于要求类型 U 和 T 必须相同(否则函数的返回类型无效,这属于 SFINAE 错误),也不能是其他类型。因此,模板成员函数 f() 所做的(几乎)与原始的非模板函数 f() 完全相同(如果该函数在类内存在重载,则存在一些细微差别)。因此,如果确实需要“隐藏”由类模板参数引起的替换错误,可以通过引入一个冗余的函数模板参数并限制这两个参数始终相同来实现。

在继续之前,让我们回顾一下遇到的三种替换失败类型。

7.3.5 替换失败发生的位置及其原因

为了理解本章的其余内容,必须清楚地区分模板函数中可能出现的几种不同类型的替换失败。

第一种类型发生在模板声明中使用了依赖类型或其他可能导致失败的构造,并且这些构造的使用对于正确声明模板是必要的。以下是一个旨在用容器参数调用的模板函数(所有 STL 容器都有一个名为 value_type 的嵌套类型):

// Example 16
template <typename T>
bool find(const T& cont, typename T::value_type val);

如果尝试使用一个未定义嵌套类型 value_type 的参数来调用此函数,该函数调用将无法编译(假设没有其他重载)。还有很多其他例子,会自然地使用依赖类型和其他表达式,而这些表达式对于某些模板参数值可能是无效的。此类无效表达式会导致替换失败。这种失败不一定发生在参数声明中。以下是一个返回类型可能未定义的模板:

// Example 16
template <typename U, typename V>
std::common_type_t<U, V> compute(U u, V v);

这个模板中,返回类型是两个模板参数类型的公共类型。但如果模板参数使得类型 U 和 V 不存在公共类型怎么办?那么类型表达式 std::common_type_t<U, V> 就无效,类型替换将失败。再看另一个例子:

// Example 15
template <typename T>
auto process(T p) -> decltype(*p);

这里,替换失败同样可能发生在返回类型中,但使用了尾部返回类型,以便能够直接检查表达式 *p 是否可以编译(或更正式地说,是否有效)。结果的类型就是返回类型;否则,替换失败。这种声明与如下形式的声明是有区别的:

template <typename T> T process(T* p);

如果函数参数是原始指针,那么这两个版本的效果是相同的。但第一个版本还适用于可以解引用的类型,例如容器迭代器和智能指针,而第二个版本仅适用于原始指针。

第二种替换失败发生在函数声明(包括类型替换)成功编译后,在函数体中出现了语法错误。可以很容易地修改上述每个示例,以观察这种情况是如何发生的。让我们从 find() 函数开始:

// Example 17
template <typename T, typename V>
bool find(const T& cont, V val) {
  for (typename T::value_type x : cont) {
    if (x == val) return true;
  }
  return false;
}

这一次,我们决定接受任意类型的值。这本身不一定错误,但模板函数的函数体是基于这样的假设编写的:容器类型 T 拥有嵌套类型 value_type,并且该类型可以与类型 V 进行比较。如果使用错误的参数调用该函数,调用仍然会编译通过,在模板声明中的替换过程对参数类型没有提出特殊要求。但随后,会在模板函数体内部遇到语法错误,而不是在调用位置。

以下是 compute() 模板中可能发生类似情况的例子:

// Example 17
template <typename U, typename V>
auto compute(U u, V v) {
  std::common_type_t<U, V> res = (u > v) ? u : v;
  return res;
}

这个模板函数可以接受任意两个参数进行调用,但除非这两个参数存在公共类型,否则将无法编译。

请注意这两种替换失败之间的重要区别:如果失败发生在 SFINAE 上下文中,该函数将从重载决议中移除,就好像根本不存在一样。如果存在另一个重载(同名函数),则会考虑该重载,并可能调用。如果不存在其他重载,将在调用点得到一个语法错误,其本质是“没有这样的函数”。另一方面,如果失败发生在模板函数体内部(或其他不受 SFINAE 规则保护的地方),假设该函数是最佳或唯一的重载将被选中。调用方的代码 —— 即调用本身 —— 将能成功编译,但模板本身的编译会失败。

第一种情况(SFINAE 移除)之所以更可取,主要有以下几个原因。首先,调用者可能本意是调用另一个重载函数(该函数本可以成功编译),但由于模板间的重载决议规则非常复杂,选中了错误的重载,调用者可能很难修复此错误并强制选择预期的重载。其次,当模板函数体编译失败时,得到的错误信息通常难以理解。我们的例子很简单,但在更现实的情况下,可能会看到涉及一些内部类型和对象的错误,而这些对你来说完全陌生。最后一个原:模板函数的接口,如同其他接口一样,应尽可能完整地描述对调用者的要求。接口是一种约定;如果调用者遵守了约定,函数的实现者就必须兑现承诺。

假设有一个模板函数,其函数体对类型参数有一些要求,而这些要求无法通过自然、直接的方式在接口中表达出来(类型替换成功了,但模板本身无法编译)。将这种硬性替换失败转化为 SFINAE 失败的唯一方法,就是让失败发生在 SFINAE 上下文中。为此,需要在接口中添加一些并非声明函数所必需的内容。添加这部分内容的唯一目的就是触发替换失败,从而在导致函数体编译错误之前,将该函数从重载决议集合中移除。这种“人为的”失败就是第三种替换失败。以下是一个示例,强制要求类型必须是指针,尽管即使没有这个要求,接口本身也完全可以正常工作:

// Example 18
template <typename U, typename V>
auto compare(U pu, V pv) -> decltype(bool(*pu == *pv)) {
  return *pu < *pv;
}

该函数接受两个指针(或其他可解引用的类指针对象),并返回所指向值的布尔比较结果。为了让函数体能够成功编译,两个参数都必须是可解引用的。此外,解引用后的结果必须能够进行相等性比较。最后,比较的结果必须能够转换为 bool 类型。这里的尾部返回类型声明并非必要:本可以直接将函数声明为返回 bool 类型。但它确实起到了作用:它将可能的替换失败从函数体转移到了函数声明处,使其成为 SFINAE 失败。除非 decltype() 内部的表达式无效,否则返回类型始终为 bool。当出现与函数体无法编译相同的原因时,其中一个参数无法解引用、值无法比较,或者比较结果无法转换为 bool(后者通常是多余的,但不妨强制执行整个约定)。

“自然”替换失败和“人为”替换失败之间的界限并不总是清晰的。有人可能会认为,之前使用 std::common_type_t<U, V> 作为返回类型是人为的(属于第三种替换失败,而非第一种),而“自然”的方式应该是将返回类型声明为 auto,如果无法推导出公共类型,则让函数体编译失败。事实上,这种区别往往归结为开发者的风格和意图:如果不是为了强制执行类型限制,开发者是否仍会在模板声明中写出该类型表达式?

第一种失败很直接:模板接口本身构成了一项约定,调用尝试违反了该约定,函数不会调用。第二种失败理想情况下应完全避免。而要做到,必须采用第三种失败,即在 SFINAE 上下文中人为制造替换失败。本章的其余部分将讨论,如何编写此类限制接口的模板约定。自 C++ 诞生之初,SFINAE 技术就用来人为地引发替换失败,从而将此类函数从重载集合中移除。C++20 引入了一种完全不同的机制来解决此问题:概念。在讨论使用 SFINAE 控制重载决议之前,需要先深入了解这一语言特性。