65.9K
CodeProject 正在变化。 阅读更多。
Home

Range-v3 库入门

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2020年8月17日

CPOL

7分钟阅读

viewsIcon

11874

downloadIcon

106

Ranges 即将登陆 C++,而 Range-v3 库是为 C++ 标准库添加 Range 支持的提案的基础。

引言

Ranges 是对标准库的重要补充,它有可能真正改变我们在 C++ 中处理数据的方式。

range-v3 库 (https://github.com/ericniebler/range-v3) 主要由 Eric Niebler 编写,它是为 C++ 标准库添加 Range 支持的提案的基础。该库建立在标准模板库提供的迭代器和算法之上,并使它们能够组合使用——这可以说是一件大事。

将 Range 视为一对迭代器是有用的,基本上就是提供 begin()end() 函数的东西。Ranges 根据其迭代器的能力进行分类:我们可以从输入 Range 读取,向输出 Range 写入,或者两者兼而有之,具体取决于迭代器的能力。

将两个迭代器组合在一起的关键好处是,我们可以将一个完整的 Range 作为函数的结果返回,并直接将其传递给另一个函数,而无需创建局部变量来保存临时结果。

视图、操作和算法

算法、视图和操作是 range-v3 库的三大支柱。

算法与标准模板库提供的算法相同,并增加了接受 Ranges 的重载。

视图是对 Range 的可组合适应,它在迭代视图时被惰性求值,在本文中我将尝试解释这实际意味着什么。

操作是对容器的即时应用,它会就地更改容器的内容并返回容器以进行进一步处理。

在查看 Ranges 之前,我们需要一些简单易用的东西

enum class Category { First, Second, Third };

struct A
{
    Category category;
    int value;

    constexpr A( int v ) noexcept
        : category( static_cast<Category>(v%3) ),
          value( v )
    { }

    constexpr int Value( ) const noexcept
    {
        return value;
    }
};

const char* to_string( Category category )
{
    return category == Category::First ?
        "First" : category == Category::Second ? "Second" : "Third";
}

std::ostream& operator << ( std::ostream& stream, const A& a )
{
    stream << to_string( a.category ) << ' ' << a.value << ' ';
    return stream;
}

最后,是一些使用 range-v3 的代码

using namespace ranges;
void Example001a()
{
    auto items = views::iota( 0, 6 )
        | views::transform( []( int i ) { return A( i ); } )
        | to<std::vector>( )
        | actions::sort( less{}, &A::category );

    // output: [First 0 ,First 3 ,Second 1 ,Second 4 ,Third 2 ,Third 5 ]       
    std::cout << views::all( items ) << std::endl;
}

很简单,但它说明了 Ranges 的一些主要好处。

便捷性

没有 Ranges,上述内容可以这样实现:

void Example001a_( )
{
    std::vector<int> ints( 6 );
    std::iota( ints.begin( ), ints.end( ), 0 );
    std::vector<A> items;
    std::transform( ints.begin( ), ints.end( ),
                std::back_inserter( items ),
                []( auto i ) { return A( i ); } );

    std::sort( items.begin( ), items.end( ),
                []( auto& first, auto& second ) 
                { return first.category < second.category; } );

    bool isFirst = true;
    std::for_each( items.begin( ), items.end( ), [&isFirst]( auto& item )
    {
        std::cout << (isFirst ? '[' : ',');
        std::cout << item;
        isFirst = false;
    } );

    std::cout << ']' << std::endl;
}

这需要更多的工作,而且由于传递迭代器造成的混乱,可读性稍差。

可组合性

Ranges 库通过允许我们使用“|”运算符将一系列操作链接在一起,从而促进了可组合性。

auto items = views::iota( 0, 6 )
    | views::transform( []( int i ) { return A( i ); } )
    | to<std::vector>( )
    | actions::sort( less{}, &A::category );

这被称为管道,在我们这个小程序示例中,‘items’ 是通过一个包含四个步骤的管道创建的。

  1. views::iota( 0, 6 ) 是一个有界值工厂,生产范围 [0,6) 中的值。
  2. views::transform( []( int i ) { return A( i ); } ) 使用第一个步骤的输出创建 A 对象。
  3. to<std::vector>( ) 创建一个 vector,这是管道最后一步所必需的,因为操作会就地更改容器的内容。
  4. actions::sort( less{}, &A::category ) 根据 Acategory 成员对 vector 进行排序。

最后一步还演示了 Ranges 库的另一项功能,称为投影,它允许我们指定 ranges::less 应该操作哪个成员,从而无需实现谓词或 A 上的自定义比较函数——相比之下,这相当不灵活。

灵活性

Ranges 库与熟悉的基于迭代器的 STL 库一样灵活,易于与 POC 数据类型集成。

void Example001b( )
{
    int values[] = {0,1,2,3,4,5};

    auto items = values
        | views::transform( []( int i ) { return A( i ); } )
        | to<std::vector>( )
        | actions::sort( less{}, &A::category );

    std::cout << views::all( items ) << std::endl;
}

void Example001c( )
{
    int ints[] = { 0,1,2,3,4,5 };
    int* first = std::begin(ints);
    int* last = std::end( ints );

    auto items = span( first, last )
        | views::transform( []( int i ) { return A( i ); } )
        | to<std::vector>( )
        | actions::sort( less{}, &A::category );

    std::cout << views::all( items ) << std::endl;
}

容器

所有具有符合 STL 标准的 begin()end() 实现的容器都是有效的 Ranges,因此您很可能已经有很多代码可以与 Ranges 一起使用了。

视图

视图是通常操作由其他 Ranges 提供的数据的 Ranges,根据一些视图特定的算法转换数据。视图不应拥有除实现其算法所需的数据之外的任何数据。视图实现的算法应在请求视图的数据时应用,而不是在创建视图时应用,这就是我们说视图被惰性求值的原因。

通过实现一个简单的玩具视图来计算输入的平方,可以更容易地解释这一点。

视图通常由视图实现、适配器实现和适配器实现的一个实例组成。

首先,我们创建一个简单的元函数,用于确定底层 Range 的迭代器类型。

template<typename T>
using IteratorBase = decltype( std::begin( std::declval<T&>( ) ) );

然后我们创建我们的视图实现。

template <typename Rng>
class SquareView : public ranges::view_base
{
    ...
};

我们将视图实现从 ranges::view_base 派生出来。ranges::view_base 是一个空基类,其唯一目的是提供一个机制,允许其他代码块将派生类识别为视图实现。

template <typename Rng>
class SquareView : public ranges::view_base
{
private:
    using RangeType = ranges::views::all_t<Rng>;
    RangeType range_;
    ...
};

SquareView 有一个数据成员,RangeType range_,其中 RangeTyperanges::views::all_t<Rng> 的别名,它会自适应地适配 Rng 指定的 Range 类型。

如果 Rng 是一个视图,我们得到该视图的类型;如果 Rng 是一个容器,我们得到一个引用底层容器的 ranges::ref_view<>;或者我们得到一个 ranges::subrange<>。这样做是为了确保容器不被复制,并最小化复制或移动视图实例所需的工作。

为了保持简单,我们让 IteratorType 从底层 Range 的迭代器实现派生,并重用基类的预增量和后增量实现。

template <typename Rng>
class SquareView : public ranges::view_base
{
    ...

    class IteratorType : public IteratorBase<Rng>
    {
    public:
        using Base = IteratorBase<Rng>;
        using value_type = typename std::iterator_traits<Base>::value_type;

        IteratorType( ) { }
        IteratorType( const Base& other )  : Base( other ) { }

        IteratorType& operator++( )
        {
            ++static_cast<Base&>( *this );
            return *this;
        }
        IteratorType operator++( int )
        {
            return static_cast<Base&>( *this )++;
        }
        // Where the magic happens
        value_type operator*( ) const
        {
            value_type value = *static_cast<Base>( *this );
            return value * value;
        }
    };
public:
    ...
};

解引用运算符的实现是事情变得有趣的地方。

  1. 我们从底层 Range 中检索“input”值。
  2. 我们计算检索值的平方并返回结果。

这就是视图如何被惰性求值。

SquareView 的其余部分易于实现。

template <typename Rng>
class SquareView : public ranges::view_base
{
    ...
public:
    using iterator = IteratorType;
    SquareView( )
    { }

    SquareView( Rng&& range )
        : range_( ranges::views::all( static_cast<Rng&&>( range ) ) )
    { }

    iterator begin( ) const { return ranges::begin( range_ ); }

    auto end( ) const { return ranges::end( range_ ); }
};

接下来,我们需要为我们的视图实现一个适配器。

// Deduction guideline needed to help the
// compiler make the right choice for us
template <typename Rng>
SquareView( Rng&& )->SquareView<Rng>;

struct SquareFn
{
    template <typename Rng>
    auto operator()( Rng&& range ) const
    {
        return SquareView( std::forward<Rng>( range ) );
    }

    template <typename Rng>
    friend auto operator|( Rng&& range, const SquareFn& )
    {
        return SquareView( std::forward<Rng>( range ) );
    }
};

适配器实现的第一个运算符允许我们将视图视为一个函数。

auto view = Views::Square( inputValues );

而第二个运算符实现允许我们使用“|”管道符号进行组合。

auto view = inputValues | Views::Square;

最后,适配器的一个实例。

namespace Views
{
    constexpr SquareFn Square;
}

如下所示,Views::Square 是我们视图的用户界面。

using namespace ranges;

void Example001( )
{
    std::vector<int> inputValues{ 1,2,3,4,5,6,7 };

    auto view = inputValues
        | Views::Square
        | views::drop( 2 );

    int sum = 0;
    for ( auto it = view.begin( ); it != view.end( ); ++it )
    {
        sum += *it;
    }

    std::cout << "Sum: " << sum << std::endl;
}

int main()
{
    Example001( );
}

只有当我们迭代视图时,才会访问 inputValues 中的数据。

auto view = inputValues
        | Views::Square
        | views::drop( 2 );

不访问任何数据,它只指定数据将如何被访问。我们实际上是在组合一个类型。

ranges::drop_view<SquareView<std::vector<int,std::allocator<int>> &>>

view 变量实例化并操作整数 vector,让编译器为我们的视图生成高度优化的代码。

此时,我希望“视图是对 Range 的可组合适应,它在迭代视图时被惰性求值”的含义更加清晰。

我们真的需要经历这一切才能创建一个视图吗?

不,当我们需要为 range-v3 库实现类似 SquareView 的东西时,使用 ranges::view_adaptor 模板会更容易,也更好。

template<class Rng>
class SquareView : public ranges::view_adaptor<SquareView<Rng>, Rng>
{
    friend ranges::range_access;
    class adaptor : public ranges::adaptor_base
    {
    public:
        adaptor( ) = default;

        auto read( ranges::iterator_t<Rng> it ) const -> decltype( (*it) * ( *it ) )
        {
            auto value = *it;
            return value * value;
        }
    };

    adaptor begin_adaptor( ) const { return { }; }
    adaptor end_adaptor( ) const { return { }; }
public:
    SquareView( ) = default;
    SquareView( Rng&& rng )
        : SquareView::view_adaptor{ std::forward<Rng>( rng ) }
    {}
};

ranges::adaptor_base 提供了处理真实视图实现应能够处理的各种迭代器所需的所有机制,包括指针——而我们的玩具实现由于我们简化的迭代器实现而无法处理。

SquareView 的此实现是输入 Range 的输入 Range,是转发 Range 的转发 Range,依此类推;允许编译器根据迭代器的能力选择算法实现。

通常,在实现类似的东西时,我们只会使用 views::transform

auto result = inputValues
    | views::transform( []( auto value ) { return value * value; } )
    | views::drop( 2 );

性能

Range-v3 库的性能非常好。考虑以下几点:

constexpr size_t ValueCount = 1000000ULL;
constexpr size_t IterationCount = 10000ULL;

size_t RangeTest( )
{
    using namespace ranges;
    size_t result = 0;

    for ( size_t i = 0; i < IterationCount; i++ )
    {
        auto values = views::iota( 0ULL, ValueCount )
            | views::transform( []( auto value ) { return value * value; } );

        for ( auto value : values )
        {
            result += value;
        }
    }
    return result;
}

这是使用经典 STL 实现类似功能的示例:

size_t VectorTest( )
{
    std::vector<size_t> values( ValueCount );
    size_t result = 0;
    for ( size_t i = 0; i < IterationCount; i++ )
    {
        std::iota( values.begin( ), values.end( ), 0 );
        std::transform( values.begin( ), values.end( ), values.begin( ), 
            []( auto value ) { return value * value; } );
        for ( auto value : values )
        {
            result += value;
        }
    }
    return result;
}

vector 在函数开头分配,然后在每次迭代中重新使用——因此不会产生太多开销。

最后,是直接执行计算的实现。

size_t DirectTest( )
{
    size_t result = 0;
    for ( size_t i = 0; i < IterationCount; i++ )
    {
        for ( size_t j = 0; j < ValueCount; j++ )
        {
            result += j * j;
        }
    }
    return result;
}

在辅助函数(用于计时三个测试的执行)的帮助下

template <typename F>
void PerformanceOf(const char* testName, F testFunction )
{
    using Seconds = std::chrono::duration<double, std::chrono::seconds::period>;

    auto start = std::chrono::high_resolution_clock::now( );
    auto value = testFunction( );
    auto end = std::chrono::high_resolution_clock::now( );
    auto duration = Seconds( end - start );
    printf( "%s value: %llu, time: %f seconds\n", testName, value, duration.count( ) );
}   

调用

void Performance001( )
{
    PerformanceOf( "Range", RangeTest );
    PerformanceOf( "Vector", VectorTest );
    PerformanceOf( "Direct", DirectTest );
}

产生以下输出:

Range value: 12914400067280709120, time: 3.573238 seconds
Vector value: 12914400067280709120, time: 8.660025 seconds
Direct value: 12914400067280709120, time: 3.102976 seconds

DirectTest() 的性能优于其他实现不足为奇,但我认为 DirectTest()RangeTest() 之间的差距并不算大,这还是很令人印象深刻的。虽然远非对 range-v3 库进行详尽的性能测试,但它证明了通过迭代器工作几乎没有开销。

此时,查看我们的视图实现如何运行也很有趣。

重写 RangeTest() 以使用基于 ranges::view_adaptorSquareView 实现。

size_t RangeTest( )
{
    using namespace ranges;
    size_t result = 0;

    for ( size_t i = 0; i < IterationCount; i++ )
    {
        auto values = views::iota( 0ULL, ValueCount )
            | Views::Square;

        for ( auto value : values )
        {
            result += value;
        }
    }
    return result;
}

产生以下输出:

Range value: 12914400067280709120, time: 3.652167 seconds

并使用我们最初的玩具实现运行它,结果如下:

Range value: 12914400067280709120, time: 3.623685 seconds

C++ 20 Ranges:即将登陆您附近的编译器

C++ 20 Ranges 将在 Visual Studio 16.8.1 中提供——在此之前,甚至之后,range-v3 库都将是一个异常好的替代方案,已被全球数千名 C++ 程序员使用。

Range-v3 是一个设计精美的库,考虑到它在多大程度上依赖于模板元编程,它的可读性令人惊讶。

历史

  • 2020 年 8 月 18 日 - 初始帖子
© . All rights reserved.