资讯动态

std::ranges 深度实践:C++20 视图、算法与性能优化指南

发布时间:2026/10/20 8:25:19 来源:尧图企业网站定制
1. 废话少说为什么std::ranges值得你花一个下午搞明白先抛个场景。你写了一段代码要把一个vectorint里的偶数挑出来、翻倍、再求和。传统写法长这样std::vectorint v{1, 2, 3, 4, 5, 6, 7, 8}; std::vectorint evens; std::copy_if(v.begin(), v.end(), std::back_inserter(evens), [](int n) { return n % 2 0; }); std::vectorint doubled; std::transform(evens.begin(), evens.end(), std::back_inserter(doubled), [](int n) { return n * 2; }); auto sum std::accumulate(doubled.begin(), doubled.end(), 0);够直白但问题也很明显中途多了两个临时容器代码被迭代器对begin()和end()撕成了一截一截读起来得手动把三段拼进脑子里。而用 C20 的std::ranges写同一个逻辑auto sum std::ranges::fold_left( v | std::views::filter([](int n) { return n % 2 0; }) | std::views::transform([](int n) { return n * 2; }), 0, std::plus());一行管道把筛选→变换→折叠串成了自然语言式的链路没有中间容器没有迭代器对。这就是std::ranges系统最直观的价值让算法更贴近人类表达同时干掉大量手写迭代器代码。这篇我想聊的不是照抄文档而是在实际项目里把std::ranges用起来之后我理解的这整套系统是怎么设计的、有哪些真正好用的点、哪些性能坑要绕开。适合三类人看刚学完 C 基础、听说过 ranges 但不知道从哪下手的写了好几年 STL 算法、觉得begin/end写烦了的老手以及负责团队代码规范、想评估要不要引入 ranges 的技术负责人。2. 从迭代器对到范围这套系统到底改了什么2.1 核心不是管道符是概念上的范围很多人第一次看到std::ranges就盯着|操作符看觉得这是新语法。其实管道符只是语法糖真正的核心是把范围Range提升为一等公民。传统 STL 算法有个根深蒂固的设计算法操作的是迭代器对也就是从哪开始和到哪结束。这带来了一个长期被吐槽的毛病——算法和容器之间永远隔着一层。你要排序一个vector写的是sort(v.begin(), v.end())而不是sort(v)。该怎么解释这个转变呢。可以这样想传统算法像拆快递你递过去的是绳子这头和绳子那头距离多少、中间有没有缠着胶带算法一概不管而 ranges 改成了你把一整团绳子递过去它自己知道从哪头开始、到哪头结束、中间经过什么结构。这个抽象可不是仅仅是少敲几个字母。它意味着算法可以直接吃容器ranges::sort(v)而不是sort(v.begin(), v.end())。算法可以吃视图viewfilter、transform运算得到的东西也是一个范围可以继续传给别的算法。算法可以吃哨兵sentinel范围的结尾不必非得是指向最后一个元素之后的迭代器可以是任何能判断是否到达末尾的谓词对象。这第三点在日常代码里最不起眼但其实是整套系统里最深刻的设计之一。以前你处理以 \0 结尾的字符串或者读文件直到某个标记得先把它们转成有end()的容器或者硬写循环判断。现在你只要定义恰当的类型迭代器 哨兵算法直接就能在上限未定的范围上工作。后面实战部分我会给个例子。2.2 三个支柱视图、算法、适配器的分工std::ranges不是一个大一统库而是三块咬合在一起的部分组件作用常见例子std::ranges::xxx算法操作一个范围对象的算法sort、transform、filter、find_if、fold_leftstd::views::xxx视图适配器把范围变换成新范围延迟计算filter、transform、take、drop、reverse、join范围概念与工具定义什么样的类型是范围、提供辅助工具std::ranges::range、std::views::all、std::ranges::size、std::ranges::subrange三个东西分工非常明确视图管怎么变换数据算法管怎么处理数据概念管什么样的对象能参与这套体系。三者独立演进又互相咬合这是整个设计最讲究的地方。打个比方视图是装修方案变换瓷砖换成木地板算法是验收标准检查地面是否平整概念是房屋基本条件必须有四面墙和屋顶否则免谈。2.3 懒惰求值为什么view不会复制数据视图适配器最核心的行为特征是懒惰求值——普通算法是你调用它它立刻把所有元素处理完而视图是你调用它它只是搭好了一条流水线真正处理要等你去消费元素的那一刻。用filter举例auto evens v | std::views::filter([](int n) { return n % 2 0; });执行这一行的时候filter并没有遍历v没有把偶数挑出来存进一个新容器甚至没有调用一次 lambda。它只是创建了一个视图对象这个对象内部保存了底层范围v的引用和筛选谓词。等你什么时候for (int n : evens)或把evens传给某个算法时它才开始一个接一个地检查元素。这个设计的好处有两个零拷贝、零分配。你永远不需要把中间结果存下来。对于动辄几十万条记录的日志文件、传感器时间序列这一点直接决定了内存占用是 O(n) 还是 O(1)。可以无限拼接。比如| take(5)从无限序列如输入流、随机数生成器里取前 5 个你不需要先生成完整序列。用传统 STL 你根本没法优雅地表示无限序列因为迭代器对要求序列必须有尽头而 ranges 的视图配上哨兵可以表达无穷尽的范围。我刚开始也被懒惰这个特性绕晕过总觉得怎么它不干活。后来我用一个类比搞明白了视图是电视遥控器上的直播画面——你按下频道1按钮屏幕并不会立刻把未来一天的所有节目都缓存下来它只是把频道指针调了一下真正播放是每秒都在发生的。视图就是调整频道指针那一下真正播放是你遍历元素那一刻。3. 先看效果再聊原理一个真实脚本被 ranges 改写的全过程3.1 改前的经典写法某次写一个小工具要从一堆日志文本里筛出所有ERROR级别的行提取错误码比如[E1001]这种格式按出现次数从高到低排序最后输出 Top 3。先行代码是这样的很常见std::ifstream file(app.log); std::vectorstd::string errors; std::string line; while (std::getline(file, line)) { if (line.find(ERROR) ! std::string::npos) { errors.push_back(line); } } std::vectorstd::string codes; for (const auto e : errors) { auto pos e.find([); if (pos ! std::string::npos) { codes.push_back(e.substr(pos 1, 5)); } } std::mapstd::string, int counter; for (const auto c : codes) { counter[c]; } std::vectorstd::pairstd::string, int sorted(counter.begin(), counter.end()); std::sort(sorted.begin(), sorted.end(), [](const auto a, const auto b) { return a.second b.second; }); for (int i 0; i 3 i sorted.size(); i) { std::cout sorted[i].first sorted[i].second \n; }能跑但这里有多少次中间拷贝errors拷贝了所有 ERROR 行codes拷贝了提取出的错误码sorted又从 map 拷了一份进 vector……这个脚本处理的日志只有几百行无所谓但如果日志有几 GB这种写法会直接让内存爆掉。还有读代码的人要把五个循环串起来才能知道整体在做什么心智负担不轻。3.2 改后的 ranges 版本用std::ranges重构后长这样std::ifstream file(app.log); std::string line; std::mapstd::string, int counter; for (auto input : std::ranges::istream_viewstd::string(file)) { if (auto pos input.find(ERROR); pos ! std::string::npos) { if (auto bracket input.find([); bracket ! std::string::npos) { counter[input.substr(bracket 1, 5)]; } } } std::vectorstd::pairstd::string, int sorted(counter.begin(), counter.end()); std::ranges::sort(sorted, std::greater{}, std::pairconst std::string, int::second); for (auto [code, count] : sorted | std::views::take(3)) { std::cout code count \n; }改动点逐个说明std::ranges::istream_viewstd::string(file)把文件流包装成一个范围每迭代一次读入一行字符串。它依然是按需读取不会预读整个文件。两个if嵌套代替了原来的errors和codes两个中间 vector。注意 C17 引入的if (auto x ...; 条件)语法搭配使用作用域紧绑在一起变量生命周期更精确。排序直接用std::ranges::sort不再需要sorted.begin(), sorted.end()而是整个sorted。第四参数传了一个投影projectionstd::pairconst std::string, int::second——这又是一个 ranges 带来的好东西下面单独说。输出时用sorted | std::views::take(3)优雅地控制只取前 3 个不需要手写i 3 i sorted.size()这种边界判断。提示std::pairconst std::string, int::second这样的写法在部分编译器上需要显式指定成员指针类型dangling 风险很低。如果嫌难看也可以写个 lambda[](const auto p) { return p.second; }。3.3 投影Projection这玩意儿是隐藏的宝贝上面解了ranges::sort的第四参数投影。它允许你在不创造临时对的情况下告诉算法请按这个属性的值来操作元素。以前你要按学生成绩排序就得写完整比较 lambdastd::sort(students.begin(), students.end(), [](const auto a, const auto b) { return a.score b.score; });用 rangesstd::ranges::sort(students, std::greater{}, Student::score);更妙的是投影不光能用于排序find_if、count、lower_bound等大量 ranges 算法都支持。比如查找第一个成绩及格的学生auto it std::ranges::find_if(students, [](int s) { return s 60; }, Student::score);这里的 lambda 拿到的直接就是score的引用而不是整个Student对象。代码意图瞬间清晰我要找这么一个人他有一个 score 字段这个字段满足某种条件。投影把取字段这个动作从比较谓词里剥离出来让每个关注点各管一摊。3.4 一个小插曲编译器的版本要求入坑 ranges 之前先检查编译器。截至 2024 年初GCC 12、Clang 16、MSVC 19.31 对 ranges 的支持都比较完整。GCC 10/11 对一些 fancy 的视图如views::enumerate、views::chunk要么缺失要么有 bug。我的建议是直接上 GCC 13 或 Clang 17省得被明明代码照着 cppreference 抄的但编译不过气得摔键盘。std::ranges::fold_left是 C23 才进的要用的也得注意标准版本C20 时期可以用std::accumulate(r.begin(), r.end(), 0)替代虽然看着不够帅但不影响逻辑。4. 容易翻车的几个点views 的生命周期和 const 问题4.1 悬垂视图Dangling View把临时容器丢了就完蛋这是 ranges 新手最常踩的坑没有之一。看这段std::ranges::range auto get_even_view(std::vectorint v) { return v | std::views::filter([](int n) { return n % 2 0; }); } auto main() - int { auto evens get_even_view(std::vectorint{1, 2, 3, 4}); // 灾难底层临时 vector 已经被销毁evens 里保存的是悬空引用 }视图默认是引用语义——它保存底层范围的引用或迭代器自己不拥有数据。第一行那个临时std::vectorint在表达式结束后就析构了而evens还傻乎乎地握着一个指向已灭亡容器的迭代器一行for (int x : evens)就是 undefined behavior轻则乱值重则段错误。这是 C 里老生常谈的悬垂引用问题在 ranges 里的形态。解决的思路也一致不要让视图比底层数据活得更久。视图的生命周期必须嵌套在容器的生命周期内。如果非要返回一个拥有数据的视图别用 filter/transform 这类引用视图可以先拷成std::vector等具体容器再返回或者用自己的 RAII 类包一层。我之前在日志分析工具里就犯过这个错。过滤器返回了一个局部范围上的视图函数一结束原先那个只读打开的文件内容缓冲区被回收整个视图变成一堆野指针。从那以后凡是视图要跨作用域传递我都额外加注释提醒自己此处按引用持有底层对象。注意views::all、views::filter这类视图绝不拥有数据而ranges::tostd::vector()C23是把范围急切地物化成容器的正确姿势——它真的把元素拷贝进去不再引用原范围。4.2 const 正确性视图和 const 容器一起用容易让编译器发飙简单规则const容器的视图只能用const谓词来直接作用在元素上但很多可变的视图如reverse、transform对const容器会抛出编译错误或显著限制。比如const std::vectorint v{1, 2, 3, 4}; auto rev v | std::views::reverse; // 在部分标准库实现里这行会编译失败因为 reverse_view 的 begin() 非常量方法调用受限原因很微妙reverse_view::begin()返回其内部迭代器该迭代器可能保存一个底层迭代器而底层迭代器在 const 容器里是 const_iterator正常应该能通过。但早期实现里reverse_view需要持有非 const 的 begin() 来满足某些概念约束导致 const 范围适配不佳。解决办法很土把容器声明为非 const 但语义上不修改或者在需要跨函数传递时显式转成std::views::all(v)再迭代。实际项目里最省心的是尽量保持容器非 const 传给视图只有在明确不会触发视图写语义的地方才放心用 const。读者可能会好奇为什么标准不把 const 这事儿完全做对。这里是客观现实有些视图比如iota_view、istream_view天生对是否可变迭代器敏感标准里对应的概念迭代器分类又保守导致你自查起来非常隐蔽。我的策略很简单交付前用static_assert(std::ranges::rangedecltype(myView))检查一遍基础性质再用编译期的概念断言去捕捉 const 相关错误剩下就交给 lint 工具了。4.3 迭代器类型太复杂auto 是你的朋友但不是全部视图链一长begin()的返回类型就变成了一串吓人的模板嵌套。别看不起编译器输出也别自己去手写那个类型——一律用auto必要的时候用decltype或者 CTAD 来拿类型。auto chain v | std::views::filter(...) | std::views::transform(...) | std::views::take(10); auto it chain.begin(); // 别试图手写它的类型 auto first *it;一旦你需要用视图的迭代器类型去声明其他东西立即变成一大坨无法维护的std::ranges::iterator_t...。万一标准库更新类型名字一变你手写的类型就崩了。所以写 ranges 代码的第一纪律任何迭代器型和视图型交给类型推导绝不手写。5. 从抄到造自建视图组件理解 view_interface 基础设施5.1 什么时候需要自写视图什么时候别写std::views提供了官方适配器全家桶但我遇到固定模式反复出现在十几个文件中时还是考虑封装成自定义视图。比如每 N 个元素归一组取平均值或者处理一个 CSV 行把每列解析成结构体——这些既不是标准库覆盖的行为又不想在业务代码里到处摆 for 循环。但先别急着造轮子。自写视图的代价是你不仅要实现begin()/end()还要确保符合一堆概念view、input_range、forward_range……还要处理值语义、拷贝语义、迭代器的六大 typedef。标准库里std::ranges::view_interface就是给你提供这些基础设施的——只需要你在派生类里实现begin()和end()它就可以免费提供size()、empty()、operator[]等一大堆便捷成员函数。5.2 一个最简单的自造视图每隔一个元素取一个stride下面这个实现适合拿来当模板它生成一个每隔 step 个元素才产出一次的视图template std::ranges::view V class stride_view : public std::ranges::view_interfacestride_viewV { private: V base_; std::ranges::range_difference_tV step_; std::ranges::iterator_tV current_; public: stride_view() default; stride_view(V base, std::ranges::range_difference_tV step) : base_(std::move(base)), step_(step) { current_ base_.begin(); } auto begin() { return current_; } auto end() { return base_.end(); } };光有begin/end还不够迭代器的自增得实现跳步逻辑。我们可以用一个非常简单的方案创建这个视图时就把迭代器直接推进到合适的起始位置而begin()返回的迭代器自增时维护一个内部计数器遇到step-1个元素就跳过、输出第step个。完整实现代码量不小不在这里全部展开。我想强调的是看官方 adapter 的源码比如drop_view、take_view是学习自造视图最快的路径。它们在实现上都利用view_interface提供的脚手架代码量其实很少。自造视图的时候先把这些条件想清楚设计问题自查项底层是单向范围还是双向范围这决定迭代器是输入迭代器还是前向迭代器能支持哪些算法step 为 0 或负数的行为必须定义别让 UB 溜进来视图的拷贝是否拷贝底层范围默认是浅拷贝符合语义即可end()返回值类型与begin()一致不一致就需要哨兵支持边界处理麻烦是否满足view概念必须是可拷贝构造和析构的且拷贝是 O(1)自造视图最大的价值不是代码复用而是让你更深地理解视图这个抽象的本质——它必须轻、必须是引用语义、必须正确建模范围的迭代能力和结束条件。5.3 性能面懒惰不等于免费但通常比临时容器快很多人看完上面的内容会有一个错觉既然是懒惰求值那性能肯定永远是最好的。真相是懒惰求值跳过的是分配内存 拷贝元素的开销但每次迭代元素时仍然可能多做几次函数调用和分支判断。比如前面的筛选偶数再翻倍例子。v | filter(偶数) | transform(翻倍)对于每个元素至少要经历 filter 谓词的一次判定、transform 谓词的一次调用。如果这两种操作本身非常廉价就是几次整数运算那传统写法两个临时 vector 三次循环相比 ranges 管道通常是差不多的开销因为传统写法也做了同样多次的谓词调用而且多了分配和拷贝。真正让 ranges 管道显得慢的场景是错误地在一个亿级元素的循环内部每轮重复构造同一串视图链。视图对象自身创建虽然便宜但在循环里反复创建、每次遍历效率确实比直接在循环体外建好再复用差一点。好在标准实现通常把这些小对象优化得很极致只要视图不在最内层循环里反复重新求值实际跑起来很难感觉到与手写循环有数量级差异。我在生产环境做过一个简单的 benchmark别太当真主要看数量级对一个 1000 万元素整数数组做过滤偶数 → 翻倍 → 累加手写循环 vs ranges 管道结果是手写循环快 20~30%。理论上差的这些点主要来自函数无法内联当谓词是无捕获 lambda 时现代编译器都能内联一旦用std::function或虚函数封装就会暴跌。所以性能建议很直白小数据集或中数据集放心用 ranges代码可读性收益远大于那百分之几的性能差。大数据集 热循环先用 ranges 写清楚再针对性 benchmark如果确实慢手写循环也不丢人。谓词必须避免虚函数调用和std::function开销。6. 我踩过的坑和团队的磨合实录前面讲了不少应该怎么做这里换个角度讲讲实际项目中踩过的几个真坑以及我们团队从禁止用 ranges到默认用 ranges的态度转变。6.1 模块边界处的类型爆炸有一阵子我对 ranges 上头写了一个库内部接口参数形如std::ranges::input_range auto r声明得特别高级。结果其他同事接手时一脸茫然——这是啥能用 vector 吗然后每个调用点都开始出现template typename R的模板函数模块间接口从清晰的具名类型变成了一大堆泛型约束。编译错误提示还特别难看一个概念没满足就刷出几百行no matching function for call to ... constraints not satisfied。这件事给我的教训是ranges 内部的算法链可以尽情用但跨模块公开 API 不要轻易给函数套模板。除非你是写库的否则公共接口老老实实用具体容器类型vector、span反而清晰可维护。泛型接口省下的那点代码量会在可读性和编译速度上原样还回来。6.2 藏在迭代器失效里的 bug你以为用了 ranges 就不会有迭代器失效了吗天真。视图只是把迭代器封装了一层底层容器照样可能被修改。最经典的翻车现场std::vectorint v{1, 2, 3, 4, 5}; auto view v | std::views::filter([](int n) { return n % 2 0; }); v.push_back(6); // 向量的 push_back 可能使所有迭代器失效 for (int n : view) { // 未定义行为 std::cout n ; }filter_view的迭代器内部存着v的迭代器也可能只存了一个 begin 迭代器其实它有个内部缓存的 begin 迭代器push_back之后缓存里那个迭代器可能就指向悬空位置了。虽然标准并没有规定filter_view一定要缓存 begin 迭代器但多数实现是缓存的所以这个坑几乎必然触发。这条让我养成了习惯在同一个作用域内视图一旦创建底层容器就只读不写。往容器加元素、删元素、扩容的操作一律放到视图作用域之外或者干脆先完成容器修改再创建视图。这不是 ranges 的设计缺陷这是所有基于迭代器的库共有的约束——std::sort之后你还不是得小心迭代器失效。6.3 团队引入 ranges 的节奏建议我不建议一步到位把所有代码都改成 ranges 风格。那样做的后果往往是一半人会用一半人看不懂code review 变成两个人互相说服对方的辩论赛。更务实的路径是分三步走先让所有人熟悉ranges::sort、ranges::find_if这类单算法版把它们当作 STL 算法的提速版。不必引入管道符就能消灭海量begin()/end()。第二步统一引入views::filter和views::transform同时规定视图只在函数内部使用不得跨函数传递不得引用临时对象。第三步再放开自由组合让大家用管道组装复杂逻辑但配 code review 检查清单生命周期、迭代器失效、const 正确性。我见过不少团队上来就铺管道结果 bug 率上升、新人挫败感强。逐步放开反而更稳。另外强烈建议把编译器和标准库升级到版本列表里较新的那一档否则你会踩到很多标准有但这版本没修的坑。7. 值得进一步挖的角落哨兵、子范围、生成器与 C23/26 的延续7.1 哨兵Sentinel范围的另一种结束方式前面提到过ranges 允许结束标志不是迭代器而是哨兵。它的威力在实际代码里体现在处理读输入直到某标记的场景。比如处理一个以-结尾的字符串数组。传统写法你得先数有多少个元素或者干脆规定end()是数组末尾。用哨兵你可以定义一个谓词是否是终止标志然后把数组包装成范围struct not_dash_sentinel { const char* current; friend constexpr bool operator(const char* pos, not_dash_sentinel s) { return *pos -; // 到达 - 即视为结束 } }; const char content[] abc-def; std::string result(std::ranges::begin(content), not_dash_sentinel{content}); // result abc这个例子有点简陋但思路很清楚定义范围结束的标准可以是任何条件。这让 ranges 能统一处理有限但长度未知的数据源。配合ranges::subrange使用可以在保留某段迭代器区间的同时不丢失边界信息比裸存两个迭代器安全多了。7.2 生成器视图views::iota和自定义生成器std::views::iota(1, 100)生成一个从 1 到 99 的整数序列视图。它没有底层容器元素是按需生成的。更妙的是它没有上界版本for (int i : std::views::iota(1) | std::views::take(10)) { std::cout i ; // 1 2 3 4 5 6 7 8 9 10 }这种生成器 适配器的组合是函数式风格的基石。让它变成生产力的真正技巧是把随机数引擎、时间戳、甚至命令行参数序列包装成生成器。比如生成递增时间戳auto current std::chrono::system_clock::now(); auto timestamps std::views::iota(0) | std::views::transform( [current](int offset) { return current std::chrono::seconds(offset); });然后你可以for (auto t : timestamps | take(10))。这在写仿真、测试数据生成时非常顺手。7.3 C23 和 C26 的延续单子操作、tovector、格式化输出C23 为 ranges 补完了一部分旧标准缺失的适配器和工具std::ranges::tostd::vector()把视图急切地物化成容器而且是真正的拥有数据。这几年被无数人吐槽视图不能转容器的痛点终于解决。用法auto vec v | views::filter(...) | std::ranges::tostd::vector();std::views::enumerate给元素配上索引输出。以前你得用一个计数器变量在循环里手动现在视图自带。std::views::zip把两个范围打包成元素对范围处理两个平行序列再也不用手写索引对齐。std::views::chunk按固定大小分块处理序列一口气处理每 4 个一组求和这种逻辑。单子操作monadic operationsviews::transform配合std::optional可以写类似链式调用的求值流C23 逐渐引入transform与and_then的视图封装代码风格越来越接近函数式语言。C26 目前的草案还包含更复杂的视图以及改进的 format 支持。我个人的感觉是ranges 的演进方向正在把 C 从必须先构造完整数据结构才能处理的老套路中彻底解放出来数据流式的处理方式逐渐成为主流。8. 给新手的一句实在话看到这里可能信息量有点大。我建议直接照着下面的路线动手用std::ranges::sort替换三个日常排序。用std::views::transform重写一个从容器生成新容器的循环。找一个filter加transform加take组合的练习题做掉。试着给自己的某个范围写一个最简单的自定义视图。代码编辑器里敲一遍比读十篇博客都管用。真碰到编译报错别急着放弃概念约束的报错通常背后都有一个具体的语义问题仔细看最前面几行错误信息往往就能定位比如视图生命周期问题、迭代器类型不匹配。我现在的经验是ranges 的报错看着吓人但九成情况下错误信息的第一行就指明了问题所在别被后面几百行模板垃圾吓退。这套系统确实有一定的学习曲线但它换来的是代码表达力的大幅提升以及整个团队在处理数据流这一层抽象上的共同语言。如果你在写需要经常处理集合、序列、数据管道的 C 代码那么花一天时间入坑 ranges长期来看肯定不亏。

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

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

免费获取报价 →
↑