柯里化函数:从一行代码到闭包魔法的完整解析

41 阅读8分钟

引言

在 JavaScript 的世界中,柯里化(Currying) 是一个听起来高深、用起来优雅、理解后令人拍案叫绝的编程技巧。它不仅体现了函数式编程的核心思想,还巧妙地利用了 JavaScript 的闭包和高阶函数特性。

本文将带你 从最基础的函数出发,一步步深入柯里化的实现原理与底层机制。我们将逐行分析你提供的所有代码文件,确保每一处细节都被清晰解释。无论你是初学者还是有经验的开发者,都能在这篇文章中获得对柯里化全面而深刻的理解。

源码链接:lesson_zp/js/curry: AI + 全栈学习仓库


第一章:起点 —— 普通函数是什么样子?

一切始于最朴素的形式。来看 1.js

function add(a, b) {
  return a + b;
}

// 参数一个一个的传递
console.log(add(1, 2));

这段代码再普通不过:定义了一个接收两个参数 ab 的函数 add,返回它们的和。调用时一次性传入两个值:add(1, 2),输出 3

但注意注释中的那句话:“参数一个一个的传递”——这其实是个“误导”。因为这里并不是“一个一个传”,而是一次传两个。真正的“一个一个传”意味着:先传 1,再传 2,中间可以隔任意时间、在任意上下文中完成。

这就引出了我们的第一个问题:

如何让函数支持“分步传参”?

答案就是:柯里化


第二章:手动柯里化 —— 闭包的初体验

进入 2.js,我们看到手动实现的柯里化版本:

function add(a) {
  return function(b) {
    return a + b;
  }
}

// 柯理化函数的调用 add(1)(2) 本质是闭包
console.log(add(1)(2));

逐行解析

  • add(a) 接收第一个参数 a

  • 不立即计算结果,而是返回一个新函数function(b) { return a + b; }

  • 这个内部函数能访问外部变量 a,即使 add 已经执行完毕——这就是 闭包(Closure)

  • 调用 add(1)(2) 实际上是:

    • 先执行 add(1) → 返回 function(b) { return 1 + b; }
    • 再立即调用这个返回的函数,传入 2 → 得到 1 + 2 = 3

优点

  • 实现了“分步传参”
  • 利用了闭包保存状态

缺点

  • 硬编码:只能处理两个参数
  • 如果原函数有 5 个参数,就得嵌套 5 层,代码难以维护
  • 不可复用:每个函数都要手动重写

于是,我们需要一个通用的柯里化工具函数


第三章:通用柯里化 —— 闭包 + 递归的完美结合

现在看 3.js,这是全文的核心:

function add(a,b,c,d) { return a + b + c + d; }

// 函数的参数数量即函数的length
console.log(add.length); // 4

继续看柯里化实现:

function curry(fn) {
  // closure curry fn
  // curried args 收集参数 不那么严谨的柯理化话函数
  return function curried(...args) {
    // 递归退出条件
    // 如果当前收集到的参数数量大于或等于原函数所需的参数个数(fn.length)
    if(args.length >= fn.length) {
      return fn(...args); // 退出递归,调用原函数并传入所有参数
    }
    // 递归收集参数
    // 如果参数还不够,则返回一个新函数,继续接收剩余参数,并与已收集的参数合并后递归调用 curried
    return (...rest) => curried(...args, ...rest);
  }
}

核心思想拆解

1. fn.length:判断“够不够”的标准

  • 每个函数都有 .length 属性,表示其形式参数的数量(不包括默认值、剩余参数等特殊情况)。
  • 对于 add(a,b,c,d)fn.length = 4
  • 我们的目标就是:收集到 4 个参数后,才真正调用 add

2. curried(...args):参数收集器

  • 第一次调用 curry(add) 返回 curried 函数。
  • curried 使用剩余参数 ...args 接收当前传入的所有参数。
  • 例如:curriedAdd(1)args = [1]

3. 递归退出条件

if(args.length >= fn.length) {
  return fn(...args);
}
  • 如果当前参数数量 ≥ 所需数量,立即执行原函数。
  • 注意这里是 >= 而不是 ===,允许“超额传参”(虽然通常不会这么做)。

4. 递归收集:返回新函数

return (...rest) => curried(...args, ...rest);
  • 如果参数不够,返回一个箭头函数,它接收新的参数 ...rest
  • 然后递归调用 curried,把旧参数 ...args 和新参数 ...rest 合并传入。
  • 这个过程会不断重复,直到参数足够。

💡 关键点:每次返回的新函数都捕获了当前的 args,形成闭包链。

实际调用演示

const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)); 
// 输出:[Function (anonymous)]
// 解释:
// - curriedAdd(1) → args=[1] < 4 → 返回新函数 F1
// - F1(2) → args=[1,2] < 4 → 返回新函数 F2
// - console.log(F2) → 显示为匿名函数

console.log(curriedAdd(1)(2)(3)(4)); 
// 输出:10
// 解释:
// - (1) → [1]
// - (2) → [1,2]
// - (3) → [1,2,3]
// - (4) → [1,2,3,4] → 长度=4 ≥ 4 → 调用 add(1,2,3,4) → 10

为什么说是“递归”?

虽然代码中没有显式的 curried() 自调用,但返回的箭头函数在被调用时会再次触发 curried,形成逻辑上的递归链。这种“延迟求值 + 链式调用”的模式,正是函数式编程的精髓。

“不那么严谨”的含义

如注释所说,这个实现依赖 fn.length,存在局限性:

  • 如果原函数使用了默认参数:function f(a, b = 1),则 f.length = 1
  • 如果使用了剩余参数:function f(...args),则 f.length = 0
  • 这会导致柯里化提前或永不触发

但在教学和大多数场景下,它完全可用。


第四章:柯里化的实际应用 —— 日志工厂

柯里化不只是理论玩具,它能写出更清晰、更可维护的代码。看 4.js

// 日志函数
// 定义一个高阶函数 log,接收日志类型 type 作为参数,
// 并返回一个新的函数,该函数接收日志消息 message 作为参数,
// 最终在控制台输出格式化的日志信息。
const log = type => message => {
  console.log(`[${type}]: ${message}`)
}

// 柯里化 固定函数参数
// 利用柯里化特性,通过预先传入固定的日志类型(如 "error" 或 "info"),
// 创建具有特定语义的新函数,从而避免每次调用时重复传入相同类型。
// 固定 日志类型, 函数的语义

// 创建一个专门用于记录错误日志的函数 errorLog,
// 其内部已固定日志类型为 "error"。
const errorLog = log("error");

// 创建一个专门用于记录信息日志的函数 infoLog,
// 其内部已固定日志类型为 "info"。
const infoLog = log("info");

// 调用 errorLog 函数,输出一条错误日志:"接口异常"。
errorLog("接口异常");

// 调用 infoLog 函数,输出一条信息日志:"页面加载完成"。
infoLog("页面加载完成");

运行结果

[error]: 接口异常
[info]: 页面加载完成

设计优势

  1. 语义明确
    errorLog 就是“报错专用”,名字即文档。
  2. 减少重复
    不用每次都写 log("error", "..."),避免 typo 和冗余。
  3. 高阶函数 + 柯里化 = 函数工厂
    log 是一个函数生成器,通过部分应用(Partial Application)产出专用函数。
  4. 易于扩展
    要加 warnLog?只需 const warnLog = log("warn");

💡 注意:这里的 log 本身就是柯里化形式(type => message => ...),不需要 curry 包装,因为它只有两层。


第五章:原理总结

原文解释
参数一个一个的传递柯里化的核心行为:分步传参
严格不严格?一次性传多个,传多次实际实现中,通常允许一次传多个(如 curried(1,2)(3,4)),只要总数达标即可
本质是闭包每一层函数都通过闭包记住之前传入的参数
每一层函数接受自己的参数每次调用只处理“当前这一批”参数
当参数数量足够后,执行函数触发原函数的时机
什么时候够?fn.length判断依据是原函数的形参个数
闭包 + 递归 curry实现手段:闭包保存状态,递归(或链式调用)收集参数
自由变量不会销毁闭包使得 args 在整个调用链中持续存在
退出条件:参数数量足够递归/链式调用的终止条件

第六章:深入思考 —— 柯里化 vs 部分应用

很多人混淆 柯里化(Currying)部分应用(Partial Application)

  • 柯里化:将 f(a, b, c) 转为 f(a)(b)(c)强制每次只传一个参数
  • 部分应用:固定部分参数,如 f(a, _, c),其余留空

但现实中,像 3.js 中的实现其实是支持多参数传入的柯里化,更接近“灵活的部分应用”。这也是为什么注释说“不那么严谨”。

真正的严格柯里化可能长这样:

const strictCurry = (fn, arity = fn.length) => {
  if (arity <= 1) return fn;
  return arg => strictCurry(fn.bind(null, arg), arity - 1);
};

但这种写法牺牲了灵活性。工程实践中,3.js 的版本更实用。


第七章:柯里化的适用场景

  1. 配置先行,数据后到
    如日志类型、API 前缀、权限校验等。

  2. 函数组合(Composition)
    在函数式编程中,柯里化函数更容易用 composepipe 组合。

  3. 事件处理中的参数预设

    const handleClick = curry((id, event) => { /* ... */ });
    button.onclick = handleClick('user-123');
    
  4. 创建 DSL(领域特定语言)
    让 API 更具表达力,如测试框架中的 expect(x).toBe(y)


结语:柯里化,不只是技巧,更是思维

柯里化教会我们:

  • 函数可以是“不完整的” ,等待更多信息才执行
  • 状态可以通过闭包隐式传递,无需全局变量
  • 高阶函数是构建抽象的利器

1.js 的朴素加法,到 3.js 的通用柯里化,再到 4.js 的日志工厂,我们见证了 JavaScript 如何用简单的机制(闭包、递归、高阶函数)实现强大的抽象能力。

下次当你写下 add(1)(2)(3) 时,请记住:
这不是语法糖,而是一场精心编排的闭包之舞

吃火锅要一口一口涮,写函数也可以一个参数一个参数来。
柯里化,让你的代码既有嚼劲,又有味道。