1.3 Lambda 与 ranges
有了 std::vector,我们仍然可以使用熟悉的下标循环:
for (std::size_t index{0}; index < students.size(); ++index) {
if (students[index].score < passing_score) {
std::cout << students[index].id << '\n';
}
}这段代码没有错,但下标、长度和取元素都是为了完成「遍历」而写的机械步骤。现代 C++ 提供了更多工具,帮助我们更简洁明确地表达意图。
基于范围的 for 循环
我们前面已经提到基于范围的 for 循环了。它可以遍历 std::array、std::vector 这些标准库容器。
如果元素是 int 等复制成本很低的简单值,可以直接按值读取:
for (int number : numbers) {
std::cout << number << '\n';
}只读地访问较大的对象时,可以使用 const 引用:
for (const auto &student : students) {
if (student.score < passing_score) {
std::cout << student.id << '\n';
}
}如果确实需要修改,使用普通引用:
for (auto &student : students) {
student.score += 5;
}表达数组操作意图
能不能继续简化呢?其实很多时候我们对数组的循环操作,是在完成某几种很常见的操作,例如查找、判断是否满足条件、对所有元素应用统一修改规则等等。
一些语言提供了很多工具用于操作数组,还能组合使用。例如 JavaScript:
const arr = [1, 2, 3, 4, 5];
arr.every((n) => n > 0);
// 检查是否所有元素都大于零,返回 true
arr.some((n) => n > 3);
// 检查是否存在元素大于 3,返回 true
arr.map((n) => n * 2);
// 产生新数组,其中每个值乘 2,返回 [2, 4, 6, 8, 10]
arr
.map((n) => n * 5) // => [5, 10, 15, 20, 25]
.filter((n) => n > 10) // => [15, 20, 25]
.some((n) => n < 20); // => true例如判断 60 分以下的学生:
const passing_score = 60;
const has_failed = students.some((s) => s.score < passing_score);类似地,C++ 标准库也提供了这样的工具:
#include <algorithm>
const auto passing_score{60};
const auto has_failed =
std::ranges::any_of(students, [passing_score](const auto &s) {
return s.score < passing_score;
});这里面出现了几个新东西,我们详细来说。
Lambda 表达式
要表达「想做什么function(x){ return x+1 } 或者 (x) => x+1 都可以「就地」定义一个小函数,用来临时做一些事情。
但我们知道,传统上,函数需要到最外层或者类中定义,函数体内本来是不能定义函数的,这就很麻烦了。因此,C++11 引入了 lambda 表达式用来「就地」定义函数。它以表达式的身份定义函数,可以直接作为参数传入,也能以变量形式保存,十分方便。例如:
auto double_it = [](int x) { return x * 2; };
std::cout << double_it(12); // 24Lambda 的完整语法为:
[capture](parameters) -> return_type { body }capture:需要捕获的外层变量parameters: 参数列表return_type: 返回值类型body: 函数体
捕获
Lambda 中的函数体默认不会持有外部变量!这与 JavaScript 等语言是不同的。
int foo{42};
const auto some_lambda = []() {
std::cout << foo;
};
// 'foo' is not captured捕获分为值捕获和引用捕获。值捕获即复制定义时的值,引用捕获即持有变量引用。在方括号中可以以如下方式捕获外部变量:
[=]值捕获所有用到的外部变量[&]引用捕获所有用到的外部变量[foo]值捕获foo[&foo]引用捕获foo
这些捕获形式可以用逗号 , 组合:
[foo, &bar]值捕获foo、引用捕获bar[=, &bar]引用捕获bar、值捕获其他用到的外部变量[&, foo]值捕获foo、引用捕获其他用到的外部变量=和&不能同时存在,例如[=, &]是非法的
考虑下面的程序:
int foo{42}, bar{37};
const auto some_lambda = [foo, &bar]() {
std::cout << foo << ", " << bar << '\n';
};
some_lambda(); // 42, 37
foo = 24;
bar = 73;
some_lambda(); // 42, 73可以看到值捕获的 foo 在外部修改之后打印的仍然是 42,但引用捕获的 bar 会打印出更新后的值。
语言差异之引用捕获与闭包
为什么要显式写出捕获列表呢?JavaScript 的函数同样会捕获外围词法环境中的绑定,只是这种捕获不需要写在函数语法中。C++ 则要求我们明确选择按值还是按引用捕获,让生命周期和修改行为直接体现在代码里。
我们在之前提到过作用域与生命周期的概念。JavaScript 函数可以连同它捕获的词法环境一起存活,即使创建它的那次函数调用已经结束,仍然可以通过闭包访问其中的绑定。例如:
function makeCounter() {
let value = 0;
return () => ++value;
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
const counterAlt = makeCounter();
console.log(counterAlt()); // 1
console.log(counterAlt()); // 2
console.log(counterAlt()); // 3
value = 0;
// Uncaught ReferenceError: value is not defined在这段示例中,makeCounter 返回的匿名函数 () => ++value 捕获了 value 所在的词法环境,因此即使 makeCounter 执行完毕,两个计数器仍各自保有一份状态。外部无法直接以 value 这个名字访问内部绑定,只能通过返回的函数执行操作。计数器和相应环境都不再可达后,垃圾回收器才可以回收它们。
如果在 C++ 中直接把这种隐式捕获理解成引用捕获,就会写出下面的错误代码:
auto make_counter() {
int value = 0;
return [&]() { return ++value; };
}
int main() {
const auto count{make_counter()};
std::cout << count() << '\n';
std::cout << count() << '\n';
std::cout << count() << '\n';
}这产生了悬空引用! 在这里,value 是自动变量,具有自动存储期,在函数 make_counter 运行结束之后结束生命周期。此时 lambda 表达式再访问 value,产生了 UB。在我的设备上,GCC 编译运行输出了三个 1,MSVC 则输出了 1、2、3。
一种看似可行的做法是将 value 声明为函数内的静态变量:static int value = 0;。这样程序确实可以运行,但这个函数只有一个共享的 value 对象,多次调用 make_counter 得到的计数器会操作同一份状态。这显然不符合我们的需求。
那在 C++ 中究竟要怎么实现这样的 make_counter 呢?答案是值捕获。
auto make_counter() {
int value = 0;
return [value]() mutable {
return ++value;
};
}
int main() {
auto count{make_counter()};
std::cout << count() << '\n'; // 1
std::cout << count() << '\n'; // 2
std::cout << count() << '\n'; // 3
auto countAlt{make_counter()};
std::cout << countAlt() << '\n'; // 1
std::cout << countAlt() << '\n'; // 2
std::cout << countAlt() << '\n'; // 3
}C++ lambda 提供了 mutable 关键字,允许 lambda 修改按值捕获的状态。此时闭包对象拥有一份自己的 value,它的生命周期跟随闭包对象,不会在 make_counter 运行完成时结束。
不过需要注意的是,使用 mutable 后,count 不能再用 const。要解释其中的原因,我们还需要更多的知识,涉及到 lambda 表达式的本质。我们会在后续章节详细介绍。
总结一下:JavaScript 运行时会在闭包仍然可达时保留相应的词法环境,并由垃圾回收系统管理其存储;C++ lambda 会产生一个闭包对象,按值捕获的内容成为闭包对象自身的状态,按引用捕获则仍然依赖外部对象继续存活。
参数列表与返回类型
Lambda 表达式的参数列表与一般函数的定义形式相同。也可以使用 auto 作为参数类型。
返回类型可以省去不写,默认为 auto,需要能够正确推断。
Lambda 本身的类型
Lambda 表达式作为一个表达式,其自身也有类型。每个 lambda 表达式都会生成一个独一无二的匿名类型,无法通过某个名字访问。因此一般使用 auto 处理它:
void do_something(auto callback) { callback(1239); }
do_something([](int x) {});
auto foo = []() { return 42; };
auto bar{[]() { return 37; }};范围算法
C++ 有一系列处理范围的工具,常见的包括:
| 目的 | 算法 |
|---|---|
| 查找一个元素 | find、find_if |
| 统计满足条件的元素 | count_if |
| 判断全部、任意或全部不满足 | all_of、any_of、none_of |
| 排序 | sort |
| 转换元素 | transform |
| 对每个元素执行操作 | for_each |
显式指明首尾:
#include <algorithm>
const bool has_failed = std::any_of(
students.begin(), students.end(),
[passing_score](const auto &s) { return s.score < passing_score; });或整个容器:
#include <algorithm>
const auto has_failed =
std::ranges::any_of(students, [passing_score](const auto &s) {
return s.score < passing_score;
});std::any_of 直接表达「是否存在满足条件的元素break 这些形式内容,直接使用 lambda 表达意图。C++20 引入了直接处理整个范围的 std::ranges::any_of,可以直接传入 students 本身。表中列出的其他算法也有对应的 ranges 版本。
这里着重介绍一下 std::sort。它默认从小到大排序,如果需要从大到小,可以使用系统提供的 std::greater{} 比较器:
#include <functional>
std::vector<int> arr{4, 5, 2, 1, 3};
std::ranges::sort(arr);
// 1, 2, 3, 4, 5
std::ranges::sort(arr, std::greater{});
// 5, 4, 3, 2, 1对于更复杂的情况,可以传入自己使用 lambda 定义的比较器。例如按分数从小到大排序:
std::ranges::sort(students, [](const Student &a, const Student &b) {
return a.score < b.score;
});需要注意这里排序函数的返回类型是 bool,其语义为「严格弱序compare(a, b) == false 表示「a 不应排在 b 前compare(a, b) 和 compare(b, a) 都为假时可以表示等价
C 风格的
qsort中,比较器返回整数,以正负性表达顺序。不要和这里记混了。
还可以进一步简化。std::ranges::sort 提供第三个参数:投影。可以使用 lambda 函数或成员指针,对元素执行预处理:
std::ranges::sort(students, {}, [](const auto &s) { return s.score; });
// 或
std::ranges::sort(students, {}, &Student::score);此时比较器就可以通过 {} 保留默认值(即从小到大std::ranges::greater{}。这样「比较顺序」与「提取规则」两件事便分开了,更方便也更易读。
介绍了这么多范围算法,也不意味着循环应该从代码中消失。复杂业务步骤用普通 for 往往更清楚。应该按可读性取舍,而不是某一种看起来更高级。
视图 views
C++20 还提供了一个强大的工具:std::views,用于处理、筛选数据。
例如,std::views::filter 可以生成一个只呈现不及格学生的视图:
#include <ranges>
auto failed_students =
students | std::views::filter([passing_score](const Student &student) {
return student.score < passing_score;
});
for (const Student &student : failed_students) {
std::cout << student.id << ' ' << student.score << '\n';
}另一个例子,求一个 vector 里所有数的平方:
std::vector<int> numbers{1, 2, 3, 4, 5};
auto squared_numbers =
numbers | std::views::transform([](const auto n) { return n * n; });
for (auto value : squared_numbers)
std::cout << value << ' '; // 1 4 9 16 25
for (auto value : numbers)
std::cout << value << ' '; // 1 2 3 4 5这里产生了 filter_view 和 transform_view。这两个示例中的 lambda 都没有修改元素,因此原始数据保持不变;view 本身并不保证只读,如果底层范围和处理函数允许,它也可能暴露可修改的元素或产生副作用。
它有三个重要特点:
轻量级
view 被设计成适合组合的轻量范围。标准要求 view 能在常量时间内移动;如果它支持复制,复制也应为常量时间。不过,这不表示 view 永远不拥有数据,也不表示创建任何 view 都不需要处理状态。
当 adaptor 接收本节这样的左值 std::vector 时,内部通常通过 ref_view 间接引用原容器,不会复制全部元素;当它接收合适的右值范围时,也可能通过 owning_view 接管并拥有这个范围。谓词、投影等处理函数同样会成为 view 的状态。
这也意味着这里的 failed_students 依赖原来的 students。如果底层容器已经销毁,或者修改容器使内部引用和迭代器失效,继续使用 view 就可能出错。view 是处理数据的窗口,不是默认拥有数据的新容器。
惰性求值
许多 view 采用惰性求值:创建 view 时不会立即完成所有筛选或变换,遍历到某个元素时才执行相应操作。例如循环提前 break 后,后续元素就不必继续计算。这在很多场景中可以避免不必要的工作。
不妨用前面计算平方的程序验证。加上更多输出语句:
std::vector<int> numbers{1, 2, 3, 4, 5};
std::cout << "Vector created\n";
auto squared_numbers = numbers | std::views::transform([](const auto n) {
std::cout << "Calculate " << n << "^2\n";
return n * n;
});
std::cout << "View created\n";
for (auto value : squared_numbers)
std::cout << value << '\n';输出为:
Vector created
View created
Calculate 1^2
1
Calculate 2^2
4
Calculate 3^2
9
Calculate 4^2
16
Calculate 5^2
25可以看到,在 view 创建之后,我们的 lambda 没有立即调用,而是在最后的输出循环中,每用到一个结果元素才会调用一次计算。
可组合
刚才的代码中,输入的容器并不是以参数形式传递的,而是使用操作符 |。而 view 本身也可以作为这些操作的输入,这意味着可以通过多个 | 将多个操作「串联」起来。如果你熟悉 shell 中的管道符 |,对此应该会感到很亲切。
#include <cctype>
const std::string hello{"hello"};
const auto result = hello | std::views::reverse |
std::views::transform([](unsigned char c) {
return static_cast<char>(std::toupper(c));
});
std::cout << (std::string(result.begin(), result.end())); // OLLEH这种编写范式消除了传统调用函数可能会写出的各种嵌套问题,使得代码更加简洁易懂。
小结
- 基于范围的 for 循环可以方便地完成遍历
- Lambda 表达式用于就地定义函数
- 关注值捕获与引用捕获的区别,当心引用捕获中的引用悬空
- 使用
auto处理其类型
- 使用范围算法快捷表达操作,减少繁琐循环结构
- 使用
std::views快捷完成数据筛选处理- 通常轻量,可以引用数据,也可能拥有传入的范围
- 许多 view 采用惰性求值,并且可以组合
- 是否能修改原始数据取决于底层范围和处理操作