25、C++20 范围库(Ranges):视图与动作、管道操作符、与 STL 算法的对比、惰性求值
说实话,C++20 的范围库是我这几年用得最爽的特性之一。以前写 STL 算法,总觉得哪里别扭——明明是想表达「过滤、转换、取前 N 个」,结果代码却写成了一堆嵌套的 begin() 和 end()。Ranges 的出现,说白了就是让代码更贴近人的思维,而不是机器的思维。
我个人习惯把 Ranges 理解为「给 STL 算法穿上了组合的外衣」。它没有发明全新的算法,而是提供了一套组合机制,让算法可以像管道一样串联起来。嗯,咱们今天就来好好聊聊这个。
视图(Views):不拥有数据,只描述变换
视图是 Ranges 的核心概念之一。它不拷贝数据,也不修改数据,只是「描述」如何查看数据。你想想看,这就像给数据戴上了一副滤镜——数据本身没变,但你看到的是经过筛选或转换后的样子。
我在项目中遇到过这样一个场景:有一个很大的 vector<Employee>,需要找出所有工龄超过 5 年的员工,然后只取他们的名字。以前我会写一个循环,再建一个临时 vector。现在用视图,一行搞定:
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
struct Employee {
std::string name;
int years_of_service;
};
int main() {
std::vector<Employee> employees = {
{"Alice", 7}, {"Bob", 3}, {"Charlie", 10}, {"Diana", 2}
};
// 视图:过滤 + 转换,不产生临时容器
auto senior_names = employees
| std::views::filter([](const Employee& e) {
return e.years_of_service > 5;
})
| std::views::transform([](const Employee& e) {
return e.name;
});
for (const auto& name : senior_names) {
std::cout << name << " ";
}
// 输出:Alice Charlie
}
关键点:视图是轻量级的。它不分配内存,不拷贝元素。遍历时,每个元素「流过」视图链,被实时处理。
动作(Actions):真正动手修改容器
视图是「只看不动」,那动作就是「真刀真枪」地改数据了。动作会直接修改容器本身,比如排序、去重、删除等。
我记得有一次写日志处理程序,需要把一批乱序的日志按时间戳排序,然后去掉重复条目。用动作写起来特别自然:
#include <ranges>
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> data = {5, 2, 8, 2, 1, 5, 3};
// 动作:排序 + 去重,直接修改 data
data |= std::ranges::actions::sort
| std::ranges::actions::unique;
for (int x : data) {
std::cout << x << " ";
}
// 输出:1 2 3 5 8
}
注意:动作会修改原始容器。如果你不想破坏原始数据,请先拷贝一份。我曾经在代码审查时看到有人直接用动作修改了共享数据,结果引发了诡异的并发 bug——嗯,教训深刻。
管道操作符 |:组合的艺术
管道操作符 | 是 Ranges 最优雅的设计之一。它把数据流和处理步骤清晰地分开了。你从左到右读代码,就是数据流动的方向。
我个人觉得,这比嵌套函数调用好理解太多了。你对比一下:
| 传统 STL 写法 | Ranges 管道写法 |
|---|---|
|
|
看到区别了吗?传统写法里,你得手动管理中间容器 v2,还得小心翼翼地维护迭代器范围。管道写法直接说「过滤偶数,然后翻倍」,代码就是你的意图。
惰性求值:不等到用的时候,绝不干活
这是 Ranges 最让我惊喜的特性。视图是惰性的——你构建了一个视图链,它并不会立即执行任何操作。只有当你开始遍历(比如用 for 循环,或者传给 std::ranges::for_each)时,计算才真正发生。
为什么会这样?因为视图本质上是一个「配方」,而不是「成品」。你想想看,如果你要做一个蛋糕,视图就是食谱,而遍历才是真正动手做蛋糕的过程。
我在项目中用这个特性优化过一个性能问题。当时有个数据处理流水线,需要对百万级的数据做多层过滤和转换。如果用传统方式,每一步都会生成一个临时容器,内存开销巨大。换成视图后,数据流就像水管一样,每个元素依次流过所有处理步骤,内存占用几乎为零。
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> big_data(1000000);
// 假设 big_data 已填充数据
// 视图链:惰性,不执行任何实际计算
auto pipeline = big_data
| std::views::filter([](int x) { return x > 0; })
| std::views::transform([](int x) { return x * 2; })
| std::views::take(10);
// 只有在这里遍历时,才真正执行过滤和转换
for (int val : pipeline) {
std::cout << val << " ";
}
}
小技巧:如果你只需要前 N 个结果,用 views::take 可以提前终止遍历。配合惰性求值,性能提升非常明显。我曾经把一个 O(n) 空间复杂度的流水线降到了 O(1)。
与 STL 算法的对比:声明式 vs 命令式
说白了,STL 算法是「命令式」的——你得告诉它每一步怎么做:从哪里开始、到哪里结束、结果放哪里。Ranges 是「声明式」的——你只需要描述你想要什么,细节由库帮你处理。
我整理了一个对比表,方便你快速理解:
| 维度 | 传统 STL 算法 | C++20 Ranges |
|---|---|---|
| 组合方式 | 嵌套调用,代码从内向外读 | 管道操作符,代码从左向右读 |
| 中间容器 | 通常需要手动创建 | 视图自动处理,无需中间容器 |
| 求值时机 | 立即求值 | 惰性求值(视图) |
| 迭代器 | 需要显式传入 begin/end | 直接传入容器或范围 |
| 可读性 | 中等,嵌套多时难读 | 高,流水线式表达 |
不过我得说一句公道话:Ranges 并不是要取代 STL 算法。对于简单的单步操作,传统算法依然简洁高效。Ranges 的优势在于组合——当你需要把多个操作串联起来时,它的威力才真正体现出来。
嗯,关于 Ranges 的核心内容就聊到这里。视图让你「只看不动」,动作让你「真改数据」,管道操作符让组合变得优雅,惰性求值则带来了性能上的惊喜。我个人建议,新项目里可以大胆用 Ranges,老项目里可以逐步引入——先从简单的视图替换开始,慢慢你就会爱上这种写法。
公众号:蓝海资料掘金营,微信deep3321