C++ Ranges(二):管道与 View Adaptors
本文承接《C++ Ranges(一):核心概念与算法》,聚焦视图工厂、适配器与管道组合。上一篇介绍 range/view 概念、借用与所有权、算法投影及 C++23 范围算法;本文示例统一以 C++23 为编译基线,章节中的 C++20/C++23 表示 ranges 设施所属的标准版本。库设施不可仅凭 -std=c++23 推断为已实现。
示例约定
本文只展示与讲解相关的代码片段,省略头文件和 main。除特别说明外,nums 表示元素为 {1, 2, 3, 4, 5, 6}、在视图使用期间一直存活的整数数组。输出默认使用 <print> 中的 std::print 和 std::println 直接格式化 range。包含 <version> 后,分别用 __cpp_lib_print >= 202207L 和 __cpp_lib_format_ranges >= 202207L 检查打印与范围格式化支持。std::print 不自动换行,std::println 在输出末尾添加换行;仅输出换行时使用 std::println("")。
算法特性宏见上一篇,C++23 views 的宏见下表。
输出约定:std::println("{}", range) 默认将序列输出为 [1, 2, 3],空范围为 [];嵌套范围保留嵌套方括号,tuple 元素使用圆括号,字符串和字符元素带引号。字符范围需要按文本输出时使用 {:s},如 std::println("{:s}", text)。范围及其元素须满足格式化约束;打印会遍历并消费 range,单遍输入范围不能因此重复使用,无限范围必须先限制消费数量。
管道表达式如何工作
标准视图适配器通常可用两种形式调用。直接调用形式接收 range 及适配参数:
1 | |
也可以先用参数构造一个适配器闭包,再通过 | 应用于 range:
1 | |
range | closure 表示把左侧 range 交给右侧闭包;closure_a | closure_b 则组合两个闭包,所得闭包再接收 range。此机制使复杂操作可命名、传递和复用。zip、cartesian_product 等接收多个 range 的设施不是单一 range 到闭包的适配器,不能把它们当作 range | zip(...) 使用。
1 | |
适配器的输入仍须满足其自身约束。例如 reverse 要求双向 range,slide 要求前向 range。闭包可以重复应用,但 filter 的谓词和 transform 的函数必须满足相应的等值保持等语义要求;不要用会改变自身状态、使同一输入得到不同答案的 callable 表达这些转换。
求值顺序、惰性与缓存
视图适配器按表达式顺序建立视图层次。迭代外层视图时,所请求的元素才沿内层视图逐步产生;中间视图通常不另存一份全量元素。下面图示的是按需拉取,而不是创建管道时预先执行全部转换。
1 | |
重新遍历可能再次执行筛选和变换,view 并不缓存全部结果。底层为前向 range 时,filter_view 会缓存首个匹配位置;drop_view 也可能缓存推进后的起点。这些局部缓存不是物化。不要依赖转换函数的精确调用次数;日志、计数等副作用更适合放在终端消费步骤。
惰性让短路和有限消费自然发生,但不能改变无限序列本身的边界。对 iota_view 等无限范围,只有在有限消费后才有界:遍历完整无限范围将不会结束。反过来,惰性并不意味着免费;嵌套变换、谓词与迭代器适配层会带来调用和抽象成本,按实际需求测量。
源 range、所有权与消费边界
views::all 将 range 转为适合构造视图的形式:左值非 view 通常包装为 ref_view,右值非 view 则由 owning_view 持有;传入 view 通常按值保留。因而 view 并不必然借用数据,也不必然拥有数据。把基于左值容器的视图返回给调用者时,源容器必须比视图活得久且不得发生使迭代器失效的操作。
1 | |
左值源的视图通常只借用:
1 | |
把惰性结果变成独立容器时可用 C++23 std::ranges::to(参见上一篇):
1 | |
上例中 nums 为左值数组;stored 独立拥有生成的整数。ranges::to 是容器转换设施,不是 view adaptor。视图的迭代器/引用可能仍借用底层对象;生命周期安全需逐层检查,Ranges 不会自动阻止所有悬空引用。
以下部分依照标准库设施的名字列出 C++20 和 C++23 views。factory 负责创建 view;adaptors 将一个 range 转成另一个 range。
C++20 视图工厂
empty
views::empty<T> 产生元素类型为 T 的空 view,长度为零。它不接收一个已有 range,也不是用来清空容器。
使用场景:向 ranges 算法或接收 range 的泛型接口传递“没有元素”的输入,或测试空输入时的边界行为。相比为此创建空 vector,empty<T> 直接表达元素类型与零长度语义,无需引入容器。注意 empty_view<T> 与其他 view 通常是不同类型,不能直接用普通条件表达式在它们之间切换。
1 | |
single
views::single(value) 产生恰含一个元素的 view。它持有传入值的副本/移动结果,不会引用临时实参。
使用场景:接口只接收 range,而调用方只有一个值,例如让单个任务复用批量处理逻辑,或将一个字符串作为一个元素交给范围算法。single 将值包装为单元素序列,不会把字符串展开为字符;它按值持有对象,若需要操作原对象而非副本,应使用借用该对象的范围。
1 | |
iota
views::iota(start) 产生不设上界的递增序列;views::iota(start, bound) 则在到达上界时结束。整数示例的结束边界不包含在结果中。对有符号整数使用时,必须避免实际递增越过可表示范围。
1 | |
1 | |
istream
views::istream<T>(stream) 从输入流逐项读取 T,属于单遍输入范围。流必须存活到视图消费完毕;遍历会推进流,不能假设可以重新从头遍历。
1 | |
C++20 没有 views::repeat。若需要重复常量,旧标准可用有界 iota 加 transform;C++23 的 views::repeat 更直接,见下文。
C++20 View Adaptors
all
views::all(range) 将输入正规化为 view。它是视图组合的基础;C++20 的 views::all_t<R> 是由该类型推导的类型别名,不是另一个 adaptor。
1 | |
对左值容器,结果借用源对象;对右值容器,结果可拥有其内容。请参见前文的所有权边界和上一篇的所有权示意图。
filter
views::filter(pred) 保留满足谓词的元素,底层只需满足 input_range;仅当底层是前向范围时,结果才支持多遍遍历及起点缓存。谓词应对同一输入保持稳定;通过前向 filter_view 修改元素,使其不再满足谓词,会导致未定义行为。
1 | |
transform
views::transform(fun) 对访问到的元素调用函数并暴露结果。函数可返回值,也可返回引用;后者会把引用生命周期带入结果视图。它不自动缓存转换结果。
1 | |
take
views::take(count) 最多产生 count 个元素;源更短则只产生实际存在的元素。count 必须非负,负值违反前置条件,并不会自动按零处理。
1 | |
take_while
views::take_while(pred) 从起点连续取元素,首次遇到不满足谓词的元素时停止;之后即使有满足条件的元素也不会越过该边界。
1 | |
drop
views::drop(count) 跳过至多 count 个起始元素,源短于计数时结果为空。count 必须非负,负值违反前置条件。
1 | |
drop_while
views::drop_while(pred) 跳过起点处连续满足谓词的元素,遇到首个不满足元素后保留剩余部分。
1 | |
reverse
views::reverse 反向遍历 range,要求双向 range。底层必须支持递减迭代器;单向 forward_list 不满足约束。
1 | |
common
views::common 将 sentinel 类型不同于 iterator 的 range 转成首尾迭代器同类型的 range(如有需要)。它不复制元素,也不保证底层 range 原本就有 common iterator。
1 | |
counted
views::counted(iterator, count) 用迭代器与非负数量构造 view,不是 adaptor closure。底层必须存在从该迭代器开始的有效 count 个元素;它不会检查真实边界,数量过大会越界。
1 | |
split
views::split(delimiter) 按子范围分割 range,产生子范围的范围。基于 P2210R2 的修订语义从 C++20 起适用(作为缺陷报告回溯修正),不是 C++23 才加入。std::string_view 用作源可避免 C 字符串终止符参与分割。
1 | |
delimiter 可以是单个值或一个 pattern range。空字段会保留;结尾 delimiter 后的尾部空子范围也会出现。split_view 的子范围共享源序列,不拥有字符。
1 | |
字符串字面量作为数组 range 时包含末尾 \0;分隔模式如果也是字面量,也包含终止符。按文本字符分割时使用 std::string_view 或 std::string 明确边界。上例使用 {:s} 直接将每个字符子范围格式化为文本,无需先构造 string_view。
lazy_split
views::lazy_split(delimiter) 也按 delimiter 分割,但用于更宽松的输入范围情形,尤其是仅满足 input range 的源。它生成的内层子范围本身可能只是输入范围,迭代子范围与外层推进具有更强的单遍限制;不能像 split_view 那样假设子范围可独立保存和重复遍历。
1 | |
对前向 range,优先选 split;选择 lazy_split 的价值在于其对输入 range 的适用性,不是普遍更快。与 split 一样,若输入来自含终止符的 C 字符串数组,终止符也是 range 元素。
join
views::join 将 range-of-ranges 展平为单个 range。它是 C++20 设施,要求外层及内层符合其迭代约束;结果引用内层元素,不会把它们拼接复制到新容器。
1 | |
elements
views::elements<N> 从 tuple-like 元素序列中选取第 N 个成员。索引从零开始;底层元素须支持相应 tuple-like 访问。
1 | |
keys
views::keys 等价于 elements<0>,可用于 pair 序列和 map。map 的键依然不可通过结果 view 修改。
1 | |
values
views::values 等价于 elements<1>。对可写的 pair 序列或 map,它保留映射值的可写引用,不复制元素。
1 | |
C++23 视图工厂
repeat
views::repeat(value) 产生无限个重复值;带第二个参数的 views::repeat(value, count) 产生有限数量。它持有值,不会从一个源 range 逐项复制。该工厂为 C++23;无限形式应配合有限消费。
1 | |
1 | |
C++23 View Adaptors
这些设施都在 <ranges>,但实现进度并不一致。包含 <version> 后检查对应宏;__cpp_lib_ranges 不能替代下列逐项检查。宏未达到所需值时,应更换标准库或明确不使用该设施,而不是用语言模式假定支持。
| 特性宏 | C++23 最低值 | 对应设施 |
|---|---|---|
__cpp_lib_ranges_as_const | 202207L | as_const、常量迭代支持 |
__cpp_lib_ranges_as_rvalue | 202207L | as_rvalue |
__cpp_lib_ranges_enumerate | 202302L | enumerate |
__cpp_lib_ranges_zip | 202110L | zip、zip_transform、adjacent 及其 transform/别名 |
__cpp_lib_ranges_chunk | 202202L | chunk |
__cpp_lib_ranges_slide | 202202L | slide |
__cpp_lib_ranges_chunk_by | 202202L | chunk_by |
__cpp_lib_ranges_stride | 202207L | stride |
__cpp_lib_ranges_join_with | 202202L | join_with |
__cpp_lib_ranges_cartesian_product | 202207L | cartesian_product |
__cpp_lib_ranges_repeat | 202207L | repeat 工厂 |
as_const
views::as_const 将元素以只读访问形式暴露,不复制底层容器。普通引用变成 const 引用;值和代理引用则按相应常量引用规则处理。它不保证底层对象无法通过其他路径修改。
1 | |
该设施可能尚未由某些 C++23 标准库实现提供。一般 ranges 算法对代理引用的支持也不能简单归结为 const auto& 一定不可用;应按具体 range 的引用类型和操作约束判断。
as_rvalue
views::as_rvalue 令解引用结果表现为右值引用,使后续消费操作可以移动元素。创建该 view 本身不移动任何元素;只有执行移动构造/赋值的操作才会消费源元素。对拥有数据的左值容器使用它,随后物化结果可能令容器元素处于 moved-from 状态。
1 | |
enumerate
views::enumerate 为每个元素配上从零开始的索引,结果是包含索引与元素引用的 tuple。索引类型是底层 view 的 range_difference_t,而非固定的 std::size_t;结构化绑定得到的元素仍可保留底层引用语义。
1 | |
zip
views::zip(ranges...) 同步组合多个输入,结果是对应元素引用的 tuple,长度取最短输入。它不能部分绑定为单源管道闭包,应直接调用。
1 | |
zip_transform
views::zip_transform(fun, ranges...) 对各输入相同位置的元素调用函数,直到最短输入结束。与 zip 一样,应直接传入多个 ranges。
1 | |
adjacent
views::adjacent<N> 产生由连续 N 个元素引用组成的 tuple,要求前向 range。N > 0 时,长度为 的输入产生 个重叠窗口;输入短于 N 时为空。adjacent<0> 是特殊情况,返回空 view,而不是每个位置一个空窗口。
1 | |
pairwise
views::pairwise 是 views::adjacent<2> 的别名,产生重叠相邻对。长度为 时结果数为 。
1 | |
adjacent_transform
views::adjacent_transform<N>(fun) 将函数应用于每个由连续 N 个元素组成的窗口,直接产生转换结果,要求前向 range。N > 0 时结果数为 。N == 0 时返回空 view,但函数必须可无参调用且返回可作为元素的对象类型。
1 | |
pairwise_transform
views::pairwise_transform(fun) 是 adjacent_transform<2>(fun) 的别名,适合相邻差值、两两求和等操作。
1 | |
chunk
views::chunk(width) 按固定宽度切成不重叠子范围,最后一块可以更短。要求输入范围,不一定要求前向范围;单遍输入的内层块会共享消费状态,必须按顺序处理,不能保存下来独立重放。width 必须为正;有限长度 、宽度 时块数为 。
1 | |
slide
views::slide(width) 产生固定宽度的重叠窗口,每次向前移动一个元素,要求前向 range。width 必须为正;长度 、宽度 时窗口数为 。它与 adjacent<N> 的区别是宽度可在运行时确定,窗口本身是 range,而非固定大小 tuple。
1 | |
chunk_by
views::chunk_by(pred) 检查相邻元素:谓词为真时留在同一组,为假时开始新组。比较的是相邻的一对,不是每个候选元素与组首;要求前向 range,组大小不固定。
1 | |
谓词应对同一对值保持稳定。例如用 a <= b 分组时,1, 3, 2 会分为 1, 3 和 2,因为 3 <= 2 为假;即使 1 <= 2 为真,也不会让 2 留在首组。
stride
views::stride(step) 每隔 step 个位置取一个元素并保留起点,支持输入范围。step 必须为正;有限长度 、步长 时元素数为 。步长大于长度时,非空输入仍保留首元素。
1 | |
join_with
views::join_with(separator) 展平 range-of-ranges,并在相邻内层 range 之间插入分隔元素或分隔 range。分隔 range 要求前向遍历,外层与内层还须满足元素兼容等约束。空子范围也参与相邻关系,例如 {"a", "", "b"} 用 '-' 连接得到 a--b。
1 | |
cartesian_product
views::cartesian_product(ranges...) 产生笛卡尔积,每个元素是引用 tuple,最右侧输入变化最快。第一个输入只需为 input range,后续输入要求前向 range,因为会反复从头遍历。任一输入为空则结果为空;零个输入时也为空。它应直接调用,不是单源 adaptor closure。
1 | |
views::concat、views::cache_latest 和 views::as_input 属于 C++26,不是 C++23 设施。ref_view 和 owning_view 是包装类型,通常由 views::all 选择,并没有对应的 views::ref 或 views::owning adaptor。
滑动窗口实战
将 slide、transform 和 fold_left 组合,可以逐窗口计算移动平均,再用 ranges::to 保存结果:
1 | |
长度为 、窗口宽度为 时,逐窗口求和耗时为 ,不是自动优化的滚动和算法。窗口较大或数据量很大时,可维护滑入/滑出元素的滚动和,达到 ;需要考虑浮点误差。此例展示组合语义,不宣称其性能总是更优。
自定义 adaptor closure
C++23 的 std::ranges::range_adaptor_closure<D> 使自定义闭包参与 range | closure 和闭包组合。下面先筛选偶数,再乘以保存的系数;该接口由 __cpp_lib_ranges >= 202202L 标识。
1 | |
使用该闭包,并继续组合转换:
1 | |
闭包以 std::forward 保留输入的左值/右值性,以值捕获 factor 避免保存局部引用。viewable_range 只表示输入可包装为 view,具体 filter 和 transform 的元素约束仍须满足。自定义闭包可以管理自己的状态,但其中传给标准 adaptor 的 callable 仍要遵守其语义要求。
自定义 View:有限重复值
实际代码应优先使用 C++23 views::repeat。下面的有限重复 view 仅用于说明 view_interface 与前向迭代器的最小组合:按值保存一个对象,迭代时重复返回其 const 引用。T 要求可默认构造且可移动;迭代器依赖 view 的地址,view 必须一直存活。
1 | |
创建并消费这个 view:
1 | |
标准工厂的等价代码更短:
1 | |
迭代器以 view 地址判断身份;移动 view 后,旧迭代器不会自动关联新对象。该例不声明 borrowed_range,也不尝试实现标准 repeat_view 的随机访问、大小查询等完整接口。
C++23 std::generator:有界 Fibonacci 序列
std::generator<T> 是 <generator> 中的协程生成器,也是只能单遍消费的 view,不是 views adaptor。通过 <version> 中的 __cpp_lib_generator >= 202207L 检查支持。下面只生成十项,避免无限 Fibonacci 序列最终溢出:
1 | |
std::println 在格式化期间消费 generator,不保存迭代器;打印后不能重新从头遍历该 generator。最大中间值仍在 unsigned 的可表示范围内;若扩展成无限序列,必须另外处理整数溢出边界。
选用管道的检查清单
- 检查源数据的存活时间与迭代器失效规则。view 身份不等于拥有数据,也不等于 borrowed range。
- 区分单源闭包与直接调用:
counted接迭代器和数量;zip、zip_transform、cartesian_product接多个 ranges;all_t是类型别名。 - 检查最低迭代能力:
filter、chunk支持单遍输入,slide、adjacent、chunk_by要求前向遍历,reverse要求双向遍历。 - 检查边界:非负
take/drop,正的chunk/slide/stride,split的空字段,chunk的短尾块,以及zip的最短输入。 - 需要独立拥有结果时物化;
as_rvalue只改变元素访问类别,真正的移动发生在后续消费时。将副作用放在终端操作,不依赖惰性转换的调用次数。






