C++20 Range 适配器入门:三板斧 transform, filter, take
一、Range 适配器的基础概念
C++20 引入的 Range 适配器,提供了一种强大而优雅的方式来操作容器和数据流。 它们通过惰性求值和链式操作,将数据处理逻辑与实际执行分离,从而简化代码、提高可读性,并带来潜在的性能优势。
什么是 Range 适配器?
简单来说,Range 适配器是操作数据范围(Range)的工具,它们可以对一个 Range 内的元素进行转换(transform
)、筛选(filter
)、截取(take
)、组合以及执行更复杂的操作,而无需手动编写循环或使用繁琐的中间变量。 关键在于,这些操作是惰性的,即只有在需要结果时才会真正执行计算。
Range 的概念涵盖了多种数据结构,包括:
- 标准容器:
std::vector
,std::list
,std::array
,std::set
,std::map
等 - C 风格数组:
int arr[10]
- 生成器: 可以动态生成元素的自定义类型
- 文件流: 从文件中读取的数据流
Range 适配器的核心作用在于提供一种声明式的数据处理方式,让代码更清晰地表达 做什么,而不是 怎么做。 传统的 C++ 数据处理往往需要以下步骤:
- 定义一个容器或确定要操作的范围。
- 手动编写循环,使用迭代器逐个访问和操作元素。
- 创建临时容器或变量来保存中间结果。
而使用 Range 适配器后,可以将这些步骤简化为一条链式操作语句,极大地提高了代码的可读性和可维护性。 例如:
// 使用 Range 适配器:筛选出偶数并取平方
auto results = input
| std::views::filter([](int x) { return x % 2 == 0; }) // 筛选偶数
| std::views::transform([](int x) { return x * x; }); // 计算平方
Range 适配器的关键特性:
-
惰性求值: Range 适配器并非立即执行操作,而是延迟到真正需要结果时才进行计算。 这使得它们非常适合处理大型数据集、无限序列或复杂的数据转换流程,避免了不必要的计算和内存分配。
-
链式操作: 多个适配器可以通过管道符 (
|
) 进行组合,形成清晰、流畅的操作链。 这种链式调用的风格类似于函数式编程中的流操作,消除了嵌套循环的复杂性,让代码更易于理解和维护。 -
范围抽象: Range 适配器对底层容器的类型和操作方式进行了抽象。 无论是
std::vector
,std::list
, 用户自定义的容器,还是其他满足 Range 概念的数据源,只要能够提供迭代器支持,都可以使用适配器。 -
类型推导与模板支持: Range 适配器与现代 C++ 的类型推导系统紧密结合。 这使得可以编写更简洁、更通用的代码,而无需显式指定复杂的类型。
-
组合性 (Composability) Range 适配器可以与其他 Range 适配器和算法组合使用,以创建更复杂的数据处理流程。 这种组合性使得 Range 适配器非常灵活和强大。
Range 适配器 vs. 传统 STL 算法:
特性 | Range 适配器 | STL 算法 |
---|---|---|
操作方式 | 惰性求值 (Lazy Evaluation) | 立即执行 (Eager Evaluation) |
代码风格 | 声明式、链式调用 | 命令式,通常需要显式迭代器 |
灵活性 | 支持无限序列、更灵活的操作组合、更高层次的抽象 | 主要针对容器,需要手动管理迭代器和范围 |
性能 | 某些场景下由于惰性求值可能更高效,减少中间数据副本 | 在某些场景下可能更直接,避免惰性求值带来的额外开销 |
可读性 | 通常更高,特别是对于复杂的数据处理流程 | 对于简单的操作可能更直接 |
复杂度 | 更高的抽象级别,代码更简洁,易于维护 | 需要手动管理迭代器和范围,代码可能更冗长 |
二、Range 适配器详解
深入transform
、filter
和 take
三个核心的 Range 适配器,并介绍其他常用的适配器,创建自定义适配器,扩展 Range 的功能。
2.1、transform 适配器
transform
适配器用于对 Range 中的每个元素应用一个转换函数,并将转换后的结果生成一个新的 Range。 它可以将一种类型的值映射到另一种类型,非常适合数据预处理、格式转换或计算派生值。
transform
接受一个函数对象(可以是 Lambda 表达式、函数指针、函数对象等)作为参数,该函数对象会被应用到输入 Range 的每个元素上。 返回的 Range 包含转换后的元素。
示例:将一组整数转换成它们的平方。
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
// 使用 transform 将每个数字转换为它的平方
auto squared = numbers | std::views::transform([](int x) { return x * x; });
// 输出结果
for (int x : squared) {
std::cout << x << " "; // 输出:1 4 9 16 25
}
std::cout << std::endl;
return 0;
}
使用场景:
- 数据预处理: 例如,将温度从摄氏度转换为华氏度,将字符串转换为小写/大写,或者对数据进行归一化处理。
- 格式转换: 将日期时间对象格式化为特定的字符串,或者将数据从一种单位转换为另一种单位。
- 计算派生值: 根据现有数据计算新的属性或指标,例如计算每个订单的总金额,或者计算每个用户的平均消费金额。
进阶用法: transform
可以接受多个输入 Range,并使用一个接受多个参数的函数对象进行转换。 这可以用于将多个数据源组合在一起进行处理。
2.2、filter 适配器
filter
适配器用于根据指定的条件筛选 Range 中的元素,只保留满足条件的元素,创建一个新的 Range。 它非常适合数据过滤、数据清洗或条件约束。
filter
接受一个谓词(predicate,即返回 bool
类型的函数对象)作为参数。 该谓词会被应用到输入 Range 的每个元素上。 只有当谓词返回 true
时,该元素才会被包含在输出 Range 中。
示例:筛选出一组整数中的偶数。
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
// 使用 filter 筛选出偶数
auto evens = numbers | std::views::filter([](int x) { return x % 2 == 0; });
// 输出结果
for (int x : evens) {
std::cout << x << " "; // 输出:2 4 6
}
std::cout << std::endl;
return 0;
}
使用场景:
- 数据过滤: 从数据集中筛选出符合特定条件的记录,例如筛选出年龄大于 18 岁的用户,或者筛选出价格高于 100 元的商品。
- 数据清洗: 移除无效或不完整的数据,例如移除空字符串,或者移除重复的记录。
- 条件约束: 限制数据的范围,例如筛选出长度超过 5 的字符串,或者筛选出日期在指定范围内的记录。
2.3、take 适配器
take
适配器用于从 Range 中获取前 N 个元素,创建一个新的 Range。 它的作用相当于截断数据流,只保留前面指定数量的结果。
take
接受一个整数 N
作为参数,表示要获取的元素数量。 返回的 Range 包含输入 Range 的前 N
个元素。 如果输入 Range 的元素数量小于 N
,则返回的 Range 包含输入 Range 的所有元素。
示例:获取前 5 个数字。
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9};
// 使用 take 获取前 5 个元素
auto firstFive = numbers | std::views::take(5);
// 输出结果
for (int x : firstFive) {
std::cout << x << " "; // 输出:1 2 3 4 5
}
std::cout << std::endl;
return 0;
}
使用场景:
- 分页: 从数据流中获取某页的内容,例如从数据库查询结果中获取前 10 条记录,或者从大型文件中读取前 100 行。
- 截断: 从无限数据流中获取固定数量的结果,例如从随机数生成器中获取前 10 个随机数。
- 测试: 从数据集中选取一部分数据用于测试,例如从大型数据集中选取前 1000 条记录用于性能测试。
2.4、其他常用适配器
C++ 标准库提供了丰富的 Range 适配器,一些常用的适配器及其应用场景:
适配器名称 | 功能 | 使用场景 |
---|---|---|
drop | 跳过 Range 中的前 N 个元素。 | 跳过分页数据的起始部分、从中间开始处理数据。 |
reverse | 将 Range 的元素顺序反转。 | 数据倒序处理、逆序排列、创建倒序迭代器。 |
unique | 移除相邻的重复元素。 | 数据去重、数据清理、生成不重复的序列。 |
split | 按分隔符拆分 Range。 | 文本处理、数据拆分、解析 CSV 文件。 |
join | 将嵌套的 Range 展平为一维 Range。 | 数据展平、合并嵌套结构、处理多维数组。 |
zip | 将多个 Range 的元素按索引配对组合。 | 数据对齐、合并多数据流、并行处理多个数据源。 |
enumerate | 为每个元素附加索引(可以使用第三方库实现)。 | 数据索引、日志记录、调试、循环计数。 |
common | 将 Range 转换为 common_range,可以提高某些算法的兼容性。 | 当 Range 的迭代器类型不同时,可以使用 common 将其转换为 common_range,以便与某些算法兼容。 |
2.5、自定义 Range 适配器
C++20 允许创建自定义的 Range 适配器,以满足特定的需求。这极大地扩展了 Range 的功能,并构建高度定制化的数据处理流程。
基本步骤:
- 定义适配器类: 创建一个类,用于封装适配器的逻辑。
- 实现
view_interface
: 让适配器类继承std::ranges::view_interface
,并实现必要的成员函数,如begin()
和end()
。 - 实现迭代器: 创建一个迭代器类,用于遍历适配器生成的 Range。
- 重载
operator|
: 重载管道运算符 (|
),使得可以将自定义适配器与其他 Range 和适配器组合使用。
示例:创建一个将 Range 中的每个元素乘以一个常数的自定义适配器。
#include <iostream>
#include <vector>
#include <ranges>
// 自定义适配器:乘以常数
template <typename Range, typename T>
class multiply_view : public std::ranges::view_interface<multiply_view<Range, T>> {
private:
Range base_; // 底层 Range
T factor_; // 乘数因子
public:
multiply_view(Range base, T factor) : base_(std::move(base)), factor_(factor) {}
auto begin() {
return multiply_iterator<decltype(base_.begin()), T>(base_.begin(), factor_);
}
auto end() {
return multiply_iterator<decltype(base_.end()), T>(base_.end(), factor_);
}
};
// 自定义迭代器
template <typename Iterator, typename T>
class multiply_iterator {
private:
Iterator current_;
T factor_;
public:
using iterator_category = std::input_iterator_tag;
using value_type = decltype(*std::declval<Iterator>() * std::declval<T>());
using difference_type = std::ptrdiff_t;
using pointer = value_type*;
using reference = value_type;
multiply_iterator(Iterator current, T factor) : current_(current), factor_(factor) {}
multiply_iterator& operator++() {
++current_;
return *this;
}
multiply_iterator operator++(int) {
multiply_iterator temp = *this;
++current_;
return temp;
}
reference operator*() const {
return *current_ * factor_;
}
pointer operator->() const {
return &operator*();
}
bool operator==(const multiply_iterator& other) const {
return current_ == other.current_;
}
bool operator!=(const multiply_iterator& other) const {
return !(*this == other);
}
};
// 重载管道运算符
template <typename Range, typename T>
multiply_view(Range&&, T) -> multiply_view<std::views::all_t<Range>, T>;
template <typename Range, typename T>
auto operator|(Range&& range, multiply_view<std::views::all_t<Range>, T> view) {
return multiply_view(std::forward<Range>(range), view.factor_);
}
namespace std::views {
inline constexpr auto multiply = [](auto factor){
return [factor](auto&& range){
return multiply_view(std::forward<decltype(range)>(range), factor);
};
};
}
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
// 使用自定义适配器将每个数字乘以 2
auto multiplied = numbers | std::views::multiply(2);
// 输出结果
for (int x : multiplied) {
std::cout << x << " "; // 输出:2 4 6 8 10
}
std::cout << std::endl;
return 0;
}
关键点:
view_interface
: 这是所有 Range 适配器的基类,提供了默认的实现和接口。- 需要自定义迭代器来遍历适配器生成的 Range。 迭代器需要实现
operator++
、operator*
、operator==
等方法。 - 重载
operator|
使得可以将自定义适配器与其他 Range 和适配器组合使用,从而实现链式调用。
三、组合使用 Range 适配器
C++ 的 Range 适配器支持链式调用,可以将多个操作组合在一起,形成简洁、高效的数据处理管道 (Data Processing Pipeline)。 这种组合方式可以像流水线一样处理数据,每个适配器负责一个特定的步骤,最终得到想要的结果。
示例:
假设有一组整数数据,需求如下:
- 从数据中跳过前 2 个元素。
- 筛选出偶数。
- 将所有偶数翻倍。
- 获取处理后的前 5 个元素。
- 将结果倒序输出。
代码实现:
#include <iostream>
#include <vector>
#include <ranges>
#include <algorithm> // std::copy
#include <iterator> // std::ostream_iterator
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 构建数据处理流水线
auto result = numbers
| std::views::drop(2) // 跳过前 2 个元素
| std::views::filter([](int x) { // 筛选偶数
return x % 2 == 0;
})
| std::views::transform([](int x) { // 将偶数翻倍
return x * 2;
})
| std::views::take(5) // 获取前 5 个处理结果
| std::views::reverse; // 倒序
std::cout << "Processed numbers: ";
// 使用 std::copy 和 ostream_iterator 输出结果
std::copy(result.begin(), result.end(), std::ostream_iterator<int>(std::cout, " "));
std::cout << std::endl; // 输出:20 16 12 8 4
return 0;
}
优势:
-
通过链式调用,代码逻辑清晰且紧凑,无需中间变量或手动管理多个步骤。
-
每个适配器表示独立操作 ,组合后的代码逻辑一目了然,易于理解和维护。
-
Range 适配器是惰性评估的,仅在访问最终结果时执行计算,避免了不必要的中间数据存储和重复计算。
-
不同适配器可以自由组合,以适应各种复杂的业务场景。
四、Range 适配器的注意事项
虽然 Range 适配器提供了强大的数据处理能力和简洁的代码风格,但在实际应用中需要注意一些潜在的性能问题和使用限制。
(1)惰性求值的陷阱:避免过度链式操作。
Range 适配器的核心特性是惰性求值,即操作仅在访问元素时才逐步执行,不会提前计算全部结果。 这种特性可以极大减少不必要的计算和内存开销,尤其是在处理大型数据集或无限序列时。
然而,在某些场景下,过度的链式操作或多次遍历可能会导致多次迭代,影响性能。 例如,重复遍历同一 Range 或在大量数据上多次调用 begin()
和 end()
。
示例:多次遍历同一 Range 导致重复计算。
#include <iostream>
#include <vector>
#include <ranges>
#include <algorithm>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto even_squared = numbers
| std::views::filter([](int x) {
std::cout << "Filtering " << x << std::endl; // 观察 filter 的执行次数
return x % 2 == 0;
})
| std::views::transform([](int x) {
std::cout << "Squaring " << x << std::endl; // 观察 transform 的执行次数
return x * x;
});
// 第一次遍历
std::cout << "First iteration:" << std::endl;
for (int x : even_squared) {
std::cout << x << " ";
}
std::cout << std::endl;
// 第二次遍历
std::cout << "Second iteration:" << std::endl;
for (int x : even_squared) {
std::cout << x << " ";
}
std::cout << std::endl;
return 0;
}
每次遍历 even_squared
时,filter
和 transform
都会重新执行,导致重复计算。 这在数据量较大或转换函数计算成本较高时会显著降低性能。
优化方案:
-
提前将结果存入容器: 在链式调用后,将结果存入容器(如
std::vector
),避免多次迭代。std::vector<int> result(even_squared.begin(), even_squared.end()); // 后续操作直接使用 result for (int x : result) { std::cout << x << " "; }
-
使用
views::cache
(如果支持): 在需要多次访问的场景中,使用views::cache
(某些第三方库提供,如 Range-v3)缓存中间结果。// 注意: views::cache 不是 C++20 标准的一部分,需要 Range-v3 库 #include <range/v3/view/cache.hpp> auto cached_even_squared = numbers | std::views::filter([](int x) { return x % 2 == 0; }) | std::views::transform([](int x) { return x * x; }) | ranges::views::cache; // 使用 ranges::views::cache // 后续操作直接使用 cached_even_squared for (int x : cached_even_squared) { std::cout << x << " "; }
(2)Range 适配器和传统循环各有优缺点,选择合适的工具取决于具体的应用场景。
特点 | Range 适配器 | 传统循环 |
---|---|---|
编写复杂度 | 简洁、链式、易于组合多个操作 | 需要手动编写循环、状态管理,代码冗长 |
可读性 | 高,代码意图清晰直观 | 低,代码意图不明确,容易出错 |
性能 | 延迟求值,避免不必要的中间存储,可能减少内存占用 | 立即求值,可能产生中间数据副本,增加内存占用 |
调试和断点 | 不易逐步调试,难以观察中间状态 | 易于逐步调试,可以方便地观察中间状态 |
适用场景 | 声明式、链式的数据处理流程,对可读性要求高的场景 | 需要细粒度控制和性能调优的场景,对代码执行过程需要精确控制的场景 |
内存管理 | 如果不及时将结果保存到容器,则可能持续占用内存,直到使用完所有数据 | 内存管理更加灵活,可以及时释放不需要的内存 |
建议:
- 使用 Range 适配器的场景: 当需要清晰地表达数据处理流程、减少代码量、提高可读性时,Range 适配器是一个很好的选择。
- 使用传统循环的场景: 当需要对代码进行细粒度控制、进行性能优化、进行调试时,传统循环可能更合适。
(3)C++ 标准支持: Range 视图(std::views
)自 C++20 正式纳入标准库,现代编译器(如 GCC 11+、Clang 14+、MSVC 19.29+)已广泛支持。 但是,并非所有 Range 适配器都包含在 C++20 标准中。
五、总结
Range 适配器的引入为 C++ 提供了一种声明式的数据流处理方式,大大简化了代码编写的复杂度:
-
通过链式调用,多个操作可以自然地组合在一起,让代码更具可读性,减少了手动管理循环变量或中间状态的繁琐操作。
-
采用类似管道的方式处理数据流,能够清晰地表达数据从输入到最终输出的处理逻辑,使代码意图更加直观。
-
Range 适配器的惰性求值特点避免了不必要的中间结果存储,仅在需要时进行计算,从而提高程序性能。
通过 Range 适配器,我们可以用更少的代码、更高的可维护性,完成复杂的数据处理任务,极大地提升开发效率。
后续学习方向:
- 深入理解 Range 的 Concept。
- Range 在并发和并行计算中的应用。
权威资源:
-
Range-v3 库: Range-v3 GitHub Repository