
第一次在 C20 的代码里写下std::ranges::sort(v)而不是std::sort(v.begin(), v.end())时我盯着编译输出愣了一会儿明明只是少写了两个迭代器为什么整个 std::ranges 类型体系会让我有种需要重新学一遍的感觉后来把filter、transform、take串成一行管道之后我才意识到这不是语法糖而是对 STL 算法模型的重新抽象。std::ranges 不是某个单一类型而是一整套围绕“范围range”这个抽象展开的类型和概念range概念、view类型、subrange、sentinel以及所谓的 borrowed range 语义。它的核心价值在于把容器或一段数据的处理从“迭代器对”提升到“范围”本身让算法真正可以接收整个对象也让组合式、惰性的数据处理流水线成为一等公民。这套东西适合两类人一是刚接触 C20、被各种视图类型绕晕的开发者二是想在生产代码里引入 ranges 但担心性能和可维护性的老手。我会从设计动机讲到类型细节再给出一套可以直接抄走的写法最后把我踩过的编译期和运行期坑一起列出来。你不需要先背文档跟着代码走一遍就能建立起直觉。1. std::ranges 到底改变了什么从迭代器对到范围抽象1.1 为什么说 ranges 是对算法接口的重新建模传统 STL 算法的调用方式几乎都是“迭代器对”std::vectorint v{5, 3, 8, 1}; std::sort(v.begin(), v.end());这看起来简单但有一堆隐含问题。第一begin()和end()必须类型一致可现实里的“结束”不一定是一个迭代器比如输入流什么时候读完、字符串到哪里结束都没有办法提前给出一个和 begin 相同类型的迭代器。第二算法被迫频繁写出v.begin()、v.end()逻辑和容器的物理边界贴得太紧。ranges 把接口改成接收“一个范围对象”std::ranges::sort(v);这里的v就是一个 range。标准库用概念std::ranges::range描述这类类型templateclass T concept range requires(T t) { std::ranges::begin(t); // 能拿到起始迭代器或哨兵 std::ranges::end(t); // 能拿到结束哨兵 };概念本身并不是性能动作它只是约束。它允许end返回一个和begin类型不同的哨兵对象这是传统迭代器对做不到的。换句话说ranges 把“一段数据”从“两个位置”的绑定中解放出来让你可以把重点放在数据本身。这也意味着大量算法有了更清晰的签名接收一个 range 参数而不是两个迭代器。写模板的时候不再需要手动推导iterator::value_type直接用std::ranges::range_value_tR就能拿到元素类型代码可读性提升非常明显。1.2 核心思想视图、管道与惰性求值range 概念只是地基真正让 std::ranges 类型体系变得强大的是“视图view”和“管道操作符|”。视图有三大特征非拥有、廉价拷贝、惰性求值。可以把它理解成一层不占内存的滤镜包在数据外面你不去遍历它它什么都不会做只有在 range-based for 循环里逐个取元素时它才逐个执行筛选和变换。管道操作符|让处理步骤从左到右排列和读句子一样自然auto result v | std::views::filter([](int x) { return x % 2 0; }) | std::views::transform([](int x) { return x * x; });这里的result并不是一个新的std::vector没有分配任何内存也没有真正计算任何平方值。它只是一个组合视图内部保存着对v的引用和两个 lambda。当你遍历result时每个元素会依次经历 filter 的筛选和 transform 的变换。这就是“惰性求值”的价值如果你想只取前 5 个满足条件的元素不需要先把整个中间结果都生成出来遍历到第 5 个时就直接停下来了。相比之下传统写法往往要先建临时容器再把结果拷来拷去内存和时间都浪费在“中间态”上。1.3 类型体系速览概念、视图、哨兵std::ranges 类型体系可以粗略分成几类。先给一张速览表后面再逐个深入。类别代表类型/概念作用核心概念std::ranges::range、input_range、forward_range、bidirectional_range、random_access_range、contiguous_range描述范围的遍历能力辅助概念sized_range、common_range、borrowed_range描述范围的可计算大小、首尾类型一致性、借用安全性基本视图std::ranges::empty_viewT、single_viewT、iota_viewT、ref_viewR、owning_viewR生成或持有数据的最小构件组合视图transform_view、filter_view、take_view、drop_view、reverse_view、common_view对范围做变换、过滤、截取等范围适配器对象std::views::all、views::filter、views::transform、views::take、views::common组合或者生成对应视图的入口哨兵std::default_sentinel_t、sentinel_for概念表示“结束条件”的类型std::views::filter和std::ranges::filter_view的关系很容易让人迷惑。实际上views::filter是一个范围适配器对象函数对象你给它一个 range 和谓词它返回一个filter_view实例。views::xxx这一层是给人类写代码用的xxx_view才是真正被构造出来的类型。正是因为有这一层适配器对象你可以用auto保存任何组合视图。如果去手写std::ranges::filter_viewstd::ranges::ref_viewstd::vectorint, lambda这种类型代码会立刻失去可读性这也是为什么我建议管道结果一律用auto接收除非你有明确理由。2. range 类型体系详解概念、视图与哨兵类型该怎么理解2.1 从 range/concepts 看类型约束概念在 std::ranges 里承担了两个作用一是约束模板参数二是把“你能对这个范围做什么”表达出来。最常见的约束是遍历能力分级。std::ranges::input_range表示可以单趟读取std::ranges::forward_range表示可以多趟遍历std::ranges::random_access_range表示可以用下标访问std::ranges::contiguous_range表示元素在内存中连续分布。写泛型代码时约束可以非常精确templatestd::ranges::random_access_range R void process(R r) { // 只有支持随机访问的范围才会进入这里 auto mid r.begin() r.size() / 2; }另一个经常遇到的是sized_range。它表示std::ranges::size(r)可以在常数时间内完成。大部分容器满足sized_range但filter_view不满足因为它必须遍历一遍才知道有多少个元素符合条件。这直接影响你能否调用.size()。还有一个很容易忽略的是common_range。它表示begin()和end()返回同一类型。传统 STL 算法和容器构造函数大多要求 common range而很多视图的哨兵和迭代器类型不同比如istream_view。遇到“为什么不能直接构造 vector”的报错时八成就是common_range没满足。理解这些概念最好的方式是把它当成“类型能力清单”你不需要背住每一个概念的名字只需要在写模板时想清楚我的算法到底需要哪种能力。2.2 视图类型哪些 view 应该背下来视图类型很多但日常工作中高频使用的其实就那么几个。ref_view是最底层的引用型视图。std::views::all(v)对一个左值容器通常会生成ref_viewstd::vectorint它只保存一个指向v的指针拷贝它极其廉价。反过来如果传入一个临时右值容器标准库会生成owning_view把容器所有权搬进视图里// 临时容器是右值视图会拥有这个容器的生命周期 auto owned std::vectorint{1, 2, 3} | std::views::transform([](int x) { return x * 2; });这个细节特别重要。很多人误以为 view 一定不拥有数据其实owning_view就是为了安全地处理右值临时容器而存在的。它不是不拥有而是替你保管到视图销毁。iota_view是另一个很有用的视图它生成一个递增序列不依赖底层存储for (int x : std::views::iota(1, 6)) { std::cout x ; // 1 2 3 4 5 }带两个参数的版本是有限区间带一个参数的是无限序列。无限序列本身不能直接遍历完但配上take_view就能安全取前 N 个auto first10 std::views::iota(0) | std::views::take(10);filter_view和transform_view是最常见的组合视图。filter_view内部保存谓词迭代时不断往后找满足条件的元素transform_view内部保存变换函数迭代时逐个映射。两者都是延迟执行的所以组合起来的类型会嵌套得很深但对使用者来说一切都被auto隐藏了。2.3 哨兵类型让“结束”不再等于同一类型传统迭代器模型中begin()和end()必须类型相同。但输入流、网络缓冲、字符串扫描这些场景结束条件往往不是一个迭代器而是一种状态。std::ranges 引入了“哨兵sentinel”概念。sentinel_for只要求能写iter sentinel来判断是否结束不要求sentinel和iterator是同一类型。标准库中std::default_sentinel_t就是典型的哨兵类型。最直观的例子是istream_viewauto ints std::views::istreamint(std::cin); for (auto x : ints) { std::cout x \n; }这个范围的begin()是一个输入迭代器end()是default_sentinel_t。它并不知道流什么时候结束只是每次比较时检查底层流状态。如果没有哨兵概念这种“不确定的结束”将很难纳入统一的 range 抽象中。哨兵类型的引入也让std::ranges::subrange变得非常灵活。你可以把一个迭代器和一个哨兵包成一个 rangeauto r std::ranges::subrange(data.begin(), data.end());这里的data.begin()和data.end()类型相同只是一种特殊情况真正有用的是把nullptr结尾的 C 风格字符串或某种自定义终止条件也包装成 range。类型系统不再逼你伪造一个没意义的“结束迭代器”。3. 实操用 std::ranges 把算法代码写得更像意图3.1 环境准备与基础模板使用 std::ranges 需要一个支持 C20 的编译器。近年来的主流编译器对 C20 ranges 的支持已经比较完整在项目里加上-stdc20即可。如果还想用 C23 的std::ranges::to则需要-stdc23建议先确认团队的编译标准。一个最基础的可编译模板是这样的#include algorithm #include iostream #include ranges #include vector int main() { std::vectorint nums{5, 3, 8, 1, 9, 2, 7}; std::ranges::sort(nums); auto result nums | std::views::filter([](int x) { return x % 2 1; }) | std::views::transform([](int x) { return x * 2; }) | std::views::take(3); for (int v : result) { std::cout v ; } }这段代码的输出是2 6 10。流程是先把nums原地排序为{1, 2, 3, 5, 7, 8, 9}再筛选奇数得到{1, 3, 5, 7, 9}再翻倍得到{2, 6, 10, 14, 18}最后只取前 3 个。注意这里std::ranges::sort(nums)是在管道之前执行的。视图本身是惰性的它不会替你排序也不会改变底层容器。如果想保留原容器不变只能先拷贝一份再排序或者把排序结果先落到某个容器里。3.2 核心组合sort、filter、transform、take 配合实际操作中最容易困惑的点是“为什么我排序之后再用 filter结果还是无序的”因为 filter 只是筛选它保持底层元素的相对顺序。如果你在未排序的容器上调用 filter筛选结果自然也是未排序的。视图没有任何魔法能力去维护全局顺序它不是排序视图。排序是一个消耗大量状态的算法不适合用惰性视图表达。正确做法是先std::ranges::sort(nums)再把视图管道接到排序后的容器上。如果不想动原容器可以这样std::vectorint nums{5, 3, 8, 1, 9, 2, 7}; std::vectorint sorted nums; std::ranges::sort(sorted); auto result sorted | std::views::filter(...) | std::views::transform(...);这里sorted是一次显式的拷贝代价是 O(n)。但换来的是语义清晰先准备数据再定义观察方式。视图擅长的是在已有数据上做轻量变换而不是改变数据本身。无限序列配合 take 也是高频写法auto squares std::views::iota(1) | std::views::transform([](int x) { return x * x; }) | std::views::take(10);这段代码不会真的把平方数全算出来。它只是定义了一个“平方数流”当你遍历前 10 个元素时才逐个计算。这个模式在生成测试数据、构造序列区间时非常方便。3.3 将视图物化回容器ranges::to 与构造很多时候你最终需要的是一个真正的容器而不是视图。C23 提供了std::ranges::to可以把视图一次性物化#include ranges #include vector auto vec std::views::iota(1, 10) | std::views::filter([](int x) { return x % 2 0; }) | std::ranges::tostd::vectorint();如果你的项目还停留在 C20也有几个等价手段。最常见的是用std::ranges::copyauto pipeline nums | std::views::filter([](int x) { return x % 2 1; }) | std::views::transform([](int x) { return x * x; }); std::vectorint result; std::ranges::copy(pipeline, std::back_inserter(result));需要留意的是直接写std::vectorint result(pipeline.begin(), pipeline.end())可能编译失败因为很多视图的begin()和end()类型不同不满足传统容器的迭代器对构造要求。解决办法是先把视图转成 common rangeauto common pipeline | std::views::common; std::vectorint result(common.begin(), common.end());std::views::common会把哨兵类型转换成迭代器类型代价通常是保存一个额外的结束状态。对于临时物化场景这个开销可以忽略不计。3.4 性能评估惰性视图真的零开销吗这个问题几乎每次聊 ranges 都会被问到。我的实际经验是在开启编译器优化后绝大多数视图组合可以被内联成接近手写循环的代码不会产生额外堆分配或虚拟调用。为什么能做到因为视图类型是模板类型组合关系在编译期完全已知。transform_view的operator只是调用底层迭代器的再调用 lambdafilter_view的operator只是反复递增直到谓词为真。这些都可以被内联展开。但有一个代价容易被忽略复杂管道会产生更深、更长的类型名编译期模板实例化树会变大编译时间会变长。这是“类型系统免费抽象”的常见代价在大型项目里需要通过合理拆分来缓解。另外视图适合流式遍历、拷贝、查找不适合作为随机访问排序的直接输入。比如filter_view本身不满足random_access_range它不能直接喂给std::ranges::sort。视图的本质是“描述遍历过程”排序需要的是“对容器状态进行原地重排”两者目标不同。4. 类型层面的坑生命周期、借用与编译错误排查4.1 保存视图的陷阱悬垂引用与借用视图最大的坑是生命周期。绝大多数视图如ref_view、filter_view、transform_view只是保存底层范围的引用不拥有它。下面这段代码能编译但运行结果是未定义行为auto bad() { std::vectorint tmp{1, 2, 3, 4}; return tmp | std::views::filter([](int x) { return x % 2 0; }); // tmp 被销毁后返回的视图还引用着已经失效的 vector }这里tmp是左值views::filter生成的是ref_view它不转移所有权。函数返回后tmp释放视图立即悬垂。想要让返回值安全必须把容器所有权也一起交给视图auto ok() { std::vectorint tmp{1, 2, 3, 4}; return std::move(tmp) | std::views::filter([](int x) { return x % 2 0; }); }传右值之后底层生成的是owning_view它拥有容器会跟着视图一起返回和解构。很多人以为 view 一定不拥有数据显然是把“不拥有”理解得太绝对了。与此相关的概念是borrowed_range。它表示一个 range 可以被安全地“借用”即便你用一个临时 range 调用算法得到的迭代器或引用也不会因为临时对象销毁而悬垂。典型的 borrowed range 有std::string_view、std::span、std::ranges::subrange。标准容器通常不是 borrowed range因为它们自身拥有数据临时容器销毁后针对它返回的迭代器没有意义。标准库用std::ranges::dangling类型来标记这种危险情况。例如auto d std::ranges::max_element(std::vectorint{3, 1, 4});这里返回值类型会推导成std::ranges::dangling而不是一个迭代器。编译器不直接报错但当你试图解引用d时会得到一个诊断信息提醒你临时容器生命周期已经结束。这是类型系统在处理一个非常棘手的内存安全问题值得在代码里好好利用。4.2 常见编译错误速查表ranges 的错误信息通常很长因为模板实例化层级深。大多数问题可以归纳为下面几类错误症状典型原因解决办法no type named value_type传入的类型其实不是 range比如裸指针只带一个起点用std::ranges::subrange包装filter_view没有size()成员filter_view不满足sized_range用std::ranges::distance(v)或自行计数common_range断言失败begin()和end()类型不同常见于构造容器时先views::common再构造invocable约束不满足transform的 lambda 参数类型不匹配或 lambda 返回void检查函数签名和返回类型borrowed_iterator_t返回dangling对临时容器调用算法并试图保存返回值改用具名容器或先在变量里保存容器管道方向反了写成 views::filter(pred)rangeview类型无法拷贝谓词或 lambda 只可移动不可拷贝使用auto/std::move避免按值复制视图前两条是最常见的初学问题。filter_view没有size()是第一天就会遇到的因为要满足sized_range必须能在 O(1) 时间内知道长度而筛选后有多少个元素必须遍历才知道。遇到这种需求要么改成std::vector物化要么接受distance的 O(n) 开销。common_range失败出现在把视图塞给旧接口时。旧接口只认begin()和end()类型一致的迭代器对而视图的哨兵往往不是迭代器。views::common就是专门用来做这层类型转换的。4.3 实际工程中的选型建议在生产代码里用 ranges我个人的建议是“渐进式引入”不要一上来就追求把每个循环都改成视图管道。先从无争议的场景开始std::ranges::sort、std::ranges::copy、std::ranges::find。这些算法和旧版本行为一致但少写迭代器出错概率低。第二步再引入filter和transform组合因为它们在数据处理场景里收益最明显。最后再碰iota_view、subrange、自定义哨兵这类更抽象的能力。如果团队里有不止一个人维护代码务必在视图边界处物化成容器或交给算法一步消费减少悬垂视图的传播。视图本身是廉价且安全的但如果一个视图被保存、被传递、被返回它内部引用的对象是否还活着就会变成代码审查的重点。类型系统可以提供borrowed_range保护但团队中不是每个人都能第一时间看出某个auto到底拥有还是不拥有元素。调试时有个小技巧用static_assert验证类型能力。static_assert(std::ranges::rangedecltype(result)); static_assert(std::ranges::input_rangedecltype(result));看起来很朴素但能在写泛型代码时快速发现问题。比去看几百行模板实例化错误要高效得多。最后说一个我实际工作里的习惯所有需要用 ranges 管道的地方我都会顺手在函数内部把结果物化成std::vector或直接tostd::vector让管道边界清清楚楚。这不是不相信视图而是代码的意图最需要被读者一眼看清。ranges 是工具不是炫技场把它用在会让代码更直白的地方而不是更抽象的地方。