Javascript进阶之柯里化
什么是柯里化(Currying)?什么是一元函数(Unary)?什么是偏函数(Partial)?
柯里化(Currying) 是一种函数式编程的概念,在JavaScript中可以用来创建灵活且可复用的函数。它指的是将一个接受多个参数的函数转换为一系列函数链,每个函数只接受一个单一的参数,这些函数称为一元函数(Unary)。这个过程中产生的一系列固定了部分参数的函数,就称为偏函数(Partial)。
例如一个普通版本的相加函数add如下
function add (x, y) {
return x + y;
}
或者用ES6 Lambda表达式写成
const add = (x, y) => x + y;
//调用
console.log( add (3, 5)); //8
这里add
函数一次接受两个参数x
和y
,返回它们的和。
柯里化的过程就是将add
分拆成两个函数,每个函数都只接受单一参数,也就是说每个都是Unary函数。
function curriedAdd (x) {
return function (y) {
return x + y;
}
}
或者用ES6 Lambda表达式写成
const curriedAdd = x => y => x + y;
//调用
const add3 = curriedAdd (3);
console.log( add3 (5)); //8
console.log( curriedAdd (5)(8));//13
把前述add
函数变成curriedAdd
函数的过程就是柯里化。而新函数add3
是一个一元函数(Unary),同时因为它固定了参与加法的第一个参数为3,因此它也是一个偏函数(Partial)。
为什么要柯里化?
柯里化能带来一系列的好处。最基本的点包括:
- 提高代码复用性
这是最显然的,柯里化之后形成一系列模块化的一元函数,它们可以复用。 - 提高安全性,降低潜在副作用
为讲清楚这个问题,先介绍纯函数的概念,它是指具有确定性(给定相同的输入,始终返回相同的输出),并且没有副作用(即不修改外部变量,不改变外部状态)的函数。柯里化形成一系列一元函数,这使得它们的行为简单透明易懂,通常就是或者非常接近于纯函数,这样有助于编写可预测、易于测试和易于维护的代码,可以提高系统的可靠性和健壮程度。 - 延迟计算
柯里化的函数在收集到足够参数之前不会进行计算。这意味着你可以延迟某些计算直到真正需要的时候才进行,从而避免不必要的计算,节省计算资源。这个延迟计算类似于懒惰求值(Lazy Evaluation) 的机制,不过前者是针对高阶函数而言,后者通常是针对表达式。 - 优雅
这也是显然的,柯里化、纯函数、Lambda表达式的组合可以让代码非常优雅。
当然缺点也是明显的,学习曲线陡峭化,代码可读性降低,这两者本质上都是函数式编程的高门槛与生俱来的副作用。此外,如果能理解在Javascript中实现柯里化对闭包的依赖(因为形参其实都是局部变量,每多一层调用,就依赖一次闭包),不难想象闭包必然伴随的内存开销副作用。
Javascript怎么实现柯里化?
JS中没有原生的柯里化方法。我们可以自己实现。
function curry (fn, acc=[]) {//fn是待柯里化的函数,acc是一个参数收集装置,初始化为空数组
return function (...args){//args收集下一次调用给的参数,无论是多少个
if([...acc,...args].length>=fn.length){//已经收集到足够的参数,调用fn
return fn(...acc,...args);
}
else{//收集到的参数不够执行,继续迭代
return curry(fn,[...acc,...args]);
}
}
};
优雅的Lambda表达式写法
const curry=(fn, acc=[])=>(...args)=>(a=>a.length>=fn.length?fn(...a):curry(fn,a))([...acc,...args]);
然后我们就可以调用这个curry方法将任意函数柯里化了
const sum = (a, b, c, d) => a + b + c + d;//一个四元求和函数sum
const curriedSum = curry(sum);//柯里化
console.log(curriedSum(1)(2)(3)(4));//10
console.log(curriedSum(1,2)(3)(4));//10
console.log(curriedSum(1)(2,3,4));//10
console.log(curriedSum(1,2,3,4));//10
console.log(curriedSum(1,2,3)(4,5,6));//输出结果仍然是10。多余参数5和6也传给了函数,但是被忽略
以上实现方法是基于递归的,递归调用可能存在栈溢出的问题,当然现代JS引擎的调用栈一般都在数千到一万个函数调用,Node.js更是可以修改调用栈的上限。
不过为了规避这个潜在问题,同时也为提升运行速度,我们可以改用迭代的方式来实现柯里化:
const curryIterative =fn=>(...args)=>{
while(args.length<fn.length){
return (...moreArgs)=>curryIterative(fn)(...args,...moreArgs);
}
return fn(...args);
} ;
测试一下
console.log(curryIterative((a,b,c)=>a*b*c)(1)(2)(5));//10
console.log(curryIterative((a,b,c)=>a*b*c)(1,2)(5,9,0));//10
//perfectly
更多版本的柯里化实现方法
版本一
const curry = function(fn){
return function curriedFn(...args){
if(args.length<fn.length){
return function(){
return curriedFn(...args.concat([...arguments]));
}
}
return fn(...args);
}
}
这个版本利用arguments数组,感觉写的一般,没有充分利用ES6+的新特性,不够优雅。
版本二
function currying (func, ...preArgs) {
let self = this
return function (...args) {
return func.apply(self, [].concat(preArgs, args))
}
};
function unCurrying (func) {
return function (reference, ...args) {
return func.apply(reference, args)
}
};
这个版本还带有 反柯里化(unCurrying) 的实现。反柯里化不止是柯里化的逆向过程,还能用于扩大适用范围,例如下面的例子:
const map = unCurrying([].map);
let tags = map(document.querySelectorAll('*'), item => item.tagName);
tags = [...new Set(tags)];
这里document.querySelectorAll()
方法返回的是一个NodeList
对象,它不是数组,没有map方法,但是借助unCurrying,可以把map方法扩展到NodeList
上调用。
版本三
const curry = fn => {
if (typeof fn !== 'function') {
throw new Error('No function provided!');
}
return function curriedFn(...args) {
if (args.length < fn.length) {
return function () {
// 使用 concat 方法连接累积参数和新传入的参数
const newArgs = args.concat(Array.from(arguments));
return curriedFn.apply(null, newArgs);
};
}
return fn.apply(null, args);
};
};
这个版本带有错误检测。
版本四
const curry =fn=>curried=(...args)=>args.length>=fn.length?fn(...args):(...nextArg)=>curried(...args,...nextArg);
就一个字,优雅。