cpp / 2023.02.28

C++ の 泛型编程(Generics)

泛型编程是程序设计语言的一种风格或范式,允许程序员在编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。在 C++ 里,这是与 OOP 并列的一大分支,通过模板来实现。

泛型编程是程序设计语言的一种风格或范式,允许程序员在编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。在 C++ 里,这是与 OOP 并列的一大分支,通过模板来实现。

Why 模板?

现在有一个需求,就是写一个简单的 swap 函数,要求支持所有内置类型。在模板出来以前,C++ 允许同名函数重载,于是我们可以针对不同类型分别定义。intdoublechar、……写着写着发现,我们最后需要实现的函数的数量是所有内置类型总数的平方!并且一旦后续添加了更多类型,我们又要编写更多函数!(不失为一个提高代码量的好方法,但毫无意义)并且维护代码的成本变得极高,一旦某一个 swap 函数出了问题,我们需要在巨大的头文件中找到那个出错的函数,仿佛大海捞针一般。

针对这些缺点,C++ 提出了泛型编程,也就是写一种与类型无关的代码,或者说是提供一个模板,提高代码的复用性。所谓模板,便是给定一套规范以及一定的占位符,根据用户后续对占位符进行不同形式的填充来产生不同的效果。C++ 里可以通过模板来定义一族函数、一族类,甚至是一族变量。

函数模板

函数模板定义一族函数。

1template<模板形参列表> 函数声明;

模板形参可以是以下项的任意排列组合:

  1. 非类型形参:仅允许带有 cv 限定的左值引用、指针、整型、枚举类型,类对象以及字符串是不允许的,C++20 起还允许浮点型;
  2. 类型形参:形如 typename|class T,类型名 T 是可选的;
  3. 模板形参:形如 template<形参列表> typename|class T,类型名 T 是可选的;
  4. 上述的形参包
1template<class T>
2void swap(T& first, T& second) {
3  T temp = first;
4  first = second;
5  second = temp;
6}

注意,函数模板自身并不是类型、函数或任何其他实体,不会从只包含模板定义的源文件生成任何代码。函数模板只有实例化后才会有代码出现。所谓实例化,就是用实参填充模板形参列表,让编译器生成指定类型函数,不用定义函数实现。实例化一个函数模板需要知道它的所有模板实参,但不需要指定每个模板实参,允许编译器进行隐式实例化,即模板实参推导,即尽可能从函数实参推导缺失的模板实参。

1int main() {
2  int a = 1, b = 2;
3  swap(a, b); // 隐式实例化。允许省略尖括号,编译器会自动进行模板实参推导
4              // 推导结果为 swap<int>
5  std::cout << a << " " << b;
6}
7// output: 2 1

在模板出现以前,对同名函数的重载如果只有某些值的类型不同,那么需要为每一个值定义一遍函数,这是非常麻烦的。有了模板,就能直接在函数名后用尖括号带上特定的类型即可。

对于特定的实现,我们或许不想要依照原来函数模板那样执行,而是自定义函数体,这也是可以的。函数模板特化能够将某一个或某几个要处理的数据类型进行单独处理,但需要额外定义对应数据类型的模板函数,比如:

 1template<char>
 2void swap(char& first, char& second) {
 3  std::cout << "我不 swap,哎,就是玩\n";
 4}
 5
 6int main() {
 7  char a = 'a', b = 'b';
 8  std::cout << func(a b); // 推导结果为 func<char>,于是使用特化的函数模板
 9}
10// output: 我不 swap,哎,就是玩

如果同时又加上一个这样的普通函数:

1char swap(char& first, char& second) {
2  std::cout << "只因你太美\n";
3}

那么原来的代码会输出 只因你太美,这是因为在省略尖括号时,编译器在编译时会试图优先将其识别为普通函数,此时如果找到对应的普通函数,则直接调用,而不会进行后续的实参推导。如果希望优先识别为特化后的模板函数,则需要加上尖括号,这样才能让编译器知道噢原来这是个模板函数。

实际上,调用顺序为普通函数 > 模板特化函数 > 模板函数

类模板

与函数模板一样,类模板定义一族类。

1template<模板形参列表> 类声明;

模板形参列表与函数模板完全一致。注意,类模板自身并不是类型、对象或任何其他实体,不会从只包含模板定义的源文件生成任何代码。类模板同样只有实例化后才会有代码出现,并且也支持类模板实参推导,即尽可能从类构造函数实参推导缺失的模板实参。此外,类模板的特化还进一步分为了全特化偏特化

 1#include <iostream>
 2
 3template <class First, class Second>
 4class MyPair {
 5 public:
 6  MyPair(First f_, Second s_) : f(f_), s(s_) {
 7    std::cout << "No specialization: " << f << ' ' << s << '\n';
 8  }
 9  void show();
10
11 private:
12  First f;
13  Second s;
14};
15
16template <class First, class Second>
17void MyPair<First, Second>::show() {  // 类模板的函数在类外实现,需要加上模板参数列表
18  std::cout << f << " " << s << "\n";
19}
20
21// 偏特化 1
22template <class First>
23class MyPair<First, char> { // 偏特化时需加上模板参数列表,以指明特化了哪个形参
24 public:
25  MyPair(First f_, char s_) : f(f_), s(s_) {
26    std::cout << "specialization <First, char>: " << f << ' ' << s << '\n';
27  }
28
29 private:
30  First f;
31  char s;
32};
33
34// 偏特化 2
35template <class Second>
36class MyPair<int, Second> {
37 public:
38  MyPair(int f_, Second s_) : f(f_), s(s_) {
39    std::cout << "specialization <int, Second>: " << f << ' ' << s << '\n';
40  }
41
42 private:
43  int f;
44  Second s;
45};
46
47// 全特化
48template <>
49class MyPair<int, char> {
50 public:
51  MyPair(int f_, char s_) : f(f_), s(s_) {
52    std::cout << "specialization <int, char>: " << f << ' ' << s << '\n';
53  }
54 private:
55  int f;
56  char s;
57};
58
59int main() {
60  MyPair<double, int> pair1(3.14, 1); // 显式实例化
61  MyPair pair2(2.7, 'a');             // 推导结果为 MyPair<double, char>
62  MyPair pair3(1, 'b');               // 推导结果为 MyPair<int, char>
63}
64// output:
65// No specialization: 3.14 1
66// specialization <First, char>: 2.7 a
67// specialization <int, char>: 1 b

与函数模板不同的是,类模板不能与普通同名类共存,即若上述代码中又定义了一个 class MyPair,则编译报错。

派生

类模板、模板类和普通类之间可以互相派生。它们之间的派生关系有以下几种情况。

1template <class T>
2class Base1 {};
3
4// case 1.1: 模板类派生类模板
5template <class T>
6class Derive11: Base1<int> { T val; };
7
8// case 1.2: 模板类派生普通类
9class Derive12: Base<int> {};
1template <class T>
2class Base2 {};
3
4// case 2.1: 类模板派生类模板
5template <class T>
6class Derive21: Base2<T> {};
1class Base3 {};
2
3// case 3.1: 普通类派生类模板
4template <class T>
5class Derive31: Base3 { T val; };
6
7// case 3.2: 普通类派生普通类,略

多态

子类和父类的模板参数列表可以不一样,但必须一一对应。

 1template <class T, class U>
 2class Base {
 3 public:
 4  virtual void foo(T, U) = 0;
 5};
 6
 7class Derive1 : public Base<int, char> {
 8 public:
 9  void foo(int a, char b) override {
10    std::cout << "Derive1 foo():" << a << ' ' << b << '\n';
11  }
12};
13
14template <class T, class U>
15class Derive2 : public Base<U, T> {
16 public:
17  void foo(U a, T b) override {
18    std::cout << "Derive2 foo():" << a << ' ' << b << '\n';
19  }
20};
21
22int main() {
23  Base<int, char>* ptr1 = new Derive1(); // 必须用 Base<int, char>* 指向,因为 Derive1 就是派生自该模板类
24  ptr1->foo(1, '2');  // 调用 Derive1::foo(int, char)
25
26  Base<int, double>* ptr2 = new Derive2<double, int>(); // 与继承顺序一一对应
27  ptr2->foo(3, 3.14); // 调用 Derive2::foo(int, double)
28}
29// output:
30// Derive1 foo():1 2
31// Derive2 foo():3 3.14

上面代码写的不规范,按道理有虚函数的类应该为其设置一个虚析构函数,上面分别有两个特化的基类 Base<int, char>Base<int, double,就需要分别定义两个特化类并设置虚析构函数。

成员模板

任意类都可以在体内嵌套声明类模板/函数模板。规则:

  1. 析构函数和复制构造函数不能是模板;
  2. 成员函数模板不能为虚,且派生类中的成员函数模板不能覆盖来自基类的虚成员函数;
  3. 可以声明具有相同名字的非模板成员函数和模板成员函数。在冲突的情况下,执行效果如同之前讨论的函数模板那样;

形参包

形参包分为两种:

  1. 模板形参包是接受零个或更多个模板实参的模板形参;
  2. 函数形参包是接受零个或更多个函数实参的函数形参。

至少有一个形参包的模板被称作变参模板

变参类模板可以用任意数量的模板实参实例化,以 std::tuple 为例,其定义为:

1template<class... Types>
2class tuple;
3
4std::tuple<> t0;           // Types 不包含实参
5std::tuple<int> t1;        // Types 包含一个实参:int
6std::tuple<int, float> t2; // Types 包含两个实参:int 与 float
7std::tuple<0> error;       // ERROR! 0 不是类型

变参函数模板也是同理,可以用任意数量的函数实参调用:

1template<class... Types>
2void f(Types... args);
3
4f();       // OK:args 不包含实参
5f(1);      // OK:args 包含一个实参:int
6f(2, 1.0); // OK:args 包含两个实参:int 与 double

一些疑问

Why 非类型模板形参?

把非类型模板形参放到函数参数列表里,或者类的构造函数里,不是效果也差不多吗,为什么要多此一举设置一个非类型模板形参呢?

首先要明确的一点是,非类型模板实参必须是 constexpr 且能转换为整类型的字面量,这就使得我们能够利用一个在编译时可知的自定义参数。而 constexpr 变量是不能用作函数形参的,如果是类构造函数,则无法进行自定义,所以总有缺陷。

std::tupleget() 函数正是采用了非类型模板形参,而不是将下标参数作为函数参数,因为我们需要确定返回值的类型,而这无法在运行时确定。借助非类型模板形参,就可以在编译时确定返回值类型,还能提高运行效率。

本质上相当于是编译器为 tuple 里的每个元素都生成一个对应的获取函数。

类模板的声明和实现为什么不能放在不同的文件里?

对于普通类而言,声明可以放在 .h 文件中,而成员函数的实现可以写在 .cpp 里。这是因为多个 .cpp 会先被编译成若干目标代码文件 .obj,最后链接到一起形成可执行文件。在编译的过程中,编译器能够知道足够信息,比如形参类型,从而根据 .cpp 里的成员函数实现生成目标代码。

这也有利于对外隐藏实现,直接将实现文件打包成库,和头文件一起发布。

而对于类模板,如果也将成员函数实现写在 .cpp 里,比如

1template<class T>
2class test {
3  public:
4    T get();
5    void set(const T& v);
6
7  private:
8    T value;
9};
1template<class T>
2T test::get() {
3  return value;
4}
5
6template<class T>
7void set(const T& v) {
8  value = v;
9}

然后又在另一个 .cpp 文件里生成了 test<int> t 并且调用 t.set(1),进行编译会报 undefined reference 错误。

《C++ 编程思想》第 15 章(P300)说明了原因:模板定义很特殊。由 template<…> 处理的任何东西都意味着编译器在当时不为它分配存储空间,它一直处于等待状态直到被一个模板实例告知。在编译器和连接器的某一处,有一机制能去掉指定模板的多重定义。所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。

意思就是说,在调用 t.set(1) 的时候,由于当前文件里没有这一函数的定义,所以编译器仅仅是生成一个符号,并寄希望于链接器在其他 .obj 文件中找到该函数的定义。然而,正如上面说的,在处理 test.cpp 的时候,编译器并不会对 set() 函数生成任何目标代码——因为编译器根本不知道 T 实际是什么!所以最后链接的时候只能在头文件 test.h 里找到答案。

很遗憾,头文件中只有声明,没有定义。我们不知道调用的这个函数具体应该做什么,也就产生了上面那个错误。

为了解决这一问题,有两种方法:

  1. 将声明和定义统一放在头文件中;
  2. 将声明和定义分离,但在 .cpp 中显式实例化声明,比如下面这样:
 1template<class T>
 2T test::get() {
 3  return value;
 4}
 5
 6template<class T>
 7void set(const T& v) {
 8  value = v;
 9}
10
11template class test<int>; // 实例化声明

这样做的缺点就是必须为所有类型添加实例化声明,这样编译器才能在处理 test.cpp 时生成正确的目标代码。