C++ / 2026-08-01
CS106L 第 10 讲:函数模板
系统学习函数模板、类型推导、概念约束、参数包与模板元编程。
本课程重构自 CS106L Spring 2026 的 Function Templates 课件。原课件的主线依次涉及函数模板、概念、可变参数模板和模板元编程,并最终连接到下一节的函数与算法。
本节课要解决的核心问题
上一节学习类模板时,我们已经见过这样的代码:
std::vector<int> numbers;
std::vector<double> prices;
std::vector<std::string> names;
它们使用的是同一个 std::vector 类模板,只是把元素类型替换成了不同的具体类型。
这解决了一个重要问题:
当多个类的逻辑完全相同,只有某些类型不同时,不需要为每种类型重新写一个类。
不过,重复代码并不只出现在类中。
假设我们想编写一个函数,返回两个值中较小的一个。整数需要这个功能,浮点数需要这个功能,字符串也需要这个功能:
int min(int a, int b);
double min(double a, double b);
std::string min(std::string a, std::string b);
这三个函数的算法完全相同,只是参数类型与返回类型不同。
于是,本节课真正要解决的第一个问题出现了:
类模板能够生成不同类型的类,那么能不能让编译器生成不同类型的函数?
解决这个问题之后,又会继续出现三个问题:
- 编译器怎样判断模板中的类型到底是什么?
- 怎样限制模板只能接受满足要求的类型?
- 怎样让函数接受任意数量、甚至不同类型的参数?
- 既然模板在编译期生成代码,我们能不能直接在编译期完成计算?
这些问题会自然带出本节的四条主线:
函数模板
↓
模板参数推导与模板实例化
↓
概念与模板约束
↓
可变参数模板
↓
模板元编程与编译期计算
学习本节需要的前置知识
开始之前,需要能够阅读以下基础语法:
- 普通函数;
- 函数参数与返回值;
- 函数重载;
const引用;std::vector、std::set和std::string;- 迭代器的
*it、++it、it != end; - 类模板的基础语法。
本节会短暂涉及一些较新的 C++20 语法,例如:
- 概念(concept);
requires;- 参数包(parameter pack);
constexpr;consteval。
这些内容会从具体问题开始讲解,不要求提前掌握。
从上一节开始:模板是一座“代码工厂”
假设没有类模板,我们可能需要分别编写:
class IntVector {
// 存储 int 的逻辑
};
class DoubleVector {
// 存储 double 的逻辑
};
class StringVector {
// 存储 std::string 的逻辑
};
它们内部的大部分逻辑都一样:
- 保存一组元素;
- 记录元素数量;
- 添加元素;
- 根据下标访问元素;
- 扩大存储空间。
不同之处只有元素类型。
类模板把变化的类型提取成一个模板参数:
template <typename T>
class Vector {
public:
T& at(std::size_t index);
// 其他成员函数
};
当程序写下:
Vector<int> numbers;
Vector<double> prices;
可以把编译器的工作暂时理解成:
Vector<int>
↓ 将 T 替换为 int
生成一个处理 int 的具体类
Vector<double>
↓ 将 T 替换为 double
生成一个处理 double 的具体类
这种“根据模板生成具体代码”的过程称为:
模板实例化(template instantiation)
模板最重要的思想并不是“类型可以写成 T”,而是:
模板让编译器自动完成重复的代码生成工作。
这个思想不只适用于类,同样适用于函数。
为什么普通的 min 函数不够用
先只考虑整数。
我们希望编写一个函数,返回两个整数中较小的那个:
#include <iostream>
int my_min(int a, int b) {
if (a < b) {
return a;
}
return b;
}
int main() {
std::cout << my_min(106, 107) << '\n';
}
程序输出:
106
这个函数也可以用条件运算符写成:
int my_min(int a, int b) {
return a < b ? a : b;
}
条件运算符(conditional operator)的结构是:
条件 ? 条件成立时的结果 : 条件不成立时的结果
因此:
a < b ? a : b
表示:
如果 a < b,表达式结果是 a;
否则,表达式结果是 b。
现在我们尝试把这个函数用于其他类型。
my_min(106, 107); // int
my_min(1.2, 3.4); // double
my_min("Preston", "Rachel"); // 字符串?
当前函数只能接受 int,因此第二个和第三个调用无法按照我们期望的方式工作。
第一次尝试:使用函数重载
函数重载(function overloading)允许多个函数拥有相同名称,只要它们的参数类型不同。
于是,可以编写:
#include <iostream>
#include <string>
int my_min(int a, int b) {
return a < b ? a : b;
}
double my_min(double a, double b) {
return a < b ? a : b;
}
std::string my_min(std::string a, std::string b) {
return a < b ? a : b;
}
int main() {
std::cout << my_min(106, 107) << '\n';
std::cout << my_min(1.2, 3.4) << '\n';
std::cout << my_min(std::string{"Preston"},
std::string{"Rachel"})
<< '\n';
}
输出:
106
1.2
Preston
对于 std::string,operator< 会按照字符串的字典序进行比较。
这里的“字典序”可以建立成这样的直觉:
逐个比较字符
↓
第一次出现不同字符的位置决定大小
例如:
"Preston"
"Rachel"
↑
'P' 小于 'R'
所以 "Preston" 排在 "Rachel" 前面。
函数重载能够解决问题,但现在请比较三个函数:
int my_min(int a, int b) {
return a < b ? a : b;
}
double my_min(double a, double b) {
return a < b ? a : b;
}
std::string my_min(std::string a, std::string b) {
return a < b ? a : b;
}
它们真正发生变化的只有类型:
int
double
std::string
算法本身完全没有变化。
这和上一节出现的三个不同 Vector 类是同一种重复:
类中的重复类型
→ 类模板
函数中的重复类型
→ 函数模板
函数模板:把函数中的类型变成参数
我们可以把三个重载合并成一个函数模板:
template <typename T>
T my_min(T a, T b) {
return a < b ? a : b;
}
逐部分观察这段语法:
template <typename T>
表示接下来的代码是一个模板,并声明了一个类型模板参数 T。
T my_min(T a, T b)
表示:
- 返回类型是
T; - 参数
a的类型是T; - 参数
b的类型也是T。
当 T 是 int 时,它可以生成类似这样的函数:
int my_min(int a, int b) {
return a < b ? a : b;
}
当 T 是 double 时,它可以生成:
double my_min(double a, double b) {
return a < b ? a : b;
}
当 T 是 std::string 时,它可以生成:
std::string my_min(std::string a, std::string b) {
return a < b ? a : b;
}
可以把函数模板想象成一座代码工厂:
┌──────────────────────┐
int ───────────────────→ │ │ ─→ my_min<int>
│ my_min 函数模板 │
double ────────────────→ │ │ ─→ my_min<double>
│ template <typename T>│
std::string ───────────→ │ │ ─→ my_min<std::string>
└──────────────────────┘
不过,这个类比有一个边界:
- “工厂”有助于理解编译器根据类型生成代码;
- 编译器不一定真的把完整源代码文本复制出来;
- C++ 语言规则只保证不同模板实例具有相应的行为,不规定编译器内部必须采用哪种具体实现。
模板本身和具体函数不是同一个东西
下面这一段:
template <typename T>
T my_min(T a, T b) {
return a < b ? a : b;
}
是一个函数模板(function template)。
它描述了怎样生成一组函数,但它本身不是某个只能接收固定类型的普通函数。
下面这些才是具体的函数模板实例:
my_min<int>
my_min<double>
my_min<std::string>
可以用文本关系表示:
函数模板
template <typename T>
T my_min(T a, T b)
│
├── my_min<int>
├── my_min<double>
└── my_min<std::string>
其中:
my_min<int>
可以称为该函数模板针对 int 的特化版本或实例。
严格地说,课件把下面的调用方式称为“显式实例化”:
my_min<int>(106, 107);
这是课堂上便于建立直觉的说法。
更严格的 C++ 术语是:
这里显式指定了模板实参
int,编译器随后根据需要实例化对应的函数模板特化。
标准意义上的“显式实例化声明”还存在另一种语法:
template int my_min<int>(int, int);
本节不需要使用这种语法。实际编程中,先把:
my_min<int>(106, 107);
理解成“明确告诉编译器 T 是 int”即可。
练习:模板会生成什么函数
阅读下面的模板:
template <typename T>
T larger(T a, T b) {
return a < b ? b : a;
}
调用:
larger<double>(1.5, 2.8);
编译器需要生成的具体函数大致是什么样?
答案与解释
T 被替换为 double:
double larger(double a, double b) {
return a < b ? b : a;
}
调用过程是:
larger<double>(1.5, 2.8)
↓
T = double
↓
a = 1.5
b = 2.8
↓
a < b 为 true
↓
返回 b
↓
结果为 2.8
这里没有修改原来的两个实参,因为参数采用值传递,函数内部的 a 和 b 是局部副本。
值传递仍然会复制对象
当前的模板是:
template <typename T>
T my_min(T a, T b) {
return a < b ? a : b;
}
调用:
std::string first = "Preston";
std::string second = "Rachel";
std::string result = my_min(first, second);
调用时发生的事情可以简化成:
调用之前:
first ──→ "Preston"
second ──→ "Rachel"
进入函数:
a 是 first 的副本
b 是 second 的副本
对于 int 和 double,复制通常很轻量。
但对于一个较大的字符串、容器或自定义对象,复制整个对象可能没有必要。my_min 只需要读取参数,不需要修改它们。
因此可以使用常量引用:
template <typename T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
现在:
a引用调用者传入的第一个对象;b引用调用者传入的第二个对象;const保证函数不能通过这两个引用修改原对象;- 返回类型仍然是
T,所以返回结果时会得到一个独立的对象。
关系可以画成:
first
┌──────────────────┐
│ "Preston" │
└──────────────────┘
↑
│ const 引用
a
second
┌──────────────────┐
│ "Rachel" │
└──────────────────┘
↑
│ const 引用
b
完整程序如下:
#include <iostream>
#include <string>
template <typename T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
int main() {
int number = my_min(106, 107);
std::string first = "Preston";
std::string second = "Rachel";
std::string name = my_min(first, second);
std::cout << number << '\n';
std::cout << name << '\n';
}
编译命令:
g++ -std=c++20 main.cpp -o main
输出:
106
Preston
练习:哪里发生了复制
阅读代码:
template <typename T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
std::string x = "apple";
std::string y = "banana";
std::string result = my_min(x, y);
判断:
- 创建参数
a时是否复制x? - 创建参数
b时是否复制y? - 得到
result时是否需要产生一个返回对象? - 函数能否修改
x和y?
答案与解释
- 不会复制
x。a是对x的常量引用。 - 不会复制
y。b是对y的常量引用。 - 返回类型是
T,不是const T&,因此返回的是一个独立结果对象。现代编译器可能通过返回值优化减少实际复制,但从程序语义上看,result是自己的字符串对象。 - 不能。
a和b都是const T&。
函数执行时:
a 引用 x
b 引用 y
a < b
即
"apple" < "banana"
结果为 true
返回 a 所代表的值
result 得到 "apple"
怎样调用函数模板
函数模板有两种常见调用方式。
明确写出模板类型
可以直接告诉编译器 T 是什么:
my_min<int>(106, 107);
my_min<double>(1.2, 3.4);
my_min<std::string>(
std::string{"Preston"},
std::string{"Rachel"}
);
尖括号中的内容就是模板实参:
<int>
<double>
<std::string>
调用:
my_min<int>(106, 107)
可以理解成:
请使用 my_min 模板
并令 T = int
然后传入 106 和 107
让编译器推导模板类型
函数模板通常不需要手动写出类型:
my_min(106, 107);
my_min(1.2, 3.4);
编译器会根据函数实参推导 T:
my_min(106, 107)
第一个实参 106 的类型是 int
第二个实参 107 的类型是 int
↓
T = int
类似地:
my_min(1.2, 3.4)
第一个实参的类型是 double
第二个实参的类型是 double
↓
T = double
这种过程称为:
模板参数推导(template argument deduction)
它和 auto 有相似的直觉。
auto number = 106;
编译器根据右侧表达式推导:
number 的类型是 int
而:
my_min(106, 107);
编译器根据实参推导:
T 是 int
不过,auto 变量推导和函数模板参数推导并不是同一套完整规则。它们在许多简单情况中表现相似,但遇到数组、引用、const 和初始化列表时可能出现区别。
模板参数推导不会随意帮你统一类型
考虑:
my_min(106, 3.14);
当前模板是:
template <typename T>
T my_min(const T& a, const T& b);
编译器分别从两个参数进行推导:
第一个参数:
106 的类型是 int
因此推导 T = int
第二个参数:
3.14 的类型是 double
因此推导 T = double
同一个 T 得到了两个不同结果:
T = int
T = double
编译器无法选择,因此调用失败。
它不会在模板参数推导阶段擅自决定:
“我把 int 转换成 double 好了。”
普通隐式类型转换通常不会用来解决这种相互冲突的模板推导结果。
一种解决方式是明确指定类型:
my_min<double>(106, 3.14);
此时不再需要推导 T:
T 已明确是 double
于是函数参数类型为:
const double&
const double&
整数 106 可以转换为 double 类型的 106.0,调用能够成立。
完整示例:
#include <iostream>
template <typename T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
int main() {
double result = my_min<double>(106, 3.14);
std::cout << result << '\n';
}
输出:
3.14
练习:哪些调用可以完成推导
给定:
template <typename T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
判断以下调用是否合法:
my_min(10, 20);
my_min(1.5, 2.0);
my_min(10, 2.5);
my_min<double>(10, 2.5);
答案与解释
第一行合法:
my_min(10, 20);
两个参数都是 int:
T = int
第二行合法:
my_min(1.5, 2.0);
两个参数都是 double:
T = double
第三行不合法:
my_min(10, 2.5);
推导产生冲突:
第一个参数推导 T = int
第二个参数推导 T = double
第四行合法:
my_min<double>(10, 2.5);
T 已经明确为 double,整数 10 可以转换成 double。
字符串字面量不是 std::string
下面的代码看起来像是在比较两个字符串:
my_min("Preston", "Rachel");
但双引号中的内容并不是 std::string 对象。
字符串字面量(string literal)本质上是一个字符数组。例如:
"Rachel"
它的类型接近:
const char[7]
其中包括结尾的空字符 '\0'。
在许多表达式中,字符数组会退化为指向第一个字符的指针:
const char*
如果模板参数采用值传递:
template <typename T>
T my_min(T a, T b) {
return a < b ? a : b;
}
调用:
my_min("Preston", "Rachel");
可能推导出:
T = const char*
生成的代码大致是:
const char* my_min(const char* a, const char* b) {
return a < b ? a : b;
}
这里的:
a < b
比较的不是字符串内容,而是两个指针值。
也就是说,它可能比较两个字符数组所在的位置,而不是字典序。
这不是我们想要的行为。
更安全的写法是显式使用 std::string:
my_min<std::string>("Preston", "Rachel");
模板参数明确为 std::string 后,两个字符串字面量会分别转换成临时的 std::string 对象。
完整示例:
#include <iostream>
#include <string>
template <typename T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
int main() {
std::string result =
my_min<std::string>("Preston", "Rachel");
std::cout << result << '\n';
}
输出:
Preston
这里还有一个需要区分的细节。
当参数是:
T a
字符串数组更容易在推导时退化成指针。
当参数是:
const T& a
数组类型可能被引用保留下来。由于 "Preston" 和 "Rachel" 的数组长度还不同,推导可能直接因为两个 T 不一致而失败。
因此,无论最终表现为“错误地推导成指针”还是“推导失败”,都不应依赖字符串字面量让编译器自动得到 std::string。
实际编程时,应写成:
my_min(std::string{"Preston"}, std::string{"Rachel"});
或者:
my_min<std::string>("Preston", "Rachel");
练习:为什么没有比较字符串内容
阅读:
template <typename T>
T smaller(T a, T b) {
return a < b ? a : b;
}
auto result = smaller("cat", "dog");
为什么这段代码不应该被理解成“按照字典序比较 cat 与 dog”?
答案与解释
字符串字面量不是 std::string。
值传递模板可能把它们推导为:
const char*
于是实例化的函数接近:
const char* smaller(const char* a, const char* b) {
return a < b ? a : b;
}
a < b 比较的是指针,而不是逐字符比较字符串内容。
正确写法之一是:
auto result = smaller(
std::string{"cat"},
std::string{"dog"}
);
此时:
T = std::string
std::string::operator< 才会按照字典序比较。
两个参数能否拥有不同类型
之前的模板要求两个参数类型完全相同:
template <typename T>
T my_min(const T& a, const T& b);
如果确实希望接受不同类型,可以设置两个模板参数:
template <typename T, typename U>
auto mixed_min(const T& a, const U& b) {
return a < b ? a : b;
}
调用:
mixed_min(106, 3.14);
会推导出:
T = int
U = double
返回类型写成 auto,由编译器根据:
a < b ? a : b
这个表达式的类型进行推导。
完整示例:
#include <iostream>
#include <typeinfo>
template <typename T, typename U>
auto mixed_min(const T& a, const U& b) {
return a < b ? a : b;
}
int main() {
auto result = mixed_min(106, 3.14);
std::cout << result << '\n';
}
输出:
3.14
因为条件运算符需要让第二个和第三个操作数形成一个共同结果类型。在 int 与 double 之间,结果通常是 double。
不过,这个函数并不是对任意两种类型都有效。
它仍然要求:
a < b是合法表达式;a和b能够形成条件运算符的共同结果类型;- 返回结果的类型能够被正常构造。
例如两个完全无关的自定义类型不一定能够使用这个函数。
使用 IDE 查看模板推导结果
模板代码中,变量的真实类型有时并不直接写在源代码里:
auto result = mixed_min(106, 3.14);
在 VS Code 等编辑器中,可以把鼠标悬停在:
result;mixed_min;- 模板调用;
auto;
上面查看编辑器推导出的类型。
可能显示类似:
double result
或者:
mixed_min<int, double>
这不是语言规则的一部分,而是编辑器提供的辅助功能。
当模板推导结果与预期不同,优先检查:
实参的真实类型是什么?
模板参数被推导成了什么?
最终实例化出的函数签名是什么?
函数模板在标准库中的实际用途
my_min 展示了函数模板的基本语法,但模板函数的真正价值远不止比较两个数。
一个很重要的应用是:
标准库算法。
例如 std::find 可以在不同容器中查找元素:
std::vector<int> numbers;
std::set<std::string> names;
std::deque<double> prices;
这些容器的内部结构不同,迭代器类型也不同:
std::vector<T>::iterator
std::deque<T>::iterator
std::set<T>::iterator
std::map<K, V>::iterator
std::unordered_map<K, V>::iterator
如果为每一种容器、每一种元素类型分别编写查找函数,重复代码会迅速增长。
这正适合函数模板。
从一个过于具体的 find 开始
先只为 std::vector<int> 编写查找函数。
我们希望它接收:
- 起始迭代器;
- 尾后迭代器;
- 要查找的整数。
并返回:
- 指向目标元素的迭代器;
- 如果没有找到,则返回
end。
函数声明可能写成:
std::vector<int>::iterator my_find(
std::vector<int>::iterator begin,
std::vector<int>::iterator end,
int value
);
它的问题是类型过于具体:
只能处理 std::vector<int>::iterator
只能查找 int
它不能直接处理:
std::vector<std::string>
std::set<int>
std::set<std::string>
std::deque<double>
先观察算法真正需要什么能力
查找算法的核心逻辑是:
while (it != end) {
if (*it == value) {
break;
}
++it;
}
这段算法并不需要知道容器到底是:
vector
deque
set
list
它只需要迭代器支持三种操作:
it != end
用于判断是否到达尾后位置。
*it
用于访问当前位置的元素。
++it
用于前进到下一个元素。
此外,元素还需要能够与目标值比较:
*it == value
这正是泛型编程的重要思想:
算法不关心对象的具体类型,只关心它能否提供算法所需要的操作。
把迭代器类型和元素类型模板化
可以把迭代器类型写成模板参数 Iterator,把查找值类型写成模板参数 TElem:
template <typename Iterator, typename TElem>
Iterator my_find(
Iterator begin,
Iterator end,
const TElem& value
) {
Iterator it = begin;
while (it != end) {
if (*it == value) {
break;
}
++it;
}
return it;
}
这里:
Iterator
表示迭代器类型。
TElem
表示要查找的值的类型。
函数的返回类型也是 Iterator,因为我们需要返回:
- 找到元素时的位置;
- 或者传入的
end。
完整实现与执行过程
#include <iostream>
#include <vector>
template <typename Iterator, typename TElem>
Iterator my_find(
Iterator begin,
Iterator end,
const TElem& value
) {
Iterator it = begin;
while (it != end) {
if (*it == value) {
break;
}
++it;
}
return it;
}
int main() {
std::vector<int> numbers{106, 111, 42, 112};
auto it = my_find(
numbers.begin(),
numbers.end(),
42
);
if (it != numbers.end()) {
*it = 107;
}
for (int number : numbers) {
std::cout << number << ' ';
}
std::cout << '\n';
}
输出:
106 111 107 112
调用之前:
numbers:
下标 0 1 2 3
┌─────┬─────┬─────┬─────┐
元素 │ 106 │ 111 │ 42 │ 112 │
└─────┴─────┴─────┴─────┘
↑
begin
end 指向最后一个元素之后的位置
进入函数:
Iterator = std::vector<int>::iterator
TElem = int
it = begin
value = 42
第一次循环:
*it 是 106
106 == 42 为 false
++it
状态:
106 111 42 112
↑
it
第二次循环:
*it 是 111
111 == 42 为 false
++it
状态:
106 111 42 112
↑
it
第三次循环:
*it 是 42
42 == 42 为 true
执行 break
函数返回指向 42 的迭代器。
回到 main:
if (it != numbers.end()) {
*it = 107;
}
*it 表示迭代器所指向的原容器元素,因此修改后:
106 111 107 112
为什么必须先检查 it != end
课件示例中为了突出重点,会直接写:
auto it = find(v.begin(), v.end(), 42);
*it = 107;
这隐含假设目标一定存在。
实际程序中,必须处理没有找到的情况:
auto it = my_find(v.begin(), v.end(), 42);
if (it != v.end()) {
*it = 107;
}
如果没有找到,my_find 返回 end。
end 是尾后迭代器,它不指向有效元素:
[106][111][112][尾后位置]
↑
end
对 end 解引用:
*end
会产生未定义行为(undefined behavior)。
未定义行为不是一个普通的、固定的运行时错误。程序可能:
- 崩溃;
- 输出错误结果;
- 看似正常;
- 在不同编译器或优化级别下表现不同。
因此:
查找函数返回迭代器后,在解引用前应先检查它是否等于
end。
同一个 my_find 可以处理不同容器
#include <iostream>
#include <set>
#include <string>
#include <vector>
template <typename Iterator, typename TElem>
Iterator my_find(
Iterator begin,
Iterator end,
const TElem& value
) {
Iterator it = begin;
while (it != end) {
if (*it == value) {
break;
}
++it;
}
return it;
}
int main() {
std::vector<std::string> words{
"seven",
"kingdoms"
};
auto vector_it = my_find(
words.begin(),
words.end(),
std::string{"kingdoms"}
);
std::set<std::string> houses{
"Stark",
"Targaryen"
};
auto set_it = my_find(
houses.begin(),
houses.end(),
std::string{"Targaryen"}
);
std::cout << (vector_it != words.end()) << '\n';
std::cout << (set_it != houses.end()) << '\n';
}
输出:
1
1
两次实例化使用的迭代器类型不同:
第一次:
Iterator = std::vector<std::string>::iterator
第二次:
Iterator = std::set<std::string>::iterator
但函数体完全相同,因为两种迭代器都支持:
it != end
*it
++it
标准库中的 std::find
C++ 标准库已经提供了这一算法。
需要包含:
#include <algorithm>
基本用法:
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> numbers{106, 111, 42, 112};
auto it = std::find(
numbers.begin(),
numbers.end(),
42
);
if (it != numbers.end()) {
*it = 107;
}
for (int value : numbers) {
std::cout << value << ' ';
}
std::cout << '\n';
}
一个简化后的经典接口可以表示为:
template <typename InputIt, typename T>
InputIt find(
InputIt first,
InputIt last,
const T& value
);
现在我们已经能够读懂这几个部分:
template <typename InputIt, typename T>
↓
这是一个拥有两个类型模板参数的函数模板
InputIt
↓
返回类型与迭代器类型相同
InputIt first, InputIt last
↓
接收一个半开区间 [first, last)
const T& value
↓
要查找的目标值,只读且避免不必要复制
练习:手动跟踪 find
给定:
std::vector<int> values{3, 8, 5, 8};
auto it = my_find(
values.begin(),
values.end(),
5
);
回答:
- 循环执行多少次比较?
- 返回的迭代器指向哪个下标?
- 如果查找
10,返回什么? - 查找
10后能否直接执行std::cout << *it;?
答案与解释
查找 5 时:
第一次:3 == 5,false
第二次:8 == 5,false
第三次:5 == 5,true
一共执行三次元素比较。
返回的迭代器指向下标 2:
下标 0 1 2 3
[3] [8] [5] [8]
↑
it
如果查找 10,所有元素都不相等,最终:
it == values.end()
因此返回尾后迭代器。
此时不能直接解引用:
std::cout << *it;
必须先检查:
if (it != values.end()) {
std::cout << *it;
}
函数模板看起来能接受任何类型,但真的可以吗
回到 my_min:
template <typename T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
语法上,T 好像可以被替换成任何类型。
但函数体中使用了:
a < b
这意味着 T 必须支持小于运算符。
考虑一个自定义类型:
#include <string>
struct StanfordID {
std::string name;
std::string username;
};
创建两个对象:
StanfordID preston{
"Preston",
"pseay"
};
StanfordID rachel{
"Rachel",
"rfern"
};
调用:
my_min(preston, rachel);
编译器可以推导:
T = StanfordID
然后尝试生成:
StanfordID my_min(
const StanfordID& a,
const StanfordID& b
) {
return a < b ? a : b;
}
问题出现在:
a < b
我们没有定义两个 StanfordID 应该怎样比较。
可以按:
- 姓名;
- 用户名;
- 学号;
- 注册时间;
比较,但编译器无法替程序员决定。
因此会产生编译错误。
为什么错误直到实例化时才出现
编译器第一次看到模板:
template <typename T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
此时还不知道 T 是什么。
对于某些类型,a < b 合法:
int
double
std::string
对于另一些类型,它不合法:
没有定义 operator< 的 StanfordID
编译器不能因为存在某种不支持 < 的类型,就直接拒绝整个模板。
它通常要等到真正实例化:
my_min<StanfordID>(preston, rachel);
才发现:
T = StanfordID
但是 StanfordID 不支持 a < b
因此模板错误经常包含两部分:
模板函数内部真正失败的位置
+
触发该实例化的调用位置
例如错误信息可能表达:
invalid operands to binary expression
return a < b ? a : b;
^
in instantiation of function template specialization
my_min<StanfordID>
阅读模板报错时,可以按下面的顺序寻找:
1. 最上方第一个真正的 error 是什么?
2. 哪个表达式无法编译?
3. 这一实例化中的模板类型是什么?
4. 哪一行代码请求了该模板实例?
不加限制的 find 也能接收荒谬参数
我们的模板是:
template <typename Iterator, typename TElem>
Iterator my_find(
Iterator begin,
Iterator end,
const TElem& value
);
调用:
my_find(1, 5, 3);
仅从参数推导看:
Iterator = int
TElem = int
函数签名似乎能够形成:
int my_find(
int begin,
int end,
const int& value
);
但函数体会执行:
*it
整数不是迭代器,不能解引用。
于是编译器可能在模板内部给出:
indirection requires pointer operand
对于调用者来说,真正的问题其实很简单:
第一个和第二个参数必须是迭代器。
但错误却可能来自函数体深处。
类似地,std::set<StanfordID> 如果需要按照 < 排序,而 StanfordID 没有比较操作,错误可能一路展开到标准库内部模板代码,产生很长的错误信息。
于是下一个问题自然出现:
能不能在模板接口上直接写明:这里要求一个可比较类型,或者这里要求一个迭代器类型?
C++20 提供的答案是概念。
概念:为模板类型写出明确要求
概念(concept)可以理解成:
一组拥有名称的类型约束。
例如,可以定义一个概念,表示类型支持小于比较:
#include <concepts>
template <typename T>
concept LessThanComparable =
requires(const T& a, const T& b) {
{ a < b } -> std::convertible_to<bool>;
};
先不要急着记住整段语法,把它拆成三个部分。
第一部分:为概念命名
template <typename T>
concept LessThanComparable = ...;
表示我们正在定义一个针对类型 T 的概念,名称是:
LessThanComparable
它回答的问题是:
类型 T 是否满足“能够进行小于比较”这组要求?
第二部分:建立待检查的表达式环境
requires(const T& a, const T& b) {
// 检查内容
}
这里不是在运行时真正创建两个对象。
它表达的是:
假设存在两个类型为
const T&的对象a和b,检查花括号中的表达式是否合法。
这里使用常量引用比直接写:
requires(T a, T b)
更加贴近实际函数参数,也不会无意中把“必须能够复制”混入这一项比较要求中。
第三部分:要求比较结果可以当作布尔值
{ a < b } -> std::convertible_to<bool>;
这包含两层要求。
第一层:
a < b
必须是合法表达式。
第二层:
-> std::convertible_to<bool>
表示表达式结果必须能够转换成 bool。
这并不要求结果类型必须严格等于 bool,只需要能够用于真假判断。
值得注意的是:
std::convertible_to
本身也是标准库提供的概念。
需要包含:
#include <concepts>
使用概念约束 my_min
概念可以通过 requires 子句使用:
template <typename T>
requires LessThanComparable<T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
也可以使用简写形式:
template <LessThanComparable T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
两种形式的核心含义相同:
只有当 T 满足 LessThanComparable 时,
这个模板才参与匹配。
当调用:
my_min(preston, rachel);
而 StanfordID 不支持 < 时,错误会更接近:
StanfordID does not satisfy LessThanComparable
because a < b is invalid
这比等到模板函数体实例化后,再从深层表达式中报错更容易理解。
一个更准确的最小值概念
课件重点强调了:
T 必须支持 operator<
这是最核心的要求。
不过,我们当前函数返回的是 T:
T my_min(const T& a, const T& b)
返回表达式来自一个 const T&,因此还需要能够构造返回对象。
对于普通类型,这通常意味着 T 应当可复制构造。
可以写出更加完整的概念:
#include <concepts>
template <typename T>
concept MinCompatible =
std::copy_constructible<T> &&
requires(const T& a, const T& b) {
{ a < b } -> std::convertible_to<bool>;
};
然后:
template <MinCompatible T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
完整程序:
#include <concepts>
#include <iostream>
#include <string>
template <typename T>
concept MinCompatible =
std::copy_constructible<T> &&
requires(const T& a, const T& b) {
{ a < b } -> std::convertible_to<bool>;
};
template <MinCompatible T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
int main() {
std::cout << my_min(106, 107) << '\n';
std::string first = "Preston";
std::string second = "Rachel";
std::cout << my_min(first, second) << '\n';
}
输出:
106
Preston
标准库已经提供了许多概念
C++20 的 <concepts> 中包含许多常用概念,例如:
| 概念 | 大致要求 |
|---|---|
std::same_as<T, U> |
T 与 U 是同一种类型 |
std::derived_from<T, U> |
T 派生自 U |
std::convertible_to<T, U> |
T 可以转换成 U |
std::common_with<T, U> |
两种类型拥有共同类型 |
std::integral<T> |
T 是整数类型 |
std::signed_integral<T> |
T 是有符号整数类型 |
std::unsigned_integral<T> |
T 是无符号整数类型 |
std::floating_point<T> |
T 是浮点类型 |
std::assignable_from<T, U> |
T 可以由 U 赋值 |
std::swappable<T> |
T 的对象可以交换 |
例如,只接受整数的模板可以写成:
#include <concepts>
template <std::integral T>
T twice(T value) {
return value * 2;
}
调用:
twice(10); // 合法
twice(3.14); // 不满足 std::integral
标准库也提供迭代器概念
常见迭代器概念包括:
std::input_iterator
std::output_iterator
std::forward_iterator
std::bidirectional_iterator
std::random_access_iterator
std::contiguous_iterator
它们表达不同等级的迭代器能力。
可以建立这样的依赖关系:
input_iterator
↓ 增加多次遍历等能力
forward_iterator
↓ 增加向后移动
bidirectional_iterator
↓ 增加随机跳转
random_access_iterator
↓ 增加内存连续性要求
contiguous_iterator
这张关系图只用于建立大致直觉。严格来说,不同迭代器概念的完整要求还涉及:
- 是否可复制;
- 解引用结果;
- 前置与后置自增;
- 多次遍历保证;
- 差值类型;
- 引用类型。
本节只需要知道:
std::input_iterator能表达一个可以向前读取元素的迭代器。
使用概念改进 my_find
可以先要求 It 是输入迭代器:
#include <iterator>
template <std::input_iterator It, typename T>
It my_find(It begin, It end, const T& value);
现在调用:
my_find(1, 5, 3);
会更早失败,因为:
int 不满足 std::input_iterator
不过,算法还需要:
*it == value
因此可以进一步添加约束:
#include <concepts>
#include <iterator>
template <typename It, typename T>
concept Findable =
std::input_iterator<It> &&
requires(It it, const T& value) {
{ *it == value } -> std::convertible_to<bool>;
};
完整实现:
#include <concepts>
#include <iostream>
#include <iterator>
#include <vector>
template <typename It, typename T>
concept Findable =
std::input_iterator<It> &&
requires(It it, const T& value) {
{ *it == value } -> std::convertible_to<bool>;
};
template <typename It, typename T>
requires Findable<It, T>
It my_find(It begin, It end, const T& value) {
It it = begin;
while (it != end) {
if (*it == value) {
break;
}
++it;
}
return it;
}
int main() {
std::vector<int> values{1, 2, 3, 4};
auto it = my_find(
values.begin(),
values.end(),
3
);
if (it != values.end()) {
std::cout << *it << '\n';
}
}
输出:
3
现在函数接口直接表达了算法要求:
It 必须是输入迭代器
*it 必须能够和 value 使用 ==
比较结果必须能当作 bool
练习:设计一个概念
编写一个概念 Addable,要求两个 T 类型对象能够进行:
a + b
并且结果能够转换回 T。
然后用它约束:
T add(const T& a, const T& b);
答案与解释
#include <concepts>
template <typename T>
concept Addable =
requires(const T& a, const T& b) {
{ a + b } -> std::convertible_to<T>;
};
template <Addable T>
T add(const T& a, const T& b) {
return a + b;
}
requires 中:
{ a + b }
要求加法表达式能够编译。
-> std::convertible_to<T>
要求结果能够转换成 T。
例如:
add(10, 20);
中:
T = int
10 + 20 的类型是 int
可以转换为 int
因此合法。
如果某个自定义类型没有定义 operator+,它不会满足 Addable。
为什么需要可变数量的参数
现在回到最小值函数。
当前函数只能处理两个值:
my_min(2.4, 7.5);
如果想比较三个值,可以新增重载:
template <MinCompatible T>
T my_min(
const T& a,
const T& b,
const T& c
) {
T rest_min = my_min(b, c);
return a < rest_min ? a : rest_min;
}
它把问题拆成:
三个数的最小值
=
第一个数
与
后两个数的最小值
再比较
四个参数可以继续写:
template <MinCompatible T>
T my_min(
const T& a,
const T& b,
const T& c,
const T& d
) {
T rest_min = my_min(b, c, d);
return a < rest_min ? a : rest_min;
}
现在:
my_min(2.4, 7.5);
my_min(2.4, 7.5, 5.3);
my_min(2.4, 7.5, 5.3, 1.2);
都能工作。
但如果需要八个参数:
my_min(
2.4,
7.5,
5.3,
1.2,
3.4,
6.7,
8.9,
9.1
);
难道还要继续手写五参数、六参数、七参数和八参数重载吗?
模板本来就是为了自动生成重复代码。
因此真正的问题是:
能不能让编译器根据参数数量,自动生成对应的函数重载?
在进入可变参数模板前,先看一个稍微不同的递归方案。
使用 std::vector 表示任意数量的值
可以让函数接收一个容器:
template <MinCompatible T>
T vector_min(const std::vector<T>& values);
这样参数数量由 vector 的长度决定。
递归思想是:
一个元素:
最小值就是这个元素
多个元素:
最小值 =
第一个元素
与
剩余元素的最小值
再比较
一个修正后的完整实现:
#include <concepts>
#include <cstddef>
#include <stdexcept>
#include <vector>
template <typename T>
concept MinCompatible =
std::copy_constructible<T> &&
requires(const T& a, const T& b) {
{ a < b } -> std::convertible_to<bool>;
};
template <MinCompatible T>
T vector_min(const std::vector<T>& values) {
if (values.empty()) {
throw std::invalid_argument(
"vector_min requires at least one value"
);
}
if (values.size() == 1) {
return values[0];
}
const T& first = values[0];
std::vector<T> rest(
values.begin() + 1,
values.end()
);
T rest_min = vector_min(rest);
return first < rest_min ? first : rest_min;
}
调用时需要先构造能够推导出类型的 vector:
std::vector<double> values{
2.4,
7.5,
5.3,
1.2
};
double result = vector_min(values);
或者显式指定模板类型:
double result = vector_min<double>({
2.4,
7.5,
5.3,
1.2
});
这里需要纠正课件中的一个教学性简写。
下面的写法:
vector_min({2.4, 7.5, 5.3});
对于参数类型:
const std::vector<T>&
通常无法直接从裸花括号初始化列表中推导 T,因为花括号初始化列表本身没有普通表达式类型。
因此应写成:
vector_min(std::vector<double>{2.4, 7.5, 5.3});
或者:
vector_min<double>({2.4, 7.5, 5.3});
递归 vector 方案怎样执行
调用:
vector_min(std::vector<int>{7, 5, 1});
第一次调用:
values = [7, 5, 1]
first = 7
rest = [5, 1]
需要计算 vector_min([5, 1])
第二次调用:
values = [5, 1]
first = 5
rest = [1]
需要计算 vector_min([1])
第三次调用:
values = [1]
size == 1
返回 1
回到第二次调用:
first = 5
rest_min = 1
5 < 1 为 false
返回 1
回到第一次调用:
first = 7
rest_min = 1
7 < 1 为 false
返回 1
最终结果:
1
vector 递归方案的问题
这个方案逻辑正确,但存在额外开销。
每一层递归都构造:
std::vector<T> rest(...)
对于:
[7, 5, 1]
会创建:
[5, 1]
[1]
对于更长的数组,会反复:
- 分配动态内存;
- 构造新的
vector; - 复制元素;
- 销毁临时容器。
课件指出可以通过包装函数避免一部分递归复制,例如传递下标或迭代器范围,但用容器表示参数本身仍然有额外结构。
我们真正想要的是:
my_min(2.4, 7.5, 5.3, 1.2);
同时不必手写任意数量的重载。
这会带出可变参数模板。
可变参数模板:模板参数的数量也可以变化
可变参数模板(variadic template)可以接收零个或多个模板参数。
先看课件中的核心结构:
template <MinCompatible T>
T variadic_min(const T& value) {
return value;
}
template <MinCompatible T, typename... Args>
T variadic_min(
const T& value,
const Args&... args
) {
auto rest_min = variadic_min(args...);
return value < rest_min ? value : rest_min;
}
这里出现了三种新东西:
typename... Args
const Args&... args
args...
它们分别表示:
模板参数包
函数参数包
参数包展开
模板参数包
typename... Args
声明了一个模板参数包(template parameter pack)。
普通模板参数:
typename T
只代表一个类型。
模板参数包:
typename... Args
可以代表零个或多个类型。
例如调用:
variadic_min(2, 7, 5, 1);
在第一层中可能得到:
T = int
Args = [int, int, int]
如果调用:
some_function(42, 3.14, std::string{"hello"});
某个可变参数模板可能推导:
Args = [int, double, std::string]
因此,参数包中的类型不一定相同。
函数参数包
const Args&... args
声明了一个函数参数包(function parameter pack)。
如果:
Args = [int, double, std::string]
那么:
const Args&... args
可以建立成类似这样的直觉:
const int& arg0,
const double& arg1,
const std::string& arg2
它表示零个或多个函数参数。
这里的 ... 不是省略号注释,而是 C++ 语法的一部分。
参数包展开
args...
称为参数包展开(pack expansion)。
假设当前参数包包含:
args = [7, 5, 1]
那么:
variadic_min(args...)
展开后可以理解为:
variadic_min(7, 5, 1)
如果:
args = [5, 1]
则展开成:
variadic_min(5, 1)
如果只剩一个值,最终展开到:
variadic_min(1)
为什么必须有递归终点
可变参数版本不断减少参数数量:
4 个参数
↓
3 个参数
↓
2 个参数
↓
1 个参数
当只剩一个参数时,最小值就是它自己:
template <MinCompatible T>
T variadic_min(const T& value) {
return value;
}
这称为递归的基础情况(base case)。
如果没有基础情况,递归调用最终会尝试:
variadic_min();
但我们没有定义零参数版本,编译会失败。
基础情况的作用可以表示为:
递归版本不断缩小问题
↓
到达一个参数
↓
基础情况直接返回
↓
递归开始逐层返回
一步步展开 variadic_min(2, 7, 5, 1)
调用:
variadic_min(2, 7, 5, 1);
第一层模板推导:
T = int
Args = [int, int, int]
value = 2
args = [7, 5, 1]
编译器需要生成接近这样的函数:
int variadic_min(
const int& value,
const int& arg0,
const int& arg1,
const int& arg2
) {
auto rest_min =
variadic_min(arg0, arg1, arg2);
return value < rest_min
? value
: rest_min;
}
接下来:
variadic_min(7, 5, 1);
需要另一个实例:
int variadic_min(
const int& value,
const int& arg0,
const int& arg1
) {
auto rest_min =
variadic_min(arg0, arg1);
return value < rest_min
? value
: rest_min;
}
然后:
variadic_min(5, 1);
对应:
int variadic_min(
const int& value,
const int& arg0
) {
auto rest_min =
variadic_min(arg0);
return value < rest_min
? value
: rest_min;
}
最后:
variadic_min(1);
匹配基础情况:
int variadic_min(const int& value) {
return value;
}
一次四参数调用会使编译器需要这些实例:
variadic_min<int, int, int, int>
variadic_min<int, int, int>
variadic_min<int, int>
variadic_min<int>
编译期生成函数和运行时递归不是一回事
这里容易出现一个重要混淆。
编译器在编译期做的是:
根据不同参数数量生成需要的函数实例
而程序运行时,生成的函数可能继续相互调用:
4 参数函数调用 3 参数函数
3 参数函数调用 2 参数函数
2 参数函数调用 1 参数函数
因此应区分:
模板实例化:
发生在编译期
函数调用与递归:
概念上发生在运行期
编译器可能进行内联和常量折叠,使运行时调用消失,但那属于优化,不是模板语法本身的保证。
为最小值函数明确要求所有类型相同
参数包本身允许不同类型,但“求一组值的最小值”通常希望所有值拥有相同类型。
下面是一个更严格的版本:
#include <concepts>
#include <type_traits>
template <typename T>
concept MinCompatible =
std::copy_constructible<T> &&
requires(const T& a, const T& b) {
{ a < b } -> std::convertible_to<bool>;
};
template <MinCompatible T>
T variadic_min(const T& value) {
return value;
}
template <MinCompatible T, typename... Args>
requires
(sizeof...(Args) > 0) &&
(std::same_as<T, Args> && ...)
T variadic_min(
const T& first,
const Args&... rest
) {
T rest_min = variadic_min(rest...);
return first < rest_min
? first
: rest_min;
}
其中:
(std::same_as<T, Args> && ...)
是折叠表达式(fold expression)。
本节先建立直觉:
检查 Args 中的每一种类型
是否都与 T 相同
例如:
T = int
Args = [int, int, int]
约束成立。
如果是:
T = int
Args = [double, int]
约束不成立。
完整使用:
#include <concepts>
#include <iostream>
#include <type_traits>
template <typename T>
concept MinCompatible =
std::copy_constructible<T> &&
requires(const T& a, const T& b) {
{ a < b } -> std::convertible_to<bool>;
};
template <MinCompatible T>
T variadic_min(const T& value) {
return value;
}
template <MinCompatible T, typename... Args>
requires
(sizeof...(Args) > 0) &&
(std::same_as<T, Args> && ...)
T variadic_min(
const T& first,
const Args&... rest
) {
T rest_min = variadic_min(rest...);
return first < rest_min
? first
: rest_min;
}
int main() {
std::cout
<< variadic_min(2, 7, 5, 1)
<< '\n';
}
输出:
1
练习:跟踪参数包
给定:
variadic_min(9, 4, 6);
写出每一层调用中的:
first;rest;- 返回值。
答案与解释
第一层:
first = 9
rest = [4, 6]
需要计算:
variadic_min(4, 6);
第二层:
first = 4
rest = [6]
需要计算:
variadic_min(6);
基础情况:
value = 6
返回 6
回到第二层:
rest_min = 6
4 < 6 为 true
返回 4
回到第一层:
rest_min = 4
9 < 4 为 false
返回 4
最终结果:
4
参数包中的类型不必相同
虽然前面的最小值函数主动要求参数类型相同,但可变参数模板本身没有这个限制。
例如,我们想实现一个简化版格式化输出函数:
format(
"Queen {}, Protector of the {} Kingdoms",
"Rhaenyra",
7
);
第一个占位符接收字符串:
"Rhaenyra"
第二个占位符接收整数:
7
另一个调用可能是:
format(
"The {} enemy won't {} out the {}",
true,
"wait",
"storm"
);
参数类型依次是:
bool
const char*
const char*
普通 std::vector<T> 不能直接保存这些完全不同的类型:
std::vector<???>
vector 中所有元素必须拥有同一个元素类型。
而模板参数包可以保存一组不同类型:
Args = [bool, const char*, const char*]
实现一个简化版 format
我们的格式字符串使用:
{}
表示占位符。
算法是:
找到第一个 {}
↓
输出它前面的文本
↓
输出当前参数
↓
把剩余格式字符串和剩余参数交给下一层递归
基础情况是:
没有参数了
↓
输出剩余字符串
完整实现:
#include <iostream>
#include <stdexcept>
#include <string>
void print_format(const std::string& fmt) {
if (fmt.find("{}") != std::string::npos) {
throw std::runtime_error(
"not enough arguments"
);
}
std::cout << fmt;
}
template <typename T, typename... Args>
void print_format(
const std::string& fmt,
const T& value,
const Args&... args
) {
const std::size_t pos = fmt.find("{}");
if (pos == std::string::npos) {
throw std::runtime_error(
"too many arguments"
);
}
std::cout << fmt.substr(0, pos);
std::cout << value;
print_format(
fmt.substr(pos + 2),
args...
);
}
int main() {
std::cout << std::boolalpha;
print_format(
"Queen {}, Protector of the {} Kingdoms",
"Rhaenyra",
7
);
std::cout << '\n';
print_format(
"The {} enemy won't {} out the {}",
true,
"wait",
"storm"
);
std::cout << '\n';
print_format("Winter is coming");
std::cout << '\n';
}
输出:
Queen Rhaenyra, Protector of the 7 Kingdoms
The true enemy won't wait out the storm
Winter is coming
format 的递归实例化过程
调用:
print_format(
"Lecture {}: {} (Week {})",
9,
"Templates",
5
);
第一次模板实例:
T = int
Args = [const char*, int]
value = 9
args = ["Templates", 5]
输出:
Lecture 9
然后调用:
print_format(
": {} (Week {})",
"Templates",
5
);
第二次模板实例:
T = const char*
Args = [int]
value = "Templates"
args = [5]
继续输出:
: Templates
然后调用:
print_format(
" (Week {})",
5
);
第三次模板实例:
T = int
Args = []
value = 5
输出:
(Week 5
递归调用基础版本:
print_format(")");
基础版本不是模板:
void print_format(const std::string& fmt);
它输出:
)
最终组合为:
Lecture 9: Templates (Week 5)
练习:参数不足与参数过多
对于上面的 print_format,判断以下调用会发生什么:
print_format("{} + {} = {}", 1, 2);
print_format("hello", 42);
答案与解释
第一种调用的占位符有三个,但参数只有两个。
递归使用完两个参数后,基础情况收到的剩余字符串中仍然包含:
{}
基础版本执行:
if (fmt.find("{}") != std::string::npos) {
throw std::runtime_error(
"not enough arguments"
);
}
因此抛出“参数不足”异常。
第二种调用没有占位符,却提供了一个参数。
模板版本查找:
fmt.find("{}")
结果是:
std::string::npos
于是抛出“参数过多”异常。
可变参数模板的完整串联
现在可以把这一部分串起来:
问题:
函数参数数量不固定
旧方法:
为 2、3、4、5……个参数分别写重载
缺点:
重复代码没有上限
第一次改进:
把值放入 vector
缺点:
要求元素同类型,而且需要额外容器和内存分配
最终方案:
可变参数模板
typename... Args
↓
零个或多个类型
const Args&... args
↓
零个或多个函数参数
args...
↓
展开参数包
基础情况
↓
停止递归
可变参数模板的关键价值是:
编译器可以根据实际参数数量和类型,生成所需的函数实例。
模板已经在编译期工作,还能做更多吗
到目前为止,模板主要帮助我们生成函数和类。
例如:
variadic_min(2, 7, 5, 1);
会使编译器实例化多个函数版本。
这说明模板实例化发生在编译期。
于是可以继续问:
既然模板能够在编译期递归实例化,我们能不能利用这个过程完成计算?
这会带出:
模板元编程(template metaprogramming,TMP)
使用模板在编译期计算阶乘
阶乘定义为:
0! = 1
N! = N × (N - 1)!
例如:
4!
= 4 × 3!
= 4 × 3 × 2!
= 4 × 3 × 2 × 1!
= 4 × 3 × 2 × 1 × 0!
= 24
可以使用一个非类型模板参数:
template <std::size_t N>
struct Factorial;
这里的 N 不是类型,而是一个编译期整数。
这种参数称为:
非类型模板参数(non-type template parameter)
完整实现:
#include <cstddef>
#include <iostream>
template <std::size_t N>
struct Factorial {
static constexpr std::size_t value =
N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static constexpr std::size_t value = 1;
};
int main() {
std::cout << Factorial<7>::value << '\n';
}
输出:
5040
模板特化提供递归基础情况
普通模板:
template <std::size_t N>
struct Factorial {
static constexpr std::size_t value =
N * Factorial<N - 1>::value;
};
负责一般情况:
N! = N × (N - 1)!
当 N = 0 时,不能继续计算:
Factorial<-1>
因此需要为 N = 0 提供一个特殊版本:
template <>
struct Factorial<0> {
static constexpr std::size_t value = 1;
};
这称为:
模板特化(template specialization)
它表达:
一般情况使用主模板
当模板参数恰好是 0 时,
改用这个专门版本
关系如下:
Factorial<7>
↓
7 × Factorial<6>
Factorial<6>
↓
6 × Factorial<5>
...
Factorial<1>
↓
1 × Factorial<0>
Factorial<0>
↓
使用特化版本
value = 1
编译期阶乘的实例化过程
编译器为了得到:
Factorial<7>::value
需要逐步实例化:
Factorial<7>
Factorial<6>
Factorial<5>
Factorial<4>
Factorial<3>
Factorial<2>
Factorial<1>
Factorial<0>
然后逐层计算:
Factorial<0>::value = 1
Factorial<1>::value
= 1 × 1
= 1
Factorial<2>::value
= 2 × 1
= 2
Factorial<3>::value
= 3 × 2
= 6
Factorial<4>::value
= 4 × 6
= 24
Factorial<5>::value
= 5 × 24
= 120
Factorial<6>::value
= 6 × 120
= 720
Factorial<7>::value
= 7 × 720
= 5040
这些计算在编译期完成。
最终可执行文件中可能直接包含常量:
5040
而不是在程序运行时重新执行七层递归。
课件通过汇编示意图表达的就是:
编译前:
Factorial<7>::value
编译后:
直接把 5040 交给输出函数
具体生成的汇编会受到:
- 编译器;
- 优化等级;
- 目标平台;
- 标准库实现;
影响,但“结果可以在编译期确定并嵌入程序”是这一示例的核心。
课件中的 enum 写法
课件使用了传统写法:
enum {
value = N * Factorial<N - 1>::value
};
早期 C++ 模板元编程常用匿名枚举保存编译期整数常量。
现代 C++ 中通常更清晰地写成:
static constexpr std::size_t value =
N * Factorial<N - 1>::value;
两者在这个例子中都用于提供编译期常量。
现代写法的优点是:
- 类型更加明确;
- 语义更加直接;
- 能够使用非整数型的
constexpr成员; - 更符合现代 C++ 风格。
Fibonacci:需要两个基础情况
斐波那契数列定义为:
F(0) = 0
F(1) = 1
F(N) = F(N - 1) + F(N - 2)
对应模板:
#include <cstddef>
#include <iostream>
template <std::size_t N>
struct Fibonacci {
static constexpr std::size_t value =
Fibonacci<N - 1>::value +
Fibonacci<N - 2>::value;
};
template <>
struct Fibonacci<0> {
static constexpr std::size_t value = 0;
};
template <>
struct Fibonacci<1> {
static constexpr std::size_t value = 1;
};
int main() {
std::cout << Fibonacci<10>::value << '\n';
}
输出:
55
这里需要两个特化:
Fibonacci<0>
Fibonacci<1>
因为递归定义依赖前两个数。
练习:模板递归缺少基础情况
下面的代码有什么问题?
template <std::size_t N>
struct Countdown {
static constexpr std::size_t value =
Countdown<N - 1>::value;
};
答案与解释
它没有基础情况。
实例化:
Countdown<3>
会继续请求:
Countdown<2>
Countdown<1>
Countdown<0>
随后 Countdown<0> 又会请求:
Countdown<非常大的无符号数>
因为 std::size_t 是无符号整数,0 - 1 会发生回绕,而不是得到普通的 -1。
编译器最终可能达到模板实例化深度限制并报错。
需要添加特化:
template <>
struct Countdown<0> {
static constexpr std::size_t value = 0;
};
模板递归和普通递归一样,需要能够停止。
模板元编程还能处理类型
模板元编程不只能计算整数。
它还可以把“类型”当作数据进行处理。
例如,概念上可以有一个包含类型的列表:
[MoveUp, MoveRight, Rotate45]
这不是:
std::vector<Object>
而是一组在编译期存在的类型。
像 Boost MPL 这样的模板元编程库支持类似操作:
创建类型列表
在类型列表末尾添加类型
根据类型列表生成特定代码
一个概念示例:
using Move =
type_list<MoveUp, MoveRight>;
using MoveAndRotate =
push_back<Move, Rotate45>::type;
随后:
apply<Move>(object);
apply<MoveAndRotate>(object);
编译器可以针对不同变换类型生成不同代码。
课件列出的模板元编程用途包括:
- 在编译期计算并把结果嵌入可执行文件;
- 优化矩阵、树和数学结构;
- 策略式设计,通过模板传递行为;
- 操作类型集合;
- 根据类型信息生成代码。
传统模板元编程甚至具有图灵完备性,也就是说,理论上可以在编译期表达任意可计算过程。
但传统模板元编程的语法往往很复杂,错误信息也很难阅读。
于是又产生了一个问题:
能不能保留编译期计算能力,同时使用更像普通函数的语法?
现代 C++ 提供了 constexpr 和 consteval。
使用 constexpr 编写可在编译期执行的函数
阶乘可以直接写成普通递归函数:
#include <cstddef>
constexpr std::size_t factorial(std::size_t n) {
if (n == 0) {
return 1;
}
return n * factorial(n - 1);
}
constexpr 可以建立这样的直觉:
当调用环境允许时,请尝试在编译期计算这个函数。
例如:
constexpr std::size_t result = factorial(7);
result 必须在编译期确定,因此:
factorial(7)
也必须产生编译期结果。
可以通过 static_assert 检查:
static_assert(factorial(7) == 5040);
static_assert 在编译期判断条件。如果条件为假,编译失败。
完整示例:
#include <cstddef>
#include <iostream>
constexpr std::size_t factorial(std::size_t n) {
if (n == 0) {
return 1;
}
return n * factorial(n - 1);
}
int main() {
constexpr std::size_t result =
factorial(7);
static_assert(result == 5040);
std::cout << result << '\n';
}
输出:
5040
constexpr 也可能在运行时执行
constexpr 不表示每次调用都必须发生在编译期。
例如:
#include <cstddef>
#include <iostream>
constexpr std::size_t factorial(std::size_t n) {
if (n == 0) {
return 1;
}
return n * factorial(n - 1);
}
int main() {
std::size_t number;
std::cin >> number;
std::size_t result =
factorial(number);
std::cout << result << '\n';
}
number 来自运行时输入,编译器在编译时不知道它的值。
因此这次调用可以在运行时执行。
constexpr 更准确的含义是:
这个函数可以参与常量表达式计算,
只要本次调用满足编译期计算条件。
consteval:每次调用都必须在编译期完成
C++20 引入了 consteval。
consteval std::size_t factorial_now(
std::size_t n
) {
if (n == 0) {
return 1;
}
return n * factorial_now(n - 1);
}
consteval 函数称为:
立即函数(immediate function)
它的每次调用都必须在编译期求值。
合法:
constexpr auto result =
factorial_now(7);
也可以:
auto result = factorial_now(7);
虽然变量本身不一定声明为 constexpr,但函数调用结果必须在编译期产生。
不合法:
std::size_t number;
std::cin >> number;
auto result =
factorial_now(number);
因为 number 只有运行时才能知道,而 consteval 要求立即在编译期计算。
constexpr 与 consteval 对比
| 特性 | constexpr |
consteval |
|---|---|---|
| 能否在编译期执行 | 可以 | 必须 |
| 能否接收运行时值 | 可以 | 不可以 |
| 调用是否每次都是常量表达式 | 不一定 | 是 |
| 适合场景 | 同时支持编译期与运行时 | 必须编译期完成的验证或生成 |
例如:
constexpr int square(int value) {
return value * value;
}
下面两种都可以:
constexpr int a = square(5);
int x;
std::cin >> x;
int b = square(x);
而:
consteval int compile_time_square(int value) {
return value * value;
}
只能使用编译期已知参数:
int a = compile_time_square(5);
不能使用运行时输入:
int x;
std::cin >> x;
int b = compile_time_square(x); // 编译错误
关于课件中“C++20 新功能”的准确说明
课件把 constexpr 与 consteval 一起作为更现代、更可读的编译期编程方式介绍,这个教学方向是正确的。
更准确地说:
constexpr最早在 C++11 中引入,并在后续标准中不断增强;consteval是 C++20 引入的;- C++20 让编译期函数、概念和模板约束之间的配合更加完整。
因此,不应把 constexpr 本身理解成 C++20 才首次出现。
练习:判断发生在编译期还是运行时
给定:
constexpr int triple(int value) {
return value * 3;
}
判断:
constexpr int a = triple(4);
int x;
std::cin >> x;
int b = triple(x);
答案与解释
第一段:
constexpr int a = triple(4);
a 是常量表达式,4 在编译期已知,因此 triple(4) 必须在编译期得到结果:
a = 12
第二段:
int x;
std::cin >> x;
int b = triple(x);
x 的值来自运行时输入,编译器无法提前知道,因此这次 triple(x) 在运行时执行。
同一个 constexpr 函数可以根据调用环境选择编译期或运行时求值。
容易混淆的概念集中对比
函数重载与函数模板
函数重载是程序员手动提供多个函数:
int my_min(int, int);
double my_min(double, double);
函数模板是程序员提供生成规则:
template <typename T>
T my_min(T, T);
前者的每一个版本都需要显式编写,后者由编译器根据类型生成实例。
函数模板与函数模板实例
函数模板:
template <typename T>
T my_min(T a, T b);
它是一份生成规则。
实例:
my_min<int>
my_min<double>
它们是拥有具体参数类型的函数。
明确模板参数与模板参数推导
明确模板参数:
my_min<double>(106, 3.14);
程序员指定:
T = double
模板参数推导:
my_min(1.2, 3.4);
编译器根据实参得出:
T = double
推导出现冲突时,编译器通常不会随意使用类型转换统一结果。
模板参数包与函数参数包
模板参数包:
typename... Args
保存零个或多个类型。
函数参数包:
const Args&... args
保存零个或多个函数参数。
包展开:
args...
把参数包展开成实际参数序列。
编译期实例化与运行时递归
模板实例化:
编译器生成 variadic_min 的不同参数版本
发生在编译期。
生成的函数之间的调用:
4 参数版本调用 3 参数版本
从程序语义上看属于运行时函数调用,尽管编译器可能优化掉。
constexpr 与 consteval
constexpr
表示函数可以在编译期执行。
consteval
表示函数必须在编译期执行。
初学者最容易遇到的错误
把字符串字面量当成 std::string
错误直觉:
my_min("cat", "dog");
会比较字符串内容。
实际可能推导成字符指针,或者因为数组类型不同而推导失败。
修正:
my_min(
std::string{"cat"},
std::string{"dog"}
);
两个参数推导出不同的 T
my_min(10, 3.14);
会分别推导:
T = int
T = double
修正之一:
my_min<double>(10, 3.14);
找不到元素后解引用 end
危险写法:
auto it = std::find(
values.begin(),
values.end(),
target
);
std::cout << *it;
修正:
if (it != values.end()) {
std::cout << *it;
}
模板体使用了类型不支持的操作
template <typename T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
这里要求 T 支持 <。
可使用概念把要求写到接口中:
template <LessThanComparable T>
T my_min(const T& a, const T& b);
可变参数递归没有基础情况
递归版本:
template <typename T, typename... Args>
T function(T value, Args... args) {
return function(args...);
}
最终会调用零参数版本。
必须提供能够停止的重载或其他终止机制。
误以为参数包中的类型自动相同
typename... Args
允许:
[int, double, std::string]
如果算法要求类型相同,需要主动添加约束。
对空容器求最小值
最小值函数无法从空集合中返回一个实际元素:
vector_min({});
应明确处理:
if (values.empty()) {
throw std::invalid_argument(...);
}
综合练习:实现一组泛型工具
请完成一个程序,包含以下功能。
第一部分,定义 MinCompatible 概念,要求类型:
- 可复制构造;
- 支持
<; <的结果能够转换为bool。
第二部分,实现:
my_min(a, b)
返回两个同类型值中的较小者。
第三部分,实现泛型:
my_find(first, last, value)
要求迭代器能够:
- 向前移动;
- 解引用;
- 与结束位置比较;
- 将元素与
value使用==比较。
第四部分,实现:
variadic_min(2, 7, 5, 1)
返回任意数量同类型参数中的最小值。
第五部分,实现简化格式化输出:
print_format(
"The {} enemy won't {} out the {}",
true,
"wait",
"storm"
);
第六部分,分别使用:
- 模板元编程;
constexpr;consteval;
计算 7!。
程序预期输出类似:
two-value min: 106
string min: Preston
vector after find: 106 111 107 112
found in set: true
variadic min: 1
The true enemy won't wait out the storm
factorials: 5040 5040
综合练习参考实现
#include <concepts>
#include <cstddef>
#include <iostream>
#include <iterator>
#include <set>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <vector>
template <typename T>
concept MinCompatible =
std::copy_constructible<T> &&
requires(const T& a, const T& b) {
{ a < b } -> std::convertible_to<bool>;
};
template <MinCompatible T>
T my_min(const T& a, const T& b) {
return a < b ? a : b;
}
template <typename It, typename T>
concept Findable =
std::input_iterator<It> &&
requires(It it, const T& value) {
{ *it == value } -> std::convertible_to<bool>;
};
template <typename It, typename T>
requires Findable<It, T>
It my_find(
It first,
It last,
const T& value
) {
while (first != last) {
if (*first == value) {
break;
}
++first;
}
return first;
}
template <MinCompatible T>
T variadic_min(const T& value) {
return value;
}
template <MinCompatible T, typename... Args>
requires
(sizeof...(Args) > 0) &&
(std::same_as<T, Args> && ...)
T variadic_min(
const T& first,
const Args&... rest
) {
T rest_min = variadic_min(rest...);
return first < rest_min
? first
: rest_min;
}
void print_format(const std::string& fmt) {
if (fmt.find("{}") != std::string::npos) {
throw std::runtime_error(
"not enough arguments"
);
}
std::cout << fmt;
}
template <typename T, typename... Args>
void print_format(
const std::string& fmt,
const T& value,
const Args&... args
) {
const std::size_t pos =
fmt.find("{}");
if (pos == std::string::npos) {
throw std::runtime_error(
"too many arguments"
);
}
std::cout
<< fmt.substr(0, pos)
<< value;
print_format(
fmt.substr(pos + 2),
args...
);
}
template <std::size_t N>
struct Factorial {
static constexpr std::size_t value =
N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static constexpr std::size_t value = 1;
};
constexpr std::size_t factorial(
std::size_t n
) {
return n == 0
? 1
: n * factorial(n - 1);
}
consteval std::size_t factorial_now(
std::size_t n
) {
return n == 0
? 1
: n * factorial_now(n - 1);
}
int main() {
std::cout << std::boolalpha;
std::cout
<< "two-value min: "
<< my_min(106, 107)
<< '\n';
std::cout
<< "string min: "
<< my_min(
std::string{"Preston"},
std::string{"Rachel"}
)
<< '\n';
std::vector<int> values{
106,
111,
42,
112
};
auto vector_it = my_find(
values.begin(),
values.end(),
42
);
if (vector_it != values.end()) {
*vector_it = 107;
}
std::cout << "vector after find: ";
for (int value : values) {
std::cout << value << ' ';
}
std::cout << '\n';
std::set<std::string> houses{
"Stark",
"Targaryen"
};
auto set_it = my_find(
houses.begin(),
houses.end(),
std::string{"Targaryen"}
);
std::cout
<< "found in set: "
<< (set_it != houses.end())
<< '\n';
std::cout
<< "variadic min: "
<< variadic_min(2, 7, 5, 1)
<< '\n';
print_format(
"The {} enemy won't {} out the {}",
true,
"wait",
"storm"
);
std::cout << '\n';
static_assert(
Factorial<7>::value == 5040
);
constexpr std::size_t a =
factorial(7);
constexpr std::size_t b =
factorial_now(7);
std::cout
<< "factorials: "
<< a
<< ' '
<< b
<< '\n';
}
编译命令:
g++ -std=c++20 main.cpp -o main
程序输出:
two-value min: 106
string min: Preston
vector after find: 106 111 107 112
found in set: true
variadic min: 1
The true enemy won't wait out the storm
factorials: 5040 5040
综合程序的执行串联
程序开始后,首先调用:
my_min(106, 107)
模板推导:
T = int
返回:
106
接着调用字符串版本:
my_min(
std::string{"Preston"},
std::string{"Rachel"}
)
推导:
T = std::string
按照字典序返回:
Preston
然后 my_find 在:
[106, 111, 42, 112]
中找到 42,返回指向它的迭代器。
通过:
*vector_it = 107;
直接修改原容器:
[106, 111, 107, 112]
之后,my_find 使用完全不同的:
std::set<std::string>::iterator
查找 "Targaryen"。
variadic_min 通过参数包递归计算:
min(2, 7, 5, 1)
→ min(7, 5, 1)
→ min(5, 1)
→ min(1)
→ 1
print_format 的参数包包含:
bool
const char*
const char*
说明可变参数模板可以处理不同类型。
最后:
Factorial<7>::value
使用传统模板元编程计算。
factorial(7)
使用 constexpr 计算。
factorial_now(7)
使用 consteval 强制在编译期计算。
本节课的完整知识串联
从最开始的问题出发:
我们需要为 int、double、string
编写完全相同的 min 函数
函数重载虽然能工作,但产生重复代码。
于是引入:
函数模板
函数模板让编译器根据类型生成具体函数。
接着出现:
编译器怎样知道 T 是什么?
于是学习:
显式指定模板参数
模板参数推导
模板推导遇到字符串字面量和混合类型时可能出现问题,因此需要理解实参的真实类型。
随后把函数模板应用到迭代器,得到:
同一个 find 算法
可以处理多种容器
但模板能够接受错误类型,并且常常直到实例化后才产生难懂错误。
于是引入:
概念与 requires
概念把模板需要的能力写到接口上,使错误更早、更清晰。
接下来,普通函数模板仍然只能描述固定数量的参数。
于是引入:
可变参数模板
模板参数包允许编译器根据任意参数数量生成实例,并通过包展开实现递归。
既然模板实例化本身发生在编译期,就可以利用它完成计算:
模板元编程
传统模板元编程能够计算阶乘、斐波那契数,甚至操作类型列表,但语法复杂。
现代 C++ 提供:
constexpr
consteval
让编译期计算使用更接近普通函数的写法。
整条知识链最终可以压缩为:
减少不同类型之间的重复代码
↓
函数模板
明确模板需要什么能力
↓
概念
处理任意数量和类型的参数
↓
可变参数模板
在程序运行之前完成工作
↓
模板元编程、constexpr、consteval
什么时候应该使用模板
当多个函数或类:
- 算法相同;
- 数据结构相同;
- 只有类型不同;
可以考虑函数模板或类模板。
当模板只对满足某种能力的类型有意义,例如:
- 必须支持
<; - 必须支持
==; - 必须是迭代器;
- 必须是整数类型;
应考虑使用概念写出约束。
当函数需要接受:
- 任意数量的参数;
- 参数类型可能不同;
可以考虑可变参数模板。
当某项计算:
- 输入在编译期已知;
- 结果适合提前生成;
- 希望进行编译期验证;
可以考虑:
constexpr
consteval
传统模板元编程仍然存在于标准库、Boost 和许多底层库中,但编写新代码时,通常优先选择更清晰的现代语言功能。
本节知识怎样连接到下一节
我们已经使用模板写出了:
my_find(begin, end, value);
这个函数不关心容器的具体类型,只关心迭代器能够做什么。
这正是标准库算法的核心设计:
容器负责保存数据
迭代器负责描述范围
算法负责处理范围
模板负责连接不同类型
例如:
std::find
std::sort
std::count
std::transform
都建立在类似思想之上。
函数模板让算法能够跨越不同容器和元素类型,概念则可以表达算法需要的迭代器能力。
因此,下一节“函数与算法”将自然继续解决:
怎样编写更聪明、更灵活,并且能够与标准库容器协作的泛型算法?