目录
非类型模板参数
类模板的特化
概念
函数模板特化
类模板特化
全特化
偏特化
类模板特化应用示例
模板的分离编译
分离编译概念
模板的分离编译
解决方法
模板总结
非类型模板参数
模板参数可分为类型形参和非类型形参。
类型形参: 出现在模板参数列表中,跟在class或typename关键字之后的参数类型名称。
非类型形参: 用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
类型形参(Type Parameters)
类型形参允许你在定义模板时指定一个或多个类型占位符,这些占位符将在实例化模板时被实际的类型所替换。这为编写泛型代码提供了基础,使得单个模板定义可以应用于多种数据类型。类型形参通常跟在
class
或typename
关键字之后,并赋予一个名称,这个名称在模板体内部代表一个未知但具体的类型。
例如,在定义一个通用的容器类模板时,你可以这样使用类型形参:
template <typename T>
class Vector {
T* data;
size_t size;
public:
Vector(size_t sz)
: size(sz)
, data(new T[sz])
{}
// ... 其他成员函数
};
在这个例子中,T
就是一个类型形参,当你实例化Vector
时,比如Vector<int>
,T
就会被替换为int
类型。
非类型形参(Non-Type Parameters)
非类型形参允许你在模板声明中使用除了类型之外的参数,最常见的形式是常量表达式(通常是编译时期可知的值)。这意味着你可以在模板中直接使用具体数值,而不仅仅是类型。非类型形参通常用于指定大小、维度等固定值,它必须是一个整数、枚举、指针或引用类型的常量表达式。
例如,定义一个计算阶乘的函数模板,其中阶乘的上限是一个非类型模板参数:
template <unsigned int N>
unsigned long long factorial()
{
if constexpr (N > 1)
return N * factorial<N-1>();
else
return 1;
}
在这个例子中,N
是一个非类型形参,表示阶乘运算的上限。当你调用factorial<5>()
时,N就被替换为5,实现计算5的阶乘。
注意:
- 非类型模板参数只允许使用整型家族,浮点数、类对象以及字符串是不允许作为非类型模板参数的。
- 非类型的模板参数在编译期就需要确认结果,因为编译器在编译阶段就需要根据传入的非类型模板参数生成对应的类或函数。
模板的特化
概念
通常情况下,使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些错误的结果,需要特殊处理,比如:实现了一个专门用来进行小于比较的函数模板。
// 函数模板 -- 参数匹配
template<class T>
bool Less(T left, T 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指针的地址,这就无法达到预期而错误。
此时,就需要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化与类模板特化。
函数模板特化
函数模板的特化分为全特化和偏特化,下面是进行特化的基本步骤:
函数模板特化步骤:
确定特化需求:首先明确你需要为哪种特定的类型或参数组合提供特化的实现。全特化意味着你要为模板的所有参数指定具体类型。
声明特化版本:使用
template<>
开始声明,后面跟上原模板的模板头,但这次要将模板参数替换为具体的类型或值。例如,如果你有一个接受两个类型参数的模板,全特化时需要明确指定这两个类型。template <> void myFunction(int a, double b) { // 特化的实现 }
实现特化逻辑:在特化声明之后,编写针对这些特定类型的实现代码。注意,这里的函数签名应与原模板匹配,只是参数类型或返回类型可能根据特化需求有所不同。
使用特化版本:在代码中,当调用函数模板并传递的参数匹配特化的类型时,编译器会自动选择特化版本。
代码示例:
//基础的函数模板
template<class T>
bool IsEqual(T x, T y)
{
return x == y;
}
//对于char*类型的特化
template<>
bool IsEqual<char*>(char* x, char* y)
{
return strcmp(x, y) == 0;
}
类模板特化
不仅函数模板可以进行特化,类模板也可以针对特殊类型进行特殊化实现,并且类模板的特化又可分为全特化和偏特化(半特化)。
全特化
类模板全特化步骤:
- 识别需求:确定你希望为类模板的哪一种具体类型或类型组合提供专门的实现。
- 声明全特化:使用
template<>
开始声明,后面紧跟着类模板的模板参数列表,但此时需将所有模板参数替换为具体的类型或值。例如,一个原本接受两个类型参数的类模板,全特化时需要明确指定这两个参数的具体类型。// 假设原类模板定义如下 template <typename T, typename U> class MyClass { // ... }; // 全特化声明 template <> class MyClass<int, double> { // 特化的实现 };
- 实现特化:在特化声明之后,提供这个特化版本的实现代码。根据特化类型的特点,你可能需要重写或添加成员函数,以及修改数据成员等,以满足特定类型的需要。
- 使用特化版本:在代码中实例化类模板时,如果使用的类型匹配全特化的类型组合,编译器将自动使用特化版本。
偏特化
类模板偏特化步骤:
- 分析模式:识别出模板参数中的某些参数或参数组合,你希望为这些特定模式提供不同的实现。偏特化允许你为模板参数的一个子集指定具体类型,而保留其他参数为泛型。
- 声明偏特化:使用
template
关键字开始声明,但只需特化部分模板参数,其他参数仍保持为模板参数。例如,如果原模板有两个参数,你可以特化第一个参数为特定类型,而保留第二个参数为泛型。// 假设原类模板定义如下 template <typename T, typename U> class MyClass { // ... }; // 偏特化声明,特化T为int template <typename U> class MyClass<int, U> { // 特化的实现,针对T为int的情况 };
- 实现偏特化版本:提供偏特化版本的实现代码,这应该针对特化参数的特性进行优化或调整。
- 应用偏特化:当实例化类模板时,如果提供的类型参数匹配你的偏特化模式,编译器将自动选择对应的偏特化版本。
那么如何证明各个类模板实例化使用的就是我们自己特化的类模板呢?
代码示例:
#include <iostream>
using namespace std;
template <typename T, typename U>
class MyClass
{
public:
MyClass()
{
cout << "MyClass<T, U>" << endl;
}
private:
T _d1;
U _d2;
};
// 全特化声明
template <>
class MyClass<int, double>
{
public:
MyClass()
{
cout << "MyClass<int, double> (Full Specialization)" << endl;
}
private:
int _d1;
double _d2;
};
// 偏特化声明,特化T为int
template <typename U>
class MyClass<int, U>
{
public:
MyClass()
{
cout << "MyClass<int, U> (Partial Specialization)" << endl;
}
private:
int _d1;
U _d2;
};
int main()
{
// 实例化类并构造对象以观察输出
MyClass<double, double> a; // 应该输出 "MyClass<T, U>"
MyClass<int, double> b; // 应该输出 "MyClass<int, double> (Full Specialization)"
MyClass<int, int> c; // 应该输出 "MyClass<int, U> (Partial Specialization)"
return 0;
}
代码结果:
类模板特化应用示例
假设我们要设计一个简单的日志记录器类,它可以处理不同类型的日志消息(如字符串、整数等)。首先,我们创建一个通用的日志记录器模板,然后针对字符串类型进行特化,提供一种更具体的处理方式,比如自动添加引号来包围字符串日志。
基础模板定义:
#include <iostream>
// 通用日志记录器模板
template <typename T>
class Logger
{
public:
void log(const T& message)
{
std::cout << "Logging generic message: " << message << std::endl;
}
};
//类模板特化:字符串日志
//对于字符串类型的消息,我们希望在输出时自动添加引号,以更清晰地标识这是一个文本日志项。因此,我们对Logger模板进行特化。
// 特化:针对std::string类型
template <>
class Logger<std::string>
{
public:
void log(const std::string& message)
{
std::cout << "Logging string message: \"" << message << "\"" << std::endl;
}
};
现在,我们可以使用这个模板及其特化版本来记录不同类型的消息:
int main()
{
Logger<int> intLogger;
intLogger.log(123); // 输出: Logging generic message: 123
Logger<std::string> stringLogger;
stringLogger.log("Hello, World!"); // 输出: Logging string message: "Hello, World!"
return 0;
}
在这个例子中,Logger<int>
使用的是基础模板的实现,而Logger<std::string>
则使用了特化的实现,自动为字符串添加了引号。
模板的分离编译
分离编译概念
一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。
模板的分离编译
在分离编译模式下,我们一般创建三个文件,一个头文件用于进行函数声明,一个源文件用于对头文件中声明的函数进行定义,最后一个源文件用于调用头文件当中的函数。
按照此方法,我们对一个加法函数模板进行分离编译,其三个文件当中的内容大致如下:
但是使用这三个文件生成可执行文件时,却会在链接阶段产生报错。
程序要运行起来一般要经历以下四个步骤:
- 预处理: 头文件展开、去注释、宏替换、条件编译等。
- 编译: 检查代码的规范性、是否有语法错误等,确定代码实际要做的工作,在检查无误后,将代码翻译成汇编语言。
- 汇编: 把编译阶段生成的文件转成目标文件。
- 链接: 将生成的各个目标文件进行链接,生成可执行文件。
以上代码在预处理阶段需要进行头文件的包含以及去注释操作:
这三个文件经过预处理后实际上就只有两个文件了,若是对应到Linux操作系统当中,此时就生成了 Add.i 和 main.i 文件了。
预处理后就需要进行编译,虽然在 main.i 当中有调用Add函数的代码,但是在 main.i 里面也有Add函数模板的声明,因此在编译阶段并不会发现任何语法错误,之后便顺利将 Add.i 和 main.i 翻译成了汇编语言,对应到Linux操作系统当中就生成了 Add.s 和 main.s 文件。
到达了汇编阶段,此阶段利用 Add.s 和 main.s 这两个文件分别生成了两个目标文件,对应到Linux操作系统当中就是生成了 Add.o 和 main.o 两个目标文件。
前面的预处理、编译和汇编都没有问题,现在就需要将生成的两个目标文件进行链接操作了,但在链接时发现,在main函数当中调用的两个Add函数实际上并没有被真正定义,主要原因是函数模板并没有生成对应的函数,因为在全过程中都没有实例化过函数模板的模板参数T,所以函数模板根本就不知道该实例化T为何类型的函数。
模板分离编译失败的原因:
在函数模板定义的地方(Add.cpp)没有进行实例化,而在需要实例化函数的地方(main.cpp)没有模板函数的定义,无法进行实例化。
解决方法
解决类似于上述模板分离编译失败的方法有两个。
第一个方法:就是在模板定义的位置进行显示实例化。
虽然第一种方法能够解决模板分离编译失败的问题,但是我们这里并不推荐这种方法,因为我们需要用到一个函数模板实例化的函数,就需要自己手动显示实例化一个函数,非常麻烦。
第二个方法:也是我们所推荐的,那就是对于模板来说最好不要进行分离编译,不论是函数模板还是类模板,将模板的声明和定义都放到一个文件当中就行了。
模板总结
优点:
- 代码重用:模板允许你编写一次代码,应用于多种数据类型,极大地减少了代码重复,并提高了维护效率。
- 类型安全:相比于使用泛型的编程语言中的类型擦除机制,C++模板在编译时生成具体类型的代码,保证了类型安全,减少了运行时错误。
- 性能:模板代码在编译时实例化,意味着没有运行时的类型检查或转换开销,通常能获得与手写特定类型代码相近的性能。
- 灵活性:模板特化允许为特定类型定制行为,使得库或框架能够适应更广泛的需求。
- 泛型编程:促进了泛型编程范式的应用,使得算法和数据结构更加抽象和通用。
缺点:
- 编译时间:模板尤其是深度嵌套或大量特化的模板会显著增加编译时间和编译复杂度,影响开发效率。
- 二进制体积:每个被实例化的模板都会生成对应的代码,对于大型项目或大量类型实例化的情况,可能会导致可执行文件体积增大。
- 调试难度:模板错误通常以编译错误的形式出现,且错误信息可能冗长难懂,特别是涉及复杂模板元编程时,调试和理解问题的根源可能较为困难。
- 学习曲线:理解和有效使用模板,特别是高级特性如模板元编程,对开发者有较高的要求,初学者可能感到门槛较高。
- 过度设计风险:有时过度依赖模板和泛型编程可能导致代码结构过于抽象,降低代码的可读性和可维护性。