C++20的范围库

C++20 中最具变革性的特性之一:范围库 (Ranges Library),彻底改变了我们与标准模板库(STL)算法交互的方式,使代码更具表现力、更简洁、也更安全。

1. C++20 之前的问题:迭代器

在 C++20 之前,几乎所有的 STL 算法都依赖于一对迭代器 (begin, end) 来指定要操作的元素序列。这种设计虽然灵活,但存在诸多痛点:

  • 冗长和重复:每次调用算法,你都需要传递 container.begin()container.end()。这非常啰嗦。
  • 容易出错:很容易意外地传递不匹配的迭代器对(例如,一个来自 vector1begin 和一个来自 vector2end),这会导致未定义行为。
  • 可读性差:当多个算法串联使用时,代码会变成一堆嵌套的函数调用,逻辑是从内到外阅读,非常不直观。
  • 需要临时容器:为了存储中间结果,常常需要创建临时的容器,这既浪费内存也影响性能。

示例:C++17 中一个简单的任务 假设我们有一个整数向量,我们想筛选出其中的偶数,然后将它们的平方打印出来。

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8};

    // "旧"方法:通常需要一个循环或者一个临时容器
    std::vector<int> evens;
    std::copy_if(numbers.begin(), numbers.end(), std::back_inserter(evens),
                 [](int n) { return n % 2 == 0; });

    std::vector<int> squares;
    std::transform(evens.begin(), evens.end(), std::back_inserter(squares),
                   [](int n) { return n * n; });

    for (int square : squares) {
        std::cout << square << " "; // 输出: 4 16 36 64
    }
    std::cout << std::endl;
}

这个过程非常繁琐,而且创建了两个不必要的临时向量 evenssquares

2. Ranges 的核心概念

Ranges 库通过引入几个核心概念来解决上述所有问题。

a. 范围 (Range)

一个“范围”是对任何可迭代序列的抽象。它不再是一对迭代器,而是一个单一的对象。容器(如 std::vector, std::list)、C 风格数组,以及接下来要讲的“视图”都是范围。

现在,你可以直接将整个容器传递给算法:

#include <vector>
#include <ranges> // C++20 中新的头文件
#include <iostream>

int main() {
    std::vector<int> numbers = {8, 2, 7, 4, 1, 5};
    std::ranges::sort(numbers); // 直接传递容器,不再需要 .begin() 和 .end()
    for(int n : numbers) {
        std::cout << n << " "; // 输出: 1 2 4 5 7 8
    }
}

b. 视图 (View)

这是 Ranges 库的精髓所在。一个视图 (std::view) 是:

  • 轻量级的:它本身不拥有数据,只是引用了底层范围的数据。复制一个视图的成本非常低。
  • 懒加载 (Lazy):视图上的操作(如筛选、转换)不会立即执行。它们只在迭代结果时“按需”计算。这避免了创建临时容器,极大地提升了性能。
  • 可组合的 (Composable):多个视图可以链接在一起,形成一个数据处理流水线。

c. 投影 (Projection)

大多数 ranges 算法都接受一个额外的参数叫做“投影”。它是一个函数,在算法执行其核心逻辑之前,会先应用在每个元素上。这使得我们可以根据对象的某个成员进行操作,而无需编写复杂的自定义 lambda。

在 C++ Ranges 库中,投影是一个可调用对象(通常是一个 lambda 表达式或者一个成员指针),你把它传递给一个算法(如 sort, find, max_element 等)。

这个投影告诉算法:“嘿,在对我给你的序列做你的本职工作(比如比较、查找)之前,请先对每个元素调用我(这个投影),然后用我返回的结果去做你的工作。”

它就像给算法戴上了一副特殊的眼镜,让算法只看到你希望它看到的数据的“某个部分”或“某个派生值”。

一个具体的代码对比

让我们用代码来直观地感受一下。假设我们有一个 struct 代表一个学生:

struct Student {
    std::string name;
    int score;
};

我们的任务是:找到分数最高的学生

方法一:没有投影的“旧”方法 (C++17)

在没有投影概念的时代,我们需要提供一个自定义的比较函数,这个函数告诉算法如何从两个 Student 对象中判断哪个“更小”。

#include <iostream>
#include <vector>
#include <algorithm>
#include <string>

// ... Student struct 定义 ...

int main() {
    std::vector<Student> students = {{"Alice", 85}, {"Bob", 92}, {"Charlie", 78}};

    // 我们需要提供一个完整的比较逻辑
    auto it = std::max_element(students.begin(), students.end(),
        [](const Student& a, const Student& b) {
            return a.score < b.score; // 核心:手动比较两个对象的 score 成员
        });

    std::cout << "The student with the highest score is: " << it->name << std::endl;
}

这里的 lambda [](const Student& a, const Student& b) { return a.score < b.score; } 写起来很繁琐,而且我们只是想比较 score 而已。

方法二:使用 Ranges 和投影的“新”方法 (C++20)

有了投影,我们可以直接告诉算法:“你只需要看 score 就行了”。

#include <iostream>
#include <vector>
#include <algorithm> // ranges 算法也在这里
#include <string>
#include <ranges>    // 引入 ranges

// ... Student struct 定义 ...

int main() {
    std::vector<Student> students = {{"Alice", 85}, {"Bob", 92}, {"Charlie", 78}};

    // 使用投影!
    auto it = std::ranges::max_element(students, {}, &Student::score);
                                             //  ^  ^
                                             //  |  |
                                             //  |  这就是投影:一个指向成员的指针
                                             //  |
                                             //  (这是一个空的比较器,因为算法会用默认的 < 对投影后的结果进行比较)

    std::cout << "The student with the highest score is: " << it->name << std::endl;
}

运行步骤

  1. 我们调用了 std::ranges::max_element
  2. 我们把 students 这个范围传给了它。
  3. 我们把 &Student::score 作为投影传给了它。
  4. 算法在内部是这样工作的:
    • 它从 students 中取出第一个元素 s1(Alice)。
    • 它对 s1 应用投影 &Student::score,得到了 85
    • 它取出第二个元素 s2(Bob)。
    • 它对 s2 应用投影 &Student::score,得到了 92
    • 现在,它执行它的本职工作:比较。它比较的不是 s1s2 这两个复杂的对象,而是投影后的结果:8592
    • 它发现 85 < 92,所以它认为 s2(Bob)是目前为止最大的。
    • 它会继续这个过程,直到找到最终的最大元素。

投影的优势总结

  1. 意图更清晰&Student::score 非常明确地表达了“我们关心的是分数”。代码的可读性大大提高。
  2. 代码更简洁:用一个简单的成员指针代替了一个完整的、需要写明参数和返回值的 lambda 表达式。
  3. 更强的通用性:你可以把同一个投影用在不同的算法上。比如,你可以用 &Student::scoresort(按分数排序),或者 find(查找某个分数的学生)。

3. “管道”操作符 (|)

Ranges 库最直观、最强大的特性就是引入了管道操作符 (|)。它允许我们将一个范围和一系列视图适配器 (View Adaptors) 串联起来,形成一个清晰的、从左到右的数据处理流。

这让代码的写法从命令式(“怎么做”)转变为声明式(“做什么”)。

4. 用 Ranges 重写示例

用 Ranges 的方式来完成前面那个“筛选偶数并求平方”的任务,代码如下。

#include <iostream>
#include <vector>
#include <ranges>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8};

    // "新"方法:使用视图和管道
    auto results = numbers
                 | std::views::filter([](int n) { return n % 2 == 0; })
                 | std::views::transform([](int n) { return n * n; });

    // 此时,没有任何计算发生
    // `results` 只是一个描述了操作流程的视图对象。

    // 当我们开始迭代时,计算才会按需进行
    for (int result : results) {
        std::cout << result << " "; // 输出: 4 16 36 64
    }
    std::cout << std::endl;
}

代码解读:

  1. numbers | std::views::filter(...):将 numbers 向量“管道输入”到 filter 视图中。filter 会创建一个只包含偶数的新视图。
  2. ... | std::views::transform(...):将上一步 filter 的结果,再次“管道输入”到 transform 视图中。transform 会创建一个视图,其中每个元素都是前一个视图中元素的平方。
  3. 整个过程是惰性的。在 for 循环之前,没有进行任何筛选或平方计算,也没有分配任何新的内存。
  4. for 循环请求第一个元素时,ranges 会从 numbers 中取出 1(不满足filter),然后取出 2(满足filter),对其求平方得到 4,然后将其返回。当请求第二个元素时,它会继续这个过程,直到遍历完所有元素。