JavaScript 中的基本函数式编程模式

2025-05-25

JavaScript 中的基本函数式编程模式

几年前,我发现了Arnau Sanchez的一个很有用的教程,它展示了如何用函数式编程方法取代常见的过程式编程模式。该教程是用 Ruby 编写的。最近我又想起了它,于是想把教程里的一些示例转换成 JavaScript 版本(不过,本文的文本内容是原创的)。

纯函数

函数式编程的核心是纯函数的概念。纯函数具有以下几个特点:

  • 我们可以反复调用纯函数,只要参数相同,它就总是返回相同的值。这意味着,获取用户输入、获取当前系统时间或检索特定股票价格的函数不是纯函数:即使我们使用相同的参数调用这些函数,也不能保证每次都返回相同的信息。
  • 纯函数没有副作用:如果一个函数在屏幕上打印内容、保存到数据库或发送短信,那么它就不是纯函数。另一个例子是状态性:如果调用一个函数改变了该函数作用域之外的变量,那也是一种副作用:调用该函数后,世界就不再一样了,所以它不是纯函数。

由于纯函数非常简单,它们有很多潜在的优势:它们更容易理解和测试。它们也易于缓存(记忆)。纯函数在多线程/多处理中非常有用,因为它们不需要在共享状态下进行同步。它还有其他好处,包括可能的编译器优化。本文将探讨的主要优势是如何利用函数式技术来减少重复,使代码更简洁、更易于维护。然而,实现这些优势可能会自动带来其他一些好处。

所以,纯函数固然很好,但它们显然有局限性:它们不能构成软件系统的全部。函数式编程的核心思想是处理编程中更复杂、更混乱的部分,例如处理状态和副作用,并在这些混乱的部分与其余代码之间定义清晰的接口:我们编写纯函数,并在其周围包裹一些更高级的代码,以处理编程中不纯粹的部分。

声明式 vs. 命令式

函数式编程与过程式编程的另一个区别在于,前者强调声明式编程风格。在过程式编程中,我们经常看到命令式代码来展示如何执行某项操作。而声明式方法则告诉我们结果应该是什么样子。我们将在本文的示例中看到这种差异。

人们确实可以用函数式语言编写命令式代码,也可以用过程式语言编写声明式代码!所以,这更多的只是侧重点的不同而已。函数式编程更倾向于强调声明式方法。

函数式编程的三位一体

迭代在很多方面都是编程的核心。在下面的示例中,我们将探索如何将一些常见的使用循环的过程式迭代模式转换为函数式方法。这些示例简洁明了,非常适合用作教程,但其核心思想——我们可以将纯函数插入到更高阶的抽象中——才是函数式编程的核心所在。

高阶函数是指接受另一个函数作为参数并/或返回另一个函数的函数。在 JavaScript 中,函数是“一等公民”。这意味着我们可以将它们赋值给变量,在其他函数内部创建它们,并像任何其他对象一样将它们作为参数传递。如果您熟悉回调,那么您一定接触过高阶函数!

函数式编程中的迭代依赖于高阶函数的三位一体:mapfilterreduce。让我们依次探讨一下。然后,我们再看看几个简单的变体:

Init+each+push -> map

让我们将一个列表转换为另一个列表。对于源列表中的每个项目,我们将在将其放入目标列表之前对其应用一些函数。例如,让我们取一个字符串列表,并生成一个包含相同字符串的大写列表。

程序化:我们创建一个空列表来保存结果。我们循环遍历源列表。对每个元素应用一个函数,并将其附加到结果列表中。

let uppercaseNames = []
for (let name of ['milu', 'rantanplan']) {
  uppercaseNames.push(name.toUpperCase())
}
console.log(uppercaseNames) // ['MILU', 'RANTANPLAN']

函数式map:我们对源列表执行一个操作。我们为 提供了一个回调函数map。在后台,map会遍历源列表,并对每个项目调用回调函数,将其添加到结果列表中。这里的目标是提取for循环样板并将其隐藏在一个高阶函数之后。剩下的就是编写一个包含我们关心的实际逻辑的纯函数。

const uppercaseNames = ['milu', 'rantanplan'].map(name => name.toUpperCase())
console.log(uppercaseNames) // ['MILU', 'RANTANPLAN']

Init+each+条件推送->过滤

这里我们从一个源列表开始并对其应用过滤器:对于每个项目,如果它符合条件,我们就保留它,否则我们就将其从结果列表中排除。

程序:我们设置一个空的结果列表,然后遍历源列表并将匹配的项目附加到我们的结果列表中。

let filteredNames = []
for (let name of ['milu', 'rantanplan']) {
  if (name.length === 4) {
    filteredNames.push(name)
  }
}
console.log(filteredNames) // ['milu']

功能:我们在回调中提供匹配逻辑filter,并让其filter完成遍历数组和根据需要应用过滤回调的工作。

const filteredNames = ['milu', 'rantanplan'].filter(name => name.length === 4)
console.log(filteredNames) // ['milu']

初始化 + 每个 + 累积 -> 减少

让我们获取一个字符串列表并返回所有字符串的长度总和。

程序:我们循环迭代,将每个字符串的长度添加到我们的sumOfLengths变量中。

let sumOfLengths = 0
for (let name of ['milu', 'rantanplan']) {
  sumOfLengths += name.length
}
console.log(sumOfLengths) // 14

函数式:首先,我们将map列表转换为长度列表,然后将该列表传递给reduce。对于每个项目,reduce运行我们提供的 Reducer 回调,并将累加器对象和当前项目作为参数传递。从 Reducer 返回的任何内容都将替换传入下一次迭代的累加器。同样,我们只需提供一个简单的纯函数作为回调,剩下的交给 Reduce 完成。

const total = (acc, len) => len + acc

const sumOfLengths = ['milu', 'rantanplan'].map(v=>v.length).reduce(total, 0)
console.log(sumOfLengths) // 14

reduce非常强大。事实上,我们可以用它来编写map和的实现filter

Init+each+accumulate+push -> 扫描

假设我们不仅想获取最终的总长度,还想跟踪中间值。在 Haskell 中,我们可以使用scan,但 JavaScript 没有内置scan函数。让我们自己创建一个吧!

程序:我们在循环的每次迭代中用运行总数更新列表for

let lengths = [0]
let totalLength = 0
for (let name of ['milu', 'rantanplan']) {
  totalLength += name.length
  lengths.push(totalLength)
}
console.log(lengths) // [0, 4, 14]

功能:代码看起来与使用 的版本非常相似reduce

const total = (acc, item) => acc + item.length

const lengths = ['milu', 'rantanplan'].scan(total, 0)
console.log(lengths) //[0, 4, 14]

以下是 的一个可能实现scan:这次,我们不再直接将回调传递给 reduce,appendAggregate而是在回调周围包装一个新的 reducer。appendAggregate它从累加器中获取包含运行总计的数组,并创建一个包含最新值运行总计的副本。这样,我们得到的不是reduce最终返回的单个值,而是所有中间总计的数组。

Array.prototype.scan = function (callback, initialValue) {
  const appendAggregate = (acc, item) => {
    const aggregate = acc[acc.length-1] //get last item
    const newAggregate = callback(aggregate, item)
    return [...acc, newAggregate]
  }

  const accumulator = [initialValue]

  return this.reduce(appendAggregate, accumulator)
}

初始化 + 每个 + 哈希 -> 混合

让我们看最后一个例子。假设我们要将一个列表转换为一个键值对的 Map。对于每个项,键就是该项本身,值则是对该项进行某种处理的结果。在下面的例子中,我们将一个字符串列表转换为一个对象,该对象以每个字符串为键,以字符串长度为值。

程序:我们创建一个空对象。对于列表中的每个项目,我们将该项目及其对应的值作为键添加到对象中。

const items = ['functional', 'programming', 'rules']

const process = item => item.length

let hash = {}
for (let item of items) {
  hash[item] = process(item)
}
console.log(hash) //{functional: 10, programming: 11, rules: 5}

功能:我们将每个项目转换为包含键和值的数组。mash将这些元组折叠成一个对象,它们成为实际的键/值对。

const items = ['functional', 'programming', 'rules']

const mashed = items.mash(item => [item, item.length])
console.log(mashed) // {functional: 10, programming: 11, rules: 5}

//also works: 
const alsoMashed = items.map(item => [item, item.length]).mash()
console.log(alsoMashed) // {functional: 10, programming: 11, rules: 5}

让我们看一下 的一个可能实现mash:我们使用与 相同的技巧scan。这次我们提供addKeyValuePairreduce。每次reduce执行此回调时,它将创建一个新对象,该对象包含累加器中的现有值以及与当前键值对相对应的新值。

Array.prototype.mash = function(callback) {
    const addKeyValuePair = (acc, item) => {
        const [key, value] = callback ? callback(item) : item
        return {...acc, [key]: value}
    }

    return this.reduce(addKeyValuePair, {})
}

以上两个示例进行了修改Array.prototype以支持scanmash。我不建议在实践中进行这种猴子补丁。这里我这样做是为了简单起见,使所有示例看起来相同。在实际应用中,我们可以将数组函数替换为以数组为参数的版本。这些函数可以用一个compose函数链接在一起。为了避免重复造轮子,我们也可以使用第三方函数式实用程序库,例如Ramda

讨论

希望以上示例能够展示如何使用函数式编程来减少日常代码中的样板代码,从而保持DRY原则。请注意,这些示例中的所有回调都是纯函数。这意味着它们不会改变外部世界的状态。特别是,appendAggregate它们addKeyValuePair不会修改作为参数接收的累加器对象。相反,它们会创建该对象的副本,其中包含所需的任何更改。

我不会在本文中详细阐述这一点,但至少应该在此提及深拷贝和浅拷贝之间的区别。使用扩展语法的示例代码...执行的是浅拷贝。通常,我们应该考虑函数是否会产生副作用,以及在函数执行过程中,传递给函数的状态是否会被外界以某种方式改变。由于我们的 Reducer 不会改变传入的参数,并且 JavaScript 是单线程/非抢占式的,因此浅拷贝在这里应该是可以的。然而,这是一个我们应该始终保持谨慎的问题。

使用纯函数通常会让程序员的工作更轻松。然而,它有一个缺点,那就是在某些情况下会影响性能:在我们的示例中,处理大型列表时,我们会创建大量短期对象,让垃圾收集器忙得不可开交。在当今这个拥有大量内存和强大计算机的时代,这在实践中通常不成问题。但是,如果它真的成了问题,那么我们可能不得不在设计上做出一些妥协。

Haskell 是一种相当纯粹的函数式语言,它利用函数的纯度保证和惰性计算特性来优化垃圾收集。然而,由于 JavaScript 等语言并不强制要求纯度,因此在 V8 引擎等引擎中,这似乎不太可能实现。

参考

有关的

函数概念的更高级应用

文章来源:https://dev.to/nestedsoftware/basic-function-programming-patterns-in-javascript-49p2
PREV
高级 NestJS:如何构建完全动态的 NestJS 模块
NEXT
git reset soft 何时使用 Git Reset、Git Revert 和 Git Checkout