4.1 模板实例化:一份泛型代码的多次开票


4.1 模板实例化:一份泛型代码的多次开票

本节摘要:模板不是运行期机制而是开票机制:编译器按每个用到的类型生成一份专属代码(实例化),int 与 string 拿到的是两张不同的票。票面行为取决于类型资质(可拷贝、可移动、noexcept),票的总量决定二进制体积。本节把开票过程做成可观察的实验,并给出控票手段。

本章把镜头从运行期转到编译期。第 3 章讲过:一份函数只有一种形态、一种成本;本章的模板打破这一点——同一份源码,对每个类型开一张专属的票。本节先看清开票机制本身:何时开、开几章、票面长什么样、在哪里合并。

从开票实验开始

一个模板加两种类型的调用,用typeid加计数把开票过程亮出来:

#include <cstdio> #include <string> #include <typeinfo> template <typename T> T twice(const T& v) { // 模板:源码只有一份 std::printf(" 开票给 %s\n", typeid(T).name()); return v + v; } int main() { std::printf("调用点 1:int\n"); int i = twice(21); std::printf("调用点 2:double\n"); double d = twice(1.5); std::printf("调用点 3:string\n"); std::string s = twice(std::string("ab")); std::printf("调用点 4:再次 int(同一张票)\n"); int i2 = twice(99); std::printf("结果:%d %.1f %s %d\n", i, d, s.c_str(), i2); return 0; }

典型输出(GCC 下类型名是缩写,MSVC 下是完整拼写):

调用点 1:int 开票给 i 调用点 2:double 开票给 d 调用点 3:string 开票给 Ss 或 NSt7__cxx11basic_stringIcSt11char_traitsIcESaIcEEE 调用点 4:再次 int(同一张票) 开票给 i 结果:42 3 ab 198

开票规则四条。其一,按需开票:模板定义本身不产生代码,每个"模板名加具体类型"的首次使用才触发实例化。其二,同类型合并:调用点 4 复用调用点 1 的票,链接器会把多个编译单元里的同一张票合并成一份。其三,票面专属:int 的票里 v + v 是整数加法,string 的票里是字符串拼接——两份不同的机器码。其四,资质检查发生在票内:如果类型不支持 +,报错发生在实例化展开处,而不是模板定义处,这就是模板错误信息又长又深的根源。

图 4-1 实例化:一份源码,多张票面

图 4-1 实例化:一份源码,多张票面

一、类模板与代码膨胀的实测

类模板的开票逻辑相同,但票面更大,是膨胀审计的主对象。下面这个类模板对三种整型开三张票,用显式实例化把票"钉"在当前编译单元里,方便用符号表观察:

#include <cstdio> template <typename T> struct Box { explicit Box(T v) : v_(v) {} T get() const { return v_; } void describe() const { std::printf("Box 内尺寸 %zu 字节\n", sizeof(T)); } private: T v_; }; // 显式实例化:即使没人用,也在此编译单元生成 int 版本的完整票面 template struct Box<int>; int main() { Box<int> a{1}; Box<long long> b{2}; Box<char> c{'x'}; a.describe(); b.describe(); c.describe(); std::printf("三张票,三个 sizeof:同一逻辑,三份代码\n"); return 0; }

输出:

Box 内尺寸 4 字节 Box 内尺寸 8 字节 Box 内尺寸 1 字节

膨胀的机制看清了:票数 = 使用到的类型组合数,票面 = 模板体的机器码大小。标准库里 vector 每用一个新类型就添一整套票;模板层层嵌套时票数还会相乘。控票三板斧按代价从小到大排:

  1. 收缩模板体:把不依赖模板参数的逻辑抽到非模板基类或普通函数,票面自然变小。日志格式化、参数校验这类通用逻辑不该住在模板里。
  2. 显式实例化声明(extern template):在头文件里写 extern template class Box<int>;,告诉其他编译单元"这张票别开了,链接时找我要"。高频票集中在一处开,其余单元省掉重复开票时间与体积。
  3. 收敛类型数量:接口层面约束调用方使用的类型集合(用 4.3 的 Concepts 明示),从源头控制票数。

二、案例:路由模块的三份手抄代码

背景:本章主线的路由模块第一版有三份几乎相同的 route 函数,分别服务整型、字符串、消息体;修一个路由规则要改三处,出过一次漏改事故。

操作:合并成一份 template <typename Key> route(const Key&)。合并后做两件审计:用符号表列出所有实例化票面,确认只有预期的几个类型;把路由表查找、日志这些与 Key 无关的逻辑抽进非模板辅助函数,票面缩小约四成。

结果:源码一份、逻辑单点维护;二进制体积不升反降(三份手抄里本就藏着重复的辅助逻辑);新类型接入只需一个调用点,无需再抄代码。

解读:这次改造的收入是单点维护(源码账),代价是票面账需要管理。模板不是免费的抽象——它把"多份相似代码"从源码里搬进了二进制里。审计视角的结论:搬得值不值,看票面是否可控;可控(类型集合有限、模板体精瘦),泛型就是净赚;失控(类型组合爆炸、模板体臃肿),膨胀就把抽象的收益吃回去。

变式:对接第 3 章——模板票面里 std::forward 转发实参、容器开票时对元素移动与拷贝的自动选择,都是"票面行为由类型资质决定"的例子。票面质量审计因此有个固定条目:你的类型在泛型代码里拿到的票,走的是移动还是拷贝——资质差的类型在所有票里都慢。

💡 关键直觉:模板是"开票机器"不是"运行期魔法"。编译产物里没有"模板"这个实体,只有一张张具体类型的票。读懂报错、控住体积、审好票面行为,三件事都建立在"票"这个模型上。

本节要点回顾

  • 按需开票:模板定义不产生代码,"模板加类型"的首次使用触发实例化。
  • 同票合并:链接器合并多个编译单元的同类型票,重复开票浪费的是编译时间与体积。
  • 票面专属:每个类型一份机器码,行为由该类型的运算与资质决定。
  • 膨胀公式:票数乘票面大小;模板嵌套让票数相乘。
  • 控票三板斧:收缩模板体、extern template 集中开票、收敛类型集合。
  • 报错位置:资质错误报在实例化展开处,层层深栈——这是 4.2 与 4.3 要治的病。

下一节看编译期怎么查账:type traits 回答类型资质问题,if constexpr 在开票前裁剪分支,enable_if 按资质决定开不开票——泛型代码从此可以"查着账写"。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U