Ramda.js中的柯里化实现

1,624 阅读6分钟

Tips:

这不是一个讲解柯里化原理与应用的文章,只是通过JavaScript来实现柯里化,所以本文不会分析柯里化在JavaScript中应用的优劣,也不会讲解柯里化的由来和他本身存在的意义。掘金上已经有很多好的文章来讲解这些了。因为是一边写代码来实现柯里化一边来写文章,所以文章读起来可能不会有很强的连贯性。如果有不对的地方,欢迎指出,搁置争议,共同开发,相互学习,共同进步~

Why?

通常柯里化的实现是这样的:

 function curry (fn) {
    return function f() {
        const args = [].slice.call(arguments);
        if(args.length < fn.length) {
            return function() {
                return f.apply(this, args.concat([].slice.call(arguments)))
            }
        } else {
          return  fn.apply(this, args);
        }
    } 
}

这个柯里化的实现有两个问题:

  • 调用柯里化的函数后无法确定函数元数。
  • 传入参数位置必须和函数接受的参数位置保持一致

现在我们解决第一个问题:获取不到柯里化后函数的参数。

我们知道函数的参数是可以通过length属性来获取的,所以我们需要一个辅助函数来确定函数参数的函数,这里是arity的实现:

function arity (n, fn) {
        switch(n){
            case 0:
                return function() { return fn.apply(this, arguments)};
            case 1:
                return function(a0) { return fn.apply(this, arguments)};
            case 2:
                return function(a0, a1) { return fn.apply(this,arguments)};
            case 3:
                return function(a0, a1, a2) { return fn.apply(this, arguments)};
            case 4:
                return function(a0, a1, a2, a3) { return fn.apply(this, arguments)};
            case 5:
                return function(a0, a1, a2, a3, a4) { return fn.apply(this, arguments)};
            case 6:
                return function(a0, a1, a2, a3, a4, a5) { return fn.apply(this, arguments)};
            case 7:
                return function(a0, a1, a2, a3, a4, a5, a6) { return fn.apply(this, arguments)};
            case 8:
                return function(a0, a1, a2, a3, a4, a5, a6, a7) { return fn.apply(this, arguments)};
            case 9:
            return function(a0, a1, a2, a3, a4, a5, a6, a7, a8) { return fn.apply(this, arguments)};
            case 10:
                return function(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9) { return fn.apply(this, arguments)};
            default: 
            throw new Error('First argument to arity must be a non-negative integer no greater than ten');
        }
    }

这里的arity只做了一件事,那就是根据包裹一个函数,返回一个确定参数的函数。一般来说,函数的复杂度是和他自身的参数成正比的,函数接收的函数越多,那么函数的复杂度就越高,虽然JavaScript中没有明确规定的传入参数的个数(好像是225个?),但是我们这里限制如果一个函数的参数超过是个那么就抛出错误。注意:arity函数也是ramda.js的内部实现。 有了包裹函数的arity函数,我们继续,来实现确定返回参数个数的柯里化版本:

function curry (length, recived, fn) {
        return function() {
            var args = [].slice.call(arguments);
            var combined = recived.concat(args);
            
            if(combined.length < length ) {
                return arity(length - combined.length, curry(length, combined, fn));
                
            } else {
                return fn.apply(this, combined);
            }
        }
    }

这里的curry函数接收三个参数,length:即函数参数的个数,recived:一个保存传入参数的数组,初始化为空数组,fn:柯里化的函数。调用curry后返回一个函数,通过闭包将返回的函数的参数和recived中的函数合并。如果接收的参数个数小于柯里化函数的参数个数,那么通过arity函数递归调用curry函数来收集剩余参数。这是一个生产->消费的过成。现在我们尝试调用一下改进后的curry:

    const a = (x, y, z) => x+y+z;
    const b = curry(3, [], a)(1);
    console.log(b.length) //=> 2
    
    const c = curry(3, [], a)(1, 2);
    console.log(c.length) //=> 1
    
    const d = curry(3, [], a)(1)(2);
    console.log(d) //=> 1
    

很完美!现在解决第二个问题,传入参数位置必须保持一致。这里我们需要一个占位符,占位符的作用就是尚待指定的参数,如果当前的参数是占位符,那表明应该忽略传入的参数。我们想要的结果是这样的

g(1, 2, 3)
g(_, 2, 3)(1)
g(_, _, 3)(1)(2)

以上的调用是等价的,示例出处Ramda官方文档。现在我们来实现占位符

const _ = { '@@function/placeholder' : true};
const _isPlaceholder = function (x) { 
        return  !!x[ '@@function/placeholder'] 
}

现在实现加入占位符的curry函数,这里我们需要一个数组来存放初始化传入的参数和经过柯里化函数调用时传入的参数,这是一个参数数组合并的过程。假设我们有一个函数:

const f = (x, y, z) => x+y+z

调用const g = curry(3, [], f) 初始化时传入了一个空数组,得到一个包裹函数,现在我们声明一个名为combined的空数组,来保存调用这个包裹函数传入的参数和初始化函数时传入的参数的数组。以下是Ramda.js中curry的实现:

//length: 柯里化函数参数的个数
//recived: 初始化接收的参数数组,
//fn : 柯里化的函数
function _curryN(length, recived, fn) {
    return function() {
        //存放每次调用函数参数的数组
        var combined = [];
        var args = [].slice.call(arguments);
        var argsIdx = 0;
        //用于检查参数是否全部传入
        var offset = length;
        
        /* 
        这里同时迭代recived和arguments。
        我们要循环取出每一次curryN初始化接收到的参数和调用函数时传入的参数保存在combined中,
        这里用一个额外的变量argsIdx用于迭代arguments的。
        */
        while(combined.length < recived.length || argsIdx < args.length) {
            var result;
            //首先迭代recived,取出不是占位符的参数仿入combined中
            if(combined.length < recived.length && (!_isPlaceholder(recived[combined.length]) || argsIdx >= args.length)) {
                result = recived[combined.length];
            } else {
                //如果recived已经迭代完了那么将arguments放入combined中
                result = args[argsIdx];
                argsIdx++;
            }
            
            combined[combined.length] = result;
            //如果当前参数不是占位符,则长度减1
            if(!_isPlaceholder(result)) offset -= 1;
            console.log(combined)
        }
        
        //如果传入参数满足fn参数个数,则直接调用fn,否则递归调用curry函数,反复过滤掉recived的占位符
        return offset <= 0 ? fn.apply(this, combined) : _arity(offset, _curryN(length, combined, fn));
    }
}

现在我们得到一个带有占位符功能的柯里化函数,我们试一下:

function say(name, age, like) { console.log(`我叫${name},我${age}岁了, 我喜欢${like}`) };
const msg = _curryN(3, [], say)
msg(_, 20)('大西瓜', _,) ('妹子')        // 我叫大西瓜,我20岁了, 我喜欢妹子
msg(_, _, '瞎bb')(_, '25')('小hb')     // 我叫小hb,我25岁了, 我喜欢瞎bb
msg('小明')(_, _)(22,  '小红')         // 我叫小明,我22岁了, 我喜欢小红

以上就是ramda中_curryN中的实现,至于curry和curryN也是基于_curryN来实现的。一开始看Ramda中curry的实现感觉脑子要炸了,那么多变量,根本不想去看。然后慢慢的尝试自己去写,比如先不实现占位符,先实现一个确定函数参数个数的curry,把复杂的问题分解成简单的问题,通过实现简单的功能来组合成复杂的功能,这也是函数式编程鼓励的。本文没有理论讲解,通篇的代码,幸好还有一点注释😄。第一次写,也算是记录自己的一个学习过程,欢迎大家留言讨论。