柯里化是什么
基本概念
前端中的柯里化(Currying)是一个源自函数式编程的概念。
函数式编程,也叫面向函数编程,之后写一篇 React 的函数式编程思想相关的文章
它指的是将原本接受多个参数的函数转换成一系列接受单个参数的函数链的过程。
注意,这里提到了,单个参数!这是个重点,后面要考!
比如对于一个add函数,它原来长这样:
function add(x, y) {
return x + y;
}
将其柯里化之后就变成了:
function curryAdd(x) {
return function(y) {
return x + y;
};
}
const res = curryAdd(2)(3);
console.log(res); // 输出5
偏函数
偏函数是一个容易与柯里化混淆的概念,它和柯里化的区别是:
柯里化严格要求每次只能传递一个参数,而偏函数则是可以传递任意参数。
也就是:
add(1)(2)(3) // ✅正宗柯里化
add(1)(2, 3)(4) // ❌ 假的柯里化,实际上是偏函数
所以严格来说,柯里化函数是一种特殊的偏函数。
我们前端圈子内,平时口头上都叫柯里化,不需要严格区分。
知晓这个小知识,面试倒是可以多点谈资。
柯里化有什么用
还记得之前面试某个大厂的时候,反问面试官柯里化有什么用,他也愣住了,有点尴尬。
所以我觉得做开发,无论是学什么技术理论,都要结合实际场景,落到实处,不然就只是纸上谈兵。
先来个结论:柯里化的作用是,以通用函数为基础,为其提供默认参数,产生定制化的、参数简洁的函数。
通俗点讲就是,比如有一个通用函数的用法是
log("xxxx")
,现在我们以此为基础通过柯里化实现一个函数,它的用法是:curriedLog("xxx")("yyyy")
,也就是能够自动拼接字符串。
那么我们就可以封装业务函数:const sayHi = curriedLog("Hi, ")
,用法就是sayHi("Zhang3")
——之后只传入一个参数就能得到"Hi, Zhang3"
的结果。
场景1:拆分计算
试想一下这个场景:这里有个获取用户数据的函数,该函数需要一个ID
和dataKey
作为参数。
先来看看不使用柯里化的方式
// 先获取userId,然后获取dataKey
getUserId().then(userId => {
getDataKey().then(dataKey => {
// 注意看这里:要两个参数都获取到了后才能开始计算
processUserInfo(userId, dataKey);
});
});
在这个非柯里化的实现中,我们必须等到dataKey
准备好后,才开始根据userId
发起获取用户数据的请求。这意味着,在获取dataKey
的等待时间内,我们无法利用这段时间来获取用户数据,导致整体执行时间较长。
为了提高效率,我们可以使用柯里化技术
getUserId().then(userId => {
// 假设这里把 processUserInfo 柯里化了
const next = processUserInfo(userId, dataKey);
getDataKey().then(dataKey => {
next(dataKey)
});
可以看到,processUserInfo
函数柯里化后,返回的是一个新的函数next
。
并且,它们就像是在一场接力赛中,每次执行都可以只完成部分计算,剩下的部分可以交给下一个函数接力。
这样做的好处是,可以先完成部分计算,先实现部分效果(比如先更新部分页面等等),再逐步实现后续效果,整体会相对比较流畅。
就问柯里化厉不厉害吧!
场景2:工厂函数
在KOA框架的中间件工厂函数中,柯里化用的也是比较多。
// 中间件工厂函数
function createMiddlewareFactory(param) {
return function middleware(next) {
return async function(ctx, nextInner) {
// ...
await next(ctx, nextInner);
// ...
};
};
}
app.use(createMiddlewareFactory(param1)());
app.use(createMiddlewareFactory(param2)());
这里用工厂模式的发挥的作用是:
-
可以通过不同参数(
param1
和param2
)来创建结构类似但不同的中间件,这样就不需要写多个创建函数了。 -
而且即使传递相同的参数,每次调用函数都能返回一个新的实例,不会是原来的引用,保证了每个中间件都是独立的。
另外,我们再看看KOA中间件的回调函数的朴素写法,它是这样的:
app.use(async (ctx, nextInner) => {
await next(ctx, nextInner);
// 想想 next 函数从哪来的呢
});
再多结合上面的中间件工厂函数看看,我们就可以感知到,柯里化在其中发挥的作用是:
-
格式化了参数(
ctx
和nextInner
)。 -
通过闭包传递了上下文(
next
)。
如何实现柯里化
虽然说上面已经给出了很多案例代码,但是都还是没有总结沉淀出一套方法论,不能做到一针见血地体现其实现方法。
这里给出几个版本,针对不同基础的群体。
基础学习版:新人入门
柯里化的精髓就是,闭包+判断参数个数。
闭包就是函数返回函数,很好实现。
至于如何获取到参数个数,有两种方法:
- 第一是
arguments
对象,这是一个可以直接在函数上下文中获取到的对象,是一个伪数组(JS早期设计缺陷的产物之一),代表实际传入的参数。
function say() {
console.log(arguments[0])
// 因为是伪数组,要用数组API的话得先转成真数组
// 即 const arr = Array.from(arguments)
}
- 第二是
Function.prototype.length
,也就是一个函数的length
属性其实就是它声明的参数数量,不包括剩余参数(展开参数,即...rest
)。
function say() {
console.log(say.length) // 0,因为没有声明参数
}
function say2(a, ...rest) {
console.log(say.length) // 1, 无法获取到剩余参数
}
这里,我们使用第二种,代码如下:
// 定义一个柯里化函数
const curry = (func) => {
return function curried(...args) {
// 如果传递的参数数量不够,则返回一个新的函数
// 需要注意的是, func.length 是形式参数的个数,但它是不包含剩余参数的
if (args.length < func.length) {
return function (...moreArgs) {
// 使用 concat 拼接已经传递的参数和新的参数
return curried(...args.concat(moreArgs));
};
} else {
// 参数数量足够,直接执行原始函数
return func(...args);
}
};
}
// 使用案例:
const add = (x, y, z, w) => {
return x + y + z + w;
}
const sum = curry(add);
console.log(sum(1)(2)(3)(4)); // 输出10,相当于调用 add(1, 2, 3, 4)
console.log(sum(1, 2)(3, 4)); // 输出10,同样相当于调用 add(1, 2, 3, 4)
极简精华版:一行代码
原理和上面的一样,但主打一个浓缩和精简,足够应付面试场景:
当然,这里的“一行”只是一个噱头,只是为了SEO(十万行的JS代码也还能压缩到一行呢!
这里不要本末倒置,看懂这个精简的实现才是目的
const curry = (fn) =>
(curried = (...args) => (args.length === fn.length // 这里为了递归能引用到,所以得给个变量名
? fn(...args)
: (...newArgs) => curried(...args, ...newArgs)));
// 使用案例:
const add = (x, y, z, w) => {
return x + y + z + w;
}
const sum = curry(add);
console.log(sum(1)(2)(3)(4)); // 输出10,相当于调用 add(1, 2, 3, 4)
console.log(sum(1, 2)(3, 4)); // 输出10,同样相当于调用 add(1, 2, 3, 4)
但是这种写法还是存在问题,需要明确参数总数,无法应用于任意数量参数的情况。
我把它写在这里的动机是,从简洁的代码中看出柯里化的核心要素。
很显然,柯里化的要点在于:实现一个参数拼接器,而参数拼接器就要考虑以下几点:
- 总共有多少参数(上述代码中的
fn.length
) - 这一次传入了多少参数(上述代码中的
args.length
)
这样一看逻辑就很简单了,如果参数传够了,那么就真正执行fn
,如果没有那就继续拼接,也就是执行curried
。
终极通解版:闭包乱炖
上文提到了无法应用于不定参数的写法,这里结合我们之前的分析,再给出一个方案。
首先明确问题的根本原因是,fn.length
无法获取到剩余参数的长度——那么我们换个方法不就能解决问题了?
比如,用闭包记录一下。
这里也有一种场景的面试场景,就是不只是要单纯地实现柯里化,还要结合更多需求。
考灵活运用也合理,不然手写这些个柯里化又有啥实际意义呢
通常也正是围绕着所谓的闭包来考,举个简单但是足够经典的例子:
curriedAdd(1)(2)(3)
// 期望它每次调用的时候都能进行输出当前的总和
// 也就是输出三次,分别是:1 3 6
// 需要拓展到任意参数个数的情况
实现的代码如下:
这里不太一样的是,我们没有限制参数个数
const curry = (fn) => {
// let len = 0; 可以记录参数个数
let sum = 0; // 闭包
return curried = (...args) => {
// len += args.length; // 每次调用后的个数
sum = fn(sum, ...args);
console.log(sum);
return curried; // 这里没有设置参数上限,所以都返回“参数拼接器”curried
};
};
const add = (...args) => {
return args.reduce((total, num) => total + num, 0);
};
// 创建一个柯里化求和并打印中间结果的函数
const curriedAddAndLog = curry(add);
// 使用示例
curriedAddAndLog(1)(2)(3, 4); // 分别输出:1、3、10
// 这里,我们没有将数据归零,哈哈
// 不过相信经过了上面的学习,当真正遇到这种场景的时候,你也知道怎么去改进了(比如改为 add(1)(),最后以空参数调用,就归零
// curriedAddAndLog(1)(2, 3)(4)
如果你不太理解闭包的原理,诸如调用栈、作用域链、outer指针等等概念,也不太了解闭包的实际应用,但又想快速应付面试,那你可以简单地把闭包题目总结为:
- 函数套函数。
- 两层函数的“夹缝”之间,可以放一些变量,这些变量对于下面那层函数来说,就像是全局变量一般,可以随时用。
按部就班地实现上述两步,再把题目的具体要求往里面一套,一切都变得非常简单而美妙了。