C++入门篇(十八):模板进阶——非类型参数、特化全家桶与分离编译(收官篇)

0.1 概述&序言

这里是白杨。上一篇把 priority_queue 从用法一路挖到仿函数和指针偏特化,结尾卖了个关子:偏特化还有别的玩法。这一篇就是兑现------它也是 C++ 入门篇的最后一讲:模板进阶。

要过四件事:

  • 非类型模板参数:array<T, N> 里的 N 是什么东西
  • 模板特化全家桶:函数模板特化、类模板全特化、偏特化
  • 特化实战:给 vector<Date*> 排序(指针比地址的坑,在这里给完整解法)
  • 分离编译:一个"编译全通过、链接才翻脸"的经典现场,附带真实报错原文

老规矩,跑得通的结果才写进来(环境见下);完整代码文末自取,也可以直接去仓库:gitee.com/BaiYang_SQ/template-advanced-sim。

让我们开始吧。

一、非类型模板参数:把常量当参数

模板参数其实分两类:

  • 类型形参 :跟在 class / typename 后面的,传的是"一个类型",比如 T;
  • 非类型形参 :传的是"一个编译期常量",比如 size_t N------它在模板里可以直接当常量用。

长得最典型的例子就是自己造一个 array:

cpp 复制代码
namespace bite
{
template <class T, size_t N = 10>
class array
{
public:
    array() : _size(N) {}   // 原版示例没给 _size 赋值,这里补上让它能跑

    T& operator[](size_t index) { return _array[index]; }
    const T& operator[](size_t index) const { return _array[index]; }

    size_t size() const { return _size; }
    bool empty() const { return 0 == _size; }

private:
    T _array[N];      // N 直接拿来当数组长度------妙就妙在这
    size_t _size;
};
}

N 妙在哪:它是常量,所以能直接下数组长度 T _array[N];而数组长度必须是编译期常量------两边严丝合缝。实例化时:

cpp 复制代码
bite::array<int, 5> a5;   // N = 5,底层 int _array[5]
bite::array<int> a10;     // N 走默认值 10

实测输出:

text 复制代码
array<int, 5>  size=5  data: 0 10 20 30 40
array<int>     size=10(默认参数 N=10)

【必背】非类型模板参数两条红线:

  1. 浮点数、类对象、字符串不能当非类型模板参数 ------array<int, 3.14> 直接编译不过;
  2. 它必须是编译期就能确定 的常量,不是运行时变量。

二、特化的动机:一个 Less 的翻车现场

模板对大多数类型都好使,遇到指针就等着翻车。看一个专门比大小的函数模板:

cpp 复制代码
template <class T>
bool LessFunc(T left, T right)
{
    return left < right;
}

int 比、Date 对象比,都正常;但传两个 Date* 进去:

cpp 复制代码
Date arr[2] = {Date(2022, 7, 8), Date(2022, 7, 7)};
Date* p1 = &arr[0];   // 日期更大,地址更低
Date* p2 = &arr[1];   // 日期更小,地址更高

cout << LessFunc(p1, p2) << endl;

它比的是 p1 < p2------两个指针比大小,比的是地址,跟日期一点关系没有。实测输出(配合对比):

text 复制代码
LessFunc(1, 2)         = 1(内置类型,正确)
LessFunc(d1, d2)       = 1(日期对象,正确)
LessFunc(p1, p2)       = 0(特化后比对象:7-8 < 7-7 不成立,正确)
std::less<Date*>()     = 1(未特化:比地址,错误)

后两行值得停一下:p1 指向的是 7-8,p2 指向 7-7,按日期 7-8 不该"小于"7-7;可按地址,低地址的 p1 反而"小"了。第三行是修好之后的结果,第四行是没修的样子。

模板本身没写错,错的是"通用逻辑对特殊类型不成立" ------这时候就需要特化:在原模板的基础上,给特殊类型单独开小灶。

三、函数模板特化(4 步)

给 LessFunc 开小灶,就 4 步:

  1. 先有一个基础的函数模板;
  2. template 后面跟一对空的尖括号 <>;
  3. 函数名后跟尖括号,写明要特殊处理的类型:LessFunc<Date*>;
  4. 形参类型必须和基础模板完全一致。
cpp 复制代码
template <>
bool LessFunc<Date*>(Date* left, Date* right)
{
    return *left < *right;    // 指针版本:比指向的对象
}

改完之后,LessFunc(p1, p2) 就会走特化版本,输出 0,正确。

【易错】第 4 步是坑王:形参写得不完全一致(比如基础模板是 T,特化里写 const Date*),不同编译器给报错乱七八糟的,查起来很折磨人。

【面试】函数模板特化能不能不用?其实工程上更推荐直接写一个普通函数:

cpp 复制代码
bool LessFunc(Date* left, Date* right) { return *left < *right; }

简单、直观、可读性好。函数模板特化更多是"知道它有、面试会问",真到动手写代码的时候,普通函数重载往往更省心。

四、类模板特化:全特化与偏特化

类模板的特化分两层:

  • 全特化 :模板参数列表里的参数全部确定;
  • 偏特化 :一部分参数确定,或者对参数做更进一步的条件限制(指针、引用就是典型)。

来看一个经典例子------Data 家族。主模板长这样:

cpp 复制代码
template <class T1, class T2>
class Data
{
public:
    Data() { cout << "Data<T1, T2>(主模板)" << endl; }
private:
    T1 _d1;
    T2 _d2;
};

真正的功夫在下面几种"开小灶"版本:

cpp 复制代码
// 全特化:两个参数都定死
template <>
class Data<int, char>
{
public:
    Data() { cout << "Data<int, char>(全特化)" << endl; }
private:
    int _d1;
    char _d2;
};

// 偏特化一:第二个参数定死为 int
template <class T1>
class Data<T1, int>
{
public:
    Data() { cout << "Data<T1, int>(偏特化)" << endl; }
private:
    T1 _d1;
    int _d2;
};

// 偏特化二:条件限制------两个都是指针
template <typename T1, typename T2>
class Data<T1*, T2*>
{
public:
    Data() { cout << "Data<T1*, T2*>(指针偏特化)" << endl; }
private:
    T1 _d1;
    T2 _d2;
};

// 偏特化三:两个都是引用
template <typename T1, typename T2>
class Data<T1&, T2&>
{
public:
    Data(const T1& d1, const T2& d2)
        : _d1(d1), _d2(d2)
    {
        cout << "Data<T1&, T2&>(引用偏特化)" << endl;
    }
private:
    const T1& _d1;
    const T2& _d2;
};

挨个实例化,看编译器"点名"了谁:

text 复制代码
Data<T1, int>(偏特化)        ← Data<int, int>
Data<int, char>(全特化)      ← Data<int, char>
Data<T1, int>(偏特化)        ← Data<double, int>
Data<T1, T2>(主模板)         ← Data<int, double>(都没命中)
Data<T1*, T2*>(指针偏特化)   ← Data<int*, int*>
Data<T1&, T2&>(引用偏特化)   ← Data<int&, int&>

如图调试,分别调用结果:

【必背】命中优先级:全特化 > 偏特化 > 主模板。编译器拿你给的类型去家族里找"最贴合"的那一版,找不到才退回主模板。

另外注意一个细节:偏特化不一定是"参数写死一半",像 Data<T1*, T2*> 这种"要求两个参数都是指针"的条件限制,同样算偏特化------这也是"偏"字的另一层含义。

五、实战:给 vector<Date*> 排序

把上面的功力用在一个真实场景里。先写一个仿函数类模板:

cpp 复制代码
template <class T>
struct Less
{
    bool operator()(const T& x, const T& y) const { return x < y; }
};

vector<Date> 排序一切正常;但如果是 vector<Date*>,Less<Date*> 里的 x < y 又变成了比地址。十七篇我们已经踩过一次这个坑(当时用的偏特化),这次给全特化解法------直接点名 Date*:

cpp 复制代码
// 全特化:Date* 改为比较指向的日期对象
template <>
struct Less<Date*>
{
    bool operator()(Date* x, Date* y) const { return *x < *y; }
};

实测对照(指针故意乱序入队):

text 复制代码
[vector<Date>  排序] 2022-7-6 2022-7-7 2022-7-8
[按地址排序  ] 2022-7-7 2022-7-6 2022-7-8
[Less<Date*> 特化] 2022-7-6 2022-7-7 2022-7-8

第二行是"按地址排序"的样子:7-6 排在 7-7 后面,一眼就能看出不是日期顺序;第三行是特化之后的正确结果。

【面试】全特化和偏特化怎么选?只治"某一种具体类型"(Date*)用全特化;要治"一类类型"(所有指针 T*)用偏特化------十七篇的 Less<T*> 就是后者,一次生效、永不遗漏。两把刀各有各的场合。

六、分离编译:编译全通过,链接才翻脸

最后一个大坑,也是最经典的"新手见鬼现场"。

先说正常流程:一个 C++ 程序由多个源文件组成,每个源文件单独编译 成目标文件(.o / .obj),最后链接成一个可执行文件。这套机制叫分离编译。

那模板参与分离编译会怎样?把声明和定义分家试试:

cpp 复制代码
// ===== a.h:只有声明 =====
template <class T>
T Add(const T& left, const T& right);

// ===== a.cpp:定义在这里 =====
template <class T>
T Add(const T& left, const T& right)
{
    return left + right;
}

// ===== main.cpp:正常调用 =====
#include "a.h"
int main()
{
    Add(1, 2);       // 需要 Add<int>
    Add(1.0, 2.0);   // 需要 Add<double>
    return 0;
}

单看每一处都挑不出毛病:声明有、定义有、调用有。但一编译------

text 复制代码
[编译 a.cpp] 通过
[编译 main.cpp] 通过
[链接]        undefined reference to `int Add<int>(int const&, int const&)'
              undefined reference to `double Add<double>(double const&, double const&)'
              collect2.exe: error: ld returned 1 exit status

(上面是 g++ 的真实输出;MSVC 下的等价信息是 error LNK2019: 无法解析的外部符号。)

为什么编译都过了,链接却找不到? 把三个环节拆开看:

  • 头文件根本不参与单独编译,它只是被"包含";
  • a.cpp 里只有模板的定义,但没有任何调用 ------模板是"按需实例化"的,没看到 Add<int> 的调用,就不会生成 Add<int> 的具体函数;
  • main.cpp 编译出来的 main.o 里,Add<int> 的位置留了个空地址,就等着链接时去别处贴;结果翻遍所有目标文件都没这个函数------undefined reference。

【必背】模板的声明和定义不能随便分家。两条修法:

  1. (推荐)声明和定义都放进头文件(.h / .hpp 都行),谁包含谁实例化,一劳永逸;
  2. 显式实例化:定义留在 .cpp,末尾加
cpp 复制代码
template int Add<int>(const int&, const int&);
template double Add<double>(const double&, const double&);

强制生成指定版本------能用,但每多一种调用类型就要补一行,工程上不实用,不推荐。

修复后两套方案都能跑,实测输出相同(3、3、11)。仓库里的 split/ 目录就是这三套代码:坏例、修复例、显式实例化,output.txt 里录了完整的链接错误原文。

七、模板总结 & 收官盘点

模板这套机制,优点和坑都鲜明:

优点 缺陷
代码复用:一份模板适配所有类型 代码膨胀:每种实例化都生成一份
泛型编程的基石,STL 因此诞生 编译时间变长
灵活性:容器/算法/仿函数随便组合 报错信息非常乱,定位靠经验

从第一篇的基础语法,到 string / vector / list / stack / queue 的用法与手写,再到最后的模板进阶------十一篇下来,C++ 入门篇这套主线到这里就算走完了。回头看,模板和"实例化"三个字贯穿了后半程:容器是模板、仿函数是特化、连链接错误都是"没实例化"惹的祸。

【一句话记】模板的坑,十有八九栽在"实例化"上:什么时候实例化、在哪实例化、有没有实例化。

八、速记卡

考前扫一眼,面试不卡壳:

九、自测三连

先自己默答,再看答案对照:

  1. 非类型模板参数是什么?有什么限制?

答案

用编译期常量当模板参数,比如 template <class T, size_t N> 里的 N,可以拿来做数组长度。限制:浮点数、类对象、字符串不能当非类型参数;且必须在编译期确定。

  1. 特化的命中优先级是什么?全特化和偏特化的区别?

答案

优先级:全特化 > 偏特化 > 主模板。全特化把所有模板参数都确定(Data<int, char>);偏特化是部分参数确定(Data<T1, int>)或对参数做条件限制(Data<T1*, T2*>、Data<T1&, T2&>)。

  1. 模板的分离编译为什么会链接报错?两条修复路径是什么?

答案

模板按需实例化:a.cpp 只有定义没有调用,不会生成具体函数;main.o 里对 Add 只留了空地址,链接时找不到。修复:①(推荐)声明和定义都放头文件;② 显式实例化 template int Add<int>(...)(不实用)。


附录 A:完整代码仓库

完整工程(单文件 + split 分离编译演示)在 Gitee:

gitee.com/BaiYang_SQ/template-advanced-sim。

仓库文件 说明
template_advanced_sim.cpp 单文件:全部实验;章节 ↔ 函数名对照见仓库 README 导览表
output.txt 全部实验的真实运行输出(含链接错误原文,不用装编译器)
build.bat / build.ps1 Windows 一键编译 + 运行 + 刷新 output.txt(含 split 演示)
split/ 分离编译三套代码:坏例 / 修复例 / 显式实例化

面试前手写练手,下面这版把四个考点压在一起(完整版见仓库):

cpp 复制代码
// 模板进阶·考点骨架(完整可跑版见 Gitee 仓库)
#include <iostream>
#include <cstddef>
using namespace std;

// ① 非类型模板参数:N 是编译期常量
namespace bite
{
template <class T, size_t N = 10>
class array
{
public:
    size_t size() const { return N; }
    T& operator[](size_t i) { return _a[i]; }
private:
    T _a[N];
};
}

// ② 函数模板特化:4 步里的关键------template <> + 函数名后写明类型 + 形参一致
struct Date { int y, m, d; };

template <class T>
bool LessFunc(T l, T r) { return l < r; }

template <>
bool LessFunc<Date*>(Date* l, Date* r)
{
    return (l->y < r->y) || (l->y == r->y && l->m < r->m) ||
           (l->y == r->y && l->m == r->m && l->d < r->d);
}

// ③ 类模板全特化 / 偏特化
template <class T1, class T2>
class Data { public: Data() { cout << "Data<T1, T2>" << endl; } };

template <>
class Data<int, char> { public: Data() { cout << "Data<int, char>" << endl; } };

template <class T1>
class Data<T1*, T1*> { public: Data() { cout << "Data<T1*, T1*>" << endl; } };

int main()
{
    bite::array<int, 5> a;
    cout << a.size() << endl;   // 5

    Date d1{2022, 7, 8}, d2{2022, 7, 7};
    cout << LessFunc(&d1, &d2) << endl;   // 0(按日期,正确)

    Data<int, char> x;    // Data<int, char>
    Data<int*, int*> y;   // Data<T1*, T1*>
    return 0;
}

好了,C++ 入门篇这套连载到这里正式收官!下一篇开启新篇章------C++ 进阶篇 ,第一站:继承 (预告:继承权限与赋值兼容、同名隐藏、构造析构的调用顺序、菱形继承与虚继承)。如果这套复习笔记对你有帮助,不要忘记点赞三连一波哦,我是白杨,我们下期见。

相关推荐
qq_284274051 小时前
液压泵类型结构与液压马达笔记:柱塞泵奇数柱塞减震槽通轴斜轴、单双作用叶片泵八段曲线限压变量、齿轮泵排量困油径向力端面补偿、内啮合螺杆泵与高低速马达(含考点)
笔记·学习·自动化·制造
ACP广源盛139246256731 小时前
项目评估记录:Type‑C DP Alt Mode 转 HDMI 桥接方案调研,GSV1201D @ACP参数与工程问题记录
笔记·硬件架构·视频·hdmi·国产芯片
C++ 老炮儿的技术栈1 小时前
char 造成的数据溢出
linux·服务器·c语言·c++·算法·c
云半S一1 小时前
Agent学习总结二
经验分享·笔记·学习·agent
彧azz1 小时前
B-树学习总结:从结构特性到核心操作的系统复习
数据结构·笔记·b树·学习
燕落南枝1 小时前
HTML 学习笔记:从基础到音频标签
笔记·学习·html
qetfw1 小时前
Bash 内置与外部命令:type、command 与 builtin 验证
linux·开发语言·bash
Dovis(誓平步青云)1 小时前
车辆进入隧道后信号断开,座舱旅程怎样补回事件
java·开发语言·前端·数据库·电脑
Wzx1980121 小时前
Python Agent 短期记忆设计:从对话历史到 LangGraph Checkpointer
开发语言·python