跳转至

42 当代C++标准库里的范围和视图

你好,我是吴咏炜。

第 29 讲我介绍过范围(ranges),但当时范围才刚刚标准化,主流编译器对它的支持还比较弱,最佳实践也不够多。今天这讲内容,可以看作是第 29 讲的更新和补充,重点放在为你介绍范围库提供的好用功能上。

基本概念

在第 29 讲我介绍过范围库的基本概念,尤其是:

  • range(范围)
  • view(视图)

让我们来回忆一下。

范围

对于范围,标准库里定义了 std::ranges::range 概念。从语法上来说,一个范围需要支持 std::ranges::beginstd::ranges::end 操作。跟 std::beginstd::end 相比,ranges 名空间下的这两个函数通过特殊的技巧做到了以下两点:

  • 要求实参要么是个左值,要么通过特化之类的技巧声明自己是一个 borrowed_range(被借用的范围)。一个 borrowed_range 的迭代器可以独立于该范围本身存在,因此使用它的右值作为 ranges 下的 beginend 的实参也是合法的。除此之外,传递右值会导致编译错误,因为结果迭代器有悬空的风险。
  • 能进行实参依赖查找,即对用户的名空间(如 my)里的对象类型(如 Obj),使用 std::ranges::begin(obj) 能找到对象所在名空间里的 begin 函数(如 my::begin(Obj&))。

第 7 讲我讨论过一个读文件的工具 istream_line_reader,它的完整版本(在 nvwa 库 [1] 里)里就有声明自己是 borrowed_range 的代码:

#if HAVE_CXX20_RANGES
#include <ranges>

template <>
inline constexpr bool std::ranges::
  enable_borrowed_range<
    NVWA::istream_line_reader> =
    true;
#endif

这是因为 istream_line_reader 对象只是一个方便配合范围相关算法使用的对象,使用它的迭代器时并不要求生成迭代器的 istream_line_reader 对象也存在。

上面讨论了语法,下面再讨论一下语义。

首先,显然 ranges 里的 beginend 返回的迭代器(be)能够表达一个范围,[b, e) 应当组成一个半闭半开区间。

其次,beginend 这两个操作都应当具有分摊常数时间复杂度,并且操作不会通过影响相等性判断的方式更改被操作对象。后一点意味着,如果 r1r2 相等,那执行 begin(r1) 之后,r1r2 仍应该相等。

最后,如果 begin 返回的是前向迭代器的话,那这个操作也需要满足“保持相等性”。也就是说,对同一个对象调用多次 begin 返回相等的结果,意味着前向迭代器可以重复遍历这个对象。

视图

类似地,我们有 std::ranges::view 概念,它在 range 的基础上提出了进一步的要求,并且这些要求目前跟我在第 29 讲里写的已有所不同。最早对 view 的额外要求是([2]):

  • 它的移动、拷贝和析构操作的时间复杂度应为 $O(1)$。
  • 它可以被默认构造。

后来人们慢慢发现,这些要求不必要地高了,并在 C++20 正式标准及后续的错误修正([3] 和 [4])里进行了修改。目前的额外要求是:

  • 它可以被移动,时间复杂度为 $O(1)$。
  • 如果它可以被拷贝,时间复杂度应为 $O(1)$(否则应当不提供相应的拷贝构造/赋值操作)。
  • 被移动过的视图对象的析构时间复杂度应为 $O(1)$(即在执行 v1 = std::move(v2); 后,v2 的析构应在常数时间完成)。

也就是说,放宽了对拷贝和析构的要求,并取消了可以默认构造的要求。

实际来说,之前对拷贝的要求和对默认构造的要求也确实给我的实现造成了麻烦,尤其前者是我当初在 nvwa 库里区分 mmap_line_readermmap_line_view 的主因。而目前,mmap_line_reader 实际上也已经是一个 view 了(但它只支持移动,不支持拷贝)。

范围算法

在第 29 讲我简单地讨论过范围算法。它让你可以把以前使用一对迭代器传参的地方改成传递一个范围,从而简化代码。也就是说,你可以把下面的代码:

std::vector<int> v{
  {1, 7, 3, 6, 5, 2, 4, 8}};
std::sort(v.begin(), v.end());

简化为:

std::vector<int> v{
  {1, 7, 3, 6, 5, 2, 4, 8}};
std::ranges::sort(v);

sort 在内的很多范围算法还会允许使用一个投影参数——这个我之前没有讨论。这个投影参数可以是函数对象、成员函数指针或数据成员指针,可以简化一些操作。比如,对于下面的数组:

using MyPair = pair<int, string>;
MyPair a[]{{1, "one"},
           {2, "two"},
           {3, "three"},
           {4, "four"}};

如果你想使用 MyPair 的第二项来进行排序 ,传统的 std::sort 会要求使用一个特殊的 lambda 表达式来比较两个 MyPair。而如果使用投影的话,代码就会简单上很多:

ranges::sort(a, less{},
             &MyPair::second);

我之前也提到过,标准算法 accumulatereduce 没有针对 ranges 的改造。这个问题在 C++23 得到了弥补——现在我们有了 fold_leftfold_right 等支持范围的“折叠”算法。下面的代码在 C++23 下可以编译通过:

constexpr int sum =
  ranges::fold_left(
    views::iota(1, 101) |
      views::transform([](int x) {
        return x * x;
      }),
    0, plus<int>{});

跟第 29 讲里的代码一样,上面的代码可以在编译期计算出 $12+22+32+\dots+1002$ 的结果,但只使用了标准库提供的功能。

实用视图

在第 29 讲我已经展示过下面几个视图:

  • views::reverse
  • views::filter
  • views::transform

下面我会再向你介绍几个其他常用视图。

引用视图和拥有视图

管道运算符和视图的构造函数一般都需要使用视图作为参数,但无论按照哪种视图的定义,容器都不是视图——它的拷贝构造和拷贝赋值是深拷贝,不能在常数时间里完成。为了统一起见,视图的构造函数里通常会通过使用 views::all 把入参转成视图。

当你传递给 views::all 一个视图时,结果是原视图类型的纯右值。而当你传递给 views::all 一个容器左值时,你就得到了一个 ref_view(引用视图)。对于下面的代码:

vector v{1, 2, 3, 4, 5};
auto vw1 = v | views::reverse;

如果你使用第 24 讲介绍的 Boost.TypeIndex,你就会发现,这里 vw1 的类型是:

std::ranges::reverse_view<std::ranges::ref_view<std::vector<int>>>

这是标准库在构造 reverse_view 时自动会做的动作。

再想象一下下面的代码:

auto vw2 = vector{1, 2, 3, 4, 5} |
           views::reverse;

这样的代码安全吗?临时对象会在这行代码执行完成后就销毁吗?

事实上,由于较早的 view 的定义,以及对悬空引用的担心,上面的代码起初不被认作是合法的 C++20 代码。以 GCC 为例,到 GCC 11 为止的各个 GCC 版本都无法编译这样的代码。但从 GCC 12 开始,上面这样的代码就能编译通过了。这也是 C++ 标准进行了修正,并将修正追溯性地应用到 C++20 上的结果。

目前,当你传递给 views::all 的是一个容器右值时,你就得到了一个 owning_view(拥有视图)。对于上面的代码,vw2 的类型是:

std::ranges::reverse_view<std::ranges::owning_view<std::vector<int>>>

这个 owning_view 延长了 vector 的生存期,使其变得跟视图变量 vw2 一样长。

序列生成视图

如果你想要生成自增序列,是不需要手工写循环的。C++11 提供了往容器填充自增序列的 iota 算法,而 C++20 又提供了能自动生成序列的 iota_view 视图。它们的用法可以直接看代码:

vector<int> v(5);
iota(v.begin(), v.end(), 1);
cout << v << '\n';
cout << views::iota(1, 6) << '\n';
for (auto i : views::iota(6)) {
  if (i == 10) break;
  cout << i << ' ';
}
cout << '\n';

输出是:

{ 1, 2, 3, 4, 5 }
{ 1, 2, 3, 4, 5 }
6 7 8 9

取用和丢弃视图

下面这四个视图我放在一起讨论:

  • 取用视图(views::take):由一个视图的前 n 个元素组成的视图。
  • 条件取用视图(views::take_while):只要条件满足,即一直返回视图中的元素;条件不满足即终止。
  • 丢弃视图(views::drop):跟 take 相反,丢弃一个视图的前 n 个元素后剩下的元素组成的视图。
  • 条件丢弃视图(views::drop_while):跟 take_while 相反,由视图中首个不满足条件的元素开始的视图。

它们的具体用法,看下面的代码就十分清晰了:

auto seq = views::iota(0, 10);
cout << (seq | views::take(5))
     << '\n';
cout << (seq | views::take_while(
                 [&](int i) {
                   return i < 5;
                 }))
     << '\n';
cout << (seq | views::drop(5))
     << '\n';
cout << (seq | views::drop_while(
                 [&](int i) {
                   return i < 5;
                 }))
     << '\n';

输出是:

{ 0, 1, 2, 3, 4 }
{ 0, 1, 2, 3, 4 }
{ 5, 6, 7, 8, 9 }
{ 5, 6, 7, 8, 9 }

分割视图

C++ 在 20 标准之前没有提供方便的字符串分割功能,这是一个经常被人诟病的问题。不过,到了 C++20,这个问题算是终于解决了。我们有了一种简单且通用的方式来分割字符串。

假设你有下面的对象:

auto input = "192.168.1.0"sv;

那该如何从“.”的位置上分割开?

下面的代码就可以:

auto split_result =
  views::split(input, "."sv);

不过,因为这是一个通用的、不仅限于 stringstring_view 的分割功能,split_result 里的“元素”的类型不是 stringstring_view,而是 ranges::subrange 的某种特化。subrangestring_view 行为很相似,但它不是 string_view。你仍需要手工构造出 string_view 才能进行输出:

for (auto item : split_result) {
  cout << string_view(item.data(),
                      item.size())
       << ' ';
}

注意本节的 views::split 代码需要一个较新的、支持 P2210 [5] 修改的编译器,如 GCC 12、Clang 17 及更新版本。最早颁布的 C++20 标准里的 views::split 并不支持本节里的用法。

到了 C++23,你可以直接用 string_view(item) 来构造出 string_view,并且,你还可以更方便地把 split_result 转成像 vector 这样的容器,方便以传统的方式进行处理。示例代码如下:

auto numerals =
  split_result |
  views::transform([](auto v) {
    int i = 0;
    std::from_chars(
      v.data(), v.data() + v.size(),
      i);
    return i;
  }) |
  ranges::to<std::vector>();

这里,代码首先把 subrange 的范围转换成数字序列(使用 C++17 提供的 from_chars 函数模板,注意没有错误处理),然后代码使用了 ranges::to 把结果转成一个 vector。注意这里你不需要(虽然可以)写 vector<int>

虽然我自己在 nvwa 里已经实现了 split 功能(接口因为针对字符串类型还更好用些),但通用性上标准库确实更强——尤其是配合管道的组合使用。

连接视图

跟分割相反的操作,就是连接了。类似地,作为一个通用的连接操作,C++ 里的 join 并不及 Python 里的字符串 join 操作那么方便——使用 ".".join(["192", "168", "1", "0"]) 可以直接得到 "192.168.1.0"——但我们仍可以设法得到类似的结果。

简单起见,下面的代码使用了 C++23 的 ranges::to 函数模板:

vector input{192, 168, 1, 0};
auto s =
  input |
  views::transform(
    [first = true](int x) mutable {
      if (first) {
        first = false;
        return to_string(x);
      } else {
        string result{"."};
        result += to_string(x);
        return result;
      }
    }) |
  views::join |
  ranges::to<string>();
cout << s << '\n';

我首先进行 transform,把数字转换成字符串,并且,除首项外在前面拼接了一个“.”;然后把它们 join 起来;最后,把这个结果的字符视图变成一个 string。结果自然就是:

192.168.1.0

注意:因为视图是延迟求值的,所以如果你没有把它转成 string 的话,连续进行两次 join | transform 的结果将会不同——第二次求值时 first 已经是 false,因此结果视图的第一个字符将会变成 '.'

管道顺序和性能

我刚才已经说过管道是延迟求值的,这里再强调一下:管道的顺序很重要。

假设已经有下面这样的视图:

/* 一个有 4 项元素的双向范围 */ |
views::reverse |
views::filter() |
views::values

对其进行遍历时,reverse 直接作用在一个双向范围上。后续过滤之后取 get<1>,过滤表达式会求值 4 次,没有什么额外的开销。如果你把 filter 放到 reverse 前面,那由于一些实现上的细节原因,过滤表达式就会求值 8 次了!

类似地,一旦过滤成功,值往下一步传时,会重新从 filter 之前的数据来源取值。如果你在 filter 之前有 transform 的话,transform 此时就会重复执行。下面的代码展示了这个问题:

MyPair a[]{{1, "one"},
           {2, "two"},
           {3, "three"},
           {4, "four"}};
int tf_count{};
cout << (a |
         views::transform(
           [&tf_count](
             const auto& pr) {
             ++tf_count;
             return pr.first;
           }) |
         views::filter([](int num) {
           return num % 2 == 0;
         }))
     << '\n';
cout << tf_count
     << " transformations are made"
        "\n";

输出结果是:

{ 2, 4 }
6 transformations are made

如果映射操作较为复杂的话,这可能会是个问题。你可以尝试改造一下代码,把 filter 放到 transform 前面,应该就可以只做 2 次映射操作了。

对于常用的视图,你应当记住:如果有 reverse 的话,应该放在管道的最前面;有 filter 的话,应该尽量靠前放,但不要放到 reverse 之前。对于复杂的组合,如果有较高性能需求的话,建议你自己测试一下。

内容小结

在本讲里,我进一步讲解了范围库的一些功能:

  • 重新回顾“范围”和“视图”的概念,尤其是当前 C++ 版本里的“视图”,其定义跟 One Ranges Proposal 时相比已经有较大变化。
  • 简单介绍范围算法,描述了投影参数和 C++23 的折叠算法。
  • 描述了一些实用的视图及其实际使用场景,并对管道顺序带来的性能问题进行了说明。

课后思考

如果不使用 C++23 的 ranges::to 的话,文中的示例代码该怎么写?如果要对 from_chars 的结果判断是否有非法字符出现,代码又该怎么写?

欢迎留言和我分享你的想法和疑问。如果读完这篇文章有所收获,也欢迎分享给你的朋友。

参考资料

[1] 吴咏炜, nvwa. https://github.com/adah1972/nvwa/

[2] Eric Niebler, Casey Carter, and Christopher Di Bella, “The one ranges proposal”. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0896r4.pdf

[3] Barry Revzin, “Views should not be required to be default constructible”. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p2325r3.html

[4] Barry Revzin and Tim Song, “What is a view?”. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p2415r2.html

[5] Barry Revzin, “Superior string splitting”. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p2210r2.html

精选留言(1)
  • 天亮了 👍(1) 💬(1)

    能否把原课程一些过时的工程实践做个翻新,比如 C++ Rest SDK,早就不维护了。内容方面,也补充一些工厂程实战的最佳实践篇章,比如 C++代码项目组织布局、C++ 常见 API 设计最佳实践等。

    2025-02-21