资讯动态

C++20 std::ranges实战:用视图与惰性求值重构迭代器代码

发布时间:2026/10/21 7:51:34 来源:尧图企业网站定制
说实话我第一次在项目里大面积替换掉手写的循环和std::find_if组合拳时心里是有点打鼓的。用了这么多年 C突然冒出std::ranges这种新式写法总觉得不太踏实。但等我真正把views::filter、views::transform这些玩意儿用顺了再回头看以前那一长串begin()、end()、迭代器加减的代码脑子里的第一反应就是这玩意儿我早该换了。这篇东西不打算给你念标准文档就讲我这一段时间把std::ranges用在真实业务逻辑里的体会、踩过的坑以及那些官方文档里不会写明白的“为什么”。如果你也是个每天跟vector、map打交道的人或者刚接触 C20 想看看 ranges 到底能省多少事这篇应该能帮你少走点弯路。1. ranges库到底解决了什么问题1.1 迭代器分离的痛点先花半分钟回忆一下没有 ranges 的日子。你想对一个整数数组做点处理最朴素的写法是std::vectorint nums {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; for (auto it nums.begin(); it ! nums.end(); it) { if (*it % 2 0) { std::cout *it ; } }这还不算太糟。可一旦你想“先把偶数挑出来再原地翻倍最后只输出前三个”代码就变成嵌套循环加一堆临时判断得小心翼翼维护迭代器位置稍不留神就在erase之后用了失效的迭代器。更要命的是std::algorithm家族的函数全都长一个样清净一点的std::find_if(v.begin(), v.end(), pred)写多了手是真的累而且这堆begin()/end()重复出现本身就是噪音。我当时在一个数据处理模块里做过统计一个稍微复杂点的过滤逻辑能堆出二十几行迭代器代码而其中绝大多数行不是业务逻辑而是在“哄”迭代器不要把位置弄丢。1.2 range概念和一统接口std::ranges做的事情说白了就是把“容器或任意可迭代的东西”包装成一个范围然后让算法直接接收这个范围整体而不是一对儿迭代器。std::ranges::sort(nums); std::ranges::find_if(nums, pred);就这么简单。std::ranges::sort直接吃容器内部自己处理begin和end。范围的概念不只是语法糖它在 C20 里是一个完整的concept概念体系有std::ranges::range、view、sized_range、borrowed_range等等。这一层不只是“少敲几个字符”它让“可迭代的东西”被抽象成了一个一等公民类型。我举个例子以前你写模板函数想接受任意可迭代容器得这样template typename Container void process(Container c) { for (auto it c.begin(); it ! c.end(); it) { ... } }现在你可以写void process(std::ranges::range auto c) { for (auto v : c) { ... } }这还只是最基本的。真正的杀手锏是接下来要聊的视图view和适配器adapter它们才是 ranges 生态的精华。2. 视图和适配器惰性求值的核心技术2.1 三个最常用的视图适配器std::views命名空间下有一堆适配器我实际项目里用到最频繁的就三个filter、transform、take。views::filter按条件筛选元素相当于“只留下满足某条件的元素”。views::transform对每个元素做映射变换相当于“对每个元素执行一个函数”。views::take / views::drop取前 N 个元素 / 跳过前 N 个元素。单独用任何一个威力都有限。它们真正的价值在于可以像水管一样接起来。举个例子我想从一个学生成绩列表里取出所有及格 60 分的成绩然后只看前 5 个std::vectorint scores {88, 45, 92, 37, 73, 60, 99, 41, 85, 66}; auto passed scores | std::views::filter([](int s) { return s 60; }) | std::views::take(5);读这段代码非常顺scores流进filter再由take截断。不需要临时容器不需要中间变量数据和操作链在一起。我第一次写出这种代码的时候愣了一下因为这跟 Python 里那种“函数式处理”几乎一模一样但这是原生 C。2.2 管道组合符怎么读管道操作符|是理解 ranges 组合逻辑的关键。它的本质是函数复合composition。如果你写过std::bind或者用过函数式语言应该对这种“从左到右的数据流”很熟悉。auto result nums | std::views::transform([](int x) { return x * x; }) | std::views::filter([](int x) { return x % 2 0; });从左往右读就完事了先把nums里每个数平方再从平方结果里筛偶数。逻辑顺序和人类思维顺序完全一致而不是以前那样从最内层括号往外读。这里有个细节值得拿出来讲管道的执行顺序是从左到右的也就是说先执行的 transform再执行 filter。我自己就吃过一次亏一开始想当然以为 filter 先执行导致筛选条件作用在了错误的数据上。写管道链之前脑子里过一遍数据流的方向比事后调试要省时间得多。2.3 惰性求值的原理视图最反直觉的一点是——它不会立刻执行任何操作。nums | views::filter(...) | views::transform(...)这一整条链仅仅构造了一个“视图对象”它保存了对nums的引用或者拷贝取决于具体情况和操作逻辑。只有当你在for循环里遍历它或者把它传给某个范围算法时元素才会被逐个计算出来。这就带来一个巨大的好处如果你想从一千万个数字里找出前面 10 个满足条件的数视图链不会像传统写法那样先把整个筛选结果生成到一个新 vector 里而是一次一个生成取满 10 个就停。换句话说take(10)会中断整条链的后继计算。这是手写循环都不一定写得优雅的性能优化机会。我脑海里第一次意识到这个特性价值是在处理一个实时日志流的时候日志可能有几十万条但我每次只关心前几条匹配特定规则的内容。用 ranges 链后取完就停完全不担心内存和多余计算。3. 实战我在项目中怎么用ranges光说理论不过瘾我把真实业务里用到的几个场景简化一下拿出来讲讲那些写了 ranges 之后让我觉得“真香”的瞬间。3.1 成绩筛选排序场景这是最经典的一个场景很多人入门 ranges 都是从这开始的。假设有一个保存学生记录的struct Student { std::string name; int score; };我需要找出所有及格的人按成绩从高到低排序最后只保留前三个名字struct Student { std::string name; int score; }; std::vectorStudent students { {张三, 88}, {李四, 45}, {王五, 92}, {赵六, 37}, {孙七, 73}, {周八, 60}, {吴九, 99}, {郑十, 41}, {钱一, 85}, {陈二, 66} }; auto top3 students | std::views::filter([](const Student s) { return s.score 60; }) | std::views::transform([](const Student s) { return s.name; }) | std::views::take(3);等一下我是不是漏了排序确实std::views里没有sort因为排序本身是“急切求值”的操作没办法做在惰性视图上。实际项目里我是先排序再灌入视图链std::vectorStudent sorted_students students; std::ranges::sort(sorted_students, std::greater(), Student::score); auto top3 sorted_students | std::views::take(3) | std::views::transform([](const Student s) { return s.name; });注意这里我用了一个新特性std::ranges::sort的第三个参数可以直接传成员指针Student::score它会自动把元素按这个成员排序不用再手写 lambda。这也是 ranges 系列算法比旧版算法更省事的一个点。但是这里有个坑必须提醒你上面第一段代码先 filter 再 transform得到的是一个视图视图里的元素是std::string的引用还是拷贝取决于后续怎么使用。如果只是for (auto name : top3)遍历那没问题但如果top3这个视图在sorted_students被修改或析构之后继续使用就会产生悬垂引用。后面 4.1 节我再仔细说。3.2 字符串与容器处理场景处理std::string也是 ranges 的高频场景。比如一个字符串我想把它按空格分词过滤掉空串再转成大写std::string text hello world ranges is awesome; auto words text | std::views::split( ) | std::views::filter([](auto part) { return !part.empty(); }) | std::views::transform([](auto part) { std::string s(part.begin(), part.end()); std::transform(s.begin(), s.end(), s.begin(), ::toupper); return s; });views::split是 C20 里比较难搞的一个适配器它返回的每个元素并不是std::string而是一个“子范围”你需要手动转成字符串。我第一次用它时直接在这个地方卡了十几分钟因为调试器里看到的类型非常绕。上面代码做了显式转换属于最直白的处理方式。如果你觉得views::split的类型障碍太大还有一个备选方案先用ranges::to扩展下面会说或者干脆用传统getline分词。别较劲工具怎么顺手怎么来。3.3 嵌套循环与扁平化处理再来一个稍微进阶的场景处理二维数据。假设有一个std::vectorstd::vectorint我想把所有内层 vector 的元素全部摊平筛选大于 5 的数字翻倍后求和。传统写法至少三层循环嵌套而 ranges 社区给出的方案是views::joinC20 里叫views::joinC23 还加了views::join_withstd::vectorstd::vectorint matrix { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} }; auto sum matrix | std::views::join | std::views::filter([](int x) { return x 5; }) | std::views::transform([](int x) { return x * 2; }) | std::ranges::tostd::vector();这里我用了std::ranges::tostd::vector()它是 C23 引入的功能把视图“物化”成一个真实容器。如果你还停留在 C20就得写循环 push_back或者借助std::vector的范围构造函数std::vectorint result; for (int x : matrix | std::views::join | std::views::filter(...)) { result.push_back(x); }我个人是很喜欢ranges::to的它让“管道处理完直接变成容器”变得无比自然。不过这东西依赖相对较新的编译器如果项目还在 C20 标准下我通常会封装一个小的to_vector辅助函数道理一样。4. 常见问题与排查技巧实录4.1 悬垂引用dangling问题这是 ranges 使用中最深的一个坑官方标准里专门讨论过社区里没少翻车。简单说视图默认不持有数据它只是“借用”底层容器的元素。如果底层容器生命周期结束了视图就会指向已释放的内存属于经典的悬垂引用。最典型的一个例子auto get_view() { std::vectorint tmp {1, 2, 3, 4}; return tmp | std::views::filter([](int x) { return x % 2 0; }); }这里tmp在函数返回时就被销毁了返回的视图却还“记得”tmp你用这个视图遍历得到的全是未定义行为。这种 bug 不一定会立刻崩溃表现形式往往是“时好时坏换个编译器优化级别就变了”极其难排查。我自己差点写过这种代码后来形成了一条铁律视图仅仅应用于局部、短生命周期的管道处理中如果要返回一个处理链的结果一定用ranges::to物化成真实容器再返回。4.2 临时对象的生命周期坑比 4.1 更隐蔽的是临时对象绑定到视图的问题。看这个例子auto view std::vectorint{1, 2, 3} | std::views::reverse;你觉得没事但这已经悬垂了。原因是std::vectorint{1, 2, 3}这个临时量在语句结束前就被销毁确切地说是完整表达式结束时而views::reverse保存了对这个临时量的引用。标准库运气好可能给你一个编译期错误因为owning_view的存在某些情况标准库会主动拷贝一份更多时候是互相依赖的瞬间被销毁你甚至无法确定到底是谁在裸奔。为了避开这类问题我的做法很粗暴只要是临时对象参与的视图链要么在链的末端用ranges::to立刻物化要么直接用一个具名的局部变量把临时对象接住延长它的生命周期。具名变量的生命周期至少会延长到作用域结束这样视图链就能安全使用。4.3 类型推导和编译错误排查ranges 的编译错误几乎是所有用过它的人共同的痛。视图的类型名字长到离谱。比如一个filter之后接transform类型可能是std::ranges::transform_viewstd::ranges::filter_viewstd::ranges::ref_viewstd::vectorint, ..., ...在错误信息里能占好几行。碰到这种情况我的排查套路是固定的优先看最后一个约束失败点。模板错误信息里最后通常有一个static_assert或者 concept 未满足的提示从这里往上找比从头啃效率高得多。用auto承接变量。如果你不确定类型千万不要把视图类型写在变量声明里写成auto view ...;就省了一堆编译错误。检查 lambda 的捕获和参数类型。最常见的错误是 lambda 的参数是const Student但实际传给它的可能是Student类型不匹配导致 concept 校验失败。提示如果你在 IDE 里看到铺天盖地的错误先检查你的 lambda 是否接受了正确的引用类型。根据我的经验ranges 相关的报错有一半以上是函数签名不匹配导致的。5. 性能考量与实用心得5.1 惰性求值带来的性能优势很多人担心 ranges 这么多抽象层性能会不会拉胯答案是绝大多数情况下不会。因为视图适配器大多是“零开销抽象”标准库实现会尽量把多层操作内联展开。我做一个简单的对比测试对一个一百万元素的std::vectorint做 filter transform 累加分别用传统手写循环和 ranges 管道// 传统方式 long long sum 0; for (int x : nums) { if (x % 2 0) { sum x * 3; } } // ranges 方式 auto sum2 nums | std::views::filter([](int x) { return x % 2 0; }) | std::views::transform([](int x) { return x * 3; }); long long total 0; for (int v : sum2) { total v; }实测下来在开启-O2优化后两者耗时几乎相等有时 ranges 版本还稍微快一点点可能因为编译器对 view 管道的优化更激进。如果只看从视图构造到for循环之前的时间ranges 版本几乎是零开销因为根本没真正计算。当然这里有个前提别引入昂贵的中间物化。如果你在管道中间用ranges::tostd::vector()物化一次那必然会多一次分配和拷贝。惰性求值最大的价值就是能让整个管道停在“视图”状态只在最终消费时一次性算完。5.2 什么时候别用ranges虽然我很喜欢 ranges但也不是所有场景都适合硬上。我总结了几种情况建议还是用传统写法代码库还停留在 C17 或更早标准。这时候用 ranges 不现实除非引入第三方库比如某个很出名的 range-v3但跨团队推广第三方库的负担往往比收益大得多。需要精确控制循环内部的复杂状态。比如你在循环中要同时更新两个计数器、暂停条件、抛异常并回滚这种情况下强行改成 ranges代码反而变得别扭。调试复杂度高于可读性收益时。视图链一旦长了比如五六个适配器串一起出问题时单步调试的结果非常抽象每一步都在一个巨大类型里跳来跳去。对于刚接触 ranges 的团队我一般建议先把链控制在三个适配器以内等熟练了再玩花的。就我个人经验而言ranges 最舒服的使用场景是“读多写少转换固定”的数据处理流。业务逻辑本身变化多端、状态复杂的场景倒不如老老实实写循环代码的可维护性更好。5.3 编译期与可读性的权衡最后聊一个实际项目里绕不开的问题编译时间。模板实例化深度上去了编译时间确实会变长。我自己在用 ranges 重构一个核心模块后编译时间从大约 12 秒涨到了 17 秒左右。对于一个全量构建来说这个涨幅不算小。解决方案因人而异我自己用的几个土办法把复杂视图链封装成独立的函数减少在头文件里的内联模板代码。尽量在.cpp文件里使用 ranges别到处在头文件展开。多个视图链复用的情况先把公共部分提取成一个auto get_base_view()之类的东西减少重复实例化。另外我还发现一个有意思的细节把长链拆成两个中间变量反而可读性更好且编译压力小很多。比如auto evens nums | std::views::filter([](int x) { return x % 2 0; }); auto first10 evens | std::views::take(10); auto result first10 | std::views::transform([](int x) { return x * 2; });这种写法更贴近常人思考习惯调试起来也更轻松。C 的 ranges 设计思路很好但没必要为了“一行流”硬拗成一个巨型管道那样反而失去了它的初衷。写在最后的一点体会用std::ranges这段时间我最大的感触不是“代码变短了”而是我的思维方式变了。以前拿到一个数据处理需求第一反应是“循环怎么写、迭代器怎么挪”现在第一反应是“这个数据流应该经过哪几个变换步骤”。当然它也不是银弹。视图的复杂性、编译期的成本、团队学习的门槛这些都需要在现实项目里权衡。但如果你在维护的代码里经常出现“过滤 转换 截断 聚合”这种组合需求趁早试试 ranges你可能会跟我一样在清理完最后一处手写迭代器循环时感受到一种久违的畅快。

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

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

免费获取报价 →
↑