2.1. C++的模板机制

C++ 最强大的特性之一是它对泛型编程的支持。泛型编程中,算法和数据结构是用将在后续指定的泛型类型进行编写的。

这使得开发者能够将一个函数或类实现一次,之后用许多不同的类型进行实例化。模板是 C++ 的一种特性,允许类和函数基于泛型类型进行定义。C++ 支持三种模板 —— 函数模板、类模板和变量模板。

2.1.1 函数模板

函数模板是泛型函数 —— 与普通函数不同,模板函数并不声明其参数的具体类型,而是使用类型作为模板参数:

// Example 01
template <typename T>
T increment(T x) { return x + 1; }

这个模板函数可以用于将支持“加一”操作的类型的值增加一:

increment(5); // T 是 int,返回 6
increment(4.2); // T 是 double,返回 5.2 char c[10];
increment(c); // T 是 char*,返回 &c[1]

大多数模板函数对其模板参数所使用的类型都有一些限制。例如,increment() 函数要求对于参数 x 的类型,表达式 x + 1 是合法的。否则,尝试实例化该模板时将会失败,并产生一条较为冗长的编译错误信息。无论是非成员函数还是类的成员函数,都可以是函数模板,但虚函数不能是模板。泛型类型不仅可以用于声明函数参数,还可以用于声明函数体内的变量:

template <typename T> T sum(T from, T to, T step) {
  T res = from;
  while ((from += step) < to) { res += from; }
  return res;
}

在 C++20 中,简单的模板声明可以进行缩写:

template <typename T> void f(T t);

可以直接写成

// Example 01a
void f(auto t);

除了声明更加简洁之外,这种缩写形式并没有特别的优势,而且该功能的功能相当有限。首先,auto 只能用作“最外层”的参数类型。例如,以下写法是无效的(尽管某些编译器允许):

void f(std::vector<auto>& v);

此外,相关类型仍必须完整写出:

template <typename T> void f(std::vector<T>& v);

如果需要在函数声明的其他位置使用模板类型参数,则无法使用这种简写形式:

template <typename T> T f(T t);

当然,可以将返回类型声明为 auto 并使用尾部返回类型来实现更复杂的类型表达:

auto f(auto t) -> decltype(t);

此时,模板的声明已经不再显得“简洁”了。接下来,让我们先介绍类模板。

2.1.2 类模板

类模板是使用泛型类型的类,通常用于声明其数据成员,也可用于声明其内部的方法和局部变量:

// Example 02
template <typename T> class ArrayOf2 {
public:
  T& operator[](size_t i) { return a_[i]; }
  const T& operator[](size_t i) const { return a_[i]; }
  T sum() const { return a_[0] + a_[1]; }
private:
  T a_[2];
};

这个类只需实现一次,之后就可以用来定义任意类型的两个元素的数组:

ArrayOf2<int> i; i[0] = 1; i[1] = 5;
std::cout << i.sum(); // 6

ArrayOf2<double> x; x[0] = -3.5; x[1] = 4;
std::cout << x.sum(); // 0.5

ArrayOf2<char*> c; char s[] = "Hello";
c[0] = s; c[1] = s + 2;

注意最后一个例子 —— 可能会认为 ArrayOf2 模板不应当与 char* 这样的类型一起使用,毕竟有一个 sum() 方法,而当 a_[0] 和 a_[1] 的类型是指针时,该方法无法编译。然而,上面的代码可以正常编译通过:类模板中的成员函数,只有在调用时才会进行实例化。只要不调用 c.sum(),其无法编译的问题就不会暴露,程序仍然合法。

如果确实调用了对所选模板参数不适用的成员函数,就会在模板体内部引发语法错误(在本例中,可能是“无法对两个指针进行加法”之类的错误)。这类错误信息通常并不直观,即使错误信息很清晰,也难以判断问题是出在函数体内部,还是这个函数本就不该调用。在本章的后续部分,将了解如何改善这种情况。

2.1.3 变量模板

C++ 中的第三种模板是变量模板,它在 C++14 中引入。这种模板允许定义具有泛型类型的变量:

// Example 03
template <typename T> constexpr T pi =
  T(3.14159265358979323846264338327950288419716939937510582097494459230781L);

pi<float>; // 3.141592
pi<double>; // 3.141592653589793

变量模板在使用上大体非常直观,通常用于定义自定义的常量。尽管用法简单,但变量模板也支持一些有趣的设计模式。我们将在下一节中看到一个利用变量模板的实用示例。

2.1.4 非类型模板参数

通常,模板参数是类型,但 C++ 也允许使用多种非类型参数。首先,模板参数可以是整数类型或枚举类型的值:

// Example 04
template <typename T, size_t N> class Array {
public:
  T& operator[](size_t i) {
    if (i >= N) throw std::out_of_range("Bad index");
    return data_[i];
  }
private:
  T data_[N];
};

Array<int, 5> a; // OK
cin >> a[0];
Array<int, a[0]> b; // Error

这是一个包含两个参数的模板 —— 第一个是类型参数,而第二个不是类型,而是一个 std::size_t 类型的值参数,决定了数组的大小。与 C 风格内置数组相比,这种模板的优势在于可以进行边界检查,从而提高安全性。C++ 标准库中提供了 std::array 类模板,实际开发中应优先使用。但用自定义的 Array 作为示例,有助于更清晰地理解非类型模板参数的用法。

用于实例化模板的非类型参数的值,必须是编译时常量或 constexpr 值 —— 因为 a[0] 的值要等到程序在运行时读取后才能确定,所以前面示例中的最后一行无效。C++20 允许使用浮点类型和用户定义类型作为非类型模板参数;在此之前,非类型模板参数仅限于整型、指针(包括函数指针和成员指针)、引用以及枚举类型。当然,非类型参数的值必须是编译时已知的常量,例如不允许指向局部变量的指针。

数值型模板参数在 C++ 中曾经非常流行,允许实现复杂的编译时计算。但在标准的较新版本中,constexpr 函数可以达到同样的效果,并且代码更易读、更易于维护。当然,标准在限制一些特性的同时也带来了新的可能性。一个有趣的新兴用法是非类型模板参数与 constexpr 函数的结合:这些函数最早在 C++11 中引入,可用于定义“立即函数”,即必须在编译时求值的函数。constexpr 函数的问题在于,可能在编译时求值,但并非强制要求 —— 也可以在运行时求值:

constexpr size_t length(const char* s) {
  size_t res = 0;
  while (*(s++)) ++res;
  return res;
}

std::cout << length("abc") << std::endl;
char s[] = "runtime";
std::cout << length(s) << std::endl;

这里,有一个 constexpr 函数 length()。那么,长度的计算真的会在编译时发生吗?除非查看生成的汇编代码(而不同编译器生成的代码可能不同),否则无法确定。唯一能确保其在编译时计算的方法是将该函数用在一个编译时上下文中:

static_assert(length("abc") == 3, ""); // 没问题
char s[] = "runtime";
static_assert(length(s) == 7, ""); // 失败

第一个断言可以编译通过,而第二个则无法编译,即使值 7 是正确的:因为其参数不是编译时的常量值,所以求值必须在运行时进行。

在 C++20 中,函数可以声明为 consteval 而非 constexpr:这保证了函数要么在编译时求值,要么不求值(因此,前面示例中的第二个 cout 语句将无法编译)。在 C++20 之前,必须采用一些巧妙的方法来强制编译时执行。以下是一种实现方式:

// Example 05c
template <auto V>
static constexpr auto force_consteval = V;

force_consteval 变量模板可用于强制进行编译时求值:

std::cout << force_consteval<length("abc")> << std::endl;
char s[] = "runtime";
std::cout << force_consteval<length(s)> << std::endl;

第二个 cout 语句无法编译,因为 length() 函数不能作为立即函数进行求值。

变量模板 force_consteval 使用了一个非类型模板参数,其类型未显式指定,而是从模板实参中自动推导(即 auto 模板参数),这是 C++17 引入的特性。在 C++14 中,要实现相同效果,只能使用丑陋的宏:

// Example 05d
template <typename T, T V>
static constexpr auto force_consteval_helper = V;

#define force_consteval(V) force_consteval_helper<decltype(V), (V)>

std::cout << force_consteval(length("abc")) << std::endl;

如果非类型模板参数看起来“不如一个类型重要”,那么你可能会喜欢下一个选项 —— 一个远不止简单类型的模板参数。

2.1.5 双重模板参数

值得一提的第二种非类型模板参数是双重模板参数 —— 即本身也是一个模板的模板参数。在本书后面的章节中,我们将需要用到它们。这种模板参数的实参不是一个类的名称,而是一个完整模板的名称。

以下是一个带有双重模板参数的类模板示例:

// Example 06a
template <typename T,
          template <typename> typename Container>
class Builder {
  Container<T> data_;
public:
  void add(const T& t) { data_.push_back(t); }
  void print() const {
    for (const auto& x : data_) std::cout << x << " ";
    std::cout << std::endl;
  }
};

Builder 模板声明了一个类,用于构造(构建)任意类型 T 的容器。该容器本身没有特定的类型,它自身也是一个模板。

它可以使用接受一个类型参数的容器模板进行实例化:

template <typename T> class my_vector { ... };
Builder<int, my_vector> b;
b.add(1);
b.add(2);
b.print();

当然,对 Container 模板还有其他一些要求:必须只有一个类型参数 T(其余参数可以有默认值),应当可默认构造,必须具有 push_back() 方法等。C++20 为我们提供了一种简洁的方式来声明这些要求,并将其作为模板接口的一部分;将在本章后面的“概念”一节中介绍相关内容。

以下是一个具有两个双重模板参数的函数模板示例:

// Example 06b
template <template <typename> class Out_container,
          template <typename> class In_container,
          typename T> Out_container<T>
resequence(const In_container<T>& in_container) {
  Out_container<T> out_container;
  for (auto x : in_container) {
    out_container.push_back(x);
  }
  return out_container;
}

该函数接受一个任意容器作为参数,并返回另一个容器 —— 不同的模板,但使用相同的类型进行实例化,且值从输入容器中复制而来:

my_vector<int> v { 1, 2, 3, 4, 5 };
template <typename T> class my_deque { ... };
auto d = resequence<my_deque>(v);// my_deque with 1 ... 5

编译器会推导出模板实参的类型(In_container 为 std::vector),其模板参数的类型(T 为 int)。当然,剩下的模板参数 Out_container 无法推导(没有出现在模板函数的参数中),必须显式指定,这也符合预期用途。

双重模板参数有一个主要限制,而不同编译器对该限制的执行程度不一,使得这一问题更加复杂(即某些编译器允许本不应通过的代码通过,而你其实希望它能通过)。这个限制是:用于双重模板参数的模板参数数量,必须与传入的模板实参的模板参数数量完全匹配。

来看看以下模板函数:

template <template <typename> class Container, typename T>
void print(const Container<T>& container) {
  for (auto x : container) { std::cout << x << " "; }
  std::cout << std::endl;
}

std::vector<int> v { 1, 2, 3, 4, 5 };
print(v);

这段代码可能能够编译通过,但这取决于所使用的 C++ 标准版本以及编译器对标准的严格遵守程度:std::vector 模板实际上有两个模板参数。第二个参数是分配器,它具有默认值,因此在声明 vector 对象时通常不需要显式指定分配器类型。然而,从严格的模板匹配规则来看,传入一个具有两个参数的模板去匹配只接受一个参数的双重模板参数是不符合要求的。

GCC、Clang 和 MSVC 都在一定程度上放宽了这一限制(但放宽的程度各不相同),因此在实际中这段代码常常能通过编译,但并不完全可移植。