K KASS 返回文章列表
公开文章

READING APPEARANCE

选择阅读主题

选择会保存在当前设备,下次阅读自动沿用。

C++ / 2026-08-01

CS106L 第 11 讲:函数与 Lambda

把行为交给算法,理解函数指针、Lambda、函数对象与标准算法。

CS106L 第 11 讲:函数与 Lambda 的封面
C++ · CLASS-C

把“行为”交给算法

本课程依据 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::vectorstd::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]
          ×    └───────────────┘   ×
                 实际搜索范围

结果是下标 2106,而不是下标 0106

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)

算法不再亲自决定什么叫“符合要求”。

它只负责:

  1. 遍历;
  2. 把每个元素交给 pred
  3. 检查返回值;
  4. 遇到第一个 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::stringstd::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 很慢或不能使用,而是不要在并不需要类型擦除时无条件套上一层包装。


练习:应该选择哪一种形式

下面三种需求分别更适合使用什么?

  1. 在一次 std::find_if 调用中临时判断数字是否小于用户输入值。
  2. 已经存在一个无状态函数 bool is_even(int),只需要传给算法。
  3. 类的成员需要保存一个未来可以被替换的回调,可能是普通函数,也可能是带捕获 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 标准库算法覆盖的常见任务

标准库还提供:

  • 二分查找;
  • 最小值与最大值;
  • 合并;
  • 集合并集、交集和差集;
  • 分区;
  • 堆操作;
  • 打乱;
  • 抽样;
  • 删除;
  • 替换;
  • 旋转;
  • 反转;
  • 全部满足、任意满足、没有满足;
  • 字典序比较。

学习标准算法的重点不是背下所有名称,而是形成一种习惯:

写循环之前,先判断这是不是一个已经存在的通用算法。


练习:选择合适的标准算法

下面的需求分别适合使用什么算法?

  1. 找到第一个负数。
  2. 统计字符串中元音数量。
  3. 把所有整数乘以 2,写入另一个容器。
  4. 把所有偶数复制到另一个容器。
  5. 根据学生成绩排序。

答案与解释

  1. 第一个负数:
std::find_if
  1. 元音数量:
std::count_if
  1. 每个整数乘以 2:
std::transform
  1. 复制所有偶数:
std::copy_if
  1. 根据成绩排序:
std::sort

对应谓词或操作的参数数量也不同:

find_if    -> 一元谓词
count_if   -> 一元谓词
transform  -> 一元操作
copy_if    -> 一元谓词
sort       -> 二元比较

十八、综合案例:用算法构建 Tokenizer

18.1 我们想解决什么问题

Tokenizer 的任务是把一句字符串拆成多个词元。

输入:

Breaking down the string

输出:

Breaking
down
the
string

如果直接手写一个复杂循环,需要同时处理:

  • 当前字符位置;
  • 单词开始位置;
  • 空格;
  • 连续多个空格;
  • 字符串结尾;
  • 空字符串;
  • 是否已经在单词内部。

逻辑很容易缠在一起。

课件采用的思路是先分解问题:

  1. 找出所有分隔位置;
  2. 把相邻分隔位置转换成 Token;
  3. 删除连续空格产生的空 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>

也就是一组迭代器。

为什么加入 firstlast

因为仅有空格位置还不够。第一个 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'
};

目标:

  1. 只保留元音;
  2. 把元音转换成大写。

传统算法可能先生成一个中间容器:

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 findfind_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;
};

请完成以下任务:

  1. 用户给出最大里程 max_miles
  2. 找到第一条同时满足以下条件的路线:
    • 有瀑布;
    • 长度不超过 max_miles
  3. 统计符合条件的路线数量;
  4. 按路线长度升序排序;
  5. 使用 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 表示排序时提取每个 Trailmiles 成员。第二个参数 {} 使用默认的小于比较。

可以把它理解为简化了下面的 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 时:

  1. 从已经排序的 trails 中寻找符合条件的路线;
  2. 对符合条件的路线提取 name
  3. 将名称交给范围 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 语法,而是开始把程序看成两部分:

稳定的算法结构
        +
由调用者提供的可变行为

当这两部分被分离后,同一套算法便能够服务于大量不同问题。