聊聊柯里化
最近在看柯里化(currying),发现网上的资料众说纷纭,鱼龙混杂。再加上也算是一道高频面试题,所以也试试实现了一下,顺便记录一下心得。
首先明确一点,currying 的定义,根据 wiki 的解释,柯里化是一种将接受多参数函数转换成一个接受单一序列参数的函数( currying is the technique of conveting a function that takes multiple arguments into a sequence of functions that each take a single arguments. )
数学表达式:
x= f(a,b,c) becomse :
h = g(a);
i = h(b);
x = i(c);
或者可以链式调用 x = g(a)(b)(c).
根据定义我们可以得知,currying 需要函数有一个固定数量入参,这样才能将对应的函数正确的“柯里”。至于非固定的入参,我们后面再讲。
先看一个简单的实现。
我们来实现一个基本的 sum 方法,接受三个参数,返回加和。
function sum(a, b, c) {
return a + b + c;
}
我们再来实现柯里方法。
function curry(fn) {
return function curried(...args) {
// fn.length 返回需要科里化的方法的 arguments 的长度,
// 如果不到这个长度,则说明需要继续接受参数,
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return function (...args2) {
return curried.apply(this, args.concat(args2));
};
}
};
}
实现比较直观,如果长度不及原始函数规定的入参,则继续 concat,直到满足长度,调用 apply 立刻执行。
使用方法如下。
const curried = curry(sum);
console.log("curried sum result", curried(1, 2, 3));
console.log("curried sum result", curried(1, 2)(3));
console.log("curried sum result", curried(1)(2)(3));
有三个小点值得注意。
第一是 arguments 对象,是一个 array like object, 它拥有 length 属性,但是想直接调用数组的方法是不行的。
记录一下茴字的四种写法。
var args = Array.prototype.slice.call(arguments)
var args = [].slice.call(arguments)
var args = Array.from(arguments)
var args = [… arguments]
第二个是上面代码里的第 3 行, fn.length, 返回的是原始函数的入参长度。
第三,调用时,不仅可以 curried(1)(2)(3)
, 还能curried(1, 2)(3)
。 后者称作 partial function。有机会再聊。
当然市面上会有各种实现方式,配合 es5,es6,检查各种边际条件。查一查会有很多,这里不做赘述。
接下来聊聊柯里化的使用场景。
柯里化提供了一种封装方式,减少代码冗余,增加代码的可读性。
最常见的例子就是 log 函数,比如 log 可以接受 time ,level, message 三个参数。
function log(date, importance, message) {
alert(`[${date.getHours()}:${date.getMinutes()}] [${importance}] ${message}`);
}
如果我想在此基础上封装,logNow ,我可以这么做
const curriedLog = _.curry(log);
// logNow will be the partial of log with fixed first argument
let logNow = curriedLog(new Date());
// use it
logNow("INFO", "message"); // [HH:mm] INFO message
或者我们想再进一步,想封装一个 debugNow 函数
let debugNow = logNow("DEBUG");
debugNow("message"); // [HH:mm] DEBUG message
谈完了应用场景,再说说效率。
是的,柯里化很慢,性能损耗很大。为什么?大量的嵌套作用域和闭包,带来了不小的内存占用。至于网上说的, fn.apply 和 fn.call 比直接调用 fn 慢(看上去是的), 老版本浏览器在 arguments.length 的实现相当慢(不知道),存取 arguments 对象比存取命名参数要慢一些(不确定),这些原因,看上去可能,但是我觉得主要原因还是内存方面。昨晚在看《函数式编程》里面,有做过柯里化和普通函数的性能对比,有空附上结果和页码。
最后说说前面遗留的一个问题,也是面试中常见的一道题,题目如下
sum(1)();
sum(1)(2)();
sum(1)(2)(3)();
题目大致如此,基本就是非固定的入参,求加和。关于这点,我想想说说我的看法。
我认为这道题要往柯里化上靠,有点勉强。放在 closure 门类里,比较合适。
附上我写的一个题解。
//首先定义一个方法,不限定入参数量
function sum() {
return Array.prototype.slice.apply(arguments).reduce((p, c) => {
return p + c;
}, 0);
}
console.log("sum", sum(1, 2, 3, 4));
function curry2(fn) {
return function curried(...args) {
// 累计保存的 arguments
return function (...args2) {
// 真正接受的 arguments
if (!args2.length) {
// 最后传的空,告知返回结果
return fn.apply(this, args);
} else {
// 如果入参不为空, 则继续 concat,返回 curried 方法,等待下一次调用
return curried.apply(this, args.concat(args2));
}
};
};
}
const curried = curry2(sum);
console.log("curried sum result", curried(1)(2)(3)(4)());
除了上述对 arguments 反复摩擦,我觉得基于 closure 的实现更加直接。主要思想就一点,也是闭包的基本概念,内层函数可以访问外层作用域。
实现如下。
function add(n) {
let s = n;
return function fun(m) {
if (!m) return s;
s += m;
return fun;
};
}
console.log(add(1)(2)(3)(4)()); //10
这种求和方式,除了最后传入一个空以外,还有各种变体,比如给 sum 对象加一个 toString 方法,通过+sum(1)
来进行隐式类型转换(type coercion),那种太 hack 了,我觉得 duck 不必。
最后,Curry 命名不是因为咖喱,而是因为 Haskell Curry。
谢谢阅读。