如果你写过一段时间 C++,就一定会遇到这样的尴尬:明明逻辑完全一样,只是因为参数类型不同,就得把同一段代码抄好几遍。写一个交换函数,int 要一份,double 要一份,char 再要一份。改一个 bug,所有相同结构的函数都得跟着改一遍,改漏一个就出大问题。这就是所谓的"代码重复"。

模板(template)就是来解决这个问题的。它的核心理念极其直白:既然你不知道将来会传入什么类型,那就先别写死,把"类型"当成一个可替换的占位符。等你真正调用的时候,编译器再根据你传进去的具体类型,"照着模具批量生产"出对应版本的代码。用一句俏皮话概括今天的主题:用一段代码,生成一堆代码。

这篇文章是 C++ 模板系列的第一篇。我会从"为什么需要模板"讲起,带你写出函数模板,理解隐式与显式实例化、模板参数推导,然后进入类模板的世界,最后解开一个许多初学者都困惑的死结——为什么模板不能把声明和定义分开放在 .h 和 .cpp 两个文件里。你只需要掌握最基础的函数、类和 C++ 的编译链接基本概念,就能顺畅读完。

在正式开始之前,先交代两个贯穿全文的概念,它们是理解模板的钥匙。第一,类型(type)。在 C++ 里,int、double、char 乃至你自己定义的 class 类,都是一种类型。模板要处理的,正是"类型不同、逻辑相同"这种情况。void 也可以是一种类型参数,但那属于更难的类型,初阶阶段我们只关注那些"有值、能用变量存放"的类型。第二,C++ 是编译型语言,程序要经历预处理、编译、汇编、链接四个阶段才能跑起来。这一点特别重要,因为模板"批量生成代码"的行为,恰恰发生在编译阶段——理解不了这一点,你就想不通模板为什么有那么多的条条框框(比如后文那句"模板不实例化就不报错",就完全建立在"模板在编译期展开"这一事实上)。

为什么需要模板

我们从一个具体的问题出发:如何实现一个通用的交换函数?

最朴素的想法是函数重载(function overloading)——在 C++ 里,多个函数可以同名,只要它们的参数列表(类型或个数)不同即可。于是你可能会这样写:

void Swap(int& left, int& right) {         // int 版本的交换
    int temp = left;                       // 用一个临时变量保存 left
    left = right;                          // 把 right 赋给 left
    right = temp;                          // 把原来保存的 left 赋给 right
}
 
void Swap(double& left, double& right) {   // double 版本的交换,逻辑一模一样
    double temp = left;
    left = right;
    right = temp;
}
 
void Swap(char& left, char& right) {       // char 版本的交换,逻辑一模一样
    char temp = left;
    left = right;
    right = temp;
}

这段代码能工作,但问题也随之而来,而且越来越痛。

第一,代码复用率极低。 三个 Swap 函数的函数体几乎逐字相同,唯一不同的是类型。假如你以后要用 float、long long、或者自己定义的某个类,只要想交换,就得把这一段再"复制粘贴"一遍。整个工程里这样的重复会越来越多,搞得代码臃肿不堪。

第二,代码可维护性很差。 这里有个非常隐蔽的坑:如果交换逻辑中某个地方写错了(比如把 right = temp 写成了 temp = right),那你改起来得把每一个重载版本都找出来改一遍。漏改了哪一个,哪一个就悄悄潜伏着一个 bug,而且极难发现——因为其他版本都是对的,只有那一个出错。这就是课件里说的"一个出错可能所有的重载均出错"。

其实你不难发现,这些重载函数本质上是同一段逻辑的复制品。我们真正想要的是:只写一份交换逻辑,让编译器根据你传入的类型,"自动生成" int、double、char……等各种具体版本。于是我们产生了一个伟大的设想——告诉编译器一个"模子",让编译器按需生产。

这个设想有个响亮的称号,叫泛型编程(generic programming)。官方定义是这样的:泛型编程,就是编写与类型无关的通用代码,是一种代码复用的手段。而模板,正是泛型编程的基础设施。

你可以把模板理解成"做饼干的模具",或者更贴切一点——"铸件的砂型"。模具本身不是饼干,但你往模具里填不同材料(对应不同的具体类型),就能得到不同材料的饼干。走到这里,我们对模板就有了一个初步的形象:模板不是函数,也不是类,而是一个"生产代码的模板"。真正跑起来的,是编译器用模板帮我们生产出来的那些具体版本。

为什么不干脆用宏来做泛型

你可能已经想到另一个偷懒的办法:我能不能用 C 语言时代的宏(macro) 来写"类型无关"的代码?比如用 #define Swap(a, b) ...。宏确实能做"文本替换",但它带来了更严重的问题,C++ 社区早已用脚投票——宏不是泛型编程的正道。

  • 宏不检查类型。 宏只是"复制、粘贴"文本,替换完之后才去编译,错误报错发生在替换后的代码上,错误信息常常莫名其妙,而且int 和类的交换在宏里没法区分,写了 temp = left,如果 left 是个复杂对象,语义就完全变了。模板则是在编译期做真正的类型推导和检查。
  • 宏无法处理"借用意图"。 变量、表达式、函数调用塞进宏里,可能被多次求值(比如 #define MAX(a,b) ((a)>(b)?(a):(b))),副作用会重复执行;而模板的参数是"真正的函数参数",有求值规则约束。
  • 宏无法做返回值/重载/作用域。 宏没有类型化的返回值、没有重载决议、没有自己的作用域,也没有模板那些"编译期就能算"的能力。

一句话:宏是"碰运气式"的文本拼接,模板是"编译期结构化的类型运算"。这也是为什么现代 C++ 极力劝你用内联函数和模板来代替宏。记住这一点,你对"为什么模板存在"的理解会更立体。

函数模板

明确了"为什么需要"之后,我们来写函数模板(function template)。

函数模板代表的是一个"函数家族":它与类型无关,在使用时被参数化,进而根据实参(实际传入的参数)类型,产生具体的某种类型版本的函数。

函数模板的定义格式如下:

#include <iostream>
using std::cout;
using std::endl;
 
// 演示"函数模板的格式":尖括号里声明多个类型参数,函数体里凡是该随类型变化处都用 T
template<typename T1, typename T2>
void ShowBoth(T1 a, T2 b)
{
    cout << a << " and " << b << endl;
}
 
int main()
{
    ShowBoth(10, 3.14);      // T1 = int,T2 = double,一个模板函数同时吃两种不同类型
    ShowBoth('A', "hi");     // T1 = char,T2 = const char*(字符串字面量按值传时退化成指针)
    return 0;
}

template 是模板关键字的"开场白",尖括号 <> 里声明模板参数,typename T1 表示"我定义了一个类型参数,名字叫 T1"。注意,尖括号里的这些 T 不是真实存在的类型,而是"待定"的占位符。模板参数用逗号分隔,可以有多个,写法是 typename T1, typename T2, ..., typename Tn。下面就用最经典的 Swap 来演示单个类型参数的情况:

#include <iostream>
using std::cout;
using std::endl;
 
template<typename T>             // 定义类型参数 T
void Swap(T& left, T& right)     // 两个引用参数,类型都是 T
{
    T temp = left;               // 临时变量也用 T,随 T 一起确定类型
    left = right;
    right = temp;
}
 
int main()
{
    int a = 1, b = 2;
    double c = 1.5, d = 2.5;
 
    Swap(a, b);                  // T 被确定为 int,生成 int 版本
    Swap(c, d);                  // T 被确定为 double,生成 double 版本
 
    cout << a << ", " << b << endl;    // 输出:2, 1
    cout << c << ", " << d << endl;    // 输出:2.5, 1.5
    return 0;
}

和前面三个重载版本对比一下,你会发现:我们只写了一份代码,但 T 就像个"万能类型"。当 T 被确定为 int 时,它就等价于 int 版本的 Swap;当 T 被确定为 double 时,就等价于 double 版本。模板就是把我们原本要重复干的活,全都甩给了编译器。

注意上面的细节:Swap 的形参是 T& left, T& right,用的是引用。为什么?因为函数模板同样遵守普通函数的一切规则——如果形参写成 T left(按值传参),那么 Swap(a, b) 交换的就是拷贝出来的临时值,外面的 a、b 根本不会被改动,交换就成了空操作。模板在这一点上并不特殊,它只是"参数化类型"的普通函数。这与后文要讲的参数退化也密切相关:按值传参和按引用传参,对数组、const、引用这些"限定"的处理完全不同。

这里要特别叮嘱一句:typename 是用来定义模板参数的关键字,也可以换成 class(即 template<class T>),两者完全等价。但千万不能用 struct 来替代——template<struct T> 这种写法是错误的。至于 typename 和 class 的区别,我们稍后会专门讲一节。

另外提醒一个定义时的注意点:typename 只在"声明模板参数"时才紧跟 <,函数体内的 T 直接用即可,不要再写 typename。 比如 T temp = left; 里直接写 T,不要再写 typename T temp,那是别处(后文依赖名的情境)才需要的语法。

函数模板的原理:编译器才是真正的工人

我们需要停下来,把函数模板的原理彻底讲透,因为它是理解后面所有内容的地基。

函数模板本身并不是一个函数。 它只是一个"蓝图"或"模具",只有当你真正去使用它的时候,编译器才会基于这个模板,"淬炼"出对应具体类型的真实函数。这个过程发生在编译阶段。

为了把这个"编译期批量生产"讲得更直观,我们要引入一个计算机科学里更精确的词:模板实例化(template instantiation)——编译器根据模板+具体类型"制造出"真实的函数/类代码的过程。你可以把模板当作"菜谱",实例化就是"按菜谱做出的那盘具体的菜"。菜谱本身不能吃,正如模板本身不能调用。

再想深一层:既然实例化发生在编译期、是编译器"按需生产"的,那么就产生一个非常反直觉、但至关重要的推论——未实例化的模板是不存在的。也就是说,如果一个函数模板从头到尾没有被谁调用过,编译器既不会给它分配任何一段真实机器码,也不会深入检查它的函数体是否"用起来合理"(只做最基础的语法检查)。我们把这个话题留到"模板与多文件分离"一节再彻底展开,那里它将是那个诡异"链接错误"的核心原因。

举个例子,假设你写了下面这段调用代码:

#include <iostream>
using std::cout;
using std::endl;
 
template<typename T>
T Add(const T& left, const T& right)
{
    return left + right;
}
 
int main() {
    int a = 10, b = 20;
    double c = 1.5, d = 2.5;
 
    cout << Add(a, b) << endl;   // 编译器推断 T = int,生成 int 版本
    cout << Add(c, d) << endl;   // 编译器推断 T = double,生成 double 版本
    return 0;
}

当编译器在编译阶段看到 Add(a, b) 时,它会偷偷做一次"类型推导":实参 a 和 b 都是 int,于是它就确定 T = int,然后根据模板生产出一份 int Add(const int&, const int&) 的函数,供这条语句调用。同理,看到 Add(c, d) 时,它再推断 T = double,生产出 double Add(const double&, const double&)。

也就是说,最终生成的函数数量,由你实际调用了多少次、用了多少种类型来决定。一次都没被用到的类型,编译器就不会生成对应版本。这带来一个非常反直觉但重要的结论:未实例化的模板是不会被检查甚至可能不被报错的,因为编译器根本还没"展开"它。这一点我们会在后面"模板与多文件分离"一节里再次用到。

用一句话总结函数模板的原理:模板把"重复劳动"交给了编译器,而编译器的"工人"属性决定了——实例化发生在编译期。

为了更好地理解"为什么未实例化的模板可以不报错",我们再往前深挖一层,这背后是标准里一个硬性规定:两阶段查找(two-phase lookup)。C++ 设计者规定,模板里的"名字"分两类:

  • 非依赖名(non-dependent name):与模板参数 T 无关的名字。这类名字在模板定义处(第一阶段)就要能被找到,否则立即报错。比如函数体里的 std::cout、某个类类型的成员函数名,如果不依赖 T,就必须在定义处可见。
  • 依赖名(dependent name):与模板参数 T 相关的名字(例如 T 本身、T::value_type、t.bar() 这类)。这类名字在模板实例化处(第二阶段)才去查找。因为要等 T 定下来,编译器才能在真实的类型上做查找。

正因为第二阶段要"等实参定了再查",所以一个尚未被实例化的模板,其函数体里的依赖名可能从未被检查过,从而错误也可能从未被触发——这解释了"模板不实例化不报错"。

这里要特别交代一个编译器差异(版本差异请务必记牢):上面的标准行为(两阶段查找)在 GCC 和 Clang 里默认就是如此,但微软的 MSVC 有历史包袱——老版本的 MSVC 是"单阶段"查找行为,它把模板体当作"记号流"延迟到实例化时才整体解析。直到 Visual Studio 2017 15.3 之后、并开启 /permissive- 遵从性模式(conformance mode),MSVC 才默认执行标准的两阶段查找。也就是说,同一段写得不太规范的模板代码,在 GCC 和 MSVC 下可能得到不同的报错结果。对初阶读者,你只需要记住"依赖名晚查找、未实例化不报错"这一标准结论即可。

模板实例化:隐式与显式

当你用不同的具体类型去使用一个函数模板时,这就叫函数模板的实例化(instantiation)。模板参数的实例化分为两种:隐式实例化和显式实例化。

隐式实例化

隐式实例化,就是让编译器根据实参,自己推演模板参数的实际类型。你不需要写任何多余的东西,把实参传进去就行。我们用一个加法函数来演示:

#include <iostream>
using std::cout;
using std::endl;
 
template<class T>                 // 用 class 定义类型参数,和 typename 等价
T Add(const T& left, const T& right) {   // 两个参数都是 T 的常量引用
    return left + right;          // 返回两者之和
}
 
int main() {
    int a1 = 10, a2 = 20;
    double d1 = 10.0, d2 = 20.0;
 
    cout << Add(a1, a2) << endl;  // 隐式实例化:编译器推出 T = int
    cout << Add(d1, d2) << endl;  // 隐式实例化:编译器推出 T = double
    return 0;
}

上面这行 Add(a1, a2),两个实参都是 int,编译器推出 T = int;Add(d1, d2),两个实参都是 double,编译器推出 T = double。一切都顺理成章。

但是,隐式实例化有个必须要绕开的坑。假如你写了 Add(a1, d1)——一个 int、一个 double,会怎样?直接看结论:编译不过。为什么?因为在编译期间,当编译器看到这句话时,它要根据实参推演类型:通过 a1 可以把 T 推演成 int,通过 d1 又可以把 T 推演成 double。但模板参数列表里只有一个 T,它到底是 int 还是 double?编译器左右为难,无法定夺,于是报错。

这个例子编不过是很正常的,因为两个不同类型的实参,跟一个模板参数 T 之间产生了"冲突"——一个要求 int,一个要求 double,而我们只有一个"坑位"。此时有两种补救方法:

  1. 你手动进行强制类型转换,让两个实参类型统一;
  2. 使用下面要讲的显式实例化,主动告诉编译器用哪个类型。

之所以能这样补救,根源在于:两个实参类型不一致,才让单参数模板推导失败;把它们统一成同一类型,模板就能顺利推下去。 记住这个"推导失败"的种因,它和后面"模板参数退化"、"重载决议"是同一套机制的三个面。

显式实例化

显式实例化,就是在函数名后面跟一对尖括号 <>,在里面显式指定模板参数的实际类型。这样就不依赖编译器去猜了。

一个小小的术语澄清:日常口语里,我们常说"显式实例化"="自己用 <> 指定类型"。但严格说来,C++ 标准里另有专门的"显式实例化定义"语法(template int Add<int>(int, int);,C++11 起还用 extern template),那是用来在"声明定义分离"的场景下强制生成代码的,属于进阶内容。本文中"显式实例化"先按教科书惯用含义——在调用处显式写 <> 指定模板实参——来使用。后文末尾再给那个严格定义的进阶补充。

#include <iostream>
using std::cout;
using std::endl;
 
template<class T>
T Add(const T& left, const T& right) {
    return left + right;
}
 
int main() {
    int a = 10;
    double b = 20.0;
 
    // 显式实例化:在函数名后的 <> 中指定 T = int
    cout << Add<int>(a, b) << endl;   // b 会被尝试转换成 int
    return 0;
}

关键就在那行 Add<int>(a, b)。我们显式声明了 T = int,于是模板被实例化成 int Add(const int&, const int&)。此时再传一个 double 的 b 进来,编译器会尝试做一次隐式类型转换(把 20.0 转成 int 的 20),只要转换成功,就能编译通过。

这里有个细节值得展开:如果类型不匹配,编译器会尝试隐式类型转换;但如果转换无法成功,编译器就会报错。 换句话说,显式实例化给了你一次"救场"的机会,但前提是这种类型之间本来就存在一种合法的转换路径。比如你要 Add<int> 却传入一个没有构造函数能转成 int 的自定义类,编译器依然会毫不留情地报错。

这里要特别强调一个"理智还原":显式写 <int> 并不会“把 double 变 int”,它只是“要求这个模板按 int 版本实例化”,至于 double 实参能不能塞进去,取决于 double 能不能转成 int。 这两件事要分开看,很多初学者的困惑正源于混淆了"指定的模板实参类型"和"普通参数的类型转换"。

补充说明一下关键字 explicit 与这里的区别:类构造函数前的 explicit 是禁止隐式转换,而这里 Add<int>(a,b) 是显式指定模板参数后"剩下的参数"仍允许普通隐式转换。二者不在同一层,别搞混。

一个小坑:模板函数一般不自动做类型转换

你可能已经敏锐地察觉到,上面 Add<int>(a, b) 是一次显式指定类型之后才发生的转换。如果是隐式实例化再混传不同类型的参数,前面已经说过——直接报错。这里面藏着模板的一个"脾气":在模板里,编译器一般不会主动帮你做类型转换。

为什么?课件里有一个很幽默的比喻:因为一旦转换出了问题,编译器就需要"背锅"。如果编译器擅自对实参做了隐式转换,可能导致精度丢失、语义改变等隐患,而这些责任不应该由编译器默默承担。所以它宁可铁面无私地报错,让你自己把类型显式、可控地写清楚。这个"不自动转换"的原则,也是后面"模板参数匹配规则"的重要一环。记住它:普通函数可以自动类型转换,模板函数不给你自动转(除非显式指定类型)。

用一句更准确的话重申,避免你被"一般"二字带偏:模板在模板参数推导环节不做类型转换;只有在模板参数已经确定(例如显式 <> 指定)之后,对"函数形参的传参"才按普通函数的规则允许隐式转换。这两个阶段别混在一起想。

模板参数的匹配规则

掌握了实例化,我们再看一组更微妙的规则——当一个"同名非模板函数"和"同名函数模板"同时存在时,编译器该怎么选?这就是模板参数的匹配原则。一共三条,都很有用。

第一条:一个非模板函数可以和一个同名的函数模板同时存在,而且这个模板还可以被实例化为这个非模板函数。 这意味着两者可以"和平共处",互不干扰。比如你可以同时写一个 int Add(int,int) 普通函数和一个 template<class T> T Add(T,T) 模板——模板若以 T=int 实例化,恰好也是 int Add(int,int),签名撞车但不违反 ODR(一处定义规则),因为一个是"用户写的具体函数",一个是"编译器按需生成的实例"。

第二条:如果其他条件都相同,编译器在调用时会优先选择非模板函数,而不是从模板再去产生一个实例。 但如果模板能产生一个匹配更好的版本,编译器就会选择模板。

第三条:模板函数不允许自动类型转换,而普通函数可以进行自动类型转换(这一点前文已经强调过)。

光看文字有点绕,上代码立刻清楚。假设我们既写了普通 Add,又写了 Add 模板:

#include <iostream>
using std::cout;
using std::endl;
 
// 专门处理 int 的非模板加法函数
int Add(int left, int right) {
    return left + right;
}
 
// 通用加法函数模板,两个参数类型都是 T(单一类型参数)
template<class T>
T Add(T left, T right) {
    return left + right;
}
 
int main() {
    cout << Add(1, 2) << endl;        // (int,int):与非模板完全匹配 → 优先用非模板,输出 3
    cout << Add<int>(1, 2) << endl;   // 显式指定 T=int → 强制用模板实例化的版本,输出 3
    return 0;
}

咱们逐步拆解 Add(1, 2):实参是两个 int,而恰好存在一个非模板的 int Add(int, int),类型完全匹配,无需任何转换。按"第二条优先选非模板"的原则,编译器直接调用它,不会多此一举去实例化模板。再看 Add<int>(1, 2):你显式写了 <int>,这等于点名"我就要模板实例化出来的 int 版本",于是编译器老老实实生产出模板的 int 实例。

也许你已经注意到:Add(1, 2) 调用的是非模板,而 Add<int>(1, 2) 调用的是模板实例——两者签名相同,只是一个是手写的、一个是生成的。 这正是第一条规则的意义所在:它们可以共存,而选谁由重载决议(overload resolution,编译器挑选最合适函数的一套规则)说了算。

现在来看第三条的精髓——"模板能给出更好匹配"。这需要把模板改成双类型参数才能演示,因为单类型模板遇到 int 和 double 混传会像之前那样推导失败。看看下面这个例子:

#include <iostream>
using std::cout;
using std::endl;
 
// 非模板:只接受两个 int(更精确的"专版")
int Add(int left, int right) {
    return left + right;
}
 
// 双类型参数函数模板:两个参数可以不同类型,返回第一个参数的类型
template<class T1, class T2>
T1 Add(T1 left, T2 right) {
    return left + right;
}
 
int main() {
    // 两个实参类型不同(int, double):
    // 模板可生成 int Add(int, double),两个实参都不需要任何转换,属于"精确匹配"
    cout << Add(1, 2.5) << endl;   // 模板更"合适" → 选模板,输出 3(1 + 2.5 是 3.5,返回 T1=int 截断为 3)
    return 0;
}

为什么这里选模板而非模板调用非模板 Add(int,int)?因为如果调用非模板版,编译器必须把 2.5 转成 int 的 2,这是需要做转换的;而模板能直接办一个 int Add(int, double),两个实参原样进出,零转换。按重载决议的标准,能"不转换就精确匹配"的候选更优,于是模板胜出。这就是"模板能给出更好匹配时,交给模板"的完整画面。

结论一句话:条件相同时优先普通函数;模板能给出更好匹配时,交给模板;而是否"更好",又以"少做转换、少损失精度"为准。

模板参数推导与退化(作为参数)

最后补一个小知识点,也是初学者经常会困惑的:当实参被传进模板函数时,跟在普通函数里的表现一样,会经历参数退化(decay)。我们先精确定义"退化"是什么意思:当一个变量作为"按值参数"传给函数时,它的某些"限定"会被悄悄抹掉,退化成更朴素的形态。 常见的退化场景有:

  • 数组退化成指向首元素的指针——int[10] 变成 int*;
  • 函数名退化成函数指针——void g() 变成 void (*)();
  • 顶层 const(最外层的 const)被剥掉——const int 按值传时变 int;
  • 实参本身若是引用,按值传的时候"引用"这个外壳也被去掉,只留下被引用的值。

换句话说,你传入一个 int[10] 数组,在模板内部推导时,T 很可能被推导为 int* 而不是 int[10]。这就解释了为什么"数组当参数传"大多时候都悄悄变成了指针。

但这里隐藏着一个极其重要的分叉:退化只发生在"按值传参"时。 如果形参是"引用"(T& 或 const T&),那么数组不会退化,T 会被推导成完整的数组类型。我们用下面这个完整程序一次性看清楚:

#include <iostream>
using std::cout;
using std::endl;
 
// 按值接收:T 会退化,数组退化成指针,T 只会是元素指针
template<class T>
size_t ByValueSize(T value) {
    // 读 T 到底被推成什么:这里 needs<T> 我们用一个技巧打印类型
    return sizeof(value);   // value 是 int*,指针在 64 位机上常是 8 字节
}
 
// 按引用接收 + 同时推导出数组长度 N:数组不退化,N 就是元素个数
template<class T, size_t N>
size_t ByRefSize(T (&arr)[N]) {
    return N;               // N 是数组长度,编译期常量
}
 
int main() {
    int arr[16] = {};
 
    // 按值:数组退化为 int*,得到的是指针大小(不是 16)
    cout << ByValueSize(arr) << endl;   // 某 64 位平台输出 8
 
    // 按引用:保留数组完整信息,直接得到元素个数
    cout << ByRefSize(arr) << endl;     // 输出 16
    return 0;
}

上面 ByRefSize 里的技巧非常重要:它用 T (&arr)[N] 作为形参,数组引用阻止了退化,同时借助推导把数组长度 N 也"顺带"导了出来——因为给 int[16] 绑定引用时,编译器能同时推出 T = int、N = 16。这就是"写一个函数既能接收数组、又能知道数组有多大"的经典写法,也是 C++ 里让 std::array、std::size 之类工具能"识别出数组长度"的底层原理之一。

理解"参数会退化"能帮你避开不少推导相关的坑:当你期望模板替你保留数组的完整类型信息时,一定要用引用参数(数组引用)来阻止退化。 现阶段你先记住"按值退化、按引用不退化"这个分水岭就好,具体的引用折叠、const T& 对 T 的影响等细节,留到模板进阶再来磨。

类模板

函数模板解决的是"函数"层面的通用化,那"类"层面呢?比如我想写一个通用栈 Stack,它能存 int、double,也能存自定义对象。显然,又得靠模板——这就是类模板(class template)。

类模板的定义格式

类模板的定义格式和函数模板很像,尖括号里声明类型参数,然后定义类:

#include <iostream>
using std::cout;
using std::endl;
 
// 类模板的最小示例:演示"格式"本身——尖括号里声明类型参数,类体里用 T 定义成员
template<class T1, class T2>
struct Pair
{
    T1 first;      // 成员类型随 T1 变
    T2 second;     // 成员类型随 T2 变
};
 
int main()
{
    Pair<int, double> p{ 10, 3.5 };      // T1 = int,T2 = double
    cout << p.first << ", " << p.second << endl;    // 输出:10, 3.5
    return 0;
}

上面的结构就是类模板的"骨架":template<class T1, class T2> 声明类型参数,随后用 class / struct / union 定义类体,类体里凡是"该随类型变"的地方都用 T1/T2 去占位。真正动手时,我们用下面这个更完整的 Stack(栈)来实战。栈是一种"先进后出"的容器,核心操作是压栈 Push 和出栈 Pop:

#include <iostream>
#include <cassert>
using std::cout;
using std::endl;
 
// 定义一个类模板 Stack,T 是要存储的元素类型
template<typename T>
class Stack
{
public:
    // 构造函数:默认容量为 4,校验非法容量
    explicit Stack(size_t capacity = 4)
    {
        if (capacity == 0)            // 边界:容量绝不能为 0,否则下面 new 0 再扩容会乱
            capacity = 4;
        _array = new T[capacity];     // 用 new 申请一块能存放 capacity 个 T 的内存
        _capacity = capacity;         // 记录容量
        _size = 0;                    // 当前元素个数从 0 开始
    }
 
    // 析构函数:释放掉 new 出来的内存,防止内存泄漏
    ~Stack()
    {
        delete[] _array;              // new[] 配 delete[],成对出现
        _array = nullptr;             // 释放后置空,防止悬空指针
    }
 
    // 压栈:向栈顶放入一个 data
    void Push(const T& data);
 
    // 出栈:弹出栈顶元素
    void Pop()
    {
        assert(_size > 0);            // 断言:栈非空才能出栈
        --_size;
    }
 
    // 栈顶元素
    T& Top()
    {
        assert(_size > 0);
        return _array[_size - 1];
    }
 
    // 判断栈是否为空
    bool Empty() const
    {
        return _size == 0;
    }
 
    // 当前元素个数
    size_t Size() const
    {
        return _size;
    }
 
private:
    T* _array;                        // 存放元素的动态数组,元素类型是 T
    size_t _capacity;                 // 容量
    size_t _size;                     // 实际元素个数
};
 
// 类外定义成员函数:必须先写 template,再用 Stack<T> 指定所属类
template<class T>
void Stack<T>::Push(const T& data)
{
    // 若已满则扩容(真实栈必须处理"装不下"的情况)
    if (_size == _capacity)           // 栈已满
    {
        size_t newCapacity = _capacity * 2;   // 翻倍扩容,均摊成本低
        T* temp = new T[newCapacity];         // 申请一块更大的内存
        for (size_t i = 0; i < _size; ++i)
            temp[i] = _array[i];              // 把旧元素逐个搬过来
        delete[] _array;                      // 释放旧内存
        _array = temp;                        // 指向新内存
        _capacity = newCapacity;              // 更新容量
    }
    _array[_size] = data;             // 在 _size 位置放入 data
    ++_size;                          // 元素个数加 1
}
 
int main()
{
    Stack<int> st1;                   // T = int,生成一个存 int 的栈
    Stack<double> st2;                // T = double,生成一个存 double 的栈
 
    for (int i = 1; i <= 10; ++i)     // 压入 10 个元素,触发自动扩容
        st1.Push(i);
 
    st2.Push(3.14);                   // 向 double 栈压入 3.14
 
    cout << "top of st1: " << st1.Top() << endl;    // 输出:10
    cout << "top of st2: " << st2.Top() << endl;    // 输出:3.14
    cout << "st1 size: " << st1.Size() << endl;     // 输出:10
    return 0;
}

这段代码里有几个值得单独拎出来讲的点。

第一,类的成员函数,凡是涉及"具体类型"的地方都改用 T:数据成员是 T*,构造函数 new T[capacity] 也是按 T 去申请内存,压栈的形参是 const T&。这样一来,同一个模板就能派生出存任意类型的栈。

第二,在类外面定义成员函数时,必须重新写上 template 那一行,并且类名要写成 Stack<T> 这种带尖括号的形式。因为编译器需要知道你正在为"类模板 Stack<T> 的成员"提供实现。这是类模板最常见也最容易忘记的写法。具体来说,void Stack<T>::Push(...) 前面那行 template<class T> 是"定海神针"——没了它,编译器看到 Stack<T> 会以为 T 是个未知类型名而直接报错。

第三,就是函数模板里 T 与 class 关键字等价的那句话,在这里依然成立——第一处我用 typename T,第二处我用 class T,两者等价,演示给你看。

第四,注意构造函数的 explicit 和扩容逻辑并不是"模板"特有的东西,而是普通类该有的健壮性,模板一样需要:容量为 0 的边界要拦截,栈满要有扩容的预案。这也给你一个提示——写类模板时,你是在写"一类性子类"的骨架,所以普通类里该处理的坑(内存、边界、拷贝)一个都不能少。

关于构造函数,这里再补充一点:Stack<pair<int,double>> 这样的"模板套模板"是允许的——模板参数可以是任何类型,包括另一个模板的实例。这就是为什么 STL 里能写出 std::vector<std::vector<int>> 这种"二维容器"。从这个意义上说,模板是一种可组合的类型生成机制。

延迟实例化:类模板的成员并非全都提前生成

这个知识点对彻底理解类模板至关重要,也是"未实例化不报错"在类这一侧的体现。请你记住:类模板被实例化时(比如写下 Stack<int>),并不是所有成员函数都被立刻生成代码。 编译器只会为"被真正使用到"的成员函数生成实例,其余成员函数先"挂账",等用到时再补生成。

这意味着什么?意味着如果一个成员函数写得有问题(比如语法或语义上有错误),但你在程序里从头到尾没调用它,那么那个错误可能永远不被发现。举例:

#include <iostream>
using std::cout;
using std::endl;
 
template<class T>
class Box
{
public:
    T value;
    void BadMethod() { int x = "类型不匹配"; (void)x; }   // 语义有错:int 装不下字符串字面量
    void GoodMethod() { cout << value << endl; }
};
 
int main()
{
    Box<int> b;
    b.value = 7;             // 给 value 赋一个确定值,让输出清晰
    b.GoodMethod();          // 只用到 GoodMethod,BadMethod 不会实例化,其"错误"不会被触发,输出:7
    return 0;
}

你可以把上面这段代码拿去编译试试——它能通过编译并正常运行,BadMethod 里的"类型不匹配"因为从未被调用而逃过了检查。一旦你在 main 里加上 b.BadMethod(); 再编译,编译器在实例化这个函数时才把错误报出来。这是类模板"按需实例化"的直接证据,也是很多老项目里"某段模板代码突然在某次调用后才爆错"的根因。

类模板的实例化:Stack 是类名,Stack<int> 才是类型

函数模板的实例化靠实参推导,但类模板的实例化方式完全不同:你必须在类模板名后面跟一对尖括号 <>,并把实例化的类型放进 <> 里。

这里有一个非常重要的概念辨析:类模板的名字本身并不是一个"类",只有实例化之后的结果才是真正的类。

举个例子,Stack 只是一个"类模板",不是类;而 Stack<int> 才是真正的类——它表示"存 int 的栈"这个具体的类。同样 Stack<double> 又是另一个独立的类。所以你在写代码时,声明对象的写法必须是 Stack<int> st1;,而绝不能简写成 Stack st1;(如果没有 Stack 这个非模板类的话,那样写会直接编译报错)。

Stack<int> st1;      // 正确:Stack<int> 才是一个真正的类型(类)
Stack<double> st2;    // 正确:每一种类型参数都对应一个独立的类

再直白一点:Stack 是"类名",Stack<int> 才是"类型"。 这一句请务必记住,它解答了"为什么我写了老半天却还得在后面带尖括号"的疑惑——因为每次你只要选一个具体类型,就会产出"一个新的类"。

顺着"每个不同实参都是全新、独立的类型"这句话再想一层:Stack<int> 和 Stack<double> 虽然都叫 Stack,但它们是两个完全不同的类,你不能把 Stack<int> 的对象赋给 Stack<double>,两者之间没有任何自动转换,也不能共用静态成员(如果类里有 static 成员,那每个实例都会各自持有一份独立的静态成员)。凡是"绑定特定类型"的资源,在不同的 Stack<T> 实例之间都是隔离的——这对保持类型安全至关重要,也正是泛型编程不牺牲类型安全的原因。

由于类型名太长,实战里我们常给模板实例起别名,这能极大改善可读性:

#include <iostream>
using std::cout;
using std::endl;
 
template<class T>
class Stack
{
public:
    Stack() : _v(new T[4]), _cap(4), _sz(0) {}
    ~Stack() { delete[] _v; }
    void Push(const T& d) { _v[_sz++] = d; }
    T Top() const { return _v[_sz - 1]; }
private:
    T* _v;
    size_t _cap;
    size_t _sz;
};
 
int main()
{
    // 给模板实例起一个更顺手的别名
    using IntStack = Stack<int>;
    using DoubleStack = Stack<double>;
 
    IntStack s1;          // 等价于 Stack<int> s1;
    DoubleStack s2;       // 等价于 Stack<double> s2;
 
    s1.Push(100);
    s2.Push(2.71);
    cout << s1.Top() << ", " << s2.Top() << endl;   // 输出:100, 2.71
    return 0;
}

using 别名(C++11 引入)在这里起到的作用,和给普通类取别名一样——只不过我们"起名"的对象多了 <int> 这一层。另外提醒:别把"类模板的默认参数"和"别名"混淆。template<class T, class Alloc = std::allocator<T>> 这样的默认模板参数能让 vector<int> 少写一个参数,那是模板自身的另一种机制,了解一下即可。

非类型模板参数

你有没有想过:模板参数是不是只能填"类型"?答案是否定的。模板参数除了类型参数(type parameter),还可以是非类型参数(non-type parameter)——也就是说,可以用整数、指针、引用这样的"值"来当模板参数。

我们把话挑明:之前的 T 是"类型的占位符";非类型参数则是"编译期常量值的占位符"。它放的不是类型,而是一个在编译期就要确定下来的值。

最常见的非类型模板参数就是尺寸(大小)。比如你想写一个固定容量的数组容器,容量是多少这种"常量值",就很适合塞进模板参数里:

#include <iostream>
using std::cout;
using std::endl;
 
// 数组容器:N 是模板的非类型参数,表示容量,必须是编译期常量
template<typename T, size_t N>
class MyArray
{
public:
    // 返回数组元素个数,直接用 N
    size_t Size() const
    {
        return N;
    }
 
    // 通过下标访问元素(这里不做越界检查,方便演示)
    T& operator[](size_t i)
    {
        return _data[i];
    }
 
private:
    T _data[N];       // 用 N 声明一个固定大小的数组,N 在编译期是确定的
};
 
int main()
{
    MyArray<int, 10> arr;        // T = int,N = 10,一个能存 10 个 int 的数组
    MyArray<double, 5> arr2;     // T = double,N = 5,一个能存 5 个 double 的数组
 
    arr[3] = 42;                 // 通过下标写入
    arr2[0] = 1.1;               // 顺手也往 arr2 写一点,避免它只是摆设
    cout << arr[3] << endl;      // 输出:42
    cout << arr2.Size() << endl;  // 输出:5
    cout << arr.Size() << endl;  // 输出:10
    return 0;
}

这里最关键的一点是:非类型模板参数必须是编译期就能确定的常量表达式。 你不能用一个运行期才知道的变量去实例化,比如 size_t n = getUserInput(); MyArray<int, n> x; 就是错误的——因为编译器在编译阶段就需要拿到 n 的准确值来定数组大小,而 n 在运行时才确定,编译器根本无从展开模板。这跟前面讲的"模板在编译期实例化"完全呼应。

为了把"必须是编译期常量"这条红线讲得更具体,我们再补充两类"可以用"和"不可以用"的对比:

  • 可以用:字面值(10、'A'、true)、constexpr 变量、通过 sizeof、枚举值等能在编译期算出来的量。
  • 不可以用:函数调用结果(getUserInput())、普通的 const 变量能否用要看它是不是 constexpr 且能在编译期初始化——普通的 const tv(int tv = 40; 后在运行时初始化)不一定能当模板实参。一句话:"能写在 constexpr 常量表达式里的量"才是非类型模板参数的正确实参。

顺带补一个非类型参数的小特性:非类型模板参数的值允许隐式转换。比如模板参数类型是 int,你传 'c'(char),C++ 会把它的值 99 转成 int 再实例化(C++17 里这种整型转换是允许的)。所以 Print<5>(); 和 Print<'c'>(); 虽然实参一个 int 一个 char,但都落到"值为 5 / 值为 99"的 int 参数上。

顺带提一下 C++ 的版本演进,这是本文里少有的"版本差异"彩蛋,请务必记牢,因为它直接决定你能不能用某些写法:

  • C++11 及之前:非类型模板参数对类型限制很严格,基本只能是比较朴素的整型、枚举、指针、引用、std::nullptr_t(C++11 起)和成员指针。想写"既能存 int 又能存 char 值的常量参数",得笨拙地写成 template<class T, T V> 这样才能同时记住类型和值。
  • C++17:引入 auto 作为非类型模板参数类型(提案编号 P0127R2,经 cppreference/cpprefjp 确认),写法变成 template<auto N>,值类型由实参自动决定。但请注意:此时浮点仍不被允许。
  • C++20:进一步允许浮点类型、以及字面值类类型(literal class type)作非类型模板参数(这一点基于标准库 std::bitset 用位数作参数的同类思想)。至此,非类型参数的"值域"大幅拓宽。

对初阶来说,你只需要牢牢抓住"必须是编译期常量"这一个灵魂即可,版本细节当作扇窗先推开,等真踩到"为什么我这样写编译不过"时再回来对照。

typename 与 class:一字之差,道理十足

在逛示例代码时,你可能早就注意到了:有时候我是 template<typename T>,有时候是 template<class T>,两者似乎能互换。没错,在定义模板的"类型参数"这个用途上,typename 和 class 完全等价,你可以按个人喜好任选。但有些细节值得厘清。

第一,class 这里跟"面向对象里的类"没有必然关系——你用它定义 T 时,T 可以是任何类型,int、double、指针都可以。不要被 class 这个单词吓到,觉得自己只能填自定义类。它在这里就是一个"类型参数的占位符"而已。

第二,为什么会有 typename?因为 class 在模板里容易造成语义混淆。后来 C++ 引入 typename 这个专门的关键字,就是为了更清晰地表达"这是一个类型"。从语义上讲,typename 更准确、更受推荐。事实上,C++ 之父 Stroustrup 本人也常建议多用 typename,因为它更能体现"这是一个待定的类型"。

第三,struct 不能顶替 class。 写 template<struct T> 是非法的。这里没有道理可讲,就是语法规定:你要么用 class,要么用 typename。

第四,有一个必须使用 typename 而不能用 class 的经典场景:在模板内部,当你需要告诉编译器"某个名字是一个类型"的时候。举一个初阶阶段你可能不容易遇到、但遇到就抓瞎的场景——假设 T 是一个类类型,你想在模板里用"T 内部定义的一个嵌套类型":

#include <iostream>
using std::cout;
using std::endl;
 
struct Foo {
    using Value = int;       // Foo 内部定义了一个嵌套类型别名 Value
};
 
// 真正解读"T::Value 是一个类型"时,必须用 typename 开头:
template<class T>
void Show() {
    typename T::Value v = 100;   // 必须写 typename,告诉编译器 T::Value 是类型
    cout << v << endl;
}
 
int main() {
    Show<Foo>();             // Foo 的嵌套类型 Value 是 int,输出:100
    return 0;
}

为什么 typename T::Value 不能省?因为 T::Value 这种写法具有二义性——它既可能是一个"类型",也可能是一个"静态成员变量"。C++ 编译器在处理模板时,默认会先把 T::Value 当作一个值来理解,而不是类型。所以你必须加上 typename 前缀,明确告诉编译器:"听好了,T::Value 是类型,请把它当类型用。"这就解释了为什么 typename 的存在并不仅仅是为了和 class 二选一,它有自己不可替代的任务。

这也是"两阶段查找"里那个"依赖名"概念的用武之地:T::Value 依赖模板参数 T,编译器在第一阶段(定义处)无从知道它到底是类型还是值,于是需要一个显式声明。凡是这种"依赖名 + :: + 嵌套名字"且你想表示"这是个类型"的地方,就得上 typename。

版本差异也在此处有一笔:C++20 起,typename 在若干常见上下文(例如声明变量、返回类型、形参类型等)可以省略,编译器能"猜"出那里必须是个类型;但语言设计者仍建议你习惯性把 typename 写出来,因为它让意图一目了然、也让你写的代码在老标准下同样成立。所以对初阶,请记住一句稳的话:定义类型参数,typename 和 class 等价;想在模板里把某个依赖类型的名字当类型用时,写上 typename 最保险。

模板与多文件分离

这大概是模板里最让初学者崩溃的一个话题。我们早就习惯把"函数的声明"放在 .h 头文件、把"定义"放在 .cpp 源文件里,工程化地组织代码。可一旦对模板也这么干,编译器却会报出一堆令人困惑的"链接错误"。这是为什么?

要讲清楚这个,我们先回忆一下前面反复铺垫的那个地基:模板的实例化发生在编译阶段,而且发生在"编译器看到模板 + 看到具体调用"的那个编译单元里。注意这个措辞——必须在同一个编译单元里同时看到"模板的完整定义"和"调用",编译器才能就地实例化。

现在设想你做了一个"分工明确"的项目:

  • func.h 里声明了函数模板 Swap;
  • func.cpp 里定义了函数模板 Swap 的函数体;
  • main.cpp 里包含 func.h 并调用 Swap(a, b)。

看起来井井有条,对不对?但实际上它无法通过链接。我们一步步看编译发生了什么。

第一步,main.cpp 这个编译单元被单独编译。 编译器在 main.cpp 里只看到了 Swap 的声明(来自 func.h),它据此知道"存在一个这样的函数",于是生成了对 Swap<int> 的调用指令。但 main.cpp 里没有 Swap 的函数体,编译器无从据此实例化出一个真正的 Swap<int> 函数实现来。

第二步,func.cpp 这个编译单元被单独编译。 编译器在这里看到了 Swap 的完整定义。但,问题来了——func.cpp 里没有任何地方实际调用 Swap,所以编译器不知道该实例化成 int 版本、double 版还是别的,于是它就一个实例都不生成(还记得前面那句话吗:未实例化的模板编译器不会处理)。

第三步,链接。 链接器把所有编译产物合到一起。main.cpp 侧需要 Swap<int> 这个符号的实现,func.cpp 侧却一个 Swap 的实例都没生产出来。两边对不上,链接器报错:找不到 Swap<int> 的定义。这就是所谓的"模板无法声明定义分离"的根源。

结论非常清晰:func.h 声明、func.cpp 定义、main.cpp 调用,这三者在编译期被"隔离"开了,谁都见不到能触发实例化的完整信息。 与其憋着这样的结构然后各种折腾,不如干脆约定:模板的声明和定义放在同一个文件(通常就是头文件)里。

这才是"正确做法":

// 文件:swap.hpp(模板的头文件,把声明和定义放在一起)
#ifndef SWAP_HPP            // 头文件保护符,防止重复包含
#define SWAP_HPP
 
template<typename T>
void Swap(T& left, T& right) {   // 定义直接写在头文件里
    T temp = left;
    left = right;
    right = temp;
}
 
#endif                        // 结束头文件保护
// 文件:main.cpp(使用方只需包含头文件即可)
#include <iostream>
#include "swap.hpp"          // 包含模板头文件,模板完整定义在这里
using std::cout;
using std::endl;
 
int main() {
    int a = 1, b = 2;
    double c = 1.5, d = 2.5;
 
    Swap(a, b);              // main.cpp 里能看到完整模板 → 这里才实例化
    Swap(c, d);              // 同理再实例化 double 版本
 
    cout << a << ", " << b << endl;   // 输出:2, 1
    cout << c << ", " << d << endl;   // 输出:2.5, 1.5
    return 0;
}

只要 main.cpp 在包含 swap.hpp 时,同时能"看见"模板的定义,编译器就有能力当场实例化出 Swap<int>、Swap<double>,链接自然顺利通过。所以记住那条铁律:模板的声明和定义必须放在同一个文件(通常是头文件)里,或者放在一个被包含的源文件中。 一旦违反,等着你的就是一头雾水的链接错误。

也许你会问:那 Stack 那种成员函数特别多的类模板,全塞进头文件会不会很乱?确实会显得文件很大,但这就是 C++ 模板的现实——几乎所有用模板写的库(STL 里那些 vector、map 的实现)都是这么干的。随着你逐渐接触现代 C++,会看到各种"模板单文件"的取舍方案,但"声明定义须同居一处"这个地基,永远不会变。

在这一节,我们还欠你一个交代——"同处一处"真的只有这一条路吗?有没有变通方案? 答案是有的,了解它们能让你不但"知其然"还"知其所以然",而且对工作有帮助:

  1. 显式实例化来兜底。 如果团队非要保留 .h 声明 + .cpp 定义的结构,可以在 .cpp 里用标准语法的"显式实例化定义"把需要的类型强制生成出来(这正是本题开头那句"严格意义的显式实例化"的正主):

    // func.cpp:模板定义 + 需要哪些类型就"点名"生成哪些
    template<typename T>
    void Swap(T& left, T& right) {
        T temp = left; left = right; right = temp;
    }
     
    template void Swap<int>(int&, int&);      // 显式实例化定义:现在就生成 int 版本
    template void Swap<double>(double&, double&);  // 生成 double 版本

    再配合 C++11 的 extern template(在头文件里写 extern template void Swap<int>(int&, int&);),可以告诉其他编译单元"这个版本已在别处生成,别重复实例化",从而提升编译速度。但它的代价是:每引入一个新类型,都要手动在这里补一条,很烦,所以这通常只用在"类型集合已知且有限"的场合(编译器、以及优化大型库时才会用)。

  2. 把 .cpp 变成"被包含的翻译单元"。 有些人会用 .tpp、.ipp 之类的命名(非标准,纯属约定)存放模板实现,然后在头文件末尾 #include "stack.tpp"。本质上还是"同处一处",只是把摊子拆得更整齐而已。

    注意:这样 .tpp 会被包含进来,所以别把它也放进编译命令里单独编译,否则可能重复定义。这就是"模板单文件"方案里一个必须牢记的边界。

  3. 用非模板类型擦除(type erasure)绕开模板。 最常见的实现是"void* + 用户提供复制回调"或 std::function、虚函数多态那一套。它能把实现藏进 .cpp,但牺牲了"每种类型一套专用代码"的性能、类型安全和直接性。泛型编程与 OOP 多态"一文一武"各有取舍。

这些都是进阶话题,你现在只要记住主线:默认情况下,模板就老老实实住进头文件;真有必要才用显式实例化 extern template 那一套。 最不该做的,就是照搬普通函数那一套 .h/.cpp 分家,然后被玄学的链接错误折磨一下午。


最后,让我帮你在脑海里把这篇文章的骨架搭一遍。我们从"为什么需要模板"出发,看到了函数重载带来的代码重复、维护性差的痛点(以及宏做泛型的局限),于是引出了泛型编程和模板。接着写了函数模板,发现它自己不是函数而是一个"模具",真正干活的是编译器在编译期替我们批量生产,并且背后有两阶段查找在支撑"未实例化不报错"这个反直觉结论的成立。然后我们区分了隐式实例化(编译器推导)与显式实例化(自己指定类型),也看清楚了模板参数匹配规则里"优先非模板、更好匹配用模板、模板不做自动转换"的三条原则,并沿着参数推导摸到了退化这个"按值变指针、按引用保完整"的分水岭。再往下,我们把模板用到类上,写了 Stack,弄明白了类模板实例化要靠 <>、以及"Stack 是类名、Stack<int> 才是类型"这句话,还领教了类模板成员延迟实例化的脾气。我们还顺带认识了非类型模板参数(用编译期常量当参数,C++17 有 auto、C++20 放开浮点与字面值类)、typename 与 class 的一字之差(定义参数等价、依赖名消歧义必须用 typename),以及那个最让人头疼却又最关键的真相——模板的声明和定义必须同处一个文件。

这一路下来,其实贯穿始终只有一条主线:模板是在编译期、由编译器替你按类型批量生成代码的机制。 只要你时刻把这句话记在心里,遇到"为什么模板必须放头文件""为什么模板不实例化就不报错""为什么类型推导会退化"这类问题时,就都能自己推导出答案了。牢牢抓住"编译期"这三个字,把它当作你理解和判断一切模板行为的罗盘——掌握了它,你已经迈进了 C++ 模板世界最坚实的第一步。

如果你愿意,可以动手做这几个小练习来检验自己:用类模板写一个"定长数组容器",并用编译期常量当它的长度参数;分别用 typename 和 class 写两个等价的小模板;再想想——如果 Stack 的成员函数全放进 .cpp 且不显式实例化,会报什么错?把这些亲手跑一遍,你对模板的掌控感会完全不同。

小练习参考答案

1. 用类模板写"定长数组容器",长度用编译期常量当参数

这就是非类型模板参数的典型用法——把"数组长度"也交给 <...> 指定的编译期常量,而不是某个运行期变量:

template<typename T, size_t N>      // T 是元素类型,N 是编译期常量(它必须是常量表达式)
class FixedArray
{
public:
    void Set(size_t i, const T& v) { _data[i] = v; }
    size_t Size() const { return N; }
    T& operator[](size_t i) { return _data[i]; }
private:
    T _data[N];                     // 长度 N 在编译期就已定死,而不是在运行期拿变量当大小
};
 
int main()
{
    FixedArray<int, 5> arr;         // 一个能装 5 个 int、在编译期就定好大小的容器
    arr.Set(0, 42);
    return 0;
}

关键点:N 必须是一个编译期常量(字面量、constexpr 变量等),不能是运行期才知道的普通变量。这正是一般 C++ 数组对"长度必须编译期确定"要求的模板化体现。

2. 分别用 typename 和 class 写两个等价的小模板

在"定义模板参数"这个位置,两者完全等价,选哪个纯粹是个人习惯(老代码多写 class,现代代码更喜欢 typename):

template<typename T> void f(T a) { /* 用 typename */ }
template<class T>    void g(T a) { /* 用 class */ }

注意:等价仅限于"声明模板参数"这一处。一旦进入依赖名消歧义的场景(比如 typename T::iterator 里,T::iterator 是个依赖名字,在实例化前编译器不知道它到底是不是类型),此时就只能用 typename 来显式告诉编译器"我要把它当类型用",不能用 class 替代。这就是正文里"一字之差,道理十足"那句的由来。

3. Stack 的成员函数全放进 .cpp 且不显式实例化,会报什么错?

典型报错是链接阶段出现 "无法解析的外部符号 / unresolved external symbol / undefined reference"(VS:error LNK2019 ... Push;g++/clang:undefined reference to "Stack<int>::Push(...)")。

原因在前面"模板与多文件分离"一节已经讲透:模板是"用的时候才现生成"的。当你把成员函数定义写进 Stack.cpp,而使用者在 test.cpp 里只写了 #include "Stack.h"(头文件里只有声明、没有定义)时——编译器在编译 test.cpp 时根本看不到成员函数的定义,没办法实例化出真正的函数代码,于是只留下一个"这一符号将由别处提供"的空引用;而真正写有定义的 Stack.cpp 编译单元里,又没人真正 Stack<int> 使用它,模板默认"没被用到就不生成代码"。两头都落空,链接器就找不到符号。唯一解法要么把所有定义也放进头文件,要么在 Stack.cpp 里对该类型做一次显式实例化(template class Stack<int>;),正文两种方案都讲了。