← 返回题目列表

如何手写函数柯里化 curry?

高频 中等 第 11 / 27 题 更新于 2026/07/28
手写代码柯里化函数式编程

简化版

柯里化把一个接收多个参数的函数转换为可分批收集参数的函数。每次调用都把新参数加入列表;累计数量达到目标参数个数时执行原函数,否则返回新的收集函数。例如 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 本来就可能是 nullundefined;单独的布尔标记能避免混淆。

三、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,可能破坏需要动态接收者的原函数。
  • 追问:为什么用“大于等于”而不是“等于”元数? 一次调用可能传入多个参数;用等于会让超量输入永远无法触发执行。
  • 追问:多余参数应该截断吗? 没有唯一答案;本文全部透传,也可以截断,但必须在接口合同中明确。
  • 追问:如何支持占位符? 把参数列表建模为可填充槽位,只有非占位参数达到元数才执行,并规定从左到右的填充规则。
  • 追问:柯里化和偏函数有什么区别? 偏函数通常预先固定部分参数后返回一次可调用函数;柯里化强调按元数持续拆分调用。

八、加强记忆

  1. 目标:把一次多参数调用变成可分批收集参数的调用。
  2. 触发:累计有效参数达到 arity 后执行原函数。
  3. 元数fn.length 只是默认值,默认参数和 rest 参数会让它失真。
  4. 接收者:明确采用第一次还是最后一次调用的 this
  5. 扩展:空调用、多余参数、占位符都属于额外合同。
  6. 成本:闭包和参数复制换来复用与组合能力,不是任何场景都需要。