A beginner‘s guide to C++ Ranges and Views

原文

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 只会是输入范围。

输入范围具有不同的优势,这些优势通过更精细的概念来实现(即对更强概念建模的类型,总是也对较弱概念建模):

ConceptDescription
std::ranges::input_rangecan be iterated from beginning to end at least once
std::ranges::forward_rangecan be iterated from beginning to end multiple times
std::ranges::bidirectional_rangeiterator can also move backwards with --
std::ranges::random_access_rangeyou can jump to elements in constant-time []
std::ranges::contiguous_rangeelements are always stored consecutively in memory

这些概念直接从迭代器上的各个概念派生而来,即如果范围的迭代器模拟 std::forward_iterator,则该范围是 std::ranges::forward_range

对于标准库中的知名容器,此矩阵显示了它们建模的概念:

std::forward_liststd::liststd::dequestd::arraystd::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::transformstd::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 模型?

Conceptyes/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 上看到类似的内容。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值