资讯动态

C++20 std::ranges入门:从迭代器到视图的组合式编程

发布时间:2026/10/16 11:56:53 来源:尧图企业网站定制
如果你写过几年C大概都有过这种经历要处理一个容器里的元素先过滤掉不满足条件的再转换一下然后取前几个最后求和。用传统STL写起来要么是一层层嵌套的lambda调用要么是几个临时容器反复倒腾代码读起来就像在剥洋葱每改一处都要重新理一遍迭代器范围。C20引入的std::ranges把这一系列操作从“迭代器对”里解放了出来。std::ranges不是推翻迭代器那一套而是站在迭代器之上重新组织了抽象层你不再直接关心“begin()和end()两个迭代器怎么传”而是把一个“范围”当成一个整体对象传递。配合视图view、哨兵sentinel和概念concept过滤、转换、截断这些操作可以像流水线一样拼接而且天然是惰性计算的。对已经熟悉传统迭代器、又想用上C20新特性的开发者来说ranges是本阶段绕不开的一课。这篇文章不是ranges的完整手册而是从“迭代器”这个底层视角切入先讲清楚它解决了传统迭代器的哪些痛再拆解哨兵、视图等核心设计接着用可运行的代码演示组合用法和自定义迭代器最后把我在真实项目里踩过的坑、排查思路一并整理出来。无论你是刚开始接触C20还是已经写了几年模板代码应该都能从中找到有价值的部分。1. 传统迭代器的三个老大难问题1.1 范围不是“对象”在C98年代迭代器对iterator pair就是一切算法的接口。std::sort(begin, end)、std::find(begin, end, value)、std::copy(begin, end, out)所有通用算法都在接收“两个迭代器”。这个抽象在底层非常漂亮但对使用者并不友好因为“范围”本身不是一个可以命名的实体。你需要的是“第三个元素到末尾”“全部偶数”“去掉结尾三个元素”这样的子范围但标准库没法直接表达。因为没有对应的类型所以这些子范围只能靠两个迭代器在函数之间传来传去稍微复杂一点的逻辑就要写自定义迭代器类代码量立刻上来。更麻烦的是算法组合。要做“过滤转换取前几”在没有ranges的C17里我见过最多的是这样第一遍生成临时容器第二遍遍历临时容器生成另一个临时容器第三遍再取前几。每个中间容器都有分配和拷贝开销代码也越来越绕。就算用上std::copy_if和std::transform嵌套可读性也不会好到哪去——一层套一层的lambda和大括号读代码的人得在脑子里模拟执行一整条流水线。迭代器失效也是老生常谈的坑。传统接口里begin和end是分开传的编译器很难验证你是否用错了“半开区间”。比如先保存了一个指向容器中元素的迭代器然后往容器里push_back再继续使用那个迭代器。在容器扩容时这就是未定义行为可能当时不崩数据量大时才崩排查难度极高。ranges并不能完全消灭这类问题但它用更高级的抽象、借用标记borrowed_range和概念约束把很多错误提前到了编译期。1.2 五类迭代器变成了“概念”传统迭代器分为输入、前向、双向、随机访问、连续存储五类一直靠标签结构iterator_category和标准库里的空类来区分。算法内部往往用std::iterator_traitsIt::iterator_category做分发。这套机制能工作但它是通过“类型内嵌标签”实现的不是编译器强校验的接口。如果一个自定义迭代器把iterator_category写错了标准算法可能悄悄选错实现产生诡异行为。ranges把这套分类重新表达成了conceptstd::ranges::input_range、forward_range、bidirectional_range、random_access_range、contiguous_range以及对应的迭代器概念std::input_iterator、std::forward_iterator等。模板的约束直接写在签名里编译器会检查传入的类型是否真的满足这些要求。比如你想调用std::ranges::sort但传入的是std::list只有双向迭代器编译期就会明确报错告诉你“不满足random_access_range”而不是等着运行期或产生难懂的实例化错误。概念检查的是行为而不是标签这从根上避免了“标签写错”这一类的低水平bug。给你个直观对比对比维度传统STLstd::ranges接口粒度迭代器对begin, end范围range对象算法调用std::sort(v.begin(), v.end())std::ranges::sort(v)组合方式嵌套调用/临时容器视图管道符 结尾标记必须和begin同类型迭代器可以是不同类型哨兵类型检查iterator_category标签concept约束生命周期提示无borrowed_range等2. std::ranges迭代器的核心设计拆解2.1 sentinel哨兵到底解决了什么传统迭代器接口要求end()必须返回和begin()相同类型的迭代器。这在很多场景下并不自然。最典型的例子是“以空字符结尾的字符串”它的结尾天然是一个特殊标记不是一个指向某个元素的迭代器。另一个例子是输入流文件流读到末尾用“文件结束”作为判断条件而不是预先知道最后一个元素在哪。std::ranges允许end()返回另一种类型——哨兵。只要这个类型满足sentinel_for概念也就是能和迭代器做相等比较算法就能正常工作。这就让很多原本“不是范围”的东西变成了范围。比如输入流视图可以用哨兵表达“到EOF为止”不需要提前把所有数据加载进容器。从迭代器角度看哨兵带来的灵活性也很实在。老式算法函数模板必须统一Iterator模板参数而在ranges算法里iterator_t和sentinel_t是两个不同的模板参数。标准库很多算法因此可以直接接受“迭代器哨兵”的混合输入而不必强行把结尾包装成真正的迭代器。不过要提醒一点如果begin()和end()返回不同类型这个类型通常是std::default_sentinel_t那么它就不是forward_range因为前向范围要求迭代器本身可迭代、可复制比较。通常这种范围只能算是input_range。这是设计上的取舍不是缺陷。2.2 视图view为什么能做到“不拥有”视图是ranges里的另一个基石。一个视图是一个轻量级的范围它不拥有底层数据而是持有另一个范围的引用或少量状态。视图最重要的特性是惰性求值在管道表达式里filter、transform这些操作并不会立刻执行而是等到遍历的时候才按元素逐个计算。auto result vec | std::views::filter([](int x) { return x % 2 0; }) | std::views::transform([](int x) { return x * x; });这行代码执行完vec没有被修改result里也没有拷贝出新的容器。它只是一个描述“我要先过滤偶数再平方”的懒惰管线。真正计算发生在对result的循环里。这种设计大大降低了组合的成本没有中间容器分配、没有多余的拷贝每个元素只经过一次处理管线。写自定义视图时“不拥有数据”是一条硬约束。如果你想写一个继承std::ranges::view_interface的类型它必须满足std::ranges::view概念也就是要可移动、析构不能有异常、并且不拥有超出自身的元素数据。你可以持有迭代器、可以持有函数对象、可以持有标量参数但不应该持有整个容器。2.3 借用范围编译器开始帮你管生命周期生命周期问题一直是迭代器的“阿喀琉斯之踵”。传统接口里迭代器指向某个容器容器销毁后迭代器就成了悬空指针编译器通常不吭声。ranges里引入了一个叫borrowed_range的概念用来标记一种情况这个范围的迭代器可以安全地在范围对象销毁之后继续使用。典型例子是std::span和std::string_view它们本身只是“指针长度”底层数据归别人管所以它们是borrowed_range。而std::vector是拥有数据的容器不是borrowed_range。为什么这个标记重要因为有的算法要返回迭代器或子范围如果输入范围是借用的返回的迭代器还能安全使用如果输入范围拥有数据返回的迭代器则有悬空风险。这在C23的std::ranges::find等接口上体现得更明显某些重载直接返回子范围而不是迭代器。编译器能根据borrowed_range的约束提前拦住一部分危险代码这在传统STL里是想都不敢想的。3. 实操组合、互操作和自定义迭代器3.1 一个能直接跑的管道示例先来一个完整的例子。需求是从1到10里过滤出偶数平方后取前三个然后求和。用C20 ranges写出来是这样#include ranges #include vector #include iostream #include numeric int main() { std::vectorint nums {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; auto pipeline nums | std::views::filter([](int n) { return n % 2 0; }) | std::views::transform([](int n) { return n * n; }) | std::views::take(3); // 遍历并打印4, 16, 36 for (int v : pipeline) { std::cout v \n; } // 求和。C23 有 std::ranges::fold_left但 C20 暂时用传统方式 int sum 0; for (int v : pipeline) { sum v; } std::cout sum sum \n; // 56 }注意这里pipeline是整个链式表达式的结果它既不是容器也没有在管道拼接时发生任何元素级计算。循环第一次访问第一个元素时filter开始从nums里找偶数找到2后传给transform变成4再传给take输出第二次循环继续往下找直到满足三个元素为止。这个例子里没有std::accumulate直接作用在view上因为std::accumulate要求迭代器类型相同而带take的视图begin和end类型不同。如果你想在C23环境里用std::ranges::fold_left可以写成std::ranges::fold_left(pipeline, 0, std::plus{})。如果你的编译器还不支持C23用循环累加就好完全没问题。3.2 和旧代码互操作从视图到容器的桥接现实项目里不可能全是C20新代码大量代码还是接收迭代器对的老算法。好在ranges并没有抛弃迭代器对它只是提供了一套更上层的调用方式互操作主要靠这几个工具// 从视图拷贝到容器 std::vectorint evens; std::ranges::copy( nums | std::views::filter([](int n) { return n % 2 0; }), std::back_inserter(evens) ); // 把整个 view 转化成 vectorC23 可用 // auto evens nums | std::views::filter(...) | std::ranges::tostd::vector();另一个常用桥接是通过std::ranges::begin和std::ranges::end拿到底层迭代器auto view nums | std::views::transform([](int n) { return n * 2; }); std::sort(std::ranges::begin(view), std::ranges::end(view)); // 注意view一般不可排序这里仅示意迭代器对获取这里要特别提醒transform视图产生的新值是没有底层存储的直接排序这种视图没有意义。std::ranges::sort要求能随机访问且能交换元素而transform_view的迭代器通常不提供真正的写回语义除非底层范围允许。所以实操中要把view先拷贝到容器再排序而不是试图直接排一个变换后的视图。3.3 手写一个能跑的迭代器并接入ranges自定义迭代器在ranges时代仍然有用而且比以往更简单因为概念是行为约束不依赖内嵌标签的准确程度。下面我写一个非常简化的“偶数序列”范围它从0开始每步2直到小于max_为止。重点看迭代器需要满足哪些要求才能被ranges算法拉进来干活。#include ranges #include iostream #include algorithm struct even_seq_view : std::ranges::view_interfaceeven_seq_view { int max_ 0; struct iter { int cur_ 0; int max_ 0; using value_type int; using difference_type std::ptrdiff_t; using iterator_concept std::forward_iterator_tag; int operator*() const { return cur_; } iter operator() { if (cur_ max_) cur_ 2; return *this; } void operator(int) { *this; } bool operator(const iter) const default; }; auto begin() const { return iter{0, max_}; } auto end() const { return iter{max_, max_}; } }; int main() { even_seq_view ev{10}; // 直接范围 for 遍历 for (int v : ev) { std::cout v ; // 0 2 4 6 8 } std::cout \n; // 作为范围传给 ranges 算法 int sum 0; std::ranges::for_each(ev, [](int v) { sum v; }); std::cout sum sum \n; // 20 }这里最重要的几个点value_type和difference_type是迭代器必需的类型别名概念检查会用到。iterator_concept标识它是前向迭代器。如果你不写标准库里std::iterator_traits会尝试通过operator*和operator推导但容易退化成input_iterator能少定义尽量少定义不了显式写更稳。operator使用C20的 default可以直接拿两个迭代器比较。end就是cur_ max_状态的迭代器简单直接。继承std::ranges::view_interface会自动获得begin/end以外的成员函数比如empty()、size()如果可随机访问等。这个自定义迭代器写完后even_seq_view自动成为std::ranges::input_range、forward_range可以适配绝大多数ranges算法。这就是概念时代的红利你不用再手工维护五六个typedef标签只要行为满足编译器就认账。4. 避坑那些每个用ranges的人都会撞上的问题4.1 悬挂视图和临时对象这是我见过最普遍的坑没有之一。因为视图不拥有数据所以底层范围如果是一个临时对象管道表达式结束后临时对象就被销毁了视图却还活着用一次就是未定义行为。// 危险临时 vector 在完整表达式结束后销毁 auto evens std::vectorint{1, 2, 3, 4, 5, 6} | std::views::filter([](int n) { return n % 2 0; }); // 此时 evens 底层已经悬空 for (int n : evens) { /* UB */ }保险写法是先把一个具名变量保存底层范围auto source std::vectorint{1, 2, 3, 4, 5, 6}; auto evens source | std::views::filter([](int n) { return n % 2 0; });或者用std::ranges::owning_viewC23喜欢地把临时范围“吞”进视图但这不是兜底方案更稳妥的还是别拿临时对象拼管道。遇到要返回一个基于容器数据的视图时也尽量避免直接从函数返回视图局部容器除非把容器一起返回否则大概率悬空。4.2 惰性求值不等于缓存结果视图是惰性的意味着每次遍历都可能重新执行所有变换和过滤逻辑。对纯函数来说没什么但如果lambda是有状态的或者底层数据在两次遍历之间被修改结果可能和直觉不一致。int count 0; auto counted nums | std::views::transform([](int n) { count; return n * 2; }); std::ranges::for_each(counted, [](int){}); std::ranges::for_each(counted, [](int){}); std::cout count \n; // 等于 nums.size() * 2而不是 nums.size()这不是bug是特性。视图是流水线不是缓存。如果你需要多次遍历同一组计算结果最直接的做法是从视图生成一个具体容器或者接受重复计算的开销。另外filter视图内部有缓存迭代器的优化但它缓存的是“满足条件的元素位置”不是元素本身。在循环中多次解引用同一个位置没问题但跨遍历就不保证复用。4.3 编译错误信息与模板噩梦ranges最大的门槛其实是编译时报错。一个复杂的view链错误信息经常把filter_viewtransform_viewref_viewvectorint, lambda这样的超长类型名重复好几层看到脑壳疼。我的经验是不要把一整条管道压在一行里硬调。先把lambda提成命名函数对象constexpr auto even [](int n) { return n % 2 0; }; constexpr auto square [](int n) { return n * n; }; auto step1 nums | std::views::filter(even); // 先编译这行 auto step2 step1 | std::views::transform(square); // 再加一层 auto step3 step2 | std::views::take(3); // 再截断这样出错时能快速定位在哪一步。如果编译器把lambda的函数签名报错通常是捕获方式有问题比如默认按引用捕获局部变量导致view持有悬空引用这类问题在管道里特别隐蔽。4.4 性能与编译时间的现实账惰性view的零开销抽象在大多数场景是真的但顺序很重要。以“过滤变换”为例如果先transform再filter你会对每个元素都做变换而先filter再transform只对满足条件的元素做变换。在数据量大、变换函数重时量级差异是明显的。所以语义允许的情况下把filter尽量往管道前面放。编译时间也没有魔法。view链会产生大量模板实例化每个operator|都是一层新的类型嵌套。在大型项目里如果头文件里放了超长的view表达式每包含一次就是一次编译负担。建议把复杂管道封装在.cpp内部的辅助函数里不要暴露到接口头文件。还有如果你的项目还不打算全部升级C20至少要统一编译标准不然视图相关头文件在不同编译单元里可能行为不一致。主流C编译器在C20 ranges上已经算稳定了但C23里那些方便的工具比如std::ranges::to、fold_left支持程度参差不齐。跨平台项目开工前先看一眼当前编译器和标准库的支持表别把代码写到不能用的特性上。5. 移植策略从哪行代码开始用ranges5.1 渐进式替换路线如果你想在存量代码里引入ranges我建议按这个顺序来风险从小到大第一替换算法调用。std::sort(v.begin(), v.end())改成std::ranges::sort(v)std::find_if改成std::ranges::find_if这类改动语义几乎不变编译器会立刻告诉你哪里不满足概念。第二替换手写循环。原来要写for (auto it v.begin(); it ! v.end(); it)的地方只要能改成for (auto x : v)就顺手改掉这本来就该如此。第三引入简单视图组合。先从最直观的views::filter和views::transform开始替换之前嵌套临时容器的代码。第四等熟练了再写自定义视图或哨兵。这样的好处是每一层都有独立的收益即使卡在某一层之前的改进也是实打实的。5.2 团队代码规范与审查要点ranges代码看着简洁但坑多且隐蔽。团队引入后代码审查里我会重点盯这几件事一是生命周期。构建视图时底层范围是不是临时对象。二是视图是否被存进成员变量。视图进入成员变量通常会引入复杂的生命周期耦合大部分情况下应该让视图在局部函数内流动而不是长期保存。三是重复遍历。同一个view在函数里被遍历两次以上要看是否真的接受重复计算。四是可读性。一行管道超过三个操作符我会建议拆成两三个具名变量给每一步一个可读的语义名称。五是自定义迭代器。要明确标注iterator_concept并确保它满足对应的概念约束不要靠碰运气。记得有位同事当初写自定义迭代器时没写difference_type结果在某标准库实现上报错换了一个标准库版本又安静通过这种跨平台隐患特别磨人。概念约束解决的是“行为是否符合”但你仍要保证迭代器该有的类型别名齐全。我自己的经验是ranges很适合做那些“把数据洗成另一种样子”的管道逻辑读起来像在表达需求而不是在描述机器步数。但也别迷信它复杂状态机逻辑、需要对多个容器同时遍历的场景硬套ranges反而会让代码拧巴。用不用、用在哪一层才是真正考验架构能力的地方。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑