跳转到内容

07 · 模板与泛型

定位:模板是 C++ 区别于 C 的最大武器之一,也是面试“进阶题”的高发区。很多公司把模板当筛选题:问得出 SFINAE、可变参数模板、模板与多态的区别,说明你是真懂 C++ 而不是只会写业务代码。 学完标准:能说清模板实例化时机;能手写 enable_if 约束重载;能解释函数模板为什么不能偏特化;能展开可变参数模板;知道模板代码为什么必须放头文件。


问题:要写一个支持任意类型的“取最大值”函数,怎么办?

// 方案一:宏(有坑,不类型安全)
#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)

一句话:宏是“文本替换”,模板是“类型安全 + 编译期计算”的代码生成。


template <typename T> // typename 和 class 等价(历史遗留,现在随便用)
T max(T a, T b) { return a > b ? a : b; }
// 用法一:自动推导
int x = max(3, 5); // T 推导为 int
double 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>
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& 或 int
int max(int a, int b) { ... } // ① 普通函数
template <typename T> T max(T a, T b) { ... } // ② 函数模板
max(1, 2); // 调用 ①:两者匹配一样好时,普通函数优先
max('a','b'); // 调用 ②:模板生成 char 版本

规则:重载决议时,若普通函数与模板转换序列一样好,普通函数(非模板)优先;若模板匹配更精确,模板会赢。

  • 模板本身不生成任何代码,只有被调用/被用到时才按具体类型生成一份。
  • 实例化发生在编译期(链接期可能合并重复实例化)。
  • 所以模板的定义必须写在头文件里(或定义在使用处可见),否则别的 .cpp 看不见定义、无法实例化 → 链接报 undefined reference
mymax.h
// 错误示范:声明在头文件,定义在 .cpp
template <typename T> T mymax(T a, T b); // 声明
// mymax.cpp
template <typename T> T mymax(T a, T b) { return a > b ? a : b; } // 定义
// main.cpp 调用 mymax<int>(1,2) → 编译器在 main.cpp 里看不到定义 → 链接错误!
// 正确做法:定义直接放头文件
// mymax.h
template <typename T>
T mymax(T a, T b) { return a > b ? a : b; }

追问:那模板都放头文件,多文件包含会不会重复定义?→ 不会,模板实例化后才生成符号,且链接器会合并相同实例化(这在 02 章 ODR 例外里讲过)。


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 或测试)。

template <typename T>
class Counter {
public:
static int count;
};
template <typename T>
int Counter<T>::count = 0; // 每个实例化类型各有一份!
Counter<int>::count++; // int 实例的 count
Counter<double>::count = 5; // double 实例的 count,和 int 的互不相干

注意:类模板的静态成员,每个实例化各有一份,不是共享一份。定义也要写在头文件(C++17 后可 inline static)。


特定类型给出专门实现:

template <typename T>
T abs_val(T v) { return v < 0 ? -v : v; }
// 全特化:对 bool 这种无意义类型给专门版本
template <>
bool abs_val<bool>(bool v) { return v; } // 全特化:template<> + 具体类型

只指定部分模板参数,或给参数加上限定(比如“指针类型”):

// 类模板偏特化:任何指针类型走这个版本
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; // false
IsPointer<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(模板面试的“天花板”题)”

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 → 替换失败 → 不报错,落到 ... 版本
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 用于“同一函数里裁剪分支”。

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)”
// 一个"打印任意数量任意类型"的函数
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) 也是)
  • 递归展开(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)等追求零开销的场景。


  1. 代码膨胀(code bloat):每种类型实例化一份代码。vector<int>vector<double> 是两份。减少办法:公共部分抽到非模板基类/函数。
  2. 编译时间变长:实例化是编译期负担。可用 extern template(C++11)显式实例化声明,减少跨翻译单元重复实例化。
  3. 报错信息恐怖:模板错误信息嵌套深,可用 SFINAE / static_assert 提前给出友好提示:
template <typename T>
void require_integral(T) {
static_assert(std::is_integral_v<T>, "这个函数只支持整型!"); // 编译期断言
}
  1. 模板与虚函数:模板不能是虚函数(虚函数数量运行期才知道,模板实例化是编译期,二者冲突)。但模板可以调用虚函数

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 void f(T, T)` 调用 `f(1, 2.5)` 为什么报错?怎么修?

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` 里的 5 是什么类型的模板参数?

我的原答:可以是变量吗?

📖 答案(先自己答完再展开)
  1. 编译期,用哪种类型生成哪种;类模板成员函数惰性实例化——用到哪个才实例化哪个,没用的即使有错也不报。
  2. 两个参数要求 T 相同,int 和 double 推导冲突。修复:显式 f<double>(1, 2.5) 或把参数改成 template<typename T, typename U>
  3. 不能;因为重载能完全替代且语义更清晰。用“重载 + 全特化”组合。
  4. 见第 5 节代码:enable_if_t<is_integral_v<T>, T> 版本 + if constexpr (is_integral_v<T>) 版本。
  5. template<typename... Args> auto add_all(Args... args) { return (args + ...); }
  6. “失败”= 某个候选模板在参数替换时签名非法(如类型不存在某成员);不是错误 = 编译器跳过它继续找其他重载,而不是直接报错。
  7. 因为实例化在编译期、在使用处;看不见定义就无法生成代码。extern template 声明“这个实例化在别处已生成,别重复实例化”,减少编译时间与代码膨胀。
  8. 不能去掉(C++11 前必须;C++20 起部分场景可省略)。因为 T::iterator 是依赖类型,编译器在见到 T 具体类型前不知道它是类型还是成员,typename 显式声明“这是类型”。
  9. CRTP = 模板基类 + 派生类把自己作为模板参数传进去,基类用 static_cast<Derived*>(this) 静态分发调用派生实现,实现编译期多态、零虚表开销、可内联。
  10. 非类型模板参数(编译期常量,size_t)。不能是运行期变量,必须是编译期常量表达式(constexpr/字面量)。

## 12. 进阶追问(答不上来就回来复习)
  • 模板的实例化、隐式实例化、显式实例化的区别?extern template 到底省了什么?
  • void_t 检测惯用法怎么写?怎么用它实现“判断某类型有没有某成员函数”?
  • 类模板参数推导(CTAD,C++17):std::pair p{1, 2.5} 为什么能推导?自定义推导指引(deduction guide)怎么写?
  • 模板递归实例化深度上限是多少?-ftemplate-depth 控制什么?
  • 模板与 inline:为什么模板头文件里不需要加 inline?(实例化后天然 ODR 安全)
  • 协程/反射(C++23 方向)和模板元编程有什么关系?