C++20: Ranges、view 和 concepts C++20: Ranges、view 和 concepts C++20: Ranges、view 和 concepts
这是学习 A Tour of C++ by Bjarne Stroustrup Chapter 14 Ranges 的读书笔记。
这里有四个概念
- ranges
- views
- range
- view
它们四个之间的关系是:
<ranges> 头文件
└── std::ranges 命名空间
├── range range 概念
├── view view 概念
├── filter_view 具体类型
└── views 创建 view 的工具集合
├── filter
├── take
└── transform
- ranges 是库/命名空间,views 是其中用于创建 view 的工具集合
- range 表示 “任何能按顺序遍历元素的对象或表达式”
- view 是一种 range,特点是不复制原数据,只是作为一种视图
- filter_view、take_view 这类具体的 view 类型由 views:: 的一些操作得到(下面讲)
ranges 命名空间提供算法
这种遍历是旧式,有两个迭代器:
sort(v.begin(), v.end());
- 旧式写法不会自动越界
- 但是有可能出现
sort(v1.begin(), v2.end())这种起点和终点分属两个容器的情况
新式写法:
ranges::sort(v);
- 这样确保起点和终点属于一个序列
view 是对已有 range 的一种非拥有式观察。
view 描述了对于容器的访问规则。有了 view,在对容器元素进行筛选、转换、取前几个、跳过前几个或反向查看时,无需创建中间容器。
range
vector、list、built-in array、std::string 都是 range。
但是 C 风格字符串不是 range,因为 C 风格字符串只是一个指针。
一种针对 range 的分类方式是看它是否是 view。除了可以按照是否为 view 分类外,还可以按照遍历能力分成:
- input range
- forward range
- bidirectional range
- random access range
一个对象可以同时处在两套分类中,比如:
std::vector<int> v;
它是一个 range,不是 view,是 forward range,也是 bidirectional range 和 random access range。
view
- 很像数据库的视图,也有点像“带筛选规则的引用”
- view 通常只保存“从哪里获取数据”和“按什么规则取”
- 底层 range 销毁后,view 仍然存在,但它指向的数据已经不存在了。之后遍历 view,就像悬空引用一样,是未定义行为
- 由于 view 只是“带筛选规则的引用”,因此 view 的复制很便宜,通常可以按值传递
- 没有一种叫“view”的类型,只有更具体的 filter_view、take_view 等,由 std::ranges::views 的各种操作得到,叙述如下:
std::ranges::views
- 是属于 Ranges 库的内容,完整写法是
std::ranges::views - 以下的
views::操作,返回类型是std::ranges::*_view,比如std::ranges::filter_view、std::ranges::take_view
std::ranges::views::filter
专门用来做筛选的 view。假设有:
vector<int> numbers {1, 2, 3, 4, 5};
然后想从中筛选出偶数,传统写法是写一个判断:
for (int x: numbers) {
if (x % 2 == 0)
cout << x << ' ';
}
但是有了 views::filter 之后:
auto even_numbers =
numbers | views::filter([](int x) {
return x % 2 == 0;
});
在 ranges 的代码中,| 这个符号表示把左边的 range 交给右边的 view 来操作。
- 这里的
even_numbers会被推断为一个 filter_view 类型 - 这里
views::filter需要一个“筛选规则”的 predicate,而 lambda 很适合临时写这种规则
std::ranges::views::take
用来限制一个 range 的元素,让你只看到 range 的前几个元素。假设有:
vector<int> v{10, 20, 30, 40, 50};
如果想获取前三个元素:
int count = 0;
for (int x: v) {
if (count >= 3) {
break;
}
cout << x << ' ';
count++;
}
而有了 views::take 之后:
for (int x: v | views::take(3)) {
cout << x << ' ';
}
generator
generator 是为了“按需产生一个 range”,不需要先有一个 vector 装着元素再用来遍历。
- empty_view:产生 0 个元素
- single_view
{x}:产生 1 个元素,也就是 x - iota_view
{x, y}:按递增规则产生多个元素,产生 x 到 y - 1 - istream_view:从输入流逐个读取元素
views::empty
auto nothing = views::empty<int>; // 根本没有元素的 range
for (int x: nothing) {
cout << x; // 一次也不会执行
}
目的是有时候某分支没有结果,仍可返回一个合法 range。
views::single
auto one = views::single(10);
适用于算法需要接收 range,但是当前只有一个值的情况。
views::iota
auto numbers = views::iota(3, 7);
产生 3、4、5、6。其实就类似 Python 的 range(3, 7)。
只给一个参数时也可产生无限序列,通常接 views::take():
auto numbers = views::iota(3) | views::take(4);
views::istream
auto numbers = views::istream<int>(std::cin);
输入什么数字,就用什么数字生成 range。
pipelining
指的就是前面的 | 操作,把多个 view 操作按顺序连起来。比如:
numbers | views::filter(is_even()) | views::take(3);
其实这样就很像机器学习里面的数据清洗流程,但是在 C++ 当中这都是通过 views 来完成的,因此没有复制原数据。
concepts
concepts 就是用来帮助编译器判断当前这个对象是否适宜做某些操作的。可以类比数学中的集合概念:预先定义好了一些条件,满足这个条件的所有类型构成一个集合,这个集合就是 concept。编译器在编译时要检查的就是:当前这个类型是否属于这个集合;若不属于,则存在非法操作。因此应当把 concept 看作“类型谓词”,而不是对象。
通常来说,我们不需要自定义 concept,因为标准库已经实现了大部分我们会用到的 concepts 了。如果一定要自定义 concept,则使用 concept 关键词,语法类似:
template<class T>
concept HasSize = requires(T x) {
x.size();
};
有三类 concepts:
- 类型 concepts
- 比如是不是整数,能否复制
- 迭代器 concepts
- 能不能向前走,能不能向后走
- range concepts
- 是不是 forward_range
用 concept 约束自定义算法
template<forward_range R>
requires sortable<iterator_t<R>>
void my_sort(R& r) {
// ...
}
这里有两个要求:
- R 必须是 forward_range
- R 的迭代器必须 sortable
这里使用的都是标准库定义好的 concept,没有自定义 concept,因此直接用就可以了。
用 concepts 区分 range 的能力
range
sized_range
input_range
forward_range
bidirectional_range
random_access_range
contiguous_range
这些 concepts 让库可以根据 range 的能力选择或限制算法实现。这里其实就是本笔记一开始说到的“根据遍历能力划分 range”。
用 concepts 描述 iterator 和操作对象
input_iterator // 描述迭代器能做什么
forward_iterator // 描述迭代器能做什么
sortable // 描述元素是否能排序
predicate // 描述函数能否充当谓词
sentinel_for // 描述一个结束标记是否能和迭代器配合