Skip to content

Latest commit

 

History

History
329 lines (226 loc) · 15.4 KB

File metadata and controls

329 lines (226 loc) · 15.4 KB

十四、使用范围库的延迟求值

我们在本书中详细讨论了如何从功能的角度思考,以及功能链接和组合如何帮助创建模块化和可组合的设计。然而,我们遇到了一个问题——在我们当前的方法中,大量数据需要从一个集合复制到另一个集合。

幸运的是,埃里克·尼布勒自己开发了一个库,该库支持纯函数编程语言的解决方案——延迟求值。这个名为范围的库随后被正式接受为 C++ 20 标准。在本章中,我们将看到如何利用它。

本章将涵盖以下主题:

  • 为什么以及什么时候延迟求值是有用的
  • 范围库简介
  • 如何使用范围库使用惰性计算

技术要求

您将需要一个支持 C++ 17 的编译器。我用的是 GCC 7.4.0。

代码可以在的 GitHub 上找到。com/ PacktPublishing/动手-函数-用- Cpp 编程Chapter14文件夹中的。它包括并使用doctest,这是一个单头开源单元测试库。你可以在它的 GitHub 资源库上找到它。com/ onqtam/ doctest

范围库概述

范围库为 C++ 程序员提供了各种有用的新工具。所有这些都是有用的,但是许多对于我们的函数式编程需求来说尤其有用。

但是首先,让我们看看如何设置它。要在 C++ 17 中使用范围库,您需要使用来自https://ericniebler.github.io/range-v3/的指令。然后,你只需要包含all.hpp头文件:

#include <range/v3/all.hpp>

至于 C++ 20,您只需要包含<ranges>头,因为标准中包含了库:

#include <ranges>

但是,如果您在尝试前一行代码时遇到编译错误,不要感到惊讶。在撰写本文时,g++ 的最新版本是 9.1,但是 ranges 库还没有包含在标准中。由于其规模,实现预计会很晚。在此之前,如果你想尝试,你仍然可以使用埃里克·尼布勒的版本。

那么,靶场图书馆提供什么?嗯,这一切都是从范围的概念开始的。范围由开始迭代器和结束迭代器组成。首先,这允许我们在现有集合的基础上添加一个范围。然后,我们可以将一个范围传递给一个需要开始和结束迭代器的算法(如transformsortaccumulate,从而消除对begin()end()的不方便的调用。

有了范围,我们可以构建视图。视图指定我们对通过两个迭代器的部分或全部集合感兴趣,但也允许延迟求值和可组合性。由于视图只是集合顶部的轻量级包装器,我们可以声明一个操作链,而无需实际执行它们,直到需要结果。我们将在下一节详细了解这是如何工作的,但这里有一个简单的例子,它由两个操作组成,这两个操作将首先过滤所有偶数的*,然后过滤 3* 的倍数的数字,从而过滤集合中所有六的倍数:

numbers | ranges::view::filter(isEven) | ranges::view::filter(isMultipleOf3)

在行动的帮助下,突变在范围上也是可能的。操作类似于视图,除了它们就地改变底层容器而不是创建副本。正如我们之前多次讨论过的,我们更喜欢在函数式编程中不变异数据;但是,有些情况下我们可以用这个解决方案优化性能,所以值得一提。这里有一个动作的例子...在行动中:

numbers |= action::sort | action::take(5);

|运算符对于函数程序员来说非常有趣,因为它是一种函数组合运算符。对于非常习惯于合成操作的 Unix/Linux 用户来说,使用也是很自然的。正如我们在第四章功能组合的思想中所看到的,这样的操作符会非常有用。不幸的是,它还不支持任何两个功能的组合——只有视图和操作。

最后,范围库支持自定义视图。这打开了诸如数据生成的可能性,这对于许多事情都是有用的,但是第 11 章、*基于属性的测试、*尤其有用。

让我们通过示例更详细地了解范围库的特性。

懒惰评价

在过去的章节中,我们已经看到了如何通过利用数据结构上的小转换,以功能的方式来构造代码。让我们举一个简单的例子——计算列表中所有偶数的总和。结构化编程方法是编写一个循环,遍历整个结构并添加所有均匀的元素:

int sumOfEvenNumbersStructured(const list<int>& numbers){
    int sum = 0;
    for(auto number : numbers){
        if(number % 2 == 0) sum += number;
    }
    return sum;
};

这个函数的测试在一个简单的例子中正确运行:

TEST_CASE("Run events and get the user store"){
    list<int> numbers{1, 2, 5, 6, 10, 12, 17, 25};

    CHECK_EQ(30, sumOfEvenNumbersStructured(numbers));
}

当然,这种方法会变异数据,我们已经看到这并不总是一个好主意。它同时也做了太多的事情。我们宁愿合成更多的函数。需要的第一个函数决定一个数是否为偶数:

auto isEven = [](const auto number){
    return number % 2 == 0;
};

第二个从集合中挑选满足谓词的数字:

auto pickNumbers  = [](const auto& numbers, auto predicate){
    list<int> pickedNumbers;
    copy_if(numbers.begin(), numbers.end(), 
        back_inserter(pickedNumbers), predicate);
    return pickedNumbers;
};

第三种方法计算集合中所有元素的总和:

auto sum = [](const auto& numbers){
    return accumulate(numbers.begin(), numbers.end(), 0);
};

这就引出了最终的实现,它包含了所有这些功能:

auto sumOfEvenNumbersFunctional = [](const auto& numbers){
    return sum(pickNumbers(numbers, isEven));
};

然后它通过测试,就像结构化解决方案一样:

TEST_CASE("Run events and get the user store"){
    list<int> numbers{1, 2, 5, 6, 10, 12, 17, 25};

    CHECK_EQ(30, sumOfEvenNumbersStructured(numbers));
    CHECK_EQ(30, sumOfEvenNumbersFunctional(numbers));
}

函数式解决方案有明显的优势——它很简单,由可以重组的小函数组成,并且是不可变的,这也意味着它可以并行运行。然而,它确实有一个缺点——它复制数据。

我们在第 10 章、 性能优化中已经看到了如何处理这个问题,但事实是最简单的解决方法就是懒评价。想象一下,如果我们可以链接函数调用,但是代码直到我们需要它的结果时才会真正执行,这将意味着什么。这个解决方案提供了编写我们需要的代码的可能性,以及我们需要它的方式,编译器最大限度地优化了函数链。

这就是范围库正在做的事情和其他事情。

使用范围库的延迟求值

靶场图书馆提供了一个名为“T2”的设施。视图允许从迭代器构建不可变且廉价的数据范围。他们不复制数据,只是参考数据。我们可以使用view来过滤我们集合中的所有偶数:

ranges::view::filter(numbers, isEven)

不需要任何复制,使用合成操作符|,就可以合成视图。例如,我们可以通过组成两个过滤器来获得可被6整除的数字列表:第一个过滤器是关于偶数的,第二个过滤器是关于可被3整除的数字的。给定一个检查一个数是否是3倍数的新谓词,我们使用以下内容:

auto isMultipleOf3 = [](const auto number){
    return number % 3 == 0;
};

我们通过以下组合获得可被6整除的数字列表:

numbers | ranges::view::filter(isEven) | ranges::view::filter(isMultipleOf3)

需要注意的是,在编写这段代码时,实际上没有计算任何东西。视图已初始化,正在等待命令。因此,让我们计算视图中元素的总和:

auto sumOfEvenNumbersLazy = [](const auto& numbers){
    return ranges::accumulate(ranges::view::
        filter(numbers, isEven), 0);
};
TEST_CASE("Run events and get the user store"){
    list<int> numbers{1, 2, 5, 6, 10, 12, 17, 25};

    CHECK_EQ(30, sumOfEvenNumbersLazy(numbers));
}

ranges::accumulate函数是一个特殊的累积实现,它知道如何处理视图。只有在调用accumulate时,视图才是代理的;此外,实际上没有复制任何数据——取而代之的是,范围使用智能迭代器来计算结果。

让我们也看看组合视图的结果。不出所料,向量中所有可被6整除的数之和为18:

auto sumOfMultiplesOf6 = [](const auto& numbers){
    return ranges::accumulate(
            numbers | ranges::view::filter(isEven) | 
                ranges::view::filter(isMultipleOf3), 0);
};
TEST_CASE("Run events and get the user store"){
    list<int> numbers{1, 2, 5, 6, 10, 12, 17, 25};

    CHECK_EQ(18, sumOfMultiplesOf6(numbers));
}

多好的编写代码的方法啊!它比前面的两个选项都容易得多,同时内存占用也很低。

但这并不是所有的范围都能做到的。

随着行动而变化

除了视图,范围库还提供操作。行动允许急切的、易变的操作。例如,要对同一向量中的值进行排序,我们可以使用以下语法:

TEST_CASE("Sort numbers"){
    vector<int> numbers{1, 12, 5, 20, 2, 10, 17, 25, 4};
    vector<int> expected{1, 2, 4, 5, 10, 12, 17, 20, 25};

    numbers |= ranges::action::sort;

    CHECK_EQ(expected, numbers);
}

|=运算符类似于ranges::action::sort(numbers)调用,将向量排序到位。动作也是可组合的,或者通过直接的方法调用,或者通过|操作符。这允许我们编写代码,通过组合sortunique操作以及|操作,对容器中的唯一项目进行分类和保存:

TEST_CASE("Sort numbers and pick unique"){
    vector<int> numbers{1, 1, 12, 5, 20, 2, 10, 17, 25, 4};
    vector<int> expected{1, 2, 4, 5, 10, 12, 17, 20, 25};

    numbers |= ranges::action::sort | ranges::action::unique;

    CHECK_EQ(expected, numbers);
}

然而,这并不是 ranges 所能做的一切。

无穷级数和数据生成

由于视图是延迟求值的,它们允许我们创建无限系列。例如,要生成一系列整数,我们可以使用view::ints函数。然后,我们需要限制系列,这样我们就可以使用view::take来保留系列的前五个元素:

TEST_CASE("Infinite series"){
    vector<int> values = ranges::view::ints(1) | ranges::view::take(5);
    vector<int> expected{1, 2, 3, 4, 5};

    CHECK_EQ(expected, values);
}

对于任何允许增量的类型,可以使用view::iota进行额外的数据生成,例如chars:

TEST_CASE("Infinite series"){
    vector<char> values = ranges::view::iota('a') | 
        ranges::view::take(5);
    vector<char> expected{'a', 'b', 'c', 'd', 'e'};

    CHECK_EQ(expected, values);
}

此外,您可以使用linear_distribute视图生成线性分布的值。给定一个值区间和要包括在线性分布中的多个项目,视图包括两个区间边界,以及来自区间内部的足够的值。例如,从[ 110 ]区间取 5 个线性分布的值,得出值{1, 3, 5, 7, 10}:

TEST_CASE("Linear distributed"){
    vector<int> values = ranges::view::linear_distribute(1, 10, 5);
    vector<int> expected{1, 3, 5, 7, 10};

    CHECK_EQ(expected, values);
}

如果我们需要更复杂的数据生成器呢?幸运的是,我们可以创建自定义范围。假设我们想从1开始创建2的每十分之一次方的列表(即21T7】211T11】221T15】等等)。我们可以通过转换调用来做到这一点;但是,我们也可以使用yield_if功能结合for_each视图来实现这一点。下面代码中的粗体行向您展示了如何将这两者结合使用:

TEST_CASE("Custom generation"){
    using namespace ranges;
    vector<long> expected{ 2, 2048, 2097152, 2147483648 };

 auto everyTenthPowerOfTwo = view::ints(1) | view::for_each([](int 
        i){ return yield_if(i % 10 == 1, pow(2, i)); });
    vector<long> values = everyTenthPowerOfTwo | view::take(4);

    CHECK_EQ(expected, values);
}

我们首先从1开始生成一个无限长的整数序列。然后,对于它们中的每一个,我们检查除以10的值是否有余数1。如果是这样,我们将2恢复到该功率。为了得到一个有限向量,我们把前面的无穷级数输入到take视图中,该视图只保留前四个元素。

当然,这种类型的生成并不是最优的。每一个有用的数字,我们都需要访问10,最好从一个范围开始,到11121等等。

这里值得一提的是,编写这段代码的替代方法是使用 stride 视图。视图从一个系列中获取每 n 个元素,正如我们需要的那样。结合transform视图,我们可以获得完全相同的结果:

TEST_CASE("Custom generation"){
    using namespace ranges;
    vector<long> expected{ 2, 2048, 2097152, 2147483648 };

 auto everyTenthPowerOfTwo = view::ints(1) | view::stride(10) | 
        view::transform([](int i){ return pow(2, i); });
    vector<long> values = everyTenthPowerOfTwo | view::take(4);

    CHECK_EQ(expected, values);
}

到目前为止,您可能已经意识到数据生成对于测试非常有趣,尤其是基于属性的测试(正如我们在第 11 章基于属性的测试中所讨论的)。然而,为了测试,我们经常需要生成字符串。让我们看看如何。

生成字符串

要生成字符串,首先,我们需要生成字符。对于 ASCII 字符,我们可以从32126的整数范围开始,也就是有趣的可打印字符的 ASCII 码。我们随机抽取一个样本,将代码转换成字符。我们如何随机抽取样本?嗯,有一种观点叫做view::sample,给定一些项目,从范围中随机抽取样本。最后,我们只需要把它变成一个字符串。这就是我们如何得到由 ASCII 字符组成的长度为10的随机字符串:

TEST_CASE("Generate chars"){
    using namespace ranges;

    vector<char> chars = view::ints(32, 126) | view::sample(10) | 
        view::transform([](int asciiCode){ return char(asciiCode); });
    string aString(chars.begin(), chars.end()); 

    cout << aString << endl;

    CHECK_EQ(10, aString.size());
}

下面是运行这段代码的几个示例:

%.0FL[cqrt
#0bfgiluwy
4PY]^_ahlr
;DJLQ^bipy

如您所见,这些是在我们的测试中使用的有趣字符串。此外,我们可以通过改变view::sample的参数来改变字符串的大小。

本示例仅限于 ASCII 字符。然而,随着对 UTF-8 的支持现在成为 C++ 标准的一部分,扩展到支持特殊字符应该很容易。

摘要

Eric Niebler 的范围库在软件工程领域是一个罕见的壮举。它设法简化了现有 STL 高阶函数的使用,同时增加了延迟求值,并在数据生成方面占据了首要位置。它不仅是 C++ 20 标准的一部分,而且对旧版本的 C++ 也很有用。

即使您没有使用函数式的方式来构造代码,无论您喜欢可变的还是不可变的代码,范围库都允许您使它变得优雅和可组合。因此,我建议您玩它,并自己尝试它如何改变您的代码。这绝对是值得的,也是一次愉快的锻炼。

我们接近这本书的结尾了。现在是时候看看 STL 和支持函数式编程的语言标准,以及我们对 C++ 20 的期望了,这将是下一章的主题。