JavaScript 中的柯里化
不过多介绍柯里化的定义和应用场景,直接看实现。
柯里化实现
如果我们有以下加法函数:
const add = (a, b) => a + b要怎么实现柯里化呢?
手工柯里化
我们可以选择直接手写柯里化的 add:
// 手工柯里化const curryingAdd = a => b => a + b
// 使用const inc = curryingAdd(1)inc(1) // 2通过闭包的嵌套,我们可以实现每次只接受一个参数的手工柯里化的函数。
但是这种方式,对于每个需要柯里化的函数,都需要侵入其实现,非常麻烦。因此并没有什么实用价值。我们需要能一种通用的方案来自动化做这个事情。
自动柯里化
柯里化的实现,核心在于根据参数数量选择返回接受剩余参数的新函数,或者返回最终结果。
在 JavaScript 中,具备实现这一切的条件,我们可以通过函数的 length 得知形参的数量,通过 arguments 对象得知实参的信息,通过闭包返回新函数也不在话下。下面开始尝试实现,先从简单的两个参数的情况开始:
// 单一参数函数的柯里化const curry1 = (fn) => { // 生成一个柯里化的版本 return function currying(a) { // 检查实参数量 // 此处不能简单地用 undefined 判断, // 因为 fn 本身可能也能接受并处理 undefined switch (arguments.length) { // 没有传入任何参数,返回柯里化后的函数本身 case 0: return currying
// 传入了任意大于 0 个数量的参数,则调用应用它们 default: return fn.apply(void 0, arguments) } }}
// 两个参数的函数柯里化const curry2 = (fn) => { // 生成一个柯里化的版本 return function currying(a,b) { // 同 curry1,检查实参数量 switch (arguments.length) { // 没有传入任何参数,返回柯里化后的函数本身 case 0: return currying
// 传入了 1 个参数,则记录下该参数, // 创建一个接受剩余参数的新函数并柯里化 // 相当于简化了函数的参数规模 case 1: { const arg = arguments[0]
// 自动柯里化接受剩余参数的函数 // 剩余参数只有 1 个,因此使用 curry1 return curry1(function() { // 合并外层函数已经部分应用的参数 const args = [arg] args.push.apply(args, arguments)
return fn.apply(void 0, args) }) }
// 传入了足够的参数时,直接应用这些参数 default: return fn.apply(void 0, arguments) } }}
// 使用const add = (a, b) => a + bconst curryingAdd = curry2(add)curryingAdd(1, 1) // 2curryingAdd(1)(1) // 2curryingAdd()(1)()(1) // 2// ...以上代码实现了通用的 2 个参数的函数的柯里化。
但如果参数的数量有很多呢,我们不可能也没必要去创建 curry3 、curry4 … curryN 等等一大批函数。完全可以定义一个通用的 curryN,指定 N ,返回自动柯里化的函数,例如下面就是一个实现:
// 缓存 sliceconst slice = Array.prototype.slice
const curryN = (arity, fn) => { // 返回柯里化的版本 return function currying() { // 本次调用的实参个数 const consumedCount = arguments.length
// 传入 0 个参数,返回柯里化后的函数本身 if (consumedCount === 0) return currying
// 算出剩余参数数量 const rest = arity - consumedCount
// 如果剩余参数不大于零,意味着提供的实参已经足够了, // 可以正式计算并返回结果了 if (rest <= 0) { return fn.apply(void 0, arguments) }
// 如果提供的实参,只是部分应用,则记录下它们 // 后续用于合并参数 const consumedArgs = slice.call(arguments)
// 检查剩余参数数量,进行不同的递归处理 switch (rest) { // 快速路径,如果只有一个参数,curry1 处理剩余参数 case 1: return curry1(function(a) { // 合并外层函数的实参 consumedArgs.push.apply(consumedArgs, arguments) return fn.apply(void 0, consumedArgs) })
// 快速路径,同上 case 2: return curry2(function(a, b) { consumedArgs.push.apply(consumedArgs, arguments) return fn.apply(void 0, consumedArgs) })
// 剩余参数更多,则递归 curryN 处理剩余参数 default: { return curryN(rest, function() { consumedArgs.push.apply(consumedArgs, arguments) return fn.apply(void 0, consumedArgs) }) } } }}
// 使用:
const add = (a, b) => a + bcurryN(2, add)(1, 1) // 2curryN(2, add)(1)(1) // 2以上就是通用 curryN 的实现代码,只要传入了正确的元数,就可以柯里化任何参数数量的函数了。
有了上面的实现代码后,我们就可以提供一个简单的 curry 函数作为对外的 API 了
const curry = (fn, arity) => { // 如果没有指定元数, // 则通过 length 属性获取 arity = arity || fn.length
switch (arity) { case 0: return fn case 1: return curry1(fn) case 2: return curry2(fn) default: return curryN(arity, fn) }}
// 使用
const add = (a, b) => a + bcurry(add)(1)(1) // 2curry 可以处理所有参数都用形参定义了的函数。而默认参数、不定义形参直接处理 arguments 等情况的函数,则无法正确处理,只能使用传入第二个参数来手工提供元数。例如:
const add = (a, b = 10) => a + badd.length // 1
curry(add)(1) // 11curry(add)(1)(1) // TypeError
// 传入第二个参数指定元数curry(add, 2)(1)(1) // 2元数信息丢失
以上实现的 curry1,curry2,curryN 函数,都存在一个问题,就是生成出来的柯里化后的函数,都会丢失掉元数的信息:
const add = (a, b) => a + b
add.length // 2
curry(add).length // 0这在一些需要判断参数数量的场景会导致 bug,因此,有必要对该问题进行修复。
而修复的一个可行的方法为,柯里化时候,根据元数,返回一个对应形参数量的函数,以下按照该思路进行改写:
const curry1 = (fn) => { // 注意此处,我们为函数提供了一个 `a` 作为形参, // 用于 “撑开” 函数的元数 return function currying(a) { switch (arguments.length) { case 0: return currying default: return fn.apply(void 0, arguments) } }}同理,我们可以改写 curry2 ,使用两个形参 “撑开” 函数。但是,对于 curryN,因为元数本身是不确定数量的,我们就没办法这么处理了。
这时候,我们可以设计一个辅助函数 _arity(n, fn),专门用于生成指定数量形参的函数:
function _arity(n, fn) { // 对于数量比较少的情况,直接暴力枚举处理 switch (n) { case 1: return function(a) { return fn.apply(void 0, arguments) } case 2: return function(a, b) { return fn.apply(void 0, arguments) } case 3: return function(a,b,c) { return fn.apply(void 0, arguments) } case 4: return function(a,b,c,d) { return fn.apply(void 0, arguments) } case 5: return function(a,b,c,d,e) { return fn.apply(void 0, arguments) } case 6: return function(a,b,c,d,e,f) { return fn.apply(void 0, arguments) } case 7: return function(a,b,c,d,e,f,g) { return fn.apply(void 0, arguments) } case 8: return function(a,b,c,d,e,f,g,h) { return fn.apply(void 0, arguments) } case 9: return function(a,b,c,d,e,f,g,h,i) { return fn.apply(void 0, arguments) } case 10: return function(a,b,c,d,e,f,g,h,i,j) { return fn.apply(void 0, arguments) } // 元数很多时,使用另一种方法 default: return arityMany(n, fn) }}我们首先假设,正常的使用过程中,形参应该不会大于 10 个,各种情况,逐个简单粗暴返回一个包裹函数即可。
而对于超过 10 个形参的情况,我们无法也没必要一一枚举了,直接调用另一个辅助函数,aritiMany,实现代码如下:
function arityMany(n, fn) { let params = [] params.push('fn') let ret = `return function(` let index = -1 while (++index < n) { ret += index === 0 ? `_${index}` : `,_${index}` } ret += `){return fn.apply(void 0,arguments)}` params.push(ret) const newFn = new Function(...params) return newFn(fn)}利用了 new Function 的方式,动态生成一个指定元数的包裹函数。
有了以上辅助函数后,我们就可以改写 curryN 的实现了:
const curryN = (arity, fn) => { function currying() { // ...实现代码同上文 }
// 注意此处, // 使用 wrapper 包裹,以确保 length 正确 const wrapper = _arity(arity, currying) // 返回包裹后的函数 return wrapper}
// 检查const add = (a, b) => a + badd.length // 2curry(add).length // 2至此,元数问题解决。
TS 类型
自动柯里化的 TS 类型写法比较绕,核心有以下部分:
type Cast<X, Y> = X extends Y ? X : Ytype Length<T extends any[]> = T['length']type Drop<N extends number, T extends any[], I extends any[] = []> = { 0: Drop<N, Tail<T>, Prepend<any, I>> 1: T}[Length<I> extends N ? 1 : 0]
type Arity1<A, R> = (a: A, ...rest: any[]) => Rtype Arity2<A, B, R> = (a: A, b: B, ...rest: any[]) => Rtype ArityN<P extends any[], R> = (...args:P)=>R
interface Curry1<A, R> { (): Curry1<A, R> (a: A, ...rest: any[]): R}
interface Curry2<A, B, R> { (): Curry2<A, B, R> (a: A, ...rest: any[]): Curry1<B, R> (a: A, b: B, ...rest: any[]): R}
type Currying<P extends any[], R> = <T extends any[]>(...args: Cast<T, Partial<P>>) => // 没有剩余参数可消耗,则返回最终结果 Drop<Length<T>, P> extends [] ? R // 剩余 1 : Drop<Length<T>, P> extends [infer A] ? Curry1<A, R> // 剩余 2 : Drop<Length<T>, P> extends [infer A, infer B] ? Curry2<A, B, R> // 剩余更多,走 curryN : Drop<Length<T>, P> extends [any, ...any[]] ? Currying<Drop<Length<T>, P> extends infer REST ? Cast<REST, any[]> : never, R> : neverfunction curry1<A, R>(fn: Arity1<A, R>) { // 重载 function curried(): Curry1<A, R> function curried(a: A): R function curried(a: A, ...rest: any): R // 实现 return function curried(a?) {/**/}}
function curry2<A, B, R>(fn: Arity2<A, B, R>) { // 重载 function curried(): Curry2<A, B, R> function curried(a: A): Curry1<B, R> function curried(a: A, b: B): R function curried(a: A, b: B, ...rest: any): R // 实现 return function curried(a?, b?) {/**/}}
function curryN<P extends any[], R>( arity: number, fn: (...args: P) => R): Currying<P, R> | ArityType<P, R> { // 实现...}
function curry<P extends any[], R>(fn: (() => R)): typeof fnfunction curry<A, R>(fn: ((a: A) => R)): Curry1<A, R>function curry<A, B, R>(fn: ((a: A, b: B) => R)): Curry2<A, B, R>function curry<P extends any[], R>(fn: ((...args: P) => R)): Currying<P, R>// 传入了 arity,则需要手动传入 P,不能使用 fn 的类型来推断,因为参数长度和手工指定的 arity 不一定一致function curry<P extends any[], R>(fn: ((...args) => R), arity: number): Currying<P, R>// 可以传入 arity 指定元数,// 以支持默认值参数 & rest 参数(目前这么做会失去 TS 类型信息)// 另外,指定元数后,参数默认值不会被使用function curry(fn, arity?: number) {/**/}完整和性能优化的 typescript 实现,等我不那么懒了,再发布到 github 上。
性能优化
由于柯里化过程中,需要创建大量的闭包,还有大量的操作 arguments 对象,因此性能会有比较大幅度的下降。有些实现中,还支持使用占位符,性能更是感人。所以作为一个基础工具函数,适当的压榨性能,还是存在意义的。
比较简单的优化方向可以有:
- 枚举处理常见的参数长度,而不是简单粗暴地 apply
arguments, - 参数拼接方法的选择,例如
push,concat,ES6 的...运算符等等 - 根据需要处理的函数参数数量,提供 curry1, curry2, curry3 等快捷方式
- …
不过许多优化都针对引擎的实现,会随着引擎的发展变化,所以是否优化也需要权衡取舍。
全文完