2026/10/18 2:29:58

【C++】泛型编程——模板进阶

【C++】泛型编程——模板进阶 文章目录前言1. 模板参数的分类2. 非类型模板参数2.1 非类型模板参数的概念2.2 铺垫2.2 非类型模板参数的使用2.4 注意2.5 array的了解3. 模板的特化3.1 概念3.2 函数模板特化3.3 类模板特化3.3.1 全特化3.3.2 偏特化部分特化参数更进一步的限制4. 模板分离编译4.1 什么是分离编译4.2 模板的分离编译4.3 解决方法5. 模板总结5.1 优点5.2 缺点前言在之前的文章里我们进行了模板初阶的学习 【C】泛型编程——模板初阶了解了什么是泛型编程学习了函数模板和类模板。那这篇文章我们继续学习模板进阶的内容的学习。1. 模板参数的分类首先我们来回顾一下我们在模板初阶的学习中定义模板参数是怎么定义的是不是使用class或者typename关键字啊templateclass T1, class T2,...,class Tn对于函数模板来说我们调用函数时传的参数是什么类型T就会被替换成对应的类型然后实例化出对应的模板函数我们实际调用的就是函数模板根据具体传入的实参类型实例化出来的模板函数。那对于跟在class或者typename之后的这种模板参数我们把它叫做类型模板参数即它定义的是一个类型对应的模板实例化的时候该参数会被替换成一个具体的类型供其对应的模板类或模板函数使用。但是呢这其实只是模板参数的一种模板参数分为类型模板参数和非类型模板参数。类型模板参数我们已经了解了类型形参即出现在模板参数列表中跟在class或者typename关键字之后的参数类型名称。那非类型模板参数又是什么呢2. 非类型模板参数2.1 非类型模板参数的概念非类型模板参数的概念非类型模板参数就是用一个常量且必须是整型常量作为类(函数)模板的一个参数在类(函数)模板中可将该参数当成常量来使用。什么意思呢下面我们通过一个栗子细细的给大家介绍一下2.2 铺垫假设我们现在要写一个静态的顺序表那我们就可以这样搞首先这里我们定义了一个标识符宏常量N用N作为当前静态数组的大小就使得我们后续想要改变数组大小的时候很方便。其次我们把它实现成了一个类模板该类模板有一个模板参数T那通过上面的了解我们知道这里的T其实就是一个类型模板参数它定义的是一个类型这样我们在使用该类模板的时候指定什么类型实例化出来的数组模板类就存放什么类型的数据。这样看起来好像也挺方便的。但是对于有些情况却不能很好的处理如果我们现在想达到这样一种效果我们想让a1的大小为10 而a2的大小为20目前的实现可以做到吗不行啊虽然这里我们用来#define定义的宏改变数组的大小是很方便的但是这里我们实例化出两个对象一个大小为10一个20是不是做不到啊。那大家来思考一下首先对于类型模板参数来说他解决了类型的问题。我们没有学模板之前写一个数据结构比如有一个栈我们一般会有一个typedef这样想要改变栈里存储的数据的类型很方便但是如果我们在main函数里定义了2个或者多个栈想让它们分别存储不同类型的数据能不能做到呢如果typedef的话是不能的但是在模板初阶的学习之后借助模板用类型模板参数是不是就可以解决这个问题啊这样在一个main函数中我们定义两个栈就可以让他们分别存储不同类型的数据。2.2 非类型模板参数的使用那再回到我们上面的问题其实这里有点类似类型模板参数呢解决了类型的问题。那这里我们想让a1大小为10a2大小为20这与类型无关啊那这种情况又该如何解决呢那非类型模板参数的引入其实就很好的解决了这种问题。类型模板参数定义了一个类型那非类型模板参数定义的是什么呢我们再来回顾一下非类型模板参数的概念非类型模板参数就是用一个常量且必须是整型常量作为类(函数)模板的一个参数在类(函数)模板中可将该参数当成常量来使用。是的非类型模板参数其实定义的是一个整型常量。注意⚠这里的整型不单指int而是整个整型家族包括C99引入的bool类型。具体怎么用呢这里我们就可以这样写和类型模板参数一样直接放在模板参数列表里面就行了。我们看到这里是这样写的——size_t N虽然没加const但是规定它在这里就是常量。后面我们会验证它是不能修改的。那这样就可以实现a1的大小是10a2的大小是20了。当然非类型模板参数也是可以给缺省值的。2.4 注意再次提醒大家非类型模板参数必须是常量且要是整型。我们可以来验证一下这次我们举个函数模板的例子首先常量就意味着它不能被修改其次必须是整型所以说浮点数、类对象以及字符串等其它非整形的类型是不允许作为非类型模板参数的。不过呢这里提一下就是C20允许使用float或double作为非类型模板参数。另外要知道非类型的模板参数必须在编译期间就能确认结果。2.5 array的了解然后我们再来了解一个东西就是C11搞出来了一个新容器——array。其实可以认为就是静态数组我们看到文档给的解释是固定大小的序列容器我们看到array这个类模板其实就用了一个非类型模板参数来作为这个数组的大小。可以看一下它的成员函数那C11搞出来这个东西其实是对标C语言里的静态数组我们包一下array这个头文件就可以使用它那array的底层其实也是一个静态数组只不过用类进行了封装。那大家想一下本来就已经有静态数组了为什么还要搞出来一个这个或者说它于C语言的静态数组相比有什么进步吗嗯~array可以用迭代器而数组不能。是虽然数组不能用迭代器但是它也可以用范围for啊范围for用起来就很方便啊。那这样看来好像没什么特别之处啊难道array可以自动初始化并没有。没啥用啊与原生的数组相比好像没啥进步啊。其实它的优势是对越界的一个检查比数组更严格一点。传统的数组对于越界的检查是一个抽查有时候越界会报错而有时候可能就不会越界读不会报错。如果越界写就不一定了所以我们说是抽查。那对于array来说它对于读写的检查就比较严格比较全面。读写因为它是一个类嘛它里面可以拿非类型模板参数这个N去比较判断你是否越界。所以要说它比传统数组的进步的话可能就这一个点了吧。所以C11搞出来这个其实是想让我们以后用数组的时候都用array。但是大家想一下如果给我们选择的话我们会用这个嘛是不是有vector啊我vector如果越界也能很好的检查啊并且我vector还能直接初始化array有哪一点比vector强。所以这个设计就感觉有点没必要了。如果要论array和vector的区别的话那就是array的空间是在栈上开辟的而vector的空间是在堆上申请的。所以这个大家了解一下就行没什么用。3. 模板的特化接下来我们再来学一个东西叫做模板的特化。首先我们来认识一下它的概念。3.1 概念通常情况下使用模板可以实现一些与类型无关的代码但对于一些特殊类型的可能会得到一些错误的结果需要特殊处理。举个栗子我们这里现在有一个专门用来进行小于比较的函数模板templateclassTboolLess(T left,T right){returnleftright;}那我们现在就可以使用它去比较不同类型数据的大小intmain(){coutLess(1,5)endl;// 可以比较结果正确return0;}我们来看一下结果没有问题结果正确。现在我们拿过来一个日期类classDate{public:Date(intyear1900,intmonth1,intday1):_year(year),_month(month),_day(day){}booloperator(constDated)const{return(_yeard._year)||(_yeard._year_monthd._month)||(_yeard._year_monthd._month_dayd._day);}booloperator(constDated)const{return(_yeard._year)||(_yeard._year_monthd._month)||(_yeard._year_monthd._month_dayd._day);}private:int_year;int_month;int_day;};他已经重载了大于小于操作符。那我们现在相比较日期类的大小看一下结果没有问题结果也是正确的。但是这样呢我们再来运行看结果欸p1指向的日期小于p2指向的日期啊那结果怎么是0呢因为这里比较的并不是它们指向的日期而是这两个指针本身那指针进行比较的话实际比较的是它们存储的地址的大小所以这里的结果可能并不是我们想要的。可以看到Less绝对多数情况下都可以正常比较但是在特殊场景下就得到错误的结果。上述示例中p1指向的d1显然小于p2指向的d2对象但是Less内部并没有比较p1和p2指向的对象内容而比较的是p1和p2指针的地址这就无法达到预期而错误。那如果我们就想比较日期即使我们拿到的是date*的指针我们也想按日期去比较怎么办我们在优先级队列那篇文章是不是解决过这个问题啊可以写一个仿函数去搞定这个问题。那除了仿函数还有没有其它方法来解决呢有的我们还可以使用模板特化去解决这个问题。那模板特化到底是什么呢模板特化即在原模板的基础上针对特殊类型所进行特殊化的实现。模板特化中分为函数模板特化与类模板特化。3.2 函数模板特化首先我们来看函数模板特化我们说上面那种情况可以用模板特化去解决。那现在问题来了对于一个像上面那样的函数模板我们想对其进行特化要怎么做呢或者说步骤是什么来看函数模板特化的步骤必须要先有一个基础的函数模板那这个我们已经有了啊templateclassTboolLess(T left,T right){returnleftright;}接着要怎么做呢特化出来的版本是这样的关键字template后面接一对空的尖括号函数名后跟一对尖括号尖括号中指定需要特化的类型函数形参表: 必须要和模板函数的基础参数类型完全相同如果不同编译器可能会报一些奇怪的错误那我们现在要对Date*这种类型进行特化所以函数名后面的尖括号里面放的就是Date*。那我们想要的是比较它们指向的日期的大小所以现在函数体应该这样实现至此我们的特化就完成了。那我们来试一下这次的结果是否正确这下结果就正确了。我们实现了特化的版本之后对于那些普通的类型就还是走普通版本的逻辑而一旦遇到我们特化的类型他就会走特化版本的逻辑。所以模板特化就是针对某些特殊类型进行特殊化的处理。当然其实针对上面这种情况我们不使用模板特化也能很轻松解决我们可以直接针对Date*这种类型写一个函数出来啊这样也可以啊。该种实现简单明了代码的可读性高容易书写。是的所以说函数模板不建议特化一般情况下如果函数模板遇到不能处理或者处理有误的类型为了实现简单通常都是将处理该情况的函数直接给出而不是对其进行特化。所以对于函数模板特化我们这里也不再继续介绍更多的内容了。3.3 类模板特化接下来我们再来学习一下类模板的特化首先我们要知道模板的特化其实分为两种——全特化和偏特化。3.3.1 全特化全特化即将模板参数列表中所有的参数都确定化。举个栗子现在有这样一个类模板templateclassT1,classT2classData{public:Data(){coutDataT1, T2endl;}private:T1 _d1;T2 _d2;};那我们现在想对这个类模板进行全特化怎么做呢那就是把该类模板模板参数列表的所有参数都确定化嘛。那此时如果我们实例化该类模板的时候如果我们指定的类型是int, char那就匹配全特化的版本因为我们的全特化就是把它确定成了int, char那其它类型就匹配的是原始的版本。3.3.2 偏特化那什么是偏特化呢偏特化有以下两种表现方式部分特化部分特化将模板参数类表中的一部分参数特化确定化什么意思举个栗子还是上面那个Data类模板那我们想对它进行部分特化比如把第二个参数特化成int就是这样搞参数更进一步的限制参数更进一步的限制针对模板参数更进一步的条件限制所设计出来的一个特化版本。什么意思呢举个栗子现在这里有一个用来进行小于比较的仿函数templateclassTstructLess{booloperator()(constTl,constTr)const{returnlr;}};我们来用它比较一些内置类型数据的大小包括我们的日期类重载了都是没什么问题的。当然如果我们比Date*的话还是比较的是地址的大小如果我们想让他比较指针指向的数据的大小我们可以对Date*这个类型进行一个特化那在这里其实就是一个全特化。但是如果我们不只针对Date*的指针对于其它类型的指针比较时我们也想去比较它们指向的内容而不是地址。那我们可以怎么做呢难道对不同的指针类型都进行一个特化吗这显然是很麻烦的。那我们此时就可以用偏特化的第二种形式——参数更进一步的限制来解决这个问题。怎么做呢进行一个偏特化将模板参数限制成T*这样只要调用仿函数时传的数据是指针类型都会去匹配偏特化的这个版本对指针指向的内容进行比较而不是存储的地址。我们来运行看一下这下就可以了。4. 模板分离编译然后我们再来学习一个东西叫做模板分离编译。大家还记不记得我们在模板初阶的学习中文章最后我们提到一个东西就是我们定义一个类可能习惯头文件和源文件分开来那普通类这样搞是没问题的就像我们之前实现的日期类就是多文件管理的。但是呢类模板不行类模板如果这样搞会链接错误的。那这篇文章在这里我们就会来解释一下其中的原因。4.1 什么是分离编译我们先来了解一下什么是分离编译分离编译模式源于C语言在C语言中继续沿用。简单地说分离编译模式是指一个程序项目由若干个源文件或在加上若干头文件共同实现而每个源文件单独编译生成目标文件最后将所有目标文件连接起来形成单一的可执行文件的过程。4.2 模板的分离编译假如有以下场景模板的声明与定义分离开在头文件中进行声明源文件中完成定义除了模板函数之外我还加了一个普通函数也是声明定义分开。然后我们在test.cpp的main函数中去调用模板函数和普通函数我们会发现普通函数func分离编译时没问题的可以正常调用没有报错。但是我们发现模板是不行的它报了一个链接错误。那为什么呢为什么模板分离编译不行呢那我们接下来就来分析一个当前这个程序编译链接的一个过程复习相关知识可看之前的这篇文章【C进阶】——我们写的代码是如何一步步变成可执行程序.EXE的看能不能发现其中的原因。那大致的过程呢差不多是这个样子的在里面这个编译的过程中会把预处理之后的C代码转换为汇编代码由一系列汇编指令组成而函数的地址信息其实就包含在这些汇编指令中。但是呢对于当前这个程序来说编译func.i——func.s)的过程中只会生成func函数的汇编指令而并没有函数模板Add的。因为Add没有被实例化为什么没实例化呢函数模板实例化不是在编译期间就会进行吗因为func.cpp和test.cpp是分开的链接之前它们都是单独进行的test.cpp里面指定了具体类型对Add进行实例化但是func.cpp编译的时候没法确定类型因为在链接之前它们不交互所以func.cpp里面的函数模板没法确定T要替换成什么类型所以没法实例化。那然后我们再来分析一下main函数中在调用它们的时候这个过程是怎么样的首先在test.c编译的过程中这几句代码肯定也会被变成汇编代码那函数调用转换成对应的汇编代码其实就是去call这个函数的地址。但是呢test.c包含了头文件func.h而func.h里面只有Add和func的声明并没有具体的函数定义和对应的实例化生成的具体函数所以这里生成的符号表里面它们的地址可能知识标识一下无实际意义。但是有声明的话这里编译也可以通过。可是呢后面链接的时候就有问题了我们知道链接的时候会做一件事情叫做符号表的合并和重定位然后就可以拿到这些函数重定位之后有效的地址就可以成功调用它们了。那func函数在func.cpp中是有具体的定义的所以最终的符号表中会有它有效的地址。但是由于Add并没有成功进行实例化没有生成具体的函数定义所以就找不到有效的地址所以符号表合并和重定位之后里面并没有有效的函数地址可能还是声明产生的那个无效的没有意义的地址。所以这里两个Add就无法调用由于是在链接阶段出现的错误所以报的错是链接错误error LNK。大家也可以看一下这张图那通过上面的分析我们可以得出其实模板分离编译导致出错的原因关键就在于这些多个文件是分离编译的在链接之前它们是不会进行交互的所以没法实例化没法产生具体的函数或类因为不交互的话类型都没法确定。所以最后链接的时候就没法找的有效的地址就出现了链接错误。4.3 解决方法那针对上面的问题有没有什么解决方法呢有的这里有两种解决方法在模板定义的位置显式实例化举个栗子对于Add这个模板你在main函数里面不是实例化了两份嘛一份是int一份是double。那我们就针对这两个在模板定义的位置显示实例化一下怎么做呢写法是这样的。然后我们再来运行就可以了。但是我们发现这种方法的增加一个新类型我们就要增加一个显式实例化很麻烦。所以不推荐这种方法。那有没有好一点的方法呢有的声明和定义可以分离但放到一个文件中什么意思呢就是你可以把模板的声明和定义分开来写但是不要分成一个头文件一个源文件而是把它们放到一个.h或者一个.hpp文件里面。有的头文件为了暗示是模板会将后缀改成.hpp但还是头文件这样就不会出错了。推荐使用这种。所以如果遇到需要分离的场景建议大家使用第二种方式。5. 模板总结最后我们来对模板进行一个简单的总结5.1 优点模板复用了代码节省资源更快的迭代开发C的标准模板库(STL)因此而产生增强了代码的灵活性5.2 缺点模板会导致代码膨胀问题也会导致编译时间变长出现模板编译错误时错误信息非常凌乱不易定位错误关于模板的讲解就先到这里。