精通函数式编程-全-

精通函数式编程(全)

原文:zh.annas-archive.org/md5/68631f9e12b788ddb49cca2bffa58c5e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

函数式编程语言,如 Java、Scala 和 Clojure,作为处理多处理器和高可用性应用程序新要求的有效方式,正吸引人们的注意。本书将帮助你通过 Scala 学习函数式编程。本书采用领导思想的方法,温和地介绍函数式编程,并带你成为这一范式的专家。从函数式编程的介绍开始,本书逐步前进,教你如何编写声明式代码,利用函数式类型和值。在介绍基础知识之后,我们将讨论函数式编程中的更高级概念。

我们将涵盖纯函数和类型类等概念,它们旨在解决的问题,以及如何在实践中使用它们。我们将看到如何使用库进行纯函数式编程。我们将探讨函数式编程的广泛库家族。最后,我们将讨论函数式编程世界中的一些更高级的模式,例如 Monad Transformers 和 Tagless Final。在介绍完纯函数式编程方法之后,我们将探讨并行编程的主题。我们将介绍 Actor 模型以及它在现代函数式语言中的实现方式。到这本书结束时,你将掌握包含面向对象(OOP)和函数式编程概念,以构建健壮应用程序。

本书面向的对象

如果你来自命令式和面向对象(OOP)的背景,这本书将引导你进入函数式编程的世界,无论你使用哪种编程语言。

本书涵盖的内容

第一章,声明式编程风格,介绍了声明式风格的主要思想,即通过抽象重复的算法模式和流程控制,使得仅用一个语句就能描述原本需要 10 行命令式代码的内容。函数式语言通常具有复杂的基础设施,使这种方法特别相关和实用。一种感受这种差异的好方法是看看使用 Java 和 Scala 集合编程的差异——前者采用命令式风格,而后者采用函数式风格。

第二章,函数和 Lambda,将从面向对象程序员熟悉的函数概念开始。然后我们将探索一些更高级的、特定于函数式编程的概念——例如 Lambda、柯里化、泛型类型参数、隐式参数和高级函数。我们将看到高级函数如何有助于抽象控制流。最后,我们将探讨部分函数的概念。

第三章,函数式数据结构,解释了一个函数式集合框架。它具有一系列针对不同场景设计的集合数据类型。然后转向其他不属于集合框架但常用于函数式编程的数据类型,因此值得我们关注。这些数据类型是 Option、Either 和 Try。最后,我们将看到数据结构如何通过隐式机制与其行为分离,这种机制存在于一些高级语言中。

第四章,副作用问题,讨论了编程中普遍存在的副作用。函数式编程倡导所谓的纯函数——不产生任何副作用的函数,这意味着你不能从这样的函数中写入文件,或接触网络。为什么函数式编程会反对产生副作用的函数?是否可能仅使用纯函数编写有用的程序?本章探讨了这些问题。

第五章,效果类型 - 抽象副作用,提供了以纯方式处理副作用问题的解决方案。纯函数式编程提出的解决方案是将你遇到的所有副作用转换为函数式数据结构。我们将探讨识别副作用并将它们转换为这些数据结构的过程。然后,我们会很快意识到产生副作用的函数通常是一起工作的。因此,我们将探讨如何使用 Monad 的概念将这些函数组合起来。

第六章,实践中的效果类型,从新的角度关注了第三章,函数式数据结构的内容。我们将看到函数式数据结构对数据类型有更深的意义——即作为数据表示现象。现象是某种发生的事情,例如异常或延迟。通过将其表示为数据,我们能够保护自己免受现象的影响,同时保留有关它的信息。

第七章,类型类的理念,探讨了类型类模式如何从处理效果类型时遇到的实际需求中逻辑地产生。

第八章,基本类型类及其用法,概述了最常遇到的基本类型类及其家族。在讨论创建类型类系统的动机之后,我们进一步考察了它们的结构和从中派生的一些基本类型类。如 Monad 和 Applicative 这样的类型类在函数式编程中经常使用,因此值得特别注意。

第九章,纯函数式编程库,讨论了如何使用迄今为止学到的纯函数式技术(效果类型和类型类)来开发服务器端软件。我们将学习如何编写并发、异步软件以响应 HTTP 请求、联系数据库。我们还将了解现代函数式编程提供的并发模型。

第十章,高级函数式编程模式,探讨了如何组合效果类型以获得新的效果类型。您将看到如何利用编译器的类型系统在编译时检查程序保证。

第十一章,演员模型简介,从详细检查传统的并发编程模型开始。这个模型引发了许多问题,如竞态条件和死锁,这使得在其中编程容易出错,尤其是难以调试的错误。本章介绍了旨在解决这些问题的演员模型的概念。

第十二章,实践中的演员模型,涵盖了框架的基本概念及其概念。您将继续学习在面向演员编程中出现的某些模式,并了解演员如何与其他广泛使用的并发原语——未来交互操作。

第十三章,用例 - 并行网络爬虫,检查了一个使用演员模型编写的更大并发应用程序。一个很好的例子是网络爬虫应用程序。网络爬虫是一个收集网站链接的应用程序。从一个给定的网站开始,它收集该网站上的所有链接,跟随它们,并递归地收集它们的所有链接。本章将检查如何实现这样一个更大的应用程序。

附录 A,Scala 简介,是 Scala 语言的简要介绍,本书中的示例都使用了 Scala 语言。

为了充分利用本书

在阅读此书之前,读者需要了解面向对象和命令式编程的概念。

为了测试本书的代码,您需要 Docker 版本 18.06 或更高版本以及 Git 版本 2.18.0 或更高版本。

下载示例代码文件

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

您可以通过以下步骤下载代码文件:

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

  2. 选择支持选项卡。

  3. 点击代码下载与勘误表。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

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

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Functional-Programming。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/上获取。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/MasteringFunctionalProgramming_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“instances包包含了语言核心中存在的基本数据类型以及由Cats库定义的类型类的实现。”

代码块设置如下:

public void drink() {
  System.out.println("You have drunk a can of soda.");
}

任何命令行输入或输出都写成如下:

:help

粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的文字会像这样显示。以下是一个示例:“for 推导是按顺序调用 flatMap 的简写。这种技术被称为单调流。”

警告或重要提示看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/submit-errata,选择您的书,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您有兴趣成为一名作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解更多关于 Packt 的信息,请访问 packtpub.com.

第一章:声明式编程风格

声明式编程与函数式编程紧密相连。现代函数式语言更喜欢将程序表达为代数而不是算法。这意味着函数式语言中的程序是某些原语与运算符的组合。通过指定要做什么而不是如何做来表达程序的技术被称为声明式编程。我们将探讨为什么声明式编程出现以及它可以在哪里使用。

在本章中,我们将涵盖以下主题:

  • 声明式编程原理

  • 声明式与命令式集合的比较

  • 其他语言中的声明式编程

技术要求

要运行本书中的示例,您需要以下软件以及对其基本使用方法的理解:

要运行示例:

  1. 在您的机器上克隆 github.com/PacktPublishing/Mastering-Functional-Programming 仓库。

  2. 从其根目录开始,运行 docker-compose.yml 中指定的 Docker 镜像集。如果您在 Linux/Mac 机器上,可以运行 ./compose.sh 来完成此步骤。如果您在 Windows 上,请在文本编辑器中打开 compose.sh 并手动从终端运行每个命令。

  3. 在名为 mastering-functional-programming_backend_1 的 Docker 服务上运行 Shell(Bash)。您可以通过在 Linux/Mac 机器上的单独终端窗口运行 ./start.sh 来完成此步骤。如果您在 Windows 机器上,请运行 docker exec -ti mastering_backend bash。然后 cd Chapter1 以访问第一章的示例,或 cd ChapterN 以访问第 N 章的示例。

  4. cpp 文件夹包含 C++ 源代码。您可以从该目录使用 ./run.sh <源代码名称> 来运行它们。

  5. jvm 文件夹包含 Java 和 Scala 源代码。您可以从该目录运行 sbt run 来运行它们。

注意,在 Docker 下运行示例是必要的。某些章节的示例会针对实时数据库运行,该数据库由 Docker 管理,因此请确保上述步骤正常工作。

本书中的代码可在以下网址找到:github.com/PacktPublishing/Mastering-Functional-Programming

声明式编程原理

为什么是声明式编程?它是如何出现的?要理解声明式编程,我们首先需要了解它与命令式编程的不同之处。长期以来,命令式编程一直是事实上的行业标准。是什么促使人们开始从命令式风格转向函数式风格?

在命令式编程中,你依赖于语言提供的一组原语。你以某种方式将它们组合起来,以实现你需要的功能。我们可以在原语下理解不同的事物。例如,这些可以是循环控制结构,或者,在集合的情况下,特定的集合操作,如创建集合和向集合中添加或删除元素。

在声明式编程中,你也依赖于原语。你使用它们来表达你的程序。然而,在声明式编程中,这些原语与你的领域更接近。它们可以接近到语言本身可以被视为领域特定语言DSL)。通过声明式编程,你可以在进行过程中创建原语。

在命令式编程中,你通常不会创建新的原语,而是依赖于语言为你提供的原语。让我们通过一些例子来了解声明式编程的重要性。

示例 – go-to与循环的比较

通过例子最好地理解命令式如何转变为声明式。你很可能已经知道go-to语句。你听说过使用go-to语句是不良的做法。为什么?考虑一个循环的例子。可以使用仅使用go-to语句来表达循环:

#include <iostream>
using namespace std;
int main() {
  int x = 0;    
  loop_start:
   x++;
  cout << x << "\n";
  if (x < 10) goto loop_start;
  return 0;
}

从前面的例子中,想象你需要表达一个while循环。你有一个变量x,你需要通过循环每次增加一,直到它达到10。在现代语言如 Java 中,你将能够使用while循环来完成这个操作,但也可以使用go-to语句来完成。例如,可以在increment语句上有一个标签。在它之后的条件语句将检查变量是否达到了必要的值。如果没有,我们将执行go-to到增加变量的代码行。

为什么在这种情况下go-to是不良风格?循环是一个模式。模式是在你的代码中重复出现在不同程序位置的逻辑元素的排列。在我们的情况下,模式是循环。为什么它是模式?首先,它由三个部分组成:

  1. 第一部分是标签,它是循环体的入口点——你从循环的末尾跳转回来重复循环的点。

  2. 第二部分是循环必须满足的条件,以便循环可以重复执行。

  3. 第三部分是重申这是一个循环的语句。它是循环体的结束。

除了由三个部分组成之外,它还描述了编程中普遍存在的动作。这个动作是重复执行代码块多次。循环在编程中的普遍性无需解释。

如果你每次需要循环模式时都重新实现它,事情可能会出错。由于这个模式由多个部分组成,它可能会因为误用其中一个部分而损坏,或者你可能在将部分组合成一个整体时出错。你可能会忘记命名要跳转的标签,或者错误地命名它。你也可能忘记定义保护跳转到循环开始处的predicate语句。或者,你可能在q语句本身中漏掉或拼写错误要跳转的标签。例如,在以下代码中,我们忘记了指定谓词保护:

int main() {
 int x = 0;
 loop_start:
  x++;
 cout << x << "\n";
 goto loop_start;
 return 0;
}

示例 - 嵌套循环

要在这样一个简单的例子中出错是非常困难的,但考虑一个嵌套循环。例如,你有一个矩阵,你想要将其输出到控制台。这可以通过一个嵌套循环来完成。你有一个循环来遍历二维数组的每个条目。另一个嵌套在这个循环中的循环检查外层循环当前正在处理的行。它遍历该行的每个元素并将其打印到控制台。

这些也可以用go-to语句来表示。因此,你将有一个标签来表示大循环的入口点,另一个标签来表示小循环的入口点,你将在每个循环的末尾调用go-to语句以跳转到相应循环的开始。

让我们看看如何做到这一点。首先,让我们定义一个二维数组如下:

 int rows = 3;
 int cols = 3;
 int matrix[rows][cols] = {
   { 1, 2, 3 },
   { 4, 5, 6 },
   { 7, 8, 9 }
 };

现在,我们可以这样遍历它:

 int r = 0;
 row_loop:
 if (r < rows) {
  int c = 0;
   col_loop:
   if (c < cols) {
  cout << matrix[r][c] << " ";
     c++;
     goto col_loop;
   }
   cout << "\n";
   r++;
   goto row_loop;
 } return 0;}

你已经可以看到复杂性在这里有所增加。例如,你可以从内循环的末尾跳转到外循环的开始。这样,只有每一列的第一个条目会收到输出。程序变成了一个无限循环:

 int r = 0;
 row_loop:
 if (r < rows) {
   int c = 0;
  col_loop:
  if (c < cols) {
    cout << matrix[r][c] << " ";
     c++; 
    goto row_loop;
  }
  cout << "\n";
   r++;
   goto row_loop;
 }

不要重复自己 (DRY)

工程学的基本规则之一是为重复的逻辑创建抽象。循环的模式无处不在。你几乎可以在任何程序中体验到它。因此,进行抽象是合理的。这就是为什么当代语言,如 Java 或 C++,都有自己的内置循环机制。

它带来的不同之处在于,现在整个模式只由一个组件组成,即必须与特定语法一起使用的关键字:

#include <iostream>
using namespace std;
int main() {
  int rows = 3;
  int cols = 3;
  int matrix[rows][cols] = {
    { 1, 2, 3 },
    { 4, 5, 6 },
    { 7, 8, 9 }
  };
  for (int r = 0; r < rows; r++) {
    for (int c = 0; c < cols; c++) cout << matrix[r][c] << " ";
    cout << "\n";
  }
}

这里发生的事情是我们给这个模式起了一个名字。每次我们需要这个模式时,我们不需要从头开始实现它。我们通过它的名字来调用这个模式。

这种通过名字调用是声明式编程的主要原则:实现只重复一次的模式,给这些模式命名,然后在我们需要的地方通过名字来引用它们。

例如,whilefor 循环是循环的模式。它们被抽象出来,并在语言级别上实现。程序员可以在需要循环时通过它们的名称来引用它们。现在,犯错的几率大大降低,因为编译器知道这个模式。它将在编译时检查你是否正确地使用了这个模式。例如,当你使用 while 语句时,编译器将检查你是否提供了一个合适的条件。它将为你执行所有的跳转逻辑。

因此,你无需担心是否跳到了正确的标签,或者是否完全忘记了跳转。因此,你不可能从内循环的末尾跳转到外循环的开始。

你在这里看到的是从命令式到声明式的转变。你需要理解的概念是,我们使编程语言意识到某个模式。编译器被迫在编译时验证模式的正确性。我们指定了一次模式。我们给它一个名字。我们使编程语言对使用这个名称的程序员施加某些约束。同时,编程语言负责模式的实现,这意味着程序员不需要关心实现模式所使用的所有算法。

因此,在声明式编程中,我们指定需要做什么,而不指定如何做。我们注意到模式并给它们命名。我们实现这些模式一次,并在需要使用它们时通过名称调用它们。实际上,现代语言,如 Java、Scala、Python 或 Haskell,都没有 go-to 语句的支持。看起来,用 go-to 语句表达的大多数程序都可以转换成一系列模式,如循环,这些模式抽象掉了 go-to 语句。程序员被鼓励通过名称使用这些高级模式,而不是自己使用低级的 go-to 原语来实现逻辑。接下来,让我们通过声明式集合的例子来看这个想法是如何进一步发展的,以及它们与命令式集合有何不同。

声明式与命令式集合

声明式风格如何工作的另一个很好的例子可以在集合框架中看到。让我们比较命令式编程语言和函数式编程语言的集合框架,例如,Java(命令式)集合和 Scala(函数式)集合。

为什么需要一个集合框架?在任何编程项目中,集合无处不在。当你处理一个由数据库支持的应用程序时,你正在使用集合。当你编写一个网络爬虫时,你正在使用集合。实际上,当你处理简单的文本字符串时,你也在使用集合。大多数现代编程语言都将集合框架的实现作为其核心库的一部分提供给你。这是因为你几乎需要它们来完成任何项目。

我们将在下一章更深入地探讨命令式集合与声明式集合的不同之处。然而,为了概述的目的,让我们简要地讨论命令式和声明式集合方法之间的一项主要差异。我们可以通过过滤的例子看到这种差异。过滤是一个无处不在的操作,你很可能经常会这样做,所以让我们看看这两种方法之间的差异。

过滤

Java 是一个非常命令式编程方法的经典例子。因此,在其集合中,你会遇到典型的命令式编程操作。例如,假设你有一个字符串数组。它们是你公司员工的姓名。你想要创建一个单独的集合,只包含那些以字母 'A' 开头的员工姓名。在 Java 中你该如何做?

// Source collection
List<String> employees = new ArrayList<String>();
employees.add("Ann");
employees.add("John");
employees.add("Amos");
employees.add("Jack");
// Those employees with their names starting with 'A'
List<String> result = new ArrayList<String>();
for (String e: employees)
  if (e.charAt(0) == 'A') result.add(e);
   System.out.println(result);

首先,你需要创建一个单独的集合来存储你的计算结果。因此,我们创建一个新的字符串 ArrayList。之后,你需要检查每个员工的姓名,以确定它是否以字母 'A' 开头。如果是,将这个姓名添加到新创建的数组中。

可能会出什么问题?第一个问题是你要存储结果的集合。你需要调用 result.add() 来添加到集合中——但如果你有几个集合,你可能会添加到错误的集合中?在那个代码行,你可以自由地添加到任何集合,所以可以想象你会添加到错误的集合——而不是你专门为过滤员工而创建的集合。

这里可能出现的另一个问题是,你可能会忘记在大型循环中编写 if 语句。当然,在这样一个简单的例子中,这种情况不太可能发生,但请记住,大型项目可能会膨胀,代码库可能会变得很大。在我们的例子中,循环体少于 10 行。但如果你有一个代码库,其中的 for 循环长达 50 行,例如?在那里,你不会忘记编写你的谓词,或者将字符串添加到任何集合中,这一点并不明显。

这里的要点是我们有与loopgo-to示例中相同的情况。我们在代码库中有一个在集合上执行的操作模式,这个模式可能会重复。模式是由多个元素组成的,其过程如下。首先,我们创建一个新的集合来存储我们计算的结果。其次,我们有一个循环,它遍历我们集合中的每个元素。最后,我们有一个谓词。如果它是真的,我们将当前元素保存到结果集合中。

我们可以想象同样的逻辑也可以在其他上下文中执行。例如,我们可以有一个数字集合,并希望只取那些大于10的数字。或者,我们可以有一个包含我们网站所有用户的列表,并希望取那些在特定年份访问网站的用户的年龄。

我们刚才讨论的特定模式被称为过滤模式。在 Scala 中,每个集合都支持一个定义在其上的方法,该方法抽象了过滤模式。这是通过以下方式实现的:

// Source collection
val employees = List(
  "Ann"
, "John"
, "Amos"
, "Jack")
// Those employees with their names starting with 'A'
val result = employees.filter ( e => e(0) == 'A' )
println(result)

注意,操作保持不变。我们需要创建一个新的集合,然后根据某些谓词将旧集合中的元素合并到新集合中。然而,在纯 Java 解决方案的情况下,我们需要执行三个单独的操作来得到期望的结果。然而,在 Scala 声明式风格的情况下,我们只需要指定一个动作:模式的名称。模式是在语言内部实现的,我们不需要担心它是如何实现的。我们有一个精确的关于它是如何工作以及它做了什么的说明,并且我们可以依赖它。

这里的优势不仅在于代码变得更易于阅读,因此也更容易推理。它还增加了可靠性和运行时性能。原因是这里的过滤模式是 Scala 核心库的一个成员。这意味着它经过了良好的测试。它已经在许多其他项目中使用过。在这种情况下可能存在的微妙错误很可能已经被捕捉并修复。

还要注意,匿名 lambda 的概念在这里被引入了。我们将一个 lambda 作为参数传递给filter方法。它们是内联定义的函数,没有通常繁琐的方法语法。匿名 lambda 是函数式语言的一个常见特性,因为它们增加了你抽象逻辑的灵活性。

其他语言中的声明式编程

在其他现代语言中,如 Haskell 或 Python,类似的声明式功能也是现成的。例如,你可以在 Python 中执行过滤操作——它是语言的一部分,并且在 Haskell 中有一个特殊的功能来执行相同的过滤操作。此外,Python 和 Haskell 的函数式特性使得自己实现相同的控制结构(如过滤)变得容易。Haskell 和 Python 都支持 lambda 函数和高阶函数的概念,因此它们可以用来实现声明式控制结构。

通常,你可以通过查看语言提供的功能来识别一个语言是否适合声明式编程。你可以寻找的一些特性包括匿名函数、函数作为一等公民和自定义操作符指定。

匿名 lambda 函数给你带来了很大的优势,因为你可以直接将函数传递给其他函数,而不必先定义它们。这在指定控制结构时尤其有用。以这种方式表达的功能,首先和最重要的是,用于指定一个将输入转换为输出的转换。

你可以在编程语言中寻找的另一个特性是支持函数作为一等公民。这意味着你能够将一个函数赋值给一个变量,通过该变量的名称引用函数,并将该变量传递给其他函数。将函数视为普通变量可以使你达到一个新的抽象层次。这是因为函数是转换;它们将输入值映射到某些输出值。而且,如果语言不允许你将转换传递给其他转换,这将是灵活性的限制。

你可以从声明式语言中期待的一个特性是,它们允许你创建自定义操作符;例如,Scala 中可用的合成糖允许你非常容易地定义新的操作符,就像类中的方法一样。

摘要

声明式风格是一种编程风格,其中你通过名称调用你想要执行的操作,而不是通过编程语言提供的底层原语以算法方式描述如何执行它们。这自然符合 DRY 原则。如果你有一个重复的操作,你想要将其抽象化,然后在以后通过名称引用它。换句话说,你需要声明该操作有一个特定的名称。而且,每次你想使用它时,你都需要声明你的意图,而不直接指定它应该如何实现。

现代函数式编程与声明式风格相辅相成。函数式编程为你提供了一个更好的抽象层次,可以用来抽象化重复的操作。

在下一章中,我们将看到函数作为一等公民的支持如何对声明式编程风格有益。

问题

  1. 声明式编程背后的原则是什么?

  2. DRY 代表什么?

  3. 为什么使用goto是坏风格?

第二章:函数与 Lambda 表达式

函数式编程范式与声明式编程范式有很多共同特征。函数式语言和声明式编程的一个定义特征是广泛使用函数。本章将更详细地讨论函数是什么以及它们在不同范式中的意义。我们将探讨如何使用函数以及它们在现代编程语言中的角色。

在本章中,我们将涵盖以下主题:

  • 函数作为行为

  • 函数式编程中的函数

  • 高阶函数

  • Lambda 表达式

  • 不同编程语言中函数的概念

函数作为行为

那么,什么是函数?我们可以将它们定义为参数化、命名的代码块。这意味着它们是可以从程序的任何其他部分通过其名称调用的代码块。参数化意味着你可以用某些参数来调用它们。使用不同参数执行的不同调用通常会导致不同的结果。

函数背后的动机是什么?答案是工程的基本原则——抽象出重复的部分。在第一章《声明式编程风格》中,我们看到了循环中的类似情况。然而,循环是内置的控制结构。这意味着它们在语言级别上定义。当我们需要在语言用户级别上定义某些逻辑,并且这种逻辑在项目的不同部分中重复时,函数就派上用场了。

我们可以将函数的概念追溯到过程式编程。在过程式编程中,函数是抽象的一个单元。这意味着函数封装了重复的逻辑。在面向对象编程中,我们对函数的理解有所发展。函数通常在对象或类的上下文中被看待。在这种情况下,它们扮演着对象行为的作用。

例如,如果你有一个名为汽水机的对象,这个对象可能与其关联某些行为,例如将硬币投入机器,或者按按钮从机器中获取一听汽水。

函数式编程中的函数

在命令式编程中,函数用于表示对象的行为。在面向对象编程中,行为通常意味着副作用。为了本书的目的,我们可以将副作用理解为以下内容——当一个函数修改其自身主体之外的环境时,它就是有副作用的。例如,它可以修改其父对象的全局变量,它可以向文件系统写入文件,或者函数可以在网络上执行某些 Web API 调用。

在函数式编程中,对函数的理解相当不同。在函数式编程中,我们重视纯净性和引用透明性。纯净性意味着没有副作用。引用透明性意味着函数计算出的结果值可以替换函数调用,而程序执行的语义将保持不变。

考虑以下示例。你有一个模拟饮料机的应用程序。它的行为是将硬币插入饮料机并从饮料机中取回饮料罐。饮料机由数据组成:机器中存在的钱和饮料罐的数量。每次插入硬币时,就会卖出一个饮料罐。

我们如何以命令式风格表达这种行为?我们可以创建一个单独的对象,称为饮料机,并在该对象中创建一个分发罐子的方法。每当这个方法被调用时,其中的硬币数量就会增加一个,而饮料罐的数量就会减少一个。此外,我们还想从该方法返回一个名为SodaCan的对象。

在面向对象编程的精神下,我们可以将饮料机表示为一个具有一些内部状态的对象:

public class ImperativeSodaMachine {
 private int coins = 0;
 private int cans  = 0;
 public ImperativeSodaMachine(int initialCans) {
   this.cans = initialCans;
 }

我们也可以定义一些机器上的行为:插入硬币并取回饮料罐时需要发生的行为。如果机器中还有饮料罐,我们就减少一个饮料罐的数量,增加一个硬币的数量,并返回一个饮料罐对象给用户。如果没有饮料罐了,我们抛出一个异常:

 public SodaCan insertCoin() {
   if (cans > 0) {
     cans--;
     coins++;
     return new SodaCan();
   }
   else throw new RuntimeException("Out of soda cans!");
 }
}

最后,SodaCan对象被定义为以下内容:

public class SodaCan {
 public void drink() {
   System.out.println("You have drunk a can of soda.");
 }
}

在饮料机中存在的罐子和钱数是饮料机的变量。它们不属于函数体。这就是为什么改变其自身作用域之外变量的函数构成了一个有副作用的函数。

虽然命令式方法被概念化为有副作用的操作,但函数式风格的函数被概念化为计算某些值的计算。在函数式世界中,副作用是不受欢迎的。让我们以前面程序的方式表达纯函数式的程序。我们将有一个没有行为的饮料机,因为行为是有副作用的。在函数式世界中,副作用通常是不好的,正如我们在后续章节中将学到的。而不是那种行为,你将有一个计算饮料机新状态的函数。这是一个从旧饮料机对象生成的新饮料机对象。这样的饮料机对象是一个不可变对象,这意味着它只包含无法修改的值。这有助于消除副作用,因为现在,定义在饮料机上的函数不能修改其作用域之外的变量。每次我们想要得到一个新的罐子时,我们也需要计算罐子分发后的饮料机新状态,然后从这个机器中返回一个罐子:

case class SodaMachine(cans: Int, coins: Int = 0)
def insertCoin(sm: SodaMachine): (SodaMachine, SodaCan) =
 if (sm.cans > 0) (SodaMachine(sm.cans - 1, sm.coins + 1), new SodaCan)
 else throw new RuntimeException("Out of soda cans!")

以这种方式表达的计算不会影响其自身作用域之外的环境。我们不再修改某些外部变量,也不会与函数作用域之外的世界交互。我们只是根据函数的输入计算结果值。这是函数式编程世界中函数的理解。本书稍后我们将讨论这种理解在行为方面比原始的方法理解更有益。

函数式风格的唯一特征不是副作用的存在。接下来我们要看的是高阶函数——接受其他函数作为输入的函数。

高阶函数

函数式编程中出现的另一个重要概念是高阶函数。高阶函数是接受一个函数作为参数的函数。一个可能非常有用的简单例子是控制结构。例如,一个while循环可以用函数式的方式表示为一个接受循环体和谓词作为参数的高阶函数。

循环体可以表示为一个不接受任何参数但计算一些副作用的功能。其工作方式是,我们有一个函数接受一个0-参数函数和一个谓词,当谓词为真时,我们递归地调用相同的loop函数。

我们可以将新的控制结构命名为whileDiy,它可以定义为如下:

@annotation.tailrec
def whileDiy(predicate: => Boolean)(body: => Unit): Unit =
  if (predicate) {
    body
    whileDiy(predicate)(body)
  }
}

whileDiy构造接受一个谓词和一个体。谓词将在每次函数调用时被评估,如果为真,我们将运行体并再次递归调用whileDiy构造。注意,在方法顶部的@annotation.tailrec注解上,它表明该方法将以尾递归方式调用,这意味着即使它是递归的,也没有机会导致StackOverflowError。这是因为它将重用其初始调用的框架进行所有后续递归调用。

我们可以这样使用新的构造:

var j = 0
whileDiy (j < 5) {
  println(s"Printing from custom while loop. Iteration: $j")
  j += 1
}

将其与内置的while循环的使用方式进行比较:

var i = 0
while (i < 5) {
  println(s"Printing from built-in while loop. Iteration: $i")
  i += 1
}

使用方式几乎相同。这说明了高阶函数可以用来定义与语言内建的控制结构非常接近的控制结构。

理解 lambda 函数

大多数函数式语言都有一个 lambda 函数的概念。它是一个定义在行内的匿名函数。如果需要,它可以被分配给一个变量。例如,考虑在一个 Web 应用程序的上下文中,我们需要一个接受带有用户会话数据的 cookie 的函数。它的任务是向用户打印标准输出的问候语。然而,在打印之前,我们需要以某种方式装饰用户的姓名。更复杂的是,我们还有一个拥有博士学位的用户数据库,如果他们有,我们需要称他们为 Dr.以下是在 Scala 中如何实现它的示例:

  1. 我们为示例定义了一个虚拟的Cookie类:
case class Cookie(name: String, gender: String)
  1. 我们定义了greeting方法。该方法的工作是从cookie对象中提取数据,并根据用户的性别应用修改器到用户的名字。

  2. 然后,问候用户。此方法不知道如何确切地修改名字。modifier逻辑被抽象化,我们依赖于调用者指定如何进行此操作:

def greeting(cookie: Cookie)(modifier: (String, String) => String): Unit = {
     val name         = cookie.name
     val gender       = cookie.gender
     val modifiedName = modifier(name, gender)
     print(s"Hello, $modifiedName")
}
  1. 最后,这是调用此方法的方式:
def isPhd(name: String): Boolean = name == "Smith"
val cookie = Cookie("Smith", "male")
greeting(cookie) { (name, gender) =>
  if (isPhd(name)) s"Dr $name"
  else gender match {
    case "male"   => s"Mr $name"
    case "female" => s"Mrs $name"
  }
}

greeting函数接受一个字符串和一个修改这个字符串的函数。注意,在调用此函数时,我们如何内联指定修改字符串的函数。我们不需要在传递给greeting函数之前定义该函数。

这就是 lambda 函数背后的理念。在使用某些高阶函数之前,你不需要先定义一个函数。相反,你可以使用 lambda 语法内联定义这样的函数。显然,这种方法在处理高阶函数的上下文中特别有用。它允许你使用高阶函数,而无需首先定义它们的参数。

嵌套函数的概念在大多数函数式语言中都有体现,包括 Scala、Haskell 和 Python。

不同编程语言中函数的概念

函数存在于许多编程语言中。有些语言对纯函数式风格的支持更好,而有些则更倾向于声明式风格。这就是为什么,例如,使用 Scala 而不是 Java 可以给你带来巨大的优势,因为你可以更容易地在其他函数内部声明函数,你可以声明接受其他函数(高阶函数)的函数,并且你可以声明匿名 lambda 函数(从 Java 8 开始,Java 也提供了这种功能)。这大大增加了你的抽象能力,创建控制结构的能力,从而使得你的应用程序能够以更DRY不要重复自己)的方式表达。

摘要

在本章中,我们看到了函数是什么以及它们是如何从编程的早期发展到今天的。我们看到了函数最初是如何被视为常见逻辑的抽象的。之后,在面向对象编程中,它们代表了某些对象的行为。面向对象程序员试图将一切视为对象。因此,函数开始从由对象组成的世界中看待。在这种情况下,函数最好被视为这些对象的行为。

在函数式编程中,函数可以从不同的角度来理解。现在,最好的方式是将函数视为数学计算。它们以纯方式从输入中计算出一些值,这意味着没有任何副作用。这种想法是将它们视为数学函数。

函数式编程接近声明式编程,因此其函数也经常根据那种风格的需求进行定制。这种方式,在函数式语言中,存在高阶函数、匿名 lambda 函数和部分函数的概念。从工程角度来看,这很有用,因为它极大地增强了你的抽象能力。

在编程中,数据结构无处不在。当采用函数式风格时,迟早你会遇到如何在函数式方式下处理数据结构的问题。在下一章中,我们将看到这个问题是如何被解决的。

问题

  1. 函数在面向对象编程的上下文中是如何被解释的?

  2. 函数在纯函数式编程的上下文中是如何被解释的?

  3. 高阶函数是什么?

  4. 高阶函数为什么有用?

第三章:函数式数据结构

编程在很大程度上处理数据操作。不同的编程风格会以不同的方式处理数据结构和数据本身。例如,命令式编程将数据视为存储在内存中的可变信息。我们将看到函数式编程的处理方式与命令式编程有何不同。

在本章中,我们将涵盖以下主题:

  • 集合框架

  • 代数方法

  • 效果类型

  • 不同编程语言中的数据结构

集合框架

讨论数据结构时,自然要从集合开始。集合是一种抽象掉多重性的数据结构。这意味着,无论何时你有多于一个特定种类的项目,并且想要对这个数据进行一系列操作,你都需要一个适当的抽象——一个在你遇到多重性时建立游戏规则的抽象。

结果表明,你几乎在每一个编程项目中都需要处理这种类型的抽象。当你处理字符串时,你经常需要将它们表示为字符的集合。每当你有数据库应用程序,并且你对这个数据库有一些查询时,你需要将这些查询的多个结果作为集合来展示。每当你在处理文本文件时,你可能想要将其表示为行列表。这种情况相当常见,例如,处理配置文件时。我们将在单独的行上指定我们的配置条目。例如,以下是我们可能表示服务器连接配置的方式:

host=192.168.12.3
port=8888
username=root
password=qwerty

或者,例如,你可能想要通过 Web API 进行数据通信。大多数现代 Web API 以 JSON 或 XML 的形式通信数据。这些都是表示数据的有结构方式,如果你仔细观察,你会注意到它们遵循一个模式;例如,一个 XML 文件由多个节点组成的树构成,一个 JSON 对象可能包含多个条目。

因此,无论你在进行编程项目时,你很可能都需要处理某种多重性的抽象。你需要一个集合框架。因为集合如此普遍,现代编程语言在它们的内核库中包含一个集合框架是很自然的。这就是为什么查看一个语言的集合框架是了解该语言哲学及其一般编程方法的一种简单方式。

在本节中,我们将比较 Java 和 Scala 的集合框架。Java 代表了一种传统的、命令式的编程方法,因此其集合框架也反映了这种方法。另一方面,Scala 代表了一种函数式、声明式的编程方法。其集合框架是根据函数式和声明式编程的哲学构建和组织的。

命令式集合

让我们来看看在命令式编程语言的框架内,集合是如何被理解的,同时看看 Java 对列表的抽象。它的 API 文档可在docs.oracle.com/javase/8/docs/api/java/util/List.html找到。这个接口只定义了有限数量的方法。在这里,我们需要注意的第一件事是可变性。立即,我们看到像addremove这样的方法。这表明这个接口应该被一个可变集合实现,该集合应该实现添加或从其中移除数据的功能。你应该知道,方法可能会抛出UnsupportedOperationException,这意味着某些集合可能会实现这个接口;然而,它们将不会实现这两个操作。在本书的后面部分,我们将看到函数式编程并不欢迎这类异常,因为它们是一种副作用,在这里,这一点尤其明显。面向对象编程的一个基本原则是多态性,这意味着你可以在一个类之上放置一个接口,然后,你就可以根据这个接口与这个类进行交互,而不必关心其内部实现。接口应该是一个与对象交互的协议;它应该指定它支持哪些行为,如果某个行为不受支持,抛出异常是 Java 的一个相当笨拙的做法,因为即使接口声明了支持,你也需要记住某些行为是不支持的。这进一步增加了程序员的心理负担,因此,它可能导致错误。

我们应该注意的另一个特性是,这里定义的其他方法相当低级。你有能力向集合中添加内容,也能从集合中移除内容。假设你需要对集合进行任何操作,你都将能够借助这些以及其他接口提供的低级方法来完成。这是通过编写一个命令式算法来实现的,该算法指定了如何使用语言提供的低级原语来执行必要的操作。这反过来意味着,你必须精通算法才能编写有效的 Java 程序,因为算法的使用是你唯一的选择。

事实上,在计算机科学和编程中,长期以来一直有一个传统,那就是高度重视算法。数据被看作是某种可变信息,以某种特定媒介书写。程序员的任务是指定一系列步骤来根据需求修改这些数据。因此,在计算机科学中,人们首先学习的就是像冒泡排序这样的排序算法。

算法当然是必要的。在任何计算机程序的背后,算法正是完成工作的东西。然而,它们并不是人类阅读、理解和编写程序的最佳方式。由于它们的反直觉性,它们可能会出错。

现在,让我们来看看函数式集合。

函数式集合

在函数式语言中,情况完全不同。让我们来看看 Scala 库中的相同抽象,即 List。它的 API 文档可在 www.scala-lang.org/api/current/scala/collection/immutable/List.html 找到。它包含比 Java 的 List 更多的方法。与 Java 的 List 接口相比,Scala 的 List 抽象是不可变的。这意味着一旦你创建了一个列表,你就无法修改它。所有对列表的修改都可以通过仅创建列表的修改副本来实现。这个概念被称为结构共享。这意味着列表的成员对象没有被复制,只是列表的结构被重新创建。因此,你不必担心内存泄漏,因为只有结构是新生成的。列表的成员对象不会被重新创建。

同时,也存在大量的声明式方法——例如,filtermapflatMap 的高级原语。与 Java 相比,这些方法指定了相当高级的操作。在上一章中,我们看到了在 Java 中定义过滤操作是多么的繁琐。相比之下,在 Scala 中,只需指定需要执行的操作的名称,你就不必担心这个操作是如何实现的。这似乎是时候将 goto 语句与之进行比较了。这是现代编程语言的显著特性之一;你可以用 goto 表达的程序也可以用几个控制结构来表达。同样,所有集合程序都可以使用大约十几个声明式高级方法来表达。你不必每次需要循环时都指定如何使用 goto。同样,如果可以在语言的核心库中命名和实现,就不必指定如 filter 这样的集合操作。

虽然 Java 和命令式语言侧重于算法推理,但函数式语言,例如 Scala,侧重于代数推理。这意味着它们将数据转换视为代数表达式。数据可以被视为某些代数的操作数,并且可以通过高级运算符与其他数据结合并转换,以获得新的数据。因此,程序不再是算法定义的,而是用数学表达式来定义的;这些表达式根据它们的输入计算某些值。

当在声明式语言中编程时,你不再需要像在 Java 这样的命令式语言中那样成为算法专家。这是因为你可能需要的所有算法都已经实现在了语言的核心库中。当然,当你对一个 Scala 集合调用如filter这样的声明式方法时,底层会执行一个算法。这种方法的优点在于你根本不需要意识到这一点。你得到了一个高级构建块,你需要用这些构建块来表达你的程序。你不需要担心这些块是如何创建的。

与命令式方法相比,有诸多好处。你无需处理难以阅读且容易出错的算法。你所需要的一切已经在语言层面上实现。这意味着该语言的多个项目都会使用这种实现。因此,你可以确信你所使用的是经过广泛测试的,从而大大降低了你编写出有缺陷代码的可能性。

与关注底层操作不同,你可以专注于用高级术语描述你的程序。考虑我们在第一章中看到的过滤示例,声明式编程风格。阅读声明式代码要容易得多,因为你一眼就能看到filter这个词,而这个单词代表了一个完整的操作。在 Java 的情况下,我们需要一个循环和两个集合的操作来完成同样的任务,代码的含义并不明显。这就是为什么声明式程序对人类来说更容易阅读。

让我们看看另一个例子——映射。映射是一个逐个转换集合元素的过程。这意味着你接受一个集合作为输入,通过以某种方式转换原始集合的每个元素来生成另一个集合。例如,如果你有一个整数列表,你可以通过一个函数将这个列表映射为每个数字平方。如果你在集合中有数字123,你将得到一个新的集合,包含数字149

让我们以命令式方式在 Java 中执行这个操作。首先,让我们定义我们将要映射的集合:

List<Integer> numbers = new ArrayList<Integer>();
numbers.add(1);
numbers.add(2);
numbers.add(3);

接下来,我们将创建一个新的集合,我们将把结果写入这个集合。然后,我们将遍历原始集合的每个元素,对这些元素应用所需的函数,然后将它们添加到结果集合中:

List<Integer> result = new ArrayList<Integer>();
for (Integer n: numbers)
  result.add(n * n);

现在,让我们看看这是如何用 Scala 完成的:

val numbers = List(1, 2, 3)
val result  = numbers.map(n => n * n)
println(result)  // List(1, 4, 9)

在 Scala 中,我们只需在需要映射的集合上调用内置的原始方法 map。在这里,我们也可以看到 lambda 函数在这里扮演的角色。我们可以将映射函数指定为 lambda 函数,作为 map 方法的参数。在 Java 中,lambda 函数从版本 8 开始才被支持,因此,这种风格在最近之前在该语言中是不可能的。这里的通用模式是我们通常需要抽象出整个计算。我们需要将一个计算嵌入到另一个计算中。这可以通过 lambda 函数来实现。

代数方法

函数式和声明式编程也可以很好地理解为代数风格。就我们的目的而言,代数方法可以被视为一种数学表达式的语言——一种由两个主要元素组成的语言:运算符和操作数。操作数可以理解为数据,是你想要操作的信息,而运算符可以理解为它们的行为以及如何利用这些数据。

考虑表达式 1 + 2。在这里,数字 12 是操作数。它们代表一些数值数据。+ 符号是一个将它们结合在一起的运算符。它具有与之相关的某些语义,即把一个数加到另一个数上。但重要的是要记住,表达式的符号结构和它的语义是两回事。你可以按照前面指定的方式取这个表达式,并给数字 1*2* 以及符号 + 赋予不同的意义,这样表达式的语义就会完全不同。

这种推理方法可以应用于声明式程序。例如,考虑以下 Scala 中的片段:

val result  = numbers.map(n => n * n)

可以使用 Scala 的中缀表示法重写如下:

val result  = numbers map (n => n * n)

这是因为 Scala 支持一种语法糖,允许以中缀运算符的方式调用 Scala 方法。这里的要点是你可以把 map 函数读作一个运算符。将它的操作数结合在一起的运算符指定了要对它们做什么。第一个操作数是我们映射的集合,而第二个操作数是你用来映射它的 lambda 函数。

程序的行为在这里用将它们的操作数结合在一起的运算符来表示,而程序的执行则被理解为计算某个值,而不是算法的执行。

这里的一个优点是缺乏变化的概念。在代数中,时间实际上被从方程中移除了。考虑 Java 中 map 功能的实现。它是算法性的,这意味着你可以在时间上解释它,如下所示:

  1. 取集合的第一个元素。

  2. 然后,对此元素应用一个函数。

  3. 然后,将其插入到结果集合中。

  4. 用相同的过程处理第二个元素,然后是第三个,依此类推。

注意到前述描述中时间的存在。你清楚地有一个关于先发生什么和之后发生什么的观念。

让我们看看该功能的 Scala 实现。在 Scala 中,你通过算术表达式指定程序需要做什么,作为两个操作符的绑定,即集合和 lambda 函数,通过 map 操作符。这个表达式不再与它相关联的时间维度。你只需写下一个数学表达式,然后让你的语言为其分配一些语义。

请注意,Scala 中的 map 函数是通过算法实现的,并且它可能的工作方式与 Java 中的一样。然而,从所有目的和用途来看,在大多数你将要编写的程序中,你可以忘记这一点。你可以将这个程序视为一种不同的范式,一种象征性地表达你想要做什么的范式。

这种范式将语义与你的程序结构分开。当我提到结构时,我指的是描述程序所涉及的符号;就像你在映射的集合中的符号,你通过映射的 lambda 函数,以及作为操作符的 map。所有这些实体都是通过你写的符号来引用的。至于语义,我指的是计算机在处理这个表达式时执行的动作,它是如何理解这个表达式的,以及它是如何运行这个表达式的。

以这种方式思考程序允许你将它们视为数学表达式,并以与数据结构相同的方式处理它们——表达式的符号结构。这与传统的命令式编程中流行的算法方法形成对比,正如我们通过涉及 Java 的例子所看到的。

这里的好处是,使用符号逻辑来解释以数学表达式而不是算法表达的程序更容易。其次,在适当的声明性程序中,没有时间维度,这消除了整个类别的错误。当然,你应该记住所有这些都是一种抽象。你可以说这几乎是一种错觉。在底层,算法仍然很重要;时间仍然存在。然而,在声明性风格中,你利用了抽象的原则。你抽象出时间和算法。这类似于当你用像 Java 这样的高级语言编写程序时,你不需要考虑编译成字节码或低级处理器指令。这种低级代码仍然存在,它仍然很重要,但就所有目的和用途而言,你可以忘记它。同样的事情发生在声明性和代数风格中抽象出算法的情况下。

将程序视为数学表达式是通过抽象掉副作用的技术来实现的。这些技术依赖于纯函数式编程中特定的数据结构。现在让我们看看这样的数据结构。

效果类型

之前,我们以集合为例讨论了命令式和声明式数据结构。然而,函数式和声明式风格也包含一些特定的数据结构。

集合抽象了多重性。像 Scala、Haskell 这样的函数式语言引入了一些其他数据结构,它们抽象了副作用。我们可以将它们称为效果类型。

我们已经论证了纯代数和声明式方法从等式中移除了时间。这是有利的,因为时间会消耗程序员的思维。函数式编程通过从你的程序中移除副作用来进一步发展这个想法。它们也给思维带来了负担,因为你也需要考虑它们并正确处理它们。

之前,我们讨论了一个 Java 列表接口抛出异常的例子。我们争论说这很糟糕,因为它增加了程序员的认知负担,因为他们需要不断记住可能会抛出异常的情况,并且应该考虑到这些情况。在函数式编程中,这是不可接受的。函数式编程追求从等式中消除所有副作用。

我们将在本书的后面详细讨论如何做到这一点,但现在,让我们看看 Try 结构。

Try

Try 数据结构以某种形式存在于许多编程语言中。它可能包含两个值之一。一种可能性是任意类型的值 A,而另一种是异常。这种数据结构可以作为可能产生错误的计算的结果返回。这样,你就不需要再抛出异常了。当你的方法可能产生错误时,你只需返回 Try[A]。在 Scala 中,类型名称后面的方括号表示类型参数,因此 Try[A] 表示具有类型参数 ATry 类型。

例如,考虑一个函数,它将一个数字除以另一个数字。然而,如果第二个数字为零,我们会抛出异常:

def division(n1: Double, n2: Double): Double =
  if (n2 == 0) throw new RuntimeException("Division by zero!")
  else n1 / n2

当我们调用方法时,在某些情况下它可能导致异常——这是我们可能没有意识到或忘记的副作用:

println(division(1, 0))  // throws java.lang.RuntimeException: Division by zero!

程序将在这一点崩溃。然而,如果我们用 Try 包装它,我们可以防止程序崩溃:

def pureDivision(n1: Double, n2: Double): Try[Double] =
  Try { division(n1, n2) }
println(pureDivision(1, 0))  // Failure(java.lang.RuntimeException:  
 Division by zero!)

因为返回类型清楚地指定了错误的可能性,所以不再需要记住这一点。由于错误现在表示为数据结构,它不会在发生时破坏程序。在这里,我们可以看到将现象表示为数据。将现象表示为数据称为 具体化,我们将在本书的后面看到这个概念在纯函数式编程中的重要性。

Option

功能性语言中具有代表性的数据结构示例之一是OptionOption可以包含一个值或者为空。你可以将其视为 Java 或 C++中空指针概念的抽象。这里的优势是程序员不再需要记住某些方法返回 null。可能或可能不返回值的方法将返回Option[A]来表示这种可能性,就像在Try的情况下一样。

例如,考虑一个通过 ID 返回用户名字的方法。有些 ID 不会映射到用户。因此,我们可以模拟以下场景:我们无法返回一个不存在的用户。

def getUserName(id: Int): Option[String] =
  if (Set(1, 2, 3).contains(id)) Some(s"User-$id")
  else None

即,如果用户 ID 是123,我们推测该用户存在于数据库中,否则他们不存在。我们明确地将用户是否存在的信息作为Option类型包含在内。也就是说,我们不仅返回用户的名字,还返回他们是否存在的信息。

这里的优势是,你无法在不检查他们是否被找到的情况下访问用户的名字:

getUserName(1) match { // "User-1"
  case Some(x) => println(x)
  case None => println("User not found")
}

在这里,getUserName的结果不是一个原始的String,而是一个被Option包裹的String。因此,我们在获取结果之前,首先使用模式匹配语句分析Option

前面的例子将User-1输出到控制台。然而,这个例子输出User not found

getUserName(10) match { // "User not found"
  case Some(x) => println(x)
  case None => println("User not found")
}

不同编程语言中的数据结构

从前面的讨论中,你可能得出结论,函数式编程和比较式编程之间存在实质性的差异。虽然命令式编程关注算法,但声明式编程关注这些算法产生的现象。

命令式编程允许你通过算法产生现象。声明式编程命名了你可能需要的现象,然后允许你通过名称调用它们。这抽象掉了现象内部工作细节的所有细节。

这在不同语言的数据结构方法中得到了体现。命令式编程语言,如 C++或 Java,将具有它们自己的数据结构,特别是集合,以低级方式实现。通常,它们将是可变的,并在其中定义一些非常基本的原始方法。无论你想表达什么,你都需要借助这些原始方法以算法的方式表达出来。

函数式编程语言,如 Scala 或 Haskell,通常会有不可变的数据结构。它们关注现象和完成工作所需的高级行为。高级行为示例包括将某种类型的值映射到另一种类型的值,以及从值集合中过滤出某些值。

通常来说,使用纯函数式和声明式编程集合进行编程要容易得多。它们为你提供了大量的构建块,用于构建你的程序。

然而,在某些情况下,你可能希望使用命令式数据结构。如果你想要自己设计算法而不是依赖现成的实现,那么低级编程风格可能是可取的。所讨论的情况可能包括高性能操作,例如。

在游戏行业中,如果你正在设计一个性能要求高的游戏,那么在游戏的某些部分,你可能需要自己编写操作以满足性能要求。此外,在微控制器编程或计算资源有限且需要充分利用现有资源的情况下,你可能需要采用这样的低级方法。

摘要

在本节中,我们探讨了不同的编程风格如何定义数据结构以及它们使用这些数据结构构建程序的方法。我们看到了命令式编程如何高度依赖算法和底层操作。你已经了解了基本可变数据结构和用于修改数据结构的基本操作,以及如何借助这些数据结构在你的编程语言中选择性地组合算法。

相比之下,在声明式风格中,焦点从算法转移到数学表达式。集合数据结构通常是不可变的。在这些数据结构上定义了许多高级操作。你使用这些操作来表达程序,而不是使用算法,而是作为一组代数表达式。

集合几乎是任何程序的主要方面之一。因此,大多数现代编程语言都默认支持它们,并且从集合框架来看,可以说出该编程语言遵循的方法和哲学。

除了集合之外,还有一些特定于函数式编程的数据结构。这些数据结构将在本书的后续章节中详细讨论。目前,值得注意的是,例如TryOption这样的数据结构是必要的,以抽象出程序中可能发生的副作用。

一些这些函数式编程特定的数据结构旨在将副作用引入纯函数式范式。使用这些结构,你可以在保持引用透明性的同时处理副作用。在下一章中,我们将详细探讨副作用的问题。

问题

  1. 当你编写一个基于命令式集合的应用程序时,你通常采取什么通用方法?

  2. 当你编写一个基于功能集合的应用程序时,你通常采取什么通用方法?

  3. 在处理功能数据结构(在大多数情况下)时,为什么不需要训练算法推理?

  4. 编程的代数方法是什么?

  5. 采用代数风格的优点是什么?

  6. 类似于 OptionTry 这样的效果类型有什么作用?

进一步阅读

Vikash Sharma 的《学习 Scala 编程》以及名为 熟悉 Scala 集合 的章节(www.packtpub.com/application-development/learning-scala-programming)。

第四章:副作用的难题

纯函数编程全部关于移除副作用和突变。我们这样做是有原因的。在本章中,我们将看到共享可变状态和有副作用的函数如何引起问题,以及为什么最好减少它们。

讨论的主题如下:

  • 副作用

  • 可变状态

  • 纯函数

  • 通常遇到的副作用

  • 不同编程语言中的纯函数范式

副作用

那么,副作用究竟是什么,为什么应该避免它们?为了这次讨论,我们可以将副作用定义为函数代码中的一些指令,这些指令修改了函数作用域之外的环境。最常见的副作用例子是程序抛出的异常。抛出异常是一种副作用,因为如果你不处理它,它将破坏函数作用域之外程序的行为。所以程序将在这一点上崩溃,并停止执行。

以上一章中的碳酸饮料机示例为例。模拟投币功能的函数如果投币机中没有碳酸饮料罐,则会抛出异常。所以如果你尝试在一个空碳酸饮料机上调用这个函数,你的程序将永远不会通过函数调用点,因为会抛出异常。除非你在同一个调用点使用try语句来处理这个异常。注意,这会把处理副作用的责任放在客户端,这可能不是我们想要做的。

你还会遇到另一个副作用,即函数返回 null。例如,你有一个用户数据库。你可以添加一个函数,该函数查询数据库并根据 ID 返回用户:

case class User(name: String)
def getUser(id: Int): User =
 if (Set(1, 2, 3).contains(id)) User(s"User-$id")   
 else null

有时这个函数会被调用,传入的用户 ID 在数据库中不存在。对于数据不存在的问题,传统的 Java 解决方案是返回 null。快速返回 null 会迅速产生问题。它违反了调用此函数的程序员对结果的预期。函数的返回类型被设置为User。程序员有理由期望从这个函数中得到一个User类型的对象。因此,他们可能会尝试调用该对象的某些User方法:

println(getUser(1 ).name)  // User-1
println(getUser(10).name)  // NullPointerException

在 null 对象上调用User类型的方法会导致空指针异常,除非你首先验证该对象不是 null。

这里的问题之一是副作用在函数的签名中没有任何体现。此外,它们的处理也不是由编译器强制执行的。当然,在 Java 中,你需要在它们的签名中声明函数抛出的某些异常。然而,这已被证明是一个糟糕的设计决策,并且大多数语言都不要求这种声明。此外,即使在 Java 中,也没有声明指定函数可以返回 null。除非程序员记得程序中存在这样的副作用,否则他们可能不会处理它们。所以除非他们处理了副作用,否则程序可能会出错。

这里主要的问题是给程序员带来了额外的心理负担。他们需要不断地记住他们函数可能产生的所有副作用。因为如果不这样做,他们也可能忘记正确处理它们。因此,他们的代码可能会引入错误。这个问题是由编译器不强制处理副作用造成的。副作用是代码在运行时产生的一些现象。这些现象是局部函数在未在函数类型中明确声明的情况下,影响或被外部环境影响的结果。这些影响可能由函数代码生成,这取决于它在运行时接收到的输入值。编译器对此一无所知。例如,函数返回 null 的现象,或者异常发生导致程序在那个点中断。这些事情发生在运行时。在编译时,编译器不会检查它们是否得到了适当的处理。

可变状态

简单来说,可变状态是可以更改的数据。例如,在某个时间点,你可能会读取某个变量,x,并发现它指向某些数据。在另一个时间点,你可能会从同一个变量读取不同的值。值的不同是因为变量是可变的,程序的其他部分对其进行了修改。

让我们来探讨为什么可变状态并不理想。想象一下你有一个在线游戏。它依赖于多个线程,所选择的并发架构是演员模型。你有一个演员,其任务是跟踪当前游戏中存在的用户。跟踪可以通过在演员内部实现一个可变集合来完成。用户通过向这个演员发送消息来登录和退出游戏。因此,每次登录消息到达演员时,用户就会被添加到已登录用户列表中。当用户想要退出时,他们就会被从列表中移除:

class GameState(notifications: ActorRef) extends Actor {
  val onlineUsers = collection.mutable.ListBuffer[User]()
  def receive = {
    case Connect   (u) => onlineUsers += u
    case Disconnect(u) => onlineUsers -= u
    case Round         => notifications ! RewardWinners(onlineUsers)
  }
}

现在,想象一下你想找到达到特定分数的用户,并通过电子邮件通知他们。你可能想在每一轮结束时(假设这是一个基于轮次的游戏)这样做,这可以通过发送另一个消息到GameState演员来实现。实现这一目标的一种方法是将所有用户的列表发送到一个单独的通知演员,由它来完成这项工作:

case Round => notifications ! RewardWinners(onlineUsers)

让我们假设查找和通知用户的工作需要一段时间才能完成。我们可以通过Thread.sleep(1000)语句来模拟延迟。这个语句在调用它的那一行暂停当前线程的执行,持续 1,000 毫秒或 1 秒。让我们看看它是如何工作的:

class NotificationsActor extends Actor {
  def receive = {
    case RewardWinners(users) =>
     Thread.sleep(1000)
    val winners = users.filter(_.score >= 100)
    if (winners.nonEmpty) winners.foreach { u =>
      println(s"User $u is rewarded!") }
    else println("No one to reward!")
  }
}

通信协议定义如下:

sealed trait Protocol
case class   Connect (user : User      ) extends Protocol
case class   Disconnect (user : User      ) extends Protocol
case class   RewardWinners(users: Seq[User]) extends Protocol
case object  Round                    extends Protocol

现在,让我们假设以下环境:

val system = ActorSystem("GameActors")
val notifications = system.actorOf(Props[NotificationsActor], name = "notifications")
val gameState     = system.actorOf(Props(classOf[GameState], notifications), name = "gameState")
val u1 = User("User1", 10)
val u2 = User("User2", 100)

我们有一个演员系统,一个用于游戏状态的演员和一个用于通知的演员。此外,我们有两个用户。第二个用户User2是一个获胜用户,因为他们的分数>= 100。考虑一下,如果一个获胜用户在完成回合后立即注销会发生什么:

gameState ! Connect(u1)
gameState ! Connect(u2)
gameState ! Round
gameState ! Disconnect(u2)

这样的用户将不会收到通知。问题出在这里,因为我们发送给负责通知的演员的集合是可变的。它被GameState演员和NotificationActor共享。这意味着一旦用户注销,他们就会被GameState演员从集合中移除,这也意味着它也将从NotificationActor的集合中被移除,因为它们是同一个集合。

上述示例演示了共享可变状态在实际操作中存在的问题。再次强调,这给程序员带来了额外的心理负担。如果你有一个与其他线程共享的对象,你不能再仅在一个线程的范围内进行推理。你必须扩大你的推理范围,涵盖所有拥有此对象的所有线程。因为就你所知,共享的可变对象可以随时被更改。演员模型旨在帮助你像你的程序是单线程的且没有其他线程存在一样进行推理。然而,如果你继续使用共享可变状态,它将不会有所帮助。

管理共享可变状态的传统方法是用锁和监视器。其背后的原理是,正在对对象进行修改的线程应该在一些监视器上获取锁,这样在它处理对象时,其他人将无法执行修改。然而,这并没有从程序员的心理负担中解脱出来。你仍然需要考虑除了你当前正在编写的线程之外的其他线程。在实践中,调试涉及并发和共享可变状态的程序是困难的。

纯函数

在前面的章节中,我们向您展示了突变和副作用如何使代码更难阅读和编写。在本节中,我们将介绍纯函数的概念,即不产生副作用的函数。这是纯函数式编程的核心。函数式范式规定,你应该使用不产生任何副作用的函数来表达你的程序。你将如何使用纯函数来模拟需要抛出异常的情况?以熟悉的饮料机示例为例。

这是我们在之前关于副作用讨论中遇到的Soda Machine示例的略微简短版本:

var cans = 0
def insertCoin(): SodaCan =
  if (cans > 0) { cans -= 1; new SodaCan }
  else throw new RuntimeException("Out of soda cans!")
println(insertCoin())

我们可以通过返回另一个数据结构包装的结果来避免从函数中抛出异常:

def insertCoin(): Try[SodaCan] = Try {
  if (cans > 0) { cans -= 1; new SodaCan }
  else throw new RuntimeException("Out of soda cans!")
}

在我们的例子中,我们不是返回一个纯结果,而是以Try的结果形式返回一个封装的结果。Try的行为是在其体内捕获抛出异常的可能性以进行进一步处理。正如前几章所讨论的,Try是一个可以包含值或异常的数据结构。因此,如果自动售货机没有罐装饮料了,我们不再抛出异常。我们从函数中返回一个错误已发生的消息。

与比较分析相比,这里的优势如下。在这个函数中不再会有意外的副作用发生。一个可能发生的错误不再会中断整个程序的流程。此外,函数的调用站点用户必须处理错误才能访问结果。因为结果被包装进数据结构中,除非我们首先解包它所包装的数据结构,否则我们无法访问那个结果。

我们可以通过分析返回的确切内容来访问结果。如果它是一个值,我们知道没有发生错误。如果它是一个异常,我们知道我们需要处理它。在这个时候,我们可以这样说,编译器强制处理错误。错误可能发生的事实反映在函数的返回类型中。因此,在返回后直接使用返回值不再可行。如果你试图在不首先处理错误可能性的情况下直接使用它,你将得到一个编译时错误。因为你将尝试使用Try数据结构,就像它是它所包装的类型一样。

纯函数编程的另一个特点是它避免了使用可变数据结构。让我们再次看看演员之间交换数据的例子。如果我们不是交换一个可变数据结构,而是交换一个不可变数据结构,比如不可变列表,会怎样?看看下面的例子:

class GameState(notifications: ActorRef) extends Actor {
  var onlineUsers = List[User]()
  def receive = {
    case Connect   (u) => onlineUsers :+= u
    case Disconnect(u) => onlineUsers = onlineUsers.filter(_ != u)
    case Round         => notifications ! RewardWinners(onlineUsers)
  }
}

如你所回忆的,在前一个例子中,我们遇到了NotificationActor试图使用列表时列表被另一个线程修改的问题。现在,如果我们用不可变列表代替可变列表,由于不可变数据结构不能被修改,因此从另一个线程的修改问题就会自行消失。一个不可变的数据结构是自动线程安全的。你可以保证没有任何东西会修改数据结构。因此,你可以自由地与其他任何线程共享它。

这个论点可以通过与其他方法交换数据来扩展。想象一下,你有一些可变的数据结构和一些blackBox方法:

val listMutable  : Seq[Int] = collection.mutable.ListBufferInt
def blackBox(x: Seq[Int]): Unit = ???
blackBox(listMutable)  // Anything could happen to listMutable here, because it is mutable

在这些blackBox方法在这个数据结构上运行之后,你怎么知道现在它确切包含什么内容?除非你知道黑盒方法中确切发生了什么,否则你无法对可变数据结构有任何保证。现在,考虑一个不可变列表的例子和同样的情况,即在这个列表上调用黑盒方法:

val listImmutable: Seq[Int] = List(1, 2, 3)
def blackBox(x: Seq[Int]): Unit = ???
blackBox(listImmutable) // No matter what happens, listImmutable remains the same, because it is immutable

在黑盒方法完成其工作后,你对这个列表中包含的内容有任何保证吗?你有,因为列表是不可变的。没有任何东西可以修改这个列表。所以你可以自由地传递它,不仅传递给其他线程,还可以传递给同一线程内的其他方法,并且可以确信它不会被修改。

这种方法的优点是,你不再需要将你的推理范围扩展到当前局部范围之外。如果你在一个不可变数据结构上调用黑盒方法,你不需要确切知道这个方法中发生了什么。这个方法永远不会修改不可变数据结构,知道这一点就足够了。所以,如果你在一个多线程环境中工作,或者你只使用不可变数据结构,你不再需要担心诸如同步或获取锁等问题。你知道你的不可变数据结构不会被任何线程更改。

到目前为止,我们已从直观的角度讨论了纯净性的属性。现在让我们看看一种更科学的方式来定义它——引用透明性的概念。

引用透明性

不可变性和无副作用的概念被术语引用透明性所包含。具有引用透明性的函数,你可以用其返回的结果替换函数调用,而不会改变程序的语义。

让我们看看它在例子上的工作方式。考虑另一种类型的副作用——日志记录。该函数返回具有给定 ID 的用户名称,但它也将该名称写入日志——在这种情况下是标准输出:

def getUserName(id: Int): String = {
  val name = s"User-$id"
  println(s"LOG: Requested user: $name")
  name
}
val u = getUserName(10)

我们能否用函数计算出的结果替换先前的函数调用,而不丢失程序的语义?让我们试试:

val u = "User-10"

在这种情况下,语义将不会相同。原始程序将日志打印到标准输出。当前的程序没有这样做。这是因为标准输出发生在我们用其计算结果替换的函数中,作为一个副作用。

现在,让我们考虑另一个程序:

def getUserNamePure(id: Int): (List[String], String) = {
  val name = s"User-$id"
  val log  = List(s"LOG: Requested user: $name")
  (log, name)
}
val u = getUserNamePure(10)

函数做的是同样的事情,但它不是产生日志记录的副作用,而是将副作用应该发生的信息包含到所有应该被记录的消息列表中。现在我们可以返回包含消息的列表以及函数的结果。

我们能否在不丢失程序语义的情况下用函数计算出的结果替换它们的函数调用?查看以下内容:

val u = (List("LOG: Requested user: User-10"), "User-10")

现在答案是肯定的。原始函数计算了它产生的所有消息的列表,并连同它计算出的值一起返回,而没有实际产生任何副作用。由于在过程中没有产生任何副作用,我们可以用函数的返回值替换函数调用,而不改变程序的语义。该函数是引用透明的。

如前例所示,在引用透明的函数中,所有的副作用都反映在返回类型中,通常由特定的数据结构表示。这种风格可能一开始看起来冗长且难以阅读,因为你从函数中返回了一个包含额外内容的对。然而,不要忘记工程学的一个主要原则是抽象。所以,如果你有适当的抽象,这里看到的难以阅读的代码可以被抽象掉。这样做的过程中不会失去我们已经获得的好处。这些好处包括减轻程序员的认知负担、能够局部解释你的程序,以及能够将副作用排除在等式之外。

这样的抽象已经被发明出来。像 Scala 或 Haskell 这样的语言对这种抽象提供了出色的支持。在本书的后面部分,我们将更深入地探讨它们是如何工作的,以及如何使用它们来编写程序。

通常遇到的副作用

在本节中,我们将更详细地讨论程序中常见的一些副作用。其中一些我们已经介绍过,而其他一些你可能已经从日常编程中了解到了。然而,对你来说,特别注意这些副作用至关重要,因为这样,你才能学会在普通程序中区分它们。

当编写程序(以及当我们一般地生活时),我们往往对某些事情习以为常,甚至没有注意到它们。某些事情可能成为头痛和问题的来源,解决这些问题的第一步是命名导致它们的原因。

由于函数式编程旨在消除副作用,因此我们合理地命名了一些导致痛苦的副作用。

错误

我们将要讨论的第一个效果是错误的效果。当你的程序中出现问题时,会产生错误。在命令式语言中,它通常由异常来建模。异常是由程序中的一行产生的现象,它在该点中断了程序的执行流程。通常,它沿着调用栈向上传播,也会中断其父调用栈的执行。如果未得到处理,异常会传播到最顶层的调用栈帧,程序将会崩溃。

考虑一个除以零的例子:

def division(n1: Double, n2: Double): Double =
  if (n2 == 0) throw new RuntimeException("Division by zero!")
  else n1 / n2

我们有一个除法函数,它会检查其分母是否为零。如果是,该函数会抛出异常。现在,考虑如下调用此函数:

division(1, 0)
println("This line will never be executed")

主程序的执行将不会超过我们尝试调用除法函数的行,该函数的第二个参数为零。这是因为错误将在函数中发生,并最终传播到堆栈中,最终导致程序崩溃。

结果缺失

考虑一种情况,我们有一个应该执行数据库查询的函数。具体来说,我们在数据库中有用户,我们希望有一个函数可以通过 ID 检索用户。现在,当数据库中没有给定 ID 的用户时会发生什么?考虑以下情况:

def getUser(id: Int): User =
  if (Set(1, 2, 3).contains(id)) User(s"User-$id")
  else null

命令式语言的解决方案是从函数返回 null。在第三章,“函数式数据结构”中,我们看到了这是多么危险。编译器不知道函数可以返回 null。更准确地说,它甚至不知道这是一个可能性。编译器允许函数返回null,并且不会警告我们可能存在 null 返回值。命令式风格接受了这种可能性。因此,在命令式语言中,可能每个返回对象的函数也可能返回 null。如果我们不检查该函数的结果是否为 null,我们可能会遇到错误。并且为了检查函数的结果,我们需要记住它可能返回 null 的可能性。这又是一个额外的心理负担。

延迟和异步计算

想象一下你的程序执行了一个 HTTP 调用。例如,它试图从一个 Web API 中检索一些 JSON 对象。这可能需要一些时间;甚至可能需要几秒钟才能完成。

假设你希望在函数内进行一些竞争,并根据 API 请求的结果返回一些值。你在这里会遇到问题,因为 API 调用的结果并没有立即可用。你需要等待一个特定的长时间运行的操作完成,这也是一个副作用。

你可以在这个长时间运行的操作上阻塞,一旦结果到达就继续你的竞争。然而,这个函数也会阻塞调用它的任何函数。在性能关键的环境中,这可能会成为一个问题。例如,考虑一个 Web 服务器。它有多个线程来处理所有传入的请求。如果它的操作完成时间过长,它会很快耗尽线程。并且一些请求最终会在队列中等待很长时间,等待一个空闲的线程。

因此,你始终需要记住,你的一些函数是阻塞的,需要时间来返回。这是一条需要记住的额外信息。这给你带来了额外的心理负担。延迟计算的副作用导致了这一切。

现代网络服务器使用的解决方案是使你的服务器异步。这意味着你永远不会等待长运行的操作。你指定接下来要做什么,使用回调,一旦结果就绪,就根据该回调继续。这可能导致一种称为回调地狱的情况。问题是当你过度使用回调时,程序的执行流程变得相当晦涩。

通常,当程序中的某件事不明显时,这表明需要抽象。因此,抽象回调可能是一个好主意。

函数式编程也有一种方法来抽象长运行的计算。有了它,你可以编写代码,就像它们立即返回一样。Future数据类型存在于 Scala 和 Java 中,也存在于许多现代编程语言中。它精确地服务于抽象长运行计算的目的。

日志

在本章“引用透明性”部分的例子中,我们看到了日志也可以是副作用。日志可以使用单独的登录框架来完成,或者它可能只是简单地写入标准输出。

当你在不熟悉的环境中工作时,日志可能会变得复杂。例如,如果是在你的桌面电脑环境中,一切都很简单。你从终端运行程序,它将所有输出都输出到终端。然而,如果是一个网络服务器呢?通常,你需要将日志输出到单独的文件中,以便之后可以阅读。或者,如果你正在编写一个移动应用程序呢?程序在单独的设备上运行,并不总是打印语句会导致输出到终端。你可能需要使用一些特定于系统的日志 API,这些 API 是本地化的,适用于你正在工作的环境。

现在想象一下,你有一个程序,其中几乎到处都有print语句。你突然开始理解,一些函数在记录日志时试图与作用域之外的环境进行交互。具体来说,是与你在工作环境中特定的日志 API。现在你需要修改这个日志调用,以匹配环境的期望。

一个写入日志的函数与作用域之外的环境进行交互。这意味着我们可以根据定义将这些调用视为副作用。由于你在不同环境中工作时需要关注这些复杂性,因此可以说它们增加了你的心理负担。

输入输出操作

我们在这里要讨论的最后一个副作用是与文件系统或网络的输入输出(IO)操作。我们可以将这些操作称为副作用,因为它们在很大程度上依赖于环境。

在文件系统上的 IO 操作中,操作的成功取决于文件系统是否包含指定的文件或文件是否可读。当执行网络操作时,操作取决于我们是否有可靠的互联网连接或任何防火墙。

当调试 IO 程序时,许多移动部件会吸引我们的注意力。我们是否有权访问所需的文件系统?我们正在尝试读取或写入的文件的所有权如何?当前用户有什么权限?不同操作系统的文件系统如何?Linux 和 Windows 在文件系统结构上有非常不同的方法,那么我们如何将我们的应用程序从一个系统移植到另一个系统?我们有一个可靠的互联网连接吗?我们是否在防火墙后面?我们的 DNS 服务器是否工作正常?我们正在尝试监听的端口在这个特定系统上是否可用?

这对你来说是一个额外的心理负担,因为你需要考虑很多事情。因此,你可以将 IO 视为副作用和额外的心理负担。

但我们如何消除副作用呢?

如果你来自纯命令式背景,你可能会在这个时候感到非常困惑。纯函数式编程认为你需要消除所有的副作用。但你能否想象一个没有日志记录的程序?如果一个无法连接到网络的 Web API 有什么用呢?如果我们不能抛出异常,我们如何指定程序的错误行为?

之前指定的副作用对于大多数现代应用程序都是必不可少的,通常无法想象一个没有副作用的合理程序。

因此,说纯函数式编程完全从你的程序中消除副作用是不正确的。相反,更精确的说法是它从你的业务逻辑中消除副作用。它将副作用推离应用程序中重要的部分。以纯函数式风格通常工作的方式是,你的业务逻辑,涵盖了 90%的代码,确实是纯函数式的。

你没有之前指定所有的副作用。然而,每当业务逻辑需要执行副作用时,它不会直接执行。相反,它创建一个数据结构来指定需要执行哪些副作用,而实际上并不执行它们。函数式应用程序通常有一个完整的特定领域语言来描述副作用。每当一段逻辑需要执行副作用时,它就用这种语言表达其需求。需要执行的操作的指定和执行该指定的行为是分开的。

函数式应用程序通常有一个薄层,负责执行在应用程序的效果语言中表达的外部效应。这种方法的优点是,大多数时候你都在处理业务逻辑,这 90%的代码。而且这段代码是引用透明的和纯的。这意味着其中所有通常存在的副作用都被分离出来。我们之前讨论的所有心理负担都消失了。这意味着,大多数时候,你都是在没有额外心理负担的情况下,局部地工作,不考虑全局范围。

确实,你还需要编写你副作用的解释器。这是你选择的效果语言中表达的外部效应的 10%的代码。然而,它与你的业务逻辑是分开的。你可以单独测试你的效果解释器。一旦编写并部署,你就可以忘记它,并以纯的方式编写你的业务逻辑。

到目前为止,我们还没有深入探讨如何实现它。本节的目的在于给你一个关于如何解决副作用应用程序中纯度问题的概念。在本书的后面部分,我们将精确地看到函数式编程如何促进这种技术,以及它究竟提供了什么来以这种风格编写程序。

不同语言中的纯函数式范式

编程时不需要任何特定的基础设施来采用纯函数式风格。你所需要的是能够在代码中看到副作用,注意到它们何时在你的脑海中增加额外的心理负担,并需要你同时记住更多的事物。当然,你需要知道如何抽象它们,如何让这种心理负担消失。大多数现代编程语言都是为了工程师而构建的。这就是为什么它们提供了出色的抽象能力,包括像 C 或 Java 这样的命令式语言。这就是为什么,如果你知道如何抽象以及如何做,你应该能够在这些语言中实现抽象。而且如果你确切地知道命令式风格如何伤害你,你可以保护自己免受麻烦。

此外,某些命令式编程语言提供了一种特定的基础设施,直接促进了纯函数式风格的实现。例如,在 Java 中,你有final关键字。使用这个关键字声明的变量是不可变的。一旦在 Java 中为final变量赋值,你就无法修改它。此外,在 Java 中,不可变集合是其核心基础设施的一部分。

尽管你可以在命令式语言中应用函数式风格,但在函数式语言中这样做要容易得多。当你将这种风格应用于命令式语言时可能会遇到麻烦。一个问题可能是你遇到的所有库都是命令式的。在这种条件下编写纯函数式代码可能很困难,因为你将在一个命令式框架内工作。这可能会产生一定的惯性,可能很难克服。因此,在命令式语言中工作在函数式风格可能并不实用。然而,如果你被复杂性压倒,它可能被用作最后的手段。

纯函数式语言的优点,例如 Scala 或 Haskell,在于它们为你提供了一个优秀的框架来编写函数式代码。Haskell 是一种强制使用函数式风格的编程语言。使用该语言时,你几乎别无选择,只能采用这种风格。因此,你将在这种语言中使用的库也都是纯函数式的。你可以在纯函数式框架下工作。在某种程度上,Scala 是一种更为自由的编程语言。它是面向对象和函数式风格的混合体。因此,使用它来在纯命令式和纯函数式风格之间进行转换非常方便。这是因为你有选择风格的空间。如果你不知道如何以纯函数式的方式实现某些功能,而且截止日期即将到来,你总是可以求助于熟悉的命令式风格。

这种命令式和函数式风格的结合在现代编程语言中相当普遍。例如,在 Python 中,你可能会遇到这两种风格。一些库相当命令式,但同时也很好地支持纯函数式风格。Java 在这个意义上比 Python 更为保守。它似乎非常严格地遵循命令式、算法范式,尽管在过去十年左右,人们投入了巨大的努力使函数式风格在 Java 中更加自然。

总的来说,本节的重点是函数式风格并不关乎语言本身。语言可以提供一些动力,这体现在其现有的基础设施和社区的方法论上。这种动力可以是双向的——要么对你有利,要么对你不利。然而,如果你理解了函数式方法,你应该能够在任何使用它的语言中进行编程。但你应该始终意识到风向——社区的气氛如何,其库遵循的哲学是什么。你应该意识到语言为你提供的动力,以及它是帮助你还是阻碍你。

摘要

传统的命令式方法严重依赖于在运行时产生某些现象的算法——副作用。编译器通常对这些现象不太了解,或者了解得不够。我们可以将本书中的副作用定义为修改其直接作用域之外环境的指令。副作用通常是不受欢迎的,因为它们给程序员的思维增加了额外的负担。

传统命令式风格的另一个问题是突变。可变数据结构不是线程安全的。而且,即使在同一线程内,它们也无法安全地在逻辑片段之间传递。

函数式编程旨在解决这些问题并减轻你的心理负担。这种风格通过抽象出副作用来实现,这样你就可以编写程序而无需显式执行它们或修改当前作用域之外的内容。

问题

  1. 副作用是什么?

  2. 什么是可变数据?

  3. 副作用和可变数据可能会引起什么问题?

  4. 纯函数是什么?

  5. 什么是引用透明性?

  6. 使用纯函数式风格有什么好处?

  7. 常见的一些副作用有哪些?

  8. 在像 Java 这样的命令式语言中,是否可以以纯函数式风格进行编程?

  9. 使用函数式编程语言而不是命令式编程语言进行纯函数式编程有什么好处?

第五章:效果类型 - 抽象化副作用

在上一章中,我们看到了副作用可能成为麻烦的来源。我们还简要讨论了效果类型。效果类型是函数式编程的一种技术,它允许抽象副作用。

在本章中,我们将探讨这是如何工作的。我们将了解模式背后的哲学。我们还将看到如何按顺序组合被效果类型捕获的副作用。

在本章中,我们将涵盖以下主题:

  • 将效果转换为数据

  • 使用 Monads 的效果类型顺序组合——mapflatMap 函数

将效果转换为数据

将编写程序的过程与建模和描述特定现实进行比较是可能的。例如,当你编写仓库管理应用程序时,你正在将在线商店的概念、其库存、库存存储的地方以及库存可以进出仓库的规则编码到逻辑规则中。这是你编写应用程序的业务领域现实。我们可以说,作为程序员,你的目标是模拟你的业务领域,即使用你的编程语言将其编码为特定的逻辑规则——定义信息存储、转换和交互的方式。

然而,在执行过程中,程序会创造出自己的现实。正如仓库、在线商店和用户都是业务领域现实的成员一样,一些元素是程序执行领域的成员。同样,你可以在你的业务领域中定义某些现象,例如库存短缺或用户从你的商店购买,你可以在编写和运行程序的世界中定义某些现象。

现实是你处于某个抽象级别工作时心中的想法。当你处于业务领域级别工作时,你心中想的是一类事物。然而,当你创建程序时,你心中想的是完全不同的事物。这两组不同的概念和现象可以理解为你在其中工作的不同现实。

例如,错误存在于程序执行的现实之中。错误的生命周期也是程序执行的现实。错误可以在调用栈中传播。当它们被处理时,它们会停止传播。它们在发生的地方破坏程序。

延迟也是程序执行的现实。当你执行数据库操作、输入输出操作或等待服务器的响应时,你正在处理延迟。

并发、模块化、类层次结构——这些都是你编程现实中的元素。编程现实是你编写程序时所关注的理念和现象。然而,这种现实并不关乎你的老板,他生活在业务领域的现实中。

为了简单起见,让我们将业务域现实称为一级现实,将编程现实称为二级现实。这样的命名是因为业务域现实是你立即关心的事情。你的程序的现实是在解决业务域问题的过程中产生的,即一级现实。

有时候,程序员只关注一级现实。他们可能不关心代码的质量或它如何处理二级现实。他们的主要关注点是描述一级现实和解决业务任务。这种情况可能源于程序员缺乏经验,或者源于缺乏能够让他们快速处理二级现实的基础设施。在紧迫的截止日期下,有时不得不在完成任务和代码质量之间做出权衡。

忽视编程现实为什么是危险的?好吧,因为它本身就是一种现实,独立于业务中可能发生的现实。无论你是否关注它,这种现实都仍然存在。而且,如果你不关注它,它可能会在复杂性上升级,尤其是在大型代码库中。

例如,如果有太多的异步计算,你可能会发现自己陷入回调地狱的情况。在回调的上下文中,很难追踪程序的执行流程。回调地狱是指你的程序过度依赖回调,以至于开始难以追踪其行为。

当你处理并发程序和多线程计算时,如果你不小心,你可能会陷入竞态条件的情况。或者,你可能会遇到死锁或系统活跃度问题。如果没有特定的技术来处理这些问题,例如 actor 系统,它们可能会产生特别难以调试的 bug。

如果你不在意何时抛出异常和从方法中返回 null,你几乎可以预期每个方法都会抛出异常或返回 null。仅仅通过滥用异常和 null 本身不应该导致有害的 bug,但这仍然会给你带来头疼。

最后,突变是你将要面对的另一个现实。在前几章中,我们讨论了突变如何增加你的心理负担。

之前讨论的几个编程情况展示了我们在前几章中广泛讨论过的心理负担。这是程序运行和编写时的二级现实。程序应该模拟其业务域的现实。然而,当你解决、运行或编写程序时,你会遇到一个完全不同的现实。如果你忽略这个现实,它将用复杂性压倒你,并导致心理超负荷。

考虑以下我们在前几章中遇到的除法函数的例子:

def imperativeDivision(n1: Double, n2: Double): Double =
  if (n2 == 0) throw new RuntimeException("Division by zero!")
  else n1 / n2

这里的第一阶现实是算术。这是我们试图建模的业务领域。确切地说,我们模拟了一个数除以另一个数的操作。这就是我们的第一阶现实。

然而,当我们开始编写代码时,我们很快就会遇到第二阶现实。也就是说,除以零的可能性以及需要在程序中处理这种情况的必要性。现在,我们从一个数学世界和我们的主要业务任务转向编程的世界。

处理这种现实的一种天真方式是在除以零的情况下抛出一个异常。然而,如果你没有足够关注第二阶现实,它将产生我们已经讨论过的心理负担。没有任何东西可以警告你错误的可能性。没有任何东西可以强迫你处理它。因此,你需要自己记住所有这些,这增加了你需要记住的程序复杂性。你需要记住的事情越多,做这件事就越困难,出错的可能性就越大。

一个更复杂的程序员在设计程序时会同时考虑第一阶和第二阶现实。他们不仅会建模业务领域;他们还会设计程序,使其执行复杂性不会阻碍可扩展性。可扩展性意味着代码库的大小不会增加单个组件编程的复杂性。

要开发一个高质量的程序,程序员需要特定的工具和方法。函数式编程提供了一种方法。

函数式编程遵循工程的基本原则——抽象出重复的部分。第二阶现实有重复的现象。因此,函数式编程设计了它自己的抽象来处理它们。

同时学习如何描述两种现实比仅学习描述第一阶现实要困难。因此,像 Python 这样的语言比 Java 等语言更容易学习。尽管 Java 不是一种函数式语言,但它也提供了一种基础设施和方法来处理编程的复杂性。同时,Python 专注于速度和原型设计的简便性。此外,Java 比 Scala 简单得多,因为 Scala 提供了更多的抽象以及控制程序两种现实的方法。

虽然学习允许更高质量编程的语言更困难,但它的价值是值得其价格的。你学会了控制第二阶现实的影响。你不仅能够描述你的直接业务领域,还能够描述你的程序是如何运行的。控制复杂性以实现可扩展性和无错误编程的方法是掌握复杂性。

让我们重新审视除以零的例子,但考虑到第二阶现实:

def functionalDivision(n1: Double, n2: Double): Try[Double] =
  if (n2 == 0) Failure(new RuntimeException("Division by zero!"))
  else Success(n1 / n2)

首先要注意的是,错误的二阶现实效果用Try数据结构来建模。错误处理的概念通过分析Try数据结构来建模。它由编译器强制执行——除非你分析数据结构以查找错误,否则你不能访问结果值。因此,复杂性降低了。

在函数式编程中,我们检测到二阶现实的具体现象并创建一个数据结构来封装(具体化)它的模式是典型的。在这本书中,我们将称封装二阶现实现象的数据结构为效果类型

本节的主要目的是从广泛的角度审视副作用,以了解其抽象背后的通用模式。如果你只关注你的业务领域,而忽略了程序的技术现实,后者将创造一个沉重的心理负担。一个复杂的程序员会平等地关注这两种现实。函数式编程允许你充分地处理它们。

使用 Monads 的效果序列组合

分析前面的数据结构很麻烦。与分析函数数据结构相关的代码最终发现很难阅读。

然而,在函数式世界中,分析数据结构是一种模式。模式在编程中被抽象出来。

在本节中,我们将查看一些常见的抽象,作为函数式程序员,当与效果类型一起工作时,你将处理这些抽象。

引入 map 函数

假设我们需要在自定义除法函数的先前示例的基础上构建另一个函数。该函数由一个参数x参数化,并计算表达式2 / x + 3

我们如何用自定义的除法函数来表达它?一种方法是在执行除法后,分析其结果,如果不是错误,则继续进行加法。然而,如果是错误,则返回该错误:

def f1(x: Double): Try[Double] =
divide(2, x) match {
  case Success(res) => res + 3
  case f: Failure   => f
}

当我们有一个返回效果类型的计算,并且我们需要用另一个返回未封装在效果类型中的原始值的计算来继续它时,这是函数式编程中的一种常见模式。模式是分析计算返回的结构,提取结果,然后应用第二个计算到这个结果上。

这种模式封装在map方法中。大多数效果类型都有在它们上面定义的map方法。以下是如何使用map方法实现前面示例的示例:

def f1Map(x: Double): Try[Double] =
 divide(2, x).map(r => r + 3)

让我们尝试培养对map方法的直觉。首先,你可以将map方法视为以下高阶函数——(A => B) => (Try[A] => Try[B])。这是一个接受A => B函数并输出Try[A] => Try[B]函数的高阶函数。

这意味着如果你有一个将A类型的值转换为B类型的值的函数,你也可以有一个将Try[B]类型的值转换为Try[B]类型的值的函数。你可以将map函数视为一个提升,它允许你从在原始值上工作的函数中产生在Try效果类型下工作的函数。

介绍flatMap函数

flatMap函数是封装函数式编程模式的一个函数的另一个例子。想象我们需要创建一个函数来计算以下数学表达式:(2 / x) / y + 3。让我们尝试使用我们之前定义的除法函数来做这件事:

def f2Match(x: Double, y: Double): Try[Double] =
  divide(2, x) match {
    case Success(r1) => divide(r1, y) match {
      case Success(r2) => Success(r2 + 3)
      case f@Failure(_) => f
    }
    case f@Failure(_) => f
  }

代码在这里变得像意大利面一样。首先,我们分析除以2的结果。如果成功,我们会将其除以y。然后我们分析那个除法的结果,如果没有错误,我们会将3加到结果上。

在这里,我们不能再使用map函数,因为除以y会返回另一个尝试。map是一个用于返回原始值的函数的提升,而不是Try。如果你觉得这个逻辑很晦涩,鼓励你尝试使用map函数实现前面的例子,以查看问题。

flatMap函数专门用于这种情况。你可以将其视为一个具有(A => Try[B]) => (Try[A] => Try[B])签名的更高阶函数。你可以这样理解它。如果你有一个产生值包裹在Try结构中的函数A => Try[B],你可以将其转换成另一个函数Try[A] => Try[B],该函数将原始函数的A域提升到Try[A]域。这意味着如果原始的A => Try[B]函数可以在A原始值上使用,新的Try[A] => Try[B]函数可以用于Try[A]作为其输入。

让我们看看它是如何使用flatMap实现的:

def f2FlatMap(x: Double, y: Double): Try[Double] =
  divide(2, x).flatMap(r1 => divide(r1, y))
   .map(r2 => r2 + 3)

我们需要从计算2/x后得到的Try数据结构中提取原始结果,并需要对这个结果进行另一个计算。这个计算,即结果除以y,也会产生Try。借助flatMap,我们可以将Int => Try[Int]计算提升为Try[Int] => Try[Int]。换句话说,一旦我们计算了2/x,我们就可以将其结果除以y

因此,flatMap用于需要继续另一个计算的情况,并且这个延续将产生一个Try作为其结果。与map函数的情况相比,map函数要求延续产生一个原始值。mapflatMap的相应版本也存在于其他效果类型中,例如 Option 或 Future。

在我们分析过的 mapflatMap 签名方面,这里可能有一点令人困惑。签名是函数。它们接受一个函数作为输入,并返回另一个函数作为输出。然而,我们在 Try 对象上调用的 mapflatMap 方法并不返回函数,而是返回 Try 对象。然而,正如我们之前讨论的,我们的 mapflatMap 签名都返回一个 Try[A] => Try[B] 函数。

在函数式编程的世界里,我们脱离面向对象编程的上下文来看待函数。Scala 是一种方便的语言,因为它结合了面向对象和函数式编程方法。因此,像 flatMapmap 这样的函数被定义为 Try 类的方法。然而,在函数式编程中,通过脱离面向对象编程的上下文,我们能更好地理解函数的本质。在函数式编程中,它们不被视为任何类的成员。它们是转换数据的方式。

假设你有一个定义为某个 Dummy 类成员的函数:

class Dummy(val id: Int) {
  val f: Int => String = x => s"Number: $x; Dummy: $id"
}

函数 f 接受一个 Int 类型的参数,并输出一个 String 类型的结果。它的签名是 Int => String。这个签名是函数在 Dummy 类内部定义时的签名。然而,请注意,由于它是在 Dummy 对象内部定义的,因此该对象的上下文总是隐含的。我们可以在函数内部执行计算时使用封装对象的的数据。

如果我们决定将这个函数移出类的范围,会发生什么?Int => String 签名是否仍然反映了函数的本质?我们能否以这种方式实现它?考虑以下:

// val f: Int => String = x => s"Number: $x; Dummy: $id"  // No `id` in scope, does not compile

答案是否定的,因为我们现在没有所需的类上下文。前面的代码会产生编译时错误。如果我们将函数移出类的范围,我们需要用 Dummy => (Int => String) 签名来定义它。也就是说,如果我们有一个 Dummy 对象,我们可以定义一个从 IntString 的函数,并在这个对象上下文中实现它:

val f1: Dummy => (Int => String) = d => (x => s"Number: $x; Dummy: ${d.id}")

注意,也可以以另一种方式实现,Int => (Dummy => String),而不影响语义:

val f2: Int => (Dummy => String) = x => (d => s"Number: $x; Dummy: ${d.id}")

这个想法在分析 mapflatMap 签名时得到了应用。

摘要

在本章中,我们学习了副作用背后的哲学。我们发现,在解决业务领域问题的过程中,程序员最终会进入一个与业务逻辑不同的现实。你编写程序和运行时发生的现象构成了一个自己的现实。如果你忽略它,后者的现实可能会变得复杂,这会导致心理负担。

函数式编程通过提供将现象具体化为效果类型并在数据结构和纯函数的语言中定义它们行为的技巧,允许你通过解决二阶现实问题。

效果类型减轻了你的心理负担,因为它们消除了记住程序中发生的所有现象的必要性,即使这些现象超出了你当前可能正在查看的代码的作用域。

效果类型也会迫使编译器让你处理这类现象。使用效果类型很快就会变得相当冗长。因此,存在像mapflatMap这样的函数来抽象处理涉及效果类型的常见场景。

问题

  1. 编程时,程序员需要考虑哪些现实?

  2. 纯函数式编程如何解决二阶现实中的复杂性问题?

  3. 在我们的程序中考虑二阶现实有哪些好处?

第六章:实践中的效果类型

在前面的章节中,我们看到了抽象副作用的一般模式是使用效果类型。这种模式允许你减轻心理负担。该模式指出,我们首先定义一个效果类型,然后使用此类型表示特定副作用的所有发生。在本章中,我们将看到更多关于现实世界效果类型的示例以及何时使用它们。

更精确地说,我们将涵盖以下主题:

  • Future

  • Either

  • Reader

未来

我们将要查看的第一个效果类型是未来。这种效果在广泛的项目中经常遇到,甚至在非功能语言中也是如此。如果你在 Java 中有关编写并发和异步应用程序的丰富经验,你可能已经了解这种类型的效果。

首先,让我们看看效果类型抽象的现象以及为什么可能需要这种效果类型的动机。

动机和命令式示例

考虑以下示例。假设你正在开发一个日历应用程序来编写用户的日常日程。此应用程序允许用户将未来的计划写入数据库。例如,如果他们与某人有一个会议,他们可以在数据库中创建一个单独的条目,指定何时以及在哪里举行。

他们还可能希望将天气预报集成到应用程序中。他们希望在用户有户外活动且天气条件不利时提醒他们。例如,在雨天举办户外野餐派对是不受欢迎的。帮助用户避免这种情况的一种方法是通过使应用程序联系天气预报服务器,看看给定日期的天气是否令人满意。

对于任何给定的事件,这个过程可以用以下算法来完成:

  1. 根据事件的 ID 从数据库中检索事件

  2. 检索事件的时间和地点

  3. 联系天气预报服务器,并给它提供我们感兴趣的日期和地点,然后检索天气预报

  4. 如果天气不好,我们可以向用户发送通知

上述算法可以如下实现:

def weatherImperative(eventId: Int): Unit = {
  val evt = getEvent(eventId)  // Will block
  val weather = getWeather(evt.time, evt.location)  // Will block
  if (weather == "bad") notifyUser() // Will block
}

方法定义如下:

case class Event(time: Long, location: String)
def getEvent(id: Int): Event = {
  Thread.sleep(1000)  // Simulate delay
  Event(System.currentTimeMillis, "New York")
}
def getWeather(time: Long, location: String): String = {
  Thread.sleep(1000) // Simulate delay
  "bad"
}
def notifyUser(): Unit = Thread.sleep(1000) // Simulate delay

在前面的例子中,有一个可能引起麻烦的效果。连接到数据库需要时间,而联系天气服务器则需要更多的时间。

如果我们像前一个示例那样从应用程序的主线程顺序执行所有这些操作,我们就有阻塞这个线程的风险。阻塞主应用程序线程意味着应用程序将变得无响应。避免这种体验的一种标准方法是在单独的线程中运行所有这些耗时计算。然而,在异步应用程序中,通常以非阻塞方式指定每个计算。阻塞方法并不常见;相反,每个方法都应该立即返回一个表示计算的异步原语。

在 Java 中,这种想法的最简单实现是在单独的线程中运行每个计算:

// Business logic methods
def notifyThread(weather: String): Thread = thread {
  if (weather == "bad") notifyUser()
}
def weatherThread(evt: Event): Thread = thread {
  val weather = getWeather(evt.time, evt.location)
  runThread(notifyThread(weather))
}
val eventThread: Thread = thread {
  val evt = getEvent(eventId)
  runThread(weatherThread(evt))
}

三个业务逻辑方法各自有自己的线程。threadrunThread方法定义如下:

// Utility methods
def thread(op: => Unit): Thread =
new Thread(new Runnable { def run(): Unit = { op }})
def runThread(t: Thread): Unit = t.start()

你可以按照以下方式运行此应用程序:

// Run the app
runThread(eventThread)  // Prints "The user is notified"

在这里,每个后续计算都在每个先前计算结束后调用,因为后续计算依赖于先前计算的结果。

代码难以阅读,执行流程难以跟踪。因此,抽象出这些计算的顺序组合是明智的。

抽象和函数式示例

让我们看看以函数式方式编写这个示例。在函数式世界中,处理异步计算的一个抽象是 Future。Future 具有以下签名——Future[A]。此类型表示在单独的线程中运行的计算,并计算一些结果,在我们的情况下是A

处理Future时的一种常见技术是使用回调来指定计算的后续操作。计算的后续操作是在计算完成后要执行的指令。后续操作可以访问它所继续的计算的结果。这是可能的,因为它在计算终止后运行。

在大多数关于Future数据类型的用法中,回调模式以某种形式存在。例如,在 Scala 的Future实现中,你可以使用onSuccess方法将函数的后续操作指定为回调:

def weatherFuture(eventId: Int): Unit = {
  implicit val context =   ExecutionContext.fromExecutorService(Executors.newFixedThreadPool(5))
  Future { getEvent(eventId) }
  .onSuccess { case evt =>
  Future { getWeather(evt.time, evt.location) }
  .onSuccess { case weather => Future { if (weather == "bad") notifyUser } }
}

在前一个示例中,我们可以在先前的 Future 终止后使用它们计算的结果启动新的 Future。

此外,请注意我们在运行 Future 之前定义的implicit val。它为 Future 引入了一个隐式的执行上下文。Future 是一个在单独的线程中运行的异步计算。它确切地运行在哪个线程上?我们如何控制线程的数量以及是否重用线程?在运行 Future 时,我们需要一个线程策略的规范。

在 Future 类型的 Scala 实现中,我们使用 Scala 的隐式机制将线程上下文引入作用域。然而,在其他语言中,你应该期望存在类似的控制 Future 线程策略的方法。

编写未来

需要在异步方式下依次运行多个计算的情况是一个常见的模式。解决这个任务的一种方法是通过回调,正如我们之前所看到的。每个异步计算都是一个独立的实体,并且从一个回调开始,该回调注册在它所依赖的另一个计算上。

另一种构想这种模式的方式是将 Futures 视为可组合的实体。所涉及的概念是将两个 Futures 组合成一个的能力。组合的Future的语义是在第一个Future之后顺序执行第二个Future

因此,给定一个用于联系数据库的 Future 和一个用于联系天气预报服务器的 Future,我们可以创建一个将两者顺序组合的 Future,第二个 Future 能够使用第一个 Future 的结果。

使用我们已经在上一章中熟悉的flatMap方法,我们可以方便地进行顺序组合。因此,我们的示例可以这样实现:

def weatherFutureFlatmap(eventId: Int): Future[Unit] = {
  implicit val context =   ExecutionContext.fromExecutorService(Executors.newFixedThreadPool(5))
  for {
    evt  <- Future { getEvent(eventId) }
    weather <- Future { getWeather(evt.time, evt.location) }
     _  <- Future { if (weather == "bad") notifyUser() }
  } yield ()
}

for推导式是顺序调用flatMap的简写。这种技术被称为单调流,存在于一些函数式语言中,包括 Scala 和 Haskell。前面的 Scala 代码是以下代码的语法糖:

def weatherFutureFlatmapDesugared(eventId: Int): Future[Unit] = {
  implicit val context =   ExecutionContext.fromExecutorService(Executors.newFixedThreadPool(5))
  Future { getEvent(eventId) }
  .flatMap { evt => Future { getWeather(evt.time, evt.location) } }
  .flatMap { weather => Future { if (weather == "bad") notifyUser() } }
}

flatMap的推广

在上一章中,我们已经看到了在Try类型上下文中flatMap的使用。它被构想为一个可能产生错误的计算的后继。我们可以将这种构想推广到 Futures 的情况。正如flatMapTry的情况下被构想为一个可能产生错误的计算的后继一样,在 Future 的情况下,它是一个异步计算的后继。

flatMap函数在处理任何效果类型时的作用大致相同。它是一个产生副作用并带有另一个产生相同副作用但需要第一个计算结果来继续的后继计算。

类似于我们在上一章中在Try的情况下使用它的方式,我们也可以为 Futures 定义一个flatMap的签名,如下所示—(A => Future[B]) => (Future[A] => Future[B])。另一种看待这个flatMap函数的方式是,它是一个提升。flatMap将产生 Future 副作用并依赖于某些值(如(A => Future[B]))的函数提升为一个执行与原始函数相同操作但依赖于Future[A]Future[A] => Future[B])值的函数。也就是说,依赖不再以原始格式存在,而是由另一个产生 Future 副作用的计算来计算。

应该提到的是,Futures 并非仅限于函数式编程。你可以在许多其他语言中遇到它们,例如 Java、JavaScript、Python 以及许多其他语言。异步计算如此普遍,程序员设计出一种原始类型来抽象它们的复杂性是自然而然的。然而,在函数式编程语言,如 Scala 或 Haskell 中,Future 获得了一种我们之前看到的功能性扭曲。

让我们通过一个使用 Either 的例子来继续探索副作用以及你可以如何使用它们。

Either

Either 是一种类似于我们在前几章中遇到过的 Try 效果。

如果你还记得,Try 是一个可以包含两个值之一的结构——一个异常或计算的结果。让我们简要回顾一下前几章中的除以零的例子:

def functionalDivision(n1: Double, n2: Double): Try[Double] =
  if (n2 == 0) Failure(new RuntimeException("Division by zero!"))
  else Success(n1 / n2)

在这里,在成功的情况下,我们创建一个 Success 数据结构。在失败的情况下,我们需要创建一个带有特定错误信息的异常。

在这里创建异常是必要的吗?毕竟,有用的负载是错误信息。异常在它们被 throw 语句抛出时是需要的。然而,正如我们在前几章中讨论的,函数式编程避免这种副作用,而是将其修正为效果类型。如果我们没有抛出异常,那么在 Failure 数据结构中显式创建和包装它的意义何在?更有效的方法是返回一个原始的错误信息,例如一个 String,而不是带有这个错误信息的异常。然而,当你查看 Failure 数据结构的签名时,你会发现它只能包含 Throwable 的子类。

为了在错误情况下返回一个字符串而不是异常,我们可以使用另一种数据类型:Either

Either 表示两个值之间的一个选择。如果 Try 是异常和结果之间的一个选择,那么 Either 就是任意两种类型之间的一个选择。它有两个子类。因此,类型为 Either[A, B] 的值可以是 Right[B]Left[A]。传统上,右侧用于成功计算的结果,而左侧用于错误。

让我们看看如何使用这个新的数据结构改进我们的除以零的例子:

def division(n1: Double, n2: Double): Either[String, Double] =
 if (n2 == 0) Left("Division by zero!")
 else Right(n1 / n2)
 println(division(1, 0))  // Left("Division by Zero")
 println(division(2, 2))  // Right(1.0)

我们不再需要将错误信息包装在异常中。我们可以直接返回错误信息。函数的结果类型现在是 Either[String, Double],其中 String 是我们表示错误的方式,而 Double 是结果类型。

应该注意的是,替代的概念可以进一步扩展。Either 不是唯一用于抽象替代的数据类型。正如你可能注意到的,Either 可以是两个值中的任意一个,但不能同时是两个,也不能是两者都不是。

无论何时你有一个同时拥有两个值的用例,或者当你有一个空的选择时,你可能希望使用其他专门针对此用例的效果类型。为 Scala 或 Haskell 等语言提供的函数式编程库提供此类类型。例如,在 Scala 中,名为cats的库提供了可以同时包含两个值的Ior数据类型。

我们可能希望同时拥有两个值的用例之一是用于显示警告。如果错误可以理解为导致计算终止而没有产生结果的致命事件,那么警告就是通知你计算中出了问题,但它能够成功终止。在这种情况下,你可能需要一个可以同时包含计算值和生成的警告的数据结构。

错误和异步计算并不是效果类型所解决的唯一领域。现在,让我们看看如何以纯函数式的方式解决依赖注入的问题。让我们来看看Reader类型。

Reader

依赖注入是一种机制,它定义了程序的部分应该如何访问同一程序的其他部分或外部资源。

让我们考虑一个依赖注入变得相关的场景。例如,假设你正在编写一个银行为数据库编写应用程序。该应用程序将包括将你的业务域对象读入和写入数据库的方法。例如,你可能有一个创建新用户的方法和一个为他们创建新账户的方法。这些方法依赖于数据库连接。注入这种依赖的一种方法是将数据库连接对象作为参数传递给这些方法:

def createUser(u: User, c: Connection): Int = ???
def createAccount(a: Account, c: Connection): Int = ???

前面的类型定义如下:

class Connection
case class User(id: Option[Int], name: String)
case class Account(id: Option[Int], ownerId: Int, balance: Double)

然而,这会使方法的签名变得杂乱。此外,调用数据库的其他方法,如依赖方法,也会变得杂乱,因为它们需要数据库连接对象来满足它们所调用方法的依赖。例如,想象一个业务逻辑方法,它同时为用户创建一个新账户和一个账户:

def registerNewUser(name: String, c: Connection): Int = {
  val uid   = createUser(User(None, name), c)
  val accId = createAccount(Account(None, uid, 0), c)
  accId
}

它由两个数据库调用组成,并且由于每个调用都依赖于数据库连接,因此此方法也必须依赖于数据库连接。因此,你必须将数据库连接作为参数提供给业务逻辑方法。将依赖作为参数提供并不方便,因为它将连接对象引入了你的关注点。在业务逻辑层,你希望专注于业务逻辑,而不是数据库连接的工作细节。

函数式解决方案

函数式编程为依赖注入问题提供的一种解决方案是,它可以把依赖需求视为一个以依赖项作为参数定义的函数,然后抽象出这个函数。如果我们想这样做,那么首先我们必须定义我们的数据库访问方法如下:

def createUserFunc   (u: User ): Connection => Int = ???
def createAccountFunc(a: Account): Connection => Int = ???

这种方法表明,每当有一个依赖于某些外部资源的计算时,我们将这种依赖建模为一个接受此资源作为参数的函数。因此,当我们有一个应该创建用户的函数时,它本身并不执行计算。相反,它返回一个执行计算的函数,前提是你提供了数据库连接。

在这个设置下,如何表达业务逻辑方法如下:

def registerNewUserFunc(name: String): Connection => Int = { c:  Connection =>
  val uid   = createUserFunc(User(None, name))(c)
  val accId = createAccountFunc(Account(None, uid, 0))(c)
  accId
}

这种方法与在函数中添加额外参数的方法并没有太大的不同。然而,这是抽象过程的第一个步骤,这个步骤是为了将注意力集中在我们要抽象的效果上。

第二步是抽象出这些函数。实现这一目标的一种方法是将这些函数视为效果。这个效果被用来确保除非你提供其依赖项——函数的参数,否则无法执行由这个函数表示的计算。考虑我们已熟悉的例子,这个例子在Reader效果类型的帮助下被重新编写:

def createUserReader   (u: User ): Reader[Connection, Int] = Reader { _ => 0 }  // Dummy implementation, always returns 0
def createAccountReader(a: Account): Reader[Connection, Int] = Reader { _ => 1 }  // Dummy implementation, always returns 1
def registerNewUserReader(name: String): Reader[Connection, Int] =
createUserReader(User(None, name)).flatMap { uid =>
createAccountReader(Account(None, uid, 0)) }

Reader可以定义为如下:

case class ReaderA, B {
  def apply(a: A): B = f(a)
  def flatMapC: Reader[A, C] =
   Reader { a => f2(f(a))(a) }
}

我们可以看到flatMap模式和效果类型正在重复出现。之前,我们看到了异步计算和错误的副作用。所有这些都由单独的数据结构表示——FutureEither(以及Try)。现在,我们可以看到依赖的效果。也就是说,这种效果是计算无法执行,除非满足特定的资源需求。这种效果也由其自己的效果类型Reader来建模:

正如我们之前所述,我们为Reader类提供了flatMap方法。这个方法的意义与FutureTry的情况相同。也就是说,对副作用计算执行后续操作。这个方法可以在依赖于createUsercreateAccount方法的业务逻辑方法设置中使用。

注意到Readers本质上就是函数。这意味着你无法在没有提供它们所需的依赖项之前运行它们。为此,你可以调用通常定义在Reader数据结构 API 中的一个方法。在我们的例子中,根据前面定义的Reader类,可以这样操作:

val reader: Reader[Connection, Int] = registerNewUserReader("John")
val accId = reader(new Connection)
println(s"Success, account id: $accId") // Success, account id: 1

摘要

在本章中,我们掌握了效果的理论基础,即它们是什么,以及为什么需要它们。我们查看了一些在实践中最常遇到的效果类型示例。我们看到了 Future 类型如何抽象处理异步计算。我们还研究了 Either 类型,它类似于 Try,但允许对错误进行不同的表示。最后,我们介绍了 Reader 效果类型,它抽象处理了依赖效果。我们还看到 flatMap 是效果类型中的一个典型模式,它抽象处理了副作用计算的顺序组合,并将这些效果修正为效果类型。

在下一章中,我们将探讨如何泛化处理效果类型的工作模式。

问题

  1. Future 效果类型是如何进行抽象的?

  2. 如果我们已经有 Try 效果类型,为什么还需要 Either 效果类型?

  3. 函数式编程如何表示依赖注入?

  4. 在我们遇到的所有效果类型中,flatMap 函数扮演着什么角色?

第七章:类型类的概念

在上一章中,我们看到了函数式编程对数据表示的观点。在函数式编程中,数据最常见的形式是函数返回的结果。这个结果通常是一个包含函数结果和函数中发生的副作用数据的结构。不同的副作用用不同的数据结构表示。

我们还看到了分析和处理这些数据结构可能会变得繁琐,因此函数式编程产生了诸如 map 和flatMap之类的模式。还有许多更多的工作效果类型的模式。mapflatMap只是特定上下文中使用的实用方法。然而,它们足够通用,可以从一种数据类型重复到另一种数据类型。

在本章中,我们将看到函数式编程如何处理数据结构的行为。我们将看到诸如mapflatMap之类的操作如何组织成逻辑单元,并展示这些类型如何表示数据结构的行为。

我们将介绍类型类的概念,并解释其背后的推理,以便更好地理解这个模式。

在本章中,我们将涵盖以下主题:

  • 丰富包装器模式

  • 类型类模式

  • 类型类模式的解释

  • 不同语言中的类型类

丰富包装器模式

在本节中,我们将开始了解类型类模式。我们将从介绍丰富包装器模式开始。这个模式是特定于 Scala 的,但它引入了将数据与行为分离的问题,这在类型类模式中变得很重要。

动机

考虑以下问题。Scala 是一种建立在 JVM 之上的语言,因此它可以访问 Java 核心库,你可以在 Scala 程序中使用 Java 核心类。你还可以在你的 Scala 程序中使用任何 Java 库。

以这种方式,Scala 的 String 和 Array 数据类型来自 Java 核心。然而,如果你熟悉 Scala,你知道 String 和 Array 更像是 Scala 集合,而不是 Java 字符串和数组。它们之所以这样处理,是因为 Scala 为你提供了一组额外的方法,例如mapflatMapfilter,这些方法在上述类型之上。因此,当与字符串和数组一起工作时,所有普通 Scala 集合的方法也都可用。字符串被视为字符集合,数组被视为元素的索引序列。

在 Scala 中,我们如何在字符串和数组上拥有来自 Java 的集合方法?答案是 Scala 有一个机制来模拟将方法注入到类中。我们可以在 Scala 中有一个来自第三方库的类,并且能够向这个类注入额外的方法,而无需修改其原始实现,也不通过子类型扩展原始类。这种方法注入机制在将数据与其行为分离的函数式世界中非常有用。

这个解决方案被称为Rich Wrapper模式。要理解它,你需要了解 Scala 中隐式转换的机制。这种机制提供了一种让编译器执行通常手动完成的工作的方法。理解隐式转换的最简单方式是通过一个例子。

隐式转换

假设你拥有同一领域两个不同的模型。某些方法期望一个领域的领域对象,但你希望用另一个领域的领域对象来调用它们。

具体来说,想象一个 Web API,它通过 JSON 响应 HTTP 请求。你可能希望有两个版本的表示用户的对象。一个版本是这个实体的完整版本。它包含密码散列和所有其他数据。以下是完整版本,是实体的内部表示,用于后端,不打算泄露给最终用户:

case class FullUser(name: String, id: Int, passwordHash: String)

这个对象的另一个版本应该在用户向 Web API 发起 HTTP 请求时发送给最终用户。我们不希望暴露太多信息,因此我们将返回这个对象的简短版本。这个版本不会暴露任何敏感信息:

case class ShortUser(name: String, id: Int)

考虑到你需要从请求处理器返回一个对象。由于后端使用FullUser类来表示用户,我们首先需要使用转换方法将其转换为ShortUser

def full2short(u: FullUser): ShortUser =
  ShortUser(u.name, u.id)

还要考虑到以下方法必须执行,以便在响应 HTTP 请求时从请求处理器返回一个对象:

def respondWith(user: ShortUser): Unit = ???

假设我们有一个root用户,并且我们需要能够在请求时返回它:

val rootUser = FullUser("root", 0, "acbd18db4cc2f85cedef654fccc4a4d8")

从前面的代码片段中,我们可以想象一个按照以下方式定义的 HTTP 请求处理器:

val handlerExplicit: PartialFunction[String, Unit] = {
  case "/root_user" => respondWith(full2short(rootUser))
}

你不希望在每次需要返回这个对象时都显式地执行从后端表示的转换。可能有许多上下文你希望这样做。例如,你可以在请求这些内容时将User实体与该用户的论坛帖子或评论关联起来。

隐式转换的概念正是为了这些情况而存在的。在 Scala 中,你可以定义一个方法如下:

implicit def full2short(u: FullUser): ShortUser =
 ShortUser(u.name, u.id)

每当我们在一个期望ShortUser实例的地方使用FullUser实例时,编译器会自动使用作用域内的implicit方法将完整对象转换为短对象。这样,你可以隐式地将一个值转换为另一个值,而无需在代码中添加无关的细节。

在作用域内有隐式转换的情况下,我们可以编写如下代码:

val handlerImplicit: PartialFunction[String, Unit] = {
  case "/root_user" => respondWith(rootUser)
}

上述代码与原始代码等价,其中转换是显式完成的。

Rich Wrapper

隐式转换如何与我们需要将方法注入类时的示例相关?我们可以将方法注入视为一个转换问题。我们可以使用包装器模式定义一个类,该类包装目标类(即将其作为构造函数参数接受)并定义我们需要的那些方法。然后,每当我们在包装器中调用任何最初不存在的方法时,我们可以隐式地将原始类转换为包装器。

考虑以下示例。我们正在对一个字符串调用filter方法:

println("Foo".filter(_ != 'o'))  // "F"

此方法不是String类的一个成员,因为这里的String类是java.lang.String。然而,Scala 集合有这个方法。接下来发生的事情是编译器意识到该对象没有这个方法,但它不会立即失败。相反,编译器开始寻找作用域内的隐式转换,这些转换可以将该对象转换为具有所需方法的另一个对象。这里的机制与我们将用户对象作为参数传递给方法的案例相同。关键是编译器期望一种类型,但接收到的却是另一种类型。

在我们的情况下,编译器期望具有定义了filter方法的类型,但接收到的String类型没有这个方法。因此,它会尝试将其转换为符合其期望的类型,即类中存在filter方法。结果是我们确实在作用域中有一个这样的方法:

implicit def augmentString(x: String): StringOps

在 Scala 的Predef对象中定义了一个隐式转换,该转换将字符串转换为具有所有集合方法(包括filter)的 Rich Wrapper。同样的技术也用于将 Scala 集合方法注入 Java 数组中。

这种技术并不特定于 Scala,尽管其底层机制是。以某种形式或另一种形式,它存在于许多语言中。例如,在 C#中,你有隐式转换的概念,可以隐式地将一种类型转换为另一种类型。在 Haskell 中,我们有这种技术的更强大、功能性的版本。

类型类模式

有时,我们打算使用的效果类型事先并不知道。考虑日志记录的问题。日志记录可以记录到列表或文件中。使用 Writer 效果类型可以实现将日志记录到列表中。

Writer 是一个计算生成的结果和日志对的抽象。在其最简单形式中,Writer 可以理解为字符串列表和任意结果的配对。我们可以如下定义 Writer 效果类型:

case class SimpleWriterA {
  def flatMapB: SimpleWriter[B] = {
    val wb: SimpleWriter[B] = f(value)
    SimpleWriter(log ++ wb.log, wb.value)
  }
  def mapB: SimpleWriter[B] =
   SimpleWriter(log, f(value)
}

注意,我们也为这种效果类型定义了熟悉的mapflatMap方法。

有几句话要说关于我们如何实现flatMap方法。实际上,我们使用的是 Writer 类型的简化版本。在其简化形式中,它是一个包含结果和字符串列表(即日志条目)的数据结构。

flatMap方法回答了如何组合具有SimpleWriter效果的顺序计算的问题。所以,给定两个这样的计算,一个作为另一个的延续(即前一个计算的结果参数化了它),问题是——我们如何产生该延续的结果,同时保留前一个计算的日志?

在前面的代码片段中,你可以看到flatMap方法是如何为SimpleWriter数据结构实现的。所以,首先,我们使用当前数据结构的结果作为输入来运行延续。这次运行在SimpleWriter的副作用下产生另一个结果,即带有计算日志的结果。之后,我们产生一个结合了第二个计算的结果和第一、第二个计算的合并日志的 Writer。

我们也可以为这种数据类型定义一个伴随对象,其中包含将任何值提升到效果类型和创建一个包含单个日志消息的空结构的方法:

object SimpleWriter {
  // Wraps a value into SimpleWriter
  def pureA: SimpleWriter[A] =
    SimpleWriter(Nil, value)
  // Wraps a log message into SimpleWriter
  def log(message: String): SimpleWriter[Unit] =
    SimpleWriter(List(message), ())
}

使用 Writer 效果类型,我们可以从操作中记录日志如下:

import SimpleWriter.log
def add(a: Double, b: Double): SimpleWriter[Double] =
for {
  _ <- log(s"Adding $a to $b")
  res = a + b
  _ <- log(s"The result of the operation is $res")
} yield res
println(add(1, 2))  // SimpleWriter(List(Adding 1.0 to 2.0, The result
of the operation is 3.0),3.0

使用另一个效果IO可以实现对文件的日志记录。IO类型代表输入输出效果,这意味着计算与某些外部资源交换信息。我们可以定义一个模拟的 IO 版本,它只是暂停计算,如下所示:

case class IOA => A) {
  def flatMapB: IO[B] =
   IO.suspend { f(operation()).operation() }
  def mapB: IO[B] =
   IO.suspend { f(operation()) }
}
object IO {
  def suspendA: IO[A] = IO(() => op)
  def log(str: String): IO[Unit] =
   IO.suspend { println(s"Writing message to log file: $str") }
}

前面的定义遵循与SimpleWriter类型相同的模式。log方法实际上并不写入任何文件,而是通过输出到终端来模拟此操作。借助这种效果类型,我们可以如下使用日志记录:

import IO.log
def addIO(a: Double, b: Double): IO[Double] =
for {
  _ <- log(s"Adding $a to $b")
  res = a + b
  _ <- log(s"The result of the operation is $res")
} yield res
addIO(1, 2).operation()
// Outputs:
// Writing message to log file: Adding 1.0 to 2.0
// Writing message to log file: The result of the operation is 3.0

如果我们事先不知道我们将要记录在哪里?如果我们有时需要将日志记录到文件,有时需要记录到列表中呢?如果我们处于不同的环境中,例如预发布、测试或生产环境,上述情况可能发生。问题是:我们如何具体进行代码的泛化,使其与效果无关?这里的问题是,前面两个代码片段仅在它们使用的日志记录效果类型上有所不同。在编程中,每当看到一种模式时,提取它是好主意。

抽象掉效果类型的一种方法如下:

// Does not compile
// def add[F[_]](a: Double, b: Double): F[Double] =
//   for {
//     _ <- log(s"Adding $a to $b")
//     res = a + b
//     _ <- log(s"The result of the operation is $res")
//   } yield res

因此,效果类型变成了一个F类型参数。函数在类型级别上变得参数化了。然而,当我们尝试实现方法的主体时,我们会迅速遇到困难。前面的代码无法编译,因为编译器对F类型参数一无所知。我们在这种类型上调用mapflatMap方法,编译器无法知道这个类型上实现了哪些方法。

这个问题的解决方案以类型类模式的形式出现。在类型类模式之下,方法看起来如下所示:

import Monad.Ops
def add[F[_]](a: Double, b: Double)(implicit M: Monad[F], L: Logging[F]): F[Double] =
for {
  _ <- L.log(s"Adding $a to $b")
  res = a + b
  _ <- L.log(s"The result of the operation is $res")
} yield res
println(addSimpleWriter)  // SimpleWriter(List(Adding 1.0 to 2.0, The result of the operation is 3.0),3.0)
println(addIO.operation())
// Outputs:
// Writing message to log file: Adding 1.0 to 2.0
// Writing message to log file: The result of the operation is 3.0
// 3.0

我们可以在这里使用mapflatMap方法的原因是,我们现在在方法参数列表中有一个隐式依赖。这个依赖是类型类Monad的依赖。Monad是函数式编程中最常见的类型类之一。还有一个依赖项是 Logging,它提供了log方法,这也是我们感兴趣的效果类型都可用的一种常见方法。

让我们看看类型类是什么,以及它们如何在Monad的例子上工作。

在函数的主体中,我们可以使用mapflatMap函数,编译器可以解析它们。之前,我们看到了使用隐式依赖完成的相同方法注入技巧。在那个案例中,我们有一个将目标类型转换为 Rich Wrapper 的隐式转换。在这种情况下,使用了类似的模式。然而,它更复杂。复杂性在于 Rich Wrapper 封装了具体类,但我们现在针对的是抽象类型变量,在我们的例子中是F

正如 Rich Wrappers 的情况一样,mapflatMap方法通过隐式转换注入到前面的代码中。让我们看看使这种转换成为可能的方法和类:

trait Monad[F[_]] {
  def pureA: F[A]
  def mapA, B(f: A => B): F[B]
  def flatMapA, B(f: A => F[B]): F[B]
}
object Monad {
  implicit class Ops[F[_], A](fa: F[A])(implicit m: Monad[F]) {
    def mapB: F[B] = m.map(fa)(f)
    def flatMapB: F[B] = m.flatMap(fa)(f)
  }
  implicit val writerMonad: Monad[SimpleWriter] = 
   new Monad[SimpleWriter] {
     def pureA: SimpleWriter[A] =
      SimpleWriter.pure(a)
    def mapA, B(f: A => B): SimpleWriter[B] =
      fa.map(f)
    def flatMapA, B(f: A => SimpleWriter[B]):
      SimpleWriter[B] = fa.flatMap(f)
  }
  implicit val ioMonad: Monad[IO] = new Monad[IO] {
    def pureA: IO[A] =
     IO.suspend(a)
    def mapA, B(f: A => B): IO[B] =
     fa.map(f)
    def flatMapA, B(f: A => IO[B]): IO[B] =
     fa.flatMap(f)
  }
}

在前面的代码片段中,你可以看到使所需的转换成为可能的整个代码。这段代码实现了类型类模式。让我们一步一步地看看它:

trait Monad[F[_]] {
  def pureA: F[A]
  def mapA, B(f: A => B): F[B]
  def flatMapA, B(f: A => F[B]): F[B]
}

在前面的代码片段中,你可以看到包含所有特定效果类型必须实现的方法的特质的定义。特质的具体实现将特质的类型参数设置为类实现的具体类型。特质由所有应该支持该类型的方法的声明组成。请注意,所有方法都期望调用它们的对象。这意味着这个特质不应该由目标对象实现。相反,这个特质的实例应该是一种工具箱,为特定类型定义某些行为,而不使该类型扩展特质。

接下来,我们有这个特质的伴随对象。这个伴随对象定义了模式中也是一部分的特定方法:

implicit class Ops[F[_], A](fa: F[A])(implicit m: Monad[F]) {
  def mapB: F[B] = m.map(fa)(f)
  def flatMapB: F[B] = m.flatMap(fa)(f)
}

首先,有一个富包装器,正如你在前面的代码中看到的那样。这个模式的工作方式与我们之前看到的字符串和数组包装器的方式相同。然而,有一个小小的不同之处。它是在一个F[A]抽象类型上定义的。原则上,它可以是对任何效果类型。一开始可能会觉得我们正在为每个可能的类型定义一组方法。然而,对于实现这些方法的类型有一些约束。这些约束是通过富包装器构造函数后面的隐含参数来强制执行的:

implicit m: Monad[F]

因此,为了构建包装器,我们需要满足对前面代码片段中定义的类型类的隐含依赖。这意味着为了F类型能够使用富包装器模式,我们需要在作用域中隐含地有一个前面代码中定义的特质的实例。当我们说“F 类型的类型类的一个实例”时,我们指的是一个具体对象,它扩展了类型类特质,其中类型参数被设置为F

例如,Monad for Writer实例是一个类型符合Monad[Writer]的对象。

所有富包装器的方法都模仿类型类,并将其委托给它。

之后,我们对某些常见的类提供了一些类型类的默认实现。例如,我们可以为我们的 Writer 和 IO 类型定义一些:

implicit val writerMonad: Monad[SimpleWriter] = new Monad[SimpleWriter] {
  def pureA: SimpleWriter[A] =
   SimpleWriter.pure(a)
  def mapA, B(f: A => B): SimpleWriter[B] =
   fa.map(f)
  def flatMapA, B(f: A => SimpleWriter[B]):
   SimpleWriter[B] = fa.flatMap(f)
}
implicit val ioMonad: Monad[IO] = new Monad[IO] {
  def pureA: IO[A] =
   IO.suspend(a)
  def mapA, B(f: A => B): IO[B] =
   fa.map(f)
  def flatMapA, B(f: A => IO[B]): IO[B] =
   fa.flatMap(f)
}

注意,在前面示例中,我们通过将它们委托给SimpleWrapper和 IO 类拥有的实现来实现mapflatMap方法。这是因为我们已经在这些类中实现了这些方法。在现实世界中,通常类将不会有所需的方法。所以你将编写它们的整个实现,而不是将它们委托给类拥有的方法。

Monad类似,Logging类型类封装了两个效果类型共有的log方法:

trait Logging[F[_]] {
  def log(msg: String): F[Unit]
}
object Logging {
  implicit val writerLogging: Logging[SimpleWriter] =
  new Logging[SimpleWriter] {
    def log(msg: String) = SimpleWriter.log(msg)
  }
  implicit val ioLogging: Logging[IO] = new Logging[IO] {
    def log(msg: String) = IO.log(msg)
  }
}

它遵循与Monad类型类相同的模式。首先,特质声明了类型类将拥有的方法。接下来,我们有伴随对象,为我们的效果类型提供了一些默认实现。

让我们看看前面的代码是如何使日志示例能够使用flatMapmap方法的,以及这里隐含解析的机制是如何工作的。

首先,编译器看到我们正在尝试在F类型上调用flatMap方法。编译器对F类型一无所知——它不知道它是否有这个方法。在普通的编程语言中,在这个点上就会发生编译时错误。然而,在 Scala 中,隐式转换开始发挥作用。编译器会尝试将这个F类型转换为具有所需flatMap方法的东西。它将开始隐式查找,以找到将任意F类型转换为具有所需方法的隐式转换。它会找到这样的转换。这种转换将是之前讨论过的Monad类型类的 Rich Wrapper。编译器会看到它可以转换任何F[A]效果类型到一个具有所需方法的包装器。然而,它会看到除非它能向 Rich Wrapper 的构造函数提供一个对类型类的隐式依赖,否则它无法做到这一点。这个类型类Monad为它所实现的效应类型定义了mapflatMap方法。换句话说,只有当作用域内有类型类的实现时,效应类型才能转换为这种 Rich Wrapper。如果一个类型没有Monad类型类的实现,它将不会被 Monad 的 Rich Wrapper 包装,因此它将不会注入mapflatMap方法,并且将生成编译时错误。

因此,编译器会看到它可以隐式地注入所需的方法,但前提是它找到了必要的类型类的隐式实现。因此,它会尝试找到这种实现。如果你使用 Writer 或 IO 类型调用它,它将能够找到类型类的实例,因为它们是在 Monad 伴随对象内部定义的。伴随对象会搜索其伴随类的隐式实现。

在这里,我们讨论了一些特定于 Scala 的细节——“Rich Wrapper”模式比其他任何模式都更特定于 Scala。然而,Type Class模式在许多语言中都有重复出现。接下来,我们将讨论一些关于类型类的原因,以便你知道如何思考这种模式。

Type Class模式的解释

由于类型类的概念非常抽象,有必要了解它是什么以及如何在实践中使用它。

可注入接口

考虑Type Class模式的一种方式是将其视为将整个接口注入现有类的方法。

在普通的命令式语言中,接口促进了多态。它们允许你统一地对待表现出相似行为的类。例如,如果你有汽车、摩托车和卡车的类,你可以定义一个vehicle接口,并将所有这些类视为该接口的实例。你不再关心每个类的实现细节,你只关心所有实体都能驾驶。也就是说,它们表现出所有类都典型的一种行为。接口是一种封装共同行为的方式。当面向接口编程时,你是在基于这样的假设来编写程序:程序中的一组实体表现出在本质上相同的行为,尽管在每种实现中可能有所不同。

然而,在普通的命令式语言中,例如 Java,你必须在定义时声明接口。这意味着一旦类被定义,你就无法让它实现额外的接口。这个事实使你在某些情况下与多态作斗争。例如,如果你有一堆第三方库,并且希望这个库的类实现你程序中定义的特定接口,你就无法做到这一点。

如果你看看带有日志的示例,我们会看到这个示例正是关于多态的。我们随机选择一个F效果类型,并基于它具有某些行为——flatMapmap——的假设来定义示例。尽管这些行为可能因效果类型而异,但它们的本质是相同的——副作用计算的顺序组合。我们唯一关心的是我们使用的效果类型支持这些方法。只要这个条件得到满足,我们就不会关心效果类型的其他细节。

这种技术在函数式编程领域特别有帮助。让我们回顾一下——mapflatMap的需求最初是如何产生的?从数学的角度来看,它们有一个理论基础。然而,从工程的角度来看,mapflatMap方法的需求非常实用。函数式程序员需要频繁地分析代码中效果类型的代码结构,以便按顺序组合纯副作用计算,这很快就会变得相当繁琐。因此,为了避免每次分析数据结构时的模板代码,我们将顺序组合的问题抽象成了mapflatMap方法。

这里的普遍模式是我们需要用功能数据结构来做各种事情。mapflatMap函数定义了如何进行计算的顺序组合。然而,我们可能想要做更多的事情。普遍的模式是,我们应该能够抽象出我们已有的常见重复操作,而我们可能事先并不知道所有我们可能想要支持的运算。这种情况使得将数据与行为分离成为必要。在现代函数式编程库中,效果类型(包含计算副作用信息的数据结构)与其行为(你可以用它们做什么)是分开的。这意味着效果类型只包含表示副作用的那些数据。每当我们需要对效果类型做些什么时,我们就可以使用之前讨论过的类型类模式将所需的行为注入其中。许多函数式库分为两部分——描述效果类型的部分,以及代表你可以对数据做什么的、其行为的类型类部分。这两部分通过特定于库所写编程语言的类型类机制统一在一起。例如,在 Scala 中,隐式转换机制为类型类模式和注入方法提供动力。Scala 编译器本身没有类型类模式的概念,但你可以使用语言提供的工具有效地表达它。

Haskell 对类型类有语言级别的支持。在 Haskell 中,数据和类型类在语言级别上是分开的。你无法在数据上定义任何行为。Haskell 在语言级别上实现了数据与行为分离的哲学。这一点在 Scala 中并不适用。在 Scala 中,你可以有普通的面向对象类,这些类可以既有数据(变量)也有行为(方法)。

工具箱

类型类模式的另一个有用的隐喻是,存在一些工具箱,允许你对数据进行操作。

想象你自己是一名木匠。木匠是这样一种人,他们用木头创造出各种东西。一个人如何从木头中创造出有用的东西呢?他们取来原木,然后去他们的工坊,那里有一堆可以用来加工木头的工具。他们使用锤子、锯子等等,将木头变成桌子、椅子和其他商品。如果木匠技艺高超,他们可能会区分不同种类的木材。例如,某些树木的木材坚固,而其他树木的木材柔软。同样的锯子对一种木材的加工效果比对另一种木材的效果更好。因此,木匠会为不同种类的木材准备不同类型的锯子。然而,无论木材的种类如何,木匠需要锯子来砍伐树木这一事实是恒定的。

在编程世界中,效果类型就像是木材。它们是函数式编程的原材料,你从中组合出你的程序。在原始状态下,没有工具很难处理——手动分析、组合和处理效果类型就像没有锯子和锤子很难从木材中雕刻出成品一样。

因此,存在处理效果类型的工具。类型类对于效果类型来说,就像锯子对于木材一样。它们是允许你处理原材料的工具。

同一把锯子可能不适用于不同类型的木材。同样地,不同的效果类型需要不同类型类的实现。例如,Writer 和 IO 效果类型需要分别实现Monad类型类。类型类的目的,即顺序组合,保持不变;不同情况下顺序组合的方式不同。这可以与锯切各种原材料的目的保持一致,即切割木材。然而,具体操作细节各不相同,因此需要为不同类型的原材料准备不同的锯子。

这就是为什么在类型类模式中,我们首先声明在特质中必须展示的行为,然后才为每个类型单独实现这种行为。

正如木匠有一个工具箱来处理原材料木材一样,函数式程序员有一个类型类来处理原材料效果类型。而且正如木匠有一个充满工具的整个车间一样,函数式程序员有充满不同目的类型类的库。我们将在下一章中介绍这些库。

不同语言中的类型类

原则上,类型类的想法即使在 Java 中也是存在的。例如,Java 有Comparator接口,它定义了如何比较两种任意类型。它定义了一个类型上的顺序关系。与集合一起使用的类型定义了它们排序的顺序。

然而,像 Java 这样的语言缺乏将此类方便地应用于类型的机制。所以,例如,当你对一个集合进行排序时,你需要显式地提供一个类型类的实例给排序方法。这与 Scala 不同,在 Scala 中,可以使用隐式转换和隐式查找,让编译器自己查找类型类的实现,从而不使代码杂乱。

在 Scala 中,编译器比 Java 中的编译器要聪明得多,部分原因是因为存在隐式解析机制。因此,当我们想要将一组特定方法注入一个类时,我们可以借助隐式转换来实现。如果在 Java 中我们需要显式提供所有类型类,那么在 Scala 中我们可以将大部分这项工作留给编译器。

在 Haskell 中,存在类似的机制来执行类型类的隐式查找。此外,Haskell 遵循数据和行为的分离。因此,通常情况下,你无法在数据上声明方法,也无法定义同时包含变量和方法的大类。这是为了强制执行纯函数式编程风格。在 Scala 中,它是一种纯函数式编程和面向对象编程的混合,你可以拥有同时包含变量和方法的大类。

谈到隐式解析机制,我们应该注意到这是一个相对高级的功能,并不是每种编程语言都有。

摘要

在本章中,我们介绍了类型类的概念,这是现代函数式编程的核心。我们通过首先介绍 Rich Wrapper 模式来构建这个概念,该模式有助于 Scala 中的类型类。类型类可以被理解为处理原始效果类型的工具箱。对类型类模式的另一种理解是,它是一个可注入的接口,你可以将其注入到你的类中以实现多态。最后,我们探讨了类型类在其他语言中的应用。在下一章中,我们将学习常用类型类及其组织在的库。

问题

  1. Scala 中的 Rich Wrapper 模式是什么?

  2. Scala 中 Rich Wrapper 的实现方式是什么?Scala 中的隐式转换机制是什么?

  3. 解释类型类模式。

  4. 类型类模式的动机是什么?

  5. 强制性语言有类型类吗?

第八章:基本类型类及其用法

在上一章中,我们讨论了类型类的概念以及类型类是如何作为解耦数据与行为的方法论的。我们还看到了类型类如何被当作工具箱来抽象某些行为。本质上,对于一个函数式程序员来说,它们就像是木匠的工作室。

在之前的章节中,我们也看到了类型类是如何基于函数式编程中出现的实际需求来激发的。在这一章中,我们将看到整个函数式编程类库是如何从实际需求中产生的。我们将查看这样一个库,并了解典型库的结构以及它们在实际中的应用。

本章我们将涵盖以下主题:

  • 将类型类组织成系统和库的动机

  • 纯函数式编程的Cats库及其结构

  • Cats类型类定义

将类型类组织成系统和库的动机

工程学的基本原则是抽象掉重复的部分。在之前的章节中,我们看到了函数式编程如何广泛地处理效果类型并将副作用封装到它们中。这是因为直接处理它们可能会很繁琐。仅使用你选择的编程语言提供的服务来分析这些数据结构是非常困难的。因此,处理效果类型的模式被抽象到类型类中。

到目前为止,我们只看到了一小部分类型类。然而,最重要的是要认识到它们背后的原则,即认识到类型类是如何被创建的以及它们存在的动机。创建新类型类的动机正是处理副作用给程序员带来的复杂性。

我们还了解到,类型类模式至少由两部分组成。第一部分是声明类型类支持的方法,第二部分是为你将要工作的效果类型实现类型类。某些效果类型被嵌入到语言的核心中。例如,在 Scala 中,FutureOptionEither等类型默认存在于语言核心库中。这意味着你将频繁地处理它们,这也意味着每次你处理这些效果类型时,都需要类型类的实现。基本上,这意味着每次你在不同的项目中需要这些类型时,你都将重新定义我们对这些类型的类型类实现。

当某些功能从项目到项目重复出现时,将其封装到独立的库中是有意义的。因此,前面的讨论表明,这里我们遇到了从项目到项目重复出现功能的情况。第一个是我们在多个项目中使用的类型类本身。例如,Monad 处理顺序组合,而顺序组合在函数式和非函数式世界中都很常见。

另一个在项目间重复的项目是频繁重复的效果类型的类型类实现。

前面的论点可以稍微扩展到效果类型本身。之前我们提到,函数式语言的核心库通常包括对经常遇到的效果类型的支持。然而,可以想象一种情况,您可能需要自己定义效果类型。例如,您可能正在处理一些想要封装的新效果,或者您可能正在定义一些针对您自己的用例和项目的特定内容。

这样,您会注意到某些不是核心库成员的副作用从项目到项目开始重复出现。在这种情况下,将它们封装到独立的库中是明智的。当然,如果您经常处理不在语言核心中的相同效果类型,那么在库中定义它们的类型类实现也是一个好主意。

这是因为每当您需要这些效果类型时,您也将需要相应的类型类来与之配合。因此,如果您打算将这些效果类型封装到一个独立的库中,您也需要在该库中封装类型类的实现。

总结前面的论点,我们需要封装三件事:

  • 类型类定义

  • 类型类实现

  • 那些不在语言核心中且为它们提供类型类实现经常遇到的效果类型

这些纯函数式编程的库已经为各种编程语言实现了。现在,让我们看看这样一个库可能的样子以及您如何在实践中使用它。我们将使用一个名为Cats的库,它来自 Scala。

用于纯函数式编程的 Cats 库

在本节中,我们将介绍我们将用于 Scala 纯函数式编程的库。它封装了经常遇到类型类、它们为经常遇到的效果类型提供的实现,以及一些效果类型本身。

在本节中,我们将更深入地探讨库的结构,并展示您如何在实践中使用它。我们将以之前章节中讨论过的Monad类型类为例。我们将看到这个类型类在这个库中的定义以及它是如何为其数据类型实现的。

库的结构

库由顶级包及其子包组成。顶级包被称为cats,是定义基本类型类的地方:

图片

除了这些,顶级包中还有几个子包。其中最重要的是instancesdatasyntax

instances包包含了语言核心和Cats库定义的基本数据类型的类型类实现。最后,在data包下定义了经常遇到但不在语言核心中的数据类型。

我们现在将详细查看这些结构元素中的每一个。我们将从顶级包开始,即cats

核心部分

库的核心包cats公开了以下 API:

图片

核心包包含由库定义的所有类型类的列表。在Cats实现中,类型类模式通常由一个特质及其伴随对象组成。

让我们用一个Monad的例子来看看在库的上下文中一个典型的类型类是什么样的:

图片

现在我们来更详细地看看Cats类型类层次结构中的类型类是如何构建的。

类型类层次结构

这里首先要注意的是,类型类是以我们在上一章中看到的形式定义的。另一个要注意的是Cats库中类型类的层次结构。例如,Monad类扩展了FlatMapApplicative类型类,如果你查看类型类的线性超类型,你会看到祖先类非常多。此外,如果你查看子类,你会注意到许多类型类也扩展了Monad类型类。

这种层次结构的原因是Cats库非常细粒度。我们之前讨论过,类型类可以被看作是用于方法的容器。例如,Monad类型类可以一次性定义多个方法。因此,为每个方法定义一个单独的类型类似乎是合理的。现在让我们讨论Monad定义的抽象方法。

抽象方法

让我们看看Cats实现的Monad的 Scaladoc 文档的value member部分。抽象成员部分是任何类型定义最重要的部分。类型类是一系列工具的声明,其具体实例必须支持这些工具。它们在类型类特质中声明,但没有实现。因此,类型类定义的抽象方法构成了这个类型类的定义。

具体来说,在Monad的情况下,我们有三个抽象方法,如下所示:

  • 有一个flatMap方法,我们已经很熟悉了。

  • 纯方法能够将任何值提升到 F 的效果类型。

  • 存在一个 tailRecM 和类型类。它是一个尾递归的 Monadic 循环。这个方法的直觉如下。Monad 的 flatMap 定义了效果计算的可序列组合。在有序列的情况下,也可能需要循环。循环是一系列重复执行的指令。因此,循环建立在序列组合之上。如果你定义了序列组合,你可以用它来定义循环。tailRecM 的作用是为效果类型下的函数式编程提供一个这样的循环。你可以把它看作是纯函数式编程的 while 循环。我们将在本章后面更详细地讨论这个方法。

具体方法

除了抽象方法之外,Monad 类型类提供了一组预定义的具体值成员。这些成员默认在类型类中实现,因此当你定义类型类实例时,不需要提供这些值成员的实现。它们的定义基于我们之前看到的抽象值成员。这意味着你可以用之前看到的抽象值成员来定义在具体值成员下遇到的每个方法。

具体值成员通常包含在类型类的超类中的抽象值成员的方法是很常见的。以我们已熟悉的 map 方法为例。技术上,它作为 Functor 类型类的抽象成员。然而,可以仅用 flatMap 和纯函数来定义类型类。这两个函数是 Monad 类的抽象成员,因此我们可以用具体的实现来覆盖继承的 map 函数,如下所示:

def mapA, B(f: A => B): F[B] = fm.flatMap(fa)(x => pure(f(x)))

在前面的代码片段中,你可以看到当你有 flatMappure 函数时,这个函数是如何实现的。提醒一下,基于 flatMappure 函数的这种实现并不总是可取的。有些情况下,你可能希望有一个自定义的函数实现,这些函数可以用抽象方法来实现。在某些场景中,重用你已有的功能并不总是最佳解决方案。

对于这种逻辑的直觉如下。我们已经讨论过,在纯函数式编程中,序列组合是由 Monad 促成的。在本章的后面,我们将看到一个为并行组合设计的类型类。并行组合两个计算的操作符可以有两种实现方式。一种方式是你从真正的并行性中期望的方式。它独立执行计算。例如,如果一个计算失败,另一个计算仍然会继续,并且仍然会产生一个值。然而,可以使用序列组合来帮助实现并行组合操作符。你可能有一个这样的组合实现,它只是顺序地组合两个计算,尽管你可能会将其命名为并行组合操作符。所以,如果你有一个如flatMap这样的序列组合操作符,一个简单的并行组合操作符将被定义为使用这个序列组合操作符对计算进行序列组合。

我们进行这次讨论的原因是,Monoid 继承自 Applicative 类型类。Applicative 类型类是为并行计算设计的。它包含一个名为ap的方法,该方法旨在并行组合计算。然而,当我们过去讨论Monad类型类时,我们没有看到这个方法在抽象成员中。这是因为它是Monad类型类的具体成员,这意味着它是使用由 Monad 定义的flatMappure函数实现的。在实践中,这意味着如果你想要执行并行组合,你可能能够做到,这取决于 Monad 或 Applicative 类型类。然而,如果你依赖于 Monad,你可能不会得到真正的并行性,因为它的并行操作符可能是以序列组合的形式实现的。因此,理解类型类的机制非常重要,不要将它们视为某种神奇的东西,因为你可能会遇到意外的错误。

类型类在形式上有一个坚实的数学基础,即范畴论。我们不会在这个关于函数式编程的实用指南中讨论这个理论。然而,在下一节中,我们将触及类型类的数学性质,并讨论它们必须遵守的数学定律。

法则

类型类是通过它们支持的方法来定义的。在定义一个类型类时,你并没有一个确切的想法知道对于每一个给定的数据类型,这些方法将如何具体实现。然而,你确实有一个大致的概念,了解这些方法将做什么。例如,我们有一个大致的概念,认为flatMap负责序列组合,而纯函数对应于将一个值提升到效果类型而不做其他任何事情。

这种关于方法应该如何表现的信息可以通过类型类必须遵守的数学定律来封装。实际上,大多数类型类都可以从数学的角度来观察,因此它们必须遵守某些定律。

让我们看看 Monads 必须遵守的定律。共有三个,如下所示:

  1. 左单位定律pure(a).flatMap(f) == f(a)。这意味着如果你有一个原始值a和一个函数f,该函数接受这个值作为输入并从中计算出一个效果类型,那么直接在a上应用这个函数的效果应该与首先在a上使用pure函数然后与f扁平映射的结果相同。

  2. 右单位定律m.flatMap(pure) == m。这意味着一个纯函数必须将一个值提升到效果类型中,而不执行任何其他操作。这个函数的效果是空的。这也意味着如果你在纯函数上使用flatMap函数,纯函数必须表现得像一个恒等函数,这意味着你扁平映射的效果类型将等于扁平映射的结果。

  3. 结合律m.flatMap(f).flatMap(g) == m.flatMap(a => f(a).flatMap(g))。基本上,这个定律表明flatMap应用的优先级并不重要。将结合律与+操作符的上下文联系起来思考——(a + b) + c == a + (b + c)

对于大多数类型类,你应该期待定义一些数学定律。它们的含义是,它们为你提供了在编写软件时可以依赖的某些保证。对于Monad类型类的每个具体实现,前面的数学定律必须成立。对于任何其他类型类,所有其实现都必须遵守其自己的定律。

由于每个类型类实现都必须遵守某些定律,因此合理地预期所有实现都必须根据这些定律进行测试。由于定律不依赖于类型类的特定实现,并且应该对类型类的每个实现都成立,因此将这些测试定义在定义类型类的同一库中也是合理的。

我们这样做是为了我们不必每次都重新定义这些测试。实际上,这些测试是在Cats库的单独模块cats-laws中定义的。该模块为每个cats类型类定义了定律,并提供与大多数流行测试框架的集成,这样一旦你定义了自己的类型类实现,你就不需要定义测试来检查这个实现是否与数学定律相符。

例如,这是定义Monad测试的方式:

implicit override def F: Monad[F]
def monadLeftIdentityA, B: IsEq[F[B]] =
 F.pure(a).flatMap(f) <-> f(a)
def monadRightIdentityA: IsEq[F[A]] =
 fa.flatMap(F.pure) <-> fa
/**
 * Make sure that map and flatMap are consistent.
 */
 def mapFlatMapCoherenceA, B: IsEq[F[B]] =
  fa.flatMap(a => F.pure(f(a))) <-> fa.map(f)
 lazy val tailRecMStackSafety: IsEq[F[Int]] = {
   val n = 50000
   val res = F.tailRecM(0)(i => F.pure(if (i < n) Either.left(i + 1)
    else Either.right(i)))
   res <-> F.pure(n)
 }

接下来,让我们讨论如何使用Cats库方便地从 Scala 代码中调用由Monad定义的方法。让我们看看Cats提供了哪些基础设施来暴露效果类型上的方法。

语法

我们在这里应该提到,使用具有 Rich Wrapper 模式的隐式机制的要求是 Scala 特有的。Scala 是一种混合面向对象和纯函数式风格的编程语言。这就是为什么某些函数式编程特性,如类型类,不是语言的一部分,而是以更通用的方式实现的。这意味着在 Scala 中,方法注入和类型类模式不是一等公民。它们不是在语言级别定义的。相反,它们利用在类级别定义的更通用机制——隐式机制。因此,为了在 Scala 项目中无缝使用类型类,你需要使用这种机制,以便它们能够手动生效。

应该注意的是,这可能不适用于其他函数式语言。例如,Haskell 对类型类编程风格有语言级别的支持。这就是为什么你不需要担心方法注入。这是因为语言本身为你做了所有必要的工作。

然而,像 Scala 这样的语言,它们可能没有对风格的一等公民支持,可能需要你使用这样的机制。类型类编程的确切方法可能因语言而异。在本节中,我们将探讨这对于 Scala 是如何工作的。

我们之前讨论过,Scala 中的方法注入是通过隐式机制和 Rich Wrapper 模式实现的。由于这种注入方法的机制是为每个类型类定义的,因此将所需的 Rich Wrappers 与所有类型类一起定义在 Cats 库中是有意义的。这确实在 Cats 库中实现了,在 syntax 包中,如下所示:

图片

该包包含一系列类和特质。你需要注意它们遵循的命名约定。你会看到许多特性和类以 OpsSyntax 结尾,例如,MonadOpsMonadSyntax

除了类和特质之外,你还会注意到这个包中存在一组单例对象。这些对象的名称模仿了它们定义的类型类的名称。

让我们看看这种机制是如何在 Monad 类型类中工作的:

图片

首先,让我们看看 MonadOps 类。这是一个 Rich Wrapper,用于 Monad 方法注入。它将 Monad 类型类提供的方法注入到效果类型 f 中。关于它注入的方法,有一点需要注意,那就是所有这些方法都有一个隐式的 Monad 参数。它们将其实施委托给这个类型类。

然而,MonadOps 类不是一个隐式类——它是一个普通类。我们之前了解到,对于 Rich Wrapper 模式,我们需要从效果类型到 Rich Wrapper 的隐式转换。那么,这个转换在哪里定义的,又是如何引入作用域的?为了找出答案,让我们看一下 MonadSyntax 特质:

如你所见,MonadSyntax 包含隐式方法。这些方法本应将任何对象 F[A] 转换为 MonadOps[F[A]]。然而,你如何将这些方法引入作用域?

为了这个目的,让我们看一下 Monad 单例:

如前述截图所示,单例扩展了 MonadSyntax 特质。所以这基本上是 MonadSyntax 特质的具体实现。你可以导入这个对象的所有内容,并将 MonadOps 的 Rich Wrapper 包含在内。

为什么它被实现为单例和特质的组合?将 Rich Wrapper 实现为一个包含所有必需方法的单例对象不是更方便吗?

如果你查看 syntax 包中存在的单例对象的数量,这可以理解。如果你在一个 Scala 文件中使用很多类型类,每个类型类的所有导入都可能很繁琐,难以编写和跟踪。因此,你可能希望一次性引入所有可用类型类的语法,即使你永远不会使用其中大部分。

正是因为这个原因,存在一个 all 单例对象,如下截图所示:

如果你查看这个对象及其超类型,你会发现其祖先构成一个庞大的列表。它们包括在包中定义的所有语法特性。这意味着这个单例对象包含了从效果类型到 Rich Wrappers 的所有隐式转换,这些 Rich Wrappers 将类型类中定义的方法注入到相关效果类型中。你可以将这个对象的所有内容导入到你的项目中,并使所有这些隐式转换在作用域内。这正是我们在特质内部而不是在单例对象内部定义隐式转换的原因。如果你将这些隐式转换定义为单例对象的一部分,你将无法将这些单例对象组合成一个对象,因为你不能从单例对象继承。然而,在 Scala 中你可以从多个特质继承。因此,特质存在的原因是模块化和可组合性。

总结来说,Cats 库包含两个主要组件:

  • 它包含包装效果类型并注入类型类定义的方法的 Rich Wrapper 类

  • 它包含从这些效果类型到 Rich Wrapper 类的隐式转换

在本章的后面部分,我们将看到如何在实际中利用这些功能。

接下来,让我们看看instances包的结构和目的。

实例

instances包公开了以下 API:

如前一个截图所示,instances包包含相当多的实体。与syntax包的情况一样,这里要注意的主要是这些实体的命名约定。首先,我们有一组特性和类。它们的命名如下——名称的第一部分是定义实例的类型名称,然后是Instances后缀。

同样,也存在一些单例对象,它们的名称与定义实例的类型相对应。

让我们看看实例特质的一个样子:

在前一个截图中,你可以看到FutureInstances特质的结构。所有的方法都被定义为implicit方法,这意味着每当导入这个特质的成员时,它们都会被带入隐式作用域。另一个需要注意的重要事情是方法的返回类型。这些返回类型都是某种类型类。这些方法的含义是为给定效果类型提供各种类型类的隐式实现。还要注意,特质包含了许多针对各种类型类的方法,但它们都是通过Future类型参数化的。所有类型类都为此效果类型实现了。

syntax包的情况类似,特质随后被用来创建单例对象。例如,让我们看看future单例:

future单例对象扩展了FutureInstances特质,同样的模式也适用于instances包中存在的所有其他单例对象。单例扩展特质的理由与syntax包的情况类似:

该包还定义了一个all单例对象,它扩展了包中存在的所有其他特质。这个策略的价值在于,为了将标准类型类的实现纳入作用域,你只需要导入all对象的内容即可。你不需要为每个类型单独导入实现。

最后,让我们来看看Cats库的最后一部分,也就是data包。

数据

现在,让我们讨论一下data包,这是你在使用Cats进行日常函数式编程时经常会用到的另一个包:

之前,我们讨论了拥有像cat这样的库的主要效用是抽象出函数式编程的常见类型类。我们还看到,不仅类型类被抽象化,而且还有各种支持性内容,以便在实践中使用它们时效率更高。这些支持性内容包括语法注入机制和常见效果类型的默认实现。

猫提供的支持性基础设施的最后一部分是一组常见的效果类型。这些类型封装在data包下。在这个包下,你会遇到各种数据类型,你可以用它们以纯函数式的方式表达你的副作用。

例如,有如ReaderWriter等其他数据类型。效果类型通常彼此不相关,你可以真正独立地使用每一个。

基础设施协同

在本节中,我们了解了猫是如何定义其类型类以及如何在函数式编程中使用它们。关于Cats库需要理解的主要点是其为你这个函数式程序员提供的支持性基础设施,以及如何在实践中具体使用它。

有关的支持性基础设施提供了一组类型类,它们对常见数据类型的实现,以及将它们的方法注入你的效果类型中的机制。此外,cats 还提供了一组常见的效果类型。

该库非常模块化,你可以独立于库的其他部分使用它的各个部分。因此,对于初学者程序员来说,这是一个很好的策略,他们可以简单地从一两个基本类型类开始,并使用库将它们纳入范围。随着你作为函数式程序员逐渐进步,你将开始挑选并熟悉越来越多的类型类和库的各个部分。

在本节中,我们熟悉了Cats库的一般结构。在本章的其余部分,我们将熟悉某些常见类型类。我们将了解如何在实践中使用它们。我们还将查看类型类是如何实现的某些机制。

类型类

到目前为止,我们已经对Cats库及其结构进行了鸟瞰。在本节中,我们将查看Cats库中一些在现实项目中经常使用的单个类型类。对于每一个这样的类型类,我们将探讨其存在的动机。我们将详细讨论它们的方法和行为。我们还将查看类型类的使用示例。最后,我们将查看为各种效果类型实现类型类的方法,并查看该类是如何为流行类型实现的,以便你有一个关于类型类实现可能外观的印象。

模态

让我们看看如何在 Monad 类型类的一个例子中使用来自 Cats 库的类型类,我们已经熟悉这个类型类。

在前面的章节中,为了使用 Monad 类型类,我们将其定义为临时性的。然而,Cats 库提供了我们需要的所有抽象,这样我们就不需要自己定义这个类型类及其语法。

那么,如何在 第七章,“类型类概念”的日志记录示例中使用 Monad 类型类呢?如你所回忆,在那个章节中,我们查看了一个日志能力的例子,并讨论了它是 Monad 可以处理的顺序组合的一个好例子。所以,让我们看看如何使用 cats 来实现这一点:

import cats.Monad, cats.syntax.monad._

首先,我们不再需要自己定义 Monad 特质以及我们通常为其定义语法的伴随对象。我们只需要从 cats 中执行一些导入。在前面的代码中,你可以看到首先我们导入了 cats 包中的 Monad 类型,然后我们导入了 Monad 的语法。我们已经在本章的前一节讨论了这一机制的工作原理。

之后,我们可以定义来自 第七章,“类型类概念”的方法,用于添加两个整数并将它们写入登录过程,如下所示:

def add[F[_]](a: Double, b: Double)(implicit M: Monad[F], L: Logging[F]): F[Double] =
 for {
   _ <- L.log(s"Adding $a to $b")
   res = a + b
   _ <- L.log(s"The result of the operation is $res")
 } yield res
 println(addSimpleWriter) // SimpleWriter(List(Adding 1.0 to
 2.0, The result of the operation is 3.0),3.0)

注意,定义看起来与 第七章,“类型类概念”中的定义完全相同。然而,语义上略有不同。Monad 类型来自 cats 包,并不是临时定义的。

此外,为了使用我们在 第七章,“类型类概念”中定义的 SimpleWriter 效果类型,我们仍然需要为这个数据类型添加一个 Monad 的实现。我们可以这样做:

implicit val monad: Monad[SimpleWriter] = new Monad[SimpleWriter] {
  override def mapA, B(f: A => B):
   SimpleWriter[B] = fa.copy(value = f(fa.value))
  override def flatMapA, B(f: A =>  
   SimpleWriter[B]): SimpleWriter[B] = {
     val res = f(fa.value)
     SimpleWriter(fa.log ++ res.log, res.value)
  }
  override def pureA: SimpleWriter[A] = SimpleWriter(Nil, a)

  override def tailRecMA, B(f: A =>
   SimpleWriter[Either[A,B]]): SimpleWriter[B] = ???
}

实际上,cats 已经提供了一个类似于我们 SimpleWriter 效果类型的类型,这个类型正是为了日志记录而设计的。现在让我们讨论一下如何用 cats 提供的功能来替代 SimpleWriter

Writer 效果类型

Writer 效果类型比 SimpleWriter 实现提供了更多的通用类型类。然而,如果我们使用它,我们就不需要定义 SimplerWriter 类型,以及为其定义类型类的实现。由于 cats 为其数据类型提供了类型类的实现,我们不需要担心自己来做这件事。

如你所回忆,我们的 SimpleWriter 对象本质上是一个对。对的第一元素是一个字符串列表,它代表了计算过程中记录的所有日志消息。对的另一个元素是计算过程中计算出的值。

catsWriter对象的实现基本上与我们更简单的 Writer 实现非常相似,除了对的数据对中的第一个元素不是一个字符串列表,而是一个任意类型。这有一定的实用性,因为现在你可以用它来记录除了字符串列表之外的数据结构。

我们所使用的SimpleWriter可以通过显式指定存储日志消息的类型,用cats Writer来表示:

图片

在前面的屏幕截图中,你可以看到data包中 Writer 单例对象的文档。这个对象可以用来将日志消息写入 Writer 效果类型。这里最重要的两个方法是tellvaluetell方法将消息写入日志,而value方法将任意值提升到 Writer 数据结构中,并带有空日志消息。Writer 数据类型有一个Monad实例,它定义了如何顺序组合两个 Writer。在顺序组合过程中,两个效果类型的日志被合并成一个。

此外,如果你查看catsdata包,你会发现没有名为 Writer 的特质或类。Writer 数据类型的真实名称是WriterT。关于cats有一件事需要记住,那就是它旨在提供高度通用和抽象的工具,这些工具可以在各种不同的场景中使用。因此,在这种情况下,使用了 Monad Transformers 技术,这就是为什么它有WriterT这个奇怪名称的原因。目前,你不需要担心 Monad Transformers,你可以使用在cats中定义的 Writer 类型,它是基于WriterT的。Writer 单例提供了一套方便的方法来处理它。

由于 Writer 数据类型是cats的标准数据类型,我们可以用来自cats的 Writer 替换我们的自定义SimpleWriter,并且我们还可以从我们的应用程序中完全删除 Logging 类型类。我们这样做的原因是标准化Cats库。这种标准化使代码更加紧凑,消除了冗余,并提高了可靠性。我们这样做是因为我们使用的是标准工具,而不是临时重新发明它们。

在代码片段中,你可以看到一个来自第七章,“类型类概念”的加法方法的实现,使用了我们之前讨论的cats的能力。

def add(a: Double, b: Double): Writer[List[String], Double] =
  for {
    _ <- Writer.tell(List(s"Adding $a to $b"))
    res = a + b
    _ <- Writer.tell(List(s"The result of the operation is $res"))
  } yield res
  println(add(1, 2)) // WriterT((List(Adding 1.0 to 2.0,
   The result of the operation is 3.0),3.0))

tailRecM方法

在本节之前,我们简要提到了tailRecM方法。它在某些情况下非常有用,因为它允许你在效果类型的上下文中定义循环。在本小节中,让我们更详细地看看它的签名以及这个方法是如何工作的:

图片

让我们看看这个方法的参数。首先,让我们看看这个方法的第二个参数,即f函数。该函数接受类型A的原始值,该函数的结果是一个效果类型,即F[Either[A, B]]

让我们思考一下我们可以如何使用这个计算来使其成为一个循环。假设我们从某个值A开始。假设我们在该值上运行计算f。那么,我们的结果是类型F[Either[A, B]]。这个类型的确切可能性有两种——要么是F[Left[A]],要么是F[Right[B]]。如果是F[Left[A]],那么我们可以在F[Left[A]]上使用flatMap;之后,我们可以从Left中提取A,然后我们可以在那个A上再次运行计算f。如果是F[Right[B]],就没有其他事情可做,只能返回计算的结果,即F[B]

因此,传递给tailRecM的函数将在参数A上运行,同时产生类型为F[Left[A]]的结果。一旦它产生F[Right[B]],这个结果就被视为最终结果,并从循环中返回。

基本上,如果我们有能力在效果类型F上执行flatMap,那么我们也能基于flatMap定义一个循环。然而,为什么它是一个抽象方法?如果创建循环只需要执行flatMap的能力,那么为什么我们不能将其定义为基于flatMap的具体方法?

好吧,我们可能想要尝试这样做。考虑我们的SimpleWriter示例的 Monad 实现,如下所示:

override def tailRecMA, B(f: A => SimpleWriter[Either[A,B]]):
  SimpleWriter[B] = f(a).flatMap {
   case Left (a1) => tailRecM(a1)(f)
   case Right(res) => pure(res)
}

在前面的示例中,我们有一个基于flatMaptailRecM。如果我们尝试一个无限循环会发生什么?

Monad[SimpleWriter].tailRecMInt, Unit { a => Monad[SimpleWriter].pure(Left(a))}

前面的代码会导致StackOverflowError

[error] java.lang.StackOverflowError
...
[error] at jvm.TailRecM$$anon$1.tailRecM(TailRecM.scala:18)
[error] at jvm.TailRecM$$anon$1.$anonfun$tailRecM$1(TailRecM.scala:19)
[error] at jvm.SimpleWriter.flatMap(AdditionMonadic.scala:19)
[error] at jvm.TailRecM$$anon$1.tailRecM(TailRecM.scala:18)
[error] at jvm.TailRecM$$anon$1.$anonfun$tailRecM$1(TailRecM.scala:19)
[error] at jvm.SimpleWriter.flatMap(AdditionMonadic.scala:19)
[error] at jvm.TailRecM$$anon$1.tailRecM(TailRecM.scala:18)
[error] at jvm.TailRecM$$anon$1.$anonfun$tailRecM$1(TailRecM.scala:19)
[error] at jvm.SimpleWriter.flatMap(AdditionMonadic.scala:19)
[error] at jvm.TailRecM$$anon$1.tailRecM(TailRecM.scala:18)
[error] at jvm.TailRecM$$anon$1.$anonfun$tailRecM$1(TailRecM.scala:19)
[error] at jvm.SimpleWriter.flatMap(AdditionMonadic.scala:19)
...

这种错误最频繁地发生在递归调用的情况下,我们耗尽了由 JVM 为我们分配的内存栈帧。

每当你执行一个方法调用时,JVM 都会为该调用分配一个特定的内存片段,用于所有变量和参数。这个内存片段被称为栈帧。因此,如果你递归地调用一个方法,你的栈帧数量将与递归的深度成比例增长。可用于栈帧的内存是在 JVM 级别设置的,通常高达 1 MB,并且如果递归足够深,很容易达到其限制。

然而,在某些情况下,你不需要在递归的情况下创建额外的栈帧。这里,我们谈论的是尾递归。基本上,如果你不再需要递归的先前栈帧,你可以将其丢弃。这种情况发生在方法拥有栈帧且没有其他事情可做时,并且该方法的输出完全依赖于递归后续调用的结果。

例如,考虑以下阶乘计算的示例:

def factorial(n: Int): Int =
  if (n <= 0) 1
  else n * factorial(n - 1)
println(factorial(5)) // 120

在前面的代码中,factorial 函数是递归定义的。因此,为了计算一个数字 n 的阶乘,你首先需要计算 n-1 的阶乘,然后将它乘以 n。当我们递归地调用 factorial 方法时,我们可以问一个问题:在递归调用完成后,在这个方法中我们是否还需要做其他事情,或者它的结果是否只依赖于我们递归调用的方法。更确切地说,我们是在讨论在 factorial 函数内部对阶乘调用之后是否还需要做其他事情。答案是,我们需要执行一个额外的步骤来完成计算。这个步骤是将阶乘调用的结果乘以数字 n。所以,直到这个步骤完成,我们才不能丢弃当前调用的栈帧。然而,考虑以下定义的 factorial 方法:

def factorialTailrec(n: Int, accumulator: Int = 1): Int =
  if (n <= 0) accumulator
  else factorialTailrec(n - 1, n * accumulator)
println(factorialTailrec(5)) // 120

在前面的例子中,当我们调用 factorial 方法时,我们可以问自己以下问题——在调用 factorial 方法之后,我们是否还需要在方法中做其他事情来完成它的计算?或者这个方法的结果是否完全依赖于我们在这里调用的 factorial 方法的结果?答案是,我们在这里不需要做其他任何事情。

Scala 编译器可以识别这种情况,并在可以重用先前递归调用栈帧的地方进行优化。这种情况被称为尾递归。一般来说,这样的调用比普通递归更高效,因为你不能因为它们而得到栈溢出,而且一般来说它们的速度与普通 while 循环的速度相当。

事实上,你可以在 Scala 中明确地对一个方法提出要求,使其成为尾递归,如下所示:

@annotation.tailrec
def factorialTailrec(n: Int, accumulator: Int = 1): Int =
 if (n <= 0) accumulator
 else factorialTailrec(n - 1, n * accumulator)

在前面的例子中,第一个方法将无法编译,因为它虽然被标注了 @tailrec,但不是尾递归。Scala 编译器将对所有标注了 @tailrec 的方法进行检查,以确定它们是否是尾递归。

让我们回顾一下 tailRecM 的例子。从名字上,你现在可以猜到这个方法应该是尾递归的。现在,让我们回忆一下 SimpleWriter 的这个方法的原始实现。它的执行导致了栈溢出异常。这是因为在这里,递归被分割成几个方法。所以如果你查看堆栈跟踪输出,你可以看到输出是周期性的。在这个输出中有两个方法在重复——flatMaptailRecM。Scala 编译器无法证明在这种情况下该方法是否是尾递归。原则上,你可以想出一个方法来优化递归,即使在这种情况下,但 Scala 编译器无法做到这一点。

此外,让我们看看如果你尝试使用 @tailrec 注解声明 tailRecM 方法会发生什么:

@annotation.tailrec
override def tailRecMA, B(f: A => SimpleWriter[Either[A,B]]): SimpleWriter[B] =
 f(a).flatMap {
  case Left (a1) => tailRecM(a1)(f)
  case Right(res) => pure(res)
 }

你会发现代码无法编译,因为该方法没有被识别为尾递归:

[error] /Users/anatolii/Projects/1mastering-funprog/Chapter8/jvm/src/main/scala/jvm/TailRecM.scala:19:12: could not optimize @tailrec annotated method tailRecM: it contains a recursive call not in tail position
[error] f(a).flatMap {
[error] ^

将此方法作为抽象方法的目的正是您必须实现它,而不是使用 flatMap(这不可避免地会导致周期性递归),而是使用一个单一尾递归方法。例如,在 SimpleWriter 的上下文中,我们可以提出如下这样的实现:

@annotation.tailrec
 override def tailRecMA, B(f: A => SimpleWriter[Either[A,B]]):
 SimpleWriter[B] = {
   val next = f(a)
   next.value match {
     case Left (a1) => tailRecM(a1)(f)
     case Right(res) => pure(res)
   }
 }

在前面的代码片段中,如您所见,我们以尾递归的方式实现了 tailRecM。请注意,我们仍在使用与 flatMap 函数中使用的类似的技术。然而,这些技术被封装在一个单一的方法中,该方法是以尾递归的方式实现的。

应该指出的一点是,并非每个 Monad 实现都有 tailRecM 的实现。您经常会遇到 tailRecM 只会抛出 NotImplementedError 的场景:

override def tailRecMA, B(f: A => SimpleWriter[Either[A,B]]): SimpleWriter[B] = ???

Scala 中使用 ??? 语法方便地抛出这样的错误。

到目前为止,我们已经讨论了在副作用计算组合的上下文中 flatMap。现在,让我们看看一个副作用计算与非副作用计算组合的例子。让我们看看 Functor。

Functor

在函数式编程中经常遇到的其他类型类是 Functor。Functor 的本质是关于 map 函数。如您从前面的章节中回忆起来,map 函数与 flatMap 函数非常相似;然而,它接受一个非副作用计算作为其参数。它用于在转换本身不是副作用的情况下,在效果类型的上下文中转换一个值。

如果您想在不需要从其效果类型中提取结果的情况下对副作用计算的结果进行操作,您可能会想使用 Functor。

如您所知,在处理 Monad 的 flatMap 的情况下,我们使用了序列组合的直觉。这种直觉对于 Functor 可能并不是最好的。在 map 的情况下,我们可以使用另一种关于函数的直觉,即在效果类型下改变一个值。在这种情况下抽象的操作是从效果类型中提取值。map 方法只询问您想要如何处理副作用计算的结果,而不要求您提供有关如何从效果类型中提取此结果的确切信息。

正如我们在前几节中详细讨论了 Monad 类型类的情况一样,我们已经在之前详细讨论了 map 方法,因此我们不会在这个类型类上停留太久。我们只是想看看您如何可能想要使用 Cats 库来使用它。

让我们看看 Cats 库为 Functor 定义的类:

在前面的屏幕截图中,您可以查看 Functor 类型类的文档和定义。现在,让我们看看 SimpleWriter 类型可能的实现。首先,让我们回顾一下 SimpleWriter 数据类型的定义:

case class SimpleWriterA

现在我们需要提供Cats库中 Functor 类型类的实现。我们将从Cats库中做一些导入:

import cats._, cats.implicits._

在前面的代码中,我们正在导入cats包中的 Functor 类型(通过导入cats._)。之后,我们必须导入这个类型类的语法(通过导入cats.implicits._导入所有类型类的语法和实例)。所以,每当类型类的实现处于作用域内时,我们也将有它的语法注入。

因此,让我们为SimpleWriter类型类提供实现:

implicit val simpleWriterFunctor: Functor[SimpleWriter] =
 new Functor[SimpleWriter] {
   override def mapA, B(f: A => B):
    SimpleWriter[B] = fa.copy(value = f(fa.value))
 }

在前面的代码中,你可以看到一个简单的SimpleWriter类型类的 Functor 实现。正如你所见,我们只需要实现这个类型类的map方法。

之后,一旦我们创建了一些非常简单的效果类型实例,我们就能调用它的map方法:

val x = SimpleWriter(Nil, 3)
println(x.map(_ * 2)) // SimpleWriter(List(),6)

因此,map方法被注入到我们的效果类型中。

你可能会有一个疑问,这个做法的意义是什么?如果 Functor 和 Monad 都定义了map方法,为什么还要有 Functor 呢?为什么不在需要map方法的每个类型类中都有 Monad 实现,而不去关心 Functor 类呢?答案是,并不是每个效果类型都有flatMap方法的实现。所以,一个效果类型可能有一个map的实现,但可能无法定义其上的flatMap。因此,Cats库提供了一个精细的类型类层次结构,这样你可以根据自己的需求来使用它。

到目前为止,我们已经讨论了用于顺序组合的类型类。现在,让我们看看并行组合的情况以及 Applicative 类型类是如何处理它的。

Applicative

知道如何按顺序组合计算是一种基本技能,它使得过程式编程得以实现。这是我们默认依赖的东西,当我们使用命令式编程语言时。当我们按顺序写两个语句时,我们隐含地意味着这两个语句应该一个接一个地执行。

然而,顺序编程无法描述所有编程情况,特别是如果你在一个应该并行运行的应用程序上下文中工作。可能会有很多你想要并行组合计算的情况。这正是 Applicative 类型类发挥作用的地方。

动机

假设有两个独立的计算。假设我们有两个计算数学表达式,然后我们需要组合它们的结果。也假设它们的计算是在Either效果类型下进行的。所以,主要思想是这两个计算中的任何一个都可能失败,如果其中一个失败了,解释的结果将留下一个错误,如果成功了,结果是某个结果的Right

type Fx[A] = Either[List[String], A]
def combineComputations[F[_]: Monad](f1: F[Double], f2: F[Double]): F[Double] =
 for {
   r1 <- f1
   r2 <- f2
 } yield r1 + r2
val result = combineComputationsFx, 
 Monad[Fx].pure(2.0))
 println(result) // Right(3.0)

在前面的代码中,你可以看到如何使用Monad类型类按顺序组合两个这样的计算。在这里,我们使用列表推导式来计算第一个计算的结果,然后是第二个计算。

让我们看看一个这些计算出错的情况:

val resultFirstFailed = combineComputationsFx), Monad[Fx].pure(2.0))
 println(resultFirstFailed) // Left(List(Division by zero))
val resultSecondFailed = combineComputationsFx, Left(List("Null pointer encountered")))
 println(resultSecondFailed) // Left(List(Null pointer encountered))

你可以看到两种情况和两种输出。第一种是第一个计算出错的情况,第二种是第二个计算出错的情况。所以,基本上,合并计算的结果将是Left,如果两个计算中的任何一个都失败了。

如果这两个计算都失败了会怎样?

val resultBothFailed = combineComputations(
 Left(List("Division by zero")), Left(List("Null pointer encountered")))
 println(resultBothFailed) // Left(List(Division by zero))

你可以看到这两种计算都失败的情况的输出。第一个计算只得到一个错误输出。这是因为它们是按顺序组合的,序列在第一个错误时终止。对于EitherMonad行为是在遇到Left时终止顺序组合。

这种情况可能并不总是期望的,特别是在由大量可能失败的各个模块组成的大型应用程序中。在这种情况下,出于调试目的,你希望尽可能多地收集已发生的错误信息。如果你一次只收集一个错误,并且你有数十个独立的计算失败,你必须逐个调试它们,因为你将无法访问已发生的整个错误集。这是因为只有遇到的第一个错误将被报告,尽管这些计算是相互独立的。

这种情况发生的原因是因为我们组合计算的方式的本质。它们是按顺序组合的。顺序组合的本质是按顺序运行计算,即使它们不依赖于彼此的结果。由于这些计算是按顺序运行的,如果在链中的某个链接发生错误,中断整个序列是自然而然的。

解决前一个场景的方法是将独立的计算并行执行而不是按顺序执行。因此,它们都应该独立于彼此运行,并在完成后以某种方式合并它们的结果。

Applicative 类型类

我们希望为前一个场景定义一个新的原始方法。我们可以称这个方法为zip

type Fx[A] = Either[List[String], A]
def zipA, B: Fx[(A, B)] = (f1, f2) match {
  case (Right(r1), Right(r2)) => Right((r1, r2))
  case (Left(e1), Left(e2)) => Left(e1 ++ e2)
  case (Left(e), _) => Left(e)
  case (_, Left(e)) => Left(e)
}

该方法将接受两个计算作为其参数,并将输出两个提供的输入的合并结果,作为一个元组,其类型是它们共同的效果类型。

还要注意,我们正在处理Left是一个字符串列表的特定情况。这是为了将多个失败的计算的多个错误字符串合并成一个错误报告。

它的工作方式是,如果两个编译都成功,它们的组合结果将是一个对。否则,如果这些计算中的任何一个失败,它们的错误将收集在一个组合列表中。

给定新的方法zip,我们可以将前面的例子表达如下:

def combineComputations(f1: Fx[Double], f2: Fx[Double]): Fx[Double] =
 zip(f1, f2).map { case (r1, r2) => r1 + r2 }

val result = combineComputations(Monad[Fx].pure(1.0),
  Monad[Fx].pure(2.0))
  println(result) // Right(3.0)

val resultFirstFailed = combineComputations(
  Left(List("Division by zero")), Monad[Fx].pure(2.0))
  println(resultFirstFailed) // Left(List(Division by zero))

val resultSecondFailed = combineComputations(
  Monad[Fx].pure(1.0), Left(List("Null pointer encountered")))
  println(resultSecondFailed) // Left(List(Null pointer encountered))

val resultBothFailed = combineComputations(
  Left(List("Division by zero")), Left(List("Null pointer encountered")))
  println(resultBothFailed) // Left(List(Division by zero, Null pointer 
  encountered))

注意,这里我们使用zip来创建两个独立计算的组合版本,并处理我们使用map方法对这个计算结果进行操作的事实。

实际上,我们可以用更通用的ap(即apply)函数来表达zip函数。具体如下:

def apA, B(fa: Fx[A]): Fx[B] = (ff, fa) match {
  case (Right(f), Right(a)) => Right(f(a))
  case (Left(e1), Left(e2)) => Left(e1 ++ e2)
  case (Left(e), _) => Left(e)
  case (_, Left(e)) => Left(e)
}

我们可以这样表达zip函数,即通过ap函数:

def zipA, B: Fx[(A, B)] =
 apB, (A, B)](Right { (a: A) => (b: B) => (a, b) })(f1))(f2)

ap函数的实际意义是表达两个独立计算同时运行的一种更通用的方式。技巧在于第一个计算结果是一个函数F[A => B],第二个计算是一个原始计算F[A]。关于这个函数以及为什么它在本质上与zip函数不同,以下是一些说明。干预是组合加执行。它组合了一些提升到效果类型F的值,以及一个在值上工作的计算A => B,这个计算也被提升到F的上下文中。由于在组合时我们已处理效果类型,因此我们已经完成了独立计算。与flatMap的情况相比,其中一个参数是一个函数A => F[B],它输出一个效果类型。所以,在flatMap的情况下,其中一个参数是一个将要被执行的函数。这是flatMap的责任,它将执行它并获得结果F[B]。这不能应用于ap,因为ap已经可以访问效果类型计算的结果——F[A => B]F[A]。因此,存在计算的独立性。由于计算的效果类型中的一个值是一个函数A => B,它不仅是在组合中通过zip成对,而且也是一个类似于映射的执行。

实际上,ap函数来自Apply类型类,它是Applicative的祖先:

图片

然而,你将更频繁地遇到扩展Apply类型类的Applicative版本。这些类型类之间的唯一区别是Applicative还有一个pure函数,该函数用于将原始值a提升到相同的效果类型F

Applicative还有许多以ap为依据的有用具体方法。cats还为你提供了一些语法糖支持,以便你可以以直观的方式在你的项目中使用Applicative。例如,你可以同时对两个值执行map操作,如下所示:

def combineComputations(f1: Fx[Double], f2: Fx[Double]): Fx[Double] =
 (f1, f2).mapN { case (r1, r2) => r1 + r2 }

我们可以使用cats在元组中注入的语法糖,以便轻松处理这种并行计算的情况。因此,你只需将两个效果类型组合成一个元组,并在作用域中使用Applicative类型类来映射它们。

类型类的实现

让我们看看类型类如何为数据类型实现。例如,让我们看看Either

implicit val applicative: Applicative[Fx] = new Applicative[Fx] {
  override def apA, B(fa: Fx[A]): Fx[B] = (ff, fa)
  match {
    case (Right(f), Right(a)) => Right(f(a))
    case (Left(e1), Left(e2)) => Left(e1 ++ e2)
    case (Left(e), _) => Left(e)
    case (_, Left(e)) => Left(e)
  }
  override def pureA: Fx[A] = Right(a)
}

你可以看到如何为Either实现类型类,其中LeftList[String]。所以,正如你所看到的,如果两个计算都成功了,即它们是Right,我们简单地将它们合并。然而,如果至少有一个是Left,我们将两个计算的Left部分合并成一个Left[List[String]]。这是专门针对可能产生错误并且你希望在一个单一的数据结构下合并的几个独立计算的情况。

你可能已经注意到我们正在使用Either的一个非常具体的案例——即Left总是List[String]的情况。我们之所以这样做,是因为我们需要一种方法将两个计算中的Left部分合并成一个,而我们无法合并泛型类型。前一个例子可以进一步推广到Left类型的任意版本,即Either[L, A]。这可以通过Monoid类型类来实现,我们将在下一节中学习它。所以,让我们来看看这个类型类,看看它在哪里可以派上用场。

Monoid

Monoid是你在实践中经常遇到的一个流行的类型类。基本上,它定义了如何组合两种数据类型。

作为 Monoid 的一个例子,让我们看看为Either数据类型实现 Applicative 类型类的实现。在前一节中,我们被迫使用一个特定的Either版本,即Left被设置为字符串列表的那个版本。这正是因为我们知道如何合并两个字符串列表,但我们不知道如何合并任何两种泛型类型。

如果我们定义前面提到的 Applicative 的签名如下,那么我们将无法提供一个合理的函数实现,因为我们无法合并两个泛型类型:

implicit def applicative[L]: Applicative[Either[L, ?]]

如果你尝试编写这个函数的实现,它可能看起来像以下这样:

override def apA, B(fa: Either[L, A]): 
Either[L, B] = (ff, fa) match {
  case (Right(f), Right(a)) => Right(f(a))
  case (Left(e1), Left(e2)) => Left(e1 |+| e2)
  case (Left(e), _) => Left(e)
  case (_, Left(e)) => Left(e)
}

我们使用一个特殊的操作符|+|来描述我们一无所知的两种数据类型的组合操作。然而,由于我们对我们要组合的数据类型一无所知,代码将无法编译。我们不能简单地组合任意两种数据类型,因为编译器不知道如何做到这一点。

如果我们让 Applicative 类型类隐式地依赖于另一个知道如何隐式合并这两种数据类型的类型类,这种状况就可以改变。那就是 Monoid:

图片

Monoid类型类扩展了SemigroupSemigroup是一种数学结构。它是一个定义为以下类型的类型类:

图片

基本上,半群是在抽象代数和集合论中定义的。给定一个集合,一个半群是这个集合上的一个结构,它定义了一个运算符,可以将集合中的任意两个元素组合起来产生集合中的另一个元素。因此,对于集合中的任意两个元素,你可以使用这个运算符将它们组合起来,产生另一个属于这个集合的元素。在编程语言中,Semigroup是一个可以定义的类型类,如前一个屏幕截图所示。

在前一个屏幕截图中,你可以看到Semigroup定义了一个名为combined的单个方法。它接受两个类型为A的参数,并返回另一个类型为A的值。

理解Semigroup的一个直观方法是看看整数上的加法运算:

implicit val semigroupInt: Semigroup[Int] = new Semigroup[Int] {
  override def combine(a: Int, b: Int) = a + b
}

在整数加法运算中,+是一个运算符,可以用来将任意两个整数组合起来得到另一个整数。因此,加法运算在所有可能的整数集合上形成一个Semigroupcats中的Semigroup类型类将这个想法推广到任何任意类型A

回顾我们的 Monoid 示例,我们可以看到它扩展了Semigroup并为其添加了另一个名为empty的方法。Monoid 必须遵守某些定律。其中一条定律是empty元素必须是combined运算的单位元。这意味着以下等式必须成立:

combine(a, empty) == combine(empty, a) == a

所以基本上,如果你尝试将空单位元与集合A中的任何其他元素组合,你将得到相同的元素作为结果。

理解这个点的直观方法是看看整数加法运算:

implicit def monoidInt: Monoid[Int] = new Monoid[Int] {
  override def combine(a: Int, b: Int) = a + b
  override def empty = 0
}

你可以看到整数 Monoid 的实现。如果我们把运算定义为加法,那么0是一个空元素。确实,如果你将0加到任何其他整数上,你将得到这个整数作为结果。0是加法运算的单位元。

这条关于加法运算的评论确实非常重要,需要注意。例如,0不是乘法运算的单位元。实际上,如果你将0与任何其他元素相乘,你将得到0而不是那个其他元素。说到乘法,我们可以定义一个整数乘法运算和单位元为1的 Monoid,如下所示:

implicit def monoidIntMult: Monoid[Int] = new Monoid[Int] {
  override def combine(a: Int, b: Int) = a * b
  override def empty = 1
}

实际上,cats为 Monoid 定义了一些很好的语法糖。给定前面定义的整数乘法运算的 Monoid,我们可以如下使用它:

println(2 |+| 3) // 6

你可以看到如何在 Scala 中使用中缀运算符|+|来组合两个元素。前面的代码等同于以下代码:

println(2 combine 3) // 6

这是在cats中定义这类符号运算符的常见做法,以定义经常遇到的运算符。让我们看看如何使用Monoid作为依赖项实现EitherApplicative

另一个用于函数式编程的库 ScalaZ 在运算符使用上比cats更为激进,因此对于初学者来说可能更难理解。在这一点上cats更为友好。符号运算符之所以不那么友好,是因为它们的含义从名称上并不立即明显。例如,前面的运算符|+|对于第一次看到它的人来说可能相当模糊。然而,combine方法给你一个非常清晰的概念,了解它是做什么的。

Either 的实现

现在我们已经熟悉了 Monoid,并查看它在简单类型(如整数)的上下文中的应用,让我们看看之前的例子,即具有泛型类型LeftEither例子——Either[L, A]。我们如何为泛型Left类型定义 Applicative 实例?之前我们看到,泛型Left类型的ap函数的主体与列表的该函数的主体并没有太大区别。唯一的问题是,我们不知道如何组合两种任意类型。

这种组合听起来正是 Monoid 的任务。因此,如果我们将 Monoid 的隐式依赖引入作用域,我们可以为Either类型定义ap和 Applicative 类型类如下:

implicit def applicative[L: Monoid]: Applicative[Either[L, ?]] =
 new Applicative[Either[L, ?]] {
   override def apA, B(fa: Either[L, A]):
   Either[L, B] = (ff, fa) match {
      case (Right(f), Right(a)) => Right(f(a))
      case (Left(e1), Left(e2)) => Left(e1 |+| e2)
      case (Left(e), _) => Left(e)
      case (_, Left(e)) => Left(e)
   }
   override def pureA: Either[L, A] = Right(a)
}

你可以看到一个隐式实现的 Applicative 类型类,它还依赖于Either类型的Left类型的隐式Monoid类型类的实现。所以,发生的情况是 Applicative 类型类将被隐式解析,但仅当可以隐式解析Left类型值的 Monoid 时。如果没有在作用域内为Left提供 Monoid 的隐式实现,我们就无法生成 Applicative。这很有道理,因为 Applicative 的主体依赖于 Monoid 提供的功能来定义其自身的功能。

ap函数的主体中需要注意的唯一一点是,它现在使用|+|运算符来组合两个计算结果都为错误的情况下的左侧元素。

关于单例(Monoid)的一个需要注意的奇特之处是,它被定义为适用于普通类型,而不是有效类型。因此,如果你再次查看单例的签名,它属于Monoid[A]类型,而不是Monoid[F[A]]类型。到目前为止,我们只遇到了作用于效果类型的类型类,即F[A]类型的类型。

为什么存在作用于原始类型而不是效果类型的类型类呢?为了回答这个问题,让我们回顾一下我们熟悉的普通类型类存在的动机。它们存在的主要动机是某些效果类型的操作不方便完成。我们需要抽象某些效果类型的操作。我们需要一个抽象来定义作用于效果类型的工具。

效果类型通常是数据结构,并且很难临时处理它们。您通常无法方便地使用您语言内置的能力来处理它们。因此,如果我们没有为这些数据类型定义工具集,我们在处理这些数据类型时就会遇到困难。因此,类型类的需求主要表现在效果类型上。

普通类型如 A 通常不像数据结构那样难以处理。因此,对于这些数据类型的工具和抽象的需求不如效果类型明显。然而,正如我们之前所看到的,在某些情况下,类型类对于原始类型也是有用的。我们需要为原始类型定义一个单独的类型类 Monoid 的原因在于,我们需要泛化类型必须是可组合的这一特性。

还要注意,我们几乎只能使用类型类以外的任何技术来做这件事。面向对象编程的普通方法来确保数据类型暴露了某种功能是接口。接口必须在实现它们的类的定义时间声明。因此,例如,没有单一的接口可以指定列表、整数和字符串可以使用相同的方法与其他类型组合。

指定此类特定功能暴露的唯一方法是将接口定义为临时的。但是,普通的面向对象编程并不提供将接口注入已实现类的能力。这一点并不适用于类型类。使用类型类,每当您想要捕捉一个类型暴露了某种功能时,您都可以定义一个临时的类型类。您还可以通过为这个特定类定义和实现这个类型类来精确地定义一个类如何展示这种功能。请注意具体操作的时间点。这种操作可以在程序的任何部分进行。因此,每当您需要明确指出一个类型展示了某种功能并且与其他类型具有这种共同功能时,您都可以通过定义一个捕获这种功能的类型类来实现这一点。

这种类型的可扩展性为您提供了普通面向对象编程技术(例如 Java 中的技术)难以达到的更高灵活性。事实上,可以争论说,程序员完全可以放弃接口的面向对象风格,而只使用类型类。在 Haskell 所基于的编程风格中,数据和行为的分离是严格的。

MonoidK

之前,我们看到了适用于所有类型的 Monoid 版本。也存在一种 Monoid 版本,它操作于效果类型,即 F[A] 类型的类型。这个类型类被称为 MonoidK

图片

所以,正如你所看到的,这个方法是为效果类型定义的,并且不是在类型类的层面上,而是在方法层面上参数化类型 A。这意味着你可以为某些效果类型 F[_] 定义单个类型类,并且你可以用它来处理 F[A] 上下文中的任意 A

这个例子在组合列表时可能很有用。虽然它实际上不是一个效果类型,因为 List 是一个不封装任何副作用的数据结构,但它仍然是形式为 F[A] 的类型。我们可以想象如下实现 combinedK

implicit val listMonoid: MonoidK[List] = new MonoidK[List] {
  override def combineKA: List[A] =
   a1 ++ a2
  override def empty[A] = Nil
}

因此,在前面的代码中,我们能够以独立于类型 A 的方式实现这个方法,因为两个列表组合的行为与包含在其中的元素类型无关。这种组合只是将一个列表的元素与另一个列表的元素连接起来,形成一个组合列表。

还要注意这里的 algebra 方法。这个方法可以用来从 MonoidK 实例获得 Monoid 实例。这在需要 Monoid 实例但只有 MonoidK 的情况下可能很有用。

Traverse

之前,我们学习了 Applicative 类型类。我们争论说,Applicative 类型类的主要效用是它允许我们并行组合两个独立的计算。我们不再受执行顺序组合的 flatMap 函数的约束,因此如果一个计算失败,则不会执行其他任何计算。在 Applicative 情景中,尽管一些计算可能会失败,但所有计算都会被执行。

然而,Applicative 只能组合两个独立的计算。也有方法将多达 22 个计算组合成元组。但是,如果我们需要组合任意数量的计算呢?对于多重性的通常推广是集合。元组只是集合的特殊情况。所以,如果有一个类型类可以将独立的计算组合成元组,那么也必须有一个类型类可以将独立的计算组合成集合。

为了说明这种情况,考虑我们在 Applicative 情况下正在处理的一个例子。如果我们有一个在并行运算符下计算出的任意数学表达式列表,并且有一个函数应该通过求和来组合它们,这样的函数可能是什么样子?

type Fx[A] = Either[List[String], A]
def combineComputations(f1: List[Fx[Double]]): Fx[Double] =
 (f1, f2).mapN { case (r1, r2) => r1 + r2 }

因此,前面的函数接受一个计算结果的列表,其任务是产生所有计算的组合结果。这个结果将是Either[List[String], List[Double]]类型,这意味着我们还需要聚合所有我们在尝试组合的计算中发生的所有错误。在 Applicative 的情况下,我们该如何处理呢?

我们需要做的是取列表的第一个元素,使用ap函数将其与列表的第二个元素结合,将结果相加以获得一个Either类型的结果,然后将这个结果与第三个元素结合,依此类推。

实际上,有一个类型类可以执行这个操作。认识一下Traverse类型类:

图片

Traverse类型类的关注点主要是traverse方法。让我们看看它的签名:

abstract def traverse[G[_], A, B](fa: F[A])(f: (A) ⇒ G[B])(implicit arg0: Applicative[G]): G[F[B]]

这个方法的签名非常抽象。所以,让我们给所有涉及的类型提供更多一些的上下文。在上面的签名中,考虑类型F是一个集合类型。考虑类型G是一个效果类型。

这意味着traverse函数接受一个集合作为其第一个参数——一个包含一些任意原始元素的集合A。第二个参数类似于我们在flatMap中看到的内容。它是一个对第一个参数中集合的元素进行操作的副作用计算。所以,想法是,你有一个包含一些元素的集合A,你可以对这些元素运行一个计算A。然而,这个计算是具有副作用的。这个计算的副作用被封装到效果类型G中。

如果你在这个集合的每个元素上运行这样的计算会发生什么?如果你使用这个操作映射集合F会发生什么?

你期望得到的结果类型是:F[G[B]]。所以,你将得到一个包含效果类型的集合,这些类型是你对原始集合的每个元素运行计算的结果。

现在,让我们回到我们需要组合的Either示例。我们会得到以下结果:

List[Either[List[String], A]]

然而,我们不是在寻找这个。我们想要获得一个在效果类型Either下的所有计算结果的列表。在 Applicative 的情况下,ap方法接受副作用计算的结果,并将它们组合在它们共同的效果类型下。所以,在ap和基于它的zip的情况下,我们有以下结果:

Either[List[String], (A, A)]

在我们的泛化情况下,元组的作用被List所取代。因此,我们的目标是以下内容:

Either[List[String], List[A]]

现在,让我们回到我们的traverse函数。让我们看看它的结果类型。这个函数的结果是G[F[B]]G是一个效果类型。F是一个集合类型。所以,所有计算的结果都组合成一个单一的集合,在效果类型G下。这正是我们在Either的情况下所追求的。

因此,这使得Traverse成为了一个更通用的 Applicative 情况,可以用于你事先不知道将要组合多少计算的情况。

在这里提醒大家注意。我们之前也讨论过,类型F是一个集合类型,而类型G是一个效果类型。你应该记住,这个约束并没有编码到类型类本身中。我们施加这个约束是为了能够对类型类有一个直观的理解。因此,你可能会有一些更高级的Traverse类型类的使用方法,这些方法超出了这个集合的范围。然而,在你的项目中,你将最频繁地在集合的上下文中使用它。

让我们借助Traverse来看看我们的示例可能是什么样子:

def combineComputationsFold(f1: List[Fx[Double]]): Fx[Double] =
 f1.traverse(identity).map { lst =>
 lst.foldLeft(0D) { (runningSum, next) => runningSum + next } }

val samples: List[Fx[Double]] =
  (1 to 5).toList.map { x => Right(x.toDouble) }

val samplesErr: List[Fx[Double]] =
  (1 to 5).toList.map {
    case x if x % 2 == 0 => Left(List(s"$x is not a multiple of 2"))
    case x => Right(x.toDouble)
  }

println(combineComputationsFold(samples)) // Right(15.0)
println(combineComputationsFold(samplesErr)) // Left(List(2 is not a 
 multiple of 2, 4 is not a multiple of 2))

如果我们使用Traverse类型类的combineAll方法,我们可以进一步增强这个示例:

def combineComputations(f1: List[Fx[Double]]): Fx[Double] =
 f1.traverse(identity).map(_.combineAll)

println(combineComputations(samples)) // Right(15.0)
println(combineComputations(samplesErr)) // Left(List(2 is not a  
 multiple of 2, 4 is not a multiple of 2))

以下是在定义的以下类型类上下文中引入的示例:

type Fx[A] = Either[List[String], A]
implicit val applicative: Applicative[Fx] = new Applicative[Fx] {
  override def apA, B(fa: Fx[A]): Fx[B] = (ff, fa)  
  match {
    case (Right(f), Right(a)) => Right(f(a))
    case (Left(e1), Left(e2)) => Left(e1 ++ e2)
    case (Left(e), _) => Left(e)
    case (_, Left(e)) => Left(e)
  }
  override def pureA: Fx[A] = Right(a)
}
implicit val monoidDouble: Monoid[Double] = new Monoid[Double] {
  def combine(x1: Double, x2: Double): Double = x1 + x2
  def empty: Double = 0
}

combinedAll在某个集合F[A]上工作,并在作用域内有Monoid[A]的情况下,从这个集合中产生结果A。Monoid 定义了如何将两个元素A组合成一个元素AF[A]是元素A的集合。所以,给定一个元素集合AcombineAll能够结合所有元素,并借助作用域内定义的二进制组合操作的 Monoid 来计算一个单一的结果A

这里需要注意的一点是,cats的类型类形成了一个生态系统,并且经常相互依赖。为了获得某个类型的某个类型类的实例,你可能会发现它隐式地依赖于另一个类型类的实例。对于其他类型类,你可能会发现它的一些方法隐式地依赖于其他类型类,就像combineAll依赖于 Monoid 的情况一样。

这种联系可以用来帮助纯函数编程的学习者。这种类型的生态系统意味着你可以从非常小的地方开始。你可以从使用你理解的一个或两个类型类开始。由于Cats库形成了一个依赖类型类的生态系统,你可能会遇到你的熟悉类型类依赖于你还不了解的类型类的情况。因此,你需要了解其他类型类。

我们需要注意的关于我们迄今为止所学的类型类的一些其他事情是,它们相当通用且与语言无关。它们编码的是类型之间的关系和转换。这可以用你选择的任何语言来编码。例如,在 Haskell 中,语言是围绕类型类的概念构建的。因此,如果你查看 Haskell,你会发现它也包含了我们在本章中涵盖的类型类。实际上,有一个完整的数学理论处理这些概念,并定义了我们涵盖的类型类,称为范畴论。这意味着我们可以从数学的角度讨论类型类,而不涉及任何编程。因此,类型类的概念是语言无关的,并且有一个坚实的数学基础。我们已经广泛地覆盖了一个特定于 Scala 的库,但我们涵盖的概念是语言无关的。以某种形式或另一种形式,它们在所有支持纯函数式风格的编程语言中都有实现。

概述

在本章中,我们深入探讨了在纯函数式编程中使用的类型类系统。我们审视了库,即纯函数式编程的标准库。我们首次了解了库的结构,并发现它由类型类、语法和效果类型的独立模型组成。

然后,我们深入研究了由库定义的一些类型类。我们看到了它们存在的动机,以及它们的实现和使用细节。关于所有类型类要记住的一件事是,它们不是 Scala 特有的。实际上,有一个完整的数学理论以与任何编程语言无关的方式处理它们。这被称为范畴论。所以,如果你了解一种编程语言的概念,我们就能在支持函数式风格的任何编程语言中使用它们。

Cats 为我们提供了有效的函数式编程工具。然而,我们需要更高级的库来编写工业级软件,例如网络应用的后端。在下一章中,我们将看到更多基于基本库的高级函数式库。

问题

  1. 将类型类组织到库中的动机是什么?

  2. Traverse 定义了哪些方法?

  3. 我们会在哪种现实场景中使用 Traverse?

  4. Monad 定义了哪些方法?

  5. 我们会在哪种现实场景中使用 Monad?

  6. Cats 库的结构是怎样的?

第九章:纯函数式编程库

在上一章中,我们借助cats等基本库讨论了纯函数式风格。这个库在纯函数式编程的任务上表现相当出色,但在实践中,这并不足以舒适地进行编程。

如果你看看传统的命令式语言,如 Java,你会发现它们通常有很多库和基础设施来执行特定任务。此外,也可以争论说,编程语言的选择主要是受其提供的基础设施驱动的。

这样,例如,Python 是机器学习的既定标准,因为它提供了一套复杂的科学库来执行科学计算,而 R 是统计计算的既定标准。公司通常选择 Scala,因为它提供了访问 Spark 和 Akka 库的途径,这些库用于机器学习和分布式计算。

因此,在讨论特定的编程风格时,同时提到它是一个围绕员工开发的基础设施,这一点非常重要。在本章中,我们将通过查看一些 Scala 中用于纯函数式编程的cats库之外的库来介绍这个基础设施。

本章将涵盖以下主题:

  • The Cats effect

  • 服务器端编程

我们将从这个章节开始,查看cats的并发库。

Cats effect

Cats effect 是一个用于cats库中并发编程的库。其主要特点是提供一系列类型类、数据类型和并发原语,用于描述使用cats在 Scala 中进行并发编程。

并发原语支持以下内容:

  • 资源管理——想想 try-with-resources。

  • 平行计算的无缝组合。

  • 平行计算之间的通信。

我们将首先通过查看其核心并发原语IO以及我们在讨论过程中需要的 Cats 的一些功能来讨论这个库。

ProductR

在深入探讨库并讨论其功能之前,我们需要提到一个在这个库中经常使用的特定操作符。我们已经讨论了 Applicative 类型类,并且它对并行组合很有用。

cats中经常使用的这个类型类的一个操作符是所谓的右乘操作符。

这个操作符接受两个计算,在它们之间执行乘法,并只取右手边的结果。特别是在 Cats effect 中,这个操作符经常用来指定一个事件应该在另一个事件之后发生。

它还有一个符号形式,看起来像这样:*>

IO – 并发数据类型

Cats effect 提供的主要数据类型是 IO。这是一个在未来的某个时刻要执行的计算的数据类型。例如,你可以有以下表达式:

object HelloWorld extends App {
  val hello = IO { println("Hello") }
  val world = IO { println("World") }
  (hello *> world).unsafeRunSync
}

关于 IO 的一个关键细节是,它恰好是对计算的描述。在这里,cats支持所谓的计算作为值范式。计算作为值规定你不应该立即评估你的竞争,而应该存储这些计算的描述。这样,你将能够在未来的任何时刻评估它们。

这种方法有许多优点,这就是我们接下来要讨论的。

引用透明性

Cats 的第一个优点是引用透明性。在先前的例子中,将“hello world”打印到命令行的计算不会立即执行。它是副作用,而我们没有立即执行它的事实意味着它是引用透明的。你可以如下评估这个计算:

(hello *> world).unsafeRunSync

IO 有一系列方法,这些方法的名称前面都带有unsafe这个词。

不安全的方法通常如它们的名称所示,是“不安全的”。这意味着它们可能会阻塞,产生副作用,抛出异常,并做其他可能让你头疼的事情。根据文档中对 IO 类型的描述,你应该只调用这样的方法一次,理想情况下是在程序末尾。

因此,基本上,主要思想是使用 Cats 效果库提供的便利,用 IO 原语来描述你的整个程序。一旦你的整个应用程序被描述,你就可以运行应用程序。

控制反转

由于用 IO 术语表达的计算不是立即执行,而仅仅是作为计算描述的存储,因此可以针对不同的执行策略执行计算。例如,你可能希望在不同的并发后端上运行计算,每个后端都有自己的并发策略。你可能希望同步或异步地运行一个竞争。在本章的后面部分,我们将看到这是如何做到的。

异步与 IO

Cats 效果的应用中心领域是异步编程。异步编程是一种事件驱动风格的编程,在这种编程中,你不会浪费线程和其他资源在阻塞,等待某个事件发生。

例如,假设你有一个处理传入 HTTP 请求的 Web 服务器。服务器有一组线程,用于处理每个请求。现在,处理器本身可能需要进行一些阻塞操作。例如,联系数据库或外部 HTTP API 可能是一个潜在的阻塞操作。这是因为数据库或 HTTP API 通常不会立即响应。这意味着如果请求处理器需要联系这样的资源,它将需要等待服务回复。

如果这种等待是天真地通过阻塞整个线程,一旦请求可用就恢复它,那么我们就会有一个浪费线程的情况。如果这样的服务器在高负载下运行,有危险的是,所有线程的大部分时间都将被阻塞。阻塞意味着它们什么也不做,只是等待资源的响应。由于它们什么也不做,这些线程本可以用来处理其他可能不需要这种阻塞类型的请求。

正是因为这个原因,当前的服务器端编程旨在实现异步处理,这意味着如果处理程序需要联系某些可能阻塞的资源,它会联系它。然而,一旦它没有其他事情可做,它应该释放其线程。一旦它等待的响应可用,它将继续计算。

这种策略允许创建非常轻量级的并发模块,不会浪费线程。这也确保了线程大部分时间都在忙于有用的工作,而不是阻塞。

然而,这种模型需要专门的库和服务器端技术,这些技术是专门为异步设计的。Cats 效果正是为了满足这种异步需求而精确设计的。

现在,让我们看看一些示例,这些示例在实践中展示了阻塞与异步的不同之处,以及 Cats 如何促进异步操作。在这个过程中,你还将学习到许多 Cats 效果 API。

阻塞示例

首先,让我们看看创建异步 IO 操作的 API 背后的内容:

图片

因此,你可以将一个任意任务提供给 IO 的apply方法,这将构建这个任务的描述。

我们可以通过在 IO 的apply方法下使用Thread.sleep Java API 来模拟计算的阻塞,如下所示:

IO { Thread.sleep(1000) }

注意,前面的示例将阻塞其线程。IO 可能只是对计算的描述。然而,计算应该在某个时刻执行。在 JVM 世界中,任何计算都是在线程上运行的。在前面示例中,我们使用 Java Thread.sleep API 来明确表示我们需要阻塞正在运行的线程一秒钟,即 1,000 毫秒。

借助于前面的原始方法,让我们构建一个无限计算,这将使我们容易追踪和研究。如果我们有一个长时间运行的计算,它以相等的时间间隔向命令行输出某些内容,我们就可以很容易地看到计算是否以及如何进行。通常,这种无限计算可以通过循环来实现。在函数式编程中,循环可以通过 Monad 的tailRecM来创建:

def taskHeavy(prefix: String): IO[Nothing] =
  Monad[IO].tailRecM(0) { i => for {
    _ <- IO { println(s"${Thread.currentThread.getName}; $prefix: $i") }
    _ <- IO { Thread.sleep(1000) }
  } yield Left(i + 1) }

在前面的代码中,你可以看到一个使用 IO 描述无限计算的 Monadic 无限循环。首先,计算将输出当前线程的名称、当前任务的名称以及从迭代到迭代将递增的数字。

线程输出可以用来跟踪计算正在哪个线程上运行。这些信息可以用来查看给定线程池中的线程是如何分配的。前缀是必要的,以便在我们要同时运行多个此类计算时区分一个任务与另一个任务。我们将这样做,以便看到此类任务在并发环境下的表现。

在并发环境模型中测试这样的阻塞任务需要在一个高负载的 HTTP 服务器下进行。在那里,你也有许多性质相同的任务正在并发运行。前面的例子模拟了一个处理任务阻塞底层线程的情况。

最后,标识号用于识别给定任务的进度,这样我们就可以看到任务进度是否均匀,以及是否有任何任务被阻塞。

由于在前面的例子中,我们受到在并发设置中测试任务的能力的激励,接下来,我们将简要介绍我们将运行任务的并发环境。

并发基础设施

并发环境由一个执行上下文表示,这是一个 Scala 类。官方文档将其定义为如下:

图片

这是一个标准的 Scala 类,它有一个方法来运行 Java Runnable

图片

在 Scala 中处理并发原语,如 Future 时,需要一个执行上下文。Cats effect 也依赖于这种类型来描述其自己的执行环境。我们可以构建一个执行上下文,并指定其线程池中可用的线程数,如下所示:

implicit val ec: ExecutionContext =
  ExecutionContext.fromExecutor(Executors.newFixedThreadPool(2))

在前面的代码中,我们正在使用fromExecutor方法,这是ExecutionContext类定义的如下:

图片

此方法使用 Java API 构建执行上下文。在我们具体的上一个例子中,我们正在构建一个具有固定线程池的执行联系,该线程池有两个线程。

我们的例子另一个激励因素是运行多个相同并发实例。接下来,我们将查看 API 以提供此功能。

批量运行任务

我们可以定义一个函数,在给定的执行上下文中以多个实例运行任意 IO 任务,如下所示:

def bunch(n: Int)(gen: String => IO[Nothing]): IO[List[Fiber[IO, Nothing]]] =
 (1 to n).toList.map(i => s"Task $i").traverse(gen(_).start)

bunch函数将需要并发启动的任务数量作为第一个参数。作为第二个参数,它接受一个函数gen来构建任务。该函数以字符串作为其第一个参数,即任务的名称。在我们需要运行相同任务的多实例的情况下,区分它们是至关重要的。因此,我们需要将名称提供给生成函数。

为了理解函数的输出类型,让我们看看函数的主体。

首先,主体构建了一个包含n个元素的列表。意图是使用这个列表来指定创建任务的循环。然后,我们使用我们创建的列表上的map函数来创建所需数量的任务:

(1 to n).toList.map(i => s"Task $i")

接下来,我们使用traverse函数,它会对我们刚刚创建的每个任务执行一些操作。让我们看看traverse函数内部发生了什么,以了解 Cats effect 中如何实现并行性:

.traverse(gen(_).start)

主要的焦点是start函数。让我们看看它是如何定义的:

图片

有关的问题函数在 IO 原语下产生所谓的Fiber。让我们看看Fiber是如何定义的以及它是什么:

图片

它定义了以下 API:

图片

通常,在基于 IO 的 Monadic 流程中等待会阻塞执行。当然,这种阻塞是异步进行的。然而,如果你在 IO 上调用start方法,它不会阻塞 Monadic 流程。相反,它会立即返回一个Fiber对象。

将一个Fiber对象视为底层 alpha 复杂度的遥控单元。它定义了两个方法,canceljoin。这两个方法可以用来与底层计算进行通信。cancel方法取消竞争,而join方法会阻塞当前的 Monadic 流程,直到底层 IO 计算完成。join方法返回这个计算在 Monadic 层中的值。

注意,这些canceljoin方法都是返回一个 IO 原语。这意味着你可以从 Monadic 流程中使用这个方法。

那么,为什么我们在 bunch 示例中使用start方法呢?

gen(_).start

记住,我们的任务是无限的。我们定义了它们为一个每秒阻塞一次的无穷循环。traverse的任务是评估它所提供的所有任务,并返回一个在单一效果类型下的组合任务,在这种情况下是 IO。然而,由于我们的任务是无限的,它们不能被评估为某个具体的结果。因此,我们将对每个任务执行start调用,以指定我们不需要任务本身的结果;我们只能对这个任务的遥控单元感到满意。这样,traverse方法将不会等待任何单个任务完成,而是在我们之前讨论的执行上下文中异步启动所有任务。

带阻塞的重负载

现在,假设我们的 taskHeavy 是一个 HTTP 服务器的处理器。服务器正在经历重负载,有 1000 个正在进行的请求。这意味着我们需要创建 1000 个任务来处理它们。使用 bunch 方法,我们可以定义如下处理方式:

(IO.shift *> bunch(1000)(taskHeavy)).unsafeRunSync

注意,在这个例子中,我们又遇到了另一个新的原语。它是在 IO 数据类型上定义的 shift 方法。它定义如下:

图片

shift 方法是执行转移到 ExecutionContext 的指令,它作为作用域中的隐式依赖项存在。在这里,我们隐式依赖于一个 Timer 对象,而不是 ExecutionContext。可以使用 IO API 的一部分隐式方法从 ExecutionContext 获取 Timer 对象:

图片

由于我们在隐式作用域中有一个 ExecutionContext,我们可以调用 shift 方法将当前 IO 计算的执行转移到我们已定义的线程池中。注意这里的 *> 操作符,我们之前在本章中讨论过。它表示第二个竞争应该在第一个之后执行,即转移到并发上下文中。我们还通过 unsafeRunSync 的帮助在现场运行了示例,以查看其执行情况。程序的输出如下:

图片

这里首先要注意的是,我们 ExecutionContext 中的两个线程都用于处理任务。你可以通过查看任务输出的线程名称来看到这一点。它会随着任务的变化而变化。然而,也要注意,只有前两个任务有机会被执行。这是因为我们使用 Thread.sleep 的阻塞调用来指定和延迟我们的执行。所以,在无限处理任务的设置中,这样的服务器一次只能处理两个请求。在一个需要处理 1000 个请求的设置中,这是不够的。

现在,让我们看看我们如何利用异步性来指定轻量级并发原语来处理那么多的请求。

同步任务

你可以异步地定义前面的计算如下:

def taskLight(prefix: String): IO[Nothing] =
  Monad[IO].tailRecM(0) { i => for {
    _ <- IO { println(s"${Thread.currentThread.getName}; $prefix: $i") }
    _ <- IO.sleep(1 second)
  } yield Left(i + 1) }

注意,这种方法与之前的任务定义方式相似。然而,我们不再阻塞线程。相反,我们正在使用一个内置的 IO 原语称为sleepsleep是一个非阻塞原语,这意味着它不会阻塞底层线程。也就是说,这是对sleep操作的描述。记住,所有用 IO 术语定义的计算都是计算的描述,而不是计算本身。因此,你可以按需定义sleep操作。因此,以非阻塞方式定义此操作是合理的,这样当遇到sleep操作时,底层线程就会释放,当执行环境收到表示sleep操作成功终止的信号时,计算就会继续。所有异步计算都使用类似的原则。我们可以如下运行此任务:

(IO.shift *> bunch(1000)(taskLight)).unsafeRunSync

程序的输出如下:

注意,所有的1000个任务都得到了足够的资源来执行。这是因为一旦这些任务不再需要它们,它们就会释放底层线程。因此,即使只有两个线程,我们也能成功一次性处理 1,000 个任务。所以,异步描述的计算相当轻量级,可以用于设计用于高负载的系统。接下来,让我们看看如何自己创建异步 IO 原语。

构建异步任务

IO 提供了一个 API,允许你将基于回调的现有计算转换为异步 IO。这可以用来以异步方式将现有计算迁移到 IO。

假设你有一个以下计算:

def taskHeavy(name: String): Int = {
  Thread.sleep(1000)
  println(s"${Thread.currentThread.getName}: " +
    s"$name: Computed!")
  42
}

正如我们之前看到的,它通过使用Thread.sleep来阻塞计算而阻塞了一个线程。整个计算的目的就是它不会立即返回。

现在,让我们看看如何异步运行计算:

def sync(name: String): IO[Int] =
  IO { taskHeavy(name) }

在这里,我们使用一种已经熟悉的方法将同步计算提升到 IO 数据类型。我们在之前的例子中已经看到了这样做的影响。这次,由于我们的计算不是无限的,让我们来看看处理这种计算与我们将要从中构建的异步竞争处理的时间差异。为此,我们需要一个基准测试能力:

def benchmarkA: IO[(A, Long)] =
  for {
    tStart <- Timer[IO].clockMonotonic(SECONDS)
    res <- io
    tEnd <- Timer[IO].clockMonotonic(SECONDS)
  } yield (res, tEnd - tStart)

在前面的代码中,我们正在构建一个基准测试能力,它将运行 IO 并报告计算运行所需的时间。

这里首先要注意的,是 IO 作为值策略如何有益于增强计算。在这里,基准方法接受一个尚未评估的 IO。它只是一个计算的描述。接下来,它将这个计算包装在一个可以测量时间的功能中,最后,它返回计算的最终结果,以及基准。

此外,注意我们在这里是如何使用 Timer 数据类型的。我们已经在 IO 原语的执行上下文背景下简要提到了 Timer 类。Timer 类恰好是 IO 用于管理其线程的执行上下文:

Timer 定义了以下抽象方法:

我们已经熟悉了 shift 方法。它可以用来将给定 IO 流的执行上下文移入这个 Timer。记住,Timer 可以从标准的 Scala ExecutionContext 构造。Timer 定义的其他方法用于时间测量。其中之一是 clockMonotonic,我们用它来进行前面的基准测试。

最后,我们可能想要定义一个 benchmarkFlush 方法来将测量结果报告到命令行,如下所示:

def benchmarkFlushA: IO[Unit] =
  benchmark(io).map { case (res, time) =>
    println(s"Computed result $res in $time seconds") }

接下来,我们将尝试在多个实例中并发运行我们的同步示例,并测量其时间。但首先,我们需要一个 bunch 函数来启动这个任务的多个实例:

def bunch(n: Int)(gen: String => IO[Int]): IO[List[Int]] =
  (1 to n).toList.map(i => s"Task $i").traverse(gen(_).start)
    .flatMap(_.traverse(_.join))

这个函数的第一部分与我们在上一个例子中看到的是相似的。然而,我们通过以下附录稍微扩展了这个函数:

.flatMap(_.traverse(_.join))

记住,我们原来的 bunch 函数是从其 traverse 方法异步开始计算的。结果是返回了一个我们并不感兴趣的 Fibers 列表。在基准测试的任务中,我们感兴趣的是所有计算终止的时间。因此,我们希望使用返回的 Fibers 的 join 方法来创建一个组合的 IO 数据类型,当所有计算成功时它才会成功。注意,我们仍然需要 start 能力来异步启动任务而不是顺序执行。如果你在这里不使用从 traverse 方法来的 start 方法,我们试图在 bunch 中启动的任务将会以同步方式执行,而我们需要并行执行来利用我们的共享线程池。

接下来,我们可以在基准测试下运行同步示例的 bunch,如下所示:

benchmarkFlush(IO.shift *> bunch(10)(sync)).unsafeRunSync

前面程序的输出如下:

我们用了五秒钟来计算 10 个任务。这是因为每个任务都会阻塞底层线程一秒钟,并且我们在执行上下文中有两个线程。

接下来,我们将看看如何定义相同任务的异步版本。

异步 API

首先,我们需要指出我们所说的“异步”究竟是什么意思。我们指的是相对于在执行 IO 数据类型的线程池中的异步。我们假设我们无法控制任务本身,并且无法重新定义它。实际上,我们并不关心它是如何实现的;我们只关心它终止的确切时刻。这里的任务是防止这个精确的 IO 执行的线程阻塞。

为了实现这一点,我们可以使用 IO.async 方法:

图片

这个方法有一个有点棘手的签名。所以,首先,让我们简要地看看它做了什么。给定一个特定的计算,它提供了一个回调,它可以通知要构建的 IO 任务。从async方法返回的 IO 任务将在底层计算调用它提供的回调时被视为已完成。

这种方法的优点是 IO 不关心计算在哪里或如何运行。它只关心何时完成。

因此,async方法是一个函数,它的参数是另一个具有以下签名的函数:

Either[Throwable, A]) ⇒ Unit

这是一个回调,底层计算在完成时会调用它。它由 IO 提供给async方法的用户,并作为通知表示 IO 应该被视为已完成。

接下来,让我们看看这个方法如何用于创建异步计算。

异步示例

我们可以用async重新定义先前的例子,如下所示:

def async(name: String): IO[Int] =
  IO.async { cb =>
    new Thread(new Runnable { override def run =
      cb { Right(taskHeavy(name)) } }).start()
  }

因此,在这里,我们使用IO.async原语将我们的计算提升到异步上下文中。首先,这个async方法给我们一个回调作为输入。我们完成计算后应该调用这个回调。

接下来,我们将我们的重计算调度到其他执行上下文中。在我们的例子中,这仅仅是启动另一个不属于我们执行 IO 的线程池的线程。这里有许多可能的场景,特别是在纯异步计算的情况下,即完全不使用阻塞的计算。例如,你可以想象从async注册另一个异步操作的回调。这可能对 GUI 编程很有用。然而,在这个例子中,使用单独的线程就足够了。唯一需要注意的是,线程是重量级的原语。尽管我们没有阻塞 IO 线程池,但我们仍在创建线程,并且我们仍在阻塞它们。这可能会耗尽系统的资源。

接下来,我们可以这样运行我们的计算:

benchmarkFlush(IO.shift *> bunch(10)(async)).unsafeRunSync

输出如下:

图片

注意,我们能够在两秒钟内完成操作。这是因为 IO 任务不再阻塞底层执行线程,IO 执行时释放了它的线程。

接下来,我们将更关注一下Fibers以及如何利用它们进行并发编程。

纤维

正如我们之前讨论的,纤维是 IO 的远程控制单元。让我们看看这在实践中如何用于并行运行操作。

计算示例

假设你有一个长时间运行的竞赛。假设相关的计算任务是找到特定范围内的数字之和。计算是长时间运行的,因为从数字到数字的调用必须暂停半秒:

def sum(from: Int, to: Int): IO[Int] =
  Monad[IO].tailRecM((from, 0)) { case (i, runningTotal) =>
    if (i == to) IO.pure( Right(runningTotal + i) )
    else if (i > to) IO.pure( Right(runningTotal) )
    else for {
      _ <- IO { println(s"${Thread.currentThread.getName}: " +
        s"Running total from $from to $to, currently at $i: $runningTotal") }
      _ <- IO.sleep(500 milliseconds)
    } yield Left((i + 1, runningTotal + i)) }

我们用 Monadic 循环来定义我们的比赛。在循环流程的主体中,我们有两个终止情况。第一个终止情况是当前数字等于我们范围的上线。在这种情况下,结果是运行总和加上那个数字。

另一个终止情况是当数字大于循环的上限。原则上,这种情况不应该发生,但仍然是一个好主意,以防无限循环。在这种情况下,我们不添加当前数字,直接返回运行总和。

还要注意 pure 方法,它在这些非终止情况中使用。它定义如下:

它将一个值提升到 IO 上下文中,而不对它做任何其他操作。

最后,我们有一个 Monadic 循环的非终止情况:

else for {
  _ <- IO { println(s"${Thread.currentThread.getName}: " +
    s"Running total from $from to $to, currently at $i: $runningTotal") }
  _ <- IO.sleep(500 milliseconds)
} yield Left((i + 1, runningTotal + i))

我们有一些调试输出,说明了当前线程和计算的状态。然后,我们通过使用 IO.sleep 原始操作异步地阻塞执行。

最后,我们返回一个新的计算状态,即下一个数字和更新的运行总和。

计算是长时间运行的,因为它会在每个数字上暂停半秒钟。

接下来,让我们看看如果我们想要组合两个此类计算的结果会发生什么。

不使用 Fibers 的 IO 组合

考虑我们需要计算两个范围的和,然后对结果求和。一种简单的方法如下:

def sequential: IO[Int] =
 for {
   s1 <- sum(1 , 10)
   s2 <- sum(10, 20)
 } yield s1 + s2

在前面的代码中,我们使用 Monadic 流合并我们的计算。让我们看看如果我们尝试在一个基准函数下运行比赛会发生什么:

benchmarkFlush(sequential).unsafeRunSync

前面执行的结果如下:

首先,注意第一个范围首先被计算。第二个范围甚至在第一个范围完成之前都不会开始。还要注意线程池中的两个线程在计算过程中的使用情况。这可以被认为是对线程和资源的浪费,因为我们本可以用这两个线程并行计算总和。然而,我们在这里是按顺序进行的。

我们可能会争辩说,前面的场景发生是因为我们使用了 Monadic 流。如您所回忆的,Monads 定义了顺序组合。在之前的计算完成之前,不可能开始下一个计算。我们还知道 Applicative 用于并行情况。我们能否应用 traverse 函数来并行计算所有的计算?让我们试试:

def sequentialTraverse: IO[Int] =
  List(sum(1, 10), sum(10, 20)).traverse(identity).map(_.sum)

现在,计算彼此独立。如果我们运行它们会发生什么?

benchmarkFlush(sequentialTraverse).unsafeRunSync

输出与前面的顺序示例完全相同,这意味着 Applicative 对于 IO 的默认实现是逐个运行计算,尽管它们是独立的。

我们如何借助 Fibers 改善这种情况?让我们看看我们如何使用 Fibers 并行启动计算。

使用 Fibers 的 IO 组合

之前,我们简要地提到了 Fibers 的主题。它们是底层计算的遥控单元。我们知道在任何 IO 上,我们可以调用一个start方法,这将导致它异步运行,这意味着它不会阻塞当前执行流的 IO 效应类型。你还知道我们可以稍后阻塞在 Fiber 上以获取结果。注意,这里我们是相对于 Monadic 流进行阻塞的。正是 Monadic 流的执行被阻塞,即 Monadic 指令的执行被暂停。用于运行的底层线程 IO 没有被任何东西阻塞。

让我们看看我们如何借助 Fibers 实现我们的求和示例:

def parallel: IO[Int] =
  for {
    f1 <- sum(1 , 10).start
    f2 <- sum(10, 20).start
    s1 <- f1.join
    s2 <- f2.join
  } yield s1 + s2

我们的求和指令相对于 Monadic 流异步执行,这意味着 Monadic 流应用程序将不会等待两个求和中的任何一个完成,并且将直接通过前两个指令而不会阻塞。结果是,两个计算都会提交以执行,并且将并行执行。

之后,我们可以阻塞在 Fibers 上以获取结果。我们可以按照以下方式运行应用程序:

benchmarkFlush(parallel).unsafeRunSync

输出如下:

现在,两个任务都是并发执行的。计算任务所需的时间减少了 2 倍。

接下来,让我们看看 Fibers 的另一个功能,即取消底层计算。

取消 Fibers

假设我们有一个比另一个短的 range,我们希望在第一个完成时取消较长的 range 计算。你可以用 Fibers 这样做:

def cancelled: IO[Int] =
  for {
    f1 <- sum(1 , 5 ).start
    f2 <- sum(10, 20).start
    res <- f1.join
    _ <- f2.cancel
  } yield res

我们可以按照以下方式运行它:

benchmarkFlush(cancelled).unsafeRunSync

执行结果如下:

注意,一旦第一个范围完成执行,第二个范围就会被取消。

在本章中,我们详细讨论了 Cats 效应库的货币能力。这是库的主要目标。然而,它还有许多其他有用的方法和原始操作。因此,接下来,我们将查看这些原始操作之一——bracket原始操作,它是 Cats 的 try-with-resources。

括号

经常,我们会遇到需要访问之后需要关闭的资源的情况。这可能是一个文件引用、数据库会话、HTTP 连接或其他东西。Cats 效应有一个专门的原始操作,允许您安全地处理此类资源。在 Java 中,有一个专门处理资源的语句,即 try-with-resources。Scala 没有类似的语句。然而,情况随着在 IO 原始操作上定义的bracket方法而改变:

正如文档中所述,bracket原始功能使底层执行引擎将此 IO 的结果视为一个需要关闭的资源。使用bracket函数,你可以传递两个参数。第一个参数指定了你对底层过程想要做什么。它非常类似于flatMap函数的参数。第二个函数是关闭底层资源的指定。这个函数将在计算完成后被调用,无论它是如何完成的。它可能以错误或取消结束,但是,在任何情况下都会调用清理函数。这防止了在性能环境中的内存泄漏问题。

让我们看看我们如何使用它的一个例子。首先,我们需要一个可关闭的资源,我们可以轻松检查其关闭状态。我们可以定义如下:

class DBSession {
  var closed = false
  def runStatement(stat: String): IO[List[String]] = {
    val computation = IO {
      if (stat.contains("user")) List("John", "Ann")
      else if (stat.contains("post")) List("Post1", "Post2")
      else Nil
    }
    if (!closed) computation
    else IO.raiseError { new RuntimeException("Connection is closed") }
  }
  def close(): Unit = closed = true
  def isClosed = closed
}

在先前的代码中,我们定义了一个数据库会话连接。它有一个closed标志,当设置时,防止对会话运行任何语句。接下来,我们有runStatement方法,它执行一些执行逻辑来模拟对数据库运行的语句。

这个runStatement方法值得特别注意,因为它展示了将计算作为值处理的强大功能。首先,你可以看到我们在computation值中定义了计算逻辑。

之后,我们检查closed标志是否已设置。如果没有设置,我们像往常一样返回计算。但是,如果设置了,我们返回一个错误。错误方法定义如下:

由于失败,它终止了正在进行的 IO 计算。

接下来,让我们定义一些辅助方法,我们将使用这些方法来测试我们的括号原始功能:

def dbSession: IO[DBSession] = IO { new DBSession }

def selectUsers(db: DBSession): IO[List[String]] =
  dbSession.flatMap(_.runStatement("select * from user"))

在先前的代码中,我们有一个创建数据库的函数,以及一个从数据库连接查询用户的函数。所有这些都是在 IO 数据类型下完成的。

接下来,让我们创建一个设置,以便我们可以看到连接是否已关闭。我们可以通过在括号原始功能下创建一个 Monadic 流程来实现这一点,然后从流程中,我们将把我们的会话引用泄露到流程外的变量中,稍后我们将检查它:

var sessIntercept: DBSession = null
val computation: IO[Unit] =
  dbSession.bracket(sess => for {
    users <- selectUsers(sess)

    _ = println(s"Users:\n${users.mkString("\n")}")
    _ = sessIntercept = sess
  } yield ())(sess => IO { sess.close() })

println(s"Session intercept before execution: $sessIntercept")
computation.unsafeRunSync
println(s"Session intercept after execution: $sessIntercept")
println(s"Session intercept closed status: ${sessIntercept.isClosed}")

因此,在先前的代码中,我们使用了计算值中的括号。我们在括号内部处于 Monadic 流程中,作为这个 Monadic 流程的一部分,我们选择用户以验证我们的程序是否正确工作。最后,我们将资源泄露到流程外的变量中。清理函数定义为关闭会话。

运行先前的计算的结果如下:

结合 IO 的异步能力,括号为你提供了一个在异步环境中使用、并希望防止内存泄漏的强大原始功能。

服务器端编程

函数式编程的一个大型应用领域是服务器端编程。服务器端编程指的是在服务器上持续运行并能够与外部世界通信的 Web 应用。这样的应用通常会在端口上监听传入的 HTTP 请求。请求到达后,它将在服务器上执行一些工作,并使用计算结果回复请求的客户端。

这类系统的应用范围很广。从常规网站到移动应用,再到软件即服务SaaS)系统,都被制作成网络应用。此外,一旦你拥有一个在服务器上持续运行、通过一个定义良好的协议与外部世界通信并执行一些计算的网络应用,你就可以为这样的应用拥有众多客户端。例如,你可能有一个基于 HTML 的前端,以及一个移动应用,还可以通过 API 与第三方应用集成。

Scala 和 Cats 基础设施恰好对服务器端编程有很好的支持。它们包含了你接受 HTTP 请求、将它们映射到你的领域模型对象、与数据库通信以及向客户端回复所需的所有基本元素。在本节中,我们将看到它是如何实现的。

但首先,让我们简要概述一下服务器端应用程序的一般架构,并指定我们将作为本章示例使用的应用程序。

服务器端应用程序的架构

首先,一个服务器应用程序包括一个服务器。服务器是一个将在给定机器上持续运行并监听给定 HTTP 端口的传入连接的应用程序。传入的连接通常是遵循某种协议的 HTTP 连接。

通信协议

结构网络应用通信协议的一种流行方式是遵循 RESTful 通信范式。

由于应用监听 HTTP 请求,因此将这些请求发送到某个路径是合理的。例如,一个典型的 HTTP 请求包含以下头部信息:

GET http://localhost:8888/order HTTP/1.0
User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64; rv:39.0) Gecko/20100101 Firefox/39.0
Pragma: no-cache
Content-Length: 19
Host: localhost:8888

因此,正如你所看到的,请求包含一个目标字符串或所谓的路径,以及 HTTP 头部信息。你可以推断出服务器公开的资源作为具有某些行为和定义在其上的数据的实体。RESTful 范式规定,服务器端通过 HTTP 公开的能力必须具有反映资源及其行为的路径和 HTTP 方法。

例如,假设你有一个管理论坛的服务器。我们将有用户和论坛帖子。关于行为,随着时间的推移,我们将想要创建新的帖子和新用户,列出现有的帖子和新用户,以及修改和删除它们。

这些行为可以通过以下方式通过 HTTP RESTful API 公开:

POST /user
POST /post
GET /user
GET /post
PUT /user
PUT /post
DELETE /user/{id}
DELETE /post/{id]

因此,HTTP 方法反映了服务器要执行的行为的性质。路径反映了给定行为中涉及资源的性质。

客户必须经常向服务器发送额外的信息。服务器应向客户回复一定的结果。这种请求和响应数据必须遵循一种双方都能理解的一定格式。此外,由于 Web 应用程序不仅可能面向一个客户,还可能面向众多潜在第三方客户,因此有必要使这种协议标准化。就像 HTTP 协议是一个众多独立各方都理解并实施的标准化协议一样,请求和响应协议也必须得到众多独立各方的支持。这是因为它们将需要一些库来编码和解码这种请求,我们不希望给他们带来额外的开销,以便他们可以自行实现。

因此,一种标准的编码请求和响应的方式是使用 JSON 或 XML。在这个例子中,我们将使用 JSON,因为它在 Scala 中比 XML 有更好的支持。此外,Cats 库系列包括轻松处理 JSON 的能力。

通信协议只是服务器架构中涉及的一部分。接下来,我们将简要讨论服务器由哪些组件组成。

服务器的软件架构

任何服务器必须拥有的第一个组件是一个能够监听 HTTP 请求并对它们做出响应的应用程序。这样的组件被称为 HTTP 服务器软件。除此之外,大多数服务器还需要一些持久化组件——数据库。接下来,服务器将需要一种与数据库通信的方式。因此,我们需要数据库访问层。

最后,需要一个编排解决方案,以便上述组件能够良好地协同工作,这意味着我们需要一个简单的启动服务器和数据库的能力,以及定义它们之间通信的方式。编排必须是明确定义的,并且可重复的,在多种不同的环境中设置最小。这很重要,因为你不希望为某个平台编写一次服务器,却无法轻松将其移植到其他平台。

上述组件是任何服务器端软件的基本组件。当然,更复杂的服务器端应用程序涉及更复杂的架构;然而,为了我们示例的目的,这已经足够了。

现在,让我们讨论我们将要使用的示例,以展示使用 cats 和 Typelevel 库进行服务器端编程。

示例规范

我们将要讨论的示例将是一个在线商店。因此,我们将有客户、商品实体,以及描述客户下订单的能力。

我们将把这些实体存储在数据库中,并且将通过 HTTP 接口暴露创建新用户和新订单的功能,以及列出现有订单和商品的功能。

接下来,让我们看看如何将这种架构付诸实践。我们将讨论整个架构,并在过程中介绍各种函数式编程库。这将有助于全面了解如何使用 Cats 进行服务端编程。

请记住,我们不会深入讨论我们将要讨论的任何库,因为这值得一本自己的书。此外,我们已经提到,猫是函数式编程技术的尖端,这意味着这个库发展迅速,我们可能涵盖的深入信息很快就会过时。然而,总体架构原则可能在未来相当长一段时间内保持不变。

编排和基础设施

首先,我们将讨论我们的基础设施以及我们将用于实现我们架构的软件。

我们的服务端软件将包含两个独立的部分。首先,它是一个基于 Scala 的服务端软件,其次,它是一个基于 Postgres 的数据库。

这两个组件通过 Docker 一起编排。

尽管本小节讨论的主题不涉及函数式编程,但为了理解函数式服务器将在哪种设置下运行,理解整体情况是必要的。

Docker

我们将在docker-compose文件中将涉及的其他软件的所有组件定义为 Docker 服务。

Docker-compose

整个文件看起来如下:

version: '3'
services:
  postgres:
    container_name: mastering_postgres
    build: postgres
    ports:
      - 5432:5432
  backend:
    container_name: mastering_backend
    build: .
    ports:
      - 8888:8888
    volumes:
      - ./_volumes/ivy2:/root/.ivy2
      - ./_volumes/sbt-boot:/root/.sbt/boot
      - ./_volumes/coursier:/root/.cache
      - .:/root/examples
    environment:
      - POSTGRES_HOST=postgres
      - POSTGRES_PORT=5432
    stdin_open: true
    tty: true

文件由两个服务组成——Postgres 服务和后端服务。Postgres 服务的定义如下:

postgres:
  container_name: mastering_postgres
  build: postgres
  ports:
    - 5432:5432

此服务定义了一个名为mastering_postgres的容器。build指令指定我们想要将当前文件夹中Postgres文件夹的内容构建到一个单独的 Docker 镜像中。端口指令指定了容器将暴露哪些端口。这个容器将运行数据库,因此我们需要暴露数据库将运行的端口。基本上,这是一个从容器端口到主机机端口映射。

第二个服务定义如下:

backend:
  container_name: mastering_backend
  build: .
  ports:
    - 8888:8888
  volumes:
    - ./_volumes/ivy2:/root/.ivy2
    - ./_volumes/sbt-boot:/root/.sbt/boot
    - ./_volumes/coursier:/root/.cache
    - .:/root/examples
  environment:
    - POSTGRES_HOST=postgres
    - POSTGRES_PORT=5432
  stdin_open: true
  tty: true

它还提供了其容器的名称,并指定我们想要将当前文件夹的内容构建到一个单独的镜像中。Docker 将在提供的目录中查找Dockerfile并将其构建到一个单独的镜像中。接下来,由于这个容器将托管一个 HTTP 服务器,我们还需要进行端口映射,以便我们可以从容器中监听主机机的 HTTP 连接。

之后,我们有 volumes 数组。这个数组指定了本地机器上的目录将被挂载到容器上的目录。在当前示例中,我们将负责缓存的容器目录集挂载。第一个条目是 ivy2 缓存,它被 Scala 和 SBT 用于存储它们的依赖项。之后,我们还挂载了 SBT 根目录,它是 SBT 安装的主机。最后,我们挂载了缓存目录,这是 SBT 存储其依赖项的另一个位置。

我们执行这些缓存目录的挂载,以便容器记住它在每次调用中获取了什么。因此,每次您重新启动 Docker 容器时,您不需要等待应用程序获取其依赖项,因为所有依赖项都将存储在主机机器上,在挂载的目录下。

最后,我们将当前目录挂载到容器下的 examples 目录。这样做是为了我们可以从容器中访问 Scala 源代码。因此,我们将能够在 Docker 容器的上下文中运行应用程序,这意味着我们将能够访问 docker-compose 文件中定义的所有基础设施。

最后,我们有一个 environment 数组。这个数组指定了容器初始化时将设置的环境变量。我们有指定 Postgres 数据库的主机和端口号的变量。我们将在 Scala 源中使用这些环境变量来指定数据库的位置。

最后,我们在文件中有两个技术条目:

stdin_open: true
tty: true

这些与从命令行访问运行中的 Docker 容器的能力相关。因此,由于这两个条目,我们应该能够在运行中的 Docker 容器上打开一个命令行。基本上,它们指定了 Docker 容器应该如何分配和处理控制台设备。如果您对此或任何其他 Docker 条目感兴趣,请查阅 Docker 文档。

接下来,让我们讨论与我们在 docker-compose 中定义的两个服务对应的两个 Dockerfile。

Dockerfile

Dockerfile 包含了如何构建特定镜像的描述。我们有两个镜像:一个是数据库镜像,另一个是后端镜像。让我们先从数据库镜像开始:

FROM postgres:latest
ADD ./*.sql /docker-entrypoint-initdb.d/

Dockerfile 只包含两行代码。首先,我们从现有的 Postgres 镜像继承。其次,我们将当前目录下的所有 SQL 文件复制到 Docker 镜像中的特定目录。这是在继承的 Postgres 镜像文档中描述的标准初始化过程。主要思想是使用我们将要使用的模式初始化数据库。我们的模式如下:

CREATE TABLE customer (
  id serial NOT NULL,
  "name" varchar NOT NULL,
  CONSTRAINT customer_pk PRIMARY KEY (id),
  CONSTRAINT customer_un UNIQUE (name)
)
WITH (
  OIDS=FALSE
) ;
CREATE UNIQUE INDEX customer_name_idx ON public.customer USING btree (name) ;

CREATE TABLE good (
  id serial NOT NULL,
  "name" varchar NOT NULL,
  price float4 NOT NULL,
  stock int4 NOT NULL DEFAULT 0,
  CONSTRAINT good_pk PRIMARY KEY (id)
)
WITH (
  OIDS=FALSE
) ;

CREATE TABLE "order" (
  id serial NOT NULL,
  customer int4 NOT NULL,
  good int4 NOT NULL,
  CONSTRAINT store_order_pk PRIMARY KEY (id),
  CONSTRAINT order_customer_fk FOREIGN KEY (customer) REFERENCES customer(id) ON DELETE CASCADE,
  CONSTRAINT order_good_fk FOREIGN KEY (good) REFERENCES good(id) ON DELETE CASCADE
)
WITH (
  OIDS=FALSE
) ;

INSERT INTO good (id, name, price, stock) VALUES(1, 'MacBook Pro 15''', 2500, 15);
INSERT INTO good (id, name, price, stock) VALUES(2, 'iPhone 10', 1000, 10);
INSERT INTO good (id, name, price, stock) VALUES(3, 'MacBook Air', 900, 3);
INSERT INTO good (id, name, price, stock) VALUES(4, 'Samsung Galaxy S5', 500, 8);
INSERT INTO good (id, name, price, stock) VALUES(5, 'Panasonic Camera', 120, 34);

我们还有三个表格。首先,我们有一个客户和商品的表格。客户和商品都有一个唯一标识它们的 ID。此外,商品还有一些特定的参数,例如价格和库存数量。最后,我们有一个将客户与商品通过订单链接的表格。

之后,我们将使用一些示例商品填充我们的数据库,我们将对这些商品运行测试查询。

接下来,让我们看看我们的后端镜像:

FROM hseeberger/scala-sbt

RUN mkdir -p /root/.sbt/1.0/plugins
RUN echo "\
addSbtPlugin(\"io.get-coursier\" % \"sbt-coursier\" % \"1.0.0-RC12-1\")\n\
addSbtPlugin(\"io.spray\" % \"sbt-revolver\" % \"0.9.0\" )\n\
" > /root/.sbt/1.0/plugins/plugins.sbt

WORKDIR /root/examples

该镜像继承自标准的 Scala SBT 镜像,因此我们将拥有 Scala 和 SBT。之后,我们定义了一些我们将要使用的 SBT 插件。第一个是用来加速下载依赖项的,第二个是用来在单独的 JVM 中启动服务器的。我们将在一个单独的 JVM 中启动服务器,因为这样我们可以保留管理服务器、从 SBT 控制台启动和重启服务器的可能性。

最后,我们将工作目录设置为我们的示例目录。

你可以在示例仓库的README文件中找到如何运行Dockerfile的说明。

现在我们已经熟悉了涉及组件的架构,让我们更详细地看看后端软件是如何使用 Scala 构建的。

后端架构

后端由三个独立的层组成。我们有业务域模型层、数据库访问层和服务器层本身。让我们依次查看这些层,看看它们如何使用 Typelevel 库实现。

模型

模型由一个 Scala 文件表示。它包含模型我们数据库的 case 类。请注意,在这里,我们使用的是普通的 Scala case 类,没有任何其他增强。如果你熟悉类似 Hibernate 的 Java 库,你会知道有一个所谓的对象关系映射ORM)库的整个类别。这些库旨在提供面向对象概念到数据库模式的无缝映射。主要思想是能够管理数据库、查询它和更新它,而无需显式执行 SQL 语句。这些库旨在为你提供一个面向对象的 API,允许执行这些操作,同时抽象底层的 SQL 引擎。

由于抽象泄露,这些库被证明是一个坏主意。有一些情况,这些类型的 ORM 库无法很好地处理。这些库可能不允许你执行某些特定于给定数据库的功能。

在现代函数式编程中,对象关系映射被认为是一种不好的做法。当前的共识似乎是执行普通的 SQL 语句是建模与数据库交互的最佳方式。因此,与对象关系映射库不同,我们不需要修改我们的领域模型以匹配我们正在工作的对象关系框架的需求。我们不需要实现模型类的接口。我们能够以普通的 case 类形式定义我们的领域模型。

数据库层

数据库层是用 Doobie 库实现的。每个实体都有一个单独的 Scala 文件,其中有一个单例对象,它包含我们为这个应用程序所需的所有方法。例如,让我们看看customer对象公开的 API:

object customer extends CustomerDbHelpers {
  def create(c: Customer): IO[Int] = ???
  def findByName(name: String): IO[Option[Customer]] = ???
  def list: IO[List[Customer]] = ???
  def get(id: Int): IO[Customer] = ???
  def update(c: Customer): IO[Int] = ???
  def delete(id: Int): IO[Int] = ???
}

trait CustomerDbHelpers {
  val selectCustomerSql = fr"select * from customer"  // to be explained further in the chapter
}

因此,我们有几种数据库访问方法,每种方法都返回一个 IO——这正是我们在上一节中学到的相同的 IO。在本节中,我们将要查看的 Doobie 库与 Cats effect 很好地集成,我们可以利用 IO 与数据库进行通信。

现在,让我们看看这样一个方法是如何在 Doobie 中实现的,以及 Doobie 的操作模型:

def create(c: Customer): IO[Int] =
  sql"""
    insert into customer (name)
    values (${c.name})
  """
  .update.withUniqueGeneratedKeysInt.transact(tr)

在这里,有几件事情在进行中。首先,我们有一个由 Doobie 提供的字符串插值器下的 SQL 语句。现在,在 Scala 中,你可以定义具有特定关键字的自定义字符串插值器,这个关键字直接写在字符串字面量之前。在我们的例子中,这样的关键字是sql。字符串插值器的主要思想是,在编译时,它们将以某种方式转换字符串,可能产生一个完全不同的对象。

首先,让我们弄清楚字符串插值器对字符串做了什么。为了做到这一点,我们将查阅文档:

图片

因此,sqlfr0的别名,定义如下:

图片

此对象有两个方法:

图片

注意,这些方法中有一个是宏定义。宏定义是 Scala 中的一个特殊方法,它在编译时被调用。它用于 Scala 的元编程。字符串插值器通常是用宏实现的。

现在,宏将字符串转换为一个Fragment对象。片段是在字符串插值器下的 SQL 语句的模型。注意,在前面的屏幕截图中,字符串插值器还提供了外部变量,用于 SQL 语句,如下所示:

values (${c.name})

插值是由专门的 Doobie 宏完成的。它以安全的方式插值变量,这样你就不必担心将变量插入 SQL 查询时的转义——这样,你不会遇到 SQL 注入。Doobie 会为你执行转义。

关于 Doobie 在这里使用的技术需要注意的一点是,与数据库交互的 SQL 代码被定义为一个字符串。然而,这个字符串是在编译时被处理的。这为您提供了编译时的类型安全和编译器辅助。

现在,让我们看一下片段的定义:

图片

片段公开了以下 API:

图片

对于我们的目的,以下两种方法尤为重要:

图片

前面的方法用于查询操作,例如从数据库中选择。

以下方法用于更新操作,例如对数据库进行修改:

图片

一个片段是你传递给字符串插值的语句的模型。然而,这个模型不存储关于你想要对数据库执行哪个确切操作的信息。因此,为了指定这种操作,你需要在Fragment上调用updatequery方法。update方法用于插入和更新操作,而query方法用于select操作。

在我们的示例中,我们调用update方法,因为我们执行了一个插入查询。接下来,从片段中生成了一个Update0对象:

图片

它公开了以下 API:

图片

注意,它的 API 分为两个部分。首先,是诊断部分。由于 Doobie 构建了你的查询的内部模型,它允许你运行某些测试来检查传递给查询的参数是否为正确的类型,以及查询本身是否被正确组成。我们还有一个执行 API。执行 API 是你用来运行查询的 API。

注意,所有来自执行类的方法都在ConnectionIO效果类型下返回一个类型。ConnectionIO本质上是一个所谓的自由对象。如果一个片段和Update0是你要运行的 SQL 查询的模型,那么ConnectionIO的自由对象就模拟了程序需要采取的精确步骤来对数据库执行此查询。自由对象是来自抽象代数的一个概念。本质上,这个想法是在不实际运行的情况下模拟自由对象下的计算。这个想法与我们在上一节中查看的 IO 效果类型相同。那个类型也是一个自由对象。

注意,在我们的示例中,我们调用了UniqueGeneratedKeys方法。该方法知道底层数据库将为我们即将执行的插入操作生成一个主键。在我们的情况下,主键是一个整数,我们向该方法传递了一个整数类型参数。

如果你查看ConnectionIO的定义,你会看到它是一个Free Monoid:

图片

因此,数据库操作的底层实现是使用 free Monad 库完成的,这也是 Cats 基础设施的一部分。正如我们之前所说的,我们不会深入讨论这些辅助库和概念,因为它们本身就值得单独一本书来介绍。所以,这里的主要要点是,Doobie 库从构建你的 SQL 查询模型开始,并提供一个 API 来逐步将其转换为针对数据库执行的计算模型。在所有地方,计算作为值的范式得到保持,除非明确指令,否则不会执行任何操作。

我们能够在给定的效果类型下运行ConnectionIO,通过对其执行transact操作。这个操作是通过一个 Rich Wrapper 注入的,其定义如下:

图片

以下构造函数用于构建包装器:

图片

它只公开了一个方法:

图片

实质上,该方法负责在给定数据库的交易者时,在某个效果类型下运行计算。现在,数据库的交易者是一个知道如何与底层数据库通信的驱动程序。请注意,到目前为止,Doobie 公开了数据库无关的 API,这是这类库所期望的。所以,特定于数据库的信息存储在Transactor对象下,你必须为你的数据库实现这个对象,以便你可以在该数据库上运行数据库查询。

还请注意,我们传递给transact方法的效果类型有一个类型参数。这个参数指定了我们将在哪种效果类型下运行我们的计算。记住,ConnectionIO只是对要执行的计算的描述。为了执行它,我们需要指定我们将要在哪种效果类型下执行它。

在我们的例子中,我们使用tr变量作为交易者。那么,让我们看看它是如何定义的,以便理解我们例子的语义:

implicit lazy val tr: Transactor[IO] =
 Transactor.fromDriverManagerIO}:${sys.env("POSTGRES_PORT")}/postgres"
 , "postgres", "")

在这里,我们使用内置的 Doobie 方法来构建交易者,给定我们将要使用的数据库的数据库驱动程序的完整类名。在我们的例子中,我们使用 Postgres,并将 Postgres 驱动程序的完全限定名称传递给驱动程序管理器构造 API。

驱动管理构建方法的下一个参数是我们将要连接的数据库的地址。在这里,我们正在连接到一个 Postgres 数据库,并且我们从环境变量中读取其主机和端口。记住,当我们讨论后端和数据库的 Docker 编排时,我们讨论了后端的环境变量是从docker-compose文件中填充的。这些变量指定了数据库的位置,以便后端可以连接到它。

在连接字符串之后,我们有数据库的登录名和密码。在我们的例子中,登录名和密码是我们使用的 Docker Postgres 镜像的标准连接字符串。

此外,请注意,我们的交易者是为 IO 的效果类型构建的。这意味着当我们将要运行针对这个交易者的查询时,结果将是一个 IO。

让我们回顾一下什么是 IO。它是将要进行的计算的描述。然而,ConnectionIO也是将要进行的计算的描述。所以,当我们对ConnectionIO上的transact语句进行执行时,我们实际上并不是作为一个计算来运行它,而是将其从一种自由语言翻译成另一种语言。我们将它从ConnectionIO翻译成 IO。这种从一种自由语言到另一种自由语言的翻译在纯函数式编程中相当常见。一个有用的直觉可能是高级编程语言与低级编程语言之间的对比。当你编译像 Scala 或 Java 这样的语言时,就会发生从高级语言到低级字节码语言的翻译。

对于人类来说,用高级语言编程更方便,但对于机器来说,消费低级语言更方便。因此,在我们实际运行程序之前,我们必须首先将其从高级语言翻译成低级语言。

类似的东西也可以说关于从一种自由效果类型到另一种自由效果的翻译。本质上,当我们试图将所有的计算指定为值时,我们迟早会遇到某些任务可以用一种更高层次的语言轻松描述的情况。然而,当它们用低级语言表达时运行它们更方便。因此,翻译发生在从高级语言到低级语言。

在我们的例子中,我们正在将ConnectionIO的翻译从描述与数据库交互的领域特定语言,翻译成 IO 语言,这是一种通用目的的低级语言,可以描述任何输入输出操作。

因此,我们的customer对象的创建方法输出为 IO,我们可以在需要它们的结果时稍后运行。

现在,让我们来看看我们之前提到的customer对象成员的附加方法。首先,让我们看看list方法,它应该列出数据库中所有现有的客户:

def list: IO[List[Customer]] =
  selectCustomerSql.query[Customer].to[List].transact(tr)

selectCustomerSql变量定义如下:

val selectCustomerSql = fr"select * from customer"

我们将这个查询定义在一个单独的变量中,因为我们将在稍后的其他查询中重用它。注意我们如何使用 Doobie 提供的其他字符串插值器:

图片

如您从文档中看到的,Doobie 提供了几种方式来指定字符串插值片段。主要区别在于它们后面是否有尾随空格。这样的尾随空格在您想要稍后与其他片段组合时可能非常有用。为了确保您不需要担心在您将要使用空格连接的两个片段之间进行分隔,有一个默认的字符串插值器会为您自动插入空格。我们稍后会看到这一点是如何有用的。

回到我们的list示例,如您所见,我们正在对片段运行查询方法。这与customer对象的create方法的update方法形成对比。我们正在执行一个select查询,因此我们将运行query方法。

该方法生成一个Query对象。在这里需要注意的一个有趣的事情是,Doobie 可以自动将数据库返回的原始数据转换为所选的数据类型。因此,我们将Customer类型作为查询的类型参数,Doobie 能够自动推断出将结果转换为该类型的方法。一般来说,对于 case classes、tuples 和原始类型,这种转换是开箱即用的。这是通过编译时元编程,通过宏和类型级计算来实现的。Doobie 的这个有用特性使其能够直接与传统对象关系映射库竞争,因为您能够以零额外成本将结果映射到您的领域模型。

query方法产生的Query0对象定义如下:

图片

让我们来看看它的 API。它由我们感兴趣的两大部分组成。首先,是诊断部分:

图片

接下来,是结果部分:

图片

Update的情况类似,API 被分为诊断和结果两个部分。对我们来说,最有趣的部分是结果部分。请注意,它包含各种方法,用于指定你期望从数据库查询中检索哪种类型的结果。例如,当你预期查询可能返回空结果时,应该调用option方法。当你预期查询只有一个结果时,应该调用unique方法。最后,当你想要将结果转换为某个集合时,应该调用to方法。实际上,正如你所看到的,在这里没有限制你只能从给定的结果构建集合。只要你的结果类型符合F[_]类型形式,你应该能够构建你想要的任何东西,前提是你有一个这个方法隐式依赖的类型类。最常见的情况是,这个方法用于从数据库中创建集合。

这个 API 的其他方法也可以用于其他类型的结果。然而,为了本教程的目的,这三个就足够了。

回到我们的列表示例,我们在它上面调用to方法来生成所有客户的列表。因此,我们得到了一个ConnectionIO类型,我们之前已经讨论过。然后我们像之前一样运行它通过我们的交易者。

现在,让我们看看Customer对象的get方法:

def get(id: Int): IO[Customer] =
  (selectCustomerSql ++ sql"where id = $id")
    .query[Customer].unique.transact(tr)

在这里要注意的第一件事是我们正在进行片段连接。因此,从数据库中选择客户的查询保持不变。然而,我们正在使用定义在Fragment上的连接方法将其与另一个片段连接起来,并生成一个复合片段。连接方法在片段上的定义如下:

图片

注意到左侧Fragment上的尾随空白在这里很有用。记住我们讨论过selectCustomerSql片段是用一个强度插值器构建的,它会将尾随空白注入到生成的片段中。这对于需要连续连接两个片段的连接情况非常有用。注意,我们不需要在带有过滤条件的第二个片段前添加空白,因为第一个片段已经考虑到了连接。

之后,我们以类似的方式运行query方法,就像我们在列出所有客户示例中所做的那样。然而,在这里,我们只期望一个客户。因此,我们将调用查询对象的unique方法。最后,我们将调用transact方法将ConnectionIO转换为IO

接下来,让我们看看findByName方法:

def findByName(name: String): IO[Option[Customer]] =
  (selectCustomerSql ++ sql"""where name = $name""")
    .query[Customer].option.transact(tr)

此方法通过名称查找客户。请注意,它与通过 ID 获取客户的方式定义得非常相似。然而,我们并没有在查询对象上调用unique方法,而是调用了option方法。这是因为我们构建此方法时已经考虑到了查询结果可能为空的可能性。每次我们通过 ID 请求用户时,我们假设具有给定 ID 的用户存在于数据库中,至少在这个示例的目的上是这样。然而,当我们正在数据库中查找用户时,我们假设具有给定名称的用户可能不存在。

因此,我们的findByName方法返回一个Option[Customer]

我们将要讨论的最后两个方法是updatedelete方法:

def update(c: Customer): IO[Int] =
  sql"""
    update customer set
      name = ${c.name}
    where id = ${c.id}
  """
  .update.run.transact(tr)

def delete(id: Int): IO[Int] =
  sql"""delete from customer where id = $id"""
    .update.run.transact(tr)

这些方法在 Doobie API 方面没有带来任何新内容,并且是使用我们已学过的 API 构建的。

现在,让我们看看这个示例在实际数据库中的工作情况。为了测试这个示例,我们将使用以下应用程序:

val customersTest: IO[Unit] = for {
  id1 <- customer.create(Customer(name = "John Smith"))
  id2 <- customer.create(Customer(name = "Ann Watson"))

  _ = println(s"Looking up customers by name")
  c1 <- customer.findByName("John Smith")
  _ = println(c1)
  c2 <- customer.findByName("Foo")
  _ = println(c2)

  _ = println("\nAll customers")
  cs <- customer.list
  _ = println(cs.mkString("\n"))

  _ = println(s"\nCustomer with id $id1")
  c3 <- customer.get(id1)
  _ = println(c3)

  _ = println(s"\nUpdate customer with id $id1")
  r <- customer.update(c3.copy(name = "Bob"))
  _ = println(s"Rows affected: $r")
  c4 <- customer.get(id1)
  _ = println(s"Updated customer: $c4")

  _ = println(s"\nClean-up: remove all customers")
  _ <- List(id1, id2).traverse(customer.delete)
  cx <- customer.list
  _ = println(s"Customers table after clean-up: $cx") 
} yield ()

customersTest.unsafeRunSync()

上述应用程序测试了我们迄今为止讨论的所有方法。首先,我们创建了一些客户以供使用。然后,我们测试了按名称查找。之后,我们测试了数据库中所有客户的列表。之后,我们测试了通过 ID 获取客户。最后,我们测试了客户上的updatedelete操作。运行上述应用程序的结果如下:

图片

除了为顾客提供的方法外,我们还需要定义如何处理商品的方法。因此,我们需要一个创建商品的方法,我们可以将其定义如下:

def create(c: Good): IO[Int] =
  sql"""
    insert into good (
      name
    , price
    , stock)
    values (
      ${c.name}
    , ${c.price}
    , ${c.stock})
  """
  .update.withUniqueGeneratedKeysInt.transact(tr)

我们还需要查询商品表的方法:

val selectGoodSql = fr"select * from good"

def findByName(name: String): IO[Option[Good]] =
  (selectGoodSql ++ sql"""where name = $name""")
    .query[Good].option.transact(tr)

def list: IO[List[Good]] =
  selectGoodSql.query[Good].to[List].transact(tr)

def get(id: Int): IO[Good] =
  (selectGoodSql ++ sql"where id = $id")
    .query[Good].unique.transact(tr)

最后,我们需要updatedelete方法来修改数据库:

def update(c: Good): IO[Int] =
  sql"""
    update good set
      name = ${c.name }
    , price = ${c.price}
    , stock = ${c.stock}
    where id = ${c.id}
  """
  .update.run.transact(tr)

def delete(id: Int): IO[Int] =
  sql"""delete from good where id = $id"""
    .update.run.transact(tr)

我们还需要为订单创建一个数据库访问对象,以便我们可以修改和列出它们。在订单对象上我们需要定义以下方法:

object order extends OrderDbHelpers {
  def create(o: Order): IO[Int] =
    sql"""
      insert into "order" (customer, good)
      values (${o.customer}, ${o.good})
    """
    .update.withUniqueGeneratedKeysInt.transact(tr)

  def list: IO[List[Order]] =
    selectOrderSql.query[Order].to[List].transact(tr)

  def get(id: Int): IO[Order] =
    (selectOrderSql ++ sql"where id = $id")
      .query[Order].unique.transact(tr)
}

trait OrderDbHelpers {
  val selectOrderSql = fr"""select * from "order""""
}

由于这些方法没有引入任何新的功能,只是展示了我们迄今为止所学的 Doobie 的使用,因此我们不会深入讨论这些方法。

接下来,我们将看到如何以纯函数式风格执行服务器端编程,以及它是如何利用我们迄今为止定义的数据库对象的。

服务器端编程

对于服务器端编程的目的,我们将使用名为HTTP4SCirce的库。HTTP4S是一个库,你可以用它来启动 HTTP 服务器,接受请求,并定义如何响应它们。Circe是一个库,你可以用它将 JSON 字符串转换为领域对象。

HTTP4S在底层使用 IO,以便它可以很好地集成到我们现有的输出 IO 的数据库基础设施中,以及确保我们的服务器以异步方式运行。Circe使用编译时编程技术(我们已简要讨论过)来定义如何将 JSON 字符串转换为 Scala 案例类或特质。

我们将按照以下方式启动我们的服务器:

BlazeBuilder[IO]
  .bindHttp(8888, "0.0.0.0")
  .mountService(all, "/")
  .serve.compile.drain.unsafeRunSync()

在底层,HTTP4S依赖于其他用于服务器端编程的库,即Blaze库。正如我们之前提到的,服务器端编程的基础设施涉及广泛的各个库,所以这里要捕捉的是服务器端编程的大致图景。

我们在BlazeBuilder对象上调用几个配置方法。bindHttp方法指定我们将监听哪个主机和端口。在这种情况下,主机设置为localhost0.0.0.0,端口设置为8888

接下来,我们定义服务器将使用的处理器。这是通过mountService方法完成的。在这种情况下,我们将单个处理器all绑定到服务器的根路径。all处理器是我们即将定义的处理器。

当我们完成服务器的配置后,我们将调用其上的serve方法。该方法返回一个流,它是 Cats 基础设施中另一个库的成员。这个库被称为 FS2(代表函数式流),是一个专门用于以函数式方式处理流的库。流是延迟评估的,为了在 IO 下运行它,我们将运行这个流的compiledrain方法。这个方法的核心是它将在 IO 的效果类型下运行一个延迟的、有副作用的流。drain方法返回 IO。接下来,我们使用unsafeRunSync方法运行这个 IO。

因此,正如你所看到的,在函数式编程中启动 HTTP 服务器涉及相当多的库。然而,所有这些库的核心思想是相同的。它们都利用了相同的效果类型 IO,并且都订阅了延迟评估、引用透明的计算作为值的理念。这意味着默认情况下不会运行任何计算;它们都存储为计算的描述。由于每个库都有自己的领域,一些库可能有自己的语言来描述它们的计算。然而,这些特定领域的语言最终被翻译成单一的底层 IO 语言。

如果你对深入了解这里发生的事情感兴趣,最好的方法是检查我们所提到的库的 Scala API 文档。检查你调用的方法、它们返回的类型,以及理解这些方法和类型的意义,这可以帮助你更好地理解这个库内部发生的事情。

接下来,我们将探讨如何定义网络服务器的处理器。

all处理器定义如下:

def all = (
    createCustomer
<+> placeOrder
<+> listOrders
<+> listGoods)

这是一个由几个其他处理器组合而成的。这里需要注意的是组合技术。因此,我们能够借助组合操作符将其他处理器组合起来。

有关的问题是一个 or 组合,这意味着将依次检查传入的请求与组合运算符指定的每个处理器。第一个能够处理请求的处理器将被使用。组成整个 all 处理器的各个处理器如下:

def createCustomer = HttpService[IO] {
  case req @ POST -> Root / "customer" =>
    for {
      reqBody <- req.as[Customer]
      id <- db.customer.create(reqBody)
      resp <- Ok(success(id.toString))
    } yield resp
  }

我们将使用 HttpService 对象的帮助来创建我们的新客户处理器。我们调用的方法定义如下:

图片

它接受一个部分函数,该函数将请求映射到在效果类型 F 下的响应。一个请求包含您期望的请求所具有的内容。以下是其定义和一些公开的 API 方法:

图片

它公开了以下 API:

图片

传递给请求的部分函数在效果类型下返回一个响应。目前,唯一支持的效果类型是 IO。它返回响应在效果类型下的事实意味着服务器是考虑异步性构建的。

以这种方式构建的处理器将匹配任何传入的请求与部分函数。

通过调用构建的 HttpService 定义如下:

图片

它是 Kleisli 类型的别名。Kleislicats 核心库的一部分,定义如下:

图片

因此,本质上,它不过是一个您会传递给,比如说,flatMap 方法的函数。它是一个以下类型的函数:

A ⇒ F[B]

我们用于构建处理器的部分函数在这里做了一些事情。首先,请注意,有一个 DSL 可以方便地从请求中提取 HTTP 方法和一个路径。这些提取器来自 HTTP4S API,可以方便地用于匹配请求。

接下来,我们开始对 IO 执行 Monadic 流:

reqBody <- req.as[Customer]

as 调用旨在从传入的请求体中提取 Customer 对象。假设请求体是一个有效的 JSON 字符串,并且底层将使用 Circe 库将传入的请求体转换为所需的数据类型。您不需要对如何将 JSON 转换为案例类进行任何其他具体规定,因为 Circe 在底层定义了如何进行转换。

接下来,我们将在数据库中创建一个客户。我们使用本节先前定义的数据库访问对象来完成此操作。因此,我们得到了新创建的客户 ID。

最后,我们构建对查询的响应:

resp <- Ok(success(id.toString))

我们使用对 Ok 方法的调用来定义 Ok 响应代码 200Ok 定义如下:

图片

Status 是一个没有 apply 方法的抽象类,这是对象可调用的必要条件。因此,我们不应该能够调用它。我们能够在程序中调用它的原因是,该方法通过以下 Rich Wrapper 注入到 Ok 对象中:

它公开了以下 API:

这个包装器由一个计算并返回响应的效果类型参数化。目前,HTTP4S 只支持 IO 效果类型,但这不是一个问题,因为 Typelevel 基础设施的所有其他库也使用 IO 的语言。

注意,我们为响应指定了一个 payload。它通过以下方式指定:success 方法,其定义如下:

def successT: Map[String, T] =
  Map("success" -> payload)

因此,有效负载被设置为普通的 Scala Map[String, Int]Int 是推断的,因为 success 的参数是一个整数)。由于我们使用 Circe,这个 Scala 集合将被自动编码为 JSON 并返回给请求客户端。同样,这是作为标准功能提供的,无需额外费用。

接下来,placeOrder 处理程序定义如下:

def placeOrder = HttpService[IO] {
  case req @ POST -> Root / "order" =>
    for {
      cookieHeader <-
        headers.Cookie.from(req.headers).map(IO.pure).getOrElse(
          IO.raiseError(noAuthCookieError))
      jsonBody <- req.as[Map[String, Int]]
      cookie <- cookieHeader.values.toList
        .find(_.name == "shop_customer_id").map(IO.pure).getOrElse(
          IO.raiseError(noAuthCookieError))
      uId = cookie.content

      oId <- db.order.create(Order(good = jsonBody("good"), customer = uId.toInt))
      order <- db.order.get(oId)
      resp <- Ok(success(order))
    } yield resp
}

它主要使用我们已经讨论过的功能。然而,应该提出几点注意事项:

cookieHeader <-
  headers.Cookie.from(req.headers).map(IO.pure).getOrElse(
    IO.raiseError(noAuthCookieError))

首先,HTTP4S 提供了从请求中提取各种参数的能力,例如 cookies。在前面的代码中,我们从所有请求头中提取了 cookie 头。如果操作未成功,我们将通过 IO 方法引发错误。本质上,从 IO 中引发错误会导致整个 Monadic 流短路。这与从命令式代码中抛出异常类似,只不过 IO 效果类型将负责错误处理:

jsonBody <- req.as[Map[String, Int]]

在前面的行中,注意我们如何能够将传入请求的 JSON 主体提取为 Scala 映射。因此,Circe 不仅支持原始类型和案例类,还支持 Scala 集合类型。Circe 在编译时自动推导 JSON 的编码器和解码器:

resp <- Ok(success(order))

注意,前面的响应将整个案例类作为其有效负载。我们返回一个嵌套在 Scala 映射中的案例类。Circe 能够无缝地将此数据结构编码为 JSON。

最后,定义了两个列表处理程序如下:

def listOrders = HttpService[IO] {
  case req @ GET -> Root / "order" =>
    db.order.list.flatMap(Ok(_))
}

def listGoods = HttpService[IO] {
  case req @ GET -> Root / "good" =>
    db.good.list.flatMap(Ok(_))
}

由于我们的数据库以 IO 形式返回结果,并且我们使用 Circe 自动将模型对象编码为 JSON,我们可以将数据库的响应 flatMap 到响应状态码中。我们能够将整个处理程序仅用一行代码指定为数据库访问方法上的薄包装器。

查询服务器

在示例仓库中,有一个 shell 脚本,您可以在启动后使用它来查询服务器。您可以从 SBT 控制台使用以下命令启动服务器:

reStart

注意,这个命令必须在 Docker 镜像下运行。所以,如果你只是从示例仓库在你的机器上运行 SBT 控制台,它将不起作用;你需要首先运行 Docker 镜像,然后从在该 Docker 镜像上启动的 SBT 控制台中运行命令。

之后,你可以使用客户端 shell 脚本来查询数据库服务器。例如,我们可以按照以下方式创建新客户:

图片

注意到响应是一个格式良好的 JSON,其中包含创建客户的 ID。

接下来,我们可以列出数据库中所有存在的商品,以便我们可以下订单:

图片

因此,我们得到了所有商品的 JSON 数组作为响应。我们可以按照以下方式下订单:

图片

最后,我们可以列出所有订单以确认数据库中是否有订单:

图片

摘要

在本章中,我们介绍了 Typelevel 库系列为纯函数式编程提供的广泛基础设施。首先,我们学习了使用 Cats 进行异步编程的基础,即 Cats 效应库。我们讨论了IO并发原语和将计算作为值的哲学。之后,我们学习了服务器端编程的基础,这涉及到一系列库。这些库负责处理 HTTP 请求和数据库访问,同时在底层使用 JSON 转换库。我们对使用这些库进行编程可能的样子有了鸟瞰式的了解。

到目前为止,我们已经涵盖了足够的材料,可以开始以纯函数式的方式编写工业软件。在下一章中,我们将看到更多高级的函数式编程模式。这些模式将帮助我们的架构解决更广泛的问题,并使它们更加灵活。

问题

  1. 解释阻塞和非阻塞编程之间的区别。

  2. 为什么异步编程对于高负载系统是强制性的?

  3. 将计算作为值的策略如何有利于并发编程?

  4. 什么是IO效应类型?

  5. IO暴露了哪些异步编程的能力?

第十章:高级函数式编程的模式

我们可能已经熟悉了来自面向对象编程的概念——模式。模式是常见问题的常见解决方案。每当你在项目之间重复遇到问题时,解决方案也倾向于重复。相似的问题以相似的方式解决。因此,这些解决方案成为广泛接受的模式。

函数式编程也有自己的模式。由于它有其独特的问题和挑战,它也将有其独特的解决方案。在本章中,我们将讨论常见函数式编程问题的解决方案。

在本章中,我们将涵盖以下主题:

  • 模态转换器

  • 标签无最终状态

  • 类型级编程

模态转换器

模态转换器是纯函数式编程的一个重要模式,它允许我们组合效果类型。现在让我们详细讨论一下。

效果类型的专业化

我们已经讨论了效果类型在纯函数式编程中的普遍性以及它们用于抽象副作用。你可能也注意到这些类型非常专业化,这意味着我们几乎有一个副作用和效果类型之间的一对一映射。例如,应用程序返回 null 的能力由 Option 效果类型表示。Option 对于这种 null 情况来说很好。然而,当它被用来模拟错误和异常时,表现并不好。这是因为它没有保留失败性质的信息。

一个适合模拟可能失败的计算的数据类型是 Either。然而,如果你尝试用 Either 模拟返回 null 的计算,你会发现这个数据类型对于这些目的来说是多余的。这是因为每当有一个不返回值的计算时,你仍然需要从它返回某些东西。在错误场景中,我们可能已经做了以下操作:

def foo(x: Int): Option[Int] =
  if (x < 0) None
  else Some(math.sqrt(x))

然而,在相同的场景下,如果你使用 Either,你会返回什么?如果正确的结果应该由 Right 模拟,我们应该用 Left 模拟一个空的结果:

def foo(x: Int): Either[???, Int] =
  if (x < 0) Left(???)
  else Right(math.sqrt(x))

然而,Left 也应该包含一个值。我们在 Left 中返回什么?我们最好的选择是返回一个明确表示计算没有产生任何结果的字符串:

def foo(x: Int): Either[String, Int] =
  if (x < 0) Left("Can't take square root of a negative value")
  else Right(math.sqrt(x))

如你所见,在这个例子中,Either 的这种用法相当牵强。这意味着我们有一个副作用非常细粒度的情况,每个副作用都有自己的表示效果类型。这对于 Scala 来说是一个自然的情况,对于任何强类型语言来说也是如此。你的类型系统与副作用越匹配,你就可以越容易地模拟出副作用。

这也意味着当我们有多个副作用时,我们需要考虑我们将如何展示和组合它们。

具有多个副作用的应用

现代应用程序追求异步。这意味着每当您需要计算任何东西,而且可能需要很长时间,您不会以同步的方式计算。也就是说,您不会等待计算产生结果。相反,您以非阻塞的方式编程。这意味着您将计算调度到未来的某个时间点,并等待结果。

异步

考虑一个例子,一个网络服务器负责处理来自一个管理在线论坛的 Web 应用的 HTTP 请求。假设这些请求需要用数据库中所有论坛帖子的数据来响应。这意味着为了回复任何给定的 HTTP 请求,我们首先需要从数据库中获取所需的数据。解决方案可能如下所示:

def handle(request: Request): Response = {
  val posts: List[Post] = allPosts()
  respond(posts)
}

在前面的例子中,类型和方法声明如下:

type Request
type Response
type Post

def allPosts(): List[Post]

def respondA: Response

然而,这并不是我们的应用程序需要完成的唯一任务。考虑一下,我们的论坛是私有的,这意味着在给用户访问论坛之前,我们需要对用户进行认证。

现在,一种流行的认证方式是使用云认证解决方案。这意味着人们不需要在服务器端自己实现认证机制,而是将其外包给某些提供商。这种机制被称为认证即服务AaaS)。许多公司提供现成的服务,您可以在应用程序中使用这些服务进行认证。这种方法有很多好处,因为您不需要浪费时间实现认证逻辑,它不仅包括密码认证,还包括社交认证和复杂的安全机制,如双因素认证。

然而,如果我们使用基于云的认证解决方案,这意味着每次用户尝试登录时,我们很可能需要联系云。现代认证方法,如 JWT,意味着无状态认证。可以生成一个 JSON 标签,用户可以将其传递给服务器,服务器可以使用加密机制验证其真实性。因此,您不需要保持状态来认证一个人。然而,即使在这种情况下,您很可能还需要联系您的基于云的认证平台,以获取您使用的密钥以及验证用户确实存在于他们的数据库中。

所有这些与我们的函数式编程示例有什么关系?这里要注意的是,我们需要接触网络。联系云的操作是一个长时间运行的操作。暂时假设这个操作是以同步方式完成的,如下所示:

def handle(request: Request): Response = {
  val userToken: Token = request.token
  val authenticated: Boolean = authenticate(userToken)

  if (authenticated) {
    val posts: List[Post] = allPosts()
    respond(posts)
  }
  else respond("You are not authorized to perform this action")
}

以下声明被添加到我们的环境中:

type Token
type Request <: { def token: Token }

def authenticate(token: Token): Boolean

在前面的代码片段中,我们询问基于云的认证服务用户是否有权限执行该操作,如果有,我们回复论坛帖子。

想象一下服务器在高负载下的情况。立即,关于处理HTTP请求可以使用的线程数量的问题浮现出来。同样,还有一个问题,那就是一个网络服务器可以多快地处理任何给定的请求。假设我们有四个线程在处理任务。再想象一下,处理一个给定的请求大约需要一秒钟,因为涉及到与数据库和基于云的认证服务的联系延迟。服务器能承受多大的负载?

如果负载超过每秒四个请求,服务器将开始耗尽线程。想象一下服务器同时处理四个请求。这意味着分配给处理 HTTP 请求的四个线程将忙一秒钟。因此,服务器在处理完这四个请求之前将无法处理进一步的请求。这意味着,例如,如果有五个请求同时到达,前四个将在一秒钟内处理,第五个将需要两秒钟来处理。这是因为它将不会开始,直到前四个请求被处理。

处理这个请求的线程大多数时间实际上并没有做什么。在第一次调用云认证解决方案时,大部分的处理时间是在等待服务器回复。随后,当它调用数据库时,大部分时间是在等待数据库回复。如果线程大部分时间都在等待回复,这意味着处理器没有做任何有用的事情,它有一些空闲的功率和容量来处理其他任务,比如可能处理其他传入的请求。

正是这种推理使得同步处理方式在为高负载设计的应用程序中不可取。一个替代方案是异步方法。在异步方法中,每当有长时间运行的计算时,你使用异步原语将其调度到线程池中,例如,使用Future。然后,你指定任务完成后要做什么。异步应用程序的技巧是从不阻塞的异步原语构建。例如,从数据库请求信息的操作不应该涉及阻塞或等待响应,即使在未来的线程中也不行。关键是发起请求的线程在请求发出后可以自由地做其他任务,而且没有其他事情要做。

目标是利用非阻塞 API 来执行 HTTP 请求和对数据库的请求,这样你的线程,在 Java 世界中相当重型的原语,就不会阻塞,也不会等待响应,也不会浪费,相反,它们应该做些有用的事情。

异步风格的应用程序处理程序看起来如下:

def allPosts(): Future[List[Post]]

def authenticate(token: Token): Future[Boolean]

def handle(request: Request): Future[Response] =
  for {
    authenticated <- authenticate(request.token)
    response <- 
      if (authenticated) allPosts.map(respond)
      else Future { respond("You are not authorized to perform this
      action") }
  } yield response

处理单个请求仍然需要大约一秒钟的时间,因为你在请求外部资源时体验到的延迟仍然存在。然而,现在服务器在高负载下将表现得更好。这是因为线程本身不会在整个一秒钟内等待单个请求。相反,对他们来说,唯一算得上时间的是他们实际工作的时间,而不是他们等待外部资源回复的时间。

例如,如果给定请求的真实处理时间,即线程实际工作而不等待回复的时间,是 10 毫秒,那么单个线程每秒可以接受和处理 100 个请求。

前面的讨论描述了为什么在实践中你可能需要异步的副作用和回调编程。并且异步性是一个你可能想要隐藏在某种原语背后的副作用。

然而,异步性并不是你在编写 HTTP 处理器时想要抽象掉的唯一副作用。你可能还想抽象的另一个副作用是错误的副作用。现在,让我们详细讨论这个副作用。

错误的副作用

在前面的例子中,我们看到了事情并不总是顺利。事情可能在多个层面上出错。因此,当我们连接到云认证服务时,连接本身可能会出错。例如,服务器响应超时。或者,你的应用程序凭证错误,你最终无法访问你想要访问的云认证服务的功能。

当联系数据库时,也可能出错。例如,你可能无法与数据库建立连接。或者,由于某种原因,数据不在数据库中,或者数据格式不正确。

最后,应用程序的业务逻辑允许出现错误。这种情况发生在请求服务器数据的人没有权限查看他们请求的数据时。在这种情况下,我们需要回复一个错误消息而不是他们请求的数据:

if (authenticated) allPosts.map(respond)
else Future { respond("You are not authorized to perform this action") }

这里讨论的所有情况都是明确的证据,表明我们这里有一个潜在的错误的副作用。通常,我们会将这个副作用抽象成一个效果类型。然后,我们会使用flatMap函数来组合这些计算。然而,我们已经有了一个效果类型,Future,它抽象掉了请求处理的异步性。我们如何在这里引入另一个副作用呢?

首先,应该注意的是Future本身提供了一种错误报告的能力。然而,这是一个与Future实现相关的细节。完全可能想象异步原语提供异步抽象,但不捕获其内部发生的错误。所以在这里,我们将把情况看作是Future没有提供错误处理能力。

处理这种情况的一个简单方法是将计算返回一个FutureEither结果。例如,对数据库和云服务的查询将如下所示:

def allPosts(): Future[Either[String, List[Post]]]

def authenticate(token: Token): Future[Either[String, Boolean]]

在前面的代码中,我们将副作用一层层堆叠。这在实践中会工作吗?原则上,可以想象一个在Future下返回Either的计算。让我们看看我们将如何结合其他复杂情况使用它。之前,我们使用flatMap函数按顺序组合计算。在嵌套副作用的情况下,这种组合将如何看起来?

for {
  authenticated <- authenticate(request.token)
  response <- 
    if (authenticated) allPosts.map(respond)
    else Future { Left("You are not authorized to perform this action") }
} yield response

上述代码无法编译。错误如下:

如您所见,编译器指出我们在期望Boolean的位置使用了Either。因此,上面代码片段中的authenticated变量是Either而不是Boolean。然而,为什么我们会在 Monadic 流中处理Either?难道 Monadic 流的目的不是抽象效果类型,以便我们能够直接处理计算值,而不必担心效果类型吗?

实际上,我们可以用flatMap重写前面的示例,以提高可读性:

authenticate(request.token).flatMap { authenticated =>
  if (authenticated) allPosts.map(respond)
  else Future { Left("You are not authorized to perform this action") }
}

现在我们来检查所有涉及值的签名和类型。首先,让我们检查我们正在调用flatMap的类型,Future[Either[/*...*/]]。之后,我们就像处理从云认证服务检索的用户对象一样处理结果。然而,让我们再次看看Future定义的flatMap的签名:

因此,该函数已经有一个熟悉的签名:A => Future[B]。那么A是什么?它是Future的类型参数。我们特定的Future的类型参数是什么?这个类型参数是Either[String, User]

这意味着flatMap并没有给我们用户对象,而是Either。利用这个信息,我们可以修改示例:

def handle(request: Request): Future[Either[String, Response]] =
  authenticate(request.token).flatMap {
    case Right(authenticated) if authenticated =>
      allPosts.map { eitherPosts =>
        eitherPosts.map(respond)
      }

    case Right(authenticated) if !authenticated =>
      Future { Left("You are not authorized to perform this action") }

    case Left(error) => Future(Left(error))
  }

在这里,我们手动从Either中提取结果值。立即,我们可以说有些地方非常不对,需要修正。这种感觉不对是因为我们之前讨论过,拥有 Monadic 流和flatMap函数的目的是抽象效果类型。然而,在这里,我们需要明确地处理它们。

Monadic Transformers

这里的问题是 flatMap 函数是在 Future 上定义的。它对 Future 的类型参数一无所知。它可以是你能想象到的任何东西。实现没有对类型参数施加任何约束。这也意味着它对这个类型签名及其属性一无所知。因此,对于 Future 的 flatMap 函数的默认实现,无论是作为 Monad 还是 Future,都没有意识到 Future 的类型参数可能是另一个效果类型。所以,当我们以前面讨论的方式堆叠我们的效果类型时,只有最顶层的效应类型在调用 flatMap 函数时会被抽象化。

这种行为是不自然的。我们没有得到我们期望的结果。我们期望得到什么?我们期望不仅副作用会被展开,而且其内部副作用也会被展开。

当我们遇到嵌套的效果类型,并且期望它们被视为一个效果时,我们需要使用 Monad Transformers。

Monad Transformers 实际上是一种模式,可以用来定义已存在效果类型的可堆叠版本。例如,让我们看看在 cats 库中如何为 Either 类型定义这样的 Monad Transformer:

让我们看看这个 Monad Transformer 的签名,以了解它是什么。首先,注意这个案例类的类型参数。与普通 Either 的两个类型参数不同,我们这里有三个类型参数。第一个参数是一个 F[_] 效果类型,最后两个参数是普通的左右类型。这个效果类型正是赋予这个 Monad Transformer 可堆叠性的原因。因此,我们可以通过将这些效果类型泵入类型变量来与其他效果类型堆叠。注意 EitherT 构造函数的参数。该参数具有 F[Either[A, B]] 类型。

让我们设想在前面的例子中,变量效果类型是 Future。那么,EitherT 的值将是 Future[Either[A, B]]。这正是我们在前面的例子中尝试堆叠这两种效果类型时所拥有的签名。

因此,这个模式本质上是在效果类型的朴素堆叠之上构建的。然而,这里我们定义了一个专门用于堆叠的案例类。它是如何工作的,以及它是如何允许我们组合效果类型的?

首先,让我们看看这个数据类型是如何定义 flatMap 函数的:

正如我们在前面的截图中所看到的,这个方法接受一个作为参数的延续函数,这是我们已知的内容。然而,请注意延续函数的第一个参数。在这里它是BBEitherRight值的类型。Either被包裹在F类型下。所以,如果我们打算使用EitherT而不是FutureEither的简单组合,我们最终会得到一个flatMap函数,它正好是我们所寻找的。

然而,请注意,这个函数隐式地依赖于一个用于副作用类型的 Monad。这意味着为了从EitherT中提取结果,我们需要知道如何从你与之组合的效果类型中提取结果:

def flatMapAA >: A, D(implicit F: Monad[F]): EitherT[F, AA, D] =
  EitherT(F.flatMap(value) {
    case l @ Left(_) => F.pure(l.rightCast)
    case Right(b) => f(b).value
  })

因此,武装自己使用EitherT,让我们重写之前的例子:

def handle(request: Request): EitherT[Future, String, Response] =
  for {
    authenticated <- authenticate(request.token)
      .ensure("You are not authorized to perform this action")(identity)
    posts <- allPosts()
    response = respond(posts)
  } yield response

如你所见,我们现在所有的计算都是基于组合效果类型定义的。此外,我们能够保持我们的 Monadic 流程不变,这意味着我们不需要担心手动提取副作用计算的结果。

如果你查看EitherT的文档,你会看到它还为你提供了一组其他便利方法,你可以在嵌套效果类型的设置中使用这些方法。

模式泛化

显然,我们之前讨论的模式并不特定于EitherT。这是我们处理效果类型时经常会遇到的事情。这不仅仅关于FutureEither,而是关于组合两个独立类型。

由于这个任务从效果类型到效果类型都是重复的,它被泛化为一个模式。这个模式是这样的。首先,你选择一个你希望可以与其他任意效果类型组合的效果类型。然后,你定义这个类型的替代组合版本。这样,对于Either,我们已经定义了一个可组合的版本,即EitherT

之后,你需要定义给定效果类型的所有必要的类型类,这可以根据你需要使其工作的任何内容进行选择,包括你打算与该效果类型组合的效果类型的类型类。

如果你查看 cats 库的数据包,你会发现它有其他一些以字母T结尾的效果。这些是针对相应效果类型的 Monad Transformers 的实现。

在你的工具箱中拥有 Monad Transformers 的重要性在于,现在你能够像使用乐高积木一样从现有的效果类型构建效果类型。这大大增加了你的灵活性;现在你不需要定义专门的效果类型来表达你想要捕获的副作用。如果这些副作用可以表示为几个其他副作用的组合,你可以使用 Transformers 创建组合效果类型并在你的应用程序中使用它。

我们观察了两种效果类型的组合。但,你不仅限于两种效果类型的组合。实际上,这种模式足以将任何数量的效果类型组合成一个。例如,这样的组合可能看起来是这样的:

type Config
type Ef[A] = ReaderT[EitherT[Future, String, ?], Config, A]

由于 ReaderT 期望其第一个参数是一个效果类型 F[_],我们通过问号——EitherT[Future, String, ?]——手动在 EitherT 中创建一个空位。这种语法不是 Scala 的标准语法,而是来自在项目的 build.sbt 中导入的 Kind Projector 插件:

addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.4")

类型签名中的 ? 在类型签名中创建了一个未绑定的类型变量,它可以用来给类型赋予 F[_] 的形状。

ReaderT[EitherT[Future, String, ?], Config, A] 是一种 Transformer 表达方式,用于表示以下类型:

Config => Future[Either[String, A]]

因此,当我们谈论 Monad Transformers 模式时,首先和最重要的是我们在谈论灵活性。然而,这并不是唯一可以提供额外灵活性的模式,当你在纯函数式方式编写程序时。接下来,我们将看看无标签最终。

无标签最终

无标签最终(Tagless Final)是高级函数式编程中的一种流行模式,可以用来抽象那些你事先不知道且无法预测的能力和副作用。通常,了解其工作原理和为什么有用的最佳方式是查看一些示例。

基于能力编程

想象你正在编写一个需要在多个环境中执行的应用程序。在现实世界中,这样的场景很常见。一个很好的例子是移动应用程序。你可以拥有多个移动平台。然而,你希望将你的应用程序发布到所有这些平台上。现有的平台之间差异很大。通常,为每个平台单独重新实现你的应用程序是非常繁琐的。因此,你希望编写一次应用程序,并使其能够在当前所有存在的平台上运行。

另一个例子是编写应针对广泛配置工作的服务器端软件。例如,相同的服务器端软件针对不同的数据库执行。关系型数据库不同,适用于一个数据库的过程可能不适用于另一个数据库。

在所有上述场景中,你希望应用程序的业务逻辑不受你运行代码的系统特性的影响。在面向对象编程中,处理此类问题的标准方法是通过应用外观(Facade)模式。你声明一个接口,列出所有你需要从应用程序应该运行的底层系统中获取的能力。之后,对于每个特定的系统,你将为该接口提供一个实现。

从这次讨论中需要注意的关键点是能力。你的应用程序依赖于某些能力。它是根据指定这些能力的接口公开的方法构建的。这个想法也在无标记最终模式中得到了重申。

为了使模式更容易理解,让我们来一个简单的应用程序示例,这个应用程序依赖于某些能力。第一个将是从系统的数据存储中读取资源。资源是一个广泛的概念,可能包括一个文件系统上的文件,在其他环境中通过网络访问数据,或者访问存储在数据库和其他环境中的数据。另一个能力将是通知能力,这意味着应用程序能够通知最终用户它正在使用从存储中检索到的数据进行的工作。

给定这两个能力,我们可以编写一系列处理应用程序。一旦我们抽象掉了读取和通知的效果,我们就可以根据这些效果构建一个处理应用程序。

我们应该如何以函数式的方式定义这样的能力?什么最有意义?之前,我们讨论了类型类的想法。我们还注意到,类型类非常类似于工具箱,这意味着它为你提供了一组你可以用于特定目的的工具。这种类比非常适合我们存储能力的案例。在某种程度上,能力也是工具,工具可以组合成工具箱。因此,我们可以定义一个类型类,其中包含我们需要的所有能力,如下所示:

trait Capabilities[F[_]] {
  def resourceA: F[A]
  def notify(target: String, text: String): F[Unit]
}

注意,我们为f效果类型定义了类型类。由于我们正在函数式范式下工作,而且能力很可能会产生副作用,我们将通过一些我们可能事先不知道的效果类型来描述这些副作用。

接下来,我们可以用类型类提供的这些能力来定义一个应用程序。想象一下,我们将借助我们的能力来检索的资源是一些在线购物销售的报告:

Name,Price
Bread,2
Butter,4
Cabbage,3
Water,2

考虑到这份文档每天都会更新,我们应用程序的目标是计算一天内业务所赚取的金额,并通知所有者关于收入的情况。我们可以这样实现:

def income[F[_]](implicit M: Monad[F], C: Capabilities[F]): F[Unit] =
  for {
    contents <- C.resource("sales.csv")
    total = contents
      .split("\n").toList.tail // Collection of lines, drop the CSV header
      .map { _.split(",").toList match // List[Double] - prices of each
       of the entries
        { case name :: price :: Nil => price.toDouble }
      }
      .sum
    _ <- C.notify("admin@shop.com", s"Total income made today: $total")
  } yield ()

注意,前面的方法是如何根据F效果类型及其子类来定义的。注意,我们事先并不知道我们将使用哪种效果类型。然而,我们知道这个效果类型必须具备哪些能力——就是我们之前定义的能力,但我们还需要一个 Monad。这是因为我们需要按顺序组合我们在自定义类型类中定义的能力。然后,我们根据我们的能力来定义我们的应用程序。

在这里需要注意的一个重要事项是,我们的能力是如何在与其他类型类相同的语言中定义的。这意味着,潜在地,我们拥有cats库或任何其他函数式编程库的全部功能。

这就是无标签最终模式与门面模式不同的地方。在门面模式中,你有接口,这些接口隐藏了平台特定的复杂功能,这些功能你并不关心。然而,这就是全部。每次我们调用这样的能力时,我们都会得到你请求的结果,没有其他。

在无标签最终模式中,每次调用能力方法时,你都会在效果类型下得到一个结果。效果类型是一种具有某些属性的数据结构。你可能无法控制能力返回的确切结果,因为它具有系统特定性。然而,你控制着返回此结果的数据结构。所涉及的数据结构由类型参数指定。不同的数据结构具有不同的能力。在其他函数中,当我们指定我们隐式需要 Monad 时,我们陈述了关于我们将要工作的结构的一些内容。我们可以声明我们的效果类型应该是顺序可组合的。同样,通过指定对其他 cats 类型类的隐式依赖,我们可以声明我们对数据结构的其他要求。

这种对我们正在工作的数据结构的控制使我们能够控制我们的计算如何组合。因此,在门面模式中,只有平台特定的能力本身被抽象化,仅此而已。然而,在无标签最终模式中,不仅计算被抽象化,而且我们在f效果类型下组合程序的方式也被抽象化。

现在,一旦我们有了抽象程序的组合,一个合理的问题就是我们如何实际上在不同的环境中运行它?

实现方式

为了在给定的环境中运行此应用程序,我们需要指定我们将要工作的效果类型。除了目标效果类型之外,我们还需要找到我们函数所需的全部隐式依赖的实现。无标签最终模式的美丽之处在于,只要我们能提供此环境以及我们选择的效果类型的隐式依赖实现,我们就可以在任意环境中运行该函数。

第一步是指定效果类型。一个好的选择是 Future,因为它是一种并发原语,能够表示广泛的计算。因此,当效果类型设置为 Future 时,我们的方法调用将如下所示:

import scala.concurrent.{ Future, Await }
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.Duration

import cats._, cats.implicits._

/*...*/
Await.result(income[Future], Duration.Inf) // Block so that the application does not exit prematurely

然而,前面的代码将无法编译,因为我们还没有为 Future 实现Capabilities类型类。

现在我们讨论我们需要的能力时,我们需要问自己我们正在哪个环境中工作。记住,能力抽象了不同环境之间的不同操作。首先,让我们想象我们正在一个普通的桌面环境和一个命令行应用程序场景中工作。在这种情况下,我们的检索资源能力将仅仅是读取一个标准目录中给定名称的文件。我们的通知能力将输出打印到命令行:

implicit val capabilities: Capabilities[Future] = new Capabilities[Future] {
  import java.io.File
  import org.apache.commons.io.FileUtils

  def resource(name: String): Future[String] =
    Future { FileUtils.readFileToString(new File(name)) }

  def notify(target: String, text: String): Future[Unit] =
    Future { println(s"Notifying $target: $text") }
}

我们使用 Apache Commons IO 库方便地读取文件,我们在并发原语 future 下做所有的事情,因为这是我们应用程序的要求。注意,所有关于通知和读取文件的技术细节都集中在那个类型类中,以至于我们只能在类型类的范围内导入FileFileUtils类,而不能在整个文件的范围内导入。

一旦我们将前面的应用程序运行在给定的文件上,我们将收到以下输出:

Notifying admin@shop.com: Total income made today: 11.0

如果我们现在需要在一个使用数据库存储数据的环境中运行应用程序怎么办?如果通知是通过电子邮件而不是通过命令行完成的怎么办?没问题,我们仍然可以使用同一个应用程序。然而,我们将需要为此环境提供一个自定义的能力实现:

implicit val anotherEnvironmentCapabilities: Capabilities[Future] = new Capabilities[Future] {
  def resource(name: String): Future[String] = ???
  def notify(target: String, text: String): Future[Unit] = ???
}

在前面的代码中,我们通过桩(stubs)实现了类型类,因为实现数据库查询逻辑和电子邮件通知逻辑可能相当繁琐。然而,我们的应用程序现在可以编译了。所以,如果你替换前面类型类中的数据库查询和电子邮件分发的实现,你也将能够成功运行应用程序。由于这个实现超出了本书的范围,并且不会对 Tagless Final 模式的讨论带来任何价值,所以我们不会在这里提供实现。

以类似的方式,我们能够为几乎任何你可以想象的平台提供实现。

你也可以用外观模式(Facade pattern)做得很好。那么,Tagless Final 模式比外观模式更强大在哪里?为什么你会选择它而不是面向对象模式?讨论 Tagless Final 模式的力量时的关键细节是注意到我们的应用程序不仅依赖于能力类型类,还依赖于 Monad 类型类。那么,这如何比外观模式带来更多的力量?让我们看看。

执行语义抽象

能力类型类与Monad类型类处于同等地位。Monad类型类定义了您如何顺序组合计算。有句话说,单子是函数式编程的分号。人们为什么会这么说?分号在面向对象编程中扮演什么角色?在常规的命令式编程中,分号是一个分隔一个语句与另一个语句的符号。分号的意义是,一个语句应该在另一个语句之后执行。在某种意义上,您可以将分号视为两个计算顺序组合的运算符。

flatMap函数在函数式编程世界中正是为此目的而设计的。flatMap定义了如何顺序组合两个计算。因此,这使得它成为函数式编程中的分号。此外,在本书的早期,我们探讨了单子流模式。我们知道单子流在底层依赖于flatMap来表示顺序组合的函数式代码。这种方式与命令式分号非常相似。

这对于我们讨论无标签最终模式的优点有何重要意义?关键在于,依赖于外观(Facade)的应用通常不会依赖于任何类似于单子(Monad)的东西。

这意味着您可以在不同的环境中用不同的外观(Facade)替换您的应用程序。然而,您永远无法改变您语句顺序执行的语义。

让我们通过一个例子来看看它是如何工作的。假设我们需要在执行前面的应用程序时进行日志记录。我们该如何实现这一点?我们可以提供一个自定义的Monad实现,其中包含其flatMap函数,顺序组合运算符被重载以记录我们所需的所有内容:

implicit val logMonad: Monad[Future] = new Monad[Future] {
  def flatMapA, B(f: (A) ⇒ Future[B]): Future[B] =
    fa.flatMap { x =>
      println(s"Trace of the Future's result: $x")
      f(x) }

您可以通过显式地将income方法指向要使用的类型类来运行应用程序:

Await.result(incomeFuture, Duration.Inf) // Block so that the application does not exit prematurely

输出结果将如下所示:

在这里,我们能够覆盖序列组合的语义。

在命令式世界中能否实现类似的效果?让我们看看前面的例子如何使用外观(Facade)模式实现:

trait Capabilities {
  def resource(name: String): String
  def notify(target: String, text: String): Unit
}

def income(c: Capabilities): Unit = {
  val contents = c.resource("sales.csv")
  val total = contents
    .split("\n").toList.tail // Collection of lines, drop the CSV header
    .map { _.split(",").toList match // List[Double] - prices of each of the entries
      { case name :: price :: Nil => price.toDouble }
    }
    .sum
  c.notify("admin@shop.com", s"Total income made today: $total")
}

在前面的代码中,我们有一个接口,方法income依赖于执行计算的类型类。

我们无法覆盖序列组合,因为唯一的控制点在于接口。我们无法控制计算是如何执行以及如何相互组合的。

但为什么在函数式世界中可以做到这一点,而在命令式世界中却不行?是什么使得函数式方法如此特别,使我们能够进行这种组合语义的抽象?

计算作为值

当讨论副作用和抽象时,我们提到函数式编程追求纯净性。每当有计算带有副作用时,我们都会将这个计算具体化为某个值。在这里,这个值是Future。我们所有的计算都被具体化为 Future。并且我们可以使用为其定义的运算符来组合Future

在命令式世界中,我们无法执行类似的计算组合,因为计算并没有被具体化为值。在命令式世界中,我们无法随意操作计算,因为那里计算并不是一个实体。我们无法引用计算,至少没有明显的方法。当然,在 Java 中,我们可以将计算放在 Runnable 接口下。然而,这将相当繁琐。在函数式世界中,Monads 在顺序组合中无处不在。所有东西都是通过flatMap来组合的。在 Java 中,将所有东西都包裹在 Runnable 中会引入过多的架构开销,所以这并不值得。

然而,有人可能会争论 Future 并不纯净。每当实例化 Future 时,我们都会给出一个启动计算的指令。是否存在一个更强的无标记最终模式版本,为我们提供更多的表达能力?

Free Monad

Free Monad 模式是 Tagless Final 模式的更强版本。实际上,自由对象是来自抽象代数的一个结构。因此,这个名字来源于这个领域。

这个模式的适用范围相当有限,很可能在刚开始纯函数式编程时,我们不会遇到任何对这种模式的真实需求。所以在这里我们不会深入探讨 Free Monad。然而,我们将一般性地描述它是如何工作的。

基本上,Free Monad 背后的想法是,所有的计算都变成了一个值。这个想法是,每当定义我们的应用程序时,它实际上并没有执行自己,而是构建了一个描述我们可以稍后运行的抽象语法树。然后执行它就是我们的责任。

这个模式相当重量级,所以前面的例子只是概述了它。另一个需要注意的事情是,无论何时我们需要应用 Free Monad 模式,我们仍然可以利用 Tagless Final 模式。在这里,我们看到了定义一个自定义的 Monad 实现如何有助于注入自定义功能,例如日志记录。同样的想法可以用来构建我们的应用程序的树。通常,顺序组合的意义是依次运行一个语句。很容易想象一个顺序组合的实现,其中语句不是依次执行,而是被具体化为树节点,并注入到一个代表应用程序的树中。记住,flatMap完全控制着如何继续计算。所以想象一个flatMap函数不运行语句而是使用它们来构建一个树是完全正常的。

我们为什么想要使用 Free Monad 模式呢?这里的想法是能够对多个解释器运行计算。一个具体的例子来自用于处理 SQL 的doobie库。在 Doobie 中,一个 SQL 语句可以写成以下形式:

sql"""select * from customers where id = $id""")

在这里,我们使用字符串插值,这是 Scala 的一个特性,允许我们在编译时从字符串生成对象。

执行这个语句之后,我们将对该对象进行几次调用,以指定我们想要对 SQL 语句执行的操作,例如我们是否想要查询或更新数据库。例如,如果我们想要查询数据库,我们可以做以下操作:

sql"""select * from customers where id = $id""")
  .query[User].unique.transact(tr)

当从编程语言处理 SQL 时,一个常见的任务是尽可能提供安全性,在调用数据库时。例如,我们可能想要回答我们的查询是否形成正确的问题。我们可能想要有一个完整的测试套件,其中包含我们正在使用的所有查询,并测试它们以检查它们是否形成正确,以及它们的返回类型是否是我们预期的。

内部,Doobie 使用自由对象来表示其 SQL 查询,这意味着它们只是指定对数据库执行的计算的数据结构。由于它只是计算的描述而不是计算本身,我们可以运行它或对它做任何其他事情。我们可能想要做的事情之一是检查它是否满足某些规则。这是在 Doobie 中完成的。在这里,我们可以运行我们的查询,要么是对查询数据库的解释器,要么是检查它们的正确性。

所以基本上,在这种情况下,当我们有一个可能想要在不同的解释器上运行的计算,或者我们可能想要传递并修改其他计算时,我们可能想要使用 Free Monad。

应该提醒一点,这个模式相当重量级,如果没有充分的理由,就不应该使用它,否则在架构方面的开销将会相当高。

说到应用程序的安全性,如果你尽可能在编译时执行尽可能多的计算,你就可以达到很高的安全性和稳定性。编程到类型类的基本原则是拥有一个强大的编译器,它具有强大的类型系统,能够为你注入适当的类型类。这样的强大编译器能否被用来执行不仅仅是注入能力和类型类到你的计算中呢?让我们在下一节进入类型级编程的世界。

类型级编程

编译器的任务是把你编写的程序从一套指令翻译成另一套指令。在高级语言中,你将易于阅读和编写的高级指令翻译成易于机器执行的低级指令。

由于编译器需要将一套符号转换成另一套符号,它构建了你正在编写的程序的一些内部模型。从某种意义上说,我们可以认为编译器理解了程序,这里的“理解”有特定的定义。

如果编译器以某种方式构建内部模型或理解你的程序,我们也可以利用编译器的力量来让编译器检查程序的正确性。我们可以让编译器强制执行某些风格或保证,你的程序必须遵守。我们已经看到了annotation.tailrec的例子。注解的目的是指示编译器检查注解函数并确保它具有某些保证。具体来说,编译器检查函数是否是尾递归的。

为了确保程序的正确性,我们可能会使用强类型语言,并在类型中编码程序的保证和语义。类型是编译器所知的,因此它可以对这些类型执行某些检查。例如,它可以确保你向函数提供了正确的类型参数,因为它知道函数的输入类型。因此,在强类型语言中,你不再可能犯向函数传递错误类型参数的错误。

强大的编译器检查程序中的错误的优势在于你能够在编译时捕获更多的错误。一般来说,在编译时捕获的错误更容易调试和修复。如果在运行时发生错误,这意味着它没有立即被发现。你可能会将应用程序发布给最终用户,对于特别棘手的错误,可能需要数月甚至数年才能被发现。一旦被发现,你需要自己调查整个代码库中的错误行为,并尝试重现它,以便修复它。

编译时错误立即显现。当你编译代码时,你会确切地看到你出错的地方。因此,确保编译器尽可能多地捕获错误是一个明显的优势。

我们能否进一步推动我们的编译器?让我们看看 Scala 中类型级编程的一个例子。它被称为类型级编程,因为我们旨在尽可能在类型中编码关于我们程序的各种保证。这样,这些保证在编译时进行检查。

异构列表的简单实现

考虑我们有以下列表:

val list: List[Any] = List(0, 2.0, "3", Fraction(4, 2))

因此,在前面的列表中,我们有不同类型的元素,因此我们被迫将此列表声明为List[Any]Fraction是为了我们的示例定义的,如下所示:

case class Fraction(numerator: Int, denominator: Int)

注意,在前面的列表中,每个元素都可以表示为一个浮点数。它们有不同的类型,但可以在所有这些类型上定义某些共同的行为。由于元素之间非常相似,我们可能想要对它们执行一个共同的操作。例如,我们可能想要找到列表中所有数字的总和:

val sum = list.map {
  case x: Int => x.toDouble
  case x: Double => x
  case x: String => x.toDouble
  case Fraction(n, d) => n / d.toDouble
}.sum

注意,我们无法立即求和所有元素,因为列表的类型是List[Any]。我们只能加起来由数字组成的列表。因此,我们将我们的列表映射为List[Double]。然后,我们在这个列表上调用相同的方法,这是由 Scala 集合框架定义的所有数值集合的标准方法。

在下面的截图中,我们可以看到程序的输出:

然而,请注意map方法的主体。map方法的主体实际上是一个部分函数。部分函数是一个不保证能够处理其定义在整个域上的整个函数。让我们看看map函数的签名,如下所示:

在前面的截图里,你可以看到它期望从列表元素的类型到某种其他类型B的函数。我们元素的类型是任何类型。然而,我们传递给函数的函数是一个部分函数,这意味着它只能处理 Scala 中所有可能对象的一个子集。

部分函数的问题在于它们是在运行时计算的。这意味着,如果你忘记在部分函数中指定某个子句,你将在运行时发现这一点,此时应用程序将抛出一个匹配错误,表示部分函数无法处理其输入。让我们模拟这种情况:

val sum = list.map {
  case x: Int => x.toDouble
  case x: Double => x
  case x: String => x.toDouble
  // case Fraction(n, d) => n / d.toDouble
}.sum

我们已经注释掉了我们用于映射列表的部分函数中的一个case子句。如果我们运行这个应用程序会发生什么?请看以下内容:

如你所见,抛出了一个异常。程序编译成功,然而,当我们尝试运行它时,我们遇到了异常。这意味着在运行时出了问题。有没有办法让编译器跟踪这些错误,并在编译时处理它们?

异构列表问题的类型级解决方案

解决这个问题的策略是将我们想要拥有的保证编码到类型中。我们将利用高度发展的隐式解析机制来强制执行对特定类型的保证。一般想法是,通过一个隐式值来表示保证。如果我们需要关于我们程序的一定保证,我们使我们的程序隐式依赖于代表这个保证的值。

但问题是,如果编译器找不到程序所依赖的任何隐式值,应用程序将无法编译。因此,通过使程序依赖于代表我们保证的隐式值,并确保这些值仅在保证得到满足的情况下存在于作用域中,我们可以确保编译器不会编译不满足这些保证的程序。

然而,首先,我们需要类型。前面的List[Any]解决方案对我们应用程序来说不好,因为编译器没有关于列表元素精确类型的信息。相反,我们可以定义一个称为异构列表的数据结构。这个新列表的代码如下:

package jvm
/*...*/
sealed trait HList {
  def :::H: H ::: this.type = jvm.:::(h, this)
}
case class :::+H, +T <: HList extends HList {
  override def toString() = s"$head ::: $tail"
}
trait HNil extends HList
case object HNil extends HNil

在上面,jvm是这个书中 JVM 示例实现的包。

异构列表是一种递归数据结构。如果你看一下:::值,这个case类由头部和尾部组成。头部可以是任何你喜欢的,而尾部必须是另一个异构列表的实例。递归数据类型的终端情况是HNil

这里是如何在您的应用程序中定义此类列表的方法:

val hlist: String ::: Int ::: Fraction ::: HNil =
  "1" ::: 2 ::: Fraction(3, 4) ::: HNil

注意这个列表如何知道每个参数的类型。此外,注意在 Scala 中,我们可以利用中缀表示法,这允许我们使用类型名称作为运算符。看看以下类型:

String ::: Int ::: Fraction ::: HNil

它等价于以下标准表示法:

:::[String, :::[Int, :::[Fraction, HNil]]]

这是一种语法糖,用于简化所谓的代数数据类型。代数数据类型是以类型安全的方式由其他数据类型组成的类型。

接下来,我们如何定义一个将在异构列表上计算总和的应用程序?之前,在一个部分函数示例中,我们看到了另一个编译时安全性的问题。在这里,我们希望将某些保证纳入我们的代码。我们希望编译器检查这些保证。你希望施加哪些保证?首先,我们需要确保我们的列表可以被映射到一个所有元素都是双精度浮点数的列表。之前,我们使用只运行在运行时的部分函数来完成这个任务。在这里,我们同意使用隐式来指定关于我们应用程序的保证。因此,我们的求和方法可以这样:

def sumSimpleL <: HList(implicit m: MapToDouble[L]): Double

MapToDouble是我们刚刚提出的一个类型类。类型类的任务是转换一个异构列表,使其所有元素都是双精度浮点数。请注意,我们向这个类型类传递一个类型参数。它是L类型,它扩展了异构列表。L扩展了异构列表,是一个代数数据类型,这意味着它是一个复合数据类型,由这个列表中所有元素的类型组成。这意味着在编译时,类型类将知道这个异构列表中所有元素的类型,这意味着我们可以定义类型类,使得如果列表的成员不能转换为双精度浮点数,我们就无法解析它。

这些是我们需要的所有保证吗?原则上,一旦我们有一个保证,并且有一种方法将我们的异构列表转换为双精度浮点数列表,递归遍历数据结构并求和所有值可能并不困难。所以,使用MapToDouble类型类的求和方法只能实现如下:

def sumSimpleL <: HList(implicit m: MapToDouble[L]): Double = {
  val mapped: m.Result = m.map(hlist)
  def loop(l: HList): Double = l match {
    case :::(h: Double, t) => h + loop(t)
    case HNil => 0
  }
  loop(mapped)
}

注意,我们再次使用部分函数来抽象异构列表的递归数据结构中的值。一旦我们达到HNil,我们就终止我们的或问题。然而,我们之前已经论证过,部分函数是糟糕的,因为它们可能在运行时失败。因此,了解我们如何避免在这里使用部分函数是有教育意义的。

因此,让我们介绍一个新的类型类,它负责在异构列表上计算求和。我们可以这样做:

def sumL <: HList, LR <: HList(implicit m: MapToDouble.Aux[L, LR], s: Sum[LR]): Double =
  s.sum(m.map(hlist))

因此,我们将我们的映射能力和求和能力外包给刚刚讨论过的类型类。然而,MapToDouble类型类末尾的Aux是什么意思?还有,那个新添加到求和函数类型中的LR类型参数是什么?

基本上,LR是这个列表通过MapToDouble映射的类型。所以,对于以下类型的列表:

String ::: Int ::: Fraction ::: HNil

这个类型将是以下这样:

Double ::: Double ::: Double ::: HNil

在前面的代码中,你可以看到辅助模式。它的全部要点在于我们不知道LR类型变量,而是依赖 Scala 编译器来计算它。编译器通过隐式解析MapToDouble类型类来计算LR类型。总体来说,Scala 编译器能够利用隐式机制来计算整个类型,并将结果存储在类型参数变量中,然后,我们可以在其他类型类的隐式解析中重用它。因此,我们使用这种由辅助机制计算出的类型来解析Sum类型类。

如果我们事先知道所有的元素都是双精度浮点数,为什么还需要计算HList类型呢?我们可能知道元素的类型,但仍然需要编译器的帮助来知道将多少个双精度浮点数堆叠到结果HList中。记住,HList存储了每个元素的类型,所以如果我们说所有的元素都是双精度浮点数,我们仍然需要知道列表的长度来构建它。在这里,编译器能够帮助我们完成这个任务。

那么,辅助模式究竟是如何工作的,编译器又是如何能够计算新类型的呢?为了回答这个问题,让我们继续到我们类型类的定义。让我们从MapToDouble类型类开始:

trait MapToDouble[L <: HList] {
  type Result <: HList
  def map(l: L): Result
}

注意,类型类有两个成员。第一个是Result类型,它表示将给定的异构列表映射为所有成员都是双精度浮点数的结果类型。然后,我们有执行自身map操作的方法。注意,MapToDouble只有一个类型参数L

Result类型是一个抽象类型,这意味着它取决于类型类的实现来定义它。我们很快就会看到如何利用这种能力来计算结果类型。

抓住问题的关键在于,从技术上讲,结果类型并没有反映在类型类的类型中。这意味着,每当我们需要这个类型类,对于某种L类型,我们都可以这样解决它:

type L = String ::: Int ::: Fraction ::: HNil
val mapper: MapToDouble[L] = implicitly[MapToDouble[L]]

在前面的代码中,隐式方法定义如下:

图片

所以基本上,这个方法是一个用于解决特定类型隐式值的实用工具。

注意,当我们请求对这个类型类的隐式依赖时,我们没有指定结果类型,这意味着我们在解决它们的隐式依赖时不需要知道这个类型。现在,想象一下,如果你的类型类定义如下:

trait MapToDoubleNoAux[L <: HList, Result <: HList] {
  def map(l: L): Result
}

现在,有了前面定义的类型类,我们不再能够不知道它将要计算的结果就解决它。实际上,我们除了以下方式外没有其他方法来引用这个类型类:

type In = String ::: Int ::: Fraction ::: HNil
type Out = Double ::: Double ::: Double ::: HNil
val mapper: MapToDoubleNoAux[In, Out] = implicitly[MapToDoubleNoAux[In, Out]]

由于类型类有两个类型参数,我们必须提供这两个参数。我们可能知道第一个类型参数,因为它存在于我们的程序中,在我们的HList的类型中。我们的异构列表类型是由编译器在我们构造列表时为我们构建的。然而,将这个列表转换为双精度浮点数列表的结果目前对编译器来说并不清楚。我们必须提供它来解决隐式依赖。因此,我们被迫自己计算它。

有没有一种方法可以让编译器计算,类似于它在构造列表时计算列表类型的方式?为此,我们有辅助模式。

辅助模式如下所示:

object MapToDouble {
  type Aux[L <: HList, LR <: HList] = MapToDouble[L] { type Result = LR }
  /*...*/
}

因此,这仅仅是我们类型类伴随对象中的一个类型定义。请注意,在这里我们正在使用结构化类型来定义我们的辅助类型。前面的程序表明,Aux类型的MapToDouble将包含一个类型成员Result,并将其设置为某个类型变量。

问题是,我们仍然能够在一个单一的Aux类型中捕获对我们有意义的两个类型变量,即输入和输出类型。然而,当我们尝试隐式解析类型类时,我们不再必须知道这两个类型。

因此,我们可以隐式地依赖于Aux类型,编译器在不知道第二个类型参数的情况下解析此类型:

def sumL <: HList, LR <: HList(implicit m: MapToDouble.Aux[L, LR], s: Sum[LR]): Double

在这种情况下,解析将如下进行:

  1. 编译器将推断第一个类型参数L。它将通过查看输入参数来推断它。所以,你不需要明确提供它。

  2. 它将开始隐式解析。首先,我们需要MapToDouble.Aux。此类型扩展为MapToDouble[L] { type Result = LR }类型。

  3. 编译器将解释为解析MapToDouble类型,其第一个类型参数必须是LAux类型的第二个类型参数是未知的,但这不是问题,因为我们之前讨论过,编译器不需要它来解析类型类。然而,这个第二个类型参数有一个名称,并且绑定到某个类型,这是我们将要解析的类型类的一个成员。因此,编译器将从MapToDoubleResult类型中推断这个第二个类型参数。

  4. 在解析了这个类型类之后,我们现在有了LR类型参数。现在我们可以将其用作下一个隐式解析的输入——即Sum[LR]的解析。

前面的逻辑之所以有效,以及编译器不需要MapToDouble.Aux的第二个类型参数LR的原因如下。MapToDouble类型的类型不包括第二个类型参数LR,而MapToDouble.AuxMapToDouble的一个别名。LMapToDouble的类型参数,这是编译器解析类型类所需的所有信息。如果你这么想,在隐式解析某个东西或指定某个变量的类型时,你不需要明确指定这个变量的成员。由于第二个类型参数是待解析对象的成员,我们在解析类型时不需要知道它。

辅助模式用于捕获类型类中的结构化成员类型,并将其作为可以从方法签名隐式组中引用的类型参数。

所以基本上,这全部关于在调用时将我们不知道的类型作为类型类的结构成员,而不是这些类型类的类型参数。这样,我们将不知道并且需要计算的类型将不会是类型类签名中的成员。因此,我们可以在不知道其成员类型的情况下解决这些类型类。所以,我们可以在隐式解析时间上计算这些类型,利用代数数据类型原理。我们将在下面的代码片段中看到我们如何确切地使用隐式解析机制来计算这些类型。

这就是我们的函数如何应用于异构列表:

val s = sum(hlist)
println(s"Sum of $hlist is $s")

运行程序后的输出如下:

Sum of 1 ::: 2 ::: Fraction(3,4) ::: HNil is 3.75

接下来,让我们看看隐式机制是如何解决我们需要的所有依赖的。

隐式解析的递归性

让我们大致了解一下 MapToDouble 伴生对象的成员看起来是什么样子:

object MapToDouble {
  type Aux[L <: HList, LR <: HList] = MapToDouble[L] { type Result = LR }
  def applyL <: HList = m

  implicit def hconsH, T <: HList, TR <: HList: Aux[H ::: T, Double ::: TR]

  implicit def hnil[H <: HNil]: MapToDouble.Aux[H, HNil]
}

所以正如你所看到的,我们有一个辅助类型,以及 apply 函数定义,这些是类型类模式中常见的。我们还有两个隐式成员,它们为 hlist 定义了 MapToDouble 类型类的实现。由于 hlist 有两种可能的实例,所以我们有两种可能的 MapToDouble 实现,分别是 :::HNil

注意这两个隐式值所具有的隐式依赖。首先,hnil 不依赖于任何东西。让我们看看它的主体:

implicit def hnil[H <: HNil]: MapToDouble.Aux[H, HNil] = new MapToDouble[H] {

  type Result = HNil
  def map(h: H) = HNil
}

映射一个空异构列表的结果只是另一个空异构列表。这是因为我们没有可以映射的内容。

hcons 然而,有隐式依赖。正如你在前面的例子中看到的,它隐式地依赖于两个其他类型类:ToDoubleMapToDouble。正如你所看到的,ToDouble 是由 H 类型参数化的。H 类型是我们异构列表的头部类型。任何异构列表都是根据其头部类型和尾部类型定义的。

所有这些隐式依赖的含义是,如果我们能够将其头部转换为双精度浮点数,并且如果我们能够将其尾部映射为双精度浮点数列表,那么我们就能将任何非空异构列表转换为双精度浮点数列表。ToDouble 类型类的定义如下:

trait ToDouble[T] {
  def toDouble(t: T): Double
}

关于这些隐式依赖的一个关键点是它们是递归的。hcons 本身是 MapToDouble.Aux 类型。但是,为了生成这个 MapToDouble,我们还需要对于所讨论的异构列表的尾部有 MapToDouble

由于 Scala 编译器能够递归地解析隐式依赖,我们能够使一个类型的一个隐式依赖递归地依赖于同一类型的隐式依赖。在这里需要注意的,就像任何其他递归一样,是确保它能够终止。这意味着,在递归的每一步,我们必须在某种定义的意义上更接近递归的终止情况。在我们的情况下,递归的终止情况是HNil。并且,在每一步中,我们都会为比前一个列表少一个元素的异构列表解析MapToDouble类型类,因为我们只取尾部而不取头部。这保证了递归将终止。

现在,让我们看看hcons的实现:

implicit def hconsH, T <: HList, TR <: HList: Aux[H ::: T, Double ::: TR] = new MapToDouble[H ::: T] {

  type Result = Double ::: TR
  def map(l: H ::: T): Double ::: TR =
    td.toDouble(l.head) ::: md.map(l.tail)
}

map函数的主体是用ToDouble类型类定义的,该类型类将头部转换为双精度浮点数,然后使用MapToDouble类型类将尾部转换为双精度浮点数。

以类似的方式,我们可以定义我们将要使用的Sum类型类,以便计算包含所有双精度浮点数的异构列表的总和:

trait Sum[L] {
  def sum(l: L): Double
}

object Sum {
  def applyL <: HList = s

  implicit def hconsT <: HList: Sum[Double ::: T] =
    { (l: Double ::: T) => l.head + st.sum(l.tail) }

  implicit def hnil[H <: HNil]: Sum[H] =
    { (x: HNil) => 0 }
}

在伴随对象中,我们也有两种递归隐式解析的情况——一种是终止情况,另一种是非终止情况。终止情况是hnil,非终止情况是hcons。终止情况很简单,因为如果我们取一个空列表,它的总和总是0,因为在这个列表中没有元素。然而,如果我们有一个非空列表,我们计算其总和的能力取决于我们计算其尾部总和的能力。如果我们能够根据其隐式依赖通知计算其尾部的总和,我们就可以计算尾部的总和,并将头部的值加到它上面。

最后,我们还没有讨论的最后一块是ToDouble类型类的实现:

trait ToDouble[T] {
  def toDouble(t: T): Double
}

object ToDouble {
  def applyT = t

  implicit def double: ToDouble[Double] = identity
  implicit def int : ToDouble[Int ] = _.toDouble
  implicit def string: ToDouble[String] = _.toDouble

  implicit def fraction: ToDouble[Fraction] =
    f => f.numerator / f.denominator.toDouble
}

如我们所见,ToDouble类型类为我们将要实际使用的每个类型都实现了。

使用类型类方法和类型级计算而不是简单地使用递归函数进行递归模式匹配的好处是什么?答案是编译时安全性。记得在本章开头,我们是如何论证在递归模式匹配的情况下,如果我们忘记对一个单个实例执行模式匹配,我们只有在运行时才会发现我们的错误吗?让我们看看在类型级计算的情况下,如果我们未能指定一个情况会发生什么。

类型级计算场景中模式匹配的等价物是ToDouble类型类的实现。如果你看一下它们,它们指定了如何精确地将每种类型转换。让我们看看如果我们注释掉我们需要的实现之一会发生什么:

implicit def double: ToDouble[Double] = identity
implicit def int : ToDouble[Int ] = _.toDouble
implicit def string: ToDouble[String] = _.toDouble

// implicit def fraction: ToDouble[Fraction] =
// f => f.numerator / f.denominator.toDouble

现在,让我们看看当我们运行程序时会发生什么:

图片

这次错误是一个编译时错误,因此应用程序甚至没有编译。然而,请注意错误信息相当晦涩难懂。接下来,我们将看看如何调试这类编译时信息。类型级计算在高级语言如 Scala 中引发了一种全新的编程风格,因此了解如何在当前环境中处理编译时错误是至关重要的。

调试类型级计算

目前,类型级计算代表了现代编程技术的最前沿。这项技术相当实验性,因此,它还没有太多关于全面错误信息和调试工具的支持。

因此,前面的错误信息可以通过利用代数数据类型逐步调试。这意味着你需要追踪递归的每一步,并确保每一步中的每个隐式类型都正确解决。在某个时刻,你会到达解决产生错误的地方,然后你可以看到哪个情况产生了错误。

这些检查可以使用 Scala 中的implicitly关键字来完成。使用implicitly函数进行调试的过程可能如下所示:

implicitly[MapToDouble[String ::: Int ::: Fraction ::: HNil]]
implicitly[MapToDouble[Int ::: Fraction ::: HNil]]
implicitly[MapToDouble[Fraction ::: HNil]]
implicitly[ToDouble[Fraction]]

前面程序编译的输出将如下所示:

图片

因此,错误是由于编译器无法找到分数的隐式ToDouble类型类。注意,随着每个语句的执行,我们通过减小我们的HList代数类型的大小逐渐缩小搜索空间。在每一步中,我们都在查看错误是否会显现。最后,我们到达了implicitly[ToDouble[Fraction]],并意识到在作用域中没有这样的隐式类型类实现。请注意,所有前面的错误都发生在编译时。

类型级计算的现状可能不如你期望的那么好。然而,你应该记住这项技术仍然是实验性的。Scala 本身是一种语言,是实验新技术的游乐场。所以,主要的要点是这项新技术通过利用类型系统和隐式解析的力量,将运行时错误纳入编译时范围,从而在类型方面为你的程序指定某些保证。在未来,随着这些技术的更广泛采用,可以合理地预期这些技术将获得更好的工具支持。

类型级编程库

就像在 Scala 中,cats是一个纯函数式编程库一样,存在一些库可以简化 Scala 中的类型级编程。其中一个这样的库是Shapeless。实际上,Shapeless是和cats相同的生态系统的一部分。它提供了一系列类和类型,包括异构列表类型,这些类型有助于在类型级别上进行一些高级的纯函数式编程。

这种方法值得有一本自己的书,因此我们在这章中不会深入探讨。如果您想了解更多关于这种方法的信息,请查阅Shapeless的官方文档和学习资源。

摘要

在本章中,我们学习了高级函数式编程的模式。

首先,我们探讨了 Monad Transformers。这些用于构建复合效果类型。给定两个描述各自副作用的自独立效果类型,您可以将它们堆叠在一起以获得一个组合类型。

之后,我们探讨了无标签最终模式。主要好处是,当您有一个单一的业务逻辑实现可以针对不同的效果系统运行以获得不同的语义时,可以实现控制反转。

最后,我们学习了函数式编程中类型级别计算的模式。这些模式的主要好处是,它们允许您以类型的形式对程序进行编码,并在编译时检查这些保证。这种检查可以通过类型级别计算的机制实现,例如 Scala 的隐式转换解析,或者任何允许类型级别编程的类似机制。

到目前为止,我们讨论的纯函数式编程技术强大且具有前景,但在工业界尚未得到广泛应用。当前并发的实际标准是 actor 模型。在接下来的章节中,我们将探讨它。下一章,我们将从模型的介绍开始。

问题

  1. 解释 Monad Transformers 的好处。

  2. 解释无标签最终模式的好处。

  3. 解释类型级别计算的好处。

第十一章:Actor 模型简介

在上一章中,我们讨论了现代编程语言中高级函数式编程的模式和技术。然而,你可能已经注意到,我们总是处理顺序编程的情况。我们离真正的并行性最近的一次是在讨论 Applicative 类型类时。

在本章中,我们将更深入地探讨现代并行性的功能性解决方案。以下是我们将在本章中涉及的主题:

  • 并行性解决方案概述

  • 传统模型在监视器上的同步

  • 作为传统模型的替代的 actor 模型

并行性解决方案概述

如果你记得,Applicative 类型类为我们提供了一个定义并行计算的抽象。它与Monad类相对立,后者是定义顺序计算的抽象。

在第八章的类型类部分,基本类型类及其用法中,我们推理出需要 Applicatives 来提供一个原始定义独立计算的机制。并行性也可以通过 Applicative 来建模。然而,正是这种独立性的理念是这个类型类背后的推动力。

并行性和并发性需要不同的方法。它们引发了在顺序编程中通常不会遇到的问题,并且这些问题有它们自己的技术,以便可以在面向对象编程中解决。然而,这些技术比常规的面向对象和命令式编程更容易出错,也更难以推理。因此,设计了一系列其他技术来简化并发软件的开发过程。

到目前为止,我们仍然不能说我们已经找到了并行和并发编程的理想方法。每当涉及到并发时,编程比单线程情况下的编程要困难得多,即使在使用最现代的技术和方法的情况下。现代系统往往是分布式的,对这类系统的可扩展性有很高的要求。这意味着在现代世界中,一个应用程序通常必须在多个机器上运行,这些机器可能位于世界的不同部分。此外,对这类系统的可扩展性也有要求。可扩展性意味着,无论何时添加额外的处理能力,例如将额外的机器添加到集群中,现有的程序都必须在这些新机器上无缝运行,而无需你编写额外的编程代码。基本上,可扩展性意味着软件必须在任何数量的机器上运行得和单台机器上一样好。

显然,在这种情况下,混乱是不可避免的。到目前为止,我们还没有一个单一的解决方案来解决分布式容错和高可用系统上下文中出现的问题。在 20 世纪,人们试图创造方法和数学理论来解决这一问题。在这里,我们主要谈论的是一类称为进程演算的数学理论。进程演算是精确针对使用数学逻辑和数学定律描述并发发生的进程的一组数学理论。进程演算的一些著名例子包括通信进程代数ACP),它在 Scala 中有 SubScript 的实现(见subscript-lang.org),pi-演算,通信系统演算CCS)。人们试图将这些理论付诸实践。然而,今天,我们无法说任何给定的理论都能深入且方便地解决现代程序员面临的整个问题范围。

此外,近年来,已经开发了一系列专门针对并发和并行应用程序开发的工程方法。其中一种方法就是响应式编程。这种方法主要基于以流、数据源和汇的形式构建你的应用程序。这种类型的在数据流密集型应用程序的上下文中非常有用,这意味着有大量数据不断从一个应用程序的部分移动到另一个部分。

这种响应式编程的一个实际应用是事件密集型应用程序。例如,许多移动应用程序依赖于事件传播和对事件的响应。这意味着描述这类应用程序的好策略是关于数据流和数据源进行推理,以及将数据作为应用程序的一等公民进行反应。通常,这类应用程序会用回调和事件响应来描述。然而,以流的形式进行推理为你提供了一套适当的抽象工具。在上一章中,我们看到了当我们频繁遇到错误和副作用时,将它们作为程序的一等公民并明确地推理它们,对于调试应用程序和减少错误发生的可能性非常有帮助。

在这里也是同样的情况——当我们有一个数据事件密集型的应用程序时,以流的形式进行推理可以非常有益。针对广泛的编程语言,已经实现了一系列这种方法的实现。

然而,以事件流和响应式编程的方式进行编程并不总是你所希望的。确实,某些事件和数据处理密集型的应用程序可以用数据流来描述是合理的。但这并不总是如此。

正如我们之前讨论的,已经开发出各种理论和方法来解决并行编程的困难。其中一些可以被视为更功能性的。例如,一些 Scala 函数式编程库,如CatsScalaZ,提供了一些原语以允许并发和并行编程。其中一些方法具有更面向对象的特点。例如,之前提到的一些进程演算往往具有相当多的面向对象精神,这意味着它们引入了一些与面向对象编程中的对象非常相似的原语。一些理论和方法位于函数式编程和面向对象编程的边缘,无法明确归类为任何这些方法的成员。例如,这可以是编程的响应式方法。尽管它在函数上投入很大,并使用 Lambda 演算来组合这些函数,但权衡通常是类型安全。

并发编程理论和方法的数量意味着这个主题非常具有推测性。通常情况下,对某个应用程序有效的技术和理论可能不会对另一个应用程序表现出同样的效果。因此,有必要指出本书对这个话题的立场。在这本书中,我们采取了一种实用主义的方法来处理函数式编程,这意味着本书的目的是为你提供一个工具集,以函数式的方式解决实际问题。到目前为止,最实用且最好的并行编程方法之一是演员模型。虽然从函数式编程的角度来看,它可能不是最优雅的模式,因为它仍然缺乏令人满意的类型安全,但它是一种高度可扩展且在实际中表现良好的方法。在本章中,我们将研究并行应用程序的演员方法,并了解如何使用基于演员的现代技术,借助演员模型来编写实际的并行和可扩展的应用程序。

然而,在我们深入讨论演员模型及其实际应用之前,有必要了解并行编程所面临的全部挑战,以及它们如何在传统的面向对象编程模型和传统方法中得到解决。因此,首先,让我们来看看传统的并行编程方法,即带有同步和监视器的多线程。

传统模型在监视器上的同步

并发场景发生在你有两个或更多操作并行执行时,这种并行性可以是真正的并行或模拟的并行。真正的并行是指你的应用程序在两个不同的 CPU 核心上并行执行,如下所示:

模拟并行性是指所有并行任务都在同一处理器核心上执行,但是处理器会不时地在任务之间切换。每个任务都由所谓的原子操作组成——最小的任务,直到完成不能被中断。处理器可以从一个任务中获取一定数量的原子操作,然后执行来自另一个任务的一定数量的原子任务:

图片

当你编写一个并行应用程序时,你经常会遇到需要任务之间相互通信的情况。这种情况可能发生的一种情况是,你的并发任务需要访问某种资源,这种资源可以是应用程序的外部或内部资源,它不是线程安全的。在这种情况下,他们需要协调对这种资源的访问。在这里,我们遇到了并行编程中的一个非常重要的概念,那就是线程安全。线程安全的资源可以从任何数量的线程并行访问,而不必担心是否会出现错误。然而,非线程安全的资源必须一次从一侧访问。一个典型的线程安全资源是不可变数据结构。一个典型的非线程安全资源是共享可变状态。

如果你从多个线程访问非线程安全的资源,可能会发生什么问题?考虑写入文件的例子。假设你正在为在线商店编写一个应用程序,该应用程序旨在以某种格式生成商品列表。假设你需要从 CSV 格式的文件中读取商品列表,然后以某种方式转换它们:

Name,Price
TV Set,100
iPhone 8,300
Samsung Galaxy S5,300
MacBook Pro,2500
MacBook Air,1500

考虑你需要使用我们已学过的Circe库以 JSON 格式输出相同的商品:

{"Name":"TV Set","Price":100}
{"Name":"iPhone 8","Price":300}
{"Name":"Samsung Galaxy S5","Price":300}
{"Name":"MacBook Pro","Price":2500}
{"Name":"MacBook Air","Price":1500}

还要考虑你想要并行执行此操作。你需要做的是将 CSV 文件的每一行转换为某种 JSON 输出。然后,我们需要将此输出写入输出文件。在这里,我们有一系列相互依赖的操作。每一行的转换都是独立于任何其他行的任何其他转换的。因此,我们可能想要从两个线程并行执行这些任务。因此,一个线程将处理列表的前半部分,而另一个线程将处理列表的后半部分。

将输出到文件的过程可以模拟为一种特定的交易,如下所示:

图片

在前面的图中,你可以看到我们执行了打开文件以供写入的操作,然后执行某些原子操作将数据写入文件,最后关闭文件。为了简单起见,将字符串Hello写入文件的过程可能看起来不像以下这样:

图片

在前面的图中,我们可以看到整个事务不是原子的。它由原子操作组成,在我们的例子中是写入单个字符。这里应该注意的是,前面的例子只是一个例子。不同的写入逻辑实现可能会以不同的方式实现事务过程,因此前面的原子操作可能并不适用于所有环境。然而,前面的例子很好地说明了这一点,因为大多数实现仍然使用原子操作以非线程安全的方式将数据写入文件。整个写入事务不是原子的。

让我们考虑如果我们尝试从两个不同的线程向同一个文件写入会发生什么:

所以,正如你所看到的,我们没有关于每个事务的原子任务执行顺序的任何保证。因此,当前面的场景发生时,你最终会在文件中得到以下输出:

WHoerllldo

这就是我们所说的操作或资源不是线程安全的含义。这意味着它只能由单个线程与源一起工作。使用相同资源从两个线程中工作可以按照以下方式实现:

因此,很明显,前面的两个线程必须意识到彼此以及它们应该执行的顺序。更确切地说,我们应该以某种方式确保一次只有一个线程可以访问共享资源。换句话说,我们需要以某种方式同步线程。

同步

可以用来同步线程的最简单方法被称为同步。它在语言级别上实现,并且是大多数编程语言中的标准构造。

理念是这样的。你的某些编程代码块可以被设置为受保护的,这意味着除非满足某个条件,否则线程无法执行这些代码。这个条件就是所谓的监视器的所有权。因此,一个线程可以拥有某些监视器。在 JVM 环境中,监视器可以是任何对象。因此,在 JVM 级别上,我们可以声明一个线程拥有一个监视器。线程可以自行决定是否获取和释放监视器的所有权。另一个规则是,监视器一次只能被一个线程持有。当一个线程想要获取已被另一个线程持有的监视器时,那么这个线程必须等待直到监视器被释放并再次可用。

之前提到的框架可以用来同步线程之间的交互。你可以按照以下方式操作。每当有一个资源不是线程安全的,并且需要从多个线程中访问时,我们可以通过使用synchronized关键字来保护代码,即在 Java 或 Scala 的情况下。这可以按照以下方式实现:

val target = new File("sample.txt")
 target.synchronized {
   FileUtils.writeStringToFile(target, "Hello World", "utf8")
}

上述代码是在某个线程的上下文中执行的。每个指令都是按顺序在线程中执行的。当线程遇到synchronized关键字时,它会尝试获取相关的对象作为监视器。如果这个对象被其他线程拥有,那么这个线程就会进入睡眠模式。这意味着它什么也不做,直到被通知监视器被释放并且可以由它获取。一旦监视器可用,它就会被这个线程获取。现在,这个线程有保证,在它自己持有之前,不会有其他线程获取相同的监视器。然后,这个线程在synchronized块中执行代码。

代码执行后,当前线程会释放监视器。执行语义如下:

图片

理论上,上述方法看起来不错。然而,在这种场景下可能会遇到一些严重的问题。这些问题非常难以调试,并且现代编译器无法检测到这些问题。这类问题的存在要求有一个更好的推理和定义并发及并行应用的框架。接下来,让我们看看这些问题是什么。

传统模型的难题——竞态条件和死锁

当涉及多个资源时,问题开始出现。例如,考虑对上述程序进行轻微修改的版本。在先前的例子中,我们必须将计算结果写入文件。考虑在执行计算的同时,我们还需要在日志文件中跟踪它们所做的工作。这种做法在现实世界的场景中对于调试目的可能是有用的。

因此,计划如下。首先,整个输入文件被读入程序内存。在我们的当前场景中,线程是异构的,这意味着它们有不同的任务要完成。同构线程通常更容易处理,因为它们的行为相似,并且从同一个地方控制。然而,在现实世界中并不总是这样。所以,让我们考虑具有以下任务的线程。第一个线程将负责将 CSV 转换为 JSON,就像先前的例子一样。此外,它必须将转换过程报告到日志文件中。另一个线程将执行不同的任务。让它计算有关文件的某些统计数据,例如,在线商店所交易的所有商品的平均价格。

让我们看看这样一个程序如何在传统的同步场景中实现。在深入这个例子之前,让我们定义一些在例子中会使用的方便方法和值。你需要以下导入:

import scala.collection.JavaConverters._

import java.io.File
import java.nio.charset.Charset

import org.apache.commons.io.FileUtils

对于文件操作,我们将使用 Apache Commons IO 库。必须在build.sbt中声明对这个库的依赖:

libraryDependencies += "commons-io" % "commons-io" % "2.6"

方便的方法如下:

// Files we will be working with
   val input = new File("goods.csv" )
   val log = new File("log.txt" )
   val output = new File("goods.json")

// Encoding for the file I/O operations
   val encoding = "utf8"

// Convenience method to construct threads
   def makeThread(f: => Unit): Thread =
    new Thread(new Runnable {
      override def run(): Unit = f
    })

// Convenience method to write log
   def doLog(l: String): Unit = {
     FileUtils.write(
       log
     , l + "\n"
     , encoding
     , true // Append to the file rather than rewrite it
     )
     println(s"Log: $l") // Trace console output
   }

// Convenience method to read the input file
   def readInput(): List[(String, Int)] =
     FileUtils.readLines(input, encoding).asScala.toList.tail
       .map(_.split(',').toList match {
         case name :: price :: Nil => (name, price.toInt)
       })

阶段设置完毕后,让我们继续到示例。首先,让我们看看负责将 CSV 转换为 JSON 的第一个线程。在这个线程中,你可能想要做的第一件事是打开我们将要工作的文件并将其读取到列表中:

val csv2json: Thread = makeThread {
  val inputList: List[(String, Int)] =
    input.synchronized {
      val result = readInput()
/*...*/

由于文件不是线程安全的资源,我们首先需要做的事情是对文件进行监控。在读取此文件后,我们可能想要向日志报告操作已成功执行。因此,我们可能想要对日志文件进行监控并报告操作如下:

log.synchronized {
  doLog(s"Read ${result.length} lines from input")
}

注意,在完成报告后,日志文件的监控会立即释放。所以,inputListcode看起来如下所示:

val inputList: List[(String, Int)] =
  input.synchronized {
    val result = readInput()
    log.synchronized {
      doLog(s"Read ${result.length} lines from input")
    }
    result
  }

读取文件完成后,我们对输入文件的每一行执行转换操作,然后将结果写入输出文件:

val json: List[String] =
  inputList.map { case (name, price) =>
    s"""{"Name": "$name", "Price": $price}""" }

FileUtils.writeLines(output, json.asJava)

因此,第一个线程的整个代码如下所示:

val csv2json: Thread = makeThread {
  val inputList: List[(String, Int)] =
    input.synchronized {
      val result = readInput()
      log.synchronized {
        doLog(s"Read ${result.length} lines from input")
      }
      result
    }

  val json: List[String] =
    inputList.map { case (name, price) =>
      s"""{"Name": "$name", "Price": $price}""" }

  FileUtils.writeLines(output, json.asJava)
}

现在,让我们看看另一条线程。它的任务是计算我们输入文件中的某些统计数据。更确切地说,我们可以对商品的所有价格进行某种聚合函数的计算。例如,我们可能考虑计算集合的平均值、最大值和最小值。然而,我们也可能想要配置这条线程以计算我们想要的精确指标:

def statistics(avg: Boolean = true, max: Boolean = false, min: Boolean = false): Thread

如你所见,我们能够指定需要计算的确切指标。一个合理的步骤是在做任何事情之前将此信息报告到日志文件中:

log.synchronized {
  doLog(s"Computing the following stats: avg=$avg, max=$max, min=$min")
}

在这里我们首先做的事情是对日志文件进行监控并报告指标。接下来我们需要做的事情是实际上读取文件:

val inputList: List[(String, Int)] = log.synchronized {
  doLog(s"Computing the following stats: avg=$avg, max=$max, min=$min")
  val res = input.synchronized { readInput() }
  doLog(s"Read the input file to compute statistics on it")
  res
}

由于我们还需要向日志报告文件已成功读取的事实,我们决定在文件成功读取后释放日志监控。注意,报告指标的片段是如何被整合到inputList代码中的,这样统计和“读取输入文件”报告都可以在同一个synchronized代码块下完成。

读取输入文件后,我们能够根据用户指定的参数在此输入文件上计算所需的指标如下:

val prices: List[Int] = inputList.map(_._2)
def reportMetrics(name: String, value: => Double): Unit = {
  val result = value
  log.synchronized { doLog(s"$name: $result") }
}

if (avg) reportMetrics("Average Price", prices.sum /
  prices.length.toDouble)
if (max) reportMetrics("Maximal Price", prices.max)
if (min) reportMetrics("Minimal Price", prices.min)

因此,statistics线程的整个代码将如下所示:

def statistics(avg: Boolean = true, max: Boolean = false, min: Boolean = false): Thread = makeThread {
  val inputList: List[(String, Int)] = log.synchronized {
    doLog(s"Computing the following stats: avg=$avg, max=$max, min=$min")
    val res = input.synchronized { readInput() }
    doLog(s"Read the input file to compute statistics on it")
    res
  }

  val prices: List[Int] = inputList.map(_._2)
  def reportMetrics(name: String, value: => Double): Unit = {
    val result = value
    log.synchronized { doLog(s"$name: $result") }
  }

  if (avg) reportMetrics("Average Price", prices.sum / prices.length.toDouble)
  if (max) reportMetrics("Maximal Price", prices.max)
  if (min) reportMetrics("Minimal Price", prices.min)
}

如果你与第一个线程并行运行此线程会发生什么?

csv2json.start()
statistics(true, true, true).start()

你可能会注意到,有时程序会挂起并且变得无响应。这种情况被称为死锁

基本上,这里的问题是两个线程正在争夺资源。这是一个关于哪个线程先获取哪个监控器的竞争条件。第一个线程获取输入文件的监控器,然后获取日志的监控器。然后,它释放锁的监控器,然后释放输入文件的监控器:

图片

在前面的图中,橙色条表示在输入监视器下执行的代码。蓝色条表示在日志监视器下的代码。在这个特定案例中,蓝色代码也拥有输入监视器,因为它在执行期间尚未释放。

相比之下,第二个线程以不同的顺序执行这些操作。首先,它锁定日志文件。然后,它锁定输入文件,接着释放输入文件的锁,最后才释放日志文件的锁:

图片

两个线程依赖于相同的一组资源,它们获取这些资源的顺序没有定义。这意味着它们将竞争这些资源,当你多次运行程序时,资源获取的顺序将在每次运行中不同。

让我们看看一个应用程序运行良好的案例:

图片

在前面的图中,第一个线程首先锁定输入监视器,然后锁定日志监视器,但之后,第二个线程试图锁定日志,但为时已晚。因此,它被迫等待其他线程完成。第一个线程已经获得了它所依赖的所有锁,因此它成功完成。完成之后,它释放了它拥有的所有监视器,第二个线程就可以获取它们。

现在,让我们看看应用程序如何以及为什么会出现死锁:

图片

因此,在上面的例子中,第一个线程锁定输入文件的监视器。与之相伴,第二个线程锁定日志文件的监视器。之后,为了继续执行,第一个线程需要日志文件的监视器,但它无法获取它,因为第二个线程已经锁定了它,因此它被迫等待。

第二个线程需要输入文件的监视器才能继续执行。然而,它无法获取它,因为它被第一个线程拥有,因此它也必须等待。这意味着我们最终陷入了一个状态,其中两个线程都无法继续执行,直到另一个线程完成,因此两个线程都不会完成。这种情况被称为死锁。

一个快速的解决方案可能是让线程以相同的顺序获取监视器。例如,如果第一个线程先锁定输入文件监视器,然后锁定日志文件监视器,我们可能希望对第二个线程也强制执行相同的顺序。因此,第二个线程将如下所示:

def statisticsSafe(avg: Boolean = true, max: Boolean = false, min: Boolean = false): Thread = makeThread {
 val inputList: List[(String, Int)] = input.synchronized {
 log.synchronized {
 doLog(s"Computing the following stats: avg=$avg, max=$max, min=$min")
 val res = readInput()
 doLog(s"Read the input file to compute statistics on it")
 res
 }
 }

  val prices: List[Int] = inputList.map(_._2)
  def reportMetrics(name: String, value: => Double): Unit = {
    val result = value
    log.synchronized { doLog(s"$name: $result") }
  }

  if (avg) reportMetrics("Average Price", prices.sum / prices.length.toDouble)
  if (max) reportMetrics("Maximal Price", prices.max)
  if (min) reportMetrics("Minimal Price", prices.min)
}

粗体中的代码块与statistics线程定义相比所做的更改。现在,第一个获取输入文件监视器的人将保证完成执行。这是因为,在先前的应用中,除非你已经拥有了输入文件的监视器,否则不可能获取日志文件的监视器。所以,第一个获取输入文件监视器的人将保证能够获取相同的日志文件监视器。

短期内这个修复可能有效。然而,你可能已经注意到它并不是最优的。在先前的例子中,我们有一个相当简单的情况。我们只有两个线程和它们依赖的两个资源。这种简单的设置在现实世界中不太可能发生。现实世界中的应用可能包含数十个线程,并依赖于数十个资源。此外,调试先前的复杂性也很棘手。如果只有两个线程能够产生这种复杂性并且需要大量的脑力去分析和找出问题,想象一下这种复杂性在现实世界设置中会如何增长。

这正是为什么标准同步方法在长期内对并行编程来说并不实用的原因。它作为编程的低级模型是不错的,这样人们可以在其之上构建一些高级原语。然而,我们在实践中无法有效地使用它。这些问题与线程和并发应用程序提供了创造更新、更稳健的推理并发编程方法动机。我们在本章开头简要讨论了一些这些方法。现在,让我们更详细地谈谈 actor 模型。

将 actor 模型作为传统模型的替代

处理先前讨论的复杂性的最流行方法之一是并发编程的 actor 方法。如果你仔细查看先前的例子,你会注意到它们的一个特点,那就是全局推理。每当有多个线程需要相互通信时,我们就被迫一起对它们进行推理。因此,我们不能单独考虑一个线程,而忽略其他线程。

正如我们之前看到的,解决死锁问题的方法是改变第二个线程中获取监视器的顺序,使其与第一个线程中的顺序相匹配。基本上,当我们处于第二个线程的作用域内时,我们被迫考虑到第一个线程中进行的操作。

全局推理给程序员的思维带来了心理负担。本书的一个核心观点是,纯函数式编程旨在通过减少对程序推理的范围来减轻程序员的思维负担。

在并发编程的背景下,我们如何处理全局作用域和共享可变状态作为线程间通信手段的问题?演员模型会提供一组抽象来确保,无论何时你在编写并行应用程序,你都能忘记你正在一个并发环境中工作。演员模型背后的核心点,它被创造出来的中心思想,以及它存在的原因,就是让你的程序在并发环境中就像你正在处理一个单线程应用程序一样,这意味着你不再需要考虑以线程安全的方式获取监视器或访问资源。

演员模型通过提供一组抽象和一组必须遵循的约定来实现这一点,作为模型的一部分。演员模型的核心抽象,不出所料,是一个演员。可以大致将演员视为一个线程。它不一定是一对一映射到线程上;实际上,它是一个更轻量级的原语,你可能会有成千上万的演员。它们并发性的管理是通过抽象来实现的。然而,正确思考演员的方式是,它们是演员模型中的并发原语。

一个演员可以拥有某些资源,如果它确实拥有,则保证没有其他演员拥有或访问这些资源。例如,如果一个演员有一个文件的引用,则保证没有其他演员有相同引用的同一文件,因此它无法写入或读取该文件。如果它需要访问另一个演员拥有的资源,它需要请求拥有该资源的演员代表这个演员执行所需的操作。由于所有非线程安全或资源上的操作都是由一个且仅有一个演员完成的,并且演员是顺序和单线程实体,因此在这种情况下不会出现任何非线程安全的行为。

一个演员如何向另一个演员请求执行一个动作?这是通过消息来完成的。每个演员都有一个所谓的邮箱。邮箱是一个存储所有发送给这个演员的通信的地方。在演员模型中,一个通信单元就是一个消息。只要消息符合演员模型的约束条件,它可以包含任何内容,我们将在后面讨论这些约束条件。邮箱是一个队列。因此,许多并行运行并发送消息的演员的消息可能会到达单个演员,并且它们将被排序到一个单一的顺序队列中。演员保证一次只处理一个消息。所以,它的工作方式是演员在其邮箱上等待邮件。然后,它一次处理一封信,并按顺序处理。

关于如何具体处理收到的邮件,一种方法是在演员的体内通过对其不同类型收到的邮件的反应来定义。因此,对于演员能够处理的每种邮件类型,它定义了一个函数,这个函数应该在信件到达演员时执行。

重新审视死锁示例

到目前为止,我们只是粗略地看了演员模型。我们还没有学习任何关于演员模型的实际实现。然而,看看我们之前的例子是如何实现的,以便我们摆脱所面临的复杂性,可能会有所帮助。

首先,我们讨论了演员模型中的并发原语。在前面的例子中,并发原语是执行某些操作的两个线程。因此,将我们需要执行的从两个线程映射到演员模型中的两个演员是合理的。所以现在,我们有了两个演员。一个演员应该从 CSV 生成 JSON,另一个演员应该对 CSV 文件进行一些统计分析。

在前面的例子中,我们有两个需要处理的文件和两个需要访问这两个文件线程。演员模型要求只有一个演员必须拥有一个给定的资源。所以,如果第一个演员需要资源,第二个演员就不能拥有它。在我们的情况下,第一个和第二个演员需要处理输入文件和日志文件。我们应该如何处理这种情况?我们应该如何使其符合演员模型?

解决方案是两个演员都不应该拥有这个资源。相反,我们应该创建一个第三演员,该演员负责运行涉及这些资源的操作。然后,每当我们需要对结果进行操作时,我们向该演员发送消息,要求执行所需的操作。

由于我们的演员,让我们称其为进程管理器,控制着对输入文件和日志文件的访问,我们必须期待其他演员提出的相关于这个资源的操作请求。换句话说,我们还需要定义对它可能收到的所有可能消息的反应。因此,我们需要考虑其他演员可能提出的哪种操作请求。我们可以考虑以下请求:

  • 首先,我们获取输入文件。这条消息是请求读取输入文件并将其作为不可变集合发送回请求的演员。在两个演员之间共享不可变资源是完全可以接受的,因为不可变资源是线程安全的。

  • 其次,我们可能期待一个写入日志文件的请求。在收到这个请求后,资源管理器演员将对日志文件执行写入操作,写入的信息是发送给它的消息。

一旦我们有了资源管理器演员,我们可以像这样表达示例:

图片

现在,执行实际工作的前两个演员是根据消息和与资源管理器的通信来定义的。第一个演员要求进程管理器发送一个输入文件给它。在收到资源管理器的响应后,它开始其操作,并且每当它执行需要记录日志的重大操作时,它将日志消息发送给资源管理器。在这种情况下没有采取监控,因为所有资源都由单个演员拥有。所有其他演员并没有直接调用它们——它们只是要求资源演员代表它们执行操作。

第二个演员与其自身有相似的情况。首先,它向资源管理器发送一条包含它将要计算的统计信息的日志消息。其次,它从资源管理器请求输入文件。最后,在作为单独的消息收到输入文件后,它执行计算,并在需要记录日志时联系我们的资源管理器。

没有演员需要采取监控或与其他演员同步以确保非线程安全资源可以安全使用。它们都由单个演员拥有,并且这个单个演员从它自己的单个线程中依次与它们一起工作。其他演员发送给它的消息可能并行到达,但它们将在单个邮箱中汇总,并且不会立即处理。资源演员根据自己的节奏、自己的时间处理消息,只要底层系统分配了资源和处理时间。保证这个演员一次处理一条消息,并且不会并行处理两条消息。因此,我们大大提高了线程安全性。

注意,在前面的图中,我们有一个会导致标准同步模型中死锁的场景。第一个演员需要访问文件,然后需要访问日志,第二个演员需要访问日志然后是文件。在本章之前,我们讨论了这种情况下产生死锁的可能性。在这里,死锁不再可能,因为资源由单个演员控制。

摘要

在本章中,我们对演员模型的动机和背后的想法进行了简要概述。我们看到了如何用演员模型可能的样子来表示应用程序的架构。在下一章中,我们将更深入地探讨模型,并了解如何在实践中使用它。我们将学习一些我们可以立即在我们的项目中使用的模型的实际实现和框架。

问题

  1. 同步模型是如何在同步并行计算中工作的?

  2. 死锁是如何发生的?描述一个可能导致死锁的场景。

  3. 演员模型的主要抽象和约束是什么?

  4. 行为模型是如何帮助防止在同步模型下通常出现的问题的?

第十二章:实践中的 Actor 模型

在上一章中,我们开始探讨 actor 模型作为 Scala 中可用的一种并发模型。在第九章,纯函数式编程库中,我们看到了如何使用 IO 及其提供的基础设施来解决异步和多线程编程的挑战。然而,这种技术仍然没有得到广泛的应用。在实践中,当你在 Scala 中处理多线程、并发和异步时,你将需要在现实场景中处理更健壮的库。

在本章中,我们将探讨以下主题:

  • Akka 概述

  • 定义、创建和消息传递 actor

  • 与 actor 系统一起工作

Akka 概述

Akka 是 actor 模型的实现,我们在上一章中讨论了其在工业应用中的目的。如果 Cats 效应专注于对新技术的实验和尝试,那么 Akka 则专注于为行业提供解决大规模问题的工具。当然,我们也可以期待 Cats 达到那个水平,然而,如果你打算在现实世界中用 Scala 处理并发和异步,你很可能会遇到 Akka。

本书的目的在于让你熟悉在现实场景中需求旺盛的函数式编程的现代技术。由于并发和异步编程无处不在,我们将讨论最广泛使用的工具来应对其挑战。我们将从查看 Akka 构建的基础原则开始。

Akka 原理

Akka 的核心抽象是 actor。actor 是一个可以接收和向其他 actor 发送消息的实体。

Actors 是轻量级的并发原语。类似于你可以在 cats 中有数百万个 Fibres,你可以在 Akka 中有数百万个 actors。这是因为它们利用异步并提供在标准 Java 虚拟机线程之上的抽象。通过利用 JVM 的资源,你可以在单台机器上并行运行数百万个 actors。

Akka 在设计时就考虑了可扩展性。该库不仅为你提供了 actor 本身的抽象。类似于 cats 有针对各种特定函数式编程情况的库基础设施,Akka 也有大量针对异步编程特殊情况的库。你将在这个库中遇到一个 HTTP 服务器,这是一个允许你与位于不同机器上的 actors 进行通信的基础设施。

Actors 模型的目的在于为你提供一个并发框架,这将减少你的心理负担,并允许构建健壮和可扩展的软件。

为了克服并发编程带来的挑战,Akka 对程序员施加了一系列相当严格的限制。只有遵循这些限制,人们才能从模型中受益。重要的是要记住,actor 模型强加的规则不是由编译器编码和执行的。所以,遵循它们的责任在于你。随意打破它们是很容易的。重要的是要记住,如果你这样做,你可能会遇到比没有 actor 模型时更大的麻烦。

封装

并发编程的一个问题是共享可变状态。Akka 通过提供一种限制来消除这个问题,即无法将 actor 作为普通对象访问。这意味着你的业务逻辑没有单个变量来存储 actor。因此,不可能通过普通面向对象的方式访问 actor 上定义的值。

通过代理类型——ActorRefs——将 actor 暴露给外部世界。Akka 库定义了这种类型,并且只允许在 actor 上执行安全操作。

如果你想对 actor 做些什么,你应该通过这个代理来做。此外,你不会将 actor 作为普通的 Java 或 Scala 对象实例化。你不会在它上面调用构造函数。相反,你将指示ActorSystem来实例化它。有了这些约束,就变得不可能通过任何其他方式接受 actor 的数据,除了通过消息传递。

消息传递

每个 actor 都有一个邮箱,任何其他消息都可以发送到那里。Akka 保证消息一次由 actor 处理,不会发生并发处理。实际上,actor 模型提供了一种保证,即一次只有一个线程可以访问 actor 的内部状态。然而,请记住,遵循 actor 模型的责任在于程序员。通过在 actor 中产生额外的线程(例如使用Future)来打破模型是很容易的,从而破坏单一线程访问保证。

在存在其他方便的并发库,如 Future 的情况下,这种限制是强制执行的。Akka 与 Scala Future 紧密协作。重要的是要记住,未来(futures)是从其他线程开始和工作的。所以,当你开始在 Akka 中启动一个 Future 时,你就失去了对 actor 状态的单一线程访问保证。如果你继续这条路,你需要指定同步并利用 JVM 为你提供的监控机制。这是一个反模式,它破坏了 Akka 的初衷,在 actor 编程中是绝对禁止的。

记住,只有当你遵循其规则时,模型才会帮助你,而且没有任何东西可以强制你这样做。

不泄露可变状态

当使用 Akka 进行编程时,你必须注意的另一件事是 actor 的可变状态的泄露。还记得之前的原则,即不能有超过一个线程访问 actor 的内部状态吗?好吧,如果你将一个由一个 actor 拥有的可变对象的引用发送给另一个 actor,这个对象可能同时被两个线程并行访问。如果你将可变状态泄露给其他 actor,你可能会遇到比从 actor 启动 Future 时更糟糕的头痛。在启动 Future 的情况下,至少你可以控制那个 Future 和它启动的线程;你可以定义一些监视器和协议来访问 actor 的状态。当然,你不应该这样做,但在理论上,这是可能的。

然而,如果你从一个 actor 向另一个 actor 泄露了一个可变状态的引用,你将无法控制该 actor 如何使用它。

再次强调,这个规则不是由 Akka 强制执行的。在 Akka 中,你可以将任何对象作为消息传递给另一个 actor。这包括可变引用。因此,你应该意识到这种可能性,并积极避免它。

容错性和监督

Akka 在设计时考虑了弹性和容错性。这意味着如果一个 actor 因为异常而失败,它将有一个定义良好的方式自动重启并恢复其状态。Akka 将 actor 组织成层次结构,父 actor 负责其子 actor 的性能。所以,如果一个子 actor 失败了,它的父 actor 应该负责重启它。理念是外部世界不应该受到 actor 下属问题的干扰。如果出现问题,解决这个问题的责任在管理者,而不是进一步升级问题。而且当子 actor 重启时,它应该能够恢复到它失败时的状态。

消息保证

Akka 被设计成可以在集群中、通过网络进行部署。这意味着你对于消息的传递保证比在单个 JVM 应用程序中要少。如果你从一个计算机上的 actor 向世界另一端的 actor 发送消息,你不能保证这个消息会被传递。

因此,由 Akka 实现的 actor 模型要求你在构建应用程序时不要对消息传递有任何保证。你的应用程序必须能够应对无法传递消息的情况。

然而,Akka 为你提供了关于从 actor 到 actor 的消息传递顺序的保证。这意味着如果你从一个 actor 发送一个消息在另一个消息之前,你可以确信这个消息也会在第二个消息之前到达。

在学习前面的理论之后,最好的做法是看看它在实际中的应用情况。接下来,我们将讨论一个依赖于 Akka 提供的众多功能的例子。我们将随着遇到这些功能来学习它们。

异步

记得我们讨论 IO 时,强调了异步和非阻塞计算的重要性吗?底层操作系统的线程是稀缺的,在一个高负载的系统上,你需要很好地利用它们。阻塞并不是线程的明智利用方式。

我们已经讨论过,你不应该从当前 actor 调用其他线程。这样做的原因是为了防止多个线程访问当前 actor 的可变状态。我们已经讨论过,每次我们需要处理某些事情时,我们都将处理作为消息调度给当前 actor。

因此,为了强制单线程处理,你可能会倾向于在消息处理逻辑上阻塞一个 future,如下所示:

Await.ready(future, 3 seconds)

然而,这会阻塞底层线程。阻塞将一个执行此操作的 actor 变成了一个重量级的并发原语。如果你在高负载应用的环境中使用它,它会快速消耗系统并发资源。这里的理由与我们在讨论 IO 时相同。总之:不要阻塞你的并发原语,因为线程是稀缺的。如果你需要等待某些异步计算的结果以继续当前计算,确保计算完成时向该 actor 发送消息,在当前 actor 上注册一个处理程序,说明任务完成后要做什么,并释放当前线程。

定义、创建和消息传递 actor

Actor 被定义为从 Akka 库中的Actor类继承的类:

class HelloWorld extends Actor {

Actor 公开以下抽象 API:

图片

Actor中唯一抽象的方法是receive方法。Akka 在 actor 需要处理传入消息时调用此方法。它返回一个从AnyUnit的偏函数。这意味着它能够处理来自所有对象域的消息,并且它应该在处理此消息时产生一些副作用,这由Unit返回类型表示。该函数是一个偏函数,这意味着它只能处理 actor 感兴趣的部分Any输入域。

当你定义一个 actor 时,你重写此方法来定义 actor 必须做什么:

val log = Logging(context.system, this)

def receive = {
  case Ping ⇒ log.info("Hello World")
}

消息定义如下:

case object Ping

因此,当 actor 收到 ping 消息时,它将输出hello world字符串到日志。我们可以通过以下函数来构建日志:

图片

此函数定义在以下对象上:

图片

Akka 所依赖的一种抽象是事件系统。事件可以用来跟踪演员状态的变化,并在失败的情况下将其恢复到之前的状态。从某种意义上说,日志记录也是一个事件流,因此 Akka 为你提供了一个详尽的日志基础设施,它还与其通用事件系统集成。在构建记录器时,你需要提供为其定义的ActorSystem以及当前演员的引用。然后你将能够正确地显示日志消息,同时指定当前演员。

注意,在这里,为了构建记录器,我们正在访问在演员上定义的其他 API。我们正在调用上下文方法和其成员系统方法。因此,接下来,让我们看看演员公开的 API。

Actor类的所有具体成员可以分为几个组。

回调

以下方法属于回调组:

图片

Akka 框架在不同情况下会调用这些回调。例如,postStop在演员停止后调用。preStart在演员开始前调用。preRestart在演员重启前调用,afterRestart在演员重启后调用。重启回调接受一个reason参数。reason是由于此演员需要重启而导致的异常。它也是 Akka 的容错策略的一部分。在构建您的演员时,您应该考虑到这种重启的可能性。

最后,当演员收到它无法处理的任何消息时,会调用unhandled方法:

图片

记住,我们讨论过receive方法返回一个部分函数。这意味着它只在其域类型的一部分上定义。因此,每当演员收到它无法处理的任何消息时,就会调用未处理的回调。

监督

此外,Actor 提供的容错策略的一部分是supervisorStrategy方法:

图片

您可以重写此方法以提供不同的方式来监督其子演员。监督是观察子演员的生命周期并在重要事件上采取行动的概念,例如当演员因异常失败时。在 Akka 中,父演员应该监督子演员。监督策略定义如下:

图片

如您所见,为此类定义了两个子类,并且文档建议您不要实现额外的子类,因为不正确的实现可能导致错误的行为:

图片

AllForOneStrategy如果其中一个子演员失败,将对所有子演员应用给定的操作:

图片

OneForOneStrategy将只对失败的子演员应用操作。

注意,这两种策略都是通过定义如何处理失败子代的情况的各种参数来参数化的。其中一个参数是 Decider

图片

Decider 类型是从 ThrowableDirective 的部分函数。它接受在演员中发生的 Throwable(可以是 ExceptionError),Decider 的任务是向演员系统提供如何处理这个异常的信息。

Directive 定义了在给定异常下对演员应该做什么:

图片

Directive 特质有四个子类。Escalate 指令将异常提升到监督演员的父母。因此,当子代失败时,父母也会失败,并将依赖于自己的父母来处理异常。

图片

Restart 指令将丢弃失败的演员,并在其位置创建一个新的演员:

图片

Resume 指令将指示发生异常的演员继续处理消息:

图片

异常将被忽略,并且不会采取任何行动来处理它。子演员将继续像之前一样执行。

最后,Stop 指令将停止当前演员,而不会在其位置启动另一个演员。

图片

前面的基础设施为你提供了构建层次结构的能力,其中父母负责孩子的正常运行。这种方法提供了关注点的分离,并允许一定程度的地方推理。这意味着演员系统尽可能早地处理失败,而不是将它们传播到层次结构中。

层次结构也为你提供了一种抽象度量的方式,因为你不再关心特定演员的子代,你可以将特定演员视为对其请求执行的任务负责的单一点。单一点的责任类似于在组织中,你有一个单个人负责一个部门,并且每当你需要部门做某事时,你都会与负责人交谈。你期望他们能妥善管理部门。Akka 就是以这种方式构建的。无论何时你有疑虑,你都会有一个演员负责这个疑虑。它可能或可能没有子演员来帮助它处理这个疑虑,然而,作为一个外部观察者,你不需要意识到这些因素。你不需要关心这些下属发生的错误。当然,前提是这些错误可以定位到特定的部门。如果错误比部门能处理的更严重,它就会向上传播到链中。

上下文和参考

除了给你控制演员如何响应各种生命周期事件的回调之外,演员还有一个用于管理其执行上下文的 API:

图片

演员对其自身的 ActorRef 和发送者演员有引用。这些引用应该从 receive 方法中访问。通过它们,你可以与发送者演员以及作为 ActorRefs 的此演员进行交互。这意味着你可以向这些演员发送消息,并执行通常作为外部观察者会做的事情。

除了引用之外,演员还有一个 context 引用,它提供了关于演员执行上下文的信息和控制:

图片

ActorContext 提供了在处理消息时可能有用的各种 API。

管理演员层次结构

管理演员层次结构的核心概念是 ActorContext 类型。它定义如下:

图片

ActorContext 允许你在演员的层次结构上执行各种操作。例如,你可以使用 actorOf 方法创建新的演员,该方法定义如下:

图片

因此,使用 ActorContext,你可以创建此演员的子演员。我们将在稍后讨论演员系统时详细介绍使用 Props 对象创建新演员的确切步骤。

当前演员能够通过 childchildren 方法访问它创建的子演员:

图片

同样,你可以访问此演员的父演员:

图片

ActorSystem 是演员的集合:

图片

我们将在 创建演员 部分稍后讨论演员系统,但就目前而言,你应该了解 ActorSystem 可以从 ActorContext 访问。

管理生命周期

Akka 上下文为你提供了管理演员生命周期的各种方法:

图片

你可以停止此演员或其他演员使用 ActorContext。基于演员的编程中的一种常见模式如下:

context stop self

上述习语通常用于终止已完成工作且没有更多事情要做的演员。

演员可以改变其行为。演员的行为由其 receive 方法定义:

图片

然而,作为处理消息的一部分,你可能想要改变演员处理消息的方式。为此,你可以使用 become 方法。

演员会记住你覆盖的过去行为,并将它们保存在堆栈中。这意味着你可以调用 unbecome 方法从堆栈中弹出当前行为并使用之前的行为:

图片

监督

一个演员可以监视另一个演员的生命周期事件:

图片

当您需要此演员意识到另一个演员何时终止时,您可以指示它使用演员上下文来监视那个演员。当那个演员终止时,监督演员将收到一个Terminated消息。您可以像处理接收到的任何其他消息一样注册Terminated消息的处理程序。

创建演员

所有的演员都属于某个ActorSystem,这是一个演员的层次结构:

图片

创建新演员时最重要的方法如下:

图片

每个演员都是通过在演员系统或演员上下文中调用名为actorOf的方法来创建的。您可以使用其伴随者对象 API 创建演员系统本身:

图片

当调用此方法时,您可以指定此系统的名称。您还可以指定定义演员系统行为某些方面的配置。配置是一个放置在CLASSPATH环境变量指定的路径下的文件。CLASSPATH是让 JVM 程序知道它使用的类所在位置的标准方式。例如,在一个 SBT 项目中,您可以在项目根目录下的以下路径放置配置文件:src/main/resources/application.conf。在这个配置文件中,例如,我们可以禁用所谓的死信的日志记录——为不存在的演员发送的消息:

akka.log-dead-letters=0

配置非常灵活,并赋予您一定程度的控制权,以确定您的演员系统如何执行。有关更多信息,请参阅 Akka 关于配置的文档。

您还可以指定ActorSystem将要使用哪个执行上下文来运行其演员。正如我们在讨论 IO 时讨论的那样,对于并发库,我们需要一种方式来指定库应该使用哪种线程策略。最后,您可以为ActorSystem提供一个类加载器,它将被用于解析配置等操作。所有这些参数都是可选的。如果您没有指定其中一个,Akka 将使用合理的默认值。

现在,让我们看看我们如何运行我们的 Hello World 示例:

val system = ActorSystem()
val helloWorld = system.actorOf(Props[HelloWorld], "hello-world")
helloWorld ! Ping

首先,我们创建ActorSystem。我们创建一个HelloWorld演员。我们不调用其构造函数。我们通过使用Props对象来这样做。我们在Props类型参数中指定我们将要创建的类。我们还指定要创建的演员的名称。Props是一个如下定义的案例类:

图片

其伴随者还定义了一组方便的方法来创建Props

图片

在我们使用Props的帮助下创建演员后,我们可以向这个演员发送消息。我们使用ActorRef定义的!运算符来这样做。

演员参数

普通类可以有接受参数的构造函数,这些参数可以用来参数化生成的实例。同样,可以使用Props工厂方法的替代版本来创建具有构造函数参数的演员。

假设我们有一个演员,其构造函数接受参数:

class HelloName(name: String) extends Actor {
  val log = Logging(context.system, this)

  def receive = {
    case "say-hello" ⇒ log.info(s"Hello, $name")
  }
}

前面的演员指定了它在输出时将要问候的人的名字。同时,注意它如何接受一个字符串作为消息。这是为了表明你可以向演员发送任何对象作为消息。

当你有一个接受构造函数参数的演员时,一个标准做法是在演员的伴生对象中声明一个Props工厂方法:

object HelloName {
  def props(name: String): Props =
    Props(classOf[HelloName], name)
}

这种方法抽象掉了创建此演员所需的Props。现在,你可以按照以下方式构建此演员并使用它:

val system = ActorSystem("hello-custom")
val helloPerson = system.actorOf(HelloName.props("Awesome Person"), "hello-name")
val helloAlien = system.actorOf(HelloName.props("Alien Invaders"), "hello-aliens")
helloPerson ! "say-hello"
helloAlien ! "say-hello"
helloAlien ! "random-msg"

输出如下:

与演员系统一起工作

演员模型的优势在于演员轻量级,这意味着你可以在单个普通计算机上运行的 JVM 上使用数百万个演员。大多数时候,你不会使用单个演员,而是使用多个演员。这需要一个模型来处理多个演员。

在 Akka 中,演员以分层树的形式组织——这意味着每个演员都有一个父演员,并且可以有多个子演员。接下来,我们将查看一个稍微复杂一点的例子,该例子将展示演员在分层中的工作方式。

任务规范

假设我们需要有多个演员,它们都将问候消息输出到日志中的给定名字。假设还需要从最终用户那里抽象出系统中存在多个演员的事实。我们将通过创建一个单一的监督演员来实现这一点,该演员将负责执行所有子演员的执行。子演员将是向日志输出问候消息的演员,而父演员将是一个代表他们的管理者。

协议将如下所示。首先,创建一个GreetingsManager演员。然后,你将发送一个SpawnGreeters消息来生成所需数量的问候演员。这些生成的问候演员必须覆盖我们已有的演员。这种覆盖是通过停止之前的演员并创建新的演员来完成的。

接下来,用户向管理演员发送一个SayHello消息,这将指示其所有子演员向日志执行输出。然而,在管理演员没有生成任何子演员的情况下,我们将输出一个错误,要求用户首先生成演员。

实现

让我们从定义Greeter演员开始,因为它更简单:

class Greeter extends Actor {
  val log = Logging(context.system, this)

  def receive = {
    case SayHello(name) => log.info(s"Greetings to $name")
    case Die =>
      context stop self
      sender ! Dead
  }
}

演员将接受一个参数化的问候消息,该参数指定演员应该问候的名字。在接收到此消息后,它将执行日志输出。

Greeter 角色有其他角色停止它的手段。我们已经讨论过,任务的一个要求是管理角色应该能够终止现有的演员以创建新的演员。这可以通过向这个 Greeter 角色发送消息来实现。

那条消息将使用 context stop self 模式,并将使用 Dead 消息向发送者角色报告它已经死亡。请注意,context stop self 只在处理完当前消息后才会终止 self 角色一次。因此,sender ! Dead 代码将在角色仍然存活时执行。角色将在处理完 Die 消息后终止。

我们使用案例类作为消息在角色之间进行通信,因为它们在模式匹配方面很方便。整个协议如下所示:

case class SpawnGreeters(n: Int)
case class SayHello(name: String)
case class Execute(f: () => Unit)
case object JobDone
case class GreetersResolution(result: Try[ActorRef])
case class GreetersTerminated(result: List[Any])
case object GreetersCreationAuthorised
case object Die
case object Dead

我们将在遇到每个消息时介绍它。

此外,请注意 Akka 有一个内置的消息可以用来停止角色。每次你向一个角色发送 PoisonPill 时,它的默认行为是终止自己:

图片

我们在这里不使用 PoisonPill 的原因是因为它与我们将要使用的模式不太兼容。

接下来,让我们开始处理 GreetingsManager 角色吧:

class GreetingsManager extends Actor {
  val log = Logging(context.system, this)

  def baseReceive: Receive = {
    case SpawnGreeters(n) =>
      log.info("Spawning {} greeters", n)
      resolveGreeters()
      context become spawningGreeters(sender, n)

    case msg@SayHello(_) =>
      resolveGreeters()
      context become sayingHello(sender, msg)
  }

  def receive = baseReceive
/*To be continued...*/
}

receive 方法被设置为 baseReceive。我们不直接在 receive 下定义角色暴露的 API 的原因是我们打算利用 context become 功能来改变角色的行为。context become 可以用来覆盖 receive 方法的功能。每个新的行为都将在这个角色的内部作为单独的方法实现,以便于切换。

baseReceive 方法使角色能够处理两个 API 消息:SpawnGreetersSayHello。它们将管理底层的问候者并指示它们执行输出。

注意,这两个方法都遵循一个模式。首先,它们可以选择性地执行日志输出,然后调用 resolveGreeters 方法,最后使用 context become 模式来改变当前角色的行为。

对这两条消息的反应取决于 Greeter 角色是否被创建。如果它们没有被创建,那么在 SpawnGreeters 消息的情况下,我们将像往常一样创建它们。在 SayHello 的情况下,我们将输出一个错误,因为我们无法操作,因为没有问候者。

如果有子角色,在 SpawnGreeter 的情况下,我们将终止所有子角色以创建新的角色。在 SayHello 的情况下,我们将指示子角色将问候输出到日志。

在原则上,你可以从 actor 内部的可变变量中跟踪所有子 actor 的状态。因此,每次你创建一个子 actor 时,你都会将其引用保存到 actor 内部的集合中。这样,你将能够快速检查我们是否已经定义了子 actor。

然而,在这个例子中,我们将探讨如何使用内置的 actor-hierarchy-management API 来检查子 actor。

该 API 是异步和基于消息的。我们已经讨论过,对于 actor 来说,非阻塞至关重要。它们是轻量级的并发原语,线程稀缺,因此,为了保持 actor 轻量级,我们需要确保它们尽可能少地使用线程。因此,我们不能在子查询操作上阻塞。策略是请求子 actor,注册监听器以作为 actor 消息的反应来响应,并释放 actor 正在使用的线程来执行此策略。

这种策略就是你在baseReceive示例中看到的内容。resolveGreeters方法启动了子 actor 的解析,结果将以消息的形式返回给 actor。我们将改变此 actor 的receive实现以处理此消息。

一旦收到适当的响应,这些新行为将执行请求的功能。我们为SpawnGreetersSayHello消息分别设置了不同的行为。注意,我们还通过当前消息的原始发送者和他们提供的数据来参数化这些行为。因此,当我们准备好时,我们将能够执行响应请求,并通知请求者此请求的成功执行。

让我们看看resolveGreeters函数是如何实现的:

def greeterFromId(id: Any) = s"greeter-$id"

def resolveGreeters() =
  context.actorSelection(greeterFromId("*")).resolveOne(1 second)
    .transformWith {
      case s@Success(_) => Future.successful(s)
      case f@Failure(_) => Future.successful(f)
    }
    .map(GreetersResolution) pipeTo self

actorSelection API 的文档如下:

图片

在 Akka 中,每个 actor 都有一个名称和它所属的父 actor 链。每个 actor 也有一个名称。这允许你通过它们的路径来识别 actor。例如,让我们再次看看我们的 hello world 应用程序的输出:

图片

日志提供了当前 actor 的路径,用方括号括起来:

akka://default/user/hello-world

在 Akka 中,你可以通过它们的路径来查询 actor。你可以查询 actor 的整个切片,并向 actor 的整个集合发送消息。

因此,actor 选择函数是一个接受 actor 路径的函数,该路径可以包含通配符以同时查询多个 actor,并且它将在 Future 下返回 actor 选择。

Actor 选择为你提供了在普通ActorRef上的一些能力。然而,对于resolveGreeters函数,我们的目标是检查 greeters 是否存在。这可以通过调用resolveOne函数并观察它是否返回成功的 Future 或失败的 Future 来实现。

resolveOne 方法接受超时作为参数,并生成一个将导致从你选择的演员集合中随机选择一个演员的 Future。如果选择为空,它将失败。

之后,我们有一个 Akka 的 Future 互操作模式。这个模式被称为管道模式,在我们的例子中,它遵循我们将暂时忽略的 Future 的某些转换:

/*...*/
  .map(GreetersResolution) pipeTo self

pipeTo 方法通过 Rich Wrapper 可在演员引用上使用:

图片

它注入的 API 如下所示:

图片

如我们之前所讨论的,从一个演员中调用 Future 是一种反模式。每当这个演员依赖于异步计算时,你需要确保这个计算在终止时向这个演员发送消息,并定义如何处理来自演员的消息。Akka 为 Future 定义了这样一个模式——管道模式。这个模式确保在 Future 完成后,消息将被发送到指定的演员。

在我们的例子中,我们请求 actorSelection 的结果,并安排一个带有此解析结果的消息发送到当前演员。

我们在模式之前进行的转换是必要的,以确保失败的 Future 也会向当前演员发送消息:

.transformWith {
  case s@Success(_) => Future.successful(s)
  case f@Failure(_) => Future.successful(f)
}
.map(GreetersResolution)

最后,我们使用 map 方法将这个 Future 的结果封装成我们想要发送给演员的消息。

让我们通过一个小示例来看看 actorSelection 在实际中的工作方式。考虑以下添加到基本处理器的案例子句:

case "resolve" =>
  val selection = context.actorSelection(greeterFromId("*"))
  selection.resolveOne(1 second).onComplete { res =>
    log.info(s"Selection: $selection; Res: $res") }

我们现在的演员管理器能够接收一个 resolve 字符串作为消息,在此之后,它将对所有当前问候者进行演员选择并将结果放入日志中。

我们可以如下运行解析:

implicit val timeout: Timeout = 3 seconds
val system = ActorSystem("hierarchy-demo")
val gm = system.actorOf(Props[this.GreetingsManager], "greetings-manager")

Await.ready(for {
  _ <- Future { gm ! "resolve" }
  _ <- gm ? SpawnGreeters(10)
  _ <- (1 to 10).toList.traverse(_ => Future { gm ! "resolve" })
} yield (), 5 seconds)

我们在这里使用 Future Monadic 流程。这是因为,在某些情况下,在继续之前,我们将等待演员系统的响应。让我们逐行查看示例。

首先,我们向当前演员发送一个 resolve 消息:

_ <- Future { gm ! "resolve" }

? 操作符是由 Akka 中的以下 Rich Wrapper 注入的。

_ <- gm ? SpawnGreeters(10)

接下来,我们有一个 ask 模式:

图片

这里是它注入的 API:

图片

这个模式向一个演员引用发送消息,就像普通的 ! 操作符一样。然而,它还期望演员对当前演员做出响应。响应消息在 Future 下返回。在底层,这个 ask 方法创建了一个临时子演员,实际上是将消息发送给原始收件人。如果收件人响应这个临时演员,响应将作为 Future 的结果可用。

我们使用 ask 模式,因为我们希望暂停示例的执行,直到管理者报告演员已成功创建。这个示例的第一行模拟了问候管理器没有子演员的情况。在第二行,我们创建子演员并等待它们被创建。下一行将测试如何对非空选择进行演员选择:

_ <- (1 to 10).toList.traverse(_ => Future { gm ! "resolve" })

这一行,我们向问候管理器发送了 10 个解决消息。程序执行的输出结果如下:

结果是非确定性的。这意味着每次我们向演员发送消息时,我们都不确定哪个演员会被返回。注意,最初返回的是 ID 为6的问候者,而在后续调用中,返回的是 ID 为1的问候者。

现在,让我们看看这个模式如何与应用程序的其他部分协同工作。首先,让我们探索SayHello消息处理示例。在调用resolveGreeters之后,我们使用context become模式来改变演员处理消息的方式,并将新的receive函数设置为sayingHello。让我们看看sayingHello是如何定义的:

def sayingHello(requester: ActorRef, msg: Any): Receive = {
  case GreetersResolution(Failure(_)) =>
    log.error("There are no greeters. Please create some first with SpawnGreeters message.")
    context become baseReceive
    requester ! JobDone

  case GreetersResolution(Success(_)) =>
    log.info(s"Dispatching message $msg to greeters")
    context.actorSelection(greeterFromId("*")) ! msg
    context become baseReceive
    requester ! JobDone
}

sayingHello将响应GreeterResolution消息。这正是我们从刚才讨论的Greeter函数的结果中发送的消息。该消息的定义如下:

case class GreetersResolution(result: Try[ActorRef])

因此,该消息的有效负载有两种情况——成功和失败。我们有一个没有注册问候者的失败情况:

case GreetersResolution(Failure(_)) =>
  log.error("There are no greeters. Please create some first with SpawnGreeters message.")
  context become baseReceive
  requester ! JobDone

在这种情况下,我们记录一个错误,说明没有问候者。然后,我们将演员的接收逻辑切换回基本状态,并向原始请求者报告工作已完成,以便它知道演员系统已经完成了对请求的处理,以防请求者需要等待此类事件:

case GreetersResolution(Success(_)) =>
  log.info(s"Dispatching message $msg to greeters")
  context.actorSelection(greeterFromId("*")) ! msg
  context become baseReceive
  requester ! JobDone

在问候者成功解决的情况下,我们使用演员选择逻辑选择问候者,并将消息发送到这个选择。最后,我们将回到基本处理逻辑,并向请求者报告工作已完成。

现在,让我们看看孵化问候者的逻辑是如何工作的:

def spawningGreeters(requester: ActorRef, numGreeters: Int): Receive = {
  case GreetersResolution(Failure(_)) =>
    self ! GreetersCreationAuthorised

  case GreetersResolution(Success(_)) =>
    log.warning(s"Greeters already exist. Killing them and creating the new ones.")
    context.children
      .filter(c => raw"greeter-\d".r.unapplySeq(c.path.name).isDefined)
      .toList.traverse(_ ? Die)
      .map(GreetersTerminated) pipeTo self

  case GreetersTerminated(report) =>
    log.info(s"All greeters terminated, report: $report. Creating the new ones now.")
    self ! GreetersCreationAuthorised

  case GreetersCreationAuthorised =>
    (1 to numGreeters).foreach { id =>
      context.actorOf(Props[Greeter], greeterFromId(id)) }
    log.info(s"Created $numGreeters greeters")
    requester ! JobDone
    context become baseReceive
}

该方法需要一个请求者演员和要创建的问候者数量。让我们看看消息处理器:

case GreetersResolution(Failure(_)) =>
  self ! GreetersCreationAuthorised

如果没有注册子演员,我们向自己发送一个GreetersCreationAuthorised消息,指定创建问候者是安全的。我们需要这种授权,因为有时创建新的问候者并不安全——即当当前演员仍有旧的问候者存活时。在这种情况下,我们可能会遇到我们想要避免的命名冲突:

case GreetersResolution(Success(_)) =>
  log.warning(s"Greeters already exist. Killing them and creating the new ones.")
  context.children
    .filter(c => raw"greeter-\d".r.unapplySeq(c.path.name).isDefined)
    .toList.traverse(_ ? Die)
    .map(GreetersTerminated) pipeTo self

如果解决结果是成功的,我们必须首先杀死这个演员的问候者。我们向日志输出一条警告消息,指定我们将要杀死现有的问候者。之后,我们从演员上下文中获取子代。"children"为我们提供了这个演员所有子代的迭代器。然后我们将使用正则表达式按名称过滤演员:

c => raw"greeter-\d".r.unapplySeq(c.path.name).isDefined

上面,raw是必需的,这样\就不会被视为转义字符,而是按字面意思解释。"r"将调用它的字符串转换为正则表达式对象——这个 API 是 Scala 核心库的一部分。"unapplySeq"尝试将其调用的正则表达式与作为参数传递给它的字符串进行匹配。如果匹配成功,该方法返回Some,否则返回None。有关更多信息,请参阅scala.util.matching.Regex的 Scala 核心 API。

如果我们还有其他子代,只有遵循特定命名约定的问候者会被选中。在这个例子中,我们没有其他子代。然而,仍然是一个好主意来识别目标子代。

在我们过滤出要杀死的演员后,我们向他们发送终止消息:

.toList.traverse(_ ? Die)

我们在traverse方法的主体中再次使用 ask 模式来产生一个 Future。问候者演员将响应一条消息,报告他们已被终止。这允许我们异步阻塞执行,一旦所有问候者都死亡,再继续。我们可以通过使用 ask 模式跟踪单个演员的终止,然后使用traverse方法将返回的 Futures 组合成一个 Future 来实现这一点。一旦所有演员都终止,这个 Future 将成功。

最后,我们将 Future 的内容包装到GreetersTerminated消息中。接下来,让我们看看GreetersTerminated分支:

case GreetersTerminated(report) =>
  log.info(s"All greeters terminated, report: $report. Creating the new ones now.")
  self ! GreetersCreationAuthorised

我们将终止报告输出到日志,并发送授权消息到self以开始创建问候者的过程:

case GreetersCreationAuthorised =>
  (1 to numGreeters).foreach { id =>
    context.actorOf(Props[Greeter], greeterFromId(id)) }
  log.info(s"Created $numGreeters greeters")
  requester ! JobDone
  context become baseReceive

GreetersCreationAuthorised是一个分支,只有当创建新的问候者是安全的时候才会执行。它将从循环中创建新的问候者:

(1 to numGreeters).foreach { id =>
  context.actorOf(Props[Greeter], greeterFromId(id)) }

在这里,actorOf的第二个参数定义如下:

def greeterFromId(id: Any) = s"greeter-$id"

接下来,我们通知请求者创建问候者的工作已完成。最后,我们将上下文切换回baseReceive。现在,让我们编写一个测试程序来看看示例是如何工作的:

implicit val timeout: Timeout = 3 seconds
val system = ActorSystem("hierarchy-demo")
val gm = system.actorOf(Props[this.GreetingsManager], "greetings-manager")

def printState(childrenEmpty: Boolean, isHelloMessage: Boolean) =
  Future { println(s"\n=== Children: ${if (childrenEmpty) "empty" else "present"}, " +
    s"Message: ${if (isHelloMessage) "SayHello" else "SpawnGreeters"}") }

Await.ready(for {
  _ <- printState(true, true)
  _ <- gm ? SayHello("me")

  _ <- printState(true, false)
  _ <- gm ? SpawnGreeters(3)

  _ <- printState(false, false)
  _ <- gm ? SpawnGreeters(3)

  _ <- printState(false, true)
  _ <- gm ? SayHello("me")
} yield (), 5 seconds)

我们首先向一个空的问候者管理器发送一条问候消息。然后,我们用相应的消息生成问候者。然后我们再次发送SpawnGreeters消息,看看问候者管理器将首先杀死现有的问候者,然后才生成新的问候者。最后,我们再次发送SayHello消息。

我们有两个消息和两个可能的状态,即管理者的孩子问候者的可能状态。这给我们提供了四种可能的组合。示例中的每条消息都检查这些情况中每一个的行为。注意,我们是如何使用询问模式来异步阻塞执行流程,直到演员响应已完成操作。这确保了我们不会过早地发送消息。

消息的输出如下所示:

图片

摘要

在本章中,我们讨论了 Akka 框架,它是 Scala 中面向演员编程的事实标准。我们学习了如何创建新的演员,如何定义它们,以及如何运行基于演员的应用程序。我们看到了演员是如何组织成演员系统,以及它们如何在层次结构中协同工作的。此外,我们还简要讨论了 Akka 为与演员和未来对象一起工作提供的模式。

在下一章中,我们将通过查看使用此模型实现的示例应用程序,来了解演员模型在实际中的应用。

问题

  1. Akka 实现中的演员模型原则是什么?

  2. 你如何在 Akka 中定义一个演员?

  3. 你如何创建一个新的演员?

  4. 你如何向演员发送消息?

  5. 询问模式是什么?你如何使用它?

  6. 管道模式是什么?你如何使用它?

第十三章:用例 - 并行网络爬虫

在上一章中,我们讨论了 actor 模型以及你可以用来用 actor 编程的框架。然而,actor 模型是一种范式,就像函数式编程一样。原则上,如果你从一个语言中了解了 actor 模型,即使另一个语言没有支持 actor 模型的框架,你仍然可以使用它。这是因为 actor 模型是一种关于并行计算推理的方法,而不是一些特定语言的工具集。

这种状态,就像函数式编程一样,有其自身的优点和缺点。优点是,如果你依赖于概念,那么你就不依赖于语言。一旦你了解了某个概念,你就可以在任何编程语言中使用它,并且能够使用它们。然而,学习曲线很陡峭。正是因为它全部关于范式和方法,仅仅安装一个库并浏览其文档后就开始使用,就像许多其他类似库或通用语言那样,是不够的。由于它全部关于范式转变,你需要付出一些学习努力来理解范式以及如何在实践中使用它。

在上一章中,我们建立了一套基于 actor 模型提供的工具集的理论基础,并讨论了 actor 模型的主要概念以及如何在实践中使用它。然而,由于它是一套理念而非仅仅是一个库,因此有必要培养对它工作原理的直觉。培养对新范式直觉的最好方法就是查看一些实际例子,这些例子将展示这个范式在实际中的应用。

因此,在本章中,我们将探讨 actor 模型可以应用的一个实际例子。我们将探讨网络爬虫的例子。

在本章中,我们将涵盖以下主题:

  • 问题陈述

  • 顺序解决方案

  • 使用 Akka 的并行解决方案

问题陈述

在本章中,我们将解决构建网络爬虫的问题。网络爬虫在索引和搜索网络领域非常重要。

网络的图结构

所有网站都可以想象成页面的图。每个页面都包含一些 HTML 标记和内容。作为内容的一部分,大多数网页都包含指向其他页面的链接。由于链接是用来从一个页面跳转到另一个页面的,因此我们可以将网络可视化为一个图。我们可以将链接可视化为从节点到节点的边。

给定整个互联网的这种模型,我们可以解决在网络上搜索信息的问题:

图片

我们在讨论搜索引擎面临的问题。搜索引擎的目标是索引存储在网上的信息,并设计算法,根据最终用户的查询高效地找到所需的页面——图中的节点。索引这些节点以及将用户请求与节点中存储的信息匹配所需的算法很复杂,我们不会在本章中讨论它们。通常,这些算法涉及高级机器学习和自然语言处理解决方案来理解和评估页面中存储的内容。机器学习和自然语言处理领域的最聪明的大脑在为像 Google 这样的公司处理搜索任务。

因此,在本章中,我们将讨论一个搜索引擎也面临但更容易解决的问题。在索引存储在网上的信息之前,需要收集这些信息。收集信息是一项遍历所有页面图并存储内容到数据库中的任务。网络爬虫的任务正是从某个节点开始遍历图,通过链接跟随到其他节点。在本章的示例中,我们不会将网站信息存储到数据库中,而是将重点放在网络爬虫任务上。

从图中收集信息

从图中收集信息可以表示如下:

图片

在前面的示意图中,蓝色节点是当前正在处理的节点,绿色节点是已经处理过的节点,白色节点是尚未处理的节点。这个前面的任务可以如下实现:

  1. 指定您希望从哪个 URL 开始,即遍历将开始的起始节点。

  2. 爬虫将通过向其发出GET请求来访问 URL,并接收一些 HTML:

图片

  1. 在接收到此 HTML 文本后,爬虫将提取当前节点的边缘——指向其他节点的链接。这些链接在 HTML 标记中定义良好。它们被指定如下:
<a herf="link_address">link_text</a>

因此,一旦我们有了 HTML 标记,我们就可以查找与先前模式匹配的内容。之后,我们可能想要将页面上展示的所有链接收集到一个单独的集合结构中,例如,一个列表。一旦我们有了这个,我们可能想要对当前节点链接的每个节点递归地执行相同的操作。

任务并行性

之前选择这个任务作为本章的例子,因为它本质上是可以并行化的。基本上,每当您有一系列相互之间不依赖的任务时,将任务并行化可能是一个好主意。在我们的例子中,前面的任务可以很好地被构想为一组独立的操作。单个操作单元是访问 URL 的任务,抽象出所有通过该 URL 页面链接的其他节点,然后对这些 URL 递归地执行相同的任务。

这使得我们的爬虫示例非常适合并行 actor 应用。对于每个 URL 运行所有这些任务可能是非常耗时的,因此应用一些策略到任务上会更为高效。即使在单核 CPU 计算机上,即使大多数任务以模拟并行方式处理,操作仍然会比顺序情况高效数十倍。这是因为从互联网或GET请求请求 HTML 时涉及大量的等待。

在接下来的图中,您可以看到一个从单个线程执行过程的请求示例:

所以,如您所见,线程发出请求,然后等待响应的产生。根据您自己的浏览经验,您很容易地说,有时一个网站的响应可能需要几秒钟才能到达。所以本质上,这些秒数将会被线程浪费,因为它将在这个任务上阻塞,而此时处理器将无法做任何有用的工作。

即使您只有一个核心,即使您只有模拟并行化的选择,如果当一个线程等待请求到达时,另一个线程发出请求或处理已经到达的响应,处理器仍然可以更有效地被利用。

如您从图中可以看出,请求的发出效率要高得多,而且当一个线程等待请求并休眠时,处理器正忙于处理其他有实际工作要做的线程:

通常,这种阻塞情况可能非常常见。例如,您可能有一些操作需要等待数据库给出响应,或者等待外部世界传来的消息。所以即使在模拟并行的情况下,这些情况也可以大大加快速度。不用说,大多数现代机器都在多核的环境中工作,这将极大地加快执行速度。

现在我们已经知道了任务的精确规格,并且我们知道了为什么并行化这个任务是有意义的,让我们动手看看一些实际的实现。然而,在我们深入到基于 actor 的任务实现的细节之前,让我们首先看看如何顺序地实现它,这样我们就有了一个基准来工作。

顺序解决方案

首先,让我们看看一旦构建了这个解决方案,我们希望如何使用它。同时,让我们看看我们期望从解决方案中得到的确切输出:

val target = new URL("http://mvnrepository.com/")
val res = fetchToDepth(target, 1)
 println(res.take(10).mkString("\n"))
 println(res.size)

在前面的代码中,我们执行以下操作。首先,我们希望能够用 Java 原生 URL 对象来定义我们的 URL。在这个例子中,我们使用 Maven 仓库网站作为爬取目标。mvnrepository.com是一个提供对所有 Maven 包进行简单搜索的网站。

之后,我们调用fetchToDepth。这是应该为我们实际工作的方法,并实际爬取网站以获取链接。作为第一个参数,我们提供一个我们想要处理的 URL。作为第二个参数,我们提供一个你想要搜索的图的深度。

深度的概念是为了避免无限递归。网络是一个非常互联的地方。因此,当我们从一个节点开始,并开始递归地进入与之相连的节点时,到达终端节点(即没有进一步链接的节点)可能需要非常长的时间。对于大多数网站来说,这样的查找可能是无限的,或者需要不合理的时间来完成。因此,我们希望限制我们的查找深度。其语义是,对于算法搜索的每个边,深度将减少一个。一旦深度达到零,算法将不会尝试跟随任何进一步的边。

函数执行的结果将是一个集合。确切地说,是从该网站收集到的链接集合。然后,我们取出前 10 个链接并将它们输出到标准输出,每行一个链接。同时,我们输出系统成功提取的总链接数。我们不打印所有链接,因为会有太多。

我们希望得到以下输出:

图片

接下来,让我们探索前面目标的顺序实现。让我们看看fetchToDepth

def fetchToDepth(url: URL, depth: Int, visited: Set[URL] = Set()): Set[URL]

如您所见,该函数接受三个参数。其中两个是我们之前已经见过并在我们的示例 API 使用中讨论过的。第三个是爬虫已经访问过的 URL 集合。为什么我们会有这样一个集合?为什么爬虫需要它?如果我们不存储所有已访问链接的集合,它会如何表现?

实际上,任何类似的图遍历情况都必须存储相同的集合。这里的问题是图中存在循环。

在下面的图中,您可以看到在网页爬虫应用程序中如何出现具有循环的图:

图片

仅当网站A引用网站B,然后网站B又引用网站A时,才会出现循环。如果图中存在循环,那么如果我们的遍历算法在不跟踪已访问节点的情况下尝试遍历它,那么它可能会进入无限循环。因此,我们需要跟踪已访问的节点。在fetchToDepth函数中要做的第一件事就是获取指定的 URL:

val links = fetch(url).getOrElse(Set())

我们使用以下函数来获取指定的 URL:

def fetch(url: URL): Option[Set[URL]] = Try {
  Jsoup.connect(url.toString)
    .get
    .getElementsByAttribute("href")
    .asScala.map( h => new URL(url, h.attr("href")) ).toSet
}.toOption

fetch函数是一个接收 URL 并输出Option[Set[URL]]的函数,这是包含此页面链接的数据结构。

在这个函数内部,除了副作用之外,并没有真正值得注意的事情发生。我们使用Try来捕获错误的副作用。然后,我们将Try转换为Option,因为我们对错误类型不感兴趣,如下所述。

这个函数的逻辑很简单。我们使用一个流行的 Java 库,称为Jsoap,来连接给定的 URL,发出 GET 请求,检索所有具有包含链接的href属性的元素,然后通过提取它们的链接来映射结果元素。

逻辑被封装在Try中,稍后将其转换为Option。为什么会有这样一个涉及TryOption的系统?正如我们在本书的第一部分所学,TryOption都是效果类型。效果类型背后的整个想法是抽象掉可能发生的某些副作用。由于我们的业务逻辑被封装在Try效果中,因此Try抽象掉的一些副作用可能会在这个逻辑中发生。

Try抽象掉了错误或异常的可能性。实际上,当我们向远程服务器发出请求时,可能会发生很多错误。例如,请求可能会超时,我们可能会失去互联网连接,或者服务器可能会返回错误的状态码。

在大规模数据处理的情况下,一些数据节点不可避免地会循环。这意味着在执行之前讨论的爬取操作时,我们可以几乎肯定有时它会失败。如果你设计了一个没有考虑到这种错误的函数,会发生什么?很可能会遇到异常,并在数据集中途失败。这就是为什么容错性是这类系统的一个关键属性。

在前面的例子中,容错是通过将错误的可能性封装在Try效果类型中实现的。这里要注意的另一件事不仅是Try类型,还有它被转换成了OptionOption表示一个函数可能或可能不产生结果。在从Try转换为Option的上下文中,如果Try是成功的尝试,它会被映射到Some;如果它是失败的,它会被映射到None。在失败的情况下,当我们把Try转换为Option时,一些关于已发生错误的详细信息会丢失。这一事实反映了我们不在乎可能发生的错误类型的态度。

这种态度需要进一步解释,因为它通常在我们有意丢弃数据的地方都会出现。我们讨论过,在这种大量数据处理中,错误是不可避免的。当然,如果您的算法能够优雅地捕获和处理它将遇到的错误中的大多数,那将是件好事,然而,这并不总是可能的。算法最终会遇到错误这一事实是无法避免的,因为算法不是在处理一些虚拟数据,而是在处理现实世界的网站。无论何时您处理现实世界的数据,错误都是生活的事实,您无法计划处理发生的每一个可能的错误。

因此,构建一个肯定会遇到错误的程序的正确方式是容错。这意味着我们构建程序时,就像它肯定会遇到错误一样,并计划从任何错误中恢复,而不实际指定错误类型。我们在这里将Try转换为Option的事实反映了我们对错误的态度。这种态度是,我们不在乎执行过程中可能发生的哪种错误,我们只关心错误可能发生。

容错是 Akka 设计的基本原则之一。Akka 是以容错和弹性为前提构建的,这意味着演员被设计成肯定会遇到错误,Akka 为您提供了一个框架来指定如何从它们中恢复。

现在让我们回到fetchToDepth方法中的fetch行,看看这种态度在我们的示例逻辑中是如何具体体现的:

fetch(url).getOrElse(Set())

让我们回到我们的fetchToDepth示例。在我们对一个给定的 URL 执行影响语句之后,我们也在它上面执行了一个getOrElse调用。getOrElse是 Scala Option上定义的一个方法,其签名如下:

图片

所以基本上,这个方法试图从Option中提取一个值。如果值不在Option中,它将输出方法提供的默认值。getOrElse等同于以下代码:

type A
val opt: Option[A]
val default: A

opt match {
  case Some(x) => x
  case None => default
}

因此,现在我们已经从给定的链接中提取了一组链接。让我们现在看看在我们的顺序示例中它是如何遍历的:

if (depth > 0) links ++ links
 .filter(!visited(_))
 ./*...*/

在前面的代码中,你可以看到fetchToDepth方法中的代码块。首先,我们检查深度是否大于0。之前我们讨论了如何通过指定深度约束来避免无限执行。

if检查之后的第一个语句是当前链接累加器,即links变量,它与一些更大的右侧语句合并。在上面的代码片段中,只展示了这个语句的一部分,我们将一步一步地讨论它。整体来看,这个语句递归地将fetchToDepth应用于links集合中的所有链接。

这意味着++操作符的右侧从我们检索到的集合中获取每个链接,并提取其页面中存在的所有链接。这必须递归地完成。但首先,我们需要清理当前链接的所有抽象 URL 集合,以确保它不包含我们之前访问过的链接。这是为了解决图中循环的问题:

.toList
.zipWithIndex

接下来,我们通过调用toListzipWithIndex方法进一步转换得到的集合。基本上,这两个方法是为了日志记录目的而需要的。在数据处理操作中,你希望有一些报告来跟踪操作。在我们的案例中,我们希望给每个将要访问的链接分配一个数字 ID,并将我们正在访问具有给定 ID 的链接的事实记录到标准输出中。zipWithIndex的签名如下:

所以基本上,一个元素列表A变成了一个AInt对的列表:

.foldLeft(Set[URL]()) { case (accum, (next, id)) =>
 println(s"Progress for depth $depth: $id of ${links.size}")
 accum ++ (if (!accum(next)) fetchToDepth(next, depth - 1, accum) else Set())
 }

在前面的代码中,你可以看到实际执行每个给定 URL 处理的逻辑。我们使用了一个foldLeft方法。让我们看看它的签名:

基本上,这个方法将你的整个集合折叠成一个单一值。为了更简单地说明其用法,考虑以下代码块:

(1 to 10).toList.foldLeft(0) { (total, nextElement) => total + nextElement }

所以基本上,我们从一个空的累加器开始,并指定在作用域内给定一个累积值时对每个元素要做什么。列表的后续每个元素都必须添加到累加器中。

回到我们的fetchToDepth示例,使用foldLeft的理由如下。最终,我们有一个链接集合,并且对于这个集合的每个元素,我们需要计算它链接到的 URL 集合。然而,我们感兴趣的是包含我们刚刚调用的所有 URL 的所有链接的合并集合。因此,这个例子类似于整数加法的例子。只不过在这里,我们是在一组链接集合上计算并集。

现在我们来看看作为foldLeft第二个参数传递的代码块。首先,我们执行一个日志语句。然后,我们执行计算属于当前 URL 的链接的步骤。然后,我们将这些链接添加到组合累加器中。

注意,在实际上爬取链接之前,我们执行一个检查,看看它是否已经包含在累加器中。只有当它不包含在当前累加器中时,才会进行爬取。这个检查用于防止重复工作。如果链接已经包含在累加器中,这意味着它已经被算法处理过了,因为这是一个图的深度优先遍历。所以我们不需要再次处理它。

此外,请注意,我们使用对 fetchToDepth 函数的递归调用进行处理,深度减少一个:

else links

最后,如果深度为 0,我们将返回从当前页面提取的链接集合。这意味着我们将在此处停止算法。

fetchToDepth 函数的完整代码如下:

def fetchToDepth(url: URL, depth: Int, visited: Set[URL] = Set()): Set[URL] = {
    val links = fetch(url).getOrElse(Set())

    if (depth > 0) links ++ links
      .filter(!visited(_))
      .toList
      .zipWithIndex
      .foldLeft(Set[URL]()) { case (accum, (next, id)) =>
        println(s"Progress for depth $depth: $id of ${links.size}")
        accum ++ (if (!accum(next)) fetchToDepth(next, depth - 1, accum) else Set())
      }
      .toSet
    else links
  }

接下来,让我们讨论一下对前面问题的并行解决方案可能的样子。

使用 Akka 的并行解决方案

在解决并行化我们的爬虫的问题之前,让我们讨论我们将如何处理它的策略。

策略

我们将使用演员树来建模创建演员系统的难题。这是因为任务本身自然形成了一个树。

我们已经讨论了所有互联网上的链接和页面如何构成一个图。然而,在我们的例子中,我们还讨论了在处理和遍历这个图时两个不希望出现的情况——循环和工作重复。所以,如果我们已经访问了一个特定的节点,我们就不会再访问它了。

这意味着在处理时,我们的图变成了一个树。这意味着当你从子节点向下遍历时,你不能从子节点到达父节点。这个树可能看起来如下:

图片

我们将与根级节点进行通信。所以基本上,我们将向此节点发送一个我们将从其开始爬取的链接,然后此演员将爬取它。一旦从这个链接中提取出所有链接,它将为每个这样的链接生成一个子演员,该子演员将对该链接执行相同的操作。这是一个递归策略。

这些工作演员将向其父演员报告结果。最底层演员的结果将通过树形结构传播到顶层演员。

在这个实现中,请注意我们并没有限制我们对演员的使用。每个链接都由一个专门的演员处理,我们并不真正关心将生成多少演员。这是因为演员是非常轻量级的原语,你不应该害怕慷慨地生成演员。

让我们看看如何实现这个策略。

实现

首先,让我们看看我们想要的 API 及其用法:

val system = ActorSystem("PiSystem")
val root = system actorOf Worker.workerProps

(root ? Job(new URL("http://mvnrepository.com/"), 1)).onSuccess {
  case Result(res) =>
  println("Crawling finished successfully")
  println(res.take(10).mkString("\n"))
  println(res.size)

}

在前面的代码中,你可以看到并行化演员应用程序的主要方法。正如你所看到的,我们创建了一个演员系统和根级别的工人演员。想法是将所有处理演员表示为一个单一的类,Worker。单个工人的任务是处理单个 URL 并产生额外的工人来处理从中提取的链接。

接下来,你可以看到一个查询演员的示例。首先,我们有以下这一行:

(root ? Job(new URL("http://mvnrepository.com/"), 1)).onSuccess {

通常,你使用 ! 操作符向演员发送消息。然而,这里我们使用 ?,因为我们正在使用询问模式。本质上,? 是向演员发送消息并等待它以另一个消息响应的行为。普通的 ! 返回 Unit,然而,? 询问模式返回一个可能响应的 Future

图片

因此,本质上,我们向根演员发送一个 Job 消息,并且我们期望在 Future 中得到一些响应。

Job 是一个普通的案例类,它指定了我们将要抓取的 URL 以及将要下探的深度。这两个参数的语义与顺序示例中的相同。

之后,我们在 Future 上调用 onSuccess 方法来设置回调。所以,一旦结果到达我们的位置,我们将向标准输出报告。

现在,让我们看看工作演员是如何实现以及它是如何工作的:

def receive = awaitingForTasks

在前面的代码中,receive 方法是用另一个称为 awaitingForTasks 的方法实现的。这是因为我们打算根据发送给演员的消息更改 receive 的实现。基本上,我们的演员将处于几个状态,在这些状态下它将接受不同的消息集合并以不同的方式对它们做出反应,但想法是将每个状态封装在单独的回调方法中,然后在这些回调的实现之间切换。

让我们看看演员的默认状态,awaitingForTasks

def awaitingForTasks: Receive = {
 case Job(url, depth, visited) =>
 replyTo = Some(sender)

 val links = fetch(url).getOrElse(Set()).filter(!visited(_))
 buffer = links
/*...*/

awaitingForTasks 指定了当演员接收到 Job 消息时应该如何反应。消息应该告诉演员开始以特定深度爬取某个 URL。此外,正如你所看到的,我们将所有已访问的节点存储在 visited 集合中。Visited 是所有已访问 URL 的集合,它的语义和动机与顺序示例中的相同,以避免重复不必要的操作。

之后,我们设置了 replyTo 变量。它指定了任务的发送者,该发送者有兴趣接收我们爬取的结果。

在设置这个变量之后,我们开始爬取过程。首先,我们使用从顺序示例中已经熟悉的 fetch 方法来获取给定 URL 页面上所有存在的链接集合,并过滤掉我们已访问的链接。

之后,类似于我们的顺序示例,我们检查深度是否允许进一步下降,如果允许,我们将按照以下方式执行链接的递归处理:

if (depth > 0) {
  println(s"Processing links of $url, descending now")

  children = Set()
  answered = 0

  for { l <- links } dispatch(l, depth - 1, visited ++ buffer)
  context become processing
}

首先,我们将定义一个空的子演员集合,这样我们就可以跟踪它们的处理情况,并根据子演员的状态变化来控制我们自己的状态。例如,我们必须知道何时确切地向请求演员报告工作结果。这必须在所有子演员完成工作后只做一次。

此外,我们将 answered 变量设置为 0。这是一个跟踪成功回复此演员处理结果的演员数量的变量。想法是,一旦这个指标达到 children 大小,我们将用处理结果回复 replyTo 演员最有趣的方法是 dispatch

def dispatch(lnk: URL, depth: Int, visited: Set[URL]): Unit = {
  val child = context actorOf Worker.workerProps
  children += child
  child ! Job(lnk, depth, visited)
}

因此,dispatch 创建一个新的工作演员并将其添加到所有子演员的集合中,最后,它们被要求对一个给定的 URL 执行处理工作。为每个单独的 URL 初始化一个单独的工作演员。

最后,让我们关注一下 Job 子句中的 context become processing 行。本质上,context become 是一个切换此演员 receive 实现的方法。之前,我们有一个 awaitingForTasks 的实现。然而,现在我们将它切换到 processing,我们将在本章中进一步讨论。

但在讨论它之前,让我们看看我们的 if 语句的 else 分支:

else {
 println(s"Reached maximal depth on $url - returning its links only")
 sender ! Result(buffer)
 context stop self
}

因此,正如我们所见,一旦达到一定的深度,我们将返回收集到的链接到请求演员的缓冲区中。

现在,让我们看看演员的 processing 状态:

def processing: Receive = {
  case Result(urls) =>
    replyTo match {
      case Some(to) =>
        answered += 1
        println(s"$self: $answered actors responded of ${children.size}")
        buffer ++= urls
        if (answered == children.size) {
          to ! Result(buffer)
          context stop self
        }

      case None => println("replyTo actor is None, something went wrong")
    }
}

如您所见,一旦这个演员成为处理演员,它将响应 Result 消息,并且它将停止响应 Job 消息。这意味着一旦您向演员发送了 Job 消息并且它开始处理它,它将不再接受任何其他工作请求。

processing 的主体中,我们确保 replyTo 演员被设置。原则上,一旦我们达到这个点,它应该始终被设置。然而,replyTo 是一个 Option,处理可选性的好方法是有一个 match 语句,该语句明确检查这个 Option 是否已定义。你永远不知道这样的程序中可能会出现什么错误,所以最好是安全第一。

processing 的逻辑如下。Result 是一个应该从其子演员到达此演员的消息。首先,我们将回答此演员的演员数量增加。我们通过 answered += 1 来做这件事。

在一些调试输出之后,我们将子演员发送给此演员的有效负载添加到此演员收集的所有链接集合中——buffer ++ = urls

最后,我们检查所有子演员是否都已回复。我们通过检查answered计数器是否等于所有子演员的大小来实现这一点。如果是,我们就向请求的演员发送我们收集的链接,to ! Result(buffer),然后最终停止此演员,因为它没有其他事情可做,context stop self

运行此演员系统的结果如下:

注意事项

与基于同步的应用程序相比,虽然演员应用程序编写和推理更为方便,但它们仍然比普通的顺序应用程序复杂得多。在本节中,我们将讨论一些应用程序的注意事项。

访问过的链接

这里最具影响力的注意事项是跟踪已访问演员的局部性。如果你还记得,在顺序示例中,我们使用foldLeft函数来累积每个 URL 处理的结果,并且我们始终有一个完整、最新的所有 URL 列表,这是整个应用程序收集的。这意味着递归爬取调用总是对应用程序迄今为止收集的内容有一个全面的了解。

在图中,我们看到一个使用foldLeft进行处理的顺序示例:

所有已处理的节点都用绿色突出显示,当前 URL 用蓝色突出显示。当前 URL 包含之前收集的所有链接列表。因此,它不会处理它们。这种情况是可能的,因为处理是顺序进行的。

然而,以下图中描述的并行示例情况则不同:

在前面的图中,蓝色部分显示了当前节点。绿色节点是当前节点所了解的所有节点。请注意,尽管它了解其兄弟节点,但它并不了解处理这些兄弟节点的结果。这是因为处理是并行的。在顺序示例中,我们有一个从左到右的深度优先树遍历。然而,在并行示例中,子节点是相互并行处理的。这意味着一个节点可能了解其兄弟节点的信息。然而,它将不会了解从其兄弟节点收集的结果。这是因为这些结果是在此节点计算其自身结果的同时并行收集的。而且我们不知道哪个节点会先完成。这意味着前面的应用程序在消除冗余工作方面并不理想。

存储演员访问过的所有链接的问题是一个经典的共享可变资源问题。在演员模型中解决这个问题的一个方案是创建一个单独的演员,该演员有一个列表,列出了所有已经访问过且不需要进一步访问的链接。因此,在通过树下降之前,每个演员都应该就是否处理某些链接的问题咨询该演员。

你应该考虑的另一个注意事项是容错性。

容错性

为了使示例简单,在并行示例中,我们使用了来自顺序示例的fetch函数来获取某个 URL 的内容:

val links = fetch(url).getOrElse(Set()).filter(!visited(_))

这个函数返回Option的动机是为了在顺序示例中的容错性——如果结果无法计算,我们返回None。然而,在演员设置中,Akka 为你提供了一个框架来指定如果演员失败时应该做什么。所以原则上,我们可以进一步改进我们的示例,使用一个专门的fetch函数,这个函数能够完美地抛出异常。然而,你可能想要指定演员级别的逻辑,比如如何重启自身以及如何通过这种紧急情况保持其状态。

计数响应的演员

在示例中,我们计算了响应演员的子演员数量,以确定何时演员准备好对其父演员做出响应:

answered += 1
// ...
if (answered == children.size) {
  to ! Result(buffer)
  context stop self
}

这种场景可能会产生某些不希望的结果。首先,这意味着系统响应所需的时间等于最深和最慢的链接被解决和处理所需的时间。

这种推理背后的逻辑如下。processing回调确定何时认为处理树中的一个节点已完成:

if (answered == children.size)

因此,一个节点完成,当且仅当其最慢的子节点完成。然而,我们应该记住,我们正在处理现实世界的处理,我们不应该忘记现实世界中可能发生的所有副作用。我们讨论的第一个副作用是失败和错误。我们通过设计我们的应用程序以具有容错性来处理这种副作用。另一个副作用是时间。仍然可能存在一些页面需要非常长的时间才能获取。因此,我们绝不能忘记这种副作用可能发生,并且可能需要制定策略来应对这种副作用。

一种直观的策略是超时。就像在容错性的情况下,每当一块数据处理时间过长时,我们可以丢弃这块数据。想法是,我们仍然有足够的数据,对于许多应用来说,并不需要 100%地召回所有目标数据。

具体来说,你可能想安排一个消息发送到当前演员。在收到此消息后,演员将立即将其所有结果发送回replyTo演员并终止自己,以便它不会对任何后续消息做出反应。对此类消息的反应可能是递归地杀死所有子演员,因为它们不再需要存在,因为它们将无法通过父演员报告此错误数据。另一种策略可能是递归地将此超时消息传播到子演员,而不会立即杀死它们。然后,子演员将立即返回它们已经完成的任何进度并终止。

现实世界的副作用

在本小节中,我们已经看到了两种副作用的实例——错误和时间。现实世界的本质是,你往往不知道会遇到哪些副作用。

这些副作用可能包括限制发送到特定域的请求数量的必要性,因为某些网站倾向于阻止发送过多请求的实体,或者我们可能想使用代理服务器来访问其他方式无法访问的网站。某些网站可能通过 Ajax 存储和检索数据,常规的抓取技术通常不起作用。

所有这些场景都可以建模为它们自己的副作用。当与实际应用一起工作时,始终要考虑你特定场景中可能出现的副作用。

一旦你决定了将要遇到什么,你就能决定如何处理和抽象副作用。你可以使用的工具包括演员系统的内置功能,或者我们在本书前半部分讨论的纯函数编程的能力。

概述

在本章中,我们探讨了使用 Akka 开发应用程序。首先,我们开发了一个针对该问题的顺序解决方案。然后,我们确定了该解决方案的独立子任务,并讨论了如何并行化它们。最后,我们使用 Akka 设计了一个并行解决方案。

此外,我们还讨论了在开发此类应用时可能会遇到的一些注意事项。其中大部分都与可能发生的副作用有关,以及构建并行应用时演员模型特有的特殊性。

第十四章:Scala 简介

本书广泛使用 Scala 作为其主要示例语言。在本章中,我们将简要概述语言基础。首先,我们将从将其作为示例语言的动机开始。

本章涵盖以下主题:

  • 使用 Scala 的动机

  • 变量和函数

  • 控制结构

  • 继承模型

本章旨在为本书的其余部分提供一个快速介绍,不应被视为 Scala 的完整教程。

使用 Scala 的动机

本书使用 Scala 作为示例语言的主要动机如下。首先,Scala 是一种函数式语言。这意味着它支持迄今为止开发的所有函数式编程风格。选择 Scala 的另一个原因是它是专门为面向对象程序员设计的。Scala 将自己定位在面向对象和函数式语言之间。这意味着来自面向对象世界的程序员可以使用 Scala 作为面向对象语言。这有助于从面向对象风格的过渡。实际上,Scala 经常被用作没有分号的 Java,这意味着你可以用与以前编写 Java 程序相同的方式编写 Scala 程序。上述原因极大地促进了新程序员的过渡。此外,Scala 还具有非常强大的函数式编程机制。因此,你可以像在 Haskell 中一样编写函数式程序。

我们甚至可以说 Scala 比 Haskell 更强大,因为在某些情况下,面向对象的方法是绝对必要的。你可以访问整个 JVM 基础设施,这意味着你可以轻松地从 Scala 使用任何 Java 库。该基础设施非常成熟且面向行业。不幸的是,你不能对 Haskell 说同样的话,因为 Haskell 比 JVM 语言更不适用于生产环境。像 Haskell 这样的纯函数式语言在生产环境中使用。然而,它们在依赖管理或编译工具等基础设施方面不如 Scala 成熟。尽管你可以在 Scala 中使用所有来自 Java 的面向对象库,但 Scala 还有一系列本机库,这些库也是生产就绪的,可以促进纯函数式编程风格。

值得注意的是,Scala 是一种实验性语言。这意味着它经常被用作测试计算机科学新功能和研究的游乐场。这意味着作为 Scala 程序员,你可以获得前沿的研究成果。结合对 JVM 的访问,你将获得一个理想工具,在开发实际软件的同时,提升你在计算机科学领域的知识。

简而言之,所有这些都意味着在使用 Scala 时,你可以使用广泛风格的组合,从传统的面向对象方法到前沿的函数式编程研究。这使得它成为这本书示例的绝佳语言。

Scala 架构

首先,让我们看看为 Scala 开发的架构。首先让我们看看 Scala 解释器。

Scala 解释器

尽管 Scala 是一种编译型语言,但它有自己的解释器。它是标准语言分发的组成部分,如果你安装了 Scala,你将能够访问它。请参阅 www.scala-lang.org/ 了解安装 Scala 的说明。

你可以通过简单地输入 scala 命令从命令行访问解释器。

在这个解释器中,你可以运行 Scala 表达式,并实时获取它们的评估结果。除了普通表达式外,你还可以运行解释器特定的表达式来调整它。这类表达式通常以冒号后跟关键字开始。要访问所有相关 Scala 解释器表达式的列表,请输入以下命令:

:help 

上述命令的输出如下:

图片

SBT 构建工具

SBT 是 Scala 的构建工具。它是一个专门为 Scala 开发的专用构建工具。可以将 Scala 与 Gradle 或 Maven 集成,实际上,这是许多团队更愿意选择的做法。SBT 应该是简单的,但实际上,它恰恰相反。如果你决定使用 SBT 作为你的 Scala 构建工具,请注意它拥有复杂的架构,并且文档并不完善。

然而,它相当强大。它允许你使用 Scala 语言的一个子集编写构建描述。这意味着你的构建脚本本身就是 Scala 程序。这不是 Gradle 或 Maven 等构建工具所提供的东西。

对于这本书,我们不需要熟悉 SBT。这本书的示例 GitHub 仓库使用 SBT,因此你需要对这款软件有一些基本的了解才能运行示例。然而,在这本书中,我们没有在功能编程至关重要的部分介绍 SBT。如果你想更熟悉这个工具,请参阅 SBT 的官方文档。

变量和函数

Scala 语言的骨架是变量和函数。

变量的定义如下:

scala> var foo = 3
foo: Int = 3

变量使用 var 关键字定义,后跟变量名,然后是你要分配给变量的值。

使用上述方式定义的变量是可变的。这意味着一旦它们被赋值,你就可以修改它们:

scala> var foo = 3
foo: Int = 3

scala> foo = 20
foo: Int = 20

然而,纯函数式编程倡导者反对这种风格。由于 Scala 将自己定位为纯函数式和面向对象风格的混合体,它提供了一种定义不可变变量的方法:

scala> val bar = 3
bar: Int = 3

现在,如果你尝试修改这个变量,你将得到一个编译时错误:

scala> val bar = 3
bar: Int = 3

scala> bar = 20
<console>:12: error: reassignment to val
       bar = 20

除了所有这些之外,Scala 还具有类似函数的语法:

scala> def square(x: Int) = x * x
square: (x: Int)Int

scala> square(10)
res0: Int = 100

scala> square(2)
res1: Int = 4

因此,一个函数就像一个值。然而,它可以由参数化,并且每次调用时都会评估。一个普通值只评估一次。

值可以通过 lazy 属性来修改,使其进行惰性评估:

scala> val x = { println("x value is evaluated now"); 10 }
x value is evaluated now
x: Int = 10

scala> lazy val x = { println("x value is evaluated now"); 10 }
x: Int = <lazy>

scala> x
x value is evaluated now
res2: Int = 10

当你以这种方式找到时,它不会立即评估,而是在第一次调用时评估。从某种意义上说,它类似于函数,因为它不会立即评估。然而,函数每次调用时都会被评估,而值则不同。

在前面的代码中,它们的定义都没有指定返回类型。然而,Scala 是一种强类型语言。编译器知道所有变量的类型。Scala 的编译器功能强大,它可以在广泛的情况下推断值和变量的类型,因此你不需要显式提供它们。所以,在前面的代码中,编译器推断出值、变量和函数的类型。

你可以显式指定你希望变量拥有的类型如下:

scala> var x: Int = 5
x: Int = 5

scala> var x: String = 4
<console>:11: error: type mismatch;
 found : Int(4)
 required: String
       var x: String = 4
                       ^

scala> val x: Int = 5
x: Int = 5

scala> def square(x: Int): Int = x * x
square: (x: Int)Int

此外,请注意,当你通过 Scala 解释器运行没有显式类型指定的代码时,结果将知道其类型。

控制结构

类似于大多数现代编程语言,Scala 语言有一系列控制结构;例如,用于分支和循环。这些控制结构包括 ifwhilefor 和模式匹配。

如果和 While

ifwhile 的实现方式与其他任何编程语言相同:

scala> val flag = true
flag: Boolean = true

scala> if (flag) {
     | println("Flag is true")
     | }
Flag is true

scala> if (!flag) {
     | println("Flag is false")
     | } else {
     | println("Flag is true")
     | }
Flag is true

scala> var x: Int = 0
x: Int = 0

scala> while (x < 5) {
     | x += 1
     | println(s"x = $x")
     | }
x = 1
x = 2
x = 3
x = 4
x = 5

注意,在这些结构中,如果结构体是一个单独的表达式,你可以选择性地省略花括号:

scala> if (flag) println("Flag is true")
Flag is true

这是在 Scala 的许多地方都可以做到的事情。无论你在哪里有一个由单个表达式组成的主体,你都可以省略围绕这个表达式的花括号。然而,这个规则有一些例外。

For

for 语句稍微有些不寻常。实际上,for 语句是 foreachmapflatMap 方法应用的一种语法糖。例如,看看以下表达式:

scala> val list = 0 to 3
list: scala.collection.immutable.Range.Inclusive = Range 0 to 3

scala> val result =
     | for {
     |   e <- list
     |   list2 = 0 to e
     |   e2 <- list2
     | } yield (e, e2)
result: scala.collection.immutable.IndexedSeq[(Int, Int)] = Vector((0,0), (1,0), (1,1), (2,0), (2,1), (2,2), (3,0), (3,1), (3,2), (3,3))

scala> println(result.mkString("\n"))
(0,0)
(1,0)
(1,1)
(2,0)
(2,1)
(2,2)
(3,0)
(3,1)
(3,2)
(3,3)

前面的 for 表达式展开为以下方法应用:

scala> val result = list.flatMap { e =>
     | val list2 = 0 to e
     | list2.map { e2 => (e, e2) }
     | }
result: scala.collection.immutable.IndexedSeq[(Int, Int)] = Vector((0,0), (1,0), (1,1), (2,0), (2,1), (2,2), (3,0), (3,1), (3,2), (3,3))

因此,基本上,如果一个类型定义了前面代码中指定的方法,你可以用 for 结构来编写应用程序。例如,如果你使用一个定义了 mapflatMapforeachOption 类型,你可以编写如下程序:

scala> val opt1 = Some(3)
opt1: Some[Int] = Some(3)

scala> val opt2 = Some(2)
opt2: Some[Int] = Some(2)

scala> val opt3: Option[Int] = None
opt3: Option[Int] = None

scala> val res1 =
     | for {
     |   e1 <- opt1
     |   e2 <- opt2
     | } yield e1 * e2
res1: Option[Int] = Some(6)

scala> val res2 =
     | for {
     |   e1 <- opt1
     |   e3 <- opt3
     | } yield e1 * e3
res2: Option[Int] = None

在 Scala 中,for 结构不被称为循环,而是一种 Monadic 流。这是由于函数式编程中 mapflatMap 函数的特殊意义。

模式匹配

Scala 中的特殊结构包括部分函数和模式匹配。例如,你可以按照以下方式编写表达式:

scala> val str = "Foo"
str: String = Foo

scala> str match {
     | case "Bar" => println("It is a bar")
     | case "Foo" => println("It is a foo")
     | }
It is a foo

更复杂的模式匹配也是可能的。例如,给定一个列表,我们可以匹配其头部和尾部,或者其头部和第二个参数及其尾部:

scala> val list = List(1, 2, 3, 4, 5)
list: List[Int] = List(1, 2, 3, 4, 5)

scala> list match {
     | case e1 :: e2 :: rest => e1 + e2
     | }
<console>:13: warning: match may not be exhaustive.
It would fail on the following inputs: List(_), Nil
       list match {
       ^
res10: Int = 3

实际上,我们可以使用所谓的提取器在几乎任何东西上执行模式匹配。例如,可以匹配自定义数据类型,如下所示:

scala> class Dummy(x: Int) { val xSquared = x * x }
defined class Dummy

scala> object square {
     | def unapply(d: Dummy): Option[Int] = Some(d.xSquared)
     | }
defined object square

scala> new Dummy(3) match {
     | case square(s) => println(s"Square is $s")
     | }
Square is 9

模式匹配的语义是,在运行时,环境将对相关数据类型调用unapply函数,并查看该函数是否返回某些结果或是否为None。如果在选项中返回了某些结果,则该结果用于填充模式匹配子句中的变量。否则,该模式被认为没有匹配。

部分函数

前面的模式匹配语句与 Scala 中部分函数的概念非常接近。与模式匹配语句有它们可以处理的特定案例域并抛出异常处理所有其他情况一样,部分函数是在它们输入域的一部分上定义的。例如,前面的match语句可以转换为部分函数,如下所示:

scala> val findSquare: PartialFunction[Any, Int] = {
     | case x: Int => x * x
     | case square(s) => s
     | }
findSquare: PartialFunction[Any,Int] = <function1>

scala> findSquare(2)
res12: Int = 4

scala> findSquare(new Dummy(3))
res13: Int = 9

scala> findSquare("Stuff")
scala.MatchError: Stuff (of class java.lang.String)
  at scala.PartialFunction$$anon$1.apply(PartialFunction.scala:255)
  at scala.PartialFunction$$anon$1.apply(PartialFunction.scala:253)
  at $anonfun$1.applyOrElse(<console>:13)
  at scala.runtime.AbstractPartialFunction.apply(AbstractPartialFunction.scala:34)
  ... 28 elided

继承模型

Scala 具有许多面向对象的功能。这意味着它支持面向对象编程的核心继承概念。此外,由于 Scala 编译到 Java 虚拟机,为了 Java 互操作性,它必须支持与 Java 相同的模型。

Scala 中的类与它们的 Java 对应物具有类似的语义。它们如下定义:

scala> :paste
// Entering paste mode (ctrl-D to finish)

class Dummy(constructorArgument: String) {
  var variable: Int = 0
  val value: String = constructorArgument * 2
  def method(x: Int): String = s"You gave me $x"
}

// Exiting paste mode, now interpreting.

defined class Dummy

scala> new Dummy("Foo")
res15: Dummy = Dummy@1a2f7e20

scala> res15.variable
res16: Int = 0

scala> res15.value
res17: String = FooFoo

scala> res15.method(2)
res18: String = You gave me 2

此外,还可以在 Scala 中定义所谓的案例类。这些类用于表示产品类型,即在一个数据类型中将多个类型绑定在一起。例如,可以定义一个用于User域对象的案例类,如下所示:

scala> case class User(id: Int, name: String, passwordHash: String)
defined class User

正如其名称所示,情况类主要用于模式匹配。当你定义一个情况类时,编译器会自动为该类生成提取器,以便可以在模式匹配中使用,如下所示:

scala> val user = User(1, "dummyuser123", "d8578edf8458ce06fbc5bb76a58c5ca4")
user: User = User(1,dummyuser123,d8578edf8458ce06fbc5bb76a58c5ca4)

scala> user match {
     | case User(id, name, hash) => println(s"The user $name has id $id and password hash $hash")
     | }
The user dummyuser123 has id 1 and password hash d8578edf8458ce06fbc5bb76a58c5ca4

此外,编译器为情况类生成方便的toStringequalshashCode方法:

scala> user.toString
res20: String = User(1,dummyuser123,d8578edf8458ce06fbc5bb76a58c5ca4)

scala> val user2 = User(user.id, user.name, user.passwordHash)
user2: User = User(1,dummyuser123,d8578edf8458ce06fbc5bb76a58c5ca4)

scala> user.equals(user2)
res21: Boolean = true

scala> user.hashCode
res22: Int = -363163489

scala> user2.hashCode
res23: Int = -363163489

情况类在建模领域时特别有用。

特质

Scala 中面向对象接口的概念封装在一个特质中。与接口类似,特质可以有抽象成员。然而,与 Java 接口不同,特质也可以有具体成员。这些成员将被注入到实现类中:

scala> :paste
// Entering paste mode (ctrl-D to finish)

trait Foo {
  def saySomething = println("I am inherited from Foo")
}

// Exiting paste mode, now interpreting.

defined trait Foo

就像在 Java 中一样,Scala 类可以实现多个特质。然而,由于 Scala 中的特质可以有具体成员,因此需要一个允许这种情况的新继承模型。

在 Scala 中,实现了一个所谓的线性化模型。这意味着每当一个类从多个特质继承时,它们将被组织成一个清晰的序列,这决定了继承的优先级。例如,考虑以下继承情况:

scala> :paste
// Entering paste mode (ctrl-D to finish)

trait Foo {
  def saySomething = println("I am inherited from Foo")
}

trait Bar {
  def saySomething = println("I am inherited from Bar")
}

class Dummy extends Foo with Bar {
  override def saySomething = super.saySomething
}

// Exiting paste mode, now interpreting.

defined trait Foo
defined trait Bar
defined class Dummy

scala> new Dummy().saySomething
I am inherited from Bar

在这种情况下,Bar特质将优先于Foo特质。这允许您从多个特质中继承,并了解它们将被应用的精确顺序。

单例对象

在 Scala 中,无法使一个类具有静态成员。然而,静态成员的概念存在于 Java 中。由于 Scala 编译到 JVM,它需要一种方式来模拟这个概念。在 Scala 中,使用单例对象的概念来模拟静态成员:

scala> :paste
// Entering paste mode (ctrl-D to finish)

object Foo {
  def say = println("I am Foo")
}

// Exiting paste mode, now interpreting.

defined object Foo

scala> Foo.say
I am Foo

在前面的代码中,我们可以直接通过其名称调用单例对象的成员,而无需实例化它或对其进行其他操作。这是因为它是一个由我们的object语句构建的独立完整对象。它在整个 JVM 中只有一个实例存在。

单例对象的概念可以用来模拟 Java 中的静态成员。在 Scala 中,有一个所谓的特质的伴生对象或类的概念。对于任何特质或类,如果您定义了一个与问题实体同名的对象,它就被认为是它的伴生对象。这个类的所有静态成员都被定义为这个单例对象的成员。这允许您在对象和类型之间进行清晰的分离。您不再能够在不实例化它的情况下调用类的成员。

摘要

在本章中,我们对 Scala 编程语言进行了简要概述,本书中的示例都是用 Scala 实现的。首先,我们通过 Scala 支持广泛的编程风格这一事实来激发使用 Scala 作为示例语言。

接下来,我们查看了一下 Scala 与其他语言的不同之处。请记住,本节旨在对语言进行简要概述,如果您想了解更多关于 Scala 的信息,请使用更全面的教程。

第十五章:评估

第一章

  1. 指定你想做什么,而不指定如何确切地做。

  2. 不要重复自己。

  3. Goto 是一个较低级的原语,用于构建高级逻辑。所有可以用 goto 实现的逻辑都可以用循环和其他控制结构实现。声明你想要一段代码循环执行,排除了如果你尝试通过 goto 实现该循环时可能出现的错误。

第二章

  1. 作为它们对象的行为。

  2. 作为数学函数。基于一些输入值计算值而不产生副作用。

  3. 接受其他函数作为输入的函数。

  4. 一个应用是编写控制结构。

第三章

  1. 你使用在命令式集合中定义的低级操作来指定需要算法执行的任务。

  2. 你使用在函数式集合中定义的高级操作将你的程序指定为一个表达式。

  3. 你可能需要的所有算法都已经在前端框架中实现。你只需要在需要时按名称调用它们。你可能想要编写的所有程序都可以表达为框架中实现的高级操作的组合。

  4. 将程序视为一个数学表达式,而不是一个算法。表达式是一个由操作符(行为)连接的操作数(数据)的结构。

  5. 对程序员心智的负担更小。代数程序通常从方程中移除副作用,如错误或时间。因此,你不需要考虑它们。这与命令式程序形成对比,在命令式程序中,副作用是自由发生的,你需要记住所有这些副作用。

  6. 它们具体化了副作用。具体化意味着将现象转化为数据。例如,我们可以在方法中返回一个包含异常对象的数据结构,而不是抛出异常(现象)。

第四章

  1. 对于这本书,副作用被定义为对当前逻辑单元(函数)作用域之外的环境的修改和交互。

  2. 是可以被程序改变的数据。

  3. 它们会在你的脑海中增加额外的认知负担,这可能导致错误。与副作用和可变状态相关的事情还有很多需要记住。你的注意力范围必须远远超出你目前正在工作的逻辑部分。

  4. 是一个不产生任何副作用的函数。

  5. 能够在代码中用函数调用的结果替换对函数的调用,而不改变代码的语义。

  6. 减少你面临的认知负担。因此,减少出现错误的可能性。

  7. 错误、结果缺失、延迟竞争、日志记录、输入输出操作。

  8. 是的,是这样的。在纯函数式风格中编程只是理解副作用的概念,理解它们是如何有害的,能够看到代码中的副作用及其危害,以及如何抽象它们的知识。在现代命令式编程语言中,可以编写用于副作用抽象的抽象。

  9. 它是支持你的基础设施的存在。你可能需要的绝大多数抽象在语言中已经存在。大多数库都是函数式的。社区也倾向于函数式风格。

第五章

  1. 第一阶现实是他们业务领域的现实。业务领域的现实是编程解决他们业务任务的现实。第二阶现实是编写和运行程序的现实。

  2. 它提供了一套技术来抽象掉第二阶现实的现象。首先,你需要识别一个重复的现象。然后,你需要创建一个数据结构来抽象掉这个现象。想法是通过描述它们来抽象掉现象,而实际上并不让它们发生。

  3. 对程序运行方式和代码库结构的控制。如果缺乏控制,第二阶现实的复杂性可能会压倒你,造成心理负担。

第六章

  1. 异步计算。

  2. Try 将错误情况表示为异常。在函数式环境中,异常可能并不总是期望的,因为它们只有在我们要抛出它们时才有意义。函数式编程不鼓励抛出异常,因为它们是副作用。因此,我们有一个更通用的类型叫做 Either,它能够表示两个值之间的替代。

  3. 函数式编程中表示依赖注入的一种方式是通过 Reader 类型。它是对这样一个事实的抽象:一个计算依赖于某些值,没有它无法执行。Reader 基本上是一个函数。然而,它有一个更简洁的签名,并且将 flatmap 的概念应用于它,就像应用于任何其他效果类型一样。

  4. Flatmap 允许你按顺序组合使用效果类型表示副作用的效果计算。

第七章

  1. 在 Scala 中,Rich Wrapper 是一种模式,允许你模拟将方法注入到类中。

  2. 该模式在 Scala 中使用隐式转换机制实现。每次你尝试在缺少该方法的类上调用方法时,编译器都会尝试将该类的实例转换为具有该方法的另一个类的实例。

  3. 请参阅第七章直觉部分的解释。

  4. 类型类模式背后的动机是将效果类型与其行为分离,以便能够根据执行函数式编程时出现的不同场景定义新的行为并将它们注入到现有的类型类中。

  5. 是的,命令式语言确实有类型类。然而,通常它们缺乏方便使用的机制。

第八章

  1. 类型类在项目之间重复。因此,将它们统一到库中是有意义的。

  2. foldLeft, foldRight, traverse.

  3. 一系列有影响计算的组合。

  4. flatMappuretailRecM

  5. 两个计算的顺序组合,其中一个依赖于另一个的结果。

  6. 核心包包含类型类,syntax 包包含丰富的包装器,可以将语法注入到效果类型中,instances 包包含某些效果类型的类型类实现,data 包包含用于函数式编程的效果类型。此外,Cats 还有一些用于书中未讨论的更具体任务的辅助包。请参阅 Cats 文档了解这些信息。

第九章

  1. 阻塞计算在需要等待某些事件发生时将阻塞它们所使用的线程。非阻塞计算在不需要时释放线程。释放的线程可以被其他任务重用。

  2. 你需要异步编程,以便线程可以利用进行有用的工作,而不是等待事件发生。

  3. 你可以将业务逻辑与并发执行策略分开。

  4. IO 是一种封装延迟计算副作用的效果类型。它使用计算作为值的方法,因此是对要执行的计算的规范。

  5. 使用 start 从单调流异步启动 IO。使用 flatMap 顺序组合 IO。请参阅 IO 的 API 文档以获取完整列表。

第十章

  1. Monad Transformers 用于将两种效果类型组合成一种。

  2. 无标签最终允许延迟选择效果类型,并使用该效果类型必须具备的能力来组合程序。

  3. 类型级计算允许在编译时识别更多错误,从而提高编译时安全性。

第十一章

  1. 每当线程需要访问非线程安全资源时,它都会对这个资源采取监视器。监视器保证只有拥有这个监视器的线程可以与其资源一起工作。

  2. 死锁是一种情况,当两个线程依赖于另一个线程的进度,并且它们中的任何一个都不能在没有另一个线程之前进展。因此,两个线程都停滞不前。请参阅第十一章了解死锁如何发生的一个示例。

  3. 一个 actor 是一个并发原语。它有一个邮箱,它可以接受来自其他 actor 的消息。它可以向其他 actor 发送消息。它是在其他 actor 的消息反应的术语中定义的。一个 actor 一次只能处理一个消息。保证如果一个 actor 拥有非线程安全资源,则不允许其他 actor 拥有它。

  4. 由于只有一个 actor 控制一个非线程安全资源,因此不存在竞态条件或死锁的危险。每当其他 actor 需要访问相关资源时,它们会通过请求拥有该资源的 actor 来访问。操作由资源的所有者 actor 间接执行,资源本身永远不会暴露给外部世界。

第十二章

  1. 使用 Actor 进行封装——可变状态只能从一个 actor 或一个线程中访问。Actor 以层次结构组织,父母监督子女以实现容错性。

  2. 我们通过扩展Actor类并实现receive方法来实现这一点。

  3. 我们通过在ActorSystemActorContext上调用actorOf方法来实现这一点。

  4. ActorRef上使用!运算符——targetActor ! message

  5. !运算符实现了fire-and-forget类型的消息发送。它发送消息后立即返回。询问模式涉及使用?运算符发送消息,该运算符返回一个Future[Any],该Future将在目标 actor 响应后完成——val futureMessage: Future[Any] = targetActor ? message

  6. 管道模式指示FutureFuture的计算完成后向 actor 发送消息——future pipeTo targetActor

posted @ 2025-09-12 13:57  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报