模板的四大核心
1.非类型模板参数
2.函数模板特化
3.类模板全特化 / 偏特化
4.模板分离式编译

、非类型模板参数(Non-Type Template Parameter)
1.1 从 #define 的痛点说起
先看最开始那个 Stack:
cpp
#define N 100
template<typename T>
class Stack {
private:
T _a[N]; // 所有实例都是 100 大小
int _top;
int _capacity;
};
问题很明显:N 是宏,全局只有一个值 。Stack<int> 需要 10 个元素,Stack<int> 需要 1000 个元素 ------ 它俩共用 100。要么浪费空间,要么不够用。宏是 "编译前文本替换",它参与不了 "这个栈该多大" 的决策。
1.2 非类型模板参数:把 "值" 也变成模板参数
cpp
template<typename T, size_t N = 1000>
class Stack {
private:
T _a[N];
int _top;
int _capacity;
};
区别在于:除了 typename T 这种类型参数 ,还可以加一个 size_t N 这种非类型参数。于是:
cpp
Stack<int, 10> s1; // 只有 10 个元素,不浪费
Stack<int> s2; // 走默认值 1000
N 是编译期常量,模板实例化时会用具体数字把 T _a[N] 的数组大小定死。两个对象 sizeof 不一样,因为数组长度写死在类型里了。
白话:类型参数决定 "存什么",非类型参数决定 "存多少 / 怎么存"。
1.3 语法与限制
非类型模板参数能 是:整型、枚举、指针、引用、字面量。不能(C++20 之前)是:
cpp
template<double de> // ❌ C++20 之前:浮点数不行
template<string str> // ❌ 类类型不行
原因:非类型参数必须是能在编译期求值的常量表达式。
浮点数在 C++20 之前编译器不保证精确、类类型(如 std::string)需要动态分配内存,没法当作纯编译期常量。
C++20 起放宽了浮点,但像 std::string 这种带堆内存的还是不行。
再配上默认参数(类型参数和非类型参数都能给默认值):
cpp
template<typename T = int, size_t N = 100>
class Stack { ... };
// Stack<> s1; // 全用默认
注意:Stack<> 要空尖括号 ,不能写成 Stack。
1.4 标准库实例:std::array
std::array 就是非类型模板参数最经典的库级应用 ------ 固定大小、栈上存储、#include <array>:
cpp
array<int, 10> a1;
a1.fill(1); // 全部填 1
a1[0] = 10;
a1[9] = 100;
for (auto e : a1) cout << e << " ";
默认:

使用fill:

越界访问问题:


| 维度 | 原生数组 int a2[10] |
std::array<int,10> |
|---|---|---|
| 存储位置 | 栈上 | 栈上(sizeof 相同) |
| 越界访问 | a2[15]=20 是未定义行为(UB),编译器不拦,悄悄写脏内存 |
a1[15] 同样是 UB;但 .at(15) 会抛异常检查 |
| 是 "对象" 吗 | 裸内存,会被隐式退化 | 是类对象,有 .size()、.fill() |
| 传给函数 | 退化成指针,大小信息丢失 | 不退化,大小随类型保留 |
| 迭代器 / 范围 for | 能用但行为依赖裸指针 | 原生支持、语义安全 |
底层本质 :两者在栈上都是连续 8×10 字节(假设 int 4 字节)。区别不在内存形态,而在于 ------array 把这块内存包成了一个带类型的类对象,所以它 "知道自己多大、能安全遍历";原生数组只是一块裸内存 ,传参时就退化成一个 int*,大小就丢了。
二、函数模板特化(Function Template Specialization)
2.1 什么时候需要特化
通用函数模板对绝大多数类型都对,但个别类型行为不对,需要单独处理。
cpp
template<class T>
bool Less(T left, T right) {
return left < right; // 按值比较
}
数值没问题。可当你传指针:
cpp
double* p1 = new double(1.1);
double* p2 = new double(2.2);
cout << Less(p1, p2); // 比较的是"指针的地址值",不是 1.1 < 2.2

T 被推导成 double*,left < right 比的是一堆地址,结果跟你想要的 "数值大小" 完全是两码事。

2.2 全特化写法
cpp
template<>
bool Less<double*>(double* left, double* right) {
return *left < *right; // 解引用再比
}
三个要点:
template<>表示 "全特化",所有类型都定死了;Less<double*>明确指定是给double*用的版本;- 函数签名必须和原模板匹配。
2.3 坑:参数是 const T& 时怎么特化
第二版函数模板改成了引用传参:
cpp
template<class T>
bool Less(const T& left, const T& right) {
return left < right;
}
这时候 T = double*,const T& 就变成了 double* const &------const 修饰的是指针本身(指针不能改),不是指向的数据。所以特化要这么写:
cpp
template<>
bool Less<double*>(double* const & left, double* const & right) {
return *left < *right;
}
这里极容易踩坑:很多人会写成 const double*&,那就变成 "指向 const 的指针引用",签名对不上,特化不生效,结果又退回到比地址。特化的签名必须和原模板推导出来的一模一样。
2.4 特化 or 重载?------ 进阶建议
函数模板特化在 C++ 里其实是个 "坑比较深" 的特性,业界更推荐两种替代:
- 重载(Overload) :写一个非模板的同名函数专门处理
double*,重载匹配优先级天然更高,更直观。if constexpr(C++17):在模板体内按类型分支,编译期裁剪,一行搞定,不用写多个版本。
结论:会读、能看懂特化 是基本功;自己写 时优先重载 /if constexpr。
三、类模板特化:全特化 vs 偏特化
3.1 全特化(Full Specialization)
cpp
template<class T1, class T2>
class Data { ... }; // 主模板
template<>
class Data<int, char> { ... }; // 两个类型全定死
只有 Data<int, char> 这一种组合走这个版本。
3.2 偏特化(Partial Specialization)
cpp
template<class T2>
class Data<int, T2> { ... }; // 只定死第一个,T2 留给用户
注意:函数模板不支持偏特化 (只有全特化),但类模板既支持全特化,也支持偏特化。这是两者最大的语法差异,也是高频考点。
3.3 匹配优先级
cpp
Data<int, char> d1; // 命中全特化 Data<int,char>
Data<int, double> d2; // 命中偏特化 Data<int,T2>
Data<float, double> d3; // 都不满足,走主模板
优先级:全特化 > 偏特化 > 主模板。编译器从 "最精确" 到 "最通用" 逐级匹配,命中即停。
四、模板分离式编译 ------ 那个 "未定义的引用" 到底为什么
4.1 现象
你把模板定义放在 FunC.cpp,头文件 FunC.h 只留声明,然后 main.cpp 里调用 Func(10)------ 编译全过,链接报错:

cpp
undefined reference to 'void Func<int>(const int&)'
4.2 根因(进阶的核心洞察)
普通函数是写出来就编译成代码 ;模板是用到哪个类型,才现场实例化成哪个。
编译单元是分开编译的:
- FunC.cpp 编译
Func<T>模板时,它根本不知道将来会被int实例化 ,所以不会生成任何Func<int>的实体代码;- main.cpp 调用
Func(10)时,头文件里只有声明没有定义,编译器只能生成一个 "待解析" 的调用引用;- 链接器在两边都找不到
Func<int>的符号 → 报 "未定义的引用"。
一句话:模板定义放 .cpp = 白白编译,链接时符号不存在。
4.3 三种解法
解法①:显式实例化(在 .cpp 里写清楚要哪些类型)
cpp
// FunC.cpp
template void Func<int>(const int&); // 显式实例化:现在就生成 int 版
这会让编译器在编译 FunC.cpp 时就产出 Func<int> 的符号,链接就能找到。
解法②:定义直接放头文件(最推荐,STL 全这么干)
cpp
// FunC.h
template<class T>
void Func(const T& i) { ... } // 定义 + 声明都在头文件
这样 main.cpp 一编译,就能看到完整定义并就地实例化,根本不需要链接去别处找符号。
解法③:约定用 .hpp / .inl,本质还是 "定义跟着头文件走",只是换个文件后缀区分。
底层结论:模板的 "定义" 最好和 "声明" 放在一起(头文件),或者在源文件里显式实例化。这是你以后读 STL、写模板库必须刻在脑子里的规则。

五、进阶地图
| 主题 | 一句话 |
|---|---|
if constexpr(C++17) |
编译期类型分支,替代大量特化 |
| 可变参数模板 | template<typename... Args> 处理不定长参数 |
模板实参推导与 auto/decltype(auto) |
返回值推导、转发引用 |
SFINAE / enable_if |
让模板 "只有满足条件才启用" |
| C++20 Concepts | 给模板参数加约束,报错更友好 |