在 C++20 标准中,Ranges(范围)被引入为一种统一、强大且可组合的方式来处理序列数据。与传统的 STL 容器和算法相比,Ranges 提供了更直观的语法、更少的模板繁琐度,并且能够让我们用一种“管道式”的方式描述数据流。本文将从 Ranges 的核心概念入手,结合实际代码示例,演示如何利用 Ranges 进行高效、可维护的数据处理。
1. Ranges 的核心概念
1.1 范围(Range)
一个 Range 是一个可遍历的序列,它由两个迭代器组成:begin 和 end。在 C++20 中,标准库提供了 std::ranges::range 协议,任何满足 begin/end 语义且满足 std::input_iterator 的类型都可以被视为 Range。
#include <vector>
#include <iostream>
#include <ranges>
std::vector <int> vec{1, 2, 3, 4, 5};
if constexpr (std::ranges::range<std::vector<int>>) {
std::cout << "vec 是一个 Range\n";
}
1.2 视图(View)
视图是对 Range 的一种惰性变换,它不会立即生成新容器,而是延迟计算直到真正需要访问元素。视图可被链式组合,形成“管道”,类似于 Unix 的 pipe 或 LINQ 的链式查询。
auto even = std::views::filter([](int x){ return x % 2 == 0; });
auto doubled = std::views::transform([](int x){ return x * 2; });
for (int n : vec | even | doubled) {
std::cout << n << ' ';
}
1.3 容器(Container)
容器是具有完整存储能力的对象,如 std::vector、std::list 等。容器本身是 Range,但不是视图。我们可以将视图的结果直接收集到容器中:
auto result = vec | even | doubled | std::ranges::to<std::vector>();
1.4 算子(Algorithm)
在 Ranges 里,算法被分为两类:管道算法(std::ranges::for_each、std::ranges::transform 等)和 传统算法(std::sort、std::accumulate 等)。大多数传统算法都有 Ranges 版本,使用方式类似但可直接作用于 Range。
auto sum = std::ranges::accumulate(vec | even | doubled, 0);
2. Ranges 与传统 STL 的对比
| 任务 | 传统 STL 代码 | Ranges 代码 |
|---|---|---|
| 过滤偶数 | std::copy_if(vec.begin(), vec.end(), back_inserter(filtered), [](int x){return x%2==0;}); |
auto filtered = vec | std::views::filter([](int x){return x%2==0;}); |
| 变换乘以 2 | std::transform(vec.begin(), vec.end(), back_inserter(transformed), [](int x){return x*2;}); |
auto transformed = vec | std::views::transform([](int x){return x*2;}); |
| 组合过滤+变换 | 嵌套 copy_if + transform |
auto combined = vec | std::views::filter(...) | std::views::transform(...); |
| 计算和 | std::accumulate(vec.begin(), vec.end(), 0); |
std::ranges::accumulate(vec, 0); |
显而易见,Ranges 通过“管道”符号 | 将操作串联起来,代码更加简洁,且每一步都保持惰性,避免了中间容器的创建。
3. 具体案例:文本日志分析
假设我们有一组日志文件,每行记录一条事件,格式为 timestamp,level,message。我们想做以下分析:
- 只关注
ERROR级别的日志。 - 从时间戳中提取日期(YYYY-MM-DD)。
- 统计每天出现错误的次数。
使用 Ranges 可以在一行代码中完成:
#include <fstream>
#include <sstream>
#include <string>
#include <unordered_map>
#include <vector>
#include <ranges>
#include <iostream>
int main() {
std::ifstream file("log.txt");
if (!file) {
std::cerr << "Cannot open log file\n";
return 1;
}
// 用 std::ranges::istream_view 读取文件行
auto lines = std::ranges::istream_view<std::string>(file);
// 处理管道
auto error_dates = lines
| std::views::filter([](const std::string& line){
std::istringstream ss(line);
std::string ts, level, msg;
std::getline(ss, ts, ',');
std::getline(ss, level, ',');
// 只取 ERROR
return level == "ERROR";
})
| std::views::transform([](const std::string& line){
std::istringstream ss(line);
std::string ts, level, msg;
std::getline(ss, ts, ',');
// 取前 10 字符即日期
return ts.substr(0, 10);
});
// 统计
std::unordered_map<std::string, int> counts;
for (const auto& date : error_dates) {
++counts[date];
}
// 输出结果
for (auto [date, cnt] : counts) {
std::cout << date << ": " << cnt << " errors\n";
}
}
代码说明
std::ranges::istream_view将输入流视为可遍历的 Range,每次迭代返回一行字符串。filter只保留ERROR级别的行。transform把每行字符串映射为日期字符串。- 最后使用普通的
for循环累加计数。我们也可以直接用std::ranges::for_each。
4. 性能考虑
4.1 惰性求值
视图是惰性的,意味着它们不会立即执行任何操作。只有当你真正遍历 Range 时,管道中的每一步才会被执行。与一次性生成完整容器相比,惰性求值可以显著降低内存占用,尤其在链式复杂操作时。
4.2 减少拷贝
传统 STL 的 std::transform 等函数需要在调用时提供输出容器,往往导致不必要的拷贝。通过视图链式组合,所有变换在同一次遍历中完成,只有最终结果才被收集。
4.3 编译器优化
现代编译器对 Ranges 的实现做了大量内联和循环合并优化。例如,std::views::filter 与 std::views::transform 在同一次循环中可以合并,避免多次遍历。
5. 常见陷阱与最佳实践
- 过度使用视图:如果你需要多次遍历同一 Range,建议先收集到容器中;视图只在单次遍历时高效。
- 自定义视图:使用
std::ranges::subrange或std::ranges::ref_view可以创建自己的视图,保持惰性。 - 避免在视图中使用非惰性函数:例如
std::vector::push_back在视图中会被立即执行,破坏惰性。 - 使用
std::ranges::to:C++23 引入的to可以简化收集到容器的过程,C++20 用户可自实现。
6. 结语
C++20 Ranges 让我们能够用更接近自然语言的方式描述数据处理流程。它将迭代器、算法和容器的责任拆分,提供了更高层次的抽象。无论是简单的过滤、映射,还是复杂的日志分析,Ranges 都能让代码更简洁、更易读。只要掌握好惰性求值和视图链式组合的原则,就能在保持可维护性的同时,获得不错的性能。祝你在 C++20 的 Ranges 世界中玩得开心!