07 · 模板与泛型
定位:模板是 C++ 区别于 C 的最大武器之一,也是面试“进阶题”的高发区。很多公司把模板当筛选题:问得出 SFINAE、可变参数模板、模板与多态的区别,说明你是真懂 C++ 而不是只会写业务代码。 学完标准:能说清模板实例化时机;能手写
enable_if约束重载;能解释函数模板为什么不能偏特化;能展开可变参数模板;知道模板代码为什么必须放头文件。
1. 模板是什么?为什么需要?
Section titled “1. 模板是什么?为什么需要?”问题:要写一个支持任意类型的“取最大值”函数,怎么办?
// 方案一:宏(有坑,不类型安全)#define MAX(a, b) ((a) > (b) ? (a) : (b)) // 危险:a++ 会被求值两次,类型错误不报
// 方案二:重载(写到手断)int max(int a, int b); double max(double a, double b); // 每种类型写一遍
// 方案三:模板(编译器帮你生成)template <typename T>T max(T a, T b) { return a > b ? a : b; }模板的本质:一种编译期代码生成器。你写的是“一份泛化代码 + 类型参数”,编译器在用到具体类型时,用该类型“替换”生成一份真正的代码。这个过程叫实例化(instantiation)。
| 特性 | 宏 | 模板 |
|---|---|---|
| 类型安全 | 无(纯文本替换) | 有(编译期类型检查) |
| 调试 | 差(展开后难查) | 好(符号清晰) |
| 作用域 | 无(全局污染) | 有(遵循命名空间) |
| 编译期能力 | 有限 | 强大(可做模板元编程) |
| 控制流 | 无(纯文本) | 有(特化、SFINAE、if constexpr) |
一句话:宏是“文本替换”,模板是“类型安全 + 编译期计算”的代码生成。
2. 函数模板
Section titled “2. 函数模板”template <typename T> // typename 和 class 等价(历史遗留,现在随便用)T max(T a, T b) { return a > b ? a : b; }
// 用法一:自动推导int x = max(3, 5); // T 推导为 intdouble y = max(3.14, 2.7); // T 推导为 double
// 用法二:显式指定(推导不出来的场景需要)template <typename T> T cast_num(double v) { return static_cast<T>(v); }int n = cast_num<int>(3.9); // 返回值类型推导不出来,必须显式写 <int>2.1 模板参数推导的几个坑
Section titled “2.1 模板参数推导的几个坑”template <typename T>void f(T a, T b) {} // 两个参数同类型
f(1, 2.5); // ❌ 推导冲突:T 既想是 int 又想是 double,报错f<double>(1, 2.5); // ✅ 显式指定
// 引用折叠问题见 08 章完美转发,这里先记住:template <typename T> void g(T&& x); // T&& 是"转发引用",T 可推导为 int& 或 int2.2 函数模板的重载
Section titled “2.2 函数模板的重载”int max(int a, int b) { ... } // ① 普通函数template <typename T> T max(T a, T b) { ... } // ② 函数模板
max(1, 2); // 调用 ①:两者匹配一样好时,普通函数优先max('a','b'); // 调用 ②:模板生成 char 版本规则:重载决议时,若普通函数与模板转换序列一样好,普通函数(非模板)优先;若模板匹配更精确,模板会赢。
2.3 实例化时机(重点)
Section titled “2.3 实例化时机(重点)”- 模板本身不生成任何代码,只有被调用/被用到时才按具体类型生成一份。
- 实例化发生在编译期(链接期可能合并重复实例化)。
- 所以模板的定义必须写在头文件里(或定义在使用处可见),否则别的 .cpp 看不见定义、无法实例化 → 链接报
undefined reference。
// 错误示范:声明在头文件,定义在 .cpptemplate <typename T> T mymax(T a, T b); // 声明// mymax.cpptemplate <typename T> T mymax(T a, T b) { return a > b ? a : b; } // 定义// main.cpp 调用 mymax<int>(1,2) → 编译器在 main.cpp 里看不到定义 → 链接错误!
// 正确做法:定义直接放头文件// mymax.htemplate <typename T>T mymax(T a, T b) { return a > b ? a : b; }追问:那模板都放头文件,多文件包含会不会重复定义?→ 不会,模板实例化后才生成符号,且链接器会合并相同实例化(这在 02 章 ODR 例外里讲过)。
3. 类模板
Section titled “3. 类模板”template <typename T>class Stack {public: void push(const T& v) { data_.push_back(v); } T pop() { T v = data_.back(); data_.pop_back(); return v; } bool empty() const { return data_.empty(); }private: std::vector<T> data_;};
Stack<int> si; // 实例化成 Stack<int>Stack<std::string> ss; // 实例化成 Stack<std::string>3.1 类模板成员函数的“惰性实例化”
Section titled “3.1 类模板成员函数的“惰性实例化””关键特性:类模板的成员函数,用到哪个才实例化哪个,没用到的不生成。
template <typename T>class Foo {public: void ok() {} // 用到了才实例化 void bad() { T t; t.undefined_func(); } // 即使 T 不支持,只要没人调 bad() 就不报错};
Foo<int> f; // ✅ 编译通过!因为 bad() 没被调用,不会实例化f.ok(); // 只实例化了 ok()// f.bad(); // ❌ 一旦调用,T=int 不支持 undefined_func → 报错面试点:这解释了为什么“模板里写了错的代码,编译不报错”——因为没被实例化。检查模板代码质量时,要专门用各种类型实例化一遍(可用 static_assert 或测试)。
3.2 类模板的 static 成员
Section titled “3.2 类模板的 static 成员”template <typename T>class Counter {public: static int count;};template <typename T>int Counter<T>::count = 0; // 每个实例化类型各有一份!
Counter<int>::count++; // int 实例的 countCounter<double>::count = 5; // double 实例的 count,和 int 的互不相干注意:类模板的静态成员,每个实例化各有一份,不是共享一份。定义也要写在头文件(C++17 后可
inline static)。
4. 模板特化与偏特化
Section titled “4. 模板特化与偏特化”4.1 全特化(Specialization)
Section titled “4.1 全特化(Specialization)”对特定类型给出专门实现:
template <typename T>T abs_val(T v) { return v < 0 ? -v : v; }
// 全特化:对 bool 这种无意义类型给专门版本template <>bool abs_val<bool>(bool v) { return v; } // 全特化:template<> + 具体类型4.2 偏特化(Partial Specialization)
Section titled “4.2 偏特化(Partial Specialization)”只指定部分模板参数,或给参数加上限定(比如“指针类型”):
// 类模板偏特化:任何指针类型走这个版本template <typename T>class IsPointer { public: static const bool value = false; };
template <typename T>class IsPointer<T*> { public: static const bool value = true; }; // 偏特化:T 是任意类型,匹配 T*
IsPointer<int>::value; // falseIsPointer<int*>::value; // true ← 匹配偏特化版本| 全特化 | 偏特化 | |
|---|---|---|
| 语法 | template<> |
template<typename T> class X<T*>; |
| 匹配范围 | 一个具体类型 | 一类类型(指针、const、某个参数固定等) |
| 函数模板 | ✅ 可以 | ❌ 不可以 |
4.3 为什么函数模板不能偏特化?(高频题)
Section titled “4.3 为什么函数模板不能偏特化?(高频题)”函数有重载机制。函数模板“偏特化”的效果,用重载就能实现,而且更自然:
template <typename T> void f(T v) { ... } // 通用template <typename T> void f(T* p) { ... } // 重载(效果像偏特化,但它是重载!)
// C++ 标准明确:函数模板不支持偏特化,只能用"重载 + 全特化"组合面试答法:函数模板的偏特化功能可以被重载完全覆盖,且重载参与重载决议的方式更清晰、更少坑,所以标准里干脆不允许函数模板偏特化(否则语义混乱——特化不参与重载决议,会导致“该用特化时没选上”的诡异行为)。
5. SFINAE 与 enable_if(模板面试的“天花板”题)
Section titled “5. SFINAE 与 enable_if(模板面试的“天花板”题)”5.1 SFINAE 是什么?
Section titled “5.1 SFINAE 是什么?”SFINAE = Substitution Failure Is Not An Error(替换失败不是错误)。
含义:函数模板重载决议阶段,用实参替换模板参数;如果替换失败发生在直接上下文(函数签名/模板形参,比如 T::value_type 不存在),编译器不报错,而是放弃该候选、继续找别的重载。函数体里的错误不适用 SFINAE。
// 例子:一个模板要求 T 有 value_type 成员template <typename T>void f(typename T::value_type*) { std::cout << "有 value_type\n"; } // 签名依赖 T::value_type
void f(...) { std::cout << "没有\n"; } // 兜底
f<int>(0); // int 没有 value_type → 替换失败 → 不报错,落到 ... 版本5.2 std::enable_if(重点)
Section titled “5.2 std::enable_if(重点)”template <bool B, typename T = void>struct enable_if {}; // 主模板:B 为 false 时没有 type 成员
template <typename T>struct enable_if<true, T> { using type = T; }; // 特化:B 为 true 时有 type = T用法:让只有满足条件的版本存在:
#include <type_traits>
// 只对整型启用这个重载template <typename T>std::enable_if_t<std::is_integral_v<T>, T> // 返回类型:T 是整型才有这个函数process(T v) { return v * 2; // 整型处理}
// 只对浮点启用这个重载template <typename T>std::enable_if_t<std::is_floating_point_v<T>, T>process(T v) { return v * 2.0; // 浮点处理}
process(3); // ✅ 整型版本process(3.5); // ✅ 浮点版本
std::enable_if_t<条件, T>的通俗解释:“如果条件成立,就让我存在”。条件不成立 → 替换失败 → 这个函数自动消失,不报错。
5.3 C++17 更优雅的写法:if constexpr(编译期 if)
Section titled “5.3 C++17 更优雅的写法:if constexpr(编译期 if)”template <typename T>T twice(T v) { if constexpr (std::is_integral_v<T>) { return v * 2; // 只编译 int 分支 } else { return v * 2.0; // 只编译浮点分支(另一个分支的代码不生成) }}对比:普通 if 两个分支都会编译(不满足条件的 T 会报错);if constexpr 在编译期剪掉分支。模板中未选中的依赖分支不实例化;但非模板/非依赖代码仍会被完整检查,语法必须合法。
面试金句:C++17 之后,80% 用 enable_if 的场景都能用
if constexpr更直观地解决;enable_if 常用于“删掉整个函数重载”,if constexpr 用于“同一函数里裁剪分支”。
5.4 类型萃取 type_traits 速查
Section titled “5.4 类型萃取 type_traits 速查”std::is_integral<T> // 是否整型std::is_floating_point<T>std::is_pointer<T>std::is_same<T, U> // 两个类型是否相同std::is_base_of<Base, D>std::is_convertible<From, To>std::remove_reference<T> / remove_const<T>// C++17 起可以用 _v 后缀拿值:is_integral_v<T>;C++14 起用 ::type 或 _t 后缀拿类型6. 可变参数模板(Variadic Templates,C++11)
Section titled “6. 可变参数模板(Variadic Templates,C++11)”6.1 基本语法
Section titled “6.1 基本语法”// 一个"打印任意数量任意类型"的函数void print() {} // ① 递归出口(空参数时停)
template <typename T, typename... Args> // Args 是"参数包"void print(T first, Args... rest) { // rest 是"包展开位置" std::cout << first << " "; print(rest...); // 递归:剥掉 first,继续}
print(1, 2.5, "hello", 'x');// 调用链:print<int,double,const char*,char>(1, ...)// → print<double,const char*,char>(2.5, ...)// → print<const char*,char>("hello", ...)// → print<char>('x', ...)// → print() 出口sizeof...(Args) 拿参数个数:
template <typename... Args>void count(Args... args) { std::cout << sizeof...(args) << "\n"; }6.2 C++17 折叠表达式(Fold Expressions)——重点
Section titled “6.2 C++17 折叠表达式(Fold Expressions)——重点”把参数包用同一个运算符“折叠”起来:
template <typename... Args>auto sum_all(Args... args) { return (args + ...); // 一元右折叠:(arg1 + arg2 + ... + argN)}
sum_all(1, 2, 3, 4); // 10
template <typename... Args>void print2(Args... args) { (std::cout << ... << args) << "\n"; // 二元左折叠:(((cout << a) << b) << c)}
// 空包的默认值:template <typename... Args>auto sum0(Args... args) { return (args + ... + 0); // 空参数包时返回 0}| 折叠写法 | 含义 |
|---|---|
(args + ...) |
右折叠:a1 + (a2 + (a3 + a4)) |
(... + args) |
左折叠:((a1 + a2) + a3) + a4 |
(args + ... + init) |
右折叠带初始值 |
(... + args + init) |
左折叠带初始值(注意:(... + args + init) 合法,(args + ... + init) 也是) |
6.3 展开的两种方式总结
Section titled “6.3 展开的两种方式总结”- 递归展开(C++11 风格):每次剥一个参数,递归调用到出口。
- 折叠表达式(C++17):一个运算符一行搞定,现代推荐。
7. 非类型模板参数(Non-type Template Parameters)
Section titled “7. 非类型模板参数(Non-type Template Parameters)”模板参数不一定是类型,也可以是编译期常量:
template <typename T, size_t N> // N 是编译期常量class Array { T data[N]; // N 必须编译期确定public: size_t size() const { return N; }};
Array<int, 100> a; // 100 必须是编译期常量(constexpr 或字面量)
// std::array 就是 std::array<T, N>
// 模板递归求阶乘(编译期计算,模板元编程入门):template <int N>struct Factorial { static constexpr int value = N * Factorial<N - 1>::value;};template <>struct Factorial<0> { static constexpr int value = 1; };
Factorial<5>::value; // 120 —— 编译期就算好了,运行期零开销8. 模板 + 多态 = ?两类多态的对比(高频追问)
Section titled “8. 模板 + 多态 = ?两类多态的对比(高频追问)”| 运行期多态(虚函数) | 编译期多态(模板/CRTP) | |
|---|---|---|
| 绑定时机 | 运行期(查虚表) | 编译期(直接调用) |
| 性能 | 有虚表开销、无法内联 | 可内联、零抽象开销 |
| 灵活性 | 运行时动态选择(容器存基类指针) | 类型必须编译期确定 |
| 代码体积 | 小(一份代码) | 大(每种类型生成一份,代码膨胀) |
| 典型场景 | 游戏里实体类型各异、需统一接口 | 算法/容器、编译期就知道类型 |
CRTP(Curiously Recurring Template Pattern,奇异递归模板)——模板实现静态多态的经典技巧:
template <typename Derived>class Base {public: void interface() { static_cast<Derived*>(this)->impl(); } // 静态分发};
class DerivedA : public Base<DerivedA> {public: void impl() { /* A 的实现 */ }};// Base<DerivedA>::interface() 调用的是 DerivedA::impl(),且编译期绑定、可内联游戏引擎里 CRTP 常见于 ECS 组件、访问者、数学库(如 glm 的 swizzle)等追求零开销的场景。
9. 模板的代价与工程注意事项
Section titled “9. 模板的代价与工程注意事项”- 代码膨胀(code bloat):每种类型实例化一份代码。
vector<int>和vector<double>是两份。减少办法:公共部分抽到非模板基类/函数。 - 编译时间变长:实例化是编译期负担。可用
extern template(C++11)显式实例化声明,减少跨翻译单元重复实例化。 - 报错信息恐怖:模板错误信息嵌套深,可用 SFINAE / static_assert 提前给出友好提示:
template <typename T>void require_integral(T) { static_assert(std::is_integral_v<T>, "这个函数只支持整型!"); // 编译期断言}- 模板与虚函数:模板不能是虚函数(虚函数数量运行期才知道,模板实例化是编译期,二者冲突)。但模板可以调用虚函数。
10. 高频面试题 Q&A(合上书能讲)
Section titled “10. 高频面试题 Q&A(合上书能讲)”Q1:模板是什么时候实例化的? 编译期,且“用到哪种类型才生成哪种”。模板本身不生成代码,直到被调用/被使用才按具体类型实例化;类模板的成员函数是惰性的,用到哪个实例化哪个。
Q2:函数模板和类模板有什么区别?
函数模板靠参数自动推导类型,支持重载;类模板不能自动推导(C++17 的类模板参数推导 CTAD 除外),需显式写 <T>,有静态成员/偏特化等特性。函数模板不能偏特化,类模板可以。
Q3:函数模板能偏特化吗?为什么? 不能。函数模板的偏特化效果可用重载完全替代,且重载语义清晰;若允许偏特化,特化不参与重载决议会引发混乱,所以标准直接禁止。
Q4:什么是 SFINAE?
替换失败不是错误。模板参数替换时若某个候选在签名处非法,不报错而是跳过该候选找其他重载。配合 std::enable_if 可以按类型条件性地启用/删除重载。
Q5:std::enable_if 怎么用?
enable_if_t<条件, T>:条件成立才有 type,才让这个函数/类型存在。常用于重载“只对整型/只对浮点生效”,或约束类模板。C++17 后用 if constexpr 更直观。
Q6:可变参数模板怎么展开参数包?
两种:①递归——每次剥第一个参数,递归处理剩余包,直到空包出口;②C++17 折叠表达式——用运算符把整包折叠,如 (args + ...) 求和、(cout << ... << args) 打印。
Q7:为什么模板定义要写在头文件? 实例化发生在编译期,且发生在“使用处”的翻译单元里;编译器必须在该 .cpp 里看得到完整定义才能生成代码。放 .cpp 里其他文件看不到定义 → 链接报 undefined reference。
Q8:模板和虚函数能一起用吗? 模板函数不能是虚函数(虚表需要运行期确定函数集合,模板是编译期生成,冲突);但类模板里可以有虚函数;模板可以调用虚函数。
Q9:模板代码膨胀怎么办?
把类型无关的公共逻辑抽到非模板基类或普通函数;用 extern template 显式实例化减少重复实例化;接受“以代码体积换性能”的权衡。
Q10:typename 和 class 有什么区别?
在模板参数位置完全等价(历史原因:class 先出现,后来为避免与类定义混淆引入 typename)。但在嵌套依赖类型声明处只能用 typename:typename T::iterator it;(告诉编译器 T::iterator 是类型不是成员变量)。
Q11:什么是模板元编程?有什么用?
在编译期用模板递归/特化做“计算”(如 Factorial<5>),运行期零开销。实际工程价值更多在于类型计算(type_traits、推导返回类型)而非数值计算,游戏引擎的数学库、反射系统会用到。
11. 本章自测(10 题,限时 15 分钟,先自己答再看答案)
Section titled “11. 本章自测(10 题,限时 15 分钟,先自己答再看答案)”1. 模板实例化发生在哪个阶段?
我的原答:类模板成员函数有什么特殊性?
2. `template
3. 函数模板为什么不能偏特化?
我的原答:替代方案是什么?
4. 写出一个只对整型启用的函数重载(用 enable_if),再写一遍用 if constexpr 的版本。
5. 用可变参数模板写一个 `add_all(...)` 返回所有参数之和(C++17 折叠)。
6. 解释 SFINAE 的"失败"指什么?为什么不是错误?
7. 模板的定义为什么必须可见于使用处?
我的原答:`extern template` 是干嘛的?
8. `typename T::iterator it;` 里的 typename 能去掉吗?为什么?
9. CRTP 是什么?
我的原答:解决了什么问题?
10. `std::array
我的原答:可以是变量吗?
📖 答案(先自己答完再展开)
- 编译期,用哪种类型生成哪种;类模板成员函数惰性实例化——用到哪个才实例化哪个,没用的即使有错也不报。
- 两个参数要求 T 相同,int 和 double 推导冲突。修复:显式
f<double>(1, 2.5)或把参数改成template<typename T, typename U>。 - 不能;因为重载能完全替代且语义更清晰。用“重载 + 全特化”组合。
- 见第 5 节代码:
enable_if_t<is_integral_v<T>, T>版本 +if constexpr (is_integral_v<T>)版本。 template<typename... Args> auto add_all(Args... args) { return (args + ...); }- “失败”= 某个候选模板在参数替换时签名非法(如类型不存在某成员);不是错误 = 编译器跳过它继续找其他重载,而不是直接报错。
- 因为实例化在编译期、在使用处;看不见定义就无法生成代码。
extern template声明“这个实例化在别处已生成,别重复实例化”,减少编译时间与代码膨胀。 - 不能去掉(C++11 前必须;C++20 起部分场景可省略)。因为
T::iterator是依赖类型,编译器在见到 T 具体类型前不知道它是类型还是成员,typename 显式声明“这是类型”。 - CRTP = 模板基类 + 派生类把自己作为模板参数传进去,基类用
static_cast<Derived*>(this)静态分发调用派生实现,实现编译期多态、零虚表开销、可内联。 - 非类型模板参数(编译期常量,size_t)。不能是运行期变量,必须是编译期常量表达式(constexpr/字面量)。
- 模板的实例化、隐式实例化、显式实例化的区别?
extern template到底省了什么? void_t检测惯用法怎么写?怎么用它实现“判断某类型有没有某成员函数”?- 类模板参数推导(CTAD,C++17):
std::pair p{1, 2.5}为什么能推导?自定义推导指引(deduction guide)怎么写? - 模板递归实例化深度上限是多少?
-ftemplate-depth控制什么? - 模板与 inline:为什么模板头文件里不需要加 inline?(实例化后天然 ODR 安全)
- 协程/反射(C++23 方向)和模板元编程有什么关系?