一、函数式编程简介

函数的第一条规则是它们应该很小。函数的第二个规则是它们应该比那个小。

罗伯特·马丁

欢迎来到函数式编程的世界,一个只有函数的世界,快乐地生活着,没有任何外部世界的依赖,没有状态,没有突变——永远。函数式编程现在是个时髦词。你可能在你的团队或当地的小组会议中听说过这个术语。如果你已经意识到这意味着什么,很好。对于那些不知道这个术语的人,不用担心。本章旨在用简单的英语向您介绍功能性术语。

我们将以问一个简单的问题开始这一章:数学中的函数是什么?稍后,我们将使用我们的函数定义用 JavaScript 创建一个简单的函数。本章最后解释了函数式编程给开发人员带来的好处。

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

在我们开始探讨函数式编程意味着什么之前,我们必须回答另一个问题:数学中的函数是什么?数学中的一个函数可以写成这样:

f ( X ) = Y

The statement can be read as “A function f, which takes X as its argument, and returns the output Y.X and Y can be any number, for instance. That’s a very simple definition. There are key takeaways in the definition, though:

  • 函数必须总是带一个参数。

  • 函数必须总是返回值。

  • 一个函数应该只作用于它的接收参数(即 X ),而不是外界。

  • 对于给定的 X,只会有一个 Y.

你可能想知道为什么我们用数学而不是用 JavaScript 给出函数的定义。是吗?这是一个很好的问题。答案非常简单:函数式编程技术在很大程度上基于数学函数及其思想。不过,请屏住呼吸;我们不会用数学来教你函数式编程,而是用 JavaScript。然而,在整本书中,我们将看到数学函数的概念,以及它们是如何被用来帮助理解函数式编程的。

With that definition in place, we are going to see the examples of functions in JavaScript. Imagine we have to write a function that does tax calculations. How are you going to do this in JavaScript? We can implement such a function as shown in Listing 1-1.var percentValue = 5; var calculateTax = (value) => { return value/100 * (100 + percentValue) } Listing 1-1

计算税收功能

calculateTax 函数正是我们想要做的。您可以使用值调用此函数,这将在控制台中返回计算出的税收值。看起来很整洁,不是吗?让我们暂停一下,用我们的数学定义来分析这个函数。我们的数学函数项的一个关键点是函数逻辑不应该依赖于外界。在我们的 calculateTax 函数中,我们让函数依赖于全局变量 percentValue。因此我们创造的这个函数在数学意义上不能被称为真正的函数。让我们解决这个问题。

The fix is very straightforward: We have to just move the percentValue as our function argument, as shown in Listing 1-2.var calculateTax = (value, percentValue) => { return value/100 * (100 + percentValue) } Listing 1-2

重写的计税函数

现在我们的 calculateTax 函数可以作为实函数来调用了。然而,我们得到了什么?我们刚刚在 calculateTax 函数中取消了全局变量访问。移除函数内部的全局变量访问使得测试变得容易。(我们将在本章后面讨论函数式编程的好处。)

现在我们已经展示了数学函数和 JavaScript 函数之间的关系。通过这个简单的练习,我们可以用简单的技术术语定义函数式编程。函数式编程是一种范例,在这种范例中,我们将创建一些函数,这些函数将只依赖于它们的输入来计算出它们的逻辑。这确保了一个函数在被多次调用时会返回相同的结果。该函数也不会改变外部世界的任何数据,从而产生一个可缓存和可测试的代码库。

JAVASCRIPT 中的函数与方法

在这篇课文中,我们已经谈了很多关于功能这个词。在我们继续之前,我们希望确保您理解 JavaScript 中函数和方法之间的区别。

简单来说,函数就是一段可以通过名字调用的代码。它可以用来传递它可以操作的参数,并有选择地返回值。

一个方法是一段代码,它必须通过与一个对象相关联的名字来调用。

Listing 1-3 and Listing 1-4 provide quick examples of a function and a method.var simple = (a) => {return a} // A simple function simple(5) //called by its name Listing 1-3

简单的功能

var obj = {simple : (a) => {return a} } obj.simple(5) //called by its name along with its associated object Listing 1-4

简单的方法

函数式编程还有两个更重要的特征在定义中缺失了。在深入了解函数式编程的好处之前,我们将在接下来的章节中详细讨论它们。

对透明性有关的

With our definition of function, we have made a statement that all the functions are going to return the same value for the same input. This property of a function is called a referential transparency . A simple example is shown in Listing 1-5.var identity = (i) => { return i } Listing 1-5

参考透明度示例

In Listing 1-5, we have defined a simple function called identity. This function is going to return whatever you’re passing as its input; that is, if you’re passing 5, it’s going to return the value 5 (i.e., the function just acts as a mirror or identity). Note that our function operates only on the incoming argument i, and there is no global reference inside our function (remember in Listing 1-2, we removed percentValue from global access and made it an incoming argument). This function satisfies the conditions of a referential transparency. Now imagine this function is used between other function calls like this:sum(4,5) + identity(1) With our referential transparency definition, we can convert that statement into this:sum(4,5) + 1

现在这个过程被称为替代模型,因为你可以直接用它的值替代函数的结果(主要是因为函数的逻辑不依赖于其他全局变量)。这导致了并行代码和缓存。想象一下,有了这个模型,你可以轻松地用多线程运行给定的函数,甚至不需要同步。为什么呢?同步的原因来自于线程在并行运行时不应该作用于全局数据这一事实。遵循参照透明性的函数将只依赖于它们参数的输入;因此,线程可以自由运行,无需任何锁定机制。

因为对于给定的输入,函数将返回相同的值,我们实际上可以缓存它。比如,假设有一个函数叫做阶乘,它计算给定数字的阶乘。Factorial 将输入作为需要计算阶乘的参数。我们知道 5 的阶乘是 120。如果用户第二次调用 5 的阶乘呢?如果阶乘函数遵循引用透明性,我们知道结果将是 120(它只取决于输入参数)。记住这个特征,我们可以缓存阶乘函数的值。因此,如果第二次调用 factorial,输入为 5,我们可以返回缓存的值,而不是再次计算它。

这里你可以看到一个简单的想法是如何帮助并行代码和可缓存代码的。在本章的后面,我们将在我们的库中编写一个函数来缓存函数结果。

参照透明是一种哲学

参照透明是一个来自分析哲学的术语(https://en.wikipedia.org/wiki/Analytical_philosophy)。这一哲学分支研究自然语言语义及其含义。在这里,所指的所指的就是表达所指的事物。如果用指代相同实体的另一个术语替换上下文中的一个术语不会改变其含义,那么句子中的上下文就是指称透明的。

这正是我们在这里定义参照透明性的方式。我们在不影响上下文的情况下替换了函数值。

命令式、声明式、抽象式

函数式编程也是关于声明性和编写抽象代码。在我们继续之前,我们需要理解这两个术语。我们都知道并致力于一个必要的范例。我们将解决一个问题,看看如何以命令和声明的方式解决它。

Suppose you have a list or array and want to iterate through the array and print it to the console. The code might look like Listing 1-6.var array = [1,2,3] for(i=0;i<array.length;i++)     console.log(array[i]) //prints 1, 2, 3 Listing 1-6

迭代数组命令式方法

它工作正常。然而,在这种解决问题的方法中,我们确切地告诉了我们需要“如何”去做。例如,我们已经编写了一个隐式 For 循环,它包含数组长度的索引计算和打印项。我们就此打住。这里的任务是什么?打印数组元素,对吗?然而,看起来我们是在告诉编译器该做什么。在这种情况下,我们告诉编译器,“获取数组长度,循环我们的数组,使用索引获取数组的每个元素,等等。”我们称之为强制性解决方案。命令式编程就是告诉编译器如何做事。

We will now switch to the other side of the coin, declarative programming. In declarative programming, we are going to tell what the compiler needs to do rather than how. The “how” parts are abstracted into common functions (these functions are called higher order functions, which we cover in the upcoming chapters). Now we can use the built-in forEach function to iterate the array and print it, as shown in Listing 1-7.var array = [1,2,3] array.forEach((element) => console.log(element)) //prints 1, 2, 3 Listing 1-7

迭代数组声明方法

清单 1-7 确实打印出与清单 1-5 完全相同的输出。不过,在这里,我们已经删除了“如何”部分,如“获取数组长度,循环数组,使用索引获取数组的每个元素,等等。”我们使用了一个抽象的函数,它负责“如何做”的部分,让我们这些开发人员去担心手头的问题(“做什么”的部分)。我们将在整本书中创建这些内置函数。

函数式编程是以抽象的方式创建函数,这些函数可以被代码的其他部分重用。现在我们对什么是函数式编程有了一个坚实的理解;考虑到这一点,我们可以探索函数式编程的好处。

函数式编程的好处

我们已经看到了函数式编程的定义和 JavaScript 中一个非常简单的函数示例。我们现在必须回答一个简单的问题:函数式编程的好处是什么?本节帮助您了解函数式编程给我们带来的巨大好处。函数式编程的大部分好处来自于编写纯函数。所以在我们看到函数式编程的好处之前,我们需要知道什么是纯函数。

纯函数

With our definition in place, we can define what is meant by pure functions. Pure functions are the functions that return the same output for the given input. Take the example in Listing 1-8.var double = (value) => value * 2; Listing 1-8

一个简单的纯函数

这个函数 double 是一个纯函数,因为给定一个输入,它总是返回相同的输出。你可以自己试试。用输入 5 调用 double 函数总是得到结果 10。纯函数服从引用透明性。因此,我们可以毫不犹豫地用 10 代替 double(5)。

那么纯函数有什么大不了的?它们提供了许多好处,我们将在下面讨论。

纯函数导致可测试的代码

Functions that are not pure have side effects. Take our previous tax calculation example from Listing 1-1:var percentValue = 5; var calculateTax = (value) => { return value/100 * (100 + percentValue) } //depends on external environment percentValue variable

函数 calculateTax 不是一个纯函数,主要是因为计算它的逻辑依赖于外部环境。该功能可以工作,但是很难测试。我们来看看这其中的原因。

Imagine we are planning to run a test for our calculateTax function three times for three different tax calculations. We set up the environment like this:calculateTax(5) === 5.25 calculateTax(6) === 6.3 calculateTax(7) === 7.3500000000000005 The entire test passed. However, because our original calculateTax function depends on the external environment variable percentValue, things can go wrong. Imagine the external environment is changing the percentValue variable while you are running the same test cases:calculateTax(5) === 5.25 // percentValue is changed by other function to 2 calculateTax(6) === 6.3  //will the test pass? // percentValue is changed by other function to 0 calculateTax(7) === 7.3500000000000005 //will the test pass or throw exception? As you can see here, the function is very hard to test. We can easily fix the issue, though, by removing the external environment dependency from our function, leading the code to this:var calculateTax = (value, percentValue) => { return value/100 * (100 + percentValue) } Now you can test this function without any pain. Before we close this section, we need to mention an important property about pure functions: Pure functions also shouldn’t mutate any external environment variables. In other words, the pure function shouldn’t depend on any external variables (as shown in the example) and also change any external variables. We’ll now take a quick look what we mean by changing any external variables. For example, consider the code in Listing 1-9.var global = "globalValue" var badFunction = (value) => { global = "changed"; return value * 2 } Listing 1-9

不良功能示例

调用 badFunction 函数时,它会将全局变量 global 更改为已更改的值。这值得担心吗?是的。设想另一个函数,它的业务逻辑依赖于全局变量。因此,调用 badFunction 会影响其他函数的行为。这种性质的函数(即具有副作用的函数)使得代码库难以测试。除了测试之外,这些副作用会使系统行为在调试时很难预测。

我们已经通过一个简单的例子看到了一个纯函数如何帮助我们轻松地测试代码。现在我们来看看我们从纯函数中得到的其他好处:合理的代码。

合理代码

As developers we should be good at reasoning about the code or a function. By creating and using pure functions we can achieve that very simply. To make this point clearer, we are going to use a simple example of function double (from Listing 1-8):var double = (value) => value * 2

看这个函数名,我们很容易推理出这个函数是给定数字的两倍,除此之外没有别的。事实上,使用我们的引用透明概念,我们可以很容易地用相应的结果替换 double 函数调用。开发人员大部分时间都在阅读别人的代码。在你的代码库中有一个带有副作用的函数会让你团队中的其他开发人员很难读懂。具有纯功能的代码库易于阅读、理解和测试。记住一个函数(不管它是否是一个纯函数)必须有一个有意义的名字。例如,考虑到函数的作用,不能将它命名为 double。

小心思游戏

我们只是用一个值来代替函数,好像知道结果却看不到它的实现。这对于你思考函数的过程是一个很大的进步。我们代入函数值,就好像这是它将返回的结果。

为了快速锻炼你的大脑,看看我们内置的 Math.max 函数的推理能力。

Given the function call:Math.max(3,4,5,6)

结果会怎样?

看到 max 给出结果的实现了吗?不是吧?为什么呢?这个问题的答案是数学。max 是一个纯函数。现在喝杯咖啡;你做得很好!

并行代码

纯函数允许我们并行运行代码。因为一个纯粹的函数不会改变它的任何环境,这意味着我们根本不需要担心同步。当然,JavaScript 没有真正的线程来并行运行这些函数,但是如果您的项目使用 WebWorkers 来并行运行多个东西会怎么样呢?还是并行运行函数的节点环境中的服务器端代码?

For example, imagine we have the code given in Listing 1-10.let global = "something" let function1 = (input) => {         // works on input         //changes global         global = "somethingElse" } let function2 = () => {         if(global === "something")         {                 //business logic         } } Listing 1-10

不纯函数

What if we need to run both function1 and function2 in parallel? Imagine thread one (T-1) picks function1 to run and thread two (T-2) picks function2 to run. Now both threads are ready to run and here comes the problem. What if T-1 runs before T-2? Because both function1 and function2 depend on the global variable global, running these functions in parallel causes undesirable effects. Now change these functions into a pure function as explained in Listing 1-11.let function1 = (input,global) => {         // works on input         //changes global         global = "somethingElse" } let function2 = (global) => {         if(global === "something")         {                 //business logic         } } Listing 1-11

纯函数

这里,我们将全局变量作为两个函数的参数,使它们变得纯净。现在,我们可以毫无问题地并行运行这两个功能。因为函数不依赖于外部环境(全局变量),所以我们不像清单 1-10 那样担心线程的执行顺序。

这一节向我们展示了纯函数如何帮助我们的代码并行运行而没有任何问题。

可缓存

Because the pure function is going to always return the same output for the given input, we can cache the function outputs. To make this more concrete, we provide a simple example. Imagine we have a function that does time-consuming calculations. We name this function longRunningFunction:var longRunningFunction = (ip) => { //do long running tasks and return } If the longRunningFunction function is a pure function, then we know that for the given input, it is going to return the same output. With that point in mind, why do we need to call the function again with its input multiple times? Can’t we just replace the function call with the function’s previous result? (Again note here how we are using the referential transparency concept, thus replacing the function with the previous result value and leaving the context unchanged.) Imagine we have a bookkeeping object that keeps all the function call results of longRunningFunction like this:var longRunningFnBookKeeper = { 2 : 3, 4 : 5 . . .  } The longRunningFnBookKeeper is a simple JavaScript object, which is going to hold all the input (as keys) and outputs (as values) in it as a result of invoking longRunningFunction functions. Now with our pure function definition in place, we can check if the key is present in longRunningFnBookKeeper before invoking our original function, as shown in Listing 1-12.var longRunningFnBookKeeper = { 2 : 3, 4 : 5 } //check if the key present in longRunningFnBookKeeper //if get back the result else update the bookkeeping object longRunningFnBookKeeper.hasOwnProperty(ip) ?       longRunningFnBookKeeper[ip] :       longRunningFnBookKeeper[ip] = longRunningFunction(ip) Listing 1-12

通过纯函数实现缓存

清单 1-12 中的代码相对简单。在调用我们真正的函数之前,我们正在检查具有相应 ip 的那个函数的结果是否在簿记对象中。如果是,我们将返回它,否则我们将调用我们的原始函数并更新我们的簿记对象中的结果。你看到我们用更少的代码使函数调用变得可缓存有多容易了吗?这就是纯函数的力量。

我们将会写一个函数库,在本书的后面,它会对我们的纯函数调用进行缓存,或者技术上的记忆。

管道和可组合

With pure functions, we are going to do only one thing in that function. We have seen already how the pure function is going to act as a self-understanding of what that function does by seeing its name. Pure functions should be designed in such a way that they should do only one thing. Doing only one thing and doing it perfectly is a UNIX philosophy; we will be following the same while implementing our pure functions. There are many commands in UNIX and LINUX platforms that we are using for day-to-day tasks. For example, we use cat to print the contents of the file, grep to search the files, wc to count the lines, and so on. These commands do solve one problem at a time, but we can compose or pipeline to do the complex tasks. Imagine we want to find a specific name in a text file and count its occurrences. How will we be doing that in our command prompt? The command looks like this:cat jsBook | grep –i "composing" | wc

这个命令通过组合许多函数解决了我们的问题。编写不仅仅是 UNIX/LINUX 命令行独有的;它是函数式编程范式的核心。在我们的世界里,我们称之为功能组合。假设这些相同的命令行已经在 JavaScript 函数中实现。我们可以用同样的原则来解决我们的问题。

现在换一种方式思考另一个问题。你想计算文本中的行数。你会怎么解决?啊哈!你得到了答案。根据我们的定义,命令实际上是一个纯粹的函数。它接受一个参数并将输出返回给调用者,而不影响任何外部环境。

遵循一个简单的定义,我们会得到很多好处。在我们结束本章之前,我们想说明一个纯函数和一个数学函数之间的关系。我们接下来处理这个问题。

纯函数是一个数学函数

Earlier we saw this code snippet in Listing 1-12:var longRunningFunction = (ip) => { //do long running tasks and return } var longRunningFnBookKeeper = { 2 : 3, 4 : 5 } //check if the key present in longRunningFnBookKeeper //if get back the result else update the bookkeeping object longRunningFnBookKeeper.hasOwnProperty(ip) ?       longRunningFnBookKeeper[ip] :       longRunningFnBookKeeper[ip] = longRunningFunction(ip) The primary aim was to cache the function calls. We did so using the bookkeeping object. Imagine we have called the longRunningFunction many times so that our longRunningFnBookKeeper grows into the object, which looks like this:longRunningFnBookKeeper = {    1 : 32,    2 : 4,    3 : 5,    5 : 6,    8 : 9,    9 : 10,    10 : 23,    11 : 44 }

例如,现在假设 longRunningFunction 的输入范围只有 1 到 11 个整数。因为我们已经为这个特定的范围构建了簿记对象,所以我们可以只引用 longRunningFnBookKeeper 来说出给定输入的输出 longRunningFunction。

我们来分析一下这个记账对象。这个对象让我们清楚地看到,我们的函数 longRunningFunction 接受一个输入,映射到给定范围的输出上(在本例中是 1–11)。这里需要注意的重要一点是,输入(在本例中是键)在对象中必须有相应的输出(在本例中是结果)。此外,key 部分中没有映射到两个输出的输入。

With this analysis we can revisit the mathematical function definition, this time providing a more concrete definition from Wikipedia ( https://en.wikipedia.org/wiki/Function_(mathematics) :

在数学中,函数是一组输入和一组允许的输出之间的关系,其性质是每个输入与一个输出正好相关。函数的输入称为自变量,输出称为值。给定函数的所有允许输入的集合称为该函数的定义域,而允许输出的集合称为共定义域。

这个定义和我们的纯函数完全一样。看看我们的 longRunningFnBookKeeper 对象。你能找到我们函数的共域吗?通过这个非常简单的例子,你可以很容易地看到数学函数思想是如何在函数范式世界中被借用的(如本章开头所述)。

我们将要建造的东西

在本章中,我们已经谈了很多关于函数和函数式编程的内容。有了这些基础知识,我们将构建名为 ES8-Functional 的函数库。这个库将在全文中一章一章地建立。通过构建函数库,您将探索如何使用 JavaScript 函数(以函数方式)以及如何在日常活动中应用函数式编程(使用我们创建的函数来解决代码库中的问题)。

JavaScript 是函数式编程语言吗?

Before we close this chapter, we have to take a step back and answer a fundamental question: Is JavaScript a functional programming language? The answer is yes and no. We said in the beginning of the chapter that functional programming is all about functions, which have to take at least an argument and return a value. To be frank, though, we can create a function in JavaScript that can take no argument and in fact return nothing. For example, the following code is a valid code in the JavaScript engine:var useless = () => {}

这段代码将在 JavaScript 世界中正确执行。原因是 JavaScript 不是一种纯粹的函数式语言(像 Haskell ),而是一种多参数语言。然而,这种语言非常适合本章讨论的函数式编程范例。到目前为止,我们已经讨论过的技术和好处可以在纯 JavaScript 中应用。这就是这本书的标题的原因。

JavaScript 是一种支持函数作为参数、将函数传递给其他函数等等的语言,这主要是因为 JavaScript 将函数视为其一等公民(我们将在接下来的章节中更多地讨论这一点)。由于术语函数定义的约束,我们作为开发人员在 JavaScript 世界中创建它们时需要考虑它们。通过这样做,我们将从本章讨论的函数式范例中获得许多好处。

摘要

在这一章中,我们已经看到了数学和编程世界中的函数。我们从数学中函数的简单定义开始,回顾了函数的小而实在的例子以及 JavaScript 中的函数式编程范例。我们还定义了什么是纯函数,并详细讨论了它们的好处。在本章的最后我们还展示了纯函数和数学函数之间的关系。我们还讨论了如何将 JavaScript 视为函数式编程语言。这一章取得了很多进展。

在下一章中,我们将学习在 ES8 环境中创建和执行函数。现在有了 ES8,我们有几种方法来创建函数;这正是我们将在下一章读到的内容。