C++模板进阶
目录
一、非类型模板参数
1. 什么是非类型模板参数?
2. 它能干什么?
二、模板的特化
1. 为什么需要模板特化?
2. 函数模板特化
3. 类模板特化
3.1 全特化
3.2 偏特化
3.3 类模板特化应用实例:解决排序问题
三、模板的分离编译
1. 分离编译的困惑
2. 问题的根源
3. 解决方案
方法一:显示实例化
方法二:把模板定义放在头文件中
四、总结:模板的优缺点与应用场景
优点
缺点
应用场景
一、非类型模板参数
1. 什么是非类型模板参数?
非类型模板参数,顾名思义,就是模板参数中不是类型的部分。它允许我们用一个常量作为类或函数模板的参数,在模板中把这个参数当作常量来使用。就好比给模板增加了一个特殊的“标签”,直接告诉编译器一些确定的信息。
2. 它能干什么?
举个简单的例子,我们要实现一个静态数组的类。静态数组的大小在编译时就必须确定,这时候非类型模板参数就派上大用场了。
template<typename T, size_t N>
class StaticArray
{
public:size_t arraysize() {return N;}private:T _array[N];
};
在上面这段代码中,N
就是一个非类型模板参数。它直接决定了 _array
这个静态数组的大小。当我们实例化对象时,就可以指定这个大小了。
int main()
{StaticArray<int, 10> a1;std::cout << a1.arraysize() << std::endl; // 输出 10StaticArray<int, 100> a2;std::cout << a2.arraysize() << std::endl; // 输出 100return 0;
}
是不是很方便?而且,非类型模板参数不仅限于整数类型,像枚举类型等也是可以的。不过,浮点数、类对象以及字符串等就不行了。因为编译器在编译阶段就得确定非类型模板参数的值,而这些类型在编译时很难直接确定。
二、模板的特化
1. 为什么需要模板特化?
模板确实很强大,能让我们写出通用的代码。但有些时候,对于某些特殊类型,通用的模板实现可能并不能达到我们想要的效果,甚至可能会出错。这时候,模板特化就成为了我们的“救星”。
2. 函数模板特化
先来看看函数模板特化的例子。假设我们有一个用于比较两个相同类型数据是否相等的函数模板。
template<typename T>
bool IsEqual(T x, T y)
{return x == y;
}
这个函数模板在大多数情况下都能正常工作。但如果传入的是字符数组呢?
char a1[] = "hello";
char a2[] = "hello";
std::cout << IsEqual(a1, a2) << std::endl; // 输出 0
结果和我们期望的不一样。因为这个函数模板比较的是两个字符数组的地址,而不是它们的内容。那怎么办?我们可以对 char*
类型进行特化。
函数模板的特化步骤:
- 必须要先有一个基础的函数模板
- 关键字template后面接一对空的尖括号<>
- 函数名后跟一对尖括号,尖括号中指定需要特化的类型
- 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误。
template<>
bool IsEqual<char*>(char* x, char* y)
{return strcmp(x, y) == 0;
}
这样,当我们比较字符数组时,就会调用这个特化版本的函数,得到正确的结果。
3. 类模板特化
类模板也可以进行特化,分为全特化和偏特化。
3.1 全特化
全特化就是把模板参数列表中所有的参数都确定下来。比如,我们有一个类模板:
template<typename T1, typename T2>
class apple
{
public:apple() {std::cout << "apple<T1, T2>" << std::endl;}
};
现在,我们想要对 T1
为 double
,T2
为 int
的情况进行特殊处理。
template<>
class apple<double, int>
{
public:apple() {std::cout << "apple<double, int>" << std::endl;}
};
这样,当我们实例化 apple<double, int>
时,就会调用这个特化版本的类模板。
3.2 偏特化
偏特化是类模板特化的“升级版”,它允许我们针对模板参数设置特定条件,当这些条件满足时,就使用特化版本的类模板。
偏特化的两种表现方式
🌴部分特化
部分特化就是只对模板参数表中的部分参数进行特化,就像给模板参数设置了一部分“专属规则”。比如,对于一个有两个模板参数的类模板 Data
,我们把第二个参数特化为 int
:
template <class T1>
class Data<T1, int>
{
public:Data() { std::cout << "Data<T1, int>" << std::endl; }
private:T1 _d1;int _d2;
};
这样,当实例化对象时,只要第二个模板参数是 int
,就会调用这个特化版本。
🌴更进一步的参数限制
偏特化还可以通过更进一步的参数限制,实现更灵活的模板定制。例如,我们把两个参数都特化为指针类型:
template <typename T1, typename T2>
class Data<T1*, T2*>
{
public:Data() { std::cout << "Data<T1*, T2*>" << std::endl; }
private:T1 _d1;T2 _d2;
};
或者特化为引用类型:
template <typename T1, typename T2>
class Data<T1&, T2&>
{
public:Data(const T1& d1, const T2& d2): _d1(d1), _d2(d2) {std::cout << "Data<T1&, T2&>" << std::endl;}
private:const T1& _d1;const T2& _d2;
};
这样,当实例化的模板参数满足这些特定条件时,就会调用相应的特化版本。
3.3 类模板特化应用实例:解决排序问题
假设我们有一个专门用于比较的类模板 Less
,用于在排序算法中按照小于关系比较元素:
template <class T>
struct Less
{bool operator()(const T& x, const T& y) const {return x < y;}
};
当我们对日期对象进行排序时,如果直接对日期指针进行排序,会出现问题。因为默认的 Less
模板会比较指针的地址,而不是指针指向的日期对象本身。
Date d1(2025, 5, 1);
Date d2(2025, 5, 2);
Date d3(2025, 5, 3);std::vector<Date*> v2;
v2.push_back(&d1);
v2.push_back(&d2);
v2.push_back(&d3);std::sort(v2.begin(), v2.end(), Less<Date*>());
这段代码会导致排序结果不符合预期,因为它是按照指针的地址排序,而不是按照日期的大小排序。
为了解决这个问题,我们可以对 Less
类模板针对指针类型进行特化:
template <>
struct Less<Date*>
{bool operator()(Date* x, Date* y) const {return *x < *y;}
};
特化后的 Less<Date*>
模板会比较指针指向的日期对象的值,而不是指针的地址。这样,当我们对日期指针进行排序时,就能得到正确的结果。
三、模板的分离编译
1. 分离编译的困惑
在实际开发中,我们经常会对代码进行分离编译。也就是把代码分成多个源文件,分别编译后再链接成一个可执行文件。然而,对于模板来说,分离编译可能会引发一些问题。
假设我们有一个函数模板:
// Add.h
#ifndef ADD_H
#define ADD_Htemplate<typename T>
T Add(T a, T b)
{return a + b;
}#endif
然后我们在另一个源文件中使用它:
// main.cpp
#include "Add.h"
#include <iostream>int main()
{std::cout << Add<int>(1, 2) << std::endl;return 0;
}
如果我们尝试将这两个文件分别编译,再链接成一个可执行文件,就会在链接阶段报错。为什么呢?
2. 问题的根源
问题在于,模板的实例化是在编译阶段进行的。如果在一个源文件中使用了模板,但模板的定义在另一个源文件中,那么编译器在编译这个源文件时并不知道要实例化哪些模板。所以在编译后的目标文件中,就不会生成对应的模板实例化代码。链接器在链接时找不到这些代码,就会报错。
3. 解决方案
为了解决这个问题,我们可以采用以下两种方法:
方法一:显示实例化
在模板定义的源文件中,手动对常用类型进行实例化。
// Add.cpp
#include "Add.h"// 显示实例化
template int Add<int>(int, int);
template double Add<double>(double, double);
这样,在编译 Add.cpp
时,就会生成这些实例化后的函数。链接时就不会出错了。
不过,这种方法有个缺点:每次需要使用一个新的类型,都要手动添加实例化代码,很麻烦。
方法二:把模板定义放在头文件中
其实,最简单的方法是直接把模板的定义放在头文件中。这样,在每个使用模板的源文件中包含头文件时,编译器就能在需要的时候实例化模板了。
// Add.h
#ifndef ADD_H
#define ADD_Htemplate<typename T>
T Add(T a, T b)
{return a + b;
}#endif
// main.cpp
#include "Add.h"
#include <iostream>int main()
{std::cout << Add<int>(1, 2) << std::endl;std::cout << Add<double>(1.1, 2.2) << std::endl;return 0;
}
这种做法虽然可能会导致一些代码冗余,但对于大多数情况来说,是最简单有效的解决方案。
四、总结:模板的优缺点与应用场景
优点
-
代码复用:通过模板,我们能写出通用的代码,适用于多种类型,大大提高了代码的复用性。
-
灵活性:模板让我们能根据不同的类型进行不同的操作,增强了代码的灵活性。
-
性能:模板在编译时期就确定了类型,所以不会像运行时的多态那样有额外的性能开销。
缺点
-
代码膨胀:每个模板实例化都会生成一份代码,可能会导致生成的可执行文件体积变大。
-
编译时间:复杂的模板代码可能会增加编译时间。
-
错误信息难以理解:模板相关的编译错误信息通常比较复杂,可能会让开发者难以快速定位问题。
应用场景
-
通用算法库:像 STL 这样的标准模板库,利用模板实现了各种通用的算法和数据结构。
-
类型安全的容器:模板能让容器类在编译时期就确定存储的类型,提高了类型安全性。
-
自定义通用工具:在项目中实现一些通用的工具类或函数,提高开发效率。