先从一个日常场景说起
假设你开了一家三明治店。每天早上,顾客排着队点餐:
"我要一个面包,加生菜,加番茄,加牛肉。"
你的厨师每次都要听完整句话才开始做——面包、生菜、番茄、牛肉,四个信息一起接收,然后开始组装。
这没问题。但有一天你发现:80% 的顾客都选全麦面包。
于是你想:能不能让事情简单一点?能不能先把"面包"这件事定下来,然后顾客只需要说剩下三个?
这就是柯里化的核心思想——不是一次性解决所有问题,而是先解决一部分,剩下的以后再说。
到底什么是柯里化?
用代码说人话:
// 普通函数:一次传入所有参数
function makeSandwich(bread, vegetable, meat, sauce) {
return `${bread} + ${vegetable} + ${meat} + ${sauce}`;
}
makeSandwich('全麦', '生菜', '牛肉', '蛋黄酱');
// "全麦 + 生菜 + 牛肉 + 蛋黄酱"
柯里化之后呢?
// 柯里化版本:每次只传一个参数,返回一个新函数
function curriedMakeSandwich(bread) {
return function(vegetable) {
return function(meat) {
return function(sauce) {
return `${bread} + ${vegetable} + ${meat} + ${sauce}`;
};
};
};
}
// 使用方式变了
const withWholeWheat = curriedMakeSandwich('全麦'); // 先固定面包
const withLettuce = withWholeWheat('生菜'); // 再固定蔬菜
const withBeef = withLettuce('牛肉'); // 再固定肉类
const mySandwich = withBeef('蛋黄酱'); // 最后加酱
console.log(mySandwich); // "全麦 + 生菜 + 牛肉 + 蛋黄酱"
你看,同一个功能,但调用方式完全不同。关键区别在于:柯里化让你可以在中间任何一步停下来,拿着"半成品"去干别的事。
// 比如你可以批量生产"全麦底"的三明治
const wholeWheatBase = curriedMakeSandwich('全麦');
const sandwich1 = wholeWheatBase('生菜')('鸡肉')('千岛酱');
const sandwich2 = wholeWheatBase('番茄')('培根')('芥末酱');
const sandwich3 = wholeWheatBase('酸黄瓜')('火腿')('蜂蜜芥末');
wholeWheatBase 就是一个已经固定了第一个参数的函数。你不需要每次都重复传"全麦"。
为什么要这么折腾?三个真正的原因
原因一:参数复用(最重要的)
这是柯里化最直接的好处。当某个参数在多次调用中值都一样时,你没必要每次都写一遍。
真实场景——格式化数字:
// 普通写法:每次都传格式
function formatNumber(locale, currency, number) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(number);
}
// 你在中国卖东西,每次都这么调:
formatNumber('zh-CN', 'CNY', 99); // "¥99.00"
formatNumber('zh-CN', 'CNY', 199); // "¥199.00"
formatNumber('zh-CN', 'CNY', 2999); // "¥2,999.00"
烦不烦?'zh-CN' 和 'CNY' 写了三遍。
柯里化版本:
const curriedFormat = locale => currency => number =>
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(number);
const formatCNY = curriedFormat('zh-CN')('CNY'); // 一次固定,永久复用
formatCNY(99); // "¥99.00"
formatCNY(199); // "¥199.00"
formatCNY(2999); // "¥2,999.00"
干净多了。而且如果你突然要支持美元市场:
const formatUSD = curriedFormat('en-US')('USD');
formatUSD(49.99); // "$49.99"
一行搞定新市场。
原因二:为函数组合铺路
这是柯里化在函数式编程中地位崇高的原因。
想象一条流水线:原料 → 切割 → 清洗 → 烹饪 → 装盘。每一步都是一个函数。如果每个函数都只接受一个参数、返回一个结果,那么它们就可以像乐高积木一样任意拼接。
// 单参数函数可以无缝串联
const add10 = x => x + 10;
const double = x => x * 2;
const halve = x => x / 2;
// 流水线:先加10,再翻倍,再减半
const pipeline = x => halve(double(add10(x)));
pipeline(5); // (5+10)*2/2 = 15
如果函数是多参数的,你就没法直接把它们串起来——你得在中间手动填参数。柯里化保证了每个步骤都是"拿一个东西,还一个东西"的纯净形态。
原因三:更声明式的代码风格
比较这两种写法:
// 命令式:描述"怎么做"
users.filter(function(user) {
return user.age >= 18 && user.active === true;
});
// 声明式配合柯里化:描述"做什么"
const isAdult = age => user => user.age >= age;
const isActive = () => user => user.active === true;
const isAdultAndActive = user => isAdult(18)(user) && isActive()(user);
users.filter(isAdultAndActive);
第二种写法把判断逻辑和数据分开了。isAdult(18) 本身就是一个可测试、可复用的断言函数,不依赖具体数据。
实际怎么用?三种方式
方式一:手写(理解原理)
// 最简单的两参数柯里化
function curryTwo(fn) {
return function(a) {
return function(b) {
return fn(a, b);
};
};
}
// 使用
const add = (a, b) => a + b;
const curriedAdd = curryTwo(add);
curriedAdd(2)(3); // 5
方式二:通用柯里化工具函数
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
// 参数够了,直接执行原函数
return fn.apply(this, args);
} else {
// 参数不够,返回一个新函数继续收集参数
return function(...moreArgs) {
return curried.apply(this, [...args, ...moreArgs]);
};
}
};
}
// 任意参数数量的函数都能用
const sum = (a, b, c, d) => a + b + c + d;
const curriedSum = curry(sum);
curriedSum(1)(2)(3)(4); // 10 — 每次传一个
curriedSum(1, 2)(3, 4); // 10 — 也可以一次传多个
curriedSum(1, 2, 3, 4); // 10 — 甚至全部一次传完
注意那个 fn.length——JavaScript 函数的 length 属性会告诉你它声明了多少个形参。这就是判断"参数够没够"的依据。
方式三:ES6 箭头函数(最常用)
实际开发中,大多数时候你不需要通用工具函数。箭头函数本身就能写出漂亮的柯里化代码:
// 数据获取场景
const get = key => obj => obj[key];
const getName = get('name');
const getAge = get('age');
getName({ name: '张三', age: 25 }); // "张三"
getAge({ name: '张三', age: 25 }); // 25
// DOM 操作场景
const getBySelector = selector => document.querySelector;
const $id = id => document.getElementById(id);
const mainEl = $id('main');
const btnEl = getBySelector('.submit-btn');
一个完整的实战例子
做一个简单的日志系统:
// 柯里化的日志函数
const logWith = level => prefix => message =>
console.log(`[${new Date().toISOString()}] [${level}] [${prefix}] ${message}`);
// 为不同模块创建专用日志
const dbLog = logWith('DEBUG')('Database');
const apiLog = logWith('INFO')('API');
const errorLog = logWith('ERROR')('Auth');
// 使用
dbLog('连接池初始化完成');
// "[2024-03-15T08:30:00Z] [DEBUG] [Database] 连接池初始化完成"
apiLog('GET /api/users 200 45ms');
// "[2024-03-15T08:30:01Z] [INFO] [API] GET /api/users 200 45ms"
errorLog('Token 已过期');
// "[2024-03-15T08:30:02Z] [ERROR] [Auth] Token 已过期"
每个模块拿到手的都是已经配置好的专属函数,调用时只需关心"要记什么",不用管"怎么记"。
什么时候不该用?
说实话,不是所有地方都需要柯里化。
- 只有一两个参数的函数——柯里化反而增加嵌套层级,可读性下降
- 参数之间有依赖关系——第二个参数的值取决于第一个参数的结果,强行拆开会很别扭
-
团队不熟悉函数式编程——代码是给人看的,如果你的同事看到
f(a)(b)(c)会懵,那就别用
记住我的原则:工具是为了解决问题存在的,不是为了炫技存在的。如果一个模式让你的代码更难理解,那它就不是正确的模式。
一句话总结
柯里化就是:把"一次给齐所有答案"变成"一次回答一个问题"。这样做的好处是,你可以在任何一个问题上停下来,拿着已有的答案去做别的事——而不必每次都从头开始。
就像你不会每次做菜都从种小麦开始一样。有些事情,提前定下来就好。