壹.3.2 JavaScript函数柯里化

第一次看到柯里化这个词的时候,还是在2007年看一篇算法相关的博客提到把函数柯里化,那时一看这个词就感觉很高端,实际上了解了后才发现其实就是高阶函数的一个特殊用法。

柯里化的定义

柯里化(Currying)是一种技术,它把接受m个参数的函数变成接受n个参数的函数(0<n<m),并且该函数返回一个新函数,这个新函数接受余下的参数……如此循环,直到最后返回结果。

看这个定义可能有一点抽象,我们就来看一个简单的示例。

// 普通的add函数
function add(x, y) {
return x + y
}
// Currying后
function curryingAdd(x) {
return function (y) {
return x + y
}
}
add(1, 2) // 3
curryingAdd(1)(2) // 3

实际上就是把add函数的x,y两个参数变成了先用一个函数接收x然后返回一个函数去处理y参数。

那么,柯里化额外的封装了一层有什么具体的好处呢?

柯里化的好处

1. 复用参数

// 正常正则验证字符串 reg.test(txt)
// 函数封装后
function check(reg, txt) {
return reg.test(txt)
}
check(/\d+/g, 'test') //false
check(/[a-z]+/g, 'test') //true
// Currying后
function curryingCheck(reg) {
return function(txt) {
return reg.test(txt)
}
}
var hasNumber = curryingCheck(/\d+/g)
var hasLetter = curryingCheck(/[a-z]+/g)
hasNumber('test1') // true
hasNumber('testtest') // false
hasLetter('21212') // false

上面的示例是一个正则的校验,正常来说直接调用check函数就可以了,但是如果我们有很多地方都要校验是否有数字,第一个参数其实没有变化,变化的是第二个参数。Currying之后,第一个参数reg的输入就可以省略掉,后面敲代码更省事了。

2. 延迟运行

先仍然看一个add函数代码:

// 利用reduce实现多参数版add
let add = function(...args){
return args.reduce(function(accumulator, currentValue) {
return accumulator + currentValue;
},0)
};
// 一个简单的currying实现
function currying(func) {
const args = [];
return function result(...rest) {
if (rest.length == 0) {
return func(...args);
} else {
args.push(...rest);
return result;
}
}
}
let sum = currying(add);
sum(1)(2)(3); //未真正执行求和运算
sum(4); //未真正执行求和运算
sum(); //执行求和

上面代码让函数通过柯里化,判断参数,如果有参数,就保存起来,不执行求和,直到最后一步才真正执行求和运算,达到了延迟运行的效果。

读者可能会问,延迟运行一定就要柯里化吗?当然不是。延迟运行和柯里化没有必然联系,本质上延迟运行,函数返回一个新函数(闭包)就可以实现了,这里只是举例说明柯里化有这个特性而已。

顺带提一下,代码中用到了ES6的reducerest运算符,对阅读理解可能有点影响,但熟悉语法运用之后,就会发现这些语法糖写出来的代码很简洁。

另外,js中经常使用的bind,实现的机制就是Currying。bind 用来改变函数执行时候的this,但是函数本身并不执行,所以是延迟运行,这一点和call / apply直接执行有所不同。

Function.prototype.bind = function (context) {
var _this = this;
var args = Array.prototype.slice.call(arguments, 1);
return function() {
return _this.apply(context, args);
}
}

3.参与科学计算

援引百度百科的解释:

理论计算机科学中,柯里化提供了一个办法,可以在简单的理论模型中,比如只接受一个单一参数的lambda演算中研究带有多个参数的函数。

通用的封装

给定一个函数fn,设计一个通用封装(currying函数)作用于这个fn,让这个fn可以支持柯里化,该怎么实现呢?思路:

  • 要让fn(a,b)等价于fn(a)(b),那么fn(a)要返回一个函数,这个函数接受参数b,并返回与fn(a,b)相同的结果。

  • 设计一个currying函数,它接受第一个参数是要被柯里化的函数fn,第2~N个参数是原本要传递给fn的参数(这个可用rest操作符实现)。

  • 柯里化主要围绕“处理参数”思考,不管怎么变化传参形式,柯里化之后的函数要把之前函数的参数都统统处理完毕才算合格。

代码如下:

// 初步的柯里化函数,其中fn是即将被柯里化的函数
var primaryCurrying = function (fn,...args) {
return function (...args2) {
// 将以后传给这个闭包函数里的全部参数和之前的args进行合并
var newArgs = args.concat(args2);
// 把合并后的参数通过apply作为fn的参数并执行
return fn.apply(this, newArgs);
}
}
function add(a, b) {
return a + b;
}
var add1= primaryCurrying(add, 1);
var result = add1(2);
console.log(result); //>> 3

看上面代码示例,已经有点柯里化了,但是还不符合要求。因为这个代码柯里化后得到的新函数add1只支持传“1段”参数,如果连续多段地传参数比如curry(a)(b)这样的话就不支持了。

笔者注:函数右边带的一个可以处理参数的小括号对(),本书称为“1段” 。函数若能多带1段,我们称函数有多1段的能力。

代码如下:

var curry = primaryCurrying(add);
var add = curry(1,2);
console.log(add);//>> 3
var add = curry(1)(2);
console.log(add); //>> TypeError: curry(...) is not a function

看上面代码第7行报错部分,是因为curry(1)没有返回函数,怎么办呢?若参数没处理完,想办法返回函数就行了。一般这种情况可以用递归再封装一层。我们可以把这个primaryCurrying函数用作辅助函数(因为它已支持1段以便处理参数了),帮助我们写真正的 柯里化函数。

// 借用之前的初步柯里化函数,让柯里化后的函数fn有多1段的能力。
var primaryCurrying = function (fn,...args) {
return function (...args2) {
var newArgs = args.concat(args2);
return fn.apply(this, newArgs);
}
}
/**
* 设计真正的柯里化函数。
* @param fn 即将被柯里化的函数。
* @param length 用来记录fn应该处理的剩余参数的长度。
*/
function curry(fn, length) {
length = length || fn.length;
return function (...args2) {
//若原本要传给fn的参数还未传完
if (args2.length < length) {
//合并参数
var combinedArgs = [fn].concat(args2);
//递归,进一步柯里化。这里调用了primaryCurrying函数,每调用一次该函数,
//就可以多“1段”以便可以处理掉剩余的参数,直到把所有应传给fn的参数都处理完。
return curry(primaryCurrying.apply(this, combinedArgs), length - args2.length);
}
//若原本要传给fn的参数都已经传完,则直接执行fn函数
else {
return fn.apply(this, args2);
}
};
}

这边其实是在初步的基础上,加上了递归的调用,只要原本要传给fn的参数还未传完,就会继续执行递归。来测试一下效果怎么样?

var fn = curry(function (a, b, c) {
return [a, b, c];
});
var l=console.log;
l(fn("a", "b", "c")); //>> ["a", "b", "c"]
l(fn("a", "b")("c")); //>> ["a", "b", "c"]
l(fn("a")("b")("c")); //>> ["a", "b", "c"]
l(fn("a")("b", "c")); //>> ["a", "b", "c"]

带占位符的封装

占位符可以支持不按顺序传递参数,举例要求如下:

var fn = curry(function(a, b, c) {
console.log([a, b, c]);
});
fn("a", _, "c")("b"); //>> ["a", "b", "c"]

这里给出一个比较强大的代码实现:

function curry(fn, args, holes) {
let length = fn.length;//fn的形参的长度
args = args || [];//fn的实参
holes = holes || [];//占位符数组
return function (...args2) {
let _args = args.slice(0),//存放组合后的参数
_holes = holes.slice(0),
argsLen = args.length,//fn的实参的长度
holesLen = holes.length,
index = 0;
for (let i = 0; i < args2.length; i++) {
let arg = args2[i];
// 处理类似 fn(1, _, _, 4)(_, 3) 这种情况,index 需要指向 holes 正确的下标
if (arg === _ && holesLen) {
index++;
if (index > holesLen) {
_args.push(arg);
_holes.push(argsLen - 1 + index - holesLen);
}
}
// 处理类似 fn(1)(_) 这种情况
else if (arg === _) {
_args.push(arg);
_holes.push(argsLen + i);
}
// 处理类似 fn(_, 2)(1) 这种情况
else if (holesLen) {
// fn(_, 2)(_, 3)
if (index >= holesLen) {
_args.push(arg);
}
// fn(_, 2)(1) 用参数 1 替换占位符
else {
_args.splice(_holes[index], 1, arg);
_holes.splice(index, 1);
}
}
else {
_args.push(arg);
}
}
if (_holes.length || _args.length < length) {
return curry.call(this, fn, _args, _holes);
}
else {
return fn.apply(this, _args);
}
}
}
///////////////////////开始测试/////////////////////////
let _ = { '@@functional/placeholder':true};//定义一个占位符,这里参考Ramda的定义
let fn = curry(function (a, b, c, d, e) {
console.log([a, b, c, d, e]);
});
// 验证 输出全部都是 [1, 2, 3, 4, 5]
fn(1, 2, 3, 4, 5);
fn(_, 2, 3, 4, 5)(1);
fn(1, _, 3, 4, 5)(2);
//上面3个很好理解,下面3个需要注意理解规则
fn(1, _, 3)(_, 4)(2)(5);
fn(1, _, _, 4)(_, 3)(2)(5);
fn(_, 2)(_, _, 4)(1)(3)(5);

柯里化性能

Currying的一些性能问题,笔者总结目前主要是以下四点:

  • 一些实现基于存取arguments对象,通常要比存取命名参数慢一点;

  • 一些老版本的浏览器在arguments.length的实现上是相当慢的;

  • 使用fn.apply( … ) 和 fn.call( … )通常比直接调用fn( … ) 稍微慢点;

  • 创建大量嵌套作用域和闭包函数会带来花销,无论是在内存还是速度上。

其实在大部分应用中,主要的性能瓶颈是在操作DOM节点上。用JavaScript的性能损耗相对DOM操作的性能损耗而言,基本是可以忽略不计的,所以柯里化在大多数场合是可以放心使用。

一道经典面试题

// 实现一个add方法,使计算结果能够满足如下预期:
add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;
////////////////////////////////////////
function add() {
// 第一次执行时,定义一个数组专门用来存储所有的参数
var _args = Array.prototype.slice.call(arguments);
// 在内部声明一个函数,利用闭包的特性保存_args并收集所有的参数值
var _adder = function() {
_args.push(...arguments);
return _adder;
};
// 利用toString隐式转换的特性,当最后执行时隐式转换,并计算最终的值返回
_adder.toString = function () {
return _args.reduce(function (accumulator, currentValue) {
return accumulator + currentValue;
}, 0);
}
return _adder;
}
////////////////////////////////////////////////////////
add(1)(2)(3).toString(); //>> 6
add(1, 2, 3)(4).toString(); //>> 10
add(1)(2)(3)(4)(5).toString(); //>> 15
add(2, 6)(1).toString(); //>> 9

最后,柯里化是实现函数式编程的重要技巧之一,更多深入知识,可以查看本书函数式编程部分的内容。

参考文献: Favoring Curry