如何手写函数柯里化 curry?
简化版
柯里化把一个接收多个参数的函数转换为可分批收集参数的函数。每次调用都把新参数加入列表;累计数量达到目标参数个数时执行原函数,否则返回新的收集函数。例如 curry(add)(1)(2, 3) 与 add(1, 2, 3) 得到相同结果。
详细版
基础实现可以使用闭包:
function curry(fn, arity = fn.length) {
function collect(args, hasReceiver, receiver) {
return function curried(...nextArgs) {
const thisArg = hasReceiver ? receiver : this
const allArgs = args.concat(nextArgs)
return allArgs.length >= arity
? Reflect.apply(fn, thisArg, allArgs)
: collect(allArgs, true, thisArg)
}
}
return collect([], false, undefined)
}
fn.length 只是默认目标元数:它只统计第一个默认参数之前的形参数量,也不包含剩余参数。因此工程版通常允许显式传入 arity。还要事先约定多余参数、空调用、占位符以及 this 取第一次还是最后一次调用。
完整版教学
一、柯里化解决的是分批传参
柯里化不是“少传参数”,而是把一次多参数调用改造成多次调用,并在参数收集够以后执行:
function sum3(a, b, c) {
return a + b + c
}
sum3(1, 2, 3) // 6
curry(sum3)(1)(2)(3) // 6
curry(sum3)(1, 2)(3) // 6
curry(sum3)(1)(2, 3) // 6
| 概念 | 形式 | 是否立即执行原函数 |
|---|---|---|
| 普通调用 | fn(1, 2, 3) | 是 |
| 偏函数 | partial(fn, 1)(2, 3) | 第二次调用时执行 |
| 柯里化 | curry(fn)(1)(2)(3) | 收集到目标元数时执行 |
工程库常允许一次收集多个参数;面试时需要把这条合同说清楚。
最容易被忽略的不是闭包,而是“什么时候算收集完成”和“哪一次调用提供
this”。先定合同,代码才有可验证的正确性。
二、基础实现与显式元数
下面约定:累计数量大于等于 arity 时执行,多余参数全部传给原函数,并采用第一次调用的 this。
function curry(fn, arity = fn.length) {
if (typeof fn !== 'function') throw new TypeError('fn must be a function')
if (!Number.isInteger(arity) || arity < 0) {
throw new TypeError('arity must be a non-negative integer')
}
function collect(collected, hasReceiver, receiver) {
return function curried(...nextArgs) {
const nextReceiver = hasReceiver ? receiver : this
const allArgs = collected.concat(nextArgs)
if (allArgs.length >= arity) {
return Reflect.apply(fn, nextReceiver, allArgs)
}
return collect(allArgs, true, nextReceiver)
}
}
return collect([], false, undefined)
}
不能用 receiver ?? this 判断是否保存过接收者,因为合法的 this 本来就可能是 null 或 undefined;单独的布尔标记能避免混淆。
三、fn.length 不是业务参数个数
JavaScript 的函数 length 只统计第一个默认参数之前的普通形参,剩余参数也不计入:
function a(x, y, z) {}
function b(x, y = 1, z) {}
function c(x, ...rest) {}
a.length // 3
b.length // 1
c.length // 1
直接 curry(b) 会在拿到第一个参数后执行,而不是等待三个参数。合理策略有两种:基础题明确采用 fn.length 并说明限制;工程版允许 curry(b, 3),把目标元数从函数声明中解耦。
显式元数也让被包装函数经过 bind、转译或装饰后仍可预测。
四、this 策略必须明确
链式调用可能出现多个接收者:
const calculator = {
base: 10,
add(a, b) {
return this.base + a + b
},
}
const curriedAdd = curry(calculator.add, 2)
curriedAdd.call(calculator, 1)(2) // 13
本文保存第一次调用的 this,这样后续返回的普通函数即使脱离对象调用,也不会丢失接收者。另一种实现可以在最终执行时采用最后一次调用的 this,但两种语义不能混写。
箭头函数没有自己的动态 this;如果收集函数全部使用箭头函数,call 和对象方法调用就无法改变接收者。
五、空调用、多余参数与占位符
基础实现中,curried() 不增加参数,只会继续返回函数;若连续空调用,链条可以无限延长。累计参数超过 arity 时,本文把多余参数也传给原函数:
function list(a, b) {
return [a, b, arguments.length]
}
curry(list, 2)(1, 2, 3) // [1, 2, 3]
占位符不是柯里化的必备定义。若要支持 curried(_, 2)(1),需要维护待填槽位、判断有效参数数量,并定义多个占位符的填充顺序。面试官没要求时,不应偷偷加入未经说明的规则。
六、复杂度和适用边界
当前实现每次调用都用 concat 复制已收集参数。收集 n 个参数且每次只传 1 个时,总复制量是 1 + 2 + ... + n,最坏为 O(n²);一般函数元数很小,这个成本通常可接受。
| 维度 | 收益 | 代价 |
|---|---|---|
| 参数复用 | 便于生成专用函数 | 创建更多闭包 |
| 组合性 | 更适合函数管道 | 调试调用栈更长 |
| 自动执行 | 调用表达简洁 | 依赖准确的 arity |
柯里化适合配置复用、校验器和函数组合,不宜为了形式把所有业务函数都改成多层调用。参数很多时,对象参数往往比位置参数更清晰。
七、常见误区与追问
- 误区:
fn.length永远等于声明的形参数量。 默认参数会截断统计,rest 参数完全不计入,生产实现应允许显式指定元数。 - 误区:只要用了闭包就是柯里化。 闭包只是保存参数的手段,关键合同是分批收集并在满足条件后执行。
- 误区:返回箭头函数一定更简洁。 箭头函数捕获词法
this,可能破坏需要动态接收者的原函数。 - 追问:为什么用“大于等于”而不是“等于”元数? 一次调用可能传入多个参数;用等于会让超量输入永远无法触发执行。
- 追问:多余参数应该截断吗? 没有唯一答案;本文全部透传,也可以截断,但必须在接口合同中明确。
- 追问:如何支持占位符? 把参数列表建模为可填充槽位,只有非占位参数达到元数才执行,并规定从左到右的填充规则。
- 追问:柯里化和偏函数有什么区别? 偏函数通常预先固定部分参数后返回一次可调用函数;柯里化强调按元数持续拆分调用。
八、加强记忆
- 目标:把一次多参数调用变成可分批收集参数的调用。
- 触发:累计有效参数达到
arity后执行原函数。 - 元数:
fn.length只是默认值,默认参数和 rest 参数会让它失真。 - 接收者:明确采用第一次还是最后一次调用的
this。 - 扩展:空调用、多余参数、占位符都属于额外合同。
- 成本:闭包和参数复制换来复用与组合能力,不是任何场景都需要。