C++ / 2026-08-01
CS106L 第 11 讲:函数与 Lambda
把行为交给算法,理解函数指针、Lambda、函数对象与标准算法。
把“行为”交给算法
本课程依据 CS106L Spring 2026 第 11 讲课件重新组织。
本节课要解决的核心问题
前面学习函数模板时,我们已经能够写出“适用于多种类型”的算法:
template <typename T>
T my_min(T a, T b) {
return a < b ? a : b;
}
后来又借助迭代器,把查找算法推广到了不同容器:
template <typename It, typename T>
It my_find(It first, It last, const T& value) {
for (auto it = first; it != last; ++it) {
if (*it == value) {
return it;
}
}
return last;
}
这已经解决了两个问题:
T让算法不再绑定某一种元素类型;It让算法不再绑定某一种容器类型。
但是它仍然把一个东西写死了:
*it == value
也就是说,我们只能问:
当前元素是否等于某个指定值?
如果真正想问的是:
- 这个字符是不是元音?
- 这个数字是不是质数?
- 这个数字能不能被 5 整除?
- 这条徒步路线有没有瀑布?
- 这条路线是否短于用户刚刚输入的长度?
- 两个元素中哪一个应该排在前面?
那么仅仅把“类型”模板化还不够。
本节课真正要做的事情,是把程序中的行为也变成可以传递的数据:
算法
├── 接收数据
└── 接收“应该怎样处理数据”的规则
C++ 为此提供了几种互相关联的工具:
- 谓词函数(predicate);
- 函数指针(function pointer);
- Lambda 表达式(lambda expression);
- 函数对象(functor);
- 标准算法(standard algorithm);
- 范围(range)与视图(view)。
它们最终会连接成一条完整的知识链:
函数模板
↓
泛型查找
↓
把判断规则传给查找函数
↓
函数指针
↓
函数需要携带额外状态
↓
Lambda
↓
Lambda 本质上是函数对象
↓
标准算法接收各种函数对象
↓
Ranges 与 Views 组合算法
一、学习本节课需要哪些前置知识
开始之前,需要能够读懂以下基础语法:
- 普通函数;
std::vector、std::string等容器;begin()和end();- 迭代器的
++it、*it; - 函数模板中的
template <typename T>; auto的基本类型推导;const T&表示只读引用参数;- 类或结构体的基础定义。
本节还会使用一些标准库头文件:
#include <algorithm>
#include <cctype>
#include <functional>
#include <iostream>
#include <iterator>
#include <ranges>
#include <string>
#include <vector>
不需要提前理解这些头文件内部是怎样实现的。我们会先从使用方式建立直觉。
二、回到上一节:函数模板到底替我们做了什么
2.1 问题:同一段逻辑为什么要写三遍
假设我们想比较两个值,并返回较小的一个。
如果没有模板,可能会这样写:
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;
}
三个函数的逻辑完全相同,只有类型不同。
于是我们把变化的类型替换为模板参数 T:
template <typename T>
T my_min(T a, T b) {
return a < b ? a : b;
}
这里的 T 不是一个真正存在的类型,而是一个等待编译器填写的类型占位符。
当程序调用:
my_min<int>(106, 107);
my_min<double>(1.2, 3.4);
可以暂时把编译器的工作理解为:
int my_min(int a, int b) {
return a < b ? a : b;
}
double my_min(double a, double b) {
return a < b ? a : b;
}
这称为模板实例化(template instantiation)。
如果显式写出类型:
my_min<int>(106, 107);
称为显式指定模板实参。
如果只写:
my_min(106, 107);
编译器会根据参数类型推导出 T = int。这通常称为模板实参推导,并导致相应模板实例化。
严格地说,模板并不是在运行时“替换字符串”。编译器会在编译期间,根据实际使用的类型生成并检查对应的函数版本。
2.2 一份可以运行的模板示例
#include <iostream>
#include <string>
template <typename T>
T my_min(T a, T 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{"apple"},
std::string{"banana"})
<< '\n';
}
编译:
g++ -std=c++20 main.cpp -o main
输出:
106
1.2
apple
执行第一条调用时:
my_min(106, 107)
编译器看到两个参数都是 int,于是确定:
T = int
函数内部状态可以表示为:
a = 106
b = 107
a < b 为 true
返回 a,也就是 106
模板解决的是“类型不同,但操作方式相同”的问题。
然而,后面很快会遇到另一个问题:
如果不仅类型不同,想执行的判断规则也不同呢?
三、为什么泛型算法通常接收迭代器
3.1 一个适用于多种容器的查找函数
我们可以写出下面的查找函数:
template <typename It, typename T>
It my_find(It first, It last, const T& value) {
for (auto it = first; it != last; ++it) {
if (*it == value) {
return it;
}
}
return last;
}
两个模板参数分别表示:
It:迭代器的类型
T :待查找值的类型
例如:
std::vector<std::string> words{"run", "forrest"};
auto vector_it =
my_find(words.begin(), words.end(), std::string{"run"});
编译器大致推导出:
It = std::vector<std::string>::iterator
T = std::string
而对于集合:
std::set<std::string> words{"run", "forrest"};
auto set_it =
my_find(words.begin(), words.end(), std::string{"run"});
会得到另一个 It:
It = std::set<std::string>::iterator
T = std::string
算法内部只使用了这些操作:
it != last
++it
*it
只要某种迭代器支持这些操作,就有机会使用这个算法。
3.2 为什么不直接把整个容器传进去
可以设计一个接收整个容器的版本:
template <typename Container, typename T>
auto my_find(const Container& container, const T& value) {
for (auto it = container.begin(); it != container.end(); ++it) {
if (*it == value) {
return it;
}
}
return container.end();
}
调用会更短:
auto it = my_find(words, std::string{"run"});
这样做的优点是调用者不需要亲自写 begin() 和 end()。
但是它失去了一项能力:只处理容器的一部分。
考虑:
std::vector<int> numbers{106, 107, 106, 143, 149, 106};
我们想跳过第一个和最后一个元素,只查找中间部分:
auto it = my_find(
numbers.begin() + 1,
numbers.end() - 1,
106
);
搜索范围是:
完整容器:
下标 0 1 2 3 4 5
元素 [106][107][106][143][149][106]
× └───────────────┘ ×
实际搜索范围
结果是下标 2 的 106,而不是下标 0 的 106:
std::cout << std::distance(numbers.begin(), it) << '\n';
输出:
2
这也是标准库算法长期采用迭代器区间的原因。
3.3 半开区间 [first, last)
标准算法通常使用半开区间:
[first, last)
含义是:
- 包含
first指向的元素; - 不包含
last指向的位置。
例如:
first
↓
[10][20][30][40][尾后位置]
↑
last
如果 last 指向 40,那么范围中只有:
10, 20, 30
如果 last == container.end(),范围才一直延伸到最后一个真实元素。
这种设计让空区间很自然:
first == last
也使区间长度可以表示为:
std::distance(first, last)
练习:判断搜索范围
下面的程序会找到哪一个 2?
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values{2, 4, 2, 6, 2};
auto it = std::find(
values.begin() + 1,
values.end() - 1,
2
);
std::cout << std::distance(values.begin(), it) << '\n';
}
答案与解释
输出:
2
搜索范围是:
下标 0 1 2 3 4
元素 [2] [4] [2] [6] [2]
× └────────┘ ×
values.begin() + 1 指向下标 1。
values.end() - 1 指向下标 4,但半开区间不包含该位置,因此实际检查:
下标 1、2、3
其中第一个等于 2 的元素位于下标 2。
初学者容易把 last 理解为“最后一个要检查的元素”。在标准库区间中,它表示的是“不再检查的位置”。
四、我们的查找函数仍然不够通用
目前的查找条件被固定成:
*it == value
因此,它只能查找“等于某个值”的元素。
但很多实际问题不是在问“是否相等”。
例如:
字符串中的第一个元音在哪里?
vector<int> 中第一个质数在哪里?
set<int> 中第一个能被 5 整除的数在哪里?
路线列表中第一条有瀑布的路线在哪里?
这些问题虽然判断规则不同,却拥有相同的查找流程:
从头开始
↓
取出当前元素
↓
询问一个真假问题
↓
若答案为 true,返回当前位置
↓
否则继续
真正变化的不是循环,而是循环中的这个问题:
“当前元素是否满足要求?”
因此,我们希望把这个问题也作为参数传给查找算法。
五、谓词:一个返回真假的问题
5.1 什么是谓词
谓词(predicate)是一个返回布尔值的可调用实体。
初学阶段可以先理解为:
谓词是一个用于回答“是或否”的函数。
例如:
bool is_vowel(char c);
它回答:
字符
c是不是元音?
bool is_divisible(int number, int divisor);
它回答:
number能不能被divisor整除?
bool is_shorter(const Trail& first, const Trail& second);
它回答:
第一条路线是否比第二条短?
5.2 一元谓词与二元谓词
根据参数个数,可以区分:
- 一元谓词(unary predicate):接收一个参数;
- 二元谓词(binary predicate):接收两个参数。
一元谓词示例:
bool is_positive(int number) {
return number > 0;
}
调用:
is_positive(10)
程序只需要提供一个被检查的元素。
二元谓词示例:
bool is_less(int first, int second) {
return first < second;
}
调用:
is_less(10, 20)
程序需要提供两个参与比较的元素。
不同算法需要的谓词形式不同:
std::find_if 通常需要一元谓词
std::count_if 通常需要一元谓词
std::sort 通常需要二元比较函数
5.3 安全地判断字符是否为元音
课件使用了 toupper。现代 C++ 中需要注意:std::toupper 接收的值应当能够表示为 unsigned char,或者是 EOF。
一个更稳妥的写法是:
#include <cctype>
bool is_vowel(char c) {
const unsigned char unsigned_c =
static_cast<unsigned char>(c);
const char upper =
static_cast<char>(std::toupper(unsigned_c));
return upper == 'A' ||
upper == 'E' ||
upper == 'I' ||
upper == 'O' ||
upper == 'U';
}
这里发生的步骤是:
原字符 c
↓
转换为 unsigned char
↓
传给 std::toupper
↓
转换回 char
↓
与 A、E、I、O、U 比较
对于普通英文字符,直接调用通常也能得到预期结果。但面对某些实现中带负值的 char,直接传递可能导致未定义行为,因此推荐进行转换。
练习:判断谓词的种类
下面哪些函数是一元谓词,哪些是二元谓词?
bool is_even(int n) {
return n % 2 == 0;
}
bool has_same_length(const std::string& a,
const std::string& b) {
return a.size() == b.size();
}
int double_value(int n) {
return n * 2;
}
bool is_empty(const std::string& text) {
return text.empty();
}
答案与解释
is_even 是一元谓词:
参数数量:1
返回类型:bool
has_same_length 是二元谓词:
参数数量:2
返回类型:bool
double_value 不是谓词,因为它返回 int,并不是在回答真假问题。
is_empty 是一元谓词,因为它接收一个字符串并返回 bool。
需要注意:真正的“可作为谓词”还与调用场景有关。某个函数即使返回 bool,如果参数类型不能接收算法提供的元素,它也无法用于该算法。
六、把谓词传给查找函数
6.1 从写死的条件开始修改
原来的函数是:
template <typename It, typename T>
It my_find(It first, It last, const T& value) {
for (auto it = first; it != last; ++it) {
if (*it == value) {
return it;
}
}
return last;
}
我们不再接收 value,而是接收一个判断规则 pred。
由于不同判断规则的类型可能不同,再增加一个模板参数:
template <typename It, typename Pred>
It my_find_if(It first, It last, Pred pred) {
for (auto it = first; it != last; ++it) {
if (pred(*it)) {
return it;
}
}
return last;
}
这里发生了关键变化:
*it == value
变成:
pred(*it)
算法不再亲自决定什么叫“符合要求”。
它只负责:
- 遍历;
- 把每个元素交给
pred; - 检查返回值;
- 遇到第一个
true时返回。
具体规则完全由调用者决定。
6.2 完整示例:寻找字符串中的第一个元音
#include <cctype>
#include <iostream>
#include <string>
template <typename It, typename Pred>
It my_find_if(It first, It last, Pred pred) {
for (auto it = first; it != last; ++it) {
if (pred(*it)) {
return it;
}
}
return last;
}
bool is_vowel(char c) {
const unsigned char unsigned_c =
static_cast<unsigned char>(c);
const char upper =
static_cast<char>(std::toupper(unsigned_c));
return upper == 'A' ||
upper == 'E' ||
upper == 'I' ||
upper == 'O' ||
upper == 'U';
}
int main() {
std::string flower = "rose";
auto it = my_find_if(
flower.begin(),
flower.end(),
is_vowel
);
if (it != flower.end()) {
*it = 'i';
}
std::cout << flower << '\n';
}
输出:
rise
6.3 一步步跟踪执行
调用发生时:
my_find_if(flower.begin(), flower.end(), is_vowel);
模板参数会根据实参推导。
大致可以理解为:
It = std::string::iterator
Pred = bool (*)(char)
后面的 Pred 暂时理解为“指向接收 char、返回 bool 的函数的指针”。稍后会正式解释。
初始字符串:
flower = "rose"
下标 0 1 2 3
字符 [r] [o] [s] [e]
↑
it
第一次循环:
pred(*it)
等价于:
is_vowel('r')
返回 false,所以执行 ++it。
第二次循环:
flower = "rose"
下标 0 1 2 3
字符 [r] [o] [s] [e]
↑
it
调用:
is_vowel('o')
返回 true,因此 my_find_if 返回当前迭代器。
回到 main():
*it = 'i';
当前 it 指向原来的 'o',所以字符串变为:
"rise"
整个过程中:
flower没有被复制;begin()和end()返回的迭代器按值传入;is_vowel没有修改字符;- 返回的迭代器仍指向原字符串中的元素;
*it = 'i'修改的是原字符串。
6.4 为什么一定要检查 it != end()
如果没有任何元素满足条件,函数返回:
last
当调用范围是整个字符串时,通常有:
last == flower.end()
end() 是尾后位置,不指向真实字符。
因此下面的代码存在问题:
auto it = my_find_if(
flower.begin(),
flower.end(),
is_vowel
);
*it = 'i';
如果字符串中没有元音,it == flower.end(),解引用 *it 会产生未定义行为。
必须先检查:
if (it != flower.end()) {
*it = 'i';
}
课件中的路线示例也有类似问题:
assert(it->hasWaterfall() == true);
更安全的写法是:
if (it != trails.end()) {
assert(it->hasWaterfall());
}
或者:
assert(it != trails.end());
assert(it->hasWaterfall());
练习:手动跟踪 find_if
预测程序输出:
#include <iostream>
#include <vector>
template <typename It, typename Pred>
It my_find_if(It first, It last, Pred pred) {
for (auto it = first; it != last; ++it) {
if (pred(*it)) {
return it;
}
}
return last;
}
bool is_negative(int value) {
return value < 0;
}
int main() {
std::vector<int> values{3, 8, -2, -7};
auto it = my_find_if(
values.begin(),
values.end(),
is_negative
);
if (it != values.end()) {
std::cout << *it << '\n';
std::cout << std::distance(values.begin(), it) << '\n';
}
}
答案与解释
输出:
-2
2
执行过程:
当前元素 3
is_negative(3) -> false
当前元素 8
is_negative(8) -> false
当前元素 -2
is_negative(-2) -> true
返回当前迭代器
返回的迭代器指向下标 2:
下标 0 1 2 3
元素 [3] [8] [-2] [-7]
↑
it
因此:
*it
得到 -2。
而:
std::distance(values.begin(), it)
得到 2。
算法在找到第一个符合条件的元素后就停止,不会继续检查 -7。
七、普通函数是怎样被传进去的
7.1 函数也有类型
观察:
bool is_vowel(char c);
这个函数具有两项重要信息:
返回类型:bool
参数列表:(char)
指向这种函数的函数指针类型写作:
bool (*)(char)
可以拆开理解:
bool (*) (char)
↑ ↑ ↑
返回值 指针 参数类型
给它起一个类型别名:
using CharPredicate = bool (*)(char);
随后可以声明变量:
CharPredicate pred = is_vowel;
调用:
pred('a');
效果与下面相同:
is_vowel('a');
7.2 完整函数指针示例
#include <cctype>
#include <iostream>
bool is_vowel(char c) {
const char upper = static_cast<char>(
std::toupper(static_cast<unsigned char>(c))
);
return upper == 'A' ||
upper == 'E' ||
upper == 'I' ||
upper == 'O' ||
upper == 'U';
}
bool is_digit_character(char c) {
return std::isdigit(
static_cast<unsigned char>(c)
) != 0;
}
int main() {
using Predicate = bool (*)(char);
Predicate pred = is_vowel;
std::cout << std::boolalpha;
std::cout << pred('a') << '\n';
std::cout << pred('7') << '\n';
pred = is_digit_character;
std::cout << pred('a') << '\n';
std::cout << pred('7') << '\n';
}
输出:
true
false
false
true
状态变化:
最开始:
pred ─────→ is_vowel
重新赋值后:
pred ─────→ is_digit_character
两个函数能够放进同一个函数指针变量,是因为它们拥有相同签名:
bool(char)
下面的函数不能直接赋给它:
bool is_less(int a, int b);
因为它的参数列表是:
(int, int)
需要使用另一种指针类型:
bool (*)(int, int)
7.3 Pred 并不永远是函数指针
当我们这样调用:
my_find_if(first, last, is_vowel);
Pred 通常会推导为:
bool (*)(char)
但这只适用于当前传入的是普通函数的情况。
后面传入 Lambda 或函数对象时,Pred 会是其他类型。
因此,更准确的说法是:
Pred表示某种可调用对象的类型。普通函数指针只是其中一种可能。
模板函数并不要求 Pred 必须叫“函数指针”。它真正要求的是下面的表达式能够成立:
pred(*it)
并且其结果能够用于 if 判断。
八、函数指针为什么仍然不够用
8.1 问题:查找小于 N 的数字
假设需要查找小于 5 的第一个数字,可以定义:
bool less_than_5(int value) {
return value < 5;
}
如果要查找小于 6:
bool less_than_6(int value) {
return value < 6;
}
再查找小于 7:
bool less_than_7(int value) {
return value < 7;
}
调用:
my_find_if(first, last, less_than_5);
my_find_if(first, last, less_than_6);
my_find_if(first, last, less_than_7);
这些函数只有数字不同,却需要不断重复定义。
更麻烦的是,阈值可能直到运行时才知道:
int limit;
std::cin >> limit;
现在无法提前写出:
less_than_多少?
8.2 为什么不能给谓词增加第二个参数
可能会想到:
bool is_less_than(int element, int limit) {
return element < limit;
}
但是我们的算法调用谓词时只提供了当前元素:
if (pred(*it)) {
return it;
}
它只会执行:
pred(element)
而 is_less_than 需要:
pred(element, limit)
两者接口不匹配,因此无法直接传入。
当然,可以修改查找算法,让它额外接收 limit:
my_find_if(first, last, pred, limit);
但这样会让算法开始关心某一种特定规则需要多少额外参数。
另一个谓词可能需要三个配置值,另一个可能需要字符串,另一个可能完全不需要额外参数。继续给算法添加参数,会让通用算法重新变得不通用。
真正需要的是:
创建一个只接收当前元素,却已经“记住”
limit的可调用对象。
它对外仍然表现为:
pred(element)
但内部额外保存着:
limit
这正是 Lambda 要解决的问题。
练习:为什么这个调用不能编译
bool is_divisible(int number, int divisor) {
return number % divisor == 0;
}
std::vector<int> values{7, 10, 13, 15};
auto it = my_find_if(
values.begin(),
values.end(),
is_divisible
);
答案与解释
my_find_if 内部会尝试:
pred(*it)
也就是类似:
is_divisible(7)
但 is_divisible 需要两个参数:
is_divisible(number, divisor)
编译器无法自动知道除数应该是什么。
错误属于编译错误,而不是运行时错误。函数签名在编译阶段就不匹配。
需要创建一个对外只接收一个参数、内部记住除数的可调用对象:
int divisor = 5;
auto divisible_by = [divisor](int number) {
return number % divisor == 0;
};
随后传入:
auto it = my_find_if(
values.begin(),
values.end(),
divisible_by
);
九、Lambda:创建能够携带状态的函数对象
9.1 第一个 Lambda
int limit;
std::cin >> limit;
auto less_than_limit = [limit](int value) {
return value < limit;
};
调用方式与普通函数相同:
bool result = less_than_limit(10);
也可以传给算法:
auto it = my_find_if(
values.begin(),
values.end(),
less_than_limit
);
Lambda 表达式(lambda expression)会创建一个匿名的可调用对象。
课件把 Lambda 描述为“能够捕获外部状态的函数”。这种说法适合建立直觉,但更准确地说:
Lambda 表达式会创建一个闭包对象(closure object)。这个对象可以保存从外层作用域捕获的变量,并通过函数调用语法执行代码。
Lambda 不一定必须捕获变量:
auto is_even = [](int value) {
return value % 2 == 0;
};
这里的捕获列表是空的:
[]
9.2 Lambda 的语法结构
auto less_than_limit = [limit](int value) {
return value < limit;
};
可以拆成:
auto less_than_limit = [limit] (int value) { ... };
↑ ↑ ↑ ↑ ↑
类型推导 变量名 捕获列表 参数列表 函数体
auto
每个 Lambda 都有编译器生成的、独一无二的类型。
这个类型通常没有供程序员直接书写的名字,所以最常见的写法是:
auto lambda = ...;
auto 并不意味着对象没有类型。它的类型非常具体,只是由编译器替我们写出。
[limit]
这是捕获列表(capture list)。
它表示把外层变量 limit 按值捕获到 Lambda 对象中。
(int value)
这是参数列表。
调用:
less_than_limit(10)
时,value 得到 10。
{ return value < limit; }
这是函数体。
函数体中可以使用:
- 参数
value; - 捕获的变量
limit; - 全局名称;
- 函数体内部新定义的局部变量。
9.3 Lambda 对象是怎样创建和调用的
考虑:
int limit = 20;
auto less_than_limit = [limit](int value) {
return value < limit;
};
创建 Lambda 前:
外层作用域:
limit = 20
创建 Lambda 后,可以建立这样的直觉:
外层变量:
limit = 20
Lambda 对象 less_than_limit:
┌─────────────────────┐
│ 保存的 limit = 20 │
│ 可执行的函数体 │
└─────────────────────┘
调用:
less_than_limit(15)
执行状态:
捕获状态 limit = 20
函数参数 value = 15
15 < 20 -> true
这里没有给 Lambda 传入第二个参数。limit 已经保存在对象内部。
十、捕获:按值还是按引用
10.1 按值捕获
int limit = 10;
auto less_than_limit = [limit](int value) {
return value < limit;
};
[limit] 表示按值捕获。
可以理解为 Lambda 对象中保存了一份 limit 的副本。
外层 limit Lambda 内部副本
┌──────────┐ ┌──────────┐
│ 10 │ │ 10 │
└──────────┘ └──────────┘
之后修改外层变量:
limit = 100;
Lambda 内部保存的副本仍然是 10。
10.2 按引用捕获
int limit = 10;
auto less_than_limit = [&limit](int value) {
return value < limit;
};
[&limit] 表示按引用捕获。
Lambda 不保存独立副本,而是继续访问外层的原变量。
Lambda 中的引用
│
▼
外层 limit = 10
之后执行:
limit = 100;
Lambda 再次调用时会看到 100。
10.3 捕获语法
常见写法如下:
[x] // 按值捕获 x
[&x] // 按引用捕获 x
[x, y] // 按值捕获 x 和 y
[&x, y] // x 按引用,y 按值
[=] // 默认按值捕获使用到的外层局部变量
[&] // 默认按引用捕获使用到的外层局部变量
[&, x] // 默认按引用,但 x 按值
[=, &x] // 默认按值,但 x 按引用
课件中的 [x&] 属于语法笔误,正确写法是:
[&x]
“捕获所有变量”也是一种方便说法。更准确地说,默认捕获会作用于 Lambda 函数体实际使用且允许捕获的外层局部实体,而不是把程序中的所有变量都复制进去。
10.4 按值与按引用的完整对比
#include <iostream>
int main() {
int limit = 10;
auto by_value = [limit](int value) {
return value < limit;
};
auto by_reference = [&limit](int value) {
return value < limit;
};
limit = 100;
std::cout << std::boolalpha;
std::cout << by_value(50) << '\n';
std::cout << by_reference(50) << '\n';
}
输出:
false
true
原因如下。
创建 by_value 时:
by_value 内部保存 limit = 10
创建 by_reference 时:
by_reference 指向外层 limit
执行:
limit = 100;
之后:
by_value(50)
计算:
50 < 10 -> false
而:
by_reference(50)
计算:
50 < 100 -> true
10.5 引用捕获的生命周期风险
按引用捕获不会延长外层变量的生命周期。
下面的代码有危险:
auto make_predicate() {
int limit = 10;
return [&limit](int value) {
return value < limit;
};
}
函数返回后:
局部变量 limit 被销毁
但返回的 Lambda 仍保存着对它的引用:
Lambda ─────→ 已经不存在的 limit
之后调用这个 Lambda 会产生未定义行为。
正确做法通常是按值捕获:
auto make_predicate() {
int limit = 10;
return [limit](int value) {
return value < limit;
};
}
此时 Lambda 对象拥有自己的副本。
练习:预测按值捕获与按引用捕获
#include <iostream>
int main() {
int number = 5;
auto first = [number]() {
return number;
};
auto second = [&number]() {
return number;
};
number = 20;
std::cout << first() << '\n';
std::cout << second() << '\n';
}
答案与解释
输出:
5
20
first 按值捕获:
创建 first 时:
Lambda 内部副本 number = 5
外层变量之后改成 20,不会影响副本。
second 按引用捕获:
second ─────→ 外层 number
调用时读取的是当前外层值,因此得到 20。
初学者容易误以为按值捕获会在“每次调用”时重新复制。实际复制通常发生在 Lambda 对象创建时。
十一、捕获值默认不能被修改
考虑:
int count = 0;
auto increment = [count]() {
++count;
};
这段代码通常不能编译。
按值捕获的成员在默认的 Lambda 调用运算符中被当作不可修改状态。
如果确实需要修改 Lambda 自己保存的副本,可以写:
int count = 0;
auto increment = [count]() mutable {
++count;
return count;
};
完整示例:
#include <iostream>
int main() {
int count = 0;
auto increment = [count]() mutable {
++count;
return count;
};
std::cout << increment() << '\n';
std::cout << increment() << '\n';
std::cout << count << '\n';
}
输出:
1
2
0
Lambda 内部状态变为:
第一次调用后:内部 count = 1
第二次调用后:内部 count = 2
外层 count 仍为 0,因为它们是两个不同对象。
如果希望修改外层变量,则使用引用捕获:
auto increment = [&count]() {
++count;
};
这时需要确保外层变量仍然存在。
十二、Lambda 可以直接写在算法调用中
如果一个判断规则只使用一次,没有必要先给它单独命名。
例如寻找第一条有瀑布的路线:
auto it = my_find_if(
trails.begin(),
trails.end(),
[](const Trail& trail) {
return trail.has_waterfall();
}
);
捕获列表为空:
[]
因为判断不需要外层变量。
参数使用:
const Trail& trail
而不是:
Trail trail
这样不会为每次检查都复制一整个 Trail 对象。
算法每次调用时:
当前路线对象
↓
通过 const 引用传入 Lambda
↓
检查 has_waterfall()
↓
返回 true 或 false
如果需要用户输入的最大长度:
double max_length;
std::cin >> max_length;
auto it = my_find_if(
trails.begin(),
trails.end(),
[max_length](const Trail& trail) {
return trail.length() <= max_length &&
trail.has_waterfall();
}
);
Lambda 对外仍然只接收一条路线,但内部记住了 max_length。
十三、泛型 Lambda:参数也可以写成 auto
可以写:
auto less_than_limit = [limit](auto value) {
return value < limit;
};
这个 Lambda 可以尝试接收多种不同类型:
less_than_limit(5);
less_than_limit(3.14);
这种 Lambda 称为泛型 Lambda(generic lambda)。
auto 参数背后可以建立这样的直觉:
每当使用一种新的参数类型调用 Lambda,
编译器就为调用运算符生成相应版本。
它在概念上类似:
template <typename T>
bool operator()(T value) const {
return value < limit;
}
但下面这种写法并不是合法的等价替换:
template <typename T>
auto less_than_limit = [limit](T value) {
return value < limit;
};
Lambda 的参数列表中不能直接使用这样一个外部声明的 T 来模拟局部变量模板。
更准确地说,泛型 Lambda 会使编译器生成一个拥有模板调用运算符的闭包类型。
练习:泛型 Lambda 能否接受这些参数
int limit = 10;
auto less_than_limit = [limit](const auto& value) {
return value < limit;
};
判断下面哪些调用有机会编译:
less_than_limit(5);
less_than_limit(3.5);
less_than_limit(std::string{"abc"});
答案与解释
下面两个调用可以编译:
less_than_limit(5);
less_than_limit(3.5);
因为表达式分别是:
5 < 10
3.5 < 10
它们都有合法含义。
下面的调用不能编译:
less_than_limit(std::string{"abc"});
Lambda 会尝试形成:
std::string{"abc"} < 10
字符串与整数之间没有这样的 < 运算。
auto 并不意味着“任何类型都一定可用”。它表示编译器根据实参推导类型,但函数体中的操作仍然必须对该类型合法。
十四、Lambda 到底是什么类型
14.1 先认识函数对象
函数对象(function object),也常称为仿函数(functor),是定义了函数调用运算符 operator() 的对象。
例如:
struct MultiplyBy {
int factor;
int operator()(int value) const {
return value * factor;
}
};
完整程序:
#include <iostream>
struct MultiplyBy {
int factor;
int operator()(int value) const {
return value * factor;
}
};
int main() {
MultiplyBy times_five{5};
std::cout << times_five(10) << '\n';
}
输出:
50
看起来像在调用函数:
times_five(10)
实际上可以理解为调用:
times_five.operator()(10)
对象状态为:
times_five
┌────────────────┐
│ factor = 5 │
└────────────────┘
调用时:
value = 10
factor = 5
10 * 5 = 50
普通函数不能自然地拥有某个实例专属的成员变量,而函数对象可以保存状态。
14.2 标准库中的函数对象
标准库提供了许多现成函数对象。
例如:
#include <functional>
#include <iostream>
int main() {
std::greater<int> greater;
std::cout << std::boolalpha;
std::cout << greater(1, 2) << '\n';
std::cout << greater(5, 2) << '\n';
}
输出:
false
true
其概念结构类似:
template <typename T>
struct GreaterConcept {
bool operator()(const T& first,
const T& second) const {
return first > second;
}
};
课件展示的 std::greater 定义是为了说明原理。程序员不应该自行重新定义标准库已有的 std::greater。
另一个函数对象是:
std::hash<T>
无序容器会使用它计算哈希值。
对于用户自定义类型,可以在符合标准要求的前提下,为该类型提供 std::hash 的特化。这涉及模板特化,属于本节的补充内容,不是理解 Lambda 的必要前提。
14.3 Lambda 会生成一个闭包类型
考虑:
int limit = 10;
auto less_than_limit = [limit](int value) {
return value < limit;
};
编译器在概念上可以生成类似下面的类型:
class CompilerGeneratedLessThan {
public:
explicit CompilerGeneratedLessThan(int captured_limit)
: limit_{captured_limit} {
}
bool operator()(int value) const {
return value < limit_;
}
private:
int limit_;
};
然后原代码在概念上类似:
int limit = 10;
auto less_than_limit =
CompilerGeneratedLessThan{limit};
捕获变量变成了对象中的状态:
less_than_limit
┌─────────────────┐
│ limit_ = 10 │
│ operator() │
└─────────────────┘
调用:
less_than_limit(7)
类似:
less_than_limit.operator()(7)
需要注意:
- 这只是帮助理解的概念模型;
- 编译器生成的真实类型名称不可由程序直接依赖;
- 标准并不保证它必须使用这里展示的成员名称和构造函数形式;
- 不同 Lambda 表达式通常拥有不同类型;
- 即使两个 Lambda 的代码文字完全一样,它们也可能是两个不同的闭包类型。
14.4 Lambda 也是一种语法糖
范围 for:
for (const int& value : values) {
// ...
}
可以概念性地展开成使用迭代器的循环:
auto first = values.begin();
auto last = values.end();
for (auto it = first; it != last; ++it) {
const int& value = *it;
// ...
}
类似地,Lambda:
auto less_than_limit = [limit](int value) {
return value < limit;
};
可以概念性地展开成一个函数对象类。
语法糖不是“没有功能”。它表示语言提供了一种更紧凑、更容易组合的写法,而编译器会将它转换为更基础的结构。
14.5 Lambda 对象的生命周期
Lambda 对象和普通局部对象一样拥有生命周期。
void example() {
int limit = 10;
auto pred = [limit](int value) {
return value < limit;
};
// pred 在这里存在
}
离开 example() 时:
pred 被销毁
如果按值捕获了 std::string、std::vector 等对象,它们的副本也会随 Lambda 对象一起销毁。
如果按引用捕获,Lambda 不拥有被引用对象。被引用对象必须活得足够久。
十五、函数指针、函数对象和 Lambda 的对比
| 形式 | 能否保存状态 | 类型 | 常见用途 |
|---|---|---|---|
| 普通函数指针 | 通常不能直接携带实例状态 | 如 bool (*)(int) |
无状态、已有命名函数 |
| 自定义函数对象 | 可以 | 自定义类类型 | 复杂状态、可复用行为 |
| Lambda | 可以 | 编译器生成的闭包类型 | 局部、临时、紧贴调用位置的行为 |
std::function |
可以保存兼容的可调用对象 | 如 std::function<bool(int)> |
需要统一类型存储不同可调用对象 |
它们都可能支持:
callable(argument)
因此标准算法通常不关心具体属于哪一种,只要求调用表达式合法。
十六、std::function:统一保存不同可调用对象
Lambda 的具体类型通常只能用 auto 表示。
有时我们需要一个明确、统一的类型,例如:
- 把回调保存为类的成员变量;
- 在容器中存放多个不同类型的可调用对象;
- 函数需要返回多种可能的可调用对象;
- 接口必须在头文件中写出固定类型。
这时可以使用:
std::function<返回类型(参数类型...)>
示例:
#include <functional>
#include <iostream>
bool is_positive(int value) {
return value > 0;
}
int main() {
std::function<bool(int)> pred;
pred = is_positive;
std::cout << std::boolalpha
<< pred(10) << '\n';
int limit = 20;
pred = [limit](int value) {
return value < limit;
};
std::cout << pred(15) << '\n';
}
输出:
true
true
同一个 std::function<bool(int)> 可以先保存普通函数,再保存兼容的 Lambda。
课件还展示了:
std::function<bool(int, int)> less =
std::less<int>{};
std::function<bool(char)> vowel =
is_vowel;
std::function<int(int)> twice =
[](int value) {
return value * 2;
};
16.1 为什么不总是使用 std::function
std::function 为不同类型的可调用对象提供统一包装,这通常通过类型擦除(type erasure)实现。
代价可能包括:
- 间接调用;
- 较难内联;
- 对较大状态进行动态内存分配;
- 额外存储空间;
- 可调用对象需要满足相应复制或移动要求。
因此,在模板函数或局部变量中,通常优先使用:
auto
或者:
template <typename Pred>
当确实需要统一、稳定的运行时包装类型时,再考虑 std::function。
这不是说 std::function 很慢或不能使用,而是不要在并不需要类型擦除时无条件套上一层包装。
练习:应该选择哪一种形式
下面三种需求分别更适合使用什么?
- 在一次
std::find_if调用中临时判断数字是否小于用户输入值。 - 已经存在一个无状态函数
bool is_even(int),只需要传给算法。 - 类的成员需要保存一个未来可以被替换的回调,可能是普通函数,也可能是带捕获 Lambda。
答案与解释
第一种适合 Lambda:
auto it = std::find_if(
values.begin(),
values.end(),
[limit](int value) {
return value < limit;
}
);
规则只在局部使用,而且需要捕获运行时状态。
第二种可以直接传函数:
auto it = std::find_if(
values.begin(),
values.end(),
is_even
);
没有必要为了使用 Lambda 而重新包装已有函数。
第三种可能适合:
std::function<void(...)>
因为成员变量需要一个固定类型,并且希望保存多种不同具体类型的可调用对象。
也可以使用类模板等其他设计,但当接口需要运行时替换回调时,std::function 是常见方案。
十七、标准算法:把遍历逻辑交给标准库
17.1 为什么有了 Lambda 之后要学习 <algorithm>
现在我们已经能够把行为传递给函数。
下一个问题是:
我们是否还需要亲自编写每一个循环?
很多常见操作拥有完全相同的循环结构:
- 找到第一个符合条件的元素;
- 统计符合条件的元素数量;
- 复制符合条件的元素;
- 修改每个元素;
- 排序;
- 找到最大元素;
- 删除符合条件的元素。
C++ 标准库的 <algorithm> 提供了这些通用操作。
算法负责遍历过程,调用者只提供:
- 输入范围;
- 输出位置;
- 判断规则;
- 转换规则;
- 比较规则。
17.2 std::find_if
std::find_if(first, last, pred);
含义:
在
[first, last)中查找第一个满足pred(element)的元素。
返回:
- 找到时:对应迭代器;
- 找不到时:
last。
17.3 std::count_if
std::count_if(first, last, pred);
含义:
统计范围中有多少元素满足谓词。
示例:
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values{1, 2, 3, 4, 5, 6};
const auto even_count = std::count_if(
values.begin(),
values.end(),
[](int value) {
return value % 2 == 0;
}
);
std::cout << even_count << '\n';
}
输出:
3
17.4 std::sort
std::sort(first, last, compare);
比较函数通常接收两个元素:
compare(a, b)
它应回答:
a是否应该排在b前面?
升序:
[](int a, int b) {
return a < b;
}
降序:
[](int a, int b) {
return a > b;
}
不要为了升序写:
return a <= b;
排序比较必须满足严格弱序(strict weak ordering)。最基本的一项要求是:
compare(x, x) 必须为 false
而:
x <= x
为 true,不符合要求。
17.5 std::max_element
std::max_element(first, last, compare);
返回范围中的最大元素迭代器。
比较规则告诉算法“什么叫更小”,算法据此判断最大者。
17.6 std::copy_if
std::copy_if(
first,
last,
output,
pred
);
它会把符合条件的元素写到输出位置。
如果输出容器一开始为空,常使用:
std::back_inserter(result)
它会在算法写入时调用:
result.push_back(...)
示例:
#include <algorithm>
#include <iostream>
#include <iterator>
#include <vector>
int main() {
std::vector<int> source{1, 2, 3, 4, 5, 6};
std::vector<int> evens;
std::copy_if(
source.begin(),
source.end(),
std::back_inserter(evens),
[](int value) {
return value % 2 == 0;
}
);
for (int value : evens) {
std::cout << value << ' ';
}
std::cout << '\n';
}
输出:
2 4 6
17.7 std::transform
一元版本大致形如:
std::transform(
first,
last,
output,
operation
);
对于范围中的每个元素,计算:
operation(element)
并把结果写到输出位置。
示例:
std::vector<int> values{1, 2, 3};
std::vector<int> doubled;
std::transform(
values.begin(),
values.end(),
std::back_inserter(doubled),
[](int value) {
return value * 2;
}
);
结果:
doubled = {2, 4, 6}
二元版本同时读取两个输入范围:
std::transform(
first1,
last1,
first2,
output,
binary_operation
);
每一步大致执行:
binary_operation(*first1, *first2)
随后两个输入迭代器一起前进。
Tokenizer 示例会使用这个版本。
17.8 std::unique_copy
std::unique_copy(
first,
last,
output,
binary_pred
);
它根据二元谓词判断相邻元素是否应当视为相同,并将去除连续重复后的序列写到输出位置。
需要注意,它处理的是连续重复,而不是任意位置的重复。
17.9 标准库算法覆盖的常见任务
标准库还提供:
- 二分查找;
- 最小值与最大值;
- 合并;
- 集合并集、交集和差集;
- 分区;
- 堆操作;
- 打乱;
- 抽样;
- 删除;
- 替换;
- 旋转;
- 反转;
- 全部满足、任意满足、没有满足;
- 字典序比较。
学习标准算法的重点不是背下所有名称,而是形成一种习惯:
写循环之前,先判断这是不是一个已经存在的通用算法。
练习:选择合适的标准算法
下面的需求分别适合使用什么算法?
- 找到第一个负数。
- 统计字符串中元音数量。
- 把所有整数乘以 2,写入另一个容器。
- 把所有偶数复制到另一个容器。
- 根据学生成绩排序。
答案与解释
- 第一个负数:
std::find_if
- 元音数量:
std::count_if
- 每个整数乘以 2:
std::transform
- 复制所有偶数:
std::copy_if
- 根据成绩排序:
std::sort
对应谓词或操作的参数数量也不同:
find_if -> 一元谓词
count_if -> 一元谓词
transform -> 一元操作
copy_if -> 一元谓词
sort -> 二元比较
十八、综合案例:用算法构建 Tokenizer
18.1 我们想解决什么问题
Tokenizer 的任务是把一句字符串拆成多个词元。
输入:
Breaking down the string
输出:
Breaking
down
the
string
如果直接手写一个复杂循环,需要同时处理:
- 当前字符位置;
- 单词开始位置;
- 空格;
- 连续多个空格;
- 字符串结尾;
- 空字符串;
- 是否已经在单词内部。
逻辑很容易缠在一起。
课件采用的思路是先分解问题:
- 找出所有分隔位置;
- 把相邻分隔位置转换成 Token;
- 删除连续空格产生的空 Token。
这正好对应几个通用算法。
18.2 第一步:理解边界位置
字符串:
"Breaking down the string"
可以画成:
字符位置:
0 8 13 17 end
↓ ↓ ↓ ↓ ↓
Breaking down the string
空格位于:
8、13、17
加上字符串开头和结尾,得到边界:
0、8、13、17、end
相邻边界形成区间:
[0, 8) -> "Breaking"
[8, 13) -> " down"
[13, 17) -> " the"
[17, end) -> " string"
每个后续区间开头包含一个空格,所以构造 Token 时需要跳过左边界上的分隔符。
如果有多个连续空格:
"a b"
相邻空格边界会形成长度为零或只包含分隔符的区间,最终产生空 Token。第三步再统一删除它们。
18.3 find_all:找出所有符合谓词的位置
标准库有 find_if,但它只返回第一个匹配位置。
这里需要所有空白字符的位置,因此实现一个 find_all:
template <typename It, typename Pred>
std::vector<It> find_all(
It first,
It last,
Pred pred
) {
std::vector<It> positions;
positions.push_back(first);
for (auto it = first; it != last; ++it) {
if (it != first && pred(*it)) {
positions.push_back(it);
}
}
positions.push_back(last);
return positions;
}
返回值是:
std::vector<It>
也就是一组迭代器。
为什么加入 first 和 last?
因为仅有空格位置还不够。第一个 Token 从字符串开头开始,最后一个 Token 到字符串结尾结束。
18.4 判断空白字符
不要只检查:
c == ' '
因为空白字符还可能包括:
- 制表符
'\t'; - 换行符
'\n'; - 回车等。
可以定义:
const auto is_space = [](char c) {
return std::isspace(
static_cast<unsigned char>(c)
) != 0;
};
这里用 Lambda 包装 std::isspace,同时完成安全的 unsigned char 转换。
18.5 第二步:用二元 transform 处理相邻边界
假设边界迭代器容器是:
boundaries = {b0, b1, b2, b3, b4}
我们需要配对:
(b0, b1)
(b1, b2)
(b2, b3)
(b3, b4)
因此二元 transform 的两个输入范围是:
第一个范围:b0 b1 b2 b3
第二个范围:b1 b2 b3 b4
对应参数:
first1 = boundaries.begin()
last1 = boundaries.end() - 1
first2 = boundaries.begin() + 1
转换规则接收两个迭代器:
[&](It left, It right) {
// 用 [left, right) 创建一个字符串
}
18.6 第三步:删除空 Token
C++20 提供:
std::erase_if(container, pred);
我们可以删除所有空字符串:
std::erase_if(
tokens,
[](const std::string& token) {
return token.empty();
}
);
算法会检查每个 Token:
token.empty() == true
↓
删除该 Token
18.7 完整的 C++20 Tokenizer
#include <algorithm>
#include <cctype>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
template <typename It, typename Pred>
std::vector<It> find_all(
It first,
It last,
Pred pred
) {
std::vector<It> positions;
positions.push_back(first);
for (auto it = first; it != last; ++it) {
if (it != first && pred(*it)) {
positions.push_back(it);
}
}
positions.push_back(last);
return positions;
}
std::vector<std::string> tokenize(
const std::string& source
) {
using It = std::string::const_iterator;
const auto is_space = [](char c) {
return std::isspace(
static_cast<unsigned char>(c)
) != 0;
};
const std::vector<It> boundaries =
find_all(
source.begin(),
source.end(),
is_space
);
std::vector<std::string> tokens;
if (boundaries.size() >= 2) {
std::transform(
boundaries.begin(),
std::prev(boundaries.end()),
std::next(boundaries.begin()),
std::back_inserter(tokens),
[&](It left, It right) {
if (left != source.end() &&
is_space(*left)) {
++left;
}
return std::string(left, right);
}
);
}
std::erase_if(
tokens,
[](const std::string& token) {
return token.empty();
}
);
return tokens;
}
int main() {
const std::string source =
"N'ot a!! Str1ngs 4re nice :/";
const auto tokens = tokenize(source);
for (const auto& token : tokens) {
std::cout << '[' << token << "]\n";
}
}
编译:
g++ -std=c++20 main.cpp -o main
输出:
[N'ot]
[a!!]
[Str1ngs]
[4re]
[nice]
[:/]
18.8 跟踪连续空格
输入片段:
"a b"
边界大致为:
begin, 空格1, 空格2, 空格3, 空格4, end
相邻边界转换后,可能暂时得到:
"a"
""
""
""
"b"
随后:
std::erase_if(tokens, is_empty);
删除空字符串,得到:
"a"
"b"
这种设计把两个问题分开处理:
如何切分
和:
如何清理空结果
每一步只负责一件事情,比把所有判断塞进一个循环更容易验证。
练习:填写二元 transform 的参数
补全:
std::transform(
____________________,
____________________,
____________________,
std::back_inserter(tokens),
make_token
);
其中:
boundaries = {b0, b1, b2, b3}
目标配对是:
(b0, b1)
(b1, b2)
(b2, b3)
答案与解释
应写成:
std::transform(
boundaries.begin(),
std::prev(boundaries.end()),
std::next(boundaries.begin()),
std::back_inserter(tokens),
make_token
);
第一个输入范围是:
b0 b1 b2
第二个输入范围从 b1 开始:
b1 b2 b3
每次调用:
make_token(b0, b1)
make_token(b1, b2)
make_token(b2, b3)
如果把第一个范围一直写到 boundaries.end(),第二个范围会在最后一步越过有效范围。
十九、从迭代器算法到 Ranges
19.1 为什么还要改进
传统算法调用经常需要:
std::find(
values.begin(),
values.end(),
target
);
这很灵活,因为可以指定任意子区间。
但很多时候,我们只是想搜索整个容器:
我想查完整个 values,
并不关心它的 begin 和 end 是什么类型。
C++20 引入了 Ranges 库,为大量标准算法提供以范围为中心的接口。
19.2 什么是范围
范围(range)可以先建立这样的直觉:
能够确定起点和终点,并可以从起点遍历到终点的对象。
常见范围包括:
std::vector<T>
std::string
std::set<T>
std::map<K, V>
std::unordered_set<T>
数组
部分自定义类型
视图
课件图中写有:
std::unordered_set<K, V>
这是一个简化或笔误。
std::unordered_set 的主要元素类型只有 K:
std::unordered_set<K>
拥有键和值的是:
std::unordered_map<K, V>
正式的范围概念比“拥有同类型的 begin() 和 end()”更灵活,因为终点可以由哨兵(sentinel)类型表示。但初学阶段把范围理解为“能通过 begin/end 遍历的对象”已经足够使用大部分接口。
19.3 使用范围算法
传统写法:
auto it = std::find(
values.begin(),
values.end(),
target
);
Ranges 写法:
auto it = std::ranges::find(
values,
target
);
完整示例:
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<char> values{
'a', 'b', 'c', 'd', 'e'
};
auto it = std::ranges::find(values, 'c');
if (it != values.end()) {
std::cout << *it << '\n';
}
}
输出:
c
这里 values 本身就是一个范围。
19.4 仍然可以使用迭代器子区间
Ranges 并没有取消迭代器版本。
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<char> values{
'a', 'b', 'c', 'd', 'e'
};
auto first = values.begin() + 1;
auto last = values.end() - 1;
auto it = std::ranges::find(
first,
last,
'c'
);
if (it != last) {
std::cout << *it << '\n';
}
}
搜索范围:
[a][b][c][d][e]
× └──────┘ ×
所以 Ranges 提供的是额外的便利接口,而不是禁止精细控制范围。
19.5 Ranges 算法受到概念约束
Ranges 算法大量使用概念(concept)。
可以先把概念理解为:
对模板参数能力的明确要求。
例如,一个输入范围需要:
- 是一个范围;
- 能够取得迭代位置;
- 迭代器满足输入迭代器的要求。
概念化的伪代码类似:
template <typename T>
concept Range = requires(T& value) {
std::ranges::begin(value);
std::ranges::end(value);
};
真实标准库定义更复杂。
概念的优势之一是:当类型不满足算法要求时,编译器有机会给出更接近问题本身的错误信息,而不是展开大量难懂的模板实例化错误。
19.6 Ranges 的版本说明
课件将 Ranges 与 Views 描述为很新的、面向 C++26 的功能。
更准确的时间关系是:
- Ranges 算法和许多基础 Views 在 C++20 中进入标准;
- C++23 增加了
std::ranges::to等能力; - C++26 继续增加和完善范围适配器、算法及相关功能。
因此,本节主要代码会保持在:
g++ -std=c++20
能够支持的范围内。
二十、传统算法组合为什么有些笨重
假设有字符:
std::vector<char> letters{
'a', 'b', 'c', 'd', 'e'
};
目标:
- 只保留元音;
- 把元音转换成大写。
传统算法可能先生成一个中间容器:
std::vector<char> vowels;
std::copy_if(
letters.begin(),
letters.end(),
std::back_inserter(vowels),
is_vowel
);
然后再生成第二个容器:
std::vector<char> upper_vowels;
std::transform(
vowels.begin(),
vowels.end(),
std::back_inserter(upper_vowels),
to_upper
);
数据流是:
letters
↓ copy_if
vowels
↓ transform
upper_vowels
这种方式没有错误,而且常常很清楚。
但它需要:
- 显式创建中间容器;
- 第一次完整遍历并写入;
- 第二次再次遍历;
- 管理多个变量名。
Views 提供了另一种组合方式。
二十一、View:惰性地改造一个范围
21.1 什么是 View
视图(view)是一种范围,它以惰性方式适配另一个范围。
“惰性”表示:
创建视图时通常不会立刻完整遍历底层数据。真正需要元素时,过滤或转换操作才会执行。
例如:
auto vowel_view =
letters | std::views::filter(is_vowel);
这时并没有必要立刻创建一个包含所有元音的新 vector。
可以建立这样的模型:
vowel_view
│
├── 保存或引用底层范围 letters
├── 保存谓词 is_vowel
└── 在遍历时跳过不满足条件的元素
21.2 filter 视图
auto vowels =
std::views::filter(letters, is_vowel);
也可以通过管道写法:
auto vowels =
letters | std::views::filter(is_vowel);
遍历 vowels 时,只会看到满足谓词的元素。
21.3 transform 视图
auto upper =
vowels | std::views::transform(to_upper);
遍历 upper 时,每个底层元素会经过 to_upper 转换。
数据流变为:
letters
↓ filter(is_vowel)
元音视图
↓ transform(to_upper)
大写元音视图
这是一条描述处理过程的流水线。
21.4 C++20 完整示例
#include <cctype>
#include <iostream>
#include <ranges>
#include <vector>
bool is_vowel(char c) {
const char upper = static_cast<char>(
std::toupper(static_cast<unsigned char>(c))
);
return upper == 'A' ||
upper == 'E' ||
upper == 'I' ||
upper == 'O' ||
upper == 'U';
}
char to_upper(char c) {
return static_cast<char>(
std::toupper(static_cast<unsigned char>(c))
);
}
int main() {
std::vector<char> letters{
'a', 'b', 'c', 'd', 'e'
};
auto view =
letters
| std::views::filter(is_vowel)
| std::views::transform(to_upper);
std::vector<char> upper_vowels;
for (char c : view) {
upper_vowels.push_back(c);
}
for (char c : upper_vowels) {
std::cout << c << ' ';
}
std::cout << '\n';
}
输出:
A E
这份代码能够使用:
g++ -std=c++20
编译。
21.5 std::ranges::to 的版本问题
课件展示了:
auto result =
view | std::ranges::to<std::vector<char>>();
std::ranges::to 是 C++23 功能,不属于 C++20。
如果编译命令是:
g++ -std=c++20
应像前面的完整示例一样,通过循环物化:
std::vector<char> result;
for (char c : view) {
result.push_back(c);
}
使用支持相应功能的 C++23 编译器时,才可以使用 std::ranges::to。
二十二、算法是立即执行的,View 是惰性的
22.1 Range 算法通常立即执行
std::ranges::sort(values);
调用发生时,values 就会被排序。
调用前:
values = {4, 1, 3, 2}
调用后:
values = {1, 2, 3, 4}
算法已经遍历并修改数据。
22.2 创建 View 时通常不遍历完整数据
auto view =
letters
| std::views::filter(is_vowel)
| std::views::transform(to_upper);
这一步主要是在构造一条处理流水线。
当执行:
for (char c : view) {
// ...
}
才开始请求元素。
可以建立以下执行直觉:
请求 view 的第一个元素
↓
从 letters 读取 'a'
↓
is_vowel('a') -> true
↓
to_upper('a') -> 'A'
↓
交给循环
请求下一个元素
↓
读取 'b'
↓
is_vowel('b') -> false
↓
跳过,继续读取
因此,“只有 ranges::to 那一行才发生处理”是一种针对特定示例的简化。
更准确地说:
创建视图本身通常不消费整个范围;任何遍历、算法调用或物化操作都可能触发惰性计算。
22.3 View 与 Python 生成器的类比
C++:
auto view =
letters
| std::views::filter(is_vowel)
| std::views::transform(to_upper);
概念上类似 Python:
view = (letter for letter in letters
if is_vowel(letter))
view = (to_upper(letter)
for letter in view)
两者都描述一个惰性序列。
类比成立的部分:
- 不必立即创建全部结果;
- 元素可以按需生成;
- 多个步骤可以连接;
- 处理发生在消费结果时。
类比并不完全准确的部分:
- C++ View 的类型、引用语义和生命周期规则与 Python 生成器不同;
- View 可能直接引用底层元素;
- 不同 View 对多次遍历、元素类型和迭代器能力有不同要求。
22.4 View 的生命周期问题
很多 View 会引用底层范围。
std::vector<int> values{1, 2, 3};
auto view =
values | std::views::filter([](int value) {
return value > 1;
});
关系大致为:
view ─────→ values
因此 values 必须在使用 view 时仍然存在。
如果底层容器被销毁,View 可能悬空。
此外,修改底层容器可能让迭代器失效,进而影响 View。具体规则取决于容器和修改操作。
View 通常不拥有一份完整、独立的数据副本。需要长期独立保存结果时,应把它物化到容器中。
二十三、Ranges 和 Views 的优缺点
23.1 为什么喜欢它们
更少直接处理迭代器
搜索整个容器时:
std::ranges::find(values, target);
比下面更集中表达意图:
std::find(
values.begin(),
values.end(),
target
);
受到概念约束
模板要求更加明确,编译错误有机会更容易理解。
容易组合
auto view =
values
| std::views::filter(pred)
| std::views::transform(operation);
代码按数据流顺序阅读。
可以避免不必要的中间容器
过滤结果不一定需要先完整存入一个临时 vector,再进行下一步转换。
23.2 为什么仍需谨慎
编译器支持存在差异
基础 Ranges 和 Views 属于 C++20,但更新的适配器和工具分布在 C++23、C++26 中。
使用前需要确认:
- 当前编译器版本;
- 编译标准;
- 标准库实现支持情况。
类型可能复杂
View 组合后的真实类型很长,因此通常使用:
auto
生命周期更容易出错
View 往往引用其他对象,需要注意底层范围是否仍然存在。
性能不能只凭语法判断
课件将性能损失列为缺点,但这并不是所有 View 都必然发生的结果。
编译器可能把简单的 View 流水线很好地内联和优化。有些复杂 View、迭代器层级或访问模式也可能产生额外开销。
更准确的原则是:
先写清楚、正确的代码;性能敏感时使用基准测试和分析工具验证,而不是仅凭代码看起来更抽象就断定更慢。
练习:惰性求值会输出什么
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> values{1, 2, 3};
auto view = values
| std::views::transform([](int value) {
std::cout << "transform "
<< value << '\n';
return value * 2;
});
std::cout << "view created\n";
for (int value : view) {
std::cout << "result "
<< value << '\n';
}
}
答案与解释
输出顺序是:
view created
transform 1
result 2
transform 2
result 4
transform 3
result 6
执行:
auto view = ...
时主要创建视图对象,并不会立刻把全部元素转换一遍。
进入范围 for 后,每请求一个元素,转换 Lambda 才会执行一次。
因此转换和消费交错发生:
转换 1 -> 使用结果 2
转换 2 -> 使用结果 4
转换 3 -> 使用结果 6
这与先创建完整的 {2, 4, 6} 中间容器再遍历不同。
二十四、容易混淆的概念集中对比
24.1 普通函数与 Lambda
普通函数:
bool is_even(int value) {
return value % 2 == 0;
}
Lambda:
auto is_even = [](int value) {
return value % 2 == 0;
};
共同点:
- 都可以接收参数;
- 都可以返回结果;
- 都可以被调用;
- 都可以传给兼容的算法。
区别:
- 普通函数拥有名称和固定函数类型;
- Lambda 创建的是对象;
- Lambda 可以捕获局部状态;
- 每个 Lambda 表达式通常有独立类型;
- Lambda 很适合紧贴算法调用位置定义短规则。
24.2 Lambda 捕获与函数参数
[limit](int value)
limit 是捕获状态:
- 创建 Lambda 时确定;
- 保存在对象中,或以引用形式连接外层变量;
- 每次调用不需要重新提供。
value 是函数参数:
- 每次调用时提供;
- 不同调用可以不同;
- 只在本次调用中存在。
调用:
pred(3);
pred(8);
状态:
捕获的 limit 始终为 10
第一次调用:value = 3
第二次调用:value = 8
24.3 按值捕获与按引用捕获
按值:
[x]
- 保存副本;
- 默认不会看到之后对外层变量的修改;
- 通常更不容易悬空;
- 复制大型对象可能有成本。
按引用:
[&x]
- 访问原对象;
- 能看到原对象之后的修改;
- 可以修改原对象,前提是引用和函数体允许;
- 原对象必须活得足够久。
24.4 find 与 find_if
find:
std::find(first, last, value);
判断逻辑由算法固定为类似:
element == value
find_if:
std::find_if(first, last, pred);
判断逻辑由调用者提供:
pred(element)
需要查具体值时用 find。
需要查满足任意条件的元素时用 find_if。
24.5 一元谓词与二元比较
find_if 的谓词:
pred(element)
只接收当前元素。
sort 的比较函数:
compare(a, b)
接收两个元素,回答 a 是否应排在 b 前。
不能把要求两个参数的函数直接传给只会提供一个参数的算法。
可以通过捕获把其中一个配置参数固定下来:
int divisor = 5;
auto divisible_by_five =
[divisor](int number) {
return number % divisor == 0;
};
对外接口重新变成:
pred(number)
24.6 算法与 View
算法:
std::ranges::sort(values);
通常立即遍历或修改范围。
View:
auto view =
values | std::views::filter(pred);
通常创建惰性适配器,遍历时才计算。
算法常常“做一件事”。
View 更像“描述以后怎样看到这个范围”。
二十五、初学者常见错误
25.1 忘记检查 end()
错误:
auto it = std::find_if(
values.begin(),
values.end(),
pred
);
std::cout << *it;
正确:
if (it != values.end()) {
std::cout << *it;
}
25.2 捕获语法写反
错误:
[x&]
正确:
[&x]
25.3 把运行时配置写成谓词的第二个参数
错误思路:
bool pred(int element, int limit);
std::find_if(first, last, pred);
find_if 只会调用:
pred(element)
解决方式:
auto pred = [limit](int element) {
return element < limit;
};
25.4 不必要地复制大型对象
可以编译,但可能低效:
[](Trail trail) {
return trail.has_waterfall();
}
更合适:
[](const Trail& trail) {
return trail.has_waterfall();
}
25.5 引用捕获了即将销毁的局部变量
危险:
return [&local](int value) {
return value < local;
};
函数结束后 local 已经销毁。
如果需要 Lambda 独立存在,通常按值捕获:
return [local](int value) {
return value < local;
};
25.6 用 <= 作为排序比较
错误:
std::sort(
values.begin(),
values.end(),
[](int a, int b) {
return a <= b;
}
);
正确:
return a < b;
排序需要严格比较关系。
25.7 认为 auto 会让错误消失
auto lambda = [](const auto& value) {
return value.nonexistent_function();
};
auto 只负责类型推导。
当某种类型没有 nonexistent_function() 时,对应调用仍然会编译失败。
25.8 在 C++20 中直接使用 std::ranges::to
std::ranges::to
属于 C++23。
使用:
g++ -std=c++20
时应通过循环或其他 C++20 方法把 View 写入容器。
二十六、综合练习:筛选徒步路线
现在把本节的主要内容组合起来。
我们有:
struct Trail {
std::string name;
double miles;
bool has_waterfall;
};
请完成以下任务:
- 用户给出最大里程
max_miles; - 找到第一条同时满足以下条件的路线:
- 有瀑布;
- 长度不超过
max_miles;
- 统计符合条件的路线数量;
- 按路线长度升序排序;
- 使用 View 只遍历符合条件的路线名称。
初始数据:
std::vector<Trail> trails{
{"Pine Loop", 3.2, false},
{"Falls Path", 5.0, true},
{"Lake Ridge", 2.4, true},
{"Granite Peak", 8.1, true},
};
假设:
max_miles = 5.5
请先预测:
- 第一条符合条件的路线;
- 符合条件的路线数量;
- 排序后符合条件的路线名称顺序。
答案与解释
原始顺序:
Pine Loop 3.2 无瀑布
Falls Path 5.0 有瀑布
Lake Ridge 2.4 有瀑布
Granite Peak 8.1 有瀑布
条件是:
has_waterfall == true
并且
miles <= 5.5
逐个检查:
Pine Loop:
无瀑布 -> 不符合
Falls Path:
有瀑布,5.0 <= 5.5 -> 符合
Lake Ridge:
有瀑布,2.4 <= 5.5 -> 符合
Granite Peak:
有瀑布,但 8.1 > 5.5 -> 不符合
第一条符合条件的是:
Falls Path
数量是:
2
按长度排序后:
Lake Ridge 2.4
Pine Loop 3.2
Falls Path 5.0
Granite Peak 8.1
其中符合条件的名称顺序是:
Lake Ridge
Falls Path
综合练习参考实现
#include <algorithm>
#include <iostream>
#include <ranges>
#include <string>
#include <vector>
struct Trail {
std::string name;
double miles;
bool has_waterfall;
};
int main() {
std::vector<Trail> trails{
{"Pine Loop", 3.2, false},
{"Falls Path", 5.0, true},
{"Lake Ridge", 2.4, true},
{"Granite Peak", 8.1, true},
};
const double max_miles = 5.5;
const auto is_suitable =
[max_miles](const Trail& trail) {
return trail.has_waterfall &&
trail.miles <= max_miles;
};
const auto first =
std::ranges::find_if(
trails,
is_suitable
);
if (first != trails.end()) {
std::cout << "first: "
<< first->name
<< '\n';
} else {
std::cout << "no suitable trail\n";
}
const auto suitable_count =
std::ranges::count_if(
trails,
is_suitable
);
std::cout << "count: "
<< suitable_count
<< '\n';
std::ranges::sort(
trails,
{},
&Trail::miles
);
auto suitable_names =
trails
| std::views::filter(is_suitable)
| std::views::transform(
[](const Trail& trail)
-> const std::string& {
return trail.name;
}
);
for (const std::string& name
: suitable_names) {
std::cout << name << '\n';
}
}
编译:
g++ -std=c++20 main.cpp -o main
输出:
first: Falls Path
count: 2
Lake Ridge
Falls Path
综合实现的执行过程
创建谓词
const auto is_suitable =
[max_miles](const Trail& trail) {
return trail.has_waterfall &&
trail.miles <= max_miles;
};
Lambda 按值捕获:
max_miles = 5.5
对象状态可以表示为:
is_suitable
┌───────────────────────┐
│ max_miles = 5.5 │
│ operator()(Trail) │
└───────────────────────┘
查找
std::ranges::find_if(trails, is_suitable);
算法依次调用:
is_suitable(Pine Loop) -> false
is_suitable(Falls Path) -> true
随后立即停止,返回 Falls Path 的迭代器。
统计
std::ranges::count_if(trails, is_suitable);
统计算法必须检查全部元素,因为它需要知道总数量。
排序
std::ranges::sort(
trails,
{},
&Trail::miles
);
第三个参数是投影(projection)。
&Trail::miles 表示排序时提取每个 Trail 的 miles 成员。第二个参数 {} 使用默认的小于比较。
可以把它理解为简化了下面的 Lambda:
std::ranges::sort(
trails,
[](const Trail& first,
const Trail& second) {
return first.miles < second.miles;
}
);
View
auto suitable_names =
trails
| std::views::filter(is_suitable)
| std::views::transform(...);
这时没有创建一个新的字符串向量。
遍历 suitable_names 时:
- 从已经排序的
trails中寻找符合条件的路线; - 对符合条件的路线提取
name; - 将名称交给范围
for。
二十七、本节知识的完整串联
我们从一个普通的查找函数开始:
if (*it == value)
这个条件只能判断相等。
为了询问任意问题,我们引入谓词:
pred(*it)
随后把谓词作为参数传给算法:
template <typename It, typename Pred>
It find_if(It first, It last, Pred pred);
普通函数可以通过函数指针传入:
find_if(first, last, is_vowel);
但普通函数指针不方便携带运行时状态。
例如,阈值直到运行时才得到:
int limit;
std::cin >> limit;
Lambda 可以捕获该状态:
auto pred = [limit](int value) {
return value < limit;
};
Lambda 之所以能够保存状态,是因为它会创建一个闭包对象。这个对象在概念上类似函数对象:
struct Predicate {
int limit;
bool operator()(int value) const {
return value < limit;
}
};
标准算法不关心传入的是:
- 函数指针;
- Lambda;
- 自定义函数对象;
std::function。
它只关心调用表达式是否合法:
pred(element)
因此,我们可以用标准算法替代大量重复循环:
std::find_if
std::count_if
std::copy_if
std::transform
std::sort
std::erase_if
传统算法通常使用迭代器范围:
[first, last)
Ranges 进一步允许直接传整个范围:
std::ranges::find(values, target);
Views 则允许把多个惰性步骤连接起来:
values
| std::views::filter(pred)
| std::views::transform(operation)
最终,程序设计方式从:
我应该怎样亲自控制每一次循环?
逐渐转变为:
我的数据是什么?
我想保留什么?
我想转换成什么?
标准库中是否已经有对应算法?
二十八、本节课留下的问题
现在,我们已经可以:
- 把行为保存为对象;
- 让行为捕获状态;
- 把行为交给泛型算法;
- 用 Ranges 简化整个容器的操作;
- 用 Views 描述惰性处理流水线。
但这也留下了新的问题:
Lambda 捕获的对象应该活多久?
View 引用的底层范围何时会失效?
算法返回的迭代器在容器修改后还能不能使用?
一个惰性流水线到底拥有数据,还是只引用数据?
怎样设计既安全又高效的泛型接口?
这些问题开始把“函数与算法”连接到 C++ 中更广泛的主题:
- 对象生命周期;
- 所有权;
- 引用失效;
- 移动与复制;
- 类型约束;
- 泛型接口设计;
- 惰性求值。
本节最重要的变化不是记住某一种 Lambda 语法,而是开始把程序看成两部分:
稳定的算法结构
+
由调用者提供的可变行为
当这两部分被分离后,同一套算法便能够服务于大量不同问题。