C++20 之 Ranges 库

在 C++20 之前,用 STL 算法处理数据就像组装一把 IKEA 家具——你知道最终结果是什么样,但中间的过程(迭代器配对、临时容器、手写 lambda)让你抓狂。比如"从 vector 中筛选出偶数,乘以 2,取前 5 个"——这么简单的需求,传统写法要三四步才能搞定,中间还夹着一堆临时变量。C++20 的 Ranges 库彻底改变了这一切:用一个管道 | 就能串联所有操作,代码像水一样流动。


一、为什么需要 Ranges?

痛点一:迭代器配对太啰嗦

传统 STL 算法要求你传递一对迭代器(begin/end),写起来又臭又长:

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

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

    // 传统写法:排序后去重
    std::sort(nums.begin(), nums.end());
    auto last = std::unique(nums.begin(), nums.end());
    nums.erase(last, nums.end());

    // 每次都要写 .begin() / .end()
    for (auto it = nums.begin(); it != nums.end(); ++it) {
        std::cout << *it << " ";
    }
}

容器和迭代器到处都是 .begin().end(),代码冗余度高,可读性差。

痛点二:临时容器满天飞

想要"筛选 + 转换 + 截取"这种链式操作?传统方式需要创建多个临时容器:

#include <vector>
#include <algorithm>
#include <numeric>

std::vector<int> process(const std::vector<int>& input) {
    // 步骤1:筛选偶数 → 临时容器
    std::vector<int> evens;
    std::copy_if(input.begin(), input.end(), std::back_inserter(evens),
                 [](int x) { return x % 2 == 0; });

    // 步骤2:每个乘以2 → 又一个临时容器
    std::vector<int> doubled(evens.size());
    std::transform(evens.begin(), evens.end(), doubled.begin(),
                   [](int x) { return x * 2; });

    // 步骤3:取前5个 → 再来一个临时容器
    std::vector<int> result(doubled.begin(),
                            doubled.begin() + std::min(5, (int)doubled.size()));
    return result;
}

三个 std::vector,三次内存分配,三次拷贝。需求越复杂,临时容器越多。

痛点三:算法接口不一致

有些算法操作整个容器(如 std::sort),有些需要迭代器对(如 std::find),还有些需要一个值加一个迭代器对(如 std::count)。接口不统一,记忆负担大:

std::sort(vec.begin(), vec.end());          // 迭代器对
auto it = std::find(vec.begin(), vec.end(), 42);  // 迭代器对 + 值
int n = std::count_if(vec.begin(), vec.end(), pred); // 迭代器对 + 谓词

C++11 引入了基于范围的 for 循环解决了遍历的问题,但算法的"范围化"迟迟未到。

C++20 的解法:Ranges 库

C++20 的 Ranges 库一举解决了以上所有问题:

痛点 Ranges 的解法
迭代器配对啰嗦 直接传容器(Range)
临时容器满天飞 View 惰性求值,零分配
接口不统一 统一的 Range 概念
链式操作困难 管道操作符 | 串联

二、Range 和 View 的概念

2.1 什么是 Range?

一个 Range 就是"可以通过 begin()end() 来遍历的东西"。这个定义非常宽泛——标准库容器是 Range,C 数组是 Range,std::initializer_list 也是 Range。

// 以下都是 Range
std::vector<int> vec = {1, 2, 3};       // ✅ vector 是 Range
int arr[] = {4, 5, 6};                  // ✅ C 数组是 Range
auto il = {7, 8, 9};                    // ✅ initializer_list 是 Range
std::string str = "hello";              // ✅ string 是 Range

C++20 用 std::ranges::range 概念(concept)来约束:

// 伪代码:concept 的含义
template<typename T>
concept range = requires(T& t) {
    std::ranges::begin(t);   // 有 begin
    std::ranges::end(t);     // 有 end
};

2.2 什么是 View?

View(视图)是一个轻量级的 Range,它不拥有数据,只是"看待"底层数据的一种方式。View 的核心特性:

特性 说明
轻量 拷贝代价极低(通常只是几个指针/偏移量)
惰性求值 不立即计算,等你真正需要元素时才计算
不拥有数据 底层数据由原始 Range 持有,View 只是"窗口"
可组合 可以用管道 | 串联多个 View

原始数据
vector<int>

View: filter(偶数)

View: transform(×2)

View: take(5)

结果: 5个元素

关键点:在管道的每一步,都没有创建新的容器。只有当你真正遍历结果时,数据才被逐个计算。

2.3 Range 与 View 的关系

«concept»

Range

+begin() : iterator

+end() : sentinel

«ranges::view_interface»

View

+begin() : iterator

+end() : sentinel

+empty() : bool

+size() : size_t

+operator[]() : T&

«vector, list...»

OwningRange

拥有数据

«filter, transform...»

NonOwningView

不拥有数据

简单来说:所有 View 都是 Range,但不是所有 Range 都是 Viewstd::vector<int> 是 Range 但不是 View(因为它拥有数据)。


三、管道操作符 |

Ranges 最大的卖点就是管道操作符 |,让数据处理像 Unix 管道一样流畅:

auto result = data | views::filter(pred)
                  | views::transform(func)
                  | views::take(n);

工作原理

管道操作符的本质是函数调用的语法糖

// 以下两种写法完全等价
auto result = data | views::filter(pred) | views::take(5);

auto result = views::take(views::filter(data, pred), 5);

管道写法的优势:

对比项 函数嵌套写法 管道写法
可读性 从内向外读,逆序 从左到右读,正序
扩展性 每加一层嵌套一层括号 直接追加 |
类比 嵌套回调 Unix 管道

嵌套写法(不推荐)

views::take(

views::transform(

views::filter(

data, pred), func), 5)

管道写法(推荐)

| views::filter(pred)

| views::transform(func)

| views::take(5)

data

filtered

transformed

result


四、常用 Views 适配器

C++20 在 <ranges> 头文件中提供了丰富的 Views 适配器。std::ranges::views(可简写为 rv)命名空间下包含所有标准 View。

4.1 views::filter — 过滤元素

只保留满足条件的元素:

#include <ranges>

auto evens = data | views::filter([](int x) { return x % 2 == 0; });

4.2 views::transform — 转换元素

对每个元素应用一个函数:

auto doubled = data | views::transform([](int x) { return x * 2; });

4.3 views::take — 取前 N 个

只取前 N 个元素:

auto first5 = data | views::take(5);

4.4 views::drop — 跳过前 N 个

跳过前 N 个元素,返回剩余部分:

auto skipFirst5 = data | views::drop(5);

4.5 其他常用适配器速查

适配器 作用 示例
views::all 将 Range 包装为 View views::all(vec)
views::iota 生成递增序列 views::iota(1, 10) → 1~9
views::reverse 反转 data | views::reverse
views::keys 取 map 的 key map | views::keys
views::values 取 map 的 value map | views::values
views::join 展平嵌套 Range nested | views::join
views::split 按分隔符拆分 str | views::split(',')
views::enumerate 带索引遍历 data | views::enumerate(C++23)
views::counted 从迭代器取 N 个 views::counted(it, n)

五、实战示例

示例 1:数据处理管道

从学生成绩列表中:筛选及格学生 → 计算加权分 → 取前 3 名:

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

struct Student {
    std::string name;
    double score;
    double weight;
};

int main() {
    std::vector<Student> students = {
        {"Alice", 85.0, 0.3},
        {"Bob",   55.0, 0.4},
        {"Carol", 92.0, 0.5},
        {"Dave",  48.0, 0.3},
        {"Eve",   78.0, 0.6},
        {"Frank", 95.0, 0.2},
        {"Grace", 60.0, 0.5},
    };

    // 管道操作:筛选及格 → 计算加权分 → 取前3 → 输出名字
    auto top_students = students
        | std::views::filter([](const Student& s) {
            return s.score >= 60.0;  // 只要及格的
        })
        | std::views::transform([](const Student& s) {
            return std::make_pair(s.name, s.score * s.weight);
        })
        | std::views::take(3);  // 取前3个

    std::cout << "=== 加权成绩 Top 3 ===" << std::endl;
    for (const auto& [name, weighted] : top_students) {
        std::cout << name << ": " << weighted << std::endl;
    }

    return 0;
}

输出:

=== 加权成绩 Top 3 ===
Alice: 25.5
Carol: 46
Eve: 46.8

关键点:

  • 整个管道没有创建任何临时容器,每个元素都是逐个经过 filter → transform → take 处理的
  • views::take(3) 确保只处理 3 个元素,后续的 Frank、Grace 根本不会被处理
  • 使用 C++17 结构化绑定 [name, weighted] 解包 pair(这不违反"不混入旧特性作为新特性介绍"的原则,结构化绑定本身已在系列第 1 篇介绍过)

示例 2:字符串处理流水线

views::split 拆分 CSV 数据,然后过滤、转换:

#include <iostream>
#include <string>
#include <ranges>
#include <vector>
#include <sstream>

std::vector<std::string> split_csv(const std::string& csv) {
    std::vector<std::string> result;
    for (const auto& field : csv | std::views::split(',')) {
        result.emplace_back(field.begin(), field.end());
    }
    return result;
}

int main() {
    // 模拟 CSV 数据
    std::string csv_data = "apple,3.5,banana,2.8,cherry,8.0,date,5.5,elderberry,12.0";

    auto fields = split_csv(csv_data);

    // 用 views::iota 生成索引序列,配对 (name, price)
    auto indexed = std::views::iota(0u, static_cast<unsigned>(fields.size()));

    auto prices = indexed
        | std::views::filter([](unsigned i) { return i % 2 == 1; })  // 取奇数索引(价格)
        | std::views::transform([&fields](unsigned i) {
            return std::make_pair(fields[i - 1], std::stod(fields[i]));
        });

    // 筛选价格 > 5 的水果
    auto expensive = prices
        | std::views::filter([](const auto& p) { return p.second > 5.0; });

    std::cout << "=== 价格超过 5 元的水果 ===" << std::endl;
    for (const auto& [name, price] : expensive) {
        std::cout << name << ": " << price << " 元" << std::endl;
    }

    return 0;
}

输出:

=== 价格超过 5 元的水果 ===
cherry: 8 元
elderberry: 12 元

示例 3:生成器式序列 + 数学运算

views::iota 生成斐波那契序列的前 N 项,并用管道做变换:

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

// 自定义 View:生成斐波那契数列
// ⚠️ 这是一个无限序列,直接遍历会无限循环!必须配合 views::take 使用。
struct FibonacciView : std::ranges::view_interface<FibonacciView> {
    struct Iterator {
        using value_type = long long;
        using difference_type = std::ptrdiff_t;
        using iterator_category = std::input_iterator_tag;

        long long a = 0, b = 1;

        const long long& operator*() const { return a; }
        Iterator& operator++() {
            auto temp = a;
            a = b;
            b = temp + b;
            return *this;
        }
        void operator++(int) { ++*this; }
        // operator== 始终返回 false:这是一个无限序列,
        // 配合 views::take 使用时,take 有自己的哨兵来终止遍历
        bool operator==(const Iterator&) const { return false; }
    };

    Iterator begin() { return {}; }
    std::unreachable_sentinel_t end() { return {}; }  // 无限序列的哨兵
};

int main() {
    // 生成前 20 个斐波那契数
    auto fib_numbers = FibonacciView{} | std::views::take(20);

    std::cout << "=== 斐波那契数列 (前20项) ===" << std::endl;
    for (auto num : fib_numbers) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    // 筛选偶数项
    auto even_fib = FibonacciView{}
        | std::views::take(30)
        | std::views::filter([](long long n) { return n % 2 == 0; });

    std::cout << "\n=== 前30项中的偶数 ===" << std::endl;
    for (auto num : even_fib) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    // 计算前 15 个斐波那契数的累加和
    auto fib_first15 = FibonacciView{} | std::views::take(15);
    long long sum = std::accumulate(fib_first15.begin(), fib_first15.end(), 0LL);

    std::cout << "\n=== 前15项之和 ===" << std::endl;
    std::cout << "Sum = " << sum << std::endl;

    return 0;
}

输出:

=== 斐波那契数列 (前20项) ===
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181

=== 前30项中的偶数 ===
0 2 8 34 144 610 2584

=== 前15项之和 ===
Sum = 986

关键点:

  • FibonacciView 实现了 view_interface,自动获得 empty()operator bool() 等接口
  • views::take(20) 将无限序列截断为有限范围,防止无限循环(注意:FibonacciView 是无限序列,直接遍历会无限循环)
  • std::accumulate 可以直接接受 View 的迭代器,证明 View 是一个标准的 Range

六、Range 算法:std::ranges:: 命名空间

C++20 不只提供了 Views,还为所有 STL 算法创建了 std::ranges:: 版本,可以直接传入 Range 而不需要迭代器对:

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

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

    // 传统写法
    // std::sort(nums.begin(), nums.end());

    // Ranges 写法:直接传容器
    std::ranges::sort(nums);

    // ranges 版本支持投影(projection)
    struct Person {
        std::string name;
        int age;
    };

    std::vector<Person> people = {
        {"Alice", 30}, {"Bob", 25}, {"Carol", 35}
    };

    // 按 age 排序——不需要写 lambda 包装
    std::ranges::sort(people, std::ranges::less{}, &Person::age);

    // ranges 版本的 find、count 等
    auto it = std::ranges::find(people, "Bob", &Person::name);
    if (it != people.end()) {
        std::cout << "找到 " << it->name << ", 年龄: " << it->age << std::endl;
    }

    // ranges::all_of / any_of / none_of
    bool all_adults = std::ranges::all_of(people, [](int age) {
        return age >= 18;
    }, &Person::age);

    std::cout << "所有人都是成年人: " << (all_adults ? "是" : "否") << std::endl;

    return 0;
}

输出:

找到 Bob, 年龄: 25
所有人都是成年人: 是

std::ranges::sort 的优势:

对比项 std::sort std::ranges::sort
参数 (begin, end) (range)
自定义比较 sort(begin, end, comp) sort(range, comp)
投影 不支持 sort(range, comp, projection)
返回值 void 指向末尾的迭代器

七、性能对比:传统写法 vs Ranges

Ranges 的惰性求值意味着不会创建中间容器,性能通常优于或等于传统手写方式:

// 传统写法:3次内存分配
std::vector<int> temp1;           // 分配1:filter 结果
std::copy_if(..., std::back_inserter(temp1), pred);

std::vector<int> temp2(temp1.size());  // 分配2:transform 结果
std::transform(..., temp2.begin(), func);

std::vector<int> result(temp2.begin(), temp2.begin() + 5);  // 分配3:take 结果

// Ranges 写法:0次内存分配(惰性求值)
auto result = data
    | std::views::filter(pred)
    | std::views::transform(func)
    | std::views::take(5);
// 只有在遍历时才逐个计算,不创建任何中间容器

⚠️ 注意: 如果你把 View 的结果写入一个新的 std::vector(如 std::vector<int> vec(result.begin(), result.end())),那就会分配内存。View 的优势在于惰性求值——如果不转存,就没有额外分配。


八、注意事项与陷阱

1. View 的生命周期

View 不拥有数据,底层数据必须保持有效

std::vector<int> get_data() {
    return {1, 2, 3, 4, 5};
}

auto bad_view() {
    auto data = get_data();
    return data | std::views::filter([](int x) { return x > 2; });
    // ❌ data 在函数返回后销毁,View 悬空引用!
}

// ✅ 正确做法:返回完整的 Range 或就地消费
void good_usage() {
    auto data = get_data();
    for (auto x : data | std::views::filter([](int x) { return x > 2; })) {
        std::cout << x << " ";  // data 在 for 循环期间有效
    }
}

2. views::all 的行为

当你用 views::all 包装一个容器时,它返回一个非拥有的 View:

std::vector<int> vec = {1, 2, 3};

// views::all 创建一个指向 vec 的 View
auto view = std::views::all(vec);

vec.push_back(4);  // 修改原始容器

// view 看到了修改——因为它只是"窗口"
for (auto x : view) {
    std::cout << x << " ";  // 输出: 1 2 3 4
}

⚠️ 重要: 当你对一个右值临时对象使用 views::all 时,返回的 View 会拥有该对象(通过 move)。但对左值使用时,View 只是引用原始对象。不要将 View 绑定到已经销毁的临时对象上。

3. views::takeviews::drop 的边界安全

std::vector<int> vec = {1, 2, 3};

// take 超过大小时不会报错,只是返回尽可能多的元素
auto t = vec | std::views::take(100);  // 有效,只返回 3 个元素

// drop 超过大小时返回空范围
auto d = vec | std::views::drop(100);  // 有效,返回空范围

// 负数参数:实现定义行为,避免使用
// auto bad = vec | std::views::take(-1);  // ⚠️ 不推荐

4. 不要复制 View

View 设计上就是轻量级的,复制它只是复制指针/偏移量,不会有性能问题。但如果你想明确表示"这个 View 只能用一次",可以使用 std::move

auto pipeline = data
    | std::views::filter(pred)
    | std::views::transform(func);

// 可以多次使用(View 是可重复遍历的)
for (auto x : pipeline) { /* ... */ }
for (auto x : pipeline) { /* ... */ }  // ✅ 没问题

// move 后原变量不再可用(某些 View 是 move-only 的,如 views::common)

5. 不是所有 View 都支持随机访问

大多数 View 只提供输入迭代器前向迭代器,不能用 operator[] 或随机跳跃:

auto filtered = data | std::views::filter(pred);

// ❌ 不支持随机访问
// auto x = filtered[5];

// ✅ 必须用迭代器遍历
auto it = filtered.begin();
std::advance(it, 5);  // O(n) 复杂度

九、编译器支持

C++20 Ranges 需要较新版本的编译器:

编译器 最低版本 备注
GCC 10.0+ 基本支持;12+ 较完善;13+ 推荐
Clang 14.0+ 基本支持;16+ 较完善
MSVC VS 2019 16.10+ 建议用 VS 2022 17.2+

编译命令:

# GCC
g++ -std=c++20 main.cpp -o main

# Clang
clang++ -std=c++20 -stdlib=libc++ main.cpp -o main

# MSVC (VS 2022)
cl /std:c++20 main.cpp

⚠️ 注意: GCC 10 的 Ranges 支持不完整,C++23 新增的适配器(如 views::zipviews::enumerate)要到 GCC 13+ 才可用。建议使用 GCC 13+Clang 16+ 以获得最佳体验。

<ranges> 头文件 vs <algorithm> 中的 ranges

C++20 中 std::ranges::sort 等算法在 <algorithm> 中,而 views::filter 等适配器在 <ranges> 中。实际使用时直接 #include <ranges> 即可(它通常会间接包含所需头文件),但规范写法是按需包含:

#include <ranges>      // views 适配器
#include <algorithm>   // ranges 版本算法(std::ranges::sort 等)

总结

C++20 的 Ranges 库是 STL 自 C++11 以来最重要的升级之一:

特性 说明
Range 概念 统一了"可遍历序列"的抽象
View 适配器 filtertransformtakedrop 等,惰性求值零分配
管道操作符 | 让数据处理像 Unix 管道一样流畅
ranges 算法 std::ranges::sort 等,直接传容器,支持投影
性能 惰性求值避免中间容器,性能不低于手写循环

核心收益:

  • 代码简洁:一个管道 | 替代多步循环
  • 零分配:View 惰性求值,不创建中间容器
  • 可读性高:从左到右的管道操作,逻辑一目了然
  • 类型安全:编译期概念约束,错误信息更友好
  • ⚠️ 编译器要求高:需要 GCC 10+ / Clang 14+ / MSVC 16.10+
  • ⚠️ 学习曲线:需要理解 Range、View、Sentinel 等新概念

一旦你习惯了 Ranges 的管道风格,再回头看传统的迭代器配对写法,就像看完自动挡再开手动挡——能开,但回不去了。


📌 系列完结感言: 这是本系列的最后一篇。从 C++14 的二进制字面量到 C++20 的协程和 Ranges,我们走过了 C++ 近十年的进化之路。每个新特性都在试图解决同一个问题——让 C++ 代码更安全、更简洁、更表达意图。掌握这些新特性,不是为了炫技,而是为了写出让自己和同事都更容易维护的代码。


💬 觉得有帮助? 点赞 👍 + 收藏 ⭐,下次找得到!有问题欢迎评论区交流~

作者:林夕07 | C++ 新特性系列完结

更多推荐