在 C++ 中,“模板特化(template specialization)” 用于在通用模板的基础上,针对某些特殊的数据类型单独提供定制实现。比如大多数类型使用同一套通用逻辑就够了,唯独布尔值或指针类型需要换一种处理方式,这时我们就可以使用模板特化。
根据定制范围的不同,模板特化一般分为 2 种:① 显式特化(全特化),也就是把模板参数全部固定为具体类型;② 偏特化(部分特化),也就是只限定一部分条件,仍然保留未知的类型参数。
类模板显式特化(全特化)
首先来看类模板的显式特化。在动手编写代码之前,我们需要先分清两个成对出现的概念:
- 主模板:也就是最先编写的通用模板。它提供默认的处理规则,负责处理所有没有特殊说明的常规类型。
- 显式特化:又称 “全特化”,是指把主模板中的模板参数全部固定为某一个具体的数据类型,并为该类型单独编写一套实现。
语法:
// 主模板
template <typename T>
class 类名 {
// 通用实现
};
// 显式特化
template <>
class 类名<具体类型> {
// 专门实现
};说明:
编写类模板的显式特化时,我们需要在特化版本的正上方写一对空的尖括号 template <>,并在类名后面的尖括号里填入具体类型,比如 class Printer<bool>。
为什么这里的 template <> 里面是空的呢?这是因为类名后面的 <bool> 已经把原本待定的类型参数 T 固定成了具体类型,没有剩余的未知类型需要声明了,但开头的 template <> 标记本身不能省略。
在编写类模板显式特化时,小伙伴们需要注意以下 2 点:
- 主模板必须写在前面:特化的前提是 “先有通用模板,再有特殊定制”,因此主模板必须声明在显式特化版本的前面,否则编译时会报错。
- 特化版本是一个独立的类:显式特化相当于针对该具体类型重新编写了一个独立的类。主模板里的成员变量和成员函数不会自动保留到特化版本中,需要用到的成员都必须在特化版本内部重新定义。
默认情况下,使用 std::cout 直接输出 bool 值 true 时,屏幕上显示的是数字 1。如果我们希望针对 bool 类型直接输出单词 "true" 或 "false",就可以为它编写一个显式特化版本。
示例 1:类模板显式特化
#include <iostream>
// 主模板
template <typename T>
class Printer {
public:
void print(T value) {
std::cout << value << '\n';
}
};
// bool 显式特化
template <>
class Printer<bool> {
public:
void print(bool value) {
std::cout << (value ? "true" : "false") << '\n';
}
};
int main() {
Printer<int> intPrinter;
Printer<double> doublePrinter;
Printer<bool> boolPrinter;
intPrinter.print(42);
doublePrinter.print(3.14);
boolPrinter.print(true);
return 0;
}运行结果如下。
42
3.14
true分析:
在这个例子中,Printer<int> 和 Printer<double> 没有对应的特化版本,编译器会自动使用主模板 Printer<T>。
而创建 Printer<bool> boolPrinter; 时,编译器发现了专门针对 bool 编写的显式特化版本 class Printer<bool>,就会直接采用该特化版本,通过三元运算符输出单词 "true"。
另外大家还要注意,假如主模板 Printer<T> 里还定义了另一个成员函数 reset(),但 Printer<bool> 特化版本里没有重新定义 reset(),那么下面这种写法就会报错:
Printer<bool> p;
p.reset(); // 编译报错函数模板显式特化
除了类模板之外,函数模板同样支持显式特化。写法也是先定义好主函数模板,再在特化函数的正上方写上空的 template <>,并在函数名后面的尖括号中指明具体的类型。
语法:
template <>
返回类型 函数名<具体类型>(具体类型形参) {
// 专门实现
}说明:
函数模板显式特化中的形参类型,必须与函数名后面 <具体类型> 填入的类型保持一致。
示例 2:函数模板显式特化
#include <iostream>
// 主函数模板
template <typename T>
void showItem(T value) {
std::cout << value << '\n';
}
// char 显式特化
template <>
void showItem<char>(char value) {
std::cout << "字符:" << value << '\n';
}
int main() {
showItem(100);
showItem(3.14);
showItem('A');
return 0;
}运行结果如下。
100
3.14
字符:A分析:
调用 showItem(100) 和 showItem(3.14) 时,匹配的是主函数模板 showItem<T>()。而调用 showItem('A') 时,实参 'A' 是 char 类型,正好命中显式特化版本 showItem<char>(),输出时在前面加上了 "字符:"。
不过对于函数来说,如果只是希望某一种具体参数类型执行不同的处理逻辑,直接编写一个同名的普通函数重载往往比写函数模板显式特化更简单。对于上面的例子,下面 2 种方式是等价的:
// 方式 1
template <>
void showItem<char>(char value) {
std::cout << "字符:" << value << '\n';
}
// 方式 2(推荐)
void showItem(char value) {
std::cout << "字符:" << value << '\n';
}其中,方式 2 省去了头顶的 template <> 和函数名后面的 <char>,就是一个普通的重载函数。当普通重载函数与函数模板同时满足调用条件时,编译器本来就会优先调用普通函数,因此在实际开发中我们更推荐使用方式 2。
类模板偏特化(部分特化)
前面介绍的显式特化,是把所有模板参数都固定成了具体的类型。但在有些场景下,我们并没有把类型完全定死,而是只限定一部分条件,仍然保留未知的模板参数,这种做法称为 “类模板偏特化”,也叫 “部分特化”。
在实际开发中,类模板偏特化常见的有以下 2 种情况:① 将模板参数限定为指针或引用等特定形态;② 在多个模板参数中只固定其中一部分参数。
1. 限定为指针类型
比如我们希望通用类模板直接输出普通变量的值,但遇到指向数据的指针时,不再直接打印内存地址,而是先判断指针是否为空,再解引用输出它所指向的数据。
语法:
// 主模板
template <typename T>
class 类名 {
// 通用实现
};
// 指针偏特化
template <typename T>
class 类名<T*> {
// 针对指针类型的实现
};说明:
很多初学的小伙伴容易搞混:什么时候在头顶写空的 template <>,什么时候写 template <typename T>?其实大家只需要看类名后面的尖括号 “<>” 里还有没有未知的类型参数就可以了:
- 类名后没有未知参数(全特化):比如
class Printer<bool>,类名后面的尖括号里已经是具体的bool类型,没有任何待定的类型字母了,所以头顶写空的template <>。 - 类名后仍有未知参数(偏特化):比如
class Printer<T*>,虽然限定成了指针形态,但指针具体指向什么类型T依然是待定的,所以头顶必须保留template <typename T>来声明这个T。
示例 3:指针类型的偏特化
#include <iostream>
// 主模板
template <typename T>
class Printer {
public:
void print(T value) {
std::cout << "普通值:" << value << '\n';
}
};
// 指针偏特化
template <typename T>
class Printer<T*> {
public:
void print(T* ptr) {
if (ptr != nullptr) {
std::cout << "指针指向的值:" << *ptr << '\n';
}
}
};
int main() {
int a = 10;
double b = 3.14;
Printer<int> valPrinter;
Printer<int*> intPtrPrinter;
Printer<double*> dblPtrPrinter;
valPrinter.print(a);
intPtrPrinter.print(&a);
dblPtrPrinter.print(&b);
return 0;
}运行结果如下。
普通值:10
指针指向的值:10
指针指向的值:3.14分析:
在上面例子中,Printer<int> valPrinter; 传入的是普通整数类型 int,匹配主模板 Printer<T>,直接输出整数 10。
而 Printer<int*> 和 Printer<double*> 传入的都是指针类型,编译器识别出它们符合 T* 的形态,会自动匹配到偏特化版本 Printer<T*>,在确认指针不为空后通过 *ptr 解引用输出变量的值。
注意 在 C++ 中,只有 “类模板” 才支持偏特化,“函数模板” 是不允许偏特化的。如果函数需要针对指针类型做特殊处理,直接再定义一个形参为 T* 的函数模板重载即可。
2. 固定部分模板参数
当一个类模板包含多个模板参数时,我们也可以只把其中的某一个参数固定为具体类型,同时让其余参数继续保持通用。下面来看一个例子。
示例 4:固定部分模板参数
#include <iostream>
#include <string>
// 主模板
template <typename K, typename V>
class KeyValue {
public:
void show(const K& key, const V& value) {
std::cout << key << " -> " << value << '\n';
}
};
// 固定第 2 个参数为 int
template <typename K>
class KeyValue<K, int> {
public:
void show(const K& key, int value) {
std::cout << key << " -> 整数 " << value << '\n';
}
};
int main() {
KeyValue<std::string, std::string> item1;
item1.show("site", "lvyenet");
KeyValue<std::string, int> item2;
item2.show("score", 95);
return 0;
}运行结果如下。
site -> lvyenet
score -> 整数 95分析:
在这个例子中,主模板 KeyValue<K, V> 包含两个可变类型参数。偏特化版本 class KeyValue<K, int> 把第 2 个参数固定成了 int,第 1 个参数 K 依然可变,所以头顶只声明了剩余的 template <typename K>。
创建 KeyValue<std::string, std::string> 对象时会匹配主模板。而创建 KeyValue<std::string, int> 对象时,由于第 2 个参数正好是 int,就会自动匹配该偏特化版本。
最后,如果同一个类模板同时存在主模板、偏特化版本和显式特化版本,编译器会按照 “越具体越优先” 的顺序进行匹配:
显式特化(全特化) > 偏特化(部分特化) > 主模板
拿前面的 Printer 类模板来说,假设代码中同时定义了主模板 Printer<T>、指针偏特化版本 Printer<T*> 以及专门针对 int* 的显式特化版本 Printer<int*>。
那么创建 Printer<int*> 对象时,编译器会优先选中最具体的 Printer<int*>。如果创建的是 Printer<double*> 对象,则会选中指针偏特化版本 Printer<T*>。
