C++20 Ranges: 用范围适配器简化数据处理

在 C++20 中,标准库引入了 Ranges 子系统,彻底改变了我们处理序列数据的方式。相比传统的迭代器 + 算法模式,Ranges 通过范围(range)和范围适配器(range adaptor)让代码更简洁、易读。本文将带你从基础概念讲起,展示如何使用 Ranges 进行常见的数据处理任务,并分享一些实用的技巧。

1. 基础概念回顾

1.1 范围(Range)

范围是一种可以产生一系列值的对象,它至少要满足以下两个要求:

  • begin() 返回一个可前向/后向/随机访问的迭代器;
  • end() 返回一个指向序列末尾的迭代器。

在 C++20 中,任何支持 begin()/end() 的对象都可以被视为范围,包括 STL 容器、C-style 数组、std::initializer_list 等。

1.2 范围适配器(Range Adaptor)

范围适配器是一种函数对象,用来对已有范围进行“变换”或“过滤”。它的工作方式类似于算法,但更像是管道式操作:适配器返回一个新的范围,随后可以再继续链式调用。

常见的适配器包括:

  • std::views::filter:按条件过滤元素。
  • std::views::transform:对元素做映射。
  • std::views::reverse:反转顺序。
  • std::views::take / std::views::drop:截取/跳过前 n 个元素。
  • std::views::unique:去重(需要排序后使用)。

2. 基本使用示例

2.1 过滤奇数

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

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

    auto odds = numbers | std::views::filter([](int n){ return n % 2 == 1; });

    for (int n : odds) {
        std::cout << n << ' ';
    }
    // 输出: 1 3 5 7 9
}

2.2 平方并排序

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

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

    auto squares = nums 
        | std::views::transform([](int n){ return n * n; })
        | std::views::common; // 转为可随机访问范围

    std::sort(squares.begin(), squares.end());

    for (int n : squares) std::cout << n << ' ';
    // 输出: 1 1 4 9 16 25 36 81
}

2.3 取前 5 个偶数的平方和

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

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

    int sum = v 
        | std::views::filter([](int n){ return n % 2 == 0; })
        | std::views::transform([](int n){ return n * n; })
        | std::views::take(5)
        | std::ranges::accumulate(0, std::plus<>());

    std::cout << "sum = " << sum << '\n';
    // 输出: sum = 244 (2^2 + 4^2 + 8^2 + 6^2 + 10^2)
}

3. 进阶技巧

3.1 views::common 与视图类型

大部分视图是惰性求值(lazy)的,迭代器仅在需要时才产生。views::common 将视图包装为常规的容器类型,提供 size()operator[] 等操作,适合需要多次遍历或随机访问的场景。

auto rng = std::views::iota(0, 1000) | std::views::common;
std::cout << rng[500] << '\n'; // 500

3.2 自定义视图

如果标准视图无法满足需求,可以自定义一个视图。最简洁的方式是使用 std::ranges::view_interface。示例:一个生成 Fibonacci 数列的视图。

#include <ranges>

struct fibonacci_view : std::ranges::view_interface <fibonacci_view> {
    struct iterator {
        std::size_t index{};
        std::size_t a{0}, b{1};

        auto& operator++() { std::swap(a, b); b += a; ++index; return *this; }
        auto operator*() const { return a; }
        bool operator==(iterator const&) const { return index == std::numeric_limits<std::size_t>::max(); }
    };

    auto begin() const { return iterator{}; }
    auto end() const { return iterator{std::numeric_limits<std::size_t>::max()}; }
};

int main() {
    auto fibs = fibonacci_view{} | std::views::take(10);
    for (auto n : fibs) std::cout << n << ' ';
    // 输出: 0 1 1 2 3 5 8 13 21 34
}

3.3 视图与 std::move 的配合

在链式调用中,如果你想在中间消耗一次范围(例如求和),可以使用 std::ranges::accumulatestd::ranges::for_each,这些函数会从左到右一次遍历,避免产生临时容器。

auto sum = std::views::iota(1, 1000000) 
          | std::views::transform([](int n){ return n * 2; })
          | std::ranges::accumulate(0, std::plus<>());

4. 与旧版算法对比

任务 传统写法 Ranges 写法
过滤偶数并平方 std::transform + std::copy_if | std::views::filter | std::views::transform
排序 std::sort(v.begin(), v.end()) std::ranges::sort(v)
取前 n 个 `std::vector
res(v.begin(), v.begin()+n)| std::views::take(n)`

Ranges 的优势显而易见:语法简洁、表达意图直观、惰性求值减少不必要的拷贝。

5. 常见陷阱

  1. 视图只能一次遍历
    惰性视图是一次性使用的。若需多次遍历,先转为 std::vector 或使用 views::common

  2. 不支持所有容器
    仅支持满足 begin()/end() 的范围。若想对自定义容器使用,需实现这些成员。

  3. std::views::unique 要求已排序
    若对未排序的范围使用,会得到意外结果。先使用 std::ranges::sort 再去重。

6. 结语

C++20 的 Ranges 子系统为处理序列数据提供了强大且优雅的工具。通过学习并灵活使用视图与适配器,你可以让代码更加声明式、可读性更高,且常常能减少内存占用与运行时间。建议从日常项目中挑选合适的场景,逐步将传统算法迁移到 Ranges,感受其带来的改变。祝你编码愉快!

发表评论