第18章:范围库(Ranges):视图、适配器、管道操作符、延迟求值
说实话,C++20 引入的范围库(Ranges)是我这几年最期待的特性之一。为什么?因为以前写 STL 算法时,那种嵌套迭代器的写法实在太折磨人了。你想想看,一个简单的过滤加转换操作,得写多少层 begin() 和 end()?
范围库的出现,说白了就是让我们能用更自然的方式表达数据处理的流水线。它把「数据在哪里」和「怎么处理数据」这两件事分开了。我个人习惯用管道操作符 | 来串联多个操作,读起来就像在描述一个流程,而不是在写一堆嵌套的函数调用。
18.1 视图(View)——不拥有数据,只描述操作
视图是范围库的核心概念。它不拷贝数据,也不修改原始数据,只是描述「我想怎么看这些数据」。嗯,这里要注意:视图是轻量级的,创建视图的开销几乎可以忽略不计。
核心要点:视图不拥有元素,它只是对底层范围的引用或变换。视图的生命周期不能超过它所引用的范围。
我在项目中遇到过这样一个场景:需要从一个包含 10 万个整数的 vector 中取出前 50 个偶数,然后对它们做平方运算。如果用传统方式,我得先拷贝一份数据,过滤,再取前 50 个,最后做变换。这中间产生了多少临时容器?浪费了多少内存?
用范围库的视图,代码就清爽多了:
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 视图:过滤出偶数,取前3个,然后平方
auto result = data
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::take(3)
| std::views::transform([](int n) { return n * n; });
for (int v : result) {
std::cout << v << " "; // 输出:4 16 36
}
return 0;
}
看到了吗?没有临时 vector,没有多余的拷贝。整个流水线只在最后遍历时才真正执行计算。这就是延迟求值的威力。
18.2 适配器(Adapter)——视图的构建工具
适配器就是那些能产生视图的函数。它们接收一个范围,返回一个新的视图。标准库提供了很多现成的适配器,比如 filter、transform、take、drop、reverse 等等。
| 适配器 | 作用 | 示例 |
|---|---|---|
filter(pred) |
保留满足谓词的元素 | v | filter(is_even) |
transform(f) |
对每个元素应用函数 | v | transform(square) |
take(n) |
取前 n 个元素 | v | take(5) |
drop(n) |
跳过前 n 个元素 | v | drop(3) |
reverse |
反转顺序 | v | reverse |
keys |
取 pair 的第一个元素 | map | keys |
values |
取 pair 的第二个元素 | map | values |
小技巧:适配器可以组合使用。我经常把 filter 和 transform 连在一起用,先过滤再变换,效率更高。因为过滤掉不需要的元素后,变换操作的次数就减少了。
18.3 管道操作符(|)——数据流的优雅表达
管道操作符 | 是范围库的语法糖。它把左操作数(一个范围)传递给右操作数(一个适配器),返回一个新的视图。多个 | 可以串联,形成一条处理流水线。
我曾经在代码审查中看到有人这样写:
// 不推荐:嵌套调用,可读性差
auto result = std::views::transform(
std::views::filter(data, [](int n) { return n > 0; }),
[](int n) { return n * 2; }
);
这种写法读起来太费劲了。你得从最内层开始往外读,才能理解整个处理流程。用管道操作符改写后:
// 推荐:管道风格,从左到右读
auto result = data
| std::views::filter([](int n) { return n > 0; })
| std::views::transform([](int n) { return n * 2; });
现在读起来就像在说:「从 data 开始,过滤出正数,然后每个数乘以 2」。是不是清晰多了?
18.4 延迟求值(Lazy Evaluation)——只在需要时才计算
延迟求值是范围库最强大的特性之一。视图不会立即执行任何操作,它只是记录下「将来要做什么」。真正的计算发生在遍历视图的时候。
为什么会这样?因为视图的迭代器在每次递增时,才去计算下一个元素的值。这意味着:
- 节省内存:不需要创建中间容器来存储临时结果
- 节省计算:如果只取前几个元素,后面的元素根本不会计算
- 无限范围:可以处理理论上无限长的序列
举个例子,生成斐波那契数列的前 10 个偶数:
#include <ranges>
#include <iostream>
int main() {
// 生成斐波那契数列的视图(无限长)
auto fibonacci = std::views::iota(0, 1)
| std::views::transform([](int n) {
static int a = 0, b = 1;
int result = a;
a = b;
b = result + b;
return result;
});
// 取前 10 个偶数
auto result = fibonacci
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::take(10);
for (int v : result) {
std::cout << v << " "; // 输出:0 2 8 34 144 610 2584 10946 46368 196418
}
return 0;
}
注意:延迟求值虽然好,但也有坑。我曾经在项目中遇到过一个 bug:创建了一个视图后,原始数据被修改了,结果视图遍历时拿到了错误的值。记住,视图不拥有数据,它只是引用。如果原始数据生命周期结束或被修改,视图的行为是未定义的。
18.5 知识体系总览
下面这张图总结了范围库的核心概念和它们之间的关系:
18.6 实战:组合使用
最后,分享一个我在实际项目中用过的例子。当时需要从一个日志文件中提取所有错误级别的消息,并按时间排序,只保留最近的 100 条:
#include <ranges>
#include <vector>
#include <string>
#include <algorithm>
#include <iostream>
struct LogEntry {
std::string timestamp;
std::string level;
std::string message;
};
int main() {
std::vector<LogEntry> logs = {
{"2024-01-01 10:00", "INFO", "Server started"},
{"2024-01-01 10:01", "ERROR", "Connection timeout"},
{"2024-01-01 10:02", "WARN", "Disk space low"},
{"2024-01-01 10:03", "ERROR", "Database connection failed"},
// ... 更多日志
};
// 先按时间降序排序(最近的在前面)
std::ranges::sort(logs, std::greater{}, &LogEntry::timestamp);
// 过滤出 ERROR 级别,取前 100 条
auto recent_errors = logs
| std::views::filter([](const LogEntry& e) {
return e.level == "ERROR";
})
| std::views::take(100);
for (const auto& entry : recent_errors) {
std::cout << entry.timestamp << " [" << entry.level << "] "
<< entry.message << "\n";
}
return 0;
}
这段代码没有创建任何临时容器,没有多余的拷贝。整个处理流程清晰得像在描述业务逻辑。说实话,用惯了范围库之后,再让我回去写传统的迭代器循环,我是不太愿意的。
避坑指南:我曾经在项目中使用 std::views::reverse 时踩过一个坑——它对 std::list 无效,因为 std::list 的迭代器不支持 std::bidirectional_iterator 以外的操作。所以使用前最好确认你的容器类型是否支持所需的迭代器类别。
范围库让 C++ 的函数式编程风格变得更加实用。它把数据处理从「怎么做」提升到了「做什么」的层面。你不需要关心循环怎么写、临时变量怎么管理,只需要描述你想要的结果。这就是我理解的现代 C++ 编程哲学。
公众号:蓝海资料掘金营,微信 deep3321