C++ ranges

在 C++ 中,所谓的 “范围(range)”,简单来说就是一段可以按顺序遍历的元素序列。像我们熟悉的 std::vector、原生数组和字符串,都属于一个范围。

C++20 标准引入了全新的 ranges 范围库,主要为我们提供了两项实用功能:一种是使用 std::ranges 范围算法直接处理整个容器,另一种是使用 std::views 范围视图配合管道符 | 把多步操作串联起来。

std::ranges 范围算法

以前我们调用 STL 算法对整个容器做排序或查找时,每次都得手动传入 begin() 和 end() 这对首尾迭代器,写起来略显繁琐。

到了 C++20,<algorithm> 头文件为常用算法都提供了位于 std::ranges 命名空间下的新版本。比如以前学过的 sort() 和 find(),现在我们可以直接写成 std::ranges::sort() 和 std::ranges::find(),调用时直接传入整个容器即可。

语法:

#include <algorithm>

std::ranges::sort(容器);
std::ranges::find(容器, 目标值);

说明:

std::ranges 是一个命名空间,而不是头文件名,这些范围算法依然定义在 <algorithm> 头文件中。它们不仅支持直接传入 std::vector 等标准库容器,也支持直接传入原生数组,返回值的用法与传统算法一致。

示例 1:排序与查找整个容器

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> nums = {8, 3, 7, 1, 5};

    // 直接传入整个容器升序排序
    std::ranges::sort(nums);

    for (int val : nums) {
        std::cout << val << ' ';
    }
    std::cout << '\n';

    // 直接在整个容器中查找 7
    auto it = std::ranges::find(nums, 7);
    if (it != nums.end()) {
        std::cout << "找到元素:" << *it << '\n';
    }
    return 0;
}

运行结果如下。

1 3 5 7 8
找到元素:7

分析:

调用 std::ranges::sort(nums) 和 std::ranges::find(nums, 7) 时,我们直接把整个 nums 传了进去,省去了手动配对 begin() 和 end() 的步骤。对于整个容器的升序排序来说,下面 2 种方式是等价的。

// 方式 1
std::sort(nums.begin(), nums.end());

// 方式 2(推荐)
std::ranges::sort(nums);

对比传统的首尾迭代器写法,方式 2 更加简短直观,也不会出现把两个不同容器的 begin() 和 end() 混传进去的失误。因此在实际开发中,我们更推荐使用方式 2。

std::views 视图与管道组合

除了简化算法调用之外,范围库更强大的功能是位于 <ranges> 头文件中的 std::views 视图。所谓的 “视图”,说白了就是一套 “对数据进行筛选、转换或截取的处理规则”。在实际开发中,常用的范围视图如下表所示。

常用范围视图
视图写法 功能说明
std::views::filter(条件) 按条件筛选,只保留使条件返回 true 的元素
std::views::transform(函数) 按指定规则将每个元素转换为新的计算结果
std::views::take(n) 只取最前面的 n 个元素
std::views::drop(n) 跳过最前面的 n 个元素,保留后面的剩余元素
std::views::reverse 按相反的顺序观察范围内的元素

在使用这些视图时,我们可以通过竖线符号 |(管道符)把容器和多个视图从左到右连接起来。

语法:

#include <ranges>

auto 视图变量 = 容器 | 视图1 | 视图2;

说明:

其中,std::views 是标准库为 std::ranges::views 提供的简短别名。管道符 | 的作用非常直观:把左边的数据交给右边的视图去处理,处理完还可以继续通过下一个 | 往后传,经常配合 Lambda 表达式一起使用。

组合视图时,还有以下 2 个语法细节需要小伙伴们留意:

  • std::views::reverse 不需要传入额外参数,跟在 | 后面时直接写名字即可,不要在末尾加空括号 ()。
  • 管道组合后的结果是一个专门的视图对象,并不是 std::vector,我们统一使用 auto 接收即可。

示例 2:串联筛选、转换与截取

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8};

    auto result = nums
        | std::views::filter([](int n) { return n % 2 == 0; })
        | std::views::transform([](int n) { return n * n; })
        | std::views::take(3);

    for (int val : result) {
        std::cout << val << ' ';
    }
    std::cout << '\n';
    return 0;
}

运行结果如下。

4 16 36

分析:

阅读带有管道符 | 的代码时,我们只需要顺着从上往下的顺序看每一站的作用即可:第 1 站通过 filter 只放行偶数,第 2 站通过 transform 算出平方,第 3 站通过 take(3) 控制只取前 3 个结果。

当我们遍历 result 时,nums 里的数字会依次流过管道。其中 1、3、5 在第 1 站被过滤掉,2、4、6 通过筛选并算出平方值 4、16、36。一旦凑够了 take(3) 要求的 3 个结果,循环就会立刻结束,后面的 7 和 8 根本不需要再处理。

除了按条件筛选和转换之外,如果想要实现类似 “跳过第 1 名,取出第 2、3 名” 的区间截取需求,我们还可以将 reverse、drop 与 take 串联起来。下面我们再来看一个例子。

示例 3:反转并截取区间元素

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> scores = {75, 82, 88, 92, 98};

    // 反转后跳过首元素再取 2 个
    auto sub = scores
        | std::views::reverse
        | std::views::drop(1)
        | std::views::take(2);

    for (int s : sub) {
        std::cout << s << ' ';
    }
    std::cout << '\n';
    return 0;
}

运行结果如下。

92 88

分析:

在这个例子中,scores 原本是按升序排列的 75、82、88、92、98。经过 std::views::reverse 反转后,观察顺序变成了从高到低的 98、92、88、82、75。

接着 std::views::drop(1) 跳过了排在最前面的 98,最后 std::views::take(2) 取出接下来的两个元素。因此屏幕上输出了 92 和 88,而原容器 scores 自身的顺序并没有发生任何改变。

视图的惰性求值与注意事项

在日常使用 std::views 时,有两个重要特点需要小伙伴们重点掌握:

  • 不复制底层数据:和前面学过的 std::span 一样,视图自己并不保存元素副本,它观察的始终是原容器里的那份数据。
  • 惰性求值(按需计算):这就像接好了一套净水过滤管道。当我们写出 auto evenView = nums | ...; 时,程序只是把 “过滤规则” 组装好了,并没有立刻去遍历容器。只有等到后面真正用循环读取视图时,元素才会流过管道,边遍历、边实时算出结果。

示例 4:视图实时观察原容器

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> nums = {1, 2, 3, 4, 5, 6};

    auto evenView = nums
        | std::views::filter([](int n) { return n % 2 == 0; });

    // 遍历视图前修改原容器元素
    nums[1] = 20;

    for (int val : evenView) {
        std::cout << val << ' ';
    }
    std::cout << '\n';
    return 0;
}

运行结果如下。

20 4 6

分析:

定义 evenView 时,nums[1] 的值还是 2。随后我们在遍历前把 nums[1] 修改成了 20,再去遍历 evenView 时,输出结果就直接变成了 20、4、6。这说明视图定义时没有复制数据,而是等到遍历时才实时读取原容器。

此外小伙伴们要注意,定义包含 filter 等规则的视图变量时,直接写 auto 即可,不要习惯性地加上 const。因为 filter 视图在首次遍历时需要在内部记录起始位置,加上 const 会导致遍历时无法更新状态而编译报错。

// 正确方式
auto v1 = nums
    | std::views::filter([](int n) { return n % 2 == 0; });
for (int val : v1) {}

// 错误方式
const auto v2 = nums
    | std::views::filter([](int n) { return n % 2 == 0; });
for (int val : v2) {}

如果我们在实际开发中确实需要把视图筛选后的结果独立保存成一个新的 std::vector,可以在遍历视图时调用 push_back() 逐个存入新容器,比如下面这样:

std::vector<int> evenNums;
for (int val : evenView) {
    evenNums.push_back(val);
}

注意 因为视图本身不保管元素,只负责观察底层的原容器,所以我们千万不要在函数中返回一个绑定在局部 std::vector 上的视图。一旦函数执行结束,局部容器就会被销毁,返回的视图也会随之悬空失效。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号