第6章 范围与视图
# 第6章 范围与视图
自首个C++标准问世以来,处理容器及其他序列中元素的方式,一直是借助迭代器来确定首个元素的位置(起始位置)以及最后一个元素之后的位置(结束位置)。因此,作用于范围的算法通常会接收两个参数,用于处理容器中的所有元素,而容器则会提供诸如begin()
和end()
这类函数来提供这些参数。
C++20引入了一种处理范围的全新方式。它支持将范围和子范围定义并作为单个对象使用,例如将它们作为一个整体当作单个参数传递,而无需分别处理两个迭代器。
这个变化看似简单,但正如你将看到的,它带来了诸多影响。对于调用者和实现者而言,处理算法的方式都发生了巨大改变。为此,C++20提供了多个用于处理范围的新特性和工具:
- 标准算法的新重载或变体,可将范围作为单个参数接收。
- 用于处理范围对象的多种工具:
- 创建范围对象的辅助函数。
- 处理范围对象的辅助函数。
- 处理范围对象的辅助类型。
- 范围概念(Concepts)。
- 轻量级范围,即视图(views),用于引用某个范围(或其一部分),并可对值进行可选的转换。
- 管道(Pipelines),作为一种灵活的方式,用于组合范围和视图的处理流程。
本章将介绍范围和视图的基本内容与特性,后续章节会详细讨论相关细节。
# 6.1 通过示例了解范围和视图
我们通过几个使用范围和视图的示例,来探究它们的作用,并在不过多深入细节的情况下讨论其基本原理。
# 6.1.1 将容器作为范围传递给算法
自1998年发布的首个C++标准(C++98)以来,在处理元素集合时,我们遍历的是半开区间。通过传递范围的起始和结束位置(通常取自容器的begin()
和end()
成员函数),可以指定需要处理哪些元素:
#include <vector>
#include <algorithm>
std::vector<int> coll{25, 42, 2, 0, 122, 5, 7};
std::sort(coll.begin(), coll.end()); // 对集合中的所有元素进行排序
2
3
4
5
C++20引入了范围(range)的概念,它是一个代表值序列的单个对象。任何容器都可以当作这样的范围来使用。
因此,现在可以将容器作为一个整体传递给算法:
#include <vector>
#include <algorithm>
std::vector<int> coll{25, 42, 2, 0, 122, 5, 7};
std::ranges::sort(coll); // 对集合中的所有元素进行排序
2
3
4
5
在此处,我们将向量coll
传递给范围算法sort()
,以对向量中的所有元素进行排序。
自C++20起,大多数标准算法都支持将范围作为单个参数传递。不过,目前还不支持并行算法和数值算法。除了能够将范围作为一个整体仅用一个参数传递外,新算法可能还存在一些细微差别:
- 它们使用迭代器和范围的概念,以确保传递的是有效的迭代器和范围。
- 它们的返回类型可能有所不同。
- 它们可能返回借用迭代器(borrowed iterators),这表明由于传递了临时范围(右值),不存在有效的迭代器。
有关详细信息,请参阅C++20中算法的概述。
# 范围的命名空间
范围库中的新算法(如sort()
)在命名空间std::ranges
中提供。一般而言,C++标准库在特定命名空间中提供了所有处理范围的功能:
- 其中大部分功能在命名空间
std::ranges
中提供。 - 有一些在
std::views
中提供,它是std::ranges::views
的别名。
之所以需要新的命名空间,是因为范围库引入了多个使用相同符号名称的新API。然而,C++20不应破坏现有代码。因此,这些命名空间对于避免与现有API产生歧义及其他冲突是必要的。
有时,很难分辨哪些属于范围库及其命名空间std::ranges
,哪些属于std
命名空间。大致来说,std::ranges
用于处理与范围整体相关的工具。例如:
- 有些概念属于
std
,有些属于std::ranges
。 比如,对于迭代器,我们有概念std::forward_iterator
,而对应的范围概念是std::ranges::forward_range
。 - 有些类型函数属于
std
,有些属于std::ranges
。 例如,我们有类型特征std::iter_value_t
,而对应的范围类型特征是std::ranges::range_value_t
。 - 有些符号甚至在
std
和std::ranges
中都有提供。 例如,我们有独立函数std::begin()
和std::ranges::begin()
。
如果两者都可用,最好使用命名空间std::ranges
中的符号和工具。原因在于范围库中的新工具可能修复了旧工具存在的缺陷。例如,最好使用命名空间std::ranges
中的begin()
或cbegin()
。
为命名空间std::ranges
引入一个快捷方式是很常见的做法,比如rg
或rng
。因此,上述代码也可以写成如下形式:
#include <vector>
#include <algorithm>
namespace rg = std::ranges; // 为std::ranges定义快捷方式
std::vector<int> coll{25, 42, 2, 0, 122, 5, 7};
rg::sort(coll); // 对集合中的所有元素进行排序
2
3
4
5
6
不要使用using namespace
指令,以避免完全不指定范围符号的命名空间。否则,很容易导致代码出现编译时冲突,或者进行错误的查找,进而产生难以发现的错误。
# 范围的头文件
范围库的许多新特性在新头文件<ranges>
中提供。不过,其中一些特性在现有头文件中也有提供。范围算法就是一个例子,它在<algorithm>
中声明。
因此,要使用将范围作为单个参数的算法,仍然只需包含<algorithm>
头文件。
然而,对于范围库提供的一些其他特性,则需要<ranges>
头文件。出于这个原因,当使用命名空间std::ranges
或std::views
中的内容时,应该始终包含<ranges>
头文件:
#include <vector>
#include <algorithm>
#include <ranges> // 用于范围工具(目前还不需要)
std::vector<int> coll{25, 42, 2, 0, 122, 5, 7};
std::ranges::sort(coll); // 对集合中的所有元素进行排序
2
3
4
5
6
# 6.1.2 范围的约束和工具
针对范围的新的标准算法将范围参数声明为模板参数(没有可用于它们的通用类型)。为了在处理这些范围参数时指定并验证必要的要求,C++20引入了多个范围概念。此外,还有一些工具可帮助应用这些概念。
例如,考虑针对范围的sort()
算法。原则上,它的定义如下(省略了一些细节,稍后会介绍):
template<std::ranges::random_access_range R, typename Comp = std::ranges::less>
requires std::sortable<std::ranges::iterator_t<R>, Comp>
... sort(R&& r, Comp comp = {});
2
3
这个声明已经体现了范围的多个新特性:
- 两个标准概念指定了对传递的范围
R
的要求:- 概念
std::ranges::random_access_range
要求R
是一个提供随机访问迭代器(可以用来在元素之间前后跳转并计算元素间距离的迭代器)的范围。该概念包含(涵盖)了范围的基本概念:std::range
,它要求对于传递的参数,可以从begin()
到end()
遍历元素(至少可以使用std::ranges::begin()
和std::ranges::end()
,这意味着原始数组也满足这个概念)。 - 概念
std::sortable
要求范围R
中的元素可以使用排序准则Comp
进行排序。
- 概念
- 新的类型工具
std::ranges::iterator_t
用于将迭代器类型传递给std::sortable
。 Comp std::ranges::less
被用作默认的比较准则。它定义了排序算法使用<
运算符对元素进行排序。std::ranges::less
类似于一个受概念约束的std::less
。它确保支持所有比较运算符(==
、!=
、<
、<=
、>
和>=
),并且这些值具有全序关系。
这意味着可以传递任何具有随机访问迭代器且元素可排序的范围。例如,在ranges/rangessort.cpp
文件中:
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
void print(const auto& coll) {
for (const auto& elem : coll) {
std::cout << elem << " ";
}
std::cout << "\n";
}
int main() {
std::vector<std::string> coll{ "Rio ", "Tokyo ", "New York ", "Berlin "};
std::ranges::sort(coll);
std::ranges::sort(coll[0]);
print(coll);
int arr[] = {42, 0, 8, 15, 7};
std::ranges::sort(arr);
print(arr);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
该程序的输出如下:
Berlin New York Rio Tokyo
0 7 8 15 42
2
如果传递的容器/范围没有随机访问迭代器,会得到一条错误消息,指出概念std::ranges::random_access_range
未得到满足:
std::list<std::string> coll2{ "New York ", "Rio ", "Tokyo "};
std::ranges::sort(coll2); // 错误:概念random_access_range未得到满足
2
如果传递的容器/范围中的元素不能使用<
运算符进行比较,则std::sortable
未得到满足:
std::vector<std::complex<double>> coll3;
std::ranges::sort(coll3); // 错误:概念sortable未得到满足
2
# 范围概念
“基本范围概念”表列出了定义范围要求的基本概念。
概念(在std::ranges 中) | 要求 |
---|---|
range output_range input_range forward_range bidirectional_range random_access_range contiguous_range sized_range | 可从起始迭代到结束 可写入值的元素范围 可读取元素值的范围 可对元素进行多次迭代的范围 可对元素进行正向和反向迭代的范围 支持随机访问(在元素间前后跳跃)的范围 所有元素都在连续内存中的范围 可在常量时间内获取大小( size() )的范围 |
表6.1 基本范围概念
请注意以下几点:
std::ranges::range
是所有其他范围概念的基础概念(所有其他概念都包含这个概念)。output_range
以及input_range
、forward_range
、bidirectional_range
、random_access_range
和contiguous_range
的层次结构与相应的迭代器类别相对应,并构建了相应的包含层次结构。std::ranges::contiguous_range
是一种新的范围/迭代器类别,它保证元素存储在连续内存中。代码可以利用这一特性,使用原始指针来迭代元素。std::ranges::sized_range
除了是一个范围之外,与其他约束无关。
注意,迭代器和相应的范围类别在C++20中有一些细微变化。实际上,C++标准现在支持两种版本的迭代器类别:C++20之前的版本和C++20及之后的版本,这两个版本可能并不相同。
“其他范围概念”表列出了一些稍后会介绍的针对特殊情况的其他范围概念。
概念(在std::ranges 中) | 要求 |
---|---|
view viewable_range borrowed_range common_range | 可廉价复制、移动和赋值的范围 可转换为视图(使用 std::ranges::all() )的范围迭代器不依赖于范围生命周期的范围 起始( begin )和结束(sentinel )具有相同类型的范围 |
表6.2 其他范围概念
有关详细信息,请参阅范围概念的讨论。
# 6.1.3 视图
为了处理范围,C++20还引入了视图(Views)。视图是轻量级的范围,创建和复制/移动的成本很低。视图可以:
- 引用范围和子范围。
- 持有临时范围。
- 过滤元素。
- 生成元素的转换后的值。
- 自身生成一个值序列。
视图通常用于临时处理底层范围的部分元素,和/或处理经过可选转换后的元素值。例如,你可以使用视图仅迭代范围的前五个元素,如下所示:
for (const auto& elem : std::views::take(coll, 5)) {
...
}
2
3
std::views::take()
是一个范围适配器,用于创建一个对传递的范围coll
进行操作的视图。在这种情况下,take()
创建一个视图,该视图指向传递范围的前n
个元素(如果有的话)。所以,使用std::views::take(coll, 5)
,我们传递一个指向coll
的视图,该视图在第六个元素(或者如果元素数量少于六个,则在最后一个元素)处结束。
C++标准库在std::views
命名空间中提供了几个范围适配器和用于创建视图的工厂函数。创建的视图提供了范围的常用API,因此可以使用begin()
、end()
和operator++
来迭代元素,使用operator*
来处理元素的值。
# 范围和视图的管道
有一种替代语法可用于调用对作为单个参数传递的范围进行操作的范围适配器:
for (const auto& elem : coll | std::views::take(5)) {
...
}
2
3
这两种形式是等效的。然而,管道语法使得在一个范围上创建一系列视图更加方便,我们稍后将详细讨论这一点。通过这种方式,简单的视图可以用作更复杂的元素集合处理的构建块。
假设你想使用以下三个视图:
// 包含coll中是3的倍数的元素的视图:
std::views::filter(coll, [] (auto elem) {
return elem % 3 == 0; })
// 包含coll中元素平方值的视图:
std::views::transform(coll, [] (auto elem) {
return elem * elem; })
// 包含coll中前三个元素的视图:
std::views::take(coll, 3)
2
3
4
5
6
7
8
因为视图是一个范围,所以你可以将一个视图作为另一个视图的参数:
// 包含coll中是3的倍数的元素的前三个平方值的视图:
auto v = std::views::take(
std::views::transform(
std::views::filter(coll,
[] (auto elem) { return elem % 3 == 0; }),
[] (auto elem) { return elem * elem; }), 3);
2
3
4
5
6
这种嵌套方式难以阅读和维护。然而,在这里,我们可以从替代的管道语法中受益,让一个视图对一个范围进行操作。通过使用operator |
,我们可以创建视图管道:
// 包含coll中是3的倍数的元素的前三个平方值的视图:
auto v = coll
| std::views::filter([] (auto elem) { return elem % 3 == 0; })
| std::views::transform([] (auto elem) { return elem * elem; })
| std::views::take(3);
2
3
4
5
这个范围和视图的管道很容易定义和理解。对于像std::vector coll{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13};
这样的集合,输出将是:
9 36 81
下面是另一个完整的程序,展示了使用管道语法的视图的组合性:
// ranges/viewspipe.cpp
#include <iostream>
#include <vector>
#include <map>
#include <ranges>
int main() {
namespace vws = std::views;
// 作曲家映射(将他们的名字映射到出生年份):
std::map<std::string, int> composers{
{ "Bach " , 1685},
{ "Mozart " , 1756},
{ "Beethoven " , 1770},
{ "Tchaikovsky " , 1840},
{ "Chopin " , 1810},
{ "Vivaldi " , 1678},
};
// 迭代自1700年以来出生的前三位作曲家的名字:
for (const auto& elem : composers
| vws::filter([](const auto& y) { // 自1700年起
return y .second >= 1700; })
| vws::take(3) // 前三位
| vws::keys // 仅键/名字
) {
std::cout << "- " << elem << "\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
在这个例子中,我们对一个作曲家的映射应用了几个视图,其中元素包含他们的名字和出生年份(注意,我们引入vws
作为std::views
的缩写):
std::map<std::string, int> composers{ ... };
...
composers
| vws::filter( ... )
| vws::take(3)
| vws::keys
2
3
4
5
6
组合管道直接传递给基于范围的for
循环,并产生以下输出(请记住,映射中的元素是根据它们的键/名字进行排序的):
- Beethoven
- Chopin
- Mozart
2
3
# 生成视图
视图也可以自身生成一个值序列。例如,通过使用iota
视图,我们可以迭代从1到10的所有值,如下所示:
for (int val : std::views::iota(1, 11)) { // 从1迭代到10
...
}
2
3
# 视图的类型和生命周期
你可以在使用视图之前创建它们并给它们命名:
auto v1 = std::views::take(coll, 5);
auto v2 = coll | std::views::take(5);
...
for (int val : v1) { }
...
std::ranges::sort(v2);
2
3
4
5
6
然而,大多数针对左值(有名称的范围)的视图具有引用语义。因此,你必须确保视图所引用的范围和迭代器仍然存在且有效。
你应该使用auto
来声明视图,因为指定确切的类型可能会很棘手。例如,在这种情况下,对于类型为std::vector<int>
的coll
,v1
和v2
都具有以下类型:
std::ranges::take_view<std::ranges::ref_view<std::vector<int>>>
在内部,适配器和构造函数可能会创建嵌套的视图类型,例如std::ranges::take_view
或std::ranges::iota_view
,它们引用一个std::ranges::ref_view
,该ref_view
用于引用传递的外部容器的元素。
你也可以直接声明并初始化实际的视图。然而,通常你应该使用提供的适配器和工厂函数来创建和初始化视图。使用适配器和工厂函数通常更好,因为它们更易于使用,往往更智能,并且可能提供优化。例如,如果传递的范围已经是std::string_view
,take()
可能只会生成一个std::string_view
。
你现在可能想知道所有这些视图类型是否会在代码大小和运行时间上产生显著的开销。请注意,视图类型仅使用小的或简单的内联函数,因此,至少在通常情况下,优化编译器可以避免显著的开销。
# 用于写入的视图
针对左值的视图通常具有引用语义。这意味着,原则上,视图既可以用于读取,也可以用于写入。
例如,我们可以如下方式仅对coll
的前五个元素进行排序:
std::ranges::sort(std::views::take(coll, 5)); // 对coll的前五个元素进行排序
// 或者如下方式:
std::ranges::sort(coll | std::views::take(5)); // 对coll的前五个元素进行排序
2
3
这通常意味着:
- 如果引用范围的元素被修改,视图的元素也会被修改。
- 如果视图的元素被修改,引用范围的元素也会被修改。
# 延迟求值
除了具有引用语义外,视图还使用延迟求值。这意味着视图在迭代器调用begin()
、++
或请求元素值时,才会对下一个元素进行处理:
auto v = coll | std::views::take(5); // 既不会定位到第一个元素,也不会获取其值
...
auto pos = v.begin(); // 定位到第一个元素
...
std::cout << *pos; // 获取其值
...
++pos; // 定位到下一个元素
...
std::cout << *pos; // 获取其值
2
3
4
5
6
7
8
9
# 缓存
此外,一些视图会使用缓存。如果使用begin()
方法定位到视图的第一个元素需要进行一些计算(比如我们需要跳过前面的元素时),视图可能会缓存begin()
的返回值,这样下次调用begin()
时,就无需再次计算第一个元素的位置。
然而,这种优化会带来一些显著的影响:
- 当视图是
const
类型时,可能无法对其进行迭代。 - 即使不进行任何修改,并发迭代也可能导致未定义行为。
- 提前的读取访问可能会使后续对视图元素的迭代无效或发生改变。
我们稍后会详细讨论这些内容。目前需要注意的是,当发生修改时,标准视图可能会产生问题:
- 在底层范围中插入或删除元素可能会对视图的功能产生重大影响。在进行这样的修改后,视图的行为可能会有所不同,甚至可能不再有效。
因此,强烈建议在需要使用视图时才创建视图,即临时创建视图使用。如果在初始化视图和使用视图之间发生了修改,就需要格外小心。
# 6.1.4 哨兵
为了处理范围,我们必须引入一个新术语——哨兵(sentinel),它表示范围的结束。
在编程中,哨兵是一个特殊的值,用于标记结束或终止。典型的例子有:
- 空字符
'\0'
作为字符序列的结束(例如在字符串字面量中使用)。 nullptr
标记链表的结束。-1
标记非负整数列表的结束。
在范围库中,哨兵定义了范围的结束。在传统的STL(标准模板库,Standard Template Library)方法中,哨兵就是结束迭代器,它通常与遍历集合的迭代器类型相同。然而,在C++20的范围库中,不再要求它们具有相同的类型。
要求结束迭代器与定义范围开始的迭代器以及用于遍历元素的迭代器类型相同存在一些缺点。创建结束迭代器可能成本很高,甚至可能无法实现:
- 如果我们想将C字符串或字符串字面量作为一个范围,首先必须通过遍历字符直到找到
'\0'
来计算结束迭代器。因此,在将字符串作为范围使用之前,我们已经进行了一次迭代。然后处理所有字符时还需要进行第二次迭代。 - 一般来说,如果我们用某个特定值定义范围的结束,这个原则都适用。如果需要一个结束迭代器来处理范围,我们首先必须遍历整个范围来找到它的结束位置。
- 有时,进行两次迭代(一次找到结束位置,然后一次处理范围中的元素)是不可行的。这适用于使用纯输入迭代器的范围,比如将输入流作为范围进行读取时。为了计算输入的结束(可能是文件结束符EOF),我们已经必须读取输入。再次读取输入要么不可行,要么会得到不同的值。
将结束迭代器泛化为哨兵解决了这个难题。C++20的范围库支持不同类型的哨兵(结束迭代器)。它们可以表示“直到'\0'
”“直到EOF”或直到任何其他值。它们甚至可以表示“没有结束”来定义无限范围,以及“嘿,迭代器,你自己检查是否到了结束位置”。
需要注意的是,在C++20之前,我们也可以有这样的哨兵,但它们被要求与迭代器类型相同。一个例子是输入流迭代器:类型为std::istream_iterator<>
的默认构造迭代器用于创建流结束迭代器,这样你就可以使用算法处理来自流的输入,直到文件结束或发生错误:
// 打印从标准输入读取的所有整数值: | |
---|---|
std::for_each(std::istream_iterator<int>{std::cin}, | // 从cin读取整数 |
std::istream_iterator<int>{}, | // 结束是一个文件结束迭代器 |
[] (int val) {<br/>std::cout << val << '\n'; }); |
通过放宽哨兵(结束迭代器)必须与迭代迭代器类型相同的要求,我们获得了一些好处:
- 我们可以在开始处理之前无需先找到结束位置。我们可以在迭代的同时处理值并找到结束位置。
- 对于结束迭代器,我们可以使用禁止导致未定义行为的操作(比如调用
*
运算符,因为在结束位置没有值)的类型。当我们尝试解引用结束迭代器时,可以利用这个特性在编译时发出错误信号。 - 定义结束迭代器变得更加容易。
让我们看一个简单的例子,使用不同类型的哨兵来遍历“范围”,其中迭代器的类型不同:
// ranges/sentinel1.cpp
#include <iostream>
#include <compare>
#include <algorithm> //for for_each()
struct NullTerm {
bool operator==(auto pos) const {
return *pos == '\0'; // 结束位置是迭代器指向'\0'的地方
}
};
int main() {
const char* rawString = "hello world";
// 遍历rawString的起始位置到结束位置的范围:
for (auto pos = rawString; pos != NullTerm{}; ++pos) {
std::cout << ' ' << *pos;
}
std::cout << '\n';
// 使用迭代器和哨兵调用范围算法:
std::ranges::for_each(rawString, // 范围的起始
NullTerm{}, // 结束是null终止符
[](char c) {
std::cout << ' ' << c;
});
std::cout << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
该程序的输出如下:
h e l l o w o r l d
h e l l o w o r l d
2
在程序中,我们首先定义了一个结束迭代器,将结束位置定义为值等于'\0'
:
struct NullTerm {
bool operator==(auto pos) const {
return *pos == '\0'; // 结束位置是迭代器指向'\0'的地方
}
};
2
3
4
5
注意,我们在这里结合了C++20的一些其他新特性:
- 定义成员函数
operator==()
时,我们将auto
用作参数类型。这样我们使成员函数具有通用性,以便operator ==
可以与任意类型的对象pos
进行比较(前提是将pos
所指向的值与'\0'
进行比较是有效的)。 - 尽管算法通常使用
!=
运算符来比较迭代器和哨兵,但我们只将operator ==
定义为通用成员函数。在这里,我们受益于C++20现在可以将operator !=
映射到operator ==
,且操作数顺序任意。
# 直接使用哨兵
然后,我们使用一个基本循环遍历字符串rawString
的字符“范围”:
for (auto pos = rawString; pos != NullTerm{}; ++pos) {
std::cout << ' ' << *pos;
}
2
3
我们将pos
初始化为一个遍历字符的迭代器,并使用*pos
打印出它们的值。只要与NullTerm{}
的比较结果表明pos
的值不等于'\0'
,循环就会继续。这样,NullTerm{}
就充当了哨兵。它与pos
的类型不同,但支持与pos
进行比较,以便检查pos
当前指向的值。
在这里,你可以看到哨兵是如何对结束迭代器进行泛化的。它们的类型可能与遍历元素的迭代器不同,但支持与迭代器进行比较,以决定我们是否到达了范围的末尾。
# 将哨兵传递给算法
C++20为算法提供了重载,不再要求起始迭代器和哨兵(结束迭代器)具有相同的类型。然而,这些重载是在命名空间std::ranges
中提供的:
std::ranges::for_each(rawString, // 范围的起始
NullTerm{}, // 结束是null终止符
... );
2
3
命名空间std
中的算法仍然要求起始迭代器和结束迭代器具有相同的类型,不能以这种方式使用:
std::for_each(rawString, NullTerm{}, // 错误:起始和结束迭代器类型不同
... );
2
如果你有两个不同类型的迭代器,std::common_iterator
提供了一种使它们适用于传统算法的方法。这可能很有用,因为数值算法、并行算法和容器仍然要求起始和结束迭代器具有相同的类型。
# 6.1.5 带哨兵和计数的范围定义
范围不仅仅可以是容器或一对迭代器。范围可以通过以下方式定义:
- 相同类型的起始迭代器和结束迭代器。
- 起始迭代器和哨兵(可能是不同类型的结束标记)。
- 起始迭代器和计数。
- 数组。
范围库支持所有这些范围的定义和使用。
首先,算法的实现方式使得范围可以是数组。例如:
int rawArray[] = {8, 6, 42, 1, 77};
// ...
std::ranges::sort(rawArray); // 对原始数组中的元素进行排序
2
3
此外,有几个实用工具用于定义由迭代器和哨兵或计数定义的范围,以下小节将介绍这些内容。
# 子范围
为了定义由迭代器和哨兵组成的范围,范围库提供了类型std::ranges::subrange<>
。让我们看一个使用子范围的简单例子:
// ranges/sentinel2.cpp
#include <iostream>
#include <compare>
#include <algorithm> //for for_each()
struct NullTerm {
bool operator==(auto pos) const {
return *pos == '\0'; // 结束位置是迭代器指向'\0'的地方
}
};
int main() {
const char* rawString = "hello world";
// 定义一个原始字符串和null终止符的范围:
std::ranges::subrange rawStringRange{rawString, NullTerm{}};
// 在算法中使用该范围:
std::ranges::for_each(rawStringRange,
[](char c) {
std::cout << ' ' << c;
});
std::cout << '\n';
// 基于范围的for循环也支持迭代器/哨兵:
for (char c : rawStringRange) {
std::cout << ' ' << c;
}
std::cout << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
作为哨兵的一个示例,我们定义NullTerm
类型作为检查字符串的null终止符以确定范围结束的哨兵类型。
通过使用std::ranges::subrange
,程序定义了一个范围对象,该对象表示字符串的起始位置和作为结束的哨兵:
std::ranges::subrange rawStringRange{rawString, NullTerm{}};
子范围是一种通用类型,可以用于将由迭代器和哨兵定义的范围转换为一个表示该范围的单个对象。实际上,这个范围甚至是一个视图,在内部它只存储迭代器和哨兵。这意味着子范围具有引用语义,并且复制成本很低。
作为一个子范围,我们可以将该范围传递给以范围作为单个参数的新算法:
std::ranges::for_each(rawStringRange, ... ); // 正确
注意,子范围并不总是公共范围(common range),这意味着对它们调用begin()
和end()
可能会得到不同的类型。子范围只是返回用于定义范围的内容。
即使子范围不是公共范围,你也可以将其传递给基于范围的for循环。基于范围的for循环接受起始迭代器和哨兵(结束迭代器)类型不同的范围(这个特性在C++17中就已经引入,但有了范围和视图,你可以更好地利用它):
for (char c : std::ranges::subrange{rawString, NullTerm{}}) {
std::cout << ' ' << c;
}
2
3
我们可以通过定义一个类模板,使这种方法更加通用,在类模板中你可以指定范围结束的值。考虑以下示例:
// ranges/sentinel3.cpp
#include <iostream>
#include <algorithm>
template<auto End>
struct EndValue {
bool operator==(auto pos) const {
return *pos == End; // 结束位置是迭代器指向End的地方
}
};
int main() {
std::vector coll = {42, 8, 0, 15, 7, -1};
// 定义一个以7为结束值的指向coll的范围:
std::ranges::subrange range{coll.begin(), EndValue<7>{}};
// 对该范围的元素进行排序:
std::ranges::sort(range);
// 打印该范围的元素:
std::ranges::for_each(range,
[](auto val) {
std::cout << ' ' << val;
});
std::cout << '\n';
// 打印coll中直到-1的所有元素:
std::ranges::for_each(coll.begin(), EndValue<-1>{},
[](auto val) {
std::cout << ' ' << val;
});
std::cout << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
在这里,我们将EndValue<>
定义为结束迭代器,检查作为模板参数传递的结束值。EndValue<7>{}
创建了一个以7为结束值的结束迭代器,EndValue<-1>{}
创建了一个以-1为结束值的结束迭代器。
该程序的输出如下:
0 8 15 42
0 8 15 42 7
2
你可以定义任何支持的非类型模板参数类型的值。
作为哨兵的另一个示例,看看std::unreachable_sentinel
。这是C++20定义的一个值,用于表示无限范围的“结束”。它可以帮助你优化代码,使其永远不会与结束值进行比较(因为如果比较总是返回false
,那么这种比较是无用的)。
有关子范围的更多内容,请查看子范围的详细信息。
# 起始和计数的范围
范围库提供了多种处理定义为起始和计数的范围的方法。
创建一个带有起始迭代器和计数的范围最方便的方法是使用范围适配器std::views::counted()
。它创建一个轻量级视图,指向起始迭代器/指针的前n
个元素。例如:
std::vector<int> coll{1, 2, 3, 4, 5, 6, 7, 8, 9};
auto pos5 = std::ranges::find(coll, 5);
if (std::ranges::distance(pos5, coll.end()) >= 3) {
for (int val : std::views::counted(pos5, 3)) {
std::cout << val << ' ';
}
}
2
3
4
5
6
7
这里,std::views::counted(pos5, 3)
创建了一个视图,该视图表示从pos5
指向的元素开始的三个元素。注意,counted()
不会检查是否存在足够的元素(传递过高的计数会导致未定义行为)。确保代码有效是程序员的责任。因此,我们使用std::ranges::distance()
检查是否有足够的元素(注意,如果你的集合没有随机访问迭代器,这个检查可能成本很高)。
如果你知道存在值为5的元素,并且它后面至少有两个元素,你也可以这样写:
// 如果我们知道存在值为5的元素,且后面至少有两个元素:
for (int val : std::views::counted(std::ranges::find(coll, 5), 3)) {
std::cout << val << ' ';
}
2
3
4
计数可以为0,这意味着范围是空的。
注意,只有当你确实有一个迭代器和一个计数时才应该使用counted()
。如果你已经有一个范围,并且只想处理前n
个元素,应该使用std::views::take()
。
更多详细信息,请查看std::views::counted()
的描述。
# 6.1.6 投影
sort()
以及许多其他针对范围的算法通常都有一个额外的可选模板参数——投影(projection):
template<std::ranges::random_access_range R, typename Comp = std::ranges::less, typename Proj = std::identity>
requires std::sortable<std::ranges::iterator_t<R>, Comp, Proj>
... sort(R&& r, Comp comp = {}, Proj proj = {});
2
3
这个可选的额外参数允许你在算法进一步处理每个元素之前,为其指定一个转换(投影)操作。
例如,sort()
允许你将排序元素的投影操作与结果值的比较方式分开指定:
std::ranges::sort(coll,
std::ranges::less{}, // 仍然使用 < 进行比较
[](auto val) { // 但使用绝对值
return std::abs(val);
});
2
3
4
5
这可能比下面这种写法更易读或更易于编程:
std::ranges::sort(coll,
[](auto val1, auto val2) {
return std::abs(val1) < std::abs(val2);
});
2
3
4
完整示例请查看 ranges/rangesproj.cpp
。
默认的投影是 std::identity()
,它只是简单地返回传递给它的参数,因此实际上不执行任何投影/转换操作。(std::identity()
是在 <functional>
中定义的一个新的函数对象。)
用户自定义的投影只需要接受一个参数,并返回转换后的参数值。
可以看到,元素可排序的要求考虑了投影:
requires std::sortable<std::ranges::iterator_t<R>, Comp, Proj>
# 6.1.7 实现范围相关代码的实用工具
为了便于针对各种不同类型的范围进行编程,范围库提供了以下实用工具:
- 通用函数,例如,用于获取迭代器或范围大小的函数。
- 类型函数,例如,用于获取迭代器类型或元素类型的函数。
假设我们要实现一个算法,用于获取范围中的最大值:
// ranges/maxvalue1.hpp
#include <ranges>
template<std::ranges::input_range Range>
std::ranges::range_value_t<Range> maxValue(const Range& rg) {
if (std::ranges::empty(rg)) {
return std::ranges::range_value_t<Range>{};
}
auto pos = std::ranges::begin(rg);
auto max = *pos;
while (++pos != std::ranges::end(rg)) {
if (*pos > max) {
max = *pos;
}
}
return max;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在这里,我们使用了一些标准实用工具来处理范围 rg
:
std::ranges::input_range
概念,用于要求传递的参数是一个可读的范围。std::ranges::range_value_t
类型函数,用于获取范围中元素的类型。std::ranges::empty()
辅助函数,用于判断范围是否为空。std::ranges::begin()
辅助函数,用于获取指向第一个元素的迭代器(如果存在)。std::ranges::end()
辅助函数,用于获取范围的哨兵(结束迭代器)。
借助这些类型实用工具,该算法甚至适用于所有范围(包括数组),因为这些实用工具对它们也有定义。
例如,std::ranges::empty()
会尝试调用成员函数 empty()
、成员函数 size()
、独立函数 size()
,或者检查起始迭代器和哨兵(结束迭代器)是否相等。后面会详细介绍范围的实用函数。
注意,通用的 maxValue()
函数应该将传递的范围 rg
声明为万能引用(也称为转发引用),因为对于某些轻量级范围(视图),当它们是 const
时无法进行迭代:
template<std::ranges::input_range Range>
std::ranges::range_value_t<Range> maxValue(Range&& rg)
2
后面会详细讨论这一点。
# 6.1.8 范围的局限性和缺点
在C++20中,范围也存在一些主要的局限性和缺点,在对其进行总体介绍时应该提及:
- 目前范围还不支持数值算法。要将范围传递给数值算法,必须显式传递
begin()
和end()
:
std::ranges::accumulate(cont, 0L); // 错误:未提供
std::accumulate(cont.begin(), cont.end(), 0L); // 正确
2
- 目前范围也不支持并行算法:
std::ranges::sort(std::execution::par, cont); // 错误:未提供
std::sort(std::execution::par, cont.begin(), cont.end()); // 正确
2
不过要注意,在将现有并行算法用于视图时,如果绕过 begin()
和 end()
直接传递视图,需要谨慎。对于某些视图,并发迭代会导致未定义行为。只有在将视图声明为 const
之后才能这样做。
- 一些传统的由起始迭代器和结束迭代器定义范围的API要求迭代器具有相同的类型(例如,这适用于容器和
std
命名空间中的算法)。可能需要使用std::views::common()
或std::common_iterator
来统一它们的类型。 - 对于某些视图,当视图是
const
时无法迭代元素。因此,通用代码可能需要使用万能/转发引用。 cbegin()
和cend()
函数旨在确保在迭代时不会(意外地)修改元素,但对于引用非const
对象的视图,这些函数存在问题。- 当视图引用容器时,它们对常量性的传播可能会出现问题。
- 范围会导致命名空间混乱。例如,看一下
std::find()
的以下声明,所有标准名称都进行了完全限定:
template<std::ranges::input_range Rg, typename T,
typename Proj = std::identity>
requires std::indirect_binary_predicate<std::ranges::equal_to,
std::projected<std::ranges::iterator_t<Rg>, Proj>, const T*>
constexpr std::ranges::borrowed_iterator_t<Rg>
find(Rg&& rg, const T& value, Proj proj = {});
2
3
4
5
6
确实很难确定在何处使用哪个命名空间。
此外,在 std
命名空间和 std::ranges
命名空间中都有一些符号,它们的行为略有不同。在上面的声明中,equal_to
就是这样一个例子。你也可以使用 std::equal_to
,但一般来说,std::ranges
中的实用工具对边界情况提供了更好的支持,也更健壮。
# 6.2 借用迭代器和范围
将范围作为单个参数传递给算法时,会出现生命周期问题。本节将介绍范围库如何处理这个问题。
# 6.2.1 借用迭代器
许多算法会返回指向其操作范围的迭代器。然而,当将范围作为单个参数传递时,会出现一个新问题,而当范围需要两个参数(起始迭代器和结束迭代器)时,这个问题是不会出现的:如果你传递一个临时范围(例如函数返回的范围)并返回指向它的迭代器,当范围在语句结束时被销毁,返回的迭代器可能会失效。使用返回的迭代器(或其副本)会导致未定义行为。
例如,考虑将一个临时范围传递给 find()
算法,该算法在范围中搜索一个值:
std::vector<int> getData(); // 前置声明
auto pos = find(getData(), 42); // 返回指向临时vector的迭代器
// 由getData()返回的临时vector在此处被销毁
std::cout << *pos; // 糟糕:使用了悬空迭代器
2
3
4
getData()
的返回值在使用它的语句结束时被销毁。因此,pos
指向的集合中的元素已经不存在了。使用 pos
会导致未定义行为(最好的情况是得到一个核心转储,这样你就能发现问题)。
为了解决这个问题,范围库引入了借用迭代器(borrowed iterator)的概念。借用迭代器确保其生命周期不依赖于可能已被销毁的临时对象。如果依赖,使用它会导致编译时错误。因此,借用迭代器表明它是否可以安全地在传递的范围之后继续存在,当范围不是临时的,或者迭代器的状态不依赖于传递范围的状态时,就可以安全存在。如果你有一个指向范围的借用迭代器,即使范围被销毁,该迭代器仍然可以安全使用,不会悬空。
出于这个原因,在范围库的草案版本中,这类迭代器被称为安全迭代器(safe iterator)。
通过使用 std::ranges::borrowed_iterator_t<>
类型,算法可以将返回的迭代器声明为借用迭代器。这意味着算法总是返回一个在语句结束后仍可安全使用的迭代器。如果迭代器可能悬空,会使用一个特殊的返回值来表示,并将可能的运行时错误转换为编译时错误。
例如,针对单个范围的 std::ranges::find()
声明如下:
template<std::ranges::input_range Rg, typename T,
typename Proj = std::identity>
...
constexpr std::ranges::borrowed_iterator_t<Rg> find(Rg&& r, const T& value, Proj proj = {});
2
3
4
通过将返回类型指定为 std::ranges::borrowed_iterator_t<>
,标准启用了编译时检查:如果传递给算法的范围 R
是一个临时对象(纯右值),返回类型就会变成悬空迭代器。在这种情况下,返回值是一个 std::ranges::dangling
类型的对象。对这样的对象进行任何使用(除了复制和赋值)都会导致编译时错误。
因此,以下代码会导致编译时错误:
std::vector<int> getData(); // 前置声明
auto pos = std::ranges::find(getData(), 42); // 返回指向临时vector的迭代器
std::cout << *pos; // 编译时错误
// 由getData()返回的临时vector已被销毁
2
3
4
为了能够对临时范围调用 find()
,必须将其作为左值传递。也就是说,它必须有一个名称。这样,算法就能确保在调用后集合仍然存在。这也意味着你还可以检查是否找到了值(这通常也是合适的做法)。
给返回的集合命名的最佳方法是将其绑定到一个引用。这样,集合永远不会被复制。注意,根据规则,对临时对象的引用总是会延长其生命周期:
std::vector<int> getData(); // 前置声明
const auto& data = getData(); // 给返回值命名,以便将其作为左值使用
// 返回的临时vector的生命周期现在随着data的销毁而结束
2
3
这里可以使用两种引用:
- 可以声明一个常量左值引用:
std::vector<int> getData(); // 前置声明
const auto& data = getData(); // 给返回值命名,以便将其作为左值使用
auto pos = std::ranges::find(data, 42); // 不会产生悬空迭代器
if (pos != data.end()) {
std::cout << *pos;
} // 正确
2
3
4
5
6
这个引用使返回值成为常量,这可能不是你想要的(注意,当视图是常量时,你无法对某些视图进行迭代;不过,由于视图的引用语义,在返回视图时需要小心)。
- 在更通用的代码中,应该使用万能引用(也称为转发引用)或
decltype(auto)
,以便保留返回值的自然非常量性:
std::vector<int> getData(); // 前置声明
auto&& data = getData(); // 给返回值命名,以便将其作为左值使用
auto pos = std::ranges::find(data, 42); // 不会产生悬空迭代器
if (pos != data.end()) {
std::cout << *pos;
} // 正确
2
3
4
5
6
这个特性的结果是,即使生成的代码是有效的,也不能将临时对象传递给算法:
process(std::ranges::find(getData(), 42)); // 编译时错误
虽然在函数调用期间迭代器是有效的(临时vector会在调用后被销毁),但 find()
返回一个 std::ranges::dangling
对象。
同样,处理这个问题的最佳方法是为 getData()
的返回值声明一个引用:
- 使用常量左值引用:
const auto& data = getData(); // 给返回值命名,以便将其作为左值使用
process(std::ranges::find(data, 42)); // 将有效迭代器传递给process()
2
- 使用万能/转发引用:
auto&& data = getData(); // 给返回值命名,以便将其作为左值使用
process(std::ranges::find(data, 42)); // 将有效迭代器传递给process()
2
记住,通常情况下,无论如何你都需要给返回值命名,以便检查返回值是否指向一个元素,而不是范围的 end()
:
auto&& data = getData(); // 给返回值命名,以便将其作为左值使用
auto pos = std::ranges::find(data, 42); // 产生一个有效迭代器
if (pos != data.end()) { // 正确
std::cout << *pos; // 正确
}
2
3
4
5
# 6.2.2 借用范围
范围类型可以声明它们是借用范围。这意味着当范围本身不再存在时,其迭代器仍可继续使用。
C++20为此提供了std::ranges::borrowed_range
概念。如果范围类型的迭代器从不依赖于其范围的生命周期,或者传递的范围对象是左值,那么这个概念就会得到满足。这意味着在具体情况下,该概念会检查为范围创建的迭代器在范围不再存在后是否还能使用。
所有标准容器以及引用作为右值(临时范围对象)传递的范围的视图都不是借用范围,因为迭代器遍历的是存储在它们内部的值。对于其他视图,情况则各不相同。在这些情况下,有两种方法可以使视图成为借用范围:
- 迭代器在本地存储所有迭代所需的信息。例如:
std::ranges::iota_view
,它生成一个递增的数值序列。这里,迭代器在本地存储当前值,不引用任何其他对象。std::ranges::empty_view
,其任何迭代器始终位于末尾,因此根本无法遍历元素值。
- 迭代器直接引用底层范围,而不使用调用
begin()
和end()
的视图。例如,以下情况:std::ranges::subrange
std::ranges::ref_view
std::span
std::string_view
请注意,当借用的迭代器引用的底层范围(上述后一类范围)不再存在时,它们仍然可能悬空。
因此,我们可以在编译时捕获部分但不是全部可能的运行时错误,我可以通过在不同范围中查找值为8的元素的各种方法来演示这一点(是的,通常我们应该检查是否返回了尾后迭代器):
- 所有左值(有名称的对象)都是借用范围,这意味着只要迭代器与范围在同一作用域或其子作用域内存在,返回的迭代器就不会悬空。
std::vector coll{0, 8, 15};
auto pos0 = std::ranges::find(coll, 8); // 借用范围
std::cout << *pos0; // 没问题(如果没有8则为未定义行为)
auto pos1 = std::ranges::find(std::vector{8}, 8); // 产生悬空迭代器
std::cout << *pos1; // 编译时错误
2
3
4
5
- 对于临时视图,情况各不相同。例如:
auto pos2 = std::ranges::find(std::views::single(8), 8); // 产生悬空迭代器
std::cout << *pos2; // 编译时错误
auto pos3 = std::ranges::find(std::views::iota(8), 8); // 借用范围
std::cout << *pos3; // 没问题(如果未找到8则为未定义行为)
auto pos4 = std::ranges::find(std::views::empty<int>, 8); // 借用范围
std::cout << *pos4; // 未找到8时为未定义行为
2
3
4
5
6
例如,单元素视图(single view
)的迭代器引用视图中元素的值,因此单元素视图不是借用范围。
另一方面,整数序列视图(iota view
)的迭代器保存了它们所引用元素的副本,这意味着整数序列视图被声明为借用范围。
- 对于引用另一个范围(整个范围或其某个子序列)的视图,情况更为复杂。如果可以的话,它们会尝试检测类似的问题。例如,适配器
std::views::take()
也会检查纯右值:
auto pos5 = std::ranges::find(std::views::take(std::vector{0, 8, 15}, 2), 8);
// 编译时错误
2
在这里,调用take()
已经是一个编译时错误。
然而,如果你使用counted()
,它只接受一个迭代器,那么确保迭代器有效的责任就在于程序员:
auto pos6 = std::ranges::find(std::views::counted(std::vector{0, 8, 15}.begin(), 2), 8);
std::cout << *pos6; // 即使找到8,也是运行时错误
2
这里用counted()
创建的视图,根据定义是借用范围,因为它们将内部引用传递给了迭代器。换句话说:计数视图(counted view
)的迭代器不需要其所属的视图。然而,迭代器仍然可能引用一个不再存在的范围(因为其视图引用的对象不再存在)。pos6
示例的最后一行演示了这种情况。即使find()
查找的值可以在临时范围中找到,我们仍然会得到未定义行为。
如果你实现一个容器或视图,可以通过特化变量模板std::ranges::enable_borrowed_range<>
来表明它是一个借用范围。
# 6.3 使用视图
如前所述,视图是轻量级范围,可用作构建块,用于处理其他范围和视图中所有或部分元素的(修改后)值。
C++20提供了几种标准视图。它们可用于将范围转换为视图,或者生成一个视图,其中元素以各种方式被修改:
- 过滤掉元素
- 生成元素的转换后的值
- 改变遍历元素的顺序
- 分割或合并范围
此外,还有一些视图会自己生成值。
几乎每种视图类型都有一个相应的定制点对象(通常是一个函数对象),允许程序员通过调用一个函数来创建视图。如果该函数从传递的范围创建视图,则这种函数对象称为范围适配器(range adaptor);如果函数在不传递现有范围的情况下创建视图,则称为范围工厂(range factory)。大多数情况下,这些函数对象的名称是视图名称去掉_view
后缀。然而,一些更通用的函数对象可能会根据传递的参数创建不同的视图。这些函数对象都定义在特殊的命名空间std::views
中,它是命名空间std::ranges::views
的别名。
“源视图”表列出了C++20中从外部资源创建视图或自己生成值的标准视图。这些视图尤其可以作为视图管道中的起始构建块。你还可以看到哪些范围适配器或工厂可能创建它们(如果有的话)。如果有可用的适配器或工厂,你应该优先使用它们,而不是原始的视图类型。
除非另有说明,适配器和工厂在命名空间std::views
中可用,视图类型在命名空间std::ranges
中可用。std::string_view
在C++17中已经引入。所有其他视图在C++20中引入,通常以_view
结尾。唯一不以_view
结尾的视图类型是std::subrange
和std::span
。
“适配视图”表列出了C++20中处理范围和其他视图的范围适配器和标准视图。它们可以在视图管道中的任何位置作为构建块,包括起始位置。同样,你应该优先使用适配器。
所有视图都提供具有常量复杂度的移动(以及可选的复制)操作(这些操作所花费的时间不依赖于元素的数量)。std::ranges::view
概念检查相应的要求。
范围工厂/适配器all()
、counted()
和common()
将在专门的章节中介绍。所有视图类型以及其他适配器和工厂的详细信息将在“视图类型详解”一章中介绍。
适配器/工厂 | 类型 | 作用 |
---|---|---|
all(rg) | 各种类型: 如果 rg 已经是视图,则返回rg ;如果 rg 是左值,则返回ref_view ;如果 rg 是右值,则返回owning_view | 将范围rg 作为视图返回 |
counted(beg,sz) | 各种类型: 如果 rg 是连续且普通的,则返回std::span ;否则(如果有效)返回 subrange | 从起始迭代器和计数生成一个视图 |
iota(val) | iota_view | 生成一个从val 开始的递增序列的无限视图 |
iota(val, endVal) | iota_view | 生成一个从val 到(但不包括)endVal 的递增序列的视图 |
single(val) | single_view | 生成一个以val 为唯一元素的视图 |
empty<T> | empty_view | 生成一个类型为T 的空视图 |
istream<T>(s) | istream_view | 生成一个从字符流s 读取T 类型数据的视图 |
wistream_view | wistream_view | 生成一个从wchar_t 流s 读取T 类型数据的视图 |
std::basic_string_view | std::basic_string_view | 生成一个指向字符数组的只读视图 |
std::span | std::span | 生成一个指向连续内存中元素的视图 |
subrange | subrange | 从起始迭代器和哨兵生成一个视图 |
表6.3 源视图
适配器 | 类型 | 作用 |
---|---|---|
take(num) | 各种类型 | 取前(最多)num 个元素 |
take_while(pred) | take_while_view | 取所有满足谓词的起始元素 |
drop(num) | 各种类型 | 去掉前num 个元素,取剩余所有元素 |
drop_while(pred) | drop_while_view | 去掉所有满足谓词的起始元素,取剩余所有元素 |
filter(pred) | filter_view | 取所有满足谓词的元素 |
transform(func) | transform_view | 取所有元素转换后的值 |
elements<idx> | elements_view | 取所有元素的第idx 个成员/属性 |
keys | elements_view | 取所有元素的第一个成员 |
values | elements_view | 取所有元素的第二个成员 |
reverse | 各种类型 | 以相反顺序取所有元素 |
join | join_view | 取多个范围组成的范围中的所有元素 |
split(sep) | split_view | 将一个范围的所有元素分割成多个范围 |
lazy_split(sep) | lazy_split_view | 将一个输入或常量范围的所有元素延迟分割成多个范围 |
common | 各种类型 | 取迭代器和哨兵类型相同的所有元素 |
表6.4 适配视图
# 6.3.1 范围上的视图
容器和字符串不是视图。这是因为它们不够轻量级:它们没有廉价的复制构造函数,因为它们必须复制元素。
然而,你可以轻松地将容器当作视图使用:
- 你可以通过将容器传递给范围适配器
std::views::all()
,显式地将其转换为视图。 - 你可以通过将起始迭代器和结束(哨兵)或大小传递给
std::ranges::subrange
或std::views::counted()
,显式地将容器中的元素转换为视图。 - 你可以通过将容器传递给适配视图之一,隐式地将其转换为视图。这些视图通常通过将容器隐式转换为视图来接受容器。
通常,最后一种方法是并且应该被使用。有多种实现这种方法的方式。例如,对于将范围coll
传递给取前视图(take view
),你有以下几种选择:
- 你可以将范围作为参数传递给视图的构造函数:
std::ranges::take_view first4{coll, 4};
- 你可以将范围作为参数传递给相应的适配器:
auto first4 = std::views::take(coll, 4);
- 你可以将范围通过管道操作符传递给相应的适配器:
auto first4 = coll | std::views::take(4);
在任何情况下,视图first4
都只遍历coll
的前四个元素(如果元素不足,则遍历更少的元素)。然而,这里具体发生的事情取决于coll
是什么:
- 如果
coll
已经是一个视图,take()
直接使用该视图。 - 如果
coll
是一个容器,take()
使用由适配器std::views::all()
自动创建的容器视图。如果容器是按名称传递的(作为左值),这个适配器会返回一个ref_view
,它引用容器的所有元素。 - 如果传递的是右值(一个临时范围,比如函数返回的容器或用
std::move()
标记的容器),该范围会被移动到一个owning_view
中,然后这个owning_view
直接持有一个包含所有移动元素的传递类型的范围。例如:
std::vector<std::string> coll{ "just ", "some ", "strings ", "to ", "deal ", "with"};
auto v1 = std::views::take(coll, 4); // 遍历指向coll的ref_view
auto v2 = std::views::take(std::move(coll), 4); // 遍历指向本地vector<string>的owning_view
auto v3 = std::views::take(v1, 2); // 遍历v1
2
3
4
在所有情况下,std::views::take()
都会创建一个新的取前视图,最终遍历在coll
中初始化的值。然而,结果类型和具体行为有所不同:
v1
是take_view<ref_view<vector<string>>>
。因为我们将容器coll
作为左值(命名对象)传递,所以取前视图遍历指向容器的引用视图。v2
是take_view<owning_view<vector<string>>>
。因为我们将coll
作为右值(临时对象或用std::move()
标记的对象)传递,所以取前视图遍历一个owning_view
,它持有一个用传递的集合移动初始化的自己的字符串向量。v3
是take_view<take_view<ref_view<vector<string>>>>
。因为我们传递的是视图v1
,所以取前视图遍历这个视图。结果是我们最终遍历coll
(在第二条语句中,coll
的元素被移动走了,所以在第二条语句之后不要这样做)。
在内部,初始化使用了推导指引和类型工具std::views::all_t<>
,后面会详细解释。
请注意,这种行为允许基于范围的for
循环遍历临时范围:
for (const auto& elem : getColl() | std::views::take(5)) {
std::cout << "- " << elem << "\n";
}
for (const auto& elem : getColl() | std::views::take(5) | std::views::drop(2)) {
std::cout << "- " << elem << "\n";
}
2
3
4
5
6
这很值得注意,因为一般来说,将对临时对象的引用用作基于范围的for
循环遍历的集合是一个致命的运行时错误(多年来,C++标准委员会一直不愿意修复这个错误;见http://wg21.link/p2012)。由于传递临时范围对象(右值)会将范围移动到一个owning_view
中,该视图不引用外部容器,因此不会出现运行时错误。
# 6.3.2 延迟求值
理解视图具体在何时进行处理非常重要。视图在定义时并不会开始处理,而是按需运行:
- 如果我们需要视图的下一个元素,会通过执行必要的迭代来计算它是哪一个元素。
- 如果我们需要视图中某个元素的值,会通过执行定义好的转换来计算它的值。
考虑以下程序:
// ranges/filttrans.cpp
#include <iostream>
#include <vector>
#include <ranges>
namespace vws = std::views;
int main()
{
std::vector<int> coll{8, 15, 7, 0, 9};
// 定义一个视图:
auto vColl = coll
| vws::filter([](int i) {
std::cout << " filter " << i << "\n";
return i % 3 == 0;
})
| vws::transform([](int i) {
std::cout << " trans " << i << "\n";
return -i;
});
// 使用它:
std::cout << "*** coll | filter | transform:\n";
for (int val : vColl) {
std::cout << "val: " << val << "\n\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
我们定义了一个视图vColl
,它仅对范围coll
中的元素进行过滤和转换:
- 通过使用
std::views::filter()
,我们只处理那些是3的倍数的元素。 - 通过使用
std::views::transform()
,我们对每个值取反。
该程序的输出如下:
*** coll | filter | transform:
filter 8
filter 15
trans 15
val: -15
filter 7
filter 0
trans 0
val: 0
filter 9
trans 9
val: -9
2
3
4
5
6
7
8
9
10
11
12
13
14
首先要注意,在定义视图vColl
时或定义之后,filter()
和transform()
都不会被调用。处理是在我们使用视图时(这里是遍历vColl
)开始的。这意味着视图使用延迟求值(lazy evaluation)。视图只是对处理过程的一种描述。当我们需要下一个元素或值时,才会执行处理。
假设我们更手动地遍历vColl
,通过调用begin()
和++
来获取下一个值,使用*
来获取其值:
std::cout << "pos = vColl.begin():\n";
auto pos = vColl.begin();
std::cout << "*pos:\n";
auto val = *pos;
std::cout << "val: " << val << "\n\n";
std::cout << "++pos:\n";
++pos;
std::cout << "*pos:\n";
val = *pos;
std::cout << "val: " << val << "\n\n";
2
3
4
5
6
7
8
9
10
这段代码的输出如下:
pos = vColl.begin():
filter 8
filter 15
*pos:
trans 15
val: -15
++pos:
filter 7
filter 0
*pos:
trans 0
val: 0
2
3
4
5
6
7
8
9
10
11
12
13
让我们逐步分析发生了什么:
- 当调用
begin()
时,会发生以下情况:- 获取
vColl
第一个元素的请求被传递给转换视图(transform view
),转换视图将其传递给过滤视图(filter view
),过滤视图再将其传递给coll
,coll
返回指向第一个元素8的迭代器。 - 过滤视图查看第一个元素的值并将其拒绝。因此,它通过在
coll
上调用++
来请求下一个元素。过滤视图获取到第二个元素15的位置,并将其位置传递给转换视图。 - 结果,
pos
被初始化为指向第二个元素位置的迭代器。
- 获取
- 当调用
*pos
时,会发生以下情况:- 因为我们需要值,所以现在对当前元素调用转换视图,并得到其取反后的值。
- 结果,
val
被初始化为当前元素取反后的值。
- 当调用
++pos
时,同样的过程再次发生:- 获取下一个元素的请求被传递给过滤视图,过滤视图将请求传递给
coll
,直到找到一个符合条件的元素(或者到达coll
的末尾)。 - 结果,
pos
得到了第四个元素的位置。
- 获取下一个元素的请求被传递给过滤视图,过滤视图将请求传递给
- 再次调用
*pos
时,我们执行转换并为循环提供下一个值。
这种迭代会一直持续,直到范围或其中一个视图表明我们已到达末尾。
这种拉取模型有一个很大的优点:我们不会处理那些永远不需要的元素。例如,假设我们使用视图来查找第一个结果值为0的元素:
std::ranges::find(vColl, 0);
那么输出将仅为:
filter 8
filter 15
trans 15
filter 7
filter 0
trans 0
2
3
4
5
6
拉取模型的另一个优点是,视图序列或视图管道甚至可以处理无限范围。我们不会在不知道会使用多少个值的情况下计算无限数量的值,而是根据视图使用者的请求计算相应数量的值。
# 6.3.3 视图中的缓存
假设我们想要多次迭代一个视图。如果我们反复计算第一个有效元素,这似乎是性能上的浪费。实际上,可能会跳过前置元素的视图在首次调用begin()
时会缓存该值。
让我们修改上面的程序,使其对视图vColl
的元素进行两次迭代:
// ranges/filttrans2.cpp
#include <iostream>
#include <vector>
#include <ranges>
namespace vws = std::views;
int main() {
std::vector<int> coll{8, 15, 7, 0, 9};
// 定义一个视图:
auto vColl = coll
| vws::filter([](int i) {
std::cout << " filter " << i << "\n";
return i % 3 == 0;
})
| vws::transform([](int i) {
std::cout << " trans " << i << "\n";
return -i;
});
// 使用视图:
std::cout << "*** coll | filter | transform:\n";
for (int val : vColl) {
...
}
std::cout << "-------------------\n";
// 再次使用视图:
std::cout << "*** coll | filter | transform:\n";
for (int val : vColl) {
std::cout << "val : " << val << "\n\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
该程序的输出如下:
*** coll | filter | transform:
filter 8
filter 15
trans 15
filter 7
filter 0
trans 0
filter 9
trans 9
*** coll | filter | transform:
trans 15
val: -15
filter 7
filter 0
trans 0
val: 0
filter 9
trans 9
val: -9
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
可以看到,第二次使用视图时,调用vColl.begin()
不再尝试查找第一个元素,因为在第一次迭代过滤器的元素时已经缓存了该值。
请注意,begin()
的这种缓存有好有坏,甚至可能产生一些意想不到的结果。首先,最好初始化一个缓存视图一次,然后使用两次,例如:
// 较好的做法:
auto v1 = coll | std::views::drop(5);
check(v1);
process(v1);
2
3
4
而不是初始化并使用两次,例如:
// 较差的做法:
check(coll | std::views::drop(5));
process(coll | std::views::drop(5));
2
3
此外,仅当在修改之前已经调用过begin()
时,修改范围的前置元素(更改其值或插入/删除元素)才可能使视图无效。
这意味着:
- 如果在修改之前不调用
begin()
,通常视图是有效的,并且在我们之后使用它时可以正常工作:
std::list coll{1, 2, 3, 4, 5};
auto v = coll | std::views::drop(2);
coll.push_front(0); // coll现在为:0 1 2 3 4 5
print(v); // 使用2初始化begin()并打印:2 3 4 5
2
3
4
- 然而,如果在修改之前调用了
begin()
(例如通过打印元素),我们很容易得到错误的元素。例如:
std::list coll{1, 2, 3, 4, 5};
auto v = coll | std::views::drop(2);
print(v); // 使用3初始化begin()
coll.push_front(0); // coll现在为:0 1 2 3 4 5
print(v); // begin()仍然是3,所以打印:3 4 5
2
3
4
5
这里,begin()
被缓存为一个迭代器,因此如果在范围中添加或删除新元素,视图将不再对底层范围的所有元素进行操作。
不过,我们也可能得到无效的值。例如:
std::vector vec{1, 2, 3, 4};
auto biggerThan2 = [](auto v){ return v > 2; };
auto vVec = vec | std::views::filter(biggerThan2);
print(vVec);
++vec[1];
vec[2] = 0;
print(vVec);
2
3
4
5
6
7
8
// 正确:3 4
// vec变为1 3 0 4
// 糟糕:0 4
2
3
请注意,这意味着即使只是读取操作的迭代,也可能被视为写访问。因此,如果视图所引用的范围在此期间被修改,那么对视图元素的迭代可能会导致后续使用无效。
这种影响取决于缓存的时间和方式。有关缓存视图的更多信息,请查看其特定部分的说明:
- 过滤视图(Filter views),将
begin()
缓存为迭代器或偏移量。 - 丢弃视图(Drop views),将
begin()
缓存为迭代器或偏移量。 - 丢弃直到视图(Drop-while views),将
begin()
缓存为迭代器或偏移量。 - 反向视图(Reverse views),将
begin()
缓存为迭代器或偏移量。
在这里,你再次看到C++对性能的关注:
- 如果我们根本不迭代视图的元素,在初始化时进行缓存会带来不必要的性能开销。
- 如果根本不进行缓存,当我们对视图的元素进行第二次或更多次迭代时(在某些情况下,对丢弃直到视图应用反向视图甚至可能具有二次复杂度),会带来不必要的性能开销。
然而,由于缓存的存在,非临时使用视图可能会产生相当惊人的结果。在修改视图使用的范围时必须小心。
另一个结果是,缓存可能要求在迭代视图元素时,视图不能是const
的。其后果更为严重,将在后面讨论。
# 6.3.4 过滤器的性能问题
拉模型也有其缺点。为了说明这一点,让我们更改上面涉及的两个视图的顺序,先调用transform()
,然后再调用filter()
:
// ranges/transfilt.cpp
#include <iostream>
#include <vector>
#include <ranges>
namespace vws = std::views;
int main() {
std::vector<int> coll{8, 15, 7, 0, 9};
// 定义一个视图:
auto vColl = coll
| vws::transform([](int i) {
std::cout << " trans : " << i << "\n";
return -i;
})
| vws::filter([](int i) {
std::cout << " filt : " << i << "\n";
return i % 3 == 0;
});
// 使用视图:
std::cout << "*** coll | transform | filter:\n";
for (int val : vColl) {
std::cout << "val : " << val << "\n\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
现在,程序的输出如下:
*** coll | transform | filter:
trans: 8
filt: -8
trans: 15
filt: -15
trans: 15
val: -15
trans: 7
filt: -7
trans: 0
filt: 0
trans: 0
val: 0
trans: 9
filt: -9
trans: 9
val: -9
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
我们对转换视图有了更多的调用:
- 现在我们对每个元素都调用
transform()
。 - 对于通过过滤器的元素,我们甚至对其进行了两次转换。
对每个元素调用转换是必要的,因为过滤器视图在后面,现在它查看的是转换后的值。在这种情况下,取反操作不会影响过滤器,因此将其放在前面看起来更好。
但是,为什么我们对通过过滤器的元素进行了两次转换呢?原因在于使用拉模型的管道的本质以及我们使用迭代器的事实。
- 首先,过滤器需要转换后的值来进行检查;因此,在过滤器使用结果值之前,必须先进行前置的转换操作。
- 请记住,范围和视图对范围中的元素进行迭代分两步:它们首先计算位置/迭代器(使用
begin()
和++
),然后作为单独的步骤,使用*
来获取值。这意味着对于每个过滤器,我们必须先对所有前置转换进行一次操作,才能检查元素的值。然而,如果检查结果为真,过滤器仅提供元素的位置,而不是其值。因此,当过滤器的使用者需要该值时,必须再次进行转换。
实际上,每个过滤器会对每个通过过滤器且后续会使用其值的元素,多调用一次所有前置的转换操作。
对于以下转换t1
、t2
、t3
和过滤器f1
、f2
组成的管道:
t1 | t2 | f1 | t3 | f2
我们有以下行为:
- 对于
f1
返回false
的元素,我们调用:t1 t2 f1
- 对于
f1
返回true
但f2
返回false
的元素,我们调用:t1 t2 f1 t1 t2 t3 f2
- 对于
f1
和f2
都返回true
的元素,我们调用:t1 t2 f1 t1 t2 t3 f2 t1 t2 t3
完整示例请查看ranges/viewscalls.cpp
。
如果这让你担心管道的性能,可以考虑以下几点:是的,在使用过滤器之前,你应该避免进行昂贵的转换操作。但是,请记住,所有视图都提供了通常经过优化的功能,最终只有转换和过滤器内部的表达式会保留下来。在像这里这样简单的情况下,过滤器检查元素是否为3的倍数,而转换只是取反,实际行为上的差异就像调用:
if (-x % 3 == 0) return -x; // 先转换后过滤
而不是:
if (x % 3 == 0) return -x; // 先过滤后转换
有关过滤器的更多详细信息,请参阅关于过滤视图的部分。
# 6.4 对被销毁或修改的范围使用视图
视图通常具有引用语义。它们常常引用存在于自身之外的范围。这意味着使用时必须小心,因为只有在底层范围存在,并且视图或其迭代器中存储的对它们的引用有效时,才能使用视图。
# 6.4.1 视图与其范围之间的生命周期依赖关系
所有对作为左值传递(作为第一个构造函数参数或使用管道操作符)的范围进行操作的视图,都会在内部存储对传递范围的引用。
这意味着在使用视图时,底层范围仍然必须存在。像这样的代码会导致未定义行为:
auto getValues() {
std::vector coll{1, 2, 3, 4, 5};
// ...
return coll | std::views::drop(2); // 错误:返回对局部范围的引用
}
2
3
4
5
我们在这里返回的是一个按值返回的drop
视图。然而,在内部,它引用了coll
,coll
会在getValues()
结束时被销毁。
这段代码和返回对局部对象的引用或指针一样糟糕。它可能偶然起作用,也可能导致致命的运行时错误。遗憾的是,编译器(目前)对此不会发出警告。
对右值范围对象使用视图是没问题的。你可以返回一个指向临时范围对象的视图:
auto getValues() {
// ...
return std::vector{1, 2, 3, 4, 5} | std::views::drop(2); // 正确
}
2
3
4
或者你可以用std::move()
标记底层范围:
auto getValues() {
std::vector coll{1, 2, 3, 4, 5};
// ...
return std::move(coll) | std::views::drop(2); // 正确
}
2
3
4
5
# 6.4.2 具有写访问权限的视图
视图绝不应该修改传递的参数,也不应该对其调用非const操作。这样,视图及其副本对于相同的输入会有相同的行为。
对于使用辅助函数来检查或转换值的视图,这意味着这些辅助函数绝不应该修改元素。理想情况下,它们应该按值或按const引用获取值。如果你修改作为非const引用传递的参数,就会出现未定义行为:
coll | std::views::transform([](auto& val) { // 最好将val声明为const&
++val; // 错误:未定义行为
})
coll | std::views::drop([](auto& val) { // 最好将val声明为const&
return ++val > 0; // 错误:未定义行为
})
2
3
4
5
6
注意,编译器无法检查辅助函数或谓词是否修改了传递的值。视图要求传递的函数或谓词是std::regular_invocable
(这是std::predicate
隐式要求的)。然而,不修改值是一个语义约束,并不总是能在编译时检查出来。因此,这取决于你确保代码正确。
不过,支持使用视图来限制你想要修改的元素子集。例如:
// 将coll中除前五个元素之外的所有元素赋值为0:
for (auto& elem : coll | vws::drop(5)) {
elem = 0;
}
2
3
4
# 6.4.3 对变化范围使用视图
如果不即时使用视图,并且底层范围发生变化(注意,这里说的是在范围和对其调用begin()
仍然有效的情况下发生的问题),缓存begin()
的视图可能会遇到严重问题。
考虑以下程序:
// ranges/viewslazy.cpp
#include <iostream>
#include <vector>
#include <list>
#include <ranges>
void print(auto&& coll) {
for (const auto& elem : coll) {
std::cout << ' ' << elem;
}
std::cout << '\n';
}
int main() {
std::vector vec{1, 2, 3, 4, 5};
std::list lst{1, 2, 3, 4, 5};
auto over2 = [](auto v) { return v > 2; };
auto over2vec = vec | std::views::filter(over2);
auto over2lst = lst | std::views::filter(over2);
std::cout << "containers and elements over 2:\n";
print(vec); // 正确: 1 2 3 4 5
print(lst); // 正确: 1 2 3 4 5
print(over2vec); // 正确: 3 4 5
print(over2lst); // 正确: 3 4 5
// 修改底层范围:
vec.insert(vec.begin(), {9, 0, -1});
lst.insert(lst.begin(), {9, 0, -1});
std::cout << "containers and elements over 2:\n";
print(vec);
print(lst);
print(over2vec);
print(over2lst);
// 现在vec: 9 0 -1 1 2 3 4 5
// 现在lst: 9 0 -1 1 2 3 4 5
// 糟糕: -1 3 4 5
// 糟糕: 3 4 5
// 复制可能会消除缓存:
auto over2vec2 = over2vec;
auto over2lst2 = over2lst;
std::cout << "elements over 2 after copying the view:\n";
print(over2vec2); // 糟糕: -1 3 4 5
print(over2lst2); // 正确: 9 3 4 5
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
该程序的输出如下:
containers and elements over 2:
1 2 3 4 5
1 2 3 4 5
3 4 5
3 4 5
containers and elements over 2:
9 0 -1 1 2 3 4 5
9 0 -1 1 2 3 4 5
-1 3 4 5
3 4 5
elements over 2 after copying the view:
-1 3 4 5
9 3 4 5
2
3
4
5
6
7
8
9
10
11
12
13
问题在于filter
视图的缓存。在第一次迭代时,两个视图都缓存了视图的起始位置。注意,这以不同的方式发生:
- 对于像
vector
这样的随机访问范围,视图缓存第一个元素的偏移量。这样,当重新分配使begin()
无效时,再次使用视图不会导致未定义行为。 - 对于像
list
这样的其他范围,视图实际上缓存了调用begin()
的结果。如果缓存的元素被删除,这可能是一个严重的问题,意味着缓存的begin()
不再有效。然而,如果在前面插入新元素,也可能会造成混淆。
缓存的总体影响是,底层范围的进一步修改可能会以各种方式使视图无效:
- 缓存的
begin()
可能不再有效。 - 缓存的偏移量可能在底层范围的末尾之后。
- 符合谓词的新元素可能不会被后续迭代使用。
- 不符合谓词的新元素可能会被后续迭代使用。
# 6.4.4 复制视图可能会改变行为
最后,注意前面的示例表明,复制视图有时可能会使缓存无效:
- 缓存了
begin()
的视图输出为:-1 3 4 5
- 这些视图的副本输出不同:
-1 3 4 5
9 3 4 5
2
复制后,指向vector
的视图的缓存偏移量仍在使用,而指向list
的视图(begin()
)的缓存begin()
被删除了。
这意味着缓存还有一个额外的影响,即视图的副本可能与源视图状态不同。因此,在复制视图之前你应该三思(尽管其中一个设计目标是使复制成本低廉,以便按值传递它们)。
如果这种行为有一个后果的话,那就是:即时使用视图(在定义视图后立即使用)。
这有点令人遗憾,因为原则上视图的延迟求值允许一些非常棒的用例,但在实践中却无法实现,因为如果不即时使用视图,并且底层范围可能发生变化,代码的行为就很难预测。
# 使用filter视图进行写访问
使用filter
视图时,对写访问还有一些重要的额外限制:
- 首先,如前所述,由于
filter
视图进行缓存,被修改的元素可能会通过过滤,即使它们不应该通过。 - 此外,你必须确保修改后的值仍然满足传递给
filter
的谓词。否则,会出现未定义行为(不过有时会产生正确的结果)。有关详细信息和示例,请查看filter
视图的描述。
# 6.5 视图与const
在使用视图(以及一般的范围库)时,关于常量性有一些令人惊讶甚至不合理的地方:
- 对于某些视图,当视图是
const
时,无法遍历其元素。 - 视图不会将常量性传递给元素。
- 像
cbegin()
和cend()
这样的函数,其目的是在遍历元素时确保元素是const
的,但这些函数要么没有提供,要么实际上存在问题。
这些问题或多或少都有一些合理的原因。其中一些与视图的性质以及一些基本的设计决策有关。我认为这是一个严重的设计错误。然而,C++标准委员会的其他成员有不同的看法。
目前正在进行一些修复,至少可以修复cbegin()
和cend()
中存在问题的常量性。遗憾的是,C++标准委员会决定不在C++20中应用这些修复。这些修复将在C++23中出现(并改变相关行为)。详细信息请查看http://wg21.link/p2278r4。
# 6.5.1 适用于容器和视图的泛型代码
实现一个能高效迭代各种容器和视图元素的泛型函数,其复杂程度超乎想象。例如,声明一个如下的用于打印所有元素的函数,并非在所有情况下都能正常工作:
template<typename T>
void print(const T& coll); // 糟糕:对某些视图可能不起作用
2
来看下面这个具体示例:
// ranges/printconst.cpp
#include <iostream>
#include <vector>
#include <list>
#include <ranges>
void print(const auto& rg) {
for (const auto& elem : rg) {
std::cout << elem << " ";
}
std::cout << "\n";
}
int main() {
std::vector vec{1, 2, 3, 4, 5, 6, 7, 8, 9};
std::list lst{1, 2, 3, 4, 5, 6, 7, 8, 9};
print(vec | std::views::take(3)); // 没问题
print(vec | std::views::drop(3)); // 没问题
print(lst | std::views::take(3)); // 没问题
print(lst | std::views::drop(3)); // 错误
for (const auto& elem : lst | std::views::drop(3)) {
std::cout << elem << " ";
}
std::cout << "\n";
auto isEven = [] (const auto& val) {
return val % 2 == 0;
};
print(vec | std::views::filter(isEven)); // 错误
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
首先,我们声明了一个非常简单的泛型函数print()
,它将以常量引用的方式打印传递范围中的所有元素:
void print(const auto& rg) {
...
}
2
3
然后,我们对几个视图调用print()
,却出现了意外情况:
- 将
take
视图和drop
视图传递给vector
时工作正常:
print(vec | std::views::take(3));// 没问题
print(vec | std::views::drop(3));// 没问题
print(lst | std::views::take(3));// 没问题
2
3
- 将
drop
视图传递给list
时无法编译:
print(vec | std::views::take(3));// 没问题
print(vec | std::views::drop(3));// 没问题
print(lst | std::views::take(3));// 没问题
print(lst | std::views::drop(3));// 错误
2
3
4
- 然而,直接迭代
drop
视图却能正常工作:
for (const auto& elem : lst | std::views::drop(3)) { // 没问题
std::cout << elem << " ";
}
2
3
这并不意味着vector
总是能正常工作。例如,如果我们传递一个filter
视图,对所有范围都会报错:
print(vec | std::views::filter(isEven)); // 错误
# 常量引用并非适用于所有视图
这种奇怪且出乎意料的行为的原因在于,某些视图在声明为常量时,并不总是支持迭代元素。这是因为迭代这些视图的元素有时需要修改视图状态的能力(例如,由于缓存机制)。对于某些视图(如filter
视图),这根本行不通;对于某些视图(如drop
视图),只是有时可行。
实际上,如果以下标准视图被声明为常量,你就无法迭代其元素:
- 始终无法迭代的常量视图:
filter
视图drop-while
视图split
视图IStream
视图
- 有时可以迭代的常量视图:
drop
视图,如果它引用的范围不支持随机访问或没有size()
函数reverse
视图,如果它引用的范围的起始迭代器和哨兵(结束迭代器)类型不同join
视图,如果它引用的范围生成的值不是引用- 所有引用其他范围的视图,如果被引用的范围本身不是常量可迭代的
对于这些视图,begin()
和end()
作为常量成员函数只是有条件地提供,或者根本不提供。例如,对于drop
视图,只有当传递的范围满足随机访问范围和可获取大小范围的要求时,才会为常量对象提供begin()
函数:
namespace std::ranges {
template<view V>
class drop_view : public view_interface<drop_view<V>> {
public :
...
// 只有当const V满足随机访问范围和可获取大小范围的要求时,才会为常量对象提供begin()函数
constexpr auto begin() const requires random_access_range<const V> && sized_range<const V>;
...
};
}
2
3
4
5
6
7
8
9
10
这意味着,如果你将参数声明为常量引用,就无法提供一个能处理所有范围和视图元素的泛型函数。
void print(const auto& coll); // 并非对所有视图都可调用
template<typename T>
void foo(const T& coll); // 并非对所有视图都可调用
2
3
对范围参数的类型进行约束也无济于事:
void print(const std::ranges::input_range auto& coll); // 并非对所有视图都可调用
template<std::ranges::random_access_range T>
void foo(const T& coll); // 并非对所有视图都可调用
2
3
# 非常量右值引用适用于所有视图
为了在泛型代码中也支持这些视图,你应该将范围参数声明为万能引用(也称为转发引用)。这些引用可以引用所有表达式,同时确保被引用的对象不是常量。例如:
void print(std::ranges::input_range auto&& coll); // 原则上可以传递所有视图
template<std::ranges::random_access_range T>
void foo(T&& coll); // 原则上可以传递所有视图
2
3
因此,以下程序可以正常工作:
// ranges/printranges.cpp
#include <iostream>
#include <vector>
#include <list>
#include <ranges>
void print(auto&& rg) {
for (const auto& elem : rg) {
std::cout << elem << " ";
}
std::cout << "\n";
}
int main() {
std::vector vec{1, 2, 3, 4, 5, 6, 7, 8, 9};
std::list lst{1, 2, 3, 4, 5, 6, 7, 8, 9};
print(vec | std::views::take(3)); // 没问题
print(vec | std::views::drop(3)); // 没问题
print(lst | std::views::take(3)); // 没问题
print(lst | std::views::drop(3)); // 没问题
for (const auto& elem : lst | std::views::drop(3)) {
std::cout << elem << " ";
}
std::cout << "\n";
auto isEven = [] (const auto& val) {
return val % 2 == 0;
};
print(vec | std::views::filter(isEven)); // 没问题
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
出于同样的原因,前面介绍的泛型maxValue()
函数也并非总是能正常工作:
template<std::ranges::input_range Range>
std::ranges::range_value_t<Range> maxValue(const Range& rg) {
... // 迭代filter视图的元素时出错
}
// 过滤后范围的最大值:
auto odd = [] (auto val) {
return val % 2 != 0;
};
std::cout << maxValue(arr | std::views::filter(odd)) << "\n"; // 错误
2
3
4
5
6
7
8
9
10
因此,最好按如下方式实现泛型maxValue()
函数:
// ranges/maxvalue2.hpp
#include <ranges>
template<std::ranges::input_range Range>
std::ranges::range_value_t<Range> maxValue(Range&& rg) {
if (std::ranges::empty(rg)) {
return std::ranges::range_value_t<Range>{};
}
auto pos = std::ranges::begin(rg);
auto max = *pos;
while (++pos != std::ranges::end(rg)) {
if (*pos > max) {
max = *pos;
}
}
return max;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这里,我们将参数rg
声明为万能/转发引用:
template<std::ranges::input_range Range>
std::ranges::range_value_t<Range> maxValue(Range&& rg)
2
这样,我们确保传递的视图不会变成常量,这意味着现在也可以传递filter
视图:
// ranges/maxvalue2.cpp
#include "maxvalue2.hpp"
#include <iostream>
#include <algorithm>
int main() {
int arr[] = {0, 8, 15, 42, 7};
// 过滤后范围的最大值:
auto odd = [] (auto val) { // 奇数的谓词
return val % 2 != 0;
};
std::cout << maxValue(arr | std::views::filter(odd)) << "\n"; // 没问题
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
程序的输出为:15
你现在可能想知道,如何声明一个能对所有常量和非常量范围及视图进行调用,并且保证元素不被修改的泛型函数呢?这里有一个重要的知识点需要了解:
自C++20起,不再有办法声明一个泛型函数,使其能接受所有标准集合类型(常量和非常量容器及视图),同时保证元素不被修改。
你所能做的就是接受范围/视图,并在函数体中确保元素不被修改。然而,这样做可能会出奇地复杂,因为正如我们现在所见:
- 将视图声明为常量并不一定能使元素成为常量。
- 视图没有
const_iterator
成员。 - 视图尚未提供
cbegin()
和cend()
成员。 std::ranges::cbegin()
和std::cbegin()
对视图不起作用。- 将视图元素声明为常量可能没有效果。
# 并发迭代时使用常量引用
关于使用万能/转发引用的建议,有一个重要的限制:当你并发迭代视图时,不应使用它们。
考虑以下示例:
std::list<int> lst{1, 2, 3, 4, 5, 6, 7, 8};
auto v = lst | std::views::drop(2);
// 另一个线程打印视图的元素:
std::jthread printThread{[&] {
for (const auto& elem : v) {
std::cout << elem << "\n";
}
}};
// 这个线程计算元素值的总和:
auto sum = std::accumulate(v.begin(), v.end(), 0L); // 致命运行时错误
2
3
4
5
6
7
8
9
10
11
12
通过使用std::jthread
,我们启动了一个线程来迭代视图v
的元素并打印它们。同时,我们也迭代v
来计算元素值的总和。对于标准容器,仅进行读取操作的并行迭代是安全的(容器有保证,调用begin()
被视为读访问)。然而,这个保证并不适用于标准视图。由于我们可能会并发调用视图v
的begin()
,这段代码会导致未定义行为(可能出现数据竞争)。
仅进行读取操作的并发迭代函数,应该使用常量视图,将可能的运行时错误转换为编译时错误:
const auto v = lst | std::views::drop(2);
std::jthread printThread{[&] {
for (const auto& elem : v) { // 编译时错误
std::cout << elem << "\n";
}
}};
auto sum = std::accumulate(v.begin(), v.end(), 0L); // 编译时错误
2
3
4
5
6
7
8
9
# 针对视图的重载
你可能会认为,可以简单地为容器和视图重载泛型函数。对于视图,可能只需添加一个重载,对函数进行约束使其适用于视图,并按值传递参数:
void print(const auto& rg); // 用于容器
void print(std::ranges::view auto rg) // 用于视图
2
然而,当你一次按值传递,一次按引用传递时,重载决议的规则可能会变得复杂。在这种情况下,会导致歧义:
std::vector vec{1, 2, 3, 4, 5, 6, 7, 8, 9};
print(vec | std::views::take(3)); // 错误:有歧义
2
使用一个包含通用情况概念的视图概念也无济于事:
void print(const std::ranges::range auto& rg); // 用于容器
void print(std::ranges::view auto rg) // 用于视图
std::vector vec{1, 2, 3, 4, 5, 6, 7, 8, 9};
print(vec | std::views::take(3)); // 错误:有歧义
2
3
4
相反,你必须声明常量引用重载不适用于视图:
template<std::ranges::input_range T>
// 用于容器
requires (! std::ranges::view<T>)
// 且不适用于视图
void print(const T& rg);
void print(std::ranges::view auto rg) // 用于视图
std::vector vec{1, 2, 3, 4, 5, 6, 7, 8, 9};
print(vec | std::views::take(3));// 没问题
2
3
4
5
6
7
8
9
然而,请记住,复制一个视图可能会创建一个与源视图状态和行为不同的视图。因此,按值传递所有视图是否合适还有待商榷。
# 6.5.2 视图可能消除常量性(const)的传递
容器具有深度常量性。因为它们具有值语义并且拥有自己的元素,所以会将任何常量性传递给其元素。当一个容器是常量时,其元素也是常量。因此,以下代码无法编译:
template<typename T>
void modifyConstRange(const T& range) {
range.front() += 1;
}
std::array<int, 10> coll{};
...
modifyConstRange(coll);
2
3
4
5
6
7
这个编译时错误很有用,因为它有助于检测那些在运行时可能出错的代码。例如,如果你不小心使用了赋值运算符而不是比较运算符,代码将无法编译:
template<typename T>
void modifyConstRange(const T& range) {
if (range[0] = 0) {
...
}
}
std::array<int, 10> coll{};
...
modifyConstRange(coll);
2
3
4
5
6
7
8
9
10
知道元素不能被修改也有助于进行优化(比如避免备份和检查变化的需求),或者确保在多线程环境下元素访问不会因为数据竞争而导致未定义行为。
对于视图,情况更为复杂。以左值形式传递的范围的视图具有引用语义。它们引用存储在其他地方的元素。按照设计,这些视图不会将常量性传递给它们的元素。它们具有浅常量性:
std::array<int, 10> coll{};
...
std::ranges::take_view v{coll, 5};
modifyConstRange(v);
2
3
4
这几乎适用于所有基于左值创建的视图,无论它们是如何创建的:
modifyConstRange(coll | std::views::drop(5));
modifyConstRange(coll | std::views::take(5));
2
因此,你可以使用范围适配器std::views::all()
将一个容器传递给一个以常量引用形式接收范围的泛型函数:
modifyConstRange(coll);
modifyConstRange(std::views::all(coll));
2
请注意,基于右值的视图(如果有效)通常仍然会传递常量性:
readConstRange(getColl() | std::views::drop(5));
readConstRange(getColl() | std::views::take(5));
2
另外,请注意,无法使用视图修改常量容器的元素:
const std::array<int, 10> coll{};
...
auto v = std::views::take(coll, 5);
modifyConstRange(v);
2
3
4
因此,你可以在视图引用容器之前将容器设为常量,以确保一个接受视图的函数无法修改容器的元素:
std::array<int, 10> coll{};
...
std::ranges::take_view v{std::as_const(coll), 5};
modifyConstRange(v);
modifyConstRange(std::as_const(coll) | std::views::take(5));
2
3
4
5
函数std::as_const()
自C++17起在头文件<utility>
中提供。不过,在视图创建之后调用std::as_const()
没有效果(除了对于少数视图,你将无法再遍历其元素)。
C++23将引入一个辅助视图std::ranges::as_const_view
以及范围适配器std::views::as_const()
,这样你可以像下面这样简单地将视图的元素设为常量(见http://wg21.link/p2278r4):
std::views::as_const(v);
不过,再次注意这里不能忘记命名空间views
:
std::as_const(v);
命名空间std
和std::ranges
中的as_const()
函数都能使某些东西变为常量,但前者使对象变为常量,而后者使元素变为常量。
你可能想知道为什么具有引用语义的视图在设计上没有传递常量性。一种观点认为,它们在内部使用指针,因此应该像指针一样行为。然而,基于右值的视图却不是这样工作的,这就很奇怪。另一种观点认为,常量性几乎没有价值,因为通过将视图复制到一个非常量视图中,常量性很容易被消除:
void foo(const auto& rg)
{
auto rg2 = rg;
...
}
2
3
4
5
这种初始化有点像const_cast<>
的作用。然而,如果这是针对容器的泛型代码,程序员通常已经习惯不复制传递进来的集合,因为这样做开销很大。因此,现在我们又有了一个不复制传递进来的范围的好理由。而且将范围(容器和视图)声明为常量的效果会更加一致。不过,设计决策已经做出,作为程序员,你必须应对这种情况。
# 6.5.3 恢复视图的深度常量性
如我们所知,对于视图类型的范围使用const
存在两个问题:
const
可能会禁止遍历元素。const
可能不会传递到视图的元素上。
那么,显而易见的问题是,在使用视图时,如何确保代码不能修改范围中的元素。
# 使用元素时将其设为常量
确保视图元素不能被修改的唯一简单方法是在访问元素时强制其具有常量性:
- 要么在基于范围的
for
循环中使用const
:
for (const auto& elem : rg) {
...
}
2
3
- 要么通过传递转换为常量的元素:
for (auto pos = rg.begin(); pos != rg.end(); ++pos) {
elemfunc(std::as_const(*pos));
}
2
3
不过,有个坏消息:视图库的设计者计划对一些视图(比如即将推出的zip
视图)禁用声明元素为常量的效果:
for (const auto& elem : myZipView) {
elem.member = value; // 什么情况:C++23计划让这段代码编译通过
}
2
3
也许我们还能阻止这种情况。不过要注意,在使用C++标准视图时,将集合或其元素设为常量可能不起作用。
# 未提供或存在问题的const_iterator
和cbegin()
遗憾的是,通常将容器所有元素设为常量的方法对视图不起作用:
- 一般来说,视图不提供
const_iterator
来进行以下操作:
for (decltype(rg)::const_iterator pos = rg.begin();
pos != range .end(); ++pos) {
elemfunc(*pos);
}
2
3
4
因此,我们也不能简单地将整个范围进行转换:
std::ranges::subrange<decltype(rg)::const_iterator> crg{rg};
- 一般来说,视图没有成员函数
cbegin()
和cend()
来进行以下操作:
callTraditionalAlgo(rg.cbegin() , rg.cend());
你可能会提议使用自C++11起就提供的独立辅助函数std::cbegin()
和std::cend()
。引入它们是为了确保在遍历元素时元素是常量。然而,在这里情况更糟,因为std::cbegin()
和std::cend()
对于视图来说存在问题。它们的规范没有考虑到具有浅常量性的类型(在内部,它们调用常量成员函数begin()
,并且不会给值类型添加常量性)。因此,对于不传递常量性的视图,这些函数根本不起作用:
for (auto pos = std::cbegin(range); pos != std::cend(range); ++pos) {
elemfunc(*pos); // 无法为视图中的值提供常量性
}
2
3
还要注意,在使用这些辅助函数时,会存在一些与参数依赖查找(ADL)相关的问题。出于这个原因,C++20在命名空间std::ranges
中引入了范围库相应的辅助函数。不幸的是,在C++20中,std::ranges::cbegin()
和std::ranges::cend()
对于视图也存在问题(不过在C++23中会修复这个问题):
for (auto pos = std::ranges::cbegin(rg); pos != std::ranges::cend(rg); ++pos) {
elemfunc(*pos); // 糟糕:在C++20中无法为值提供常量性
}
2
3
因此,在C++20中,当试图将视图的所有元素设为常量时,我们又遇到了另一个严重的问题:
在泛型代码中使用cbegin()
、cend()
、cdata()
时要小心,因为这些函数对于某些视图不可用或存在问题。
我不知道更糟糕的是我们存在这个问题,还是C++标准委员会及其范围库小组不愿意为C++20修复这个问题。例如,我们可以在std::ranges::view_interface<>
(它是所有视图的基类)中提供const_iterator
和cbegin()
成员。有趣的是,到目前为止只有一个视图提供了const_iterator
支持:std::string_view
。具有讽刺意味的是,这或多或少是唯一一个我们不需要它的视图,因为在字符串视图中,字符始终是常量。
不过,还是有一些希望:http://wg21.link/p2278为C++23提供了一种修复这种有问题的常量性的方法(遗憾的是,对std::cbegin()
和std::cend()
不起作用,唉)。通过这种方法,在一个泛型函数内部,你可以像下面这样将元素设为常量:
void print(auto&& rgPassed) {
auto rg = std::views::as_const(rgPassed); // 确保所有元素都是常量
... // 现在使用rg而不是rgPassed
}
2
3
4
作为另一种方法,你可以这样做:
void print(R&& rg)
{
if constexpr (std::ranges::const_range<R>) {
// 传递进来的范围及其元素是常量
...
}
else {
// 在将视图及其元素设为常量后再次调用这个函数:
print(std::views::as_const(std::forward<R>(rg)));
}
}
2
3
4
5
6
7
8
9
10
11
遗憾的是,C++23仍然不会提供一种通用的方法,来为容器和视图声明一个引用参数,以保证内部元素是常量。你无法声明一个print()
函数,在其签名中保证不修改元素。
# 6.6 所有被视图破坏的容器惯用法总结
如本章所述,在使用容器时一些我们习以为常的惯用法,在视图中并不适用。以下是一个快速总结,在为容器和视图编写通用代码时,你应该始终牢记这些要点:
- 当标准视图(standard view)是常量时,可能无法遍历其元素。 因此,适用于各种范围(容器和视图)的通用代码必须将参数声明为万能引用(universal/forwarding references)。
不过,在进行并发迭代时,不要使用万能引用。在这种情况下,const
才是可靠的选择。
- 基于左值范围(lvalue range)的标准视图不会传播常量性(constness)。
这意味着将这样的视图声明为
const
,并不会将其元素也声明为const
。 - 对标准视图进行并发迭代可能会导致数据竞争(由于未定义行为而引发的运行时错误),即使只是进行读取操作。
- 读取迭代可能会影响后续的函数行为,甚至使后续的迭代失效。应按需谨慎使用标准视图。
- 复制一个视图可能会创建一个与源视图状态和行为不同的视图。应避免复制标准视图。
cbegin()
和cend()
可能无法使元素具有常量性。 在C++23中,通过提供cbegin()
和cend()
成员函数,以及修复std::ranges::cbegin()
和std::ranges::cend()
,这个问题将得到部分解决。遗憾的是,std::cbegin()
和std::cend()
仍然会存在问题。- 通常不存在
const_iterator
类型。 - 对于C++23,计划进行以下改动:对于某些标准视图,将元素声明为
const
可能不会产生任何效果。你可能可以修改视图中const
元素的成员。
这意味着标准视图并不总是一个纯粹的子集,用于限制或处理范围中的元素;它可能会提供一些在将整个范围作为一个整体使用时不被允许的选项和操作。
因此,应按需谨慎使用视图,并且不要依赖临时的常量性。坦率地说,你可能需要考虑避免使用标准视图,而是选择设计更安全的方案。
# 6.7 后记
自第一个C++标准采用标准模板库(Standard Template Library)以来,人们一直在讨论如何处理单个范围对象,而不是传递起始迭代器和结束迭代器。标准模板库引入了一对迭代器作为容器/集合的抽象,以便在算法中进行处理。Boost.Range库和Adobe Source Libraries(ASL)是早期提出具体实现库的两种尝试。
2005年,Thorsten Ottosen在http://wg21.link/n1871 (opens new window)上提出了第一个将范围纳入C++标准的提案。多年来,Eric Niebler在许多人的支持下推动这项工作向前发展。2014年,Eric Niebler、Sean Parent和Andrew Sutton在http://wg21.link/n4128 (opens new window)上提出了另一个提案(该文档包含了许多关键设计决策的基本原理)。因此,2015年10月,一份范围技术规范(Ranges Technical Specification)开始制定,起始文档为http://wg21.link/n4560 (opens new window)。
最终,Eric Niebler、Casey Carter和Christopher Di Bella在http://wg21.link/p0896r4 (opens new window)中提议将范围技术规范(Ranges TS)合并到C++标准中,范围库(ranges library)由此被正式采用。
在被采用之后,针对C++20的一些提案、论文,甚至缺陷报告对范围库,尤其是视图部分的重要内容进行了修改。例如,Barry Revzin、Tim Song和Nicolai Josuttis提出的http://wg21.link/p2210r2 (opens new window)(修复拆分视图)、http://wg21.link/p2325r3 (opens new window)(修正视图的定义)、http://wg21.link/p2415r2 (opens new window)(为右值范围引入拥有视图)和http://wg21.link/p2432r1 (opens new window)(修复输入流视图)。
此外,值得注意的是,至少部分与视图相关的常量性问题,可能会在C++23中通过http://wg21.link/p2278r4 (opens new window)得到修复。希望在C++23正式发布之前,各编译器厂商就能够提供这些修复,否则C++20代码在这里可能无法与C++23兼容。