壹.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

最后更新于