
1. 为什么是 ranges先把三个老痛点摆出来1.1 传统 STL 算法的三个痛点你大概率写过类似的代码一个容器先过滤出符合条件的元素再对每个元素做变换最后把结果收集到另一个容器里。用传统 STL 算法写就是下面这个样子std::vectorint nums{1, 2, 3, 4, 5, 6}; std::vectorint tmp, result; std::copy_if(nums.begin(), nums.end(), std::back_inserter(tmp), [](int n) { return n % 2 0; }); std::transform(tmp.begin(), tmp.end(), std::back_inserter(result), [](int n) { return n * n; });这段代码能跑但问题很明显。第一每次调用算法都要把begin()和end()写一遍信息密度极低十个算法就是二十次迭代器。第二只要算法组合超过两层你就得引入临时容器tmp这个名字会反复出现在你的代码里为的是什么只是为了让下一段算法能看到上一段的结果。第三算法和算法之间靠“容器”传话导致一个本可以用一行表达的处理流水线硬生生被拆成了三段读代码的人得反向追踪数据流动才能明白整体逻辑。这还只是最简单的过滤加变换。如果中间再穿插排序、去重、取前 N 个、分组统计传统写法会迅速膨胀成一大坨难以维护的循环和临时变量。迭代器当然是灵活的但灵活不等于好用就像你可以用扳手砸钉子可那不该是日常姿势。1.2 ranges 改写后的第一印象std::ranges 要解决的正是这些问题。“range”这个词直译为“范围”意思是把“一对迭代器”这个东西升级成“一个可遍历的范围”这个抽象。C20 的 ranges 算法可以直接吃整个容器也可以用|管道把多个视图适配器串起来。同样一段逻辑用 ranges 写是这样namespace rv std::views; auto result nums | rv::filter([](int n) { return n % 2 0; }) | rv::transform([](int n) { return n * n; }); for (int v : result) { std::cout v ; } // 输出 4 16 36注意到差别没有没有临时容器没有成对的begin/end甚至连“把结果存到哪”都暂时不用管。result不是一个容器它是一个“视图”描述了一段数据处理的配方。这个配方是惰性的——你在这个循环真正迭代它之前它不会做任何计算。这种声明式的写法让人把注意力从“怎么取数据”转移到“我要什么结果”上。我第一次用 ranges 改某段日志解析代码时最大的感受不是代码变短了多少而是思路变清楚了过滤条件、变换规则、截断逻辑各自独立成一段谁也不会干扰谁。后来再给同事讲这个特性我都喜欢说一句话ranges 不是让 C 变得更花哨而是让“处理一批数据”这件事终于有了符合直觉的表达方式。适合谁适合所有写过 STL 算法、觉得迭代器写起来烦、希望在 C20/23 项目里提升代码表达力的开发者。2. 核心模块速览range、view、sentinel、concepts 各自解决什么问题2.1 range 与 view可借用范围是一切的原料C20 里std::ranges是一个命名空间里面装了一大票与范围相关的组件。最基础的range是一个概念concept定义也很直观一个类型如果能提供begin()和end()那它就是一个range。数组是 range各种std::vector是 rangestd::string是 range自定义的容器只要实现了迭代器接口也是 range。但真正让 ranges 好用的是view。view 是一个“轻量、可拷贝、O(1) 拷贝代价”的范围它不拥有数据只是对已有数据的观察角度。std::views::filter返回的filter_view就是这样一个观察者它底层还是引用着你的std::vector但每次遍历时只把满足条件的元素交给上层。view 本身不分配内存不拷贝元素这也是把它组合进管道时不会产生额外性能开销的根本原因。这里有个容易混淆的点就是“右值范围”和“左值范围”的区别。假设你直接写auto v std::vectorint{1, 2, 3} | rv::take(2);这里的临时容器会被移进一个内部持有的所有权视图里数据跟着v走。这个所有权视图在 C23 里正式叫std::ranges::owning_view。而如果你传的是一个左值容器std::vectorint nums{1, 2, 3}; auto v nums | rv::take(2);那么nums不会被拷贝它会被包成一个ref_viewv实际上“借用”了nums。借用意味着一个问题你必须保证nums活得比v久。这个问题后面讲悬垂案例时还会细说这里只要先建立印象view 默认不拥有数据它的生命周期和你传入的范围绑定。2.2 哨兵 sentinel迭代器不必和哨兵同类型传统 STL 算法接受first, last两个迭代器要求它们类型相同。这带来一个隐性的限制哨兵本身必须是同类型的迭代器。但很多场景下范围结束的标志并不是一个迭代器而是一个可以判断“到没到头”的哨兵。ranges 引入了 sentinel 概念允许begin()返回迭代器、end()返回一个不同类型的哨兵只要这个哨兵满足“和迭代器比较相等”的约束。这样做的直接收益有两个。第一个收益是字符串处理。像const char*这种以\0结尾的字符串传统方式得到last只能是first strlen(first)需要先遍历一次求长度而如果允许哨兵是一个“遇到空字符就停止”的对象就可以直接把这个字符串当作 range 遍历省掉一次扫描。第二个收益是让“结束条件”更自由。比如一个范围不是以位置结束而是以某个谓词结束你可以用带哨兵的范围来表达。虽然平时写业务代码用到自定义哨兵的机会不多但理解这个机制后再看到 ranges 里那些“迭代器和哨兵可以不是同一类型”的算法重载就不会觉得奇怪了。2.3 concepts 与约束编译错误变得人能读懂ranges 依赖 concepts 来做约束检查这可能是它另一个被低估的贡献。以前写模板传入一个不支持的类型爆出来的错误往往是一坨深不见底的实例化嵌套最后一行写着某个库里static_assert失败。Ranges 引入了std::ranges::input_range、std::ranges::forward_range、std::ranges::random_access_range、std::ranges::sortable等一系列概念后算法对自己的输入有了明确要求。举个例子std::ranges::sort要求范围满足sortable而sortable展开后涉及“元素可交换、可移动、可比较”等约束。当你试图对一个只读范围排序时编译器会直接告诉你“不满足indirectly_movable_storable”而不是让模板实例化错误把你淹没在几百行日志里。这意味着什么意味着库的作者可以把约束写成文档的一部分用它来精确表达算法对输入的要求使用者在写错时也能更快定位问题。我个人的经验是不要神话 concepts它不会让所有错误信息都变得一目了然但比起 C98 时代模板报错的混乱程度已经是质的提升。尤其是当你给函数写的模板参数用std::ranges::range约束时调用方的错误提示会清楚得多。3. 高密度实战什么时候真正该用 ranges3.1 过滤加变换统计订单金额我实际工作中用得最多的场景就是“过滤 变换”。比如某次我们改历史订单的结算逻辑要从一批订单里筛出金额大于某个阈值的已支付订单再把每单抽成算出来。传统写法需要两三次循环加中间容器ranges 则是一段管道struct Order { std::string id; double amount; bool paid; }; std::vectorOrder orders /* ... */; double threshold 100.0; auto commissions orders | rv::filter([](const Order o) { return o.paid; }) | rv::filter([threshold](const Order o) { return o.amount threshold; }) | rv::transform([](const Order o) { return o.amount * 0.02; });用这个管道去统计总抽成只需要在循环里累加double total 0.0; for (double c : commissions) { total c; }这里建议把两个过滤分开写而不是合并成一个大的 lambda。原因很简单业务条件通常是独立的拆开后将来改“支付状态判断”或“金额阈值”都可以各自维护。这是我在真实代码里反复踩过的一个组织习惯——ranges 管道虽然可以写得很短但语义清晰比短更重要。如果你想把结果收集成std::vectordouble可以先做这一步std::vectordouble vec; for (double c : commissions) { vec.push_back(c); }C23 有std::ranges::tostd::vector(commissions)一行搞定这个后面单独讲。3.2 投影排序按结构体成员排序另一个高频场景是排序。处理结构体数组时传统写法要写一个比较 lambda比如按age排序struct Person { std::string name; int age; }; std::vectorPerson people{ {Alice, 30}, {Bob, 24}, {Charlie, 35} }; std::sort(people.begin(), people.end(), [](const Person a, const Person b) { return a.age b.age; });ranges 里排序可以接受一个投影projection参数直接指定用哪个成员参与比较std::ranges::sort(people, {}, Person::age);第二个参数是比较器这里我用{}表示默认的std::less第三个参数就是投影。这个写法省掉了 lambda更重要的是它把“比较规则”和“比较对象”分开了比较规则是升序比较对象是age成员。如果按名字长度排std::ranges::sort(people, {}, [](const Person p) { return p.name.size(); });投影也不限于成员变量任何能从元素身上取出“比较键”的可调用对象都行。我在解析日志文件时经常用投影按时间戳排序能少写很多模板样板。3.3 字符串拆分与解析std::views::split对字符串处理特别有用。以前要按逗号拆 CSV要么用std::getline配合istringstream要么手写循环找find。用 ranges 可以这样std::string line apple,banana,grape; auto parts line | rv::split(,) | rv::transform([](auto piece) { return std::string_view(piece); }); for (std::string_view s : parts) { std::cout s \n; }这里有个细节值得展开split产生的“子串”类型并不是std::string_view而是一个表示子范围的 range 对象所以通常要再transform一层把它显式转成std::string_view。不转的话你也能遍历子串的字符但直接打印或传给需要string_view的函数时会多一层周折。我自己写小工具时还会把这段包装成通用函数auto split_to_views(std::string_view s, char delim) - std::vectorstd::string_view { return s | rv::split(delim) | rv::transform([](auto r) - std::string_view { return std::string_view(r); }) | std::ranges::tostd::vector(); }注意 C23 才提供std::ranges::to如果你的工具链支持 C23这个函数非常简单。即使还在 C20用循环收集也不算复杂只是 code 稍长一点。另一个坑是string_view指向的原始字符串生命期如果s是临时字符串转换出来的string_view可就悬空了这个问题本质上是借用生命周期下面单独会聊。3.4 统计、区间判断与分页除了组合式视图ranges 还提供一批直接吃 range 的算法重载日常写代码时几乎每天都会碰到几个。比如统计满足条件的数量std::vectordouble prices{99.0, 199.0, 49.0, 799.0}; auto over100 std::ranges::count_if(prices, [](double p) { return p 100.0; });判断一段数据是不是全部满足条件bool allPositive std::ranges::all_of(prices, [](double p) { return p 0.0; });检查是否已排序、找最大元素、判断值是否存在这些都能用 ranges 算法一行完成。和传统 STL 算法的区别只有一个不用写begin/end。别小看这个差异函数签名短了一截代码密度和可读性会明显提升。分页是另一个很常见的场景。假设要展示第 2 页每页 10 条auto page all_items | rv::drop(10 * 1) | rv::take(10);drop跳过前 N 个take取接下来 N 个两个视图一组合就是清晰的分页逻辑。以前写这种要么用下标循环要么先resize一个临时容器现在一行管道讲清楚“从哪开始取多少”。这类逻辑在列表接口、报表分页、日志采样里都能直接套。4. 惰性求值与性能真相view 不拷贝但也不是魔法4.1 惰性求值的工作机制视图是惰性的这是 ranges 最重要的设计原则之一。所谓惰性就是管道构造完成时数据并没有被实际处理只有当你开始迭代视图或者把视图传给一个急切算法时计算才发生。用个生活化的类比管道描述的是“做菜的配方”而不是已经做好的菜。nums | filter(...) | transform(...)这条语句只把三个“加工步骤”串成了流水线但流水线本身没有运转。每次你在 for 循环里取一个元素流水线才往前推一格先到容器里拿一个数判断是否偶数是则平方然后交给循环体。如果你只取前两个元素那容器里后面的元素永远不会被检查。这个设计带来两件事。第一中间结果不落地不会生成临时容器。第二组合次数与运行时开销没有必然关系因为每一步都是逐个元素流式处理而不是每层生成一个完整数组。但也正因如此新手往往会觉得 ranges 很“玄学”。其实只要记住一句话视图是延迟求值的它描述一个过程真正执行发生在消费端。写代码时如果分不清哪一步是构建视图、哪一步是触发求值那就容易写出“看起来对、但什么都不做”的代码。4.2 性能到底如何说到性能很多人第一反应是担心 ranges 的抽象会不会产生额外开销。实测下来主流编译器在开启优化后简单的 filtertransform 管道能生成和手写循环几乎等价的机器代码。比如我们组之前把一个热点路径上的日志清洗循环改成 ranges 管道开了 O2 以后汇编反而少了几个分支因为编译器更容易看出每个元素之间没有依赖可以做向量化。需要注意的其实是分配行为。视图本身不分配内存但如果把一个视图tostd::vector变成容器那必然有一次容器分配。这个分配是符合预期的别误以为所有操作都零分配就行。另一个容易忽略的细节是views::filter的谓词如果捕获了较大的对象视图对象本身会变大因为视图里保存了谓词副本。但通常无状态的 lambda 是空对象不会增加视图体积。用 ranges 写代码不一定更快也不一定更慢它的收益主要在于表达和可维护性。性能上我可以负责任地说大多数场景和手写循环同量级少数场景因为编译器更容易理解意图反而有优化空间。4.3 什么时候别用 ranges虽然我很喜欢 ranges但不会无脑推崇。有两个场景我会刻意少用一个是需要和 C11/14 老代码库兼容另一个是在调试铺天盖地的模板错误时先稳住主线。第二个场景其实不是 ranges 的错而是模板深度带来的现实成本——管道组合越复杂类型名越长断点调试就越痛苦。还有一种情况是嵌套循环里本身就不适合用视图。比如你要处理一个二维矩阵同时依赖行和列的索引来做逻辑判断这时直接遍历下标反而更清楚。ranges 能做的事很多但“清晰”永远是第一原则。视图管道写出来漂亮如果读的人需要花五分钟才能看明白每一层在干嘛那就该考虑拆函数、加注释或者退回循环。5. 踩坑实录视图悬垂、生命周期与调试教训5.1 最经典的悬垂案例写了两年 ranges我遇到的最大坑也是社区里讨论最多的坑给filter或transform传一个临时范围结果视图悬垂。看这段代码auto bad std::vectorint{1, 2, 3, 4} | std::views::filter([](int n) { return n % 2 0; });乍一看没毛病管道建好filter 条件也对。但bad这个视图内部持有指向临时容器的迭代器临时容器在这条语句结束后就被销毁了。等你在函数返回后遍历bad它访问的是已经释放的内存这就是经典未定义行为。这种 bug 最阴的地方在于它可能在你本机跑一百次都不崩换一个编译器、加一个优化选项就随机出现奇怪结果。那为什么filter和transform会这样因为它们内部存的是迭代器而不是范围本身。迭代器指向哪里完全取决于传入的 range 是谁的。临时容器一消失迭代器就成了空中的楼阁。5.2 take 为什么安全这里有个反直觉的对比views::take对临时范围反而安全。比如auto ok std::vectorint{1, 2, 3, 4} | std::views::take(2);take_view在底层范围是右值时会直接把范围“抱住”存进自己内部而不是只存迭代器。所以你把这个ok拿出去用数据是安全的。标准库里也有owning_view来专门持有右值范围。但问题就在这里同样是管道有的适配器安全有的不安全细节藏在“这个视图到底存储了什么”上面。我给自己的规则很简单凡是管道起始端是临时构造的右值容器一律先用一个具名局部变量保存std::vectorint nums{1, 2, 3, 4}; auto safe nums | std::views::filter(...) | std::views::transform(...);这样生命周期就一目了然也能躲掉大多数悬垂场景。5.3 调试 ranges 表达式的技巧调试 view 管道比调试普通循环麻烦。原因有两个第一表达式长中间变量多没有地方可以打断点观察“这一层过滤完是什么”第二view 的类型名极长IDE 的调试面板里显示出来像天书。我的调试套路是先拆函数。把每个谓词抽成一个命名好的 lambda再单独验证auto is_paid [](const Order o) { return o.paid; }; auto above_threshold [t threshold](const Order o) { return o.amount t; }; auto commission [](const Order o) { return o.amount * 0.02; }; auto result orders | rv::filter(is_paid) | rv::filter(above_threshold) | rv::transform(commission);好处是每个 lambda 都有名字编译错误能直接定位到具体步骤调试时也能单独测谓词对不对。还有一个技巧想确认某个视图产生的结果是什么先把它强制转成std::vector再遍历打印auto intermediate orders | rv::filter(is_paid); auto debug_vec intermediate | std::ranges::tostd::vector();C20 里没有to那就std::vectorOrder dv(intermediate.begin(), intermediate.end());。这种“中间落地法”虽然牺牲了惰性但排查问题时最直接。5.4 和旧代码的互操作ranges 和旧代码共存是个现实问题。好消息是C20 的 ranges 算法普遍保留了迭代器版本的重载。比如std::ranges::sort(first, last)仍然可以传一对迭代器std::ranges::transform(first, last, out, f)也照常工作。所以你不必一次性把所有代码都改成 range 写法可以逐步迁移。反过来也一样标准算法吃迭代器的也能用 ranges 的视图迭代器。只要 view 的迭代器满足相应 category比如random_access_iterator就可以当普通迭代器去调老算法。唯一需要注意的是一些老函数可能要求迭代器和哨兵同类型而视图的end()可能是个哨兵而不是迭代器这时显式转成容器再用就比较稳。另外自定义容器接入 ranges 生态非常简单保证提供begin()和end()且返回合法的迭代器即可。我自己的一个模拟项目里给自研的环形缓冲区补了begin/end所有 ranges 算法和视图适配器立刻就能用了没有额外的模板扩展成本。6. C23 的新玩具与后续扩展zip、chunk、stride、ranges::to6.1 C23 新增的视图适配器C23 给 ranges 补了一批实用适配器让很多原先要写一行循环的场景也能流程化。我挑几个最常用的讲。std::views::zip把多个 range 同步打包成元组流。以前要同时遍历两个容器并组合只能写带下标的循环。现在std::vectorstd::string names{a, b, c}; std::vectorint scores{90, 85, 95}; for (auto [name, score] : rv::zip(names, scores)) { std::cout name : score \n; }views::chunk(n)把范围切成每组 n 个元素views::stride(n)每隔 n 个取一个。我在做时序数据抽稀和窗口统计时经常用这两个。比如对采样点每 5 个一组取平均可以先chunk(5)再对每组做累加。views::enumerate给元素带上下标也省掉了 index 循环for (auto [idx, val] : nums | rv::enumerate) { std::cout idx - val \n; }views::join_with适合拼接内层范围比如打印二维矩阵时插入分隔符。这些适配器单独看都不复杂但组合起来后原先需要三四层嵌套循环的逻辑可以压成一段平铺直叙的管道。6.2 更顺手的容器转换ranges::toC23 的std::ranges::toContainer()是我最期待落地的功能之一。它把一个 view 或 range 直接转成目标容器并且支持指定转换策略。之前想收集 view 结果必须自己循环push_back现在一行auto result nums | rv::filter(...) | rv::transform(...) | std::ranges::tostd::vectorint();甚至可以直接转成关联容器。比如从一个订单视图里提取 id 到std::setauto ids orders | rv::transform(Order::id) | std::ranges::tostd::setstd::string();这个能力让“视图负责惰性计算容器负责存储结果”的分工彻底清晰。实际项目里我通常让 view 管道保持惰性处理到最后一刻只有需要传给旧接口、或者需要持久保存时才用to落地。这不只是代码好看也是性能上的正确选择先声明处理逻辑再一次性分配结果避免中间容器反复分配。6.3 与标准库新旧代码的互操作深化C23 还调整了一些 ranges 语义比如split_view和lazy_split_view的区分更明确。C20 时代用split偶尔会遇到实现差异C23 的标准把它梳理好了普通split返回的每段默认也是惰性的想要稳定的行为就显式转string_view。如果你的代码需要同时兼容 C20 和 C23 工具链对split的用法最好保持谨慎尽量把输出立刻转成容器或string_view避免依赖未定义的行为。ranges 生态从 C20 到 C23 一直在补短板未来的 C26 也在继续扩展。就我自己的心智模型来说range是抽象输入view是组合加工算法是消费处理ranges::to是最后落袋。这四层互相解耦又通过一致的接口串联起来。日常写代码时先用视图描述处理流程最后用to或者循环收口基本能覆盖九成数据处理场景。最后分享一点个人体会我踩过很多视图悬垂的坑也见过同事因为模板报错对 ranges 敬而远之但真正用顺手之后它是能明显减少样板代码、提升逻辑清晰度的。如果你所在的项目已经切到 C20真心建议从最基础的一个filter加transform开始小步尝试先不追求花哨的组合把“视图是惰性的”“借用生命周期要管好”这两条原则吃透ranges 就会慢慢变成你工具箱里顺手的那把螺丝刀而不是束之高阁的新奇玩意。