JavaScript函數柯里化

什麼是柯里化?

官方的說法

在計算機科學中,柯里化(英語:Currying),又譯爲卡瑞化加里化,是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,而且返回接受餘下的參數並且返回結果的新函數的技術。這個技術由克里斯托弗·斯特雷奇以邏輯學家哈斯凱爾·加里命名的,儘管它是Moses Schönfinkel戈特洛布·弗雷格發明的javascript

在直覺上,柯里化聲稱若是你固定某些參數,你將獲得接受餘下參數的一個函數
在理論計算機科學中,柯里化提供了在簡單的理論模型中,好比:只接受一個單一參數的lambda演算中,研究帶有多個參數的函數的方式。
函數柯里化的對偶是Uncurrying,一種使用匿名單參數函數來實現多參數函數的方法。java

方便的理解

Currying概念其實很簡單,只傳遞給函數一部分參數來調用它,讓它返回一個函數去處理剩下的參數。git

若是咱們須要實現一個求三個數之和的函數:github

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)使這類函數的定義和調用更加容易。ajax

ES6的箭頭函數,咱們能夠將上面的add實現成這樣:設計模式

const add = x => y => z => x + y + z;

好像使用箭頭函數更清晰了許多。數組

偏函數?

來看這個函數:閉包

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

有這樣的一個場景:咱們須要對多個不一樣的接口發起HTTP請求,有下列兩種作法:app

  • 在調用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的優點所在了,沒錯,柯里化就是:將一個帶有多個參數的函數轉換爲一次一個的函數的過程。每次調用函數時,它只接受一個參數,並返回一個函數,直到傳遞全部參數爲止。

The process of converting a function that takes multiple arguments into a function that takes them one at a time.

Each time the function is called it only accepts one argument and returns a function that takes one argument until all arguments are passed.

假設咱們已經建立了一個柯里化版本的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屬性,代表函數指望接受的參數個數。所以咱們能夠充分利用預傳參數的這個特色。

借鑑自mqyqingfeng

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))。所以,若是一個函數最終須要三個實參,那麼它被柯里化之後會變成須要三次調用,每次調用須要一個實參的函數。當咱們組合函數時,這種單元函數的形式會讓咱們處理起來更簡單。

概括下來,主要爲如下常見的三個用途:

  • 延遲計算
  • 參數複用
  • 動態生成函數
相關文章
相關標籤/搜索