本章将从上一章关于算法及其局限性的地方继续。范围库中的视图是算法库的强大补充,它允许我们在一系列元素上将多个转换组合成一个延迟的评估视图。阅读本章后,您将了解什么是范围视图,以及如何将它们与标准库中的容器、迭代器和算法结合使用。
具体来说,我们将涵盖以下主要主题:
- 算法的可组合性
- 范围适配器
- 将视图具体化为容器
- 生成、转换和采样范围内的元素
在我们进入 Ranges 库之前,让我们讨论一下为什么它被添加到 C++ 20 中,以及为什么我们想要使用它。
随着将 Ranges 库引入 C++ 20,我们在实现算法时如何从标准库中获益得到了一些重大改进。以下列表显示了新功能:
- 定义迭代器和范围需求的概念现在可以被编译器更好地检查,并在开发过程中提供更多帮助
<algorithm>
头中所有函数的新重载都被刚才提到的概念所约束,并接受范围作为参数,而不是迭代器对- 迭代器头中的受约束迭代器
- 范围视图,使合成算法成为可能
本章将集中讨论最后一项:视图的概念,它允许我们编写算法来避免将数据不必要地复制到拥有的容器中。为了充分理解这一点的重要性,让我们从演示算法库中缺乏可组合性开始。
标准库算法缺少一个基本方面:可组合性。让我们通过查看第 5 章**算法中的最后一个例子来研究这是什么意思,我们在这里对此进行了简要讨论。如果你还记得的话,我们有一节课要用一个特定的考试分数来代表一个特定年份的Student
:
struct Student {
int year_{};
int score_{};
std::string name_{};
// ...
};
如果我们想从一大群第二年的学生中找到最高分,我们可能会在score_
上使用max_element()
,但由于我们只想考虑特定年份的学生,这变得很棘手。通过使用既接受范围又接受投影的新算法(参考第 5 章、算法,我们可能会得出如下结论:
auto get_max_score(const std::vector<Student>& students, int year) {
auto by_year = [=](const auto& s) { return s.year_ == year; };
// The student list needs to be copied in
// order to filter on the year
auto v = std::vector<Student>{};
std::ranges::copy_if(students, std::back_inserter(v), by_year);
auto it = std::ranges::max_element(v, std::less{}, &Student::score_);
return it != v.end() ? it->score_ : 0;
}
下面是如何使用它的一个例子:
auto students = std::vector<Student>{
{3, 120, "Niki"},
{2, 140, "Karo"},
{3, 190, "Sirius"},
{2, 110, "Rani"},
// ...
};
auto score = get_max_score(students, 2);
std::cout << score << '\n';
// Prints 140
get_max_score()
的这种实现很容易理解,但是在使用copy_if()
和std::back_inserter()
时会产生不必要的Student
对象的副本。
你现在可能在想get_max_score()
可以写成一个简单的for-
循环,这可以减轻我们由于copy_if()
带来的额外分配:
auto get_max_score(const std::vector<Student>& students, int year) {
auto max_score = 0;
for (const auto& student : students) {
if (student.year_ == year) {
max_score = std::max(max_score, student.score_);
}
}
return max_score;
}
虽然这在这个小例子中很容易实现,但我们希望能够通过组成小的算法构建块来实现这个算法,而不是使用单个for
循环从头开始实现它。
我们想要的是像使用算法一样可读的语法,但是能够避免为算法中的每一步构建新的容器。这就是范围库中的视图发挥作用的地方。虽然范围库包含的不仅仅是视图,但与算法库的主要区别是能够将本质上是不同类型的迭代器组成一个延迟的求值范围。
如果前面的示例是使用范围库中的视图编写的,那么它会是这样的:
auto max_value(auto&& range) {
const auto it = std::ranges::max_element(range);
return it != range.end() ? *it : 0;
}
auto get_max_score(const std::vector<Student>& students, int year) {
const auto by_year = [=](auto&& s) { return s.year_ == year; };
return max_value(students
| std::views::filter(by_year)
| std::views::transform(&Student::score_));
}
现在我们回到使用算法,因此可以避免可变变量、for
-循环和if
-语句。在我们最初的例子中,额外的向量在特定的一年里吸引了学生,现在已经被消除了。相反,我们构建了一个范围视图,它代表了所有被by_year
谓词过滤的学生,然后转换以仅显示分数。该视图然后被传递给一个小的实用函数max_value()
,该函数使用max_element()
算法来比较所选学生的分数,以便找到最大值。
这种通过将算法链接在一起并同时避免不必要的复制来组合算法的方式促使我们开始使用范围库中的视图。
范围库中的视图是一个范围内的延迟评估迭代。从技术上来说,它们只是带有内置逻辑的迭代器,但是从语法上来说,它们为许多常见的操作提供了非常令人愉快的语法。
以下是如何使用视图对向量中的每个数字进行平方(通过迭代)的示例:
auto numbers = std::vector{1, 2, 3, 4};
auto square = [](auto v) { return v * v; };
auto squared_view = std::views::transform(numbers, square);
for (auto s : squared_view) { // The square lambda is invoked here
std::cout << s << " ";
}
// Output: 1 4 9 16
变量squared_view
不是数值平方的numbers
向量的副本;它是数字的代理对象,只有一个细微的区别——每次你访问一个元素时,都会调用std::transform()
函数。这就是为什么我们说一个视图是延迟求值的。
从外部来看,您仍然可以像任何常规容器一样迭代squared_view
,因此,您可以执行常规算法,如find()
或count()
,但是,在内部,您没有创建另一个容器。
如果要存储范围,可以使用std::ranges::copy()
将视图物化到一个容器中。(这将在本章后面演示。)一旦视图被复制回一个容器,原始的和转换后的容器之间就不再有任何依赖关系。
有了范围,也可以创建一个过滤视图,其中只有一部分范围是可见的。在这种情况下,迭代视图时,只有满足条件的元素才可见:
auto v = std::vector{4, 5, 6, 7, 6, 5, 4};
auto odd_view =
std::views::filter(v, [](auto i){ return (i % 2) == 1; });
for (auto odd_number : odd_view) {
std::cout << odd_number << " ";
}
// Output: 5 7 5
Ranges 库多功能性的另一个例子是它提供了创建一个视图的可能性,该视图可以遍历几个容器,就像它们是一个列表一样:
auto list_of_lists = std::vector<std::vector<int>> {
{1, 2},
{3, 4, 5},
{5},
{4, 3, 2, 1}
};
auto flattened_view = std::views::join(list_of_lists);
for (auto v : flattened_view)
std::cout << v << " ";
// Output: 1 2 3 4 5 5 4 3 2 1
auto max_value = *std::ranges::max_element(flattened_view);
// max_value is 5
现在,我们已经简要地看了一些使用视图的例子,让我们检查所有视图的共同需求和属性
观点的全部力量来自于结合它们的能力。因为它们不复制实际数据,所以您可以在数据集上表达多个操作,而在内部,只能迭代一次。为了理解视图是如何组成的,让我们看看我们最初的例子,但是不使用管道操作符来组成视图;相反,让我们直接构造实际的视图类。以下是它的外观:
auto get_max_score(const std::vector<Student>& s, int year) {
auto by_year = [=](const auto& s) { return s.year_ == year; };
auto v1 = std::ranges::ref_view{s}; // Wrap container in a view
auto v2 = std::ranges::filter_view{v1, by_year};
auto v3 = std::ranges::transform_view{v2, &Student::score_};
auto it = std::ranges::max_element(v3);
return it != v3.end() ? *it : 0;
}
我们首先创建一个std::ranges::ref_view
,它是一个容器周围的薄包装。在我们的例子中,它把向量s
变成了一个廉价复制的视图。我们需要这个,因为我们的下一个视图std::ranges::filter_view
,需要一个视图作为它的第一个参数。如您所见,我们通过引用链中的前一个视图来构建下一个视图。
当然,这个可组合视图链可以任意变长。算法max_element()
不需要了解完整的链;它只需要迭代范围v3
,因为它是一个普通的容器。
下图是 max_element()
算法、视图和输入容器之间关系的简化视图:
图 6.1:顶层算法 std::ranges::max_element(),从视图中提取值,这些视图从底层容器(std::vector)中轻松处理元素
现在,撰写视图的这种风格有点啰嗦,如果我们试图删除中间变量v1
和v2
,我们最终会得到这样的结果:
using namespace std::ranges; // _view classes live in std::ranges
auto scores =
transform_view{filter_view{ref_view{s}, by_year},
&Student::score_};
现在,这可能看起来在语法上并不优雅。通过去掉中间变量,我们得到了即使训练有素的人也很难读懂的东西。我们还被迫从内到外阅读代码来理解依赖关系。幸运的是,范围库为我们提供了范围适配器,这是组合视图的首选方式。
正如您之前看到的,Ranges 库还允许我们使用范围适配器和管道操作符来构建视图,以获得更优雅的语法(您将在第 10 章、代理对象和延迟求值中了解更多关于在自己的代码中使用管道操作符的信息)。前面的代码示例可以通过使用 range adaptor 对象来重写,我们将得到如下内容:
using namespace std::views; // range adaptors live in std::views
auto scores = s | filter(by_year) | transform(&Student::score_);
从左到右而不是从里到外阅读语句的能力使代码更容易阅读。如果您使用过 Unix shell,您可能对链接命令的这种符号很熟悉。
范围库中的每个视图都有一个相应的范围适配器对象,可以与管道操作器一起使用。在使用范围适配器时,我们也可以跳过额外的std::ranges::ref_view
,因为范围适配器直接与viewable_ranges
一起工作,即可以安全转换成view
的范围。
您可以将范围适配器视为一个全局无状态对象,它实现了两个功能:operator()()
和operator|()
。这两个函数都构造和返回视图对象。管道操作符就是前面例子中使用的。但是也可以使用 call 运算符,使用带有括号的嵌套语法来形成视图,如下所示:
using namespace std::views;
auto scores = transform(filter(s, by_year), &Student::score_);
同样,当使用范围适配器时,不需要将输入容器包装在ref_view
中。
总的来说,范围库中的每个视图都包括:
- 对视图对象进行操作的类模板(实际视图类型),例如
std::ranges::transform_view
。这些视图类型可以在名称空间std::ranges
下找到。 - 从范围创建视图类实例的范围适配器对象,例如
std::views::transform
。所有范围适配器都实现了operator()()
和operator|()
,这使得使用管道操作符或通过嵌套进行转换成为可能。范围适配器对象位于名称空间std::views
下。
在前一章中,引入了范围的概念。任何提供函数begin()
和end()
的类型,其中begin()
返回一个迭代器,end()
返回一个哨兵,都有资格作为一个范围。我们得出结论,所有标准容器都是范围。容器拥有自己的元素,因此我们可以称之为拥有范围。
视图也是一个范围,即提供begin()
和end()
功能。但是,与容器不同的是,视图不拥有视图所覆盖范围内的元素。
视图的构造需要是一个恒定时间的操作, O(1) 。它不能执行任何依赖于底层容器大小的工作。这同样适用于分配、复制、移动和析构视图。这使得在使用视图组合多种算法时,很容易对性能进行推理。这也使得视图不可能拥有元素,因为这将需要线性时间复杂性的建设和破坏。
乍一看,视图可能看起来像是输入容器的变异版本。然而,容器根本没有变化:所有的处理都是在迭代器中执行的。视图只是一个代理对象,当迭代时,看起来像一个变异的容器。
这也使得视图可以公开不同于输入元素类型的元素类型。下面的代码片段演示了视图如何将元素类型从int
转换为std::string
:
auto ints = std::list{2, 3, 4, 2, 1};
auto strings = ints
| std::views::transform([](auto i) { return std::to_string(i); });
也许我们有一个在容器上运行的函数,我们希望使用范围算法对其进行转换,然后我们希望将其返回并存储回容器中。例如,在上面的例子中,我们可能希望将字符串实际存储在一个单独的容器中。在下一节中,您将学习如何做到这一点。
有时候,我们想把视图存放在一个容器里,也就是物化视图。所有视图都可以物化到容器中,但这并不像您希望的那样容易。为 C++ 20 提出了一个名为std::ranges::to<T>()
的函数模板,它可以将视图转换成任意的容器类型T
,但并没有成功。希望我们能在未来的 C++ 版本中得到类似的东西。在此之前,我们需要自己做更多的工作来实现观点。
在上例中,我们将ints
转换为std::strings
,如下所示:
auto ints = std::list{2, 3, 4, 2, 1};
auto r = ints
| std::views::transform([](auto i) { return std::to_string(i); });
现在,如果我们想将范围r
具体化为一个向量,我们可以这样使用std::ranges::copy()
:
auto vec = std::vector<std::string>{};
std::ranges::copy(r, std::back_inserter(vec));
物化视图是一个常见的操作,所以如果我们有一个通用的实用程序来处理这种情况会很方便。说我们想把一些任意的视图物化成一个std::vector
;我们可以使用一些通用编程来实现以下方便的实用功能:
auto to_vector(auto&& r) {
std::vector<std::ranges::range_value_t<decltype(r)>> v;
if constexpr(std::ranges::sized_range<decltype(r)>) {
v.reserve(std::ranges::size(r));
}
std::ranges::copy(r, std::back_inserter(v));
return v;
}
这个片段摘自帖木儿·杜姆勒的博文https://Timur . audio/如何从 c20 范围制作容器,非常值得一读。
在本书中,我们还没有过多地讨论泛型编程,但是接下来的几章将解释auto
参数类型和if constexpr
的使用。
我们正在使用reserve()
来优化该功能的性能。它将为该范围内的所有元素预分配足够的空间,以避免进一步分配。但是,如果我们知道范围的大小,我们只能调用reserve()
,因此我们必须在编译时使用if constexpr
语句来检查范围是否是size_range
。
有了这个实用工具,我们可以将某种类型的容器转换成保存另一种任意类型元素的向量。让我们看看如何使用to_vector()
将整数列表转换为std::strings
的向量。这里有一个例子:
auto ints = std::list{2, 3, 4, 2, 1};
auto r = ints
| std::views::transform([](auto i) { return std::to_string(i); });
auto strings = to_vector(r);
// strings is now a std::vector<std::string>
请记住,一旦视图被复制回容器,原始容器和转换后的容器之间就不再有任何依赖关系。这也意味着物化是一个急切的操作,而所有的视图操作都是延迟的。
由视图执行的所有工作都懒洋洋地发生。这与<algorithm>
头中的函数相反,这些函数在被调用时会立即对所有元素执行工作。
你已经看到std::views::filter
视图可以代替std::copy_if()
算法,std::views::transform
视图可以代替std::transform()
算法。当我们使用视图作为构建块并将它们链接在一起时,通过避免急切算法所需的容器元素的不必要的副本,我们从延迟求值中受益。
但是std::sort()
呢?有相应的排序视图吗?答案是否定的,因为它需要视图首先急切地收集所有元素,以便找到要返回的第一个元素。相反,我们必须通过在我们的视图上明确地调用 sort 来实现这一点。在大多数情况下,我们还需要在排序之前物化视图。我们可以用一个例子来澄清这一点。假设我们有一个被谓词过滤的数字向量,如下所示:
auto vec = std::vector{4, 2, 7, 1, 2, 6, 1, 5};
auto is_odd = [](auto i) { return i % 2 == 1; };
auto odd_numbers = vec | std::views::filter(is_odd);
如果我们试图使用std::ranges::sort()
或std::sort()
对视图odd_numbers
进行排序,我们会得到一个编译错误:
std::ranges::sort(odd_numbers); // Doesn't compile
编译器抱怨odd_numbers
范围提供的迭代器类型。排序算法需要随机访问迭代器,但这不是我们视图提供的迭代器类型,即使底层输入容器是std::vector
。我们需要做的是在排序之前物化视图:
auto v = to_vector(odd_numbers);
std::ranges::sort(v);
// v is now 1, 1, 5, 7
但是为什么有这个必要呢?答案是这是延迟评价的结果。当评估需要通过一次读取一个元素来偷懒时,过滤器视图(和许多其他视图)不能保留底层范围的迭代器类型(在本例中为std::vector
)。
那么,有没有可以排序的视图呢?是的,一个例子是std::views::take
,它返回一个范围内的第一个 n 元素。以下示例编译并运行良好,无需在排序前具体化视图:
auto vec = std::vector{4, 2, 7, 1, 2, 6, 1, 5};
auto first_half = vec | std::views::take(vec.size() / 2);
std::ranges::sort(first_half);
// vec is now 1, 2, 4, 7, 2, 6, 1, 5
迭代器的质量得到了保留,因此可以对first_half
视图进行排序。最终结果是基础向量vec
中的前半部分元素已经排序。
您现在已经很好地理解了范围库中的视图是什么以及它们是如何工作的。在下一节中,我们将探讨如何使用标准库中包含的视图。
到目前为止,在本章中,我们已经讨论了范围库中的视图。正如前面所描述的,这些视图类型需要在恒定时间内构建,并且还具有恒定时间复制、移动和赋值操作符。然而,在 C++ 中,在将 Ranges 库添加到 C++ 20 之前,我们已经讨论过视图类。这些视图类是非拥有类型,就像std::ranges::view
一样,但是没有复杂度保证。
在本节中,我们将从探索范围库中与std::ranges::view
概念相关联的视图开始,然后进入std::string_view
和std::span
,它们与std::ranges::view
无关。
Ranges 库中已经有多个视图了,我想在未来的 C++ 版本中我们会看到更多的视图。本节将快速概述一些可用的视图,并根据它们的功能将它们分为不同的类别。
生成视图产生值。它们可以生成有限或无限范围的值。这一类别中最明显的例子是std::views::iota
,它产生的值在半开范围内。以下代码片段打印了值-2
、-1
、0
和1
:
for (auto i : std::views::iota(-2, 2)) {
std::cout << i << ' ';
}
// Prints -2 -1 0 1
通过省略第二个参数,std::views::iota
将根据请求产生无限多个值。
变换视图是变换范围元素或范围本身的结构的视图。一些例子包括:
std::views::transform
:转换每个元素的值和/或类型std::views::reverse
:返回输入范围的反转版本std::views::split
:将一个元素拆开,将每个元素拆分为一个子范围。结果范围是一系列范围std::views::join
:分裂的反义词;展平所有子范围
以下示例使用split
和join
从逗号分隔值的字符串中提取所有数字:
auto csv = std::string{"10,11,12"};
auto digits = csv
| std::views::split(',') // [ [1, 0], [1, 1], [1, 2] ]
| std::views::join; // [ 1, 0, 1, 1, 1, 2 ]
for (auto i : digits) { std::cout << i; }
// Prints 101112
采样视图是选择一个范围内元素子集的视图,例如:
std::views::filter
:只返回满足所提供谓词的元素std::views::take
:返回一个范围的第一个元素 nstd::views::drop
:删除第一个 n 元素后,返回一个范围内的所有剩余元素
你在本章中已经看到了大量使用std::views::filter
的例子;这是一个非常有用的观点。std::views::take
和std::views::drop
都有_while
版本,接受谓词而不是号。下面是一个使用take
和drop_while
的例子:
auto vec = std::vector{1, 2, 3, 4, 5, 4, 3, 2, 1};
auto v = vec
| std::views::drop_while([](auto i) { return i < 5; })
| std::views::take(3);
for (auto i : v) { std::cout << i << " "; }
// Prints 5 4 3
本示例使用drop_while
来丢弃前面小于 5 的值。剩余的元素传递给take
,T1 返回前三个元素。现在进入最后一类范围视图。
您已经在本章中看到了一些实用程序视图的作用。当你有一些你想转换或当作一种观点的东西时,它们就派上用场了。这类视图中的一些示例有ref_view
、all_view
、subrange
、counted
和istream_view
。
下面的示例向您展示了如何读取带有浮点数的文本文件,然后打印它们。
假设我们有一个名为numbers.txt
的文本文件,其中充满了重要的浮点数,如下所示:
1.4142 1.618 2.71828 3.14159 6.283 ...
然后我们可以通过使用std::ranges::istream_view
来创建floats
的视图:
auto ifs = std::ifstream("numbers.txt");
for (auto f : std::ranges::istream_view<float>(ifs)) {
std::cout << f << '\n';
}
ifs.close();
通过创建一个std::ranges::istream_view
并将其传递给一个istream
对象,我们有了一种简洁的方式来处理来自文件或任何其他输入流的数据。
Ranges 库中的视图经过精心选择和设计。在即将发布的标准版本中,很可能会有更多这样的版本。意识到不同类别的视图有助于我们将它们区分开来,并在我们需要时使它们易于找到。
值得注意的是标准库为我们提供了范围库之外的其他视图。在第 4 章、数据结构中介绍的std::string_view
和std::span
都是非自有范围,非常适合与范围视图结合使用。
不能保证这些视图可以在恒定的时间内构建,范围库中的视图就是这种情况。例如,从空终止的 C 风格字符串构造std::string_view
可以调用strlen()
,这是一个 O(n) 操作。
假设,出于某种原因,我们有一个函数可以重置某个范围内的第一个n
值:
auto reset(std::span<int> values, int n) {
for (auto& i : std::ranges::take_view{values, n}) {
i = int{};
}
}
在这种情况下,不需要使用带有values
的范围适配器,因为values
已经是视图。通过使用std::span
,我们可以传递两个内置数组或一个容器,如std::vector
:
int a[]{33, 44, 55, 66, 77};
reset(a, 3);
// a is now [0, 0, 0, 66, 77]
auto v = std::vector{33, 44, 55, 66, 77};
reset(v, 2);
// v is now [0, 0, 55, 66, 77]
类似地,我们可以将std::string_view
与 Ranges 库一起使用。下面的函数将std::string_view
的内容拆分为std::string
元素的std::vector
:
auto split(std::string_view s, char delim) {
const auto to_string = [](auto&& r) -> std::string {
const auto cv = std::ranges::common_view{r};
return {cv.begin(), cv.end()};
};
return to_vector(std::ranges::split_view{s, delim}
| std::views::transform(to_string));
}
λto_string
将一系列char
转化为std::string
。std::string
构造函数需要相同的迭代器和哨兵类型,因此,范围被包装在一个std::ranges::common_view
中。实用程序to_vector()
将视图具体化,并返回一个std::vector<std::string>
。 to_vector()
在本章前面已经定义过了。
我们的split()
功能现在可以同时用于const char*
弦和std::string
物体,如下所示:
const char* c_str = "ABC,DEF,GHI"; // C style string
const auto v1 = split(c_str, ','); // std::vector<std::string>
const auto s = std::string{"ABC,DEF,GHI"};
const auto v2 = split(s, ','); // std::vector<std::string>
assert(v1 == v2); // true
现在,我们将在这一章结束时,稍微讨论一下在 C++ 的未来版本中,我们期望在 Ranges 库中看到什么。
在 C++ 20 中被接受的 Ranges 库是基于 Eric Niebler 创作的一个库,可在https://github.com/ericniebler/range-v3获得。目前,这个库的组件中只有一小部分进入了标准,但很可能很快会添加更多的东西。
除了许多尚未被接受的有用视图,如group_by
、zip
、slice
、unique
之外,还有动作的概念,可以像视图一样管道化。然而,行动不是像视图一样被延迟地评估,而是执行范围的急切突变。排序是一个典型动作的例子。
如果您等不及将这些功能添加到标准库中,我建议您看一看 range-v3 库。
本章介绍了使用范围视图构建算法背后的一些动机。通过使用视图,我们可以使用管道操作符以简洁的语法高效地编写算法。您还了解了类是视图意味着什么,以及如何使用范围适配器将范围转换成视图。
视图没有自己的元素。构建一个范围视图需要一个恒定的时间操作,并且所有视图都是延迟计算的。您已经看到了如何将容器转换成视图,以及如何将视图物化回拥有的容器的例子。
最后,我们简要概述了标准库附带的视图,以及 C++ 中范围的可能未来。
本章是关于容器、迭代器、算法和范围系列的最后一章。我们现在将继续讨论 C++ 中的内存管理。*