聊聊函数柯里化:来源、解决的问题与优缺点
柯里化(Currying)是函数式编程里最常被提起、也最常被误解的概念之一。面试喜欢问,但很多人只背得住一句「把多参函数变成单参函数的链式调用」,说不清它从哪来、为什么存在、以及它到底划算不划算。
这篇文章把三件事讲透:它是谁发明的、它解决什么问题、它的代价是什么。
一、什么是柯里化
一句话定义:把一个接收多个参数的函数,改造成一串只接收一个参数的函数。
// 普通函数:一次接收 3 个参数
const add = (a, b, c) => a + b + c;
// 柯里化之后:一个一个地收
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 6
add(1, 2, 3) 变成 add(1)(2)(3),仅此而已。听着像语法糖,但「每次只收一个参数」背后藏着一整套思想——这就是下一节要说的来源。
二、来源:一段被张冠李戴的数学史
柯里化的根不在 JavaScript,也不在前端,而在数理逻辑。
λ 演算:函数天生只能收一个参数
20 世纪 30 年代,Alonzo Church 提出 λ 演算(lambda calculus)——一个只靠「函数定义、函数应用」就能表达一切计算的模型。但 λ 演算里有个硬约束:一个函数只能接收一个参数。
那「两个数相加」怎么表示?靠嵌套:
λa.λb.(a + b)
即「一个接收 a、返回『接收 b 的函数』的函数」。多参函数就是单参函数的嵌套——这就是柯里化的数学原型。它不是一个设计选择,而是 λ 演算的生存需要。
Schönfinkel 发明,Curry 推广,Strachey 命名
- Moses Schönfinkel 早在 1924 年就在逻辑学论文里系统阐述了「多参函数可以化为一串单参函数」。
- Haskell Curry 在 1920–30 年代研究组合子逻辑时独立发展了同样的思想,并把它带进了主流数学逻辑。
- 「Currying」这个名字,是 Christopher Strachey 在 1967 年造的。有趣的是,它其实是个「错误归因」——真正的发明者是 Schönfinkel,所以这个操作偶尔也被叫 「Schönfinkel 化」(Schönfinkelisation),只是「柯里化」流传更广。
顺带两个冷知识:Haskell 编程语言就是以 Haskell Curry 命名的;Curry 本人的名字还出现在「Curry–Howard 对应」里——证明与程序的同构关系。
从数学走进编程
λ 演算 → ML → Haskell 等函数式语言天然支持柯里化(f a b c 就是 ((f a) b) c)→ JavaScript 社区借鉴过来,但因为 JS 没有原生柯里化,要靠 curry() 这个高阶函数手动转换。
所以柯里化在 JS 里是「借来的概念」,它的很多优点和缺点,都源于这个「借」字。
三、它解决什么问题
1. 参数复用:一个通用函数,变身一堆专用函数
柯里化最大的实际价值是预设参数。把「日志」这个通用函数固定住部分参数,就能派生出专用版:
const log = curry((level, tag, msg) => `[${level}] ${tag}: ${msg}`);
const logError = log('error'); // 固定 level
const logApiError = logError('api'); // 再固定 tag
logApiError('请求超时'); // "[error] api: 请求超时"
不用柯里化的话,你只能写 log('error', 'api', '请求超时'),每次重复前两个参数。柯里化把「重复的样板」收进了一次性的预设里。
2. 延迟执行:攒够参数才跑
柯里化函数不攒够参数就不执行。这让它天然支持「先收集参数、准备好了再触发」:
const fetchData = curry((baseURL, path, params) => request(`${baseURL}${path}`, params));
const api = fetchData('https://api.example.com'); // 还没发请求
const getUser = api('/user'); // 还是没发
getUser({ id: 1 }); // 参数齐了,才真正请求
3. 变成单参函数,好配合高阶函数
map、filter、reduce 的回调都是一元函数(收一个参数)。柯里化能让你把「带配置的运算」直接塞进这些高阶函数:
// 给每个数 +1 —— add(1) 是一元函数,正好喂给 map
[1, 2, 3].map(add(1)); // [2, 3, 4]
// filter 只留大于 2 的
[1, 2, 3].filter(greaterThan(2)); // [3]
这同时为函数组合(compose / pipe)铺了路——组合要求「上一个的输出恰好是下一个的输入」,单参函数是标准的积木。
4. 消除重复样板
事件处理、请求配置、格式化……凡是「大部分参数固定、只有最后一两个会变」的地方,柯里化都能把样板收进预设里,调用处只写会变的部分。
四、JS 手写一个 curry
原理很直白:返回一个新函数,收集参数;不够 arity 就继续返回收集函数,够了就调用原函数。
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args); // 参数够了 → 真正执行
}
return (...rest) => curried(...args, ...rest); // 不够 → 攒着,返回新函数
};
}
用法,三种凑参数的方式都行:
const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 一次一个
curriedAdd(1, 2)(3); // 一次两个
curriedAdd(1)(2, 3); // 先一后二
// 都是 6
注意 fn.length 是函数声明的参数个数,这是「参数够没够」的判断依据——但它不认默认参数和 rest 参数((a, b = 1) => ... 的 length 是 1),这是手写实现的常见坑。
五、优点
| 优点 | 说明 |
|---|---|
| 参数复用 | 固定参数,派生专用函数,减少重复样板 |
| 延迟执行 | 攒够参数才执行,天然支持「先配置、后触发」 |
| 配合组合 | 输出接输入,一元函数是 compose / pipe 的积木 |
| 纯函数、可预测 | 柯里化的每一步都是纯函数,好测试、好推理 |
| 语义自文档化 | log('error') 返回的函数,名字本身就说明了用途 |
六、缺点
| 缺点 | 说明 |
|---|---|
| 性能开销 | JS 里每层柯里化都是一个闭包 + 一次函数调用,参数多、调用频繁时有损耗 |
| 调用方式不自然 | f(a)(b)(c) 对命令式思维的开发者不直观,学习曲线陡 |
| 可读性下降 | 参数被拆散到多处,函数签名不再一眼看全,(a)(b)(c) 很难从字面读出含义 |
| 调试变难 | 错误堆栈里全是嵌套的 curried 调用,定位问题要绕几层 |
| 实现有边界 | fn.length 的坑、this 的绑定、占位符需求,都让手写实现比想象中麻烦 |
| 容易过度设计 | 一两次调用也要柯里化,反而把简单事搞复杂 |
什么时候该用、什么时候别用
适合:函数式风格的项目、参数大量复用、和 pipe / compose 深度配合、配置型 API(固定 baseURL、level 这类)。
不适合:命令式主导的小团队、性能敏感的热点路径(每帧都要跑的函数)、只需要调用一两次的场景——这时 f(a, b, c) 就是最诚实的写法。
七、总结
柯里化从 λ 演算「函数只能收一个参数」的约束里长出来,被 Schönfinkel 发明、Curry 推广、Strachey 命名,然后被 JS 借来用。它解决的问题是参数复用、延迟执行、配合函数组合;它的代价是闭包开销、可读性下降、调试变难。
它不是银弹,而是一个工具——和所有工具一样,好不好用取决于场景。理解了它从哪来、解决什么、付出什么,你才能判断「这个函数要不要柯里化」,而不是因为「函数式的人都在用」而盲目套用。
