【C++】模板进阶
在学习本篇内容前,建议大家先去看一下这篇文章 -> 【C++】模板初阶
一、非类型模板参数
1、基本介绍
模板参数分为两类,分别是类型模板参数和非类型模板参数。
类型模板参数:出现在模板参数列表中,跟在class或者typename之后的参数类型名称。
非类型形参:用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常
量来使用。
举个例子:
template <class T,size_t N>
class Stack
{
private:
T _a[N];
size_t _size;
size_t capacity;
};
第一个模板参数(T)是类型模板参数,第二个模板参数(N)是非类型模板参数。
非类型模板参数是用来定义常量的,它可以直接作为数组的大小(它定义的是常量)。因为模板在编译时就实例化了,所以在编译时就确定非类型模板参数就是常量。
其实宏也可以这样写:
#define N 10
class Stack
{
private:
int _a[N];
size_t _size;
size_t capacity;
};
但相比于宏来说非类型模板参数更加灵活,比如:
int main()
{
//宏定义下
Stack st1; //这里的栈st1固定10个元素
Stack st2; //这里的栈st2也固定10个元素
//非类型模板参数下
Stack<int, 5> st3;//这里的栈st3固定5个元素
Stack<int, 10> st4;//这里的栈st3固定10个元素
//在非类型模板参数下,可以控制每一个对象中元素的个数
//在宏定义下,每一个对象中元素的个数是固定的,不可改变
return 0;
}
非类型模板参数也有限制,它只支持整形:
整形包括:short、unsigned short、int、unsigned int、long、unsigned long、long long、unsigned long long、char。特殊的,也支持bool类型。
注意:
1. 浮点数、类对象以及字符串是不允许作为非类型模板参数的。
2. 非类型的模板参数必须在编译期就能确认结果。
但在C++20下,开始支持浮点数作非类型模板参数了。
非类型模板参数也支持有缺省值:
template <size_t N = 10>
class Stack
{
private:
int _a[N];
size_t _size;
size_t capacity;
};
int main()
{
Stack st1; //C++20及其之后的版本支持不加<>定义对象
Stack<> st2; //C++20之前必须加上<>,否则语法上会报错
return 0;
}
2、应用
在C++标准库中,array容器用到了非类型模板参数。(array是C++11才有的)
它的底层就是一个静态数组(用非类型模板参数非常适合),它是不支持扩容的。具体看->array文档
C++单独出了一个array其目的是替代静态数组。我们来看一下它们的区别:
#include <array>
int main()
{
array<int, 10> a1; //int类型数组,10个元素,默认不初始化
array<int, 20> a2; //int类型数组,20个元素,默认不初始化
int a3[10]; //int类型数组,10个元素,默认不初始化
return 0;
}
表面上它们只是表达的形式上不一样,其它地方没有什么区别,实际上,并不是这样。
array对于越界检查问题,比普通静态数组要更加严格:
#include <array>
int main()
{
array<int, 10> a1; //int类型数组,10个元素,默认不初始化
array<int, 20> a2; //int类型数组,20个元素,默认不初始化
int a3[10]; //int类型数组,10个元素,默认不初始化
//越界检查问题
//1、对于普通静态数组
//(1)越界读检查不出来
cout << a3[10] << endl;
//(2)越界写抽查
//静态数组在定义时会在末尾设置两个标识位,若在标识位上写,就会报错
//增加这两个标识位是为了防止越界写,编译器不会将后面全部设为标识位,它检查不完的
//我们通常越界都是发生在标识位上,所以这两个标识位还是挺有用的
//a3[10] = 1; //越界写,报错(标识位)
//a3[11] = 11; //越界写,报错(标识位)
a3[12] = 111; //越界写,不报错(不是标识位)
//2、对于array容器
//(1)越界读会检查
cout << a1[10] << endl; //err,运行时报错
//(2)越界写也会检查
//a1[10] = 1; //err,运行时报错
//a1[11] = 11; //err,运行时报错
a1[12] = 111; //err,运行时报错
return 0;
}
array容器为什么这么严格?因为它是自定义类型,它调用下标引用操作符时,此时下标引用操作符已经被重载了,那就会在operator[]中添加断言语句进行强制检查。
比如这样:
template<class T, size_t N = 10>
class Array
{
public:
T& operator[](size_t index) {
assert(index < N); //强制进行检查
return _array[index];
}
private:
T _array[N];
size_t _size;
};
那这时就会出现一个疑问,vector在越界时也能检查,vector还可以动态开辟,array可以做的事,vector都能做,那为什么不用vector代替array呢?
它们的本质区别是,array容器中的数据是在栈上的,vector容器中的数据是在堆上。在栈上开空间要比在堆上开空间效率更高。
二、模板的特化
通常情况下,使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些
错误的结果,需要特殊处理,比如:实现了一个专门用来进行小于比较的函数模板
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
bool operator<(const Date& d)const
{
return (_year < d._year) ||
(_year == d._year && _month < d._month) ||
(_year == d._year && _month == d._month && _day < d._day);
}
bool operator>(const Date& d)const
{
return (_year > d._year) ||
(_year == d._year && _month > d._month) ||
(_year == d._year && _month == d._month && _day > d._day);
}
private:
int _year;
int _month;
int _day;
};
// 函数模板
template<class T>
bool LessFunc(T left, T right)
{
return left < right;
}
int main()
{
cout << LessFunc(1, 2) << endl; // 可以比较,结果正确
Date d1(2024, 9, 27);
Date d2(2024, 9, 28);
cout << LessFunc(d1, d2) << endl; // 可以比较,结果正确
Date* p1 = &d1;
Date* p2 = &d2;
cout << LessFunc(p1, p2) << endl; // 可以比较,这里比较的是指针,指针的大小是不确定的,如果将d1和d2的定义顺序改变,结果会和现在的不一致,故结果错误
return 0;
}
LessFunc绝对多数情况下都可以正常比较,但是在特殊场景下就得到错误的结果。上述示
例中,p1指向的d1显然小于p2指向的d2对象,但是LessFunc内部并没有比较p1和p2指向的对象内
容,而比较的是p1和p2指针的地址,这就无法达到预期而错误。
此时,就需要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方
式。模板特化中分为函数模板特化与类模板特化。
1、函数模板特化
函数模板的特化步骤:
- 必须要先有一个基础的函数模板
- 关键字template后面接一对空的尖括号<>
- 函数名后跟一对尖括号,尖括号中指定需要特化的类型
- 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇
怪的错误
对LessFunc进行特化后的结果:
//函数模板
template<class T>
bool LessFunc(T left, T right)
{
return left < right;
}
//函数模板特化,专门针对Date*进行特化,前提:原函数模板必须存在
template<>
bool LessFunc<Date*> (Date* left, Date* right)
{
return *left < *right;
}
特化的意义是如果T是其它类型就调用函数模板,如果T是Date*就会调用函数模板特化。这里对Date*进行了特殊处理。
函数模板的特化会有许多坑,这里我们可以直接写一个函数来替代它的作用:
bool LessFunc(Date* left, Date* right)
{
return *left < *right;
}
函数模板的特化具体会有哪些坑,我们来一探究竟吧!
严格来说LessFunc应该这样写:
//T有可能是自定义类型,为了防止调用拷贝构造,参数位置添加引用,同时加上const防止被修改
template<class T>
bool LessFunc(const T& left,const T& right)
{
return left < right;
}
那么原来的函数模板特化就不行了,它没有和函数模板参数进行格式匹配,所以理应这样修改:
template<>
bool LessFunc<Date*>(const Date*& left,const Date*& right)
{
return *left < *right;
}
这样写看似格式匹配了,实则不然,函数模板中const修饰的是left和right,而特化后,const修饰的是*left和*right,所以 实际上应该这样修改:
template<>
bool LessFunc<Date*>( Date* const & left, Date* const & right)
{
return *left < *right;
}
当const遇到指针时一定要格外注意!
现在,再进行变换,这样调用:
int main()
{
Date d1(2024, 9, 27);
Date d2(2024, 9, 28);
const Date* p1 = &d1;
const Date* p2 = &d2;
cout << LessFunc(p1, p2) << endl;
return 0;
}
那它还是会调用函数模板,而不会调用特化,特化需要这样修改:
template<>
bool LessFunc<const Date*>(const Date* const& left, const Date* const& right)
{
return *left < *right;
}
这是因为我们之前特化的时Date*而不是const Date* 它们两个类型是不一样,所以要单独特化const Date*。
如果觉得理解困难,我们可以换一种形式来表达:
//函数模板
template<class T>
bool LessFunc(T const & left,T const & right) //针对函数模板特化这一部分这样写,平时尽量还是将const写到前面
{
return left < right;
}
//函数模板特化,特化Date*
template<>
bool LessFunc<Date*>( Date* const & left, Date* const & right)
{
return *left < *right;
}
//函数模板特化,特化const Date*
template<>
bool LessFunc<const Date*>(const Date* const & left, const Date* const & right)
{
return *left < *right;
}
将原函数模板中的const放在T和引用之间,这样就可以很好的理解,这种是写法是被允许的(虽然看起来有点怪),如果T是内置类型,我们通常是这样写的:
const int a = 10;
但是也可以这样写:
int const a = 10;
对于引用也是如此:
//通常写法
const int& ra = a;
//也可以这样写
int const& ra = a;
所以平时尽量不要去写函数模板特化,一不小心就掉进坑里面了,我们可以直接写一个重载函数来达到同样的目的:
bool LessFunc(Date* left, Date* right)
{
return *left < *right;
}
bool LessFunc(const Date* left, const Date* right)
{
return *left < *right;
}
注意:一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将该
函数直接给出。
该种实现简单明了,代码的可读性高,容易书写,因为对于一些参数类型复杂的函数模板,特化
时特别给出,因此函数模板不建议特化。
2、类模板特化
类模板在特化时分为全特化和偏特化两种。
类模板的特化步骤:
- 必须要先有一个基础的类模板
- 关键字template后面接一对尖括号<>,尖括号中是不需要特化的参数
- 类名后跟一对尖括号,尖括号中指定需要特化的类型
(1)全特化
全特化:将模板参数列表中所有的参数都确定化。请看代码样例:
//类模板
template<class T1, class T2>
class Data
{
public:
Data()
{
cout << "Data<T1, T2>" << endl;
}
private:
T1 _d1;
T2 _d2;
};
// 全特化
template<> //尖括号为空说明所有参数都要特化,即全特化
class Data<int, char>
{
public:
Data()
{
cout << "Data<int, char>" << endl;
}
};
int main()
{
Data<int, int> d1; //调用类模板
Data<int, char> d2; //调用类模板的特化
return 0;
}
运行结果:
(2)偏特化/半特化
①普通偏特化
偏特化也被称为是半特化,它们只是翻译不同。
偏特化:任何针对模版参数进一步进行条件限制设计的特化版本。请看代码样例:
//类模板
template<class T1, class T2>
class Data
{
public:
Data()
{
cout << "Data<T1, T2>" << endl;
}
private:
T1 _d1;
T2 _d2;
};
//偏特化/半特化
template<class T1>
class Data<T1, double>
{
public:
Data()
{
cout << "Data<T1, double>" << endl;
}
};
int main()
{
//只要第二个参数是double类型都会走偏特化
Data<char, double> d1;
Data<int, double> d2;
return 0;
}
运行结果:
我们接下来看另外一种情况:
//类模板
template<class T1, class T2>
class Data
{
public:
Data()
{
cout << "Data<T1, T2>" << endl;
}
private:
T1 _d1;
T2 _d2;
};
// 全特化
template<>
class Data<int, char>
{
public:
Data()
{
cout << "Data<int, char>" << endl;
}
};
//偏特化/半特化
template<class T1>
class Data<T1, char>
{
public:
Data()
{
cout << "Data<T1, char>" << endl;
}
};
int main()
{
Data<int, char> d1;
return 0;
}
这时d1会走全特化的构造函数还是偏特化的构造函数?
d1可以调用偏特化或者全特化的构造函数,但实际上它会选择全特化,如果选择偏特化,编译器还需要判断T1到底是什么类型,所以编译器会选择具体参数更多的来进行调用,就不需要判断T1的类型了,可以认为编译器是一个"懒虫",它也不想多做事情。
运行结果:
②特殊偏特化
我们直接看例子:
//类模板
template<class T1, class T2>
class Data
{
public:
Data()
{
cout << "Data<T1, T2>" << endl;
}
private:
T1 _d1;
T2 _d2;
};
//偏特化
//只要传的类型是指针就调用Data <T1*, T2*>
template <class T1, class T2>
class Data <T1*, T2*>
{
public:
Data()
{
cout << "Data<T1*, T2*>" << endl;
}
};
int main()
{
Data<int*, char*> d1;
Data<double*, float*> d2;
Data<short*, long*> d3;
return 0;
}
运行结果:
不管类型如何,只要是指针,都会走特化指针的类模板。
同样的,引用也是如此:
//类模板
template<class T1, class T2>
class Data
{
public:
Data()
{
cout << "Data<T1, T2>" << endl;
}
private:
T1 _d1;
T2 _d2;
};
//偏特化
//只要传的类型是引用就调用Data <T1&, T2&>
template <class T1, class T2>
class Data <T1&, T2&>
{
public:
Data()
{
cout << "Data<T1&, T2&>" << endl;
}
};
int main()
{
Data<int&, char&> d1;
Data<double&, float&> d2;
Data<short&, long&> d3;
return 0;
}
运行结果:
当然,也可能特化一个指针,一个引用,总之,可以据此随意发挥。
三、模板分离编译
看下面内容前,建议先看一下这篇文章 -> C语言编译与链接
分离编译:一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。
就如下代码所示,模板的声明与定义分离开,在头文件中进行声明,源文件中完成定义:
//Func.h
template<class T>
T Add(const T& left, const T& right);
//Func.cpp
#include"Func.h"
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);
return 0;
}
这样贸然的将模板的声明和定义分离,会发生链接错误,接下来具体分析:
在进行编译链接时会经过以下几个步骤:
1、预处理
预处理阶段会发生:头文件在源文件中展开、宏替换、条件编译、去掉注释...
经过以上步骤此时会生成Func.i 、Test.i文件
2、编译
编译阶段会发生:检查语法、生成汇编代码...
经过以上步骤此时会生成Func.s 、Test.s文件
3、汇编
汇编阶段会发生:将汇编代码转换成二进制代码...
经过以上步骤此时会生成Func.obj 、Test.obj文件、符号表:用来存储函数地址
4、链接
链接阶段会发生:将所有目标文件合并在一起生成可执行程序,并且把需要的函数地址等链接上...
经过以上步骤此时会生成xxx.exe / a.out
在链接之前,其它各个文件是不交互的,各干各的事情。
了解了以上4个步骤后,那为什么函数的声明和定义可以分离,而模板的声明和定义就不能分离呢?
这是因为在汇编阶段,每个obj文件会各自生成一个符号表,符号表中存储各自文件中函数的地址,而Func中只有模板,模板在未调用时是不会实例化的,也就是没有地址,故不会再符号表中,在链接过程中,Test文件中调用了函数模板,它会先在符号表中找到函数模板的地址,但符号表中并没有函数模板的地址,所以在链接阶段就会报错。
下面一幅图片,可能会有更加直观的感受:
那如何解决函数模板声明与定义分离的问题呢?
只有显式实例化可以解决,代码如下:
#include"Func.h"
template //为了与特化进行区分,这里不加<>
int Add(const int& left, const int& right)
{
return left + right;
}
这可以解决声明与定义分离带来的问题,但这种方式是不好的,如果调用Add时传double类型的实参,那么还要单独再写一个double类型的显式实例化,这样是麻烦的,所以不推荐这种写法。
最好的解决方法还是直接在头文件中定义。
这样的话,在编译阶段直接就在Test.cpp文件中将函数模板实例化了,生成对应的函数,在汇编阶段,符号表中就可以存放这个函数的地址,那么就可以正常的调用。
四、模板总结
优点:
- 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库(STL)因此而产生
- 增强了代码的灵活性
缺点:
- 模板会导致代码膨胀问题,也会导致编译时间变长
- 出现模板编译错误时,错误信息非常凌乱,不易定位错误
总的来说,模板的优点胜于缺点,模板的产生使C++变的多姿多彩。
五、结语
本篇到这里就结束了,主要讲了非类型模板参数、模板的特化、分离编译等相关知识点,希望对大家有所帮助,我们下篇文章再见!
原文地址:https://blog.csdn.net/weixin_74012727/article/details/142575414
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!