javascript函数柯里化

·  阅读 1046
javascript函数柯里化

什么是柯里化?

如果你固定某些参数,你将得到接受余下参数的一个函数。

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

函数柯里化的对偶是Uncurrying,一种使用匿名单参数函数来实现多参数函数的方法。

Currying概念其实很简单,只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

如果我们需要实现一个求三个数之和的函数:

function add(x, y, z) {
  return x + y + z;
}
console.log(add(1, 2, 3)); // 6
var add = function(x) {
  return function(y) {
    return function(z) {
      return x + y + z;
    }
  }
}


var addOne = add(1);
var addOneAndTwo = addOne(2);
var addOneAndTwoAndThree = addOneAndTwo(3);


console.log(addOneAndTwoAndThree);
复制代码

这里我们定义了一个add函数,它接受一个参数并返回一个新的函数。调用add之后,返回的函数就通过闭包的方式记住了add的第一个参数。一次性地调用它实在是有点繁琐,好在我们可以使用一个特殊的curry帮助函数(helper function)使这类函数的定义和调用更加容易。

ES6的箭头函数,我们可以将上面的add实现成这样:

const add = x => y => z => x + y + z;
复制代码

偏函数?

来看这个函数:

function ajax(url, data, callback) {
  // ..
}
复制代码

有这样的一个场景:我们需要对多个不同的接口发起HTTP请求,有下列两种做法:

  • 在调用ajax()函数时,传入全局URL常量。
  • 创建一个已经预设URL实参的函数引用。

下面我们创建一个新函数,其内部仍然发起ajax()请求,此外在等待接收另外两个实参的同时,我们手动将ajax()第一个实参设置成你关心的API地址。

对于第一种做法,我们可能产生如下调用方式:

function ajaxTest1(data, callback) {
  ajax('http://www.test.com/test1', data, callback);
}


function ajaxTest2(data, callback) {
  ajax('http://www.test.com/test2', data, callback);
}
复制代码

对于这两个类似的函数,我们还可以提取出如下的模式:

function beginTest(callback) {
  ajaxTest1({
    data: GLOBAL_TEST_1,
  }, callback);
}
复制代码

相信您已经看到了这样的模式:我们在函数调用现场(function call-site),将实参应用(apply) 于形参。如你所见,我们一开始仅应用了部分实参 —— 具体是将实参应用到URL形参 —— 剩下的实参稍后再应用。

上述概念即为偏函数的定义,偏函数一个减少函数参数个数的过程;这里的参数个数指的是希望传入的形参的数量。我们通过ajaxTest1()把原函数ajax()的参数个数从3个减少到了2个。

我们这样定义一个partial()函数:

function partial(fn, ...presetArgs) {
  return function partiallyApplied(...laterArgs) {
    return fn(...presetArgs, ...laterArgs);
  }
}
复制代码

partial()函数接收fn参数,来表示被我们偏应用实参(partially apply)的函数。接着,fn形参之后,presetArgs数组收集了后面传入的实参,保存起来稍后使用。

我们创建并return了一个新的内部函数(为了清晰明了,我们把它命名为partiallyApplied(..)),该函数中,laterArgs数组收集了全部实参。

使用箭头函数,则更为简洁:

var partial =
  (fn, ...presetArgs) =>
    (...laterArgs) =>
      fn(...presetArgs, ...laterArgs);
复制代码

使用偏函数的这种模式,我们重构之前的代码:

function ajax(url, data, callback) {
  // ..
}


var ajaxTest1 = partial(ajax, 'http://www.test.com/test1');
var ajaxTest2 = partial(ajax, 'http://www.test.com/test1');
复制代码

再次思考beginTest()函数,我们使用partial()来重构它应该怎么做呢?

function ajax(url, data, callback) {
  // ..
}


// 版本1
var beginTest = partial(ajax, 'http://www.test.com/test1', {
  data: GLOBAL_TEST_1,
});


// 版本2
var ajaxTest1 = partial(ajax, 'http://www.test.com/test1');
var beginTest = partial(ajaxTest1, {
  data: GLOBAL_TEST_1,
});
复制代码

一次传一个

相信你已经在上述例子中看到了版本2比起版本1的优势所在了,没错,柯里化就是:将一个带有多个参数的函数转换为一次一个的函数的过程。每次调用函数时,它只接受一个参数,并返回一个函数,直到传递所有参数为止。

假设我们已经创建了一个柯里化版本的ajax()函数curriedAjax()

curriedAjax('http://www.test.com/test1')
  ({
    data: GLOBAL_TEST_1,
  })
  (function callback(data) {
    // dosomething
  });
复制代码

我们将三次调用分别拆解开来,这也许有助于我们理解整个过程:

var ajaxTest1 = curriedAjax('http://www.test.com/test1');


var beginTest = ajaxTest1({
  data: GLOBAL_TEST_1,
});


var ajaxCallback = beginTest(function callback(data) {
  // dosomething
});
复制代码

实现柯里化

那么,我们如何来实现一个自动的柯里化的函数呢?

var currying = function(fn) {
  var args = [];


  return function() {
    if (arguments.length === 0) {
      return fn.apply(this, args); // 没传参数时,调用这个函数
    } else {
      [].push.apply(args, arguments); // 传入了参数,把参数保存下来
      return arguments.callee; // 返回这个函数的引用
    }
  }
}
复制代码

调用上述currying()函数:

var cost = (function() {
  var money = 0;
  return function() {
    for (var i = 0; i < arguments.length; i++) {
      money += arguments[i];
    }
    return money;
  }
})();


var cost = currying(cost);


cost(100); // 传入了参数,不真正求值
cost(200); // 传入了参数,不真正求值
cost(300); // 传入了参数,不真正求值


console.log(cost()); // 求值并且输出600
复制代码

上述函数是我之前的JavaScript设计模式与开发实践读书笔记之闭包与高阶函数所写的currying版本,现在仔细思考后发现仍旧有一些问题。

我们在使用柯里化时,要注意同时为函数预传的参数的情况。

因此把上述柯里化函数更改如下:

var currying = function(fn) {
  var args = Array.prototype.slice.call(arguments, 1);


  return function() {
    if (arguments.length === 0) {
      return fn.apply(this, args); // 没传参数时,调用这个函数
    } else {
      [].push.apply(args, arguments); // 传入了参数,把参数保存下来
      return arguments.callee; // 返回这个函数的引用
    }
  }
}
复制代码

使用实例:

var cost = (function() {
  var money = 0;
  return function() {
    for (var i = 0; i < arguments.length; i++) {
      money += arguments[i];
    }
    return money;
  }
})();


var cost = currying(cost, 100);
cost(200); // 传入了参数,不真正求值
cost(300); // 传入了参数,不真正求值


console.log(cost()); // 求值并且输出600
复制代码

你可能会觉得每次都要在最后调用一下不带参数的cost()函数比较麻烦,并且在cost()函数都要使用arguments参数不符合你的预期。我们知道函数都有一个length属性,表明函数期望接受的参数个数。因此我们可以充分利用预传参数的这个特点。

function sub_curry(fn) {
  var args = [].slice.call(arguments, 1);
  return function() {
    return fn.apply(this, args.concat([].slice.call(arguments)));
  };
}


function curry(fn, length) {


  length = length || fn.length;


  var slice = Array.prototype.slice;


  return function() {
    if (arguments.length < length) {
      var combined = [fn].concat(slice.call(arguments));
      return curry(sub_curry.apply(this, combined), length - arguments.length);
    } else {
      return fn.apply(this, arguments);
    }
  };
}
复制代码

在上述函数中,我们在currying的返回函数中,每次把arguments.lengthfn.length作比较,一旦arguments.length达到了fn.length的数量,我们就去调用fn(return fn.apply(this, arguments);)

验证:

var fn = curry(function(a, b, c) {
  return [a, b, c];
});


fn("a", "b", "c") // ["a", "b", "c"]
fn("a", "b")("c") // ["a", "b", "c"]
fn("a")("b")("c") // ["a", "b", "c"]
fn("a")("b", "c") // ["a", "b", "c"]
复制代码

bind方法的实现

使用柯里化,能够很方便地借用call()或者apply()实现bind()方法的polyfill

Function.prototype.bind = Function.prototype.bind || function(context) {
  var me = this;
  var args = Array.prototype.slice.call(arguments, 1);
  return function() {
    var innerArgs = Array.prototype.slice.call(arguments);
    var finalArgs = args.concat(innerArgs);
    return me.apply(contenxt, finalArgs);
  }
}
复制代码

上述函数有的问题在于不能兼容构造函数。我们通过判断this指向的对象的原型属性,来判断这个函数是否通过new作为构造函数调用,来使得上述bind方法兼容构造函数。

Function.prototype.bind() by MDN如下说到:

绑定函数适用于用new操作符 new 去构造一个由目标函数创建的新的实例。当一个绑定函数是用来构建一个值的,原来提供的 this 就会被忽略。然而, 原先提供的那些参数仍然会被前置到构造函数调用的前面。

这是基于MVC的JavaScript Web富应用开发bind()方法实现:

Function.prototype.bind = function(oThis) {
  if (typeof this !== "function") {
    throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
  }


  var aArgs = Array.prototype.slice.call(arguments, 1),
    fToBind = this,
    fNOP = function() {},
    fBound = function() {
      return fToBind.apply(
        this instanceof fNOP && oThis ? this : oThis || window,
        aArgs.concat(Array.prototype.slice.call(arguments))
      );
    };


  fNOP.prototype = this.prototype;
  fBound.prototype = new fNOP();


  return fBound;
};
复制代码

反柯里化(uncurrying)

可能遇到这种情况:拿到一个柯里化后的函数,却想要它柯里化之前的版本,这本质上就是想将类似f(1)(2)(3)的函数变回类似g(1,2,3)的函数。

下面是简单的uncurrying的实现方式:

function uncurrying(fn) {
  return function(...args) {
    var ret = fn;


    for (let i = 0; i < args.length; i++) {
      ret = ret(args[i]); // 反复调用currying版本的函数
    }


    return ret; // 返回结果
  };
}
复制代码

注意,不要以为uncurrying后的函数和currying之前的函数一模一样,它们只是行为类似!

var currying = function(fn) {
  var args = Array.prototype.slice.call(arguments, 1);


  return function() {
    if (arguments.length === 0) {
      return fn.apply(this, args); // 没传参数时,调用这个函数
    } else {
      [].push.apply(args, arguments); // 传入了参数,把参数保存下来
      return arguments.callee; // 返回这个函数的引用
    }
  }
}


function uncurrying(fn) {
  return function(...args) {
    var ret = fn;


    for (let i = 0; i < args.length; i++) {
      ret = ret(args[i]); // 反复调用currying版本的函数
    }


    return ret; // 返回结果
  };
}


var cost = (function() {
  var money = 0;
  return function() {
    for (var i = 0; i < arguments.length; i++) {
      money += arguments[i];
    }
    return money;
  }
})();


var curryingCost = currying(cost);
var uncurryingCost = uncurrying(curryingCost);
console.log(uncurryingCost(100, 200, 300)()); // 600
复制代码

柯里化或偏函数有什么用?

无论是柯里化还是偏应用,我们都能进行部分传值,而传统函数调用则需要预先确定所有实参。如果你在代码某一处只获取了部分实参,然后在另一处确定另一部分实参,这个时候柯里化和偏应用就能派上用场。

另一个最能体现柯里化应用的的是,当函数只有一个形参时,我们能够比较容易地组合它们(单一职责原则(Single responsibility principle))。因此,如果一个函数最终需要三个实参,那么它被柯里化以后会变成需要三次调用,每次调用需要一个实参的函数。当我们组合函数时,这种单元函数的形式会让我们处理起来更简单。

归纳下来,主要为以下常见的三个用途:

  • 延迟计算
  • 参数复用

实例:

前提:假设你是一个商家,要出售商品,为了卖出去更多的商品,决定打9这出售,可以使用以下函数进行折扣后的售出价格计算

function discount(price, discount) {
  return price * (1 - discount)
}
const price = discount(5000, 0.1);    //5000* (1 - 0.1) = 4500;
复制代码

当有一个顾客购买商品就会使用0.1的折扣去调用一次discount方法,那么当有很多顾客的时候,就会每次都变化discount的第一个参数,而第二个参数一致重复一样的0.1.这里的参数一直重复一样,可以使用柯里化进行优化

function discount(discount) {
  return function(price) {
    return price * (1 - discount)
  }
}


const tenPercentDiscount = discount(0.1);
const price1 = tenPercentDiscount(5000);    //4500
const price2 = tenPercentDiscount(1000);    //900
const price3 = tenPercentDiscount(3000);    //2700


const thirtyPercentDiscount = discount(0.3);
const price4 = thirtyPercentDiscount(5000);    //3500
复制代码
  • 动态生成函数

兼容IE和其他浏览器的添加事件方法

const addEvent = (ele, type, fn, capture) => {
	if (window.addEventListener) {
		ele.addEventListener(type, (e) => fn.call(ele, e), capture);
	} else if (window.attachEvent) {
		ele.attachEvent('on'+type, (e) => fn.call(ele, e));
	}
}
复制代码

这里会有一个问题,就是每一次绑定事件的时候,都需要一次环境的判断,再去进行绑定,如果我们将上面的函数进行柯里化,就能规避这个问题,在使用前做一次判断即可

const addEvent = (function() {
	if (window.addEventListener) {
		return function(ele) {
			return function(type) {
				return function(fn) {
					return function(capture) {
						ele.addEventListener(type, (e) => fn.call(ele, e), capture);
					}
				}
			}
		}
	} else if (window.attachEvent) {
		return function(ele) {
			return function(type) {
				return function(fn) {
					return function(capture) {
						ele.addEventListener(type, (e) => fn.call(ele, e), capture);
					}
				}
			}
		}
	}
})();
// 调用
addEvent(document.getElementById('app'))('click')((e) => {console.log('click function has been call:', e);})(false);
// 分步骤调用会更加清晰
const ele = document.getElementById('app');
// get environment
const environment = addEvent(ele)
// bind event
environment('click')((e) => {console.log(e)})(false);
复制代码

将普通函数柯里化的函数

// 柯里化函数
const curry = function (fn) {
    return function nest(...args) {
        // fn.length表示函数的形参个数
        if (args.length === fn.length) {
            // 当参数接收的数量达到了函数fn的形参个数,即所有参数已经都接收完毕则进行最终的调用
            return fn(...args);
        } else {
            // 参数还未完全接收完毕,递归返回judge,将新的参数传入
            return function (arg) {
                return nest(...args, arg);
            }
        }
    }
}
复制代码

调用过程

function addNum(a, b, c) {
    return a + b + c;
}
const addCurry = curry(addNum);
addCurry(1)(2)(3);// 6
复制代码

解析curry函数被调用的整个过程是如何生成柯里化函数并最终调用的时候得到最终的结果的

const addCurry = curry(addNum);
// 这一步得到了内部返回nest函数
// ƒ judge(...args) {
//         console.log('args:', args);
//         console.log('fn.length:', fn.length);
//         if (args.length === fn.length) {
//             return fn(...args);
//         } else {
//           …
addCurry(1)(2)(3);
/**
step1: 
nest(1) --> else --> nest(1,arg);
step2:
nest(1, 2) --> else --> nest(1,2,arg);
step3:
nest(1, 2, 3) --> if --> fn(1, 2, 3) --> addNum(1, 2, 3) --> return 6
*/
复制代码

参考:JavaScript函数柯里化:github.com/xingbofeng/…

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改