📌 创作初心:在构建个人知识体系的同时,希望帮助更多正在学习 C++ 模板机制的同学

📚 系列专栏柯一梦的STL进阶之路

🌐 个人主页
👉 Gitee
👉 GitHub

💡 座右铭:功不唐捐,玉汝于成

🎯 本文目标

  • 理解非类型模板参数的设计意义与使用方式
  • 掌握函数模板与类模板的特化机制(全特化 / 偏特化)
  • 理解 const、引用与指针在模板推导中的作用
  • 搞清模板分离编译的本质原因与解决方案
  • 建立对 STL “header-only 设计”的底层认知

🧠 阅读建议:本文涉及模板底层机制与编译原理,建议先掌握函数模板基础、类模板基础以及 C++ 编译流程(预处理 / 编译 / 链接)
模版支持:泛型编程

非类型模版参数

模版参数分为类型参数和非类型参数

类型形参:出现在模版的参数列表中,跟在class或者typename的关键字之后的参数类型名称
非类型形参:用一个常量作为类(函数)模版的一个参数,在类(函数)模版中,可以将该参数当作常量来使用

我们用静态栈来举一个例子,说明一下我们为什么要学习非类型模版参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define n 100
namespace rxj
{
/*template<class T, size_t n = 100>*/
template<class T>
class arry
{
public:

private:
T a[n];
size_t size;
};
}

int main()
{
rxj::arry<int> a1;
rxj::arry<int> a2;//如果a2我想开200个空间就没办法了
}

==这段代码就是我们学习c语言的时候写的静态数组,但是它的弊端是我们只能创建大小是100的数组==

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace rxj
{
template<class T, size_t n = 100>
/*template<class T>*/
class arry
{
public:

private:
T a[n];
size_t size;
};
}
  1. 对于静态栈,我们使用宏来控制一个对象的数组大小,使用类型模版控制数组成员类型。不同对象可以存入不同类型数据,==但是只能分配相同的大小空间
  2. 类型参数的话,函数模版可以传对象,类模版只能传类型或者常量(常量:非类型模版参数)
  3. 模版参数可以给缺省值,全缺省值我们要用Stack<> s1去实例化对象
  4. 非类型模版参数的应用:一个容器:静态数组arry。普通数组没法查出来你有没有非法访问,但是静态数组可以。
    ==注意==

    1.非类型模版参数只支持整型家族
    2.浮点数、类对象以及字符串是不被允许作为非类型模版参数的


模版的特化

==特化的概念==:通常情况下,使用模版可以实现一些与==类型==无关的代码,但是对于一些特殊类型的,可能会得到一些错误的结果。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<class T>
bool Less(T left, T right)
{
return left < right;
}

int main()
{
int x = 2;
int y = 4;
cout << Less(x, y) << endl;
double a = 1.2;
double b = 2.4;
cout << Less(a, b) << endl;
double* c = new double(2.2);
double* d = new double(3.3);
cout << Less(c, d) << endl;
return 0;
}

从还是那个面那个例子可以看出,函数模版可以解决大多数例子,但是针对特殊场景就得到错误的结果。此时,我们就需要对模版进行特化。即:在原模版类的基础上,针对特殊类型实现特殊化的实现方式。模版特化分为函数模版特化和类模版特化

函数模版特化

函数模版特化的步骤:

  1. 必须要有一个基础的函数模版
  2. 关键字template后面接一对空的尖括号<>
  3. 函数名后面跟一对尖括号 ,尖括号中指定需要特化的类型
  4. 函数形参列表:必须要和函数模版基础参数类型完全相同
  5. ==注意==我们在写模版函数的时候:内置类型值传参和引用传参代价差别不大;而自定义类型就很大。既然我们不知道是什么类型,所以我们要引用传参,既然是引用传参,就有改变的风险,所以我们要加const
    我们现在对上面的那个例子进行一下优化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<class T>
bool Less(T left, T right)
{
return left < right;
}
template<>
bool Less<double*>(double* left, double* right)
{
return *left < *right;
}

int main()
{
int x = 2;
int y = 4;
cout << Less(x, y) << endl;
double a = 1.2;
double b = 2.4;
cout << Less(a, b) << endl;
double* c = new double(2.2);
double* d = new double(3.3);
cout << Less(c, d) << endl;
return 0;
}

但是这段代码有一个问题,针对我们的第五条规则,我们应该对参数进行引用和const修饰,但同样的我们的模版特化也应该保持一致。但问题是如何保持一致呢?首先我们要知道const T& left中的const修饰的是谁,const修饰的是left本身
对于const与指针之间的关系:这三种写法都是正确的
三种写法

听我讲解为什么这个可以这样使用:主模版的const修饰的是left引用的T类型的对象不能修改,我们传入的是double*,翻译过来也就是我们引用的那个double* 对象不能修改,而不是说指针指向的对象的值不能修改,所以const就应该在* 和变量之间,修饰指针本身,&在类型后面就行
所以,正确写法应该是下面这段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<class T>
bool Less(const T& left, const T& right)
{
return left < right;
}

template<>
bool Less<double*>(double* const & left, double* const & right)
{
return *left < *right;
}

int main()
{
int x = 2;
int y = 4;
cout << Less(x, y) << endl;
double a = 1.2;
double b = 2.4;
cout << Less(a, b) << endl;
double* c = new double(2.2);
double* d = new double(3.3);
cout << Less(c, d) << endl;
return 0;
}

但其实我们可以不使用函数模版特化,可以使用函数重载。因为函数模版和同名函数是可以同时存在的。可以构成重载

  1. 对于类模版来说:有全特化和偏特化。成员变量可以不写 (类里面是随便写的,相当于写了一个全新的类,和原本的类没有关系)

类模版的特化

全特化

全特化就是将模版参数列表中的所有参数都确定化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
template<class T1,class T2>
class Data
{
public:
Data()
{
cout << "Data<T1,T2>" << endl;
}
private:
T1 _d1;
T2 _d2;
};


template<>
class Data<int, char>
{
public:
Data()
{
cout << "Data<int, char>" << endl;
}
private:
int _d1;
char _d2;
};
int main()
{
Data<int,int>d1;
Data<int,char>d2;
return 0;
}

==注意==:

1.首先要有一个模版类
2.在进行特化的时候,把需要特化的参数从模版参数列表中除去,然后将其写在类名后面,并且将类中之前涉及到的模版参数都进行依次的替换
3.==全特化本质上已经不是“模板”了,全特化后的类不再依赖模板参数,而是一个普通类,==

偏特化

偏特化:偏特化是对模板参数进行“部分具体化”或“形式限制”,用于匹配某一类类型,而不是某个具体类型。比如下面这个类:

1
2
3
4
5
6
7
8
9
10
11
12
template<class T1,class T2>
class Data
{
public:
Data()
{
cout << "Data<T1,T2>" << endl;
}
private:
T1 _d1;
T2 _d2;
};

偏特化有两种形式:

  • 部分特化
1
2
3
4
5
6
7
8
9
10
11
12
 template<class T2>
class Data<int,T2>
{
public:
Data()
{
cout << "Data<T1,T2>" << endl;
}
private:
int _d1;
T2 _d2;
};

==注意==:

1对于不特化的参数还要写在关键字template<>里面,并且类后面的<>既要写偏特化的类型,也要写未偏特化的参数类型
2.别忘了修改类中已经被实例化的模版参数

  • 对模板参数进行“形式匹配限制”(如指针、引用等)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<class T1,class T2>
class Data<T1*,T2*>
{
public:
Data()
{
cout << "Data<T1*,T2*>" << endl;
}
void fun()
{
cout << typeid(T1).name() << endl;
}
private:
T1 _d1;
T2 _d2;
};

==注意==:

1.若是对参数进行进一步的限制,比如加上一个指针或者引用时,template后面的参数不能省略
2.当模板参数为 T* 时,模板匹配会进行“模式拆解”,将实参类型 int* 拆解为:T = int 。因此 T 接收的是去掉指针后的类型

==总结==:当存在多个匹配的模板时,编译器会选择“最特化”的版本:
全特化 > 偏特化 > 主模板

模版分离编译

==要想懂的模版分离编译,就不得不先了解c++的编译流程==

  1. 预处理(文本替换)
  2. 编译(变成汇编)
  3. 汇编(变成机器码)
  4. 链接(拼成可执行文件)

预处理

预处理阶段只进行纯文本处理。主要包含:1、头文件展开 2、宏替换 3、条件编译 4、去掉注释。

编译

编译阶段会进行语法分析,并且生成汇编语言。

汇编

把汇编变成机器码。.o文件里有符号表(将函数简化成一个符号)和已经编译好的函数

链接

把所有.o文件拼起来。其中最重要的就是符号解析。

==总结==:在 预处理阶段,头文件只是被文本展开;到了 编译阶段,编译器根据声明检查语法,并生成目标文件(.o),其中函数调用不会变成具体地址,而是记录为“未解析的符号引用”,同时函数定义会被编译成机器码并导出为“已定义符号”;最后在 链接阶段,链接器会收集所有 .o 文件的符号表,把“未定义符号”和“已定义符号”进行匹配,统一分配实际内存地址,并将调用处的符号引用回填为真实地址,从而生成最终可执行文件。


为什么模版的分离编译会报错呢?

模板在使用时会发生隐式实例化,编译器必须在使用点看到完整定义才能生成具体代码。如果模板定义放在 .cpp 文件中,其他编译单元无法看到定义,就无法实例化对应版本的函数或类,最终导致链接阶段找不到符号,从而报错。

解决方法:

我们可以把声明和定义写在一个文件里。但是值得一提的是如果函数的定义在类里,那么就是内联函数。所以我们可以把长一点的函数进行声明和定义分离:即声明在类里,定义在类外,但都是在一个文件中