精通-JavaScript-函数式编程-全-
精通 JavaScript 函数式编程(全)
原文:
zh.annas-archive.org/md5/C4CB5F08EDA7F6C7DED597C949390410
译者:飞龙
前言
在计算机编程中,范式层出不穷:一些例子包括命令式编程、结构化(少用 goto)编程、面向对象编程、面向方面编程和声明式编程。最近,对一种可以说比大多数(如果不是全部)上述范式更古老的范式重新产生了兴趣——函数式编程。函数式编程(FP)强调编写函数,并以简单的方式连接它们,以产生更易理解和更易测试的代码。因此,鉴于今天的 Web 应用程序的复杂性增加,逻辑上会对更安全、更清洁的编程方式产生兴趣。
对 FP 的兴趣与 JavaScript 的发展息息相关。尽管 JavaScript 的创建有些仓促(据说是由 Netscape 的 Brendan Eich 在 1995 年仅用了 10 天完成),但今天它是一种标准化和迅速增长的语言,具有比大多数其他类似流行语言更先进的特性。这种语言的普及性,现在可以在浏览器、服务器、手机等各种设备上找到,也推动了对更好的开发策略的兴趣。此外,即使 JavaScript 本身并不是作为一种函数语言而构思的,事实上它提供了你在这种方式下所需的所有功能,这也是一个优点。
还必须说一下,FP 并没有被广泛应用于工业中,可能是因为它有一定的难度,被认为是理论性而不是实用性,甚至数学性,可能使用的词汇和概念对开发人员来说是陌生的——函子?单子?折叠?范畴论?虽然学习所有这些理论肯定会有所帮助,但也可以说,即使对上述术语一无所知,你也可以理解 FP 的原则,并看到如何将其应用于你的编程。
FP 不是你必须独自完成的事情,没有任何帮助。有许多库和框架,以不同程度融合了 FP 的概念。从 jQuery 开始(其中包括一些 FP 概念),经过 Underscore 及其近亲 LoDash,或其他库如 Ramda,再到更完整的 Web 开发工具如 React 和 Redux,Angular,或 Elm(一种 100%的函数语言,可以编译成 JavaScript),用于编码的功能性辅助工具列表不断增长。
学习如何使用 FP 可能是一项值得投资的事情,即使你可能无法使用其所有方法和技术,只要开始应用其中的一些方法,就会在编写更好的代码方面获得回报。你不需要从一开始就尝试应用 FP 的所有内容,也不需要试图放弃语言中的每一个非函数特性。JavaScript 确实有一些不好的特性,但也有一些非常好和强大的特性。关键不是要抛弃你学到的和使用的一切,然后采用 100%的函数式方式;相反,指导思想是演变,而不是革命。在这个意义上,可以说我们要做的不是 FP,而是有点函数式编程(SFP),旨在融合不同的范式。
关于本书中代码风格的最后一点评论——确实有一些非常好的库,可以为你提供函数式编程工具:Underscore、LoDash、Ramda 等等。然而,我更倾向于避免使用它们,因为我想展示事物的真实运行方式。应用某个包中的给定函数很容易,但通过编写所有代码(如果你愿意,可以称之为纯 FP),我相信你可以更深入地理解事物。此外,正如我在某些地方所评论的,由于箭头函数和其他特性的强大和清晰,纯 JS版本甚至更容易理解!
本书涵盖的内容
在本书中,我们将以实际的方式涵盖函数式编程(FP),尽管有时我们会提到一些理论观点:
第一章,成为函数式-几个问题,讨论了函数式编程,给出了使用它的原因,并列出了您需要利用本书其余部分的工具。
第二章,功能性思维-第一个例子,将通过考虑一个常见的与 Web 相关的问题,并讨论几种解决方案,最终专注于一种功能性的方式,提供了函数式编程的第一个例子。
第三章,从函数开始-核心概念,将介绍函数式编程的核心概念:函数,以及 JavaScript 中的不同选项。
第四章,行为得体-纯函数,将考虑纯度和纯函数的概念,并展示它如何导致更简单的编码和更容易的测试。
第五章,声明式编程-更好的风格,将使用简单的数据结构来展示如何以声明式的方式工作,而不是以命令式的方式。
第六章,生成函数-高阶函数,将处理高阶函数,它们接收其他函数作为参数,并产生新的函数作为结果。
第七章,转换函数-柯里化和部分应用,将展示一些从早期函数中产生新的专门函数的方法。
第八章,连接函数-管道和组合,将展示如何通过连接先前定义的函数来构建新函数的关键概念。
第九章,设计函数-递归,将展示函数式编程中的关键概念递归如何应用于设计算法和函数。
第十章,确保纯净性-不可变性,将展示一些工具,可以通过提供不可变对象和数据结构来帮助您以纯净的方式工作。
第十一章,实现设计模式-函数式方式,将展示在以函数式方式编程时如何实现(或不需要!)几种流行的面向对象设计模式。
第十二章,构建更好的容器-函数数据类型,将展示更高级的函数模式,介绍类型、容器、函子、单子以及其他更高级的函数式编程概念。
我试图保持示例简单和贴近实际,因为我想专注于功能方面,而不是纠缠于这个或那个问题的复杂性。有些编程文本是针对学习某个框架,然后解决特定问题,看如何用所选工具完全解决它。 (事实上,在规划这本书的最初阶段,我曾经考虑过开发一个应用程序,该应用程序将使用我心目中的所有函数式编程的东西,但是没有办法将所有内容都放入一个项目中。夸张一点说,我感觉自己像是一名医生,试图找到一个可以应用他所有医学知识和治疗方法的病人!)因此,我选择展示大量的个别技术,这些技术可以在多种情况下使用。我不想建造一座房子,我想向您展示如何把砖块放在一起,如何连接线路等,这样您就可以根据需要应用任何内容。
您需要为本书做好准备
要理解本书中的概念和代码,您不需要比 JavaScript 环境和文本编辑器更多的东西。老实说,我甚至开发了一些完全在线工作的示例,使用诸如 JSFiddle(在jsfiddle.net/
)之类的工具,绝对没有其他东西。
然而,您需要一些关于最新版本的 JavaScript 的经验,因为它包括一些功能,可以帮助编写更简洁、更紧凑的代码。我们将经常包含指向在线文档的指针,例如 MDN(Mozilla Development Network)上可用的文档,以帮助您获得更深入的知识。
这本书是为谁准备的
这本书面向具有良好的 JavaScript 工作知识的程序员,无论是在客户端(浏览器)还是服务器端(Node.JS)工作,他们有兴趣应用技术来编写更好、可测试、可理解和可维护的代码。一些计算机科学背景(包括例如数据结构)和良好的编程实践也会派上用场。
约定
在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是这些样式的一些示例以及它们的含义解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“只需将要激活的图层的名称分配给VK_INSTANCE_LAYERS
环境变量”。
代码块设置如下:
{
if( (result != VK_SUCCESS) ||
(extensions_count == 0) ) {
std::cout << "Could not enumerate device extensions." << std::endl;
return false;
}
任何命令行输入或输出都以以下方式编写:
setx VK_INSTANCE_LAYERS VK_LAYER_LUNARG_api_dump;VK_LAYER_LUNARG_core_validation
新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中出现,就像这样:“从管理面板中选择系统信息”。
警告或重要说明会出现在这样的框中。提示和技巧会出现在这样。
第一章:成为函数式编程者——几个问题
- 函数式编程(通常缩写为 FP)自古以来就存在,并且由于它在几个框架和库中的广泛使用,尤其是在 JavaScript 中的增加使用,它正在经历一种复兴。在本章中,我们将:
-
介绍一些函数式编程的概念,给出一点点它的意义
-
展示使用函数式编程所暗示的好处(和问题)
-
开始思考为什么JavaScript(JS)可以被认为是适合函数式编程的语言
-
了解你应该注意的语言特性和工具,以充分利用本书中的一切
所以,让我们开始问自己什么是函数式编程?并开始研究这个主题。
什么是函数式编程?
如果你回顾计算机历史,你会发现仍在使用的第二古老的编程语言 LISP,它的基础就是函数式编程。从那时起,出现了许多更多的函数式语言,并且函数式编程得到了更广泛的应用。但即便如此,如果你询问函数式编程是什么,你可能会得到两种截然不同的答案。
根据你问的人,你要么会得知它是一种现代的、先进的、开明的编程方法,超越了其他范式,要么会被告知它主要是一个理论上的东西,比好处更多的是复杂性,在实际世界中几乎不可能实现。而且,通常情况下,真正的答案不在极端之间,而是在其中某个地方。
对于琐事迷来说,仍在使用的最古老的语言是 FORTRAN,它于 1957 年出现,比 LISP 早了一年。LISP 之后不久又出现了另一种长寿的语言:面向业务编程的 COBOL。
理论与实践
在这本书中,我们不会以理论的方式来讨论函数式编程:我们的观点是,相反地,要展示一些函数式编程的技术和原则如何成功地应用于日常的 JavaScript 编程。但是,这很重要,我们不会以教条的方式来做这件事,而是以非常实际的方式。我们不会因为它们不符合函数式编程的学术期望而放弃有用的 JS 构造。我们也不会避免实际的 JS 特性,只是为了符合函数式编程的范式。事实上,我们几乎可以说我们将会做SFP—**有点函数式编程,因为我们的代码将是函数式编程特性和更经典的命令式和面向对象编程(OOP)的混合。
(这并不意味着我们会把所有的理论都丢在一边。我们会挑剔,只触及主要的理论要点,给一些词汇和定义,并解释核心的函数式编程概念...但我们始终会牢记帮助产生实际有用的 JS 代码的想法,而不是试图达到某种神秘的、教条式的函数式编程标准。)
OOP 一直是解决编写大型程序和系统的固有复杂性,以及开发清洁、可扩展、可伸缩的应用架构的一种方式。然而,由于今天的 Web 应用规模不断增长,所有代码库的复杂性也在不断增加。此外,JS 的新特性使得开发几年前甚至不可能的应用成为可能;例如,使用 Ionic、Apache Cordova 或 React Native 开发的移动(混合)应用,或者使用 Electron 或 NW.js 开发的桌面应用。JS 也已经迁移到了后端,使用 Node.js,因此今天语言的使用范围已经严重扩大,处理所有增加的复杂性对所有设计都是一种负担。
一种不同的思维方式
FP 意味着一种不同的编程方式,有时可能很难学习。在大多数语言中,编程是以命令式的方式进行的:程序是一系列语句,按照规定的方式执行,并通过创建对象并对它们进行操作来实现所需的结果,通常会修改对象本身。FP 是基于通过评估表达式来产生所需的结果,这些表达式由组合在一起的函数构建而成。在 FP 中,通常会传递函数(作为其他函数的参数,或作为某些计算的结果返回),不使用循环(而是选择递归),并且跳过副作用(例如修改对象或全局变量)。
另一种说法是,FP 关注的是应该做什么,而不是如何做。你不必担心循环或数组,而是在更高的层次上工作,考虑需要完成的任务。适应了这种风格之后,你会发现你的代码变得更简单、更短、更优雅,并且可以轻松进行测试和调试。然而,不要陷入将 FP 视为目标的陷阱!将 FP 仅视为达到目的的手段,就像所有软件工具一样。功能性代码并不仅仅因为是功能性的而好...使用 FP 编写糟糕的代码与使用其他技术一样可能!
函数式编程不是什么
既然我们已经说了一些关于 FP 是什么的事情,让我们也澄清一些常见的误解,并考虑一些 FP不是的事情:
-
FP 不仅仅是学术的象牙塔之物:它是真实的,基于它的lambda 演算是由阿隆佐·邱奇在 1936 年开发的,作为证明理论计算机科学中重要结果的工具。(这项工作比现代计算机语言早了 20 多年!)然而,FP 语言今天被用于各种系统。
-
FP 不是面向对象编程(OOP)的对立面:它也不是选择声明式或命令式编程的情况。你可以根据自己的需要混合使用,我们将在本书中进行这种混合,汇集所有最好的东西。
-
学习 FP 并不是过于复杂:一些 FP 语言与 JS 相比相当不同,但区别主要是语法上的。一旦你学会了基本概念,你会发现你可以在 JS 中获得与 FP 语言相同的结果。
还值得一提的是,一些现代框架,如 React+Redux 组合,包含了 FP 的思想。例如,在 React 中,视图(用户在某一时刻看到的内容)被认为是当前状态的函数。你使用函数来计算每个时刻必须生成的 HTML 和 CSS,以黑盒的方式思考。
同样,在 Redux 中,你会得到actions的概念,这些actions由reducers处理。一个action提供一些数据,而reducer是一个函数,以一种功能性的方式从当前状态和提供的数据中产生应用程序的新状态。
因此,无论是因为理论上的优势(我们将在接下来的部分中介绍这些优势)还是实际上的优势(比如能够使用最新的框架和库),考虑使用 FP 编码都是有意义的;让我们开始吧。
为什么使用函数式编程?
多年来,出现了许多编程风格和潮流。然而,FP 已经被证明相当有韧性,并且今天非常有趣。你为什么要关心使用 FP?问题应该首先是,你想得到什么?然后才是FP 能帮你实现吗?
我们需要的
我们当然可以同意以下关注点是普遍的。我们的代码应该是:
-
模块化:程序的功能应该被划分为独立的模块,每个模块包含执行程序功能的一个方面所需的内容。对模块或函数的更改不应影响代码的其余部分。
-
可理解性:程序的读者应该能够辨别其组件、它们的功能,并理解它们之间的关系,而不需要过多的努力。这与可维护性高度相关:你的代码将来必须进行维护,以改变或添加一些新功能。
-
可测试性:单元测试尝试测试程序的小部分,验证它们的行为与其余代码的独立性。你的编程风格应该有利于编写简化编写单元测试工作的代码。此外,单元测试就像文档,因为它们可以帮助读者理解代码应该做什么。
-
可扩展性:事实上,你的程序总有一天会需要维护,可能是为了添加新功能。这些更改应该对原始代码的结构和数据流只有最小的影响(如果有的话)。小的更改不应该意味着对代码进行大规模、严重的重构。
-
可重用性:代码重用的目标是通过利用先前编写的代码来节省资源、时间、金钱,并减少冗余。有一些特征有助于实现这一目标,比如模块化(我们已经提到过),再加上高内聚(模块中的所有部分都是相关的)、低耦合(模块之间相互独立)、关注点分离(程序的各部分应该尽可能少地重叠功能)、以及信息隐藏(模块内部的变化不应该影响系统的其余部分)。
我们得到了什么
那么,FP 是否能满足这五个特点呢?
-
在 FP 中,目标是编写独立的函数,它们被组合在一起以产生最终结果。
-
以函数式风格编写的程序通常更加清晰、更短、更容易理解。
-
函数可以单独进行测试,FP 代码在这方面有优势。
-
你可以在其他程序中重用函数,因为它们是独立的,不依赖于系统的其他部分。大多数函数式程序共享常见的函数,其中我们将在本书中考虑其中的一些。
-
函数式代码没有副作用,这意味着你可以通过研究函数来理解其目的,而不必考虑程序的其余部分。
最后,一旦你习惯了 FP 的方式,代码就会变得更容易理解和扩展。因此,似乎所有五个特点都可以通过 FP 来实现!
对于 FP 的原因,我建议阅读约翰·休斯的《为什么函数式编程很重要》(Why Functional Programming Matters);它可以在网上找到www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf。虽然它不是针对 JS 的,但这些论点仍然很容易理解。
并非所有都是金子……
然而,让我们努力追求一点平衡。使用 FP 并不是一个能够自动使你的代码变得更好的“灵丹妙药”。一些 FP 解决方案实际上是棘手的,有些开发人员在编写代码后会兴高采烈地问“这段代码是做什么用的?”如果你不小心,你的代码可能会变得“只能写”,几乎不可能维护……这样就会失去“可理解性”、“可扩展性”和“可重用性”!
另一个缺点是:你可能会发现很难找到精通 FP 的开发人员。(快问:你见过多少招聘“寻找函数式编程员”的工作广告?)今天绝大多数的 JS 代码都是用命令式、非函数式的方式编写的,大多数编程人员习惯于这种工作方式。对于一些人来说,不得不转变思路,开始以不同的方式编写程序,可能会成为一个无法逾越的障碍。
最后,如果你试图完全采用函数式方法,你可能会发现自己与 JS 不合拍,简单的任务可能会变得难以完成。正如我们在开始时所说的,我们更愿意选择“有点函数式”,因此我们不会彻底拒绝任何不是 100%函数式的 JS 特性。我们希望使用 FP 来简化我们的编码,而不是使其更加复杂!
因此,虽然我会努力向你展示在你的代码中采用功能性的优势,但与任何改变一样,总会有一些困难。然而,我完全相信你能够克服这些困难,并且你的组织将通过应用 FP 开发出更好的代码。敢于改变!
JavaScript 是功能性的吗?
大约在这个时候,你应该问另一个重要的问题:JS 是一种功能性语言吗?通常,在考虑 FP 时,提到的语言不包括 JS,但列出了一些常见的选项,比如 Clojure、Erlang、Haskell 或 Scala。然而,对于 FP 语言没有明确的定义,也没有一组确切的特性。主要的观点是,如果一种语言支持与 FP 相关的常见编程风格,那么你可以认为它是功能性的。
JavaScript 作为一种工具
JS 是什么?如果你考虑像www.tiobe.com/tiobe-index/或pypl.github.io/PYPL.html
这样的流行指数,你会发现 JS 一直处于十大流行之列。从更学术的角度来看,这种语言有点像混合体,具有来自几种不同语言的特性。几个库帮助了语言的发展,通过提供一些不那么容易获得的特性,比如类和继承(今天的 JS 版本确实支持类,但不久前还不是这样),否则必须通过一些原型技巧来模拟。
JavaScript这个名字是为了利用 Java 的流行而选择的——只是作为一种营销策略!它的第一个名字是Mocha;然后是LiveScript,然后才是JavaScript。
JS 已经发展成为非常强大的工具。但是,就像所有强大的工具一样,它可以帮助你产生出色的解决方案,也可以造成巨大的伤害。FP 可以被认为是一种减少或放弃语言中一些最糟糕部分的方式,并专注于以更安全、更好的方式工作。然而,由于现有的大量 JS 代码,你不能期望对语言进行大规模的重构,这将导致大多数网站失败。你必须学会接受好的和坏的,并简单地避免后者。
此外,JS 有各种各样的可用库,以许多方式完善或扩展语言。在本书中,我们将专注于单独使用 JS,但我们将参考现有的可用代码。
如果我们问 JS 是否实际上是功能性的,答案将是,再一次,有点。由于一些特性,如一流函数,匿名函数,递归和闭包,JS 可以被认为是功能性的——我们稍后会回到这个问题。另一方面,JS 有很多非函数式的方面,比如副作用(不纯性),可变对象和递归的实际限制。因此,当以一种功能性的方式编程时,我们将利用所有相关的 JS 语言特性,并尽量减少语言更传统部分造成的问题。从这个意义上讲,JS 将或不将是功能性的,取决于你的编程风格!
如果你想使用 FP,你应该决定使用哪种语言。然而,选择完全功能性的语言可能并不明智。今天,开发代码并不像只是使用一种语言那么简单:你肯定需要框架、库和其他各种工具。如果我们可以利用所有提供的工具,同时在我们的代码中引入 FP 工作方式,我们将得到最好的两种世界——不管 JS 是不是功能性!
使用 JavaScript 进行功能性编程
JS 经过多年的发展,我们将使用的版本(非正式地)称为 JS8,(正式地)称为 ECMAScript 2017,通常缩写为 ES2017 或 ES8;这个版本于 2017 年 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 月最终确定
ECMA 最初代表欧洲计算机制造商协会,但现在这个名字不再被认为是一个首字母缩写。该组织负责的标准不仅仅是 JS,还包括 JSON、C#、Dart 等。请参阅其网站www.ecma-international.org/。
您可以在www.ecma-international.org/ecma-262/7.0/上阅读标准语言规范。每当我们在文本中提到 JS 而没有进一步的规定时,指的是 ES8(ES2017)。然而,在本书中使用的语言特性方面,如果您只使用 ES2015,您不会在本书中遇到问题。
没有浏览器完全实现 ES8;大多数提供较旧版本的 JavaScript 5(从 2009 年开始),其中包含 ES6、ES7 和 ES8 的一些功能。这将成为一个问题,但幸运的是,这是可以解决的;我们很快就会解决这个问题,并且在整本书中我们将使用 ES8。
事实上,ES2016 和 ES2015 之间只有一点点区别,比如Array.prototype.includes
方法和指数运算符**
。ES2017 和 ES2016 之间有更多的区别,比如async
和await
,一些字符串填充函数等,但它们不会影响我们的代码。
JavaScript 的主要特点
JS 不是一种函数式语言,但它具有我们需要的所有功能,可以像函数式语言一样工作。我们将使用的语言的主要特点是:
-
函数作为一等对象
-
递归
-
箭头函数
-
闭包
-
展开
让我们看一些每一个的例子,解释为什么它们对我们有用。
函数作为一等对象
说函数是一等对象(也可以说是一等公民)意味着您可以对函数做任何其他对象可以做的事情。例如,您可以将函数存储在变量中,将其传递给函数,将其打印出来等等。这确实是进行 FP 的关键:我们经常会将函数作为参数(传递给其他函数)或将函数作为函数调用的结果返回。
如果您一直在进行异步 Ajax 调用,您已经在使用这个功能:回调是一个在 Ajax 调用完成后被调用并作为参数传递的函数。使用 jQuery,您可以写出类似以下的代码:
$.get("some/url", someData, function(result, status) {
// *check status, and do something*
// *with the result*
});
$.get()
函数接收一个回调函数作为参数,并在获得结果后调用它。
这个问题可以更现代化地通过使用 promises 或 async/await 来解决,但是为了我们的例子,旧的方法已经足够了。不过,我们将在第十二章的构建更好的容器-功能数据类型中讨论单子时,会回到 promises;特别是看看意外的单子:promises一节。
由于函数可以存储在变量中,您也可以这样写:
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!。如果你继续处理这个例子,你会得到5!=54321=120,所以n!是所有小于n*的所有数字的乘积。
这可以立即转换为 JS 代码:
function fact(n) {
if (n === 0) {
return 1;
} else {
return n * fact(n - 1);
}
}
console.log(fact(5)); // *120*
递归将是算法设计的重要辅助工具。通过使用递归,您可以不使用任何while
或for
循环——虽然我们不想这样做,但有趣的是我们能!我们将把完整的第九章,设计函数-递归,用于设计算法和递归编写函数。
闭包
闭包是实现数据隐藏(使用私有变量)的一种方式,这导致了模块和其他很好的特性。关键概念是,当你定义一个函数时,它不仅可以引用自己的局部变量,还可以引用函数上下文之外的所有东西:
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()
)不应该在使用相同参数调用时返回不同的结果!
我们将发现闭包有几种用途:包括记忆化(见第四章,行为良好-纯函数,和第六章,生成函数-高阶函数)和模块模式(见第三章,从函数开始-核心概念,和第十一章,实现设计模式-函数式方法)。
箭头函数
箭头函数只是创建(无名)函数的一种更简洁的方式。箭头函数几乎可以在几乎任何地方使用经典函数,除了它们不能用作构造函数。语法要么是(参数,另一个参数,...等)=> { *语句* }
,要么是(参数,另一个参数,...等)=> *表达式*。第一种允许您编写尽可能多的代码;第二种是
{ return 表达式 }`的简写。我们可以将我们之前的 Ajax 示例重写为:
$.get("some/url", data, (result, status) => {
// *check status, and do something*
// *with the result*
});
阶乘代码的新版本可能是:
const fact2 = n => {
if (n === 0) {
return 1;
} else {
return n * fact2(n - 1);
}
};
console.log(fact2(5)); // *also 120*
箭头函数通常被称为匿名函数,因为它们没有名称。如果您需要引用箭头函数,您必须将其分配给变量或对象属性,就像我们在这里做的那样;否则,您将无法使用它。我们将在第三章的箭头函数部分中看到更多内容,从函数开始-核心概念。
你可能会将后者写成一行代码——你能看到等价吗?
const fact3 = n => (n === 0 ? 1 : n * fact3(n - 1));
console.log(fact3(5)); // again 120
使用这种更短的形式,您不必写return
--它是暗示的。简短的评论:当箭头函数只有一个参数时,您可以省略括号。我通常更喜欢留下它们,但我已经应用了一个 JS 美化程序prettier到代码中,它会删除它们。是否包括它们取决于您!(有关此工具的更多信息,请查看github.com/prettier/prettier
。)顺便说一句,我格式化的选项是--print-width 75 --tab-width 4 --no-bracket-spacing
。
在λ演算中,函数x => 2*x
将表示为λx.2x--尽管有一些语法上的差异,但定义是类似的。具有更多参数的函数会复杂一些:(x,y)=>x+y将表示为λx.λy.x+y.我们将在第三章的Lambda 和函数部分,第七章的柯里化*部分中看到更多关于这一点的内容。
扩展
传播运算符(参见developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Spread_operator
)允许您在需要多个参数、元素或变量的地方扩展表达式。例如,您可以替换函数调用中的参数:
const x = [1, 2, 3];
function sum3(a, b, c) {
return a + b + c;
}
const y = sum3(...x); // equivalent to sum3(1,2,3)
console.log(y); // 6
您还可以创建或加入数组:
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]
它也适用于对象:
const p = { some: 3, data: 5 };
const q = { more: 8, ...p }; // { more:8, some:3, data:5 }
您还可以使用它来处理期望单独参数而不是数组的函数。这种情况的常见示例是Math.min()
和Math.max()
:
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*
您还可以编写以下等式。.apply()
方法需要一个参数数组,而.call()
则需要单独的参数:
someFn.apply(thisArg, someArray) === someFn.call(thisArg, ...someArray);
如果您记不住.apply()
和.call()
需要哪些参数,这个记忆法可能会有所帮助:A 代表数组,C 代表逗号。有关更多信息,请参见developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/apply
和developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call
。
使用传播运算符有助于编写更短、更简洁的代码,我们将充分利用它。
我们如何使用 JavaScript?
这一切都很好,但正如我们之前提到的,几乎所有地方都可用的 JS 版本都不是 ES8,而是较早的 JS5。Node.js 是一个例外:它基于 Chrome 的 V8 高性能 JS 引擎,该引擎已经支持了几个 ES8 功能。尽管如此,截至今天,ES8 覆盖率并不是 100%,还有一些功能是您会错过的。(有关 Node 和 V8 的更多信息,请查看nodejs.org/en/docs/es6/
。)
那么,如果您想使用最新版本进行编码,但可用的版本是较早、较差的版本,您该怎么办?或者,如果您的大多数用户可能使用不支持您想要使用的新功能的老版本浏览器,会发生什么?让我们看看一些解决方案。
如果您想在使用任何给定的新功能之前确保,可以查看kangax.github.io/compat-table/es6/
上的兼容性表。 (见图 1.1)。特别是对于 Node.js,请查看node.green/
。图 1.1 - JS 的最新版本尚未得到广泛和完全支持,因此在使用任何新功能之前,您需要进行检查
使用转换器
为了摆脱这种可用性和兼容性问题,你可以使用一些转译器。转译器将你的原始 ES8 代码转换为等效的 JS5 代码。(这是一种源到源的转换,而不是编译中的源到对象代码。)你可以使用 ES8 的高级特性编码,但用户的浏览器将接收 JS5 代码。转译器还可以让你跟上语言的即将推出的版本,尽管浏览器在桌面和移动设备上采用新标准需要时间。
如果你想知道转译器一词是从哪里来的,它是translate和compiler的混成词。在技术术语中有许多这样的组合:email(electronic+mail)、emoticon(emotion+icon)、malware(malicious+software)、或alphanumeric(alphabetic+numeric),以及其他几个。
JS 最常见的转译器是Babel(在babeljs.io/
)和Traceur(在github.com/google/traceur-compiler
)。使用npm或Webpack等工具,配置代码自动转译并提供给最终用户非常容易。你也可以在线尝试转译;参见图 1.2,这是使用 Babel 的在线环境的示例:
图 1.2 - Babel 转译器将 ES8 代码转换为兼容的 JS5 代码
如果你更喜欢 Traceur,可以使用它的工具google.github.io/traceur-compiler/demo/repl.html#
,但你需要打开开发者控制台来查看运行代码的结果。(见图 1.3。)选择实验选项,以完全启用 ES8 支持:
图 1.3 - Traceur 转译器是 ES8 到 JS5 翻译的同样有效的选择使用转译器也是学习新 JS 特性的好方法。只需在左侧输入一些代码,然后在右侧看到等效的结果。或者,使用命令行界面(CLI)工具来转译源文件,然后检查生成的输出。
还有一个可能要考虑的选择:不使用 JS,而是选择微软的 TypeScript(在www.typescriptlang.org/
),这是 JS 的超集,编译为 JS5。TypeScript 的主要优势是为 JS 添加(可选的)静态类型检查,有助于在编译时检测一些编程错误。注意:与 Babel 或 Traceur 一样,并非所有 ES8 都可用。
你也可以在不使用 TypeScript 的情况下获得类型检查,方法是使用 Facebook 的 Flow(参见flow.org/
)。
如果选择使用 TypeScript,你也可以在它们的playground上在线测试;参见www.typescriptlang.org/play/
。你可以设置选项来更严格或更宽松地检查数据类型,并且还可以立即运行你的代码。见图 1.4:
图 1.4 - TypeScript 添加了类型检查功能,使 JS 编程更安全
在线工作
有一些在线工具可以用来测试你的 JS 代码。查看JSFiddle(在jsfiddle.net/
)、CodePen(在codepen.io/
)、或JSBin(在jsbin.com/
)等等。你可能需要指定是否使用 Babel 或 Traceur;否则,新的 JS 特性将被拒绝。在图 1.5 中可以看到 JSFiddle 的示例:
图 1.5 - JSFiddle 让你尝试 ES8 代码(还包括 HTML 和 CSS),而无需任何其他工具
测试
我们还将涉及测试,毕竟,这是 FP 的主要优势之一。为此,我们将使用 Jasmine(jasmine.github.io/
),尽管我们也可以选择 Mocha(mochajs.org/
)。
您可以使用 Karma(karma-runner.github.io
)等运行器来运行 Jasmine 测试套件,但我选择了独立测试;有关详细信息,请参见github.com/jasmine/jasmine#installation
。
问题
1.1. 类作为一等对象:我们看到函数是一等对象,但您知道类也是吗?(当然,谈论类作为对象听起来很奇怪……)研究这个例子,看看是什么使它起作用!注意:其中有一些故意奇怪的代码:
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.2. 阶乘错误:我们定义的阶乘应该只计算非负整数。然而,我们编写的函数没有验证其参数是否有效。您能添加必要的检查吗?尽量避免重复冗余的测试!
1.3. 爬升阶乘:我们的阶乘实现从n开始乘,然后是n-1,然后是n-2,依此类推,可以说是以向下的方式。您能否编写阶乘函数的新版本,它将以向上的方式循环?
总结
在本章中,我们已经了解了函数式编程的基础知识,以及一些历史、优势(也可能有一些可能的劣势,公平地说),为什么我们可以将其应用于 JavaScript,这通常不被认为是一种函数式语言,以及我们将需要哪些工具才能利用本书的其余部分。
在第二章中,“功能性思维-第一个例子”,我们将讨论一个简单问题的例子,并以常见的方式来看待它,最终以函数式的方式解决它,并分析这种工作方式的优势。
第二章:功能性思维 - 第一个例子
在第一章中,成为功能性 - 几个问题,我们讨论了 FP 是什么,提到了应用它的一些优势,并列出了一些我们在 JS 中需要的工具...但现在让我们把理论抛在脑后,从考虑一个简单的问题开始,以及如何以功能性的方式解决它。
在这一章中,我们将看到:
-
一个简单的、常见的、与电子商务相关的问题
-
用它们相关的缺陷解决它的几种常见方法
-
通过功能性的方式解决问题的方法
-
一个高阶解决方案,可以应用到其他问题上
-
如何对功能性解决方案进行单元测试
在未来的章节中,我们将回到这里列出的一些主题,所以我们不会深入细节。我们只会展示 FP 如何为我们的问题提供不同的观点,并留下更多细节以后再讨论。
问题 - 只做一次某事
让我们考虑一个简单但常见的情况。你开发了一个电子商务网站:用户可以填写他们的购物车,最后,他们必须点击一个“账单”按钮,这样他们的信用卡就会被收费。然而,用户不应该点击两次(或更多),否则他们将被多次计费。
你的应用程序的 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
。当用户点击按钮时,如果clicked
是false
,你就把它改为true
,并执行该函数;否则,你根本不做任何事情:
let clicked = false;
.
.
.
function billTheUser(some, sales, data) {
if (!clicked) {
clicked = true;
window.alert("Billing the user...");
// *actually bill the user*
}
}
关于不使用全局变量的更多好理由,
阅读wiki.c2.com/?GlobalVariablesAreBad
。
这显然有效,但有几个问题必须解决:
-
你正在使用一个全局变量,你可能会意外地改变它的值。全局变量不是一个好主意,无论是在 JS 还是其他语言中。
-
当用户重新开始购买时,你还必须记得重新将其初始化为
false
。如果你不这样做,用户将无法进行第二次购买,因为支付将变得不可能。 -
你将很难测试这段代码,因为它依赖于外部事物(也就是
clicked
变量)。
所以,这不是一个很好的解决方案...让我们继续思考!
解决方案#3 - 移除处理程序
我们可以采用一种侧面的解决方案,而不是让函数避免重复点击,我们可能只是完全删除点击的可能性:
function billTheUser(some, sales, data) {
document.getElementById("billButton").onclick = null;
window.alert("Billing the user...");
// actually bill the user
}
这个解决方案也有一些问题:
-
代码与按钮紧密耦合,因此您将无法在其他地方重用它
-
您必须记住重置处理程序,否则用户将无法进行第二次购买
-
测试也会更加困难,因为您将不得不提供一些 DOM 元素
我们可以稍微改进这个解决方案,并通过在调用中提供后者的 ID 作为额外参数来避免将函数与按钮耦合在一起。(这个想法也可以应用于以下一些解决方案。)HTML 部分将是:
<button
id="billButton"
onclick="billTheUser('billButton', some, sales, data)"
>
Bill me
</button>;
(注意额外的参数)和被调用的函数将是:
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.");
}
function billTheUser(some, sales, data) {
document.getElementById("billButton").onclick = alreadyBilled;
window.alert("Billing the user...");
// actually bill the user
}
这个解决方案有一个好处:如果用户第二次点击,他们会收到一个警告,不要这样做,但他们不会再次被收费。(从用户体验的角度来看,这更好。)但是,这个解决方案仍然有与前一个相同的异议(代码与按钮耦合在一起,需要重置处理程序,更难的测试),所以我们不认为它很好。
解决方案#5-禁用按钮
一个类似的想法:不要删除事件处理程序,而是禁用按钮,这样用户就无法单击。您可能会有一个类似以下的函数。
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-使用本地标志
我们可以回到使用标志的想法,但是不要使其全局(这是我们的主要异议),我们可以使用立即调用的函数表达式(IIFE):我们将在第三章中看到更多关于这一点,从函数开始-核心概念,以及在第十一章中,实施设计模式-功能方式。通过这样做,我们可以使用闭包,因此clicked
将局部于函数,而不会在任何其他地方可见:
var billTheUser = (clicked => {
return (some, sales, data) => {
if (!clicked) {
clicked = true;
window.alert("Billing the user...");
// *actually bill the user*
}
};
})(false);
看看clicked
如何从最后的调用中获得其初始值false
。
这个解决方案沿着全局变量解决方案的思路,但是使用私有的本地变量是一种增强。我们唯一找到的异议是,您将不得不重新设计需要以这种方式工作的每个函数。(正如我们将在下一节中看到的那样,我们的 FP 解决方案在某些方面与它相似。)好吧,这并不难做,但不要忘记不要重复自己(D.R.Y)的建议!
一个功能性的解决方案
让我们尝试更通用一些:毕竟,要求某个函数或其他函数只执行一次,这并不奇怪,而且可能在其他地方也需要!让我们建立一些原则:
-
原始函数(只能调用一次的函数)应该只执行那件事,而不是其他事情
-
我们不想以任何方式修改原始函数
-
我们需要一个新函数,只能调用原始函数一次
-
我们希望有一个通用解决方案,可以应用于任意数量的原始函数
先前列出的第一个原则是单一职责原则(S.O.L.I.D.中的S),它规定每个函数应负责单一功能。有关 S.O.L.I.D.的更多信息,请查看Uncle Bob(编写了这五个原则的 Robert C. Martin)的文章butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod
。
我们能做到吗?是的;我们将编写一个高阶函数,我们将能够将其应用于任何函数,以生成一个只能工作一次的新函数。让我们看看!
一个高阶解决方案
如果我们不想修改原始函数,我们将创建一个高阶函数,我们将其有灵感地命名为once()
。该函数将接收一个函数作为参数,并将返回一个只能工作一次的新函数。(我们将在第六章中看到更多的高阶函数;特别是,请参阅Doing things once, revisited部分。)
Underscore 和 LoDash 已经有一个类似的函数,被调用为_.once()
。Ramda 还提供了R.once()
,大多数 FP 库都包含类似的功能,因此您不必自己编写它。
我们的once()
函数方式一开始似乎有些强制,但是当您习惯以 FP 方式工作时,您会习惯这种代码,并发现它非常易懂。
const once = fn => {
let done = false;
return (...args) => {
if (!done) {
done = true;
fn(...args);
}
};
};
让我们来看一下这个函数的一些要点:
-
第一行显示
once()
接收一个函数(fn()
)作为其参数。 -
我们通过利用闭包定义了一个内部的私有
done
变量,就像之前的解决方案#7 一样。我们选择不将其称为clicked
,因为您不一定需要点击按钮才能调用该函数;我们选择了一个更通用的术语。 -
return (...args) => ...
这一行表示once()
将返回一个带有一些(0、1 或更多)参数的函数。请注意,我们正在使用我们在第一章中看到的扩展语法,成为函数式 - 几个问题。在较旧版本的 JS 中,您必须使用arguments
对象;有关更多信息,请参阅developer.mozilla.org/en/docs/Web/JavaScript/Reference/Functions/arguments
。ES8 的方式更简单更短! -
在调用
fn()
之前,我们先赋值done = true
,以防该函数抛出异常。当然,如果您不想在函数成功结束之前禁用该函数,那么您可以将赋值移到fn()
调用的下面。 -
设置完成后,我们最终调用原始函数。请注意使用扩展运算符传递原始
fn()
的任何参数。
那么我们该如何使用它呢?我们甚至不需要将新生成的函数存储在任何地方;我们可以简单地编写onclick
方法,如下所示:
<button id="billButton" onclick="once(billTheUser)(some, sales, data)">
Bill me
</button>;
请注意语法!当用户点击按钮时,使用(some, sales, data)
参数调用的函数不是billTheUser()
,而是使用billTheUser
作为参数调用once()
的结果。该结果只能被调用一次。
请注意,我们的once()
函数使用函数作为一等对象、箭头函数、闭包和展开操作符;回到第一章,成为函数式编程 - 几个问题,我们说我们会需要这些,所以我们信守承诺!我们在这一章中唯一缺少的是递归...但正如滚石乐队唱的那样,你并不总是能得到你想要的!
手动测试解决方案
我们可以运行一个简单的测试:
const squeak = a => console.log(a, " squeak!!");
squeak("original"); // "original squeak!!"
squeak("original"); // "original squeak!!" squeak("original"); // "original squeak!!" const squeakOnce = once(squeak);
squeakOnce("only once"); // "only once squeak!!"
squeakOnce("only once"); // no output
squeakOnce("only once"); // no output
在 CodePen 上查看结果,或者查看图 2.2:
图 2.2 - 测试我们的 once()高阶函数
自动测试解决方案
手动运行测试不好;它会变得烦人、无聊,久而久之,就不再运行测试了。让我们做得更好一些,用 Jasmine 编写一些自动测试。按照jasmine.github.io/pages/getting_started.html
上的说明,我设置了一个独立的运行器:
<!DOCTYPE html> <html> <head>
<meta charset="utf-8">
<title>Jasmine Spec Runner v2.6.1</title>
<link rel="shortcut icon" type="image/png" href="lib/jasmine-2.6.1/jasmine_favicon.png">
<link rel="stylesheet" href="lib/jasmine-2.6.1/jasmine.css">
<script src="lib/jasmine-2.6.1/jasmine.js"></script>
<script src="lib/jasmine-2.6.1/jasmine-html.js"></script>
<script src="lib/jasmine-2.6.1/boot.js"></script>
<script src="src/once.js"></script>
<script src="tests/once.test.1.js"></script> </head> <body> </body> </html>
src/once.js
文件中有我们刚刚看到的once()
定义,tests/once.test.js
中有实际的测试套件:
describe("once", () => {
beforeEach(() => {
window.myFn = () => {};
spyOn(window, "myFn");
});
it("without 'once', a function always runs", () => {
myFn();
myFn();
myFn();
expect(myFn).toHaveBeenCalledTimes(3);
});
it("with 'once', a function runs one time", () => {
window.onceFn = once(window.myFn);
spyOn(window, "onceFn").and.callThrough();
onceFn();
onceFn();
onceFn();
expect(onceFn).toHaveBeenCalledTimes(3);
expect(myFn).toHaveBeenCalledTimes(1);
});
});
这里有几点需要注意:
-
为了监听一个函数,它必须与一个对象相关联。(或者,你也可以直接使用 Jasmine 的
.createSpy()
方法直接创建一个 spy。)全局函数与 window 对象相关联,所以window.fn
是一种说法,即fn
实际上是全局的。 -
当你对一个函数进行监听时,Jasmine 会拦截你的调用并注册函数被调用的次数、使用的参数以及调用的次数。所以,就我们所关心的而言,
window.fn
可以简单地是null
,因为它永远不会被执行。 -
第一个测试只检查如果我们多次调用函数,它会被调用相应的次数。这很琐碎,但如果这没有发生,我们肯定做错了什么!
-
在第二组测试中,我们想要看到
once()
函数(window.onceFn()
)被调用,但只调用一次。所以,我们告诉 Jasmine 监听onceFn
,但让调用通过。对fn()
的任何调用也会被计数。在我们的情况下,尽管调用了onceFn()
三次,fn()
只被调用了一次,这是我们预期的。
我们可以在图 2.3 中看到结果:
图 2.3 - 在 Jasmine 上运行自动测试我们的函数
一个更好的解决方案
在之前的解决方案中,我们提到每次第一次之后都做一些事情而不是默默地忽略用户的点击是一个好主意。我们将编写一个新的高阶函数,它接受第二个参数;一个从第二次调用开始每次都要调用的函数:
const onceAndAfter = (f, g) => {
let done = false;
return (...args) => {
if (!done) {
done = true;
f(...args);
} else {
g(...args);
}
};
};
我们已经在高阶函数中更进一步;onceAndAfter
接受两个函数作为参数,并产生一个包含另外两个函数的第三个函数。
你可以通过为g
提供一个默认值来使onceAndAfter
更加强大,类似于const onceAndAfter = (f, g = ()=>{})
...所以如果你不想指定第二个函数,它仍然可以正常工作,因为它会调用一个什么都不做的函数,而不是引起错误。
我们可以进行一个快速而简单的测试,与之前我们做的类似:
const squeak = (x) => console.log(x, "squeak!!");
const creak = (x) => 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!!"
为这个新函数编写测试并不难,只是有点长:
describe("onceAndAfter", () => {
it("should call the first function once, and the other after", () => {
func1 = () => {};
spyOn(window, "func1");
func2 = () => {};
spyOn(window, "func2");
onceFn = onceAndAfter(func1, func2);
onceFn();
expect(func1).toHaveBeenCalledTimes(1);
expect(func2).toHaveBeenCalledTimes(0);
onceFn();
expect(func1).toHaveBeenCalledTimes(1);
expect(func2).toHaveBeenCalledTimes(1);
onceFn();
expect(func1).toHaveBeenCalledTimes(1);
expect(func2).toHaveBeenCalledTimes(2);
onceFn();
expect(func1).toHaveBeenCalledTimes(1);
expect(func2).toHaveBeenCalledTimes(3);
});
});
请注意,我们总是检查func1
只被调用一次。同样,我们检查func2
;调用次数从零开始(func1
被调用的时间),然后每次调用都会增加一次。
问题
2.1. 没有额外的变量:我们的函数式实现需要使用一个额外的变量done
来标记函数是否已经被调用。这并不重要...但你能在不使用任何额外变量的情况下做到吗?请注意,我们并没有告诉你不使用任何变量;这只是一个不添加新变量,比如done
,只是一个练习!
2.2. 交替函数:在我们的onceAndAfter()
函数的精神下,你能否编写一个alternator()
高阶函数,它接受两个函数作为参数,并在每次调用时交替调用一个和另一个?预期的行为应该如下例所示:
let sayA = () => console.log("A");
let sayB = () => console.log("B");
let 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)会产生完全相同行为的函数。
总结
在这一章中,我们看到了一个常见的简单问题,基于一个真实的情况,并在分析了几种通常的解决方法之后,我们选择了一个功能性思维的解决方案。我们看到了如何将 FP 应用到我们的问题上,我们还找到了一个更一般的高阶方法,我们可以将其应用到类似的问题上,而无需进行进一步的代码更改。我们看到了如何为我们的代码编写单元测试,以完成开发工作。最后,我们甚至提出了一个更好的解决方案(从用户体验的角度来看),并看到了如何编写代码以及如何对其进行单元测试。
在下一章第三章中,从函数开始-核心概念,我们将更深入地探讨函数,这是所有 FP 的核心。
第三章:开始学习函数 - 一个核心概念
在第二章中,函数式思维 - 第一个例子,我们讨论了一个函数式思维的例子,但现在让我们回到基础,复习一下函数。在第一章中,成为函数式 - 几个问题,我们提到两个重要的 JS 特性是函数作为一等对象和闭包。现在,在这一章中,让我们:
-
检查 JS 中定义函数的一些关键方式
-
详细讨论箭头函数,它们是最接近 lambda 演算函数的
-
介绍currying的概念
-
重新审视函数作为一等对象的概念
我们还将考虑几种函数式编程技术,比如:
-
注入,根据不同策略进行排序和其他用途
-
回调和 promises,引入continuation passing 风格
-
Polyfilling 和 stubbing
-
立即调用方案
关于函数的一切
让我们从 JS 中函数的简要回顾和它们与函数式编程概念的关系开始。我们可以从我们在之前章节提到的东西开始,关于函数作为一等对象,然后继续讨论它们在 JS 中的使用。
关于 lambda 和函数
用 lambda 演算的术语来看,一个函数可以看起来像λx.2x。理解的是,λ* 字符后面的变量是函数的参数,点后面的表达式是你将要替换为传递的任何值的地方。
如果你有时想知道参数和实参之间的区别,一些头韵的助记词可能会有所帮助:Parameters are Potential, Arguments are Actual. 参数是潜在值的占位符,将要传递的值,而实参是传递给函数的实际值。
应用一个函数意味着你向它提供一个实际的参数,并且通常是用括号来表示。例如,(λx.2x)(3)* 将被计算为 6。这些 lambda 函数在 JS 中的等价物是什么?这是一个有趣的问题!有几种定义函数的方式,并且并非所有的方式都有相同的含义。
一篇很好的文章展示了定义函数、方法等的多种方式,是JavaScript 中函数的多种面孔,由 Leo Balter 和 Rick Waldron 撰写,网址是bocoup.com/blog/the-many-faces-of-functions-in-javascript
--去看看吧!
在 JS 中你可以用多少种方式定义一个函数?答案是,可能比你想象的要多! 至少,你可以写:
-
一个命名的函数声明:
function first(...) {...};
-
一个匿名函数表达式:
var second = function(...) {...};
-
一个命名的函数表达式:
var third = function someName(...) {...};
-
一个立即调用的表达式:
var fourth = (function() { ...; return function(...) {...}; })();
-
一个函数构造器:
var fifth = new Function(...);
-
一个箭头函数:
var sixth = (...) => {...};
如果你愿意的话,你还可以添加对象方法声明,因为它们实际上也意味着函数,但这已经足够了。
JS 还允许定义生成器函数,如function*(...) {...}
,实际上返回一个Generator
对象,以及真正是生成器和 promises 混合的async
函数。我们不会使用这些类型的函数,但是可以在developer.mozilla.org/en/docs/Web/JavaScript/Reference/Statements/function*
和developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
了解更多--它们在其他情境中可能会有用。
所有这些定义函数的方式之间的区别是什么,为什么我们要在意?让我们一一讨论:
- 第一个定义,使用
function
关键字作为独立声明,可能是 JS 中最常用的方式,并定义了一个名为first
的函数(即first.name=="first"
)。由于变量提升,这个函数将在定义它的作用域中随处可访问。
在developer.mozilla.org/en-US/docs/Glossary/Hoisting
上阅读更多关于变量提升的内容,并记住它只适用于声明,而不适用于初始化。
- 第二个定义,将函数赋值给一个变量,也会产生一个函数,但是是一个匿名的函数(即没有名称)。然而,许多 JS 引擎能够推断名称应该是什么,并设置
second.name=="second"
(检查下面的代码,显示了匿名函数没有被分配名称的情况)。由于赋值不会被提升,函数只有在赋值执行后才能访问。此外,你可能更喜欢用const
来定义变量,而不是var
,因为你不应该改变这个函数:
var second = function() {};
console.log(second.name);
// "second"
var myArray = new Array(3);
myArray[1] = function() {};
console.log(myArray[1].name);
// ""
- 第三个定义与第二个相同,只是函数现在有了自己的名称:
third.name === "someName"
。
函数的名称在你想要调用它时是相关的,如果你计划进行递归调用也是相关的;我们将在第九章Designing Functions - Recursion中回到这一点。如果你只是想要一个用于回调的函数,你可以不用名称。但是请注意,命名函数在错误回溯中更容易被识别。
- 第四个定义,使用立即调用的表达式,让你可以使用闭包。内部函数可以以完全私有、封装的方式使用外部函数中定义的变量或其他函数。回到我们在第一章的Closures部分看到的计数器制作函数,我们可以写出以下内容:
var myCounter = (function(initialValue = 0) {
let count = initialValue;
return function() {
count++;
return count;
};
})(77);
myCounter(); // 78
myCounter(); // 79
myCounter(); // 80
仔细研究代码:外部函数接收一个参数(在这种情况下是 77),这个参数被用作count
的初始值(如果没有提供初始值,我们从零开始)。内部函数可以访问count
(因为闭包的原因),但是这个变量在其他地方是无法访问的。在所有方面,返回的函数是一个普通的函数;唯一的区别是它可以访问私有元素。这也是module模式的基础。
- 第五个定义是不安全的,你不应该使用它!你传递参数名称,然后将实际的函数体作为最后一个参数的字符串传递--并且使用了
eval()
的等价物来创建函数,这可能会导致许多危险的黑客攻击,所以不要这样做!只是为了激发你的好奇心,让我们看一个例子,重写我们在第一章的Spread部分中看到的非常简单的sum3()
函数:
var sum3 = new Function("x", "y", "z", "var t = x+y+z; return t;");
sum3(4, 6, 7); // 17
这种定义不仅不安全,而且还有一些其他怪癖,比如不会在创建上下文中创建闭包,而且总是全局的。查看developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function
了解更多信息,但请记住,使用这种方式创建函数不是一个好主意!
- 最后,使用箭头
=>
定义的最紧凑的方式来定义函数,我们将尽可能地尝试使用这种方式。我们将在下一节详细介绍。
箭头函数 - 现代的方式
即使箭头函数基本上与其他函数一样工作,但是与普通函数有一些重要的区别。这些函数可以隐式返回一个值,this
的值不会被绑定,也没有arguments
对象。让我们来看看这三点。
还有一些额外的区别:箭头函数不能用作构造函数,它们没有prototype
属性,也不能用作生成器,因为它们不允许使用yield
关键字。有关这些点的更多细节,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions#No_binding_of_this
。
返回值
在 lambda 风格中,函数只包括一个结果。为了简洁起见,新的箭头函数提供了这种语法。当你写类似(x,y,z) =>
的表达式时,会隐含一个返回。例如,以下两个函数实际上与我们之前展示的sum3()
函数做的事情是一样的:
const f1 = (x, y, z) => x + y + z;
const f2 = (x, y, z) => {
return x + y + z;
};
如果你想返回一个对象,那么你必须使用括号,否则 JS 会认为代码是有意义的。
“风格问题:当你用只有一个参数定义箭头函数时,你可以省略它周围的括号。为了一致性,我更喜欢总是包括它们。然而,我使用的格式化工具,prettier,不赞成。随意选择你的风格!”
处理 this 值
JS 的一个经典问题是处理this
的方式--它的值并不总是你期望的那样。ES2015 通过箭头函数解决了这个问题,它们继承了正确的this
值,因此避免了问题。要看一个可能出现问题的例子,在下面的代码中,当超时函数被调用时,this
将指向全局(window
)变量,而不是新对象,所以你会在控制台中得到一个未定义:
function ShowItself1(identity) {
this.identity = identity;
setTimeout(function() {
console.log(this.identity);
}, 1000);
}
var x = new ShowItself1("Functional");
// *after one second, **undefined** is displayed*
有两种经典的解决方法,使用老式的 JS5,以及箭头函数的工作方式:
-
一种解决方案使用了闭包,并定义了一个本地变量(通常命名为
that
或者有时是self
),它将获得this
的原始值,这样它就不会是未定义的 -
第二种方法使用
.bind()
,所以超时函数将绑定到正确的this
值。 -
第三种更现代的方式只是使用箭头函数,所以
this
会得到正确的值(指向对象)而无需其他操作
我们还将使用.bind()
。请参见 lambda 和 eta 部分。
让我们看看实际代码中的三种解决方案:
function ShowItself2(identity) {
this.identity = identity;
let that = this;
setTimeout(function() {
console.log(that.identity);
}, 1000);
setTimeout(
function() {
console.log(this.identity);
}.bind(this),
2000
);
setTimeout(() => {
console.log(this.identity);
}, 3000);
}
var x = new ShowItself2("JavaScript");
// *after one second, "JavaScript"*
// *after another second, the same*
// *after yet another second, once again*
处理参数
在第一章中,成为功能性-几个问题,和第二章中,思考功能性-第一个例子,我们看到了一些使用扩展(...
)运算符的用法。然而,我们将要做的最实际的用法,与处理参数有关;我们将在第六章中看到一些这方面的案例,生成函数-高阶函数。让我们回顾一下我们的once()
函数:
const once = func => {
let done = false;
return (...args) => {
if (!done) {
done = true;
func(...args);
}
};
};
为什么我们要写return (...args) =>
,然后是func(...args)
?关键在于处理可变数量(可能为零)的参数的更现代方式。在旧版本的 JS 中,你是如何处理这种代码的?答案与arguments
对象有关(不是数组!),它允许你访问传递给函数的实际参数。
有关更多信息,请阅读developer.mozilla.org/en/docs/Web/JavaScript/Reference/Functions/arguments.
在 JS5 及更早版本中,如果我们希望函数能够处理任意数量的参数,我们必须编写以下代码:
function somethingElse() {
// *get arguments and do something*
}
function listArguments() {
console.log(arguments);
var myArray = Array.prototype.slice.call(arguments);
console.log(myArray);
somethingElse.apply(null, myArray);
}
listArguments(22, 9, 60);
// (3) [22, 9, 60, callee: function, Symbol(Symbol.iterator): function]
// (3) [22, 9, 60]
第一个日志显示arguments
实际上是一个对象;第二个日志对应一个简单的数组。另外,注意调用somethingElse()
所需的复杂方式,需要使用.apply()
。
在 ES8 中等价的代码是什么?答案要简短得多,这就是为什么我们将在整个文本中看到使用扩展运算符的几个例子:
function listArguments2(...args) {
console.log(args);
somethingElse(...args);
}
listArguments2(12, 4, 56);
// (3) [12, 4, 56]
要记住的要点是:
-
通过编写
listArguments2(...args)
,我们立即并清楚地表达了我们的新函数接收多个(可能为零)参数。 -
你无需做任何事情就可以得到一个数组。控制台日志显示
args
确实是一个数组,不需要进一步操作。 -
编写
somethingElse(...args)
比之前必须使用的替代方法(使用.apply()
)更清晰。
顺便说一下,ES8 中仍然可以使用arguments
对象。如果你想从中创建一个数组,有两种替代方法可以做到,而不必使用Array.prototype.slice.call
的技巧:
-
使用
.from()
方法,并写var myArray=Array.from(arguments)
-
或者更简单地说,比如
var myArray=[...arguments]
,这展示了扩展操作符的另一种用法。
当我们涉及到高阶函数时,编写处理其他函数的函数,可能具有未知数量的参数,将会很普遍。ES8 提供了一种更简洁的方法来做到这一点,这就是为什么你必须习惯这种用法;这是值得的!
一个参数还是多个参数?
还可以编写返回函数的函数,在第六章中,我们将看到更多的这种情况。例如,在 lambda 演算中,你不会写带有多个参数的函数,而只会使用一个参数,通过应用一种叫做“柯里化”的东西(为什么要这样做?先留着这个想法;我们会讲到的)。
柯里化得名于哈斯克尔·柯里,他发展了这个概念。请注意,他也因函数式编程语言Haskell的名字而被铭记;双重认可!
例如,我们之前看到的对三个数字求和的函数,将被写成如下形式:
const altSum3 = x => y => z => x + y + z;
为什么我改变了函数的名字?简单地说,因为这与之前的函数不相同。尽管它可以用来产生与我们之前函数完全相同的结果,但它在一个重要的方面有所不同:你如何使用它?比如,对数字 1、2 和 3 求和?你将不得不写成:
altSum3(1)(2)(3); // 6
在继续阅读之前先自我测试一下,并思考一下:如果你写成altSum3(1,2,3)
会返回什么?
提示:它不会是一个数字!要获得完整答案,请继续阅读。
这是如何工作的?分开多次调用可能会有所帮助;这是 JS 解释器实际计算前面表达式的方式:
let fn1 = altSum3(1);
let fn2 = fn1(2);
let fn3 = fn2(3);
从功能上来说!调用altSum3(1)
的结果,根据定义,是一个函数,由于闭包的原因,等效于:
let fn1 = y => z => 1 + y + z;
我们的altSum3()
函数旨在接收一个参数,而不是三个!这次调用的结果fn1
也是一个单参数函数。当你执行fn1(2)
时,结果再次是一个函数,同样只有一个参数,等效于:
let fn2 = z => 1 + 2 + z;
当你计算fn2(3)
时,最终返回一个值;太好了!正如我们所说,这个函数做的是我们之前看到的相同类型的计算,但是以一种内在不同的方式。
你可能会认为柯里化只是一个奇特的技巧:谁会只想使用单参数函数呢?当我们考虑如何在第八章中连接函数-流水线和组合,或者第十二章中构建更好的容器-函数数据类型时,你会明白这样做的原因,下一步传递多个参数将不可行。
函数作为对象
“头等对象”的概念意味着函数可以被创建、分配、更改、作为参数传递,或者作为其他函数的结果返回,就像你可以对待数字或字符串一样。让我们从它们的定义开始。当你以通常的方式定义一个函数时:
function xyzzy(...) { ... }
这(几乎)等同于写成:
var xyzzy = function(...) { ... }
除了hoisting。JS 将所有定义移动到当前范围的顶部,但不包括赋值;因此,使用第一个定义,您可以从代码的任何位置调用xyzzy(...)
,但使用第二个定义,直到执行赋值之后才能调用该函数。
看到与巨型洞穴冒险游戏的类似之处了吗?在任何地方调用xyzzy(...)
并不总是有效!如果您从未玩过这个著名的互动小说游戏,请尝试在线游戏--例如,在www.web-adventures.org/cgi-bin/webfrotz?s=Adventure
或www.amc.com/shows/halt-and-catch-fire/colossal-cave-adventure/landing
。
我们想要表达的观点是,函数可以分配给变量--并且如果需要,还可以重新分配。同样,我们可以在需要时现场定义函数。我们甚至可以在不命名它们的情况下执行此操作:与常见表达式一样,如果仅使用一次,则不需要命名它或将其存储在变量中。
一个 React+Redux 减速器
我们可以看到另一个涉及分配函数的例子。正如我们在本章前面提到的,React+Redux 通过分派由减速器处理的操作来工作。通常,减速器包括带有开关的代码:
function doAction(state = initialState, action) {
let newState = {};
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;
}
}
为state
提供initialState
作为默认值是初始化全局状态的简单方法。不要注意这个默认值;对于我们的示例来说并不重要,我只是为了完整性而包含它。
通过利用存储函数的可能性,我们可以构建一个调度表并简化前面的代码。首先,我们将使用每种操作类型的函数代码初始化一个对象。基本上,我们只是采用前面的代码,并创建单独的函数:
const dispatchTable = {
CREATE: (state, action) => {
// *update state, generating newState,*
// *depending on the action data*
// *to create a new item*
return newState;
},
DELETE: (state, action) => {
// *update state, generating newState,*
// *after deleting an item*
return newState;
},
UPDATE: (state, action) => {
// *update an item,*
// *and generate an updated state*
return newState;
}
};
我们已经将处理每种类型的操作的不同函数存储为对象中的属性,该对象将作为调度表。该对象仅创建一次,并且在应用程序执行期间保持不变。有了它,我们现在可以用一行代码重写操作处理代码:
function doAction2(state = initialState, action) {
return dispatchTable[action.type]
? dispatchTableaction.type
: state;
}
让我们来分析一下:给定操作,如果action.type
与调度对象中的属性匹配,我们执行相应的函数,该函数取自存储它的对象。如果没有匹配,我们只需返回当前状态,就像 Redux 要求的那样。如果我们不能处理函数(存储和调用它们)作为一等对象,这种代码是不可能的。
一个不必要的错误
然而,通常会有一个常见的(尽管实际上是无害的)错误。您经常会看到这样的代码:
fetch("some/remote/url").then(function(data) {
processResult(data);
});
这段代码是做什么的?这个想法是获取远程 URL,并在数据到达时调用一个函数--这个函数本身调用processResult
并将data
作为参数。也就是说,在then()
部分,我们希望一个函数,给定data
,计算processResult(data)
...我们已经有这样一个函数了吗?
一点点理论:在λ演算术语中,我们将λx.func x 替换为一个函数--这称为 eta 转换,更具体地说是 eta 缩减。(如果您要以另一种方式进行操作,那将是 eta 抽象。)在我们的情况下,这可以被认为是一种(非常非常小的!)优化,但它的主要优势是更短,更紧凑的代码。
基本上,我们可以应用的规则是,每当您看到以下内容时:
function someFunction(someData) {
return someOtherFunction(someData);
}
您可以用someOtherFunction
替换它。因此,在我们的示例中,我们可以直接写下面的内容:
fetch("some/remote/url").then(processResult);
这段代码与以前的方式完全相同(或者,由于避免了一个函数调用,可能稍微更快),但更容易理解...或者不是?
这种编程风格称为 pointfree 风格或暗示风格,其主要特点是您从不为每个函数应用指定参数。这种编码方式的优势在于,它有助于编写者(以及代码的未来读者)思考函数本身及其含义,而不是在低级别上处理数据并与之一起工作。在较短的代码版本中,没有多余或无关的细节:如果您了解所调用的函数的作用,那么您就了解了完整代码的含义。在我们的文本中,我们通常(但不一定总是)以这种方式工作。
Unix/Linux 用户可能已经习惯了这种风格,因为当他们使用管道将命令的结果作为输入传递给另一个命令时,他们就以类似的方式工作。当您编写类似 ls | grep doc | sort 的内容时,ls 的输出是 grep 的输入,后者的输出是 sort 的输入--但是输入参数没有写在任何地方;它们是暗示的。我们将在第八章的PointFree Style部分中回到这一点,连接函数 - 管道和组合。
使用方法
然而,有一种情况您应该注意:如果您正在调用对象的方法会发生什么?如果您的原始代码是这样的:
fetch("some/remote/url").then(function(data) {
myObject.store(data);
});
然后,看似明显的转换后的代码会失败:
fetch("some/remote/url").then(myObject.store);
为什么?原因是在原始代码中,调用的方法绑定到一个对象(myObject
),但在修改后的代码中,它没有绑定,它只是一个free
函数。然后我们可以通过使用bind()
以简单的方式来修复它:
fetch("some/remote/url").then(myObject.store.bind(myObject));
这是一个通用解决方案。处理方法时,您不能只是分配它;您必须使用.bind(
以便正确的上下文可用。像这样的代码:
function 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()
的更多信息。
这看起来相当笨拙,不太优雅,但是这是必需的,以便方法将与正确的对象关联。我们将在第六章中看到这种应用,生成函数 - 高阶函数。即使这段代码看起来不太好看,但是每当您必须使用对象(记住,我们并没有说我们会尝试完全 FP 代码,并且如果其他构造使事情变得更容易,我们将接受其他构造)时,您必须记住在以 pointfree 风格传递它们之前绑定方法。
使用 FP 方式的函数
实际上有几种常见的编码模式实际上利用了 FP 风格,即使您不知道。让我们来看看它们,并指出代码的功能方面,这样您就可以更加习惯这种编码风格。
注入 - 整理它
Array.prototype.sort()
方法提供了将函数作为参数传递的第一个示例。如果您有一个字符串数组,并且想对其进行排序,您可以使用以下代码。例如,要按字母顺序对彩虹颜色数组进行排序:
var 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之前...这需要修复!下面的代码显示了问题。
var someNumbers = [3, 20, 100];
someNumbers.sort();
console.log(someNumbers);
// ***[100, 20, 3]***
但是,让我们暂时忘记数字,继续排序字符串。我们要问自己:如果我们想按适当的区域设置规则对一些西班牙单词(palabras)进行排序,会发生什么?我们将对字符串进行排序,但结果无论如何都不正确:
var palabras = ["ñandú", "oasis", "mano", "natural", "mítico", "musical"];
palabras.sort();
console.log(palabras);
// *["mano", "musical", "mítico", "natural", "oasis", "ñandú"]* -- ***wrong result***!
对于语言或生物学爱好者,英文中的"ñandú"
是"rhea"
,一种类似鸵鸟的奔跑鸟。以"ñ"
开头的西班牙语单词并不多,我们碰巧在我的国家乌拉圭有这些鸟,所以这就是这个奇怪单词的原因!
糟糕!在西班牙语中,"ñ"
位于"n"
和"o"
之间,但"ñandú"
最终被排序。此外,"mítico"
(英文中为"mythical"
;请注意带重音的"i"
)应该出现在"mano"
和"musical"
之间,因为应该忽略波浪号。解决这个问题的适当方法是为sort()
提供一个比较函数。在这种情况下,我们可以使用localeCompare()
方法:
palabras.sort((a, b) => a.localeCompare(b, "es"));
console.log(palabras);
// *["mano", "mítico", "musical", "natural", "ñandú", "oasis"]*
a.localeCompare(b,"es")
调用比较字符串a
和b
,如果a
应该在b
之前,则返回负值,如果a
应该在b
之后,则返回正值,如果a
和b
相同,则返回 0--但是,根据西班牙("es"
)排序规则。现在事情变得正确了!通过引入一个易懂的名称的新函数,代码可能会变得更清晰:
const spanishComparison = (a, b) => a.localeCompare(b, "es");
palabras.sort(spanishComparison);
// *sorts the palabras array according to Spanish rules:*
// *["mano", "mítico", "musical", "natural", "ñandú", "oasis"]*
在接下来的章节中,我们将讨论 FP 如何让您以更声明式的方式编写代码,生成更易理解的代码,这种小的改变有所帮助:代码的读者在到达排序时,即使没有注释,也会立即推断出正在做什么。
通过注入不同的比较函数来改变sort()
函数的工作方式,实际上是策略设计模式的一个案例。我们将在第十一章中看到更多关于这一点的内容,实现设计模式-函数式方法。
以参数形式提供排序函数(以非常 FP 的方式!)还可以帮助解决其他一些问题,例如:
-
sort()
只适用于字符串。如果要对数字进行排序(就像我们之前尝试的那样),您必须提供一个进行数字比较的函数。例如,您可以编写类似myNumbers.sort((a,b) => a-b)
的东西 -
如果要按给定属性对对象进行排序,您将使用一个与之进行比较的函数。例如,您可以按年龄对人进行排序,类似于
myPeople.sort((a,b) => a.age - b.age)
的方式
有关localeCompare()
的更多可能性,请参阅developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare
。您可以指定要应用的区域设置规则,要放置大写/小写字母的顺序,是否忽略标点符号等等--但要小心;并非所有浏览器都支持所需的额外参数。
这是一个简单的例子,您可能以前使用过--但毕竟是 FP 模式。让我们继续讨论函数作为参数的更常见用法,当您进行 Ajax 调用时。
回调,承诺和继续
可能是将函数作为一等对象使用的最常见例子与回调和承诺有关。在 Node.JS 中,读取文件是通过类似以下方式异步完成的:
const fs = require("fs");
fs.readFile("someFile.txt", (err, data) => {
if (err) {
console.error(err); // *or throw an error, or otherwise handle the problem*
} else {
console.log(data.toString());
}
});
readFile()
函数需要一个回调,在这个例子中只是一个匿名函数,当文件读取操作完成时调用。
使用更现代的编程风格,您可以使用承诺或 async/await。例如,在进行 Ajax 网络服务调用时,使用更现代的fetch()
函数,您可以编写类似以下代码的内容:
fetch("some/remote/url")
.then(data => {
// *Do some work with the returned data*
})
.catch(error => {
// *Process all errors here*
});
请注意,如果您定义了适当的processData(data)
和processError(error)
函数,代码可以缩短为fetch("some/remote/url").then(processData).catch(processError)
,就像我们之前看到的那样。
Continuation Passing Style
在前面的代码中,您调用一个函数,同时传递另一个函数,该函数在输入/输出操作完成时将被执行,可以被视为 CPS - Continuation Passing Style的一种情况。这种编码方式是什么?一个解释方式是,如果使用return
语句是被禁止的,您将如何编程?
乍一看,这可能看起来是一个不可能的情况。然而,我们可以摆脱困境,只要我们同意这一点:允许您将回调传递给被调用的函数,因此当该过程准备返回给调用者时,它将调用传递的回调,而不是实际返回。在这些条件下,回调为被调用的函数提供了继续过程的方式,因此称为Continuation。我们现在不会深入讨论这个问题,但在第九章中,设计函数 - 递归,我们将深入研究它。特别是,CPS 将有助于避免重要的递归限制,正如我们将看到的那样。
研究如何使用 continuations 有时是具有挑战性的,但总是可能的。这种编码方式的一个有趣优势是,通过自己指定过程如何继续,您可以超越所有通常的结构(if
,while
,return
等)并实现您可能想要的任何机制。这在某些类型的问题中可能非常有用,其中过程不一定是线性的。当然,这也可能导致您发明任何一种控制结构,远比您可能想象的使用GOTO
语句更糟糕!图 3.1 显示了这种做法的危险!
图 3.1:如果您开始干扰程序流程,最糟糕的情况会是什么?
(注:这张 XKCD 漫画可以在 https://xkcd.com/292/上在线获取。)
您不仅限于传递单个 continuation。与 promises 一样,您可以提供两个或更多的备用回调。顺便说一句,这也可以提供另一个问题的解决方案:您如何处理异常?如果我们简单地允许函数抛出错误,那将意味着隐含地返回给调用者 - 而我们不希望这样。解决方法是提供一个备用回调(即不同的 continuation),以便在抛出异常时使用(在第十二章中,构建更好的容器 - 函数数据类型,我们将找到另一个解决方案,使用Monads):
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)*
}
Polyfills
能够动态分配函数(就像您可以为变量分配不同的值一样)还可以让您在定义polyfills时更有效地工作。
检测 Ajax
让我们回到 Ajax 开始出现的时候。鉴于不同的浏览器以不同的方式实现了 Ajax 调用,您总是需要围绕这些差异编码:
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 检查,即使测试的结果永远不会改变。有一种更有效的方法,它涉及使用函数作为一等对象。我们可以定义两个不同的函数,只测试一次条件,然后将正确的函数分配给以后使用:
(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,代表Immediately Invoked Function Expression。函数运行后,会自我清理,因为它的所有变量都是局部的,在函数运行后甚至都不存在了。我们以后会更多地了解这一点。
添加缺失的函数
这种在运行时定义函数的想法,也使我们能够编写polyfills,提供其他缺失的函数。例如,假设我们不是写代码像:
if (currentName.indexOf("Mr.") !== -1) {
// *it's a man*
...
}
你会更喜欢使用更新、更清晰的方式,只需写:
if (currentName.includes("Mr.")) {
// *it's a man*
...
}
如果你的浏览器不提供.includes()
会发生什么?再一次,我们可以在运行时定义适当的函数,但只有在需要时才这样做。如果.includes()
可用,你什么都不用做,但如果它缺失了,你就定义一个提供完全相同功能的 polyfill。
你可以在 Mozilla 的开发者网站上找到许多现代 JS 功能的 polyfill。例如,我们用于 includes 的 polyfill 直接取自developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/String/includes
。
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,几乎不太可能引起任何冲突。
最后,如果你认为之前展示的 Ajax 示例已经老掉牙了,考虑一下:如果你想使用更现代的fetch()
方式来调用服务,你会发现并不是所有的现代浏览器都支持它(查看caniuse.com/#search=fetch
来验证),你也需要使用一个 polyfill,比如github.com/github/fetch
上的 polyfill。研究一下代码,你会发现它基本上使用了之前描述的相同方法,来检查是否需要一个 polyfill,并创建它。
Stubbing
这是一个在某些方面类似于 polyfill 的用例:根据环境的不同,让函数执行不同的工作。这个想法是做stubbing,这是测试中的一个概念,意思是用另一个函数替换一个函数,这个函数执行一个更简单的工作,而不是执行实际的工作。
一个常见的情况是使用日志函数。你可能希望应用程序在开发时进行详细的日志记录,但在生产时不发出任何声音。一个常见的解决方案是写一些类似于以下的东西:
let myLog = someText => {
if (DEVELOPMENT) {
console.log(someText); // *or some other way of logging*
} else {
// do nothing
}
}
这样做是有效的,但就像关于 Ajax 检测的示例一样,它做的工作比需要的要多。
关于 Ajax 检测,它做的工作比需要的要多,因为它每次都要检查应用程序是否处于开发状态。如果我们将日志函数 stub out,这样它就不会实际记录任何东西,我们可以简化代码(并获得一个非常非常小的性能提升!):
let myLog;
if (DEVELOPMENT) {
myLog = someText => console.log(someText);
} else {
myLog = someText => {};
}
我们甚至可以用三元运算符做得更好:
const myLog = DEVELOPMENT
? someText => console.log(someText)
: someText => {};
这有点晦涩,但我更喜欢它,因为它使用了const
,它是不可修改的。
考虑到 JS 允许调用函数时传递比参数更多的参数,并且当我们不处于开发状态时myLog()
不做任何事情,我们也可以写() => {}
,它也可以正常工作。然而,我更喜欢保持相同的签名,这就是为什么我指定了someText
参数,即使它不会被使用;由你决定!
立即调用
还有另一种常见的函数用法,通常在流行的库和框架中看到,它让你从其他语言中带入 JS(甚至是旧版本!)一些模块化的优势。通常的写法是像下面这样:
(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);
模式本身被称为,正如我们提到的,立即调用函数表达式 - 通常简化为 IIFE,发音为iffy。这个名字很容易理解:你正在定义一个函数并立即调用它,所以它立即执行。为什么要这样做,而不是简单地内联编写代码呢?原因与作用域有关。
注意函数周围的括号。这有助于解析器理解我们正在写一个表达式。如果你省略了第一组括号,JS 会认为你正在写一个函数声明而不是调用。括号也作为一个视觉提示,所以你的代码读者会立即认出 IIFE。
如果你在 IIFE 内定义了任何变量或函数,由于 JS 的函数作用域,这些定义将是内部的,你的代码的任何其他部分都无法访问它。想象一下,你想写一些复杂的初始化,比如下面的例子:
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,问题就不会发生。此外,三个内部函数甚至不会对代码的其余部分可见,这有助于保持全局命名空间的污染较少:
(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
变量,因为它只能在返回的函数内部访问。
问题
3.1 未初始化的对象?React+Redux 程序员通常编写action creators来简化稍后由 reducer 处理的操作的创建。操作是对象,必须包括一个type
属性,用于确定你正在分派的操作的类型。下面的代码应该做到这一点,但你能解释意外的结果吗?
const simpleAction = t => {
type: t;
};
console.log(simpleAction("INITIALIZE"));
// ***undefined***
3.2. 箭头函数允许吗?如果你使用箭头函数来定义listArguments()
和listArguments2()
,而不是我们使用的经典方式,使用function
关键字,一切都会一样吗?
3.3. 一行代码。一些节省代码行数的程序员建议将doAction2()
重写为一行代码...尽管格式不让它看起来如此!你认为这样正确吗?
const doAction3 = (state = initialState, action) =>
(dispatchTable[action.type] &&
dispatchTableaction.type) ||
state;
总结
在本章中,我们讨论了 JS 中定义函数的几种方式,主要关注箭头函数,它比标准函数有几个优点,包括更简洁。我们展示了柯里化的概念(我们稍后会重新讨论),考虑了函数作为一等对象的一些方面,最后考虑了几种 JS 技术,这些技术在概念上完全是 FP。
在第四章中,行为得当 - 纯函数,让我们更深入地探讨函数,从而引入纯函数的概念,这将使我们的编程风格更好。
第四章:行为得当-纯函数
在第三章中,从函数开始-核心概念,我们将函数视为 FP 中的关键元素,详细介绍了箭头函数,并介绍了一些概念,如注入、回调、填充和存根。现在,在这一章中,我们将有机会重新审视或应用其中一些想法,同时我们也...
-
考虑纯度的概念,以及为什么我们应该关心纯函数
-
审查引用透明性的概念
-
认识到副作用所暗示的问题
-
展示纯函数的一些优势
-
描述不纯函数的主要原因
-
找到减少不纯函数数量的方法
-
专注于测试纯函数和不纯函数的方法
纯函数
纯函数的行为方式与数学函数相同,并提供各种好处。如果函数满足两个条件,可以认为函数是纯的:
-
给定相同的参数,函数总是计算并返回相同的结果,无论调用多少次,或者在什么条件下调用它。这个结果值不能依赖于任何外部信息或状态,这些信息在程序执行期间可能会发生变化,并导致它返回不同的值。函数结果也不能依赖于 I/O 结果、随机数或其他外部变量,这些变量不是直接可控的值。
-
在计算其结果时,函数不会引起任何可观察的副作用,包括输出到 I/O 设备,对象的突变,函数外部程序状态的改变等等。
如果你愿意,你可以简单地说纯函数不依赖于,也不修改其范围之外的任何东西,并且总是对相同的输入参数返回相同的结果。
在这个背景下还有一个词叫做幂等性,但它并不完全相同。一个幂等函数可以被调用任意次,并且总是产生相同的结果。然而,这并不意味着函数没有副作用。幂等性通常在 RESTful 服务的背景下提到,并且一个简单的例子展示了纯度和幂等性之间的区别。一个PUT
调用会导致数据库记录被更新(一个副作用),但如果你重复调用,元素将不会被进一步修改,因此数据库的全局状态不会再发生变化。
我们还可以引用一个软件设计原则,并提醒自己函数应该只做一件事,只做一件事,而且只做那件事。如果一个函数做了其他事情,并且有一些隐藏的功能,那么对状态的依赖将意味着我们无法预测函数的输出,并且会让开发人员的工作变得更加困难。
让我们更详细地了解这些条件。
引用透明性
在数学中,引用透明性是一种属性,它允许您用其值替换表达式,而不改变您正在进行的任何操作的结果。
引用透明性的对应物是引用不透明性。引用不透明的函数不能保证始终产生相同的结果,即使使用相同的参数调用。
举个简单的例子,当优化编译器决定进行常量折叠并替换句子时:
var x = 1 + 2 * 3;
与:
var x = 1 + 6;
或者,更好的是,直接使用:
var x = 7;
为了节省执行时间,它利用了所有数学表达式和函数(根据定义)都是引用透明的事实。另一方面,如果编译器无法预测给定表达式的输出,它将无法以任何方式优化代码,计算将不得不在运行时进行。
在λ演算中,如果你用函数的计算值替换涉及函数的表达式的值,这个操作被称为β(beta)规约。请注意,你只能安全地对引用透明的函数进行这样的操作。
所有算术表达式(涉及数学运算符和函数)都是引用透明的:229*总是可以被 198 替换。涉及 I/O 的表达式不是透明的,因为它们的结果在执行之前无法知道。出于同样的原因,涉及日期和时间相关函数或随机数的表达式也不是透明的。
关于 JS 函数,你可能会自己编写一些不满足引用透明条件的函数。事实上,函数甚至不需要返回一个值,尽管 JS 解释器会在这种情况下返回一个未定义的值。
有些语言区分函数和过程,预期函数返回某个值,而过程不返回任何东西,但 JS 不是这种情况。此外,有些语言提供手段来确保函数是引用透明的。
如果你愿意的话,你可以将 JS 函数分类为:
-
纯函数:它们根据其参数返回一个值,并且没有任何副作用
-
副作用:它们不返回任何东西(实际上,JS 让这些函数返回一个
undefined
值,但这在这里并不重要),但会产生某种副作用 -
具有副作用的函数:意味着它们返回一些值(这些值可能不仅取决于函数参数,还涉及副作用)
在 FP 中,非常强调第一组引用透明函数。不仅编译器可以推断程序行为(从而能够优化生成的代码),而且程序员也可以更容易地推断程序和其组件之间的关系。反过来,这可以帮助证明算法的正确性,或者通过用等效函数替换一个函数来优化代码。
副作用
什么是副作用?我们可以将其定义为在执行某些计算或过程期间发生的状态变化或与外部元素(用户、网络服务、另一台计算机等)的交互。
对于这个意义的范围可能存在一些误解。在日常语言中,当你谈论副作用时,这有点像谈论附带损害--对于给定行动的一些意外后果。然而,在计算中,我们包括函数外的每一个可能的效果或变化。如果你编写一个旨在执行console.log()
调用以显示一些结果的函数,即使这正是你首先打算让函数执行的,它也会被视为副作用!
通常的副作用
有(太多!)被认为是副作用的事情。在 JS 编程中,包括前端和后端编码,你可能会发现更常见的副作用包括:
-
改变全局变量。
-
改变接收的对象。
-
进行任何类型的 I/O,比如显示警报消息或记录一些文本。
-
处理和更改文件系统。
-
更新数据库。
-
调用网络服务。
-
查询或修改 DOM。
-
触发任何外部进程。
-
最后,只是调用一些其他函数,这些函数恰好会产生自己的副作用。你可以说不纯度是具有传染性的:调用不纯的函数的函数会自动变得不纯!
有了这个定义,让我们开始考虑什么会导致函数不纯(或者引用不透明,正如我们所看到的)。
全局状态
在所有前述观点中,最常见的原因是使用非本地变量,与程序的其他部分共享全局状态。由于纯函数根据定义,始终返回相同的输出值,给定相同的输入参数,如果函数引用其内部状态之外的任何东西,它就会自动变得不纯。此外,这对于调试是一个障碍,要理解函数的作用,你必须了解状态如何得到其当前值,这意味着要理解程序的所有过去历史:这并不容易!
let limitYear = 1999;
const isOldEnough = birthYear => birthYear <= limitYear;
console.log(isOldEnough(1960)); // true
console.log(isOldEnough(2001)); // false
isOldEnough()
函数正确检测一个人是否至少 18 岁,但它依赖于一个外部变量(该变量仅适用于 2017 年)。除非你知道外部变量及其值是如何得到的,否则你无法知道函数的作用。测试也很困难;你必须记住创建全局limitYear
变量,否则所有的测试都将无法运行。尽管函数可以工作,但实现并不是最佳的。
这个规则有一个例外。看看下面的情况:circleArea
函数,它根据半径计算圆的面积,是纯的还是不纯的?
const PI = 3.14159265358979;
const circleArea = r => PI * Math.pow(r, 2); // or PI * r ** 2
尽管函数正在访问外部状态,但PI
是一个常数(因此不能被修改),允许在circleArea
中替换它而不改变功能,因此我们应该接受函数是纯净的。对于相同的参数,函数将始终返回相同的值,因此满足我们的纯度要求。
即使你使用Math.PI
而不是我们定义的常数(顺便说一句,这是一个更好的主意),参数仍然是相同的;常数是不能改变的,所以函数保持纯净。
内部状态
这个概念也适用于内部变量,其中存储了本地状态,然后用于将来的调用。在这种情况下,外部状态没有改变,但是有一些副作用意味着未来从函数返回的值会有所不同。让我们想象一个roundFix()
四舍五入函数,它考虑到是否已经过多地向上或向下四舍五入,所以下次它将以另一种方式四舍五入,使累积差异更接近零:
const roundFix = (function() {
let accum = 0;
return n => {
// *reals get rounded up or down*
// *depending on the sign of accum*
let 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()
。我们使用的方式更清晰,但我只是想提醒你,如果你碰巧发现这种其他编码风格。
仅使用两个值(认出它们吗?)运行此函数显示,对于给定的输入,结果并不总是相同。控制台日志的结果部分显示了值是如何四舍五入的,向上还是向下:
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,因为累积的差值是负的;我们得到了相同输入的不同值!其余的例子类似;你可以得到相同的值被舍入为上或下,取决于累积的差异,因为函数的结果取决于它的内部状态。
这种使用内部状态的方式,是为什么许多 FPers 认为使用对象可能是不好的。在 OOP 中,我们开发人员习惯于存储信息(属性)并将它们用于未来的计算。然而,这种用法被认为是不纯的,因为尽管传递相同的参数,重复的方法调用可能返回不同的值。
参数突变
你还需要意识到一个不纯的函数可能会修改它的参数。在 JS 中,参数是按值传递的,除了数组和对象,它们是按引用传递的。这意味着对函数参数的任何修改都会影响原始对象或数组的实际修改。这可能会更加模糊,因为有几种mutator方法,它们根据定义改变了底层对象。例如,假设你想要一个函数,它会找到一个字符串数组的最大元素(当然,如果它是一个数字数组,你可以简单地使用Math.max()
而无需进一步操作)。一个简短的实现可能如下所示:
const maxStrings = a => a.sort().pop();
let countries = ["Argentina", "Uruguay", "Brasil", "Paraguay"];
console.log(maxStrings(countries)); // ***"Uruguay"***
该函数确实提供了正确的结果(如果你担心外语,我们已经在第三章的注入:解决问题部分看到了解决方法,从函数开始-核心概念),但它有一个缺陷:
console.log(countries); // ***["Argentina", "Brasil", "Paraguay"]***
糟糕的是,原始数组被修改了;这是根据定义的副作用!如果你再次调用maxStrings(countries)
,而不是返回与之前相同的结果,它会产生另一个值;显然,这不是一个纯函数。在这种情况下,一个快速的解决方法是对数组的副本进行操作(我们可以使用扩展运算符来帮助),但我们将在第十章中处理更多避免这类问题的方法,确保纯度-不可变性:
const maxStrings2 = a => [...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"
)的函数:
const getRandomLetter = () => {
const min = "A".charCodeAt();
const max = "Z".charCodeAt();
return String.fromCharCode(
Math.floor(Math.random() * (1 + max - min)) + min
);
};
这个函数不接受任何参数,但是预期每次调用都会产生不同的结果,这清楚地表明这个函数是不纯的。
查看我写的getRandomLetter()
函数的解释,请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random
,以及developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String
的.charCodeAt()
方法。
调用函数会继承不纯性。如果一个函数使用了不纯的函数,它立即变得不纯。我们可能想要使用getRandomLetter()
来生成随机文件名,还可以选择给定的扩展名:
const getRandomFileName = (fileExtension = "") => {
const NAME_LENGTH = 12;
let namePart = new Array(NAME_LENGTH);
for (let i = 0; i < NAME_LENGTH; i++) {
namePart[i] = getRandomLetter();
}
return namePart.join("") + fileExtension;
};
在第五章中,声明式编程——更好的风格,我们将看到一种更加函数式的初始化数组namePart
的方法,使用map()
。
由于它使用了getRandomLetter()
,getRandomFileName()
也是不纯的,尽管它的表现如预期:
console.log(getRandomFileName(".pdf")); // *"SVHSSKHXPQKG.pdf"*
console.log(getRandomFileName(".pdf")); // *"DCHKTMNWFHYZ.pdf"*
console.log(getRandomFileName(".pdf")); // *"GBTEFTVVHADO.pdf"*
console.log(getRandomFileName(".pdf")); // *"ATCBVUOSXLXW.pdf"*
console.log(getRandomFileName(".pdf")); // *"OIFADZKKNVAH.pdf"*
记住这个函数;我们稍后会在本章解决单元测试问题的一些方法,并稍作修改以帮助解决这个问题。
对于访问当前时间或日期的函数,不纯性的考虑也适用,因为它们的结果将取决于外部条件(即一天中的时间),这是应用程序的全局状态的一部分。我们可以重写我们的isOldEnough()
函数,以消除对全局变量的依赖,但这并没有太大帮助:
const isOldEnough2 = birthYear =>
birthYear <= new Date().getFullYear() - 18;
console.log(isOldEnough2(1960)); // true
console.log(isOldEnough2(2001)); // false
一个问题已经被解决了——新的isOldEnough2()
函数现在更加安全。此外,只要你不在新年前夕的午夜附近使用它,它将始终返回相同的结果,因此你可以说,用 19 世纪象牙皂的广告语来说,它是约 99.44%纯。然而,一个不便仍然存在:你该如何测试它?如果你今天写了一些测试,明年它们可能会开始失败。我们将不得不努力解决这个问题,我们稍后会看到如何解决。
还有其他一些不纯的函数,比如那些引起 I/O 的函数。如果一个函数从某个来源获取输入(网络服务、用户本身、文件等),显然返回的结果可能会有所不同。你还应该考虑 I/O 错误的可能性,因此同一个函数,调用同一个服务或读取同一个文件,可能在某个时候失败,原因是超出了它的控制范围(你应该假设你的文件系统、数据库、套接字等可能不可用,因此给定的函数调用可能产生错误,而不是预期的恒定、不变的答案)。即使是一个纯输出的、通常安全的语句,比如console.log()
,它在内部并不会改变任何东西(至少在可见的方式上),但它确实会产生一些影响,因为用户看到了变化:产生的输出。
这是否意味着我们永远无法编写需要随机数、处理日期或进行 I/O 的程序,并且还使用纯函数?一点也不——但这意味着有些函数不会是纯函数,它们会有一些我们需要考虑的缺点;我们稍后会回到这个问题。
纯函数的优势
使用纯函数的主要优势,源于它们没有任何副作用。当你调用一个纯函数时,你不需要担心任何事情,除了你传递给它的参数。而且更重要的是,你可以确信你不会造成任何问题或破坏其他任何东西,因为函数只会处理你给它的东西,而不会处理外部来源。但这并不是它们唯一的优势;让我们看看更多。
执行顺序
从这一章中我们所说的另一个角度来看,纯函数可以被称为健壮的。你知道它们的执行——无论以哪种顺序——都不会对系统产生任何影响。这个想法可以进一步扩展:你可以并行评估纯函数,放心地得出结果不会与单线程执行中得到的结果有所不同。
不幸的是,JS 在并行编程方面限制了我们很多。我们可能会以非常有限的方式使用 Web Workers,但这大概就是它的极限了。对于 Node.js 开发人员,集群模块可能会有所帮助,尽管它并不是线程的替代品,只允许您生成多个进程以利用所有可用的 CPU 核心。总之,您不会得到诸如 Java 的线程之类的设施,因此在 JS 术语中,并行化并不是 FP 的优势。
当您使用纯函数时,需要牢记的另一个考虑因素是,没有明确的需要指定它们应该被调用的顺序。如果您使用数学,例如f(2)+f(5)这样的表达式总是与f(5)+f(2)相同;顺便说一下,这被称为交换律。然而,当您处理不纯函数时,这可能不成立,就像下面的代码所示:
var mult = 1;
const f = x => {
mult = -mult;
return x * mult;
};
console.log(f(2) + f(5)); // 3
console.log(f(5) + f(2)); // -3
对于之前显示的不纯函数,您不能假设计算f(3)+f(3)会产生与2f(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 Bonacci,或者Bonacci 的儿子。他最著名的是引入了我们今天所知的 0-9 数字的使用,而不是繁琐的罗马数字。他将以他命名的序列作为解答引入了一个涉及兔子的谜题!
如果您计算一下,序列从 0 开始,然后是 1,从那一点开始,每个项都是前两个项的和:再次是 1,然后是 2,3,5,8,13,21,依此类推。通过递归编程这个系列很简单--尽管我们将在第九章中重新讨论这个例子,设计函数 - 递归。下面的代码,是对定义的直接翻译,将会这样做:
const fib = (n) => {
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*
如果您真的喜欢一行代码,您也可以写成const fib = (n) => (n<=1) ? n : fib(n-2)+fib(n-1)
--您明白为什么吗?但更重要的是...值得失去清晰度吗?
如果您尝试使用这个函数来增加n
的值,很快就会意识到存在问题,计算开始花费太多时间。例如,在我的机器上,这是我测得的一些时间,以毫秒为单位--当然,您的情况可能有所不同。由于函数速度相当快,我不得不运行 100 次计算,对n
的值在 0 到 40 之间。即使如此,对于较小的n
值,时间确实非常短暂;只有从 25 开始,我得到了有趣的数字。图表(见图 4.1)显示了指数增长,这预示着不祥的事情。
图 4.1:fib()递归函数的计算时间呈指数增长。
如果我们绘制出计算fib(6)
所需的所有调用的图表,你会注意到问题。每个节点代表计算fib(n)
的调用:我们只在节点中记录n
的值。除了n
=0 或 1 的调用外,每个调用都需要进一步的调用;参见图 4.2:
图 4.2:计算 fib(6)所需的所有计算显示出大量重复
延迟增加的原因变得很明显:例如,fib(2)
的计算在四个不同的场合重复进行,而fib(3)
本身被计算了三次。鉴于我们的函数是纯函数,我们可以存储计算出的值,避免一遍又一遍地进行数字计算。可能的版本如下:
let cache = [];
const fib2 = (n) => {
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!*
最初,缓存是空的。每当我们需要计算fib2(n)
的值时,我们都会检查它是否已经计算过。如果不是,我们进行计算,但有一个小变化:我们不会立即返回值,而是先将其存储在缓存中,然后再返回。这意味着不会重复进行计算:在我们为特定的n
计算了fib2(n)
之后,未来的调用将不会重复这个过程,而只是返回之前已经计算过的值。
一些简短的注释:
-
我们手动进行了函数的记忆化,但我们可以使用高阶函数来实现,我们将在第六章中看到,生成函数 - 高阶函数。完全可以对函数进行记忆化,而无需改写它。
-
使用全局变量作为缓存不是一个很好的做法;我们可以使用 IIFE 和闭包来隐藏缓存;你看到了吗?在第三章的立即调用部分中查看
myCounter()
示例,回顾我们如何做到这一点。
当然,你不需要为程序中的每个纯函数都这样做。你只会对频繁调用、需要花费重要时间的函数进行这种优化 - 如果情况不是这样的话,额外的缓存管理时间将会比你期望节省的时间更多!
自我文档化
纯函数还有另一个优势。由于函数需要处理的一切都通过其参数给出,没有任何隐藏的依赖关系,所以当你阅读其源代码时,你已经拥有了理解函数目标所需的一切。
额外的优势:知道一个函数不会访问除了其参数之外的任何东西,会让你更有信心使用它,因为你不会意外地产生一些副作用,函数将会完成的唯一事情,就是你已经通过文档学到的。
单元测试(我们将在下一节中介绍)也可以作为文档,因为它们提供了在给定特定参数时函数返回的示例。大多数程序员都会同意,最好的文档是充满示例的,每个单元测试都可以被视为这样一个示例。
测试
纯函数的另一个优势 - 也是最重要的之一 - 与单元测试有关。纯函数只负责以其输入产生输出。因此,当你为纯函数编写测试时,你的工作会简化得多,因为不需要考虑上下文,也不需要模拟状态。
你可以简单地专注于提供输入和检查输出,因为所有函数调用都可以在与世界其他部分独立的情况下重现。我们将在本章后面更多地了解测试纯函数和不纯函数。
不纯函数
如果你决定完全放弃所有种类的副作用,你的程序只能使用硬编码的输入...并且无法显示计算结果!同样,大多数网页将变得无用;你将无法进行任何网络服务调用,或者更新 DOM;你只能有静态页面。对于服务器端的 JS,你的 Node.JS 代码将变得非常无用,无法进行任何 I/O...
在 FP 中减少副作用是一个很好的目标,但我们不能过分追求!所以,让我们想想如何避免使用不纯的函数,如果可能的话,以及如何处理它们,寻找最好的方法来限制或限制它们的范围。
避免不纯的函数
在本章的前面,我们看到了不纯函数更常见的原因。现在让我们考虑如何最小化它们的数量,如果完全摆脱它们并不现实的话。
避免使用状态
关于使用全局状态--获取和设置--解决方案是众所周知的。关键在于:
-
将全局状态所需的内容作为参数提供给函数
-
如果函数需要更新状态,它不应该直接这样做,而是应该产生状态的新版本,并返回它
-
如果有的话,将由调用者负责获取返回的状态并更新全局状态
这是 Redux 用于其 reducer 的技术。reducer 的签名是(previousState, action) => newState
,意味着它以状态和动作作为参数,并返回一个新的状态作为结果。更具体地说,reducer 不应该简单地改变previousState
参数,它必须保持不变(我们将在第十章中看到更多关于这一点的内容,确保纯度-不可变性)。
关于我们第一个版本的isOldEnough()
函数,它使用了一个全局的limitYear
变量,改变很简单:我们只需要将limitYear
作为函数的参数提供。有了这个改变,函数就会变得纯净,因为它只会使用它的参数来产生结果。更好的是,我们应该提供当前年份,让函数来计算,而不是强制调用者这样做:
const isOldEnough3 = (currentYear, birthYear) => birthYear <= currentYear-18;
显然,我们将不得不改变所有调用以提供所需的limitYear
参数(我们也可以使用柯里化,正如我们将在第七章中看到的,转换函数-柯里化和部分应用)。初始化limitYear
的值的责任仍然在函数之外,但我们已经成功避免了一个缺陷。
我们也可以将这个解决方案应用到我们特殊的roundFix()
函数中。你还记得,这个函数通过累积由四舍五入引起的差异来工作,并根据累加器的符号决定是向上还是向下舍入。我们无法避免使用这个状态,但我们可以将四舍五入部分与累积部分分开。因此,我们的原始代码(减去注释和日志)将从以下内容更改:
const roundFix1 = (function() {
let accum = 0;
return n => {
let nRounded = accum > 0 ? Math.ceil(n) : Math.floor(n);
accum += n - nRounded;
return nRounded;
};
})();
至于:
const roundFix2 = (a, n) => {
let r = a > 0 ? Math.ceil(n) : Math.floor(n);
a += n - r;
return {a, r};
};
你会如何使用这个函数?初始化累加器,将其传递给函数,并在之后更新,现在都是调用者代码的责任。你会有类似以下的东西:
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
。
这个新的roundFix2()
函数是完全纯粹的,可以很容易地进行测试。如果你想要隐藏累加器不被应用程序的其他部分访问,你仍然可以像其他示例中一样使用闭包,但这将再次在你的代码中引入不纯性;你决定!
注入不纯的函数
如果一个函数变得不纯,因为它需要调用一些其他函数,而这些函数本身是不纯的,解决这个问题的方法是在调用中注入所需的函数。这种技术实际上为您的代码提供了更多的灵活性,并允许更容易地进行未来更改,以及更简单的单元测试。
让我们考虑一下我们之前看到的随机文件名生成器函数。问题的关键在于它使用getRandomLetter()
来生成文件名:
const getRandomFileName = (fileExtension = "") => {
...
for (let i = 0; i < NAME_LENGTH; i++) {
namePart[i] = getRandomLetter();
}
...
};
解决这个问题的方法是用一个注入的外部函数替换不纯的函数:
const getRandomFileName2 = (fileExtension = "", randomLetterFunc) => {
const NAME_LENGTH = 12;
let 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
参数提供一个默认值,如下所示:
const getRandomFileName2 = (
fileExtension = "",
randomLetterFunc = getRandomLetter
) => {
...
};
或者你也可以通过部分应用来解决这个问题,就像我们将在第七章中看到的那样,转换函数 - 柯里化和部分应用。
这实际上并没有避免使用不纯的函数。在正常使用中,你将调用getRandomFileName()
并提供我们编写的随机字母生成器,因此它将表现为一个不纯的函数。然而,为了测试目的,如果你提供一个返回预定义(即非随机)字母的函数,你将能够更轻松地测试它是否纯粹。
但是原始问题函数getRandomLetter()
呢?我们可以应用相同的技巧,编写一个新版本,如下所示:
const getRandomLetter = (getRandomInt = Math.random) => {
const min = "A".charCodeAt();
const max = "Z".charCodeAt();
return String.fromCharCode(
Math.floor(getRandomInt() * (1 + max - min)) + min
);
};
在正常使用中,getRandomFileName()
会调用getRandomLetter()
而不提供任何参数,这意味着被调用的函数将按照预期的随机方式行事。但是,如果我们想要测试函数是否符合我们的预期,我们可以运行它,使用一个返回我们决定的任何内容的注入函数,让我们彻底测试它。
这个想法实际上非常重要,对其他问题有广泛的应用。例如,我们可以提供一个函数来直接访问 DOM,而不是直接访问 DOM。对于测试目的,可以简单地验证被测试的函数是否真的做了它需要做的事情,而不是真的与 DOM 进行交互(当然,我们必须找到其他方法来测试那些与 DOM 相关的函数)。这也适用于需要更新 DOM、生成新元素和进行各种操作的函数,你只需使用一些中间函数。
你的函数是纯的吗?
让我们通过考虑一个重要的问题来结束这一节:你能确保一个函数实际上是纯的吗?为了展示这个任务的困难,我们将回到我们在前几章中看到的简单的sum3()
函数。你会说这个函数是纯的吗?它看起来是!
const sum3 = (x, y, z) => x + y + z;
让我们看看,这个函数除了它的参数之外没有访问任何东西,甚至不尝试修改它们(即使它可能...或者可能吗?),不进行任何 I/O 或使用我们之前提到的任何不纯的函数或方法...会出什么问题呢?
答案与检查你的假设有关。例如,谁说这个函数的参数应该是数字?你可能会对自己说“好吧,它们可以是字符串...但是函数仍然是纯的,不是吗?”,但是对于这个(肯定是邪恶的!)答案,看看下面的代码。
let x = {};
x.valueOf = Math.random;
let y = 1;
let 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
方法,我们充分利用了函数是一级对象的事实。在第三章的一个不必要的错误部分中,可以了解更多相关信息。
嗯,sum3()
应该是纯的...但它实际上取决于你传递给它的参数!你可能会安慰自己,认为肯定没有人会传递这样的参数,但边缘情况通常是错误的根源。但你不必放弃纯函数的想法。添加一些类型检查(TypeScript 可能会派上用场),你至少可以防止一些情况--尽管 JS 永远不会让你完全确定你的代码总是是纯的!
测试-纯函数与不纯函数
我们已经看到纯函数在概念上比不纯函数更好,但我们不能开始一场消灭代码中所有不纯性的运动。首先,没有人能否认副作用是有用的,或者至少是不可避免的:你需要与 DOM 交互或调用 Web 服务,而没有办法以纯粹的方式做到这一点。因此,与其抱怨你必须允许不纯性,不如尝试构建你的代码,以便隔离不纯函数,并让你的代码尽可能地好。
有了这个想法,你将能够为各种函数编写单元测试,无论是纯函数还是不纯函数。编写纯函数和不纯函数的单元测试是不同的,因为在处理纯函数或不纯函数时,其难度和复杂性也不同。对于前者编写测试通常相当简单,并遵循基本模式,而对于后者通常需要搭建和复杂的设置。因此,让我们通过看看如何测试这两种类型的函数来结束本章。
测试纯函数
鉴于我们已经描述的纯函数的特性,你的大部分单元测试可能会很简单:
-
使用给定的一组参数调用函数
-
验证结果是否与预期相匹配
让我们从一些简单的例子开始。测试isOldEnough()
函数将比需要访问全局变量的版本更复杂。另一方面,最后一个版本isOldEnough3()
不需要任何东西,因为它接收了两个参数,所以测试起来很简单:
describe("isOldEnough", function() {
it("is false for people younger than 18", () => {
expect(isOldEnough3(1978, 1963)).toBe(false);
});
it("is true for people older than 18", () => {
expect(isOldEnough3(1988, 1965)).toBe(true);
});
it("is true for people exactly 18", () => {
expect(isOldEnough3(1998, 1980)).toBe(true);
});
});
我们编写的另一个纯函数同样简单,但需要注意精度。如果我们测试circleArea
函数,我们必须使用 Jasmine 的.toBeCloseTo()
匹配器,它允许在处理浮点数时进行近似相等。除此之外,测试基本相同:使用已知参数调用函数,并检查预期结果。
describe("circle area", function() {
it("is zero for radius 0", () => {
let area = circleArea(0);
expect(area).toBe(0);
});
it("is PI for radius 1", () => {
let area = circleArea(1);
expect(area).toBeCloseTo(Math.PI);
});
it("is approximately 12.5664 for radius 2", () => {
let area = circleArea(2);
expect(area).toBeCloseTo(12.5664);
});
});
毫无困难!测试运行报告对两个套件都成功(见图 4.3):
图 4.3:一对简单纯函数的成功测试运行
因此,我们不必担心纯函数,让我们继续处理不纯函数,将它们转换为纯函数的等价物。
测试纯化函数
当我们考虑roundFix
特殊函数时,它需要使用状态来累积由于舍入而产生的差异,我们通过将当前状态作为附加参数提供,并使函数返回两个值:舍入后的值和更新后的状态,从而生成了一个新版本:
const roundFix2 = (a, n) => {
let r = a > 0 ? Math.ceil(n) : Math.floor(n);
a += n - r;
return {a, r};
};
这个函数现在是纯的,但测试它需要验证不仅返回的值,还有更新的状态。我们可以基于之前的实验来进行测试。再次,我们必须使用toBeCloseTo()
来处理浮点数,但对于整数,我们可以使用toBe()
,它不会产生舍入误差:
describe("roundFix2", function() {
it("should round 3.14159 to 3 if differences are 0", () => {
let {a, r} = roundFix2(0.0, 3.14159);
expect(a).toBeCloseTo(0.14159);
expect(r).toBe(3);
});
it("should round 2.71828 to 3 if differences are 0.14159", () => {
let {a, r} = roundFix2(0.14159, 2.71828);
expect(a).toBeCloseTo(-0.14013);
expect(r).toBe(3);
});
it("should round 2.71828 to 2 if differences are -0.14013", () => {
let {a, r} = roundFix2(-0.14013, 2.71828);
expect(a).toBeCloseTo(0.57815);
expect(r).toBe(2);
});
it("should round 3.14159 to 4 if differences are 0.57815", () => {
let {a, r} = roundFix2(0.57815, 3.14159);
expect(a).toBeCloseTo(-0.28026);
expect(r).toBe(4);
});
});
我们注意到包括了几种情况,积累的差异为正、零或负,并检查在每种情况下是否四舍五入。我们当然可以进一步进行,对负数进行四舍五入,但思路很清楚:如果你的函数将当前状态作为参数,并更新它,与纯函数测试的唯一区别是你还必须测试返回的状态是否符合你的期望。
现在让我们考虑测试的另一种方式,对于我们纯净的getRandomLetter()
变体;让我们称之为getRandomLetter2()
。这很简单;你只需要提供一个函数,它本身会产生随机数字。(在测试术语中,这种函数被称为存根)。存根的复杂性没有限制,但你会希望保持它简单。
然后,我们可以根据对函数工作原理的了解进行一些测试,以验证低值产生A
,接近 1 的值产生Z
,因此我们可以有一点信心,不会产生额外的值。此外,中间值(大约 0.5)应该产生字母在字母表中间的位置。然而,请记住,这种测试并不是很好;如果我们替换了一个同样有效的getRandomLetter()
变体,新函数可能完全正常工作,但由于不同的内部实现,可能无法通过这个测试!
describe("getRandomLetter2", function() {
it("returns A for values close to 0", () => {
let letterSmall = getRandomLetter2(() => 0.0001);
expect(letterSmall).toBe("A");
});
it("returns Z for values close to 1", () => {
let letterBig = getRandomLetter2(() => 0.99999);
expect(letterBig).toBe("Z");
});
it("returns a middle letter for values around 0.5", () => {
let letterMiddle = getRandomLetter2(() => 0.49384712);
expect(letterMiddle).toBeGreaterThan("G");
expect(letterMiddle).toBeLessThan("S");
});
it("returns an ascending sequence of letters for ascending values", () => {
let a = [0.09, 0.22, 0.6];
const f = () => a.shift(); // impure!!
let letter1 = getRandomLetter2(f);
let letter2 = getRandomLetter2(f);
let letter3 = getRandomLetter2(f);
expect(letter1).toBeLessThan(letter2);
expect(letter2).toBeLessThan(letter3);
});
});
测试我们的文件名生成器可以通过使用存根来以类似的方式进行。我们可以提供一个简单的存根,按顺序返回"SORTOFRANDOM"
的字母(这个函数是相当不纯的;知道为什么吗?)。因此,我们可以验证返回的文件名是否与预期的名称匹配,以及返回的文件名的一些其他属性,例如其长度和扩展名:
describe("getRandomFileName", function() {
let a = [];
let f = () => a.shift();
beforeEach(() => {
a = "SORTOFRANDOM".split("");
});
it("uses the given letters for the file name", () => {
let fileName = getRandomFileName("", f);
expect(fileName.startsWith("SORTOFRANDOM")).toBe(true);
});
it("includes the right extension, and has the right length", () => {
let fileName = getRandomFileName(".pdf", f);
expect(fileName.endsWith(".pdf")).toBe(true);
expect(fileName.length).toBe(16);
});
});
测试纯化的不纯函数与测试最初纯函数非常相似。现在,我们将不得不考虑一些真正不纯函数的情况,因为正如我们所说的,几乎可以肯定,你迟早会使用这样的函数。
测试不纯函数
首先,让我们回到我们的getRandomLetter()
函数。有了对其实现的内部知识(这被称为白盒测试,与黑盒测试相对,后者我们对函数代码本身一无所知),我们可以监视(Jasmine 术语)Math.random()
方法,并设置一个模拟函数,它将返回我们想要的任何值。
我们可以重新审视我们在上一节中进行的一些测试用例。在第一个案例中,我们将Math.random()
设置为返回 0.0001,并测试它是否实际被调用,以及最终返回是否为A
。在第二个案例中,为了多样化,我们设置了Math.random()
可以被调用两次,返回两个不同的值。我们还验证了函数被调用了两次,而且两个结果都是Z
。第三个案例展示了检查Math.random()
(或者说,我们的模拟函数)被调用了多少次的另一种方式:
describe("getRandomLetter", function() {
it("returns A for values close to 0", () => {
spyOn(Math, "random").and.returnValue(0.0001);
let letterSmall = getRandomLetter();
expect(Math.random).toHaveBeenCalled();
expect(letterSmall).toBe("A");
});
it("returns Z for values close to 1", () => {
spyOn(Math, "random").and.returnValues(0.98, 0.999);
let letterBig1 = getRandomLetter();
let letterBig2 = getRandomLetter();
expect(Math.random).toHaveBeenCalledTimes(2);
expect(letterBig1).toBe("Z");
expect(letterBig2).toBe("Z");
});
it("returns a middle letter for values around 0.5", () => {
spyOn(Math, "random").and.returnValue(0.49384712);
let letterMiddle = getRandomLetter();
expect(Math.random.calls.count()).toEqual(1);
expect(letterMiddle).toBeGreaterThan("G");
expect(letterMiddle).toBeLessThan("S");
});
});
当然,你不会随意发明任何测试。据说,你会从所需的getRandomLetter()
函数的描述开始工作,这个描述是在你开始编码或测试之前编写的。在我们的情况下,我假装那个规范确实存在,并且明确指出,例如,接近 0 的值应该产生A
,接近 1 的值应该返回Z
,并且函数应该对升序的random
值返回升序的字母。
现在,你如何测试原始的getRandomFileName()
函数,即调用不纯的getRandomLetter()
函数的函数?这是一个更加复杂的问题....你有什么期望?你无法知道它将会给出什么结果,因此你无法编写任何.toBe()
类型的测试。你可以测试一些预期结果的属性。而且,如果你的函数涉及某种形式的随机性,你可以重复测试多次,以增加捕获错误的机会:
describe("getRandomFileName, with an impure getRandomLetter function", function() {
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++) {
let n = getRandomFileName();
for (j = 0; j < n.length; n++) {
expect(n[j] >= "A" && n[j] <= "Z").toBe(true);
}
}
});
it("includes the right extension if provided", () => {
let fileName1 = getRandomFileName(".pdf");
expect(fileName1.length).toBe(16);
expect(fileName1.endsWith(".pdf")).toBe(true);
});
it("doesn't include any extension if not provided", () => {
let fileName2 = getRandomFileName();
expect(fileName2.length).toBe(12);
expect(fileName2.includes(".")).toBe(false);
});
});
我们没有向getFileName()
传递任何随机字母生成函数,因此它将使用原始的、不纯的函数。我们对一些测试运行了一百次,作为额外的保险。
在测试代码时,永远记住没有证据不是证据的缺失。即使我们的重复测试成功了,也不能保证,使用其他随机输入时,它们不会产生意外的、迄今未被发现的错误。
让我们进行另一个属性测试。假设我们想测试一个洗牌算法;我们可以决定实现 Fisher-Yates 版本,按照以下的方式。按照实现,该算法是双重不纯的:它不总是产生相同的结果(显然!)并且修改了它的输入参数:
const shuffle = arr => {
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;
};
var xxx = [11, 22, 33, 44, 55, 66, 77, 88];
console.log(shuffle(xxx));
// ***[55, 77, 88, 44, 33, 11, 66, 22]***
有关此算法的更多信息--包括对不慎的程序员造成的一些问题--请参阅en.wikipedia.org/wiki/Fisher-Yates_shuffle
。
你如何测试这个算法?考虑到结果是不可预测的,我们可以检查其输出的属性。我们可以使用已知的数组调用它,然后测试它的一些属性:
describe("shuffleTest", function() {
it("shouldn't change the array length", () => {
let a = [22, 9, 60, 12, 4, 56];
shuffle(a);
expect(a.length).toBe(6);
});
it("shouldn't change the values", () => {
let 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.1. 极简主义函数:函数式程序员有时候倾向于以极简主义的方式编写代码。你能检查这个斐波那契函数的版本,并解释它是否有效,如果有效,是如何有效的吗?
const fib2 = n => (n < 2 ? n : fib2(n - 2) + fib2(n - 1));
4.2. 一个廉价的方法:下面这个版本的斐波那契函数非常高效,不会进行任何不必要或重复的计算。你能看出来吗?建议:尝试手工计算fib4(6)
,并与本书前面给出的例子进行比较:
const fib4 = (n, a = 0, b = 1) => (n === 0 ? a : fib4(n - 1, b, a
+ b));
4.3 洗牌测试:你如何为shuffle()
编写单元测试,以测试它在具有重复值的数组上是否正确工作?
4.4. 违反规律:使用.toBeCloseTo()
非常实用,但可能会引发一些问题。一些基本的数学属性是:
一个数字应该等于它自己:对于任何数字a,a应该等于a
-
如果数字a等于数字b,那么b应该等于a
-
如果a等于b,b等于c,那么a应该等于c
-
如果a等于b,c等于d,那么a+c应该等于b+d
-
如果a等于b,c等于d,那么ac应该等于b**d
-
如果a等于b,c等于d,那么a/c应该等于b/d
.toBeCloseTo()
是否也满足所有这些属性?
总结
在本章中,我们介绍了纯函数的概念,并研究了它们为什么重要。我们还看到了副作用造成的问题,这是不纯函数的原因之一;考虑了一些净化这些不纯函数的方法,最后,我们看到了对纯函数和不纯函数进行单元测试的几种方法。
在第五章中,声明式编程 - 更好的风格,我们将展示 FP 的其他优势:如何以声明式的方式进行编程,以更高的层次编写更简单、更强大的代码。
第五章:声明式编程 - 更好的风格
到目前为止,我们还没有真正能够欣赏到 FP 的可能性,因为它涉及以更高级别、声明性的方式工作。在本章中,我们将纠正这一点,并通过使用一些高阶函数(HOF:接受函数作为参数的函数)来编写更短、更简洁、更易于理解的代码。
-
.reduce()
和.reduceRight()
来对整个数组应用操作,将其减少为单个结果 -
.map()
,通过对其每个元素应用函数来将数组转换为另一个数组 -
.forEach()
,通过抽象必要的循环代码来简化编写循环
我们还可以使用以下功能进行搜索和选择:
-
.filter()
,从数组中选择一些元素 -
.find()
和.findIndex()
,用于搜索满足条件的元素 -
还有一对谓词
.every()
和.some()
,用于检查数组是否通过了某些布尔测试
使用这些函数可以让您更加声明式地工作,您会发现您的注意力往往会转向需要做什么,而不是如何做;肮脏的细节隐藏在我们的函数内部。我们将不再编写一系列可能嵌套的for
循环,而是更专注于使用函数作为构建块来指定我们想要的结果。
我们还可以以流畅的方式工作,其中函数的输出成为下一个函数的输入:这是我们稍后将涉及的一种风格。
转换
我们将要考虑的第一组操作是在数组上进行操作,并在函数的基础上处理它以产生一些结果。有几种可能的结果:使用.reduce()
操作得到单个值;使用.map()
得到一个新数组;或者使用.forEach()
得到几乎任何类型的结果。
如果您在网上搜索,您会发现一些声明这些函数不高效的文章,因为手动完成的循环可能更快。尽管这可能是真的,但实际上并不重要。除非您的代码真的受到速度问题的困扰,并且能够测量出慢速是由于使用这些高阶函数导致的,否则试图避免它们,使用更长的代码和更多的错误可能性根本就没有多大意义。
让我们从考虑函数列表开始,按顺序开始,从最一般的函数开始,正如我们将看到的那样,甚至可以用来模拟本章中其余的转换!
将数组减少为一个值
回答这个问题:你有多少次不得不循环遍历数组,执行一些操作(比如,求和元素)以产生单个值(也许是所有数组值的总和)作为结果?可能很多次。这种操作通常可以通过应用.reduce()
和.reduceRight()
来实现函数化。让我们从前者开始!
是时候学一些术语了!在通常的 FP 术语中,我们谈论折叠操作:.reduce()
是foldl(fold left)或简单的fold,而.reduceRight()
相应地被称为foldr。在范畴论术语中,这两个操作都是catamorphisms:将容器中所有值减少到单个结果。
reduce()
函数的内部工作如图 5.1 所示:
图 5.1:reduce 操作遍历数组,对每个元素和累积值应用函数为什么应该尽量使用.reduce()
或.reduceRight()
而不是手动编写循环?
-
所有循环控制方面都会自动处理,因此您甚至没有可能出现例如偏移一个的错误
-
结果值的初始化和处理也是隐式完成的
-
而且,除非你非常努力地进行不纯和修改原始数组,否则你的代码将是无副作用的
对数组求和
.reduce()
的最常见应用示例通常在所有教科书和网页中都能看到,就是对数组中所有元素求和。因此,为了保持传统,让我们从这个例子开始!
基本上,要减少一个数组,你必须提供一个二元函数(也就是说,一个带有两个参数的函数;二进制可能是另一个名称)和一个初始值。在我们的情况下,函数将对它的两个参数求和。最初,函数将被应用于提供的初始值和数组的第一个元素,所以对我们来说,我们必须提供的第一个结果是零,第一个结果将是第一个元素本身。然后,函数将再次被应用,这次是对上一次操作的结果和数组的第二个元素--因此第二个结果将是数组的前两个元素的和。以这种方式沿着整个数组进行下去,最终的结果将是所有元素的和:
const myArray = [22, 9, 60, 12, 4, 56];
const sum = (x, y) => x + y;
const mySum = myArray.reduce(sum, 0); // 163
你实际上不需要sum
的定义;你可以直接写myArray.reduce((x,y) => x+y, 0)
。然而,用这种方式代码的含义更清晰:你想通过对所有元素进行求和来将数组减少为一个单一的值。而不是必须编写循环,初始化一个变量来保存计算结果,然后遍历数组进行求和,你只需声明应该执行的操作。这就是我所说的,使用本章中将要看到的这些函数进行编程,可以让你更多地以声明性的方式工作,关注做什么而不是如何做。
你甚至可以不提供初始值:如果你跳过它,数组的第一个值将被使用,并且内部循环将从数组的第二个元素开始。更多信息请参见developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce
。然而,如果数组为空,并且你跳过提供初始值,你将得到一个运行时错误!
我们可以改变减少函数来看它是如何通过包含一点不纯度而进行计算的!
const sumAndLog = (x, y) => {
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
你可以看到第一个求和是通过将初始值(零)和数组的第一个元素相加来完成的,然后将该结果用于第二次相加,依此类推。
之前看到的foldl名称的一部分(至少是l
部分)现在应该是清楚的:减少操作从左到右进行,从第一个元素到最后一个元素。然而,你可能会想知道,如果它是由一个从右到左的语言(比如阿拉伯语、希伯来语、波斯语或乌尔都语)的说话者定义的,它会被命名为什么!
计算平均值
让我们再多做一点工作;如何计算一组数字的平均值?如果你要向某人解释这个问题,你的答案肯定会有点像“对列表中的所有元素求和,然后除以元素的数量”。从编程的角度来看,这不是一个过程性的描述(你不解释如何对元素求和,或者如何遍历数组),而是一个声明性的描述,因为你说了要做什么,而不是如何做。
我们可以将这个计算的描述转化为一个几乎是自解释的函数:
const average = arr => arr.reduce(sum, 0) / arr.length;
console.log(average(myArray)); // *27.166667*
average()
的定义遵循了一个口头解释:对数组中的元素求和,从零开始,然后除以数组的长度--简单,不可能出错!
正如我们在前一节中提到的,你也可以写成arr.reduce(sum)
,而不指定减少的初始值(零);这样更简洁,更接近所需计算的口头描述。然而,这样做不太安全,因为如果数组为空,它会失败(产生运行时错误)。因此,最好总是提供起始值。
然而,这并不是计算平均值的唯一方法。减少函数还会传递数组的当前位置的索引和数组本身,因此您可以在最后一次做一些不同的事情:
const myArray = [22, 9, 60, 12, 4, 56];
const average2 = (sum, val, ind, arr) => {
sum += val;
return ind == arr.length - 1 ? sum / arr.length : sum;
};
console.log(myArray.reduce(average2, 0)); // 27.166667
获取数组和索引意味着您也可以将函数转换为不纯的函数;避免这样做!每个看到.reduce()
调用的人都会自动假设它是一个纯函数,并且在使用它时肯定会引入错误。
然而,从可读性的角度来看,我相信我们会同意,我们看到的第一个版本比这个第二个版本更具声明性,更接近数学定义。
也可以修改Array.prototype
以添加新函数。通常修改原型是不受欢迎的,因为至少可能会与不同的库发生冲突。但是,如果您接受这个想法,那么您可以编写以下代码。请注意需要外部function()
(而不是箭头函数)的需要,因为它隐式处理this
,否则将无法绑定:
Array.prototype.average = function() {
return this.reduce((x, y) => x + y, 0) / this.length;
};
let myAvg = [22, 9, 60, 12, 4, 56].average(); // *27.166667*
同时计算多个值
如果您需要计算两个或更多结果,您会怎么做?这似乎是一个适合使用普通循环的情况,但是您可以使用一个技巧。让我们再次回顾一下平均值的计算。我们可能想要以老式的方式循环,同时对所有数字进行求和和计数。嗯,.reduce()
只允许您生成一个单一的结果,但是没有反对返回一个对象,其中包含尽可能多的字段:
const average3 = arr => {
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*
仔细检查代码。我们需要两个变量,用于所有数字的总和和计数。我们提供一个对象作为累加器的初始值,其中两个属性设置为零,我们的减少函数更新这两个属性。
顺便说一句,使用对象并不是唯一的选择。您还可以生成任何其他数据结构;让我们看一个数组的例子:
const average4 = arr => {
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*
坦率地说,我认为这比使用对象的解决方案更加晦涩。只需将其视为一种(不太可取的)同时计算多个值的替代方法!
左右折叠
补充的.reduceRight()
方法与 reduce 方法一样,只是从末尾开始循环,直到数组的开头。对于许多操作(例如我们之前看到的平均值的计算),这没有区别,但也有一些情况会有区别。
我们将在第八章中看到一个明显的例子,连接函数 - 管道和组合,当我们比较管道和组合时:让我们在这里使用一个更简单的例子:
图 5.2:.reduceRight()
操作与.reduce()
相同,只是顺序相反。在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/ReduceRight
上阅读更多关于.reduceRight()
的信息。
假设我们想要实现一个反转字符串的函数。一种解决方案是使用.split()
将字符串转换为数组,然后反转该数组,最后使用.join()
将其重新组合:
const reverseString = str => {
let arr = str.split("");
arr.reverse();
return arr.join("");
};
console.log(reverseString("MONTEVIDEO")); // *OEDIVETNOM*
这个解决方案(是的,它可以被简化,但这不是重点)有效,但让我们以另一种方式来做,只是为了尝试.reduceRight()
:
const reverseString2 = str =>
str.split("").reduceRight((x, y) => x + y, "");
console.log(reverseString2("OEDIVETNOM")); // *MONTEVIDEO*
鉴于加法运算符也适用于字符串,我们也可以编写reduceRight(sum,"")
。如果我们使用的不是函数,而是(x,y) => y+x
,结果将是我们的原始字符串;您能看出为什么吗?
从前面的例子中,你也可以得到一个想法:如果你首先对一个数组应用reverse()
,然后使用reduce()
,效果将与你只是对原始数组应用.reduceRight()
相同。只需要考虑一点:reverse()
改变了给定的数组,所以你会导致一个意外的副作用,即颠倒了原始数组!唯一的出路是首先生成数组的副本,然后再做其他操作... 太麻烦了;还是继续使用.reduceRight()
吧!
然而,我们可以得出另一个结论,展示了我们之前预言的结果:即使更加繁琐,也可以使用.reduce()
来模拟与.reduceRight()
相同的结果--在后面的章节中,我们还将使用它来模拟本章中的其他函数。
应用操作 - map
处理元素列表,并对每个元素应用某种操作,在计算机编程中是一个非常常见的模式。编写循环,系统地遍历数组或集合的所有元素,从第一个开始循环,直到最后一个结束,并对每个元素进行某种处理,是一个基本的编码练习,通常在所有编程课程的第一天就学到。我们已经在上一节中看到了这样一种操作,使用了.reduce()
和.reduceRight()
;现在让我们转向一个新的操作,叫做.map()
。
在数学中,map是将元素从域转换为余域的变换。例如,你可以将数字转换为字符串,或者字符串转换为数字,但也可以将数字转换为数字,或者字符串转换为字符串:重要的是你有一种方法将第一种类型或域的元素(如果有帮助的话,可以考虑类型)转换为第二种类型或余域的元素。在我们的情况下,这意味着取出数组的元素,并对每个元素应用一个函数,以产生一个新的数组。更像计算机的术语,map 函数将输入数组转换为输出数组。
还有一些术语。我们会说一个数组是一个函子,因为它提供了一个具有一些预先指定属性的映射操作,我们稍后会看到。在范畴论中,我们将在第十二章中稍微谈一下,构建更好的容器-函数数据类型,映射操作本身将被称为态射。
.map()
操作的内部工作可以在图 5.3 中看到:
图 5.3:map()操作通过应用映射函数转换输入数组的每个元素 jQuery 库提供了一个函数$.map(array, callback)
,它类似于.map()
方法。不过要小心,因为有重要的区别。jQuery 函数处理数组的未定义值,而.map()
跳过它们。此外,如果应用的函数产生一个数组作为其结果,jQuery 会展平它,并单独添加其每个个体元素,而.map()
只是将这些数组包含在结果中。
使用.map()
的优势,而不是使用直接的循环是什么?
-
首先,你不必编写任何循环,这样就少了一个可能的错误来源。
-
其次,你甚至不需要访问原始数组或索引位置,尽管它们可以供你使用,如果你真的需要的话
-
最后,产生了一个新的数组,所以你的代码是纯的(当然,如果你真的想产生副作用,当然可以!)
在 JS 中,.map()
基本上只适用于数组。(在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
上阅读更多。)然而,在第十二章的扩展当前数据类型中,构建更好的容器-功能数据类型,我们将考虑如何使它适用于其他基本类型,如数字、布尔值、字符串,甚至函数。此外,诸如 LoDash 或 Underscore 或 Ramda 之类的库提供类似的功能。
在使用此功能时只有两个注意事项:
-
总是从您的映射函数返回一些东西。如果您忘记了这一点,因为 JS 总是为所有函数提供默认的
return undefined
,那么您将只会生成一个填满undefined
的数组。 -
如果输入数组元素是对象或数组,并且您将它们包含在输出数组中,那么 JS 仍然允许访问原始元素。
从对象中提取数据
让我们从一个简单的例子开始。假设我们有一些地理数据,如下面的片段所示,与国家和它们首都的坐标(纬度、经度)有关。假设我们碰巧想要计算这些城市的平均位置。(不,我不知道为什么我们要这样做……)我们该如何去做?
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}
];
如果您想知道为什么所有数据都是负数,那只是因为所显示的国家都位于赤道以南,而且位于格林威治以西。然而,有一些南美国家的纬度是正数,比如哥伦比亚或委内瑞拉,所以并非所有数据都是负数。当我们学习some()
和every()
方法时,我们将在下面回到这个问题。
我们想要使用我们在本章前面开发的average()
函数,但是有一个问题:该函数只能应用于数字数组,而我们这里有的是对象数组。然而,我们可以做一个小技巧。专注于计算平均纬度;我们可以以类似的方式稍后处理经度。我们可以将数组的每个元素映射到其纬度,然后我们就可以得到average()
的适当输入。解决方案可能是以下内容:
let averageLat = average(markers.map(x => x.lat));
let averageLon = average(markers.map(x => x.lon));
如果您扩展了Array.prototype
,那么您可以以不同的风格编写一个等效版本:
let averageLat2 = markers.map(x => x.lat).average();
let averageLon2 = markers.map(x => x.lon).average();
我们将在第八章中看到更多关于这些风格的内容,连接函数-管道和组合。
暗示式解析数字
使用 map 通常比手动循环更安全和更简单,但有些边缘情况可能会让您感到困惑。假设您收到了一个表示数值的字符串数组,并且您想将它们解析为实际的数字。您能解释以下结果吗?
["123.45", "67.8", "90"].map(parseFloat);
// [123.45, 67.8, 90]
["123.45", "-67.8", "90"].map(parseInt);
// [123, NaN, NaN]
当您使用parseFloat()
获得浮点结果时,一切都很好。然而,如果您想要将结果截断为整数值,那么输出就会出现问题……发生了什么?
答案在于暗示式编程的问题。(我们已经在第三章的不必要的错误部分看到了一些暗示式编程的用法,我们将在第八章中看到更多,连接函数-管道和组合。)当您不明确显示函数的参数时,很容易出现一些疏忽。请看下面的代码,这将引导我们找到解决方案:
["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()
的信息,其中详细解释了基数参数。您应该始终提供它,因为某些浏览器可能会将具有前导零的字符串解释为八进制,这将再次产生不需要的结果。
那么,当我们将parseInt()
提供给map()
时会发生什么?记住,.map()
调用映射函数时会传递三个参数:数组元素值,其索引和数组本身。当parseInt
接收这些值时,它会忽略数组,但假设提供的索引实际上是一个基数...并且会产生NaN
值,因为原始字符串在给定基数下不是有效数字。
使用范围
现在让我们转向一个辅助函数,这将对许多用途很有用。我们想要一个range(start,stop)
函数,它生成一个数字数组,值范围从start
(包括)到stop
(不包括):
const range = (start, stop) =>
new Array(stop - start).fill(0).map((v, i) => start + i);
let from2To6 = range(2, 7); // [2, 3, 4, 5, 6];
为什么要使用.fill(0)
?所有未定义的数组元素都会被map()
跳过,所以我们需要用一些东西来填充它们,否则我们的代码将没有效果。
像 Underscore 或 LoDash 这样的库提供了我们的范围函数的更强大版本,让您可以按升序或降序进行操作,并且还可以指定要使用的步长,就像_.range(0, -8, -2)
会产生[0
, -2
, -4
, -6
],但对于我们的需求,我们编写的版本就足够了。请参阅本章末尾的问题部分。
我们如何使用它?在接下来的部分中,我们将看到一些使用forEach()
进行控制循环的用法,但我们可以通过应用range()
然后reduce()
来重新实现我们的阶乘函数。这个想法很简单,就是生成从 1 到 n 的所有数字,然后将它们相乘:
const factorialByRange = n => range(1, n + 1).reduce((x, y) => x * y, 1);
factorialByRange(5); // 120
factorialByRange(3); // 6
检查边界情况很重要,但该函数也适用于零;你能看出原因吗?原因是生成的范围是空的(调用是range(1,1)
返回一个空数组),然后reduce()
不进行任何计算,只是返回初始值(1),这是正确的。
在第八章中,连接函数-管道和组合,我们将有机会使用range()
来生成源代码;请查看使用 eval() 进行柯里化和使用 eval() 进行部分应用部分。
您可以使用这些数字范围来生成其他类型的范围。例如,如果您需要一个包含字母表的数组,您肯定可以(而且很繁琐地)写["A", "B", "C"...
一直到..."X", "Y", "Z"]
。一个更简单的解决方案是生成一个包含字母表的 ASCII 代码范围,并将其映射为字母:
const ALPHABET = range("A".charCodeAt(), "Z".charCodeAt() + 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()
到(最初为空的)结果数组中。当循环完成处理输入数组时,结果数组将具有所需的输出值:
const myMap = (arr, fn) => arr.reduce((x, y) => x.concat(fn(y)), []);
让我们用一个简单的数组和函数来测试一下:
const myArray = [22, 9, 60, 12, 4, 56];
const dup = x => 2 * x;
console.log(myArray.map(dup)); // *[44, 18, 120, 24, 8, 112]*
console.log(myMap(myArray, dup)); // *[44, 18, 120, 24, 8, 112]*
console.log(myArray); // *[22, 9, 60, 12, 4, 56]*
第一个日志显示了由map()
产生的预期结果。第二个输出给出了相同的结果,所以似乎.myMap()
有效!最后一个输出只是为了检查原始输入数组没有以任何方式被修改;映射操作应该总是产生一个新数组。
更一般的循环
我们上面看到的例子,只是简单地循环遍历数组。然而,有时您需要做一些循环,但所需的过程实际上并不适合.map()
或.reduce()
...那么该怎么办呢?有一个.forEach()
方法可以帮助。
在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach
上阅读更多关于.forEach()
方法的规范。
您必须提供一个回调函数,该函数将接收值、索引和您正在操作的数组。(最后两个参数是可选的。)JS 将负责循环控制,您可以在每一步做任何您想做的事情。例如,我们可以通过使用一些Object
方法逐个复制源对象属性,并生成一个新对象来编写对象复制方法:
const objCopy = obj => {
let copy = Object.create(Object.getPrototypeOf(obj));
Object.getOwnPropertyNames(obj).forEach(prop =>
Object.defineProperty(
copy,
prop,
Object.getOwnPropertyDescriptor(obj, prop)
)
);
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
是的,当然,您也可以编写myCopy={...myObj}
,但这样做有什么乐趣呢?好吧,那样更好,但我需要一个好的例子来使用.forEach()
...对此很抱歉!此外,在那段代码中还有一些隐藏的不便之处,我们将在第十章中解释,确保纯度-不可变性,当我们试图获得真正冻结的、不可修改的对象时。只是一个提示:新对象可能与旧对象共享值,因为我们进行的是浅复制,而不是深复制。我们将在本书的后面更多地了解这一点。
如果您使用我们之前定义的range()
函数,您也可以执行常见的循环,例如for(i=0; i<10; i++)
。我们可以使用这种方式编写阶乘(!)的另一个版本:
const factorial4 = n => {
let result = 1;
range(1, n + 1).forEach(v => (result *= v));
return result;
};
console.log(factorial4(5)); // 120
这个阶乘的定义确实与通常的描述相匹配:它生成从 1 到 n 的所有数字,并将它们相乘;简单!
为了更通用,您可能希望扩展range()
,使其能够生成升序和降序的值范围,可能还可以通过不同于 1 的数字进行步进。这实际上可以让您用.forEach()
循环替换代码中的所有循环。
逻辑高阶函数
到目前为止,我们一直在使用高阶函数来生成新的结果,但也有一些其他函数,通过将谓词应用于数组的所有元素来生成逻辑结果。
一些术语:谓词一词可以用多种意义(如谓词逻辑),但对于我们来说,在计算机科学中,我们采用返回 true 或 false 的函数的含义。好吧,这不是一个非常正式的定义,但对我们的需求来说足够了。例如,我们将根据谓词筛选数组,这意味着我们可以决定根据谓词的结果包含或排除哪些元素。
使用这些函数意味着您的代码将变得更短:您可以用一行代码获得与整套值对应的结果。
筛选数组
一个常见的需求是根据某些条件筛选数组的元素。.filter()
方法允许您检查数组的每个元素,方式与.map()
相同。不同之处在于,函数的结果决定了输入值是否会保留在输出中(如果函数返回true
)或者是否会被跳过(如果函数返回false
)。与.map()
类似,.filter()
不会改变原始数组,而是返回一个包含选定项的新数组。
查看图 5.4,显示输入和输出的图表:
图 5.4:filter()
方法选择满足给定谓词的数组元素在developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
上阅读更多关于.filter()
函数的内容。
筛选数组时要记住的事情有:
-
始终从谓词中返回一些东西。如果你忘记包含一个
return
,函数将隐式返回undefined
,而由于那是一个假值,输出将是一个空数组。 -
复制的是浅层的。如果输入数组元素是对象或数组,原始元素仍然是可访问的。
一个 reduce()示例
让我们看一个实际的例子。假设一个服务返回了一个 JSON 对象,其中包含一个包含账户id
和账户balance
的对象数组。我们如何获取处于赤字状态,即余额为负的 ID 列表?输入数据可能如下:
{
accountsData: [
{
id: "F220960K",
balance: 1024
},
{
id: "S120456T",
balance: 2260
},
{
id: "J140793A",
balance: -38
},
{
id: "M120396V",
balance: -114
},
{
id: "A120289L",
balance: 55000
}
]
}
假设我们将这些数据存储在一个serviceResult
变量中,我们可以通过以下方式获取拖欠账户:
const delinquent = serviceResult.accountsData.filter(v => v.balance < 0);
console.log(delinquent); // two objects, with id's J140793A and M120396V
顺便说一下,考虑到过滤操作产生了另一个数组,如果你只想要账户 ID,你可以通过映射输出来实现。
const delinquentIds = delinquent.map(v => v.id);
如果你不在乎中间结果,一行代码也可以。
const delinquentIds2 = serviceResult.accountsData
.filter(v => v.balance < 0)
.map(v => v.id);
使用 reduce()模拟 filter()
就像我们之前用.map()
做的一样,我们也可以通过使用.reduce()
创建我们自己的.filter()
版本。这个想法是类似的:循环遍历输入数组的所有元素,对其应用谓词,如果结果为true
,则将原始元素添加到输出数组中。当循环结束时,输出数组将只包含谓词为true
的那些元素。
const myFilter = (arr, fn) =>
arr.reduce((x, y) => (fn(y) ? x.concat(y) : x), []);
我们可以很快地看到我们的函数按预期工作。
console.log(myFilter(serviceResult.accountsData, v => v.balance < 0));
// two objects, with id's J140793A and M120396V
输出与本节前面的账户对相同。
搜索数组
有时,你不想过滤数组的所有元素,而是想找到满足给定条件的元素。根据你的具体需求,可以使用一些函数来实现这一点:
-
.find()
搜索数组并返回满足给定条件的第一个元素的值,如果找不到这样的元素,则返回undefined
-
.findIndex()
执行类似的任务,但是它返回的不是元素,而是数组中满足条件的第一个元素的索引,如果找不到则返回-1
这个类比很明显,.includes()
和.indexOf()
搜索特定的值,而不是满足更一般条件的元素。我们可以很容易地编写等效的一行代码:
arr.includes(value); // arr.find(**v => v === value**)
arr.indexOf(value); // arr.findIndex(**v => v === value**)
回到我们之前使用的地理数据,我们可以很容易地找到一个给定的国家。
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 brazilData = markers.find(v => v.name === "BR");
// {name:"BR", lat:-15.8, lon:-47.9}
我们无法使用更简单的.includes()
方法,因为我们必须深入对象以获取我们想要的字段。如果我们想要数组中国家的位置,我们将使用.findIndex()
:
let brazilIndex = markers.findIndex(v => v.name === "BR"); // 2
let mexicoIndex = markers.findIndex(v => v.name === "MX"); // -1
特殊的搜索情况
现在,为了多样化,来做一个小测验。假设你有一个数字数组,并想要进行一次健全性检查,研究其中是否有任何NaN
。你会怎么做?提示:不要尝试检查数组元素的类型:尽管NaN
代表Not a Number,typeof NaN === "number"
...如果你试图以显而易见的方式进行搜索,你会得到一个令人惊讶的结果...
[1, 2, NaN, 4].findIndex(x => x === NaN); // -1
这里发生了什么?这是有趣的 JS 小知识:NaN
是唯一不等于自身的值。如果你需要查找NaN
,你将不得不使用新的isNaN()
函数,如下所示:
[1, 2, NaN, 4].findIndex(x => isNaN(x)); // 2
使用 reduce()模拟 find()和 findIndex()
和其他方法一样,让我们通过使用万能的.reduce()
来学习如何实现我们展示的方法。这是一个很好的练习,可以让你习惯使用高阶函数,即使你永远不会真正使用这些 polyfills!
.find()
函数需要一些工作。我们从一个未定义的值开始搜索,如果我们找到一个数组元素使得谓词为true
,我们就将累积值更改为数组的值:
arr.find(fn);
// arr.reduce((x, y) => (x === undefined && fn(y) ? y : x), undefined);
对于findIndex()
,我们必须记住回调函数接收累积值、数组当前元素和当前元素的索引,但除此之外,等价表达式与find()
的表达式非常相似;比较它们是值得的。
arr.findIndex(fn);
// arr.reduce((x, y, i) => (x == -1 && fn(y) ? i : x), -1);
初始累积值在这里是-1
,如果没有元素满足谓词,则将返回该值。每当累积值仍为-1
,但我们找到满足谓词的元素时,我们将累积值更改为数组索引。
更高级的谓词-一些,每个
我们要考虑的最后一个函数大大简化了通过数组来测试条件。这些函数是:
-
.every()
,如果数组中的每个元素都满足给定的谓词,则为true
-
.some()
,如果数组中至少一个元素满足谓词,则为true
例如,我们可以轻松检查我们关于所有国家都有负坐标的假设:
markers.every(v => v.lat < 0 && x.lon < 0); // *false*
markers.some(v => v.lat < 0 && x.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)
,并将结果与先前的测试进行逻辑与运算;最终结果为true
的唯一方式是如果每个测试都为true
。第二个折叠操作类似,但将结果与先前的结果进行逻辑或运算,除非每个测试都为false
,否则将产生true
。
从布尔代数的角度来看,我们会说every()
和some()
的替代形式表现出对偶性。这种对偶性与表达式x === x && true
和x === x || false
中出现的对偶性相同;如果x
是一个布尔值,并且我们交换&&
和||
,以及true
和false
,我们将一个表达式转换为另一个表达式,两者都是有效的。
检查负数-无
如果您愿意,您还可以定义.none()
,作为.every()
的补集--这个新函数只有在数组的元素都不满足给定的谓词时才为真。编写这个函数的最简单方法是注意到如果没有元素满足条件,那么所有元素都满足条件的否定。
const none = (arr, fn) => arr.every(v => !fn(v));
如果您愿意,您可以将其转换为一个方法,通过修改数组原型,就像我们之前看到的那样--这仍然是一个不好的做法,但这是我们在开始寻找更好的方法来组合和链接函数之前所拥有的。
Array.prototype.none = function(fn) {
return this.every(v => !fn(v));
};
我们必须使用function()
,而不是箭头函数,原因与我们之前看到的相同;在这种情况下,我们确实需要正确分配this
。
在第六章中,生成函数-高阶函数,我们将看到通过编写适当的自定义高阶函数来否定函数的其他方法。
问题
5.1. 过滤...但是什么:假设您有一个名为someArray
的数组,并且您对其应用以下.filter()
,乍一看甚至看起来不像有效的 JS 代码。新数组中会有什么,为什么?
let newArray = someArray.filter(Boolean);
5.2. 生成 HTML 代码,带限制:使用filter()
...map()
...reduce()
序列是相当常见的(即使有时您可能不会使用所有三个),我们将在第十一章的功能设计模式部分回到这一点,实现设计模式-功能方式。这里的问题是使用这些函数(而不是其他任何函数!)来生成一个无序元素列表(<ul>
...</ul>
),以便稍后在屏幕上使用。您的输入是一个类似以下对象的数组(字符列表是否让我显得老?),您必须列出与国际象棋或跳棋玩家对应的每个名称:
var 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.3 更正式的测试: 在前面的一些示例中,比如在用reduce()
模拟map()
部分,我们没有编写实际的单元测试,而是满足于做一些控制台日志记录。你能否写出适当的单元测试呢?
5.4. 广泛涉猎: 我们在这里看到的range()
函数可以有很多用途,但在通用性上有点欠缺。你能否扩展它,使其允许降序范围,比如range(10,1)
?(范围中的最后一个数字应该是什么?)另外,你还能否允许包含步长,以指定范围中连续数字之间的差异?有了这个,range(1,10,2)
将产生[1, 3, 5, 7, 9]
。
5.5 做字母表: 如果在使用范围部分,而不是编写map(x => String.fromCharCode(x))
,你只是简单地写了map(String.fromCharCode)
会发生什么?你能解释不同的行为吗?提示:我们在本章的其他地方已经看到了类似的问题。
5.6. 生成 CSV: 在某个应用程序中,您希望用户能够通过使用数据 URI(逗号分隔值)文件下载一组数据。 (在developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs/
中了解更多。)当然,第一个问题是生成 CSV 本身!假设您有一个数字值数组的数组,如下面的代码段所示,并编写一个函数,将该结构转换为 CSV 字符串,然后您将能够将其插入 URI 中。像往常一样,\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"
摘要
在本章中,我们已经开始使用高阶函数,以展示更具声明性的工作方式,以更简洁、更具表现力的代码。我们已经讨论了几种操作:我们已经看到了.reduce()
和.reduceRight()
,从数组中获取单个结果;.map()
,对数组的每个元素应用函数;.forEach()
,简化循环;.filter()
,从数组中选择元素;.find()
和.findIndex()
,在数组中搜索;以及.every()
和.some()
,验证一般逻辑条件。
在第六章中,生成函数 - 高阶函数,我们将继续使用高阶函数,但随后我们将转而编写自己的函数,以获得更多表达力,为我们的编码。
第六章:生成函数 - 高阶函数
在第五章中,声明式编程 - 更好的风格,我们使用了一些预定义的高阶函数,并且能够看到它们的使用方式让我们编写了声明式的代码,不仅在可理解性上有所提升,而且在紧凑性上也有所提升。在这一新章节中,我们将进一步探讨高阶函数的方向,并且我们将开发我们自己的高阶函数。我们可以将我们要进入的函数类型大致分类为三组:
-
包装函数,保持其原始功能,添加某种新功能。在这一组中,我们可以考虑日志记录(为任何函数添加日志记录功能)、计时(为给定函数生成时间和性能数据)和记忆化(缓存结果以避免未来的重新计算)。
-
修改函数,在某些关键点上与它们的原始版本不同。在这里,我们可以包括
once()
函数(我们在第二章中编写过,函数式思维 - 第一个示例),它改变了原始函数只运行一次,像not()
或invert()
这样改变函数返回值的函数,以及产生具有固定参数数量的新函数的 arity 相关转换。 -
其他产物,提供新的操作,将函数转换为 promises,提供增强的搜索功能,或允许将方法与对象解耦,以便我们可以在其他上下文中使用它们,就像它们是普通函数一样。
包装函数
在这一部分,让我们考虑一些提供对其他函数进行包装以某种方式增强其功能,但不改变其原始目的的高阶函数。在设计模式方面(我们将在第十一章中重新讨论),我们也可以谈论装饰器。这种模式基于向对象(在我们的情况下是函数)添加一些行为而不影响其他对象的概念。装饰器这个术语也很受欢迎,因为它在 Angular 等框架中的使用,或者(在实验模式下)用于 JS 的一般编程。
装饰器正在考虑在 JS 中进行一般采用,但目前(2017 年 8 月)处于 2 阶段,草案级别,可能要等一段时间才能进入 3 阶段(候选)和最终进入 4 阶段(完成,意味着正式采用)。你可以在tc39.github.io/proposal-decorators/
了解更多关于 JS 装饰器的信息,以及 JS 采用过程本身,称为 TC39,在tc39.github.io/process-document/
。在第十一章,实现设计模式 - 函数式方法的问题部分中查看更多信息。
至于包装器这个术语,它比你想象的更重要和普遍;事实上,JavaScript 广泛使用它。在哪里?你已经知道对象属性和方法是通过点表示法访问的。然而,你也知道你可以编写诸如myString.length
或22.9.toPrecision(5)
的代码--这些属性和方法是从哪里来的,因为字符串和数字都不是对象?JavaScript 实际上在你的原始值周围创建了一个包装对象。这个对象继承了适用于包装值的所有方法。一旦需要进行评估,JavaScript 就会丢弃刚刚创建的包装器。我们无法对这些瞬时包装器做任何事情,但有一个概念我们将会回来:包装器允许在不适当类型的东西上调用方法--这是一个有趣的想法;参见第十二章,构建更好的容器 - 函数式数据类型,了解更多应用。
日志
让我们从一个常见的问题开始。在调试代码时,通常需要添加某种日志信息,以查看函数是否被调用,使用了什么参数,返回了什么,等等。(是的,当然,您可以简单地使用调试器并设置断点,但请在这个例子中忍耐一下!)正常工作意味着您将不得不修改函数本身的代码,无论是在进入还是退出时。您将不得不编写如下的代码:
function someFunction(param1, param2, param3) {
// *do something*
// *do something else*
// *and a bit more,*
// *and finally*
return *some expression*;
}
到这样的程度:
function someFunction(param1, param2, param3) {
console.log("entering someFunction: ", param1, param2, param3);
// *do something*
// *do something else*
// *and a bit more,*
// *and finally*
let auxValue = *some expression*;
console.log("exiting someFunction: ", auxValue);
return auxValue;
}
如果函数可以在多个地方返回,您将不得不修改所有的return
语句,以记录要返回的值。当然,如果您只是在动态计算返回表达式,您将需要一个辅助变量来捕获该值。
以一种功能性的方式记录
这样做并不困难,但修改代码总是危险的,容易发生“意外”。因此,让我们戴上我们的 FP 帽子,想出一种新的方法来做这件事。我们有一个执行某种工作的函数,我们想知道它接收到的参数和它返回的值。
我们可以编写一个高阶函数,它将有一个参数,即原始函数,并返回一个新的函数,该函数将执行以下操作:
-
记录接收到的参数。
-
调用原始函数,捕获其返回的值。
-
记录该值;最后。
-
返回给调用者。
一个可能的解决方案如下:
const addLogging = fn => (...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 应用程序执行此操作,您可能会选择更好的日志记录方式,比如使用 Winston、Morgan 或 Bunyan 等库--但我们的重点是展示如何包装原始函数,使用这些库所需的更改将很小。
例如,我们可以将其与即将到来的函数一起使用--我同意,以一种过于复杂的方式编写,只是为了有一个合适的例子!
function subtract(a, b) {
b = changeSign(b);
return a + b;
}
function changeSign(a) {
return -a;
}
subtract = addLogging(subtract);
changeSign = addLogging(changeSign);
let x = subtract(7, 5);
执行最后一行的结果将产生以下日志行:
entering subtract: 7 5
entering changeSign: 5
exiting changeSign: -5
exiting subtract: 2
我们在代码中所做的所有更改都是重新分配subtract()
和changeSign()
,这实质上替换了它们的新的生成日志的包装版本。对这两个函数的任何调用都将产生此输出。
我们将会看到一个可能的错误,因为在下一节的Memoizing中没有重新分配包装的日志函数。
考虑异常情况
让我们稍微增强我们的日志函数,考虑到需要的调整。如果函数抛出错误,您的日志会发生什么?幸运的是,这很容易解决。我们只需要添加一些代码:
const addLogging2 = fn => (...args) => {
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;
}
};
其他更改将由您决定--添加日期和时间数据,增强参数列表的方式等。然而,我们的实现仍然存在一个重要的缺陷;让我们改进一下。
以更纯粹的方式工作
当我们编写了addLogging()
前面的函数时,我们放弃了第四章中看到的一些原则,行为得体 - 纯函数,因为我们在代码中包含了一个不纯的元素(console.log()
)。这样做,我们不仅失去了灵活性(您能够选择替代的日志方式吗?),而且还使我们的测试变得更加复杂。当然,我们可以通过监听console.log()
方法来测试它,但这并不是很干净:我们依赖于了解我们想要测试的函数的内部,而不是进行纯粹的黑盒测试:
describe("a logging function", function() {
it("should log twice with well behaved functions", () => {
let something = (a, b) => `result=${a}:${b}`;
something = addLogging(something);
spyOn(window.console, "log");
something(22, 9);
expect(window.console.log).toHaveBeenCalledTimes(2);
expect(window.console.log).toHaveBeenCalledWith(
"entering something: 22,9"
);
expect(window.console.log).toHaveBeenCalledWith(
"exiting something: result=22:9"
);
});
it("should report a thrown exception", () => {
let thrower = (a, b, c) => {
throw "CRASH!";
};
spyOn(window.console, "log");
expect(thrower).toThrow();
thrower = addLogging(thrower);
try {
thrower(1, 2, 3);
} catch (e) {
expect(window.console.log).toHaveBeenCalledTimes(2);
expect(window.console.log).toHaveBeenCalledWith(
"entering thrower: 1,2,3"
);
expect(window.console.log).toHaveBeenCalledWith(
"exiting thrower: threw CRASH!"
);
}
});
});
运行这个测试表明addLogging()
的行为符合预期,所以这是一个解决方案。
即使这样,以这种方式测试我们的函数并不能解决我们提到的灵活性不足。我们应该注意我们在注入不纯函数部分写的内容:日志函数应该作为参数传递给包装函数,这样我们就可以在需要时更改它:
const addLogging3 = (fn, logger = console.log) => (...args) => {
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,结果会相应地有所不同:
有关winston日志工具的更多信息,请参见github.com/winstonjs/winston
。
const winston = require("winston");
const myLogger = **t => winston.log("debug", "Logging by winston: %s", t)**;
winston.level = "debug";
subtract = addLogging3(subtract, myLogger);
changeSign = addLogging3(changeSign, myLogger);
let x = subtract(7, 5);
// *debug: Logging by winston: entering subtract: 7,5*
// *debug: Logging by winston: entering changeSign: 5*
// *debug: Logging by winston: exiting changeSign: -5*
// *debug: Logging by winston: exiting subtract: 2*
现在我们已经遵循了我们之前的建议,我们可以利用存根。测试代码几乎与以前相同,但我们使用了一个没有提供功能或副作用的存根dummy.logger()
,所以在各方面都更安全。确实:在这种情况下,最初被调用的真实函数console.log()
不会造成任何伤害,但并非总是如此,因此建议使用存根:
describe("after addLogging2()", function() {
let dummy;
beforeEach(() => {
dummy = {logger() {}};
spyOn(dummy, "logger");
});
it("should call the provided logger", () => {
let something = (a, b) => `result=${a}:${b}`;
something = addLogging2(something, dummy.logger);
something(22, 9);
expect(dummy.logger).toHaveBeenCalledTimes(2);
expect(dummy.logger).toHaveBeenCalledWith(
"entering something: 22,9"
);
expect(dummy.logger).toHaveBeenCalledWith(
"exiting something: result=22:9"
);
});
it("a throwing function should be reported", () => {
let thrower = (a, b, c) => {
throw "CRASH!";
};
thrower = addLogging2(thrower, dummy.logger);
try {
thrower(1, 2, 3);
} catch (e) {
expect(dummy.logger).toHaveBeenCalledTimes(2);
expect(dummy.logger).toHaveBeenCalledWith(
"entering thrower: 1,2,3"
);
expect(dummy.logger).toHaveBeenCalledWith(
"exiting thrower: threw CRASH!"
);
}
});
});
在应用 FP 技术时,一定要记住,如果你在某种程度上使自己的工作复杂化——例如,使测试任何一个函数变得困难——那么你一定是在做错事。在我们的案例中,addLogging()
的输出是一个不纯的函数,这一事实本应引起警惕。当然,鉴于代码的简单性,在这种特殊情况下,你可能会决定不值得修复,你可以不测试,你也不需要能够更改日志生成的方式。然而,长期的软件开发经验表明,迟早你会后悔这样的决定,所以尽量选择更清洁的解决方案。
时间
包装函数的另一个可能的应用是以完全透明的方式记录和记录每个函数调用的时间。
如果你计划优化你的代码,请记住以下规则:不要这样做,然后还不要这样做,最后不要在没有测量的情况下这样做。经常提到,很多糟糕的代码都是由早期的优化尝试产生的,所以不要试图写出最佳的代码,不要试图优化,直到你意识到需要优化,不要随意地进行优化,而是通过测量应用程序的所有部分来确定减速的原因。
在前面的例子的基础上,我们可以编写一个addTiming()
函数,给定任何函数,它将生成一个包装版本,该版本将在控制台上写出时间数据,但在其他方面的工作方式完全相同:
const myPut = (text, name, tStart, tEnd) =>
console.log(`${name} - ${text} ${tEnd - tStart} ms`);
const myGet = () => performance.now();
const addTiming = (fn, getTime = myGet, output = myPut) => (...args) => {
let tStart = getTime();
try {
const valueToReturn = fn(...args);
output("normal exit", fn.name, tStart, getTime());
return valueToReturn;
} catch (thrownError) {
output("exception thrown", fn.name, tStart, getTime());
throw thrownError;
}
};
请注意,与我们在前一节对日志函数应用的增强相一致,我们提供了单独的记录器和时间访问函数。编写我们的addTiming()
函数的测试应该很容易,因为我们可以注入两个不纯函数。
使用performance.now()
提供了最高的精度。如果你不需要这个函数提供的精度(它可能是过度的),你可以简单地用Date.now()
替代。有关这些替代方案的更多信息,请参见developer.mozilla.org/en-US/docs/Web/API/Performance/now
和developer.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
。
为了能够充分尝试日志功能,我修改了subtract()
函数,这样如果你尝试减去零,它会抛出一个错误。如果需要,你也可以列出输入参数,以获取更多信息:
subtract = **addTiming(subtract)**;
let x = subtract(7, 5);
// subtract - normal exit 0.10500000000001819 ms
let y = subtract(4, 0);
// subtract - exception thrown 0.0949999999999136 ms
这段代码与之前的addLogging()
函数非常相似,这是合理的--在这两种情况下,我们都在实际函数调用之前添加了一些代码,然后在函数返回后添加了一些新代码。您甚至可以考虑编写一个更高级的高阶函数,它将接收三个函数,并且会产生一个高阶函数作为输出(例如addLogging()
或addTiming()
),该函数将在开始时调用第一个函数,然后在包装函数返回值时调用第二个函数,或者在抛出错误时调用第三个函数!怎么样?
记忆化
在第四章中,行为良好-纯函数,我们考虑了斐波那契函数的情况,并看到了如何通过手工将其转换为更高效的版本,通过记忆化:缓存计算的值,以避免重新计算。为简单起见,现在让我们只考虑具有单个非结构化参数的函数,并留待以后处理具有更复杂参数(对象、数组)或多个参数的函数。
我们可以轻松处理的值的类型是 JS 的原始值:不是对象且没有方法的数据。JS 有六种原始值:boolean
、null
、number
、string
、symbol
和undefined
。很可能我们只会看到前四个作为实际参数。在developer.mozilla.org/en-US/docs/Glossary/Primitive
中了解更多。
简单的记忆化
我们将使用我们提到的斐波那契函数,这是一个简单的情况:它接收一个数字参数。我们看到的函数如下:
function fib(n) {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
return fib(n - 2) + fib(n - 1);
}
}
我们在那里做的解决方案在概念上是通用的,但在实现上特别是:我们必须直接修改函数的代码,以便利用所述的记忆化。现在我们应该研究一种自动执行相同方式的方法,就像对其他包装函数一样。解决方案将是一个memoize()
函数,它包装任何其他函数,以应用记忆化:
const memoize = fn => {
let cache = {};
return x => (x in cache ? cache[x] : (cache[x] = fn(x)));
};
这是如何工作的?对于任何给定的参数,返回的函数首先检查参数是否已经接收到;也就是说,它是否可以在缓存对象中找到。如果是这样,就不需要计算,直接返回缓存的值。否则,我们计算缺失的值并将其存储在缓存中。(我们使用闭包来隐藏缓存,防止外部访问。)我们在这里假设记忆化函数只接收一个参数(x
),并且它是一个原始值,然后可以直接用作缓存对象的键值;我们以后会考虑其他情况。
这个方法有效吗?我们需要计时--我们碰巧有一个有用的addTiming()
函数来做这个!首先,我们对原始的fib()
函数进行一些计时。我们想要计时完整的计算过程,而不是每个递归调用,所以我们编写了一个辅助的testFib()
函数,这是我们将计时的函数。我们应该重复计时操作并取平均值,但是,由于我们只是想确认记忆化是否有效,我们将容忍差异:
const testFib = n => fib(n);
addTiming(testFib)(45); // 15,382.255 ms
addTiming(testFib)(40); // 1,600.600 ms
addTiming(testFib)(35); // 146.900 ms
当然,您的时间可能会有所不同,但结果似乎是合乎逻辑的:我们在第四章中提到的指数增长似乎是存在的,时间增长迅速。现在,让我们对fib()
进行记忆化,我们应该得到更短的时间--或者不应该吗?
const testMemoFib = memoize(n => fib(n));
addTiming(testMemoFib)(45); // 15,537.575 ms
addTiming(testMemoFib)(45); // 0.005 ms... *good!*
addTiming(testMemoFib)(40); // 1,368.880 ms... *recalculating?*
addTiming(testMemoFib)(35); // 123.970 ms... *here too?*
出了些问题!时间应该下降了——但它们几乎一样。这是因为一个常见的错误,我甚至在一些文章和网页中看到过。我们正在计时memofib()
——但除了计时之外,没有人调用那个函数,而且那只会发生一次!在内部,所有的递归调用都是fib()
,它没有被记忆化。如果我们再次调用testMemoFib(45)
,那个调用会被缓存,它会几乎立即返回,但这种优化不适用于内部的fib()
调用。这也是为什么testMemoFib(40)
和testMemoFib(35)
的调用没有被优化的原因——当我们计算testMemoFib(45)
时,那是唯一被缓存的值。
正确的解决方案如下:
fib = memoize(fib);
addTiming(testFib)(45); // 0.080 ms
addTiming(testFib)(40); // 0.025 ms
addTiming(testFib)(35); // 0.009 ms
现在,当计算fib(45)
时,实际上所有中间的斐波那契值(从fib(0)
到fib(45)
本身)都被存储了,所以即将到来的调用几乎没有什么工作要做。
更复杂的记忆化
如果我们必须处理接收两个或更多参数的函数,或者可以接收数组或对象作为参数的函数,我们该怎么办?当然,就像我们在第二章中看到的问题一样,函数式思维 - 第一个例子,关于让函数只执行一次,我们可以简单地忽略这个问题:如果要进行记忆化的函数是一元的,我们就进行记忆化;否则,如果函数的 arity 不同,我们就什么都不做!
函数的参数个数称为函数的arity,或者它的valence。你可以用三种不同的方式来说:你可以说一个函数的 arity 是 1、2、3 等,或者你可以说一个函数是一元的、二元的、三元的等,或者你也可以说它是单元的、二元的、三元的等:随你挑!
const memoize2 = fn => {
if (fn.length === 1) {
let cache = {};
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()
应用于参数数组的结果作为缓存键。我们增强的记忆化高阶函数可以如下:
const memoize3 = fn => {
let cache = {};
const PRIMITIVES = ["number", "string", "boolean"];
return (...args) => {
let strX =
args.length === 1 && PRIMITIVES.includes(typeof args[0])
? args[0]
: JSON.stringify(args);
return strX in cache ? cache[strX] : (cache[strX] = fn(...args));
};
};
就普遍性而言,这是最安全的版本。如果你确定要处理的函数的参数类型,可以说我们的第一个版本更快。另一方面,如果你想要更容易理解的代码,即使牺牲一些 CPU 周期,你可以选择一个更简单的版本:
const memoize4 = fn => {
let cache = {};
return (...args) => {
let strX = JSON.stringify(args);
return strX in cache ? cache[strX] : (cache[strX] = fn(...args));
};
};
如果你想了解一个性能最佳的记忆化函数的开发情况,可以阅读 Caio Gondim 的文章How I wrote the world's fastest JavaScript memoization library,在线可供阅读community.risingstack.com/the-worlds-fastest-javascript-memoization-library/
。
记忆化测试
测试记忆化高阶函数提出了一个有趣的问题--你会怎么做?第一个想法是查看缓存--但那是私有的,不可见的。当然,我们可以改变memoize()
来使用全局缓存,或者以某种方式允许外部访问缓存,但这种内部检查是不受欢迎的:你应该尝试仅基于外部属性进行测试。
接受我们应该省略尝试检查缓存,我们可以进行时间控制:调用一个函数,比如fib()
,对于一个很大的 n 值,如果函数没有进行记忆化,应该需要更长的时间。这当然是可能的,但也容易出现可能的失败:你的测试之外的某些东西可能会在恰好的时候运行,可能你的记忆化运行时间会比原始运行时间更长。好吧,这是可能的,但不太可能--但你的测试并不完全可靠。
然后,让我们更直接地分析记忆化函数的实际调用次数。使用非记忆化的原始fib()
,我们可以首先测试函数是否正常工作,并检查它调用了多少次:
var fib = null;
beforeEach(() => {
fib = n => {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
return fib(n - 2) + fib(n - 1);
}
};
});
describe("the original fib", function() {
it("should produce correct results", () => {
expect(fib(0)).toBe(0);
expect(fib(1)).toBe(1);
expect(fib(5)).toBe(5);
expect(fib(8)).toBe(21);
expect(fib(10)).toBe(55);
});
it("should repeat calculations", () => {
spyOn(window, "fib").and.callThrough();
expect(fib(6)).toBe(8);
expect(fib).toHaveBeenCalledTimes(25);
});
});
fib(6)
等于 8 这一事实很容易验证,但你怎么知道函数被调用了 25 次?为了回答这个问题,让我们重新看一下之前在第四章中看到的图表,行为得体-纯函数:
图 6.1。计算 fib(6)所需的所有递归调用。
每个节点都是一个调用;仅仅计数,我们得到为了计算fib(6)
,实际上有 25 次对fib()
的调用。现在,让我们转向函数的记忆版本。测试它是否仍然产生相同的结果很容易:
describe("the memoized fib", function() {
beforeEach(() => {
fib = memoize(fib);
});
it("should produce same results", () => {
expect(fib(0)).toBe(0);
expect(fib(1)).toBe(1);
expect(fib(5)).toBe(5);
expect(fib(8)).toBe(21);
expect(fib(10)).toBe(55);
});
it("shouldn't repeat calculations", () => {
spyOn(window, "fib").and.callThrough();
expect(fib(6)).toBe(8); // 11 calls
expect(fib).toHaveBeenCalledTimes(11);
expect(fib(5)).toBe(5); // 1 call
expect(fib(4)).toBe(3); // 1 call
expect(fib(3)).toBe(2); // 1 call
expect(fib).toHaveBeenCalledTimes(14);
});
});
但为什么在计算fib(6)
时被调用了 11 次,然后在计算fib(5)
,fib(4)
和fib(3)
之后又被调用了三次?为了回答问题的第一部分,让我们分析一下之前看到的图:
-
首先,我们调用
fib(6)
,它调用了fib(4)
和fib(5)
:三次调用 -
在计算
fib(4)
时,调用了fib(2)
和fib(3)
;计数增加到了五 -
在计算
fib(5)
时,调用了fib(3)
和fib(4)
;计数上升到 11 -
最后,计算并缓存了
fib(6)
-
fib(3)
和fib(4)
都被缓存了,所以不再进行调用 -
fib(5)
被计算并缓存 -
在计算
fib(2)
时,调用了fib(0)
和fib(1)
;现在我们有了七次调用 -
在计算
fib(3)
时,调用了fib(1)
和fib(2)
;计数增加到了九 -
fib(4)
被计算并缓存 -
fib(1)
和fib(2)
都已经被缓存了,所以不会再进行进一步的调用 -
fib(3)
被计算并缓存 -
在计算
fib(0)
和fib(1)
时,不会进行额外的调用,两者都被缓存了 -
fib(2)
被计算并缓存
哇!所以fib(6)
的调用次数是 11--现在,鉴于所有fib(n)
的值都已经被缓存,对于 n 从 0 到 6,很容易看出计算fib(5)
,fib(4)
和fib(3)
只会增加三次调用:所有其他所需的值都已经被缓存。
改变函数
在前一节中,我们考虑了一些包装函数的方法,使它们保持其原始功能,尽管在某些方面得到了增强。现在我们将转而实际修改函数的功能,使新的结果实际上与原始函数的结果不同。
重新做一次事情
回到第二章,思考功能性-第一个例子,我们通过一个简单的问题的 FP 风格解决方案的例子:修复一个给定函数只能工作一次的问题:
const once = func => {
let done = false;
return (...args) => {
if (!done) {
done = true;
func(...args);
}
};
};
这是一个完全合理的解决方案,我们没有任何异议。然而,我们可以考虑一种变体。我们可以观察到给定的函数被调用一次,但其返回值被丢失了。然而,这很容易解决;我们只需要添加一个return
语句。然而,这还不够;如果调用更多次,函数会返回什么呢?我们可以借鉴记忆化解决方案,并为将来的调用存储函数的返回值:
const once2 = func => {
let done = false;
let result;
return (...args) => {
if (!done) {
done = true;
result = func(...args);
}
return result;
};
};
你也可以考虑使函数仅对每组参数起作用一次...但是你不必为此做任何工作:memoize()
就足够了!
回到提到的第二章,函数式思维 - 第一个例子,我们考虑了once()
的一个可能替代品:另一个高阶函数,它以两个函数作为参数,并且只允许调用第一个函数一次,从那时起调用第二个函数。添加一个return
语句,它将如下所示:
const onceAndAfter = (f, g) => {
let done = false;
return (...args) => {
if (!done) {
done = true;
return f(...args);
} else {
return g(...args);
}
};
};
如果我们记得函数是一级对象,我们可以重写这个过程。我们可以使用一个变量(toCall
)直接存储需要调用的函数,而不是使用标志来记住要调用哪个函数。从逻辑上讲,该变量将被初始化为第一个函数,但随后将更改为第二个函数:
const onceAndAfter2 = (f, g) => {
let toCall = f;
return (...args) => {
let result = toCall(...args);
toCall = g;
return result;
};
};
我们之前看到的完全相同的例子仍然可以工作:
const squeak = (x) => console.log(x, "squeak!!");
const creak = (x) => 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()
方法,声明式编程 - 更好的风格。给定一个谓词,我们可以过滤数组,只包括谓词为真的元素。但是如何进行反向过滤并排除谓词为真的元素呢?
第一个解决方案应该是相当明显的:重新设计谓词,使其返回与原始返回值相反的值。在前面提到的章节中,我们看到了这个例子:
const delinquent = serviceResult.accountsData.filter(v => v.balance < 0);
因此,我们可以以另一种方式写出它,以这两种等效方式之一:
const notDelinquent = serviceResult.accountsData.filter(
v => v.balance >= 0
);
const notDelinquent2 = serviceResult.accountsData.filter(
v => !(v.balance < 0)
);
这是完全可以的,但我们也可以有类似以下的东西:
const isNegativeBalance = v => v.balance < 0;
// ...*many lines later..*.
const delinquent2 = serviceResult.accountsData.filter(isNegativeBalance);
在这种情况下,重写原始函数是不可能的。然而,在函数式编程中,我们可以编写一个高阶函数,它将接受任何谓词,评估它,然后否定其结果。由于 ES8 的语法,可能的实现会非常简单:
const not = fn => (...args) => !fn(...args);
以这种方式工作,我们可以将前面的过滤重写为以下形式:
const isNegativeBalance = v => v.balance < 0;
// ...*many lines later...*
const notDelinquent3 = serviceResult.accountsData.filter(
not(isNegativeBalance)
);
我们可能想要尝试的另一个解决方案是--而不是颠倒条件(如我们所做的),我们可以编写一个新的过滤方法(可能是filterNot()
?),它将以与filter()
相反的方式工作:
const filterNot = arr => fn => arr.filter(not(fn));
这个解决方案与.filter()
并不完全匹配,因为你不能将其用作方法,但我们可以将其添加到Array.prototype
中,或者应用一些我们将在第八章中看到的方法,连接函数 - 管道和组合。然而,更有趣的是,我们使用了否定的函数,因此not()
对于反向过滤问题的两种解决方案都是必要的。在即将到来的去方法化部分中,我们将看到另一个解决方案,因为我们将能够将诸如.filter()
之类的方法与它们适用的对象分离开来,将它们变成普通函数。
至于否定函数与使用新的filterNot()
,尽管两种可能性同样有效,但我认为使用not()
更清晰;如果你已经理解了过滤的工作原理,那么你几乎可以大声朗读它,它就会被理解:我们想要那些没有负余额的,对吧?
反转结果
与前面的过滤问题类似,现在让我们重新讨论第三章中的注入-排序部分中的排序问题,从函数开始-核心概念。我们想要使用特定的方法对数组进行排序,并且我们使用了.sort()
,提供了一个比较函数,基本上指出了哪个字符串应该先进行排序。为了提醒你,给定两个字符串,函数应该执行以下操作:
-
如果第一个字符串应该在第二个字符串之前,则返回一个负数
-
如果两个字符串相同,则返回零
-
返回一个正数,如果第一个字符串应该跟在第二个字符串后面
让我们回到我们之前在西班牙语排序中看到的代码。我们必须编写一个特殊的比较函数,以便排序能够考虑西班牙语的特殊字符顺序规则,比如在n和o之间包括字母ñ,等等。
const spanishComparison = (a, b) => a.localeCompare(b, "es");
palabras.sort(spanishComparison); // *sorts the* palabras *array according to Spanish rules*
我们面临着类似的问题:我们如何能够以降序的方式进行排序?根据我们在前一节中看到的内容,应该立即想到两种替代方案:
-
编写一个函数,它将反转比较函数的结果。这将反转所有关于哪个字符串应该在前面的决定,最终结果将是一个完全相反排序的数组。
-
编写一个
sortDescending()
函数或方法,以与sort()
相反的方式进行工作。
让我们编写一个invert()
函数,它将改变比较的结果。代码本身与前面的not()
非常相似:
const invert = fn => (...args) => -fn(...args);
有了这个高阶函数,我们现在可以通过提供一个适当反转的比较函数来进行降序排序:
const spanishComparison = (a, b) => a.localeCompare(b, "es");
var 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()
比较函数时,结果是相反的顺序。顺便说一句,编写单元测试将非常容易,因为我们已经有了一些测试用例和它们的预期结果,不是吗?
改变参数数量
回到第五章中隐式地解析数字的部分,我们看到使用parseInt()
与.reduce()
会产生问题,因为该函数的参数数量是意外的,它需要多于一个参数:
["123.45", "-67.8", "90"].map(parseInt); // *problem: parseInt isn't monadic!*
// [123, NaN, NaN]
我们有多种解决方法。在提到的章节中,我们选择了箭头函数,这是一个简单的解决方案,而且具有清晰易懂的优势。在第七章中,转换函数-柯里化和部分应用,我们将看到另一种方法,基于部分应用。但是,在这里,让我们使用一个高阶函数。我们需要的是一个函数,它将另一个函数作为参数,并将其转换为一元函数。使用 JS 的展开运算符和箭头函数,这很容易管理:
const unary = fn => (...args) => fn(args[0]);
使用这个函数,我们的数字解析问题就解决了:
["123.45", "-67.8", "90"].map(unary(parseInt));
// *[123, -67, 90]*
不用说,同样简单地定义进一步的binary()
、ternary()
等函数,可以将任何函数转换为等效的、限定数量参数的版本。
你可能会认为没有多少情况需要应用这种解决方案,但事实上,情况比你想象的要多得多。通过查看所有 JavaScript 的函数和方法,你可以轻松地列出一个以.apply()
、.assign()
、.bind()
、.concat()
、.copyWithin()
...等等开头的列表!如果你想以一种心照不宣的方式使用其中任何一个,你可能需要修复它的参数数量,这样它就可以使用固定的、非可变的参数数量。
如果你想要一个漂亮的 JavaScript 函数和方法列表,请查看developer.mozilla.org/en/docs/Web/JavaScript/Guide/Functions
和developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Methods_Index
上的页面。至于暗示(或点自由风格)编程,我们将在第八章中回到它,连接函数 - 管道和组合。
其他高阶函数
让我们在本章结束时考虑其他杂项函数,提供诸如新查找器、将方法与对象解耦等结果。
将操作转换为函数
我们已经看到了几种情况,我们需要编写一个函数来添加或乘以一对数字。例如,在第五章的求和数组部分,声明式编程 - 更好的风格,我们不得不编写等效于以下代码的代码:
const mySum = myArray.reduce((x, y) => x + y, 0);
在同一章节中,在使用范围部分,为了计算阶乘,我们需要这样:
const factorialByRange = n => range(1, n + 1).reduce((x, y) => x * y, 1);
如果我们能够将二元运算符转换为计算相同结果的函数,那将会更容易。前面的两个例子可以更简洁地写成如下所示:
const mySum = myArray.reduce(binaryOp("+"), 0);
const factorialByRange = n => range(1, n + 1).reduce(binaryOp("*"), 1);
实施操作
我们如何编写这个binaryOp()
函数?至少有两种方法:一种安全但冗长,一种更冒险但更短的替代方法。第一种方法需要列出每个可能的运算符:
const binaryOp1 = op => {
switch (op) {
case "+":
return (x, y) => x + y;
case "-":
return (x, y) => x - y;
case "*":
return (x, y) => x * y;
//
// etc.
//
}
};
这个解决方案完全没问题,但需要太多的工作。第二个更危险,但更短。请将其仅视为一个示例,用于学习目的;出于安全原因,不建议使用eval()
!
const binaryOp2 = op => new Function("x", "y", `return x ${op} y;`);
如果你遵循这种思路,你也可以定义一个unaryOp()
函数,尽管它的应用更少。 (我把这个实现留给你;它与我们已经写的内容非常相似。)在即将到来的第七章中,转换函数 - 柯里化和部分应用,我们将看到创建这个一元函数的另一种方法,即使用部分应用。
更方便的实现
让我们超前一步。进行 FP 并不意味着总是要回到非常基本、最简单的函数。例如,在第八章的转换为自由点风格部分,连接函数 - 管道和组合,我们将需要一个函数来检查一个数字是否为负数,并考虑使用binaryOp2()
来编写它:
const isNegative = curry(binaryOp2(">"))(0);
现在不要担心curry()
函数(我们很快会在第七章中讨论它,转换函数 - 柯里化和部分应用),但其思想是将第一个参数固定为零,因此我们的函数将检查给定数字n是否0>n。这里的重点是,我们刚刚编写的函数并不是很清晰。如果我们定义一个二元操作函数,还可以让我们指定其参数之一,左边的参数或右边的参数,以及要使用的运算符,我们可以做得更好:
const binaryLeftOp = (x, op) =>
(y) => binaryOp2(op)(x,y);
const binaryOpRight = (op, y) =>
(x) => binaryOp2(op)(x,y);
或者,你可以回到new Function()
风格的代码:
const binaryLeftOp2 = (x, op) => y => binaryOp2(op)(x, y);
const binaryOpRight2 = (op, y) => x => binaryOp2(op)(x, y);
有了这些新函数,我们可以简单地写出以下任一代码--尽管我认为第二个更清晰:我宁愿测试一个数字是否小于零,而不是零是否大于该数字:
const isNegative1 = binaryLeftOp(0, ">");
const isNegative2 = binaryOpRight("<", 0);
这有什么意义?不要追求某种基本简单或回归基础的代码。我们可以将运算符转换为函数,没错--但如果你能做得更好,并通过允许指定操作的两个参数之一来简化编码,那就去做吧!FP 的理念是帮助编写更好的代码,而创造人为限制对任何人都没有好处。
当然,对于一个简单的函数,比如检查一个数字是否为负数,我绝对不想用柯里化、二元运算符或点自由风格或其他任何东西来复杂化事情,我只会毫不犹豫地写出以下内容:
const isNegative3 = x => x < 0;
将函数转换为 promises
在 Node 中,大多数异步函数需要一个回调,比如(err,data)=>{...}
:如果err
是null
,函数成功,data
是其结果,如果err
有一些值,函数失败,err
给出了原因。(有关更多信息,请参见nodejs.org/api/errors.html#errors_node_js_style_callbacks
。)
但是,您可能更喜欢使用 promises。因此,我们可以考虑编写一个高阶函数,将需要回调的函数转换为一个 promise,让您使用.then()
和.catch()
方法。(在第十二章中,构建更好的容器-功能数据类型,我们将看到 promises 实际上是 monads,因此这种转换在另一个方面也很有趣。)
我们如何管理这个?转换相当简单。给定一个函数,我们生成一个新的函数:这将返回一个 promise,当使用一些参数调用原始函数时,将适当地reject()
或resolve()
promise:
const promisify = fn => (...args) =>
new Promise((resolve, reject) =>
fn(...args, (err, data) => (err ? reject(err) : resolve(data)))
);
有了这个函数,我们可以这样写代码:
const fs = require("fs");
const cb = (err, data) =>
err ? console.log("ERROR", err) : console.log("SUCCESS", data);
fs.readFile("./exists.txt", cb); // *success, list the data*
fs.readFile("./doesnt_exist.txt", cb); // *failure, show exception*
相反,您可以使用 promises:
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
,正如我们在第三章的一个不必要的错误部分中提到的那样,从函数开始-核心概念。
从对象中获取属性
有一个简单但经常使用的函数,我们也可以生成。从对象中提取属性是一个常见的操作。例如,在第五章中,以声明方式编程-更好的风格,我们需要获取纬度和经度以便计算平均值:
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);
我们本可以将这两行合并,并用一行代码产生所需的结果,但这里并不重要。事实上,除非delinquent
中间结果出于某种原因是必需的,大多数 FP 程序员都会选择一行解决方案。
我们需要什么?我们需要一个高阶函数,它将接收一个属性的名称,并产生一个新的函数作为其结果,这个函数将能够从对象中提取所述属性。使用 ES8 语法,这个函数很容易编写:
const getField = attr => obj => obj[attr];
在第十章的获取器和设置器部分,确保纯度-不可变性,我们将编写这个函数的更通用版本,能够“深入”到对象中,获取对象的任何属性,无论其在对象中的位置如何。
有了这个函数,坐标提取可以这样写:
let averageLat = average(markers.map(getField("lat")));
let averageLon = average(markers.map(getField("lon")));
为了多样化,我们可以使用辅助变量来获取拖欠的 ID。
const getId = getField("id");
const delinquent = serviceResult.accountsData.filter(v => v.balance < 0);
const delinquentIds = delinquent.map(getId);
一定要完全理解这里发生了什么。getField()
调用的结果是一个函数,将在进一步的表达式中使用。map()
方法需要一个映射函数,这就是getField()
产生的东西。
去方法化-将方法转换为函数
.filter()
或.map()
等方法仅适用于数组--但实际上,你可能希望将它们应用于NodeList
或String
,但你可能会碰壁。此外,我们正在关注字符串,因此必须将这些函数用作方法并不是我们想要的。最后,每当我们创建一个新函数(比如none()
,我们在第五章 以更好的方式编程 - 声明式编程 的检查否定部分中看到的),它不能像它的同行(在这种情况下是.some()
和.every()
)那样应用,除非你做一些原型的把戏--这是被严厉谴责的,也完全不推荐...但是请看第十二章 构建更好的容器 - 函数数据类型 的扩展当前数据类型部分,我们将使.map()
适用于大多数基本类型!
那么...我们能做什么呢?我们可以应用古话如果山不来,穆罕默德就去山,而不是担心无法创建新的方法,我们将现有的方法转换为函数。如果我们将每个方法转换为一个函数,该函数将作为其第一个参数接收它将要操作的对象。
解耦方法和对象可以帮助你,因为一旦你实现了这种分离,一切都变成了一个函数,你的代码会更简单。(还记得我们在逻辑否定一个函数中写的内容吗,关于可能的filterNot()
函数与.filter()
方法的比较?)解耦的方法在某种程度上类似于其他语言中所谓的通用函数,因为它们可以应用于不同的数据类型。
在 ES8 中,有三种不同但相似的实现方式。列表中的第一个参数将对应于对象;其他参数将对应于被调用方法的实际参数。
请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function
以了解apply()
、call()
和bind()
的解释。顺便说一句,在第一章 成为函数 - 几个问题 中,我们看到了在使用展开运算符时.apply()
和.call()
之间的等价性。
const demethodize1 = fn => (arg0, ...args) => fn.apply(arg0, args);
const demethodize2 = fn => (arg0, ...args) => fn.call(arg0, ...args);
const demethodize3 = fn => (...args) => fn.bind(...args)();
还有另一种方法: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
。
让我们看一些应用!从一个简单的例子开始,我们可以使用.map()
来循环遍历一个字符串,而不必先将其转换为字符数组。假设你想将一个字符串分隔成单个字母并将它们转换为大写:
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 = demethodize3(Array.prototype.map);
const toUpperCase = demethodize3(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);
// *["2,209.6", "124.56", "1,048,576"]*
或者,给定前面的 map 函数,这也可以工作:
const strings2 = map(numbers, toLocaleString);
将方法解除为函数的想法在不同的情况下将会非常有用。我们已经看到了一些例子,我们可以应用它,并且在本书的其余部分还会有更多这样的情况。
找到最佳解决方案
让我们通过创建.find()
方法的扩展来结束本节。假设我们想要找到数组中的最优值--假设它是最大值--:
const findOptimum = arr => Math.max(...arr);
const myArray = [22, 9, 60, 12, 4, 56];
findOptimum(myArray); // 60
现在,这是否足够通用?这种方法至少存在一对问题。首先,你确定集合的最优值总是最大值吗?如果你考虑了几种抵押贷款,那么利率最低的那个可能是最好的,不是吗?假设我们总是想要集合的最大值太过于局限了。
你可以绕个弯:如果你改变数组中所有数字的符号,找到它的最大值,然后改变它的符号,那么你实际上得到了数组的最小值。在我们的例子中,-findOptimum(myArray.map((x) => -x))
将产生 4--但这不是容易理解的代码。
其次,找到最大值的这种方式取决于每个选项都有一个数值。但如果这样的值不存在,你该如何找到最优值?通常的方法依赖于将元素相互比较,并选择在比较中排在前面的元素:将第一个元素与第二个元素进行比较,并保留其中较好的那个;然后将该值与第三个元素进行比较,并保留最好的;依此类推,直到你完成了所有元素的遍历。
以更一般的方式解决这个问题的方法是假设存在一个comparator()
函数,它以两个元素作为参数,并返回最好的那个。如果你能为每个元素关联一个数值,那么比较函数可以简单地比较这些值。在其他情况下,它可以根据需要执行任何逻辑,以便决定哪个元素排在前面。
让我们尝试创建一个合适的高阶函数:
const findOptimum2 = fn => arr => arr.reduce(fn);
有了这个,我们可以轻松地复制最大值和最小值查找函数。
const findMaximum = findOptimum2((x, y) => (x > y ? x : y));
const findMinimum = findOptimum2((x, y) => (x < y ? x : y));
findMaximum(myArray); // 60
findMinimum(myArray); // 4
让我们更上一层楼,比较非数值值。假设有一款超级英雄卡牌游戏:每张卡代表一个英雄,具有几个数值属性,如力量、能力和科技。当两个英雄互相对抗时,具有更多类别的英雄,其数值高于另一个英雄,将成为赢家。让我们为此实现一个比较器:
const compareHeroes = (card1, card2) => {
const oneIfBigger = (x, y) => (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;
};
然后,我们可以将这应用到我们的英雄“比赛”中:
function Hero(n, s, p, t) {
this.name = n;
this.strength = s;
this.powers = p;
this.tech = t;
}
const codingLeagueOfAmerica = [
new Hero("Forceful", 20, 15, 2),
new Hero("Electrico", 12, 21, 8),
new Hero("Speediest", 8, 11, 4),
new Hero("TechWiz", 6, 16, 30)
];
const findBestHero = findOptimum2(compareHeroes);
findBestHero(codingLeagueOfAmerica); // Electrico is the top hero!
当你根据一对一比较对元素进行排名时,可能会产生意想不到的结果。例如,根据我们的超级英雄比较规则,你可能会找到三个英雄,第一个击败第二个,第二个击败第三个,但第三个击败第一个!在数学术语中,这意味着比较函数不是传递的,你没有集合的完全排序。
问题
6.1. 一个边界情况。如果我们将getField()
函数应用于一个空对象,会发生什么?它应该是什么行为?如果需要,修改该函数。
6.2. 多少次? 要计算fib(50)
需要多少次调用而不使用记忆化?例如,计算fib(0)
或fib(1)
,只需要一次调用,不需要进一步递归,而对于fib(6)
,我们看到需要 25 次调用。你能找到一个公式来做这个计算吗?
6.3. 一个随机平衡器。编写一个高阶函数randomizer(fn1, fn2, ...)
,它将接收可变数量的函数作为参数,并返回一个新的函数,该函数在每次调用时将随机调用fn1
、fn2
等。如果每个函数都能执行 Ajax 调用,你可能会用到这个函数来平衡对服务器上不同服务的调用。为了加分,确保连续两次不会调用同一个函数。
6.4. 只说不! 在本章中,我们编写了一个与布尔函数一起工作的not()
函数和一个与数值函数一起工作的negate()
函数。你能更上一层楼,只编写一个opposite()
函数,根据需要表现为not()
或negate()
吗?
总结
在本章中,我们已经看到如何编写我们自己的高阶函数,它可以包装另一个函数以提供一些新功能,改变函数的目标以便做其他事情,甚至是全新的功能,比如将方法与对象解耦或创建更好的查找器。
在第七章中,函数转换-柯里化和部分应用,我们将继续使用高阶函数,并且我们将看到如何通过柯里化和部分应用来生成现有函数的专门版本,带有预定义的参数。
第七章:函数转换-柯里化和部分应用
在第六章中,生成函数-高阶函数,我们看到了几种操纵函数的方法,以获得具有某些功能变化的新版本。在本章中,我们将深入研究一种特定类型的转换,一种工厂方法,它让您可以使用一些固定参数来生成任何给定函数的新版本。
我们将考虑以下内容:
-
柯里化,一个经典的 FP 理论函数,将具有许多参数的函数转换为一系列一元函数
-
部分应用,另一个历史悠久的 FP 转换,通过固定一些参数来产生函数的新版本
-
我将称之为部分柯里化的东西,可以看作是两种先前转换的混合体
公平地说,我们还将看到,一些这些技术可以通过简单的箭头函数来模拟,可能会更清晰。然而,由于您很可能会在各种 FP 文本和网页上找到柯里化和部分应用,因此了解它们的含义和用法非常重要,即使您选择更简单的方法。
一点理论
本章中我们将使用的概念在某些方面非常相似,在其他方面则有很大不同。人们常常会对它们的真正含义感到困惑,并且有很多网页滥用术语。您甚至可以说,本章中的所有转换大致等效,因为它们让您将一个函数转换为另一个函数,固定一些参数,留下其他参数自由,并最终导致相同的结果。好吧,我同意,这并不是很清楚!因此,让我们从澄清一些概念开始,并提供一些简短的定义,稍后我们将进行扩展。(如果您觉得自己的眼睛开始发直,请跳过这一部分,稍后再来看!)是的,您可能会觉得以下描述有点令人困惑,但请耐心等待:我们马上就会详细介绍!
-
柯里化是将m元函数(即,具有m个参数的函数)转换为一系列m个一元函数的过程,每个函数接收原始函数的一个参数,从左到右。(第一个函数接收原始函数的第一个参数,第二个函数接收第二个参数,依此类推。)每次调用带有参数的函数时,都会产生序列中的下一个函数,最后一个函数执行实际的计算。
-
部分应用是提供n个参数给m元函数的想法,其中n小于或等于m,以将其转换为具有(m-n)个参数的函数。每次提供一些参数时,都会产生一个具有更小元数的新函数。当提供最后的参数时,将执行实际的计算。
-
部分柯里化是两种先前想法的混合体:您向m元函数提供n个参数(从左到右),并产生一个新的元函数(m-n)。当这个新函数接收到其他参数,同样是从左到右,它将产生另一个函数。当提供最后的参数时,函数将产生正确的计算结果。
在本章中,我们将看到这三种转换,它们需要什么,以及实现它们的方法。关于这一点,我们将探讨每个高阶函数的编码方式,这将为我们提供有关 JS 编码的一些有趣见解,您可能会发现对其他应用程序很有趣。
柯里化
我们已经在第一章的箭头函数部分和第三章的一个参数还是多个参数?部分中提到了柯里化,但让我们在这里更加彻底。柯里化是一种设备,它使您只能使用单变量函数,即使您需要多变量函数。
将多变量函数转换为一系列单变量函数的想法(或者更严格地说,将具有多个操作数的运算符减少为单操作数运算符的一系列应用)是由 Moses Schönfinkel 研究过的,有一些作者建议,不一定是开玩笑,柯里化更正确地被称为Schönfinkeling!
处理许多参数
柯里化的想法本身很简单。如果您需要一个带有三个参数的函数,而不是(使用箭头函数)像下面这样写:
const make3 = (a, b, c) => String(100 * a + 10 * b + c);
您可以有一系列具有单个参数的函数:
const make3curried = a => b => c => String(100 * a + 10 * b + c);
或者,您可能希望将它们视为嵌套函数:
const make3curried2 = function(a) {
return function(b) {
return function(c) {
return String(100 * a + 10 * b + c);
};
};
};
在使用上,每个函数的使用方式有一个重要的区别。虽然您可以像这样调用第一个函数,比如make3(1,2,4)
,但是对于第二个定义,这样是行不通的。让我们来看看为什么:make3curried()
是一个一元(单参数)函数,所以我们应该写make3curried(1)
...但是这会返回什么?根据上面的定义,这也会返回一个一元函数--那个函数也会返回一个一元函数!因此,要获得与三元函数相同的结果,正确的调用应该是make3curried(1)(2)(4)
!参见图 7.1:
图 7.1。普通函数和柯里化等价函数之间的区别。
仔细研究这一点--我们有第一个函数,当我们对其应用一个参数时,我们得到第二个函数。对它应用一个参数会产生第三个函数和最终的应用会产生期望的结果。这可以被视为在理论计算中不必要的练习,但实际上它带来了一些优势,因为您可以始终使用一元函数,即使您需要具有更多参数的函数。
由于存在柯里化转换,也存在反柯里化转换!在我们的例子中,我们会写make3uncurried = (a,b,c) => make3curried(a)(b)(c)
来恢复柯里化过程,并再次使用,一次性提供所有参数。
在某些语言中,比如 Haskell,函数只允许接受一个参数--但是语言的语法允许您调用函数,就好像允许多个参数一样。对于我们的例子,在 Haskell 中,写make3curried 1 2 4
会产生结果 124,甚至不需要有人意识到它涉及三个函数调用,每个函数都有一个参数。由于您不在参数周围写括号,并且不用逗号分隔它们,您无法知道您没有提供三个单一值而是三个值的三元组。
柯里化在 Scala 或 Haskell 中是基本的,这些都是完全功能的语言,但 JavaScript 有足够的功能来允许我们在工作中定义和使用柯里化。这不会那么容易--毕竟,它不是内置的--但我们将能够应对。
因此,回顾基本概念,我们原始的make3()
和make3curried()
之间的关键区别如下:
-
make3()
是一个三元函数,但make3curried()
是一元的 -
make3()
返回一个字符串;make3curried()
返回另一个函数--它本身返回第二个函数,然后返回第三个函数,最终返回一个字符串! -
您可以通过编写类似
make3(1,2,4)
的东西来生成一个字符串,它返回 124,但是您将不得不编写make3curried(1)(2)(4)
来获得相同的结果
为什么要费这么大的劲呢?让我们看一个简单的例子,然后我们将看到更多的例子。假设您有一个计算增值税(VAT)的函数:
const addVAT = (rate, amount) => amount * (1 + rate / 100);
addVAT(20, 500); // 600 -- *that is,* 500 + 20%
addVAT(15, 200); // 230 -- 200 +15%
如果您必须应用单一的恒定费率,那么您可以对addVAT()
函数进行柯里化,以生成一个更专业的版本,它总是应用您给定的费率。例如,如果您的国家税率是 6%,那么您可以有以下内容:
const addVATcurried = rate => amount => amount * (1 + rate / 100);
const addNationalVAT = addVATcurried(6);
addNationalVAT(1500); // 1590 -- 1500 + 6%
第一行定义了我们的增值税计算函数的柯里化版本。给定一个税率,addVATcurried()
返回一个新函数,当给定一定金额的钱时,最终将原始税率加到其中。因此,如果国家税率为 6%,那么addNationalVAT()
将是一个函数,它会给任何给定的金额增加 6%。例如,如果我们要计算addNationalVAT(1500)
,就像前面的代码一样,结果将是 1590:1500 美元,再加上 6%的税。
当然,你可能会认为这种柯里化对于只增加 6%的税来说有点过分,但简化才是最重要的。让我们看一个例子。在您的应用程序中,您可能希望包含一些日志记录,例如以下函数:
let myLog = (severity, logText) => {
// *display logText in an appropriate way,*
// *according to its severity ("NORMAL", "WARNING", or "ERROR")*
};
然而,采用这种方法,每次您想要显示一个正常的日志消息时,您将写myLog
("NORMAL"
, "一些正常文本"),而对于警告,您将写myLog
("WARNING"
, "一些警告")--但您可以通过柯里化简化一下,通过固定myLog()
的第一个参数,如下所示,使用我们稍后将看到的curry()
函数:
myLog = curry(myLog);
// *replace myLog by a curried version of itself*
const myNormalLog = myLog("NORMAL");
const myWarningLog = myLog("WARNING");
const myErrorLog = myLog("ERROR");
你得到了什么?现在你可以只写myNormalLog("一些正常文本")
或myWarningLog("一些警告")
,因为你已经对myLog()
进行了柯里化,然后固定了它的参数--这使得代码更简单,更易读!
顺便说一句,如果您愿意,您也可以通过逐个案例地对原始的非柯里化myLog()
函数进行柯里化来以单个步骤实现相同的结果:
const myNormalLog2 = curry(myLog)("NORMAL");
const myWarningLog2 = curry(myLog)("WARNING");
const myErrorLog2 = curry(myLog)("ERROR");
手动柯里化
如果我们只想为特殊情况实现柯里化,就没有必要做任何复杂的事情,因为我们可以使用简单的箭头函数来处理:我们看到了make3curried()
和addVATcurried()
都是如此,所以没有必要重新审视这个想法。
相反,让我们看一些自动执行这些操作的方法,这样我们将能够生成任何函数的等效柯里化版本,即使事先不知道它的 arity。更进一步,我们可能希望编写一个函数的更智能版本,它可以根据接收到的参数数量而有所不同。例如,我们可以有一个sum(x,y)
函数,它的行为如下例所示:
sum(3, 5); // 8; *did you expect otherwise?*
const add2 = sum(2);
add2(3); // 5
sum(2)(7); // 9 -- *as if it were curried*
我们可以手动实现这种行为。我们的函数将是以下内容:
const sum = (x, y) => {
if (x !== undefined && y !== undefined) {
return x + y;
} else if (x !== undefined && y == undefined) {
return z => sum(x, z);
} else {
return sum;
}
};
让我们回顾一下我们在这里做了什么。我们手动柯里化的函数有以下行为:
-
如果我们用两个参数调用它,它会将它们相加,并返回总和;这提供了我们的第一个用例,就像
sum(3,5)==8
一样。 -
如果只提供一个参数,它将返回一个新函数。这个新函数期望一个参数,并将返回该参数和原始参数的总和:这种行为是我们在其他两种用例中所期望的,比如
add2(3)==5
或sum(2)(7)==9
。 -
最后,如果没有提供参数,它将返回自身。这意味着我们可以写
sum()(1)(2)
如果我们愿意。(不,我想不出想要写那个的原因...)
因此,如果我们愿意,我们可以在函数的定义中直接包含柯里化。然而,您必须同意,必须在每个函数中处理所有特殊情况,这很容易变得麻烦,也容易出错。因此,让我们尝试找出一些更通用的方法来实现相同的结果,而不需要任何特定的编码。
使用 bind()进行柯里化
我们可以通过使用.bind()
方法找到柯里化的解决方案。这使我们能够固定一个参数(或更多,如果需要;我们现在不需要,但以后会用到),并提供具有固定参数的函数。当然,许多库(如 Lodash、Underscore、Ramda 等)提供了这种功能,但我们想看看如何自己实现。
在developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_objects/Function/bind
上阅读更多关于.bind()
的内容--这将很有用,因为我们将在本章中多次利用这个方法。
我们的实现非常简短,但需要一些解释:
const curryByBind = fn =>
fn.length === 0 ? fn() : p => curryByBind(fn.bind(null, p));
首先注意到curry()
总是返回一个新函数,该函数取决于作为其参数给定的函数fn
。如果函数没有(更多)剩余参数(当fn.length===0
时),因为所有参数已经被固定,我们可以通过执行fn()
来简单评估它。否则,柯里化函数的结果将是一个新函数,它接收一个参数,并产生一个新的柯里化函数,其中另一个参数被固定。让我们通过一个详细的例子来看看这个过程,再次使用我们在本章开头看到的make3()
函数:
const make3 = (a, b, c) => String(100 * a + 10 * b + c);
const f1 = curryByBind(make3); // *f1 is a function, that will fix make3's 1st parameter*
const f2 = f1(6); // *f2 is a function, that will fix make3's 2nd parameter*
const f3 = f2(5); // *f3 is a function, that will fix make3's last parameter*
const f4 = f3(8); // *"658" is calculated, since there are no more parameters to fix*
这段代码的解释如下:
-
第一个函数
f1()
还没有接收任何参数。它的结果是一个单参数函数,它本身将产生make3()
的柯里化版本,其第一个参数固定为给定的值。 -
调用
f1(6)
会产生一个新的一元函数f2()
,它本身将产生make3()
的柯里化版本--但其第一个参数设置为6
,因此实际上新函数将结束固定make3()
的第二个参数。 -
类似地,调用
f2(5)
会产生第三个一元函数f3()
,它将产生make3()
的一个版本,但固定其第三个参数,因为前两个参数已经被固定。 -
最后,当我们计算
f3(8)
时,这将把make3()
的最后一个参数固定为8
,并且由于没有更多的参数了,三次绑定的make3()
函数被调用,产生结果"658"
。
如果您想手动进行函数柯里化,可以使用 JavaScript 的.bind()
方法。顺序如下:
const step1 = make3.bind(null, 6);
const step2 = step1.bind(null, 5);
const step3 = step2.bind(null, 8);
step3(); // *"658"*
在每一步中,我们提供一个进一步的参数。(需要null
值来提供上下文。如果它是附加到对象的方法,我们将该对象作为.bind()
的第一个参数提供。由于这不是这种情况,所以期望是null
。)这相当于我们的代码所做的事情,唯一的例外是最后一次,curryByBind()
执行实际计算,而不是让您自己来做,就像step3()
中一样。
测试这个转换相当简单--因为柯里化的可能方式并不多!
const make3 = (a, b, c) => String(100 * a + 10 * b + c);
describe("with curryByBind", function() {
it("you fix arguments one by one", () => {
const make3a = curryByBind(make3);
const make3b = make3a(1)(2);
const make3c = make3b(3);
expect(make3c).toBe(make3(1, 2, 3));
});
});
还有什么可以测试的吗?也许可以添加只有一个参数的函数,但没有更多可以尝试的了。
如果我们想对具有可变参数数量的函数进行柯里化,那么使用fn.length
是行不通的;它只对具有固定参数数量的函数有值。我们可以通过提供所需的参数数量来简单解决这个问题:
const curryByBind2 = (fn, len = fn.length) =>
len === 0 ? fn() : p => curryByBind2(fn.bind(null, p), len - 1);
const sum2 = (...args) => args.reduce((x, y) => x + y, 0);
sum2.length; // *0;* *curryByBind() wouldn't work*
sum2(1, 5, 3); // 9
sum2(1, 5, 3, 7); // 16
sum2(1, 5, 3, 7, 4); // 20
curriedSum5 = curryByBind2(sum2, 5); // *curriedSum5 will expect 5 parameters*
curriedSum5(1)(5)(3)(7)(4); // *20*
新的curryByBind2()
函数与以前的工作方式相同,但是不再依赖于fn.length
,而是使用len
参数,该参数默认为fn.length
,用于具有恒定参数数量的标准函数。请注意,当len
不为 0 时,返回的函数调用curry2()
,并将len-1
作为其最后一个参数--这是有道理的,因为如果一个参数刚刚被固定,那么剩下要固定的参数就会少一个。
在我们的例子中,sum()
函数可以处理任意数量的参数,JavaScript 告诉我们sum.length
为零。然而,当对函数进行柯里化时,如果我们将len
设置为5
,柯里化将被视为sum()
是一个五参数函数--代码中列出的最后一行显示这确实是这种情况。
与之前一样,测试是相当简单的,因为我们没有要尝试的变体:
const sum2 = (...args) => args.reduce((x, y) => x + y, 0);
describe("with curryByBind2", function() {
it("you fix arguments one by one", () => {
const suma = curryByBind2(sum2, 5);
const sumb = suma(1)(2)(3)(4)(5);
expect(sumb).toBe(sum(1, 2, 3, 4, 5));
});
it("you can also work with arity 1", () => {
const suma = curryByBind2(sum2, 1);
const sumb = suma(111);
expect(sumb).toBe(sum(111));
});
});
我们测试了将柯里化函数的 arity 设置为 1,作为边界情况,但没有更多的可能性。
使用 eval()进行柯里化
还有一种有趣的柯里化函数的方法,通过使用eval()
创建一个新的函数... 是的,那个不安全的、危险的eval()
!(记住我们之前说过的:这是为了学习目的,但最好避免eval()
可能带来的潜在安全问题!)我们还将使用我们在第五章的使用范围部分编写的range()
函数,声明式编程-更好的风格。
像 LISP 这样的语言一直都有生成和执行 LISP 代码的可能性。JavaScript 也共享了这一功能,但并不经常使用--主要是因为可能带来的危险!然而,在我们的情况下,由于我们想要生成新的函数,利用这种被忽视的能力似乎是合乎逻辑的。
这个想法很简单:在本章的一点理论部分中,我们看到我们可以通过使用箭头函数轻松地柯里化一个函数:
const make3 = (a, b, c) => String(100 * a + 10 * b + c);
const make3curried = a => b => c => String(100 * a + 10 * b + c);
让我们对第二个版本进行一些更改,以便以后能更好地帮助我们:
const make3curried = x1 => x2 => x3 => make3(x1, x2, x3);
生成等效版本所需的代码如下。我们将使用我们在第五章的使用范围部分编写的range()
函数,以避免需要编写显式循环:
const range = (start, stop) =>
new Array(stop - start).fill(0).map((v, i) => start + i);
const curryByEval = (fn, len = fn.length) =>
eval(`**${range(0, len).map(i => `x${i}`).join("=>")}** **=>
${fn.name}(${range(0, len).map(i => `x${i}`).join(",")})**`);
这是相当多的代码需要消化,实际上,它应该被编码成几行分开来更容易理解。让我们以make3()
函数作为输入来跟随它:
-
range()
函数生成一个值为[0,1,2]
的数组。如果我们不提供len
参数,将使用make3.length
(即 3)。 -
我们使用
.map()
生成一个包含值["x0","x1","x2"]
的新数组。 -
我们使用
join()
将该数组中的值连接起来,生成x0=>x1=>x2
,这将是我们将要eval()
的代码的开头。 -
然后我们添加一个箭头,函数的名称和一个开括号,以使我们新生成的代码的中间部分:
=> make3(
。 -
我们再次使用
range()
、map()
和join()
,但这次是为了生成参数列表:x0,x1,x2
。 -
最后我们添加一个闭括号,并在应用
eval()
之后,我们得到了make3()
的柯里化版本:
curryByEval(make3); // x0=>x1=>x2=> make3(x0,x1,x2)
只有一个问题:如果原始函数没有名称,转换就无法进行。(有关更多信息,请查看第三章的关于 Lambda 和函数部分,从函数开始-核心概念。)我们可以通过包含要柯里化的函数的实际代码来解决函数名称问题:
const curryByEval2 = (fn, len = fn.length) =>
eval(`${range(0, len).map(i => `x${i}`).join("=>")} =>
**(${fn.toString()})**(${range(0, len).map(i => `x${i}`).join(",")})`);
唯一的变化是,我们用实际的代码替换原始函数名:
curryByEval2(make3); // x0=>x1=>x2=> ((a,b,c) => 100*a+10*b+c)(x0,x1,x2)
生成的函数令人惊讶,有一个完整的函数后跟其参数--但这实际上是有效的 JavaScript!所有以下都会产生相同的结果:
const add = (x, y) => x + y;
add(2, 5); // 7
((x, y) => x + y)(2, 5); // *7*
当你想调用一个函数时,你写下它,并在括号内跟上它的参数--这就是我们正在做的,即使看起来有点奇怪!
部分应用
我们将要考虑的第二个转换允许你固定函数的一些参数,创建一个接收其余参数的新函数。让我们通过一个无意义的例子来澄清这一点。想象一下,你有一个有五个参数的函数。你可能想要固定第二个和第五个参数,部分应用将产生一个新版本的函数,固定这两个参数,但为新的调用留下其他三个。如果你用这三个必需的参数调用结果函数,它将使用原始的两个固定参数加上新提供的三个参数产生正确的答案。
在函数应用中只指定一些参数,生成剩余参数的函数的想法被称为投影:你被认为是投影函数到剩余的参数上。我们不会使用这个术语,但我们想引用一下,以防你在其他地方找到它。
让我们考虑一个例子,使用被广泛认为是现代 Ajax 调用的fetch()
API。你可能想要获取多个资源,总是指定调用的相同参数(例如请求头),只改变搜索的 URL。因此,通过部分应用,你可以创建一个新的myFetch()
函数,它总是提供固定的参数。假设我们有一个实现这种应用的partial()
函数,看看我们如何使用它。
你可以在developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
上了解更多关于fetch()
的信息。根据caniuse.com/#search=fetch
的信息,你可以在大多数浏览器中使用它,除了(哦,惊讶!)Internet Explorer...但你可以通过 polyfill 绕过这个限制,比如在github.com/github/fetch
找到的 polyfill:
const myParameters = {
method: "GET",
headers: new Headers(),
cache: "default"
};
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()
的第一个参数,柯里化就会起作用。(我们稍后会详细讨论参数的顺序。)通过部分应用,你可以替换任何参数,所以在这种情况下,myFetch()
最终成为一个一元函数。这个新函数将从任何你希望的 URL 获取数据,始终传递相同的参数集合进行GET
操作。
箭头函数的部分应用
手动进行部分应用,就像我们用柯里化一样,太复杂了,因为对于一个有五个参数的函数,你需要编写代码,允许用户提供 32 种可能的固定和未固定参数的组合(32 等于 5 的 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)
,但事实仍然是,使用箭头函数固定参数是简单的。现在让我们考虑一个更一般的解决方案。
使用 eval()进行部分应用
如果我们想要能够部分应用固定任意组合的参数,我们必须有一种方法来指定哪些参数将被保留,哪些将从那一点开始被固定。一些库,比如 Underscore 或 LoDash,使用一个特殊对象 _
来表示省略的参数。以这种方式,仍然使用相同的 nonsense()
函数,我们将编写以下内容:
const fix2and5 = _.partial(nonsense, _, 22, _, _, 1960);
我们可以通过使用一个全局变量来表示一个待处理的、尚未固定的参数来做同样的事情,但让我们简化一下,只需写 undefined
来表示缺少的参数。
在检查未定义时,记得始终使用 ===
运算符;使用 ==
会导致 null==undefined
,你不希望出现这种情况。请参阅 developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/undefined
了解更多信息。
我们想要编写一个函数,部分应用一些参数,并将其余部分留给未来。我们想要编写类似以下的代码,并以与我们之前使用箭头函数相同的方式生成一个新函数:
const nonsense = (a, b, c, d, e) => `${a}/${b}/${c}/${d}/${e}`;
const fix2and5 = partialByEval(
nonsense,
undefined,
22,
undefined,
undefined,
1960
);
// *fix2and5 would become* (X0, X2, X3) => nonsense(X0, 22, X2, X3, 1960);
我们可以回到使用 eval()
,并想出类似以下的东西:
const range = (start, stop) =>
new Array(stop - start).fill(0).map((v, i) => start + i);
const partialByEval = (fn, ...args) => {
const rangeArgs = range(0, fn.length);
const leftList = rangeArgs
.map(v => (args[v] === undefined ? `x${v}` : null))
.filter(v => !!v)
.join(",");
const rightList = rangeArgs
.map(v => (args[v] === undefined ? `x${v}` : args[v]))
.join(",");
return eval(`(${leftList}) => ${fn.name}(${rightList})`);
};
让我们一步一步地分解这个函数。我们再次使用我们的 range()
函数:
-
rangeArgs
是一个包含从零到输入函数的参数数量(不包括)的数字的数组。 -
leftList
是一个字符串,表示未应用变量的列表。在我们的例子中,它将是"X0,X2,X3"
,因为我们为第二个和第五个参数提供了值。这个字符串将用于生成箭头函数的左部分。 -
rightList
是一个字符串,表示调用提供的函数的参数列表。在我们的例子中,它将是"X0,'Z',X2,X3,1960"
。我们将使用这个字符串来生成箭头函数的右部分。
在生成了两个列表之后,代码的剩余部分只是生成适当的字符串,并将其传递给 eval()
以获得一个函数。
如果我们对具有可变数量参数的函数进行部分应用,我们可以用 args.length
替换 fn.length
,或者提供一个额外的(可选的)参数来指定要使用的数量,就像我们在本章的柯里化部分所做的那样。
顺便说一句,我故意用这种冗长的方式来表达这个函数,以使其更清晰。(我们之前已经看到了类似的,虽然更短的代码,当我们使用 eval()
进行柯里化时。)然而,请注意,你可能会找到一个更短、更紧凑和更难理解的版本……这就是给函数式编程带来不好名声的代码!
const partialByEval2 = (fn, ...args) =>
eval(
`(${range(0, fn.length)
.map(v => (args[v] === undefined ? `x${v}` : null))
.filter(v => !!v)
.join(",")}) => ${fn.name}(${range(0, fn.length)
.map(v => (args[v] == undefined ? `x${v}` : args[v]))
.join(",")})`
);
让我们通过编写一些测试来结束这一部分。我们应该考虑一些什么事情?
-
当我们进行部分应用时,生成的函数的参数个数应该减少。
-
当参数按正确顺序传入时,应该调用原始函数。
我们可以编写类似以下的代码,允许在不同位置固定参数。我们可以直接使用 nonsense()
函数,而不是使用间谍或模拟,因为它非常高效:
const nonsense = (a, b, c, d, e) => `${a}/${b}/${c}/${d}/${e}`;
describe("with partialByEval()", function() {
it("you could fix no arguments", () => {
const nonsensePC0 = partialByEval(nonsense);
expect(nonsensePC0.length).toBe(5);
expect(nonsensePC0(0, 1, 2, 3, 4)).toBe(nonsense(0, 1, 2, 3, 4));
});
it("you could fix only some initial arguments", () => {
const nonsensePC1 = partialByEval(nonsense, 1, 2, 3);
expect(nonsensePC1.length).toBe(2);
expect(nonsensePC1(4, 5)).toBe(nonsense(1, 2, 3, 4, 5));
});
it("you could skip some arguments", () => {
const nonsensePC2 = partialByEval(
nonsense,
undefined,
22,
undefined,
44
);
expect(nonsensePC2.length).toBe(3);
expect(nonsensePC2(11, 33, 55)).toBe(nonsense(11, 22, 33, 44, 55));
});
it("you could fix only some last arguments", () => {
const nonsensePC3 = partialByEval(
nonsense,
undefined,
undefined,
undefined,
444,
555
);
expect(nonsensePC3.length).toBe(3);
expect(nonsensePC3(111, 222, 333)).toBe(
nonsense(111, 222, 333, 444, 555)
);
});
it("you could fix ALL the arguments", () => {
const nonsensePC4 = partialByEval(nonsense, 6, 7, 8, 9, 0);
expect(nonsensePC4.length).toBe(0);
expect(nonsensePC4()).toBe(nonsense(6, 7, 8, 9, 0));
});
});
我们编写了一个部分应用的高阶函数,但它并不像我们希望的那样灵活。例如,我们可以在第一次调用中固定一些参数,但然后我们必须在下一次调用中提供所有其余的参数。如果在调用 partialByEval()
后,我们得到一个新函数,并且如果我们没有提供所有需要的参数,我们将得到另一个函数,以此类推,直到所有参数都被提供——这与柯里化的情况有些类似。因此,让我们改变部分应用的方式,并考虑另一个解决方案。
使用闭包进行部分应用
让我们再看一种进行部分应用的方式,它的行为方式有点像我们在本章前面写的curry()
函数,并解决了我们在上一节末尾提到的不足:
const partialByClosure = (fn, ...args) => {
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
: fn)(...allParams);
};
return partialize(...args);
};
哇,一大段代码!关键在于内部函数partialize()
。给定一个参数列表(args1
),它生成一个接收第二个参数列表(args2
)的函数:
-
首先,它用
args2
中的值替换args1
中所有可能的未定义值。 -
然后,如果
args2
中还有任何参数,它也会将它们附加到args1
的参数中,生成allParams
。 -
最后,如果参数列表中不再包含任何未定义值,并且足够长,它就会调用原始函数。
-
否则,它会部分化自身,等待更多的参数。
举个例子会更清楚。让我们回到我们可靠的make3()
函数,并构建它的一个部分版本:
const make3 = (a, b, c) => String(100 * a + 10 * b + c);
const f1 = partialByClosure(make3, undefined, 4);
现在我们写一个第二个函数:
const f2 = f1(7);
发生了什么?原始参数列表([undefined, 4]
)与新列表(在这种情况下是一个单一元素,[7]
)合并,生成一个现在接收7
和4
作为它的前两个参数的函数。然而,这还不够,因为原始函数需要三个参数。如果我们现在写:
const f3 = f2(9);
然后,当前的参数列表将与新参数合并,生成[7,4,9]
。由于列表现在是完整的,原始函数将被评估,产生749
作为最终结果。
这段代码的结构与我们之前在使用bind()
进行柯里化部分写的另一个高阶函数有重要的相似之处。
-
如果所有参数都已经提供,原始函数就会被调用。
-
如果还需要一些参数(在柯里化时,只是简单地计算参数的数量;在进行部分应用时,你还必须考虑可能存在一些未定义的参数),那么高阶函数会调用自身来生成函数的新版本,这个新版本将等待缺失的参数。
让我们最后写一些测试,展示我们新的部分应用方式的增强。基本上,我们之前做的所有测试都会生效,但我们还必须尝试按顺序应用参数,这样我们应该在两个或更多步骤的应用之后得到最终结果。然而,由于我们现在可以用任意数量的参数调用我们的中间函数,我们无法测试参数个数:对于所有函数,function.length===0
:
describe("with partialByClosure()", function() {
it("you could fix no arguments", () => {
const nonsensePC0 = partialByClosure(nonsense);
expect(nonsensePC0(0, 1, 2, 3, 4)).toBe(nonsense(0, 1, 2, 3, 4));
});
it("you could fix only some initial arguments, and then some more", () => {
const nonsensePC1 = partialByClosure(nonsense, 1, 2, 3);
const nonsensePC1b = nonsensePC1(undefined, 5);
expect(nonsensePC1b(4)).toBe(nonsense(1, 2, 3, 4, 5));
});
it("you could skip some arguments", () => {
const nonsensePC2 = partialByClosure(
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 = partialByClosure(
nonsense,
undefined,
undefined,
undefined,
444,
555
);
expect(nonsensePC3(111)(222, 333)).toBe(
nonsense(111, 222, 333, 444, 555)
);
});
it("you could simulate currying", () => {
const nonsensePC4 = partialByClosure(nonsense);
expect(nonsensePC4(6)(7)(8)(9)(0)).toBe(nonsense(6, 7, 8, 9, 0));
});
it("you could fix ALL the arguments", () => {
const nonsensePC5 = partialByClosure(nonsense, 16, 17, 18, 19, 20);
expect(nonsensePC5()).toBe(nonsense(16, 17, 18, 19, 20));
});
});
代码比以前长了,但测试本身很容易理解。倒数第二个测试应该会让你想起柯里化!
部分柯里化
最后一个我们将看到的转换是柯里化和部分应用的混合。如果你在网上搜索一下,在一些地方你会发现它被称为柯里化,在其他地方被称为部分应用,但事实上,它都不太符合……所以我还在犹豫不决,称它为部分柯里化!
这个想法是,给定一个函数,固定它的前几个参数,并生成一个新的函数来接收其余的参数。然而,如果给这个新函数传递的参数较少,它将固定它所接收到的参数,并生成一个新的函数来接收其余的参数,直到所有参数都被给出并且最终结果可以被计算出来。参见图 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。当我们部分柯里化该函数,并给它参数 9 和 22 时,它变成了一个三元函数,因为在原始的五个参数中,有两个已经固定。如果我们拿到这个三元函数并给它一个参数(60),结果就是另一个函数:在这种情况下,是一个二元函数,因为现在我们已经固定了原始五个参数中的前三个。最后一次调用,提供最后两个参数,然后执行实际计算所需的结果。
柯里化和部分应用有一些共同点,但也有一些不同之处:
-
原始函数被转换为一系列函数,每个函数产生下一个函数,直到系列中的最后一个实际执行其计算。
-
您始终从第一个参数(最左边的参数)开始提供参数,就像柯里化一样,但您可以像部分应用一样提供多个参数。
-
在柯里化函数时,所有中间函数都是一元的,但部分柯里化则不需要如此。然而,如果在每个实例中我们提供一个参数,那么结果将需要与普通柯里化一样多的步骤。
所以,我们有了我们的定义--现在让我们看看如何实现我们的新高阶函数;我们可能会在本章的这一部分中重复使用前几节中的一些概念。
使用 bind()进行部分柯里化
与我们对柯里化所做的类似,有一种简单的方法可以进行部分柯里化。我们将利用.bind()
实际上可以一次固定多个参数的事实:
const partialCurryingByBind = fn =>
fn.length === 0
? fn()
: (...pp) => partialCurryingByBind(**fn.bind(null, ...pp)**);
将代码与之前的curryByBind()
函数进行比较,您会看到非常小的差异:
const curryByBind = fn =>
fn.length === 0
? fn()
: p => curryByBind(fn.bind(null, p));
机制完全相同。唯一的区别是在我们的新函数中,我们可以同时绑定多个参数,而在curryByBind()
中我们总是只绑定一个。我们可以重新访问我们之前的例子--唯一的区别是我们可以在更少的步骤中得到最终结果:
const make3 = (a, b, c) => String(100 * a + 10 * b + c);
const f1 = partialCurryingByBind(make3);
const f2 = f1(6, 5); // *f2 is a function, that fixes make3's first two arguments*
const f3 = f2(8); // *"658" is calculated, since there are no more parameters to fix*
顺便说一句,只要意识到现有的可能性,您可以在柯里化时固定一些参数:
const g1 = partialCurryingByBind(make3)(8, 7);
const g2 = g1(6); // "876"
测试这个函数很容易,我们提供的例子是一个很好的起点。但是,请注意,由于我们允许固定任意数量的参数,我们无法测试中间函数的参数个数:
const make3 = (a, b, c) => String(100 * a + 10 * b + c);
describe("with partialCurryingByBind", function() {
it("you could fix arguments in several steps", () => {
const make3a = partialCurryingByBind(make3);
const make3b = make3a(1, 2);
const make3c = make3b(3);
expect(make3c).toBe(make3(1, 2, 3));
});
it("you could fix arguments in a single step", () => {
const make3a = partialCurryingByBind(make3);
const make3b = make3a(10, 11, 12);
expect(make3b).toBe(make3(10, 11, 12));
});
it("you could fix ALL the arguments", () => {
const make3all = partialCurryingByBind(make3);
expect(make3all(20, 21, 22)).toBe(make3(20, 21, 22));
});
it("you could fix one argument at a time", () => {
const make3one = partialCurryingByBind(make3)(30)(31)(32);
expect(make3one).toBe(make3(30, 31, 32));
});
});
现在,让我们考虑具有可变参数数量的函数。与以前一样,我们将不得不提供额外的值:
const partialCurryingByBind2 = (fn, len = fn.length) =>
len === 0
? fn()
: (...pp) =>
partialCurryingByBind2(
fn.bind(null, ...pp),
len - pp.length
);
我们可以以一种简单的方式尝试这一点,重新访问一些页面前的柯里化示例:
const sum = (...args) => args.reduce((x, y) => x + y, 0);
pcSum5 = partialCurryingByBind2(sum2, 5); // curriedSum5 will expect 5 parameters
pcSum5(1, 5)(3)(7, 4); // 20
新的pcSum5()
函数首先收集了两个参数(1,5),并产生了一个期望另外三个参数的新函数。给定了一个单一参数(3),并创建了第三个函数,等待最后两个参数。当提供了这两个参数(7,4)时,原始函数被调用,计算结果为(20)。
我们还可以为这种替代的部分柯里化添加一些测试:
const sum2 = (...args) => args.reduce((x, y) => x + y, 0);
describe("with partialCurryingByBind2", function() {
it("you could fix arguments in several steps", () => {
const suma = partialCurryingByBind2(sum2, 3);
const sumb = suma(1, 2);
const sumc = sumb(3);
expect(sumc).toBe(sum2(1, 2, 3));
});
it("you could fix arguments in a single step", () => {
const suma = partialCurryingByBind2(sum2, 4);
const sumb = suma(10, 11, 12, 13);
expect(sumb).toBe(sum(10, 11, 12, 13));
});
it("you could fix ALL the arguments", () => {
const sumall = partialCurryingByBind2(sum2, 5);
expect(sumall(20, 21, 22, 23, 24)).toBe(sum2(20, 21, 22, 23, 24));
});
it("you could fix one argument at a time", () => {
const sumone = partialCurryingByBind2(sum2, 6)(30)(31)(32)(33)(34)(
35
);
expect(sumone).toBe(sum2(30, 31, 32, 33, 34, 35));
});
});
尝试不同的参数个数比坚持只使用一个更好,所以我们为了多样性而这样做了。
使用闭包进行部分柯里化
与部分应用一样,有一种使用闭包的解决方案:
const partialCurryByClosure = fn => {
const curryize = (...args1) => (...args2) => {
const allParams = [...args1, ...args2];
return (allParams.length < func.length ? curryize : fn)(
...allParams
);
};
return curryize();
};
如果您比较partialCurryByClosure()
和partialByClosure()
,主要区别在于部分柯里化,因为我们总是从左边提供参数,没有办法跳过一些参数,您将之前的任何参数与新参数连接起来,并检查是否已经足够。如果新的参数列表达到了原始函数的预期参数个数,您可以调用它,并得到最终结果。在其他情况下,您只需使用curryize()
来获得一个新的中间函数,等待更多的参数。
与以前一样,如果您必须处理具有不同数量参数的函数,您可以为部分柯里化函数提供额外的参数:
const partialCurryByClosure2 = (fn, len = fn.length) => {
const curryize = (...args1) => (...args2) => {
const allParams = [...args1, ...args2];
return (allParams.length < len ? curryize : fn)(...allParams);
};
return curried();
};
结果与上一节的通过 bind 进行部分柯里化完全相同,因此不值得重复。您还可以轻松地更改我们编写的测试,使用partialCurryByClosure()
而不是partialCurryByBind()
,它们也可以正常工作。
最后的想法
让我们以两个更多的关于柯里化和部分应用的哲学考虑来结束这一章,这可能会引起一些讨论:
-
首先,许多库在参数顺序上都是错误的,使它们更难使用
-
其次,我通常甚至不使用本章中的高阶函数,而是使用更简单的 JS 代码!
这可能不是您此时所期望的,所以让我们更详细地讨论这两点,这样您就会看到这不是我说什么,我做什么或库所做的的问题!
参数顺序
不仅如此,这个问题不仅存在于 Underscore 或 LoDash 的_.map(list, mappingFunction)
或_.reduce(list, reducingFunction, initialValue)
等函数中,还存在于我们在本书中生成的一些函数中,比如demethodize()
的结果。 (请参阅第六章的Demethodizing: turning methods into functions部分,以回顾高阶函数。)问题在于它们的参数顺序并不能真正帮助柯里化。
在柯里化函数时,您可能希望存储中间结果。当我们像下面的代码一样做某事时,我们假设您将重用带有固定参数的柯里化函数,这意味着原始函数的第一个参数最不可能改变。现在让我们考虑一个具体的情况。回答这个问题:更可能的是——您将使用map()
将相同的函数应用于几个不同的数组,还是将几个不同的函数应用于相同的数组?对于验证或转换,前者更有可能……但这并不是我们得到的结果!
我们可以编写一个简单的函数来翻转二元函数的参数:
const flipTwo = fn => (p1, p2) => fn(p2, p1);
请注意,即使原始的fn()
函数可以接收更多或更少的参数,但在将flipTwo()
应用于它之后,结果函数的 arity 将固定为 2。我们将在接下来的部分中利用这一事实。
有了这个,您可以按照以下方式编写代码:
const myMap = curry(flipTwo(demethodize(map)));
const makeString = v => String(v);
const stringify = myMap(makeString);
let x = stringify(anArray);
let y = stringify(anotherArray);
let z = stringify(yetAnotherArray);
最常见的使用情况是您希望将函数应用于几个不同的列表,无论是库函数还是我们自己的demethodized函数都无法提供这种功能。然而,通过使用flipTwo()
,我们可以按照我们希望的方式工作。
在这种特殊情况下,我们可能已经通过使用部分应用来解决了我们的问题,而不是柯里化,因为这样我们就可以固定map()
的第二个参数而不需要进一步的麻烦。然而,翻转参数以产生具有不同参数顺序的新函数也是一种经常使用的技术,我认为你应该意识到这一点很重要。
对于像.reduce()
这样通常接收三个参数(列表、函数和初始值)的情况,我们可以选择这样做:
const flip3 = fn => (p1, p2, p3) => fn(p2, p3, p1);
const myReduce = partialCurry(flip3(demethodize(reduce)));
const sum = (x, y) => x + y;
const sumAll = myReduce(sum, 0);
sumAll(anArray);
sumAll(anotherArray);
我使用了部分柯里化,简化了sumAll()
的表达式。另一种选择是使用常规柯里化,然后我会定义sumAll = myReduce(sum)(0)
。
如果您愿意,您也可以选择更神秘的参数重新排列函数,但通常您不需要更多的这两种。对于真正复杂的情况,您可能更愿意使用箭头函数(就像我们在定义flipTwo()
和flip3()
时所做的那样),并明确说明您需要哪种重新排序。
功能性
现在我们接近本章的结束,有一个坦白的话要说:我并不总是像上面所示的那样使用柯里化和部分应用!不要误会我,我确实应用这些技术 -- 但有时它会导致更长、不太清晰、不一定更好的代码。让我向您展示我在说什么。
如果我正在编写自己的函数,然后想要对其进行柯里化以固定第一个参数,与箭头函数相比,柯里化(或部分应用,或部分柯里化)并不真的有什么区别。我将不得不编写以下内容:
const myFunction = (a, b, c) => { ... };
const myCurriedFunction = curry(myFunction)(fixed_first_argument);
// *and later in the code...*
myCurriedFunction(second_argument)(third_argument);
柯里化函数,并在同一行给它一个第一个参数,可能被认为不太清晰;另一种调用需要一个额外的变量和一行代码。稍后,未来的调用也不太好;然而,部分柯里化使它更简单:myPartiallyCurriedFunction(second_argument, third_argument)
。无论如何,当我将最终代码与箭头函数的使用进行比较时,我认为其他解决方案并不真的更好:
const myFunction = (a, b, c) => { ... };
const myFixedFirst = (b, c) => myFunction(fixed_first_argument, b, c);
// *and later...*
myFixedFirst(second_argument, third_argument);
我认为柯里化和部分应用非常好的地方在于我的小型库中的去方法化、预柯里化的基本高阶函数。我有自己的一组函数,如下所示:
const _plainMap = demethodize(map);
const myMap = curry(_plainMap, 2);
const myMapX = curry(flipTwo(_plainMap));
const _plainReduce = demethodize(reduce);
const myReduce = curry(_plainReduce, 3);
const myReduceX = curry(flip3(_plainReduce));
const _plainFilter = demethodize(filter);
const myFilter = curry(_plainFilter, 2);
const myFilterX = curry(flipTwo(_plainFilter));
// *...and more functions in the same vein*
以下是有关代码的一些要点:
-
我将这些函数放在一个单独的模块中,并且只导出
myXXX()
命名的函数。 -
其他函数是私有的,我使用前导下划线来提醒我这一点。
-
我使用
my...
前缀来记住这些是我的函数,而不是正常的 JavaScript 函数。有些人可能更愿意保留标准名称,如map()
或filter()
,但我更喜欢不同的名称。 -
由于大多数 JavaScript 方法具有可变的 arity,我在进行柯里化时必须指定它。
-
我总是为
.reduce()
提供第三个参数(用于减少的初始值),因此我为该函数选择的 arity 是三。 -
当对翻转函数进行柯里化时,您不需要指定参数的数量,因为翻转已经为您做到了。
最终,这完全取决于个人决定;尝试本章中所见的技术,并看看您更喜欢哪些!
问题
7.1. 随心所欲地求和。以下练习将帮助您理解我们上面讨论的一些概念,即使您在不使用我们在本章中看到的任何函数的情况下解决它。编写一个sumMany()
函数,让您以以下方式对不定数量的数字求和。请注意,当不带参数调用该函数时,将返回总和:
let result = sumMany((9)(2)(3)(1)(4)(3)());
// *22*
7.2. 时尚工作。编写一个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.3. 原型柯里化。修改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.4. 取消柯里化。编写一个函数unCurry(fn,arity)
,它接收一个(柯里化的)函数和其预期的 arity 作为参数,并返回fn()
的一个非柯里化版本;也就是说,一个将接收n个参数并产生结果的函数。(提供预期的 arity 是必要的,因为您无法自行确定它。)
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
总结
在本章中,我们考虑了一种新的生成函数的方式,即通过多种不同的方式固定现有函数的参数:柯里化,一种理论方式;部分应用,更灵活;以及部分柯里化,结合了前两种方法的优点。使用这些转换,您可以简化编码,因为您可以生成更专门的通用函数版本,而无需任何麻烦。
在第八章中,连接函数 - 管道和组合,我们将回顾一些我们在纯函数章节中看到的概念,并考虑确保函数不会因为意外变得不纯的方法,通过寻找使它们的参数不可变的方式,使它们不可能被改变。
第八章:连接函数-管道和组合
在第七章中,转换函数-柯里化和部分应用,我们看到了通过应用高阶函数构建新函数的几种不同方式。在本章中,我们将深入 FP 的核心,看看如何创建函数调用序列,以便它们的组合将从几个更简单的组件中产生更复杂的结果。我们将包括以下内容:
-
管道,一种类似于 Unix/Linux 管道的函数连接方式
-
链接,这可能被认为是管道的一种变体,但限于对象
-
组合,这是一种经典操作,起源于基本的计算机理论
在这个过程中,我们将涉及相关概念,例如以下内容:
-
无点风格,通常与管道和组合一起使用
-
组合或管道函数的调试,我们将编写一些辅助工具
-
组合或管道函数的测试,这不会被证明是高复杂度的
管道
管道和组合是一种设置函数按顺序工作的技术,因此一个函数的输出成为下一个函数的输入。有两种看待这个问题的方式:从计算机的角度和从数学的角度。通常,大多数 FP 文本都从后者开始,但由于我假设大多数读者更接近计算机而不是数学,让我们从前者开始。
Unix/Linux 中的管道
在 Unix/Linux 中,执行一个命令并将其输出作为第二个命令的输入,其输出将作为第三个命令的输入,依此类推,称为管道。这是相当常见的,也是 Unix 哲学的应用,正如贝尔实验室的一篇文章所解释的,这篇文章是由管道概念的创造者 Doug 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
,它计算其输入中有多少行
您可以在 Dennis Ritchie 和 Ken Thompson 的UNIX 分时系统文章的第 6.2 节过滤器中找到管道,这也是我上面提到的贝尔实验室期刊的一部分。
从 FP 的角度来看,这是一个关键概念。我们希望通过简单、单一用途、较短的函数来构建更复杂的操作。管道是 Unix shell 用来应用这个概念的方式,简化了执行命令、获取其输出,并将其作为输入传递给另一个命令的工作。我们将在 JS 中以我们自己的函数式风格应用类似的概念,正如我们将看到的;请查看图 8.1:
图 8.1. JS 中的管道与 Unix/Linux 中的管道类似。每个函数的输出都成为下一个函数的输入。
顺便说一句(不,放心,这不会变成一个 shell 教程!)你也可以使管道接受参数。例如,如果我经常想要计算我有多少个带有这种或那种扩展名的文件,我可以创建一个名为cfe
的函数,代表计算扩展名的数量:
$ function cfe() {
ls -1 | grep "$1\$"| wc -l
}
然后我可以使用cfe
作为一个命令,将所需的扩展名作为参数传递:
$ cfe odt
***4***
$ cfe pdf
***6***
我们还希望编写类似的参数化管道:我们不仅受限于在我们的流程中只有固定的函数,而是完全自由地决定要包含什么。
重新审视一个例子
我们可以通过重新审视早期章节中的一个问题来开始将各个部分联系在一起。还记得之前需要计算一些地理数据的平均纬度和经度吗?我们在第五章的从对象中提取数据部分中看到了这个问题,声明式编程 - 更好的风格?基本上,我们从以下数据开始,问题是要计算给定点的平均纬度和经度:
let 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}
];
有了我们现在所知道的,我们可以用以下方式来编写一个解决方案:
-
能够从每个点中提取纬度(以及之后的经度)
-
使用该函数来创建一个纬度数组
-
将结果数组传递给我们在计算平均值部分编写的平均函数,上述章节
要完成第一个任务,我们可以使用第七章的参数顺序部分中的myMap()
函数,以及第六章的从对象中获取属性部分中的getField()
函数,再加上一些柯里化来固定一些值。用长篇大论来写,我们的解决方案可能是以下内容:
const average = arr => arr.reduce(sum, 0) / arr.length;
const getField = attr => obj => obj[attr];
const myMap = curry(flipTwo(demethodize(map)));
const getLat = curry(getField)("lat");
const getAllLats = curry(myMap)(getLat);
let averageLat = pipeline(getAllLats, average);
// *and similar code to average longitudes*
当然,你总是可以屈服于去写一些一行代码的诱惑,但要注意:这样真的更清晰,更好吗?
let averageLat2 = pipeline(curry(myMap)(curry(getField)("lat")), average);
let averageLon2 = pipeline(curry(myMap)(curry(getField)("lon")), average);
这是否对你有意义将取决于你对 FP 的经验。无论采取哪种解决方案,事实仍然是,添加管道(以及后来的组合)到你的工具集中可以帮助你编写更紧凑、声明式、更容易理解的代码,所以现在让我们转向看看如何以正确的方式进行函数管道化。
创建管道
我们希望能够生成一个包含多个函数的管道。我们可以以两种不同的方式来做到这一点:通过以问题特定的方式手动构建管道,或者试图使用更通用的构造,可以以一般性地应用。让我们看看这两种解决方案。
手动构建管道
让我们以一个 Node.js 的例子来进行,类似于我们在本章前面构建的命令行管道。我们需要一个函数来读取目录中的所有文件,我们可以这样做(这种方式不太推荐,因为它是同步调用,通常在服务器环境中不好):
function getDir(path) {
const fs = require("fs");
const files = fs.readdirSync(path);
return files;
}
过滤odt
文件非常简单。我们从以下函数开始:
const filterByText = (text, arr) => arr.filter(v => v.endsWith(text));
因此,我们现在可以写出以下内容:
const filterOdt = arr => filterByText(".odt", arr);
更好的是,我们可以应用柯里化,并采用无参风格,就像第三章中的一个不必要的错误部分所示的那样:
const filterOdt2 = curry(filterByText)(".odt");
最后,要计算数组中的元素,我们可以简单地编写以下代码。由于.length
不是一个函数,我们无法应用我们的去方法化技巧:
const count = arr => arr.length;
有了这些函数,我们可以写出类似这样的代码:
const countOdtFiles = (path) => {
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*
如果你想摆脱所有的中间变量,你也可以选择一行式的定义:
const countOdtFiles2 = path => count(filterOdt(getDir(path)));
countOdtFiles2("/home/fkereki/Documents"); // 4, *as before*
这就是问题的关键:我们的文件计数函数的两种实现都有缺点。第一个定义使用了几个中间变量来保存结果,并且将 Linux shell 中的一行代码变成了多行函数。另一方面,第二个定义要短得多,但在某种程度上更难理解,因为我们似乎是以相反的顺序编写计算的步骤!我们的流水线必须首先读取文件,然后过滤它们,最后计数--但在我们的定义中,这些函数的顺序却是相反的!
我们当然可以手动实现流水线处理,正如我们所见,但如果我们可以采用更具声明性的风格会更好。让我们继续尝试以更清晰和可理解的方式构建更好的流水线,尝试应用我们已经见过的一些概念。
使用其他构造
如果我们从函数的角度思考,我们拥有的是一系列函数,我们想要按顺序应用它们,从第一个开始,然后将第二个应用于第一个函数产生的结果,然后将第三个应用于第二个函数的结果,依此类推。如果我们只是修复两个函数的流水线,这样就可以:
const pipeTwo = (f, g) => (...args) => g(f(...args));
这并不是那么无用,因为我们可以组合更长的流水线--尽管,我承认,这需要写得太多了!我们可以用两种不同但等效的方式来编写我们的三个函数的流水线:
const countOdtFiles3 = path =>
pipeTwo(pipeTwo(getDir, filterOdt), count)(path);
const countOdtFiles4 = path =>
pipeTwo(getDir, pipeTwo(filterOdt, count))(path);
我们正在利用管道是一个可结合的操作这一事实。在数学中,结合性质是指我们可以通过首先添加1+2然后将结果添加到 3,或者通过将 1 添加到添加2+3的结果来计算1+2+3:换句话说,1+2+3与(1+2)+3或1+(2+3)相同。
这是如何工作的?详细跟踪给定调用的执行将是有用的;很容易因为有这么多的调用而感到困惑!第一个实现可以一步一步地跟踪,直到最终结果,幸运的是与我们已经知道的相匹配:
countOdtFiles3("/home/fkereki/Documents") ===
pipeTwo(pipeTwo(getDir, filterOdt), count)("/home/fkereki/Documents") ===
count(pipeTwo(getDir, filterOdt)("/home/fkereki/Documents")) ===
count(filterOdt(getDir("/home/fkereki/Documents"))) // 4
第二个实现也得到了相同的最终结果:
countOdtFiles4("/home/fkereki/Documents") ===
pipeTwo(getDir, pipeTwo(filterOdt, count))("/home/fkereki/Documents") ===
pipeTwo(filterOdt, count)(getDir("/home/fkereki/Documents")) ===
count(filterOdt(getDir("/home/fkereki/Documents"))) // **4**
好吧,现在我们知道我们只需要一个基本的两个管道高阶函数...但我们真的希望能够以更短、更紧凑的方式工作。首先的实现可能是以下内容:
const pipeline = (...fns) => (...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()
来做得更好,就像第五章中的以更好的风格进行声明式编程。
如果你查看一些 FP 库,我们这里称为pipeline()
的函数也可能被称为flow()
--因为数据从左到右流动--或sequence()
--暗示操作是按升序顺序执行的--但语义是相同的。
这个想法是从第一个函数开始评估,将结果传递给第二个函数,然后将该结果传递给第三个函数,依此类推。然后我们可以用更短的代码实现我们的流水线:
const pipeline2 = (...fns) =>
fns.reduce((result, f) => **(...args) => f(result(...args))**);
pipeline2(getDir, filterOdt, count)("/home/fkereki/Documents"); // 4
这段代码更具声明性,你甚至可以通过使用我们的pipeTwo()
函数来写得更好,它执行的是相同的操作:
const pipeline3 = (...fns) => fns.**reduce(pipeTwo)**;
**pipeline3(getDir, filterOdt, count)**("/home/fkereki/Documents"); // *again* 4
您也可以通过意识到,基本上它使用了我们提到的结合性质,并首先将第一个函数传递给第二个;然后,将这个结果传递给第三个函数,依此类推来理解这段代码。
哪个版本更好?我会说引用pipeTwo()
函数的版本更清晰:如果您知道.reduce()
的工作原理,您可以很容易理解我们的管道是如何一次两个函数地通过的,从第一个开始--这与您对管道工作原理的了解相匹配。我们写的其他版本更多或少是陈述性的,但可能不那么容易理解。
调试管道
现在,让我们转向一个实际问题:如何调试您的代码?使用管道,您无法真正看到从函数到函数传递的内容,那么您该如何做呢?我们有两个答案:一个(也)来自 Unix/Linux 世界,另一个(最适合本书)使用包装器来提供一些日志。
使用 tee
我们将使用的第一个解决方案意味着向管道中添加一个函数,该函数将仅记录其输入。我们希望实现类似于tee
Linux 命令的功能,它可以拦截管道中的标准数据流并将副本发送到备用文件或设备。记住/dev/tty
是通常的控制台,我们可以执行以下操作并在屏幕上获得通过tee
命令传递的所有内容的副本:
$ ls -1 | grep "odt$" | **tee /dev/tty** | wc -l
*...the list of files with names ending in odt...*
*4*
我们可以轻松地编写一个类似的函数:
const tee = arg => {
console.log(arg);
return arg;
};
如果您了解逗号运算符的用法,您可以更加简洁,只需编写const tee = (arg) => (console.log(arg), arg)
--您明白为什么吗?查看developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comma_Operator
获取答案!
我们的日志记录函数将接收一个参数,列出它,并将其传递给管道中的下一个函数。我们可以看到它的工作方式:
console.log(
pipeline2(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()
函数可以接收一个日志记录函数作为参数,那就更好了,就像我们在第六章的以函数式方式记录日志部分中所做的那样;这只是做出与我们之前所做的相同类型的更改的问题。同样的良好设计概念再次应用!
const tee2 = (arg, logger = console.log) => {
logger(arg);
return args;
};
请注意,以这种方式传递console.log
可能会存在绑定问题。最好写成console.log.bind(console)
,作为一种预防措施。
然而,这只是一个特定的增强:现在让我们考虑一个更通用的接入函数,比仅仅做一些日志记录更有可能。
接入流
如果您愿意,您可以编写一个增强的tee()
函数,可以产生更多的调试信息,可能将报告的数据发送到文件或远程服务等--您可以探索许多可能性。您还可以探索更一般的解决方案,tee()
只是一个特例,并且还允许创建个性化的接入函数。参见图 8.2:
图 8.2。接入允许您应用一些函数来检查数据在管道中流动的情况。
在使用管道时,您可能希望在其中间放置一个日志记录函数,或者您可能需要一些其他类型的窥探函数--可能在某处存储数据,或者调用服务,或者其他一些副作用。我们可以有一个通用的tap()
函数,它可以以这种方式运行:
const tap = curry((fn, x) => (fn(x), x));
这可能是本书中 看起来最棘手的代码 候选,所以让我们解释一下。我们想要生成一个函数,给定一个函数 fn()
和一个参数 x
,将评估 fn(x)
(以产生我们可能感兴趣的任何一种副作用),但返回 x
(这样管道就可以继续进行而不受干扰)。逗号运算符正好具有这种行为:如果您编写像 (a, b, c)
这样的代码,JS 将按顺序评估这三个表达式,并使用最后一个值作为表达式的值。
逗号在 JS 中有几种用法,您可以在 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comma_Operator
上阅读更多关于其作为运算符的用法。
现在我们可以利用柯里化来生成几个不同的 tapping 函数。我们在上一节中编写的 tee()
函数也可以按照以下方式编写:
const tee3 = tap(console.log);
顺便说一句,您也可以不使用柯里化来编写 tap()
... 但您会承认它失去了一些神秘感!
const tap2 = fn => x => (fn(x), x);
您会认出这种柯里化的方式,就像我们在 第七章 的 Currying by hand 部分中看到的那样,Transforming Functions - Currying and Partial Application。
使用日志包装器
我们提到的第二个想法基于我们在 第六章 的 Logging 部分中编写的 addLogging()
函数,Producing Functions - Higher-Order Functions。这个想法是用一些日志功能包装一个函数,这样在进入时,参数将被打印出来,退出时,函数的结果将被显示出来:
pipeline2(
**addLogging**(getDir),
**addLogging**(filterOdt),
**addLogging**(count))("/home/fkereki/Documents"));
entering getDir: /home/fkereki/Documents
exiting getDir: ...*the list of all the files in the directory*...
entering filterOdt: ...*the same list of files*...
exiting filterOdt: ...*the list of files with names ending in odt*...
entering count: ...*the list of files with names ending in odt*...
exiting count: 4
我们可以轻松验证 pipeline()
函数是否正确执行 -- 函数产生的结果作为输入传递给下一个函数,我们也可以理解每次调用发生了什么。当然,您不需要在 每个 管道函数中添加日志记录:您可能只在怀疑出现错误的地方这样做。
链接和流畅接口
当您使用对象或数组时,还有另一种方法可以将多个调用的执行链接在一起,即应用 chaining。例如,当您使用数组时,如果应用了 .map()
或 .filter()
方法,结果将是一个新数组,您可以对其应用新的方法,依此类推。我们已经使用了这样的方法,就像我们在 第五章 的 Working with ranges 部分中定义 range()
函数时一样:
const range = (start, stop) =>
new Array(stop - start).fill(0).map((v, i) => start + i);
首先,我们创建了一个新数组;然后,我们对其应用了 .fill()
方法,这个方法会直接更新数组(副作用...)并返回更新后的数组,最后我们对其应用了 .map()
方法。后者确实生成了一个新数组,我们可以对其应用进一步的映射、过滤或任何其他可用的方法。
这种连续链式操作的风格也用于流畅的 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 + ")";
});
每个方法都作用于前一个对象,并提供对将来应用方法调用的新对象的访问(例如 .selectAll()
或 .append()
方法),或者更新当前对象(就像 .attr()
属性设置调用一样)。这种风格并不是唯一的,还有其他一些知名的库(比如 jQuery,仅举一个例子)也应用了这种风格。
我们能自动化这个过程吗?在这种情况下,答案可能是可能,但我宁愿不这样做。在我看来,使用pipeline()
或compose()
同样可以实现相同的结果。使用对象链接,你只能返回新的对象或数组或可以应用方法的东西。 (请记住,如果你使用标准类型,比如字符串或数字,你不能给它们添加方法,除非你修改它们的原型,这是不推荐的!)然而,使用组合,你可以返回任何类型的值;唯一的限制是下一个函数必须期望你提供的数据类型。
另一方面,如果你正在编写自己的 API,那么你可以通过让每个方法return this
来提供一个流畅的接口--当然,除非它需要返回其他东西!如果你正在使用其他人的 API,你也可以通过使用代理来进行一些技巧,但要注意可能有情况下你的代理代码可能会失败:也许正在使用另一个代理,或者有一些 getter 或 setter 会导致问题,等等。
你可能想在developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Proxy
上阅读代理对象的相关内容--它们非常强大,可以提供有趣的元编程功能,但它们也可能陷入技术细节,并且会导致代理代码的轻微减速。
让我们来看一个基本的例子。我们可以有一个City
类,带有名称、纬度(lat
)和经度(long
)属性:
class City {
constructor(name, lat, long) {
this.name = name;
this.lat = lat;
this.long = long;
}
getName() {
return this.name;
}
setName(newName) {
this.name = newName;
}
setLat(newLat) {
this.lat = newLat;
}
setLong(newLong) {
this.long = newLong;
}
getCoords() {
return [this.lat, this.long];
}
}
我们可以像下面这样使用这个类,详细介绍我的家乡蒙得维的亚,乌拉圭:
let myCity = new City("Montevideo, Uruguay", -34.9011, -56.1645);
console.log(myCity.getCoords(), myCity.getName());
// [ -34.9011, -56.1645 ] 'Montevideo, Uruguay'
如果我们想要允许流畅地处理 setter,我们可以设置一个代理来检测这样的调用,并提供缺失的return this
。我们怎么做呢?如果原始方法没有返回任何东西,JS 将默认包含一个return undefined
语句,因此我们可以检测方法是否返回这个值,并替换为return this
。当然,这是一个问题:如果我们有一个方法,根据其语义,它可以合法地返回一个undefined
值,我们可以有一种异常列表,告诉我们的代理在这些情况下不添加任何东西,但我们不要深入讨论这个问题。
我们的处理程序代码如下。每当调用对象的方法时,都会隐式调用一个 get,我们捕获它。如果我们得到一个函数,那么我们用自己的一些代码包装它,这些代码将调用原始方法,然后决定是返回它的值还是返回代理对象的引用。如果我们没有得到一个函数,那么我们直接返回所请求属性的值。我们的chainify()
函数将负责将处理程序分配给一个对象,并创建所需的代理。
const getHandler = {
get(target, property, receiver) {
if (typeof target[property] === "function") {
// requesting a method? return a wrapped version
return (...args) => {
const result = targetproperty;
return result === undefined ? receiver : result;
};
} else {
// an attribute was requested - just return it
return target[property];
}
}
};
const chainify = obj => new Proxy(obj, getHandler);
有了这个,我们可以chainify任何对象,这样我们就有机会检查任何调用的方法。当我写这篇文章时,我目前住在印度浦那,所以让我们反映这个变化。
myCity = chainify(myCity);
console.log(myCity
.setName("Pune, India")
.setLat(18.5626)
.setLong(73.8087)
.g oords(),
myCity.getName());
// [ 18.5626, 73.8087 ] 'Pune, India'
请注意以下内容:
-
我们将
myCity
更改为它自己的代理版本。 -
我们以流畅的方式调用了几个 setter,它们工作正常,因为我们的代理负责为下一个调用提供所需的 this 值。
-
对
.getCoords()
和.getName()
的调用被拦截,但没有做任何特殊处理,因为它们已经返回一个值。
这值得吗?这取决于你--但请记住我的评论,可能有情况下这种方法会失败,所以要小心!
Pointfree 风格
当你将函数连接在一起,无论是像这样以管道方式,还是像我们将在本章后面看到的组合方式,你都不需要任何中间变量来保存结果,这些结果将成为下一个函数的参数:它们是隐式的。同样,你可以编写函数而不提及它们的参数,这被称为 pointfree 风格。
点无码风格也被称为暗示式编程--以及无意义的编程,由反对者提出!术语point本身意味着函数参数,点无码指的是不命名这些参数。
定义点无码函数
你可以很容易地识别点无码函数定义,因为它既不需要function
关键字,也不需要=>
符号。我们可以重新审视本章中我们之前编写的一些函数的定义,来验证这一点。例如,我们原始的文件计数函数的定义:
const countOdtFiles3 = path =>
pipeTwo(pipeTwo(getDir, filterOdt), count)(path);
const countOdtFiles4 = path =>
pipeTwo(getDir, pipeTwo(filterOdt, count))(path);
前面的代码可以重写如下:
const countOdtFiles3b = pipeTwo(pipeTwo(getDir, filterOdt), count);
const countOdtFiles4b = pipeTwo(getDir, pipeTwo(filterOdt, count));
新的定义没有引用新定义的函数的参数。你可以通过检查管道中的第一个函数(在这种情况下是getDir()
)并查看它接收的参数来推断它。 (在第十二章中,我们将看到,使用类型签名会对文档方面有所帮助。)同样,getLat()
的定义是点无码的:
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);
要从给定对象中提取余额属性,我们可以使用getField()
和一点柯里化,然后写成以下形式:
const getBalance = curry(getField)("balance");
对于第二个函数,我们可以写成以下形式:
const isNegative = x => 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()
的新版本没有引用它们的参数,并且完全是点无码的,但使用点无码风格的想法应该是为了帮助提高代码的清晰度和可读性,而不是产生混淆和不透明性!我怀疑任何人看到我们函数的新版本并认为它们比原来的有任何优势。
如果你发现你的代码变得难以理解,而这只是因为你想使用点无码编程,那就停下来,撤销你的更改。记住我们书中的原则:我们想要进行 FP,但我们不想过分使用它--使用点无码风格并不是一个要求!
组合
组合与管道非常相似,但它源自数学理论。组合的概念很简单 - 一系列函数调用,其中一个函数的输出是下一个函数的输入 - 但顺序与管道相反。在后者中,要应用的第一个函数是最左边的,但在组合中,你从最右边开始。让我们更深入地研究一下这个问题。
当你定义三个函数的组合,比如(f∘ g∘ h)并将其应用于x时,这等同于你写成f(g(h(x)))。重要的是要注意,与管道相同,第一个要应用的函数的 arity 可以是任何值,但所有其他函数必须是一元的。此外,除了函数评估的顺序不同之外,组合是 FP 中的一个重要工具,因为它也抽象了实现细节(让你专注于你需要完成的任务,而不是为了实现这个任务而专注于具体的细节),因此让你以更声明式的方式工作。
如果有帮助的话,你可以将(f∘ g∘ h)看作是f 在 g 之后在 h 之后,这样就清楚了h是要应用的第一个函数,f是最后一个。
由于与管道的相似性,实现组合并不会太难,但仍然有一些重要和有趣的细节。
一些组合的例子
也许对你来说并不奇怪,但我们已经看到了几个组合的例子,或者至少是功能上等价于使用组合的情况。让我们回顾一些这些例子,并且也用一些新的例子来工作。
一元运算符
在第六章的逻辑否定函数部分,生成函数 - 高阶函数,我们写了一个not()
函数,给定另一个函数,它会逻辑地反转其结果。我们使用该函数来否定对负余额的检查;示例代码可能如下:
const not = fn => (...args) => !fn(...args);
const positiveBalance = not(isNegativeBalance);
在同一章的另一部分,将操作转换为函数,我给你留下了一个挑战,写一个unaryOp()
函数,它将提供与常见 JS 运算符等价的一元函数。所以,如果你能写出以下内容:
const logicalNot = unaryOp("!");
然后,假设存在一个compose()
函数,你也可以写成以下形式:
const positiveBalance = compose(logicalNot, isNegativeBalance);
你更喜欢哪一个?这实际上是一个品味的问题,但我认为第二个版本更清楚地表达了我们想要做的事情。使用not()
函数,你必须检查它的作用才能理解整个代码。而使用组合,你仍然需要知道logicalNot()
是什么,但整体结构是可以看到的。
在同一章的反转结果部分,你也可以看到另一个例子。记住,我们有一个函数可以根据西班牙语规则比较字符串,但我们想要反转比较的意义,以降序排序:
const changeSign = unaryOp("-");
palabras.sort(**compose(changeSign, spanishComparison)**);
计算文件
我们也可以回到我们的管道。我们已经写了一个单行函数来计算给定路径中的odt
文件:
const countOdtFiles2 = path => count(filterOdt(getDir(path)));
暂且不考虑这段代码不如后来我们开发的管道版本清晰的观察,我们也可以用组合来编写这个函数:
const countOdtFiles2b = path => compose(count, filterOdt, getDir)(path);
countOdtFiles2b("/home/fkereki/Documents"); // *4, no change here*
我们也可以以 pointfree 的方式编写这个函数,不指定path
参数,使用const countOdtFiles2 = compose(count, filterOdt, getDir)
,但我想更好地与之前的定义相对应。
也可以以一行的方式来看待这个问题:
compose(count, filterOdt, getDir)("/home/fkereki/Documents");
即使它不像流水线版本那样清晰(这只是我的观点,可能受我对 Linux 的喜好影响!),这种声明式实现清楚地表明我们依赖于组合三个不同的函数来获得我们的结果--这很容易看出,并应用了将大型解决方案构建成更简单的代码片段的思想。
查找唯一单词
最后,让我们举一个例子,我同意,这也可以用于流水线处理。假设你有一段文本,你想从中提取所有唯一的单词:你会怎么做?如果你考虑它的步骤(而不是试图一次性创建一个完整的解决方案),你可能会想出类似这样的解决方案:
-
忽略所有非字母字符
-
将所有内容转换为大写
-
将文本拆分为单词
-
创建一个单词集合
为什么要使用集合?因为它会自动丢弃重复的值;请查看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
。
现在,以 FP 方式解决每个问题:
const removeNonAlpha = str => str.replace(/[^a-z]/gi, " ");
const toUpperCase = demethodize(String.prototype.toUpperCase);
const splitInWords = str => str.trim().split(/\s+/);
const arrayToSet = arr => new Set(arr);
const setToList = set => Array.from(set).sort();
有了这些函数,结果可以写成如下形式:
const getUniqueWords = compose(
setToList,
arrayToSet,
splitInWords,
toUpperCase,
removeNonAlpha
);
由于你看不到组合函数的参数,你真的不需要显示getUniqueWords()
的参数,所以在这种情况下,点无风格是自然的。
我们可以测试我们的函数;让我们将这个函数应用于亚伯拉罕·林肯于 1863 年 11 月 19 日在葛底斯堡的演讲的前两句话,并打印出由 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 dedicated,
can long endure.`; console.log(**getUniqueWords(GETTYSBURG_1_2)**); [ 'A', 'AGO', 'ALL', 'AND', 'ANY', 'ARE', 'BROUGHT', 'CAN', 'CIVIL',
... 'TESTING',| 'THAT', 'THE', 'THIS', 'TO', 'WAR', 'WE', 'WHETHER', 'YEARS' ]
当然,你可能已经以可能更短的方式编写了getUniqueWords()
,但我要说的是,通过将解决方案组合成几个较短的步骤,你的代码更清晰,更容易理解。然而,如果你希望说流水线处理的解决方案似乎更好,那只是一种观点!
使用高阶函数进行组合
很明显,手动组合可以像我们上面看到的流水线处理一样轻松地完成。例如,我们在前面的几节中编写的唯一单词计数函数可以用简单的 JS 风格编写:
const getUniqueWords1 = str => {
const str1 = removeNonAlpha(str);
const str2 = toUpperCase(str1);
const arr1 = splitInWords(str2);
const set1 = arrayToSet(arr1);
const arr2 = setToList(set1);
return arr2;
};
或者,它可以以更简洁(更晦涩!)的一行风格编写:
const getUniqueWords2 = str =>
setToList(arrayToSet(splitInWords(toUpperCase(removeNonAlpha(str)))));
console.log(getUniqueWords2(GETTYSBURG_1_2));
// [ 'A', 'AGO', 'ALL', 'AND', ... 'WAR', 'WE', 'WHETHER', 'YEARS' ]
然而,与流水线处理一样,让我们寻找一个更通用的解决方案,这样就不需要每次想要组合其他函数时都写一个特殊的函数。
组合两个函数非常容易,只需要对我们在本章前面看到的pipeTwo()
函数进行一点小改动:
const pipeTwo = (f, g) => (...args) => g(f(...args));
const composeTwo = (f, g) => (...args) => f(g(...args));
唯一的区别是,使用流水线处理时,你首先应用最左边的函数,而使用组合时,你从最右边的函数开始。这种变化表明我们可以使用来自第七章 转换函数-柯里化和部分应用部分的flipTwo()
高阶函数。这样清楚吗?
const composeTwoByFlipping = flipTwo(pipeTwo);
无论如何,如果我们想要组合超过两个函数,我们也可以利用结合律,编写类似以下的内容:
const getUniqueWords3 = composeTwo(
setToList,
composeTwo(
arrayToSet,
composeTwo(splitInWords, composeTwo(toUpperCase, removeNonAlpha))
)
);
console.log(getUniqueWords3(GETTYSBURG_1_2));
// [ 'A', 'AGO', 'ALL', 'AND', ... 'WAR', 'WE', 'WHETHER', 'YEARS' ] *OK again*
尽管这样可以运行,但让我们寻找更好的解决方案--我们可以提供至少两种。第一种方法与流水线和组合工作相反有关。当我们进行流水线处理时,我们从左到右应用函数,而在组合时,我们从右到左应用函数。因此,我们可以通过颠倒函数的顺序并进行流水线处理来实现与组合相同的结果;这是一个非常实用的解决方案,我非常喜欢!
const compose = (...fns) => pipeline(...(fns.reverse**()))**; console.log(
compose(
setToList,
arrayToSet,
splitInWords,
toUpperCase,
removeNonAlpha
)(GETTYSBURG_1_2)
);
// [ 'A', 'AGO', 'ALL', 'AND', ... 'WAR', 'WE', 'WHETHER', 'YEARS' ] *OK once more*
唯一棘手的部分是在调用pipeline()
之前使用展开运算符。在反转fns
数组之后,我们必须再次展开其元素,以正确调用pipeline()
。
另一个不太声明式的解决方案是使用.reduceRight()
,所以我们不是反转函数列表,而是反转处理它们的顺序:
const compose2 = (...fns) => fns.reduceRight(pipeTwo);
console.log(
compose2(
setToList,
arrayToSet,
splitInWords,
toUpperCase,
removeNonAlpha
)(GETTYSBURG_1_2)
);
// [ 'A', 'AGO', 'ALL', 'AND', ... 'WAR', 'WE', 'WHETHER', 'YEARS' ] *still OK*
为什么/如何这个工作?让我们跟随这个调用的内部工作。我们可以用它的定义替换pipeTwo()
,以使这更清晰:
const compose2b = (...fns) =>
fns.reduceRight((f,g) => (...args) => g(f(...args)));
好的,让我们看看!
-
由于没有提供初始值,第一次
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()
来实现这个功能--你能看出为什么吗?推理与我们所做的类似,所以我们将其留给读者作为一个练习!
const compose3 = (...fns) => fns.reduce(composeTwo**)**;
弄清楚compose3()
的工作原理后,您可能想编写一个使用.reduceRight()
的pipeline()
版本,只是为了对称地完成一切!
我们可以通过提及,就测试和调试而言,我们可以应用与调试相同的思想;只是记住组合走另一条路!我们不会通过提供更多相同类型的示例来获得任何好处,所以现在让我们考虑一种在使用对象时链接操作的常见方式,并看看它是否有利,鉴于我们不断增长的 FP 知识和经验。
测试组合函数
让我们通过考虑对流水线化或组合函数进行测试来完成本章。鉴于这两种操作的机制相似,我们将为它们都提供示例,它们不会有区别,除了由于函数评估的从左到右或从右到左的逻辑差异。
在流水线方面,我们可以从看如何测试pipeTwo()
函数开始,因为设置将类似于pipeline()
。我们需要创建一些间谍,然后检查它们是否被正确调用了正确次数,以及每次是否收到了正确的参数。我们将设置间谍,以便它们提供对调用的已知答案,这样我们就可以看到函数的输出是否成为管道中下一个函数的输入:
var fn1, fn2;
describe("pipeTwo", function() {
beforeEach(() => {
fn1 = () => {};
fn2 = () => {};
});
it("works with single arguments", () => {
spyOn(window, "fn1").and.returnValue(1);
spyOn(window, "fn2").and.returnValue(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", () => {
spyOn(window, "fn1").and.returnValue(11);
spyOn(window, "fn2").and.returnValue(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()
,测试会相当类似。不过,我们可以为单函数管道添加一个测试(边界情况!),另一个测试包含四个函数:
describe("pipeline", function() {
beforeEach(() => {
fn1 = () => {};
fn2 = () => {};
fn3 = () => {};
fn4 = () => {};
});
it("works with a single function", () => {
spyOn(window, "fn1").and.returnValue(11);
const pipe = pipeline(fn1);
const result = pipe(60);
expect(fn1).toHaveBeenCalledTimes(1);
expect(fn1).toHaveBeenCalledWith(60);
expect(result).toBe(11);
});
// *we omit here tests for 2 functions,*
// *which are similar to those for pipeTwo()*
it("works with 4 functions, multiple arguments", () => {
spyOn(window, "fn1").and.returnValue(111);
spyOn(window, "fn2").and.returnValue(222);
spyOn(window, "fn3").and.returnValue(333);
spyOn(window, "fn4").and.returnValue(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);
});
});
最后,对于组合,风格是一样的(除了函数评估的顺序相反),所以让我们只看一个测试--我只是改变了前一个测试中函数的顺序:
var fn1, fn2, fn3, fn4;
describe("compose", function() {
beforeEach(() => {
fn1 = () => {};
fn2 = () => {};
fn3 = () => {};
fn4 = () => {};
});
// *other tests omitted...*
it("works with 4 functions, multiple arguments", () => {
spyOn(window, "fn1").and.returnValue(111);
spyOn(window, "fn2").and.returnValue(222);
spyOn(window, "fn3").and.returnValue(333);
spyOn(window, "fn4").and.returnValue(444);
const pipe = compose(fn4, fn3, fn2, fn1);
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);
});
});
最后,为了测试chainify()
函数,我选择使用上面创建的City
对象--我不想搞乱模拟、存根、间谍之类的东西,而是想确保代码在正常情况下能够工作:
class City {
// *as above*
}
var myCity;
describe("chainify", function() {
beforeEach(() => {
myCity = new City("Montevideo, Uruguay", -34.9011, -56.1645);
myCity = chainify(myCity);
});
it("doesn't affect get functions", () => {
expect(myCity.getName()).toBe("Montevideo, Uruguay");
expect(myCity.getCoords()[0]).toBe(-34.9011);
expect(myCity.getCoords()[1]).toBe(-56.1645);
});
it("doesn't affect getting attributes", () => {
expect(myCity.name).toBe("Montevideo, Uruguay");
expect(myCity.lat).toBe(-34.9011);
expect(myCity.long).toBe(-56.1645);
});
it("returns itself from setting functions", () => {
expect(myCity.setName("Other name")).toBe(myCity);
expect(myCity.setLat(11)).toBe(myCity);
expect(myCity.setLong(22)).toBe(myCity);
});
it("allows chaining", () => {
const newCoords = myCity
.setName("Pune, India")
.setLat(18.5626)
.setLong(73.8087)
.getCoords();
expect(myCity.name).toBe("Pune, India");
expect(newCoords[0]).toBe(18.5626);
expect(newCoords[1]).toBe(73.8087);
});
});
所有测试的最终结果显示在下图中:
图 8.3。组合函数测试的成功运行。
问题
8.1. 标题大写。让我们定义标题风格大写,要求一个句子全部用小写书写,除了每个单词的第一个字母。(这种风格的真正定义更复杂,所以让我们简化这个问题。)编写一个函数headline(sentence)
,它将接收一个字符串作为参数,并返回一个适当大写的版本。空格分隔单词。通过组合较小的函数来构建这个函数:
console.log(headline("**Alice's ADVENTURES in WoNdErLaNd**"));
// Alice's Adventures In Wonderland
8.2. 待办任务。一个 web 服务返回一个结果,如下所示,逐个人显示他们所有分配的任务。任务可能已完成(done===true
)或待办(done===false
)。你的目标是为给定的人(通过名字识别)生成一个待办任务 ID 数组,该数组应该与responsible
字段匹配。通过使用组合或管道解决这个问题:
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}]
}
]
};
确保你的代码不会抛出异常,例如,如果你要查找的人在 web 服务结果中没有出现!
在书的最后一章,更进一步,我们将看到另一种解决这个问题的方法,通过使用Maybe
单子,这将大大简化处理可能缺失的数据的问题。
8.3. 以抽象方式思考。假设你正在查看一些旧代码,你发现一个函数看起来像下面这样。(我保持名称模糊和抽象,这样你可以专注于结构而不是实际功能。)你能把这个转换成 Pointfree 风格吗?
function getSomeResults(things) {
return sort(group(filter(select(things))));
};
总结
在本章中,我们已经看到了通过不同方式将几个其他函数连接起来创建新函数的方法,通过管道化(还有一个我们不推荐的变体,链式)和组合。
在第九章中,设计函数 - 递归,我们将继续进行函数设计,并学习递归的使用,这在函数式编程中经典上是一种基本工具,并且允许非常干净的算法设计。
第九章:设计函数-递归
在第八章中,连接函数-管道和组合,我们考虑了更多的方法来通过组合现有的函数来创建新函数。在这里,我们将进入一个不同的主题:如何通过应用递归技术以典型的功能方式设计和编写函数。
我们将涵盖以下主题:
-
了解递归是什么以及如何思考以产生递归解决方案
-
将递归应用于一些众所周知的问题,例如找零钱或汉诺塔
-
使用递归而不是迭代来重新实现早期章节中的一些高阶函数
-
轻松编写搜索和回溯算法
-
遍历数据结构,例如树,以处理文件系统目录或浏览器 DOM
-
解决由浏览器 JS 引擎考虑引起的一些限制
使用递归
递归是 FP 中的关键技术,有些语言甚至不提供任何形式的迭代或循环,而完全使用递归(我们已经提到的 Haskell 就是一个典型例子)。计算机科学的一个基本事实是,无论您使用递归还是迭代(循环),您都可以使用递归完成的任何事情,反之亦然。关键概念是有许多算法的定义如果使用递归工作起来要容易得多。另一方面,递归并不总是被教授,或者许多程序员即使了解它,也宁愿不使用它。因此,在本节中,我们将看到几个递归思维的例子,以便您可以将其适应到您的功能编码中。
典型的、经常引用的、非常古老的计算机笑话!*字典定义:
递归:(n)见递归*
但是,什么是递归?有许多定义递归的方法,但我见过的最简单的一种是一个函数一遍又一遍地调用自己,直到不再需要。递归是解决几种问题的自然技术,例如:
-
数学定义,例如斐波那契数或阶乘
-
与递归定义的结构相关的数据结构算法,例如列表(列表要么为空,要么由一个头节点和一个节点列表组成)或树(树可以被定义为一个特殊节点,称为根节点,链接到零个或多个树)
-
基于语法规则的编译器的语法分析,这些规则本身依赖于其他规则,这些规则又依赖于其他规则,依此类推
-
以及更多
Google 本身就对此开玩笑:如果您询问递归,它会回答您是否想要:递归!
无论如何,递归函数除了一些简单的基本情况外,其中不需要进一步的计算,总是需要调用自身一次或多次以执行所需计算的一部分。这个概念现在可能不太清楚,所以让我们看看如何以递归的方式思考,然后通过应用该技术解决几个常见问题。
递归思考
递归解决问题的关键是假设您已经有一个可以满足您需求的函数,然后正常调用它。(这听起来奇怪吗?实际上,这是相当合适的:要使用递归解决问题,您必须首先解决问题...)另一方面,如果您试图在脑海中思考递归调用的工作方式并尝试在脑海中跟随流程,您可能会迷失方向。因此,您需要做的是:
-
假设您已经有一个适当的函数来解决您的问题。
-
然后,看看如何通过解决一个(或多个)较小的问题来解决大问题。
-
使用步骤 1 中想象的函数解决这些问题。
-
确定哪些是您的基本情况,足够简单,可以直接解决,不需要任何更多的调用。
有了这些元素,你可以通过递归来解决问题,因为你将拥有递归解决方案的基本结构。
通过应用递归,有三种通常的方法来解决问题:
-
减少和征服是最简单的情况,其中解决一个问题直接取决于解决其自身的一个更简单的情况
-
分而治之是一种更一般的方法。其思想是尝试将问题分解为两个或更多较小的版本,递归地解决它们,并使用这些解决方案来解决原始问题。减少和征服的唯一区别在于,这里你需要解决两个或更多其他问题,而不仅仅是一个问题
-
动态规划可以被看作是分而治之的一种变体:基本上,你通过将一个复杂的问题分解为一系列稍微简单的相同问题的版本,并依次解决每个问题来解决它。然而,这种策略中的一个关键思想是存储先前找到的解决方案,因此每当你发现自己需要再次解决一个更简单的情况时,你不会直接应用递归,而是使用存储的结果,避免不必要的重复计算
在这一部分,我们将看到一些问题,并通过递归的方式来解决它们。当然,在本章的其余部分,我们将看到递归的更多应用;在这里,我们将专注于创建这样一个算法所需的关键决策和问题。
减少和征服:搜索
递归的最常见情况涉及一个更简单的情况。我们已经看到了一些例子,比如普遍的阶乘计算:要计算n的阶乘,你之前需要计算n-1的阶乘。(见第一章,成为函数式 - 几个问题。)现在让我们转向一个非数学的例子。
要在数组中搜索一个元素,你也会使用这种减少和征服策略。如果数组为空,显然搜索的值不在其中。否则,结果在数组中,当且仅当它是数组中的第一个元素,或者它在数组的其余部分中:
const search = (arr, key) => {
if (arr.length === 0) {
return false;
} else if (arr[0] === key) {
return true;
} else {
return search(arr.slice(1), key);
}
};
这个实现直接反映了我们的解释,很容易验证其正确性。
顺便说一句,作为一种预防措施,让我们看看相同概念的另外两种实现。你可以稍微缩短搜索函数 -- 这样还清晰吗?
const search2 = (arr, key) =>
arr.length === 0
? false
: arr[0] === key || search2(arr.slice(1), key);
稀疏性甚至可以更进一步!
const search3 = (arr, key) =>
arr.length && (arr[0] === key || search3(arr.slice(1), key));
我并不是真的建议你以这种方式编写函数 -- 相反,把它看作是对一些 FP 开发者倾向的一种警告,他们试图去寻求最紧凑、最简短的解决方案...而不在乎清晰度!
减少和征服:做幂
另一个经典的例子涉及以高效的方式计算数字的幂。如果你想计算,比如说,2 的 13 次方(2¹³),你可能需要进行 12 次乘法。然而,你可以通过将 2¹³写成以下形式来做得更好:
= 2 乘以 2¹²
= 2 乘以 4⁶
= 2 乘以 16³
= 2 乘以 16 乘以 16²
= 2 乘以 16 乘以 256¹
= 8192
总乘法次数的减少可能看起来并不是很令人印象深刻,但是从算法复杂度的角度来看,它可以将计算的顺序从O(n)降低到O(lg n)。在一些与加密相关的方法中,这将产生非常重要的差异。我们可以用几行代码来实现这个递归算法:
const powerN = (base, power) => {
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 发明。据说印度有一座寺庙,有三根柱子,上面有 64 个金质圆盘,直径递减。僧侣们必须将圆盘从第一根柱子移动到最后一根柱子,遵循两条规则:一次只能移动一个圆盘,较大的圆盘永远不能放在较小的圆盘上。根据传说,当 64 个圆盘移动时,世界将终结。这个难题通常以汉诺塔的名义(是的,他们换了国家!)在 10 个圆盘以下进行市场营销。见图 9.1:
图 9.1-经典的汉诺塔难题有一个简单的递归解法。n 个圆盘的解决方案需要2^n-1次移动。原始难题需要2⁶⁴-1次移动,以每秒一次的速度,需要超过 5840 亿年才能完成……这是一个非常长的时间,考虑到宇宙的年龄只有 138 亿年!
假设我们已经有一个能够解决从源柱移动任意数量的圆盘到目标柱,使用剩余柱作为额外辅助的问题的函数。那么,现在考虑解决一般问题,如果你已经有一个解决该问题的函数:hanoi(disks, from, to, extra)
。如果你想要从一个柱移动多个圆盘到另一个柱,你可以通过使用这个(尚未编写的!)函数轻松解决:
-
将所有圆盘但一个移动到额外柱
-
将较大的圆盘移动到目标柱
-
再次使用你的函数,将所有圆盘从额外柱(你之前放置它们的地方)移动到目标柱
但是,我们的基本情况呢?我们可以决定,要移动一个单独的圆盘,你不需要使用函数;你可以直接移动它。编码后变成:
const hanoi = (disks, from, to, extra) => {
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);
}
};
我们可以快速验证这段代码是否有效:
hanoi (4, "A", "B", "C"); // we want to 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
等于 1。你也可以以不同的方式解决它,让圆盘减少到零,然后根本不做任何事情——毕竟,从一个柱移动零个圆盘到另一个柱是通过根本不做任何事情来实现的!
const hanoi2 = (disks, from, to, extra) => {
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);
}
};
我们可以跳过检查是否有圆盘需要移动,而不是在进行递归调用之前进行检查,并让函数在下一级测试是否有事情要做。
如果你正在手动解决这个难题,有一个简单的解决方案:在奇数轮次,总是将较小的圆盘移动到下一个柱子(如果圆盘的总数是奇数)或者移动到前一个柱子(如果圆盘的总数是偶数)。在偶数轮次,做唯一可能的不涉及较小圆盘的移动。
因此,递归算法设计的原则是有效的:假设你已经有了你想要的函数,并用它来构建它!
分而治之:排序
我们可以看到另一个例子,使用分而治之策略,进行排序。一种对数组进行排序的方法,称为快速排序,基于以下前提:
-
如果你的数组有 0 或 1 个元素,什么也不做;它已经排序好了(这是基本情况)。
-
否则,选择数组的某个元素(称为“枢轴”),并将数组的其余部分分成两个子数组:小于您选择的元素和大于或等于您选择的元素的元素。
-
递归地对每个子数组进行排序。
-
将两个排序后的结果连接起来,枢轴放在中间,以生成原始数组的排序版本。
让我们看一个简单版本的这个问题--有一些更好优化的实现,但我们现在对递归逻辑感兴趣。通常建议随机选择数组的一个元素,以避免一些性能不佳的边界情况,但是对于我们的例子,让我们只取第一个元素:
const quicksort = arr => {
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.2 中看到这是如何工作的:每个数组和子数组的枢轴都被划线标出。拆分用虚线箭头表示,并用实线连接:
图 9.2. 快速排序递归地对数组进行排序,应用分而治之的策略,将原始问题减小为较小的问题。
动态规划:找零
第三种一般策略,动态规划,假设您将不得不解决许多较小的问题,但是不是每次都使用递归,而是依赖于存储先前找到的解决方案...也就是记忆化!在第四章中,行为得当 - 纯函数,以及在第六章中以更好的方式,生成函数 - 高阶函数,我们已经看到了如何优化通常的斐波那契数列的计算,避免不必要的重复调用。现在,让我们考虑另一个问题。
给定一定金额的美元和现有票面值列表,计算我们可以用不同的票据组合支付该金额的美元的方式有多少种。假设您可以无限使用每张票据。我们该如何解决这个问题?让我们从考虑基本情况开始,不需要进一步计算的情况:
-
支付负值是不可能的,因此在这种情况下,我们应该返回 0
-
支付零美元只有一种可能的方式(不给任何票据),因此在这种情况下,我们应该返回 1
-
如果没有提供任何票据,则无法支付任何正数金额的美元,因此在这种情况下也返回 0
最后,我们可以回答这个问题:用给定的票据集合,我们可以用多少种方式支付N
美元?我们可以考虑两种情况:我们根本不使用更大的票据,只使用较小面额的票据支付金额,或者我们可以拿一张更大金额的票据,并重新考虑这个问题。(现在让我们忘记避免重复计算。)
-
在第一种情况下,我们应该使用相同的
N
值调用我们假定存在的函数,但已经从可用票据列表中删除了最大面额的票据 -
在第二种情况下,我们应该使用
N
减去最大面额的票据调用我们的函数,保持票据列表不变:
const makeChange = (n, bills) => {
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)
,但要准备大量的输出!)但是,我们已经有了解决方案:记忆化!由于我们正在将这种技术应用于二元函数,我们将需要一个处理多个参数的记忆化算法的版本:
const memoize3 = fn => {
let cache = {};
return (...args) => {
let strX = JSON.stringify(args);
return strX in cache ? cache[strX] : (cache[strX] = fn(...args));
};
};
const makeChange = memoize3((n, bills) => {
// ...*same as above*
});
makeChange()
的记忆化版本要高效得多,您可以通过记录来验证。虽然您可以自己处理重复(例如,通过保留已计算的值的数组),但是记忆化解决方案在我看来更好,因为它由两个函数组合产生了给定问题的更好解决方案。
高阶函数再探讨
经典的 FP 技术根本不使用迭代,而是完全依赖递归作为唯一的循环方式。让我们重新审视一些我们在第五章中已经看到的函数,如map()
、reduce()
、find()
和filter()
,看看我们如何只使用递归就能完成。
尽管如此,我们并不打算用我们自己的递归 polyfills替换基本的 JS 函数:很可能我们的性能会比递归 polyfills差,而且我们不会因为函数使用递归而获得任何优势。相反,我们想研究如何以递归方式执行迭代,因此我们的努力更多是教学性的,好吗?
映射和过滤
映射和过滤非常相似,因为两者都意味着遍历数组中的所有元素,并对每个元素应用回调以产生输出。让我们首先解决映射逻辑,这将有几个要解决的问题,然后过滤将变得几乎轻而易举,只需要做一些小改动。
对于映射,根据我们使用的递归函数开发方式,我们需要一个基本情况。幸运的是,这很容易:映射一个空数组只会产生一个新的空数组。映射一个非空数组可以通过首先将映射函数应用于数组的第一个元素,然后递归地映射数组的其余部分,最后产生一个累积两个结果的单一数组。
基于这个想法,我们可以制定一个简单的初始版本:让我们称之为mapR()
,只是为了记住我们正在处理我们自己的递归版本的map()
。但是,要小心:我们的 polyfill 有一些错误!我们将逐个解决它们:
const mapR = (arr, cb) =>
arr.length === 0 ? [] : [cb(arr[0])].concat(mapR(arr.slice(1), cb));
让我们来测试一下:
let aaa = [ 1, 2, 4, 5, 7];
const timesTen = x => 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, i) => 10 * v + i;
console.log(aaa.map(timesTenPlusI)); // *[10, 21, 42, 53, 74]*
console.log(mapR2(aaa, timesTenPlusI)); // *[**NaN, NaN, NaN, NaN, NaN**]*
生成适当的索引位置将需要递归的额外参数,但基本上很简单:当我们开始时,我们有index=0
,当我们递归调用我们的函数时,它从位置index+1
开始。访问原始数组需要另一个参数,这个参数永远不会改变:
const mapR2 = (arr, cb, i = 0, orig = arr) =>
arr.length == 0
? []
: [cb(arr[0], i, orig)].concat(
mapR2(arr.slice(1), cb, i + 1, orig)
);
let aaa = [1, 2, 4, 5, 7];
const senseless = (x, i, a) => 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()
来处理循环。实际上,这是唯一使用递归时实现循环的常规方式:
const mapR3 = (orig, cb) => {
const mapLoop = (arr, i) =>
arr.length == 0
? []
: [cb(arr[0], i, orig)].concat(
mapR3(arr.slice(1), cb, i + 1, orig)
);
return mapLoop(orig, 0);
};
只有一个未解决的问题:如果原始数组中有一些缺失的元素,在循环过程中它们应该被跳过:
[1, 2, , , 5].map(tenTimes)
// [10, 20, undefined × 2, 50]
幸运的是,修复这个问题很简单——并且很高兴在这里获得的所有经验将帮助我们编写本节中的其他函数!
const mapR4 = (orig, cb) => {
const mapLoop = (arr, i) => {
if (arr.length == 0) {
return [];
} else {
const mapRest = mapR4(arr.slice(1), cb, i + 1, orig);
if (!(0 in arr)) {
return [,].concat(mapRest);
} else {
return [cb(arr[0], i, orig)].concat(mapRest);
}
}
};
return mapLoop(orig, 0);
};
console.log(mapR4(aaa, timesTen)); // *[**10, 20, undefined × 2, 50**]*
哇!这比我们预期的要多得多,但我们看到了几种技巧:用递归替换迭代,如何在迭代中累积结果,如何生成和提供索引值——很好的建议!此外,编写过滤代码将会更容易,因为我们可以应用与映射几乎相同的逻辑。主要区别在于我们使用回调函数来决定元素是否进入输出数组,因此内部循环函数会稍微长一点:
const filterR = (orig, cb) => {
const filterLoop = (arr, i) => {
if (arr.length == 0) {
return [];
} else {
const filterRest = filterR(arr.slice(1), cb, i + 1, orig);
if (!(0 in arr)) {
return filterRest;
} else if (cb(arr[0], i, orig)) {
return [arr[0]].concat(filterRest);
} else {
return filterRest;
}
}
};
return filterLoop(orig, 0);
};
let aaa = [1, 12, , , 5, 22, 9, 60];
const isOdd = x => x % 2;
console.log(aaa.filter(isOdd)); // *[1, 5, 9]*
console.log(filterR(aaa, isOdd)); // *[1, 5, 9]*
好吧,我们成功实现了两个基本的高阶函数,使用了非常相似的递归函数。其他的呢?
其他高阶函数
从一开始,编写.reduce()
就有点棘手,因为你可以决定省略累加器的初始值。既然我们之前提到提供该值通常更好,那么我们在这里假设它会被给出;处理其他可能性也不会太难。
基本情况很简单:如果数组为空,结果就是累加器。否则,我们必须将 reduce 函数应用于当前元素和累加器,更新后者,然后继续处理数组的其余部分。这可能有点令人困惑,因为有三元运算符,但毕竟,我们已经看到了,应该足够清楚:
const reduceR = (orig, cb, accum) => {
const reduceLoop = (arr, i) => {
return arr.length == 0
? accum
: reduceR(
arr.slice(1),
cb,
!(0 in arr) ? accum : cb(accum, arr[0], i, orig),
i + 1,
orig
);
};
return reduceLoop(orig, 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()
特别适用于递归逻辑,因为你(尝试)找到某物的定义本身就是递归的:
-
你首先看你想到的地方——如果你找到了你要找的东西,你就完成了
-
或者,你可以看看其他地方,看看你所寻找的东西是否在那里
我们只缺少基本情况,但那很简单:如果你没有地方可以查找,那么你知道你在搜索中不会成功:
const findR = (arr, cb) => {
if (arr.length === 0) {
return undefined;
} else {
return cb(arr[0]) ? arr[0] : findR(arr.slice(1), cb);
}
};
同样地:
const findR2 = (arr, cb) =>
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
让我们完成我们的管道函数。管道的定义本身适合快速实现。
-
如果我们想要将单个函数串联起来,那么结果就是管道的结果
-
否则,如果我们想要将几个函数串联起来,那么我们必须先应用初始函数,然后将该结果作为输入传递给其他函数的管道
我们可以直接将这转化为代码:
const pipelineR = (first, ...rest) =>
rest.length == 0
? first
: (...args) => pipelineR(...rest)(first(...args));
我们可以验证它的正确性:
const plus1 = x => x + 1;
const by10 = x => x * 10;
pipelineR(
by10,
plus1,
plus1,
plus1,
by10,
plus1,
by10,
by10,
plus1,
plus1,
plus1
)(2);
// 23103
对于组合来说,做同样的事情很容易,只是你不能使用展开运算符来简化函数定义,而必须使用数组索引——自己解决吧!
搜索和回溯
寻找问题的解决方案,特别是当没有直接的算法,你必须诉诸反复试验时,递归特别适用。这些算法中的许多都属于这样的方案:
-
在众多可选项中,选择一个
-
如果没有其他选择,你就失败了
-
如果你能挑选一个,应用相同的算法,但找到其余部分的解决方案
-
如果你成功了,你就完成了
-
否则,尝试另一个选择
稍微变种一下,你也可以应用类似的逻辑来找到一个好的——或者可能是最优的——解决方案。每当你找到一个可能的解决方案时,你都会将其与之前可能找到的解决方案进行匹配,并决定保留哪一个。这可能会一直持续下去,直到所有可能的解决方案都被评估,或者直到找到足够好的解决方案为止。
有许多问题适用于这种逻辑:
-
找到迷宫的出口——选择任何路径,标记为已经跟随,并尝试找到迷宫的出口,不要重复使用该路径:如果成功,你就完成了,如果没有,回去选择另一条路径
-
填充数独谜题——如果一个空单元格只能包含一个数字,那么分配它;否则,运行所有可能的分配,并对每一个进行递归尝试,看看是否可以填充谜题的其余部分
-
下棋——你不太可能能够跟随所有可能的走法序列,所以你更愿意选择最佳估计的位置
让我们将这些技术应用于两个问题:解决八皇后问题和遍历完整的文件目录。
八皇后问题
八皇后问题是在 19 世纪发明的,需要在标准国际象棋棋盘上放置八个国际象棋皇后。特殊条件是没有皇后可以攻击另一个——这意味着没有一对皇后可以共享一行、一列或对角线。这个谜题可能要求任何解决方案,或者,正如我们将要做的那样,要求不同解决方案的总数。
这个谜题也可以推广到n 皇后,通过在nxn方格棋盘上工作。已知对于 n 的所有值都有解决方案,除了 n=2(很容易看出为什么:放置一个皇后后,整个棋盘都受到威胁)和 n=3(如果在中心放置一个皇后,整个棋盘都受到威胁,如果在一侧放置一个皇后,只有两个方块没有受到威胁--但它们互相威胁,这使得不可能在它们上面放置皇后)。
让我们从顶层逻辑开始解决我们的问题。由于给定的规则,每列中将有一个皇后,因此我们使用places()
数组来记录每个皇后在给定列中的行。SIZE
常量可以修改以解决更一般的问题。我们将在solutions
变量中计算每个找到的皇后分布。最后,finder()
函数将对解决方案进行递归搜索。
const SIZE = 8;
let places = Array(SIZE);
let solutions = 0;
finder();
console.log(`Solutions found: ${solutions}`);
当我们想在某一列的特定行放置一个皇后时,我们必须检查之前放置的任何一个皇后是否已经放在了同一行或对角线上。让我们编写一个checkPlace(column, row)
函数来验证是否可以安全地在给定方块中放置皇后。最直接的方法是使用.every()
,如下面的代码所示:
const checkPlace = (column, row) =>
places
.slice(0, column)
.every((v, i) => v !== row && Math.abs(v - row) !== column - i);
这种声明式的方式似乎是最好的:当我们在一个位置放置一个皇后时,我们希望确保每个先前放置的皇后都在不同的行和对角线上。递归解决方案也是可能的,所以让我们看看。我们怎么知道一个方块是安全的?
-
基本情况是:当没有更多的列可以检查时,方块是安全的
-
如果方块与任何其他皇后在同一行或对角线上,那么它是不安全的
-
如果我们已经检查了一列,并且没有问题,我们现在可以递归地检查下一列:
const checkPlace2 = (column, row) => {
const checkColumn = i => {
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,但这里并不重要)。
const finder = (column = 0) => {
if (column === SIZE) {
// *all columns tried out?*
console.log(places.map(x => x + 1)); // *print out solution*
solutions++; // *count it*
} else {
const testRowsInColumn = j => {
if (j < SIZE) {
if (checkPlace(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]
*...*
*... 84 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()
中相同的 Node.js 函数(来自第八章中的手动构建管道部分,连接函数-管道和组合),再加上一些函数,以便测试目录条目是否是符号链接(我们不会跟随它,以避免可能的无限循环),目录(这将需要递归列表)或普通文件:
const fs = require("fs");
const recursiveDir = path => {
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);
}
}
});
};
列表很长,但是正确的。我选择在我自己的 OpenSUSE 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 结构。我们可以从给定元素开始列出所有标签,使用基本相同的方法:我们列出一个节点,然后(通过应用相同的算法)列出它的所有子节点。基本情况也与以前相同:当一个节点没有子节点时,不再进行递归调用:
const traverseDom = (node, 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()
将其创建为一个真正的数组,然后编写一个更具声明性的解决方案:
const traverseDom2 = (node, depth = 0) => {
console.log(`${"| ".repeat(depth)}<${node.nodeName.toLowerCase()}>`);
Array.from(node.children).forEach(child =>
traverseDom2(child, depth + 1)
);
};
写[...node.children].forEach()
也可以工作,但我认为使用Array.from()
可以更清楚地告诉潜在的读者,我们试图从看起来像数组的东西中创建一个数组,但实际上并不是。
递归技术
虽然递归是一种非常好的技术,但由于实际实现中的细节,它可能会遇到一些问题。每个函数调用,无论是递归还是非递归,都需要在内部 JS 堆栈中有一个条目。当您使用递归时,每个递归调用本身都计为另一个调用,您可能会发现在某些情况下,由于多次调用而导致代码崩溃并抛出错误,因为内存耗尽。另一方面,对于大多数当前的 JS 引擎,您可能可以有数千个待处理的递归调用而没有问题(但对于早期浏览器和较小的机器,这个数字可能会下降到数百,甚至可能更低),因此可以说,目前您不太可能遇到任何特定的内存问题。
无论如何,让我们回顾一下问题,并讨论一些可能的解决方案,因为即使您可能无法真正应用它们,它们代表了有效的 FP 思想,您可能会在其他问题中找到它们的位置。
尾调用优化
递归调用何时不是递归调用?以这种方式提出问题可能没有多少意义,但有一个常见的优化--对于其他语言来说,不幸的是,但不适用于 JS!--可以解释答案。如果递归调用是函数将要执行的最后一件事,那么调用可以转换为简单地跳转到函数的开始,而无需创建新的堆栈条目。(为什么?不需要堆栈条目:在递归调用完成后,函数将没有其他事情要做,因此无需进一步保存进入函数时推入堆栈的任何元素。)原始堆栈条目将不再需要,可以简单地替换为新的堆栈条目,对应于最近的调用。
递归调用,作为典型的 FP 技术,被一个基本的命令式GO TO
语句实现,这可能被认为是一个终极的讽刺!
这些调用被称为尾调用(理由很明显),并且意味着更高的效率,不仅因为节省了堆栈空间,而且因为跳转比任何其他替代方案都要快得多。如果浏览器实现了这个增强功能,它就是在进行尾调用优化,简称 TCO。然而,查看kangax.github.io/compat-table/es6/
上的兼容性表,现在(2017 年中)唯一提供 TCO 的浏览器是 Safari。
图 9.3。要理解这个笑话,你必须事先理解它!
(注意:这张 XKCD 漫画可以在 https://xkcd.com/1270/上在线获取。)
有一个简单(虽然非标准)的测试,可以让您验证您的浏览器是否提供 TCO。(我在网上的几个地方找到了这段代码片段,但很抱歉我不能证明原作者。不过,我相信这是来自匈牙利的 Csaba Hellinger。)调用detectTCO()
可以让您知道您的浏览器是否使用 TCO:
"use strict";
function detectTCO() {
const outerStackLen = new Error().stack.length;
return (function inner() {
const innerStackLen = new Error().stack.length;
return innerStackLen <= outerStackLen;
})();
}
Error().stack
的结果不是 JS 标准,但现代浏览器支持它,尽管方式有些不同。无论如何,这个想法是,当一个名字很长的函数调用另一个名字较短的函数时,堆栈跟踪:
-
如果浏览器实现了 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 这样的数字运行它,您可能相当确定您的 JS 引擎正在执行 TCO!
function justLoop(n) {
n && justLoop(n - 1); // *until n is zero*
}
让我们用一个非常简短的测验来结束这一节,以确保我们理解了什么是尾调用。我们在第一章中看到的阶乘函数中的递归调用是否是尾调用?
function fact(n) {
if (n === 0) {
return 1;
} else {
return n * fact(n - 1);
}
}
好好想想,因为答案很重要!您可能会倾向于肯定回答,但正确答案是不是。这有很好的理由,这是一个关键点:在递归调用完成之后,fact(n-1)
的值已经被计算出来,函数仍然有工作要做。(因此,递归调用实际上不是函数将要做的最后一件事。)如果您用等价的方式编写函数,您会更清楚地看到它:
function fact2(n) {
if (n === 0) {
return 1;
} else {
const aux = fact2(n - 1);
return n * aux;
}
}
所以...这一节的要点应该有两个:TCO 通常不被浏览器支持,即使支持,如果您的调用不是实际的尾调用,您也可能无法利用它。既然我们知道问题所在,让我们看看一些 FP 解决方法!
继续传递风格
如果我们的递归调用堆栈太高,我们已经知道我们的逻辑会失败。另一方面,我们知道尾调用应该缓解这个问题...但是,由于浏览器的实现,它并没有,但是有一种解决方法。让我们首先考虑如何将递归调用转换为尾调用,使用一个众所周知的 FP 概念,continuations,并且我们将在下一节解决 TCO 限制的问题。(我们在第三章的回调,承诺和 continuations部分提到了 continuations,但当时我们没有详细讨论。)
在 FP 术语中,continuation是表示进程状态并允许处理继续的东西。这可能太抽象了,所以让我们为我们的需求实际一些。关键思想是,当你调用一个函数时,你也会提供一个继续函数(实际上是一个简单的函数),它将在返回时被调用。
让我们看一个简单的例子。假设你有一个返回当天时间的函数,并且你想在控制台上显示出来。通常的做法可能如下:
function getTime() {
return new Date().toTimeString();
}
console.log(getTime()); // *"21:00:24 GMT+0530 (IST)"*
如果你正在使用 CPS(Continuation Passing Style),你会将一个继续函数传递给getTime()
函数。函数不会返回计算出的值,而是会调用继续函数,将值作为参数传递给它:
function getTime2(cont) {
return cont(new Date().toTimeString());
}
getTime2(console.log); // *similar result as above*
有什么不同?关键在于我们可以应用这种机制将递归调用转换为尾调用,因为所有之后的代码都将在递归调用本身中提供。为了澄清这一点,让我们重新看一下阶乘函数,在明确表示我们没有进行尾调用的版本中:
function fact2(n) {
if (n === 0) {
return 1;
} else {
const aux = fact2(n - 1);
return n * aux;
}
}
我们将为函数添加一个新的参数,用于继续函数。对于fact(n-1)
调用的结果我们该怎么办?我们将它乘以n
,所以让我们提供一个将这样做的继续函数。我将阶乘函数重命名为factC()
,以明确表示我们正在使用继续函数:
function factC(n, cont) {
if (n === 0) {
return cont(1);
} else {
return factC(n - 1, x => cont(n * x));
}
}
我们如何得到最终结果?很简单:我们可以用一个继续函数调用factC()
,这个继续函数将返回它所给出的任何东西:
factC(7, x => x); // *5040, correctly*
在 FP 中,一个返回其参数作为结果的函数通常被称为identity()
,原因是显而易见的。在组合逻辑中(我们不会使用),我们会谈到I组合子。
你能理解它是如何工作的吗?那么我们来看一个更复杂的例子,使用斐波那契函数,其中有两个递归调用:
const fibC = (n, cont) => {
if (n <= 1) {
return cont(n);
} else {
return fibC(n - 2, p => fibC(n - 1, q => cont(p + q)));
}
};
这更加棘手:我们用n-2
调用fibC()
,并且一个继续函数表示无论那个调用返回了什么,然后调用fibC()
用n-1
,当那个调用返回时,然后将这两个调用的结果相加并将结果传递给原始的继续函数。
让我们再看一个例子,涉及一个未定义数量的递归调用的循环,到那时,你应该对如何将 CPS 应用到你的代码有一些想法--尽管我愿意承认,它可能变得非常复杂!我们在本章的遍历树结构部分中已经看到了这个函数。这个想法是打印出 DOM 结构,就像这样:
<body>
| <script>
| <div>
| | <div>
| | | <a>
| | | <div>
| | | | <ul>
| | | | | <li>
| | | | | | <a>
| | | | | | | <div>
| | | | | | | | <div>
| | | | | | | <div>
| | | | | | | | <br>
| | | | | | | <div>
| | | | | | <ul>
| | | | | | | <li>
| | | | | | | | <a>
| | | | | | | <li>
*...etc!*
我们最终设计的函数如下:
const traverseDom2 = (node, depth = 0) => {
console.log(`${"| ".repeat(depth)}<${node.nodeName.toLowerCase()}>`);
Array.from(node.children).forEach(child =>
traverseDom2(child, depth + 1)
);
};
让我们从完全递归开始,摆脱forEach()
循环。我们之前已经看过这种技术,所以我们可以直接转向结果:
var traverseDom3 = (node, depth = 0) => {
console.log(`${"| ".repeat(depth)}<${node.nodeName.toLowerCase()}>`);
const traverseChildren = (children, i = 0) => {
if (i < children.length) {
traverseDom3(children[i], depth + 1);
return traverseChildren(children, i + 1); // loop
}
return;
};
return traverseChildren(Array.from(node.children));
};
现在,我们需要给traverseDom3()
添加一个继续函数。与之前的情况唯一的区别是这个函数不返回任何东西,所以我们不会给继续函数传递任何参数。另外,重要的是要记住traverseChildren()
循环结束时的隐式return
:我们必须调用继续函数:
var traverseDom3C = (node, depth = 0, cont = () => {}) => {
console.log(`${"| ".repeat(depth)}<${node.nodeName.toLowerCase()}>`);
const traverseChildren = (children, i = 0) => {
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
对于我们问题的最后一个关键点,我们必须考虑问题的原因。每个待处理的递归调用都会创建一个新的堆栈条目。每当堆栈变得太空,程序就会崩溃,你的算法也就结束了。因此,如果我们能找到一种避免堆栈增长的方法,我们就可以自由了。在这种情况下,解决方案相当响亮,需要 thunks 和一个跳板--让我们看看这些是什么!
首先,thunk真的很简单:它只是一个无参数的函数(所以,没有参数),它有助于延迟计算,提供了一种惰性评估的形式。如果你有一个 thunk,除非你调用它,否则你不会得到它的值。例如,如果你想要以 ISO 格式获取当前日期和时间,你可以用new Date().toISOString()
得到它。然而,如果你提供一个计算它的 thunk,你在实际调用它之前不会得到值。
const getIsoDateAndTime = () => new Date().toISOString(); // a thunk
const isoDateAndTime = getIsoDateAndTime(); // getting the thunk's value
这有什么用呢?递归的问题在于一个函数调用它自己,然后调用它自己,然后调用它自己,依此类推,直到堆栈溢出。我们不是直接调用它自己,而是让函数返回一个 thunk——当执行时,实际上会递归调用函数。所以,堆栈不会越来越多地增长,它实际上会相当平坦,因为函数永远不会真正调用它自己——当你调用函数时,堆栈会增加一个位置,然后在函数返回它的 thunk 时,堆栈会恢复到原来的大小。
但是...谁来做递归呢?这就是蹦床的概念介入的地方。蹦床只是一个调用函数的循环,获取它的返回值,如果它是一个 thunk,那么它就调用它,所以递归将继续进行——但是以一种平坦、线性的方式!当 thunk 评估返回一个实际值时,循环退出,而不是一个新的函数。
const trampoline = (fn) => {
while (typeof fn === 'function') { fn = fn();
}
return fn;
};
我们如何将这个应用到一个实际的函数?让我们从一个简单的函数开始,它只是递归地求和从 1 到 n 的所有数字,但以一种保证会导致堆栈崩溃的方式。
const sumAll = n => (n == 0 ? 0 : n + sumAll(n - 1));
sumAll(10); // 55
sumAll(100); // 5050
sumAll(1000); // 500500
sumAll(10000); // ***Uncaught RangeError: Maximum call stack size exceeded***
堆栈问题将根据你的机器、内存大小等的不同,迟早会出现,但它肯定会出现。让我们以延续传递风格重写函数,这样它将变成尾递归。
const sumAllC = (n, cont) =>
n === 0 ? cont(0) : sumAllC(n - 1, v => cont(v + n));
sumAllC(10000, console.log); // *crash as earlier*
现在,让我们应用一个简单的规则:每当你要从一个调用中返回时,而不是返回一个 thunk,当执行时,它将执行你实际想要执行的调用。
const sumAllT = (n, cont) =>
n === 0 ? () => cont(0) : () => sumAllT(n - 1, v => () => cont(v + n));
每当应该调用一个函数时,我们现在返回一个 thunk。我们如何运行这个函数?这是缺失的细节。你需要一个初始调用,它将首次调用sumAllT()
,并且(除非函数是用零参数调用的)会立即返回一个 thunk。蹦床函数将调用 thunk,这将导致一个新的调用,依此类推,直到最终得到一个简单返回值的 thunk,然后计算将结束。
const sumAll2 = n => trampoline(sumAllT(n, x => x));
实际上,你可能不想要一个单独的sumAllT()
函数,所以你可以选择这样的方式:
const sumAll3 = n => {
const sumAllT = (n, cont) =>
n === 0
? () => cont(0)
: () => sumAllT(n - 1, v => () => cont(v + n));
return trampoline(sumAllT(n, x => x));
};
现在只剩下一个问题:如果我们递归函数的结果不是一个值,而是一个函数,我们该怎么办?问题在于trampoline()
代码,只要 thunk 评估的结果是一个函数,它就会一次又一次地返回。最简单的解决方案是返回一个 thunk,但包装在一个对象中:
function Thunk(fn) {
this.fn = fn;
}
var trampoline2 = thk => {
while (typeof thk === "object" && thk.constructor.name === "Thunk") {
thk = thk.fn();
}
return thk;
};
现在的区别在于,你不再返回一个 thunk,而是写成return (v) => new Thunk(() => cont(v+n)
,所以我们的新蹦床函数现在可以区分一个实际的 thunk(意味着要被调用和执行)和任何其他类型的结果(意味着要被返回)。
所以,如果你碰巧有一个非常复杂的算法,递归解决方案是最好的,但由于堆栈限制而无法运行,你可以通过一个合理的方式来修复它:
-
通过使用延续,将所有递归调用改为尾递归。
-
替换所有的返回语句,使它们返回 thunk。
-
用蹦床调用替换对原始函数的调用,以开始计算。
当然,这并不是免费的。你会注意到,当使用这种机制时,会有额外的工作涉及返回 thunk,评估它们,等等,所以你可以期待总时间增加。尽管如此,这是一个便宜的代价,如果另一种选择是有一个不能工作的问题解决方案!
递归消除
还有另一种可能性你可能想探索,但这超出了 FP 的范围,而是算法设计的范畴。计算机科学事实是,任何使用递归实现的算法都有一个不使用递归而完全依赖于堆栈的等价版本。有方法可以将递归算法系统地转换为迭代算法,因此,如果你耗尽了所有选项(意思是:甚至连 continuations 或 thunks 也无法帮助你),那么你将有最后的机会,通过用迭代替换所有递归。我们不会深入讨论它--正如我所说的,这种消除与 FP 关系不大--但重要的是要知道这个工具存在,你可能能够使用它。
问题
9.1. 逆转。你能以递归的方式编写一个reverse()
函数吗?显然,最好的方法是使用标准的 String.reverse()
方法,如developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reverse
中详细说明的,但这不适合作为递归问题的问题,是吗...?
9.2. 爬楼梯。假设你想要爬一个有n步的梯子。每次,你可以选择走 1 步或 2 步。你可以以多少种不同的方式爬上那个梯子?例如,你可以用五种不同的方式爬上一个有四步的梯子。
-
总是一次走一步
-
总是一次走两步
-
先走两步,然后一步,再一步
-
先走一步,然后两步,再走一步
-
先走一步,然后再一步,最后两步
9.3. 最长公共子序列。一个经典的动态规划问题如下:给定两个字符串,找到它们共同存在的最长子序列的长度。注意:我们将子序列定义为以相同相对顺序出现但不一定相邻的字符序列。例如,INTERNATIONAL 和 CONTRACTOR 的最长公共子序列是 N...T...R...A...T...O。尝试使用或不使用记忆化,看看有什么区别!
9.4. 对称皇后。在我们上面解决的八皇后问题中,只有一个解决方案显示了皇后的摆放对称性。你能修改你的算法找到它吗?
9.5. 递归排序。有许多可以用递归描述的排序算法;你能实现它们吗?
-
选择排序:找到数组的最大元素,移除它,递归地对剩下的部分进行排序,然后将最大元素推到排序好的剩余部分的末尾
-
插入排序:取数组的第一个元素;对剩下的部分进行排序;最后将移除的元素插入到排序好的剩余部分的正确位置
-
归并排序:将数组分成两部分;对每一部分进行排序;最后将两个排序好的部分合并成一个排序好的列表
9.6. 完成回调。在我们的findR()
函数中,我们没有为cb()
回调提供所有可能的参数。你能修复吗?你的解决方案应该沿用我们为map()
和其他函数所做的方式。
9.7. 递归逻辑。我们没有使用递归编写.every()
和.some()
:你能做到吗?
总结
在本章中,我们已经看到了如何使用递归,这是 FP 中的一种基本工具,作为一种强大的技术来创建算法,对于其他问题,可能需要更复杂的解决方案。我们首先考虑了什么是递归以及如何递归思考来解决问题,然后继续看到了不同领域中几个问题的递归解决方案,最后分析了深度递归可能出现的问题以及如何解决这些问题。
在第十章中,“确保纯净性 - 不可变性”,我们将回顾本书中早前提到的一个概念,即函数纯净性,并了解一些技术,这些技术将帮助我们确保函数不会产生任何副作用,通过确保参数和数据结构的不可变性。
第十章:确保纯度-不可变性
在第四章的行为良好-纯函数中,当我们考虑纯函数及其优点时,我们看到修改接收到的参数或全局变量等副作用经常导致不纯。现在,在处理 FP 的许多方面和工具的几章之后,让我们来看看不可变性的概念:如何以这样一种方式处理对象,使得意外修改它们变得更加困难,甚至更好的是不可能。
我们无法强迫开发人员以安全、受保护的方式工作,但如果我们找到某种方法使数据结构不可变(意味着除了通过一些永远不允许修改原始数据但产生新对象的接口之外,它们不能直接更改),那么我们将有一个可执行的解决方案。在本章中,我们将看到两种处理这种不可变对象和数据结构的不同方法:
-
基本的 JS 方法,如冻结对象,以及克隆来创建新对象,而不是修改现有对象
-
持久数据结构,具有允许更新它们而不更改原始数据且无需克隆所有内容的方法,以获得更高的性能
警告:本章中的代码不适合生产;我想专注于主要观点,而不是所有与属性、getter、setter、原型等有关的无数细节,这些细节应该考虑到一个完整、牢固的解决方案。对于实际开发,我非常建议使用第三方库,但在确认它确实适用于您的情况之后。我们将推荐几个这样的库,但当然还有许多其他库可供使用。
直接的 JS 方式
副作用的最大原因之一是函数可能修改全局对象或其参数本身。所有非原始对象都作为引用传递,因此当/如果您修改它们时,原始对象将被更改。如果我们想要阻止这种情况(而不仅仅依赖开发人员的善意和清洁编码),我们可能需要考虑一些直接的 JS 技术来禁止这些副作用。
修改器函数
意外问题的一个常见来源是几个 JS 方法实际上修改了底层对象。在这种情况下,仅仅使用它们就会导致副作用,甚至您可能都意识不到。数组是问题的基本来源,令人头痛的方法列表并不短。(有关每种方法的更多信息,请参见developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#Mutator_methods
。)
-
.copyWithin()
允许您在数组内复制元素 -
.fill()
用给定值填充数组 -
.push()
和.pop()
允许您在数组末尾添加或删除元素 -
.shift()
和.unshift()
以相同的方式工作,但在数组的开头 -
.splice()
允许您在数组中的任何位置添加或删除元素 -
.reverse()
和.sort()
在原地修改数组,颠倒其元素或对其进行排序
对于其中一些操作,您可能会生成数组的副本,然后使用它。在第四章的参数突变部分,行为良好-纯函数,我们就是用了展开运算符;我们也可以使用.slice()
:
const maxStrings2 = a => [...a].sort().pop();
const maxStrings3 = a => a.slice().sort().pop();
let countries = ["Argentina", "Uruguay", "Brasil", "Paraguay"];
console.log(maxStrings3(countries)); // *"Uruguay"*
console.log(countries); // *["Argentina", "Uruguay", "Brasil", "Paraguay"] - unchanged*
Setter 方法也是修改器,逻辑上会产生副作用,因为它们可以做任何事情。如果是这种情况,您将不得不选择稍后描述的其他解决方案之一。
常量
如果突变不是因为使用某些 JS 方法而发生的,那么我们可能希望尝试使用const
定义,但那只是行不通的。在 JS 中,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}
因此,如果您决定在任何地方都使用const
,那么您只能安全地防止对对象和数组的直接赋值。更为温和的副作用,例如更改属性或数组元素,仍然是可能的,因此这不是一个解决方案。
可以工作的是使用冻结来提供不可修改的结构和克隆来生成修改后的新结构。这可能不是禁止对象被更改的最佳方法,但可以用作权宜之计。让我们详细讨论一下这两种方法。
冻结
如果我们想要避免程序员意外或故意修改对象的可能性,冻结它是一个有效的解决方案。在对象被冻结之后,任何修改它的尝试都将悄无声息地失败。
const myObj = { d: 22, m: 9 };
Object.freeze(myObj);
myObj.d = 12; // *won't have effect...*
console.log(myObj);
// Object {d: 22, m: 9}
不要将冻结与密封混淆:Object.seal()
应用于对象,禁止向其添加或删除属性,因此对象的结构是不可变的,但属性本身可以更改。Object.freeze()
不仅包括密封属性,还使它们不可更改。有关更多信息,请参阅developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/seal
和developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze
。
这种解决方案只有一个问题:冻结对象是一个浅操作,它类似于const
声明,冻结属性本身。如果任何属性本身是对象或数组,并且具有进一步的对象或数组作为属性,依此类推,它们仍然可以被修改。在这里我们只考虑数据;您可能还想要冻结函数,但对于大多数用例,您想要保护的是数据。
let 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; // *wont' work*
myObj3.o.f.a = 9999; // *oops, does work!!*
console.log(myObj3);
// *{d:22, m:9, o:{c:"MVD", i:"UY", f:{ **a:9999** }}}*
如果我们想要实现对象的真正不可变性,我们需要编写一个冻结对象所有级别的例程。幸运的是,通过递归很容易实现这一点。主要的想法是首先冻结对象本身,然后递归地冻结其每个属性。我们必须确保只冻结对象自己的属性;例如,我们不应该干扰对象的原型:
const deepFreeze = obj => {
if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
Object.freeze(obj);
Object.getOwnPropertyNames(obj).forEach(prop =>
deepFreeze(obj[prop])
);
}
return obj;
};
请注意,与Object.freeze()
的工作方式相同,deepFreeze()
也会原地冻结对象。我希望保持操作的原始语义,因此返回的对象将始终是原始对象。如果我们想以更纯粹的方式工作,我们应该首先复制原始对象(我们将在下一节中看到如何做到这一点),然后再冻结它。
仍然存在一个小的可能问题,但结果非常糟糕:如果对象包含对自身的引用,那么会发生什么?如果我们跳过已经冻结的对象进行冻结,我们可以避免这种情况:因为对象所引用的对象已经被冻结,所以会忽略向后的循环引用。因此,我们编写的逻辑已经解决了这个问题,没有更多需要做的了!
如果我们对一个对象应用deepFreeze()
,我们可以安全地将其传递给任何函数,知道它根本不可能被修改。您还可以使用此属性来测试函数是否修改其参数:深度冻结它们,调用函数,如果函数依赖于修改其参数,它将无法工作,因为更改将被悄悄忽略。但是,那么,我们如何从函数中返回结果,如果它涉及到一个接收到的对象?这可以通过许多方式解决,一个简单的方法使用克隆,我们将看到。
在本章末尾的问题部分中,查看另一种通过代理冻结对象的方式。
克隆和变异。
如果不允许改变对象,则必须创建一个新对象。例如,如果你使用 Redux,reducer 是一个函数,它接收当前状态和一个动作(本质上是一个带有新数据的对象),并产生新状态。修改当前状态是完全禁止的,我们可以通过始终使用冻结对象来避免这种错误,就像我们在前一节中看到的那样。因此,为了满足 reducer 的要求,我们将需要能够克隆原始状态,然后根据接收到的动作进行相应的改变,然后得到的对象将成为新状态。
您可能希望重新查看第五章的更一般的循环部分,即声明式编程 - 更好的风格,在那里我们编写了一个基本的objCopy()
函数,提供了与此处所示的不同方法。
最后,我们还应该冻结返回的对象,就像我们对原始状态做的那样。但让我们从头开始:我们如何克隆一个对象?当然,你总是可以手工做,但当处理大型复杂对象时,这不是你真正想考虑的事情。
let oldObject = {
d: 22,
m: 9,
o: {c: "MVD", i: "UY", f: {a: 56}}
};
let newObject = {
d: oldObject.d,
m: oldObject.m,
o: {c: oldObject.o.c, i: oldObject.o.i, f: {a: oldObject.o.f.a}}
};
现在,寻找更自动化的解决方案,有几种简单的 JS 数组或对象复制方式,但它们都有相同的浅显性问题。
let newObject1 = Object.assign({}, myObj);
let newObject2 = {...myObj};
let myArray = [1, 2, 3, 4];
let newArray1 = myArray.slice();
let newArray2 = [...myArray];
如果一个对象或数组包含对象(它们自己可能包含对象,依此类推),我们会遇到与冻结相同的问题:对象是通过引用复制的,这意味着新对象的更改也将意味着更改旧对象。
let oldObject = {
d: 22,
m: 9,
o: { c: "MVD", i: "UY", f: { a: 56 } }
};
let newObject = Object.assign({}, oldObject);
newObject.d = 8888;
newObject.o.f.a = 9999;
console.log(newObject);
// {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!!*
有一个简单的解决方案,基于 JSON。如果我们stringify()
原始对象,然后parse()
结果,我们将得到一个新对象,但它与旧对象完全分离。
const jsonCopy = obj => JSON.parse(JSON.stringify(obj));
这适用于数组和对象,但无论如何都存在一个问题。如果对象的任何属性具有构造函数,它将不会被调用:结果将始终由普通 JS 对象组成。我们可以通过Date()
非常简单地看到这一点。
let myDate = new Date();
let newDate = jsonCopy(myDate);
console.log(typeof myDate, typeof newDate); // ***object string***
我们可以采用递归解决方案,就像深度冻结一样,逻辑是相当相似的。每当我们发现一个真正是对象的属性时,我们调用适当的构造函数。
const deepCopy = obj => {
let aux = obj;
if (obj && typeof obj === "object") {
aux = new obj.constructor();
Object.getOwnPropertyNames(obj).forEach(
prop => (aux[prop] = deepCopy(obj[prop]))
);
}
return aux;
};
这解决了我们在日期或者实际上任何对象中发现的问题!如果我们运行上面的代码,但使用deepCopy()
而不是jsonCopy()
,我们将得到object object
作为输出,这正是应该的。如果我们检查类型和构造函数,一切都将匹配。此外,数据更改实验现在也将正常工作。
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!*
现在我们知道如何复制一个对象,我们可以这样工作:
-
接收一个(冻结的)对象作为参数。
-
制作一个不会被冻结的副本。
-
从该副本中获取值,以在您的代码中使用。
-
随意修改副本。
-
冻结它。
-
将其作为函数的结果返回。
尽管有些麻烦,但所有这些都是可行的。因此,让我们添加一些函数,帮助将所有内容整合在一起。
获取器和设置器
在上一节末尾列出的所有工作中,每次你想要更新一个字段,都会变得麻烦,并容易出错。让我们添加一对函数,以便能够从冻结的对象中获取值,但解冻它们以便你可以使用,并允许修改对象的任何属性,创建它的新副本,这样原始对象就不会被实际修改。
获取属性
回到第六章中的从对象中获取属性部分,生成函数 - 高阶函数,我们编写了一个简单的getField()
函数,可以处理从对象中获取单个属性。
const getField = attr => obj => obj[attr];
我们可以通过组合一系列getField()
调用来从对象中获取深层属性,但这样做会相当麻烦。相反,让我们编写一个函数,它将接收一个路径 - 一个字段名称的数组 - 并返回对象的相应部分,如果路径不存在则返回 undefined。使用递归非常合适,简化了编码!
const getByPath = (arr, obj) => {
if (arr[0] in obj) {
return arr.length > 1
? getByPath(arr.slice(1), obj[arr[0]])
: deepCopy(obj[arr[0]]);
} else {
return undefined;
}
};
一旦对象被冻结,就无法解冻它,所以我们必须求助于制作它的新副本;deepCopy()
非常适合这个任务。让我们尝试一下我们的新函数:
let myObj3 = {
d: 22,
m: 9,
o: {c: "MVD", i: "UY", f: {a: 56}}
};
deepFreeze(myObj3);
console.log(getByPath(["d"], myObj3)); // 22
console.log(getByPath(["o"], myObj3)); // {c: "MVD", i: "UY", f: {a: 56}}
console.log(getByPath(["o", "c"], myObj3)); // "MVD"
console.log(getByPath(["o", "f", "a"], myObj3)); // 56
我们还可以检查返回的对象是否被冻结。
let fObj = getByPath(["o", "f"], myObj3);
console.log(fObj); // {a: 56}
fObj.a = 9999;
console.log(fObj); // {a: 9999} *-- it's not frozen*
按路径设置属性
现在我们写了这个,我们可以编写一个类似的setByPath()
函数,它将接受一个路径、一个值和一个对象,并更新一个对象。
const setByPath = (arr, value, obj) => {
if (!(arr[0] in obj)) {
obj[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]] = value;
return obj;
}
};
我们在这里使用递归来进入对象,如果需要的话创建新属性,直到我们遍历完路径的全部长度。一个重要的细节是,在创建属性时,我们是否需要一个数组还是一个对象。我们可以通过检查路径中的下一个元素来确定:如果它是一个数字,那么我们需要一个数组;否则,一个对象就可以了。当我们到达路径的末尾时,我们简单地赋予新给定的值。
如果你喜欢这种做事情的方式,你应该看看seamless-immutable库,它正是以这种方式工作。名称中的seamless部分指的是你仍然可以使用正常的对象,尽管是冻结的!所以你可以使用.map()
、.reduce()
等方法。在github.com/rtfeldman/seamless-immutable
了解更多。
然后我们可以编写一个函数,它将能够接受一个冻结的对象,并更新其中的属性,返回一个新的,同样被冻结的对象。
const updateObject = (arr, obj, value) => {
let newObj = deepCopy(obj);
setByPath(arr, value, newObj);
return deepFreeze(newObj);
};
我们可以看看它是如何工作的:让我们对我们一直在使用的myObj3
对象运行几次更新。
let new1 = updateObject(["m"], myObj3, "sep");
// {d: 22, m: "sep", o: {c: "MVD", i: "UY", f: {a: 56}}};
let new2 =updateObject(["b"], myObj3, 220960);
// {d: 22, m: 9, o: {c: "MVD", i: "UY", f: {a: 56}}, b: 220960};
let new3 =updateObject(["o", "f", "a"], myObj3, 9999);
// {d: 22, m: 9, o: {c: "MVD", i: "UY", f: {a: 9999}}};
let new4 =updateObject(["o", "f", "j", "k", "l"], myObj3, "deep");
// {d: 22, m: 9, o: {c: "MVD", i: "UY", f: {a: 56, j: {k: "deep"}}}};
有了这一对函数,我们终于找到了保持不可变性的方法:
-
对象必须从一开始就被冻结
-
从对象中获取数据是通过
getByPath()
完成的 -
使用
updateObject()
来设置数据,它在内部使用setByPath()
如果你想看另一种使用 setter 和 getter 来实现对对象的功能访问和更新的方法,请查看 lenses,它由 Ramda 等库提供。Lenses 可以被看作是一种功能性的方式,不仅可以获取和设置变量,还可以以可组合的方式对其运行函数:一种某物,让你专注于数据结构的特定部分,访问它,并可能也改变它或对其应用函数。从ramdajs.com/docs/#lens.
开始了解更多。
持久数据结构
如果每次你想要改变数据结构中的某些东西,你都去改变它,你的代码将充满副作用。另一方面,每次复制完整的结构都是浪费时间和空间。有一种中间方法,使用持久数据结构,如果处理正确,可以让你在创建新结构的同时应用更改,以一种高效的方式。
使用列表
考虑一个简单的过程:假设你有一个列表,你想要向其中添加一个新元素。你会怎么做?我们可以假设每个节点都是一个NodeList
对象。
class ListNode {
constructor(value, 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。在持久的方式下删除第 3 个元素后的原始列表。
使用列表或其他结构始终可以解决数据持久性的问题。但是,现在让我们专注于对我们来说可能是最重要的工作:处理简单的 JS 对象。毕竟,所有的数据结构都是 JS 对象,所以如果我们可以处理任何对象,我们就可以处理其他结构。
更新对象
这种方法也可以应用于更常见的需求,比如修改一个对象。这对于 Redux 用户来说是一个非常好的主意:可以编写一个 reducer,它将接收旧状态作为参数,并生成一个带有最小必要更改的更新版本,而不会以任何方式改变原始状态。
想象你有一个如下的对象:
myObj = {
a: ...,
b: ...,
c: ...,
d: {
e: ...,
f: ...,
g: {
h: ...,
i: ...
}
}
};
如果你想修改myObj.d.f
,并且想以持久的方式进行,你将创建一个新对象,它将与之前的对象有几个共同的属性,但将为修改的属性定义新的属性。见图 10.6:
图 10.6。通过创建一个具有一些共享属性和一些新属性的新对象,以持久的方式编辑对象。
如果你想手动完成这个操作,你将不得不以非常繁琐的方式编写类似下面的内容。大多数属性都来自原始对象,但d
和d.f
是新的:
newObj = {
a: myObj.a,
b: myObj.b,
c: myObj.c,
d: {
e: myObj.d.e,
f: *the new value*,
g: myObj.d.g
}
};
我们在本章的早些时候已经看到了类似的代码,当时我们决定要编写一个克隆函数,但现在让我们选择一种不同类型的解决方案。事实上,这种更新可以自动完成。
const setIn = (arr, val, obj) => {
const newObj = Number.isInteger(arr[0]) ? [] : {};
Object.keys(obj).forEach(k => {
newObj[k] = k !== arr[0] ? obj[k] : null;
});
newObj[arr[0]] =
arr.length > 1 ? setIn(arr.slice(1), val, obj[arr[0]]) : val;
return newObj;
};
逻辑是递归的,但并不太复杂。首先,我们在当前级别确定我们需要什么样的对象:数组还是对象。然后,我们将所有属性从原始对象复制到新对象,除了我们正在更改的属性。最后,我们将该属性设置为给定值(如果我们已经完成了属性名称的路径),或者我们使用递归来深入复制。
注意参数的顺序:首先是路径,然后是值,最后是对象。我们应用了将最稳定的参数放在前面,最可变的参数放在最后的概念。如果你对这个函数进行柯里化,你可以将相同的路径应用到几个不同的值和对象上,如果你固定路径和值,你仍然可以使用不同的对象来使用该函数。
我们可以尝试这种逻辑。让我们从一个毫无意义的对象开始,但是有几个级别,甚至有一个对象数组,以增加变化。
let 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(myObj.d === myObj2.d); // *false*
console.log(myObj.d.f === myObj2.d.f); // *false*
console.log(myObj.d.g === myObj2.d.g); // *true*
底部的日志验证了算法是否正确运行:myObj2.d
是一个新对象,但myObj2.d.g
重用了myObj
中的值。
在第二个对象中进一步更新数组让我们也能测试逻辑在这些情况下是如何工作的。
let 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(myObj.d.j === myObj3.d.j); // *false*
console.log(myObj.d.j[0] === myObj3.d.j[0]); // *true*
console.log(myObj.d.j[1] === myObj3.d.j[1]); // *false*
console.log(myObj.d.j[2] === myObj3.d.j[2]); // *true*
我们可以将myObj.d.j
数组中的元素与新创建的对象中的元素进行比较,你会发现数组是一个新数组,但两个元素(没有更新的元素)仍然是与myObj
中相同的对象。
这显然还不够。我们的逻辑可以更新现有字段,甚至在没有的情况下添加它,但你还需要可能消除一些属性的可能性。通常库提供了更多的功能,但至少让我们来看看如何删除一个属性,以查看对象中的其他重要结构变化。
const deleteIn = (arr, obj) => {
const newObj = Number.isInteger(arr[0]) ? [] : {};
Object.keys(obj).forEach(k => {
if (k !== arr[0]) {
newObj[k] = obj[k];
}
});
if (arr.length > 1) {
newObj[arr[0]] = deleteIn(arr.slice(1), obj[arr[0]]);
}
return newObj;
};
这个逻辑类似于setIn()
的逻辑。不同之处在于我们并不总是将所有属性从原始对象复制到新对象:只有在我们还没有到达路径属性数组的末尾时才这样做。在更新后继续测试系列之后,我们得到了以下结果:
myObj4 = deleteIn(["d", "g"], myObj3);
myObj5 = deleteIn(["d", "j"], myObj4);
// {a: 111, b: 222, c: 333, d: {e: 444, f: 88888}};
有了这一对函数,我们可以管理持久对象的工作,以一种高效的方式进行更改、添加和删除,而不会不必要地创建新对象。
可能最著名的用于处理不可变对象的库是名为immutable.js的库,网址为facebook.github.io/immutable-js/
。唯一的弱点是其臭名昭著的晦涩文档。然而,对此有一个简单的解决方案:查看untangled.io/the-missing-immutable-js-manual/
上的The Missing Immutable.js Manual With All The Examples You’ll Ever Need,你就不会有任何麻烦了!
最后的警告
使用持久数据结构需要一些克隆,但你如何实现一个持久数组?如果你考虑一下,你会意识到,在这种情况下,除了在每次操作后克隆整个数组之外,没有其他办法。这意味着像更新数组中的元素这样的操作,它本来只需要基本恒定的时间,现在将需要与数组大小成比例的时间。
在算法复杂度方面,我们会说更新从 O(1)操作变为 O(n)操作。同样,访问一个元素可能会变成 O(log n)操作,其他操作也可能出现类似的减速,比如映射、减少等。
我们如何避免这种情况?没有简单的解决方案。例如,你可能会发现数组在内部被表示为二叉搜索树(甚至更复杂的数据结构),并且持久库提供了所需的接口,这样你仍然可以将其用作数组,而不会注意到内部的差异。
当使用这种类型的库时,具有不可变更新而无需克隆的优势可能部分地被一些操作所抵消,这些操作可能变得更慢。如果这成为应用程序的瓶颈,甚至可能需要改变实现不可变性的方式,甚至想出一些改变基本数据结构的方法来避免时间损失,或者至少将其最小化。
问题
10.1. 通过代理进行冻结。在第八章的链接函数 - 管道和组合部分,我们使用代理来获取操作,以便提供自动链接。通过使用代理进行设置和删除操作,您可以自行进行冻结(如果您不想设置对象的属性,而是宁愿抛出异常)。实现一个freezeByProxy(obj)
函数,将这个想法应用到禁止所有类型的更新(添加、修改或删除属性)的对象上。记得要递归地工作,以防一个对象具有其他对象作为属性!
10.2. 持久地插入到列表中。在使用列表部分,我们描述了一种算法如何以持久的方式向列表中添加一个新节点,通过创建一个新的列表,就像我们之前描述的那样。实现一个insertAfter(list, newKey, oldKey)
函数,它将创建一个新的列表,但在具有键oldKey
的节点之后添加一个具有键newKey
的新节点。您可以假设列表的节点是通过以下逻辑创建的:
class Node {
constructor(key, next = null) {
this.key = key;
this.next = next;
}
}
const node = (key, next) => new Node(key, next);
let c3 = node("G", node("B", node("F", node("A", node("C", node("E"))))));
总结
在本章中,我们已经看到了两种不同的方法(实际上是常见的不可变性库使用的方法),通过使用不可变对象和数据结构来避免副作用:一种是基于使用 JavaScript 的对象冻结加上一些特殊逻辑来克隆,另一种是应用持久数据结构的概念,其中的方法允许进行各种更新,而不会改变原始对象或需要完全克隆。
在第十一章实现设计模式 - 函数式方法中,我们将专注于面向对象程序员经常问的一个问题:设计模式在 FP 中如何使用?它们是否必需、可用或可用?它们是否仍然被实践,但关注点转移到了函数而不是对象?我们将通过几个示例来回答这些问题,展示它们在哪里以及如何它们与通常的 OOP 实践相等或不同。
第十一章:实现设计模式-函数式方法
在[第十章](383f5538-72cc-420a-ae77-896776c03f27.xhtml)中,我们看到了几种解决不同问题的函数式技术。然而,习惯于使用 OOP 的程序员可能会发现我们错过了一些众所周知的公式和解决方案,这些公式和解决方案在命令式编码中经常使用。由于设计模式是众所周知的,并且程序员可能已经了解它们在其他语言中的应用,因此重要的是看看如何进行函数实现。
在本章中,我们将考虑设计模式所暗示的解决方案,这些解决方案在面向对象编程中很常见,以便看到它们在 FP 中的等价物。特别是,我们将研究以下主题:
-
设计模式的概念及其适用范围
-
一些 OOP 标准模式以及在 FP 中我们有什么替代方案,如果需要的话。
-
与面向对象设计模式无关的 FP 设计模式讨论
什么是设计模式?
软件工程中最重要的书籍之一是《设计模式:可复用面向对象软件的元素》,1994 年,由 GoF(四人帮):Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides 编写。这本书介绍了大约两打不同的 OOP 模式,并被认为是计算机科学中非常重要的书籍。
模式实际上是建筑设计的概念,最初由建筑师克里斯托弗·亚历山大定义。
在软件术语中,设计模式是软件设计中通常出现的常见问题的一般适用的可重用解决方案。它不是特定的、完成的和编码的设计,而是一个可以解决许多情境中出现的给定问题的解决方案的描述(也使用了“模板”这个词)。鉴于它们的优势,设计模式本身是开发人员在不同类型的系统、编程语言和环境中使用的最佳实践。
这本书显然侧重于 OOP,并且其中的一些模式不能推荐或应用于 FP。其他模式是不必要的或无关的,因为 FP 语言已经为相应的 OOP 问题提供了标准解决方案。即使存在这种困难,由于大多数程序员已经接触过 OOP 设计模式,并且通常会尝试在其他上下文中(如 FP)应用它们,因此考虑原始问题,然后看看如何产生新的解决方案是有意义的。标准的基于对象的解决方案可能不适用,但问题仍然存在,因此看看如何解决它仍然是有效的。
通常用四个基本要素来描述模式:
-
用于描述问题、解决方案及其后果的简单、简短的名称。这个名称对于与同事交流、解释设计决策或描述特定实现是有用的。
-
模式适用的上下文:这意味着需要解决的特定情况,可能还需要满足一些额外条件。
-
列出解决特定情况所需的元素(类、对象、函数、关系等)的解决方案
-
如果应用模式,后果(结果和权衡)。您可能会从解决方案中获得一些收益,但它也可能意味着一些损失。
在本章中,我们将假设读者已经了解我们将描述和使用的设计模式,因此我们不会提供太多关于它们的细节。相反,我们将重点放在 FP 如何使问题变得无关(因为有一种明显的应用函数技术来解决它的方式)或以某种方式解决它。此外,我们不会涉及所有 GoF 模式;我们只会专注于那些应用 FP 更有趣的模式,从而带出与通常的 OOP 实现更多的差异。
设计模式类别
设计模式通常根据它们的焦点分为几个不同的类别。以下列表中的前三个是出现在原始 GoF 书中的模式,但还添加了更多的类别:
-
行为设计模式:这些与对象之间的交互和通信有关。与其关注对象如何创建或构建,关键是如何连接它们,以便它们在执行复杂任务时可以合作,最好以提供众所周知的优势的方式,例如减少耦合或增强内聚性。
-
创建设计模式:它们处理以适合当前问题的方式创建对象的方法,可能引导在几种替代对象之间进行选择,以便程序可以根据可能在编译时或运行时已知的参数以不同的方式工作。
-
结构设计模式:它们涉及对象的组成,从许多个体部分形成更大的结构,并实现对象之间的关系。一些模式意味着继承或接口的实现,而其他模式使用不同的机制,都旨在能够在运行时动态地改变对象组合的方式。
-
并发模式:它们与处理多线程编程有关。尽管函数式编程通常非常适合这样做(例如,由于缺少赋值和副作用),但由于我们使用 JavaScript,这些模式对我们来说并不是很相关。
-
架构模式:它们更加高层次,比我们列出的先前模式具有更广泛的范围,并提供了软件架构问题的一般解决方案。目前,我们不考虑这些问题,所以我们也不会处理这些问题。
耦合和内聚性是在面向对象编程流行之前就已经使用的术语;它们可以追溯到 60 年代末,当时 Larry Constantine 的《结构化设计》出版。前者衡量任何两个模块之间的相互依赖性,后者与模块的所有组件真正属于一起的程度有关。低耦合和高内聚性是软件设计的良好目标,因为它们意味着相关的事物是靠在一起的,而不相关的事物是分开的。
沿着这些线路,你也可以将设计模式分类为“对象模式”(涉及对象之间的动态关系)和“类模式”(处理类和子类之间的关系,这些关系在编译时静态定义)。我们不会过多地担心这种分类,因为我们的观点更多地与行为和函数有关,而不是类和对象。
正如我们之前提到的,我们现在可以清楚地观察到这些类别是严重面向面向对象编程的,并且前三个直接提到了对象。然而,不失一般性,我们将超越定义,记住我们试图解决的问题,然后探讨函数式编程的类似解决方案,即使不是与面向对象编程完全等价,也会以类似的方式解决相同的问题。
我们需要设计模式吗?
有一个有趣的观点认为,设计模式只是需要修补编程语言的缺陷。理由是,如果你可以用一种语言以简单、平凡的方式解决问题,那么你可能根本不需要设计模式。
无论如何,对于面向对象的开发人员来说,真正理解为什么函数式编程可以解决一些问题而无需进一步的工具是很有趣的。在下一节中,我们将考虑几种众所周知的设计模式,并看看为什么我们不需要它们,或者我们如何可以轻松地实现它们。事实上,我们在文本中已经应用了几种模式,所以我们也会指出这些例子。
然而,我们不会试图将所有设计模式都表达或转换成 FP 术语。例如,Singleton模式基本上需要一个单一的全局对象,这与函数式编程者习惯的一切都有点相悖。鉴于我们对 FP 的方法(还记得第一章初步部分的 SFP,Sorta Functional Programming吗?),我们也不会介意,如果需要 Singleton,我们可能会考虑使用它,即使 FP 没有合适的等价物。
最后,必须说一下,一个人的观点可能会影响什么被认为是模式,什么不是。对一些人来说可能是模式,对其他人来说可能被认为是微不足道的细节。我们会发现一些这样的情况,因为 FP 让我们以简单的方式解决一些特定问题,我们在之前的章节中已经看到了一些例子。
面向对象的设计模式
在本节中,我们将介绍一些 GoF 设计模式,检查它们是否与 FP 相关,并学习如何实现它们。当然,有一些设计模式没有 FP 解决方案。例如,没有 Singleton 的等价物,这意味着全局访问对象的外来概念。此外,虽然你可能不再需要面向对象的特定模式,但开发人员仍会以这些术语思考。最后,既然我们不是完全函数式,如果面向对象的模式适用,为什么不使用呢?
Façade 和 Adapter
在这两种模式中,让我们从Façade开始。这是为了解决为类或库的方法提供不同接口的问题。其想法是为系统提供一个新的接口,使其更易于使用。你可以说,Façade 提供了一个更好的控制面板来访问某些功能,为用户消除了困难。
Façade 还是 facade?原词是建筑术语,意思是建筑物的正面,来自法语。根据这个来源和ç的通常发音,它的发音大约是fuh-sahd。另一种拼写可能与键盘上国际字符的缺失有关,并提出了以下问题:你不应该把它读成faKade吗?你可以把这个问题看作是celtic的反面,celtic的发音是Keltic,用k音代替了s音。
我们要解决的主要问题是能够以更简单的方式使用外部代码(当然,如果是你的代码,你可以直接处理这些问题;我们必须假设你不能——或者不应该——尝试修改其他代码。例如,当你使用任何可在网上获得的库时,就会出现这种情况)。关键是实现一个自己的模块,提供更适合你需求的接口。你的代码将使用你的模块,而不会直接与原始代码交互。
假设你想要进行 Ajax 调用,你唯一的可能性是使用一些具有非常复杂接口的库。有了 ES8 的模块,你可以编写以下内容,使用一个想象中的复杂 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 things to convert params*
// *into the way that the hard library requires*
};
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*
现在,如果你需要进行GET
或POST
,而不是必须经历提供的复杂 Ajax 库的所有复杂性,你可以使用提供更简单工作方式的新 façade。开发人员只需import {getUrl, postUrl} from "simpleAjax"
,然后可以以更合理的方式工作。
然而,为什么我们要展示这段代码,虽然有趣,但并没有显示任何特定的 FP 方面?关键是,至少在浏览器中完全实现模块之前,隐式的内部方法是使用 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
};
})();
揭示模块名称的原因现在应该是显而易见的。由于 JS 中的作用域规则,simpleAjax
的唯一可见属性将是simpleAjax.getUrl
和simpleAjax.postUrl
;使用 IIFE 让我们以安全的方式实现模块(因此也实现了外观),使实现细节成为私有的。
现在,适配器模式类似,因为它也意味着定义一个新接口。然而,虽然外观为旧代码定义了一个新接口,但当您需要为新代码实现旧接口时,就会使用适配器,以便匹配您已经拥有的内容。如果您正在使用模块,很明显,对于外观有效的解决方案在这里也同样有效,因此我们不必深入研究它。
装饰器或包装器
装饰器模式(也称为包装器)在您希望以动态方式向对象添加额外的职责或功能时非常有用。让我们考虑一个简单的例子,我们将用一些 React 代码来说明。 (如果您不了解这个框架,不要担心;这个例子很容易理解)。假设我们想在屏幕上显示一些元素,并且出于调试目的,我们想在对象周围显示一个红色的细边框。您该如何做?
如果您使用面向对象编程,您可能需要创建一个具有扩展功能的新子类。对于这个特定的例子,您可能只需提供一些属性,其名称为一些 CSS 类,该类将提供所需的样式,但让我们将注意力集中在面向对象上;使用 CSS 并不总是解决这个软件设计问题,因此我们需要一个更通用的解决方案。新的子类将知道如何显示自己的边框,并且每当您想要对象的边框可见时,您将使用这个子类。
有了我们对高阶函数的经验,我们可以用包装的方式以不同的方式解决这个问题;将原始函数包装在另一个函数中,该函数将提供额外的功能。
请注意,在第六章的生成函数 - 高阶函数部分中,我们已经看到了一些包装的示例。例如,在该部分中,我们看到了如何包装函数以生成可以记录其输入和输出、提供时间信息,甚至记忆调用以避免未来延迟的新版本。在这种情况下,为了多样性,我们将这个概念应用于装饰一个可视组件,但原则仍然是相同的。
让我们定义一个简单的 React 组件,ListOfNames
,它可以显示一个标题和一个人员列表,对于后者,它将使用FullNameDisplay
组件。这些元素的代码如下片段所示:
class FullNameDisplay extends React.Component {
render() {
return (
<div>
First Name: <b>{this.props.first}</b>
<br />
Last Name: <b>{this.props.last}</b>
</div>
);
}
}
class ListOfNames extends React.Component {
render() {
return (
<div>
<h1>
{this.props.heading}
</h1>
<ul>
{this.props.people.map(v =>
<FullNameDisplay first={v.first} last={v.last} />
)}
</ul>
</div>
);
}
}
ListOfNames
组件使用映射来创建FullNameDisplay
组件,以显示每个人的数据。我们应用程序的完整逻辑可能如下:
import React from "react";
import ReactDOM from "react-dom";
class FullNameDisplay extends React.Component {
// *...as above...*
}
class ListOfNames extends React.Component {
// *...as above...*
}
const GANG_OF_FOUR = [
{first: "Erich", last: "Gamma"},
{first: "Richard", last: "Helm"},
{first: "Ralph", last: "Johnson"},
{first: "John", last: "Vlissides"}
];
ReactDOM.render(
<ListOfNames heading="GoF" people={GANG_OF_FOUR} />,
document.body
);
在现实生活中,您不会将每个组件的所有代码都放在同一个源代码文件中——您可能会有几个 CSS 文件。但是,对于我们的例子,将所有内容放在一个地方,并使用内联样式就足够了,所以请忍耐一下,并记住以下格言:说话容易做到难。
我们可以在codesandbox.io/
在线 React 沙箱中快速测试结果;如果您想要其他选项,请搜索react online sandbox。结果并不值得讨论,但我们现在对设计模式感兴趣,而不是 UI 设计;参考图 11.1:
图 11.1:我们组件的原始版本显示了一个(不值得一提)的名称列表
在 React 中,内联组件是用 JSX(内联 HTML 样式)编写的,实际上被编译为对象,稍后将其转换为 HTML 代码以进行显示。每当调用render()
方法时,它都会返回一组对象结构。因此,如果我们编写一个函数,该函数将以组件作为参数,并返回新的 JSX,这将是一个包装对象。在我们的情况下,我们希望在所需的边框内包装原始组件:
const makeVisible = component => {
return (
<div style={{border: "1px solid red"}}>
{component}
</div>
);
};
如果您愿意,您可以使此函数知道它是在开发模式下执行还是在生产模式下执行;在后一种情况下,它将简单地返回原始组件参数,而不做任何更改,但现在让我们不要担心这个。
现在我们必须更改ListOfNames
以使用包装组件:
class ListOfNames extends React.Component {
render() {
return (
<div>
<h1>
{this.props.title}
</h1>
<ul>
{this.props.people.map(v =>
makeVisible(
<FullNameDisplay
first={v.first}
last={v.last}
/>
)
)}
</ul>
</div>
);
}
}
代码的装饰版本按预期工作:现在ListOfNames
组件中的每个组件都包装在另一个组件中,该组件为它们添加所需的边框;请参阅图 11.2:
图 11.2:装饰的 ListOfNames 组件仍然没有太多可看的,但现在它显示了一个添加的边框
在早期的章节中,我们看到如何装饰一个函数,将其包装在另一个函数中,以便执行额外的代码并添加一些功能。现在,在这里,我们看到了如何应用相同风格的解决方案,以提供一个高阶组件(在 React 术语中称为)包装在额外的<div>
中,以提供一些视觉上的独特细节。
如果您使用过 Redux 和react-redux包,您可能会注意到后者的connect()
方法也是以相同方式的装饰器;它接收一个组件类,并返回一个新的、连接到存储的组件类,供您在表单中使用;有关更多详细信息,请参阅github.com/reactjs/react-redux
。
策略、模板和命令
策略模式适用于您希望能够通过更改执行其操作方式的方式来更改类、方法或函数的能力,可能是以动态方式。例如,GPS 应用程序可能希望在两个地点之间找到一条路线,但如果人是步行、骑自行车或开车,就应用不同的策略。在这种情况下,可能需要最快或最短的路线。问题是相同的,但根据给定条件,必须应用不同的算法。
顺便说一下,这听起来很熟悉吗?如果是这样,那是因为我们已经遇到过类似的问题。当我们想以不同的方式对一组字符串进行排序时,在第三章中,从函数开始 - 核心概念,我们需要一种方法来指定如何应用排序,或者等效地,如何比较两个给定的字符串并确定哪个应该先进行。根据语言的不同,我们必须应用不同的比较方法进行排序。
在尝试 FP 解决方案之前,让我们考虑更多实现我们的路由功能的方法。您可以通过编写足够大的代码来实现,该代码将接收声明要使用哪种算法以及起点和终点的参数。有了这些参数,函数可以执行 switch 或类似的操作来应用正确的路径查找逻辑。代码大致等同于以下片段:
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 函数(例如,直到 2014 年 Java 8 推出之前,Java 就是这种情况),则此问题的 OO 解决方案需要定义实现您可能想要的不同策略的类,创建一个适当的对象,并将其传递。
在 JS 中使用 FP,实现策略是微不足道的,而不是使用byMeans
这样的变量进行切换,您可以只是传递一个函数,该函数将实现所需的路径逻辑:
function findRoute(routeAlgorithm, fromPoint, toPoint) {
return routeAlgorithm(fromPoint, toPoint);
}
您仍然必须实现所有所需的策略(没有其他方法),并决定要传递给findRoute()
的函数,但现在该函数独立于路由逻辑,如果您想要添加新的路由算法,您不会触及findRoute()
。
如果考虑模板模式,不同之处在于策略允许您使用完全不同的方式来实现结果,而模板提供了一个总体算法(或模板),其中一些实现细节留给方法来指定。同样,您可以提供函数来实现策略模式;您也可以为模板模式提供函数。
最后,命令模式也受益于能够将函数作为参数传递。这种模式旨在将请求封装为对象,因此对于不同的请求,您有不同参数化的对象。鉴于我们可以简单地将函数作为参数传递给其他函数,因此不需要封闭对象。
我们还在《第三章》的A React+Redux reducer部分看到了这种模式的类似用法,从函数开始 - 核心概念。在那里,我们定义了一个表,其中每个条目都是在需要时调用的回调。我们可以直接说,命令模式只是作为回调工作的普通函数的面向对象替代。
其他模式
让我们通过简要介绍一些其他模式来结束本节,其中等价性可能不那么完美:
-
柯里化和部分应用(我们在第七章中看到,转换函数 - 柯里化和部分应用):这可以被视为函数的工厂的近似等价物。给定一个通用函数,您可以通过固定一个或多个参数来生成专门的情况,这本质上就是工厂所做的事情,当然,这是关于函数而不是对象。
-
声明性函数(例如
map()
或reduce()
):它们可以被视为Iterator模式的应用。容器元素的遍历与容器本身解耦。您还可以为不同的对象提供不同的map()
方法,因此可以遍历各种数据结构。 -
持久数据结构:如第十章中所述,确保纯度 - 不可变性,它们允许实现Memento模式。其核心思想是,给定一个对象,能够返回到先前的状态。正如我们所看到的,数据结构的每个更新版本都不会影响先前的版本,因此您可以轻松添加一个机制来提供任何先前的状态并回滚到它。
-
责任链模式:在这种模式中,可能存在可变数量的请求处理器,并且要处理的请求流可以使用
find()
来确定哪个是处理请求的处理器(所需的是接受请求的列表中的第一个),然后简单地执行所需的处理。
请记住开始时的警告:对于这些模式,与 FP 技术的匹配可能不像我们之前看到的那样完美,但是我们的目的是要表明有一些常见的 FP 模式可以应用,并且将产生与面向对象解决方案相同的结果,尽管具有不同的实现。
功能设计模式
在看过了几种面向对象设计模式之后,可能会认为说 FP 没有经过批准、官方或甚至远程普遍接受的类似模式列表是一种欺骗。然而,对于某些问题,存在标准的 FP 解决方案,这些解决方案本身可以被视为设计模式,并且我们已经在书中涵盖了大部分。
可能的模式清单有哪些候选者?让我们尝试准备一个--但请记住,这只是一个个人观点;另外,我承认我并不打算模仿通常的模式定义风格--我只会提到一个一般问题并提到 JS 中 FP 的解决方法,我也不会为这些模式力求取一个好听、简短、易记的名字:
- 使用 filter/map/reduce 处理集合:每当你需要处理数据集合时,使用声明式的高阶函数,如
filter()
、map()
和reduce()
,就像我们在第五章中看到的那样,声明式编程 - 更好的风格,是一种从问题中消除复杂性的方法(通常的MapReduce web 框架是这个概念的扩展,它允许在多个服务器之间进行分布式处理,即使实现和细节并不完全相同)。你不应该将循环和处理作为一个步骤来执行,而应该将问题看作一系列顺序应用的步骤,应用转换直到获得最终期望的结果。
JS 还包括迭代器,也就是通过集合的另一种循环方式。使用迭代器并不特别功能,但你可能想看看它们,因为它们可能能简化一些情况。在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols
了解更多。
- 使用 thunks 进行惰性求值:惰性求值的概念是在实际需要之前不进行任何计算。在一些编程语言中,这是内置的。然而,在 JS(以及大多数命令式语言)中,应用的是急切求值,也就是表达式在绑定到某个变量时立即求值(另一种说法是 JavaScript 是一种严格的编程语言,具有严格的范式,只有在所有参数都完全求值后才允许调用函数)。当你需要精确指定求值顺序时,这种求值是必需的,主要是因为这样的求值可能会产生副作用。在 FP 中,你可以通过传递一个可以执行而不是进行计算的 thunk(我们在第九章的Trampolines and Thunks部分中使用了 thunk,设计函数 - 递归)来延迟这种求值,这样每当实际值需要时,它将在那时计算,而不是更早。
你可能还想看看 JS 的生成器,这是另一种延迟求值的方式,尽管它与 FP 并没有特别的关系。在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator
了解更多关于生成器的信息。生成器和 promises 的组合被称为异步函数,这可能会引起你的兴趣;参考developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
。
-
不可变性的持久数据结构。拥有不可变的数据结构,就像我们在第十章中看到的那样,确保纯净 - 不可变性,在使用某些框架时是强制性的,而且一般来说是推荐的,因为它有助于推理程序或调试程序。(在本章的早些地方,我们还提到了备忘录面向对象模式可以以这种方式实现)。每当你需要表示结构化数据时,使用持久数据结构的 FP 解决方案在许多方面都有帮助。
-
用于检查和操作的包装值:如果直接使用变量或数据结构,您可能会随意修改它们(可能违反任何限制),或者在使用它们之前可能需要进行许多检查(例如在尝试访问相应对象之前验证值不为空)。这种模式的想法是将一个值包装在对象或函数中,这样就不可能进行直接操作,并且可以以更加功能化的方式进行管理检查。我们将在第十二章中更多地提到这一点,构建更好的容器-功能数据类型。
正如我们所说,FP 的力量在于,与其拥有几十种标准设计模式(这仅仅是在 GoF 书中;如果您阅读其他文本,列表会变得更长!),还没有一个标准或公认的功能模式列表。
问题
11.1. 装饰方法,未来的方式。在第六章中,生成函数-高阶函数,我们编写了一个装饰器来为任何函数启用日志记录。目前,方法装饰器正在考虑纳入 JavaScript 的即将推出的版本中:请参阅tc39.github.io/proposal-decorators/
(草案 2 意味着该功能很可能会被纳入标准,尽管可能会有一些添加或小的更改)。研究草案,看看是什么让下一个代码运行。
一些问题:您是否认为需要savedMethod
变量?为什么在分配新的descriptor.value
时使用function()
,而不是箭头函数?您能理解为什么要使用.bind()
吗?descriptor
是什么?
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*
11.2.使用 mixin 的装饰器:回到第一章的问题部分,成为功能性-几个问题,我们看到类是一等对象。利用这一点,完成以下addBar()
函数,它将向Foo
类添加一些 mixin,以便代码将如所示运行。创建的fooBar
对象应该有两个属性(.fooValue
和.barValue
)和两个方法(.doSomething()
和.doSomethingElse()
),它们只是显示一些文本和一个属性。
class Foo {
constructor(fooValue) {
this.fooValue = fooValue;
}
doSomething() {
console.log("something: foo... ", this.fooValue);
}
}
var addBar = BaseClass =>
/*
*your code goes here*
*/
;
var fooBar = new (addBar(Foo))(22, 9);
fooBar.doSomething(); // *something: foo... 22*
fooBar.somethingElse(); // *something else: bar... 9* console.log(Object.keys(fooBar)); // [*"fooValue", "barValue"*]
您能否包括第三个 mixin,addBazAndQux()
,以便addBazAndQux(addBar(Foo))
会向Foo
添加更多属性和方法?
总结
在本章中,我们已经从面向对象的思维方式和编码时使用的常规模式,过渡到了函数式编程风格,通过展示如何解决相同的基本问题,但比使用类和对象更容易。
在第十二章中,构建更好的容器-功能数据类型,我们将使用一系列功能编程概念,这将给您更多关于可以使用的工具的想法。我承诺这本书不会变得深奥理论,而更加实用,我们会尽量保持这种方式,即使其中一些概念可能看起来晦涩或遥远。
第十二章:构建更好的容器-函数式数据类型
在第十二章 以函数式方式实现设计模式 中,我们已经讨论了使用函数实现不同结果的许多方法,在本章中,我们将更深入地从函数式角度考虑数据类型。我们将考虑实际实现自己的数据类型的方法,其中包括几个功能,以帮助组合操作或确保纯度,因此您的 FP 编码实际上会变得更简单和更短。我们将涉及几个主题:
-
从函数式角度看数据类型,因为即使 JavaScript 不是一种类型化的语言,也需要更好地理解类型和函数
-
容器,包括函子和神秘的单子,以更好地结构化数据流
-
函数作为结构,我们将看到另一种使用函数表示数据类型的方式,其中还加入了不可变性
数据类型
即使 JavaScript 是一种动态语言,没有静态或显式的类型声明和控制,也不意味着您可以简单地忽略类型。即使语言不允许您指定变量或函数的类型,您仍然会--即使只是在脑海中--使用类型。现在让我们来看看如何指定类型的主题,这样我们至少会有一些优势:
-
即使您没有运行时数据类型检查,也有一些工具,比如 Facebook 的flow静态类型检查器或 Microsoft 的TypeScript语言,可以让您处理它
-
如果您计划从 JavaScript 转移到更多的函数式语言,比如Elm,这将有所帮助
-
它作为文档,让未来的开发人员了解他们必须传递给函数的参数的类型,以及它将返回的类型。例如,Ramda 库中的所有函数都是以这种方式记录的
-
这也将有助于后面的函数数据结构,在这一部分中,我们将研究一种处理结构的方法,某些方面类似于您在 Haskell 等完全函数语言中所做的事情。
如果您想了解我引用的工具,请访问flow.org/
了解 flow,www.typescriptlang.org/
了解 TypeScript,以及elm-lang.org/
了解 Elm。如果您直接想了解类型检查,相应的网页是flow.org/en/docs/types/functions/
,www.typescriptlang.org/docs/handbook/functions.html
,以及flow.org/en/docs/types/functions/
每当您阅读或使用函数时,您将不得不思考类型,考虑对这个或那个变量或属性的可能操作等。有类型声明将有所帮助,因此我们现在将开始考虑如何定义最重要的函数类型及其参数和结果。
函数的签名
函数的参数和结果的规范由签名给出。类型签名基于一个名为 Hindley-Milner 的类型系统,它影响了几种(最好是函数式)语言,包括 Haskell,尽管符号已经从原始论文中改变。这个系统甚至可以推断出不直接给出的类型;诸如 TypeScript 或 Flow 的工具也可以做到这种推断,因此开发人员不需要指定所有类型。与其去进行干燥、正式的解释关于编写正确签名的规则,我们不如通过例子来工作。我们只需要知道:
-
我们将把类型声明写成注释。
-
函数名首先写出,然后是
::
,可以读作是类型或具有类型。 -
可选的约束条件可能会跟在之后,使用双(粗)箭头
⇒
(或者如果你无法输入箭头,则使用基本 ASCII 风格的=>
)。 -
函数的输入类型在箭头后面,使用
→
(或者根据你的键盘使用->
)。 -
函数的结果类型最后出现。
请注意,除了这种普通的 JS 风格之外,Flow 和 TypeScript 都有自己的语法来指定类型签名。
现在我们可以开始一些例子:
// firstToUpper :: String → String
const firstToUpper = s => s[0].toUpperCase() + s.substr(1).toLowerCase();
// Math.random :: () → Number
这些都是简单的情况——注意签名;我们这里不关心实际的函数。第一个函数接收一个字符串作为参数,并返回一个新的字符串。第二个函数不接收参数(空括号表明如此),并返回一个浮点数。箭头表示函数。因此,我们可以将第一个签名解读为firstToUpper
是一个接收字符串并返回字符串的类型的函数,我们也可以类似地谈论受到诟病(在纯度方面)的Math.random()
函数,唯一的区别是它不接收参数。
我们看到了零个或一个参数的函数:那么多个参数的函数呢?对此有两个答案。如果我们在严格的函数式风格中工作,我们总是会进行柯里化(正如我们在第七章中看到的,转换函数 - 柯里化和部分应用),因此所有函数都是一元的。另一个解决方案是将参数类型的列表括在括号中。我们可以这样看待以下两种方式:
// sum3C :: Number → Number → Number → Number
const sum3C = curry((a, b, c) => a + b + c);
// sum3 :: (Number, Number, Number) → Number
const sum3 = (a, b, c) => a + b + c;
第一个签名也可以解读为:
// sum3C :: Number → (Number → (Number → (Number)))
当你记得柯里化的概念时,这是正确的。当你提供函数的第一个参数后,你会得到一个新的函数,它也期望一个参数,并返回一个第三个函数,当给定一个参数时,将产生最终结果。我们不会使用括号,因为我们总是假设从右到左进行分组。
现在,对于接收函数作为参数的高阶函数呢?map()
函数提出了一个问题:它可以处理任何类型的数组。此外,映射函数可以产生任何类型的结果。对于这些情况,我们可以指定通用类型,用小写字母表示:这些通用类型可以代表任何可能的类型。对于数组本身,我们使用方括号。因此,我们会有以下内容:
// map :: [a] → (a → b) → [b]
const map = curry((arr, fn) => arr.map(fn));
a和b代表相同类型是完全有效的,就像应用于数字数组的映射会产生另一个数字数组一样。关键是,原则上a和b可以代表不同的类型,这就是之前描述的内容。还要注意,如果我们不进行柯里化,签名将是([a], (a → b)) → [b]
,显示一个接收两个参数(类型为a的元素数组和从类型a到类型b的映射函数)并产生类型为b的元素数组作为结果的函数。鉴于此,我们可以以类似的方式写出以下内容:
// filter :: [a] → (a → Boolean) → [a]
const filter = curry((arr, fn) => arr.filter(fn));
还有一个大问题:reduce()
的签名是什么?一定要仔细阅读,看看你能否弄清楚为什么它是这样写的。你可能更喜欢将签名的第二部分看作((b, a) → b)
:
// reduce :: [a] → (b → a → b) → b → b
const reduce = curry((arr, fn, acc) => arr.reduce(fn, acc));
最后,如果你定义的是一个方法而不是一个函数,你会使用一个类似~>
的波浪箭头:
// String.repeat :: String ⇝ Number → String
其他类型选项
我们还缺少什么?让我们看看你可能会使用的其他选项。联合类型被定义为可能值的列表。例如,我们在第六章中的getField()
函数,生成函数 - 高阶函数,要么返回属性的值,要么返回 undefined。然后我们可以写出以下签名:
// getField :: String → attr → a | undefined
const getField = attr => obj => obj[attr];
我们还可以定义一个类型(联合类型或其他类型),然后在进一步的定义中使用它。例如,可以直接比较和排序的数据类型是数字、字符串和布尔值,因此我们可以写出以下定义:
// Sortable :: Number | String | Boolean
之后,我们可以指定比较函数可以根据可排序类型来定义...但要小心:这里存在一个隐藏的问题!
// compareFunction :: (Sortable, Sortable) → Number
实际上,这个定义并不太准确,因为实际上你可以比较任何类型,即使这并没有太多意义。然而,为了例子的完整性,请暂时忍耐!如果你想要回顾一下排序和比较函数,请参阅developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
。
最后的定义将允许编写一个函数,比如说,接收一个数字和一个布尔值:它并没有说这两种类型应该是相同的。然而,还是有办法的。如果对于某些数据类型有约束条件,你可以在实际签名之前表达它们,使用一个胖箭头:
// compareFunction :: Sortable a ⇒ (a, a) → Number
现在定义是正确的,因为所有相同类型的出现(在这种情况下,用相同的字母表示,a)必须完全相同。另一种选择,但需要更多的输入,是使用联合写出所有可能性:
// compareFunction ::
// ((Number, Number) | (String, String) | (Boolean, Boolean)) → Number
到目前为止,我们一直在使用标准类型定义。但是,当我们使用 JavaScript 时,我们必须考虑一些其他可能性,比如带有可选参数的函数,甚至带有不确定数量的参数。我们可以使用...
代表任意数量的参数,并添加?
来表示可选类型:
// unary :: ((b, ...) → a) → (b → a)
const unary = fn => (...args) => fn(args[0]);
我们在之前引用的同一章节中定义的unary()
高阶函数,它以任何函数作为参数,并返回一个一元函数作为其结果:我们可以表明原始函数可以接收任意数量的参数,但结果只使用第一个:
// parseInt :: (String, Number?) -> Number
parseInt()
函数提供了可选参数的示例:虽然强烈建议不要省略第二个参数(基数),但实际上可以跳过它。
查看github.com/fantasyland/fantasy-land/
和sanctuary.js.org/#types
以获取更正式的类型定义和描述,应用于 JavaScript。
从现在开始,在本章中,我们将经常为方法和函数添加签名。这不仅是为了让你习惯于它们,而且当我们开始深入研究更复杂的容器时,它将有助于理解我们正在处理的内容:有些情况可能很难理解!
容器
回到第五章,声明式编程-更好的风格,以及稍后的第八章,连接函数-管道和组合,我们看到能够将映射应用于数组的所有元素,甚至更好的是,能够链接一系列类似的操作,是生成更好、更易理解的代码的好方法。
然而,存在一个问题:.map()
方法(或等效的解方法,如第六章,生成函数-高阶函数)仅适用于数组,我们可能希望能够将映射和链接应用于其他数据类型。那么,我们该怎么办呢?
让我们考虑不同的做法,这将为我们提供更好的功能编码工具。基本上,解决这个问题只有两种可能的方法:我们可以为现有类型添加新的方法(尽管这将受到限制,因为我们只能将其应用于基本的 JS 类型),或者我们可以将类型包装在某种类型的容器中,这将允许映射和链接。
让我们首先扩展当前类型,然后转而使用包装器,这将使我们进入深层的功能领域,涉及到诸如函子和单子等实体。
扩展当前数据类型
如果我们想要将基本的 JS 数据类型添加映射,让我们首先考虑我们的选择:
-
对于
null
、undefined
和Symbol
,应用映射听起来并不太有趣 -
对于
Boolean
、Number
和String
数据类型,我们有一些有趣的可能性,因此我们可以检查其中一些 -
将映射应用于对象将是微不足道的:你只需要添加一个
.map()
方法,它必须返回一个新对象 -
最后,尽管不是基本数据类型,我们也可以考虑特殊情况,比如日期或函数,我们也可以添加
.map()
方法
与本书的其余部分一样,我们坚持使用纯 JS,但是你应该查看诸如 LoDash、Underscore 或 Ramda 之类的库,它们已经提供了我们在这里开发的功能。
在所有这些映射操作中,一个关键点应该是返回的值与原始值的类型完全相同:当我们使用Array.map()
时,结果也是一个数组,任何其他.map()
实现都必须遵循类似的考虑(你可以观察到生成的数组可能具有不同的元素类型,但它仍然是一个数组)。
我们能对布尔值做什么?首先,让我们接受布尔值不是容器,因此它们的行为方式与数组不同:显然,布尔值只能有一个布尔值,而数组可以包含任何类型的元素。然而,接受这种差异,我们可以扩展Boolean.prototype
(尽管,正如我已经提到的,这通常是不推荐的),通过向其添加一个新的.map()
方法,并确保映射函数返回的任何内容都转换为新的布尔值。对于后者,解决方案将是类似的:
// Boolean.map :: Boolean ⇝ (Boolean → a) → Boolean
Boolean.prototype.map = function(fn) {
return !!fn(this);
};
!!
运算符强制结果为布尔值:Boolean(fn(this))
也可以使用。这种解决方案也可以应用于数字和字符串:
// Number.map :: Number ⇝ (Number → a) → Number
Number.prototype.map = function(fn) {
return Number(fn(this));
};
// String.map :: String ⇝ (String → a) → String
**String.prototype.map** = function(fn) {
return **String(fn(this))**;
};
与布尔值一样,我们强制映射操作的结果为正确的数据类型。
最后,如果我们想将映射应用到一个函数,那意味着什么?映射一个函数应该产生一个函数。f.map(g)
的逻辑解释应该是首先应用f()
,然后将g()
应用于结果。因此,f.map(g)
应该与编写x => g(f(x))
或等效地pipe(f,g)
是相同的:
// Function.map :: (a → b) ⇝ (b → c) → (a → c)
Function.prototype.map = function(fn) {
return (...args) => fn(this(...args));
};
验证这是否有效很简单:
const plus1 = x => x + 1;
const by10 = y => 10 * y;
console.log(plus1.map(by10)(3));
// 40: first add 1 to 3, then multiply by 10
有了这个,我们对基本的 JS 类型可以做的事情就完成了——但是如果我们想将这个应用到其他数据类型,我们需要一个更通用的解决方案。我们希望能够将映射应用到任何类型的值上,为此,我们需要创建一些容器;让我们来做这个。
容器和函子
我们在上一节中所做的确实有效,并且可以无问题地使用。然而,我们希望考虑一个更通用的解决方案,可以应用于任何数据类型。由于 JS 中并非所有东西都提供所需的.map()
方法,我们将不得不扩展类型(就像我们在上一节中所做的那样),或者应用我们在第十一章中考虑过的设计模式,实现设计模式-函数式方法:用一个包装器包装我们的数据类型,该包装器将提供所需的map()
操作。
包装一个值:一个基本的容器
让我们暂停一下,考虑一下我们需要这个包装器。有两个基本要求:
-
我们必须有一个
.map()
方法 -
我们需要一种简单的方法来包装一个值
让我们创建一个基本的容器来开始——但我们需要做一些改变:
const VALUE = Symbol("Value");
class Container {
constructor(x) {
this[VALUE] = x;
}
map(fn) {
return fn(this[VALUE]);
}
}
一些基本的考虑:
-
我们希望能够将一些值存储在容器中,因此构造函数会处理这个问题
-
使用
Symbol
有助于隐藏字段:属性键不会显示在Object.keys()
中,也不会显示在for...in
或for...of
循环中,使它们更加不易干涉 -
我们需要能够
.map()
,因此提供了一个方法
我们的基本容器已经准备好了,但是我们可以为方便起见添加一些其他方法:
-
为了获取容器的值,我们可以使用
.map(x => x)
,但这对于更复杂的容器不起作用,所以让我们添加一个.valueOf()
方法来获取包含的值 -
能够列出一个容器肯定有助于调试:
.toString()
方法会派上用场 -
因为我们不需要一直写
new Container()
,我们可以添加一个静态的.of()
方法来完成相同的工作。
当在函数式编程世界中使用类来表示容器(以及后来的函子和单子)可能看起来像异端邪说或罪恶...但请记住我们不想教条主义,class
和extends
简化了我们的编码。同样,可以说你绝不能从容器中取出一个值--但是使用.valueOf()
有时太方便了,所以不会那么严格。我们的容器变成了这样:
class Container {
//
// *everything as above*
//
static of(x) {
return new Container(x);
}
toString() {
return `${this.constructor.name}(${this[VALUE]})`;
}
valueOf() {
return this[VALUE];
}
}
现在,我们可以使用这个容器来存储一个值,并且我们可以使用.map()
来对该值应用任何函数...但这与我们可以用变量做的事情并没有太大的不同!让我们再加强一点。
增强我们的容器:函子
我们想要包装值,那么map()
方法到底应该返回什么?如果我们想要能够链接操作,那么唯一合乎逻辑的答案是它应该返回一个新的包装对象。在真正的函数式风格中,当我们对包装值应用映射时,结果将是另一个包装值,我们可以继续使用它。
这个操作有时被称为fmap()
,代表函子映射,而不是.map()
。更改名称的原因是为了避免扩展.map()
的含义。但是,由于我们正在使用支持重用名称的语言,我们可以保留它。
我们可以扩展我们的Container
类来实现这个改变。.of()
方法将需要一个小改变:
class Functor extends Container {
static of(x) {
return new Functor(x);
}
map(fn) {
return Functor.of(fn(this[VALUE]));
}
}
有了这些属性,我们刚刚定义了范畴论中所谓的函子!(或者,如果你想变得更加技术化,是指向函子,因为有.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 a → f b
第一个函数of()
是最简单的:给定任何类型的值,它产生该类型的函子。接下来的两个也很容易理解:给定一个函子,toString()
总是返回一个字符串(毫无意外!),如果函子包含的值是某种给定类型,valueOf()
产生相同类型的结果。第三个map()
更有趣。给定一个接受类型为a的参数并产生类型为b的结果的函数,将其应用于包含类型为a的值的函子,产生包含类型为b的值的函子--这正是我们上面描述的。
目前,函子不允许或期望产生副作用、抛出异常或任何其他行为,除了产生一个包含的结果。它们的主要用途是提供一种操作值、对其应用操作、组合结果等的方式,而不改变原始值--在这个意义上,我们再次回到了不可变性。
你也可以将函子与承诺进行比较,至少在一个方面是如此。在函子中,你不直接作用于其值,而是使用.map()
应用函数——在承诺中,你也是这样做的,但是使用.then()
!事实上,还有更多的类比,我们很快就会看到。
然而,你可能会说这还不够,因为在正常的编程中,必须处理异常、未定义或空值等情况是非常常见的。因此,让我们开始看更多的函子示例,过一段时间,我们将进入单子的领域,进行更复杂的处理。所以,现在让我们进行一些实验!
使用 Maybe 处理丢失的值
编程中的一个常见问题是处理丢失的值。造成这种情况的可能原因有很多:Web 服务 Ajax 调用可能返回空结果,数据集可能为空,或者对象中可能缺少可选属性,等等。在正常的命令式方式中处理这种情况需要在各处添加if
语句或三元运算符,以捕获可能丢失的值,避免某种运行时错误。通过实现一个Maybe
函子,我们可以做得更好,以表示可能存在(或可能不存在)的值!我们将使用两个类,Just
(表示刚好有些值)和Nothing
,每个函子一个:
class Nothing extends Functor {
isNothing() {
return true;
}
toString() {
return "Nothing()";
}
map(fn) {
return this;
}
}
class Just extends Functor {
isNothing() {
return false;
}
map(fn) {
return Maybe.of(fn(this[VALUE]));
}
}
class Maybe extends Functor {
constructor(x) {
return x === undefined || x === null
? new Nothing()
: new Just(x);
}
static of(x) {
return new Maybe(x);
}
}
我们可以通过尝试将操作应用于有效值或丢失的值来快速验证这一点:
const plus1 = x => x + 1;
Maybe.of(2209).map(plus1).map(plus1).toString(); // *"Just(2211)"*
Maybe.of(null).map(plus1).map(plus1).toString(); // *"Nothing()"*
我们刚刚对Maybe.of(null)
值多次应用了plus1()
,完全没有错误。MayBe
函子可以处理映射丢失的值,只需跳过操作,并返回一个包装的null
值。这意味着这个函子基本上包括了一个抽象的检查,不会让错误发生。让我们举一个更现实的例子来说明它的用法。
在本章后面,我们将看到 Maybe 实际上可以是一个单子,而不是一个函子,并且我们还将研究更多的单子示例。
假设我们正在 Node 中编写一个小的服务器端服务,并且我们想要获取某个城市的警报,并生成一个不太时尚的 HTML <table>
,假设它是某个服务器端生成的网页的一部分(是的,我知道你应该尽量避免在你的页面中使用表格,但我在这里想要的是一个 HTML 生成的简短示例,实际结果并不重要)。如果我们使用Dark Sky API(请参阅darksky.net/
了解更多关于此 API 的信息,并注册使用),来获取警报,我们的代码将是这样的;都很正常...请注意错误的回调;你将在下面的代码中看到原因:
const request = require("superagent");
const getAlerts = (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.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/cap/wwacapget.php?..."
}
]
};
我在飓风哈维逼近德克萨斯州的那一天获取了这些信息。如果你在正常的一天调用 API,数据将完全排除alerts:[...]
部分。因此,我们可以使用Maybe
函子来处理接收到的数据,无论是否有警报,都不会出现任何问题:
const getField = attr => obj => obj[attr];
const os = require("os");
const produceAlertsTable = weatherObj =>
Maybe.of(weatherObj)
.map(getField("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>**
如果我们改为使用乌拉圭蒙得维的坐标调用getAlerts(-34.9, -54.60, ...)
,因为该城市没有警报,getField("alerts")
函数将返回undefined
——尽管所有后续的.map()
操作仍将被执行,但实际上没有任何操作,最终结果将是null
值。见图 12.1:
图 12.1。输出表格看起来并不起眼,但产生它的逻辑并不需要一个 if 语句。
我们在编写错误逻辑时也利用了这种行为。如果在调用服务时发生错误,我们仍然会调用原始回调来生成一个表,但提供一个空对象。即使这个结果是意外的,我们也会很安全,因为相同的保护措施会避免导致运行时错误。
作为最后的增强,我们可以添加一个.orElse()
方法,在没有值的情况下提供一个默认值:
class Maybe extends Functor {
//
// *everything as before...*
//
orElse(v) {
return this.isNothing() ? v : this.valueOf();
}
}
使用这种新方法而不是valueOf()
,如果尝试为某个地方获取警报,而那里没有警报,你将得到任何你想要的默认值。在我们之前引用的情况下,当尝试获取蒙得维的亚的警报时,我们现在将得到一个合适的结果,而不是一个null
值:
getAlerts(-34.9, -54.6, x =>
console.log(
produceAlertsTable(x).orElse("<span>No alerts today.</span>")
)
);
以这种方式工作,我们可以简化我们的编码,并避免对空值和其他类似情况进行许多测试。然而,我们可能想要超越这一点;例如,我们可能想知道为什么没有警报:是服务错误吗?还是正常情况?最后只得到一个null
是不够的,为了满足这些新的要求,我们需要向我们的函子添加一些东西,并进入单子的领域。
单子
单子在程序员中有着奇怪的名声。著名的开发者道格拉斯·克罗克福德曾经谈到过它们的“诅咒”,他认为一旦你终于理解了单子,你立刻就失去了向其他人解释它们的能力!另一方面,如果你决定回到基础,阅读一本像是工作数学家的范畴这样的书,作者是范畴论的创始人之一桑德斯·麦克莱恩,你可能会发现一个有些令人困惑的解释:X 中的单子只是 X 的自函子范畴中的幺半群,乘积 × 被自函子的组合所取代,单位集由恒等自函子取代。并不是太有启发性!
单子和函子之间的区别只是前者增加了一些额外的功能。让我们先看看新的要求,然后再考虑一些常见的有用的单子。与函子一样,我们将有一个基本的单子,你可以将其视为抽象版本,并且具体的单子类型,它们是具体的实现,旨在解决特定情况。
如果你想阅读关于函子、单子以及它们所有家族的精确和仔细的描述(但更倾向于理论方面,并且有大量的代数定义),你可以尝试一下 Fantasy Land 规范,网址是github.com/fantasyland/fantasy-land/
。不要说我们没有警告过你:该页面的另一个名称是代数 JavaScript 规范!
添加操作
让我们考虑一个简单的问题。假设你有以下一对函数,它们使用Maybe
函子工作:第一个函数尝试根据其键搜索某些东西(比如客户或产品,无论是什么),第二个函数尝试从中提取某些属性(我故意含糊其辞,因为问题与我们可能正在处理的任何对象或事物无关)。这两个函数产生Maybe
结果,以避免可能的错误。我们使用了一个模拟的搜索函数,只是为了帮助我们看到问题:对于偶数键,它返回虚假数据,对于奇数键,它会抛出异常。
const fakeSearchForSomething = key => {
if (key % 2 === 0) {
return {key, some: "whatever", other: "more data"};
} else {
throw new Error("Not found");
}
};
const findSomething = key => {
try {
const something = fakeSearchForSomething(key);
return Maybe.of(something);
} catch (e) {
return Maybe.of(null);
}
};
const getSome = something => Maybe.of(something.map(getField("some")));
const getSomeFromSomething = key => getSome(findSomething(key));
问题在哪里?问题在于getSome()
的输出是一个Maybe
值,它本身包含一个Maybe
值,所以我们想要的结果被双重包装了。
let xxx = getSomeFromSomething(2222).valueOf().valueOf(); // *"whatever"*
let yyy = getSomeFromSomething(9999).valueOf().valueOf(); // *null*
这个玩具问题中可以很容易地解决这个问题(只需在getSome()
中避免使用Maybe.of()
),但这种结果可能以更复杂的方式发生。例如,您可能正在构建一个Maybe
,其中一个属性恰好是一个Maybe
,如果在访问该属性时出现相同的情况:您最终会得到一些双重包装的值。
单子应该提供以下操作:
-
一个构造函数。
-
一个将值插入单子的函数:我们的
.of()
方法。 -
允许链接操作的函数:我们的
.map()
方法。 -
可以去除额外包装的函数:我们将其称为
.unwrap()
,它将解决我们之前的多重包装问题。有时它被称为.flatten()
。
我们还将有一个用于链接调用的函数,只是为了简化我们的编码,还有另一个用于应用函数的函数,但我们稍后再说。让我们看看实际的 JavaScript 代码中单子是什么样子的。数据类型规范非常类似于函子的规范,所以我们不会在这里重复它们:
class Monad extends Functor {
static of(x) {
return new Monad(x);
}
map(fn) {
return Monad.of(fn(this[VALUE]));
}
unwrap() {
const myValue = this[VALUE];
return myValue instanceof Container ? myValue.unwrap() : this;
}
}
我们使用递归来逐步去除包装,直到包装的值不再是一个容器。使用这种方法,我们可以轻松地避免双重包装:
const getSomeFromSomething = key => getSome(findSomething(key)).unwrap();
然而,这种问题可能会在不同的层面上重复出现。例如,如果我们正在进行一系列.map()
操作,任何中间结果都可能最终被双重包装。您可以很容易地通过记住在每个.map()
之后调用.unwrap()
来解决这个问题--请注意,即使实际上并不需要,您也可以这样做,因为在这种情况下,.unwrap()
的结果将是完全相同的对象(你能看出为什么吗?)。但我们可以做得更好!让我们定义一个.chain()
操作,它将为我们执行这两个操作(有时.chain()
被称为.flatMap()
):
class Monad extends Functor {
//
// *everything as before...*
//
chain(fn) {
return this.map(fn).unwrap();
}
}
只剩下一个操作。假设您有一个柯里化的函数,有两个参数;没有什么奇怪的!如果您将该函数提供给.map()
操作,会发生什么?
const add = x => y => x+y; // *or* curry((x,y) => x+y)
const something = **Monad.of(2).map(add)**;
某物会是什么?鉴于我们只提供了一个参数来添加,该应用的结果将是一个函数...不仅仅是任何函数,而是一个包装的函数!(由于函数是一级对象,逻辑上没有障碍将函数包装在单子中,对吧?)我们想对这样的函数做什么?为了能够将这个包装的函数应用到一个值上,我们需要一个新的方法:.ap()
。这个值可能是什么?在这种情况下,它可以是一个普通的数字,或者是由其他操作的结果作为单子包装的数字。由于我们总是可以将一个普通数字Map.of()
成一个包装数字,让我们让.ap()
使用一个单子作为它的参数:
class Monad extends Functor {
//
// *everything as earlier...*
//
ap(m) {
return m.map(this.valueOf());
}
}
有了这个,你就可以这样做:
const monad5 = something.ap(Monad.of(3)); // Monad(5)
现在,您可以使用单子来保存值或函数,并根据需要与其他单子和链接操作进行交互。因此,正如您所看到的,单子并没有什么大技巧,它们只是带有一些额外方法的函子。现在让我们看看如何将它们应用到我们的原始问题中,并以更好的方式处理错误。
处理替代方案 - Either 单子
知道一个值是否丢失在某些情况下可能足够了,但在其他情况下,您可能希望能够提供一个解释。如果我们使用一个不同的函子,它将接受两个可能的值,一个与问题、错误或失败相关联,另一个与正常执行或成功相关联,我们可以得到这样的解释:
-
一个左值,应该是 null,但如果存在,它代表某种特殊值(例如,错误消息或抛出的异常),它不能被映射
-
一个正确的值,它代表了函子的正常值,并且可以被映射
我们可以以与我们为Maybe
所做的类似的方式构造这个 monad(实际上,添加的操作使得Maybe
也可以扩展Monad
)。构造函数将接收左值和右值:如果左值存在,它将成为Either
monad 的值;否则将使用右值。由于我们为所有的 functors 提供了.of()
方法,我们也需要为Either
提供一个:
class Left extends Monad {
isLeft() {
return true;
}
map(fn) {
return this;
}
}
class Right extends Monad {
isLeft() {
return false;
}
map(fn) {
return Either.of(null, fn(this[VALUE]));
}
}
class Either extends Monad {
constructor(left, right) {
return right === undefined || right === null
? new Left(left)
: new Right(right);
}
static of(left, right) {
return new Either(left, right);
}
}
.map()
方法是关键。如果这个 functor 有一个left值,它将不会被进一步处理;在其他情况下,映射将被应用于right值,并且结果将被包装。现在,我们如何用这个来增强我们的代码呢?关键的想法是每个涉及的方法都返回一个Either
monad;.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 => {
return weatherObj
.chain(obj => {
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()
,所以多个包装器不会有问题。现在我们可以测试多种情况,并得到适当的结果--或者至少对于世界各地的当前天气情况是这样!
-
对于 TX 的 Houston,我们仍然得到一个 HTML 表格。
-
对于 UY 的 Montevideo,我们得到一条消息,说没有警报。
-
对于错误坐标的点,我们得知 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 monad。你的大部分代码可能涉及调用函数。让我们寻找一个更好的方法来实现这一点,通过这个 monad 的一个变体。
调用函数 - Try monad
如果我们调用可能抛出异常的函数,并且我们想以一种功能性的方式来做,我们可以使用Try monad,来封装函数的结果或异常。这个想法基本上与 Either monad 是一样的:唯一的区别在于构造函数,它接收一个函数,并调用它:
-
如果没有问题,返回的值将成为 monad 的右值
-
如果有异常,它将成为左值
class Try extends Either {
constructor(fn, msg) {
try {
return Either.of(null, fn());
} catch (e) {
return Either.of(msg || e, null);
}
}
static of(fn, msg) {
return new Try(fn, msg);
}
}
现在,我们可以调用任何函数,以一种良好的方式捕获异常。例如,我们一直在使用的getField()
函数,如果用空参数调用,就会崩溃:
// getField :: String → attr → a | undefined
const getField = attr => obj => obj[attr];
我们可以使用 Try monad 来重写它,这样它就可以与其他组合函数友好地协作:
const getField2 = attr => obj => Try.of(() => 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-ness!
意外的 Monads - Promises
让我们通过提及另一个你可能使用过的 monad 来完成 monads 的这一部分,尽管它有一个不同的名字:Promises!我们在本章的前面已经评论过,functors(记住,monads 是 functors)至少与 promises 有一些共同之处:使用方法来访问值。然而,这种类比更大!
-
Promise.resolve()
对应于Monad.of()
-- 如果你传递一个值给.resolve()
,你将得到一个解析为该值的 promise,如果你提供一个 promise,你将得到一个新的 promise,其值将是原始 promise 的值(有关更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve
)。这是一种解包行为! -
Promise.then()
代表Monad.map()
,也代表Monad.chain()
,鉴于前面提到的解包。 -
我们没有直接匹配
Monad.ap()
,但我们可以添加类似以下代码的东西:
Promise.prototype.ap = function(promise2) {
return this.then(x => promise2.map(x));
};
即使您选择现代的async
和await
功能,它们在内部也是基于承诺。此外,在某些情况下,您可能仍然需要Promise.race()
和Promise.all()
,因此您可能会继续使用承诺,即使选择完整的 ES8 编码。
这是本节的一个合适的结尾。之前,您已经发现常见的数组实际上是函子。现在,以同样的方式,就像莫里哀戏剧《市民绅士》中的角色若尔当先生发现他一生都在说散文一样,您现在知道自己已经在使用单子,即使不知道它!
函数作为数据结构
到目前为止,我们已经看到如何使用函数来处理其他函数,处理数据结构或创建数据类型。让我们通过展示函数实际上如何实现自己的数据类型来结束本章,成为一种容器。事实上,这是λ演算的一个基本理论点(如果您想了解更多,请查阅Church 编码和Scott 编码),因此我们很可能可以说我们已经回到了本书的起点,即函数式编程的起源!
Haskell 中的二叉树
考虑一个二叉树。这样的树可以是空的,也可以由一个节点(树的根)和两个子树组成:左二叉树和右二叉树。
在第九章中,设计函数 - 递归,我们使用了更一般的树结构,比如文件系统或浏览器 DOM 本身,这些结构允许一个节点有任意数量的子节点。在本节中,我们正在处理的树的特殊情况是,每个节点始终有两个子节点,尽管它们中的每一个都可能为空。这种差异似乎很小,但允许空子树是让您定义所有节点都是二进制的关键。
让我们用 Haskell 语言做一个离题。在这种语言中,我们可能会写出以下内容;a将是我们在节点中持有的任何值的类型:
data Tree a = Nil | Node a (Tree a) (Tree a)
在这种语言中,模式匹配经常用于编码。例如,我们可以定义一个empty
函数,如下所示:
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
空树不包含搜索的值。对于其他树,如果根与搜索的值匹配,我们就完成了。如果根大于搜索的值,则在左子树中搜索;否则,在右子树中搜索。
有一个重要的要点需要记住:对于这种数据类型,两种可能类型的联合,我们必须提供两个条件,并且将使用模式匹配来决定应用哪一个。记住这一点!
函数作为二叉树
我们能否用函数做类似的事情?答案是肯定的:我们将用函数本身来表示树(或任何其他结构) - 请注意:不是用一组函数处理的数据结构,也不是用一些方法的对象,而只是一个函数。此外,我们将得到一个功能性数据结构,100%不可变,如果更新会产生一个新的副本。而且,我们将在不使用对象的情况下完成所有这些操作;相反,闭包将提供所需的结果。
这怎么可能?我们将应用与本章前面所见类似的概念,因此该函数将充当容器,并且其结果将是其包含值的映射。让我们倒着走,首先展示如何使用新的数据类型,然后再去实现细节。
创建树将使用两个函数:EmptyTree()
和Tree(value, leftTree, rightTree)
。例如,创建图 12.2 中所示的树,将使用以下代码:
图 12.2 二叉搜索树,由以下代码创建。
const myTree = Tree(
22,
Tree(
9,
Tree(4, EmptyTree(), EmptyTree()),
Tree(12, EmptyTree(), EmptyTree())
),
Tree(
60,
Tree(56, EmptyTree(), EmptyTree()),
EmptyTree()
)
);
你如何使用这个结构?根据数据类型描述,每当你使用树时,你必须考虑两种情况:非空树或空树。在前面的代码中,myTree()
实际上是一个接收两个函数作为参数的函数,分别对应两种数据类型情况。第一个函数将以节点值和左右树作为参数调用,第二个函数将不接收参数。因此,要获取根,我们可以写如下内容:
const myRoot = myTree((value, left, right) => value, () => null);
如果我们处理的是非空树,我们期望调用第一个函数并将根的值作为结果。对于空树,应该调用第二个函数,然后返回一个null
值。
同样,如果我们想要计算树中有多少个节点,我们会写如下代码:
const treeCount = aTree => aTree(
(value, left, right) => 1 + treeCount(left) + treeCount(right),
() => 0
);
console.log(treeCount(myTree));
对于非空树,第一个函数将返回 1(对于根)加上根的子树的节点计数。对于空树,计数就是零。明白了吗?
现在我们可以展示Tree()
和EmptyTree()
函数:
const Tree = (value, left, right) => (destructure, __) =>
destructure(value, left, right);
const EmptyTree = () => (__, destructure) => destructure();
destructure()
函数是你将作为参数传递的函数(名称来自 JS 中的解构语句,它允许你将对象属性分隔为不同的变量)。你将需要提供这个函数的两个版本。如果树是非空的,将执行第一个函数;对于空树,将运行第二个函数(这模仿了 Haskell 代码中的case选择,只是我们将非空树的情况放在第一位,空树的情况放在最后)。__
变量只是作为占位符使用,表示一个被忽略的参数,但显示了假定有两个参数。
这可能很难理解,所以让我们看一些更多的例子。如果我们需要访问树的特定元素,我们有以下三个函数,其中一个(treeRoot()
)我们已经看到了--让我们在这里重复一下以完整起见:
const treeRoot = tree => tree((value, left, right) => value, () => null);
const treeLeft = tree => tree((value, left, right) => left, () => null);
const treeRight = tree => tree((value, left, right) => right, () => null);
访问结构的组件值的函数(或构造,用另一个术语)称为投影函数。我们不会使用这个术语,但你可能会在其他地方找到它。
我们如何判断一棵树是否为空?看看你是否能理解为什么这一行代码有效:
const treeIsEmpty = tree => tree(() => false, () => true);
让我们再看一些例子。例如,我们可以从树中构建一个对象,这有助于调试。我添加了逻辑以避免包含左侧或右侧的空子树,因此生成的对象会更短:
const treeToObject = tree =>
tree((value, left, right) => {
const leftBranch = treeToObject(left);
const rightBranch = treeToObject(right);
const result = { 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 版本保持一致):
const treeSearch = (findValue, tree) =>
tree(
(value, left, right) =>
findValue === value
? true
: findValue < value
? treeSearch(findValue, left)
: treeSearch(findValue, right),
() => false
);
最后,为了完成本节,让我们还包括如何向树中添加新节点。仔细研究代码,您会注意到当前树没有被修改,而是产生了一个新的树。当然,鉴于我们使用函数来表示我们的树数据类型,显然我们不能只修改旧结构:它默认是不可变的:
const treeInsert = (newValue, tree) =>
tree(
(value, left, right) =>
newValue <= value
? Tree(value, treeInsert(newValue, left), right)
: Tree(value, left, treeInsert(newValue, right)),
() => Tree(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.key
和value.key
以决定在哪里添加新节点。当然,如果两个键相等,我们将更改当前树的根节点:
const compare = (obj1, obj2) =>
obj1.key === obj2.key ? 0 : obj1.key < obj2.key ? -1 : 1;
const treeInsert2 = (comparator, newValue, tree) =>
tree(
(value, left, right) =>
comparator(newValue, value) === 0
? Tree(newValue, left, right)
: comparator(newValue, value) < 0
? Tree(
value,
treeInsert2(comparator, newValue, left),
right
)
: Tree(
value,
left,
treeInsert2(comparator, newValue, right)
),
() => Tree(newValue, EmptyTree(), EmptyTree())
);
我们还需要什么?当然,我们可以编写各种函数:删除节点,计算节点数,确定树的高度,比较两棵树等等。但是,为了获得更多的可用性,我们真的应该将结构转换为一个函子,通过实现map()
函数。幸运的是,使用递归,这被证明是很容易的:
const treeMap = (fn, tree) =>
tree(
(value, left, right) =>
Tree(fn(value), treeMap(fn, left), treeMap(fn, right)),
() => EmptyTree()
);
我们可以继续举更多的例子,但这不会改变我们从这项工作中得出的重要结论:
-
我们正在处理一个数据结构(一个递归的数据结构),并用一个函数来表示它
-
我们没有为数据使用任何外部变量或对象:而是使用闭包
-
数据结构本身满足我们在第十章确保纯度-不可变性中分析的所有要求,因为它是不可变的,所有更改总是产生新的结构
-
最后,树是一个函子,提供了所有相应的优势
因此,我们甚至看到了函数式编程的另一个应用--我们看到一个函数实际上可以成为一个结构,这并不是人们通常习惯的!
问题
12.1. 也许任务? 在第八章的问题部分,连接函数-管道和组合,一个问题涉及获取某人的待办任务,但考虑到错误或边界情况,比如所选的人可能根本不存在。重新做这个练习,但使用 Maybe 或 Either 单子来简化编码。
12.2. 扩展您的树。为了获得我们的函数式二叉搜索树的更完整的实现,实现以下函数:
-
计算树的高度--或者等效地,从根到任何其他节点的最大距离
-
按升序列出树的所有键
-
从树中删除一个键
12.3. 函数式列表。在与二叉树相同的精神下,实现函数式列表。由于列表被定义为空或一个节点(头部)后跟另一个列表(尾部),您可能希望从以下内容开始:
const List = (head, tail) => (destructure, __) =>
destructure(head, tail);
const EmptyList = () => (__, destructure) => destructure();
以下是一些简单的一行操作,让您开始:
const listHead = list => list((head, __) => head, () => null);
const listTail = list => list((__, tail) => tail, () => null);
const listIsEmpty = list => (() => false, () => true);
const listSize = list => list((head, tail) => 1 + listSize(tail),
() => 0);
您可以考虑进行以下操作:
-
将列表转换为数组,反之亦然
-
反转列表
-
将一个列表附加到另一个列表的末尾
-
连接两个列表
不要忘记listMap()
函数!此外,listReduce()
和listFilter()
函数会派上用场。
12.4. 代码缩短。我们提到treeSearch()
函数可以缩短 - 你能做到吗?是的,这更多是一个 JavaScript 问题,而不是一个功能性的问题,我并不是说更短的代码一定更好,但许多程序员似乎是这样认为的,所以了解这种风格是很好的,因为你可能会遇到它。
总结
在本章中,我们更接近理论,看到了如何从功能性的角度使用和实现数据类型。我们从定义函数签名的方式开始,以帮助理解后来遇到的多个操作所暗示的转换;然后,我们继续定义了几个容器,包括函子和单子,并看到它们如何用于增强函数组合,最后我们看到函数如何直接被自身使用,不需要额外的负担,来实现功能性数据结构。
到目前为止,在本书中我们已经看到了 JavaScript 的函数式编程的几个特性。我们从一些定义开始,到一个实际的例子,然后转向重要的考虑因素,如纯函数、避免副作用、不可变性、可测试性、通过函数连接和数据容器实现数据流的构建新函数,我们已经看到了很多概念,但我相信你能够将它们付诸实践,并开始编写更高质量的代码 - 试一试吧!