1. 废话少说:为什么std::ranges值得你花一个下午搞明白
先抛个场景。你写了一段代码,要把一个vector<int>里的偶数挑出来、翻倍、再求和。传统写法长这样:
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8}; std::vector<int> evens; std::copy_if(v.begin(), v.end(), std::back_inserter(evens), [](int n) { return n % 2 == 0; }); std::vector<int> 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())撕成了一截一截,读起来得手动把三段拼进脑子里。而用 C++20 的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())。 - 算法可以吃"视图"(view):
filter、transform运算得到的东西也是一个"范围",可以继续传给别的算法。 - 算法可以吃"哨兵"(sentinel):范围的结尾不必非得是"指向最后一个元素之后的迭代器",可以是任何能判断"是否到达末尾"的谓词对象。
这第三点在日常代码里最不起眼,但其实是整套系统里最深刻的设计之一。以前你处理"以 '\0' 结尾的字符串"或者"读文件直到某个标记",得先把它们转成有end()的容器,或者硬写循环判断。现在你只要定义恰当的类型(迭代器 + 哨兵),算法直接就能在"上限未定"的范围上工作。后面实战部分我会给个例子。
2.2 三个支柱:视图、算法、适配器的分工
std::ranges不是一个大一统库,而是三块咬合在一起的部分:
| 组件 | 作用 | 常见例子 |
|---|---|---|
std::ranges::xxx算法 | 操作一个范围对象的算法 | sort、transform、filter、find_if、fold_left |
std::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::vector<std::string> errors; std::string line; while (std::getline(file, line)) { if (line.find("ERROR") != std::string::npos) { errors.push_back(line); } } std::vector<std::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::map<std::string, int> counter; for (const auto& c : codes) { counter[c]++; } std::vector<std::pair<std::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::map<std::string, int> counter; for (auto&& input : std::ranges::istream_view<std::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::vector<std::pair<std::string, int>> sorted(counter.begin(), counter.end()); std::ranges::sort(sorted, std::greater{}, &std::pair<const std::string, int>::second); for (auto&& [code, count] : sorted | std::views::take(3)) { std::cout << code << " " << count << "\n"; }改动点逐个说明:
std::ranges::istream_view<std::string>(file)把文件流包装成一个范围,每迭代一次读入一行字符串。它依然是"按需读取",不会预读整个文件。- 两个
if嵌套代替了原来的errors和codes两个中间 vector。注意 C++17 引入的if (auto x = ...; 条件)语法搭配使用,作用域紧绑在一起,变量生命周期更精确。 - 排序直接用
std::ranges::sort,不再需要sorted.begin(), sorted.end(),而是整个sorted。第四参数传了一个"投影"(projection)&std::pair<const std::string, int>::second——这又是一个 ranges 带来的好东西,下面单独说。 - 输出时用
sorted | std::views::take(3)优雅地控制只取前 3 个,不需要手写i < 3 && i < sorted.size()这种边界判断。
提示:
&std::pair<const std::string, int>::second这样的写法在部分编译器上需要显式指定成员指针类型,dangling 风险很低。如果嫌难看,也可以写个 lambda:[](const auto& p) { return p.second; }。
3.3 投影(Projection):这玩意儿是隐藏的宝贝
上面解了ranges::sort的第四参数:投影。它允许你在不创造临时对的情况下,告诉算法"请按这个属性的值来操作元素"。
以前你要按学生成绩排序,就得写完整比较 lambda:
std::sort(students.begin(), students.end(), [](const auto& a, const auto& b) { return a.score > b.score; });用 ranges:
std::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是 C++23 才进的,要用的也得注意标准版本;C++20 时期可以用std::accumulate(r.begin(), r.end(), 0)替代,虽然看着不够帅但不影响逻辑。
4. 容易翻车的几个点:views 的生命周期和 const 问题
4.1 悬垂视图(Dangling View):把临时容器丢了就完蛋
这是 ranges 新手最常踩的坑,没有之一。
看这段:
std::ranges::range auto get_even_view(std::vector<int>& v) { return v | std::views::filter([](int n) { return n % 2 == 0; }); } auto main() -> int { auto evens = get_even_view(std::vector<int>{1, 2, 3, 4}); // 灾难:底层临时 vector 已经被销毁,evens 里保存的是悬空引用 }视图默认是"引用语义"——它保存底层范围的引用或迭代器,自己不拥有数据。第一行那个临时std::vector<int>在表达式结束后就析构了,而evens还傻乎乎地握着一个指向已灭亡容器的迭代器,一行for (int x : evens)就是 undefined behavior,轻则乱值重则段错误。
这是 C++ 里老生常谈的悬垂引用问题在 ranges 里的形态。解决的思路也一致:
- 不要让视图比底层数据活得更久。视图的生命周期必须嵌套在容器的生命周期内。
- 如果非要返回一个"拥有数据"的视图,别用 filter/transform 这类引用视图,可以先拷成
std::vector等具体容器再返回;或者用自己的 RAII 类包一层。
我之前在日志分析工具里就犯过这个错。过滤器返回了一个局部范围上的视图,函数一结束,原先那个"只读打开的文件内容"缓冲区被回收,整个视图变成一堆野指针。从那以后,凡是视图要跨作用域传递,我都额外加注释提醒自己"此处按引用持有底层对象"。
注意:
views::all、views::filter这类视图绝不拥有数据;而ranges::to<std::vector>()(C++23)是把范围急切地物化成容器的正确姿势——它真的把元素拷贝进去,不再引用原范围。
4.2 const 正确性:视图和 const 容器一起用,容易让编译器发飙
简单规则:const容器的视图,只能用const谓词来直接作用在元素上,但很多可变的视图(如reverse、transform)对const容器会抛出编译错误或显著限制。
比如:
const std::vector<int> 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::range<decltype(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_interface<stride_view<V>> { private: V base_; std::ranges::range_difference_t<V> step_; std::ranges::iterator_t<V> current_; public: stride_view() = default; stride_view(V base, std::ranges::range_difference_t<V> 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::vector<int> 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. 值得进一步挖的角落:哨兵、子范围、生成器与 C++23/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 C++23 和 C++26 的延续:单子操作、to<vector>、格式化输出
C++23 为 ranges 补完了一部分旧标准缺失的适配器和工具:
std::ranges::to<std::vector>():把视图急切地物化成容器,而且是真正的"拥有数据"。这几年被无数人吐槽"视图不能转容器"的痛点终于解决。用法:auto vec = v | views::filter(...) | std::ranges::to<std::vector>();std::views::enumerate:给元素配上索引输出。以前你得用一个计数器变量在循环里手动++,现在视图自带。std::views::zip:把两个范围打包成"元素对"范围,处理两个平行序列再也不用手写索引对齐。std::views::chunk:按固定大小分块处理序列,一口气处理"每 4 个一组求和"这种逻辑。- 单子操作(monadic operations):
views::transform配合std::optional可以写类似链式调用的求值流,C++23 逐渐引入transform与and_then的视图封装,代码风格越来越接近函数式语言。
C++26 目前的草案还包含更复杂的视图,以及改进的 format 支持。我个人的感觉是:ranges 的演进方向正在把 C++ 从"必须先构造完整数据结构才能处理"的老套路中彻底解放出来,数据流式的处理方式逐渐成为主流。
8. 给新手的一句实在话
看到这里,可能信息量有点大。我建议直接照着下面的路线动手:
- 用
std::ranges::sort替换三个日常排序。 - 用
std::views::transform重写一个"从容器生成新容器"的循环。 - 找一个
filter加transform加take组合的练习题做掉。 - 试着给自己的某个范围写一个最简单的自定义视图。
代码编辑器里敲一遍比读十篇博客都管用。真碰到编译报错别急着放弃,概念约束的报错通常背后都有一个具体的语义问题,仔细看最前面几行错误信息往往就能定位(比如视图生命周期问题、迭代器类型不匹配)。我现在的经验是,ranges 的报错看着吓人,但九成情况下错误信息的第一行就指明了问题所在,别被后面几百行模板垃圾吓退。
这套系统确实有一定的学习曲线,但它换来的是代码表达力的大幅提升,以及整个团队在"处理数据流"这一层抽象上的共同语言。如果你在写需要经常处理集合、序列、数据管道的 C++ 代码,那么花一天时间入坑 ranges,长期来看肯定不亏。