我们在本书中详细讨论了如何从功能的角度思考,以及功能链接和组合如何帮助创建模块化和可组合的设计。然而,我们遇到了一个问题——在我们当前的方法中,大量数据需要从一个集合复制到另一个集合。
幸运的是,埃里克·尼布勒自己开发了一个库,该库支持纯函数编程语言的解决方案——延迟求值。这个名为范围的库随后被正式接受为 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 库还没有包含在标准中。由于其规模,实现预计会很晚。在此之前,如果你想尝试,你仍然可以使用埃里克·尼布勒的版本。
那么,靶场图书馆提供什么?嗯,这一切都是从范围的概念开始的。范围由开始迭代器和结束迭代器组成。首先,这允许我们在现有集合的基础上添加一个范围。然后,我们可以将一个范围传递给一个需要开始和结束迭代器的算法(如transform
、sort
或accumulate
,从而消除对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)
调用,将向量排序到位。动作也是可组合的,或者通过直接的方法调用,或者通过|
操作符。这允许我们编写代码,通过组合sort
和unique
操作以及|
操作,对容器中的唯一项目进行分类和保存:
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
视图生成线性分布的值。给定一个值区间和要包括在线性分布中的多个项目,视图包括两个区间边界,以及来自区间内部的足够的值。例如,从[ 1
、10
]区间取 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
,最好从一个范围开始,到1
、11
、21
等等。
这里值得一提的是,编写这段代码的替代方法是使用 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 字符,我们可以从32
到126
的整数范围开始,也就是有趣的可打印字符的 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 的期望了,这将是下一章的主题。