前言
前面学习模板时,我们最先接触的是:
cpp
template<class T>
它解决的问题非常直接:
原本写死的数据类型,可以抽象成一个模板参数,让同一份代码适用于不同类型。
比如一个交换函数:
cpp
template<class T>
void Swap(T& x, T& y)
{
T tmp = x;
x = y;
y = tmp;
}
这里的 T 可以是:
cpp
int
double
char
从而避免针对不同类型重复写多份代码。
但是随着模板继续深入,仅仅会写:
cpp
template<class T>
还远远不够。
后面会遇到一些更加实际的问题:
cpp
如果数组大小也想作为模板参数怎么办?
如果通用模板面对某些特殊类型时逻辑不正确怎么办?
为什么普通函数可以把声明和定义分开写,而模板这样做却容易出现链接错误?
这几个问题分别对应:
非类型模板参数
模板特化
模板分离编译
它们也是模板从"会写"走向"真正理解"的重要内容。
一、非类型模板参数
我们之前写模板时:
cpp
template<class T>
这里的 T 代表的是一种类型。
例如:
cpp
template<class T>
class Box
{
private:
T _value;
};
使用:
cpp
Box<int> b1;
Box<double> b2;
这里:
cpp
T = int
T = double
所以这种模板参数叫做:
类型模板参数。
但是模板能不能不仅接收"类型",还接收一个具体的值?
当然可以。
假设我们现在想实现一个固定大小的数组:
cpp
template<class T>
class Array
{
private:
T _data[10];
};
这里的问题在于:
cpp
10
被直接写死了。
如果有时候想创建 5 个元素:
cpp
5
有时候想创建:
cpp
20
个元素,就不够灵活了。
于是可以把数组大小也变成模板参数:
cpp
template<class T, size_t N>
class Array
{
private:
T _data[N];
};
使用:
cpp
Array<int, 5> a1;
Array<double, 20> a2;
这里:
cpp
T
代表一种类型。
而:
N
代表一个具体的值。
这种参数就叫:
非类型模板参数。
我们可以稍微完善一下:
cpp
#include <iostream>
#include <cassert>
using namespace std;
template<class T, size_t N = 10>
class StaticArray
{
public:
T& operator[](size_t pos)
{
assert(pos < N);
return _data[pos];
}
const T& operator[](size_t pos) const
{
assert(pos < N);
return _data[pos];
}
size_t size() const
{
return N;
}
bool empty() const
{
return N == 0;
}
private:
T _data[N]{};
};
int main()
{
StaticArray<int, 5> a1;
StaticArray<double, 20> a2;
StaticArray<char> a3;
cout << a1.size() << endl;
cout << a2.size() << endl;
cout << a3.size() << endl;
return 0;
}
其中:
cpp
StaticArray<char> a3;
使用的是:
cpp
N = 10
这个默认值。
非类型参数的限制
非类型模板参数有一个很关键的要求:
它必须能够在编译阶段确定。
例如:
cpp
StaticArray<int, 10> a;
没有问题。
或者:
cpp
constexpr size_t N = 20;
StaticArray<int, N> a;
也可以。
因为:
cpp
10
20
在程序编译的时候就已经能够确定。
但是下面这样就不行:
cpp
size_t n;
cin >> n;
StaticArray<int, n> a;
因为:
cpp
n
要等程序运行以后,用户输入了数据才能知道具体是多少。
而模板实例化发生在:
cpp
编译阶段
编译器必须提前知道:
我要生成 StaticArray<int, 5>
还是 StaticArray<int, 20>
还是 StaticArray<int, 100>
所以理解非类型模板参数时,我觉得最重要的关键词其实是:
编译期确定。
非类型参数的应用
非类型参数并不只是拿来控制数组大小。
它更重要的意义是:
让一些原本属于运行逻辑的配置提前进入编译阶段。
例如:
固定大小数组
缓冲区长度
编译期配置
模板元编程
甚至可以利用模板完成一些编译期计算。
比如:
cpp
template<int N>
struct Fibonacci
{
static constexpr int value =
Fibonacci<N - 1>::value +
Fibonacci<N - 2>::value;
};
template<>
struct Fibonacci<0>
{
static constexpr int value = 0;
};
template<>
struct Fibonacci<1>
{
static constexpr int value = 1;
};
使用:
cpp
cout << Fibonacci<10>::value << endl;
结果:
cpp
55
这里:
cpp
Fibonacci<10>
中的:
cpp
10
直接参与了模板实例化。
这时候模板已经不仅仅是:
代码复用工具
还开始承担:
编译期计算
的作用。
二、模板特化
模板的特点本来就是:
一套代码处理多种类型。
但是有时候恰恰会遇到:
通用逻辑适合绝大多数类型,却不适合某一个特殊类型。
例如我们写:
cpp
template<class T>
bool Less(const T& x, const T& y)
{
return x < y;
}
比较:
cpp
Less(10, 20);
当然没有问题。
再定义一个日期类:
cpp
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{}
bool operator<(const Date& d) const
{
if (_year != d._year)
return _year < d._year;
if (_month != d._month)
return _month < d._month;
return _day < d._day;
}
private:
int _year;
int _month;
int _day;
};
于是:
cpp
Date d1(2026, 8, 20);
Date d2(2026, 8, 23);
cout << Less(d1, d2) << endl;
依然没有问题。
因为:
cpp
Date
自己已经重载:
cpp
operator<
但是现在变成:
cpp
Date* p1 = &d1;
Date* p2 = &d2;
cout << Less(p1, p2) << endl;
问题就来了。
此时:
cpp
T = Date*
所以模板真正执行的是:
cpp
return p1 < p2;
这里比较的是:
两个指针保存的地址大小。
而我们真正想比较的是:
p1 指向的 Date
和
p2 指向的 Date
谁更小。
这时候就需要:
模板特化。
可以简单理解为:
主模板
=
默认方案
模板特化
=
遇到某些特殊类型时使用的专门方案
所以模板特化并不是要破坏泛型。
恰恰相反:
它是在保留通用代码的同时,为少数特殊情况留出单独处理的入口。
三、全特化与偏特化
模板特化主要包括:
函数模板特化
类模板特化
而类模板特化还可以进一步分成:
全特化
偏特化
函数模板特化
继续使用前面的:
cpp
template<class T>
bool Less(T x, T y)
{
return x < y;
}
现在专门为:
cpp
Date*
写一个版本:
cpp
template<>
bool Less<Date*>(Date* x, Date* y)
{
return *x < *y;
}
这样:
cpp
Less(p1, p2);
遇到:
cpp
Date*
时,就会比较:
cpp
*x < *y
而不是比较指针地址。
函数模板特化有一个比较容易被忽略的地方:
特化版本的参数类型,要和主模板代入具体类型以后形成的参数类型对应起来。
这一点如果模板参数比较简单还不明显,但一旦模板参数中出现:
cpp
const T&
再碰上:
cpp
T = Date*
就很容易绕晕。
先来看主模板:
cpp
template<class T>
bool Less(const T& x, const T& y)
{
return x < y;
}
如果我们传入的是普通整数:
cpp
int a = 10;
int b = 20;
Less(a, b);
此时:
cpp
T = int
把 T 代入:
cpp
const T&
得到:
cpp
const int&
这个很好理解。
再比如:
cpp
Date d1;
Date d2;
Less(d1, d2);
此时:
cpp
T = Date
所以:
cpp
const T&
就变成:
cpp
const Date&
也没有什么问题。
真正容易绕的是:
cpp
Date* p1 = &d1;
Date* p2 = &d2;
Less(p1, p2);
这时候模板推导得到的是:
cpp
T = Date*
很多人看到主模板里的:
cpp
const T&
会下意识认为:
cpp
const + Date*
=
const Date*
于是觉得最终应该是:
cpp
const Date*&
其实并不是。
关键在于:
不能把
const T&拆开以后凭感觉重新组合,而应该把T当成一个完整类型进行替换。
现在:
T = Date*
那么先看:
const T
把完整的 Date* 替换进去:
cpp
const (Date*)
它真正表示的是:
cpp
Date* const
也就是:
一个 const 指针,指向 Date。
然后外面还有一个:
cpp
&
所以最终:
cpp
const T&
变成:
cpp
Date* const&
整个替换过程可以写成:
cpp
T
↓
Date*
const T
↓
Date* const
const T&
↓
Date* const&
Date* const& 到底是什么意思?
这一串:
cpp
Date* const&
可以从里面往外看。
先看:
cpp
Date*
表示:
指向 Date 对象的指针。
然后:
cpp
Date* const
表示:
const 指针,也就是这个指针本身不能被修改。
最后:
cpp
Date* const&
表示:
对一个 const 指针的引用。
可以这样理解:
cpp
Date d1;
Date d2;
Date* p = &d1;
Date* const& ref = p;
通过 ref,我们不能让这个指针改去指向其他对象:
cpp
ref = &d2; // 不允许
但是:
cpp
*ref
所指向的 Date 对象本身并没有因为这里的 const 而变成 const。
所以仍然要区分下面几个类型:
cpp
Date*
↓
普通指针,指向普通 Date
const Date*
↓
普通指针,指向 const Date
Date* const
↓
const 指针,指向普通 Date
const Date* const
↓
const 指针,指向 const Date
这里模板中得到的是:
cpp
Date* const&
而不是:
cpp
const Date*&
这两个类型的含义完全不同。
为什么这一点会影响函数模板特化?
现在重新看主模板:
cpp
template<class T>
bool Less(const T& x, const T& y)
{
return x < y;
}
当:
cpp
T = Date*
以后,编译器看到的这个模板实例可以近似理解成:
cpp
bool Less(
Date* const& x,
Date* const& y)
{
return x < y;
}
问题是:
cpp
x < y
比较的依然是:
两个 Date* 保存的地址
而不是 Date 对象本身。
所以我们想给:
Date*
提供一个特化版本。
这时候就不能随便写成:
cpp
template<>
bool Less<Date*>(Date* x, Date* y)
{
return *x < *y;
}
如果主模板使用的是:
cpp
const T&
那么更严格地对应主模板实例化后的形参类型,应该写成:
cpp
template<>
bool Less<Date*>(
Date* const& x,
Date* const& y)
{
return *x < *y;
}
这样才能清楚地看到:
cpp
主模板:
template<class T>
bool Less(const T&, const T&);
T = Date*
↓
实际参数类型:
Date* const&
Date* const&
↓
特化版本:
template<>
bool Less<Date*>(
Date* const&,
Date* const&);
这就是为什么函数模板特化的参数匹配会比普通函数严格一些。
为什么容易把 Date* const& 和 const Date*& 搞混?
这两个写法长得非常像:
cpp
Date* const&
和:
cpp
const Date*&
但是含义不同。
第一种:
cpp
Date* const&
核心是:
cpp
Date* const
所以 const 修饰的是:
指针本身。
可以理解成:
指针不能改
指向的 Date 可以改
而第二种:
cpp
const Date*&
核心是:
cpp
const Date*
所以 const 修饰的是:
指针指向的 Date 对象。
可以理解成:
指针本身可以改变
但是不能通过它修改 Date
简单对比:
cpp
Date* const&
↑
const修饰指针
const Date*&
↑
const修饰Date
一个比较实用的判断方法
以后看到:
const T&
不要马上判断 const 最终修饰谁。
先做一件事情:
把 T 整体替换进去。
例如:
cpp
T = int
得到:
cpp
const int&
cpp
T = Date
得到:
cpp
const Date&
cpp
T = Date*
先替换:
cpp
const (Date*)&
再整理:
cpp
Date* const&
这样就不容易出错。
可以记住一个非常重要的模板思维:
模板替换不是字符串替换,而是类型替换。T 本身可能已经是一个完整的复合类型。
实际开发中一定要使用函数模板特化吗?
也不一定。
比如我们的需求只是:
普通类型
↓
直接使用 <
Date*
↓
解引用以后再使用 <
其实直接写一个普通函数重载:
cpp
bool Less(
Date* const& x,
Date* const& y)
{
return *x < *y;
}
有时候反而更加直观。
于是代码里同时存在:
cpp
template<class T>
bool Less(const T& x, const T& y)
{
return x < y;
}
以及:
cpp
bool Less(
Date* const& x,
Date* const& y)
{
return *x < *y;
}
调用:
cpp
Less(p1, p2);
时,编译器可以选择更加匹配的普通函数重载。
因此在工程实践中:
函数模板特化
和:
函数重载
都可能解决特殊类型问题。
如果只是少量特殊类型,普通函数重载往往更加直观;
而类模板因为不存在完全对应于函数重载的机制,所以:
全特化
偏特化
在类模板设计中会更加常见。
这一点也是函数模板和类模板在"特殊类型处理"上的一个重要区别。
类模板全特化
例如先写一个通用模板:
cpp
template<class T1, class T2>
class TypeInfo
{
public:
TypeInfo()
{
cout << "通用模板" << endl;
}
};
现在希望:
cpp
TypeInfo<int, char>
使用一套特殊逻辑。
就可以写:
cpp
template<>
class TypeInfo<int, char>
{
public:
TypeInfo()
{
cout << "int + char 专用版本" << endl;
}
};
这里:
cpp
T1 → int
T2 → char
所有模板参数都已经完全确定。
因此叫:
全特化。
例如:
cpp
TypeInfo<int, double> t1;
TypeInfo<int, char> t2;
第一个:
没有完全匹配的特化版本
↓
使用主模板
第二个:
完全匹配 TypeInfo<int,char>
↓
使用全特化
类模板偏特化
偏特化特别容易被误解成:
只确定一半参数。
其实并不准确。
偏特化真正的含义是:
在主模板的基础上进一步限制模板参数的匹配范围。
例如:
cpp
template<class T1>
class TypeInfo<T1, int>
{
public:
TypeInfo()
{
cout << "第二个参数为int" << endl;
}
};
这里:
T2 必须是 int
T1 仍然可以变化
所以:
cpp
TypeInfo<double, int>
TypeInfo<char, int>
TypeInfo<string, int>
都能匹配这个版本。
偏特化还可以限制参数必须具有某种结构。
例如:
cpp
template<class T1, class T2>
class TypeInfo<T1*, T2*>
{
public:
TypeInfo()
{
cout << "指针类型偏特化" << endl;
}
};
那么:
cpp
TypeInfo<int*, double*>
就会进入这个版本。
同样还可以针对引用:
cpp
template<class T1, class T2>
class TypeInfo<T1&, T2&>
{
};
甚至进一步限制:
cpp
template<class T1, class T2>
class TypeInfo<const T1*, const T2*>
{
};
因此我觉得偏特化最好的理解方式是:
主模板
↓
什么类型都允许
偏特化
↓
增加限制条件
全特化
↓
直接确定成具体类型
特化版本如何匹配?
假设有两个偏特化:
cpp
template<class T1, class T2>
class Data<T1*, T2*>
{
};
以及:
cpp
template<class T2>
class Data<int*, T2*>
{
};
现在创建:
Data<int*, double*> d;
两个版本都能够匹配。
但是:
cpp
Data<int*, T2*>
比:
cpp
Data<T1*, T2*>
限制更严格、更具体。
所以编译器会优先选择:
cpp
Data<int*, T2*>
可以大致理解成:
能匹配的模板很多
↓
寻找约束更具体的版本
↓
使用更具体的特化
这也是模板编程很有意思的一点:
很多类型选择并不是等程序运行以后通过
if判断,而是在编译阶段就已经确定。
四、模板特化的应用
模板特化如果只用来:
cpp
cout << "我是特化";
其实看不出它真正的价值。
更典型的场景,是和泛型算法结合。
例如设计一个比较器:
cpp
template<class T>
struct MyLess
{
bool operator()(const T& x, const T& y) const
{
return x < y;
}
};
如果:
cpp
vector<Date> dates;
可以:
sort(
dates.begin(),
dates.end(),
MyLess<Date>()
);
因为 Date 已经支持:
operator<
所以没问题。
但是如果:
vector<Date*> dates;
默认:
MyLess<Date*>
执行:
x < y
就又变成比较指针地址。
此时可以写:
cpp
template<>
struct MyLess<Date*>
{
bool operator()(Date* x, Date* y) const
{
return *x < *y;
}
};
然后:
cpp
sort(
dates.begin(),
dates.end(),
MyLess<Date*>()
);
这样真正比较的就是:
Date 对象内容
而不是:
Date* 地址
这个例子能很好体现模板特化的意义:
sort算法
不变
↓
比较器使用模板
↓
普通类型
使用通用版本
特殊类型
使用特化版本
所以:
模板特化的价值在于让泛型框架保持不变,同时允许特殊类型改变局部行为。
这也是 STL 里面很多:
仿函数
traits
allocator
模板特化
设计的重要思想。
五、模板分离编译
这一部分是模板学习里面非常经典的一个坑。
普通函数经常这样组织:
Add.h
cpp
int Add(int x, int y);
Add.cpp
cpp
#include "Add.h"
int Add(int x, int y)
{
return x + y;
}
main.cpp
cpp
#include "Add.h"
int main()
{
Add(1, 2);
}
没有问题。
整个过程大概是:
cpp
Add.cpp
↓
编译
↓
Add.obj
里面有真正的Add函数
main.cpp
↓
编译
↓
main.obj
里面记录"我要调用Add"
最后链接
↓
找到Add
↓
成功
但是模板就不一样。
例如:
Add.h
cpp
template<class T>
T Add(const T& x, const T& y);
Add.cpp
cpp
#include "Add.h"
template<class T>
T Add(const T& x, const T& y)
{
return x + y;
}
main.cpp
cpp
#include "Add.h"
int main()
{
Add(1, 2);
Add(1.1, 2.2);
}
看起来也没有任何问题。
但是最后可能出现:
链接错误
为什么?
模板什么时候生成代码?
模板本身并不是最终函数。
例如:
cpp
template<class T>
T Add(T x, T y)
编译器必须等到:
cpp
Add(1, 2);
才知道:
cpp
T = int
↓
生成 Add<int>
看到:
cpp
Add(1.1, 2.2);
才知道:
cpp
T = double
↓
生成 Add<double>
也就是说:
模板通常是在具体使用以后才根据实际类型进行实例化。
现在再看 Add.cpp。
编译它的时候:
编译器看到模板定义
但是:
不知道未来到底需要
Add<int>
还是
Add<double>
还是
Add<string>
所以不会把所有可能类型全部生成出来。
而编译:
main.cpp
的时候:
看到了 Add(1,2)
↓
知道需要 Add<int>
看到了 Add(1.1,2.2)
↓
知道需要 Add<double>
但是 main.cpp 通过头文件只看见:
cpp
template<class T>
T Add(const T&, const T&);
只有声明,没有完整实现。
最终就会出现:
Add.cpp:
有定义
但不知道要生成什么
main.cpp:
知道要生成什么
但看不到定义
链接器:
找不到最终 Add<int> / Add<double>
于是报:
无法解析的外部符号
所以模板分离编译问题真正的本质是:
需要实例化的地方看不到模板定义,看得到模板定义的地方又不知道应该实例化哪些类型。
解决方法一:定义放到头文件
最常见的做法是:
Add.h
cpp
#pragma once
template<class T>
T Add(const T& x, const T& y)
{
return x + y;
}
然后:
cpp
#include "Add.h"
int main()
{
Add(1, 2);
Add(1.1, 2.2);
}
此时:
main.cpp
↓
看到 Add<int> 调用
+
看到完整模板定义
↓
可以直接实例化
所以很多模板库的实现都会直接放在:
cpp
.h
.hpp
文件中。
解决方法二:显式实例化
如果项目中明确只需要几种类型,可以主动告诉编译器:
cpp
template int Add<int>(
const int&,
const int&
);
template double Add<double>(
const double&,
const double&
);
意思就是:
cpp
请明确生成:
Add<int>
Add<double>
这样对应代码就可以进入 .obj。
但是它的问题也很明显。
以后突然需要:
Add<long>
就必须继续补:
template long Add<long>(...);
所以:
类型数量很少而且固定
↓
可以考虑显式实例化
通用模板
↓
通常让完整定义在头文件中可见
更加自然。
六、模板的优缺点
模板最大的优点当然是:
代码复用能力强。
例如原本:
cpp
int Add(int, int);
double Add(double, double);
可以变成:
cpp
template<class T>
T Add(T x, T y);
模板还能够实现:
泛型编程。
通过:
类型参数
非类型参数
特化
偏特化
一套框架就能够适应多种不同类型。
STL 中大量:
vector
list
map
set
sort
priority_queue
本质上都离不开模板。
另外,大量类型选择发生在:
编译阶段
所以很多抽象可以在程序真正运行之前就确定下来。
但模板也不是完全没有代价。
代码膨胀
例如:
cpp
Container<int>
Container<double>
Container<string>
可能分别生成不同的模板实例。
类型越多:
生成的机器代码
也可能越多
于是可能导致:
代码膨胀
编译时间增加
模板实例化主要发生在:
编译阶段
模板越复杂:
嵌套越深
依赖越多
编译器需要做的工作也越多。
所以大型模板项目的编译速度可能明显变慢。
报错信息复杂
模板错误还有一个非常经典的问题:
错误信息特别长
比如我们可能只是一个类型没写对。
结果编译器把:
模板实例
STL容器
迭代器
仿函数
算法
类型推导
整条调用链全部打印出来。
这也是初学模板时最容易让人头疼的地方。
static_assert
为了让模板错误更早、更明确地暴露,可以使用:
cpp
static_assert
例如:
cpp
#include <type_traits>
template<class T>
class NumberBox
{
static_assert(
std::is_integral<T>::value,
"T must be an integral type"
);
};
这样:
cpp
NumberBox<int> a;
没有问题。
而:
cpp
NumberBox<double> b;
会直接在编译阶段提示:
T must be an integral type
这比让错误一路传播到很深的模板实例化以后才报出来,要容易定位得多。
总结
这一节虽然包含:
非类型模板参数
模板特化
全特化
偏特化
模板实例化
分离编译
显式实例化
很多概念,但实际上可以沿着一条很清晰的逻辑理解。
最开始:
template<class T>
解决的是:
类型能不能变成参数?
接着:
template<class T, size_t N>
解决:
编译期的值能不能也成为模板参数?
然后出现:
模板特化
解决:
通用模板无法正确处理特殊类型怎么办?
再进一步:
全特化
偏特化
解决:
不同范围、不同类型结构应该如何选择不同实现?
最后:
模板分离编译
让我们开始理解:
模板到底什么时候才真正生成代码?
整条知识线可以整理成:
模板初阶
↓
类型参数化
↓
非类型模板参数
↓
编译期值参数化
↓
模板特化
↓
全特化 / 偏特化
↓
模板实例化
↓
模板分离编译
↓
.hpp / 显式实例化
↓
模板工程实践
学到这里以后,我觉得模板真正值得掌握的已经不是单纯:
template<class T>
这一行语法。
而是开始思考:
哪些东西应该在编译期确定?
哪些类型可以共用一套逻辑?
哪些类型需要单独处理?
编译器到底什么时候生成模板代码?
泛型带来的复用性和复杂度应该怎么平衡?
模板真正强大的地方,也正在这里。
它不仅解决"少写重复代码"的问题,更是在逐渐建立一种:
让编译器根据类型和编译期参数自动选择、生成合适实现的能力。
后面继续学习 STL、仿函数、类型萃取以及更加复杂的泛型编程时,这一节关于模板特化和实例化的知识还会不断被用到。