《JavaScript ES6函数式编程入门经典》使用JavaScript ES6带你学习函数式编程。

函数的第一条原则是要小。函数的第二条原则是要更小。

                                —ROBERT C. MARTIN


       欢迎来到函数式编程的世界。在这个只有函数的世界中,我们愉快地生活着,没有任何外部环境的依赖,没有状态,没有突变——永远没有。函数式编程是最近的一个热点。你可能在团队中和小组会议中听说过这个术语,或许还做过一些思考。如果你已经了解了它的含义,非常好!但是那些不知道的人也不必担心。本章的目的就是:用通俗的语言为你介绍函数式编程。
        我们将以一个简单的问题开始本章:数学中的函数是什么?随后给出函数的定义并用其创建一个简单的JavaScript 函数示例。本章结尾将说明函数式编程带给开发者的好处。


1.1 什么是函数式编程?为何它重要

    在开始了解函数式编程这个术语的含义之前,我们要回答另一个问题:数学中的函数是什么?数学中的函数可以写成如下形式:
f(X) = Y
这条语句可以被解读为“一个函数F,以X 作为参数,并返回输出Y。”例如,X 和Y 可以是任意的数字。这是一个非常简单的定义,但是其中包含了几个关键点:
● 函数必须总是接受一个参数。
● 函数必须总是返回一个值。
● 函数应该依据接收到的参数(例如X)而不是外部环境运行。
● 对于一个给定的X,只会输出唯一的一个Y。
       你可能想知道为什么我们要了解数学中的函数定义而不是JavaScript 中的。你是这样想的吗?对于我来说这是一个值得思考的问题。答案非常简单:函数式编程技术主要基于数学函数和它的思想。但是等等——我们并不是要在数学中教你函数式编程,而是使用JavaScript来传授该思想。但是贯穿全书,我们将看到数学函数的思想和用法,以便能够理解函数式编程。
        有了数学函数的定义,下面来看看JavaScript 函数的例子。假设我们要编写一个计税函数。在JavaScript 中你会如何做?


注意

本书的所有例子都用ES6 编写。书中的代码片段是独立的,所以你
可以复制并把它们粘贴到任意喜欢的支持ES6 的浏览器中。所有的例子
可以在Chrome 浏览器的51.0.2704.84 版本中运行。ES6 的规范请参见:
http://www.ecma-international.org/ecma-262/6.0/。


我们可以实现如代码清单1-1 所示的函数。

代码清单1-1 用ES6 编写的计税函数
var percentValue = 5;
var calculateTax = (value) => { return value/100 * (100 + percentValue) }
上面的calculateTax 函数准确地实现了我们的想法。你可以用参数调用该函数,它会在控制台中返回计算后的税值。该函数看上去很整洁,不是吗?让我们暂停一下,用数学的定义分析一下它。数学函数定义的关键是函数逻辑不应依赖外部环境。在calculateTax 函数中,我们让函数依赖全局变量percentValue。因此,该函数在数学意义上就不能被称为一个真正的函数。下面将修复该问题。请思考一下,为什么不能在模板中改变字体?

修复方法非常简单:我们只需要移动percentValue,把它作为函数的参数。见代码清单1-2。

代码清单1-2 重写计税函数
var calculateTax = (value, percentValue) => { return value/100 * (100 +
percentValue) }
现在calculateTax 函数可以被称为一个真正的函数了。但是我们得到了什么?我们只是在其内部消除了对全局变量的访问。移除一个函数内部对全局变量的访问会使该函数的测试更容易(我们将在本章的稍后部分讨论函数式编程的好处)。
现在我们了解了数学函数与JavaScript 函数的关系。通过这个简单的练习,我们就能用简单的技术术语定义函数式编程。函数式编程是一种范式,我们能够以此创建仅依赖输入就可以完成自身逻辑的函数。这保证了当函数被多次调用时仍然返回相同的结果。函数不会改变任何外部环境的变量,这将产生可缓存的、可测试的代码库。

函数与JavaScript 方法
前面介绍了很多有关“函数”的内容。在继续之前,我想确保你理解了函数和JavaScript 方法之间的区别。简言之,函数是一段可以通过其名称被调用的代码。它可以传递参数并返回值。
然而,方法是一段必须通过其名称及其关联对象的名称被调用的代码。下面快速看一下函数和方法的例子,如代码清单1-3 和代码清单1-4 所示。

函数
代码清单1-3 一个简单的函数
var simple = (a) => {return a} // 一个简单的函数simple(5) // 用其名称调用
方法
代码清单1-4 一个简单的方法
var obj = {simple : (a) => {return a} }
obj.simple(5) // 用其名称及其关联对象调用
在函数式编程的定义中还有两个重要的特性并未提及。在研究函数式编程的好处之前,我们将在下一节详细阐述。


1.2 引用透明性
根据函数的定义,我们可以得出结论:所有的函数对于相同的输入都将返回相同的值。函数的这一属性被称为引用透明性(Referential Transparency)。下面举一个简单的例子,如代码清单1-5 所示:
代码清单1-5 引用透明性的例子
var identity = (i) => { return i }

在上面的代码片段中,我们定义了一个简单的函数identity。无论传入什么作为输入,该函数都会把它返回。也就是说,如果你传入5,它就会返回5(换言之,该函数就像一面镜子或一个恒等式)。注意,我们的函数只根据传入的参数“i”进行操作,在函数内部没有全局引用(记住代码清单1-2,我们从全局访问中移除了“percentValue”并把它作为一个传入的参数)。该函数满足了引用透明性条件。现在假设该函数被用于其他函数调用之间,如下所示:
sum(4,5) + identity(1)
根据引用透明性的定义,我们可以把上面的语句转换为:
sum(4,5) + 1
该过程被称为替换模型(Substitution Model),因为你可以直接替换函数的结果(主要因为函数的逻辑不依赖其他全局变量),这与它的值是一样的。这使并发代码和缓存成为可能。根据该模型想象一下,你可以轻松地用多线程运行上面的代码,甚至不需要同步!为什么?同步的问题在于线程不应该在并发运行的时候依赖全局数据。遵循引用透明性的
函数只能依赖来自参数的输入。因此,线程可以自由地运行,没有任何锁机制!
由于函数会为给定的输入返回相同的值,实际上我们就可以缓存它了!例如,假设有一个函数“factorial”计算给定数值的阶乘。“factorial”接受输入作为参数以计算其阶乘。我们都知道“5”的“factorial”是“120”。
如果用户第二次调用“5”的“factorial”,情况会如何呢?如果“factorial”函数遵循引用透明性,我们知道结果将依然是“120”(并且它只依赖输入参数)。记住这个特性后,就能够缓存“factorial”函数的值。因此,如果“factorial”以“5”作为输入被第二次调用,就能够返回已缓存的
值,而不必再计算一次。
在此可以看到,引用透明性在并发代码和可缓存代码中发挥着重要的作用。本章的稍后部分将编写一个用于缓存函数结果的库函数。

引用透明性是一种哲学
“引用透明性”一词来自分析哲学(https://en.wikipedia.org/wiki/Analytical_ philosophy)。该哲学分支研究自然语言的语义及其含义。单词“Referential”或“Referent”意指表达式引用的事物。句子中的上下文是“引用透明的”,如果用另一个引用相同实体的词语替换上下文中的一个词语,并不会改变句子的含义。
这就是我们在本节定义的引用透明性。替换函数的值并不影响上下文。这就是函数式编程的哲学!

1.3 命令式、声明式与抽象
函数式编程主张声明式编程和编写抽象的代码。在更进一步介绍之前,我们需要理解这两个术语。我们都知道并使用过多种命令式范式。
下面以一个问题为例,看看如何用命令式和声明式的方法解决它。
假设有一个数组,你想遍历它并把它打印到控制台。代码如代码清
单1-6 所示:
代码清单1-6 用命令式方法遍历数组
var array = [1,2,3]
for(i=0;i<array.length;i++)
console.log(array[i]) // 打印1, 2, 3
这段代码运行良好。但是为了解决问题,我们精确地告诉程序应该“如何”做。例如,我们用数组长度的索引计算结果编写了一个隐式的for 循环并打印出数组项。在此暂停一下。我们的任务是什么?“打印数组的元素”,对不对?但是看起来我们像在告诉编译器该做什么。在本例中,我们在告诉编译器“获得数组长度,循环数组,用索引获取每个数组元素,等等。”我们将之称为“命令式”解决方案。命令式编程主张告诉编译器“如何”做。
现在我们来看另一方面,声明式编程。在声明式编程中,我们要告诉编译器做“什么”,而不是“如何”做。“如何”做的部分将被抽象到普通函数中(这些函数被称为高阶函数,我们会在后续的章节中介绍)。现在我们可以用内置的forEach 函数遍历数组并打印它。见代码清单1-7。

代码清单1-7 用声明式方法遍历数组
var array = [1,2,3]
array.forEach((element) => console.log(element))// 打印1, 2, 3
上面的代码片段打印了与代码清单1-6 相同的输出。但是我们移除了“如何”做的部分,比如“获得数组长度,循环数组,用索引获取每一个数组元素,等等。”我们使用了一个处理“如何”做的抽象函数,如此可以让开发者只需要关心手头的问题(做“什么”的部分)。这非常棒!贯穿本书,我们都将创建这样的内置函数。
函数式编程主张以抽象的方式创建函数,这些函数能够在代码的其他部分被重用。现在我们对什么是函数式编程有了透彻的理解。基于这一点,我们就能够去研究函数式编程的好处了。

1.4 函数式编程的好处
我们了解了函数式编程的定义和一个非常简单的JavaScript 函数。但是不得不回答一个简单的问题:“函数式编程的好处是什么?”这一节将帮助你透过现象看本质,了解函数式编程带给我们的巨大好处!大多数函数式编程的好处来自于编写纯函数。所以在此之前,我们将了解一下什么是纯函数。
1.5 纯函数
有了前面的定义,我们就能够定义纯函数的含义。纯函数是对给定的输入返回相同的输出的函数。举一个例子,见代码清单1-8:

代码清单1-8 一个简单的纯函数
var double = (value) => value * 2;
上面的函数“double”是一个纯函数,只因为给它一个输入,它总是返回相同的输出。你不妨自己试试。用输入5 调用double 函数总是返回结果10!纯函数遵循引用透明性。因此,我们能够毫不犹豫地用10替换double(5)。
所以,纯函数了不起的地方是什么?它能带给我们很多好处。下面依次讨论。
1.5.1 纯函数产生可测试的代码
不纯的函数具有副作用。下面以前面的计税函数为例进行说明(代
码清单1-1):

var percentValue = 5;
var calculateTax = (value) => { return value/100 * (100 +
percentValue) } //
依赖外部环境的percentValue 变量
函数calculateTax 不是纯函数,主要因为它依赖外部环境计算其逻
辑。尽管该函数可以运行,但非常难于测试!下面看看原因。
假设我们打算对calculateTax 函数运行测试,分别执行三次不同的
税值计算。按如下方式设置环境:
calculateTax(5) === 5.25
calculateTax(6) === 6.3
calculateTax(7) === 7.3500000000000005
整个测试通过了!但是别急,既然原始的calculateTax 函数依赖外
部环境变量percentValue,就有可能出错。假设你在运行相同的测试用
例时,外部环境也正在改变变量percentValue:
calculateTax(5) === 5.25
// percentValue 被其他函数改成2
calculateTax(6) === 6.3 // 这条测试能通过吗?
// percentValue 被其他函数改成0
calculateTax(7) === 7.3500000000000005 // 这条测试能通过吗,还是
会抛出异常?
如你所见,此时的calculateTax 函数很难测试。但是我们可以很容
易地修复这个问题,从该函数中移除外部环境依赖,代码如下:
var calculateTax = (value, percentValue) => { return value/100 *
(100 +percentValue) }
现在可以顺畅地测试calculateTax 函数了!在结束本节前,我们需
要提及纯函数的一个重要属性,即“纯函数不应改变任何外部环境的
变量。”
换言之,纯函数不应依赖任何外部变量(就像例子中展示的那样),
也不应改变任何外部变量。我们通过改变任意一个外部变量就能马上理
解其中的含义。例如,考虑代码清单1-9:

代码清单1-9 badFunction 例子
var global = "globalValue"
var badFunction = (value) => { global = "changed"; return value * 2 }
当badFunction 函数被调用时,它将全局变量global 的值改成
changed。需要担心这件事吗?是的!假设另一个函数的逻辑依赖global
变量!因此,调用badFunction 就影响了其他函数的行为。具有这种性
质的函数(也就是具有副作用的函数)会使代码库变得难以测试。除了测
试,在调试的时候这些副作用会使系统的行为变得非常难以预测!
至此,我们通过简单的示例了解到纯函数有助于我们更容易地测试
代码。现在来看一下纯函数的其他好处——合理的代码。
1.5.2 合理的代码
作为开发者,我们应该善于推理代码或函数。通过创建和使用纯函
数,能够非常简单地实现该目标。为了明确这一点,我们将使用一个简
单的double 函数(来自代码清单1-8):
var double = (value) => value * 2
通过函数的名称能够轻易地推理出:这个函数把给定的数值加倍,
其他什么也没做!事实上,根据引用透明性概念,我们可以简单地用相
应的结果替换double 函数调用!开发者的大部分时间花在阅读他人的代
码上。在代码库中包含具有副作用的函数对团队中的其他开发者来说是
难以阅读的。包含纯函数的代码库会易于阅读、理解和测试。记住,函
数(无论它是否为纯函数)必须总是具有一个有意义的名称。按照这种说
法,在给定行为后你不能将函数“double”命名为“dd”。
小脑力游戏
我们只需要用值替换函数,就好像不看它的实现就知道结果一样!
这是你在理解函数思想过程中的一个巨大进步。我们取代函数值,就好
像这是它要返回的结果!
为了快速练习一下你的脑力,下面用内置的Math.max 函数测试一下你的推理能力。
给定函数调用:
Math.max(3,4,5,6)
结果是什么?
为了给出结果,你看了max 的实现了吗?没有,对不对?为什么?
答案是Math.max 是纯函数。现在喝一杯咖啡吧,你已经完成了一项伟
大的工作!
1.6 并发代码
纯函数总是允许我们并发地执行代码。因为纯函数不会改变它的环
境,这意味着我们根本不需要担心同步问题!当然,JavaScript 并没有
真正的多线程用来并发地执行函数, 但是如果你的项目使用了
WebWorker 来并发地执行多任务,该怎么办呢?或者有一段Node 环境
中的服务端代码需要并发地执行函数,又该怎么办呢?
例如,假设我们有代码清单1-10 给出的如下代码:
代码清单1-10 非纯函数
let global = "something"
let function1 = (input) => {
// 处理input
// 改变global
global = "somethingElse"
}
let function2 = () => {
if(global === "something")
{
// 业务逻辑
}
}
如果我们需要并发地执行function1 和function2,该怎么办呢?假
设线程一(T-1)选择function1 执行,线程二(T-2)选择function2 执行。现在两个线程都准备好执行了,那么问题来了。如果T-1 在T-2 之前执行,
情况会如何?由于两个函数(function1 和function2)都依赖全局变量
global,并发地执行这些函数就会引起不良的影响。现在把这些函数改
为纯函数,如代码清单1-11 所示:
代码清单1-11 纯函数
let function1 = (input,global) => {
// 处理input
// 改变global
global = "somethingElse"
}
let function2 = (global) => {
if(global === "something")
{
// 业务逻辑
}
}
此处我们移动了global 变量,把它作为两个函数的参数,使它们变
成纯函数。现在可以并发地执行这两个函数了,不会带来任何问题。由
于函数不依赖外部环境(global 变量),因此我们不必再像代码清单1-10
那样担心线程的执行顺序。
本节说明了纯函数是如何使代码并发执行的,你不必担心任何问题。
1.7 可缓存
既然纯函数总是为给定的输入返回相同的输出,那么我们就能够缓
存函数的输出。讲得更具体些,请看下面的例子。假设有一个做耗时计
算的函数,名为longRunningFunction:
var longRunningFunction = (ip) => { //do long running tasks and return }
如果longRunningFunction 函数是纯函数,我们知道对于给定的输
入,它总会返回相同的输出!考虑到这一点,为什么要通过多次的输入
来反复调用该函数呢?不能用函数的上一个结果代替函数调用吗?

(此处再次注意我们是如何使用引用透明性概念的,因此,用上一个结
果值代替函数不会改变上下文)。假设我们有一个记账对象,它存储了
longRunningFunction 函数的所有调用结果,如下所示:
var longRunningFnBookKeeper = { 2 : 3, 4 : 5 . . . }
longRunningFnBookKeeper 是一个简单的JavaScript 对象,存储了所
有的输入(key)和输出(value),它是longRunningFunction 函数的调用结
果。现在使用纯函数的定义,我们能够在调用原始函数之前检查key 是
否在longRunningFnBookKeeper 中,如代码清单1-12 所示:
代码清单1-12 通过纯函数缓存结果
var longRunningFnBookKeeper = { 2 : 3, 4 : 5 }
// 检查key 是否在longRunningFnBookKeeper 中
// 如果在,则返回结果,否则更新记账对象
longRunningFnBookKeeper.hasOwnProperty(ip) ?
longRunningFnBookKeeper[ip] :
longRunningFnBookKeeper[ip] = longRunningFunction(ip)
上面的代码相当直观。在调用真正的函数之前,我们用相应的ip
检查函数的结果是否在记账对象中。如果在,则返回之,否则就调用原
始函数并更新记账对象中的结果。看到了吗?用更少的代码很容易使函
数调用可缓存。这就是纯函数的魅力!
在本书后面,我们将编写一个使用纯函数调用的用于处理缓存或技
术性记忆(technical memorization)的函数库。
1.8 管道与组合
使用纯函数,我们只需要在函数中做一件事。纯函数能够自我理解,
通过其名称就能知道它所做的事情。纯函数应该被设计为只做一件事。
只做一件事并把它做到完美是UNIX 的哲学,我们在实现纯函数时也将
遵循这一原则。UNIX 和LINUX 平台有很多用于日常任务的命令。例
如,cat 用于打印文件内容,grep 用于搜索文件,wc 用于计算行数等。
这些命令的确一次只解决一个问题。但是我们可以用组合或管道来完成复杂的任务。假如我们要在一个文件中找到一个特定的名称并统计它的
出现次数。在命令提示符中要如何做?命令如下:
cat jsBook | grep –i "composing" | wc
上面的命令通过组合多个函数解决了我们的问题。组合不是
UNIX/LINUX 命令行独有的,但它们是函数式编程范式的核心。我们把
它们称为函数式组合(Functional Composition)。假设同样的命令行在
JavaScript 函数中已经实现了,我们就能够根据同样的原则使用它们来
解决问题!
现在考虑用一种不同的方式解决另一个问题。你想计算文本中的行
数。如何解决呢?你已经有了答案。不是吗?
根据我们的定义,命令实际上是一种纯函数。它接受参数并向调用
者返回输出,不改变任何外部环境!
注意
也许你在想,JavaScript 支持用于组合函数的操作符“|”吗?答案
是否定的,但是我们可以创建一个。后面的章节将创建相应的函数。
遵循一个简单的定义,我们收获了很多好处。在结束本章之前,我
想说明纯函数与数学函数之间的关系。
1.9 纯函数是数学函数
在1.7 节“可缓存”中我们见过如下一段代码(代码清单1-12):
var longRunningFunction = (ip) => { // 执行长时间运行的任务并返回
var longRunningFnBookKeeper = { 2 : 3, 4 : 5 }
// 检查key 是否在longRunningFnBookKeeper 中
// 如果在,则返回结果,否则更新记账对象
longRunningFnBookKeeper.hasOwnProperty(ip) ?
longRunningFnBookKeeper[ip] :
longRunningFnBookKeeper[ip] = longRunningFunction(ip)

这段代码的主要目的是缓存函数调用。我们通过记账对象实现了该
功能。假设我们多次调用了longRunningFunction,longRunningFnBook-
Keeper 增长为如下的对象:
longRunningFnBookKeeper = {
1 : 32,
2 : 4,
3 : 5,
5 : 6,
8 : 9,
9 : 10,
10 : 23,
11 : 44
}
现在假设longRunningFunction 的输入范围限制为1-11 的整数(正如
例子所示)。由于我们已经为这个特别的范围构建了记账对象,因此只
能参照longRunningFnBookKeeper 来为给定的输入返回输出。
下面分析一下该记账对象。该对象为我们清晰地描绘出,函数
longRunningFunction 接受一个输入并为给定的范围(在这个例子中,是
1-11)映射输出。此处的关键是,输入(在这个例子中,是key)具有强制
的、相应的输出(在这个例子中,是结果)。在key 中也不存在映射两个
输出的输入。
通过上面的分析,我们再看一下数学函数的定义(这次是来自维
基百科的更具体的定义,网址为https://en.wikipedia.org/wiki/Function_
(mathematics)):
在数学中,函数是一种输入集合和可允许的输出集合之间的关系,
具有如下属性:每个输入都精确地关联一个输出。函数的输入称为参数,
输出称为值。对于一个给定的函数,所有被允许的输入集合称为该函数
的定义域,而被允许的输出集合称为值域。
上面的定义与纯函数完全一致!看一下longRunningFnBookKeeper
对象。你能找到函数的定义域和值域吗?当然可以!通过这个非常简单
的例子,很容易看到数学函数的思想被借鉴到函数式范式的世界(正如
本章开始阐述的那样)。

1.10 我们要构建什么
本章介绍了很多关于函数和函数式编程的知识。有了这些基础知
识,我们将构建一个名为ES6-Functional 的函数式库。这个库将在全书
中逐章地构建。通过构建这个函数式库,你将探索如何使用JavaScript
函数,以及如何在日常工作中应用函数式编程(使用创建的函数解决代
码库中的问题)!
1.11 JavaScript 是函数式编程语言吗
在结束本章之前,我们要回答一个基础的问题。JavaScript 是函数
式编程语言吗?答案不置可否。在本章的开头,我们说函数式编程主张
函数必须接受至少一个参数并返回一个值。不过坦率地讲,我们可以创
建一个不接受参数并且实际上什么也不返回的函数。例如,下面的代码
在JavaScript 引擎中是一段有效的代码:
var useless = () => {}
上面的代码在JavaScript 中执行时不会报错!原因是JavaScript 不
是一种纯函数语言(比如Haskell),而更像是一种多范式语言。但是如本
章所讨论的,这门语言非常适合函数式编程范式。到目前为止,我们讨
论的技术和好处都可以应用于纯JavaScript!这就是书名的由来!
JavaScript 语言支持将函数作为参数,以及将函数传递给另一函数
等特性——主要原因是JavaScript 将函数视为一等公民(我们将在后续章
节做更多的讨论)。由于函数定义的约束,开发者需要在创建JavaScript
函数时将其考虑在内。如此,我们就能从函数式编程中获得很多优势,
正如本章中讨论的一样。

1.12 小结
在本章中,我们介绍了在数学和编程世界中函数的定义。我们从数
学函数的简单定义开始,研究了短小而透彻的函数例子和JavaScript 中
的函数式编程。还定义了什么是纯函数并详细讨论了它们的益处。在本
章结尾,我们说明了纯函数和数学函数之间的关系。还讨论了JavaScript
如何被视为一门函数式编程语言。通过本章的学习,你将收获颇丰。
在下一章中,我们将学习用ES6 创建并执行函数。用ES6 创建函
数有多种方式,我们将在下一章中学习这些方式!

阅读更多 登录后自动展开
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页