
写 C 也有年头了前两年第一次在项目里大规模使用std::ranges时我最大的感受不是“代码变快了”而是“代码终于能被一眼看懂了”。但真正吸引我深入研究的是它在性能层面带来的几个不太直观的好处中间容器消失了、临时分配减少了、很多原本要手动维护的状态变量被隐式折叠进了数据流。这篇文章我打算把我在一个仿真数据处理模块里实际用std::ranges做优化时的思路、踩过的坑和最终的取舍完整记录下来希望能给正在评估或已经在用 ranges 的朋友一些参考。这个模块的功能不算复杂读入一批采样点过滤掉低于阈值的点做线性变换再计算加权平方和。用传统写法大概三四十行循环改成 ranges 管道后压缩到了十行以内。压缩代码不是重点重点是改完后我发现程序在这条路径上的 CPU 占用反而降了一点而 Debug 模式下更容易出问题的缓存和悬垂问题也恰恰都藏在管道的“惰性”和“所有权”这两个关键词里。下面从最基础的部分讲起。1. 先用两个例子看清ranges的“优化”到底优化在哪1.1 例一三层嵌套循环变成一条管道先看我最初写的那版代码。它的逻辑很直白过滤、变换、再变换、求和四个步骤拆成了两个循环和一个中间vectorstd::vectordouble scores read_scores(); std::vectordouble filtered; for (const auto s : scores) { if (s kMinScore) continue; filtered.push_back(s * 1.1 kOffset); } double sum 0.0; for (const auto v : filtered) { sum v * v * kWeight; }这个版本本身没有大问题但filtered这个中间容器让我很不舒服它只是为了一次累加而存在却要完整分配、填充、遍历、释放一圈。改用std::ranges之后是下面这样namespace rv std::views; namespace rg std::ranges; auto pipeline scores | rv::filter([](double s) { return s kMinScore; }) | rv::transform([](double s) { return s * 1.1 kOffset; }) | rv::transform([](double s) { return s * s * kWeight; }); // C23 可以直接用 fold_leftC20 可以退而求其次用 accumulate double sum rg::fold_left(pipeline, 0.0, std::plus{});第一眼看过去最直观的区别是表达顺序数据从scores出发像流水线一样从左往右流过去最后汇入sum。在 C20 下没有fold_left我习惯用std::accumulate(rg::begin(pipeline), rg::end(pipeline), 0.0, std::plus{})效果一样只是长一点。这段代码有两个容易被忽略的“优化点”没有创建中间vector。pipeline是一个 view不是容器它不会拷贝scores里的任何元素。两个transform被串成了同一个惰性管道元素只有到了accumulate里才会被真正计算。换句话说循环和中间容器被“折叠”进了管道的求值过程中。这个折叠就是 ranges 带给我最核心的收益而不是什么神奇的零开销抽象。1.2 例二中间vector消失了性能真的变快了吗老实说我第一次测得时候并没有看到特别夸张的加速。在这个仿真模块里scores的规模大概在十万到百万量级Release 模式下ranges 版本比手写双循环版本快了约 2% 到 5%。这个幅度不算大但有一个前提值得注意手写版本里filtered也是一个紧贴使用场景的vectordouble分配和释放由小对象优化和内存池兜着所以差距没有拉得很开。真正把差距拉开的场景是过滤后的数据还要继续经过好几轮变换或者中间vector的元素类型不是double而是自定义结构体。一旦元素变重、拷贝变贵省掉中间容器带来的收益就会成倍放大。我做过一组对比测试当着scores.size()到 500 万时如果filtered存的是std::string或std::pairdouble, double这类带构造开销的类型ranges 版本能比双循环版本快 10% 以上因为少了一次大规模拷贝和析构。所以“用 ranges 代码就一定更快”是片面的。它优化的主要对象是“不必要的分配”和“不必要的遍历”这两点在某些场景下意义重大在另一些场景下则几乎无感。1.3 优化的真实来源中间分配与状态变量的消除前面两个例子背后真正起作用的是两类被隐藏消除的东西中间容器的内存分配。分配本身不是问题问题是分配之后还有填充、遍历、释放以及可能触发的 rehash、扩容和拷贝。filter和transform的组合把“边过滤边变换”所以再也没有一个“先装好再拿出来”的阶段。手写循环里的状态变量。比如sum、比如filtered的push_back次数。在 ranges 管道里这些状态被封装进了迭代器和 fold 的求值过程代码层面不会出现一个到处传递的可变变量。我把这两点统称为“把循环的副作用收敛到表达式的求值里”。这带来的不仅是性能上的可能提升更重要的是代码语义更接近数学表达式bug 更容易被看出。真正的优化技术往往不是让 CPU 跑得更快而是让你一眼看出哪一步其实根本不必要。2. 惰性求值最值钱的“不干活”2.1 view组合不产生中间容器std::views::filter、std::views::transform、std::views::take这些适配器返回的都是 view它们的特点就是“组合时什么都不做”。我在项目里第一次被这个特性惊艳到是下面这段无限序列的代码auto natural std::views::iota(1); auto first_10_odds natural | std::views::filter([](int n) { return n % 2 1; }) | std::views::take(10);这段代码可以在编译期和运行期都“正常停下”因为take(10)让整个管道只在需要前十个奇数时去遍历真正求值的时候不会遍历自然数到无穷而是在凑满十个奇数后立刻停住。这个特性放到优化语境下价值就是短路。以前手写循环时我们经常这样写size_t count 0; for (int n 1; count 10; n) { if (n % 2 1) { out.push_back(n); count; } }ranges管道把这个“边找边停”的逻辑直接表达成了filter | take语义更清楚而且不会因为手写循环漏掉某个终止条件而多算几万次。2.2 惰性的边界什么操作会逼着它立刻算惰性很美好但你要知道哪个操作会“强制求值”。我经常看到有人写auto view data | rv::transform(f); auto vec std::vectorint(view.begin(), view.end()); // 这里才开始真正遍历把 view 转成容器或者把 view 传给std::ranges::count_if、fold_left、std::accumulate、std::filesystem这类直接消费 range 的算法惰性就会结束元素被逐一计算。做一个简单的归纳操作是否立刻遍历 view备注组合多个适配器否只是构造 view 对象range-based for / for_each是遍历全部元素accumulate / fold_left / reduce是完整消费 pipelinetovector / vector构造是物化容器分配内存distance / size视情况非 sized_range 可能也要遍历一说“立刻遍历”新的优化点就出来了如果最终只需要前 20 个结果却把整个管道先tovector再处理那中间所有元素都白算了。反过来如果这数据之后还要被反复访问好多次那物化成vector反而是对的选择因为 view 每次重新遍历都可能重复计算。2.3 用短路思维排布管道顺序管的顺序会影响实际的计算量。最典型的例子是先做高过滤率的filter再做昂贵的transform。比如下面的管道auto expensive [](const Item it) - double { /* 很重的计算 */ }; auto heavy data | rv::filter([](const Item it) { return it.is_valid; }) | rv::transform(expensive);同样是“过滤变换”如果我把transform放前面那is_valid判断为 false 的元素也白做了一次昂贵计算。这个道理和手写if与compute的顺序等价但 ranges 管道更容易让人只顾着“把步骤串起来”忘了顺序就是计算量。还有一点容易被忽略filter无法通过非破坏手段跳过多个元素它的谓词本质上要做完整个语义上的筛选。假如业务上最多只需要前 N 个结果把take(N)放在所有昂贵变换之前可以避免大量无谓的变换。当然这会改变语义比如顺序是“取前 N 个再变换”还是“变换后担心前 N 个”要完全按业务来。我只是提醒你惰性是可以利用的排布管道时要在语义不变的前提下尽可能让贵的操作晚发生、让短路的操作早发生。3. 生命周期与悬垂view优化前必须解决的隐患3.1 悬垂view的现场重现惰性带来的最大代价就是 view 本身不拥有数据它只是借用了底层 range 的迭代器范围。一旦底层容器提前销毁view 就成了悬垂指针般的“悬垂 view”。我在一个功能分支里写出过这样的代码auto get_scores() - std::optionalstd::vectordouble { // 返回一个临时 vector return std::vectordouble{1.0, 5.0, -2.0, /*...*/}; } auto view get_scores().value() | rv::filter([](double s) { return s 0; }) | rv::transform([](double s) { return s * 2.0; });粗看没问题过滤、变换逻辑都对。但在函数返回后临时vector已经被销毁view手里拿着的是已经死掉的容器的迭代器之后任何遍历都是未定义行为。这类 bug 最讨厌的地方在于它不会必然崩溃可能只是某次恰好读到残留内存导致一个月后出现一个偶发的越界问题。3.2 函数返回view的隐藏陷阱很多人喜欢写一个“返回过滤后的 view”的辅助函数比如auto select_positive(std::vectordouble v) { return v | rv::filter([](double x) { return x 0; }); }这样写本身的逻辑没错因为v是引用只要外部保证v在 view 使用期间一直活着就行。真正危险的是有人在这个函数里接收右值auto select_positive(std::vectordouble v) { return v | rv::filter(...); // v 是右值引用但函数返回后临时对象销毁 }你可能会想“右值引用会延长生命周期”但filter返回的 view 并不拥有这个容器的所有权。标准库在 C23 提供了std::views::owning_view来明确“接管所有权”的场景比如auto select_positive(std::vectordouble v) { return std::views::owning_view(std::move(v)) | rv::filter([](double x) { return x 0; }); }这样 view 会真正持有那个vector临时对象的销毁也不会影响 view。但要记住owning_view在 C20 时代并不存在我当时是用“把容器先接住再组合”的方式规避auto vec std::move(tmp); auto view vec | rv::filter(...);一句话让 view 只绑定活得比它久的对象永远别绑临时对象。这是 ranges 编程的第一安全守则。3.3 borrowed_range到底在保护什么std::ranges::borrowed_range这个概念本质上就是在回答“这个 range 被复制/移动之后还能安全地用迭代器访问源数据吗”。像std::string_view、std::span这类本身就是为了“借用”而存在的类型都满足borrowed_range。std::vector不满足因为它拥有数据而且没有借用语义。std::ranges::iota_view也有趣它生成无穷序列不持有任何需要生命周期管理的资源所以也是 borrowed_range。在写自定义函数时如果一个函数接收std::ranges::borrowed_range auto r就相当于明确告诉调用者我返回的东西只借用你的数据你必须保证数据活得比我返回的结果久。C20 的 ranges 算法在传入非 borrowed_range 的右值容器时会返回一个std::ranges::dangling类型这虽然不是编译错误但能在类型层面提醒你“这里可能悬垂”。这个机制对我优化代码时的价值是它不是性能优化但它是性能优化的前提。一个悬垂的 view跑得再快也没有意义。我会在动手改管道之前先审视每一条 view 的源头数据是谁、生命周期到哪结束。4. 单遍迭代、迭代器缓存与多遍遍历的隐藏开销4.1 filter_view内部的“记忆”是把双刃剑filter_view的迭代器实现里通常会缓存“上一次匹配到的位置”这样单遍遍历时它不用每次从 begin 重新扫描。这是一种很普遍的优化手段但也带来了一个传统迭代器没有的怪癖它的迭代器不是普通的透明指针它在内部“记住”了一些状态。我实际遇到过的情况是这样的auto evens nums | rv::filter([](int n) { return n % 2 0; }); auto it1 evens.begin(); it1; // 内部缓存跳到了第一个偶数位置 auto it2 it1; // 拷贝了一份迭代器缓存状态一起被拷贝 // 如果你同时用 it1 和 it2 交错遍历可能执行两遍相同的谓词逻辑单遍场景下这套缓存体验很好但如果你把filter_view::iterator存进一个std::vector再排序、去重、交错使用就会看到奇怪的重复计算或者出乎意料的遍历顺序。当时我在排查一个数据去重模块时就发现迭代器的拷贝导致缓存状态被带到了另一个循环某些元素被重复访问了几次。因此优化原则是不要把 view 的迭代器当作普通数值型迭代器随便复制和长期保存更不要在两个独立循环里维护同一个 filter_view 的多个迭代器副本。你真正想长期保存的应该是“计算结果”而不是“计算过程”。4.2 view拷贝是O(1)但不等于免费用这是我最想强调的一点view 拷贝是 O(1)因为底层只是拷贝迭代器、哨兵和一些适配器本身的状态。但如果你拷贝了一个filter_view这个拷贝可能有自己的内嵌状态例如缓存位置。于是在两个地方分别遍历同一份 view 时实际发生的谓词调用次数可能比你预期的要多。更具体地说如果你写auto evens nums | rv::filter(pred); auto evens_copy evens; // 在同一次逻辑里同时使用 evens 和 evens_copy底层容器是同一个元素是同一批但两个 view 的迭代器状态各不相同最终可能导致运行时访问的位置部分重叠、部分遗漏。对于很多初学者来说这跟直觉相悖因为普通std::vector的拷贝是深拷贝你怎么折腾两个容器都是隔离的view 则是浅的像一层玻璃纸所有拷贝都映射到同一块底层数据上。实际项目里我很少反复拷贝 view但如果你确实需要把“同一份过滤结果”传给多个下游我会建议先物化成容器auto evens_vec evens | std::ranges::tostd::vector();牺牲一次分配换取后续多次遍历时的稳定性和简单性。这又回到了那个经典的取舍计算密集型场景物化不一定慢因为它能避免反复扫描和重复计算。4.3 用一点容器物化换两次遍历的效率我花了很长时间才接受一个反直觉的结论在需要多遍遍历同一个过滤结果时物化vector反而更快。原因是多遍遍历 view 时每一遍都可能重新执行过滤谓词、重新进行模式匹配而物化后这些工作只做一次后续遍历只需读内存数组。这就像准备一桌饭如果你只是临时看一眼冰箱里有什么菜打开冰箱门就行但如果你要反复核对菜量、反复比较搭配不如先列一张清单之后看清单不用每次都开冰箱。我一般会按照下列标准来决定“是否物化”管道只需单遍消费用 view不物化。同一管道要消费两次以上且过滤/变换逻辑较重物化或者部分物化。元素本身很重、拷贝贵物化时要谨慎因为vectorHeavy的网络遍历也很贵这时我会考虑先只保留索引或者更轻的结构。这个取舍没有绝对答案但会用“物化”和“不物化”做对比测试是判断优化方向的最好方式。别靠猜跑一次基准测试数据会告诉你答案。5. 自定义视图什么时候值得亲手实现5.1 一个step_view的骨架标准库的适配器组合可以覆盖大多数场景但偶尔会碰上没法直接用组合表达的简单需求。比如说对序列做等间距采样即“每隔 N 个元素取一个”。在 C20 时代标准还没有std::views::stride我就在模拟项目里写了一个极简的step_view。思路很简单把迭代器的自增“放大” N 步。骨架如下templatestd::ranges::input_range R class step_view : public std::ranges::view_interfacestep_viewR { private: R base_; std::size_t stride_; public: step_view() default; constexpr step_view(R base, std::size_t stride) : base_(std::move(base)), stride_(stride) {} constexpr auto begin() { return iterator{std::ranges::begin(base_), stride_}; } constexpr auto end() { return std::ranges::end(base_); } class iterator { std::ranges::iterator_tR cur_; std::size_t stride_; // 实现 operator 时让 cur_ stride_ }; }; // 配合一个工厂函数或适配器使用 auto step [](std::size_t n) { return std::views::transform([n](auto rng) { return step_viewstd::views::all_tdecltype(rng)( std::views::all(std::forwarddecltype(rng)(rng)), n); }); };这个骨架只是演示真正写进生产前还要处理const迭代、iterator_category、common_range等一堆细节。但它说明了一件重要的事自定义 view 的大部分代码是在处理迭代器语义而不是业务逻辑。5.2 实现size()让distance从O(n)变O(1)自定义 view 有一个很容易被忽略的优化点实现size()成员函数。标准库里像filter_view这类无法确定数量的视图没有size()因此std::ranges::size(view)会直接不可用std::ranges::distance如果想拿到长度不得不遍历一遍。而iota_view这类有明确数学规则的视图实现了size()所以distance是 O(1)。我写step_view时因为步长已知元素数量可以公式算出来constexpr auto size() const { using size_type std::ranges::range_size_tR; return size_type{(std::ranges::size(base_) stride_ - 1) / stride_}; }这一步带来的性能收益相当直白如果某个算法在遍历开始前需要知道范围长度size()能省去一整轮遍历。比如你要取“采样后的最后三个元素”如果不知道采样容器长度就得先完整迭代一遍看有多少个有size()后可以直接根据长度判断是否需要drop。所以在自定义 view 时我总是先问自己这个 view 的长度能不能用数学公式表达能的话务必提供size()。这个习惯在优化场景里比任何微优化都管用。5.3 自定义前先问三个问题自定义 view 虽然能带来精确控制和潜在性能收益但它也是一座复杂度陷阱。我写过一次之后轻易不会再从头写一个完整的 view除非能回答下面三个问题能不能用现有适配器组合出来比如step_view在 C23 里就是标准views::stride根本不需要自己写。自定义后能否显著减少运行时开销或让算法复杂度降低如果只是想让代码好看不值得。团队里其他人能维护你的自定义 view 吗view 的迭代器语义、生命周期和标准容器不一样没人维护很容易变成事故。答案如果都是否那宁可在调用处写一个简单循环也不要造一个标准库里没有的轮子。6. 哪些场景我会退回手写循环6.1 交叉读取和乱序更新ranges 擅长表达“单序列、按顺序流动”的计算也就是所谓的数据流管道。一旦变成“多个容器交叉读取”或者“对同一块内存做乱序更新”管道的表达力就和手写循环差了一截性能也可能反而变差。比如下面这种模式用 ranges 写起来很别扭// 对于每个 i要找 j使得 arr[j] arr[i] * 0.5 // 实际上是 O(n^2) 的比较逻辑 for (size_t i 0; i n; i) { for (size_t j 0; j n; j) { if (arr[j] arr[i] * 0.5) { result[i] j; break; } } }你可以用std::views::iota和first之类的适配器强行表达但读代码的人需要费很大力气才能还原出原来的双重循环。而且如果需要基于索引做复杂缓存比如“上一次命中的位置”手写循环反而更容易配合一个局部变量做缓存。6.2 复杂break条件与多状态机take和filter能处理“取前 N 个”“跳过不符合条件”这类简单短路。但现实里有很多提前终止条件依赖多个状态共同决定比如“找到第一个满足条件 A 和条件 B 的元素并且 A 的出现次数不能超过三次”。这种逻辑用管道写要么得引入自定义状态要么得把状态塞进谓词的捕获变量导致谓词变成有状态的——这恰恰是函数式管道最不舒服的地方。遇到这种情况我一般直接开写手写循环。它虽然啰嗦但每个状态都是局部变量每一行进入分支的原因都清清楚楚不会有“这个 view 内部缓存了什么”的疑云。6.3 编译时间与调试体验的账最后是一个不算性能、但每个项目都会碰到的现实问题ranges头文件很重模板嵌套深编译时间和调试体验都不如简单容器。在较大的代码库中一个高频包含的工具头如果引入了std::views全套整仓编译时间可能增加十几秒热路径迭代器在调试器里的展示也非常不直观——一层套一层的 view 类型字符串能占据好几行。我的习惯是把 ranges 管道封装在一些局部函数里不要让大段 ranges 逻辑直接暴露在核心头文件中这样既享受管道表达力又不让模板爆炸式传染到全仓。调试的建议也很简单遇到 view 相关怪问题先尝试把管道中间结果物化成一个小vector打印出来看看再用二分法缩小范围。等确认了哪一层的 filter/transform 有问题再把这层单独提出来测试。这三种场景汇总下来我的判断基准很稳定ranges 适合数据流清晰、按顺序消费、生命周期可控的地方手写循环适合状态交互复杂、需要乱序访问、对编译体验敏感的地方。两者不是替代关系而是工具箱里的两把不同钳子。最后再多说一句我自己的体会。用std::ranges做优化的这几年最大的收获并不是某个具体适配器的用法而是它逼着我重新审视每个中间容器这一个vector真的必要吗这一遍遍历能省吗这个谓词是不是在每次访问时都白跑了一遍与其说 ranges 让你的代码更快不如说它让“寻找不必要开销”的过程变得可控、可读、可维护。遇到性能问题先让数据流说话再决定是优化算法复杂度还是干脆退回手写循环陪你更久的伙伴。