☰
C++20 ranges库实战:视图、管道与惰性求值替代STL迭代器
2026/10/21 0:44:11 网站建设 项目流程

1. 为什么是 ranges:先把三个老痛点摆出来

1.1 传统 STL 算法的三个痛点

你大概率写过类似的代码:一个容器,先过滤出符合条件的元素,再对每个元素做变换,最后把结果收集到另一个容器里。用传统 STL 算法写,就是下面这个样子:

std::vector<int> nums{1, 2, 3, 4, 5, 6}; std::vector<int> 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”这个词直译为“范围”,意思是把“一对迭代器”这个东西,升级成“一个可遍历的范围”这个抽象。C++20 的 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 算法、觉得迭代器写起来烦、希望在 C++20/23 项目里提升代码表达力的开发者。

2. 核心模块速览:range、view、sentinel、concepts 各自解决什么问题

2.1 range 与 view:可借用范围是一切的原料

C++20 里,std::ranges是一个命名空间,里面装了一大票与范围相关的组件。最基础的range是一个概念(concept),定义也很直观:一个类型如果能提供begin()和end(),那它就是一个range。数组是 range,各种std::vector是 range,std::string是 range,自定义的容器只要实现了迭代器接口也是 range。

但真正让 ranges 好用的是view。view 是一个“轻量、可拷贝、O(1) 拷贝代价”的范围,它不拥有数据,只是对已有数据的观察角度。std::views::filter返回的filter_view就是这样一个观察者:它底层还是引用着你的std::vector,但每次遍历时只把满足条件的元素交给上层。view 本身不分配内存,不拷贝元素,这也是把它组合进管道时不会产生额外性能开销的根本原因。

这里有个容易混淆的点,就是“右值范围”和“左值范围”的区别。假设你直接写:

auto v = std::vector<int>{1, 2, 3} | rv::take(2);

这里的临时容器会被移进一个内部持有的所有权视图里,数据跟着v走。这个所有权视图在 C++23 里正式叫std::ranges::owning_view。而如果你传的是一个左值容器:

std::vector<int> nums{1, 2, 3}; auto v = nums | rv::take(2);

那么nums不会被拷贝,它会被包成一个ref_view,v实际上“借用”了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,它不会让所有错误信息都变得一目了然,但比起 C++98 时代模板报错的混乱程度,已经是质的提升。尤其是当你给函数写的模板参数用std::ranges::range约束时,调用方的错误提示会清楚得多。

3. 高密度实战:什么时候真正该用 ranges

3.1 过滤加变换:统计订单金额

我实际工作中用得最多的场景就是“过滤 + 变换”。比如某次我们改历史订单的结算逻辑,要从一批订单里筛出金额大于某个阈值的已支付订单,再把每单抽成算出来。传统写法需要两三次循环加中间容器,ranges 则是一段管道:

struct Order { std::string id; double amount; bool paid; }; std::vector<Order> 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::vector<double>,可以先做这一步:

std::vector<double> vec; for (double c : commissions) { vec.push_back(c); }

C++23 有std::ranges::to<std::vector>(commissions)一行搞定,这个后面单独讲。

3.2 投影排序:按结构体成员排序

另一个高频场景是排序。处理结构体数组时,传统写法要写一个比较 lambda,比如按age排序:

struct Person { std::string name; int age; }; std::vector<Person> 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::vector<std::string_view> { return s | rv::split(delim) | rv::transform([](auto&& r) -> std::string_view { return std::string_view(r); }) | std::ranges::to<std::vector>(); }

注意 C++23 才提供std::ranges::to,如果你的工具链支持 C++23,这个函数非常简单。即使还在 C++20,用循环收集也不算复杂,只是 code 稍长一点。另一个坑是string_view指向的原始字符串生命期,如果s是临时字符串,转换出来的string_view可就悬空了,这个问题本质上是借用生命周期,下面单独会聊。

3.4 统计、区间判断与分页

除了组合式视图,ranges 还提供一批直接吃 range 的算法重载,日常写代码时几乎每天都会碰到几个。比如统计满足条件的数量:

std::vector<double> 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 的抽象会不会产生额外开销。实测下来,主流编译器在开启优化后,简单的 filter+transform 管道能生成和手写循环几乎等价的机器代码。比如我们组之前把一个热点路径上的日志清洗循环改成 ranges 管道,开了 O2 以后,汇编反而少了几个分支,因为编译器更容易看出每个元素之间没有依赖,可以做向量化。

需要注意的其实是分配行为。视图本身不分配内存,但如果把一个视图to<std::vector>变成容器,那必然有一次容器分配。这个分配是符合预期的,别误以为所有操作都零分配就行。另一个容易忽略的细节是,views::filter的谓词如果捕获了较大的对象,视图对象本身会变大,因为视图里保存了谓词副本。但通常无状态的 lambda 是空对象,不会增加视图体积。

用 ranges 写代码不一定更快也不一定更慢,它的收益主要在于表达和可维护性。性能上,我可以负责任地说:大多数场景和手写循环同量级,少数场景因为编译器更容易理解意图,反而有优化空间。

4.3 什么时候别用 ranges

虽然我很喜欢 ranges,但不会无脑推崇。有两个场景我会刻意少用:一个是需要和 C++11/14 老代码库兼容,另一个是在调试铺天盖地的模板错误时先稳住主线。第二个场景其实不是 ranges 的错,而是模板深度带来的现实成本——管道组合越复杂,类型名越长,断点调试就越痛苦。

还有一种情况是嵌套循环里本身就不适合用视图。比如你要处理一个二维矩阵,同时依赖行和列的索引来做逻辑判断,这时直接遍历下标反而更清楚。ranges 能做的事很多,但“清晰”永远是第一原则。视图管道写出来漂亮,如果读的人需要花五分钟才能看明白每一层在干嘛,那就该考虑拆函数、加注释,或者退回循环。

5. 踩坑实录:视图悬垂、生命周期与调试教训

5.1 最经典的悬垂案例

写了两年 ranges,我遇到的最大坑,也是社区里讨论最多的坑:给filter或transform传一个临时范围,结果视图悬垂。看这段代码:

auto bad = std::vector<int>{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::vector<int>{1, 2, 3, 4} | std::views::take(2);

take_view在底层范围是右值时,会直接把范围“抱住”存进自己内部,而不是只存迭代器。所以你把这个ok拿出去用,数据是安全的。标准库里也有owning_view来专门持有右值范围。但问题就在这里:同样是管道,有的适配器安全,有的不安全,细节藏在“这个视图到底存储了什么”上面。

我给自己的规则很简单:凡是管道起始端是临时构造的右值容器,一律先用一个具名局部变量保存:

std::vector<int> 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::to<std::vector>();

C++20 里没有to,那就std::vector<Order> dv(intermediate.begin(), intermediate.end());。这种“中间落地法”虽然牺牲了惰性,但排查问题时最直接。

5.4 和旧代码的互操作

ranges 和旧代码共存是个现实问题。好消息是,C++20 的 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. C++23 的新玩具与后续扩展:zip、chunk、stride、ranges::to

6.1 C++23 新增的视图适配器

C++23 给 ranges 补了一批实用适配器,让很多原先要写一行循环的场景也能流程化。我挑几个最常用的讲。

std::views::zip:把多个 range 同步打包成元组流。以前要同时遍历两个容器并组合,只能写带下标的循环。现在:

std::vector<std::string> names{"a", "b", "c"}; std::vector<int> 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::to

C++23 的std::ranges::to<Container>()是我最期待落地的功能之一。它把一个 view 或 range 直接转成目标容器,并且支持指定转换策略。之前想收集 view 结果,必须自己循环push_back;现在一行:

auto result = nums | rv::filter(...) | rv::transform(...) | std::ranges::to<std::vector<int>>();

甚至可以直接转成关联容器。比如从一个订单视图里提取 id 到std::set:

auto ids = orders | rv::transform(&Order::id) | std::ranges::to<std::set<std::string>>();

这个能力让“视图负责惰性计算,容器负责存储结果”的分工彻底清晰。实际项目里,我通常让 view 管道保持惰性处理到最后一刻,只有需要传给旧接口、或者需要持久保存时才用to落地。这不只是代码好看,也是性能上的正确选择:先声明处理逻辑,再一次性分配结果,避免中间容器反复分配。

6.3 与标准库新旧代码的互操作深化

C++23 还调整了一些 ranges 语义,比如split_view和lazy_split_view的区分更明确。C++20 时代用split偶尔会遇到实现差异,C++23 的标准把它梳理好了:普通split返回的每段默认也是惰性的,想要稳定的行为就显式转string_view。如果你的代码需要同时兼容 C++20 和 C++23 工具链,对split的用法最好保持谨慎,尽量把输出立刻转成容器或string_view,避免依赖未定义的行为。

ranges 生态从 C++20 到 C++23 一直在补短板,未来的 C++26 也在继续扩展。就我自己的心智模型来说:range是抽象输入,view是组合加工,算法是消费处理,ranges::to是最后落袋。这四层互相解耦,又通过一致的接口串联起来。日常写代码时先用视图描述处理流程,最后用to或者循环收口,基本能覆盖九成数据处理场景。

最后分享一点个人体会:我踩过很多视图悬垂的坑,也见过同事因为模板报错对 ranges 敬而远之,但真正用顺手之后,它是能明显减少样板代码、提升逻辑清晰度的。如果你所在的项目已经切到 C++20,真心建议从最基础的一个filter加transform开始,小步尝试;先不追求花哨的组合,把“视图是惰性的”“借用生命周期要管好”这两条原则吃透,ranges 就会慢慢变成你工具箱里顺手的那把螺丝刀,而不是束之高阁的新奇玩意。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询