引言
在 JavaScript 的世界中,柯里化(Currying) 是一个听起来高深、用起来优雅、理解后令人拍案叫绝的编程技巧。它不仅体现了函数式编程的核心思想,还巧妙地利用了 JavaScript 的闭包和高阶函数特性。
本文将带你 从最基础的函数出发,一步步深入柯里化的实现原理与底层机制。我们将逐行分析你提供的所有代码文件,确保每一处细节都被清晰解释。无论你是初学者还是有经验的开发者,都能在这篇文章中获得对柯里化全面而深刻的理解。
源码链接:lesson_zp/js/curry: AI + 全栈学习仓库
第一章:起点 —— 普通函数是什么样子?
一切始于最朴素的形式。来看 1.js:
function add(a, b) {
return a + b;
}
// 参数一个一个的传递
console.log(add(1, 2));
这段代码再普通不过:定义了一个接收两个参数 a 和 b 的函数 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]: 页面加载完成
设计优势
- 语义明确
errorLog就是“报错专用”,名字即文档。 - 减少重复
不用每次都写log("error", "..."),避免 typo 和冗余。 - 高阶函数 + 柯里化 = 函数工厂
log是一个函数生成器,通过部分应用(Partial Application)产出专用函数。 - 易于扩展
要加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 的版本更实用。
第七章:柯里化的适用场景
-
配置先行,数据后到
如日志类型、API 前缀、权限校验等。 -
函数组合(Composition)
在函数式编程中,柯里化函数更容易用compose或pipe组合。 -
事件处理中的参数预设
const handleClick = curry((id, event) => { /* ... */ }); button.onclick = handleClick('user-123'); -
创建 DSL(领域特定语言)
让 API 更具表达力,如测试框架中的expect(x).toBe(y)
结语:柯里化,不只是技巧,更是思维
柯里化教会我们:
- 函数可以是“不完整的” ,等待更多信息才执行
- 状态可以通过闭包隐式传递,无需全局变量
- 高阶函数是构建抽象的利器
从 1.js 的朴素加法,到 3.js 的通用柯里化,再到 4.js 的日志工厂,我们见证了 JavaScript 如何用简单的机制(闭包、递归、高阶函数)实现强大的抽象能力。
下次当你写下 add(1)(2)(3) 时,请记住:
这不是语法糖,而是一场精心编排的闭包之舞。
吃火锅要一口一口涮,写函数也可以一个参数一个参数来。
柯里化,让你的代码既有嚼劲,又有味道。