函数式 JavaScript 的隐喻介绍
函数式 JavaScript 并非工具、框架、第三方插件、npm 模块或其他任何可以添加的东西。函数式编程是一种编写代码的方法,而函数式 JavaScript (FJS) 则是一种将这种方法应用于 JavaScript 的方式。与所有方法一样,它有其优缺点、权衡利弊、有人喜欢它,也有人不喜欢它,并且在国际政府中也存在不同的游说团体。
我坚定地支持 FJS。我已经写了好几年 JavaScript(虽然写得不好,但总体来说还不错),真希望一开始就学它。我发现 FJS 的好处非常值得我学习,它让我写出的代码更易读、更灵活、更易于维护。我曾经努力弄清楚写 FJS 意味着什么,但一旦搞清楚了,我就再也回不去了。
顾名思义,函数式编程就是编写一堆函数。某种程度上确实如此,但要做到这一点,必须遵循一些规则。这些规则通常乍一看很难理解,甚至在谷歌搜索之后也很难理解。
- 使用纯函数
- 不要改变状态
- 声明式,而非命令式
- 高阶函数
在本文中,我将尝试以更易于理解的方式分解 FJS 的这些元素。这绝不是一份详尽的指南,而是一个起点,以便人们能够更好地理解更详细、更全面的 FJS 学习资源。
让我们开始吧!
保持函数纯粹
使用纯函数并避免状态突变或许是编写 FJS 最重要的部分。我不会从常见的定义开始,而是放纵一下自己,用一场假想的晚宴来解释它们。
一位天使和一位变种人参加晚宴……
首先,想象一位天使。天使散发着柔和纯净的白色光芒,拥有闪闪发光的翅膀和宁静的面容。它们在地面上轻柔地摆动,动作流畅而优雅,却又充满目的性。任何活人都无法看见这位天使,它能穿透任何触碰过的地方。
假设这位天使在一个拥挤的晚宴角落里。你告诉天使,他们需要穿过房间,站在潘趣酒碗旁边。天使点点头,开始飘向这个位置。没有人能看到或触摸到它。没有人的谈话受到干扰,也没有人需要让开。得益于这一切,天使选择了最短的路线到达潘趣酒碗。如果晚宴上挤满了新客人,天使可以沿着同样的路径再次到达。
现在想象一下这位天使的反面:一个放射性突变体。这个突变体曾经是人类,但如今已变成了某种怪诞的生物。它们可以拥有任何你想要的怪诞特征:挥舞的触手、满是眼睛的背影、长着蹼和爪子的脚、一件印着过时几十年流行文化元素的T恤,或者拥有一家房地产公司。无论你选择哪种,这个突变体都令人恐惧,你无法长时间注视它。
假设这个变种人面临同样的任务:从晚宴的角落移动到潘趣酒碗。你可以想象那会多么可怕。人们会尖叫着推开变种人。而且,它的放射性会随机地使人发生不同的变异,客人们也会纷纷逃离。变种人需要沿着一条难以预测的路径推推搡搡才能到达那个位置。如果你在派对上重新开始这个场景,不同的客人会有不同的变异,人类会以新的方式感到恐慌。变种人需要走一条不同的、但同样崎岖的路线才能到达潘趣酒碗。
成为纯函数
您可能已经猜到了,天使具有纯函数的所有特质。
- 外部状态不会改变。天使穿过房间时,没有任何人或任何事物发生改变。纯函数完成其工作时,函数外部的任何事物也不会发生改变。
- 相同的输入会有相同的结果。天使每次都会沿着相同的路径到达相同的地点。纯函数每次输入相同的内容时,都会返回相同的结果。
如果名字还不足以说明问题的话,突变体具有改变状态的函数的所有特性。
- 函数外部的变量会受到影响。变异函数会通过吓唬派对嘉宾并让其他人变异来影响其他人。不纯函数会有意或无意地改变其外部的变量。
- 相同的输入可能会有不同的结果。突变体会让随机的人发生变异,这会改变恐慌的类型,从而改变突变体每次采取的路径。非纯函数会返回不同的值,因为它们每次都会影响外部变量。
下面这段 JavaScript 代码能帮你理解这一切。下面的addNumber
函数是天使还是变种?
let number = 0;
let addNumber = x => {
number += x;
return number;
}
addNumber
是一个突变体,因为它改变了number
函数外部的变量。这些变化意味着我们可以使用相同的参数运行该函数两次,并得到不同的结果。
addNumber(5) // 5
addNumber(5) // 10 (which is not 5)
如果我们想要一个纯粹的天使函数,我们会像这样重写一个。
let number = 0;
let addNumbers = (x, y) => x + y;
我们并不依赖外部变量,而是将传入的两个数字都设为变量。这样可以将函数的所有变量保留在其自己的范围内,并且相同的输入会产生相同的结果。
addNumbers(number, 5); // 5
addNumbers(number, 5); // 5 (which is 5)!
FJS 使用纯函数,因为它们就像天使一样。天使是善良的,变异体是邪恶的。别让变异体得逞。使用纯函数。
声明式,而非命令式
我一直很难理解声明式编程和命令式编程之间的区别。首先,我要知道声明式编程和命令式编程都是有效的方法,各有优缺点。函数式编程只是更倾向于声明式。
至于具体细节,我们再想象一下两个不同的人。这次,一个南方美女和一个马夫。我们请她们俩去取一桶牛奶,并给她们一个空桶作为任务。
南方美女性格傲慢,讨厌弄脏自己的手。她会召唤仆人,说道:“我郑重声明,如果外面有牛,就用像这样的桶给我拿一桶牛奶来!”仆人鞠了一躬,检查了一下桶,然后离开,又提着一桶牛奶回来了。这桶牛奶装在另一个桶里,和我们给她的那桶一模一样。南方美女接过牛奶递给我们。
马夫喜欢亲自动手。他负责这项任务:拿着桶,去谷仓,找到一头牛,然后像往常一样挤奶。他选对了牛,给牛挤奶,把牛奶倒进桶里,然后亲自把桶拿回来给我们。
两个人都给我们弄来了那桶牛奶,尽管方式截然不同。南方美女没有参与取牛奶的具体步骤,她专注于自己需要的东西,并让仆人帮忙取牛奶。而马夫则专注于如何取牛奶,并完成了所有步骤。
从本质上讲,这就是声明式编程和命令式编程的区别。声明式编程根据需求解决问题,避免直接操作 DOM 或变量。这非常适合纯函数,因为它们会提供新的数据和对象,以避免改变状态。而命令式编程会修改 DOM 并操作状态,但方式更专注,如果操作正确,控制力会更强。
为了通过一些代码示例更好地提醒您这一切,我只是向您推荐了这条推文!
Liquid 错误:内部
当您不编写 JavaScript 来操作 DOM 时,我通过声明新变量而不是改变现有变量来进行声明式编程。
例如,假设你需要编写一个函数,将数组中的所有数字翻倍。命令式方法会直接操作给定的数组,并重新定义每个元素。
const doubleArray = array => {
for (i = 0; i < array.length; i++) {
array[i] += array[i];
}
return array;
}
这段代码相当于马夫拿着数组,将每个元素翻倍,然后返回一个变异后的数组。声明式版本看起来完全不同。
const doubleArray = array => array.map(item => item * 2);
这个声明式版本将工作交给了另一个函数,在本例中是map
,它已经内置了遍历每个元素的逻辑(我们稍后会介绍)。它返回一个与原始数组不同的数组,并且第一个数组不会被改变,这使得它成为一个纯函数!因此,这个函数更简单、更清晰、更安全,并且更符合 FJS 的规范。
南方美女只是声明她想要一个具有双倍值的数组,而她的仆人(map
)返回一个不同的数组来满足她的要求。
使用正确的 FJS 工具
好了,比喻就到此为止。让我们深入探讨一下编写 FJS 的具体方法。首先,我们来介绍一下编写纯命令式函数时最常用的一些工具。
箭头函数
箭头函数是 ES6 新增的,其主要优势在于函数语法更简洁、更简洁。FJS 意味着要编写大量的函数,所以我们不妨简化一下。
在箭头函数出现之前,一个基本的“将五加到一个数字”函数看起来是这样的。
const addFive = function(number) {
return number + 5;
}
像这样的简单函数可以不用function
关键字或显式返回来编写。
const addFive = number => number + 5;
变量首先标识参数,在本例中为number
。您也可以使用括号表示无参数,例如()
,或表示有多个参数,例如(number1, number2)
。
之后是箭头,显示为=>
。后面跟着的表达式将自动返回,在本例中,表达式number
加了 5。
更复杂的函数可以使用括号括起来,但这样会丢失隐式表达式return
,需要重新写出来。虽然不如第一种语法好,但仍然比第一种语法好。
const addFive = number => {
// more code here
return number + 5;
};
数组原型方法
每个数组都内置了几个强大的工具,可以满足你大部分(甚至全部)的 FJS 需求。调用它们会返回新的、经过修改的数组,你可以轻松地将其赋值给新的变量。它们就像声明式隐喻中南方美女的仆人一样——它们已经存在,为你完成工作,并根据你最初的状态返回新的对象。
让我们从最基本的方法之一开始map
。它获取数组中的每个项目,通过函数运行它来获取新值,并用新值替换旧值。对每个项目执行此操作后,它将返回一个更新后的数组。
这是之前的声明性代码示例的调整示例,但使用map
双倍数组值。
[2, 4, 6].map(item => item * 2);
// [4, 8, 12]
您基本上是使用map
拉出每个数组对象并item
说“item
用替换它item * 2
”。
你也可以单独编写加倍函数,使代码更加实用。此外,你还可以将map
返回值赋给一个完全不同的变量。
const double = (item) => item * 2,
array = [2, 4, 6],
doubledArray = array.map(double);
console.log(array); // [2, 4, 6]
console.log(doubledArray); // [4, 8, 12]
// The original array hasn't been mutated!
有很多很棒的方法值得学习,如果要全部介绍的话,那就另当别论了。可以查看我的学习仓库,快速了解不同的数组原型方法,或者直接在 Google 上搜索一下!
奖励:链式数组原型方法
还有一个有趣的事实你应该知道:数组方法可以链接在一起!这可以让你快速组合不同的数组更改,而不会违反 FJS 规则。
假设我们想将每个数组值翻倍,然后过滤掉小于 5 的值(filter
这是另一个以后会学习的有用方法)。我们只需要编写一个额外的函数,并在数组中添加另一个方法。
const double = (item) => item * 2,
higherThanFive = (item) => item > 5,
array = [2, 4, 6],
doubledArray = array.map(double).filter(higherThanFive);
console.log(array); // [2, 4, 6]
console.log(doubledArray); // [8, 12]
最后,很多人(比如我自己)在链接时经常使用不同的间距来保持可读性。下面的变量与上面的相同,但一目了然,更容易理解。
doubledArray = array
.map(double)
.filter(higherThanFive);
记住减少
reduce
是我想重点介绍的一个原型方法,因为它可以说是最强大的。它本身可以重现几乎任何其他原型方法,甚至可以创建更复杂、更强大的方法。Reduce 也是另一篇博文,因此我再次推荐您参考我的学习笔记(或谷歌搜索),以便快速了解更多信息reduce
。只需记住以下重要事实:
- Reduce 非常适合复杂的数据合并或操作,只要您确保另一个原型方法尚未完成您需要的操作。
- 其他原型方法的所有内容(例如链接和传递函数)都适用于 reduce。
reduce
是您最强大的 FJS 工具之一,因此请好好学习它。
高阶函数
既然我们有了这些编写函数的新方法,接下来就是管理它们的新方法。FJS 的最佳方法之一是利用高阶函数(HOF)。到目前为止,已经有几个代码示例使用了 HOF,但更清晰的定义有助于充分利用它。
HOF 是接受其他函数作为参数的函数。记住,函数是 JavaScript 王国中的一等公民,因此它们可以:
- 保存到变量
- 传递给其他函数
- 从其他函数返回
我希望自己早点学会使用 HOF。它帮助我编写了更多能够抽象逻辑的函数,使程序更易读、更易于维护。所以,养成“函数传递函数”的思维模式对 FJS 来说很有帮助,而且总体上也能让 JavaScript 变得更好。
假设我需要测试多个数字是否能被一组其他数字整除。人们的第一反应可能是把每个函数写成这样。
const divisibleby3 = (n) => n % 3 === 0,
divisibleby5 = (n) => n % 5 === 0,
divisibleby7 = (n) => n % 7 === 0;
divisibleBy3(6); // true
divisibleBy5(14); // false
divisibleBy7(28); // false
这可行,但你必须一遍又一遍地重复相同的表达式。使用 HOF 的解决方案应该如下所示,并得到相同的结果。
const divideBy = (x) => (y) => y % x === 0;
const divisibleBy3 = divideBy(3),
divisibleBy5 = divideBy(5),
divisibleBy7 = divideBy(7);
divisibleBy3(6); // true
divisibleBy5(14); // false
divisibleBy7(28); // true
这很复杂,所以让我们分解一下。
- 该
divideBy
函数接受一个参数,x
并在返回另一个函数时保存它。因此,当我们调用时divideBy(3)
,我们x
每次都会将其保存为返回函数的一部分。 - 我们可以将此函数保存到变量中,例如
divisibleBy3
。这是有意义的,因为我们已经使3
函数的一部分每次都返回。 - 由于
divideBy
返回一个函数,我们现在可以divisibleBy3
像调用普通函数一样调用它。它既使用y
调用时获取的变量,也x
使用之前获取的变量。
以上都是“柯里化”函数或返回其他函数直到最终返回最终函数(例如)的示例divisibleBy3
。如您所见,JavaScript 中的柯里化有很多使用 HOF 的示例。如果您的函数有一些(但不是全部)共同的逻辑,那么柯里化就非常有用。您可以创建一个包含它们共同逻辑(比较运算符)的模板,并传入每个函数特有的逻辑(运算中使用的数字)。
如果您不想将柯里化函数的第一部分保存到变量中,则可以使用同时调用多个参数的快捷方式。
const divideBy = (x) => (y) => y % x === 0;
divideBy(3)(6); // true
divideBy(5)(14); // false
divideBy(7)(28); // true
正如您所见,无论您在程序中使用一次还是数十次,柯里化函数都会对您的代码有所帮助!
柯里化函数也很难理解,因此我另外指出,如果需要的话最好将柯里化分解开来。
这是另一个 HOF 的例子,它以函数作为fn
参数。接受它的函数像引用其他变量一样引用它。
const performMultipleTimes = (times, x, fn) => {
for (let i = 0; i < times; i++) {
fn(x);
}
}
此函数有三个参数:
- 重复循环的次数
fn
传递给函数的参数- 函数
fn
该函数在循环内部被调用,因此performMultipleTimes
我们只需编写一次即可多次调用该函数。我们需要做的就是将一个函数传递给另一个函数,这可以通过将函数存储在变量中来实现。高阶函数 (HOF) 再次发挥作用!
const logString = s => console.log(s);
performMultipleTimes(3, 'Greetings!', logString);
// Greetings!
// Greetings!
// Greetings!
如果您不在其他任何地方使用此函数,并且不想将其保存以供日后使用,您也可以直接传递它。您可以使用或不使用括号语法来执行此操作。
performMultipleTimes(3, 'Greetings!', (s) => console.log(s));
performMultipleTimes(3, 'Greetings!', (s) => {
const newString = `I am here to say '${s}'`;
console.log(newString);
});
理解 HOF 很重要,尤其适用于 FJS。这种风格注重函数的强大功能,而有效地传递函数可以成倍提升函数的功能和模块化程度。
然而,这对我来说很难理解,对你来说可能也一样。所以,如果你(可以理解)仍然感到困惑,Eloquent JavaScript 的这一章对 HOF 进行了更深入的解析,效果非常好。
FJS 是一种风格,而不是绝对的
关于 FJS 的最后一点:它是一种存在于特定范围内的 JavaScript 编写风格。它并非简单的“这是或不是 FJS”。你可以使用 FJS 的元素(例如纯函数或声明式)编写代码,而无需遵循任何规则。每一种都是一种偏好,当它们组合在一起时,你的 JavaScript 代码将更接近函数式范式。
从我引用的额外阅读链接数量就可以看出,FJS 可能比较难掌握。但理解这四个主题能帮助你为进一步学习打下坚实的基础。对我来说,这一点尤其重要,因为我读到的每一篇关于 FJS 的文章都让我豁然开朗。希望这篇文章也能帮助其他想要学习和使用它的人。
如果你真的想深入了解 FJS,我推荐Kyle Simpson 的书《Functional-Light JS》。它对函数式编程和 JavaScript 进行了更深入的讲解,你可以在 Github 上免费阅读!
文章来源:https://dev.to/maxwell_dev/a-metaphorical-introduction-to-function-javascript-3og4