目录
C++ 模板进阶深度解析
1. 非类型模板参数详解
类型模板参数就是我们最常使用的模板方式,允许将类型作为“参数”传递,出现在模板参数列表中,跟在class或者typename之类的参数类型名称。,就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
非类型参数编译期将具体的值作为模板参数传递,与宏定义类似。C++20之前只允许传递整型常量,C++20则可以传递其他类型的常量,
代码示例:
template<class T, size_t N = 10> // N就是非类型模板参数
class array {
private:
T _array[N]; // 这里N被当作编译期常量使用
size_t _size;
};
对于上面代码,我们需要理解:
- N 不是变量,而是编译期确定的常量
- 编译器在实例化时会用具体数值替换 N
- 模板实例化后,N的值就确定了,在编译期就会确定数组大小
2. 模板特化
2.1 什么是模板特化
我们在使用模板编程的时候,有时候会遇到特殊情况,我们所写的模板可能无法满足我们需要使用的类型的一些要求,无法实现相应功能,因此需要重写一个模板来实现相应功能。
如下所示:
template<class T>
bool Less(T left, T right) {
return left < right;
}
Date* p1 = &d1, *p2 = &d2;
cout << Less(p1, p2); // 灾难!比较的是指针地址而非对象内容
cout << Less(*p1, *p2); //这是我们所期待的方式
此时,该函数无法实现比较大小的功能,他只会按照指针去比大小,而不是解引用取到值去比较大小,无法实现相应功能,**模板的通用性在这里变成了"错误的通用"——它对所有类型都一视同仁,但指针类型需要特殊处理。**因此需要重写一个模板来实现相应功能。
而重写模板费时费力,可能大部分功能和接口都相同的,只是个别功能出现问题,因此我们可以进行模板特化,在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。
特化分为函数模板特化和类模板特化。
具体特化方式及语法请看下文。
2.2 函数模板特化
特化语法详解
函数模板的要求:
- 必须要先有一个基础的函数模板
- 关键字template后面接一对空的尖括号<>
- 函数名后跟一对尖括号,尖括号中指定需要特化的类型
- 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误
// 基础模板 - 通用版本
template<class T>
bool Less(T left, T right) {
return left < right;
}
// 特化版本 - 针对Date*类型的特殊处理
template<> // 空尖括号表示这是特化
bool Less<int*>(int* left, int* right) { // 明确指定特化类型
return *left < *right; // 解引用后比较实际对象
}
int main()
{
int a = 3;
int b = 4;
Less(a, b); //调用普通版本
Less(&a, &b); // 指针类型,调用特化版本,解引用后再比较大小
return 0;
}
但是我们一般情况下不推荐函数模板特化,因为有更好的解决方案。
函数模板特化缺点
// 方式1:函数模板特化(不推荐)
template<>
bool Less<int*>(int* left, int* right);
// 方式2:普通函数重载(推荐)
bool Less(int* left, int* right);
函数模板特化相比起来直接重载函数,代码可读性低,不易维护,不易书写,所以说遇到特殊情况时,直接重载一个函数,更加方便。
2.3 类模板特化
2.3.1 全特化
全特化即是将模板参数列表中所有的参数都确定化。
为特定的类型组合提供完全不同的代码实现。
// 通用模板 - 处理大多数情况
template<class T1, class T2>
class Data {
public:
Data() { cout << "通用版本" << endl; }
};
// 全特化 - 为<int, char>量身定制
template<>
class Data<int, char> {
public:
Data() { cout << "int和char的特化版本" << endl; }
// 可以有不同的成员变量、不同的方法实现
};
当某个特定类型组合需要完全不同的数据结构或算法时,我们就可会使用全特化来特殊处理。
2.3.2 偏特化
偏特化,也叫半特化,相比比全特化更灵活,它允许我们对模板参数施加条件限制。
部分特化:
允许部分模板参数特化
// 当第二个参数是int时的特化版本
template <class T1>
class Data<T1, int> { // 注意语法:在类名后的模板参数中指定特化类型
public:
Data() { cout << "第二个参数为int的版本" << endl; }
};
类型限制特化:
// 指针类型的特化
template <typename T1, typename T2>
class Data<T1*, T2*> { // 两个参数都是指针类型
public:
Data() { cout << "指针版本" << endl; }
};
// 引用类型的特化
template <typename T1, typename T2>
class Data<T1&, T2&> { // 两个参数都是引用类型
public:
Data(const T1& d1, const T2& d2) : _d1(d1), _d2(d2) {
cout << "引用版本" << endl;
}
private:
const T1& _d1; // 引用成员
const T2& _d2;
};
那要是同时存在全特化,偏特化,那么具体实例化哪一个呢?
Date<int, int> d1; //不符合全特化,也不符合偏特化,调用普通版本
Date<int, char> d2; //符合全特化,调用全特化
Date<char, int> d3; //符合偏特化第二个参数要求,调用偏特化
Date<char, char> d4; //不符合偏特化,也不符合偏特化,调用普通版本
Date<int*, char*> d5; //两个参数都是指针,符合指针类型限制的特化,调用该版本
Date<int&, char&> d6; //两个指针都是引用,符合引用类型限制的特化,调用该版本
总结:先检查时候符合全特化条件,符合的话调用全特化,然后检查是否符合偏特化条件,符合的话调用符合条件的偏特化,没有符合任意一个特化条件,则调用普通版本
2.3.3 实际应用
STL算法需要比较器,但通用比较器对指针类型失效。
namespace cmp
{
template< class T >
struct Less {
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
template<class T>
struct Less<T*> {
bool operator()(const T* x, const T* y)
{
return *x < *y;
}
};
}
int main()
{
char a = 5;
char b = 4;
std::cout << cmp::Less<char>()(a, b) << std::endl; //调用普通版本
std::cout << cmp::Less<char*>()(&a, &b) << std::endl; //调用特化版本
return 0;
}
有了指针类型的特化版本后,可以解引用的类型就可以通过指针去调用特化版本来比较大小
3. 模板分离编译的底层原理
3.1 编译链接过程
理解模板分离编译问题,需要先明白C++的编译过程:
- 预处理:头文件展开、替换宏定义等
- 编译:将每个.cpp文件单独编译成目标文件(.obj)
- 链接:将所有目标文件合并,解析符号引用
3.2 模板分离编译问题
我们生命和定义分离时,会有下列情况:
a.h(声明) → 被main.cpp和a.cpp包含
a.cpp(定义) → 编译成a.obj
main.cpp(使用) → 编译成main.obj
问题发生过程:
- 编译a.cpp时:编译器看到 Add 模板的定义,但没看到具体的实例化调用,所以不会生成Add< int >或Add< double >的实际代码
- 编译main.cpp时:编译器看到Add(1, 2)调用,知道需要Add< int > 函数,但在当前文件中找不到定义,只能生成一个"未解析符号",放进符号表,等待链接时再寻找函数地址
- 链接时:由于没有实例化出具体代码,因此链接器在a.obj中找不到Add< int >的实现,报链接错误
3.3 解决方案
方案1:声明定义放在一起(推荐)
// xxx.h 或 xxx.hpp
template<class T>
T Add(const T& left, const T& right) {
return left + right、;
}
当main.cpp包含头文件时,头文件展开后,就可以看到完整的模板定义,编译器在编译main.cpp看到集体函数调用后就能实例化出Add< int >等具体函数,直接获得函数地址,不存在链接时找不到定义
方案2:显式实例化(不推荐)
// a.cpp
template<class T>
T Add(const T& left, const T& right) {
return left + right;
}
// 显式告诉编译器:请生成这些具体版本
template int Add<int>(const int&, const int&);
template double Add<double>(const double&, const double&);
缺点:
- 需要预知所有可能用到的类型
- 维护困难,容易遗漏
- 违背了模板的通用性原则
4. 模板按需实例化
模板的“按需实例化”,有时也被称为“隐式实例化”,是C++模板工作的核心机制。
大概意思就是:编译器不会为你的模板生成一整套完整的代码,而是只生成那些真正在程序中被使用到的部分。 这种机制深刻地影响了模板代码的编写、编译和最终生成的可执行文件。
4.1 按需实例化的行为
当你编写了一个模板类或模板函数时,它只是一个“蓝图”,编译器并不会立即为其生成任何实际的机器代码。只有当你在代码中明确使用了这个模板的某个特定版本时,编译器才会动手。例如,你定义了一个MyClass< int >对象,编译器才会开始将模板MyClass< T >中的T
替换为 int,并生成MyClass< int >的二进制代码。而且,在这个生成的类中,它只会实例化那些被你调用了的成员函数。如果一个成员函数从未被调用,那么编译器就永远不会为它生成代码。
4.2 优点:控制代码膨胀
这是“按需实例化”最重要的价值所在。它是对抗模板可能引起的“代码膨胀”问题的第一道防线。想象一下,如果你有一个包含20个成员函数的模板类,而你只使用了其中的2个。如果没有“按需实例化”,编译器为每种类型生成全部20个函数,会造成巨大的浪费。而“按需实例化”确保了最终的可执行文件中只包含那2个真正被用到的函数,极大地节省了空间。
4.3 缺点:延迟错误暴露
这是一个非常关键且有时令人困惑的副作用。由于编译器只检查和处理被实例化的部分,这意味着模板代码中的错误(不包括没有分号,缺少花括号等一些“框架”错误,),只有在它被实例化时才会被编译器发现。你可以编写一个语法有误或者对某些类型无意义的模板,但只要你不使用有问题的那个部分,编译器就会安然放过它。这被称为“两阶段查找”:
- 第一阶段(模板定义时):编译器检查不依赖于模板参数的语法,比如基本符号、括号匹配等。
- 第二阶段(模板实例化时):编译器检查所有依赖于模板参数的代码是否有效。
举个例子来说明:
#pragma once
#include<iostream>
#include<vector>
#include<list>
namespace mystack{
template<class T, class Container = std::vector<T>> //默认使用vecotr<T>, 然而在C++STL标准中默认使用的是dqueue
class stack{
private:
Container _st;
namespace mystack {
template<class T, class Container = std::vector<T>> //默认使用vecotr<T>, 然而在C++STL标准中默认使用的是dqueue
class stack {
private:
Container _st;
public:
void push(const T& val)
{
_st.push_back(val);
}
void pop()
{
_st.pop_back();
}
const T& top()
{
return _st.back();
}
bool empty()
{
return _st.empty();
}
size_t size()
{
_st++; //vector不支持++运算符,因此这是一个编译错误
return _st.size();
}
void swap(stack& val)
{
_st.swap(val._st);
}
};
}
int main()
{
mystack::stack<int> st1; //此时实例化stack类
st1.push(1); //调用时实例化push函数
st1.top(); //调用时实例化top函数
//st1.size(); //该函数中存在错误,但是不调用的话,编译器没有实例化,就不会发现该错误,程序可以正常运行,去掉注释后,调用该该函数了,就会报错
//st1.swap(); //该函数如果一直不被调用,那么他将一直不实例化,其他未调用的模板类或者模板函数也是如此
//……
return 0;
}
上面代码中st1.size()调用就会报错,不调用则正常运行
调用:
不调用:
总结:“按需实例化”是C++模板设计哲学的一种体现——不为不需要的东西付出代价。它要求程序员以一种新的思维方式来对待代码:一个模板的正确性,不仅仅在于它的定义本身,更在于它被以何种方式实例化。理解这一点,对于编写高效、健壮的模板代码,以及解读那些“看似正确却编译失败”的复杂模板错误信息至关重要
5.模板的缺点
5.1 代码复用的代价
模板所谓的“代码复用”并非传统意义上的一份代码被多处调用,其本质是一种编译期的“代码生成”机制;当我们为不同的类型使用同一个模板时,编译器会为每一种类型都实例化并编译出一份完全独立的代码副本。这种机制的代价是会导致代码膨胀,即最终的可执行文件体积显著增大,因为它包含了多份逻辑相同但类型不同的代码,这可能会影响程序的加载速度和内存占用。
5.2 灵活性的代价:
C++模板设计哲学的一种体现——不为不需要的东西付出代价。它要求程序员以一种新的思维方式来对待代码:一个模板的正确性,不仅仅在于它的定义本身,更在于它被以何种方式实例化。理解这一点,对于编写高效、健壮的模板代码,以及解读那些“看似正确却编译失败”的复杂模板错误信息至关重要