模板
模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅优化运行期的性能。
外部模板
外部模板(extern template) 是一种向编译器发出的指令,用于阻止编译器在当前翻译单元(也就是当前的 .cpp 文件)中隐式地实例化一个模板。它告诉编译器:“不必在此处生成这个模板的完整代码,我向你保证,它的代码会在其他某个地方被生成,链接器(Linker)最终会找到它。”
它的主要,也是唯一的目标,就是减少编译时间和避免代码冗余。
C++ 模板的默认工作方式
要理解 extern template 的用处,首先必须了解 C++ 模板的默认工作方式,以及它带来的问题
当你在代码中使用一个模板时,比如
std::vector<int>,编译器会自动为你生成 std::vector
模板针对 int 类型特化的所有代码。这个过程叫做隐式实例化。
问题在于,如果你的项目中有多个 .cpp 文件都包含了 std::vector<int>,那么编译器会在每一个使用它的 .cpp
文件中都生成一份完整的 std::vector<int> 的代码。
- a.cpp 使用了
std::vector<int>-> 编译器在 a.o 中生成一份std::vector<int>的代码。 - b.cpp 使用了
std::vector<int>-> 编译器在 b.o 中也生成一份std::vector<int>的代码。 - c.cpp 使用了
std::vector<int>-> 编译器在 c.o 中又生成一份std::vector<int>的代码。 - ……
显然, 这样重复生成同样的代码,不仅浪费了编译时间,还会导致生成的目标文件(.o 文件)体积膨胀,链接器最终只会选择一个模板而丢弃多余的模板.o文件, 最终影响链接时间和可执行文件的大小。
解决方案:extern template 与 显式实例化 组合拳
为了解决上述问题,C++11 提供了 extern template 关键字,允许你显式地告诉编译器在哪个翻译单元中实例化模板,而在其他翻译单元中则不进行实例化。
- 选择一个地方进行显式实例化: 我们创建一个专门的 .cpp 文件(例如
templates.cpp),或者在某个主要的 .cpp 文件中,来统一管理模板的实例化。
1
2
3
4
5
6
7// templates.cpp
// 显式实例化定义:强制编译器在此处生成代码
template class std::vector<int>;
template class std::vector<std::string>; - 在其他地方使用 extern template: 在所有其他需要使用这些模板的 .cpp
文件中,使用 extern template 来告诉编译器不要在这些文件中实例化模板。
1
2
3
4
5
6// a.cpp
extern template class std::vector<int>; // 告诉编译器不要在这里实例化
void foo() {
std::vector<int> vec; // 使用 std::vector<int>,但不实例化
}
变长模板参数
在 C++11 之前,无论是类模板还是函数模板,都只能按其指定的样子, 接受一组固定数量的模板参数;而 C++11 加入了新的表示方法, 允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定。
核心语法
变长模板的核心是一个名为 “参数包” (Parameter Pack) 的概念,其语法中使用了省略号 …
模板参数包 (Template Parameter Pack): 用于声明一个可以接收零个或多个模板参数的包。
1
2template <typename... Args> // Args 就是一个模板参数包
class MyTuple {};函数参数包 (Function Parameter Pack): 用于声明一个可以接收零个或多个函数参数的包。
1
2
3
4template <typename... Args>
void my_printf(const char* format, Args... args) { // args 就是一个函数参数包
// ...
}包展开 (Pack Expansion): 这是使用参数包的关键。你不能像遍历数组一样直接访问包中的每一个参数,而是需要通过“展开”的方式来一次性地处理它们, 通过在参数包后面加上省略号 …,可以将参数包展开成多个独立的参数。
1
2
3
4
5template <typename... Args>
void my_printf(const char* format, Args... args) {
printf(format, args...); // 展开 args 包, args...将变为<arg1, arg2, arg3, ...>
// 这会被展开成 printf(format, arg1, arg2, arg3, ...);
}获取包的大小: 可以使用 sizeof…(PackName) 运算符来获取参数包中的参数数量
1
2
3
4template <typename... Args>
void print_num_args(Args... args) {
std::cout << "Number of arguments: " << sizeof...(args) << std::endl;
}
如何使用参数包(包展开技巧)
既然不能直接迭代,我们该如何处理包里的每一个参数呢?主要有以下几种方法,从经典到现代。
方法一:递归函数(经典方式)
这是C++11中最常用、最基础的展开方式。它包含两个部分: - 一个处理包中第一个元素,并用剩余元素递归调用自身的递归函数。 - 一个用于终止递归的同名基本函数(当参数包为空时调用)。
1 |
|
方法二:使用 if constexpr 简化递归(C++17)
C++17 的 if constexpr 可以在编译期进行判断,使得我们可以将递归函数和基本函数合二为一,代码更简洁。
1 |
|
方法三:折叠表达式(C++17,最现代、最推荐)
对于很多常见的操作(如求和、打印),C++17 提供了折叠表达式 (Fold Expressions),这是一种极其简洁的包展开语法。
1 |
|
折叠表达式(Fold Expressions)
折叠表达式的本质,是提供一种极其简洁的语法,来将一个二元操作符 (binary operator),例如 +, *, &&, ||, , 等,重复地应用于参数包中的所有元素。
想象一下你想对一包数字 1, 2, 3, 4 求和,你实际上想计算的是 1 + 2 + 3 + 4。折叠表达式就是让你能够直接表达这个意图的工具。
折叠表达式一共有四种形式,它们在结合性(从左到右还是从右到左计算)和是否提供初始值方面有所不同。
假设我们有一个参数包 pack,包含元素 E1, E2, E3, … En,以及一个二元操作符 op。
| 形式 | 语法 | 展开形式 | 结合性 |
|---|---|---|---|
| 一元右折叠 | (pack op ...) |
E1 op (E2 op (E3 op E4)) |
右结合 |
| 一元左折叠 | (... op pack) |
(((E1 op E2) op E3) op E4) |
左结合 |
| 二元右折叠 | (pack op ... op init) |
E1 op (E2 op (E3 op (E4 op init))) |
右结合 |
| 二元左折叠 | (init op ... op pack) |
((((init op E1) op E2) op E3) op E4) |
左结合 |
一元折叠 (Unary Folds):不提供显式的初始值。
二元折叠 (Binary Folds):提供一个显式的初始值 init。
… 与 pack 的位置决定了展开的顺序(结合性), … 在 pack 右边是右折叠,在 pack 左边是左折叠。
实例深度解析
求和
1
2
3
4
5
6
7
8
9
10template<typename... Args>
auto sum(Args... args) {
// return (args + ...); // 一元左折叠。如果调用 sum(),编译会失败。
return (args + ... + 0); // 二元右折叠,更安全!
// 或者 return (0 + ... + args); // 二元左折叠,效果相同
}
// sum(1, 2, 3, 4, 5) 展开为:
// 二元右折叠: (1 + (2 + (3 + (4 + (5 + 0)))))
// 二元左折叠: (((((0 + 1) + 2) + 3) + 4) + 5)打印
1
2
3
4template<typename... Args>
void print_fold(Args... args) {
((std::cout << args << " "), ...); // 一元左折叠
}
- 操作符 op:这里是逗号操作符 ,。
- 表达式:std::cout << args << ” ” 是应用到参数包 args 中每个元素 arg 上的表达式。
- 形式:这是一元右折叠 (pack op …)。
- 展开过程:假设调用 print_fold(1, “hi”, 3.0),它会展开成:((std::cout << 1 << ” “), (std::cout <<”hi” << ” “), (std::cout << 3.0 <<” “))
逗号操作符的特性是“计算左边的表达式,丢弃其结果,然后计算右边的表达式,并返回其结果”。由于C++保证逗号操作符的求值顺序是从左到右的,尽管是右折叠, 依旧完美地实现了按顺序打印每一个元素
- 完美转发与函数调用
折叠表达式可以极大地简化对参数包中每个元素执行同一操作的场景,例如将所有参数完美转发给另一个函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 一个将所有参数 push_back 到 vector 的函数
template<typename T, typename... Args>
void push_all(std::vector<T>& vec, Args&&... args) {
// 对每个参数,调用 vec.push_back,并用逗号操作符连接
(vec.push_back(std::forward<Args>(args)), ...);
}
int main() {
std::vector<int> v;
push_all(v, 1, 2, 3, 4, 5); // v 中现在是 {1, 2, 3, 4, 5}
}
模板元编程
模板元编程(Template Metaprogramming) 是一种利用 C++ 模板机制在编译期执行计算和逻辑操作的编程技术。它允许程序员在编译阶段生成代码,从而实现类型计算、条件选择、循环等功能,而无需在运行时进行这些操作。
核心思想是将模板视为一种编译期的函数,通过模板的参数化和特化机制,可以在编译期进行复杂的计算和决策。
类型萃取 (Type Traits) 是模板元编程的“查询语言”。 概念 (Concepts) 是模板元编程的“约束语言”
而对于C++开发者来说,类型萃取是模板元编程(TMP)和泛型编程的基石。它允许我们在编译期查询、检查和转换类型的属性。
C++11 风格的类型萃取和更久远的标签分派已经在(条款27 -
熟悉依万能引用型别进行重载的替代方案.md) 有过详细介绍, 这里重点介绍
C++14/17 引入的 _t 系列和 _v 系列的便捷别名模板,以及 C++17
引入的if constexpr和 C++20 引入的 Concepts
如何简化类型萃取。
_ t 系列和 _ v 系列别名模板
C++14 引入了 _t
系列别名模板,用于简化类型萃取的使用。它们是对
typename std::remove_const<T>::type
这类冗长写法的简化。而 C++17 引入了 _v
系列别名模板,用于简化对布尔类型萃取结果的使用。
| 第 1 类:类型分类 (Type Classification) |
核心问题:类型 T 是什么? |
| 重要性:这是最基础的查询,允许泛型代码根据类型是整数、指针还是类,执行不同逻辑分支。 |
语法:均返回 bool,C++17 推荐使用
_v 后缀。 |
| 常用萃取: |
- std::is_integral_v<T>:是否为整数类型(如
int, bool, char 等)。 -
std::is_floating_point_v<T>:是否为浮点类型(如
float, double)。 -
std::is_arithmetic_v<T>:是否为算术类型(整数或浮点)。
- std::is_pointer_v<T>:是否为指针类型(如
int*)。 -
std::is_reference_v<T>:是否为引用类型(T&
或 T&&)。 -
std::is_class_v<T>:是否为类或结构体类型。 -
std::is_enum_v<T>:是否为枚举类型。 -
std::is_void_v<T>:是否为 void
类型。 |
第 2 类:类型属性 (Type Properties)
核心问题:类型 T 具有哪些特性?
重要性:用于了解类型的内在属性,如是否为空、是否抽象、是否可简单复制等,对性能优化和对象生命周期管理很重要。
语法:大多返回 bool(_v
后缀),少数返回 size_t。
常用萃取:
std::is_abstract_v<T>:是否为抽象类(含纯虚函数)。std::is_empty_v<T>:是否为空类(无非静态成员、无虚函数)。std::is_aggregate_v<T>(C++17):是否为聚合体(可用{}初始化)。std::is_trivial_v<T>:是否为平凡类型(可平凡构造、复制、移动、析构)。std::is_standard_layout_v<T>:是否为标准布局类型(内存布局可预测)。std::is_constructible_v<T, Args...>:是否可用Args...构造T。std::is_copy_constructible_v<T>:是否可拷贝构造。std::is_move_constructible_v<T>:是否可移动构造。std::is_assignable_v<T&, U>:U是否可赋值给T。
第 3 类:类型关系 (Type Relations)
核心问题:类型 T 和类型 U
有何关系?
重要性:用于检查多个类型之间的兼容性。
语法:均返回 bool(_v
后缀)。
常用萃取:
std::is_same_v<T, U>:T和U是否完全相同(const/volatile视为不同)。std::is_base_of_v<Base, Derived>:Base是否为Derived的基类。std::is_convertible_v<From, To>:From是否可隐式转换为To。
第 4 类:类型修饰 (Type Modifiers)
核心问题:如何基于 T
得到一个新类型?
重要性:元编程的“转换器”,返回新类型而非
bool,用于编译期类型转换。
语法:C++11 用 ::type,C++14 及以后推荐
_t 后缀类型别名。
常用萃取:
std::remove_reference_t<T>:移除引用(int&→int)。std::remove_const_t<T>/std::remove_cv_t<T>:移除const/volatile。std::add_pointer_t<T>:添加指针(int→int*)。std::add_lvalue_reference_t<T>:添加左值引用(int→int&)。std::decay_t<T>(C++14):模拟按值传参时的类型退化(移除引用、const/volatile,数组/函数转指针)。
5 类:调用相关 (Invocable / Call-related)
核心问题:Func 类型能否用
Args... 参数调用?
重要性:C++17 的现代可调用对象处理方式,取代 C++11
的 std::result_of。
语法:_v 返回
bool,_t 返回类型。
常用萃取:
std::is_invocable_v<Func, Args...>:Func是否可用Args...调用。std::is_nothrow_invocable_v<Func, Args...>:调用是否为noexcept。std::invoke_result_t<Func, Args...>:调用Func(Args...)的返回类型。- 例如,
std::invoke_result_t<decltype(&MyClass::method), MyClass&, int>表示调用MyClass::method时传入MyClass&和int参数的返回类型。
- 例如,
std::is_invocable_r_v<R, Func, Args...>:Func(Args...)是否可调用且返回类型可隐式转换为R。
下面展示了如何综合利用这五类类型萃取
1 |
|
if constexpr
if constexpr 是 C++17
引入的一种编译期条件语句,允许在编译阶段根据常量表达式的值选择性地编译代码块。与传统的
if 语句不同,if constexpr
在编译时就会决定哪一个分支被编译,而未被选择的分支将不会被编译,从而避免了不必要的编译错误。
> constexpr 关键字用于指示某个表达式或函数在编译期求值,
例如constexpr int square(int x) { return x * x;}代表函数
square 可以在编译期计算结果, if constexpr
则是将这种编译期求值的能力应用到条件判断中。
假如使用普通的 if
语句,编译器会尝试编译所有分支,即使某些分支在运行时永远不会被执行,这可能导致编译错误。而
if constexpr
只会编译满足条件的分支,从而避免了这种问题。
1 |
|
也就是说, 普通的 if 是运行时行为。编译器在编译模板时,必须确保所有分支在语法上都是(至少可能是)有效的,它不会因为 if 的条件是编译期常量就“跳过”编译某个分支。
if constexpr 则告诉编译器:“这个 if 语句的条件是一个编译期常量,请你在编译时就对它求值,并且只保留 true 的那个分支,彻底‘丢弃’(Discard) 另一个分支。”. 被“丢弃”的分支就好像从未被写过一样。它不需要是语法正确的。
1 |
|
| 特性 | #if (预处理器) | if (普通) | if constexpr (C++17) |
|---|---|---|---|
| 执行阶段 | 预处理期 | 运行时 | 编译期(模板实例化时) |
| 判断依据 | 宏定义(#define) | 变量的值 | 编译期常量表达式(如 sizeof, std::is_…) |
| 分支处理 | 仅保留 true 的分支文本 | 两个分支都必须编译 | 仅保留 true 的分支,丢弃另一个 |
| C++感知 | 否(纯文本替换) | 是 | 是(感知类型和 constexpr 值) |
if constexpr 的优势:
- 极大地提升了可读性:在 C++17 之前,要实现上述 getValue 的功能,你需要使用 SFINAE (Substitution Failure Is Not An Error) 或 标签分发 (Tag Dispatching),这两种技术都极其复杂、晦涩,充满了模板“黑话”。而 if constexpr 用一个简单的 if 就替代了这一切。
- 简化了模板元编程 (TMP):它是编写模板代码的首选工具,使得泛型代码可以根据类型特性(std::is_integral、std::is_same_v 等)轻松地“特化”自己的行为。
- 零运行时开销:这个 if 判断在编译时就完成了。最终生成的机器码中不包含这个 if 分支判断指令。getValue_Fixed(5) 编译后就是 return 5;getValue_Fixed(p) 编译后就是 return *p。这对于高性能代码至关重要。
概念 (Concepts)
概念 (Concepts) 是 C++20 引入的一种编译期约束机制,用于指定模板参数必须满足的条件。它们提供了一种更直观、更易读的方式来表达模板参数的要求,从而改善了模板代码的可维护性和错误诊断能力。
if constexpr 让你能在模板内部根据类型执行不同的代码路径。 concept 则让你能在模板外部就限制哪些类型可以进入这个模板。
在 C++20 之前,我们使用一种称为 SFINAE (Substitution Failure Is Not An Error,替换失败不是错误) 的“黑魔法”来约束模板。SFINAE 的核心问题是语法极其复杂:你需要使用 std::enable_if、std::void_t 等晦涩的元编程技巧来“伪造”一个编译错误,从而让编译器“知难而退”,去尝试别的重载; 而且错误信息极其恐怖:这是最致命的问题。当你不小心用一个不符合要求的类型去调用一个模板时,编译器会尝试将该类型代入模板。这会导致模板内部深处的某个操作(比如 iterator - iterator)失败。其结果是,编译器会吐出长达几百行、完全无法阅读的错误日志,你根本不知道最初的错误是你传错了类型。
concept(概念)是一个编译期的“类型检查器”。它是一个具名的、可复用的约束条件。它在模板被实例化之前就进行检查,确保传入的类型满足特定的要求。如果不满足,编译器会给出清晰、简洁的错误信息,指出哪个概念没有被满足。
这带来了两大核心优势:
- 语法极其简洁:你用清晰的 C++ 语法来描述你对一个类型的要求(“它必须能相加”、“它必须有 .begin() 方法”)。
- 错误信息极其友好:如果一个类型不满足
concept,编译器不会再吐出那几百行的内部错误,而是会直接、清晰地告诉你:“Error:
constraints not satisfied for function ‘std::sort’. The type
‘std::list
::iterator’ does not satisfy the concept ‘std::random_access_iterator’.”
concept 的语法分为两部分:定义 (Defining) 和 使用 (Using)。
语法 1:如何定义一个 Concept
使用 concept 关键字来定义一个编译期布尔值。最强大的方式是结合 requires 表达式 (expression) 来检查语法。requires 表达式允许你“排演”你希望对这个类型执行的操作。
1 |
|
语法 2:如何使用一个 Concept
有四种(实际上是三种主要)方式来使用 concept 来约束一个函数模板。假设我们有一个 MyIntegral 概念:
1 | // 方式 1: Trailing requires (尾随 requires 子句) |
requires 表达式 vs requires 子句
这是一个常见的混淆点:
requires 表达式 (Expression):
- 用途:用于定义 concept。
- 语法:requires (param) { … }
- 功能:检查花括号 {} 内的 C++ 语法是否有效。
- 示例:concept C = requires(T a) { a + a; };
requires 子句 (Clause):
- 用途:用于使用 concept(约束模板)。
- 语法:requires ConceptName
- 功能:检查 ConceptName
这个编译期布尔值是否为 true。 - 示例:
template<typename T> void func(T) requires C<T>;
总之, concept 是 C++20 将模板元编程“现代化”、“人性化”的基石。它将模板约束从一个充满陷阱、依赖“黑魔法”的 SFINAE 技巧,转变为一个清晰、健壮、编译器友好的核心语言特性。
假如你经常需要编写高度泛型的代码(例如一个策略模板,可以处理 float 或 double), concept 允许你明确地约束:“这个策略模板只接受 std::floating_point 类型。”这能在编译时就防止不小心传入一个 int,从而避免了可能导致严重后果的(例如整数除法)的运行时 Bug。