C++-函数式编程实用指南(全)

C++ 函数式编程实用指南(全)

原文:annas-archive.org/md5/873bfe33df74385c75906a2f129ca61f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到 C++中的函数式编程实践之旅!这本书讲述了一个古老的概念,即函数式编程,以及一个经典的编程语言,即 C++,最终联合起来。

函数式编程自上世纪 50 年代以来就存在;然而,由于其数学基础,多年来一直对主流软件开发的兴趣有限。随着多核 CPU 和大数据的出现导致需要并行化,以及编程语言设计者对不可变性和 lambda 表达式的兴趣增加,函数式编程概念逐渐被引入到包括 C#、Java、PHP、JavaScript、Python 和 Ruby 在内的所有主要编程语言中。C++一直与函数式编程息息相关,例如函数指针、函数对象和 STL 中的算法等功能使许多程序员能够利用某些构造。然而,从 C++ 11 开始,我们看到了 lambda 的引入,以及all_ofany_ofnone_of等高阶函数的引入。在 C++ 17 中,我们看到了更多的进展,包括map(实现为transform)。此外,C++ 20 中的功能也非常令人兴奋;例如,允许可组合、轻量级和惰性评估转换的 ranges 库是标准库的一个重要补充。

这就引出了你将从本书中学到的内容。无论您是经验丰富的程序员还是 C++初学者,您都将学习有关函数式编程概念的知识,以及如何在 C++中使用它们,以及它们对管理和改进现有代码库的有用性。每个想法都将通过清晰的代码示例展示,并通过单元测试进行验证;我们强烈建议您拿这些代码示例来自己尝试一下。

我们特别努力确保每个想法都以清晰的方式呈现,并且遵循理解的流程;换句话说,我们一直在优化您的学习体验。为了做到这一点,我们决定夸大使用某些构造。例如,示例代码大量使用 lambda,因为我们想展示它们的用法。我们认为学习函数式编程的最佳方式是充分了解 lambda 和对 lambda 的操作。我们期望读者能够将这种方法与生产方法区分开;事实上,我建议您自己尝试这些概念,然后在生产代码的小部分上进行实验,然后再充分利用那些有前途的概念。为了支持这一目标,我们记录了多种使用函数操作的方法,这样您将拥有足够的工具来在各种情境下使用。

需要注意的是,我们经过深思熟虑决定在大部分书中使用 C++ 17 标准。我们不使用外部库(除了单元测试库),并且坚持使用语言和 STL 的标准功能。重点是函数式编程概念以及如何使用最简化的方法来实现它们。唯一的例外是书的最后一部分,它涉及 C++和 STL 的未来。我们这样做是因为我们认为让您理解这些概念并准备好以最少的工具应用它们比提供多种实现选项更重要。这在大部分书中省略了 ranges 库、Boost 库对函数式编程的支持,以及其他可能的有用库,可以扩展或简化代码。我将把尝试它们的机会留给读者,并让我们知道它们的效果如何。

这本书适合谁

这本书适用于已经了解 C++(包括语言语法、STL 容器和模板元素)并希望为自己的工具箱增添更多工具的程序员。您不需要了解任何有关函数式编程的知识来阅读本书;我们已经以清晰实用的方式解释了每个想法。

然而,您需要对来自函数式编程世界的工具集感到好奇。大量的实验将帮助您充分利用本书,因此我鼓励您尝试运行代码,并告诉我们您的发现。

本书涵盖的内容

第一章《函数式编程简介》向您介绍了函数式编程的基本思想。

第二章《理解纯函数》教会您函数式编程的基本构建块,即侧重于不变性的函数,以及如何在 C++中编写它们。

第三章《深入了解 Lambda 表达式》侧重于 Lambda 表达式以及如何在 C++中编写它们。

第四章《函数组合的概念》探讨了如何使用高阶操作组合函数。

第五章《部分应用和柯里化》教会您如何在 C++中使用函数的两个基本操作——部分应用和柯里化。

第六章《函数式思维-从数据到数据输出》向您介绍了另一种组织代码的方式,实现以函数为中心的设计。

第七章《使用功能操作消除重复》是对“不要重复自己”(DRY)原则、代码重复和相似性类型以及如何使用功能操作(如组合、部分应用和柯里化)编写更加 DRY 代码的概述。

第八章《使用类改善内聚性》演示了函数如何演变为类,以及如何将类转换为函数。

第九章《函数式编程的测试驱动开发》探讨了如何在函数式编程中使用测试驱动开发(TDD),以及不变性和纯函数如何简化测试。

第十章《性能优化》深入探讨了如何优化以函数为中心设计的性能的具体方法,包括记忆化、尾递归优化和并行执行。

第十一章《基于属性的测试》探讨了函数式编程如何实现编写自动化测试的新范式,通过数据生成增强了基于示例的测试。

第十二章《重构到和通过纯函数》解释了任何现有代码如何被重构为纯函数,然后再次转换为类,而风险最小。它还涉及经典设计模式和一些函数式设计模式。

第十三章《不变性和架构-事件溯源》解释了不变性可以在数据存储级别上移动,介绍了如何使用事件溯源,并讨论了它的优缺点。

第十四章《使用 Ranges 库进行惰性求值》深入研究了强大的 Ranges 库,并演示了如何在 C++ 17 和 C++ 20 中使用它。

第十五章《STL 支持和提案》介绍了 C++ 17 标准中的 STL 功能特性,以及 C++ 20 的一些有趣的补充。

第十六章,标准语言支持和提案,总结了函数式编程的基本构建块以及在 C++ 17 标准中使用它们的各种选项。

充分利用本书

本书假定您对 C++语法和基本 STL 容器有很好的了解。但是,它并不假定您对函数式编程、函数式构造、范畴论或数学有任何了解。我们已经非常努力地确保每个概念都以清晰的方式从实际的、以程序员为中心的角度进行解释。

我们强烈建议您在阅读章节后玩弄代码,或者在完成章节后尝试复制样本中的代码。更好的是,选择一个编码卡塔(例如,来自codingdojo.org/kata/)问题,并尝试使用本书中的技术来解决它。通过阅读和玩弄代码的结合,您将学到更多,而不仅仅是阅读理论。

本书中的大部分内容需要您以不同的方式思考代码结构,有时这与您习惯的方式相悖。然而,我们认为函数式编程是您工具箱中的另一个工具;它并不与您已经知道的知识相矛盾,而是为您提供了额外的工具来用于生产代码。何时以及如何使用它们是您的决定。

要运行本书中的代码示例,您将需要g++make命令。或者,您可以使用支持 C++ 17 的任何编译器运行示例,但您需要手动运行每个文件。所有代码示例都可以使用makemake [specific example]进行编译和自动运行,并在控制台上提供输出,但有一些注意事项需要遵循。

来自第十章的内存优化示例,性能优化,需要使用make allMemoryLogs或特定目标运行,需要在每个目标运行后按键盘,将在out/文件夹中创建日志文件,显示进程分配内存的演变。这仅适用于 Linux 系统。

来自第十章的反应式编程示例,性能优化,需要用户输入。只需输入数字,程序将以反应式方式计算它们是否为质数。即使在计算过程中,程序也应该接收输入。来自第十六章的代码示例,标准语言支持和提案,需要支持 C++20 的编译器;目前使用g++-8。您需要单独安装g++-8

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的以下工具解压或提取文件夹:

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Functional-Programming-with-Cpp。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

代码实例

访问以下链接以查看代码的执行情况:

bit.ly/2ZPw0KH

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是一个例子:“在 STL 中,它是用find_if函数实现的。让我们看看它的运行情况。”

一块代码设置如下:

class Number{
    public:
        static int zero(){ return 0; }
        static int increment(const int value){ return value + 1; }
}

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目将以粗体设置:

First call: 1,367 ns < 16,281 ns
Second call: 58,045 ns < 890,056 ns Third call: 16,167 ns > 939 ns Fourth call: 1,334 ns > 798 ns

警告或重要说明看起来像这样。

提示和技巧看起来像这样。

第一部分:C++中的函数式编程基本组件

在本节中,我们将学习函数式编程的基本构建块以及如何在 C++中使用它们。首先,我们将了解函数式编程是什么,以及它与面向对象编程(OOP)有何不同和相似之处。然后,我们将深入了解不可变性的基本概念,并学习如何在 C++中编写纯函数——即不改变状态的函数。然后,我们将学习如何使用 lambda 表达式以及如何使用它们编写纯函数。

一旦我们掌握了这些基本组件,我们就可以继续进行函数操作。在函数式编程中,函数就是数据,因此我们可以传递它们并对它们进行操作。我们将学习部分应用和柯里化,这两个基本且密切相关的操作。我们还将看到如何组合函数。这些操作将使我们能够用几行简单的代码将简单的函数转变为非常复杂的函数。

本节将涵盖以下章节:

  • 第一章,函数式编程简介

  • 第二章,理解纯函数

  • 第三章,深入了解 Lambda

  • 第四章,函数组合的概念

  • 第五章,部分应用和柯里化

第一章:函数式编程简介

为什么函数式编程有用?在过去的十年里,函数式编程构造已经出现在所有主要的编程语言中。程序员们享受了它们的好处——简化循环,更具表现力的代码,以及简单的并行化。但其中还有更多——脱离时间的耦合,提供消除重复、可组合性和更简单的设计的机会。更多人采用函数式编程(包括金融领域大规模采用 Scala)意味着一旦你了解并理解它,就会有更多的机会。虽然我们将在本书中深入探讨函数式编程,帮助你学习,但请记住,函数式编程是你工具箱中的另一个工具,当问题和上下文适合时,你可以选择使用它。

本章将涵盖以下主题:

  • 函数式编程简介以及对你已经在使用的函数式构造的检查

  • 结构化循环与函数式循环

  • 不可变性

  • 面向对象编程OOP)与函数式设计

  • 可组合性和消除重复

技术要求

代码适用于 g++ 7.3.0 和 C++ 17;它包括一个makefile以方便你使用。你可以在 GitHub 仓库(github.com/PacktPublishing/Hands-On-Functional-Programming-with-Cpp)的Chapter01目录中找到它。

函数式编程简介

我第一次接触函数式编程是在大学。我是一个 20 岁的极客,对科幻小说、阅读和编程感兴趣;编程是我学术生活的亮点。对我来说,与 C++、Java、MATLAB 以及我们使用的其他一些编程语言有关的一切都很有趣。不幸的是,我不能说同样的话适用于电气工程、电路或编译器理论等学科。我只想写代码!

根据我的兴趣,函数式编程本应该是一门非常有趣的课程。我们的老师非常热情。我们不得不写代码。但出了些问题——我没有理解老师在告诉我们的内容。为什么列表如此有趣?为什么语法如此反向且充满括号?为什么我要使用这些东西,当用 C++写相同的代码要简单得多?最终我试图将我从 BASIC 和 C++中所知的所有编程构造翻译成 Lisp 和 OCaml。这完全错过了函数式编程的要点,但我通过了这门课程,多年来都忘记了它。

我想很多人都能理解这个故事,我对此有一个可能的原因。我现在相信,尽管我的老师非常热情,但采用了错误的方法。今天,我明白了函数式编程在其核心具有一定的优雅,因为它与数学有着密切的关系。但这种优雅需要一种深刻的洞察力,而我 20 岁时并没有,也就是说,我在多年的各种经历后才有幸建立起来的洞察力。现在对我来说很明显,学习函数式编程不应该与读者看到这种优雅的能力有关。

那么,我们可以使用什么方法呢?回想起过去的我,也就是那个只想写代码的极客,只有一种方法——看看代码中的常见问题,并探索函数式编程如何减少或完全消除这些问题。此外,从一开始就开始;你已经看到了函数式编程,已经使用了一些概念和构造,你甚至可能发现它们非常有用。让我们来看看为什么。

函数式编程构造随处可见

在我完成大学函数式编程课程大约 10 年后,我和我的朋友 Felix 闲聊。像所有的极客一样,我们很少见面,但多年来,我们一直在即时通讯中讨论各种书呆子话题,当然也包括编程。

不知何故,我们谈到了函数式编程这个话题。Felix 指出我最喜欢和最享受的编程语言之一,LOGO,实际上是一种函数式编程语言。

LOGO是一种教育性编程语言,其主要特点是利用所谓的turtle graphics

回顾起来是显而易见的;以下是如何在 LOGO 的 KTurtle 版本中编写一个画正方形的函数:

learn square {
    repeat 4 {forward 50 turnright 90}
}

结果显示在以下截图中:

你能看到我们是如何将两行代码传递给 repeat 函数的吗?这就是函数式编程!函数式编程的一个基本原则是,代码只是另一种类型的数据,可以被打包在一个函数中,并传递给其他函数。我在 LOGO 中使用了这个构造数百次,却没有意识到这一点。

这个认识让我想:是否还有其他函数式编程构造是我在不知情中使用的?事实证明,是的,还有。事实上,作为一个 C++程序员,你很可能也使用过它们;让我们看看一些例子:

int add(const int base, const int exponent){
   return pow(base, exponent);
}

这个函数是推荐的 C++代码的典型例子。我最初是从 Bertrand Meyer 的惊人著作《Effective C++》、《More Effective C++》和《Effective STL》中了解到在任何地方都添加const的好处的。这个构造之所以有效有多个原因。首先,它保护了不应该改变的数据成员和参数。其次,它通过消除可能的副作用,使程序员更容易推理出函数中发生的事情。第三,它允许编译器优化函数。

事实证明,这也是不可变性的一个例子。正如我们将在接下来的章节中发现的那样,函数式编程将不可变性置于程序的核心,将所有的副作用移到程序的边缘。我们已经了解了函数式编程的基本构造;说我们使用函数式编程只是意味着我们更广泛地使用它!

以下是 STL 的另一个例子:

std::vector aCollection{5, 4, 3, 2, 1};
sort (aCollection.begin(), aCollection.end());

STL 算法具有很大的威力;这种威力来自多态性。我使用这个术语的含义比在 OOP 中更基本——这仅仅意味着集合包含什么并不重要,因为只要实现了比较,算法就能正常工作。我必须承认,当我第一次理解它时,我对这个聪明、有效的解决方案印象深刻。

有一种sort函数的变体,允许在比较没有实现或者不按我们期望的情况下对元素进行排序;例如,当我们给出一个Name结构时,如下所示:

using namespace std;

// Parts of code omitted for clarity
struct Name{
     string firstName;
     string lastName;
};

如果我们想要按照名字对vector<Name>容器进行排序,我们只需要一个compare函数:

bool compareByFirstName(const Name& first, const Name& second){
     return first.firstName < second.firstName;
}

此外,我们需要将其传递给sort函数,如下面的代码所示:

int main(){
    vector<Name> names = {Name("John", "Smith"), Name("Alex",
    "Bolboaca")};

    sort(names.begin(), names.end(), compareByFirstName);
}
// The names vector now contains "Alex Bolboaca", "John Smith"

这构成了一种高阶函数。高阶函数是一种使用其他函数作为参数的函数,以允许更高级别的多态性。恭喜——你刚刚使用了第二个函数式编程构造!

我甚至要说 STL 是函数式编程在实践中的一个很好的例子。一旦你了解更多关于函数式编程构造,你会意识到它们在 STL 中随处可见。其中一些,比如函数指针或者仿函数,已经存在于 C++语言中很长时间了。事实上,STL 经受住了时间的考验,那么为什么不在我们的代码中也使用类似的范式呢?

没有比 STL 中的函数式循环更好的例子来支持这个说法了。

结构化循环与函数式循环

作为程序员,我们学习的第一件事之一就是如何编写循环。我在 C++中的第一个循环是打印从110的数字:

for(int i = 0; i< 10; ++i){
    cout << i << endl;
}

作为一个好奇的程序员,我曾经认为这种语法是理所当然的,研究了它的特殊之处和复杂性,然后就使用了它。回想起来,我意识到这种结构有一些不寻常的地方。首先,为什么要从0开始?我被告知这是一个惯例,出于历史原因。然后,for循环有三个语句——初始化、条件和增量。对于我们想要实现的目标来说,这听起来有点太复杂了。最后,结束条件让我犯了比我愿意承认的更多的偏差错误。

此时,您会意识到 STL 允许您在循环遍历集合时使用迭代器:

for (list<int>::iterator it = aList.begin(); it != aList.end(); ++it)
      cout << *it << endl;

这绝对比使用游标的for循环要好。它避免了偏差错误,也没有0的惯例怪事。然而,该操作周围仍然有很多仪式感。更糟糕的是,随着程序复杂性的增加,循环往往会变得越来越大。

有一种简单的方法可以显示这种症状。让我们回顾一下我用循环解决的第一个问题。

让我们考虑一个整数向量并计算它们的总和;朴素的实现将如下所示:

int sumWithUsualLoop(const vector<int>& numbers){
    int sum = 0;
    for(auto iterator = numbers.begin(); iterator < numbers.end(); 
    ++iterator){
        sum += *iterator;
    }
    return sum;
}

如果生产代码能如此简单就好了!相反,一旦我们实现了这段代码,就会得到一个新的需求。现在我们需要对向量中的偶数进行求和。嗯,这很容易,对吧?让我们看看下面的代码:

int sumOfEvenNumbersWithUsualLoop(const vector<int>& numbers){
    int sum = 0;
    for(auto iterator = numbers.begin(); iterator<numbers.end(); 
    ++iterator){
        int number = *iterator;
        if (number % 2 == 0) sum+= number;
    }
    return sum;
}

如果你以为这就是结尾,那就错了。我们现在需要对同一个向量进行三次求和——偶数的和、奇数的和和总和。现在让我们添加一些更多的代码,如下所示:

struct Sums{
    Sums(): evenSum(0),  oddSum(0), total(0){}
    int evenSum;
    int oddSum;
    int total;
};

const Sums sums(const vector<int>& numbers){
    Sums theTotals;
    for(auto iterator = numbers.begin(); iterator<numbers.end(); 
    ++iterator){
        int number = *iterator;
        if(number % 2 == 0) theTotals.evenSum += number;
        if(number %2 != 0) theTotals.oddSum += number;
        theTotals.total += number;
    }
    return theTotals;
}

我们最初相对简单的循环变得越来越复杂。当我开始专业编程时,我们常常责怪用户和客户无法确定完美功能并给出最终的冻结需求。然而,在现实中很少可能;我们的客户每天都从用户与我们编写的程序的互动中学到新的东西。我们有责任使这段代码清晰,而使用函数循环是可能的。

多年后,我学会了 Groovy。Groovy 是一种基于 Java 虚拟机的编程语言,它专注于通过帮助程序员编写更少的代码和避免常见错误来简化程序员的工作。以下是您如何在 Groovy 中编写先前的代码:

def isEven(value){return value %2 == 0}
def isOdd(value){return value %2 == 1}
def sums(numbers){
   return [
      evenSum: numbers.filter(isEven).sum(),
      oddSum: numbers.filter(isOdd).sum(),
      total: numbers.sum()
   ]
}

让我们比较一下这两种方法。没有循环。代码非常清晰。没有办法犯偏差错误。没有计数器,因此也没有0开始的怪异现象。此外,它周围没有支撑结构——我只需写出我想要实现的目标,一个经过训练的读者就可以轻松理解。

虽然 C++版本更冗长,但它允许我们实现相同的目标:

const Sums sumsWithFunctionalLoops(const vector<int>& numbers){
    Sums theTotals;
    vector<int> evenNumbers;
    copy_if(numbers.begin(), numbers.end(), 
    back_inserter(evenNumbers), isEven);
    theTotals.evenSum = accumulate(evenNumbers.begin(), 
    evenNumbers.end(), 0);

    vector<int> oddNumbers;
    copy_if(numbers.begin(), numbers.end(), back_inserter(oddNumbers), 
    isOdd);
    theTotals.oddSum= accumulate(oddNumbers.begin(), oddNumbers.end(), 
    0);

    theTotals.total = accumulate(numbers.begin(), numbers.end(), 0);

    return theTotals;
}

尽管如此,仪式感仍然很浓重,而且代码相似度太高。因此,让我们摆脱它,如下所示:

template<class UnaryPredicate>
const vector<int> filter(const vector<int>& input, UnaryPredicate filterFunction){
    vector<int> filtered;
    copy_if(input.begin(), input.end(), back_inserter(filtered), 
    filterFunction);
    return filtered;
}

const int sum(const vector<int>& input){
    return accumulate(input.begin(), input.end(), 0);
}

const Sums sumsWithFunctionalLoopsSimplified(const vector<int>& numbers){
    Sums theTotals(
        sum(filter(numbers, isEven)),
        sum(filter(numbers, isOdd)),
        sum(numbers)
    ); 
    return theTotals;
}

我们刚刚用一些更简单、更易读和可组合的函数替换了一个复杂的for循环。

那么,这段代码更好吗?嗯,这取决于你对“更好”的定义。我喜欢用优点和缺点来思考任何实现。函数式循环的优点是简单性、可读性、减少代码重复和可组合性。有什么缺点吗?嗯,我们最初的for循环只需要通过向量进行一次遍历,而我们当前的实现需要三次遍历。对于非常大的集合,或者当响应时间和内存使用非常重要时,这可能是一个负担。这绝对值得讨论,我们将在第十章中更详细地研究这个问题,即专注于函数式编程性能优化的性能优化。现在,我建议你专注于理解函数式编程的新工具。

为了做到这一点,我们需要重新思考不可变性。

不可变性

我们已经了解到,在 C++中,一定程度的不可变性是首选的;常见的例子如下:

class ...{
    int add(const int& first, const int& second) const{
        return first + second;
    }
}

const关键字清楚地传达了代码的一些重要约束,例如以下内容:

  • 函数在返回之前不会改变任何参数。

  • 函数在其所属的类的任何数据成员之前不会更改。

现在让我们想象一个add的另一个版本,如下所示

int uglyAdd(int& first, int& second){
    first = first + second;
    aMember = 40;
    return first;
}

我之所以称之为uglyAdd,是有原因的——我在编程时不容忍这样的代码!这个函数违反了最小惊讶原则,做了太多的事情。阅读函数代码并不能揭示其意图。想象一下调用者的惊讶,如果不小心的话,仅仅通过调用add函数,就会有两件事情发生变化——一个是传递的参数,另一个是函数所在的类。

虽然这是一个极端的例子,但它有助于支持不可变性的论点。不可变函数很无聊;它们接收数据,在接收的数据中不做任何改变,在包含它们的类中也不做任何改变,并返回一个值。然而,当涉及长时间维护代码时,无聊是好事。

不可变性是函数式编程中函数的核心属性。当然,你的程序中至少有一部分是不可变的——输入/输出I/O)。我们将接受 I/O 的本质,并专注于尽可能增加我们代码的不可变性。

现在,你可能想知道是否你需要完全重新思考编写程序的方式。你是否应该忘记你学到的关于面向对象编程的一切?嗯,并不完全是这样,让我们看看为什么。

面向对象编程与函数式设计风格

我的工作的一个重要部分是与程序员合作,帮助他们改善编写代码的方式。为此,我尽力提出简单的解释复杂的想法。我对软件设计有一个这样的解释。对我来说,软件设计是我们构建代码的方式,使其最大程度地优化为业务目的。

我喜欢这个定义,因为它简单明了。但在我开始尝试函数式构造之后,有一件事让我感到困扰;即,函数式编程会导致出现以下代码:

const Sums sumsWithFunctionalLoopsSimplified(const vector<int>& numbers){
    Sums theTotals(
        sum(filter(numbers, isEven)),
        sum(filter(numbers, isOdd)),
        sum(numbers)
    );
    return theTotals;
 }

在面向对象编程风格中编写类似的代码很可能意味着创建类并使用继承。那么,哪种风格更好?此外,如果软件设计涉及代码结构,那么这两种风格之间是否存在等价性?

首先,让我们看看这两种设计风格真正推广了什么。什么是面向对象编程?多年来,我相信了所有列出以下三个面向对象语言属性的书籍:

  • 封装

  • 继承

  • 多态

作为面向对象编程(OOP)的思想家,Alan Kay 并不完全同意这个列表。对他来说,OOP 是关于许多小对象之间的通信。作为生物学专业的学生,他看到了将程序组织成身体组织细胞的机会,并允许对象像细胞一样进行通信。他更看重对象而不是类,更看重通信而不是通常列出的 OOP 特性。我最好地总结他的立场如下:系统中的动态关系比其静态属性更重要。

这改变了关于 OOP 范式的很多东西。那么,类应该与现实世界匹配吗?并不是真的。它们应该被优化以表示现实世界。我们应该专注于拥有清晰、深思熟虑的类层次结构吗?不,因为这些比对象之间的通信更不重要。我们能想到的最小对象是什么?嗯,要么是数据的组合,要么是函数。

在 Quora 的最近一个回答中(www.quora.com/Isnt-getting-rid-of-the-evil-state-like-Haskells-approach-something-every-programmer-should-follow/answer/Alan-Kay-11),Alan Kay 在回答有关函数式编程的问题时提出了一个有趣的想法。函数式编程源自数学,也是为了模拟现实世界以实现人工智能的努力。这一努力遇到了以下问题——Alex 在布加勒斯特Alex 在伦敦 都可能是真实的,但发生在不同的时间点。解决这个建模问题的方法是不可变性;也就是说,时间成为函数的一个参数,或者是数据结构中的一个数据成员。在任何程序中,我们可以将数据变化建模为数据的时间限定版本。没有什么能阻止我们将数据建模为小对象,将变化建模为函数。此外,正如我们将在后面看到的那样,我们可以轻松地将函数转换为对象,反之亦然。

因此,总结一下,Alan Kay 所说的 OOP 和函数式编程之间并没有真正的紧张关系。只要我们专注于增加代码的不可变性,并且专注于小对象之间的通信,我们可以一起使用它们,可以互换使用。在接下来的章节中,我们将发现用函数替换类,反之亦然是多么容易。

但是有很多使用 OOP 的方式与 Alan Kay 的愿景不同。我在客户那里看到了很多 C++ 代码,我见过一切——庞大的函数、巨大的类和深层次的继承层次结构。大多数情况下,我被叫来的原因是因为设计太难改变,添加新功能会变得非常缓慢。继承是一种非常强的关系,过度使用会导致强耦合,因此代码难以改变。长方法和长类更难理解和更难改变。当然,有些情况下继承和长类是有意义的,但总的来说,选择松散耦合的小对象能够实现可变性。

但是类可以被重用,对吗?我们能用函数做到吗?让我们下一个讨论这个话题。

可组合性和去除重复

我们已经看到了一个存在大量重复的例子:

const Sums sumsWithFunctionalLoops(const vector<int>& numbers){
    Sums theTotals;
    vector<int> evenNumbers;
    copy_if(numbers.begin(), numbers.end(), back_inserter(evenNumbers), 
    isEven);
    theTotals.evenSum = accumulate(evenNumbers.begin(), 
    evenNumbers.end(), 0);

    vector<int> oddNumbers;
    copy_if(numbers.begin(), numbers.end(), back_inserter(oddNumbers), 
    isOdd);
    theTotals.oddSum= accumulate(oddNumbers.begin(), oddNumbers.end(), 
    0);

    theTotals.total = accumulate(numbers.begin(), numbers.end(), 0);

    return theTotals;
}

我们设法使用函数来减少它,如下面的代码所示:

template<class UnaryPredicate>
const vector<int> filter(const vector<int>& input, UnaryPredicate filterFunction){
    vector<int> filtered;
    copy_if(input.begin(), input.end(), back_inserter(filtered), 
    filterFunction);
    return filtered;
}

const int sum(const vector<int>& input){
    return accumulate(input.begin(), input.end(), 0);
}

const Sums sumsWithFunctionalLoopsSimplified(const vector<int>& numbers){
    Sums theTotals(
        sum(filter(numbers, isEven)),
        sum(filter(numbers, isOdd)),
        sum(numbers)
    );

    return theTotals;
}

看到函数以各种方式组合是很有趣的;我们两次调用了 sum(filter()),并且一次调用了 sum()。此外,filter 可以与多个谓词一起使用。此外,通过一些工作,我们可以使 filtersum 成为多态函数:

template<class CollectionType, class UnaryPredicate>
const CollectionType filter(const CollectionType& input, UnaryPredicate filterFunction){
    CollectionType filtered;
    copy_if(input.begin(), input.end(), back_inserter(filtered), 
    filterFunction);
    return filtered;
}
template<typename T, template<class> class CollectionType>
const T sum(const CollectionType<T>& input, const T& init = 0){
    return accumulate(input.begin(), input.end(), init);
} 

现在很容易使用除了vector<int>之外的类型的参数调用filtersum。实现并不完美,但它说明了我试图表达的观点,即小的不可变函数可以轻松变成多态和可组合的。当我们可以将函数传递给其他函数时,这种方法特别有效。

总结

我们已经涵盖了很多有趣的话题!你刚刚意识到你已经掌握了函数式编程的基础知识。你可以使用const关键字在 C++中编写不可变函数。你已经在 STL 中使用了高级函数。此外,你不必忘记面向对象编程的任何内容,而是从不同的角度来看待它。最后,我们发现了小的不可变函数如何组合以提供复杂的功能,并且如何借助 C++模板实现多态。

现在是时候深入了解函数式编程的构建模块,并学习如何在 C++中使用它们了。这包括纯函数、lambda 表达式,以及与函数相关的操作,如函数组合、柯里化或部分函数应用。

问题

  1. 什么是不可变函数?

  2. 如何编写不可变函数?

  3. 不可变函数如何支持代码简洁性?

  4. 不可变函数如何支持简单设计?

  5. 什么是高级函数?

  6. 你能从 STL 中举一个高级函数的例子吗?

  7. 函数式循环相对于结构化循环有哪些优势?可能的缺点是什么?

  8. 从 Alan Kay 的角度来看,面向对象编程是什么?它如何与函数式编程相关?

第二章:理解纯函数

纯函数是函数式编程的核心构建模块。它们是不可变的函数,这使它们简单和可预测。在 C++中编写纯函数很容易,但是有一些事情你需要注意。由于 C++中的函数默认是可变的,我们需要学习告诉编译器如何防止变异的语法。我们还将探讨如何将可变代码与不可变代码分开。

本章将涵盖以下主题:

  • 理解纯函数是什么

  • 在 C++中编写纯函数和使用元组返回多个参数的函数

  • 确保 C++纯函数的不可变性

  • 理解为什么 I/O 是可变的,需要与纯函数分开

技术要求

你需要一个支持 C++ 17 的 C++编译器。我使用的是 GCC 版本 7.3.0。代码示例在 GitHub(github.com/PacktPublishing/Hands-On-Functional-Programming-with-Cpp)的Chapter02文件夹中,并且有一个makefile文件供您使用。

什么是纯函数?

让我们花点时间思考一个简单的日常体验。当你打开灯开关时,会发生两种情况之一:

  • 如果灯是开着的,它就会关掉

  • 如果灯是关着的,它就会打开

灯开关的行为是非常可预测的。它是如此可预测,以至于当灯不亮时,你立刻认为有什么地方出了问题——可能是灯泡、保险丝或开关本身。

以下是你打开或关闭开关时不希望发生的一些事情:

  • 你的冰箱不会关掉

  • 你邻居的灯不会亮起

  • 你的浴室水槽不会打开

  • 你的手机不会重置

当你打开灯开关时为什么会发生所有这些事情?那将是非常混乱的;我们不希望生活中出现混乱,对吧?

然而,程序员经常在代码中遇到这种行为。调用函数通常会导致程序状态的改变;当这种情况发生时,我们说函数具有副作用

函数式编程试图通过广泛使用纯函数来减少状态变化引起的混乱。纯函数是具有两个约束的函数:

  • 它们总是对相同的参数值返回相同的输出值。

  • 它们没有副作用。

让我们探讨如何编写灯开关的代码。我们假设灯泡是一个我们可以调用的外部实体;把它看作我们程序的输入/输出I/O)的输出。结构化/面向对象程序员的自然代码看起来可能是这样的:

void switchLight(LightBulb bulb){
    if(switchIsOn) bulb.turnOff();
    else bulb.turnOn();
}

这个函数有两个问题。首先,它使用了不属于参数列表的输入,即switchIsOn。其次,它直接对灯泡产生了副作用。

那么,纯函数是什么样子的呢?首先,它的所有参数都是可见的:

void switchLight(boolean switchIsOn, LightBulb bulb){    if(switchIsOn) 
    bulb.turnOff();
    else bulb.turnOn();
}

其次,我们需要消除副作用。我们该如何做呢?让我们将下一个状态的计算与打开或关闭灯泡的动作分开:

LightBulbSignal signalForBulb(boolean switchIsOn){
    if(switchIsOn) return LightBulbSignal.TurnOff;
    else return LightBulbSignal.TurnOn;
}
// use the output like this: sendSignalToLightBulb(signalForBulb(switchIsOn))

该函数现在是纯的,我们稍后会更详细地讨论这一点;但是,现在让我们简化如下:

LightBulbSignal signalForBulb(boolean switchIsOn){
    return switchIsOn ? LightBulbSignal.TurnOff :    
    LightBulbSignal.TurnOn;
}
// use the output like this: sendSignalToLightBulb(signalForBulb(switchIsOn))

让我们更清晰一些(我会假设该函数是一个类的一部分):

static LightBulbSignal signalForBulb(const boolean switchIsOn){
    return switchIsOn ? LightBulbSignal.TurnOff :  
    LightBulbSignal.TurnOn;
}
// use the output like this: sendSignalToLightBulb(signalForBulb(switchIsOn))

这个函数非常无聊:它非常可预测,易于阅读,而且没有副作用。这听起来就像一个设计良好的灯开关。而且,这正是我们在维护数十年的大量代码时所希望的。

我们现在了解了纯函数是什么以及它为什么有用。我们还演示了如何将纯函数与副作用(通常是 I/O)分离的例子。这是一个有趣的概念,但它能带我们到哪里?我们真的可以使用这样简单的构造来构建复杂的程序吗?我们将在接下来的章节中讨论如何组合纯函数。现在,让我们专注于理解如何在 C++中编写纯函数。

C++中的纯函数

在前面的例子中,您已经看到了我们在 C++中需要使用的纯函数的基本语法。您只需要记住以下四个想法:

  • 纯函数没有副作用;如果它们是类的一部分,它们可以是staticconst

  • 纯函数不改变它们的参数,因此每个参数都必须是constconst&const* const类型。

  • 纯函数总是返回值。从技术上讲,我们可以通过输出参数返回一个值,但通常更简单的是直接返回一个值。这意味着纯函数通常没有 void 返回类型。

  • 前面的观点都不能保证没有副作用或不可变性,但它们让我们接近了。例如,数据成员可以标记为可变,const方法可以改变它们。

在接下来的章节中,我们将探讨如何编写自由函数和类方法作为纯函数。当我们浏览示例时,请记住我们现在正在探索语法,重点是如何使用编译器尽可能接近纯函数。

没有参数的纯函数

让我们从简单的开始。我们可以在没有参数的情况下使用纯函数吗?当然可以。一个例子是当我们需要一个默认值时。让我们考虑以下例子:

int zero(){return 0;}

这是一个独立的函数。让我们了解如何在类中编写纯函数:

class Number{
    public:
        static int zero(){ return 0; }
}

现在,static告诉我们该函数不会改变任何非静态数据成员。但是,这并不能阻止代码改变static数据成员的值:

class Number{
    private:
        static int accessCount;
    public:
        static int zero(){++accessCount; return 0;}
        static int getCount() { return accessCount; }
};
int Number::accessCount = 0;
int main(){
Number::zero();
cout << Number::getCount() << endl; // will print 1
}

幸运的是,我们会发现我们可以通过恰当使用const关键字来解决大多数可变状态问题。以下情况也不例外:

static const int accessCount;

现在我们已经对如何编写没有参数的纯函数有了一些了解,是时候添加更多参数了。

带有一个或多个参数的纯函数

让我们从一个带有一个参数的纯类方法开始,如下面的代码所示:

class Number{
    public:
        static int zero(){ return 0; }
        static int increment(const int value){ return value + 1; }
}

两个参数呢?当然,让我们考虑以下代码:

class Number{
    public:
        static int zero(){ return 0; }
        static int increment(const int value){ return value + 1; }
        static int add(const int first, const int second){ return first  
        + second; }
};

我们可以用引用类型做同样的事情,如下所示:

class Number{
    public:
        static int zero(){ return 0; }
        static int increment(const int& value){ return value + 1; }
        static int add(const int& first, const int& second){ return 
        first + second; }
};

此外,我们可以用指针类型做同样的事情,尽管有点更多的语法糖:

class Number{
    public:
        static int incrementValueFromPointer(const int* const value )   
        {return *value + 1;}
};

恭喜——您现在知道如何在 C++中编写纯函数了!

嗯,有点;不幸的是,不可变性在 C++中实现起来比我们迄今所见到的要复杂一些。我们需要更深入地研究各种情况。

纯函数和不可变性

1995 年的电影《阿波罗 13 号》是我最喜欢的惊悚片之一。它涉及太空、一个真实的故事和多个工程问题。在许多令人难忘的场景中,有一个特别能教给我们很多关于编程的场景。当宇航员团队正在准备一个复杂的程序时,由汤姆·汉克斯扮演的指挥官注意到,他的同事在一个指令开关上贴了一张标签,上面写着“不要按动”。指挥官问他的同事为什么这样做,他的回答大致是“我的头脑不清醒,我害怕我会按动这个开关把你送上太空。所以,我写下这个来提醒自己不要犯这个错误。”

如果这种技术对宇航员有效,那么对程序员也应该有效。幸运的是,我们有编译器告诉我们何时做错了。但是,我们需要告诉编译器我们希望它检查什么。

毕竟,我们可以编写纯函数,而不需要任何conststatic。函数纯度不是语法问题,而是一个概念。正确地放置标签可以防止我们犯错。然而,我们会看到,编译器只能做到这一点。

让我们看看另一种实现我们之前讨论过的递增函数的方法:

class Number{
    public:
        int increment(int value){ return ++value; }
};
int main(){
    Number number;
    int output = number.increment(Number::zero());
    cout << output << endl;
 }

这不是一个纯函数。你能看出为什么吗?答案就在下一行:

 int increment(int value){ return ++value; }

++value不仅会递增value,还会改变输入参数。虽然在这种情况下并不是问题(value参数是按值传递的,所以只有它的副本被修改),但这仍然是一个副作用。这显示了在 C++中编写副作用有多容易,或者在任何不默认强制不可变性的语言中。幸运的是,只要我们告诉编译器我们确切地想要什么,编译器就可以帮助我们。

回想一下之前的实现如下:

 static int increment(const int value){ return value + 1; }

如果你尝试在这个函数的主体中写++valuevalue++,编译器会立即告诉你,你试图改变一个const输入参数。这真是太好了,不是吗?

那么通过引用传递的参数呢?

不可变性和通过引用传递

问题本来可能更糟。想象一下以下函数:

 static int increment(int& value){ return ++value; }

我们避免了按值传递,这涉及更多的内存字节。但是值会发生什么变化呢?让我们看看以下代码:

  int value = Number::zero(); //value is 0
      cout << Number::increment(value) << endl;
      cout << value << endl; // value is now 1

value参数开始为0,但当我们调用函数时,它被递增,所以现在它的value1。这就像每次你打开灯时,冰箱门都会打开。幸运的是,如果我们只添加一个小小的const关键字,我们会看到以下结果:

static int increment(const int& value) {return value + 1; }

然后,编译器再次友好地告诉我们,在函数体中不能使用++valuevalue++

这很酷,但指针参数呢?

不可变性和指针

在使用指针作为输入参数时,防止不需要的更改变得更加复杂。让我们看看当我们尝试调用这个函数时会发生什么:

  static int increment(int* pValue)

以下事情可能会改变:

  • pValue指向的值可能会改变。

  • 指针可能会改变其地址。

pValue指向的值在类似条件下可能会改变,就像我们之前发现的那样。例如,考虑以下代码:

 static int increment(int* pValue){ return ++*pValue; }

这将改变指向的值并返回它。要使其不可更改,我们需要使用一个恰到好处的const关键字:

 static int increment(int* const pValue){ return *pValue + 1; }

指针地址的更改比你期望的要棘手。让我们看一个会以意想不到的方式行为的例子:

class Number {
    static int* increment(int* pValue){ return ++pValue; }
}

int main(){
    int* pValue = new int(10);
    cout << "Address: " << pValue << endl;
    cout << "Increment pointer address:" <<   
    Number::incrementPointerAddressImpure(pValue) << endl;
    cout << "Address after increment: " << pValue << endl;
    delete pValue;
}

在我的笔记本上运行这个程序会得到以下结果:

Address: 0x55cd35098e80
Increment pointer address:0x55cd35098e80
Address after increment: 0x55cd35098e80
Increment pointer value:10

地址不会改变,即使我们在函数中使用++pValue进行递增。pValue++也是如此,但为什么会这样呢?

嗯,指针地址是一个值,它是按值传递的,所以函数体内的任何更改只适用于函数范围。要使地址更改,您需要按引用传递地址,如下所示:

 static int* increment(int*& pValue){ return ++pValue; }

这告诉我们,幸运的是,编写更改指针地址的函数并不容易。我仍然觉得告诉编译器强制执行这个规则更安全:

 static int* increment(int* const& pValue){ return ++pValue; }

当然,这并不妨碍你改变指向的值:

  static int* incrementPointerAddressAndValue(int* const& pValue){
      (*pValue)++;
      return pValue + 1;
  }

为了强制不可变性,无论是值还是地址,你需要使用更多的const关键字,如下面的代码所示:

  static const int* incrementPointerAddressAndValuePure(const int* 
      const& pValue){
          (*pValue)++;//Compilation error
          return pValue + 1;
  }

这涵盖了所有类型的类函数。但是,C++允许我们在类外编写函数。那么在这种情况下,static还有效吗?(剧透警告:并不完全如你所期望)。

不可变性和非类函数

到目前为止的所有示例都假设函数是类的一部分。C++允许我们编写不属于任何类的函数。例如,我们可以编写以下代码:

int zero(){ return 0; }
int increment(int& value){ return ++value; }
const int* incrementPointerAddressAndValuePure(const int* const& pValue){
    return pValue + 1;
}

您可能已经注意到我们不再使用static了。您可以使用static,但需要注意它对类中的函数具有完全不同的含义。应用于独立函数的static意味着您无法从不同的翻译单元中使用它;因此,如果您在 CPP 文件中编写函数,它将只在该文件中可用,并且链接器会忽略它。

我们已经涵盖了所有类型的类和非类函数。但是对于具有输出参数的函数呢?事实证明,它们需要一些工作。

不可变性和输出参数

有时,我们希望函数改变我们传入的数据。在标准模板库STL)中有许多例子,其中最简单的一个例子是sort

vector<int> values = {324, 454, 12, 45, 54564, 32};
     sort(values.begin(), values.end());

然而,这并不符合纯函数的概念;sort的纯函数等价物如下:

vector<int> sortedValues = pureSort(values);

我能听到你在想,“但 STL 实现是为了优化而在原地工作,那么纯函数是否 less optimized 呢?”事实证明,纯函数式编程语言,比如 Haskell 或 Lisp,也会优化这样的操作;pureSort的实现只会移动指针,并且只有在指向的值之一发生变化时才会分配更多的内存。然而,这是两种不同的上下文;C++必须支持多种编程范式,而 Haskell 或 Lisp 则优化了不可变性和函数式风格。我们将在第十章中进一步讨论优化,即性能优化。现在,让我们来看看如何使这些类型的函数成为纯函数。

我们已经发现了如何处理一个输出参数。但是我们如何编写纯函数,使其具有多个输出参数呢?让我们考虑以下例子:

void incrementAll(int& first, int& second){
    ++first;
    ++second;
}

解决这个问题的一个简单方法是用vector<int>替换这两个参数。但是如果参数具有不同的类型会怎么样?那么,我们可以使用一个结构体。但如果这是我们唯一需要它的时候呢?幸运的是,STL 提供了解决这个问题的方法,即通过元组:

const tuple<int, int> incrementAllPure(const int& first, const int&  
    second){
        return make_tuple(first + 1, second + 1);
 }
 int main(){
     auto results = incrementAllPure(1, 2);
     // Can also use a simplified version
     // auto [first, second] = incrementAllPure(1, 2);
     cout << "Incremented pure: " << get<0>(results) << endl;
     cout << "Incremented pure: " << get<1>(results) << endl;
 }

元组有许多优点,如下所示:

  • 它们可以用于多个值。

  • 这些值可以具有不同的数据类型。

  • 它们易于构建——只需一个函数调用。

  • 它们不需要额外的数据类型。

根据我的经验,当您尝试将具有多个输出参数的函数渲染为纯函数,或者返回值和输出参数时,元组是一个很好的解决方案。但是,我经常在设计完成后尝试将它们重构为命名的struct或数据类。尽管如此,使用元组是一个非常有用的技术;只是要适度使用。

到目前为止,我们已经使用了很多static函数。但它们不是不好的实践吗?嗯,这取决于很多因素;我们将在接下来更详细地讨论这个问题。

static函数不是不好的实践吗?

到目前为止,您可能会想知道纯函数是否好,因为它们与面向对象编程OOP)或干净的代码规则相矛盾,即避免使用static。然而,直到现在,我们只编写了static函数。那么,它们是好的还是坏的呢?

使用static函数有两个反对意见。

static函数的第一个反对意见是它们隐藏了全局状态。由于static函数只能访问static值,这些值就成为了全局状态。全局状态是不好的,因为很难理解是谁改变了它,当其值出乎意料时也很难调试。

但要记住纯函数的规则——纯函数应该对相同的输入值返回相同的输出值。因此,只有当函数不依赖于全局状态时,函数才是纯的。即使程序有状态,所有必要的值也作为输入参数发送给纯函数。不幸的是,我们无法轻易地通过编译器来强制执行这一点;避免使用任何类型的全局变量并将其转换为参数,这必须成为程序员的实践。

对于这种情况,特别是在使用全局常量时有一个特例。虽然常量是不可变状态,但考虑它们的演变也很重要。例如,考虑以下代码:

static const string CURRENCY="EUR";

在这里,你应该知道,总会有一个时刻,常量会变成变量,然后你将不得不改变大量的代码来实现新的要求。我的建议是,通常最好也将常量作为参数传递进去。

static函数的第二个反对意见是它们不应该是类的一部分。我们将在接下来的章节中更详细地讨论这一观点;暂且可以说,类应该将具有内聚性的函数分组在一起,有时纯函数应该在类中整齐地组合在一起。将具有内聚性的纯函数分组在一个类中还有另一种选择——只需使用一个命名空间。

幸运的是,我们不一定要在类中使用static函数。

静态函数的替代方案

我们在前一节中发现了如何通过使用static函数在Number类中编写纯函数:

class Number{
    public:
        static int zero(){ return 0; }
        static int increment(const int& value){ return value + 1; }
        static int add(const int& first, const int& second){ return  
        first + second; }
};

然而,还有另一种选择;C++允许我们避免static,但保持函数不可变:

class Number{
    public:
        int zero() const{ return 0; }
        int increment(const int& value) const{ return value + 1; }
        int add(const int& first, const int& second) const{ return 
        first + second; }
};

每个函数签名后面的const关键字只告诉我们该函数可以访问Number类的数据成员,但永远不能改变它们。

如果我们稍微改变这段代码,我们可以在类的上下文中提出一个有趣的不可变性问题。如果我们用一个值初始化数字,然后总是加上初始值,我们就得到了以下代码:

class Number{
    private:
        int initialValue;

    public:
        Number(int initialValue) : initialValue(initialValue){}
        int initial() const{ return initialValue; }
        int addToInitial(const int& first) const{ return first + 
        initialValue; }
};

int main(){
    Number number(10);
    cout << number.addToInitial(20) << endl;
}

这里有一个有趣的问题:addToInitial函数是纯的吗?让我们按照以下标准来检查:

  • 它有副作用吗?不,它没有。

  • 它对相同的输入值返回相同的输出值吗?这是一个棘手的问题,因为函数有一个隐藏的参数,即Number类或其初始值。然而,没有人可以从Number类的外部改变initialValue。换句话说,Number类是不可变的。因此,该函数将对相同的Number实例和相同的参数返回相同的输出值。

  • 它改变了参数的值吗?嗯,它只接收一个参数,并且不改变它。

结果是函数实际上是纯的。我们将在下一章中发现它也是部分应用函数

我们之前提到程序中的一切都可以是纯的,除了 I/O。那么,我们对执行 I/O 的代码怎么办?

纯函数和 I/O

看一下以下内容,并考虑该函数是否是纯的:

void printResults(){
    int* pValue = new int(10);
    cout << "Address: " << pValue << endl;
    cout << "Increment pointer address and value pure:" <<    
    incrementPointerAddressAndValuePure(pValue) << endl;
    cout << "Address after increment: " << pValue << endl;
    cout << "Value after increment: " << *pValue << endl;
    delete pValue;
}

好吧,让我们看看——它没有参数,所以值没有改变。但与我们之前的例子相比,有些不对劲,也就是它没有返回值。相反,它调用了一些函数,其中至少有一个是纯的。

那么,它有副作用吗?嗯,几乎每行代码都有一个:

cout << ....

这行代码在控制台上写了一行字符串,这是一个副作用!cout基于可变状态,因此它不是一个纯函数。此外,由于它的外部依赖性,cout可能会失败,导致异常。

尽管我们的程序中需要 I/O,但我们可以做什么呢?嗯,很简单——只需将可变部分与不可变部分分开。将副作用与非副作用分开,并尽量减少不纯的函数。

那么,我们如何在这里实现呢?嗯,有一个纯函数等待从这个不纯函数中脱颖而出。关键是从问题开始;所以,让我们将cout分离如下:

string formatResults(){
    stringstream output;
    int* pValue = new int(500);
    output << "Address: " << pValue << endl;
    output << "Increment pointer address and value pure:" << 
    incrementPointerAddressAndValuePure(pValue) << endl;
    output << "Address after increment: " << pValue << endl;
    output << "Value after increment: " << *pValue << endl;
    delete pValue;
    return output.str();
}

void printSomething(const string& text){
    cout << text;
}

printSomething(formatResults());

我们将由cout引起的副作用移到另一个函数中,并使初始函数的意图更清晰——即格式化而不是打印。看起来我们很干净地将纯函数与不纯函数分开了。

但是我们真的吗?让我们再次检查formatResults。它没有副作用,就像以前一样。我们正在使用stringstream,这可能不是纯函数,并且正在分配内存,但所有这些都是函数内部的局部变量。

内存分配是副作用吗?分配内存的函数可以是纯函数吗?毕竟,内存分配可能会失败。但是,在函数中几乎不可能避免某种形式的内存分配。因此,我们将接受一个纯函数可能会在某种内存失败的情况下失败。

那么,它的输出呢?它会改变吗?嗯,它没有输入参数,但它的输出可以根据new运算符分配的内存地址而改变。所以,它还不是一个纯函数。我们如何使它成为纯函数呢?这很容易——让我们传入一个参数,pValue

string formatResultsPure(const int* pValue){
    stringstream output;
    output << "Address: " << pValue << endl;
    output << "Increment pointer address and value pure:" << 
    incrementPointerAddressAndValuePure(pValue) << endl;
    output << "Address after increment: " << pValue << endl;
    output << "Value after increment: " << *pValue << endl;
    return output.str();
}

int main(){
    int* pValue = new int(500);
    printSomething(formatResultsPure(pValue));
    delete pValue;
}

在这里,我们使自己与副作用和可变状态隔离。代码不再依赖 I/O 或new运算符。我们的函数是纯的,这带来了额外的好处——它只做一件事,更容易理解它的作用,可预测,并且我们可以很容易地测试它。

关于具有副作用的函数,考虑以下代码:

void printSomething(const string& text){
    cout << text;
}

我认为我们都可以同意,很容易理解它的作用,只要我们的其他函数都是纯函数,我们可以安全地忽略它。

总之,为了获得更可预测的代码,我们应该尽可能地将纯函数与不纯函数分开,并尽可能将不纯函数推到系统的边界。在某些情况下,这种改变可能很昂贵,拥有不纯函数在代码中也是完全可以的。只要确保你知道哪个是哪个。

总结

在本章中,我们探讨了如何在 C++中编写纯函数。由于有一些需要记住的技巧,这里是推荐的语法列表:

  • 通过值传递的类函数:

  • static int increment(const int value)

  • int increment(const int value) const

  • 通过引用传递的类函数:

  • static int increment(const int& value)

  • int increment(const int&value) const

  • 通过值传递指针的类函数:

  • static const int* increment(const int* const value)

  • const int* increment(const int* const value) const

  • 通过引用传递的类函数:

  • static const int* increment(const int* const& value)

  • const int* increment(const int* const& value) const

  • 通过值传递的独立函数:int increment(const int value)

  • 通过引用传递的独立函数:int increment(const int& value)

  • 通过值传递指针的独立函数:const int* increment(const int* value)

  • 通过引用传递的独立函数:const int* increment(const int* const& value)

我们还发现,虽然编译器有助于减少副作用,但并不总是告诉我们函数是纯函数还是不纯函数。我们始终需要记住编写纯函数时要使用的标准,如下所示:

  • 它总是对相同的输入值返回相同的输出值。

  • 它没有副作用。

  • 它不会改变输入参数的值。

最后,我们看到了如何将通常与 I/O 相关的副作用与我们的纯函数分离。这很容易,通常需要传入值并提取函数。

现在是时候向前迈进了。当我们将函数视为设计的一等公民时,我们可以做更多事情。为此,我们需要学习 lambda 是什么以及它们如何有用。我们将在下一章中学习这个。

问题

  1. 什么是纯函数?

  2. 不可变性与纯函数有什么关系?

  3. 你如何告诉编译器防止对按值传递的变量进行更改?

  4. 你如何告诉编译器防止对按引用传递的变量进行更改?

  5. 你如何告诉编译器防止对按引用传递的指针地址进行更改?

  6. 你如何告诉编译器防止对指针指向的值进行更改?

第三章:深入了解 Lambda

恭喜!你刚刚掌握了纯函数的力量!现在是时候进入下一个级别——纯函数的超级版本,或者传说中的 lambda。它们存在的时间比对象更长,它们有一个围绕它们的数学理论(如果你喜欢这种东西的话),并且它们非常强大,正如我们将在本章和下一章中发现的那样。

本章将涵盖以下主题:

  • 理解 lambda 的概念和历史

  • 如何在 C++中编写 lambda

  • 纯函数与 lambda 的比较

  • 如何在类中使用 lambda

技术要求

您将需要一个支持 C++ 17 的 C++编译器。代码可以在 GitHub 存储库(github.com/PacktPublishing/Hands-On-Functional-Programming-with-Cpp)的Chapter03文件夹中找到。提供了一个makefile文件,以便您更轻松地编译和运行代码。

什么是 lambda?

那年是 1936 年。33 岁的数学家阿隆佐·邱奇发表了他关于数学基础的研究。在这样做的过程中,他创造了所谓的lambda 演算,这是最近创建的计算领域的模型。在与艾伦·图灵合作后,他随后证明了 lambda 演算等价于图灵机。这一发现的相关性对编程至关重要——这意味着我们可以通过使用 lambda 和利用 lambda 演算来为现代计算机编写任何程序。这就解释了为什么它被称为lambda——数学家们长期以来更喜欢用单个希腊字母来表示每个符号。但它到底是什么?

如果你忽略所有的数学符号,lambda 只是一个可以应用于变量或值的纯函数。让我们看一个例子。我们将学习如何在 C++中编写 lambda,但是现在我将使用 Groovy 语法,因为这是我知道的最简单的语法:

def add = {first, second -> first + second}
add(1,2) //returns 3

add是一个 lambda。正如你所看到的,它是一个具有两个参数并返回它们的和的函数。由于 Groovy 具有可选类型,我不必指定参数的类型。此外,我不需要使用return语句来返回总和;它将自动返回最后一个语句的值。在 C++中,我们不能跳过类型或return语句,我们将在下一节中发现。

现在,让我们看一下 lambda 的另一个属性,即从上下文中捕获值的能力:

def first = 5
def addToFirst = {second -> first + second}
addToFirst(10) // returns 5 + 10 = 15

在这个例子中,first不是函数的参数,而是在上下文中定义的变量。lambda 捕获变量的值并在其主体内使用它。我们可以利用 lambda 的这个属性来简化代码或逐渐重构向不可变性。

我们将在未来的章节中探讨如何使用 lambda;现在,让我们演示如何在 C++中编写它们,如何确保它们是不可变的,以及如何从上下文中捕获值。

C++中的 lambda

我们探讨了如何在 Groovy 中编写 lambda。那么,我们可以在 C++中使用它们的功能吗?自 C++ 11 以来,引入了特定的语法。让我们看看我们的add lambda 在 C++中会是什么样子:

int main(){
    auto add = [](int first, int second){ return first + second;};
    cout << add(1,2) << endl; // writes 3
}

让我们按照以下方式解释语法:

  • 我们的 lambda 以[]开始。这个块指定了我们从上下文中捕获的变量,我们将看到如何在一会儿使用它。由于我们没有捕获任何东西,这个块是空的。

  • 接下来,我们有参数列表,(int first, int second),就像任何其他 C++函数一样。

  • 最后,我们编写 lambda 的主体,使用 return 语句:{ return first + second; }

语法比 Groovy 有点更加正式,但感觉像 C++,这是一件好事;统一性有助于我们记住事情。

或者,我们可以使用箭头语法,如下面的代码所示:

    auto add = [](int first, int second) -> int { return first +   
        second;};

箭头语法是 lambda 的标志,自从 Alonzo Church 在他的 lambda 演算中使用这种符号以来。除此之外,C++要求在 lambda 主体之前指定返回类型,这可能在涉及类型转换的情况下提供了清晰度。

由于历史原因,箭头语法以某种方式存在于所有函数式编程语言中。在 C++中很少有用,但是如果你想要习惯函数式编程,了解它是很有用的。

现在是时候探索如何从上下文中捕获变量了。正如我们之前提到的,这都在[]块中。

捕获变量

那么,如果我们想要捕获变量呢?在 Groovy 中,我们只需在 lambda 范围内使用变量。这在 C++中行不通,因为我们需要指定我们要捕获的变量以及捕获它们的方式。因此,如果我们只在add lambda 中使用first变量,我们将得到以下编译错误:

int main(){
    int first = 5;
    auto addToFirst = [](int second){ return first + second;}; 
    // error: variable 'first' cannot be implicitly captured 
    cout << add(10) << endl;
}

为了在 C++中捕获变量,我们需要在[]块内使用捕获说明符。有多种方法可以做到这一点,具体取决于你的需求。最直观的方法是直接写出我们要捕获的变量的名称。在我们的情况下,由于我们要捕获第一个变量,我们只需要在 lambda 参数前添加[first]

int main(){
    int first = 5;
    auto addToFirst = first{ return first + second;};
    cout << addToFirst(10) << endl; // writes 15
}

正如我们将看到的,这意味着first变量是按值捕获的。由于 C++给程序员提供了很多控制权,我们期望它提供特定的语法来按引用捕获变量。现在,让我们更详细地探讨捕获语法。

按值和按引用捕获变量

我们知道按值捕获变量的说明符只是写变量的名称,即[first]。这意味着变量被复制,因此我们浪费了一些内存。解决方案是通过引用捕获变量。捕获说明符的语法非常直观——我们可以将变量名作为[&first]引用:

int main(){
    int first = 5;
    auto addToFirstByReference = &first{ return first + 
        second;};
    cout << addToFirstByReference(10) << endl; // writes 15
}

我知道你在想什么:lambda 现在可以修改first变量的值吗,因为它是按引用传递的?剧透警告——是的,它可以。我们将在下一节重新讨论不可变性、纯函数和 lambda。现在,还有更多的语法要学习。例如,如果我们想要从上下文中捕获多个变量,我们是否必须在捕获说明符中写出它们所有?事实证明,有一些快捷方式可以帮助你避免这种情况。

捕获多个值

那么,如果我们想要捕获多个值呢?让我们探索一下如果我们添加了五个捕获的值,我们的 lambda 会是什么样子:

    int second = 6;
    int third = 7;
    int fourth = 8;
    int fifth = 9;

    auto addTheFive = [&first, &second, &third, &fourth, &fifth]()   
    {return first + second + third + fourth + fifth;};
    cout << addTheFive() << endl; // writes 35

我们当前的语法有点多余,不是吗?我们可以使用默认捕获说明符。幸运的是,语言设计者也是这么想的;注意 lambda 参数前的[&]语法:

    auto addTheFiveWithDefaultReferenceCapture = [&](){return first + second + third + fourth + fifth;};
    cout << addTheFiveWithDefaultReferenceCapture() << endl; // writes 35

[&]语法告诉编译器从上下文中引用所有指定的变量。这是默认按引用捕获说明符。

如果我们想要复制它们的值,我们需要使用默认按值捕获说明符,你需要记住这是唯一使用这种方式的地方。注意 lambda 参数前的[=]语法:

auto addTheFiveWithDefaultValueCapture = [=](){return first + 
second + third + fourth + fifth;};
cout << addTheFiveWithDefaultValueCapture() << endl; // writes 35

[=]语法告诉编译器所有变量都将通过复制它们的值来捕获。至少,默认情况下是这样。如果出于某种原因,你想要除了first之外的所有变量都通过值传递,那么你只需将默认与变量说明符结合起来:

auto addTheFiveWithDefaultValueCaptureForAllButFirst = [=, &first](){return first + second + third + fourth + fifth;};
cout << addTheFiveWithDefaultValueCaptureForAllButFirst() << endl; // writes 35

我们现在知道了如何按值和按引用捕获变量,以及如何使用默认说明符。这使我们留下了一个重要类型的变量——指针。

捕获指针值

指针只是简单的值。如果我们想要按值捕获指针变量,我们可以像下面的代码中那样写它的名称:

    int* pFirst = new int(5);
    auto addToThePointerValue = pFirst{return *pFirst + 
        second;};
    cout << addToThePointerValue(10) << endl; // writes 15
    delete pFirst;

如果我们想要按引用捕获指针变量,捕获语法与捕获任何其他类型的变量相同:

auto addToThePointerValue = &pFirst{return *pFirst + 
    second;};

默认的限定符的工作方式正如你所期望的那样;也就是说,[=]通过值来捕获指针变量:

 auto addToThePointerValue = ={return *pFirst + second;};

相比之下,[&]通过引用来捕获指针变量,如下面的代码所示:

    auto addToThePointerValue = &{return *pFirst + 
    second;};

我们将探讨通过引用捕获变量对不可变性可能产生的影响。但首先,由于有多种捕获 lambda 变量的方式,我们需要检查我们更喜欢哪一种,以及何时使用它们。

我们应该使用什么捕获?

我们已经看到了一些捕获值的选项,如下所示:

  • 命名变量以通过值来捕获它;例如,[aVariable]

  • 命名变量并在前面加上引用限定符以通过引用来捕获它;例如,[&aVariable]

  • 使用默认值限定符通过值来捕获所有使用的变量;语法是[=]

  • 使用默认引用限定符通过引用来捕获所有使用的变量;语法是[&]

实际上,我发现使用默认值限定符是大多数情况下最好的版本。这可能受到我偏好不改变捕获值的非常小的 lambda 的影响。我相信简单性非常重要;当你有多个选项时,很容易使语法比必要的更复杂。仔细考虑每个上下文,并使用最简单的语法;我的建议是从[=]开始,只有在需要时才进行更改。

我们已经探讨了如何在 C++中编写 lambda。我们还没有提到它们是如何实现的。当前的标准将 lambda 实现为一个在堆栈上创建的具有未知类型的 C++对象。就像任何 C++对象一样,它背后有一个类,有一个构造函数,一个析构函数,以及捕获的变量作为数据成员存储。我们可以将 lambda 传递给function<>对象,这样function<>对象将存储 lambda 的副本。此外,lambda 使用延迟评估,不同于function<>对象。

Lambda 似乎是编写纯函数的一种更简单的方法;那么,lambda 和纯函数之间的关系是什么?

Lambda 和纯函数

我们在第二章中学到,纯函数具有三个特征:

  • 它们总是对相同的参数值返回相同的值

  • 它们没有副作用

  • 它们不改变其参数的值

我们还发现在编写纯函数时需要注意不可变性。只要我们记得在哪里放置const关键字,这很容易。

那么,lambda 如何处理不可变性?我们需要做一些特殊的事情吗,还是它们只是工作?

Lambda 的不可变性和通过值传递的参数

让我们从一个非常简单的 lambda 开始,如下所示:

auto increment = [](int value) { 
    return ++value;
};

在这里,我们通过值传递参数,所以我们在调用 lambda 后不希望值发生任何改变:

    int valueToIncrement = 41;
    cout << increment(valueToIncrement) << endl;// prints 42
    cout << valueToIncrement << endl;// prints 41

由于我们复制了值,我们可能使用了一些额外的内存字节和额外的赋值。我们可以添加一个const关键字来使事情更清晰:

auto incrementImmutable = [](const int value) { 
    return value + 1;
};

由于const限定符,如果 lambda 尝试改变value,编译器将会报错。

但我们仍然通过值传递参数;那么通过引用传递呢?

Lambda 的不可变性和通过引用传递的参数

让我们探讨当我们调用这个 lambda 时对输入参数的影响:

auto increment = [](int& value) { 
    return ++value;
};

事实证明,这与你所期望的相当接近:

int valueToIncrement = 41;
cout << increment(valueToIncrement) << endl;// prints 42
cout << valueToIncrement << endl;// prints 42

在这里,lambda 改变了参数的值。这还不够好,所以让我们使其不可变,如下面的代码所示:

auto incrementImmutable = [](const int& value){
    return value + 1;
};

编译器会再次通过错误消息帮助我们,如果 lambda 尝试改变value

好了,这样更好了;但指针呢?

Lambda 的不可变性和指针参数

就像我们在第二章中看到的那样,关于指针参数有两个问题,如下所示:

  • lambda 能改变指针地址吗?

  • lambda 能改变指向的值吗?

再次,如果我们按值传递指针,地址不会改变:

auto incrementAddress = [](int* value) { 
    return ++value;
};

int main(){
    int* pValue = new int(41);
    cout << "Address before:" << pValue << endl;
    cout << "Address returned by increment address:" <<   
    incrementAddress(pValue) << endl;
    cout << "Address after increment address:" << pValue << endl;
}

Output:
Address before:0x55835628ae70
Address returned by increment address:0x55835628ae74
Address after increment address:0x55835628ae70

通过引用传递指针会改变这一点:

auto incrementAddressByReference = [](int*& value) { 
    return ++value;
};

void printResultsForIncrementAddressByReference(){
    int* pValue = new int(41);
    int* initialPointer = pValue;
    cout << "Address before:" << pValue << endl;
    cout << "Address returned by increment address:" <<    
    incrementAddressByReference(pValue) << endl;
    cout << "Address after increment address:" << pValue << endl;
    delete initialPointer;
}

Output:
Address before:0x55d0930a2e70
Address returned by increment address:0x55d0930a2e74
Address after increment address:0x55d0930a2e74

因此,我们需要再次使用适当的const关键字来保护自己免受这种变化的影响:

auto incrementAddressByReferenceImmutable = [](int* const& value) { 
    return value + 1;
};

Output:
Address before:0x557160931e80
Address returned by increment address:0x557160931e84
Address after increment address:0x557160931e80

让我们也使值不可变。如预期的那样,我们需要另一个const关键字:

auto incrementPointedValueImmutable = [](const int* const& value) { 
    return *value + 1;
};

虽然这样可以工作,但我建议您更倾向于使用更简单的方式传递[](const int& value)值,也就是说,只需对指针进行解引用并将实际值传递给 lambda 表达式,这将使参数语法更容易理解和更可重用。

所以,毫不意外!我们可以使用与纯函数相同的语法来确保不可变性。

但是 lambda 表达式能调用可变函数吗,比如 I/O 呢?

Lambda 表达式和 I/O

测试 lambda 表达式和 I/O 的更好方法是Hello, world程序:

auto hello = [](){cout << "Hello, world!" << endl;};

int main(){
    hello();
}

显然,lambda 表达式无法防止调用可变函数。这并不奇怪,因为我们对纯函数也学到了同样的事情。这意味着,类似于纯函数,程序员需要特别注意将 I/O 与其余可能是不可变的代码分开。

由于我们试图让编译器帮助我们强制实施不可变性,我们能为捕获的值做到这一点吗?

Lambda 表达式的不可变性和捕获值

我们已经发现 lambda 表达式可以从上下文中捕获变量,无论是按值还是按引用。那么,这是否意味着我们可以改变它们的值呢?让我们来看看:

int value = 1;
auto increment = [=](){return ++value;};

这段代码立即给出了一个编译错误——无法对按值捕获的变量赋值。这比按值传递参数要好,也就是说,不需要使用const关键字——它可以按预期工作。

按引用捕获的值的不可变性

那么,通过引用捕获的值呢?好吧,我们可以使用默认的引用说明符[&],并在调用我们的increment lambda 之前和之后检查变量的值:

void captureByReference(){
    int value = 1;
    auto increment = [&](){return ++value;};

    cout << "Value before: " << value << endl;
    cout << "Result of increment:" << increment() << endl;
    cout << "Value after: " << value << endl;
}

Output:
Value before: 1
Result of increment:2
Value after: 2

如预期的那样,value发生了变化。那么,我们如何防止这种变化呢?

不幸的是,没有简单的方法可以做到这一点。C++假设如果您通过引用捕获变量,您想要修改它们。虽然这是可能的,但它需要更多的语法糖。具体来说,我们需要捕获其转换为const类型的内容,而不是变量本身:

#include <utility>
using namespace std;
...

    int value = 1;
    auto increment = [&immutableValue = as_const(value)](){return  
        immutableValue + 1;};

Output:
Value before: 1
Result of increment:2
Value after: 1

如果可以选择,我更喜欢使用更简单的语法。因此,除非我真的需要优化性能,我宁愿使用按值捕获的语法。

我们已经探讨了如何在捕获值类型时使 lambda 表达式不可变。但是在捕获指针类型时,我们能确保不可变性吗?

按值捕获的指针的不可变性

当我们使用指针时,事情变得有趣起来。如果我们按值捕获它们,就无法修改地址:

    int* pValue = new int(1);
    auto incrementAddress = [=](){return ++pValue;}; // compilation 
    error

然而,我们仍然可以修改指向的值,就像下面的代码所示:

    int* pValue = new int(1);
    auto increment= [=](){return ++(*pValue);};

Output:
Value before: 1
Result of increment:2
Value after: 2

限制不可变性需要一个const int*类型的变量:

    const int* pValue = new int(1);
    auto increment= [=](){return ++(*pValue);}; // compilation error

然而,有一个更简单的解决方案,那就是只捕获指针的值:

 int* pValue = new int(1);
 int value = *pValue;
 auto increment = [=](){return ++value;}; // compilation error

按引用捕获的指针的不可变性

通过引用捕获指针允许您改变内存地址:

 auto increment = [&](){return ++pValue;};

我们可以使用与之前相同的技巧来强制内存地址的常量性:

 auto increment = [&pImmutable = as_const(pValue)](){return pImmutable 
    + 1;};

然而,这变得相当复杂。这样做的唯一原因是由于以下原因:

  • 我们希望避免最多复制 64 位

  • 编译器不会为我们进行优化

最好还是坚持使用按值传递的值,除非您想在 lambda 表达式中进行指针运算。

现在您知道了 lambda 表达式在不可变性方面的工作原理。但是,在我们的 C++代码中,我们习惯于类。那么,lambda 表达式和类之间有什么关系呢?我们能将它们结合使用吗?

Lambda 表达式和类

到目前为止,我们已经学习了如何在 C++中编写 lambda 表达式。所有的例子都是在类外部使用 lambda 表达式,要么作为变量,要么作为main()函数的一部分。然而,我们的大部分 C++代码都存在于类中。这就引出了一个问题——我们如何在类中使用 lambda 表达式呢?

为了探讨这个问题,我们需要一个简单类的例子。让我们使用一个表示基本虚数的类:

class ImaginaryNumber{
    private:
        int real;
        int imaginary;

    public:
        ImaginaryNumber() : real(0), imaginary(0){};
        ImaginaryNumber(int real, int imaginary) : real(real), 
        imaginary(imaginary){};
};

我们想要利用我们新发现的 lambda 超能力来编写一个简单的toString函数,如下面的代码所示:

string toString(){
    return to_string(real) + " + " + to_string(imaginary) + "i";
}

那么,我们有哪些选择呢?

嗯,lambda 是简单的变量,所以它们可以成为数据成员。或者,它们可以是static变量。也许我们甚至可以将类函数转换为 lambda。让我们接下来探讨这些想法。

Lambda 作为数据成员

让我们首先尝试将其写为成员变量,如下所示:

class ImaginaryNumber{
...
    public:
        auto toStringLambda = [](){
            return to_string(real) + " + " + to_string(imaginary) +  
             "i";
        };
...
}

不幸的是,这导致编译错误。如果我们想将其作为非静态数据成员,我们需要指定 lambda 变量的类型。为了使其工作,让我们将我们的 lambda 包装成function类型,如下所示:

include <functional>
...
    public:
        function<string()> toStringLambda = [](){
            return to_string(real) + " + " + to_string(imaginary) +    
            "i";
        };

函数类型有一个特殊的语法,允许我们定义 lambda 类型。function<string()>表示函数返回一个string值并且不接收任何参数。

然而,这仍然不起作用。我们收到另一个错误,因为我们没有捕获正在使用的变量。我们可以使用到目前为止学到的任何捕获。或者,我们可以捕获this

 function<string()> toStringLambda = [this](){
     return to_string(real) + " + " + to_string(imaginary) + 
     "i";
 };

因此,这就是我们可以将 lambda 作为类的一部分编写,同时捕获类的数据成员。在重构现有代码时,捕获this是一个有用的快捷方式。但是,在更持久的情况下,我会避免使用它。最好直接捕获所需的变量,而不是整个指针。

Lambda 作为静态变量

我们还可以将我们的 lambda 定义为static变量。我们不能再捕获值了,所以我们需要传入一个参数,但我们仍然可以访问realimaginary私有数据成员:

    static function<string(const ImaginaryNumber&)>   
         toStringLambdaStatic;
...
// after class declaration ends
function<string(const ImaginaryNumber&)> ImaginaryNumber::toStringLambdaStatic = [](const ImaginaryNumber& number){
    return to_string(number.real) + " + " + to_string(number.imaginary)  
        + "i";
};

// Call it
cout << ImaginaryNumber::toStringLambdaStatic(Imaginary(1,1)) << endl;
// prints 1+1i

将静态函数转换为 lambda

有时,我们需要将static函数转换为 lambda 变量。在 C++中,这非常容易,如下面的代码所示:

static string toStringStatic(const ImaginaryNumber& number){
    return to_string(number.real) + " + " + to_string(number.imaginary)  
    + "i";
 }
string toStringUsingLambda(){
    auto toStringLambdaLocal = ImaginaryNumber::toStringStatic;
    return toStringLambdaLocal(*this);
}

我们可以简单地将一个来自类的函数分配给一个变量,就像在前面的代码中所示的那样:

  auto toStringLambdaLocal = ImaginaryNumber::toStringStatic;

然后我们可以像使用函数一样使用变量。正如我们将要发现的那样,这是一个非常强大的概念,因为它允许我们在类内部定义函数时组合函数。

Lambda 和耦合

在 lambda 和类之间的交互方面,我们有很多选择。它们既可以变得令人不知所措,也可以使设计决策变得更加困难。

虽然了解选项是好的,因为它们有助于进行困难的重构,但通过实践,我发现在使用 lambda 时最好遵循一个简单的原则;也就是说,选择减少 lambda 与代码其余部分之间耦合区域的选项是最好的。

例如,我们已经看到我们可以将我们的 lambda 写成类中的static变量:

function<string(const ImaginaryNumber&)> ImaginaryNumber::toStringLambdaStatic = [](const ImaginaryNumber& number){
    return to_string(number.real) + " + " + to_string(number.imaginary)  
        + "i";
};

这个 lambda 的耦合区域与ImaginaryNumber类一样大。但它只需要两个值:实部和虚部。我们可以很容易地将它重写为一个纯函数,如下所示:

auto toImaginaryString = [](auto real, auto imaginary){
    return to_string(real) + " + " + to_string(imaginary) + "i";
};

如果由于某种原因,您决定通过添加成员或方法、删除成员或方法、将其拆分为多个类或更改数据成员类型来更改虚数的表示,这个 lambda 将不需要更改。当然,它需要两个参数而不是一个,但参数类型不再重要,只要to_string对它们有效。换句话说,这是一个多态函数,它让您对表示数据结构的选项保持开放。

但我们将在接下来的章节中更多地讨论如何在设计中使用 lambda。

总结

你刚刚获得了 lambda 超能力!你不仅可以在 C++中编写简单的 lambda,还知道以下内容:

  • 如何从上下文中捕获变量

  • 如何指定默认捕获类型——按引用或按值

  • 如何在捕获值时编写不可变的 lambda

  • 如何在类中使用 lambda

我们还提到了低耦合设计原则以及 lambda 如何帮助实现这一点。在接下来的章节中,我们将继续提到这一原则。

如果我告诉你,lambda 甚至比我们目前所见到的更强大,你会相信吗?好吧,我们将发现通过函数组合,我们可以从简单的 lambda 发展到复杂的 lambda。

问题

  1. 你能写出最简单的 lambda 吗?

  2. 如何编写一个将作为参数传递的两个字符串值连接起来的 lambda?

  3. 如果其中一个值是被值捕获的变量会发生什么?

  4. 如果其中一个值是被引用捕获的变量会发生什么?

  5. 如果其中一个值是被值捕获的指针会发生什么?

  6. 如果其中一个值是被引用捕获的指针会发生什么?

  7. 如果两个值都使用默认捕获说明符被值捕获会发生什么?

  8. 如果两个值都使用默认捕获说明符被引用捕获会发生什么?

  9. 如何在一个类的数据成员中写入与两个字符串值作为数据成员相同的 lambda?

  10. 如何在同一个类中将相同的 lambda 写为静态变量?

第四章:函数组合的概念

在过去的章节中,我们已经学习了如何编写纯函数和 lambda。这些是函数式编程的基本构建模块。现在是时候将它们提升到下一个级别了。

在这一章中,我们将学习如何从现有的函数中获得更多功能,从而从我们迄今为止所看到的简单示例中构建复杂的行为。

本章将涵盖以下主题:

  • 在 C++中组合函数

  • 具有多个参数的函数的基本分解策略

  • 使用函数组合消除重复(或代码相似性)

技术要求

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

该代码位于 GitHub 上的github.com/PacktPublishing/Hands-On-Functional-Programming-with-Cpp中,位于Chapter04文件夹中。它包括并使用doctest,这是一个单头开源单元测试库。您可以在其 GitHub 存储库中找到它:github.com/onqtam/doctest

什么是函数组合?

纯函数和 lambda 是函数式编程的基本组成部分。但到目前为止,我们所看到的所有示例都使用非常简单的函数。在我们的行业中,我们显然处理着更复杂的问题。然而,正如我们所看到的,我们仍然希望我们的基本组成部分非常简单,因为我们希望能够轻松理解和维护它们。那么,我们如何能够从迄今为止所看到的简单 lambda 和纯函数创建复杂的程序呢?函数式编程有一个简单的答案——让我们通过组合我们拥有的简单函数来创建更复杂的函数。在函数式编程中创建复杂函数的基本方法是函数组合。

函数组合

从本质上讲,函数组合非常简单。我们将使用一个基本示例来说明它。我们将从我们的increment函数开始。从现在开始,我将使用测试用例来展示代码的工作原理。我正在使用doctest,这是一个单头开源单元测试库(github.com/onqtam/doctest)。

让我们看看我们的increment函数及其测试用例:

auto increment = [](const int value) { return value + 1; };

TEST_CASE("Increments value"){
    CHECK_EQ(2, increment(1));
}

我们还可以说,出于某种原因,我们需要两次增加值。由于我们在思考函数,我们希望重用我们的函数。因此,我们可以调用它两次:

TEST_CASE("Increments twice"){
    CHECK_EQ(3, increment(increment(1)));
}

如果我们只需要在代码中的一个地方进行双重增量,那么这样做是可以的。如果我们需要在代码中的多个地方进行双重增量,我们将需要一个函数。很容易提取一个执行双重增量的函数:

auto incrementTwiceLambda = [](int value){return increment(increment(value));};

TEST_CASE("Increments result of addition with lambda"){
    CHECK_EQ(3, incrementTwiceLambda(1));
}

如果我们看incrementTwiceLambda,我们可以看到它是由对increment的结果调用increment形成的。

让我们暂且不谈它,转而讨论另一个情况。我们现在想要计算一个数字的平方,仍然使用函数。这很容易写,再次:

auto square = [](int value){ return value * value; };

TEST_CASE("Squares the number"){
    CHECK_EQ(4, square(2));
}

我们的下一个要求是计算一个值的增加平方。我们可以再次提取一个 lambda,将incrementsquare组合在一起,因为我们需要它们:

auto incrementSquareLambda = [](int value) { return increment(square(value));};

TEST_CASE("Increments the squared number"){
    CHECK_EQ(5, incrementSquareLambda(2));
}

这很好。然而,我们在代码中有一个隐藏的相似之处。让我们看看incrementTwiceLambdaincrementSquareLambda函数:

auto incrementTwiceLambda = [](int value){ return increment(increment(value)); };
auto incrementSquareLambda = [](int value) { return increment(square(value)); };

它们都有相同的模式——我们通过让一个函数f调用另一个函数g应用于传递给我们的函数C的值的结果来创建一个函数C。这是一种我们可以期望在使用小的纯函数时经常看到的代码相似性。最好有一个名称,甚至可能有一种方法来实现它,而不需要写太多样板代码。

事实证明,它确实有一个名字——这就是函数组合。一般来说,对于任何具有单个参数的fg函数,我们可以按照以下方式获得一个函数C

意味着对于x的每个值,

符号是函数组合的数学运算符。

正如你所看到的,我们实际上正在尝试通过对函数本身进行操作来获得其他函数!这是一种使用 lambda 而不是数字的微积分类型,并定义了对 lambda 的操作。Lambda 演算是一个合适的名称,你不觉得吗?

这就是函数组合的概念。下一个问题是-我们能否消除样板代码?

在 C++中实现函数组合

如果我们能有一个运算符,允许我们执行函数组合,那就太好了。事实上,其他编程语言提供了一个;例如,在 Groovy 中,我们可以使用<<运算符如下:

def incrementTwiceLambda = increment << increment
def incrementSquareLambda = increment << square

不幸的是,C++(尚)没有标准的函数组合运算符。但是,C++是一种强大的语言,因此应该可以为有限的情况编写自己的执行函数组合的函数。

首先,让我们清楚地定义问题。我们希望有一个compose函数,它接收两个 lambda,fg,并返回一个调用value -> f(g(value)的新 lambda。在 C++中最简单的实现看起来像下面的代码:

auto compose(auto f, auto g){
    return f, g{ return f(g(x); };
}

TEST_CASE("Increments twice with composed lambda"){
    auto incrementTwice = compose(increment, increment);
    CHECK_EQ(3, incrementTwice(1));
}

不幸的是,这段代码无法编译,因为 C++不允许使用auto类型的参数。一种方法是指定函数类型:

function<int(int)> compose(function<int(int)> f,  function<int(int)> g){
    return f, g{ return f(g(x); };
}

TEST_CASE("Increments twice with composed lambda"){
    auto incrementTwice = compose(increment, increment);
    CHECK_EQ(3, incrementTwice(1));
}

这很好地运行并通过了测试。但现在我们的compose函数取决于函数类型。这并不是很有用,因为我们将不得不为我们需要的每种类型的函数重新实现compose。虽然比以前的样板代码少了,但仍然远非理想。

但这正是 C++模板解决的问题类型。也许它们可以帮助:

template <class F, class G>
auto compose(F f, G g){
    return ={return f(g(value));};
}

TEST_CASE("Increments twice with composed lambda"){
    auto incrementTwice = compose(increment, increment);
    CHECK_EQ(3, incrementTwice(1));
}

TEST_CASE("Increments square with composed lambda"){
    auto incrementSquare = compose(increment, square);
    CHECK_EQ(5, incrementSquare(2));
}

事实上,这段代码有效!因此,我们现在知道,尽管 C++中没有函数组合的运算符,但我们可以用一个优雅的函数来实现它。

请注意,compose 返回一个 lambda,它使用惰性评估。因此,我们的函数组合函数也使用惰性评估。这是一个优势,因为组合的 lambda 只有在我们使用它时才会初始化。

函数组合不是可交换的

重要的是要意识到函数组合不是可交换的。事实上,当我们说话时很容易理解-“值的增量平方”与“增量值的平方”是不同的。然而,在代码中我们需要小心,因为这两者只是 compose 函数参数顺序不同而已:

auto incrementSquare = compose(increment, square);
auto squareIncrement = compose(square, increment);

我们已经看到了函数组合是什么,如何在 C++中实现它,以及如何在简单情况下使用它。我敢打赌你现在渴望尝试它,用于更复杂的程序。我们会到那里的,但首先让我们看看更复杂的情况。多参数函数怎么办?

复杂的函数组合

我们的 compose 函数有一个问题-它只能与接收一个参数的 lambda 一起使用。那么,如果我们想要组合具有多个参数的函数,我们该怎么办呢?

让我们看下面的例子-给定两个 lambda,multiplyincrement

auto increment = [](const int value) { return value + 1; };
auto multiply = [](const int first, const int second){ return first * second; };

我们能否获得一个增加乘法结果的 lambda?

不幸的是,我们不能使用我们的compose函数,因为它假定两个函数都有一个参数:

template <class F, class G>
auto compose(F f, G g){
    return ={return f(g(value));};
}

那么,我们有哪些选择呢?

实现更多的组合函数

我们可以实现compose函数的变体,它接受一个接收一个参数的函数f,和另一个接收两个参数的函数g

template <class F1, class G2>
auto compose12(F1 f, G2 g){
    return ={ return f(g(first, second)); };
}

TEST_CASE("Increment result of multiplication"){
    CHECK_EQ(5, compose12(increment, multiply)(2, 2));
}

这个解决方案足够简单。但是,如果我们需要获得一个函数,它将增加其参数的值,我们需要另一个compose变体:

template <class F2, class G1>
auto compose21(F2 f, G1 g){
    return ={ return f(g(first), g(second)); };
}

TEST_CASE("Multiplies two incremented values"){
    CHECK_EQ(4, compose21(multiply, increment)(1, 1));
}

如果我们只想增加其中一个参数怎么办?有很多可能的组合,虽然我们可以用多个 compose 变体来覆盖它们,但也值得考虑其他选项。

分解具有多个参数的函数

而不是实现更多的 compose 变体,我们可以查看multiply函数本身:

auto multiply = [](const int first, const int second){ return first *  
    second; };

我们可以使用一个技巧将其分解为两个分别接收一个参数的 lambda。关键思想是 lambda 只是一个值,因此它可以被函数返回。我们已经在我们的compose函数中看到了这一点;它创建并返回一个新的 lambda:

template <class F, class G>
auto compose(F f, G g){
    return ={return f(g(value));};
}

因此,我们可以通过返回一个捕获上下文中的first参数的单参数 lambda 来分解具有两个参数的函数:

auto multiplyDecomposed = [](const int first) { 
    return ={ return first * second; }; 
};

TEST_CASE("Adds using single parameter functions"){
    CHECK_EQ(4, multiplyDecomposed(2)(2));
}

让我们解开这段代码,因为它非常复杂:

  • multiplyDecomposed接收一个参数first,并返回一个 lambda。

  • 返回的 lambda 捕获了上下文中的first

  • 然后接收一个参数second

  • 它返回了firstsecond的加法结果。

事实证明,任何具有两个参数的函数都可以像这样分解。因此,我们可以使用模板编写一个通用实现。我们只需要使用相同的技巧——将函数类型指定为模板类型,并继续在我们的分解中使用它:

template<class F>
auto decomposeToOneParameter(F f){
    return ={
        return ={
            return f(first, second);
        };
    };
}

TEST_CASE("Multiplies using single parameter functions"){
    CHECK_EQ(4, decomposeToOneParameter(multiply)(2)(2));
}

这种方法很有前途;它可能简化我们的函数组合实现。让我们看看它是否有效。

增加乘法结果

让我们朝着我们的目标前进。我们能否使用compose来获得一个增加乘法结果的函数?现在很容易,因为add已经分解成了接收一个参数的 lambda。我们期望只需将multiplyDecomposedincrement组合起来:

TEST_CASE("Increment result of multiplication"){
    int first = 2;
    int second = 2;
    auto incrementResultOfMultiplication = compose(increment, 
        multiplyDecomposed);
    CHECK_EQ(5, incrementResultOfMultiplication(first)(second));
}

然而,这不会编译。我们的 compose 函数假设multiplyDecomposed(first)的结果可以传递给 increment。但是multiplyDecompose(first)返回一个 lambda,而increment接收一个整数。

因此,我们需要将incrementmultipyDecomposed(first)组合:

TEST_CASE("Increment result of multiplication"){
    int first = 2;
    int second = 2;
    auto incrementResultOfMultiplication = compose(increment, 
        multiplyDecomposed(first));
    CHECK_EQ(5, incrementResultOfMultiplication(second));
}

这样做是有效的,但我们还没有实现我们的目标。我们没有获得一个接收两个值的函数;相反,在将其与increment函数组合时,第一个值被传递给了multiplyDecomposed

幸运的是,这是使用 lambda 的完美场所,如下面的代码所示:

TEST_CASE("Increment result of multiplication final"){
    auto incrementResultOfMultiplication = [](int first, int second) {
        return compose(increment, multiplyDecomposed(first))(second);
    };

    CHECK_EQ(5, incrementResultOfMultiplication(2, 2));
}

这绝对有效,我们实现了我们的目标!incrementResultOfMultiplication lambda 接收两个参数并返回乘法的增量。不过,如果我们不必重写multiply就更好了。幸运的是,我们有我们的decomposeToOneParameter函数来帮助我们:

TEST_CASE("Increment result of multiplication"){
    auto incrementResultOfMultiplication = [](int first, int second) { 
        return compose(increment, decomposeToOneParameter(multiply) 
            (first)) (second);
 };
    int result = incrementResultOfMultiplication(2, 2);
    CHECK_EQ(5, result);
}

现在是时候看看反向组合了——如果我们想要将两个参数的增量相乘呢?

乘法增量

我们希望通过使用我们的compose函数获得一个将参数的增量相乘的函数。不使用compose的最简单的代码如下:

TEST_CASE("Multiply incremented values no compose"){
    auto multiplyIncrementedValues = [](int first, int second){
        return multiply(increment(first), increment(second)); 
    };
    int result = multiplyIncrementedValues(2, 2);
    CHECK_EQ(9, result);
}

正如我们所见,如果我们想要使用我们的 compose 版本,我们首先需要分解multiplylambda:

TEST_CASE("Multiply incremented values decompose"){
    auto multiplyIncrementedValues = [](int first, int second){
        return multiplyDecomposed(increment(first))(increment(second)); 
    };
    int result = multiplyIncrementedValues(2, 2);
    CHECK_EQ(9, result);
}

现在我们可以看到对multiplyDecomposed(increment(first))的调用,这是multiplyDecomposedincrement之间的组合。我们可以用我们的compose函数替换它,如下面的代码所示:

TEST_CASE("Multiply incremented values compose simple"){
    auto multiplyIncrementedValues = [](int first, int second){
        return compose(multiplyDecomposed, increment)(first)
            (increment(second)); 
    };

    int result = multiplyIncrementedValues(2, 2);
    CHECK_EQ(9, result);
}

再次强调,如果我们不必重写我们的multiply函数就好了。但是请记住,我们实现了一个有用的函数,可以将具有两个参数的任何函数分解为具有一个参数的两个函数。我们不必重写multiply;我们只需在其上调用我们的分解实用程序:

TEST_CASE("Multiply incremented values decompose first"){
    auto multiplyIncrementedValues = [](int first, int second){
        return compose(
                decomposeToOneParameter(multiply), 
                increment
               )(first)(increment(second)); 
    };
    int result = multiplyIncrementedValues(2, 2);
    CHECK_EQ(9, result);
}

我们实现了我们的目标!

对函数的组合和分解的反思

让我们花点时间来看看结果和我们的工作方法。好消息是,我们在学习如何以函数思维的方式思考方面取得了良好的进展。我们之前的例子只需在代码中操作函数作为一等公民就可以工作,这正是我们在设计应用程序时需要的思维方式。函数的分解和重组非常强大;掌握它,你将能够用很少的代码实现非常复杂的行为。

至于结果代码,它具有一个有趣的属性——我们可以将其泛化以在许多函数组合上重用。

但我们还没有完成!我们可以使用这些函数来从我们的代码中删除某些类型的重复。让我们看看如何做到这一点。

使用函数组合来消除重复

到目前为止,我们已经看到了如何以各种方式编写组合 lambda 的函数。但是代码往往会重复,因此我们希望使这种方法更加通用。我们确实可以进一步进行;让我们看几个例子。

泛化增量乘法结果

让我们再看看我们的incrementResultOfMultiplication lambda:

 auto incrementResultOfMultiplication = [](int first, int second) { 
     return compose(increment, decomposeToOneParameter(multiply) 
        (first))(second);
  };

这里有一些有趣的东西——它并不特定于“增量”和“乘法”。由于 lambda 只是值,我们可以将它们作为参数传递并获得一个通用的composeWithTwoParameters函数:

template <class F, class G>
auto composeWithTwoParameters(F f, G g){
    return = { 
        return compose(
                f, 
                decomposeToOneParameter(g)(first)
                )(second);
   };
};

TEST_CASE("Increment result of multiplication"){
    auto incrementResultOfMultiplication =  
    composeWithTwoParameters(increment, multiply);
    int result = incrementResultOfMultiplication(2, 2);
    CHECK_EQ(5, result);
}

这个函数允许我们组合任何其他两个函数f g,其中 g 接受两个参数, f 只接受一个参数

让我们再做一些。让我们泛化multiplyIncrementedValues

泛化增量乘法结果

同样,我们可以轻松地泛化我们的multiplyIncrementedValues lambda,如下面的代码所示:

    auto multiplyIncrementedValues = [](int first, int second){
        return compose(
                 decomposeToOneParameter(multiply), 
                 increment
                 )(first)(increment(second)); 
    };

同样,我们需要将“乘法”和“增量”lambda 作为参数传递:

template<class F, class G>
auto composeWithFunctionCallAllParameters(F f, G g){
    return ={
        return compose(
                decomposeToOneParameter(f), 
                g 
                )(first)(g(second)); 
    };
};

TEST_CASE("Multiply incremented values generalized"){
    auto multiplyIncrementedValues = 
    composeWithFunctionCallAllParameters(multiply, increment);
    int result = multiplyIncrementedValues(2, 2);
    CHECK_EQ(9, result);
}

我们可以使用这个新函数来创建一个函数C,它实现了g(f(first), f(second)),无论gf是什么。

我们目前的工作已经完成。

总结

如果你认为纯函数和 lambda 很强大,那么现在你将意识到通过组合它们可以做多少事情!在本章中,您学会了什么是函数组合以及如何在 C++中组合函数。

我们还做了一件更重要的事情。在本章中,我们真正开始思考函数。以下是我们学到的一些东西:

  • lambda 只是一个值,所以我们可以有返回 lambda 的函数,或者返回 lambda 的 lambda。

  • 此外,我们可以有接收一个或多个 lambda 并返回一个新 lambda 的函数。

  • 任何具有多个参数的函数都可以分解为具有单个参数和捕获值的多个 lambda。

  • 函数的操作非常复杂。如果你感到头晕,没关系——我们一直在玩强大而抽象的概念。

  • 在各种组合函数的方式上立即想出解决方案是非常困难的。最好的方法是一步一步地进行,设定明确的目标和清晰的思路,并使用本章中描述的技术来改进。

  • 函数组合可以帮助消除某些类型的重复;例如,当您有多个具有相似签名的不同函数之间的多个组合时。

  • 然而,像我们在本章中所做的那样实现 compose 函数族是有成本的——更高的抽象级别。理解对 lambda 执行操作的函数的工作方式非常困难;确实,相信我,我也很难理解结果。但是,一旦您理解了它们的目标,它们就非常容易使用。

经过所有这些努力,让我们花点时间考虑一下结果。想象一下,您已经在代码库中拥有的任何两个函数,或者您使用的库中的任何两个函数,都可以通过一个函数调用组合并表示为变量。此外,这些调用可以堆叠;您获得的函数甚至可以进一步组合。函数组合非常强大;通过非常简单的 lambda 和一些函数操作,我们可以非常快速地实现复杂的行为。

我们已经看到了如何组合两个函数。我们还需要学习函数的另一个操作——通过玩弄参数来获得新函数。

问题

  1. 什么是函数组合?

  2. 函数组合具有通常与数学运算相关联的属性。是什么?

  3. 如何将具有两个参数的add函数转换为具有一个参数的两个函数?

  4. 你如何编写一个包含两个单参数函数的 C++函数?

  5. 函数组合的优势是什么?

  6. 在函数操作的实施中有哪些潜在的缺点?

第五章:部分应用和柯里化

我们已经在探索函数式编程的过程中走得很远!我们学习了纯函数和 lambda,并借助函数组合深入了解了 lambda 演算。我们现在知道如何从其他函数创建函数。

关于 lambda 演算基础的还有一件事要学习。除了函数组合,我们还可以通过两种操作——柯里化和部分应用——从其他函数创建函数。这将完成我们对函数式构建块的讨论,并让你向前迈进,朝着使用函数进行设计。

本章将涵盖以下主题:

  • 什么是部分应用?

  • 如何在 C++中使用部分应用

  • 什么是柯里化?

  • 如何在 C++中柯里化函数

  • 柯里化和部分应用之间的关系

  • 如何将柯里化与函数组合结合

技术要求

你需要一个支持 C++ 17 的编译器。我使用的是 GCC 7.3.0。

代码在 GitHub 上的github.com/PacktPublishing/Hands-On-Functional-Programming-with-CppChapter05文件夹中。它包括并使用doctest,这是一个单头开源单元测试库。你可以在它的 GitHub 仓库中找到它:github.com/onqtam/doctest

部分应用和柯里化

如果你考虑 lambda 以及我们可以对它们进行的操作来获得其他 lambda,会想到两件事:

  • 关于组合两个 lambda 的事情,我们在函数组合中已经见过

  • 关于 lambda 的参数,我们将在下一节讨论

我们可以用 lambda 的参数做什么?有两件事:

  • 将具有多个参数的 lambda 分解为具有一个参数的更多 lambda,这个操作称为柯里化

  • 通过将具有N个参数的 lambda 的一个参数绑定到一个值来获得具有N-1个参数的 lambda,这个操作称为部分应用

由于很快就会变得明显的原因,这两个操作是相关的,所以我们将一起讨论它们。

部分应用

如果你有一个带有N个参数的 lambda,部分应用意味着通过将一个参数绑定到一个值来获得另一个 lambda,从而获得一个带有N-1个参数的新 lambda。例如,我们可以对add函数进行部分应用,将其中一个参数绑定到值1,从而得到一个increment函数。在伪 C++中,它看起来像这样:

auto add = [](const int first, const int second){return first + second;};
auto increment = partialApplication(add, /*first*/ 1); 
/* equivalent with 
auto increment = [](const int second){return 1 + second;}; 
*/

就是这样!部分应用的想法相当简单。让我们看看 C++中的语法。

C++中的部分应用

部分应用的基本实现可以手动完成。我们可以简单地创建一个名为increment的 lambda,调用通用的add函数,将1作为第二个参数传递:

auto add = [](const int first, const int second) { return first + second; };
TEST_CASE("Increments using manual partial application"){
    auto increment = [](const int value) { return add(value, 1); };

    CHECK_EQ(43, increment(42));
}

这不是我们正在寻找的简洁操作,但在某些情况下可能很有用,你无法使用通用方法时。

幸运的是,STL 在我们友好的头文件functional中提供了一个更好的选择——bind函数。它的参数是函数、你想要绑定的值和占位符参数,它只是转发参数。通过调用bind获得increment函数,我们传入通用的add lambda;第一个参数的参数值1;以及指定未绑定参数的占位符:

using namespace std::placeholders; // to allow _1, _2 etc.

TEST_CASE("Increments using bind"){
    // bind the value 1 to the first parameter of add 
    // _1 is a placeholder for the first parameter of the increment    
       lambda
    auto increment = bind(add, 1, _1); 

    CHECK_EQ(43, increment(42));
}

虽然方便,但你应该意识到bind具有很高的编译时开销。当这是一个问题时,你总是可以回到之前的选项——从另一个手动编写的 lambda 直接调用更通用的 lambda。

当然,我们可以绑定两个参数。由于程序员喜欢数字42,我将add lambda 的两个参数都绑定到值141,以获得另一个 lambda,number42

TEST_CASE("Constant using bind"){
   auto number42 = bind(add, 1, 41); 
   CHECK_EQ(42, number42());
}

bind语法有时可能有点棘手,所以让我们更详细地看一下。关键是要理解参数占位符指的是结果 lambda 的参数,而不是初始 lambda 的参数

为了更清楚地说明这一点,让我们看一个添加其三个参数的 lambda 的示例:

auto addThree = [](const int first, const int second, const int third){return first + second + third;};

TEST_CASE("Adds three"){
    CHECK_EQ(42, addThree(10, 20, 12));
}

如果我们想通过将其第一个参数绑定到值10,从我们的addThree lambda 中获得另一个 lambda addTwoNumbersTo10bind的语法是什么?嗯,我们的结果 lambda addTwoNumbersTo10 将接收两个参数。它们的占位符将用 _1_2 表示。因此,我们需要告诉bind我们初始 lambda addThree的第一个参数是10。第二个参数将从addTwoNumbersTo10中转发,所以是_1。第三个参数也将从addNumbersTo10的第二个参数中转发,所以是_2。我们最终得到这段代码:

TEST_CASE("Adds two numbers to 10"){
    auto addTwoNumbersTo10 = bind(addThree, 10, _1, _2);

    CHECK_EQ(42, addTwoNumbersTo10(20, 12));
}

让我们继续。我们希望通过部分应用从我们最初的addThree lambda 中获得另一个 lambda,addTo10Plus20。结果函数将只有一个参数,_1。要绑定的其他参数将是值1020。我们最终得到以下代码:

TEST_CASE("Adds one number to 10 + 20"){
    auto addTo10Plus20 = bind(addThree, 10, 20, _1);

    CHECK_EQ(42, addTo10Plus20(12));
}

如果我们想要绑定第一个和第三个参数呢?现在应该很清楚,参数是完全相同的,但它们在bind调用中的顺序发生了变化:

TEST_CASE("Adds 10 to one number, and then to 20"){
    auto addTo10Plus20 = bind(addThree, 10, _1, 20);

    CHECK_EQ(42, addTo10Plus20(12));
}

如果我们想要绑定第二和第三个参数呢?嗯,占位符会移动,但它仍然是结果函数的唯一参数,所以 _1

TEST_CASE("Adds one number to 10, and then to 20"){
    auto addTo10Plus20 = bind(addThree, _1, 10, 20);

    CHECK_EQ(42, addTo10Plus20(12));
}

如果我们想对类方法进行部分应用呢?

类方法的部分应用

bind函数允许我们对类方法进行部分应用,但有一个问题——第一个参数必须是类的实例。例如,我们将使用一个实现两个数字之间简单相加的AddOperation类来进行示例:

class AddOperation{
    private:
        int first;
        int second;

    public:
        AddOperation(int first, int second): first(first), 
            second(second){}
        int add(){ return first + second;}
};

我们可以通过将AddOperation类的实例绑定到函数来创建一个新函数add

TEST_CASE("Bind member method"){
    AddOperation operation(41, 1);
    auto add41And1 = bind(&AddOperation::add, operation); 

    CHECK_EQ(42, add41And1());
}

更有趣的是,更接近部分应用的概念,我们可以从调用者那里转发实例参数:

TEST_CASE("Partial bind member method no arguments"){
    auto add = bind(&AddOperation::add, _1); 
    AddOperation operation(41, 1);
    CHECK_EQ(42, add(operation));
}

如果方法接收参数,那么绑定也是可能的。例如,假设我们有另一个实现AddToOperation的类:

class AddToOperation{
    private:
        int first;

    public:
        AddToOperation(int first): first(first) {}
        int addTo(int second){ return first + second;}
};

我们可以使用类的实例对addTo进行部分应用,如下面的代码所示:

TEST_CASE("Partial application member method"){
    AddToOperation operation(41);
    auto addTo41 = bind(&AddToOperation::addTo, operation, _1); 

    CHECK_EQ(42, addTo41(1));
}

类方法的部分应用表明,在函数式和面向对象编程之间进行转换是相当容易的。我们将在接下来的章节中看到如何利用这一点。在那之前,让我们为我们现在知道的部分应用和如何在 C++中使用它而感到高兴。现在是时候谈谈它的近亲柯里化了。

柯里化

让我们试着想一想软件开发中的一些著名人物,不要在互联网上搜索。有 Alan Turing,Ada Lovelace(她有一个迷人的故事),Grace Hopper,Donald Knuth,Bjarne Stroustroup,Grady Booch,可能还有其他许多人。他们中有多少人的名字不仅出现在行业中,而且还出现在两个你经常听到的事物中?对于 Alan Turing 来说,这是肯定的,他有图灵机和图灵测试,但对于其他许多人来说并非如此。

因此,令人惊讶的是,Haskell 编程语言的名称和柯里化操作的名称都来自同一个人——Haskell Curry。Haskell Curry 是一位美国数学家和逻辑学家。他研究了一种叫做组合逻辑的东西,这是函数式编程的一部分基础。

但是什么是柯里化?它与部分应用有什么关系?

什么是柯里化?

柯里化是将具有N个参数的函数分解为具有一个参数的N个函数的过程。我们可以通过变量捕获或部分应用来实现这一点。

让我们再次看看我们的add lambda:

auto add = [](const int first, const int second) { return first +  
     second; };

TEST_CASE("Adds values"){
    CHECK_EQ(42, add(25, 17));
}

我们如何分解它?关键在于 lambda 只是一个普通值,这意味着我们可以从函数中返回它。因此,我们可以传入第一个参数并返回一个捕获第一个参数并使用第一个和第二个参数的 lambda。在代码中比在文字中更容易理解,所以这里是:

auto curryAdd = [](const int first){ 
    return first{
        return first + second;
    };
};

TEST_CASE("Adds values using captured curry"){
    CHECK_EQ(42, curryAdd(25)(17));
}

让我们来解开发生了什么:

  • 我们的curryAdd lambda 返回一个 lambda。

  • 返回的 lambda 捕获第一个参数,接受第二个参数,并返回它们的和。

这就是为什么在调用它时,我们需要使用双括号。

但这看起来很熟悉,好像与偏函数应用有关。

柯里化和偏函数应用

让我们再次看看我们之前是如何进行偏函数应用的。我们通过对add函数进行偏函数应用创建了一个increment函数:

TEST_CASE("Increments using bind"){
    auto increment = bind(add, 1, _1); 

    CHECK_EQ(43, increment(42));
}

然而,让我们对我们的add函数进行柯里化:

auto curryAdd = [](const int first){ 
    return first{
        return first + second;
    };
};

TEST_CASE("Adds values using captured curry"){
    CHECK_EQ(42, curryAdd(25)(17));
}

然后,increment非常容易编写。你能看到吗?

increment lambda 只是curryAdd(1),如下面的代码所示:

TEST_CASE("Increments value"){
    auto increment = curryAdd(1);

    CHECK_EQ(43, increment(42));
}

这向我们展示了函数式编程语言常用的一个技巧——函数可以默认进行柯里化。在这样的语言中,编写以下内容意味着我们首先将add函数应用于first参数,然后将结果函数应用于second参数:

add first second

看起来好像我们正在使用参数列表调用函数;实际上,这是一个部分应用的柯里化函数。在这样的语言中,increment函数可以通过简单地编写以下内容从add函数派生出来:

increment = add 1

反之亦然。由于 C++默认情况下不进行柯里化,但提供了一种简单的偏函数应用方法,我们可以通过偏函数应用来实现柯里化。不要返回带有值捕获的复杂 lambda,只需绑定到单个值并转发结果函数的单个参数:

auto curryAddPartialApplication = [](const int first){ 
    return bind(add, first, _1);
};

TEST_CASE("Adds values using partial application curry"){
    CHECK_EQ(42, curryAddPartialApplication(25)(17));
}

但我们能走多远呢?对带有多个参数的函数进行柯里化容易吗?

对具有多个参数的函数进行柯里化

在前一节中,我们已经看到了如何对带有两个参数的函数进行柯里化。当我们转向三个参数时,柯里化函数也会增长。现在我们需要返回一个返回 lambda 的 lambda。再次,代码比任何解释都更容易理解,所以让我们来看看:

auto curriedAddThree = [](const int first){
    return first{ 
        return first, second{
            return first + second + third;
        };
    };
}; 

TEST_CASE("Add three with curry"){
    CHECK_EQ(42, curriedAddThree(15)(10)(17));
}

似乎有一个递归结构在那里。也许通过使用bind我们可以理解它?

原因是它并不那么简单,但是确实是可能的。我想写的是这样的:

bind(bind(bind(addThree, _1),_1), _1)

然而,addThree有三个参数,所以我们需要将它们绑定到某些东西。下一个bind会导致一个具有两个参数的函数,再次,我们需要将它们绑定到某些东西。因此,实际上看起来是这样的:

bind(bind(bind(addThree, ?, ?, _1), ?,_1), _1)

问号应该被之前绑定的值替换,但这在我们当前的语法中不起作用。

然而,有一个变通方法。让我们实现多个使用bind在具有N个参数的函数上的simpleCurryN函数,并将它们减少到N-1。对于一个参数的函数,结果就是以下函数:

auto simpleCurry1 = [](auto f){
     return f;
 };

对于两个参数,我们绑定第一个参数并转发下一个:

auto simpleCurry2 = [](auto f){
    return f{ return bind(f, x, _1); };
};

类似的操作也适用于三个和四个参数:

auto simpleCurry3 = [](auto f){
     return f{ return bind(f, x, y, _1); };
};
auto simpleCurry4 = [](auto f){
    return f{ return bind(f, x, y, z, _1);  
};
};

这组simpleCurryN函数允许我们编写我们的curryN函数,它接受一个具有N个参数的函数并返回其柯里化形式:

auto curry2 = [](auto f){
    return simpleCurry2(f);
 };

auto curry3 = [](auto f){
    return curry2(simpleCurry3(f));
 };

auto curry4 = [](auto f){
    return curry3(simpleCurry4(f));
};

让我们在具有两个、三个和四个参数的add lambda 上进行测试,如下面的代码所示:

TEST_CASE("Add three with partial application curry"){
    auto add = [](int a, int b) { return a+b; };
    CHECK_EQ(3, curry2(add)(1)(2));

    auto addThreeCurryThree = curry3(addThree);
    CHECK_EQ(6, curry3(addThree)(1)(2)(3));

    auto addFour = [](int a, int b, int c, int d){return a + b + c +  
        d;};
    CHECK_EQ(10, curry4(addFour)(1)(2)(3)(4));
 }

很可能我们可以通过巧妙地使用模板来重写这些函数。我将把这个练习留给读者。

目前,重要的是要看到偏函数应用如何与柯里化相连接。在默认情况下对函数进行柯里化的编程语言中,偏函数应用非常容易——只需使用更少的参数调用函数。对于其他编程语言,我们可以通过偏函数应用来实现柯里化。

这些概念非常有趣,但你可能想知道它们在实践中是否有用。让我们看看如何使用这些技术来消除重复。

使用部分应用和柯里化来消除重复

程序员长期以来一直在寻找写更少的代码做更多事情的解决方案。函数式编程提出了一个解决方案——通过从其他函数派生函数来构建函数。

我们已经在之前的例子中看到了这一点。由于increment是加法的一个特殊情况,我们可以从我们的加法函数中派生它:

auto add = [](const auto first, const auto second) { return first + second; };
auto increment = bind(add, _1, 1);

TEST_CASE("Increments"){
    CHECK_EQ(43, increment(42));
}

这对我们有什么帮助?嗯,想象一下,你的客户某天走进来告诉你我们想使用另一种加法类型。想象一下,你不得不在你的代码中到处搜索+++,并找出实现新行为的方法。

相反,使用我们的addincrement函数,再加上一点模板魔法,我们可以做到这一点:

auto add = [](const auto first, const auto second) { return first + 
    second; };

template<typename T, T one>
auto increment = bind(add, _1, one);

TEST_CASE("Increments"){
    CHECK_EQ(43, increment<int, 1>(42));
}

我们的add方法不关心它得到什么类型,只要它有一个加法运算符。我们的increment函数不关心它使用什么类型和add是如何工作的,只要你为其中一个提供一个值。而我们只用了三行代码就实现了这一点。我很少这样说代码,但这不是很美吗?

当然,你可能会说,但我们的客户并不真的想改变我们添加事物的方式。你会惊讶于用一些简单的运算符可以做多少事情。让我给你举一个简单的例子。实现一个角色在一个循环移动的线上的游戏,如下面的截图所示:

这不就是加法的修改版本吗?让我们来看看:

// Assume wrap at 20 for now
auto addWrapped = [](const auto first, const auto second) { return 
    (first + second)%20; };

TEST_CASE("Adds values"){
    CHECK_EQ(7, addWrapped(10, 17));
}

template<typename T, T one>
auto incrementWrapped = bind<T>(addWrapped, _1, one);

TEST_CASE("Increments"){
    CHECK_EQ(1, incrementWrapped<int, 1>(20));
}

嗯,这段代码看起来与add非常相似。也许我们可以使用部分应用?让我们看看:

auto addWrapped = [](const auto first, const auto second, const auto 
    wrapAt) { return (first + second) % wrapAt; };

auto add = bind(addWrapped, _1, _2, 20);

template<typename T, T one>
    auto increment = bind<T>(add, _1, one);

TEST_CASE("Increments"){
    CHECK_EQ(1, increment<int, 1>(20));
}

我们的increment函数与以前完全相同,而我们的add函数已经成为了addWrapped的部分应用。值得注意的是,为了使代码更清晰,我仍然会更改函数名称,以便非常清楚地了解函数的功能。然而,主要的观点是,部分应用和柯里化帮助我们从代码中删除某些类型的重复,使我们能够打开代码以实现我们在设计初始解决方案时并不一定知道的实现。虽然我们也可以使用面向对象编程或模板来实现这一点,但函数式解决方案通过消除副作用来限制复杂性,只需要几行代码。这使得在设计程序时成为一个值得选择。

总结

看看我们在理解函数式编程方面取得了多大的进步!我们学习了所有的构建模块——纯函数和 lambda——以及我们可以在它们上面使用的操作——柯里化、部分应用和函数组合。我们还看到了这些操作是如何相互关联的,以及我们如何使用柯里化来实现部分应用,反之亦然。我们还看到了在 C++中实现柯里化的方法。

但我们的探索才刚刚开始。下一站是——开始在更有趣的上下文中使用这些构造。现在是时候解决一个困难的问题了——我们到底如何使用函数进行设计?

问题

  1. 什么是部分函数应用?

  2. 什么是柯里化?

  3. 柯里化如何帮助我们实现部分应用?

  4. 我们如何在 C++中实现部分应用?

第二部分:使用函数进行设计

到目前为止,我们已经了解了函数式编程的基本构建模块。现在是时候让它们发挥作用,进入以函数为中心的软件设计世界了。

首先,我们将探讨如何从以命令方式编写的面向对象编程(OOP)的思维方式转变为以函数为中心的设计。为此,我们需要了解如何将输入数据转换为期望的输出数据,最好是借助现有的高阶函数。然后,我们将研究“不要重复自己”(DRY)原则以及如何使用函数操作(部分应用、柯里化和函数组合)来从代码中消除某些类型的重复。接着,我们将研究函数和类之间的关系,以及如何将纯函数分组到类中,如果我们想要将设计从以函数为中心转换为面向对象编程,以及如何将类转换为一组纯函数。

掌握了所有这些技术后,我们将学习测试驱动开发以及如何通过使用纯函数简化它。

本节将涵盖以下章节:

  • 第六章,从数据输入到数据输出的函数思维

  • 第七章,使用函数操作消除重复

  • 第八章,使用类改善内聚性

  • 第九章,函数式编程的测试驱动开发

第六章:从输入数据到输出数据的函数思维

在我迈向理解函数式编程的旅程中,我遇到了一个困难的障碍——我的思维是在完全不同的编程风格中训练的。我们称之为命令式面向对象编程。那么,我如何将我的思维模式从对象思考转变为函数思考?我如何以一种良好的方式将这两者结合起来?

我首先研究了函数式编程资源。不幸的是,其中大多数都集中在数学和概念的内在美上,这对于那些已经能够以这些术语思考的人来说是很好的。但是,如果你只是想学习它们呢?难道只能通过数学理论来学习吗?虽然我喜欢数学,但我已经生疏了,我宁愿找到更实际的方法。

我已经接触过各种编写代码的方式,比如 Coderetreats、Coding Dojos,或者与来自欧洲各地的程序员进行配对编程。我逐渐意识到,解决这个问题的一个简单方法是专注于输入和输出,而不是专注于它们之间的模型。这是学习以函数思考的一个更具体和实际的方法,接下来我们将探讨这个问题。

本章将涵盖以下主题:

  • 函数思维的基础。

  • 重新学习如何识别功能的输入和输出数据,并利用类型推断

  • 将数据转换定义为纯函数

  • 如何使用典型的数据转换,比如 map、reduce、filter 等

  • 如何使用函数思维解决问题

  • 为围绕函数设计的代码设计错误管理

技术要求

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

代码可以在 GitHub 上找到github.com/PacktPublishing/Hands-On-Functional-Programming-with-Cpp,在Chapter06文件夹中。它包括并使用了doctest,这是一个单头开源单元测试库。您可以在其 GitHub 存储库上找到它github.com/onqtam/doctest

通过函数从输入数据到输出数据

我的计算机编程教育和作为程序员的重点大多是编写代码,而不是深入理解输入和输出数据。当我学习测试驱动开发(TDD)时,这种重点发生了变化,因为这种实践迫使程序员从输入和输出开始。通过应用一种称为“TDD As If You Meant It”的极端形式,我对程序的核心定义有了新的认识——接受输入数据并返回输出数据。

然而,这并不容易。我的训练使我重新思考构成程序的事物。但后来,我意识到这些事物只是纯函数。毕竟,任何程序都可以按照以下方式编写:

  • 一组纯函数,如前所定义

  • 一组与输入/输出(I/O)交互的函数

如果我们将程序简化到最小,并将所有 I/O 分开,找出其余程序的 I/O,并为我们能够的一切编写纯函数,我们刚刚迈出了以函数思考的第一步。

接下来的问题是——这些函数应该是什么?在本章中,我们将探讨最简单的使用函数进行设计的方法:

  1. 从输入数据开始。

  2. 定义输出数据。

  3. 逐步定义一系列转换(纯函数),将输入数据转换为输出数据。

让我们看一些对比两种编写程序的方法的例子。

命令式与函数式风格的工作示例

为了展示不同的方法之间的差异,我们需要使用一个问题。我喜欢使用从游戏中衍生出的问题来练习新的编程技术。一方面,这是一个我不经常接触的有趣领域。另一方面,游戏提供了许多常见的商业应用所没有的挑战,从而使我们能够探索新的想法。

在接下来的部分中,我们将看一个问题,让人们学会如何开始以函数的方式思考——井字棋结果问题。

井字棋结果

井字棋结果问题有以下要求——给定一个可能为空的井字棋棋盘或已经有了棋子的棋盘,打印出游戏的结果,如果游戏已经结束,或者打印出仍在进行中的游戏。

看起来问题似乎相当简单,但它将向我们展示功能和命令式面向对象OO)方法之间的根本区别。

如果我们从面向对象的角度来解决问题,我们已经在考虑一些要定义的对象——一个游戏,一个玩家,一个棋盘,也许一些代表XO的表示(我称之为标记),等等。然后,我们可能会考虑如何连接这些对象——一个游戏有两个玩家和一个棋盘,棋盘上有标记或空格等等。正如你所看到的,这涉及到很多表示。然后,我们需要在某个地方实现一个computeResult方法,返回GameState,要么是XWonOWondraw,要么是InProgress。乍一看,computeResult似乎适合于Game类。该方法可能需要在Board内部循环,使用一些条件语句,并返回相应的GameState

我们将使用一些严格的步骤来帮助我们以不同的方式思考代码结构,而不是使用面向对象的方法:

  1. 清晰地定义输入;给出例子。

  2. 清晰地定义输出;给出例子。

  3. 识别一系列功能转换,你可以将其应用于输入数据,将其转换为输出数据。

在我们继续之前,请注意,这种心态的改变需要一些知识和实践。我们将研究最常见的转换,为您提供一个良好的开始,但您需要尝试这种方法。

输入和输出。

我们作为程序员学到的第一课是任何程序都有输入和输出。然后我们继续把我们的职业生涯的其余部分放在输入和输出之间发生的事情上,即代码本身。

尽管如此,输入和输出值得程序员更多的关注,因为它们定义了我们软件的要求。我们知道,软件中最大的浪费是实现了完美的功能,但却没有完成它应该完成的任务。

我注意到程序员很难重新开始思考输入和输出。对于给定功能的输入和输出应该是什么的看似简单的问题经常让他们感到困惑和困惑。所以,让我们详细看看我们问题的输入和输出数据。

在这一点上,我们将做一些意想不到的事情。我从业务分析师那里学到了一个很棒的技巧——在分析一个功能时最好从输出开始,因为输出往往比输入数据更小更清晰。所以,让我们这样做。

输出数据是什么?

我们期望什么样的输出?鉴于棋盘上可以有任何东西,或者根本没有东西,我们正在考虑以下可能性:

  • 游戏未开始

  • 游戏正在进行中

  • X赢了

  • O赢了

  • 平局

看,输出很简单!现在,我们可以看到输入数据与这些可能性之间的关系。

输入数据是什么?

在这种情况下,输入数据在问题陈述中——我们的输入是一个有棋子的棋盘。但让我们看一些例子。最简单的例子是一个空棋盘:

_ _ _ 
_ _ _ 
_ _ _

为了清晰起见,我们使用_来表示棋盘上的空格。

当然,空白的棋盘对应于“游戏未开始”的输出。

这足够简单了。现在,让我们看一个上面有几步的例子:

X _ _    
O _ _ 
_ _ _

XO都已经走了他们的步子,但游戏仍在进行中。我们可以提供许多进行中的游戏的例子:

X X _ 
O _ _ 
_ _ _

这是另一个例子:

X X O 
O _ _ 
_ _ _

有一些例子在井字棋游戏中永远不会发生,比如这个:

X X _ 
O X _ 
X _ _

在这种情况下,X已经走了四步,而O只走了一步,这是井字棋规则不允许的。我们现在将忽略这种情况,只返回一个进行中的游戏。不过,一旦我们完成了代码的其余部分,你可以自己实现这个算法。

让我们看一个X赢得的游戏:

X X X 
O O _ 
_ _ _

X赢了,因为第一行被填满了。X还有其他赢的方式吗?是的,在一列上:

X _ _ 
X O O 
X _ _

它也可以在主对角线上获胜:

X O _ 
O X _ 
_ _ X

这是X在次对角线上的胜利:

_ O X 
O X _ 
X _ _

同样地,我们有O通过填充一条线获胜的例子:

X X _ 
O O O 
X _ _

这是通过填充一列获胜的情况:

X O _ 
X O X 
_ O _

这是O在主对角线上的胜利:

O X _ 
_ O X 
X _ O

这是通过次对角线获胜的情况:

X X O 
_ O X 
O _ _

那么,怎么样才能结束成为平局呢?很简单——所有的方格都被填满了,但没有赢家:

X X O 
O X X 
X O O

我们已经看过了所有可能的输出的例子。现在是时候看看数据转换了。

数据转换

我们如何将输入转换为输出?为了做到这一点,我们将不得不选择一个可能的输出来先解决。现在最容易的是X获胜的情况。那么,X怎么赢?

根据游戏规则,如果棋盘上的一条线、一列或一条对角线被X填满,X就赢了。让我们写下所有可能的情况。如果发生以下任何一种情况,X就赢了:

  • 任何一条线都被X填满了,或者

  • 任何一列都被X填满,或者

  • 主对角线被X填满,或者

  • 次对角线被X填满了。

为了实现这一点,我们需要一些东西:

  • 从棋盘上得到所有的线。

  • 从棋盘上得到所有的列。

  • 从棋盘上得到主对角线和次对角线。

  • 如果它们中的任何一个被X填满了,X就赢了!

我们可以用另一种方式来写这个:

board -> collection(all lines, all columns, all diagonals) -> any(collection, filledWithX) -> X won

filledWithX是什么意思?让我们举个例子;我们正在寻找这样的线:

X X X

我们不是在寻找X O XX _ X这样的线。

听起来我们正在检查一条线、一列或一条对角线上的所有标记是否都是'X'。让我们将这个检查视为一个转换:

line | column | diagonal -> all tokens equal X -> line | column | diagonal filled with X

因此,我们的转换集合变成了这样:

board -> collection(all lines, all columns, all diagonals) -> if any(collection, filledWithX) -> X won 

filledWithX(line|column|diagonal L) = all(token on L equals 'X')

还有一个问题——我们如何得到线、列和对角线?我们可以分别看待这个问题,就像我们看待大问题一样。我们的输入肯定是棋盘。我们的输出是由第一行、第二行和第三行、第一列、第二列和第三列、主对角线和次对角线组成的列表。

下一个问题是,什么定义了一条线?嗯,我们知道如何得到第一条线——我们使用[0, 0][0, 1][0, 2]坐标。第二条线有[1, 0][1, 1][1, 2]坐标。列呢?嗯,第一列有[1, 0][1, 1][2, 1]坐标。而且,正如我们将看到的,对角线也是由特定的坐标集定义的。

那么,我们学到了什么?我们学到了为了得到线、列和对角线,我们需要以下的转换:

board -> collection of coordinates for lines, columns, diagonals -> apply coordinates to the board -> obtain list of elements for lines, columns, and diagonals

这就结束了我们的分析。现在是时候转向实现了。所有之前的转换都可以通过使用函数式构造来用代码表达。事实上,一些转换是如此常见,以至于它们已经在标准库中实现了。让我们看看我们如何可以使用它们!

使用all_of来判断是否被X填满

我们将要看的第一个转换是all_of。给定一个集合和一个返回布尔值的函数(也称为逻辑谓词),all_of将谓词应用于集合的每个元素,并返回结果的逻辑与。让我们看一些例子:

auto trueForAll = [](auto x) { return true; };
auto falseForAll = [](auto x) { return false; };
auto equalsChara = [](auto x){ return x == 'a';};
auto notChard = [](auto x){ return x != 'd';};

TEST_CASE("all_of"){
    vector<char> abc{'a', 'b', 'c'};

    CHECK(all_of(abc.begin(), abc.end(), trueForAll));
    CHECK(!all_of(abc.begin(), abc.end(), falseForAll));
    CHECK(!all_of(abc.begin(), abc.end(), equalsChara));
    CHECK(all_of(abc.begin(), abc.end(), notChard));
}

all_of函数接受两个定义范围开始和结束的迭代器和一个谓词作为参数。当你想将转换应用于集合的子集时,迭代器是有用的。由于我通常在整个集合上使用它,我发现反复写collection.begin()collection.end()很烦人。因此,我实现了自己简化的all_of_collection版本,它接受整个集合并处理其余部分:

auto all_of_collection = [](const auto& collection, auto lambda){
    return all_of(collection.begin(), collection.end(), lambda);
};

TEST_CASE("all_of_collection"){
    vector<char> abc{'a', 'b', 'c'};

    CHECK(all_of_collection(abc, trueForAll));
    CHECK(!all_of_collection(abc, falseForAll));
    CHECK(!all_of_collection(abc, equalsChara));
    CHECK(all_of_collection(abc, notChard));
}

知道这个转换后,编写我们的lineFilledWithX函数很容易-我们将标记的集合转换为指定标记是否为X的布尔值的集合:

auto lineFilledWithX = [](const auto& line){
    return all_of_collection(line, [](const auto& token){ return token == 'X';});
};

TEST_CASE("Line filled with X"){
    vector<char> line{'X', 'X', 'X'};

    CHECK(lineFilledWithX(line));
}

就是这样!我们可以确定我们的线是否填满了X

在我们继续之前,让我们做一些简单的调整。首先,通过为我们的vector<char>类型命名来使代码更清晰:

using Line = vector<char>;

然后,让我们检查代码是否对负面情况也能正常工作。如果Line没有填满X标记,lineFilledWithX应该返回false

TEST_CASE("Line not filled with X"){
    CHECK(!lineFilledWithX(Line{'X', 'O', 'X'}));
    CHECK(!lineFilledWithX(Line{'X', ' ', 'X'}));
}

最后,一个敏锐的读者会注意到我们需要相同的函数来满足O获胜的条件。我们现在知道如何做到这一点-记住参数绑定的力量。我们只需要提取一个lineFilledWith函数,并通过将tokenToCheck参数绑定到XO标记值,分别获得lineFilledWithXlineFilledWithO函数:

auto lineFilledWith = [](const auto line, const auto tokenToCheck){
    return all_of_collection(line, &tokenToCheck{  
        return token == tokenToCheck;});
};

auto lineFilledWithX = bind(lineFilledWith, _1, 'X'); 
auto lineFilledWithO = bind(lineFilledWith, _1, 'O');

让我们回顾一下-我们有一个Line数据结构,我们有一个可以检查该行是否填满XO的函数。我们使用all_of函数来为我们做繁重的工作;我们只需要定义我们的井字棋线的逻辑。

是时候继续前进了。我们需要将我们的棋盘转换为线的集合,由三条线、三列和两条对角线组成。为此,我们需要使用另一个函数式转换map,它在 STL 中实现为transform函数。

使用 map/transform

现在我们需要编写一个将棋盘转换为线、列和对角线列表的函数;因此,我们可以使用一个将集合转换为另一个集合的转换。这种转换通常在函数式编程中称为map,在 STL 中实现为transform。为了理解它,我们将使用一个简单的例子;给定一个字符向量,让我们用'a'替换每个字符:

TEST_CASE("transform"){
    vector<char> abc{'a', 'b', 'c'};

// Not the best version, see below
vector<char> aaa(3);
transform(abc.begin(), abc.end(), aaa.begin(), [](auto element){return 
    'a';});
CHECK_EQ(vector<char>{'a', 'a', 'a'}, aaa);
}

虽然它有效,但前面的代码示例是天真的,因为它用稍后被覆盖的值初始化了aaa向量。我们可以通过首先在aaa向量中保留3个元素,然后使用back_inserter来避免这个问题,这样transform就会自动在aaa向量上调用push_back

TEST_CASE("transform-fixed") { 
    const auto abc = vector{'a', 'b', 'c'}; 
    vector<char> aaa; 
    aaa.reserve(abc.size()); 
    transform(abc.begin(), abc.end(), back_inserter(aaa), 
            [](const char elem) { return 'a'; }
    ); 
    CHECK_EQ(vector{'a', 'a', 'a'}, aaa); 
}

如你所见,transform基于迭代器,就像all_of一样。到目前为止,你可能已经注意到我喜欢保持事情简单,专注于我们要完成的任务。没有必要一直写这些;相反,我们可以实现我们自己的简化版本,它可以在整个集合上工作,并处理围绕此函数的所有仪式。

简化转换

让我们尝试以最简单的方式实现transform_all函数:

auto transform_all = [](auto const source, auto lambda){
    auto destination; // Compilation error: the type is not defined
    ...
}

不幸的是,当我们尝试以这种方式实现它时,我们需要一个目标集合的类型。这样做的自然方式是使用 C++模板并传递Destination类型参数:

template<typename Destination>
auto transformAll = [](auto const source,  auto lambda){
    Destination result;
    result.reserve(source.size());
    transform(source.begin(), source.end(), back_inserter(result), 
        lambda);
    return result;
};

这对于任何具有push_back函数的集合都有效。一个很好的副作用是,我们可以用它来连接string中的结果字符:

auto turnAllToa = [](auto x) { return 'a';};

TEST_CASE("transform all"){
    vector abc{'a', 'b', 'c'};

    CHECK_EQ(vector<char>({'a', 'a', 'a'}), transform_all<vector<char>>
        (abc, turnAllToa));
    CHECK_EQ("aaa", transform_all<string>(abc,turnAllToa));
}

使用transform_allstring允许我们做一些事情,比如将小写字符转换为大写字符:

auto makeCaps = [](auto x) { return toupper(x);};

TEST_CASE("transform all"){
    vector<char> abc = {'a', 'b', 'c'};

    CHECK_EQ("ABC", transform_all<string>(abc, makeCaps));
}

但这还不是全部-输出类型不一定要与输入相同:

auto toNumber = [](auto x) { return (int)x - 'a' + 1;};

TEST_CASE("transform all"){
    vector<char> abc = {'a', 'b', 'c'};
    vector<int> expected = {1, 2, 3};

    CHECK_EQ(expected, transform_all<vector<int>>(abc, toNumber));
}

因此,transform函数在我们需要将一个集合转换为另一个集合时非常有用,无论是相同类型还是不同类型。在back_inserter的支持下,它还可以用于string输出,从而实现对任何类型集合的字符串表示的实现。

我们现在知道如何使用 transform 了。所以,让我们回到我们的问题。

我们的坐标

我们的转换从计算坐标开始。因此,让我们首先定义它们。STL pair类型是坐标的简单表示:

using Coordinate = pair<int, int>;

从板和坐标获取一条线

假设我们已经为一条线、一列或一条对角线构建了坐标列表,我们需要将令牌的集合转换为Line参数。这很容易通过我们的transformAll函数完成:

auto accessAtCoordinates = [](const auto& board, const Coordinate&  
    coordinate){
        return board[coordinate.first][coordinate.second];
};

auto projectCoordinates = [](const auto& board, const auto&  
    coordinates){
        auto boardElementFromCoordinates = bind(accessAtCoordinates,  
        board, _1);
        return transform_all<Line>(coordinates,  
            boardElementFromCoordinates);
};

projectCoordinates lambda 接受板和坐标列表,并返回与这些坐标对应的板元素列表。我们在坐标列表上使用transformAll,并使用一个接受两个参数的转换——board参数和coordinate参数。然而,transformAll需要一个带有单个参数的 lambda,即Coordinate值。因此,我们必须要么捕获板的值,要么使用部分应用。

现在我们只需要构建我们的线、列和对角线的坐标列表了!

从板上得到一条线

我们可以通过使用前一个函数projectCoordinates轻松地从板上得到一条线:

auto line = [](auto board, int lineIndex){
   return projectCoordinates(board, lineCoordinates(board, lineIndex));
};

line lambda 接受boardlineIndex,构建线坐标列表,并使用projectCoordinates返回线。

那么,我们如何构建线坐标?嗯,由于我们有lineIndexCoordinate作为一对,我们需要在(lineIndex, 0)(lineIndex, 1)(lineIndex, 2)上调用make_pair。这看起来也像是一个transform调用;输入是一个{0, 1, 2}集合,转换是make_pair(lineIndex, index)。让我们写一下:

auto lineCoordinates = [](const auto board, auto lineIndex){
    vector<int> range{0, 1, 2};
    return transformAll<vector<Coordinate>>(range, lineIndex{return make_pair(lineIndex, index);});
};

范围

但是{0, 1, 2}是什么?在其他编程语言中,我们可以使用范围的概念;例如,在 Groovy 中,我们可以编写以下内容:

def range = [0..board.size()]

范围非常有用,并且已经在 C++ 20 标准中被采用。我们将在第十四章中讨论它们,使用 Ranges 库进行惰性求值。在那之前,我们将编写我们自己的toRange函数:

auto toRange = [](auto const collection){
    vector<int> range(collection.size());
    iota(begin(range), end(range), 0);
    return range;
};

toRange接受一个集合作为输入,并从0collection.size()创建range。因此,让我们在我们的代码中使用它:

using Board = vector<Line>;
using Line = vector<char>;

auto lineCoordinates = [](const auto board, auto lineIndex){
    auto range = toRange(board);
    return transform_all<vector<Coordinate>>(range, lineIndex{return make_pair(lineIndex, index);});
};

TEST_CASE("lines"){
    Board board {
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

    Line expectedLine0 = {'X', 'X', 'X'};
    CHECK_EQ(expectedLine0, line(board, 0));
    Line expectedLine1 = {' ', 'O', ' '};
    CHECK_EQ(expectedLine1, line(board, 1));
    Line expectedLine2 = {' ', ' ', 'O'};
    CHECK_EQ(expectedLine2, line(board, 2));
}

我们已经把所有元素都放在了正确的位置,所以现在是时候看看列了。

获取列

获取列的代码与获取线的代码非常相似,只是我们保留columnIndex而不是lineIndex。我们只需要将其作为参数传递:

auto columnCoordinates = [](const auto& board, const auto columnIndex){
    auto range = toRange(board);
    return transformAll<vector<Coordinate>>(range, columnIndex{return make_pair(index, columnIndex);});
};

auto column = [](auto board, auto columnIndex){
    return projectCoordinates(board, columnCoordinates(board,  
        columnIndex));
};

TEST_CASE("all columns"){
    Board board{
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

    Line expectedColumn0{'X', ' ', ' '};
    CHECK_EQ(expectedColumn0, column(board, 0));
    Line expectedColumn1{'X', 'O', ' '};
    CHECK_EQ(expectedColumn1, column(board, 1));
    Line expectedColumn2{'X', ' ', 'O'};
    CHECK_EQ(expectedColumn2, column(board, 2));
}

这不是很酷吗?通过几个函数和标准的函数变换,我们可以在我们的代码中构建复杂的行为。现在对角线变得轻而易举了。

获取对角线

主对角线由相等的行和列坐标定义。使用与之前相同的机制读取它非常容易;我们构建相等索引的对,并将它们传递给projectCoordinates函数:

auto mainDiagonalCoordinates = [](const auto board){
    auto range = toRange(board);
    return transformAll<vector<Coordinate>>(range, [](auto index) 
       {return make_pair(index, index);});
};
auto mainDiagonal = [](const auto board){
    return projectCoordinates(board, mainDiagonalCoordinates(board));
};

TEST_CASE("main diagonal"){
    Board board{
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

    Line expectedDiagonal = {'X', 'O', 'O'};

    CHECK_EQ(expectedDiagonal, mainDiagonal(board));
}

那么对于次对角线呢?嗯,坐标的总和总是等于board参数的大小。在 C++中,我们还需要考虑基于 0 的索引,因此在构建坐标列表时,我们需要通过1进行适当的调整:

auto secondaryDiagonalCoordinates = [](const auto board){
    auto range = toRange(board);
    return transformAll<vector<Coordinate>>(range, board 
        {return make_pair(index, board.size() - index - 1);});
};

auto secondaryDiagonal = [](const auto board){
    return projectCoordinates(board, 
        secondaryDiagonalCoordinates(board));
};

TEST_CASE("secondary diagonal"){
    Board board{
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

    Line expectedDiagonal{'X', 'O', ' '};

    CHECK_EQ(expectedDiagonal, secondaryDiagonal(board));
}

获取所有线、所有列和所有对角线

说到这一点,我们现在可以构建所有线、列和对角线的集合了。有多种方法可以做到这一点;因为我要写一个以函数式风格编写的通用解决方案,我将再次使用transform。我们需要将(0..board.size())范围转换为相应的线列表和列列表。然后,我们需要返回一个包含主对角线和次对角线的集合:

typedef vector<Line> Lines;

auto allLines = [](auto board) {
    auto range = toRange(board);
    return transform_all<Lines>(range, board { return 
        line(board, index);});
};

auto allColumns = [](auto board) {
    auto range = toRange(board);
    return transform_all<Lines>(range, board { return 
        column(board, index);});
};

auto allDiagonals = [](auto board) -> Lines {
    return {mainDiagonal(board), secondaryDiagonal(board)};
};

我们只需要一件事情——一种连接这三个集合的方法。由于向量没有实现这个功能,推荐的解决方案是使用insertmove_iterator,从而将第二个集合的项目移动到第一个集合的末尾:

auto concatenate = [](auto first, const auto second){
    auto result(first);
    result.insert(result.end(), make_move_iterator(second.begin()), 
        make_move_iterator(second.end()));
    return result;
};

然后,我们只需将这三个集合合并为两个步骤:

auto concatenate3 = [](auto first, auto const second, auto const third){
    return concatenate(concatenate(first, second), third);
};

现在我们可以从棋盘中获取所有行、列和对角线的完整列表,就像你在下面的测试中看到的那样:

auto allLinesColumnsAndDiagonals = [](const auto board) {
    return concatenate3(allLines(board), allColumns(board),  
        allDiagonals(board));
};

TEST_CASE("all lines, columns and diagonals"){
    Board board {
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

    Lines expected {
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'},
        {'X', ' ', ' '},
        {'X', 'O', ' '},
        {'X', ' ', 'O'},
        {'X', 'O', 'O'},
        {'X', 'O', ' '}
    };

    auto all = allLinesColumnsAndDiagonals(board);
    CHECK_EQ(expected, all);
}

在找出X是否获胜的最后一步中只剩下一个任务。我们有所有行、列和对角线的列表。我们知道如何检查一行是否被X填满。我们只需要检查列表中的任何一行是否被X填满。

使用 any_of 来检查 X 是否获胜

类似于all_of,另一个函数构造帮助我们在集合上应用的谓词之间表达 OR 条件。在 STL 中,这个构造是在any_of函数中实现的。让我们看看它的作用:

TEST_CASE("any_of"){
    vector<char> abc = {'a', 'b', 'c'};

    CHECK(any_of(abc.begin(), abc.end(), trueForAll));
    CHECK(!any_of(abc.begin(), abc.end(), falseForAll));
    CHECK(any_of(abc.begin(), abc.end(), equalsChara));
    CHECK(any_of(abc.begin(), abc.end(), notChard));
}

像我们在本章中看到的其他高级函数一样,它使用迭代器作为集合的开始和结束。像往常一样,我喜欢保持简单;因为我通常在完整集合上使用any_of,我喜欢实现我的辅助函数:

auto any_of_collection = [](const auto& collection, const auto& fn){
 return any_of(collection.begin(), collection.end(), fn);
};

TEST_CASE("any_of_collection"){
    vector<char> abc = {'a', 'b', 'c'};

    CHECK(any_of_collection(abc, trueForAll));
    CHECK(!any_of_collection(abc, falseForAll));
    CHECK(any_of_collection(abc, equalsChara));
    CHECK(any_of_collection(abc, notChard));
}

我们只需要在我们的列表上使用它来检查X是否是赢家:

auto xWins = [](const auto& board){
    return any_of_collection(allLinesColumnsAndDiagonals(board), 
        lineFilledWithX);
};

TEST_CASE("X wins"){
    Board board{
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

    CHECK(xWins(board));
}

这就结束了我们对X获胜条件的解决方案。在我们继续之前,能够在控制台上显示棋盘将是很好的。现在是使用map/transform的近亲——reduce的时候了,或者在 STL 中被称为accumulate

使用 reduce/accumulate 来显示棋盘

我们想在控制台上显示棋盘。通常,我们会使用可变函数,比如cout来做到这一点;然而,记住我们讨论过,虽然我们需要保持程序的某些部分可变,比如调用cout的部分,但我们应该将它们限制在最小范围内。那么,替代方案是什么呢?嗯,我们需要再次考虑输入和输出——我们想要编写一个以board作为输入并返回string表示的函数,我们可以通过使用可变函数,比如cout来显示它。让我们以测试的形式写出我们想要的:

TEST_CASE("board to string"){
    Board board{
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };
    string expected = "XXX\n O \n  O\n";

    CHECK_EQ(expected, boardToString(board));
}

为了获得这个结果,我们首先需要将board中的每一行转换为它的string表示。我们的行是vector<char>,我们需要将它转换为string;虽然有很多方法可以做到这一点,但请允许我使用带有string输出的transformAll函数:

auto lineToString = [](const auto& line){
    return transformAll<string>(line, [](const auto token) -> char { 
        return token;});
};

TEST_CASE("line to string"){
    Line line {
        ' ', 'X', 'O'
    };

    CHECK_EQ(" XO", lineToString(line));
}

有了这个函数,我们可以轻松地将一个棋盘转换为vector<string>

auto boardToLinesString = [](const auto board){
    return transformAll<vector<string>>(board, lineToString);
};

TEST_CASE("board to lines string"){
    Board board{
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };
    vector<string> expected{
        "XXX",
        " O ",
        "  O"
    };

    CHECK_EQ(expected, boardToLinesString(board));
}

最后一步是用\n将这些字符串组合起来。我们经常需要以各种方式组合集合的元素;这就是reduce发挥作用的地方。在函数式编程中,reduce是一个接受集合、初始值(例如,空的strings)和累积函数的操作。该函数接受两个参数,对它们执行操作,并返回一个新值。

让我们看几个例子。首先是添加一个数字向量的经典例子:

TEST_CASE("accumulate"){
    vector<int> values = {1, 12, 23, 45};

    auto add = [](int first, int second){return first + second;};
    int result = accumulate(values.begin(), values.end(), 0, add);
    CHECK_EQ(1 + 12 + 23 + 45, result);
}

以下向我们展示了如果需要添加具有初始值的向量应该怎么做:

    int resultWithInit100 = accumulate(values.begin(), values.end(),  
        100, add);
    CHECK_EQ(1oo + 1 + 12 + 23 + 45, resultWithInit100);

同样,我们可以连接strings

    vector<string> strings {"Alex", "is", "here"};
    auto concatenate = [](const string& first, const string& second) ->  
        string{
        return first + second;
    };
    string concatenated = accumulate(strings.begin(), strings.end(),  
        string(), concatenate);
    CHECK_EQ("Alexishere", concatenated);

或者,我们可以添加一个前缀:

    string concatenatedWithPrefix = accumulate(strings.begin(),  
        strings.end(), string("Pre_"), concatenate);
    CHECK_EQ("Pre_Alexishere", concatenatedWithPrefix);

像我们在整个集合上使用默认值作为初始值的简化实现一样,我更喜欢使用decltype魔术来实现它:

auto accumulateAll = [](auto source, auto lambda){
    return accumulate(source.begin(), source.end(), typename  
        decltype(source)::value_type(), lambda);
};

这只留下了我们的最后一个任务——编写一个连接string行的实现,使用换行符:

auto boardToString = [](const auto board){
    auto linesAsString = boardToLinesString(board);
    return accumulateAll(linesAsString, 
        [](string current, string lineAsString) { return current + lineAsString + "\n"; }
    );
};
TEST_CASE("board to string"){
    Board board{
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };
    string expected = "XXX\n O \n  O\n";

    CHECK_EQ(expected, boardToString(board));
}

现在我们可以使用cout << boardToString来显示我们的棋盘。再次,我们使用了一些函数变换和非常少的自定义代码来将一切整合在一起。这非常好。

map/reduce组合,或者在 STL 中被称为transform/accumulate,是功能性编程中非常强大且非常常见的。我们经常需要从一个集合开始,多次将其转换为另一个集合,然后再组合集合的元素。这是一个如此强大的概念,以至于它是大数据分析的核心,使用诸如 Apache Hadoop 之类的工具,尽管在机器级别上进行了扩展。这表明,通过掌握这些转换,您可能最终会在意想不到的情况下应用它们,使自己成为一个不可或缺的问题解决者。很酷,不是吗?

使用find_if来显示特定的赢的细节

我们现在很高兴,因为我们已经解决了X的井字游戏结果问题。然而,正如总是一样,需求会发生变化;我们现在不仅需要说X是否赢了,还需要说赢了在哪里——在哪一行、或列、或对角线。

幸运的是,我们已经有了大部分元素。由于它们都是非常小的函数,我们只需要以一种有助于我们的方式重新组合它们。让我们再次从数据的角度思考——我们的输入数据现在是一组行、列和对角线;我们的结果应该是类似于X在第一行的信息。我们只需要增强我们的数据结构,以包含有关每行的信息;让我们使用map

    map<string, Line> linesWithDescription{
        {"first line", line(board, 0)},
        {"second line", line(board, 1)},
        {"last line", line(board, 2)},
        {"first column", column(board, 0)},
        {"second column", column(board, 1)},
        {"last column", column(board, 2)},
        {"main diagonal", mainDiagonal(board)},
        {"secondary diagonal", secondaryDiagonal(board)},
    };

我们知道如何找出X是如何赢的——通过我们的lineFilledWithX谓词函数。现在,我们只需要在地图中搜索符合lineFilledWithX谓词的行,并返回相应的消息。

这是功能性编程中的一个常见操作。在 STL 中,它是用find_if函数实现的。让我们看看它的运行情况:

auto equals1 = [](auto value){ return value == 1; };
auto greaterThan11 = [](auto value) { return value > 11; };
auto greaterThan50 = [](auto value) { return value > 50; };

TEST_CASE("find if"){
    vector<int> values{1, 12, 23, 45};

    auto result1 = find_if(values.begin(), values.end(), equals1);
    CHECK_EQ(*result1, 1);

    auto result12 = find_if(values.begin(), values.end(), 
        greaterThan11);
    CHECK_EQ(*result12, 12);

    auto resultNotFound = find_if(values.begin(), values.end(), 
        greaterThan50);
    CHECK_EQ(resultNotFound, values.end());
}

find_if根据谓词在集合中查找并返回结果的指针,如果找不到任何内容,则返回指向end()迭代器的指针。

像往常一样,让我们实现一个允许在整个集合中搜索的包装器。我们需要以某种方式表示not found的值;幸运的是,我们可以使用 STL 中的可选类型:

auto findInCollection = [](const auto& collection, auto fn){
    auto result = find_if(collection.begin(), collection.end(), fn);
    return (result == collection.end()) ? nullopt : optional(*result);
};

TEST_CASE("find in collection"){
    vector<int> values {1, 12, 23, 45};

    auto result1 = findInCollection(values, equals1);
    CHECK_EQ(result1, 1);

    auto result12 = findInCollection(values, greaterThan11);
    CHECK_EQ(result12, 12);

    auto resultNotFound = findInCollection(values, greaterThan50);
    CHECK(!resultNotFound.has_value());
}

现在,我们可以轻松实现新的要求。我们可以使用我们新实现的findInCollection函数找到被X填满的行,并返回相应的描述。因此,我们可以告诉用户X是如何赢的——是在一行、一列还是对角线上:

auto howDidXWin = [](const auto& board){
    map<string, Line> linesWithDescription = {
        {"first line", line(board, 0)},
        {"second line", line(board, 1)},
        {"last line", line(board, 2)},
        {"first column", column(board, 0)},
        {"second column", column(board, 1)},
        {"last column", column(board, 2)},
        {"main diagonal", mainDiagonal(board)},
        {"secondary diagonal", secondaryDiagonal(board)},
    };
    auto found = findInCollection(linesWithDescription,[](auto value) 
        {return lineFilledWithX(value.second);}); 
    return found.has_value() ? found->first : "X did not win";
};

当然,我们应该从棋盘生成地图,而不是硬编码。我将把这个练习留给读者;只需再次使用我们最喜欢的transform函数即可。

完成我们的解决方案

虽然我们已经为X赢实现了解决方案,但现在我们需要研究其他可能的输出。让我们先来看最简单的一个——O赢。

检查O是否赢了

检查O是否赢很容易——我们只需要在我们的函数中做一个小改变。我们需要一个新函数oWins,它检查任何一行、一列或对角线是否被O填满:

auto oWins = [](auto const board){
    return any_of_collection(allLinesColumnsAndDiagonals(board),  
        lineFilledWithO);
};
TEST_CASE("O wins"){
    Board board = {
        {'X', 'O', 'X'},
        {' ', 'O', ' '},
        {' ', 'O', 'X'}
    };

    CHECK(oWins(board));
}

我们使用与xWins相同的实现,只是在作为参数传递的 lambda 中稍作修改。

使用none_of检查平局

那么平局呢?嗯,当board参数已满且既没有X也没有O赢时,就会出现平局:

auto draw = [](const auto& board){
    return full(board) && !xWins(board) && !oWins(board); 
};

TEST_CASE("draw"){
    Board board {
        {'X', 'O', 'X'},
        {'O', 'O', 'X'},
        {'X', 'X', 'O'}
    };

    CHECK(draw(board));
}

满棋盘意味着每一行都已满:

auto full = [](const auto& board){
    return all_of_collection(board, fullLine);
};

那么我们如何知道一行是否已满?嗯,我们知道如果行中的任何一个标记都不是空(' ')标记,那么该行就是满的。正如您现在可能期望的那样,STL 中有一个名为none_of的函数,可以为我们检查这一点:

auto noneOf = [](const auto& collection, auto fn){
    return none_of(collection.begin(), collection.end(), fn);
};

auto isEmpty = [](const auto token){return token == ' ';};
auto fullLine = [](const auto& line){
    return noneOf(line, isEmpty);
};

检查游戏是否正在进行中

最后一种情况是游戏仍在进行中。最简单的方法就是检查游戏是否没有赢,且棋盘还没有满:

auto inProgress = [](const auto& board){
    return !full(board) && !xWins(board) && !oWins(board); 
};
TEST_CASE("in progress"){
    Board board {
        {'X', 'O', 'X'},
        {'O', ' ', 'X'},
        {'X', 'X', 'O'}
    };

    CHECK(inProgress(board));
}

恭喜,我们做到了!我们使用了许多功能转换来实现了井字游戏结果问题;还有我们自己的一些 lambda。但更重要的是,我们学会了如何开始像一个功能性程序员一样思考——清晰地定义输入数据,清晰地定义输出数据,并找出可以将输入数据转换为所需输出数据的转换。

使用可选类型进行错误管理

到目前为止,我们已经用函数式风格编写了一个小程序。但是错误情况怎么处理呢?

显然,我们仍然可以使用 C++机制——返回值或异常。但是函数式编程还可以看作另一种方式——将错误视为数据。

我们在实现find_if包装器时已经看到了这种技术的一个例子:

auto findInCollection = [](const auto& collection, auto fn){
    auto result = find_if(collection.begin(), collection.end(), fn);
    return (result == collection.end()) ? nullopt : optional(*result);
};

我们使用了optional类型,而不是抛出异常或返回collection.end(),这是一个本地值。如其名称所示,optional 类型表示一个可能有值,也可能没有值的变量。可选值可以被初始化,可以使用底层类型支持的值,也可以使用nullopt——一个默认的非值,可以这么说。

当在我们的代码中遇到可选值时,我们需要考虑它,就像我们在检查X赢得函数中所做的那样:

return found.has_value() ? found->first : "X did not win";

因此,“未找到”条件不是错误;相反,它是我们代码和数据的正常部分。事实上,处理这种情况的另一种方法是增强findInCollection,在未找到时返回指定的值:

auto findInCollectionWithDefault = [](auto collection, auto 
    defaultResult, auto lambda){
        auto result = findInCollection(collection, lambda);
        return result.has_value() ? (*result) : defaultResult;
}; 

现在我们可以使用findInCollectionWithDefault来在X没有赢得情况下调用howDidXWin时获得一个X 没有赢的消息:

auto howDidXWin = [](auto const board){
    map<string, Line> linesWithDescription = {
        {"first line", line(board, 0)},
        {"second line", line(board, 1)},
        {"last line", line(board, 2)},
        {"first column", column(board, 0)},
        {"second column", column(board, 1)},
        {"last column", column(board, 2)},
        {"main diagonal", mainDiagonal(board)},
        {"secondary diagonal", secondaryDiagonal(board)},
        {"diagonal", secondaryDiagonal(board)},
    };
    auto xDidNotWin = make_pair("X did not win", Line());
    auto xWon = [](auto value){
        return lineFilledWithX(value.second);
    };

    return findInCollectionWithDefault(linesWithDescription, xDidNotWin, xWon).first; 
};

TEST_CASE("X did not win"){
    Board board {
        {'X', 'X', ' '},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

    CHECK_EQ("X did not win", howDidXWin(board));
}

我最好的建议是这样——对所有异常情况使用异常,并将其他所有情况作为数据结构的一部分。使用可选类型,或者带有默认值的转换。你会惊讶于错误管理变得多么容易和自然。

总结

在本章中,我们涵盖了很多内容!我们经历了一次发现之旅——我们首先列出了问题的输出和相应的输入,对它们进行了分解,并找出了如何将输入转换为所需的输出。我们看到了当需要新功能时,小函数和函数操作如何给我们带来灵活性。我们看到了如何使用anyallnonefind_ifmap/transformreduce/accumulate,以及如何使用可选类型或默认值来支持代码中的所有可能情况。

现在我们已经了解了如何以函数式风格编写代码,是时候在下一章中看看这种方法如何与面向对象编程结合了。

第七章:使用函数操作消除重复

软件设计中的一个关键原则是减少代码重复。函数式构造通过柯里化和函数组合提供了额外的机会来减少代码重复。

本章将涵盖以下主题:

  • 如何以及为什么避免重复代码

  • 如何识别代码相似性

  • 使用柯里化来消除某些类型的代码相似性

  • 使用组合来消除某些类型的代码相似性

  • 使用 lambda 表达式或组合来消除某些类型的代码相似性

技术要求

你需要一个支持 C++ 17 的编译器。我使用的是 GCC 7.3.0。

该代码可以在 GitHub 上找到,网址为github.com/PacktPublishing/Hands-On-Functional-Programming-with-Cpp,在Chapter07文件夹中。它包括并使用了doctest,这是一个单头文件的开源单元测试库。你可以在它的 GitHub 仓库上找到它,网址为github.com/onqtam/doctest

使用函数操作来消除重复

长时间维护代码时,只需要在一个地方更改代码,以及可以重新组合现有的代码片段,会更加容易。朝着这个理想的最有效方法之一是识别并消除代码中的重复。函数式编程的操作——部分应用、柯里化和函数组合——提供了许多机会,使代码更清晰,重复更少。

但首先,让我们了解重复是什么,以及为什么我们需要减少它。首先,我们将看看不要重复自己(DRY)原则,然后看看重复和代码相似性之间的关系。最后,我们将看看如何消除代码相似性。

DRY 原则

软件开发中核心书籍的数量出乎意料地少。当然,有很多关于细节和帮助人们更好地理解这些想法的书籍,但是关于核心思想的书籍却非常少而且陈旧。能够列入核心书籍名单对作者来说是一种荣誉,也是该主题极其重要的一个暗示。许多程序员会把《程序员修炼之道》(Andrew Hunt 和 David Thomas 合著,1999 年出版)列入这样的书单。这本书详细介绍了一个原则,对于长期从事大型代码库工作的人来说非常有意义——DRY 原则。

在核心,DRY 原则是基于代码是存储知识的理解。每个函数和每个数据成员都代表了对问题的知识。理想情况下,我们希望避免在系统中重复存储知识。换句话说,无论你在找什么,它都应该只存在于一个地方。不幸的是,大多数代码库都是WET(写两遍、我们喜欢打字或浪费每个人的时间的缩写),而不是 DRY。

然而,消除重复的想法是很久以前就有的。肯特·贝克在 1990 年代曾提到过,作为极限编程(XP)实践的一部分。肯特·贝克描述了简单设计的四个要素,这是一种获得或改进软件设计的思维工具。

简单的设计意味着它做了以下事情:

  • 通过了测试

  • 揭示意图

  • 减少重复

  • 元素更少

我从 J.B. Rainsberger 那里学到了这些规则,他也致力于简化这些规则。他教会我,在大多数情况下,专注于三件事就足够了——测试代码、改进命名和减少重复。

但这并不是唯一提到消除重复的地方。这个原则以各种方式出现在 Unix 设计哲学中,在领域驱动设计(DDD)技术中,作为测试驱动开发(TDD)实践的帮助,以及许多其他方面。可以说这是一个良好软件设计的普遍原则,每当我们谈论模块内部代码的结构时,使用它是有意义的。

重复和相似

在我迈向学习良好软件设计的旅程中,我意识到术语“重复”对于表达我们试图实现的哲学非常有用,但很难理解如何将其付诸实践。我找到了一个更好的名字,用于描述我在尝试改进设计时寻找的东西——我寻找“代码相似之处”。一旦我找到相似之处,我会问它们是否显示了更深层次的重复,还是它们只是偶然事件。

我也及时注意到,我寻找了一些特定类型的相似之处。以下是一些例子:

  • 相似的名称,无论是函数、参数、方法、变量、常量、类、模块、命名空间等的全名或嵌入在更长的名称中

  • 相似的参数列表

  • 相似的函数调用

  • 不同的代码试图实现类似的结果

总的来说,我遵循这两个步骤:

  1. 首先,注意相似之处。

  2. 其次,决定是否移除相似之处。

当不确定相似之处是否对设计有更深层次的影响时,最好保留它。一旦你看到它们出现了三次,最好开始消除相似之处;这样,你就知道它违反了 DRY 原则,而不仅仅是一个偶然事件。

接下来,我们将看一下通过函数操作可以消除的几种相似之处。

通过部分应用解决参数相似之处

在我们之前的章节中,你已经看到了在一个参数的值相同时多次调用函数的情况。例如,在我们的井字游戏结果问题中的代码中,我们有一个函数负责检查一行是否被一个标记填满:

auto lineFilledWith = [](const auto& line, const auto tokenToCheck){
    return all_of_collection(line, &tokenToCheck{   
        return token == tokenToCheck;});
};

由于井字游戏使用两个标记,XO,很明显我们会重复调用这个函数,其中tokenToCheck要么是X要么是O。消除这种相似之处的常见方法是实现两个新函数,lineFilledWithXlineFilledWithO

auto lineFilledWithX = [](const auto& line){
    return lineFilledWith(line, 'X');
};

这是一个可行的解决方案,但它仍然需要我们编写一个单独的函数和三行代码。正如我们所见,我们在函数式编程中还有另一个选择;我们可以简单地使用部分应用来获得相同的结果:

auto lineFilledWithX = bind(lineFilledWith, _1, 'X'); 
auto lineFilledWithO = bind(lineFilledWith, _1, 'O');

我更喜欢在可能的情况下使用部分应用,因为这种代码只是管道,我需要编写的管道越少越好。然而,在团队中使用部分应用时需要小心。每个团队成员都应该熟悉部分应用,并且熟练理解这种类型的代码。否则,部分应用的使用只会使开发团队更难理解代码。

用函数组合替换另一个函数输出的调用函数相似之处

你可能已经注意到了过去在下面的代码中显示的模式:

int processA(){
    a  = f1(....)
    b = f2(a, ...)
    c = f3(b, ...)
}

通常,如果你足够努力地寻找,你会发现在你的代码库中有另一个做类似事情的函数:

int processB(){
    a  = f1Prime(....)
    b = f2(a, ...)
    c = f3(b, ...)
}

由于应用程序随着时间的推移变得越来越复杂,这种相似之处似乎有更深层次的原因。我们经常从实现一个通过多个步骤的简单流程开始。然后,我们实现同一流程的变体,其中一些步骤重复,而其他步骤则发生变化。有时,流程的变体涉及改变步骤的顺序,或者调整一些步骤。

在我们的实现中,这些步骤转化为以各种方式组合在其他函数中的函数。但是,如果我们使用上一步的输出并将其输入到下一步,我们就会发现代码中的相似之处,而不取决于每个步骤的具体操作。

为了消除这种相似之处,传统上我们会提取代码的相似部分并将结果传递,如下所示:

int processA(){
    a  = f1(....)
    return doSomething(a)
}

int processB(){
    a = f1Prime(....)
    return doSomething(a)
}

int doSomething(auto a){
    b = f2(a, ...)
    return f3(b, ...)
}

然而,当提取函数时,代码通常变得更难理解和更难更改,如前面的代码所示。提取函数的共同部分并没有考虑到代码实际上是一个链式调用。

为了使这一点显而易见,我倾向于将代码模式重新格式化为单个语句,如下所示:

processA = f3(f2(f1(....), ...), ...)
processB = f3(f2(f1Prime(....), ...), ...)

虽然不是每个人都喜欢这种格式,但两个调用之间的相似性和差异更加清晰。很明显,我们可以使用函数组合来解决问题——我们只需要将f3f2组合,并将结果与f1f1Prime组合,就可以得到我们想要的结果:

C = f3 ∘ f2
processA = C ∘ f1
processB  = C ∘ f1Prime

这是一个非常强大的机制!我们可以通过函数组合创建无数的链式调用组合,只需几行代码。我们可以用几个组合语句替换隐藏的管道,这些管道伪装成函数中语句的顺序,表达我们代码的真实本质。

然而,正如我们在第四章中所看到的,函数组合的概念,在 C++中这并不一定是一项容易的任务,因为我们需要编写适用于我们特定情况的compose函数。在 C++提供更好的函数组合支持之前,我们被迫将这种机制保持在最低限度,并且只在相似性不仅明显,而且我们预计它会随着时间的推移而增加时才使用它。

使用更高级函数消除结构相似性

到目前为止,我们的讨论中一直存在一个模式——函数式编程帮助我们从代码中消除管道,并表达代码的真实结构。命令式编程使用语句序列作为基本结构;函数式编程减少了序列,并专注于函数的有趣运行。

当我们讨论结构相似性时,这一点最为明显。结构相似性是指代码结构重复的情况,尽管不一定是通过调用相同的函数或使用相同的参数。为了看到它的作用,让我们从我们的井字棋代码中一个非常有趣的相似之处开始。这是我们在第六章中编写的代码,从数据到函数的思考

auto lineFilledWith = [](const auto& line, const auto& tokenToCheck){
    return allOfCollection(line, &tokenToCheck{  
        return token == tokenToCheck;});
};

auto lineFilledWithX = bind(lineFilledWith, _1, 'X'); 
auto lineFilledWithO = bind(lineFilledWith, _1, 'O');

auto xWins = [](const auto& board){
    return any_of_collection(allLinesColumnsAndDiagonals(board), 
        lineFilledWithX);
};

auto oWins = [](const auto& board){
    return any_of_collection(allLinesColumnsAndDiagonals(board), 
        lineFilledWithO);
};

xWinsoWins函数看起来非常相似,因为它们都将相同的函数作为第一个参数调用,并且将lineFilledWith函数的变体作为它们的第二个参数。让我们消除它们的相似之处。首先,让我们移除lineFilledWithXlineFilledWithO,并用它们的lineFilledWith等效替换:

auto xWins = [](const auto& board){
    return any_of_collection(allLinesColumnsAndDiagonals(board), []  
        (const auto& line) { return lineFilledWith(line, 'X');});
};

auto oWins = [](const auto& board){
    return any_of_collection(allLinesColumnsAndDiagonals(board), []
        (const auto& line) { return lineFilledWith(line, 'O');});
};

现在相似之处显而易见,我们可以轻松提取一个通用函数:

auto tokenWins = [](const auto& board, const auto& token){
    return any_of_collection(allLinesColumnsAndDiagonals(board),  
        token { return lineFilledWith(line, token);});
};
auto xWins = [](auto const board){
    return tokenWins(board, 'X');
};

auto oWins = [](auto const board){
    return tokenWins(board, 'O');
}

我们还注意到xWinsoWins只是tokenWins的偏函数应用,所以让我们明确这一点:

auto xWins = bind(tokenWins, _1, 'X');
auto oWins = bind(tokenWins, _1, 'O');

现在,让我们专注于tokenWins

auto tokenWins = [](const auto& board, const auto& token){
    return any_of_collection(allLinesColumnsAndDiagonals(board),  
        token { return lineFilledWith(line, token);});
};

首先,我们注意到我们传递给any_of_collection的 lambda 是一个带有固定令牌参数的偏函数应用,所以让我们替换它:

auto tokenWins = [](const auto& board, const auto& token){
    return any_of_collection(
            allLinesColumnsAndDiagonals(board), 
            bind(lineFilledWith, _1, token)
    );
};

这是一个非常小的函数,由于我们的偏函数应用,它具有很强的功能。然而,我们已经可以提取一个更高级的函数,它可以让我们创建更相似的函数而不需要编写任何代码。我还不知道该如何命名它,所以我暂时称它为foo

template <typename F, typename G, typename H>
auto foo(F f, G g, H h){
    return ={
    return f(g(first), 
    bind(h, _1, second));
    };
}
auto tokenWins = compose(any_of_collection, allLinesColumnsAndDiagonals, lineFilledWith);

我们的foo函数展示了代码的结构,但它相当难以阅读,所以让我们更好地命名事物:

template <typename CollectionBooleanOperation, typename CollectionProvider, typename Predicate>
auto booleanOperationOnProvidedCollection(CollectionBooleanOperation collectionBooleanOperation, CollectionProvider collectionProvider, Predicate predicate){
    return ={
      return collectionBooleanOperation(collectionProvider(collectionProviderSeed), 
              bind(predicate, _1, predicateFirstParameter));
  };
}
auto tokenWins = booleanOperationOnProvidedCollection(any_of_collection, allLinesColumnsAndDiagonals, lineFilledWith);

我们引入了更高级的抽象层次,这可能会使代码更难理解。另一方面,我们使得能够在一行代码中创建f(g(first), bind(h, _1, second))形式的函数成为可能。

代码变得更好了吗?这取决于上下文、你的判断以及你和同事对高级函数的熟悉程度。然而,请记住——抽象虽然非常强大,但是也是有代价的。抽象更难理解,但如果你能够用抽象进行交流,你可以以非常强大的方式组合它们。使用这些高级函数就像从头开始构建一种语言——它使你能够在不同的层次上进行交流,但也为其他人设置了障碍。谨慎使用抽象!

使用高级函数消除隐藏的循环

结构重复的一个特殊例子经常在代码中遇到,我称之为隐藏的循环。隐藏的循环的概念是我们在一个序列中多次使用相同的代码结构。然而,其中的技巧在于被调用的函数或参数并不一定相同;因为函数式编程的基本思想是函数也是数据,我们可以将这些结构视为对可能也存储我们调用的函数的数据结构的循环。

我通常在一系列if语句中看到这种模式。事实上,我在使用井字棋结果问题进行实践会话时开始看到它们。在面向对象编程OOP)或命令式语言中,问题的通常解决方案大致如下所示:

enum Result {
    XWins,
    OWins,
    GameNotOverYet,
    Draw
};

Result winner(const Board& board){ 
    if(board.anyLineFilledWith(Token::X) ||    
        board.anyColumnFilledWith(Token::X) || 
        board.anyDiagonalFilledWith(Token::X)) 
    return XWins; 

    if(board.anyLineFilledWith(Token::O) ||  
        board.anyColumnFilledWith(Token::O) ||  
        board.anyDiagonalFilledWith(Token::O)) 
    return OWins; 

    if(board.notFilledYet()) 
    return GameNotOverYet; 

return Draw; 
}

在前面的示例中,enum标记包含三个值:

enum Token {
    X,
    O,
    Blank
};

Board类大致如下:

using Line = vector<Token>;

class Board{
    private: 
        const vector<Line> _board;

    public: 
        Board() : _board{Line(3, Token::Blank), Line(3, Token::Blank),  
            Line(3, Token::Blank)}{}
        Board(const vector<Line>& initial) : _board{initial}{}
...
}

anyLineFilledWithanyColumnFilledWithanyDiagonalFilledWithnotFilledYet的实现非常相似;假设一个 3 x 3 的棋盘,anyLineFilledWith的非常简单的实现如下:

        bool anyLineFilledWith(const Token& token) const{
            for(int i = 0; i < 3; ++i){
                if(_board[i][0] == token && _board[i][1] == token &&  
                    _board[i][2] == token){
                    return true;
                }
            }
            return false;
        };

然而,我们对底层实现不太感兴趣,更感兴趣的是前面的 winner 函数中的相似之处。首先,if语句中的条件重复了,但更有趣的是,有一个重复的结构如下:

if(condition) return value;

如果你看到一个使用数据而不是不同函数的结构,你会立刻注意到这是一个隐藏的循环。当涉及到函数调用时,我们并没有注意到这种重复,因为我们没有接受将函数视为数据的训练。但这确实就是它们的本质。

在我们消除相似之前,让我们简化条件。我将通过部分函数应用使所有条件成为无参数函数:

auto tokenWins = [](const auto board, const auto& token){
    return board.anyLineFilledWith(token) ||   
board.anyColumnFilledWith(token) || board.anyDiagonalFilledWith(token);
};

auto xWins = bind(tokenWins, _1, Token::X);
auto oWins = bind(tokenWins, _1, Token::O);

auto gameNotOverYet = [](auto board){
    return board.notFilledYet();
};

Result winner(const Board& board){ 
    auto gameNotOverYetOnBoard = bind(gameNotOverYet, board);
    auto xWinsOnBoard = bind(xWins, board);
    auto oWinsOnBoard = bind(oWins, board);

    if(xWins()) 
        return XWins; 

    if(oWins())
        return OWins; 

    if(gameNotOverYetOnBoard()) 
        return GameNotOverYet; 

    return Draw; 
}

我们的下一步是消除四种不同条件之间的差异,并用循环替换相似之处。我们只需要有一对(lambda, result)的列表,并使用find_if这样的高级函数来为我们执行循环:

auto True = [](){
    return true;
};

Result winner(Board board){
    auto gameNotOverYetOnBoard = bind(gameNotOverYet, board);
    auto xWinsOnBoard = bind(xWins, board);
    auto oWinsOnBoard = bind(oWins, board);

    vector<pair<function<bool()>, Result>> rules = {
        {xWins, XWins},
        {oWins, OWins},
        {gameNotOverYetOnBoard, GameNotOverYet},
        {True, Draw}
    };

    auto theRule = find_if(rules.begin(), rules.end(), [](auto pair){
            return pair.first();
            });
    // theRule will always be found, the {True, Draw} by default.
    return theRule->second;
}

最后一块拼图是确保我们的代码在没有其他情况适用时返回Draw。由于find_if返回符合规则的第一个元素,我们只需要在最后放上Draw,并与一个总是返回true的函数关联。我将这个函数恰如其分地命名为True

这段代码对我们有什么作用呢?首先,我们可以轻松地添加新的条件和结果对,例如,如果我们曾经收到要在多个维度或更多玩家的情况下实现井字棋变体的请求。其次,代码更短。第三,通过一些改变,我们得到了一个简单但相当通用的规则引擎:

auto True = [](){
    return true;
};

using Rule = pair<function<bool()>, Result>;

auto condition = [](auto rule){
    return rule.first();
};

auto result = [](auto rule){
    return rule.second;
};

// assumes that a rule is always found
auto findTheRule = [](const auto& rules){
    return *find_if(rules.begin(), rules.end(), [](auto rule){
 return condition(rule);
 });
};

auto resultForFirstRuleThatApplies = [](auto rules){
    return result(findTheRule(rules));
};

Result winner(Board board){
    auto gameNotOverYetOnBoard = bind(gameNotOverYet, board);
    vector<Rule> rules {
        {xWins, XWins},
        {oWins, OWins},
        {gameNotOverYetOnBoard, GameNotOverYet},
        {True, Draw}
    };

    return resultForFirstRuleThatApplies(rules);
}

在前面示例中唯一特殊的代码是规则列表。其他所有内容都是相当通用的,可以在多个问题上重复使用。

和往常一样,提升抽象级别是需要付出代价的。我们花时间尽可能清晰地命名事物,我相信这段代码非常容易阅读。然而,对许多人来说可能并不熟悉。

另一个可能的问题是内存使用。尽管初始版本的代码重复了相同的代码结构,但它不需要为函数和结果对的列表分配内存;然而,重要的是要测量这些东西,因为即使初始代码也需要一些额外指令的处理内存。

这个例子向我们展示了如何通过一个非常简单的代码示例将重复的结构转换为循环。这只是皮毛;这种模式是如此普遍,我相信一旦你开始寻找,你会在你的代码中注意到它。

摘要

在本章中,我们看了不同类型的代码相似之处,以及如何通过各种函数式编程技术来减少它们。从可以用部分应用替换的重复参数,到可以转换为函数组合的链式调用,一直到可以通过更高级别的函数移除的结构相似之处,你现在已经有能力注意并减少任何代码库中的相似之处了。

正如你已经注意到的,我们开始讨论代码结构和软件设计。这将我们引向设计的另一个核心原则——高内聚和低耦合。我们如何使用函数来增加内聚?原来这正是类非常有用的地方,这也是我们将在下一章讨论的内容。

第八章:使用类提高内聚性

我们之前讨论过如何使用函数和函数操作来组织我们的代码。然而,我们不能忽视过去几十年软件设计的主流范式——面向对象编程(OOP)。面向对象编程能够与函数式编程配合吗?它们之间是否存在任何兼容性,还是完全不相关?

事实证明,我们可以很容易地在类和函数之间进行转换。我通过我的朋友和导师 J.B. Rainsberger 学到,类只不过是一组部分应用的、内聚的纯函数。换句话说,我们可以使用类作为一个方便的位置,将内聚的函数组合在一起。但是,为了做到这一点,我们需要理解高内聚原则以及如何将函数转换为类,反之亦然。

本章将涵盖以下主题:

  • 理解函数式编程和面向对象编程之间的联系

  • 理解类如何等同于一组内聚的、部分应用的纯函数

  • 理解高内聚性的必要性

  • 如何将纯函数分组到类中

  • 如何将一个类分解为纯函数

技术要求

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

代码可以在 GitHub 的https:/​/​github.​com/​PacktPublishing/​Hands-​On-​Functional-Programming-​with-​CppChapter08文件夹中找到。它包括并使用了doctest,这是一个单头开源单元测试库。您可以在其 GitHub 存储库中找到它,网址为https:/​/github.​com/​onqtam/​doctest

使用类提高内聚性

作为一名年轻的软件工程学生,我花了大量时间阅读面向对象编程的相关内容。我试图理解面向对象编程的工作原理,以及为什么它对现代软件开发如此重要。那时,大多数书籍都提到面向对象编程是将代码组织成具有封装、继承和多态三个重要属性的类。

近 20 年后,我意识到这种面向对象编程的观点相当有限。面向对象编程主要是在施乐帕克(Xerox PARC)开发的,这个实验室以产生大量高质量的想法而闻名,比如图形用户界面、点和点击、鼠标和电子表格等。艾伦·凯(Alan Kay)是面向对象编程的创始人之一,他在面对支持新的图形用户界面范式的大型代码库组织问题时,借鉴了自己作为生物学专业的知识。他提出了对象和类的概念,但多年后他表示,这种代码组织风格的主要思想是消息传递。他对对象的看法是,它们应该以与细胞类似的方式进行通信,在代码中模拟它们的化学信息传递。这就是为什么从他的观点来看,面向对象编程语言中的方法调用应该是一个从一个细胞或对象传递到另一个细胞或对象的消息。

一旦我们忘记了封装、继承和多态的概念,更加重视对象而不是类,函数式编程范式和面向对象编程之间的摩擦就消失了。让我们看看这种面向对象编程的基本观点会带我们去哪里。

从功能角度看待类

有多种方式来看待类。在知识管理方面,我将概念化为分类——它是一种将具有相似属性的实例(或对象)分组的方式。如果我们以这种方式思考类,那么继承就是一种自然的属性——有一些对象类具有相似的属性,但它们在各种方面也有所不同;说它们继承自彼此是一种快速解释的方式。

然而,这种类的概念适用于我们的知识是准完全的领域。在软件开发领域,我们经常在应用领域的知识有限的情况下工作,而且领域随着时间的推移而不断扩展。因此,我们需要专注于代码结构,这些结构在概念之间有着薄弱的联系,使我们能够在了解领域的更多内容时进行更改或替换。那么,我们应该怎么处理类呢?

即使没有强大的关系,类在软件设计中也是一个强大的构造。它们提供了一种整洁的方法来分组方法,并将方法与数据结合在一起。与函数相比,它们可以帮助我们更好地导航更大的领域,因为我们最终可能会有成千上万个函数(如果不是更多)。那么,我们如何在函数式编程中使用类呢?

首先,正如你可能从我们之前的例子中注意到的那样,函数式编程将复杂性放在数据结构中。类通常是定义我们需要的数据结构的一种整洁方式,特别是在像 C++这样的语言中,它允许我们重写常见的运算符。常见的例子包括虚数、可测单位(温度、长度、速度等)和货币数据结构。每个例子都需要将数据与特定的运算符和转换进行分组。

其次,我们编写的不可变函数往往自然地分组成逻辑分类。在我们的井字棋示例中,我们有许多函数与我们称之为line的数据结构一起工作;我们的自然倾向是将这些函数分组在一起。虽然没有什么能阻止我们将它们分组在头文件中,但类提供了一个自然的地方来组合函数,以便以后能够找到它们。这导致了另一种类型的类——一个初始化一次的不可变对象,其每个操作都返回一个值,而不是改变其状态。

让我们更详细地看一下面向对象设计和函数结构之间的等价关系。

面向对象设计和函数式的等价关系

如果我们回到我们的井字棋结果解决方案,你会注意到有许多函数将board作为参数接收:

auto allLines = [](const auto& board) {
...
};

auto allColumns = [](const auto& board) {
...
};

auto mainDiagonal = [](const auto& board){
...
};

auto secondaryDiagonal = [](const auto& board){
 ...
};

auto allDiagonals = [](const auto& board) -> Lines {
...
};

auto allLinesColumnsAndDiagonals = [](const auto& board) {
 ...
};

例如,我们可以定义一个棋盘如下:

    Board board {
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

然后,当我们将其传递给函数时,就好像我们将棋盘绑定到函数的参数上。现在,让我们为我们的allLinesColumnsAndDiagonals lambda 做同样的事情:

auto bindAllToBoard = [](const auto& board){
    return map<string, function<Lines  ()>>{
        {"allLinesColumnsAndDiagonals",   
            bind(allLinesColumnsAndDiagonals, board)},
    };
};

前面的 lambda 和我们在早期章节中看到的许多其他例子都调用了其他 lambda,但它们没有捕获它们。例如,bindAllToBoard lambda 如何知道allLinesColumnsAndDiagonal lambda?这能够工作的唯一原因是因为 lambda 在全局范围内。此外,使用我的编译器,当尝试捕获allLinesColumnsAndDiagonals时,我会得到以下错误消息:<lambda> cannot be captured because it does not have automatic storage duration,因此如果我尝试捕获我使用的 lambda,它实际上不会编译。

我希望我即将说的是不言自明的,但我还是要说一下——对于生产代码,避免在全局范围内使用 lambda(以及其他任何东西)是一个好习惯。这也会迫使你捕获变量,这是一件好事,因为它会使依赖关系变得明确。

现在,让我们看看我们如何调用它:

TEST_CASE("all lines, columns and diagonals with class-like structure"){
    Board board{
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

    Lines expected{
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'},
        {'X', ' ', ' '},
        {'X', 'O', ' '},
        {'X', ' ', 'O'},
        {'X', 'O', 'O'},
        {'X', 'O', ' '}
    };

    auto boardObject = bindAllToBoard(board);
    auto all = boardObject["allLinesColumnsAndDiagonals"]();
    CHECK_EQ(expected, all);
}

这让你想起了什么吗?让我们看看我们如何在类中编写这个。我现在将其命名为BoardResult,因为我想不出更好的名字:

class BoardResult{
    private:
        const vector<Line> board;

    public:
        BoardResult(const vector<Line>& board) : board(board){
        };

         Lines allLinesColumnsAndDiagonals() const {
             return concatenate3(allLines(board), allColumns(board),  
                 allDiagonals(board));
        }
};

TEST_CASE("all lines, columns and diagonals"){
 BoardResult boardResult{{
 {'X', 'X', 'X'},
 {' ', 'O', ' '},
 {' ', ' ', 'O'}
 }};

 Lines expected {
 {'X', 'X', 'X'},
 {' ', 'O', ' '},
 {' ', ' ', 'O'},
 {'X', ' ', ' '},
 {'X', 'O', ' '},
 {'X', ' ', 'O'},
 {'X', 'O', 'O'},
 {'X', 'O', ' '}
 };

 auto all = boardResult.allLinesColumnsAndDiagonals();
 CHECK_EQ(expected, all);
}

让我们回顾一下我们做了什么:

  • 我们看到更多的函数将board作为参数。

  • 我们决定使用一个单独的函数将board参数绑定到一个值,从而获得一个字符串表示函数名和与该值绑定的 lambda 之间的映射。

  • 要调用它,我们需要先调用初始化函数,然后才能调用部分应用的 lambda。

  • 这看起来非常类似于一个类——使用构造函数传递类方法之间共享的值,然后调用方法而不传递参数。

因此,一个类只是一组部分应用的 lambda。但我们如何将它们分组呢?

高内聚原则

在我们之前的例子中,我们根据它们都需要相同的参数board将函数分组在一起。我发现这是一个很好的经验法则。然而,我们可能会遇到更复杂的情况。

为了理解为什么,让我们看另一组函数(为了讨论的目的,实现已被忽略):

using Coordinate = pair<int, int>;

auto accessAtCoordinates = [](const auto& board, const Coordinate& coordinate)
auto mainDiagonalCoordinates = [](const auto& board)
auto secondaryDiagonalCoordinates = [](const auto& board)
auto columnCoordinates = [](const auto& board, const auto& columnIndex)
auto lineCoordinates = [](const auto& board, const auto& lineIndex)
auto projectCoordinates = [](const auto& board, const auto& coordinates)

这些函数应该是之前定义的BoardResult类的一部分吗?还是应该是另一个类Coordinate的一部分?或者我们应该将它们拆分,其中一些归入BoardResult类,另一些归入Coordinate类?

我们以前的方法并不适用于所有的功能。如果我们仅仅看它们的参数,所有之前的函数都需要board。然而,其中一些还需要coordinate / coordinates作为参数。projectCoordinates应该是BoardResult类的一部分,还是Coordinate类的一部分?

更重要的是,我们可以遵循什么基本原则将这些功能分组到类中呢?

由于代码的静态结构没有明确的答案,我们需要考虑代码的演变。我们需要问的问题是:

  • 我们期望哪些函数一起改变?我们期望哪些函数分开改变?

  • 这种推理方式引导我们到高内聚原则。但是,让我们先解开它。我们所说的内聚是什么意思?

作为一名工程师和科学迷,我在物理世界中遇到了内聚。例如,当我们谈论水时,构成液体的分子倾向于粘在一起。我也遇到了内聚作为一种社会力量。作为一个与试图采用现代软件开发实践的客户合作的变革者,我经常不得不处理群体凝聚力——人们围绕一种观点聚集在一起的倾向。

当我们谈论函数的内聚性时,没有物理力量将它们推在一起,它们绝对不会固守观点。那么,我们在谈论什么呢?我们在谈论一种神经力量,可以这么说。

人脑有着发现模式和将相关物品分组到类别中的巨大能力,再加上一种神奇的快速导航方式。将函数绑在一起的力量在我们的大脑中——它是从看似无关的功能组合中出现的统一目的的发现。

高内聚性很有用,因为它使我们能够理解和导航一些大概念(如棋盘、线和标记),而不是数十甚至数百个小函数。此外,当(而不是如果)我们需要添加新的行为或更改现有行为时,高内聚性将使我们能够快速找到新行为的位置,并且以最小的更改添加它到网络的其余部分。

内聚是软件设计的一个度量标准,由拉里·康斯坦丁在 20 世纪 60 年代作为他的结构化设计方法的一部分引入。通过经验,我们注意到高内聚性与低变更成本相关。

让我们看看如何应用这个原则来将我们的函数分组到类中。

将内聚的函数分组到类中

正如之前讨论的,我们可以从一个类的统一目的或概念的角度来看内聚。然而,我通常发现更彻底的方法是根据代码的演变来决定函数组,以及未来可能发生的变化以及它可能触发的其他变化。

你可能不会指望从我们的井字棋结果问题中学到很多东西。它相当简单,看起来相当容易控制。然而,网上的快速搜索会带我们找到一些井字棋的变体,包括以下内容:

  • m x n棋盘,赢家由一排中的k个项目决定。一个有趣的变体是五子棋,在15 x 15的棋盘上进行,赢家必须连成 5 个。

  • 一个 3D 版本。

  • 使用数字作为标记,并以数字的总和作为获胜条件。

  • 使用单词作为标记,获胜者必须在一行中放置 3 个带有 1 个共同字母的单词。

  • 使用3 x 3的 9 个棋盘进行游戏,获胜者必须连续获胜 3 个棋盘。

这些甚至不是最奇怪的变体,如果你感兴趣,可以查看维基百科上关于这个主题的文章en.wikipedia.org/wiki/Tic-tac-toe_variants

那么,在我们的实现中可能会发生什么变化呢?以下是一些建议:

  • 棋盘大小

  • 玩家数量

  • 标记

  • 获胜规则(仍然是一行,但条件不同)

  • 棋盘拓扑——矩形、六边形、三角形或 3D 而不是正方形

幸运的是,如果我们只是改变了棋盘的大小,我们的代码实际上不会有太大变化。事实上,我们可以传入一个更大的棋盘,一切仍然可以正常工作。改变玩家数量只需要做很小的改动;我们假设他们有不同的标记,我们只需要将tokenWins函数绑定到不同的标记值上。

那么获胜规则呢?我们假设规则仍然考虑了行、列和对角线,因为这是井字游戏的基本要求,所有变体都使用它们。然而,我们可能不考虑完整的行、列或对角线;例如,在五子棋中,我们需要在大小为 15 的行、列或对角线上寻找 5 个标记。从我们的代码来看,这只是选择其他坐标组的问题;我们不再需要寻找被标记X填满的完整行,而是需要选择所有可能的五连坐标集。这意味着我们的与坐标相关的函数需要改变——lineCoordinatesmainDiagonalCoordinatescolumnCoordinatessecondaryDiagonalCoordinates。它们将返回一个五连坐标的向量,这将导致allLinesallColumnsallDiagonals的变化,以及我们连接它们的方式。

如果标记是一个单词,获胜条件是找到单词之间的共同字母呢?好吧,坐标是一样的,我们获取行、列和对角线的方式也是一样的。唯一的变化在于fill条件,所以这相对容易改变。

这引出了最后一个可能的变化——棋盘拓扑。改变棋盘拓扑将需要改变棋盘数据结构,以及所有的坐标和相应的函数。但是这是否需要改变行、列和对角线的规则呢?如果我们切换到 3D,那么我们将有更多的行、更多的列,以及一个不同的对角线寻址方式——所有坐标的变化。矩形棋盘本身并没有对角线;我们需要使用部分对角线,比如在五子棋的情况下。至于六边形或三角形的棋盘,目前还没有明确的变体,所以我们可以暂时忽略它们。

这告诉我们,如果我们想要为变化做好准备,我们的函数应该围绕以下几个方面进行分组:

  • 规则(也称为填充条件

  • 坐标和投影——并为多组行、列和对角线准备代码

  • 基本的棋盘结构允许基于坐标进行访问

这就解决了问题——我们需要将坐标与棋盘本身分开。虽然坐标数据类型将与棋盘数据类型同时改变,但由于游戏规则的原因,提供行、列和对角线坐标的函数可能会发生变化。因此,我们需要将棋盘与其拓扑分开。

面向对象设计OOD)方面,我们需要在至少三个内聚的类之间分离程序的责任——RulesTopologyBoardRules类包含游戏规则——基本上是我们如何计算获胜条件,当我们知道是平局时,或者游戏何时结束。Topology类涉及坐标和棋盘的结构。Board类应该是我们传递给算法的结构。

那么,我们应该如何组织我们的函数?让我们列个清单:

  • 规则xWinsoWinstokenWinsdrawinProgress

  • TopologylineCoordinatescolumnCoordinatesmainDiagonalCoordinatessecondaryDiagonalCoordinates

  • BoardaccessAtCoordinatesallLinesColumnsAndDiagonals

  • 未决allLinesallColumnsallDiagonalsmainDiagonalsecondaryDiagonal

总是有一系列函数可以成为更多结构的一部分。在我们的情况下,allLines应该是Topology类还是Board类的一部分?我可以为两者找到同样好的论点。因此,解决方案留给编写代码的程序员的直觉。

然而,这显示了你可以用来将这些函数分组到类中的方法——考虑可能发生的变化,并根据哪些函数将一起变化来分组它们。

然而,对于练习这种方法有一个警告——避免陷入过度分析的陷阱。代码相对容易更改;当你对可能发生变化的事情知之甚少时,让它工作并等待直到同一代码区域出现新的需求。然后,你会对函数之间的关系有更好的理解。这种分析不应该花费你超过 15 分钟;任何额外的时间很可能是过度工程。

将一个类分割成纯函数

我们已经学会了如何将函数分组到一个类中。但是我们如何将代码从一个类转换为纯函数?事实证明,这是相当简单的——我们只需要使函数成为纯函数,将它们移出类,然后添加一个初始化器,将它们绑定到它们需要的数据上。

让我们举另一个例子,一个执行两个整数操作数的数学运算的类:

class Calculator{
    private:
        int first;
        int second;

    public:
        Calculator(int first, int second): first(first), second(second){}

        int add() const {
            return first + second;
        }

        int multiply() const {
            return first * second;
        }

        int mod() const {
            return first % second;
        }

};

TEST_CASE("Adds"){
    Calculator calculator(1, 2);

    int result = calculator.add();

    CHECK_EQ(result, 3);
}

TEST_CASE("Multiplies"){
    Calculator calculator(3, 2);

    int result = calculator.multiply();

    CHECK_EQ(result, 6);
}

TEST_CASE("Modulo"){
    Calculator calculator(3, 2);

    int result = calculator.mod();

    CHECK_EQ(result, 1);
}

为了使它更有趣,让我们添加另一个函数,用于反转第一个参数:

class Calculator{
...
    int negateInt() const {
        return -first;
    }
...
}

TEST_CASE("Revert"){
    Calculator calculator(3, 2);

    int result = calculator.negateInt();

    CHECK_EQ(result, -3);
}

我们如何将这个类分割成函数?幸运的是,这些函数已经是纯函数。很明显,我们可以将函数提取为 lambda:

auto add = [](const auto first, const auto second){
    return first + second;
};

auto multiply = [](const auto first, const auto second){
    return first * second;
};

auto mod = [](const auto first, const auto second){
    return first % second;
};

auto negateInt = [](const auto value){
    return -value;
};

如果你真的需要,让我们添加初始化器:

auto initialize = [] (const auto first, const auto second) -> map<string, function<int()>>{
    return  {
        {"add", bind(add, first, second)},
        {"multiply", bind(multiply, first, second)},
        {"mod", bind(mod, first, second)},
        {"revert", bind(revert, first)}
    };
};

然后,可以进行检查以确定一切是否正常工作:

TEST_CASE("Adds"){
    auto calculator = initialize(1, 2);

    int result = calculator["add"]();

    CHECK_EQ(result, 3);
}

TEST_CASE("Multiplies"){
    auto calculator = initialize(3, 2);

    int result = calculator["multiply"]();

    CHECK_EQ(result, 6);
}

TEST_CASE("Modulo"){
    auto calculator = initialize(3, 2);

    int result = calculator["mod"]();

    CHECK_EQ(result, 1);
}

TEST_CASE("Revert"){
    auto calculator = initialize(3, 2);

    int result = calculator["revert"]();

    CHECK_EQ(result, -3);
}

这让我们只剩下一个未决问题——如何将不纯的函数转变为纯函数?我们将在第十二章中详细讨论这个问题,重构为纯函数。现在,让我们记住本章的重要结论——一个类只不过是一组内聚的、部分应用的函数

总结

在本章中,我们有一个非常有趣的旅程!我们成功地以一种非常优雅的方式将两种看似不相关的设计风格——面向对象编程和函数式编程联系起来。纯函数可以根据内聚性原则分组到类中。我们只需要发挥想象力,想象一下函数可能发生变化的情景,并决定哪些函数应该分组在一起。反过来,我们总是可以通过使它们成为纯函数并反转部分应用,将函数从一个类移动到多个 lambda 中。

面向对象设计和函数式编程之间没有摩擦;它们只是实现功能的代码的两种不同结构方式。

我们使用函数进行软件设计的旅程还没有结束。在下一章中,我们将讨论如何使用测试驱动开发TDD)设计函数。

第九章:函数式编程的测试驱动开发

测试驱动开发TDD)是一种设计软件的非常有用的方法。该方法如下——我们首先编写一个失败的单一测试,然后实现最少的代码使测试通过,最后进行重构。我们在短时间内进行小循环来完成这个过程。

我们将看看纯函数如何简化测试,并提供一个应用 TDD 的函数示例。纯函数允许我们编写简单的测试,因为它们始终为相同的输入参数返回相同的值;因此,它们相当于大数据表。因此,我们可以编写模拟输入和预期输出的数据表的测试。

本章将涵盖以下主题:

  • 如何使用数据驱动测试利用纯函数的优势

  • 了解 TDD 周期的基础

  • 如何使用 TDD 设计纯函数

技术要求

您将需要一个支持C++ 17的编译器。我使用了GCC 7.3.0

代码可以在 GitHub 上找到,网址为https:/​/​github.​com/​PacktPublishing/​Hands-​On-​Functional-Programming-​with-​Cpp,在Chapter09文件夹中。它包括并使用doctest,这是一个单头开源单元测试库。您可以在其 GitHub 存储库上找到它,网址为https:/​/github.​com/​onqtam/​doctest

函数式编程的 TDD

20 世纪 50 年代的编程与今天的编程非常不同。我们现在所知道的程序员的工作被分为三个角色。程序员会编写要实现的算法。然后,专门的打字员会使用特殊的机器将其输入到穿孔卡片中。然后,程序员必须手动验证穿孔卡片是否正确——尽管有数百张。一旦确认穿孔卡片正确,程序员会将它们交给大型机操作员。由于当时唯一存在的计算机非常庞大且价格昂贵,因此在计算机上花费的时间必须受到保护。大型机操作员负责计算机,确保最重要的任务优先进行,因此新程序可能需要等待几天才能运行。运行后,程序将打印完整的堆栈跟踪。如果出现错误,程序员必须查看一张充满奇怪符号的非常长的纸,并找出可能出错的地方。这个过程缓慢、容易出错且不可预测。

然而,一些工程师提出了一个想法。如果他们不是从失败的程序中获得复杂的输出,而是得到明确指出问题的信息会怎样?他们决定开始编写额外的代码,检查生产代码并生成通过或失败的输出。他们不是运行程序,或者在运行程序的同时,他们会运行单元测试。

一旦程序员拥有了更短的反馈循环,如终端的发明,后来是个人电脑和强大的调试器,单元测试的实践就被遗忘了。然而,它从未完全消失,突然以不同的形式回来了。

直到 20 世纪 90 年代,单元测试才意外地重新出现。包括 Kent Beck、Ward Cunningham 和 Ron Jeffries 在内的一群程序员尝试将开发实践推向极端。他们的努力的结果被称为极限编程XP)。其中一种实践就是单元测试,结果非常有趣。

常见的单元测试实践是在编写代码后写一些测试,作为测试期间的一部分。这些测试通常由测试人员编写——与实现功能的程序员不同的一个组。

然而,最初的 XPers 尝试了一种不同的单元测试方式。如果我们在编写代码的同时编写测试呢?更有趣的是,如果我们在实现之前编写测试呢?这导致了两种不同的技术——测试驱动编程TFP),它包括首先编写一些测试,然后编写一些代码使测试通过,以及我们将在更详细地讨论的 TDD。

当我第一次听说这些技术时,我既感到困惑又着迷。你怎么能为不存在的东西编写测试呢?这有什么好处呢?幸运的是,在 J.B. Rainsberger 的支持下,我很快意识到了 TFP/TDD 的力量。我们的客户和利益相关者希望尽快在软件中获得可用的功能。然而,往往他们无法解释他们想要的功能。从测试开始意味着你完全理解了要实现什么,并且会引发有用和有趣的对话,澄清需求。一旦需求明确,我们就可以专注于实现。此外,在 TDD 中,我们尽快清理代码,以免随着时间的推移造成混乱。这真的是一种非常强大的技术!

但让我们从头开始。我们如何编写单元测试呢?更重要的是,对于我们的目的来说,为纯函数编写单元测试更容易吗?

纯函数的单元测试

让我们首先看一下单元测试是什么样子的。在本书中,我已经使用了一段时间,我相信你能理解这段代码。但是现在是时候看一个特定的例子了:

TEST_CASE("Greater Than"){
    int first = 3;
    int second = 2;

    bool result = greater<int>()(first, second);

    CHECK(result);
}

我们首先使用特定值初始化两个变量(单元测试的安排部分)。然后我们调用生产代码(单元测试的行动部分)。最后,我们检查结果是否符合我们的预期(单元测试的断言部分)。我们正在使用的名为doctest的库提供了允许我们编写单元测试的宏的实现。虽然 C++存在更多的单元测试库,包括 GTest 和Boost::unit_test等,但它们提供给程序员的功能相当相似。

在谈论单元测试时,更重要的是找出使其有用的特征。前面的测试是小型、专注、快速的,只能因为一个原因而失败。所有这些特征使测试有用,因为它易于编写、易于维护、清晰明了,并且在引入错误时提供有用和快速的反馈。

在技术方面,前面的测试是基于示例的,因为它使用一个非常具体的示例来检查代码的特定行为。我们将在第十一章中看到一种名为基于属性的测试的不同单元测试方法,基于属性的测试。由于这是基于示例的测试,一个有趣的问题出现了:如果我们想测试greaterThan函数,还有哪些其他示例会很有趣呢?

好吧,我们想要查看函数的所有可能行为。那么,它可能的输出是什么?以下是一个列表:

  • 如果第一个值大于第二个值,则为 True

  • 如果第一个值小于第二个值,则为 False

然而,这还不够。让我们添加边缘情况:

  • 如果第一个值等于第二个值,则为 False

还有,不要忘记可能的错误。传入值的域是什么?可以传入负值吗?浮点数值?复数?这是与该函数的利益相关者进行有趣对话。

现在让我们假设最简单的情况——该函数将仅接受有效的整数。这意味着我们需要另外两个单元测试来检查第一个参数小于第二个参数的情况以及两者相等的情况:

TEST_CASE("Not Greater Than when first is less than second"){
    int first = 2;
    int second = 3;

    bool result = greater<int>()(first, second);

    CHECK_FALSE(result);
}

TEST_CASE("Not Greater Than when first equals second"){
    int first = 2;

    bool result = greater<int>()(first, first);

    CHECK_FALSE(result);
}

在第七章中,使用功能操作去除重复,我们讨论了代码相似性以及如何去除它。在这里,我们有一个测试之间的相似性。去除它的一种方法是编写所谓的数据驱动测试DDT)。在 DDT 中,我们编写一组输入和期望的输出,并在每行数据上重复测试。不同的测试框架提供了不同的编写这些测试的方式;目前,doctest对 DDT 的支持有限,但我们仍然可以按照以下方式编写它们:

TEST_CASE("Greater than") {
    struct Data {
        int first;
        int second;
        bool expected;
 } data;

    SUBCASE("2 is greater than 1") { data.first = 2; data.second = 1; 
        data.expected = true; }
    SUBCASE("2 is not greater than 2") { data.first = 2; data.second = 
         2; data.expected = false; }
    SUBCASE("2 is not greater than 3") { data.first = 2; data.second = 
         3; data.expected = false; }

    CAPTURE(data);

    CHECK_EQ(greaterThan(data.first, data.second), data.expected);
}

如果我们忽略管道代码(struct Data定义和对CAPTURE宏的调用),这显示了一种非常方便的编写测试的方式——特别是对于纯函数。鉴于纯函数根据定义在接收相同输入时返回相同输出,用一组输入/输出进行测试是很自然的。

DDT 的另一个便利之处在于,我们可以通过向列表添加新行来轻松添加新的测试。这在使用纯函数进行 TDD 时特别有帮助。

TDD 循环

TDD 是一个常见的开发循环,通常如下所示:

  • 红色:编写一个失败的测试。

  • 绿色:通过对生产代码进行尽可能小的更改来使测试通过。

  • 重构:重新组织代码以包含新引入的行为。

然而,TDD 的实践者(比如我自己)会急于提到 TDD 循环始于另一步骤——思考。更准确地说,在编写第一个测试之前,让我们理解我们要实现的内容,并找到现有代码中添加行为的好位置。

这个循环看起来简单得令人误解。然而,初学者经常在第一个测试应该是什么以及之后的测试应该是什么方面挣扎,同时编写过于复杂的代码。重构本身就是一门艺术,需要对代码异味、设计原则和设计模式有所了解。总的来说,最大的错误是过于考虑你想要获得的代码结构,并编写导致那种结构的测试。

相反,TDD 需要一种心态的改变。我们从行为开始,在小步骤中完善适合该行为的代码结构。一个好的实践者会有小于 15 分钟的步骤。但这并不是 TDD 的唯一惊喜。

TDD 最大的惊喜是,它可以通过允许您探索同一问题的各种解决方案来教您软件设计。您愿意探索的解决方案越多,您在设计代码方面就会变得越好。当以适当的好奇心进行实践时,TDD 是一个持续的学习经验。

我希望我引起了你对 TDD 的好奇心。关于这个主题还有很多要学习的,但是对于我们的目标来说,尝试一个例子就足够了。而且,由于我们正在谈论函数式编程,我们将使用 TDD 来设计一个纯函数。

例子——使用 TDD 设计一个纯函数

再次,我们需要一个问题来展示 TDD 的实际应用。由于我喜欢使用游戏来练习开发实践,我查看了 Coding Dojo Katas(codingdojo.org/kata/PokerHands/)的列表,并选择了扑克牌问题来进行练习。

扑克牌问题

问题的描述如下——给定两个或多个扑克牌手,我们需要比较它们并返回排名较高的手以及它赢得的原因。

每手有五张牌,这些牌是从一副普通的 52 张牌的牌组中挑选出来的。牌组由四种花色组成——梅花、方块、红桃和黑桃。每种花色从2开始,以 A 结束,表示如下——23456789TJQKAT表示 10)。

扑克牌手中的牌将形成不同的组合。手的价值由这些组合决定,按以下降序排列:

  • 同花顺:五张相同花色的牌,连续的值。例如,2♠3♠4♠5♠6♠。起始值越高,同花顺的价值就越高。

  • 四条:四张相同牌值的牌。最高的是四张 A——A♣A♠A♦A♥

  • 葫芦:三张相同牌值的牌,另外两张牌也是相同的牌值(但不同)。最高的是——A♣A♠A♦K♥K♠

  • 同花:五张相同花色的牌。例如——2♠3♠5♠6♠9♠

  • 顺子:五张连续值的牌。例如——2♣3♠4♥5♣6♦

  • 三条:三张相同牌值的牌。例如——2♣2♠2♥

  • 两对:见对子。例如——2♣2♠3♥3♣

  • 对子:两张相同牌值的牌。例如——2♣2♠

  • 高牌:当没有其他组合时,比较每手中最高的牌,最高的获胜。如果最高的牌具有相同的值,则比较下一个最高的牌,以此类推。

要求

我们的目标是实现一个程序,比较两个或更多个扑克牌手,并返回赢家和原因。例如,让我们使用以下输入:

  • 玩家 1*2♥ 4♦ 7♣ 9♠ K♦*

  • 玩家 2*2♠ 4♥ 8♣ 9♠ A♥*

对于这个输入,我们应该得到以下输出:

  • 玩家 2 以他们的高牌——一张 A 赢得比赛

步骤 1 - 思考

让我们更详细地看一下问题。更准确地说,我们试图将问题分解为更小的部分,而不要过多考虑实现。我发现查看可能的输入和输出示例,并从一个简化的问题开始,可以让我尽快实现一些有效的东西,同时保持问题的本质。

很明显,我们有很多组合要测试。那么,什么是限制我们测试用例的问题的有用简化呢?

一个明显的方法是从手中的牌较少开始。我们可以从一张牌开始,而不是五张牌。这将限制我们的规则为高牌。下一步是有两张牌,这引入了对子>高牌更高的对子>更低的对子,依此类推。

另一种方法是从五张牌开始,但限制规则。从高牌开始,然后实现一对,然后两对,依此类推;或者,从同花顺一直到对子和高牌。

TDD 的有趣之处在于,这些方法中的任何一个都将以相同的方式产生结果,尽管通常使用不同的代码结构。TDD 的一个优势是通过改变测试的顺序来帮助您访问相同问题的多种设计。

不用说,我以前做过这个问题,但我总是从手中的一张牌开始。让我们有些乐趣,尝试一种不同的方式,好吗?我选择用五张牌开始,从同花顺开始。为了保持简单,我现在只支持两个玩家,而且由于我喜欢给他们起名字,我会用 Alice 和 Bob。

例子

对于这种情况,有一些有趣的例子是什么?让我们先考虑可能的输出:

  • Alice 以同花顺获胜。

  • Bob 以同花顺获胜。

  • Alice 和 Bob 有同样好的同花顺。

  • 未决(即尚未实施)。

现在,让我们写一些这些输出的输入示例:

Case 1: Alice wins

Inputs:
 Alice: 2♠, 3♠, 4♠, 5♠, 6♠
 Bob: 2♣, 4♦, 7♥, 9♠, A♥

Output:
 Alice wins with straight flush

Case 2: Bob wins

Inputs:
    Alice: 2♠, 3♠, 4♠, 5♠, 9♠
    Bob: 2♣, 3♣, 4♣, 5♣, 6♣

Output:
    Bob wins with straight flush

Case 3: Alice wins with a higher straight flush

Inputs:
    Alice: 3♠, 4♠, 5♠, 6♠, 7♠
    Bob: 2♣, 3♣, 4♣, 5♣, 6♣

Output:
    Alice wins with straight flush

Case 4: Draw

Inputs:
    Alice: 3♠, 4♠, 5♠, 6♠, 7♠
    Bob: 3♣, 4♣, 5♣, 6♣, 7♣

Output:
    Draw (equal straight flushes)

Case 5: Undecided

Inputs:
    Alice: 3♠, 3♣, 5♠, 6♠, 7♠
    Bob: 3♣, 4♣, 6♣, 6♥, 7♣

Output:
    Not implemented yet.

有了这些例子,我们准备开始编写我们的第一个测试!

第一个测试

根据我们之前的分析,我们的第一个测试如下:

Case 1: Alice wins

Inputs:
 Alice: 2♠, 3♠, 4♠, 5♠, 6♠
 Bob: 2♣, 4♦, 7♥, 9♠, A♥

Output:
 Alice wins with straight flush

让我们写吧!我们期望这个测试失败,所以在这一点上我们可以做任何我们想做的事情。我们需要用前面的卡片初始化两只手。现在,我们将使用vector<string>来表示每只手。然后,我们将调用一个函数(目前还不存在)来比较这两只手,我们想象这个函数将在某个时候实现。最后,我们将检查结果是否与之前定义的预期输出消息相匹配:

TEST_CASE("Alice wins with straight flush"){
    vector<string> aliceHand{"2♠", "3♠", "4♠", "5♠", "6♠"};
    vector<string> bobHand{"2♣", "4♦", "7♥", "9♠", "A♥"};

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Alice wins with straight flush", result);
}

现在,这个测试无法编译,因为我们甚至还没有创建comparePokerHands函数。是时候继续前进了。

使第一个测试通过

让我们先写这个函数。这个函数需要返回一些东西,所以我们暂时只返回空字符串:

auto comparePokerHands = [](const auto& aliceHand, const auto& bobHand){
    return "";
};

使测试通过的最简单实现是什么?这是 TDD 变得更加奇怪的地方。使测试通过的最简单实现是将预期结果作为硬编码值返回:

auto comparePokerHands = [](const auto& aliceHand, const auto& bobHand){
    return "Alice wins with straight flush";
};

此时,我的编译器抱怨了,因为我打开了所有警告,并且将所有警告报告为错误。编译器注意到我们没有使用这两个参数并抱怨。这是一个合理的抱怨,但我计划很快开始使用这些参数。C++语言给了我们一个简单的解决方案——只需删除或注释掉参数名,如下面的代码所示:

auto comparePokerHands = [](const auto& /*aliceHand*/, const auto&  
    /*bobHand*/){
        return "Alice wins with straight flush";
};

我们运行测试,我们的第一个测试通过了!太棒了,有东西可以用了!

重构

有什么需要重构的吗?嗯,我们有两个被注释掉的参数名,我通常会把它们删除掉,因为注释掉的代码只会增加混乱。但是,我决定暂时保留它们,因为我知道我们很快会用到它们。

我们还有一个重复的地方——在测试和实现中都出现了相同的“Alice 以顺子获胜”的字符串。值得把它提取为一个常量或者公共变量吗?如果这是我们的实现的最终结果,那当然可以。但我知道这个字符串实际上是由多个部分组成的——获胜玩家的名字,以及根据哪种手牌获胜的规则。我想暂时保持它原样。

因此,没有什么需要重构的。让我们继续吧!

再次思考

当前的实现感觉令人失望。只是返回一个硬编码的值并不能解决太多问题。或者呢?

这是学习 TDD 时需要的心态转变。我知道这一点,因为我经历过。我习惯于看最终结果,将这个解决方案与我试图实现的目标进行比较,感觉令人失望。然而,有一种不同的看待方式——我们有一个可以工作的东西,而且我们有最简单的实现。还有很长的路要走,但我们已经可以向利益相关者展示一些东西。而且,正如我们将看到的,我们总是在坚实的基础上构建,因为我们编写的代码是经过充分测试的。这两件事是非常令人振奋的;我只希望你在尝试 TDD 时也能有同样的感受。

但是,接下来我们该怎么办呢?我们有几个选择。

首先,我们可以写另一个测试,其中 Alice 以顺子获胜。然而,这不会改变实现中的任何东西,测试会立即通过。虽然这似乎违反了 TDD 循环,但为了我们的安心,增加更多的测试并没有错。绝对是一个有效的选择。

其次,我们可以转移到下一个测试,其中 Bob 以顺子获胜。这肯定会改变一些东西。

这两个选项都不错,你可以选择其中任何一个。但由于我们想要看到 DDT 的实践,让我们先写更多的测试。

更多的测试

将我们的测试转换成 DDT 并添加更多的案例非常容易。我们只需改变 Alice 手牌的值,而保持 Bob 的手牌不变。结果如下:

TEST_CASE("Alice wins with straight flush"){
    vector<string> aliceHand;
    const vector<string> bobHand {"2♣", "4♦", "7♥", "9♠", "A♥"};

    SUBCASE("2 based straight flush"){
        aliceHand = {"2♠", "3♠", "4♠", "5♠", "6♠"};
    };
    SUBCASE("3 based straight flush"){
        aliceHand = {"3♠", "4♠", "5♠", "6♠", "7♠"};
    };
    SUBCASE("4 based straight flush"){
        aliceHand = {"4♠", "5♠", "6♠", "7♠", "8♠"};
    };
    SUBCASE("10 based straight flush"){
        aliceHand = {"T♠", "J♠", "Q♠", "K♠", "A♠"};
    };

    CAPTURE(aliceHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Alice wins with straight flush", result);
}

再次,所有这些测试都通过了。是时候继续进行我们的下一个测试了。

第二个测试

我们描述的第二个测试是 Bob 以顺子获胜:

Case: Bob wins

Inputs:
 Alice: 2♠, 3♠, 4♠, 5♠, 9♠
 Bob: 2♣, 3♣, 4♣, 5♣, 6♣

Output:
 Bob wins with straight flush

让我们写吧!这一次,让我们从一开始就使用数据驱动的格式:

TEST_CASE("Bob wins with straight flush"){
    const vector<string> aliceHand{"2♠", "3♠", "4♠", "5♠", "9♠"};
    vector<string> bobHand;

    SUBCASE("2 based straight flush"){
        bobHand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    CAPTURE(bobHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Bob wins with straight flush", result);
}

当我们运行这个测试时,它失败了,原因很简单——我们有一个硬编码的实现,说 Alice 获胜。现在怎么办?

使测试通过

再次,我们需要找到使这个测试通过的最简单方法。即使我们可能不喜欢这个实现,下一步是清理混乱。那么,最简单的实现是什么呢?

显然,我们需要在我们的实现中引入一个条件语句。问题是,我们应该检查什么?

再次,我们有几个选择。一个选择是再次伪装,使用与我们期望获胜的确切手牌进行比较:

auto comparePokerHands = [](const vector<string>& /*aliceHand*/, const vector<string>& bobHand){
    const vector<string> winningBobHand {"2♣", "3♣", "4♣", "5♣", "6♣"};
    if(bobHand == winningBobHand){
        return "Bob wins with straight flush";
    }
    return "Alice wins with straight flush";
};

为了使其编译,我们还必须使vector<string> hands 的类型出现在各处。一旦这些更改完成,测试就通过了。

我们的第二个选择是开始实现实际的同花顺检查。然而,这本身就是一个小问题,要做好需要更多的测试。

我现在会选择第一种选项,重构,然后开始更深入地研究检查同花顺的实现。

重构

有什么需要重构的吗?我们仍然有字符串的重复。此外,我们在包含 Bob 的手的向量中添加了重复。但我们期望这两者很快都会消失。

然而,还有一件事让我感到不安——vector<string> 出现在各处。让我们通过为vector<string>类型命名为Hand来消除这种重复:

using Hand = vector<string>;

auto comparePokerHands = [](const Hand& /*aliceHand*/, const Hand& bobHand){
    Hand winningBobHand {"2♣", "3♣", "4♣", "5♣", "6♣"};
    if(bobHand == winningBobHand){
        return "Bob wins with straight flush";
    }
    return "Alice wins with straight flush";
};

TEST_CASE("Bob wins with straight flush"){
    Hand aliceHand{"2♠", "3♠", "4♠", "5♠", "9♠"};
    Hand bobHand;

    SUBCASE("2 based straight flush"){
        bobHand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    CAPTURE(bobHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Bob wins with straight flush", result);
}

思考

再次思考。我们已经用硬编码的值实现了两种情况。对于 Alice 以同花顺获胜并不是一个大问题,但如果我们为 Bob 添加另一组不同的牌测试用例,这就是一个问题。我们可以进行更多的测试,但不可避免地,我们需要实际检查同花顺。我认为现在是一个很好的时机。

那么,什么是同花顺?它是一组有相同花色和连续值的五张牌。我们需要一个函数,它可以接受一组五张牌,并在是同花顺时返回true,否则返回false。让我们写下一些例子:

  • 输入:2♣ 3♣ 4♣ 5♣ 6♣ => 输出:true

  • 输入:2♠ 3♠ 4♠ 5♠ 6♠ => 输出:true

  • 输入:T♠ J♠ Q♠ K♠ A♠ => 输出:true

  • 输入:2♣ 3♣ 4♣ 5♣ 7♣ => 输出:false

  • 输入:2♣ 3♣ 4♣ 5♣ 6♠ => 输出:false

  • 输入:2♣ 3♣ 4♣ 5♣ => 输出:false(只有四张牌,需要正好五张)

  • 输入:[空向量] => 输出:false(没有牌,需要正好五张)

  • 输入:2♣ 3♣ 4♣ 5♣ 6♣ 7♣ => 输出:false(六张牌,需要正好五张)

你会注意到我们也考虑了边缘情况和奇怪的情况。我们有足够的信息可以继续,所以让我们写下下一个测试。

下一个测试-简单的同花顺

我更喜欢从正面案例开始,因为它们往往会更推进实现。让我们看最简单的一个:

  • 输入:2♣ 3♣ 4♣ 5♣ 6♣ => 输出:true

测试如下:

TEST_CASE("Hand is straight flush"){
    Hand hand;

    SUBCASE("2 based straight flush"){
        hand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    CAPTURE(hand);

    CHECK(isStraightFlush(hand));
}

再次,测试无法编译,因为我们没有实现isStraightFlush函数。但测试是正确的,它失败了,所以是时候继续了。

使测试通过

再次,第一步是编写函数的主体并返回预期的硬编码值:

auto isStraightFlush = [](const Hand&){
    return true;
};

我们运行了测试,它们通过了,所以现在我们完成了!

继续前进

嗯,你可以看到这是怎么回事。我们可以为正确的同花顺添加一些更多的输入,但它们不会改变实现。第一个将迫使我们推进实现的测试是我们的第一个不是同花顺的一组牌的例子。

对于本章的目标,我将快进。但我强烈建议你自己经历所有的小步骤,并将你的结果与我的进行比较。学习 TDD 的唯一方法是自己练习并反思自己的方法。

实现 isStraightFlush

让我们再次看看我们要达到的目标——同花顺,它由正好五张具有相同花色和连续值的牌定义。我们只需要在代码中表达这三个条件:

auto isStraightFlush = [](const Hand& hand){
    return has5Cards(hand) && 
        isSameSuit(allSuits(hand)) && 
        areValuesConsecutive(allValuesInOrder(hand));
};

实现得到了一些不同的 lambda 的帮助。首先,为了检查组合的长度,我们使用has5Cards

auto has5Cards = [](const Hand& hand){
    return hand.size() == 5;
};

然后,为了检查它是否有相同的花色,我们使用allSuits来提取手中的花色,isSuitEqual来比较两个花色,isSameSuit来检查手中的所有花色是否相同:

using Card = string;
auto suitOf = [](const Card& card){
    return card.substr(1);
};

auto allSuits = [](Hand hand){
    return transformAll<vector<string>>(hand, suitOf);
};

auto isSameSuit = [](const vector<string>& allSuits){
    return std::equal(allSuits.begin() + 1, allSuits.end(),  
        allSuits.begin());
};

最后,为了验证这些值是连续的,我们使用valueOf从一张牌中提取值,使用allValuesInOrder获取一手牌中的所有值并排序,使用toRange从一个初始值开始创建一系列连续的值,使用areValuesConsecutive检查一手牌中的值是否连续:

auto valueOf = [](const Card& card){
    return charsToCardValues.at(card.front());
};

auto allValuesInOrder = [](const Hand& hand){
    auto theValues = transformAll<vector<int>>(hand, valueOf);
    sort(theValues.begin(), theValues.end());
    return theValues;
};

auto toRange = [](const auto& collection, const int startValue){
    vector<int> range(collection.size());
    iota(begin(range), end(range), startValue);
    return range;
};

auto areValuesConsecutive = [](const vector<int>& allValuesInOrder){
    vector<int> consecutiveValues = toRange(allValuesInOrder, 
        allValuesInOrder.front());

    return consecutiveValues == allValuesInOrder;
};

最后一块拼图是一个从charint的映射,帮助我们将所有的牌值,包括TJQKA,转换成数字:

const std::map<char, int> charsToCardValues = {
    {'1', 1},
    {'2', 2},
    {'3', 3},
    {'4', 4},
    {'5', 5},
    {'6', 6},
    {'7', 7},
    {'8', 8},
    {'9', 9},
    {'T', 10},
    {'J', 11},
    {'Q', 12},
    {'K', 13},
    {'A', 14},
};

让我们也看一下我们的测试(显然都通过了)。首先是有效的顺子同花的测试;我们将检查以23410开头的顺子同花,以及它们在数据区间上的变化:

TEST_CASE("Hand is straight flush"){
    Hand hand;

    SUBCASE("2 based straight flush"){
        hand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    SUBCASE("3 based straight flush"){
        hand = {"3♣", "4♣", "5♣", "6♣", "7♣"};
    };

    SUBCASE("4 based straight flush"){
        hand = {"4♣", "5♣", "6♣", "7♣", "8♣"};
    };

    SUBCASE("4 based straight flush on hearts"){
        hand = {"4♥", "5♥", "6♥", "7♥", "8♥"};
    };

    SUBCASE("10 based straight flush on hearts"){
        hand = {"T♥", "J♥", "Q♥", "K♥", "A♥"};
    };

    CAPTURE(hand);

    CHECK(isStraightFlush(hand));
}

最后,对于一组不是有效顺子同花的牌的测试。我们将使用几乎是顺子同花的手牌作为输入,除了花色不同、牌数不够或者牌数太多之外:

TEST_CASE("Hand is not straight flush"){
    Hand hand;

    SUBCASE("Would be straight flush except for one card from another 
        suit"){
            hand = {"2♣", "3♣", "4♣", "5♣", "6♠"};
    };

    SUBCASE("Would be straight flush except not enough cards"){
        hand = {"2♣", "3♣", "4♣", "5♣"};
    };

    SUBCASE("Would be straight flush except too many cards"){
        hand = {"2♣", "3♣", "4♣", "5♣", "6♠", "7♠"};
    };

    SUBCASE("Empty hand"){
        hand = {};
    };

    CAPTURE(hand);

    CHECK(!isStraightFlush(hand));
}

现在是时候回到我们的主要问题了——比较扑克牌的手。

将检查顺子同花的代码重新插入到 comparePokerHands 中

尽管我们迄今为止实现了所有这些,但我们的comparePokerHands的实现仍然是硬编码的。让我们回顾一下它当前的状态:

auto comparePokerHands = [](const Hand& /*aliceHand*/, const Hand& bobHand){
    const Hand winningBobHand {"2♣", "3♣", "4♣", "5♣", "6♣"};
    if(bobHand == winningBobHand){
        return "Bob wins with straight flush";
    }
    return "Alice wins with straight flush";
};

但是,现在我们有了检查顺子同花的方法!所以,让我们把我们的实现插入进去:

auto comparePokerHands = [](Hand /*aliceHand*/, Hand bobHand){
    if(isStraightFlush(bobHand)) {
        return "Bob wins with straight flush";
    }
    return "Alice wins with straight flush";
};

所有的测试都通过了,所以我们快要完成了。是时候为我们的Bob 赢得顺子同花情况添加一些额外的测试,以确保我们没有遗漏。我们将保持 Alice 的相同手牌,一个几乎是顺子同花的手牌,然后改变 Bob 的手牌,从以2310开头的顺子同花:

TEST_CASE("Bob wins with straight flush"){
    Hand aliceHand{"2♠", "3♠", "4♠", "5♠", "9♠"};
    Hand bobHand;

    SUBCASE("2 based straight flush"){
        bobHand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    SUBCASE("3 based straight flush"){
        bobHand = {"3♣", "4♣", "5♣", "6♣", "7♣"};
    };

    SUBCASE("10 based straight flush"){
        bobHand = {"T♣", "J♣", "Q♣", "K♣", "A♣"};
    };

    CAPTURE(bobHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Bob wins with straight flush", result);
}

所有之前的测试都通过了。所以,我们已经完成了两种情况——当 Alice 或 Bob 有顺子同花而对手没有时。是时候转移到下一个情况了。

比较两个顺子同花

正如我们在本节开头讨论的那样,当 Alice 和 Bob 都有顺子同花时还有另一种情况,但是 Alice 用更高的顺子同花赢了:

Case: Alice wins with a higher straight flush

Inputs:
 Alice: 3♠, 4♠, 5♠, 6♠, 7♠
 Bob: 2♣, 3♣, 4♣, 5♣, 6♣

Output:
 Alice wins with straight flush

让我们写下测试并运行它:

TEST_CASE("Alice and Bob have straight flushes but Alice wins with higher straight flush"){
    Hand aliceHand;
    Hand bobHand{"2♣", "3♣", "4♣", "5♣", "6♣"};

    SUBCASE("3 based straight flush"){
        aliceHand = {"3♠", "4♠", "5♠", "6♠", "7♠"};
    };

    CAPTURE(aliceHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Alice wins with straight flush", result);
}

测试失败了,因为我们的comparePokerHands函数返回 Bob 赢了,而不是 Alice。让我们用最简单的实现来修复这个问题:

auto comparePokerHands = [](const Hand& aliceHand, const Hand& bobHand){
    if(isStraightFlush(bobHand) && isStraightFlush(aliceHand)){
         return "Alice wins with straight flush";
    }

    if(isStraightFlush(bobHand)) {
        return "Bob wins with straight flush";
    }

    return "Alice wins with straight flush";
};

我们的实现决定了如果 Alice 和 Bob 都有顺子同花,那么 Alice 总是赢。这显然不是我们想要的,但测试通过了。那么我们可以写什么测试来推动实现向前发展呢?

思考

事实证明,我们在之前的分析中漏掉了一个情况。我们看了当 Alice 和 Bob 都有顺子同花并且 Alice 赢的情况;但是如果 Bob 有更高的顺子同花呢?让我们写一个例子:

Case: Bob wins with a higher straight flush

Inputs:
 Alice: 3♠, 4♠, 5♠, 6♠, 7♠
 Bob: 4♣, 5♣, 6♣, 7♣, 8♣

Output:
 Bob wins with straight flush

是时候写另一个失败的测试了。

比较两个顺子同花(续)

现在写这个测试已经相当明显了:

TEST_CASE("Alice and Bob have straight flushes but Bob wins with higher 
    straight flush"){
        Hand aliceHand = {"3♠", "4♠", "5♠", "6♠", "7♠"};
        Hand bobHand;

        SUBCASE("3 based straight flush"){
            bobHand = {"4♣", "5♣", "6♣", "7♣", "8♣"};
    };

    CAPTURE(bobHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Bob wins with straight flush", result);
}

测试再次失败了,因为我们的实现假设当 Alice 和 Bob 都有顺子同花时,Alice 总是赢。也许是时候检查哪个是它们中最高的顺子同花了。

为此,我们需要再次写下一些情况并进行 TDD 循环。我将再次快进到实现。我们最终得到了以下的辅助函数,用于比较两个顺子同花。如果第一手牌有更高的顺子同花,则返回1,如果两者相等,则返回0,如果第二手牌有更高的顺子同花,则返回-1

auto compareStraightFlushes = [](const Hand& first, const Hand& second){
    int firstHandValue = allValuesInOrder(first).front();
    int secondHandValue = allValuesInOrder(second).front();
    if(firstHandValue > secondHandValue) return 1;
    if(secondHandValue > firstHandValue) return -1;
    return 0;
};

通过改变我们的实现,我们可以让测试通过:

auto comparePokerHands = [](const Hand& aliceHand, const Hand& bobHand){
    if(isStraightFlush(bobHand) && isStraightFlush(aliceHand)){
        int whichIsHigher = compareStraightFlushes(aliceHand, bobHand);
        if(whichIsHigher == 1) return "Alice wins with straight flush";
        if(whichIsHigher == -1) return "Bob wins with straight flush";
    }

    if(isStraightFlush(bobHand)) {
        return "Bob wins with straight flush";
    }

    return "Alice wins with straight flush";
};

这让我们留下了最后一种情况——平局。测试再次非常明确:

TEST_CASE("Draw due to equal straight flushes"){
    Hand aliceHand;
    Hand bobHand;

    SUBCASE("3 based straight flush"){
        aliceHand = {"3♠", "4♠", "5♠", "6♠", "7♠"};
    };

    CAPTURE(aliceHand);
    bobHand = aliceHand;

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Draw", result);
}

而且实现的改变非常直接:

auto comparePokerHands = [](Hand aliceHand, Hand bobHand){
    if(isStraightFlush(bobHand) && isStraightFlush(aliceHand)){
        int whichIsHigher = compareStraightFlushes(aliceHand, bobHand);
        if(whichIsHigher == 1) return "Alice wins with straight flush";
        if(whichIsHigher == -1) return "Bob wins with straight flush";
        return "Draw";
    }

    if(isStraightFlush(bobHand)) {
        return "Bob wins with straight flush";
    }

    return "Alice wins with straight flush";
};

这不是最漂亮的函数,但它通过了我们所有的顺子同花比较测试。我们肯定可以将它重构为更小的函数,但我会在这里停下来,因为我们已经达到了我们的目标——使用 TDD 和 DDT 设计了不止一个纯函数。

总结

在本章中,你学会了如何编写单元测试,如何编写数据驱动测试,以及如何将数据驱动测试与 TDD 结合起来设计纯函数。

TDD 是有效软件开发的核心实践之一。虽然有时可能看起来奇怪和违反直觉,但它有一个强大的优势——每隔几分钟,你都有一个可以演示的工作内容。通过测试通过不仅是一个演示点,而且也是一个保存点。如果在尝试重构或实现下一个测试时发生任何错误,你总是可以回到上一个保存点。我发现这种实践在 C++中更有价值,因为有很多事情可能会出错。事实上,我自第三章 深入了解 Lambda以来,都是采用 TDD 方法编写的所有代码。这非常有帮助,因为我知道我的代码是有效的——在没有这种方法的情况下编写技术书籍时,这是相当困难的。我强烈建议你更深入地了解 TDD 并亲自实践;这是你成为专家的唯一途径。

函数式编程与 TDD 完美契合。当将其与命令式面向对象的代码一起使用时,我们经常需要考虑到变异,这使事情变得更加困难。通过纯函数和数据驱动的测试,添加更多的测试实践变得尽可能简单,并允许我们专注于实现。在函数操作的支持下,在许多情况下使测试通过变得更容易。我个人发现这种组合非常有益;我希望你也会觉得同样有用。

现在是时候向前迈进,重新审视软件设计的另一个部分——设计模式。它们在函数式编程中会发生变化吗?(剧透警告——实际上它们变得简单得多。)这是我们将在下一章讨论的内容。

第三部分:收获函数式编程的好处

我们已经学到了很多关于函数式编程的构建模块,如何在 C++中编写它们以及如何使用它们来构建以函数为中心的设计。现在是时候看一看与函数式编程密切相关的一些专门主题了。

首先,我们将深入探讨性能优化这一巨大的主题。我们将学习一些特别适合纯函数的优化技术(例如,记忆化和尾递归优化)。我们将同时关注内存占用和执行时间的优化,进行许多测量,并比较不同的方法。

然后,我们将研究函数式编程如何实现并行和异步执行。不变性导致了对共享状态的避免,因此,对并行执行模式的简化。

但我们可以利用更多的函数式编程。数据生成器和纯函数使得一种称为基于属性的测试的自动化测试范式成为可能,这使我们能够用很少的代码检查许多可能的场景。然后,如果我们需要重构复杂的现有代码,我们会发现我们可以首先将其重构为纯函数,快速为其编写测试,然后决定是否将其重新分发到类中或保留它们。

最后,我们将提升到更高的层次,基于不可变状态的架构范式,因此,与函数式编程密切相关的东西:事件溯源。

以下章节将在本节中涵盖:

  • 第十章,性能优化

  • 第十一章,基于属性的测试

  • 第十二章,重构到和通过纯函数

  • 第十三章,不变性和架构-事件溯源

第十章:性能优化

性能是选择 C++作为项目编程语言的关键驱动因素之一。现在是讨论如何在以函数式风格构建代码时改善性能的时候了。

虽然性能是一个庞大的主题,显然我们无法在一个章节中完全覆盖,但我们将探讨改善性能的关键思想,纯函数式语言如何优化性能,以及如何将这些优化转化为 C++。

本章将涵盖以下主题:

  • 交付性能的流程

  • 如何使用并行/异步来提高性能

  • 理解什么是尾递归以及如何激活它

  • 如何在使用函数式构造时改善内存消耗

  • 功能性异步代码

技术要求

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

代码可以在 GitHub 上找到,位于https:/​/​github.​com/​PacktPublishing/​Hands-​On-​Functional-Programming-​with-​CppChapter10文件夹中。它包括并使用doctest,这是一个单头文件的开源单元测试库。您可以在其 GitHub 存储库上找到它,网址为https:/​/github.​com/​onqtam/​doctest

性能优化

谈论性能优化就像谈论披萨。有些人喜欢和寻找菠萝披萨。其他人只吃传统的意大利披萨(或来自特定地区的披萨)。有些人只吃素食披萨,而其他人喜欢各种披萨。关键是,性能优化是与您的代码库和产品相关的。您正在寻找什么样的性能?对于您的用户来说,性能的最有价值的部分是什么?您需要考虑哪些约束?

我与合作的客户通常有一些性能要求,取决于主题:

  • 嵌入式产品(例如汽车、能源或电信)通常需要在内存限制内工作。堆栈和堆通常很小,因此限制了长期存在的变量数量。增加内存的成本可能是禁止性的(一位客户告诉我们,他们需要超过 1000 万欧元才能在所有设备上增加 1MB 的额外内存)。因此,程序员需要通过尽可能避免不必要的内存分配来解决这些限制。这可能包括初始化、通过复制传递参数(特别是较大的结构)以及避免需要内存消耗的特定算法,等等。

  • 工程应用(例如计算机辅助设计或 CAD)需要在非常大的数据集上使用从数学、物理和工程中衍生出的特定算法,并尽快返回结果。处理通常在现代 PC 上进行,因此 RAM 不是问题;然而,CPU 是问题。随着多核 CPU 的出现,专用 GPU 可以接管部分处理工作以及允许在多个强大或专用服务器之间分配工作负载的云技术的出现,开发人员的工作往往变成了在并行和异步世界中优化速度。

  • 桌面游戏和游戏引擎有它们自己特别的关注点。图形必须尽可能好看,以便在中低端机器上优雅地缩放,并避免延迟。游戏通常会占据它们运行的机器,因此它们只需要与操作系统和系统应用程序(如防病毒软件或防火墙)争夺资源。它们还可以假定特定级别的 GPU、CPU 和 RAM。优化变得关于并行性(因为预期有多个核心)以及避免浪费,以保持整个游戏过程中的流畅体验。

  • 游戏服务器,然而,是一个不同的问题。例如暴雪的战网(我作为星际争霸 II玩家经常使用的一个)需要快速响应,即使在压力下也是如此。在云计算时代,使用的服务器数量和性能并不重要;我们可以轻松地扩展或缩减它们。主要问题是尽可能快地响应大多数 I/O 工作负载。

  • 未来令人兴奋。游戏的趋势是将处理移动到服务器,从而使玩家甚至可以在低端机器上玩游戏。这将为未来的游戏开辟令人惊人的机会。(如果你有 10 个 GPU,你能做什么?如果有 100 个呢?)但也将导致需要优化游戏引擎以进行服务器端、多机器、并行处理。远离游戏,物联网行业为嵌入式软件和可扩展的服务器端处理提供了更多机会。

考虑到所有这些可能性,我们可以在代码库中做些什么来提供性能?

提供性能的流程

正如您所看到的,性能优化在很大程度上取决于您要实现的目标。下一步可以快速总结如下:

  1. 为性能设定明确的目标,包括指标和如何测量它们。

  2. 为性能定义一些编码准则。保持它们清晰并针对代码的特定部分进行调整。

  3. 使代码工作。

  4. 在需要的地方测量和改进性能。

  5. 监控和改进。

在我们更详细地了解这些步骤之前,重要的是要理解性能优化的一个重要警告——有两种优化类型。第一种来自清晰的设计和清晰的代码。例如,通过从代码中删除某些相似性,您可能会减少可执行文件的大小,从而为数据提供更多空间;数据可能会通过代码传输得更少,从而避免不必要的复制或间接;或者,它将允许编译器更好地理解代码并为您进行优化。根据我的经验,将代码重构为简单设计也经常提高了性能。

改进性能的第二种方法是使用点优化。这些是非常具体的方式,我们可以重写函数或流程,使代码能够更快地工作或消耗更少的内存,通常适用于特定的编译器和平台。结果代码通常看起来很聪明,但很难理解和更改。

点优化与编写易于更改和维护的代码存在天然冲突。这导致了唐纳德·克努斯说过早优化是万恶之源。这并不意味着我们应该编写明显缓慢的代码,比如通过复制大型集合。然而,这意味着我们应该首先优化设计以便更易更改,然后测量性能,然后优化它,并且只在绝对必要时使用点优化。平台的怪癖、特定的编译器版本或使用的库可能需要不时进行点优化;将它们分开并节制使用。

现在让我们来看看我们的性能优化流程。

为性能设定明确的目标,包括指标和如何测量它们

如果我们不知道我们要去哪里,那么我们去哪个方向都无所谓——我是从《爱丽丝梦游仙境》中引用的。因此,我们应该知道我们要去哪里。我们需要一个适合我们产品需求的性能指标列表。此外,对于每个性能指标,我们需要一个定义该指标的值和可接受值的范围。让我们看几个例子。

如果您正在为具有 4MB 内存的设备构建嵌入式产品,您可能会关注诸如:

  • 内存消耗:

  • 很好:1-3 MB

  • 好:3-4 MB

  • 设备启动时间:

  • 很好:<1 秒

  • 好:1-3 秒

如果你正在构建一个桌面 CAD 应用程序,用于模拟建筑设计中的声波,其他指标也很有趣。

模拟声波建模的计算时间:

  • 对于一个小房间:

  • 很好:<1 分钟

  • 好:<5 分钟

  • 对于一个中等大小的房间:

  • 很好:<2 分钟

  • 好:<10 分钟

这里的数字仅供参考;你需要为你的产品找到自己的度量标准。

有了这些度量标准和好/很好的范围,我们可以在添加新功能后测量性能并进行相应的优化。它还可以让我们向利益相关者或业务人员简单地解释产品的性能。

为性能定义一些编码准则-保持清晰,并针对代码的特定部分进行定制

如果你问 50 个不同的 C++程序员关于优化性能的建议,你很快就会被淹没在建议中。如果你开始调查这些建议,结果会发现其中一些已经过时,一些非常具体,一些很好。

因此,对性能有编码准则是很重要的,但有一个警告。C++代码库往往很庞大,因为它们已经发展了很多年。如果你对你的代码库进行批判性审视,你会意识到只有一部分代码是性能瓶颈。举个例子,如果一个数学运算快了 1 毫秒,只有当这个运算会被多次调用时才有意义;如果它只被调用一两次,或者很少被调用,就没有必要进行优化。事实上,下一个版本的编译器或 CPU 可能会比你更擅长优化它。

由于这个事实,你应该了解你的代码的哪些部分对你定义的性能标准至关重要。找出哪种设计最适合这个特定的代码片段;制定清晰的准则,并遵循它们。虽然const&在任何地方都很有用,也许你可以避免浪费开发人员的时间对一个只做一次的非常小的集合进行排序。

让代码工作

牢记这些准则,并有一个新功能要实现,第一步应该始终是让代码工作。此外,结构化使其易于在你的约束条件内进行更改。不要试图在这里优化性能;再次强调,编译器和 CPU 可能比你想象的更聪明,做的工作也比你期望的多。要知道是否是这种情况,唯一的办法就是测量性能。

在需要的地方测量和改进性能

你的代码可以按照你的准则工作和结构化,并且为变更进行了优化。现在是时候写下一些关于优化它的假设,然后进行测试了。

由于你对性能有明确的度量标准,验证它们相对容易。当然,这需要正确的基础设施和适当的测量过程。有了这些,你就可以测量你在性能指标上的表现。

在这里应该欢迎额外的假设。比如- 如果我们像这样重构这段代码,我期望指标 X 会有所改善。然后你可以继续测试你的假设-开始一个分支,改变代码,构建产品,经过性能指标测量过程,看看结果。当然,实际情况可能比我说的更复杂-有时可能需要使用不同的编译器进行构建,使用不同的优化选项,或者统计数据。如果你想做出明智的决定,这些都是必要的。投入一些时间来进行度量,而不是改变代码并使其更难理解会更好。否则,你最终会得到一笔技术债务,你将长期支付利息。

然而,如果你必须进行点优化,没有变通的办法。只需确保尽可能详细地记录它们。因为你之前已经测试过你的假设,你会有很多东西要写,对吧?

监控和改进

我们通过定义性能指标来开始循环。现在是时候结束了,我们需要监控这些指标(可能还有其他指标),并根据我们所学到的知识调整我们的间隔和编码准则。性能优化是一个持续的过程,因为目标设备也在不断发展。

我们已经看过了交付性能的流程,但这与函数式编程有什么关系呢?哪些用例使函数式代码结构发光,哪些又效果不佳?现在是时候深入研究我们的代码结构了。

并行性-利用不可变性

编写并行运行的代码一直是软件开发中的一大痛点。多线程、多进程或多服务器环境带来的问题似乎根本难以解决。死锁、饥饿、数据竞争、锁或调试多线程代码等术语让我们这些见过它们的人害怕再次遇到它们。然而,由于多核 CPU、GPU 和多个服务器,我们不得不面对并行代码。函数式编程能帮助解决这个问题吗?

每个人都同意这是函数式编程的一个强项,特别是源自不可变性。如果你的数据从不改变,就不会有锁,同步也会变得非常简单并且可以泛化。如果你只使用纯函数和函数转换(当然除了 I/O),你几乎可以免费获得并行化。

事实上,C++ 17 标准包括 STL 高级函数的执行策略,允许我们通过一个参数将算法从顺序改为并行。让我们来检查向量中是否所有数字都大于5的并行执行。我们只需要将execution::par作为all_of的执行策略即可:

auto aVector = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto all_of_parallel = [&aVector](){
    return all_of(execution::par, aVector.begin(), aVector.end(),  
        [](auto value){return value > 5;});
};

然后,我们可以使用chrono命名空间的高分辨率计时器来衡量使用顺序和并行版本算法的差异,就像这样:

auto measureExecutionTimeForF = [](auto f){
    auto t1 = high_resolution_clock::now();
    f();
    auto t2 = high_resolution_clock::now();
    chrono::nanoseconds duration = t2 - t1;
    return duration;
};

通常情况下,我现在会展示基于我的实验的执行差异。不幸的是,在这种情况下,我不能这样做。在撰写本文时,唯一实现执行策略的编译器是 MSVC 和英特尔 C++,但它们都不符合标准。然而,如下代码段所示,我在parallelExecution.cpp源文件中编写了代码,当你的编译器支持标准时,你可以通过取消注释一行来启用它:

// At the time when I created this file, only MSVC had implementation  
    for execution policies.
// Since you're seeing this in the future, you can enable the parallel 
    execution code by uncommenting the following line 
//#define PARALLEL_ENABLED

当你运行这段代码时,它将显示顺序和并行运行all_of的比较持续时间,就像这样:

TEST_CASE("all_of with sequential execution policy"){
    auto aVector = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    auto all_of_sequential = [&aVector](){
        return all_of(execution::seq, aVector.begin(), aVector.end(), 
            [](auto value){return value > 5;});
    };

    auto sequentialDuration = 
        measureExecutionTimeForF(all_of_sequential);
        cout << "Execution time for sequential policy:" << 
            sequentialDuration.count() << " ns" << endl;

    auto all_of_parallel = [&aVector](){
        return all_of(execution::par, aVector.begin(), aVector.end(), 
            [](auto value){return value > 5;});
    };

    auto parallelDuration = measureExecutionTimeForF(all_of_parallel);
    cout << "Execution time for parallel policy:" <<   
        parallelDuration.count() << " ns" << endl;
}

虽然我很想在这里分析一些执行数据,但也许最好的是我不能,因为这一章最重要的信息是要衡量、衡量、衡量,然后再优化。希望在合适的时候你也能进行一些衡量。

C++ 17 标准支持许多 STL 函数的执行策略,包括sortfindcopytransformreduce。也就是说,如果你在这些函数上进行链式调用并使用纯函数,你只需要为所有调用传递一个额外的参数(或者将高级函数绑定),就可以实现并行执行!我敢说,对于那些尝试自己管理线程或调试奇怪同步问题的人来说,这几乎就像魔法一样。事实上,在前几章中我们为井字棋和扑克牌手写的所有代码都可以很容易地切换到并行执行,只要编译器支持完整的 C++ 17 标准。

但是这是如何工作的?对于all_of来说,运行在多个线程中是相当容易的;每个线程在集合中的特定元素上执行谓词,返回一个布尔值,并且当第一个谓词返回False时,进程停止。只有当谓词是纯函数时才可能发生这种情况;以任何方式修改结果或向量都会创建竞争条件。文档明确指出程序员有责任保持谓词函数的纯净性——不会有警告或编译错误。除了是纯函数外,你的谓词不能假设元素被处理的顺序。

如果并行执行策略无法启动(例如,由于资源不足),执行将回退到顺序调用。在测量性能时,这是一个需要记住的有用事情:如果性能远低于预期,请首先检查程序是否可以并行执行。

这个选项对于使用多个 CPU 的计算密集型应用程序非常有用。如果你对它的内存消耗感兴趣,你需要测量一下,因为它取决于你使用的编译器和标准库。

记忆化

纯函数具有一个有趣的特性。对于相同的输入值,它们返回相同的输出。这使它们等同于一个大表格的值,其中每个输入参数的组合都对应一个输出值。有时,记住这个表格的部分比进行计算更快。这种技术称为记忆化

纯函数式编程语言以及诸如 Python 和 Groovy 之类的语言,都有办法在特定函数调用上启用记忆化,从而提供了高度的控制。不幸的是,C++没有这个功能,所以我们必须自己编写它。

实现记忆化

要开始我们的实现,我们需要一个函数;最好是计算昂贵的。让我们选择power函数。一个简单的实现只是标准pow函数的包装器,如下面的代码片段所示:

function<long long(int, int)> power = [](auto base, auto exponent){
    return pow(base, exponent);
};

我们如何开始实现记忆化?嗯,在其核心,记忆化就是缓存。每当一个函数第一次被调用时,它会正常运行,但同时也将结果与输入值组合存储起来。在后续的调用中,函数将搜索映射以查看值是否被缓存,并在有缓存时返回它。

这意味着我们需要一个缓存,其键是参数,值是计算结果。为了将参数组合在一起,我们可以简单地使用一对或元组:

tuple<int, int> parameters

因此,缓存将是:

    map<tuple<int, int>, long long> cache;

让我们改变我们的power函数以使用这个缓存。首先,我们需要在缓存中查找结果:

    function<long long(int, int)> memoizedPower = &cache{
            tuple<int, int> parameters(base, exponent);
            auto valueIterator = cache.find(parameters);

如果没有找到任何东西,我们计算结果并将其存储在缓存中。如果找到了某些东西,那就是我们要返回的值:

        if(valueIterator == cache.end()){
            result = pow(base, exponent);
            cache[parameters] = result;
        } else{
            result = valueIterator -> second;
        }
        return result; 

为了检查这种方法是否正常工作,让我们运行一些测试:

    CHECK_EQ(power(1, 1), memoizedPower(1, 1));
    CHECK_EQ(power(3, 19), memoizedPower(3, 19));
    CHECK_EQ(power(2, 25), memoizedPower(2, 25));

一切都很顺利。现在让我们比较 power 的两个版本,在下面的代码片段中有和没有记忆化。下面的代码显示了我们如何提取一种更通用的方法来记忆化函数:

    function<long long(int, int)> power = [](int base, int exponent){
        return pow(base, exponent);
    };

    map<tuple<int, int>, long long> cache;

    function<long long(int, int)> memoizedPower = &cache{
            tuple<int, int> parameters(base, exponent);
            auto valueIterator = cache.find(parameters);
            long long result;
            if(valueIterator == cache.end()){
 result = pow(base, exponent);
            cache[parameters] = result;
        } else{
            result = valueIterator -> second;
        }
        return result; 
    };

第一个观察是我们可以用原始 power 函数的调用替换粗体行,所以让我们这样做:

    function<long long(int, int)> memoizedPower = &cache, &power{
            tuple<int, int> parameters(base, exponent);
            auto valueIterator = cache.find(parameters);
            long long result;
            if(valueIterator == cache.end()){
 result = power(base, exponent);
            cache[parameters] = result;
        } else{
            result = valueIterator -> second;
        }
        return result; 
    };

如果我们传入我们需要在记忆化期间调用的函数,我们将得到一个更通用的解决方案:

    auto memoize = &cache{
            tuple<int, int> parameters(base, exponent);
            auto valueIterator = cache.find(parameters);
            long long result;
            if(valueIterator == cache.end()){
            result = functionToMemoize(base, exponent);
            cache[parameters] = result;
        } else{
            result = valueIterator -> second;
        }
        return result; 
    };

    CHECK_EQ(power(1, 1), memoize(1, 1, power));
    CHECK_EQ(power(3, 19), memoize(3, 19, power));
    CHECK_EQ(power(2, 25), memoize(2, 25, power));

但是返回一个记忆化的函数不是很好吗?我们可以修改我们的memoize函数,使其接收一个函数并返回一个记忆化的函数,该函数接收与初始函数相同的参数:

    auto memoize = [](auto functionToMemoize){
        map<tuple<int, int>, long long> cache;
 return & {
            tuple<int, int> parameters(base, exponent);
            auto valueIterator = cache.find(parameters);
            long long result;
            if(valueIterator == cache.end()){
                result = functionToMemoize(base, exponent);
                cache[parameters] = result;
            } else{
                result = valueIterator -> second;
            }
            return result; 
            };
    };
    auto memoizedPower = memoize(power);

这个改变最初不起作用——我得到了一个分段错误。原因是我们在 lambda 内部改变了缓存。为了使它工作,我们需要使 lambda 可变,并按值捕获:

    auto memoize = [](auto functionToMemoize){
        map<tuple<int, int>, long long> cache;
 return = mutable {
            tuple<int, int> parameters(base, exponent);
            auto valueIterator = cache.find(parameters);
            long long result;
            if(valueIterator == cache.end()){
                result = functionToMemoize(base, exponent);
                cache[parameters] = result;
            } else{
                result = valueIterator -> second;
            }
            return result; 
            };
    };

现在我们有一个可以对任何带有两个整数参数的函数进行记忆化的函数。通过使用一些类型参数,很容易使它更通用。我们需要一个返回值的类型,第一个参数的类型和第二个参数的类型:

template<typename ReturnType, typename FirstArgType, typename 
    SecondArgType>
auto memoizeTwoParams = [](function<ReturnType(FirstArgType, SecondArgType)> functionToMemoize){
    map<tuple<FirstArgType, SecondArgType>, ReturnType> cache;
    return = mutable {
        tuple<FirstArgType, SecondArgType> parameters(firstArg, 
    secondArg);
        auto valueIterator = cache.find(parameters);
        ReturnType result;
        if(valueIterator == cache.end()){
            result = functionToMemoize(firstArg, secondArg);
            cache[parameters] = result;
        } else{
            result = valueIterator -> second;
        }
        return result; 
    };
};

我们已经实现了一个对具有两个参数的任何函数进行记忆化的函数。我们可以做得更好。C++允许我们使用具有未指定数量类型参数的模板,即所谓的可变参数模板。通过使用它们的魔力,我们最终得到了一个可以处理任何数量参数的函数的记忆化实现:

template<typename ReturnType, typename... Args>
function<ReturnType(Args...)> memoize(function<ReturnType(Args...)> f){
    map<tuple<Args...>, ReturnType> cache;
    return (= mutable  {
            tuple<Args...> theArguments(args...);
            auto cached = cache.find(theArguments);
            if(cached != cache.end()) return cached -> second;
            auto result = f(args...);
            cache[theArguments] = result;
            return result;
    });
};

这个函数对缓存任何其他函数都有帮助;然而,有一个问题。到目前为止,我们使用了power的包装实现。以下是一个示例,如果我们自己编写它会是什么样子:

function<long long(int, int)> power = & 
{
    return (exponent == 0) ? 1 : base * power(base, exponent - 1);
};

对这个函数进行记忆化只会缓存最终结果。然而,这个函数是递归的,我们的memoize函数调用不会记忆递归的中间结果。为了做到这一点,我们需要告诉我们的记忆化幂函数不要调用power函数,而是调用记忆化的power函数。

不幸的是,没有简单的方法可以做到这一点。我们可以将递归调用的函数作为参数传递,但这会因为实现原因改变原始函数的签名。或者我们可以重写函数以利用记忆化。

最终,我们得到了一个相当不错的解决方案。让我们来测试一下。

使用记忆化

让我们使用我们的measureExecutionTimeForF函数来测量调用我们的power函数所需的时间。现在也是时候考虑我们期望的结果了。我们确实缓存了重复调用的值,但这需要在每次调用函数时进行自己的处理和内存。所以,也许它会有所帮助,也许不会。在尝试之前,我们不会知道。

TEST_CASE("Pow vs memoized pow"){
    function<int(int, int)> power = [](auto first, auto second){
        return pow(first, second);
    };

    cout << "Computing pow" << endl;
    printDuration("First call no memoization: ",  [&](){ return 
        power(5, 24);});
    printDuration("Second call no memoization: ", [&](){return power(3, 
        1024);});
    printDuration("Third call no memoization: ", [&](){return power(9, 
        176);});
    printDuration("Fourth call no memoization (same as first call): ", 
        [&](){return power(5, 24);});

    auto powerWithMemoization = memoize(power);
    printDuration("First call with memoization: ",  [&](){ return 
        powerWithMemoization(5, 24);});
    printDuration("Second call with memoization: ", [&](){return 
        powerWithMemoization(3, 1024);});
    printDuration("Third call with memoization: ", [&](){return 
        powerWithMemoization(9, 176);});
    printDuration("Fourth call with memoization (same as first call): 
        ", [&](){return powerWithMemoization(5, 24);});
    cout << "DONE computing pow" << endl;

    CHECK_EQ(power(5, 24),  powerWithMemoization(5, 24));
    CHECK_EQ(power(3, 1024),  powerWithMemoization(3, 1024));
    CHECK_EQ(power(9, 176),  powerWithMemoization(9, 176));
}

这段代码使用相同的值调用power函数,最后一次调用返回到第一次调用的值。然后继续做同样的事情,但在创建power的记忆化版本之后。最后,一个健全性检查——power函数的结果和记忆化的power函数的结果进行比较,以确保我们的memoize函数没有错误。

问题是——记忆化是否改善了执行系列中最后一个调用所需的时间(与系列中第一个调用完全相同)?在我的配置中,结果是混合的,如下面的片段所示:

Computing pow
First call no memoization: 26421 ns
Second call no memoization: 5207 ns
Third call no memoization: 2058 ns
Fourth call no memoization (same as first call): 179 ns
First call with memoization: 2380 ns
Second call with memoization: 2207 ns
Third call with memoization: 1539 ns
Fourth call with memoization (same as first call): 936 ns
DONE computing pow

或者,为了更好地查看(首先是没有记忆化的调用),有以下内容:

First call: 26421 ns > 2380 ns
Second call: 5207 ns > 2207 ns
Third call: 2058 ns > 1539 ns
Fourth call: 179 ns < 936 ns

总的来说,使用记忆化的调用更好,除非我们重复第一个调用。当然,重复运行测试时结果会有所不同,但这表明提高性能并不像只是使用缓存那么简单。背后发生了什么?我认为最有可能的解释是另一个缓存机制启动了——CPU 或其他机制。

无论如何,这证明了测量的重要性。不出乎意料的是,CPU 和编译器已经做了相当多的优化,我们在代码中能做的也有限。

如果我们尝试递归记忆化呢?我重写了power函数以递归使用记忆化,并将缓存与递归调用混合在一起。以下是代码:

    map<tuple<int, int>, long long> cache;
    function<long long(int, int)> powerWithMemoization = & -> long long{
            if(exponent == 0) return 1;
            long long value;

            tuple<int, int> parameters(base, exponent);
            auto valueIterator = cache.find(parameters);
            if(valueIterator == cache.end()){
            value = base * powerWithMemoization(base, exponent - 1);
            cache[parameters] = value;
            } else {
            value = valueIterator->second;
        };
        return value;
    };

当我们运行它时,结果如下:

Computing pow
First call no memoization: 1761 ns
Second call no memoization: 106994 ns
Third call no memoization: 8718 ns
Fourth call no memoization (same as first call): 1395 ns
First call with recursive memoization: 30921 ns
Second call with recursive memoization: 2427337 ns
Third call with recursive memoization: 482062 ns
Fourth call with recursive memoization (same as first call): 1721 ns
DONE computing pow

另外,以压缩视图(首先是没有记忆化的调用),有以下内容:

First call: 1761 ns < 30921 ns
Second call: 106994 ns < 2427337 ns
Third call: 8718 ns < 482062 ns
Fourth call: 1395 ns < 1721 ns

正如你所看到的,构建缓存的时间是巨大的。然而,对于重复调用来说是值得的,但在这种情况下仍然无法击败 CPU 和编译器的优化。

那么,备忘录有帮助吗?当我们使用更复杂的函数时,它确实有帮助。接下来让我们尝试计算两个数字的阶乘之间的差异。我们将使用阶乘的一个简单实现,并首先尝试对阶乘函数进行备忘录,然后再对计算差异的函数进行备忘录。为了保持一致,我们将使用与之前相同的数字对。让我们看一下以下代码:

TEST_CASE("Factorial difference vs memoized"){
    function<int(int)> fact = &fact{
        if(n == 0) return 1;
        return n * fact(n-1);
    };

    function<int(int, int)> factorialDifference = &fact{
            return fact(second) - fact(first);
    };
    cout << "Computing factorial difference" << endl;
    printDuration("First call no memoization: ",  [&](){ return 
        factorialDifference(5, 24);});
    printDuration("Second call no memoization: ", [&](){return 
        factorialDifference(3, 1024);});
    printDuration("Third call no memoization: ", [&](){return 
        factorialDifference(9, 176);});
    printDuration("Fourth call no memoization (same as first call): ", 
        [&](){return factorialDifference(5, 24);});

    auto factWithMemoization = memoize(fact);
    function<int(int, int)> factorialMemoizedDifference = 
        &factWithMemoization{
        return factWithMemoization(second) - 
            factWithMemoization(first);
    };
    printDuration("First call with memoized factorial: ",  [&](){ 
        return factorialMemoizedDifference(5, 24);});
    printDuration("Second call with memoized factorial: ", [&](){return 
        factorialMemoizedDifference(3, 1024);});
    printDuration("Third call with memoized factorial: ", [&](){return 
        factorialMemoizedDifference(9, 176);});
    printDuration("Fourth call with memoized factorial (same as first 
        call): ", [&](){return factorialMemoizedDifference(5, 24);});

    auto factorialDifferenceWithMemoization = 
        memoize(factorialDifference);
    printDuration("First call with memoization: ",  [&](){ return 
        factorialDifferenceWithMemoization(5, 24);});
    printDuration("Second call with memoization: ", [&](){return 
        factorialDifferenceWithMemoization(3, 1024);});
    printDuration("Third call with memoization: ", [&](){return 
        factorialDifferenceWithMemoization(9, 176);});
    printDuration("Fourth call with memoization (same as first call): 
        ", [&](){return factorialDifferenceWithMemoization(5, 24);});

    cout << "DONE computing factorial difference" << endl;

    CHECK_EQ(factorialDifference(5, 24),  
        factorialMemoizedDifference(5, 24));
    CHECK_EQ(factorialDifference(3, 1024),  
        factorialMemoizedDifference(3, 1024));
    CHECK_EQ(factorialDifference(9, 176),        
        factorialMemoizedDifference(9, 176));

    CHECK_EQ(factorialDifference(5, 24),  
        factorialDifferenceWithMemoization(5, 24));
    CHECK_EQ(factorialDifference(3, 1024),  
        factorialDifferenceWithMemoization(3, 1024));
    CHECK_EQ(factorialDifference(9, 176),  
        factorialDifferenceWithMemoization(9, 176));
}

结果是什么?让我们先看一下普通函数和使用备忘录阶乘函数之间的差异:

Computing factorial difference
First call no memoization: 1727 ns
Second call no memoization: 79908 ns
Third call no memoization: 8037 ns
Fourth call no memoization (same as first call): 1539 ns
First call with memoized factorial: 4672 ns
Second call with memoized factorial: 41183 ns
Third call with memoized factrorial: 10029 ns
Fourth call with memoized factorial (same as first call): 1105 ns

让我们再次并排比较它们:

First call: 1727 ns < 4672 ns
Second call: 79908 ns > 41183 ns
Third call: 8037 ns < 10029 ns
Fourth call: 1539 ns > 1105 ns

尽管其他调用的结果是混合的,但在命中缓存值时,备忘录函数比非备忘录函数有约 20%的改进。这似乎是一个小的改进,因为阶乘是递归的,所以理论上,备忘录应该会有很大的帮助。然而,我们没有对递归进行备忘录。相反,阶乘函数仍然递归调用非备忘录版本。我们稍后会回到这个问题;现在,让我们来看一下在备忘录factorialDifference函数时会发生什么:

First call no memoization: 1727 ns
Second call no memoization: 79908 ns
Third call no memoization: 8037 ns
Fourth call no memoization (same as first call): 1539 ns
First call with memoization: 2363 ns
Second call with memoization: 39700 ns
Third call with memoization: 8678 ns
Fourth call with memoization (same as first call): 704 ns

让我们并排看一下结果:

First call: 1727 ns < 2363 ns
Second call: 79908 ns > 39700 ns
Third call: 8037 ns < 8678 ns
Fourth call: 1539 ns > 704 ns

备忘录版本比非备忘录版本在缓存值上快两倍!这太大了!然而,当我们没有缓存值时,我们会因此而付出性能损失。而且,在第二次调用时出现了一些奇怪的情况;某种缓存可能会干扰我们的结果。

我们能通过优化阶乘函数的所有递归来使其更好吗?让我们看看。我们需要改变我们的阶乘函数,使得缓存适用于每次调用。为了做到这一点,我们需要递归调用备忘录阶乘函数,而不是普通的阶乘函数,如下所示:

    map<int, int> cache;
    function<int(int)> recursiveMemoizedFactorial = 
        &recursiveMemoizedFactorial, &cache mutable{
        auto value = cache.find(n); 
        if(value != cache.end()) return value->second;
        int result;

        if(n == 0) 
            result = 1;
        else 
            result = n * recursiveMemoizedFactorial(n-1);

        cache[n] = result;
        return result;
    };

我们使用差异函数,递归地对阶乘的两次调用进行备忘录:

    function<int(int, int)> factorialMemoizedDifference =  
        &recursiveMemoizedFactorial{
                return recursiveMemoizedFactorial(second) -  
                    recursiveMemoizedFactorial(first);
    };

通过并排运行初始函数和先前函数的相同数据,我得到了以下输出:

Computing factorial difference
First call no memoization: 1367 ns
Second call no memoization: 58045 ns
Third call no memoization: 16167 ns
Fourth call no memoization (same as first call): 1334 ns
First call with recursive memoized factorial: 16281 ns
Second call with recursive memoized factorial: 890056 ns
Third call with recursive memoized factorial: 939 ns
Fourth call with recursive memoized factorial (same as first call): 798 ns 

我们可以并排看一下:

First call: 1,367 ns < 16,281 ns
Second call: 58,045 ns < 890,056 ns Third call: 16,167 ns > 939 ns Fourth call: 1,334 ns > 798 ns

正如我们所看到的,缓存正在累积,对于第一个大计算来说惩罚很大;第二次调用涉及 1024!然而,由于缓存命中,随后的调用速度要快得多。

总之,我们可以说,当有足够的内存可用时,备忘录对于加速重复的复杂计算是有用的。它可能需要一些调整,因为缓存大小和缓存命中取决于对函数的调用次数和重复调用次数。因此,不要认为这是理所当然的——要进行测量,测量,测量。

尾递归优化

递归算法在函数式编程中非常常见。实际上,我们的命令式循环中的许多循环可以使用纯函数重写为递归算法。

然而,在命令式编程中,递归并不是很受欢迎,因为它有一些问题。首先,开发人员往往对递归算法的练习比起命令式循环要少。其次,可怕的堆栈溢出——递归调用默认情况下会被放到堆栈上,如果迭代次数太多,堆栈就会溢出并出现一个丑陋的错误。

幸运的是,编译器很聪明,可以为我们解决这个问题,同时优化递归函数。进入尾递归优化。

让我们来看一个简单的递归函数。我们将重用前一节中的阶乘,如下所示:

    function<int(int)> fact = &fact{
        if(n == 0) return 1;
        return n * fact(n-1);
    };

通常,每次调用都会被放在堆栈上,因此每次调用堆栈都会增长。让我们来可视化一下:

Stack content fact(1024)
1024 * fact(1023)
1023 * fact(1022)
...
1 * fact(0)
fact(0) = 1 => unwind the stack

我们可以通过重写代码来避免堆栈。我们注意到递归调用是在最后进行的;因此,我们可以将函数重写为以下伪代码:

    function<int(int)> fact = &fact{
        if(n == 0) return 1;
        return n * (n-1) * (n-1-1) * (n-1-1-1) * ... * fact(0);
    };

简而言之,如果我们启用正确的优化标志,编译器可以为我们做的事情。这个调用不仅占用更少的内存,避免了堆栈溢出,而且速度更快。

到现在为止,你应该知道不要相信任何人的说法,包括我的,除非经过测量。所以,让我们验证这个假设。

首先,我们需要一个测试,用于测量对阶乘函数的多次调用的时间。我选择了一些值来进行测试:

TEST_CASE("Factorial"){
    function<int(int)> fact = &fact{
        if(n == 0) return 1;
        return n * fact(n-1);
    };

    printDuration("Duration for 0!: ", [&](){return fact(0);});
    printDuration("Duration for 1!: ", [&](){return fact(1);});
    printDuration("Duration for 10!: ", [&](){return fact(10);});
    printDuration("Duration for 100!: ", [&](){return fact(100);});
    printDuration("Duration for 1024!: ", [&](){return fact(1024);});
}

然后,我们需要编译此函数,分别禁用和启用优化。GNU 编译器集合GCC)优化尾递归的标志是-foptimize-sibling-calls;该名称指的是该标志同时优化了兄弟调用和尾调用。我不会详细介绍兄弟调用优化的作用;让我们只说它不会以任何方式影响我们的测试。

运行这两个程序的时间。首先,让我们看一下原始输出:

  • 这是没有优化的程序:
Duration for 0!: 210 ns
Duration for 1!: 152 ns
Duration for 10!: 463 ns
Duration for 100!: 10946 ns
Duration for 1024!: 82683 ns
  • 这是带有优化的程序:
Duration for 0!: 209 ns
Duration for 1!: 152 ns
Duration for 10!: 464 ns
Duration for 100!: 6455 ns
Duration for 1024!: 75602 ns

现在让我们一起看一下结果;没有优化的持续时间在左边:

Duration for 0!: 210 ns > 209 ns
Duration for 1!: 152 ns  = 152 ns
Duration for 10!: 463 ns < 464 ns
Duration for 100!: 10946 ns > 6455 ns
Duration for 1024!: 82683 ns > 75602 ns

看起来在我的机器上,优化确实对较大的值起作用。这再次证明了在性能要求时度量的重要性。

在接下来的几节中,我们将以各种方式对代码进行实验,并测量结果。

完全优化的调用

出于好奇,我决定运行相同的程序,并打开所有安全优化标志。在 GCC 中,这个选项是-O3。结果令人震惊,至少可以这么说:

Duration for 0!: 128 ns
Duration for 1!: 96 ns
Duration for 10!: 96 ns
Duration for 100!: 405 ns
Duration for 1024!: 17249 ns

让我们比较启用所有优化标志的结果(下一段代码中的第二个值)与仅尾递归优化的结果:

Duration for 0!: 209 ns > 128 ns
Duration for 1!: 152 ns > 96 ns
Duration for 10!: 464 ns > 96 ns
Duration for 100!: 6455 ns > 405 ns
Duration for 1024!: 75602 ns > 17249 ns

差异是巨大的,正如你所看到的。结论是,尽管尾递归优化很有用,但启用编译器的 CPU 缓存命中和所有优化功能会更好。

但是我们使用了if语句;当我们使用?:运算符时,这会有不同的效果吗?

if vs ?:

出于好奇,我决定使用?:运算符重写代码,而不是if语句,如下所示:

    function<int(int)> fact = &fact{
        return (n == 0) ? 1 : (n * fact(n-1));
    };

我不知道会有什么结果,结果很有趣。让我们看一下原始输出:

  • 没有优化标志:
Duration for 0!: 633 ns
Duration for 1!: 561 ns
Duration for 10!: 1441 ns
Duration for 100!: 20407 ns
Duration for 1024!: 215600 ns
  • 打开尾递归标志:
Duration for 0!: 277 ns
Duration for 1!: 214 ns
Duration for 10!: 578 ns
Duration for 100!: 9573 ns
Duration for 1024!: 81182 ns

让我们比较一下结果;没有优化的持续时间首先出现:

Duration for 0!: 633 ns > 277 ns
Duration for 1!: 561 ns > 214 ns
Duration for 10!: 1441 ns > 578 ns
Duration for 100!: 20407 ns > 9573 ns
Duration for 1024!: 75602 ns > 17249 ns

两个版本之间的差异非常大,这是我没有预料到的。像往常一样,这很可能是 GCC 编译器的结果,你应该自己测试一下。然而,看起来这个版本对于我的编译器来说更适合尾部优化,这是一个非常有趣的结果。

双递归

尾递归对双递归有效吗?我们需要想出一个例子,将递归从一个函数传递到另一个函数,以检查这一点。我决定编写两个函数,f1f2,它们互相递归调用。f1将当前参数与f2(n - 1 )相乘,而f2f1(n)添加到f1(n-1)。以下是代码:

    function<int(int)> f2;
    function<int(int)> f1 = &f2{
        return (n == 0) ? 1 : (n * f2(n-1));
    };

    f2 = &f1{
        return (n == 0) ? 2 : (f1(n) + f1(n-1));
    };

让我们检查对f1的调用的时间,值从08

    printDuration("Duration for f1(0): ", [&](){return f1(0);});
    printDuration("Duration for f1(1): ", [&](){return f1(1);});
    printDuration("Duration for f1(2): ", [&](){return f1(2);});
    printDuration("Duration for f1(3): ", [&](){return f1(3);});
    printDuration("Duration for f1(4): ", [&](){return f1(4);});
    printDuration("Duration for f1(5): ", [&](){return f1(5);});
    printDuration("Duration for f1(6): ", [&](){return f1(6);});
    printDuration("Duration for f1(7): ", [&](){return f1(7);});
    printDuration("Duration for f1(8): ", [&](){return f1(8);});

这是我们得到的结果:

  • 没有尾调用优化:
Duration for f1(0): 838 ns
Duration for f1(1): 825 ns
Duration for f1(2): 1218 ns
Duration for f1(3): 1515 ns
Duration for f1(4): 2477 ns
Duration for f1(5): 3919 ns
Duration for f1(6): 5809 ns
Duration for f1(7): 9354 ns
Duration for f1(8): 14884 ns
  • 使用调用优化:
Duration for f1(0): 206 ns
Duration for f1(1): 327 ns
Duration for f1(2): 467 ns
Duration for f1(3): 642 ns
Duration for f1(4): 760 ns
Duration for f1(5): 1155 ns
Duration for f1(6): 2023 ns
Duration for f1(7): 3849 ns
Duration for f1(8): 4986 ns

让我们一起看一下结果;没有尾优化的调用持续时间在左边:

f1(0): 838 ns > 206 ns
f1(1): 825 ns > 327 ns
f1(2): 1218 ns > 467 ns
f1(3): 1515 ns > 642 ns
f1(4): 2477 ns > 760 ns
f1(5): 3919 ns > 1155 ns
f1(6): 5809 ns > 2023 ns
f1(7): 9354 ns > 3849 ns
f1(8): 14884 ns > 4986 ns

差异确实非常大,显示代码得到了很大的优化。但是,请记住,对于 GCC,我们使用的是-foptimize-sibling-calls优化标志。该标志执行两种优化:尾调用和兄弟调用。兄弟调用是指对返回类型和参数列表总大小相同的函数的调用,因此允许编译器以与尾调用类似的方式处理它们。在我们的情况下,很可能两种优化都被应用了。

使用异步代码优化执行时间

当我们有多个线程时,我们可以使用两种近似技术来优化执行时间:并行执行和异步执行。我们已经在前一节中看到了并行执行的工作原理;异步调用呢?

首先,让我们回顾一下异步调用是什么。我们希望进行一次调用,然后在主线程上继续正常进行,并在将来的某个时候获得结果。对我来说,这听起来像是函数的完美工作。我们只需要调用函数,让它们执行,然后在一段时间后再与它们交谈。

既然我们已经谈到了 future,让我们来谈谈 C++中的future构造。

Futures

我们已经确定,在程序中避免管理线程是理想的,除非进行非常专业化的工作,但我们需要并行执行,并且通常需要同步以从另一个线程获取结果。一个典型的例子是一个长时间的计算,它会阻塞主线程,除非我们在自己的线程中运行它。我们如何知道计算何时完成,以及如何获得计算的结果?

在 1976 年至 1977 年,计算机科学中提出了两个概念来简化解决这个问题的方法——futures 和 promises。虽然这些概念在各种技术中经常可以互换使用,在 C++中它们有特定的含义:

  • 一个 future 可以从提供者那里检索一个值,同时进行同步处理

  • promise 存储了一个未来的值,并提供了一个同步点

由于它的性质,future对象在 C++中有一些限制。它不能被复制,只能被移动,并且只有在与共享状态相关联时才有效。这意味着我们只能通过调用asyncpromise.get_future()packaged_task.get_future()来创建一个有效的 future 对象。

值得一提的是,promises 和 futures 在它们的实现中使用了线程库;因此,您可能需要添加对另一个库的依赖。在我的系统(Ubuntu 18.04,64 位)上,使用 g++编译时,我不得不添加一个对pthread库的链接依赖;如果您在 mingw 或 cygwin 配置上使用 g++,我希望您也需要相同的依赖。

让我们首先看看如何在 C++中同时使用futurepromise。首先,我们将为一个秘密消息创建一个promise

    promise<string> secretMessagePromise;

接下来,让我们创建一个future并使用它启动一个新的线程。线程将使用一个 lambda 函数简单地打印出秘密消息:

    future<string> secretMessageFuture = 
        secretMessagePromise.get_future();
    thread isPrimeThread(printSecretMessage, ref(secretMessageFuture));

注意我们需要避免复制future;在这种情况下,我们使用一个对future的引用包装器。

现在我们暂时只讨论这个线程;下一步是实现承诺,也就是设置一个值:

    secretMessagePromise.set_value("It's a secret");
    isPrimeThread.join();

与此同时,另一个线程将做一些事情,然后要求我们信守诺言。嗯,不完全是;它将要求promise的值,这将阻塞它,直到调用join()

auto printSecretMessage = [](future<string>& secretMessageFuture) {
    string secretMessage = secretMessageFuture.get();
    cout << "The secret message: " << secretMessage << '\n';
};

正如您可能注意到的,这种方法将计算值的责任放在了主线程上。如果我们希望它在辅助线程上完成呢?我们只需要使用async

假设我们想要检查一个数字是否是质数。我们首先编写一个 lambda 函数,以一种天真的方式检查这一点,对2x-1之间的每个可能的除数进行检查,并检查x是否可以被它整除。如果它不能被任何值整除,那么它是一个质数:

auto is_prime = [](int x) {
    auto xIsDivisibleBy = bind(isDivisibleBy, x, _1);
    return none_of_collection(
            rangeFrom2To(x - 1), 
            xIsDivisibleBy
        );
};

使用了一些辅助的 lambda 函数。一个用于生成这样的范围:

auto rangeFromTo = [](const int start, const int end){
    vector<int> aVector(end);
    iota(aVector.begin(), aVector.end(), start);
    return aVector;
};

这是专门用于生成以2开头的范围:

auto rangeFrom2To = bind(rangeFromTo, 2, _1);

然后,一个检查两个数字是否可被整除的谓词:

auto isDivisibleBy = [](auto value, auto factor){
    return value % factor == 0;
};

要在主线程之外的一个单独线程中运行这个函数,我们需要使用async声明一个future

    future<bool> futureIsPrime(async(is_prime, 2597));

async的第二个参数是我们函数的输入参数。允许多个参数。

然后,我们可以做其他事情,最后,要求结果:

TEST_CASE("Future with async"){
    future<bool> futureIsPrime(async(is_prime, 7757));
    cout << "doing stuff ..." << endl;
 bool result = futureIsPrime.get();

    CHECK(result);
}

粗体代码行标志着主线程停止等待来自辅助线程的结果的点。

如果您需要多个future,您可以使用它们。在下面的示例中,我们将使用四个不同的值在四个不同的线程中运行is_prime,如下所示:

TEST_CASE("more futures"){
    future<bool> future1(async(is_prime, 2));
    future<bool> future2(async(is_prime, 27));
    future<bool> future3(async(is_prime, 1977));
    future<bool> future4(async(is_prime, 7757));

    CHECK(future1.get());
    CHECK(!future2.get());
    CHECK(!future3.get());
    CHECK(future4.get());
}

功能性异步代码

我们已经看到,线程的最简单实现是一个 lambda,但我们可以做得更多。最后一个示例使用多个线程异步地在不同的值上运行相同的操作,可以转换为一个功能高阶函数。

但让我们从一些简单的循环开始。首先,我们将输入值和预期结果转换为向量:

    vector<int> values{2, 27, 1977, 7757};
    vector<bool> expectedResults{true, false, false, true};

然后,我们需要一个for循环来创建 futures。重要的是不要调用future()构造函数,因为这样做会由于尝试将新构造的future对象复制到容器中而失败。相反,将async()的结果直接添加到容器中:

    vector<future<bool>> futures;
    for(auto value : values){
        futures.push_back(async(is_prime, value));
    }

然后,我们需要从线程中获取结果。再次,我们需要避免复制future,因此在迭代时将使用引用:

    vector<bool> results;
    for(auto& future : futures){
        results.push_back(future.get());
    }

让我们来看看整个测试:

TEST_CASE("more futures with loops"){
    vector<int> values{2, 27, 1977, 7757};
    vector<bool> expectedResults{true, false, false, true};

    vector<future<bool>> futures;
    for(auto value : values){
        futures.push_back(async(is_prime, value));
    }

    vector<bool> results;
    for(auto& future : futures){
        results.push_back(future.get());
    }

    CHECK_EQ(results, expectedResults);
}

很明显,我们可以将这些转换成几个 transform 调用。然而,我们需要特别注意避免复制 futures。首先,我创建了一个帮助创建future的 lambda:

    auto makeFuture = [](auto value){
        return async(is_prime, value);
    };

第一个for循环然后变成了一个transformAll调用:

    vector<future<bool>> futures = transformAll<vector<future<bool>>>
       (values, makeFuture);

第二部分比预期的要棘手。我们的transformAll的实现不起作用,所以我将内联调用transform

    vector<bool> results(values.size());
    transform(futures.begin(), futures.end(), results.begin(), []
        (future<bool>& future){ return future.get();});

我们最终得到了以下通过的测试:

TEST_CASE("more futures functional"){
    vector<int> values{2, 27, 1977, 7757};

    auto makeFuture = [](auto value){
        return async(is_prime, value);
    };

    vector<future<bool>> futures = transformAll<vector<future<bool>>>
        (values, makeFuture);
    vector<bool> results(values.size());
    transform(futures.begin(), futures.end(), results.begin(), []
        (future<bool>& future){ return future.get();});

    vector<bool> expectedResults{true, false, false, true};

    CHECK_EQ(results, expectedResults);
}

我必须对你诚实,这是迄今为止实现起来最困难的代码。在处理 futures 时,有很多事情可能会出错,而且原因并不明显。错误消息相当没有帮助,至少对于我的 g++版本来说是这样。我成功让它工作的唯一方法是一步一步地进行,就像我在本节中向你展示的那样。

然而,这个代码示例展示了一个重要的事实;通过深思熟虑和测试使用 futures,我们可以并行化高阶函数。因此,如果您需要更好的性能,可以使用多个核心,并且不能等待标准中并行运行策略的实现,这是一个可能的解决方案。即使只是为了这一点,我认为我的努力是有用的!

由于我们正在谈论异步调用,我们也可以快速浏览一下响应式编程的世界。

响应式编程的一点体验

响应式编程是一种编写代码的范式,专注于处理数据流。想象一下需要分析一系列温度值的数据流,来自安装在自动驾驶汽车上的传感器的值,或者特定公司的股票值。在响应式编程中,我们接收这个连续的数据流并运行分析它的函数。由于新数据可能会不可预测地出现在流中,编程模型必须是异步的;也就是说,主线程不断等待新数据,当数据到达时,处理被委托给次要流。结果通常也是异步收集的——要么推送到用户界面,保存在数据存储中,要么传递给其他数据流。

我们已经看到,函数式编程的主要重点是数据。因此,函数式编程是处理实时数据流的良好选择并不足为奇。高阶函数的可组合性,如mapreducefilter,以及并行处理的机会,使得函数式设计风格成为响应式编程的一个很好的解决方案。

我们不会详细讨论响应式编程。通常使用特定的库或框架来简化这种数据流处理的实现,但是根据我们目前拥有的元素,我们可以编写一个小规模的示例。

我们需要几样东西。首先,一个数据流;其次,一个接收数据并立即将其传递到处理管道的主线程;第三,一种获取输出的方式。

对于本例的目标,我将简单地使用标准输入作为输入流。我们将从键盘输入数字,并以响应式的方式检查它们是否是质数,从而始终保持主线程的响应。这意味着我们将使用async函数为我们从键盘读取的每个数字创建一个future。输出将简单地写入输出流。

我们将使用与之前相同的is_prime函数,但添加另一个函数,它将打印到标准输出该值是否是质数。

auto printIsPrime = [](int value){
    cout << value << (is_prime(value) ? " is prime" : " is not prime")  
    << endl;
};

main函数是一个无限循环,它从输入流中读取数据,并在每次输入新值时启动一个future

int main(){
    int number;

    while(true){
        cin >> number;
        async(printIsPrime, number);
    }
}

使用一些随机输入值运行此代码会产生以下输出:

23423
23423 is not prime
453576
453576 is not prime
53
53 is prime
2537
2537 is not prime
364544366
5347
54
534532
436
364544366 is not prime
5347 is prime
54 is not prime
534532 is not prime
436 is not prime

正如你所看到的,结果会尽快返回,但程序允许随时引入新数据。

我必须提到,为了避免每次编译本章的代码时都出现无限循环,响应式示例可以通过make reactive编译和运行。你需要用中断来停止它,因为它是一个无限循环。

这是一个基本的响应式编程示例。显然,它可以随着数据量的增加、复杂的流水线和每个流水线的并行化等变得更加复杂。然而,我们已经实现了本节的目标——让你了解响应式编程以及我们如何使用函数构造和异步调用使其工作。

我们已经讨论了如何优化执行时间,看了各种帮助我们实现更快性能的方法。现在是时候看一个情况,我们想要减少程序的内存使用。

优化内存使用

到目前为止,我们讨论的用于以函数方式构造代码的方法涉及多次通过被视为不可变的集合。因此,这可能会导致集合的复制。例如,让我们看一个简单的代码示例,它使用transform来增加向量的所有元素:

template<typename DestinationType>
auto transformAll = [](const auto source, auto lambda){
    DestinationType result;
    transform(source.begin(), source.end(), back_inserter(result), 
        lambda);
    return result;
};

TEST_CASE("Memory"){
    vector<long long> manyNumbers(size);
    fill_n(manyNumbers.begin(), size, 1000L);

    auto result = transformAll<vector<long long>>(manyNumbers, 
        increment);

    CHECK_EQ(result[0], 1001);
}

这种实现会导致大量的内存分配。首先,manyNumbers向量被复制到transformAll中。然后,result.push_back()会自动调用,可能导致内存分配。最后,result被返回,但初始的manyNumbers向量仍然被分配。

我们可以立即改进其中一些问题,但讨论它们与其他可能的优化方法的比较也是值得的。

为了进行测试,我们需要处理大量的集合,并找到一种测量进程内存分配的方法。第一部分很容易——只需分配大量 64 位值(在我的编译器上是长长类型);足够分配 1GB 的 RAM:

const long size_1GB_64Bits = 125000000;
TEST_CASE("Memory"){
    auto size = size_1GB_64Bits;
    vector<long long> manyNumbers(size);
    fill_n(manyNumbers.begin(), size, 1000L);

    auto result = transformAll<vector<long long>>(manyNumbers, 
        increment);

    CHECK_EQ(result[0], 1001);
}

第二部分有点困难。幸运的是,在我的 Ubuntu 18.04 系统上,我可以在/proc/PID/status文件中监视进程的内存,其中 PID 是进程标识符。通过一些 Bash 魔法,我可以创建一个makefile配方,将每 0.1 秒获取的内存值输出到一个文件中,就像这样:

memoryConsumptionNoMoveIterator: .outputFolder 
    g++ -DNO_MOVE_ITERATOR -std=c++17 memoryOptimization.cpp -Wall -
        Wextra -Werror -o out/memoryOptimization
    ./runWithMemoryConsumptionMonitoring memoryNoMoveIterator.log

你会注意到-DNO_MOVE_ITERATOR参数;这是一个编译指令,允许我为不同的目标编译相同的文件,以检查多个解决方案的内存占用。这意味着我们之前的测试是在#if NO_MOVE_ITERATOR指令内编写的。

只有一个注意事项——因为我使用了 bash watch命令来生成输出,你需要在运行make memoryConsumptionNoMoveIterator后按下一个键,以及对每个其他内存日志配方也是如此。

有了这个设置,让我们改进transformAll以减少内存使用,并查看输出。我们需要使用引用类型,并从一开始就为结果分配内存,如下所示:

template<typename DestinationType>
auto transformAll = [](const auto& source, auto lambda){
    DestinationType result;
    result.resize(source.size());
    transform(source.begin(), source.end(), result.begin(), lambda);
    return result;
};

预期的结果是,改进的结果是最大分配从 0.99 GB 开始,但跳到 1.96 GB,大致翻了一番。

我们需要将这个值放在上下文中。让我们先测量一下一个简单的for循环能做什么,并将结果与使用transform实现的相同算法进行比较。

测量简单 for 循环的内存

使用for循环的解决方案非常简单:

TEST_CASE("Memory"){
    auto size = size_1GB_64Bits;
    vector<long long> manyNumbers(size);
    fill_n(manyNumbers.begin(), size, 1000L);

    for(auto iter = manyNumbers.begin(); iter != manyNumbers.end(); 
        ++iter){
            ++(*iter);
    };

    CHECK_EQ(manyNumbers[0], 1001);
}

在测量内存时,没有什么意外——整个过程中占用的内存保持在 0.99 GB。我们能用transform也实现这个结果吗?嗯,有一个版本的transform可以就地修改集合。让我们来测试一下。

测量就地 transform 的内存

要就地使用transform,我们需要提供目标迭代器参数source.begin(),如下所示:

auto increment = [](const auto value){
    return value + 1;
};

auto transformAllInPlace = [](auto& source, auto lambda){
    transform(source.begin(), source.end(), source.begin(), lambda);
};

TEST_CASE("Memory"){
    auto size = size_1GB_64Bits;
    vector<long long> manyNumbers(size);
    fill_n(manyNumbers.begin(), size, 1000L);

    transformAllInPlace(manyNumbers, increment);

    CHECK_EQ(manyNumbers[0], 1001);
}

根据文档,这应该在同一集合中进行更改;因此,它不应该分配更多的内存。如预期的那样,它具有与简单的for循环相同的行为,内存占用在整个程序运行期间保持在 0.99 GB。

然而,您可能会注意到我们现在不返回值以避免复制。我喜欢返回值,但我们还有另一个选择,使用移动语义:

template<typename SourceType>
auto transformAllInPlace = [](auto& source, auto lambda) -> SourceType&& {
    transform(source.begin(), source.end(), source.begin(), lambda);
    return move(source);
};

为了使调用编译通过,我们需要在调用transformAllInPlace时传递源的类型,因此我们的测试变成了:

TEST_CASE("Memory"){
    auto size = size_1GB_64Bits;
    vector<long long> manyNumbers(size);
    fill_n(manyNumbers.begin(), size, 1000L);

    auto result = transformAllInPlace<vector<long long>>(manyNumbers, 
        increment);

    CHECK_EQ(result[0], 1001);
}

让我们测量一下移动语义是否有所帮助。结果如预期;内存占用在整个运行时保持在 0.99 GB。

这引出了一个有趣的想法。如果我们在调用transform时使用移动语义呢?

使用移动迭代器进行 transform

我们可以将我们的transform函数重写为使用移动迭代器,如下所示:

template<typename DestinationType>
auto transformAllWithMoveIterator = [](auto& source, auto lambda){
    DestinationType result(source.size());
    transform(make_move_iterator(source.begin()), 
        make_move_iterator(source.end()), result.begin(), lambda);
    source.clear();
    return result;
};

理论上,这应该是将值移动到目标而不是复制它们,从而保持内存占用低。为了测试一下,我们运行相同的测试并记录内存:

TEST_CASE("Memory"){
    auto size = size_1GB_64Bits;
    vector<long long> manyNumbers(size);
    fill_n(manyNumbers.begin(), size, 1000L);

    auto result = transformAllWithMoveIterator<vector<long long>>
        (manyNumbers, increment);

    CHECK_EQ(result[0], 1001);
}

结果出乎意料;内存从 0.99 GB 开始上升到 1.96 GB(可能是在transform调用之后),然后又回到 0.99 GB(很可能是source.clear()的结果)。我尝试了多种变体来避免这种行为,但找不到保持内存占用在 0.99 GB 的解决方案。这似乎是移动迭代器实现的问题;我建议您在您的编译器上测试一下它是否有效。

比较解决方案

使用就地或移动语义的解决方案,虽然减少了内存占用,但只有在不需要源数据进行其他计算时才有效。如果您计划重用数据进行其他计算,那么保留初始集合是不可避免的。此外,不清楚这些调用是否可以并行运行;由于 g++尚未实现并行执行策略,我无法测试它们,因此我将把这个问题留给读者作为练习。

但是函数式编程语言为了减少内存占用做了什么呢?答案非常有趣。

不可变数据结构

纯函数式编程语言使用不可变数据结构和垃圾回收的组合。修改数据结构的每次调用都会创建一个似乎是初始数据结构的副本,只有一个元素被改变。初始结构不会受到任何影响。然而,这是使用指针来完成的;基本上,新的数据结构与初始数据结构相同,只是有一个指向改变值的指针。当丢弃初始集合时,旧值不再被使用,垃圾收集器会自动将其从内存中删除。

这种机制充分利用了不可变性,允许了 C++无法实现的优化。此外,实现通常是递归的,这也利用了尾递归优化。

然而,可以在 C++中实现这样的数据结构。一个例子是一个名为immer的库,你可以在 GitHub 上找到它,网址是github.com/arximboldi/immer。Immer 实现了许多不可变的集合。我们将看看immer::vector;每当我们调用通常会修改向量的操作(比如push_back)时,immer::vector会返回一个新的集合。每个返回的值都可以是常量,因为它永远不会改变。我在本章的代码中使用 immer 0.5.0 编写了一个小测试,展示了immer::vector的用法,你可以在下面的代码中看到:

TEST_CASE("Check immutable vector"){
    const auto empty = immer::vector<int>{};
    const auto withOneElement = empty.push_back(42);

    CHECK_EQ(0, empty.size());
    CHECK_EQ(1, withOneElement.size());
    CHECK_EQ(42, withOneElement[0]);
}

我不会详细介绍不可变数据结构;但是,我强烈建议你查看immer网站上的文档(sinusoid.es/immer/introduction.html)并尝试使用该库。

总结

我们已经看到,性能优化是一个复杂的话题。作为 C++程序员,我们需要从我们的代码中获得更多的性能;本章中我们提出的问题是:是否可能优化以函数式风格编写的代码?

答案是——是的,如果你进行了测量,并且有一个明确的目标。我们需要特定的计算更快完成吗?我们需要减少内存占用吗?应用程序的哪个领域需要最大程度的性能改进?我们想要进行怪异的点优化吗,这可能需要在下一个编译器、库或平台版本中进行重写?这些都是你在优化代码之前需要回答的问题。

然而,我们已经看到,当涉及到利用计算机上的所有核心时,函数式编程有巨大的好处。虽然我们正在等待高阶函数的标准实现并行执行,但我们可以通过编写自己的并行算法来利用不可变性。递归是函数式编程的另一个基本特征,每当使用它时,我们都可以利用尾递归优化。

至于内存消耗,实现在第三方库中的不可变数据结构,以及根据目标谨慎优化我们使用的高阶函数,都可以帮助我们保持代码的简单性,而复杂性发生在代码的特定位置。当我们丢弃源集合时,可以使用移动语义,但记得检查它是否适用于并行执行。

最重要的是,我希望你已经了解到,测量是性能优化中最重要的部分。毕竟,如果你不知道自己在哪里,也不知道自己需要去哪里,你怎么能进行旅行呢?

我们将继续通过利用数据生成器来进行测试来继续我们的函数式编程之旅。现在是时候看看基于属性的测试了。

第十一章:基于属性的测试

我们已经看到纯函数有一个重要的属性——它们对于相同的输入返回相同的输出。我们也看到这个属性使我们能够轻松地为纯函数编写基于示例的单元测试。此外,我们可以编写数据驱动的测试,允许一个测试函数被多个输入和输出重复使用。

事实证明,我们甚至可以做得更好。除了编写许多行的数据驱动测试之外,我们还可以利用纯函数的数学属性。这种技术是由函数式编程启用的数据生成器所实现的。这些测试被误导地称为基于属性的测试;您必须记住,这个名称来自纯函数的数学属性,而不是来自类或对象中实现的属性。

本章将涵盖以下主题:

  • 理解基于属性的测试的概念

  • 如何编写生成器并利用它们

  • 如何从基于示例的测试转向基于属性的测试

  • 如何编写良好的属性

技术要求

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

代码可以在 GitHub 上找到,网址为https:/​/​github.​com/​PacktPublishing/​Hands-​On-​Functional-Programming-​with-​Cpp,位于Chapter11文件夹中。它包括并使用了doctest,这是一个单头开源单元测试库。您可以在其 GitHub 存储库上找到它,网址为https:/​/github.com/​onqtam/​doctest

基于属性的测试

单元测试是一种非常有用的软件开发技术。一套良好的单元测试可以做到以下几点:

  • 通过自动化回归测试的繁琐部分来加快部署速度。

  • 使专业测试人员能够发现隐藏的问题,而不是一遍又一遍地运行相同的测试计划。

  • 在开发过程的早期消除错误,从而减少查找和修复错误的成本。

  • 通过提供反馈来改进软件设计,作为代码结构的第一个客户端(如果测试复杂,很可能您的设计也很复杂),只要开发人员知道如何看到和解释反馈。

  • 增加对代码的信任,从而允许更多的更改,从而促进加速开发或消除代码中的风险。

我喜欢编写单元测试。我喜欢找出有趣的测试用例,我喜欢使用测试驱动我的代码——正如您在第九章中所看到的,函数式编程的测试驱动开发。与此同时,我一直在寻找更好的编写测试的方法,因为如果我们能加快这个过程,那将是很棒的。

我们已经在第九章中看到,纯函数使我们更容易识别测试用例,因为根据定义,它们的输出是受限制的。事实证明,如果我们涉足与这些纯函数相关的数学属性领域,我们可以走得更远。

如果您已经写了一段时间的单元测试,您可能会觉得其中一些测试有点多余。如果我们能够编写这样的测试——对于一定范围内的输入,预期输出必须具有某种属性,那将是很好的。事实证明,借助数据生成器和一点抽象思维,我们可以做到这一点。

让我们比较一下方法。

基于示例的测试与基于属性的测试

让我们以power函数为例:

function<int(int, int)> power = [](auto first, auto second){
    return pow(first, second);
};

如何使用基于示例的测试来测试它?我们需要找出一些有趣的值作为第一个和第二个,并将它们组合。对于这个练习的目标,我们将限制自己只使用正整数。一般来说,整数的有趣值是01,很多,和最大值。这导致了以下可能的情况:

  • 0⁰ -> 未定义(在 C++的 pow 实现中,除非启用了特定错误,否则返回1

  • 0^(0 到 max 之间的任何整数) -> 0

  • 1^(任何整数) -> 1

  • (除了 0 之外的任何整数)⁰ -> 1

  • 2² -> 4

  • 2^(不会溢出的最大整数) -> 要计算的值

  • 10⁵ -> 100000

  • 10^(不会溢出的最大整数) -> 要计算的值

这个清单当然并不完整,但它展示了对问题的有趣分析。因此,让我们写下这些测试:

TEST_CASE("Power"){
    int maxInt = numeric_limits<int>::max();
    CHECK_EQ(1, power(0, 0));
    CHECK_EQ(0, power(0, 1));
    CHECK_EQ(0, power(0, maxInt));
    CHECK_EQ(1, power(1, 1));
    CHECK_EQ(1, power(1, 2));
    CHECK_EQ(1, power(1, maxInt));
    CHECK_EQ(1, power(2, 0));
    CHECK_EQ(2, power(2, 1));
    CHECK_EQ(4, power(2, 2));
    CHECK_EQ(maxInt, power(2, 31) - 1);
    CHECK_EQ(1, power(3, 0));
    CHECK_EQ(3, power(3, 1));
    CHECK_EQ(9, power(3, 2));
    CHECK_EQ(1, power(maxInt, 0));
    CHECK_EQ(maxInt, power(maxInt, 1));
}

这显然不是我们需要检查以确保幂函数有效的所有测试的完整清单,但这是一个很好的开始。看着这个清单,我在想,你认为——你会写更多还是更少的测试?我肯定想写更多,但在这个过程中我失去了动力。当然,其中一个问题是我是在编写代码之后才写这些测试;我更有动力的是在编写代码的同时编写测试,就像测试驱动开发TDD)一样。但也许有更好的方法?

让我们换个角度思考一下。有没有一些我们可以测试的属性,适用于一些或所有的预期输出?让我们写一个清单:

  • 0⁰ -> 未定义(在 C++的 pow 函数中默认为 1)

  • 0^([1 .. maxInt]) -> 0

  • 值:[1 .. maxInt]⁰ -> 1

  • 值:[0 .. maxInt]¹ -> 值

这些是一些明显的属性。然而,它们只涵盖了一小部分值。我们仍然需要涵盖x**^y的一般情况,其中xy都不是01。我们能找到任何属性吗?好吧,想想整数幂的数学定义——它是重复的乘法。因此,我们可以推断,对于大于1的任何xy值,以下成立:

我们在这里有一个边界问题,因为计算可能会溢出。因此,需要选择xy的值,使x^y小于maxInt。解决这个问题的一种方法是首先选择x,然后选择yy=2maxy=floor(log[x]maxInt)之间。为了尽可能接近边界,我们应该始终选择maxy作为一个值。要检查溢出情况,我们只需要测试xmaxy + 1次方是否溢出。

前面的方法当然意味着我们信任标准库中对数函数的结果。如果你的“测试者偏执狂”比我更大,我建议使用经过验证的对数表,包括从2maxInt和值maxInt的所有基数。然而,我会使用 STL 对数函数。

现在我们有了幂函数的数学属性清单。但我们想要像之前看到的那样,使用区间来实现它们。我们能做到吗?这就是数据生成器的作用。

生成器

生成器是函数式编程语言的一个重要特性。它们通常通过 lambda 和惰性求值的组合来实现,允许编写以下代码:

// pseudocode
vector<int> values = generate(1, maxInt, [](){/*generatorCode*/}).pick(100)

生成器函数通常会生成无限数量的值,但由于它是惰性求值的,只有在调用pick时,这100个值才会实现。

C++目前还没有标准支持惰性求值和数据生成器,因此我们必须实现自己的生成器。值得注意的是,C++ 20 已经采纳了在标准中包含了令人敬畏的 ranges 库,该库可以实现这两个功能。对于本章的目标,我们将坚持使用今天可用的标准,但你将在本书的最后几章中找到 ranges 库的基本用法。

首先,我们如何生成数据?STL 为我们提供了一种生成均匀分布的随机整数的好方法,使用uniform_int_distribution类。让我们先看看代码;我已经添加了注释来解释发生了什么:

auto generate_ints = [](const int min, const int max){
    random_device rd; // use for generating the seed
    mt19937 generator(rd()); // used for generating pseudo-random 
        numbers
    uniform_int_distribution<int> distribution(min, max); // used to 
        generate uniformly distributed numbers between min and max
    auto values = transformAll<vector<int>>(range(0, 98), // generates 
        the range [0..98]
            &distribution, &generator{
                return distribution(generator); // generate the random 
                    numbers
            });
    values.push_back(min); // ensure that min and max values are 
        included
    values.push_back(max);
    return values;
};

这个函数将从minmax生成均匀分布的数字。我倾向于始终包括区间的边缘,因为这些对于测试来说总是有趣的值。

我们还使用了一个名为range的函数,您还没有看到。它的目标是用minValuemaxValue的值填充一个向量,以便进行简单的转换。在这里:

auto range = [](const int minValue, const int maxValue){
    vector<int> range(maxValue - minValue + 1);
    iota(range.begin(), range.end(), minValue);
    return range;
};

值得注意的是,在函数式编程语言中,范围通常是惰性求值的,这大大减少了它们的内存占用。不过,对于我们的示例目标来说,这也很好用。

先前的generator函数允许我们为我们的测试创建输入数据,这些数据在 1 和最大整数值之间均匀分布。它只需要一个简单的绑定:

auto generate_ints_greater_than_1 = bind(generate_ints, 1, numeric_limits<int>::max());

让我们将其用于我们的属性测试。

将属性放到测试中

让我们再次看看我们想要检查的属性列表:

  • 0⁰ -> 未定义(在 C++的 pow 函数中默认为 1)

  • 0^([1 .. maxInt]) -> 0

  • 值:[1 .. maxInt]⁰ -> 1

  • 值:[0 .. maxInt]¹ -> 值

  • x^y = x^(y-1) * x

现在我们将依次实现每个属性。对于每个属性,我们将使用基于示例的测试或受generate_ints_greater_than_1函数启发的数据生成器。让我们从最简单的属性开始——0⁰应该是未定义的——或者实际上是其标准实现中的1

属性:00 -> 未定义

第一个问题使用基于示例的测试非常容易实现。出于一致性考虑,我们将其提取到一个函数中:

auto property_0_to_power_0_is_1 = [](){
    return power(0, 0) == 1;
};

在我们的测试中,我们还将编写属性的描述,以便获得信息丰富的输出:

TEST_CASE("Properties"){
    cout << "Property: 0 to power 0 is 1" << endl;
    CHECK(property_0_to_power_0_is_1);
 }

当运行时,会产生以下输出,通过测试:

g++ -std=c++17 propertyBasedTests.cpp -o out/propertyBasedTests
./out/propertyBasedTests
[doctest] doctest version is "2.0.1"
[doctest] run with "--help" for options
Property: 0 to power 0 is 1
===============================================================================
[doctest] test cases:      1 |      1 passed |      0 failed |      0 skipped
[doctest] assertions:      1 |      1 passed |      0 failed |
[doctest] Status: SUCCESS!

这很容易!我们现在有了一个基本的属性测试结构。下一个测试将需要一个数据生成器,但我们已经有了。让我们看看它如何适用于0属性到任何幂,除了0等于0

属性:0[1 .. maxInt] -> 0

我们需要我们的数字生成器从1maxInt,这已经实现了。然后我们需要一个属性函数,检查对于从1maxInt的任何指数,0的指数等于0。代码编写起来相当容易:

auto prop_0_to_any_nonzero_int_is_0= [](const int exponent){
    CHECK(exponent > 0); // checking the contract just to be sure
    return power(0, exponent) == 0;
};

接下来,我们需要检查这个属性。由于我们有一个生成的值列表,我们可以使用all_of函数来检查所有这些值是否符合属性。为了使事情更加信息丰富,我决定显示我们正在使用的值列表:

auto printGeneratedValues = [](const string& generatorName, const auto& 
    values){
        cout << "Check generator " << generatorName << endl;
        for_each(values.begin(), values.end(), [](auto value) { cout << 
            value << ", ";});
        cout << endl;
 };

auto check_property = [](const auto& generator, const auto& property, const string& generatorName){
    auto values = generator();
    printGeneratedValues(generatorName, values);
    CHECK(all_of_collection(values, property));
};

最后,我们可以编写我们的测试。我们将再次在测试之前显示属性名称:

TEST_CASE("Properties"){
    cout << "Property: 0 to power 0 is 1" << endl;
    CHECK(property_0_to_power_0_is_1);

    cout << "Property: 0 to [1..maxInt] is 0" << endl;
    check_property(generate_ints_greater_than_1,  
        prop_0_to_any_nonzero_int_is_0, "generate ints");
}

运行测试会产生以下输出:

Property: 0 to power 0 is 1
Property: 0 to [1..maxInt] is 0
Check generator generate ints
1073496375, 263661517, 1090774655, 590994005, 168796979, 1988143371, 1411998804, 1276384966, 252406124, 111200955, 775255151, 1669887756, 1426286501, 1264685577, 1409478643, 944131269, 1688339800, 192256171, 1406363728, 1624573054, 2654328, 1025851283, 1113062216, 1099035394, 624703362, 1523770105, 1243308926, 104279226, 1330992269, 1964576789, 789398651, 453897783, 1041935696, 561917028, 1379973023, 643316376, 1983422999, 1559294692, 2097139875, 384327588, 867142643, 1394240860, 2137873266, 2103542389, 1385608621, 2058924659, 1092474161, 1071910908, 1041001035, 582615293, 1911217125, 1383545491, 410712068, 1161330888, 1939114509, 1395243657, 427165959, 28574042, 1391025789, 224683120, 1222884936, 523039771, 1539230457, 2114587312, 2069325876, 166181790, 1504124934, 1817094271, 328329837, 442231460, 2123558414, 411757963, 1883062671, 1529993763, 1645210705, 866071861, 305821973, 1015936684, 2081548159, 1216448456, 2032167679, 351064479, 1818390045, 858994762, 2073835547, 755252854, 2010595753, 1882881401, 741339006, 1080861523, 1845108795, 362033992, 680848942, 728181713, 1252227588, 125901168, 1212171311, 2110298117, 946911655, 1, 2147483647, 
===============================================================================
[doctest] test cases:      1 |      1 passed |      0 failed |      0 skipped
[doctest] assertions:    103 |    103 passed |      0 failed |
[doctest] Status: SUCCESS!

正如您所看到的,一堆随机值被用于测试,最后两个值是1maxInt

现在是时候停下来思考一分钟了。这些测试是不寻常的。单元测试的一个关键思想是进行可重复的测试,但在这里,我们有一堆随机值。这些算不算?当一个值导致失败时我们该怎么办?

这些都是很好的问题!首先,使用基于属性的测试并不排除基于示例的测试。实际上,我们现在正在混合使用这两种——0⁰是一个示例,而不是一个属性。因此,在有意义时,不要犹豫检查任何特定值。

其次,支持属性测试的库允许收集特定失败值并自动重新测试这些值。很简单——每当有失败时,将值保存在某个地方,并在下次运行测试时包含它们。这不仅可以让您进行更彻底的测试,还可以发现代码的行为。

因此,我们必须将基于示例的测试和基于属性的测试视为互补的技术。第一个帮助您使用测试驱动开发TDD)来驱动代码,并检查有趣的案例。第二个允许您找到您尚未考虑的案例,并重新测试相同的错误。两者都有用,只是方式不同。

让我们回到编写我们的属性。接下来的一个属性是任何数的零次幂等于1

属性:value: [1 .. maxInt]0 -> 1

我们已经准备就绪,我们只需要写下来:

auto prop_anyIntToPower0Is1 = [](const int base){
    CHECK(base > 0);
    return power(base, 0) == 1;
};

测试变成了以下内容:

TEST_CASE("Properties"){
    cout << "Property: 0 to power 0 is 1" << endl;
    CHECK(property_0_to_power_0_is_1);

    cout << "Property: 0 to [1..maxInt] is 0" << endl;
    check_property(generate_ints_greater_than_1, 
        prop_0_to_any_nonzero_int_is_0, "generate ints");

    cout << "Property: any int to power 0 is 1" << endl;
    check_property(generate_ints_greater_than_1, 
        prop_anyIntToPower0Is1, "generate ints");
}

运行测试会得到以下输出(为简洁起见,省略了几行):

Property: 0 to power 0 is 1
Check generator generate ints
1673741664, 1132665648, 342304077, 936735303, 917238554, 1081591838, 743969276, 1981329112, 127389617, 
...
 1, 2147483647, 
Property: any int to power 0 is 1
Check generator generate ints
736268029, 1304281720, 416541658, 2060514167, 1695305196, 1479818034, 699224013, 1309218505, 302388654, 765083344, 430385474, 648548788, 1986457895, 794974983, 1797109305, 1131764785, 1221836230, 802640954,
...
1543181200, 1, 2147483647, 
===============================================================================
[doctest] test cases:      1 |      1 passed |      0 failed |      0 skipped
[doctest] assertions:    205 |    205 passed |      0 failed |
[doctest] Status: SUCCESS!

从前面的示例中可以看出,这些数字确实是随机的,同时始终包括1maxInt

我们已经掌握了这个!下一个属性是任何值的 1 次幂就是这个值。

属性:value: [0 .. maxInt]1 -> value

我们需要另一个生成方法,从0开始。我们只需要再次使用 bind 魔术来获得所需的结果:

auto generate_ints_greater_than_0 = bind(generate_ints, 0, numeric_limits<int>::max());

这个属性写起来很容易:

auto prop_any_int_to_power_1_is_the_value = [](const int base){
    return power(base, 1) == base;
};

测试很明显:

TEST_CASE("Properties"){
    cout << "Property: 0 to power 0 is 1" << endl;
    CHECK(property_0_to_power_0_is_1);

    cout << "Property: 0 to any non-zero power is 0" << endl;
    check_property(generate_ints_greater_than_1, 
        prop_0_to_any_nonzero_int_is_0, "generate ints");

    cout << "Property: any int to power 0 is 1" << endl;
    check_property(generate_ints_greater_than_1, 
        prop_anyIntToPower0Is1, "generate ints");

    cout << "Property: any int to power 1 is the value" << endl;
    check_property(generate_ints_greater_than_0, 
        prop_any_int_to_power_1_is_the_value, "generate ints");
}

再次运行测试,结果再次通过。

让我们再次反思一下:

  • 我们要检查多少个值?答案是301

  • 测试代码有多少行?测试代码只有 23 行代码,而我们用于测试的函数大约有 40 行代码。

这不是很神奇吗?这不是对你的测试值得投资吗?

我们知道如何做到这一点。是时候来看我们的练习中最复杂的属性了——任何数的 y 次幂等于 y-1 次幂乘以这个数。

属性:xy = xy-1 * x

这将要求我们生成两组值,xy,以便x^y < maxInt。我花了一些时间与数据生成器一起摸索,但我发现任何大于x只能测试y=1。因此,我将使用两个生成器;第一个将生成2之间的数字,而第二个将生成大于且小于maxInt的数字:

auto generate_ints_greater_than_2_less_sqrt_maxInt = bind(generate_ints, 2, sqrt(numeric_limits<int>::max()));

属性的第一部分变成了以下内容:

cout << "Property: next power of x is previous power of x multiplied by  
    x" << endl;
check_property(generate_ints_greater_than_2_less_sqrt_maxInt, 
    prop_nextPowerOfXIsPreviousPowerOfXMultipliedByX, "generate greater 
        than 2 and less than sqrt of maxInt");

为了实现属性,我们还需要生成x基数的指数,这样我们就可以将属性写成如下形式:

auto prop_nextPowerOfXIsPreviousPowerOfXMultipliedByX = [](const int x){
    auto exponents = bind(generate_exponent_less_than_log_maxInt, x);
    return check_property(exponents, x{ return power(x, y) ==  
      power(x, y - 1) * x;}, "generate exponents for " + to_string(x));
};

从生成函数的名称中可以看出,我们需要生成在1log[x]maxInt之间的数字。超过这个值的任何数字在计算 x^y 时都会溢出。由于 STL 中没有通用对数函数,我们需要实现一个。为了计算log[x]maxInt,我们只需要使用一个数学等式:

auto logMaxIntBaseX = [](const int x) -> int{
    auto maxInt = numeric_limits<int>::max() ;
    return floor(log(maxInt) / log(x));
};

我们的生成函数变成了以下内容:

auto generate_exponent_less_than_log_maxInt = [](const int x){
    return generate_ints(1, logMaxIntBaseX(x));
};

有了这个,我们可以运行我们的测试。以下是输出的简要部分:

Check generator generate exponents for 43740
1, 2, 
Check generator generate exponents for 9320
1, 2, 
Check generator generate exponents for 2
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 
Check generator generate exponents for 46340
1, 2,

测试的最后一部分是添加从 + 1 到maxInt的区间:

check_property(generate_ints_greater_than_sqrt_maxInt,  
    prop_nextPowerOfXIsPreviousPowerOfXMultipliedByX, "generate greater    
    than sqrt of maxInt");

这也导致了生成函数的更新,以支持一些边缘情况;请参考以下代码中的注释以获取解释:

auto generate_ints = [](const int min, const int max){
    if(min > max) { // when lower range is larger than upper range, 
        just return empty vector
            return vector<int>();
    }
    if(min == max){ // if min and max are equal, just return {min}
        return range(min, min);
    }

    if(max - min <= 100){ // if there not enough int values in the 
        range, just return it fully
            return range(min, max);
    }
    ...
}

我们已经实现了我们的最终属性!

结论

现在我们只需用几行代码来检查所有以下内容:

  • 0⁰ -> undefined (1 by default in pow function in C++)

  • 0^([1 .. maxInt]) -> 0

  • value: [1 .. maxInt]⁰ -> 1

  • value: [0 .. maxInt]¹ -> value

  • x^y = x^(y-1) * x

这与更常用的基于示例的测试方法相比如何?我们用更少的代码进行更多的测试。我们可以发现代码中隐藏的问题。但是属性比示例更难识别。我们还确定了基于属性的测试与基于示例的测试非常有效地配合使用。

因此,让我们现在解决找到属性的问题。这需要一些分析,我们将探讨一种实际的方式,通过数据驱动测试从示例中演变出属性。

从示例到数据驱动测试到属性

当我第一次听说基于属性的测试时,我有两个问题。首先,我以为它们是用来替代示例测试的——现在我们知道它们并不是;只需将这两种技术并行使用。其次,我不知道如何提出好的属性。

然而,我对如何提出好的示例和如何消除测试之间的重复有了一个好主意。我们已经看到了如何为幂函数提出好的示例;让我们回顾一下:

  • 0⁰ -> 未定义(C++中的 pow 实现返回 1,除非启用了特定错误)

  • 0^(0 到最大的任何整数) -> 0

  • 1^(任何整数) -> 1

  • (除 0 外的任何整数)⁰ -> 1

  • 2² -> 4

  • 2^(不会溢出的最大整数) -> 要计算的值

  • 10⁵ -> 100000

  • 10^(不会溢出的最大整数) -> 要计算的值

我们还看到了为这些情况编写基于示例的测试非常容易:

TEST_CASE("Power"){
    int maxInt = numeric_limits<int>::max();
    CHECK_EQ(1, power(0, 0));
    CHECK_EQ(0, power(0, 1));
    CHECK_EQ(0, power(0, maxInt));
    CHECK_EQ(1, power(1, 1));
    CHECK_EQ(1, power(1, 2));
    CHECK_EQ(1, power(1, maxInt));
    CHECK_EQ(1, power(2, 0));
    CHECK_EQ(2, power(2, 1));
    CHECK_EQ(4, power(2, 2));
    CHECK_EQ(maxInt, power(2, 31) - 1);
    CHECK_EQ(1, power(3, 0));
    CHECK_EQ(3, power(3, 1));
    CHECK_EQ(9, power(3, 2));
    CHECK_EQ(1, power(maxInt, 0));
    CHECK_EQ(maxInt, power(maxInt, 1));
}

这些示例展示了代码的相似之处。0123的基数重复了多次。我们在第九章中已经看到,函数式编程的测试驱动开发,我们可以通过指定多个输入值来使用数据驱动测试来消除这种相似性:

TEST_CASE("1 raised to a power is 1"){
    int exponent;

    SUBCASE("0"){
        exponent = 0;
    }
    SUBCASE("1"){
        exponent = 1;
    }
    SUBCASE("2"){
        exponent = 1;
    }
    SUBCASE("maxInt"){
        exponent = maxInt;
    }

    CAPTURE(exponent);
    CHECK_EQ(1, power(1, exponent));
}

在我努力一段时间后消除这些相似性之后,我开始看到这些属性。在这种情况下,很明显,我们可以添加一个检查相同数学属性的测试,而不是使用特定示例。事实上,我们在上一节中写了它,它看起来像这样:

cout << "Property: any int to power 1 is the value" << endl;
check_property(generate_ints_greater_than_0, 
    prop_any_int_to_power_1_is_the_value, "generate ints");

所以我的建议是——如果你花几分钟思考问题并找到要检查的数学属性,那太好了!(编写基于属性的测试,并添加尽可能多的基于示例的测试,以确保你已经涵盖了各种情况。)如果你看不到它们,别担心;继续添加基于示例的测试,通过使用数据驱动测试消除测试之间的重复,并最终你会发现这些属性。然后,添加基于属性的测试,并决定如何处理现有的基于示例的测试。

好的属性,坏的属性

由于属性比示例更抽象,因此很容易以混乱或不清晰的方式实现它们。你已经需要对基于示例的测试付出很多注意力;现在你需要加倍努力来处理基于属性的测试。

首先,好的属性就像好的单元测试。因此,我们希望有以下属性:

  • 适当命名和清晰

  • 在失败时提供非常清晰的消息

  • 快速

  • 可重复

不过,基于属性的测试有一个警告——由于我们使用随机值,我们是否应该期望随机失败?当基于属性的测试失败时,我们会对我们的代码有所了解,因此这是值得庆祝的。然而,我们应该期望随着时间的推移和错误的消除,失败次数会减少。如果你的基于属性的测试每天都失败,那肯定有问题——也许属性太大,或者实现中存在许多漏洞。如果你的基于属性的测试偶尔失败,并且显示代码中可能存在的错误——那太好了。

基于属性的测试的一个困难之处在于保持生成器和属性检查没有错误。这也是代码,任何代码都可能有错误。在基于示例的测试中,我们通过简化单元测试的方式来解决这个问题,使错误几乎不可能发生。请注意,属性更加复杂,因此可能需要更多的注意。旧的原则“保持简单,愚蠢”在基于属性的测试中更加有价值。因此,更偏爱小属性而不是大属性,进行分析,并与同事一起审查代码,包括名称和实现。

关于实现的一些建议

在本章中,我们使用了一组自定义函数来实现数据生成器,以保持代码标准为 C++ 17。然而,这些函数是为了学习技术而优化的,并不适用于生产环境。您可能已经注意到,它们并不针对内存占用或性能进行优化。我们可以通过巧妙地使用迭代器来改进它们,但还有更好的方法。

如果您可以使用范围库或使用 C++ 20 编译您的测试,那么实现无限数据生成器就会变得非常容易(由于惰性评估)。我还建议您搜索基于属性的测试库或生成器库,因为一些生成器已经被其他人编写,一旦您理解了概念,就可以更快地在您的代码中使用它们。

总结

基于属性的测试是我们多年来所知道和使用的基于示例的测试的一个受欢迎的补充。它向我们展示了如何将数据生成与一些分析相结合,以消除测试中的重复项并找到我们未考虑的情况。

基于属性的测试是通过非常容易使用纯函数实现的数据生成器来实现的。随着 C++ 20 中的惰性评估或范围库的到来,事情将变得更加容易。

但基于属性的测试的核心技术是识别属性。我们已经看到了两种方法来做到这一点——第一种是通过分析示例,第二种是通过编写基于示例的测试,消除重复项,将其转换为数据驱动测试,然后用属性替换数据行。

最后,请记住,基于属性的测试是代码,它们需要非常干净,易于更改和理解。尽可能偏爱小属性,并通过清晰命名使它们易于理解。

在下一章中,我们将看看如何使用纯函数来支持我们的重构工作,以及如何将设计模式实现为函数。

第十二章:重构到纯函数并通过纯函数

程序员经常遇到他们害怕改变的代码。通过提取纯函数,使用柯里化和组合,并利用编译器,你可以以更安全的方式重构现有代码。我们将看一个通过纯函数重构的例子,然后我们将看一些设计模式,以及它们在函数式编程中的实现,以及如何在重构中使用它们。

本章将涵盖以下主题:

  • 如何思考遗留代码

  • 如何使用编译器和纯函数来识别和分离依赖关系

  • 如何从任何代码中提取 lambda

  • 如何使用柯里化和组合消除 lambda 之间的重复,并将它们分组到类中

  • 如何使用函数实现一些设计模式(策略、命令和依赖注入)

  • 如何使用基于函数的设计模式来重构

技术要求

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

代码在 GitHub 上的Chapter12文件夹中。它包括并使用doctest,这是一个单头文件的开源单元测试库。你可以在它的 GitHub 仓库上找到它https:/​/github.​com/​onqtam/​doctest

重构到纯函数并通过纯函数

重构是软件开发的一个重要而持续的部分。主要原因是需求的持续变化,由我们构建的应用程序周围世界的变化所驱动。我们的客户不断了解产品所在的生态系统,并需要我们将这些产品适应他们发现的新现实。因此,我们的代码,即使结构完美,几乎总是落后于我们当前对所解决问题的理解。

完美地构建我们的代码也不容易。程序员是人,所以我们会犯错,失去焦点,有时找不到最佳解决方案。处理这种复杂情况的唯一方法是使用无情的重构;也就是说,在让事情运转后,我们改进代码结构,直到在我们拥有的约束下代码达到最佳状态。

只要我们很早就重构并编写测试,那就很容易说和做。但是如果我们继承了一个没有测试的代码库呢?那我们该怎么办?我们将讨论这个问题,以及后面将使用纯函数来重构遗留代码的一个有前途的想法。

首先,让我们定义我们的术语。什么是重构?

什么是重构?

重构是行业中普遍使用的术语之一,但并不被很好理解。不幸的是,这个术语经常被用来证明大的重设计。考虑以下关于给定项目的常见故事:

  • 项目开始时,功能以快速的速度添加。

  • 很快(几个月、一年,甚至几周),速度下降了,但需求是一样的。

  • 多年后,添加新功能变得如此困难,以至于客户感到恼火并向团队施加压力。

  • 最终,决定重写或改变代码的整体结构,希望能加快速度。

  • 六个月后,重写或重设计(通常)失败,管理层面临着一个不可能的情况——我们应该尝试重设计、重新启动项目,还是做其他事情?

这个循环的大重设计阶段通常错误地被称为重构,但这并不是重构的含义。

相反,要理解重构的真正含义,让我们从思考对代码库可以做出的改变开始。我们通常可以将这些改变分类如下:

  • 实施新要求

  • 修复一个错误

  • 以各种方式重新组织代码——重构、重工程、重设计和/或重架构

我们可以将这些更改大致分类为两大类,如下:

  • 影响代码行为的更改

  • 不影响代码行为的更改

当我们谈论行为时,我们谈论输入和输出,比如“当我在用户界面(UI)表单中输入这些值并单击此按钮时,然后我看到这个输出并保存这些东西”。我们通常不包括性能、可伸缩性或安全性等跨功能关注点在行为中。

有了这些明确的术语,我们可以定义重构——简单地对不影响程序外部行为的代码结构进行更改。大型重设计或重写很少符合这个定义,因为通常进行大型重设计的团队并不证明结果与原始代码具有相同的行为(包括已知的错误,因为有人可能依赖它们)。

对程序进行任何修改其行为的更改都不是重构。这包括修复错误或添加功能。然而,我们可以将这些更改分为两个阶段——首先重构以为更改腾出空间,然后进行行为更改。

这个定义引发了一些问题,如下:

  • 我们如何证明我们没有改变行为?我们知道的唯一方法是:自动回归测试。如果我们有一套我们信任且足够快速的自动化测试,我们可以轻松地进行更改而不改变任何测试,并查看它们是否通过。

  • 重构有多小?更改越大,证明没有受到影响就越困难,因为程序员是人类,会犯错误。我们更喜欢在重构中采取非常小的步骤。以下是一些保持行为的小代码更改的示例:重命名、向函数添加参数、更改函数的参数顺序以及将一组语句提取到函数中等。每个小更改都可以轻松进行,并运行测试以证明没有发生行为更改。每当我们需要进行更大的重构时,我们只需进行一系列这些小更改。

  • 当我们没有测试时,我们如何证明我们没有改变代码的行为?这就是我们需要谈论遗留代码和遗留代码困境的时候。

遗留代码困境

编程可能是唯一一个“遗留”一词具有负面含义的领域。在任何其他情况下,“遗留”都意味着某人留下的东西,通常是某人引以为傲的东西。在编程中,遗留代码指的是我们继承的独占代码,维护起来很痛苦。

程序员经常认为遗留代码是不可避免的,对此无能为力。然而,我们可以做很多事情。首先是澄清我们所说的遗留代码是什么意思。迈克尔·菲瑟斯在他的遗留代码书中将其定义为没有测试的代码。然而,我更倾向于使用更一般的定义:你害怕改变的代码。你害怕改变的代码会减慢你的速度,减少你的选择,并使任何新的开发成为一场磨难。但这绝不是不可避免的:我们可以改变它,我们将看到如何做到这一点。

我们可以做的第二件事是了解遗留代码的困境。为了不那么害怕改变,我们需要对其进行重构,但为了重构代码,我们需要编写测试。要编写测试,我们需要调整代码使其可测试;这看起来像一个循环——为了改变代码,我们需要改变代码!如果我们一开始就害怕改变代码,我们该怎么办?

幸运的是,这个困境有一个解决办法。如果我们能够对代码进行安全的更改——这些更改几乎没有错误的机会,并且允许我们测试代码——那么我们就可以慢慢但肯定地改进代码。这些更改确实是重构,但它们甚至比重构步骤更小、更安全。它们的主要目标是打破代码中设计元素之间的依赖关系,使我们能够编写测试,以便在之后继续重构。

由于我们的重点是使用纯函数和函数构造来重构代码,我们不会查看完整的技术列表。我可以给出一个简单的例子,称为提取和覆盖。假设您需要为一个非常大的函数编写测试。如果我们只能为函数的一小部分编写测试,那将是理想的。我们可以通过将要测试的代码提取到另一个函数中来实现这一点。然而,新函数依赖于旧代码,因此我们将很难弄清所有的依赖关系。为了解决这个问题,我们可以创建一个派生类,用虚拟函数覆盖我们函数的所有依赖关系。在单元测试中,这称为部分模拟。这使我们能够用测试覆盖我们提取函数的所有代码,同时假设类的所有其他部分都按预期工作。一旦我们用测试覆盖了它,我们就可以开始重构;在这个练习结束时,我们经常会提取一个完全由模拟或存根的新类。

这些技术是在我们的语言中广泛支持函数式编程之前编写的。现在我们可以利用纯函数来安全地重构我们编写的代码。但是,为了做到这一点,我们需要了解依赖关系如何影响我们测试和更改代码的能力。

依赖和变更

我们的用户和客户希望项目成功的时间越长,就能获得越多的功能。然而,我们经常无法交付,因为随着时间的推移,代码往往变得越来越僵化。随着时间的推移,添加新功能变得越来越慢,而且在添加功能时会出现新的错误。

这引出了一个十分重要的问题——是什么使代码难以更改?我们如何编写能够保持变更速度甚至增加变更速度的代码?

这是一个复杂的问题,有许多方面和各种解决方案。其中一个在行业中基本上是一致的——依赖关系往往会减慢开发速度。具有较少依赖关系的代码结构通常更容易更改,从而更容易添加功能。

我们可以从许多层面来看依赖关系。在更高的层面上,我们可以谈论依赖于其他可执行文件的可执行文件;例如,直接调用另一个网络服务的网络服务。通过使用基于事件的系统而不是直接调用,可以减少这个层面上的依赖关系。在更低的层面上,我们可以谈论对库或操作系统例程的依赖;例如,一个网络服务依赖于特定文件夹或特定库版本的存在。

虽然其他所有层面都很有趣,但对于我们的目标,我们将专注于类/函数级别,特别是类和函数如何相互依赖。由于在任何非平凡的代码库中都不可能避免依赖关系,因此我们将专注于依赖关系的强度。

我们将以我编写的一小段代码作为示例,该代码根据员工列表和角色、资历、组织连续性和奖金水平等参数计算工资。它从 CSV 文件中读取员工列表,根据一些规则计算工资,并打印计算出的工资列表。代码的第一个版本是天真地编写的,只使用main函数,并将所有内容放在同一个文件中,如下面的代码示例所示。

#include <iostream>
#include <fstream>
#include <string>
#include <cmath>

using namespace std;

int main(){
    string id;
    string employee_id;
    string first_name;
    string last_name;
    string seniority_level;
    string position;
    string years_worked_continuously;
    string special_bonus_level;

    ifstream employeesFile("./Employees.csv");
    while (getline(employeesFile, id, ',')) {
        getline(employeesFile, employee_id, ',') ;
        getline(employeesFile, first_name, ',') ;
        getline(employeesFile, last_name, ',') ;
        getline(employeesFile, seniority_level, ',') ;
        getline(employeesFile, position, ',') ;
        getline(employeesFile, years_worked_continuously, ',') ;
        getline(employeesFile, special_bonus_level);
        if(id == "id") continue;

        int baseSalary;
        if(position == "Tester") baseSalary= 1500;
        if(position == "Analyst") baseSalary = 1600;
        if(position == "Developer") baseSalary = 2000;
        if(position == "Team Leader") baseSalary = 3000;
        if(position == "Manager") baseSalary = 4000;

        double factor;
        if(seniority_level == "Entry") factor = 1;
        if(seniority_level == "Junior") factor = 1.2;
        if(seniority_level == "Senior") factor = 1.5;

        double continuityFactor;
        int continuity = stoi(years_worked_continuously);
        if(continuity < 3) continuityFactor = 1;
        if(continuity >= 3 && continuity < 5) continuityFactor = 1.2;
        if(continuity >= 5 && continuity < 10) continuityFactor = 1.5;
        if(continuity >=10 && continuity <= 20) continuityFactor = 1.7;
        if(continuity > 20) continuityFactor = 2;

        int specialBonusLevel = stoi(special_bonus_level);
        double specialBonusFactor = specialBonusLevel * 0.03;

        double currentSalary = baseSalary * factor * continuityFactor;
        double salary = currentSalary + specialBonusFactor * 
            currentSalary;

        int roundedSalary = ceil(salary);

        cout  << seniority_level << position << " " << first_name << " 
            " << last_name << " (" << years_worked_continuously << 
            "yrs)" <<  ", " << employee_id << ", has salary (bonus                 
            level  " << special_bonus_level << ") " << roundedSalary << 
            endl;
    }
}

输入文件是使用专门的工具生成的随机值,看起来像这样:

id,employee_id,First_name,Last_name,Seniority_level,Position,Years_worked_continuously,Special_bonus_level
1,51ef10eb-8c3b-4129-b844-542afaba7eeb,Carmine,De Vuyst,Junior,Manager,4,3
2,171338c8-2377-4c70-bb66-9ad669319831,Gasper,Feast,Entry,Team Leader,10,5
3,807e1bc7-00db-494b-8f92-44acf141908b,Lin,Sunley,Medium,Manager,23,3
4,c9f18741-cd6c-4dee-a243-00c1f55fde3e,Leeland,Geraghty,Medium,Team Leader,7,4
5,5722a380-f869-400d-9a6a-918beb4acbe0,Wash,Van der Kruys,Junior,Developer,7,1
6,f26e94c5-1ced-467b-ac83-a94544735e27,Marjie,True,Senior,Tester,28,1

当我们运行程序时,为每个员工计算了salary,输出如下所示:

JuniorManager Carmine De Vuyst (4yrs), 51ef10eb-8c3b-4129-b844-542afaba7eeb, has salary (bonus level  3) 6279
EntryTeam Leader Gasper Feast (10yrs), 171338c8-2377-4c70-bb66-9ad669319831, has salary (bonus level  5) 5865
MediumManager Lin Sunley (23yrs), 807e1bc7-00db-494b-8f92-44acf141908b, has salary (bonus level  3) 8720
MediumTeam Leader Leeland Geraghty (7yrs), c9f18741-cd6c-4dee-a243-00c1f55fde3e, has salary (bonus level  4) 5040
JuniorDeveloper Wash Van der Kruys (7yrs), 5722a380-f869-400d-9a6a-918beb4acbe0, has salary (bonus level  1) 3708
SeniorTester Marjie True (28yrs), f26e94c5-1ced-467b-ac83-a94544735e27, has salary (bonus level  1) 4635
EntryAnalyst Muriel Dorken (10yrs), f4934e00-9c01-45f9-bddc-2366e6ea070e, has salary (bonus level  8) 3373
SeniorTester Harrison Mawditt (17yrs), 66da352a-100c-4209-a13e-00ec12aa167e, has salary (bonus level  10) 4973

那么,这段代码有依赖关系吗?有,并且它们就在眼前。

查找依赖关系的一种方法是查找构造函数调用或全局变量。在我们的例子中,我们有一个对ifstream的构造函数调用,以及一个对cout的使用,如下例所示:

ifstream employeesFile("./Employees.csv")
cout  << seniority_level << position << " " << first_name << " " << 
    last_name << " (" << years_worked_continuously << "yrs)" <<  ", " 
    << employee_id << ", has salary (bonus level  " << 
    special_bonus_level << ") " << roundedSalary << endl;

识别依赖的另一种方法是进行一种想象练习。想象一下什么要求可能会导致代码的变化。有几种情况。如果我们决定切换到员工数据库,我们将需要改变读取数据的方式。如果我们想要输出到文件,我们将需要改变打印工资的代码行。如果计算工资的规则发生变化,我们将需要更改计算salary的代码行。

这两种方法都得出了相同的结论;我们对文件系统和标准输出有依赖。让我们专注于标准输出,并提出一个问题;我们如何改变代码,以便将工资输出到标准输出和文件中?答案非常简单,由于标准模板库STL)流的多态性,只需提取一个接收输出流并写入数据的函数。让我们看看这样一个函数会是什么样子;为了简单起见,我们还引入了一个名为Employee的结构,其中包含我们需要的所有字段,如下例所示:

void printEmployee(const Employee& employee, ostream& stream, int 
    roundedSalary){
        stream << employee.seniority_level << employee.position << 
        " " << employee.first_name << " " << employee.last_name << 
        " (" << employee.years_worked_continuously << "yrs)" <<  ",             
        " << employee.employee_id << ", has salary (bonus level  " << 
        employee.special_bonus_level << ") " << roundedSalary << endl;
    }

这个函数不再依赖于标准输出。在依赖方面,我们可以说我们打破了依赖关系,即员工打印和标准输出之间的依赖关系。我们是如何做到的呢?嗯,我们将cout流作为函数的参数从调用者传递进来:

        printEmployee(employee, cout, roundedSalary);

这个看似微小的改变使函数成为多态的。printEmployee的调用者现在控制函数的输出,而不需要改变函数内部的任何东西。

此外,我们现在可以为printEmployee函数编写测试,而不必触及文件系统。这很重要,因为文件系统访问速度慢,而且由于诸如磁盘空间不足或损坏部分等原因,在测试正常路径时可能会出现错误。我们如何编写这样的测试呢?嗯,我们只需要使用内存流调用该函数,然后将写入内存流的输出与我们期望的输出进行比较。

因此,打破这种依赖关系会极大地改善我们代码的可更改性和可测试性。这种机制非常有用且广泛,因此它得到了一个名字——依赖注入DI)。在我们的情况下,printEmployee函数的调用者(main函数、test函数或另一个未来的调用者)将依赖注入到我们的函数中,从而控制其行为。

关于 DI 有一点很重要——它是一种设计模式,而不是一个库。许多现代库和 MVC 框架都支持 DI,但您不需要任何外部内容来注入依赖关系。您只需要将依赖项传递给构造函数、属性或函数参数,然后就可以了。

我们学会了如何识别依赖关系以及如何使用 DI 来打破它们。现在是时候看看我们如何利用纯函数来重构这段代码了。

纯函数和程序的结构

几年前,我学到了关于计算机程序的一个基本定律,这导致我研究如何在重构中使用纯函数:

任何计算机程序都可以由两种类型的类/函数构建——一些进行 I/O,一些是纯函数。

在之后寻找类似想法时,我发现 Gary Bernhardt 对这些结构的简洁命名:functional core, imperative shellwww.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell)。

无论你如何称呼它,这个想法对重构的影响都是根本的。如果任何程序都可以被写成两种不同类型的类/函数,一些是不可变的,一些是 I/O,那么我们可以利用这个属性来重构遗留代码。高层次的过程看起来会像这样:

  • 提取纯函数(我们将看到这些步骤识别依赖关系)。

  • 测试和重构它们。

  • 根据高内聚原则将它们重新分组为类。

我想在这个定律中添加一个公理。我相信我们可以在代码的任何级别应用这个定律,无论是函数、类、代码行组、类组还是整个模块,除了那些纯 I/O 的代码行。换句话说,这个定律是分形的;它适用于代码的任何级别,除了最基本的代码行。

这个公理的重要性是巨大的。它告诉我们的是,我们可以在代码的任何级别应用之前描述的相同方法,除了最基本的。换句话说,我们从哪里开始应用这个方法并不重要,因为它在任何地方都会起作用。

在接下来的几节中,我们将探讨该方法的每个步骤。首先,让我们提取一些纯函数。

使用编译器和纯函数来识别依赖关系。

尝试更改我们不理解且没有测试的代码可能会感到冒险。任何错误都可能导致丑陋的错误,任何更改都可能导致错误。

幸运的是,编译器和纯函数可以帮助揭示依赖关系。记住纯函数是什么——对于相同的输入返回相同输出的函数。这意味着,根据定义,纯函数的所有依赖关系都是可见的,通过参数、全局变量或变量捕获传递。

这引导我们以一种简单的方式来识别代码中的依赖关系:选择几行代码,将它们提取到一个函数中,使其成为纯函数,然后让编译器告诉你依赖关系是什么。此外,这些依赖关系将需要被注入,从而使我们得到一个可测试的函数。

让我们看几个例子。一个简单的开始是下面几行代码,根据公司员工的职位计算基本工资:

        int baseSalary;
        if(position == "Tester") baseSalary = 1500;
        if(position == "Analyst") baseSalary = 1600;
        if(position == "Developer") baseSalary = 2000;
        if(position == "Team Leader") baseSalary = 3000;
        if(position == "Manager") baseSalary = 4000;

让我们将其提取为一个纯函数。现在名称并不重要,所以我们暂时称之为doesSomething,然后我将代码行复制粘贴到新函数中,而不是从旧函数中删除它们,如下例所示:

auto doesSomething = [](){
        int baseSalary;
        if(position == "Tester") baseSalary = 1500;
        if(position == "Analyst") baseSalary = 1600;
        if(position == "Developer") baseSalary = 2000;
        if(position == "Team Leader") baseSalary = 3000;
        if(position == "Manager") baseSalary = 4000;
};

我的编译器立即抱怨说位置未定义,所以它帮我找出了依赖关系。让我们将其添加为一个参数,如下面的示例所示:

auto doesSomething = [](const string& position){
        int baseSalary;
        if(position == "Tester") baseSalary = 1500;
        if(position == "Analyst") baseSalary = 1600;
        if(position == "Developer") baseSalary = 2000;
        if(position == "Team Leader") baseSalary = 3000;
        if(position == "Manager") baseSalary = 4000;
};

这个函数缺少一些东西;纯函数总是返回值,但这个函数没有。让我们添加return语句,如下面的代码示例所示:

auto doesSomething = [](const string& position){
        int baseSalary;
        if(position == "Tester") baseSalary = 1500;
        if(position == "Analyst") baseSalary = 1600;
        if(position == "Developer") baseSalary = 2000;
        if(position == "Team Leader") baseSalary = 3000;
        if(position == "Manager") baseSalary = 4000;
        return baseSalary;
};

现在这个函数足够简单,可以独立测试了。但首先,我们需要将其提取到一个单独的.h文件中,并给它一个合适的名称。baseSalaryForPosition听起来不错;让我们在下面的代码中看看它的测试:

TEST_CASE("Base salary"){
    CHECK_EQ(1500, baseSalaryForPosition("Tester"));
    CHECK_EQ(1600, baseSalaryForPosition("Analyst"));
    CHECK_EQ(2000, baseSalaryForPosition("Developer"));
    CHECK_EQ(3000, baseSalaryForPosition("Team Leader"));
    CHECK_EQ(4000, baseSalaryForPosition("Manager"));
    CHECK_EQ(0, baseSalaryForPosition("asdfasdfs"));
}

编写这些测试相当简单。它们也重复了许多来自函数的东西,包括位置字符串和薪水值。有更好的方法来组织代码,但这是预期的遗留代码。现在,我们很高兴我们用测试覆盖了初始代码的一部分。我们还可以向领域专家展示这些测试,并检查它们是否正确,但让我们继续进行重构。我们需要从main()开始调用新函数,如下所示:

    while (getline(employeesFile, id, ',')) {
        getline(employeesFile, employee_id, ',') ;
        getline(employeesFile, first_name, ',') ;
        getline(employeesFile, last_name, ',') ;
        getline(employeesFile, seniority_level, ',') ;
        getline(employeesFile, position, ',') ;
        getline(employeesFile, years_worked_continuously, ',') ;
        getline(employeesFile, special_bonus_level);
        if(id == "id") continue;

 int baseSalary = baseSalaryForPosition(position);
        double factor;
        if(seniority_level == "Entry") factor = 1;
        if(seniority_level == "Junior") factor = 1.2;
        if(seniority_level == "Senior") factor = 1.5;
        ...
}

虽然这是一个简单的案例,但它展示了基本的过程,如下所示:

  • 选择几行代码。

  • 将它们提取到一个函数中。

  • 使函数成为纯函数。

  • 注入所有依赖。

  • 为新的纯函数编写测试。

  • 验证行为。

  • 重复,直到整个代码都被测试覆盖。

如果您遵循这个过程,引入错误的风险将变得极小。根据我的经验,您需要最小心的是使函数成为纯函数。记住——如果它在一个类中,将其设为带有const参数的静态函数,但如果它在类外部,将所有参数作为const传递,并将其设为 lambda。

如果我们重复这个过程几次,我们最终会得到更多的纯函数。首先,factorForSeniority根据资历级别计算因子,如下例所示:

auto factorForSeniority = [](const string& seniority_level){
    double factor;
    if(seniority_level == "Entry") factor = 1;
    if(seniority_level == "Junior") factor = 1.2;
    if(seniority_level == "Senior") factor = 1.5;
    return factor;
};

然后,factorForContinuity根据——你猜对了——连续性计算因子:

auto factorForContinuity = [](const string& years_worked_continuously){
    double continuityFactor;
    int continuity = stoi(years_worked_continuously);
    if(continuity < 3) continuityFactor = 1;
    if(continuity >= 3 && continuity < 5) continuityFactor = 1.2;
    if(continuity >= 5 && continuity < 10) continuityFactor = 1.5;
    if(continuity >=10 && continuity <= 20) continuityFactor = 1.7;
    if(continuity > 20) continuityFactor = 2;
    return continuityFactor;
};

最后,bonusLevel函数读取奖金级别:

auto bonusLevel = [](const string& special_bonus_level){
    return stoi(special_bonus_level);
};

这些函数中的每一个都可以很容易地通过基于示例的、数据驱动的或基于属性的测试进行测试。提取了所有这些函数后,我们的主要方法看起来像以下示例(为简洁起见,省略了几行):

int main(){
...
    ifstream employeesFile("./Employees.csv");
    while (getline(employeesFile, id, ',')) {
        getline(employeesFile, employee_id, ',') ;
...
        getline(employeesFile, special_bonus_level);
        if(id == "id") continue;

 int baseSalary = baseSalaryForPosition(position);
 double factor = factorForSeniority(seniority_level);

 double continuityFactor = 
            factorForContinuity(years_worked_continuously);

 int specialBonusLevel =  bonusLevel(special_bonus_level);
        double specialBonusFactor = specialBonusLevel * 0.03;

        double currentSalary = baseSalary * factor * continuityFactor;
        double salary = currentSalary + specialBonusFactor * 
            currentSalary;

        int roundedSalary = ceil(salary);

        cout  << seniority_level << position << " " << first_name << "           
          " << last_name << " (" << years_worked_continuously << "yrs)"     
          <<  ", " << employee_id << ", has salary (bonus level  " << 
          special_bonus_level << ") " << roundedSalary << endl;
    }

这样会更清晰,而且测试覆盖更好。然而,lambda 还可以用于更多的操作;让我们看看我们如何做到这一点。

从遗留代码到 lambda

除了纯度,lambda 还为我们提供了许多可以使用的操作:函数组合、部分应用、柯里化和高级函数。在重构遗留代码时,我们可以利用这些操作。

展示这一点最简单的方法是从main方法中提取整个salary计算。以下是计算salary的代码行:

...        
        int baseSalary = baseSalaryForPosition(position);
        double factor = factorForSeniority(seniority_level);

        double continuityFactor = 
            factorForContinuity(years_worked_continuously);

        int specialBonusLevel =  bonusLevel(special_bonus_level);
        double specialBonusFactor = specialBonusLevel * 0.03;

        double currentSalary = baseSalary * factor * continuityFactor;
        double salary = currentSalary + specialBonusFactor * 
            currentSalary;

        int roundedSalary = ceil(salary);
...

我们可以以两种方式提取这个纯函数——一种是将需要的每个值作为参数传递,结果如下所示:

auto computeSalary = [](const string& position, const string seniority_level, const string& years_worked_continuously, const string& special_bonus_level){
    int baseSalary = baseSalaryForPosition(position);
    double factor = factorForSeniority(seniority_level);

    double continuityFactor = 
        factorForContinuity(years_worked_continuously);

    int specialBonusLevel =  bonusLevel(special_bonus_level);
    double specialBonusFactor = specialBonusLevel * 0.03;

    double currentSalary = baseSalary * factor * continuityFactor;
    double salary = currentSalary + specialBonusFactor * currentSalary;

    int roundedSalary = ceil(salary);
    return roundedSalary;
};

第二个选项更有趣。与其传递变量,不如我们传递函数并事先将它们绑定到所需的变量?

这是一个有趣的想法。结果是一个接收多个函数作为参数的函数,每个函数都没有任何参数:

auto computeSalary = [](auto baseSalaryForPosition, auto factorForSeniority, auto factorForContinuity, auto bonusLevel){
    int baseSalary = baseSalaryForPosition();
    double factor = factorForSeniority();
    double continuityFactor = factorForContinuity();
    int specialBonusLevel =  bonusLevel();

    double specialBonusFactor = specialBonusLevel * 0.03;

    double currentSalary = baseSalary * factor * continuityFactor;
    double salary = currentSalary + specialBonusFactor * currentSalary;

    int roundedSalary = ceil(salary);
    return roundedSalary;
};

main方法需要首先绑定这些函数,然后将它们注入到我们的方法中,如下所示:

        auto roundedSalary = computeSalary(
                bind(baseSalaryForPosition, position), 
                bind(factorForSeniority, seniority_level),
        bind(factorForContinuity, years_worked_continuously),
        bind(bonusLevel, special_bonus_level));

        cout  << seniority_level << position << " " << first_name << " 
          " << last_name << " (" << years_worked_continuously << "yrs)"           
          <<  ", " << employee_id << ", has salary (bonus level  " <<              
          special_bonus_level << ") " << roundedSalary << endl;

为什么这种方法很有趣?好吧,让我们从软件设计的角度来看看。我们创建了小的纯函数,每个函数都有明确的责任。然后,我们将它们绑定到特定的值。之后,我们将它们作为参数传递给另一个 lambda,该 lambda 使用它们来计算我们需要的结果。

面向对象编程OOP)风格中,这意味着什么?好吧,函数将成为类的一部分。将函数绑定到值相当于调用类的构造函数。将对象传递给另一个函数称为 DI。

等一下!实际上我们正在分离责任并注入依赖项,只是使用纯函数而不是对象!因为我们使用纯函数,依赖关系由编译器明确表示。因此,我们有一种重构代码的方法,几乎没有错误的可能性,因为我们经常使用编译器。这是一个非常有用的重构过程。

我不得不承认,结果并不如我所希望的那样好。让我们重构我们的 lambda。

重构 lambda

我对我们提取出来的computeSalary lambda 的样子并不满意。由于接收了许多参数和多个责任,它相当复杂。让我们仔细看看它,看看我们如何可以改进它:

auto computeSalary = [](auto baseSalaryForPosition, auto 
    factorForSeniority, auto factorForContinuity, auto bonusLevel){
        int baseSalary = baseSalaryForPosition();
        double factor = factorForSeniority();
        double continuityFactor = factorForContinuity();
        int specialBonusLevel =  bonusLevel();

        double specialBonusFactor = specialBonusLevel * 0.03;

        double currentSalary = baseSalary * factor * continuityFactor;
        double salary = currentSalary + specialBonusFactor * 
            currentSalary;

        int roundedSalary = ceil(salary);
         return roundedSalary;
};

所有迹象似乎表明这个函数有多个责任。如果我们从中提取更多的函数会怎样呢?让我们从specialBonusFactor计算开始:

auto specialBonusFactor = [](auto bonusLevel){
    return bonusLevel() * 0.03;
};
auto computeSalary = [](auto baseSalaryForPosition, auto     
factorForSeniority, auto factorForContinuity, auto bonusLevel){
    int baseSalary = baseSalaryForPosition();
    double factor = factorForSeniority();
    double continuityFactor = factorForContinuity();

    double currentSalary = baseSalary * factor * continuityFactor;
    double salary = currentSalary + specialBonusFactor() * 
        currentSalary;

    int roundedSalary = ceil(salary);
    return roundedSalary;
};

现在我们可以注入specialBonusFactor。但是,请注意,specialBonusFactor是唯一需要bonusLevel的 lambda。这意味着我们可以将bonusLevel lambda 部分应用于specialBonusFactor lambda,如下例所示:

int main(){
        ...
  auto bonusFactor = bind(specialBonusFactor, [&](){ return 
    bonusLevel(special_bonus_level); } );
  auto roundedSalary = computeSalary(
      bind(baseSalaryForPosition, position), 
      bind(factorForSeniority, seniority_level),
      bind(factorForContinuity, years_worked_continuously),
      bonusFactor
     );
 ...
}

auto computeSalary = [](auto baseSalaryForPosition, auto factorForSeniority, auto factorForContinuity, auto bonusFactor){
    int baseSalary = baseSalaryForPosition();
    double factor = factorForSeniority();
    double continuityFactor = factorForContinuity();

    double currentSalary = baseSalary * factor * continuityFactor;
    double salary = currentSalary + bonusFactor() * currentSalary;

    int roundedSalary = ceil(salary);
    return roundedSalary;
};

我们的computeSalary lambda 现在更小了。我们甚至可以通过内联临时变量使它更小:

auto computeSalary = [](auto baseSalaryForPosition, auto 
    factorForSeniority, auto factorForContinuity, auto bonusFactor){
        double currentSalary = baseSalaryForPosition() * 
            factorForSeniority() * factorForContinuity();
    double salary = currentSalary + bonusFactor() * currentSalary;
    return ceil(salary);
};

这很不错!然而,我想让它更接近一个数学公式。首先,让我们重写计算salary的那一行(在代码中用粗体标出):

auto computeSalary = [](auto baseSalaryForPosition, auto 
    factorForSeniority, auto factorForContinuity, auto bonusFactor){
        double currentSalary = baseSalaryForPosition() * 
            factorForSeniority() * factorForContinuity();
 double salary = (1 + bonusFactor()) * currentSalary;
    return ceil(salary);
};

然后,让我们用函数替换变量。然后我们得到以下代码示例:

auto computeSalary = [](auto baseSalaryForPosition, auto 
    factorForSeniority, auto factorForContinuity, auto bonusFactor){
        return ceil (
                (1 + bonusFactor()) * baseSalaryForPosition() *                             
                    factorForSeniority() * factorForContinuity()
    );
};

因此,我们有一个 lambda 函数,它接收多个 lambda 函数并使用它们来计算一个值。我们仍然可以对其他函数进行改进,但我们已经达到了一个有趣的点。

那么我们接下来该怎么办呢?我们已经注入了依赖关系,代码更加模块化,更容易更改,也更容易测试。我们可以从测试中注入 lambda 函数,返回我们想要的值,这实际上是单元测试中的一个 stub。虽然我们没有改进整个代码,但我们通过提取纯函数和使用函数操作来分离依赖关系和责任。如果我们愿意,我们可以把代码留在这样。或者,我们可以迈出另一步,将函数重新分组成类。

从 lambda 到类

在这本书中,我们已经多次指出,一个类只不过是一组具有内聚性的部分应用纯函数。到目前为止,我们使用的技术已经创建了一堆部分应用的纯函数。现在将它们转换成类是一项简单的任务。

让我们看一个baseSalaryForPosition函数的简单例子:

auto baseSalaryForPosition = [](const string& position){
    int baseSalary;
    if(position == "Tester") baseSalary = 1500;
    if(position == "Analyst") baseSalary = 1600;
    if(position == "Developer") baseSalary = 2000;
    if(position == "Team Leader") baseSalary = 3000;
    if(position == "Manager") baseSalary = 4000;
    return baseSalary;
};

我们在main()中使用它,就像下面的例子一样:

        auto roundedSalary = computeSalary(
 bind(baseSalaryForPosition, position), 
                bind(factorForSeniority, seniority_level),
                bind(factorForContinuity, years_worked_continuously),
                bonusFactor
            );

要将其转换成类,我们只需要创建一个接收position参数的构造函数,然后将其改为类方法。让我们在下面的示例中看一下:

class BaseSalaryForPosition{
    private:
        const string& position;

    public:
        BaseSalaryForPosition(const string& position) : 
            position(position){};

        int baseSalaryForPosition() const{
            int baseSalary;
            if(position == "Tester") baseSalary = 1500;
            if(position == "Analyst") baseSalary = 1600;
            if(position == "Developer") baseSalary = 2000;
            if(position == "Team Leader") baseSalary = 3000;
            if(position == "Manager") baseSalary = 4000;
            return baseSalary;
        }
};

我们可以简单地将部分应用函数传递给computeSalary lambda,如下面的代码所示:

 auto bonusFactor = bind(specialBonusFactor, [&](){ return 
            bonusLevel(special_bonus_level); } );
            auto roundedSalary = computeSalary(
                theBaseSalaryForPosition,
                bind(factorForSeniority, seniority_level),
                bind(factorForContinuity, years_worked_continuously),
                bonusFactor
            );

为了使其工作,我们还需要像这里所示的改变我们的computeSalary lambda:

auto computeSalary = [](const BaseSalaryForPosition& 
    baseSalaryForPosition, auto factorForSeniority, auto     
        factorForContinuity, auto bonusFactor){
            return ceil (
                (1 + bonusFactor()) * 
                    baseSalaryForPosition.baseSalaryForPosition() *                             
                        factorForSeniority() * factorForContinuity()
            );
};

现在,为了允许注入不同的实现,我们实际上需要从BaseSalaryForPosition类中提取一个接口,并将其作为接口注入,而不是作为一个类。这对于从测试中注入 double 值非常有用,比如 stub 或 mock。

从现在开始,你可以根据自己的需要将函数重新分组成类。我会把这留给读者作为一个练习,因为我相信我们已经展示了如何使用纯函数来重构代码,即使我们最终想要得到面向对象的代码。

重温重构方法

到目前为止,我们学到了什么?嗯,我们经历了一个结构化的重构过程,可以在代码的任何级别使用,减少错误的概率,并实现可更改性和测试性。这个过程基于两个基本思想——任何程序都可以被写成不可变函数和 I/O 函数的组合,或者作为一个函数核心在一个命令式外壳中。此外,我们已经表明这个属性是分形的——我们可以将它应用到任何代码级别,从几行到整个模块。

由于不可变函数可以成为我们程序的核心,我们可以逐渐提取它们。我们写下新的函数名称,复制并粘贴函数体,并使用编译器将任何依赖项作为参数传递。当代码编译完成时,如果我们小心而缓慢地进行更改,我们可以相当确信代码仍然正常工作。这种提取揭示了我们函数的依赖关系,从而使我们能够做出设计决策。

接下来,我们将提取更多的函数,这些函数接收其他部分应用的纯函数作为参数。这导致了依赖关系和实际的破坏性依赖关系之间的明显区别。

最后,由于部分应用函数等同于类,我们可以根据内聚性轻松地封装一个或多个函数。这个过程无论我们是从类还是函数开始,都可以工作,而且无论我们最终想要以函数或类结束都没有关系。然而,它允许我们使用函数构造来打破依赖关系,并在我们的代码中分离责任。

由于我们正在改进设计,现在是时候看看设计模式如何应用于函数式编程以及如何向它们重构。我们将访问一些四人帮模式,以及我们已经在我们的代码中使用过的 DI。

设计模式

软件开发中的许多好东西都来自于那些注意到程序员工作方式并从中提取某些教训的人;换句话说,看待实际方法并提取共同和有用的教训,而不是推测解决方案。

所谓的四人帮(Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides)在记录设计模式时采取了这种确切的方法,用精确的语言列出了一系列设计模式。在注意到更多程序员以类似的方式解决相同问题后,他们决定将这些模式写下来,并向编程世界介绍了在明确上下文中对特定问题的可重用解决方案的想法。

由于当时的设计范式是面向对象编程,他们出版的设计模式书籍展示了使用面向对象方法的这些解决方案。顺便说一句,有趣的是注意到他们在可能的情况下至少记录了两种类型的解决方案——一种基于继承,另一种基于对象组合。我花了很多时间研究设计模式书籍,我可以告诉你,这是一个非常有趣的软件设计课程。

我们将在下一节中探讨一些设计模式以及如何使用函数来实现它们。

策略模式,功能风格

策略模式可以简要描述为一种结构化代码的方式,它允许在运行时选择算法。面向对象编程的实现使用 DI,你可能已经熟悉 STL 中的面向对象和功能性设计。

让我们来看看 STL sort函数。其最复杂的形式需要一个函数对象,如下例所示:

class Comparator{
    public: 
        bool operator() (int first, int second) { return (first < second);}
};

TEST_CASE("Strategy"){
    Comparator comparator;
    vector<int> values {23, 1, 42, 83, 52, 5, 72, 11};
    vector<int> expected {1, 5, 11, 23, 42, 52, 72, 83};

    sort(values.begin(), values.end(), comparator);

    CHECK_EQ(values, expected);
}

sort函数使用comparator对象来比较向量中的元素并对其进行排序。这是一种策略模式,因为我们可以用具有相同接口的任何东西来交换comparator;实际上,它只需要实现operator()函数。例如,我们可以想象一个用户在 UI 中选择比较函数并使用它对值列表进行排序;我们只需要在运行时创建正确的comparator实例并将其发送给sort函数。

你已经可以看到功能性解决方案的种子。事实上,sort函数允许一个更简单的版本,如下例所示:

auto compare = [](auto first, auto second) { return first < second;};

TEST_CASE("Strategy"){
    vector<int> values {23, 1, 42, 83, 52, 5, 72, 11};
    vector<int> expected {1, 5, 11, 23, 42, 52, 72, 83};

    sort(values.begin(), values.end(), compare);

    CHECK_EQ(values, expected);
}

这一次,我们放弃了仪式感,直接开始实现我们需要的东西——一个可以插入sort的比较函数。不再有类,不再有运算符——策略只是一个函数。

让我们看看这在更复杂的情境中是如何工作的。我们将使用维基百科关于策略模式的页面上的问题,并使用功能性方法来编写它。

这里有个问题:我们需要为一家酒吧编写一个计费系统,可以在欢乐时光时应用折扣。这个问题适合使用策略模式,因为我们有两种计算账单最终价格的策略——一种返回全价,而另一种返回全账单的欢乐时光折扣(在我们的例子中使用 50%)。再次,解决方案就是简单地使用两个函数来实现这两种策略——normalBilling函数只返回它接收到的全价,而happyHourBilling函数返回它接收到的值的一半。让我们在下面的代码中看看这个解决方案(来自我的测试驱动开发(TDD)方法):

map<string, double> drinkPrices = {
    {"Westmalle Tripel", 15.50},
    {"Lagavulin 18y", 25.20},
};

auto happyHourBilling = [](auto price){
    return price / 2;
};

auto normalBilling = [](auto price){
    return price;
};

auto computeBill = [](auto drinks, auto billingStrategy){
    auto prices = transformAll<vector<double>>(drinks, [](auto drink){ 
    return drinkPrices[drink]; });
    auto sum = accumulateAll(prices, 0.0, std::plus<double>());
    return billingStrategy(sum);
};

TEST_CASE("Compute total bill from list of drinks, normal billing"){
   vector<string> drinks; 
   double expectedBill;

   SUBCASE("no drinks"){
       drinks = {};
       expectedBill = 0;
   };

   SUBCASE("one drink no discount"){
       drinks = {"Westmalle Tripel"};
       expectedBill = 15.50;
   };

   SUBCASE("one another drink no discount"){
       drinks = {"Lagavulin 18y"};
       expectedBill = 25.20;
   };

  double actualBill = computeBill(drinks, normalBilling);

   CHECK_EQ(expectedBill, actualBill);
}

TEST_CASE("Compute total bill from list of drinks, happy hour"){
   vector<string> drinks; 
   double expectedBill;

   SUBCASE("no drinks"){
       drinks = {};
       expectedBill = 0;
   };

   SUBCASE("one drink happy hour"){
       drinks = {"Lagavulin 18y"};
       expectedBill = 12.60;
   };

   double actualBill = computeBill(drinks, happyHourBilling);

   CHECK_EQ(expectedBill, actualBill);
}

我认为这表明,策略的最简单实现是一个函数。我个人喜欢这种模型为策略模式带来的简单性;编写最小的有用代码使事情正常运行是一种解放。

命令模式,函数式风格

命令模式是我在工作中广泛使用的一种模式。它与 MVC 网络框架完美契合,允许将控制器分离为多个功能片段,并同时允许与存储格式分离。它的意图是将请求与动作分离开来——这就是它如此多才多艺的原因,因为任何调用都可以被视为一个请求。

命令模式的一个简单用法示例是在支持多个控制器和更改键盘快捷键的游戏中。这些游戏不能直接将W键按下事件与移动角色向上的代码关联起来;相反,您将W键绑定到MoveUpCommand,从而将两者清晰地解耦。我们可以轻松地更改与命令关联的控制器事件或向上移动的代码,而不会干扰两者之间的关系。

当我们看命令在面向对象代码中是如何实现的时,函数式解决方案变得同样明显。MoveUpCommand类将如下例所示:

class MoveUpCommand{
    public:
        MoveUpCommand(/*parameters*/){}
        void execute(){ /* implementation of the command */}
}

我说过这是显而易见的!我们实际上要做的是很容易用一个命名函数来完成,如下例所示:

auto moveUpCommand = [](/*parameters*/{
/* implementation */
};

最简单的命令模式就是一个函数。谁会想到呢?

函数依赖注入

谈论广泛传播的设计模式时,不能不提及 DI。虽然没有在《四人组》的书中定义,但这种模式在现代代码中变得如此普遍,以至于许多程序员认为它是框架或库的一部分,而不是设计模式。

DI 模式的意图是将类或函数的依赖项的创建与其行为分离。为了理解它解决的问题,让我们看看这段代码:

auto readFromFileAndAddTwoNumbers = [](){
    int first;
    int second;
    ifstream numbersFile("numbers.txt");
    numbersFile >> first;
    numbersFile >> second;
    numbersFile.close();
    return first + second;
};

TEST_CASE("Reads from file"){
    CHECK_EQ(30, readFromFileAndAddTwoNumbers());
}

如果您只需要从文件中读取两个数字并将它们相加,那么这是相当合理的代码。不幸的是,在现实世界中,我们的客户很可能需要更多的读取数字的来源,比如,如下所示,控制台:

auto readFromConsoleAndAddTwoNumbers = [](){
    int first;
    int second;
    cout << "Input first number: ";
    cin >> first;
    cout << "Input second number: ";
    cin >> second;
    return first + second;
};

TEST_CASE("Reads from console"){
    CHECK_EQ(30, readFromConsoleAndAddTwoNumbers());
}

在继续之前,请注意,此函数的测试只有在您从控制台输入两个和为30的数字时才会通过。因为它们需要在每次运行时输入,所以测试用例在我们的代码示例中被注释了;请随意启用它并进行测试。

这两个函数看起来非常相似。为了解决这种相似之处,DI 可以帮助,如下例所示:

auto readAndAddTwoNumbers = [](auto firstNumberReader, auto 
    secondNumberReader){
        int first = firstNumberReader();
        int second = secondNumberReader();
        return first + second;
};

现在我们可以实现使用文件的读取器:


auto readFirstFromFile = [](){
    int number;
    ifstream numbersFile("numbers.txt");
    numbersFile >> number;
    numbersFile.close();
    return number;
};

auto readSecondFromFile = [](){
    int number;
    ifstream numbersFile("numbers.txt");
    numbersFile >> number;
    numbersFile >> number;
    numbersFile.close();
    return number;
};

我们还可以实现使用控制台的读取器:


auto readFirstFromConsole = [](){
    int number;
    cout << "Input first number: ";
    cin >> number;
    return number;
};

auto readSecondFromConsole = [](){
    int number;
    cout << "Input second number: ";
    cin >> number;
    return number;
};

像往常一样,我们可以测试它们在各种组合中是否正确工作,如下所示:

TEST_CASE("Reads using dependency injection and adds two numbers"){
    CHECK_EQ(30, readAndAddTwoNumbers(readFirstFromFile, 
        readSecondFromFile));
    CHECK_EQ(30, readAndAddTwoNumbers(readFirstFromConsole, 
        readSecondFromConsole));
    CHECK_EQ(30, readAndAddTwoNumbers(readFirstFromFile, 
        readSecondFromConsole));
}

我们通过 lambda 注入了读取数字的代码。请注意测试代码中使用此方法允许我们随心所欲地混合和匹配依赖项——最后一个检查从文件中读取第一个数字,而第二个数字从控制台中读取。

当然,我们通常在面向对象语言中实现 DI 的方式是使用接口和类。然而,正如我们所看到的,实现 DI 的最简单方式是使用函数。

纯函数式设计模式

到目前为止,我们已经看到了一些经典面向对象设计模式如何转变为函数变体。但我们能想象出源自函数式编程的设计模式吗?

嗯,我们实际上已经使用了其中一些。map/reduce(或 STL 中的transform/accumulate)就是一个例子。大多数高阶函数(如filterall_ofany_of等)也是模式的例子。然而,我们甚至可以进一步探索一种常见但不透明的设计模式,它源自函数式编程。

理解它的最佳方法是从具体的问题开始。首先,我们将看看如何在不可变的上下文中保持状态。然后,我们将了解设计模式。最后,我们将在另一个上下文中看到它的应用。

保持状态

在函数式编程中如何保持状态?鉴于函数式编程背后的一个想法是不可变性,这似乎是一个奇怪的问题,因为不可变性似乎阻止了状态的改变。

然而,这种限制是一种幻觉。为了理解这一点,让我们想一想时间是如何流逝的。如果我戴上帽子,我就会从没戴帽子变成戴帽子。如果我能够一秒一秒地回顾过去,从我伸手拿帽子的那一刻到戴上它,我就能看到我的每一次动作是如何每秒向着这个目标前进的。但我无法改变任何过去的一秒。无论我们喜欢与否,过去是不可改变的(毕竟,也许我戴帽子看起来很傻,但我无法恢复它)。因此,自然使时间以这样的方式运行,过去是不可改变的,但我们可以改变状态。

我们如何在概念上对这进行建模?好吧,这样想一想——首先,我们有一个初始状态,亚历克斯没戴帽子,以及一个意图到达帽子并戴上的运动定义。在编程术语中,我们用一个函数来模拟运动。该函数接收手的位置和函数本身,并返回手的新位置加上函数。因此,通过模仿自然,我们得到了以下示例中的状态序列:

Alex wants to put the hat on
Initial state: [InitialHandPosition, MovementFunction (HandPosition -> next HandPosition)]
State1 = [MovementFunction(InitialHandPosition), MovementFunction]
State2 = [MovementFunction(HandPosition at State1),MovementFunction]...
Staten = [MovementFunction(HandPosition at Staten-1), MovementFunction]
until Alex has hat on

通过反复应用MovementFunction,我们最终得到一系列状态。每个状态都是不可变的,但我们可以存储状态

现在让我们看一个在 C++中的简单例子。我们可以使用的最简单的例子是一个自增索引。索引需要记住上次使用的值,并使用increment函数从索引返回下一个值。通常情况下,我们在尝试使用不可变代码实现这一点时会遇到麻烦,但我们可以用之前描述的方法做到吗?

让我们找出来。首先,我们需要用第一个值初始化自增索引——假设它是1。像往常一样,我想检查值是否初始化为我期望的值,如下所示:

TEST_CASE("Id"){
    const auto autoIncrementIndex = initAutoIncrement(1);
    CHECK_EQ(1, value(autoIncrementIndex)); 
}

请注意,由于autoIncrementIndex不会改变,我们可以将其设为const

我们如何实现initAutoIncrement?正如我们所说,我们需要初始化一个结构,其中包含当前值(在这种情况下为1)和增量函数。我将从这样的一对开始:

auto initAutoIncrement = [](const int initialId){
    function<int(const int)> nextId = [](const int lastId){
        return lastId + 1;
    };

    return make_pair(initialId, nextId);
};

至于之前的value函数,它只是返回一对中的值;它是一对中的第一个元素,如下面的代码片段所示:

auto value = [](const auto previous){
    return previous.first;
};

现在让我们计算一下我们的自增索引的下一个元素。我们初始化它,然后计算下一个值,并检查下一个值是否为2

TEST_CASE("Compute next auto increment index"){
    const auto autoIncrementIndex = initAutoIncrement(1);

    const auto nextAutoIncrementIndex = 
        computeNextAutoIncrement(autoIncrementIndex);

    CHECK_EQ(2, value(nextAutoIncrementIndex)); 
}

请再次注意,由于它们永远不会变化,所以两个autoIncrementIndex变量都是const。我们已经有了值函数,但computeNextAutoIncrement函数是什么样子的呢?好吧,它必须接受当前值和一对中的函数,将函数应用于当前值,并返回新值和函数之间的一对:

auto computeNextAutoIncrement = [](pair<const int, function<int(const 
    int)>> current){
        const auto currentValue = value(current);
        const auto functionToApply = lambda(current);
        const int newValue = functionToApply(currentValue);
        return make_pair(newValue, functionToApply);
};

我们正在使用一个实用函数lambda,它返回一对中的 lambda:

auto lambda = [](const auto previous){
    return previous.second;
};

这真的有效吗?让我们测试下一个值:

TEST_CASE("Compute next auto increment index"){
    const auto autoIncrementIndex = initAutoIncrement(1);
    const auto nextAutoIncrementIndex = 
        computeNextAutoIncrement(autoIncrementIndex);
    CHECK_EQ(2, value(nextAutoIncrementIndex)); 

 const auto newAutoIncrementIndex = 
        computeNextAutoIncrement(nextAutoIncrementIndex);
 CHECK_EQ(3, value(newAutoIncrementIndex));
}

所有的测试都通过了,表明我们刚刚以不可变的方式存储了状态!

由于这个解决方案看起来非常简单,下一个问题是——我们能否将其概括化?让我们试试看。

首先,让我们用struct替换pair。结构需要有一个值和一个计算下一个值的函数作为数据成员。这将消除我们的value()lambda()函数的需要:

struct State{
    const int value;
    const function<int(const int)> computeNext;
};

int类型会重复出现,但为什么呢?状态可能比int更复杂,所以让我们把struct变成一个模板:

template<typename ValueType>
struct State{
    const ValueType value;
    const function<ValueType(const ValueType)> computeNext;
};

有了这个,我们可以初始化一个自增索引并检查初始值:

auto increment = [](const int current){
    return current + 1;
};

TEST_CASE("Initialize auto increment"){
    const auto autoIncrementIndex = State<int>{1, increment};

    CHECK_EQ(1, autoIncrementIndex.value); 
}

最后,我们需要一个计算下一个State的函数。该函数需要返回一个State<ValueType>,所以最好将其封装到State结构中。此外,它可以使用当前值,因此无需将值传递给它:

template<typename ValueType>
struct State{
    const ValueType value;
    const function<ValueType(const ValueType)> computeNext;

 State<ValueType> nextState() const{
 return State<ValueType>{computeNext(value), computeNext};
 };
};

有了这个实现,我们现在可以检查我们的自动增量索引的下两个值:

TEST_CASE("Compute next auto increment index"){
    const auto autoIncrementIndex = State<int>{1, increment};

    const auto nextAutoIncrementIndex = autoIncrementIndex.nextState();

    CHECK_EQ(2, nextAutoIncrementIndex.value); 

    const auto newAutoIncrementIndex = 
        nextAutoIncrementIndex.nextState();
    CHECK_EQ(3, newAutoIncrementIndex.value);
}

测试通过了,所以代码有效!现在让我们再玩一会儿。

假设我们正在实现一个简单的井字棋游戏。我们希望在移动后使用相同的模式来计算棋盘的下一个状态。

首先,我们需要一个可以容纳 TicTacToe 棋盘的结构。为简单起见,我将使用vector<vector<Token>>,其中Token是一个可以容纳BlankXO值的enum

enum Token {Blank, X, O};
typedef vector<vector<Token>> TicTacToeBoard;

然后,我们需要一个Move结构。Move结构需要包含移动的棋盘坐标和用于进行移动的标记:

struct Move{
    const Token token;
    const int xCoord;
    const int yCoord;
};

我们还需要一个函数,它可以接受一个TicTacToeBoard,应用一个移动,并返回新的棋盘。为简单起见,我将使用本地变异来实现它,如下所示:

auto makeMove = [](const TicTacToeBoard board, const Move move) -> 
    TicTacToeBoard {
        TicTacToeBoard nextBoard(board);
        nextBoard[move.xCoord][move.yCoord] = move.token;
         return nextBoard;
};

我们还需要一个空白的棋盘来初始化我们的State。让我们手工填充Token::Blank

const TicTacToeBoard EmptyBoard{
    {Token::Blank,Token::Blank, Token::Blank},
    {Token::Blank,Token::Blank, Token::Blank},
    {Token::Blank,Token::Blank, Token::Blank}
};

我们想要进行第一步移动。但是,我们的makeMove函数不符合State结构允许的签名;它需要一个额外的参数,Move。首先,我们可以将Move参数绑定到一个硬编码的值。假设X移动到左上角,坐标为(0,0)

TEST_CASE("TicTacToe compute next board after a move"){
    Move firstMove{Token::X, 0, 0};
    const function<TicTacToeBoard(const TicTacToeBoard)> makeFirstMove 
        = bind(makeMove, _1, firstMove);
    const auto emptyBoardState = State<TicTacToeBoard>{EmptyBoard, 
        makeFirstMove };
    CHECK_EQ(Token::Blank, emptyBoardState.value[0][0]); 

    const auto boardStateAfterFirstMove = emptyBoardState.nextState();
    CHECK_EQ(Token::X, boardStateAfterFirstMove.value[0][0]); 
}

如你所见,我们的State结构在这种情况下运行良好。但是,它有一个限制:它只允许一次移动。问题在于计算下一个阶段的函数不能更改。但是,如果我们将其作为参数传递给nextState()函数呢?我们最终得到了一个新的结构;让我们称之为StateEvolved。它保存一个值和一个nextState()函数,该函数接受计算下一个状态的函数,应用它,并返回下一个StateEvolved

template<typename ValueType>
struct StateEvolved{
    const ValueType value;
    StateEvolved<ValueType> nextState(function<ValueType(ValueType)> 
        computeNext) const{
            return StateEvolved<ValueType>{computeNext(value)};
    };
};

现在我们可以通过将makeMove函数与绑定到实际移动的Move参数一起传递给nextState来进行移动:

TEST_CASE("TicTacToe compute next board after a move with 
    StateEvolved"){
    const auto emptyBoardState = StateEvolved<TicTacToeBoard>
        {EmptyBoard};
    CHECK_EQ(Token::Blank, emptyBoardState.value[0][0]); 
    auto xMove = bind(makeMove, _1, Move{Token::X, 0, 0});
    const auto boardStateAfterFirstMove = 
        emptyBoardState.nextState(xMove);
    CHECK_EQ(Token::X, boardStateAfterFirstMove.value[0][0]); 
}

我们现在可以进行第二步移动。假设O移动到坐标(1,1)的中心。让我们检查前后状态:

    auto oMove = bind(makeMove, _1, Move{Token::O, 1, 1});
    const auto boardStateAfterSecondMove = 
        boardStateAfterFirstMove.nextState(oMove);
    CHECK_EQ(Token::Blank, boardStateAfterFirstMove.value[1][1]); 
    CHECK_EQ(Token::O, boardStateAfterSecondMove.value[1][1]); 

正如你所看到的,使用这种模式,我们可以以不可变的方式存储任何状态。

揭示

我们之前讨论的设计模式对函数式编程似乎非常有用,但你可能已经意识到我一直在避免命名它。

事实上,到目前为止我们讨论的模式是单子的一个例子,具体来说是State单子。我一直避免告诉你它的名字,因为单子在软件开发中是一个特别晦涩的话题。对于这本书,我观看了数小时的单子视频;我还阅读了博客文章和文章,但出于某种原因,它们都无法理解。由于单子是范畴论中的一个数学对象,我提到的一些资源采用数学方法,并使用定义和运算符来解释它们。其他资源尝试通过示例来解释,但它们是用具有对单子模式的本地支持的编程语言编写的。它们都不符合我们这本书的目标——对复杂概念的实际方法。

要更好地理解单子,我们需要看更多的例子。最简单的例子可能是Maybe单子。

也许

考虑尝试在 C++中计算以下表达式:

2  + (3/0) * 5

可能会发生什么?通常会抛出异常,因为我们试图除以0。但是,有些情况下,我们希望看到一个值,比如NoneNaN,或者某种消息。我们已经看到,我们可以使用optional<int>来存储可能是整数或值的数据;因此,我们可以实现一个返回optional<int>的除法函数,如下所示:

    function<optional<int>(const int, const int)> divideEvenWith0 = []
      (const int first, const int second) -> optional<int>{
        return (second == 0) ? nullopt : make_optional(first / second);
    };

然而,当我们尝试在表达式中使用divideEvenWith0时,我们意识到我们还需要改变所有其他操作符。例如,我们可以实现一个plusOptional函数,当任一参数为nullopt时返回nullopt,否则返回值,如下例所示:

    auto plusOptional = [](optional<int> first, optional<int> second) -
        > optional<int>{
            return (first == nullopt || second == nullopt) ? 
                nullopt :
            make_optional(first.value() + second.value());
    };

虽然它有效,但这需要编写更多的函数和大量的重复。但是,嘿,我们能写一个函数,它接受一个function<int(int, int)>并将其转换为function<optional<int>(optional<int>, optional<int>)吗?当然,让我们编写以下函数:

    auto makeOptional = [](const function<int(int, int)> operation){
        return operation -> optional<int>{
            if(first == nullopt || second == nullopt) return nullopt;
            return make_optional(operation(first.value(), 
                second.value()));
        };
    };

这很好地运行了,如下所示通过了测试:

    auto plusOptional = makeOptional(plus<int>());
    auto divideOptional = makeOptional(divides<int>());

    CHECK_EQ(optional{3}, plusOptional(optional{1}, optional{2}));
    CHECK_EQ(nullopt, plusOptional(nullopt, optional{2}));

    CHECK_EQ(optional{2}, divideOptional(optional{2}, optional{1}));
    CHECK_EQ(nullopt, divideOptional(nullopt, optional{1}));

然而,这并没有解决一个问题——当除以0时,我们仍然需要返回nullopt。因此,以下测试将失败如下:

//    CHECK_EQ(nullopt, divideOptional(optional{2}, optional{0}));
//    cout << "Result of 2 / 0 = " << to_string(divideOptional
        (optional{2}, optional{0})) << endl;

我们可以通过使用我们自己的divideEvenBy0方法来解决这个问题,而不是使用标准的除法:

    function<optional<int>(const int, const int)> divideEvenWith0 = []
      (const int first, const int second) -> optional<int>{
        return (second == 0) ? nullopt : make_optional(first / second);
    };

这次,测试通过了,如下所示:

    auto divideOptional = makeOptional(divideEvenWith0);

    CHECK_EQ(nullopt, divideOptional(optional{2}, optional{0}));
    cout << "Result of 2 / 0 = " << to_string(divideOptional
        (optional{2}, optional{0})) << endl;

此外,运行测试后的显示如下:

Result of 2 / 0 = None

我不得不说,摆脱除以0的暴政并得到一个结果有一种奇怪的满足感。也许这只是我。

无论如何,这引导我们来定义Maybe单子。它存储一个值和一个名为apply的函数。apply函数接受一个操作(plus<int>()minus<int>()divideEvenWith0,或multiplies<int>()),以及一个要应用操作的第二个值,并返回结果:

template<typename ValueType>
struct Maybe{
    typedef function<optional<ValueType>(const ValueType, const 
        ValueType)> OperationType;
    const optional<ValueType> value;

    optional<ValueType> apply(const OperationType operation, const 
        optional<ValueType> second){
            if(value == nullopt || second == nullopt) return nullopt;
            return operation(value.value(), second.value());
    }
};

我们可以使用Maybe单子来进行计算如下:

TEST_CASE("Compute with Maybe monad"){
    function<optional<int>(const int, const int)> divideEvenWith0 = []
      (const int first, const int second) -> optional<int>{
        return (second == 0) ? nullopt : make_optional(first / second);
    };

    CHECK_EQ(3, Maybe<int>{1}.apply(plus<int>(), 2));
    CHECK_EQ(nullopt, Maybe<int>{nullopt}.apply(plus<int>(), 2));
    CHECK_EQ(nullopt, Maybe<int>{1}.apply(plus<int>(), nullopt));

    CHECK_EQ(2, Maybe<int>{2}.apply(divideEvenWith0, 1));
    CHECK_EQ(nullopt, Maybe<int>{nullopt}.apply(divideEvenWith0, 1));
    CHECK_EQ(nullopt, Maybe<int>{2}.apply(divideEvenWith0, nullopt));
    CHECK_EQ(nullopt, Maybe<int>{2}.apply(divideEvenWith0, 0));
    cout << "Result of 2 / 0 = " << to_string(Maybe<int>
        {2}.apply(divideEvenWith0, 0)) << endl;
}

再次,我们可以计算表达式,即使有nullopt

那么单子是什么?

单子是一种模拟计算的函数式设计模式。它来自数学;更确切地说,来自称为范畴论的领域。

什么是计算?基本计算是一个函数;但是,我们有兴趣为函数添加更多的行为。我们已经看到了维护状态和允许可选类型操作的两个例子,但是单子在软件设计中是相当普遍的。

单子基本上有一个值和一个高阶函数。为了理解它们的作用,让我们来比较以下代码中显示的State单子:

template<typename ValueType>
struct StateEvolved{
    const ValueType value;

    StateEvolved<ValueType> nextState(function<ValueType(ValueType)> 
        computeNext) const{
            return StateEvolved<ValueType>{computeNext(value)};
    };
};

使用此处显示的Maybe单子:

template<typename ValueType>
struct Maybe{
    typedef function<optional<ValueType>(const ValueType, const 
        ValueType)> OperationType;
    const optional<ValueType> value;

    optional<ValueType> apply(const OperationType operation, const 
        optional<ValueType> second) const {
            if(value == nullopt || second == nullopt) return nullopt;
            return operation(value.value(), second.value());
    }
};

它们都包含一个值。该值封装在单子结构中。它们都包含一个对该值进行计算的函数。apply/nextState(在文献中称为bind)函数本身接收一个封装计算的函数;但是,单子除了计算之外还做了一些其他事情。

单子还有更多的内容,不仅仅是这些简单的例子。但是,它们展示了如何封装某些计算以及如何消除某些类型的重复。

值得注意的是,C++中的optional<>类型实际上是受到了Maybe单子的启发,以及承诺,因此您可能已经在代码中使用了等待被发现的单子。

总结

在本章中,我们学到了很多关于改进设计的知识。我们了解到重构意味着重构代码而不改变程序的外部行为。我们看到为了确保行为的保留,我们需要采取非常小的步骤和测试。我们了解到遗留代码是我们害怕改变的代码,为了为其编写测试,我们需要首先更改代码,这导致了一个困境。我们还学到,幸运的是,我们可以对代码进行一些小的更改,这些更改保证了行为的保留,但打破了依赖关系,从而允许我们通过测试插入代码。然后我们看到,我们可以使用纯函数来识别和打破依赖关系,从而导致我们可以根据内聚性将它们重新组合成类。

最后,我们了解到我们可以在函数式编程中使用设计模式,并且看到了一些例子。即使您不使用函数式编程的其他内容,使用策略、命令或注入依赖等函数将使您的代码更容易进行最小干扰的更改。我们提到了一个非常抽象的设计模式,单子,以及我们如何使用Maybe单子和State单子。这两者都可以在我们的写作中帮助我们更少的代码实现更丰富的功能。

我们已经讨论了很多关于软件设计的内容。但是函数式编程是否适用于架构?这就是我们将在下一章中讨论的内容——事件溯源。

第十三章:不变性和架构 - 事件溯源

事件溯源是一种利用不变性进行存储的架构模式。事件溯源的基本思想是,与其存储数据的当前状态,不如存储修改数据的事件。这个想法可能看起来很激进,但并不新颖;事实上,您已经在使用基于这一原则的工具——例如 Git 等源代码控制系统遵循这种架构。我们将更详细地探讨这个想法,包括其优点和缺点。

本章将涵盖以下主题:

  • 不变性的概念如何应用于数据存储

  • 事件溯源架构的外观

  • 在决定是否使用事件溯源时需要考虑的因素

技术要求

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

代码可以在 GitHub 上找到https:/​/​github.​com/​PacktPublishing/​Hands-​On-​Functional-Programming-​with-​CppChapter13文件夹中。它包括并使用了doctest,这是一个单头开源单元测试库。您可以在其 GitHub 存储库中找到它https:/​/github.​com/​onqtam/​doctest

不变性和架构 - 事件溯源

直到 2010 年左右,数据存储的选择相当有限。无论您偏好的是 Oracle、MySQL 还是 PostgreSQL,您几乎都必须使用关系模型来存储数据。

然后,突然间,大量新的数据库引擎如雨后春笋般出现,对关系数据的支持部分或完全不足。它们如此不同,以至于无法进行积极的分类,因此世界最终以它们不做的事情来命名它们——NoSQL 数据库。事实上,它们唯一的共同点是对 SQL 的支持很少或根本没有。引擎的列表很长且不断变化,但在撰写本文时,一些引擎很普遍,包括 Redis、MongoDB、DynamoDb、Cassandra 和 Couchbase 等。每个引擎都有其自身的优势和劣势,它们出现的原因是为了优化各种场景,通常是在云计算的背景下。例如,Cassandra 具有高度分布式,而 MongoDB 允许轻松存储多种类型的数据。

大约在我听说 NoSQL 的同时,我开始听说一种称为事件溯源的新架构模式。与通常的 UI 服务器关系数据库模式相比,事件溯源对数据存储采取了一种根本不同的方法。事件溯源模式认为,与其存储系统的当前状态,不如我们将系统的增量更改编码为领域事件进行存储。

敏锐的读者会注意到这个想法的两个方面:

  • 这听起来像是领域驱动设计DDD)运动中的产物,事实上确实如此。领域事件可以作为我们在架构和领域模型演进中使用的另一种模式。

  • 尽管对于业务应用程序来说,在数据存储中存储增量更改的想法可能是激进的,但在软件架构中并不新鲜。事实上,在撰写本书的过程中,我一直在使用基于这种模式的工具。您可能也使用它来获取代码示例。虽然使用了比我们将在事件溯源中讨论的历史更复杂的模型,但 Git 将增量更改与代码的当前状态一起存储。

Git 并不是唯一使用这种模式的工具。多年来,我们一直在运维中使用这样的工具进行数据备份。由于完整备份可能需要很长时间,一个好的策略是将频繁的增量备份与不经常的完整备份混合使用。然而,诀窍在于,当需要恢复时,我们可以依次应用增量备份,达到与完整备份相同的状态。这是在备份所需的时间和存储空间以及恢复备份所需的时间之间的一个很好的权衡。

到这一点,你可能会想知道事件溯源与 NoSQL 数据库有什么关系,除了与存储相关?虽然我无法证明,但我相信这两个想法都来自于 2010 年代围绕编程的思想潮流——通过消除技术障碍来优化开发速度,并为各种网络和基于云的架构优化系统。

让我们来思考一下 Twitter。在数据流方面,Twitter 有两个主要功能——发布消息和查看其他用户发布的消息。如果你不能立即看到另一个用户发布的消息,你甚至都不会知道,因此允许高延迟。然而,我们不希望丢失数据,所以需要尽快将用户消息存储起来。

实现这样的功能的标准方式是在请求时直接将消息保存到数据库中,并在响应时返回更新后的消息源。这使我们能够立即看到消息,但它也有一些缺点。首先,它使数据库成为瓶颈,因为每条发布的消息都执行了INSERTSELECT语句。其次,它需要更多的服务器资源,从而增加了基于云的服务器成本。

如果我们换个思路呢?当你发布一条消息时,我们只是将事件保存到一个快速事件存储中,并立即返回。在未来的请求中更新消息源时,事件会被考虑进去,并返回更新后的消息源。数据存储不再是瓶颈,我们减少了服务器负载。然而,我们在系统中增加了一个新元素,即事件存储,这可能会增加一些成本,但事实证明,在高规模下,这可能比另一种选择更便宜、更响应。这是事件溯源的一个例子。

另一个选择是在数据引擎层解决这个问题,并像之前提到的那样分离写入和读取;然而,我们使用的数据存储是为写入进行了优化。缺点是数据的可读性比以前更高延迟,但这没关系。在未来的某个时候,数据变得可用,消息源也会更新。这是使用 NoSQL 数据库而不是关系数据库管理系统的一个例子。

2010 年代确实非常有趣,引发了软件架构和设计领域的许多新想法,同时将函数式编程引入了主流编程语言。顺便说一句,这个时期还因漫威电影宇宙(MCU)的一系列超级英雄电影而变得有趣。这两者之间没有联系,我只是喜欢漫威电影宇宙!然而,我必须停止对软件设计历史和漫威电影宇宙的狂热追捧,转而讨论另一个奇怪的想法——将不可变性引入数据存储。

将不可变性引入架构

我们已经看到不可变性对代码结构有深远影响,因此也对软件设计产生影响。我们还多次讨论过,I/O 基本上是可变的。我们将要展示的是,数据存储不一定是可变的,不可变的数据存储也对架构产生深远影响。

数据存储如何做到不可变?毕竟,许多软件应用的整个目的就是 CRUD——创建、检索、更新和删除。唯一不改变数据的操作是检索,尽管在某些情况下,检索数据可能会产生额外的副作用,如分析或日志记录。

然而,要记住我们面临着与数据结构相同的问题。可变数据结构在添加或删除元素时会改变其结构。然而,纯函数式语言支持不可变数据结构。

不可变数据结构具有以下特性——添加或删除项目不会改变数据结构。相反,它会返回初始数据结构的副本以及变化。为了优化内存,纯函数式编程语言实际上并不克隆数据,它们只是巧妙地利用指针来重用现有的内存。然而,对于程序员来说,就好像数据结构已经完全被克隆了。

考虑将相同的想法应用于存储。与其改变现有数据,每次写入或删除都会创建一个应用了变化的新版本的数据,同时保留之前的版本不变。想象一下可能性;我们得到了数据变化的整个历史,我们总是可以恢复它们,因为我们有一个非常近期的数据版本。

不过这并不容易。存储的数据往往很大,在每次变化时复制它将占用大量的存储空间,并且在这个过程中变得极其缓慢。与内存数据一样,同样的优化技术并不奏效,因为存储的数据往往更加复杂,而指针在文件系统中并不(还没有?)那么容易管理。

幸运的是,还有一种选择——一开始存储数据的版本,然后只存储数据的一些变化。我们可以在关系数据库中实现这一点(毕竟这些变化只是实体),但幸运的是,我们不必这样做。为了支持这种存储模型,一些被称为事件存储的存储引擎已经被实现。它们允许我们存储事件,并在需要时获取数据的最新版本。

这样的系统会如何运作呢?嗯,我们需要对领域和领域事件进行建模。让我们以 Twitter 为例来做这个。

如果我们使用传统的数据存储,我们只会以某种方式保存实体,但我们想要存储事件,所以我们将会有一个长长的增量变化列表,概念上看起来像这样:

CreateUser name:alexboly -> userid 1
CreateUser name: johndoe -> userid 2
PostMessage userid: 1, message: 'Hello, world!' -> messageid 1
PostMessage userid: 2, message: 'Hi @alexboly' -> messageid 2
CreateNotification userid: 1, notification: "Message from johndoe"
PostMessage userid: 1, message: 'Hi @johndoe' -> messageid 3
CreateNotification userid: 2, notification: "Message from alexboly"
LikeMessage userid: 2, messageid: 3
...

在我们继续看一个实现的例子之前,我们需要记住我们正在讨论软件架构,没有解决方案是完美的。因此,我们必须停下来考虑一下在使用事件溯源时所做的权衡。

事件溯源的优势

如果事件溯源没有优势,我们就不会谈论它。

在概念层面,领域模型和领域事件可以很快地从领域专家那里提取出来,而且可以在非常快速、轻量级的会话中完成。事件风暴是一个促进会话,允许我们通过技术和领域专家之间的合作在几小时内设计一个复杂的系统。在这个事件中创造的知识不容小觑;这种共同的理解是知识工作中复杂努力中任何领域之间合作的强有力基础。

在软件设计层面,事件溯源比其他代码结构更好地揭示了意图。领域操作往往隐藏在实体内部;而在事件溯源中,领域模型的变化成为了架构的核心。我们实际上可以搜索数据可能经历的所有变化,并获得一个列表——这对其他代码结构来说是很困难的。

在编码层面,事件溯源简化了编程。虽然一开始可能很难以事件的方式思考,但它很快就会变得很自然。这种模型允许我们编写反映最重要业务特性的代码,从而使程序员和产品所有者或客户之间的理解更加容易。它还很好地封装了每种类型的变化,从而简化了我们的测试和代码。

在数据存储级别上,事件溯源允许我们查看对数据所做的更改列表,这对于其他数据存储模型来说是一个极端的壮举。增量备份在这种模型中更合适,因为它基本上是增量的。恢复内置于数据存储中,允许我们从任何过去的具体化存储开始,并应用所有事件。

此外,事件溯源允许我们回到过去。如果每个事件都有一个相反的事件,通常很容易做到,我们可以从末尾播放相反的事件到特定的时间戳,从而导致我们在那个时间点拥有的确切数据。

在性能水平上,事件溯源优化了数据的写入,使其对于大多数需要快速写入但可以处理读取延迟的应用程序非常有用(也被称为大多数基于 Web 的系统)。

但没有什么是免费的,那么什么可能出错呢?

事件溯源的缺点和注意事项

尽管事件溯源具有诸多优势,但在跳上这辆车之前,你需要考虑一些重要的缺点。

更改事件模式

第一个问题来自事件溯源的核心模型——如果我们需要在已经有大量数据的情况下更改事件的结构会怎样?例如,如果我们需要为每个事件添加时间戳怎么办?或者如果我们需要更改我们的PostMessage事件以包括一个可见性字段,该字段只能是接收者、只有关注者或所有人?

这个问题有解决方案,但每个解决方案都有自己的问题。一个解决方案是对事件模式进行版本控制,并且并排使用多个模式,这样做虽然有效,但会使具体化变得复杂。另一个解决方案是使用数据迁移脚本来更改过去的事件,但这会破坏不可变性的概念,而且必须做得正确。另一个选择是永远不更改事件模式,只是添加新的事件类型,但这可能会因多个已弃用的事件类型而导致混乱。

删除过去的数据

第二个问题是隐私。最近在欧洲联盟EU)颁布的通用数据保护条例GDPR)影响了世界各地许多软件系统,赋予用户要求从系统中完全删除私人数据的权利。在使用普通数据库时,这相对容易——只需删除与用户 ID 相关的记录——但在事件存储中该如何做呢?

我们可以从删除与用户相关的所有事件开始。但我们能这样做吗?如果事件具有时间关系,我们可能会遇到问题。例如,想象一下协同编辑文档的以下场景:

CreateAuthor alexboly => authorid 1
CreateAuthor johndoe => authorid 2
...
AddText index: 2400, authorid:1, text: "something interesting here."
AddText index: 2427, authorid:2, text: "yes, that's interesting" => 
    "something interesting here. yes that's interesting"
DeleteText index: 2400, length: 10, authorid: 1 =>"interesting here. 
    yes that's interesting"
...

如果用户alexboly要求我们删除事件,让我们标记需要删除的事件:

CreateAuthor alexboly => authorid 1
CreateAuthor johndoe => authorid 2
...
AddText index: 2400, authorid:1, text: "something interesting here."
AddText index: 2427, authorid:2, text: "yes, that's interesting" => 
    "something interesting here. yes that's interesting"
DeleteText index: 2400, length: 10, authorid: 1 =>"interesting here. 
    yes that's interesting"
...

你看到问题了吗?如果我们删除了突出显示的事件,不仅会丢失文档中的数据,而且索引也不再匹配!按顺序应用事件到空白文档将导致错误或损坏的数据。

我们可以做一些事情:

  • 一个解决方案是删除用户的身份但保留数据。虽然这在特定情境下可能有效,但这个解决方案取决于删除请求的范围。有一种特殊情况,即用户已将个人数据(例如地址、电子邮件地址或 ID 号码)添加到文档中。如果我们删除了用户的身份,但也需要删除个人数据,我们将需要扫描所有事件以查找个人数据,并删除或用相同数量的空白字符替换它。

  • 另一个解决方案是具体化数据库,删除数据,并从新的检查点开始处理未来事件。这破坏了事件溯源的核心理念之一——从空存储重建数据的能力——对于具有许多事件或许多删除的系统来说可能会很困难。但通过适当的规划和结构是可能的。

  • 第三种解决方案是利用架构并使用DeletePrivateData的特殊事件。但是,这个事件不同,因为它将改变事件存储而不是数据。虽然它符合架构,但它是有风险的,并且需要广泛的测试,因为它可能会破坏一切。

  • 第四种解决方案是设计事件,使它们不是时间上耦合的。从理论上讲,这听起来不错,但我们必须承认在实践中可能并不总是可能的。在前面的例子中,我们需要一些文本的位置,我向你挑战找到一种独立于现有文本的指定位置的方法。还要考虑到,我们将在一个罕见的情况下进行这种设计工作,这可能使所有事件都不那么容易理解。如果可以通过最小的更改实现,那就太好了;但如果不能,你就需要自己做出决定。

实现示例

接下来我们将看一个使用事件源的简单实现示例。我们将从我们的 Twitter 示例开始,然后开始编写一些测试。

首先,让我们创建一个用户,并在伪代码中检查正确的事件存储:

TEST_CASE("Create User"){
    EventStore eventStore;
    ...
    auto alexId = createUser("alexboly", eventStore);
    ...
    CHECK_EQ(lastEvent, expectedEvent);
}

我们需要一些东西来编译这个测试。首先,一个可以存储事件的事件存储,但是如何表示可以存储的事件呢?我们需要一种可以保存属性名称和值的数据结构。最简单的是一个map<string, string>结构,它将属性的名称映射到它们的值。为了看到它的作用,让我们为CreateUser创建事件结构:

auto makeCreateUserEvent = [](const string& handle, const int id){
    return map<string, string>{
            {"type", "CreateUser"}, 
            {"handle", handle}, 
            {"id", to_string(id)}
    };
};

CreateUser事件有一个类型,CreateUser,并且需要一个句柄,例如alexboly,以及用户的id。让我们使用typedef使其更加友好和明确:

typedef map<string, string> Event;
auto makeCreateUserEvent = [](const string& handle, const int id){
    return Event{
            {"type", "CreateUser"}, 
            {"handle", handle}, 
            {"id", to_string(id)}
    };
};

现在我们可以创建我们的EventStore。因为它基本上是一个事件列表,让我们直接使用它:

class EventStore : public list<Event>{
    public:
        EventStore() : list<Event>(){
        };
};

所以,现在我们的测试可以使用EventStoremakeCreateUserEvent函数来检查,在调用createUser后,正确的事件将在事件存储中:

TEST_CASE("Create User"){
    auto handle = "alexboly";
    EventStore eventStore;

    auto alexId = createUser(handle, eventStore);

    auto expectedEvent = makeCreateUserEvent(handle, alexId);
    auto event = eventStore.back();
    CHECK_EQ(event, expectedEvent);
}

我们现在只需要为这个测试实现createUser。这很简单;调用makeCreateUserEvent并将结果添加到EventStore。我们需要一个id,但由于我们现在只有一个元素,让我们使用一个硬编码值1

int id = 1;
auto createUser = [](string handle, EventStore& eventStore){
    eventStore.push_back(makeCreateUserEvent(handle, id));
    return id;
};

测试通过了;现在我们可以执行事件,并它们将进入事件存储。

现在让我们看看新用户如何发布消息。我们将需要第二种事件类型PostMessage,以及类似的代码基础设施。让我们编写测试。首先,我们需要创建一个用户。其次,我们需要创建一个通过userId与用户关联的消息。以下是测试:

TEST_CASE("Post Message"){
    auto handle = "alexboly";
    auto message = "Hello, world!";
    EventStore eventStore;

    auto alexId = createUser(handle, eventStore);
    auto messageId = postMessage(alexId, message, eventStore);
    auto expectedEvent = makePostMessageEvent(alexId, message, 
        messageId);
    auto event = eventStore.back();
    CHECK_EQ(event, expectedEvent);
}

makePostMessageEvent函数将只创建一个带有所有必需信息的Event结构。它还需要一个类型和messageId

auto makePostMessageEvent = [](const int userId, const string& message, int id){
    return Event{
            {"type", "PostMessage"}, 
            {"userId", to_string(userId)}, 
            {"message", message},
            {"id", to_string(id)}
    };
};

最后,postMessage只需将makePostMessageEvent的结果添加到EventStore中。我们再次需要一个 ID,但我们只有一条消息,所以我们可以使用相同的 ID1

auto postMessage = [](const int userId, const string& message, 
    EventStore& eventStore){
      eventStore.push_back(makePostMessageEvent(userId, message, id));
      return id;
};

所以,现在我们有一个用户可以通过事件发布消息。这相当不错,也没有像一开始看起来那么困难。

这个实现提出了一些有趣的问题。

你如何检索数据?

首先,如果我想通过他们的句柄或id搜索用户怎么办?这是 Twitter 上的一个真实使用场景。如果我在消息中提到另一个用户@alexboly,通知应该发布到具有句柄alexboly的用户。此外,我想在时间轴上显示与用户@alexboly相关的所有消息。

对此我有两个选择。第一个选择是仅存储事件,并在读取数据时运行所有事件。第二个选择是维护一个具有当前值的域存储,并像任何其他数据库一样查询它。重要的是要注意,这些存储中的每一个或两个可能都是内存中的,以便非常快速地访问。

无论当前值是缓存还是计算得出的,我们都需要一种执行事件并获取它们的方法。我们怎么做呢?

让我们编写一个测试来描述我们需要的内容。在运行一个或多个事件之后,我们需要执行这些事件并获取当前值,以便在需要时检索它们:

TEST_CASE("Run events and get the user store"){
    auto handle = "alexboly";
    EventStore eventStore;

    auto alexId = createUser(handle, eventStore);
    auto dataStore = eventStore.play();

    CHECK_EQ(dataStore.users.back(), User(alexId, handle));
}

为了使测试通过,我们需要一些东西。首先,一个User领域对象,我们将保持非常简单:

class User{
    public:
        int id;
        string handle;
        User(int id, string handle): id(id), handle(handle){};
};

其次,一个包含users列表的数据存储:

class DataStore{
    public:
        list<User> users;
};

最后,play机制。现在让我们先使用一个丑陋的实现:

  class EventStore : public list<Event>{
    public:
       DataStore play(){
            DataStore dataStore;
            for(Event event :  *this){
                if(event["type"] == "CreateUser"){
                    dataStore.users.push_back(User(stoi(event["id"]), 
                        event["handle"]));
                }
            };
            return dataStore;
        };
}

了解高阶函数后,我们当然可以看到我们在前面的片段中的for语句可以转换为函数式方法。实际上,我们可以通过调用transform将所有事件按CreateUser类型进行过滤,然后将每个事件转换为实体。首先,让我们提取一些较小的函数。我们需要一个将CreateUser事件转换为用户的函数:

auto createUserEventToUser = [](Event event){
    return User(stoi(event["id"]), event["handle"]);
};

我们还需要另一个函数,它可以按类型过滤事件列表:

auto createUserEventToUser = [](Event event){
    return User(stoi(event["id"]), event["handle"]);
};

现在我们可以提取一个playEvents函数,它接受一个事件列表,按类型进行过滤,并运行转换,得到一个实体列表:

template<typename Entity>
auto playEvents = [](const auto& events, const auto& eventType, 
    auto playEvent){
      list<Event> allEventsOfType;
      auto filterEventByThisEventType = bind(filterEventByEventType, 
        _1, eventType);
      copy_if(events.begin(),events.end(),back_insert_iterator
        (allEventsOfType), filterEventByThisEventType);
      list<Entity> entities(allEventsOfType.size());
      transform(allEventsOfType.begin(), allEventsOfType.end(),    
        entities.begin(), playEvent); 
      return entities;
};

现在我们可以在我们的EventStore中使用这个函数来替换CreateUser的处理,并将其泛化到其他事件中:

class EventStore : public list<Event>{
    public:
        EventStore() : list<Event>(){
        };
        DataStore play(){
            DataStore dataStore;
            dataStore.users = playEvents<User>(*this, "CreateUser", 
                createUserEventToUser);
            return dataStore;
        };
};

我们现在有了一种根据事件从我们的存储中检索数据的方法。是时候看看下一个问题了。

引用完整性怎么样?

到目前为止,我们已经看到了在使用事件时实体之间的关系是基于 ID 的,但是如果我们使用错误的id调用事件会怎样?看看下面片段中的例子:

CreateUser handle:alexboly -> id 1
DeleteUser id: 1
PostMessage userId: 1, text: "Hello, world!" -> user with id 1 doesn't 
                                                exist anymore

我看到了这个问题的几个解决方案:

  • 第一个解决方案是无论如何都运行事件。如果这不会在显示上创建额外的问题,那么这将起作用。在 Twitter 上,如果我看到一条消息,我可以导航到发布消息的用户。在这种情况下,导航将导致一个不存在的页面。这是一个问题吗?我认为对于像 Twitter 这样的东西,这不是一个很大的问题,只要它不经常发生,但你必须在你自己产品的上下文中判断它。

  • 第二个解决方案是在没有任何检查的情况下运行事件,但运行一个重复的作业来检查引用问题并清理它们(通过事件,当然)。这种方法允许您最终使用事件源清理数据,而不会通过完整性检查减慢更新。再次,您需要弄清楚这在您的上下文中是否起作用。

  • 第三种解决方案是在每次事件运行时运行完整性检查。虽然这可以确保引用完整性,但也会减慢一切速度。

检查可以通过两种方式进行——要么通过检查数据存储,要么通过检查事件存储。例如,你可以检查DeleteUser的 ID1从未发生过,或者它没有在CreateUser之后发生过(但你需要用户句柄)。

在选择事件源应用程序时请记住这一点!

总结

事件源是一种不可变数据存储方法,从一个简单的想法开始——我们存储导致当前状态的所有事件,而不是存储世界的当前状态?这种方法的优势很多,也很有趣——能够在时间上前进和后退,内置增量备份,并且以时间线而不是状态来思考。它也有一些注意事项——删除过去的数据非常困难,事件模式很难更改,引用完整性往往变得更松散。您还需要注意可能的错误,并定义处理它们的结构化和可重复的策略。

我们还看到了如何使用 lambda 作为事件实现简单的事件源架构。我们还可以看一下用于存储 lambda 的事件源,因为存储的事件基本上是一个命令模式,而命令模式的最简单实现是 lambda。好奇的读者可以尝试将事件序列化/反序列化为 lambda,并看看它如何改变设计。

像任何架构模式一样,我的建议是仔细考虑权衡,并对实施中提出的最重要的挑战有答案。如果您选择尝试事件溯源,我还建议您尝试一个成熟的事件存储,而不是自己构建一个。本章中我们编写的事件存储对展示事件溯源的核心原则和挑战很有用,但远未准备好投入生产使用。

现在是时候转向 C ++中函数式编程的未来了。在下一章中,我们将介绍 C ++ 17 中现有的函数式编程特性,并了解关于 C ++ 20 的最新消息。

第四部分:C++中函数式编程的现在和未来

我们已经学习了很多在函数式编程中可以使用的技术,从基本构建模块,到我们可以以以函数为中心的风格进行设计的方式,再到我们如何可以利用函数式编程来实现各种目标。现在是时候看看标准 C++ 17 和 20 中函数式编程的现在和未来了。

我们将首先使用令人惊叹的 Ranges 库进行实践,该库作为 C++ 17 的外部实现和 C++ 20 标准的一部分。我们将看到一个简单的想法,以轻量级的方式包装现有容器,结合组合运算符和我们广泛使用的高阶函数的新方法,使我们能够编写比标准 C++ 17 中的替代方案更简单、更快和更轻的代码。

然后,我们将讨论 STL 支持并看看接下来会发生什么。最后,我们将看一下函数式编程的主要构建模块以及它们在 C++中的支持情况。

本节将涵盖以下章节:

  • 第十四章,使用 Ranges 库进行惰性求值

  • 第十五章,STL 支持和提案

  • 第十六章,标准语言支持和提案

第十四章:使用 ranges 库进行懒惰评估

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

幸运的是,Eric Niebler 自己着手开发了一个库,使纯函数式编程语言中的解决方案——懒惰评估成为可能。该库名为ranges,随后被正式纳入 C++ 20 标准。在本章中,我们将看到如何利用它。

本章将涵盖以下主题:

  • 为什么以及何时懒惰评估是有用的

  • ranges 库的介绍

  • 如何使用 ranges 库进行懒惰评估

技术要求

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

该代码可以在 GitHub 上找到,网址为https:/​/​github.​com/​PacktPublishing/​Hands-​On-​Functional-Programming-​with-​Cpp,在Chapter14文件夹中。它包括并使用了doctest,这是一个单头文件的开源单元测试库。你可以在它的 GitHub 仓库上找到它,网址为https:/​/github.​com/​onqtam/​doctest

ranges 库概述

ranges 库为 C++程序员提供了各种有用的新工具。它们都很有用,但对于我们的函数式编程需求来说,许多工具尤其如此。

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

#include <range/v3/all.hpp>

至于 C++ 20,你只需要包含<ranges>头文件,因为该库已包含在标准中:

#include <ranges>

然而,如果你在尝试上一行代码时遇到编译错误,不要感到惊讶。在撰写本文时,最新版本的 g++是 9.1,但 ranges 库尚未包含在标准中。由于其规模,实现预计会相当晚。在那之前,如果你想尝试它,你仍然可以使用 Eric Niebler 的版本。

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

使用 ranges,我们可以构建视图。视图指定我们对部分或全部集合感兴趣,通过两个迭代器,但也允许懒惰评估和可组合性。由于视图只是集合的轻量级包装器,我们可以声明一系列操作,而不实际执行它们,直到需要结果。我们将在下一节详细介绍这是如何工作的,但这里有一个简单的示例,组合两个操作,将过滤出集合中所有的倍数为六的数字,首先通过过滤所有的偶数,然后再过滤出是 3 的倍数的数字:

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

在 ranges 上也可以进行突变,借助于操作。操作类似于视图,只是它们会就地改变底层容器,而不是创建副本。正如我们之前多次讨论过的那样,在函数式编程中,我们更喜欢不改变数据;然而,在某些情况下,我们可以通过这种解决方案优化性能,因此值得一提。下面是一个操作的示例...嗯,在操作中:

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

|运算符对于函数式编程者来说非常有趣,因为它是一种函数组合运算符。对于 Unix/Linux 用户来说,使用它也很自然,他们非常习惯组合操作。正如我们在第四章中所看到的,函数组合的概念,这样的运算符将非常有用。不幸的是,它还不支持任意两个函数的组合,只支持视图和操作的组合。

最后,ranges 库支持自定义视图。这打开了诸如数据生成之类的可能性,这对许多事情都很有用,特别是第十一章中的基于属性的测试

让我们更详细地访问范围库的特性,并举例说明。

惰性求值

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

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));
}

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

我们已经在第十章中看到了如何处理这个问题,但事实上,最简单的解决方案是惰性求值。想象一下,如果我们可以链接函数调用,但是在我们需要其结果的时刻之前,代码实际上并没有执行,那将意味着什么。这个解决方案打开了编写我们需要编写的代码以及我们需要的方式的可能性,编译器最大限度地优化了函数链。

这就是 ranges 库正在做的事情,以及其他一些额外的功能。

使用 ranges 库进行惰性求值

ranges 库提供了一个名为views的工具。视图允许从迭代器构造不可变且廉价的数据范围。它们不会复制数据,只是引用数据。我们可以使用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 的一个特殊实现,它知道如何与视图一起工作。只有在调用accumulate时,视图才会起作用;此外,实际上没有数据被复制——相反,ranges 使用智能迭代器来计算结果。

让我们也看看组合视图的结果。如预期的那样,向量中所有能被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));
}

写代码的方式真好!它比以前的两种选项都要容易得多,同时内存占用也很低。

但这还不是 ranges 能做的全部。

使用操作进行可变更改

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

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);
}

然而,这还不是范围可以做的一切。

无限序列和数据生成

由于视图是惰性评估的,它们允许我们创建无限序列。例如,要生成一系列整数,我们可以使用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]区间中取出五个线性分布的值会得到这些值:{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的十次幂的列表(即2¹¹2²¹等)。我们可以使用 transform 调用来做到这一点;然而,我们也可以使用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 视图。stride视图从序列中取出每个 n^(th)元素,正好符合我们的需求。结合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);
}

到目前为止,您可能已经意识到数据生成对于测试非常有趣,特别是基于属性的测试(正如我们在第十一章中讨论的那样,基于属性的测试)。然而,对于测试,我们经常需要生成字符串。让我们看看如何做到这一点。

生成字符串

要生成字符串,首先我们需要生成字符。对于 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 的 ranges 库在软件工程中是一个罕见的成就。它成功地简化了现有 STL 高阶函数的使用,同时添加了惰性评估,并附加了数据生成。它不仅是 C++ 20 标准的一部分,而且也适用于较旧版本的 C++。

即使您不使用函数式的代码结构,无论您喜欢可变的还是不可变的代码,ranges 库都可以让您的代码变得优雅和可组合。因此,我建议您尝试一下,看看它如何改变您的代码。这绝对是值得的,也是一种愉快的练习。

我们即将结束本书。现在是时候看看 STL 和语言标准对函数式编程的支持,以及我们可以从 C++ 20 中期待什么,这将是下一章的主题。

第十五章:STL 支持和提案

自从 90 年代以来,标准模板库STL)一直是 C++程序员的有用伴侣。从泛型编程和值语义等概念开始,它已经发展到支持许多有用的场景。在本章中,我们将看看 STL 如何支持 C++ 17 中的函数式编程,并了解一些在 C++ 20 中引入的新特性。

本章将涵盖以下主题:

  • 使用<functional>头文件中的函数式特性

  • 使用<numeric>头文件中的函数式特性

  • 使用<algorithm>头文件中的函数式特性

  • std::optionalstd::variant

  • C++20 和 ranges 库

技术要求

你需要一个支持 C++ 17 的编译器。我使用的是 GCC 7.4.0c。

代码在 GitHub 上的https:/​/​github.​com/​PacktPublishing/​Hands-​On-​Functional-Programming-​with-​CppChapter15文件夹中。它包括并使用了doctest,这是一个单头开源单元测试库。你可以在它的 GitHub 仓库中找到它:https:/​/github.​com/​onqtam/​doctest

<functional>头文件

我们需要从 STL 中的函数式编程支持中的某个地方开始,而名为<functional>的头文件似乎是一个不错的起点。这个头文件定义了基本的function<>类型,我们可以用它来表示函数,并且在本书中的几个地方已经使用过了 lambda 表达式:

TEST_CASE("Identity function"){
    function<int(int)> identity = [](int value) { return value;};

    CHECK_EQ(1, identity(1));
}

我们可以使用function<>类型来存储任何类型的函数,无论是自由函数、成员函数还是 lambda。让我们看一个自由函数的例子:

TEST_CASE("Free function"){
    function<int()> f = freeFunctionReturns2;

    CHECK_EQ(2, f());
}

这里有一个成员函数的例子:

class JustAClass{
    public:
        int functionReturns2() const { return 2; };
};

TEST_CASE("Class method"){
    function<int(const JustAClass&)> f = &JustAClass::functionReturns2;
    JustAClass justAClass;

    CHECK_EQ(2, f(justAClass));
}

正如你所看到的,为了通过function<>类型调用成员函数,需要传递一个有效的对象引用。可以把它看作是*this实例。

除了这种基本类型之外,<functional>头文件还提供了一些已定义的函数对象,当在集合上使用函数式转换时非常方便。让我们看一个简单的例子,使用sort算法与定义的greater函数结合,以便按降序对向量进行排序:

TEST_CASE("Sort with predefined function"){
    vector<int> values{3, 1, 2, 20, 7, 5, 14};
    vector<int> expectedDescendingOrder{20, 14, 7, 5, 3,  2, 1};

    sort(values.begin(), values.end(), greater<int>());

    CHECK_EQ(expectedDescendingOrder, values);
}

<functional>头文件定义了以下有用的函数对象:

  • 算术操作plusminusmultipliesdividesmodulusnegate

  • 比较equal_tonot_equal_togreaterlessgreater_equalless_equal

  • 逻辑操作logical_andlogical_orlogical_not

  • 位操作bit_andbit_orbit_xor

当我们需要将常见操作封装在函数中以便在高阶函数中使用时,这些函数对象可以帮助我们省去麻烦。虽然这是一个很好的集合,但我敢于建议一个恒等函数同样有用,尽管这听起来有些奇怪。幸运的是,实现一个恒等函数很容易。

然而,<functional>头文件提供的不仅仅是这些。bind函数实现了部分函数应用。我们在本书中多次看到它的应用,你可以在第五章中详细了解它的用法,部分应用和柯里化。它的基本功能是接受一个函数,绑定一个或多个参数到值,并获得一个新的函数:

TEST_CASE("Partial application using bind"){
    auto add = [](int first, int second){
        return first + second;
    };

    auto increment = bind(add, _1, 1);

    CHECK_EQ(3, add(1, 2));
    CHECK_EQ(3, increment(2));
}

有了function<>类型允许我们编写 lambda 表达式,预定义的函数对象减少了重复,以及bind允许部分应用,我们就有了以函数式方式构造代码的基础。但是如果没有高阶函数,我们就无法有效地这样做。

头文件

<algorithm>头文件包含了一些算法,其中一些实现为高阶函数。在本书中,我们已经看到了许多它们的用法。以下是一些有用的算法列表:

  • all_ofany_ofnone_of

  • find_iffind_if_not

  • count_if

  • copy_if

  • generate_n

  • sort

我们已经看到,专注于数据并结合这些高阶函数将输入数据转换为所需的输出是你可以思考的一种方式,这是小型、可组合、纯函数的一种方式。我们也看到了这种方法的缺点——需要复制数据,或者对相同的数据进行多次遍历——以及新的 ranges 库如何以一种优雅的方式解决了这些问题。

虽然所有这些函数都非常有用,但有一个来自<algorithm>命名空间的函数值得特别提及——函数式map操作transform的实现。transform函数接受一个输入集合,并对集合的每个元素应用一个 lambda,返回一个具有相同数量元素但其中存储了转换值的新集合。这为我们适应数据结构提供了无限的可能性。让我们看一些例子。

从集合中投影每个对象的一个属性

我们经常需要从集合中获取每个元素的属性值。在下面的例子中,我们使用transform来获取一个向量中所有人的姓名列表:

TEST_CASE("Project names from a vector of people"){
    vector<Person> people = {
        Person("Alex", 42),
        Person("John", 21),
        Person("Jane", 14)
    };

    vector<string> expectedNames{"Alex", "John", "Jane"};
    vector<string> names = transformAll<vector<string>>(
            people, 
            [](Person person) { return person.name; } 
    );

    CHECK_EQ(expectedNames, names);
}

再次使用transformtransformAll的包装器,以避免编写样板代码:

template<typename DestinationType>
auto transformAll = [](auto source, auto lambda){
    DestinationType result;
    transform(source.begin(), source.end(), back_inserter(result), 
        lambda);
    return result;
};

计算条件

有时,我们需要计算一组元素是否满足条件。在下面的例子中,我们将通过比较他们的年龄与18来计算人们是否未成年:

TEST_CASE("Minor or major"){
    vector<Person> people = {
        Person("Alex", 42),
        Person("John", 21),
        Person("Jane", 14)
    };

    vector<bool> expectedIsMinor{false, false, true};
    vector<bool> isMinor = transformAll<vector<bool>>(
            people, 
            [](Person person) { return person.age < 18; } 
    );

    CHECK_EQ(expectedIsMinor, isMinor);
}

将所有内容转换为可显示或可序列化格式

我们经常需要保存或显示一个列表。为了做到这一点,我们需要将列表的每个元素转换为可显示或可序列化的格式。在下面的例子中,我们正在计算列表中的Person对象的 JSON 表示:

TEST_CASE("String representation"){
    vector<Person> people = {
        Person("Alex", 42),
        Person("John", 21),
        Person("Jane", 14)
    };

    vector<string> expectedJSON{
        "{'person': {'name': 'Alex', 'age': '42'}}",
        "{'person': {'name': 'John', 'age': '21'}}",
        "{'person': {'name': 'Jane', 'age': '14'}}"
    };
    vector<string> peopleAsJson = transformAll<vector<string>>(
            people, 
            [](Person person) { 
            return 
            "{'person': {'name': '" + person.name + "', 'age': 
                '" + to_string(person.age) + "'}}"; } 
    );

    CHECK_EQ(expectedJSON, peopleAsJson);
}

即使transform函数打开了无限的可能性,但与reduce(在 C++中为accumulate)高阶函数结合使用时,它变得更加强大。

<numeric>头文件 - accumulate

有趣的是,形成map/reduce模式的两个高阶函数之一,即函数式编程中最常见的模式之一,最终出现在 C++的两个不同的头文件中。transform/accumulate组合需要<algorithm><numeric>头文件,可以解决许多具有以下模式的问题:

  • 提供了一个集合。

  • 集合需要转换为其他形式。

  • 需要计算一个聚合结果。

让我们看一些例子。

计算购物车的含税总价

假设我们有一个Product结构,如下所示:

struct Product{
    string name;
    string category;
    double price;
    Product(string name, string category, double price): name(name), 
        category(category), price(price){}
};

假设我们根据产品类别有不同的税率:

map<string, int> taxLevelByCategory = {
    {"book", 5},
    {"cosmetics", 20},
    {"food", 10},
    {"alcohol", 40}
};

假设我们有一个产品列表,如下所示:

    vector<Product> products = {
        Product("Lord of the Rings", "book", 22.50),
        Product("Nivea", "cosmetics", 15.40),
        Product("apple", "food", 0.30),
        Product("Lagavulin", "alcohol", 75.35)
    };

让我们计算含税和不含税的总价。我们还有一个辅助包装器accumulateAll可供使用:

auto accumulateAll = [](auto collection, auto initialValue,  auto 
    lambda){
        return accumulate(collection.begin(), collection.end(), 
            initialValue, lambda);
};

要计算不含税的价格,我们只需要获取所有产品的价格并相加。这是一个典型的map/reduce场景:

   auto totalWithoutTax = accumulateAll(transformAll<vector<double>>
        (products, [](Product product) { return product.price; }), 0.0, 
            plus<double>());
     CHECK_EQ(113.55, doctest::Approx(totalWithoutTax));

首先,我们将Products列表转换为价格列表,然后将它们进行reduce(或accumulate)处理,得到一个单一的值——它的总价。

当我们需要含税的总价时,一个类似但更复杂的过程也适用:

    auto pricesWithTax = transformAll<vector<double>>(products, 
            [](Product product){
                int taxPercentage = 
                    taxLevelByCategory[product.category];
                return product.price + product.price * 
                    taxPercentage/100;
            });
    auto totalWithTax = accumulateAll(pricesWithTax, 0.0, 
        plus<double> ());
    CHECK_EQ(147.925, doctest::Approx(totalWithTax));

首先,我们将Products列表与含税价格列表进行maptransform)处理,然后将所有值进行reduce(或accumulate)处理,得到含税总价。

如果你想知道,doctest::Approx函数允许对浮点数进行小的舍入误差比较。

将列表转换为 JSON

在前一节中,我们看到如何通过transform调用将列表中的每个项目转换为 JSON。通过accumulate的帮助,很容易将其转换为完整的 JSON 列表:

    string expectedJSONList = "{people: {'person': {'name': 'Alex', 
        'age': '42'}}, {'person': {'name': 'John', 'age': '21'}}, 
            {'person': {'name': 'Jane', 'age': '14'}}}"; 
    string peopleAsJSONList = "{people: " + accumulateAll(peopleAsJson, 
        string(),
            [](string first, string second){
                return (first.empty()) ? second : (first + ", " + 
                    second);
            }) + "}";
    CHECK_EQ(expectedJSONList, peopleAsJSONList);

我们使用transform将人员列表转换为每个对象的 JSON 表示的列表,然后我们使用accumulate将它们连接起来,并使用一些额外的操作来添加 JSON 中列表表示的前后部分。

正如你所看到的,transform/accumulate(或map/reduce)组合可以根据我们传递给它的函数执行许多不同的用途。

回到 – find_if 和 copy_if

我们可以通过transformaccumulateany_of/all_of/none_of实现很多事情。然而,有时我们需要从集合中过滤掉一些数据。

通常的做法是使用find_if。然而,如果我们需要找到集合中符合特定条件的所有项目,find_if就显得很麻烦了。因此,使用 C++ 17 标准以函数式方式解决这个问题的最佳选择是copy_if。以下示例使用copy_if在人员列表中找到所有未成年人:

TEST_CASE("Find all minors"){
    vector<Person> people = {
        Person("Alex", 42),
        Person("John", 21),
        Person("Jane", 14),
        Person("Diana", 9)
    };

    vector<Person> expectedMinors{Person("Jane", 14), 
                                  Person("Diana", 9)};

    vector<Person> minors;
    copy_if(people.begin(), people.end(), back_inserter(minors), []
        (Person& person){ return person.age < 18; });

    CHECK_EQ(minors, expectedMinors);
}

我们已经讨论了很多快乐路径的情况,即数据对我们的数据转换是有效的情况。那么对于边缘情况和错误情况,我们该怎么办呢?当然,在特殊情况下,我们可以抛出异常或返回错误情况,但是在我们需要返回错误消息的情况下呢?

在这些情况下,功能性的方式是返回数据结构。毕竟,即使输入无效,我们也需要返回一个输出值。但我们面临一个挑战——在错误情况下我们需要返回的类型是错误类型,而在有效数据情况下我们需要返回的类型是更多的有效数据。

幸运的是,我们有两种结构在这些情况下支持我们——std::optionalstd::variant。让我们以一个人员列表为例,其中一些人是有效的,一些人是无效的:

    vector<Person> people = {
        Person("Alex", 42),
        Person("John", 21),
        Person("Jane", 14),
        Person("Diana", 0)
    };

最后一个人的年龄无效。让我们尝试以一种功能性的方式编写代码,以显示以下字符串:

Alex, major
John, major
Jane, minor
Invalid person

要有一系列的转换,我们需要使用optional类型,如下所示:

struct MajorOrMinorPerson{
    Person person;
    optional<string> majorOrMinor;

    MajorOrMinorPerson(Person person, string majorOrMinor) : 
        person(person), majorOrMinor(optional<string>(majorOrMinor)){};

    MajorOrMinorPerson(Person person) : person(person), 
        majorOrMinor(nullopt){};
};
    auto majorMinorPersons = transformAll<vector<MajorOrMinorPerson>>
        (people, [](Person& person){ 
            if(person.age <= 0) return MajorOrMinorPerson(person);
            if(person.age > 0 && person.age < 18) return 
                MajorOrMinorPerson(person, "minor");
            return MajorOrMinorPerson(person, "major");
            });

通过这个调用,我们得到了一个人和一个值之间的配对列表,该值要么是nullopt,要么是minor,要么是major。我们可以在下面的transform调用中使用它,以根据有效条件获取字符串列表:

    auto majorMinorPersonsAsString = transformAll<vector<string>>
        (majorMinorPersons, [](MajorOrMinorPerson majorOrMinorPerson){
            return majorOrMinorPerson.majorOrMinor ? 
            majorOrMinorPerson.person.name + ", " + 
                majorOrMinorPerson.majorOrMinor.value() :
                    "Invalid person";
            });

最后,调用 accumulate 创建了预期的输出字符串:

    auto completeString = accumulateAll(majorMinorPersonsAsString, 
        string(), [](string first, string second){
            return first.empty() ? second : (first + "\n" + second);
            });

我们可以通过测试来检查这一点:

    string expectedString("Alex, major\nJohn, major\nJane, 
                                    minor\nInvalid person");

    CHECK_EQ(expectedString, completeString);

如果需要,可以使用variant来实现另一种方法,例如,返回与人员组合的错误代码。

C++ 20 和范围库

我们在第十四章中详细讨论了范围库,使用范围库进行惰性评估。如果你可以使用它,要么是因为你使用 C++ 20,要么是因为你可以将它作为第三方库使用,那么前面的函数就变得非常简单且更快:

TEST_CASE("Ranges"){
    vector<Person> people = {
        Person("Alex", 42),
        Person("John", 21),
        Person("Jane", 14),
        Person("Diana", 0)
    };
    using namespace ranges;

    string completeString = ranges::accumulate(
            people |
            view::transform(personToMajorMinor) | 
            view::transform(majorMinor),
            string(),
            combineWithNewline
           ); 
    string expectedString("Alex, major\nJohn, major\nJane, 
                                    minor\nInvalid person");

    CHECK_EQ(expectedString, completeString);
}

同样,从人员列表中找到未成年人的列表在范围的view::filter中非常容易:

TEST_CASE("Find all minors with ranges"){
    using namespace ranges;

    vector<Person> people = {
        Person("Alex", 42),
        Person("John", 21),
        Person("Jane", 14),
        Person("Diana", 9)
    };
    vector<Person> expectedMinors{Person("Jane", 14),
                                   Person("Diana", 9)};

    vector<Person> minors = people | view::filter(isMinor);

    CHECK_EQ(minors, expectedMinors);
}

一旦我们有了isMinor谓词,我们可以将它传递给view::filter来从人员列表中找到未成年人。

摘要

在本章中,我们对 C++ 17 STL 中可用的函数式编程特性进行了介绍,以及 C++ 20 中的新特性。通过函数、算法、variantoptional在错误或边缘情况下提供的帮助,以及使用范围库可以实现的简化和优化代码,我们对函数式编程特性有了相当好的支持。

现在,是时候进入下一章,看看 C++ 17 对函数式编程的语言支持,以及 C++ 20 中即将出现的有趣的事情了。

第十六章:标准语言支持和提案

在本书中,我们已经涉及了许多主题,现在是时候将它们全部归纳到一个方便的章节中,以帮助您记住我们涵盖的函数式编程技术的使用方法。我们将利用这个机会来看看 C++ 20 标准,并提及我们如何在我们的代码中使用这些新功能。

本章将涵盖以下主题:

  • C++中编写纯函数的支持方式和未来提案

  • C++中编写 lambda 的支持方式和未来提案

  • C++中柯里化的支持方式和未来提案

  • C++中函数组合的支持方式和未来提案

技术要求

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

代码在 GitHub 上的https:/​/​github.​com/​PacktPublishing/​Hands-​On-​Functional-Programming-​with-​CppChapter16文件夹中。它包括并使用doctest,这是一个单头开源单元测试库。您可以在 GitHub 存储库中找到它:https:/​/github.​com/​onqtam/​doctest

标准语言支持和提案

到目前为止,我们已经探讨了在 C++中以函数式风格编写代码的几种方式。现在,我们将看看 C++ 17 标准允许的一些额外选项,以及 C++ 20 允许的一些选项。因此,让我们开始编写纯函数。

纯函数

纯函数是在接收相同输入时返回相同输出的函数。它们的可预测性使它们对于理解编写的代码与其运行时性能的相关性非常有用。

我们在第二章中发现,要在 C++中编写纯函数,需要结合conststatic,具体取决于函数是类的一部分还是自由函数,并且取决于我们如何将参数传递给函数。为了方便起见,我将在此重述我们在纯函数语法上的结论:

  • 类函数,按值传递:

  • static int increment(const int value)

  • int increment(const int value) const

  • 类函数,按引用传递:

  • static int increment(const int& value)

  • int increment(const int&value) const

  • 类函数,按值传递指针:

  • static const int* increment(const int* value)

  • const int* increment(const int* value) const

  • 类函数,按引用传递指针:

  • static const int* increment(const int* const& value)

  • const int* increment(const int* const& value) const

  • 独立函数,按值传递int increment(const int value)

  • 独立函数,按引用传递int increment(const int& value)

  • 独立函数,按指针传递值const int* increment(const int* value)

  • 独立函数,按引用传递指针const int* increment(const int* const& value)

我们还发现,虽然编译器有助于减少副作用,但它并不总是告诉我们一个函数是纯的还是不纯的。在编写纯函数时,我们始终需要记住使用这三个标准,并小心应用它们:

  • 它总是为相同的输入值返回相同的输出值。

  • 它没有副作用。

  • 它不会改变其参数值。

Lambda 表达式

Lambda 是函数式编程的基本部分,允许我们对函数进行操作。C++自 C++11 以来就有 lambda,但最近对语法进行了一些添加。此外,我们将探讨一些 lambda 功能,在本书中我们还没有使用过,但对您自己的代码可能会有用。

让我们从一个简单的 lambda 开始——increment有一个输入并返回增加后的值:

TEST_CASE("Increment"){
    auto increment =  [](auto value) { return value + 1;};

    CHECK_EQ(2, increment(1));
}

方括号([])指定了捕获值的列表,我们将在以下代码中看到。我们可以以与任何函数相同的方式指定参数的类型:

TEST_CASE("Increment"){
    auto increment =  [](int value) { return value + 1;};

    CHECK_EQ(2, increment(1));
}

我们还可以在参数列表后立即指定返回值,并加上->符号:

TEST_CASE("Increment"){
    auto increment =  [](int value) -> int { return value + 1;};

    CHECK_EQ(2, increment(1));
}

如果没有输入值,参数列表和圆括号()可以被忽略:

TEST_CASE("One"){
    auto one =  []{ return 1;};

    CHECK_EQ(1, one());
}

通过指定名称来捕获一个值,这样它就会被复制:

TEST_CASE("Capture value"){
    int value = 5;
    auto addToValue =  value { return value + toAdd;};

    CHECK_EQ(6, addToValue(1));
}

或者,我们可以通过引用捕获一个值,使用捕获说明中的&运算符:

TEST_CASE("Capture value by reference"){
    int value = 5;
    auto addToValue =  &value { return value + toAdd;};

    CHECK_EQ(6, addToValue(1));
}

如果我们捕获多个值,我们可以枚举它们,也可以捕获所有值。对于按值捕获,我们使用=说明符:

TEST_CASE("Capture all values by value"){
    int first = 5;
    int second = 10;
    auto addToValues = = { return first + second + 
        toAdd;};
    CHECK_EQ(16, addToValues(1));
}

要通过引用捕获所有值,我们使用&说明符而不带任何变量名:

TEST_CASE("Capture all values by reference"){
    int first = 5;
    int second = 10;
    auto addToValues = & { return first + second + 
        toAdd;};
    CHECK_EQ(16, addToValues(1));
}

虽然不推荐,但我们可以在参数列表后使用mutable说明符使 lambda 调用可变:

TEST_CASE("Increment mutable - NOT RECOMMENDED"){
    auto increment =  [](int& value) mutable { return ++value;};

    int value = 1;
    CHECK_EQ(2, increment(value));
    CHECK_EQ(2, value);
}

此外,从 C++ 20 开始,我们可以指定函数调用为consteval,而不是默认的constexpr

TEST_CASE("Increment"){
    auto one = []() consteval { return 1;};

    CHECK_EQ(1, one());
}

不幸的是,这种用法在 g++8 中尚不受支持。

异常说明也是可能的;也就是说,如果 lambda 没有抛出异常,那么noexcept可能会派上用场:

TEST_CASE("Increment"){
    auto increment =  [](int value) noexcept { return value + 1;};

    CHECK_EQ(2, increment(1));
}

如果 lambda 抛出异常,可以指定为通用或特定:

TEST_CASE("Increment"){
    auto increment =  [](int value) throw() { return value + 1;};

    CHECK_EQ(2, increment(1));
}

但是,如果您想使用通用类型怎么办?在 C++ 11 中,您可以使用function<>类型。从 C++ 20 开始,所有类型约束的好处都可以以一种简洁的语法用于 lambda。

TEST_CASE("Increment"){
    auto increment =  [] <typename T>(T value) -> requires 
        NumericType<T> { return value + 1;};

    CHECK_EQ(2, increment(1));
}

不幸的是,这在 g++8 中也尚不受支持。

部分应用和柯里化

部分应用意味着通过在1(或更多,但少于N)个参数上应用具有N个参数的函数来获得一个新函数。

我们可以通过实现一个传递参数的函数或 lambda 来手动实现部分应用。以下是使用std::plus函数实现部分应用以获得一个increment函数的例子,将其中一个参数设置为1

TEST_CASE("Increment"){
    auto increment =  [](const int value) { return plus<int>()(value, 
        1); };

    CHECK_EQ(2, increment(1));
}

在本书中,我们主要关注了如何在这些情况下使用 lambda;然而值得一提的是,我们也可以使用纯函数来实现相同的目标。例如,相同的增量函数可以编写为普通的 C++函数:

namespace Increment{
    int increment(const int value){
        return plus<int>()(value, 1);
    };
}

TEST_CASE("Increment"){
    CHECK_EQ(2, Increment::increment(1));
}

在 C++中可以使用bind()函数进行部分应用。bind()函数允许我们为函数绑定参数值,从而可以从plus派生出increment函数,如下所示:

TEST_CASE("Increment"){
    auto increment = bind(plus<int>(), _1, 1);

    CHECK_EQ(2, increment(1));
}

bind接受以下参数:

  • 我们想要绑定的函数。

  • 要绑定到的参数;这些可以是值或占位符(如_1_2等)。占位符允许将参数转发到最终函数。

在纯函数式编程语言中,部分应用与柯里化相关联。柯里化是将接受N个参数的函数分解为接受一个参数的N个函数。在 C++中没有标准的柯里化函数,但我们可以通过使用 lambda 来实现。让我们看一个柯里化pow函数的例子:

auto curriedPower = [](const int base) {
    return base {
        return pow(base, exponent);
    };
};

TEST_CASE("Power and curried power"){
    CHECK_EQ(16, pow(2, 4));
    CHECK_EQ(16, curriedPower(2)(4));
}

如您所见,借助柯里化的帮助,我们可以通过只使用一个参数调用柯里化函数来自然地进行部分应用,而不是两个参数:

    auto powerOf2 = curriedPower(2);
    CHECK_EQ(16, powerOf2(4));

这种机制在许多纯函数式编程语言中默认启用。然而,在 C++中更难实现。C++中没有标准支持柯里化,但我们可以创建自己的curry函数,该函数接受现有函数并返回其柯里化形式。以下是一个具有两个参数的通用curry函数的示例:

template<typename F>
auto curry2(F f){
    return ={
        return ={
            return f(first, second);
        };
    };
}

此外,以下是如何使用它进行柯里化和部分应用:

TEST_CASE("Power and curried power"){
    auto power = [](const int base, const int exponent){
        return pow(base, exponent);
    };
    auto curriedPower = curry2(power);
    auto powerOf2 = curriedPower(2);
    CHECK_EQ(16, powerOf2(4));
}

现在让我们看看实现函数组合的方法。

函数组合

函数组合意味着取两个函数fg,并获得一个新函数h;对于任何值,h(x) = f(g(x))。我们可以手动实现函数组合,无论是在 lambda 中还是在普通函数中。例如,给定两个函数,powerOf2计算2的幂,increment增加一个值,我们将看到以下结果:

auto powerOf2 = [](const int exponent){
    return pow(2, exponent);
};

auto increment = [](const int value){
    return value + 1;
};

我们可以通过简单地将调用封装到一个名为incrementPowerOf2的 lambda 中来组合它们:

TEST_CASE("Composition"){
    auto incrementPowerOf2 = [](const int exponent){
        return increment(powerOf2(exponent));
    };

    CHECK_EQ(9, incrementPowerOf2(3));
}

或者,我们可以简单地使用一个简单的函数,如下所示:

namespace Functions{
    int incrementPowerOf2(const int exponent){
        return increment(powerOf2(exponent));
    };
}

TEST_CASE("Composition"){
    CHECK_EQ(9, Functions::incrementPowerOf2(3));
}

然而,一个接受两个函数并返回组合函数的运算符非常方便,在许多编程语言中都有实现。在 C++中最接近函数组合运算符的是|管道运算符,它来自于 ranges 库,目前已经包含在 C++ 20 标准中。然而,虽然它实现了组合,但对于一般函数或 lambda 并不适用。幸运的是,C++是一种强大的语言,我们可以编写自己的 compose 函数,正如我们在第四章中发现的,函数组合的概念

template <class F, class G>
auto compose(F f, G g){
    return ={return f(g(value));};
}

TEST_CASE("Composition"){
    auto incrementPowerOf2 = compose(increment, powerOf2); 

    CHECK_EQ(9, incrementPowerOf2(3));
}

回到 ranges 库和管道运算符,我们可以在 ranges 的上下文中使用这种形式的函数组合。我们在第十四章中对这个主题进行了广泛探讨,使用 ranges 库进行惰性求值,这里有一个使用管道运算符计算集合中既是2的倍数又是3的倍数的所有数字的和的例子:

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

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

auto sumOfMultiplesOf6 = [](const auto& numbers){
    return ranges::accumulate(
            numbers | ranges::view::filter(isEven) | 
                ranges::view::filter(isMultipleOf3), 0);
};

TEST_CASE("Sum of even numbers and of multiples of 6"){
    list<int> numbers{1, 2, 5, 6, 10, 12, 17, 25};

    CHECK_EQ(18, sumOfMultiplesOf6(numbers));
}

正如你所看到的,在标准 C++中有多种函数式编程的选项,而且 C++ 20 中还有一些令人兴奋的发展。

总结

这就是了!我们已经快速概述了函数式编程中最重要的操作,以及我们如何可以使用 C++ 17 和 C++ 20 来实现它们。我相信你现在掌握了更多工具,包括纯函数、lambda、部分应用、柯里化和函数组合,仅举几例。

从现在开始,你可以自行选择如何使用它们。选择一些,或者组合它们,或者慢慢将你的代码从可变状态转移到不可变状态;掌握这些工具将使你在编写代码的方式上拥有更多选择和灵活性。

无论你选择做什么,我祝你在你的项目和编程生涯中好运。愉快编码!

第十七章:评估

第一章

  1. 什么是不可变函数?

不可变函数是一个不改变其参数值或程序状态的函数。

  1. 如何编写一个不可变函数?

如果你希望编译器帮助你,将参数设为const

  1. 不可变函数如何支持代码简洁性?

因为它们不改变它们的参数,所以它们从代码中消除了任何潜在的复杂性,从而使程序员更好地理解它。

  1. 不可变函数如何支持简单设计?

不可变函数很无聊,因为它们只做计算。因此,它们有助于长时间的维护。

  1. 什么是高级函数?

高级函数是一个接收另一个函数作为参数的函数。

  1. STL 中可以给出哪些高级函数的例子?

STL 中有许多高级函数的例子,特别是在算法中。sort是我们在本章中使用的例子;然而,如果你查看<algorithm>头文件,你会发现许多其他例子,包括findfind_ifcountsearch等等。

  1. 函数式循环相对于结构化循环的优势是什么?它们的潜在缺点是什么?

函数式循环避免了一次循环错误,并更清晰地表达了代码的意图。它们也是可组合的,因此可以通过链接多个循环来进行复杂的操作。然而,当组合时,它们需要多次通过集合,而这可以通过使用简单循环来避免。

  1. Alan Kay 的角度看 OOP 是什么?它如何与函数式编程相关?

Alan Kay 将 OOP 视为按细胞有机体原则构建代码的一种方式。细胞是通过化学信号进行通信的独立实体。因此,小对象之间的通信是 OOP 最重要的部分。

这意味着我们可以在表示为对象的数据结构上使用函数算法而不会产生任何冲突。

第二章

  1. 什么是纯函数?

纯函数有两个约束条件,如下所示:

    • 它总是对相同的参数值返回相同的输出值。
  • 它没有副作用。
  1. 不可变性与纯函数有什么关系?

纯函数是不可变的,因为它们不会改变程序状态中的任何内容。

  1. 如何告诉编译器防止传递的变量发生变化?

只需将参数定义为const,如下所示:

int square(const int value)
  1. 如何告诉编译器防止通过引用传递的变量发生变化?

只需将参数定义为const&,如下所示:

int square(const int& value)
  1. 如何告诉编译器防止通过引用传递的指针地址发生变化?

如果通过值传递指针,不需要任何操作,因为所有的更改都将局限于函数内部:

int square(int* value)

如果通过引用传递指针,我们需要告诉编译器地址不能改变:

int square(int*& const value)
  1. 如何告诉编译器防止指针指向的值发生变化?

如果通过值传递指针,我们将应用与通过值传递的简单值相同的规则:

int square(const int* value)

为了防止通过引用传递指针时对值和地址的更改,需要更多地使用const关键字:

int square(const int&* const value)

第三章

  1. 你可以写一个最简单的 lambda 吗?

最简单的 lambda 不接收参数并返回一个常量;可以是以下内容:

auto zero = [](){return 0;};
  1. 如何编写一个连接作为参数传递的两个字符串值的 lambda?

根据您喜欢的字符串连接方式,这个答案有几种变化。使用 STL 的最简单方法如下:

auto concatenate = [](string first, string second){return first + second;};
  1. 如果其中一个值是按值捕获的变量怎么办?

答案类似于前面的解决方案,但使用上下文中的值:

auto concatenate = first{return first + second;};

当然,我们也可以使用默认的按值捕获符号,如下所示:

auto concatenate = ={return first + second;};
  1. 如果其中一个值是通过引用捕获的变量怎么办?

与前一个解决方案相比,除非您想要防止值的更改,否则几乎没有变化,如下所示:

auto concatenate = &first{return first + second;};

如果要防止值的更改,我们需要转换为const

auto concatenate = &firstValue = as_const(first){return firstValue + second;};
  1. 如果其中一个值是以值方式捕获的指针会怎样?

我们可以忽略不可变性,如下所示:

auto concatenate = ={return *pFirst + second;};

或者,我们可以使用指向const类型的指针:

const string* pFirst = new string("Alex");
auto concatenate = ={return *pFirst + second;};

或者,我们可以直接使用该值,如下所示:

string* pFirst = new string("Alex");
first = *pFirst;
auto concatenate = ={return first + second;}
  1. 如果其中一个值是以引用方式捕获的指针会怎样?

这使我们可以在 lambda 内部更改指向的值和指针地址。

最简单的方法是忽略不可变性,如下所示:

auto concatenate = &{return *pFirst + second;};

如果我们想要限制不可变性,我们可以使用转换为const

auto concatenate = &first = as_const(pFirst){return *first + second;};

然而,通常最好的方法是直接使用该值,如下所示:

string first = *pFirst;
auto concatenate = ={return first + second;};
  1. 如果两个值都使用默认捕获说明符以值方式捕获,会怎么样?

这个解决方案不需要参数,只需要从上下文中捕获两个值:

auto concatenate = [=](){return first + second;};
  1. 如果两个值都使用默认捕获说明符以引用方式捕获,会怎么样?

如果我们不关心值的变化,我们可以这样做:

auto concatenate = [&](){return first + second;};

为了保持不可变性,我们需要将其转换为const

auto concatenate = [&firstValue = as_const(first), &secondValue = as_const(second)](){return firstValue + secondValue;}

只使用默认的引用捕获说明符无法确保不可变性。请改用值方式捕获。

  1. 如何在具有两个字符串值作为数据成员的类中将相同的 lambda 写为数据成员?

在类中,我们需要指定 lambda 变量的类型以及是否捕获两个数据成员或 this。

以下代码显示了如何使用[=]语法以复制方式捕获值:

function<string()> concatenate = [=](){return first + second;};

以下代码显示了如何捕获this

function<string()> concatenate = [this](){return first + second;};
  1. 如何在同一类中将相同的 lambda 写为静态变量?

我们需要将数据成员作为参数接收,如下所示:

static function<string()> concatenate;
...
function<string()> AClass::concatenate = [](string first, string second){return first + second;};

我们已经看到,这比传递整个AClass实例作为参数更好,因为它减少了函数和类之间的耦合区域。

第四章

  1. 什么是函数组合?

函数组合是函数的操作。它接受两个函数fg,并创建第三个函数C,对于任何参数xC(x) = f(g(x))

  1. 函数组合具有通常与数学操作相关联的属性。它是什么?

函数组合不是可交换的。例如,对一个数字的增量进行平方不同于对一个数字的平方进行增量。

  1. 如何将带有两个参数的加法函数转换为带有一个参数的两个函数?

考虑以下函数:

auto add = [](const int first, const int second){ return first + second; };

我们可以将前面的函数转换为以下形式:

auto add = [](const int first){ 
    return first{
        return first + second;
    };
};
  1. 如何编写一个包含两个单参数函数的 C++函数?

在本章中,我们看到借助模板和auto类型的魔力,这是非常容易做到的:

template <class F, class G>
auto compose(F f, G g){
  return ={return f(g(value));};
}
  1. 函数组合的优势是什么?

函数组合允许我们通过组合非常简单的函数来创建复杂的行为。此外,它允许我们消除某些类型的重复。它还通过允许以无限方式重新组合小函数来提高重用的可能性。

  1. 实现函数操作的潜在缺点是什么?

函数的操作可以有非常复杂的实现,并且可能变得非常难以理解。抽象是有代价的,程序员必须始终平衡可组合性和小代码的好处与使用抽象操作的成本。

第五章

  1. 什么是部分函数应用?

部分函数应用是从一个接受N个参数的函数中获取一个新函数的操作,该函数通过将其中一个参数绑定到一个值来接受N-1个参数。

  1. 什么是柯里化?

柯里化是将接受N个参数的函数拆分为N个函数的操作,每个函数接受一个参数。

  1. 柯里化如何帮助实现部分应用?

给定柯里化函数f(x)(y),对x = valuef的部分应用可以通过简单地像这样调用f来获得:g = f(value)

  1. 我们如何在 C++中实现部分应用?

部分应用可以在 C++中手动实现,但使用functional头文件中的bind函数来实现会更容易。

posted @ 2024-05-04 22:46  绝不原创的飞龙  阅读(217)  评论(0编辑  收藏  举报