
1. 项目概述为什么我们需要 C20 范围库如果你写过几年 C尤其是用过标准模板库STL那你肯定对std::sort(vec.begin(), vec.end())这种模式再熟悉不过了。每次调用算法都得手动传递一对迭代器指明操作的起点和终点。这模式本身没问题它提供了灵活性但写多了你就会发现代码里充斥着重复的begin()和end()调用尤其是在需要组合多个操作时代码会变得冗长且需要引入中间变量破坏了表达式的流畅性和直观性。C20 引入的 Ranges 库就是为了解决这个“历史遗留问题”。它的核心思想很简单把整个序列容器、视图等当作一个整体来操作而不是零散的迭代器对。这不仅仅是语法糖它带来的是编程范式的转变让 C 的泛型编程更接近函数式编程的简洁与优雅。想象一下你不再需要关心迭代器的具体类型也不需要手动管理迭代器的有效性只需声明“我要对这个范围做什么”剩下的交给编译器。这对于提升代码的可读性、可维护性以及编写安全性都有着巨大的意义。无论是处理数据过滤、转换还是复杂的流水线操作Ranges 库都能让你写出更清晰、更高效的代码。2. 核心概念深度解析范围、视图与适配器要玩转 Ranges 库必须吃透三个核心概念范围Range、视图View和范围适配器Range Adaptor。它们是构建一切高级用法的基石。2.1 范围Range一切的基础在 C20 中一个范围被定义为“拥有一个起始迭代器和一个哨兵sentinel的对象”。哨兵标志着范围的结束它的类型可以和迭代器不同这为表示无限序列或特殊终止条件提供了可能。简单来说任何能提供begin()和end()操作的东西基本上都是一个范围。这包括了所有标准容器std::vector,std::list,std::array等、原生数组、初始化列表以及std::string_view等。Ranges 库通过一系列Range 概念对范围进行了更精细的分类这类似于迭代器分类但是在范围层面上的抽象范围概念要求典型容器示例std::ranges::input_range至少可从头到尾单次遍历。std::istream_viewstd::ranges::forward_range可多次从头遍历迭代器可复制。std::forward_list,std::unordered_setstd::ranges::bidirectional_range可向前和向后遍历。std::list,std::set,std::mapstd::ranges::random_access_range支持常数时间的随机访问[]运算符。std::vector,std::deque,std::arraystd::ranges::contiguous_range元素在内存中连续存储。std::vector,std::array,std::stringstd::ranges::output_range可向其写入元素。输出迭代器指向的容器这些概念通过 C20 的概念Concepts特性实现编译器能在编译期检查一个类型是否满足特定概念从而提供更好的错误信息和约束。例如std::ranges::sort要求其参数至少是random_access_range且元素可比较如果你传一个std::list给它编译器会给出清晰的概念检查失败信息而不是晦涩的模板错误。2.2 视图View惰性求值的轻量级范围视图是 Ranges 库的灵魂它是一种特殊的范围。视图本身通常不拥有数据它只是对底层范围称为“基范围”的一种“看法”或“变换”。视图的拷贝、移动、赋值等操作都是常数时间复杂度O(1)因为它通常只存储迭代器或少量状态而非数据本身。视图最关键的特性是惰性求值Lazy Evaluation。当你创建一个视图时例如std::views::filter并不会立即对底层范围的所有元素执行过滤操作。计算只发生在你真正需要访问元素的时候比如在基于范围的 for 循环中迭代视图或者将视图转换为容器。这种特性带来了两大好处性能优化避免了不必要的中间结果存储和计算。在传统的std::copy_ifstd::transform链式调用中copy_if的结果必须存储在一个中间容器里然后transform再读取这个中间容器。而视图链可以无缝连接只在最终需要时计算每个元素。无限序列处理可以表示和处理理论上无限的序列如生成所有自然数的视图因为计算是按需进行的。2.3 范围适配器Range Adaptor构建视图的管道范围适配器是用于创建视图的函数对象。它们通常通过管道操作符|来调用形成一种类似 Unix shell 管道的风格让数据从左向右“流动”依次经过各个变换阶段。#include ranges #include vector #include iostream int main() { std::vectorint numbers {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 传统方式过滤偶数然后求平方需要中间变量。 // std::vectorint temp; // std::copy_if(numbers.begin(), numbers.end(), std::back_inserter(temp), [](int n){ return n % 2 0; }); // std::vectorint result; // std::transform(temp.begin(), temp.end(), std::back_inserter(result), [](int n){ return n * n; }); // Ranges 方式使用管道操作符组合适配器无中间存储惰性求值。 auto even_squares numbers | std::views::filter([](int n) { return n % 2 0; }) | std::views::transform([](int n) { return n * n; }); for (int x : even_squares) { // 在此循环中filter 和 transform 才真正执行 std::cout x ; // 输出4 16 36 64 100 } std::cout \n; }在上面的例子中std::views::filter和std::views::transform就是范围适配器。numbers | std::views::filter(...)表达式产生了一个过滤视图这个视图再通过|传递给std::views::transform最终生成一个组合视图even_squares。整个过程中numbers的数据没有被修改也没有产生完整的中间向量temp。实操心得理解“视图不拥有数据”这一点至关重要。这意味着视图的生命周期不能长于其基范围。如果一个局部容器被销毁而指向它的视图还在被使用就会导致悬垂引用和未定义行为。这是使用视图时需要特别注意的“坑”。3. 核心工具详解算法、视图工厂与适配器Ranges 库并非完全抛弃旧世界它提供了两套工具一套是现代化、支持范围参数的范围算法另一套是用于创建和组合视图的视图工厂和适配器。3.1 范围算法Range Algorithms标准库头文件algorithm中的大多数算法在 C20 中都拥有了对应的“范围版本”位于std::ranges命名空间中。它们的函数签名通常更简洁直接接受一个范围作为参数而不是迭代器对。#include algorithm #include ranges #include vector int main() { std::vectorint vec {5, 3, 1, 4, 2}; // 传统 STL 算法 std::sort(vec.begin(), vec.end()); // C20 范围算法 std::ranges::sort(vec); // 更简洁意图更清晰 }范围算法的优势不止于简洁。它们还普遍支持投影Projection参数。投影允许你在应用算法如比较之前先对元素进行一个变换。这在基于对象某个成员排序或查找时极其方便。#include algorithm #include ranges #include vector #include string struct Person { std::string name; int age; }; int main() { std::vectorPerson people {{Alice, 30}, {Bob, 25}, {Charlie, 35}}; // 传统方式需要自定义比较函数或 Lambda std::sort(people.begin(), people.end(), [](const Person a, const Person b) { return a.age b.age; }); // 范围算法 投影直接指定按成员排序 std::ranges::sort(people, std::less{}, Person::age); // 等价于std::ranges::sort(people, {}, Person::age); // 第一个参数是范围第二个是比较器默认是 std::less{}第三个是投影这里指向成员 age。 // 效果比较的是 a.age 和 b.age而不是整个 Person 对象。 }投影极大地减少了编写样板 Lambda 表达式的需要让代码更声明式。许多范围算法如std::ranges::find,std::ranges::count_if等都支持投影。3.2 视图工厂View Factories视图工厂是能凭空创建视图的适配器它们不依赖于一个已存在的基范围。最常见的视图工厂是std::views::iota用于生成一个单调递增的整数序列视图。#include ranges #include iostream int main() { // 生成一个从 0 开始的无限整数序列视图 auto infinite_numbers std::views::iota(0); // 但通常我们会限制它例如取前10个 for (int i : infinite_numbers | std::views::take(10)) { std::cout i ; // 输出 0 1 2 3 4 5 6 7 8 9 } std::cout \n; // 生成一个有限序列从 5 开始到 15 结束不包含15 for (int i : std::views::iota(5, 15)) { std::cout i ; // 输出 5 6 7 8 9 10 11 12 13 14 } std::cout \n; }iota视图是惰性的它可以表示无限序列这在传统 STL 中很难优雅地实现。结合std::views::take或std::views::take_while可以方便地截取需要的部分。3.3 常用范围适配器实战Ranges 库提供了丰富的适配器下面通过一个综合例子展示几个最常用的#include iostream #include ranges #include vector #include string int main() { using namespace std::string_literals; std::vectorstd::string words {apple, banana, cherry, date, elderberry, fig, grape}; // 目标过滤出长度大于4的单词转换为大写然后取前3个 auto processed words | std::views::filter([](const std::string s) { return s.size() 4; }) | std::views::transform([](std::string s) { for (auto c : s) c std::toupper(c); return s; }) | std::views::take(3); std::cout Result: ; for (const auto w : processed) { std::cout w ; // 输出APPLE BANANA CHERRY } std::cout \n; // 反转视图 std::cout Reversed first 3: ; for (const auto w : processed | std::views::reverse) { std::cout w ; // 输出CHERRY BANANA APPLE } std::cout \n; // 获取元素索引使用 std::views::enumerate (C23) 或自己组合 // C20 中可以通过 iota 和 zip 模拟需要 ranges 和 tuple std::cout With indices:\n; auto indexed_view std::views::zip(std::views::iota(0), words); for (const auto [idx, word] : indexed_view | std::views::take(3)) { std::cout idx : word \n; } }关键适配器解析filter: 接受一个谓词返回bool的函数只保留满足条件的元素。transform: 接受一个函数将每个元素映射为另一个值。注意传递给transform的 Lambda 应该接受值或 const 引用并返回新值。如果修改原元素需确保基范围支持。take: 取前 N 个元素。对于无限序列特别有用。reverse: 产生一个反向遍历的视图。要求基范围至少是双向范围bidirectional_range。drop: 跳过前 N 个元素取剩下的。keys/values: 对于元素为pair或tuple的范围如std::map分别获取键或值的视图。非常实用。注意事项适配器的求值顺序是从左到右就像管道一样。data | filter(A) | transform(B)意味着先过滤再对过滤后的结果进行转换。这与数学上的函数组合B(A(data))顺序一致但和嵌套函数调用transform(filter(data, A), B)的阅读顺序从内到外不同需要适应。4. 高级特性与自定义视图当你熟悉了基本用法后可以探索 Ranges 库更强大的能力包括处理嵌套范围、编写自定义适配器以及利用哨兵实现特殊迭代逻辑。4.1 处理嵌套范围与join、split现实中的数据常常是嵌套的比如vectorvectorint。Ranges 库提供了std::views::join来将其扁平化。#include iostream #include ranges #include vector int main() { std::vectorstd::vectorint matrix {{1, 2}, {3, 4, 5}, {6}}; // 将二维向量展平为一维视图 auto flattened matrix | std::views::join; for (int val : flattened) { std::cout val ; // 输出1 2 3 4 5 6 } std::cout \n; }另一个强大的适配器是std::views::split它可以将一个范围根据给定的分隔符拆分成子范围视图。#include iostream #include ranges #include string #include vector int main() { using namespace std::string_literals; std::string csv apple,banana,cherry,date; // 注意split 返回的是子范围string_view-like 对象的视图 auto subranges csv | std::views::split(,); // 将子范围视图转换为字符串向量需要消耗视图 std::vectorstd::string fruits; for (auto subrange : subranges) { // subrange 是一个范围我们需要将其内容构造成字符串 // 一种方法是使用 ranges::to (C23) 或手动构造 fruits.emplace_back(subrange.begin(), subrange.end()); } for (const auto f : fruits) { std::cout f \n; // 输出 apple\nbanana\ncherry\ndate } }split视图的元素本身也是范围这体现了视图的惰性和组合性。在 C23 中std::ranges::to可以方便地将任何范围转换为容器简化了上述操作。4.2 自定义范围适配器虽然标准库提供了丰富的适配器但有时你需要特定的转换逻辑。编写自定义适配器可以让你复用这种逻辑。一个自定义适配器通常是一个函数对象它返回一个满足view概念的类型。最简单的方式是利用现有的适配器进行组合或者实现一个简单的适配器闭包对象。例如创建一个将容器中所有元素加倍的适配器#include ranges // 方法一定义一个返回视图的函数或函数对象 auto double_elements [](auto range) { return std::forwarddecltype(range)(range) | std::views::transform([](auto x) { return x * 2; }); }; // 使用 std::vectorint v {1, 2, 3}; for (int x : double_elements(v)) { /* x 是 2, 4, 6 */ } // 方法二创建一个真正的适配器对象更复杂但更符合库风格 // 这通常涉及定义继承自 std::ranges::range_adaptor_closure 的类对于大多数日常使用方法一Lambda 组合已经足够。只有当你想创建一个像std::views::transform一样可以通过管道操作符|在右侧使用的“左结合”适配器时才需要更复杂的实现继承range_adaptor_closure。4.3 哨兵Sentinel与无限序列哨兵是标记范围结束的对象它的类型可以与迭代器不同。这为表示“特殊结束条件”的范围提供了可能。最常见的例子是 C 风格字符串其结束哨兵是空字符\0而迭代器是char*。在 Ranges 中你可以利用哨兵创建自定义的迭代逻辑。例如创建一个读取输入直到遇到特定值的“范围”#include iostream #include ranges #include iterator class UntilSentinel { char stop_char; public: UntilSentinel(char c) : stop_char(c) {} // 定义相等比较操作判断迭代器是否到达哨兵位置 friend bool operator(std::istream_iteratorchar it, const UntilSentinel s) { return !it.operator bool() || *it s.stop_char; // 流结束或遇到停止字符 } // 对称版本 friend bool operator(const UntilSentinel s, std::istream_iteratorchar it) { return it s; } // C20 起还需要定义 !但通常 就够用了编译器会自动生成 ! }; int main() { // 从标准输入读取字符直到遇到换行符 \n auto input_range std::ranges::subrange( std::istream_iteratorchar(std::cin), UntilSentinel(\n) ); std::cout You entered: ; for (char c : input_range) { std::cout c; } std::cout \n; }这个例子展示了如何通过自定义哨兵来定义一个“范围”其结束条件不是固定的迭代器位置而是一个动态判断。这使得 Ranges 库的表达能力远超传统的迭代器对模型。5. 性能考量、常见陷阱与最佳实践引入任何新特性都需要权衡Ranges 库在带来便利的同时也需要注意其开销和适用场景。5.1 性能与优化惰性求值的优势与代价惰性求值避免了中间存储通常能提升性能并减少内存占用。但是它也可能导致重复计算。如果一个视图被多次遍历且其适配器包含昂贵操作如复杂计算或动态分配每次遍历都会重新计算。对于需要重复访问的结果考虑使用std::ranges::toC23或手动存入std::vector进行物化Materialize。编译时间Ranges 库重度依赖模板和概念可能会增加编译时间。在大型项目中合理组织代码避免在头文件中过度使用复杂的范围管道可能有助于编译速度。内联与优化现代编译器能很好地优化基于范围的管道尤其是当适配器是简单的内联 Lambda 时。最终的汇编代码通常与手写的、优化的循环相当。但对于非常复杂的管道建议进行性能剖析。与手写循环对比对于极其简单的操作如遍历求和手写循环可能略微直接但可读性和可维护性差。Ranges 管道在表达复杂数据流时更具优势且不易出错如避免迭代器失效问题。在绝大多数情况下可读性和正确性的提升远胜于微乎其微的性能差异。5.2 常见陷阱与解决方案悬垂引用Dangling References这是使用视图时最容易犯的错误。视图不拥有数据它只是底层范围的“观察者”。auto get_bad_view() { std::vectorint local_data {1, 2, 3}; return local_data | std::views::filter([](int n) { return n 1; }); // 危险 } // local_data 被销毁返回的视图引用了已释放的内存。 auto view get_bad_view(); // 视图无效使用它导致未定义行为。解决方案确保视图的生命周期不超过其基范围的生命周期。如果函数需要返回一个范围考虑返回容器如std::vector或者返回一个由std::views::iota等工厂生成的、不依赖外部数据的视图。修改基范围导致迭代器失效如果在使用视图的同时修改了底层容器如vector::push_back导致重分配那么视图内部持有的迭代器就会失效继续使用视图是未定义行为。解决方案遵循 STL 的迭代器失效规则。在修改容器后应丢弃旧的视图基于新容器创建新的视图。std::views::filter与元素删除在基于范围的 for 循环中直接通过filter视图的引用删除容器元素是危险的因为这可能使迭代器失效。std::vectorint v {1, 2, 3, 4, 5}; for (int i : v | std::views::filter([](int n) { return n % 2 0; })) { if (i 2) { // 直接删除 v 中的元素 2 是危险的可能使 filter 视图内部的迭代器失效 // auto it std::ranges::find(v, 2); // v.erase(it); // 潜在问题 } }解决方案如果需要删除通常先收集要删除的元素例如索引或迭代器在遍历结束后再统一删除。或者使用std::erase_if算法它专门处理此场景。类型推导与auto范围管道常常返回复杂的嵌套类型。使用auto来接收视图结果是正确且推荐的做法。试图写出完整的类型通常既困难又没必要。// 好 auto result vec | std::views::filter(pred) | std::views::transform(func); // 不好类型极其复杂 std::ranges::transform_viewstd::ranges::filter_view... result ...;5.3 最佳实践总结优先使用范围算法在新的 C20 代码中优先使用std::ranges::命名空间下的算法它们更安全支持投影、概念约束、更简洁。善用管道操作符对于复杂的数据处理流水线使用|操作符组合视图使代码从左到右阅读逻辑清晰。理解视图的惰性明确知道计算何时发生避免对昂贵视图的重复遍历。需要复用的结果及时物化。管理好生命周期时刻警惕视图和其基范围的生命周期关系避免悬垂引用。拥抱auto放心使用auto来推导视图和范围算法的返回类型。结合 C23 新特性如果项目可以使用 C23一定要探索std::ranges::to用于将范围转换为容器和std::views::enumerate用于获取索引等特性它们能极大提升编码体验。从简单开始如果不确定某个复杂管道是否高效可以先用它写出清晰正确的代码然后通过性能剖析工具如 perf, VTune评估热点再进行针对性优化。在大多数情况下清晰度比那一点可能的性能提升更重要。C20 范围库的学习曲线初期可能有些陡峭尤其是理解其惰性求值和组合哲学。但一旦掌握它将成为你处理序列数据的利器让代码变得更加声明式、简洁且富有表现力。它代表了 C 现代泛型编程的一个重要发展方向值得每一位严肃的 C 开发者投入时间学习。