C++ Ranges(范围)是 C++20 中的主要新事物之一,“Views(视图)”是Ranges的重要组成部分。 本文是为刚接触 C++ Ranges 的程序员的简短介绍。
前言
你不需要有任何 C++ 范围预备知识,但应该具有 C++ iterators (迭代器)的基本知识,并且你之前应该听说过 C++ Concepts (概念)。 有关 C++ Concepts 的各种资源,例如 好的Concepts,维基百科(虽然两者都包含稍微过时的语法)。
本文基于我为 SeqAn3 库编写的库文档。 原件可在此处获得。 那边也有关于 C++ 概念的初学者文档。
由于现在没有大型标准库提供 C++ 范围,如果你想尝试其中任何一个,则需要使用 range-v3 库。 如果这样做,你需要将 std::ranges::
前缀替换为 range::
并将任何 std::views::
前缀替换为 range::views::
。
动机
传统上,C++ 标准库中的大多数通用算法,如 std::sort
,都采用一对迭代器(例如,begin()
返回的对象)。 如果要对 std::vector v
进行排序,则必须调用 std::sort(v.begin(), v.end())
而不是 std::sort(v)
。 为什么选择这种带有迭代器的设计? 它更灵活,因为它允许例如:
- 仅对第五个元素之后的所有元素进行排序:
std::sort(v.begin() + 5, v.end())
- 使用非标准迭代器,如反向迭代器(按相反顺序排序):
std::sort(v.rbegin(), v.rend());
- 结合两者(以相反的顺序对除最后 5 个元素之外的所有元素进行排序):
std::sort(v.rbegin() + 5, v.rend());
但是这个接口不如在你希望排序的实体上调用 std::sort
直观,并且它允许更多错误,例如 混合两个不兼容的迭代器。 C++20 引入了 Ranges 的概念并提供了在命名空间 std::ranges::
中接受范围的算法,例如 如果 v
是范围,std::ranges::sort(v)
现在可以工作——向量是范围!
那些表明基于迭代器的方法的优越性的例子呢? 在 C++20 中,你可以执行以下操作:
- 仅对第五个元素之后的所有元素进行排序:
std::ranges::sort(std::views::drop(v, 5));
- 倒序排序:
std::ranges::sort(std::views::reverse(v));
- 结合两者:
std::ranges::sort(std::views::drop(std::views::reverse(v), 5));
稍后我们将讨论 std::views::reverse(v)
的作用,现在了解它返回看起来像容器的东西并且 std::ranges::sort
可以对其进行排序就足够了。 稍后你将看到这种方法比使用迭代器提供了更多的灵活性。
Ranges
Ranges 是“条目集合”或“可迭代的东西”的抽象。 最基本的定义只需要范围上存在 begin()
和 end()
。
范围概念
有多种对范围进行分类的方法,最重要的一种方法是通过其迭代器的功能。
范围通常是输入范围(可以从中读取)、输出范围(可以写入)或两者兼而有之。 例如。 std::vector<int>
两者都是,但 std::vector<int> const
只会是输入范围。
输入范围具有不同的优势,这些优势通过更精细的概念来实现(即对更强概念建模的类型,总是也对较弱概念建模):
Concept | Description |
---|---|
std::ranges::input_range | can be iterated from beginning to end at least once |
std::ranges::forward_range | can be iterated from beginning to end multiple times |
std::ranges::bidirectional_range | iterator can also move backwards with -- |
std::ranges::random_access_range | you can jump to elements in constant-time [] |
std::ranges::contiguous_range | elements are always stored consecutively in memory |
这些概念直接从迭代器上的各个概念派生而来,即如果范围的迭代器模拟 std::forward_iterator
,则该范围是 std::ranges::forward_range
。
对于标准库中的知名容器,此矩阵显示了它们建模的概念:
std::forward_list | std::list | std::deque | std::array | std::vector | |
---|---|---|---|---|---|
std::ranges::input_range | ✅ | ✅ | ✅ | ✅ | ✅ |
std::ranges::forward_range | ✅ | ✅ | ✅ | ✅ | ✅ |
std::ranges::bidirectional_range | ✅ | ✅ | ✅ | ✅ | |
std::ranges::random_access_range | ✅ | ✅ | ✅ | ||
std::ranges::contiguous_range | ✅ | ✅ |
还有一些与输入或输出或上述概念之一无关的范围概念,例如 std::ranges::sized_range
要求范围的大小可由 std::ranges::size()
检索(以恒定时间)。
存储行为
容器是最著名的范围,它们拥有自己的元素。 标准库已经提供了很多容器,见上。
Views (视图)是通常在另一个范围上定义的范围,并通过某种算法或操作转换基础范围。 视图不拥有超出其算法的任何数据,构造、销毁或复制它们所需的时间不应取决于它们所代表的元素数量。 该算法需要进行惰性评估,因此组合多个视图是可行的。 更多关于这个下面。
存储行为与上述迭代器定义的范围概念正交,即你可以拥有一个满足 std::ranges::random_access_range
的容器(例如 std::vector
满足,但 std::list
不满足),并且你可以 有这样做或不这样做的意见。
Views
懒惰评估
视图的一个关键特性是,无论它们应用什么转换,它们都会在你请求元素时执行,而不是在创建视图时执行。
std::vector vec{1, 2, 3, 4, 5, 6};
auto v = std::views::reverse(vec);
这里 v
是一个视图; 创建它既不会改变 vec
,也不会存储任何元素。 构造 v
所需的时间及其在内存中的大小与 vec
的大小无关。
std::vector vec{1, 2, 3, 4, 5, 6};
auto v = std::views::reverse(vec);
std::cout << *v.begin() << '\n';
这将打印“6”,但重要的是将 v
的第一个元素解析为 vec
的最后一个元素是按需发生的。 这保证了视图可以像迭代器一样灵活地使用,但这也意味着如果视图执行昂贵的转换,如果多次请求相同的元素,它将不得不重复执行此操作。
可组合性
你可能想知道我为什么写
auto v = std::views::reverse(vec);
而不是
std::views::reverse v{vec};
那是因为 std::views::reverse
不是视图本身,它是一个 adaptor (适配器),它接受底层 range (范围)(在我们的例子中是vector(向量))并返回向量之上的视图对象。 此视图的确切类型隐藏在 auto
语句后面。 这有一个好处,我们不需要担心视图类型的模板参数,但更重要的是适配器有一个附加功能:它可以与其他适配器链接!
std::vector vec{1, 2, 3, 4, 5, 6};
auto v = vec | std::views::reverse | std::views::drop(2);
std::cout << *v.begin() << '\n';
这将打印什么?
它将打印“4”,因为“4”是去掉前两个后反转字符串的第 0 个元素。
在上面的例子中,向量被“管道” |
(类似于 unix 命令行)进入反向适配器,然后进入丢弃适配器,并返回一个组合视图对象。 管道只是一种提高可读性的不同符号,即 vec | foo | bar(3) | baz(7)
等价于 baz(bar(foo(vec), 3), 7)
。 请注意,访问视图的第 0 个元素仍然是惰性的,在访问时确定它映射到哪个元素。
练习
在 std::vector vec{1, 2, 3, 4, 5, 6} 上创建一个视图; 过滤掉所有奇数并将剩余(偶数)值平方,即
std::vector vec{1, 2, 3, 4, 5, 6};
auto v = vec | // ...?
std::cout << *v.begin() << '\n'; // should print 4
要解决这个问题,你可以使用 std::views::transform
和 std::views::filter
。 两者都将 invocable
作为参数,例如 一个 lambda 表达式。 std::views::transform
在底层范围内的每个元素上应用 lambda,而 std::views::filter
会“移除”那些其 lambda 函数求值为 false
的元素。
std::vector vec{1, 2, 3, 4, 5, 6};
auto v = vec | std::views::filter(
[] (auto const i) { return i % 2 == 0; }
) | std::views::transform(
[] (auto const i) { return i*i; }
);
std::cout << *v.begin() << ‘\n’; // prints 4
View概念
视图是一种特定类型的范围,在 std::ranges::view
概念中被形式化。
视图适配器返回的每个视图都对这个概念进行建模,但是视图对哪些其他范围概念进行了建模?
这取决于基础范围以及视图本身。
除了少数例外,视图不会模拟比它们的底层范围更多/更强的范围概念(除了它们总是一个 std::ranges::view
),并且它们试图尽可能多地保留底层范围的概念。
例如,std::views::reverse
返回的视图对std::ranges::random_access_range
(和较弱的概念)进行建模,如果基础范围也对各自的概念进行建模。
它从不建模 std::ranges::contiguous_range
,因为视图的第三个元素并不位于内存中的第二个元素之后(而是在第二个元素之前)。
有些人可能会感到惊讶,如果基础范围确实如此,许多视图也会对 std::ranges::output_range
建模,即 视图不是只读的:
std::vector vec{1, 2, 3, 4, 5, 6};
auto v = vec | std::views::reverse | std::views::drop(2);
*v.begin() = 42; // now vec == {1, 2, 3, 42, 5, 6 } !!
看看上一个练习 filter + transform(过滤器+转换)的解决方案。 你认为以下哪些概念是 v 模型?
Concept | yes/no? |
---|---|
std::ranges::input_range | ✅ |
std::ranges::forward_range | ✅ |
std::ranges::bidirectional_range | ✅ |
std::ranges::random_access_range | |
std::ranges::contiguous_range | |
std::ranges::view | ✅ |
std::ranges::sized_range | |
std::ranges::output_range |
过滤器不保留随机访问,因此不保留连续性,因为它不“知道”基础范围的哪个元素是恒定时间内的第 i 个元素。 它不能“跳”到那里,它需要逐个元素地穿过底层范围。 这也意味着我们不知道尺寸。
变换视图将能够跳转,因为它总是对每个元素独立执行相同的操作; 并且它也会保留大小,因为大小保持不变。 在任何情况下,由于过滤器,这两个属性都丢失了。 另一方面,转换视图在每次访问时都会产生一个新元素(乘法的结果),因此 v 不是输出范围,你不能为其元素赋值。 这也会阻止对连续范围进行建模——如果它没有被过滤器建立——因为值是按需创建的,根本不存储在内存中。
了解哪些范围概念“拯救”哪些特定视图需要一些练习。 对于 SeqAn3 库,我们尝试详细记录这一点,我希望我们能在 cppreference.com 上看到类似的内容。