精通-JavaScript-函数式编程第三版-全-

精通 JavaScript 函数式编程第三版(全)

原文:zh.annas-archive.org/md5/54d4130c8e0f9b39a20d8fcb7b637f61

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在计算机编程中,范式众多。一些例子包括命令式编程、结构化(无 goto)编程、面向对象编程OOP)、面向方面编程和声明式编程。最近,人们对一种可能被认为比上述所有范式都要古老的特定范式产生了新的兴趣——函数式编程FP)。FP 强调以简单的方式编写函数并将它们连接起来,以产生更易于理解和测试的代码。因此,鉴于当今 Web 应用的复杂性不断增加,一种更安全、更干净的编程方式自然会受到关注。

对函数式编程(FP)的兴趣与 JavaScript 的演变息息相关。尽管 JavaScript 的创建有些仓促(据报道,1995 年由 Brendan Eich 在 Netscape 公司仅用 10 天完成),但如今,JavaScript 已经成为一个标准化且快速发展的语言,其功能比大多数类似流行的语言都要先进。这种语言无处不在,现在可以在浏览器、服务器、手机等设备上找到,这也促使人们对更好的开发策略产生了兴趣。此外,即使 JavaScript 最初并不是作为函数式语言设计的,但事实上它提供了你以那种方式工作所需的所有功能,这也是一个加分项。

话虽如此,我们还得评论一下语言和相关工具的进步。数据类型的好处通常得到认可,近年来,TypeScript 得到了广泛的应用,并被用于前端和后端编码。因此,在本书中也包括其使用是有意义的。我们认为,这将使示例更清晰,并简化所展示代码在实际工作中的应用。

还必须指出,FP 在工业界并没有得到普遍应用,可能是因为它有一定的难度,人们认为它更偏向于理论而非实践,甚至可以说是数学性的,并且可能使用了开发者不熟悉的词汇和概念——例如,函子、单子、折叠和范畴论。虽然学习所有这些理论无疑会有所帮助,但也有人认为,即使对之前的术语一无所知,你也能理解 FP 的原则,并看到如何将其应用到自己的编程中。

函数式编程(FP)并不是你必须独自完成的事情,无需任何帮助。有许多库和框架在某种程度上融入了 FP 的概念。从 jQuery(它确实包含了一些 FP 概念)开始,经过 Underscore 及其近亲 Lodash,以及其他库如 Ramda,再到更完整的 Web 开发工具,如 React 和 Redux、Angular 和 Elm(一种 100%函数式语言,编译成 JavaScript),你用于编码的函数式辅助工具清单不断增长。

学习如何使用 FP 可以是一项值得的投资,即使你可能无法使用其所有方法和技巧,但只要开始应用其中的一些,就能在编写更好的代码中获得回报。你不必一开始就尝试应用 FP 的所有概念,也不必尝试放弃 JavaScript 中的每一个非功能特性。JavaScript 确实有一些不好的特性,但它也有几个非常好且强大的特性。我们的想法不是抛弃你所学的一切,而是采用 100%的函数式方法;相反,指导思想是进化,而不是革命。从这个意义上说,可以说我们正在做的不完全是 FP,而是半函数式编程SFP),旨在融合范式。

关于本书代码风格的一个最后评论——确实有几个非常好的库为你提供了 FP 工具:Underscore、Lodash 和 Ramda 就是其中之一。然而,我更喜欢避免使用它们,因为我想要展示事物真正的工作方式。应用某个包中的给定函数很容易,但通过编写一切(如果你愿意,纯 FP),我相信你可以更深入地理解事物。此外,正如我将在某些地方评论的那样,由于箭头函数和其他特性的强大和清晰,纯 JavaScript 版本甚至可能更容易理解!

本书面向对象

本书面向那些对 JavaScript(或者更好的是 TypeScript)有良好实际了解的程序员,他们要么在客户端(浏览器)工作,要么在服务器端(Node.js)工作,并希望应用技术来编写更好、可测试、可理解和可维护的代码。一些计算机科学背景(例如,数据结构)和良好的编程实践也将很有帮助。然而,本书将以实用的方式介绍 FP,尽管有时我们会提到一些理论观点。

本书涵盖内容

第一章成为函数式程序员 – 几个问题,讨论 FP,给出其使用的原因,并列出你将需要利用本书剩余部分所需工具。

第二章以函数式思考 – 第一个例子,将通过考虑一个常见的与网络相关的问题并探讨几个解决方案,最终专注于函数式解决方案。

第三章从函数开始 – 核心概念,将介绍 FP 的核心概念,即函数,以及 JavaScript 中可用的不同选项。

第四章行为规范 – 纯函数,将考虑纯度和纯函数的概念,并展示它如何导致更简单的编码和更简单的测试。

第五章声明式编程 - 更好的风格,将使用简单的数据结构来展示如何以声明式方式而不是命令式方式产生结果。

第六章生成函数 - 高阶函数,将处理高阶函数,这些函数接收其他函数作为参数并产生新的函数作为结果。

第七章转换函数 - 柯里化和部分应用,将探讨一些方法,可以从早期函数中产生新的和特殊化的函数。

第八章连接函数 - 管道、组合等,将展示如何通过连接先前定义的函数来构建新的函数的关键概念。

第九章设计函数 - 递归,将探讨函数式编程中的一个关键概念,递归,如何应用于算法和函数的设计。

第十章确保纯净性 - 不可变性,将介绍一些工具,这些工具可以通过提供不可变对象和数据结构来帮助你以纯净的方式工作。

第十一章以函数式方式实现设计模式,将展示在以函数式编程方式编程时,几个流行的面向对象设计模式是如何实现(或不需要!)的。

第十二章构建更好的容器 - 函数式数据类型,将探讨一些更高级的函数式模式,介绍类型、容器、函子、单子以及几个其他更高级的函数式编程概念。

我试图使这本书中的例子简单易懂,因为我想专注于函数式方面,而不是某个问题的复杂性。有些编程教材倾向于学习某个特定的框架,然后解决某个特定的问题,展示如何使用选定的工具彻底解决问题。

事实上,在为这本书的早期规划阶段,我考虑过开发一个应用,该应用将使用我心中所有的函数式编程元素,但无法将所有这些内容都包含在一个单一的项目中。夸张一点说,我感觉就像一个医生试图找到一个病人来应用他所有的医学知识和治疗方法!因此,我选择展示许多可以用于多种情况的技术。与其建造一座房子,我更想展示如何将砖块组合在一起,如何布线,等等,这样你就可以根据需要应用任何你需要的东西。

为了充分利用这本书

要理解本书中的概念和代码,您不需要比 JavaScript 环境和文本编辑器更多的东西。说实话,我甚至使用 JSFiddle(在jsfiddle.net)等工具完全在线开发了一些示例,而且绝对不需要其他任何东西。

在本书中,我们将使用 ES2022 和 Node 19,代码将在任何操作系统上运行,例如 Linux、macOS 或 Windows。

您需要一些关于最新版 JavaScript 的经验,因为它包括一些可以帮助您编写更简洁和紧凑代码的功能。我们将经常包括指向在线文档的指针,例如在developer.mozilla.org上可用的Mozilla 开发网络MDN)文档,以帮助您获得更深入的知识。

我们还将使用 TypeScript 的最新版本,为我们的 JavaScript 代码添加数据类型。有关该语言的更多信息,必读的参考资料是www.typescriptlang.org,在那里您可以找到文档、教程,甚至一个在线沙盒,可以直接在那里测试代码。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Mastering-JavaScript-Functional-Programming-3E。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/UsFuE

使用的约定

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

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。以下是一个示例:“有几种可能的结果:使用reduce()操作的单个值、使用map()的新数组,或者使用forEach()的几乎所有类型的输出。”

代码块设置如下:

// reverse.ts
const reverseString = (str: string): string => {
  const arr = str.split("");
  arr.reverse();
  return arr.join("");
};
console.log(reverseString("MONTEVIDEO"));	// OEDIVETNOM

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

// continued...
const reverseString2 = (str: string): string =>
  str.split("").reduceRight((x, y) => x + y, "");
console.log(reverseString2("OEDIVETNOM")); // MONTEVIDEO

任何命令行输入或输出都如下所示:

START MAP
2022-10-29T01:47:06.726Z [ 10, 20, 30, 40 ]
END MAP

粗体:表示新术语、重要词汇或屏幕上看到的词汇。

小贴士或重要注意事项

看起来是这样的。

联系我们

我们欢迎读者的反馈。

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

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。

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

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

分享您的想法

一旦您阅读了《精通 JavaScript 函数式编程 - 第三版》,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢随时随地阅读,但无法携带您的印刷书籍到任何地方吗?您的电子书购买是否与您选择的设备不兼容?

别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接!二维码图片

packt.link/free-ebook/9781804610138

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱

第一章:成为函数式——几个问题

函数式编程(或FP)自计算机的最早时期以来就存在,由于在多个框架和库中的使用增加,尤其是JavaScript,它经历了一种复兴。

在本章中,我们将做以下几件事:

  • 介绍一些 FP 的概念,以给出其含义的一小部分体验

  • 展示使用函数式编程(FP)带来的好处(以及问题),以及为什么我们应该使用它

  • 开始思考为什么 JavaScript 可以被认为是适合 FP 的语言

  • 概述你应该了解的语言特性和工具,以便充分利用本书中的所有内容

到本章结束时,你将拥有我们将在这本书中使用的所有基本工具,所以让我们开始学习 FP。

什么是函数式编程?

如果你回顾计算机历史,你会发现仍在使用的第二古老的编程语言 Lisp 是基于 FP 的。从那时起,出现了更多功能性的语言,FP 的应用也更加广泛。但即便如此,如果你问人们什么是 FP,你可能会得到两个截然不同的答案。

一点趣闻

对于喜欢 trivia 或历史的人来说,仍在使用的最古老的编程语言是 Fortran,它在 1957 年出现,比 Lisp 早一年。在 Lisp 之后不久,又出现了一种长寿的语言,COBOL,用于面向商业的编程。

根据你询问的人不同,你可能会了解到它是一种现代、先进、开明的编程方法,超越了其他所有范式,或者它主要是一个理论上的东西,弊大于利,在现实世界中几乎无法实施。通常,真正的答案并不在极端,而是在两者之间。让我们先从理论与实践对比开始,看看我们计划如何使用 FP。

理论与实际

在这本书中,我们不会以理论的方式介绍 FP。相反,我们的目的是向你展示一些其技术和原则如何成功地应用于常见的日常 JavaScript 编程。但——这一点很重要——我们不会教条地这样做,而是以一种非常实用的方式。我们不会因为它们不符合 FP 的学术期望而摒弃有用的 JavaScript 结构。同样,我们也不会为了避免符合 FP 范式而避免使用实用的 JavaScript 特性。我们几乎可以说,我们将进行Sorta Functional ProgrammingSFP),因为我们的代码将是 FP 特性、更传统的命令式特性和面向对象编程OOP)的混合体。

虽然如此,我们所说的并不意味着我们会把所有理论都放在一边。我们会挑剔地选择,只触及主要的理论点,学习一些词汇和定义,并解释核心的 FP 概念,但我们始终会着眼于产生实际、有用的 JavaScript 代码的想法,而不是试图满足某些神秘、教条的 FP 标准。

面向对象编程(OOP)一直是解决编写大型程序和系统固有的复杂性、开发干净、可扩展、可伸缩的应用程序架构的方法;然而,由于今天 Web 应用程序的规模,所有代码库的复杂性持续增长。此外,JavaScript 的新特性使得开发几年前甚至不可能开发的应用程序成为可能;例如,使用 Ionic、Apache Cordova 或 React Native 制作的移动(混合)应用程序,或使用 Electron、Tauri 或 NW.js 制作的桌面应用程序。JavaScript 还通过 Node.js 或 Deno 迁移到后端,因此,今天,该语言的使用范围在处理现代设计的附加复杂性方面有了重大增长。

不同的思维方式

函数式编程(FP)是一种不同的编写程序的方式,有时可能难以学习。在大多数语言中,编程是命令式的:程序是一系列语句的序列,按照规定的方式执行,通过创建对象并操作它们来达到期望的结果,这通常意味着修改对象本身。FP 基于通过评估由函数组成的表达式来产生期望的结果。在 FP 中,传递函数(例如将参数传递给其他函数或返回计算的结果作为函数)是常见的,不使用循环(选择递归代替),并跳过副作用(如修改对象或全局变量)。

换句话说,函数式编程(FP)关注的是应该做什么,而不是如何去做。你不需要担心循环或数组,而是在一个更高的层面上工作,考虑需要完成的事情。习惯这种风格后,你会发现你的代码变得更加简单、更短、更优雅,并且可以轻松地进行测试和调试。然而,不要陷入将 FP 视为目标的陷阱!将 FP 仅视为达到目的的手段,就像所有软件工具一样。函数式代码并不仅仅因为其函数性而好,使用 FP 编写糟糕的代码与使用任何其他技术一样可能!

函数式编程和其他编程范式

编程范式根据编程语言的特征对编程语言进行分类。然而,一些语言可能被归类到多个范式——JavaScript 本身就是这样!

主要的划分是命令式声明式语言。在前者中,开发者必须逐步指导机器如何完成其工作。编程可能是过程式的(如果指令被分组为过程),或者面向对象的(如果指令与相关状态一起分组)。

相反,在声明式语言中,开发者只需声明所求结果必须满足的属性,但不必说明如何计算它。声明式语言可能是基于逻辑的(基于逻辑规则和约束)、响应式的(基于数据和事件流)或函数式的(基于应用和函数的组合)。从某种意义上说,我们可以说命令式语言关注如何,而声明式语言关注什么

JavaScript 是多范式的:它是命令式的(既过程式又面向对象),但也允许声明式编程,包括函数式(如本书中的几乎所有内容!特别是,我们将专门在第五章声明式编程)和响应式(我们将在第十一章实现设计模式)中探讨这一主题)。

为了给你一个命令式与声明式解决问题方式的区别的基本例子,让我们解决一个简单的问题:假设你有一组人的个人数据数组,如下所示:

// imperative.js
const data = [
  { name: "John", age: 23, other: "xxx" },
  { name: "Paul", age: 18, other: "yyy" },
  { name: "George", age: 16, other: "zzz" },
  { name: "Ringo", age: 25, other: "ttt" },
];

假设你想要提取成年人的数据(至少 21 岁)。命令式地,你会做如下操作:

// continued...
const result1 = [];
for (let i = 0; i < data.length; i++) {
  if (data[i].age >= 21) {
    result1.push(data[i]);
  }
}

你必须初始化所选人员的输出数组(result1)。然后,你必须指定一个循环,说明索引变量(i)如何初始化、测试和更新。在循环的每次迭代中,你检查相应人员的年龄,如果该人员是成年人,则将数据推送到输出数组。换句话说,你逐步指定代码将执行的所有操作。

以声明式工作,你更愿意写如下内容:

// declarative.js
const isAdult = (person) => person.age >= 21;
const result2 = data.filter(isAdult);

第一行声明了如何测试一个人是否是成年人;第二行说明结果是过滤数据数组的结果,选择满足给定谓词的元素。(对于isAdult(),我们使用箭头函数;我们将在本章后面的箭头函数部分了解更多。)你不需要初始化输出数组,指定如何循环,或确保你的数组索引不超过数组的长度等——所有这些细节都由语言处理,因此你不需要。

阅读和理解命令式版本需要了解编程语言以及循环的算法或技术;声明式版本更易于编写,更易于维护,且可读性更强。

FP 不是什么

由于我们已经谈论了很多关于 FP 是什么的内容,让我们也澄清一些常见的误解,并看看 FP 不是什么:

  • FP 不仅仅是学术象牙塔中的事物:FP 所基于的λ演算是由 Alonzo Church 在 1936 年开发的,作为一种证明理论计算机科学中一个重要结果(这比现代计算机语言早了 20 多年!)的工具;然而,FP 语言今天被用于各种系统。

  • FP 不是面向对象(OOP)的对立面:这并不是选择声明式或命令式编程方式的问题。你可以根据需要混合和匹配,我们将在整本书中这样做,将所有世界的最佳之处结合起来。

  • FP 学习并不复杂:一些函数式编程(FP)语言与 JavaScript 相当不同,但差异主要在于语法。一旦你掌握了基本概念,你就会发现你可以在 JavaScript 中实现与 FP 语言相同的结果。

还可能需要提到的是,几个现代框架,如 React 和 Redux 的组合,都包含了 FP 思想。

例如,在 React 中,据说视图(用户在任何给定时刻能看到的内容)是当前状态的函数。你使用一个函数来计算在每一刻必须生成的 HTML 和 CSS,以黑盒的方式思考。

同样,在 Redux 中,你有由 reducers 处理的行为的概念。一个行为提供了一些数据,而 reducer 是一个函数,它以函数式的方式从当前状态和提供的数据中生成应用程序的新状态。

因此,无论是由于理论优势(我们将在下一节中讨论这些优势)还是实际优势(例如,能够使用最新的框架和库),考虑使用 FP 编码都是合理的。让我们继续前进。

为什么使用 FP?

几年来,出现了许多编程风格和潮流。然而,FP 已经证明相当有弹性,并且今天非常受欢迎。你为什么想使用 FP?相反,首先应该问的问题是,你需要什么?然后,FP 才能满足你的需求?我们将在以下章节中回答这些重要问题。

我们需要

我们可以肯定地同意以下列表中的担忧是普遍的。我们的代码应该具有以下品质:

  • 模块化:你的程序的功能应该分为独立的模块,每个模块都包含解决方案的一部分。模块或函数中的更改不应影响其余代码。

  • 易于理解:你的程序读者应该能够不费吹灰之力地辨别其组件、函数和关系。这与代码的可维护性密切相关;你的代码将来必须得到维护,无论是为了更改还是为了添加新功能。

  • 可测试性单元测试尝试测试程序的小部分,验证其行为独立于其他代码。你的编程风格应该倾向于编写简化单元测试工作的代码。单元测试也像文档一样,可以帮助读者理解代码的预期功能。

  • 可扩展性:这是一个事实,你的程序总有一天需要维护,可能需要添加新功能。这些更改应该对原始代码的结构和数据流只有最小的影响(如果有的话)。小的更改不应该意味着对代码进行大规模的重大重构。

  • 可重用性:代码重用的目标是节省资源、时间和金钱,通过利用先前编写的代码来减少冗余。一些特性有助于实现这一目标,例如模块化(我们之前已经提到过)、高内聚(模块中的所有部分都属于一起)、低耦合(模块相互独立)、关注点分离(程序的部分应该在功能上尽可能少地重叠)和信息隐藏(模块的内部更改不应影响系统的其他部分)。

我们所得到的

那么,FP 是否提供了我们之前章节中列出的五个特性?

  • 在 FP 中,目标是编写独立的函数,然后将它们组合起来以产生最终结果。

  • 以函数式风格编写的程序通常更干净、更短,更容易理解。

  • 函数可以单独测试,FP 代码在这方面具有优势。

  • 你可以在其他程序中重用函数,因为它们是独立的,不依赖于系统的其他部分。大多数函数式程序共享一些公共函数,其中一些我们将在本书中讨论。

  • 函数式代码没有副作用,这意味着你可以通过研究函数本身来理解其目标,而无需考虑整个程序。

最后,一旦你习惯了 FP 编程风格,代码就会变得更加可理解且易于扩展。因此,似乎我们可以通过 FP 实现所有五个特性!

为什么使用 FP?

为了全面了解使用函数式编程(FP)的理由,我建议阅读约翰·休斯(John Hughes)的《Why Functional Programming Matters》;它可在www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf在线获取。这本书并非针对 JavaScript 编写,但其中的论点易于理解。

并非所有都是金子

然而,让我们努力寻求一种平衡。使用 FP 并不是一个银弹,它会自动让你的代码变得更好。一些 FP 解决方案可能很棘手,有些开发者喜欢编写代码后问自己,这会做什么?如果你不小心,你的代码可能会变得只能写不能读,实际上难以维护;这样一来,可理解性、可扩展性和可重用性就都消失了!

另一个缺点是,你可能发现很难找到熟悉函数式编程(FP)的开发者。(快速问题:你见过多少 FP-sought 的招聘广告?)今天的大多数网络代码都是用命令式、非函数式的方式编写的,大多数程序员都习惯了这种方式。对于一些人来说,不得不转换思路并以不同的方式编写程序可能是一个难以逾越的障碍。

最后,如果你尝试完全采用函数式编程,你可能会发现自己在 JavaScript 上遇到了困难,简单的任务可能变得难以完成。正如我们一开始所说的,我们将选择 SFP,因此我们不会彻底拒绝任何非 100% 函数式的语言特性。毕竟,我们想要使用 FP 来简化我们的编码,而不是让它变得更加复杂!

因此,虽然我会努力向你展示在代码中采用函数式编程的优势,但就像任何改变一样,总会有些困难。然而,我坚信你能够克服这些困难,并且通过应用 FP,你的组织将能够编写更好的代码。敢于改变!所以,既然你接受 FP 可能适用于你的问题,让我们考虑另一个问题:我们能否以函数式的方式使用 JavaScript,这是否合适?

JavaScript 是否是函数式的?

大约在这个时候,你应该问自己另一个重要的问题:JavaScript 是一种函数式语言吗? 通常,当人们想到 FP 时,提到的语言列表并不包括 JavaScript,而是包括一些不太常见的选项,如 Clojure、Erlang、Haskell 和 Scala;然而,并没有对 FP 语言或这些语言应包含的确切特征的定义。关键是,如果你认为一种语言支持与 FP 相关的常见编程风格,那么你可以认为这种语言是函数式的。让我们先了解一下为什么我们要使用 JavaScript,以及这种语言是如何发展到当前版本的,然后看看我们将使用的一些关键特性,这些特性将帮助我们以函数式的方式工作。

JavaScript 作为一种工具

什么是 JavaScript?如果你考虑像 www.tiobe.com/tiobe-index/pypl.github.io/PYPL.html 这样的流行度指数,你会发现 JavaScript 持续位于最受欢迎的 10 种语言之列。从更学术的角度来看,这种语言是一种混合体,从几种不同的语言中借鉴了特性。几个库通过提供一些不太容易获得的特性(如类和继承——今天语言的版本支持类,但不久前并不支持),帮助了语言的增长,否则这些特性可能需要通过一些原型技巧来实现。

名字里有什么?

JavaScript 这个名字的选择是为了利用 Java 的流行度——这纯粹是一种营销策略!它的原名是 Mocha,然后是 LiveScript,最后才是 JavaScript

JavaScript 已经变得非常强大。但是,就像所有强大的工具一样,它不仅让你能够产生出色的解决方案,还能造成巨大的伤害。函数式编程(FP)可以被认为是一种减少或摒弃语言中最糟糕的部分,并专注于以更安全、更好的方式工作的方式;然而,由于现有的 JavaScript 代码量巨大,你不能期望它促进对语言的大规模重构,这会导致大多数网站失败。你必须学会接受好的一面和不好的一面,并简单地避免后者。

此外,该语言有各种各样的可用库,以多种方式完善或扩展了语言。在这本书中,我们将专注于使用 JavaScript,但我们会参考现有的、可用的代码。

如果我们问 JavaScript 是否是函数式的,答案将再次是“有点”。它之所以被视为函数式,是因为它具有一些特性,如一等函数、匿名函数、递归和闭包——我们稍后会回到这一点。另一方面,它也有许多非函数式编程(FP)的方面,如副作用(不纯性)、可变对象和递归的实际限制。因此,当我们以函数式编程的方式编程时,我们将利用所有相关、适当的语言特性,并尝试最小化由语言更传统部分引起的问题。从这个意义上说,JavaScript 是否是函数式的,将取决于你的编程风格!

如果你想要使用函数式编程(FP),你应该决定使用哪种语言;然而,选择完全功能性的语言可能并不那么明智。如今,编写代码并不仅仅是使用一种语言;你肯定需要框架、库和其他各种工具。如果我们能够利用所有提供的工具,同时在我们代码中引入函数式编程的工作方式,我们将能够兼顾两者之长,无论 JavaScript 是否是函数式的!

使用 JavaScript 进行函数式编程

JavaScript 经过多年的发展,我们将要使用的是(非正式地)称为 JS13,而(正式地)称为 ECMAScript 2022,通常简称为 ES2022 或 ES13;这个版本于 2022 年 6 月最终确定。之前的版本如下:

  • ECMAScript 1,1997 年 6 月

  • ECMAScript 2,1998 年 6 月,与之前的版本 ECMAScript 3 相同,1999 年 12 月发布,增加了几个新功能

  • ECMAScript 5,2009 年 12 月(而且,实际上从未有过 ECMAScript 4,因为它被放弃了)

  • ECMAScript 5.1,2011 年 6 月

  • ECMAScript 6(或 ES6;后来更名为 ES2015),2015 年 6 月 ECMAScript 7(也称为 ES7,或 ES2016),2016 年 6 月 ECMAScript 8(ES8 或 ES2017),2017 年 6 月

  • ECMAScript 9(ES9 或 ES2018),2018 年 6 月

  • ECMAScript 10(ES10 或 ES2019),2019 年 6 月

  • ECMAScript 11(ES11 或 ES2020),2020 年 6 月

  • ECMAScript 12(ES12 或 ES2021),2021 年 6 月

什么是 ECMA?

ECMA 最初代表欧洲计算机制造商协会,但如今,这个名字不再被视为首字母缩略词。该组织还负责除 JavaScript 之外的其他标准,包括 JSON、C#、Dart 等。有关更多详细信息,请访问其网站 www.ecma-international.org/

你可以在 www.ecma-international.org/publications-and-standards/standards/ecma-262/ 阅读标准的语言规范。当我们在文本中提到 JavaScript 而没有进一步说明时,指的是 ES13(ES2022);然而,就本书中使用的语言特性而言,如果你只使用 ES2015,那么你在这本书中遇到的问题将主要不会太多。

没有浏览器完全实现 ES13;大多数提供的是较旧的版本,即 JavaScript 5(2009 年发布),其中包含从 ES6 到 ES13 的(不断增长的)一些特性。这将会成为一个问题,但幸运的是,这是一个可解决的问题;我们很快就会讨论这个问题。本书将使用 ES13。

差异,差异……

ES2016 与 ES2015 之间只有少数差异,例如 Array.prototype.includes 方法和对数运算符 **。ES2017 与 ES2016 之间有更多差异——例如 asyncawait,一些字符串填充函数等——但它们不会影响我们的代码。我们还会在后面的章节中探讨更多现代添加的替代方案,例如 flatMap()

由于我们将使用 JavaScript,让我们首先考虑与其函数式编程(FP)目标相关的最重要的特性。

JavaScript 的关键特性

JavaScript 不是一个纯粹的功能性语言,但它拥有我们需要的所有特性,使其能够像功能性语言一样工作。我们将使用的语言的主要特性如下:

  • 函数作为一等对象

  • 递归

  • 闭包

  • 箭头函数

  • 展开操作

让我们看看每个特性的示例,并找出它们为什么对我们有用。不过,请记住,JavaScript 还有我们将要使用的更多特性;接下来的章节只是突出我们用于函数式编程(FP)的最重要特性。

函数作为一等对象

说函数是 一等对象(也称为 一等实体一等公民)意味着你可以用函数做任何可以用其他对象做的事情。例如,你可以将函数存储在变量中,你可以将其传递给另一个函数,你可以打印它,等等。这实际上是进行函数式编程的关键;我们经常会将函数作为参数(传递给其他函数)或作为函数调用的结果返回。

如果你已经进行过异步 Ajax 调用,那么你已经在使用这个特性了:回调 是一个在 Ajax 调用完成后将被调用并作为参数传递的函数。使用 jQuery,你可以编写如下内容:

$.get("some/url", someData, function(result, status) {
  // check status, and do something
  // with the result
});

$.get() 函数接收一个回调函数作为参数,并在结果获取后调用它。

前进的道路

这更好解决,以更现代的方式,通过使用承诺或 async/await,但为了我们这个例子,旧的方式就足够了。不过,我们将在 第十二章构建更好的容器 中再次回到承诺,当我们讨论单子时;特别是,请参阅 意外的单子 – 承诺 部分。

由于函数可以存储在变量中,你也可以编写如下内容。请注意我们在 $.get(...) 调用中如何使用 doSomething 变量:

var doSomething = function(result, status) {
  // check status, and do something
  // with the result
};
$.get("some/url", someData, doSomething);

我们将在 第六章生成函数 中看到更多这样的例子。

递归

递归 是开发算法的最强大工具,也是解决大量问题的一个大帮手。其思想是,一个函数可以在某个时刻调用自己,当 那个 调用完成后,继续使用它所接收到的任何结果。这通常对某些类的问题或定义非常有帮助。最常引用的例子是阶乘函数(n 的阶乘写作 n!),它定义为非负整数值:

  • 如果 n 是 0,那么 n! = 1

  • 如果 n 大于 0,那么 n! = n * (n-1)!

排列事物

n! 的值是你将 n 个不同的元素排列成一行的不同方式数量。例如,如果你想将五本书排成一行,你可以为第一个位置选择任何一本,然后以所有可能的方式排列剩下的四本,所以 5! = 54!。要排列这四本,你可以为第一个位置选择任何一本,然后以所有可能的方式排列剩下的三本,所以 4! = 43!。如果你继续这个例子,你将得到 5! = 54321=120,并且一般来说,n! 是所有不超过 n 的数的乘积。

这可以立即转换为代码:

// factorial.js
function fact(n) {
  if (n === 0) {
    return 1;
  } else {
    return n * fact(n - 1);
  }
}
console.log(fact(5)); // 120

递归将是设计算法的一个大帮手。通过使用递归,你可以不用任何 whilefor 循环——我们并不想那样做,但有趣的是我们可以做到!我们将把 第九章设计函数 的全部内容都用于设计算法和递归编写函数。

闭包

闭包是实现数据隐藏(使用私有变量)的一种方式,这导致了模块和其他一些很好的特性。闭包的关键概念是,当你定义一个函数时,它不仅可以引用其局部变量,还可以引用函数上下文之外的一切。我们可以编写一个计数函数,它将使用闭包保持其 count

// closure.js
function newCounter() {
  let count = 0;
  return function () {
    count++;
    return count;
  };
}
const nc = newCounter();
console.log(nc()); // 1
console.log(nc()); // 2
console.log(nc()); // 3

即使newCounter()退出后,内部函数仍然可以访问count,但这个变量对代码的其他部分不可访问。

这不是一个很好的 FP 示例——一个函数(在这种情况下是nc())不应该在用相同的参数调用时返回不同的结果!

我们会发现闭包有几种用途,例如记忆化(见第四章行为规范,以及第六章生成函数)和模块模式(见第三章从函数开始,以及第十一章实现设计模式),等等。

箭头函数

(parameter, anotherparameter, ...etc) => { statements }(parameter, anotherparameter, ...etc) => expression。前者允许你编写尽可能多的代码,而后者是 { return expression } 的简写。

我们可以将之前的 Ajax 示例重写如下:

$.get("some/url", data, (result, status) => {
// check status, and do something
// with the result
});

因子代码的新版本可能如下——唯一的区别是使用了箭头函数:

// factorial.js, continued...
const fact2 = (n) => {
  if (n === 0) {
    return 1;
  } else {
    return n * fact2(n – 1);
  }
};

函数,匿名

箭头函数通常被称为匿名函数,因为它们没有名字。如果你需要引用箭头函数,你必须将其分配给变量或对象属性,就像我们在这里做的那样;否则,你将无法使用它。我们将在第三章**,从函数开始中的箭头函数——现代方式*部分了解更多。

你可能会将 fact2() 写成一行代码——你能看到与我们之前代码的等价性吗?使用三元运算符代替 if 是相当常见的:

// continued...
const fact3 = (n) => (n === 0 ? 1 : n * fact3(n - 1));

使用这种更简短的形式,你不需要写return,它是隐含的。

函数——lambda 方式

在 lambda 演算中,一个如 x => 2x* 的函数会被表示为 λx.2x。尽管存在语法上的差异,但定义是相似的。具有更多参数的函数稍微复杂一些;(x,y)=>x+y* 会表示为 λxy.x+y。我们将在第三章从函数开始,以及第七章**,变换函数中的关于 lambda 和函数*部分了解更多。

还有一点需要注意:当箭头函数只有一个参数时,你可以省略其周围的括号。我通常喜欢保留它们,但我已经应用了一个 JavaScript 美化器,Prettier,来美化代码,它会移除它们。是否包含它们完全取决于你!(有关此工具的更多信息,请参阅github.com/prettier/prettier。)顺便说一句,我的格式化选项是--print-width 75 -- tab-width 2 --no-bracket-spacing

展开

展开运算符...(见developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Spread_operator)允许你在需要多个参数、元素或变量的地方展开一个表达式。例如,你可以替换函数调用中的参数,如下面的代码所示:

// sum3.js
function sum3(a, b, c) {
  return a + b + c;
}
const x  = [1, 2, 3];
const y = sum3(...x); // equivalent to sum3(1,2,3)

你还可以创建或连接数组,如下面的代码所示:

// continued...
const f = [1, 2, 3];
const g = [4, ...f, 5]; // [4,1,2,3,5]
const h = [...f, ...g]; // [1,2,3,4,1,2,3,5]

它也适用于对象:

// continued…
const p = { some: 3, data: 5 };
const q = { more: 8, ...p }; // { more:8, some:3, data:5 }

你也可以用它来处理期望单独参数而不是数组的函数。常见的例子包括Math.min()Math.max()

// continued...
const numbers = [2, 2, 9, 6, 0, 1, 2, 4, 5, 6];
const minA = Math.min(...numbers); // 0
const maxArray = (arr) => Math.max(...arr);
const maxA = maxArray(numbers); // 9

我们指定maxArray()应该接收一个数字数组作为参数。

你还可以写出以下等式,因为.apply()方法需要一个参数数组,但.call()期望单独的参数,你可以通过展开来获取:

someFn.apply(thisArg, arr) === someFn.call(thisArg, ...arr)

参数的助记符

如果你记不住.apply().call()需要哪些参数,这个助记符可能有所帮助:A 代表 Array(数组),C 代表 Comma(逗号)。更多信息请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/applydeveloper.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call

使用展开运算符有助于编写更短、更简洁的代码,我们将充分利用它。我们已经看到了我们将要使用的所有最重要的 JavaScript 特性。让我们通过查看我们将要使用的工具来结束这一章。

我们如何使用 JavaScript?

这一切都很不错,但正如我们之前提到的,实际上几乎到处可用的 JavaScript 版本并不是 ES13,而是更早的 JS5。一个例外是 Node.js。它基于 Chrome 的 V8 高性能 JavaScript 引擎,该引擎已经提供了几个 ES13 特性。然而,在撰写本文时,ES13 的覆盖范围并不是 100%完整,并且有一些特性你会错过。(更多信息请查看nodejs.org/en/docs/es6/关于 Node.js 和 v8。)由于 Internet Explorer 正在淡出(其支持已于 2022 年 6 月结束),已被 Microsoft 的 Edge 浏览器取代,后者与 Chrome 共享引擎,所以这一切都在改变。无论如何,我们仍然必须处理较旧、功能较弱的引擎。

如果在使用任何给定的新特性之前想要确保你的选择是正确的,请查看kangax.github.io/compat-table/es6/上的兼容性表格(见图 1.1)。

图 1.1 – 最新 JavaScript 特性可能并不被广泛和完全支持,所以在使用之前请检查

图 1.1 – 最新 JavaScript 功能可能并不广泛和完全支持,所以在使用之前您需要检查

对于 Node.js 而言,请查看 node.green/,它从 Kangax 表格中获取数据;参见 图 1.2

图 1.2 – 专门针对 Node.js 的兼容性表格

图 1.2 – 专门针对 Node.js 的兼容性表格

那么,如果您想使用最新版本的代码,但可用的版本是较早的、功能较差的版本,您能做什么呢?或者,如果大多数用户都在使用不支持您热衷使用的花哨功能的旧浏览器,会发生什么呢?让我们看看一些解决方案。

使用转换器

要摆脱这种可用性和兼容性问题,有几个转换器您可以使用。转换器会将您的原始 ES13 代码(可能使用最现代的 JavaScript 功能)转换成等效的 JS5 代码。这是一种源到源转换,而不是在编译中使用的源到对象代码。您可以使用高级的 ES13 功能,但用户的浏览器将接收 JS5 代码。转换器还将让您跟上语言即将推出的版本,尽管浏览器需要时间在桌面和移动设备上采用新标准。

关于词源

如果您想知道“转换器”这个词是从哪里来的,它是由“translate”(翻译)和“compiler”(编译器)组合而成的。在技术术语中有很多这样的组合:email(电子和邮件)、emoticon(情感和图标)、malware(恶意和软件)、alphanumeric(字母数字),等等。

目前,最常用的 JavaScript 转换器是 Babel (babeljs.io/);几年前,我们还有 Traceur (github.com/google/traceur-compiler),但现在不再维护了。其他两种可能性是 SWC (swc.rs/) 和 Sucrase (sucrase.io/);特别是后者拥有更快的转换速度。

使用 npmwebpack 等工具,配置代码以便自动转换并提供给最终用户相对容易。您还可以在线进行转换;参见 图 1.3 中的 Babel 在线环境示例:

图 1.3 – Babel 转换器将 ES13 代码转换为兼容的 JS5 代码

图 1.3 – Babel 转换器将 ES13 代码转换为兼容的 JS5 代码

为您的编程环境安装这些工具有特定的方法,通常您不需要手动操作;更多信息请查看 www.typescriptlang.org/download

在线工作

有一些在线工具可以帮助你测试你的 JavaScript 代码。查看 JSFiddle (jsfiddle.net/)、CodePen (codepen.io/) 和 JSBin (jsbin.com/) 等工具。你可以在 图 1.4 中看到一个 CodePen 的例子:

图 1.4 – CodePen 允许你尝试现代 JavaScript 代码(包括 HTML 和 CSS),无需其他任何工具

图 1.4 – CodePen 允许你尝试现代 JavaScript 代码(包括 HTML 和 CSS),无需其他任何工具

使用这些工具提供了一种非常快速的方式来尝试代码或进行小实验 – 我确实可以为此作证,因为我已经用这种方式测试了本书中的大部分代码!

再进一步 – TypeScript

在本书的前几版中,我们使用了纯 JavaScript。然而,自那以后,微软的 TypeScript (www.typescriptlang.org/),作为自身编译成 JavaScript 的语言的超集,已经获得了大量的追随者,现在已成为许多框架的标准,并且你可以用于前端和后端代码。

TypeScript 的主要优势是能够为 JavaScript 添加(可选的)静态类型检查,这有助于在编译时检测编程错误。但请注意:与 Babel 一样,并非所有 ES13 的功能都将可用。然而,对于我们来说,这已经足够了,使我们能够更加谨慎地进行编码。

大多数关于编程语言流行度的统计数据都将 TypeScript 排在前十名;图 1.5(来自 spectrum.ieee.org/top-programming-languages-2022)证实了这一点:

图 1.5 – 根据 IEEE Spectrum 的 2022 年编程语言流行度

图 1.5 – 根据 IEEE Spectrum 的 2022 年编程语言流行度

追根溯源

尽管使用了 TypeScript,但在本书的其余部分,我们仍将参考 JavaScript,毕竟这是被执行的语言。

你还可以通过使用 Facebook 的 Flow (flow.org/) 来执行类型检查。然而,与 Flow 相比,TypeScript 在使用外部库方面有更多的支持。此外,TypeScript 的工具和开发安装更为简单。

忽略类型?

有一个提议(可能不会实现 – 提醒您!)允许 JavaScript 通过忽略类型来处理,这样你就可以直接运行 TypeScript,而不需要进行任何预处理或转换。有关更多信息,请访问 tc39.es/proposal-type-annotations/

应该明确的是,TypeScript 不仅仅是一个类型检查器;它是一门语言(好吧,它非常类似于 JavaScript,但仍然……)。例如,它为语言添加了接口、装饰器、枚举类型等,因此你可以使用在其他语言中典型的这些特性。无论如何,如果你不关心 TypeScript,你只需忽略类型相关的语法,那么你将拥有纯 JavaScript。

TypeScript 可通过在线工具获得,你还可以在他们的游乐场中在线测试它(www.typescriptlang.org/play/)。你可以设置选项以对数据类型检查进行更严格或更宽松的处理,并且你还可以立即运行你的代码;请参阅图 1**.6以获取更多详细信息:

图 1.6 – 你可以在 TypeScript 的网站上在线检查和转译你的代码

图 1.6 – 你可以在 TypeScript 的网站上在线检查和转译你的代码

在本书的后面部分,在第十二章 构建更好的容器指定数据类型部分,我们将考虑函数式编程语言的正式类型系统(不仅仅是 JavaScript),并且我们会发现我们的 TypeScript 工作已经消除了大多数困难。

最后的坦白:有时,当你必须处理复杂的数据类型表达式时,TypeScript 可能看起来更像是一种阻碍而不是一种帮助。(将本书中的所有代码更新为 TypeScript 有时让我怀疑自己使用它的理智!)然而,从长远来看,用 TypeScript 编写的代码更不容易出错,因为它的静态类型检查可以检测并避免许多常见错误。

测试

我们还将涉及测试,毕竟,测试是函数式编程的主要优势之一。在本书的前几版中,我们使用了Jasmine (jasmine.github.io/),但现在,我们已经改为使用 Facebook 的Jest (jestjs.io/) – 它建立在 Jasmine 之上!

Jest 由于其易用性和广泛适用性而越来越受欢迎:你可以同样好地测试前端和后端代码,配置很少。(有关其安装和配置,请参阅jestjs.io/docs/getting-started。)我们不会为本书中的每一块代码编写测试,但在遵循测试驱动开发TDD)的思想时,我们通常会这样做。

摘要

在本章中,我们看到了函数式编程的基础、它的一些历史、它的优势(以及一些可能的劣势,公平地说),为什么我们可以在 JavaScript(这通常不被认为是函数式语言)中应用它,以及我们完成本书剩余部分所需哪些工具。

第二章 函数式思考中,我们将讨论一个简单问题的例子,以常见的方式审视它,并以函数式的方式解决它,并分析我们方法的优势。

问题

1.1 TypeScript,请! 让我们遵守承诺:将本章提供的 JavaScript 示例转换为 TypeScript。

1.2 类作为一等对象:我们了解到函数是一等对象,但你是否知道类也是一等对象?(尽管,当然,将类称为对象听起来有点奇怪。)看看下面的例子,看看是什么让它运转!请注意:里面有一些故意写得很奇怪的代码:

const makeSaluteClass = (term) =>
  class {
    constructor(x) {
      this.x = x;
    }
    salute(y) {
      console.log(`${this.x} says "${term}" to ${y}`);
    }
  };
const Spanish = makeSaluteClass("HOLA");
new Spanish("ALFA").salute("BETA");
// ALFA says "HOLA" to BETA
new (makeSaluteClass("HELLO"))("GAMMA").salute("DELTA");
// GAMMA says "HELLO" to DELTA
const fullSalute = (c, x, y) => new c(x).salute(y);
const French = makeSaluteClass("BON JOUR");
fullSalute(French, "EPSILON", "ZETA");
// EPSILON says "BON JOUR" to ZETA

1.3 递减阶乘:我们实现阶乘的方法是从乘以 n 开始,然后是 n-1,然后是 n-2,以此类推,这可以称为递减方式。你能编写一个新的阶乘函数,使其向上循环吗?

1.4 阶乘错误:根据我们的定义,阶乘只应该对非负整数进行计算。然而,我们在 递归 部分编写的函数并没有检查其参数是否有效。你能添加必要的检查吗?尽量避免重复和冗余的测试!

1.5 阶乘测试:为上一题中的函数编写完整的测试。尽量达到 100%的覆盖率。

1.6 将 newCounter() 缩短到一半。你能看到如何做到吗?

1.7 newCounter() 函数?

第二章:函数式思考 – 第一个例子

第一章成为函数式开发者中,我们介绍了什么是函数式编程(FP),提到了应用它的某些优点,并列出了我们在 JavaScript 中需要的工具。现在,让我们先放下理论,考虑一个简单的问题以及如何以函数式方式解决它。

在本章中,我们将做以下几件事:

  • 看一个简单、与电子商务相关的问题

  • 考虑几种通常的解决方法(及其相关缺陷)

  • 通过函数式思考找到解决问题的方法

  • 设计一个可以应用于其他问题的更高阶解决方案

  • 学习如何进行功能解决方案的单元测试

在未来的章节中,我们将回到这里列出的某些主题,所以我们将不会深入探讨。我们只会展示函数式编程如何以不同的视角看待我们的问题,并将更多细节留到以后。

在完成本章内容后,你将首次接触到常见问题及其通过函数式思考方式解决问题的方法,这为本书后续内容做了铺垫。

我们的问题 – 只做一次的事情

让我们考虑一个简单但常见的情况。你已经开发了一个电子商务网站;用户可以填写他们的购物车,最后,他们必须点击一个账单按钮,以便他们的信用卡被扣款。然而,用户不应该点击两次(或更多次),否则他们将被多次扣款。

你的应用程序的 HTML 部分可能在某个地方有类似以下内容:

<button id="billButton"
    onclick="billTheUser(some, sales, data)">Bill me
      </button>

在脚本中,你可能会看到以下类似的代码:

function billTheUser(some, sales, data) {
  window.alert("Billing the user...");
  // actually bill the user
}

一个糟糕的例子

直接在 HTML 中分配事件处理器,就像我这样做的那样,是不推荐的。相反,你应该通过代码隐秘地设置处理器。所以,照我说的做,别像我那样做

这是对网页问题的简单解释,但对我们来说已经足够了。现在,让我们开始思考如何避免重复点击该按钮。我们如何防止用户多次点击?这是一个有趣的问题,有几种可能的解决方案 – 让我们先看看不好的那些!

你能想到多少种解决我们问题的方法?让我们回顾几种解决方案并分析它们的质量。

解决方案 1 – 期望最好的结果!

我们该如何解决这个问题?第一个解决方案可能听起来像是一个玩笑:什么都不做,告诉用户不要点击两次,并期望最好的结果!你的页面可能看起来像图 2**.1

图 2.1 – 一页实际的截图,只是提醒你避免多次点击

图 2.1 – 一页实际的截图,只是提醒你避免多次点击

这是一种逃避问题的方法;我见过一些网站只是警告用户关于多次点击的风险,却没有采取任何预防措施。所以,用户被扣款两次?我们警告过他们...这是他们的错!

你的解决方案可能看起来像以下代码:

<button
  id="billButton"
  onclick="billTheUser(some, sales, data)">Bill me
</button>
<b>WARNING: PRESS ONLY ONCE, DO NOT PRESS AGAIN!!</b>

好吧,这并不是一个真正的解决方案;让我们继续探讨更严肃的提议。

解决方案 2 – 使用全局标志

人们可能首先想到的解决方案是使用一些全局变量来记录用户是否已经点击了按钮。你可以定义一个名为 clicked 的标志,初始化为 false。当用户点击按钮时,如果 clickedfalse,你将其更改为 true 并执行函数;否则,你什么都不做。这可以在以下代码中看到:

let clicked = false;
.
.
.
function billTheUser(some, sales, data) {
  if (!clicked) {
    clicked = true;
    window.alert("Billing the user...");
    // actually bill the user
  }
}

这可行,但它有几个必须解决的问题:

  • 你正在使用全局变量,并且可能会意外地更改其值。在 JavaScript 或其他语言中,全局变量不是一个好主意。你还必须记得在用户再次开始购买时将其重新初始化为 false。如果你不这样做,用户将无法进行第二次购买,因为支付将变得不可能。

  • 你将难以测试此代码,因为它依赖于外部事物(即,点击变量)。

因此,这不是一个非常好的解决方案。让我们继续思考!

解决方案 3 – 移除处理程序

我们可能寻求一种横向的解决方案,而不是让函数避免重复点击,我们可能完全移除点击的可能性。以下代码正是这样做的;billTheUser() 做的第一件事就是从按钮中移除 onclick 处理程序,因此将不再可能进行进一步的调用:

function billTheUser(some, sales, data) {
  document
    .getElementById("billButton")
    .onclick = null;
  window.alert("Billing the user...");
  // actually bill the user
}

这个解决方案也有一些问题:

  • 代码与按钮紧密耦合,因此你无法在其他地方重用它。

  • 你必须记得重置处理程序;否则,用户将无法进行第二次购买。

  • 测试也将变得更加复杂,因为你将必须提供一些 DOM 元素。

我们可以增强这个解决方案,并通过在调用中提供按钮的 ID 作为额外的参数来避免将函数耦合到按钮上。(这个想法也可以应用于我们将会看到的某些其他解决方案。)HTML 部分如下;注意 billTheUser() 的额外参数:

<button
  id="billButton"
  onclick="billTheUser('billButton', some, sales, data)"
>Bill me
</button>

我们还必须更改调用的函数,以便它将使用接收到的 buttonId 值来访问相应的按钮:

function billTheUser(buttonId, some, sales, data) {
  document.getElementById(buttonId).onclick = null;
  window.alert("Billing the user...");
  // actually bill the user
}

这个解决方案稍微好一些。但,本质上,我们仍在使用一个全局元素 – 不是变量,而是 onclick 值。所以,尽管有所改进,但这也不是一个非常好的解决方案。让我们继续前进。

解决方案 4 – 更改处理程序

上一个解决方案的一个变体可能不是删除点击函数,而是分配一个新的函数。在这里,当我们将 alreadyBilled() 函数分配给点击事件时,我们正在使用函数作为一等对象。警告用户他们已经点击的函数可能看起来像这样:

function alreadyBilled() {
  window.alert("Your billing process is running; don't
    click, please.");
}

我们的 billTheUser() 函数将如下所示 – 注意与上一节中分配 nullonclick 处理程序不同,现在分配的是 alreadyBilled() 函数:

function billTheUser(some, sales, data) {
  document
    .getElementById("billButton")
    .onclick = alreadyBilled;
  window.alert("Billing the user...");
  // actually bill the user
}

这个解决方案也有优点;如果用户第二次点击,他们会收到警告不要这样做,但不会再次收费。(从用户体验的角度来看,这更好。)然而,这个解决方案仍然有与上一个相同的反对意见(代码与按钮耦合,需要重置处理器,以及更困难的测试),所以我们仍然认为它并不好。

解决方案 5 – 禁用按钮

在这里有一个类似的想法:我们不是移除事件处理器,而是禁用按钮,这样用户就无法点击。你可能有一个如下所示的函数,它通过设置按钮的disabled属性来执行这一操作:

function billTheUser(some, sales, data) {
  document
    .getElementById("billButton")
    .setAttribute("disabled", "true");
  window.alert("Billing the user...");
  // actually bill the user
}

这也行得通,但我们仍然有与之前解决方案相同的反对意见(将代码与按钮耦合,需要重新启用按钮,以及更困难的测试),所以我们也不喜欢这个解决方案。

解决方案 6 – 重新定义处理器

另一个想法:我们不是在按钮上做任何改变,而是让事件处理器改变自己。技巧在于以下代码的第二行;通过给billTheUser变量赋一个新的值,我们动态地改变了函数的行为!第一次调用函数时,它会执行其操作,但它也会通过将其名称赋予一个新的函数来改变自己消失:

function billTheUser(some, sales, data) {
  billTheUser = function() {};
  window.alert("Billing the user...");
  // actually bill the user
}

解决方案中有一个特殊的技巧。函数是全局的,所以billTheUser=...这一行改变了函数的内部工作方式。从那时起,billTheUser将变成新的(空)函数。这个解决方案仍然很难测试。更糟糕的是,你该如何恢复billTheUser的功能,将其恢复到原始目标?

解决方案 7 – 使用局部标志

我们可以回到使用标志的想法,但不是将其设置为全局的(这是我们反对第二个解决方案的主要原因),我们可以使用一个clicked,它将只对函数是局部的,在其他任何地方都不可见:

var billTheUser = (clicked => {
  return (some, sales, data) => {
    if (!clicked) {
      clicked = true;
      window.alert("Billing the user...");
      // actually bill the user
    }
  };
})(false);

这个解决方案与全局变量解决方案类似,但使用私有、局部变量是一种改进。(注意clicked是如何从最后的调用中获得其初始值的。)我们唯一能找到的缺点是,我们必须重写每个只需要调用一次的函数,以便以这种方式工作(而且,正如我们将在下一节中看到的,我们的函数式解决方案在某些方面与之相似)。好吧,这并不太难做,但别忘了不要重复自己DRY)的常规建议!

我们已经探讨了多种解决“只做一次”问题的方法 – 但正如我们所看到的,它们并不很好!让我们从功能的角度思考这个问题,以便我们得到一个更通用的解决方案。

我们问题的功能解决方案

让我们尝试更通用一些;毕竟,要求某些函数或其他函数只执行一次并不荒谬,可能在其他地方也需要!让我们制定一些原则:

  • 原始函数(可能只能调用一次的函数)应该执行它预期执行的所有操作,而不做其他任何事情

  • 我们不希望以任何方式修改原始函数

  • 我们需要一个新函数,它将只调用原始函数一次

  • 我们希望得到一个通用的解决方案,可以应用于任意数量的原始函数

SOLID 基础

之前列出的第一个原则是单一职责原则(SOLID 缩写中的 S),它指出每个函数都应该负责单一功能。有关 SOLID 的更多信息,请参阅 Uncle Bob(罗伯特·马丁,他写了这五个原则)的文章,见 butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod

我们能行吗?是的,我们将编写一个高阶函数,它将能够应用于任何函数,以生成一个新的函数,该函数将只工作一次。让我们看看怎么做!我们将在第六章“生成函数”中介绍高阶函数。在那里,我们将测试我们的函数解决方案,并对它进行一些改进。

高阶解决方案

如果我们不希望修改原始函数,我们可以创建一个高阶函数,我们可以(富有创意地!)将其命名为 once()。这个函数将接收一个函数作为参数,并返回一个新的函数,该函数将只工作一次。(正如我们之前提到的,我们将在后面看到更多关于高阶函数的内容;特别是,请参阅第六章**,“一次做事情,重访”部分*)。

许多解决方案

Underscore 和 Lodash 已经有一个类似的函数,称为 _.once()。Ramda 也提供了 R.once(),大多数 FP 库都包括类似的功能,所以你不必自己编写它。

我们的 once() 函数一开始可能看起来有些吓人,但随着你习惯以 FP 风格工作,你会习惯这种代码,并发现它相当易于理解:

// once.ts
const once = <FNType extends (...args: any[]) => any>(
  fn: FNType
) => {
  let done = false;
  return ((...args: Parameters<FNType>) => {
    if (!done) {
      done = true;
      return fn(...args);
    }
  }) as FNType;
};

让我们来看看这个函数的一些细节:

  • 我们的 once() 函数接收一个函数(fn)作为其参数,并返回一个相同类型的新的函数。(我们将在稍后更详细地讨论这种类型。)

  • 我们通过利用 闭包,就像在 解决方案 7 中那样,定义了一个内部、私有的 done 变量。我们选择不将其命名为 clicked(如我们之前所做的那样),因为你不一定需要点击按钮来调用函数;我们选择了更通用的术语。每次你将 once() 应用到某个函数时,都会创建一个新的、独特的 done 变量,并且只能从返回的函数中访问。

  • return 语句显示 once() 将返回一个函数,其参数类型与原始 fn() 相同。我们使用了在 第一章 成为函数式 中看到的展开语法。在 JavaScript 的旧版本中,你必须处理参数对象;有关更多信息,请参阅 developer.mozilla.org/en/docs/Web/JavaScript/Reference/Functions/arguments。现代方式更简单、更简洁!

  • 在调用 fn() 之前,我们将 done = true 赋值,以防该函数抛出异常。当然,如果你不想在函数成功结束之前禁用它,你可以将赋值移动到 fn() 调用下面。(有关此点的另一种看法,请参阅 问题 部分的 问题 2.4。)

  • 设置完成后,我们最终调用原始函数。注意使用了展开运算符来传递原始 fn() 所有的参数。

once() 的类型定义可能有些晦涩。我们必须指定输入函数的类型和 once() 的类型是相同的,这就是定义 FNType 的原因。图 2.2 显示 TypeScript 正确理解了这一点(查看本书末尾 问题 1.7 的答案以获取此例的另一个示例):

图 2.2 – 悬停显示 once() 输出的类型与输入的类型匹配

图 2.2 – 悬停显示 once() 输出的类型与输入的类型匹配

如果你还没有习惯使用 TypeScript,让我们看看纯 JavaScript 的等效代码,这同样是相同的代码,但用于类型定义:

// once_JS.js
const once = (fn) => {
  let done = false;
  return (...args) => {
    if (!done) {
      done = true;
      return fn(...args);
    }
  };
};

那么,我们该如何使用它呢?我们首先创建一个计费函数的新版本。

const billOnce = once(billTheUser);

然后,我们将 onclick 方法重写如下:

<button id="billButton"
  onclick="billOnce(some, sales, data)">Bill me
</button>;

当用户点击按钮时,调用带有 (some, sales, data) 参数的函数不是原始的 billTheUser(),而是应用了 once() 的结果。结果是只能调用一次的函数。

你不可能总是得到你想要的!

注意,我们的 once() 函数使用了诸如一等对象、箭头函数、闭包和展开运算符等函数。在 第一章 成为函数式 中,我们说过我们需要这些,所以我们信守承诺!我们唯一缺少的是递归,但正如滚石乐队所唱的,你不可能总是得到你想要的

我们现在有一种功能方式来让函数只做一次事情,但我们应该如何测试它呢?现在让我们来探讨这个话题。

手动测试解决方案

我们可以运行一个简单的测试。让我们编写一个 squeak() 函数,当被调用时,它将适当地发出 吱吱声!代码很简单:

// once.manual.ts
const squeak = a => console.log(a, " squeak!!");
squeak("original"); // "original squeak!!"
squeak("original"); // "original squeak!!"
squeak("original"); // "original squeak!!"

如果我们将 once() 应用到它上面,我们得到一个新的函数,它只会发出一次吱吱声。请看以下代码中高亮的行:

// continued...
const squeakOnce = once(squeak);
squeakOnce("only once"); // "only once squeak!!" squeakOnce("only once"); // no output
squeakOnce("only once"); // no output

之前的步骤展示了我们如何手动测试我们的once()函数,但我们的方法并不完全理想。在下一节中,我们将看到为什么以及如何做得更好。

自动测试解决方案

手动运行测试并不合适:它会变得令人厌烦和无聊,而且过一段时间后,会导致不再运行测试。让我们做得更好,并使用Jest编写一些自动测试:

// once.test.ts
import once } from "./once";
describe("once", () => {
  it("without 'once', a function always runs", () => {
    const myFn = jest.fn();
    myFn();
    myFn();
    myFn();
    expect(myFn).toHaveBeenCalledTimes(3);
  });
  it("with 'once', a function runs one time", () => {
    const myFn = jest.fn();
    const onceFn = jest.fn(once(myFn));
    onceFn();
    onceFn();
    onceFn();
    expect(onceFn).toHaveBeenCalledTimes(3);
    expect(myFn).toHaveBeenCalledTimes(1);
  });
});

这里有几个需要注意的点:

  • 要监视一个函数(例如,统计它被调用的次数),我们需要将其作为参数传递给jest.fn();我们可以对结果应用测试,它的工作方式与原始函数完全一样,但可以被监视。

  • 当你监视一个函数时,Jest 会拦截你的调用并记录该函数被调用,以及调用次数和参数。

  • 第一次测试只是检查如果我们多次调用函数,它会被调用相应次数。这很简单,但如果这种情况没有发生,我们就做错了什么!

  • 在第二个测试中,我们将once()应用于一个(虚拟的)myFn()函数,并多次调用结果(onceFn())。然后我们检查myFn()只被调用了一次,尽管onceFn()被调用了三次。

我们可以在图 2**.3中看到结果:

图 2.3 – 使用 Jest 对我们函数进行自动测试

图 2.3 – 使用 Jest 对我们函数进行自动测试

通过这样,我们不仅看到了如何手动测试我们的函数解决方案,还看到了自动测试的方法,所以我们已经完成了测试。让我们最后考虑一个更好的解决方案,也是以函数方式实现的。

产生一个更好的解决方案

在之前的解决方案中,我们提到每次第一次点击后做一些事情是个好主意,而不是默默地忽略用户的点击。我们将编写一个新的高阶函数,它接受第二个参数——一个从第二次调用开始每次都要调用的函数。我们的新函数将被称为onceAndAfter(),可以写成以下形式:

// onceAndAfter.ts
const onceAndAfter = <
  FNType extends (...args: any[]) => any
>(
  f: FNType,
  g: FNType
) => {
  let done = false;
  return ((...args: Parameters<FNType>) => {
    if (!done) {
      done = true;
      return f(...args);
    } else {
      return g(...args);
    }
  }) as FNType;
};

我们已经进一步探索了高阶函数;onceAndAfter()接受两个函数作为参数并产生一个第三个函数,它包含其他两个函数。

函数作为默认值

你可以通过为g提供一个默认值(例如() => {})来使onceAndAfter()更强大,这样如果你没有指定第二个函数,它仍然可以正常工作,因为默认的不做任何事情函数会被调用而不是导致错误。

我们可以像之前一样进行快速测试。让我们向之前的squeak()函数添加一个creak()吱吱作响函数,并检查如果我们将onceAndAfter()应用于它们会发生什么。然后我们可以得到一个makeSound()函数,它应该先吱吱作响然后吱吱作响:

// onceAndAfter.manual.ts
import { onceAndAfter } from "./onceAndAfter";
const squeak = (x: string) => console.log(x, "squeak!!");
const creak = (x: string) => console.log(x, "creak!!");
const makeSound = onceAndAfter(squeak, creak);
makeSound("door"); // "door squeak!!"
makeSound("door"); // "door creak!!"
makeSound("door"); // "door creak!!"
makeSound("door"); // "door creak!!"

为这个新函数编写测试并不难,只是稍微长一点。我们必须检查哪个函数被调用以及调用了多少次:

// onceAndAfter.test.ts
import { onceAndAfter } from "./onceAndAfter";
describe("onceAndAfter", () => {
  it("calls the 1st function once & the 2nd after", () => {
    const func1 = jest.fn();
    const func2 = jest.fn();
    const testFn = jest.fn(onceAndAfter(func1, func2));
    testFn();
    testFn();
    testFn();
    testFn();
    expect(testFn).toHaveBeenCalledTimes(4);
    expect(func1).toHaveBeenCalledTimes(1);
    expect(func2).toHaveBeenCalledTimes(3);
  });
});

注意,我们始终检查 func1() 只被调用一次。同样,我们检查 func2();调用次数从零开始(即 func1() 被调用时),然后每次调用增加一。

摘要

在本章中,我们看到了一个基于现实生活情况的常见简单问题。在分析了多种解决该问题的典型方法后,我们选择了函数式思考的解决方案。我们看到了如何将 FP 应用到我们的问题上,并找到了一个更通用的更高阶解决方案,我们可以将其应用于类似的问题,而无需进一步修改代码。我们还看到了如何为我们的代码编写单元测试,以完善开发工作。

最后,我们产生了一个更好的解决方案(从用户体验的角度来看),并看到了如何编码它以及如何进行单元测试。现在,你已经开始掌握如何以函数式的方式解决问题;接下来,在 第三章从函数开始,我们将更深入地探讨函数,这是所有 FP 的核心。

问题

2.1 done,用来标记函数是否已经被调用。虽然这并不重要,但你能否不使用任何额外的变量来完成这个任务?请注意,我们并没有告诉你不要使用任何变量,只是不添加任何新的变量,例如 done,这只是一个练习!

2.2 onceAndAfter() 函数,你能编写一个 alternator() 高阶函数,它接受两个函数作为参数,并在每次调用时交替调用其中一个和另一个吗?预期的行为应如下例所示:

const sayA = () => console.log("A");
const sayB = () => console.log("B");
const alt = alternator(sayA, sayB);
alt(); // A
alt(); // B
alt(); // A
alt(); // B
alt(); // A
alt(); // B

2.3 once(),你能编写一个高阶函数 thisManyTimes(fn,n),它将允许你最多调用 fn() 函数 n 次,但在之后不做任何事情吗?为了举例,once(fn)thisManyTimes(fn,1) 将产生具有相同行为的函数。也要为它编写测试。

2.4 将 once() 函数传递给一个函数,并且当该函数第一次被调用时崩溃。在这里,我们可能希望允许对该函数的第二次调用,希望它不会再次崩溃。我们需要一个 onceIfSuccess() 函数,它将接受一个函数作为参数,并生成一个新的函数,该函数只能成功运行一次,但在必要时允许失败(抛出异常)。实现 onceIfSuccess(),并且别忘了为它编写单元测试。

2.5 使用经典函数而不是箭头函数来实现 once()。这只是为了帮助你探索所需的数据类型语法略有不同的需求。

第三章:从函数开始 – 一个核心概念

第二章 函数式思考 中,我们讨论了一个函数式编程思考的例子,但现在,让我们来看看基础知识并回顾一下函数。

在本章中,我们将做以下事情:

  • 讨论 JavaScript 中的函数,包括如何定义它们,特别关注箭头函数

  • 了解 currying 和函数作为一等对象

  • 探索几种以函数式编程方式使用函数的方法

在学习完所有这些内容后,你将了解与函数相关的通用和特定概念,毕竟,函数是函数式编程的核心!

所有关于函数的内容

让我们从对 JavaScript 中的函数及其与函数式编程概念的关系的简要回顾开始。我们将从主要在第一章 成为函数式函数作为一等对象 部分,以及在 第二章 函数式思考 的几个地方提到的东西开始,然后继续讨论它们在实际编码中的使用。特别是,我们将关注以下内容:

  • 关于 lambda 演算的一些基本概念,它是函数式编程的理论基础

  • 箭头函数,这是 lambda 演算直接翻译到 JavaScript 的最直接方式

  • 将函数作为一等对象使用,这是函数式编程中的一个关键概念

关于 lambda 和函数

在 lambda 演算的术语中,一个函数可以看起来像 λx.2**x*。理解是 λ 字符后面的变量(希腊字母 lambda 的小写形式)是函数的参数,点号后面的表达式是你要替换传递作为参数的任何值的所在位置。在本章的后面部分,我们将看到这个特定的例子可以用 JavaScript 的箭头函数形式 (x) => 2*x 来编写,正如你所看到的,它非常相似。

一个押韵的辅助工具

如果你有时想知道参数和参数之间的区别,一个带有一些押韵的助记符可能会有所帮助:参数是潜在的,参数是实际的。参数是潜在值的占位符,这些值将被传递,而参数是传递给函数的实际值。换句话说,当你定义函数时,你列出它的参数,当你调用它时,你提供参数。

应用一个函数意味着你向它提供一个实际参数,这通常以括号内的方式书写。例如,(λx.2**x*)(3) 将被计算为 6。这些 lambda 函数在 JavaScript 中的等效函数是什么?这是一个有趣的问题!定义函数的方式有几种,并且它们并不都具有相同的意义。

在 JavaScript 中,你可以有多少种方式定义函数?答案可能比你想象的要多!(一篇展示定义函数、方法等多种方式的优秀文章是 JavaScript 中函数的多种面貌,作者为 Leo Balter 和 Rick Waldron,可在 bocoup.com/blog/the-many-faces-of-functions-in-javascript 上阅读——值得一读!)至少,你可以写出以下内容——我将使用纯 JavaScript,因为在这里类型不是问题:

  • 命名函数声明:function first(...) {...};

  • 匿名函数表达式:var second = function(...) {...};

  • 命名函数表达式:var third = function someName(…) {...};

  • 立即调用的表达式:var fourth = (function() { ...; return function(...) {...}; })();

  • 函数构造器:var fifth = new Function(...);

  • 箭头函数:var sixth = (...) => {...};

如果你愿意,你可以添加对象方法声明,因为它们也意味着函数,但前面的列表应该足够了。

更多函数类型

JavaScript 还允许我们定义 function*(...) {...}) 返回一个 Generator 对象和 async 函数,它们是生成器和承诺的混合体。你可以在 developer.mozilla.org/en/docs/Web/JavaScript/Reference/Statements/functiondeveloper.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function 分别了解更多关于它们的信息。

所有这些定义函数的方式之间有什么区别,为什么我们应该关心?让我们逐一探讨:

  • 第一种定义,function first(...) {...},是一个以 function 关键字开始的独立声明,可能是 JavaScript 中使用最广泛的方式,它定义了一个名为 first 的函数(即 first.name==="first")。由于 var 定义;使用 letconst 时,不会应用提升。你可以在 developer.mozilla.org/en-US/docs/Glossary/Hoisting 上了解更多关于提升的信息。请记住,它仅适用于声明,而不适用于初始化。

  • 将函数赋值给变量的second = function(...) {...}定义,也产生了一个函数,但是一个匿名(即没有名字)的函数。然而,许多 JavaScript 引擎可以推断出应该使用的名字,然后设置second.name === "second"。(查看以下代码,它显示了一个匿名函数没有分配名字的情况。)由于赋值没有提升,函数只有在赋值执行之后才能访问。此外,您可能更喜欢使用const而不是var来定义变量,因为您不会(不应该)更改函数——查看 ESLint 的no-varprefer-const规则以强制执行此操作:

    var second = function() {};
    console.log(second.name);
    // "second"
    var myArray = new Array(3);
    myArray[1] = function() {};
    console.log(myArray[1].name);
    // ""
    
  • 第三个定义,third = function someName(…) {...},与第二个定义相同,只是函数现在有自己的名字:third.name === "someName"。当您想要调用函数时,函数的名字是相关的,并且在递归调用时是必需的;我们将在第九章设计函数中回到这一点。如果您只是需要一个用于,比如说,回调的函数,您可以使用一个没有名字的函数。然而,请注意,有名字的函数在错误跟踪中更容易被识别,这是您在试图理解发生了什么以及哪个函数调用了哪个函数时使用的列表。

  • 第四个定义,fourth = (function() { ...; return function(...) {...}; })(),使用立即执行的表达式,允许您使用闭包。回到我们在第一章**,成为函数式开发者部分的闭包*部分中看到的计数器制作函数,我们可以编写如下内容。内部函数可以使用在外部函数中定义的变量或其他函数,以私有、封装的方式使用。外部函数接收一个参数(在这种情况下是77),用作count的初始值(如果没有提供初始值,我们从头开始0)。内部函数可以访问count(因为闭包),但变量不能在其他任何地方访问。在所有方面,返回的函数都是通用的——唯一的区别是它对私有元素的访问。这也是模块模式的基础:

    const myCounter = (function myCounter(initialValue =
      0) {
      let count = initialValue;
      return function () {
        count++;
        return count;
      };
    })(77);
    console.log(myCounter()); // 78
    console.log(myCounter()); // 79
    console.log(myCounter()); // 80
    
  • 第五个定义,fifth = new Function(...),是不安全的,您不应该使用它!您首先传递参数的名字,然后是实际的函数体作为字符串,然后使用eval()的等效功能来创建函数——这允许许多危险的漏洞,所以不要这样做!(此外,TypeScript 无法推断出产生的函数的类型;它只是假设通用的Function类型。)为了激发您的兴趣,让我们看看重写我们在第一章**,成为函数式开发者部分的展开*部分中看到的非常简单的sum3()函数的例子:

    const sum3 = new Function(
      "x",
      "y",
      "z",
      "const t = x+y+z; return t;"
    );
    sum3(4, 6, 7); // 17
    

eval()的怪癖

这种定义不仅不安全,还有一些其他的小问题——它们不会创建具有创建上下文的闭包,因此它们总是全局的。有关更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function,但请记住,这种方式创建函数并不是一个好主意!

  • 最后,最后一个定义,sixth = (...) => {...},它使用了箭头=>,是定义函数最紧凑的方式,也是我们尽可能尝试使用的方式。

到目前为止,我们已经看到了几种定义函数的方法,所以让我们专注于箭头函数,这是我们在这本书的编码中将偏好的风格。

箭头函数——现代的方式

即使箭头函数在大多数情况下与其他函数的工作方式相同,它们之间以及与常规函数之间也有一些关键的区别(参见developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions):箭头函数即使没有return语句也可以隐式返回一个值;this(函数的上下文)的值没有被绑定;没有arguments对象;它们不能用作构造函数;它们没有原型属性;并且由于不允许使用yield关键字,它们不能用作生成器。

在本节中,我们将探讨几个与 JavaScript 函数相关的话题,包括以下内容:

  • 如何返回不同的值

  • 如何处理this值的难题

  • 如何处理不同数量的参数

  • 一个重要的概念,柯里化(currying),我们将在本书的其余部分找到许多用途

返回值

在 lambda 编码风格中,函数只包含一个结果。为了简洁起见,新的箭头函数提供了一种语法来表示这一点。当你写一个像(x,y,z) =>后面跟着一个表达式的东西时,就隐含了一个return。例如,以下两个函数与之前展示的sum3()函数做的是同样的事情:

const f1 = (x: number, y: number, z: number): number =>
  x + y + z;
const f2 = (x: number, y: number, z: number): number => {
  return x + y + z;
};

如果你想要返回一个对象,你必须使用括号;否则,JavaScript 会假设代码随后。不要认为这是一个极不可能的情况,请查看本章后面的问题部分中的问题 3.1,这是一个非常常见的场景!

有关风格的问题

当你定义一个只有一个参数的箭头函数时,你可以省略其周围的括号。为了保持一致性,我更喜欢总是包括它们。我使用的格式化工具 Prettier(我们在第一章成为函数式开发者)最初并不赞成这样做,但在 2.0 版本中,它将arrow-parens配置项的默认值从avoid(意味着,尽量不使用括号)更改为always

处理this

JavaScript 中有一个经典问题,就是处理 this,其值并不总是你期望的。ES2015 通过箭头函数解决了这个问题,箭头函数会继承正确的 this 值,从而避免了问题。看看以下代码的例子,以了解可能存在的问题:当超时函数被调用时,this 将指向全局(window)变量而不是新对象,因此在控制台你会得到 undefined

function ShowItself1(identity: string) {
  this.identity = identity;
  setTimeout(function () {
    console.log(this.identity);
  }, 1000);
}
var x = new ShowItself1("Functional");
// after one second, undefined is displayed, not Functional

有一些传统的方法可以用老式的 JavaScript 解决这个问题:

  • 一种解决方案是使用闭包并定义一个局部变量(通常命名为 that 或有时 self),它会获取 this 的原始值,因此它不会是 undefined

  • 第二种方法使用 bind(),因此超时函数将被绑定到正确的 this 值(我们在 关于 lambda 和函数 部分使用 bind() 达到类似的目的)。

  • 第三种,更现代的方法只是使用箭头函数,这样 this 就会得到正确的值(指向对象),无需进一步操作。

让我们看看实际的三个解决方案。我们使用闭包处理第一个超时,绑定处理第二个,箭头函数处理第三个:

// continued...
function ShowItself2(identity: string) {
  this.identity = identity;
  const that = this;
  setTimeout(function () {
    console.log(that.identity);
  }, 1000);
  setTimeout(
    function () {
      console.log(this.identity);
    }.bind(this),
    2000
  );
  setTimeout(() => {
    console.log(this.identity);
  }, 3000);
}
const x2 = new ShowItself2("JavaScript");
// after one second, "JavaScript"
// after another second, the same
// after yet another second, once again

如果你运行这段代码,你会在 1 秒后得到 JavaScript,然后又过一秒再次得到,再过一秒又得到一次。所有三种方法都正确工作,所以你选择哪一种只取决于你更喜欢哪一种。

处理参数

第一章**,《成为函数式开发者》第二章**,《函数式思考》 中,我们看到了一些使用展开(...)操作符的例子。然而,我们将最实用地使用它来处理参数;我们将在 第六章**,《生成函数》 中看到一些这样的例子。

让我们回顾一下来自 第二章**,《函数式思考》once() 函数:

// once.ts
const once = <FNType extends (...args: any[]) => any>(
  fn: FNType
) => {
  let done = false;
  return ((...args: Parameters<FNType>) => {
    if (!done) {
      done = true;
      return fn(...args);
    }
  }) as FNType;
};

为什么我们写 return (...args) => 然后紧接着 func(...args)?答案与处理可变数量(可能为零)的参数的更现代方式有关。你是如何管理 JavaScript 旧版本中的这类代码的?答案是 arguments 对象(不是一个数组;阅读 developer.mozilla.org/en/docs/Web/JavaScript/Reference/Functions/arguments),它允许你访问传递给函数的实际参数。

事实上,arguments 是一个类似数组的对象,而不是真正的数组——它唯一的数组属性是 length。你不能在 arguments 上使用 map()forEach() 等方法。要将 arguments 转换为真正的数组,你必须使用 slice();你还需要使用 apply() 来调用另一个函数,如下所示:

function useArguments() {
  …
  var myArray = Array.prototype.slice.call(arguments);
  somethingElse.apply(null, myArray);
  …
}

在现代 JavaScript 中,你不需要使用参数、切片或应用:

function useArguments2(...args) {
  …
  somethingElse(...args);
  …
}

在查看此代码时,你应该记住以下要点:

  • 通过编写 useArguments2(...args),我们立即清楚地表达我们的新函数接收几个(可能为零)参数。

  • 你不需要做任何事情来得到一个数组;args 是一个真正的数组。

  • somethingElse(...args) 比使用 apply() 更清晰。

顺便说一下,arguments 对象在当前版本的 JavaScript 中仍然可用。如果你想从它创建一个数组,你有两种替代方法来做这件事,而无需求助于 Array.prototype.slice.call 技巧:

  • 使用 from() 方法并编写 myArray=Array.from(arguments)

  • myArray=[...arguments],这显示了扩展运算符的另一种用法。

当我们讨论到高阶函数时,编写处理其他函数、可能未知数量的参数的函数将变得很常见。

JavaScript 提供了一种更简洁的方式来完成这个任务,所以你将不得不习惯这种用法。这是值得的!

一个参数还是多个?

还可以编写返回函数的函数,在 第六章 生成函数 中,我们将看到更多关于这个的内容。例如,在 lambda 演算中,你不会写带有多个参数的函数,而只有一个;你使用一种称为 currying 的技术来做这件事。(但你为什么要这样做呢?保留这个想法;我们稍后会谈到。)

双重认可

Currying 这个名字来源于 Haskell Curry,他提出了这个概念。一种函数式语言,Haskell,也是以他的名字命名的——双重认可!

例如,我们之前看到的那三个数字求和的函数可以这样写:

// sum3.ts
const altSum3 = (x: number) => (y: number) => (z: number)
  =>
    x + y + z;

为什么我改变了函数的名称?简单地说,这 不是 我们之前看到的同一个函数。sum3() 的类型是 (x: number, y: number, z: number) => number,而 altSum3() 的类型是 (x: number) => (y: number) => (z: number) => number,这是不同的。(有关更多内容,请参阅 问题 3.3。)然而,它可以用来说明与我们的早期函数完全相同的结果。让我们看看你如何使用它,比如,来求和数字 1、2 和 3:

altSum3(1)(2)(3); // 6

在继续阅读之前,先测试一下自己,并思考这个问题:如果你写了 altSum3(1,2,3),会返回什么?提示:它不会是一个数字!要得到完整的答案,请继续阅读。

这是如何工作的?将其分解为许多调用可以帮助;这将是以这种方式由 JavaScript 解释器计算前一个表达式的:

const fn1 = altSum3(1);
const fn2 = fn1(2);
const fn3 = fn2(3);

想象一下函数式!调用 altSum3(1) 的结果,根据定义,是一个函数,由于闭包,它解析为以下内容:

const fn1 = y => z => 1 + y + z;

我们的 altSum3() 函数旨在接收单个参数,而不是三个!这个调用的结果,fn1,也是一个单参数函数。当你使用 fn1(2) 时,结果再次是一个函数,也只有一个参数,它等同于以下内容:

const fn2 = z => 1 + 2 + z;

当你计算fn2(3)时,最终返回了一个值——太棒了!正如我们所说的,函数执行的计算类型与我们之前看到的相同,但方式本质上不同。

你可能会认为柯里化是一个奇特的技巧:谁会想只使用单参数函数呢?当我们考虑如何在第八章“连接函数”和第十二章“构建更好的容器”中将函数组合在一起时,你会看到这样做的原因,在这些章节中,从一个步骤传递超过一个参数到下一个步骤是不切实际的。

函数作为对象

首类对象的概念意味着函数可以被创建、分配、更改、作为参数传递,并且可以作为其他函数的结果返回,就像你可以用数字或字符串一样。让我们从它的定义开始。让我们看看你通常是如何定义一个函数的——你认出函数的名字了吗?(提示:谷歌“Colossal Cave Adventure”!)

function xyzzy(...) { ... }

这(几乎)等同于编写以下内容:

var xyzzy = function(...) { ... }

然而,这与提升(hoisting)不同,正如我们在关于 lambda 函数和函数部分中解释的那样。JavaScript 将所有定义移动到当前作用域的顶部,但不会移动赋值。在第一个定义中,你可以从代码的任何地方调用xyzzy(...),但在第二个定义中,你必须等到赋值执行后才能调用该函数。

我们想要表达的观点是,一个函数可以被分配给一个变量,如果需要的话也可以重新分配。在类似的方面,我们可以在需要的地方定义函数。我们甚至可以不命名它们来做这件事:就像常见的表达式一样,如果它们只使用一次,你不需要命名它们或将它们存储在变量中。

一个巨大的并行

你看到与 70 年代的Colossal Cave Adventure游戏的平行了吗?在任何地方调用xyzzy()并不总是有效的!如果你从未玩过那个著名的交互式小说游戏,可以在线尝试——例如,在www.web-adventures.org/cgi-bin/webfrotz?s=Adventurewww.amc.com/blogs/play-the-colossal-cave-adventure-game-just-like-halt-and-catch-fires-cameron-howe--1009966

让我们看看一个实际的代码示例,它涉及到函数的赋值。

一个 React-Redux reducer

正如我们在第一章“成为函数式”中提到的,React-Redux 通过分发由reducer处理的动作来工作。(更多关于这个话题的信息,请参阅redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers。)通常,reducer 包含带有 switch 语句的代码。以下是一个例子——我使用 JavaScript(而不是 TypeScript)来关注逻辑方面:

// reducer.ts
function doAction(
  state = initialState,
  action = emptyAction
) {
  const newState: State = {};
  switch (action?.type) {
    case "CREATE":
      // update state, generating newState,
      // depending on the action data
      // to create a new item
      return newState;
    case "DELETE":
      // update state, generating newState,
      // after deleting an item
      return newState;
    case "UPDATE":
      // update an item,
      // and generate an updated state
      return newState;
    default:
      return state;
  }
}

初始状态

initialState作为状态的默认值是一种初始化全局状态的方法。不要注意那个default;它与我们的例子无关,我之所以包含它,只是为了完整性。我还假设存在StateAction和其他类型——参见问题 3.5

通过利用存储函数的可能性,我们可以构建一个调度 并简化前面的代码。首先,我们初始化一个对象,其中包含每个动作类型的函数代码。我们只是将前面的代码取出来,创建单独的函数:

// continued...
const dispatchTable = {
  CREATE: (state, action) => {
    // update state, generating newState,
    // depending on the action data
    // to create a new item
    const NewState = {
      /* updated State */
    };
    return NewState;
  },
  DELETE: (state, action) => {
    // update state, generating newState,
    // after deleting an item
    const NewState = {
      /* updated State */
    };
    return NewState;
  },
  UPDATE: (state, action) => {
    // update an item,
    // and generate an updated state
    const NewState = {
      /* updated State */
    };
    return NewState;
  },
};

我们将处理每种动作的不同函数存储在作为调度表工作的对象属性中。此对象仅创建一次,并在应用程序执行期间保持不变。有了它,我们现在可以只用一行代码重写动作处理代码:

// continued...
function doAction2(state, action) {
  return dispatchTable[action.type]
    ? dispatchTableaction.type
    : state;
}

让我们分析一下:给定动作,如果action.type与调度对象中的属性匹配,我们执行从对象中取出的相应函数。如果没有匹配,我们只返回当前状态,因为 Redux 要求这样做。如果没有能够将函数(存储和调用它们)作为一等对象处理,这种类型的代码是不可能的。

一个不必要的错误

然而,有一个常见的(尽管实际上是无害的)错误通常会被犯。你经常看到这样的代码:

fetch("some/url").then(function(data) {
  processResult(data);
});
fetch("some/url").then((data) => processResult(data));

这段代码做什么?其思路是获取远程 URL,当数据到达时,调用一个函数——然后这个函数用data作为参数调用processResult。也就是说,在then()部分,我们想要一个函数,给定数据,计算processResult(data)。但我们不是已经有了这样的函数吗?

有一个规则,你可以在看到以下内容时应用:

function someFunction(someData) {
  return someOtherFunction(someData);
}

此规则指出,你可以用someOtherFunction替换类似前面代码的代码。所以,在我们的例子中,我们可以直接写出以下内容:

fetch("some/url").then(processResult);

这段代码与之前我们查看的方法等价(尽管由于避免了函数调用,它几乎更快),但它是否更容易理解?

一些术语

在λ演算术语中,我们是用简单的func替换了λx.func x——这被称为η (eta) 转换,或者更具体地说,是η 归约。(如果你反过来做,那将是η 抽象。)在我们的情况下,它可以被认为是一个(非常,非常小的!)优化,但其主要优势是代码更短,更紧凑。

这种编程风格被称为无点(也称为无点)或隐式风格,其定义特征是您永远不会为每个函数应用指定参数。这种编码方式的一个优点是它帮助作者(以及未来的代码读者)思考函数及其含义,而不是在低级别上工作,传递数据,并与之交互。在代码的简短版本中,没有无关或无关紧要的细节:如果您知道被调用的函数做什么,您就理解了整个代码片段的含义。我们通常(但不一定总是)会以这种方式在我们的文本中工作。

传统的 Unix 风格

Unix/Linux 用户已经习惯了这种风格,因为他们使用管道将命令的结果作为输入传递给另一个命令时,以类似的方式进行工作。当您编写ls|grep doc|sort时,ls的输出是grep的输入,而grep的输出是sort的输入——但输入参数并没有写出来;它们是隐含的。我们将在第八章“无点风格”部分中回到这一点,“连接函数”

与方法交互

然而,有一个情况您应该注意:如果您调用一个对象的方法会发生什么?看看以下代码:

fetch("some/remote/url").then(function (data) {
  myObject.store(data);
});

如果您的原始代码与前面的代码类似,那么看似明显的转换代码将失败:

fetch("some/remote/url").then(myObject.store); // Fail!

为什么?原因是原始代码中,被调用的方法绑定到一个对象(myObject),但在修改后的代码中,它没有绑定,只是一个自由函数。我们可以通过使用bind()来修复它:

fetch("some/remote/url").then(
  myObject.store.bind(myObject)
);

这是一个通用的解决方案。在处理方法时,您不能只是分配它;您必须使用 bind() 以确保正确的上下文可用。看看以下代码:

const doSomeMethod = (someData) => {
  return someObject.someMethod(someData);
}

依照这个规则,这样的代码应该转换为以下形式:

const doSomeMethod = someObject.someMethod.bind(someObject);

小贴士

developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_objects/Function/bind 上了解更多关于 bind() 的信息。

这看起来相当笨拙,并不太优雅,但这是必须的,以便方法将与正确的对象相关联。当我们第六章中的函数“生成函数”进行“Promise 化”时,我们将看到这种应用的例子。即使这段代码看起来并不那么令人愉快,但每当您必须与对象(记住,我们并没有说我们会尝试实现完全的函数式编程代码,而是说如果它们使事情变得更容易,我们会接受其他结构)交互时,您必须记得在以无点风格将它们作为一等对象传递之前绑定方法。

到目前为止,我们讨论了许多关于函数的方面;现在,让我们更深入地探讨 FP 中的函数,看看我们将如何使用它们。

使用函数进行 FP 编程方式

几种常见的编码模式利用了函数式编程(FP)风格,即使你并没有意识到。在本节中,我们将逐一介绍它们,并查看代码的功能性方面,以便你能够更习惯这种编码风格。

然后,我们将详细探讨以函数式编程(FP)方式使用函数,通过考虑以下几种函数式编程技术:

  • 注入,这是排序不同策略以及其他用途所必需的

  • Callbackspromises,引入 continuation-passing 风格

  • Polyfillingstubbing

  • Immediate invocation schemes

注入 - 排序问题

Array.prototype.sort() 方法提供了传递函数作为参数的第一个例子。如果您有一个字符串数组并且想要对其进行排序,您可以直接使用 sort() 方法。例如,要对彩虹的颜色进行字母排序,我们会写如下内容:

// sort.ts
const colors = [
  "violet",
  "indigo",
  "blue",
  "green",
  "yellow",
  "orange",
  "red",
];
colors.sort();
console.log(colors);
// 'blue', 'green', 'indigo', 'orange', 'red',
// 'violet', 'yellow'

注意,我们不需要向 sort() 调用提供任何参数,但数组被完美排序了。默认情况下,此方法根据字符串的 ASCII 内部表示进行排序。因此,如果您使用此方法对数字数组进行排序,它将失败,因为它会决定 20 必须位于 100 和 3 之间,因为 100 在 20(作为字符串)之前,而后者在 3 之前,所以这需要修复!以下代码显示了问题:

// continued...
const someNumbers = [3, 20, 100];
someNumbers.sort();
console.log(someNumbers);
// 100, 20, 3

但让我们暂时忘记数字,专注于排序字符串。如果我们想要按照适当的区域规则对一些西班牙语单词(palabras)进行排序,会发生什么?我们会排序字符串,但结果可能不会正确:

// continued...
const palabras = [
  "ñandú",
  "oasis",
  "mano",
  "natural",
  "mítico",
  "musical",
];
palabras.sort();
console.log(palabras);
// "mano", "musical", "mítico",
// "natural", "oasis", "ñandú" -- wrong result!

一个词中有什么?

对于语言或生物学爱好者来说,英语中的 ñandúrhea,一种类似鸵鸟的奔跑鸟类。以 ñ 开头的西班牙语单词并不多,而且我们恰好在我所在的国家乌拉圭有这些鸟类,这就是这个奇怪单词的原因!

哎呀!在西班牙语中,ñ 位于 no 之间,但 "ñandú" 被排序到了最后。此外,"mítico"(在英语中意为神话般的;注意重音 í)应该位于 "mano""musical" 之间,因为波浪号应该被忽略。解决这个问题的适当方法是提供一个比较函数给 sort()。在这种情况下,我们可以使用 localeCompare() 方法,如下所示:

// continued...
palabras.sort((a: string, b: string) =>
  a.localeCompare(b, "es")
);
console.log(palabras);
// "mano", "mítico", "musical",
// "natural", "ñandú", "oasis" –- correct result!

a.localeCompare(b,"es") 调用比较了 ab 字符串,如果 ab 之前,则返回负值;如果 ab 之后,则返回正值;如果 ab 相同,则返回 0 - 但根据西班牙语("es")的排序规则。

现在,一切正常!而且可以通过引入一个新的函数 spanishComparison() 来执行所需的字符串比较,使代码更清晰:

// continued...
const spanishComparison = (a: string, b: string) =>
  a.localeCompare(b, "es");
palabras.sort(spanishComparison); // same correct result

国际排序

更多关于 localeCompare() 的可能性,请参阅 developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare。你可以指定要应用哪些区域规则,字母的大小写顺序,是否忽略标点符号,以及更多。但请注意:并非所有浏览器都支持所需的额外参数。

在接下来的章节中,我们将讨论函数式编程如何让你以更声明性的方式编写代码,生成更易于理解的代码,以及这种微小的变化是如何帮助的。当代码的读者到达 sort 函数时,他们将会立即推断出正在执行的操作,即使注释没有出现。

关于策略和模式

通过注入不同的比较函数来改变 sort() 函数的工作方式,这是一种策略 设计模式 的例子。我们将在 第十一章实现 设计模式 中了解更多。

sort 函数作为参数提供(以一种非常函数式编程的方式!)也可以帮助解决几个其他问题,例如以下内容:

  • 默认情况下,sort() 只能与字符串一起使用。如果你想对数字进行排序(就像我们之前尝试的那样),你必须提供一个将数值比较的函数。例如,你会写类似 myNumbers.sort((a:number, b:number) => a – b) 的内容。(为什么?见 问题 3.7。)

  • 如果你想要根据给定的属性对对象进行排序,你将使用一个与它比较的函数。例如,你可以使用类似 myPeople.sort((a:Person, b:Person) => a.age - b.age) 的方式对人们按年龄进行排序。

这是一个你可能之前使用过的简单例子,但毕竟它是一个函数式编程模式。让我们继续探讨在执行 Ajax 调用时将函数作为参数的更常见用法。

回调和承诺

函数作为一等对象传递的最常用例子之一与回调和承诺有关。在 Node.js 中,读取文件是通过以下类似代码的异步方式完成的:

const fs = require("fs");
fs.readFile("someFile.txt", (err, data) => {
  if (err) {
    // handle the error
  } else {
    // do something with the received data
  }
});

readFile() 函数需要一个回调(在这个例子中是一个匿名函数),当文件读取操作完成时会被调用。

一种更好、更现代的方法是使用承诺;更多内容请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise。使用这种方式,当使用 fetch() 函数执行 Ajax 网络服务调用时,你可以编写类似以下代码的内容:

fetch("some/remote/url")
  .then((data) => {
    // do something with the received data
  })
  .catch((error) => {
    // handle the error
  });

最后,你也应该考虑使用async/await;更多关于它们的信息可以在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_functiondeveloper.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await中找到。

延续传递风格

之前提到的代码,其中你调用一个函数,同时也传递另一个在输入/输出操作完成后要执行的函数,可以被认为是称为延续传递风格CPS)的东西。这种编程技术是什么?一种看待它的方式是思考以下问题:如果你被禁止使用 return 语句,你会如何编程?

初看起来,这似乎是一种不可能的情况。然而,我们可以通过传递一个回调给被调用的函数来摆脱困境,这样当该过程准备好返回给调用者时,而不是返回,它将调用给定的回调。通过这样做,回调为被调用的函数提供了继续过程的方式,因此得名延续。我们现在不会深入探讨这个问题,但在第九章设计函数中,我们将深入研究它。特别是,CPS 将帮助我们避免一个重要的递归限制,正如我们将看到的。

确定如何使用延续有时具有挑战性,但总是可能的。这种编程方式的令人兴奋的优势在于,通过指定过程将如何继续,你可以超越所有常规结构(ifwhilereturn等),并实现你想要的任何机制。这在处理过程不一定是线性的问题中非常有用。当然,这也可能导致你发明一种比你可能想象的 GOTO 语句的潜在使用更糟糕的控制结构!图 3.1显示了这种做法的危险!

图 3.1 – 如果你开始篡改程序流程,最坏的情况会是什么?(这个 XKCD 漫画可在 xkcd.com/292/在线查看)

图 3.1 – 如果你开始篡改程序流程,最坏的情况会是什么?(这个 XKCD 漫画可在xkcd.com/292/在线查看)

你不仅限于传递单个延续。就像承诺一样,你可以提供两个或更多的替代回调。顺便说一句,这可以提供一种解决方案来解决如何处理异常的问题。如果我们简单地允许一个函数抛出错误,那么它将是对调用者的隐式返回,我们不希望这样。摆脱这种困境的方法是提供一个替代回调(即不同的延续),以便在抛出异常时(在第十二章构建更好的容器中,我们将找到另一种使用单子的解决方案)使用。

function doSomething(a, b, c,
  normalContinuation, errorContinuation) {
  let r = 0;
  // ... do some calculations involving a, b, and c,
  // and store the result in r
  // if an error happens, invoke:
  // errorContinuation("description of the error")
  // otherwise, invoke:
  // normalContinuation(r)
}

使用 CPS 甚至可以让你超越 JavaScript 提供的控制结构,但这超出了本书的目标,所以我会让你自己研究这一点!

Polyfills

能够动态分配函数(就像你可以为变量分配不同的值一样)也允许你在定义 polyfills 时更加高效地工作。

检测 Ajax

让我们回到 Ajax 开始出现的时候。鉴于不同的浏览器以不同的方式实现了 Ajax 调用,你总是必须围绕这些差异进行编码。以下代码展示了你将如何通过测试几个不同的条件来实现 Ajax 调用:

// ajax.ts
function getAjax() {
  let ajax = null;
  if (window.XMLHttpRequest) {
    // modern browser? use XMLHttpRequest
    ajax = new XMLHttpRequest();
  } else if (window.ActiveXObject) {
    // otherwise, use ActiveX for IE5 and IE6
    ajax = new ActiveXObject("Microsoft.XMLHTTP");
  } else {
    throw new Error("No Ajax support!");
  }
  return ajax;
}

这行代码运行了,但暗示了你会为每个调用重新进行 Ajax 检查,尽管测试结果永远不会改变。有一种更有效的方法来做这件事,这与使用函数作为一等对象有关。我们可以定义两个不同的函数,只测试一次条件,然后分配正确的函数供以后使用;研究以下代码以了解这种替代方案:

// continued...
(function initializeGetAjax() {
  let myAjax = null;
  if (window.XMLHttpRequest) {
    // modern browsers? use XMLHttpRequest
    myAjax = function () {
      return new XMLHttpRequest();
    };
  } else if (window.ActiveXObject) {
    // it's ActiveX for IE5 and IE6
    myAjax = function () {
      new ActiveXObject("Microsoft.XMLHTTP");
    };
  } else {
    myAjax = function () {
      throw new Error("No Ajax support!");
    };
  }
  window.getAjax = myAjax;
})();

这段代码展示了两个重要的概念。首先,我们可以动态地分配一个函数:当这段代码运行时,window.getAjax(全局的getAjax变量)将根据当前浏览器获得三个可能值之一。当你后来在你的代码中调用getAjax()时,正确的函数将执行,而无需你进行任何进一步的浏览器检测测试。

第二个有趣的想法是我们定义了initializeGetAjax()函数并立即运行它——这种模式被称为立即调用的函数表达式IIFE),我们已经在第二章**——函数式思考中的解决方案 7——使用局部标志*部分中看到了它。函数运行后会自行清理,因为所有变量都是局部的,并且在函数运行后甚至不会存在。我们将在本章后面了解更多关于这一点。

现在,你会使用模块而不是 IIFE,如下所示:

// ajax.module.ts
let getAjax = null;
if (window.XMLHttpRequest) {
  // modern browsers? use XMLHttpRequest
  getAjax = function () {
    return new XMLHttpRequest();
  };
} else if (window.ActiveXObject) {
  // it's ActiveX for IE5 and IE6
  getAjax = function () {
    new ActiveXObject("Microsoft.XMLHTTP");
  };
} else {
  getAjax = function () {
    throw new Error("No Ajax support!");
  };
}
export { getAjax };

模块中的代码保证只运行一次。无论你需要进行 Ajax 调用的地方,你只需import { getAjax } from "/path/to/ajax.module",然后你可以随意使用getAjax()

添加缺失的函数

这种在运行时定义函数的想法也允许我们编写提供其他缺失函数的 polyfills。例如,假设我们有一些如下所示的代码:

if (currentName.indexOf("Mr.") !== -1) {
  // it's a man
  ...
}

而不是这样做,你可能会非常倾向于使用较新的includes()方法,并简单地写下如下内容:

if (currentName.includes("Mr.")) {
  // it's a man
  ...
}

如果你的浏览器不提供includes()会发生什么?再一次,我们可以在运行时定义适当的函数,但仅当需要时。如果includes()可用,你不需要做任何事情,但如果它缺失,你需要定义一个 polyfill 来提供相同的功能。(你可以在 Mozilla 的开发者网站上找到 polyfills 的链接。)以下代码展示了这样一个 polyfill 的示例:

if (!String.prototype.includes) {
  String.prototype.includes = function (search, start) {
    "use strict";
    if (typeof start !== "number") {
      start = 0;
    }
    if (start + search.length > this.length) {
      return false;
    } else {
      return this.indexOf(search, start) !== -1;
    }
  };
}

当这段代码运行时,它会检查 String 原型是否已经有了 includes() 方法。如果没有,它会给它分配一个执行相同工作的函数,因此从那时起,你将能够使用 includes() 而无需进一步担心。顺便说一句,还有其他定义 polyfill 的方法:查看 问题 3.7 的答案以获取替代方案。另一个解决方案是 core-js 包 (github.com/zloirock/core-js),它为 ECMAScript 提供了直到最新版本的 polyfill,甚至包括尚未进入语言的某些提案。

好或不好?

直接修改标准类型的原型对象通常是不被推荐的,因为本质上,这相当于使用全局变量,因此容易出错;然而,在这种情况下(为已建立且已知的功能编写 polyfill)引发冲突的可能性非常小。

最后,如果你认为之前展示的 Ajax 示例已经过时了,那么考虑一下这个:如果你想使用更现代的 fetch() 调用服务的方式,你也会发现并不是所有现代浏览器都支持它(通过访问 caniuse.com/#search=fetch 来验证这一点),因此你可能需要使用 polyfill,比如 github.com/github/fetch 上的那个。研究一下代码,你会发现它使用了之前描述的相同方法来检查是否需要 polyfill 并创建它。

Stubbing

在这里,我们将探讨一个类似于使用 polyfill 的用例:一个函数根据环境执行不同的工作。这个想法是执行 stubbing,这是一个来自测试的概念,涉及用一个执行更简单工作的函数替换实际工作的函数。

Stubbing 通常与日志函数一起使用。你可能希望在开发时让应用程序执行详细的日志记录,但在生产时则不希望有任何输出。一个常见的解决方案可能是编写如下内容:

let myLog = (someText) => {
  if (DEVELOPMENT) {
    console.log(someText); // or some other way of logging
  } else {
    // do nothing
  }
};

这方法是可行的,但就像 Ajax 检测的例子一样,它做了比所需更多的工作,因为它每次都会检查应用程序是否处于开发状态。

如果我们取消日志函数的记录功能,使其不记录任何内容,我们可以简化代码(并且获得一个非常非常小的性能提升!)!一个简单的实现方法如下:

let myLog;
if (DEVELOPMENT) {
  myLog = (someText: string) => console.log(someText);
} else {
  myLog = (someText: string) => {};
}

我们可以使用三元运算符做得更好:

const myLog = DEVELOPMENT
  ? (someText: string) => console.log(someText)
  : (someText: string) => {};

这有点晦涩,但我更喜欢它,因为它使用了 const,它不能被修改。

还有一种可能性:你可以像这样修改原始方法:

if (DEVELOPMENT) {
  // do nothing, let things be
} else {
  console.log = (someText: string) => {};
}

在这种情况下,我们直接改变了 console.log() 的工作方式,因此它不会记录任何内容。

无用的参数 - 忽略还是排除?

由于 JavaScript 允许我们用比参数更多的参数调用函数,并且在我们不在开发阶段时,我们并没有在 myLog() 中做任何事情,我们也可以写成 () => {} 并正常工作。然而,我更喜欢保持相同的签名,这就是为什么我指定了 someText 参数,即使它不会被使用。但是,如果你使用 ESLint 的 no-unused-vars 规则来检测未使用的变量,你可能需要调整其配置以允许未使用的参数。

你会注意到我们反复使用函数作为一等对象的概念;浏览所有代码示例,你就会看到这一点!

立即调用(IIFE)

函数的另一种常见用法,通常在流行的库和框架中看到,它可以让您从其他语言中带来一些模块化优势到 JavaScript(即使是较旧的版本!)!通常的写法如下:

(function () {
  // do something...
})();

或者,你可能还会发现 (function(){ ... }()) – 注意函数调用中括号的放置方式不同。这两种风格都有其支持者;选择适合你的,并保持一致。

你还可以向函数传递一些参数,这些参数将用作其参数的初始值:

(function (a, b) {
  // do something, using the
  // received arguments for a and b...
})(some, values);

最后,你也可以从函数中返回一些内容——通常是对象(带有多个方法)或函数:

let x = (function (a, b) {
  // ...return an object or function
})(some, values);

注意函数周围的括号。这有助于解析器理解我们正在编写一个表达式。如果你省略第一组括号,JavaScript 会认为你正在编写一个函数声明而不是调用。括号还起到视觉提示的作用,因此你的代码的读者会立即识别出 IIFE。

如前所述,这种模式被称为 IIFE(发音为 iffy)。这个名字很容易理解:你定义一个函数并立即调用它,以便它立即执行。你为什么要这样做,而不是简单地内联编写代码呢?原因与作用域有关。

如果你定义了任何变量或函数在 IIFE 中,那么由于 JavaScript 定义函数的作用域,这些定义将是内部的,并且你的代码的其他部分将无法访问它们。想象一下,你想要编写一些复杂的初始化,如下所示:

function ready() { ... }
function set() { ... }
function go() { ... }
// initialize things calling ready(),
// set(), and go() appropriately

会出什么问题?问题在于你可能会(意外地)有一个与这三个中的任何一个具有相同名称的函数,提升会导致最后一个函数被调用:

function ready() {
  console.log("ready");
}
function set() {
  console.log("set");
}
function go() {
  console.log("go");
}
ready();
set();
go();
function set() {
  console.log("UNEXPECTED...");
}
// "ready"
// "UNEXPECTED"
// "go"

哎呀!如果你使用了 IIFE,这个问题就不会发生。(使用 ESLint 的 no-func-assign 规则也会防止这种情况。)此外,这三个内部函数甚至对代码的其他部分不可见,这有助于保持全局命名空间不太混乱。以下代码展示了这种广泛使用的模式:

(function () {
  function ready() {
    console.log("ready");
  }
  function set() {
    console.log("set");
  }
  function go() {
    console.log("go");
  }
  ready();
  set();
  go();
})();
function set() {
  console.log("UNEXPECTED...");
}
// "ready"
// "set"
// "go"

要查看涉及返回值的示例,我们可以回顾第一章 成为函数式中的示例,并编写以下代码,这将创建一个单独的计数器:

const myCounter = (function () {
  let count = 0;
  return function () {
    count++;
    return count;
  };
})();

然后,每次调用 myCounter() 都会返回一个增加的计数,但没有任何其他部分的代码有机会覆盖内部的 count 变量,因为它只可以在返回的函数中访问。

摘要

在本章中,我们介绍了在 JavaScript 中定义函数的几种方法,主要关注箭头函数,它们在简洁性方面具有几个优点,优于标准函数。我们学习了柯里化(我们稍后会再次讨论)的概念,考虑了函数作为一等对象的一些方面,并回顾了几种实际上是完全函数式编程概念的技术。请放心,我们将使用本章中的所有内容作为本书其余部分更高级技术的基石;只需等待并观察!

第四章 行为规范中,我们将更深入地探讨函数,并了解纯函数的概念,这将引导我们走向更好的编程风格。

问题

3.1 type 属性,用于确定你正在创建什么类型的操作。以下代码据说会产生一个操作,但你能否解释意外结果?

const simpleAction = (t:string) => {
  type: t;
};
console.log(simpleAction("INITIALIZE"));
// undefined

3.2 使用箭头函数而不是我们用 function 关键字的方式,从处理参数部分的 useArguments()useArguments2()

3.3 sum3()altsum3(),但我们没有对 fn1fn2fn3 做这样的事情。这些函数的类型是什么?

3.4 doAction2() 作为一行代码,尽管从格式上无法看出这一点!你认为这是正确的还是不正确的?

const doAction3 = (state = initialState, action) =>
  (dispatchTable[action.type] &&
    dispatchTableaction.type) ||
  state;

3.5 doAction()dispatchTabledoAction2()?务必描述所有需要的类型。

3.6 set() 方法。在创建新的存储对象后,他编写了以下代码,以便在处理之前记录 store.set() 的参数。不幸的是,代码没有按预期工作。问题是什么?你能找到错误吗?

window.store = new Store();
const oldSet = window.store.set;
window.store.set = (...data) => (
  console.log(...data), oldSet(...data)
);

3.7 bind() 不可用;你如何为它做 polyfill?

3.8 myNumbers.sort((a:number, b:number) => a-b) – 为什么/它是如何工作的?

3.9 负数排序:在之前的注入 – 排序它部分,我们看到了将数字作为字符串排序会产生意外的结果。如果数组中包含负数和正数,结果会是什么?

3.10 字典序排序:在排序,比如书籍标题或个人姓名时,会应用特殊的排序规则。例如,“THE SHINING”将被排序为“SHINING, THE”,而“Stephen King”将被排序为“King, Stephen”。你如何(高效地)实现这样的排序?

3.11 console.log() 方法没有正确的数据类型 – 例如,我们的版本仅允许单个参数。你能提供正确的数据类型定义吗?

第四章:正确行为——纯净函数

第三章,“函数入门”,我们考虑函数作为函数式编程FP)中的关键元素,详细介绍了箭头函数,并介绍了一些概念,例如注入、回调、polyfilling 和 stubbing。在本章中,我们将有机会回顾或应用这些想法。

在本章中,我们将做以下事情:

  • 考虑纯净性的概念以及为什么我们应该关注纯净函数——以及不纯净函数

  • 检查指称透明性的概念

  • 认识到副作用所暗示的问题

  • 展示纯净函数的一些优点

  • 描述不纯净函数背后的主要原因

  • 发现最小化不纯净函数数量的方法

  • 专注于测试纯净函数和不纯净函数的方法

纯净函数

纯净函数的行为与数学函数相同,并提供各种好处。一个函数是纯净的,如果它满足以下两个条件:

  • 给定相同的参数,函数总是计算并返回相同的结果:这应该在任何调用次数或调用条件下都成立。此结果不能依赖于任何外部信息或状态,这些信息或状态在程序执行过程中可能会改变,从而导致返回不同的值。同样,函数的结果也不能依赖于 I/O 结果、随机数、其他外部变量或无法直接控制的价值。

  • 在计算其结果时,函数不会引起任何可观察的副作用:这包括输出到 I/O 设备、对象的修改、函数外部程序状态的改变等。

你可以简单地这样说,纯净函数不依赖于(也不修改)它们作用域之外的东西,并且对于相同的输入参数总是返回相同的结果。

在这个上下文中使用的另一个词是幂等性,但它并不完全相同。一个幂等的函数可以被调用任意多次,并且总是产生相同的结果。然而,这并不意味着函数没有副作用。

幂等性通常在 RESTful 服务上下文中提到。让我们看看一个简单的例子,说明纯净性和幂等性的区别。一个PUT调用会导致数据库记录被更新(副作用),但如果你重复调用,元素将不会被进一步修改,因此数据库的全局状态不会进一步改变。

我们还可能引用一个软件设计原则,并提醒自己,一个函数应该只做一件事,只做那件事,而且只做那件事。如果一个函数做了其他事情并且有一些隐藏的功能,那么这种对状态的依赖意味着我们无法预测函数的输出,这将使我们的开发工作变得更加困难。

让我们更详细地研究这些条件。

指称透明性

在数学中,引用透明性是指你可以用一个表达式的值来替换它,而不会改变你正在做的任何操作的结果。引用透明性的对立面,恰如其分地,是引用不透明性。一个引用不透明的函数不能保证它在用相同的参数调用时总是产生相同的结果。

以一个简单的例子来说明,让我们考虑一个执行常量折叠的优化编译器会发生什么。假设你有一个这样的句子:

const x = 1 + 2 * 3;

编译器可能会通过注意到 2*3 是一个常量值来优化代码:

const x = 1 + 6;

更好的是,新一轮的优化可以完全避免求和:

const x = 7;

为了节省执行时间,编译器正在利用这样一个事实:所有数学表达式和函数(根据定义)都是引用透明的。

另一方面,如果编译器无法预测给定表达式的输出,它将无法以任何方式优化代码,计算将不得不在运行时完成。

(TypeScript 进行了类似的类型分析,并且根据原始的 const x = 1 + 2 * 3 行,它会正确地判断 x 的类型为 number。)

关于 lambda 和 beta

在 lambda 演算中,如果你用一个函数涉及的表达式的值替换为该函数的计算值,那么这个操作就称为β (beta) 折减。请注意,你只能安全地使用引用透明的函数来做这件事。

所有算术表达式(涉及数学运算符和函数)都是引用透明的:22*9 总是可以替换为 198。涉及 I/O 的表达式不透明,因为它们的执行结果在执行之前是无法知道的。同样地,涉及日期和时间相关函数或随机数的表达式也不透明。

关于你可以生成的 JavaScript 函数,编写一些不满足引用透明性条件的函数相当容易。实际上,函数甚至不需要返回一个值,尽管在这种情况下 JavaScript 解释器会返回 undefined

区分的问题

一些语言区分函数和过程,函数预期返回一个值,而过程不返回任何值,但 JavaScript 的情况并非如此。一些语言甚至提供了确保函数是引用透明的手段。

如果你愿意,你可以这样对函数进行分类:

  • 纯函数:这些函数返回的值仅取决于其参数,并且没有任何副作用。

  • undefined,但在这里这不相关)但确实会产生一些副作用。

  • 有副作用的函数:这意味着它们返回的值可能不仅取决于函数参数,还涉及副作用。

在函数式编程(FP)中,对第一组(引用透明的纯函数)给予了更多重视。编译器可以推理程序行为(从而能够优化生成的代码),程序员也可以更容易地推理程序及其组件之间的关系。这反过来可以帮助证明算法的正确性或通过用等效函数替换函数来优化代码。

副作用

什么是副作用?我们可以将这些定义为在执行某些计算或过程中的状态变化或与外部元素(用户、网络服务、另一台计算机——无论什么)的交互,这些交互发生在执行某些计算或过程中的某个时刻。

对于这个含义的范围,可能存在一些误解。在日常用语中,当你说到副作用时,就像谈论附带损害——对于某个动作的一些意外后果;然而,在计算机科学中,我们包括函数之外的所有可能的效果或变化。如果你编写一个函数,目的是执行一个console.log()调用以显示结果,那么这将被视为副作用,即使这最初正是你打算让函数执行的事情!

在本节中,我们将探讨以下内容:

  • JavaScript 编程中的常见副作用

  • 全局和内部状态引起的问题

  • 函数修改其参数的可能性

  • 一些总是麻烦的函数

常见的副作用

在编程中,有(太多!)被认为是副作用的事情。在 JavaScript 编程中,包括前端和后端编码,你可能会遇到以下更常见的情况:

  • 更改全局变量。

  • 修改作为参数接收的对象。

  • 执行任何 I/O 操作,例如显示一个警告消息或记录一些文本。

  • 与文件系统一起工作或更改文件系统。

  • 查询或更新数据库。

  • 调用一个网络服务。

  • 查询或修改 DOM。

  • 触发任何外部过程。

  • 只调用另一个产生自身副作用的函数。可以说,不纯性是具有传染性的:调用一个不纯函数的函数自动变得不纯!

根据这个定义,让我们开始探讨什么可以导致函数的不纯性(或引用不透明性)。

全局状态

在所有上述点中,最常见的副作用原因是使用非局部变量,这些变量与其他程序部分共享全局状态。由于纯函数,根据定义,给定相同的输入参数总是返回相同的输出值,如果一个函数引用了其内部状态之外的内容,它就自动变成了不纯的。此外——这对调试是一个障碍——要了解一个函数做了什么,你必须了解状态是如何获得当前值的,这意味着理解程序的所有历史:这并不容易!

让我们编写一个函数来检测一个人是否是合法的成年人,通过检查他们是否至少 18 年前出生。(好吧——这不够精确,因为我们没有考虑出生的日和月,但请耐心等待;问题在其他地方。)一个isOldEnough()函数的版本可能如下所示:

// isOldEnough.ts
const limitYear = 2004; // only good for 2022!
const isOldEnough = (birthYear: number) =>
  birthYear <= limitYear;
console.log(isOldEnough(1960)); // true
console.log(isOldEnough(2010)); // false

isOldEnough()函数正确地检测一个人是否至少 18 岁,但它依赖于一个外部变量——一个只适用于 2022 年的变量!尽管函数可以工作,但实现并不是最好的。除非你知道关于外部变量及其值的信息,否则你无法知道函数的功能。测试也很困难;你必须记得创建全局的limitYear变量,否则所有测试都会失败。

这条规则有一个例外。看看以下情况:以下circleArea()函数,它根据圆的半径计算圆的面积,是纯函数还是非纯函数?

// area.ts
const PI = 3.14159265358979;
const circleArea = (r: number) => PI * r ** 2;

即使函数正在访问外部状态,由于PI是一个常数(因此不能被修改),我们可以将其在circleArea函数内部替换,而不会对函数的功能产生影响,因此我们应该接受这个函数是纯函数。该函数对于相同的参数总是返回相同的值,从而满足我们的纯函数要求。

如果你使用Math.PI而不是我们在代码中定义的常数(顺便说一句,这是一个更好的主意),由于常数不能被更改,所以函数将保持纯函数状态。

在这里,我们处理了由全局状态引起的问题;让我们继续讨论内部状态。

内部状态

这个概念也扩展到内部变量,其中存储并使用局部状态以供未来的调用。外部状态保持不变,但内部副作用意味着函数将返回的结果在未来会有所不同。让我们想象一个roundFix()舍入函数,它会考虑是否已经向上或向下舍入过多,以便下次它会向另一方向舍入,使累积差异更接近零。我们的函数将不得不累积先前舍入的效果,以决定如何进行下一步。实现可能如下所示:

// roundFix.ts
const roundFix = (function () {
  let accum = 0;
  return (n: number): number => {
    // reals get rounded up or down
    // depending on the sign of accum
    const nRounded =
      accum > 0 ? Math.ceil(n) : Math.floor(n);
    console.log(
      "accum",
      accum.toFixed(5),
      " result",
      nRounded
    );
    accum += n - nRounded;
    return nRounded;
  };
})();

关于此函数的一些注释:

  • console.log()调用只是为了这个示例;它不会包含在现实世界的函数中。它列出了到该点的累积差异以及它将返回的结果:向上或向下舍入的给定数字。

  • 我们正在使用来自立即执行函数表达式(IIFE)模式的myCounter()示例,该模式位于第三章**,从函数开始部分,以获取隐藏的内部变量。

  • nRounded的计算也可以写成Mathaccum > 0 ? "ceil": "floor"——我们测试accum以确定调用哪个方法("ceil""floor"),然后使用Object["method"]的表示法间接调用Object.method()。我认为我们使用的方法更清晰,但我只是想提前提醒你,以防你偶然发现这种其他的编码风格。

只用两个值运行这个函数(认出它们了吗?)表明对于给定的输入,结果并不总是相同的。控制台日志的result部分显示了值是如何四舍五入的,向上或向下:

roundFix(3.14159); // accum  0.00000  result 3
roundFix(2.71828); // accum  0.14159  result 3
roundFix(2.71828); // accum -0.14013  result 2
roundFix(3.14159); // accum  0.57815  result 4
roundFix(2.71828); // accum -0.28026  result 2
roundFix(2.71828); // accum  0.43802  result 3
roundFix(2.71828); // accum  0.15630  result 3

第一次,accum为零,所以 3.14159 被向下舍入,accum变为 0.14159。第二次,由于accum是正的(这意味着我们已经是在有利于我们的方向上舍入),2.71828 被向上舍入到 3,现在accum变为负数。第三次,相同的 2.71828 值被向下舍入到 2,因为累积的差异是负的——对于相同的输入我们得到了不同的值!其余的例子类似;你可以根据累积的差异得到向上或向下舍入的相同值,因为函数的结果取决于其内部状态。

为什么不是面向对象编程(OOP)?

正是因为这种内部状态的使用,许多函数式编程(FP)程序员认为使用对象可能是有缺陷的。在面向对象编程(OOP)中,我们开发者习惯于存储信息(属性)并用于未来的计算;然而,这种使用被认为是不纯的,因为重复的方法调用可能会返回不同的值,尽管传递了相同的参数。

我们现在已经处理了由全局和内部状态引起的问题,但还有更多的可能副作用。例如,如果一个函数改变了其参数的值会发生什么?让我们考虑这一点。

参数突变

你还需要意识到一个不纯的函数可能会修改其参数的可能性。在 JavaScript 中,参数是通过值传递的,除了数组和对象,它们是通过引用传递的。这意味着对函数参数的任何修改都将影响原始对象或数组的实际修改。这一点可能被几个Math.max()(无需进一步说明)的简短实现所进一步掩盖。一个简短的实现可能如下所示:

// maxStrings.ts
const maxStrings = (a: string[]) => a.sort().pop();
const countries = [
  "Argentina",
  "Uruguay",
  "Brasil",
  "Paraguay",
];
console.log(maxStrings(countries)); // "Uruguay"

函数确实提供了正确的结果(如果你担心外语,我们已经在第三章**,开始使用函数)的注入 – 解决问题部分中看到了一种解决方案,但它有一个缺陷。让我们看看原始数组发生了什么:

console.log(countries);
// ["Argentina", "Brasil", "Paraguay"]

哎呀——原始数组被修改了;这是定义上的副作用!(如果我们只为maxStrings()编写一个完整的类型定义,TypeScript 本可以帮助检测这个错误;有关详细信息,请参阅问题 4.2。)如果你再次调用maxStrings(countries),那么它将不会返回之前的结果,而是产生另一个值;显然,这不是一个纯函数。

在这种情况下,一个快速的解决方案是在数组的副本上工作,我们可以使用扩展运算符来帮助。尽管如此,我们将在第十章 确保纯度中讨论更多避免这类问题的方法。

// continued...
const maxStrings2 = (a: string[]) => [...a].sort().pop();
let countries = [
  "Argentina",
  "Uruguay",
  "Brasil",
  "Paraguay",
];
console.log(maxStrings2(countries));
// "Uruguay"
console.log(countries);
// ["Argentina", "Uruguay", "Brasil", "Paraguay"]

因此,我们现在又找到了另一个副作用的原因:修改其参数的函数。需要考虑的最后一个情况是必须不纯的函数!

麻烦的函数

最后,一些函数也会引起问题。例如,Math.random()是不纯的:它并不总是返回相同的值,如果它这样做的话,就会违背其目的!此外,每次调用该函数都会修改一个全局种子值,下一个随机值将从该值计算出来。

并非真正的随机

随机数实际上是由一个内部函数计算出来的,这使得它们根本不是随机的;伪随机可能更适合它们的名称。如果你知道了使用的公式和种子初始值,你就能以完全非随机的方式预测接下来的数字。

例如,考虑以下从"A"到"Z"生成随机字母的函数:

// random.ts
const getRandomLetter = (): string => {
  const min = "A".charCodeAt(0);
  const max = "Z".charCodeAt(0);
  return String.fromCharCode(
    Math.floor(Math.random() * (1 + max - min)) + min
  );
};

事实上,它不接受任何参数,但在每次调用时都期望产生不同的结果,这清楚地表明这个函数是不纯的。

随机解释

前往developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random了解我们的getRandomLetter()函数的解释,以及developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/charCodeAt了解.charCodeAt()方法。

不纯性可以通过调用函数来继承。如果一个函数使用了不纯的函数,它立即变得不纯。我们可能想使用getRandomLetter()来生成带有可选扩展名的随机文件名;那么我们的getRandomFileName()函数可以如下所示:

// continued...
const getRandomFileName = (fileExtension = ""): string => {
  const NAME_LENGTH = 12;
  const namePart = new Array(NAME_LENGTH);
  for (let i = 0; i < NAME_LENGTH; i++) {
    namePart[i] = getRandomLetter();
  }
  return namePart.join("") + fileExtension;
};

由于使用了getRandomLetter()getRandomFileName()也是不纯的,尽管它按预期执行,正确地生成了完全随机的文件名:

getRandomFileName(".pdf"); // "SVHSSKHXPQKG.pdf"
getRandomFileName(".pdf"); // "DCHKTMNWFHYZ.pdf"
getRandomFileName(".pdf"); // "GBTEFTVVHADO.pdf"
getRandomFileName(".pdf"); // "ATCBVUOSXLXW.pdf"
getRandomFileName(".pdf"); // "OIFADZKKNVAH.pdf"

记住这个函数;我们将在本章后面讨论一些绕过单元测试问题的方法,并且我们会稍作修改以帮助解决这个问题。

对不纯的担忧也扩展到访问当前时间或日期的函数,因为它们的结果将取决于一个外部条件(即一天中的时间),这是应用程序的全局状态的一部分。我们可以重写我们的isOldEnough()函数以消除对全局变量的依赖,但这不会有多大帮助。一个尝试如下:

// isOldEnough.js
const isOldEnough2 = (birthYear: number): boolean =>
  birthYear <= new Date().getFullYear() - 18;
console.log(isOldEnough2(1960)); // true
console.log(isOldEnough2(2010)); // false

一个问题已经被解决——新的isOldEnough2()函数现在更安全。此外,只要你不在大年夜午夜前后使用它,它将始终返回相同的结果,所以可以说——借用 19 世纪象牙肥皂的广告语——它是“大约 99.44%纯”;然而,一个不便之处仍然存在:你如何测试它?如果你编写了一些今天可以正常工作的测试,它们明年就会开始失败。我们得花点时间解决这个问题,稍后我们会看到。

几个也是不纯的函数包括那些引起输入/输出的函数。如果一个函数从某个来源(一个网络服务、用户本人、一个文件或其它来源)获取输入,那么结果显然可能会变化。你还应该考虑 I/O 错误的可能性,因此,同一个函数,调用相同的服务或读取相同的文件,可能在某个时刻因为其控制范围之外的原因而失败(你应该假设你的文件系统、数据库、套接字等可能不可用,因此某个特定的函数调用可能会产生错误,而不是预期的恒定、不变的答案)。

即使有一个纯输出和通常安全的声明(如console.log()),它内部没有改变任何东西(至少在可见的方式上),也会引起一些副作用,因为用户确实会看到变化:即产生的输出。

这是否意味着我们永远无法编写一个需要随机数、处理日期、执行 I/O 以及使用纯函数的程序?绝对不是——但这确实意味着某些函数不会是纯函数,它们将有一些我们必须考虑的缺点;我们稍后会回到这个问题。

纯函数的优势

使用纯函数的主要优势是它们没有任何副作用。当你调用一个纯函数时,你不需要担心任何其他事情,除了你传递给它的参数。更重要的是,你可以确信你不会引起任何问题或破坏其他东西,因为函数只会处理你给它的一切,而不会与外部来源交互。但这并不是它们的唯一优势。让我们在接下来的章节中了解更多。

执行顺序

另一种看待我们在这章中所说内容的方法是将纯函数视为健壮的。你知道它们的执行——无论顺序如何——永远不会影响系统。这个想法可以进一步扩展:你可以并行评估纯函数,确保结果不会与单线程执行的结果不同。(JavaScript 不提供 Java-like 线程,但我们可以用 workers 来做到这一点。我们将在第五章声明式编程)中讨论这个话题。)

当你与纯函数一起工作时,需要记住的另一个考虑因素是,没有必要明确指定它们应该调用的顺序。如果你从事数学工作,表达式如 f(2)+f(5) 总是与 f(5)+f(2) 相同;这被称为交换律

然而,当你处理不纯函数时,这可能是不正确的,如下面故意写出的棘手函数所示:

// tricky.ts
let mult = 1;
const f = (x: number): number => {
  mult = -mult;
  return x * mult;
};
console.log(f(2) + f(5)); //  3
console.log(f(5) + f(2)); // -3

对于像前面那样的不纯函数,你不能假设计算 f(3)+f(3) 会产生与 2*f(3) 相同的结果,或者 f(4)-f(4) 实际上是零;你自己检查一下... 常见的数学属性,都流失了!

你为什么要关心这个?在编写代码时,无论是有意还是无意,你总是牢记你学到的那些属性,比如交换律。所以,虽然你可能认为这两个表达式应该产生相同的结果,并相应地编写代码,但当你使用不纯函数时,可能会遇到难以找到的难以修复的错误。

缓存

由于纯函数对于给定输入的输出始终相同,你可以缓存函数结果并避免可能昂贵的重新计算。这个过程,意味着只评估表达式一次并将结果缓存以供后续调用,被称为缓存

我们将在第六章生成函数中回到这个想法,但让我们先看看一个手工完成的例子。斐波那契序列总是用于这个例子,因为它简单且隐藏的计算成本。这个序列被定义为如下:

  • 对于 n=0,fib(n)=0

  • 对于 n=1,fib(n)=1

  • 对于 n>1,fib(n)=fib(n-2)+fib(n-1)

斐波那契是谁?

斐波那契的名字实际上来自 filius Bonaccison of Bonacci。他最著名的是引入了我们今天所知道的 0-9 位数的使用,而不是繁琐的罗马数字。他推导出以他的名字命名的序列作为解决涉及兔子的谜题的答案!你可以在 en.wikipedia.org/wiki/Fibonacci_number#History 或plus.maths.org/content/life-and-numbers-fibonacci上了解更多关于它和斐波那契生平的信息。

如果你运行这些数字,序列从 0 开始,然后是 1,从那时起,每个项都是前两个项的和:1 再次,然后是 2,3,5,8,13,21,34,55,等等。使用递归编程这个序列很简单;我们将在第九章设计函数中重新审视这个例子。以下代码是定义的直接翻译,将执行——参见问题 4.4以获取另一种方法:

// fibonacci.ts
const fib = (n: number): number => {
  if (n == 0) {
    return 0;
  } else if (n == 1) {
    return 1;
  } else {
    return fib(n - 2) + fib(n - 1);
  }
};
console.log(fib(10)); // 55, a bit slowly

如果你尝试使用递增的n值测试这个函数,你很快就会意识到存在问题,计算开始花费太多时间。例如,我在我的机器上进行了计时(以毫秒为单位),并在图表上绘制了它们。由于函数速度相当快,我不得不为 0 到 40 之间的n值运行 100 次计算。即便如此,小n值的计时仍然非常小;只有从 25 开始,我才得到了有趣的数据。

图表(见图 4.1)显示了指数增长,这预示着不祥:

图 4.1 – fib()递归函数的计算时间呈指数增长

图 4.1 – fib()递归函数的计算时间呈指数增长

如果你绘制出计算fib(6)所需的所有调用的图表,你会注意到问题:

图 4.2 – 计算 fib(6) 所需的计算显示了大量的重复

图 4.2 – 计算 fib(6) 所需的计算显示了大量的重复

每个节点代表对计算fib(n)的调用。我们在节点中记录n的值。除了n=0 或n=1 的调用外,每个调用都需要进一步的调用,如图图 4.2所示。

增加延迟的原因变得明显:例如,fib(2)的计算在四次不同的情况下被重复,而fib(3)本身被计算了三次。鉴于我们的函数是纯函数,我们本可以将计算值存储起来以避免反复运行数字。一个可能的版本,使用用于先前计算值的缓存数组,如下所示:

// continued...
const cache: number[] = [];
const fib2 = (n: number): number => {
  if (cache[n] === undefined) {
    if (n === 0) {
      cache[0] = 0;
    } else if (n === 1) {
      cache[1] = 1;
    } else {
      cache[n] = fib2(n - 2) + fib2(n - 1);
    }
  }
  return cache[n];
};
console.log(fib2(10)); // 55, as before, but more quickly!

初始时,cache数组为空。每次我们需要计算fib2(n)的值时,我们会检查它是否之前已经计算过。如果不是,我们就进行计算,但有一个转折:不是立即返回值,而是首先将其存储在缓存中,然后返回。这意味着不会进行两次计算:一旦我们为特定的n值计算了fib2(n),未来的调用将不会重复该过程,而是返回之前评估的结果。

几点简要说明:

  • 我们手动备忘录化了函数,但我们可以使用高阶函数来完成。我们将在第六章生产函数中看到这一点。完全有可能备忘录化一个函数,而无需更改或重写它。

  • 使用全局cache变量并不是一个好的做法;我们本可以使用 IIFE 和闭包来隐藏cache,你看懂了吗?(也请参阅本章末尾的问题 4.3。)第三章**,从函数开始入门,中的myCounter()示例在立即执行部分展示了如何做到这一点。

  • 当然,你将受到可用缓存空间的限制,并且有可能最终通过消耗所有可用的 RAM 来崩溃你的应用程序。求助于外部内存(数据库、文件或云解决方案)可能会吞噬缓存的所有性能优势。有一些标准解决方案(涉及最终从缓存中删除项目),但这些超出了本书的范围。

当然,你不需要为程序中的每个纯函数都这样做。你只会对那些频繁调用且占用大量时间的函数进行此类优化;否则,增加的缓存管理时间可能会超过你预期的节省!

自我文档化

纯函数还有另一个优势。由于函数需要处理的所有内容都是通过其参数提供的,没有任何隐藏的依赖,当你阅读其源代码时,你就有所有你需要了解其目标所需的信息。

一个额外的优势:知道一个函数不会访问其参数之外的内容,这让你在使用它时更有信心,因为你不会意外地产生副作用;函数唯一能完成的事情就是通过其文档你已经了解的内容。

单元测试(我们将在下一节中介绍)也充当文档的作用,因为它们提供了函数在给定某些参数时返回的示例。大多数程序员都会同意,最好的文档是充满示例的,每个单元测试都可以被视为这样一个案例。

测试

纯函数的另一个优势——也是最重要的优势之一——与单元测试有关。纯函数只有一个职责:根据其输入产生输出。因此,当你为纯函数编写测试时,你的工作会大大简化,因为你不需要考虑任何上下文,也不需要模拟任何状态。

你可以专注于提供输入和检查输出,因为所有函数调用都可以在隔离状态下重现,独立于世界上的其他部分。

我们已经看到了纯函数的几个方面。让我们继续前进,了解一些不纯函数,最后测试纯函数和不纯函数。

不纯函数

如果你决定完全放弃所有副作用,那么你的程序就只能处理硬编码的输入,并且无法显示计算结果!同样,大多数网页将变得毫无用处:你将无法进行网络服务调用或更新 DOM;你将只有静态页面。而且你的 Node 代码对于服务器端工作将毫无用处,因为它无法执行任何 I/O 操作。

在函数式编程(FP)中,减少副作用是一个良好的目标,但我们不应该过分追求!因此,让我们思考如何尽可能避免使用不纯函数,如果无法避免,则如何处理它们,寻找最佳方法来限制或缩小它们的范围。

避免使用不纯函数

在本章的早期,我们看到了使用不纯函数的更常见原因。现在,让我们考虑如何减少不纯函数的数量,即使完全消除它们并不现实。基本上,我们将有两种方法:

  • 避免使用状态

  • 使用编程模式注入来控制不纯性

避免使用状态

关于全局状态的使用——获取和设置——解决方案是众所周知的。以下是关键点:

  • 将全局状态所需的部分作为参数提供给函数

  • 如果函数需要更新状态,它不应该直接进行,而应该生成一个新的状态版本并返回它

  • 如果有返回的状态,更新全局状态应该是调用者的责任

这是 Redux 用于其 reducers 的技术。(我们在第一章什么是函数式编程不是部分和第三章函数作为对象部分中看到了这一点,分别是成为函数式程序员从函数开始。)reducer 的签名是(previousState, action) => newState,这意味着它接受状态和动作作为参数,并返回一个新的状态作为结果。最具体地说,reducer 不应该改变previousState参数,它必须保持不变(我们将在第十章确保纯净性中了解更多关于这一点)。

对于我们的第一个isOldEnough()函数版本,它使用了一个全局的limitYear变量,更改很简单:我们必须将limitYear作为参数提供给函数。通过这个更改,它将变得纯净,因为它将只通过使用其参数来生成结果。

更好的做法是提供当前年份,并让函数自己进行计算,而不是强迫调用者这样做。那么我们更新的成人年龄测试版本可以如下所示:

// isOldEnough.ts
const isOldEnough3 = (
  birthYear: number,
  currentYear: number
): boolean => birthYear <= currentYear - 18;

显然,我们必须更改所有调用以提供所需的currentYear参数(我们也可以使用偏应用,正如我们将在第七章转换函数)中看到的)。currentYear的值责任仍然在函数外部,就像之前一样,但我们已经设法避免了缺陷:

console.log(isOldEnough3(1960, 2022)); // true
console.log(isOldEnough3(2010, 2022)); // false

我们还可以将此解决方案应用于我们特有的roundFix()函数。如您所回忆的,该函数通过累积舍入引起的差异并决定根据该累积器的符号向上还是向下舍入来工作。我们无法避免使用该状态,但我们可以将舍入部分从累积部分分离出来。我们的原始代码(注释较少,没有日志记录,并使用箭头函数)如下所示:

// roundFix.ts
const roundFix1 = (() => {
  let accum = 0;
  return (n: number): number => {
    const nRounded =
      accum > 0 ? Math.ceil(n) : Math.floor(n);
    accum += n - nRounded;
    return nRounded;
  };
})();

新版本(有关更多信息,请参阅问题 4.6)将有两个参数:

// continued...
const roundFix2 = (accum: number, n: number) => {
  const nRounded = accum > 0 ? Math.ceil(n) : Math.floor(n);
  accum += n - nRounded;
  return { accum, nRounded };
};

你会如何使用这个函数?初始化累积器,将其传递给函数,并在之后更新它现在是调用者代码的责任。你会有以下类似的内容:

let accum = 0;
// ...some other code...
let { a, r } = roundFix2(accum, 3.1415);
accum = a;
console.log(accum, r); // 0.1415 3

注意以下内容:

  • accum值现在是应用程序的全局状态的一部分

  • 由于roundFix2()需要它,其值在每次调用中提供

  • 调用者负责更新全局状态,而不是roundFix2()

再次扩展

注意使用解构赋值来允许一个函数返回多个值,并轻松地将每个值存储在不同的变量中。有关更多信息,请参阅developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment。有关替代方案,请参阅问题 4.7

这个新的roundFix2()函数是完全纯的,可以轻松测试。如果你想将累积器从应用程序的其余部分隐藏起来,你仍然可以使用闭包,就像我们在其他示例中看到的那样,但这又会再次在你的代码中引入不纯性——你的调用!

注入不纯函数

如果一个函数因为需要调用另一个自身不纯的函数而变得不纯,解决这个问题的方法是在调用中注入所需的函数。这种技术为你的代码提供了更多的灵活性,并允许更容易的未来更改和更简单的单元测试。

让我们考虑我们之前看到的随机文件名生成函数。这个函数的问题部分在于它使用getRandomLetter()来生成文件名:

// random.ts
const getRandomFileNae = (fileExtension = ""): string => {
  … 
    namePart[i] = getRandomLetter();
  …

};

解决这个问题的方法是将不纯函数替换为一个注入的外部函数;我们现在必须为我们的随机文件名函数提供一个randomLetterFunc()参数来使用:

// continued...
const getRandomFileName2 = (
  fileExtension = "",
  randomLetterFunc: () => string
): string => {
  const NAME_LENGTH = 12;
  const namePart = new Array(NAME_LENGTH);
  for (let i = 0; i < NAME_LENGTH; i++) {
    namePart[i] = randomLetterFunc();
  }
  return namePart.join("") + fileExtension;
};

现在,我们已经从这个函数中移除了固有的不纯性。如果我们想提供一个预定义的伪随机函数,该函数返回固定的、已知的值,我们可以轻松地对这个函数进行单元测试;我们将在下面的例子中看到。函数的使用将改变,我们可能需要编写以下内容:

let fn = getRandomFileName2(".pdf", getRandomLetter);

如果这让你烦恼,你可能想为randomLetterFunc参数提供一个默认值,如下所示:

// continued...
const getRandomFileName3 = (
  fileExtension = "",
  randomLetterFunc: () => string = getRandomLetter
): string => {
  const NAME_LENGTH = 12;
  const namePart = new Array(NAME_LENGTH);
  for (let i = 0; i < NAME_LENGTH; i++) {
    namePart[i] = randomLetterFunc();
  }
  return namePart.join("") + fileExtension;
};

你也可以使用偏应用来解决这个问题,我们将在第七章**,转换函数*中看到。

这实际上并没有避免使用不纯函数。通常,你会通过提供一个我们编写的随机字母生成器来调用getRandomFileName(),因此它表现得像一个不纯函数。然而,如果你出于测试目的提供一个返回预定义(即,非随机)字母的函数,你将能够更容易地像纯函数一样测试它。

但原始问题函数getRandomLetter()怎么办?我们可以应用同样的技巧并编写一个新的版本,如下所示,它将有一个产生随机数的参数:

// random.ts
const getRandomLetter2 = (
  getRandomNum: () => number = Math.random.bind(Math)
): string => {
  const min = "A".charCodeAt(0);
  const max = "Z".charCodeAt(0);
  return String.fromCharCode(
    Math.floor(getRandomNum() * (1 + max - min)) + min
  );
};

我们应该将getRandomFileName3()修改为调用getRandomLetter2()。如果它不提供任何参数就调用它,getRandomLetter2()将以预期的随机方式运行。但如果我们想测试getRandomFileName3()是否按预期工作,我们可以通过注入一个返回我们决定的内容的函数来运行它,这样我们就可以彻底测试它。

绑定所有

对于getRandomNum的默认值,我们编写了Math.random.bind(Math),如前一章的与方法一起工作部分所述。另一种(对某些人来说可能更清晰)的替代方法是() => Math.random();我们将在本章后面的你的函数是纯的吗?部分使用它,只是为了多样化。

让我们用一个更复杂的案例来结束这一节:一个具有多个不纯性的函数会发生什么?例如,我们可能正在处理一个后端calculateDebt()服务,该服务根据人的id计算债务。为此,我们可以访问数据库,获取该人的发票列表,然后调用一些服务以获取每张发票的欠款金额;这些金额的总和就是计算出的债务。这样一个函数的骨架可能如下——我使用纯 JavaScript 来省略不必要的细节:

// calculateDebt.js
const calculateDebt = async (id) => {
  // access a database to get a list of invoices
  const listOfInvoices =
    await mySqlConn.query(/* SQL query to get invoices */);
  // call a remote service to learn what's owed for each
  const owedAmounts =
    await axios.get(/* API call to get owed amounts */);
  const calculatedDebt = owedAmounts.reduce(
    (x, y) => x + y,
    0
  );
  return calculatedDebt;
};

(如果你不熟悉calculatedDebt = wedAmounts.reduce(…),请参阅第五章**,声明式编程中的求和数组*部分。)

我们不能轻易地测试这样的函数,因为它依赖于数据库和另一个服务的可用性。为了净化它,我们需要注入两个函数:一个用于从数据库获取数据,另一个用于查询服务。净化后的函数将变成这样:

// continued...
const calculateDebt2 = async (
  id,
  { getInvoices, getOwedAmounts } =
    { getInvoicesFromDb, getOwedAmountFromAPI }
) => {
  const listOfInvoices = await getInvoices(id);
  const owedAmounts = await getOwedAmounts(listOfInvoices);
  const calculatedDebt = owedAmounts.reduce(
    (x, y) => x + y,
    0
  );
  return calculatedDebt;
};

在这段代码中,getInvoicesFromDb()getOwedAmountFromAPI()将是执行数据库访问和 API 调用的函数。我们的calculateDebt2()函数现在不知道(或不需要知道)如何访问和操作数据库或其他服务的细节;这是一个更好的软件设计。

现在,这个函数有两个参数:id(如前所述)和一个可选的对象,包含要注入的两个函数。在常规使用中,我们不会提供第二个参数,函数会根据需要访问数据库并调用 API。但这里是关键点:为了测试目的,我们注入一个包含两个模拟函数的对象,然后能够编写简单的测试。(如果你想知道为什么我们注入了一个对象,请参阅问题 4.8。)

一个额外的细节:在实际世界中彻底测试函数通常很难实现。例如,如何模拟断开连接或服务调用失败?使用注入,这不成问题;我们可以轻松提供一个返回错误值、抛出异常以及完成你需要的任何其他测试的模拟。

使用注入来避免杂质非常重要,并且对于其他问题有着广泛的应用。例如,我们不是让一个函数直接访问 DOM,而是可以给它提供一些注入的函数来完成这项工作。在测试目的下,简单地验证被测试的函数是否完成了它需要做的事情,而不需要真正与 DOM 交互(当然,我们得找到另一种方法来测试那些与 DOM 相关的函数)。这同样适用于需要更新 DOM、生成新元素以及进行各种操作的函数——你使用一些中间函数。我们甚至会在第十一章**,实现设计模式中应用注入,以推导出更好的系统架构,因此它是一个强大且关键的概念。

你的函数是纯函数吗?

让我们通过考虑一个重要问题来结束本节:你能确保一个函数真正是纯函数吗?为了展示这个任务的困难,我们将回到我们在第一章成为函数式开发者散列部分看到的简单sum3()函数,只是为了简洁而重写为使用箭头函数。你会说这个函数是纯函数吗?它看起来确实像是!

// sum3.ts (in chapter 3)
const sum3 = (x: number, y: number, z: number): number =>
  x + y + z;

让我们看看:这个函数没有访问任何东西,除了它的参数,它甚至没有尝试修改它们(当然,它不能(或它能吗?)),它没有执行任何 I/O,也没有与任何我们之前提到的非纯函数或方法一起工作。可能出错的地方在哪里?

答案与检查你的假设有关。例如,谁说这个函数的参数应该是数字?在纯 JavaScript 中,我们可以用字符串调用它,但现在我们使用 TypeScript,它应该检查这一点,对吧?即使向函数传递字符串,你也可能会问自己:好吧,它们可以是字符串,但函数仍然会是纯的,不是吗? 对于这个(肯定邪恶!)问题的答案,请看以下代码:

// sum3.trick.ts
const x = {} as number;
x.valueOf = () => Math.random();
const y = 1;
const z = 2;
console.log(sum3(x, y, z)); // 3.2034400919849431
console.log(sum3(x, y, z)); // 3.8537045249277906
console.log(sum3(x, y, z)); // 3.0833258308458734

恶劣的编码!

我们将一个新函数分配给x.valueOf()方法,使对象看起来像是一个数字。当我们说x = {} as number时,我们也撒了谎;否则,TypeScript 会反对你传递了一个期望数字的对象。

好吧,sum3()应该是纯函数,但实际上这取决于你传递给它的参数;你可以让一个纯函数表现得像不纯函数!你可能会自我安慰地想,肯定没有人会传递这样的参数,但边缘情况通常是错误所在。但你不必放弃纯函数的想法。正如我们所见,你甚至可以欺骗 TypeScript 接受错误的数据类型,所以你永远不能完全确定你的代码总是纯的!

在这些章节中,我们已经探讨了纯函数和不纯函数的特点。让我们通过看看如何测试这类函数来结束这一章。

测试 – 纯函数与不纯函数

我们已经看到纯函数在概念上比不纯函数更好,但我们不能发动一场十字军东征,从我们的代码中消除所有的不纯性。首先,没有人能否认副作用可能是有用的,或者至少是不可避免的:你需要与 DOM 交互或调用网络服务,而这两种方式都没有纯方法可以做到。因此,与其哀叹不得不允许不纯性,不如尝试结构化你的代码,将不纯函数隔离开来,让其余的代码尽可能做到最好。

考虑到这一点,你必须能够为所有类型的函数编写单元测试,无论是纯函数还是不纯函数。编写单元测试在难度和复杂性方面对纯函数和不纯函数是不同的。对于前者,编写测试代码通常相当简单,遵循一个基本模式,而对于后者,通常需要搭建脚手架和复杂的设置。因此,让我们通过看看如何测试这两种类型的函数来结束这一章。

测试纯函数

根据我们已描述的纯函数的特点,你大部分的单元测试可能是以下这样的:

  • 使用给定的参数集调用函数

  • 验证结果是否符合预期

让我们从几个简单的例子开始。测试isOldEnough()函数比我们需要的版本更复杂,因为那个版本需要访问全局变量。另一方面,最后一个版本isOldEnough3(),因为它接收了两个参数,所以不需要任何东西,测试起来很简单:

// isOldEnough.test.ts
describe("isOldEnough", function () {
  it("is false for people younger than 18", () => {
    expect(isOldEnough3(2010, 2022)).toBe(false);
  });
  it("is true for people older than 18", () => {
    expect(isOldEnough3(1960, 2022)).toBe(true);
  });
  it("is true for people exactly 18", () => {
    expect(isOldEnough3(2004, 2022)).toBe(true);
  });
});

测试我们编写的另一个纯函数同样简单,但我们必须小心,因为考虑到精度。如果我们测试circleArea函数,我们必须使用 Jest 的toBeCloseTo()匹配器,它允许在处理浮点数时进行近似相等。有关 JavaScript 中的数学的更多信息,请参阅问题 4.9)。除了这一点,测试几乎相同——用已知的参数调用函数并检查预期的结果:

// area.test.ts
describe("circle area", function () {
  it("is zero for radius 0", () => {
    const area = circleArea(0);
    expect(area).toBe(0);
  });
  it("is PI for radius 1", () => {
    expect(circleArea(1)).toBeCloseTo(Math.PI);
  });
  it("is approximately 12.5664 for radius 2", () =>
    expect(circleArea(2)).toBeCloseTo(12.5664));
});

完全没有难度!(我故意用不同的风格写了三个测试,只是为了多样化。)测试运行报告显示两个测试套件都成功(见图 4.3):

图 4.3 – 一对纯函数的成功测试运行

图 4.3 – 一对纯函数的成功测试运行

我们不必担心纯函数;让我们继续处理我们通过转换为纯等价物来处理的非纯函数。

测试净化函数

当我们考虑以下roundFix()特殊函数时,它要求我们使用状态来累积舍入差异,我们通过提供当前状态作为附加参数并让函数返回两个值——舍入后的值和更新的状态来生成一个新的版本:

// roundFix.ts
const roundFix2 = (accum: number, n: number) => {
  const nRounded = accum > 0 ? Math.ceil(n) :
    Math.floor(n);
  accum += n - nRounded;
  return { accum, nRounded };
};

这个函数现在是纯函数,但测试它不仅需要验证返回的值,还需要验证更新的状态。我们可以基于之前所做的实验来构建我们的测试。再次强调,我们必须使用toBeCloseTo()来处理浮点数(有关更多内容,请参阅问题 4.10),但对于整数,我们可以使用toBe(),这不会产生舍入错误。我们可以这样编写我们的测试:

// roundFix.test.ts
describe("roundFix2", function () {
  it("rounds 3.14159->3 if differences are 0", () => {
    const { accum, nRounded } = roundFix2(0.0, 3.14159);
    expect(accum).toBeCloseTo(0.14159);
    expect(nRounded).toBe(3);
  });
  it("rounds 2.71828->3 if differences are 0.14159", () => {
    const { accum, nRounded } = roundFix2(0.14159,
      2.71828);
    expect(accum).toBeCloseTo(-0.14013);
    expect(nRounded).toBe(3);
  });
  it("rounds 2.71828->2 if differences are -0.14013", () => {
    const { accum, nRounded } = roundFix2(
      -0.14013,
      2.71828
    );
    expect(accum).toBeCloseTo(0.57815);
    expect(nRounded).toBe(2);
  });
  it("rounds 3.14159->4 if differences are 0.57815", () => {
    const { accum, nRounded } = roundFix2(0.57815,
      3.14159);
    expect(accum).toBeCloseTo(-0.28026);
    expect(nRounded).toBe(4);
  });
});

我们包括了几个案例,其中累积差异为正、零或负,并检查了它们在每种情况下是向上还是向下舍入。我们当然可以通过对负数进行舍入来进一步深入,但这个想法很明确:如果你的函数将当前状态作为参数并更新它,与纯函数的测试相比,唯一的区别是你还必须测试返回的状态是否符合你的预期。

现在让我们考虑一种测试我们的净化getRandomLetter2()函数的替代方法。这很简单:你必须提供一个生成随机数的函数。(在测试术语中,这种函数被称为存根。)存根的复杂性没有限制,但你希望保持它简单。

根据我们对函数工作原理的了解,我们可以进行一些测试来验证低值产生"A"输出,而接近 1 的值产生"Z"输出,这样我们就可以对没有产生额外值有一定的信心。我们还应该测试中间值(大约 0.5)应该生成字母表中中间位置的字母。然而,这种测试并不很好——如果我们用另一种方式实现了getRandomLetter2(),它可能工作得很好,但不会通过这个测试!我们的测试可以写成如下:

// random.test.ts
describe("getRandomLetter2", function () {
  it("returns A for values close to 0", () => {
    const letterSmall = getRandomLetter2(() => 0.0001);
    expect(letterSmall).toBe("A");
  });
  it("returns Z for values close to 1", () => {
    const letterBig = getRandomLetter2(() => 0.99999);
    expect(letterBig).toBe("Z");
  });
  it("returns middle letter for values around 0.5", () => {
    const letterMiddle = getRandomLetter2(() =>
      0.49384712);
    expect(letterMiddle > "G").toBeTruthy();
    expect(letterMiddle < "S").toBeTruthy();
  });
  it("returns ascending letters for ascending #s", () => {
    const letter1 = getRandomLetter2(() => 0.09);
    const letter2 = getRandomLetter2(() => 0.22);
    const letter3 = getRandomLetter2(() => 0.60);
    expect(letter1 < letter2).toBeTruthy();
    expect(letter2 < letter3).toBeTruthy();
  });
});

通过使用存根来测试我们的文件名生成器可以类似地进行,我们可以提供一个简单的存根f(),它将按顺序返回"SORTOFRANDOM"中的字母(这个函数相当不纯;你能看出为什么吗?)。因此,我们可以验证返回的文件名与预期的名称匹配,以及返回的文件名的几个其他属性,例如其长度和扩展名。我们的测试可以写成如下所示:

// continued...
describe("getRandomFileName3", function () {
  let a: string[] = [];
  const f = () => a.shift() as string;
  beforeEach(() => {
    a = "SORTOFRANDOM".split("");
  });
  it("uses the given letters for the file name", () => {
    const fileName = getRandomFileName3("", f);
    expect(fileName.startsWith("SORTOFRANDOM")).toBe(true);
  });
  it("includes right extension, has right length", () => {
    const fileName = getRandomFileName3(".pdf", f);
    expect(fileName.endsWith(".pdf")).toBe(true);
    expect(fileName.length).toBe(16);
  });
});

测试纯化不纯函数与测试原始纯函数相同。现在,我们需要考虑一些真正不纯函数的情况,因为正如我们所说,在某个时候,你肯定会需要使用这样的函数。

测试不纯函数

首先,我们将回到原始的getRandomLetter()函数。通过对其实现的内幕知识(这被称为Math.random()方法)设置一个模拟函数,该函数将返回我们想要的任何值。

我们可以回顾一下上一节中我们讨论的一些测试用例。在第一种情况下,我们将Math.random()设置为返回 0.0001(并测试它是否确实被调用),我们还检查最终返回的是"A"。在第二种情况下,为了多样化,我们设置了一个场景,使得Math.random()将被调用两次,返回两个不同的值。我们还验证了两个结果都是"Z"。我们回顾的测试可能如下所示:

// continued...
describe("getRandomLetter", function () {
  afterEach(() => {
    // so count of calls to Math.random will be OK
    jest.restoreAllMocks();
  });
  it("returns A for values ~ 0", () => {
    jest.spyOn(Math, "random").mockReturnValue(0.00001);
    const letterSmall = getRandomLetter();
    expect(Math.random).toHaveBeenCalled();
    expect(letterSmall).toBe("A");
  });
  it("returns Z for values ~ 1", () => {
    jest
      .spyOn(Math, "random")
      .mockReturnValueOnce(0.988)
      .mockReturnValueOnce(0.999);
    const letterBig1 = getRandomLetter();
    const letterBig2 = getRandomLetter();
    expect(Math.random).toHaveBeenCalledTimes(2);
    expect(letterBig1).toBe("Z");
    expect(letterBig2).toBe("Z");
  });
  it("returns middle letter for values ~ 0.5", () => {
    jest.spyOn(Math, "random").mockReturnValue(0.49384712);
    const letterMiddle = getRandomLetter();
    expect(Math.random).toHaveBeenCalledTimes(1);
    expect(letterMiddle > "G").toBeTruthy();
    expect(letterMiddle < "S").toBeTruthy();
  });
});

(当然,你不会随意发明任何出现在你脑海中的测试。很可能会根据你开始编码或测试之前编写的预期getRandomLetter()函数的描述进行工作。在我们的情况下,我假设该规范存在,并且明确指出——例如——接近 0 的值应该产生"A"输出,接近 1 的值应该返回"Z",并且函数应该对递增的随机值返回递增的字母。)

现在,你将如何测试原始的getRandomFileName()函数,即调用不纯的getRandomLetter()函数的那个函数?这是一个更复杂的问题。

你有什么样的期望?你无法知道它将给出的结果,因此你将无法编写任何.toBe()类型的测试。你可以做的是测试预期结果的某些属性,如果你的函数暗示了某种随机性,你可以重复测试任意多次,这样你就有更大的机会捕捉到错误。我们可以进行一些类似于以下代码的测试:

// continued...
describe("getRandomFileName+impure getRandomLetter", () => {
  it("generates 12 letter long names", () => {
    for (let i = 0; i < 100; i++) {
      expect(getRandomFileName().length).toBe(12);
    }
  });
  it("generates names with letters A to Z, only", () => {
    for (let i = 0; i < 100; i++) {
      const name = getRandomFileName();
      for (let j = 0; j < name.length; j++) {
        expect(name[j] >= "A" && name[j] <=
          "Z").toBe(true);
      }
    }
  });
  it("includes right extension if provided", () => {
    const fileName1 = getRandomFileName(".pdf");
    expect(fileName1.length).toBe(16);
    expect(fileName1.endsWith(".pdf")).toBe(true);
  });
  it("doesn't include extension if not provided", () => {
    const fileName2 = getRandomFileName();
    expect(fileName2.length).toBe(12);
    expect(fileName2.includes(".")).toBe(false);
  });
});

我们没有将任何随机字母生成函数传递给getFileName(),因此它将使用原始的不纯函数。我们运行了一些测试一百次,作为额外的保险。我们的测试检查以下内容:

  • 文件名长度为 12 个字母

  • 名称只包含字母“A”到“Z

  • 文件名包含提供的扩展名

  • 如果没有提供扩展名,则不包含任何扩展名

需要证据

在测试代码时,始终记住没有证据并不意味着没有证据。即使我们的重复测试成功,也不能保证它们不会在某个其他随机输入上产生意外、以前未检测到的错误。

让我们再进行另一个属性测试。假设我们想要测试一个洗牌算法;我们可能会决定按照以下代码实现 Fisher–Yates 版本。(关于此算法的更多信息——包括对粗心大意的程序员的陷阱——请参阅en.wikipedia.org/wiki/Fisher-Yates_shuffle。)按照实现,该算法是双重的非纯:它并不总是产生相同的结果(显然!)并且它修改了它的输入参数:

// shuffle.test.ts
const shuffle = <T>(arr: T[]): T[] => {
  const len = arr.length;
  for (let i = 0; i < len - 1; i++) {
    let r = Math.floor(Math.random() * (len - i));
    [arr[i], arr[i + r]] = [arr[i + r], arr[i]];
  }
  return arr;
};
const xxx = [11, 22, 33, 44, 55, 66, 77, 88];
console.log(shuffle(xxx));
// [55, 77, 88, 44, 33, 11, 66, 22]

你如何测试这个算法?鉴于结果是不可预测的,我们可以检查其输出的属性。我们可以用一个已知的数组调用它,然后测试一些属性——但请参阅问题 4.13以了解一个重要细节:

// continued...
describe("shuffleTest", function () {
  it("shouldn't change the array length", () => {
    const a = [22, 9, 60, 12, 4, 56];
    shuffle(a);
    expect(a.length).toBe(6);
  });
  it("shouldn't change the values", () => {
    const a = [22, 9, 60, 12, 4, 56];
    shuffle(a);
    expect(a.includes(22)).toBe(true);
    expect(a.includes(9)).toBe(true);
    expect(a.includes(60)).toBe(true);
    expect(a.includes(12)).toBe(true);
    expect(a.includes(4)).toBe(true);
    expect(a.includes(56)).toBe(true);
  });
});

我们不得不以这种方式编写单元测试的第二部分,因为正如我们所看到的,shuffle()会修改输入参数。对于不同(且不好!)的洗牌函数的测试,请参阅问题 4.14

摘要

在本章中,我们介绍了纯函数的概念,并研究了它们为什么重要。我们还看到了由副作用(不纯函数的一个原因)引起的问题——查看一些净化这种不纯函数的方法,最后,我们看到了对纯函数和不纯函数进行单元测试的几种方法。有了这些技术,你将能够在编程中优先使用纯函数,当需要不纯函数时,你将有一些方法可以以受控的方式使用它们。

第五章 声明式编程中,我们将展示函数式编程的其他优点:如何在更高层次上以声明式方式编程,以获得更直接和健壮的代码。

问题

4.1 必须返回吗?一个简单、几乎哲学性的问题:纯函数是否总是必须返回一些东西?你能有一个不返回任何内容的纯函数吗?

4.2 maxStrings()

const maxStrings = (a: string[]): string => a.sort().pop();

4.3 为优化后的fib2()函数创建cache数组。

4.4 最小化函数:函数式程序员有时会以最小化方式编写代码。你能检查以下版本的斐波那契函数,并解释它是否工作,如果是的话,是如何工作的吗?

// fibonacci.ts
const fib3 = (n: number): number =>
  n < 2 ? n : fib2(n - 2) + fib2(n - 1);

4.5 手动计算fib4(6)并与书中早些时候给出的示例进行比较:

// fibonacci.ts
const fib4 = (n: number, a = 0, b = 1): number =>
  n === 0 ? a : fib4(n - 1, b, a + b);

4.6 roundFix2()函数?即使 TypeScript 可以自己解决这个问题(如本例所示),我也更喜欢明确地写出它以进行额外的检查。

4.7 将roundFix2()函数修改为返回一个元组而不是一个记录。这个重写的函数的输入可以是两个单独的参数或一个单一的元组参数。

4.8 一个还是两个注入?为什么用两个函数而不是两个单独的函数注入一个对象更好?换句话说,为什么不写如下内容?

const calculateDebt2 = async (
  id,
  getInvoices = getInvoicesFromDb,
  getOwedAmounts = getOwedAmountFromAPI
) => … ;

4.9 toBeCloseTo()由于精度问题。一个相关的问题,经常在面试中问到,是以下代码将输出什么,为什么?

const a = 0.1;
const b = 0.2;
const c = 0.3;
if (a + b === c) {
  console.log("Math works!");
} else {
  console.log("Math failure?");
}

4.10 toBeCloseTo()函数实用但可能引起问题。一些基本的数学属性如下:

  • 一个数字应该等于它自己:对于所有数字aa应该等于a

  • 如果a等于b,那么b应该等于a

  • 如果a等于b,且b等于c,那么a应该等于c

  • 如果a等于b,且c等于d,那么a+c应该等于b+da-c应该等于b-dac应该等于bd,和a/c应该等于b/d

toBeCloseTo()是否满足所有这些属性?

4.11 <T>shuffle()的定义中?

4.12 shuffle()在原地修改输入数组(副作用!)我们实际上不需要最后的return arr行,可以将其删除。那么shuffle()的类型定义会是什么?

4.13. shuffle()测试它是否正确地与具有重复值的数组一起工作?我们编写的测试只对具有不同值的数组有效;你能看出为什么吗?

4.14 流行但错误!许多在线文章建议以下代码作为洗牌的方法。想法是对数组进行排序,但,而不是使用正确的比较函数随机返回正或负值,这些随机比较应该使数组处于混乱状态。然而,这个想法是错误的,算法也是糟糕的,因为它没有以相等的概率产生所有可能的输出。你如何检查这一点?

const poorShuffle = (arr) =>
  arr.sort(() => Math.random() - 0.5);

4.15 通过排序进行洗牌:排序和洗牌可以看作是相反的操作;一个带来秩序,另一个产生混乱。然而,有一种方法可以通过排序来洗牌;你能想出来吗?(不,答案不是前一个问题中展示的糟糕算法!)我们正在寻找一个算法,它可以以相同的概率产生所有可能的输出,而不是偏向某些输出。

第五章:声明式编程 – 更好的风格

到目前为止,我们还没有真正能够欣赏到函数式编程FP)在以高级、声明式方式工作时的可能性。在本章中,我们将纠正这一点,并通过使用一些高阶函数HOFs)——即接受函数作为参数的函数,例如以下内容——来生成更短、更简洁、更易于理解的代码:

  • reduce()reduceRight() 将操作应用于整个数组,将其缩减为单个结果

  • map() 通过对每个元素应用函数将一个数组转换成另一个数组

  • flat() 将数组中的数组转换成一个单一的数组

  • flatMap() 将映射和扁平化混合在一起

  • forEach() 通过抽象必要的循环代码来简化循环的编写

我们还将能够使用以下方法进行搜索和选择:

  • filter() 从数组中挑选一些元素

  • find()findIndex() 用于搜索满足条件的元素

  • 一对断言函数,every()some(),用于检查数组中的布尔测试

使用这些函数将使你能够以更声明式的方式工作,你会发现你的焦点将转移到你需要做什么,而不是那么关注它是如何完成的;脏细节都隐藏在我们的函数内部。我们不会编写一系列可能嵌套的循环,而是将重点放在使用函数作为构建块来指定我们期望的结果。

我们将使用这些函数以声明式方式处理事件,正如我们将在第十一章中看到的,实现设计模式,当我们使用观察者模式时。我们还将能够以流畅的方式工作,其中函数的输出成为下一个函数的输入,这种风格我们将在稍后探讨。

转换

我们将要考虑的第一组操作是在数组上进行的,它基于一个函数来处理并产生某些结果。有几种可能的结果:使用 reduce() 操作的单个值,使用 map() 的新数组,或者使用 forEach() 的几乎所有类型的结果。

关注效率

如果你四处搜索,你将找到一些文章宣称这些函数效率低下,因为手动编写的循环可能更快。虽然这可能正确,但实际上并不重要。除非你的代码真的存在速度问题,并且你可以确定这种缓慢是由于使用这些 HOFs 而导致的,否则试图通过更长的代码来避免它们,这样做更有可能引入错误,这并没有太多意义。

让我们从考虑前面列出的函数列表开始,从最一般的一个开始,正如我们将看到的,它甚至可以用来模拟本章中其余的转换!

将数组缩减为值

回答这个问题:你有多少次需要遍历一个数组,执行一个操作(比如求和)来得到一个单一的结果(可能是所有数组值的总和)?可能很多很多次。这种操作通常可以通过应用 reduce()reduceRight() 函数以函数式的方式实现。让我们从前者开始!

要折叠还是不折叠

是时候引入一些术语了!在通常的函数式编程(FP)用语中,我们说 reduce(),相应地,reduceRight() 被称为 foldr。在范畴论术语中,这两个操作都是 猫形态学:将容器中所有值缩减为一个单一结果。

reduce() 函数的内部工作原理在 图 5**.1 中展示:

图 5.1 – reduce() 操作的工作原理

图 5.1 – reduce() 操作的工作原理

看看 reduce() 如何遍历数组,将一个累加函数应用于每个元素和累积值。

为什么你应该总是尝试使用 reduce()reduceRight() 而不是手写的循环?以下要点可能回答了这个问题:

  • 循环控制的各个方面都自动处理,因此你甚至没有出现一个错误的可能性

  • 初始化和处理结果值也是隐式完成的

  • 除非你非常努力地使代码不纯并修改原始数组,否则你的代码将没有副作用

现在我们能够使用 reduce() 来处理数组,让我们看看它的实际应用案例。

求和数组

reduce() 的最常见应用示例,通常在所有教科书中和所有网页上都可以看到,就是计算数组中所有元素的总和。所以,为了保持传统,让我们从精确的这个例子开始!

要缩减一个数组,你必须提供一个 二元 函数(具有两个参数的函数;“二进制”是这个的另一个名称)和一个初始值。在我们的例子中,这个函数将累加其两个参数。最初,这个函数将应用于提供的初始值和数组的第一个元素。对我们来说,要提供的初始值是零,第一个结果将是数组的第一个元素本身。然后,这个函数将再次应用,这次是应用于前一个操作的结果和数组的第二个元素,因此第二个结果将是数组前两个元素的和。以这种方式沿着整个数组进行,最终结果将是所有元素的总和:

// sum.ts
const myArray = [22, 9, 60, 12, 4, 56];
const sum = (x: number, y: number): number => x + y;
const mySum = myArray.reduce(sum, 0); // 163

你实际上不需要求和的定义——你只需写下 myArray.reduce((x,y) => x+y, 0) 即可——然而,当以这种方式编写时,代码的含义更清晰:你想要通过 sum-ming 所有元素来将数组缩减为一个单一值。(我们会忘记数据类型吗?不会的;TypeScript 可以自己推断所有隐含的类型。)

你不必编写循环,初始化一个变量来保存计算结果,然后遍历数组进行求和,你只需声明应该执行的操作。这就是我说使用我们将在本章中看到的函数进行编程时,允许你更声明性地工作,专注于“是什么”而不是“如何”的意思。

你甚至可以在不提供初始值的情况下使用 reduce():如果你跳过了它,数组的第一项将被使用,内部循环将从数组的第二项开始;然而,如果你跳过了提供初始值,并且数组为空,那么你将得到一个运行时错误!有关更多详细信息,请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce

我们可以通过加入一点杂质来改变减少函数,以查看它是如何进行计算的。

// continued...
const sumAndLog = (x: number, y: number): number => {
  console.log(`${x}+${y}=${x + y}`);
  return x + y;
};
myArray.reduce(sumAndLog, 0);

输出将如下所示:

0+22=22
22+9=31
31+60=91
91+12=103
103+4=107
107+56=163

你可以看到第一个求和是通过将初始值(0)和数组的第一个元素相加来完成的,然后这个结果被用于第二个加法,依此类推。

名称中有什么?

之前看到的 foldl 名称的部分原因(至少,它的结尾,l)现在应该很清楚了:减少操作是从左到右进行的,从第一个元素到最后一个元素。然而,你可能想知道,如果它是由从右到左的语言(如阿拉伯语、希伯来语、波斯语或乌尔都语)的说话者定义的,它会被如何命名!

这个例子很常见,也很知名;让我们来做点更复杂的事情。正如我们将发现的,reduce() 将对许多不同的目标非常有用!

计算平均值

让我们再做一些工作。你如何计算一列数字的平均值?如果你在向某人解释这个,你的答案肯定会是类似于 将列表中的所有元素相加,然后除以元素的数量 这样的话。在编程术语中,这并不是一个过程性描述(你不会解释如何累加元素或遍历数组),而是一个声明性描述,因为你只是说明了要做什么,而不是如何做。

我们可以将计算描述转换为几乎无需解释的函数(在下一章的 数组求平均值 部分,我们将扩展数组以包括基于此代码的平均值方法):

// average.ts
const myArray = [22, 9, 60, 12, 4, 56];
const sum = (x: number, y: number): number => x + y;
const average = (arr: number[]): number =>
  arr.reduce(sum, 0) / arr.length;
console.log(average(myArray)); // 27.166667

average() 的定义遵循口头解释:从 0 开始累加数组的元素,然后除以数组的长度——更简单地说:不可能!

不太安全的减少

正如我们在上一节中提到的,你也可以不指定累减的初始值(0)来编写arr.reduce(sum);这甚至更短,更接近所需计算的口头描述。然而,这不太安全,因为如果数组为空,它将失败(产生运行时错误)。因此,最好始终提供起始值。

然而,这并不是计算平均值的唯一方法。累减函数还传递了数组的当前位置索引以及数组本身,因此你可以做与上次不同的事情:

// continued...
const sumOrDivide = (
  sum: number,
  val: number,
  ind: number,
  arr: number[]
) => {
  sum += val;
  return ind == arr.length - 1 ? sum / arr.length : sum;
};
const average2 = (arr: number[]): number =>
  arr.reduce(sumOrDivide, 0);
console.log(myArray.reduce(average2, 0)); // 27.166667

给定当前索引(以及显然,访问数组的长度),我们可以做一些小把戏:在这种情况下,我们的累减sumOrDivide()函数始终求和值,但在数组的末尾,它抛入一个除法,以便返回数组的平均值。这很酷,但从可读性的角度来看,我们可以同意我们最初看到的第一个版本更具有声明性,更接近数学定义,而不是这个第二个版本。

不纯性警告!

获取数组和索引意味着你也可以将函数转换为不纯的函数。避免这样做!任何看到reduce()调用的人都会自动假设它是一个纯函数,并且在使用它时肯定会引入错误。

这个例子和上一个例子需要计算单个结果,但有可能超越这一点,在单次遍历中计算多个值。让我们看看如何。

同时计算多个值

如果你需要计算两个或多个结果而不是单个值,这似乎是标准循环提供明确优势的情况,但你有一个可以使用的技巧。让我们再次回顾平均值的计算。我们可以通过循环和同时求和和计数所有数字来以传统方式完成它。嗯,reduce()只允许你产生一个结果,但你没有理由不能返回一个具有所需字段数量的对象,就像我们在第四章**,“不纯函数”*部分中做的那样:

// continued...
const average3 = (arr: number[]): number => {
  const sc = arr.reduce(
    (ac, val) => ({
      sum: val + ac.sum,
      count: ac.count + 1,
    }),
    { sum: 0, count: 0 }
  );
  return sc.sum / sc.count;
};
console.log(average3(myArray)); // 27.166667

仔细审查代码。我们需要两个变量:一个用于总和,一个用于所有数字的计数。我们提供一个对象作为累加器的初始值,其中两个属性设置为0,我们的累减函数更新这两个属性。在获得sumcount的最终结果后,我们进行除法以得到所需的平均值。

顺便说一句,除了使用对象之外,还有其他选择。你也可以产生任何其他数据结构;让我们通过一个元组的例子来看看。这种相似性非常明显:

// continued...
const average4 = (arr: number[]) => {
  const sc = arr.reduce(
    (ac, val) => [ac[0] + val, ac[1] + 1],
    [0, 0]
  );
  return sc[0] / sc[1];
};
console.log(average4(myArray)); // 27.166667

坦白说,我认为这比使用对象的方法更难以理解!请将此视为一种(并不特别推荐)同时计算多个值的方法!

我们现在已经看到了 reduce() 的几个使用示例,现在是时候来认识它的一个变体,reduceRight(),它的工作方式类似。

左右折叠

相补的 reduceRight() 方法与 reduce() 方法的工作方式相同,只是从数组的末尾开始,循环到数组的开头。(更多关于 reduceRight() 的信息请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/ReduceRight。)对于许多操作(例如我们之前看到的平均值计算),这并没有区别,但在某些情况下会有所不同。请参阅 图 5**.2

我们将在 第八章连接函数 中看到一个明显的例子;这里让我们用一个更简单的例子来探讨:

图 5.2 – reduceRight() 操作与 reduce() 操作相同,但顺序相反

图 5.2 – reduceRight() 操作与 reduce() 操作相同,但顺序相反

假设我们想要实现一个反转字符串的函数。(显然,我们也不知道 JavaScript 已经提供了 reverse() 方法!)一个解决方案可能是通过使用 split() 将字符串转换为数组,然后反转这个数组,最后使用 join() 使其再次成为整体:

// reverse.ts
const reverseString = (str: string): string => {
  const arr = str.split("");
  arr.reverse();
  return arr.join("");
};
console.log(reverseString("MONTEVIDEO"));  // OEDIVETNOM

这个解决方案是可行的(是的,它可以缩短,但这不是重点),但让我们用另一种方式来做,只是为了实验 reduceRight()

// continued...
const reverseString2 = (str: string): string =>
  str.split("").reduceRight((x, y) => x + y, "");
console.log(reverseString2("OEDIVETNOM")); // MONTEVIDEO

注意,我们不需要为减少函数指定数据类型;就像本章前面提到的那样,TypeScript 能够自行推断它们。此外,如果你喜欢重用代码,看看 问题 5.2

从前面的例子中,你还可以得到一个想法:如果你首先对一个数组应用 reverse(),然后使用 reduce(),效果将等同于直接对原始数组应用 reduceRight()。唯一需要注意的是:reverse() 会改变给定的数组,所以如果你反转原始数组,你将会造成一个意外的副作用!唯一的解决办法是首先生成数组的副本,然后再进行其他操作。这太麻烦了,所以最好使用 reduceRight()

然而,我们还可以得出另一个结论,展示一个我们之前预测的结果:虽然比较繁琐,但使用 reduce() 可以模拟出与 reduceRight() 相同的结果——在后面的章节中,我们也会用它来模拟本章中的其他函数。现在让我们继续探讨另一个常见且强大的操作:映射

应用操作 – map()

在计算机编程中,处理元素列表并对每个元素应用某种操作是一种相当常见的模式。编写系统性地遍历数组或集合中所有元素的循环,从第一个开始循环,直到最后一个结束,并对每个元素执行某种操作是一种基本的编码练习,通常在所有编程课程的第一天就会学到。我们已经在上一节中看到了这种类型的一种操作,即 reduce()reduceRight();现在让我们转向一个新的操作,称为 map()

在数学中,map() 函数将输入数组转换为输出数组。

名字,名字,名字……

一些更多的术语:我们会说数组是一个 函子,因为它提供了一种具有一些预定义属性的映射操作,我们将在后面看到。在范畴论中,我们将在 第十二章 构建更好的容器 中简要讨论,映射操作本身被称为 态射

map() 操作的内部工作原理可以在 图 5**.3 中看到:

图 5.3 – map() 操作通过应用映射函数转换输入数组的每个元素

图 5.3 – map() 操作通过应用映射函数转换输入数组的每个元素

可用更多地图

jQuery 库提供了一个函数,$.map(array, callback),它与 map() 方法类似。但请注意,它们之间有一些重要的区别。jQuery 函数处理数组的未定义值,而 map() 则跳过它们。此外,如果应用函数的结果是一个数组,jQuery 会将其扁平化并分别添加其各个元素,而 map() 只会将这些数组包含在结果中。Underscore 和 Ramda 也提供了类似的功能。最后,JavaScript 本身也提供了一种执行 map() 的替代方法:查看 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from 中的 Array.from() 方法,并特别注意其第二个参数!

使用 map() 而不是直接使用循环的优势是什么?

  • 第一,你不需要编写任何循环,这样就少了一个可能的错误来源

  • 第二,你甚至不需要访问原始数组或索引位置,尽管它们都在那里供你使用,如果你真的需要的话

  • 最后,会生成一个新的数组,所以你的代码是纯的(尽管,当然,如果你真的想产生副作用,你也可以!)

在这样做时只有两个注意事项:

  • 在映射函数中始终返回一些内容。如果你忘记了这一点,那么你将只会产生一个填充了 undefined 值的数组,因为 JavaScript 总是为所有函数提供一个默认的 return undefined

  • 如果输入数组的元素是对象或数组,并且你将它们包含在输出数组中,那么 JavaScript 仍然允许访问原始元素。

此外,还有一个限制。在 JavaScript 中,map() 主要只适用于数组(你可以在 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map 了解更多);然而,在 第十二章扩展当前数据类型 部分,构建更好的容器 中,我们将学习如何使其对其他基本类型也有效,例如数字、布尔值、字符串,甚至是函数。此外,像 Lodash、Underscore 和 Ramda 这样的库也提供了类似的功能。

正如我们之前使用 reduce() 一样,现在让我们看看 map() 在常见过程中的使用示例,以便你更好地欣赏其强大和便捷之处。

从对象中提取数据

让我们从简单的例子开始。假设我们有一些与一些南美国家和它们首都的坐标(纬度和经度)相关的地理数据(如下面的片段所示)。假设我们想计算这些城市的平均位置。(不,我不知道我们为什么要这样做。)我们该如何着手呢?

// average.ts
const markers = [
  { name: "AR", lat: -34.6, lon: -58.4 },
  { name: "BO", lat: -16.5, lon: -68.1 },
  { name: "BR", lat: -15.8, lon: -47.9 },
  { name: "CL", lat: -33.4, lon: -70.7 },
  { name: "CO", lat:   4.6, lon: -74.0 },
  { name: "EC", lat:  -0.3, lon: -78.6 },
  { name: "PE", lat: -12.0, lon: -77.0 },
  { name: "PY", lat: -25.2, lon: -57.5 },
  { name: "UY", lat: -34.9, lon: -56.2 },
  { name: "VE", lat:  10.5, lon: -66.9 },
];

很多负面情绪?

如果你想知道所有数据是否都是负数,如果是的话,原因是什么,那是因为这里显示的国家都位于赤道以南和格林威治子午线以西。然而,一些南美国家,如哥伦比亚和委内瑞拉,具有正纬度。当我们学习 some()every() 方法时,我们稍后会回到这些数据。

我们希望使用我们的 average() 函数(我们在本章早期开发了这个函数),但有一个问题:该函数只能应用于 数字 数组,而我们这里有一个 对象 数组。然而,我们可以玩一个花招:我们可以专注于计算平均纬度(我们可以在类似的方式中稍后处理经度)。我们可以将数组的每个元素映射到其纬度,然后我们将有 average() 函数的适当输入。解决方案可能如下所示:

// continued...
const averageLat = average(markers.map((x) => x.lat));
const averageLon = average(markers.map((x) => x.lon));
console.log(averageLat, averageLon); // -15.76, -65.53

将数组映射以提取数据非常强大,但你必须小心。现在让我们看看一个看似正确但产生错误结果的情况!

隐式解析数字

使用 map() 通常比手动循环更安全、更简单,但一些边缘情况可能会让你陷入困境。比如说,你收到了一个表示数值的字符串数组,并想将它们解析成实际的数字。你能解释以下结果吗?

["123.45", "67.8", "90"].map(parseFloat);
// [123.45, 67.8, 90]
["123.45", "-67.8", "90"].map(parseInt);
// [123, NaN, NaN]

让我们分析一下结果。当我们使用 parseFloat() 获取浮点数结果时,一切正常;然而,当我们想用 parseInt() 将结果截断为整数时,输出真的非常糟糕,出现了奇怪的 NaN 值。发生了什么?

答案在于一个隐式编程的问题。(我们已经在第三章不必要的错误部分看到了一些隐式编程的用法,以及第八章连接函数中我们将看到更多,但我们现在来看看以下代码,它将引导我们找到解决方案:)

["123.45", "-67.8", "90"].map((x) => parseFloat(x));
// [123.45, -67.8, 90]
["123.45", "-67.8", "90"].map((x) => parseInt(x));
// [123, -67, 90]

parseInt()出现意外行为的原因是,此函数还可以接收第二个参数——即转换字符串为数字时要使用的基数。例如,调用parseInt("100010100001", 2)将把二进制数100010100001转换为十进制。

注意:

你可以在developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/parseInt上了解更多关于parseInt()的信息,其中详细解释了基数参数。你应该始终提供它,因为某些浏览器可能会将带前导零的字符串解释为八进制,这又将产生不希望的结果。

那么,当我们向map()提供parseInt()时会发生什么?记住,map()会使用三个参数调用你的映射函数:数组元素的值、它的索引和数组本身。当parseInt接收到这些值时,它会忽略数组,并假设提供的索引实际上是基数,由于原始字符串在给定的基数中不是有效的数字,因此会产生NaN值。

好的,一些函数在映射时可能会误导你,现在你知道该寻找什么了。让我们通过使用范围来编写通常需要手动循环的代码,继续提升我们的工作方式。

与范围一起工作

让我们现在转向一个辅助函数,它将在许多用途中很有用。我们想要一个range(start,stop)函数,它生成一个数字数组,其值从start(包含)到stop(不包含):

// range.ts
const range = (start: number, stop: number): number[] =>
  new Array(stop - start).fill(0).map((v, i) => start + i);
range(2, 7); // [2, 3, 4, 5, 6]

为什么使用fill(0)?在map()中,未定义的数组元素会被跳过,因此我们需要用某些东西填充它们,否则我们的代码将没有效果。

扩展你的范围

像 Underscore 和 Lodash 这样的库提供了我们range()函数的一个更强大的版本,允许我们以升序或降序进行,还可以指定要使用的步长——例如_.range(0, -8, -2),它产生[0, -2, -4, -6]——但就我们的需求而言,我们编写的版本已经足够了。请参阅本章末尾的问题部分。

我们如何使用它?在接下来的部分,我们将看到使用forEach()进行受控循环的一些用法,但我们可以通过应用range()然后reduce()来重新实现我们的阶乘函数。这个想法是生成从 1 到n的所有数字,然后将它们相乘:

// continued...
const factorialByRange = (n: number): number =>
  range(1, n + 1).reduce((x, y) => x * y, 1);

检查边界情况很重要,但该函数对零也有效;你能看出为什么吗?原因在于生成的范围是空的:调用是 range(1,1),它返回一个空数组。然后,reduce() 不进行任何计算,并返回初始值(1),这是正确的。

第七章变换函数 中,我们将有机会使用 range() 生成源代码;查看 使用 eval() 进行柯里化使用 eval() 进行部分应用 部分。

你可以使用这些数字范围来生成其他类型的范围。例如,如果你需要一个包含字母表的数组,你当然可以(但很繁琐)写出 ["A", "B", "C"... up to ..."X", "Y", "Z"]。一个更简单的解决方案是使用字母表的 ASCII 码生成范围,并将这些映射到字母:

// continued...
const ALPHABET = range(
  "A".charCodeAt(0),
  "Z".charCodeAt(0) + 1
).map((x) => String.fromCharCode(x));
// ["A", "B", "C", ... "X", "Y", "Z"]

注意使用 charCodeAt() 获取字母的 ASCII 码,以及使用 String.fromCharCode(x) 将 ASCII 码转换为字符。

映射非常重要且经常使用,所以现在让我们分析你如何自己实现它,这可能会帮助你为更复杂的情况编写代码。

使用 reduce() 模拟 map()

在本章的早期,我们看到了如何使用 reduce() 实现 reduceRight()。现在,让我们看看 reduce() 如何也用于提供 map() 的 polyfill(虽然现在浏览器提供了这两种方法,但这将给你更多关于你可以用这些工具实现什么的想法)。

我们自己的 myMap() 是一行代码,但可能难以理解。我们将函数应用于数组的每个元素,并使用 concat() 将结果追加到结果数组(最初为空)。当循环完成对输入数组的处理时,结果数组将包含所需的输出值。在讨论数据类型之前,我们先看看一个普通的 JavaScript 版本:

// map.js
const myMap = (arr, fn) =>
  arr.reduce((x, y) => x.concat(fn(y)), []);

我们将映射函数应用于每个数组元素,一个接一个,并将结果连接到累积的输出数组。

让我们用一个数组和一个简单的函数来测试这个。我们将使用原始的 map() 方法以及 myMap(),结果应该匹配!我们的映射函数将返回其输入的两倍:

// continued...
const dup = (x: number): number => 2 * x;
console.log(myMap(myArray, dup));
console.log(myArray.map(dup));
// [44, 18, 120, 24, 8, 112] both times

第一条日志显示了由 map() 产生的预期结果。第二条输出给出了相同的结果,所以看起来 myMap() 是有效的!最后的输出只是检查原始输入数组没有被任何方式修改;映射操作应该始终产生一个新的数组。参见 问题 5.3 以更彻底地测试我们的 myMap() 函数。

让我们回顾一下 myMap() 函数并添加类型注解。所需的数据类型更复杂,我们将有一个通用函数:

// map.ts
const myMap = <T, R>(arr: T[], fn: (x: T) => R): R[] =>
  arr.reduce(
    (x: R[], y: T): R[] => x.concat(fn(y)),
    [] as R[]
  );

我们的 myMap() 函数接收一个类型为 T 的元素数组和一个 fn() 映射函数,该函数将它的 T 参数转换为一个 R 类型的结果。映射的结果是一个 R 类型的元素数组。你自己检查累加函数;它的类型是否可理解?

让我们尝试一个不同的映射函数来验证我们的类型是否正确。我们将使用一个返回字符串而不是数字的函数——它只是在输入前后添加破折号,以生成一个字符串。

// continued...
const addDashes = (x: number): string => `-${x}-`;
const myDashes = myArray.map(addDashes);
// [ '-22-', '-9-', '-60-', '-12-', '-4-', '-56-' ]

好吧,看起来我们的复杂类型定义是正确的!

本章的所有先前列表示例都集中在简单的数组上。但如果事情变得更复杂,比如说,你必须处理一个其元素本身也是数组的数组,会怎样?幸运的是,有一个解决办法。让我们继续前进。

处理数组的数组

到目前为止,我们已将一个包含(单个)值的数组作为输入进行处理,但如果你输入的是一个数组的数组会怎样?如果你认为这是一个牵强附会的案例,有许多可能的场景可以应用这种情况:

  • 对于某些应用,你可能有一个距离表,在 JavaScript 中需要数组的数组:distance[i][j] 将是 ij 之间的距离。你如何找到任意两点之间的最大距离?使用普通数组找到最大值很简单,但如何处理数组的数组?

  • 一个更复杂的例子,同样在地理方面,是你可能查询一个地理 API 以获取与字符串匹配的城市,响应可能是一个包含国家的数组,每个国家都有一个包含州的数组,每个州本身都有一个包含匹配城市的数组:一个数组的数组数组!

在第一种情况下,你可能想要一个包含所有距离的单个数组,而在第二种情况下,一个包含所有城市的数组;你将如何管理这种情况?需要一个新操作,扁平化;让我们看看。

扁平化一个数组

在 ES2019 中,JavaScript 添加了两个操作:flat(),我们现在将探讨它,以及 flatMap(),我们稍后将会探讨。展示它们的作用比解释它们更容易——请耐心等待!

不允许使用 flat()?

如常发生的那样,并非所有浏览器都已更新以包含这些新方法,微软的 Internet Explorer 和其他浏览器在这方面存在缺陷,因此对于网络编程,将需要 polyfill。通常,为了获取更新的兼容性数据,请查看 Can I use? 网站,在本例中,请访问 caniuse.com/#feat=array-flat。一个好消息:自 2018 年 9 月以来,所有主要浏览器都原生支持此功能!

flat() 方法创建一个新的数组,将子数组的所有元素连接到所需的级别,默认为 1

const a = [[1, 2], [3, 4, [5, 6, 7]], 8, [[[9]]]];
console.log(a.flat()); // or a.flat(1)
[ 1, 2, 3, 4, [ 5, 6, 7 ], 8, [ [ 9 ] ] ]
console.log(a.flat(2));
[ 1, 2, 3, 4, 5, 6, 7, 8, [ 9 ]]
console.log(a.flat(Infinity));
[ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]

那么,我们如何使用这个函数来解决我们的问题呢?使用 flat()、展开和 Math.max() 回答了第一个问题(正如我们在 第一章**,成为函数式开发者展开 部分中看到的,我们当时可以使用我们写的 maxArray() 函数),我们还可以使用 reduce() 来增加多样性。假设我们有一个距离表:

const distances = [
  [0, 20, 35, 40],
  [20, 0, 10, 50],
  [35, 10, 0, 30],
  [40, 50, 30, 0],
];

然后,我们可以通过几种方式找到我们的最大距离:我们或者扁平化数组,展开它,并使用 Math.max(),或者扁平化数组并使用归约来显式地找到最大值:

// flat.js
const maxDist1 = Math.max(...distances.flat()); // 50
const maxDist2 = distances
  .flat()
  .reduce((p, d) => Math.max(p, d), 0); // also 50

让我们回到第二个问题。假设我们查询了一个地理 API,以获取名字中包含 "LINCOLN"(大小写不敏感)的城市,并得到了以下答案:

// continued...
const apiAnswer = [
  {
    country: "AR",
    name: "Argentine",
    states: [
      {
        state: "1",
        name: "Buenos Aires",
        cities: [{city: 3846864, name: "Lincoln"}],
      },
    ],
  },
  {
    country: "GB",
    name: "Great Britain",
    states: [
      {
        state: "ENG",
        name: "England",
        cities: [{city: 2644487, name: "Lincoln"}],
      },
    ],
  },
  {
    country: "US",
    name: "United States of America",
    states: [
      {
        state: "CA",
        name: "California",
        cities: [{city: 5072006, name: "Lincoln"}],
      },
      .
      .
      .
      {
        state: "IL",
        name: "Illinois",
        cities: [
          {city: 4899911, name: "Lincoln Park"},
          {city: 4899966, name: "Lincoln Square"},
        ],
      },
    ],
  },
];

通过应用 map()flat() 两次,我们可以提取城市列表:

// continued...
console.log(
  apiAnswer
    .map(x => x.states)
    .flat()
    .map(y => y.cities)
    .flat()
);
/* Results:
[ { city: 3846864, name: 'Lincoln' },
  { city: 2644487, name: 'Lincoln' },
  { city: 5072006, name: 'Lincoln' },
  { city: 8531960, name: 'Lincoln' },
  { city: 4769608, name: 'Lincolnia' },
  { city: 4999311, name: 'Lincoln Park' },
  { city: 5072006, name: 'Lincoln' },
  { city: 4899911, name: 'Lincoln Park' },
  { city: 4899966, name: 'Lincoln Square' }
]
*/

我们已经看到了如何使用 flat() 来扁平化一个数组;现在让我们看看如何使用 flatMap(),这是 flat()map() 的有趣混合,来进一步简化我们的编码,甚至进一步缩短我们前面的第二个解决方案!(如果你认为这个练习还不够难,其输出有点无聊,可以尝试 问题 5.10 以获得更具挑战性的版本!)

映射和扁平化 – flatMap()

基本上,flatMap() 函数所做的是首先应用一个 map() 函数,然后将映射操作的结果应用 flat() 函数。这是一个有趣的组合,因为它允许你生成一个具有不同元素数量的新数组。(使用正常的 map() 操作,输出数组将与输入数组长度完全相同)。如果你的映射操作生成一个包含两个或更多元素的数组,那么输出数组将包含许多输出值;如果你生成一个空数组,输出数组将包含较少的值。

让我们来看一个(某种意义上说不通)的例子。假设我们有一个名字列表,例如 "Winston Spencer Churchill""Abraham Lincoln""Socrates"。我们的规则是,如果一个名字包含多个单词,排除第一个(我们假设是名字,即姓氏),然后将剩下的(我们假设是姓氏)分开,但如果一个名字是一个单词,则将其删除(我们假设这个人没有姓氏):

// continued...
const names = [
  "Winston Spencer Churchill",
  "Plato",
  "Abraham Lincoln",
  "Socrates",
  "Charles Darwin",
];
const lastNames = names.flatMap((x) => {
  const s = x.split(" ");
  return s.length === 1 ? [] : s.splice(1);
});
// [ 'Spencer', 'Churchill', 'Lincoln', 'Darwin' ]

如我们所见,输出数组与输入数组的元素数量不同:仅仅因为这个原因,我们可以考虑 flatMap()map() 的升级版,甚至包括一些 filter() 的方面,比如当我们排除单个名字时。

现在我们来看一个简单的例子。继续上节中林肯主题,让我们计算林肯的葛底斯堡演讲中包含多少个单词,这些单词以句子数组的形式给出。顺便说一句,这个演讲通常被认为有 272 个单词长,但我找到的版本并没有产生这个数字!这可能是因为有五个林肯亲笔撰写的演讲稿副本,加上另一个从事件中的速记笔记转录的版本。无论如何,我将把这个差异留给历史学家,并专注于编码!

我们可以使用 flatMap() 将每个句子拆分成一个单词数组,然后查看展平后的数组长度:

const gettysburg = [
  "Four score and seven years ago our fathers",
  "brought forth, on this continent, a new nation,",
  "conceived in liberty, and dedicated to the",
  "proposition that all men are created equal.",
  "Now we are engaged in a great civil war,",
  "testing whether that nation, or any nation",
  "so conceived and so dedicated, can long endure.",
  "We are met on a great battle field of that",
  "war. We have come to dedicate a portion of",
  "that field, as a final resting place for",
  "those who here gave their lives, that that",
  "nation might live. It is altogether",
  "fitting and proper that we should do this.",
  "But, in a larger sense, we cannot dedicate,",
  "we cannot consecrate, we cannot hallow,",
  "this ground.",
  "The brave men, living and dead, who",
  "struggled here, have consecrated it far",
  "above our poor power to add or detract.",
  "The world will little note nor long",
  "remember what we say here, but it can",
  "never forget what they did here.",
  "It is for us the living, rather, to be",
  "dedicated here to the unfinished work",
  "which they who fought here have thus far",
  "so nobly advanced.",
  "It is rather for us to be here dedicated",
  "to the great task remaining before us—",
  "that from these honored dead we take",
  "increased devotion to that cause for",
  "which they here gave the last full",
  "measure of devotion— that we here highly",
  "resolve that these dead shall not have",
  "died in vain— that this nation, under",
  "God, shall have a new birth of freedom-",
  "and that government of the people, by",
  "the people, for the people, shall not",
  "perish from the earth.",
];
console.log(
  gettysburg.flatMap((s: string) => s.split(" ")).length
);
// 270 ...not 272?

让我们回到城市的问题。如果我们注意到每个 map() 后面都跟着 flat(),一个替代方案就立即显现出来。将这个解决方案与我们之前在 Flattening an array 部分中写的方案进行比较;它们基本上是相同的,但是将每个 map() 与其后面的 flat() 合并:

// continued...
console.log(
  apiAnswer
    .flatMap((x) => x.states)
    .flatMap((y) => y.cities)
);
// same result as with separate map() and flat() calls

我们现在已经看到了新的操作。(是的,没有映射也可以解决本节中的问题,但这不会是本节的良好示例!有关单词计数问题的替代方案,请参阅 问题 5.11。)现在让我们学习如何模拟这些操作,以防你无法立即获得它们。

模拟 flat() 和 flatMap()

我们已经看到了如何使用 reduce() 来模拟 map()。现在让我们看看如何为 flat()flatMap() 找到等效的方法,以获得更多的实践。我们还将加入一个递归版本,这是一个我们将在 第九章 设计函数 中再次讨论的主题。正如之前提到的,我们并不是在追求最快、最小或任何特定的代码版本;相反,我们想要专注于使用这本书中我们一直在探讨的概念。

完全展平一个数组可以通过递归调用来完成。我们使用 reduce() 逐个处理数组元素,如果一个元素恰好是一个数组,我们就递归地展平它:

// continued...
const flatAll = <T>(arr: T[]): T[] =>
  arr.reduce(
    (f: T[], v: T) =>
      f.concat(Array.isArray(v) ? flatAll(v) : v),
    [] as T[]
  );

如果你能先展平一个数组的单个级别,那么将数组展平到给定级别(不是无限;让我们留到以后再说)就很容易了。我们可以通过展开或使用 reduce() 来做到这一点。让我们编写一个 flatOne() 函数,它只展平数组的单个级别。这里有这个函数的两个版本;选择你喜欢的任何一个:

// continued...
const flatOne1 = <T>(arr: T[]): T[] =>
  ([] as T[]).concat(...arr);
const flatOne2 = <T>(arr: T[]): T[] =>
  arr.reduce((f, v) => f.concat(v), [] as T[]);

使用这两个函数中的任何一个,我们都可以将多层数组展平,并且我们可以以两种不同的方式做到这一点。我们两个版本的 flat() 函数使用了我们之前的 flatOne()flatAll() 函数,但第一个只使用标准循环,而第二个则以完全递归的方式工作。你更喜欢哪一个?

// continued...
const flat1 = <T>(arr: T[], n = 1): T[] => {
  if (n === Infinity) {
    return flatAll(arr);
  } else {
    let result = arr;
    range(0, n).forEach(() => {
      result = flatOne(result);
    });
    return result;
  }
};
const flat2 =  <T>(arr: T[], n = 1): T[] => {
  n === Infinity
    ? flatAll(arr)
    : n === 1
    ? flatOne(arr)
    : flat2(flatOne(arr), n - 1);

我认为递归版本更优雅,更符合本书的主题。尽管如此,这完全取决于你——尽管如果你不熟悉三元运算符,那么递归版本肯定不适合你!

如果你希望对这些函数进行 polyfill(尽管我们的建议是不这么做),这并不复杂,并且与我们之前对 average() 方法所做的是类似的。我注意到了不要创建任何额外的函数:

// continued...
if (!Array.prototype.flat) {
  Array.prototype.flat = function (this, n): any[] {
    if (n === undefined || n === 1) {
      return flatOne(this as any[]);
    } else if (n === Infinity) {
      return flatAll(this as any[]);
    } else {
      return flatOne(this as any[]).flat(n - 1);
    }
  };
}

我们的 flatOneX()flatAllX() 方法只是我们之前开发的副本,你会在我们的实现末尾认出我们之前的 flat2() 函数的代码。

最后,模拟 flatMap() 本身很简单,我们可以跳过它,因为这只是一个先应用 map(),然后 flat() 的问题;没什么大不了的!

我们已经看到了如何以几种不同的方式处理数组,但有时你所需要的并不是我们看到的任何函数所提供的服务。让我们继续探讨更通用的循环方式,以获得更大的能力。

更通用的循环

我们之前看到的示例都是遍历数组,做一些工作。然而,有时你需要循环,但所需的过程并不真正适合 map()reduce()。在这种情况下,我们能做什么呢?有一个 forEach() 方法可以帮助。(更多关于它的信息请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach。)

你必须提供一个回调函数,该函数将接收值、索引以及你正在操作的数组。(后两个参数是可选的。)JavaScript 将负责循环控制,你可以在每个步骤做任何你想做的事情。例如,我们可以通过使用 Object 方法逐个复制源对象的属性并生成一个新对象来编程一个对象复制方法:

// copy.ts
const objCopy = <T>(obj: T): T => {
  const copy = Object.create(Object.getPrototypeOf(obj));
  Object.getOwnPropertyNames(obj).forEach((prop: string) =>
    Object.defineProperty(
      copy,
      prop,
      Object.getOwnPropertyDescriptor(obj, prop) as string
    )
  );
  return copy;
};
const myObj = { fk: 22, st: 12, desc: "couple" };
const myCopy = objCopy(myObj);
console.log(myObj, myCopy);
// {fk: 22, st: 12, desc: "couple"}, twice

理念是:我们创建一个与原始 obj 具有相同原型的 copy 对象,然后对于原始对象中的每个属性,我们在副本中定义一个等效的属性。函数的签名清楚地表明输入和输出类型是相同的。一个特别的细节:鉴于我们正在编写的循环,我们知道 Object.getOwnPropertyDescriptor(obj, prop) 将是一个字符串(而不是 undefined),但 TypeScript 无法判断;添加 as string 解决了这个问题。

浅拷贝或深拷贝?

当然,我们本来可以写 myCopy={...myObj},但那样有什么乐趣呢?这会更好,但我需要一个很好的例子来使用 forEach()。对此表示歉意!此外,该代码中还有一些隐藏的不便,我们将在 第十章 确保纯净性 中解释,当我们尝试获取冻结的、不可修改的对象时。只是提示一下:新对象可能与旧对象共享值,因为我们有一个浅拷贝,而不是深拷贝。我们将在本书的后面部分了解更多关于这一点。

如果我们使用之前定义的 range() 函数,我们也可以执行类似于 for(let i=0; i<10; i++) 的常见循环。我们可以使用它来编写另一个阶乘 (!) 的版本:

// loops.ts
import { range } from "./range";
const fact4 = (n: number): number => {
  let result = 1;
  range(1, n + 1).forEach((v) => (result *= v));
  return result;
};
console.log(fact4(5)); // 120

这个阶乘的定义确实符合通常的描述:它生成从 1 到 n 的所有数字(包括 n)并将它们相乘——简单!

为了提高通用性,考虑扩展 range() 以生成升序和降序的值范围,可能以除 1 以外的数字为步长。这将允许你用 forEach() 循环替换代码中的所有循环。

到目前为止,我们已经看到了许多处理数组以生成结果的方法,但其他目标可能也很有趣,所以现在让我们转向逻辑函数,这将简化我们的编码需求。

逻辑高阶函数

到目前为止,我们一直在使用高阶函数(HOFs)来生成新结果。然而,有些其他函数通过将谓词应用于数组的所有元素来生成逻辑结果。(顺便说一下,我们将在下一章看到更多关于高阶函数的内容。)

许多含义

一点术语:单词 谓词 可以用在几个意义上(如在谓词逻辑中),但对我们来说,在计算机科学中,它意味着 一个返回 true 或 false 的函数。好吧,这不是一个非常正式的定义,但对于我们的需求来说足够了。例如,说我们将根据谓词过滤数组意味着我们可以根据谓词的结果来决定哪些元素被包含或排除。

使用这些函数意味着你的代码将变得更短:你可以用一行代码得到与整个值集相对应的结果。

过滤数组

我们会遇到一个常见的需求,即根据特定条件过滤数组中的元素。filter() 方法允许你以与 map() 相同的方式检查数组中的每个元素。区别在于,你函数的结果决定了输入值是否会被保留在输出中(如果函数返回 true),或者是否会被跳过(如果函数返回 false)。同样,与 map() 类似,filter() 不会改变原始数组,而是生成一个包含所选项的新数组。你可以在 developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Array/filter 上了解更多关于 filter() 函数的信息。

图 5**.4 以了解输入和输出的示意图:

图 5.4 – filter() 方法选择满足给定谓词的数组元素

图 5.4 – filter() 方法选择满足给定谓词的数组元素

在过滤数组时,有几件事情需要记住:

  • undefined,由于这是一个假值,输出将是一个空数组

  • 复制的副本是浅层的:如果输入数组元素是对象或数组,则原始元素仍然可以访问

让我们通过查看 filter() 的实际示例来深入了解,然后看看我们如何使用 reduce() 实现该功能。

filter() 示例

让我们看看一个实际例子。假设一个服务返回了一个 JSON 对象,其中包含一个包含账户 id 值和 balance 的对象数组。我们如何获取具有负余额的 “in the red” ID 列表?输入数据可能如下所示:

// filter.ts
const serviceResult = {
  accountsData: [
    { id: "F220960K", balance: 1024 },
    { id: "S120456T", balance: 2260 },
    { id: "J140793A", balance: -38 },
    { id: "M120396V", balance: -114 },
    { id: "A120289L", balance: 55000 },
  ],
};

我们可以用类似以下的方式获取违约账户。你可以检查 delinquent 变量的值是否正确地包含了具有负余额的两个账户 ID:

// continued...
const delinquent = serviceResult.accountsData.filter(
  (v) => v.balance < 0
);
console.log(delinquent);
// two objects, with id's J140793A and M120396V

顺便说一句,鉴于过滤操作又产生了一个数组,如果你只想获取账户 ID,你可以通过映射输出只获取 id 字段:

// continued...
const delinquentIds = delinquent.map((v) => v.id);

如果你不在乎中间结果,单行代码也可以工作得很好:

// continued...
const delinquentIds2 = serviceResult.accountsData
  .filter((v) => v.balance < 0)
  .map((v) => v.id);

过滤是一个非常有用的功能,因此现在,为了更好地掌握它,让我们看看你如何模拟它,这可以作为你自己的更复杂、更强大的函数的基础。

使用 reduce() 模拟 filter()

正如我们之前使用 map() 一样,我们也可以通过使用 reduce() 创建自己的 filter() 版本。想法是相似的:遍历输入数组的所有元素,对其应用谓词,如果结果是 true,则将原始元素添加到输出数组中。当循环完成后,输出数组将只包含谓词为 true 的元素:

// continued...
const myFilter = <T>(arr: T[], fn: (x: T) => boolean) =>
  arr.reduce(
    (x: T[], y: T) => (fn(y) ? x.concat(y) : x),
    []
  );

我们的功能是通用的;它接受一个类型为 T 的元素数组和一个接受类型为 T 的参数的谓词,该谓词生成一个类型为 T 的新元素数组。我们可以快速看到我们的函数按预期工作:

myFilter(serviceResult.accountsData, (v) => v.balance < 0);

输出与我们在本节前面看到的相同的一对账户。

搜索数组

有时候,你不想过滤数组中的所有元素,而是想找到一个满足给定谓词的元素。有几个函数可以用来完成这个任务,具体取决于你的具体需求:

  • find() 遍历数组,并返回满足给定条件的第一个元素的值,如果没有找到,则返回 undefined

  • findIndex() 执行类似的任务,但它返回满足条件的第一个元素的索引,如果没有找到,则返回 -1

includes()indexOf() 的相似性很明显;这些函数搜索的是特定值,而不是满足更一般条件的元素。我们可以轻松地编写等效的单行代码:

arr.includes(value);  // arr.find(v => v === value) arr.indexOf(value);  // arr.findIndex(v => v === value)

回到我们之前使用的地理数据,我们可以很容易地使用 find() 方法找到一个给定的国家。例如,让我们获取巴西("BR")的数据;这只需要一行代码:

// search.ts
import { markers } from "./average";
const brazilData = markers.find((v) => v.name === "BR");
// {name:"BR", lat:-15.8, lon:-47.9}

我们不能使用更简单的 includes() 方法,因为我们必须深入到对象中才能获取我们想要的字段。如果我们想获取国家在数组中的位置,我们会使用 findIndex()

// continued...
const brazilIndex = markers.findIndex(
  (v) => v.name === "BR"
);
// 2

好的,这很简单!那么特殊案例呢?这甚至可能是一个面试难题?继续阅读!

特殊搜索案例

假设你有一个数字数组,并想进行一个合理性检查,研究其中是否有任何是 NaN。你会怎么做?提示:不要尝试检查数组元素的类型——尽管 NaN 代表“不是一个数字”,但 typeof NaN 的值是 "number"。如果你尝试以明显的方式搜索,你会得到一个令人惊讶的结果:

[1, 2, NaN, 4].findIndex((x) => x === NaN); // -1

这里发生了什么?这是一个有趣的 JavaScript 知识点:NaN 是唯一一个不等于自身的值。如果你需要查找 NaN,你必须使用新的 isNaN() 函数,如下所示:

[1, 2, NaN, 4].findIndex(x => isNaN(x)); // 2

ESLint 会帮助 use-isnan 规则:有关更多信息,请参阅 eslint.org/docs/latest/rules/use-isnan。图 5**.5 展示了结果。

图 5.5 – ESLint 防止你犯与 NaN 相关的错误

图 5.5 – ESLint 防止你犯与 NaN 相关的错误

这是一个值得了解的特殊情况;我必须处理过一次!现在,让我们像以前一样继续,通过使用 reduce() 模拟搜索方法,这样我们就可以看到更多该函数的强大功能示例。

使用 reduce() 模拟 find() 和 findIndex()

就像其他方法一样,让我们通过研究如何使用无所不能的 reduce() 实现我们展示的方法来结束本节。这是一个很好的练习,让你习惯于使用 HOFs(高阶函数),即使你实际上永远不会使用这些 polyfills(多项填充)!

find() 方法需要一点工作。我们以一个 undefined 值开始搜索,如果我们找到一个数组元素使得谓词为 true,我们将累计值更改为该数组:

arr.find(fn); // or arr.find((x) => fn(x));
arr.reduce(
  (x, y) => (x === undefined && fn(y) ? y : x),
  undefined
);

在性能方面,它与标准的 find() 方法略有不同。语言规范(在 tc39.es/ecma262/#sec-array.prototype.find)显示,搜索会在找到一个满足搜索条件的元素时停止。然而,我们的代码会继续处理数组的其余部分(因为这就是 reduce() 的工作方式),尽管它不会再次评估谓词;你能看出为什么吗?

对于 findIndex(),我们必须记住回调函数接收累计值、当前元素的数组以及当前元素的索引,但除此之外,等效表达式与 find() 的表达式非常相似;比较它们是值得花时间的:

arr.findIndex(fn);
arr.reduce((x, y, i) => (x == -1 && fn(y) ? i : x), -1);

这里的初始累计值是 -1,如果没有元素满足谓词,它将是返回值。每当累计值仍然是 -1,但我们找到一个满足谓词的元素时,我们将累计值更改为数组索引。

好的,我们现在完成了搜索:让我们继续考虑更高级的谓词,这将简化测试数组以检查条件,但始终使用我们迄今为止一直在使用的声明式风格。

更高级的谓词 – every() 和 some()

我们将要考虑的最后几个函数将极大地简化遍历数组以测试条件。这些函数如下:

  • every(),当且仅当数组中的每个元素都满足给定的谓词时为 true

  • some(),当数组中至少有一个元素满足谓词时为 true

例如,我们可以快速检查关于所有国家坐标为负数的假设:

// continued...
markers.every((v) => v.lat < 0 && v.lon < 0); // false
markers.some((v) => v.lat < 0 && v.lon < 0);  // true

如果我们想用 reduce() 的方式找到这两个函数的等价函数,两种替代方案显示了很好的对称性:

arr.every(fn);
arr.reduce((x, y) => x && fn(y), true);
arr.some(fn);
arr.reduce((x, y) => x || fn(y), false);

第一次折叠操作评估 fn(y) 并将结果与之前的测试进行 AND 操作;最终结果为 true 的唯一方式是每个测试都成功。第二次折叠操作类似,但它将结果与之前的测试结果进行 OR 操作,除非每个测试都失败,否则将产生 true

布尔对偶性

在布尔代数中,every()some() 的替代公式表现出对偶性。这种对偶性与出现在 x === x && truex === x || false 表达式中的对偶性相同;如果 x 是布尔值,并且我们交换 &&||,以及 truefalse,那么我们将一个表达式转换成另一个表达式,两者都是有效的。

在本节中,我们看到了如何检查给定的布尔条件。让我们通过发明我们自己的方法来检查负条件来完成。

检查负数 – none()

如果你愿意,你也可以将 none() 定义为 every() 的补集。这个新函数仅在数组的元素都不满足给定的谓词时为 true。实现这一点的最简单方法是通过注意,如果没有元素满足条件,那么所有元素都满足条件的否定:

// continued...
const none = <T>(arr: T[], fn: (x: T) => boolean) =>
  arr.every((v) => !fn(v));

你可以通过修改数组原型将其转换为方法,就像我们之前看到的那样。这仍然是一个坏习惯,但这是我们开始寻找更好的方法来组合和链接函数之前的情况,我们将在 第八章 连接函数 中这样做:

// continued...
declare global {
  interface Array<T> {
    none(f: (x: T) => boolean): boolean;
  }
}
Array.prototype.none = function (fn) {
  return this.every((v) => !fn(v));
};

我们必须使用 function() 而不是箭头函数,原因与我们在之前场合看到的原因相同:我们需要 this 被正确分配。我们还需要添加一个全局定义,就像我们使用平均值时那样,这样 TypeScript 就不会反对新添加的 none() 方法。除此之外,这只是简单的编码,我们现在为所有数组都有一个可用的 none() 方法。在 第六章 生成函数 中,我们将看到通过编写我们自己的适当的高阶函数(HOF)来否定函数的其他方法。

在本节和前一节中,我们处理了日常问题,并看到了如何声明式地解决它们。然而,当你开始使用 async 函数时,事情会有所变化。在下一节中,我们将看到需要新的解决方案。

使用异步函数

在前几节中我们学习的所有示例和代码都是为了与常用函数一起使用,具体来说就是不是async函数。当你想要进行映射、过滤、归约等操作,但使用的函数是一个async函数时,结果可能会让你感到惊讶。为了简化我们的工作并避免处理实际的 API 调用,让我们创建一个fakeAPI(delay, value)函数,该函数会在返回给定值之前延迟一段时间:

// async.ts
const fakeAPI = <T>(delay: number, value: T): Promise<T> =>
  new Promise((resolve) =>
    setTimeout(() => resolve(value), delay)
  );

让我们再有一个函数来显示fakeAPI()返回的内容,这样我们就可以看到一切是否按预期工作:

// continued...
const useResult = (x: any): void =>
  console.log(new Date(), x);

我们正在使用 ES2017 中的现代asyncawait功能来简化我们的代码,并且我们避免使用顶级await

// async.ts
(async () => {
  console.log("START");
  console.log(new Date());
  const result = await fakeAPI(1000, 229);
  useResult(result);
  console.log("END");
})();
/*
START
2022-10-29T01:28:12.986Z
2022-10-29T01:28:13.989Z 229
END
*/

结果是可以预见的:我们得到START文本,然后大约 1 秒(1,000 毫秒)后,得到伪造 API 调用的结果(229),最后是END文本。可能出什么问题?

顶级 await

为什么我们要使用在第三章“开始使用函数”中看到的立即调用模式?原因是自 Node.js 版本 14.8(2020 年 8 月)和浏览器自 2021 年以来,await在顶级的使用一直是可用的,所以它还没有得到广泛的应用。因此,由于你只能在async函数中使用await,我选择在这里使用 IIFE 以实现主要兼容性。

关键问题是我们在本章前面看到的所有函数都不是async-感知的,所以它们不会按你预期的那样工作。让我们开始探讨这个问题。

一些奇怪的行为

让我们从一个小测验开始:结果是你预期的吗?让我们看看几个涉及async调用的代码示例,并会发现一些意外的结果。首先,让我们看看一个典型的直接async调用序列:

// continued...
(async () => {
  console.log("START SEQUENCE");
  const x1 = await fakeAPI(1000, 1);
  useResult(x1);
  const x2 = await fakeAPI(2000, 2);
  useResult(x2);
  const x3 = await fakeAPI(3000, 3);
  useResult(x3);
  const x4 = await fakeAPI(4000, 4);
  useResult(x4);
  console.log("END SEQUENCE");
})();

如果你运行这段代码,你会得到以下结果,这确实是你预期的——一个START SEQUENCE文本,四行单独的文本显示了伪造 API 调用的结果,以及最后的END SEQUENCE文本。这里没有什么特别的——一切正常!

START SEQUENCE
2022-10-29T01:32:11.671Z 1
2022-10-29T01:32:13.677Z 2
2022-10-29T01:32:16.680Z 3
2022-10-29T01:32:20.683Z 4
END SEQUENCE

让我们尝试一个替代的第二版本,你可能会期望它与第一个版本等效。这里唯一的区别是我们使用循环来执行四个 API 调用;它应该是相同的,不是吗?(我们也可以使用我们之前看到的range()函数的forEach()循环,但这不会产生任何区别。)尽管在这个特定情况下不需要,但我仍然使用了 IIFE;你能看出为什么吗?

// continued...
(() => {
  console.log("START FOREACH");
  [1, 2, 3, 4].forEach(async (n) => {
    const x = await fakeAPI(n * 1000, n);
    useResult(x);
  });
  console.log("END FOREACH");
})();

这段代码看起来确实与第一个例子相同,但它产生的是完全不同的结果!

START FOREACH
END FOREACH
2022-10-29T01:34:06.287Z 1
2022-10-29T01:34:07.287Z 2
2022-10-29T01:34:08.286Z 3
2022-10-29T01:34:09.286Z 4

END FOREACH文本出现在 API 调用结果之前。发生了什么?答案是我们在前面提到过的:类似于forEach()这样的方法旨在与标准、同步函数调用一起使用,并且在与async函数调用一起使用时会表现得非常奇怪。

关键概念是 async 函数始终返回承诺,因此在我们得到 START FOREACH 文本后,循环实际上创建了四个承诺(最终将在某个时刻解决),但不需要等待它们,我们的代码继续打印 END FOREACH 文本。

问题不仅在于 forEach(),还影响了所有其他类似的方法。让我们看看我们如何解决这个问题,并编写 async 意识函数,让我们能够以声明式的方式继续工作,就像我们在本章前面所做的那样。

async 准备循环

如果我们不能直接使用 forEach()map() 等方法,我们就必须开发我们自己的新版本。让我们看看如何实现这一点。

遍历 async 调用

由于 async 调用返回承诺,我们可以通过从已解决的承诺开始,并将每个数组中的值的承诺链接到它,使用 reduce() 来模拟 forEach()then() 方法将按正确顺序被调用,因此结果将是正确的。以下代码片段成功地得到了正确、预期的结果:

// continued...
const forEachAsync = <T>(
  arr: T[],
  fn: (x: T) => any
): Promise<any> =>
  arr.reduce(
    (promise: Promise<void>, value: T) =>
      promise.then(() => fn(value)),
    Promise.resolve()
  );
(async () => {
  console.log("START FOREACH VIA REDUCE");
  await forEachAsync([1, 2, 3, 4], async (n) => {
    const x = await fakeAPI(n * 1000, n);
    useResult(x);
  });
  console.log("END FOREACH VIA REDUCE");
})();

结果如下:

START FOREACH VIA REDUCE
2022-10-29T01:42:09.385Z 1
2022-10-29T01:42:11.388Z 2
2022-10-29T01:42:14.391Z 3
2022-10-29T01:42:18.392Z 4
END FOREACH VIA REDUCE

由于 forEachAsync() 返回一个承诺,我们必须记得在显示最终文本消息之前等待它。除了不要忘记所有的 await 语句外,代码与使用 forEach() 构建的代码类似,关键的区别在于这确实按预期工作!

映射 async 调用

我们可以使用其他函数吗?编写 mapAsync(),这是一个可以与 async 映射函数一起工作的 map() 版本,很简单,因为你可以利用 Promise.all() 将承诺数组转换为承诺:

// continued...
const mapAsync = <T, R>(
  arr: T[],
  fn: (x: T) => Promise<R>
) => Promise.all(arr.map(fn));
(async () => {
  console.log("START MAP");
  const mapped = await mapAsync([1, 2, 3, 4], async (n) => {
    const x = await fakeAPI(n * 1000, n);
    return x * 10;
  });
  useResult(mapped);
  console.log("END MAP");
})();

我们得到以下结果:

START MAP
2022-10-29T01:47:06.726Z [ 10, 20, 30, 40 ]
END MAP

解决方案的结构与 forEachAsync() 代码类似。就像之前一样,我们必须记得在继续处理之前等待 mapAsync() 的结果。除此之外,逻辑很简单,结果也符合预期;映射函数延迟一段时间,然后返回其输入参数的 10 倍,我们看到产生了正确的输出。

使用 async 调用进行过滤

使用 async 函数进行过滤稍微复杂一些。我们将不得不使用 mapAsync() 来生成一个包含 truefalse 结果的数组,然后使用标准的 filter() 方法根据 async 过滤函数返回的结果从原始数组中挑选值。让我们尝试一个简单的例子,使用 fakeFilter() 函数调用 API 并只接受偶数结果,其中,对于我们的例子,fakeFilter() 接受偶数并拒绝奇数:

// continued...
const fakeFilter = (value: number): Promise<boolean> =>
  new Promise((resolve) =>
    setTimeout(() => resolve(value % 2 === 0), 1000)
  );

需要的 async 过滤代码如下:

// continued...
const filterAsync = <T>(
  arr: T[],
  fn: (x: T) => Promise<boolean>
) =>
  mapAsync(arr, fn).then((arr2) =>
    arr.filter((v, i) => Boolean(arr2[i]))
  );
(async () => {
  console.log("START FILTER");
  const filtered = await filterAsync(
    [1, 2, 3, 4],
    async (n) => {
      const x = await fakeFilter(n);
      return x;
    }
  );
  useResult(filtered);
  console.log("END FILTER");
})();

结果如下:

START FILTER
2022-10-29T01:56:19.798Z [ 2, 4 ]
END FILTER

注意,async 调用的映射结果是一个布尔数组(arr2),然后我们使用 filter() 从原始值数组(arr)中选择元素;这可能会有些难以理解!

减少 async 调用

最后,找到reduce()的等效函数要复杂一些,但不像我们看到的其他函数那样复杂。关键思想与forEachAsync()相同:每个函数调用将返回一个 promise,必须等待它以在即将到来的then()中更新累加器。我们使用一个立即解析为累加器初始值的初始 promise 来设置这个迭代:

// continued...
const reduceAsync = <T, R>(
  arr: T[],
  fn: (acc: R, val: T) => Promise<R>,
  init: R
) =>
  Promise.resolve(init).then((accum) =>
    forEachAsync(arr, async (v: T) => {
      accum = await fn(accum, v);
    }).then(() => accum)
  );

为了进行归约,让我们使用一个asyncfakeSum()函数,该函数将累加 API 返回的值:

// continued...
const fakeSum = (
  value1: number,
  value2: number
): Promise<number> =>
  new Promise((resolve) =>
    setTimeout(() => resolve(value1 + value2), 1000)
  );
(async () => {
  console.log("START REDUCE");
  const summed = await reduceAsync(
    [1, 2, 3, 4],
    async (_accum, n) => {
      const accum = await _accum;
      const x = await fakeSum(accum, n);
      useResult(`accum=${accum} value=${x} `);
      return x;
    },
    0
  );
  useResult(summed);
  console.log("END REDUCE");
})();

注意关键细节:在我们的归约函数中,我们必须首先await累加器的值,然后才能await我们async函数的结果。这是一个你必须注意的重要点:由于我们是以async方式归约的,获取累加器也是一个async过程,因此我们需要await累加器和新的 API 调用。

结果显示了四个中间值和最终结果:

START REDUCE
2022-10-29T02:04:20.862Z accum=0 value=1
2022-10-29T02:04:21.864Z accum=1 value=3
2022-10-29T02:04:22.865Z accum=3 value=6
2022-10-29T02:04:23.866Z accum=6 value=10
2022-10-29T02:04:23.866Z 10
END REDUCE

通过查看这些等效项,我们已看到,尽管async函数在第一章开头我们研究的常规声明式方法中会产生问题,但它们也可以通过我们自己的类似新函数来处理,因此我们甚至可以保留这些情况的新风格。即使我们必须使用一组略有不同的函数,你的代码仍然将是声明式的、更紧凑的、更清晰的;这是一个全面的胜利!

与并行函数一起工作

JavaScript 通过async函数提供并发,这意味着即使单个 CPU 正在完成所有工作,也可以同时进行多个任务。Web workers(用于前端)和工作线程(用于后端)允许在不同的核心上并行处理,以获得更好的性能。这可以从主线程卸载工作并解决潜在问题,符合我们的 FP 方法。

在本节中,我们将看到如何通过使用与本章前几节类似的方式使用工作者来避免前端和后端编程中的瓶颈。

无响应页面

让我们回到上一章记忆化部分中的斐波那契慢速执行代码。假设我们想要创建一个网页,允许用户输入一个数字并计算相应的斐波那契数,如图图 5.6所示。

图 5.6 – 斐波那契计算器

图 5.6 – 斐波那契计算器

这个页面的代码非常基础——不,我甚至没有尝试进行任何样式设计;这不是问题所在!

// workers/test_worker_1.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Fibonacci</title>
  </head>
  <body>
    Fibonacci:
    <input id="num" type="number" min="0" value="0" />
    <br />
    <br />
    <button onclick="locally()">Locally</button>
    <br />
    <br />
    Result: <span id="res"></span>
    <script src="img/test_worker_1.js"></script>
  </body>
</html>

脚本代码如下:

// workers/test_worker_1.ts
function fib(n: number): number {
  return n < 2 ? n : fib(n - 2) + fib(n - 1);
}
function getNumber(): number {
  return Number(
    (document.getElementById("num") as HTMLInputElement)
      .value
  );
}
function showResult(result: number): void {
  document.getElementById("res")!.innerText =
    String(result);
}
function locally(): void {
  showResult(fib(getNumber()));
}

当用户输入一个数字并点击本地按钮时,相应的斐波那契数将被计算并显示,但如果输入一个相当大的数字(比如说,大约 50),会发生什么?图 5.7说明了这个问题。

图 5.7 – 一个长时间运行的过程最终会阻塞浏览器

图 5.7 – 一个长时间运行的过程最终会阻塞浏览器

当代码运行时,页面会完全无响应,你无法点击任何地方或输入新数字。此外,如果某个进程需要太多的处理时间,浏览器会认为存在问题,并提示用户终止页面……这并不是我们想要的!

解决方案是什么?我们希望将计算任务卸载到工作者(worker),使其并行运行,从而释放浏览器。让我们看看我们如何以一种不太实用的方式设置它!

前端工作者

工作者(有关 Web 工作者 API,请参阅 developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API;有关 Node.js 工作者线程,请参阅 nodejs.org/api/worker_threads.html)以类似的方式工作。它们是普通的 JavaScript 代码,可以监听消息,并在完成工作后,通过发送另一条消息来响应调用者。

对于我们的斐波那契计算,以下内容是必要的:

// workers/web_fib_worker.ts
function fib(n: number): number {
  return n < 2 ? n : fib(n - 2) + fib(n - 1);
}
onmessage = (e: MessageEvent<number>) =>
  postMessage(fib(e.data));;

代码的最后一行提供了调用者和工作者之间的所有交互。在接收到消息e后,其e.data值被传递给fib()函数,并将结果发送回调用者。

这将如何使用?图 5.8显示了我们试图实现的结果。现在我们希望允许两种计算斐波那契数的方法:本地计算,如之前所述,但存在处理时间长的问题,或者通过将任务卸载到工作者来并行计算。

图 5.8 – 使用工作者作为选项计算斐波那契数

图 5.8 – 使用工作者作为选项计算斐波那契数

新的代码如下;我们将突出显示新增内容:

// workers/test_worker_2.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Fibonacci</title>
  </head>
  <body>
    Fibonacci:
    <input id="num" type="number" min="0" value="0" />
    <br />
    <br />
    <button onclick="locally()">Locally</button>
    <button onclick="parallelly()">Parallelly</button>
    <br />
    <br />
    Result: <span id="res"></span>
    <script src="img/test_worker_2.js"></script>
  </body>
</html>

新的脚本文件与之前的文件类似,只是在末尾添加了一些内容:

// workers/test_worker_2.ts
.
.
.
const worker = new Worker(
  "http://localhost:8887/test_fib_worker.js"
);
worker.onmessage = (e: MessageEvent<number>) =>
  showResult(e.data);
/* eslint-disable-next-line */
function parallelly(): void {
  worker.postMessage(getNumber());
}

新的“并行”按钮调用相应的parallelly()函数。这个函数获取用户输入的数字,并通过消息将其发送到之前创建的工作者。该工作者的onmessage方法接收计算结果并在屏幕上显示。

使用这种方法,用户可以请求任何斐波那契数,而窗口将保持响应,不会弹出警告让用户关闭页面;请参见图 5.9

图 5.9 – 即使计算时间很长,页面也能保持响应

图 5.9 – 即使计算时间很长,页面也能保持响应

好的,使用工作者(workers)在需要在前端执行大量计算时确实有帮助;让我们看看后端的一个类似实现。

后端工作者

让我们快速看一下后端工作者的一个例子,我们可以使用 Node.js 或类似的技术。这个例子将非常基础;在现实生活中,我们会包括路由定义和更多内容,但在这里我们只想关注工作者创建和使用。

我们的工作者与 Web 工作者类似;差异很容易理解:

// workers/fib_worker.ts
import { parentPort } from "worker_threads";
function fib(n: number): number {
  return n < 2 ? n : fib(n - 2) + fib(n - 1);
}
parentPort!.on("message", (m: number) =>
  parentPort!.postMessage(fib(m))
);

理念完全相同;当message事件发生时,我们调用fib()来计算相应的斐波那契数,并使用postMessage()将其发送给调用者。

调用者代码也会很简单:

// workers/fib_worker_test.ts
import { Worker } from "worker_threads";
const worker = new Worker("./fib_worker.js");
console.log("START");
worker.postMessage(40);
console.log("END");
worker.on("message", (msg) => {
  console.log("MESSAGE", msg);
  worker.terminate();
});

代码与前端代码完全类似。我们创建一个工作者(使用new Worker()调用),通过postMessage()向其发送消息,并监听工作者的message事件。当我们收到计算结果时,我们将其显示出来,并调用terminate()来终止工作者。运行此代码会产生以下简单结果——最后一行需要一段时间才能出现!

START
END
MESSAGE 102334155

我们已经看到了如何在事件驱动编程中使用工作者,但这并不特别适合我们想要的函数式编程(FP)工作方式;让我们来解决这个问题。

工作者,函数式编程风格

工作者适合函数式编程(FP)编程的原因如下:

工作者在单独的上下文中运行,因此它们不能与 DOM 或全局变量交互。

所有通信都是通过消息完成的;否则,工作者将与它们的调用者分离。

  • 传递给工作者及其返回的数据是副本;在传递之前进行序列化,在接收时进行反序列化。即使工作者修改了它接收到的参数,也不会对调用者造成任何问题。

我们可以处理事件,但最好是将工作者包装在承诺(promises)中,这样我们就可以应用上一节中开发的async函数。

事件或承诺?

工作者可以向它们的调用者发送多条消息。如果这种情况发生,承诺(promise)将不是一个好主意,因为它将在第一个结果后解决,而忽略未来的消息。在大多数情况下,期望只有一个结果,所以承诺是好的,但请记住还有其他可能性。

包装工作者的直接方法如下:

// workers/fib_worker_test_with_promise.ts
import { Worker } from "worker_threads";
const callWorker = (filename: string, value: unknown) =>
  new Promise((resolve) => {
    const worker = new Worker(filename);
    worker.on("message", resolve);
    worker.postMessage(value);
  });
console.log("START");
const result = await callWorker("./fib_worker.js", 40);
console.log("AWAITED", result);
console.log("END");
/* Result:
START
AWAITED 102334155
END
*/

我们创建的callWorker对象是一个承诺,当工作者发送回结果时将解决。结果正如预期:START文本、工作者返回的AWAITED结果,以及END文本。请注意,我们正在使用无点(point-free)风格来处理message事件。

这些代码示例运行良好,但它们有一个性能问题:每次调用它们时,都会创建一个新的工作者(这意味着其 JavaScript 代码必须被读取、解析和处理),因此会有延迟。让我们考虑避免这种情况的方法。

长期存在的池化工作者

工作者可以保持未终止状态,并将能够接收新消息并回复它们。消息会被排队,所以如果你需要同时多次使用同一个工作者,将会有一个逻辑上的延迟;调用将按顺序进行。如果你需要一个工作者且它空闲,你可以直接调用它,但如果需要它且它正忙,创建一个新的工作者是有意义的。我们将保持一个线程池,并且每当有调用到来时,我们将检查是否有可用的工作者来处理它,或者是否需要首先创建一个新的工作者。

让我们看看如何做到这一点。首先,我们需要一个池:

// workers/pool.ts
import { Worker } from "worker_threads";
type PoolEntry = {
  worker: Worker;
  filename: string;
  value: any;
  inUse: boolean;
};
const pool: PoolEntry[] = [];

PoolEntry对象将具有以下内容:

  • 工作者对象。

  • 与创建工作者时使用的路径对应的文件名。

  • 上次使用此工作者时调用它的值(仅用于记录;我们可以没有它)。

  • 使用inUse标志来显示它是否可用。pool只是一个PoolEntry对象的数组。

我们需要一个函数来允许我们调用工作者;让我们称它为workerCall()。我们必须指定要调用的函数的文件名以及传递给它的值。该函数首先将以非常声明性的方式检查是否存在一个合适的可用空闲工作者(具有相同的文件名且未被使用),如果找不到这种类型的工作者,它将创建一个新的工作者。然后,工作者将通过使用承诺(promise)来调用,就像上一节中那样,当结果返回时,工作者将被标记为未使用,准备接受新的调用:

// continued...
export const workerCall = (
  filename: string,
  value: any
): Promise<any> => {
  let available = pool
    .filter((v) => !v.inUse)
    .find((x) => x.filename === filename);
  if (available === undefined) {
    // console.log("CREATING", filename, value);
    available = {
      worker: new Worker(filename),
      filename,
      value,
      inUse: true,
    } as PoolEntry;
    pool.push(available);
  } else {
    // console.log("REUSING", filename, available.value);
  }
  return new Promise((resolve) => {
    available!.inUse = true;
    available!.worker.on("message", (x) => {
      resolve(x);
      available!.inUse = false;
      // console.log("RESOLVING", filename, value, x);
    });
    available!.worker.postMessage(value);
  });
};

我们可以通过我们之前使用的斐波那契工作者以及一个新随机工作者来查看这是如何工作的,该随机工作者在返回随机数之前会延迟一段时间:

// workers/random_worker.ts
import { parentPort } from "worker_threads";
async function random(n: number): Promise<number> {
  await new Promise((resolve) => setTimeout(resolve, n));
  return Math.floor(n * Math.random());
}
parentPort!.on("message", async (m) =>
  parentPort!.postMessage(await random(m))
);

我们可以验证这一点:

// workers/pool_test.ts
import { workerCall } from "./pool";
const FIB_WORKER = "./fib_worker.js";
const RANDOM_WORKER = "./random_worker.js";
const showResult = (s: string) => (x: any) =>
  console.log(s, x);
workerCall(FIB_WORKER, 35).then(showResult("fib(35)"));
workerCall(RANDOM_WORKER, 3000).then(showResult("random"));
workerCall(FIB_WORKER, 20).then(showResult("fib(20)"));
workerCall(FIB_WORKER, 44).then(showResult("fib(44)"));
workerCall(FIB_WORKER, 10).then((x) => {
  console.log("fib(10)", x);
  workerCall(FIB_WORKER, 11).then((y) =>
    console.log("fib(11)", y)
  );
});
workerCall(RANDOM_WORKER, 2000).then(showResult("random"));
workerCall(RANDOM_WORKER, 1000).then(showResult("random"));

运行此代码的结果如下——但我禁用了"Resolving"日志行,因为我还以另一种方式记录了输出:

CREATING ./fib_worker.js 35
CREATING ./random_worker.js 3000
CREATING ./fib_worker.js 20
CREATING ./fib_worker.js 44
CREATING ./fib_worker.js 10
CREATING ./random_worker.js 2000
CREATING ./random_worker.js 1000
fib(10) 55
REUSING ./test_fib_worker.js 10
fib(11) 89
fib(20) 6765
fib(35) 9227465
random 602
random 135
random 17
fib(44) 701408733

斐波那契数列的调用结果按顺序返回;这是合乎逻辑的,因为我们知道它们的计算时间会增长。对随机工作者的 3 次调用花费的时间稍长,但少于计算第 44 个斐波那契数的时间。

注意,我们直到第 10 个斐波那契数的结果返回后才请求第 11 个斐波那契数。我们的池正确地检测到它有一个可用的工作者可以使用,并且没有创建一个新的工作者。

你可以探索一些额外的想法(见本章末尾的问题部分),但我们实现了一个高效的解决方案,使我们能够在并行中运行函数式代码并获得良好的性能;这是一个不错的收获!

摘要

在本章中,我们开始使用高阶函数(HOFs)来展示一种更声明性的工作方式,代码更短、更易于表达。我们讨论了几个操作:我们使用了reduce()reduceRight()从数组中获取单个结果,map()将函数应用于数组的每个元素,forEach()简化循环,flat()flatMap()处理数组中的数组,filter()从数组中选择元素,find()findIndex()在数组中进行搜索,以及every()some()(加上我们自己编造的none())来验证一般的逻辑条件。然后,我们考虑了一些处理async函数时可能出现的不预期情况,并为这些情况编写了特殊函数。最后,我们展示了如何以函数式的方式执行并行工作以获得额外的性能。

第六章 生成函数中,我们将继续使用高阶函数(HOFs),但我们将编写自己的函数来增强我们的编码表达力。

问题

5.1 filter()map()reduce()序列相当常见(即使有时你不会使用所有三个),我们将在第十一章**,实现设计模式功能设计模式部分再次回到这个问题。这里的问题是,如何使用这些函数(以及没有其他函数!)来生成一个无序列表元素(<ul>...</ul>),稍后可以在屏幕上使用。你的输入是一个字符数组,例如以下内容(这个列表让我显得很老吗?),你必须生成一个与象棋或跳棋玩家对应的每个名称的列表:

const characters = [
  { name: "Fred", plays: "bowling" },
  { name: "Barney", plays: "chess" },
  { name: "Wilma", plays: "bridge" },
  { name: "Betty", plays: "checkers" },
  .
  .
  .
  { name: "Pebbles", plays: "chess" },
];

输出将类似于以下内容(尽管如果你不生成空格和缩进也没有关系)。如果能使用join()会更容易,但在这个情况下,不允许这样做;只能使用提到的三个函数:

<div>
  <ul>
    <li>Barney</li>
    <li>Betty</li>
    .
    .
    .
    <li>Pebbles</li>
  </ul>
</div>;

5.2 更正式的测试:在一些先前的例子中,例如在使用 reduce()模拟 map()部分,我们没有编写实际的单元测试,而是满足于进行一些控制台日志记录。你能编写适当的单元测试吗?

5.3 reverseString2()函数,我们使用了求和函数进行减少,但我们已经在求和数组部分写了一个sum()函数;我们为什么不能在这里使用它呢?为什么不呢?我们该如何解决这个问题?

const reverseString2 = (str: string): string =>
  str.split("").reduceRight(sum, "");

5.4 reverseString2()函数(参见上一个问题),如果我们以相反的方式求和xy,写成这样?

const reversedReverse = (str: string): string =>
  str.split("").reduceRight((x, y) => y + x, "");

5.5 我们在这里看到的range()函数有很多用途,但缺乏一点通用性。你能扩展它以允许倒序范围,比如range(10,1)吗?(范围中的最后一个数字应该是什么?)你也能包括一个步长来指定范围内连续数字之间的差异吗?有了这个,range(1,10,2)将产生[1, 3, 5, 7, 9]

5.6 range()函数。不是首先生成一个包含所有数字的整个数组然后再处理它们,基于生成器的解决方案一次生成一个范围数字。你能提供这样的实现吗?

5.7 map(x => String.fromCharCode(x)),你写了map(String.fromCharCode)?你能解释不同的行为吗?提示:我们在本章的另一个地方遇到过类似的问题。

5.8 \n代表换行符:

let myData = [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]];
let myCSV = dataToCsv(myData);
// "1,2,3,4\n5,6,7,8\n9,10,11,12\n"

5.9 flat1()flat2()在应用于有空位的数组时工作正常,例如[22, , 9, , , 60, , ]。为什么它们能工作?

5.10 产生更好的输出:修改城市查询,以产生一个包含城市名称、州和国家的字符串列表。

5.11 仅限旧式代码!你能不用任何映射或减少操作重写单词计数解决方案吗?这更像是一个 JavaScript 问题,而不是一个函数式编程问题,但为什么不试试呢?

5.12 someArray,并应用以下filter(),乍一看甚至不像是有效的 JavaScript 代码。新数组中会有什么,为什么?

let newArray = someArray.filter(Boolean);

5.13 fact4(0)产生正确的结果,即 1 吗?为什么,或者为什么不呢?

5.14 ...Async()函数不是方法;你能修改它们并将它们添加到Array.prototype中,以便我们可以编写,例如,[1,2,3,4].mapAsync(…)?顺便问一下,你的解决方案能否支持链式调用?

5.15 forEach()map()filter()reduce()async等价函数,但我们没有为find()findIndex()some()every()做同样的事情;你能做到吗?

5.16 排空泳池:按照编码,工作者池的大小只能增加。你该如何防止它无限增长?尝试以下想法:每当有超过,比如说,10 个未使用的工作者时,从池中移除一些。

5.17 排队进入池:你不能同时运行无限数量的并行工作者。实现一个排队程序,以便所有调用都将被接受,但只有在使用中的工作者数量低于某个特定阈值时才会调用工作者。

5.18 上一个部分中的showResult()函数很有趣;它是如何工作的?这是一个返回函数的函数;FP(函数式编程)的一个最佳示例!

5.19 在长生命周期的池工作者部分,我们编写了以下内容——这是找到可用工作者的最佳方式吗?

  let available = pool
    .filter((v) => !v.inUse)
    .find((x) => x.filename === filename);

5.20 workerCall()更适合现实世界的问题吗?

第六章:生成函数 – 高阶函数

第五章声明式编程中,我们使用了一些声明式代码,以便我们能够提高可读性并获得更紧凑、更短的代码。在本章中,我们将进一步探讨高阶函数HOFs)并开发自己的。我们可以将我们预期得到的结果大致分为三类:

  • 包装函数:这些函数在保持原有功能的同时,添加了一些新的功能。在这个类别中,我们可以考虑日志记录(为任何函数添加日志生成能力)、计时(为特定函数生成时间和性能数据),以及函数和承诺的缓存(缓存结果以避免未来的重复工作)。

  • once() 函数(我们曾在第二章函数式思维中介绍过),它改变了原始函数,使其只运行一次;例如 not()invert() 函数,它们会改变函数的返回值;与函数的元数相关的转换,它们会生成一个具有固定参数数量的新函数;以及节流和去抖函数,用于提高性能。

  • 其他生成:这些提供新的操作,将函数转换为承诺,允许增强搜索功能,将方法从对象中解耦,将它们转换为普通函数,以及相反的操作,将函数转换为方法。我们将特别案例 – 转换器 – 留到第八章连接函数中讨论。

包装函数 – 保持行为

在本节中,我们将考虑一些高阶函数,它们为其他函数提供包装,以某种方式增强它们,但不会改变它们原有的目标。从设计模式的角度来看(我们将在第十一章实现设计模式中重新讨论),我们也可以谈论装饰器。这种模式基于向对象添加某些行为(在我们的情况下,是函数)而不影响其他对象的概念。装饰器这个术语也因其用于框架,如 Angular,或(在实验模式下)用于 JavaScript 的一般编程而流行。

等待装饰器

装饰器正在考虑在 JavaScript 中普遍采用。目前(截至 2022 年 12 月),它们处于第 3 阶段,候选级别,因此可能还需要一段时间才能达到第 4 阶段(完成,意味着“正式采用”)。您可以在tc39.github.io/proposal-decorators/上阅读有关装饰器提案的更多信息,以及关于 JavaScript 采用过程的更多信息,称为 TC39,请参阅tc39.es/process-document/。有关更多信息,请参阅第十一章**,实现设计模式中的问题部分。

至于术语包装器,它比你想象的更重要和普遍;事实上,JavaScript 广泛地使用它。在哪里?你已经知道对象属性和方法是通过点符号访问的。然而,你也知道你可以编写像myString.length22.9.toPrecision(5)这样的代码,那么这些属性和方法从何而来,鉴于字符串和数字都不是对象?JavaScript 实际上在你的原始值周围创建了一个包装对象。这个对象继承了适用于包装值的所有方法。一旦完成所需的评估,JavaScript 就会丢弃刚刚创建的包装器。我们无法对这些短暂的包装器做任何事情,但有一个概念我们将回到,关于允许在不是适当类型的事物上调用方法的包装器。这是一个有趣的想法;参见第十二章构建更好的容器,了解更多关于该技术的应用!

在本节中,我们将探讨三个示例:

  • 向函数添加日志

  • 从函数中获取时间信息

  • 使用缓存(记忆化)来提高函数的性能

让我们开始工作!

日志记录

让我们从一个问题开始。当调试代码时,你通常需要添加一些日志信息来查看函数是否被调用,使用了什么参数,返回了什么,等等。(是的,当然,你可以简单地使用调试器并设置断点,但请耐心听我解释这个例子!)正常工作,这意味着你将不得不修改函数本身的代码,在进入和退出时,以产生一些日志输出。例如,你的原始代码可能如下所示:

function someFunction(param1, param2, param3) {
  // do something
  // do something else
  // and a bit more,
  // and finally
  return some expression;
}

在这种情况下,你将不得不修改它,如下所示。在这里,我们需要添加一个auxValue变量来存储我们想要记录并返回的值:

function someFunction(param1, param2, param3) {
  console.log(
    "entering someFunction: ",
    param1,
    param2,
    param3
  );
  // do something
  // do something else
  // and a bit more,
  // and finally
  const auxValue = ...some expression... ;
  console.log("exiting someFunction: ", auxValue);
  return auxValue;
}

如果函数可以在多个地方返回,你必须修改所有的return语句来记录要返回的值。如果你只是即时计算返回表达式,你需要一个辅助变量来捕获那个值。

在下一节中,我们将学习关于日志记录和一些特殊情况,例如抛出异常的函数,我们将更加纯粹地工作。

以函数式方式记录日志

通过修改你的函数来记录日志并不困难,但修改代码总是危险的,容易出错。所以,让我们戴上我们的 FP 帽子,并考虑一种新的方法来做这件事。我们有一个执行一些工作的函数,我们想知道它接收到的参数和返回的值。

在这里,我们可以编写一个 HOF(高阶函数),它将有一个单一参数——原始函数——并返回一个新的函数,该函数将按以下顺序执行:

  1. 记录接收到的参数。

  2. 调用原始函数,捕获其返回值。

  3. 记录那个值。

  4. 返回给调用者。

一个可能的解决方案如下,让我们首先使用纯 JavaScript 来关注实现:

// logging.ts
function addLogging(fn) {
  return (...args) => {
    console.log(`entering ${fn.name}(${args})`);
    const valueToReturn = fn(...args);
    console.log(`exiting  ${fn.name}=>${valueToReturn}`);
    return valueToReturn;
  };
}

addLogging()返回的函数表现如下:

  • 第一行console.log(...)显示了原始函数的名称及其参数列表。

  • 然后,调用原始函数fn(),并将返回值存储起来。第二行console.log(...)显示了函数名(再次)及其返回值。

  • 最后,返回fn()计算出的值。

一个小注释:如果你是在 Node.js 应用程序中做这件事,你可能会选择比console.log()更好的日志记录方式,比如使用WinstonMorganBunyan等库,具体取决于你想要记录什么。然而,我们的重点是了解如何包装原始函数,使用这些库所需的变化可以忽略不计。

现在让我们转向 TypeScript 实现:

// continued...
function addLogging<T extends (...args: any[]) => any>(
  fn: T
): (...args: Parameters<T>) => ReturnType<T> {
  return (...args: Parameters<T>): ReturnType<T> => {
    console.log(`entering ${fn.name}(${args})`);
    const valueToReturn = fn(...args);
    console.log(`exiting  ${fn.name} => ${valueToReturn}`);
    return valueToReturn;
  };
}

我们的addLogging()函数适用于泛型函数T类型,并返回一个与原始函数类型完全相同的新函数:它的参数(Parameters<T>)是T的参数,它的结果(ReturnType<T>)也是与T相同的类型。我们将在本章和本书的其余部分多次使用这种定义。

现在让我们举一个例子。我们可以使用addLogging()与即将到来的函数——我同意,这些函数的编写方式过于复杂,只是为了有一个合适的例子!我们将有一个通过改变第二个数的符号然后加到第一个数上来完成减法的函数。而且,为了有一个错误情况,如果尝试从零减去,我们将让该函数抛出错误。(是的,当然,你可以从另一个数中减去零!但我无论如何都想有一种抛出错误的情况!)以下代码就是这样做的:

// continued...
function subtract(a: number, b: number): number {
  if (b === 0) {
    throw new Error("We don't subtract zero!");
  } else {
    b = changeSign(b);
    return a + b;
  }
}
let changeSign = (a: number): number => -a;
// @ts-expect-error We want to reassign the function
subtract = addLogging(subtract);
subtract(8, 3);
console.log(); // to separate
changeSign = addLogging(changeSign);
subtract(7, 5);

那个@ts-expect-error注释是什么意思?TypeScript 拒绝以下行的赋值,说“不能将subtract赋值,因为它是一个函数.ts(2630)。这种禁止可以保持代码的安全性,但既然我们非常确定我们不会更改subtract()`的类型,我们可以包含这个注释,TypeScript 会让我们绕过它。

执行此操作的结果将是以下日志行:

entering subtract(8,3)
exiting  subtract => 5
entering subtract(7,5)
entering changeSign(5)
exiting  changeSign => -5
exiting  subtract => 2

我们在代码中必须做的所有更改都是重新分配subtract()changeSign(),这实际上是在所有地方用它们新的日志生成包装版本替换了它们。对这两个函数的任何调用都将产生这个输出。

这对大多数函数都很好,但如果包装函数抛出异常会发生什么呢?让我们看看。

考虑异常情况

让我们通过考虑一个调整来增强我们的日志记录函数。如果你的日志在函数抛出错误时会发生什么?幸运的是,这很容易解决。我们必须添加一个try/catch结构,如下面的代码所示:

// continued...
function addLogging2<T extends (...args: any[]) => any>(
  fn: T
): (...args: Parameters<T>) => ReturnType<T> {
  return (...args: Parameters<T>): ReturnType<T> => {
    console.log(`entering ${fn.name}(${args})`);
    try {
      const valueToReturn = fn(...args);
      console.log(`exiting  ${fn.name}=>${valueToReturn}`);
      return valueToReturn;
    } catch (thrownError) {
      console.log(
        `exiting  ${fn.name}=>threw ${thrownError}`
      );
      throw thrownError;
    }
  };
}

通过这个更改,如果函数抛出错误,你也会得到适当的日志消息,异常将被重新抛出以进行处理。以下是一个快速演示:

try {
  subtract2(11, 0);
} catch (e) {
  /* nothing */
}
/*
entering subtract(11,0)
exiting  subtract=>threw Error: We don't subtract zero!
*/

要获得更好的日志输出,其他更改将取决于你——添加日期和时间数据,增强参数的列表方式等等。然而,我们的实现仍然有一个重要的缺陷;让我们让它变得更好、更纯粹。

以更纯粹的方式工作

当我们编写addLogging()函数时,我们忽略了一些在第四章,“正确行为”中看到的教条,因为我们把一个不纯的元素(console.log())包含在我们的代码中。因此,我们不仅失去了灵活性(你能否选择另一种日志记录方式?),而且也使我们的测试变得复杂。我们可以通过监视console.log()方法来测试它,但这并不干净:我们依赖于了解我们想要测试的函数的内部结构,而不是进行纯粹的黑盒测试。请看以下示例以获得更清晰的理解:

// logging.test.ts
import { addLogging2 } from "./logging";
describe("a logging function", function () {
  afterEach(() => {
    // so count of calls to Math.random will be OK
    jest.restoreAllMocks();
  });
  it("should log twice with well behaved functions", () => {
    jest.spyOn(global.console, "log");
    let something = (a: number, b: number): string =>
      `result=${a}:${b}`;
    something = addLogging2(something);
    something(22, 9);
    expect(global.console.log).toHaveBeenCalledTimes(2);
    expect(global.console.log).toHaveBeenNthCalledWith(
      1,
      "entering something(22,9)"
    );
    expect(global.console.log).toHaveBeenNthCalledWith(
      2,
      "exiting  something=>result=22:9"
    );
  });
  it("should report a thrown exception", () => {
    jest.spyOn(global.console, "log");
    let subtractZero = (x: number) => subtract(x, 0);
    subtractZero = addLogging2(subtractZero);
    expect(() => subtractZero(10)).toThrow();
    expect(global.console.log).toHaveBeenCalledTimes(2);
    expect(global.console.log).toHaveBeenNthCalledWith(
      1,
      "entering subtractZero(10)"
    );
    expect(global.console.log).toHaveBeenNthCalledWith(
      2,
      "exiting  subtractZero=>threw Error: We don't subtract zero!"
    );
  });
  });
});

运行这个测试表明addLogging()的行为符合预期,因此这是一个解决方案。我们的第一个测试只是进行简单的减法运算,并验证是否以适当的数据调用了日志记录。第二个测试检查我们的(故意失败的)subtract()函数是否抛出错误,以验证是否生成了正确的日志。

即使如此,能够以这种方式测试我们的函数并不能解决我们提到的缺乏灵活性。我们应该注意我们在第四章中“正确行为”部分提到的内容,在第四章,“注入不纯函数”;日志函数应该作为参数传递给包装函数,这样我们就可以在需要时更改它:

// logging3.ts
function addLogging3<T extends (...args: any[]) => any>(
  fn: T,
  logger = console.log.bind(console)
): (...args: Parameters<T>) => ReturnType<T> {
  return (...args: Parameters<T>): ReturnType<T> => {
    logger(`entering ${fn.name}(${args})`);
    try {
      const valueToReturn = fn(...args);
      logger(`exiting  ${fn.name}=>${valueToReturn}`);
      return valueToReturn;
    } catch (thrownError) {
      logger(`exiting  ${fn.name}=>threw ${thrownError}`);
      throw thrownError;
    }
  };
}

如果我们不采取任何行动,日志包装器将产生与上一节相同的结果。然而,我们可以提供一个不同的记录器——例如,使用 Node.js,我们可以使用winston日志工具(有关更多信息,请参阅github.com/winstonjs/winston),结果将相应变化:

// continued...
function subtract(...) { ... }
let changeSign = ... ;
// @ts-expect-error We want to reassign the function
subtract = addLogging3(subtract, myLogger);
subtract(8, 3);
console.log(); // to separate
changeSign = addLogging3(changeSign, myLogger);
subtract(7, 5);
/*
{"level":"debug","message":"entering subtract(8,3)"}
{"level":"debug","message":"exiting  subtract=>5"}
{"level":"debug","message":"entering subtract(7,5)"}
{"level":"debug","message":"entering changeSign(5)"}
{"level":"debug","message":"exiting  changeSign=>-5"}
{"level":"debug","message":"exiting  subtract=>2"}
*/

默认情况下,日志格式是 JSON。通常会将它路由到文件进行存储,因此它不如控制台输出清晰,但如果我们需要的话,可以重新格式化使其更易读。然而,这对我们的示例来说已经足够了,我们不会做任何其他的事情。

现在我们已经遵循了自己的建议,我们可以利用存根。测试代码实际上与之前相同;然而,我们正在使用一个没有任何提供功能或副作用dummy.logger()存根,因此从各方面来说都更安全。在这种情况下,最初被调用的实际函数console.log()不会造成任何伤害,但并不总是这样,因此使用存根是推荐的:

// logging3.test.ts
import { addLogging3 } from "./logging3";
describe("addLogging3()", function () {
  it("should call the provided logger", () => {
    const logger = jest.fn();
    let something = (a: number, b: number): string =>
      `result=${a}:${b}`;
    something = addLogging3(something, logger);
    something(22, 9);
    expect(logger).toHaveBeenCalledTimes(2);
    expect(logger).toHaveBeenNthCalledWith(
      1,
      "entering something(22,9)"
    );
    expect(logger).toHaveBeenNthCalledWith(
      2,
      "exiting  something=>result=22:9"
    );
  });
  it("a throwing function should be reported", () => {
    const logger = jest.fn();
    let thrower = () => {
      throw "CRASH!";
    };
    thrower = addLogging3(thrower, logger);
    try {
      thrower();
    } catch (e) {
      expect(logger).toHaveBeenCalledTimes(2);
      expect(logger).toHaveBeenNthCalledWith(
        1,
        "entering thrower()"
      );
      expect(logger).toHaveBeenNthCalledWith(
        2,
        "exiting  thrower=>threw CRASH!"
      );
    }
  });
});

前面的测试与我们在之前编写的测试完全一样(尽管,为了多样性,在以更纯净的方式工作部分,我们使用了expect(…).toThrow(),而在这里我们使用了try/catch结构来测试抛出错误的函数)。我们使用了并检查了虚拟日志记录器,而不是处理原始的console.log()调用。以这种方式编写测试避免了所有可能由于副作用引起的问题,因此它更干净、更安全。

在应用函数式编程(FP)技术时,请记住,如果你在以某种方式使自己的工作复杂化——例如,使测试任何函数变得困难——那么你肯定是在做错事。在我们的例子中,仅仅因为addLogging()的输出是一个不纯函数,就应该引起警觉。当然,鉴于代码的简单性,在这个特定情况下,你可能会决定修复它不值得,你可以不进行测试,而且你不需要能够改变日志生成的方式。然而,长期的软件开发经验表明,迟早你会后悔这种决定,所以尽量选择更干净的解决方案。

现在我们已经处理了日志记录,我们将看看另一个需求:出于性能原因对函数进行计时。

计时函数

包装函数的另一个可能的应用是以完全透明的方式记录和记录每个函数调用的计时。简单来说,我们希望能够知道函数调用花费了多长时间,这很可能是为了性能研究。然而,就像我们处理日志一样,我们不想修改原始函数,而将使用高阶函数(HOF)。

优化的三条规则

如果你计划优化你的代码,请记住以下三条规则:不要做不要急于做不要在没有测量的情况下做。已经提到,很多糟糕的代码都源于早期的优化尝试,所以不要试图编写最优化的代码,不要在你认识到需要它之前尝试优化,不要在没有尝试通过测量应用程序的所有部分来确定减速原因的情况下随意进行。

沿着前面的例子,我们可以编写一个addTiming()函数,它给定任何函数,将产生一个包装版本,该版本将在控制台上输出计时数据,但除此之外将以完全相同的方式工作。数据类型与我们在上一节中看到的内容非常相似,所以让我们立即编写 TypeScript 代码:

const myGet = (): number => performance.now();
const myPut = (
  text: string,
  name: string,
  tStart: number,
  tEnd: number
): void =>
  console.log(`${name} - ${text} ${tEnd - tStart} ms`);
function addTiming<T extends (...args: any[]) => any>(
  fn: T,
  { getTime, output } = {
    getTime: myGet,
    output: myPut,
  }
): (...args: Parameters<T>) => ReturnType<T> {
  return (...args: Parameters<T>): ReturnType<T> => {
    const tStart = getTime();
    try {
      const valueToReturn = fn(...args);
      output("normal exit", fn.name, tStart, getTime());
      return valueToReturn;
    } catch (thrownError) {
      output("exception!!", fn.name, tStart, getTime());
      throw thrownError;
    }
  };
}

沿着我们在上一节中对日志功能进行的增强,我们提供了独立的日志记录器和时间访问函数。鉴于我们可以注入不纯函数,为我们的addTiming()函数编写测试应该很容易。

我们可以在这里看到它是如何工作的:

// continued...
function subtract(...) { ... }
let changeSign = ... ;
// @ts-expect-error We want to reassign the function
subtract = addTiming(subtract, myLogger);
subtract(8, 3);
console.log(); // to separate
changeSign = addTiming(changeSign, myLogger);
subtract(7, 5);
/*
subtract - normal exit 0.0217440128326416 ms
changeSign - normal exit 0.0014679431915283203 ms
subtract - normal exit 0.0415341854095459 ms
*/

准确性很重要

使用performance.now()提供最高的精度。如果你不需要该函数提供的那么高的精度(可以说,这是过度杀鸡用牛刀),你可以使用Date.now()代替。有关这些替代方案,请参阅developer.mozilla.org/en-US/docs/Web/API/Performance/nowdeveloper.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Date/now。考虑使用console.time()console.timeEnd();有关更多信息,请参阅developer.mozilla.org/en-US/docs/Web/API/Console/time

之前的代码与之前的addLogging()函数非常相似,这是合理的:在两种情况下,我们都在实际函数调用之前添加一些代码,然后在函数返回之后添加一些新代码。你甚至可以考虑编写一个更高阶的 HOF(高阶函数),它将接收三个函数并输出一个新的 HOF(例如addLogging()addTiming()),该 HOF 会在开始时调用第一个函数,如果包装函数返回了一个值,则调用第二个函数,如果抛出了错误,则调用第三个函数!怎么样?

缓存函数

第四章“行为规范”中,我们考虑了斐波那契函数的情况,并学习了如何通过手动操作将其转换为使用缓存的更高效版本:缓存计算值以避免重复计算。一个缓存过的函数会在之前找到结果的情况下避免重复执行过程。我们希望能够将任何函数转换为缓存过的版本,以便获得更优化的版本。然而,现实中的缓存解决方案还应考虑可用的 RAM,并有一些避免填满它的方法;然而,这超出了本书的范围,我们也不会探讨性能问题;这些优化也超出了本书的范围。

关于框架和缓存

一些缓存功能由 React(useMemo()钩子)或 Vue(v-memo指令)等工具提供,但这并不完全相同。在这些情况下,只保留前一个结果,如果值发生变化,则避免重新渲染。在我们讨论的这种缓存方式中,所有前一个值都会被缓存以供重用;React 和 Vue 只缓存一个值。

为了简化,我们只考虑具有单个非结构化参数的函数,并将具有更复杂参数(对象和数组)或多个参数的函数留待以后讨论。我们可以轻松处理的值类型是 JavaScript 的原始值:不是对象且没有方法的日期。JavaScript 有六种这样的值:booleannullnumberstringsymbolundefined。通常,我们只看到前四种作为实际参数。你可以通过访问 developer.mozilla.org/en-US/docs/Glossary/Primitive 了解更多。

我们的目标不是产生最佳的缓存化解决方案,但让我们研究一下这个主题,并产生几个缓存化高阶函数(HOF)的变体。首先,我们将处理具有单个参数的函数,然后考虑具有多个参数的函数。

简单缓存化

我们将使用之前提到的斐波那契函数,这是一个简单的情况:它接收一个数值参数。该函数如下所示:

// fibonacci.ts
function fib(n: number): number {
  if (n == 0) {
    return 0;
  } else if (n == 1) {
    return 1;
  } else {
    return fib(n - 2) + fib(n - 1);
  }
}

我们之前创建的解决方案在概念上是通用的,但在实现上并不特别出色:我们必须直接修改函数的代码来利用这种缓存化。让我们看看如何自动完成这项工作,就像其他包装函数一样。解决方案将是一个 memoize() 函数,它将包装任何其他函数以应用缓存化。为了清晰起见,让我们首先使用 JavaScript,并且只针对具有单个数值参数的函数:

// memoize.ts
const memoize = (fn) => {
  const cache = {};
  return (x) =>
    x in cache ? cache[x] : (cache[x] = fn(x));
};

这是如何工作的?返回的函数对于任何给定的参数,都会检查该参数是否已经被接收;也就是说,是否可以在缓存对象中找到它作为键。(参见 问题 6.2 了解缓存的不同实现。)如果是这样,则不需要计算,直接返回缓存值。否则,我们计算缺失的值并将其存储在缓存中。(我们使用闭包来隐藏缓存以防止外部访问。)在这里,我们假设缓存化的函数只接收一个参数(x)并且它是一个数值,这样就可以直接用作缓存对象的键值;我们稍后会考虑其他情况。

现在,我们需要转向 TypeScript;这是 memoize() 的等效版本。泛型数据类型与我们在 以函数式方式记录 部分中看到的是一样的,唯一的区别是现在我们处理的是接收单个数值参数的函数:

// continued...
const memoize = <T extends (x: number) => any>(
  fn: T
): ((x: number) => ReturnType<T>) => {
  const cache = {} as Record<number, ReturnType<T>>;
  return (x) =>
    x in cache ? cache[x] : (cache[x] = fn(x));
};

缓存化是否有效?我们需要对其进行计时——幸运的是,我们有一个有用的 addTiming() 函数来做这件事!首先,我们计时原始的 fib() 函数。我们想要计时整个计算过程,而不是每个递归调用,因此我们编写了一个辅助的 testFib() 函数,这是我们将会计时的函数。

我们应该重复计时操作并计算平均值,但既然我们只想确认缓存化是否有效,我们可以容忍差异:

const testFib = (n: number) => fib(n);
addTiming(testFib)(45); // 18,957 ms
addTiming(testFib)(40); //  1,691 ms
addTiming(testFib)(35); //    152 ms

当然,你的时间将取决于你的具体 CPU、RAM 等等。然而,结果似乎是有逻辑的:我们在第四章中提到的指数增长似乎存在,时间增长得很快。现在,让我们记忆化fib()。我们应该得到更短的时间……不是吗?

const testMemoFib = memoize((n: number) => fib(n));
addTiming(testMemoFib)(45); // 19,401 ms
addTiming(testMemoFib)(45); //  0.005 ms – good!
addTiming(testMemoFib)(40); //  2,467 ms  ???
addTiming(testMemoFib)(35); //    174 ms  ???

出了点问题!时间应该下降,但它们几乎是一样的。这是因为一个常见的错误,我甚至在一些文章和网页中见过。我们在计时testMemoFib(),但除了计时之外,没有人调用那个函数,而计时只发生一次!内部,所有的递归调用都是到fib(),它没有被记忆化。如果我们再次调用testMemoFib(45)那个调用将被缓存,并且它将几乎立即返回,但这个优化不适用于内部的fib()调用。这就是为什么testMemoFib(40)testMemoFib(35)的调用没有被优化——当我们计算testMemoFib(45)时,那才是唯一被缓存的价值。

正确的解决方案如下:

fib = memoize(fib);
addTiming(testFib)(45); // 0.1481 ms
addTiming(testFib)(45); // 0.0022 ms
addTiming(testFib)(40); // 0.0019 ms
addTiming(testFib)(35); // 0.0029 ms

现在,当计算fib(45)时,所有的中间斐波那契值(从fib(0)fib(45)本身)都被存储起来,所以接下来的调用实际上没有多少工作要做。

现在我们知道了如何记忆化单参数函数,让我们看看多参数函数。

更复杂的记忆化

如果我们必须与一个接收两个或更多参数的函数一起工作,或者可以接收数组或对象作为参数,我们能做什么呢?当然,就像我们在第二章中讨论的“函数只做一次工作”的问题一样,我们可以简单地忽略这个问题:如果需要记忆化的函数是一元函数,我们就进行记忆化过程;否则,我们不做任何事情!

关于函数长度的讨论

函数的参数数量被称为函数的arity,或valence,JavaScript 将其作为函数的length属性提供;参见developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/length。你可以用三种不同的方式说话:你可以说你有一个一元、二元、三元……的函数;你也可以说它是一元、二元、三元……;或者你可以说你有一个单子、二元、三元……。随便选一个!

我们的第一种尝试可能是只记忆化一元函数,其余的保持不变,如下面的代码所示:

// continued...
const memoize2 = <
  T extends (x: number, ...y: any[]) => any
>(
  fn: T
): ((x: number, ...y: any[]) => ReturnType<T>) => {
  if (fn.length === 1) {
    const cache = {} as Record<number, ReturnType<T>>;
    return (x) =>
      x in cache ? cache[x] : (cache[x] = fn(x));
  } else {
    return fn;
  }
};

更加认真地工作,如果我们想要能够记忆化任何函数,我们必须找到一种方法来生成缓存键。为此,我们必须找到一种方法将任何参数转换为字符串。我们不能直接使用非原始值作为缓存键。我们可以尝试使用strX = String(x)之类的将值转换为字符串,但我们会遇到问题。对于数组来说,这似乎可以工作。然而,看看以下三个涉及不同数组但带有转折的案件:

var a = [1, 5, 3, 8, 7, 4, 6];
String(a); // "1,5,3,8,7,4,6"
var b = [[1, 5], [3, 8, 7, 4, 6]];
String(b); // "1,5,3,8,7,4,6"
var c = [[1, 5, 3], [8, 7, 4, 6]];
String(c); // "1,5,3,8,7,4,6"

这三个案例产生了相同的结果。如果我们只考虑单个数组参数,我们可能可以应付,但当不同的数组产生相同的键时,这就成为一个问题。如果我们必须接收对象作为参数,情况会更糟,因为任何对象的String()表示法不可避免地是"[object Object]"

var d = {a: "fk"};
String(d); // "[object Object]"
var e = [{p: 1, q: 3}, {p: 2, q: 6}];
String(e); // "[object Object],[object Object]"

最简单的解决方案是使用JSON.stringify()将我们收到的任何参数转换为有用的、独特的字符串:

var a = [1, 5, 3, 8, 7, 4, 6];
JSON.stringify(a); // "[1,5,3,8,7,4,6]"
var b = [[1, 5], [3, 8, 7, 4, 6]];
JSON.stringify(b); // "[[1,5],[3,8,7,4,6]]"
var c = [[1, 5, 3], [8, 7, 4, 6]];
JSON.stringify(c); // "[[1,5,3],[8,7,4,6]]"
var d = {a: "fk"}; JSON.stringify(d); // "{"a":"fk"}"
var e = [{p: 1, q: 3}, {p: 2, q: 6}];
JSON.stringify(e); // "[{"p":1,"q":3},{"p":2,"q":6}]"

为了性能,我们的逻辑应该是这样的:如果我们正在记忆化的函数接收到的参数是一个原始值,我们可以直接使用该参数作为缓存键。在其他情况下,我们会使用对参数数组应用JSON.stringify()的结果。我们增强的记忆化高阶函数(HOF)可能如下所示:

// continued...
const memoize3 = <T extends (...x: any[]) => any>(
  fn: T
): ((...x: Parameters<T>) => ReturnType<T>) => {
  const cache = {} as Record<
    number | string,
    ReturnType<T>
  >;
  const PRIMITIVES = ["number", "string"];
  return (...args) => {
    const strX: number | string =
      args.length === 1 &&
      PRIMITIVES.includes(typeof args[0])
        ? args[0]
        : JSON.stringify(args);
    return strX in cache
      ? cache[strX]
      : (cache[strX] = fn(...args));
  };
};

在通用性方面,这是最安全的版本。如果你确定你将要处理的函数的参数类型,那么可以说我们的第一个版本更快。另一方面,如果你想编写更容易理解的代码,即使是以一些浪费的 CPU 周期为代价,你也可以选择一个更简单的版本:

// continued...
const memoize4 = <T extends (...x: any[]) => any>(
  fn: T
): ((...x: Parameters<T>) => ReturnType<T>) => {
  const cache = {} as Record<string, ReturnType<T>>;
  return (...args) => {
    const strX = JSON.stringify(args);
    return strX in cache
      ? cache[strX]
      : (cache[strX] = fn(...args));
  };
};

速度者的诞生

如果你想了解顶级性能记忆化函数的开发,请阅读 Caio Gondim 的我如何编写世界上最快的 JavaScript 记忆化库文章,该文章可在网上找到,链接为blog.risingstack.com/the-worlds-fastest-javascript-memoization-library/

到目前为止,我们已经实现了几个有趣的记忆化函数,但我们将如何为它们编写测试?现在让我们分析这个问题。

记忆化测试

测试记忆化高阶函数(HOF)提出了一个有趣的问题——你将如何着手?第一个想法可能是查看缓存,但它是私有的且不可见的。然后,当然,我们可以修改memoize()使其使用全局缓存或以某种方式允许外部访问缓存,但这种内部审查是不被提倡的:你应该尝试仅基于外部属性进行测试。

承认我们不应该尝试检查缓存后,我们可以尝试时间控制:对于大数值的 n 调用函数 fib(n) 应该花费更长的时间,如果函数没有被记忆化。这当然是有可能的,但它也容易受到可能的失败的影响:测试之外的东西可能在完全错误的时间运行,而且有可能你的记忆化运行会比原始运行更长。好吧,这是可能的,但不太可能——但你的测试并不完全可靠。

我们可以思考计算一些斐波那契数并测试函数被调用的次数——一次直接调用,其余的调用都是由于递归。有关更多内容,请参阅 问题 6.3。前面的代码相当直接:我们正在使用我们之前开发的斐波那契函数,并测试它是否产生正确的值。例如,我们可以发现计算 fib(6) 需要 25 次调用,通过重新查看我们在 第四章 中看到的图表,“行为正确”,我们可以看到其中包含 25 个节点(每个节点代表对 fib() 的一个调用):

图 6.1 – 计算 fib(6) 需要 25 次调用

图 6.1 – 计算 fib(6) 需要 25 次调用

第一个想法是计数,如这里所示——但这不会起作用!

// memoize.test.ts
import { fib } from "./fibonacci";
import * as moduleFib from "./fibonacci";
describe("the original fib", function () {
  it("should repeat calculations", () => {
    jest.spyOn(moduleFib, "fib");
    expect(fib(6)).toBe(8);
    expect(fib).toHaveBeenCalledTimes(25);
  });
});

我们首先计算 fib(6)——它正确地返回 8——然后我们检查应该有 25 次对 fib() 的调用,但只找到一次;发生了什么?问题出在 Jest 上:当你监视一个函数时,你实际上是在监视一个调用你想要检查的函数的包装器。这个包装器函数只被调用了一次;我们的 fib() 函数被调用了 25 次,但 Jest 没有看到这一点!

我们不得不以一种非常非功能性的方式来做些别的事情!让我们测试一个修改过的 fib() 函数,它将更新一个外部计数器;我们将其称为 fibM()

// continued...
describe("the modified fib", function () {
  it("should repeat calculations", () => {
    let count = 0;
    const fibM = (n: number): number => {
      count++;
      if (n == 0) {
        return 0;
      } else if (n == 1) {
        return 1;
      } else {
        return fibM(n - 2) + fibM(n - 1);
      }
    };
    expect(fibM(6)).toBe(8);
    expect(count).toBe(25);
  });
});

现在测试通过了,我们再测试一下记忆化、修改过的版本怎么样?在这种情况下,由于缓存的结果,调用次数应该会更低。实际上,它应该是 7,因为我们需要从 fib(6)fib(0) 的所有值:

// continued...
describe("the memoized, modified fib", function () {
  it("should repeat calculations", () => {
    let count = 0;
    const fibMM = memoize((n: number): number => {
      count++;
      if (n == 0) {
        return 0;
      } else if (n == 1) {
        return 1;
      } else {
        return fibMM(n - 2) + fibMM(n - 1);
      }
    });
    expect(fibMM(6)).toBe(8);
    expect(count).toBe(7);
  });

在本节中,我们处理了几个示例,这些示例暗示了包装函数,以便它们能够继续工作,但增加了额外的功能。现在,让我们看看一个不同的案例,其中我们想要改变函数的工作方式。

记忆化承诺

让我们更进一步,考虑记忆化返回 promise 的async函数。在一个复杂的 Web 应用程序中,有许多相关的组件,完全有可能出现没有良好理由的冗余、重复的 API 调用,这会损害性能并产生糟糕的用户体验。例如,想象一个具有多个标签的仪表板风格的 Web 页面。每次用户选择一个标签时,都会发出几个 API 调用以获取页面所需的数据。然而,如果用户选择不同的标签但后来又返回到第一个标签,相同的 API 调用将再次发出。对于许多应用程序来说,数据基本上是恒定的,意味着“不是实时变化的。”因此,你不需要重新发送 API 调用;重用之前获取的数据也行。

一些解决方案并不实用:我们可以修改服务器以启用缓存,但如果这不可能呢?或者我们可以使用缓存,在每次调用之前检查数据是否已经被获取,但这将涉及手动修改每个 API 调用以首先检查缓存!我们想要一个不需要代码更改的解决方案,而记忆化(memoizing)就浮现在脑海中。

假设我们使用返回 promise 的async函数调用 API。鉴于我们开发的memoize()函数,我们可以记忆化async函数,这将是一个开始。第一次用一些参数调用该函数时,API 调用将会发出,并返回一个 promise(因为这是函数返回的内容)。如果你再次用相同的参数调用该函数,记忆化的 promise 将会立即返回。太好了!但是有一个问题……如果 API 调用失败会怎样?我们需要添加一些错误捕获逻辑:

// memoize.ts
const promiseMemoize = <
  A,
  T extends (...x: any[]) => Promise<A>
>(
  fn: T
): ((...x: Parameters<T>) => Promise<A>) => {
  const cache = {} as Record<string, Promise<A>>;
  return (...args) => {
    const strX = JSON.stringify(args);
    return strX in cache
      ? cache[strX]
      : (cache[strX] = fn(...args).catch((x) => {
          delete cache[strX];
          return x;
        }));
  };
};

所有逻辑与之前相同,但有几点额外的细节:

  • 我们现在明确指出,记忆化的函数返回一个泛型类型A的 promise (Promise<A>)

  • 如果 promise 被拒绝,我们添加代码来删除缓存的 promise,以便未来的调用将再次发出

我们的新promiseMemoize()函数可以处理错误,允许未来重试被拒绝的调用;很好!现在让我们看看另一个不同的情况,我们想要改变函数的实际工作方式。

修改函数的行为

在上一节中,我们考虑了一些封装函数的方法,以便它们在某种程度上得到增强的同时,仍能保持其原始功能。现在,我们将转向修改函数的行为,以便新的结果与原始结果不同。

我们将涵盖以下主题:

  • 再次探讨函数只工作一次的问题

  • 取反或反转函数的结果

  • 改变函数的阶数

  • 函数节流和去抖动以提高性能

让我们开始吧!

一次性完成任务,再次探讨

在 (第二章函数式思考) 中,我们通过一个例子了解了如何为简单问题开发函数式编程风格的解决方案:确保一个给定的函数只被调用一次。我们当时使用箭头函数定义了 once();让我们为了多样性使用一个标准函数:

// once.ts
function once<T extends (...args: any[]) => void>(
  f: T
): (...args: Parameters<T>) => void {
  let done = false;
  return ((...args: Parameters<T>) => {
    if (!done) {
      done = true;
      f(...args);
    }
  }) as T;
}

这是一个完全可接受的解决方案;它运行良好,我们没有任何反对意见。然而,我们可以考虑一个变体。我们可以观察到给定的函数只被调用了一次,但其返回值丢失了。这很容易修复:我们需要添加一个 return 语句。然而,这还不够;如果函数被多次调用,它应该返回什么?我们可以借鉴记忆化解决方案,为未来的调用存储函数的返回值。

让我们将函数的值存储在 result 变量中,以便我们可以在以后返回它:

// continued...
function once2<T extends (...args: any[]) => any>(
  f: T
): (...args: Parameters<T>) => ReturnType<T> {
  let done = false;
  let result: ReturnType<T>;
  return ((...args: Parameters<T>): ReturnType<T> => {
    if (!done) {
      done = true;
      result = f(...args);
    }
    return result;
  }) as T;
}

第一次调用函数时,其值存储在 result 中;后续的调用只需返回该值,无需进行进一步处理。你也可以考虑使函数只对每一组参数工作一次。你不必为此做任何工作——memoize() 就足够了!

第二章产生更好的解决方案 部分 (Chapter 2函数式思考) 中,我们考虑了 once() 的一个可能的替代方案:另一个接受两个函数作为参数的高阶函数(HOF),允许第一个函数只被调用一次,从那时起调用第二个函数。在之前的代码中添加一个 return 语句并将其重写为一个标准函数,结果如下:

// continued...
function onceAndAfter<T extends (...args: any[]) => any>(
  f: T,
  g: T
): (...args: Parameters<T>) => ReturnType<T> {
  let done = false;
  return ((...args: Parameters<T>): ReturnType<T> => {
    if (!done) {
      done = true;
      return f(...args);
    } else {
      return g(...args);
    }
  }) as T;
}

如果我们记得函数是一阶对象,我们可以重写这个例子。而不是使用一个标志来记住要调用哪个函数,我们可以使用一个 toCall 变量直接存储需要被调用的函数。从逻辑上讲,该变量将被初始化为第一个函数,但随后将更改为第二个函数。以下代码实现了这一变化:

// continued...
function onceAndAfter2<T extends (...args: any[]) => any>(
  f: T,
  g: T
): (...args: Parameters<T>) => ReturnType<T> {
  let toCall = f;
  return ((...args: Parameters<T>): ReturnType<T> => {
    let result = toCall(...args);
    toCall = g;
    return result;
  }) as T;
}

toCall 变量被初始化为 f,因此第一次调用将会执行 f(),但随后 toCall 获取了 g 的值,这意味着所有后续的调用都将执行 g()。我们之前在这本书中看到的相同示例仍然有效:

const squeak = (x: string) => console.log(x, "squeak!!");
const creak = (x: string) => console.log(x, "creak!!");
const makeSound = onceAndAfter2(squeak, creak);
makeSound("door"); // "door squeak!!"
makeSound("door"); // "door creak!!"
makeSound("door"); // "door creak!!"
makeSound("door"); // "door creak!!"

在性能方面,差异可能微不足道。展示这种进一步变体的原因是为了表明你应该记住,通过存储函数,你通常可以更简单地产生结果。在过程编程中使用标志来存储状态是一种常见的技术。然而,在这里,我们设法跳过了这种用法并产生了相同的结果。现在,让我们看看一些新的示例,展示如何包装函数以改变它们的行为。

逻辑否定一个函数

让我们考虑来自 第五章声明式编程filter() 方法。给定一个谓词,我们可以过滤数组,只包含那些谓词为真的元素。但你是如何进行反向过滤并排除那些谓词为真的元素的?

第一个解决方案应该是相当明显的:重新编写谓词以返回其原本返回值的相反。在提到的章节中,我们看到了以下示例:

// not.ts
const delinquent = serviceResult.accountsData.filter(
  (v) => v.balance < 0
);

(有关 serviceResult 对象,请参阅前一章中的 filter() 示例 部分。)

因此,我们可以以相反的方式编写它,以这两种等效的方式之一。注意以下不同方式来编写相同的谓词以测试非负值:

// continued...
const notDelinquent = serviceResult.accountsData.filter(
  (v) => v.balance >= 0
);
const notDelinquent2 = serviceResult.accountsData.filter(
  (v) => !(v.balance < 0)
);

这完全没问题,但我们的代码中也可以有类似以下的内容:

// continued...
const isNegativeBalance = (v: AccountData) => v.balance < 0;
.
. many lines later
.
const delinquent2 = serviceResult.accountsData.filter(
  isNegativeBalance
);

在这种情况下,重写原始的 isNegativeBalance() 函数是不可能的。(另一种可能性:该函数可能定义在单独的模块中,你无法或不应修改。)然而,以函数式的方式工作,我们可以编写一个高阶函数,它将接受任何谓词,评估它,然后否定其结果。可能的实现相当简单,多亏了现代 JavaScript 语法 – 对于 TypeScript 版本,请参阅 问题 6.5

// continued...
const not = (fn) => (...args) => !fn(...args);

以这种方式工作,我们可以将前面的过滤器重写如下;为了测试非负余额,我们使用原始的 isNegativeBalance() 函数,该函数通过我们的 not() 高阶函数被否定:

// continued...
const notDelinquent3 = serviceResult.accountsData.filter(
  not(isNegativeBalance)
);

我们可能还想尝试另一种解决方案。而不是反转条件(如我们所做的那样),我们可以编写一个新的过滤方法(可能是 filterNot()?),它将以与 filter() 相反的方式工作。以下代码显示了如何编写这个新函数。给定一个值数组 arr 和一个谓词 fn,我们将有如下内容:

// continued...
const filterNot =
  <A, T extends (x: A) => boolean>(arr: A[]) =>
  (fn: T): A[] =>
    arr.filter(not((y) => fn(y)));

这个解决方案并不完全匹配 filter(),因为你不能将其用作方法,但我们可以将其添加到 Array.prototype 中,或者应用一些方法。我们将在 第八章连接函数 中探讨这些想法。然而,更有趣的是注意到我们使用了否定函数,因此 not() 对于解决反向过滤问题的两种解决方案都是必要的。在即将到来的 去方法化 – 将方法转换为函数 部分,我们将看到我们还有另一个解决方案,因为我们可以将像 filter() 这样的方法与其应用的对象解耦,从而将其转换为通用函数。

关于否定函数与使用新的 filterNot() 函数,尽管这两种可能性同样有效,但我认为使用 not() 更清晰。如果你已经理解了过滤的工作原理,那么你可以几乎大声朗读代码,并且它将是可理解的:我们想要那些没有负余额的账户,对吧?现在,让我们考虑一个相关的问题:反转函数的结果。

反转结果

与前面的过滤问题类似,让我们回顾一下 第三章Injection – sorting it out 部分的排序问题,即 Starting Out with Functions。在这里,我们想要用一种特定的方法对数组进行排序。因此,我们使用了 sort(),并向它提供了一个 comparison 函数,该函数基本上指出了两个字符串中哪一个应该排在前面。为了刷新你的记忆,给定两个字符串,该函数应该执行以下操作:

  • 如果第一个字符串应该排在第二个字符串之前,则返回一个负数

  • 如果字符串相同,则返回 0

  • 如果第一个字符串应该排在第二个字符串之后,则返回一个正数

让我们回到我们之前查看的用于西班牙语排序的代码。我们必须编写一个专门的 comparison 函数,以便排序时考虑到西班牙语的字符排序规则,例如将字母 ñ 放在 no 之间,等等。这个代码如下:

const spanishComparison = (a: string, b: string) =>
  a.localeCompare(b, "es");
palabras.sort(spanishComparison);
// sorts the array according to Spanish rules

我们面临着一个类似的问题:我们如何按降序排序?鉴于我们在上一节中看到的内容,一些选项应该立刻浮现在脑海中:

  • 按升序排序,然后反转数组。虽然这解决了问题,但我们仍然只按升序排序,我们希望避免额外的反转步骤。

  • 编写一个函数,将比较函数的结果反转。这将反转所有关于哪个字符串应该排在前面所做的决定,最终结果将是一个按完全相反的顺序排序的数组。

  • 编写一个 sortDescending() 函数或方法,使其工作方式与 sort() 相反。

让我们选择第二个选项,编写一个 invert() 函数来改变比较结果。代码本身与 not() 的代码非常相似。再次检查 问题 6.5 以获取 TypeScript 的等效代码:

// invert.ts
const invert = (fn) => (...args) => -fn(...args);

给定这个高阶函数(HOF),我们可以通过提供一个适当反转的 comparison 函数来按降序排序。看看最后几行,我们使用 invert() 来改变排序比较的结果:

const spanishComparison = (a: string, b: string): number =>
  a.localeCompare(b, "es");
const palabras = [
  "ñandú",
  "oasis",
  "mano",
  "natural",
  "mítico",
  "musical",
];
palabras.sort(spanishComparison);
// "mano", "mítico", "musical", "natural", "ñandú", "oasis"
palabras.sort(invert(spanishComparison));
// "oasis", "ñandú", "natural", "musical", "mítico", "mano"

输出符合预期:当我们 invert() comparison 函数时,结果顺序相反。考虑到我们已经有一些带有预期结果的测试用例,编写单元测试应该相当容易,不是吗?

参数数量变化

回到 第五章Parsing numbers tacitly 部分,在 Programming Declaratively 中,我们看到了使用 parseInt()reduce() 产生问题,因为该函数的预期参数数量不正确,它接受了一个以上的参数——还记得之前的例子吗?

["123.45", "-67.8", "90"].map(parseInt);
// [123, NaN, NaN]

我们有不止一种方法来解决这个问题。在提到的章节中,我们选择了箭头函数。这是一个简单的解决方案,并且具有易于理解的额外优势。在第 第七章 转换函数 中,我们将探讨另一种基于偏应用的方法。现在,让我们使用一个高阶函数(HOF)。我们需要一个函数,它将接受另一个函数作为参数,并将其转换为单参数函数。使用 JavaScript 的展开操作符和箭头函数,这很容易处理:

const unary = fn => (...args) => fn(args[0]);

以下是用 TypeScript 编写的示例:

// arity.ts
const unary =
  <T extends (...x: any[]) => any>(
    fn: T
  ): ((arg: Parameters<T>[0]) => ReturnType<T>) =>
(x) => fn(x);

我们的 unary() 函数与一个通用的 T 函数一起工作。它产生一个新的函数,只有一个参数(第一个参数,Parameters<T>[0]),该参数返回与原始函数相同类型的结果(ReturnType<T>)。

使用这个函数,我们的数字解析问题就消失了:

["123.45", "-67.8", "90"].map(unary(parseInt));
// [123, -67, 90]

不言而喻,定义进一步的 binary()ternary() 函数,以及其他将任何函数转换为等价但限制参数版本的函数同样简单。我们不要做得太过分,只看看所有可能函数中的一小部分——有关更多信息,请参阅 问题 6.10

// continued...
const binary = fn => (...a) => fn(a[0], a[1]);
const ternary = fn => (...a) => fn(a[0], a[1], a[2]);

这方法可行,但逐个列出所有参数可能会变得令人厌烦。我们可以更进一步,通过使用数组操作和展开操作,创建一个通用函数来处理所有这些情况,如下所示:

// continued...
const arity = (n, fn) => (...a) => fn(...a.slice(0, n));

使用这个通用的 arity() 函数,我们可以为 unary()binary() 等函数提供不同的定义。我们甚至可以将之前的函数重写如下:

const unary = fn => arity(1, fn);
const binary = fn => arity(2, fn);
const ternary = fn => arity(3, fn);

你可能会认为你不太可能需要应用这种解决方案,但比你预期的要多得多。通过遍历 JavaScript 的所有函数和方法,你可以快速生成一个以 apply()assign()bind()concat()copyWithin() 等开始的列表!如果你想在隐式方式下使用这些函数,你可能需要固定它们的参数数量,以便它们能够与固定数量的参数一起工作。

世间万物

如果你想查看 JavaScript 函数和方法的良好列表,请查看 developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functionsdeveloper.mozilla.org/en-US/docs/Web/JavaScript/Reference/Methods_Index。至于隐式编程(或点免费风格),我们将在 第八章 连接函数 中再次讨论。

然而,TypeScript 会遇到一个问题。TypeScript 处理静态类型,但 arity() 函数调用的结果类型是在运行时确定的。我们最多可以通过一系列重载来说明,给定一个具有多个参数的函数,应用 arity() 后的结果将有零个、一个、两个等不同可能性——但我们无法做得更多。

到目前为止,我们已经学习了如何包装函数,同时保持其原始行为或以某种方式改变它。现在,让我们考虑一些修改函数的其他方法。

阻尼和节流

让我们以两种限制函数何时以及如何频繁执行的技术来结束本节:阻尼节流。这两种技术具有相同的概念,因此我们将一起探讨:

  • 阻尼一个函数意味着我们延迟一段时间,直到我们实际调用该函数才执行任何操作

  • 节流一个函数意味着我们延迟一段时间,在实际上调用函数之后不执行任何操作

这些技术在网页中非常高效,允许更好的性能。从某种意义上说,它们与记忆化(memoization)相关。通过记忆化,你修改一个函数,使其只会在某些给定参数的情况下被调用一次,但不会更多。在这里的技术中,我们不会走那么远——我们将允许函数再次执行,但以一种受限制的方式,并添加一些延迟。

阻尼函数

阻尼(debouncing)这一概念源自电子学,它涉及到等待直到达到一个稳定状态。例如,如果你编写了一个自动完成组件,每次用户输入一个字母时,你可能会查询一个 API 来获取可能的选择。然而,你并不想逐个按键地这样做,因为你会产生大量的调用,其中大部分你甚至不会使用,因为你只关心你最后做出的那个调用。其他常见的例子包括鼠标移动或页面滚动事件;你不想频繁地运行相关的处理程序,因为这会负面影响页面的性能。

如果你阻尼了 API 调用函数,你仍然可以每次按键时调用它,但直到一段时间内没有更多调用,不会进行任何 API 调用。请参见图 6.2中的示例;事件以圆圈表示,实际的调用只在最后一个事件之后的一段时间内发生,没有事件发生:

图 6.2 – 阻尼函数仅在调用暂停后执行

图 6.2 – 阻尼函数仅在调用暂停后执行

我们可以通过以下方式使用超时来实现这一点:

// debounce.ts
const debounce = <T extends (...args: any[]) => void>(
  fn: T,
  delay = 1000
) => {
  let timer: ReturnType<typeof setTimeout>;
  return (...args: Parameters<T>): void => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), timeDelay);
  };
};

一个阻尼函数是一个新的函数,它可以被频繁调用,但在计时器运行之前不会执行任何操作。如果你调用一次函数,然后再次调用它,计时器将被重置并重新开始运行。函数真正执行其操作的唯一方式是在给定延迟期间没有新的调用。

限制函数

对于互补的节流转换,想象一个带有FETCHAPPLY FILTERSRETRIEVE按钮的网页表单。当你点击它时,会发起 API 调用以获取一些数据。然而,如果用户不断点击,即使会得到相同的结果,也会发起过多的调用。我们希望节流调用,以便第一次调用通过,但进一步的调用将在一段时间后禁用。类似的使用场景也适用于无限滚动;当用户向下滚动页面时,你想要获取更多数据,但出于性能原因,你既不想非常频繁地执行,也不想等到用户到达底部(就像防抖那样),因为那样滚动就会停止。

节流与防抖类似,但节流函数运行后等待下一次运行,而防抖函数先等待然后运行。图 6.3显示了节流的工作原理。与上一节一样,事件以圆圈表示。在调用 API 之后,除非经过一段时间,否则不会进行进一步的调用:

图 6.3 – 节流函数第一次调用时运行,但随后延迟直到再次运行

图 6.3 – 节流函数第一次调用时运行,但随后延迟直到再次运行

// throttle.ts
const throttle = <T extends (...args: any[]) => void>(
  fn: T,
  delay = 1000
) => {
  let timer: ReturnType<typeof setTimeout> | undefined;
  return (...args: Parameters<T>): void => {
    if (!timer) {
      timer = setTimeout(() => {
        timer = undefined;
      }, delay);
      fn(...args);
    }
  };
};

节流函数是一个新的函数,你可以按需多次调用,但它在第一次“做其事”后不会再次运行,直到某个delay。当你调用函数时,它首先检查是否设置了timer;如果是,则不会做任何事情。如果没有设置timer,则会设置一个超时,在一段时间后清除timer,然后调用函数。我们正在使用timer变量作为超时以及作为标志(“我们在等待吗?”)。

到目前为止,我们已经学习了如何包装函数,同时保持其原始行为或以某种方式修改它们。现在,让我们考虑一些修改函数的其他方法。

以其他方式更改函数

让我们通过考虑其他一些提供结果的函数来结束这一章,例如新的查找器、从对象中解耦的方法等。我们的例子将包括以下内容:

  • 将操作(如使用+运算符进行加法)转换为函数

  • 将函数转换为承诺

  • 访问对象以获取属性的值

  • 将方法转换为函数

  • 寻找最优值的一种更好的方法

将操作转换为函数

我们已经看到几个需要编写函数来仅对一对数字进行加法或乘法运算的例子。例如,在第五章的“求和数组”部分中,声明式编程,我们不得不编写以下等效代码:

const mySum = myArray.reduce(
  (x: number, y: number): number => x + y,
  0
);

第五章的“处理范围”部分中,声明式编程,我们编写了以下代码来计算阶乘:

const factorialByRange = (n: number): number =>
  range(1, n + 1).reduce((x, y) => x * y, 1);

如果我们可以将二元运算符转换为一个计算相同结果的函数,那就简单多了。前两个例子可以更简洁地写成如下。你能理解我们做出的改变吗?

const mySum = myArray.reduce(binaryOp2("+"), 0);
const factorialByRange = (n: number): number =>
  range(1, n + 1).reduce(binaryOp2("*"), 1);

我们还没有查看 binaryOp() 的实现方式,但关键概念是,我们不再使用中缀运算符(就像我们在写 22+9 时使用的那样),我们现在有一个函数(就像我们能够将求和写成 +(22,9) 一样,这当然不是有效的 JavaScript)。让我们看看我们如何实现这一点。

实现操作

我们将如何编写这个 binaryOp() 函数?至少有两种方法:一种安全但较长的方案,另一种风险更高但更短的替代方案。第一种方案需要列出每个可能的运算符。以下代码通过使用较长的 switch 语句来实现这一点:

// binaryOp.ts
const binaryOp1 = (op: string) => {
  switch (op) {
    case "+":
      return (x: number, y: number): number => x + y;
    case "-":
      return (x: number, y: number): number => x - y;
    case "*":
      return (x: number, y: number): number => x * y;
    //
    // etc.
    //
    default:
      throw new Error(`Unknown ${op} operator`);
  }
};

这个解决方案完全可行,但需要做太多的工作。顺便说一句,我们应该有单独的 binaryMathOp()binaryLogicalOp() 函数;第一个会是 (op: string) => ((x: number, y: number) => number),而第二个会是 (op: string) => ((x: boolean, y: boolean) => boolean),因为,正如前一个部分中提到的,TypeScript 无法推断返回函数的类型。

另一个解决方案是更短但更危险的。请仅将其视为学习目的的示例;出于安全原因,不建议使用 eval()!我们的第二个版本将使用 Function() 创建一个新的函数,该函数使用所需的运算符,如下所示:

// continued...
const binaryOp2 = (op) =>
  new Function("x", "y", `return x ${op} y;`);

再次强调,TypeScript 无法确定返回函数的类型,因为这将仅在运行时确定。因此,我们需要编写以下代码:

// continued...
const binaryOp2 = (op: string) =>
  new Function("x", "y", `return x ${op} y;`) as (
    x: number,
    y: number
  ) => number;

我们不需要指定 binaryOp2() 的类型,因为 TypeScript 可以根据我们应用到的类型转换自行推断出 (o: string) => (x: number, y: number) => number

(更简单)的出路

一些库,如 Lodash,已经提供了 _.multiply()_.sum() 等函数,因此这是一个更直接的解决方案!你可以快速创建自己的,并创建自己的数学和逻辑基本函数的迷你库。

如果你遵循这个思路,你也可以定义一个 unaryOp() 函数,尽管它的应用较少。(我将这个实现留给你;它与我们已经写过的类似。)在第七章转换函数中,我们将探讨通过使用部分应用来创建这个一元函数的另一种方法。

一个更方便的实现

让我们先不要急于求成。进行函数式编程(FP)并不总是意味着要深入到最简单的函数。例如,在这本书的一个即将到来的部分,我们需要一个函数来检查一个数字是否为负,我们将考虑(参见第八章**,将函数转换为无参数风格部分)使用 binaryOp2() 来编写它:

const isNegative = curry(binaryOp2(">"))(0);

目前不必担心curry()函数(我们将在下一章中详细介绍)——其想法是将第一个参数固定为0,这样我们的函数将检查给定的n数字是否0>n。关键是,我们刚刚编写的函数不是很清晰。如果我们定义一个二元操作函数,它允许我们指定其中一个参数——左边的或右边的——以及要使用的运算符,我们会做得更好。在这里,我们可以编写以下两个函数,它们定义了左操作符或右操作符缺失的函数:

// continued...
const binaryLeftOp =
  (x: number, op: string) => (y: number) =>
    binaryOp2(op)(x, y);
const binaryOpRight =
  (op: string, y: number) => (x: number) =>
    binaryOp2(op)(x, y);

使用这些新函数,我们可以写出以下两种定义中的任意一种,尽管我认为第二种更清晰。我更愿意测试一个数字是否小于0,而不是0是否大于该数字:

const isNegative1 = binaryLeftOp(0, ">");
const isNegative2 = binaryOpRight("<", 0);

这有什么意义?不要追求一些基本的简单性或回到基础。我们可以将运算符转换为函数,但如果你能通过指定操作的两个参数之一来做得更好并简化你的编码,那就这么做吧!函数式编程(FP)的目的是帮助编写更好的代码,而创造人为的限制对任何人都没有帮助。

当然,对于像检查一个数字是否为负这样的简单函数,我永远不会想通过 currying、二元运算符、pointfree 风格或其他任何东西来使事情复杂化,我会毫不犹豫地写出以下内容:

const isNegative3 = (x: number): boolean => x < 0;

到目前为止,我们已经看到了解决相同问题的几种方法。请记住,函数式编程(FP)并不强迫你选择一种做事的方式;相反,它为你提供了很多自由来决定走哪条路!

将函数转换为承诺

在 Node.js 中,大多数异步函数需要一个回调,例如(err,data)=>{...}:如果err是假的,函数执行成功,data是它的结果;否则,函数失败,err给出原因。(有关更多信息,请参阅nodejs.org/api/errors.html#error-first-callbacks。)

然而,你可能更喜欢使用承诺。因此,我们可以考虑编写一个高阶函数(HOF),它将需要一个回调的函数转换为一个承诺,让你可以使用then()catch()方法。(在第十二章构建更好的容器中,我们将看到承诺实际上是单子,所以这种转换在另一个方面也很有趣。)这将是一些开发者的练习,因为 Node.js(自版本 8 起)已经提供了util.promisify()函数,它将异步函数转换为承诺。有关更多信息,请参阅nodejs.org/dist/latest-v8.x/docs/api/util.html#util_util_promisify_original

那么,我们该如何管理这个问题呢?转换过程相对简单。给定一个函数,我们生成一个新的函数:这个新函数会在调用原始函数并传入一些参数后,适当地reject()resolve()一个承诺。

promisify()函数正是如此。它的参数是一个返回通用类型Eerr错误或一些通用类型Ddatafn函数。fn的参数可以是任何类型,除了最后一个参数必须是回调;这需要使用自 TypeScript 4.0 版本以来可用的可变参数数据类型,从 2020 年开始:

// promisify.ts
const promisify =
  <E, T extends any[], D>(
    fn: (...args: [...T, (err: E, data: D) => void]) => void
  ) =>
  (...args: T): Promise<D> =>
    new Promise((resolve, reject) =>
      fn(...args, (err: E, data: D) =>
        err ? reject(err) : resolve(data)
      )
    );

给定的fn函数被转换成一个承诺。这个承诺调用fn并使用一个特殊的回调:如果那个回调得到一个非空err值,承诺会因那个错误而被拒绝;否则,承诺会因data而被解决。

在 Node.js 中工作的时候,以下风格相当常见:

const fs = require("fs");
const cb = (err, data) =>
  err
    ? console.log("ERROR", err)
    : console.log("SUCCESS", data);
fs.readFile("./exists.txt", cb);       // success, data
fs.readFile("./doesnt_exist.txt", cb); // fail, exception

您可以使用我们的promisify()函数来使用承诺,或者在当前版本的 Node.js 中,使用util.promisify()(但请参见本节末尾的内容!):

const fspromise = promisify(fs.readFile.bind(fs));
const goodRead = (data) =>
  console.log("SUCCESSFUL PROMISE", data);
const badRead = (err) =>
  console.log("UNSUCCESSFUL PROMISE", err);
fspromise("./readme.txt")    // success
  .then(goodRead)
  .catch(badRead);
fspromise("./readmenot.txt") // failure
  .then(goodRead)
  .catch(badRead);

现在,您可以使用fspromise()代替原始方法。为此,我们必须绑定fs.readFile,正如我们在第三章**,从函数开始不必要的错误部分中提到的:

顺便说一句,当使用 Node.js 时,请注意,许多模块已经提供了基于承诺的 API,除了较老的基于回调的 API;例如,参见nodejs.org/api/fs.html#promises-api并与nodejs.org/api/fs.html#callback-api进行比较。

从对象中获取属性

我们可以生成一个简单的函数。从对象中提取属性是一个常见的操作。例如,在第五章声明式编程中,我们需要获取纬度和经度来计算平均值。这段代码如下:

// getField.ts
const markers = [
  { name: "UY", lat: -34.9, lon: -56.2 },
  { name: "AR", lat: -34.6, lon: -58.4 },
  { name: "BR", lat: -15.8, lon: -47.9 },
  // ...
  { name: "BO", lat: -16.5, lon: -68.1 },
];
let averageLat = average(markers.map(x => x.lat));
let averageLon = average(markers.map(x => x.lon));

当我们学习如何过滤数组时,我们看到了另一个例子;在我们的例子中,我们想要获取所有账户的 ID,这些账户的余额为负。在过滤掉所有其他账户后,我们仍然需要提取id字段:

const delinquent = serviceResult.accountsData.filter(
  (v) => v.balance < 0
);
const delinquentIds = delinquent.map((v) => v.id);

我们需要什么?我们需要一个 HOF(高阶函数),它将接收一个属性的名称并生成一个新的函数,可以从对象中提取属性。使用箭头函数语法,这个函数很容易编写;f是我们想要的字段名称,而obj是从中获取字段的对象:

// getField.ts
const getField = f => obj => obj[f];

完整的 TypeScript 版本要长一些,但并不多;主要,我们需要指定f必须是对象的一个键:

// continued...
const getField = <D>(f: keyof D) => (obj: D) => obj[f];

使用这个函数,坐标提取过程可以写成如下形式:

let averageLat = average(markers.map(getField("lat")));
let averageLon = average(markers.map(getField("lon")));

但这不会被接受!问题是 TypeScript 无法检测getField()调用结果的类型,因为这将由运行时决定。我们必须通过通知它我们的两个调用将返回数字来帮助它。我们可以将泛型数字返回函数的类型定义为NumFn,然后编写以下内容:

type NumFn = (...args: any[]) => number;
const averageLat2 = average(
  markers.map(getField("lat") as NumFn)
);
const averageLon2 = average(
  markers.map(getField("lon") as NumFn)
);

为了多样化,我们可以使用一个辅助变量来获取拖欠的 ID,并避免使用类似于NumFn的额外类型,如下所示:

const getId = getField("id") as (...args: any[]) => string;
const delinquent = serviceResult.accountsData.filter(
  (v) => v.balance < 0
);
const delinquentIds = delinquent.map(getId);

确保你完全理解这里发生的事情。getField()调用的结果是将在后续表达式中使用的函数。map()方法需要一个映射函数,这正是getField()产生的。

去方法化——将方法转换为函数

filter()map()等方法仅适用于数组;然而,你可能希望将它们应用于NodeListString等,这将很不幸。此外,我们专注于字符串,因此不得不将这些函数作为方法使用,这并不完全符合我们的初衷。最后,每次我们创建一个新函数(例如,我们在第五章检查负数部分中看到的none()函数),它不能像它的同伴(在这种情况下是some()every())那样应用,除非你做一些原型技巧。这是正确的,不推荐这样做,但我们将探讨它;这是“说一套,做一套”的另一个案例。

请阅读第十二章中的扩展当前数据类型部分,即《构建更好的容器》,我们将在这里使map()函数适用于大多数基本类型。

那么,我们能做什么呢?我们可以应用古老的谚语“如果山不来穆罕默德,那么穆罕默德就去山”。我们不再担心无法创建新方法,而是将现有方法转换为函数。如果我们把每个方法转换成一个函数,它将作为其第一个参数接收它将工作的对象。我们可以这样做。

将方法从对象中解耦可以帮助你,一旦你实现了这种分离,一切都会变成函数,你的代码将会更简单。(记得我们在逻辑上否定函数部分写的内容,关于与filter()方法相比可能的filterNot()函数?解耦的方法在其他语言中的泛型函数工作方式相似,因为它们可以应用于不同的数据类型)。

重要 ABC:应用、绑定、调用

查看 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function,了解apply()call()bind()的解释。我们将使用这些来实现我们的功能。回到第一章成为函数式开发者,我们使用了展开运算符时,看到了apply()call()之间的等价性。

在 JavaScript 中实现这种解耦有三种不同但相似的方法。列表中的第一个参数(arg0)将对应于对象,其他参数(...args)对应于被调用方法的实际参数。三个等效版本如下,任何一种都可以用作 demethodize() 函数;选择你喜欢的!让我们用一个纯 JavaScript 版本来理解它们是如何工作的;有关 TypeScript 版本的详细信息,请参阅 问题 6.15

// demethodize.ts
const demethodize1 =
  (fn) =>
  (arg0, ...args) =>
    fn.apply(arg0, args);
const demethodize2 =
  (fn) =>
  (arg0, ...args) =>
    fn.call(arg0, ...args);
const demethodize3 =
  (fn) =>
  (arg0, ...args) =>
    fn.bind(arg0, ...args)();

第四种方法

还有另一种方法来做这件事:const demethodize = Function.prototype.bind.bind(Function.prototype.call)。如果你想了解它是如何工作的,请阅读 Leland Richardson 的 Clever Way to Demethodize Native JS Methods,网址为 www.intelligiblebabble.com/clever-way-to-demethodize-native-js-methods

让我们看看这些应用的一些例子!让我们从一个简单但也会起到警示作用的例子开始。我们可以将 sort() 方法转换为一个函数——但不要认为它会变得纯净!

const sort = demethodize1(Array.prototype.sort);
const a = ["delta", "alfa", "beta", "gamma", "epsilon"];
const b = sort(a);
console.log(a, b);
// [ 'alfa', 'beta', 'delta', 'epsilon', 'gamma' ] twice!

现在,我们可以将 sort() 作为函数使用——但它仍然会产生相同的外部效应;ab 是同一个数组,因为 sort() 仍然在原地工作。

更复杂的情况:我们可以使用 map() 来遍历一个字符串,而无需先将其转换为字符数组。比如说,如果你想将一个字符串拆分成单独的字母并将它们转换为大写;我们可以通过使用 split()toUpperCase() 来实现这一点:

const name = "FUNCTIONAL";
const result = name.split("").map((x) => x.toUpperCase());
// ["F", "U", "N", "C", "T", "I", "O", "N", "A", "L"]

通过去方法化 map()toUpperCase(),我们可以简单地写出以下代码:

const map = demethodize1(Array.prototype.map);
const toUpperCase = demethodize2(
  String.prototype.toUpperCase
);
const result2 = map(name, toUpperCase);
// ["F", "U", "N", "C", "T", "I", "O", "N", "A", "L"]

当然,对于这个特定的情况,我们可以将字符串转换为大写,然后将其拆分为单独的字母,就像 name.toUpperCase().split("") 一样,但这不会是一个很好的例子,因为这里使用了两次去方法化。

同样,我们可以将一系列十进制金额转换为带有千位分隔符和小数点的正确格式的字符串:

const toLocaleString = demethodize3(
  Number.prototype.toLocaleString
);
const numbers = [2209.6, 124.56, 1048576];
const strings = numbers.map(toLocaleString);
console.log(strings);
/*
[ '2.209,6', '124,56', '1.048.576' ] // Uruguay Locale
*/

或者,鉴于前面的去方法化 map() 函数,我们也可以用 map(numbers, toLocaleString) 来进行映射。

将方法去方法化以将其转换为函数的想法将在各种情况下证明非常有用。我们已经在一些例子中看到了它的应用,这本书的其余部分还将出现更多这样的案例。

方法化——将函数转换为方法

在上一节中,我们看到了如何将方法从对象中分离出来,将它们转换成独立的、独立的函数。然后,为了公平起见,让我们考虑相应的转换,即向对象添加一个函数(作为方法)。我们应该称这个操作为 方法化,不是吗?

当我们在第三章**的添加缺失功能*部分定义和操作 polyfills 时,我们已经看到了一些这样的内容。修改原型通常是不受欢迎的,因为可能会与不同的库发生冲突,至少在理论上是这样。然而,这是一个有趣的技巧,所以无论如何让我们来研究它。

反转字符串

让我们从简单的例子开始。回到第五章**的左右折叠部分,我们在声明式编程*中定义了一个 reverseString() 函数来反转字符串。由于我们已经有了一个可以与数组一起工作的 reverse() 方法,我们可以为字符串实现一个 reverse() 方法。为了增加多样性,让我们对字符串反转逻辑进行新的实现。我们将添加一个布尔参数;如果设置为 true,函数将在字母之间添加破折号;这只是为了表明方法化也可以与具有更多参数的函数一起工作。我们想要实现的是以下内容:

"ABCDE".reverse();     // "EDCBA"
"ABCDE".reverse(true); // "E-D-C-B-A"

需要的函数如下(作为一个好奇的观察,请注意,我们正在使用数组的 reverse() 方法来实现我们的 reverse() 字符串方法!):

// methodize.ts
function reverse(x: string, y = false): string {
  return x
    .split("")
    .reverse()
    .join(y ? "-" : "");
}

我们使用了一个标准函数(而不是箭头函数),因为如果没有额外的定义,this 将不会被绑定。另一个关键细节:函数的第一个参数必须是它将要操作的字符串。

现在,我们必须告诉 TypeScript 我们将扩展 String.prototype 对象以添加新方法(有关更多信息,请参阅 www.typescriptlang.org/docs/handbook/declaration-files/templates/global-modifying-module-d-ts.html):

// continued...
declare global {
  interface String {
    reverse(y?: boolean): string;
  }
}

没有这个定义(它也可以在单独的 .d.ts 文件中),当我们尝试分配新方法时,我们会得到 图 6.4 中显示的错误:

图 6.4 – 没有额外定义,您不能向现有对象添加新方法

图 6.4 – 没有额外定义,您不能向现有对象添加新方法

我们如何向 String.prototype 对象添加新方法?本质上,我们想要实现以下内容:

// continued...
String.prototype.reverse = function (
  this: string,
  y
): string {
  return reverse(this, y);
};

我们添加了一个调用我们原始函数的函数。注意,this(当前字符串对象)被作为第一个参数传递。其他参数保持不变。我们可以使用 methodize() 函数来实现所有这些;让我们首先在 JavaScript 中看看,然后再深入了解类型细节。我们想要做以下事情来实现这一点:

// continued...
function methodize(obj, fn) {
  obj.prototype[fn.name] = function (...args) {
    return fn(this, ...args);
  };
}

这就是我们之前所做的事情。我们使用函数的名称作为新添加的方法的名称。在 TypeScript 中,这要复杂一些,但我们需要数据类型检查,所以让我们这样做:

function methodize<
  T extends any[],
  O extends { prototype: { [key: string]: any } },
  F extends (arg0: any, ...args: T) => any
>(obj: O, fn: F) {
  obj.prototype[fn.name] = function (
    this: Parameters<F>[0],
    ...args: T
  ): ReturnType<F> {
    return fn(this, ...args);
  };
}

让我们看看我们添加的数据类型:

  • T 是我们将传递给新方法化函数的参数的泛型类型

  • O 是我们将添加新方法的那个对象的类型

  • F是我们将要方法化的函数;第一个参数(arg0)是关键,我们将分配this的值。其他参数(如果有)是T类型

我们如何使用这个methodize()函数?很简单,只需一行代码:

methodize(String, reverse);

使用这种方法,我们可以按计划使用我们的新方法:

console.log("MONTEVIDEO".reverse());
// OEDIVETNOM
console.log("MONTEVIDEO".reverse(true));
// O-E-D-I-V-E-T-N-O-M

数组求平均值

让我们再看一个例子,以突出一个可能的类型细节。我们将取我们在第五章**计算平均值部分中编写的average()函数,并将其添加到Array.prototype

// continued...
function average(x: number[]): number {
  return (
    x.reduce((x: number, y: number) => x + y, 0) / x.length
  );
}

问题是我们希望我们的函数只应用于数字数组。我们希望 TypeScript 能够检测并拒绝以下错误数据类型的数组行:

const xx = ["FK", "ST", "JA", "MV"].average();

当编写添加方法的全局声明时,将出现错误:

// methodize.ts
declare global {
  // eslint-disable @typescript-eslint/no-unused-vars
 interface Array<T> {
    average(): number;
  }
}

Array的定义必须绑定到泛型Array<T>。然而,我们的average()函数定义并不依赖于T。这意味着我们有一个未使用的定义,ESLint 会对此提出警告。由于我们无法在我们的函数中包含T,我们不得不告诉 ESLint 忽略这个错误;没有其他解决方案!

没有更多的事情了;我们现在可以方法化average()函数,并使用它作为一个方法:

methodize(Array, average);
const myAvg = [22, 9, 60, 12, 4, 56].average(); // 27.166667

你现在可以按需扩展所有基类——但记住我们关于非常非常小心的建议!

寻找最佳值

让我们以创建find()方法的扩展来结束本节。假设我们想要找到一组数字中的最佳值——让我们假设是最大值——我们可以这样做:

// optimum.ts
const findOptimum = (arr: number[]): number =>
  Math.max(...arr);
const myArray = [22, 9, 60, 12, 4, 56];
console.log(findOptimum(myArray)); // 60

现在,这种方法是否足够通用?至少存在两个问题。首先,你能确定一组数据的最佳值总是最大值吗?如果你在考虑几个抵押贷款,利率最低的那个可能是最好的,对吧?假设你总是想要一组数据的最大值是过于限制性的。

负的最大值?

你可以玩一个绕弯子的技巧:如果你改变数组中所有数字的符号,找到它的最大值,并改变它的符号,你实际上得到了数组的最低值。在我们的例子中,-findOptimum(myArray.map((x) => -x))会正确地产生4,但这不是容易理解的代码。

第二,这种寻找最大值的方法依赖于每个选项都有一个数值。但如果没有这样的值,你将如何找到最佳值?通常的方法是相互比较元素,并选择出类拔萃的那个:

  1. 将第一个元素与第二个元素进行比较,并保留这两个元素中最好的。

  2. 然后将该值与第三个元素进行比较,并保留最好的。

  3. 继续这样做,直到你完成对所有元素的遍历。

以更通用的方式解决这个问题的方法是假设存在一个比较函数,它接受两个元素作为参数并返回最好的那个。如果你能将每个元素与一个数值相关联,比较函数就可以简单地比较这些值。在其他情况下,它可能需要进行任何必要的逻辑判断以决定哪个元素胜出。

让我们尝试创建一个适当的高阶函数;我们的新版本将使用 reduce() 如下所示:

// continued...
const findOptimum2 =
  <T>(fn: (x: T, y: T) => T) =>
  (arr: T[]): T =>
    arr.reduce(fn);

这个通用函数接受一个返回 T 类型两个元素中最好的比较器,然后将其应用于 T 类型元素的数组以产生最佳结果。

通过这种方式,我们可以轻松地复制最大值和最小值查找函数;我们只需要提供适当的归约函数:

const findMaximum = findOptimum2(
  (x: number, y: number): number => (x > y ? x : y)
);
const findMinimum = findOptimum2(
  (x: number, y: number): number => (x < y ? x : y)
);
console.log(findMaximum(myArray)); // 60
console.log(findMinimum(myArray)); // 4

让我们更进一步,比较非数值值。让我们想象一个超级英雄卡牌游戏:每张卡片代表一个英雄,并具有多个数值属性,例如 strength(力量)、powers(能力)和 tech(技术)。相应的类可能如下所示:

class Card {
  name: string;
  strength: number;
  powers: number;
  tech: number;
  constructor(n: string, s: number, p: number, t: number) {
    this.name = n;
    this.strength = s;
    this.powers = p;
    this.tech = t;
  }
}

当两个英雄相互战斗时,胜利者是拥有更多具有更高值的类别的一方。让我们实现一个比较器来处理这种情况;一个合适的 compareHeroes() 函数可能如下所示:

const compareHeroes = (card1: Card, card2: Card): Card => {
  const oneIfBigger = (x: number, y: number): number =>
    x > y ? 1 : 0;
  const wins1 =
    oneIfBigger(card1.strength, card2.strength) +
    oneIfBigger(card1.powers, card2.powers) +
    oneIfBigger(card1.tech, card2.tech);
  const wins2 =
    oneIfBigger(card2.strength, card1.strength) +
    oneIfBigger(card2.powers, card1.powers) +
    oneIfBigger(card2.tech, card1.tech);
  return wins1 > wins2 ? card1 : card2;
};

然后,我们可以将此应用于我们的英雄锦标赛。首先,让我们创建我们自己的英雄联盟:

const codingLeagueOfAmerica = [
  new Card("Forceful", 20, 15, 2),
  new Card("Electrico", 12, 21, 8),
  new Card("Speediest", 8, 11, 4),
  new Card("TechWiz", 6, 16, 30),
];

使用这些定义,我们可以编写一个 findBestHero() 函数来获取最佳英雄:

const findBestHero = findOptimum2(compareHeroes);
console.log(findBestHero(codingLeagueOfAmerica));
// Electrico is the top Card!

顺序很重要

当你根据一对一的比较对元素进行排序时,可能会产生意外结果。例如,根据我们的超级英雄比较规则,你可能会发现三个英雄,结果显示第一个打败了第二个,第二个打败了第三个,但第三个又打败了第一个!在数学术语中,比较函数是非传递的,并且对于该集合你没有完全排序

通过这种方式,我们已经看到了几种修改函数以产生具有增强处理功能的新变体的方法;考虑你可能遇到的具体情况,并考虑是否高阶函数(HOF)可能对你有所帮助。

摘要

在本章中,我们学习了如何编写我们自己的高阶函数(HOFs),这些函数可以包装另一个函数以提供一些新功能,改变函数的目标以便它执行其他操作,甚至提供全新的功能,例如将方法从对象中解耦或创建更好的查找器。本章的主要收获是,你有一种修改函数行为的方法,而无需实际修改其代码;高阶函数可以有序地管理这一点。

第七章转换函数中,我们将继续使用高阶函数(HOFs)并学习如何通过使用柯里化和部分应用来产生具有预定义参数的现有函数的专用版本。

问题

6.1 使用函数addLogging(),并且它的类型并不简单。只是为了处理不同的语法,你能提供一个使用箭头函数的addLogging()的替代实现吗?

6.2 映射内存:我们通过使用对象作为缓存来实现我们的记忆化函数。然而,使用映射会更好;进行必要的更改。

6.3 fib(50)不使用记忆化?例如,一个调用和不需要进一步递归来计算fib(0)fib(1),而fib(6)需要 25 次调用。你能找到一个公式来完成这个计算吗?

6.4 randomizer(fn1, fn2, ...),它将接收一个可变数量的函数作为参数,并返回一个新的函数,该函数将在每次调用时随机调用fn1fn2等中的一个。你可以使用这个功能来平衡服务器上不同服务的调用,如果每个函数都执行 AJAX 调用。为了加分,确保没有函数会连续调用两次。

6.5 not()invert()函数。

6.6 与布尔函数一起工作的not()函数以及与数值一起工作的negate()函数。你能更进一步,写一个单一的opposite()函数,该函数将根据需要表现为not()negate()吗?

6.7 invert(),如建议所示。

6.8 filterNot()函数略有变化,如所示,TypeScript 会提出异议;为什么?

const filterNot2 =
  <A, T extends (x: A) => boolean>(arr: A[]) =>
  (fn: T): A[] =>
    arr.filter(not(fn));

6.9 arity()函数工作得很好,但产生的函数没有正确的length属性。你能写一个不同的 arity-changing 函数,没有这个缺陷吗?

6.10 binary()ternary()

6.11 async函数,每次你用相同的参数调用它时,你都会得到相同的结果。但想象一下,我们正在调用一个每 5 分钟更新其数据的天气 API。我们不想只调用一次就再也不调用(就像记忆化一样),但我们也不想每次都调用。你能否向我们的promiseMemoize()函数添加节流行为,以便在给定延迟后,将再次调用 API?

6.12 一个binaryOp()函数,它将与数字一起工作,你应该考虑的所有操作符列表是什么?

6.13 getField()函数,我们还应该有一个setField()函数,所以你能定义它吗?当我们处理获取器、设置器和透镜时,在第十章 确保纯净性时,我们需要这两个函数。请注意,setField()不应该直接修改一个对象;相反,它应该返回一个具有更改值的新对象——它应该是一个纯函数!

6.14 如果我们将getField()函数应用于一个空对象,会发生什么?它的行为应该是什么?如果需要,请修改该函数。在 JavaScript 和 TypeScript 中,这个问题有不同的答案;请小心!

6.15 demethodize()函数。提示:一旦你正确地得到其中一个,其他两个将非常相似!

6.16 findMaximum()findMinimum(),我们编写了自己的函数来比较两个值——但 JavaScript 已经提供了相应的函数!你能根据这个提示想出我们代码的替代版本吗?

6.17 在我们的 compareHeroes() 函数中,const wins2 = 3 – wins1?这不会更快吗?或者甚至更好:根本避免计算 wins2,并将最后一行改为 return wins1 >= 2

第七章:函数转换——柯里化和偏应用

第六章“生成函数”中,我们看到了几种操纵函数的方法,以获得功能有所改变的新版本。在本章中,我们将探讨一种特定的转换,一种工厂方法,它允许你生成任何给定函数的新版本。

我们将考虑以下内容:

  • 柯里化:一个经典的 FP 理论函数,它将具有许多参数的函数转换为一串一元函数

  • 偏应用:这是 FP 中一个历史悠久的变化,通过固定函数的一些参数来产生新的函数版本

  • 部分柯里化(我自己的名字):可以看作是前两种转换的混合

本章中的技术将为你提供从其他函数生成函数的不同方法。公平地说,我们还将看到,其中一些技术可以通过简单的箭头函数来模拟,可能更加清晰。然而,由于你可能会在各种关于 FP 的文本和网页中遇到柯里化和偏应用,了解它们的意义和用法是很重要的,即使你选择更简单的方法。我们将在接下来的几节中探讨这些想法的几个应用。

一点理论

本章中我们将讨论的概念在某些方面非常相似,在其他方面则相当不同。常常会发现人们对它们的真正含义感到困惑,而且很多网页都误用了术语。你甚至可以说,本章中的所有变化在某种程度上都是大致等价的,因为它们允许你将一个函数转换成另一个函数,该函数固定了一些参数,而其他参数是自由的,最终得到相同的结果。好吧,我同意;这并不非常清楚!所以,让我们先澄清一下,并提供一些简短的定义,我们将在稍后进行扩展。(如果你觉得你的眼睛开始发花了,请跳过这一部分,稍后再回来!)是的,你可能会觉得以下描述有点令人困惑,但请耐心等待——我们将在稍后进行更详细的解释:

  • 柯里化是将一个m元函数(即参数数为m的函数)转换为一串m个一元函数的过程,每个一元函数接收原始函数的一个参数,从左到右。(第一个函数接收原始函数的第一个参数,并返回一个接收第二个参数的第二个函数,该第二个函数返回一个接收第三个参数的第三个函数,以此类推。)当被一个参数调用时,每个函数都会产生序列中的下一个函数,最后一个函数执行实际的计算。

  • 部分应用 是向一个 m 元函数提供 n 个参数,其中 n 小于或等于 m,将其转换为一个具有 (m-n) 个参数的函数。每次你提供一些参数,就会产生一个新的函数,具有更小的参数数量。当你提供最后一个参数时,实际的计算将被执行。

  • 部分 Currying 是前两种思想的结合:你向一个 m 元函数提供 n 个参数(从左到右),并产生一个新的具有 (m-n) 个参数的函数。当这个新函数接收到其他参数时,也是从左到右,它将产生另一个函数。当提供最后一个参数时,函数将执行正确的计算。

在本章中,我们将看到这三种转换,它们需要什么,以及实现它们的方法。

Currying

我们已经在 第一章箭头函数 部分,以及 第三章一个参数还是多个参数? 部分中提到了 Currying,但在 成为函数式编程者开始使用函数 中,我们将更加详细地介绍。Currying 是一种技术,它使你能够只与单变量函数一起工作,即使你需要一个多变量函数。

简称“Currying”?

将多变量函数转换为一系列单变量函数(或者更严格地说,将具有多个操作数的算子减少为单个操作数算子的应用序列)的想法是由 Moses Schönfinkel 研究的。一些作者建议,不一定是在开玩笑,Currying 应该更正确地命名为 Schönfinkeling

在接下来的章节中,我们将首先看到如何处理具有许多参数的函数,然后继续介绍如何手动 Currying 或使用 bind()

处理多个参数

Currying 的想法本身很简单。如果你需要一个具有三个参数的函数,你可以使用箭头函数编写如下:

// curryByHand.ts
const make3 = (a: string, b: number, c: string): string =>
  `${a}:${b}:${c}`;

或者,你可以有一系列函数,每个函数只有一个参数,如下所示:

// continued...
const make3curried =
  (a: string) => (b: number) => (c: string) =>
    `${a}:${b}:${c}`;

或者,你可能想将它们视为嵌套函数,如下面的代码片段所示:

// continued...
const make3curried2 = function (a: string) {
  return function (b: number) {
    return function (c: string) {
      return `${a}:${b}:${c}`;
    };
  };
};

在使用方面,使用每个函数的方式有一个本质的区别。虽然你会像通常那样调用第一个,例如 make3("A",2,"Z"),但这与第二个定义不兼容。让我们分析一下原因:make3curried() 是一个一元(单个参数)函数,所以我们应该写 make3curried("A")。但这是什么返回值?根据前面的定义,这也返回一个一元函数——而且那个函数也返回一个一元函数!所以,要得到与三元函数相同的结果的正确调用应该是 make3curried("A")(2)("Z")!请参见 图 7.1

图 7.1 – 普通函数与 Currying 等价函数的区别

图 7.1 – 普通函数与 Currying 等价函数的区别

仔细研究这个例子——我们有一个函数,当我们向它应用一个参数时,我们得到第二个函数。向第二个函数应用参数产生第三个函数,最终应用产生所需的结果。这可以看作是理论计算中的无意义练习,但实际上它带来了一些优势,因为你可以始终使用一元函数,即使你需要更多参数的函数。

柯里化与去柯里化

由于存在柯里化转换,也存在去柯里化转换!在我们的例子中,我们会写 make3uncurried = (a, b, c) => make3curried(a)(b)(c) 来逆转柯里化过程,使其再次可用,一次提供所有参数。

在某些语言中,例如 Haskell,函数只能接受单个参数——然而,该语言的语法允许你以允许多个参数的方式调用函数。在我们的例子中,在 Haskell 中,编写 make3curried "A" 2 "Z" 将会生成 "A:2:Z",甚至没有人需要意识到这涉及到三个函数调用,每个调用都有一个我们的参数。由于你不需要在参数周围写括号,也不需要用逗号分隔它们,所以你无法知道你提供的是三个单独的值而不是一个三元组。

柯里化在 Scala 或 Haskell 中是基本的,它们是完全函数式语言,但 JavaScript 具有足够的特性,允许我们在工作中定义和使用柯里化。这不会那么容易,因为毕竟它不是内置的——但我们能够处理。

因此,为了回顾基本概念,我们的原始 make3()make3curried() 函数之间的关键区别如下:

  • make3() 是一个三元函数,但 make3curried() 是一元函数

  • make3() 返回一个字符串;make3curried() 返回另一个函数——该函数本身返回第二个函数,该函数返回第三个函数,最终返回一个字符串

  • 你可以通过编写类似 make3("A",2,"Z") 的内容来生成一个字符串,它返回 "A:2:Z",但你需要编写 make3curried("A")(2)("Z") 来得到相同的结果

为什么你要费这么大的力气?让我们看看一个简单的例子,然后我们还会看到更多例子。假设你有一个函数用来计算金额的增值税(VAT),如下所示:

// continued...
const addVAT = (rate: number, amount: number): number =>
  amount * (1 + rate / 100);
addVAT(20, 500); // 600 -- that is, 500 + 20%
addVAT(15, 200); // 230 -- 200 +15%

如果你必须应用一个单一的、恒定的税率,你可以将 addVAT() 函数柯里化,以产生一个更专业的版本,该版本始终应用你给出的税率。例如,如果你的国家税率是 6%,那么你就可以有如下所示的内容:

// continued...
const addVATcurried =
  (rate: number) =>
  (amount: number): number =>
    amount * (1 + rate / 100);
const addNationalVAT = addVATcurried(6);
addNationalVAT(1500); // 1590 -- 1500 + 6%

第一行定义了我们 VAT 计算函数的 curried 版本。给定一个税率,addVATcurried()返回一个新的函数,当给定一个金额时,最终将原始税率加到它上面。所以,如果国家税率是 6%,addNationalVAT()将是一个将 6%加到它所接收的任何金额上的函数。例如,如果我们计算addNationalVAT(1500),就像前面的代码中那样,结果将是1590:$1,500 加上 6%的税。

当然,你有理由说,这种 currying 方法只是为了加 6%的税而有点过度,但简化才是关键。让我们再看一个例子。在你的应用程序中,你可能想使用如下函数添加一些日志:

// continued...
function myLog(severity: string, logText?: string) {
  // display logText in an appropriate way,
  // according to its severity
  // ("NORMAL", "WARNING", or "ERROR")
}

然而,使用这种方法,每次你想显示一个正常的日志消息时,你都会写myLog("NORMAL", "some normal text"),对于警告,你会写myLog("WARNING", "some warning text")。你可以通过以下方式使用 currying 简化这一点,固定myLog()的第一个参数,如下所示,使用我们稍后将要看到的curry()函数。然后我们的代码可以是这样的:

// continued...
myLog = curry(myLog);
const myNormalLog = myLog("NORMAL");
const myWarningLog = myLog("WARNING");
const myErrorLog = myLog("ERROR");

你得到了什么?现在,你可以写myNormalLog("some normal text")myWarningLog("some warning text"),因为你已经 curried myLog()并固定了它的参数,这使得代码更简单、更容易阅读!

顺便说一句,如果你愿意,你也可以通过一步实现相同的结果,使用原始的非 curried myLog()函数,通过逐个 case 进行 currying:

// continued...
const myNormalLog2 = curry(myLog)("NORMAL");
const myWarningLog2 = curry(myLog)("WARNING");
const myErrorLog2 = curry(myLog)("ERROR");

所以,有一个curry()函数让你可以固定一些参数,同时让其他参数仍然开放;让我们看看如何以三种不同的方式做到这一点。

手动 currying

在尝试更复杂的事情之前,我们可以手动 curry 一个函数,而不需要任何特殊的辅助函数或其他任何东西。实际上,如果我们想为特定情况实现 currying,没有必要做复杂的事情,因为我们可以用简单的箭头函数来处理。我们在make3curried()addVATcurried()中都看到了这一点,所以没有必要重新审视这个想法。

相反,让我们看看一些自动完成这个任务的方法,这样我们就可以生成任何函数的等效 curried 版本,甚至不需要事先知道它的 arity。更进一步,我们应该编写一个更智能的函数版本,它可以根据接收到的参数数量以不同的方式工作。例如,我们可以有一个sum(x,y)函数,其行为如下所示:

sum(3, 5); // 8; did you expect otherwise?
const add3 = sum(3);
add3(5);   // 8
sum(3)(5); // 8

我们可以通过手动实现这种行为。我们的函数可能如下所示——由于我们不会使用这种风格,让我们保持使用纯 JavaScript,不使用类型:

// continued...
const sum = (x, y) => {
  if (x !== undefined && y !== undefined) {
    return x + y;
  } else if (x !== undefined && y == undefined) {
    return (z) => sum(x, z);
  } else {  // x,y both undefined
    return sum;
  }
};

让我们回顾一下我们在这里做了什么。我们手动编写的 curried 函数具有以下行为:

  • 如果我们用两个参数调用它,它将它们相加并返回总和;这提供了我们的第一个用例,就像sum(3,5)===8

  • 如果只提供了一个参数,它将返回一个新的函数。这个新函数期望一个参数,并将返回该参数与原始参数的和:这正是我们在其他两个用例中期望的行为,例如add2(3)===5sum(2)(7)===9

  • 最后,如果没有提供任何参数,它将返回自身。这意味着如果我们想的话,我们可以写sum()(1)(2)。(不,我想不出为什么要写那个。)

因此,我们可以在函数的定义本身中结合柯里化。然而,你必须同意处理每个函数中的所有特殊情况可能会很快变得麻烦且容易出错。所以,让我们找出一些通用的方法来完成相同的结果,而不需要特定的编码。

使用 bind()进行柯里化

我们可以通过使用bind()方法来找到柯里化的解决方案,这种方法我们在本书的几个地方已经应用过了。这允许我们固定一个参数(或者如果需要的话,可以固定多个参数;在这里我们不需要这么做,但稍后我们会用到它)并提供一个带有这个固定参数的函数。当然,许多库(如 Lodash、Underscore、Ramda 等)都提供了这个功能,但我们想看看如何自己实现它。

纯 JavaScript 版本

我们的实现相对较短,但需要一些解释。首先,让我们看看 JavaScript 版本,稍后再处理 TypeScript:

// curry.js
function curry(fn) {
  return fn.length === 0
    ? fn()
    : (x) => curryByBind(fn.bind(null, x));
}

首先,要注意curryByBind()总是返回一个新的函数,它依赖于作为其参数提供的fn函数。如果函数没有(更多)参数(当fn.length===0)因为所有参数都已经绑定,我们可以通过使用fn()来评估它。否则,函数柯里化的结果将是一个接收单个参数并产生一个新的带有另一个固定参数的柯里化函数的新函数。让我们通过一个详细的例子来看看这个动作,再次使用我们在本章开头看到的make3()函数:

// continued...
const make3 = (a, b, c) => `${a}:${b}:${c}`;
// f1 is the curried version of make3
const f1 = curry(make3);
// f2 is a function that will fix make3's 1st parameter
const f2 = f1("A");
// f3 is a function that will fix make3's 2nd parameter
const f3 = f2(2);
// "A2Z" will be now calculated, since we are providing
// the 3rd (last) make3's parameter
const f4 = f3("Z");
console.log(f4);

这段代码的解释如下:

  • 第一个函数f1()还没有收到任何参数。当调用一个参数时,它将产生一个带有固定第一个参数的make3()的柯里化版本。

  • 调用f1("A")会产生一个新的单参数函数f2(),它本身将产生一个带有其第一个参数设置为"A"make3()的柯里化版本——但实际上,这个新函数最终会固定make3()的第二个参数。

  • 同样,调用f2(2)会产生一个第三个一元函数f3(),它将产生一个make3()的版本,但固定其第三个参数,因为前两个参数已经被固定了。

  • 最后,当我们计算f3("Z")时,这会将make3()的最后一个参数固定为"Z",由于没有更多的参数了,三次绑定的make3()函数被调用,并产生了"A:2:Z"的结果。

你也可以做其他的调用序列,例如以下内容:

// continued...
const f2b = f1("TEA")(4);
const f3b = f2b("TWO");
// "TEA:4:TWO"
const f1c = f1("IN")(10)("TION");
// "IN":10:"TION"

要手动柯里化函数,你可以使用 JavaScript 的 .bind() 方法。顺序如下:

// continued…
const step1 = make3.bind(null, "A");
const step2 = step1.bind(null, 2);
const step3 = step2.bind(null, "Z");
console.log(step3()); // "A:2:Z"

在每一步中,我们提供一个额外的参数。(需要 null 值以提供上下文。如果这是一个附加到对象上的方法,我们将提供该对象作为 .bind() 的第一个参数。由于情况并非如此,因此预期为 null。)这与我们的代码所做的工作等效,只是最后一次,curryByBind() 实际上进行了计算,而不是像 step3() 那样让你去做。

TypeScript 版本

现在我们已经在 JavaScript 中实现了这个功能,让我们看看如何定义柯里化的类型。我们必须递归地工作并考虑两种情况:

  • 如果我们只对一个参数进行柯里化,该函数将直接产生所需的结果

  • 如果我们对具有两个或更多参数的函数进行柯里化,我们将创建一个一元函数(具有第一个参数),该函数将返回一个(柯里化!)函数,该函数将处理其余的参数:

// curry.ts
type Curry<P, R> = P extends [infer H]
  ? (arg: H) => R // only 1 arg
  : P extends [infer H, ...infer T] // 2 or more args
  ? (arg: H) => Curry<[...T], R>
  : never;

我们将有一个具有两个输入的通用类型:P,代表要处理的函数的参数,R 代表该函数的结果类型。如果 P 只有一个类型 H,我们返回一个函数,给定一个 H 类型的参数,返回一个 R 类型的结果。如果 P 由一个 H 类型的第一个(“头部”)和一些其他 T 类型(“尾部”)组成,我们返回一个函数,该函数将返回一个具有 T 类型参数的(柯里化)函数。

使用这种类型有一个额外的复杂性。TypeScript 无法验证我们的 curryByBind() 函数是否正确工作,因为它无法推断出,对于每个函数,我们最终会产生一个结果而不是另一个柯里化函数。有一个涉及只有一个签名(one signature)的重载函数的巧妙解决方案。关键是实现检查得更宽松,你可以使用 any 类型来通过。当然,这样工作并不完全类型安全;你必须确保函数类型正确,因为你实际上绕过了 TypeScript 的检查。在本章中,我们不得不做这类技巧不止一次:

// continued…
function curry<A extends any[], R>(
  fn: (...args: A) => R
): Curry<A, R>;
function curry(fn: (...args: any) => any) {
  return fn.length === 0
    ? fn()
    : (x: any) => curry(fn.bind(null, x));
}

让我们回到我们的 make3() 示例。类型工作得非常完美:

const f1 = curry(make3);
// (arg: string) => (arg: number) => (arg: string) => string
const f2 = f1("A");
// (arg: number) => (arg: string) => string
const f3 = f2(2);
// (arg: string) => string
const f4 = f3("Z");
// string

f1 的类型是关键;它表明我们的递归类型工作正如预期。f2f3 的类型更短,而 f4 的类型是最终结果 string 的类型。

柯里化测试

测试这种转换相对简单,因为柯里化的可能方式并不多:

// curry.test.js
describe("with curry", function () {
  it("you fix arguments one by one", () => {
    const make3a = curry(make3);
    const make3b = make3a("A")(2);
    const make3c = make3b("Z");
    expect(make3c).toBe(make3("A", 2, "Z"));
  });
});

你还能测试什么?也许可以添加只有一个参数的函数,但已经没有更多可以尝试的了。

部分应用

我们将要考虑的第二种转换允许你固定函数的一些参数,创建一个新的函数,该函数将接收其余的参数。让我们用一个无意义的例子来说明这一点。假设你有一个有五个参数的函数。你可能想要固定第二个和第五个参数,部分应用将生成一个新的函数版本,固定这两个参数,但留下其他三个参数开放供新的调用。如果你用三个必需的参数调用这个结果函数,它将通过使用原始的两个固定参数加上新提供的三个参数来产生正确的答案。

投影参数

在函数应用中仅指定一些参数,生成剩余参数的函数,这种思想被称为投影:你可以说你在将函数投影到剩余的参数上。我们不会使用这个术语,但我想在你在其他地方找到它时引用它。

让我们考虑一个使用fetch() API 的例子,它被广泛认为是进行 Ajax 调用的现代方式。你可能想要获取多个资源,始终为调用指定相同的参数(例如,请求头)并且只更改搜索的 URL。通过使用部分应用,你可以创建一个新的myFetch()函数,该函数将始终提供固定参数。

在获取数据

你可以在developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch上了解更多关于fetch()的信息。根据caniuse.com/#search=fetch,你可以在除(哦,惊喜!)Internet Explorer 之外的大多数浏览器中使用它,但你可以通过 polyfill 来绕过这个限制,例如在github.com/github/fetch上找到的一个。

假设我们有一个partial()函数实现了这种应用,让我们看看我们如何使用它来产生我们新的、专门的fetch()版本:

const myFetch = partial(fetch, undefined, myParameters);
// undefined means the first argument for fetch
// is not yet defined; the second argument for
// fetch() is set to myParameters
myFetch("a/first/url")
  .then(/* do something */)
  .catch(/* on error */);
myFetch("a/second/url")
  .then(/* do something else */)
  .catch(/* on error */);

如果请求参数是fetch()的第一个参数,柯里化(currying)将有效。(我们稍后会更多地说到参数的顺序。)使用部分应用,你可以替换任何参数,无论哪个,所以在这种情况下,myFetch()最终成为一个一元函数。这个新函数将获取你想要的任何 URL 的数据,始终传递相同的参数集进行GET操作。

使用箭头函数进行部分应用

尝试手动进行部分应用,就像我们处理柯里化(currying)那样,过于复杂。例如,对于一个有 5 个参数的函数,你需要编写代码,允许用户提供 32 种可能的固定和未固定参数组合——32 等于 2 的 5 次方。即使你可以简化问题,编写和维护代码仍然会很困难。参见图 7**.2,展示了许多可能的组合之一:

图 7.2 – 部分应用可以先提供一些参数,然后提供剩余的参数,最终得到结果

图 7.2 – 部分应用可以先提供一些参数,然后提供剩余的参数,最终得到结果

然而,使用箭头函数进行部分应用要简单得多。以我们之前提到的例子为例,我们会有以下代码。在这种情况下,我们将假设我们想要将第二个参数固定为22,第五个参数固定为1960

const nonsense = (a, b, c, d, e) =>
  `${a}/${b}/${c}/${d}/${e}`;
const fix2and5 = (a, c, d) => nonsense(a, 22, c, d, 1960);

以这种方式进行部分应用相当简单,尽管我们可能想要找到一个更通用的解决方案。你可以通过从先前的函数中创建一个新的函数并固定更多的参数来设置任意数量的参数。(正如第六章生成函数中提到的,可以使用包装器。)例如,你现在可能还想要将新函数fix2and5()的最后一个参数固定为9,如下面的代码所示;这没有什么更简单的了:

const fixLast = (a, c) => fix2and5(a, c, 9);

如果你愿意,你也可以写成nonsense(a, 22, c, 9, 1960),但事实仍然是,使用箭头函数固定参数很简单。现在,让我们考虑我们所说的更通用的解决方案。

使用闭包进行部分应用

如果我们想要能够固定任何参数组合的部分应用,我们必须有一种方法来指定哪些参数将被保留为自由参数,哪些参数将从该点开始固定。一些库,如 Underscore 和 Lodash,使用一个特殊的对象_来表示省略的参数。以这种方式,仍然使用相同的nonsense()函数,我们会编写以下内容:

const fix2and5 = _.partial(nonsense)(_, 22, _, _, 1960);

我们可以通过有一个表示待定、尚未固定的参数的全局变量来做同样的事情,但让我们让它更简单,只用undefined来表示缺失的参数。

小心比较

在检查undefined时,请记住始终使用===运算符;使用==时,null==undefined会发生,你不想这样。有关更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/undefined

我们想要编写一个函数,它将部分应用一些参数,并将剩余的参数留待将来使用。我们希望编写的代码类似于以下内容,并以与之前箭头函数相同的方式生成一个新的函数:

const nonsense = (a, b, c, d, e) =>
  `${a}/${b}/${c}/${d}/${e}`;
const fix2and5 = partial(nonsense)(
  undefined,
  22,
  undefined,
  undefined,
  1960
);
// fix2and5 would become
//     (X0, X2, X3) => nonsense(X0, 22, X2, X3, 1960);

我们将如何实现这一点?我们的实现将使用闭包。(你可能需要回顾一下第一章成为函数式开发者。)这种部分应用的方式在某种程度上类似于柯里化,因为每个函数都会产生一个新的函数,并带有更多的固定参数。我们新的实现将如下所示——而且,让我们再次从纯 JavaScript 开始:

// partial.js
function partial(fn) {
  const partialize =
    (...args1) =>
    (...args2) => {
      for (
        let i = 0;
        i < args1.length && args2.length;
        i++
      ) {
        if (args1[i] === undefined) {
          args1[i] = args2.shift();
        }
      }
      const allParams = [...args1, ...args2];
      return allParams.includes(undefined) ||
        allParams.length < fn.length
        ? partialize(...allParams)
        : fn(...allParams);
    };
  return partialize();
}

哇——一段相当长的代码!关键是内部的partialize()函数。给定一个参数列表(args1),它产生一个接收第二个参数列表(args2)的函数:

  • 首先,它将args1中所有可能的undefined值替换为args2中的值

  • 然后,如果args2中还有任何参数,它也将它们追加到args1的参数中,生成allParams

  • 最后,如果allParams不再包含任何undefined值并且足够长,它将调用原始函数

  • 否则,它将部分化自身以等待更多的参数

一个例子会使它更清楚。让我们回到我们信任的make3()函数,并构建它的部分版本:

const make3 = (a: string, b: number, c: string): string =>
  `${a}:${b}:${c}`;
const f0 = partial(make3);
const f1 = f0(undefined, 2);

f1()函数接收[undefined, 2]作为参数。现在,让我们创建一个新的函数:

const f2 = f1("A");

发生了什么?先前的参数列表([undefined, 2])与新列表(一个元素——在这种情况下,["A"])合并,生成一个现在接收"A"2作为其前两个参数的函数。然而,这还不是最终的,因为原始函数需要三个参数。我们可以写出以下内容:

const f3 = f2("Z");

然后,当前参数列表将与新参数合并,生成["A",2,"Z"]。由于列表现在完整,原始函数将被评估,生成"A:2:Z"作为最终结果。

这段代码的结构与其他我们在柯里化与 bind()部分写过的其他高阶函数有显著的相似之处:

  • 如果所有参数都已提供,将调用原始函数

  • 否则,如果还需要一些参数(在柯里化时,这只是一个通过检查函数的length属性来计数参数的问题;在部分应用时,你还必须考虑存在一些undefined参数的可能性),高阶函数会调用自身以产生函数的新版本,该版本将等待缺失的参数

现在让我们来看一个带有其数据类型的 TypeScript 版本。

部分数据类型

我们将使用一个辅助类型,Partialize<P,A>。如果P是函数参数类型的元组,而A是函数调用参数类型的元组,Partialize<>将返回一个元组,其中包含P中在A中有undefined类型的类型:

// partial.ts
type Partialize<
  P extends any[],
  A extends any[]
> = 0 extends P["length"]
  ? []
  : 0 extends A["length"]
  ? P
  : [P, A] extends [
      [infer PH, ...infer PT],
      [infer AH, ...infer AT]
    ]
  ? AH extends undefined
    ? [PH, ...Partialize<PT, AT>]
    : [...Partialize<PT, AT>]
  : never;

这是如何工作的?

  • 如果P为空,输出也是空的。

  • 如果A为空(没有更多的参数),输出是P

  • 如果P被分成PH(头部)和PT(尾部),并且A也被类似地分成AHAT,那么如果AHundefined,我们返回一个新类型,它包括PH(因为没有为其提供值)和Partialize<PT,AT>,以递归处理这两个元组的其余部分。否则,如果AH不是undefined,我们为相应的参数提供一个值,因此结果是Partialize<PT,AT>;我们不必关心与PH对应的参数。

使用递归使得这更难理解;让我们看看一些例子:

// continued...
type p00 = Partialize<
  [boolean, number, string],
  [undefined, undefined, undefined]
>; // [boolean, number, string]
type p01 = Partialize<
  [boolean, number, string],
  [boolean, undefined, undefined]
>; // [number, string]
type p02 = Partialize<
  [boolean, number, string],
  [undefined, string, undefined]
>; // [boolean, string]
type p03 = Partialize<
  [boolean, number, string],
  [undefined, undefined, string]
>; // [boolean, number]
type p04 = Partialize<
  [boolean, number, string],
  [boolean, undefined, string]
>; // [number]
type p05 = Partialize<[boolean, number, string], [boolean]>;
// [number, string]
type p06 = Partialize<[boolean, number, string], []>;
// [boolean, number, string]

例如,p04 类型表明,如果你有一个期望三个参数(booleannumberstring)的函数,并且你用 booleanundefined 值和 string 调用它,那么偏应用后的函数将只有一个 number 参数。p05 类型表明,如果你只使用 boolean 调用该函数,那么偏应用后的函数将有 numberstring 作为参数。

然而,这并不完全正确。假设我们写的是以下内容:

type p04 = Partialize<
  [boolean, number, string],
  [string, undefined, number]
>; // [number]

结果将是相同的;我们正在检查我们是否有正确数量的参数,但不是它们的类型。让我们再做一个辅助类型检查:

// continued...
type TypesMatch<
  P extends any[],
  A extends any[]
> = 0 extends P["length"]
  ? boolean
  : 0 extends A["length"]
  ? boolean
  : [P, A] extends [
      [infer PH, ...infer PT],
      [infer AH, ...infer AT]?
    ]
  ? AH extends undefined
    ? TypesMatch<PT, AT>
    : PH extends AH
    ? TypesMatch<PT, AT>
    : never
  : never;

TypesMatch 获取两个类型列表 PA

  • 如果任何一个列表为空,那没关系。

  • 如果两个列表都不为空,它将它们分为头部和尾部,分别记为 PHPT,以及 AHAT。如果 AHundefined,或者如果它与 PH 匹配,那么 TypesMatch<> 将继续分析两个尾部。

  • 如果 AH 不是 undefined 但不匹配 PH,将生成 never(这表示错误)。

现在我们可以使用这个辅助定义来编写 Partial<> 泛型类型:

// continued...
type Partial<P extends any[], R> = <A extends any[]>(
  ...x: A
) => TypesMatch<P, A> extends never
  ? never
  : P extends any[]
  ? 0 extends Partialize<P, A>["length"]
    ? (...x: [...P]) => R
    : Partial<Partialize<P, A>, R>
  : never;

在这里,P 代表函数参数的类型,R 代表其结果类型,A 代表函数参数的类型。我们首先检查 PA 是否匹配类型。如果是这样,如果 Partialize<P,A> 为空,我们返回一个 (...x: [...P]) => R 函数;否则,我们(递归地)返回一个具有 Partialize<P,A> 参数类型的函数。

最后,我们拥有了编写 TypeScript 版本的 partial() 所需的一切:

// continued...
function partial<P extends any[], R>(
  fn: (...a: P) => R
): Partial<P, R>;
function partial(fn: (...a: any) => any) {
  const partialize =
    (...args1: any[]) =>
    (...args2: any[]) => {
      for (
        let i = 0;
        i < args1.length && args2.length;
        i++
      ) {
        if (args1[i] === undefined) {
          args1[i] = args2.shift();
        }
      }
      const allParams = [...args1, ...args2];
      return allParams.includes(undefined) ||
        allParams.length < fn.length
        ? partialize(...allParams)
        : fn(...allParams);
    };
  return partialize();
}

值得注意的是,正如在柯里化示例中一样,我们使用了多个 any 类型,因为 TypeScript 并非特别擅长处理递归。这意味着我们必须格外小心我们的代码,因为将无法检测到错误。

偏测试

让我们通过编写一些测试来完成这个部分。以下是我们应该考虑的一些事情:

  • 当我们进行偏应用时,产生的函数的 arity 应该减少

  • 当参数顺序正确时,应该调用原始函数

我们可以写点像下面这样的东西,允许在不同位置修复参数。而不是使用间谍或模拟,我们可以直接与 nonsense() 函数一起工作,因为它相当高效:

// partial.test.ts
function nonsense(
  a: number,
  b: number,
  c: number,
  d: number,
  e: number
) {
  return `${a}/${b}/${c}/${d}/${e}`;
}
describe("with partial()", function () {
  it("you could fix no arguments", () => {
    const nonsensePC0 = partial(nonsense);
    expect(nonsensePC0(0, 1, 2, 3, 4)).toBe(
      nonsense(0, 1, 2, 3, 4)
    );
  });
  it("you could fix only some initial arguments", () => {
    const nonsensePC1 = partial(nonsense)(1, 2, 3);
    expect(nonsensePC1(4, 5)).toBe(nonsense(1, 2, 3, 4,
      5));
  });
  it("you could skip some arguments", () => {
    const nonsensePC2 = partial(nonsense)(
      undefined,
      22,
      undefined,
      44
    );
    expect(nonsensePC2(11, 33, 55)).toBe(
      nonsense(11, 22, 33, 44, 55)
    );
  });
  it("you could fix only some last arguments", () => {
    const nonsensePC3 = partial(nonsense)(
      undefined,
      undefined,
      undefined,
      444,
      555
    );
    expect(nonsensePC3(111, 222, 333)).toBe(
      nonsense(111, 222, 333, 444, 555)
    );
  });
  it("you could fix ALL the arguments", () => {
    const nonsensePC4 = partial(nonsense)(6, 7, 8, 9, 0);
    expect(nonsensePC4).toBe(nonsense(6, 7, 8, 9, 0));
  });
  it("you could work in steps - (a)", () => {
    const nonsensePC5 = partial(nonsense);
    const nn = nonsensePC5(undefined, 2, 3);
    const oo = nn(undefined, undefined, 5);
    const pp = oo(1, undefined);
    const qq = pp(4);
    expect(qq).toBe(nonsense(1, 2, 3, 4, 5));
  });
  it("you could work in steps - (b)", () => {
    const nonsensePC6 = partial(nonsense)(undefined, 2, 3)(
      undefined,
      undefined,
      5
    )(
      1,
      undefined
    )(4);
    expect(nonsensePC6).toBe(nonsense(1, 2, 3, 4, 5));
  });
});

我们现在已经看到了柯里化和偏应用;让我们看看我们的第三个也是最后一个转换,它是我们之前方法的混合体。

偏柯里化

我们将要看的最后一个转换是柯里化和偏应用的混合体。如果你在谷歌上搜索它,你会在一些地方找到它被称为柯里化,在其他地方被称为偏应用,但事实上,它既不是柯里化也不是偏应用,所以我站在中间,称之为 偏柯里化

给定一个函数,我们的想法是固定其前几个参数,并产生一个新的函数,该函数将接收其余的参数。然而,如果这个新函数接收到的参数更少,它将固定所提供的参数,并产生一个新的函数来接收其余的参数,直到所有参数都给出,最终结果可以计算。参见 图 7.3**.3

图 7.3 – 部分柯里化是柯里化和部分应用的混合。你可以提供任意数量的参数,从左侧开始,直到所有参数都提供,然后计算结果

图 7.3 – 部分柯里化是柯里化和部分应用的混合。你可以提供任意数量的参数,从左侧开始,直到所有参数都提供,然后计算结果

为了查看一个例子,让我们回到我们在前几节中使用过的 nonsense() 函数,如下所示。假设我们已经有了一个 partialCurry() 函数:

const nonsense = (a, b, c, d, e) =>
  `${a}/${b}/${c}/${d}/${e}`;
const pcNonsense = partialCurry(nonsense);
const fix1And2 = pcNonsense(9, 22);
// fix1And2 is now a ternary function
const fix3 = fix1And2(60);
// fix3 is a binary function
const fix4and5 = fix3(12, 4);
// fix4and5 === nonsense(9,22,60,12,4), "9/22/60/12/4"

原始函数的元数是 5。当我们对那个函数进行部分柯里化并给它提供 922 参数时,它变成了一个三元函数,因为从原始的五个参数中,有两个已经固定。如果我们给那个三元函数提供一个单一参数 (60),结果又是另一个函数:在这种情况下,是一个二元函数,因为我们现在已经固定了原始五个参数中的前三个。最后的调用,提供最后两个参数,然后执行实际计算所需结果的职责。

与柯里化和部分应用有一些共同点,但也有一些差异,如下所示:

  • 原始函数被转换成一系列函数,每个函数都产生下一个函数,直到系列中的最后一个函数实际上执行其计算。

  • 你总是从第一个参数(最左侧的一个)开始提供参数,就像柯里化一样,但你可以提供多个参数,就像部分应用一样。

  • 当进行函数柯里化时,所有中间函数都是一元的,但在部分柯里化中,并不一定如此。然而,如果我们每次都提供一个参数,那么结果将需要与普通柯里化一样多的步骤。

因此,我们有我们的定义——现在让我们看看我们如何实现我们新的高阶函数;我们可能会在这个章节的上一节中重新使用一些概念。

使用 bind() 进行部分柯里化

与我们之前对柯里化所做的一样,部分柯里化有一个简单的方法。我们将利用 bind() 实际上可以同时固定多个参数的事实,并且我们首先查看 JavaScript 代码以增强清晰度:

// partialCurry.js
function partialCurry(fn) {
  return fn.length === 0
    ? fn()
    : (...x) => partialCurry(fn.bind(null, ...x));
}

将代码与之前的 curry() 函数进行比较,你会看到主要但非常小的差异:

function curry(fn) {
  return fn.length === 0
    ? fn()
    : (x) => curry(fn.bind(null, x));
}

机制完全相同。唯一的区别在于,在我们的新函数中,我们可以同时绑定多个参数,而在 curry() 中,我们总是只绑定一个。

在某种意义上,TypeScript 版本类似于 partial() 的版本。提供的参数必须与原始函数参数的类型匹配,因此我们将再次使用上一节中的 TypesMatch<> 类型。如果原始函数有多个参数,而我们只提供了其中几个,我们需要找出剩余的参数——我们的 Minus<> 类型将完成这项工作:

// partialCurry.ts
type Minus<X, Y> = [X, Y] extends [
  [any, ...infer XT],
  [any, ...infer YT]
]
  ? Minus<XT, YT>
  : X;

基本上,如果两种类型都有多个元素,我们忽略第一个,并处理两种类型的尾部;否则,我们返回第一个。有了这个,我们可以编写 PartialCurry<> 类型:

// partialCurry.ts
type PartialCurry<P extends any[], R> = <A extends any[]>(
  ...x: A
) => TypesMatch<P, A> extends never
  ? never
  : P extends any[]
  ? A["length"] extends P["length"]
    ? R
    : PartialCurry<Minus<P, A>, R>
  : never;

如果类型不匹配(提供了错误类型参数),结果将是错误,never。否则,如果我们提供了足够的参数,将产生原始的 R 结果类型;如果没有,我们将通过递归使用 Minus<> 产生一个参数更少的新的函数。

我们可以回顾一下之前的例子,使用 make3() 函数,唯一的区别是我们可以在更少的步骤中获取结果——或者更多,就像那个有点无意义的 h7 示例一样!

const h1 = partialCurryByBind(make3);
const h2 = h1("A");
const h3 = h2(2, "Z");
console.log(h3); // A:2:Z
const h5 = h1("BE", 4);
const h6 = h5("YOU");
console.log(h6); // BE:4:YOU
const h7 = h5()()()("ME");
console.log(h7); // B:4:ME

顺便说一下,为了了解现有的可能性,你可以在柯里化时固定一些参数,如下所示:

const h8 = partialCurryByBind(make3)("I",8);
const h9 = h8("SOME");
console.log(h9); // I:8:SOME

测试这个函数很容易,我们提供的例子是一个非常好的起点。然而,请注意,由于我们允许固定任意数量的参数,我们无法测试中间函数的参数数量。我们的测试可能如下所示:

// partialCurry.test.ts
describe("with partialCurryByBind", function () {
  it("you could fix arguments in several steps", () => {
    const make3a = partialCurryByBind(make3);
    const make3b = make3a("MAKE", 1);
    const make3c = make3b("TRY");
    expect(make3c).toBe(make3("MAKE", 1, "TRY"));
  });
  it("you could fix arguments in a single step", () => {
    const make3a = partialCurryByBind(make3);
    const make3b = make3a("SET", 2, "IT");
    expect(make3b).toBe(make3("SET", 2, "IT"));
  });
  it("you could fix ALL the arguments", () => {
    const make3all = partialCurryByBind(make3);
    expect(make3all("SOME", 1, "KNOWS")).toBe(
      make3("SOME", 1, "KNOWS")
    );
  });
  it("you could fix one argument at a time", () => {
    const make3one =
      partialCurryByBind(make3)("READY")(2)("GO");
    expect(make3one).toBe(make3("READY", 2, "GO"));
  });
});

使用闭包进行部分柯里化

与部分应用类似,有一个解决方案可以与闭包一起使用。既然我们已经讨论了许多必要的细节,让我们直接进入代码,首先是 JavaScript 版本:

// partialCurry.js
const partialCurryByClosure = (fn) => {
  const curryize =
    (...args1) =>
    (...args2) => {
      const allParams = [...args1, ...args2];
      return allParams.length < fn.length
        ? curryize(...allParams)
        : fn(...allParams);
    };
  return curryize();
};

如果你比较 partialCurryByClosure()partial(),主要区别在于,在部分柯里化中,由于我们总是从左侧提供参数,并且无法跳过某些参数,所以你会将已有的参数与新参数连接起来,并检查是否足够。如果新的参数列表达到了原始函数期望的参数数量,你可以调用它并得到最终结果。在其他情况下,你只需使用 curryize()(在 partial() 中,我们有一个类似的 partialize() 函数)来获取一个新的中间函数,该函数将等待更多的参数。

使用 TypeScript,我们不需要任何新的类型,因为函数只是以不同的方式(内部)工作,但产生相同的结果:

// partialCurry.ts
function partialByClosure<P extends any[], R>(
  fn: (...a: P) => R
): PartialCurry<P, R>;
function partialByClosure(fn: (...a: any) => any) {
  const curryize =
    (...args1: any[]) =>
    (...args2: any[]) => {
      const allParams = [...args1, ...args2];
      return allParams.length < fn.length
        ? curryize(...allParams)
        : fn(...allParams);
    };
  return curryize();
}

结果与上一节完全相同,所以不值得重复。你可以更改我们编写的测试,使用 partialCurryByClosure() 而不是 partialCurryByBind(),它们将正常工作。

最后的想法

让我们以一些简短的主题结束这一章。首先,我们应该考虑如何将本章的方法应用于具有可变数量参数的函数——这不是一个简单的问题,因为我们看到的所有代码都强烈依赖于函数的参数数量。

然后,我们将以两个关于柯里化和部分应用的哲学思考结束,这可能会引起一些讨论:

  • 首先,许多库在参数顺序上都是错误的,这使得它们更难使用

  • 其次,我通常甚至不使用本章中的高阶函数,而是追求更简单的 JavaScript 代码

这可能不是你现在所期望的,所以让我们首先解决具有未知数量的参数的函数的问题,然后更详细地讨论最后两点,这样你就会看到这并不是 do as I say, not as I do... 或是库的做法!

可变数量的参数

我们如何处理允许变量(可能未定义、不确定)数量的参数的函数?这是一个问题,因为我们在本章中开发的全部代码都依赖于 fn.length,即要处理的函数的元数。你可能想要柯里化 reduce() 函数,但你发现它的元数是 1,所以柯里化后的函数不会接受第二个参数。另一个例子:你可能有一个 sumAll() 函数如下,并且你想要对其应用 partial() 并得到一个具有三个参数的函数,但 sumAll.length0,因为它的所有参数都是可选的:

const sumAll = (...args: number[]): number =>
  args.reduce((x, y) => x + y, 0);

在本书的前两版中,我向 curry() 和其他函数添加了一个额外的参数,以便我可以覆盖输入函数的 length 属性:

const curry = (fn, len = fn.length) =>
  len === 0
    ? fn()
    : (p) => curry(fn.bind(null, p), len - 1);

然而,目前我认为这并不是最好的方法。首先,TypeScript 无法理解函数将有多少个参数,这并不好。其次,我们实际上并不需要这个!根据我们在上一章 Arity changing 部分中看到的函数,如果你有一个 fn() 函数,你只想对其柯里化两个参数,你可以这样做 curry(binary(fn)) – 这就解决了问题!

我认为组合函数比调整现有的良好实现更好,所以从现在开始,我推荐这种新的方法。请查看本章后面的 Being functional 部分,以了解更多关于这种用法的示例。

参数顺序

有一个问题不仅存在于像 Underscore 或 Lodash 的 _.map(list, mappingFunction)_.reduce(list, reducingFunction, initialValue) 这样的函数中,也存在于我们在这本书中产生的某些函数中,例如 demethodize() 的结果。例如,请参阅 第六章Demethodizing – turning methods into functions 部分,以回顾那个高阶函数。问题是它们的参数顺序实际上并不能帮助进行柯里化。

当柯里化一个函数时,你可能想要存储中间结果。当我们像以下代码中所做的那样做某事时,我们假设你将使用固定的参数重新使用柯里化函数,这意味着原始函数的第一个参数最不可能改变。现在让我们考虑一个具体的情况。回答这个问题:你更有可能使用map()将相同的函数应用于几个不同的数组,还是将几个不同的函数应用于同一个数组?在验证或转换的情况下,前者更有可能,但这不是我们得到的结果!

我们可以编写一个简单的函数来翻转二进制函数的参数,如下所示:

const flip2 = fn => (p1, p2) => fn(p2, p1);

使用这个,你可以编写如下代码:

const myMap = curry(flip2(demethodize(map)));
const makeString = (v) => String(v);
const stringify = myMap(makeString);
let x = stringify(anArray);
let y = stringify(anotherArray);
let z = stringify(yetAnotherArray);

最常见的用例是您将想要将函数应用于几个不同的列表;库函数和我们的非方法化函数都不能提供这一点。然而,通过使用flip2(),我们可以按照我们喜欢的风格工作。

(是的,在这个特定的情况下,我们可能通过使用偏应用而不是柯里化来解决我们的问题;通过这种方式,我们可以将map()的第二个参数固定,而无需进一步麻烦。然而,翻转参数以产生具有不同参数顺序的新函数也是一种常用的技术,你必须对此有所了解。)

对于像reduce()这样的情况,它通常接收三个参数(列表、函数和初始值),我们可能会选择这样做:

const flip3 = (fn) => (p1, p2, p3) => fn(p2, p3, p1);
const myReduce = partialCurry(
  flip3(demethodize(Array.prototype.reduce))
);
const sum = (x, y) => x + y;
const sumAll = myReduce(sum, 0);
sumAll(anArray);
sumAll(anotherArray);

在这里,我们使用了偏柯里化来简化sumAll()的表达式。另一种选择是使用常规柯里化,然后我们会定义sumAll = myReduce(sum)(0)

如果你想使用更神秘的参数重新排列函数,也可以,但你通常不需要比这两个更多的函数。对于真正复杂的情况,你可能会选择使用箭头函数(就像我们在定义flip2()flip3()时做的那样)并清楚地说明你需要什么样的重新排序。

成为函数式编程者

现在我们即将结束本章,一个坦白是必要的:我并不总是使用柯里化和偏应用,就像之前展示的那样!不要误解我,我确实应用了这些技术——但有时它们会使代码更长、不那么清晰,并不一定更好。让我向你展示我是说什么。

如果我正在编写自己的函数,然后我想将其柯里化以固定第一个参数,与箭头函数相比,柯里化、偏应用或偏柯里化实际上并没有太大区别。我必须编写以下内容:

const myFunction = (a, b, c) => { ... };
const myCurriedFn = curry(myFunction)(fix1st);
// and later in the code...
myCurriedFn(set2nd)(set3rd);

在同一行中将函数柯里化并给它一个第一个参数可能被认为不是很清晰;另一种方法需要添加一个变量和一行额外的代码。然而,未来的调用也不是很好;然而,偏柯里化使其更加直接,例如myPartiallyCurriedFn(set2nd, set3rd)

在任何情况下,当我将最终代码与箭头函数的使用进行比较时,我认为其他解决方案并不真的更好;请对你下面的样本进行自己的评估:

const myFunction = (a, b, c) => { ... };
const myFixedFirst = (b, c) => myFn(fix1st, b, c);
// and later...
myFixedFirst(set2nd, set3rd);

我认为柯里化和偏应用相当好的地方在于我的小型去方法化、预柯里化的基本高阶函数库。我有一套自己的函数,如下所示:

const _plainMap = demethodize(Array.prototype.map);
const myMap = curry(binary(_plainMap));
const myMapX = curry(flipTwo(_plainMap));
const _plainReduce = demethodize(Array.prototype.reduce);
const myReduce = curry(ternary(_plainReduce));
const myReduceX = curry(flip3(_plainReduce));
const _plainFilter = demethodize(Array.prototype.filter);
const myFilter = curry(binary(_plainFilter));
const myFilterX = curry(flipTwo(_plainFilter));
// ...and more functions in the same vein

关于这段代码,以下是一些需要注意的点:

  • 我将这些函数放在一个单独的模块中,并且只导出名为myXXX()的函数。

  • 其他函数是私有的,我使用前导下划线来提醒自己。

  • 我使用my...前缀来记住这些是我的函数,而不是正常的 JavaScript 函数。有些人可能更喜欢保留熟悉的名字,如map()filter(),但我更喜欢独特的名字。

  • 由于大多数 JavaScript 方法都有可变参数数量,我在可变数量参数部分描述了如何解决这个问题。

  • 我总是为reduce()函数提供第三个参数(累加的初始值),因此我为该函数选择的是3个参数。

  • 当对翻转函数进行柯里化时,你不需要指定参数数量,因为翻转已经为你做了这件事。

最终,这都归结于个人决定;尝试本章中我们探讨的技术,看看你更喜欢哪一种!

摘要

在本章中,我们考虑了一种通过以几种不同的方式将参数固定到现有函数上来产生函数的新方法:柯里化,它最初来自计算机理论;偏应用,它更灵活;以及偏柯里化,它结合了前两种方法的优点。使用这些转换,你可以简化你的编码,因为你可以在不费事的情况下生成更专业的通用函数版本。

第八章 连接函数中,我们将回顾我们在纯函数章节中探讨的一些概念,并考虑确保函数不会意外变得不纯的方法,通过寻找使它们的参数不可变的方法,使它们无法被修改。

问题

7.1 sum()函数,我们可以写成sum()(3)(5)并得到8。但如果我们写成sum(3)()(5)会发生什么呢?

7.2 sumMany()函数允许你以以下方式对不确定数量的数字进行求和。请注意,当函数没有参数被调用时,返回求和结果:

let result = sumMany(9)(2)(3)(1)(4)(3)();
// 22

7.3 eval() – 是的,那个不安全、危险的eval()!如果你愿意避免eval()可能带来的潜在安全头痛,你可以用它来转换如下函数:

const make3 = (a: string, b: number, c: string): string =>
  `${a}:${b}:${c}`;

你可以将其转换为柯里化等价形式:

const make3curried = x1 => x2 => x3 => make3(x1, x2, x3);

尝试一下!提示:使用我们在 第五章使用范围 部分中编写的 range() 函数,可能会缩短你的代码。另外,记住 fn.length 会告诉你 fn() 函数的参数数量。

7.4 unCurry(fn, arity) 函数接收一个(柯里化的)函数及其预期参数数量作为参数,并返回 fn() 的非柯里化版本——即一次接收所有参数并产生结果的函数(需要预期参数数量,因为你无法自己确定它):

const make3 = (a, b, c) => String(100 * a + 10 * b + c);
const make3c = curry(make3);
console.log(make3c(1)(2)(3)); // 123
const remake3 = uncurry(make3c, 3);
console.log(remake3(1, 2, 3)); // 123

7.5 (a) => (b) => (c) => result。然而,如果你使用部分柯里化,还有更多使用它的方法:(a,b) => (c) => result,(a) => (b,c) => result,甚至 (a,b,c) => result。你有多少种方式可以使用具有 n 个参数的部分柯里化函数?

7.6 在 Function.prototype 中提供一个 curry() 方法,使其像我们在本章中看到的 curry() 函数一样工作。完成以下代码应该会产生以下结果:

Function.prototype.curry = function () {
  // ...your code goes here...
};
const sum3 = (a, b, c) => 100 * a + 10 * b + c;
sum3.curry()(1)(2)(4); // 124
const sum3C = sum3.curry()(2)(2);
sum3C(9); // 229

7.7 如果首先对两个或更多参数进行了测试,Curry<> 类型可以用一种等效但更简洁的方式编写。你能实现这个更改吗?

7.8 Curry<> 类型,我们通过编写 P extends [infer H] 来测试是否提供了一个单个参数——你能重写代码以使用 ["length"] 吗?提示:我们在定义 Partial<> 类型时做过类似的事情:

type Curry<P, R> = P extends [infer H]
  ? (arg: H) => R // only 1 arg
  : P extends [infer H, ...infer T] // 2 or more args
  ? (arg: H) => Curry<[...T], R>
  : never;

7.9 applyStyle() 函数将允许你以以下方式对字符串应用基本样式。可以使用柯里化或部分应用:

const makeBold = applyStyle("b");
document.getElementById("myCity").innerHTML =
  makeBold("Montevideo");
// <b>Montevideo</b>, to produce Montevideo
const makeUnderline = applyStyle("u");
document.getElementById("myCountry").innerHTML =
  makeUnderline("Uruguay");
// <u>Uruguay</u>, to produce Uruguay

7.10 神秘问题函数:以下故意以不友好的方式编写的函数实际上做什么?

const what = (who) => (...why) => who.length <= why.length
  ? who(...why) : (...when) => what(who)(...why, ...when);

7.11 partial()partialCurry() 将作为方法提供。

7.12 更多的柯里化!这是另一个关于柯里化风格函数的建议:你能看出为什么它有效吗?提示——代码与我们本章中看到的内容有关:

const curryN =
  (fn) =>
  (...args) =>
    args.length >= fn.length
      ? fn(...args)
      : curryN(fn.bind(null, ...args));

第八章:连接函数——流水线、组合以及更多

第七章 变换函数中,我们探讨了通过应用高阶函数构建新函数的方法。在本章中,我们将深入 FP 的核心,学习如何创建函数调用序列以及如何将它们组合以从几个更简单的组件中产生更复杂的结果。为此,我们将涵盖以下主题:

  • 流水线:一种连接函数的方式,类似于 Unix/Linux 的管道

  • 链式操作:流水线的一种变体,但仅限于对象

  • 组合:一种起源于基本计算机理论的经典操作

  • mapfilterreduce操作

在此过程中,我们将涉及一些相关概念,例如以下内容:

  • 无状态风格,通常与流水线和组合一起使用

  • 调试组合或流水线函数,我们将准备一些辅助工具

  • 测试这些函数,它们不会证明具有高复杂性

有了这些技术,你将能够将小函数组合成更大的函数,这是 FP(函数式编程)的一个特点,并将帮助你编写更好的代码。

流水线

流水线组合是用于设置函数按顺序工作的技术,以便一个函数的输出成为下一个函数的输入。看待这个问题有两种方式:从计算机的角度和从数学的角度。在本节中,我们将探讨这两种方式。大多数 FP(函数式编程)文本都是从后者开始的,但鉴于我假设你们大多数人更倾向于计算机而不是数学,让我们从前者开始。

Unix/Linux 中的管道

在 Unix/Linux 中,执行一个命令并将它的输出作为输入传递给第二个命令,该命令的输出将作为第三个命令的输入,依此类推,这被称为流水线。这是 Unix 哲学的一种相当常见的应用,正如管道概念创造者、贝尔实验室的 Douglas McIlroy 在贝尔实验室的文章中所解释的:

  • 让每个程序只做好一件事。要完成一项新工作,最好是重新构建,而不是通过添加新功能来复杂化旧程序。

  • 期望每个程序输出都成为另一个尚未知的程序的输入。

一点历史

考虑到 Unix 的历史重要性,我建议阅读一些在 1978 年 7 月的《贝尔系统技术期刊》中描述(当时新)操作系统的开创性文章,可以在emulator.pdp-11.org.ru/misc/1978.07_-_Bell_System_Technical_Journal.pdf找到。引用的两个规则在序言文章的风格部分。

让我们考虑一个简单的例子来开始。假设我想知道目录中有多少个 LibreOffice 文本文档。有很多种方法可以做到这一点,但以下示例将做到。我们将执行三个命令,将每个命令的输出作为输入传递给下一个命令。假设我们有cd /home/fkereki/Documents然后执行以下操作(请忽略美元符号,它只是控制台提示符):

$ ls -1 | grep "odt$" | wc -l
4

这是什么意思?它是如何工作的?我们必须逐步分析这个过程:

  • 管道的第一个部分,ls -1,将当前目录(根据我们的cd命令,即/home/fkereki/Documents)中的所有文件以单列形式列出,每行一个文件名

  • 第一个命令的输出被提供给grep "odt$",它过滤(只允许通过)以"odt"结尾的行,这是 LibreOffice Writer 的标准文件扩展名

  • 过滤后的输出提供给计数命令wc -l,它计算其输入中的行数

更多关于管道(pipelining)的内容

你可以在 Dennis Ritchie 和 Ken Thompson 的《UNIX 时间共享系统》的第 6.2 节过滤器中了解更多关于管道的信息,这本书也发表在之前提到的贝尔实验室期刊上。

从函数式编程(FP)的角度来看,这是一个关键概念。我们希望用简单、单一用途、较短的函数构建更复杂的操作。管道是 Unix shell 用来应用这个概念的工具。它通过简化执行命令、获取其输出并将其作为输入提供给另一个命令的工作来实现这一点。我们将在 JavaScript 中稍后应用类似的概念:

图 8.1 – JavaScript 中的管道与 Unix/Linux 中的管道类似。每个函数的输出成为下一个函数的输入

图 8.1 – JavaScript 中的管道与 Unix/Linux 中的管道类似。每个函数的输出成为下一个函数的输入

顺便说一句(而且——请放心,这不会变成一个 shell 教程!),你可以使管道接受参数。例如,如果我想计算有多少文件具有这种或那种扩展名,我可以创建一个如cfe这样的函数,代表计数 扩展名

$ function cfe() {
ls -1 | grep "$1\$"| wc -l
}

然后,我可以使用cfe作为命令,给它传递所需的扩展名作为参数:

$ cfe odt
4
$ cfe pdf
6

cfe执行我的管道并告诉我有四个odt文件(LibreOffice)和六个pdf文件;太棒了!我们也会想要编写类似的参数化管道:在我们的流程中,我们不受固定函数的限制;我们完全自由地决定要包含什么。在 Linux 上工作过之后,我们现在可以回到编码。让我们看看如何。

回顾一个例子

我们可以通过回顾前一章中的一个问题来开始将两端连接起来。你还记得我们在第五章**,从对象中提取数据*部分中计算一些地理数据的平均纬度和经度时的情况吗?基本上,我们开始于以下数据,问题是要计算给定点的平均纬度和经度:

const markers = [
  { name: "AR", lat: -34.6, lon: -58.4 },
  { name: "BO", lat: -16.5, lon: -68.1 },
  { name: "BR", lat: -15.8, lon: -47.9 },
  { name: "CL", lat: -33.4, lon: -70.7 },
  { name: "CO", lat:   4.6, lon: -74.0 },
  { name: "EC", lat:  -0.3, lon: -78.6 },
  { name: "PE", lat: -12.0, lon: -77.0 },
  { name: "PY", lat: -25.2, lon: -57.5 },
  { name: "UY", lat: -34.9, lon: -56.2 },
  { name: "VE", lat:  10.5, lon: -66.9 },
];

根据我们所知,我们可以用以下方式编写解决方案:

  • 能够从每个点中提取纬度(之后,经度)

  • 使用该函数创建纬度数组

  • 将结果数组管道化到我们在本章前面提到的计算平均值部分中编写的平均函数

要完成第一个任务,我们可以使用来自第七章**,转换函数部分中的myMap()函数。对于第二个任务,我们可以使用来自第六章**,从对象获取属性部分中的getField()函数。最后,对于第三个任务,我们将使用我们即将开发的(尚未编写的)pipeline()函数!完整地,我们的解决方案可能看起来像这样:

const sum = (x: number, y: number): number => x + y;
const average = (arr: number[]) =>
  arr.reduce(sum, 0) / arr.length;
const myMap = curry(
  flip2(demethodize(Array.prototype.map))
);
const getAllLats = myMap(getField("lat")) as (
  arg: any
) => number[];
const averageLat = pipeline(getAllLats, average)(markers);
// and similar code to average longitudes

我们不得不在getAllLats中添加一些类型转换,这样 TypeScript 就会知道我们将应用该函数。

当然,你总是可以屈服于追求一行代码的诱惑,但这会使代码更清晰或更好吗?

const averageLat2 = pipeline(
  curry(flip2(demethodize(Array.prototype.map)))(
    getField("lat")
  ) as (arg: any) => number[],
  average
)(markers);

这是否对你有意义将取决于你对 FP 的经验。无论如何,无论你选择哪种解决方案,事实仍然是,将管道化(以及稍后,组合)添加到你的工具集中可以帮助你编写更紧凑、声明性更强、更容易理解的代码。

现在,让我们学习如何正确地管道化函数。

创建管道

我们希望能够生成一个包含多个函数的管道。我们可以通过两种方式做到这一点:通过手动以问题特定的方式构建管道,或者使用更通用的结构,这些结构可以普遍应用。让我们看看这两种方法。

可能的管道提案

一个新的操作符|>正在考虑用于 JavaScript,但它目前仅处于第 2 阶段,这意味着它可能还需要一段时间才能被接受并可用。你可以阅读更多关于这个提案及其多变历史的资料,请参阅github.com/tc39/proposal-pipeline-operator/blob/main/HISTORY.md

手动构建管道

让我们用一个 Node.js 的例子来开始,类似于我们在本章早期构建的命令行管道。在这里,我们将手动构建所需的管道。我们需要一个函数来读取目录中的所有文件。我们可以用类似以下的方式做到这一点(尽管这并不推荐,因为在服务器环境中通常不推荐同步调用):

// pipeline.ts
function getDir(path) {
  const fs = require("fs");
  const files = fs.readdirSync(path);
  return files;
}

仅选择odt文件相当简单。我们从以下函数开始:

// continued...
const filterByText = (
  text: string,
  arr: string[]
): string[] => arr.filter((v) => v.endsWith(text));

此函数接受一个字符串数组,并过滤掉不以给定文本结尾的元素,因此我们现在可以写出以下内容:

// continued...
const filterOdt = (arr: string[]): string[] =>
  filterByText(".odt", arr);

更好的是,我们可以应用柯里化并采用无参数风格,正如在第三章不必要的错误部分所示,从函数开始入门,并写成这样:

// continued...
const filterOdt = curry(filterByText)(".odt");

过滤函数的两个版本是等价的;你使用哪个取决于你的喜好。最后,我们可以写出以下内容来计数数组中的元素。由于length不是一个函数,我们不能应用我们的去方法技巧:

// continued...
const count = <T>(arr: T[]): number => arr.length;

使用这些函数,我们可以写出类似以下的内容:

// continued...
const countOdtFiles = (path: string): number => {
  const files = getDir(path);
  const filteredFiles = filterOdt(files);
  const countOfFiles = count(filteredFiles);
  return countOfFiles;
};
countOdtFiles("/home/fkereki/Documents");
// 4, as with the command line solution

我们实际上在执行与 Linux 中相同的过程:获取文件,只保留odt文件,并计算由此产生的文件数量。如果你想要去除所有中间变量,你也可以选择一个单行定义,它以完全相同的方式执行相同的工作,尽管行数更少:

const countOdtFiles2 = (path: string): number =>
  count(filterOdt(getDir(path)));
const c2 = countOdtFiles2("/home/fkereki/Documents");
// 4, again

这触及了问题的核心:我们文件计数函数的两个实现都有缺点。第一个定义使用几个中间变量来保存结果,并将原本在 Linux shell 中一行代码的多行函数。另一方面,第二个,更短的定义更难以理解,因为我们在看似相反的顺序中编写计算步骤!我们的流水线必须首先读取文件,然后过滤它们,最后计数,但这些函数在我们的定义中却是相反的

我们无疑可以手动实现流水线,就像我们看到的,但如果我们采用更声明式的风格会更好。

让我们继续前进,尝试通过应用我们已看到的一些概念,更清晰地构建一个更好的流水线。

使用其他构造

如果我们从函数的角度思考,我们有一个函数列表,我们想要按顺序应用它们,从第一个开始,然后将第二个应用到第一个函数的结果上,然后将第三个应用到第二个函数的结果上,依此类推。如果我们正在修复两个函数的流水线,我们可以使用以下代码:

// continued...
const pipeTwo =
  <AF extends any[], RF, RG>(
    f: (...args: AF[]) => RF,
    g: (arg: RF) => RG
  ) =>
  (...args: any[]) => g(f(...args));

这是我们在本章早期提供的基本定义:我们评估第一个函数,其输出成为第二个函数的输入;相对简单!输入很简单:要应用的第一个函数(f())可以有任意数量的参数,但第二个函数(g())必须只有一个参数,其类型与f()返回的类型相同。流水线的返回类型是g()的返回类型。

你可能会反对,说这个只有两个函数的管道有点太有限了!这并不像看起来那么无用,因为我们可以组合更长的管道——尽管我必须承认这需要太多的编写!假设我们想要编写我们之前章节中的三个函数管道;我们可以以两种不同但等效的方式做到这一点:

// continued...
const countOdtFiles3 = (path: string): number =>
  pipeTwo(pipeTwo(getDir, filterOdt), count)(path);
const countOdtFiles4 = (path: string): number =>
  pipeTwo(getDir, pipeTwo(filterOdt, count))(path);

一点数学

我们正在利用管道是一种结合操作的事实。在数学中,结合性质是指我们可以通过先加 1+2,然后将其结果加到 3 上,或者先加 2+3,然后将结果加到 1 上,来计算 1+2+3;换句话说,1+2+3 等于(1+2)+3 或 1+(2+3)。

它们是如何工作的?它们是如何等效的?跟踪给定调用的执行将很有用;在这么多调用中很容易混淆!第一个实现可以一步一步地跟踪,直到最终结果,这与我们已知的结果相匹配:

countOdtFiles3(path) ===
  pipeTwo(pipeTwo(getDir, filterOdt), count)
  pipeTwo(filterOdt(getDir(path)), count)(path)
  pipeTwo(count(filterOdt(getDir(path))))

第二种实现也得到了相同的结果:

countOdtFiles4(path) ===
  pipeTwo(getDir, pipeTwo(filterOdt, count))(path)
  pipeTwo(getDir(path), pipeTwo(filterOdt, count))
  pipeTwo(filterOdt, count)(getDir(path))
  pipeTwo(count(filterOdt(getDir(path))))

两种推导都得到了相同的结果——实际上就是之前我们手写的那个——所以我们现在知道我们只需要两个高级函数的基本“管道”,但我们确实希望能够以更短、更紧凑的方式工作。一个初步的实现可以按照以下方式,稍后再来看代码:

function pipeline(...fns) {
  return (...args) => {
    let result = fns0;
    for (let i = 1; i < fns.length; i++) {
      result = fnsi;
    }
    return result;
  };
}
pipeline(
  getDir,
  filterOdt,
  count
)("/home/fkereki/Documents"); // still 4

这确实有效——并且指定我们的文件计数管道更清晰,因为函数是按照正确的顺序给出的。然而,pipeline()函数的实现并不太符合函数式编程,它回到了旧的、命令式的、手动循环的方法。我们可以使用reduce()做得更好,就像在第五章中提到的,声明式编程

理念是从第一个函数开始评估,将结果传递给第二个函数,然后是第三个函数,依此类推。通过这种方式,我们可以用更短的代码进行管道操作,再次,我们稍后再讨论代码编写:

// continued...
function pipeline2(...fns) {
  return fns.reduce(
    (result, f) =>
      (...args) =>
        f(result(...args))
  );
}

这段代码更具声明性。然而,如果你使用我们的pipeTwo()函数来编写它,效果会更好,这个函数做的是同样的事情,但更简洁:

// continued...
function pipeline3(...fns) {
  return fns.reduce(pipeTwo);
}

(使用箭头函数可以使代码更短。)你可以通过意识到它使用了我们之前提到的结合性质,将第一个函数管道到第二个函数;然后,它将这个结果管道到第三个函数,依此类推。

哪个版本更好?我认为引用pipeTwo()函数的版本更清晰:如果你知道reduce()是如何工作的,你就可以很容易地理解我们的管道是两个函数一次通过,从第一个开始——这与你对管道工作方式的理解相匹配。我们编写的其他版本或多或少是声明性的,但不是那么容易理解。

我们没有查看所有管道函数的打字,所以现在让我们来做这件事。

打字

当我们管道化多个函数时,一个函数的输出类型应该与下一个函数的参数类型相同。让我们有一个辅助的 FnsMatchPipe<> 类型来检查两个类型是否满足这个条件:

// continued…
type FN = (...args: any[]) => any;
type FnsMatchPipe<FNS extends FN[]> =
  1 extends FNS["length"]
    ? boolean
    : FNS extends [
        infer FN1st extends FN,
        infer FN2nd extends FN,
        ...infer FNRest extends FN[]
      ]
    ? Parameters<FN2nd> extends [ReturnType<FN1st>]
      ? FnsMatchPipe<[FN2nd, ...FNRest]>
      : never
    : never;

这会递归地进行。如果我们管道中只有一个函数(FNS 的长度为 1),那么我们返回 boolean 来表示成功。如果我们有多个函数,我们取第一个和第二个函数,检查后者的参数是否与前者的返回类型相同,并从第二个函数开始递归检查类型。如果类型不匹配,我们返回 never 来标记失败。

现在,管道的类型是什么?其参数的类型将与第一个函数的参数类型匹配,其结果类型将与最后一个函数的结果类型匹配:

// continued...
type Pipeline<FNS extends FN[]> =
  boolean extends FnsMatchPipe<FNS>
    ? 1 extends FNS["length"]
      ? FNS[0]
      : FNS extends [
          infer FNFIRST extends FN,
          ...FN[],
          infer FNLAST extends FN
        ]
      ? (...args: Parameters<FNFIRST>) => ReturnType<FNLAST>
      : never
    : never;

我们首先使用 FnsMatchPipe<> 验证函数的类型是否正确。如果类型匹配,整个管道的类型就是一个函数,该函数接收与管道中第一个函数相同类型的参数,并返回与最后一个管道函数相同类型的值。

现在,我们的管道可以正确编写了——我们得使用与上一章相同的“重载”来帮助 TypeScript 确定类型:

// continued...
function pipeline<FNS extends FN[]>(
  ...fns: FNS
): Pipeline<FNS>;
function pipeline<FNS extends FN[]>(...fns: FNS): FN {
  return (...args: Parameters<FNS[0]>) => {
    let result = fns0;
    for (let i = 1; i < fns.length; i++) {
      result = fnsi;
    }
    return result;
  };
}
function pipeline2<FNS extends FN[]>(
  ...fns: FNS
): Pipeline<FNS>;
function pipeline2<FNS extends FN[]>(...fns: FNS): FN {
  return fns.reduce(
    (result, f) =>
      (...args) =>
        f(result(...args))
  );
}
function pipeline3<FNS extends FN[]>(
  ...fns: FNS
): Pipeline<FNS>;
function pipeline3<FNS extends FN[]>(
  ...fns: FNS
): (...fns: FNS) => FN {
  return fns.reduce(pipeTwo);
}

在我们查看连接函数的其他方法之前,让我们考虑如何调试我们的管道。

调试管道

现在,让我们转向一个实际问题:你如何调试你的代码?使用管道,你无法看到从函数到函数传递的内容,那么你该如何做?对此我们有两个答案:一个(同样)来自 Unix/Linux 世界,另一个(对于这本书来说最合适)使用包装器来提供一些日志。

使用 tee

我们将要使用的第一个解决方案意味着向管道中添加一个函数,该函数仅记录其输入。我们想要实现类似于 Linux 命令 tee 的功能,该命令可以拦截管道中的标准数据流并将其副本发送到另一个文件或设备。记住 /dev/tty 是通常的控制台,我们可以执行类似以下操作,并使用 tee 命令获取屏幕上的所有内容副本:

$ ls -1 | grep "odt$" | tee /dev/tty | wc -l
...the list of files with names ending in odt...
4

我们可以轻松地编写一个类似的函数:

// pipeline_debug.ts
const tee = <A>(arg: A) => {
  console.log(arg);
  return arg;
};

逗号的力量!

如果你了解逗号操作符的用法,你可以更简洁地编写 const tee2 = <A>(arg: A) => (console.log(arg), arg)——你看到为什么吗?查看 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comma_Operator 以获取答案!

我们的日志函数简短而直接:它将接收一个参数,列出它,并将其传递给管道中的下一个函数。我们可以在以下代码中看到它的工作情况:

// continued...
console.log(
  pipeline3(
    getDir,
    tee,
    filterOdt,
    tee,
    count
  )("/home/fkereki/Documents")
);
...the list of all the files in the directory...
...the list of files with names ending in odt...
4

如果我们的 tee() 函数能够接收一个日志记录函数作为参数,就像在 第六章 以函数方式记录日志 部分中提到的“产生函数”;那只是我们成功实现的那种改变。同样的优秀设计理念再次得到应用!

// continued...
const tee2 = <A>(arg: A, logger = console.log) => {
  logger(arg);
  return arg;
};

这个函数与之前的 tee() 函数完全一样,尽管它将使我们能够更灵活地应用和测试。然而,在我们的情况下,这仅仅是对一个已经很容易测试的函数的额外增强。

让我们考虑一个更通用的拨号函数,它不仅限于进行一些日志记录。

拨入流程

如果你愿意,你可以编写一个增强的 tee() 函数来产生更多的调试信息,将报告的数据发送到文件或远程服务,等等——有许多你可以探索的可能性。你也可以探索一个更通用的解决方案,其中 tee() 只是一个特例,并允许我们创建个性化的拨号函数。这可以在以下图中看到:

图 8.2 – 拨号允许你在管道中应用一个函数,以便你可以检查数据在管道中流动的情况

图 8.2 – 拨号允许你在管道中应用一个函数,以便你可以检查数据在管道中流动的情况

当与管道一起工作时,你可能想在管道的中间放置一个日志记录函数,或者你可能想要某种其他类型的窥探函数——可能是为了将数据存储在某个地方,调用一个服务,或者产生某种副作用。我们可以有一个通用的 tap() 函数,让我们能够检查数据在管道中移动时的状态,它将以以下方式工作:

// continued...
const tap = curry(<A>(fn: FN, x: A) => (fn(x), x));

这段代码是本书中看起来最复杂的代码之一,所以让我们来解释一下。我们想要产生一个函数,给定一个函数 fn() 和一个参数 x,将评估 fn(x)(以产生我们可能感兴趣的任何类型的副作用),但返回 x(这样管道就可以继续而不会受到干扰)。逗号操作符正好具有这种行为:如果你写类似 (a, b, c) 的东西,JavaScript 将按顺序评估三个表达式,并使用最后一个值作为表达式的值。

在这里,我们可以使用柯里化来产生几个不同的拨号函数。我们在上一节中编写的 tee() 函数也可以用以下方式编写:

// continued...
const tee3 = tap(console.log);

顺便说一句,你也可以不使用柯里化来编写 tap(),但你必须承认这失去了一些神秘感!这在这里得到了演示:

// continued...
const tap2 = (fn: FN) => <A>(x: A) => (fn(x), x);

这确实做了完全相同的工作,你将从这个 手动柯里化 部分 第七章 转换函数 中认出这种柯里化的方式。现在我们已经学会了如何“拨入”管道,让我们通过回顾之前章节中探讨的一些概念,继续探索另一种日志记录方式。

使用日志包装器

我们提到的第二个想法是基于我们在*第六章**“日志记录”部分中编写的addLogging()函数。想法是将一些日志功能包装在函数中,以便在进入时打印参数,在退出时显示函数的结果:

pipeline2(
  addLogging(getDir),
  addLogging(filterOdt),
  addLogging(count)
)("/home/fkereki/Documents");
entering getDir(/home/fkereki/Documents)
exiting  getDir=> ...list of files...
entering filterOdt(...list of files, again...)
exiting  => ...list of .odt files...
entering count(...list of .odt files ...)
exiting  count=>4

我们可以轻易地验证pipeline()函数是否正确执行——无论函数产生什么结果,都会作为输入传递给下一行中的函数,我们也可以理解每次调用发生了什么。当然,你不需要在管道中的每个函数中添加日志:你可能会在怀疑发生错误的地方这样做。

现在我们已经了解了如何组合函数,让我们看看在 FP 中定义函数的一种常见方式,无点风格,你可能会遇到。

无点风格

当你将函数组合在一起,无论是通过管道还是通过组合,正如我们将在本章后面看到的那样,你不需要任何中间变量来保存将成为后续函数参数的结果:它们是隐式的。同样,你可以编写不提及参数的函数;这被称为无点风格

(顺便说一下,无点风格也被批评者称为隐式编程无意义编程!术语“点”本身意味着函数参数,而无点则表示不命名这些参数。)

定义无点函数

你可以很容易地识别无点函数定义,因为它不需要function关键字或=>箭头。让我们回顾一下我们在本章中编写的某些先前函数,并检查它们。例如,我们原始的文件计数函数的定义如下:

const countOdtFiles3 = (path: string): number =>
  pipeTwo(pipeTwo(getDir, filterOdt), count)(path);
const countOdtFiles4 = (path: string): number =>
  pipeTwo(getDir, pipeTwo(filterOdt, count))(path);

上述代码可以重写如下:

// pointfree.ts
const countOdtFiles3b = pipeTwo(
  pipeTwo(getDir, filterOdt),
  count
);
const countOdtFiles4b = pipeTwo(
  getDir,
  pipeTwo(filterOdt, count)
);

新的定义不引用新定义函数的参数。

你可以通过检查管道中的第一个函数(在这种情况下是getDir())以及它接收什么参数来推断这一点。(使用类型签名,正如我们将在第十二章**“构建更好的容器”中看到的那样,对于文档来说非常有帮助,并且补充了 TypeScript 类型。)在我们的回顾示例*部分,我们可以编写一个getLat()函数,以无点方式从对象中获取lat字段:

const getLat = curry(getField)("lat");

相应的全风格定义应该是什么?你必须检查getField()函数(我们在回顾示例部分中看到了这个函数)以确定它期望一个对象作为参数。然而,通过编写以下内容来明确这种需求并没有太多意义:

const getLat = (obj) => curry(getField)("lat")(obj);

如果你愿意编写所有这些,你可能希望坚持以下做法:

const getLat = (obj) => obj.lat;

然后,你就根本不需要担心柯里化!

转换为无点风格

另一方面,你最好暂停一分钟,尽量不去编写纯函数式代码,无论如何。例如,考虑我们在第六章生产函数中编写的isNegativeBalance()函数:

const isNegativeBalance = v => v.balance < 0;

我们能否以纯函数式风格编写这个?是的,我们可以,我们将看到如何编写——但我不确定我们是否真的想这样编写代码!我们可以考虑构建一个由两个函数组成的管道:一个将提取给定对象的余额,而另一个将检查它是否为负。因此,我们将像这样编写我们的替代版本的余额检查函数:

const isNegativeBalance2 = pipeline(getBalance,
  isNegative);

要从一个给定的对象中提取balance属性,我们可以使用getField()和一些柯里化,并编写以下代码:

const getBalance = curry(getField)("balance");

对于第二个函数,我们可以编写以下代码:

const isNegative = (x: number): boolean => x < 0;

我们的纯函数目标就这样消失了!相反,我们可以使用binaryOp()函数,这也是我们之前提到的同一章中的函数,再加上一些额外的柯里化,来编写以下代码:

const isNegative = curry(binaryOp(">"))(0);

我之所以以相反的方式编写测试(0>x而不是x<0),只是为了方便。另一种选择是使用我在第六章更方便的实现部分提到的增强函数,这部分内容稍微简单一些,如下所示:

const isNegative = binaryOpRight("<", 0);

所以,最终,我们可以编写以下代码:

const isNegativeBalance2 = pipeline(
  curry(getField)("balance"),
  curry(binaryOp(">"))(0)
);

或者,我们还可以编写以下代码:

const isNegativeBalance3 = pipeline(
  curry(getField)("balance"),
  binaryOpRight("<", 0)
);

这是一种改进吗?我们新的isNegativeBalance()版本没有引用它们的参数,并且是完全的纯函数式,但使用纯函数式风格的想法应该是帮助提高代码的清晰度和可读性,而不是产生混淆和晦涩!我怀疑没有人会看我们这个函数的新版本,并认为它们比原始版本有优势。

如果你发现由于使用纯函数编程,你的代码变得越来越难以理解,请停止并撤销你的更改。记住这本书的教条:我们想要做函数式编程,但我们不想过分追求——使用纯函数式风格并不是强制性的!

在本节中,我们学习了如何构建函数管道——这是一个强大的技术。然而,对于对象和数组,我们还有一种你可能已经使用过的特殊技术:链式调用。现在让我们来看看这个。

链式和流畅接口

当你与对象或数组一起工作时,还有另一种将多个调用执行链接起来的方法:通过应用链式调用。例如,当你与数组一起工作时,如果你应用一个map()filter()方法,结果会是一个新的数组,然后你可以对这个新数组应用另一个map()filter()函数,依此类推。我们在第五章使用范围部分定义range()函数时使用了这些方法,声明式编程

const range = (start: number, stop: number): number[] =>
  new Array(stop - start).fill(0).map((v, i) => start + i);

首先,我们创建了一个新的数组;然后,我们应用了fill()方法来更新它(副作用)并返回更新后的数组,最后我们应用了一个map()方法。后者方法生成了一个新的数组,我们可以对它进行进一步的映射、过滤或其他任何可用的方法。

让我们看看流畅 API 的一个常见例子,这些 API 通过链式操作工作,然后考虑我们如何在自己的 API 上实现这一点。

流畅 API 的示例

这种连续链式操作的风格也用于流畅的 API 或接口。仅举一个例子,图形D3.js库(更多关于它的信息请参阅d3js.org)经常使用这种风格。以下例子,取自bl.ocks.org/mbostock/4063269,展示了它的实际应用:

var node = svg
  .selectAll(".node")
  .data(pack(root).leaves())
  .enter()
  .append("g")
  .attr("class", "node")
  .attr("transform", function (d) {
    return "translate(" + d.x + "," + d.y + ")";
  });
node
  .append("circle")
  .attr("id", function (d) {
    return d.id;
  })
  .attr("r", function (d) {
    return d.r;
  })
  .style("fill", function (d) {
    return color(d.package);
  });

每个方法都作用于前一个对象,并提供对新的对象的访问,未来的方法调用将应用于该对象(例如selectAll()append()方法),或者更新当前对象(如attr()属性设置调用所做的那样)。这种风格并不独特,其他一些知名的库(比如 jQuery)也采用了它。

我们能自动化这一点吗?在这种情况下,答案是“可能,但我宁愿不这么做。”我认为使用pipeline()compose()效果一样好,并且可以达到相同的目的。使用对象链式调用,你只能返回新的对象或数组或方法可以应用到的某种东西。(记住,如果你正在处理标准类型,如字符串或数字,除非你修改它们的原型,否则你不能向它们添加方法,这并不推荐!)然而,使用组合,你可以返回任何值;唯一的限制是下一个函数必须期望你提供的数据类型。

另一方面,如果你正在编写自己的 API,你可以通过让每个方法都返回this来提供一个流畅的接口——除非它需要返回其他内容!如果你在使用别人的 API,你也可以通过使用代理来玩一些小技巧。然而,请注意,可能会有一些情况导致你的代理代码可能失败:可能另一个代理正在使用,或者有一些 getter 或 setter 可能会引起问题,等等。

关于代理

你可能想了解一下代理对象,请参阅developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Proxy – 它们非常强大,并允许实现有趣的元编程功能。然而,它们可能会让你陷入技术细节的陷阱,并且会导致代理代码的(尽管是轻微的)性能下降。

现在我们来看看如何链式调用,这样我们就可以对任何类进行操作。

链式调用方法

让我们来看一个基本的例子。我们有一个City类,它具有name、纬度(lat)和经度(long)属性:

// chaining.ts
class City {
  name: string;
  lat: number;
  long: number;
  constructor(name: string, lat: number, long: number) {
    this.name = name;
    this.lat = lat;
    this.long = long;
  }
  getName() {
    return this.name;
  }
  setName(newName: string) {
    this.name = newName;
  }
  setLat(newLat: number) {
    this.lat = newLat;
  }
  setLong(newLong: number) {
    this.long = newLong;
  }
  getCoords() {
    return [this.lat, this.long];
  }
}

这是一个带有几个方法的常见类;一切都很正常。我们可以使用这个类如下,并提供关于我的家乡乌拉圭蒙得维的亚的详细信息:

const myCity = new City(
  "Montevideo, Uruguay",
  -34.9011,
  -56.1645
);
console.log(myCity.getCoords(), myCity.getName());
// [ -34.9011, -56.1645 ] 'Montevideo, Uruguay'

如果我们希望设置器以流畅的方式处理,我们可以设置一个代理来检测这些调用并提供缺少的 return this。我们如何做到这一点?如果原始方法不返回任何内容,JavaScript 会默认包含一个 return undefined 语句,这样我们就可以检测方法返回的是否是 undefined,并用 return this 来替换它。当然,这是一个问题:如果我们有一个合法返回 undefined 的方法,我们会怎么办?因为它具有语义?我们可以有一个某种异常列表来告诉我们的代理在这些情况下不要添加任何内容,但让我们不要深入这个话题。

我们处理器的代码如下。每当调用一个对象的某个方法时,都会隐式地调用一个 get(),然后我们捕获它。如果我们得到一个函数,我们用一些自己的代码将其包装起来,这样它就会调用原始方法,然后决定是返回其值还是返回代理对象的引用。如果我们没有得到一个函数,我们就返回请求的属性的值。我们的 chainify() 函数将负责将处理器分配给一个对象并创建所需的代理:

// chainify.ts
const chainify = <OBJ extends { [key: string]: any }>(
  obj: OBJ
): Chainify<OBJ> =>
  new Proxy(obj, {
    get(target, property, receiver) {
      if (typeof property === "string") {
        if (typeof target[property] === "function") {
          // requesting a method? return a wrapped version
          return (...args: any[]) => {
            const result = targetproperty;
            return result === undefined ? receiver :
              result;
          };
        } else {
          // an attribute was requested - just return it
          return target[property];
        }
      } else {
        return Reflect.get(target, property, receiver);
      }
    },
  });

我们必须检查被调用的 get() 是针对函数还是属性。在第一种情况下,我们用额外的代码包装方法,使其执行并返回其结果(如果有)或对象的引用。在第二种情况下,我们返回属性,这是预期的行为。(关于 Reflect.get() 的用法,请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get。)

“链式化”对象的类型是什么?任何非函数的属性都是相同的。返回某些非 void 值的函数属性也是相同的。然而,如果一个函数返回 void,我们会将其包装,使其返回对象本身。Chainify<> 类型定义就是这样:

// continued...
type Chainify<A extends { [key: string]: any }> = {
  [key in keyof A]: A[key] extends (...args: any[]) => any
    ? void extends ReturnType<A[key]>
      ? (...args: Parameters<A[key]>) => Chainify<A>
      : (...args: Parameters<A[key]>) => ReturnType<A[key]>
    : A[key];
};

通过这种方式,我们可以链式化任何对象,以便我们可以检查任何调用的方法。当我写这篇文章的时候,我正在印度浦那生活,所以让我们反映这个变化:

const myCity2 = chainify(myCity);
console.log(
  myCity2
    .setName("Pune, India")
    .setLat(18.5626)
    .setLong(73.8087)
    .getCoords(),
  myCity.getName()
);
// [ 18.5626, 73.8087 ] 'Pune, India'

注意以下内容:

  • myCity2(已链式化)的类型与 myCity 的类型不同。例如,myCity2.setLong() 现在是 setLong(newLong: number): Chainify<City> 类型,而不是之前的 setLong(newLong: number): void。(参见 问题 8.8。)

  • 我们以流畅的方式调用几个设置器,它们运行良好,因为我们的代理正在处理为后续调用提供值。

  • 调用 getCoords()getName() 被拦截,但由于它们已经返回了值,所以没有进行特殊处理。

以链式方式工作值得吗?这取决于你——但记住,可能会有一些情况下这种方法会失败,所以要保持警惕!现在,让我们继续探讨组合,这是连接函数的另一种最常见的方式。

组合

组合与管道化非常相似,但其根源在于数学理论。组合的概念是一系列函数调用的序列,其中一个函数的输出是下一个函数的输入——但顺序与管道化相反。因此,如果你有一系列函数,从左到右,当进行管道化时,要应用系列中的第一个函数是左边的那个,但当你使用组合时,你从最右边的一个开始。

让我们更深入地研究一下。当你定义了三个函数的组合,比如(fgh),并将这个组合应用到x上,这相当于写出f(g(h(x)))

重要的是要注意,与管道化一样,要应用的第一(实际上是列表中的最后一个)函数的 arity 可以是任何值,但所有其他函数都必须是一元的。此外,除了函数评估顺序的差异外,组合是 FP 中的一个重要工具:它抽象了实现细节(将你的焦点放在你需要完成的事情上,而不是实现它的具体细节),因此让你以更声明性的方式工作。

阅读提示

如果有帮助,你可以将(fgh)读作“f之后g之后h”,这样就可以清楚地看出h是第一个要应用的函数,而f是最后一个。

由于其与管道化的相似性,实现组合并不困难。然而,还有一些重要且有趣细节。在继续使用高阶函数和使用一些关于测试组合函数的考虑之后,让我们看看一些组合的例子。

组合的一些例子

这可能不会让你感到惊讶,但我们已经看到了几个组合的例子——或者至少,我们实现的一些解决方案在功能上等同于使用组合。让我们回顾一些这些例子,并尝试一些新的例子。

一元运算符

第六章逻辑否定函数部分,我们编写了一个not()函数,它接受另一个函数作为输入,并逻辑上反转其结果。我们使用该函数来否定对负余额的检查;这个示例代码(这里我使用纯 JavaScript,以保持清晰)可以是以下这样:

const not = (fn) => (...args) => !fn(...args);
const positiveBalance = not(isNegativeBalance);

在该章节的另一个部分(将操作转换为函数)中,我给你留下了一个挑战,即编写一个unaryOp()函数,该函数将提供与常见 JavaScript 运算符等效的一元函数。如果你接受了这个挑战,你应该能够写出以下这样的代码:

const logicalNot = unaryOp("!");

假设存在一个compose()函数,你也可以写成以下这样:

const positiveBalance = compose(
  logicalNot,
  isNegativeBalance
);

你更喜欢哪一个?这是一个口味问题——但我认为第二个版本更好地阐明了我们试图做什么。使用not()函数时,你必须检查它做了什么才能理解通用代码。使用组合,你仍然需要知道logicalNot()是什么,但全局结构是开放的,可以查看。

再举一个类似的例子,你可以在同一章节的结果反转部分达到我们得到的结果。回想一下,我们有一个可以按照西班牙语规则比较字符串的函数,但我们想反转比较的结果,使其按降序排列:

const changeSign = unaryOp("-");
palabras.sort(compose(changeSign, spanishComparison));

这段代码产生了与我们的先前排序问题相同的结果,但逻辑表达得更清晰,代码也更少:这是一个典型的函数式编程结果!让我们通过回顾我们之前讨论的另一个任务来查看一些通过组合函数的更多示例。

文件计数

我们还可以回到我们的管道。我们编写了一个单行函数来计算给定路径中的odt文件数量:

const countOdtFiles2 = (path: string): number =>
  count(filterOdt(getDir(path)));

(至少暂时)忽略这个观察结果,即这段代码不如我们后来开发的管道版本清晰,我们也可以用组合的方式编写这个函数:

const countOdtFiles2b = (path: string): number =>
  compose(count, filterOdt, getDir)(path);
countOdtFiles2b("/home/fkereki/Documents");
// 4, no change here

我们也可以将其写成一行:

compose(count, filterOdt, getDir)("/home/fkereki/Documents");

即使它不如管道版本清晰(这仅是我的个人观点,可能因为我对 Linux 的喜爱而带有偏见),这种声明式实现清楚地表明我们依赖于组合三个不同的函数来得到我们的结果——这很容易看出,并且应用了从更简单的代码片段构建大型解决方案的理念。

让我们看看另一个旨在尽可能组合尽可能多函数的例子。

查找唯一单词

最后,让我们再举一个例子,我同意,这个例子也可以用于管道。假设你有一些文本,并想从中提取所有唯一的单词:你会如何操作?如果你按步骤思考(而不是一次性创建一个完整的解决方案),你可能会提出一个类似于以下解决方案:

  1. 忽略所有非字母字符。

  2. 将所有内容转换为大写。

  3. 将文本拆分为单词。

  4. 创建一个单词集合。

(为什么是集合?因为它会自动丢弃重复的值;有关更多信息,请参阅developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Set。顺便说一下,我们将使用Array.from()方法将我们的集合转换为数组;有关更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from

现在,使用函数式编程,让我们解决每个问题:

const removeNonAlpha = (str: string): string =>
  str.replace(/[^a-z]/gi, " ");
const toUpperCase = demethodize(
  String.prototype.toUpperCase
);
const splitInWords = (str: string): string[] =>
  str.trim().split(/\s+/);
const arrayToSet = (arr: string[]): Set<string> =>
  new Set(arr);
const setToList = (set: Set<string>): string[] =>
  Array.from(set).sort();

使用这些函数,结果可以写成以下形式:

const getUniqueWords = compose(
  setToList,
  arrayToSet,
  splitInWords,
  toUpperCase,
  removeNonAlpha
);

由于你无法看到任何组合函数的参数,因此你也不需要显示getUniqueWords()的参数,所以在这种情况下,无参数风格是自然的。

现在,让我们测试我们的函数。为此,让我们将此函数应用于亚伯拉罕·林肯在葛底斯堡演讲的前两句话(我们在第五章映射和扁平化 – flatMap部分的一个例子中已经使用过它,编程声明式),并打印出其中的 43 个不同单词(相信我,我已经数过了!):

const GETTYSBURG_1_2 = `Four score and seven years ago
our fathers brought forth on this continent, a new nation, conceived in liberty, and dedicated to the proposition that all men are created equal. Now we are engaged in a great civil war, testing whether that nation, or any nation so conceived and so dedicated, can long
endure.`;
console.log(getUniqueWords(GETTYSBURG_1_2));
// Output: 43 words, namely
  'A',           'AGO',       'ALL',
  'AND',         'ANY',       'ARE',
  'BROUGHT',     'CAN',       'CIVIL',
  'CONCEIVED',   'CONTINENT', 'CREATED',
  'DEDICATED',   'ENDURE',    'ENGAGED',
  'EQUAL',       'FATHERS',   'FORTH',
  'FOUR',        'GREAT',     'IN',
  'LIBERTY',     'LONG',      'MEN',
  'NATION',      'NEW',       'NOW',
  'ON',          'OR',        'OUR',
  'PROPOSITION', 'SCORE',     'SEVEN',
  'SO',          'TESTING',   'THAT',
  'THE',         'THIS',      'TO',
  'WAR',         'WE',        'WHETHER',
  'YEARS'

当然,你可以将getUniqueWords()编写得更简洁,但我想表达的观点是,通过将解决方案组合成几个更短的步骤,你的代码更清晰,更容易理解。然而,如果你认为流水线解决方案更好,这只是一种观点!

到目前为止,我们已经看到了许多函数组合的例子,但还有另一种管理方法——通过使用高阶函数。

使用高阶函数进行组合

显然,手动组合可以与流水线类似进行。例如,我们之前编写的独特单词计数函数可以用简单的 JavaScript 风格编写:

const getUniqueWords1 = (str: string): string[] => {
  const str1 = removeNonAlpha(str);
  const str2 = toUpperCase(str1);
  const arr1 = splitInWords(str2);
  const set1 = arrayToSet(arr1);
  const arr2 = setToList(set1);
  return arr2;
};
console.log(getUniqueWords1(GETTYSBURG_1_2));
// Output: the same 43 words

或者,它可以用更简洁(但更晦涩!)的一行代码风格编写:

const getUniqueWords2 = (str: string): string[] =>
  setToList(
    arrayToSet(
      splitInWords(toUpperCase(removeNonAlpha(str)))
    )
  );
console.log(getUniqueWords2(GETTYSBURG_1_2));
// Output: the same 43 words

这工作得很好,但就像我们在学习流水线时做的那样,让我们寻找一个更通用的解决方案,这样我们就不需要每次想要组合其他函数时都编写一个新的特定函数。

组合两个函数相对简单,只需要对我们的pipeTwo()函数进行少量修改,这是我们之前在本章中看到的。我们只需交换fg以获得新的定义!

// compose.ts
const pipeTwo =
  <F extends FN, G extends FN>(f: F, g: G) =>
  (...args: Parameters<F>): ReturnType<G> =>
    g(f(...args));
const composeTwo =
  <F extends FN, G extends FN>(f: F, g: G) =>
  (...args: Parameters<G>): ReturnType<F> =>
    f(g(...args));

唯一的区别是,在流水线中,你首先应用最左边的函数,而在组合中,你首先从最右边的函数开始。这种变化表明,我们本可以使用第七章参数顺序部分中的flipTwo()高阶函数,变换函数。这更清晰吗?以下是代码:

// continued...
const composeTwoByFlipping = flipTwo(pipeTwo);

无论如何,如果我们想组合超过两个函数,我们可以利用结合性质,并编写如下内容:

const getUniqueWords3 = composeTwo(
  setToList,
  composeTwo(
    arrayToSet,
    composeTwo(
      splitInWords,
      composeTwo(toUpperCase, removeNonAlpha)
    )
  )
);

尽管这可行,但让我们追求一个更好的解决方案——我们可以提供几个。我们可以使用一个循环,就像我们编写第一个流水线函数时那样:

// continued...
function compose(...fns) {
  return (...args) => {
    let result = fnsfns.length - 1;
    for (let i = fns.length - 2; i >= 0; i--) {
      result = fnsi;
    }
    return result;
  };
}
console.log(
  compose(
    setToList,
    arrayToSet,
    splitInWords,
    toUpperCase,
    removeNonAlpha
  )(GETTYSBURG_1_2)
);
// same output as earlier

我们还可以注意到,流水线和组合是相反方向的。在流水线中,我们从左到右应用函数,而在组合中,我们从右到左应用。因此,我们可以通过反转函数的顺序并执行流水线来达到与组合相同的结果;这是一个非常实用的解决方案,我真的很喜欢!如下所示:

// continued...
function compose1(...fns) {
  return pipeline(...fns.reverse());
}

唯一棘手的部分是在调用pipeline()之前使用扩展运算符。在反转fns数组后,我们必须扩展其元素以正确调用pipeline()

另一种解决方案,不那么声明式,是使用 reduceRight(),这样我们就不需要反转函数列表,而是反转处理它们的顺序:

// continued...
function compose2(...fns) {
  return fns.reduceRight(
    (f, g) => (...args) => g(f(...args))
  );
}
console.log(
  compose2(
    setToList,
    arrayToSet,
    splitInWords,
    toUpperCase,
    removeNonAlpha
  )(GETTYSBURG_1_2)
);
// still same output

为什么以及如何使其工作?让我们看看这个调用的内部工作原理:

  • 由于没有提供初始值,f()removeNonAlpha(),而 g()toUpperCase(),所以第一个中间结果是函数,(...args) => toUpperCase(removeNonAlpha(...args));让我们称之为 step1()

  • 第二次,f() 是前一步的 step1(),而 g()splitInWords(),所以新的结果是函数,(...args) => splitInWords(step1(...args))),我们可以称之为 step2()

  • 第三次,以同样的方式,我们得到 (...args) => arrayToSet(step2(...args)))),我们称之为 step3()

  • 最后,结果是 (...args) => setToList(step3(...args)),一个函数;让我们称之为 step4()

最终结果是一个接收 (...args) 的函数,它首先应用 removeNonAlpha(),然后是 toUpperCase(),依此类推,最后通过应用 setToList() 完成。

惊讶的是,我们也可以用 reduce() 使其工作——你能看出为什么吗?推理与之前所做的是相似的,所以我们将这个作为练习留给你:

// continued...
function compose3(...fns) {
  return fns.reduceRight(pipeTwo);
}

一个对称的挑战!

在弄清楚 compose3() 的工作原理后,你可能想写一个使用 reduceRight()pipeline() 版本,只是为了对称,使事情完整!

组合的数据类型

考虑到我们为管道化所做的,对于组合的数据类型来说几乎是一样的,我们将遵循并行处理的方式。首先,我们将有一个辅助类型来检查我们的函数类型是否可以正确组合:

// compose.ts
type FnsMatchComp<FNS extends FN[]> =
  1 extends FNS["length"]
    ? boolean
    : FNS extends [
        ...infer FNInit extends FN[],
        infer FNPrev extends FN,
        infer FNLast extends FN
      ]
    ? Parameters<FNPrev> extends [ReturnType<FNLast>]
      ? FnsMatchComp<[...FNInit, FNPrev]>
      : never
    : never;

这基本上与我们为管道化所写的是一样的,只不过我们是从右到左处理函数。完成这个步骤后,我们现在可以编写我们的 Compose<> 类型:

// continued...
type Compose<FNS extends FN[]> =
  boolean extends FnsMatchComp<FNS>
    ? 1 extends FNS["length"]
      ? FNS[0]
      : FNS extends [
          infer FNFIRST extends FN,
          ...FN[],
          infer FNLAST extends FN
        ]
      ? (...args: Parameters<FNLAST>) =>
        ReturnType<FNFIRST>
      : never
    : never;

这也是管道化中已有的,只是结果的类型是对称的。最后,我们可以将类型应用到我们的组合函数上;让我们看看一个例子,因为(从逻辑上讲!)类型对所有版本的代码都是相同的!

function compose<FNS extends FN[]>(
  ...fns: FNS
): Compose<FNS>;
function compose<FNS extends FN[]>(...fns: FNS): FN {
  return (...args: Parameters<FNS[0]>) => {
    let result = fnsfns.length - 1;
    for (let i = fns.length - 2; i >= 0; i--) {
      result = fnsi;
    }
    return result;
  };
}

到目前为止,我们已经看到了我们可以用来使用管道化、链式和组合连接函数的重要方法。所有这些都工作得很好,但我们将看到有一个特殊情况会影响代码的性能,这需要一种新的处理组合的方式:转换

转换

让我们考虑一个在 JavaScript 中发生性能问题的场景,当我们处理大型数组并应用多个map()filter()reduce()操作时。如果你从一个数组开始并应用这些操作(通过链式调用,如我们在本章前面所见),你会得到期望的结果。然而,会创建许多中间数组,处理它们,然后丢弃它们——这会导致延迟。如果你处理的是小型数组,额外的时间不会产生影响,但处理大型数组(如在大数据处理中,也许在 Node.js 中,你正在处理大型数据库查询的结果),那么你可能需要一些优化。我们将通过学习一个新的用于组合函数的工具来实现这一点:转换

首先,让我们创建一些函数和数据。我们将使用一个无意义的例子来处理,因为我们不是关注实际的运算,而是关注一般的过程。我们将从一些过滤函数和一些映射函数开始:

// transducing.ts
const testOdd = (x: number): boolean => x % 2 === 1;
const testUnderFifty = (x: number): boolean => x < 50;
const duplicate = (x: number): number => x + x;
const addThree = (x: number): number => x + 3;

现在,让我们将这些映射和过滤函数应用于一个数组。首先,我们去除偶数,复制奇数,去除超过 50 的结果,最后将 3 加到所有结果上:

// continued...
const myArray = [22, 9, 60, 24, 11, 63];
const a0 = myArray
  .filter(testOdd)
  .map(duplicate)
  .filter(testUnderFifty)
  .map(addThree);
console.log(a0);
// Output: [ 21, 25 ]

下面的图示显示了这一系列操作是如何工作的:

图 8.3 – 连接 map/filter/reduce 操作会导致创建并随后丢弃中间数组

图 8.3 – 连接 map/filter/reduce 操作会导致创建并随后丢弃中间数组

在这里,我们可以看到将多个map()filter()reduce()操作连接起来会导致创建并随后丢弃中间数组(在这个例子中是三个)——对于大型数组,这可能会变得很麻烦。

我们如何优化这个问题呢?这里的问题是处理过程将第一个转换应用于输入数组;然后,第二个转换应用于结果数组;然后是第三个,依此类推。一个替代方案是取输入数组的第一个元素,并按顺序对其应用所有转换。然后,你需要取输入数组的第二个元素,并对其应用所有转换,然后取第三个,依此类推。在伪代码中,区别在于这个:

for each transformation to be applied:
    for each element in the input list:
        apply the transformation to the element

然后是这个方法:

for each element in the input list:
    for each transformation to be applied:
        apply the transformation to the element

使用第一种逻辑,我们逐个转换,将其应用于每个列表,并生成一个新的列表。这需要产生几个中间列表。使用第二种逻辑,我们逐个元素地处理,并将所有转换按顺序应用于每个元素,以得到最终输出列表,而不创建任何中间列表。

现在,问题在于能够转置转换;我们如何做到这一点?我们在 第五章 中看到了这个关键概念,声明式编程,并且我们可以用 reduce() 来定义 map()filter()。使用这些定义,而不是一系列不同的函数,我们将在每个步骤应用相同的操作(reduce()),这就是秘密!如图所示,我们通过组合所有转换来改变评估顺序,以便它们可以在单次遍历中应用,而无需任何中间数组:

图 8.4 – 通过应用转换器,我们将改变评估顺序但得到相同的结果

图 8.4 – 通过应用转换器,我们将改变评估顺序但得到相同的结果

而不是应用第一个 reduce() 操作,将其结果传递给第二个,然后传递给第三个,依此类推,我们将所有归约函数组合成一个单一的函数!让我们分析一下。

组合归约器

实际上,我们想要的是将每个函数(testOdd()duplicate() 等)转换成一个归约操作,该操作将调用以下归约器。几个高阶函数将有所帮助;一个用于映射函数,另一个用于过滤函数。有了这个想法,操作的结果将被传递到下一个操作,避免了中间数组:

// continued...
const mapTR =
  <V, W>(fn: (x: V) => W) =>
  <A>(reducer: (am: A, wm: W) => A) =>
  (accum: A, value: V): A =>
    reducer(accum, fn(value));
const filterTR =
  <V>(fn: (x: V) => boolean) =>
  <A>(reducer: (af: A, wf: V) => A) =>
  (accum: A, value: V): A =>
    fn(value) ? reducer(accum, value) : accum;

这两个转换函数是 转换器:接受一个归约函数并返回一个新的归约函数的函数。(一些趣闻:单词 transduce 来自拉丁语,意为转换、运输、转换、改变、转换,并在许多不同的领域中得到应用,包括生物学、心理学、机器学习、物理学、电子学等。)

打字并不太难。对于映射,我们假设一个映射函数,它接收类型为 V 的值并产生类型为 W 的结果。通用的归约器接收类型为 A 的累加器和一个类型为 W 的值,并产生一个新的累加器,也是类型 A。对于过滤,过滤函数接收类型为 V 的值并产生一个 Boolean 值,而归约器接收类型为 A 的累加器和一个类型为 V 的值,返回类型 A 的结果。

我们如何使用这些转换器?我们可以编写如下代码,尽管我们稍后会想要一个更抽象、更通用的版本:

// continued...
const testOddR = filterTR(testOdd);
const testUnderFiftyR = filterTR(testUnderFifty);
const duplicateR = mapTR(duplicate);
const addThreeR = mapTR(addThree);

我们原始的四个函数都经过了转换,因此它们将计算结果并调用归约器来处理这些结果。以 addThreeR() 为例,它将向其输入值加三,并将增加后的值传递给下一个归约器,在这种情况下是 addToArray()

这将构建最终的结果数组。现在,我们可以一步完成整个转换:

// continued...
const addToArray = (a: any[], v: any): any[] => {
  a.push(v);
  return a;
};
const a1 = myArray.reduce(
  testOddR(
    duplicateR(testUnderFiftyR(addThreeR(addToArray)))
  ),
  []
);
console.log(a1);
// Output: [ 21, 25 ], again

这听起来可能有些复杂,但它确实有效!然而,我们可以通过使用 compose() 函数来简化我们的代码:

// continued...
const transduce = <A>(arr: A[], fns: FN[]) =>
  arr.reduce(compose(...fns)(addToArray), []);
console.log(
  transduce(myArray, [
    testOddR,
    duplicateR,
    testUnderFiftyR,
    addThreeR,
  ])
);
// Output: [ 21, 25 ], yet again

代码是相同的,但请注意compose(...fns)(addToArray)表达式:我们将所有映射和过滤函数(最后一个为addToArray)组合起来,以构建输出。然而,这并不像我们希望的那样通用:为什么我们必须创建一个数组?为什么我们不能有一个不同的最终 reducing 函数?我们可以通过进一步泛化做得更好。

为所有 reducer 进行泛化

为了能够与所有类型的 reducer 一起工作并产生它们构建的任何类型的输出,我们需要进行一些小的修改。想法很简单:让我们修改我们的transduce()函数,使其能够接受一个最终 reducer 和一个累加器的起始值:

// continued...
const transduce2 = <A>(
  arr: A[],
  fns: FN[],
  reducer: FN = addToArray,
  initial: any = []
) => arr.reduce(compose(...fns)(reducer), initial);
console.log(
  transduce2(myArray, [
    testOddR,
    duplicateR,
    testUnderFiftyR,
    addThreeR,
  ])
);
// Output: [ 21, 25 ], always

为了使这个函数更易于使用,我们指定了我们的数组构建函数(以及一个空数组作为初始累加器值),这样如果你跳过这两个参数,你将得到一个生成数组的 reducer。现在,让我们看看另一个选项:不是数组,而是计算所有映射和过滤后的结果数字的总和:

// continued...
console.log(
  transduce2(
    myArray,
    [testOddR, duplicateR, testUnderFiftyR, addThreeR],
    (acc, value) => acc + value,
    0
  )
);
// 46

通过使用 transducers,我们已经能够优化一系列mapfilterreduce操作,使得输入数组只处理一次,并直接产生输出结果(无论是数组还是单个值),而不创建任何中间数组;这是一个很好的改进!

我们已经看到了几种连接函数的方法;为了圆满结束,让我们看看如何为连接函数编写单元测试。

测试连接的函数

让我们通过考虑测试本章中我们看到的所有方式连接的函数来完成。由于管道和组合的机制相似,我们将查看两者的示例。除了由于函数评估的左到右或右到左顺序导致的逻辑差异外,它们不会有所不同。

测试管道函数

当涉及到管道时,我们可以从如何测试pipeTwo()函数开始,因为设置将与pipeline()相似。我们需要创建一些模拟并检查它们是否被正确地调用了正确的次数,以及每次调用时是否接收到了正确的参数。我们将它们设置为提供一个已知答案的调用。

通过这样做,我们可以检查函数的输出是否成为管道中下一个函数的输入:

// pipetwo.test.ts
describe("pipeTwo", function () {
  it("works with single arguments", () => {
    const fn1 = jest.fn().mockReturnValue(1);
    const fn2 = jest.fn().mockReturnValue(2);
    const pipe = pipeTwo(fn1, fn2);
    const result = pipe(22);
    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(22);
    expect(fn2).toHaveBeenCalledWith(1);
    expect(result).toBe(2);
  });
  it("works with multiple arguments", () => {
    const fn1 = jest.fn().mockReturnValue(11);
    const fn2 = jest.fn().mockReturnValue(22);
    const pipe = pipeTwo(fn1, fn2);
    const result = pipe(12, 4, 56);
    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(12, 4, 56);
    expect(fn2).toHaveBeenCalledWith(11);
    expect(result).toBe(22);
  });
});

由于我们的函数始终接收两个函数作为参数,所以测试内容很少。测试之间的唯一区别是,一个显示了应用于单个参数的管道,而另一个显示了应用于多个参数的管道。

接下来是pipeline(),测试将非常相似。然而,我们可以添加一个针对单函数管道(一个边界情况!)的测试,以及一个包含四个函数的测试:

// pipeline.test.ts
describe("pipeline", function () {
  it("works with a single function", () => {
    const fn1 = jest.fn().mockReturnValue(11);
    const pipe = pipeline(fn1);
    const result = pipe(60);
    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(60);
    expect(result).toBe(11);
  });
  it("works with single arguments", () => {
    const fn1 = jest.fn().mockReturnValue(1);
    const fn2 = jest.fn().mockReturnValue(2);
    const pipe = pipeline(fn1, fn2);
    const result = pipe(22);
    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(22);
    expect(fn2).toHaveBeenCalledWith(1);
    expect(result).toBe(2);
  });
  it("works with multiple arguments", () => {
    const fn1 = jest.fn().mockReturnValue(11);
    const fn2 = jest.fn().mockReturnValue(22);
    const pipe = pipeline(fn1, fn2);
    const result = pipe(12, 4, 56);
    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(12, 4, 56);
    expect(fn2).toHaveBeenCalledWith(11);
    expect(result).toBe(22);
  });
  it("works with 4 functions, multiple arguments", () => {
    const fn1 = jest.fn().mockReturnValue(111);
    const fn2 = jest.fn().mockReturnValue(222);
    const fn3 = jest.fn().mockReturnValue(333);
    const fn4 = jest.fn().mockReturnValue(444);
    const pipe = pipeline(fn1, fn2, fn3, fn4);
    const result = pipe(24, 11, 63);
    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn3).toHaveBeenCalledTimes(1);
    expect(fn4).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(24, 11, 63);
    expect(fn2).toHaveBeenCalledWith(111);
    expect(fn3).toHaveBeenCalledWith(222);
    expect(fn4).toHaveBeenCalledWith(333);
    expect(result).toBe(444);
  });
});

测试组合函数

对于组合,风格相同(除了函数评估的顺序相反),所以让我们看看一个单独的测试——在这里,我只是改变了前面测试中函数的顺序:

// compose.test.ts
describe("compose", function () {
  // other tests, omitted here
  it("works with 4 functions, multiple arguments", () => {
    const fn1 = jest.fn().mockReturnValue(111);
    const fn2 = jest.fn().mockReturnValue(222);
    const fn3 = jest.fn().mockReturnValue(333);
    const fn4 = jest.fn().mockReturnValue(444);
    const comp = compose(fn4, fn3, fn2, fn1);
    const result = comp(24, 11, 63);
    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn3).toHaveBeenCalledTimes(1);
    expect(fn4).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(24, 11, 63);
    expect(fn2).toHaveBeenCalledWith(111);
    expect(fn3).toHaveBeenCalledWith(222);
    expect(fn4).toHaveBeenCalledWith(333);
    expect(result).toBe(444);
  });
});

测试链式函数

为了测试chainify()函数,我选择使用我之前创建的City对象——我不想与模拟、存根、间谍等打交道;我想确保代码在正常条件下能够工作:

// chaining.test.ts
class City {
// as above
}
let myCity: City;
let myCity2: Chainify<City>;
describe("chainify", function () {
  beforeEach(() => {
    myCity = new City(
      "Montevideo, Uruguay",
      -34.9011,
      -56.1645
    );
    myCity2 = chainify(myCity);
  });
  it("doesn't affect get functions", () => {
    expect(myCity2.getName()).toBe("Montevideo, Uruguay");
    expect(myCity2.getCoords()[0]).toBe(-34.9011);
    expect(myCity2.getCoords()[1]).toBe(-56.1645);
  });
  it("doesn't affect getting attributes", () => {
    expect(myCity2.name).toBe("Montevideo, Uruguay");
    expect(myCity2.lat).toBe(-34.9011);
    expect(myCity2.long).toBe(-56.1645);
  });
  it("returns itself from setting functions", () => {
    //    expect(myCity2.setName("Other
      name")).toBe(myCity2);
    expect(myCity2.setLat(11)).toBe(myCity2);
    expect(myCity2.setLong(22)).toBe(myCity2);
  });
  it("allows chaining", () => {
    const newCoords = myCity2
      .setName("Pune, India")
      .setLat(18.5626)
      .setLong(73.8087)
      .getCoords();
    expect(myCity2.name).toBe("Pune, India");
    expect(newCoords[0]).toBe(18.5626);
    expect(newCoords[1]).toBe(73.8087);
  });
});

测试转换函数

我们在本章的早期尝试了几个示例,很容易将它们转换为测试。我们还将添加针对边界情况(例如,只有一个函数,只有映射函数等)的新测试,以增加通用性。为了简单起见,我继续使用之前使用的相同数据数组以及映射和过滤函数:

// transducing.test.ts
describe("transducing", () => {
  it("works with several functions", () => {
    expect(
      transduce(myArray, [
        testOddR,
        duplicateR,
        testUnderFiftyR,
        addThreeR,
      ])
    ).toEqual([21, 25]);
  });
  it("works with just one function at all", () => {
    expect(transduce(myArray, [testOddR])).toEqual([
      9, 11, 63,
    ]);
    expect(transduce(myArray, [addThreeR])).toEqual([
      25, 12, 63, 27, 14, 66,
    ]);
  });
  it("works with just mapping", () => {
    expect(
      transduce(myArray, [addThreeR, duplicateR])
    ).toEqual([50, 24, 126, 54, 28, 132]);
  });
  it("works with just filtering", () => {
    expect(
      transduce(myArray, [testOddR, testUnderFiftyR])
    ).toEqual([9, 11]);
  });
  it("works with special reducer", () => {
    expect(
      transduce2(
        myArray,
        [testOddR, duplicateR, testUnderFiftyR, addThreeR],
        (acc, value) => acc + value,
        0
      )
    ).toBe(46);
  });
});

所有这些测试的最终结果可以在以下屏幕截图中看到:

图 8.5 – 连接函数测试的成功运行

图 8.5 – 连接函数测试的成功运行

如我们所见,所有测试都成功通过;很好!

摘要

在本章中,我们学习了如何通过使用管道和组合以不同的方式连接几个其他函数来创建新函数。我们还探讨了流畅接口,它应用了链式调用,以及转换,这是一种将归约器组合起来以获得更快的转换序列的方法。使用这些方法,你将能够从现有函数中创建新函数,并继续以我们偏爱的声明性方式编程。

第九章 设计函数中,我们将继续学习函数设计,并研究递归的使用,这是函数式编程中的一个基本工具,它允许进行非常干净的算法设计。

问题

8.1 headline(sentence)函数将接收一个字符串作为参数,并返回一个适当的大写版本。单词之间用空格分隔。通过连接较小的函数来构建这个函数:

console.log(headline("Alice's ADVENTURES in WoNdErLaNd"));
// Alice's Adventures In Wonderland

8.2 done===true) 或挂起 (done===false)。你的目标是生成一个包含给定人员 ID 的数组,该人员通过名称识别,应与责任字段匹配。通过使用组合或管道来解决此问题:

const allTasks = {
  date: "2017-09-22",
  byPerson: [
    {
      responsible: "EG",
      tasks: [
        { id: 111, desc: "task 111", done: false },
        { id: 222, desc: "task 222", done: false },
      ],
    },
    {
      responsible: "FK",
      tasks: [
        { id: 555, desc: "task 555", done: false },
        { id: 777, desc: "task 777", done: true },
        { id: 999, desc: "task 999", done: false },
      ],
    },
    {
      responsible: "ST",
      tasks: [{ id: 444, desc: "task 444", done: true }],
    },
  ],
};

确保你的代码在例如,你要找的人没有出现在网络服务结果中时不会抛出异常!

8.3 抽象思维:假设你正在查看一些较旧的代码,并发现一个看起来像以下这样的函数。(我保留了模糊和抽象的名称,这样你就可以专注于结构而不是实际的功能)。你能将其转换为无点风格吗?

function getSomeResults(things) {
  return sort(group(filter(select(things))));
}

8.4 使用Pipeline<>类型加上一个新的Reverse<>类型来创建Compose<>类型。这个新类型应该是什么?

8.5 空管道? 我们的管道函数是否可以与一个空的函数数组一起工作?你能修复这个问题吗?

8.6 我们编写的addToArray()函数实际上是不纯的?(如果你还不确定,请查看第四章 行为规范中的参数突变部分!)如果我们按照以下方式编写它会更好吗?我们应该这样做吗?

const addToArray = (a, v) => [...a, v];

8.7 map()操作?如果你只有filter()操作会怎样?

8.8 myCity2 对象与原始 myCity 对象的类型不同。它的确切类型是什么?

第九章:设计函数 – 递归

第八章 连接函数中,我们考虑了更多从现有函数组合创建新函数的方法。在这里,我们将探讨一个不同的主题:如何通过应用递归技术以典型的函数式方式设计和编写函数。

我们将涵盖以下主题:

  • 理解递归是什么以及如何思考以产生递归解决方案

  • 将递归应用于一些著名问题,例如找零或汉诺塔问题

  • 使用递归而不是迭代来重新实现前面章节中的一些高阶函数

  • 轻松编写搜索和回溯算法

  • 遍历数据结构,如树,以处理文件系统目录或浏览器 DOM

  • 理解互递归并将其应用于正确评估算术表达式等问题

  • 解决由浏览器 JavaScript 引擎考虑引起的某些限制

使用递归

递归是函数式编程(FP)的关键技术,以至于某些语言不提供迭代或循环,而完全使用递归(我们之前提到的 Haskell 就是这样一个典型的例子)。计算机科学的一个基本事实是,你可以用递归做到的事情,你也可以用迭代(循环)做到,反之亦然。关键概念是,有许多算法的定义如果以递归方式工作会更容易。另一方面,递归并不总是被教授,许多程序员,即使知道了递归,也更愿意不使用它。因此,在本节中,我们将看到几个递归思维的例子,以便您可以将它们应用于您的函数式编码。

一个典型、经常引用且非常古老的计算机笑话!

词典定义:递归: (n) 参见 递归

但什么是递归?定义递归有很多方法,但我看到的最简单的一种是这样的:一个函数一次又一次地调用自己,直到不再调用为止。一个更复杂的情况是互递归,最简单的例子是我们有两个函数,A()B(),每个函数都一次又一次地调用另一个,直到它们完成。

递归是解决多种问题的自然技术,例如以下问题:

  • 数学定义,例如斐波那契数列或一个数的阶乘

  • 与递归定义的结构相关的数据结构算法,例如列表(一个列表要么是空的,要么由一个头节点后跟一个节点列表组成)或树(树可以定义为称为根的特殊节点,它连接零个或多个树)

  • 基于语法规则的编译器语法分析,这些规则本身又依赖于其他规则,这些规则又依赖于其他规则,如此等等

以及更多!它甚至出现在艺术和幽默中,如图 9.1所示:

图 9.1 – Google 自己也开玩笑说:如果你问及递归,它会回答,“你是指:递归”

图 9.1 – Google 自己也开玩笑说:如果你问及递归,它会回答,“你是指:递归”

除了一些不需要进一步计算的基本情况外,递归函数必须调用自己一次或多次以执行所需的计算的一部分。这个概念可能在这个阶段不是很清楚,所以在下文中,我们将看到我们如何进行递归思考,并通过应用这种技术解决几个常见问题。

递归思考

解决递归问题的关键是假设你已经有一个能够完成你需要的功能的函数,并且只是调用它。(这听起来是不是很奇怪?实际上,这是非常合适的:如果你想用递归解决问题,你必须首先解决它...) 另一方面,如果你试图在脑海中弄清楚递归调用是如何工作的,并试图跟随流程,你可能会迷失方向。所以,你需要做的是以下:

  1. 假设你已经有一个合适的函数来解决你的问题。

  2. 看看如何通过解决一个(或多个)更小的问题来解决大问题。

  3. 使用步骤 1中想象出的函数来解决这些问题。

  4. 决定你的基本情况是什么。确保它们足够简单,可以直接解决,而无需更多的调用。

考虑到这些要点,你可以通过递归解决问题,因为你将拥有你的递归解决方案的基本结构。

应用递归解决问题通常有三种常用方法:

  • 减少和征服是最简单的情况,其中解决问题直接依赖于解决它自己的一个更简单的情况。

  • 分而治之是一种更通用的方法。想法是尝试将你的问题分解为两个或更多更小的版本,递归地解决它们,并使用这些解决方案来解决原始问题。这种技术与减少和征服之间的唯一区别是,你必须解决两个或更多其他问题,而不仅仅是解决一个问题。

  • 动态规划可以看作是分而治之的一种变体:基本上,你通过将复杂问题分解为一系列相对简单的同一问题的版本,并按顺序解决每个版本来解决问题;然而,这个策略中的关键思想是存储之前找到的解决方案,这样当你再次需要解决更简单的情况的解决方案时,你不会直接应用递归,而是使用存储的结果,避免不必要的重复计算。

在本节中,我们将探讨一些问题,并通过递归思考来解决它们。当然,我们将在本章的其余部分看到递归的更多应用;在这里,我们将专注于创建此类算法所需的关键决策和问题。

减少和征服 – 搜索

递归最常见的情况只涉及一个简单的情况。我们已经看到了一些这样的例子,比如无处不在的阶乘计算:要计算n的阶乘,你之前需要计算n-1 的阶乘。(参见第一章成为函数式编程者。)现在让我们转向一个非数学的例子。

你也会使用这种减少和征服策略来搜索数组中的元素。如果数组为空,那么显然搜索的值不在那里;否则,如果它是数组的第一个元素或者它在数组的其余部分中,那么结果就在数组中。以下代码正是这样做的:

// search.ts
const search = <A>(arr: A[], key: A): boolean => {
  if (arr.length === 0) {
    return false;
  } else if (arr[0] === key) {
    return true;
  } else {
    return search(arr.slice(1), key);
  }
};

这种实现直接反映了我们的解释,验证其正确性也很容易。

顺便说一句,作为一个预防措施,让我们看看同一概念的两种进一步实现。你可以稍微缩短搜索函数——它是否仍然清晰?

我们使用三元运算符来检测数组是否为空,并使用布尔||运算符在第一个元素是所需元素时返回true,否则返回递归搜索的结果:

// continued...
const search2 = <A>(arr: A[], key: A): boolean =>
  arr.length === 0
    ? false
    : arr[0] === key || search2(arr.slice(1), key);

稀疏性可以更进一步!使用&&作为快捷方式是一种常见的习语:

// continued...
const search3 = <A>(arr: A[], key: A): boolean =>
  !!arr.length &&
  (arr[0] === key || search3(arr.slice(1), key));

我并不是真的建议你以这种方式编写函数——相反,把它当作一个警告,提醒一些 FP 开发者试图追求最紧密、最短的解决方案,而不考虑清晰度!

减少和征服 – 计算幂

另一个经典的例子与高效计算数字的幂有关。如果你想计算,比如说,2 的 13 次幂(2¹³),那么你可以用 12 次乘法来完成;然而,你可以通过将 2¹³ 写成以下形式来做得更好:

= 2 乘以 212

= 2 乘以 46

= 2 乘以 163

= 2 乘以 16 乘以 162

= 2 乘以 16 乘以 2561

= 8192

这种总乘法次数的减少可能看起来不太令人印象深刻,但从算法复杂性的角度来看,它使我们能够将计算的阶数从 O(n)降低到 O(log n)。在一些需要将数字提高到非常高指数的加密相关方法中,这会带来显著差异。我们可以用几行代码实现这个递归算法,如下所示:

// power.ts
const powerN = (base: number, power: number): number => {
  if (power === 0) {
    return 1;
  } else if (power % 2) {
    // odd power?
    return base * powerN(base, power - 1);
  } else {
    // even power?
    return powerN(base * base, power / 2);
  }
};

额外的速度

当用于生产实现时,使用位运算而不是取模和除法。检查一个数是否为奇数可以写成power & 1,而除以 2 可以通过power >> 1实现。这些替代计算比替换操作要快得多。

当达到基本情况(将某物提高到零次幂)或基于较小指数之前计算出的幂时,计算幂是简单的。(如果你愿意,可以为将某物提高到一次幂添加另一个基本情况。)这些观察表明,我们正在看到减少和征服递归策略的典型教材案例。

最后,我们的一些高阶函数,如map()reduce()filter(),也应用了这种技术;我们将在本章后面探讨这一点。

分而治之 – 汉诺塔

使用分而治之的策略,解决问题需要两个或更多的递归解决方案。首先,让我们考虑一个 19 世纪由法国数学家Édouard Lucas 发明的经典谜题。这个谜题涉及印度的寺庙,有 3 个柱子,第一个柱子上有 64 个直径递减的金盘。僧侣们必须按照两个规则将磁盘从第一个柱子移动到最后一个柱子:一次只能移动一个磁盘,较大的磁盘不能放在较小的磁盘上面。根据传说,当 64 个磁盘被移动时,世界将结束。这个谜题通常以“汉诺塔”的名字(是的,他们改变了国家!)进行营销,磁盘数量少于 10 个。见图 9.2*:

图 9.2 – 经典的汉诺塔问题有一个简单的递归解决方案

图 9.2 – 经典的汉诺塔问题有一个简单的递归解决方案

很长,很长的时间…

解决n个磁盘的问题需要 2^n-1 次移动。原始问题需要 264-1 次移动,每次移动一秒,需要超过 5840 亿年才能完成——这是一个非常长的时间,考虑到宇宙的年龄估计只有 1380 亿年!

假设我们已经有了一个函数,该函数可以解决使用剩余柱子作为额外辅助工具,将任意数量的磁盘从源柱子移动到目标柱子的问题。如果你已经有一个函数可以解决这个问题:hanoi(disks, from, to, extra),那么考虑解决一般问题:如果你已经有了解决该问题的函数,你会怎么做:通过执行以下步骤来解决这个问题(这个函数仍然是未编写的!):

  1. 将除了最后一个磁盘之外的所有磁盘移动到额外的柱子。

  2. 将最后一个磁盘移动到目的地柱子。

  3. 将所有磁盘从额外的柱子(你之前放置它们的地方)移动到目的地。

但我们的基本情况又是什么呢?我们可以决定,移动单个磁盘时不需要函数;你只需直接移动磁盘即可。当编码时,它变成了以下这样:

// hanoi.ts
const hanoi = (
  disks: number,
  from: Post,
  to: Post,
  extra: Post
) => {
  if (disks === 1) {
    console.log(
      `Move disk 1 from post ${from} to post ${to}`
    );
  } else {
    hanoi(disks - 1, from, extra, to);
    console.log(
      `Move disk ${disks} from post ${from} to post ${to}`
    );
    hanoi(disks - 1, extra, to, from);
  }
};

使用Post类型可能不是必需的,但仍然是好习惯。我们可以快速验证这段代码是否有效:

hanoi (4, "A", "B", "C");
// move all disks from A to B
Move disk 1 from post A to post C
Move disk 2 from post A to post B
Move disk 1 from post C to post B
Move disk 3 from post A to post C
Move disk 1 from post B to post A
Move disk 2 from post B to post C
Move disk 1 from post A to post C
Move disk 4 from post A to post B
Move disk 1 from post C to post B
Move disk 2 from post C to post A
Move disk 1 from post B to post A
Move disk 3 from post C to post B
Move disk 1 from post A to post C
Move disk 2 from post A to post B
Move disk 1 from post C to post B

只有一个小细节需要考虑,这可以进一步简化函数。在这段代码中,我们的基本情况(不需要进一步递归的情况)是当disks等于一。你也可以通过让磁盘数量下降到零并简单地什么都不做来解决这个问题——毕竟,从一个柱子移动零个磁盘到另一个柱子什么也不做就能完成!修改后的代码如下:

// continued...
const hanoi2 = (
  disks: number,
  from: Post,
  to: Post,
  extra: Post
) => {
  if (disks > 0) {
    hanoi(disks - 1, from, extra, to);
    console.log(
      `Move disk ${disks} from post ${from} to post ${to}`
    );
    hanoi(disks - 1, extra, to, from);
  }
};

在进行递归调用之前,我们不必检查是否有磁盘要移动,我们只需跳过检查,让函数在下一级测试是否有事情要做。

手动解决汉诺塔问题

如果你手动解这个谜题,有一个简单的解决方案:在奇数次移动时,总是将较小的磁盘移动到下一个柱子(如果磁盘总数是奇数)或上一个柱子(如果磁盘总数是偶数)。在偶数次移动时,做出唯一可能的移动,不涉及较小的磁盘。

所以,递归算法设计的原则是有效的:假设你已经有了你想要的功能,然后使用它来构建自己!

分而治之 – 排序

我们可以用排序来看到分而治之策略的另一个例子。一种排序数组的方法称为快速排序,基于以下步骤:

  1. 如果你的数组有 0 个或 1 个元素,则无需操作;它已经排序了(这是基本情况)。

  2. 选择数组中的一个元素(称为枢轴)并将数组的其余部分分成两个子数组:小于所选元素的元素和大于或等于所选元素的元素。

  3. 递归排序每个子数组。

  4. 要生成原始数组的排序版本,将两个排序结果连接起来,中间是枢轴。

让我们看看这个简单版本(有一些更好的优化实现,但我们现在对递归逻辑感兴趣)。通常,建议选择数组中的一个随机元素以避免一些性能边界情况,但为了我们的例子,让我们只取第一个:

// quicksort.ts
const quicksort = <A>(arr: A[]): A[] => {
  if (arr.length < 2) {
    return arr;
  } else {
    const pivot = arr[0];
    const smaller = arr.slice(1).filter((x) => x < pivot);
    const greaterEqual = arr
      .slice(1)
      .filter((x) => x >= pivot);
    return [
      ...quicksort(smaller),
      pivot,
      ...quicksort(greaterEqual),
    ];
  }
};
console.log(quicksort([22, 9, 60, 12, 4, 56]));
// [4, 9, 12, 22, 56, 60]

我们可以在图 9**.3中看到这是如何工作的:每个数组和子数组的枢轴都被下划线标出。分割用虚线箭头表示,并用实线连接:

图 9.3 – 快速排序递归地对数组进行排序,应用分而治之策略将原始问题简化为更小的问题

图 9.3 – 快速排序递归地对数组进行排序,应用分而治之策略将原始问题简化为更小的问题

容易出现的错误!

正确编写快速排序并非易事;请参阅本章末尾的问题 9.8,以获取一个几乎正确但并非完全正确的替代版本!

我们已经看到了将问题简化为其自身更简单版本的基本策略。现在让我们看看一个重要的优化,这是许多算法的关键。

动态规划 – 改变

第三种一般策略,动态规划,假设你将不得不解决许多更小的问题,但不是每次都使用递归,而是依赖于你存储之前找到的解决方案 – 也就是说,记忆化!在第四章行为规范和后来在更优雅的第六章生成函数中,我们已经看到了如何优化通常的斐波那契数列的计算,避免不必要的重复调用。现在让我们考虑另一个问题。

给定一定数量的美元和现有纸币的面额列表,计算我们可以用不同组合的纸币支付该金额的不同方式有多少种。假设你能够无限制地使用每种面额的纸币。我们该如何解决这个问题呢?让我们先考虑不需要进一步计算的基本情况。它们如下:

  • 支付负值是不可能的,所以在这种情况下,我们应该返回 0

  • 支付零美元只有一种方式(不给出任何纸币),所以在这种情况下,我们应该返回 1

  • 如果没有提供任何纸币,就无法支付任何正金额,所以在这种情况下,也应该返回 0

最后,我们可以回答这个问题:使用给定的纸币组合支付 N 美元有多少种方式?我们可以考虑两种情况:我们完全不使用大面额纸币,只用小面额纸币支付金额,或者我们可以取一张大面额纸币并重新考虑这个问题。(现在我们先不考虑避免重复计算的问题。)

在第一种情况下,我们应该用相同的 N 值调用我们假设存在的函数,并从可用纸币列表中删除最大面额的纸币。

在第二种情况下,我们应该用 N 减去最大面额的纸币,保持纸币列表不变,如下面的代码所示:

// makeChange.ts
const makeChange = (n: number, bills: number[]): number => {
  if (n < 0) {
    return 0; // no way of paying negative amounts
  } else if (n == 0) {
    return 1; // one single way of paying $0: with no bills
  } else if (bills.length == 0) {
    // here, n>0
    return 0; // no bills? no way of paying
  } else {
    return (
      makeChange(n, bills.slice(1)) +
      makeChange(n - bills[0], bills)
    );
  }
};
console.log(makeChange(64, [100, 50, 20, 10, 5, 2, 1]));
// 969 ways of paying $64

现在,让我们进行一些优化。这个算法经常需要反复重新计算相同的值。(为了验证这一点,在 makeChange() 中将 console.log(n, bills.length) 作为第一行添加——但要做好大量输出的准备!)然而,我们已经有了解决这个问题的解决方案:记忆化!由于我们正在将这种技术应用于二元函数,我们需要一个处理多个参数的记忆化算法版本。我们在 第六章**, 产生函数 中看到了它:

// continued...
const memoize4 = <T extends (...x: any[]) => any>(
  fn: T
): ((...x: Parameters<T>) => ReturnType<T>) => {
  const cache = {} as Record<string, ReturnType<T>>;
  return (...args) => {
    const strX = JSON.stringify(args);
    return strX in cache
      ? cache[strX]
      : (cache[strX] = fn(...args));
  };
};
const makeChange = memoize4((n, bills) => {
// ...same as above
});

makeChange() 的记忆化版本效率更高,你可以通过日志来验证。当然,你可以自己处理重复(例如,通过保留已计算值的数组),但在我看来,记忆化解决方案更好,因为它将两个函数组合起来,为给定的问题产生更好的解决方案。

Higher-order functions revisited

经典的函数式编程技术完全不使用迭代,而是仅通过递归作为进行某些循环的唯一方式。让我们回顾一下我们在 第五章 编程声明式 中已经看到的一些函数,例如 map()reduce()find()filter(),看看我们如何仅通过递归来完成。

我们并不打算用我们的函数替换基本的 JavaScript 函数:我们的递归 polyfill 的性能可能会更差,仅仅因为函数使用了递归,我们不会从中获得任何优势。相反,我们想研究如何在递归方式下执行迭代,以便我们的努力更具教育意义而不是实际意义,好吗?

映射和过滤

映射和过滤在某种程度上很相似,因为两者都意味着遍历数组中的所有元素并对每个元素应用回调以产生输出。让我们首先制定映射逻辑,这将有几个要点需要解决,然后我们应该看到过滤已经变得几乎易如反掌,只需要做小的改动。

对于映射,鉴于我们正在开发递归函数,我们需要一个基本情况。幸运的是,这很简单:映射一个空数组会产生一个新的空数组。映射一个非空数组可以通过首先将映射函数应用于数组的第一个元素,然后递归映射数组的其余部分,最后,产生一个累积了两个结果的单一数组来完成。

基于这个想法,我们可以制定一个简单的初始版本:让我们称它为 mapR(),只是为了记住我们正在处理自己的、递归版本的 map();然而,请注意——我们的 polyfill 有一些错误!我们将逐一解决它们。以下是编写我们自己的映射代码的第一次尝试:

// map.ts
const mapR = <A, B>(arr: A[], cb: (x: A) => B): B[] =>
  arr.length === 0
    ? []
    : [cb(arr[0])].concat(mapR(arr.slice(1), cb));

让我们测试一下:

const aaa = [1, 2, 4, 5, 7];
const timesTen = (x: number): number => x * 10;
console.log(aaa.map(timesTen));   // [10, 20, 40, 50, 70]
console.log(mapR(aaa, timesTen)); // [10, 20, 40, 50, 70]

太好了!我们的 mapR() 函数似乎产生了与 map() 相同的结果。然而,我们的回调函数不应该接收一些额外的参数吗,特别是数组中的索引和原始数组本身?(查看 map() 回调函数的定义,请参阅 developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Array/map。)

我们的实施方案还没有完全准备好。让我们首先通过一个简单的例子来看看它是如何失败的:

const timesTenPlusI = (v: number, i: number) => 10 * v + i;
console.log(aaa.map(timesTenPlusI)); // [10, 21, 42, 53,
  74]
console.log(mapR(aaa, timesTenPlusI));

如果你使用 JavaScript,最后的调用将产生 [NaN, NaN, NaN, NaN, NaN]——TypeScript 检测到错误,因为 timesTenPlusI() 的类型是错误的:

Argument of type '(v: number, i: number) => number' is not assignable to parameter of type '(x: number) => number'.

生成适当的索引位置需要为递归添加一个额外的参数。尽管如此,这基本上很简单:当我们开始时,我们有 index=0,当我们递归调用我们的函数时,它带有 index+1 位置。访问原始数组还需要另一个参数,这个参数永远不会改变,现在我们有一个更好的映射函数:

// continued...
const mapR2 = <A, B>(
  arr: A[],
  cb: (x: A, i: number, arr: A[]) => B,
  i = 0,
  orig = arr
): B[] =>
  arr.length == 0
    ? []
    : [cb(arr[0], i, orig)].concat(
        mapR2(arr.slice(1), cb, i + 1, orig)
      );
const senseless = (
  x: number,
  i: number,
  a: number[]
): number => x * 10 + i + a[i] / 10;
console.log(aaa.map(senseless));
// [10.1, 21.2, 42.4, 53.5, 74.7]
console.log(mapR2(aaa, senseless));
// [10.1, 21.2, 42.4, 53.5, 74.7]

太好了!当你用递归代替迭代时,你无法访问索引,所以如果你需要它(就像我们的情况一样),你必须自己生成它。这是一个常用的技术,所以制定我们的 map() 替代品是个好主意。

然而,函数中额外的参数并不好;开发者可能会意外地提供它们,结果将不可预测。所以,使用另一种常用的技术,让我们定义一个内部函数 mapLoop() 来处理循环。实际上,当你只使用递归时,这是实现循环的常用方法;看看以下代码,其中额外的函数在外部是不可访问的:

// continued...
const mapR3 = <A, B>(
  orig: A[],
  cb: (x: A, i: number, a: A[]) => B
): B[] => {
  const mapLoop = (arr: A[], i: number): B[] =>
    arr.length == 0
      ? []
      : [cb(arr[0], i, orig)].concat(
          mapLoop(arr.slice(1), i + 1)
        );
  return mapLoop(orig, 0);
};
console.log(mapR3(aaa, senseless));
// [10.1, 21.2, 42.4, 53.5, 74.7], again

只有一个悬而未决的问题:如果原始数组有一些缺失的元素,它们应该在循环期间被跳过。让我们看看一个简单的例子,使用纯 JavaScript:

[1, 2, , , 5].map(tenTimes)
// [10, 20, undefined × 2, 50]

(为什么是 JavaScript?TypeScript 会提出异议,因为要处理的数组有 number | undefined 类型,但 timesTen() 函数期望的是一个只有 number 类型的数组。顺便说一句,我还不得不禁用 ESLint 的 no-sparse-array 规则,它会捕获数组中意外的额外逗号。)

幸运的是,解决这个问题很简单——并且很高兴地知道在这里获得的所有经验都将帮助我们编写本节中的其他函数!你能理解以下代码中的修复,除了允许数组中的值是 undefined 的明显变化,我使用了辅助的 Opt<> 类型定义吗?

// continued...
type Opt<X> = X | undefined;
const mapR4 = <A, B>(
  orig: Opt<A>[],
  cb: (x: A, i: number, a: Opt<A>[]) => B
): Opt<B>[] => {
  const mapLoop = (arr: Opt<A>[], i: number): Opt<B>[] =>
    arr.length == 0
      ? []
      : !(0 in arr) || arr[0] === undefined
      ? ([,] as Opt<B>[]).concat(
          mapLoop(arr.slice(1), i + 1)
        )
      : ([cb(arr[0] as A, i, orig)] as Opt<B>[]).concat(
          mapLoop(arr.slice(1), i + 1)
        );
  return mapLoop(orig, 0);
};

哇!这超出了我们的预期,但我们看到了几种技术:如何用递归代替迭代,如何在迭代中累积结果,以及如何生成和提供索引值——很好的建议!此外,编写过滤代码将会容易得多,因为我们能够应用与映射相同的逻辑。主要区别在于我们使用回调函数来决定一个元素是否进入输出数组,因此内部循环函数会稍微长一些:

// filter.ts
type Opt<X> = X | undefined;
const filterR = <A>(
  orig: Opt<A>[],
  cb: (x: A, i: number, a: Opt<A>[]) => boolean
): A[] => {
  const filterLoop = (arr: Opt<A>[], i: number): A[] =>
    arr.length == 0
      ? []
      : !(0 in arr) ||
        arr[0] === undefined ||
        !cb(arr[0] as A, i, orig)
      ? filterLoop(arr.slice(1), i + 1)
      : ([arr[0]] as A[]).concat(
          filterLoop(arr.slice(1), i + 1) as A[]
        );
  return filterLoop(orig, 0);
};

好吧,我们成功地实现了两个基本的高阶函数,使用类似的递归函数。那么其他的高阶函数呢?

其他高阶函数

编写 reduce() 函数从一开始就有点棘手,因为你可以选择省略累加器的初始值。由于我们之前提到提供该值通常是更好的,让我们假设它会被提供;处理其他可能性不会太难。

基本情况很简单:如果数组为空,结果是累加器;否则,我们必须将 reduce 函数应用于当前元素和累加器,更新后者,然后继续处理数组的其余部分。这可能会因为三元运算符而有些令人困惑,但考虑到我们已经看到的所有内容,应该足够清晰。看看以下代码的细节:

// reduce.ts
const reduceR = <A, B>(
  orig: A[],
  cb: (acc: B, x: A, i: number, a: A[]) => B,
  accum: B
) => {
  const reduceLoop = (arr: A[], accum: B, i: number): B =>
    arr.length == 0
      ? accum
      : !(0 in arr) || arr[0] === undefined
      ? reduceLoop(arr.slice(1), accum, i + 1)
      : reduceLoop(
          arr.slice(1),
          cb(accum, arr[0], i, orig),
          i + 1
        );
  return reduceLoop(orig, accum, 0);
};
let bbb = [1, 2, , 5, 7, 8, 10, 21, 40];
console.log(bbb.reduce((x, y) => x + y, 0));   // 94
console.log(reduce2(bbb, (x, y) => x + y, 0)); // 94

另一方面,find() 函数特别适合递归逻辑,因为找到某物的定义本身就是递归的:

  • 你看看你首先想到的地方,如果你找到了你想要的东西,你就完成了

  • 或者,你可以看看其他地方是否有什么是你想要的

我们只缺少基本情况,但这很简单,我们已经在本章前面看到了这一点 – 如果你没有地方可以搜索,那么你知道你的搜索不会成功:

// find.ts
const findR = <A>(
  arr: A[],
  cb: (x: A) => boolean
): Opt<A> =>
  arr.length === 0
    ? undefined
    : cb(arr[0])
    ? arr[0]
    : findR(arr.slice(1), cb);

我们可以快速验证这是否有效:

let aaa = [1, 12, , , 5, 22, 9, 60];
const isTwentySomething = x => 20 <= x && x <= 29; console.log(findR(aaa, isTwentySomething)); // 22
const isThirtySomething = x => 30 <= x && x <= 39; console.log(findR(aaa, isThirtySomething)); // undefined

让我们以我们的管道化函数结束。管道的定义适合快速实现:

  • 如果我们想要管道化一个单一函数,那么这就是管道的结果

  • 如果我们想要管道化多个函数,我们必须首先应用初始函数,然后将该结果作为输入传递给其他函数的管道

我们可以直接将其转换为代码:

function pipelineR<FNS extends FN[]>(
  ...fns: FNS
): Pipeline<FNS>;
function pipelineR<FNS extends FN[]>(...fns: FNS): FN {
  return fns.length === 1
    ? fns[0]
    : (...args) =>
        pipelineR(...fns.slice(1))(fns0);
}

我们可以用一个简单的例子来验证其正确性。让我们将几个函数调用管道化,其中一个只是将其参数加 1,另一个乘以 10:

const plus1 = (x: number): number => x + 1;
const by10 = (x: number): number => x * 10;
pipelineR(
  by10,
  plus1,
  plus1,
  plus1,
  by10,
  plus1,
  by10,
  by10,
  plus1,
  plus1,
  plus1
)(2);
// 23103

如果你遵循数学,你将能够检查管道是否正常工作。如果我们把基本情况设定为没有提供函数,我们可以有一个稍微不同的递归调用:

// continued...
function pipelineR2<FNS extends FN[]>(
  ...fns: FNS
): Pipeline<FNS>;
function pipelineR2<FNS extends FN[]>(...fns: FNS): FN {
  return fns.length === 0
    ? (...args) => args[0]
    : (...args) =>
        pipelineR2(...fns.slice(1))(fns0);
}

无论如何,这些管道在 TypeScript 中不会工作,因为我们的 Pipeline<> 类型定义不允许空函数集 – 你能修复这个问题吗?

对于组合做同样的事情很容易,只是你不能使用扩展运算符来简化函数定义,你将不得不与数组索引一起工作——把它弄清楚!

搜索和回溯

搜索问题的解决方案,尤其是在没有直接算法且必须求助于试错的情况下,尤其适合递归。许多这些算法都落入以下方案:

  • 在许多可用的选择中,选择一个。如果没有选项可用,你就失败了。

  • 如果你可以选择,应用相同的算法,但找到其余问题的解决方案。

  • 如果你成功,你就完成了。否则,尝试另一个选择。

你可以用类似的逻辑,稍作变化,找到给定问题的良好或可能最优的解决方案。每次你找到一个可能的解决方案,你就将其与之前可能找到的解决方案进行匹配,并决定保留哪一个。这可能会继续进行,直到所有可能的解决方案都被评估或找到一个足够好的解决方案。

许多问题都适用于这种逻辑。具体如下:

  • 找到迷宫的出口—选择任何路径,将其标记为已走过,并尝试找到一条不会重复该路径的迷宫出口:如果你成功,你就完成了,如果你不成功,就回到选择不同的路径

  • 填写数独谜题—如果一个空单元格只能包含一个数字,那么就分配它;否则,遍历所有可能的分配,并对每个分配,递归地尝试看是否可以填充谜题的其余部分

  • 下棋—你不太可能能够跟随所有可能的移动序列,所以你选择最佳估计的位置

让我们将这些技术应用到两个问题上:解决八皇后问题和遍历完整的文件目录。

八皇后问题

八皇后问题是在 19 世纪发明的,涉及在标准棋盘上放置八个国际象棋皇后。限制是没有任何皇后能够攻击另一个皇后——这意味着没有一对皇后可以共享一行、一列或对角线。这个谜题可能要求任何解决方案或不同解决方案的总数,我们将尝试找到。

n 皇后变体

这个谜题也可以通过在一个 nxn 的方格板上工作来推广到 n 皇后问题。已知对于所有 n 的值都有解,除了 n=2(很容易看出原因:放置一个皇后后,整个棋盘都受到威胁)和 n=3(如果你在中心放置一个皇后,整个棋盘都受到威胁,如果你在一边放置一个皇后,只有两个格子不受威胁,但它们相互威胁,使得无法在这两个格子上放置皇后)。

让我们从顶层逻辑开始我们的解决方案。由于给定的规则,每一列将有一个皇后,所以我们使用一个 places 数组来记录每个皇后在给定列中的行。SIZE 常量可以修改以解决更一般的问题。我们将在 solutions 变量中计算找到的皇后分布。最后,finder() 函数将执行解决方案的递归搜索。代码的基本框架如下:

// queens.ts
const SIZE = 8;
const places = Array(SIZE);
let solutions = 0;
finder();
console.log(`Solutions found: ${solutions}`);

让我们深入研究所需的逻辑。当我们想要在给定的行和列中放置一个皇后时,我们必须检查之前放置的皇后是否放置在同一行或从该行延伸出的对角线上。参见 图 9**.4

图 9.4 – 在一列中放置皇后之前,我们必须检查之前放置的皇后的位置

图 9.4 – 在一列中放置皇后之前,我们必须检查之前放置的皇后的位置

让我们编写一个 checkPlace(column, row) 函数来验证是否可以在给定的方格中安全地放置一个皇后。最直接的方法是使用 every(),如下面的代码所示:

// continued...
const checkPlace = (column: number, row: number): boolean =>
  places
    .slice(0, column)
    .every(
      (v, i) =>
        v !== row && Math.abs(v - row) !== column - i
    );

这种声明式的方法似乎最好:当我们放置一个皇后在某个位置时,我们想要确保每个之前放置的皇后都在不同的行和对角线上。一个递归解决方案也是可能的,所以让我们看看。我们如何知道一个方格是安全的?

  • 基本情况是没有更多的列需要检查,方格是安全的

  • 如果方格与任何其他皇后的行或对角线相同,则它不安全

  • 如果我们已经检查了一列并且没有发现问题,我们现在可以递归地检查下一列

因此,检查列中的位置是否可以被皇后占据所需的替代代码如下:

// continued...
const checkR = (column: number, row: number): boolean => {
  const checkColumn = (i: number): boolean => {
    if (i == column) {
      return true;
    } else if (
      places[i] == row ||
      Math.abs(places[i] - row) == column - i
    ) {
      return false;
    } else {
      return checkColumn(i + 1);
    }
  };
  return checkColumn(0);
};

代码是有效的,但我不会使用它,因为声明式版本更清晰。无论如何,在解决了这个检查之后,我们可以关注主要的 finder() 逻辑,这将执行递归搜索。过程按照我们最初描述的方式进行:尝试放置一个皇后的可能位置,如果这是可接受的,使用相同的搜索程序尝试放置剩余的皇后。我们从列 0 开始,我们的基例是当我们到达最后一列时,这意味着所有皇后都已成功放置:我们可以打印出解决方案,计数它,并返回以寻找新的配置。

获得良好的输出

查看我们如何使用 map() 和一个简单的箭头函数来按列打印皇后的行,数字在 1 到 8 之间,而不是 0 和 7。在棋类游戏中,行数从 1 到 8 编号(列从 a 到 h,但在这里这不重要)。

查看以下代码,它应用了我们之前描述的逻辑:

// continued...
const finder = (column = 0) => {
  if (column === SIZE) {
    // all columns tried out?
    // if so, print and count solution
    console.log(JSON.stringify(places.map((x) => x + 1)));
    solutions++;
  } else {
    const testRowsInColumn = (j: number) => {
      if (j < SIZE) {
        if (checkR(column, j)) {
          places[column] = j;
          finder(column + 1);
        }
        testRowsInColumn(j + 1);
      }
    };
    testRowsInColumn(0);
  }
};

内部的 testRowsInColumn() 函数也扮演了迭代角色,但以递归的方式。想法是尝试在每个可能的行放置一个皇后,从零开始:如果方格是安全的,finder() 被调用以从下一列开始搜索。无论是否找到解决方案,都会尝试列中的所有行,因为我们感兴趣的是解决方案的总数。在其他搜索问题中,你可能只满足于找到任何解决方案,并在那里停止搜索。

我们已经走到这一步,所以让我们找到我们问题的答案!

[1,5,8,6,3,7,2,4]
[1,6,8,3,7,4,2,5]
[1,7,4,6,8,2,5,3]
[1,7,5,8,2,4,6,3]
[2,4,6,8,3,1,7,5]
[2,5,7,1,3,8,6,4]
[2,5,7,4,1,8,6,3]
[2,6,1,7,4,8,3,5]
   ...
   ... 70 lines snipped out
   ...
[8,2,4,1,7,5,3,6]
[8,2,5,3,1,7,4,6]
[8,3,1,6,2,5,7,4]
[8,4,1,3,6,2,7,5]
Solutions found: 92

每个解决方案都给出了皇后的行位置,按列逐列给出,总共有 92 个解决方案。

遍历树结构

定义中包含递归的数据结构自然适用于递归技术。让我们考虑一下,例如,如何遍历完整的文件系统目录,列出其所有内容。递归在哪里?如果你考虑到每个目录都可以执行以下任一操作,答案就很简单:

  • 为空——一个没有要执行的事情的基例

  • 包含一个或多个条目,每个条目本身是文件或目录

让我们解决一个完整的递归目录列表——这意味着当我们遇到一个目录时,我们也会列出其内容,如果这些内容中包含更多目录,我们也会列出它们,依此类推。我们将使用与 getDir()(来自第八章**,手动构建管道部分)中相同的节点函数,以及一些额外的函数来测试目录条目是否是符号链接(我们不会跟随它以避免可能的无穷循环),是目录(这将需要递归列表)或普通文件:

// directory.ts
import * as fs from "fs";
const recursiveDir = (path: string) => {
  console.log(path);
  fs.readdirSync(path).forEach((entry) => {
    if (entry.startsWith(".")) {
      // skip it!
    } else {
      const full = path + "/" + entry;
      const stats = fs.lstatSync(full);
      if (stats.isSymbolicLink()) {
        console.log("L ", full); // symlink, don't follow
      } else if (stats.isDirectory()) {
        console.log("D ", full);
        recursiveDir(full);
      } else {
        console.log("  ", full);
      }
    }
  });
};

列表很长但正确。我选择在我的开源 SUSE Linux 笔记本电脑上列出 /boot 目录,结果如下:

recursiveDir("/boot");
/boot
/boot/System.map-4.11.8-1-default
/boot/boot.readme
/boot/config-4.11.8-1-default D  /boot/efi
D    /boot/efi/EFI
D    /boot/efi/EFI/boot
/boot/efi/EFI/boot/bootx64.efi
/boot/efi/EFI/boot/fallback.efi
...
... many omitted lines
...
L    /boot/initrd
/boot/initrd-4.11.8-1-default
/boot/message
/boot/symtypes-4.11.8-1-default.gz
/boot/symvers-4.11.8-1-default.gz
/boot/sysctl.conf-4.11.8-1-default
/boot/vmlinux-4.11.8-1-default.gz  L  /boot/vmlinuz
/boot/vmlinuz-4.11.8-1-default

我们可以将相同的结构应用到类似的问题上:遍历 DOM 结构。我们可以从给定的元素开始,使用相同的方法列出所有标签:我们列出节点,并通过应用相同的算法列出所有子节点。基本情况与之前相同:当一个节点没有子节点时,不再进行递归调用。你可以在下面的代码中看到这一点:

// dom.ts
const traverseDom = (node: Element, depth = 0) => {
  console.log(
    `${"| ".repeat(depth)}<${node.nodeName.toLowerCase()}>`
  );
  for (let i = 0; i < node.children.length; i++) {
    traverseDom(node.children[i], depth + 1);
  }
};

我们使用depth变量来知道我们距离原始元素有多少层。我们也可以用它来使遍历逻辑在某个级别停止;在我们的例子中,我们只使用它来添加一些横线和空格,以便根据 DOM 层次结构中的位置适当地缩进每个元素。这个函数的结果在下面的代码中显示。很容易列出更多信息,而不仅仅是元素标签,但我只想关注递归过程:

traverseDom(document.body);
<body>
| <script>
| <div>
| | <div>
| | | <a>
| | | <div>
| | | | <ul>
| | | | | <li>
| | | | | | <a>
| | | | | | | <div>
| | | | | | | | <div>
| | | | | | | <div>
| | | | | | | | <br>
| | | | | | | <div>
| | | | | | <ul>
| | | | | | | <li>
| | | | | | | | <a>
| | | | | | | <li>
...etc.!

然而,这里有一个丑陋的点:为什么我们要通过循环遍历所有子节点?我们应该知道得更好!问题在于我们从 DOM 得到的结构实际上并不是一个数组。然而,有一个解决办法——我们可以使用Array.from()从它创建一个真正的数组,然后编写一个更声明性的解决方案。下面的代码以更好的方式解决了这个问题:

// continued...
const traverseDom2 = (node: Element, depth = 0) => {
  console.log(
    `${"| ".repeat(depth)}<${node.nodeName.toLowerCase()}>`
  );
  Array.from(node.children).forEach((child) =>
    traverseDom2(child, depth + 1)
  );
};

[...node.children].forEach()也可以,但使用Array.from()会让任何读者更清楚地知道我们正在尝试将看起来像数组的东西转换成数组,但实际上并不是。

我们已经看到了许多关于递归使用的方法,我们也看到了它的许多应用;然而,有些情况下你可能会遇到问题,所以现在让我们考虑一些可能对特定问题有帮助的调整。

相互递归

递归不必像有一个调用自身的函数那样“简单”和“直接”。我们可以有更复杂的情况,作为一组函数,每个函数调用一个或多个其他函数,但不一定调用自身。(然而,请注意这也是允许的。)

从相互递归的角度思考更困难。对于简单的递归,你必须想象你已经有一个函数来做某事,然后你使用它(本身!)来做那件事。在相互递归中,你必须考虑一组函数,每个函数通过同时依赖于整个函数集(包括其他函数,以及可能包括自身)来完成自己的部分。

让我们先考察一个简单的案例,然后尝试一个“实际应用”。

奇数和偶数

你如何确定一个(非负)整数是奇数还是偶数?这确实是一个简单的问题(但参见问题 9.11),如果我们意识到以下事实,我们就可以得到一个有趣的解决方案:

  • 零是偶数

  • 如果一个数是偶数,当你从它减去 1 时,你会得到一个奇数

  • 如果一个数不是偶数,那么它是奇数:

function isEven(n: number): boolean {
  if (n === 0) {
    return true;
  } else {
    return isOdd(n - 1);
  }
}
function isOdd(n: number): boolean {
  return !isEven(n);
}
console.log("22.. isEven?", isEven(22));
console.log("9... isOdd?", isOdd(5));
console.log("60... isOdd?", isOdd(10));

这是如何工作的?每个函数(isEven()isOdd())都依赖于另一个函数来产生结果。我们如何知道 9 是奇数?计算如下:

is 9 odd?
Is 9 not even?
Is 8 odd?
Is 8 not even?
Is 7 odd?
Is 7 not even?
   ...
   ... several lines skipped
   ...
Is 1 odd?
Is 1 not even?
Is 0 odd?
Is 0 not even?

在最后调用之后,整个调用塔都会得到解决;9 被报告为奇数(幸运的是!)

你可以说,这实际上不是一个很好的相互递归的例子,因为你可以轻松地替换isEven()中的isOdd()代码以获得单递归版本:

// continued…
function isEven2(n: number): boolean {
  if (n === 0) {
    return true;
  } else {
    return !isEven2(n - 1);
  }
}
function isOdd2(n: number): boolean {
  return !isEven2(n);
}

然而,我们可以用另一种方式来做这件事,这也会包括相互递归:

// continued...
function isEven3(n: number): boolean {
  if (n === 0) {
    return true;
  } else {
    return isOdd3(n - 1);
  }
}
function isOdd3(n: number): boolean {
  if (n === 0) {
    return false;
  } else {
    return isEven3(n - 1);
  }
}

所以,不要认为相互递归总能被简化掉;有时,这实际上并不可能或不切实际。

回到代码,应该很明显,没有人会以这种方式实现奇偶性测试。尽管如此,这个例子为实施更复杂的函数铺平了道路:解析和评估算术表达式,这将涉及多个相互递归的函数,正如我们接下来将要看到的。

进行算术运算

让我们看看一个更完整的问题,这个问题也经常(!)在网上出现,如图图 9.5中的谜题。我们将实现一组相互递归的函数,这些函数可以按照运算符的标准优先级规则正确评估算术表达式。

图 9.5 – 常见谜题要求评估算术表达式

图 9.5 – 常见谜题要求评估算术表达式

为了解决这个问题,我们首先将看到一个工具,它让我们能够正确处理操作:铁路图。我们想要评估一个表达式,我们可以这样说,一个表达式要么是一个单独的项,要么是几个项相加或相减。图 9.6以图形方式展示了这一点。想象箭头是铁路轨道,你遵循的任何从左到右的路径都代表一个可能的表达式。

图 9.6 – 算术表达式的铁路语法图

图 9.6 – 算术表达式的铁路语法图

现在,什么是项?项要么是一个单一因子,要么是通过对多个因子进行乘法、除法或取模运算的结果,如图图 9.7所示。注意,根据这两条规则,23+5被正确地评估为(23)+5,因为235*是项。

图 9.7 – 项的铁路语法:乘法、除法和取模运算在加法或减法之前执行

图 9.7 – 项的铁路语法:乘法、除法和取模运算在加法或减法之前执行

我们还需要一个图,用于因子。因子可以是一个单独的数字或括号内的表达式。我们将在开头允许一个可选的负号,因此-3 是被接受的。图 9.8显示了所需的图。

图 9.8 – 因子以可选的负号开头,可以是数字或括号内的表达式

图 9.8 – 因子以可选的负号开头,可以是数字或括号内的表达式

我们将使用三个函数来实现所需的评估,每个函数对应一个图。在常规的编译器或解释器代码中,我们有一个读取输入并将其拆分为标记的第一阶段,以及一个处理这些标记以执行所需操作的第二阶段。在我们的情况下,标记将是数字(单个数字,为了简单起见)、运算符和括号。我们将编写的代码如下:

function evaluate(str: string) {
  const PLUS = "+";
  const MINUS = "-";
  const TIMES = "*";
  const DIVIDES = "/";
  const MODULUS = "%";
  const LPARENS = "(";
  const RPARENS = ")";
  let curr = 0;
  const tokens = str
    .split("")
    .map((x) => (Number.isNaN(Number(x)) ? x : Number(x)));
  return expression();
  function expression(): number { ... }
  function term(): number { ... }
  function factor(): number { ... }
}

我们定义了一些常量(PLUSMINUS等)以提高清晰度。给定一个如"7+7/7+7*7-7"的字符串,我们将其拆分为标记数组;我们注意评估数字。最后,我们有一个curr变量指向当前正在处理的标记。输入表达式的评估将由三个函数完成,这些函数将使用相互递归:

  function expression(): number {
  let accum = term();
  while (
    tokens[curr] === PLUS ||
    tokens[curr] === MINUS
  ) {
    if (tokens[curr] === PLUS) {
      curr++;
      accum += term();
    } else if (tokens[curr] === MINUS) {
      curr++;
      accum -= term();
    }
  }
  return accum;
}

expression()函数首先调用term()来获取第一个项的值,如果发现加法或减法,则循环。在我们的例子中,这意味着函数首先评估一个 7,然后加上 7/7,接着也加上 7*7,最后减去最后的 7。 (是的,结果是 50。)处理完一个标记后,curr会增加以继续处理剩余的标记。

term()的代码风格相似;唯一的区别是它如何处理乘法等:

function term(): number {
  let accum = factor();
  while (
    tokens[curr] === TIMES ||
    tokens[curr] === DIVIDES ||
    tokens[curr] === MODULUS
  ) {
    if (tokens[curr] === TIMES) {
      curr++;
      accum *= factor();
    } else if (tokens[curr] === DIVIDES) {
      curr++;
      accum /= factor();
    } else if (tokens[curr] === MODULUS) {
      curr++;
      accum %= factor();
    }
  }
  return accum;
}

这个函数会被调用以评估 7,然后是 7/7,接着是 7*7,最后再是另一个 7。

最后,factor()略有不同:

function factor(): number {
  let mult = 1;
  if (tokens[curr] === MINUS) {
    mult = -1;
    curr++; // skip MINUS
  }
  let result = 0;
  if (tokens[curr] === LPARENS) {
    curr++; // skip LPARENS
    result = expression();
    curr++; // skip RPARENS
  } else {
    result = tokens[curr] as number;
    curr++;
  }
  return mult * result;
}

如果存在初始减号,mult变量将是-1,否则是+1。这里没有循环,只有一个选择:如果看到一个左括号,我们跳过它,递归地评估包含的表达式,跳过右括号,并返回表达式的值。另一种情况是我们有一个数字,我们返回它。无论我们返回什么,我们都会乘以mult值以产生正确的结果。

如果你分析递归调用,我们有expression()调用term()term()调用factor()factor()调用recursive()——这是一个三重的循环!相互递归更难理解且更难正确实现,因为编写此类代码时,你必须预见几个函数将做什么。然而,对于正确的问题(如这里所示),它是一个非常强大的技术。

递归技术

虽然递归是一种非常好的技术,但你可能会因为其内部实现的方式而遇到一些问题。每个函数调用,无论是递归的还是非递归的,都需要在 JavaScript 的内部栈中有一个条目。当你使用递归时,每个递归调用本身又算作另一个调用,你可能会发现,在某些情况下,你的代码会因为多次调用而崩溃并抛出错误,仅仅是因为内存不足。另一方面,在大多数当前的 JavaScript 引擎中,你可能有几千个挂起的递归调用而不会出现问题——但在早期的浏览器和较小的机器上,这个数字可能会降到几百,甚至更低。因此,可以认为,目前你不太可能遇到任何特定的内存问题。

无论如何,让我们在接下来的几节中回顾一下问题,并探讨一些可能的解决方案。即使你实际上无法应用它们,它们也代表了有效的 FP(函数式编程)思想,你可能在其他问题中找到它们的应用。我们将探讨以下解决方案:

  • 尾调用优化,一种加快递归传递风格CPS)的技术,是函数式编程中的一个重要技术,可以帮助处理递归

  • 几种有趣命名的技术,跳板thunks,它们也是标准的 FP 工具

  • 递归消除,一种超出本书范围的技术,但仍然可以应用

尾调用优化

当一个递归调用不是递归调用时?这样表述,这个问题可能没有太多意义,但有一个常见的优化——对于其他语言来说,遗憾的是,但不是 JavaScript!——可以解释答案。如果递归调用是函数将要做的最后一件事,那么这个调用可以转换成一个简单的跳转到函数的开始,而不需要创建一个新的栈条目。(为什么?栈条目是不需要的:递归调用完成后,函数就没有其他事情要做了,因此没有必要进一步保存函数进入时推入栈中的任何元素。)原始的栈条目就不再需要了,可以被一个新的条目所取代,对应于最近的调用。

实现讽刺

递归调用,一种典型的 FP 技术,被一个基本的命令式GO TO语句实现,这可以被认为是一种终极讽刺!

这些调用被称为尾调用(出于显而易见的原因)并且效率更高,不仅因为节省了栈空间,而且因为跳转比任何其他替代方案都要快得多。如果浏览器实现了这个增强功能,它将使用尾调用优化TCO);然而,查看kangax.github.io/compat-table/es6/上的兼容性表格,在撰写本文时(2022 年底),唯一提供 TCO 的浏览器是 Safari。

图 9.9 – 要理解这个笑话,你必须先理解它!

图 9.9 – 要理解这个笑话,你必须先理解它!

一个简单的(尽管是非标准的)测试可以让你验证你的浏览器是否提供 TCO。我在网上找到了这段代码的几个地方,但我很抱歉不能证明原始作者的姓名,尽管我相信它是来自匈牙利的 Csaba Hellinger。调用 detectTCO() 会让你知道你的浏览器是否使用 TCO:

// tailRecursion.ts
function detectTCO() {
  const outerStackLen = new Error().stack!.length;
  return (function inner() {
    const innerStackLen = new Error().stack!.length;
    return innerStackLen <= outerStackLen;
  })();
}

Error().stack 的结果不是 JavaScript 标准,但现代浏览器支持它,尽管方式略有不同。(我不得不添加“!”符号,这样 TypeScript 才会接受 stack 会存在。)无论如何,想法是当一个具有长名称的函数调用另一个具有短名称的函数时,堆栈跟踪应该执行以下操作:

  • 如果浏览器实现了 TCO,它应该会变得更短,因为较长的函数名称的旧条目将被较短的函数名称的条目所替换

  • 在没有尾调用优化(TCO)的情况下,它应该会变得更长,因为没有消除原始的栈条目,就会创建一个新的完全新的栈条目

我在我的 Linux 笔记本电脑上使用 Chrome,并添加了一个 console.log() 语句来显示 Error().stack。你可以看到两个栈条目(inner()detectTCO())都是 活跃的,所以没有 TCO:

Error
at inner (<anonymous>:6:13)
at detectTCO (<anonymous>:9:6) at <anonymous>:1:1

当然,还有另一种方法来了解你的环境是否包括 TCO:尝试以下(实际上什么也不做!)函数,使用足够大的数字。如果你能够用像 100,000 或 1,000,000 这样的数字运行它,你可以合理地确信你的 JavaScript 引擎正在执行 TCO!一个可能的函数如下:

// continued...
function justLoop(n: number): void {
  n && justLoop(n - 1); // until n is zero
}

让我们通过一个简短的测验来结束这一节,以确保我们理解什么是尾调用。我们在 第一章 “成为函数式程序员”(但在这里用 TypeScript 编写)中看到的阶乘函数中的递归调用是尾调用吗?

function fact(n: number): number {
  if (n === 0) {
    return 1;
  } else {
    return n * fact(n - 1);
  }
}

想一想,因为答案很重要!你可能倾向于肯定回答,但正确答案是 。这有一个很好的原因,并且是一个关键点:在递归调用完成后,并且已经计算出 fact(n-1) 的值之后,函数仍然有工作要做。(所以递归调用实际上并不是函数要做的最后一件事。)如果你以这种方式写函数,你可以更清楚地看到这一点:

function fact2(n: number): number {
  if (n === 0) {
    return 1;
  } else {
    const aux = fact2(n - 1);
    return n * aux;
  }
}

从这一节中应该有两个要点:浏览器通常不提供 TCO,即使提供了,如果你的调用不是实际的尾调用,你也无法利用它。既然我们已经知道了问题是什么,让我们看看一些函数式编程的方法来绕过它!

Continuation-passing style

我们已经知道,如果我们有堆叠太高的递归调用,我们的逻辑将会失败。另一方面,我们知道尾递归应该减轻这个问题,但由于浏览器的实现,它们并没有这样做!然而,有一种解决办法。让我们首先考虑如何通过使用一个著名的 FP 概念——延续——将递归调用转换为尾递归调用,然后我们将把解决 TCO 限制的问题留到下一节。(我们在第三章从函数开始中提到了延续,但没有深入探讨。)

在 FP 术语中,一个表示进程状态的延续允许处理继续。这可能太抽象了,所以让我们看看这意味着什么。关键思想是当你调用一个函数时,你也提供了一个将在返回时被调用的延续(实际上是一个简单的函数)。

让我们来看一个简单的例子。假设你有一个返回一天中时间的函数,并且你想要在控制台上显示这个时间。通常的做法可能是如下所示:

function getTime(): string {
  return new Date().toTimeString();
}
console.log(getTime()); // "21:00:24 GMT+0530 (IST)"

如果你正在做 CPS,你会将一个延续传递给getTime()函数。而不是返回一个计算值,函数将调用延续,并将值作为参数传递:

function getTime2(cont: FN) {
  return cont(new Date().toTimeString());
}
getTime2(console.log); // similar result as above

有什么区别?关键是我们可以应用这个机制将递归调用转换为尾递归调用,因为所有后续的代码都将由递归调用本身提供。为了使这一点更清楚,让我们回顾一下显式表示我们不是在执行尾递归的阶乘函数版本:

function fact2(n: number): number {
  if (n === 0) {
    return 1;
  } else {
    const aux = fact2(n - 1);
    return n * aux;
  }
}

我们将向函数添加一个新的延续参数。fact(n-1)调用的结果我们如何处理?我们将它乘以n,所以让我们提供一个将执行这一操作的延续。我将阶乘函数重命名为factC(),以清楚地表明我们正在处理延续,如下面的代码所示:

// continued...
function factC(
  n: number,
  cont: (x: number) => number
): number {
  if (n === 0) {
    return cont(1);
  } else {
    return factC(n - 1, (x) => cont(n * x));
  }
}

我们如何得到最终结果呢?很简单——我们可以调用factC(),并传递一个将在返回时返回其给定内容的延续:

console.log(factC(7, x => x)); // 5040, correctly
factC(7, console.log);         // same result

标识符组合子

在 FP 中,一个返回其参数作为结果的函数通常被称为identity(),这是显而易见的。在组合逻辑(我们不会使用)中,我们会提到I 组合子

你能理解它是如何工作的吗?那么让我们尝试一个更复杂的案例,使用斐波那契函数,它包含两个递归调用,如下所示的高亮代码:

// continued...
const fibC = (n: number, cont: FN): number => {
  if (n <= 1) {
    return cont(n);
  } else {
    return fibC(n - 2, (p) =>
      fibC(n - 1, (q) => cont(p + q))
    );
  }
};

这更复杂:我们用n-2和说无论那个调用返回什么,都调用fibC(),并且当那个调用返回时,将两个调用的结果相加,并将那个结果传递给原始延续。

让我们再来看一个例子,这个例子涉及一个具有未定义递归调用次数的循环。到那时,你应该对如何将 CPS 应用到你的代码中有所了解——尽管我必须承认这可能会变得非常复杂!

在本章前面的遍历树结构部分,我们看到了这个函数。想法是打印出 DOM 结构,如下所示:

<body>
| <script>
| <div>
| | <div>
| | | <a>
| | | <div>
| | | | <ul>
| | | | | <li>
| | | | | | <a>
| | | | | | | <div>
| | | | | | | | <div>
| | | | | | | <div>
| | | | | | | | <br>
| | | | | | | <div>
| | | | | | <ul>
| | | | | | | <li>
| | | | | | | | <a>
| | | | | | | <li>
...etc.!

我们当时设计的函数如下:

// dom.ts
const traverseDom2 = (node: Element, depth = 0) => {
  console.log(
    `${"| ".repeat(depth)}<${node.nodeName.toLowerCase()}>`
  );
  Array.from(node.children).forEach((child) =>
    traverseDom2(child, depth + 1)
  );
};

让我们先从使其完全递归开始,去掉forEach()循环。我们之前已经见过这种技术,所以我们可以继续到以下结果;注意以下代码是如何通过递归形成循环的。此外,注意我们添加了很多return语句,即使它们实际上并不需要;我们很快就会看到这样做的原因:

// continued...
const traverseDom3 = (node: Element, depth = 0): void => {
  console.log(
    `${"| ".repeat(depth)}<${node.nodeName.toLowerCase()}>`
  );
  const traverseChildren = (
    children: Element[],
    i = 0
  ): void => {
    if (i < children.length) {
      traverseDom3(children[i], depth + 1);
      return traverseChildren(children, i + 1); // loop
    }
    return;
  };
  return traverseChildren(Array.from(node.children));
};

现在,我们必须向traverseDom3()添加一个延续。与之前的情况唯一的不同是,该函数不返回任何内容,所以我们不会向延续传递任何参数。还要记住traverseChildren()循环末尾的隐式返回;我们必须调用延续:

// continued...
const traverseDom3C = (
  node: Element,
  depth = 0,
  cont: FN = () => {
    /*  nothing */
  }
): void => {
  console.log(
    `${"| ".repeat(depth)}<${node.nodeName.toLowerCase()}>`
  );
  const traverseChildren = (
    children: Element[],
    i = 0
  ): void => {
    if (i < children.length) {
      return traverseDom3C(children[i], depth + 1, () =>
        traverseChildren(children, i + 1)
      );
    }
    return cont();
  };
  return traverseChildren(Array.from(node.children));
};

我们选择为cont提供一个默认值,这样我们就可以像以前一样调用traverseDom3C(document.body)。如果我们尝试这个逻辑,它是可行的,但尚未解决潜在的高数量挂起调用的问题;让我们在下一节中寻找这个问题的解决方案。

跳床和 thunks

对于我们问题的最后一个解决方案,我们必须考虑问题的原因。每个挂起的递归调用都会创建一个新的栈条目。每当栈变得太空时,程序就会崩溃,我们的算法就结束了。所以,如果我们能想出一个避免栈增长的方法,我们就应该自由了。在这种情况下,解决方案相当强有力,需要thunkstrampoline——让我们看看它们是什么!

首先,一个new Date().toISOString();然而,如果你提供一个计算这个值的 thunk,你只有在实际调用它时才会得到值:

// trampoline.ts
const getIsoDT = () => new Date().toISOString(); // a thunk
const isoDT = getIsoDT(); // getting the thunk's value

这有什么用?递归的问题在于一个函数会调用自己,然后再次调用自己,然后再次调用自己,如此等等,直到栈溢出。我们不会直接调用自己,而是让函数返回一个 thunk,当它执行时,实际上会递归地调用函数。因此,而不是让栈越来越长,它实际上会非常平坦,因为函数永远不会真正调用自己;当你调用函数时,栈会增加一个位置,然后当函数返回其 thunk 时,它会立即回到其大小。

但谁来做递归呢?这就是trampoline概念出现的地方。trampoline 只是一个循环,它调用一个函数,获取其返回值,如果它是一个 thunk,那么它会调用它,以便递归继续,但以一种平坦、线性的方式!当 thunk 评估返回一个实际值而不是新函数时,循环才会退出。看看以下代码:

// continued...
const trampoline = (fn: FN): any => {
  while (typeof fn === "function") {
    fn = fn();
  }
  return fn;
};

我们如何将这个应用到实际函数中?让我们从一个简单的函数开始,该函数从 1 加到n,但以递归、保证导致栈崩溃的方式。我们的简单sumAll()函数可能是以下这样:

// continued...
const sumAll = (n: number): number =>
  n == 0 ? 0 : n + sumAll(n - 1);

然而,如果我们开始尝试这个函数,我们最终会出错并崩溃,如下面的示例所示:

console.log(sumAll(10));
console.log(sumAll(100));
console.log(sumAll(1_000));
console.log(sumAll(10_000));
console.log(sumAll(100_000));
// Output:
55
5050
500500
50005000
RangeError: Maximum call stack size exceeded

栈问题迟早会出现,这取决于你的机器、内存大小等因素——但无疑它会出现的。让我们用 CPS(Continuation Passing Style)重写这个函数,使其成为尾递归。我们将应用之前看到的相同技术,如下面的代码所示:

// continued...
const sumAllC = (n: number, cont: FN): number =>
  n === 0 ? cont(0) : sumAllC(n - 1, (v) => cont(v + n));

然而,这仍然会像之前一样崩溃;最终,栈增长过多。让我们给代码应用一个简单的规则:每当你即将从调用返回时,相反,返回一个 thunk,当它执行时,将执行你实际上想要做的调用。以下代码实现了这个更改:

// continued...
const sumAllT = (n: number, cont: FN): (() => number) =>
  n === 0
    ? () => cont(0)
    : () => sumAllT(n - 1, (v) => () => cont(v + n));

每当原本要调用一个函数时,我们现在返回一个 thunk。我们如何运行这个函数呢?这是缺失的细节。你需要一个初始调用,它将第一次调用 sumAllT(),并且(除非函数是用零个参数调用的)将立即返回一个 thunk。trampoline 函数将调用这个 thunk,这将导致新的调用,以此类推,直到我们最终得到一个只返回值的 thunk,然后计算将结束:

// continued...
const sumAll2 = n => trampoline(sumAllT(n, x => x));
console.log(sumAll2(1_000_000)); // no problem now!

实际上,你可能不希望有一个单独的 sumAllT() 函数,所以你会选择类似以下这样的方案:

const sumAll3 = (n: number): number => {
  const sumAllT = (n: number, cont: FN) =>
    n === 0
      ? () => cont(0)
      : () => sumAllT(n - 1, (v) => () => cont(v + n));
  return trampoline(sumAllT(n, (x) => x));
};
console.log(sumAll3(1_000_000)); // no stack crash

剩下的唯一问题是:如果我们的递归函数的结果不是一个值而是一个函数,我们会怎么办?问题将出现在 trampoline() 代码上,只要 thunk 评估的结果是一个函数,它就会一次又一次地返回去评估它。最简单的解决方案是返回一个 thunk,但包裹在一个对象中,如下面的代码所示:

// continued...
class Thunk {
  fn: FN;
  constructor(fn: FN) {
    this.fn = fn;
  }
}
const trampoline2 = (thk: Thunk) => {
  while (
    typeof thk === "object" &&
    thk.constructor.name === "Thunk"
  ) {
    thk = thk.fn();
  }
  return thk;
};

现在的区别是,你将返回一个 Thunk 对象,而不是返回一个 thunk,这样我们新的 trampolining 函数就可以区分实际的 thunk(它被期望被调用和执行)和任何其他类型的结果(它被期望被返回)。

因此,如果你有一个递归算法,但由于栈限制而无法运行,你可以通过以下步骤合理地修复它:

  1. 使用延续将所有递归调用改为尾递归。

  2. 将所有 return 语句替换,使它们返回 thunks。

  3. 将对原始函数的调用替换为 trampoline 调用来开始计算。

当然,这并不是免费的。你会注意到,当使用这个机制时,会有额外的开销,包括返回 thunks、评估它们等等,所以你可以预期总时间会增加。尽管如此,如果替代方案是有一个无法解决问题的解决方案,这仍然是一个低廉的代价!

递归消除

还有一种你可能想要探索的可能性,但它超出了 FP(函数式编程)的范畴,进入了算法设计的领域。在计算机科学中,任何使用递归实现的算法都有一个不使用递归的等价版本,而是依赖于栈。有方法可以将递归算法系统地转换为迭代算法,所以如果你已经用尽了所有选项(也就是说,即使延续或 thunks 也无法帮助你),那么你就有最后一个机会通过将所有递归替换为迭代来实现你的目标。我们不会深入探讨这一点——正如我所说的,这种消除与 FP 关系不大——但重要的是要知道这个工具存在,你可能能够使用它。

摘要

在本章中,我们看到了如何使用递归,这是 FP(函数式编程)的一个基本工具,作为一种强大的技术来创建算法,这些算法在其他情况下可能需要更复杂的解决方案。我们首先考虑了递归是什么以及如何以递归的方式思考问题,然后看到了一些不同领域问题的递归解决方案,最后分析了深递归可能存在的问题以及如何解决它们。

第十章 确保纯净性中,我们将回到本书中较早出现的一个概念,即函数纯净性,并看到一些技术,这些技术将帮助我们通过确保参数和数据结构的不可变性来保证函数不会产生任何副作用。

问题

9.1 如何以递归方式实现reverse(str: string)函数?最好的方法就是使用标准的字符串reverse()方法,如developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reverse中详细说明的那样,但这对于一个关于递归的问题来说是不够的,对吧?

9.2 爬楼梯:假设你想要爬上有n个台阶的楼梯。每次你抬起脚,你可以爬上一阶或两阶。你有多少种不同的方式可以爬上那个楼梯?例如,你可以用五种不同的方式爬上四阶楼梯:

  • 每次只迈一步

  • 每次总是迈两步

  • 先迈两步,然后迈一步,再迈一步

  • 先迈一步,然后迈两步,再迈一步

  • 先迈一步,然后迈另一步,最后迈两步

9.3 递归排序:许多排序算法可以用递归来描述;你能实现它们吗?

  • 选择排序:找到数组的最大元素,移除它,递归地对剩余部分进行排序,然后将最大元素推到已排序剩余部分的末尾

  • 插入排序:取数组的第一个元素,对剩余的元素进行排序,最后将移除的元素插入到已排序剩余部分的正确位置

  • 归并排序:将数组分为两部分,对每一部分进行排序,最后将两个已排序的部分合并成一个有序列表

9.4 greaterEqual。你能预见任何可能的问题吗?以下代码突出了开发者与我们之前看到的原始版本所做的更改:

const quicksort = <A>(arr: A[]): A[] => {
  if (arr.length < 2) {
    return arr;
  } else {
    const pivot = arr[0];
    const smaller = arr.filter((x) => x < pivot);
    const greaterEqual = arr.filter((x) => x >= pivot);
    return [
      ...quicksort(smaller),
      ...quicksort(greaterEqual),
    ];
  }
};

9.5 通过避免两次调用filter()来使quicksort()更高效。类似于我们在第五章中看到的“同时计算多个值”部分,声明式编程,编写一个partition(arr, pr)函数,给定一个arr数组和一个fn()谓词,将返回两个数组:第一个数组包含arrfn为真的值,第二个数组包含arr中其余的值:

const partition = <A>(
  arr: A[],
  fn: (x: A) => boolean
): [A[], A[]] => { … };
const quicksort = <A>(arr: A[]): A[] => {
  if (arr.length < 2) {
    return arr;
  } else {
    const pivot = arr[0];
    const [smaller, greaterEqual] = partition(
      arr.slice(1),
      (x) => x < pivot
    );
    return [
      ...quicksort(smaller),
      pivot,
      ...quicksort(greaterEqual),
    ];
  }
};

9.6 findR()函数,我们没有提供所有可能的参数给cb()回调。你能修复这个问题吗?你的解决方案应该类似于我们为map()和其他函数所做的那样。(而且,如果你还能允许数组中有空位,那就更好了。)

9.7 使用递归实现every()some():你能做到吗?

9.8 对称的皇后:在我们之前解决的八皇后问题中,只有一个解决方案显示了皇后的放置对称性。你能修改你的算法来找到它吗?

9.9 INTERNATIONALCONTRACTORN...T...R...A...T...O。尝试使用和不用 memoizing 来运行它,看看有什么区别!

9.10 isOdd()?有很多!

9.11 使用 trampoline 来避免栈溢出问题实现isOdd()isEven()

9.12 isEven()/isOdd()如下,但它有一个严重的错误;你能找到它吗?

function isEven(n: number): boolean {
  if (n === 0) {
    return true;
  } else {
    return isOdd(n - 1);
  }
}
function isOdd(n: number): boolean {
  if (n === 1) {
    return true;
  } else {
    return isEven(n - 1);
  }
}

9.13 expression()term(),它们不使用while,接下来是——它们正确吗?

function expression(): number {
  for (let accum = term(); ; ) {
    if (tokens[curr] === PLUS) {
      curr++; // skip PLUS
      accum += term();
    } else if (tokens[curr] === MINUS) {
      curr++; // skip MINUS
      accum -= term();
    } else {
      return accum;
    }
  }
}
function term(): number {
  for (let accum = factor(); ; ) {
    if (tokens[curr] === TIMES) {
      curr++; // skip TIMES
      accum *= factor();
    } else if (tokens[curr] === DIVIDES) {
      curr++; // skip DIVIDES
      accum /= factor();
    } else if (tokens[curr] === MODULUS) {
      curr++; // skip MODULUS
      accum %= factor();
    } else {
      return accum;
    }
  }
}

9.14 "^",到我们的算术表达式评估器中。(是的,JavaScript 中的指数运算符是"**",而不是"^",但我希望为了简单起见使用单字符标记。)务必正确实现优先级,并使运算符具有右结合性:2³⁴应该被评估为(2^(3⁴)),而不是((2³)⁴)

9.15 易出错的评估:我们的评估算法容易出错,因为它期望表达式在语法上是有效的。你如何增强它?

第十章:确保纯净性 – 不可变性

第四章 行为规范 中,当我们考虑纯函数及其优势时,我们看到修改接收到的参数或全局变量等副作用是导致不纯的常见原因。现在,在处理 FP 的许多方面和工具的几个章节之后,让我们来谈谈 不可变性 的概念——如何以这种方式处理对象,使得意外修改它们变得更困难,甚至更好,变得不可能。

我们不能强迫开发者以安全、受保护的方式工作。然而,如果我们找到一种方法使数据结构不可变(这意味着它们不能直接更改,除非通过一些接口,该接口永远不会允许我们修改原始数据,而是产生新的对象),那么我们将有一个可执行的解决方案。在本章中,我们将探讨两种不同的方法来处理这样的不可变对象和数据结构:

  • 基本的 JavaScript 方法,如冻结对象,以及克隆以创建新对象而不是修改现有对象

  • 持久数据结构,具有允许我们更新它们而不改变原始数据,也不需要克隆一切的方法,以提高性能

警告!

本章中的代码不是生产就绪的;我想专注于主要观点,而不是所有关于属性、获取器、设置器、透镜、原型等细节,这些细节你应该考虑到一个完整、无懈可击的解决方案。对于实际开发,我建议使用第三方库,但只有在确认它确实适用于你的情况之后。我们将推荐几个这样的库,但当然,还有更多你可以使用的库。

直接使用 JavaScript 的方式

最大的副作用之一是函数修改其参数或全局对象的可能性。所有非原始对象都作为引用传递,所以如果你/当你修改它们时,原始对象将会改变。如果我们想阻止这种情况(而不仅仅依赖于开发者的善意和良好的编码习惯),我们可能需要考虑一些简单的 JavaScript 技术来禁止这些副作用:

  • 避免直接修改它们所应用的对象的突变函数

  • 使用 const 声明来防止变量被更改

  • 冻结对象,使其无法以任何方式被修改

  • 创建(更改)对象的副本以避免修改原始对象

  • 使用获取器和设置器来控制更改的内容和方式

  • 使用函数式概念 – 透镜 – 来访问和设置属性

让我们更详细地看看每种技术。

突变函数

一个常见的不期望问题的来源是,有几个 JavaScript 方法是突变者,它们会修改底层对象。(有关突变者的更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#Mutator_methods。)在这种情况下,仅仅使用这些方法,您就会产生可能甚至没有意识到的副作用。数组是最基本的问题来源,而问题方法列表并不短:

  • copyWithin()允许您在数组内部复制元素

  • fill()用给定的值填充数组

  • push()pop()允许您在数组的末尾添加或删除元素

  • shift()unshift()的工作方式与push()pop()相同,但是在数组的开始处

  • splice()允许您在数组的任何位置添加或删除元素

  • reverse()sort()在原地修改数组,反转或排序其元素

让我们看看我们在第四章**, 行为规范中的“参数突变”部分看到的例子:

// maxStrings.ts
const maxStrings = (a: string[]) => a.sort().pop();
const countries = [
  "Argentina",
  "Uruguay",
  "Brasil",
  "Paraguay",
];
console.log(maxStrings(countries)); // "Uruguay"

我们的maxStrings()函数返回数组中的最大值,但也会修改原始数组;这是sort()pop()突变函数的副作用。在这种情况下和其他情况下,您可能需要生成数组的副本,然后使用它;展开运算符和.slice()都很有用:

const maxStrings2 = (a: string[]): string =>
  [...a].sort().pop() as string;
const maxStrings3 = (a: string[]): string =>
  a.slice().sort().pop() as string;
console.log(maxStrings2(countries)); // "Uruguay"
console.log(maxStrings3(countries)); // "Uruguay"
console.log(countries);
// ["Argentina", "Uruguay", "Brasil", "Paraguay"]
// unchanged

我们maxStrings()函数的新版本现在都是功能性的,没有副作用,因为突变方法已经应用于原始参数的副本。顺便说一句,如果您对两个新函数中的as string部分感到好奇,那是因为 TypeScript 会警告您数组可能为空,而我正在告诉它我保证数组不会这样。

当然,设置方法也是突变者,并且逻辑上会产生副作用,因为它们几乎可以做任何事情。如果这种情况发生,您将不得不选择本章后面描述的其他一些解决方案。

常量

如果突变不是由于使用某些 JavaScript 方法引起的,那么我们可能想要尝试使用const定义,但不幸的是,这根本行不通。在 JavaScript 中,const意味着对象或数组的引用不能改变(您不能将其分配给不同的对象),但您仍然可以修改其属性。我们可以在以下代码中看到这一点:

const myObj = { d: 22, m: 9 };
console.log(myObj);
// {d: 22, m: 9}
myObj = { d: 12, m: 4 };
// Uncaught TypeError: Assignment to constant variable.
myObj.d = 12; // but this is fine!
myObj.m = 4;
console.log(myObj);
// {d: 12, m: 4}

您不能通过为其分配新值来修改myObj的值,但您可以修改其当前值,这样只有对象的引用是恒定的,而不是对象的值本身。(顺便说一句,这种情况也会发生在数组上。)因此,如果您在所有地方都使用const,那么您将只能防止直接对对象和数组的赋值。更多微妙的副作用,例如更改属性或数组元素,仍然可能发生,所以这不是一个解决方案。

有两种方法可以工作——冻结以提供不可修改的结构,以及克隆以产生修改后的新对象。这些可能不是禁止修改对象的最佳方法,但它们可以用作临时解决方案。让我们更详细地看看它们,从冻结开始。

冻结

如果我们想要避免程序员意外或故意修改对象的可能性,冻结它是一个有效的解决方案。对象一旦被冻结,任何修改尝试都将静默失败——JavaScript 不会报告错误或抛出异常,但也不会改变对象。

在以下示例中,如果我们尝试进行与上一节相同的更改,它们将没有任何效果,myObj将保持不变:

const myObj2 = { d: 22, m: 9 };
console.log(myObj2);
// {d: 22, m: 9}
Object.freeze(myObj2);
myObj2.d = 12; // won't have effect...
myObj2.m = 4;
console.log(myObj2);
// Object {d: 22, m: 9}

密封还是冻结?

不要混淆冻结和密封——Object.seal()当应用于对象时,禁止你向其添加或删除属性。这意味着对象的结构是不可变的,但属性本身可以更改。Object.freeze()不仅覆盖了密封属性,还使它们不可更改。有关更多信息,请参阅developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/sealdeveloper.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze

这个解决方案只有一个问题——冻结对象是一个浅层操作,它冻结了属性本身,类似于const声明所做的那样。如果任何属性本身是对象或数组,它们仍然可以被修改。我们在这里只考虑数据;你可能还想冻结函数,例如,但对于大多数用例,你想要保护的是数据:

const myObj3 = {
  d: 22,
  m: 9,
  o: { c: "MVD", i: "UY", f: { a: 56 } },
};
Object.freeze(myObj3);
console.log(myObj3);
// {d:22, m:9, o:{c:"MVD", i:"UY", f:{ a:56}}}

这只部分成功,正如我们尝试更改一些属性时可以看到的:

myObj3.d = 8888;     // won't work, as earlier
myObj3.o.f.a = 9999; // oops, does work!!
console.log(myObj3);
// {d:22, m:9, o:{c:"MVD", i:"UY", f:{ a:9999 }}}

修改myObj3.d不起作用,因为对象已被冻结,但这并不适用于myObj3内的对象,因此修改myObj3.o.f.a是有效的。

如果我们想要使我们的对象达到真正的不可变性,我们需要编写一个将冻结对象所有级别的例程。幸运的是,通过应用递归可以轻松实现这一点。(我们在上一章的遍历树结构部分看到了递归的类似应用。)主要思路是首先冻结对象本身,然后递归地冻结其每个属性。我们必须确保我们只冻结对象的自身属性;我们不应该干扰对象的原型,例如:

// deepFreeze.ts
const deepFreeze = <O extends OBJ>(obj: O): O => {
  if (
    obj &&
    typeof obj === "object" &&
    !Object.isFrozen(obj)
  ) {
    Object.freeze(obj);
    Object.getOwnPropertyNames(obj).forEach((prop) =>
      deepFreeze(obj[prop])
    );
  }
  return obj;
};

注意,与Object.freeze()的工作方式相同,deepFreeze()也会原地冻结对象。我想保持操作的原始语义,以便返回的对象始终是原始对象。如果我们想以更纯粹的方式工作,我们应该首先复制原始对象(我们将在下一节中学习如何做),然后冻结它。至于 TypeScript,返回的值与输入类型相同;被冻结的对象与类型无关。

还存在一个小问题,但结果非常糟糕——如果一个对象包含了对自身的引用会发生什么?如果我们跳过冻结已经冻结的对象,我们可以避免这个问题;向后循环引用将被忽略,因为它们引用的对象已经冻结了。所以,我们编写的逻辑处理了这个问题,没有更多的事情要做!

如果我们对一个对象应用deepFreeze(),我们可以安全地将它传递给任何函数,因为我们知道它不可能被修改。你还可以使用这个属性来测试一个函数是否修改了它的参数——将它们深度冻结,调用函数,如果函数依赖于修改其参数,则它将无法工作,因为更改将被默默地忽略。那么,如果一个函数涉及到接收到的对象,我们如何从函数中返回一个结果呢?这可以通过许多方式解决。一种简单的方法是使用克隆,正如我们将要看到的。

在本节中,我们处理了我们可以使用的方法之一来避免对象的变化。(检查本章末尾的问题部分,了解另一种通过代理冻结对象的方法。)现在,让我们看看一种涉及克隆的替代方法。

克隆和修改

如果不允许修改对象,你必须创建一个新的对象。例如,如果你使用 Redux,一个 reducer 是一个接收当前状态和一个动作(本质上是一个包含新数据的对象)的函数,并产生新的状态。修改当前状态是完全禁止的,我们可以通过始终使用冻结对象来避免这个错误,就像我们在上一节中看到的那样。为了满足 reducer 的要求,我们必须能够克隆原始状态,并根据接收到的动作对其进行修改。结果对象将成为新的状态。

为了使事情更加完整,我们也应该冻结返回的对象,就像我们对原始状态所做的那样。但让我们从开始说起——我们如何克隆一个对象?当然,你可以手动完成这个操作,但在处理大型、复杂对象时,你不会希望考虑这一点。(你可能想回顾一下第五章中的更通用的循环部分,我们在那里编写了一个基本的objCopy()函数,它提供了一种与我们在这里展示的不同方法。)例如,如果你想克隆oldObject以生成newObject,手动操作将意味着大量的代码:

const oldObject = {
  d: 22,
  m: 9,
  o: { c: "MVD", i: "UY", f: { a: 56 } },
};
const newObject = {
  d: oldObject.d,
  m: oldObject.m,
  o: {
    c: oldObject.o.c,
    i: oldObject.o.i,
    f: { a: oldObject.o.f.a },
  },
};

这种手动解决方案显然需要做很多工作,而且容易出错;你可能会忘记一个属性!寻求更自动化的解决方案,在 JavaScript 中有几种简单的方法可以复制数组或对象,但它们都存在同样的问题。你可以使用Object.assign()或者通过展开来创建一个(浅层)对象的副本:

const myObj = { d: 22, m: 9 };
const newObj1 = Object.assign({}, myObj);
const newObj2 = { ...myObj };

要创建一个(浅层)数组的副本,你可以使用slice()或者展开,就像我们在本章前面的突变函数部分看到的那样:

const myArray = [1, 2, 3, 4];
const newArray1 = myArray.slice();
const newArray2 = [...myArray];

这些解决方案有什么问题?如果一个对象或数组包含对象(这些对象可能自身也包含对象),我们将遇到我们在冻结时遇到的问题——对象是通过引用复制的,这意味着新对象的变化也会改变旧对象:

const oldObject = {
  d: 22,
  m: 9,
  o: { c: "MVD", i: "UY", f: { a: 56 } },
};
const newObject2 = Object.assign({}, oldObject);
newObject2.d = 8888;
newObject2.o.f.a = 9999;
console.log(newObject2);
// {d:8888, m:9, o: {c:"MVD", i:"UY", f: {a:9999}}} -- ok
console.log(oldObject);
// {d:22, m:9, o: {c:"MVD", i:"UY", f: {a:9999}}} -- oops!!

在这种情况下,注意当我们改变newObject的一些属性时发生了什么。改变newObject.d没有问题,但改变newObject.o.f.a也影响了oldObject,因为newObject.ooldObject.o实际上指向的是同一个对象。

新时代,旧章节

自 2022 年以来,一个新的structuredClone()函数已经可用,所以如果你的浏览器支持它,这些页面上的代码就不需要了。更多信息,请查看developer.mozilla.org/en-US/docs/Web/API/structuredClone

基于 JSON,有一个简单的解决方案。如果我们stringify()原始对象,然后parse()结果,我们将得到一个与旧对象完全不同的新对象:

// deepCopy.ts
const jsonCopy = <O extends OBJ>(obj: O): O =>
  JSON.parse(JSON.stringify(obj));

通过使用JSON.stringify(),我们可以将我们的对象转换成一个字符串。然后,JSON.parse()从这个字符串中创建一个新的对象——很简单!这对数组和对象都适用,但有一个问题。如果任何对象的属性有构造函数,它们不会被调用;结果将始终由普通的 JavaScript 对象组成。(这并不是jsonCopy()的唯一问题;参见问题 10.2。)我们可以通过Date()简单地看到这一点:

const myDate = new Date();
const newDate = jsonCopy(myDate);
console.log(typeof myDate, typeof newDate);
// object string

虽然myDate是一个对象,但newDate实际上是一个包含日期和时间的字符串,即我们在转换时的日期和时间,"2023-01-15T09:23:55.125Z"。

我们可以采用递归解决方案,就像我们处理深度冻结时做的那样,逻辑相当相似。每当我们找到一个确实是对象的属性时,我们会调用适当的构造函数:

// continued...
const deepCopy = <O extends OBJ>(obj: O): O => {
  let aux: O = obj;
  if (obj && typeof obj === "object") {
    aux = new (obj as any).constructor(); // TS hack!
    Object.getOwnPropertyNames(obj).forEach((prop) => {
      aux[prop as keyof O] = deepCopy(obj[prop]);
    });
  }
  return aux;
};

每当我们发现一个对象的属性实际上是一个对象时,我们在继续之前会调用它的构造函数。这解决了我们遇到的问题,无论是日期还是任何对象!如果我们运行前面的代码,但使用deepCopy()而不是jsonCopy(),我们将得到object object作为输出,正如预期的那样。如果我们检查类型和构造函数,一切都会匹配。

由于 TypeScript 与类相比更适合与构造函数一起使用,因此需要进行一些小的修改——编写 obj as any 可以使类型检查工作,但这并不优雅。此外,我们还需要编写 prop as keyof O,因为否则 TypeScript 会抗议 prop 可以是任何东西,而不一定是原始类型的键。

数据更改实验现在也将正常工作:

let oldObject = {
  d: 22,
  m: 9,
  o: { c: "MVD", i: "UY", f: { a: 56 } },
};
let newObject = deepCopy(oldObject);
newObject.d = 8888;
newObject.o.f.a = 9999;
console.log(newObject);
// {d:8888, m:9, o:{c:"MVD", i:"UY", f:{a:9999}}}
console.log(oldObject);
// {d:22, m:9, o:{c:"MVD", i:"UY", f:{a:56}}} -- unchanged!

让我们检查最后几行。修改 newObjectoldObject 没有影响,因此两个对象是完全独立的。

现在我们知道了如何复制一个对象,我们可以遵循以下步骤:

  1. 接收一个(冻结的)对象作为参数。

  2. 制作一个副本,它不会被冻结。

  3. 从这个副本中取出我们可以在代码中使用的数据。

  4. 随意修改副本。

  5. 冻结它。

  6. 将其作为函数的结果返回。

所有这些都是可行的,尽管有些繁琐。此外,还有一些限制——我们无法复制私有属性或涉及符号的属性,我们也不会复制获取器和设置器,并且与元数据相关的功能也将缺失。让我们接受这一点,并添加几个将有助于将一切整合在一起的函数。

获取器和设置器

当遵循上一节末尾提供的步骤时,你可能已经注意到,每次你想更新一个字段时,事情都会变得麻烦且容易出错。让我们使用一种常见的技术来添加一对函数:获取器和设置器。它们如下所示:

  • 获取器 可以通过解冻对象来从冻结对象中获取值,以便使用。

  • 设置器 允许你修改对象的任何属性。你可以通过创建一个新的更新版本,而保留原始版本不变来实现这一点。

让我们构建我们的获取器和设置器。

获取属性

第六章从对象获取属性 部分,我们编写了一个简单的 getField() 函数,它可以处理从对象中获取单个属性。 (参见该章节的 问题 6.13 以获取缺失的配套 setField() 函数。) 让我们看看我们如何编写这个函数。我们可以有一个直接的版本,如下所示:

// getByPath.ts
const getField =
  <O extends OBJ>(f: keyof O) =>
  (obj: O) =>
    obj[f];

我们甚至可以通过应用柯里化来做得更好,从而得到一个更通用的版本:

// continued...
const getField2 = curry(getField);

我们可以通过组合一系列 getField() 应用来从对象中获取深层属性,但这会很麻烦。相反,让我们创建一个函数,它将接收一个路径——一个字段名称数组——并返回对象对应的部分,或者在路径不存在时返回 undefined。在这里使用递归是合适的,并且可以简化编码!观察以下代码:

// continued...
const getByPath = <O extends OBJ>(
  arr: string[],
  obj: O
): any => {
  if (arr[0] in obj) {
    return arr.length > 1
      ? getByPath(arr.slice(1), obj[arr[0]])
      : deepCopy(obj[arr[0]]);
  } else {
    return undefined;
  }
};

基本上,我们查找路径中的第一个字符串是否存在于对象中。如果不存在,操作失败,因此我们返回 undefined。如果成功,并且路径中还有更多的字符串,我们使用递归继续深入对象;否则,我们返回属性的深层副本。

一旦一个对象被冻结,我们就无法解冻它,因此我们必须求助于创建它的一个新副本;deepCopy()非常适合做这件事。让我们试试我们的新函数:

const myObj4 = deepFreeze({
  d: 22,
  m: 9,
  o: { c: "MVD", i: "UY", f: { a: 56 } },
});
console.log(getByPath(["d"], myObj4));
// 22
console.log(getByPath(["o"], myObj4));
// {c: "MVD", i: "UY", f: {a: 56}}
console.log(getByPath(["o", "c"], myObj4));
// "MVD"
console.log(getByPath(["o", "f", "a"], myObj4));
// 56

我们还可以检查返回的对象是否未冻结:

const fObj = getByPath(["o", "f"], myObj4);
console.log(fObj);
// {a: 56}
fObj.a = 9999;
console.log(fObj);
// {a: 9999} -- it's not frozen

在这里,我们可以看到我们可以直接更新fObj对象,这意味着它没有被冻结。现在我们已经编写了获取器,我们可以创建一个设置器。

通过路径设置属性

现在,我们可以编写一个类似的setByPath()函数,它将接受一个路径、一个值和一个对象,并更新对象。这不是一个纯函数,但我们将用它来编写一个纯函数——稍后你就会看到!以下是代码:

// setByPath.ts
const setByPath = <O extends OBJ>(
  arr: string[],
  value: any,
  obj: O
): O => {
  if (!(arr[0] in obj)) {
    (obj as any)[arr[0]] =
      arr.length === 1
        ? null
        : Number.isInteger(arr[1])
        ? []
        : {};
  }
  if (arr.length > 1) {
    return setByPath(arr.slice(1), value, obj[arr[0]]);
  } else {
    obj[arr[0] as keyof O] = value;
    return obj;
  }
};

在这里,我们使用递归进入对象,如果需要,创建新的属性,直到我们走完整个路径。在创建属性时有一个关键细节,那就是我们需要数组还是对象。(为什么需要对obj进行as any类型转换?这是 TypeScript 的一个问题,它反对obj[arr[0]],因此我们必须“欺骗”它。奇怪的是,使用Reflect.set()也行!)我们可以通过检查路径中的下一个元素来确定这一点——如果是数字,则需要数组;否则,对象即可。

当我们到达路径的末尾时,我们分配新的给定值。

无缝、不可变的对象

如果你喜欢这种方式,可以查看seamless-immutable库,它以这种方式工作。名字中的“无缝”部分暗示了这样一个事实,即你仍然使用正常的对象——尽管是冻结的——这意味着你可以使用map()reduce()等。你可以在github.com/rtfeldman/seamless-immutable了解更多信息。

现在,你可以编写一个函数,该函数能够接受一个冻结的对象并更新其内部的属性,返回一个新的、同样冻结的对象:

// continued...
const updateObject = <O extends OBJ>(
  arr: string[],
  obj: O,
  value: any
) => {
  const newObj = deepCopy(obj);
  setByPath(arr, value, newObj);
  return deepFreeze(newObj);
};

让我们看看它是如何工作的。为此,我们将对我们一直在使用的myObj3对象进行几次更新:

const myObj3 = {
  d: 22,
  m: 9,
  o: { c: "MVD", i: "UY", f: { a: 56 } },
};
const new1 = updateObject(["m"], myObj3, "sep");
console.log(new1);
// {d: 22, m: "sep", o: {c: "MVD", i: "UY", f: {a: 56}}};
const new2 = updateObject(["b"], myObj3, 220960);
console.log(new2);
// {d: 22, m: 9, o: {c: "MVD", i: "UY", f: {a: 56}}, b:
  220960};
const new3 = updateObject(["o", "f", "a"], myObj3, 9999);
console.log(new3);
// {d: 22, m: 9, o: {c: "MVD", i: "UY", f: {a: 9999}}};
const new4 = updateObject(
  ["o", "f", "j", "k", "l"],
  myObj3,
  "deep"
);
console.log(new4);
// {d: 22, m: 9, o: {c: "MVD", i: "UY", f: {a: 56, j: {k:
  "deep"}}}};

给定这对函数,我们终于找到了一种保持不可变性的方法:

  • 对象必须从一开始就冻结

  • 从对象中获取数据使用getByPath()

  • 设置数据使用updateObject(),它内部使用setByPath()

在本节中,我们学习了如何以保持对象不可变的方式从对象中获取和设置值。现在让我们看看这个概念的变体——镜头——它不仅允许我们获取和设置值,还可以对数据进行函数应用。

镜头

另一种获取和设置值的方法被称为光学,包括透镜(我们现在将研究)和棱镜(我们将在本章后面讨论)。什么是透镜?它们是在对象中聚焦于特定位置的功能方式(另一个光学术语!),这样我们就可以以非突变的方式访问或修改其值。在本节中,我们将查看一些透镜用法的示例,并考虑两种实现方式——首先是一个基于对象的简单实现,然后是一个更完整的实现,后者之所以有趣,是因为我们将使用的一些技术。

使用透镜

这两种实现将共享基本功能,所以让我们先跳过透镜是什么或它们是如何构建的,而是看看它们用法的示例。首先,让我们创建一个我们将要使用的示例对象——关于一个作家(他的名字听起来很熟悉...)和他的书籍的一些数据:

const author = {
  user: "fkereki",
  name: {
    first: "Federico",
    middle: "",
    last: "Kereki",
  },
  books: [
    { name: "Google Web Toolkit", year: 2010 },
    { name: "Functional Programming", year: 2017 },
    { name: "Javascript Cookbook", year: 2018 },
  ],
};

我们将假设存在几个函数;我们将在接下来的章节中看到它们的实现。透镜依赖于给定属性的存在一个获取器和设置器,我们可以通过直接使用lens(),或者更简短的lensProp()来构建一个。让我们为user属性创建一个透镜:

const lens1 = lens(getField("user"), setField("user"));

这定义了一个聚焦于user属性的透镜。由于这是一个常见的操作,它也可以更紧凑地写出来:

const lens1 = lensProp("user");

这两个透镜都允许我们聚焦于我们使用它们的任何对象的user属性。使用透镜,有三个基本操作,我们将遵循传统,使用大多数(如果不是所有)库使用的名称:

  • view():用于访问属性的值

  • set():用于修改属性的值

  • over():用于将函数应用于属性并更改其值

让我们假设函数是柯里化的,就像我们在上一章中看到的那样。因此,要访问user属性,我们可以写出以下内容:

console.log(view(lens1)(author));
// fkereki

view()函数将其第一个参数作为透镜。当应用于对象时,它产生透镜聚焦的任何值的值——在我们的例子中,是user属性。当然,你可以应用一系列view()函数来到达对象的更深层部分:

console.log(
  view(lensProp("last"))(view(lensProp("name"))(author))
);
// Kereki

在本节关于光学的部分,我们将始终使用完全柯里化的函数,这不仅是为了多样性,也因为通常就是这样应用这些函数的,正如你将在任何教科书中看到的那样。

而不是编写一系列view()调用,我们将组合透镜,以便我们可以更深入地聚焦于对象。让我们看看最后一个示例,它展示了我们如何访问一个数组:

const lensBooks = lensProp("books");
console.log(
  "The author wrote " +
    view(lensBooks)(author).length +
    " book(s)"
);
// The author wrote 3 book(s)

在未来,如果作者结构有任何变化,只需简单更改lensBooks的定义就足以保持其余代码不变。

透镜在其他地方?

你也可以使用透镜来访问其他结构;参考问题 10.8了解如何使用透镜与数组一起使用,以及问题 10.9了解如何使用透镜使其与映射一起工作。

接下来,set() 函数允许我们设置 lenses 的焦点值:

console.log(set(lens1)("FEFK")(author));
/*
{
  user: 'FEFK',
  name: { first: 'Federico', middle: '', last: 'Kereki' },
  books: [
    { name: 'Google Web Toolkit', year: 2010 },
    { name: 'Functional Programming', year: 2017 },
    { name: 'Javascript Cookbook', year: 2018 }
  ]
}
*/

set() 的结果是具有更改值的新对象。以完全柯里化的方式使用此函数可能会令人惊讶,但如果我们使用之前章节中的 curry()partialCurry() 函数,我们也可以将 set(lens1, "FEFK", author) 写出来。

使用 over() 类似,它返回一个新的对象,但在这个例子中,通过应用映射函数来更改值:

const triple = (x: string): string => x + x + x;
const newAuthor = over(lens1)(triple)(author);
console.log(newAuthor);
/*
{
  user: 'fkerekifkerekifkereki',
  name: { first: 'Federico', middle: '', last: 'Kereki' },
  books: [
    { name: 'Google Web Toolkit', year: 2010 },
    { name: 'Functional Programming', year: 2017 },
    { name: 'Javascript Cookbook', year: 2018 }
  ]
}
*/

一个基本问题是,为什么 user 等于 "fkerekifkerekifkereki",而不是 "FEFKFEFKFEFK"?我们的 lens 在使用设置器时不会修改对象,而是提供一个新对象,因此我们将 triple() 应用到原始对象的用户属性上。

你可以用 lenses 做更多的事情,但现在我们只关注这三个。(这里有一个建议——看看 问题 10.7,了解如何使用 lenses 访问实际上不存在于对象中的虚拟属性的一个有趣想法。)

为了完成本节,我建议查看一些第三方光学库(github.com/stoeffel/awesome-fp-js#lensestinyurl.com/jslenses 有几个建议),以了解所有可用的功能。现在我们已经了解了使用 lenses 时可以期待什么,让我们学习如何实现它们。

使用对象实现 lenses

实现一个 lens 的最简单方法是通过一个具有两个属性的对象来表示它——一个获取器和设置器。在这种情况下,我们会得到类似这样的东西:

// lensesWithObjects.ts
const getField =
  <O extends OBJ>(attr: string) =>
  (obj: O) =>
    obj[attr];
const setField =
  <O extends OBJ>(attr: string) =>
  (value: any) =>
  (obj: O): O => ({ ...obj, [attr]: value });

我们已经看到了类似的 getField()setField() 函数;前者从一个对象中获取一个特定的属性,而后者返回一个具有单个更改属性的新的对象。我们现在可以定义我们的 lens

// continued...
type GET<O extends OBJ> = ReturnType<typeof getField<O>>;
type SET<O extends OBJ> = ReturnType<typeof setField<O>>;
const lens = <O extends OBJ>(
  getter: GET<O>,
  setter: SET<O>
) => ({
  getter,
  setter,
});
const lens = (getter: GET, setter: SET): LENS => ({
  getter,
  setter,
});
const lensProp = (attr: string) =>
  lens((getField as any)(attr), setField(attr));

这很容易理解——给定一个获取器和设置器,lens() 创建一个具有这两个属性的对象,而 lensProp() 通过使用 getField()setField()lens() 创建一个获取器/设置器对,这非常直接。现在我们有了我们的 lens,我们如何实现之前章节中看到的三个基本函数?查看一个属性需要应用获取器;为了保持柯里化风格,让我们手动进行柯里化:

// continued...
type LENS<O extends OBJ> = ReturnType<typeof lens<O>>;
const view =
  <O extends OBJ>(someLens: LENS<O>) =>
  (someObj: O) =>
    someLens.getter(someObj);

通用 LENS<> 类型是 lens() 函数返回的类型。

同样,设置一个属性只是应用设置器的问题:

// continued...
const set =
  <O extends OBJ>(someLens: LENS<O>) =>
  (newVal: any) =>
  (someObj: O) =>
    someLens.setter(newVal)(someObj);

最后,将映射函数应用于一个属性是一个“一石二鸟”的操作——我们使用获取器来获取属性的当前值,我们将函数应用于它,然后我们使用设置器来存储计算出的结果:

// continued...
const over =
  <O extends OBJ>(someLens: LENS<O>) =>
  (mapFn: (arg: any) => any) =>
  (someObj: O) =>
    someLens.setter(mapFn(someLens.getter(someObj)))(
      someObj
    );

这需要仔细研究。我们使用 lens 的 getter() 函数从输入对象中获取一些属性,我们将映射函数应用于获取的值,然后我们使用 lens 的 setter() 函数来生成一个新的具有更改属性的对象。

现在我们已经可以执行所有三种操作,我们有了工作的透镜!关于组合呢?透镜有一个独特的特性——它们是反向组合的,从左到右,所以你从最通用的开始,以最具体的结束。这确实与直觉相反;我们将在下一节中更详细地了解这一点,但现在,我们将保持传统:

// continued...
const composeTwoLenses = <O extends OBJ>(
  lens1: LENS<O>,
  lens2: LENS<O>
) => ({
  getter: (obj: O) => lens2.getter(lens1.getter(obj)),
  setter: (newVal: any) => (obj: O) =>
    lens1.setter(lens2.setter(newVal)(lens1.getter(obj)))(
      obj
    ),
});

代码有点令人印象深刻,但并不难理解。两个透镜组合的获取器是使用第一个透镜的获取器,然后应用第二个透镜的获取器到该结果上。组合的设置器稍微复杂一些,但遵循相同的思路;你能看到它是如何工作的吗?现在,我们可以轻松地组合透镜;让我们从一个虚构的无意义对象开始:

const deepObject = {
  a: 1,
  b: 2,
  c: {
    d: 3,
    e: {
      f: 6,
      g: { i: 9, j: { k: 11 } },
      h: 8,
    },
  },
};

现在,我们可以定义一些透镜:

const lC = lensProp("c");
const lE = lensProp("e");
const lG = lensProp("g");
const lJ = lensProp("j");
const lK = lensProp("k");

我们可以尝试以几种不同的方式组合我们的新透镜,只是为了变化,以检查一切是否正常工作:

const lJK = composeTwoLenses(lJ, lK);
const lGJK = composeTwoLenses(lG, lJK);
const lEGJK = composeTwoLenses(lE, lGJK);
const lCEGJK1 = composeTwoLenses(lC, lEGJK);
console.log(view(lCEGJK1)(deepObject));
const lCE = composeTwoLenses(lC, lE);
const lCEG = composeTwoLenses(lCE, lG);
const lCEGJ = composeTwoLenses(lCEG, lJ);
const lCEGJK2 = composeTwoLenses(lCEGJ, lK);
console.log(view(lCEGJK2)(deepObject));
/*
11 both times
*/

使用 lCEGJ1,我们编写了一些透镜,从后者开始。使用 lCEGJ2,我们从透镜的开始部分开始,但结果是一样的。现在,让我们尝试设置一些值。我们想要将 k 属性设置为 60。我们可以通过使用刚刚应用的相同透镜来完成这个操作:

const setTo60 = set(lCEGJK1)(60)(deepObject);
/*
{
  a: 1,
  b: 2,
  c: {
    d: 3,
    e: {
      f: 6,
      g: { i: 9, j: { k: 60 } },
      h: 8,
    },
  },
};
*/

组合透镜工作得非常完美,值已经改变。(此外,还返回了一个新对象;原始对象未修改,正如我们所希望的。)为了完成,让我们验证我们是否可以使用 over() 与我们的透镜一起使用,并尝试复制 k 值,使其变为 22。为了变化,让我们使用另一个组合透镜,尽管我们知道它以相同的方式工作:

const setToDouble = over(lCEGJK2)((x) => x * 2)(deepObject);
/*
{
  a: 1,
  b: 2,
  c: {
    d: 3,
    e: {
      f: 6,
      g: { i: 9, j: { k: 22 } },
      h: 8,
    },
  },
};
*/

现在,我们已经学会了如何以简单的方式实现透镜。然而,让我们考虑一种不同的方法来实现相同的目标,即使用实际函数来表示透镜。这将允许我们以标准方式执行组合,而无需任何特殊的透镜函数。

使用函数实现透镜

之前使用对象实现的透镜实现效果很好,但我们想看看一种不同的做事方式,这将使我们能够使用更高级的函数概念。这将涉及我们在 第十二章构建更好的容器 中将要更详细分析的一些概念,但在这里,我们将只使用我们需要的,这样你就不必现在就去读那章!我们的透镜将以与之前相同的方式工作,只是由于它们将是函数,我们将能够不使用任何特殊的组合代码来组合它们。

这里的关键概念是什么?透镜将是一个基于获取器和设置器对的功能,它将构建一个 容器(实际上是一个对象,但让我们使用容器名称)并具有一个 value 属性和一个 map 方法(在 第十二章构建更好的容器 中,我们将看到这是一个 函子,但你现在不需要知道这一点)。通过具有特定的映射方法,我们将实现我们的 view()set()over() 函数。

我们的定义 lens() 函数如下。我们将在稍后解释其细节:

// lensesWithFunctions.ts
const lens =
  <O extends OBJ>(getter: GET<O>, setter: SET<O>) =>
  (fn: FN) =>
  (obj: O) =>
    fn(getter(obj)).map((value: any) =>
      setter(value)(obj));

让我们考虑它的参数:

  • gettersetter 参数与之前相同。

  • fn 函数是“魔法调料”,让一切工作;根据我们想要对透镜做什么,我们将提供一个特定的函数。我们将在稍后了解更多关于这个!

  • obj 参数是我们想要应用透镜的对象。

让我们编写我们的 view() 函数。为此,我们需要一个辅助类 Constant,它给定一个值 v,产生一个具有该值的容器和一个返回相同容器的 map 函数:

// continued...
class Constant<V> {
  private value: V;
  map: FN;
  constructor(v: V) {
    this.value = v;
    this.map = () => this;
  }
}

现在,我们可以编写 view() 代码了:

// continued...
const view =
  <O extends OBJ, V>(someLens: LENS<O>) =>
  (obj: O) =>
    someLens((x: V) => new Constant(x))(obj).value;
const user = view(lensProp("user"), author);
/*
fkereki
*/

这里发生了什么?让我们一步一步地跟踪;这有点棘手!

  1. 我们使用 lensProp() 创建一个聚焦于 user 属性的透镜。

  2. 我们的 view() 函数将构建常数的函数传递给 lens()

  3. 我们的 lens() 函数使用获取器来访问 author 对象中的用户属性。

  4. 然后,我们接收到的值被用来创建一个常数容器。

  5. 调用 map() 方法,它返回相同的容器。

  6. 访问容器的 value 属性,这就是在第 3 步中获取器的返回值。哇!

在掌握这些之后,让我们继续学习 set()over(),这需要不同的辅助函数来创建一个值可能变化的容器:

// continued...
class Variable<V> {
  private value: V;
  map: FN;
  constructor(v: V) {
    this.value = v;
    this.map = (fn) => new Variable(fn(v));
  }
}

在这种情况下(与 Constant 对象相反),map() 方法确实做了些事情——当提供一个函数时,它将此函数应用于容器的值,并返回一个新的 Variable 对象,带有结果值。现在可以实施 set() 函数:

// continued...
const set =
  <O extends OBJ, V>(someLens: LENS<O>) =>
  (newVal: V) =>
  (obj: O) =>
    someLens(() => new Variable(newVal))(obj).value;
const changedUser = set(lensProp("user"))("FEFK")(author);
/*
{
  user: 'FEFK',
  name: { first: 'Federico', middle: '', last: 'Kereki' },
  books: [
    { name: 'Google Web Toolkit', year: 2010 },
    { name: 'Functional Programming', year: 2017 },
    { name: 'Javascript Cookbook', year: 2018 }
  ]
}
*/

在这种情况下,当透镜调用容器的 map() 方法时,它将产生一个新的容器和一个新值,这产生了所有差异。为了理解它是如何工作的,请遵循我们之前看到的 get() 的相同六个步骤——唯一的区别将在 第 5 步,那里将产生一个新的、不同的容器。

现在我们已经成功通过了这段(确实有点棘手!)代码,over() 函数很简单,唯一的区别是,你不再映射到一个给定的值,而是使用提供的映射函数 mapfn 来计算容器的新值:

// continued...
const over =
  <O extends OBJ, V>(someLens: LENS<O>) =>
  (mapfn: FN) =>
  (obj: O) =>
    someLens((x: V) => new Variable(mapfn(x)))(obj).value;
const newAuthor = over(lensProp("user"))(triple)(author);
/*
{
  user: 'fkerekifkerekifkereki',
  name: { first: 'Federico', middle: '', last: 'Kereki' },
  books: [
    { name: 'Google Web Toolkit', year: 2010 },
    { name: 'Functional Programming', year: 2017 },
    { name: 'Javascript Cookbook', year: 2018 }
  ]
}
*/

如您所见,set()over() 之间的区别在于,在前者中,你提供一个值来替换原始值,而在后者中,你提供一个函数来计算新值。除此之外,两者相似。

最后,让我们验证 compose() 是否可以应用于基于函子(functor)的透镜:

// continued...
const lastName = view(
  compose(lensProp("name"), lensProp("last"))
)(author);
/*
Kereki
*/

在这里,我们为 namelast 创建了两个单独的透镜,并使用我们之前在 第八章 中开发的相同的 compose() 函数将它们组合起来,连接函数。使用这个组合透镜,我们没有任何问题地关注了作者的姓氏,所以一切如预期进行。

方向错误吗?

透镜应该从左到右组合似乎与逻辑相悖;这似乎是倒退的。这是让开发者感到困扰的事情,如果你在谷歌上搜索解释,你会找到很多。为了自己回答这个问题,我建议详细说明compose()是如何工作的——两个函数就足够了——然后替换透镜的定义;你会看到为什么以及如何一切都能正常工作。

现在我们已经了解了透镜,我们可以继续了解棱镜,另一种光学工具。

棱镜

如我们在上一节所见,透镜在处理产品类型时很有用。然而,棱镜在处理求和类型时很有用。但它们是什么?(我们将在本书的最后一章更详细地探讨产品类型和联合类型。)产品类型总是由相同的选项构建而成,例如一个类中的对象,而求和类型可能具有不同的结构——例如额外的或缺失的属性。当你使用透镜时,你假设你要应用透镜的对象具有已知结构且没有变化,但如果对象具有不同的结构,你将使用什么?答案是棱镜。让我们首先看看它们是如何使用的;然后,我们将检查它们的实现。

使用棱镜

使用棱镜与使用透镜类似,但要注意当某个属性不存在时会发生什么。让我们看看上一节的一个例子:

const author = {
  user: "fkereki",
  name: {
    first: "Federico",
    middle: "",
    last: "Kereki",
  },
  books: [
    { name: "Google Web Toolkit", year: 2010 },
    { name: "Functional Programming", year: 2017 },
    { name: "Javascript Cookbook", year: 2018 },
  ],
};

如果我们想使用棱镜访问user属性,我们会写一些类似以下的内容(现在不用担心细节):

const pUser = prismProp("user");
console.log(preview(pUser, author).toString());
/*
fkereki
*/

这里,我们使用prismProp()函数定义了一个棱镜,这与我们之前的lensProp()函数类似。然后,我们使用棱镜与preview()函数一起使用,这与透镜中的get()函数类似,结果与使用透镜一样——没有惊喜。如果我们请求一个不存在的别名属性会发生什么?让我们看看:

const pPseudonym = prismProp("pseudonym");
console.log(preview(pPseudonym, author).toString());
/*
undefined
*/

到目前为止,我们可能没有看到任何区别,但让我们看看如果我们尝试组合具有多个缺失属性的透镜或棱镜会发生什么。假设你想使用透镜访问一个(缺失的!)pseudonym.usedSince属性,而不采取预防措施并检查属性是否存在。在这里,你会得到以下输出:

const lPseudonym = lensProp("pseudonym");
const lUsedSince = lensProp("usedSince");
console.log(
  "PSEUDONYM, USED SINCE",
  view(compose(lPseudonym, lUsedSince))(author)
);
/*
TypeError: Cannot read property 'usedSince' of undefined
.
. many more error lines, snipped out
.
*/

另一方面,由于棱镜已经考虑了缺失的值,这不会引起任何问题,我们会得到一个undefined结果;这就是为什么preview()有时被称为getOptional()的原因:

const pUsedSince = prismProp("usedSince");
console.log(
  preview(compose(pPseudonym, pUsedSince))(
    author
  ).toString()
);
/*
undefined
*/

如果我们想设置一个值会发生什么?与set()函数类似的是review()函数;让我们看看它将如何工作。想法是,无论我们指定什么属性,只有在属性已经存在的情况下才会设置该属性。所以,如果我们尝试更改user.name属性,这将起作用:

const fullAuthor2 = review(
  compose(prismProp("name"), prismProp("first")),
  "FREDERICK",
  author
);

然而,如果我们尝试修改(不存在的)pseudonym属性,将返回原始的、未更改的对象:

const fullAuthor3 = review(pPseudonym, "NEW ALIAS", author);
// returns author, unchanged

因此,使用棱镜可以处理所有可能的缺失或可选字段。我们如何实现这种新的光学类型?使用了新的名称(preview()review() 代替 get()set()),但这个差异很小。让我们看看。

实现棱镜

我们如何实现棱镜?我们将从我们的透镜实现中汲取灵感并做出一些更改。在获取属性时,我们必须检查我们正在处理的对象是否不是 nullundefined,以及我们想要的属性是否在对象中。我们可以通过对我们原始的 getField() 函数进行一些小的更改来做到这一点:

// prisms.ts
const getFieldP =
  <O extends OBJ>(attr: string) =>
  (obj: O) =>
    obj && attr in obj ? obj[attr] : undefined;

在这里,我们检查对象和属性的存在性 – 如果一切正常,我们返回 obj[attr];否则,我们返回 undefinedsetField() 的更改非常相似:

// continued...
const setFieldP =
  <O extends OBJ>(attr: string) =>
  (value: any) =>
  (obj: O): O =>
    obj && attr in obj
      ? { ...obj, [attr]: value }
      : { ...obj };

如果对象和属性都存在,我们通过更改属性值返回一个新的对象;否则,我们返回对象的副本。这就是全部!定义其他函数直接基于 lens()lensProp() 等等,所以我们将跳过这一点。

现在我们已经学会了如何以函数式的方式访问对象,让我们分析那些可以非常高效地修改而不需要原始对象完整副本的持久化数据结构。

创建持久化数据结构

如果你想在数据结构中更改某些内容,只是直接去更改它,你的代码将充满副作用。另一方面,每次都复制完整的结构是时间和空间的浪费。有一个中间方案与此有关,即持久化数据结构,如果处理得当,可以在创建新结构的同时应用更改。

既然有那么多可能的数据结构可以工作,我们只需看看几个例子:

  • 与列表一起工作,这是最简单的数据结构之一

  • 与对象一起工作,这是 JavaScript 程序中非常常见的需求

  • 处理数组,这将证明更难处理

让我们开始吧!

与列表一起工作

让我们考虑一个简单的程序 – 假设你有一个列表并想向其中添加一个新元素。你会怎么做?在这里,我们可以假设每个节点都是一个 NodeList 对象:

// lists.ts
class ListNode<T> {
  value: T;
  next: ListNode<T> | null;
  constructor(value: any, next = null) {
    this.value = value;
    this.next = next;
  }
}

可能的列表如下所示 – list 变量将指向第一个元素。看看下面的图;你能说出列表中缺少什么,以及在哪里吗?

图 10.1 – 初始列表

图 10.1 – 初始列表

如果你想在 B 和 F(示例列表代表音乐家会理解的概念,即大三和弦,但缺少 D 音)之间添加 D,最简单的解决方案是添加一个新节点并更改一个现有节点。这将导致以下结果:

图 10.2 – 列表现在有一个新元素 – 我们必须修改一个现有的元素来执行添加

图 10.2 – 列表中现在有一个新元素——我们必须修改一个现有的元素来执行添加操作

然而,以这种方式工作显然是非函数性的,我们显然正在修改数据。有另一种工作方式——创建一个持久数据结构,其中所有更改(插入、删除和修改)都是单独进行的,注意不要修改现有数据。另一方面,如果结构的一部分可以重用,这是为了提高性能。进行持久更新将返回一个新的列表,其中一些节点是前一个的一些副本,但原始列表没有任何更改。这可以在以下图中看到:

图 10.3 – 虚线元素显示新返回的列表,它与旧列表共享一些元素

图 10.3 – 虚线元素显示新返回的列表,它与旧列表共享一些元素

以这种方式更新结构需要复制一些元素以避免修改原始结构,但列表的一部分是共享的。

当然,我们也会处理更新或删除。再次从以下图中显示的列表开始,如果我们想更新其第四个元素,解决方案意味着创建一个新的列表子集,包括第四个元素,同时保持其余部分不变:

图 10.4 – 我们列表中的更改元素

图 10.4 – 我们列表中的更改元素

删除元素也会类似。让我们按照以下方式删除原始列表中的第三个元素 F:

图 10.5 — 删除第三个元素后的原始列表

图 10.5 — 删除第三个元素后的原始列表

使用列表或其他结构始终可以解决以提供数据持久性。目前,让我们专注于对我们来说可能最重要的工作类型——处理简单的 JavaScript 对象。毕竟,所有数据结构都是 JavaScript 对象,所以如果我们能处理对象,我们就能处理其他结构。

更新对象

此方法也可以应用于更常见的需求,例如修改对象。这对于 Redux 用户来说是一个非常不错的想法——一个 reducer 可以被编程为接收旧状态作为参数,并生成一个带有最小必要更改的更新版本,而不会以任何方式改变原始状态。

假设你有一个以下对象:

myObj = {
    a: ...,
    b: ...,
    c: ...,
    d: {
        e: ...,
        f: ...,
        g: {
            h: ...,
            i: ...
        }
    }
};

假设你想以持久的方式修改myObj.d.f属性的值。而不是复制整个对象(使用我们之前编写的deepCopy()函数),我们可以创建一个新的对象,它与前一个对象有多个共同属性,但对于修改的部分有新的属性。这可以在以下图中看到:

图 10.6 – 一种持久编辑对象的方法 – 即通过共享一些属性和创建其他属性

图 10.6 – 一种持久编辑对象的方法 – 即通过共享一些属性和创建其他属性

旧对象和新对象共享大多数属性,但新的 df 属性,所以你在创建新对象时最小化了更改。

如果你想手动完成这项工作,你需要以一种非常繁琐的方式写下类似以下的内容。大多数属性都来自原始对象,但 dd.f 是新的:

newObj = {
  a: myObj.a,
  b: myObj.b,
  c: myObj.c,
  d: {
    e: myObj.d.e,
    f: the new value,
    g: myObj.d.g,
  },
};

在本章前面,当我们决定编写一个克隆函数时,我们看到了一些类似的代码。这里,让我们尝试一种不同的解决方案。实际上,这种更新可以自动化:

// objects.ts
const setIn = <O extends OBJ>(
  arr: (string | number)[],
  value: any,
  obj: O
): O => {
  const newObj = Number.isInteger(arr[0]) ? [] : {};
  Object.keys(obj).forEach((k) => {
    (newObj as any)[k] = k !== arr[0] ? obj[k] : null;
  });
  (newObj as any)[arr[0]] =
    arr.length > 1
      ? setIn(arr.slice(1), value, obj[arr[0]])
      : value;
  return newObj as O;
};

逻辑是递归的,但并不复杂。首先,我们在当前级别确定我们需要什么类型的对象——一个数组还是一个对象。然后,我们将原始对象的所有属性复制到新对象中,除了我们要更改的属性。最后,我们将该属性设置为给定的值(如果我们已经完成了属性名称的路径),或者我们使用递归进一步复制。

排序参数

注意参数的顺序 – 首先是路径,然后是值,最后是对象。我们正在应用将最稳定的参数放在前面,最易变的参数放在后面的概念。如果你将这个函数 currying 化,你可以将相同的路径应用到几个不同的值和对象上,如果你固定路径和值,你仍然可以使用这个函数与不同的对象一起使用。

让我们尝试这个逻辑。我们将从一个多级且具有多个对象数组的无意义对象开始,以增加多样性:

const myObj1 = {
  a: 111,
  b: 222,
  c: 333,
  d: {
    e: 444,
    f: 555,
    g: {
      h: 666,
      i: 777,
    },
    j: [{ k: 100 }, { k: 200 }, { k: 300 }],
  },
};

我们可以通过更改 myObj.d.f 为新值来测试这一点:

let myObj2 = setIn(["d", "f"], 88888, myObj1);
/*
{
  a: 111,
  b: 222,
  c: 333,
  d: {
    e: 444,
    f: 88888,
    g: { h: 666, i: 777 },
    j: [{ k: 100 }, { k: 200 }, { k: 300 }],
  }
}
*/
console.log(myObj1.d === myObj2.d);     // false
console.log(myObj1.d.f === myObj2.d.f); // false
console.log(myObj1.d.g === myObj2.d.g); // true

底部的日志验证了算法是否正确工作 – myObj2.d 是一个新对象,但 myObj2.d.g 重新使用了 myObj1 的值。

更新第二个对象中的数组让我们测试逻辑在这些情况下的工作情况:

const myObj3 = setIn(["d", "j", 1, "k"], 99999, myObj2);
/*
{
  a: 111,
  b: 222,
  c: 333,
  d: {
    e: 444,
    f: 88888,
    g: { h: 666, i: 777 },
    j: [{ k: 100 }, { k: 99999 }, { k: 300 }],
  }
}
*/
console.log(myObj1.d.j === myObj3.d.j);       // false
console.log(myObj1.d.j[0] === myObj3.d.j[0]); // true
console.log(myObj1.d.j[1] === myObj3.d.j[1]); // false
console.log(myObj1.d.j[2] === myObj3.d.j[2]); // true

我们可以将 myObj1.d.j 数组中的元素与新创建的对象中的元素进行比较。你会发现数组是一个新的,但有两个元素(那些未更新的)仍然是 myObj1 中的相同对象。

这显然不足以解决问题。我们的逻辑可以更新现有字段,甚至如果它不存在,还可以添加它,但你还需要消除旧属性。库通常提供更多函数,但让我们现在专注于删除属性,这样我们可以看看我们可以对对象进行的其他一些重要结构更改:

// continued...
const deleteIn = <O extends OBJ>(
  arr: (string | number)[],
  obj: O
): O => {
  const newObj = Number.isInteger(arr[0]) ? [] : {};
  Object.keys(obj).forEach((k) => {
    if (k !== arr[0]) {
      (newObj as any)[k] = obj[k];
    }
  });
  if (arr.length > 1) {
    (newObj as any)[arr[0]] = deleteIn(
      arr.slice(1),
      obj[arr[0]]
    );
  }
  return newObj as O;
};

这里的逻辑与 setIn() 的逻辑相似。区别在于我们并不总是从原始对象复制所有属性到新对象;我们只在没有到达路径属性数组的末尾时才这样做。在更新后的测试系列中继续,我们得到以下结果:

const myObj4 = deleteIn(["d", "g"], myObj3);
const myObj5 = deleteIn(["d", "j"], myObj4);
console.log(myObj5);
// { a: 111, b: 222, c: 333, d: { e: 444, f: 88888 } }

使用这对函数,我们可以通过以高效的方式修改、添加和删除,而不必无谓地创建新对象来管理持久化对象。

一个小贴士

最著名的用于处理不可变对象的库是名为immutable.js的库,可以在immutable-js.com/找到。它唯一的弱点是它的文档臭名昭著地晦涩难懂。然而,有一个简单的解决方案——查看untangled.io/the-missing-immutable-js-manual/上的The Missing Immutable.js Manual,那里有你需要的一切示例,你将不会遇到任何麻烦!

一个最后的注意事项

使用持久化数据结构需要一些克隆操作,但你是如何实现一个持久化数组的呢?如果你思考这个问题,你会意识到在这种情况下,除了在每次操作后克隆整个数组之外,没有其他出路。这意味着像更新数组中的一个元素这样的操作,原本是常数时间的,现在将需要与数组大小成比例的时间。

复杂度问题

在算法复杂度方面,更新操作从 O(1)变成了 O(n)。同样,访问一个元素可能变成 O(log n)的操作,其他操作,如映射和归约,也可能观察到类似的减速。

我们如何避免这种情况?没有简单的解决方案。例如,你可能发现数组在内部表示为二叉搜索树(甚至更复杂的数据结构),而持久化库提供了必要的接口,这样你仍然可以将其用作数组,而不会注意到内部差异。

当使用这类库时,没有克隆的不可变更新的优势可能会部分被一些可能变慢的操作所抵消。如果这成为你应用程序的瓶颈,你可能不得不改变实现不可变性的方式,甚至想方设法改变你的基本数据结构以避免时间损失,或者至少最小化它。

总结

在本章中,我们探讨了两种不同的方法(由常用的不可变库使用)通过处理不可变对象和数据结构来避免副作用——一种基于使用 JavaScript 的对象冻结,以及一些特殊的克隆逻辑,另一种基于应用持久数据结构的概念,这些方法允许进行各种更新,而不改变原始数据或需要完全克隆。

第十一章 实现设计模式 中,我们将关注面向对象程序员经常提出的问题——设计模式在函数式编程(FP)中是如何使用的?它们是必需的、可用的还是可用的?它们是否仍然在实践,但新的焦点是函数而不是对象?我们将通过几个示例来回答这些问题,展示它们在哪里以及如何与常规的面向对象(OOP)实践等效或不同。

问题

10.1 jsonCopy() 函数在处理日期时存在问题,但这并非全部。如果我们尝试复制包含映射的对象会发生什么?集合?正则表达式?函数?

10.2 jsonCopy()deepCopy() 在处理相同类型的对象时表现如何?它可以被增强吗?

10.3 JSON.stringify() 在处理此类情况时会提出抗议。你该如何修复我们的 deepCopy() 函数以避免这个问题?我们在 deepFreeze() 函数中处理了这个问题,但那个解决方案在这里不能使用;需要不同的方法。

10.4 freezeByProxy(obj) 函数将应用这个想法来禁止对对象的所有类型的更新(添加、修改或删除属性)。记住,如果一个对象有其他对象作为属性,你需要递归地工作!

10.5 insertAfter(list, newKey, oldKey) 函数将创建一个新的列表,但在 oldKey 节点之后添加一个带有 newKey 的新节点。在这里,你需要假设列表中的节点是由以下逻辑创建的:

type MUSICAL_KEY = string;
class Node {
  key: MUSICAL_KEY;
  next: Node | null;
  constructor(key: MUSICAL_KEY, next: Node | null) {
    this.key = key;
    this.next = next;
  }
}
const c3 =
  new Node("G",
    new Node("B",
      new Node("F",
        new Node("A",
          new Node("C",
            new Node("E", null)
          )
        )
      )
    )
  );

10.6 composeLenses() 函数将允许你组合尽可能多的简单透镜,而不是像 composeTwoLenses() 那样仅限于两个,正如我们在 第八章 *连接函数**中做的那样,当我们从 composeTwo() 转换到通用的 compose() 函数时。

10.7 getField()setField()。然后,我们使用组合来访问更深的属性。你能通过提供一个路径来创建一个透镜,从而允许更短的代码吗?

10.8 author 和返回以 LAST NAME, FIRST NAME 格式的作者全名?其次,你能写一个 setter,给定一个全名,将其分成两半并设置其首尾名吗?有了这两个函数,你可以编写以下内容:

const fullNameLens = lens(
...your getter...,
...your setter...
);
console.log(view(fullNameLens, author));
/*
Kereki, Federico
*/
console.log(set(fullNameLens, "Doe, John", author));
/*
{
  user: 'fkereki',
  name: { first: ' John', middle: '', last: 'Doe' },
…
}
*/

10.9 数组的透镜? 如果你创建了以下代码中的透镜并将其应用于数组,会发生什么?如果存在问题,你能修复它吗?

const getArray = curry((ind, arr) => arr[ind]);
const setArray = curry((ind, value, arr) => {
  arr[ind] = value;
  return arr;
});
const lensArray = (ind) =>
  lens(getArray(ind), setArray(ind));

10.10 lensMap() 函数来创建一个可以用来访问和修改映射的透镜。你可能需要查看以下内容以获取有关克隆映射的更多信息:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map。你的函数应该声明如下。你还将不得不编写几个辅助函数:

const lensMap = key => lens(getMap(key), setMap(key));

第十一章:以函数式方式实现设计模式

第十章 确保纯净性 中,我们看到了解决不同问题的几个函数式技术。然而,习惯于使用 OOP 的程序员可能会发现我们遗漏了一些在命令式编码中常用的一些知名公式和解决方案。由于设计模式是众所周知的,程序员可能已经了解它们在其他语言中的应用,因此了解函数式实现是如何进行的非常重要。

在本章中,我们将考虑 OOP 中常见的 设计模式 提供的解决方案,以查看其在 FP 中的等效模式。这将帮助您从 OOP 过渡到更函数式的方法,并通过查看问题的替代解决方案来了解 FP 的力量和方法。

尤其是我们将研究以下主题:

  • 设计模式 的概念及其适用范围

  • 一些 OOP 标准模式和如果需要我们在 FP 中有什么替代方案

  • 观察者模式,它导致 响应式编程,一种处理事件的声明式方法

  • FP 设计模式,与 OOP 模式无关

在本章中,我们不会过多地担心打字和 TypeScript,因为我们想专注于模式,最小化并抽象其他所有内容。

理解设计模式

在软件工程中最相关的书籍之一是 《设计模式:可复用面向对象软件元素》(1994),由 四人帮GoF)—— Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著。这本书介绍了大约二十个 OOP 模式,并被认为在计算机科学中非常重要。

模式 实际上是一个建筑设计的概念,最初由建筑师 Christopher Alexander 定义。然而,在软件术语中,设计模式 是一种适用于软件设计中常见问题的通用、可重用解决方案。它不是具体的完成和编码的设计,而是一种描述解决方案(也用 模板 一词)的描述,可以解决在许多上下文中出现的问题。鉴于它们的优点,设计模式是开发者可以用于不同类型的系统、编程语言和环境中的最佳实践。

GoF 书籍显然专注于 OOP,其中一些模式不适用于或应用于 FP。其他模式是不必要的或无关的,因为函数式语言已经为相应的面向对象问题提供了标准解决方案。即使存在这种困难,由于大多数程序员已经接触过 OOP 设计模式,并且通常试图在其他上下文中(如 FP)应用它们,因此考虑原始问题并查看如何产生新的解决方案是有意义的。标准基于对象的解决方案可能不适用,但问题仍然存在,因此了解如何解决它们仍然有效。

模式通常用四个基本、基本元素来描述:

  • 一个简单、简短的名称,用于描述问题、解决方案及其后果。当与同事交谈、解释设计决策或描述特定实现时,名称很有帮助。

  • 模式适用的上下文 – 需要解决方案的具体情况,可能还需要满足一些附加条件。

  • 一个解决方案,列出解决给定情况所需的元素(类、对象、函数、关系等)。

  • 应用该模式可能产生的后果(结果和权衡)。你可能从解决方案中获得一些收益,但也可能意味着一些损失。

在本章中,我们假设你已经了解我们将描述和使用的所有设计模式,因此我们只会提供一些关于它们的细节。相反,我们将关注 FP 如何使问题变得无关紧要(因为应用函数式技术解决它的方法很明显)或者以某种方式解决它。

此外,我们不会涵盖所有 GoF 模式;我们只会关注那些最有趣的,也就是说,那些在应用函数式编程(FP)与面向对象编程(OOP)相比能带来更多差异的模式。

设计模式类别

根据它们的焦点,设计模式通常被分为几个不同的类别。以下列表中的前三个出现在原始 GoF 书中,但后来又增加了更多类别。具体如下:

  • 行为设计模式涉及对象之间的交互和通信。与其关注对象是如何创建或构建的,关键考虑的是如何将它们连接起来,以便在执行复杂任务时能够协作,最好是以提供已知优势的方式,例如减少耦合或增强内聚性。

  • 创建型设计模式处理以适合当前问题的方式创建对象的方法。通过它们,你可以决定在几个替代对象之间进行选择,这样程序可以以不同的方式工作,这取决于可能在编译时或运行时已知的参数。

  • 结构设计模式涉及对象的组合,从许多单个部分形成更大的结构,并实现对象之间的关系。一些模式暗示了继承或接口的实现,而其他模式则使用不同的机制,所有这些机制都是为了能够在运行时动态地改变对象的组合方式。

  • 并发模式处理多线程编程。尽管函数式编程(FP)通常非常合适(例如,考虑到缺乏赋值和副作用),但由于我们使用的是 JavaScript,这些模式对我们来说并不非常相关。

  • 架构模式更倾向于高层次,比我们之前列出的模式范围更广,并为软件架构问题提供一般性解决方案。就目前而言,我们在这本书中不考虑这些问题,因此我们也不会处理这些问题。

这些类别并不是固定不变的,或者说是不可动摇的。在 GoF(Gang of Four)原始书籍出版 15 年后,其三位作者中的三位(参见www.informit.com/articles/article.aspx?p=1404056上的Design Patterns 15 Years Later: An Interview with Erich Gamma, Richard Helm, and Ralph Johnson文章)提出了一组新的类别列表——核心创建型(类似于原始类别,但增加了依赖注入模式,我们将在稍后研究)、外围其他

旧的好习惯

耦合和内聚是早在面向对象编程流行之前就使用的术语;它们可以追溯到 20 世纪 60 年代末,当时 Larry Constantine 的结构化设计一书出版。耦合衡量任何两个模块之间的相互依赖性,而内聚性与所有模块组件真正属于一起的程度有关。低耦合和高内聚是软件设计值得追求的目标,因为它们意味着相关的事物靠近,而不相关的事物分离。

沿着这个思路,你还可以将设计模式分类为对象模式(涉及对象之间的动态关系)和类模式,它们处理类与子类之间的关系(这些关系在编译时静态定义)。我们不会过多关注这种分类,因为我们的观点更多地与行为和功能有关,而不是类和对象。

如前所述,我们现在可以很容易地观察到这些类别高度倾向于面向对象编程(OOP),前三个类别直接提到了对象。然而,为了不失一般性,我们将超越定义,记住我们试图解决的问题,然后探讨与函数式编程(FP)类似的解决方案,这些解决方案虽然可能不是 100%等同于面向对象的解决方案,但精神上将以并行的方式解决相同的问题。让我们继续前进,首先考虑我们为什么要处理模式!

我们需要设计模式吗?

一个有趣的观点认为,设计模式只是为了修补编程语言的不足。其理由是,如果你可以用一种简单、直接和直截了当的方式用给定的编程语言解决问题,那么你可能根本不需要设计模式。(例如,如果你的语言不提供递归,你必须自己实现它;否则,你就可以直接使用它,无需进一步操作。)然而,研究模式让你思考解决问题的不同方式,这是它们的一个优点。

在任何情况下,对于面向对象(OOP)开发者来说,了解 FP 如何帮助解决某些问题而无需进一步工具是有趣的。在下一节中,我们将考虑几个知名的设计模式,并探讨为什么我们不需要它们或我们如何可以轻松实现它们。事实上,我们已经在文本中应用了几个模式,因此我们也会指出那些例子。

然而,我们不会尝试将所有设计模式都表达或转换为函数式编程(FP)术语。例如,单例模式基本上需要一个单一的全局对象,这在某种程度上与函数式程序员所习惯的一切相悖。鉴于我们对 FP 的方法(记得本书第一章中的“Sorta Functional Programming”(SFP)吗?),我们也不会介意,如果需要单例,我们可能会考虑使用它,即使 FP 没有合适的等价物。(而且,正如我们很快就会看到的,每次你从一个模块导入时,你都在使用一个单例!)

最后,必须说的是,我们的观点可能会影响人们认为是什么模式以及不是什么模式。对某些人来说可能是模式的东西,对其他人来说可能只是微不足道的细节。鉴于 FP 让我们能够以简单的方式解决某些特定问题,我们已经在前几章中看到了这样的例子,我们将发现一些这样的情况。

面向对象设计模式

在本节中,我们将回顾一些 GoF 设计模式,检查它们是否与 FP 相关,并研究如何实现它们。当然,有些设计模式没有 FP 解决方案。例如,没有单例的等价物,这暗示了全局访问对象的外来概念。此外,虽然你可能不再需要面向对象的特定模式,但开发者仍然会以这些模式为思考方式。而且,由于我们不会完全采用函数式编程,如果某些 OOP 模式适用,为什么不用它呢,即使它不是完全函数式的?

我们将考虑以下内容:

  • 门面(Façade)和适配器(Adapter)为其他代码提供新的接口

  • 装饰器(Decorator)(也称为包装器(Wrapper))向现有代码添加新功能

  • 策略(Strategy)、模板(Template)和命令(Command)通过传递函数作为参数来让你微调算法

  • 依赖注入(Dependency Injection)有助于解耦组件并简化测试

  • 观察者(Observer),它导致响应式编程,一种声明式处理事件的方式

  • 其他不完全符合相应面向对象(OOP)模式的模式

让我们通过分析几个让你以不同方式使用代码的类似模式开始我们的研究。

门面(Facade)和适配器(Adapter)

在这两种模式中,让我们从外观(Facade)开始,或者更准确地说,外观(Façade)。这是为了解决为类或库的方法提供不同接口的问题。想法是为系统提供一个新的接口,使其更容易使用。你可以说外观提供了一个更好的控制面板来访问某些功能,消除了用户的困难。

S 还是 K?

外观外观?原始单词是一个建筑术语,意为“建筑物的正面”,源自法语。根据这个来源和通常的撇号(ç)字符的发音,它的发音有点像fuh-sahd。另一种拼写可能与国际键盘上缺少国际字符有关,并提出了以下问题——你不应该读作fah-Kade吗?你可能会将这个问题视为凯尔特人的相反,发音为Keltic,将s音变为k音。

我们想要解决的主要问题是以更简单的方式使用外部代码。(当然,如果这是你的代码,你可以直接处理这类问题;我们必须假设你无法——或者不应该——尝试修改其他代码。这通常发生在你使用任何可在网络上获得的库时,例如。)关键是实现一个模块,它将提供一个更适合你需求的接口。你的代码将使用你的模块,而不会直接与原始代码交互。

假设你想进行 Ajax 调用,而你唯一的选择是使用一些具有非常复杂界面的困难库。使用模块,你可能编写如下内容,与一个想象中的、难以使用的 Ajax 库一起工作:

// simpleAjax.js
import * as hard from "hardajaxlibrary";
// import the other library that does Ajax calls
// but in a hard, difficult way, requiring complex code
const convertParamsToHardStyle = (params) => {
  // do some internal steps to convert params
  // into whatever the hard library may require
};
const makeStandardUrl = (url) => {
  // make sure the URL is in the standard
  // way for the hard library
};
const getUrl = (url, params, callback) => {
  const xhr = hard.createAnXmlHttpRequestObject();
  hard.initializeAjaxCall(xhr);
  const standardUrl = makeStandardUrl(url);
  hard.setUrl(xhr, standardUrl);
  const convertedParams = convertParamsToHardStyle(params);
  hard.setAdditionalParameters(params);
  hard.setCallback(callback);
  if (hard.everythingOk(xhr)) {
    hard.doAjaxCall(xhr);
  } else {
    throw new Error("ajax failure");
  }
};
const postUrl = (url, params, callback) => {
  // some similarly complex code
  // to do a POST using the hard library
};
export { getUrl, postUrl };
// the only methods that will be seen

现在,如果你需要执行GETPOST操作,你不必通过提供的复杂 Ajax 库的所有复杂性,可以使用提供更简单工作方式的新外观。开发者将编写import {getUrl, postUrl} from "simpleAjax"并更合理地工作。

现在,随着浏览器对import/export的支持,代码将像之前显示的那样工作。在那之前(或者出于向后兼容性的原因),实现将需要使用立即执行函数表达式(IIFE),如第三章中“立即调用”部分所述,开始使用函数,使用揭示模块模式。那么实现该模式的办法如下:

const simpleAjax = (function () {
  const hard = require("hardajaxlibrary");
  const convertParamsToHardStyle = (params) => {
    // ...
  };
  const makeStandardUrl = (url) => {
    // ...
  };
  const getUrl = (url, params, callback) => {
    // ...
  };
  const postUrl = (url, params, callback) => {
    // ...
  };
  return { getUrl, postUrl };
})();

揭示模块名称的原因现在应该很明显了。在先前的代码中,由于 JavaScript 的作用域规则,simpleAjax可见的属性只有simpleAjax.getUrlsimpleAjax.postUrl;使用立即执行函数表达式(IIFE)让我们能够安全地实现模块(以及,因此,外观),使实现细节保持私有。

关于模块和单例

在现代 JavaScript 中,模块是单例模式的一个例子。(在数学中,“单例”是一个只有一个元素的集合。)如果你在代码的几个不同地方导入一个模块,所有引用都将指向同一个对象,这正是单例模式在面向对象代码中要求的。

现在,适配器模式与之类似,因为它也是旨在定义一个新的接口。然而,虽然外观(Façade)模式定义了一个新的接口来旧代码,适配器模式用于当你需要为新代码实现旧接口时,因此它会匹配你已有的内容。如果你正在使用模块,很明显,适用于外观模式的相同类型的解决方案在这里也会起作用,所以我们不需要详细研究它。现在,让我们继续探讨一个我们在这本书中之前已经看到过的著名模式!

装饰器或包装器

装饰器模式(也称为包装器)在你想要以动态方式向对象添加额外职责或功能时非常有用。让我们考虑一个简单的例子,我们将用一些 React 代码来展示它。(如果你不知道这个框架,不用担心;例子会很容易理解。使用 React 的想法是因为它能够很好地利用这个模式。此外,我们已经看到了纯 JavaScript 高阶函数的例子,所以看到一些新内容是好的。)假设我们想在屏幕上显示一些元素,并且出于调试目的,我们想在对象周围显示一个细红色的边框。你该如何做?

如果你使用面向对象编程(OOP),你必须创建一个新的子类来提供扩展的功能。对于这个特定的例子,你可能提供一个带有 CSS 类名的属性,该类名提供所需的样式,但让我们保持对面向对象编程的关注;使用 CSS 并不总是解决这个软件设计问题,所以我们想要一个更通用的解决方案。新的子类将知道如何显示自己带有边框,并且你将使用这个子类来显示任何你想要边框可见的对象。

通过我们对高阶函数的经验,我们可以通过在另一个函数中包装原始函数来以不同的方式解决这个问题,该函数将提供额外的功能。

注意,我们在第六章“包装函数 – 保持行为”部分已经看到了一些包装的例子。例如,在该部分中,我们看到了如何包装函数以产生新的版本,这些版本可以记录输入和输出,提供计时信息,甚至记住调用以避免未来的延迟。这次,我们将这个概念应用于装饰一个视觉组件,但原则是相同的。

让我们定义一个简单的 React 组件ListOfNames,它可以显示一个标题和一组人员名单,对于后者,我们将使用FullNameDisplay组件。这些元素的代码如下所示:

const FullNameDisplay = ({ first, last }) => {
  return (
    <div>
      First Name: <b>{first}</b>
      <br />
      Last Name: <b>{last}</b>
    </div>
  );
};
const ListOfNames = ({ people, heading }) => {
  return (
    <div>
      <h1>{heading}</h1>
      <ul>
        {people.map((v) => (
          <FullNameDisplay first={v.first} last={v.last} />
        ))}
      </ul>
    </div>
  );
};

ListOfNames 组件使用映射创建一个 FullNameDisplay 组件来显示每个人的数据。我们的应用程序的逻辑可能是以下这样:

import { createRoot } from "react-dom/client";
const GANG_OF_FOUR = [
  { first: "Erich", last: "Gamma" },
  { first: "Richard", last: "Helm" },
  { first: "Ralph", last: "Johnson" },
  { first: "John", last: "Vlissides" }
];
const FullNameDisplay = ...
const ListOfNames = ...
const rootElement = document.getElementById("root");
const root = createRoot(rootElement);
root.render(
  <ListOfNames people={GANG_OF_FOUR} heading="GoF" />
);

照我说的做...

在现实生活中,你不会把每个组件的所有代码都放在同一个源代码文件中——你可能会有一两个 CSS 文件。然而,在我们的例子中,把所有东西放在一个地方并使用内联样式就足够了,所以请耐心一点,记住这句话 “照我说的做,别照我做的做。”

我们可以快速在 codesandbox.io/ 的在线 React 沙盒中测试结果;如果你想要其他选项,请谷歌搜索 react online sandbox。界面设计没什么可说的(所以请不要批评我的糟糕网页!)因为我们现在对设计模式感兴趣;参考 图 11.1,如下所示:

图 11.1 – 我们组件的原始版本显示了一个(没什么可说的)名字列表

图 11.1 – 我们组件的原始版本显示了一个(没什么可说的)名字列表

在 React 中,内联组件是用 JSX(内联 HTML 风格)编写的,并编译成对象,这些对象随后被转换成 HTML 代码以供显示。每当调用 render() 方法时,它都会返回一个对象结构。因此,我们将编写一个函数,它将组件作为参数,并返回一个新的 JSX,一个包裹的对象。在我们的例子中,我们希望将原始组件包裹在 <div> 中,并添加所需的边框:

const makeVisible = (component) => {
  return (
    <div style={{ border: "1px solid red" }}>
      {component}
    </div>
  );
};

如果你愿意,你可以让这个函数知道它是在开发模式还是生产模式下执行;在后一种情况下,它将简单地返回原始组件参数而不做任何更改,但现在我们不必担心这个问题。

我们现在必须将 ListOfNames 改为使用包裹组件;新版本如下所示:

const ListOfNames = ({ people, heading }) => {
  return (
    <div>
      <h1>{heading}</h1>
      <ul>
        {people.map((v) =>
          makeVisible(<FullNameDisplay
            first={v.first}
            last={v.last} />)
        )}
      </ul>
    </div>
  );
};

装饰过的代码按预期工作:每个 ListOfNames 组件现在都被另一个组件包裹,为它们添加了所需的边框;参考 图 11.2,如下所示:

图 11.2 – 装饰过的 ListOfNames 组件看起来仍然没什么特别的,但现在它显示了一个添加的边框

图 11.2 – 装饰过的 ListOfNames 组件看起来仍然没什么特别的,但现在它显示了一个添加的边框

在前面的章节中,我们看到了如何装饰一个函数,将其包裹在另一个函数中,以便它执行额外的代码并添加一些功能。在这里,我们看到了如何将相同的解决方案风格应用于提供 高阶组件(在 React 术语中称为),并包裹在额外的 <div> 中以提供一些视觉上的独特细节。

一个 Redux 装饰器

如果你使用过 Redux 和 react-redux 包,你可能已经注意到后者的 connect() 方法在相同的意义上也是一个装饰器;它接收一个组件类,并返回一个新的组件类,该类连接到存储库,以便在你的表单中使用。有关更多详细信息,请参阅 github.com/reduxjs/react-redux

让我们转向另一组模式,这些模式将允许我们改变函数的执行方式。

策略、模板和命令

策略模式适用于你想要能够通过改变方式来改变类、方法或函数,可能以动态的方式,即通过改变它实际执行预期任务的方式。例如,一个 GPS 应用程序可能需要根据人是步行、骑自行车还是开车来应用不同的策略,在两个地点之间找到一条路径。在这种情况下,可能需要最快的或最短的路线。问题是相同的,但必须根据给定条件应用不同的算法。

这听起来熟悉吗?如果是的话,那是因为我们之前已经遇到过类似的问题。当我们想要以不同的方式对一组字符串进行排序时,在 第三章从函数开始,我们需要一种指定应用排序方式的方法,或者说,如何比较两个给定的字符串并确定哪个应该排在前面。根据语言的不同,我们必须应用不同的比较方法来排序。

在尝试 FP 解决方案之前,让我们考虑更多实现我们的路由函数的方法。你可以通过编写足够大的代码片段来做到这一点,该代码片段接收一个声明要使用哪个算法的参数,以及起点和终点。有了这些参数,函数可以进行切换或类似操作,以应用正确的路径查找逻辑。代码大致相当于以下片段:

function findRoute(byMeans, fromPoint, toPoint) {
  switch (byMeans) {
    case "foot":
    /*
    find the shortest road for a walking person
    */
    case "bicycle":
    /*
    find a route apt for a cyclist
    */
    case "car-fastest":
    /*
    find the fastest route for a car driver
    */
    case "car-shortest":
    /*
    find the shortest route for a car driver
    */
    default:
    /*
    plot a straight line, or throw an error,
    or whatever suits you
    */
  }
}

这种解决方案并不理想,你的函数是许多不同函数的总和,这并不提供高度的凝聚力。如果你的语言不支持 lambda 函数(例如,Java 在 2014 年 Java 8 发布之前就是这样),那么这个 OOP(面向对象编程)解决方案需要定义实现你可能想要的策略的不同类的类,创建一个适当的对象,并将其传递出去。

在 JavaScript 中使用 FP(函数式编程),实现策略变得非常简单;你不需要使用像 byMeans 这样的变量来切换,而是提供一个路由查找函数(以下代码中的 routeAlgorithm()),该函数将实现所需的路径逻辑:

function findRoute(routeAlgorithm, fromPoint, toPoint) {
  return routeAlgorithm(fromPoint, toPoint);
}

你仍然需要实现所有期望的策略(这是不可避免的)并决定传递给 findRoute() 的函数,但现在这个函数与路由逻辑独立,如果你想要添加新的路由算法,你就不需要修改 findRoute()

如果你考虑模板模式,区别在于策略允许你使用完全不同的方式来实现结果,而模板提供了一个包含一些实现细节留给方法来指定的总体算法(或模板)。同样,你可以提供实现策略模式的函数;你也可以为模板模式提供它们。

最后,命令模式还受益于能够将函数作为参数传递的能力。这种模式旨在能够将请求封装为一个对象,因此对于不同的请求,你有不同参数化的对象。鉴于我们可以将函数作为参数传递给其他函数,就没有必要有封装对象。

我们在第三章“使用函数入门”部分的“React-Redux reducer”部分也看到了这种模式的类似使用。第三章“使用函数入门”*。在那里,我们定义了一个表,其中的每个条目都是一个回调,每当需要时就会被调用。我们可以直接说命令模式只是作为回调函数工作的面向对象(OO)替代。

现在,让我们考虑一个相关的模式,依赖注入,它也将允许我们改变方法或函数的工作方式。

依赖注入

在基本术语中,依赖注入是一种模式,其中对象或函数接收它完成工作所需的任何其他对象或函数,这导致更少的耦合和更多的灵活性。使用这种技术,一个服务可以在多个环境中工作或使用不同的配置,并且改变它可能不需要修改其代码。

为了使事情更清晰,让我们考虑一个服务,它使用 Node 和 Express 实现,接收一个请求,与其他实体交互(可能查询数据库,访问一些文件存储桶,向消息队列发送消息,调用其他服务等),并最终构建一个响应发送回去。这有什么问题吗?一个快速的答案可能是“没有问题!”因为它工作得很好,这也是许多服务实现的方式。然而,进一步挖掘,我们可能会决定答案应该是“一切都有问题!”为什么?

对于任何一段代码,总有三个主要关注点:

  • 它是否易于理解? 我们服务的代码可能难以理解,因为它将业务逻辑关注点与实现细节混合在一起,涉及到次要问题,比如如何查询数据库和访问存储桶。

  • 它是否可维护? 如果我们想知道改变我们服务代码的简单程度,问题就变成了可能有多少种改变的理由。业务逻辑的改变始终是一个可能;这是必要的。然而,其他改变(比如使用 Redis 代替 MySQL 或者向数据库表添加记录而不是向队列发送消息)与服务的业务目标无关,也会要求代码的改变。

  • 可测试性? 我们可能需要也可能不需要维护代码(实际上,如果需要任何更改,那将是未来的事情),但我们今天必须测试我们的代码。我们将如何进行?这会容易吗?

最后一个是我们现在关心的事项。与其他实体的所有交互都是明显的不纯函数,因此我们可以以三种方式设置我们的测试。

  • 我们可以与独立的、特殊的环境一起工作。每个开发者都需要一个完整的环境(包括数据库、队列、服务器等),以便代码可以像现实中一样运行。为了进行测试,开发者首先应该以已知的方式设置一切,然后检查数据库是否被正确修改,是否发送了正确的消息,等等。所有这些都是可能的,但成本高昂,设置困难,而且主要很慢——在每次测试之前,你必须重置一切,在每次测试之后,你必须检查一切。

  • 我们可以使用完全模拟的外部实体。像 Jest 或 Jasmine 这样的工具允许我们模拟实体,因此我们的代码,而不是处理实际的数据库、队列、服务等等,将与模拟的实体(透明地)交互,这些实体模仿了所需的行为。这要高效得多(因为不需要真实的环境,没有实际的数据库被更新,没有消息真正被发送,等等),但模拟所有所需的行为仍然是一项大量工作。

  • 我们可以先让服务不那么不纯!我们之前在第四章中看到了这种方法,行为规范,它允许我们轻松编写测试。

现在我们来探讨实际细节,并考虑一个可能的服务。

实现一个服务

假设我们有一个端点,它通过在数据库中搜索客户并在搜索后向队列发送消息来响应GET /client/:id请求。

我们将根据它将接收的端口(接口)和适配器(接口实现)来编写我们的服务代码。在我们的情况下,端口将(抽象地)定义我们的服务应该如何与其他实体交互,而适配器将(具体地)实现所需的功能。有了这个想法,我们将能够提供不同的适配器,为不同的环境提供灵活性。在生产环境中,我们将提供能够工作、访问数据库、发送消息等的适配器,但在测试中,我们将能够注入带有简单“什么也不做”的模拟实现。

任何其他名称的架构

这种架构风格自然被称为“端口和适配器”,但它也被称为“六边形架构”——一个更吸引人的名字!不要试图弄清楚为什么使用“六边形”这个词;它只是指在图中使用六边形来表示服务,没有其他含义!

让我们看看这是如何工作的。如果我们的服务需要在数据库中通过其 ID 查找客户,我们必须定义一个合适的接口,一个“查找客户”端口。我们可以定义以下内容:

type FindClientPort = (
  id: number
) => Promise<ClientType | null>;

这个定义说明我们的端口将接收一个数值 ID 作为参数,并返回一个承诺,该承诺将解析为一个ClientType对象或null。(我们无法指定语义方面,但听起来返回的对象很可能是找到的客户端;null将代表搜索失败。)我们还需要一个实际实现:

const findClientFromDBAdapter: FindClientPort = async (
  id: number
) => {
  // access the database, do the search, etc., and
  // return a promise to get the client from DB
};

命名很重要;端口定义并没有说明客户端将从哪里来,但适配器会说明。我们可以有不同的适配器,它们会在其他地方(如密钥存储、电子表格或文件系统)寻找客户端,但它们都会实现相同的接口。

当然,根据我们的服务定义,我们还需要一个端口和适配器来发送消息。我们该如何编写我们的服务呢?代码如下:

function getClientService(id: number,
  { findClient, sendMsg } =
  { findClient: findClientFromDBAdapter,
    sendMsg: sendMsgToMQAdapter }) {
  ...
}

我们在做什么?我们的服务接收id和一个可选的对象,该对象提供两个适配器。如果省略此对象,我们的服务将使用与数据库和消息队列一起工作的默认适配器。在我们的服务器中,处理/client/:id端点的代码将使用getClientService(req.params.id),因此与实际的数据库和消息队列一起工作。但我们如何测试我们的服务?这正是我们现在需要看到的。

测试一个服务

在上一节中,我们看到了如何在生产环境中调用我们的服务。然而,对于测试,我们会采取不同的做法,例如以下内容:

findClientMock = jest.fn().mockResolvedValue(...);
sendMsgMock = jest.fn().mockReturnValue(...);
result = await getClientService(22,
  { findClient: findClientMock,
    sendMsg: sendMsgMock });
expect(findClientMock).toHaveBeenCalledWith(22);
expect(sendMsgMock).toHaveBeenCalledWith(...);
expect(result).toMatchObject(...);

我们首先定义几个模拟函数;findClientMock将模拟数据库中的搜索,而sendMsgMock将返回成功消息发送操作会返回的内容。现在我们可以用模拟调用我们的getClientService(),然后验证(模拟)适配器是否被正确使用,并且服务返回正确的答案。

现在我们来讨论一个经典的模式,它引入了一个新术语,响应式编程,这个术语在当今被广泛使用。

观察者和响应式编程

观察者模式的想法是定义实体之间的联系,以便当一个实体发生变化时,所有依赖的实体都会自动更新。一个可观察对象可以发布其状态的变化,并且其观察者(已订阅可观察对象)将收到此类变化的通知。

目前没有可观察对象

有一个提议要将可观察对象添加到 JavaScript 中(见github.com/tc39/proposal-observable),但截至 2023 年 1 月,它仍然处于第一阶段,自 2020 年末以来没有活动。因此,目前使用库仍然是强制性的。

有一个扩展这个概念的概念叫做响应式编程,它涉及到异步的事件流(如鼠标点击或按键)或数据(来自 API 或 WebSockets),以及应用程序的不同部分通过传递回调来订阅观察这些流,这些回调将在出现新内容时被调用。

我们不会自己实现响应式编程;相反,我们将使用 RxJS,这是由微软最初开发的响应式扩展(ReactiveX)的 JavaScript 实现。RxJS 在 Angular 框架中广泛使用,也可以用于其他前端框架,如 React 或 Vue,或者后端使用 Node.js。了解更多关于 RxJS 的信息,请访问rxjs-dev.firebaseapp.comwww.learnrxjs.io

我们将在这些部分展示的技术,令人困惑地被称为map()filter()reduce(),用于处理这些流并选择要处理的事件以及如何处理。好吧,现在可能有些令人困惑,所以请耐心一点,我们先看看一些概念,然后是一些 FRP 的示例——或者你可以称它为什么!我们将看到以下内容:

  • 你在处理 FRP 时需要了解的几个基本概念和术语

  • 你将使用到的许多可用操作符之一。

  • 几个示例——检测多击并提供自动完成搜索。

让我们继续分析每个项目,从你需要了解的基本思想开始。

基本概念和术语

使用 FRP 需要习惯几个新术语,所以让我们从一份简短的词汇表开始:

  • $; 请参阅angular.io/guide/rx-library#naming-conventions

  • next()error()complete(),当有值可用时、出错时以及流结束时,observable会分别调用这些方法。

  • 来自第五章声明式编程map()filter()等,让你以声明式的方式对流应用转换。

  • 我们在第八章连接函数中开发的pipeline()函数。

  • subscribe()方法,提供一个观察者。

观察一个有趣的方法是它们完成了这个表格的底部一行——检查一下。你可能对单个列非常熟悉,但可能对多个列不太熟悉:

单个 多个
Function Iterator
Promise Observable

我们如何解释这个表格?行区分了拉(你调用某个东西)和推(你被调用),而列表示你得到多少个值——一个或多个。有了这些描述,我们可以看到以下内容:

  • 一个function被调用并返回一个单一值。

  • 一个promise会调用你的代码(then()方法中的回调),也只有一个值。

  • 一个iterator每次被调用时都会返回一个新的值——至少直到序列结束。

  • 一个observable会在流中的每个值上调用你的代码(前提是你已经subscribe()到该observable)。

可以将Observablespromises比较一下:

  • 它们本质上都是async的,你的回调将在不确定的未来时间被调用。

  • 承诺不能被取消,但你可以从一个observableunsubscribe()

  • 承诺在创建时立即开始执行;可观察对象是惰性的,直到观察者对它们执行 subscribe() 操作之前,不会发生任何事情

可观察对象的真正力量来自于你可以使用的各种操作符;让我们看看其中的一些。

可观察对象的操作符

基本上,操作符只是函数。创建操作符可以用来从许多不同的来源创建可观察对象,而可连接操作符可以应用于修改流,产生一个新的可观察对象;我们将看到许多这样的家族,但为了完整的列表和描述,你应该访问 www.learnrxjs.io/learn-rxjs/operatorsrxjs.dev/guide/operators

此外,我们不会涵盖如何安装 RxJS;有关所有可能性,请参阅 rxjs.dev/guide/installation。特别是,在我们的示例中,针对浏览器,我们将从 CDN 安装 RxJS,这会创建一个全局的 rxjs 变量,类似于 jQuery 的 $ 或 Lodash 的 _ 变量。

让我们先创建可观察对象,然后继续转换它们。对于创建,以下表格解释了你可以使用的一些几个操作符:

操作符 用法
Ajax 创建一个用于 Ajax 请求的可观察对象,我们将发出返回的响应
from 从数组、可迭代对象或承诺中产生一个可观察对象
fromEvent 将事件(例如鼠标点击)转换为可观察序列
interval 以周期性间隔发出值
of 从给定的一组值生成一个序列
range 在一个范围内产生一个值的序列
timer 在初始延迟后,以周期性间隔发出值

为了提供一个基本的例子,以下三个可观察对象都将产生从 1 到 10 的值序列,我们将在本章稍后看到更多实际示例:

const obs1$ = from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const obs2$ = of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
const obs3$ = range(1, 10);

可用的可连接操作符数量太多,无法在本节中全部涵盖,所以我们只简要介绍一些家族,并描述它们的基本概念,提及一两个特定的例子。以下表格列出了最常见的家族及其最常用的操作符:

家族 描述

| 组合 | 这些操作符允许我们结合来自几个不同可观察对象的信息,包括以下内容:

  • concat() 用于将可观察对象依次放入队列中

  • merge() 用于从多个可观察对象中创建一个单一的可观察对象

  • pairWise() 用于输出前一个值和当前值作为一个数组

  • startWith() 用于在一个可观察对象中注入一个值

|

| 条件 | 这些操作符根据条件产生值,包括以下内容:

  • defaultIfEmpty() 如果可观察对象在完成之前没有发出任何内容,则发出一个值

  • every() 如果所有值都满足谓词则发出 true,否则发出 false

  • iif() 根据条件订阅两个可观察对象之一,例如三元 ? 操作符

|

| 错误处理 | 这些(显然!)适用于错误条件,包括以下内容:

  • catchError() 用于优雅地处理可观察者中的错误

  • retry()retryWhen() 用于重试可观察者序列(最可能的是与 HTTP 请求相关的)

|

| 过滤 | 可能是最重要的家族,提供了许多操作符,通过选择哪些元素将被处理或忽略,并应用不同的条件类型进行选择。其中一些更常见的包括以下内容:

  • debounce()debounceTime() 用于处理时间上过于接近的值

  • distinctUntilChanged() 仅在新值与最后一个值不同时发出

  • filter() 仅发出满足给定谓词的值

  • find() 仅发出满足条件的第一个值

  • first()last() 用于选择序列的第一个或最后一个值

  • skip()skipUntil()skipWhile() 用于丢弃值

  • take()takeLast() 用于从序列的开始或末尾选择给定数量的值

  • takeUntil()takeWhile() 用于选择值等更多操作

|

| 转换 | 另一个非常常用的家族,包括用于转换序列值的操作符。许多可能性中包括以下内容:

  • buffer()bufferTime() 用于收集值并将它们作为数组发出

  • groupBy() 根据某些属性将值分组在一起

  • map() 将给定的映射函数应用于序列中的每个元素

  • partition() 根据给定的谓词将可观察者分成两个

  • pluck() 用于从每个元素中选择一些属性

  • reduce() 将值序列缩减为单个值

  • scan()reduce() 类似,但会发出所有中间值

  • toArray() 收集所有值并将它们作为单个数组发出

|

| 工具 | 一系列具有不同功能的操作符,包括以下内容:

  • tap() 执行副作用,类似于我们在 第八章Tapping into a flow 部分中看到的,连接函数

  • delay() 用于延迟序列值一段时间

  • finalize() 在可观察者完成或产生错误时调用一个函数

  • repeat()retry() 类似,但用于正常(即非错误)情况

  • timeout() 如果在给定持续时间之前没有产生值,则产生错误

|

哇,这有很多操作符!我们已经排除了很多,你甚至可以编写自己的操作符,所以请务必查看文档。顺便说一句,理解操作符使用宝石图会更简单;我们这里不会使用它们,但请阅读 reactivex.io/documentation/observable.html 以获得基本解释,然后查看 rxmarbles.com 以了解操作符的许多交互式示例及其功能。

让我们通过几个示例来结束本节,展示这些方法在你自己的编码中的应用可能性。

检测多击

假设你出于某种原因决定,用户应该能够进行三击或四击操作,并且点击次数以某种方式具有意义并产生某种结果。浏览器在检测单击或双击并让你响应方面做得很好,但三击(或更多)点击并不容易实现。

然而,我们可以用一点 FRP 来凑合。让我们从一个真正基础的布局开始,包括一个用户应该点击的文本 span。代码如下:

<html>
  <head>
    <title>Multiple click example</title>
    <script
      type="text/javascript"
      src="img/rxjs.umd.js"
    ></script>
  </head>
  <body>
    <span id="mySpan"
      >Click this text many times (quickly)</span
    >
    <script>
      // our code goes here...
    </script>
  </body>
</html>

这是最简单的;你只是在屏幕上得到一些文本,敦促你进行多击。参见 图 11**.3

图 11.3 – 用于检测三击的非常简单的屏幕

图 11.3 – 用于检测三击的非常简单的屏幕

为了检测这些多击,我们需要一些 RxJS 函数,所以让我们从这些函数开始:

const { fromEvent, pipe } = rxjs;
const { buffer, filter } = rxjs.operators;

我们很快就会使用这些函数。我们如何检测三击(或更多)点击?让我们直接来看这里给出的代码:

const spanClick$ = fromEvent(
  document.getElementById("mySpan"),
  "click"
);
spanClick$
  .pipe(
    buffer(spanClick$.pipe(debounceTime(250))),
    map((list) => list.length),
    filter((x) => x >= 3)
  )
  .subscribe((e) => {
    console.log(`${e} clicks at ${new Date()}`);
  });
/*
5 clicks at Fri Feb 03 2023 18:08:42 GMT-0300
3 clicks at Fri Feb 03 2023 18:08:45 GMT-0300
6 clicks at Fri Feb 03 2023 18:08:47 GMT-0300
4 clicks at Fri Feb 03 2023 18:08:51 GMT-0300
*/

逻辑很简单:

  1. 我们使用 fromEvent() 创建一个可观察对象来监听我们对 span 的鼠标点击。

  2. 现在,一个棘手的问题——我们使用 buffer() 将来自应用 debounceTime() 的点击序列中的许多事件连接起来,所以所有在 250 毫秒间隔内发生的点击都将被组合成一个数组。

  3. 然后,我们应用 map() 将每个点击数组转换为它的长度——毕竟,我们关心的是点击次数,而不是它们的详细信息。

  4. 我们通过过滤掉小于 3 的值来完成,这样只有较长的点击序列会被处理。

  5. 订阅只是记录点击,但在你的应用程序中,它应该做更多相关的事情。

如果你愿意,你可以手动检测多击,编写自己的代码;参见 问题 11.3问题 部分。让我们用一个更长的例子来完成,并进行一些类型搜索,调用一些外部 API。

提供类型搜索

让我们再做一个网络示例:类型搜索。通常的设置是有一个文本框,用户在其中输入,网页查询 API 以提供完成搜索的方式。重要的是何时以及如何进行搜索,并尽可能避免不必要地调用后端服务器。一个(完全基础的)HTML 页面可能如下所示(参见本节后面的 图 11**.4):

<html>
  <head>
    <title>Cities search</title>
    <script
      type="text/javascript"
      src="img/rxjs.umd.js"
    ></script>
  </head>
  <body>
    Find cities:
    <input type="text" id="myText" />
    <br />
    <h4>Some cities...</h4>
    <div id="myResults"></div>
    <script>
      // typeahead code goes here...
    </script>
  </body>
</html>

现在我们有一个单行文本框,用户将在其中输入,下面是我们将显示 API 提供的内容的区域。我们将使用 GeoDB Cities API(见geodb-cities-api.wirefreethought.com),它提供了许多搜索选项,来搜索以用户输入内容开头的城市。为了让它不干扰我们,让我们看看getCitiesOrNull()函数,它将返回搜索结果的承诺(如果输入了某些内容)或null(如果没有输入任何内容,则没有城市)。这个承诺的结果将用于填充页面上的myResults分区。让我们看看代码是如何实现的:

const URL = `http://` +
  `geodb-free-service.wirefreethought.com/v1/geo/cities`;
const getCitiesOrNull = (text) => {
  if (text) {
    const citySearchUrl =
      `${URL}?` +
      `hateoasMode=false&` +
      `sort=-population&` +
      `namePrefix=${encodeURIComponent(text)}`;
    return;
    fetch(citySearchUrl);
  } else {
    return Promise.resolve(null);
  }
};

代码很简单——如果提供了文本,我们生成城市搜索的 URL 并使用fetch()获取 API 数据。完成这个操作后,让我们看看如何生成所需的可观察对象。我们需要一些 RxJS 函数,所以首先,让我们有一些定义:

const { fromEvent, pipe } = rxjs;
const {
  debounceTime,
  distinctUntilChanged,
  filter,
  map,
  reduce,
  switchMap,
} = rxjs.operators;

我们稍后会使用所有这些函数。现在,我们可以编写代码来实现自动完成:

const textInput$ = fromEvent(
  document.getElementById("myText"),
  "input"
).pipe(
  map((e) => e.target.value),
  debounceTime(200),
  filter((w) => w.length === 0 || w.length > 3),
  distinctUntilChanged(),
  switchMap((w) => getCitiesOrNull(w))
);

这需要一步一步来:

  1. 我们使用fromEvent()构造函数来观察myText输入字段上的输入事件(每次用户输入时)。

  2. 我们使用map()来获取事件的目标值,即输入字段的完整文本。

  3. 我们使用debounceTime(200),这样可观察对象就不会在用户停止输入 0.2 秒(200 毫秒)内发出——如果用户没有完成他们的查询,调用 API 有什么用?

  4. 我们接着使用filter()来丢弃只有一、二或三个字符长的输入,因为这对我们的搜索来说不够长。我们接受空字符串(因此我们会清空结果区域)和四个或更多字符长的字符串。

  5. 然后,我们使用distinctUntilChanged(),所以如果搜索字符串与之前相同(用户可能添加了一个字符但很快退格删除了它),则不会发出任何内容。

  6. 最后,我们将switchMap()更改为取消对可观察对象的先前订阅,并使用getCitiesOrNull()创建一个新的订阅。

我们如何使用它?我们订阅可观察对象,并在我们得到结果时使用它们来显示值。以下是一个可能的示例代码:

textInput$.subscribe(async (fetchResult) => {
  domElem = document.getElementById("myResults");
  if (fetchResult !== null) {
    result = await fetchResult.json();
    domElem.innerHTML = result.data
      .map((x) => `${x.city}, ${x.region}, ${x.country}`)
      .join("<br />");
  } else {
    domElem.innerHTML = "";
  }
});

一个重要的点——承诺被解决,因此序列的最终值就是承诺产生的值。如果结果不是null,我们得到一个城市数组,然后我们使用map()join()来生成(非常基础的!)HTML 输出;否则,我们清空结果区域。

让我们试一试。如果你开始输入,直到你输入至少四个字符并稍作停顿(见图 11**.4,如下所示):

图 11.4 – 我们的城市搜索不会在少于四个字符时触发

图 11.4——我们的城市搜索不会在少于四个字符时触发

当你达到四个字符并暂停一下时,可观察对象将发出一个事件,我们将进行第一次搜索——在这种情况下,搜索以MONT开头的城市(见图 11.5):

图 11.5 – 达到四个字符后,将触发搜索

图 11.5 – 达到四个字符后,将触发搜索

最后,随着你添加更多字符,将进行新的 API 调用,细化搜索(见图 11.6)。

图 11.6 – 使用更多字符来细化搜索

图 11.6 – 使用更多字符来细化搜索

我们能从这些例子中学到什么?使用可观察对象进行事件处理,使我们能够实现关于事件生产和事件消费的良好关注点分离,流处理声明式风格使数据流更清晰。请注意,即使是 HTML 代码也没有引用点击方法或类似的东西;完整的代码是分开的。

我们现在已经看到了大多数有趣的模式;让我们以一些其他模式结束,这些模式可能与它们的经典 OOP 伙伴完全相同或不完全相同。

其他模式

让我们通过简要查看一些可能或可能不如此等效的模式来结束本节。

  • 柯里化和部分应用(我们在第七章中看到,转换函数):这可以被视为与函数的工厂大约等效。给定一个通用函数,你可以通过固定一个或多个参数来产生特殊案例,这本质上就是工厂所做的——谈论函数而不是对象。

  • map()reduce():这些可以被视为迭代器模式的应用。容器元素的遍历与容器本身解耦。你也可以为不同的对象提供不同的map()方法来遍历各种数据结构。

  • 持久数据结构:如第十章中提到的确保纯净性,这些允许实现备忘录模式。核心思想是,给定一个对象,能够回到之前的状态。正如我们所看到的,数据结构的每个更新版本都不会影响之前的版本,因此你可以轻松地添加一个机制来提供早期状态并回滚到它。

  • 一个find()来确定哪个处理器将处理请求(所需的处理器是列表中第一个接受请求的处理器),然后简单地执行所需的过程。

记住开头的警告——使用这些模式时,与 FP 技术的匹配可能不如我们之前看到的那些完美。然而,目的是展示一些常见的 FP 模式可以应用,并且会产生与 OOP 解决方案相同的结果,尽管实现方式不同。

现在,在看过几个 OOP 等效模式之后,让我们转向更具体的 FP 模式。

函数式设计模式

看过几个 OOP 设计模式后,可能会觉得说没有经过批准、官方或甚至广泛接受的类似模式列表似乎是一种欺骗。然而,确实存在一些标准 FP 解决方案,这些问题可以被视为设计模式,而且我们在这本书中已经涵盖了其中大部分。

可能的模式列表有哪些候选人?让我们尝试准备一个——但请记住,这只是个人观点。此外,我要承认,我并不是试图模仿通常的模式定义风格;我只会提到一个一般性问题,并参考 JavaScript 中的 FP 如何解决它,而且我不会试图为模式找到好、简短且易于记忆的名称:

  • filter()map()reduce(),正如我们在本章以及之前在 第五章 声明式编程 中所看到的,是一种从问题中去除复杂性的方法。(通常的 MapReduce 网络框架是这一概念的扩展,它允许在多个服务器之间进行分布式处理,即使实现和细节并不完全相同。)与其将循环和处理作为一个步骤执行,你应该将问题视为一系列按顺序应用的步骤,直到获得最终、所需的结果。

以其他方式循环

JavaScript 还包括 迭代器,这是另一种遍历集合的方法。使用迭代器并不特别符合函数式编程,但你可能想了解一下它们,因为它们可能能够简化某些情况。更多信息请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols

  • 使用 thunks 进行惰性评估:惰性评估的想法是在实际需要之前不进行任何计算。在某些编程语言中,这是内置的。然而,在 JavaScript(以及大多数命令式语言)中,采用的是 贪婪评估,即表达式一旦绑定到某个变量就会立即被评估。(另一种说法是,JavaScript 是一种 严格的编程语言,具有 严格的范式,只允许在所有参数都完全评估后调用函数。)这种评估在需要精确指定评估顺序时是必要的,主要是因为这种评估可能产生副作用。

在更声明式和纯的 FP 中,你可以通过传递一个 thunks(我们在 第九章 设计函数 中的 Trampolines 和 thunks 部分使用过)来延迟这种评估,这个 thunks 只在需要时计算所需值,而不会提前计算。

生成更多结果

您还可以查看 JavaScript 生成器,这是延迟评估的另一种方式,尽管它与 FP 没有特别的关系。更多关于它们的信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator。生成器和承诺的组合称为async函数,这可能对您感兴趣;请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

  • 持久性数据结构用于不可变性:正如我们在第十章中看到的,确保纯净性,在处理某些框架时,拥有不可变的数据结构是强制性的,并且通常也是推荐的,因为它有助于对程序进行推理或调试。(在本章的早期,我们也提到了如何以这种方式实现备忘录面向对象(OOP)模式。)每当您需要表示结构化数据时,使用持久数据结构的 FP 解决方案在许多方面都有帮助。

  • 在尝试访问相应对象之前,请检查null。这种模式旨在将一个值封装在对象或函数中,以便无法直接操作,并且可以更功能性地管理检查。我们将在第十二章中进一步讨论这一点,构建更好的容器

正如我们所说的,函数式编程(FP)的力量在于,它没有几十种标准的设计模式(这只是在 GoF 书中;如果你阅读其他文本,列表会变得更长!),还没有一个标准或公认的函数式模式列表。

摘要

在本章中,我们通过展示如何以比使用类和对象更简单的方式解决相同的基本问题,将面向对象(OO)的思维方式和我们通常在编码时使用的模式与 FP 风格连接起来。我们看到了几个常见的设计模式,并且我们已经看到,即使在实现可能有所不同的情况下,相同的概念也适用于 FP,因此现在您有了一种将那些众所周知的解决方案结构应用于 JavaScript 编码的方法。

第十二章中,构建更好的容器,我们将处理一系列 FP 概念,为您提供更多关于可以使用工具的想法。我承诺这本书不会过于理论化,而是更实用,我们将努力保持这种风格,即使一些展示的概念可能看起来有些抽象或遥远。

问题

11.1 装饰方法,未来的方式:在第六章生成函数中,我们编写了一个装饰器来为任何函数启用日志记录。目前,方法装饰器正在考虑纳入 JavaScript 的未来版本:有关更多信息,请参阅 tc39.github.io/proposal-decorators。(第二阶段草案意味着该功能很可能被纳入标准,尽管可能会有一些添加或小的变化。TypeScript 现在提供装饰器,但警告说 “装饰器是一个可能在未来版本中更改的实验性功能”;更多信息请参阅 www.typescriptlang.org/docs/handbook/decorators.html。)研究以下代码,看看是什么让接下来的代码运行起来:

const logging = (target, name, descriptor) => {
  const savedMethod = descriptor.value;
  descriptor.value = function (...args) {
    console.log(`entering ${name}: ${args}`);
    try {
      const valueToReturn = savedMethod.bind(this)(...args);
      console.log(`exiting ${name}: ${valueToReturn}`);
      return valueToReturn;
    } catch (thrownError) {
      console.log(`exiting ${name}: threw ${thrownError}`);
      throw thrownError;
    }
  };
  return descriptor;
};

一个工作示例如下:

class SumThree {
  constructor(z) {
    this.z = z;
  }
  @logging
  sum(x, y) {
    return x + y + this.z;
  }
}
new SumThree(100).sum(20, 8);
// entering sum: 20,8
// exiting sum: 128

以下是一些关于 logging() 代码的问题:

  • 为什么需要 savedMethod 变量?

  • 为什么在分配新的 descriptor.value 时使用 function() 而不是箭头函数?

  • 为什么使用 .bind()

  • 什么是 descriptor

11.2 addBar() 函数,它将为 Foo 类添加一些混合,使得代码能够按所示运行。创建的 fooBar 对象应该有两个属性(fooValuebarValue)和两个方法(doSomething()doSomethingElse()),这些方法简单地显示一些文本和属性,如下所示:

class Foo {
  constructor(fooValue) {
    this.fooValue = fooValue;
  }
  doSomething() {
    console.log("something: foo... ", this.fooValue);
  }
}
const addBar = (BaseClass) => {
  /*
  your code goes here
  */
};
const fooBar = new (addBar(Foo))(22, 9);
fooBar.doSomething();
// something: foo... 22
fooBar.somethingElse();
// something else: bar... 9
console.log(Object.keys(fooBar));
// ["fooValue", "barValue"]

你能包括一个第三个混合函数 addBazAndQux(),使得 addBazAndQux(addBar(Foo)) 能够为 Foo 添加更多的属性和方法吗?

11.3 手动多击:你能编写自己的多击检测代码,使其工作方式与我们的示例完全相同吗?

11.4 首先使用 false 值,然后使用 true 值?

11.5 客观地寻找路线:以面向对象的方式工作,路线寻找问题可能会以另一种方式解决,涉及类和子类。如何?(提示:这个问题的答案是我们在本章中提到的一个模式。)

第十二章:构建更好的容器 - 函数式数据类型

第十一章 实现设计模式 中,我们讨论了如何使用函数来实现不同的结果。在本章中,我们将从函数式角度探讨数据类型。我们将考虑如何实现我们自己的数据类型,以及一些可以帮助我们组合操作或确保其纯度的特性,从而使我们的函数式编程变得更加简单和简洁。

我们将涉及几个主题:

  • 从函数式角度数据类型。尽管 JavaScript 不是一种类型语言,但为了补充我们对 TypeScript 的使用,我们需要更好地理解类型和函数。

  • 容器,包括函子和神秘的单子,以结构化数据流。

  • 函数作为结构,我们将看到另一种使用函数来表示数据类型的方法,并加入不可变性作为额外的特性。

就这样,让我们开始吧!

指定数据类型

尽管 JavaScript 是一种动态语言,但没有静态或显式的类型声明和控制,并不意味着你可以简单地忽略类型。即使语言不允许你指定变量或函数的类型,你仍然在用类型工作——即使只是在你的脑海中。指定类型有如下优势:

  • TypeScript 可以检测编译时错误,避免许多 bug。

  • 如果你从 JavaScript 迁移到更函数式的语言,如 Elm(见elm-lang.org),这将有所帮助。

  • 它作为文档,让未来的开发者了解他们必须传递给函数的参数类型以及函数将返回的类型。Ramda 库中的所有函数都是以这种方式进行文档化的。

  • 这也将帮助我们理解本节后面将要介绍的功能性数据结构,我们将探讨一种处理结构的方法,类似于你在像 Haskell 这样的完全函数式语言中所做的那样。

为什么在整本书都使用 TypeScript 之后,我们又要讨论类型呢?原因在于,在大多数函数式编程文本中,使用了一种不同的风格。TypeScript 定义只是,嗯,TypeScript,但我们将在这里看到的定义可以应用于任何其他语言。让我们暂时忘记 TypeScript,开始思考一个新的类型系统。我们将从函数开始,这是最相关的类型,然后考虑其他定义。

函数的签名

函数的参数和结果的指定由签名给出。类型签名基于称为 Hindley–MilnerHM)的类型系统,该系统影响了几个(主要是函数式)语言,包括 Haskell,尽管符号与原始论文中的不同。该系统甚至可以推断出没有直接给出的类型,就像 TypeScript 或 Flow 一样。而不是提供关于编写正确签名的规则的枯燥、正式的解释,让我们通过例子来工作。我们只需要知道以下内容:

  • 我们将把类型声明写成注释

  • 函数名首先写出来,然后是 ::,这可以读作 is of typehas type

  • 可选约束可能随后,后面跟着一个双箭头(粗箭头)(或在基本 ASCII 格式下,如果无法输入箭头,则为 =>

  • 函数的输入类型随后,后面跟着 (或 ->,取决于你的键盘)

  • 函数的结果类型位于最后

小心箭头!

提前警告:查看我们将使用的箭头样式;它们与 TypeScript 使用的不同!我们将使用“细”箭头代替 =>,使用“粗”箭头来指定泛型约束;请小心!

现在,我们可以从一些例子开始。让我们定义一个将单词转换为大写的简单函数的类型,并为 Math.random 函数做同样的处理:

// firstToUpper :: String → String
const firstToUpper = (s: string): string =>
  s[0].toUpperCase() + s.substring(1).toLowerCase();
// Math.random :: () → Number

这些是简单的情况——这里只考虑签名;我们对实际的函数不感兴趣。箭头表示函数。第一个函数接收一个字符串作为参数并返回一个新的字符串。第二个函数不接收任何参数(如空括号所示)并返回一个浮点数。因此,我们可以将第一个签名读作 firstToUpper() 是一个接收字符串并返回字符串的函数类型。我们可以类似地谈论被诟病的(从不纯性角度看)Math.random() 函数,唯一的区别是它不接收参数。

将新的类型定义与 TypeScript 进行比较,很明显它们非常相似。然而,新的风格更清晰。你也可以用以下方式定义 firstToUpper(),不指定结果类型(因为 TypeScript 可以推断出来),但使用 HM 类型,你必须提供所有细节,提供更多的清晰度:

// firstToUpper :: String → String
const firstToUpper = (s: string) =>
  s[0].toUpperCase() + s.substring(1).toLowerCase();

另一个细节是,在这种指定类型的新方式中,类型描述独立存在,没有与编程语言的细节混合——你不需要理解 JavaScript、TypeScript 或任何其他语言就能弄清楚函数中涉及的类型。

我们已经研究了具有零个或一个参数的函数,但对于具有多个参数的函数呢?有两个答案。如果我们在一个严格的函数式风格中工作,我们总是会进行柯里化(如我们在 第七章转换函数)中看到的),所以所有的函数都会是一元函数。另一个解决方案是将参数类型列表括起来。我们可以在以下代码中看到这两个解决方案:

// sum3C :: Number → Number → Number → Number
const sum3C = curry(
  (a: number, b: number, c: number): number => a + b + c
);
// sum3 :: (Number, Number, Number) → Number
const sum3 = (a: number, b: number, c: number) => a + b +
  c;

记住,sum3c() 实际上是 (a) => (b) => (c) => a + b + c;这解释了第一个签名,它也可以读作以下内容:

// sum3C :: Number → (Number → (Number → (Number)))
const sum3C = curry(
  (a: number, b: number, c: number): number => a + b + c
);

在你向函数提供第一个参数之后,你将剩下一个新的函数,它也期望一个参数,并返回一个第三个函数,当给定一个参数时,将产生最终结果。我们不会使用括号,因为我们始终假设从右到左的这种分组。

现在,关于接受函数作为参数的高阶函数,我们该怎么办呢?map() 函数提出了一个问题:它可以与任何类型的数组一起工作。此外,映射函数可以产生任何类型的输出。对于这些情况,我们可以指定泛型类型,由小写字母标识。这些泛型类型可以代表任何可能的类型。对于数组本身,我们使用括号。因此,我们将有如下所示的内容:

// map :: [a] → (a → b) → [b]
const map = curry(<A, B>(arr: A[], fn: (x: A) => B) =>
  arr.map(fn)
);

ab 代表相同的类型是完全有效的,例如,在应用于数字数组的映射中,它会产生另一个数字数组。关键是,原则上,ab 可能代表不同的类型,这是我们之前描述的。这个定义要求在 TypeScript 中使用泛型类型,在我们的例子中是 AB

注意,如果我们没有进行柯里化,签名将是 ([a], (a → b)) → [b],这显示了一个接收两个参数(类型为 a 的元素数组和一个从类型 a 映射到类型 b 的函数)并产生类型为 b 的元素数组作为其结果的函数。

我们可以类似地写出以下内容:

// filter :: [a] → (a → Boolean) → [a]
const filter = curry(<A>(arr: A[], fn: (x: A) => B) =>
  arr.filter(fn)
);

现在是时候考虑 reduce() 的签名了。务必仔细阅读它,看看你是否能弄清楚为什么它被写成这样。你可能更喜欢将签名的第二部分想象成 ((b, a) → b)

// reduce :: [a] → (b → a → b) → b → b
const reduce = curry(
  <A, B>(arr: A[], fn: (a: B, v: A) => B, acc: B) =>
    arr.reduce(fn, acc)
);

最后,如果你正在定义一个方法而不是函数,你使用波浪线箭头,例如 ~>

// String.repeat :: String ⇝ Number → String

到目前为止,我们已经为函数定义了数据类型,但我们对这个主题还没有完成。让我们考虑一些其他的情况。

其他数据类型选项

我们还缺少什么?让我们看看你可能使用的其他选项。产品类型 是一组总是在一起出现的值,通常与对象一起使用。对于 元组(即具有固定数量元素(可能是不同类型)的数组),我们可以写出如下内容:

// getWeekAndDay :: String → (Number × String)
const getWeekAndDay = (
  yyyy_mm_dd: string
): [number, string] => {
  let weekNumber: number;
  let dayOfWeekName: string;
  .
  .
  .
  return [weekNumber, dayOfWeekName];
};

对于对象,我们可以采用与 JavaScript 已经使用的定义非常相似的定义。让我们想象我们有一个getPerson()函数,它接收一个 ID 并返回一个包含有关人员数据的对象:

// getPerson :: Number → { id:Number × name:String }
const getPerson = (
  personId: number
): { id: number; name: string } => {
  .
  .
  .
  return { id: personId, name: personName };
};

求和类型(也称为联合类型)被定义为可能值的列表。例如,我们的getField()函数来自第六章生成函数,返回一个属性的值或undefined。为此,我们可以写出以下签名:

// getField :: String → Object → a | undefined
const getField =
  <A>(attr: string) =>
  (obj: { [key: string]: A }) =>
    obj[attr];

我们也可以定义一个类型(联合或否则)并在进一步的定义中使用它。例如,可以直接比较和排序的数据类型有数字、字符串和布尔值,因此我们可以写出以下定义:

// Sortable :: Number | String | Boolean

之后,我们可以指定比较函数可以用Sortable类型来定义,但要注意:这里有一个隐藏的问题!

// compareFunction :: (Sortable, Sortable) → Number

之前的定义将允许我们编写一个接收,比如说,一个number和一个Boolean的函数。它并没有说这两种类型应该是相同的。然而,有一个解决办法。如果你对某些数据类型有约束,你可以在实际的签名之前表达它们,使用一个粗箭头,如下面的代码所示:

// compareFunction :: Sortable a ⇒ (a, a) → Number

现在的定义是正确的,因为所有相同类型(用相同的字母表示,在这种情况下是a)的出现必须完全相同。一个替代方案,但需要输入更多文字,就是用联合来写出所有三种可能性:

// compareFunction ::
//    ((Number, Number) |
//    (String, String)  |
//    (Boolean, Boolean)) → Number

实际上,这个定义并不非常精确,因为你可以比较任何类型,即使它没有太多意义。然而,为了这个例子,请耐心一点!如果你想刷新你对排序和比较函数的记忆,请参阅developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Array/sort

到目前为止,我们一直在使用标准的类型定义。然而,当使用 JavaScript 时,我们必须考虑其他可能性,例如具有可选参数的函数,甚至具有不确定数量的参数的函数。我们可以使用...来表示任意数量的参数,并添加?来表示可选类型,如下所示:

// unary :: ((b, ...) → a) → (b → a)

我们在之前引用的同一章中定义的unary()高阶函数接受任何函数作为参数,并返回一个一元函数作为其结果。我们可以表明原始函数可以接收任意数量的参数,但结果只使用了第一个。这个数据类型定义如下:

// parseInt :: (String, Number?) → Number

标准的parseInt()函数提供了一个可选参数的例子,尽管强烈建议不要省略第二个参数(基数);实际上,你可以跳过它。

优秀的定义?

查看 github.com/fantasyland/fantasy-land/和 sanctuary.js.org/#types 以获取关于类型在 JavaScript 中应用的更正式定义和描述。

从现在开始,在本章的整个过程中,我们不仅将使用 TypeScript,我们还将向方法和函数添加 HM 签名,这样你就可以习惯它们。现在让我们改变方向,覆盖一个高度重要的话题:容器

构建容器

第五章“声明式编程”和随后的第八章“函数连接”中,我们看到了将映射应用于数组所有元素的能力——甚至更好的是,能够链式执行一系列类似操作——这是生成更好、更易于理解的代码的绝佳方式。

然而,有一个问题:map()方法(或其等效的未方法化版本,我们在第六章“生成函数”中讨论过)仅适用于数组,我们可能希望能够将映射和链式操作应用于其他数据类型。那么,我们能做什么呢?

让我们考虑不同的实现方式,这将为我们提供几个新的工具,以更好地进行函数式编程。基本上,有两种可能的解决方案:我们可以向现有类型添加新方法(尽管这将有限,因为我们只能将其应用于基本 JavaScript 类型)或者将类型包装在某种容器中,这将允许映射和链式操作。

首先,在转向使用包装器之前,让我们先扩展当前的数据类型,这将引导我们进入深度的函数式领域,涉及诸如函子(functors)和单子(monads)等实体。

扩展当前数据类型

如果我们要将映射添加到基本 JavaScript 数据类型,我们需要首先考虑我们的选项:

  • 对于nullundefinedSymbol,应用映射听起来并不那么有趣。

  • 我们在BooleanNumberString数据类型上有一些有趣的可能性,因此我们可以检查其中的一些。

  • 将映射应用于一个对象是微不足道的:我们只需添加一个map()方法,该方法必须返回一个新的对象。

  • 最后,尽管它们不是基本数据类型,但我们也可以考虑特殊案例,例如日期或函数,我们可以为它们添加map()方法。

正如本书的其余部分一样,我们坚持使用纯 JavaScript 和 TypeScript,但你应该研究 Lodash、Underscore 或 Ramda 等库,这些库已经提供了我们在这里开发的功能。

在所有这些映射操作中,一个需要考虑的关键点是返回的值应该与原始类型相同。当我们使用Array.map()时,结果也是一个数组,类似的考虑必须适用于任何其他map()方法实现(你可以观察到结果数组可能具有与原始数组不同的元素类型,但它仍然是一个数组)。

我们可以用布尔值做什么呢?首先,让我们接受布尔值不是容器,因此它们并不真正以与数组相同的方式表现。显然,布尔值只能有布尔值,而数组可以包含任何类型的元素。然而,接受这种差异,我们可以通过向其中添加一个新的map()方法来扩展Boolean.prototype(尽管,如我之前提到的,这通常不推荐),并确保映射函数返回的任何内容都被转换成一个新的布尔值。对于后者,解决方案将与以下类似:

// Boolean.map :: Boolean ⇝ (Boolean → a) → Boolean
Boolean.prototype.map = function (
  this: boolean,
  fn: (x: boolean) => any
) {
  return !!fn(this);
};

我们已经看到了如何向一个方法添加一个(假的)this参数的例子,以让 TypeScript 知道this的类型是什么——在这种情况下,是一个布尔值。!!运算符强制结果成为一个布尔值。Boolean(fn(this))也可以使用。这种解决方案也可以应用于数字和字符串,如下面的代码所示:

// Number.map :: Number ⇝ (Number → a) → Number
Number.prototype.map = function (
  this: number,
  fn: (x: number) => number
) {
  return Number(fn(this));
};
// String.map :: String ⇝ (String → a) → String
String.prototype.map = function (
  this: string,
  fn: (x: string) => string
) {
  return String(fn(this));
};

与布尔值一样,我们正在强制映射操作的结果转换为正确的数据类型。顺便说一句,TypeScript 不会直接接受这些新的map()定义;参见问题 12.1以修复此问题。

最后,如果我们想要将映射应用于一个函数,那意味着什么呢?映射一个函数应该产生一个函数。对于f.map(g)的逻辑解释将是先应用f(),然后对结果应用g()。因此,f.map(g)应该等同于写作x => g(f(x))或者,等价地,pipe(f,g)。这个定义比之前的例子更复杂(但在我看来,在 HM 中比在 TypeScript 中更简单),所以请仔细研究:

// Function.map :: (a → b) ⇝ (b → c) → (a → c)
Function.prototype.map = function <A, B, C>(
  this: (x: A) => B,
  fn: (y: B) => C
): (x: A) => C {
  return (x: A) => fn(this(x));
};

验证这一点很简单,以下代码是展示如何做到这一点的简单示例。times10()映射函数被应用于计算plus1(3)的结果,因此结果是 40:

const plus1 = (x) => x + 1;
const times10 = (y) => 10 * y;
console.log(plus1.map(by10)(3));
// 40: first add 1 to 3, then multiply by 10

通过这种方式,我们就完成了关于我们可以用基本 JavaScript 类型实现什么内容的讨论,但如果我们想将其应用于其他数据类型,我们需要一个更通用的解决方案。我们希望能够将映射应用于任何类型的值,为此,我们需要创建一个容器。我们将在下一节中这样做。

容器和函子

在上一节中我们所做的工作是有效的,并且可以无问题地使用。然而,我们希望考虑一个更通用的解决方案,我们可以将其应用于任何数据类型。由于并非 JavaScript 中的所有事物都提供所需的map()方法,我们可能必须扩展类型(就像我们在上一节中所做的那样)或应用我们在第十一章中考虑的设计模式:用包装器包装我们的数据类型,该包装器将提供所需的map()操作。

特别是,我们将执行以下操作:

  • 首先看看如何构建一个基本的容器,包装一个值

  • 将容器转换成更强大的函子

  • 学习如何使用特殊的函子Maybe处理缺失值

包装一个值——一个基本容器

让我们暂停一下,考虑一下我们需要从这个包装器中得到什么。有两个基本要求:

  • 我们必须有一个map()方法

  • 我们需要一个简单的方式来封装一个值

要开始,让我们创建一个基本的容器。任何只包含一个值的对象都可以,但我们希望有一些额外的功能,所以我们的对象不会那么简单;我们将在代码之后解释这些差异:

// container.ts
class Container<A> {
  protected x: A;
  constructor(x: A) {
    this.x = x;
  }
  map(fn: (_: A) => any) {
    return fn(this.x);
  }
}

我们需要记住的一些基本考虑因素如下:

  • 我们希望能够在容器中存储一些值,因此构造函数负责这一点。

  • 使用protected属性可以避免外部“篡改”,但允许子类访问。(参见问题 12.2中的一些 JavaScript 考虑因素。)

  • 我们需要能够使用map(),因此提供了一个方法来实现这一点。

我们的基本容器已经准备好了,但我们也可以添加一些其他方法以方便使用,如下所示:

  • 要获取容器的值,我们可以使用map((x) => x),但对于更复杂的容器来说,这不会起作用,因此我们将添加一个valueOf()方法来获取包含的值。

  • 能够列出容器无疑有助于调试。toString()方法将非常有用。

  • 由于我们不需要每次都写new Container(),我们可以添加一个静态的of()方法来完成同样的工作。

函数式编程的一个禁忌?

在函数式编程的世界中,使用类来表示容器(以及后来的函子和单子)可能看起来像是异端或罪恶...但记住,我们不想过于教条,使用类可以简化我们的编码。同样,可以争论说,你永远不应该从容器中取出值——但使用valueOf()方法有时非常方便,所以我们不会那么限制。

考虑到所有这些,我们的容器如下:

// continued...
class Container<A> {
  protected x: A;
  constructor(x: A) {
    this.x = x;
  }
  static of<B>(x: B): Container<B> {
    return new Container(x);
  }
  map(fn: (_: A) => any) {
    return fn(this.x);
  }
  toString() {
    return `${this.constructor.name}(${this.x})`;
  }
  valueOf() {
    return this.x;
  }
}

现在,我们可以使用这个容器来存储一个值,并使用map()来对那个值应用任何函数,但这与我们使用变量所能做的并没有太大区别!让我们再增强一下这一点。

增强我们的容器 – 函子

我们希望拥有封装的值,那么map()函数究竟应该返回什么?如果我们想要能够链式调用操作,唯一合理的答案就是它应该返回一个新的封装对象。在真正的函数式风格中,当我们对一个封装值应用映射时,结果将是一个新的封装值,我们可以继续对其操作。

任何名称的映射

map()不同,这个操作有时被称为fmap(),代表函子映射。名称变更的理由是为了避免扩展map()的含义。然而,由于我们正在使用一个支持重用名称的语言,我们可以保留它。

我们可以将我们的Container类扩展以实现这一变化,并得到一个增强的容器:一个函子of()map()方法将需要一些小的改动。为此,我们将创建一个新的类,如下面的代码所示:

// functor.ts
class Functor<A> extends Container<A> {
  static of<B>(x: B) {
    return new Functor(x);
  }
  map<B>(fn: (_: A) => B): Functor<B> {
    return Functor.of(fn(this.x));
  }
}

在这里,of() 方法产生一个 Functor 对象,map() 方法也是如此。通过这些变化,我们刚刚定义了在范畴论中什么是 函子!(或者,如果你想真正技术化,由于 of() 方法,它是一个 带点函子——但让我们保持简单。)我们不会深入理论细节,但大致来说,函子是一些容器,允许我们对其内容应用 map(),产生相同类型的新容器。如果你听起来很熟悉,那是因为你已经知道函子了:数组!当你对数组应用 map() 时,结果是包含转换(映射)值的新数组。

额外要求

函子有更多的要求。首先,包含的值可能是多态的(任何类型),就像数组一样。其次,必须存在一个函数,其映射会产生相同的包含值——(x) => x 就为我们做了这件事。最后,应用两个连续的映射必须产生与应用它们的组合相同的结果。这意味着 container.map(f).map(g) 必须与 container.map(compose(g,f)) 相同。

让我们暂停一下,考虑我们的函数和方法签名:

// of :: Functor f ⇒ a → f a
// Functor.toString :: Functor f ⇒ f a ⇝ String
// Functor.valueOf :: Functor f ⇒ f a ⇝ a
// Functor.map :: Functor f ⇒ f a ⇝ (a → b) → f b

第一个函数 of() 是最简单的:给定任何类型的值,它会产生该类型的函子。接下来的两个也比较容易理解:给定一个函子,toString() 总是返回一个字符串(这不足为奇!),如果函子包含的值是给定类型,valueOf() 会产生相同类型的值。第三个 map() 更有趣。给定一个接受类型 a 的参数并产生类型 b 的结果的函数,将其应用于包含类型 a 的值的函子会产生包含类型 b 的值的函子。这正是我们之前描述的。

Promises 和 Functors

你可以将函子与承诺进行比较,至少在一个方面。在函子中,你无法直接对其值进行操作,而是必须使用 map() 应用一个函数。在承诺中,你完全一样,但使用 then()!实际上,还有更多的类比,我们很快就会看到。

就目前而言,函子不允许或预期产生副作用、抛出异常或展示任何其他在产生容器结果之外的行为。它们的主要用途是提供一种方式来操作值、对其应用操作、组合结果等,而不改变原始值——从这个意义上说,我们再次回到了不可变性。

然而,你可以说这还不够,因为在日常编程中,处理异常、未定义或空值等情况是非常常见的。所以,让我们先看看更多关于函子的例子。之后,我们将进入单子的领域,看看更复杂的处理。让我们实验一下!

使用 Maybe 处理缺失值

编程中常见的一个问题是处理缺失值。这种情况可能有多种原因:一个 Web 服务的 Ajax 调用可能返回了一个空结果,一个数据集可能是空的,一个可选属性可能从对象中缺失,等等。在常规的命令式风格中,处理这种情况需要在每个地方添加if语句或三元运算符来捕获可能缺失的值以避免某些运行时错误。我们可以通过实现一个Maybe函子来表示可能(或可能)存在的值来做得更好!我们将使用两个类,Just(表示仅仅某个值)和Nothing,它们都是函子。Nothing函子尤其简单,具有平凡的方法:

// maybe.ts
class Nothing extends Maybe<any> {
  constructor() {
    super(null);
  }
  isNothing() {
    return true;
  }
  toString() {
    return "Nothing()";
  }
  map(_fn: FN) {
    return this;
  }
}

isNothing()方法返回truetoString()返回恒定的文本,而map()无论给定什么函数都总是返回自身。

接下来,Just函子也是一个基本的函子,增加了isNothing()方法(它总是返回false,因为Just对象不是Nothing),以及一个现在返回Maybemap()方法:

// continued...
class Just<A> extends Maybe<A> {
  static of<B>(x: B): Maybe<B> {
    if (x === null || x === undefined) {
      throw new Error("Just should have a value");
    } else {
      return new Just(x);
    }
  }
  isNothing() {
    return false;
  }
  map<B>(fn: (_: A) => B): Just<B> {
    return new Just(fn(this.x));
  }
}

最后,我们的Maybe类包含了构建NothingJust所需的所有逻辑。如果它接收到一个undefinednull值,将构建Nothing;在其他情况下,结果是Justof()方法具有完全相同的行为:

// continued...
abstract class Maybe<A> extends Functor<A> {
  static of<B>(x: B): Maybe<B> {
    return x === null || x === undefined
      ? new Nothing()
      : new Just(x);
  }
  isNothing() {
    /* abstract */
  }
  map<B>(fn: (_: A) => B): Maybe<B> {
    return Maybe.of(fn(this.x));
  }
}

我们使用一个abstract类,因为你不应该直接写new Maybe(…);你应该使用Maybe.of()或直接构建JustNothing。(如果你想知道如何在 JavaScript 中这样做,请参阅问题 12.3。)我们可以通过尝试对一个有效值或缺失值应用操作来快速验证这一点。让我们看看这个的两个例子:

const plus1 = x => x + 1;
Maybe.of(2209).map(plus1).map(plus1).toString();
// "Just(2211)"
Maybe.of(null).map(plus1).map(plus1).toString();
// "Nothing()"

当我们将plus1()(两次)应用于Maybe.of(2209)时,一切正常,我们最终得到了一个Just(2011)值。另一方面,当我们将相同的操作序列应用于Maybe.of(null)值时,最终结果是Nothing,即使我们尝试用null值进行数学运算,也没有错误。一个Maybe函子可以通过跳过操作并返回一个包装的null值来处理映射缺失值。这意味着这个函子包含了一个抽象的检查,它不会让错误发生。

(在本章的后面部分,我们将看到Maybe实际上可以是一个函子而不是函子,我们还将检查更多关于函子的例子。)

让我们看看它使用的一个更现实的例子。

处理变化的 API 结果

假设我们正在用 Node.js 编写一个小型的服务器端服务,用于获取某个城市的天气警报,并生成一个不太时尚的 HTML <table> 表格,作为某个服务器端生成的网页的一部分。(是的,我知道你应该尽量避免在页面上使用表格,但我只想举一个简单的 HTML 生成示例,实际结果并不重要。)如果我们使用 Dark Sky API(有关此 API 的更多信息以及如何注册,请参阅 darksky.net),来获取警报,我们的代码可能如下所示,都是相当正常的。注意错误情况下的回调;你将在下面的代码中看到原因:

import request from "superagent";
const getAlerts = (
  lat: number,
  long: number,
  callback: FN
) => {
  const SERVER = "https://api.darksky.net/forecast";
  const UNITS = "units=si";
  const EXCLUSIONS = "exclude=minutely,hourly,daily,flags";
  const API_KEY = "you.need.to.get.your.own.api.key";
  request
    .get(
      `${SERVER}/${API_
        KEY}/${lat},${long}?${UNITS}&${EXCLUSIONS}`
    )
    .end(function (err, res) {
      if (err) {
        callback({});
      } else {
        callback(JSON.parse(res.text));
      }
    });
};

这样的调用(经过大量编辑和缩小尺寸)的输出可能如下所示:

{
  latitude: 29.76,
  longitude: -95.37,
  timezone: "America/Chicago",
  offset: -5,
  currently: {
    time: 1503660334,
    summary: "Drizzle",
    icon: "rain",
    temperature: 24.97,
    .
    .
    .
    uvIndex: 0,
  },
  alerts: [
    {
      title: "Tropical Storm Warning",
      regions: ["Harris"],
      severity: "warning",
      time: 1503653400,
      expires: 1503682200,
      description:
        "TROPICAL STORM WARNING REMAINS IN EFFECT... WIND -        LATEST LOCAL FORECAST: Below tropical storm force wind         ... CURRENT THREAT TO LIFE AND PROPERTY: Moderate ...        Locations could realize roofs peeled off buildings,        chimneys toppled, mobile homes pushed off foundations         or overturned ...",
      uri: "https://alerts.weather.gov/cap/wwacapget.php?x=      TX125862DD4F88.TropicalStormWarning.125862DE8808TX.      HGXTCVHGX.73ee697556fc6f3af7649812391a38b3",
    },
    .
    .
    .
    {
      title: "Hurricane Local Statement",
      regions: ["Austin", ... , "Wharton"],
      severity: "advisory",
      time: 1503748800,
      expires: 1503683100,
      description:
        "This product covers Southeast Texas **HURRICANE         HARVEY DANGEROUSLY APPROACHING THE TEXAS COAST** ...        The next local statement will be issued by the National         Weather Service in Houston/Galveston TX around 1030 AM         CDT, or sooner if conditions warrant.\n",
      uri: "https://alerts.weather.gov/...",
    },
  ],
};

我是在美国德克萨斯州休斯顿获取的这些信息,那天飓风哈维正在接近该州。如果你在正常的日子里调用 API,数据将不会包括 alerts:[...] 部分。在这里,我们可以使用 Maybe 函子来处理接收到的数据,无论是否有任何警报:

import os from "os";
const produceAlertsTable = (weatherObj: typeof resp) =>
  Maybe.of(weatherObj)
    .map((w: typeof resp) => w.alerts)
    .map((a) =>
      a.map(
        (x) =>
          `<tr><td>${x.title}</td>` +
          `<td>${x.description.substr(0,
            500)}...</td></tr>`
      )
    )
    .map((a) => a.join(os.EOL))
    .map((s) => `<table>${s}</table>`);
getAlerts(29.76, -95.37, (x) =>
  console.log(produceAlertsTable(x).valueOf())
);

当然,你可能不会仅仅记录 produceAlertsTable() 包含的结果的值。最可能的选择是再次使用 map(),并使用一个函数来输出表格,将其发送到客户端,或者执行你需要做的任何事情。在任何情况下,最终的输出看起来可能如下所示:

<table><tr><td>Tropical Storm Warning</td><td>...TROPICAL STORM WARNING REMAINS IN EFFECT... ...STORM SURGE WATCH REMAINS IN EFFECT... * WIND -
LATEST LOCAL FORECAST: Below tropical storm force wind - Peak Wind Forecast: 25-35 mph with gusts to 45 mph - CURRENT THREAT TO LIFE AND PROPERTY: Moderate - The wind threat has remained nearly steady from the previous assessment. - Emergency plans should include a reasonable threat for strong tropical storm force wind of 58 to 73 mph. - To be safe, earnestly prepare for the potential of significant...</td></tr>
<tr><td>Flash Flood Watch</td><td>...FLASH FLOOD WATCH REMAINS IN EFFECT
THROUGH MONDAY MORNING... The Flash Flood Watch continues for * Portions of Southeast Texas...including the following counties...Austin...Brazoria...Brazos...Burleson...
Chambers...Colorado...Fort Bend...Galveston...Grimes...
Harris...Jackson...Liberty...Matagorda...Montgomery...Waller... Washington and Wharton. * Through Monday morning * Rainfall from Harvey will cause devastating and life threatening flooding as a prolonged heavy rain and flash flood thre...</td></tr>
<tr><td>Hurricane Local Statement</td><td>This product covers Southeast
Texas **PREPARATIONS FOR HARVEY SHOULD BE RUSHED TO COMPLETION THIS MORNING** NEW INFORMATION --------------- * CHANGES TO WATCHES AND
WARNINGS: - None * CURRENT WATCHES AND WARNINGS: - A Tropical Storm Warning and Storm Surge Watch are in effect for Chambers and Harris - A Tropical Storm Warning is in effect for Austin, Colorado, Fort Bend, Liberty, Waller, and Wharton - A Storm Surge Warning and Hurricane Warning are in effect for Jackson and Matagorda - A Storm S...</td></tr></table>

前面代码的输出可以在以下屏幕截图中看到:

图 12.1 – 输出表格看起来并不怎么样,但生成它的逻辑并没有使用任何 if 语句

图 12.1 – 输出表格看起来并不怎么样,但生成它的逻辑并没有使用任何 if 语句

如果我们用蒙得维的亚,乌拉圭的坐标调用 getAlerts(-34.9, -54.60, ...),由于该城市没有警报,getField("alerts") 函数将返回 undefined——由于该值被 Maybe 函子识别,尽管接下来的所有 map() 操作仍然会执行,但实际上没有人会做任何事情,最终结果将是 null 值。

我们在编写错误逻辑时利用了这种行为。如果在调用服务时发生错误,我们仍然会调用原始回调来生成表格,但提供一个空对象。即使这个结果出乎意料,我们也会很安全,因为相同的防护措施将避免导致运行时错误。

作为最后的增强,我们可以添加一个 orElse() 方法,在未提供值时提供一个默认值。如果 MaybeNothing,则添加的方法将返回默认值,否则返回 Maybe 值本身:

// continued...
class Maybe<A> extends Functor<A> {
  .
  .
  .
  orElse(v: any) {
    /* abstract */
  }
}
class Nothing extends Functor<any> {
  .
  .
  .
  orElse(v: any) {
    return v;
  }
}
class Just<A> extends Functor<A> {
  .
  .
  .
  orElse(v: any) {
    return this.x;
  }
}

使用这个新方法代替 valueOf(),尝试获取没有天气警告的地方的警报,只会返回默认结果。在我们之前提到的例子中,尝试获取蒙得维的亚的警报,而不是 null 值,我们会得到以下适当的结果:

getAlerts(-34.9, -54.6, (x) =>
  console.log(
    produceAlertsTable(x).orElse(
      "<span>No alerts today.</span>"
    )
  )
);

通过这种方式,我们已经看到了处理与 API 一起工作时不同情况的一个示例。让我们快速回顾一下之前章节中的另一个主题,并看看棱镜的更好实现。

实现棱镜

常见的棱镜实现(我们首次在 第十章**,确保纯净性)部分中遇到),而不是返回某个值或 undefined 并让调用者检查发生了什么,而是选择返回 Maybe,这已经为我们提供了处理缺失值的简单方法。在我们的新实现(我们很快就会看到)中,上述章节中的示例将看起来像这样:

const author = {
  user: "fkereki",
  name: {
    first: "Federico",
    middle: "",
    last: "Kereki",
  },
  books: [
    { name: "Google Web Toolkit", year: 2010 },
    { name: "Functional Programming", year: 2017 },
    { name: "Javascript Cookbook", year: 2018 },
  ],
};

如果我们想要访问 author.user 属性,结果将不同:

const pUser = prismProp("user");
console.log(review(pUser, author).toString());
/*
Just("fkereki")
*/

同样地,如果我们请求一个不存在的别名属性,而不是 undefined(如我们之前版本的 Prism),我们会得到 Nothing

const pPseudonym = prismProp("pseudonym"); console.log(review(pPseudonym, author).toString());
/*
Nothing()
*/

因此,如果你已经习惯了处理 Maybe 值,那么这个 Prism 的新版本会更好用。我们需要做些什么来实现这个功能呢?我们只需要一个改动;我们的 Constant 类现在需要返回 Maybe 而不是值,因此我们将有一个新的 ConstantP (P 代表 Prism) 类:

class ConstantP<V> {
  private value: Maybe<V>;
  map: FN;
  constructor(v: V) {
    this.value = Maybe.of(v);
    this.map = () => this;
  }
}

我们将不得不重写 preview() 以使用新的类,这样改动就完成了:

const preview = curry(
  (prismAttr, obj) =>
    prismAttr((x) => new ConstantP(x))(obj).value
);

因此,让 PrismMaybe 一起工作并不那么困难,现在我们有了处理可能缺失属性的一致方式。以这种方式工作,我们可以简化我们的编码并避免许多对空值的测试和其他类似情况。然而,我们可能想要做得更多;例如,我们可能想知道为什么没有警报:是服务错误吗?还是只是一个正常情况?仅仅在最后得到空值是不够的,为了满足这些新要求,我们需要向我们的函子添加一些额外的功能(我们将在下一节中看到),并进入 单子 的领域。

单子

单子在程序员中有着奇特的名声。知名开发者 Douglas Crockford 曾著名地提到一个诅咒,认为“一旦你最终理解了单子,你立即失去了向其他人解释它们的能力! ”另一方面,如果你决定回到基础并阅读 Saunders Mac Lane 的 工作数学家的范畴学(范畴理论的创始人之一),你可能会发现一个多少有些令人不安的解释——这并不是特别有启发性!

在 X 中的单子就是 X 的内射函子范畴中的幺半群,其中乘积 × 被内射函子的组合所替代,单位集由恒等内射函子所构成。

单子和模范函数之间的区别在于前者添加了一些额外的功能;我们很快就会看到它们添加了哪些功能。让我们先看看新的要求,然后再考虑一些常见且有用的单子。与模范函数一样,我们将有一个基本的单子,你可以将其视为一个 抽象 版本,以及特定的 单子类型,它们是针对解决特定情况的具体实现。

你想阅读的一切

要阅读关于模范函数、单子和它们家族的精确和细致的描述(但侧重于理论方面,有很多代数定义),请尝试查看 github.com/fantasyland/fantasy-land/Fantasy Land Specification。请别说我没有警告你:该页面的另一个名称是 代数 JavaScript Specification

添加操作

让我们考虑一个简单的问题。假设你有一对函数,它们使用 Maybe 模范函数:第一个函数尝试根据其键搜索某些内容(比如,客户或产品),第二个函数尝试从找到的任何内容中提取一些属性(我故意说得模糊,因为这个问题与我们可能正在处理的任何对象或事物无关)。这两个函数都产生 Maybe 结果以避免可能的错误。我们使用一个模拟的搜索函数只是为了帮助我们理解这个问题。对于偶数键,它返回假数据,对于奇数键,它抛出异常。这个搜索的代码非常简单:

const fakeSearchForSomething = (key: number) => {
  if (key % 2 === 0) {
    return { key, some: "whatever", other: "more data" };
  } else {
    throw new Error("Not found");
  }
};

使用这个搜索,我们的 findSomething() 函数将尝试进行搜索,并在成功调用时返回 Maybe.of()(一个 Just),在出错时返回 Maybe.of(null)(一个 Nothing):

const findSomething = (key: number) => {
  try {
    const something = fakeSearchForSomething(key);
    return Maybe.of(something);
  } catch (e) {
    return Maybe.of(null);
  }
};

这样,我们可能会认为可以编写这两个函数来进行一些搜索,但并不是所有事情都会顺利;你能看到这里的问题吗?

const getSome = (something: any) =>
  Maybe.of(something.map((x: any) => x.some));
const getSomeFromSomething = (key: number) =>
  getSome(findSomething(key));

这个序列中的问题是 getSome() 的输出是一个 Maybe 值,它本身又包含一个 Maybe 值,所以结果被双重封装,正如我们可以通过执行几个调用所看到的,对于偶数(将返回 "whatever")和奇数(将是一个错误),如下所示:

const xxx = getSomeFromSomething(2222).valueOf().valueOf();
// "whatever"
const yyy = getSomeFromSomething(9999).valueOf().valueOf();
// undefined

如果在这个玩具问题中避免在 getSome() 中使用 Maybe.of(),这个问题就可以轻松解决,但这类问题可能会以许多更复杂的方式出现。例如,你可能会从一个对象中构建一个 Maybe,该对象的某个属性恰好是一个 Maybe,当你访问该属性时,你将遇到相同的情况:你将得到一个双重封装的值。

现在,我们将探讨单子。一个单子应该提供以下操作:

  • 一个构造函数。

  • 一个将值插入到单子中的函数:我们的 of() 方法。

  • 一个允许我们链式操作的功能:我们的 map() 方法。

  • 一个可以移除额外包装器的函数:我们将称之为unwrap()。它将解决我们之前的多重包装问题。有时,这个函数被称为flatten()

为了简化我们的编码,我们还将有一个用于链式调用的函数,另一个用于应用函数的函数,但我们会稍后讨论这些。让我们看看在实际的 JavaScript 代码中,单子(monad)看起来是什么样子。数据类型规范与函子(functor)的规范非常相似,所以我们在这里不再重复:

// monad.ts
class Monad<A> extends Functor<A> {
  static of<B>(x: B): Monad<B> {
    return new Monad(x);
  }
  map<B>(fn: (_: A) => B): Monad<B> {
    return new Monad(fn(this.x));
  }
  unwrap(): any {
    const myValue = this.x;
    return myValue instanceof Monad
      ? myValue.unwrap()
      : this;
  }
}

我们使用递归来连续移除包装器,直到包装的值不再是容器为止。使用这种方法,我们可以轻松地避免双重包装,并且我们可以像这样重写我们之前麻烦的函数:

const getSomeFromSomething = key => getSome(findSomething(key)).unwrap();

然而,这类问题可能会在不同级别上重复出现。例如,如果我们进行一系列的map()操作,任何中间结果最终可能会被双重包装。你可以通过记住在每次map()之后调用unwrap()来解决此问题——请注意,即使实际上不需要这样做,你也可以这样做,因为unwrap()的结果将是完全相同的对象(你能看到为什么吗?)。但我们可以做得更好!让我们定义一个chain()操作(有时也称为flatMap(),这有点令人困惑,因为我们已经为另一个含义赋予了它的名字;有关更多信息,请参阅第五章声明式编程),这个操作将为我们完成这两件事:

// continued...
class Monad<A> extends Functor<A> {
  .
  .
  .
  chain<B>(fn: (_: A) => B) {
    return this.map(fn).unwrap();
  }
}

只剩下最后一个操作。假设你有一个有两个参数的柯里化函数——这没什么奇怪的!如果你将这个函数提供给map()操作,会发生什么?

const add = (x: number) => (y: number) => x + y;
// or curry((x,y) => x+y)
const something = Monad.of(2).map(add);

something会是什么?鉴于我们只提供了一个参数来添加,该应用的结果将是一个函数——但不是一个普通的函数,而是一个被包装的函数!(由于函数是一等对象,将函数包装在单子中没有逻辑障碍,对吧?)我们想用这样的函数做什么?为了能够将这个包装函数应用于一个值,我们需要一个新的方法:ap()。它的值会是什么?在这种情况下,它可以是普通的数字,也可以是其他操作的结果而被单子包装的数字。由于我们总是可以用Map.of()将普通数字映射到一个包装的数字,让我们让ap()以单子作为其参数;新的方法如下:

// continued...
class Monad<A> extends Functor<A> {
  .
  .
  .
  ap<B, C extends FN>(this: Monad<C>, m: Monad<B>) {
    return m.map(this.x);
  }
}

使用这个,你就可以做以下事情:

const monad5 = something.ap(Monad.of(3));
console.log(monad5.toString())
// Monad(5)

你可以使用单子来持有值或函数,并按需与其他单子和链式操作交互。所以,正如你所看到的,单子并没有什么大技巧,它们只是具有一些额外方法的函子。现在,让我们看看我们如何将它们应用于我们的原始问题,并以更好的方式处理错误。

处理替代方案——Either 单子

在某些情况下,知道一个值缺失可能就足够了,但在其他情况下,你可能希望能够提供一个解释。如果我们使用不同的函子,我们可以得到这样的解释,这个函子将取两个可能值之一——一个与问题、错误或失败相关联,另一个与正常执行或成功相关联:

  • 一个值,它应该是 null,但如果存在,则表示一些特殊值(例如,错误消息或抛出的异常),这些值不能被映射

  • 一个正确的值,它代表函子的正常值,并且可以被映射

我们可以像为Maybe所做的那样构建这个单子(实际上,添加的操作使Maybe扩展Monad变得更好)。构造函数将接收一个左值和一个右值。如果左值存在,它将成为Either单子的值;否则,将使用右值。由于我们已经为所有我们的函子提供了of()方法,我们也需要为Either提供一个。Left单子与我们之前的Nothing非常相似:

// either.ts
class Left extends Monad<any> {
  isLeft() {
    return true;
  }
  map(_: any) {
    return this;
  }
}

同样,Right与我们之前的Just相似:

// continued...
class Right<A> extends Monad<A> {
  isLeft() {
    return false;
  }
  map(fn: (_: A) => any) {
    return Either.of(null, fn(this.x));
  }
}

现在我们已经掌握了这两个单子,我们可以编写我们的Either单子。这和之前的Maybe相似,这并不令人惊讶,对吧?

// continued...
abstract class Either<A, B> extends Monad<A | B> {
  static of<C, D>(left: C, right?: D): Left | Right<D> {
    return right === undefined || right === null
      ? new Left(left)
      : new Right(right);
  }
  isLeft() {
    /* */
  }
}

map()方法是关键。如果这个函子有一个左值,它将不会被进一步处理;在其他情况下,映射将被应用于右值,并且结果将被包装。现在,我们如何用这个来增强我们的代码?关键思想是让每个涉及的方法都返回一个Either单子;chain()将用于依次执行操作。获取警报将是第一步——我们用AJAX FAILURE消息或 API 调用的结果调用回调,如下所示:

const getAlerts2 = (lat, long, callback) => {
  const SERVER = "https://api.darksky.net/forecast";
  const UNITS = "units=si";
  const EXCLUSIONS = "exclude=minutely,hourly,daily,flags";
  const API_KEY = "you.have.to.get.your.own.key";
  request
    .get(
      `${SERVER}/${API_KEY}/${lat},${long}` +
        `?${UNITS}&${EXCLUSIONS}`
    )
    .end((err, res) =>
      callback(
        err
          ? Either.of("AJAX FAILURE", null)
          : Either.of(null, JSON.parse(res.text))
      )
    );
};

然后,一般的过程是这样的。我们再次使用Either单子。如果没有警报,我们将返回一个"NO ALERTS"消息,而不是一个数组:

const produceAlertsTable2 = (weatherObj: typeof resp) => {
  return weatherObj
    .chain((obj: typeof resp) => {
      const alerts = getField("alerts")(obj);
      return alerts
        ? Either.of(null, alerts)
        : Either.of("NO ALERTS", null);
    })
    .chain((a) =>
      a.map(
        (x) =>
          `<tr><td>${x.title}</td>` +
          `<td>${x.description.substr(0,
            500)}...</td></tr>`
      )
    )
    .chain((a) => a.join(os.EOL))
    .chain((s) => `<table>${s}</table>`);
};

注意我们如何使用chain(),这样多个包装器就不会有问题。现在,我们可以测试多种情况并得到适当的结果——至少,对于全球当前的天气状况来说是这样!

  • 对于德克萨斯州的休斯顿,我们仍然得到一个 HTML 表格

  • 对于乌拉圭的蒙得维的亚,我们得到一条消息说没有警报

  • 对于坐标错误的点,我们了解到 AJAX 调用失败了:太好了!

// Houston, TX, US:
getAlerts2(29.76, -95.37, (x) =>
  console.log(produceAlertsTable2(x).toString())
);
// Right("...a table with alerts: lots of HTML code...");
// Montevideo, UY
getAlerts2(-34.9, -54.6, (x) =>
  console.log(produceAlertsTable2(x).toString())
);
// Left("NO ALERTS");
// A point with wrong coordinates
getAlerts2(444, 555, (x) =>
  console.log(produceAlertsTable2(x).toString())
);
// Left("AJAX FAILURE");

我们还没有完成Either单子的工作。很可能你的代码将涉及调用函数。让我们看看如何通过使用这个单子的一个变体来更好地实现这一点。

调用一个函数——Try 单子

如果我们调用可能会抛出异常的函数,并且想以函数式的方式这样做,我们可以使用Try单子来封装函数结果或异常。基本思想与Either单子相同。唯一的不同在于构造函数,它接收一个函数并调用它:

  • 如果没有问题,返回的值将成为单子的右值

  • 如果有异常,它将成为left

这可以在以下代码中看到:

// try.ts
class Try<A> extends Either<A, string> {
  // @ts-expect-error Call to super() not needed
  constructor(fn: () => A, msg?: string) {
    try {
      return Either.of(null, fn()) as Either<A, string>;
    } catch (e: any) {
      return Either.of(msg || e.message, null) as Either<
        string,
        string
      >;
    }
  }
}

为什么使用@ts-expect-error注解?构造函数应该要么调用super(),要么返回一个完全构建的方法,但 TypeScript 总是期望前者,因此我们必须告诉它我们在这里知道我们在做什么。

现在,我们可以以良好的方式调用任何函数,并捕获异常。例如,我们一直在使用的getField()函数,如果它被一个null参数调用,将会崩溃:

const getField = attr => obj => obj[attr];

第十章实现 prisms部分中,确保纯净性,我们编写了一个getFieldP()函数,它可以处理null值,但在这里,我们将使用Try monad 重写它,因此,它还将与其他组合函数很好地协同工作。我们的 getter 的替代实现如下:

const getField2 = (attr: string) => (obj: OBJ | null) =>
  new Try(() => obj![attr], "NULL OBJECT");

我们可以通过尝试将我们的新函数应用于一个空值来检查这是否有效:

const x = getField2("somefield")(null);
console.log(x.isLeft()); // true
console.log(x.toString()); // Left(NULL OBJECT)

还有更多的 monads,当然,你甚至可以定义自己的,所以我们不可能涵盖所有这些。然而,让我们参观最后一个——你已经使用过,但没有意识到它的 monad 特性!

意料之外的 monads——承诺

让我们通过提及另一个你可能已经使用过但名称不同的 monad 来结束关于 monad 的这部分内容:承诺!之前,我们提到过,functors(记住,monads 也是 functors)至少在一点上与承诺有共同之处:使用方法来访问值。然而,相似之处远不止于此!

  • Promise.resolve()对应于Monad.of()——如果你向.resolve()传递一个值,你将得到一个解析为该值的承诺,如果你提供一个承诺,你将得到一个新的承诺,其值将是原始承诺的值(有关更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve)。这是一个解包行为!

  • Promise.then()代表Monad.map()以及Monad.chain(),考虑到提到的解包行为。

我们没有直接匹配Monad.ap()的对应物,但我们可以添加一些类似以下代码的东西(这将由 TypeScript 拒绝,但我们已经看到了如何解决这个问题):

Promise.prototype.ap = function (promise2) {
  return this.then((x) => promise2.map(x));
};

承诺——永不消失

即使你选择了现代的asyncawait特性,在内部,它们基于承诺。此外,在某些情况下,你可能仍然需要Promise.race()Promise.all(),所以即使你选择了完整的 ES8 编码,你也很可能会继续使用承诺。

这是对本节的适当结束。之前,你发现普通的数组实际上是函子。现在,就像 Monsieur Jourdain(莫里哀戏剧《Le Bourgeois Gentilhomme》中的角色,《The Bourgeois Gentleman》)发现他一生都在用散文说话一样,你现在知道你已经在不知不觉中使用了单子!到目前为止,我们已经学习了如何构建不同类型的容器。现在,让我们学习函数如何也能作为容器,以及所有种类的数据结构!

函数作为数据结构

到目前为止,我们已经学习了如何使用函数来处理或转换其他函数以处理数据结构或创建数据类型。现在,我们将通过展示一个函数如何实现数据类型,成为其自己的容器来结束这一章。实际上,这是λ演算的一个基本理论点(如果你想了解更多,可以查阅Church 编码Scott 编码),所以我们可以说,我们确实回到了这本书的开头,回到了函数式编程的起源!我们将从一个考虑二叉树的不同函数式语言,Haskell 的偏离开始,然后转向在 JavaScript 中实现作为函数的树。这次经历将帮助你弄清楚如何处理其他数据结构。

Haskell 中的二叉树

考虑一个二叉树。这样的树可能为空,或者由一个节点(树的根节点)及其两个子节点组成:一个二叉树和一个二叉树。没有子节点的节点称为叶节点

树的许多类型

第九章 设计函数中,我们处理了更通用的树结构,例如文件系统或浏览器 DOM 本身,这些结构允许一个节点有任意数量的子节点。在本节中的树的情况下,每个节点总是有两个子节点,尽管它们中的每一个都可能为空。这种差异可能看起来很小,但允许空子树可以使你定义所有节点都是二元的。

让我们用 Haskell 语言做一个偏离。在其中,我们可能会写如下内容;a将是节点中持有的任何值的类型:

data Tree a = Nil | Node a (Tree a) (Tree a)

在 Haskell 语言中,模式匹配常用于编码。例如,我们可以如下定义一个空函数:

empty :: Tree a -> Bool empty Nil = True
empty (Node root left right) = False

这是什么意思?除了数据类型定义之外,逻辑很简单:如果树是Nil(类型定义中的第一个可能性),那么树肯定是空的;否则,树不是空的。最后一行可能写成empty _ = False,使用_作为占位符,因为你实际上并不关心树的组件;它不是Nil的事实就足够了。

在二叉搜索树(其中根节点大于其左子树的所有值,小于其右子树的所有值)中搜索一个值的方式类似:

contains :: (Ord a) => (Tree a)
     -> a -> Bool contains Nil _ = False
contains (Node root left right) x
| x == root = True
| x   < root = contains left x
| x   > root = contains right x

这里匹配了哪些模式?我们现在有四种模式,必须按顺序考虑:

  1. 空树(Nil——我们寻找什么并不重要,所以只需写 _)不包含搜索值。

  2. 如果树不为空,且根匹配搜索值(x),我们就完成了。

  3. 如果根不匹配且大于搜索值,答案在搜索左子树时找到。

  4. 否则,答案是通过搜索右子树找到的。

有一个重要的要点需要记住:对于这种数据类型,它是两种可能类型的联合,我们必须提供两个条件,并且模式匹配将用于决定应用哪一个。请记住这一点!

作为二叉树的函数

我们能否用函数做类似的事情?答案是肯定的:我们将用函数本身来表示一棵树(或任何其他结构),而不是用一组函数处理的数据结构,也不是用具有一些方法的对象,而只是一个函数。此外,我们将得到一个 100%不可变的函数数据结构,如果更新,将产生一个新的副本。我们将不使用对象来完成所有这些;在这里,闭包将提供所需的结果。

这是如何工作的呢?我们将应用与本章前面所讨论的类似的概念,因此函数将充当容器,并产生其包含值的映射作为其结果。让我们从后往前看,首先看看我们将如何使用新的数据类型。然后,我们将通过实现细节进行说明。

创建一棵树可以通过使用两个函数来完成:EmptyTree()Tree(value, leftTree, rightTree)。例如,假设我们希望创建一个类似于以下图表的树:

图 12.2 – 二叉搜索树

图 12.2 – 二叉搜索树

我们可以使用以下代码创建它:

// functionAsTree.ts
const myTree: TREE = NewTree(
  22,
  NewTree(
    9,
    NewTree(4, EmptyTree(), EmptyTree()),
    NewTree(12, EmptyTree(), EmptyTree())
  ),
  NewTree(
    60,
    NewTree(56, EmptyTree(), EmptyTree()),
    EmptyTree()
  )
);

如何处理这个结构?根据数据类型描述,每次处理树时,你必须考虑两种情况:非空树和空树。在前面的代码中,myTree() 是一个接收两个函数作为参数的函数,每个参数对应两种数据类型情况之一。第一个函数将使用节点值和左右树作为参数被调用,而第二个函数将不接收任何参数:

// continued...
type TREE<A> = (
  _nonEmptyTree: (
    _x: A,
    _left: TREE<A>,
    _right: TREE<A>
  ) => any,
  _emptyTree: () => any
) => any;

要获取一棵树的根,我们可以写类似以下的内容:

const myRoot = myTree(
  (value) => value,
  () => null
);

如果我们处理的是非空树,我们期望第一个函数被调用并返回根的值。对于空树,第二个函数应该被调用,然后返回一个 null 值。

同样,如果我们想计算树中有多少个节点,我们会写以下内容:

// continued...
const treeCount = <A>(tree: TREE<A>): number =>
  tree(
    (value, left, right) =>
      1 + treeCount(left) + treeCount(right),
    () => 0
  );
console.log(treeCount(myTree));

对于非空树,第一个函数将返回 1(对于根),然后加上根的左右子树的节点计数。对于空树,计数是简单的 0。明白了吗?

现在,我们可以展示 NewTree()EmptyTree() 函数。它们如下所示:

// continued...
const NewTree =
  <A>(value: A, left: TREE<A>, right: TREE<A>): TREE<A> =>
  (destructure, _) =>
    destructure(value, left, right);
const EmptyTree =
  <A>(): TREE<A> =>
  (_, destructure) =>
    destructure();

destructure() 函数是你将作为参数传递的内容(这个名字来自 JavaScript 中的解构语句,它允许你将对象属性分离成不同的变量)。你必须提供这个函数的两个版本。如果树不为空,第一个函数将被执行;对于空树,第二个函数将被运行(这模仿了 Haskell 代码中的情况选择,除了我们将非空树的情况放在第一位,空树放在最后)。以下划线命名的变量用作占位符,代表一个否则会被忽略的参数,但同时也表明假设有两个参数;通常,初始的下划线意味着某个参数没有被使用。

这可能难以理解,所以让我们看看更多的例子。如果我们需要访问树中的特定元素,我们有以下三个函数:

// continued...
const treeRoot = <A>(tree: TREE<A>): A | null =>
  tree(
    (value, _left, _right) => value,
    () => null
  );

我们如何判断一棵树是否为空?看看你是否能弄清楚以下简短代码行为什么能工作:

// continued...
const treeIsEmpty = <A>(tree: TREE<A>): boolean =>
  tree(
    () => false,
    () => true
  );

让我们再看看这个的几个更多例子。例如,我们可以从树中构建一个对象,这有助于调试。我添加了逻辑来避免包含左或右空子树,因此生成的对象会更紧凑;查看以下代码中的两个 if 语句:

// continued...
const treeToObject = <A>(tree: TREE<A>): OBJ =>
  tree(
    (value, left, right) => {
      const leftBranch = treeToObject(left);
      const rightBranch = treeToObject(right);
      const result: OBJ = { value };
      if (leftBranch) {
        result.left = leftBranch;
      }
      if (rightBranch) {
        result.right = rightBranch;
      }
      return result;
    },
    () => null
  );

注意到递归的使用,正如在第九章“设计函数”中的遍历树结构部分所展示的,以生成左右子树的等价对象。这个函数的一个例子如下;我编辑了输出以使其更清晰:

console.log(treeToObject(myTree));
/*
{
  value: 22,
  left: {
    value: 9,
    left: {
      value: 4,
    },
    right: { value: 12 },
  },
  right: {
    value: 60,
    left: {
      value: 56,
    },
  },
};
*/

我们能否搜索一个节点?当然可以,其逻辑与我们在上一节中看到的定义紧密相关。(我们本可以稍微缩短代码,但我希望与 Haskell 版本保持一致;对于更简洁的版本,请参阅问题 12.6。)我们的 treeSearch() 函数可能如下所示:

// continued...
const treeSearch = <A>(
  findValue: A,
  tree: TREE<A>
): boolean =>
  tree(
    (value, left, right) =>
      findValue === value
        ? true
        : findValue < value
        ? treeSearch(findValue, left)
        : treeSearch(findValue, right),
    () => false
  );

如果我们想要的值是根节点,我们就找到了它;如果它小于根节点,我们递归地搜索左子树,如果大于,则搜索右子树。

为了结束这一节,让我们也看看如何向树中添加新节点。仔细研究代码;你会注意到当前树没有被修改,而是生成了一个新的树。当然,鉴于我们正在使用函数来表示我们的树数据类型,应该很明显,我们无法修改旧的结构:它默认是不可变的。树插入函数可能如下所示:

// continued...
const treeInsert = <A>(
  newValue: A,
  tree: TREE<A>
): TREE<A> =>
  tree(
    (value, left, right) =>
      newValue <= value
        ? NewTree(value, treeInsert(newValue, left), right)
        : NewTree(value, left, treeInsert(newValue,
          right)),
    () => NewTree(newValue, EmptyTree(), EmptyTree())
  );

当尝试插入一个新键时,如果其值小于或等于树的根,我们产生一个新的树,其当前根作为其自己的根,保持旧的右子树,但将其左子树更改为包含新值(这将递归完成)。如果键大于根,变化就不会是对称的;它们将是类似的。如果我们尝试插入一个新键并发现自己有一个空树,我们用具有新值作为根的新树替换那个空结构,并带有空左子树和右子树。

我们可以轻松地测试这个逻辑,但最简单的方法是验证我们之前显示的二叉树(图 12**.2)是通过以下操作序列生成的:

let myTree = EmptyTree();
myTree = treeInsert(22, myTree);
myTree = treeInsert(9, myTree);
myTree = treeInsert(60, myTree);
myTree = treeInsert(12, myTree);
myTree = treeInsert(4, myTree);
myTree = treeInsert(56, myTree);
// The resulting tree is:
{
  value: 22,
  left: {
    value: 9,
    left: { value: 4 },
    right: { value: 12 },
  },
  right: { value: 60, left: { value: 56 } },
}

我们可以通过提供用于比较值的比较函数来使这个插入函数更加通用。这样,我们就可以轻松地将二叉树调整为表示一个通用映射。一个节点的值实际上是一个对象,例如 {key:... , data:...},提供的函数将比较 newValue.keyvalue.key 以确定添加新节点的地方。当然,如果两个键相等,我们会改变当前树的根。新的树插入代码如下。让我们从类型和比较开始:

// continued...
type NODE<K, D> = { key: K; data: D };
const compare = <K, D>(
  obj1: NODE<K, D>,
  obj2: NODE<K, D>
) =>
  obj1.key === obj2.key ? 0 : obj1.key < obj2.key ? -1 : 1;

树插入代码现在是以下内容:

// continued...
const treeInsert2 = <K, D>(
  comparator: typeof compare<K, D>,
  newValue: NODE<K, D>,
  tree: TREE<NODE<K, D>>
): TREE<NODE<K, D>> =>
  tree(
    (value, left, right) =>
      comparator(newValue, value) === 0
        ? NewTree(newValue, left, right)
        : comparator(newValue, value) < 0
        ? NewTree(
            value,
            treeInsert2(comparator, newValue, left),
            right
          )
        : NewTree(
            value,
            left,
            treeInsert2(comparator, newValue, right)
          ),
    () => NewTree(newValue, EmptyTree(), EmptyTree())
  );

我们还需要什么?当然,我们可以编写各种函数:删除节点、计数节点、确定树的高度、比较两个树等等。然而,为了获得更多的可用性,我们实际上应该通过实现一个 map() 函数将结构转换为函子。幸运的是,使用递归,这证明是容易的——我们将映射函数应用于树根,并在左子树和右子树上递归地使用 map(),如下所示:

// continued...
const treeMap = <A, B>(
  fn: (_x: A) => B,
  tree: TREE<A>
): TREE<B> =>
  tree(
    (value, left, right) =>
      NewTree(
        fn(value),
        treeMap(fn, left),
        treeMap(fn, right)
      ),
    () => EmptyTree()
  );

我们可以继续提供更多示例,但这不会改变我们可以从这个工作中得出的重要结论:

  • 我们正在处理一个数据结构(一个递归的数据结构)并用一个函数来表示它

  • 我们没有使用外部变量或对象来处理数据:而是使用闭包

  • 该数据结构满足我们在第十章“确保纯净性”中分析的所有要求,即它是不可变的,所有的更改总是产生新的结构

  • 该树是一个函子,提供了所有相应的优势

在本节中,我们探讨了 FP 的另一个应用,以及一个函数如何实际上成为一个结构本身,而这并不是我们通常习惯的!

摘要

在本章中,我们探讨了数据类型的理论,并从函数式角度学习了如何使用和实现它们。我们从定义函数签名开始,以帮助我们理解后来观察到的多个操作所隐含的转换,该语法独立于 TypeScript 的语法。然后,我们继续定义了几个容器,包括函子(functors)和单子(monads),并看到了它们如何被用来增强函数组合。最后,我们学习了函数可以直接自身使用,没有任何额外负担,以实现函数式数据结构,从而简化错误处理。

在这本书中,我们探讨了 JavaScript 和 TypeScript 的 FP(函数式编程)的几个特性。我们从一个定义和一些实际例子开始,然后转向重要的考虑因素,如纯函数、避免副作用、不可变性、可测试性、从其他函数构建新函数,以及基于函数连接和数据容器的数据流实现。我们探讨了大量的概念,但我相信你将能够将它们付诸实践,并开始编写更高品质的代码——试试看,非常感谢你阅读这本书!

问题

12.1 全局声明;你能添加这个声明吗?

12.2 符号!

12.3 MaybeEither单子,但这些类型的类仅在 TypeScript 中可用。你能想出一个在 JavaScript 中工作的替代方法吗?

12.4 使用MaybeEither单子来简化代码。

12.5 扩展你的树:为了得到我们函数式二叉搜索树的更完整实现,实现以下函数:

  • 计算树的高度,或者等价地,从根到任何其他节点的最大距离

  • 按升序列出树的所有键

  • 从树中删除一个键

12.6 treeSearch()函数可以被缩短——你能做到吗?是的,这更像是 JavaScript 问题而不是函数式问题,我并不是说更短的代码一定是更好的,但许多程序员似乎是这样认为的,所以了解这种风格是好的,仅因为你有可能会遇到它。

12.7 函数式列表:与二叉树的精神相同,实现函数式列表。由于列表被定义为要么是空的,要么是一个节点(),后面跟着另一个列表(),你可能想从以下内容开始,这与我们的二叉搜索树非常相似:

type LIST<A> = (
  _nonEmptyList: (_head: A, _tail: LIST<A>) => any,
  _emptyList: LIST<A>
) => any;
const NewList =
  <A>(head: A, tail: LIST<A>): LIST<A> =>
  (f: FN, _g: FN) =>
    f(head, tail);
const EmptyList =
  <A>(): LIST<A> =>
  (f: FN, g: FN) =>
    g();

这里有一些简单的单行操作来帮助你开始;注意它们在风格上与我们为二叉树所写的非常相似:

const listHead = <A>(list: LIST<A>): A | null =>
  list(
    (head: A, _tail: LIST<A>) => head,
    () => null
  );
const listTail = <A>(list: LIST): LIST<A> | null =>
  list(
    (head: A, tail: LIST<A>) => tail,
    () => null
  );
const listIsEmpty = <A>(list: LIST<A>): boolean =>
  list(
    (_head: A, _tail: LIST<A>) => false,
    () => true
  );
const listSize = <A>(list: LIST<A>): number =>
  list(
    (head: A, tail: LIST<A>) => 1 + listSize(tail),
    () => 0
  );

你可以考虑以下操作:

  • 将列表转换为数组,反之亦然

  • 反转一个列表

  • 将一个列表追加到另一个列表的末尾

  • 连接两个列表

不要忘记listMap()函数!此外,listReduce()listFilter()函数也会很有用。

12.8 truefalse布尔值,但我们没有像&&||!这样的运算符。虽然我们可以通过一些(可能是重复的)编码来弥补它们的缺失,但我们可以让函数产生相同的结果;你能看到吗?沿着二叉树的思路思考。我们可以用一个函数来表示布尔值,该函数接受一对函数作为参数,并在布尔值为真时应用第一个函数,否则应用第二个函数。

问题答案

这里提供了本书各章节中包含的问题的解决方案(部分或全部解决)。在许多情况下,还有额外的题目,以便你可以选择进一步工作。

第一章,成为函数式编程者——几个问题

1.1 TypeScript,请!以下是对章节中代码的完整注释版本。这是阶乘函数的代码:

// question_01_typescript_please.ts
function fact(n: number): number {
  if (n === 0) {
    return 1;
  } else {
    return n * fact(n - 1);
  }
}
const fact2 = (n: number): number => {
  if (n === 0) {
    return 1;
  } else {
    return n * fact2(n - 1);
  }
};
const fact3 = (n: number): number =>
  n === 0 ? 1 : n * fact3(n - 1);

这是展开示例的代码:

// continued...
function sum3(a: number, b: number, c: number): number {
  return a + b + c;
}
const x: [number, number, number] = [1, 2, 3];
const y = sum3(...x); // equivalent to sum3(1,2,3)
const f = [1, 2, 3];
const g = [4, ...f, 5];
const h = [...f, ...g];
const p = { some: 3, data: 5 };
const q = { more: 8, ...p };
const numbers = [2, 2, 9, 6, 0, 1, 2, 4, 5, 6];
const minA = Math.min(...numbers); // 0
const maxArray = (arr: number[]) => Math.max(...arr);
const maxA = maxArray(numbers); // 9

为什么我们需要指定x的类型,但不指定fghpq的类型?问题是 TypeScript 检查sum3()的调用,为此,它需要确保x被定义为包含三个数字的数组。

TypeScript 能够推断出sum3()返回一个数字,但最好还是指定它,以防止未来可能出现的错误,你可能会返回一个不是数字的东西。

newCounter()函数不需要类型定义;TypeScript 能够推断出类型。(参见后面的问题 1.7。)

1.2 new。因此,我们可以合理地认为我们应该能够将类作为参数传递给其他函数。makeSaluteClass()创建了一个类(即一个特殊函数),它使用闭包来记住term的值。我们在本书中已经看到了更多这样的例子。

类的 TypeScript 代码如下:

// question_01_classes_as_1st_class.ts
const makeSaluteClass = (term: string) =>
  class {
    x: string;
    constructor(x: string) {
      this.x = x;
    }
    salute(y: string) {
      console.log(`${this.x} says "${term}" to ${y}`);
    }
  };
const Spanish = makeSaluteClass("HOLA");
new Spanish("ALFA").salute("BETA");
// ALFA says "HOLA" to BETA
new (makeSaluteClass("HELLO"))("GAMMA").salute("DELTA");
// GAMMA says "HELLO" to DELTA
const fullSalute = (
  c: ReturnType<typeof makeSaluteClass>,
  x: string,
  y: string
) => new c(x).salute(y);
const French = makeSaluteClass("BON JOUR");
fullSalute(French, "EPSILON", "ZETA");
// EPSILON says "BON JOUR" to ZETA

注意 TypeScript 的ReturnType<>实用类型的使用,以指定c将通过调用makeSaluteClass()创建。

1.3 f,我们让它从1增加到n。我们必须小心,以确保factUp(0) === 1

// question_01_climbing_factorial.ts
const factUp = (n: number, f = 1): number =>
  n <= f ? f : f * factUp(n, f + 1);

你不需要指定f的类型为number;TypeScript 会自动推断出来。

这个解决方案可能会让你担心,因为没有人阻止用两个参数调用factUp()——但我们需要省略第二个参数,因此它将被初始化为1。我们可以如下解决这个缺陷:

// continued...
const factUp2 = (n: number): number => {
  const factAux = (f: number): number =>
    n <= f ? f : f * factAux(f + 1);
  return factAux(1);
};

内部的factAux()函数基本上是我们之前的factUp()函数,除了它不需要n参数,因为它在其作用域内可用。我们新的factUp2()函数调用factAux(),提供其需要的默认值1

如果你喜欢使用默认值,你可以使用以下代码:

// continued...
const factUp3 = (n: number): number => {
  const factAux = (f = 1): number =>
    n <= f ? f : f * factAux(f + 1);
  return factAux();
};

要测试这些函数,可以在问题 1.5中找到的测试(对于正确值)将进行测试。

1.4 阶乘错误:避免重复测试的关键是编写一个函数,它会检查参数的值以确保其有效性,如果是这样,就调用一个内部函数来执行阶乘本身,而不必担心错误的参数:

// question_01_factorial_errors.ts
const carefulFact = (n: number): number | never => {
  if (
    typeof n === "number" &&
    n >= 0 &&
    n === Math.floor(n)
  ) {
    const innerFact = (n: number): number =>
      n === 0 ? 1 : n * innerFact(n - 1);
    return innerFact(n);
  } else {
    throw new Error("Wrong parameter for carefulFact2");
  }
};

按顺序,我们检查 n 必须是一个数字,不能是负数,并且是一个整数。当识别到错误的参数时,我们抛出一个错误。顺便说一句,这就是 number | never 类型指定的原因;此函数的使用者直接认识到有时(即当抛出异常时)不会返回任何值。

1.5 阶乘测试:以下测试有效:

// question_01_factorial_testing.test.ts
import { carefulFact } from "./question_1.4";
describe("Correct cases", () => {
  test("5! = 120", () => expect(carefulFact(5)).toBe(120));
  test("0! = 1", () => expect(carefulFact(0)).toBe(1));
});
describe("Errors", () => {
  test("Should reject 3.1", () => {
    expect(() => carefulFact(3.1)).toThrow();
  });
  test("Should reject -4", () => {
    expect(() => carefulFact(-3)).toThrow();
  });
  test("Should reject -5.2", () => {
    expect(() => carefulFact(-3)).toThrow();
  });
});

运行测试套件显示我们达到了 100% 的覆盖率。

1.6 ++ 操作符(更多信息请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Increment),你可以将 newCounter() 简化为以下形式:

// question_01_code_squeezing.ts
const shorterCounter = () => {
  let count = 0;
  return () => ++count;
};

使用箭头函数并不难理解,但请注意,许多开发者可能对使用 ++ 作为前缀操作符有疑问或怀疑,因此这个版本可能更难理解。

ESLint 有一个 no-plusplus 规则,禁止使用 ++--。由于我并不反对使用它们,所以我不得不禁用这个规则;有关更多信息,请参阅 eslint.org/docs/latest/user-guide/configuring/rules

1.7 newCounter() 不接受任何参数并返回一个数字,答案是 () => number

如果你正在使用 Visual Studio Code,有一种更快的方法来做这件事:悬停会提供答案,就像 图 1 中所示。

图 1 – Visual Studio Code 帮助输入

图 1 – Visual Studio Code 帮助输入

第二章,函数式思考 – 第一个示例

2.1 fn 变量本身作为标志。在调用 fn() 之后,我们将变量设置为 null。在调用 fn() 之前,我们通过使用短路 && 操作符检查它是否不是 null

// question_02_no_extra_variables.ts
const once = <FNType extends (...args: any[]) => any>(
  fn: FNType | null
) =>
  ((...args: Parameters<FNType>) => {
    fn && fn(...args);
    fn = null;
  }) as FNType;

我们需要做一个小改动,让 TypeScript 知道 fn 可能是 null;否则,它会反对 fn = null 的赋值。

2.2 交替函数:就像我们在上一个问题中所做的那样,我们交换函数,然后进行调用。在这里,我们使用解构赋值来更紧凑地编写交换。有关更多信息,请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#swapping_variables

// question_02_alternating_fns.ts
const alternator = <FNType extends (...args: any[]) =>
  any>(
  fn1: FNType,
  fn2: FNType
) =>
  ((...args: Parameters<FNType>) => {
    [fn1, fn2] = [fn2, fn1];
    return fn2(...args);
  }) as FNType;

我们可以这样编写测试:

// question_02_alternating_fns.test.ts
import { alternator } from "./question_2.2";
describe("alternator", () => {
  it("calls the two functions alternatively", () => {
    const funcA = jest.fn().mockReturnValue("A");
    const funcB = jest.fn().mockReturnValue("B");
    const testFn = jest.fn(alternator(funcA, funcB));
    expect(testFn()).toEqual("A");
    expect(testFn()).toEqual("B");
    expect(testFn()).toEqual("A");
    expect(testFn()).toEqual("B");
    expect(testFn()).toEqual("A");
    expect(testFn()).toEqual("B");
    expect(testFn).toHaveBeenCalledTimes(6);
    expect(funcA).toHaveBeenCalledTimes(3);
    expect(funcB).toHaveBeenCalledTimes(3);
  });
});

我们设置了两个模拟函数,一个将返回 "A",另一个返回 "B",然后我们测试连续调用在这两个值之间交替。

2.3 limit 大于 0。如果是这样,我们将其减 1 并调用原始函数;否则,我们不做任何事情:

// question_02_everything_has_a_limit.ts
const thisManyTimes =
  <FNType extends (...args: any[]) => any>(
    fn: FNType,
    limit: number
  ) =>
  (...args: Parameters<FNType>) => {
    if (limit > 0) {
      limit--;
      return fn(...args);
    }
  };

我们可以这样编写测试:

// question_02_everything_has_a_limit.test.ts
import { thisManyTimes } from "./question_2.3";
describe("thisManyTimes", () => {
  it("calls the function 2 times, nothing after", () => {
    const fn = jest.fn();
    const testFn = jest.fn(thisManyTimes(fn, 2));
    testFn(); // works
    testFn(); // works
    testFn(); // nothing now
    testFn(); // nothing now
    testFn(); // nothing now
    testFn(); // nothing now
    expect(testFn).toHaveBeenCalledTimes(6);
    expect(fn).toHaveBeenCalledTimes(2);
  });
});

我们的 testFn() 函数被设置为调用 fn() 两次,不再调用;测试确认了这种行为。

2.4 once(),所以如果 fn() 发生崩溃,我们将 done 重置为 false 以允许新的尝试:

// question_02_allow_for_crashing.ts
const onceIfSuccess = <
  FNType extends (...args: any[]) => any
>(
  fn: FNType
) => {
  let done = false;
  return ((...args: Parameters<FNType>) => {
    if (!done) {
      done = true;
      try {
        return fn(...args);
      } catch {
        done = false;
      }
    }
  }) as FNType;
};

我们可以通过一个简单的例子看到这是如何工作的;我们的 crashTwice() 函数将抛出两次错误并在之后正常工作:

// question_02_allow_for_crashing.manual.ts
import { onceIfSuccess } from "./question_2.4";
let count = 0;
const crashTwice = () => {
  count++;
  if (count <= 2) {
    console.log("CRASH!");
    throw new Error("Crashing...");
  } else {
    console.log("OK NOW");
  }
};
const doIt = onceIfSuccess(crashTwice);
doIt(); // CRASH!
doIt(); // CRASH!
doIt(); // OK NOW
doIt(); // nothing
doIt(); // nothing
doIt(); // nothing

我们可以按照以下方式编写测试:

// question_02_allow_for_crashing.test.ts
import { onceIfSuccess } from "./question_2.4";
describe("onceIfSuccess", () => {
  it("should run once if no errors", () => {
    const myFn = jest.fn();
    const onceFn = jest.fn(onceIfSuccess(myFn));
    onceFn();
    onceFn();
    onceFn();
    expect(onceFn).toHaveBeenCalledTimes(3);
    expect(myFn).toHaveBeenCalledTimes(1);
  });
  it("should run again if an exception", () => {
    const myFn = jest.fn()
      .mockImplementationOnce(() => {
        throw new Error("ERROR 1");
      })
      .mockImplementationOnce(() => {
        throw new Error("ERROR 2");
      })
      .mockReturnValue(22);
    const onceFn = jest.fn(onceIfSuccess(myFn));
    expect(onceFn).toThrow();
    expect(onceFn).toThrow();
    expect(onceFn()).toBe(22); // OK now (returns 22)
    onceFn(); // nothing
    onceFn(); // nothing
    onceFn(); // nothing
    expect(onceFn).toHaveBeenCalledTimes(6);
    expect(myFn).toHaveBeenCalledTimes(3);
  });
});

我们需要检查两种情况:当被调用的函数正常工作以及当它至少崩溃一次。第一种情况就像我们为 once() 编写的测试一样,所以这里没有新的内容。对于第二种情况,我们设置了一个模拟的 myFn() 函数,该函数会抛出两次错误并在之后返回一个常规值;测试验证了预期的行为。

2.5 拒绝箭头函数:代码本质上相同,但类型信息的放置不同:

// question_02_say_no_to_arrows.ts
function once<FNType extends (...args: any[]) => any>(
  fn: FNType
): FNType {
  let done = false;
  return function (...args: Parameters<FNType>) {
    if (!done) {
      done = true;
      return fn(...args);
    }
  } as FNType;
}

第三章,从函数开始——一个核心概念

3.1 type 被认为是标记一个语句,实际上并没有做什么:它是一个 (t) 表达式,没有被使用。因此,代码被认为是有效的,并且由于它没有显式的 return 语句,隐式返回的值是 undefined

修正后的代码如下所示:

const simpleAction = (t:string) => ({
  type: t;
});

查看 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/label 了解更多关于标签的信息,以及 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions#Returning_object_literals 了解更多关于返回对象的信息。

3.2 useArguments2(),但使用 useArguments(),你会得到一个错误,因为箭头函数没有定义参数:

useArguments(22,9,60);
Uncaught ReferenceError: arguments is not defined

3.3 三种更多类型:我们有以下内容:

  • fn1(y: number) => (z: number) => number

  • fn2(z: number) => number

  • fn3 只是 number

3.4 一行代码即可解决问题:它确实有效!(是的,在这种情况下,一行回答是合适的!)

3.5 State,我们将有一个包含你应用程序所需所有字段的对象。对于通用版本,我们可以写出以下内容,但具体的描述会更好:

type State = Record<string, unknown>;

我们会用类似以下的方式定义所有可能的行为类型:

type ActionType = "CREATE" | "DELETE" | "UPDATE";

我们将有一个包含 type 和可选 payload 的动作对象:

type Action = {
  type: ActionType;
  payload: Record<string, unknown> | null;
};

(如果你能详细定义可能有的有效负载,而不是像前面代码中那样使用通用定义,那会好得多。)

我们的 doAction() 函数如下所示:

function doAction(state: State, action: Action) {
  const newState: State = {};
  switch (action?.type) {
    …
  }
}

对于 dispatchTable,我们将有如下内容:

const dispatchTable: Record<
  ActionType,
  (state: State, action: Action) => State
> = {
  CREATE: (state, action) => {
    // update state, generating newState,
    // depending on the action data
    // to create a new item
    const NewState: State = {
      /* updated State */
    };
    return NewState;
  },
  …
};

最后,我们会写出以下内容:

function doAction2(state: State, action: Action) {
  return dispatchTable[action.type]
    ? dispatchTableaction.type
    : state;
}

3.6 console(...), window.store.set(...)) 代码,但错误并不在那里:因为逗号操作符的工作方式,JavaScript 首先执行日志记录,然后是设置。真正的问题是 oldSet() 没有绑定到 window.store 对象,所以第二行应该如下所示:

const oldSet = window.store.set.bind(window.store);

重新阅读 使用方法 部分,了解更多相关信息,并查看 问题 11.1 了解另一种日志记录方式——即使用装饰器。

3.7 bind()不可用,你可以使用闭包,that技巧(我们在处理 this 值部分看到过),以及apply()方法,如下所示:

// question_03_bindless_binding.ts
function bind(context) {
  var that = this;
  return function() {
    return that.apply(context, arguments);
  };
}

我们可以做一些类似于我们在添加缺失函数部分所做的事情。

或者,为了变化,我们可以使用基于||操作符的常用惯用语:如果Function.prototype.bind存在,评估将立即停止,并使用现有的bind()方法;否则,应用我们新的函数:

Function.prototype.bind =
  Function.prototype.bind || function(context) {
  var that = this;
  return function() {
    return that.apply(context, arguments);
  };
};

3.8 compare(a,b)比较函数必须返回一个正数,如果a>b,一个负数,如果a<b,或者0,如果a等于b。当你从ab中减去时,你会得到那个结果,所以它有效。(当然,这假设没有任何数字是InfinityNaN。)关于这一点,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#description

3.9 -”,所以这有点正确,但数字本身仍然是按字符串排序的,所以无论如何结果都是错误的。在以下示例中,最低的数字是-666,在排序后应该是第一个元素:

let someNumbers = [3, 20, 100, -44, -5, -666];
someNumbers.sort();  // [-44, -5, -666, 100, 20, 3]

3.10 字典序排序:假设我们有一个字符串数组。为了高效地按字典序排序,一个解决方案如下:

  1. 将字符串数组转换为具有添加的sortBy字段的对象数组。

  2. 对于每个字符串,生成相应的用于排序的字符串,并将值放入sortBy字段。

  3. 按照sortBy排序数组。

  4. 删除添加的字段,将排序后的数组转换为原始的字符串数组。

3.11 console.log()方法可以接受任何数量和类型的参数,并且不会返回任何内容,所以它的类型是(...args: any[]): void

第四章,行为规范 – 纯函数

4.1 必须返回吗?如果一个纯函数不返回任何内容,这意味着该函数没有做任何事情,因为它不能修改其输入,并且没有其他副作用。

4.2 string | undefined,因为.pop()方法在输入数组为空时返回undefined

4.3 fib2()在 IIFE 中;fibC()fib2()等效,但有一个内部的cache

// question_04_go_for_a_closure.ts
const fibC = (() => {
  const cache: number[] = [];
  const fib2 = (n: number): number => {
    if (cache[n] === undefined) {
      if (n === 0) {
        cache[0] = 0;
      } else if (n === 1) {
        cache[1] = 1;
      } else {
        cache[n] = fib2(n - 2) + fib2(n - 1);
      }
    }
    return cache[n];
  };
  return fib2;
})();

4.4 最小化函数:它之所以有效,是因为fib(0)=0fib(1)=1,所以对于*n*<2fib(*n*)等于*n*

4.5 ab代表两个连续的斐波那契数。这种实现相当高效!

4.6 舍入类型:完整的定义,包括结果类型,如下所示:

const roundFix2 = (
  accum: number,
  n: number
): {
  accum: number;
  nRounded: number;
} => ...

4.7 元组传递:在这种情况下,我们将返回一个包含两个数字的数组,因此我们可以写出以下内容:

// question_04_tuples_to_go.ts
type AccumRoundedType = [number, number];
const roundFix2a = (
  accum: number,
  n: number
): AccumRoundedType => {
  const nRounded = accum > 0 ? Math.ceil(n) :
    Math.floor(n);
  accum += n - nRounded;
  return [accum, nRounded];
};
const roundFix2b = ([
  accum,
  n,
]: AccumRoundedType): AccumRoundedType => {
  const nRounded = accum > 0 ? Math.ceil(n) :
    Math.floor(n);
  accum += n - nRounded;
  return [accum, nRounded];
};

测试与我们已经写过的非常相似;这里,我们有我们之前代码的简略版本,突出显示所需的变化:

// question_04_tuples_to_go.test.ts
describe("roundFix2a", function () {
  it("rounds 3.14159->3 if differences are 0", () => {
    const [accum, nRounded] = roundFix2a(0.0, 3.14159);
    expect(accum).toBeCloseTo(0.14159);
    expect(nRounded).toBe(3);
  });
  it("rounds 2.71828->3 if differences are 0.14159", () =>
    {
    const [accum, nRounded] = roundFix2a(0.14159, 2.71828);
    expect(accum).toBeCloseTo(-0.14013);
    expect(nRounded).toBe(3);
  });
});
describe("roundFix2b", function () {
  it("rounds 2.71828->2 if differences are -0.14013", () =>
    {
    const [accum, nRounded] = roundFix2b([
      -0.14013, 2.71828,
    ]);
    expect(accum).toBeCloseTo(0.57815);
    expect(nRounded).toBe(2);
  });
  it("rounds 3.14159->4 if differences are 0.57815", () =>
    {
    const [accum, nRounded] = roundFix2b([
      0.57815, 3.14159,
    ]);
    expect(accum).toBeCloseTo(-0.28026);
    expect(nRounded).toBe(4);
  });
});

4.8 calculateDeb2()仍然会尝试调用 API。提供一个包含依赖项的对象使注入成为一个全有或全无的选项。

4.9 "Math failure?" 消息。这个问题与 JavaScript 内部使用二进制而不是十进制有关,并且浮点精度有限。在十进制中,0.1、0.2 和 0.3 有一个固定的、简短的表示,但在二进制中,它们有无限的表示,就像十进制中的 1/3=0.33333... 一样。如果你在测试后写出 a+b 的值,你会得到 0.30000000000000004 – 这就是为什么在 JavaScript 中测试相等性时必须非常小心。

4.10 违反规则:一些属性不再总是有效。为了简化我们的示例,让我们假设两个数字如果它们之间的差异不超过 0.1,则彼此接近。如果是这样,那么我们就有以下情况:

  • 0.5 接近 0.6,0.6 接近 0.7,但 0.5 不接近 0.7

  • 0.5 接近 0.6,0.7 接近 0.8,但 0.5+0.7=1.2 不接近 0.6+0.8=1.4,并且 0.50.7=0.35* 也不接近 0.60.8=0.48*。

  • 0.5 接近 0.4,0.2 接近 0.3,但 0.5-0.2=0.3 不接近 0.4-0.3=0.1,并且 0.5/0.2=2.5 也不接近 0.4/0.3=1.333

另一些引用的属性始终有效。

4.11 打乱类型:这种类型定义允许我们的函数处理任何类型的数组(字符串、数字、对象等),并说明输出数组的类型将与输入数组的类型相同。

4.12 <T>(arr: T[]) => void。更多信息请参阅 www.typescriptlang.org/docs/handbook/2/functions.html。

在它上面使用 JSON.stringify() 并保存结果。在打乱顺序后,对打乱顺序的数组的一个副本进行排序,并再次使用 JSON.stringify()。这两个 JSON 字符串应该相等。这消除了所有其他测试,因为它确保数组不会改变其长度或元素,这也适用于包含重复元素的数组:

// question_04_a_shuffle_test.test.ts
describe("shuffleTest", function () {
  it("doesn't change the array length or elements", () => {
    const a = [22, 9, 60, 22, 12, 4, 56, 22, 60];
    const oldA = JSON.stringify([...a].sort());
    shuffle(a);
    const newA = JSON.stringify([...a].sort());
    expect(oldA).toBe(newA);
  });
});

4.14 shuffle 函数工作良好,一个想法是多次打乱一个小数组,并计算出现多少种可能的输出;最终的计数应该相似,尽管不一定相等(因为随机因素),在我的文章 blog.openreplay.com/forever-functional-shuffling-an-array-not-as-trivial-as-it-sounds/ 中,我通过打乱一个四个字母(A 到 D)的数组 24,000 次来测试 Fisher–Yates 算法,并得到了以下结果:

所有可能的 24 种排序都产生了(见 第一章**,成为函数式程序员中的 递归 部分),并且结果都相当接近 1,000;最高和最低计数之间的差异只有大约 10%。这并不是彻底的统计确认 – 对于那,我们得应用统计频率测试,如 χ²(卡方)、Kolmogorov–Smirnov 或 Anderson–Darling – 但至少我们得到了一个概念,即打乱并没有工作得很差。

当我应用(据称是好的!)算法时,计数结果更加不平衡:

最高计数是最低计数的 14 倍以上;我们可以肯定地说,并非所有排列的可能性都是相等的,因此流行的洗牌算法显然是不够好的。

4.15 通过排序进行洗牌:为了得到一个随机序列,我们可以给每个数组元素分配一个随机数,并按该数进行排序;结果将是一个完全随机的洗牌:

// question_04_shuffling_by_sorting.ts
const sortingShuffle = <T>(arr: T[]): T[] =>
  arr
    .map((v) => ({ val: v, key: Math.random() }))
    .sort((a, b) => a.key - b.key)
    .map((o) => o.val);

第一个 .map() 将每个数组元素转换为一个对象,其中原始值在 val,随机值在 key。然后我们按 key 值对数组进行排序,使用 问题 3.8 中显示的技术。最后,我们撤销第一个映射,只得到原始值。

最后的评论:这段代码确实是函数式的,并返回一个新的数组,而不是在原地修改原始参数。

第五章,声明式编程 – 更好的风格

5.1 filter()map()reduce(),但这个问题的目的是让你思考如何仅使用这些方法来管理。使用 join() 或其他额外的字符串函数会使问题更容易解决。例如,找出添加包围 <div><ul> ... </ul></div> 标签的方法是棘手的,因此我们必须使第一个 reduce() 操作产生一个数组,这样我们就可以继续工作:

const characters = [
  { name: "Fred", plays: "bowling" },
  { name: "Barney", plays: "chess" },
  { name: "Wilma", plays: "bridge" },
  { name: "Betty", plays: "checkers" },
  { name: "Pebbles", plays: "chess" },
];
const list = characters
  .filter(
    (x) => x.plays === "chess" || x.plays == "checkers"
  )
  .map((x) => `<li>${x.name}</li>`)
  .reduce((a, x) => [a[0] + x], [""])
  .map((x) => `<div><ul>${x}</ul></div>`)
  .reduce((a, x) => x);
console.log(list);
/* Output is a single line; here output is wrapped
<div><ul><li>Barney</li><li>Betty</li><li>Pebbles</li>
</ul></div>
*/

访问 map()reduce() 回调的数组参数和索引参数也会提供解决方案:

const list2 = characters
  .filter(
    (x) => x.plays === "chess" || x.plays == "checkers"
  )
  .map(
    (x, i, t) =>
      `${i === 0 ? "<div><ul>" : ""}` +
      `<li>${x.name}</li>` +
      `${i == t.length - 1 ? "</ul></div>" : ""}`
  )
  .reduce((a, x) => a + x, "");
// exact same result

我们还可以这样做:

const list3 = characters
  .filter(
    (x) => x.plays === "chess" || x.plays == "checkers"
  )
  .map((x) => `<li>${x.name}</li>`)
  .reduce(
    (a, x, i, t) =>
      a + x + (i === t.length - 1 ? "</ul></div>" : ""),
    "<div><ul>"
  );
// again, the same result

研究这三个示例:它们将帮助你深入了解这些高阶函数,并提供你独立工作的想法。

5.2 map() 方法和新 myMap() 函数,但不是使用 JSON.stringify(),而是使用 Jest 的 toEqual() 方法来比较结果。有关更多信息,请参阅 问题 5.5 的答案。

5.3 sum() 函数,这样 TypeScript 不会反对。箭头函数不支持重载,因此我们必须改变定义 sum() 的方式:

function sum(x: number, y: number): number;
function sum(x: string, y: string): string;
function sum(x: any, y: any): string | number {
  return x + y;
}

现在 reverseString2() 可以正常工作,并且对数字数组求和也可以正常工作:

const reverseString2 = (str: string): string =>
  str.split("").reduceRight(sum, "");
console.log(reverseString2("MONTEVIDEO"));
// OEDIVETNOM
const myArray = [22, 9, 60, 12, 4, 56];
console.log(myArray.reduce(sum, 0));
// 163

如果你尝试执行 sum(22,"X")sum(false,{a:1}) 这样的操作,TypeScript 会拒绝它,因为它不会匹配定义的重载:

describe("myMap", () => {
  const myArray = [22, 9, 60, 12, 4, 56];
  it("duplicates values", () => {
    const dup = (x: number): number => 2 * x;
    expect(myArray.map(dup)).toEqual(myMap(myArray, dup));
  });
  it("add dashes", () => {
    const addDashes = (x: number): string => `-${x}-`;
    expect(myArray.map(addDashes)).toEqual(
      myMap(myArray, addDashes)
    );
  });
});

5.4 反转反转? 在这种情况下,它将返回与输入字符串相同的输出字符串;检查一下!

5.5 1 用于前者,-1 用于后者。我们使用了 Math.sign() 来实现这一点:

const range2 = (
  from: number,
  to: number,
  step = Math.sign(to - from)
): number[] => {
  const arr = [];
  do {
    arr.push(from);
    from += step;
  } while (
    (step > 0 && to > from) ||
    (step < 0 && to < from)
  );
  return arr;
};

另一种实现方式首先计算所需的数组大小,然后使用 fill()map() 来填充它。我们必须小心,如果 startstop 相等,以避免除以零:

const range2b = (
  start: number,
  stop: number,
  step: number = Math.sign(stop - start)
): number[] =>
  new Array(
    step === 0 ? 1 : Math.ceil((stop - start) / step)
  )
    .fill(0)
    .map((v, i) => start + i * step);

一些计算范围的示例显示了我们在选项方面的多样性:

range2(1, 10);        // [1, 2, 3, 4, 5, 6, 7, 8, 9]
range2(1, 10, 2);     // [1, 3, 5, 7, 9]
range2(1, 10, 3);     // [1, 4, 7]
range2(1, 10, 6);     // [1, 7]
range2(1, 10, 11);    // [1]
range2(21, 10);       // [21, 20, 19, ... 13, 12, 11]
range2(21, 10, -3);   // [21, 18, 15, 12]
range2(21, 10, -4);   // [21, 17, 13]
range2(21, 10, -7);   // [21, 14]
range2(21, 10, -12);  // [21]

编写 Jest 测试很简单;以下代码仅显示了前面代码的三个案例:

describe("range2()", () => {
  it("works from 1 to 10", () =>
    expect(range2(1, 10)).toEqual([
      1, 2, 3, 4, 5, 6, 7, 8, 9,
    ]));
  it("works from 1 to 10 by 2", () =>
    expect(range2(1, 10, 2)).toEqual([1, 3, 5, 7, 9]));
  it("works from 21 down to 10 by -4", () =>
    expect(range2(21, 10, -4)).toEqual([21, 17, 13]));
});

使用这个新的 range2() 函数意味着你可以以函数式的方式编写更多样化的循环,无需使用 for(...) 语句。

5.6 from) 然后通过累加 step 值来更新它,直到结果值超出范围:

function* range4(
  from: number,
  to: number,
  step: number = Math.sign(to - from)
): Generator<number> {
  do {
    yield from;
    from += step;
  } while (
    (step > 0 && to >= from) ||
    (step < 0 && to <= from)
  );
}

我们可以用几种不同的方式为这个函数编写测试:手动多次调用生成器,使用扩展运算符一次性获取所有值,或者使用 for..of 构造。

describe("range4", () => {
  it("generates 2..5", () => {
    const range = range4(2, 5);
    expect(range.next().value).toBe(2);
    expect(range.next().value).toBe(3);
    expect(range.next().value).toBe(4);
    expect(range.next().value).toBe(5);
    expect(range.next().value).toBe(undefined);
  });
  it("generates 5..2", () => {
    const range = range4(5, 2);
    expect([...range]).toEqual([5, 4, 3, 2]);
  });
  it("generates 1..10 by 2", () => {
    const numbers = [];
    for (const i of range4(1, 10, 2)) {
      numbers.push(i);
    }
    expect(numbers).toEqual([1, 3, 5, 7, 9]);
  });
});

5.7 String.fromCharCode() 不是一元函数;它可以接收任意数量的参数。当你写 map(String.fromCharCode) 时,回调函数会接收到三个参数(当前值、索引和数组),这会导致意外的结果。使用来自 Arity changing 部分的 unary(),在 第六章**,生成函数 也会工作。要了解更多信息,请访问 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/fromCharCode

5.8 生成 CSV:一个初步解决方案,以及一些辅助函数,如下所示;你能理解每个函数的作用吗?

const myData = [
  [1, 2, 3, 4],
  [5, 6, 7, 8],
  [9, 10, 11, 12],
];
const concatNumbers = (a: string, b: number): string =>
  !a ? `${b}` : `${a},${b}`;
const concatLines = (c: string, d: string): string =>
  c + "\n" + d;
const makeCSV = (data: number[][]) =>
  data
    .map((x) => x.reduce(concatNumbers, ""))
    .reduce(concatLines, "");
console.log(makeCSV(myData));
/*
1,2,3,4
5,6,7,8
9,10,11,12
*/

可能有一个替代的一行解决方案,但不是很清晰——你同意吗?

const makeCSV2 = (data: number[][]) =>
  data
    .map((x: number[]) =>
      x.reduce(
        (a: string, b: number): string =>
          !a ? `${b}` : `${a},${b}`,
        ""
      )
    )
    .reduce((c: string, d: string) => c + "\n" + d, "");

5.9 flat1()flat2() 依赖于 flatOne()。如果该函数(在其两个提供的实现中的任何一个)发现一个空数组位置,它不会将其输出 concat() 到任何东西。

5.10 生成更好的输出:为此,你可能需要进行一些额外的映射,如下所示:

const better = apiAnswer
  .flatMap((c) =>
    c.states.map((s) => ({ ...s, country: c.name }))
  )
  .flatMap((s) =>
    s.cities.map((t) => ({
      ...t,
      state: s.name,
      country: s.country,
    }))
  )
  .map((t) => `${t.name}, ${t.state}, ${t.country}`);
console.log(better);
/*
[
  'Lincoln, Buenos Aires, Argentine',
  'Lincoln, England, Great Britain',
  'Lincoln, California, United States of America',
  'Lincoln, Rhode Island, United States of America',
  'Lincolnia, Virginia, United States of America',
  'Lincoln Park, Michigan, United States of America',
  'Lincoln, Nebraska, United States of America',
  'Lincoln Park, Illinois, United States of America',
  'Lincoln Square, Illinois, United States of America'
]
*/

5.11 使用 join() 将单个长字符串构建出来,使用 split() 将该字符串分割成单词,最后查看结果数组的长度:

const words = gettysburg.join(" ").split(" ").length; // 270

5.12 Boolean(x)!!x 相同,将表达式从 truthyfalsy 转换为 truefalse,因此 filter() 操作从数组中移除了所有 falsy 元素。

5.13 fact4(0) 返回预期的 1。调用 range(1,1) 产生一个空数组,因此原始的 result (1) 值被返回,没有进一步的变化。

5.14 forEach()map() 等等,并开发了一个允许链式调用的 async 数组类。

5.15 使用 mapAsync() 获取异步值并将原始函数应用于返回的数组。对于 some() 的一个示例如下:

const someAsync = <T>(
  arr: T[],
  fn: (x: T) => Promise<boolean>
) =>
  mapAsync(arr, fn).then((mapped) => mapped.some(Boolean));

我们可以用两种不同的方式为这个函数编写测试:等待调用的结果,或者使用 Jest 的 .resolves 来编写更短的代码:

describe("someAsync", () => {
  it("succeeds if sometimes OK", async () => {
    const someEven = await someAsync(
      [1, 2, 3, 4],
      fakeFilter
    );
    expect(someEven).toBeTruthy();
  });
  it("fails if never OK", () => {
    expect(
      someAsync([1, 3, 5, 7, 9], fakeFilter)
    ).resolves.toBeFalsy();
  });
});

5.16 在调用 workerCall() 或当我们重置一个工作线程使其不再使用时。让我们选择第二个方案,以便尽可能快地完成调用。我们将添加一个 MAX_NOT_IN_USE 常量,其阈值是未使用的工作线程数量,并添加一个 notInUse() 断言作为重构:

const notInUse = (p: PoolEntry): boolean => !p.inUse;
const MAX_NOT_IN_USE = 10;

然后,我们将 workerCall() 函数的最后部分修改如下:

  return new Promise((resolve) => {
    available!.inUse = true;
    available!.worker.on("message", (x) => {
      resolve(x);
      available!.inUse = false;
      while (
        pool.filter(notInUse).length > MAX_NOT_IN_USE
      ) {
        const notUsed = pool.findIndex(notInUse);
        pool[notUsed].worker.terminate();
        pool.splice(notUsed, 1);
      }
    });
    available!.worker.postMessage(value);
  });

当未使用的工作线程数量超过我们的限制时,我们会找到一个工作线程来移除,terminate() 它,并从工作线程池中移除。

5.17 为池排队:这个问题有一个有趣的方式来处理承诺作为屏障,最初拒绝但最终允许程序通过。想法是在向池中添加工人之前检查运行中的工人数量是否太多。如果是这样,就按以前的方式进行,如果不是,就将某些东西添加到队列中(我们稍后会看到是什么),这样我们就可以稍后运行该工人。每当工人响应时,我们将检查队列中是否有任何东西允许它运行。

我们首先添加三件事:

const queue: ((v?: any) => void)[] = [];
let running = 0;
const MAX_TO_USE = 5;

我们处理等待的方式是通过创建一个承诺,我们将在稍后某个时间点最终解决它。这解释了奇怪的queue数据类型,它包含了解决函数。running变量将计算正在运行的工人数,而MAX_TO_USErunning可能的最大值。

要与队列一起工作,我们将有两个函数:

const enqueue = (resolve2: (v?: any) => void) => {
  if (running < MAX_TO_USE) {
    running++;
    resolve2();
  } else {
    queue.push(resolve2);
  }
};
const dequeue = () => {
  const resolve2 = queue.shift();
  resolve2 && resolve2();
};

enqueue()函数检查正在运行的工人数;如果少于MAX_TO_USE,则增加running(因为一个工人将要运行)然后调用resolve2()以允许相应的请求前进。如果有太多的正在运行的工人,则将需要调用的函数推入队列。dequeue()函数只是尝试从队列中获取前面的元素,如果有东西,它将调用出列的值以允许队列中的请求继续。

修改后的workerCall()函数现在如下所示:

export const workerCall = (
  filename: string,
  value: any
): Promise<any> => {
  return new Promise((resolve) => {
    new Promise((resolve2) => enqueue(resolve2)).then(
      () => {
        let available = pool
          .filter(notInUse)
          .find((x) => x.filename === filename);
        if (available === undefined) {
          available = {
            worker: new Worker(filename),
            filename,
            value,
            inUse: true,
          } as PoolEntry;
          pool.push(available);
        }
        available!.inUse = true;
        available!.worker.on("message", (x) => {
          resolve(x);
          available!.inUse = false;
          dequeue();
        });
        available!.worker.postMessage(value);
      }
    );
  });
};

new Promise((resolve2) => …)这一行是我们提到的屏障;只有当其resolve2()函数被调用时,它才会允许工作继续(在then()中)——这可以通过enqueue()(如果有很少的正在运行的工人)或dequeue()(当某个之前正在运行的工人结束时)来完成。

5.18 显示结果:基本上,给定一个字符串,它返回一个期望单个参数的日志函数,并列出该字符串和参数。我们将在第六章 生产函数中看到实现类似结果的其他方法。

5.19 filter()遍历所有工人,然后find()遍历过滤后的那些。这可以通过以下方式在单次遍历中实现:

  let available = pool
    .find((v) => !v.inUse && v.filename === filename);

5.20 "error"事件,如果工人在执行过程中发生错误,则会触发。在这种情况下,应该将工人标记为未使用(因为它已经完成了工作)并且应该拒绝承诺。workerCall()函数的最后部分应该看起来像这样:

  return new Promise((resolve, reject) => {
    available!.inUse = true;
    available!.worker.on("message", (x) => {
      resolve(x);
      available!.inUse = false;
    });
    available!.worker.on("error", (x) => {
      reject(x);
      available!.inUse = false;
    });
    available!.worker.postMessage(value);
  });

对于一个“工业级”的库,你应该处理所有可能的事件;查看 developer.mozilla.org/en-US/docs/Web/API/Worker#events 和 nodejs.org/api/worker_threads.html#class-worker 以获取更多相关信息。

第六章,生产函数 – 高阶函数

6.1 使用箭头:只需要做些小的改动:

const addLogging = <T extends (...args: any[]) => any>(
  fn: T
): ((...args: Parameters<T>) => ReturnType<T>) => {
  return (...args: Parameters<T>): ReturnType<T> => {
    console.log(`entering ${fn.name}(${args})`);
    const valueToReturn = fn(...args);
    console.log(`exiting  ${fn.name}=>${valueToReturn}`);
    return valueToReturn;
  };
};

6.2 memoize4()。我们不是使用对象作为cache,而是创建一个映射。我们检查映射中是否有搜索到的strX键,在调用原始函数后设置新值,并从缓存中获取返回值。return中的as部分是为了让 TypeScript 知道get()将成功,因为搜索不会失败:

const memoize4 = <T extends (...x: any[]) => any>(
  fn: T
): ((...x: Parameters<T>) => ReturnType<T>) => {
  const cache = new Map() as Map<string, ReturnType<T>>;
  return (...args) => {
    const strX = JSON.stringify(args);
    if (!cache.has(strX)) {
      cache.set(strX, fn(...args));
    }
    return cache.get(strX) as ReturnType<T>;
  };
};

6.3 calc(n)是评估fib(n)所需的调用次数。分析显示所有必要计算的树,我们得到以下结果:

  • calc(0)=1

  • calc(1)=1

  • 对于n>1,calc(n)=1 + calc(n-1) + calc(n-2)

最后一行遵循这样一个事实:当我们调用fib(n)时,我们有一个调用,加上对fib(n-1)fib(n-2)的调用。电子表格显示calc(50)是 40,730,022,147 – 相当高!

如果你关心一些代数,可以证明 calc(n)=5fib(n-1)+fib(n-4)-1,或者随着n的增长,calc(n)大约是(1+√5)=3.236 倍 fib(n)的值 – 但由于这不是一本数学书,我不会提及这些结果!

6.4 shuffle()函数来自第四章行为规范,我们可以编写以下代码。在打乱其余函数之前,我们从列表中删除第一个函数,并在数组的末尾将其添加回来,以避免重复调用:

const randomizer =
  <T extends (...x: any[]) => any>(...fns: T[]) =>
  (
    ...args: Parameters<T>
  ): ((...args: Parameters<T>) => ReturnType<T>) => {
    const first: T = fns.shift() as T;
    fns = shuffle(fns);
    fns.push(first);
    return fns0;
  };

当我们将值赋给first时,需要添加as T;否则,TypeScript 会提出异议,因为如果fns为空,fns.shift()将返回undefined。检查fns不为空也不是一个坏主意;你能添加它吗?

快速验证表明它满足我们所有的要求:

const say1 = () => console.log(1);
const say22 = () => console.log(22);
const say333 = () => console.log(333);
const say4444 = () => console.log(4444);
const rrr = randomizer(say1, say22, say333, say4444);
rrr(); // 333
rrr(); // 4444
rrr(); // 333
rrr(); // 22
rrr(); // 333
rrr(); // 22
rrr(); // 333
rrr(); // 4444
rrr(); // 1
rrr(); // 4444

一个小考虑:由于randomizer()的编写方式,列表中的第一个函数在第一次调用时永远不会被调用。你能提供一个更好的版本,这样就不会有这个轻微的缺陷,以便列表中的所有函数都有相同的机会在第一次被调用吗?

6.5 不在 TypeScript 中:以下代码可以完成任务。函数之间的唯一区别是,一个与返回布尔值的函数一起工作,另一个与返回数字值的函数一起工作:

const not =
  <T extends (...args: any[]) => boolean>(fn: T) =>
  (...args: Parameters<T>): boolean =>
    !fn(...args);
const invert =
  <T extends (...args: any[]) => number>(fn: T) =>
  (...args: Parameters<T>): number =>
    -fn(...args);

6.6 使用typeof检查返回值是数字还是布尔值,在决定返回什么之前。我们必须声明输入函数要么是返回布尔值的函数,要么是返回数字值的函数:

const opposite =
  <T extends (...args: any[]) => number | boolean>(fn: T)
    =>
  (…args: Parameters<T>): ReturnType<T> => {
    const result = fn(...args);
    return (
      typeof result === "boolean" ? !result : -result
    ) as any;
  };

6.7 反转测试:我们可以快速将文本中显示的示例转换为实际测试;我们将把它留给你自己来编写更多测试:

import { invert } from "../invert";
describe("invert", () => {
  it("can be used to sort Spanish words", () => {
    const spanishComparison = (
      a: string,
      b: string
    ): number => a.localeCompare(b, "es");
    const palabras = [
   "  "ñandú",
   "  "oasis",
   "  "mano",
   "  "natural",
   "  "mítico",
   "  "musical",
    ];
    expect(
      palabras.sort(invert(spanishComparison))
    ).toEqual([
   "  "oasis",
   "  "ñandú",
   "  "natural",
   "  "musical",
   "  "mítico",
   "  "mano",
    ]);
  });
});

6.8 filter()期望接收一个具有三个参数(AnumberA[]类型)的函数,而not(fn)的类型不匹配。

6.9 eval() – 通常不是一个好主意!尽管如此,如果你坚持并坚持,我们可以编写一个保留function.length版本的arity(),如下所示;让我们称它为arityL()

import { range } from "../../chapter 05/range";
function arityL<T extends (...args: any[]) => any>(
  n: number,
  fn: T
): (...x: Parameters<T>) => ReturnType<T> {
  const args1n = range(0, n)
    .map((i) => `x${i}`)
    .join(",");
  return eval(`(${args1n}) => ${fn.name}(${args1n})`);
}

如果你将arityL()应用于Number.parseInt,结果如下。产生的函数具有正确的length属性,它们的实际实现已在注释中给出:

const parseInt1 = arityL(parseInt, 1);
// (x0) => parseInt(x0,x1) parseInt1.length === 1
const parseInt2 = arity(Number.parseInt,2)
// (x0,x1) => parseInt(x0,x1) parseInt2.length === 2

然而,请注意,TypeScript 无法确定结果的函数类型,因为这将是在运行时知道的。

6.10 许多变元!如果我们只使用 JavaScript,以下就会这样做:

const binary = (fn) => (...a) => fn(a[0], a[1]);
const ternary = (fn) => (...a) => fn(a[0], a[1], a[2]);

添加数据类型,我们得到以下:

const binary =
  <T extends (...x: any[]) => any>(
    fn: T
  ): ((
    arg0: Parameters<T>[0],
    arg1: Parameters<T>[1]
  ) => ReturnType<T>) =>
  (x, y) =>
    fn(x, y);
const ternary =
  <T extends (...x: any[]) => any>(
    fn: T
  ): ((
    arg0: Parameters<T>[0],
    arg1: Parameters<T>[1],
    arg2: Parameters<T>[2]
  ) => ReturnType<T>) =>
  (x, y, z) =>
    fn(x, y, z);

6.11 节流承诺:每次我们实际进行调用时,我们都会设置一个计时器,该计时器最终会从缓存中删除承诺。默认情况下,让我们有一个 5 分钟的延迟。我们将有一个计时器池,每个承诺一个计时器。在调用 API 时发生错误的情况下,我们将删除被拒绝的承诺及其对应的计时器:

const promiseThrottle = <
  A,
  T extends (...x: any[]) => Promise<A>
>(
  fn: T,
  delay = 300_000  /* 5 minutes */
): ((...x: Parameters<T>) => Promise<A>) => {
  const cache = {} as Record<string, Promise<A>>;
  const timers = {} as Record<
    string,
    ReturnType<typeof setTimeout>
  >;
  return (...args) => {
    const strX = JSON.stringify(args);
    if (!(strX in timers)) {
      timers[strX] = setTimeout(() => {
        delete cache[strX];
        delete timers[strX];
      }, delay);
    }
    return strX in cache
      ? cache[strX]
      : (cache[strX] = fn(...args).catch((x) => {
          delete cache[strX];
          delete timers[strX];
          return x;
        }));
  };
};

6.12 +, -, *, /, **, 和 %),所有位运算符(&, |, 和 ^),所有逻辑运算符(&&||),所有位移运算符(<<, >>, 和 >>>),所有比较运算符(>, >=, <, <=, ==, ===, !=, 和 !==),以及新的空值合并运算符(??)。逗号运算符也可以包括在内。有关更多关于这个主题的信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Expressions_and_Operators

6.13 缺少伴随者:一个简单的单行版本可以是以下这样。在这里,我们使用展开操作来获取原始对象的一个浅拷贝,然后通过使用计算属性名来设置指定的属性为新值。有关更多详细信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer

const setField = <D>(
  attr: keyof D,
  value: any,
  obj: D
) => ({
  ...obj,
  [attr]: value,
});

第十章确保纯净性中,我们写了deepCopy(),当涉及到创建一个全新的对象而不是浅拷贝时,这会比展开操作更好。通过使用这个,我们会有以下:

const setField2 = <D>(
  attr: keyof D,
  value: any,
  obj: D
) => ({
  ...deepCopy(obj),
  [attr]: value,
});

最后,你也可以考虑修改updateObject()函数,它也来自第十章确保纯净性,通过删除冻结代码;我将把它留给你。

6.14 null 对象会抛出错误:

const getField = attr => obj => obj[attr];
getField("someField")(null);
// Uncaught TypeError: Cannot read property 'a' of null

使用 TypeScript,代码将无法编译,因为首先"someField"不是属性名,其次null不是一个有效的对象:

const getField =
  <D>(f: keyof D) =>
  (obj: D) =>
    obj[f];

然而,仍然可以在 TypeScript 的背后做一些事情,并使代码被接受,异常被抛出。在函数中抛出异常通常在 FP(函数式编程)中不是很好。你可以选择产生undefined,或者与 monads 一起工作,就像在第十二章构建更好的容器中一样。getField()的一个更安全的版本会添加一个保护措施,并返回obj && obj[f]

6.15 类型化去方法化:三个完整的定义如下:

const demethodize1 =
  <T extends (arg0: any, ...args: any[]) => any>(fn: T) =>
  (arg0: any, ...args: Parameters<T>) =>
    fn.apply(arg0, args);
const demethodize2 =
  <T extends (arg0: any, ...args: any[]) => any>(fn: T) =>
  (arg0: any, ...args: Parameters<T>): ReturnType<T> =>
    fn.call(arg0, ...args);
const demethodize3 =
  <T extends (arg0: any, ...args: any[]) => any>(fn: T) =>
  (arg0: any, ...args: Parameters<T>): ReturnType<T> =>
    fn.bind(arg0, ...args)();

6.16 Math.max()Math.min() 如下:

const findMaximum2 = findOptimum2((x, y) => Math.max(x,
  y));
const findMinimum2 = findOptimum2((x, y) => Math.min(x,
  y));

另一种写法可以是首先定义以下内容:

const max = (...arr: number[]): number => Math.max(...arr);
const min = (...arr: number[]): number => Math.min(...arr);

然后,我们可以用无点风格来写:

const findMaximum3 = findOptimum2(max);
const findMinimum3 = findOptimum2(min);

6.17 比较英雄:第一个建议的更改不会允许某些特征中出现平局,即没有英雄能打败另一个。实际上,这指出了我们逻辑中的一个问题;如果第一个英雄没有打败第二个,我们就假设后者打败了前者,不允许英雄之间有平局。

第七章,函数转换——柯里化和部分应用

7.1 sum(3) 返回一个已经绑定 3 的函数;sum(3)() 返回相同的结果,而 sum(3)()(5) 产生结果。

7.2 sumMany() 函数完成了工作:

const sumMany = (total: number) => (value?: number) =>
  value === undefined ? total : sumMany(total + value);
sumMany(2)(2)(9)(6)(0)(-3)(); // 16

在 JavaScript 中,这个函数没有问题;但在 TypeScript 中,我们会遇到一个反对意见,因为它无法确定 sumMany(2) 是一个函数,而不是一个数字。

一个小细节:你能修复函数,让 sumMany() 返回 0 吗?

7.3 使用 eval() 柯里化?让我们首先在 JavaScript 中看看这个例子:

// curryByEval.js
function curryByEval(fn) {
  return eval(`${range(0, fn.length)
    .map((i) => `x${i}`)
    .join("=>")} => ${fn.name}(${range(0, fn.length)
    .map((i) => `x${i}`)
    .join(",")})`);
}

这是一段相当难消化的代码,实际上,它应该被编码在几行中,以便更容易理解。让我们看看当它应用于 make3() 函数作为输入时是如何工作的:

  1. range() 函数生成一个包含 [``0,1,2] 值的数组。

  2. 我们使用 map() 生成一个包含 ["``x0","x1","x2"] 值的新数组。

  3. 我们在这个数组中的值上使用 join() 生成 x0=>x1=>x2,这将是我们将要评估的代码的开始。

  4. 然后我们添加一个箭头,函数的名称,以及一个开括号,来构成我们新生成代码的中间部分:=> make3(

  5. 我们再次使用 range()map()join(),但这次是为了生成一个参数列表:x0,x1,x2

  6. 我们最终添加一个闭括号,然后应用 eval(),我们得到了 make3() 的柯里化版本。

在遵循所有这些步骤之后,在我们的例子中,生成的函数如下:

curryByEval(make3); // x0=>x1=>x2 => make3(x0,x1,x2)

类型基本上与我们的 curry() 函数相同,因为我们得到相同的参数和相同的输出。然而,请注意,我们绝对是在对 TypeScript “撒谎”,因为它无法推断出 eval() 返回了什么;这完全取决于我们不要出错!无需多言,我们可以写出以下内容:

function curryByEval<A extends any[], R>(
  fn: (...args: A) => R
): Curry<A, R>;
function curryByEval(fn: (...args: any) => any) {
  const pp = `${range(0, fn.length)
    .map((i) => `x${i}`)
    .join("=>")} => ${fn.name}(${range(0, fn.length)
    .map((i) => `x${i}`)
    .join(",")})`;
}

我们看到我们可以通过使用 eval() 来进行柯里化——但还有一个问题:如果原始函数没有名字,转换就不会工作。我们可以通过包含要柯里化的函数的实际代码来绕过函数名的问题:

function curryByEval2<A extends any[], R>(
  fn: (...args: A) => R
): Curry<A, R>;
function curryByEval2(fn: (...args: any) => any) {
  return eval(`${range(0, fn.length)
    .map((i) => `x${i}`)
    .join("=>")} =>
    (${fn.toString()})
    (${range(0, fn.length)
      .map((i) => `x${i}`)
      .join(",")})`);
}

唯一的改变是,我们不是包含原始函数的名称,而是替换它的实际代码:

curryByEval2(make3);
// x0=>x1=>x2=> ((a, b, c) => `${a}:${b}:${c}`)(x0,x1,x2)

7.4 取消柯里化:我们可以类似地做我们之前做过的事情:

const uncurry = (fn, len) =>
  eval(
    `(${range(0, len)
      .map((i) => `x${i}`)
      .join(",")}) => ${fn.name}${range(0, len)
      .map((i) => `(x${i})`)
      .join("")}`
  );

之前在柯里化时,给定一个 arity3fn() 函数,我们会生成以下内容:

x0=>x1=>x2=> make3(x0,x1,x2)

现在,要取消一个函数的柯里化(比如 curriedFn()),我们想要做的是非常相似的:唯一的区别是括号的位置:

(x0,x1,x2) => curriedFn(x0)(x1)(x2)

预期的行为如下——让我们使用上一个问题的最后一个结果:

const curriedMake3 = (x0) => (x1) => (x2) =>
  ((a, b, c) => `${a}:${b}:${c}`)(x0, x1, x2);
console.log(uncurry(curriedMake3, 3).toString());
// (x0,x1,x2) => curriedMake3(x0)(x1)(x2)

如果你想要考虑一个没有名称的“去 curry”函数的情况,你可以应用我们在上一个问题中做的相同更改,并在输出中包含fn.toString()

7.5 让我数数方式:如果函数有n个参数,那么有 2n-1 种调用它的方法。这意味着我们的三个参数函数可以以 22=4 种方式调用(正确!),具有两个参数的函数将允许 21=2 种方式,而只有一个参数的函数将只允许 20=1 种方式。

7.6 curry()版本,使其使用此:

Function.prototype.curry = function () {
  return this.length === 0
    ? this()
    : (p) => this.bind(this, p).curry();
};

7.7 更短的类型:之前建议的测试缩短了代码。我们本质上是在说“如果至少有一个参数,则返回一个 curried 函数;否则,返回”:

type Curry2<P, R> = P extends [infer H, ...infer T]
  ? (arg: H) => Curry2<[...T], R>
  : R;

7.8 通过以下方式检查P["length"]来使P具有单一类型 – 而要访问这个单一类型,我们将不得不编写P[0]

type Curry<P extends any[], R> = 1 extends P["length"]
  ? (arg: P[0]) => R // only 1 arg
  : P extends [infer H, ...infer T] // 2 or more args
  ? (arg: H) => Curry<[...T], R>
  : never;

7.9 使用applyStyle()或通过我们的curry()函数 – 让我们看看两种方式:

const applyStyle =
  (style: string) =>
  (text: string): string =>
    `<${style}>${text}</${style}>`;
const makeBold = applyStyle("b");
console.log(makeBold("Montevideo"));
// <b>Montevideo</b>
const applyStyle2 = (style: string, text: string): string
  =>
  `<${style}>${text}</${style}>`;
const makeUnderline = curry(applyStyle2)("u");
console.log(makeUnderline("Uruguay"));
// <u>Uruguay</u>

7.10 what()函数如下:

const partial =
  (fn) =>
  (...params) =>
    fn.length <= params.length
      ? fn(...params)
      : (...otherParams) =>
          partial(fn)(...params, ...otherParams);

7.11 this

7.12 curryN()函数是partialCurry()的另一种版本。唯一的区别是,如果你向函数提供了所有参数,这个新的curryN()函数将直接调用 curried 函数,而partialCurry()将首先将函数绑定到所有参数,然后递归调用它以返回最终结果 – 但结果将完全相同。

第八章,连接函数 – 管道、组合和更多

8.1 split()map()join()。使用来自第六章demethodize()生成函数和来自第七章flipTwo()转换函数也是可能的:

const split = (str: string) => (text: string) =>
  text.split(str);
const map =
  (fn: (x: string) => string) => (arr: string[]) =>
    arr.map(fn);
const firstToUpper = (word: string): string =>
  word[0].toUpperCase() + word.substring(1).toLowerCase();
const join = (str: string) => (arr: string[]) =>
  arr.join(str);
const headline = pipeline(
  split(" "),
  map(firstToUpper),
  join(" ")
);

管道工作如预期:我们将字符串拆分为单词,将每个单词映射为其首字母大写,然后将数组元素连接起来再次形成字符串。我们本可以使用reduce()来完成最后一步,但join()已经完成了我们需要的功能,所以为什么还要重新发明轮子呢?

console.log(headline("Alice's ADVENTURES in WoNdErLaNd"));
// Alice's Adventures In Wonderland

8.2 待办任务:以下管道完成了工作:

const getField = attr => obj => obj[attr]; const filter =
  fn => arr => arr.filter(fn); const map = fn => arr =>
  arr.map(fn);
const reduce = (fn, init) => arr => arr.reduce(fn, init);
const pending = (listOfTasks, name) => pipeline(
getField("byPerson"),
filter(t => t.responsible === name), map(t => t.tasks),
reduce((y, x) => x, []), filter(t => t && !t.done),
  map(getField("id"))
)(allTasks || {byPerson: []}); //

reduce()调用可能令人困惑。到那时,我们正在处理一个只有一个元素的数组 – 一个对象 – 我们想要管道中的对象,而不是数组。即使负责的人不存在,或者所有任务都已完成,这段代码仍然可以工作;你能看出为什么吗?此外,请注意,如果allTasksnull,必须提供一个具有byPerson属性的object,以便未来的函数不会崩溃!为了更好的解决方案,我认为单子更好:参见问题 12.1以获取更多信息。

8.3 以抽象术语思考:简单的解决方案意味着组合。我更喜欢它,而不是管道,以便保持函数列表的顺序:

const getSomeResults2 = compose(sort, group, filter, select);

8.4 反转类型:我们可以通过递归来反转类型列表:

type Reverse<FNS extends FN[]> = 1 extends FNS["length"]
  ? [FNS[0]]
  : FNS extends [
      infer FN1st extends FN,
      ...infer FNRest extends FN[]
    ]
  ? [...Reverse<FNRest>, FN1st]
  : never;

通过这种方式,我们可以用Pipeline<>来定义Compose<>

type Compose<FNS extends FN[]> = Pipeline<Reverse<FNS>>;
function compose1<FNS extends FN[]>(
  ...fns: FNS
): Compose<FNS> {
  return pipeline(...fns.reverse()) as Compose<FNS>;
}

我们在这里使用类型转换而不是重载,让 TypeScript 知道我们正在处理什么类型。

8.5 我们编写的 pipeline() 函数在访问 fns[0] 时没有检查 fns 数组是否为空,所以它不会工作。pipeline1()pipeline2() 函数使用没有初始值的 reduce(),所以它们也会失败。我们必须添加一个初始测试,如果没有提供函数(fns.length===0),我们只需将输入值作为结果返回。

8.6 未检测到的杂质? 是的,这个函数是不纯的,但如果我们直接使用它,就会完全符合我们在 第一章理论与实践 部分提到的 Sorta Functional Programming (SFP)风格。我们使用的版本不是纯的,但按照我们的使用方式,最终结果仍然是纯的:我们在原地修改数组,但我们创建的是一个新数组。另一种实现方式是纯的,也可以工作,但会慢一些,因为它每次调用都会创建一个全新的数组。所以,接受这一点杂质有助于我们获得性能更好的函数;我们可以接受这一点!

8.7 在 map() 操作中,你可以通过将所有映射函数管道化到一个单独的函数中,应用单个 map()。对于 filter() 操作,这会变得有点困难,但这里有一个提示:使用 reduce() 来按顺序应用所有过滤器,并使用一个精心设计的累积函数。

8.8 chainify()myCity2 的类型是 Chainify<City>。属性类型与之前相同,但现在返回 void 的方法现在返回相同类型的 Chainify<City> 对象:

{
    name: string;
    lat: number;
    long: number;
    extra: boolean;
    getName: () => string;
    setName: (newName: string) => Chainify<City>;
    setLat: (newLat: number) => Chainify<City>;
    setLong: (newLong: number) => Chainify<City>;
    getCoords: () => number[];
}

第九章,设计函数 – 递归

9.1 可以通过 reverse("ONTEVIDEO")+"M" 找到 reverse("MONTEVIDEO")。同样,reverse("ONTEVIDEO") 将等于 reverse("NTEVIDEO")+"O",以此类推:

const reverse = (str: string): string =>
  str.length === 0 ? "" : reverse(str.slice(1)) + str[0];

9.2 爬楼梯步骤:要爬一个有 n 级的楼梯,我们可以有两种行动方式:

  • 先爬一个单级楼梯,然后爬一个 (n-1) 级的楼梯

  • 一次爬两级楼梯,然后爬一个 (n-2) 级的楼梯

因此,如果我们调用 ladder(*n*) 来表示爬楼梯的方式数,我们知道 ladder(n)= ladder(*n*-1) + ladder(*n*-2)。加上事实 ladder(0)=1(没有台阶的楼梯只有一种爬法:什么都不做)和 ladder(1)=1,解决方案是 ladder(*n*) 等于 (n-1) 项斐波那契数!看看这个:ladder(2)=2ladder(3)=3ladder(4)=5,以此类推。

9.3 使用 max 函数),创建一个不包含该元素的数组的新副本,对副本进行排序,然后将 max 添加到排序副本的末尾。看看我们是如何处理修改器函数以避免修改原始数组的,注意这段排序代码只适用于数字,因为我们找到 max 的方式:

const selectionSort = (arr: number[]): number[] => {
  if (arr.length === 0) {
    return [];
  } else {
    const max = Math.max(...arr);
    const rest = [...arr];
    rest.splice(arr.indexOf(max), 1);
    return [...selectionSort(rest), max];
  }
};
selectionSort([2, 2, 0, 9, 1, 9, 6, 0]);
// [0, 0, 1, 2, 2, 6, 9, 9]

9.4 如果 smaller 是一个空数组,而 greaterEqual 等于要排序的整个数组,那么逻辑将进入无限循环。

原始代码永远不会进入循环,因为每次遍历都会移除一个元素(枢轴),所以你一定会达到一个没有剩余元素可以排序的状态。

9.5 更高的效率:以下代码为我们完成了这项工作。在这里,我们使用三元运算符来决定将新项目推送到哪里:

const partition = <A>(
  arr: A[],
  fn: (x: A) => boolean
): [A[], A[]] =>
  arr.reduce(
    (result: [A[], A[]], elem: A) => {
      result[fn(elem) ? 0 : 1].push(elem);
      return result;
    },
    [[], []]
  );

9.6 mapR(),所以我将跳过重复的解释——唯一的区别在于 return 值,现在是一个来自数组(findLoop() 中的 arr[0])的值,而不是 mapR() 中的映射整个数组:

type Opt<X> = X | undefined;
const findR = <A>(
  orig: Opt<A>[],
  cb: (x: A, i: number, a: Opt<A>[]) => boolean
): Opt<A> => {
  const findLoop = (arr: Opt<A>[], i: number): Opt<A> =>
    arr.length === 0
      ? undefined
      : !(0 in arr) || arr[0] === undefined
      ? findLoop(arr.slice(1), I + 1)
      : cb(arr[0], i, orig)
      ? arr[0]
      : findLoop(arr.slice(1), i + 1);
  return findLoop(orig, 0);
};

9.7 mapR() 示例,所以我不会对循环、类型等进行注释。当编程 everyR() 时,我们必须小心处理空数组或缺失的位置;标准的 every() 方法认为它们返回 true,所以我们将这样做:

type Opt<X> = X | undefined;
const everyR = <A>(
  orig: Opt<A>[],
  cb: (x: A, i: number, a: Opt<A>[]) => boolean
): boolean => {
  const everyLoop = (arr: Opt<A>[], i: number): boolean =>
    arr.length === 0
      ? true
      : !(0 in arr) || arr[0] === undefined
      ? true
      : !cb(arr[0], i, orig)
      ? false
      : everyLoop(arr.slice(1), i + 1);
  return everyLoop(orig, 0);
};

当编程 someR() 时,一个空数组意味着一个错误的结果,但空位将被跳过:

type Opt<X> = X | undefined;
const someR = <A>(
  orig: Opt<A>[],
  cb: (x: A, i: number, a: Opt<A>[]) => boolean
): boolean => {
  const someLoop = (arr: Opt<A>[], i: number): boolean =>
    arr.length === 0
      ? false
      : !(0 in arr) || arr[0] === undefined
      ? someLoop(arr.slice(1), i + 1)
      : cb(arr[0], i, orig)
      ? true
      : someLoop(arr.slice(1), i + 1);
  return someLoop(orig, 0);
};

9.8 对称皇后:找到仅对称解的关键如下。在第一个四个皇后(试探性地)放置在棋盘的前半部分之后,我们不需要尝试其他皇后的所有可能位置;它们会自动根据第一个确定:

const SIZE = 8;
const places = Array(SIZE);
const checkPlace = (column: number, row: number): boolean
  =>
  places
    .slice(0, column)
    .every(
      (v, i) =>
        v !== row && Math.abs(v - row) !== column - i
    );
const symmetricFinder = (column = 0): void => {
  if (column === SIZE) {
    console.log(JSON.stringify(places.map((x) => x + 1)));
  } else if (column <= SIZE / 2) {
    // first half of the board?
    const testRowsInColumn = (j: number): void => {
      if (j < SIZE) {
        if (checkPlace(column, j)) {
          places[column] = j;
          symmetricFinder(column + 1);
        }
        testRowsInColumn(j + 1);
      }
    };
    testRowsInColumn(0);
  } else {
    // second half of the board
    const symmetric = SIZE - 1 - places[SIZE - 1 - column];
    if (checkPlace(column, symmetric)) {
      places[column] = symmetric;
      symmetricFinder(column + 1);
    }
  }
};

调用 symmetricFinder() 产生四个解,它们本质上是一样的。画图并检查它们以确保解是正确的!

[3,5,2,8,1,7,4,6]
[4,6,8,2,7,1,3,5]
[5,3,1,7,2,8,6,4]
[6,4,7,1,8,2,5,3]

9.9 ab,可以通过以下递归方式找到:

  • 如果 a 的长度为零,或者如果 b 的长度为零,则返回零

  • 如果 ab 的第一个字符匹配,答案是 1 加上 ab 的 LCS,两者都减去它们的初始字符

  • 如果 ab 的第一个字符不匹配,答案是以下两个结果中的较大者:

    • LCS(最长公共子序列)的 a 减去其初始字符,以及 b

    • ab 的 LCS 减去其初始字符

我们可以如下实现。我们手动进行记忆化以避免重复计算;我们也可以使用我们的记忆化函数:

const LCS = (strA: string, strB: string): number => {
  // memoization "by hand"
  const cache: { [k: string]: number } = {};
  const innerLCS = (strA: string, strB: string): number =>
    {
    const key = strA + "/" + strB;
    let ret: number;
    if (!(key in cache)) {
      if (strA.length === 0 || strB.length === 0) {
        ret = 0;
      } else if (strA[0] === strB[0]) {
        ret = 1 + innerLCS(strA.substr(1), strB.substr(1));
      } else {
        ret = Math.max(
          innerLCS(strA, strB.substr(1)),
          innerLCS(strA.substr(1), strB)
        );
      }
      cache[key] = ret;
    }
    return cache[key];
  };
  return innerLCS(strA, strB);
};
console.log(LCS("INTERNATIONAL", "CONTRACTOR"));
// 6, as in the text

作为额外的练习,你可以不仅产生 LCS 的长度,还可以产生涉及到的字符。

9.10 2 并检查余数是否为 1

function isOdd1(n: number): boolean {
  return n % 2 === 1;
}

你可以通过执行 return Boolean(n % 2) 来得到另一个解决方案:

function isOdd2(n: number): boolean {
  return Boolean(n % 2);  // or !!(n % 2) instead
}

另一种方法是除以 2 并检查是否有分数部分:

function isOdd3(n: number): boolean {
  return Math.floor(n / 2) !== n / 2;
}

如果一个数字是奇数,将其除以 2 并将其前驱除以 2,两个结果都有相同的整数部分(例如,9/2 和 8/2 都有整数部分 4):

function isOdd4(n: number): boolean {
  return Math.floor(n / 2) === Math.floor((n - 1) / 2);
}

使用位操作速度快;奇数将会有其最低有效位设置为 1

function isOdd5(n: number): boolean {
  return (n & 1) === 1;
}

isOdd1() 类似,你可以通过执行 return Boolean(n & 1) 来得到另一种变体:

function isOdd6(n: number): boolean {
  return Boolean(n & 1); // or !!(n & 1) instead
}

二进制中的移位也有效;如果我们把数字右移一位(丢弃其最低有效位),然后把这个数字左移一位,对于奇数,我们不会得到相同的结果:

function isOdd7(n: number): boolean {
  return (n >> 1) << 1 !== n;
}

右移等同于除以 2 并保留整数部分,所以这个解决方案基本上与第三个相同:

function isOdd8(n: number): boolean {
  return n >> 1 === (n - 1) >> 1;
}

奇数以 1、3、5、7 或 9 结尾,因此我们也可以查看数字的字符串表示并检查其值:

function isOdd9(n: number): boolean {
  return "13579".includes(String(n).at(-1)!);
}

我们可以通过使用 find()indexOf() 来处理字符串;我将把这些版本留给你。

9.11 trampoline()

function isEven(n: number, cont: FN): () => boolean {
  if (n === 0) {
    return trampoline(() => cont(true));
  } else {
    return trampoline(() => isOdd(n - 1, (v) => cont(v)));
  }
}
function isOdd(n: number, cont: FN): () => boolean {
  return trampoline(() => isEven(n, (v) => cont(!v)));
}

例如,isEven() 函数中的第一个 return 之前是 return true;现在我们使用 trampoline 技巧调用一个带有 true 的 continuation。现在我们可以通过提供一个仅返回计算值的适当 continuation 来完成工作:

function isEvenT(n: number): boolean {
  return trampoline(isEven(n, (x) => x));
}
function isOddT(n: number): boolean {
  return trampoline(isOdd(n, (x) => x));
}
console.log("22.. isEven?", isEvenT(22));  // true
console.log("9... isOdd?", isOddT(5));     // true
console.log("63... isEven?", isEvenT(63)); // false
console.log("60... isOdd?", isOddT(60));   // false

9.12 isEven(1)isOdd(2),你会得到一个无限循环;你能看出为什么?(如果你用任何奇数或偶数分别替换 12,也会发生相同的情况。)提示:问题出在递归的基本情况上。

9.13 使用 for(;;) 循环和 return 语句的 while()

9.14 power() 函数位于 term()factor() 之间,因此其优先级将被正确放置。

图 2 – Power 表示指数序列

图 2 – Power 表示指数序列

我们将修改 term() 以调用 power() 而不是 factor()

function term(): number {
  let accum = power();
  while (
    tokens[curr] === TIMES ||
    tokens[curr] === DIVIDES ||
    tokens[curr] === MODULUS
  ) {
    if (tokens[curr] === TIMES) {
      curr++;
      accum *= power();
    } else if (tokens[curr] === DIVIDES) {
      curr++;
      accum /= power();
    } else if (tokens[curr] === MODULUS) {
      curr++;
      accum %= power();
    }
  }
  return accum;
}

为了正确计算“塔”如 2³⁴,我们将 234 存储在一个数组中,然后从右到左进行归约:我们首先计算 3⁴,然后计算 2^(3⁴ 的计算结果):

function power(): number {
  const tower = [factor()];
  while (tokens[curr] === POWER) {
    curr++;
    tower.push(factor());
  }
  while (tower.length > 1) {
    tower[tower.length - 2] **= tower[tower.length - 1];
    tower.pop();
  }
  return tower[0];
}

9.15 易出错的评估:以下是一些想法:

  • 在跳过一个标记时,检查它是否正确;例如,factor() 跳过了第二个括号,但实际上并没有检查它是否正确,因此它会将“(1+2]”评估为 3,尽管这是错误的。

  • 添加一个特殊的字符串结束(EOS)标记,以检查评估是否在该标记处结束。

  • 每次前进到下一个标记时,请检查您是否没有超出 tokens 数组的末尾。

第十章,确保纯净性 – 不可变性

10.1 在以下示例中,jsonCopy(),但不要假设没有更多问题:

const agent = {
  error: new Error("It's stirred; I ordered it shaken"),
  map: new Map([["James", "Bond"]]),
  set: new Set([0, 0, 7]),
  regex: /007/,
  useLicense() {
    console.log("Bang! Bang!");
  },
};
console.log(jsonCopy(agent));
/*
{ error: {}, map: {}, set: {}, regex: {} }
*/

四个属性被转换成了空对象,而函数被忽略了。

10.2 deepCopy() 函数略有改进;与上一个问题中的相同代理对象,复制产生以下结果:

/*
{
  error: Error: It's stirred; I ordered it shaken
    ...many lines snipped out
  map: Map(0) {},
  set: Set(0) {},
  regex: /(?:)/,
  useLicense: [Function: useLicense]
}
*/

错误和函数已正确转换。map 和 set 已转换为正确的类型,但它们是空的;可以通过添加逻辑来修复,该逻辑将扫描原始对象并将它们的副本插入到新对象中。(问题 10.10 可能有所帮助。)最后,克隆正则表达式稍微困难一些,但搜索“clone regexp in JavaScript”将找到几个实现。

10.3 deepCopy2() 函数如下:

const deepCopy2 = <O extends OBJ>(obj: O): O => {
  const mapped = new Map<O, O>();
  const deepCopy = (obj: O): O => {
    let aux: O = obj;
    if (obj && typeof obj === "object") {
      if (mapped.has(obj)) {
        return mapped.get(obj) as O;
      }
      aux = new (obj as any).constructor();
      mapped.set(obj, aux);
      Object.getOwnPropertyNames(obj).forEach((prop) => {
        (aux as any)[prop as keyof O] =
          deepCopy(obj[prop]);
      });
    }
    return aux;
  };
  return deepCopy(obj);
};

我们将使用 mapped 变量进行映射。当我们发现需要克隆一个 obj 对象时,我们首先检查(mapped.has(obj))是否已经执行过,如果是,则从映射中返回值。如果这是一个新的、尚未复制的对象,我们将将其及其 aux 复制添加到映射中(mapped.set(obj,aux))以供将来参考。

我们可以通过一个简单的例子来验证其工作原理:

const circular = {
  a: 1,
  b: { c: 3, d: { e: 5, f: null } }
};
circular.b.d.f = circular.b as any;
console.log(deepCopy2(circular));
/*
{
  a: 1,
  b: <ref *1> { c: 3, d: { e: 5, f: [Circular *1] } }
}
*/

如果我们在circular上使用deepCopy(),我们将得到一个RangeError: Maximum call stack size exceeded异常。然而,使用我们新的deepCopy2()函数,循环引用问题得以解决。

10.4 通过代理冻结:如请求,代理允许你拦截对象上的更改。(有关更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)我们使用递归将代理应用到所有属性,以防某些属性本身是对象:

const proxySetAll = (obj: OBJ): OBJ => {
  Object.keys(obj).forEach((v) => {
    if (typeof obj[v] === "object") {
      obj[v] = proxySetAll(obj[v]);
    }
  });
  return new Proxy(obj, {
    set() {
      throw new Error("DON'T MODIFY ANYTHING IN ME");
    },
    deleteProperty() {
      throw new Error("DON'T DELETE ANYTHING IN ME");
    },
  }) as OBJ;
};

以下是在前面的代码中的输出。对于现实生活中的实现,你可能会需要除了DON'T MODIFY ANYTHING IN ME消息之外的其他东西,当然!

const myObj = proxySetAll({
  a: 5,
  b: 6,
  c: { d: 7, e: 8 },
});
myObj.a = 777;
// Uncaught Error: DON'T MODIFY ANYTHING IN ME
myObj.f = 888;
// Uncaught Error: DON'T MODIFY ANYTHING IN ME
delete myObj.b;
// Uncaught Error: DON'T DELETE ANYTHING IN ME

10.5 持久地插入到列表中:使用递归有助于如下:

  • 如果列表为空,我们无法插入新键。

  • 如果我们处于一个键值为oldKey的节点,我们将创建一个指向以newKey为其值的新节点和指向原始节点列表其余部分的指针的该节点的克隆。

  • 如果我们处于一个键值不是oldKey的节点,我们将创建该节点的克隆,并将新键(递归地)插入到原始节点列表的其余部分:

type NODE_PTR = Node | null;
const insertAfter = (
  list: NODE_PTR,
  newKey: string,
  oldKey: string
): NODE_PTR => {
  if (list === null) {
    return null;
  } else if (list.key === oldKey) {
    return new Node(list.key, new Node(newKey, list.next));
  } else {
    return new Node(
      list.key,
      insertAfter(list.next, newKey, oldKey)
    );
  }
};

在以下代码中,我们可以看到这个工作。新列表类似于图 10.2 中显示的列表。然而,仅打印列表(c3newList)是不够的;你将无法区分新节点或旧节点,所以我包括了一些比较:

const c3 =
  new Node("G",
    new Node("B",
      new Node("F",
        new Node("A",
          new Node("C",
            new Node("E", null))))));
const newList = insertAfter(c3, "D", "B");
console.log(c3 === newList);
// false
console.log(c3!.key === newList!.key);
// true (both are "G")
console.log(c3!.next === newList!.next);
// false
console.log(c3!.next!.key === newList!.next!.key);
// true (both are "B")
console.log(c3!.next!.next === newList!.next!.next);
// false
console.log(c3!.next!.next!.key === "F");
// true
console.log(newList!.next!.next!.key === "D");
// true
console.log(
  c3!.next!.next!.next === newList!.next!.next!.next!.next
);
// true – after F, the list is the old one

需要很多!非空断言来通知 TypeScript 没有null值。

一个新问题:在先前的逻辑中,如果没有找到oldKey,则不会插入任何内容。你能改变逻辑,使得在这种情况下,新节点被添加到列表的末尾吗?

10.6 reduce()。让我们编写composeManyLenses()函数并将其应用于文本中显示的相同示例:

const composeManyLenses = <O extends OBJ>(
  ...lenses: LENS<O>[]
) =>
  lenses.reduce((acc, lens) => composeTwoLenses(acc,
    lens));

使用前面看到的deepObject示例,以及所有获取ceg等值的透镜,我们得到以下内容:

const deepObject = {
  a: 1,
  b: 2,
  c: {
    d: 3,
    e: {
      f: 6,
      g: { i: 9, j: { k: 11 } },
      h: 8,
    },
  },
};
console.log(
  view(composeManyLenses(lC, lE, lG, lJ, lK), deepObject)
);
// 11, same as earlier

10.7 getField()getByPath()

10.8 fullName 属性:

const lastNameLens = composeTwoLenses(
  lensProp("name"),
  lensProp("last")
);
const firstNameLens = composeTwoLenses(
  lensProp("name"),
  lensProp("first")
);
const fullNameGetter = <O extends OBJ>(obj: O): string =>
  `${view(lastNameLens)(obj)},
    ${view(firstNameLens)(obj)}`;

基于单个值设置多个属性并不总是可能的,但如果我们假设传入的名称是LAST,FIRST格式,我们可以通过逗号分割它,并将两部分分别分配给姓氏和名字:

const fullNameSetter =
  <O extends OBJ>(fullName: string) =>
  (obj: O): O => {
    const parts = fullName.split(",");
    return set(firstNameLens)(parts[1])(
      set(lastNameLens)(parts[0])(obj)
    ) as O;
  };
const fullNameLens = lens(fullNameGetter, fullNameSetter);

10.9 view() 函数将工作得很好,但set()over()在纯方式下不会工作,因为setArray()不会返回一个新数组;相反,它会在原地修改当前数组。查看以下问题以了解相关的问题。

10.10 映射中的透镜:从映射中获取值没有问题,但对于设置,我们需要克隆映射:

const getMap =
  <K, V>(key: K) =>
  (map: Map<K, V>) =>
    map.get(key);
const setMap =
  <K, V>(key: K) =>
  (value: V) =>
  (map: Map<K, V>) =>
    new Map(map).set(key, value);
const lensMap = <K, V>(key: K) =>
  lens(getMap<K, V>(key), setMap<K, V>(key));

第十一章,以函数式方式实现设计模式

11.1 装饰方法,未来的方式:正如我们之前提到的,装饰器目前不是一个固定的、最终确定的功能。然而,通过遵循 tc39.github.io/proposal-decorators/,我们可以编写以下代码:

const logging = (target, name, descriptor) => {
  const savedMethod = descriptor.value;
  descriptor.value = function (...args) {
    console.log(`entering ${name}: ${args}`);
    try {
      const valueToReturn =
        savedMethod.bind(this)(...args);
      console.log(`exiting ${name}: ${valueToReturn}`);
      return valueToReturn;
    } catch (thrownError) {
      console.log(`exiting ${name}: threw ${thrownError}`);
      throw thrownError;
    }
  };
  return descriptor;
};

我们想给一个方法添加 @logging 装饰器。我们将原始方法保存在 savedMethod 中,并替换为一个新方法,该新方法将记录接收到的参数,调用原始方法以保存其返回值,记录该值,并最终返回它。如果原始方法抛出异常,我们将捕获它,报告它,并再次抛出,以便它可以按预期进行处理。这个简单示例如下:

class SumThree {
  constructor(z) {
    this.z = z;
  }
  @logging
  sum(x, y) {
    return x + y + this.z;
  }
}
new SumThree(100).sum(20, 8);
// entering sum: 20,8
// exiting sum: 128

11.2 接收 Base 类并扩展它的 addBar() 函数。在这种情况下,我决定添加一个新的属性和一个新的方法。扩展类的构造函数调用原始构造函数并创建 barValue 属性。新类既有原始的 doSomething() 方法,也有新的 somethingElse() 方法:

const addBar = (Base) =>
  class extends Base {
    constructor(fooValue, barValue) {
      super(fooValue);
      this.barValue = barValue;
    }
    somethingElse() {
      console.log(
        "something added: bar... ",
        this.barValue
      );
    }
  };

11.3 event.detail;你可以在 developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail 上了解更多信息。

11.4 带有布尔值的 flags 数组,你不需要任何特殊的比较函数;flags.sort() 可以“直接使用”并将 false 值放在前面,true 值放在后面。这是因为标准排序是通过将值转换为字符串,然后比较它们来实现的;当你这样做时,布尔值变为 "false""true",由于 "false" 小于 "true",所以一切顺利!

11.5 RouteFinder 类及其几个子类,如 ByFootRouteFinderBicycleRouteFinder 等,每个子类以不同的方式实现 findRouteAlgorithm() 方法,并有一个工厂来选择要实例化的子类。

第十二章,构建更好的容器 - 函数式数据类型

12.1 map() 方法到布尔值、数字和字符串:

declare global {
  interface Boolean {
    map(_f: (_x: boolean) => boolean): boolean;
  }
}
declare global {
  interface Number {
    map(_f: (_x: number) => number): number;
  }
}
declare global {
  interface String {
    map(_f: (_x: string) => string): string;
  }
}

以下是一些示例:

Boolean.prototype.map = function (
  this: boolean,
  fn: (_x: boolean) => any
) {
  return !!fn(this);
};
const t = true;
const f = false;
const negate = (x: boolean) => !x;
console.log(t.map(negate), f.map(negate));
// false true
Number.prototype.map = function (
  this: number,
  fn: (_x: number) => number
) {
  return Number(fn(this));
};
const n = 22;
const add1 = (n: number) => n + 1;
console.log(n.map(add1));
// 23
String.prototype.map = function (
  this: string,
  fn: (_x: string) => string
) {
  return String(fn(this));
};
const s = "Montevideo";
const addBangs = (s: string): string => s + "!!!";
console.log(s.map(addBangs));
// Montevideo!!!

12.2 Symbol,其值是在模块内部定义的,并且没有导出,所以没有人可以访问相应的属性:

const VALUE = Symbol("Value");
class Container {
  constructor(x) {
    this[VALUE] = x;
  }
  map(fn) {
    return fn(this[VALUE]);
  }
  .
  . other methods
  .
}

使用 Symbol 有助于隐藏字段:属性键不会出现在 Object.keys()for...infor...of 循环中,这使得它们更加难以篡改。(如果你还没有使用过 JavaScript 符号,可能是它最不为人知的原始数据类型,你可能想查看 developer.mozilla.org/en-US/docs/Glossary/symbol。)

map() 方法能够访问“受保护”的属性,因为它可以访问 VALUE 符号,但如果没有那个,就无法获取该属性。

12.3 XXX 类应该是一个抽象类,它应该像这样开始:

class XXX {
  constructor(...) {
    if (this.constructor === XXX) {
      throw new Error("Cannot initialize XXX class")
    }
    .
    . rest of the constructor
    .
  }
  .
  . other methods
  .
}

12.4 可能是任务吗? 以下代码显示了一个比我们之前看到的更简单的解决方案:

const pending = Maybe.of(listOfTasks)
  .map(getField("byPerson"))
  .map(filter((t) => t.responsible === name))
  .map((t) => tasks)
  .map((t) => t[0])
  .map(filter((t) => !t.done))
  .map(getField("id"))
  .valueOf();

在这里,我们依次应用一个函数,因为我们知道如果这些函数中的任何一个产生空结果(或者如果原始的listOfTasks是 null),调用序列将继续。最终,你将得到一个任务 ID 数组或一个null值。

12.5 扩展你的树:如果你以递归方式计算,树的高度计算很简单。空树的高度为零,而非空树的高度是一(对于根节点)加上其左右子树的最大高度:

const treeHeight = <A>(tree: TREE<A>): number =>
  tree(
    (val, left, right) =>
      1 + Math.max(treeHeight(left), treeHeight(right)),
    () => 0
  );

按顺序列出键是一个众所周知的要求。由于树是按照这种方式构建的,你首先列出左子树的键,然后是根,最后是右子树的键,所有这些都是在递归方式下完成的:

const treeList = <A>(tree: TREE<A>): void =>
  tree(
    (value, left, right) => {
      treeList(left);
      console.log(value);
      treeList(right);
    },
    () => {
      // nothing
    }
  );

最后,从二叉搜索树中删除一个键要复杂一些。首先,你必须找到将要被删除的节点,然后有几种情况:

  • 如果节点没有子树,删除操作很简单。

  • 如果节点只有一个子树,你只需用它的子树替换节点

  • 如果节点有两个子树,那么你必须这样做:

    • 在树中找到具有更大键的最小键

    • 将其放在节点的位置

由于这个算法在所有计算机科学教科书中都有很好的覆盖,所以我不会在这里详细介绍:

const treeRemove = <A>(
  toRemove: A,
  tree: TREE<A>
): TREE<A> =>
  tree(
    (val, left, right) => {
      const findMinimumAndRemove = (
        tree: TREE<A> /* never empty */
      ): { min: A; tree: TREE<A> } =>
        tree(
          (value, left, right) => {
            if (treeIsEmpty(left)) {
              return { min: value, tree: right };
            } else {
              const result = findMinimumAndRemove(left);
              return {
                min: result.min,
                tree: Tree(value, result.tree, right),
              };
            }
          },
          () => {
            /* not needed */
          }
        );
      if (toRemove < val) {
        return Tree(val, treeRemove(toRemove, left),
          right);
      } else if (toRemove > val) {
        return Tree(val, left, treeRemove(toRemove,
          right));
      } else if (treeIsEmpty(left) && treeIsEmpty(right)) {
        return EmptyTree();
      } else if (treeIsEmpty(left) !== treeIsEmpty(right))
        {
        return tree(
          (val, left, right) =>
            left(
              () => left,
              () => right
            ),
          () => {
            /* not needed */
          }
        );
      } else {
        const result = findMinimumAndRemove(right);
        return Tree(result.min, left, result.tree);
      }
    },
    () => tree
  );

12.6 ||运算符:

const treeSearch2 = <A>(
  findValue: A,
  tree: TREE<A>
): boolean =>
  tree(
    (value, left, right) =>
      findValue === value ||
      (findValue < value
        ? treeSearch2(findValue, left)
        : treeSearch2(findValue, right)),
    () => false
  );

此外,鉴于第二个三元运算符中的两个备选方案非常相似,你还可以在那里进行一些简化:

const treeSearch3 = <A>(
  findValue: A,
  tree: TREE<A>
): boolean =>
  tree(
    (value, left, right) =>
      findValue === value ||
      treeSearch3(
        findValue,
        findValue < value ? left : right
      ),
    () => false
  );

记住:短并不意味着好!然而,我发现了很多这种代码紧缩的例子,如果你也接触过这些,那就更好了。

12.7 函数列表:让我们添加已经提供的样本。如果我们能够将列表转换成数组,反之亦然,我们可以简化与列表的交互:

const listToArray = <A>(list: LIST<A>): A[] =>
  list(
    (head, tail) => [head, ...listToArray(tail)],
    () => []
  );
const listFromArray = <A>(arr: A[]): LIST<A> =>
  arr.length
    ? NewList(arr[0], listFromArray(arr.slice(1)))
    : EmptyList();

将两个列表连接在一起并将一个值追加到列表中都有简单的递归实现。我们还可以通过使用追加函数来反转列表:

const listConcat = <A>(list1: LIST<A>, list2: LIST<A>) =>
  list1(
    (head, tail) => NewList(head, listConcat(tail, list2)),
    () => list2
  );
const listAppend = <A>(list: LIST<A>, value: A): LIST<A> =>
  list(
    (head, tail) => NewList(head, listAppend(tail, value)),
    () => NewList(value, EmptyList())
  );
const listReverse = <A>(list: LIST<A>): LIST<A> =>
  list(
    (head, tail) => listAppend(listReverse(tail), head),
    () => EmptyList()
  );

最后,基本的map()filter()reduce()操作是很有用的:

const listMap = <A, B>(
  list: LIST<A>,
  fn: (_x: A) => B
): LIST<B> =>
  list(
    (head, tail) => NewList(fn(head), listMap(tail, fn)),
    EmptyList
  );
const listFilter = <A>(
  list: LIST<A>,
  fn: (_x: A) => boolean
): LIST<A> =>
  list(
    (head, tail) =>
      fn(head)
        ? NewList(head, listFilter(tail, fn))
        : listFilter(tail, fn),
    EmptyList
  );
const listReduce = <A, B>(
  list: LIST<A>,
  fn: (_acc: B, _val: A) => B,
  accum: B
): B =>
  list(
    (head, tail) => listReduce(tail, fn, fn(accum, head)),
    () => accum
  );

以下是一些留给你的练习。生成一个可打印的列表版本:

  • 比较两个列表,看它们是否有相同的值,且顺序相同

  • 在列表中搜索一个值

  • 获取、更新或删除列表中第n个位置上的值

12.8 BOOLEAN类型和两个特殊函数TRUEFALSE,它们将代表通常的truefalse值:

type BOOLEAN = (_true: any, _false: any) => any;
const TRUE: BOOLEAN = (trueValue: any, __: any) =>
  trueValue;
const FALSE: BOOLEAN = (__: any, falseValue: any) =>
  falseValue;

BOOLEAN类型接收两个值并返回其中一个。一个TRUE布尔值返回这两个值中的第一个;一个FALSE布尔值返回第二个。我们可以这样构造和检查变量:

const MakeBool = (value: boolean) => (value ? TRUE :
  FALSE);
const valueOf = (boolValue: BOOLEAN): boolean =>
  boolValue(true, false);
console.log("LOG T  ", valueOf(TRUE));
console.log("LOG F  ", valueOf(FALSE));
// true false
console.log("VAL T  ", valueOf(MakeBool(true)));
console.log("VAL F  ", valueOf(MakeBool(false)));
// true false

我们现在可以定义运算符:

const NOT = (boolValue: BOOLEAN): BOOLEAN =>
  boolValue(FALSE, TRUE);
const AND = (
  boolLeft: BOOLEAN,
  boolRight: BOOLEAN
): BOOLEAN => boolLeft(boolRight, FALSE);
const OR = (
  boolLeft: BOOLEAN,
  boolRight: BOOLEAN
): BOOLEAN => boolLeft(TRUE, boolRight);
const XOR = (
  boolLeft: BOOLEAN,
  boolRight: BOOLEAN
): BOOLEAN => boolLeft(NOT(boolRight), boolRight);
const EQU = (
  boolLeft: BOOLEAN,
  boolRight: BOOLEAN
): BOOLEAN => boolLeft(boolRight, NOT(boolRight));
const IMP = (
  boolLeft: BOOLEAN,
  boolRight: BOOLEAN
): BOOLEAN => boolLeft(boolRight, TRUE);

这些不是唯一可能的情况,但我将留给你去发现其他替代方案。最后,我们可以有一个ifElse()函数来处理这些BOOLEAN值和 thunks:

const ifElse = (
  boolValue: BOOLEAN,
  fnTRUE: FN,
  fnFALSE: FN
) => boolValue(fnTRUE, fnFALSE)();
ifElse(
  TRUE,
  () => console.log("I'm true"),
  () => console.log("I'm false")
);
// true
ifElse(
  FALSE,
  () => console.log("I'm true"),
  () => console.log("I'm false")
);
// false

最后的评论:这段代码展示了你可以用函数做更多的事情,但这并不意味着你应该这样去做!你可以在www.usrsb.in/Building-Data-Structures-from-Functions.html阅读以下内容:

最后,这可能会让你觉得这不过是一个无用的编程技巧。从某种意义上说,这是正确的。我永远不会在我的代码中使用这个技巧。这个技巧之所以有价值,是因为它实际上符合 lambda 演算的更广泛背景,而 lambda 演算是一种计算数学抽象。

我无法用更好的方式来表达这一点!

参考书目

以下文本可在网上免费获取:

如果你更喜欢纸质书籍,可以参考以下列表:

  • 《JavaScript 函数式编程入门》,由Anto Aravinth著,Apress出版社,2017 年

  • 《探索函数式 JavaScript》,由Cristian Salcescu著,(独立出版),2019 年

  • 《函数式 JavaScript》,由Michael Fogus著,O'Reilly Media,2013 年

  • 《JavaScript 函数式编程》,由Dan Mantyla著,Packt Publishing,2015 年

  • 《JavaScript 函数式编程》,由Luis Atencio著,Manning Publications,2016 年

  • 《通过函数式思维驯服复杂软件——Grokking Simplicity》,由Eric Normand著,Manning Publications,2021 年

  • 《使用 TypeScript 进行实战函数式编程》,由Remo Jansen著,Packt Publishing,2019 年

  • 《函数式编程导论》,由Richard Bird 和 Philip Wadler著,Prentice Hall International,1988 年。这是一个更理论的观点,并不特别针对 JavaScript

  • 《JavaScript 设计模式》,由Ross Harmes 和 Dustin Díaz著,Apress,2008 年

  • 《JavaScript 忍者秘籍》,由John Resig 和 Bear Bibeault著,Manning Publications,2012 年

  • 《TypeScript 4 设计模式和最佳实践》,由Theo Despoudis著,Packt Publishing,2021 年

同样有趣,尽管对函数式编程的关注较少,以下是一些内容:

  • 高性能 JavaScript,作者 Nicholas ZakasO’Reilly Media,2010

  • JavaScript 模式,作者 Stoyan StefanovO’Reilly Media,2010

  • JavaScript:良好的部分,作者 Douglas CrockfordO’Reilly Media,2008

  • 使用 Promises 的 JavaScript,作者 Daniel ParkerO’Reilly Media,2015

  • 学习 JavaScript 设计模式,作者 Addy OsmaniO’Reilly Media,2012

  • 精通 JavaScript 设计模式,第二版,作者 Simon TimmsPackt Publishing,2016

  • 精通 JavaScript 高性能,作者 Chad AdamsPackt Publishing,2015

  • JavaScript 性能优化,作者 Tom BarkerApress,2012

这些标题都是关于响应式函数式编程主题的:

  • 精通响应式 JavaScript,作者 Erich de Souza OliveiraPackt Publishing,2017

  • 使用 Node.js 进行响应式编程,作者 Fernando DoglioApress,2016

  • 使用 RxJS 进行响应式编程,作者 Sergi MansillaThe Pragmatic Programmers,2015

posted @ 2025-10-26 08:57  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报