32、C++20 核心特性:范围(Ranges)库,声明式编程处理集合
说实话,我第一次看到 Ranges 库的时候,心里想的是:「又来了,C++ 又要加新语法了。」
但真正用起来之后,我发现这东西确实不一样。它让处理集合的方式,从「怎么干」变成了「要什么」。说白了,就是声明式编程。
为什么需要 Ranges?
你想想看,以前我们处理一个 vector,要写多少循环?
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::vector<int> result;
for (auto x : v) {
if (x % 2 == 0) {
result.push_back(x * 2);
}
}
这段代码没什么问题,但读起来你得在脑子里「模拟执行」一遍。我有个同事,每次看到这种嵌套逻辑就头疼,尤其是循环里套循环的时候。
Ranges 要解决的就是这个问题。它让你用管道操作符 | 把操作串起来,像流水线一样。
auto result = v
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * 2; });
看到区别了吗?代码从左到右读,意思一目了然:先过滤出偶数,再每个乘以2。没有临时变量,没有循环,没有 push_back。
核心思想:Ranges 把「数据」和「操作」分开了。数据是集合,操作是视图(views)。视图不拥有数据,只是对数据的「视角」。
视图(Views)—— 懒求值的核心
Ranges 里最重要的概念就是视图。视图不会复制数据,也不会立即计算。它只是记录了你想要做什么,等到真正需要结果的时候才执行。
我刚开始学的时候,总觉得这有点玄乎。直到我在项目中处理一个 100 万条记录的日志文件,才真正体会到它的好处。
std::vector<std::string> logs = load_huge_log_file(); // 100万条
// 视图:只定义操作,不执行
auto error_logs = logs
| std::views::filter([](const std::string& s) {
return s.find("ERROR") != std::string::npos;
})
| std::views::take(10); // 只要前10条
// 真正遍历时才执行
for (const auto& log : error_logs) {
std::cout << log << "\n";
}
这里有个关键点:take(10) 意味着一旦找到 10 条错误日志,后面的 999990 条根本不会遍历。这在传统写法里,你得手动加 break 才能做到。
小技巧:视图是轻量级的,你可以随意组合。我习惯把常用的视图组合封装成函数,比如 auto get_active_users() 返回一个视图,而不是一个 vector。
常用视图一览
Ranges 提供了几十种视图,但日常开发中常用的就那么几个。我列个表,方便你快速查阅。
| 视图 | 作用 | 示例 |
|---|---|---|
filter |
按条件过滤 | v | filter(pred) |
transform |
映射转换 | v | transform(fn) |
take |
取前 N 个 | v | take(5) |
drop |
跳过前 N 个 | v | drop(5) |
reverse |
反转顺序 | v | reverse |
join |
展平嵌套集合 | v | join |
keys |
取 map 的 key | m | keys |
values |
取 map 的 value | m | values |
注意:视图是懒求值的,但如果你把视图赋值给 auto 变量,要小心悬空引用。我曾经在项目里踩过这个坑——视图引用的临时对象被销毁了,结果访问时崩溃。
范围适配器与管道操作
管道操作符 | 是 Ranges 的语法糖。它让代码像 Unix 管道一样,数据从左到右流动。
auto result = numbers
| std::views::filter([](int n) { return n > 0; })
| std::views::transform([](int n) { return n * n; })
| std::views::take(3);
这段代码的意思是:从 numbers 中取出正数,平方,然后取前 3 个。每一步都是独立的,你可以随意增删或调整顺序。
我个人习惯把这种写法叫做「声明式流水线」。它比传统的 for 循环更容易维护,因为每个操作都是独立的函数对象,可以单独测试。
动作(Actions)—— 就地修改
视图是只读的,不会修改原数据。但有时候我们需要就地修改集合。这时候就要用动作(Actions)。
std::vector<int> v = {5, 2, 8, 1, 9, 3};
// 动作:就地排序并去重
std::ranges::sort(v);
auto [first, last] = std::ranges::unique(v);
v.erase(first, last);
// 用 Ranges 动作的写法(C++23 更完善,但 C++20 已有部分)
// 注意:C++20 的 actions 还不完整,需要等 C++23
嗯,这里要说明一下。C++20 的 Ranges 主要聚焦在视图和算法上。动作(Actions)在 C++23 中才得到完善。但核心思想是一样的:用声明式的方式描述「你要对集合做什么」。
实战:从日志中提取错误信息
我在之前的一个项目中,需要从海量日志中提取特定时间段的错误信息。用传统写法,代码又长又乱。用 Ranges 就清爽多了。
struct LogEntry {
std::chrono::system_clock::time_point timestamp;
std::string level; // "INFO", "WARN", "ERROR"
std::string message;
};
std::vector<LogEntry> logs = load_logs();
auto recent_errors = logs
| std::views::filter([](const LogEntry& e) {
return e.level == "ERROR";
})
| std::views::filter([](const LogEntry& e) {
return e.timestamp >= one_hour_ago;
})
| std::views::transform([](const LogEntry& e) {
return e.message;
})
| std::views::take(20);
for (const auto& msg : recent_errors) {
std::cout << msg << "\n";
}
你看,代码读起来就像在描述需求:「给我最近一小时的错误日志,只要消息内容,取前 20 条。」没有索引,没有临时容器,没有 break 判断。
Ranges 知识体系
下面这张图帮你理清 Ranges 的核心概念和关系。
避坑指南
我曾经在项目里犯过一个低级错误。当时我用 std::views::filter 过滤一个临时 vector,结果视图还没用,vector 就被销毁了。
// 错误示例
auto get_evens() {
std::vector<int> v = {1, 2, 3, 4, 5};
return v | std::views::filter([](int n) { return n % 2 == 0; });
// v 在这里被销毁,返回的视图悬空了!
}
// 正确做法:返回 vector 或确保视图引用的对象存活
std::vector<int> get_evens() {
std::vector<int> v = {1, 2, 3, 4, 5};
auto filtered = v | std::views::filter([](int n) { return n % 2 == 0; });
return std::vector<int>(filtered.begin(), filtered.end());
}
嗯,这里要注意:视图不拥有数据,它只是「看」数据。如果数据没了,视图就废了。
什么时候用 Ranges?
我个人觉得,Ranges 最适合的场景是:
- 数据流水线处理:过滤、映射、排序、截取一条龙
- 懒加载场景:数据量大,只需要前几条结果
- 代码可读性要求高:团队协作时,声明式代码更容易理解
- 避免临时容器:视图不复制数据,节省内存
但也不是所有场景都适合。比如简单的 for 循环,或者性能极其敏感的内层循环,传统写法可能更直接。
总结一下:Ranges 让 C++ 处理集合的方式更接近现代编程语言。它不复杂,但需要转变思维——从「怎么遍历」到「要什么结果」。一旦你习惯了这种声明式写法,就很难回去了。
公众号:蓝海资料掘金营,微信deep3321