类模板是 C++ 泛型编程的核心。上一篇讲了函数模板,本文接着讲类模板:如何定义、成员函数怎么写、全特化与部分特化、非类型参数、默认参数,以及模板模板参数。

一、为什么需要类模板

和函数模板同理:如果为 int、float、double 各写一个 Vector 类,逻辑完全相同,只是元素类型不同。类模板让我们写一份代码,编译器根据实例化时的类型自动生成对应版本。

二、基本定义语法

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T>
class Vec3 {
private:
T m_v[3];
public:
Vec3(T v1, T v2, T v3) {
m_v[0] = v1;
m_v[1] = v2;
m_v[2] = v3;
}
T get(int i) const { return m_v[i]; }
};

使用时用 类名<具体类型> 实例化:

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;

int main() {
Vec3<int> a(1, 2, 3);
Vec3<float> b(1.1f, 2.2f, 3.3f);
cout << a.get(0) << ' ' << b.get(2) << endl; // 输出 1 3.3
return 0;
}

注意:类模板的名字(Vec3)本身不是类型,必须带上参数 Vec3<int> 才是一个具体类型。这一点和函数模板不同——函数模板靠调用自动推导参数,类模板必须显式写出。

三、类外定义成员函数

成员函数在类内直接写很简单,但在类外定义时需要用 类名<模板参数>:: 限定,并重复 template 声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T>
class Vec3 {
private:
T m_v[3];
public:
Vec3(T v1, T v2, T v3);
T getMax() const; // 只声明,定义放类外
};

// 构造函数的类外定义
template <typename T>
Vec3<T>::Vec3(T v1, T v2, T v3) {
m_v[0] = v1;
m_v[1] = v2;
m_v[2] = v3;
}

// getMax 的类外定义
template <typename T>
T Vec3<T>::getMax() const {
T temp = m_v[0] > m_v[1] ? m_v[0] : m_v[1];
return temp > m_v[2] ? temp : m_v[2];
}

两个要点:

  1. 类外定义必须以 template <typename T> 开头,告诉编译器「这是一个模板的成员」;
  2. 限定符写成 Vec3<T>::(带尖括号),不能只写 Vec3::。

四、类模板特化(全特化)

和函数模板类似,类模板也能为特定类型单独写一份完整实现,这叫全特化。语法是 template <> 后跟 class 类名<具体类型>。

比如我们想给 Vec3<char> 换个实现:内部用 4 字符数组存 3 个字符 + 结尾 \0,并新增一个 asString() 把它当字符串输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <>
class Vec3<char> {
private:
char m_v[4]; // 注意长度变成 4,留位置给 '\0'
public:
Vec3(char v1, char v2, char v3) {
m_v[0] = v1;
m_v[1] = v2;
m_v[2] = v3;
m_v[3] = '\0';
}
const char* asString() const { return m_v; }
};

// 使用
Vec3<char> c('a', 'b', 'c');
cout << c.asString() << endl; // 输出 abc

全特化版本可以和通用模板完全不一样:成员变量、成员函数都能重新定义。

五、部分特化(partial specialization)

类模板比函数模板多一个能力:部分特化——只固定一部分模板参数,另一部分仍然泛化。

比如有个 MyPair<T1, T2>,当第一个参数是 int 时我们想走特殊实现,但第二个参数仍然任意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <typename T1, typename T2>
class MyPair {
private:
T1 first;
T2 second;
public:
MyPair(T1 a, T2 b) : first(a), second(b) {}
void print() { std::cout << "通用 MyPair\n"; }
};

// 部分特化:固定第一个参数为 int
template <typename T2>
class MyPair<int, T2> {
private:
int first;
T2 second;
public:
MyPair(int a, T2 b) : first(a), second(b) {}
void print() { std::cout << "部分特化 MyPair<int, T2>\n"; }
};

使用:

1
2
3
4
MyPair<double, int> p1(1.5, 2);   // 走通用模板
MyPair<int, double> p2(2, 1.2); // 第一个参数是 int,走部分特化
p1.print(); // 通用 MyPair
p2.print(); // 部分特化 MyPair<int, T2>

部分特化只能用于类模板,函数模板不支持部分特化(函数模板靠重载达到类似效果)。

六、非类型参数(non-type parameter)

模板参数不一定是类型,也可以是编译期常量(整型、枚举、指针等)。这种叫非类型参数。

经典用法:用整型常量指定数组大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T, int N>
class Array {
private:
T m_data[N];
public:
// 参数是「长度为 N 的 T 数组引用」,强制要求传入长度正好匹配
Array(T (&values)[N]) {
for (int i = 0; i < N; i++) {
m_data[i] = values[i];
}
}
T get(int i) const { return m_data[i]; }
};

使用:

1
2
float values[5] = {1.2f, 1.3f, 1.4f, 1.5f, 1.6f};   // 注意是单 =,不是 ==
Array<float, 5> arr(values); // 注意类名是 Array,不是 std::vector

几个关键点:

  • 第二个参数 5 必须是编译期常量,不能是运行时变量;
  • 构造函数参数写成 T (&values)[N](数组的引用),会强制检查数组长度,长度不匹配直接编译失败,比裸指针安全;
  • 不同的 N 值代表不同的类型:Array<float, 5> 和 Array<float, 6> 是两个完全不同的类。

七、默认模板参数

和函数默认参数类似,模板参数也能给默认值:

1
2
3
4
template <typename T = float, int N = 5>
class Array {
/* 同上,省略 */
};

实例化时可以省略带默认值的参数:

1
2
3
Array<>         arr1(values);   // 等价于 Array<float, 5>
Array<double> arr2(values); // T=double,N 用默认值 5
Array<double, 3> arr3(/* 3 元素的数组 */); // 两个参数都显式给

注意省略只能从右往左连续省略,不能跳着省。

八、模板模板参数(template template parameter)

模板参数本身也可以是另一个模板,这种叫「模板模板参数」。语法是 template <模板参数列表> class 名字。

一个典型场景:写一个 Wrapper 包装任意容器(std::vector、std::list 等):

1
2
3
4
5
6
7
8
9
10
11
#include <vector>
#include <list>

template <template <typename> class Container, typename T>
class Wrapper {
private:
Container<T> m_values;
public:
Wrapper(const Container<T>& o) : m_values(o) {}
const Container<T>& get() const { return m_values; }
};

使用:

1
2
3
4
5
std::vector<int> v{1, 2, 3};
Wrapper<std::vector, int> wv(v); // Container = std::vector, T = int

std::list<double> l{1.5, 2.5};
Wrapper<std::list, double> wl(l); // Container = std::list, T = double

第一个参数 Container 接收的是「带一个类型参数的模板」(如 std::vector),而不是具体类型。这种写法比较少用,了解即可。

九、常见的坑

  1. 类模板的成员函数定义通常也要放头文件里。和函数模板同理,模板实例化时必须看到完整定义。只有声明会报「未定义的引用」。
  2. 类模板名不是类型。Vec3 不是类型,Vec3<int> 才是。在函数参数、返回值里不能直接写 Vec3,必须带参数(C++14 起类模板参数推导能省略一部分场景,但显式写更稳妥)。
  3. 部分特化不能改变参数个数。MyPair<T1, T2> 部分特化必须仍是两个参数,不能变成一个参数。
  4. 全特化后成员函数可以和通用模板完全不同,但部分特化一般保持接口一致,方便调用方代码不用改。
  5. 自己定义类名时避开 vector(小写)这种和标准库冲突的名字,用 Vec3、Array、MyVector 等。

十、小结

  • 类模板用 template <typename T> class 类名 定义,实例化时写 类名<具体类型>;
  • 类外定义成员函数要重复 template 声明,并用 类名<T>:: 限定;
  • 全特化(template <> class 类名<具体类型>)为特定类型整体重写;
  • 部分特化(template <typename T2> class 类名<具体类型, T2>)只固定一部分参数,只有类模板支持;
  • 非类型参数用编译期常量(如 int N),不同常量值是不同类型;
  • 模板参数可以给默认值,从右往左连续省略;
  • 模板模板参数接收另一个模板作为参数,写法是 template <typename> class 名字;
  • 模板实现一般放头文件;类名避开标准库名字。