前言
本教程面向想要进一步了解C++模板的、已经入门了的人。这里的入门指已经了解了C语言语法,包括变量定义、判断语句、循环语句、函数定义、指针、结构体、类、多文件编译等,起码不会分不清int(*a)[3]和int *a[3]。
类与模板,是C++的两个基石,是C++真正高级的部分。但是模板的思维与语法都略显抽象,需要仔细理解,写出的代码也是出了名的抽象。希望各位能通过本教程,早日写出抽象代码。
在教程之前,回顾一下C++编译的过程:源代码-预处理-编译-可执行文件,预处理过程会把预处理指令(#include, #if, #define等)执行一遍,然后再进入编译期。模板的大部分事情都是和编译期相关的。
1. 模板入门:类似define的东西?
回顾一下,#define是宏定义,作用约等于查找+替换。
比如,#define ONE 1, 就会把所有文件里出现的ONE换成1,然后再编译。
比如,#define ADD(a,b) a+b,就会把所有类似ADD(甲, 乙)的东西换成甲+乙,然后再编译。也就是3*ADD(1,2)*5会被换成3*1+2*5,然后再编译,最后结果是13。
1.1 模板基础语法
模板是一个类似于#define的东西。让我们先来看一段模板的基本代码:
#include<iostream>
using std::cout;
using std::endl; //写项目尽量不要using namespace std
template <class T> //模板参数声明
T add (const T &a, const T &b) {
return a+b;
}
int main (){
cout<<add<int>(1,2)<<endl;
return 0;
}
在上面这段代码里,<int>就是在传模板参数。
编译add<int>的时候,会把所有T换成int编译出来一个函数(假设我们叫他add_int),然后再调用add_int(1,2):
int add_int (const int &a, const int &b){
return a+b;
}
所以,如果我们写add<double>(1,2),就会得到一个浮点数结果(double)3。因为会把T换成double编译出一个函数,然后再调用。
模板参数除了传类型,还可以传编译期期间能确定的常量。这种常量,要么是1这种字面量,要么是定义时注明了constexpr的变量。
下面的代码里,模板参数是int N,需要传进去一个编译期期间能确定的int量,比如10。
template<int N>
int f(){
return N;
}
int main(){
cout << f<10>() << endl;
//输出10
return 0;
}
比较经典的“动态数组”类,std::vector,就是模板类。我们在声明变量的时候需要把数组类型写进去:
vector<int> intArray{1,2,3};
vector<double> doubleArray{1., 2., 3.};
总结,模板的基础语法就是:
- 在声明函数/结构体/类/inline全局变量的时候,在声明前加上template<模板参数>,就是模板的定义方式;
- 在调用函数/类声明的时候,我们除了正常声明以外,还要在模板函数名/类名后面加上尖角号<>,将模板参数传递进去;
- 模板参数必须是编译期期间可以确定的量,比如类名和编译期期间能确定的常量;
tips: 与#define的区别
把T换成double,有点像#define的替换操作。但是模板参数显然会限制,而且不是字面替换。
add<decltype(1)>和add<int>是等价的,都是add<int>;
如果#define S(a) a,显然S(3)*4和S(1+2)*4是不等价的,因为后者会被替换成1+2*4
1.2 模板参量的推导
调用的时候,你不必把所有模板参量都写在尖角号内。以vector为例,你可以这样写:
vector a {1,2,3};
由于1,2,3都是int类型,编译器会自动补全,把vector的模板参数补成<int>。
实际上去看C++标准库,vector的模板参数不止2个,只是后面的参数像函数参数默认值一样,给了个模板参数默认值。只有第一个参数是强制填的。但是这个第一个参数可以推导,所以有的时候也不用填。
模板的很多操作就建立在模板参数推导上。
例如,我们可以在把数组作为函数参数传递时,获得数组的长度:
using size_t = unsigned long long;
template<class T, size_t N>
void printArray ( T (&arr) [N]){
for(size_t i=0;i<N;++i)
cout<<arr[i]<<endl;
}
int main(){
int arr[10]={1,2,3,4,5,6,7,8,9,10};
printArray(arr);//这里会推导成printArray<int,10>(arr);
return 0;
}
额,你可能已经不知道T(&arr)[N]是什么了。好在我这里有一份变量起名原则,忘记的可以来复习一下。总之这个变量arr是一个长度为N的数组的引用, 是reference of a N-sized array of T.
printArray的模板参数在这里被推导为<int, 10>, 这是因为arr的引用只能是int(&)[10]类型。这个函数可以用来输出任意一个C语言的数组变量。
为什么参数是这个呢?因为首先,参数不能传数组,但是可以传引用、指针。而传指针的话就得不到长度信息了,所以传一个数组的引用。
总结:模板的参数可以被推导出来,不必都写出来,只有推导不出来的需要被填写。 一般我们将能推导出来的参数放在后面,推导不出来的放在前面。
1.3 模板与类
1.3.1 成员函数的类外定义
在数据结构课上,写动态数组的时候,我们经常需要在类外定义成员函数:
template<class T>
class Array{
//...
size_t length() const;
};
template<class T>
size_t Array<T>::length() const {return this->len;}
在有模板的情况下,类外定义成员函数必须先写模板,再把模板参数填进去定义成员函数。为什么?
因为模板就是模板,只有填入了模板参数才会被编译出一个类/函数。
正常来讲,类外定义成员函数的语法是: 返回类型 类名::函数名(…){…},但是在上述代码里,Array并不是类名,只是个模板。只有填入参数后,Array<int>, Array<double>, 这才算真正的类。那么他们对应的类外成员函数length的定义也就应该是:
size_t Array<int>::length() const{...}
size_t Array<double>::length() const{...}
然而你总不能每一个都写出来吧,所以借助模板,我们应该把这些类的类外成员函数定义写成:
template<class T>
size_t Array<T>::length() const {...}
这样当你需要调用Array<int>::length的时候,上面这个模板就会把T换成int编译一个版本出来。调用Array<double>::length的时候,T换成double编译一个版本出来……
这就是类外如此定义的原因。
1.3.2 为什么模板类的类外函数定义不能分文件写?
首先复习#include的作用。它的作用就是把#include后面的那个文件里的所有内容复制粘贴到这行指令这里。是的,没有任何其他的操作,没有类似python、java那样的包管理,就是单纯的把头文件的内容粘过来。所以你编译main.cpp的时候,会先把#include的东西粘过来、#define的东西替换掉,得到的这一坨代码进行编译。
然后就能开始正题了。回顾上一节的一句话:模板就是模板,只有填了参数后才能被编译出对应的函数/类。
那么,写在其他cpp文件里的类外函数定义的模板不会被#include进main.cpp。一般咱们都是include头文件,不会include一个cpp文件。
那么,其他cpp文件里的模板,也就不知道main里到底填了什么参数。不知道填了什么参数,也就不会被编译。不被编译就不会被链接,编译器就会报undefined reference。
为此,c++弄了个后缀名是hpp的东西,意思就是.h和.cpp合体,为模板类而生。啊当然后缀名都是随便起的,你写.h也一点问题没有。看个人喜好,我个人还是喜欢.h和.cpp一起用。
1.4 using与constexpr static
前面几节内容是大学教学考试范围的极限了,没理解的一定要理解。这节补充一个基础语法,struct内using和static constexpr
- static constexpr int不用多说,字面意思,就是类的静态常量成员(constexpr表示编译期可定)。
- using type = int其实也可以写成typedef int type。这么写完之后,T::type就是int。但是有的时候编译器会不知道你这个T::type究竟是类型名字还是是static成员,所以把它作为类型名的时候,要在前面加一个typename注明T::type是个类型名。
- using的时候也可以用模板
示例代码:
struct T{
using type = int;
constexpr static int value = 10;
};
int main(){
typename T::type a = T::value;
cout<<a<<endl;
}
模板类套模板的时候,我们可以写上template标注,类似typename.
template<unsigned N>
struct Test{
template<class T>
using type = T[N];
};
template<class T, unsigned N>
using Array = typename Test<N>::template type<T>;
Array<int,3> arr{1,2,3}; //aka int arr[3];
标准库里,往往会声明一个struct xxx,然后类外声明一个xxx_t
template<class T>
using xxx_t = typename xxx<T>::type;
或者xxx_v
template<class T>
inline constexpr auto xxx_v = xxx<T>::value;
这样,xxx_t<int>就是一个类型, xxx_v<int>就是一个值。
我们写代码的时候也可以沿用这个起名规则。
1.5 偏特化与SFINAE原则
偏特化是模板神奇操作里很重要的操作。它的基础理念是,模板是泛型,是大家都一样,但是总有需要特事特办的一些类。那么怎么办呢?我们偏特化。
1.5.1 偏特化语法
偏特化的语法就是:在基础定义之后,针对某些特殊情况,重新定义。重新定义时,要在声明模板时,把特殊情况的模板参数填上。
template<class T,class S> struct Test{};
template<>
struct Test<char, int>{};//合法
template<class T>
struct Test<char, T*>{};//合法
template<class T, class S>
struct Test<T, S>{}; //这是不合法的
上面给出了几个偏特化的合法定义的例子,template<>不能省。同时要注意,偏特化填入的模板参数(不是偏特化时的template后面的东西,是偏特化时填在Test后面的东西),一定要比没有偏特化的特殊。
1.5.2 举例:判断类型相同、判断是否是指针类型
偏特化有着很广泛的应用,并不是朴素的特事特办。
比如,我们怎么判断两个类型相同?模板的解决思路是这样的:
template<class T1, class T2>
struct is_same{
static constexpr int value = 0;
};
template<class T>
struct is_same<T,T>{
static constexpr int value = 1;
};
int main(){
cout<<is_same<int, int>::value<<endl; //1
cout<<is_same<int, double>::value<<endl; //0
return 0;
}
先看第一个模板,很好解释,就是定义了一个叫做is_same的结构体,就包含一个static成员value=0。这个模板接受两个类型作为参数。
关键在于第二个模板,第二个模板是偏特化。也就是,针对T1和T2相同的情况,我们重新定义is_same这个结构体。改成了就包含一个value是1的结构体。
如此,T1,T2不同的时候,is_same<T1,T2>会落到第一种定义里,基础定义,value是0。T1,T2相同的时候,is_same<T1,T2>会落到第二种定义,value是1。这样就能判断两种类型是否一样啦。
想一想,如何判断一个类型是否是指针呢?答案如下:
template<class T>
struct is_pointer{static constexpr bool value=false;};
template<class T>
struct is_pointer<T*>{static constexpr bool value=true;};
1.5.3 SFINAE原则
全称是匹配失败不是一个错误原则。比如上面这个判断是否是指针类型的例子,对于is_pointer<int>, 它是绝无可能匹配上偏特化的模板的。那么匹配不上这个模板是错误吗?不是。因为我们还可以匹配第一个模板。
只要有一个能匹配上,那就不是错误,都匹配不上才有问题。
1.6 模板参数包
这节介绍C++版本的函数不定参数。如果不会C语言版本的,,,那也可以先不会,反正基本不在C++里用。
不定参数的模板写法如下:
template<class ...Args>
int sum(int first, Args... args){
return first+sum(args...);
}
template<>
int sum (int a) {
return a;
}
int main(){
cout<<sum(1,2,3)<<endl;
return 0;
}
在低版本C++中,我们不能直接通过args[0]来获取不定参数里的第1个参数,不能下标获取。这就使我们即使写一个简单的sum都无法很容易地实现。那么我们具体应该怎么展开?怎么利用C++的语法获得不定参数呢?走进参数包吧~
1.6.1 参数包
上述代码里,Args是模板参数包,打包了一堆类型。比如上述代码里的sum(1,2,3), 会被推导为sum<int, int>(1,2,3)。即Args这个参数包的内容是<int,int>. 在这个模板里,“Args…” 这个表达式会像#define那样,被替换成“int,int”,再进行编译。
注:函数参数声明Args…args这里会特殊处理args。sum<int,int>的类型,就是“Args…”被替换成“int,int”之后的函数类型,即int(int,int,int)。
参数表里的Args…args定义了实参的参数包args. 在调用sum<int, int>(1,2,3)时,first是1, args打包了(2,3). 类似于“Args…”会被替换成“int,int”,“args…”也会被替换成“2,3”。注意是“args…”,不是“args”。
我们可以用 sizeof…(Args) 来获取Args打包的东西的个数,sizeof…(args)获取args打包的东西的个数。虽然Args和args不一样,但是sizeof…(Args)和sizeof…(args)基本不会不一样。sum(1,2,3)里,sizeof…(Args)是2。
注:和#define的替换不同的是,模板的替换是用结果替换,而不是像#define那样原封不动的替换。也就是sum(1,2+1,3+1)等价于sum(1,3,4),不会出现3*ADD(1,2)*5的“意外”。
这样就能理解sum函数的运行逻辑了:sum(1,2,3)函数会返回1+sum(2,3), sum(2,3)会返回2+sum(3), sum(3)是3。递归结束。
1.6.2 递归展开
就像上面给的sum的例子一样。用偏特化+参数包,我们只需要写递归过程+偏特化递归终点两个模板,即可达到使用不定参数的目的。
1.6.3 深刻理解…的展开艺术
先上结论:…会把 …前面的那一个 含args表达式 展开。
一般有下面这几种形式:
- 序列的展开。也就是前面说的args…替换成2,3。在下面的代码里,我们用std::vector<int> a{args…}来声明了一个变量a。调用func(1,2,3)的时候,实际上这句话会先被变成std::vector<int> a{1,2,3},然后再被编译。
template<class ...Args>
void func(Args... args){
std::vector<int> a{args...};
}
- 给函数传参。在下面的代码里,我们用func1((args+1)…)来进行展示。调用func(1,2,3)的时候,这段代码会被替换成func1(2,3,4),然后再被编译。没错,这里…展开的那个表达式是(args+1),也就是func1((args+1)…)会被展开为func1(args里第一个+1,第二个+1,第三个+1, …);
template<class ...Args>
void func(Args... args){
func1((args+1)...);
}
- 二元运算符结尾的表达式。在这里,…的展开不再是补逗号了,而是补你最后限定的那个符号。
- 来看下面例子里的(args + …)。这可不是args和…相加,而是…把“args+”展开了。也就是,调用func(1,2,3)的时候,这句话会被展开为int b = 1+2+3;
- 后面那个lambda表达式也是会被展开成一个个lambda表达式被调用、中间用“,”相连(因为表达式结尾是逗号)
- 如果写一个int c = (args - …),猜猜结果会是什么?是 1 − 2 − 3 1-2-3 1−2−3还是 1 − ( 2 − 3 ) 1-(2-3) 1−(2−3)?自己试一试吧!
template<class ...Args>
void func(Args... args){
int b = (args + ...);
([&args](){
printf("lambda print:%d\n", args);
}(), ...);
}
你甚至可以在继承的时候展开,毕竟Args…就是int,int,int么:
class A{};
class B{};
template<class ...Bases>
class T:public Bases...
{};
T<A,B> obj; //T继承自public A, public B
2. 模板实操:理解enable_if
在偏特化那一节我们知道,有的时候我们希望我们的模板,对满足某些条件的类型能够进行特殊处理。比如有的时候我们可能会对指针类型进行处理。但是,如果条件更复杂一些呢?难道我们要把所有需要被特殊处理的都穷举一遍吗?
std::enable_if说,不用。让我们一起来了解一下enable_if吧!
2.1 enable_if的声明
enable_if的声明及其简单,就是一个模板+一个偏特化:
template<bool _Test = false, class T=void>
struct enable_if{};
template<class T>
struct enable_if<true, T>{
using type = T;
};
template<bool _Test, class T>
using enable_if_t = typename enable_if<_Test, T>::type;
显然,模板参数就俩,一个bool和一个类型。如果bool值为true,则有成员type。如果bool值为false,就没有成员type,那么在用std::enable_if_t<_Test, T>::type的时候就会出错,编译不成功。
这就是enable_if的工作原理。
2.2 enable_if的使用
加入我现在有个基类A:
class A{};
我现在只想让我的模板对继承自A的类起效果,C++提供了一个模板来判断某类是否是某类的基类std::is_base_of_v<Base, Derived>,如果Base是Derived的父类,那么该模板值为true,否则为false。
我可以结合这个,与enable_if一起使用。
template<class T>
void func(
std::enable_if_t<
std::is_base_of_v<A, T>, T
> arg
){...}
如果A是T的父类,is_base_of_v值为true,enable_if就不会报错。否则就会报错,因为没有::type, enable_if_t的定义就不对了。
2.3 concept(requires我还没学会)
步入C++20,我们可以摒弃这一长串恶心的enable_if了
我们可以定义一个concept,后面跟一个bool表达式。
template<class T>
concept derived_from_A = std::is_base_of_v<A, T>;
然后在模板定义的时候,用这个concept去限制,而不是enable_if
template<derived_from_A T>
void func(const T &){...}
作用和enable_if类似,都是这个bool表达式需为true才可。
注意,concept可以传多个模板参数。用concept定义的时候,后面那个T作为concept第一个参数。如果除了这个参数以外,concept没别的参数了,那就可以不填
template<class T1, class T2>
concept SameAs = std::is_same_v<T1, T2>;
template<SameAs<int> T>
void func(const T &){...} //for concept, T1=T, T2=int
结语
本教程仅是简单的一个模板入门。实际上,以此教程涉及的内容为基础的模板编程用途十分广泛,本教程仅仅展示了冰山一角。C++语言的许多设计,如std::move, std::tuple等都离不开模板操作。希望诸位一起,继续探索C++模板的奥秘!