发布于 2026-01-06 2 阅读
0

何时在 JavaScript 中使用柯里化?

何时在 JavaScript 中使用柯里化?

这篇文章将介绍函数式编程中的柯里化概念,以及何时应该在 JavaScript 中使用它。

说实话,你可能根本不需要在 JavaScript 中使用柯里化。事实上,除非只是为了好玩,否则强行把它塞进你的代码里弊大于利。柯里化只有在你完全接受函数式编程时才有用,而在 JavaScript 中,这意味着使用像Ramda这样的库,而不是标准的内置函数。

在本文中,我将首先解释什么是柯里化,然后展示它在函数式编程环境中的用途


什么是咖喱?

柯里化是指函数永远不需要接受多个参数;每个函数只接受一个参数。如果一个函数需要表现得像接受多个参数一样,它会返回另一个函数。

你通常看到的是普通的、非柯里化的函数:

const add = (x, y) => x + y

console.log(
  add(2, 3) // 2 + 3
) // prints 5
Enter fullscreen mode Exit fullscreen mode

这是一个简单的函数,它接受两个数字并返回它们的和。

同一函数的柯里化版本如下所示:

const addCurried = x => y => x + y

console.log(
  addCurried(2)(3) // 2 + 3
) // prints 5
Enter fullscreen mode Exit fullscreen mode

这个函数不再接受两个参数,而是接受一个参数,然后返回另一个接受一个参数并返回总和的函数。注意,我们需要使用更多的括号来传递参数,因为参数是逐个传递给每个嵌套函数的。

这样我们就可以提出任意多的论点:

const addMore = a => b => c => d => e => a + b + c + d + e

console.log(
  addMore(1)(2)(3)(4)(5) // 1 + 2 + 3 + 4 + 5
) // prints 15
Enter fullscreen mode Exit fullscreen mode

由于柯里化函数的特殊工作方式,我们可以进行一种称为部分应用的操作。这指的是我们给函数提供的参数数量少于它所能接受的参数数量:

const addCurried = x => y => x + y

console.log(
  addCurried(2) // y => 2 + y
) // [Function (anonymous)]
Enter fullscreen mode Exit fullscreen mode

如果我们只传递addCurried一个参数,结果会是一个需要另一个参数的函数。换句话说,部分结果2被传递到了x参数中,所以我们只剩下部分结果y => 2 + y。如果需要,我们可以将这个部分应用的函数存储到一个变量中,以便事后使用它:

const addCurried = x => y => x + y
const add2 = addCurried(2)
// This is the same as:
// const add2 = y => 2 + y

console.log(
  add2(3) // 2 + 3
) // prints 5

console.log(
  add2(10) // 2 + 10
) // prints 12
Enter fullscreen mode Exit fullscreen mode

现在我们有一个函数add2,它需要一个参数。无论我们给它什么值,它都会将结果加 2。


什么时候需要用到柯里化?

正如我所说,在典型的 JavaScript 代码库中并非如此。你可能已经看出,这个addCurried例子非常牵强,并没有展示任何实际好处。但如果你想深入探索函数式编程,让我来向你展示如何使用柯里化函数,它甚至比传统做法更加优雅。

关键在于构图。

在函数式编程中,函数组合是一个基本概念。这意味着对同一组数据依次使用不同的函数。柯里化函数的优势就在于其函数组合的特性。

在 JavaScript 中,组合两个函数的方式如下:

const compose = (f, g) => x => f(g(x))

const addCurried = x => y => x + y

console.log(
  compose(addCurried(2), addCurried(3))(10) // 10 + 3 + 2 = 15
) // prints 15
Enter fullscreen mode Exit fullscreen mode

使用组合时,应该将其理解为从右到左对某些数据进行操作。在上面的例子中,初始数据是10,经过adding 3,然后是adding 2,结果为15

让我通过一个例子来说明,与惯用的函数式 JavaScript 相比,组合一系列柯里化函数会是什么样子。我将使用一个基于我实际解决的问题的例子,这个问题不要求使用任何特定的编程风格或语言。

目标是编写一个函数cleanExpression,该函数接收一个基本的数学表达式字符串(例如,“1 + 10 / 2”),并返回一个经过清理的表达式。清理过程包括移除多余的空格,并确保表达式中数字和运算符交替出现(两个数字或两个运算符不能相邻)。我们只处理个位数。

例如,“1 + 2 2 / 3 *” 清洗后将是“1 + 2 / 3”。

以下是一个典型的函数式 JavaScript 解决方案。我们称之为“轻量级函数式编程”。

// Helper functions
const isOperator = x => "+-*/".includes(x)

const isDigit = x => "1234567890".includes(x)

const last = xs => xs[xs.length - 1]

const init = xs => xs.slice(0, -1)

const intersperse = (sep, xs) => xs.map(x => [sep, x]).flat()

// The main function
const cleanExpression = expr => {
  const parseNext = ([acc, shouldBe], x) => {
    if (shouldBe === 'digit' && isDigit(x)) {
      return [[...acc, x], 'operator']
    } else if (shouldBe === 'operator' && isOperator(x)) {
      return [[...acc, x], 'digit']
    } else {
      return [acc, shouldBe]
    }
  }

  const chars = expr.split('')
  const alternating = chars.reduce(parseNext, ['', 'digit'])[0]
  const cleaned = isOperator(last(alternating)) ? init(alternating) : alternating
  return intersperse(' ', cleaned).join('')
}

console.log(
  cleanExpression('1    + 2 2 / 3 *')
)
Enter fullscreen mode Exit fullscreen mode

这里提供一个更函数式的 JavaScript 解决方案,它使用Ramda库代替内置函数:

const R = require('ramda')

const isOperator = x => R.includes(x, "+-*/")

const isDigit = x => R.includes(x, "1234567890")

const cleanExpression = expr => {
  const parseNext = ([acc, shouldBe], x) => {
    if (shouldBe === 'digit' && isDigit(x)) {
      return [acc + x, 'operator']
    } else if (shouldBe === 'operator' && isOperator(x)) {
      return [acc + x, 'digit']
    } else {
      return [acc, shouldBe]
    }
  }

  return R.compose(
    R.join(''),
    R.intersperse(' '),
    (xs => isOperator(R.last(xs)) ? R.init(xs) : xs),
    R.head,
    R.reduce(parseNext, ['', 'digit']),
    R.split('')
  )(expr)
}

console.log(
  cleanExpression('1    + 2 2 / 3 *')
)
Enter fullscreen mode Exit fullscreen mode

由于 Ramda 实现了其他辅助函数,因此所需的辅助函数较少,但这并不是重点。需要比较的主要代码行位于以下代码块中cleanExpression

// functional-lite
const chars = expr.split('')
const alternating = chars.reduce(parseNext, ['', 'digit'])[0]
const cleaned = isOperator(last(alternating)) ? init(alternating) : alternating
return intersperse(' ', cleaned).join('')

// more functional
return R.compose(
  R.join(''),
  R.intersperse(' '),
  (xs => isOperator(R.last(xs)) ? R.init(xs) : xs),
  R.head,
  R.reduce(parseNext, ['', 'digit']),
  R.split('')
)(expr)
Enter fullscreen mode Exit fullscreen mode

Ramdacompose函数将函数组合扩展到任意数量的函数,而不再局限于两个函数。不过,它的阅读顺序仍然是从右到左(或从下到上)。上面的例子可以理解为:

  • 输入expr要进行运算的数据,在本例中应该是一个字符串形式的数学表达式。
  • 将字符串分割成字符数组。
  • 用于reduce逐步执行表达式,构建一个交替使用数字和运算符的新版本(以数字开头)。
  • 取前一个结果的第一个元素(因为它返回的是一个对,而我们只需要新的表达式)。
  • 如果最后一个字符是运算符,则将其删除。
  • 在新表达式中穿插空格。
  • 最后,将新表达式转换为字符串。

这样,我们可以把解决方案想象成通过一条管道(从下到上)处理数据。每一步的输出都会作为下一步的输入,直到到达终点,最终结果返回。

两个版本的解题步骤相同,但第二个版本看起来更线性,我们可以清楚地看到每个步骤。

为了获得最实用的版本,这里提供相同的 Haskell 解决方案,其中所有函数默认都是柯里化的,组合运算符是点号 ( .):

isOperator :: Char -> Bool
isOperator x = x `elem` "+-*/"

isDigit :: Char -> Bool
isDigit x = x `elem` "1234567890"

intersperse :: Char -> String -> String
intersperse sep = init . concat . map (\x -> [x, sep])

cleanExpression :: String -> String
cleanExpression =
  intersperse ' '
  . (\xs -> if isOperator (last xs) then init xs else xs)
  . fst
  . foldl parseNext ("", "digit")
  where
    parseNext :: (String, String) -> Char -> (String, String)
    parseNext (acc, shouldBe) x
      | shouldBe == "digit" && isDigit x =
        (acc ++ [x], "operator")
      | shouldBe == "operator" && isOperator x =
        (acc ++ [x], "digit")
      | otherwise = (acc, shouldBe)

main :: IO ()
main = do
  print $ cleanExpression "1    + 2 2 / 3 *" == "1 + 2 / 3"
Enter fullscreen mode Exit fullscreen mode

结论

柯里化并非一个复杂的概念,但大多数人并不熟悉它,因为他们用不到它。这也不难理解!只有当你打算编写高度函数式的代码并尽可能多地使用组合时,柯里化才能真正发挥作用。像 Haskell 这样的语言就充分利用了这一点,它们默认将所有函数都柯里化,并提供一个非常简洁的函数组合运算符(类似于点号)。

为了增加趣味性,compose不妨自己尝试实现 Ramda 的函数!它应该能够组合任意数量的函数,而不仅仅是两个。


原文发表于https://timjohns.ca

文章来源:https://dev.to/slimtim10/when-to-use-currying-in-javascript-1cn4