JavaScript函数式编程指南

978-7-115-46204-6
作者: [美] 路易斯·阿泰西奥(Luis Atencio)
译者: 欧阳继超屈鉴铭
编辑: 吴晋瑜

图书目录:

详情

本书共三部分内容。第一部分“函数式思想”是为第二部分的学习作铺垫的,这一部分引入了对函数式JavaScript的描述,从一些核心的函数式概念入手,介绍了纯函数、副作用以及声明式编程等函数式编程的主要支柱;第二部分“函数式基础”重点介绍函数式编程的核心技术,如函数链、柯里化、组合、Monad等;第三部分“函数式技能提升”则是介绍使用函数式编程解决现实问题的方法。

图书摘要

版权信息

书名:JavaScript函数式编程指南

ISBN:978-7-115-46204-6

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    [美]路易斯•阿泰西奥(Luis Atencio)

译    欧阳继超 屈鉴铭

责任编辑 吴晋瑜

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


Simplified Chinese translation copyright©2018 by Posts and Telecommunications Press

All rights reserved.

Functional Programming in JavaScript by Luis Atencio.

Copyright ©2016 by Manning Publications Co.

本书中文简体版由Manning Publications Co公司授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。

版权所有,侵权必究。


本书主要介绍如何通过ECMAScript 6将函数式编程技术应用于代码来降低代码的复杂性。

本书共三部分内容。第一部分“函数式思想”是为第二部分的学习作铺垫的,这一部分引入了对函数式JavaScript的描述,从一些核心的函数式概念入手,介绍了纯函数、副作用以及声明式编程等函数式编程的主要支柱;第二部分“函数式基础”重点介绍函数式编程的核心技术,如函数链、柯里化、组合、Monad等;第三部分“函数式技能提升”则是介绍使用函数式编程解决现实问题的方法。

本书循序渐进地将函数式编程的相关知识铺陈开来,以理论作铺垫,并辅以实例,旨在帮助读者更好地掌握这些内容。如果读者是对面向对象软件有一定的了解,且对现代Web应用程序挑战有一定认识的JavaScript开发人员,那么可以从中提升函数式编程技能。如果读者是函数式编程的初学者,那么可以将本书作为入门书籍仔细阅读,为今后的学习夯实基础。


在本科和研究生阶段,我的课程安排专注于面向对象设计,并将其作为软件系统规划与架构设计的唯一方法。像许多开发人员一样,我的职业生涯也是从编写面向对象代码开始的,并且基于该编程范式来构建整个系统。

在整个职业生涯中,我密切关注并学习编程语言,不仅是因为想要学习一些很酷的知识,也因为我对每种语言的设计决策和设计哲学都很感兴趣。新的语言会对如何解决软件问题提供不同的观点,新的范式可以达到相同的效果。虽然面向对象的方法仍然是软件设计的主流工作方式,但是学习函数式编程能够拓宽视野,因为该技术既能够单独使用,也可以与其他设计范例并用。

函数式编程已经存在多年。尽管我听说过Haskell、Lisp、Scheme以及近年流行的Scala、Clojure和F#在表现力方面以及高效的平台上拥有优势,但起初我对此并不是很关心。随着时间的流逝,即使是传统上一直被认为很啰嗦的语言Java,也具有了一些让代码更简洁的函数式特性。最终,这项不起眼的技术变得让我无法抵挡。更令人难以置信的是,JavaScript这种大家都当成面向对象的语言,也可以作为函数式语言来使用了。事实证明,这正是JavaScript更强大、更高效的使用方法。我花了很长时间才发现这一点,所以希望能通过本书让你也意识到这一点,如此一来,你的JavaScript代码就不会变得过于复杂。

作为开发人员,我学会了如何使用函数式编程原则来创建模块化、表达性强且易于理解和测试的代码。毫无疑问,作为一名软件工程师,函数式编程让我脱胎换骨,所以我想记录下这些经验,将其放到一本书中。于是,我联系了Manning出版社,打算以Dart编程语言为基础来编写这本函数式编程的书。当时我正在使用Dart,并认为如果将它与我的函数式背景相结合,会产生一个非常有趣的未知领域。因此,我拟定了一个写作方案,并在一个星期后与出版社的人进行了沟通——我了解到Manning正在寻找人写一本关于JavaScript函数式编程的书。因为JavaScript也是我非常痴迷的语言,所以我毫不犹豫地抓住了这个机会。通过这本书,我希望能帮助你提升这方面的技能,并为你的发展带来新的方向。


复杂性是一头需要驯服的巨兽,我们永远无法完全摆脱它,而它也将永远是软件开发的一部分。我曾尝试花费无数小时和无法估量的脑力试图了解一段特定的代码。函数式编程能够帮助你控制代码的复杂性,使其不会与代码库的大小成正比。我们正在编写越来越多的JavaScript代码。我们已经经历了小型客户端事件处理程序的构建、富客户端架构以及同构(服务器+客户端)JavaScript应用程序的实现。函数式编程不是一种工具,而是一种可以同时适用于任何环境的思维方式。

本书旨在说明如何通过ECMAScript 6将函数式编程技术应用于代码。本书以渐进、稳定的速度呈现,涵盖了函数式编程的理论和实践两个方面,还为高级读者提供了更多信息,以帮助他们深入了解一些更高级的概念。

本书分为三部分内容,指导读者学习从基础到函数式编程的更先进的应用。

第一部分“函数式思想”描绘了函数式JavaScript的高层次景观。它还讨论了如何像函数式程序员一样函数式地使用和思考JavaScript的核心。

第二部分“函数式基础”着重于核心函数式编程技术,包括函数链、柯里化、组合、Monad等。

第三部分“函数式技能提升”讨论了使用函数式编程解决现实世界挑战的优势。

本书是针对对面向对象软件有基本了解,以及对现代Web应用程序挑战具有一定认识的JavaScript开发人员编写的。JavaScript是一种无处不在的语言,如果你需要函数式编程的介绍,并喜欢熟悉的语法,那么完全可以充分利用本书,而不是去学习Haskell(如果想要以更轻松的方式入门Haskell,本书不是最好的资源,因为每种语言都有自己的特性,直接学习其实是最好的理解)。

本书通过对高阶函数、闭包、函数调用、组合以及新的JavaScript ES6特性(如lambda表达式、迭代器、生成器和Promise)的介绍,帮助初级和中级程序员提高他们的JavaScript技能。高级开发人员也将从中领略到Monad和响应式编程的解读,从而可以运用创新的方法,来完成处理事件驱动和异步代码的艰巨任务,并充分地使用JavaScript平台。

如果读者是初级或中级JavaScript开发人员,并且刚刚接触函数式编程,请从第1章读起。如果读者是一名高级JavaScript程序员,那么可以简要阅读第2章,然后从第3章的函数链和整体函数式设计读起。

函数式JavaScript的更高级用户通常已经理解纯函数、柯里化和组合,因此可以快速浏览第4章,并从第5章开始学习Functor与Monad。

本书中的代码示例使用ECMAScript 6 JavaScript编写,它可以在服务器(Node.js)或客户端上运行。一些示例需要IO和浏览器DOM API,但没有考虑浏览器的兼容性。期望读者已经有在HTML页面和控制台的基础级互动的经验。代码对浏览器没有特定要求。

本书大量使用了诸如Lodash.js、Ramda.js等函数式的JavaScript库。读者可以在附录中找到文档和安装信息。

本书包含大量用于展示函数式技术的代码清单,并在适当的情况下比较了命令式和函数式设计。读者可以在Manning官方网站和GitHub上找到所有代码示例。

本书中使用了以下约定:

粗体字用于引用重要术语。

Courier字体用于表示代码清单,以及元素和属性、方法名称、类、函数和其他编程工件。

代码清单中会有一些代码注释,以突出重要的概念。

Luis Atencio(@luijar)是美国佛罗里达州劳德代尔堡的Citrix Systems公司的一名软件工程师。他拥有计算机科学学士学位和硕士学位,现在使用JavaScript、Java和PHP平台进行全职开发和构建应用程序。Luis积极参与社区活动,并经常在当地的聚会和会议中发表演讲。他在luisatencio.net上发布关于软件工程的博客,并为杂志和DZone撰写文章,同时还是《RxJS in Action》的共同作者。

读者可免费访问由Manning出版社运营的专有网络论坛,可以在论坛对本书发表评论、询问技术问题,并从作者和其他用户那里获得帮助。注册后可在该页面获取如何进入论坛,如何寻求帮助以及论坛上的行为规则等信息。

Manning只提供读者之间以及读者和作者之间进行有意义的对话的平台。作者对其具体参与程度不承担任何责任,且作者在线的贡献是自愿的(而且是无偿的),因此我们建议读者尽可能提出一些具有挑战性的问题,以获得作者关注。只要本书在销,读者将一直可以访问作者在线论坛及所有讨论。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入勘误信息,单击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,将赠送给您异步社区的100积分(积分可用于在异步社区兑换优惠券、样书或奖品)。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


写书并不是一件容易的事,需要经过许多人不懈的合作,才能从一页页手稿汇聚成最终呈现到你面前的一本书。

Manning出版社编辑团队的工作非常出色,他们发挥了极其重要的作用,确保图书的质量达到了我们双方的预期。我发自内心地感谢他们每一个人。没有他们,这本书是不可能完成的。特别感谢Marjan Bace和Mike Stephens相信我能够胜任本书作者;感谢Marina Michaels给了我“地图”和“手电筒”,以引导我走出如迷宫般的写书的挑战;感谢Susan Conant给我上了如何编写技术书的第一课,让我的写作走上正轨;感谢Bert Bates给了我最初的创意火花,以及他在教授编程时的惊人见解;感谢团队的所有编辑和制作人员,包括Mary Piergies、Janet Vail、Kevin Sullivan、Tiffany Taylor、Katie Tennant、Dennis Dalinnik和许多在幕后工作的人。

非常感谢Aleksandar Dragosavljevic带领的技术审阅小组:Amy Teng、Andrew Meredith、Becky Huett、Daniel Lamb、David Barkol、Ed Griebel、Efran Cobisi、Ezra Simeloff、John Shea、Ken Fukuyama、Peter Edwards、Subhasis Ghosh、Tanner Slayton、Thorsten Szutzkus、Wilfredo Manrique、William E. Wheeler、Yiling Lu以及那些才华横溢的论坛贡献者。他们找出了各种技术错误、术语错误和文字差错,并给出了相应的建议。论坛的每一轮审查过程和每一个反馈都使得该手稿更加完美。

在技术方面,特别感谢担任本书技术编辑的Dean Iverson以及担任本书技术校对的Daniel Lamb。同时感谢Brian Hanafee,他对整本书进行了深入的评估。他们是我见过的最好的技术编辑。

最后感谢我的妻子一直以来的支持,感谢我的家人每天都在帮助我变得更好,感谢他们不计较我在写书的时候没有经常与他们联系。此外,感谢我的同事购买了早期版本的章节。我很荣幸能与这些了不起的人一起工作。


也许读者构建专业应用程序的大部分经验都与面向对象语言有关。读者可能通过阅读其他书籍、博客、论坛和杂志文章听说过函数式编程,但却从来没有编写过任何函数式代码。别担心,这正是笔者所想到的。笔者也曾在面向对象的环境中完成了大部分开发工作。编写函数式代码并不困难,但学会函数式的思考、放弃旧习惯才是真正的挑战。本书第一部分的主要目标是为第二部分和第三部分讨论的函数式技术奠定基础。

第1章讨论了什么是函数式编程,以及需要以什么样的心态来迎接它,同时还介绍了基于纯函数、不可变性、副作用和引用透明性等概念的一些重要技术。这些技术能够形成函数式代码的主干,并将帮助读者更轻松地走近函数式编程。此外,这也将成为后面章节中许多代码设计的指导原则。

第2章揭示了JavaScript作为函数式语言的另一面。由于Javascript是主流语言且广泛存在,因此这是一门理想的、可用来教授函数式编程的语言。如果读者不是一名高级JavaScript开发人员,本章将帮助你快速了解学习函数式JavaScript的必备基础,例如高阶函数、闭包和作用域规则。


本章内容

 

面向对象编程(OO)通过封装变化使得代码更易理解。

函数式编程(FP)通过最小化变化使得代码更易理解。

——Michael Feathers (Twitter)

 

如果你正在阅读这本书,那么很可能你已经是一名拥有面向对象或结构化设计工作经验的JavaScript软件开发人员,但你对函数式编程很感兴趣。或许你曾经尝试过学习它,但并不能在工作或个人项目中成功地应用它。这样的话,你的主要目标是增强开发技能,提高代码质量,那么本书可以帮助你实现这一目标。

Web平台的快速演进和浏览器的不断进化以及最重要的——用户的需求,给如今的Web应用的设计带来了意想不到的变化。人们期望Web应用给人的感觉应该更像本地的桌面应用,或是具有丰富且响应式的部件的移动应用。这样的期望自然而然地迫使JavaScript开发人员能够更广泛地去思考各种解决方案,并适时地采用那些可能提供最优解决方案的编程范式和最佳实践。

作为开发人员,我们总是更喜欢那些拥有简洁应用结构并可以增强软件扩展性的框架。然而代码库的复杂性仍然超出预期,这使得我们去重新审视这些代码的基本设计原则。此外,互联网对于JavaScript开发人员来说已今非昔比,因为今天的我们可以实现很多以前技术上不可行的东西了。我们可以用Node.js来编写大型的服务器端应用程序,还可以将大量的业务逻辑放到客户端去实现,使得服务端非常轻巧。这就需要与外部存储的交互、创建异步进程、处理事件,等等。

面向对象设计有助于解决一部分问题,但由于JavaScript是一种拥有很多共享状态的动态语言,用不了多久,代码就会积累足够的复杂性,变得笨拙而难以维护。面向对象设计的确能够一定程度地缓解这个问题,但我们需要的比缓解更多。也许最近几年你听说过响应式编程这个术语。这种编程范式有助于数据流的处理和变化的传递。而在处理JavaScript中的异步或事件响应时,这一点至关重要。总之,我们需要一个能够引发我们对数据及其交互的函数深入思考的编程范式。当考虑应用设计时,你应该问问自己是否遵从了以下的设计原则。

如果对于这些问题,你的回答是“是”或是“不知道”,那么本书就能够指导你提高生产效率。函数式编程就是你需要的编程范式。尽管函数式编程基于一些简单的概念,但它还需要你换一种思考问题的方式。函数式编程不是一种新工具或新的API,而是另一种解决问题的方式,一旦你了解了它的基本原则,所要解决的问题将变得很直观。

本章将解释函数式编程的概念,并告诉你它那么有用和重要的原因,以及让其发挥作用的方法。我们将了解不变性和纯函数的核心原则,探讨函数式编程的技术,以及这些技术能够怎样影响程序的设计。这些技术能够使你更加轻松地学习响应式编程,并解决复杂的JavaScript任务。但在了解这一切之前,你需要知道为什么函数式思维方式如此重要的,以及它如何帮助解决JavaScript程序的复杂性。

函数式编程的学习从未像今天这样重要。开发社区和各大软件公司都开始意识到使用函数式编程给其业务应用带来的好处。如今,大多数主流编程语言(如Scala、Java 8、F#、Python 和 JavaScript 等)都提供原生的或基于API的函数式支持。因此,行业对函数式编程技能的需求量很大,同时将在未来的几年不断增长。

在 JavaScript 的上下文中,函数式思想可以用来塑造令人难以置信的语言特性,帮助你编写干净的、模块化的、可测试的并且简洁的代码,使你在开发过程中更加高效。多年来,一个一直被忽略的事实是,JavaScript可以用函数式风格写得更加高效。部分原因是由于对JavaScript语言的整体理解偏差,另外也由于JavaScript缺乏一些能够妥当管理状态的原生结构——这种动态语言将管理状态的职责交给了开发人员(也是在程序中引入bug的原因之一)。这个问题并不会影响规模较小的脚本代码,但随着代码量的不断增长,会变得越来越难以控制。所以从某种程度上而言,我认为函数式编程能够在 JavaScript 中保护你不受该问题的影响。这个问题将在第2章进一步探讨。

编写函数式的 JavaScript 代码能够克服以上提到的大部分问题。通过使用一整套基于纯函数式的已被科学证明的技术与实践,即便复杂性日益提高,你也可以编写出易于推理和理解的代码。编写函数式的 JavaScript 是一件一举两得的事情,因为它不仅能够提高整个应用程序的质量,也能够更好地了解并精通 JavaScript 语言本身。

因为函数式编程是一种编写代码的方式,而不是一种框架或工具,函数式的思维方式与面向对象的思维方式完全不同。但如何迈向函数式呢?如何开始使用函数式去思考呢?一旦你掌握了它的本质,函数式编程将是直观的。摒弃旧习是最难的部分,对于一个有面向对象背景的人来说,将是一个巨大的编程范式转变。在学习如何使用函数式思考之前,首先你必须知道函数式编程到底是什么。

简单来说,函数式编程是一种强调以函数使用为主的软件开发风格。你可能会说,“就这样啊,我早就在日常的基本工作中使用函数了。有什么不一样么?”正如之前提到的,函数式编程需要你在思考解决问题的思路时有所变化。其实使用函数来获得结果并不重要,函数式编程的目标是使用函数来抽象作用在数据之上的控制流与操作,从而在系统中消除副作用减少对状态的改变。我知道这听起来很拗口,但我将在书中进一步逐个地解释这些贯穿全书的术语。

通常情况下,函数式编程一类的书都会以斐波那契数列的计算为例开始讲解,但我更愿意以一个在HTML页面上显示文字的简单 JavaScript 程序作为开始。还有什么例子比输出一句经典的“Hello World”更好的呢?

document.querySelector('#msg').innerHTML = '<h1>Hello World</h1>';

注意

 

就像之前提到的,函数式编程不是一种具体的工具,而是一种编写代码的方式。因此,你既可以用它来编写客户端(基于浏览器的)程序,也可以用它来编写服务器端的应用程序(如Node.js)。打开浏览器、直接输入一段代码,这应该是让JavaScript运行起来的最简单的方式,而这也是本书需要你准备的所有东西。

这个程序很简单,但因为所有代码都是写死的,所以不能动态地显示消息。如果想改变消息的格式、内容或者目标DOM元素,就需要重写整个表达式。也许你决定用一个函数来封装这段代码,用参数来表明可变的部分。这样就可以只定义一遍,并通过不同的参数配置来使用它:

function printMessage(elementId, format, message) {
     document.querySelector(`#${elementId}`).innerHTML =
         `<${format}>${message}</${format}>`;
}

printMessage('msg', 'h1','Hello World');

这样确实有所改进,但它仍然不是一段可重用的代码。假设要将文本写入文件,而非 HTML 页面。你要形成一种简单的思维过程,即在另一个层面来创建参数化的函数,其参数不再只是量值,也可以是可以提供更多功能的函数。函数式编程就像是给函数打了激素,唯一目的就是执行并组合各种函数来实现更强大的功能。先展示一下函数式解决该问题的部分代码,如清单1.1所示。

清单 1.1 函数式的printMessage

var printMessage = run(addToDom('msg'), h1, echo);

printMessage('Hello World');

毫无疑问,这段代码与之前的完全不同。首先,h1不再是一个量值了,它与 addToDomecho一样都是函数。这样看上去好像是用一些较小的函数构建了一个新的函数。

代码写成这样是有原因的。清单1.1将程序分解为一些更可重用、更可靠且更易于 理解的部分,再将它们组合起来,形成一个更易推理的程序整体。所有的函数式程序都遵循这一基本原则。从目前来看,要用一个神奇的函数run[1]来序列地调用一系列的函数,例如addToDomh1echo。后面会详细解释 run 函数。在后台,run 函数基本上是通过将一个函数的返回值作为下一个函数的输入这种方式将各个函数链接起来。这样,由 echo 返回的字符串“Hello World”被传递到 h1 中,而结果又最终被传递到 addToDom 里。

为什么函数式的解决方案是这样的呢?笔者更喜欢将其想成将代码本身参数化,这样以一种非侵入式的方式修改它 —— 例如修改一个算法的初始条件。基于这种方式,开发者可以轻松地增强 printMessage 来输出两遍文本,再换个 h2 的标题,最终将文本信息写入到控制台,而非 DOM 元素,而所有这些都无须重写任何内部的逻辑。代码如清单1.2所示。

清单1.2 扩展 printMessage

var printMessage = run(console.log, repeat(3), h2, echo);

printMessage('Get Functional');

这种视觉上不同的做法并非偶然。通过比较函数式和非函数式的解决方案,你会发现它们在代码风格上存在着根本区别。尽管它们的打印输出相同,但它们看起来却截然不同。这是源于函数式编程开发中固有的声明模式。为了充分理解函数式编程,读者首先必须知道它所基于的一些基本概念。

将 lambda 转换为常规函数

lambda 表达式提供了一种比常规函数更具语法优势的特性,因为它简化了常规函数的结构,使人关注于函数的那些真正重要的部分。下面的ES6 lambda表达式:

等同于以下函数:

函数式编程属于声明式编程范式:这种范式会描述一系列的操作,但并不会暴露它们是如何实现的或是数据流如何穿过它们。目前,更加主流的是命令式的或过程式的编程范式,如Java、C#、C++ 和其他大多数结构化语言和面向对象语言都对其提供支持。命令式编程将计算机程序视为一系列自上而下的断言,通过修改系统的各个状态来计算最终的结果。

我们来看一个命令式的程序样例。假设你需要计算一个数组中所有数的平方,命令式的程序应有如下步骤:

 num => Math.pow(num, 2)
var array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for(let i = 0; i < array.length; i++) {
   array[i] = Math.pow(array[i], 2);
}
array; //-> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

命令式编程很具体地告诉计算机如何执行某个任务(在本例中是通过数组循环并将平方公式应用在每个数上)。这是编写代码的最常见方式,你在第一次实现该功能时很有可能也是这样写的。

 function(num) {
   return Math.pow(num, 2);
 }

而声明式编程是将程序的描述与求值分离开来的。它关注于如何用各种表达式来描述程序逻辑,而不一定要指明其控制流或状态的变化。你可以在SQL语句中找到声明性编程的例子。SQL语句是由一个个描述查询结果应是什么的断言组成,对数据检索的内部机制进行了抽象。在第3章中,我们会看到一个使用类似SQL语句的模式组织起来的函数式代码,它能够同时描述应用程序及运行于其中的数据的意义。

如果使用函数式来解决相同的问题,只需要对应用在每个数组元素上的行为予以关注,将循环交给系统的其他部分去控制。完全可以让 Array.map() 去做这种繁重的工作:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(
     function(num) {
         return Math.pow(num, 2);  <--- map接收一个计算平方的函数
      });

//-> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

与之前的命令式代码相比,可以看到函数式的代码让开发者免于考虑如何妥善管理循环计数器以及数组索引访问的问题。简单地说,代码量越大,存在bug的地方就会越多。同时,标准的代码循环是很难被重用的东西,除非将它们抽象为函数。而这正是我们要去做的。在第3章中,我们将阐述如何使用如 mapreducefilter这样的一等高阶函数来从代码中去除循环,它们都以函数为参数,可以增强代码的可重用性、可扩展性和声明性。这就是那个神奇的 run 函数在清单1.1和清单1.2中所做的事。

你可以发挥 ES6 JavaScript 的lambda表达式以及箭头函数的优势来将循环抽象成函数。lambda 表达式提供了一种匿名函数的简写方式,并可以作为函数类型的参数来传递,以减少代码的书写:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => Math.pow(num, 2));

//-> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

为什么要去掉代码循环?循环是一种重要的命令控制结构,但很难重用,并且很难插入其他操作中。此外,它意味着为响应新的迭代,代码会不断变化。你马上就会知道,函数式编程旨在尽可能地提高代码的无状态性不变性。无状态的代码不会改变或破坏全局的状态。但要做到这一点,开发者要学会使用那些没有副作用和状态变化的函数——也称为纯函数

函数式编程基于一个前提,即使用纯函数构建具有不变性的程序。纯函数具有以下性质。

直观地看,任何不符合以上条件的函数都是“不纯的”。编写不可变的程序起初会令人感到陌生。毕竟,我们所习惯的命令式程序设计的本质,就是声明一些从一个状态变为下一个状态的变量(毕竟它们是“变量”)。这是我们做起来很自然的事。考虑以下函数:

var counter = 0;
function increment() {
   return ++counter;
}

这个函数是不纯的,因为它读取并修改了一个外部变量,即函数作用域外的 counter。一般来说,函数在读取或写入外部资源时都会产生副作用,如图1.1所示。另一个例子是经常见到的函数 Date.now(),它的输出肯定是不可预见的并且不一致的,因为它总是依赖于一个不断变化的因素——时间。

图1.1 函数 increment() 通过读取 / 修改一个外部变量 counter 而产生副作用。其结果是不可预见的,因为 counter 可以在调用间隔的任何时间发生改变

在这个例子中,counter 可以通过一个隐式全局变量被访问到(在浏览器的 JavaScript 环境中,这个变量是window对象)。另一种常见的副作用发生在通过this关键字访问实例数据时。this在JavaScript中的行为与其他编程语言中的不同,因为它决定了一个函数在运行时的上下文。而这往往就导致很难去推理代码,这就是为什么要尽可能地避免。我们将在下一章重温这个话题。在很多情况下,以下副作用都有可能发生。

如果无法创建和修改对象,或是打印到控制台,这样的程序会有什么实用价值?事实上,在一个充满了动态行为与变化的世界里,纯函数确实是很难使用的。但是,函数式编程在实践上并不限制一切状态的改变。它只是提供了一个框架来帮助管理和减少可变状态,同时让你能够将纯函数从不纯的部分中分离出来。之前列出的那些不纯的代码都会产生外部可见的副作用,而本书会探索处理该问题的方法。

为了更具体地讨论这些问题,假设你是一名开发人员,而你的团队正在实现一个用来管理学校学生数据的应用程序。清单1.3是一个短小的命令式程序,它能通过社会安全号码(SSN)找到一个学生的记录并渲染在浏览器中(同样,是不是使用浏览器并不重要,你也可以很容易地写入控制台、数据库或文件)。本书会涉及并扩展这个程序,因为它是一个典型的、真实的场景,其中包含了很多与外部的本地对象存储结构(例如一个对象数组)和不同层次的IO交互而产生的副作用。

清单1.3 命令式的 showStudent 函数以及产生的副作用

function showStudent(ssn) {
    var student = db.get(ssn);  <---在对象存储中通过SSN查找学生。请假设这个操作现在是同步的,之后我会处理异步的情况
    if(student !== null) {
       document.querySelector(`#${elementId}`).innerHTML =  <---读取函数外的elementId变量
          `${student.ssn},
           ${student.firstname},
           ${student.lastname}`;
    }
    else {
        throw new Error('Student not found!');  <---当学生信息错误时抛出异常
    }
}
showStudent('444-44-4444');  <---使用SSN号444-44-4444作为参数执行函数,结果会显示在页面上

进一步分析这段代码。这个函数显然将一些副作用暴露到其作用域之外:

一方面,清单1.3中的函数依赖了外部资源,使得代码很不灵活,很难维护并且难以测试。另一方面,使用纯函数,其函数签名对所描述的所有形参(输入集)都有明确的约定,使其更易于理解和使用。

再回到函数式的世界,用在简单的 printMessage 程序中学到的东西来应对这种真实的情况。在阅读本书时,你会逐渐适应函数式,而本书会不断地改进并应用新技术来实现这个任务。目前可以改进以下两点。

首先分离屏幕显示与获取学生记录的行为。当然,与外部存储系统和 DOM 交互所造成的副作用是不可避免的,但至少可以通过将其从主逻辑中分离出来的方式使它们更易于管理。要做到这一点,需要引入一种常见的函数式编程技巧——柯里化。使用柯里化,可以允许部分地传递函数参数,以便将函数的参数减少为一个。就像在清单1.4中显示的那样,可以使用 curry 减少 findappend 的参数,使其成为可以与 run 组合的一元函数。

清单1.4 showStudent 程序的分解

var find = curry(function (db, id) {  <---函数find需要对象存储的引用和ID来查找学生
    var obj = db.get(id);
    if(obj === null) {
       throw new Error('Object not found!');
    }
    return obj;
});

var csv = (student) {  <---将学生对象转换成用逗号分隔的字符串
   return `${student.ssn}, ${student.firstname}, ${student.lastname}`;
};

var append = curry(function (elementId, info) {  <---为了在屏幕上显示学生信息,这里需要elementId以及学生的数据
   document.querySelector(elementId).innerHTML = info;
});

读者并不需要现在就理解如何柯里化,但要看到很重要的一点,那就是通过减少这些函数的长度,可以将 showStudent 编写为这些小函数的组合:

var showStudent = run(
   append('#student-info'),  <---部分设置HTML元素的ID
   csv,
   find(db));  <---部分设置查找对象为学生表

showStudent('444-44-4444');

尽管这个程序只有些许的改进,但是它开始展现出许多的优势。

这个程序仍然有一些枝节问题需要解决,但减少副作用能够在修改各种外部条件时使程序不那么脆弱。如果仔细看一下 find 函数,就会发现它有一个可以产生异常的检查 null 值的分支。由于许多我们会在后续了解的原因,能够确保一个函数有相同的返回值是一个优点,它使得函数的结果是一致的和可预测的。这是纯函数的一个特质,称为引用透明

引用透明是定义一个纯函数较为正确的方式。纯度在这个意义上表明一个函数的参数和返回值之间映射的纯的关系。因此,如果一个函数对于相同的输入始终产生相同的结果,那么就说它是引用透明的。例如,之前看到的那个有状态的函数increment不是引用透明的,因为其返回值严重依赖外部变量counter。再看一下这段代码:

var counter = 0;

function increment() {
    return ++counter;
}

为了使其引用透明,需要删除其依赖的外部变量这一状态,使其成为函数签名中显式定义的参数。可以将其转换为 ES6 lambda 的形式:

var increment = counter => counter + 1;

现在这个函数是稳定的,对于相同的输入每次都返回相同的输出结果。否则,该函数的返回值总会受到一些外部因素的影响。

我们之所以追求这种函数的这种特质,是因为它不仅能使代码更易于测试,还可以让我们更容易推理整个程序。引用透明(又称为等式正确性)来自数学概念,但编程语言中的函数的行为和数学中的函数不同,所以引用透明必须由我们来实现。通过再次使用神奇的 run 函数,图1.2展示了increment函数的命令式与函数式版本的对比。

图1.2 increment函数的命令式与函数式版本的比较。命令式版本的结果是不可预测的,并且可能是不一致的。外部变量counter随时会改变,这影响了函数连续调用的结果。而引用透明的函数式版本中,函数总是等式正确的,因此不可能出现任何错误

构建这样的程序更容易推理,因为可以在心中形成一个状态系统的模型,并通过重写替换来达到期望的输出。具体来讲,假设任何程序可以被定义为一组的函数,对于一个给定的输入,会产生一个输出,则可表示为:

Program = [Input] + [func1, func2, func3, ...] -> Output

如果函数 [func1, func2, func3, ...] 都是纯的,则可以轻易地将由其产生的值来重写这个程序——[val1, val2, val3, ...] ——而不改变结果。考虑计算学生的平均成绩这样一个简单的例子:

var input = [80, 90, 100];
var average = (arr) => divide(sum(arr), size(arr));
average (input); //-> 90

由于函数 sumsize 都是引用透明的,对于如下的给定输入,可以很容易地重写这个表达式。

var average = divide(270, 3); //-> 90

由于 divide 总是纯的,因此可以利用其数学符号进一步改写,所以对于当前输入,平均值永远是 270/3=90。引用透明使得开发者可以用这种系统的甚至是数理的方法来推导程序。整个程序可如下实现:

var sum = (total, current) => total + current;
var total = arr =&gt; arr.reduce(sum);   <---又一新函数:reduce。跟map一样,reduce遍历整个集合。通过sum函数,可以将叠加集合中的数
var size = arr => arr.length;
var divide = (a, b) => a / b;
var average = arr => divide(total(arr), size(arr));  <---在第4章中,我们会用新的方式组合average函数
average(input); //-> 90

尽管本书并不打算对每个程序进行这种等价推导,但应该知道这种形式隐式地存在于任何纯函数的程序,但对于有副作用的函数来说,这却是不可能的。在中,我们会在函数式单元测试的上下文中重识其重要性。虽然可以通过定义函数形参的方式来避免在大多数情况下的副作用,但是在用引用来传递对象时,一定要谨慎,不要在不经意间改变它们。

不可变数据是指那些被创建后不能更改的数据。与许多其他语言一样,JavaScript中的所有基本类型(StringNumber等)从本质上是不可变的。但是其他对象,例如数组,都是可变的——即使它们作为输入传递给另一个函数,仍然可以通过改变原有内容的方式产生副作用。考虑一个简单的数组排序代码:

var sortDesc = function (arr) {
  return arr.sort(function (a, b) {
     return b - a;
  });
}

乍一眼看去,这段代码看起来完全正常,并没有副作用。它确实如所期望的那样——给一个数组,返回以降序排序的相同数组:

var arr = [1,2,3,4,5,6,7,8,9];
sortDesc(arr); //-> [9,8,7,6,5,4,3,2,1]

不幸的是,array.sort 函数是有状态的,会导致在排序过程中产生副作用,因为原始的引用被修改了。这是语言的一个缺陷,我们将在后续的章节中克服它。

现在,读者已经了解了函数式编程的一些基本原则(如声明式的、纯的和不可变的),就可以更简洁地描述它:函数式编程是指为创建不可变的程序,通过消除外部可见的副作用,来对纯函数的声明式的求值过程。——还是比较拗口。之前只是通过编写函数式应用来获取一些显式的实践优势,但现在读者应该开始明白用函数式思考的意义了。

大多数 JavaScript 开发人员面临的问题都是由大量使用严重依赖外部共享变量的、存在太多分支的以及没有清晰的结构大函数所造成的。然而,这正是许多 JavaScript 应用今天的处境——即便是一些由很多文件组成并执行得很成功的应用,也会形成一种共享的可变全局数据网,难以跟踪和调试。

强迫自己去思考纯的操作,将函数看作永不会修改数据的闭合功能单元,必然可以减少这种潜在bug的可能性。理解这些核心的原则非常重要,它可以让代码发挥出函数式的诸多优势,从而引导你走向克服复杂性的函数式编程之路。

为了从函数式编程中受益,你必须学会函数式的思考并掌握合适的工具。在本节中,为了增强函数式编程的意识,也就是将问题看作许多简单函数组合来提供完整解决方案的直觉,笔者将介绍一些工具箱中不可或缺的核心技术,还会简单地介绍一下本书的一些后续章节。如果某个概念现在很难把握,请不用担心,它将会在你阅读后续章节的过程中变得更加清晰明了。

现在来宏观地了解一下函数式能为JavaScript应用程序带来的好处。

从宏观上讲,函数式编程实际上是分解(将程序拆分为小片段)和组合(将小片段连接到一起)之间的相互作用。正是这种二元性,使得函数式程序如此模块化和高效。正如上文提到的,这里的模块化单元(或称为功能单元),就是函数本身。函数式思维的学习通常始于将特定任务分解为逻辑子任务(函数)的过程,图1.3所示的是对showStudent的分解。

图1.3 将 showStudent 分解为小片段的过程。这些子任务是相互独立并易于
理解的,这样在组合时,可以有助于解决最终的问题

如果需要,这些子任务可以进一步分解,直到成为一个个简单的、相互独立的纯函数功能单元。请记住,这是笔者在重构清单1.4 中showStudent时采用的思维方式。函数式编程的模块化的概念与单一职责原则息息相关,也就是说,函数都应该拥有单一的目的——之前例子中的 average 函数正体现了这一原则。纯度和引用透明会促使你这样思考问题,因为为了将函数组合在一起,它们必须在输入和输出的形式上形成一致。通过引用透明的概念能够看出,函数的复杂性往往与其接收的参数数量相关(这更多是来自实际观察的结果,函数的参数越少就越简单并不是绝对的)。

笔者一直在使用run函数来组合各种函数,从而实现整个程序。现在是时候揭秘这个黑魔法了。在现实中,run 函数是一个极为重要的技术的别名:组合。两个函数的组合是一个新的函数,它拿到一个函数的输出,并将其传递到另一个函数中。假设有两个函数 fg,形式上,其组合可以如下描述:

f • g = f(g(x))

这个公式读作“f组合上g”,它在g的返回值与f的参数之间构建了一个松耦合的且类型安全的联系。两个函数能够组合的条件是,它们必须在参数数目及参数类型上形成一致(见第3章)。现在用compose构建组合函数 showStudent,其结构如图1.4所示。

var showStudent = compose(append('#student-info'), csv, find(db));

showStudent('444-44-4444');

图1.4 两个函数组合后的数据流。函数 find 的返回值必须与函数 csv 的参数在类型和数量上相兼容,而之后的返回值又必须是 append 函数可以使用的信息。
注意:为了使数据流明晰,函数调用的顺序被翻转了

了解compose是学习如何实现函数式应用的模块化和可重用性的关键——笔者会在第4章详细讨论。函数式的组合表明了整个表达式的意义可以从其各个部分分别去理解,这是其他编程范式所难以实现的特性。

此外,函数式的组合提高了抽象的层次,可以清晰地勾勒代码的所有步骤,但又不暴露任何底层细节在此代码执行的所有步骤。由于 compose 接收其他函数为参数,这被称为高阶函数。但组合并不是构建流式的、模块化的代码的唯一方式。在本书中,读者还将学习如何通过连接各种操作来构建链式的运行序列。

除了map,开发者可以通过导入一些功能强大的、最优化的函数式类库来获得更多的高阶函数。在第3章和第4章中,我们将介绍很多实现于像 Lodash.js 和 Ramda.js 这种流行的 JavaScript 工具包中的高阶函数。尽管它们在某些方面有所重叠,但每一个都带来了独特的、可简化函数链式装配的功能。

如果读者以前写过一些 jQuery 代码,那么可能熟悉一个词语——链。指的是一连串函数的调用,它们共享一个通用的对象返回值(如 $jQuery 对象)。就像组合一样,链有助于写出简明扼要的代码,而且它通常多用于函数式和响应式的JavaScript类库(后面会见到更多)。为了说明这一点,下面来解决一个不同的问题。假设需要用程序计算那些选了多门课程的学生的平均成绩。已知选课数据的数组:

let enrollment = [
  {enrolled: 2, grade: 100},
  {enrolled: 2, grade: 80},
  {enrolled: 1, grade: 89}
];

命令式的实现可能是这样的:

var totalGrades = 0;
var totalStudentsFound = 0;
for(let i = 0; i < enrollment.length; i++) {
    let student = enrollment [i];
    if(student !== null) {
       if(student.enrolled > 1) {
          totalGrades+= student.grade;
          totalStudentsFound++;
       }
    }
}
var average = totalGrades / totalStudentsFound; //-> 90

与之前一样,用函数式的思维来分解这个问题,可以发现有三个主要步骤。

这样就可以用 Lodash 缝合表征这些步骤的函数,形成一个清单1.5所示的函数链(如果想知道其中每一个函数的详细说明,可以查看附录中的相应文档)。函数链是一种惰性计算程序,这意味着当需要时才会执行。这对程序性能是有利的,因为可以避免执行可能包含一些永不会使用的内容的整个代码序列,节省宝贵的CPU计算周期。这有效地模拟了其他函数式语言的按需调用的行为。

清单1.5 使用函数链编程

_.chain(enrollment)
  .filter(student => student.enrolled > 1)
  .pluck('grade')
  .average()
  .value(); //-> 90  <---调用 _.value() 会触发整个链上的所有操作

目前不要太在意这段代码中发生的一切。当下,请与其命令式的版本进行比较,并注意如何消除变量的声明和变化,以及循环和 if-else 语句。正如你将在第7章所学的,诸如循环和逻辑分支这样的很多命令式控制流机制,会提高函数的复杂程度,因为它们会根据某些条件不同而执行不同的路径,非常难以测试。

公平地说,这个例子略过了一些真实世界程序中典型的错误处理代码。而上文提到抛出异常是产生副作用的一个原因。异常尽管不会存在于理论上的函数式编程之中,但在现实生活中,你将无法避免它们。其实,纯粹的错误处理和异常处理是有区别的。本书的目标是尽可能多地使用纯粹的错误处理,并在像之前描述的那些真正需要异常的情况下抛出异常。

幸运的是,利用一些纯函数式的设计模式,你将不需要通过牺牲函数链的描述性来为代码提供强大的错误处理逻辑。我们将在第5章讨论这个话题。

到目前为止,读者已经看到了如何使用函数式编程来帮助创建模块化的、可测试的以及可扩展的应用程序了。但如何利用函数式编程与来自用户输入、远程Web请求、文件系统或持久化存储的基于异步或事件驱动的数据进行交互呢?

如果读者还记得最近一次请求远程数据、处理用户输入或与本地存储交互的情况,也许能够想起是如何将整个业务逻辑放入回调函数的嵌套序列之中的。这种回调模式打破了线性的代码流,使代码变得难以阅读,因为它的成功处理和错误处理的逻辑混杂在一起。而这一切将得以改变。

正如之前所说,学习函数式编程,尤其是对于如今的JavaScript 开发人员是极其重要的。当构建大型应用程序时,大家关注的焦点从像 Backbone.js 这样的面向对象框架逐渐转移到像采用响应式编程范式这样的框架上。像 Angular.js 这样的 Web 框架今天仍然广为使用;但像 RxJS 这样的新成员,也在通过函数式编程赋予的力量解决着很多极具挑战的任务。

响应式编程可能是函数式编程最令人兴奋和感到有趣的应用之一。JavaScript 开发人员每天都需要处理那些在服务端或客户端中的异步和事件驱动的代码,而你可以使用响应式编程来大幅降低这些代码的复杂性。

采用响应式范式的主要好处是,它能够提高代码的抽象级别,使你忘记与异步和事件驱动程序创建的相关样板代码,从而更专注于具体的业务逻辑。此外,这种新兴范式能够充分利用函数式编程中函数链和组合的优势。

事件有很多种:鼠标点击、文本变化、焦点变化、新HTTP请求的处理、数据库查询以及文件写入,等等。假设需要读取并验证学生的社会保险号(SSN),那么典型的命令式代码可能是清单1.6所示的这样:

清单1.6 读取并验证学生的SSN 的命令式程序

var valid = false;
var elem = document.querySelector('#student-ssn');
elem.onkeyup = function(event) {
  var val = elem.value;  <---访问函数作用域外的数据会产生副作用
   if(val !== null && val.length !== 0) {
      val = val.replace(/^\s*|\s*$|\-s/g, '');  <---裁剪并清理输入,直接改变数据
     if(val.length === 9) {  <---嵌套的分支逻辑
         console.log(`Valid SSN: ${val}!`);
         valid = true;  <---访问函数作用域外的数据会产生副作用
      }
   }
   else {
      console.log(`Invalid SSN: ${val}!`);
   }
};

对于这样一个简单的任务,从一开始就变得复杂,并且代码缺乏一种将所有业务逻辑模块化的能力。此外,由于依赖于外部状态,该函数无法被重用。由于基于函数式编程,响应式编程也是使用像 mapreduce 以及简洁的lambda表达式这样的纯函数来处理数据的。所以学习响应式编程的第一部分就是学习函数式编程!这种编程范式使用了一个叫作observable 的概念。observable 能够订阅一个数据流,让开发者可以通过使用组合和链式操作来优雅地处理数据。下面来看它的实际应用——订阅一个学生的 SSN 字段的简单输入,如清单1.7所示。

清单1.7 读取并验证学生 SSN 的函数式程序

Rx.Observable.fromEvent(document.querySelector('#student-ssn'), 'keyup')
   .map(input => input.srcElement.value)
   .filter(ssn => ssn !== null && ssn.length !== 0)
   .map(ssn => ssn.replace(/^\s*|\s*$|\-/g, ''))
   .skipWhile(ssn => ssn.length !== 9)
   .subscribe(
      validSsn => console.log(`Valid SSN ${validSsn}`)
   );

能看出清单1.7和采用链式编程的清单1.5的相似性吗?这说明,无论是处理集合元素序列或是用户输入序列,一切都被抽象了出来,这使得可以使用相同的方式去处理(本书第8章将会详细介绍)。

其中一个最重要的知识点是,所有清单1.7中的操作都是完全不可变的,并且所有的业务逻辑被分隔成单独的函数。并不是必须要响应式地使用函数,但函数式的思维会迫使开发者这样做。一旦这样做了,将解开一个基于函数式响应式编程(FRP)的非常了不起的架构。

函数式编程是一种编程范式的转变,可以改变开发者对任何编程挑战的解决方式。那么,能说函数式编程是更为流行的面向对象设计的替代品么?幸运的是,就如本章开始 Michael Feathers 所说的,函数式编程对代码来说不是一个全有或全无的方案。事实上,很多采用面向对象架构的应用依然可以受益于函数式编程。由于对不可变性及状态共享的严格把控,函数式编程可以使得多线程编程更加简单。但由于 JavaScript 是单线程运行的,本书不会涵盖多线程编程。下一章会重点介绍一些函数式和面向对象设计的主要区别,帮助读者更容易地适应函数式的思维方式。

本章简要介绍了将在整本书中细致讨论的各个主题,让读者从思维方式上深入了解函数式的意境。不必担心遗漏一些东西,只要能够理解上述的所有概念,那么就很不错,这说明你选择了一本正确的书。在传统的面向对象编程中,读者习惯于采用命令式/过程式的编程风格。要改变这一点,思维过程需要有巨大的转变,要开始使用函数式的方式去解决问题。

[1] 更多关于这个临时的`run`函数的细节,请访问http://mng.bz/nmax


相关图书

深入浅出Spring Boot 3.x
深入浅出Spring Boot 3.x
JavaScript核心原理:规范、逻辑与设计
JavaScript核心原理:规范、逻辑与设计
JavaScript入门经典(第7版)
JavaScript入门经典(第7版)
PHP、MySQL和JavaScript入门经典(第6版)
PHP、MySQL和JavaScript入门经典(第6版)
JavaScript学习指南(第3版)
JavaScript学习指南(第3版)
JavaScript数据可视化编程
JavaScript数据可视化编程

相关文章

相关课程