29 | Ranges:无迭代器的迭代和更方便的组合

今天,我们继续上一讲开始的话题,讨论 ranges(范围)。

Ranges 简介

像下面这样的代码:

#include <algorithm>
#include <iostream>
#include <iterator>

int main()
{
  using namespace std;
  int a[] = {1, 7, 3, 6,
             5, 2, 4, 8};
  copy(begin(a), end(a),
       ostream_iterator<int>(
         std::cout, " "));
  std::cout << std::endl;
  sort(begin(a), end(a));
  copy(begin(a), end(a),
       ostream_iterator<int>(
         std::cout, " "));
  std::cout << std::endl;
}

你应该已经见到过好多次了。有没有觉得这个代码有点重复、有点无聊呢?尤其是里面的 begin 和 end?

很多人都留意到了迭代器虽然灵活,但不是一个足够高级的抽象——尤其是我们已经对 C 数组都可以进行基于“范围”的循环之后。如果我们把数组看作一个抽象的“范围”,我们就可以得到下面的代码:

#include <experimental/ranges/algorithm>
#include <experimental/ranges/iterator>
#include <iostream>

int main()
{
  using namespace std::
    experimental::ranges;
  int a[] = {1, 7, 3, 6,
             5, 2, 4, 8};
  copy(a, ostream_iterator<int>(
            std::cout, " "));
  std::cout << std::endl;
  sort(a);
  copy(a, ostream_iterator<int>(
            std::cout, " "));
  std::cout << std::endl;
}

这是真正可以编译的代码,用我们上一讲讲过的环境——最新版的 MSVC(编译命令行上需要额外加 /permissive- 选项)或 GCC 7+——都可以。不过,这一次即使最新版的 MSVC 也不能靠编译器本身支持 ranges 库的所有特性了:在两种环境下我们都必须使用 CMCSTL2 ,也只能(在 C++20 之前临时)使用 std::experimental::ranges 而不是 std::ranges。注意我只引入了 ranges 名空间,而没有引入 std 名空间,这是因为 copy、sort 等名称同时出现在了这两个名空间里,同时引入两个名空间会在使用 sort 等名字时导致冲突。

这个程序的输出,当然是毫不意外的:

1 7 3 6 5 2 4 8

1 2 3 4 5 6 7 8

下面我们看“视图”。比如下面的代码展示了一个反转的视图:

#include <experimental/ranges/algorithm>
#include <experimental/ranges/iterator>
#include <experimental/ranges/ranges>
#include <iostream>

int main()
{
  using namespace std::
    experimental::ranges;
  int a[] = {1, 7, 3, 6,
             5, 2, 4, 8};
  copy(a, ostream_iterator<int>(
            std::cout, " "));
  std::cout << std::endl;
  auto r = reverse_view(a);
  copy(r, ostream_iterator<int>(
            std::cout, " "));
  std::cout << std::endl;
}

这个程序的输出是:

1 7 3 6 5 2 4 8

8 4 2 5 6 3 7 1

为什么 r 是视图,而不是反向复制出的内容?我们可以在输出 r 之前(15 行之后,16 行之前)插入下面这行:

  a[0] = 9;

我们可以看到最后那行输出变成了:

8 4 2 5 6 3 7 9

这就证明了,r 没有复制 a 的内容。

视图的大小也不一定跟原先的“范围”一样。下面是我们在[第 17 讲] 讨论过的过滤视图在 ranges 里的实现的用法:

  auto r =
    filter_view(a, [](int i) {
      return i % 2 == 0;
    });

拿这个来替换上面用到 reverse_view 的那行,我们就能得到:

6 2 4 8

这些视图还能进行组合:我们可以写 reverse_view(filter_view(…))。不过,在组合的情况下,下面这样的写法(使用 | 和视图适配器)可能更清晰些:

  auto r = a |
           views::filter([](int i) {
             return i % 2 == 0;
           }) |
           views::reverse;

这个程序的执行结果是:

8 4 2 6

如果你用过 Unix 的管道符,你一定会觉得这种写法非常自然、容易组合吧……

范围相关的概念

整个 ranges 库是基于概念来定义的。下面这张图展示了 range 相关的概念:

从图的右下角,我们可以看到上一讲讨论过的几个概念,包括 copyable 和 semiregular。再往上,我们看到了 view——视图——也看到了视图是一个 range。现在我们就先来看一下 range 和 view 的定义。

在 CMCSTL2 里,range 是这样定义的:

template <class T>
concept _RangeImpl =
  requires(T&& t) {
    begin(static_cast<T&&>(t));
    end(static_cast<T&&>(t));
  };

template<class T>
concept range = _RangeImpl<T&>;

换句话说,一个 range 允许执行 begin 和 end 操作(注意这是在 ranges 名空间下的 begin 和 end,和 std 下的有些小区别)。所以,一个数组,一个容器,通常也能当作一个 range。

我们已经提到了视图,我们接下来就看一下 view 的定义:

template <class T>
concept view =
  range<T> &&
  semiregular<T> &&
  enable_view<__uncvref<T>>;

可以看到,view 首先是一个 range,其次它是 semiregular,也就是,可以被移动和复制(对 range 没有这个要求)。然后 enable_view 是个实现提供的概念,它的实际要求就是,视图应该不是一个容器,可以在 O(1) 复杂度完成拷贝或移动操作。我们常用的 string 满足 range,不满足 view;而 string_view 则同时满足 range 和 view。

下面,我们看 common_range,它的意思是这是个普通的 range,对其应用 begin() 和 end(),结果是同一类型:

template <class T>
concept common_range =
  range<T> &&
  same_as<iterator_t<T>,
          sentinel_t<T>>;

然后,sized_range 的意思就是这个 range 是有大小的,可以取出其大小(注意我们刚才的 filter_view 就是没有大小的):

template <class T>
concept sized_range =
  range<T> &&
  requires(T& r) { size(r); };

自然,output_range 的意思是这个 range 的迭代器满足输出迭代器的条件:

template <class R, class T>
concept output_range =
  range<R> &&
  output_iterator<iterator_t<R>, T>;

当然,input_range 的意思是这个 range 的迭代器满足输入迭代器的条件:

template <class T>
concept input_range =
  range<T> &&
  input_iterator<iterator_t<T>>;

再往上的这些概念,我想我就不用再啰嗦了……

Sentinel

我估计其他概念你理解起来应该问题不大,但 common_range 也许会让有些人迷糊:什么样的 range 会不是 common_range 呢?

答案是,有些 range 的结束点,不是固定的位置,而是某个条件:如遇到 0,或者某个谓词满足了 10 次之后……从 C++17 开始,基于范围的 for 循环也接受 begin 和 end 的结果不是同一类型了——我们把前者返回的结果类型叫 iterator(迭代器),而把后者返回的结果类型叫 sentinel(标记)。

下面展示了一个实际的例子:

#include <experimental/ranges/algorithm>
#include <experimental/ranges/iterator>
#include <iostream>

using namespace std::experimental::
  ranges;

struct null_sentinel {};

template <input_iterator I>
bool operator==(I i, null_sentinel)
{
  return *i == 0;
}

template <input_iterator I>
bool operator==(null_sentinel, I i)
{
  return *i == 0;
}

template <input_iterator I>
bool operator!=(I i, null_sentinel)
{
  return *i != 0;
}

template <input_iterator I>
bool operator!=(null_sentinel, I i)
{
  return *i != 0;
}

int main(int argc, char* argv[])
{
  if (argc != 2) {
    std::cout << "Please provide "
                 "an argument!"
              << std::endl;
    return 1;
  }
  for_each(argv[1], null_sentinel(),
           [](char ch) {
             std::cout << ch;
           });
  std::cout << std::endl;
}

在这个程序里,null_sentinel 就是一个“空值标记”。这个类型存在的唯一意义,就是允许 == 和 != 根据重载规则做一些特殊的事情:在这里,就是判断当前迭代器指向的位置是否为 0。上面程序的执行结果是把命令行上传入的第一个参数输出到终端上。

概念测试

我们现在对概念来做一下检查,看看常用的一些容器和视图满足哪些 ranges 里的概念。

这张表里没有什么意外的东西。除了 view,vector 满足所有的 range 概念。另外,const vector <int>不能满足 output_range,不能往里写内容,也一切正常。

这张表,同样表达了我们已知的事实:list 不满足 random_access_range 和 contiguous_range。

这张表,说明了从 range 的角度,C 数组和 vector 是没啥区别的。

这张就有点意思了,展示了反转视图的特点。我们可以看到它几乎和原始容器可满足的概念一样,就多了 view,少了 contiguous_range。应该没有让你感到意外的内容吧。

但过滤视图就不一样了:我们不能预知元素的数量,所以它不能满足 sized_range。

我们前面说过,istream_line_reader 的迭代器是输入迭代器,所以它也只能是个 input_range。我们在设计上对 begin() 和 end 的返回值采用了相同的类型,因此它仍是个 common_range。用 take_view 可以取一个范围的前若干项,它就不是一个 commom_range 了。因为输入可能在到达预定项数之前结束,所以它也不是 sized_range。

我们再来介绍一个新的视图,iota_view。它代表一个从某个数开始的递增序列。单参数的 iota_view 是无穷序列,双参数的是有限序列,从它们能满足的概念上就能看出来。这儿比较有趣的事实是,虽然 iota_view(0, 5) 和 iota_view(0) | take(5) 的结果相同,都是序列 {0, 1, 2, 3, 4},但编译器看起来,前者比后者要多满足两个概念。这应该也不难理解。

抽象和性能

说了这么多,你可能还是有点好奇,那 ranges 的用途是什么呢?为了少写 begin() 和 end()?为了方便函数式编程?

当然,上面的说法都对,但最基本的目的,还是为了抽象和表达能力。我们可以看一眼下面的 Python 代码:

reduce(lambda x, y: x + y,
       map(lambda x: x * x, range(1, 101)))

这个表达式C++ 里我们该怎么做呢?当然,手工循环是可以的:

auto square = [](int x) {
  return x * x;
};

int sum = 0;
for (int i = 1; i < 101; ++i) {
  sum += square(i);
}

比起 Python 的代码来,似乎上面这个写法有点啰嗦?我们试试使用 ranges:

int sum = nvwa::reduce(
  std::plus<int>(),
  views::iota(1, 101) |
    views::transform(
      [](int x) { return x * x; }));

我不知道你喜不喜欢上面这个表达方式,但它至少能在单个表达式里完成同样的功能。唯一遗憾的是,标准算法 accumulate 或 reduce 在上面不可用(没有针对 ranges 的改造),我只好拿我的非标 reduce  来凑凑数了。

同样重要的是,上面的代码性能很高……多高呢?看下面这行汇编输出的代码就知道了:

movl  $338350, -4(%rbp)

ranges 名空间

我们现在再来看一下 ranges 名空间(我们目前代码里的 std::experimental::ranges,C++20 的 std::ranges)。这个名空间有 ranges 特有的内容:

视图(如 reverse_view)和视图适配器(如 views::reverse)

ranges 相关的概念(如 range、view 等)

但也有些名称是从 std 名空间“复制”过来的,包括:

标准算法(如 copy、transform、sort、all_of、for_each 等;但是,如前面所说,没有 accumulate 或 reduce)

begin 和 end

std::copy 接受的是迭代器,而 ranges::copy 接受的是范围,似乎还有点道理。那 begin 和 end 呢?本来接受的参数就是一个范围啊……

Eric Niebler(Ranges TS 的作者)引入 ranges::begin 的目的是解决下面的代码可能产生的问题(他的例子 ): 

extern std::vector<int> get_data();
auto it = std::begin(get_data());
int i = *it; // BOOM

注意在读取 *it 的时候,get_data() 返回的 vector 已经被销毁了——所以这个读取操作是未定义行为(undefined behavior)。

Eric Niebler 和 Casey Carter(CMCSTL2 的主要作者)使用了一个特殊的技巧,把 begin 和 end 实现成了有特殊约束的函数对象,使得下面这样的代码无法通过编译:

extern std::vector<int> get_data();
auto it = ranges::begin(get_data());
int i = *it; // BOOM

如果你对此有兴趣的话,可以看一下 CMCSTL2 里的 include/stl2/detail/range/access.hpp。对一般的用户而言,记住 ranges::begin 和 ranges::end 是将来 std::begin 和 std::end 的更好的替代品就行了。

一点历史

对于标准算法里的迭代器的问题早就有人看到了,并且有不少人提出了改进的方案。最早在 2003 年,Boost.Range 就已经出现(但影响似乎不大)。Andrei Alexandresu 在 2009 年发了一篇很有影响力的文章,“Iterators must go” ,讨论迭代器的问题,及他在 D 语言里实现 ranges 的经验,但在 C++ 界没有开花结果。Eric Niebler 在 2013 年开始了 range-v3 的工作,这才是目前的 ranges 的基础。他把 ranges 写成了一个标准提案 ,并在 2017 年被 ISO 出版成为正式的 Ranges TS。2018 年末,好消息传来,C++ 委员会通过了决议,Ranges 正式被并入了 C++20 的草案!

谁说程序员都是无趣的?这篇内容申请把 Ranges 并入 C++ 标准草案的纯技术文档 The One Ranges Proposal ,开头绝对是激情四射啊。

批评和未来

如果我只说好的方面、问题一点不说,对于学习道路上的你,也不是件好事。最有名的对 C++ Ranges 的批评,就是 Unity 开发者 Aras Pranckevičius 发表的一篇文章 。我不完全认同文中的观点,但我觉得读一下反面的意见也很重要。

此外,C++20 里的 ranges 不是一个概念的终点。即便在 range-v3 库里,也有很多东西仍然没有进入 C++ 标准。比如,看一眼下面的代码:

#include <iostream>
#include <string>
#include <vector>
#include <range/v3/all.hpp>

int main()
{
  std::vector<int> vd{1, 7, 3, 6,
                      5, 2, 4, 8};
  std::vector<std::string> vs{
    "one",  "seven", "three",
    "six",  "five",  "two",
    "four", "eight"};
  auto v =
    ranges::views::zip(vd, vs);
  ranges::sort(v);
  for (auto i : vs) {
    std::cout << i << std::endl;
  }
}

上面的代码展示了标准 ranges 中还没有的 zip 视图,并且,zip 视图的结果还可以被排序,结果将使得原始的两个 vector 都重新排序。上述程序的运行结果是:

one

two

three

four

five

six

seven

eight

这个非标的 range-v3 库的另外一个好处是,它不依赖于概念的支持,因而可以用在更多的环境中,包括目前还不支持概念的 Clang。

如果你希望自己尝试一下这个代码的话,需要在命令行上使用 -I 选项来包含 range-v3 的 include 目录,此外 MSVC 还需要几个特殊选项:

cl /EHsc /std:c++latest /permissive- /experimental:preprocessor …

内容小结

本讲讨论了 C++20 的又一重要特性 ranges。虽然这一特性比起 concepts 来争议要多,但无疑它展示了 C++ 语言的一些新的可能性,并可以产生非常紧凑的高性能代码。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
第一篇 预备知识 第1章 C++编程技术 2 1.1 类和对象 2 1.2 类的继承 5 1.3 函数重载 5 1.4 访问控制 7 1.5 操作符重载 8 1.6 显式类型转换 9 1.7 异常处理 13 1.8 名字空间 17 1.9 友员函数 20 1.10 内联函数 21 1.11 静态成员 22 1.12 本章小结 23 第2章 C++模板技术 25 2.1 函数模板 25 2.2 类模板 27 2.3 模板完全特化 28 2.4 函数模板重载 30 2.5 类模板继承 30 2.6 本章小结 31 第3章 C++ I/O流技术 32 3.1 I/O流类 32 3.2 标准输入输出 34 3.3 文件输入输出 36 3.4 流的格式控制 41 3.5 本章小结 45 第二篇 C++ STL泛化技术基础 第4章 C++ STL泛型库概述 48 4.1 C++ STL的发展历程 48 4.2 C++ STL的各种实现版本 49 4.2.1 HP STL 49 4.2.2 SGI STL 50 4.2.3 STLport 50 4.2.4 P.J.Plauger STL 50 4.2.5 Rouge Wave STL 50 4.3 C++ STL的Visual C++编译 50 4.4 C++ STL的体系结构 52 4.4.1 容器(Container) 52 4.4.2 迭代器(Iterator) 53 4.4.3 算法(Algorithm) 53 4.4.4 函数对象(Function Object) 54 4.4.5 适配器(Adapter) 55 4.4.6 内存分配器(Allocator) 56 4.4.7 概念(Concept)和模型(Model) 56 4.5 C++ STL存在的一些问题 57 4.6 本章小结 57 第5章 C++ STL泛化技术分析 58 5.1 算法和迭代器 58 5.1.1 算法 58 5.1.2 迭代器 61 5.1.3 函数对象 65 5.1.4 适配器 68 5.2 内存分配器和容器 74 5.2.1 内存分配器 75 5.2.2 容器 77 5.3 概念 82 5.3.1 基础性概念 82 5.3.2 容器概念 84 5.3.3 迭代器概念 86 5.3.4 函数对象概念 88 5.4 本章小结 89 第三篇 C++ STL容器技术 第6章 vector向量容器 92 6.1 vector技术原理 92 6.2 vector应用基础 94 6.3 本章小结 101 第7章 deque双端队列容器 102 7.1 deque技术原理 102 7.2 deque应用基础 108 7.3 本章小结 115 第8章 list双向链表容器 116 8.1 list技术原理 116 8.2 list应用基础 124 8.3 本章小结 131 第9章 slist单向链表容器 132 9.1 slist技术原理 132 9.2 slist应用基础 140 9.3 本章小结 148 第10章 bit_vector位向量容器 149 10.1 bit_vector技术原理 149 10.2 bit_vector应用基础 156 10.3 本章小结 161 第11章 set集合容器 162 11.1 set技术原理 162 11.2 set应用基础 181 11.3 本章小结 186 第12章 multiset多重集合容器 187 12.1 multiset技术原理 187 12.2 multiset应用基础 190 12.3 本章小结 196 第13章 map映照容器 197 13.1 map技术原理 197 13.2 map应用基础 200 13.3 本章小结 206 第14章 multimap多重映照容器 207 14.1 multimap技术原理 207 14.2 multimap应用基础 210 14.3 本章小结 216 第15章 hash_set哈希集合容器 217 15.1 hash_set技术原理 217 15.2 hash_set应用基础 230 15.3 本章小结 234 第16章 hash_map哈希映照容器 235 16.1 hash_map技术原理 235 16.2 hash_map应用基础 237 16.3 本章小结 242 第17章 string基本字符序列容器 243 17.1 string技术原理 243 17.2 string应用基础 258 17.3 本章小结 264 第18章 stack堆栈容器 265 18.1 stack技术原理 265 18.2 stack应用基础 266 18.3 本章小结 269 第19章 queue队列容器 270 19.1 queue技术原理 270 19.2 queue应用基础 271 19.3 本章小结 274 第20章 priority_queue优先队列容器 275 20.1 priority_queue技术原理 275 20.2 priority_queue应用基础 278 20.3 本章小结 281 第四篇 C++ STL算法技术 第21章 非变易算法 284 21.1 逐个容器元素for_each 284 21.2 查找容器元素find 285 21.3 条件查找容器元素find_if 286 21.4 邻近查找容器元素adjacent_find 287 21.5 范围查找容器元素find_first_of 289 21.6 统计等于某值的容器元素个数count 290 21.7 条件统计容器元素个数count_if 291 21.8 元素不匹配查找mismatch 293 21.9 元素相等判断equal 295 21.10 子序列搜索search 296 21.11 重复元素子序列搜索search_n 299 21.12 最后一个子序列搜索find_end 301 21.13 本章小结 303 第22章 变易算法 304 22.1 元素复制copy 304 22.2 反向复制copy_backward 305 22.3 元素交换swap 306 22.4 迭代器交换iter_swap 307 22.5 区间元素交换swap_ranges 308 22.6 元素变换transform 309 22.7 替换 310 22.8 条件替换replace_if 311 22.9 替换和复制replace_copy 312 22.10 条件替换和复制replace_copy_if 313 22.11 填充fill 314 22.12 n次填充fill_n 315 22.13 随机生成元素generate 316 22.14 随机生成n个元素generate_n 317 22.15 移除复制remove_copy 318 22.16 条件移除复制remove_copy_if 319 22.17 移除remove 320 22.18 条件移除remove_if 321 22.19 不连续重复元素复制unique_copy 322 22.20 剔除连续重复元素unique 324 22.21 元素反向reverse 325 22.22 反向复制reverse_copy 326 22.23 旋转rotate 327 22.24 旋转复制rotate_copy 329 22.25 随机抖动random_shuffle 330 22.26 随机采样random_sample 331 22.27 容器分割partition 333 22.28 容器稳定分割stable_partition 335 22.29 本章小结 338 第23章 排序算法 339 23.1 元素入堆push_heap 339 23.2 创建堆make_heap 343 23.3 元素出堆pop_heap 348 23.4 堆排序sort_heap 351 23.5 是否为堆is_heap 352 23.6 局部排序partial_sort 354 23.7 局部排序复制partial_sort_copy 356 23.8 排序sort 359 23.9 归并merge 366 23.10 内部归并inplace_merge 368 23.11 稳定排序stable_sort 376 23.12 是否排序is_sorted 383 23.13 第n个元素nth_element 384 23.14 下确界lower_bound 386 23.15 上确界upper_bound 388 23.16 等价区间equal_range 390 23.17 折半搜索binary_search 392 23.18 子集合includes 393 23.19 集合求并set_union 394 23.20 集合求交set_ intersection 396 23.21 集合求差set_difference 398 23.22 集合求异set_symmetric_difference 399 23.23 最小值min 401 23.24 最大值max 402 23.25 最小元素min_element 403 23.26 最大元素max_element 404 23.27 字典比较lexicographical_compare 405 23.28 下一排列组合next_permutation 406 23.29 上一排列组合prev_permutation 409 23.30 本章小结 411 第24章 数值算法 412 24.1 递增赋值iota 412 24.2 元素求和accumulate 413 24.3 两序列元素内积inner_product 414 24.4 部分元素求和partial_sum 415 24.5 相邻元素求差adjacent_difference 417 24.6 n次方计算power 419 24.7 本章小结 421 第五篇 C++ STL迭代器技术 第25章 输入输出流迭代器 424 25.1 输入流迭代器 424 25.2 输出流迭代器 426 25.3 本章小结 427 第26章 插入/反向/存储迭代器 428 26.1 向前插入迭代器 428 26.2 向后插入迭代器 429 26.3 插入迭代器 431 26.4 反向迭代器 432 26.5 反向双向迭代器 434 26.6 原始存储迭代器 435 26.7 本章小结 437 附录 STL版权说明 438

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值