从概念上讲,范围(Range)是一个简单的概念:它只是一对迭代器——指向序列的开始和结束(在某些情况下是一个哨兵)。然而,这样的抽象却可以从根本上改变编写算法的方式。在这篇博文中,我将向你展示 C++20 中范围库带来的一个关键变化。
通过在迭代器上使用这一抽象层,我们可以表达更多的想法,并拥有不同的计算模型。
2023 年 3 月更新:增加了 C++23 的注释。
计算模型
让我们看一个 C++ 中“常规” STL 的简单例子。
它从一个数字列表开始,跳过第一个,选择偶数,然后以相反的顺序打印它们:
#include <algorithm>
#include <vector>
#include <iostream>
int main() {
const std::vector numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto even = [](int i) { return 0 == i % 2; };
std::vector<int> temp;
std::copy_if(begin(numbers), end(numbers), std::back_inserter(temp), even);
std::vector<int> temp2(begin(temp)+1, end(temp));
for (auto iter = rbegin(temp2); iter!=rend(temp2); ++iter)
std::cout << *iter << ' ';
}
到 Compiler Explorer 上运行它。
以上代码执行以下步骤:
- 它用所有的偶数来创建 temp,
- 然后,它跳过一个元素并将所有内容复制到 temp2,
- 最后,它以相反的顺序输出 temp2 中的所有元素。
(*):对于 temp2,我们可以在最后一个元素之前停止反向迭代,但这需要首先找到最后一个元素,所以让我们坚持使用一个临时容器的简单版本…
(*):本文的早期版本包含了一个不同的示例,它跳过了前两个元素,但这不是最好的一个,我对它进行了修改(感谢各种评论)。
我特意使用名称 temp 和 temp2 来表示代码必须执行输入序列的额外副本。
现在让我们用范围(Ranges)库来重写它:
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges> // new header!
int main() {
const std::vector numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto even = [](int i) { return 0 == i % 2; };
using namespace std::views;
auto rv = reverse(drop(filter(numbers, even), 1));
for (auto& i : rv)
std::cout << i << ' ';
}
到 Compiler Explorer 上运行它。
哇!非常地 nice!
这一次,我们有一个完全不同的计算模型:我们没有创建临时对象并逐级执行算法,而是将逻辑包装到组合视图中。
在讨论代码之前,我应该引入两个基本主题,并对它们进行松散定义,以获得基本的直觉:
范围(Ranges)—— 范围是一种抽象,允许 C++ 程序对数据结构的元素进行统一操作。我们可以把它看作是对两个迭代器的泛化。最简单的范围应该定义了 begin() 和 end() 元素。有几种不同类型的范围:容器(containers)、视图(views)、大小范围(sized ranges)、假借范围(borrowed ranges)、双向范围(bidirectional ranges)、向前范围(forward ranges)等等。
容器(Container)—— 它是一个包含有元素的范围。
视图(View)—— 它是一种不拥有其
begin
/end
所指向的元素的范围。视图的创建、复制和移动成本很低。
我们的代码做以下工作(由内而外):
- 我们从 std::views::filter 开始,它额外接受一个谓词 even,
- 然后,我们添加 std::views::drop(从上一步中删除一个元素),
- 最后一个视图是在以上应用 std::reverse 视图,
- 最后一步是获取该视图并在循环中遍历它。
你能看出区别吗?
视图 rv 在创建时不做任何工作。我们只填写最后的“收据”。只有当我们迭代它时,执行才会惰性地发生。
字符串左侧除空并转大写
让我们再看一个字符串处理的例子:
下面是标准版本:
const std::string text { " Hello World" };
std::cout << std::quoted(text) << '\n';
auto firstNonSpace = std::find_if_not(text.begin(), text.end(), ::isspace);
std::string temp(firstNonSpace, text.end());
std::transform(temp.begin(), temp.end(), temp.begin(), ::toupper);
std::cout << std::quoted(temp) << '\n';
到 Compiler Explorer 上运行它。
接下来是范围库版:
const std::string text { " Hello World" };
std::cout << std::quoted(text) << '\n';
auto conv = std::views::transform(
std::views::drop_while(text, ::isspace),
::toupper
);
std::string temp(conv.begin(), conv.end());
std::cout << std::quoted(temp) << '\n';
到 Compiler Explorer 上运行它。
这次,我们将 views::drop_while 与 views::transform 组合在一起。之后,一旦视图准备好了,我们就可以迭代并构建最终的 temp 字符串。
范围视图和范围适配器对象
示例使用了来自 std::views 名称空间的视图,该名称空间定义了一组预定义的范围适配器对象。这些对象和管道操作符允许我们使用更短的语法。
根据 C++ Reference:
如果 C 是一个范围适配器对象,R 是一个 viewable_range 对象,那么这两个表达式是等价的:C(R) 和 R | C。
适配器通常创建一个 ranges::xyz_view 对象。例如,我们可以将初始示例重写为:
std::ranges::reverse_view rv{
std::ranges::drop_view {
std::ranges::filter_view{ numbers, even }, 1
}
};
代码等效于我们的 std::views::… ,但情况可能更糟,如下所示:
std::views::xyz 与 ranges::xyz_view 孰优孰劣?
我们应该使用 std::views::drop 还是显式 std::ranges::drop_view?
它们有何区别?
为了理解其中的区别,我想引用 Barry Revzin 的一篇博客文章的片段。
对比:
auto a = v | views::transform(square);
auto b = views::transform(v, square);
auto c = ranges::transform_view(v, square);
他声称 views::transform(或更通用的 views::meow)是一种面向用户的算法,应该优先于选项 c (应该考虑实现细节)。
例如,views::as_const 生成对象的 const 视图。对于 int& ,它构建了 const int& 对象的视图。但如果你传递的是已经是 const int&,那么这个视图会返回初始视图。因此 views::meow 通常更聪明,可以比 ranges::meow_view 做出更多的选择。
ranges::meow_view 唯一合理的用例是当您实现另一个自定义视图时。在这种情况下,最好直接“generate” ranges::meow。
总是更喜欢 views::meow 而不是 ranges::meow_view,除非你有非常明确的理由特别需要使用后者——这几乎肯定意味着你正在实现一个视图,而不是使用一个视图。
C++23(尚未支持)
您可能注意到,我仍然需要一个额外的步骤来从视图构建最终的字符串。这是因为范围在 C++ 20中是不完整的,我们将在 C++ 23 中得到更多方便的东西。
C++23 最突出和最方便的特性之一是 ranges::to。简而言之,我们将能够编写std::ranges::to<std::string>();因此,代码将变得更加简单:
#include <algorithm>
#include <vector>
#include <iostream>
#include <iomanip>
#include <ranges>
int main() {
const std::string text { " Hello World" };
std::cout << std::quoted(text) << '\n';
auto temp = text |
std::views::drop_while(isspace) |
std::views::transform(::toupper) |
std::ranges::to<std::string>();
std::cout << std::quoted(temp) << '\n';
}
你可以在MSVC的最新版本中尝试一下:Compiler Explorer。
现在,temp 是一个从视图创建的字符串。算法的组合和其他容器的创建将变得更加简单。
预定义的视图
下面是 C++20 中预定义视图的列表:
名称 | 含义 |
---|---|
views::all | 返回一个包含传入的 range 参数的所有元素的视图。 |
filter_view /filter | 返回满足谓词的基础序列元素的视图。 |
transform_view /transform | 在对每个元素应用转换函数后,返回底层序列的视图。 |
take_view /take | 返回来自另一个视图的前N个元素的视图,如果改编后的视图包含的元素少于N,则返回所有元素。 |
take_while_view /take_while | 给定一个一元谓词 pred 和一个视图 r,它生成一个范围 [begin(r), ranges::find_if_not(r, pred)) 的视图。 |
drop_view /drop | 返回一个从另一个视图中排除前 N 个元素的视图,如果改编后的视图包含少于 N 个元素,则返回一个空范围。 |
drop_while_view /drop_while | 给定一个一元谓词 pred 和一个视图 r,它生成一个范围 [ranges::find_if_not(r, pred), ranges::end(r)) 的视图。 |
join_view /join | 它将一个范围视图平展为一个视图 |
split_view /split | 它接受一个视图和一个分隔符,并将视图划分为分隔符上的子范围。分隔符可以是单个元素,也可以是元素的视图。 |
counted | 计数视图显示了迭代器i和非负整数 n 的计数范围([iterator.requirements.general]) i+[0, n) 的元素的视图。 |
common_view /common | 获取一个迭代器和哨兵具有不同类型的视图,并将其转换为具有相同类型迭代器和哨兵的相同元素的视图。对于调用期望范围的迭代器和哨点类型相同的遗留算法很有用。 |
reverse_view /reverse | 它采用一个双向视图并生成另一个视图,该视图以相反的顺序迭代相同的元素。 |
elements_view /elements | 它接受一个类元组值和 size_t 的视图,并生成一个值类型为已改编视图值类型的第 n 个元素的视图。 |
keys_view /keys | 获取一个类元组值的视图(例如 std::tuple 或 std::pair),并生成一个值类型为已改编视图的值类型的第一个元素的视图。它是elements_view<views::all_t<R>, 0> 的别名。 |
values_view /values | 获取一个类元组值的视图(例如std::tuple 或 std::pair),并生成一个值类型为已改编视图的值类型的第二个元素的视图。它是elements_view<views::all_t<R>, 1>的别名。 |
您可以在标准的这一部分中阅读它们的详细信息:[range.factories]
另外,从c++ 23开始,我们将新增以下视图:
名称 | 含义 |
---|---|
repeat_view /views::repeat | 由重复产生相同值的生成序列组成的视图 |
cartesian_product_view /views::cartesian_product | 一种由由n元笛卡尔积计算出的结果元组组成的视图 |
zip_view /views::zip | 由对已改编视图的相应元素的引用的元组组成的视图 |
zip_transform_view /views::zip_transform | 一种由转换函数应用到所适应视图的相应元素的结果元组组成的视图 |
adjacent_view /views::adjacent | 由对已改编视图的相邻元素的引用元组组成的视图 |
adjacent_transform_view /views::adjacent_transform | 一种视图,由转换函数应用于所适应视图的相邻元素的结果元组组成 |
join_with_view /views::join_with | 一种视图,由将范围视图平展得到的序列组成,元素之间有分隔符 |
slide_view /views::slide | 第 M 个元素是另一个视图的第 M 个到 (M + N - 1) 个元素的视图 |
ranges::chunk_view /views::chunk | 一个由另一个视图的元素组成的n个大小的不重叠连续块的视图范围 |
ranges::chunk_by_view /views::chunk_by | 将视图拆分为给定谓词返回false的每对相邻元素之间的子范围 |
ranges::as_const_view /views::as_const | 将视图转换为常量范围 |
ranges::as_rvalue_view /views::as_rvalue | 将每个元素强制转换为右值的序列视图 |
ranges::stride_view /views::stride | 由另一个视图的元素组成的视图,一次向前移动N个元素 |
详见:Ranges library (C++20) - cppreference.com
总结
在这篇博文中,我只是简单介绍了 C++20 的范围库(甚至快速浏览了 C++23)。
如你所见,这个想法很简单:将迭代器包装成一个单一的对象——一个范围,并提供一个额外的抽象层。尽管如此,就像一般的抽象一样,我们现在得到了许多新的强大的技术。计算模型因算法组合而改变。与其分步骤执行代码并创建临时容器,不如构建一个视图并执行一次。
你开始使用范围了吗?你最初的经历是什么?请在文章下方的评论中告诉我们。