ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++20 std::ranges:编译期策略内联的现代C++高效编程

C++20 std::ranges:编译期策略内联的现代C++高效编程 你要是问我C20里最值得花时间研究的特性是哪个我会毫不犹豫地说std::ranges。不只是因为它的“管道写法”很爽而是在这套新库背后藏着一种“策略内联编译器”式的实现思路——你在源代码里写的谓词、投影、比较器最终会在编译期被展开、内联、常量折叠成接近手写循环的汇编。网上很多人还在玩爱心代码、小游戏或者纠结scanf和cin谁快但真正的分水岭是你能不能理解并驾驭这种编译期“策略机制”。这篇文章会把std::ranges从“能用”讲到“好用”再深入到“为什么它快”的底层逻辑。如果你有C11/14基础想进阶到现代C或者正在准备面试想聊点有深度的这篇内容都适合你。我们直接开讲。1. 内容整体设计与思路拆解1.1 经典STL算法的问题到底在哪先说一个很实际的问题。为什么有了std::sort、std::find_if、std::copy_if这些泛型算法大家写代码时还是经常宁愿自己手写循环因为经典STL有四个让人别扭的地方。第一迭代器对的设计太啰嗦。std::sort(v.begin(), v.end(), cmp)这种写法在1980年代的设计语境下是合理的但在现代代码里99%的场景我们面对的都是“一整个容器”而不是“两个迭代器”。第二算法不可组合。你想“过滤-映射-排序”就得先copy_if到一个临时容器再transform到另一个临时容器中间产生一堆无意义的内存分配和拷贝。第三约束太弱。std::sort接受任意迭代器但如果你传进来一个双向迭代器比如std::list的迭代器它照样编译通过然后在运行期爆炸或者退化成效率极低的实现。第四参数顺序反直觉。std::sort(iterator, iterator, comp)里的比较器永远在最后而std::transform里又得先传函数再传迭代器没有一个统一的规则。这四个痛点不是语法层面的小瑕疵而是设计哲学问题。std::ranges的诞生就是为了系统性地解决这些问题而不是像之前那样靠开发者自己写一堆辅助函数来“绕过去”。1.2 从“迭代器对”到“范围”再到“视图”std::ranges的核心概念是range。什么叫range简单说任何“有begin()和end()的东西”都是一个range。容器是rangeC风格数组是range甚至一个工厂函数生成的“无限序列”也可以是range。这个抽象直接消灭了“传两个迭代器”的繁琐。但这只是第一步。ranges真正革命性的地方是视图view。视图是一个“惰性求值”的range——它不拥有数据不产生新容器只是在原有数据之上描述一个变换规则。std::views::filter(v, pred)返回的不是一个新vector而是一个“知道如何遍历v并跳过不符合条件的元素”的轻量对象。这一点非常关键。我曾经见过有同事把std::views::filter(v, pred) | std::views::transform(f)的结果直接存在变量里然后到处传最后担心半天“这里面到底拷贝了多少数据”。答案是一个字节的底层数据都不用拷贝。视图的组合本质上是管道pipeline——每个视图都是管道上的一级处理器元素只有在你真正遍历它的时候才会一级一级地流过过滤器、变换器。1.3 “策略内联编译器”到底指什么把标题里的“策略内联编译器”拆开看它不是一个新工具而是std::ranges的实现方式。什么是策略在ranges库的语境下策略包括比较策略如std::less{}、std::greater{}或者任意lambda投影策略如Student::score告诉算法“按哪个成员排序”谓词策略如[](const Student s){ return s.score 80; }这些策略都是编译期实体它们的类型会在模板实例化时被完整保留函数体可以直接被内联进算法内部。这就是“内联”。更关键的是整个“生成算法”的过程是在编译期完成的——你在调用点写的那些策略、视图组合、比较器编译器会像“即时编译”一样把它们组合成一个量身定做的循环体而不是调用一个在某个.so里预编译的通用函数。这就是为什么我说它像一个“编译器”你输入的是声明式的策略描述输出的机器码却是手写级的优化产物。后面我会用具体例子证明这一点。2. 核心细节解析与实操要点2.1 视图组合的惰性求值看不见的管道既然说了视图是惰性的就必须深挖一下这个“惰性”到底是怎么实现的以及它对你写代码有什么影响。先看一个最典型的组合用法#include ranges #include vector #include iostream int main() { std::vectorint v{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; auto result v | std::views::filter([](int n) { return n % 2 0; }) | std::views::transform([](int n) { return n * n; }); // 到这一步奇迹发生了result 是一个视图可不是一个 vector for (int x : result) { std::cout x ; // 输出4 16 36 64 100 } return 0; }这个代码里result的类型是一个极其复杂的嵌套模板类型大概长这样ranges::transform_viewranges::filter_viewranges::ref_viewstd::vectorint, lambda1, lambda2。别被这个类型吓到它内部其实只存了一个指向v的指针和两个lambda对象。为什么管它叫“内联编译器”因为你用filter和transform声明了一个“装配流水线”但这些流水线里的机器比较、过滤、映射全都是模板参数在编译期就确定了。遍历result时begin()会一路返回“第一个满足条件的元素”每次都会先移动底层迭代器再检查条件如果条件不满足就继续移动直到找到下一个满足条件的元素或者到达末尾。这个过程全部是内联展开的没有虚函数调用没有运行时多态连迭代器类型都是编译期确定的。有个坑必须提醒视图是“一次使用”的。如果你拿一个filter_view去遍历两遍第二次可能得到空结果因为有些视图内部保持了状态比如std::views::istream_view。容器可以反复遍历视图不一定可以。2.2 排序中的“投影”经典STL里没有的大杀器经典STL里写“按学生成绩排序”是这样std::sort(students.begin(), students.end(), [](const Student a, const Student b) { return a.score b.score; });你得写一个完整的lambda把“取score字段”这件事藏在lambda体内。如果用rangesstd::ranges::sort(students, std::greater{}, Student::score);这里Student::score是一个成员指针ranges把它当作投影projection。sort的首个参数是范围第二个参数是比较器第三个参数是投影。它的语义是先把每个元素投影成score再对投影结果用std::greater{}比较。代码量直接砍半而且意图一目了然。这个投影参数是C20 ranges带给算法最大的礼物之一。它在编译期被翻译成“读取对象偏移量处的成员”连lambda的开销都没有直接内联成一个mov指令级别的操作。这就是“策略内联”的最直观体验。投影同样适用于其他算法。比如你不知道最大值是谁、但想知道最大分数的学生auto it std::ranges::max_element(students, std::less{}, Student::score); std::cout 最高分学生: it-name \n;对比经典写法你不需要自定义比较函数了直接投影出score字段用标准库自带的std::less{}。2.3 为什么lambda能内联、函数指针不能这句话我在面试和代码评审里讲过很多次。你用std::function、裸函数指针和你用lambda、投影性能差距不是“一点点”。看下面这个例子// 情况1函数指针 bool is_even(int n) { return n % 2 0; } std::count_if(v.begin(), v.end(), is_even); // 情况2lambda auto is_even_lambda [](int n) { return n % 2 0; }; std::ranges::count_if(v, is_even_lambda);情况1里is_even被传入时退化为一个函数指针。编译器不能确定这个指针在运行时到底指向哪个函数虽然这里很显然是is_even但标准库模板容器不这么认为所以在循环体内只能做一次间接函数调用。情况2里lambda是prvalue纯右值它有唯一的类型编译器百分百知道函数体是什么可以直接内联。std::ranges的整个设计都在鼓励你使用lambda、投影、仿函数这类类型完整的策略对象而不是退化的函数指针。这也是为什么“策略内联编译器”这个提法如此准确——你把策略作为类型传给编译器编译器还你一个没代价的抽象。3. 实操过程与核心环节实现3.1 一个真实场景学生成绩分析程序光说理论没用我们干个实际项目。需求是假设有一个学生成绩容器要找出所有及格学生的姓名按分数降序排列最后统计平均分。我分别用手写循环、经典STL算法、ranges管道三版代码。先定义数据结构#include algorithm #include iostream #include numeric #include ranges #include string #include vector struct Student { int id; std::string name; double score; }; std::vectorStudent make_students() { return { {1, Alice, 92.5}, {2, Bob, 45.0}, {3, Charlie, 78.0}, {4, David, 61.5}, {5, Eve, 88.0}, {6, Frank, 55.5}, {7, Grace, 95.0} }; }手写循环版void analyze_classic_loop(const std::vectorStudent students) { std::vectorconst Student* pass; for (const auto s : students) { if (s.score 60.0) { pass.push_back(s); } } std::sort(pass.begin(), pass.end(), [](const Student* a, const Student* b) { return a-score b-score; }); double sum 0.0; int count 0; for (const auto* p : pass) { std::cout p-name : p-score \n; sum p-score; count; } if (count 0) { std::cout 平均分: (sum / count) \n; } }经典STL版void analyze_classic_stl(const std::vectorStudent students) { std::vectorconst Student* pass; std::copy_if(students.begin(), students.end(), std::back_inserter(pass), [](const Student s) { return s.score 60.0; }); std::sort(pass.begin(), pass.end(), [](const Student* a, const Student* b) { return a-score b-score; }); double sum 0.0; std::for_each(pass.begin(), pass.end(), [sum](const Student* p) { std::cout p-name : p-score \n; sum p-score; }); std::cout 平均分: (sum / (pass.empty() ? 1 : pass.size())) \n; }ranges管道版void analyze_ranges(const std::vectorStudent students) { auto pass students | std::views::filter([](const Student s) { return s.score 60.0; }) | std::views::transform([](const Student s) { return s; }) | std::ranges::tostd::vector(); // 或者更简洁直接投影排序 std::ranges::sort(pass, std::greater{}, Student::score); for (const auto* p : pass) { std::cout p-name : p-score \n; } double sum std::accumulate(pass.begin(), pass.end(), 0.0, [](double acc, const Student* p) { return acc p-score; }); if (!pass.empty()) { std::cout 平均分: (sum / pass.size()) \n; } }注意第一版我用了std::ranges::tostd::vector()这是C23才有的功能。如果没有C23环境可以改用auto pass students | std::views::filter(...) | std::views::transform(...) | std::ranges::tostd::vector();或者退而求其次直接对const Student*的vector手动构造std::vectorconst Student* pass; for (const auto s : students | std::views::filter(...)) { pass.push_back(s); }哪种更优雅一目了然。但我要强调一个容易误用的点filter和transform视图组合后并不会主动把结果“保存”下来。如果你不做tostd::vector()得到的依然是一个惰性视图当你把这个视图传出去、原容器被销毁后再遍历就是悬垂指针/引用崩溃这是ranges使用者的第一杀手。3.2 三种实现的编译产物对比光看源码没法说明“策略内联编译器”的魔力。我们打开编译器资源管理器Compiler Explorer用-O2编译三种版本对比生成的汇编。真相是手写循环版循环体紧凑直接内联sort的比较逻辑没有多余函数调用。经典STL版因为std::copy_if和std::sort分开中间多了一次pass容器的push_back在O2优化下也足够好但生成的机器码比手写循环略多几条。ranges管道版在-O2、-stdliblibstdc、GCC 13以上的环境里ranges管道版的汇编和手写循环版几乎一模一样甚至更好。filter和transform两个lambda都被完美内联进调用点整个“取指、判断、取地址”的过程被编译器看成了一段线性代码。为什么能做到这一点因为std::views::transform的迭代器是直接包装了底层迭代器lambda是它的成员。每次解引用、递增、比较所有操作都在同一个模板实例化内部编译器可以把这些调用全部掐头去尾留一个裸循环。3.3 用ranges重构一个“分组统计”的坑项目里有个需求统计vector里每个单词出现的次数按次数降序输出。经典做法是std::unordered_map计数然后拷贝到vector排序。ranges能帮忙的地方是排序前的那一次“筛选”只显示出现次数大于等于3的。#include unordered_map #include map void word_count_with_ranges(const std::vectorstd::string words) { std::unordered_mapstd::string, int counter; for (const auto w : words) { counter[w]; } auto entries counter | std::views::transform([](const auto kv) { return std::pair{kv.first, kv.second}; }) | std::ranges::tostd::vector(); std::ranges::sort(entries, std::greater{}, std::pairstd::string, int::second); for (const auto [word, count] : entries | std::views::filter([](const auto p) { return p.second 3; })) { std::cout word : count \n; } }这段代码有几个注意点std::ranges::sort的投影参数用法std::pairstd::string, int::second是一个指向second成员的指针不要求排序对象是pair本身只要它有这个成员。entries | std::views::filter(...)返回的view不能直接拿去做std::ranges::sort因为filter是惰性的你不能修改它背后的容器。所以要先tovector()拿到实体再排序。counter | std::views::transform(...)里counter是std::unordered_map它的元素是const std::pairconst std::string, int注意key是const如果你用std::pairconst std::string, int::second做投影也是可以的。一句话总结实操心得ranges管道适合“查询、筛选、转换、累加”这类只读过程排序、删除、修改这类需要写到容器上的操作还是先物化再操作。4. 常见问题与排查技巧实录4.1 编译错误长达十行怎么读这是我被问到最多的问题。std::ranges的模板错误在GCC和Clang下动辄几百行看起来像天书。我告诉你实际经验不用全读抓住三条线索。第一找“注意”后面的第一行那里通常写着“constraints not satisfied”约束不满足。第二步看错误信息里最靠近末尾的“required by the constraints”和“the expression is invalid”这两行它告诉你哪个表达式不合法。第三也是最重要的把range换成container试试比如std::vectorint能过、std::listint过不了往往能帮你快速定位是“要求随机访问迭代器”还是“要求sized_range”。一个典型的错误std::vectorint v{4, 1, 3, 2}; std::ranges::sort(v | std::views::transform([](int x) { return x * 2; }));这段代码编译不过。因为sort要求的是可写随机访问范围而你传入的transform_view返回的是prvalue临时值只可读不可写。编译器会给你一个“no match for operator”之类的错误。实际解决方式是把transform的结果收集起来再排或者干脆把变换逻辑放到比较器/投影里。4.2 悬垂视图视图不拥有数据的代价前面提到视图只是“指向数据的窗口”。如果数据生命周期比视图短你就踩进了未定义行为区。最常见的例子auto get_pass_names() { std::vectorStudent students make_students(); return students | std::views::filter([](const Student s) { return s.score 60.0; }) | std::views::transform(Student::name); }这个函数返回一个视图但视图像一个“寄生虫”一样引用着已经被销毁的students。任何对这个返回值的遍历都是UB。这个问题在经典STL里不存在因为经典STL没有“不拥有数据的组合视图”这种概念。解决办法不要返回视图返回容器用std::ranges::tostd::vector()物化auto get_pass_names() { std::vectorStudent students make_students(); return students | std::views::filter(...) | std::views::transform(Student::name) | std::ranges::tostd::vector(); }这句tostd::vector()真的很重要我项目里至少有一半的崩溃都是因为忘了这一句。4.3 性能反直觉当你需要“强制求值”时惰性求值是ranges的优点但有时也是坑。如果一个视图被反复遍历每次遍历都会重新执行所有过滤/变换逻辑。假设你在一个循环里多次访问同一个filter_viewauto evens v | std::views::filter([](int x) { return x % 2 0; }); for (int i 0; i 100; i) { auto it std::ranges::find(evens, i * 10); // ... }这里每次find都从头扫描复杂度是O(N)不说实际执行的开销还要叠加上filter的lambda调用链。如果你本意是“先算好一部分再复用”那就应该物化一次auto evens_vec evens | std::ranges::tostd::vector();然后对这个vector反复find。惰性求值适合“遍历一次、用完即走”的场景不要把它当缓存用。4.4 内联失效的几个瞬间策略内联编译器不是万能的。当我谈到“零成本抽象”时一定会加一句零成本是有条件的。条件一策略对象必须是类型完整的lambda或函数对象不能用std::function。一旦你的比较器或谓词是std::function它内部就是类型擦除大概率走堆分配或间接调用编译器没法内联。条件二别传捕获了太多状态的lambda尤其是捕获了shared_ptr、std::function成员的lambda内联边界会扩大寄存器分配变差。条件三开-O2以上。没开优化的代码什么都别谈。ranges的惰性视图在-O0下性能很惨但这也是所有C模板抽象的通病。实际操作中我有个习惯写完后打开汇编或基准测试框架Google Benchmark确认关键路径没“漏”。如果预期内联的lambda没有内联检查是不是意外类型擦除成了std::function或者投影写成了运行时函数。4.5 C版本差异速查很多人用了C20的ranges却不知道C23又补充了好几个关键部分。这张表帮你看清版本差异特性C20C23std::ranges::sort等算法支持支持并有更多算法补全std::views::filter、transform、take支持支持std::ranges::to物化视图到容器不支持支持std::views::zip多范围并行遍历不支持支持std::views::enumerate带下标遍历不支持支持std::ranges::fold_left不支持支持C23如果你还在用C20std::ranges::to不能用手动构造vector或者用std::vector(begin, end)过渡。理论上C23在2023年发布但主流编译器的完整支持还得看2024-2025年的版本。GCC 13和Clang 16已经支持大部分C23 ranges功能了但有些库实现仍然有bug项目里用得最稳的其实是C20那一套。5. 进阶延伸从使用到理解5.1 视图的“值语义”与“引用语义”理解ranges的底层能力对你的调试帮助巨大。视图相当于一个“按持有底层数据的引用但按值拷贝视图本身”的对象。视图拷贝后两个视图引用同一份底层容器。这个设计是有意的——避免深拷贝、避免生命周期的悬挂、方便把视图当作参数传入传出。因此如果你写一个函数接收视图参数template std::ranges::range R void print_scores(R r) { for (const auto s : r) std::cout s ; }这里的R是一个通用的转发引用既能接收容器也能接收视图。内部for的展开机制要求r满足std::ranges::range概念。这个概念就是“策略内联编译器”对类型做编译期检查的那只手。你可以把range概念理解为一种编译期契约所有算法模板在实例化前先验证参数类型是否满足概念不满足直接给你一个“概念未满足”的编译错误而不是等到运行期才爆炸。5.2 自定义范围适配器把你的策略变成管道如果你觉得“filter transform take”太常见想封装成一个自己的“策略”ranges允许你自定义适配器或者更朴素地写一个返回视图的重用函数。auto topN(std::ranges::range auto r, size_t n) { return std::move(r) | std::views::take(n); }更精细的做法是实现一个Range Adaptor ObjectRAO但那要处理闭包、管道操作符等复杂模板篇幅太大。我给你的实操建议是如果只是项目内部重复使用用普通函数包装就够了如果写库发布给别人用再考虑真正的自定义适配器。5.3 面试加分concept和ranges的关系面试官如果问“ranges和concept有什么关系”不要只回答“都是C20特性”。你要说std::ranges里的所有算法都用concept约束参数。比如sort的头文件里就是templatestd::ranges::random_access_range R, std::indirect_strict_weak_orderstd::ranges::iterator_tR Comp std::ranges::less, std::indirectly_copyable_storablestd::ranges::iterator_tR, std::ranges::range_value_tR * Proj std::identity这不是语法装饰而是编译期“策略内联编译器”的入口编译器检查这些concept如果不满足在实例化之前就拒绝编译而不是像旧STL那样报一个从模板深坑里冒出来的“no matching function”错误。concept给错误信息设了一道清晰的闸门ranges利用这道闸门把所有策略的合法性在编译期锁死。5.4 我的最后一个实战建议我真正开始在日常项目里全面用std::ranges是从一次代码评审被骂开始的。那段代码用经典STL写了小100行各种临时容器、嵌套循环。重构成ranges管道后30行搞定而且逻辑一眼看懂。但我也交过学费——悬垂视图、std::ranges::to不可用、标准库实现的差异这些坑踩一遍就记住了。给你的行动清单别试图一口气学完全部ranges接口先掌握sort、filter、transform、take、iota_view这几个最常用的有一个“物化习惯”凡是视图要跨作用域强制用std::ranges::tostd::vector()新项目直接启用C20老项目评估后可以逐步替换最痛的STL调用点碰到编译错误先看concept约束再看表达式错误不要一头扎进几百行的模板报错里我在实际使用中最深的一点体会是std::ranges不是让你写“看起来很酷的链式调用”它是在逼你把“要做什么”和“怎么做”彻底分开。你写的是策略编译器负责帮你把它们内联成最高效的执行路径。用好了这套东西你的代码会比以前短三分之一性能还不会掉。如果你是从C11/14直接跳到C20的别急先用一个周末的时间把ranges这章啃下来之后写代码的舒服程度会是另一个世界。C的现代演进从来不是把旧东西推翻重来而是给你更好的表达工具让你能把脑子里的设计意图直接翻译成机器码——std::ranges就是这套哲学最极致的体现。
返回列表