C++20 之 Ranges 库
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 |
关键点:在管道的每一步,都没有创建新的容器。只有当你真正遍历结果时,数据才被逐个计算。
2.3 Range 与 View 的关系
简单来说:所有 View 都是 Range,但不是所有 Range 都是 View。std::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 适配器
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::take 和 views::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::zip、views::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 适配器 | filter、transform、take、drop 等,惰性求值零分配 |
| 管道操作符 | | 让数据处理像 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++ 新特性系列完结
更多推荐
所有评论(0)