Ranges革命与管道
# 57.Ranges革命与管道
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. Views——惰性求值的核心
- 4. 管道操作符 | ——从函数嵌套到从左到右的数据流
- 5. Ranges 算法——超越迭代器对的 std::ranges 命名空间
- 6. 组合 view 适配器——构建数据处理管道
- 7. 自定义 view 与适配器——扩展 ranges 生态
- 8. Ranges 的性能——汇编层验证零开销
- 9. 常见陷阱与反模式
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 STL 算法的迭代器对地狱——10 个中间变量只是为了传 begin/end
某数据处理模块用传统 STL 算法做数据清洗——代码散落在 40 行中——中间临时容器就有 5 个:
// ====== 事故代码 V1:迭代器对地狱 ======
std::vector<int> data = read_data();
// 过滤:只保留偶数
std::vector<int> filtered;
std::copy_if(data.begin(), data.end(), std::back_inserter(filtered),
[](int x) { return x % 2 == 0; });
// 变换:平方
std::vector<int> transformed;
std::transform(filtered.begin(), filtered.end(), std::back_inserter(transformed),
[](int x) { return x * x; });
// 取前 5 个
std::vector<int> taken(transformed.begin(),
transformed.begin() + std::min(5ul, transformed.size()));
// 排序
std::sort(taken.begin(), taken.end());
// 问题:
// ① 3 个中间容器——每个都分配堆内存——O(N) 额外空间
// ② filtered、transformed 遍历两次——一次生成、一次消费——O(2N) 时间
// ③ 大量 begin/end 重复——阅读困难
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 1.2 filter + transform 的两次遍历——非惰性求值的性能陷阱
案例 1.1 的性能分析——5 个中间容器的代价:
传统 STL(急切求值):
data → copy_if → filtered (N 次迭代——分配新容器——~N 次 malloc)
filtered → transform → transformed (N 次迭代——分配新容器——~N 次 malloc)
transformed → take(5) → taken (5 次迭代——分配小容器)
taken → sort → taken (5log5 ~ 12 次)
总迭代:~2N + 5 + 12 次元素访问
总分配:3 个中间容器(filtered N, transformed N, taken 5)
总内存:~2N + 5 个 int 的额外堆内存
Ranges(惰性求值):
data | filter(even) | transform(square) | take(5) | sort
每个元素一次性穿过整个管道——不需要任何中间容器
总迭代:N 次(只遍历源容器一次——直到管道产生 5 个输出)
总分配:0 个中间容器
总内存:0 额外堆内存
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1.3 七个待解疑问
① range 和 view 有什么区别?为什么 view 可以惰性求值? → 第 2 / 第 3 章
② 管道 | 操作符是什么?怎么把 filter | transform 串成链? → 第 4 章
③ std::ranges::sort 和 std::sort 哪里不同?projection 是什么? → 第 5 章
④ ranges 的惰性求值在汇编层真的零开销吗? → 第 8 章
⑤ 自定义 view 怎么写?怎么扩展 ranges 生态? → 第 7 章
⑥ filter_view 的迭代器为什么不能随机访问? → 第 9 章
⑦ ranges 和 Boost.Range 的演进关系?C++20→C++23 加了什么? → 第 7.2 / 第 10 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 range 的概念层级——从 input_range 到 contiguous_range
C++20 定义的 range 概念层级(从弱到强):
① std::ranges::input_range — 可以遍历(单次消费)
② std::ranges::forward_range — 可重复遍历(多遍)
③ std::ranges::bidirectional_range — 可反向遍历
④ std::ranges::random_access_range — 可随机访问 (O(1) 索引)
⑤ std::ranges::contiguous_range — 内存连续(和 vector/array 一样)
每个 range 通过其迭代器的能力自然获得对应的概念层级。
view 保持了源 range 的迭代器能力——除了某些适配器会降级:
filter_view:源是 random_access → view 退化为 bidirectional
transform_view:保持源的能力
2
3
4
5
6
7
8
9
10
11
12
# 2.2 为何这么切
传统 STL:
容器和算法通过迭代器对 (begin+end) 耦合——必须传两个参数
sort(v.begin(), v.end()) ← 必须同类型的迭代器
Ranges:
容器本身就是一个 range——算法接受一个 range 参数
ranges::sort(v) ← 简洁——且 sentinel 可以不同于 iterator
这一刀切在 C++ 最痛的痛点——迭代器对的繁琐和错误倾向:
sort(v1.begin(), v2.end()) ← 编译通过!不同容器的迭代器在类型相同时也是合法参数
2
3
4
5
6
7
8
9
10
# 3. Views——惰性求值的核心
# 3.1 view 的编译器魔法——不拥有数据、O(1) 拷贝、惰性求值
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// view = 数据的「观察窗」——不拥有数据——只持有引用/指针
auto even = data | std::views::filter([](int x) { return x % 2 == 0; });
// sizeof(even) ≈ 2 个指针(data.begin + predicate)——~16 字节
// even 不分配任何新内存——它只是「被过滤的 data 的视角」
// 惰性求值——在这个时间点 even 还没有被真的「过滤」
// 只有当你开始遍历 even——元素才被逐个检查
2
3
4
5
6
7
8
9
view 的核心性质:
| 性质 | 含义 |
|---|---|
| 不拥有数据 | view 只是引用/指针指向源数据 |
| O(1) 拷贝 | 拷贝 view 只是拷贝几个指针 |
| O(1) 析构 | 析构 view 不需要释放任何资源 |
| 惰性求值 | 遍历 view 时才做变换 |
# 3.2 常用 view 适配器——filter / transform / take / drop / reverse
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// filter — 只保留满足条件的元素
auto even = v | std::views::filter([](int x) { return x % 2 == 0; });
// even: 2, 4, 6, 8, 10
// transform — 对每个元素做变换
auto squares = v | std::views::transform([](int x) { return x * x; });
// squares: 1, 4, 9, 16, 25, 36, 49, 64, 81, 100
// take — 只取前 N 个
auto first5 = v | std::views::take(5);
// first5: 1, 2, 3, 4, 5
// drop — 跳过前 N 个
auto after3 = v | std::views::drop(3);
// after3: 4, 5, 6, 7, 8, 9, 10
// reverse — 反向遍历(需要 bidirectional_range)
auto rev = v | std::views::reverse;
// rev: 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 3.3 惰性求值 vs 急切求值——view 在整个管道中只遍历一次
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 管道组合——每个元素「一次性穿过」整个管道:
auto result = v
| std::views::filter([](int x) { return x % 2 == 0; }) // ① 滤奇
| std::views::transform([](int x) { return x * x; }) // ② 平方
| std::views::take(3); // ③ 取前 3
// 底层执行模型(pull model):
// 消费者请求下一个元素
// → take(3) 向 transform 请求
// → transform 向 filter 请求
// → filter 向 v 请求——找到偶数 2
// → transform 把 2 变成 4
// → take(3) 拿到 4——计数 1/3
// 继续:
// → filter 找到偶数 4 → transform 变 16 → take 计数 2/3
// → filter 找到偶数 6 → transform 变 36 → take 计数 3/3
// 管道停止——v 中剩余 8,10 永不被访问
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 3.4 为什么 view 的迭代器需要特殊处理——sentinel 替代 end iterator
// filter_view 的 end() —— 不是简单的 v.end()
// 因为「最后一个满足 filter 条件的元素」的位置不在编译期确定
// 需要用 sentinel 替代 iterator
auto even = v | std::views::filter(is_even);
// even.begin() → filter_view::iterator
// even.end() → filter_view::sentinel(不是 iterator!)
// sentinel 和 iterator 可以比较——但类型不同
// 这就是 C++20 sentinel 的核心用途——
// 让 range 的 begin 和 end 可以是不同类型
2
3
4
5
6
7
8
9
10
11
12
# 4. 管道操作符 | ——从函数嵌套到从左到右的数据流
# 4.1 | 的本质——operator| 重载与适配器的链式组合
// ① 最底层——直接构造 view 对象:
auto v1 = std::ranges::filter_view(v, is_even);
auto v2 = std::ranges::transform_view(v1, square);
auto v3 = std::ranges::take_view(v2, 3);
// ② | 运算符——等价于上面的链:
auto v3 = v | std::views::filter(is_even)
| std::views::transform(square)
| std::views::take(3);
// operator| 被重载——左值 (a | b) = b(a)——把左边的 range 作为右边适配器的参数
2
3
4
5
6
7
8
9
10
# 4.2 管道 vs 传统 STL 算法链——可读性的量变引起质变
// ❌ 传统 STL——从右向左读(函数嵌套)或从下向上读(中间变量)
auto r1 = std::views::take(
std::views::transform(
std::views::filter(v, is_even),
square),
3);
// 读:filter → transform → take?还是从内到外?
// ✅ Ranges 管道——从左到右——和数据流方向一致
auto r2 = v | std::views::filter(is_even)
| std::views::transform(square)
| std::views::take(3);
// 读:v → 过滤偶数 → 平方 → 取前 3 —— 无比清晰
2
3
4
5
6
7
8
9
10
11
12
13
# 4.3 管道中的类型推演——每一步 view 的完整类型签名
auto pipe = v // vector<int>&
| std::views::filter(is_even) // filter_view<vector<int>&, Pred>
| std::views::transform(square) // transform_view<filter_view<...>, Fn>
| std::views::take(3); // take_view<transform_view<...>>
// 不要尝试写出中间类型——用 auto——这是 ranges 的设计意图
// typeof(pipe) 是一个三层嵌套的模板类型——但作为 range 使用——完全透明
2
3
4
5
6
7
# 5. Ranges 算法——超越迭代器对的 std::ranges 命名空间
# 5.1 std::ranges::sort 和 std::sort 的区别——range 参数 vs 迭代器对
std::vector<int> v = {3, 1, 4, 1, 5};
// 传统——传两个迭代器
std::sort(v.begin(), v.end());
// Ranges——传 range(容器本身)
std::ranges::sort(v);
// Ranges 还支持投影(projection)——下次
2
3
4
5
6
7
8
9
# 5.2 算法返回值的增强——不仅返回迭代器、还返回 subrange
// 传统 std::find——返回一个迭代器
auto it = std::find(v.begin(), v.end(), 42);
// Ranges std::ranges::find——返回一个迭代器(同上)
// 更复杂的——std::ranges::unique
auto [first, last] = std::ranges::unique(v); // 返回 subrange
v.erase(first, last); // 删除重复元素——erasure idiom
2
3
4
5
6
7
8
# 5.3 投影(projection)——把「比什么」从比较器中解放出来
struct Employee { std::string name; int age; double salary; };
std::vector<Employee> staff = { ... };
// ❌ 传统——手写 lambda 提取 age 比较
std::sort(staff.begin(), staff.end(),
[](const Employee& a, const Employee& b) { return a.age < b.age; });
// ✅ Ranges——投影:告诉算法「看这个字段」——比较器默认为 <
std::ranges::sort(staff, {}, &Employee::age); // {} = 默认比较器 <
// 语义:比较员工——但每两个比较前——先投影到 &Employee::age
// 按薪水降序——投影 + 自定义比较器
std::ranges::sort(staff, std::greater{}, &Employee::salary);
// 按名字长度排序
std::ranges::sort(staff, {}, [](const std::string& s) { return s.size(); });
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 5.4 projection 的汇编零开销——编译器内联整个 lambda 链
// 源码:
std::ranges::sort(staff, {}, &Employee::age);
// 编译器转化后的等价代码——projection 被完全内联:
std::ranges::sort(staff, [](const Employee& a, const Employee& b) {
return a.age < b.age; // &Employee::age 被内联——零额外调用
});
// 汇编中看不到对 projection 的函数调用——只看到 age 成员的直接比较
// 这就是 ranges 的零开销原则——projection 不是在运行时多调用一层
2
3
4
5
6
7
8
9
10
# 6. 组合 view 适配器——构建数据处理管道
# 6.1 经典六步管道——filter → transform → take → reverse → filter → join
std::vector<std::string> words = {"hello", "world", "cpp", "ranges", "pipe"};
auto result = words
| std::views::filter([](const std::string& s) { return s.size() > 3; })
| std::views::transform([](const std::string& s) {
std::string copy = s;
std::ranges::reverse(copy);
return copy;
})
| std::views::take(3);
// step 1: {"hello", "world", "ranges", "pipe"} (filter size > 3)
// step 2: {"olleh", "dlrow", "segnar", "epip"} (reverse each)
// step 3: {"olleh", "dlrow", "segnar"} (take 3)
2
3
4
5
6
7
8
9
10
11
12
13
14
# 6.2 管道的惰性求值模型——每个元素独立经过整个管道才产下一个
pull model(消费者驱动):
消费者(如 for 循环)请求 next()
→ 管道最后一层(take)请求前一层的 next()
→ 管道中间层(transform)请求前一层的 next()
→ 管道最前层(filter)从源容器拉取
→ 找到第一个满足条件的元素——逐层返回
→ 消费者拿到第一个「完全处理过」的元素
关键:管道中间没有任何中间容器——没有中间分配——纯函数调用链
2
3
4
5
6
7
8
9
10
# 6.3 与 Unix 管道的哲学同构——数据流从左到右、每步独立变换
# Unix 管道
cat data.txt | grep -v "^#" | awk '{print $1}' | sort | uniq
# C++ Ranges 管道
data | filter(non_comment) | transform(first_col) | sort | unique
# 同一条哲学:数据从左向右流动——每一步做一件事——组合而不是嵌套
2
3
4
5
6
7
# 7. 自定义 view 与适配器——扩展 ranges 生态
# 7.1 用 views::transform 函数对象替代手写 lambda
namespace views = std::views;
// 使用标准库函数对象——不需要手写 lambda
auto squares = v | views::transform([](int x) { return x * x; }); // 手写 lambda
auto squares2 = v | views::transform(square_fn); // 可复用的函数对象
2
3
4
5
# 7.2 C++23 新增的适配器——丰富 ranges 工具箱
| 适配器 | C++ 版本 | 效果 |
|---|---|---|
views::zip | C++23 | 把两个 range 的元素配对——(a[i], b[i]) |
views::zip_transform | C++23 | zip + transform——在 zip 的同时做变换 |
views::adjacent<N> | C++23 | 滑动窗口——取相邻 N 个元素 |
views::stride | C++23 | 每隔 N 个元素取一个 |
views::slide | C++23 | 大小为 N 的滑动窗口 |
views::chunk | C++23 | 把 range 分成固定大小的块 |
views::chunk_by | C++23 | 根据谓词分组 |
views::join_with | C++23 | 用分隔符 join |
# 8. Ranges 的性能——汇编层验证零开销
# 8.1 filter|transform 管道 vs 手写 for 循环——汇编对比
// 手写 for 循环
int sum = 0;
for (int x : data)
if (x % 2 == 0)
sum += x * x;
// Ranges 管道
auto even_squares = data
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
int sum = std::ranges::fold_left(even_squares, 0, std::plus{});
2
3
4
5
6
7
8
9
10
11
GCC 13.2 -O2 汇编对比——两者生成的汇编几乎完全相同——都是直接的循环体内嵌条件和乘法。没有任何 view 的构造/析构开销。
# 8.2 ranges::sort + projection vs 手写 lambda——性能差异
// 手写 lambda 排序
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
// ranges 排序 + projection
std::ranges::sort(v, std::greater{}); // 默认投影 = 元素本身
2
3
4
5
性能:两者相同——projection 被内联——没有额外函数调用。
# 8.3 惰性求值的缓存友好性——单次遍历 vs 多次中间容器分配
传统(急切求值+中间容器):
filter → 分配 N 个元素的中间容器 → transform → 分配 N 个元素的中间容器
→ 数据被搬移两次——每次搬移都触发 cache miss(加载新分配的内存)
Ranges(惰性求值):
单次遍历源容器——每个元素在 L1 缓存中走完整个管道
→ 零中间分配 → 零额外的 cache miss
2
3
4
5
6
7
# 9. 常见陷阱与反模式
# 9.1 view 在源容器析构后使用——悬挂引用
auto get_view() {
std::vector<int> v = {1, 2, 3, 4, 5};
return v | std::views::filter(is_even); // ❌ v 析构后——view 变成悬挂引用
}
// view 持有的引用指向已析构的 v——UB
// ✅ 把数据移到外面——或把 view 的消费放在同一个作用域
2
3
4
5
6
7
# 9.2 试图给 view 做 sort——view 不拥有数据、不可修改
auto even = data | std::views::filter(is_even);
// std::ranges::sort(even); // ❌ filter_view 不是 random_access_range——不可 sort
// ✅ 把 view 的结果收集到容器——再 sort
std::vector<int> result;
std::ranges::copy(even, std::back_inserter(result));
std::ranges::sort(result);
2
3
4
5
6
7
# 9.3 filter_view 的迭代器递增——可能多次 ++ 才找到一个有效元素
auto even = data | std::views::filter(is_even);
auto it = even.begin(); // ① 找到第一个偶数——可能遍历 N/2 个元素
++it; // ② 找到下一个偶数——可能再次遍历多个元素
// 每次 ++it 的复杂度由 filter 中有效元素的间隔决定
// 平均:O(1) —— 但最坏:O(N)(只有一个偶数在末尾)
2
3
4
5
6
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | view vs range? | 第 2-3 章:range=可遍历、view=不拥有数据的 range——惰性求值 |
| ② | 管道 | 操作符? |
| ③ | ranges::sort 区别? | 第 5 章:接受 range 整体、返回 subrange、支持 projection |
| ④ | 汇编零开销? | 第 8 章:view 的 lambda 被完全内联—和手写循环相同的指令 |
| ⑤ | 自定义 view? | 第 7 章:C++23 新增 zip/adjacent/slide/chunk 等适配器 |
| ⑥ | filter 迭代器代价? | 第 9.3:每次 ++ 可能需要跳过非匹配元素——均摊 O(1) |
| ⑦ | Boost.Range→C++20? | 第 7.2:Boost.Range 是先驱——C++20 标准化了核心 view 适配器 |
案例①修复——迭代器对地狱:用 ranges 管道替代所有中间容器:
auto result = read_data()
| std::views::filter(even)
| std::views::transform(square)
| std::views::take(5);
std::vector<int> v;
std::ranges::copy(result, std::back_inserter(v));
std::ranges::sort(v);
2
3
4
5
6
7
案例②修复——非惰性求值:用 ranges 管道——零中间容器——单个元素一次穿管——无额外 malloc。
# 10.2 一次管道求值的完整旅程——从容器到输出
管道:v | filter(is_even) | transform(square) | take(3)
消费:for (int x : result)
═══════ 消费者请求 1 ═══════
take(3)::iterator::operator*()
→ 调用 transform::iterator::operator*()
→ 调用 filter::iterator::operator*()
→ 迭代 v: 跳过 1 → 跳过 2? 2 是偶数!停在 2
→ filter 返回 2
→ transform 把 2 变换为 4
→ take 拿到 4——计数 1
═══════ 消费者请求 2 ═══════
take(3)::iterator::operator++() → operator*()
→ ... 同上——跳过 3 → 停在 4 → transform 变 16 → take 计数 2
═══════ 消费者请求 3 ═══════
→ ... 停在 6 → transform 变 36 → take 计数 3
═══════ 消费者请求 4 ═══════
take(3)::iterator 检测到 count == 3 → 返回 sentinel
→ 循环终止——管道停止——源容器中 8, 10 永不被访问
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 10.3 设计哲学回扣
哲学 1:Ranges 把 C++ 从「算法操作数据」变成了「数据流经管道」
传统 STL:算法是主动的——sort(v.begin(), v.end())——算法驱动数据。
Ranges:数据是主动的——v | filter | transform | take——数据从左到右流动。
这不仅是语法变化——是思维模型的范式转换。和 Unix 管道的哲学一致:数据是主语、操作是修饰。
哲学 2:惰性求值让组合成为可能——不构造中间容器就不需要管理它们的生命周期
v | filter | transform | take 在传统 STL 中需要 3 个中间容器——每一个都分配内存、都需要命名、都需要在作用域结束时析构。Ranges 的惰性求值消灭了这三个中间容器——代码从「3 个容器的管理」变成「1 条数据流链」。
哲学 3:Projection 把二维比较化为一维——比较什么、怎么比——分开编码
传统比较器需要手写 lambda 提取成员——a.age < b.age 把「取什么」和「怎么比」混在一起。Projection 把这两个维度分开——std::ranges::sort(staff, {}, &Employee::age)——投影回答「看什么」、比较器回答「怎么比」。两个维度正交——独立变化。 而且投影被编译器完全内联——零运行时开销。
哲学 4:实现复杂度不应暴露在接口上——auto 类型推演隐藏了 view 的深层嵌套类型
filter_view<transform_view<take_view<vector<int>&>>>——这是三层的嵌套类型。但用户从来不需要写这些类型——auto 让 ranges 的实现复杂度完全隐藏在编译器中。这和 Rust 的 impl Iterator、Haskell 的类型推演共享同一哲学——复杂类型对用户不可见。
# 10.4 速查表合集
常用 view 速查:
| 适配器 | 效果 | 降级迭代器? |
|---|---|---|
views::filter | 只保留满足谓词的元素 | ✅ → bidirectional |
views::transform | 对每个元素做变换 | ❌ 保持 |
views::take | 取前 N 个 | ❌ 保持 |
views::drop | 跳过前 N 个 | ❌ 保持 |
views::reverse | 反向遍历 | ❌ 保持(已经是双向) |
views::join | 展开嵌套 range | ✅ → forward |
管道 vs 传统 STL:
| 维度 | 传统 STL | Ranges |
|---|---|---|
| 参数 | 迭代器对 | range 整体 |
| 组合方式 | 嵌套函数调用 | operator| 管道 |
| 中间容器 | 需要(急切求值) | 不需要(惰性求值) |
| 比较 | lambda 提取成员 | projection |
| 返回值 | 单个迭代器 | 迭代器 / subrange |
| end 类型 | 必须和 begin 同类型 | 可以用 sentinel |
Ranges 算法 vs 传统算法:
// 传统
std::sort(v.begin(), v.end());
std::copy_if(v.begin(), v.end(), out, pred);
// Ranges
std::ranges::sort(v);
std::ranges::copy_if(v, out, pred);
// Ranges + projection
std::ranges::sort(staff, {}, &Employee::age);
std::ranges::find_if(staff, [](const auto& e) { return e.salary > 100000; }, &Employee::salary);
2
3
4
5
6
7
8
9
10
11
本篇小结:Ranges 是 C++20 对算法库的最大升级——把「迭代器对」抽象为 range、用惰性求值的 view 替代急切求值的中间容器、用
|管道替代函数嵌套、用 projection 替代手写比较 lambda。惰性求值让每个元素一次性穿过整个管道——零中间分配、零额外的 cache miss。汇编层验证了 view 和 projection 的零开销——编译期内联的结果和手写 for 循环完全相同。
下一篇:数据处理管道说清了。下一篇进入 58.format与print体系——
std::format语法、扩展格式化、locale 无关、与printf/iostream性能对比——把 C++ 的输入输出也现代化。