0.1 概述&序言
这里是白杨。上一篇把 priority_queue 从用法一路挖到仿函数和指针偏特化,结尾卖了个关子:偏特化还有别的玩法。这一篇就是兑现------它也是 C++ 入门篇的最后一讲:模板进阶。
要过四件事:
- 非类型模板参数:
array<T, N>里的 N 是什么东西 - 模板特化全家桶:函数模板特化、类模板全特化、偏特化
- 特化实战:给
vector<Date*>排序(指针比地址的坑,在这里给完整解法) - 分离编译:一个"编译全通过、链接才翻脸"的经典现场,附带真实报错原文
老规矩,跑得通的结果才写进来(环境见下);完整代码文末自取,也可以直接去仓库:gitee.com/BaiYang_SQ/template-advanced-sim。
让我们开始吧。
一、非类型模板参数:把常量当参数
模板参数其实分两类:
- 类型形参 :跟在
class/typename后面的,传的是"一个类型",比如T; - 非类型形参 :传的是"一个编译期常量",比如
size_t N------它在模板里可以直接当常量用。
长得最典型的例子就是自己造一个 array:
cpp
namespace bite
{
template <class T, size_t N = 10>
class array
{
public:
array() : _size(N) {} // 原版示例没给 _size 赋值,这里补上让它能跑
T& operator[](size_t index) { return _array[index]; }
const T& operator[](size_t index) const { return _array[index]; }
size_t size() const { return _size; }
bool empty() const { return 0 == _size; }
private:
T _array[N]; // N 直接拿来当数组长度------妙就妙在这
size_t _size;
};
}

N 妙在哪:它是常量,所以能直接下数组长度 T _array[N];而数组长度必须是编译期常量------两边严丝合缝。实例化时:
cpp
bite::array<int, 5> a5; // N = 5,底层 int _array[5]
bite::array<int> a10; // N 走默认值 10
实测输出:
text
array<int, 5> size=5 data: 0 10 20 30 40
array<int> size=10(默认参数 N=10)
【必背】非类型模板参数两条红线:
- 浮点数、类对象、字符串不能当非类型模板参数 ------
array<int, 3.14>直接编译不过; - 它必须是编译期就能确定 的常量,不是运行时变量。

二、特化的动机:一个 Less 的翻车现场
模板对大多数类型都好使,遇到指针就等着翻车。看一个专门比大小的函数模板:
cpp
template <class T>
bool LessFunc(T left, T right)
{
return left < right;
}
int 比、Date 对象比,都正常;但传两个 Date* 进去:
cpp
Date arr[2] = {Date(2022, 7, 8), Date(2022, 7, 7)};
Date* p1 = &arr[0]; // 日期更大,地址更低
Date* p2 = &arr[1]; // 日期更小,地址更高
cout << LessFunc(p1, p2) << endl;
它比的是 p1 < p2------两个指针比大小,比的是地址,跟日期一点关系没有。实测输出(配合对比):
text
LessFunc(1, 2) = 1(内置类型,正确)
LessFunc(d1, d2) = 1(日期对象,正确)
LessFunc(p1, p2) = 0(特化后比对象:7-8 < 7-7 不成立,正确)
std::less<Date*>() = 1(未特化:比地址,错误)
后两行值得停一下:p1 指向的是 7-8,p2 指向 7-7,按日期 7-8 不该"小于"7-7;可按地址,低地址的 p1 反而"小"了。第三行是修好之后的结果,第四行是没修的样子。
模板本身没写错,错的是"通用逻辑对特殊类型不成立" ------这时候就需要特化:在原模板的基础上,给特殊类型单独开小灶。
三、函数模板特化(4 步)
给 LessFunc 开小灶,就 4 步:
- 先有一个基础的函数模板;
template后面跟一对空的尖括号<>;- 函数名后跟尖括号,写明要特殊处理的类型:
LessFunc<Date*>; - 形参类型必须和基础模板完全一致。
cpp
template <>
bool LessFunc<Date*>(Date* left, Date* right)
{
return *left < *right; // 指针版本:比指向的对象
}
改完之后,LessFunc(p1, p2) 就会走特化版本,输出 0,正确。
【易错】第 4 步是坑王:形参写得不完全一致(比如基础模板是 T,特化里写 const Date*),不同编译器给报错乱七八糟的,查起来很折磨人。
【面试】函数模板特化能不能不用?其实工程上更推荐直接写一个普通函数:
cpp
bool LessFunc(Date* left, Date* right) { return *left < *right; }
简单、直观、可读性好。函数模板特化更多是"知道它有、面试会问",真到动手写代码的时候,普通函数重载往往更省心。
四、类模板特化:全特化与偏特化
类模板的特化分两层:
- 全特化 :模板参数列表里的参数全部确定;
- 偏特化 :一部分参数确定,或者对参数做更进一步的条件限制(指针、引用就是典型)。
来看一个经典例子------Data 家族。主模板长这样:
cpp
template <class T1, class T2>
class Data
{
public:
Data() { cout << "Data<T1, T2>(主模板)" << endl; }
private:
T1 _d1;
T2 _d2;
};
真正的功夫在下面几种"开小灶"版本:
cpp
// 全特化:两个参数都定死
template <>
class Data<int, char>
{
public:
Data() { cout << "Data<int, char>(全特化)" << endl; }
private:
int _d1;
char _d2;
};
// 偏特化一:第二个参数定死为 int
template <class T1>
class Data<T1, int>
{
public:
Data() { cout << "Data<T1, int>(偏特化)" << endl; }
private:
T1 _d1;
int _d2;
};
// 偏特化二:条件限制------两个都是指针
template <typename T1, typename T2>
class Data<T1*, T2*>
{
public:
Data() { cout << "Data<T1*, T2*>(指针偏特化)" << endl; }
private:
T1 _d1;
T2 _d2;
};
// 偏特化三:两个都是引用
template <typename T1, typename T2>
class Data<T1&, T2&>
{
public:
Data(const T1& d1, const T2& d2)
: _d1(d1), _d2(d2)
{
cout << "Data<T1&, T2&>(引用偏特化)" << endl;
}
private:
const T1& _d1;
const T2& _d2;
};

挨个实例化,看编译器"点名"了谁:
text
Data<T1, int>(偏特化) ← Data<int, int>
Data<int, char>(全特化) ← Data<int, char>
Data<T1, int>(偏特化) ← Data<double, int>
Data<T1, T2>(主模板) ← Data<int, double>(都没命中)
Data<T1*, T2*>(指针偏特化) ← Data<int*, int*>
Data<T1&, T2&>(引用偏特化) ← Data<int&, int&>
如图调试,分别调用结果:

【必背】命中优先级:全特化 > 偏特化 > 主模板。编译器拿你给的类型去家族里找"最贴合"的那一版,找不到才退回主模板。
另外注意一个细节:偏特化不一定是"参数写死一半",像 Data<T1*, T2*> 这种"要求两个参数都是指针"的条件限制,同样算偏特化------这也是"偏"字的另一层含义。
五、实战:给 vector<Date*> 排序
把上面的功力用在一个真实场景里。先写一个仿函数类模板:
cpp
template <class T>
struct Less
{
bool operator()(const T& x, const T& y) const { return x < y; }
};
vector<Date> 排序一切正常;但如果是 vector<Date*>,Less<Date*> 里的 x < y 又变成了比地址。十七篇我们已经踩过一次这个坑(当时用的偏特化),这次给全特化解法------直接点名 Date*:
cpp
// 全特化:Date* 改为比较指向的日期对象
template <>
struct Less<Date*>
{
bool operator()(Date* x, Date* y) const { return *x < *y; }
};
实测对照(指针故意乱序入队):
text
[vector<Date> 排序] 2022-7-6 2022-7-7 2022-7-8
[按地址排序 ] 2022-7-7 2022-7-6 2022-7-8
[Less<Date*> 特化] 2022-7-6 2022-7-7 2022-7-8
第二行是"按地址排序"的样子:7-6 排在 7-7 后面,一眼就能看出不是日期顺序;第三行是特化之后的正确结果。
【面试】全特化和偏特化怎么选?只治"某一种具体类型"(Date*)用全特化;要治"一类类型"(所有指针 T*)用偏特化------十七篇的 Less<T*> 就是后者,一次生效、永不遗漏。两把刀各有各的场合。
六、分离编译:编译全通过,链接才翻脸
最后一个大坑,也是最经典的"新手见鬼现场"。
先说正常流程:一个 C++ 程序由多个源文件组成,每个源文件单独编译 成目标文件(.o / .obj),最后链接成一个可执行文件。这套机制叫分离编译。
那模板参与分离编译会怎样?把声明和定义分家试试:
cpp
// ===== a.h:只有声明 =====
template <class T>
T Add(const T& left, const T& right);
// ===== a.cpp:定义在这里 =====
template <class T>
T Add(const T& left, const T& right)
{
return left + right;
}
// ===== main.cpp:正常调用 =====
#include "a.h"
int main()
{
Add(1, 2); // 需要 Add<int>
Add(1.0, 2.0); // 需要 Add<double>
return 0;
}
单看每一处都挑不出毛病:声明有、定义有、调用有。但一编译------
text
[编译 a.cpp] 通过
[编译 main.cpp] 通过
[链接] undefined reference to `int Add<int>(int const&, int const&)'
undefined reference to `double Add<double>(double const&, double const&)'
collect2.exe: error: ld returned 1 exit status
(上面是 g++ 的真实输出;MSVC 下的等价信息是 error LNK2019: 无法解析的外部符号。)

为什么编译都过了,链接却找不到? 把三个环节拆开看:
- 头文件根本不参与单独编译,它只是被"包含";
a.cpp里只有模板的定义,但没有任何调用 ------模板是"按需实例化"的,没看到Add<int>的调用,就不会生成Add<int>的具体函数;main.cpp编译出来的main.o里,Add<int>的位置留了个空地址,就等着链接时去别处贴;结果翻遍所有目标文件都没这个函数------undefined reference。
【必背】模板的声明和定义不能随便分家。两条修法:
- (推荐)声明和定义都放进头文件(.h / .hpp 都行),谁包含谁实例化,一劳永逸;
- 显式实例化:定义留在 .cpp,末尾加
cpp
template int Add<int>(const int&, const int&);
template double Add<double>(const double&, const double&);
强制生成指定版本------能用,但每多一种调用类型就要补一行,工程上不实用,不推荐。
修复后两套方案都能跑,实测输出相同(3、3、11)。仓库里的 split/ 目录就是这三套代码:坏例、修复例、显式实例化,output.txt 里录了完整的链接错误原文。
七、模板总结 & 收官盘点
模板这套机制,优点和坑都鲜明:
| 优点 | 缺陷 |
|---|---|
| 代码复用:一份模板适配所有类型 | 代码膨胀:每种实例化都生成一份 |
| 泛型编程的基石,STL 因此诞生 | 编译时间变长 |
| 灵活性:容器/算法/仿函数随便组合 | 报错信息非常乱,定位靠经验 |
从第一篇的基础语法,到 string / vector / list / stack / queue 的用法与手写,再到最后的模板进阶------十一篇下来,C++ 入门篇这套主线到这里就算走完了。回头看,模板和"实例化"三个字贯穿了后半程:容器是模板、仿函数是特化、连链接错误都是"没实例化"惹的祸。
【一句话记】模板的坑,十有八九栽在"实例化"上:什么时候实例化、在哪实例化、有没有实例化。
八、速记卡
考前扫一眼,面试不卡壳:

九、自测三连
先自己默答,再看答案对照:
- 非类型模板参数是什么?有什么限制?
答案
用编译期常量当模板参数,比如 template <class T, size_t N> 里的 N,可以拿来做数组长度。限制:浮点数、类对象、字符串不能当非类型参数;且必须在编译期确定。
- 特化的命中优先级是什么?全特化和偏特化的区别?
答案
优先级:全特化 > 偏特化 > 主模板。全特化把所有模板参数都确定(Data<int, char>);偏特化是部分参数确定(Data<T1, int>)或对参数做条件限制(Data<T1*, T2*>、Data<T1&, T2&>)。
- 模板的分离编译为什么会链接报错?两条修复路径是什么?
答案
模板按需实例化:a.cpp 只有定义没有调用,不会生成具体函数;main.o 里对 Add 只留了空地址,链接时找不到。修复:①(推荐)声明和定义都放头文件;② 显式实例化 template int Add<int>(...)(不实用)。
附录 A:完整代码仓库
完整工程(单文件 + split 分离编译演示)在 Gitee:
gitee.com/BaiYang_SQ/template-advanced-sim。
| 仓库文件 | 说明 |
|---|---|
template_advanced_sim.cpp |
单文件:全部实验;章节 ↔ 函数名对照见仓库 README 导览表 |
output.txt |
全部实验的真实运行输出(含链接错误原文,不用装编译器) |
build.bat / build.ps1 |
Windows 一键编译 + 运行 + 刷新 output.txt(含 split 演示) |
split/ |
分离编译三套代码:坏例 / 修复例 / 显式实例化 |
面试前手写练手,下面这版把四个考点压在一起(完整版见仓库):
cpp
// 模板进阶·考点骨架(完整可跑版见 Gitee 仓库)
#include <iostream>
#include <cstddef>
using namespace std;
// ① 非类型模板参数:N 是编译期常量
namespace bite
{
template <class T, size_t N = 10>
class array
{
public:
size_t size() const { return N; }
T& operator[](size_t i) { return _a[i]; }
private:
T _a[N];
};
}
// ② 函数模板特化:4 步里的关键------template <> + 函数名后写明类型 + 形参一致
struct Date { int y, m, d; };
template <class T>
bool LessFunc(T l, T r) { return l < r; }
template <>
bool LessFunc<Date*>(Date* l, Date* r)
{
return (l->y < r->y) || (l->y == r->y && l->m < r->m) ||
(l->y == r->y && l->m == r->m && l->d < r->d);
}
// ③ 类模板全特化 / 偏特化
template <class T1, class T2>
class Data { public: Data() { cout << "Data<T1, T2>" << endl; } };
template <>
class Data<int, char> { public: Data() { cout << "Data<int, char>" << endl; } };
template <class T1>
class Data<T1*, T1*> { public: Data() { cout << "Data<T1*, T1*>" << endl; } };
int main()
{
bite::array<int, 5> a;
cout << a.size() << endl; // 5
Date d1{2022, 7, 8}, d2{2022, 7, 7};
cout << LessFunc(&d1, &d2) << endl; // 0(按日期,正确)
Data<int, char> x; // Data<int, char>
Data<int*, int*> y; // Data<T1*, T1*>
return 0;
}
好了,C++ 入门篇这套连载到这里正式收官!下一篇开启新篇章------C++ 进阶篇 ,第一站:继承 (预告:继承权限与赋值兼容、同名隐藏、构造析构的调用顺序、菱形继承与虚继承)。如果这套复习笔记对你有帮助,不要忘记点赞三连一波哦,我是白杨,我们下期见。