Skip to content

1.3 Lambda 与 ranges

有了 std::vector,我们仍然可以使用熟悉的下标循环:

cpp
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::arraystd::vector 这些标准库容器。

如果元素是 int 等复制成本很低的简单值,可以直接按值读取:

cpp
for (int number : numbers) {
    std::cout << number << '\n';
}

只读地访问较大的对象时,可以使用 const 引用:

cpp
for (const auto &student : students) {
    if (student.score < passing_score) {
        std::cout << student.id << '\n';
    }
}

如果确实需要修改,使用普通引用:

cpp
for (auto &student : students) {
    student.score += 5;
}

表达数组操作意图

能不能继续简化呢?其实很多时候我们对数组的循环操作,是在完成某几种很常见的操作,例如查找、判断是否满足条件、对所有元素应用统一修改规则等等。

一些语言提供了很多工具用于操作数组,还能组合使用。例如 JavaScript:

js
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 分以下的学生:

ts
const passing_score = 60;

const has_failed = students.some((s) => s.score < passing_score);

类似地,C++ 标准库也提供了这样的工具:

cpp
#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 表达式

要表达「想做什么我们自然地想到函数。例如在 JavaScript 中,function(x){ return x+1 } 或者 (x) => x+1 都可以「就地」定义一个小函数,用来临时做一些事情。

但我们知道,传统上,函数需要到最外层或者类中定义,函数体内本来是不能定义函数的,这就很麻烦了。因此,C++11 引入了 lambda 表达式用来「就地」定义函数。它以表达式的身份定义函数,可以直接作为参数传入,也能以变量形式保存,十分方便。例如:

cpp
auto double_it = [](int x) { return x * 2; };

std::cout << double_it(12); // 24

Lambda 的完整语法为:

cpp
[capture](parameters) -> return_type { body }
  • capture:需要捕获的外层变量
  • parameters: 参数列表
  • return_type: 返回值类型
  • body: 函数体

捕获

Lambda 中的函数体默认不会持有外部变量!这与 JavaScript 等语言是不同的。

cpp
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、引用捕获其他用到的外部变量
  • =& 不能同时存在,例如 [=, &] 是非法的

考虑下面的程序:

cpp
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 函数可以连同它捕获的词法环境一起存活,即使创建它的那次函数调用已经结束,仍然可以通过闭包访问其中的绑定。例如:

js
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++ 中直接把这种隐式捕获理解成引用捕获,就会写出下面的错误代码:

cpp
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 呢?答案是值捕获。

cpp
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 处理它:

cpp
void do_something(auto callback) { callback(1239); }

do_something([](int x) {});
auto foo = []() { return 42; };
auto bar{[]() { return 37; }};

范围算法

C++ 有一系列处理范围的工具,常见的包括:

目的算法
查找一个元素findfind_if
统计满足条件的元素count_if
判断全部、任意或全部不满足all_ofany_ofnone_of
排序sort
转换元素transform
对每个元素执行操作for_each

显式指明首尾:

cpp
#include <algorithm>

const bool has_failed = std::any_of(
    students.begin(), students.end(),
    [passing_score](const auto &s) { return s.score < passing_score; });

或整个容器:

cpp
#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{} 比较器:

cpp
#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 定义的比较器。例如按分数从小到大排序:

cpp
std::ranges::sort(students, [](const Student &a, const Student &b) {
    return a.score < b.score;
});

需要注意这里排序函数的返回类型是 bool,其语义为「严格弱序compare(a, b) == false 表示「a 不应排在 b不表示 b 一定排在 a 前,compare(a, b)compare(b, a) 都为假时可以表示等价

C 风格的 qsort 中,比较器返回整数,以正负性表达顺序。不要和这里记混了。

还可以进一步简化。std::ranges::sort 提供第三个参数:投影。可以使用 lambda 函数或成员指针,对元素执行预处理:

cpp
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 可以生成一个只呈现不及格学生的视图:

cpp
#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 里所有数的平方:

cpp
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_viewtransform_view。这两个示例中的 lambda 都没有修改元素,因此原始数据保持不变;view 本身并不保证只读,如果底层范围和处理函数允许,它也可能暴露可修改的元素或产生副作用。

它有三个重要特点:

轻量级

view 被设计成适合组合的轻量范围。标准要求 view 能在常量时间内移动;如果它支持复制,复制也应为常量时间。不过,这不表示 view 永远不拥有数据,也不表示创建任何 view 都不需要处理状态。

当 adaptor 接收本节这样的左值 std::vector 时,内部通常通过 ref_view 间接引用原容器,不会复制全部元素;当它接收合适的右值范围时,也可能通过 owning_view 接管并拥有这个范围。谓词、投影等处理函数同样会成为 view 的状态。

这也意味着这里的 failed_students 依赖原来的 students。如果底层容器已经销毁,或者修改容器使内部引用和迭代器失效,继续使用 view 就可能出错。view 是处理数据的窗口,不是默认拥有数据的新容器。

惰性求值

许多 view 采用惰性求值:创建 view 时不会立即完成所有筛选或变换,遍历到某个元素时才执行相应操作。例如循环提前 break 后,后续元素就不必继续计算。这在很多场景中可以避免不必要的工作。

不妨用前面计算平方的程序验证。加上更多输出语句:

cpp
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 中的管道符 |,对此应该会感到很亲切。

cpp
#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 采用惰性求值,并且可以组合
    • 是否能修改原始数据取决于底层范围和处理操作