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

背景

C++迭代器模式的优点在于:

简单、健壮、高效、灵活、可维护、可调式。

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

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

一、基于范围的算法

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

cpp 复制代码
std::sort(begin(names), end(names));

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

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

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

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

cpp 复制代码
std::ranges::sort(names);

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

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

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

与迭代器一样,可以有多重范围:前向范围、双向范围、随机访问范围,等等。这些类别通常镜像了底层迭代器。但仅在阅读基于范围的算法的规范时,这种区别才显得重要。

例如std::ranges::sort()仅用于随机访问范围,因此不能将这个基于范围的算法引用于std::list()

二、投影

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

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

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

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

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

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

在把元素传递给比较函数之前,先将其传递给投影函数进行。

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

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

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

cpp 复制代码
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中,完成这样一个任务至少需要如下两个步骤:

cpp 复制代码
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

cpp 复制代码
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循环隐式遍历。

cpp 复制代码
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命名空间中的范围适配器与重载的按位运算符|

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

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

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

cpp 复制代码
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

cpp 复制代码
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中的用法。

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

cpp 复制代码
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. 将范围转换为容器

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

cpp 复制代码
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:

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

3. 范围工厂

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

示例:

cpp 复制代码
#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;
}

输出结果:

cpp 复制代码
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对给定范围内的所有偶数求平方:

cpp 复制代码
#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

相关推荐
Oneforlove_twoforjob7 分钟前
【Java基础面试题033】Java泛型的作用是什么?
java·开发语言
向宇it24 分钟前
【从零开始入门unity游戏开发之——C#篇24】C#面向对象继承——万物之父(object)、装箱和拆箱、sealed 密封类
java·开发语言·unity·c#·游戏引擎
小蜗牛慢慢爬行26 分钟前
Hibernate、JPA、Spring DATA JPA、Hibernate 代理和架构
java·架构·hibernate
星河梦瑾1 小时前
SpringBoot相关漏洞学习资料
java·经验分享·spring boot·安全
黄名富1 小时前
Redis 附加功能(二)— 自动过期、流水线与事务及Lua脚本
java·数据库·redis·lua
love静思冥想1 小时前
JMeter 使用详解
java·jmeter
言、雲1 小时前
从tryLock()源码来出发,解析Redisson的重试机制和看门狗机制
java·开发语言·数据库
TT哇2 小时前
【数据结构练习题】链表与LinkedList
java·数据结构·链表
java1234_小锋2 小时前
JVM对象分配内存如何保证线程安全?
jvm
Yvemil72 小时前
《开启微服务之旅:Spring Boot 从入门到实践》(三)
java