C++20四大之Range

本文是C++20四大系列的收官之作,不少读者可能会与这样的疑问:位列四大的range是个什么特性? 笔者一开始也有同样的感觉:C++20前三大都是“划时代”的改动:module改变了C++工程的组织模型,coroutine改变了C++并发的实现、concept则是模板编程自存在以来的最大变革,range到底带来了哪些改变,可位列于四大? 因为他改变了循环的方式,或者说,他给循环提供了更高层的抽象

什么是Range?

定义了begin()、end()迭代器的就算一个Range. Range共有两大类:Container与View. Container拥有begin() 、end() 所指向的数据,而view不拥有begin() 、end()所指向的数据,view更轻量、易于拷贝、移动。

我们以这样一个操作为例,可以清楚的看清range的面貌: 1,给定一个int数组,挑出其中的偶数 2,将得到的数据平方 3,将这些偶数倒序

#include <algorithm>
#include <vector>
#include <iostream>

int main() {
    const std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    auto even = [](int i) { return 0 == i % 2; };
    auto square = [](int i) { return i * i; };
    
    std::vector<int> temp;    
    std::copy_if(begin(numbers), end(numbers), std::back_inserter(temp), even);
    std::transform(begin(temp), end(temp), begin(temp), square);
    std::vector<int> temp2(rbegin(temp), rend(temp));
    
    for (auto iter = begin(temp2); iter!=end(temp2); ++iter)
        std::cout << *iter << ' ';                                  
}

在C++20 range之前,我们需要使用copy_if、rbegin等设施来实现以上功能,同时不可避免的,需要中间变量来辅助。下面看下range的做法:

int main() {
    const std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    auto even = [](int i) { return 0 == i % 2; };
    auto square = [](int i) { return i * i; };
    
    std::ranges::reverse_view rv{ 
        std::ranges::transform_view{
            std::ranges::filter_view{ numbers, even }, square
        }
    };
    
    for (const auto& i : rv)
        std::cout << i << ' ';                            
}

我们从内往外看,range先用filter_view将偶数挑出,然后将得到的结果作为transform_view的输入,进行平方操作,最后将平方操作的结果作为reverse_view的输入,进行倒序。 就易读性而言,个人感觉第一种稍微好于第二种:第一种阅读的顺序(从上到下)与操作的逻辑顺序是一致的,第二种的阅读顺序需要由内到外,才能与逻辑顺序对上。 但是,二者的效率差异却很大:第一种不得不采用temp\temp2等中间变量来保存中间结果,作为下一步操作的输入。而range以及view完全不需要,view不拥有数据,他只是数据的“引用”。 为了缓解range在易读性上的弱势,C++20引入了管道操作符 “|” :

int main() {
    const std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    auto even = [](int i) { return 0 == i % 2; };
    auto square = [](int i) { return i * i; };
    
    namespace sv = std::views;
    auto result = numbers | sv::filter(even) | sv::transform(square) | sv::reverse;
    
    for (const auto& i : result)
        std::cout << i << ' ';                                
}

std::ranges::filter_view与std::views::filter在功能上完全一致,只不过std::views::filter可以与管道操作符 “|” 配合使用,使代码在易读性与简洁程度上飙升:我们可以从左到右阅读这几个操作: 将numbers中的数据过滤出偶数,然后平方,然后倒序。 既省略了中间变量,又有着较好的易读性,同时代码简洁程度较之前有了较大提升!

懒惰求值

上面三段代码可以让我们快速、直观的感受到range的简洁与高效,View内部只是对源数据的引用,从而避免了数据的拷贝,大大减少了辅助内存的使用。 除了使用引用,另一高效利器便是懒惰求值。 懒惰求值意味着,对view的那些操作仅在必要的时候才会执行,而不是马上全部执行。这些操作会被添加到一个pipline中。当我们遍历最view的时候才会最终执行那些操作,每个元素都会通过pipline进行传递,并返回最终结果。

标准库提供的views

views 作用描述
std::views::all 从第一个元素开始,drop指定数量的元素,然后返回剩余的元素的view
std::views::drop 从第一个元素开始,drop指定数量的元素,然后返回剩余的元素的view
std::views::drop_while 从第一个元素开始, 一直drop,直到第一个不满足指定谓词的元素,然后返回剩下的元素的view
std::views::filter 返回满足指定谓词的所有元素的view
std::views::join 相当于把二维数组串成一维数组的一个view
std::views::join_with 把二维数组串成一维数组的,并且在串联时安插指定的内容的一个view
std::views::reverse 将元素倒序的view
std::views::split 用指定的分隔符将一个view分割成多个view,并返回这些view,相当于join_with的逆操作
std::views::take 从第一个元素开始,take指定数量的元素的view
std::views::take_while 从第一个元素开始take,直到遇到第一个不满足指定谓词的元素,并返回take的元素的view
std::views::transform 返回由指定函数转换的所有元素的view
std::views::keys 采用由类似pair的值组成的view,并生成每个pair的第一个元素的view
std::views::values 采用由类似pair的值组成的view,并生成每个pair的第二个元素的view
std::views::elements 接受tuple-like数据组成的 view 和数值 N ,产生每个 tuple 的第 N 个元素的 view(相当于返回一个二维数组的第N个子数组,不过每个子数组必须是一个tuple)
std::views::zip view::elements的逆操作的view(相当于把多个子数组合并成一个二维数组)
std::views::zip_transform 相当于把多个子数组合并成一个数组,合并的方式可以是add等等随意指定
std::views::adjacent 相当于返回数组中所有连续N个值的所有组合的view
std::views::adjacent_transform 比view::adjacent更进一步,可以指定对连续的N个值的操作,例如add
std::views::slide 类似于adjacent,区别在于,adjacent只接受tuple-like的参数,且adjacent的N是编译期指定的,slide接受任何range,且N可以在运行期指定
std::views::stride 接受一个view和一个数字 n, 从第一个元素开始,每隔n个元素取一个值
std::views::chunk 接受一个view和一个数字 n, 从第一个元素开始,每n个元素作为一个块
std::views::counted 类似view::take,区别在于take只能从第一个开始,counted可以指定起始位置
std::views::common 把一个non_common_range转换为common_range,例如take_while就不是一个common_range,所以take_while的结果无法被某些算法直接使用
std::views::as_const 生成对象的 const view
std::views::as_rvalue 生成对象的 rvalue view

参考资料:

https://www.cppstories.com/2022/ranges-composition/ https://hannes.hauswedell.net/post/2019/11/30/range_intro/ https://itnext.io/c-20-ranges-complete-guide-4d26e3511db0 A beginner’s guide to C++ Ranges and Views. // Hannes Hauswedell