自学内容网 自学内容网

C++20投影、范围与视图

背景

C++迭代器模式的优点在于:
简单、健壮、高效、灵活、可维护、可调式。

但是使用迭代器对可能会导致代码冗长且笨拙。虽然这要好过直接之间处理底层数据结构,但仍然无法实现我们想要的简洁、优雅的代码。

C++20引入的范围和视图解决了该问题。它们在迭代器上方添加了额外一个强大的抽象层。这样相比于以前,能够更舒适和优雅地处理数据。

一、基于范围的算法

例如我们想对一个容器names排序,用迭代器就是:

std::sort(begin(names), end(names));

每次都需要在这种意图和一对迭代器之间进行转换, 这很快就让人厌倦,并且会导致冗长且笨拙的代码。

迭代器的功能非常强大,但它们太过于具体化,太低级了。

因此,在C++20中,std命名空间中的大部分算法std::ranges命名空间中都有对应的模板,这样就可以直接使用范围来代替迭代器对。

上面的写法,通过范围,可以用更优雅的方式重写:

std::ranges::sort(names);

有效的范围包括:容器、静态大小的数组、字符串、字符串视图、std::span<>等。

基本上任何支持begin()end()的东西。基于范围的算法在内部使用的仍然是迭代器。但作为标准库的用户,不会再直接处理迭代器。

也正应该如此:一个好的、易用的API应该隐藏了实现细节(例如迭代器)。

与迭代器一样,可以有多重范围:前向范围、双向范围、随机访问范围,等等。这些类别通常镜像了底层迭代器。但仅在阅读基于范围的算法的规范时,这种区别才显得重要。
例如std::ranges::sort()仅用于随机访问范围,因此不能将这个基于范围的算法引用于std::list()

二、投影

在介绍视图(view)前,先简单介绍一下许多基于范围的算法的一个额外功能——投影(projection)。

std命名空间中相对应的算法不同,一些std::ranges算法支持一种额外的功能:投影。

假定我们想要对一个Box序列排序,并且想按照它们的高度而不是体积进行排序。在C++17中通过如下语句完成:

std::sort(begin(boxes), end(boxes),
[](const Box& one, const Box& other) {
return one.getHeight() < other.getHeight();
}
);

而在C++20中,可以用下面的语句:

std::ranges::sort(boxes, std::less<>{},
[](const Box& box) {
return box.getHeight();
}
);

在把元素传递给比较函数之前,先将其传递给投影函数进行。
在本实例中,在将Box传递给泛型std::less<>{}函子之前,投影函数会将所有的Box转换为对应的高度值。

换言之,std::less<>{}函子总是会接收两个double类型的值,它并不知道我们实际上是在对Box进行排序,而不是对double类型的值进行排序。

可选的投影参数甚至可以是指向(无参数)成员函数的指针,或者是指向(公共)成员变量的指针。这种纯粹优雅的方式最好用一个示例来演示:

std::ranges::sort(boxes, std::less<>{}, &Box::getHeight);
// 或者:
std::ranges::sort(boxes, std::less<>{}, &Box::m_height); //此处m_height应该是public的

当调用每个对象的成员函数,或者从每个对象读取给定成员变量的值时,就会用到投影功能。

三、视图

代码冗长不是传统的基于迭代器对的算法的唯一缺点,它们也不能很好地组合。

假定我们有一个Box的容器boxes,想要获取boxes中大到能够容纳指定体积required_volumn的所有Box的指针。

在标准库中,std::transform()算法可将某类型的元素(Box)的一个范围转换为另一类型元素(Box *)的一个范围。

但令人惊讶的是,并不存在仅转换元素子集的transform_if()。因此在C++17中,完成这样一个任务至少需要如下两个步骤:

std::vector<Box *> box_pointers;
std::transform (
std::begin (boxes),
std::end (boxes),
std::back_inserter (box_pointers),
[] (Box &box) { return &box;}
);

std::vector<Box *> large_boxes;
std::copy_if (
std::begin (box_pointers),
std::end (box_pointers),
std::back_inserter (large_boxes),
[=] (const Box *box) { return *box >= required_volume; }
);
  1. 首先要将boxes转换成Box*指针,然后仅复制那些指向足够大的Box的指针。
    • 显然,这里为完成这个简单的任务编写了太多代码。
    • 而且,这些代码的性能达不到期望:如果大部分Box不能容纳required_volume,那么将所有的Box*指针首先存放到一个临时变量中显然是一种浪费。
  2. 即使获取Box的存储地址仍然没有太大的开销,但一般情况下,转换函数可能有很大的开销。将其应用于所有元素可能较低效。

为了解决这些问题,我们首先可能想要过滤出不相关的对象,之后仅转换符合条件的一些Box
在C++17中,为了使用算法完成该任务,必须借助于更高级的中介容易,如std::vector<reference_wrapper<Box>>

这里不会介绍这类容器,但是要明确一点:将算法组合起来很快就会变得冗长和笨拙。

这些需要将几个算法步骤组合起来的问题会经常出现。使用C++20中的基于范围的算法,可以有效地解决这些问题,甚至可采用多种方法解决它们。这要归功于一个强大的概念:视图。

四、视图与范围

视图与范围是两个类似的概念。实际上,每个视图就是一个范围,但并非所有的范围都是视图。
在视图中移动、析构和复制(如果可以的话)元素的开销与其中元素的数量无关,因此这个开销几乎可以忽略不计。

例如,容器是一范围,但它不是视图,容器中的元素越多,复制和销毁元素的开销就越高。

std::string_viewstd:span<>就是视图概念的实现。创建和复制这些类型的对象几乎是没有开销的,不管底层范围有多大。但是视图仍然相当直观:它们只是以相同的顺序重复与底层范围相同的元素,完全不做修改。

<ranges>模块提供的视图要强大得多。

例如,当通过一个transform_view查看一个Box范围时,可能会看到一个高度体积、Box*指针的范围;
当通过filter_view查看一个Box范围时,则看到的Box可能一下子少了很多,可能只会看待大Box、立方形Box

视图允许改变后续算法步骤看待给定范围的方式、看到这个范围的哪些部分以及/或者查看这些部分的顺序。

例如,创建transform_viewfilter_view

auto volumes_view = std::ranges::transform_view{
        boxes,
        [](const Box &box) {
            return box.volume();
        }
};

auto big_box_view = std::ranges::filter_view{
    boxes,
    [](const Box& box){
        return box >= required_volume;
    }
};

与任何范围一样,我们可以通过迭代器遍历视图的元素,既可以调用begin()end()来显式遍历,也可以通过基于范围的for循环隐式遍历。

for(auto iter{volumes_view.begin()};iter != volumes_view.end(); ++iter) {/*...*/}
for(const Box& box : big_box_view) {/*...*/}

这里的要点是,创建这些视图是几乎没有开销的(时间或空间开销),这与有多少个Box无关。

创建transform_view不会转换任何元素:只有当解引用该视图的迭代器时才会进行转换。类似地,创建filter_view并不会进行任何过滤;只有当递增视图的迭代器时才会进行过滤。用技术术语来说,视图及其元素通常是延迟(或按需)生成的。

1. 范围适配器

在实践中,通常不会像前一节那样。使用构造函数直接创建这些视图。相反,我们大部分时候会结合使用std::ranges::views命名空间中的范围适配器与重载的按位运算符|

一般来说,下面的两个表达式是等效的:

std::ranges::xxx_view { range, args } /* View constructor */
range | std::ranges::views::xxx(args) /* Range adaptor + overloaded | operator */

因为std::ranges::views读起来不太容易,使用namespace简化后:

using namespace std::ranges::views;

auto volumes_view = boxes | transform([](const Box& box){ return box.volume(); });
auto big_box_view = boxes | filter([=](const Box& box){ return box >= required_volume; });

这种表示法的好处是,可以将|运算符连接起来,组成多个视图。

例如,通过使用范围适配器,很容易解决“收集所有指向足够大Box的指针”的问题。

从现在开始,我们假定添加了using namespace std::ranges::views

std::ranges:copy(
boxes | filter([=](const Box& box){ return box >= required_volume; })
  | transform([=](Box& box){ return &box; }),
  back_inserter(large_boxes)
);

可以看出在转换前进行过滤容易多了。
还可以根据需要交换filter()transform()适配器的顺序。

注意:适配器被称为管道,在此上下文中,|常被称为管道字符或管道运算符。这里的|表示的法类似于大部分Unix shell中的用法。

使用基于范围的算法和视图适配器解决之前问题的完整代码:

std::ranges:copy_if( /* Transform using adaptor before filtering in copy_if() */
boxes | transform([](Box& box){ return &box; }), // Input view of boxes
back_inserter(large_boxes), // Output iterator
[=](const Box* box){ return *box >= required_volume; } // Condition for copy_if()
);

std::ranges::transform(/* Filter using adaptor before transforming using algorithm */
boxes | filter([=](const Box& box){ return box >= required_volume; }),
back_inserter(large_boxes), // Output iterator
[](Box& box){ return &box; } // Transform functor of transform()
);

提示:类似于基于范围的算法中的投影参数,transform()filter()这样的范围适配器也接收成员指针作为输入。假设Box::isCube()是一个返回布尔值的成员寒素,Box::m_height是一个公共成员变量(正常情况下不应该是公共的),那么下面的管道将生成一个Box范围内所有立方体的高度的视图:

boxes | filter(&Box::isCube) | transform(&Box::m_height)

2. 将范围转换为容器

对于前面小节中的例子,可能认为下面注释掉的能够工作:

auto range = boxes | filter([=](const Box& box){ return box >= required_volume; })
   | transform([](Box& box){ return &box; });
std::vector<Box*> large_boxes;
// large_boxes = range;
// large_boxes.assign(range);
// std::set<Box*> large_box_set{ range };

但其实它们不能工作。标准库并没有提供特别优雅的语法将范围转换为容器。

就现在而言,需要依赖于容器的基于迭代器对的API:

large_boxes.assign(begin(range), end(range));
std::set<Box*> large_box_set{ range.begin(), range.end() };

3. 范围工厂

除了范围适配器,<ranges>模块还提供了所谓的范围工厂。顾名思义,范围工厂不是适配给定的范围,而是生成一个新的范围。

示例:

#include <iostream>
#include <ranges>

namespace view = std::ranges::views;

bool isEven(int i) {
    return i % 2 == 0;
}

int squared(int i) {
    return i * i;
}

int main() {
    for (int i: view::iota(1, 10))//Lazily generate range(1,10)
        std::cout << i << ' ';

    std::cout << std::endl;

    for (int i: view::iota(1, 1000)
                | view::filter(isEven)
                | view::transform(squared)
                | view::drop(2)
                | view::take(5)
                | view::reverse)
        std::cout << i << ' ';
    std::cout << std::endl;
}

输出结果:

1 2 3 4 5 6 7 8 9
196 144 100 64 36

调用std::ranges:view::iota(from, to)工厂函数会构造一个iota_view,就好像是由std::ranges::iota_view{from, to}构造的。这个视图代表一个范围,该范围在概念上包含从[from, to)的数字。

与前面一样,创建iota_view是没有开销的。即,它并不会实际分配或者填充任何范围。相反,在迭代视图是时,才会延迟生成数字、

第一个循环只是简单地打印出一个小iota()范围的内容。而在第二个循环中:

  1. filter()transform()前面已经介绍过;
  2. drop(n)生成一个drop_view,它删除一个范围内的前n个元素
    • 此例中drop(2)将删除元素4和16,前两个偶数的平方。
  3. take(n)生成一个take_view ,它保存给定范围的前n个元素,丢弃剩余的元素。
    • 此例中take(5)间丢弃256及更大的平方数。
  4. reverse生成的视图将翻转给定范围。

4. 通过视图写入

只要视图(或者任何范围)基于非const迭代器,解引用其迭代器就将得到左值(lvalue)引用。

例如,在下面的程序中,使用filter_view对给定范围内的所有偶数求平方:

#include <iostream>
#include <vector>
#include <ranges>

bool isEven(int i) { return i % 2 == 0; }

int main() {

    std::vector<int> numbers{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    for (int &i: numbers | std::ranges::views::filter(isEven)) {
        i *= i;
    }

    for (int i: numbers) {
        std::cout << i << ' ';
    }

    std::cout << std::endl;
}


如果在numbers定义前加上const,则for循环中的复合赋值将无法通过编译。

如果将numbers替换为std::ranges::views::iota(1, 11),也将无法通过编译,因为std::ranges::views::iota(1, 11)是一个只读视图(这个视图是动态生成的),然后被丢弃,所以写入该视图没有意义。


参考书籍:《C++20实践入门第6版》Ivor Horton


原文地址:https://blog.csdn.net/qq_51470638/article/details/142846491

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!