第一次看到柯里化这个词的时候,还是在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的reduce 和rest运算符 ,对阅读理解可能有点影响,但熟悉语法运用之后,就会发现这些语法糖写出来的代码很简洁。
另外,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
最后,柯里化是实现函数式编程的重要技巧之一,更多深入知识,可以查看本书函数式编程 部分的内容。