C++ 模板入门:函数模板、类模板与实例化机制 C 模板入门函数模板、类模板与实例化机制假设要写一个交换函数最直接的做法是为每种类型分别提供重载voidSwap(intleft,intright);voidSwap(doubleleft,doubleright);voidSwap(charleft,charright);这些函数除了类型不同内部逻辑几乎完全一样。增加一种类型就复制一份代码修改算法时还要同步维护所有重载。模板解决的正是这类问题把与类型无关的算法写一次具体类型版本交给编译器按需生成。这篇文章从函数模板开始讲清模板参数推导、显式实例化、重载匹配和类模板。模板语法并不难真正容易出错的是“编译器何时生成代码”和“为什么某些调用推导失败”。一、什么是泛型编程泛型编程是编写能够适用于多种类型的通用代码。算法关注类型需要具备什么能力而不是把类型名称写死。交换两个对象需要的能力很简单对象可以被复制或移动并且可以赋值。至于对象是int、double还是自定义类型交换步骤并没有本质区别。templatetypenameTvoidSwap(Tleft,Tright){T templeft;leftright;righttemp;}调用方式和普通函数一样inta10;intb20;Swap(a,b);doublex1.5;doubley2.5;Swap(x,y);编译器根据实参推导T。第一次调用得到T int第二次调用得到T double再分别生成可用的函数版本。模板并不是“一个函数可以接收任意类型”。更准确地说它是一份生成函数或类的规则。某个类型只有满足模板内部使用的操作这次实例化才能通过编译。二、函数模板的基本语法函数模板以模板参数列表开头templatetypenameT返回类型 函数名(参数列表){// 函数体}模板参数可以有多个templatetypenameT1,typenameT2voidPrintPair(constT1first,constT2second){std::coutfirst, second\n;}typename 和 class 有什么区别在模板类型参数列表中下面两种写法等价templatetypenameTvoidFunc(constTvalue);templateclassTvoidFunc(constTvalue);这里的class表示“这是一个类型参数”不要求传入类型必须是类。int、指针、结构体和类都可以作为T。不能把这个位置的class随意替换成struct// template struct T // 错误语法现代 C 代码通常更偏爱typename因为它直接表达“类型名称”但两种写法都很常见。三、函数模板是怎样生成具体函数的函数模板本身不是某个确定的普通函数。编译器看到模板调用后会结合实参类型形成一个具体版本这个过程称为实例化。函数模板 Swap(T, T)Swap(int, int)Swap(double, double)Swap(char, char)不同类型的调用可以把下面的模板templatetypenameTvoidSwap(Tleft,Tright){T templeft;leftright;righttemp;}理解成编译器按需生成类似这样的函数voidSwap_int(intleft,intright){inttempleft;leftright;righttemp;}voidSwap_double(doubleleft,doubleright){doubletempleft;leftright;righttemp;}这只是帮助理解的等价模型真实函数名称、符号修饰和代码合并由编译器决定。模板不会凭空赋予类型能力templatetypenameTTAdd(constTleft,constTright){returnleftright;}Add要求T类型的两个对象可以执行并且结果能够作为T返回。int和double满足要求某个没有定义加法的自定义类型在实例化时会编译失败。模板的通用性来自“延迟确定类型”不是绕过类型检查。实例化后的代码仍然要遵守完整的 C 类型规则。四、隐式实例化与显式实例化函数模板有两种常见使用方式让编译器自行推导模板参数或者在函数名后明确写出模板实参。1. 隐式实例化#includeiostreamtemplatetypenameTTAdd(constTleft,constTright){returnleftright;}intmain(){intintResultAdd(10,20);doubledoubleResultAdd(1.5,2.5);std::coutintResult\n;std::coutdoubleResult\n;}编译器从Add(10, 20)推导出T int从Add(1.5, 2.5)推导出T double。编译运行g function_template.cpp-stdc11-ofunction_template ./function_template预期输出30 42. 两个实参推导出不同的 T下面的调用无法完成模板实参推导intnumber10;doubledecimal2.5;// Add(number, decimal); // 推导失败第一个实参要求T是int第二个实参要求T是double。模板参数列表中只有一个T编译器不能自行选择其中一个。很多初学者会把这个现象概括成“模板不允许类型转换”这句话不够准确。问题发生在模板实参推导阶段编译器通常不会先把某个实参转换成另一种类型再用转换后的结果消除推导冲突。可以在调用处明确转换intresultAdd(number,static_castint(decimal));也可以显式指定TintresultAddint(number,decimal);指定T int后不再需要从两个实参推导T。此时参数类型已经确定为const intdecimal可以按照普通函数调用规则转换为int。小数部分会丢失这也是显式指定类型时必须注意的语义变化。3. 显式写出模板实参模板实参写在函数名后的尖括号中Addint(10,20);Adddouble(10,20.5);这常被口头称为显式实例化调用。严格区分术语时它属于“显式指定模板实参”独立的显式实例化声明或定义还有专门语法。入门阶段先记住它的实际作用模板参数不再完全依赖编译器推导。4. 使用多个类型参数如果函数本来就应该接收不同类型可以为两个参数分别设置模板类型templatetypenameT1,typenameT2autoAdd(constT1left,constT2right)-decltype(leftright){returnleftright;}现在下面的调用可以直接成立autoresultAdd(10,2.5);// 结果类型由 int double 决定这里使用 C11 的尾置返回类型和decltype避免简单地把返回类型写成T1。如果写成T1Add(10, 2.5)的计算结果会被转换回int容易在不经意间丢失小数。五、普通函数与函数模板如何匹配普通函数可以和同名函数模板同时存在#includeiostreamvoidPrint(intvalue){std::coutordinary: value\n;}templatetypenameTvoidPrint(T value){std::couttemplate: value\n;}intmain(){Print(10);Print(3.14);Printint(20);}预期输出ordinary: 10 template: 3.14 template: 20三次调用的选择过程不同调用选择结果原因Print(10)普通函数两边都能精确匹配优先普通函数Print(3.14)模板生成的函数模板能精确匹配double普通函数需要转换成intPrintint(20)模板生成的函数尖括号明确要求使用模板“有普通函数就一定不用模板”也是错误理解。重载决议会先比较匹配质量普通函数只有在匹配程度不差于模板生成函数时才会因为非模板身份获得优先。模板版本可以生成与普通函数相同的签名intAdd(intleft,intright){returnleftright;}templatetypenameTTAdd(T left,T right){returnleftright;}Add(1, 2)调用普通函数Addint(1, 2)则明确使用模板生成的int版本。两者可以同时存在编译器会根据调用表达式完成选择。六、类模板让一种数据结构适配多种元素类型函数模板复用算法类模板复用类型结构。一个栈可以保存整数、浮点数或自定义对象入栈和出栈规则并不会因为元素类型不同而改变。类模板的基本格式如下templatetypenameTclassClassName{// 成员定义};下面实现一个可运行的简化栈。为了让示例具备完整生命周期代码补上了析构、扩容和边界检查并禁止直接拷贝避免默认浅拷贝带来的重复释放。#includecstddef#includeiostream#includestdexcepttemplatetypenameTclassStack{public:explicitStack(std::size_t capacity4):_array(newT[capacity]),_capacity(capacity),_size(0){}~Stack(){delete[]_array;}Stack(constStack)delete;Stackoperator(constStack)delete;voidPush(constTvalue){if(_size_capacity){Expand();}_array[_size]value;_size;}voidPop(){if(_size0){throwstd::out_of_range(stack is empty);}--_size;}constTTop()const{if(_size0){throwstd::out_of_range(stack is empty);}return_array[_size-1];}std::size_tSize()const{return_size;}private:voidExpand(){std::size_t newCapacity_capacity0?1:_capacity*2;T*newArraynewT[newCapacity];for(std::size_t i0;i_size;i){newArray[i]_array[i];}delete[]_array;_arraynewArray;_capacitynewCapacity;}private:T*_array;std::size_t _capacity;std::size_t _size;};intmain(){Stackintnumbers;numbers.Push(10);numbers.Push(20);std::coutnumbers.Top()\n;Stackdoubledecimals;decimals.Push(3.14);std::coutdecimals.Top()\n;}编译运行g stack_template.cpp-stdc11-ostack_template ./stack_template预期输出20 3.14Stack 和 Stack 的区别Stack是类模板名称它描述怎样生成一组类。Stackint才是使用int实例化后得到的具体类型StackintintStack;StackdoubledoubleStack;Stackint与Stackdouble是两个不同类型。它们的数据布局和成员函数都围绕各自的T形成二者不能随意互相赋值。七、类模板成员函数写在类外时怎么写成员函数可以在类模板内部定义也可以放在类定义之后。写在类外时必须重新声明模板参数并指明成员属于StackTtemplatetypenameTclassStack{public:voidPush(constTvalue);private:T*_arraynullptr;std::size_t _size0;};templatetypenameTvoidStackT::Push(constTvalue){_array[_size]value;_size;}这段定义中有三个容易漏掉的部分函数定义前仍要写template typename T。作用域必须写成StackT::不能只写Stack::。参数中的T来自当前这次类模板实例化。上面的片段只用于说明类外定义语法没有包含空间申请和越界处理。实际使用时应采用前一节那种生命周期完整的实现。八、为什么模板通常把声明和定义放在头文件普通函数可以把声明放在头文件把定义放在.cpp文件中因为编译.cpp时就能生成目标代码。模板的情况不同编译器通常要在看到具体模板实参后才能生成对应版本。假设头文件里只有声明// stack.hpptemplatetypenameTclassStack{public:voidPush(constTvalue);};定义被单独放进stack.cpp// stack.cpptemplatetypenameTvoidStackT::Push(constTvalue){// ...}另一个编译单元使用Stackint时只看到了模板声明没有看到Pushint所需的定义最终可能出现链接错误。常见组织方式有两种直接把模板声明和定义都放在.hpp文件中。将定义放在.tpp或.ipp文件中再由.hpp在末尾包含该文件。也可以在实现文件中为确定类型进行显式实例化但这样只会生成事先列出的类型版本失去了随意适配新类型的便利。对通用类模板而言让定义在实例化位置可见通常最省心。九、常见问题与易错点1. 把模板理解成一个万能函数模板只有在具体类型支持函数体中的操作时才能实例化。模板内部写了left right传入类型就必须支持相应加法表达式。2. 两个不同类型推导同一个 TtemplatetypenameTTAdd(T left,T right);// Add(1, 2.5); // T 同时被推导为 int 和 double解决方式是显式转换、指定模板实参或者把模板改成两个类型参数。选哪一种取决于函数语义不要只为了通过编译就随意截断数据。3. 认为模板推导会主动寻找所有转换路径推导的目标是从实参结构确定模板参数不是穷举转换方案。显式给出模板实参后剩余参数才按照已经确定的函数参数类型参与普通转换检查。4. 忘记尖括号中的类型才构成具体类在 C11 的常规类模板语法中要写Stackint object;不能只写Stack object;。Stack是生成类的模板Stackint才是类型。5. 类外定义成员时漏写模板声明templatetypenameTvoidStackT::Push(constTvalue){// ...}template typename T和StackT::都不能省略。6. 把模板定义单独藏在 cpp 文件里如果使用模板的编译单元看不到定义就可能无法生成对应实例。把模板定义放进头文件或者采用头文件包含.tpp的组织方式。7. 只关注类型复用忽略资源管理类模板里只要出现new[]就要同步考虑析构、拷贝和赋值。模板不会自动修复浅拷贝问题。入门实现可以像示例那样先禁用拷贝后续再补充深拷贝或移动语义。8. 被很长的模板错误信息带偏模板报错经常展开多层实例化信息。排查时先找最早出现的“由此实例化”位置再看具体类型不支持哪个表达式。错误的根源通常仍是普通问题没有对应运算符、参数类型冲突、成员不存在或定义不可见。十、模板适合解决什么问题模板适合“处理逻辑相同参与类型不同”的场景通用算法例如交换、比较、查找和排序。通用数据结构例如栈、队列和动态数组。只依赖某种操作能力而不关心具体类型名称的工具函数。如果不同类型需要完全不同的业务流程强行塞进一个模板只会增加判断和阅读成本。模板的价值是消除真正重复的类型代码不是把所有函数都改成带尖括号的形式。十一、总结函数模板是一份生成函数的规则编译器根据实参推导模板参数再形成具体函数。推导阶段遇到同一个模板参数对应多个冲突类型时不会擅自选择转换方向可以通过显式转换、指定模板实参或增加模板参数解决。普通函数和函数模板可以同名共存。匹配质量更好的候选优先匹配程度相同时通常选择普通函数。显式写出尖括号则可以明确要求使用模板版本。类模板用于生成具体类Stack是模板名称Stackint才是类型。模板定义通常要对实例化位置可见因此常与声明一起放在头文件中。理解“模板是生成规则实例才是具体代码”后面的模板特化、标准容器和泛型算法就都有了落脚点。