C++模板进阶解析:非类型参数、特化技术与分离编译实战
1.非类型模板参数
- 模板参数分类类型形参与非类型形参。
- 类型形参即:出现在模板参数列表中,跟在class或者typename之类的参数类型名称。
- 非类型形参:就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
// 定义一个模板类型的静态数组 template<class T, size_t N = 10> class array { public: 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; }; int main() { array<int,5> s5; array<int,10> s10;//生成两个类 //array s;当只有非类型模板参数并且给了缺省值,C++20之后支持使用必需array<> s这样写 return 0; }与宏相比如#define N 10,宏是写死的,而模板非类型参数提供了更大的灵活性和类型安全性
注意:1. 浮点数(C++20支持)、类对象以及字符串是不允许作为非类型模板参数的 。2. 非类型的模板参数必须在编译期就能确认结果 。![]()
非类型模板参数在array中有使用,用来定义静态数组的大小。array比起正常静态数组有越界检查优势。
普通静态数组,在越界读基本检查不出来,在越界写时是一种抽查,一般在标志位(数组的后面)检查是否被修改,而在array中,就可以用assert来检查越界读和越界写。
int main() { array<int, 10>a1; array<int, 100>a2; int a3[10] = {0}; //越界检查问题 cout<<a3[10]<<endl; //a3[10] = 10; //a3[11] = 10; //越界读写都会检查 //a1[10]; //a1[10] = 10; return 0; }
2.模板特化
2.1概念
通常情况下, 使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些 错误的结果 ,需要特殊处理,比如:实现了一个专门用来进行小于比较的函数模板// 函数模板 -- 参数匹配 template<class T> bool Less(T left, T right) { return left < right; } // 解决方案一:对Less函数模板进行特化 template<> bool Less<Date*>(Date* left, Date* right) { return *left < *right; } //所表达的意思就是,当模板参数推导出的类型与特化版本完全匹配时,优先选择特化版本;否则选择主模板版本。 // 解决方案二:直接提供支持Date*类型的函数 //bool Less(Date* left, Date* right) //{ // return *left < *right; //}//有现成用现成的 int main() { cout << Less(1, 2) << endl; // 可以比较,结果正确 Date d1(2022, 7, 7); Date d2(2022, 7, 8); cout << Less(d1, d2) << endl; // 可以比较,结果正确 Date* p1 = &d1; Date* p2 = &d2; cout << Less(p1, p2) << endl; // 没有特化前,可以比较,但结果错误 return 0; }可以看到, Less 绝对多数情况下都可以正常比较,但是在特殊场景下就得到错误的结果。上述示 例中,p1 指向的 d1 显然小于 p2 指向的 d2 对象,但是 Less 内部并没有比较 p1 和 p2 指向的对象内容,而比较的是p1 和 p2 指针的地址,这就无法达到预期而错误。此时,就 需要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方 式 。模板特化中分为 函数模板特化 与 类模板特化 。
2.2函数模板特化
函数模板的特化步骤:
- 必须要先有一个基础的函数模板
- 关键字template后面接一对空的尖括号<>
- 函数名后跟一对尖括号,尖括号中指定需要特化的类型
- 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误。
注意:一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将该函数直接给出。这样简单明了,代码的可读性高,容易书写,因为对于一些参数类型复杂的函数模板,特化时特别给出,因此函数模板不建议特化。
在上面的例子中假设在叠加一层const也就是如下:
const Date* p3=&d1; const Date* p4=&d2; cout<<Less(p3,p4)<<endl;//匹配到原模版这里匹配到原模版的原因是,我们只特化了Date*,而这里是const Date*两者类型不同,而且const Date*不能传递给Date*,权限放大,所以我们可以特化出const Date*版本
template<> bool LessFunc<const Date*>(const Date* const& left,const Date* const& right) { return *left<*right; }这里的第一个const代表重载const Date*版本,第二个const则是修饰引用变量本身
这里在注意一下const T& left和T const& left 两者完全等价,也就是说const int a=0 等价于int const a=0,const int&rx=a等价于int const&rx=a。
这里再一次体现了直接给函数的简洁性《Effective C++》作者 Scott Meyers 的建议:
- "如果你想要自定义模板函数的行为,不要特化它,要重载它。"
2.3 类模板特化
原模板:
//原模板 template<class T1, class T2> class Data { public: Data() { cout << "Data<T1, T2>" << endl; } private: T1 _d1; T2 _d2; };1.全特化:全特化即是将模板参数列表中所有的参数都确定化。
// 全特化 template<> class Data<int, char> { public: Data() { cout << "Data<int, char>" << endl; } private: int _d1; char _d2; };2.偏特化(半特化):任何针对模版参数进一步进行条件限制设计的特化版本。比如对于以下模板类:偏特化有以下两种表现方式:
部分特化 将模板参数类表中的一部分参数特化。// 偏特化 // 将第二个参数特化为char template <class T1> class Data<T1, char> { public: Data() { cout << "Data<T1, char>" << endl; } private: T1 _d1; char _d2; };注意:匹配优先级:全特化 > 偏特化 > 主模板
- 参数更进一步的限制 偏特化并不仅仅是指特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一个特化版本。
// 偏特化 // 两个参数偏特化为指针类型 template <class T1, class T2> class Data <T1*, T2*> { public: Data() { cout << "Data<T1*, T2*>" << endl; } private: T1* _d1; T2* _d2; }; //两个参数偏特化为引用类型 template <class T1, class 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; }; //一个参数偏特化为引用类型,另一个参数偏特化为指针类型 template <class T1, class T2> class Data <T1&, T2*> { public: Data() { cout << "Data<T1&, T2*>" << endl; } };注意,在下面例子中这里的T1是原类型而不是指针
template <class T1, class T2> class Data <T1*, T2*> { public: Data() { cout << "Data<T1*, T2*>" << endl; T1 x; T1* ptr=nullptr; cout<<typeid(x).name() << endl; } private: T1* _d1; T2* _d2; };
可以看到,我们传二级指针,T1就是一级指针,传一级指针,T1就是原类型,这样设计语法的目就是既可以用T1来定义原类型,也可以定义指针类型(T1*,T1&)使用更加灵活
测试:
int main() { Data<int, char> d1; // 全特化 - Data<int, char> Data<int, double> d2; // 主模板 - Data<T1, T2> Data<double, char> d3; // 偏特化 - Data<T1, char> Data<int*, char*> d4; // 偏特化 - Data<T1*, T2*> Data<int&, char&> d5(1, 2); // 偏特化 - Data<T1&, T2&> Data<int&, double*> d6; // 偏特化 - Data<T1&, T2*> Data<double*, int*> d7; // 偏特化 - Data<T1*, T2*> Data<string&, int&> d8(string("hello"), 42); // 偏特化 - Data<T1&, T2&> Data<Data<int, int>*, Data<double, double>*> d9; // 偏特化 - Data<T1*, T2*> return 0; }上一节我们在模拟实现优先级队列时就可以使用参数限制的偏特化来实现指针的比较逻辑。感兴趣可以了解我的上一篇文章:C++ Stack Queue 详解-CSDN博客
3 模板分离编译
3.1 什么是分离编译
一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。
3.2 模板的分离编译
假如有以下场景,模板的声明与定义分离开,在头文件中进行声明,源文件中完成定义://--------Func.h文件-------- #include<iostream> using namespace std; //普通函数 void func(); template<class T> T Add(const T& left, const T& right); //--------Func.cpp-------- #include"Func.h" void func() { cout<<"the func is normal"<< endl; } template<class T> T Add(const T& left, const T& right) { return left + right; } //--------test.cpp-------- #include"Func.h" int main() { Add(1, 2); Add(1.0, 2.0); func(); return 0; }我们发现当我们将Add(1, 2);Add(1.0, 2.0);注释掉,程序正常打印the func is normal,但是将Add函数注释取消就会
要理解这个问题,首先要理解C/C++程序的执行流程:
阶段一:预处理 (Preprocessing)
输入: 源代码文件 (
.cpp,.h)
输出: 经过处理的“编译单元” (通常为.i或.ii文件)
执行工具: 预处理器 (如cpp,通常是编译器的一部分,如g++ -E)主要工作:
头文件包含 (
#include): 将#include指令指定的文件内容原封不动地插入到该指令所在的位置。例如,#include "Func.h"会被替换成Func.h文件里的所有内容。宏展开 (
#define): 将所有宏定义展开,并替换代码中所有用到该宏的地方。条件编译 (
#if,#ifdef,#ifndef,#else,#elif,#endif): 根据条件决定哪些代码块参与编译。删除注释: 将所有注释(
//和/* ... */)替换成空格。处理后: 得到一个非常“干净”的、不包含任何预处理指令的纯C++代码文件。
阶段二:编译 (Compilation)
输入: 预处理后的编译单元 (
.i文件)
输出: 汇编代码文件 (.s文件)
执行工具: 编译器 (如g++ -S,clang++ -S)主要工作:
这是最复杂、最核心的阶段。编译器将高级的C++代码“翻译”成低级的、与硬件相关的汇编代码。这个过程包括:
词法分析: 将源代码分解成一个个标记,如关键字、标识符、运算符等。
语法分析: 根据C++语法规则,将标记组织成语法树。
语义分析: 检查程序逻辑是否正确,如类型检查、函数调用匹配等。
中间代码生成与优化: 生成一种中间的、与机器无关的表示,并进行各种优化(如常量折叠、死代码消除等)。
代码生成: 将优化后的中间代码转换为目标平台的汇编代码。
阶段三:汇编 (Assembly)
输入: 汇编代码文件 (
.s文件)
输出: 目标文件 (.o或.obj文件)
执行工具: 汇编器 (如as,通常是编译器套件的一部分)主要工作:
这是一个相对简单的“翻译”过程。汇编器将人类可读的汇编代码一对一地转换成机器可以执行的二进制机器码。每个汇编指令几乎都对应一条或多条机器指令。此时生成的目标文件已经是二进制格式,但还不能直接运行。因为它可能包含对其他地方(例如其他
.cpp文件中的函数)的引用,这些引用的地址还没有被解析。阶段四:链接 (Linking)
输入: 一个或多个目标文件 (
.o文件) 和库文件 (如 C++ 标准库libstdc++.a)
输出: 最终的可执行文件 (如a.out,test.exe)
执行工具: 链接器 (如ld,通常是编译器套件的一部分)主要工作:
这是最后一步,将所有“碎片”拼接到一起。
地址和空间分配: 为所有目标文件分配最终的内存地址。
符号解析: 这是链接器的核心任务。
在我们的例子中,
test.o中有一个call fnuc的指令,但它并不知道fnuc函数的具体实现在哪里。
Func.o中包含了fnuc函数的实现。链接器会扫描所有目标文件,找到
fnuc这个“符号”的定义,然后将test.o中对fnuc的引用与 Fnuc.o中fnuc的定义关联起来。重定位: 根据符号解析的结果,修改所有对这些符号的引用地址,让它们指向正确的内存位置。
库链接: 链接程序所依赖的库,例如C++标准库中的
std::cout和std::endl的实现。链接完成后,就生成了一个完整的、操作系统可以加载和执行的程序文件。
运行阶段 (Runtime)
当在终端输入
./my_program后,操作系统会执行以下操作:
加载: 操作系统的加载器将可执行文件从硬盘读入内存。
创建进程: 操作系统为程序创建一个新的进程,分配PID,建立虚拟地址空间等。
动态链接(如果有): 如果程序使用了共享库(如
.dll或.so),动态链接器会在这个时候将它们加载到内存并解析剩余的符号。执行: CPU从
main函数的入口地址开始,逐条执行机器指令。程序终止: 当
main函数返回,或调用exit()时,程序结束,操作系统回收其占用的资源。
回头来看
1. 普通函数
func()为什么能正常工作
编译阶段:在
Func.cpp中,func()函数有完整的定义,编译器会生成该函数的机器代码链接阶段:
test.cpp中调用func()时,链接器能在Func.obj中找到func()的地址并完成链接2. 函数模板
Add()为什么链接失败根本原因:函数模板需要实例化才能生成具体代码
在代码中:
Func.h中只有模板的声明
Func.cpp中有模板的定义
test.cpp中尝试使用Add<int>和Add<double>问题发生过程:
编译
Func.cpp时,编译器看到了Add模板的定义,但没有看到任何使用它的代码,所以不会生成任何具体的模板实例编译
test.cpp时,编译器看到了Add(1, 2)和Add(1.0, 2.0),知道需要Add<int>和Add<double>,但在头文件中找不到模板定义,只能生成函数调用指令链接时,链接器在
test.obj中找到了对Add<int>和Add<double>的调用,但在Func.obj中找不到这些函数的实现,因此报"无法解析的外部符号"解决方法:
1. 直接将模板定义放在头文件中,用的地方直接就有定义,直接实例化, 推荐使用这种。2. 模板定义的位置显式实例化 。这种方法不实用,不推荐使用。//--------Func.cpp-------- void func() { cout<<"the func is normal"<< endl; } template<class T> T Add(const T& left, const T& right) { return left + right; } //显示实例化 template int Add(const int& left,const int& right); template double Add(const double& left,const double& right);
4.模板优缺点总结
优点
类型安全:比宏和void指针更安全
性能优异:编译期多态,无运行时开销,更快的迭代开发
代码复用:编写一次,适用于多种类型,节省了资源,增强代码灵活性
抽象能力强:支持高度泛化的编程
缺点:
编译时间长:每次实例化都会生成新代码
代码膨胀:可能生成多个相似的具体类/函数
错误信息晦涩:模板错误信息非常凌乱,往往很难理解
调试困难:调试模板代码比较挑战
更多推荐







所有评论(0)