【c++深入系列】:万字详解模版(下)
🔥 本文专栏:c++
🌸作者主页:努力努力再努力wz
💪 今日博客励志语录:
成功没有标准答案,但坚持永远是必选项
★★★ 本文前置知识:
模版(上)
那么在之前的文章中我们展示了一些模版的用法,在这篇文章中,我会继续来补充模版的更多的细节
1.非类型模版参数
那么这里我们知道模版的出现支持了一种泛型编程的思想,那么所谓的泛型编程就会是用户不用再关注该函数涉及到的数据类型,而是关注代码的逻辑,从而避免了我们编写出很多逻辑冗余的代码,而编译器只需要根据我们的模版的实例化处识别出参数推导出模版参数的数据类型,然后再自己生成一份对应的带有具体数据类型的代码
而模版之所以能够实现泛型编程的核心便是这个模版参数,因为模版参数的具体的语义或者说其代表的数据类型编译器并不知道,那么这个模版参数的具体的语义的赋予则是在模版实例化处,那么我们可以将模版参数实例化成内置类型或者自定义类型,而这里c++的设计者还设计了一个特殊的模版参数,那么其特殊就特殊在于该模版参数是有具体的数据类型,比如int类型:
template<typename T,int N>
class container
{T arr[N];
}
那么看到这里,那么肯定会有一些读者会产生一个疑问,有的读者认为这个所谓的非模版参数是很鸡肋或者说没有意义,那么假设我定义的模板类有些变量的数据类型是固定的,比如是int或者double的话,那么我直接在模板类或者模版函数中显示定义不就完了吗。例如:
template<typename T>
class container
{int memeber1;double memeber2;T memeber3;
};
何必需要再画蛇添足去定义一个所谓的非模版参数呢,那么这里我们首先得注意一点,这个非模版参数,它不是你想固定哪个数据类型就固定哪个数据类型,而事实上,非类型模版参数能固定的数据类型只有整形,也就是char类型和int类型,而double或者float这种浮点数类型甚至自定义类型都是不被允许的,那么我们可以写一段简单的代码来实验一下:
#include<iostream>
using namespace std;
template<typename T, double N>
class node
{
public:T member1;
};
int main()
{const double k = 10.7;node<int,k> l1;l1.member1 = 10;return 0;
}
那么这里你可以猜一下为什么这里的c++的设计者要规定这里的非模版参数的数据类型只能是int或者char这样的整形,那么在之前我们定义模版参数或者模版类中,那么我们可能已经习惯定义了动态数组在模版类或者模版参数,但是不免有的用户会在模版类或者模版函数中定义静态数组,而我们知道静态数组的长度只能在编译器期间确定,所以静态数组的数组长度只能是由常量设置,我们当然可以在模版类或者模版函数中直接定义一个固定大小的静态数组,但是我们要知道的就是模版类或者模版函数可以被实例化多份,那么每一份实例中的静态数组中存放的元素的数量可能是不同的,所以就会面临如果我们在模版类定义的静态数组的长度过大,那么可能在有的模版类或者模版函数的实例中就会造成静态数组的空间的浪费,而同时如果定义的长度过小,由于静态数组不能扩容,那么如果存储的元素过多,那么可能会造成越界的问题,所以c++的设计者考虑到这个问题,那么就提供了非类型的模版参数,那么意味着我们可以为每一份模版类或者模版函数的实例,在实例化的时候提供一个非模版参数,那么该非模版参数就是用来设置合适容量的静态数组,所以自然这里的非类型模版参数的属性就得要求const常量属性,那么我们可以验证这个情况,我们可以尝试在模版类中或者模版函数中修改这个非类型模版参数,那么我们知道常量是只能读不能写的,那么如果编译器报错,那么就能验证非类型的模版参数的const属性:
#include<iostream>
using namespace std;
template<typename T, size_t N>
class Array
{
public:T arr[N];Array(){N =N- 5;}};
int main()
{const char N = 10;Array<int, N> l1;l1.arr[0] = 10;cout << l1.arr[0] << endl;return 0;
}
那么这里报了这个左值的错误,那么左值就是指在赋值运算符左侧位置的值,那么左侧位置的值要求可以被修改,而常量要求在定义处就得有初始值,并且在之后的下文中不能被修改,所以这里会报这样一个编译错误,因为我们尝试修改了已经初始化的常量
所以这里我们发现非类型的模版参数,那么它只能是常量属性的整形,那么之所以非类型模版参数要这么设置而不支持double或者自定义类型等,那么原因我们不难发现,非类型模版参数的应用场景其实非常的局限,就是应用在定义静态数组,并且在我们的stl库中还有这么一个容器,那么它就是array,而这个array底层实现就是一个静态的数组,那么这里就会用到非类型模版参数
并且这里的array的底层实现并没有提供构造函数,而编译器的默认构造函数对内置类型不会做任何初始化而对于自定义类型则会调用其默认的构造函数,所以这里array和我们的传统的定义的静态数组相比,那么首先大家都要显示的手动定义,也就是要指定存储的数据类型和静态数组容量,并且这里和传统数组一样,那么不会对数据进行初始化,所以说这里的array相比于传统数组其实并没有什么大的优化,唯一不同的一点就是这里的array的下标访问运算符[]重载函数会检查索引是否越界,而编译器则一般不会检查普通的静态数组的越界问题,那么我们写一个简单的代码来验证这个情况:
#include<iostream>
#include<array>
using namespace std;
int main()
{const int N = 10;array<int, N> l1;l1[0] = 1;cout << l1[0] << endl;l1[11] = 1;cout << l1[11] << endl;return 0;
}
而我们知道stl还有一个容器底层实现也是数组,那么其就是vector,那么vector不仅可以进行元素的初始化,也可以满足索引的越界检查,并且其还支持动态扩容,而如果读者之间模拟实现过vector,那么知道vector底层其实是维护三个成员变量也就是三个迭代器本质就是原生指针,其指向动态数组的起始位置与有效数据末尾和动态数组的末尾,而array的成员变量则是一个静态数组,那么意味着vector对象的空间消耗还没有array大,所以这里总体评价array其实是一个很鸡肋的容器,而这里的非类型模版参数也比较冷门,因为一般大家不会在模版中定义静态数组,所以不怎么会用到非类型模版参数,但是这里还是给读者介绍了解一下
2.typename关键字
这里在正式介绍typename关键字的时候,那么我们还是先引入一个场景,假设你现在要编写一个print函数,那么该函数就是打印容器里面存放的所有元素,那么由于我们不确定用户调用该函数具体要打印的是什么类型的容器,所以自然而然你会想到将该函数定义为模版函数,那么容器本质就是一个自定义类型的对象,那么到时候,我们只需要根据模版函数的实例化处,编译器识别传递给该模版函数的参数推导出模版参数的数据类型从而生成一份对应的函数的代码,而其中函数体的内容就是我们用迭代器来依次遍历打印容器里面的元素,那么stl的容器都做了统一规定,都实现了迭代器并且将其作为外部接口来访问容器内的元素
#include<iostream>
#include<vector>
using namespace std;
template<typename container>
void print(container l1)
{container::iteratoer it = l1.begin();while (it != end){cout << *it << " ";++it;}cout << endl;
}
int main()
{vector<int> l1(10, 6);print(l1);return 0;
}
那么当我们运行上面的代码,我们发现,编译却无法通过,那么这里为什么编译无法通过呢,那么这里我们就要理解编译器生成模版的一个全流程,可以分为两大步骤,分别是模版的分析以及模版的实例化,那么在模版的实例化之前,那么编译器会先进行模版的分析
那么所谓的模版的分析就是检查该模版的定义,那么具体检查什么内容呢,由于我们可以在代码中定义多份模版类或者模版函数,那么意味着我们可以在模版中调用外部的模版函数或者定义外部的模版类对象,那么编译器就要确认这些内容,也就是查看该模版中依赖的外部的模版函数或者模版类,因为模版的实例化,如果你调用了外部的模版类或者函数,编译器会优先生成外部的模板类或者函数,而同理如果该模版依赖的外部的模板类或者模版函数内部还调用了外部的模版函数或者模版类,那么此时就会最先实例化最外部的模版函数或者模板类,再依次实例化内层的,所以编译器在模版的分析阶段会生成一个模版的实例化树,叶子节点就是依赖的最外层的模板类或者函数,那么采取递归的方式先到最底层,再往上依次实例化到根节点
所以这里当我们编写了上文所说的print函数,那么先进入模版的分析阶段,那么注意,模版的分析阶段编译器只能看到该模版函数的定义,此时它扫描检查的范围就只有该模版的定义,所以编译器看不到外部代码中的该模版函数的实例化点,从而无法进行模版参数的推导,那么意味着此时编译器它根本不知道模版参数container的具体的语义,其代表的具体是哪种数据类型,所以当它扫描到这行代码的时候:
container::iterator it=l1.begin();
那么编译器识别到container后面的域作用限定符:: ,而域作用限定符的使用的对象要么就是命名空间要么就是类,而这里由于container是作为模版参数,所以编译器知道这里container一定是一个类而不是命名空间,那么编译器此时就要分析域作用限定符之后的内容,也就是这个iterator,那么编译器此时就有三种解释,那么这个iterator可以是container类中定义的一个静态的成员变量:
template <typename T>
class container
{static T iterator;
};
template<typename T>
T container<T>::iterator;
那么访问一个静态成员变量的方式就是node1::iterator,但是这里iterator还可以被解释为container类中定义的内部类:
template<typename T>
class container
{class iterator{T member1;};
};
如果是内部类的话,那么这行代码会被解释为创建一个container的内部类对象it,而这里编译器还有第三种解释,那么就是iterator还可以是定义的外部类然后在container类中将其typedef为了iterator,所以这行container::iterator it,就可以视作定义container类中typedef的iterator类型的对象it
template<typename U>
class node
{};
template<typename T>
class container
{typedef node<T> iterator;
};
所以这里我们再来总结一下,那么这里编译器对于此时container后面的内容总共有三种解释,分别是静态的成员变量iterator,以及一个内部类iterator类型和一个外部类在container类中被typedef为了iterator类型
那么这三种解释我们可以分为两类,其中内部类类型和外部类被typedef的这两种解释可以归为一类,将其统一归为数据类型,而另一类解释则是解释静态成员变量,而如果按照第二类解释成静态成员变量的话,那么这里iterator就不需要后面的it,也就是后面的it就是多余的,就好比iterator后面的it就是我们学习英语语法的一个名词,那么名词前面应该跟的是形容词来修饰,也就对应这里的数据类型比如int或者double等,而这里的iterator本身就是名词,那么意味着就不需要后面这个多余的名词it,也就是两个只能存在一个,所以解释成静态成员变量的正确的写法应该是直接将右侧的赋值的内容交给左侧的静态变量iterator
container::iterator =l1.begin();
那么意味着这种解释,语法是无法通过,而编译器再进行模版分析阶段的时候,那么检查的范围就是模版函数或者模板类的定义部分,那么这里是无法去查看container类的内部的定义,所以编译器无法自己亲自确定这里iterator究竟是静态成语变量还是内部类还是外部类被typedef成iterator类型,其次这里container是模版参数,外部也没有定义,就算有,编译器也不会查看,所以这里的问题就是第一类解释与第二类解释发生了冲突,产生了二义性,那么如果是按照第一类解释,也就是解释成静态成员变量,那么是无法通过编译,而第二类解释就能通过编译的原因,就是如果这里是内部类的话,那么我们知道模板类中定义内部类的话,那么内部类会和外部的模板类会共用模版参数,同理如果是typedef了外部的模版类,那么同样该外部的模板类会接收所处的该模版类的模版参数,意味着也是和当前模版类共用模版参数
所以这里编译器如果知道当前container::iterator是类型的话,那么到时候编译器在实例化阶段的时候,获取了container的具体含义,那么此时不管是内部类还是被typedef外部类,那么已经实例化,那么编译器就能获取到了container模版参数对应的实例化的模板类的定义,那么编译器就能够一下区分出此时iterator是对应哪种情况,所以这两种情况不会造成冲突
解决方法就是这里需要在前面加上typename关键字,那么告诉编译器container::iterator整体是一个类型,至于具体是什么类型,是内部类类型还是被typedef的外部类型,到时候你实例化就知道了,但是你现在还没有实例化,无法持有该container模版参数的具体语义以及对应的定义,所以这里先不要报错
#include<iostream>
#include<vector>
using namespace std;
template<typename container>
void print(container l1)
{typename container::iterator it = l1.begin();while (it != l1.end()){cout << *it << " ";++it;}cout << endl;
}
int main()
{vector<int> l1(10, 6);print(l1);return 0;
}
所以这里的typename不仅可以用来声明模版参数还可以用来给编译器标识数据类型
补充:
那么在理解了typename关键字的另一个作用之后,那么这里我们再来引入另一个场景,那么这里你还是编写了一个模版函数,那么此时你要在该模版函数中创建一个嵌套的模板类对象,注意这里是嵌套的模板类而不是内部类,所谓嵌套的模板类就是在模板类中又定义一个模版类,并且假设嵌套的模板类是长这样:
template <typename T>
class node1
{
public:T member1;template <typename U>class node2{public:U member2;};
};
template <typename T, typename U>
void fun(T x1, U x2)
{........
}
那么有的读者,他创建嵌套的模板类对象的代码则是
node1<T>::node2<U> it;
那么根据我们上文所说的,那么编译器首先会进行模版的分析阶段,那么此时它首先会对该模版函数进行检查,检查该模版函数是否依赖于外部的函数模版对象,那么这里编译器当识别到这行代码的时候:node1< T >::node2 < U > it,那么这里编译器识别到了node1后面的模版参数,那么编译器知道这个node1是一个外部模版,而后面紧跟着的域作用限定符,那么就确定了这个node1是一个模版类而不是模版函数,而接下来后面的内容,那么这里面临和上文一样的问题,那么编译器此时对于node1< T >后面的内容有多种解释,那么第一种解释就是这里的node2是该node1模板类中中定义的静态成员变量,而第二种解释就是这里的node2是node1模版类中定义的内部类,第三种解释则是node2是node1中定义的嵌套的模板类,而第四种解释则是node1外部定义的一个独立的模版类,但是在node1中被实例化并且被typedef为了node2
//解释一
template<typename T>
class node1
{public:static T node2;
}
template<typename T>T node1<T>:: node2;
//解释2
template<typename T>
class node1
{public:class node2{T member;}
}
//解释三
template<typename T>
class node1
{public:template<typename U>{U member;}
}
//解释四
template <typename T>
class outer
{T member;
}
template <typename T>
class node1
{public:typedef outer<T> node2;
}
那么这里者四种解释同样可以分为两类,分别是静态成员变量和类型,那么我们由上文,我们就可以知道,这两类解释是会产生冲突,因为如果解释为静态成员变量,那么这里node2作为静态成员变量,那么后面的模版参数的符号< >,则会被解释为比较运算符,那么这里是无法通过编译的,所以这里为了避免这两种类型的冲突,那么由上文的讲解,我们知道应该在前面加typename关键字,避免被被解释为静态成员变量
typename node1<T>::node2<U> it;
但是我发现即使我们加了这个关键字,发现还是无法通过编译:那么这里就只能说明一个问题,也就是这里我们即使加了typename关键字,虽然避免了解释成静态成员变量的冲突,那么这里编译还报错,那么意味着那三种数据类型的解释,也就是解释成模板类的内部类和嵌套模板类以及外部类在模版中被实例化然后typedef的这三种解释之间也会产生冲突
那么产生冲突的原因就是有node2后面的模版参数引起的,那么我们知道如果被解释为内部类以及被解释是外部类只不过在模版类实例化被typedef为了node2,这两种解释都有一个共同点,那么就是他们都和node1共用模版参数,那么也就是如果我们要定义一个node1的内部类类型或者node1中显示实例化外部类被typedef为了node2的数据类型,那么我们只需要这么写:
typename node1<T> ::node2 it;
而不需要node2后面再提供额外的模版参数,因为他们都是共有node1的模版参数,所以这里我们就知道如果按照这两种解释,那么node2后面的模版参数会被解释为比较运算符,那么是无法通过编译的,那么唯一满足的只有嵌套的模板类,那么才需要我们后面额外给出模版参数,所以这里我们又要告诉编译器这里不要给我解释为内部类或者外部类在node1中被typedef为了node2的这两种解释,所以这里就需要再node2前面添加template关键字,那么原本编译器对node1< T >后面的内容有四种解释,在有了typename和template关键字的双重约束下,此时编译器就只能将其解释为嵌套的模版类
#include<iostream>
using namespace std;
template <typename T>
class node1
{
public:T member1;template <typename U>class node2{public:U member2;};
};
template <typename T, typename U>
void fun(T x1, U x2)
{typename node1<T>::template node2<U> it;it.member2 = 2;cout<<it.member2<<endl;
}
int main()
{fun<double, int>(1.0,2);return 0;
}
所以这里我们发现这里相比于上文,此时这里我们还需要额外添加一个template关键字来约束,就是因为有模版参数的存在,那么即使编译器避免了数据类型与静态成员变量之间的冲突,不会将其解释为静态成员变量,但是这里的数据类型之间仍然有冲突,就是因为模版参数的存在,会被解释为运算符
3.模版的特化
那么这里我们再来讲一下模版的特化,那么我们知道模版的核心思想就是忽略处理的数据类型,只关注代码的逻辑,但是模版会存在一个问题,那么就是该模版的代码逻辑不能适用于所有的数据类型,那么我们先来看这样一个场景,那么假设我们编写了一个模版函数,那么该模版函数就是比较两个同种数据类型的数的大小:
template<typename T>
bool compare(T x1,T x2)
{return x1>x2;
}
但是我们如果假设我们实例化该模版函数,那么传递的是指针的话,那么我该代码逻辑在比较指针这种数据类型就会出错,因为比较的两个数的物理位置是随机的,那么指针比较的是地址的大小,而我们真正要比较的内容而是指针指向的内容,所以这里我们就应该单独为指针这个数据类型编写一个模版函数,而这正是模版特化的应用场景
#include<iostream>
using namespace std;
template<typename T>
bool compare(T x1, T x2)
{return x1 > x2;
}
template<>
bool compare<int*>(int* x1, int* x2)
{return *x1 > *x2;
}
int main()
{int N1 = 7;int N2 = 5;int* ptr1 = &N1;int* ptr2 = &N2;if (compare(N1, N2)){cout << "N1 max" << endl;}if (compare(ptr1, ptr2)){cout << "N1 max" << endl;}return 0;
}
那么模版的特化的应用场景就是,当我们编写的模版函数或者模板类,如果某种特定的数据类型不适合当前的代码逻辑比如int类型或者某种特定的自定义类型,那么这里我们就需要为该数据类型单独定义适用于该数据类型的模版的定义,那么在上文的场景中,那么如果我们要比较两个数的大小,那么这里之前模版函数的代码逻辑不适用于指针,假设我们有比较nt类型的指针指向内容的需求,那么这里我们就可以利用模版的特化,单独为指向int类型的指针编写特定的特化的模版函数
而模版的特化的语法则是:
//模版的特化:
template<可变的模版参数>
返回值 函数名<可变的参数加上固定的模版参数> (参数列表)
{}
//类的特化
template<可变的模版参数>
class 类名<可变的模版参数加上固定的模版参数>
{};
而模版的特化其实一般不应用在函数上,那么在刚才的那个场景下,如果这里指针类型不适用,那么实际上我们可以在自己独立的编写一套针对于指针的函数模版:
template<typename T>
bool compare(T* x1, T* x2)
{return *x1 > *x2;
}
而模版的调用机制就是编译器会首先收集参数匹配并且同名的所有候选函数,其中包括模版函数以及普通函数,那么调用哪一个函数则是根据函数的参数匹配程度来决定,如果参数匹配程度最高的是普通函数,那么就直接调用该普通函数不用实例化生成模版函数而如果模版函数的匹配程度是最高的,那么就会实例化对应的模版函数,所以这里如果我们模版函数的代码逻辑不符合某些数据类型,那么一般要么就是我们直接编写普通函数或者就再编写一个独立的函数模版,那么到时候编译器自然会选择参数最匹配的函数调用,而如果确实是这里的代码逻辑只是对某一个特定的数据类型不适用,那么才会涉及到模版函数的特化,所以模版的特化的应用场景大部分在类模版上
而模版的特化,就好比现实生活中的大学,那么大学会有两部分群体能上,分别是正常人士以及残障人士,那么大学的通用的教学方案那么就对于残障人士不适用,因为通用的教学方案是针对正常人士的学生群体,所以这里大学就得针对特定的残障人士比如盲人或者聋哑人,为其设计提供一个特殊的教学方案以及计划,那么这个生活中的例子其实就对应这里的模版的特化,那么针对某种特定的数据类型,为其编写一套不同于其它数据类型的代码逻辑。
而这里的模版的特化我们又可以分位两种,分别是全特化以及偏特化,那么这里我们先来介绍全特化
全特化
在介绍全特化之前,我们先引入一个场景,现在我们有一个编写了针对所有内置类型和自定义类型的一个模板类,假设这里的模版类的所有的成员函数的代码逻辑不适用于某个特定的数据类型,比如int型,那么这里我们就可以利用模版的特化,直接重新编写一个独立的类,那么重写所有的成员函数的代码逻辑,那么这里假设原本的模版类的模版参数只有一个,那么所谓的全特化,我们就可以理解为将原本的通用版本的模版函数或者模板类的所有的模版参数都固定,或者更为本质的说就是全特化的模板类或者模版函数没有需要推导的模版参数,全特化下的模版类或者模版函数的模版参数全部都是固定的,是某个特定的数据类型比如int或者double等,那么这就要我们在类名或者函数名来显示指定<固定的模版参数>
#include<iostream>
using namespace std;
template<typename T>
class node
{
private:T member1;
public:void memberfun1(){cout << "most type" << endl;}
};
//特化版本
template<>
class node<int>
{
private:int member1;
public:void memberfun1(){cout << "only for int type" << endl;}
};
int main()
{node<double> l1;l1.memberfun1();node<int> l2;l2.memberfun1();return 0;
}
那么很多人知道之所以要进行特化是因为原有的模版函数或者模板类的代码逻辑不适用于某个特定的数据类型,所以需要进行特化,那么这里确实特化之后的模版函数或者说模板类是和原来的通用版本的模板类或者说模版函数是有关系的,但是在编译器的视角下,其实特化版本的模版函数或者模板类和原本的通用版本的模版函数或者模版类是独立的两个模版,也就意味着,你可以特化一个模板类,然后该模板类自己定义全新的成员变量与成员函数,那么相当于这里实际上你创建了一个新的类,那么这样做肯定是没问题的,因为模版的特化只影响编译器如何调用模版函数或者模板类,只有当我们实例化点调用的模版函数或者模板类推导出的模版参数的数据类型刚好就是特化的模版函数或者模板类固定的模版参数,这时特化的模版函数或者模板类参数最匹配,那么编译器就会对应实例化生成器对应的代码
偏特化
那么全特化就是模版参数全部固定,而所谓的偏特化,就是不固定所有的模版参数,那么有的模版参数是可变,意味着到时候需要编译器到代码的实例化处去推导出其中的可变的模版参数的数据类型,那么偏特化的应用场景,其中就包括是模版类中某些成员函数的逻辑能够通用所有数据类型,而有些成员函数不能适用某个特定的数据类型,那这里就可以应用我们的偏特化,那么template关键字后面就需要显示声明出可变的模版参数,而模版函数的函数名或者模版类中的类名就需要包含可变的模版参数和固定的模版参数
#include<iostream>
template <typename T,typename U>
class node
{private:T member1;U member2;public:void memberfun1(){cout<<"most type"<<endl;}
};
//特化版本
template<typename T>
class node<int,T>
{private:
T member1;int member2;public:void memberfun1(){cout<<"for T and int type"<<endl;}
}
int main()
{node<double,int> l1;l1.memberfun1();node<int,double> l2;l2.memberfun1();return 0;
}
4.模版的声明和定义分离
那么我们知道读者在编写程序的时候,会习惯的将函数的声明和定义存放到不同的文件,那么将函数的声明放在头文件而函数的定义放在源文件,而我们形成一个完整的程序那么就至少需要三个文件,分别是main函数所在的源文件以及函数声明所在的头文件以及函数定义所在的源文件,那么形成一个可执行程序需要经过编译以及链接这两个过程,那么之所以函数的声明和定义分离,那么是因为我们形成一个程序,除了会使用到第一方库的函数比如stdio.h的printf函数,也会使用别人的第三方库,也就是别人自己自定义实现的函数,那么这些函数的定义往往是人家的机密,是人家的业务,那么他们就不能让你看到该函数的实现,但是至少需要告诉你怎么用他自定义的函数,那么就需要需要告诉你函数的参数列表以及返回值,也就是函数的声明,所以他会采取这种方式,那么给你一个函数声明所在的头文件,而函数定义所在的源文件则是已经经过编译形成了一个.o的文件,那么此时.o文件里面的内容已经全是二进制机器码,人类是无法阅读的,所以到时候,你就只需要持有定义所在的源文件与main函数所在的.o文件经过链接形成一个完整的可执行文件即可。
所以有的读者对这种函数的声明和定义分离的意识是很强的,值得表扬,但是这里我们对于模版函数或者模板类来说,那么能否像普通函数或则类那样声明和定义分离呢?
那么对于模版函数的声明和定义分离,那么我假设我们现在有一个编写了一个比较大小的Max模版函数,那么这里我们尝试将其声明和定义分离,那么我们看看会有什么现象发生
//max.h
#pragma once
template<typename T>
T Max(T& x1, T& x2);
//max.cpp
template<typename T>T Max(T& x1,T& x2)
{if (x1 > x2){return x1;}return x2;
}
//main.cpp
#include<iostream>
#include"max.h"
using namespace std;
int main()
{cout<<Max(1, 3)<<endl;return 0;
}
那么我们发现此时我们将模版函数的声明和定义分离,报了一个链接错误,那么为什么会出现这种情况呢?那么这就要和我们形成一个可执行文件的过程有关
那么我们知道形成一个可执行文件,那么要经过编译以及链接这两个过程,那么其中编译阶段有分为4个子过程,分别是预处理以及语法语义检查和编译和最后的汇编,那么这里我们最开始有三个源文件文件,分别是模版函数声明所在的头文件max.h和主函数所在的源文件main.cpp和模版函数定义所在的源文件max.cpp,那么在预处理阶段会进行头文件展开以及宏替换等工作,那么此时max.h中的模版函数的声明就会被编译器给拷贝到main.cpp源文件中替换掉#include"max.h"语句,此时头文件就不在参与后序的过程,那么这个阶段会形成后缀名为.i的临时文件,然后再进入下一个阶段,那么就是语法语义检查,那么这个阶段的主要工作我们可以简单理解为就是检查语法是否有错,至于检查的细节,也就是构建语法树,这些都是编译原理的知识,读者感兴趣可以自行了解,接着就是到了编译,那么编译阶段就会将原来的c++代码给转化为汇编码,最后则是到经历最后一个阶段,那么就是汇编,那么汇编就是这里的重头戏,因为编译器此时是以每一个文件作为独立的单元进行编译,那么意味着它在编译当前文件时,是无法查看其他文件的内容,那么在这个阶段,会为每一个文件建立一个局部的符号表来记录其函数的原型也就是函数的声明包含参数列表以及函数名同时还要记录其对应的定义所在的地址,那么这个时候,编译器在编译main函数所在的文件的时候,那么此时编译器发现了main函数的代码中有模版函数的实例化点,那么此时编译器会在该实例化点根据传递给Max函数的参数来推导出模版参数的数据类型,并且编译器会持有了Max模版函数的声明,但是由于编译器是以独立的文件编译,那么编译器是无法获取其Max函数的定义,因为其定义在另一个文件,那么此时它只能生成一个实例化的模版函数的声明但是却缺少函数体部分,然后编译器在该文件的局部符号表中填写Max函数的符号,但是其定义所在的地址还是未解析,而编译器知道当前文件没有定义,可能意味着该函数的定义是在其他文件中,那么到时候会统一在链接阶段处理,因为链接阶段会生成一个全局的符号表,那么未被解析的符号会寻找其他文件的局部符号表寻找,如果找到就填入定义的地址
而此时编译器再编译函数定义所在的文件的时候,那么由于编译器是以文件为单元独立编译,那么编译器此时不知道该函数是否被实例化,由于它看不到另一个文件中main函数的实例化点,所以这里编译器无法实例化该模版函数,但是它还是要记录该模版函数的符号,但是由于模版参数没有推导,那么其不是一个具体的数据类型,那么编译器也没有办法,只能硬着头皮将未推导的模版参数填入该文件的局部的符号表中,并且同时填入其定义的地址
那么在链接阶段,此时合并所有经过编译形成的.o文件,同时生成一个全局的符号表,那么此时main.o的实例化的Max函数的符号的地址还未解析,那么编译器就要到Max.o文件的局部符号表尝试寻找定义的地址,也就是看Max.o的符号表是否有对应匹配的符号条目,但是这里由于Max.o的文件中的模版函数的模版参数是未推导的,所以这里发现其局部符号表中没有匹配的条目,所以这里无法填入地址,那么就会报链接错误
所以这下就能理解为什么刚才的模版的定义和声明的分离会报链接错误了,本质原因其实就是编译器在编译器在编译模版函数定义所在的文件的时候,那么它是看不到模版函数的实例化点的,所以它不会实例化该模版函数,那么解决方式就是告诉编译器,这里你给我实例化处一份模版函数的代码,那么这里就需要我们显示实例化,那么显示实例化的语法则是:
//模版函数的显示实例化
template 返回值 函数名(参数列表)
例: template int Max(const int&,const int&);
//类模板的显示实例化
template class 类名<具体类型>;
例:template mylist<int>;
//显示实例化类中某个特定的成员函数
template 返回值 类型::函数名(参数列表);
例:template void mylist<int>:: push_back(const int& val);
那么显示实例化就是告诉编译器,那么由于你看不到另一个文件的实例化点,所以这里你先听我的,先给我实例化一份函数模版或者类模版,这样才能够在符号表中填入正确的符号,从而避免链接错误,而这里还可以特定的显示实例化类模版中的某个成员函数,那么是因为模版的实例化的机制是**按需实例化,**那么我们知道类是由成员函数以及成员变量所组成,那么当我们创建一个类模版对象时候,那么此时编译器进行实例化,但其只会实例化类模版中的成员变量而不会实例化类模板中的成员函数(除构造函数以及析构函数),除非我们调用该成员函数,那么编译器才会实例化对应的成员函数,因为模版的实例化是有成本的,会影响编译的速度还会带来代码膨胀的问题,这也是为什么编译器要采取这个按需实例化的机制,所以这里显示实例化也允许我们实例化类中某个特定的成员函数
但是模版的声明和定义分离的缺点其实很明显,那么就是由于为了避免链接错误,所以只能显示实例化,那么这就限制要求我们的代码中实例化该模板类或者模版函数时,那么一定要和显示实例化的数据类型对应,如果你在代码中实例化了一份其他数据类型的模版函数或者模版类,那么还是会造成链接错误,那么我们也就只能调用显示实例化生产的函数模版或者创建显示实例化的类对象
但是这还不是最坑的,最坑的则是因为显示实例化是强制编译器实例化相应的函数模版或者模版类,但是你如果代码中没有使用实例化后的函数模版或者模版类,那么这里编译器相当于白忙活了一场,所以这里我们不推荐的模版的声明和定义分离
这里我们也可以来简单的应用模版的编译分离,那么这里我准备了我之前实现优先级队列的类模版,那么这里我们就来尝试将该优先级队列的类模版的声明和定义进行分离,然后显示实例化一份存储int类型数据的优先级队列
test.h
#pragma once
#include<vector>
#include<algorithm>
namespace wz
{template<typename T>class less{public:bool operator()(const T& l1, const T& l2);};template<typename T>class greater{public:bool operator()(const T& l1, const T& l2);};template<typename T, typename container = std::vector<T>, typename com = less<T>>class prioriety_queue{private:container _con;public:void adjustDown(size_t parent);void adjustUp(size_t child);void push(const T& val);void pop();const T& top();bool empty();};
}
test.cpp
#include"test.h"
template class wz::prioriety_queue<int>;
template<typename T>
bool wz::less<T>::operator()(const T& l1,const T& l2)
{return l1 > l2;
}
template<typename T>
bool wz::greater<T>::operator()(const T& l1, const T& l2)
{return l1 < l2;
}
template<typename T,typename container,typename com>
void wz::prioriety_queue<T, container, com>::adjustDown(size_t parent)
{com compare;size_t child = parent * 2 + 1;while (child < _con.size()){int fit_child = child;if (child + 1 < _con.size() && compare(_con[child + 1], _con[child])){fit_child = child + 1;}if (compare(_con[fit_child], _con[parent])){std::swap(_con[parent], _con[fit_child]);parent = fit_child;child = 2 * parent + 1;}else{break;}}
}
template<typename T,typename container,typename com>
void wz::prioriety_queue<T, container, com>::adjustUp(size_t child)
{com compare;size_t parent = (child - 1) / 2;while (child > 0){if (compare(_con[child], _con[parent])){std::swap(_con[child], _con[parent]);child = parent;parent = (child - 1) / 2;}else{break;}}
}
template<typename T,typename container,typename com>
void wz::prioriety_queue<T, container, com>::push(const T& val)
{_con.push_back(val);adjustUp(_con.size() - 1);
}
template<typename T,typename container,typename com>
void wz::prioriety_queue<T, container, com>::pop()
{std::swap(_con[0], _con[_con.size() - 1]);_con.pop_back();adjustDown(0);
}
template<typename T,typename container,typename com>
const T& wz::prioriety_queue<T, container, com>::top()
{return _con[0];
}
template<typename T,typename container,typename com>
bool wz::prioriety_queue<T, container, com>::empty()
{return _con.empty();
}
main.cpp
#include"test.h"
#include<iostream>
using std::cout;
using std::endl;
int main()
{wz::prioriety_queue<int> l1;l1.push(20);l1.push(19);l1.push(8);l1.push(88);l1.push(2);l1.push(4);while (!l1.empty()){cout << l1.top() << " ";l1.pop();}return 0;
}
结语
那么这就是模版的全部内容,那么下一期我将会更新继承,也就是面向对象编程的第二大特性,那么感谢耐心看到这里的读者,读者下来也可以自己去尝试写代码实现或者验证模版的这些特性,那么我会持续更新,希望你能够多多关注,如果本文有帮组到你的话,还请三连加关注哦,你的支持就是我创作的最大的动力!