C++20 中最具变革性的特性之一:范围库 (Ranges Library),彻底改变了我们与标准模板库(STL)算法交互的方式,使代码更具表现力、更简洁、也更安全。
1. C++20 之前的问题:迭代器
在 C++20 之前,几乎所有的 STL 算法都依赖于一对迭代器 (begin
, end
) 来指定要操作的元素序列。这种设计虽然灵活,但存在诸多痛点:
- 冗长和重复:每次调用算法,你都需要传递
container.begin()
和container.end()
。这非常啰嗦。 - 容易出错:很容易意外地传递不匹配的迭代器对(例如,一个来自
vector1
的begin
和一个来自vector2
的end
),这会导致未定义行为。 - 可读性差:当多个算法串联使用时,代码会变成一堆嵌套的函数调用,逻辑是从内到外阅读,非常不直观。
- 需要临时容器:为了存储中间结果,常常需要创建临时的容器,这既浪费内存也影响性能。
示例: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;
}
这个过程非常繁琐,而且创建了两个不必要的临时向量 evens
和 squares
。
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;
}
运行步骤
- 我们调用了
std::ranges::max_element
。 - 我们把
students
这个范围传给了它。 - 我们把
&Student::score
作为投影传给了它。 - 算法在内部是这样工作的:
- 它从
students
中取出第一个元素s1
(Alice)。 - 它对
s1
应用投影&Student::score
,得到了85
。 - 它取出第二个元素
s2
(Bob)。 - 它对
s2
应用投影&Student::score
,得到了92
。 - 现在,它执行它的本职工作:比较。它比较的不是
s1
和s2
这两个复杂的对象,而是投影后的结果:85
和92
。 - 它发现
85 < 92
,所以它认为s2
(Bob)是目前为止最大的。 - 它会继续这个过程,直到找到最终的最大元素。
- 它从
投影的优势总结
- 意图更清晰:
&Student::score
非常明确地表达了“我们关心的是分数”。代码的可读性大大提高。 - 代码更简洁:用一个简单的成员指针代替了一个完整的、需要写明参数和返回值的 lambda 表达式。
- 更强的通用性:你可以把同一个投影用在不同的算法上。比如,你可以用
&Student::score
来sort
(按分数排序),或者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;
}
代码解读:
numbers | std::views::filter(...)
:将numbers
向量“管道输入”到filter
视图中。filter
会创建一个只包含偶数的新视图。... | std::views::transform(...)
:将上一步filter
的结果,再次“管道输入”到transform
视图中。transform
会创建一个视图,其中每个元素都是前一个视图中元素的平方。- 整个过程是惰性的。在
for
循环之前,没有进行任何筛选或平方计算,也没有分配任何新的内存。 - 当
for
循环请求第一个元素时,ranges
会从numbers
中取出1
(不满足filter),然后取出2
(满足filter),对其求平方得到4
,然后将其返回。当请求第二个元素时,它会继续这个过程,直到遍历完所有元素。