背景
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; }
);
- 首先要将
boxes
转换成Box*
指针,然后仅复制那些指向足够大的Box
的指针。- 显然,这里为完成这个简单的任务编写了太多代码。
- 而且,这些代码的性能达不到期望:如果大部分
Box
不能容纳required_volume
,那么将所有的Box*
指针首先存放到一个临时变量中显然是一种浪费。
- 即使获取
Box
的存储地址仍然没有太大的开销,但一般情况下,转换函数可能有很大的开销。将其应用于所有元素可能较低效。
为了解决这些问题,我们首先可能想要过滤出不相关的对象,之后仅转换符合条件的一些Box
。
在C++17中,为了使用算法完成该任务,必须借助于更高级的中介容易,如std::vector<reference_wrapper<Box>>
。
这里不会介绍这类容器,但是要明确一点:将算法组合起来很快就会变得冗长和笨拙。
这些需要将几个算法步骤组合起来的问题会经常出现。使用C++20中的基于范围的算法,可以有效地解决这些问题,甚至可采用多种方法解决它们。这要归功于一个强大的概念:视图。
四、视图与范围
视图与范围是两个类似的概念。实际上,每个视图就是一个范围,但并非所有的范围都是视图。
在视图中移动、析构和复制(如果可以的话)元素的开销与其中元素的数量无关,因此这个开销几乎可以忽略不计。
例如,容器是一范围,但它不是视图,容器中的元素越多,复制和销毁元素的开销就越高。
std::string_view
和std:span<>
就是视图概念的实现。创建和复制这些类型的对象几乎是没有开销的,不管底层范围有多大。但是视图仍然相当直观:它们只是以相同的顺序重复与底层范围相同的元素,完全不做修改。
<ranges>
模块提供的视图要强大得多。
例如,当通过一个transform_view
查看一个Box
范围时,可能会看到一个高度体积、Box*
指针的范围;
当通过filter_view
查看一个Box
范围时,则看到的Box
可能一下子少了很多,可能只会看待大Box
、立方形Box
。
视图允许改变后续算法步骤看待给定范围的方式、看到这个范围的哪些部分以及/或者查看这些部分的顺序。
例如,创建transform_view
和filter_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()
范围的内容。而在第二个循环中:
filter()
和transform()
前面已经介绍过;drop(n)
生成一个drop_view
,它删除一个范围内的前n个元素- 此例中
drop(2)
将删除元素4和16,前两个偶数的平方。
- 此例中
take(n)
生成一个take_view
,它保存给定范围的前n个元素,丢弃剩余的元素。- 此例中
take(5)
间丢弃256及更大的平方数。
- 此例中
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