🌇前言
模板作为搭建STL
的关键工具以及泛型编程思想的核心体现,对提高程序灵活性和推动高效迭代开发具有重要意义。除了基本的类型替换功能外,模板还具备如非类型模板参数、全特化、偏特化等高级操作。同时,模板声明与定义不能分离的问题也值得深入探讨。
🏙️正文
1、非类型模板参数
- 1.1 使用方法
- 以往模板参数多是用于匹配不同类型,如
int
、double
、Date
等。但实际上模板参数还可匹配常量(非类型),用于确定如数组、位图等结构的大小。定义非类型模板参数时,不再使用class
或typename
,而是直接使用具体类型,如size_t
。需注意,非类型模板参数必须为常量,且在编译阶段确定值。 - 例如,利用非类型模板参数定义一个大小可自由调整的整型数组类:
- cpp
- 以往模板参数多是用于匹配不同类型,如
template <size_t N>
class arr
{
public:
int& operator[](size_t pos)
{
assert(pos >= 0 && pos < N);
return _arr[pos];
}
size_t size() const
{
return N;
}
private:
int _arr[N];
};
在main
函数中可以这样使用:
- cpp
int main()
{
arr<10> a1;
arr<20> a2;
arr<100> a3;
cout <<"a1 size():"<<a1.size() <<endl;
cout <<"a2 size():"<<a2.size() <<endl;
cout <<"a3 size():"<<a3.size() <<endl;
return 0;
}
输出结果为:
a1 size():10
a2 size():20
a3 size():100
进一步地,如果再加入一个模板参数(类型),就可得到一个泛型且大小可自定义的数组:
- cpp
template <class T, size_t N>
class arr
{
public:
T& operator[](size_t pos)
{
assert(pos >= 0 && pos < N);
return _arr[pos];
}
size_t size() const
{
return N;
}
private:
T _arr[N];
};
- 使用示例:
- cpp
int main()
{
arr<int, 10> a1;
arr<double, 20> a2;
arr<char,100> a3;
cout << typeid(a1).name() <<endl;
cout<<typeid(a2).name()<<endl;
cout<<typeid(a3).name()<<endl;
return 0;
}
-
非类型模板参数还支持缺省,例如:
template <class T, size_t N = 10>
。 -
1.2 类型要求
- 非类型模板参数要求类型为整型家族,其他类型不符合标准。
- 例如:
- cpp
//整型家族(部分)
template <class T, int N>
class arr1 { /*……*/ };
template <class T, long N>
class arr2 { /*……*/ };
template <class T, char N>
class arr3 { /*……*/ };
- 而使用其他家族类型作为非类型模板参数会引发报错,
- 比如:
- cpp
//浮点型,非标准
template <class T, double N>
class arr4 { /*……*/ };
会出现错误提示:浮点模板参数是非标准的(在 C++20 标准中或许有相关引入,但部分编译器可能仍不支持)。
-
总结来说,非类型模板参数只能将整型家族类型作为参数,且必须为常量,在编译阶段确定结果。整型家族包括
char
、short
、bool
、int
、long
、long long
等。 -
1.3 实际例子:array
- C++官网:array - C++ Reference (cplusplus.com)
- 在
C++11
标准中,新容器array
使用了非类型模板参数,是一个真正意义上的泛型数组,用于对标传统数组。 - cpp
#include <iostream>
#include <cassert>
#include <array>
using namespace std;
int main()
{
int arrOld[10] = { 0 };
array<int, 10> arrNew;
//与传统数组一样,新数组未初始化
//新数组越界读、写检查更严格
arrOld[15];
arrNew[15];
arrOld[12] = 0;
arrNew[12] = 10;
return 0;
}
array
是泛型编程思想的产物,支持STL
容器的一些功能,如迭代器和运算符重载等,主要改进是严格检查越界行为。不过在实际开发中,由于其对标传统数组且连初始化都没有,功能和实用性上不如vector
,并且使用栈区空间存在栈溢出问题,所以使用相对较少。其严格检查越界行为是通过在进行下标相关操作前,对传入的下标进行合法性检验实现的,如assert(pos >= 0 && pos < N)
。
2、模板特化
-
2.1 概念
通常模板可实现与类型无关的代码,但在某些场景中,泛型无法满足精准需求,会引发错误。例如使用日期类对象指针构建优先级队列时,若不编写对应的仿函数,比较结果会是未定义的。
例如:
如果传递的不是指针是正常的:
如果传的是地址的话,地址每一次都不一样,不能满足需求;
模板特化就是在原模板基础上进行特殊化处理,创造出符合特定需求的 “特殊” 模板。
-
2.2 函数模板特化
- 函数模板也支持特化。例如下面的比较函数,如果不进行特化,对于字符串比较会出现错误结果:
- cpp
template <class T>
bool isEqual(T x, T y)
{
return x == y;
}
int main()
{
int x = 10;
int y = 20;
cout << "x == y: " << isEqual(x, y) << endl;
char str1[] = "Haha";
char str2[] = "Haha";
cout << "str1 == str2: " << isEqual(str1, str2) << endl;
return 0;
}
- 原因是字符串比较时,泛型比较的是地址而非内容。解决方法是利用模板特化为字符串比较构建特殊模板:
- cpp
//函数模板特殊,专为char*服务
template <>
bool isEqual<char*>(char* x, char* y)
{
return strcmp(x, y) == 0;
}
- 2.3 类模板特化
- 类模板特化可解决大部分特殊问题,分为全特化和偏特化。
- 2.3.1 全特化
- 全特化是将所有模板参数特化为具体类型,全特化后的模板在调用时会优先被选择。例如:
- cpp
//原模板
template <class T1, class T2>
class Test
{
public:
Test(const T1& t1, const T2& t2)
: _t1(t1), _t2(t2)
{
cout << "template<class T1, class T2>" << endl;
}
private:
T1 _t1;
T2 _t2;
} ;
//全特化后的模板
template <>
class Test<int, char>
{
public:
Test(const int& t1, const char& t2)
: _t1(t1), _t2(t2)
{
cout << "template<>" << endl;
}
private:
int _t1;
char _t2;
} ;
int main()
{
Test<int, int> T1(1, 2);
Test<int, char> T2(20, 'c');
return 0;
}
在进行全特化前需要存在基本的泛型模板,全特化模板中的模板参数可以不写,但要在类名之后指明具体参数类型,否则无法实例化对象。
- 2.3.2 偏特化
- 偏特化是将泛型范围进一步限制,可以限制为某种类型的指针或具体类型。
- 例如:
- cpp
//原模板---两个模板参数
template <class T1, class T2>
class Test
{
public:
Test()
{
cout << "class Test" << endl;
}
} ;
//偏特化之一:限制为某种类型
template <class T>
class Test<T, int>
{
public:
Test()
{
cout << "class Test<T, int>" << endl;
}
} ;
//偏特化之二:限制为不同的具体类型
template <class T>
class Test<T*, T*>
{
public:
Test()
{
cout << "class Test<T*, T*>" << endl;
}
} ;
int main()
{
Test<double, double> t1;
Test<char, int> t2;
Test<Date*, Date*> t3;
return 0;
}
偏特化在泛型思想和特殊情况之间做了折中处理,在进行偏特化前需要存在基本的泛型模板,且要注意与全特化区分。
3、模板的分离编译问题
- 3.1 失败原因
- 当模板声明与定义分离后,在链接时无法在符号表中找到目标地址进行跳转,从而导致链接错误。
- 例如,当模板声明与定义写在同一个文件中时,如
Test.h
和main.cpp
:Test.h
文件内容:
cpp
#pragma once
//声明
template <class T>
T add(const T x, const T y);
//定义
template <class T>
T add(const T x, const T y)
{
return x + y;
}
main.cpp
文件内容:- cpp
#include <iostream>
#include "Test.h"
using namespace std;
int main()
{
add(1, 2);
return 0;
}
可以正常运行。但当声明与定义分离时,编译器无法确定函数原型,无法生成函数,也就无法获得函数地址,在符号表中进行函数链接时必然失败。
- 3.2 解决方法
- 解决方法有两种:
- 在函数定义时进行模板特化,编译时生成地址以进行链接,但如果类型较多,不推荐这种方法,因为需要特化很多份。
- 例如:
- cpp
- 解决方法有两种:
//定义
//解决方法一:模板特化(不推荐,如果类型多的话,需要特化很多份)
template <>
int add(const int x, const int y)
{
return x + y;
}
- 模板的声明和定义不要分离,直接写在同一个文件中。例如:
- cpp
//定义
//解决方法二:声明和定义写在同一个文件中
template <class T>
T add(const T x, const T y)
{
return x + y;
}
这也是为什么涉及模板的类,其中的函数声明和定义通常写在同一个文件(.h
)中的原因,如STL
库中的代码。为了区分,也可将头文件后缀改为.hpp
,如Boost
库中的一些命名方式。
4.模板中必须使用typename关键字而非class的场景
一、模板中的依赖名称
当在模板中使用一个依赖于模板参数的名称,且这个名称可能是一个类型也可能是一个值时,编译器无法确定它到底是不是一个类型。
- 例如:
- cpp
template<typename T>
void func(T t) {
T::iterator it; // 这里编译器不知道 T::iterator 是不是一个类型
//T::iterator 可能是T类中的静态变量,也有可能是一个类(类部类)
}
在这种情况下,如果T::iterator
确实是一个类型,就必须使用typename
来明确告知编译器:
- cpp
template<typename T>
void func(T t) {
typename T::iterator it; // 使用 typename 明确表示这是一个类型
}
二、避免二义性
使用typename
可以避免与class
关键字可能引起的二义性。当使用class
时,编译器可能会将其理解为一个类名的定义,而不是表示一个类型。
- 例如:cpp
template<typename T>
class MyClass {
public:
void doSomething() {
typename T::SomeType st; // 使用 typename 明确类型
// 如果这里使用 class,可能会被误解为类的定义而不是类型
}
};
总之,在 C++ 模板中,为了明确表示一个依赖于模板参数的名称是一个类型,应该使用typename
而不是class
,以避免编译器的不确定性和二义性。
5、模板小结
- 模板是
STL
的基础支撑,具有诸多优点。- 它复用了代码,节省资源,推动了更快的迭代开发,是
C++
标准模板库(STL
)产生的基础。 - 增强了代码的灵活性。
- 它复用了代码,节省资源,推动了更快的迭代开发,是
- 同时也存在一些缺点。
- 模板会导致代码膨胀,增加编译时间。
- 出现模板编译错误时,错误信息复杂,不易定位。
🌆总结
本文详细介绍了 C++ 模板进阶的相关内容,包括非类型模板参数、模板特化以及模板的分离编译问题。非类型模板参数可用于确定结构大小,有特定的使用方法和类型要求;模板特化分为函数模板特化和类模板特化,可解决泛型无法满足的特殊需求;模板声明与定义分离会导致链接错误,可通过特化或不分离来解决。总之,模板虽有优缺点,但合理使用可使代码更灵活、更优雅。后续还会继续探索C++
进阶内容,如继承、多态、高阶二叉树等知识点。