精通函数式编程(一)

原文: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,是定义基本类型类的地方:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/57e96392-216e-4c9e-8d27-bde045a1f7c9.png

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

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

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

核心部分

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/89caab0d-1699-4481-89fd-58c3aa51e29a.png

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/3c0d41ca-1195-4e93-9f52-67a917eca5ca.png

现在我们来更详细地看看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 包中,如下所示:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/4a746d62-f06f-4c52-90e1-b2f9fd93c7fb.png

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

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/73859f8e-f26d-4159-a9de-a7e3e4a1a574.png

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/074e0741-687d-457e-a8e2-14cc3aefbf88.png

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/1332acf1-bb04-4a99-b52a-3a0bcec796c2.png

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

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

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/51ef1905-4d96-45f3-a975-db8a94c2971a.png

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

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

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

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

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

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

实例

instances包公开了以下 API:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/af1ffeec-e2b4-40b3-91b0-7abca12e0880.png

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

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/8398a71f-5910-43e2-9d41-37fd43946f58.png

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/4bb931b8-754c-4440-a36a-c97d9c48bf18.png

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/bc1a769f-6656-47ae-a76d-d46b9d0b40ee.png

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

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

数据

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/6ba84616-5235-4e68-87b5-0701bae792c6.png

之前,我们讨论了拥有像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来表示:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/556bd98d-0ac0-445e-b5ec-8628c4b34067.png

在前面的屏幕截图中,你可以看到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方法。它在某些情况下非常有用,因为它允许你在效果类型的上下文中定义循环。在本小节中,让我们更详细地看看它的签名以及这个方法是如何工作的:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/77354c52-e187-496e-944b-788b880e01d4.png

让我们看看这个方法的参数。首先,让我们看看这个方法的第二个参数,即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 定义的类:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/def275eb-529e-48ac-a367-c3b7ad143bc1.png

在前面的屏幕截图中,您可以查看 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的祖先:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/f5db63dd-576a-4fe0-b27c-adac657fbae9.png

然而,你将更频繁地遇到扩展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:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/0b8f98c4-c89d-4aa5-a17b-682bf0a1179b.png

Monoid类型类扩展了SemigroupSemigroup是一种数学结构。它是一个定义为以下类型的类型类:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/7a1fd818-e5ee-4247-8abd-0f0260f0c9ff.png

基本上,半群是在抽象代数和集合论中定义的。给定一个集合,一个半群是这个集合上的一个结构,它定义了一个运算符,可以将集合中的任意两个元素组合起来产生集合中的另一个元素。因此,对于集合中的任意两个元素,你可以使用这个运算符将它们组合起来,产生另一个属于这个集合的元素。在编程语言中,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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/9518c592-e97f-40ca-89fe-c850a3020a58.png

所以,正如你所看到的,这个方法是为效果类型定义的,并且不是在类型类的层面上,而是在方法层面上参数化类型 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类型类:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ms-fp/img/ca9ec1bf-e863-4365-a00a-36b5c5eb9a10.png

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 库的结构是怎样的?

卷积神经网络(CNN)是针对多维网格数据(如图像、视频)设计的深度学习架构,其结构灵感来源于生物视觉系统对信息的分层处理机制。该模型通过局部连接、参数共享、层级特征提取等策略,有效捕获数据中的空间模式。以下从结构特性、工作机制及应用维度展开说明: **1. 局部连接与卷积运算** 卷积层利用可学习的多维滤波器对输入进行扫描,每个滤波器仅作用于输入的个有限邻域(称为感受野),通过线性加权与非线性变换提取局部特征。这种设计使网络能够聚焦于相邻像素间的关联性,从而识别如边缘走向、色彩渐变等基础视觉模式。 **2. 参数共享机制** 同卷积核在输入数据的整个空间范围内保持参数不变,大幅降低模型复杂度。这种设计赋予模型对平移变换的适应性:无论目标特征出现在图像的任何区域,均可由相同核函数检测,体现了特征位置无关性的建模思想。 **3. 特征降维与空间鲁棒性** 池化层通过对局部区域进行聚合运算(如取最大值或均值)实现特征降维,在保留显著特征的同时提升模型对微小形变的容忍度。这种操作既减少了计算负荷,又增强了特征的几何不变性。 **4. 层级特征抽象体系** 深度CNN通过堆叠多个卷积-池化层构建特征提取金字塔。浅层网络捕获点线面等基础模式,中层网络组合形成纹理部件,深层网络则合成具有语义意义的对象轮廓。这种逐级递进的特征表达机制实现了从像素级信息到概念化表示的自动演进。 **5. 非线性扩展与泛化控制** 通过激活函数(如ReLU及其变体)引入非线性变换,使网络能够拟合复杂决策曲面。为防止过拟合,常采用权重归化、随机神经元失活等技术约束模型容量,提升在未知数据上的表现稳定性。 **6. 典型应用场景** - 视觉内容分类:对图像中的主体进行类别判定 - 实例定位与识别:在复杂场景中标定特定目标的边界框及类别 - 像素级语义解析:对图像每个像素点进行语义标注 - 生物特征认证:基于面部特征的个体身份鉴别 - 医学图像判读:辅助病灶定位与病理分析 - 结构化文本处理:与循环神经网络结合处理序列标注任务 **7. 技术演进脉络** 早期理论雏形形成于1980年代,随着并行计算设备的发展与大规模标注数据的出现,先后涌现出LeNet、AlexNet、VGG、ResNet等里程碑式架构。现代研究聚焦于注意力分配、跨层连接、卷积分解等方向,持续推动模型性能边界。 卷积神经网络通过其特有的空间特征提取范式,建立了从原始信号到高级语义表达的映射通路,已成为处理几何结构数据的标准框架,在工业界与学术界均展现出重要价值。 资源来源于网络分享,仅用于学习交流使用,请勿用于商业,如有侵权请联系我删除!
屋顶面板实例分割数据集 、数据集基础信息 • 数据集名称:屋顶面板实例分割数据集 • 图片数量: 训练集:1559张图片 验证集:152张图片 测试集:95张图片 总计:1806张图片 • 训练集:1559张图片 • 验证集:152张图片 • 测试集:95张图片 • 总计:1806张图片 • 分类类别: panel(面板):屋顶上的面板结构,如太阳能板或其他安装组件。 roof(屋顶):建筑屋顶区域,用于定位和分割。 • panel(面板):屋顶上的面板结构,如太阳能板或其他安装组件。 • roof(屋顶):建筑屋顶区域,用于定位和分割。 • 标注格式:YOLO格式,包含实例分割的多边形标注,适用于实例分割任务。 • 数据格式:图片文件,来源于航拍或建筑图像,涵盖多种场景。 二、数据集适用场景 • 建筑与施工检查:用于自动检测和分割屋顶上的面板,辅助建筑质量评估、维护和安装规划。 • 可再生能源管理:在太阳能发电系统中,识别屋顶太阳能板的位置和轮廓,优化能源部署和监控。 • 航拍图像分析:支持从空中图像中提取建筑屋顶信息,应用于城市规划、房地产评估和基础设施管理。 • 计算机视觉研究:为实例分割算法提供基准数据,推动AI在建筑和能源领域的创新应用。 三、数据集优势 • 精准实例分割标注:每个面板和屋顶实例均通过多边形标注精确定义轮廓,确保分割边界准确,支持细粒度分析。 • 类别聚焦与实用性:专注于屋顶和面板两个关键类别,数据针对性强,直接适用于建筑和能源行业的实际需求。 • 数据多样性与泛化性:涵盖不同环境下的屋顶和面板图像,增强模型在多变场景中的适应能力。 • 任务适配便捷:标注兼容主流深度学习框架(如YOLO),可快速集成到实例分割模型训练流程。 • 行业价值突出:助力自动化检测系统开发,提升建筑检查、能源管理和城市分析的效率与准确性。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值