JS 中的函数式编程,第一部分 - 组合(Currying、Lodash 和 Ramda)
在本系列文章中,我们将对 JavaScript 中的函数式编程进行简单的介绍。
每篇文章都会深入探讨函数式编程的不同方面。在理论介绍之后,我们将看到这些概念如何在实际的 JavaScript 库中得到运用。
这种理论与实践的结合将确保您深入了解所有概念,同时能够在日常工作中毫不费力地运用它们。
请注意,本系列文章假设您已经熟练掌握了使用数组方法(例如map
、filter
和)编写代码的能力reduce
。如果您仍然感到困惑,请告诉我,我会写一篇文章深入解释它们。
准备好了吗?我们开始吧!
作品
如果我必须用一个词来概括本文第一篇文章的重点,那就是组合或可组合性。
更具体地说,我指的是用小型的、可重用的函数来编写代码的艺术。就像用更小的积木来组装一套乐高积木一样。
事实证明,正确编写的函数式代码具有极强的可组合性。这意味着什么?这意味着,我们可以非常轻松地从中取出一小段代码,并在完全不同的场景中重用它。
看一下以传统风格编写的这段代码:
let result = [];
for (let i = 0, i < data.length, i++) {
const num = parseInt(data[i], 10);
if (num < 5) {
result.push(num);
}
}
现在将其与以下内容进行比较:
const stringToInt = str => parseInt(str, 10);
const lessThan = compareTo => num => num < compareTo;
const result = data
.map(stringToInt)
.filter(lessThan(5));
这两个代码片段的作用完全相同。我们首先获取一个data
数组,其中包含一些字符串。然后,我们将这些字符串转换为整数。最后,我们将严格小于 5 的整数存储在一个新数组中。我们将该数组保存在result
变量 下。
因此,如果我们得到一个["1", "6", "3"]
数组,我们将返回[1, 3]
结果。
根据你更习惯的风格,你会发现上面两个代码片段中有一个更具可读性。我认为第二个代码片段更具可读性,因为——不考虑我们定义的小辅助函数——它读起来几乎像英语:
取每个data
,然后只取那些值。map
stringToInt
filter
lessThan(5)
但是,如果你不习惯函数式风格,那么第二段代码会显得笨拙且不必要地复杂。用这种风格编写代码有什么客观好处吗?
当然!而这个好处就是可组合性。请注意,我们特意将代码中最简单的部分都定义为函数。正因如此,我们现在可以在全新的场景中使用这些代码片段,而无需重复编写相同的代码。
当然,这些可复用stringToInt
函数极其lessThan
简单,甚至可以说是不值得复用。但请记住,这个例子只是对整个方法的一个启发。
在更复杂的应用程序中,这些函数会变得越来越复杂。在更大的代码库中,尽可能多地重用代码并利用之前编写的函数编写新代码的方法将带来更明显的好处。
还要注意,除了最简单的可重用性——简单地在不同的上下文中使用stringToInt
和lessThan
函数——我们还看到了使用高阶数组函数的例子——map
和filter
。值得注意的是,它们拥有强大的功能——它们允许你将为奇异值(例如字符串)定义的函数应用于这些值的整个数组(例如字符串数组)。
这是你第一次真正体会到这种方法的威力。你写了两个函数 -stringToInt
和lessThan
- ,它们本不应该用于数组。然而,只需用几个字符 - .map(stringToInt)
,包裹它们.filter(lessThan(5))
,你就能突然拥有对整个数组使用这些函数的能力。
这正是我们一开始想要表达的意思。函数式编程允许你在完全不同的上下文中使用相同的代码——事实上,同样的代码甚至可以用于完全不同类型的值!一个原本只能处理字符串的函数现在可以处理字符串数组了!这太酷了。
柯里化
也许你已经问过自己——“等等,这个奇怪的lessThan
关于的定义是什么?”。
如果我让你编写一个lessThen
函数,你可能会这样做:
const lessThan = (num, compareTo) => num < compareTo;
但我们却这样做了:
const lessThan = compareTo => num => num < compareTo;
不仅参数被切换了,函数定义的语法也不同了。这难道是 JavaScript 标准中新增的、奇特的功能吗?
事实上,不是。我们在这里做的只是编写了一个返回另一个函数的函数。
我们返回的函数是:
num => num < compareTo;
然后我们将其包装在另一个函数中,最终compareTo
为其提供变量:
compareTo => (num => num < compareTo);
这次我们将返回的函数括在括号中,以提高可读性。
注意,我们这里利用了箭头函数可以直接提供返回值,而不是函数体。如果我们真的想写函数体,可以像这样重写上面的例子:
compareTo => {
return num => num < compareTo;
};
事实上,这个模式并不真正依赖于 ES6 箭头函数语法。我可能用老式的函数语法来写:
function(compareTo) {
return function(num) {
return num < compareTo;
};
}
然而,ES6 箭头语法的作用是让这段可怕的代码看起来更加美观:
compareTo => num => num < compareTo;
这种模式被称为柯里化。
如果你采用一个带有一定数量参数的函数:
const someFunction = (a, b, c) => {
// some code here
};
你可以“咖喱”它(或产生它的“咖喱”版本),看起来像这样:
const someFunction = a => b => c => {
// some code here
};
在这种情况下,原始函数接受三个参数。
对其进行柯里化之后,我们得到一个接受一个参数的函数a
,返回一个接受一个参数的函数b
,然后返回一个接受一个参数的函数c
,最后执行原始函数的主体。
好的,我们解释了该机制的工作原理,但我们没有解释为什么我们决定以这样的方式编写我们的函数。
坦白说,答案非常简单。唯一的原因是我们以后可以lessThan
像这样使用函数:
.filter(lessThan(5))
请注意,如果我们使用该函数的第一个定义:
const lessThan = (num, compareTo) => num < compareTo;
那么在方法中应用它filter
就没那么好了。我们必须像这样写代码:
.filter(num => lessThan(num, 5))
因此,您再次看到,我们以一种可以使其与诸如等方法很好地组合的方式编写了我们的函数filter
。
事实上,它也能很好地与 组合map
。代码如下:
numbers.map(lessThan(5))
将返回一个布尔数组,表示数组中给定位置的数字是否小于 5。例如,在数组上运行该代码[5, 1, 4]
将返回一个数组[false, true, true]
。
因此您可以看到该lessThen
函数现在可以与其他高阶函数更好地组合。
除此之外,假设我们注意到我们lessThen
经常使用的数字是5。这或许是一个非常重要的数字,比如我们公司服务器的数量。
这个数字现在出现在我们代码中的几个地方。但像这样硬编码它是一个非常糟糕的做法。如果这个数字在某个时候发生变化,例如变成了 6,该怎么办?我们必须搜索所有出现 5 的地方,然后手动将它们改为 6。这既非常繁琐,又容易出错。
我想到的第一个解决方案是将该数字存储在一个变量中,该变量是一个常量,具有一些语义名称来描述该数字的真正含义:
const NUMBER_OF_SERVERS = 5;
现在我们可以使用常数,而不是数字:
.filter(lessThan(NUMBER_OF_SERVERS))
如果该数字发生变化(例如,我们公司购买了更多服务器),我们可以在定义该常量的地方简单地更新它。
lessThan
这当然更好并且可读性更强,但导入两个单独的值(和)仍然有点麻烦,NUMBER_OF_SERVERS
尽管我们总是想将它们一起使用。
但是,我们定义函数的方式lessThan
可以解决这个问题。我们可以简单地将返回的函数存储在另一个变量中!
const lessThanNumberOfServers = lessThan(NUMBER_OF_SERVERS);
现在,每当我们想要使用该函数和特定值时,我们只需导入一次并直接使用它:
.filter(lessThanNumberOfServers)
因此,我们的函数不仅可以与其他函数更好地组合,而且还允许我们以非常简单的方式定义新函数。
很多时候,函数中的某些值只是某种配置。这些值不会经常更改。事实上,你经常会发现自己在函数内部硬编码了这些值:
const someFunction = (...someArguments) => {
const SOME_VALUE_THAT_WILL_PROBABLY_NOT_CHANGE = 5;
// some code here
};
有时将这样的值作为柯里化函数的参数并简单地创建一个新函数是一个好主意,并且该值已经设置为我们期望最常见的值:
const someBiggerFunction = (someValueThatWillProbablyNotChange) => (...someArguments) => {
// some code here
}
const someFunction = someBiggerFunction(5);
这种模式很方便,因为它最终会给出相同的结果——一个内部硬编码了值的函数。但同时,你也获得了更大的灵活性。当需要将该变量设置为其他值时,你可以轻松地完成,无需任何重构,只需传入someBiggerFunction
另一个参数运行即可。
因此,正如我们所见,使用柯里化版本的函数可以给我们带来更大的可组合性,既可以更轻松地在其他组合中使用这些函数,也可以轻松地组合全新的函数。
Lodash 和 Ramda
我希望现在已经清楚,为了使用函数式编程的这个方面,您不需要任何外部库。
您所需要的一切都已经融入到 JavaScript 本身中(最显著的是箭头函数语法)。
但是,如果您决定以这种风格编写代码,那么使用流行的函数式编程实用程序库之一也许不是一个坏主意。
毕竟,编写可组合代码的好处之一就是可重用性。这意味着从头编写别人已经写好并仔细测试过的代码是毫无意义的。
另外,正如我们所见,用函数式风格编写 JavaScript 可以让你的功能尽可能通用。所以,如果你能简单地用两三个现有的函数组合成一个函数,那么再写一个全新的函数来解决一个特定的问题就太愚蠢了。
那么让我们来看看Lodash和Ramda,看看它们能为以函数式风格编码的程序员提供什么。
值得一提的是,在 Lodash 的情况下,我们将特别讨论lodash/fp
包,它是更适合函数式编程的库版本。
另一方面,Ramda 开箱即用地支持函数式风格。
柯里化 API
我们花了这么多时间来描述柯里化,因为它确实是函数编程中一个非常强大的工具。它如此强大,以至于它被内置在 Ramda 和 Lodash 库中。
看一下 RamdassplitWhen
函数,它允许您使用一个函数来拆分数组,该函数通过对所选参数返回 true 来决定拆分发生的位置。
例如,给定一个数字数组,我们可能希望在数字 5 第一次出现时将其拆分。因此,我们首先构建一个函数,该函数根据数组中的任意元素检测数字 5。
听起来很复杂?其实并不复杂:
x => x === 5
现在我们可以在 RamdassplitWhen
函数中使用该函数了。运行以下代码:
import { splitWhen } from 'ramda';
splitWhen(x => x === 5, [1, 2, 5, 6]);
结果将是一个由两个数组组成的数组:
[[1, 2], [5, 6]]
因此我们看到原始数组按照我们想要的方式在 5 处被分割。
请注意,我们splitWhen
以传统方式执行函数,向其传递两个参数并获取一些结果。
但事实证明,Ramda 中的函数也可以像柯里化函数一样工作。这意味着我们可以创建一个新函数,如下所示:
const splitAtFive = splitWhen(x => x === 5);
请注意,这次我们没有splitWhen
同时传递两个参数。我们创建了一个新函数,它等待传入一个数组。运行splitAtFive([1, 2, 5, 6])
后将返回与之前完全相同的结果[[1, 2], [5, 6]]
:
由此可见,Ramda 开箱即用地支持柯里化!这对于喜欢函数式编程的人来说真是太棒了。
说到这儿,我们可以提一下,Ramda 有一个equals
方法,它基本上是一个===
运算符的包装器。
这看起来可能毫无意义(毕竟equals(2, 3)
可读性比 稍差一些2 === 3
),但因为所有 Ramda 函数都支持柯里化,equals
也不例外,所以我们可以splitAtFive
像这样重构我们的函数:
const splitAtFive = splitWhen(equals(5));
这读起来基本上就像英语一样!这就是函数式编程的魅力所在。
最后一个例子有效,因为splitWhen
只能接受一个参数函数。equals
需要两个参数,但由于柯里化,我们可以先提供一个参数,而第二个参数将由其本身提供splitWhen
。
这与我们之前创建的函数完全相同的技巧lessThan
。
柯里化你自己的函数
我们提到过,使用箭头语法在现代 JavaScript 中编写柯里化函数非常容易。例如,我们可以equals
像这样实现效用函数:
const equals = a => b => a === b;
但这种方法有一个缺点。如果你将一个函数定义为柯里化函数,那么现在你只能以柯里化的形式使用它。也就是说,equals(5, 4)
现在写法就行不通了。
这是因为即使你传递了两个参数,我们的equals
函数只需要一个。第二个参数会被忽略,函数返回另一个函数,而我们刚才可以将第二个参数应用到这个函数上。
所以最后我们不得不通过编写来使用此功能equals(5)(4)
,这也许并不悲惨,但看起来有点尴尬。
幸运的是,Ramda 和 Lodash 都为我们提供了一个方便的curry
辅助函数,可用于生成在柯里化和非柯里化形式下均可工作的函数。
因此,使用 Ramda 库,我们可以equals
像这样定义我们的函数:
import { curry } from 'ramda';
const equals = curry((a, b) => a === b);
现在我们可以通过调用以传统方式使用此函数equals(5, 4)
,但我们也可以利用其柯里化形式 - 例如 - 在过滤方法中仅向其传递一个参数:
.filter(equals(5))
这种多功能性是许多函数式编程语言的内置特性。借助curry
辅助函数,我们可以轻松地在 JavaScript 中实现同样的效果。
JS 方法的函数式包装器
关于 Ramda 和 Lodash 库,我最后想提的是原生 JavaScript 函数和方法的包装器。
我们已经看到,语言中已经可用且简单的东西(比如相等性检查)都有相应的包装器(equals
函数),以便更容易地使用它们进行函数式编程。
其他方法也是如此。例如,流行的数组方法map
filter
和reduce
函数在 Ramda 和 Lodash 中都有对应的函数。
这为什么有用?
正如我们反复提到的,函数式编程的重点在于易于组合。创建一个具有新行为的函数应该非常容易,最好是与其他函数组合起来。
假设stringToInt
我们现在想创建一个处理字符串数组的版本,那么显而易见的解决方案是这样的:
const stringsToInts = strings => strings.map(stringToInt);
这还不是最糟糕的,但是有没有办法写得更干净呢?
首先要注意的是,该map
方法接受两个参数,而不是一开始看起来只有一个。它的第一个参数是字符串数组(按照方法语法,在点之前),第二个参数是一个函数(在常规函数括号内):
firstArgument.map(secondArgument);
这种面向对象的语法让事情变得有点混乱。假设这map
是一个普通函数,而不是方法。那么我们可以像这样重写代码:
const stringsToInts = strings => map(strings, stringToInt);
可是等等。现在我们注意到了一件事。我们能不能用柯里化的 map 来写这段代码?在尝试之前,我们先反转一下函数接受的顺序strings
和参数:stringToInt
const stringsToInts = strings => map(stringToInt, strings);
我们有一个函数,它接受一个数组作为参数并返回一个数组。但这正是柯里化版本map
能做到的!让我们看看:
const stringsToInts = map(stringToInt);
哇哦!这到底发生了什么?让我们一步一步地回顾一下这个例子。
map
是一个函数,它接受两个参数:一个数组和一个函数,并返回一个新数组。如果map
是柯里化,我们可以只提供一个参数——函数。
我们最终会得到什么结果呢?柯里化函数返回了另一个函数,该函数等待第二个参数。在本例中,第二个参数是一个数组,因为到目前为止我们只传递了函数。
因此我们得到...一个接受数组并返回数组的函数(stringToInt
当然是在将函数应用于每个参数之后)。
但这正是我们想要的!
事实上,这两个功能:
const stringsToInts = strings => strings.map(stringToInt);
const stringsToInts = map(stringToInt);
行为完全一样!运行后,["1", "2", "3"]
我们得到了[1, 2, 3]
。
再次强调,哪种代码看起来更干净完全取决于您过去的经验,但您不能否认,使用柯里化版本map
至少可以让您在编写代码时更加灵活。
请注意,我们必须对 map 进行三处更改:我们必须使其成为一个函数(而不是方法),我们必须反转参数的顺序,我们必须使该函数柯里化。
这正是 Ramdas 和 Lodash 数组方法与其本机实现的不同之处。
当使用本机 JavaScript 实现编写函数代码显得笨拙且复杂时,您可以使用这些(以及更多)包装函数。
结论
本文的主题是可组合性。我试图向你展示如何利用函数式编程模式,尤其是柯里化函数,让你的代码库更具可组合性。
然后我介绍了一些函数式编程实用程序库(如 Ramda 和 lodash)如何使在 JavaScript 中编写这种风格的代码变得更容易。
我强烈建议你完全用函数式风格编写一些代码。我不会在生产环境中这样做,因为我认为最易读的 JavaScript 是函数式和面向对象方法的混合体。但这仍然是一个很好的练习,可以帮助你深入了解那篇文章中描述的概念。
实践是关键。如果你这样做,很快即使是最令人困惑的函数式代码,在你看来也会比传统的替代方案更简单、更友好。
如果您喜欢这篇文章,请在Twitter上关注我,我会定期发布有关 JavaScript 编程的文章。
感谢阅读!
(封面照片由La-Rel Easter在Unsplash上拍摄)
鏂囩珷鏉ユ簮锛�https://dev.to/mpodlasin/function-programming-in-js-part-i-composition-currying-lodash-and-ramda-1ohb