何时在 JavaScript 中使用柯里化?
这篇文章将介绍函数式编程中的柯里化概念,以及何时应该在 JavaScript 中使用它。
说实话,你可能根本不需要在 JavaScript 中使用柯里化。事实上,除非只是为了好玩,否则强行把它塞进你的代码里弊大于利。柯里化只有在你完全接受函数式编程时才有用,而在 JavaScript 中,这意味着使用像Ramda这样的库,而不是标准的内置函数。
在本文中,我将首先解释什么是柯里化,然后展示它在函数式编程环境中的用途。
什么是咖喱?
柯里化是指函数永远不需要接受多个参数;每个函数只接受一个参数。如果一个函数需要表现得像接受多个参数一样,它会返回另一个函数。
你通常看到的是普通的、非柯里化的函数:
const add = (x, y) => x + y
console.log(
add(2, 3) // 2 + 3
) // prints 5
这是一个简单的函数,它接受两个数字并返回它们的和。
同一函数的柯里化版本如下所示:
const addCurried = x => y => x + y
console.log(
addCurried(2)(3) // 2 + 3
) // prints 5
这个函数不再接受两个参数,而是接受一个参数,然后返回另一个接受一个参数并返回总和的函数。注意,我们需要使用更多的括号来传递参数,因为参数是逐个传递给每个嵌套函数的。
这样我们就可以提出任意多的论点:
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
由于柯里化函数的特殊工作方式,我们可以进行一种称为部分应用的操作。这指的是我们给函数提供的参数数量少于它所能接受的参数数量:
const addCurried = x => y => x + y
console.log(
addCurried(2) // y => 2 + y
) // [Function (anonymous)]
如果我们只传递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
现在我们有一个函数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
使用组合时,应该将其理解为从右到左对某些数据进行操作。在上面的例子中,初始数据是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 *')
)
这里提供一个更函数式的 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 *')
)
由于 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)
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"
结论
柯里化并非一个复杂的概念,但大多数人并不熟悉它,因为他们用不到它。这也不难理解!只有当你打算编写高度函数式的代码并尽可能多地使用组合时,柯里化才能真正发挥作用。像 Haskell 这样的语言就充分利用了这一点,它们默认将所有函数都柯里化,并提供一个非常简洁的函数组合运算符(类似于点号)。
为了增加趣味性,compose不妨自己尝试实现 Ramda 的函数!它应该能够组合任意数量的函数,而不仅仅是两个。
原文发表于https://timjohns.ca。
文章来源:https://dev.to/slimtim10/when-to-use-currying-in-javascript-1cn4