【進階 6-2 期】深刻高階函數應用之柯里化

更新:謝謝你們的支持,最近折騰了一個博客官網出來,方便你們系統閱讀,後續會有更多內容和更多優化,猛戳這裏查看前端

------ 如下是正文 ------git

引言

上一節介紹了高階函數的定義,並結合實例說明了使用高階函數和不使用高階函數的狀況。後面幾部分將結合實際應用場景介紹高階函數的應用,本節先來聊聊函數柯里化,經過介紹其定義、比較常見的三種柯里化應用、並在最後實現一個通用的 currying 函數,帶你認識完整的函數柯里化。github

有什麼想法或者意見均可以在評論區留言,下圖是本文的思惟導圖,高清思惟導圖和更多文章請看個人 Github面試

【進階 6-2 期】深刻高階函數應用之柯里化

柯里化

定義

函數柯里化又叫部分求值,維基百科中對柯里化 (Currying) 的定義爲:segmentfault

在數學和計算機科學中,柯里化是一種將使用多個參數的函數轉換成一系列使用一個參數的函數,而且返回接受餘下的參數並且返回結果的新函數的技術。數組

用大白話來講就是隻傳遞給函數一部分參數來調用它,讓它返回一個新函數去處理剩下的參數。使用一個簡單的例子來介紹下,最經常使用的就是 add 函數了。瀏覽器

// 木易楊
const add = (...args) => args.reduce((a, b) => a + b);

// 傳入多個參數,執行 add 函數
add(1, 2) // 3

// 假設咱們實現了一個 currying 函數,支持一次傳入一個參數
let sum = currying(add);
// 封裝第一個參數,方便重用
let addCurryOne = sum(1);
addCurryOne(2) // 3
addCurryOne(3) // 4
複製代碼

實際應用

一、延遲計算

咱們看下面的部分求和例子,很好的說明了延遲計算這個狀況。閉包

// 木易楊
const add = (...args) => args.reduce((a, b) => a + b);

// 簡化寫法
function currying(func) {
    const args = [];
    return function result(...rest) {
        if (rest.length === 0) {
          return func(...args);
        } else {
          args.push(...rest);
        	return result;
        }
    }
}

const sum = currying(add);

sum(1,2)(3); // 未真正求值
sum(4); 		 // 未真正求值
sum(); 			 // 輸出 10
複製代碼

上面的代碼理解起來很容易,就是「用閉包把傳入參數保存起來,當傳入參數的數量足夠執行函數時,就開始執行函數」。上面的 currying 函數是一種簡化寫法,判斷傳入的參數長度是否爲 0,若爲 0 執行函數,不然收集參數。app

另外一種常見的應用是 bind 函數,咱們看下 bind 的使用。函數

// 木易楊
let obj = {
  name: 'muyiy'
}
const fun = function () {
  console.log(this.name);
}.bind(obj);

fun(); // muyiy
複製代碼

這裏 bind 用來改變函數執行時候的上下文,可是函數自己並不執行,因此本質上是延遲計算,這一點和 call / apply 直接執行有所不一樣。

咱們看下 bind 模擬實現,其自己就是一種柯里化,咱們在最後的實現部分會發現,bind 的模擬實現和柯理化函數的實現,其核心代碼都是一致的。

如下實現方案是簡化版實現,完整版實現過程和代碼解讀請看我以前寫的一篇文章,【進階3-4期】深度解析bind原理、使用場景及模擬實現

// 木易楊
// 簡化實現,完整版實現中的第 2 步
Function.prototype.bind = function (context) {
    var self = this;
    // 第 1 個參數是指定的 this,截取保存第 1 個以後的參數
		// arr.slice(begin); 即 [begin, end]
    var args = Array.prototype.slice.call(arguments, 1); 

    return function () {
        // 此時的 arguments 是指 bind 返回的函數調用時接收的參數
        // 即 return function 的參數,和上面那個不一樣
      	// 類數組轉成數組
        var bindArgs = Array.prototype.slice.call(arguments);
      	// 執行函數
        return self.apply( context, args.concat(bindArgs) );
    }
}
複製代碼

二、動態建立函數

有一種典型的應用情景是這樣的,每次調用函數都須要進行一次判斷,但其實第一次判斷計算以後,後續調用並不須要再次判斷,這種狀況下就很是適合使用柯里化方案來處理。即第一次判斷以後,動態建立一個新函數用於處理後續傳入的參數,並返回這個新函數。固然也可使用惰性函數來處理,本例最後一個方案會有所介紹。

咱們看下面的這個例子,在 DOM 中添加事件時須要兼容現代瀏覽器和 IE 瀏覽器(IE < 9),方法就是對瀏覽器環境進行判斷,看瀏覽器是否支持,簡化寫法以下。

// 簡化寫法
function addEvent (type, el, fn, capture = false) {
    if (window.addEventListener) {
        el.addEventListener(type, fn, capture);
    }
    else if(window.attachEvent){
        el.attachEvent('on' + type, fn);
    }
}
複製代碼

可是這種寫法有一個問題,就是每次添加事件都會調用作一次判斷,那麼有沒有什麼辦法只判斷一次呢,能夠利用閉包和當即調用函數表達式(IIFE)來處理。

const addEvent = (function(){
    if (window.addEventListener) {
        return function (type, el, fn, capture) {
            el.addEventListener(type, fn, capture);
        }
    }
    else if(window.attachEvent){
        return function (type, el, fn) {
            el.attachEvent('on' + type, fn);
        }
    }
})();
複製代碼

上面這種實現方案就是一種典型的柯里化應用,在第一次的 if...else if... 判斷以後完成部分計算,動態建立新的函數用於處理後續傳入的參數,這樣作的好處就是以後調用就不須要再次計算了。

固然可使用惰性函數來實現這一功能,原理很簡單,就是重寫函數。

function addEvent (type, el, fn, capture = false) {
  	// 重寫函數
    if (window.addEventListener) {
        addEvent = function (type, el, fn, capture) {
            el.addEventListener(type, fn, capture);
        }
    }
    else if(window.attachEvent){
        addEvent = function (type, el, fn) {
            el.attachEvent('on' + type, fn);
        }
    }
  	// 執行函數,有循環爆棧風險
  	addEvent(type, el, fn, capture); 
}
複製代碼

第一次調用 addEvent 函數後,會進行一次環境判斷,在這以後 addEvent 函數被重寫,因此下次調用時就不會再次判斷環境,能夠說很完美了。

三、參數複用

咱們知道調用 toString() 能夠獲取每一個對象的類型,可是不一樣對象的 toString() 有不一樣的實現,因此須要經過 Object.prototype.toString() 來獲取 Object 上的實現,同時以 call() / apply() 的形式來調用,並傳遞要檢查的對象做爲第一個參數,例以下面這個例子。

function isArray(obj) { 
    return Object.prototype.toString.call(obj) === '[object Array]';
}

function isNumber(obj) {
    return Object.prototype.toString.call(obj) === '[object Number]';
}

function isString(obj) {
    return Object.prototype.toString.call(obj) === '[object String]';
}

// Test
isArray([1, 2, 3]); // true
isNumber(123); // true
isString('123'); // true
複製代碼

可是上面方案有一個問題,那就是每種類型都須要定義一個方法,這裏咱們可使用 bind 來擴展,優勢是能夠直接使用改造後的 toStr

const toStr = Function.prototype.call.bind(Object.prototype.toString);

// 改造前
[1, 2, 3].toString(); // "1,2,3"
'123'.toString(); // "123"
123.toString(); // SyntaxError: Invalid or unexpected token
Object(123).toString(); // "123"

// 改造後
toStr([1, 2, 3]); 	// "[object Array]"
toStr('123'); 		// "[object String]"
toStr(123); 		// "[object Number]"
toStr(Object(123)); // "[object Number]"
複製代碼

上面例子首先使用 Function.prototype.call 函數指定一個 this 值,而後 .bind 返回一個新的函數,始終將 Object.prototype.toString 設置爲傳入參數,其實等價於 Object.prototype.toString.call()

實現 currying 函數

咱們能夠理解所謂的柯里化函數,就是封裝「一系列的處理步驟」,經過閉包將參數集中起來計算,最後再把須要處理的參數傳進去。那如何實現 currying 函數呢?

實現原理就是「用閉包把傳入參數保存起來,當傳入參數的數量足夠執行函數時,就開始執行函數」。上面延遲計算部分已經實現了一個簡化版的 currying 函數。

下面咱們來實現一個更加健壯的的 currying 函數。

// 木易楊
function currying(fn, length) {
  length = length || fn.length; 	// 註釋 1
  return function (...args) {			// 註釋 2
    return args.length >= length	// 註釋 3
    	? fn.apply(this, args)			// 註釋 4
      : currying(fn.bind(this, ...args), length - args.length) // 註釋 5
  }
}

// Test
const fn = currying(function(a, b, c) {
    console.log([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"]
複製代碼
  • 註釋 1:第一次調用獲取函數 fn 參數的長度,後續調用獲取 fn 剩餘參數的長度

  • 註釋 2:currying 包裹以後返回一個新函數,接收參數爲 ...args

  • 註釋 3:新函數接收的參數長度是否大於等於 fn 剩餘參數須要接收的長度

  • 註釋 4:知足要求,執行 fn 函數,傳入新函數的參數

  • 註釋 5:不知足要求,遞歸 currying 函數,新的 fn 爲 bind 返回的新函數(bind 綁定了 ...args 參數,未執行),新的 length 爲 fn 剩餘參數的長度

上面使用的是 ES5 和 ES6 的混合語法,那我不想使用 call/apply/bind 這些方法呢,天然是能夠的,看下面的 ES6 極簡寫法,更加簡潔也更加易懂。

// 參考自 segmentfault 的@大笑平 
const currying = fn =>
    judge = (...args) =>
        args.length >= fn.length
            ? fn(...args)
            : (...arg) => judge(...args, ...arg)

// Test
const fn = currying(function(a, b, c) {
    console.log([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"]
複製代碼

若是你還沒法理解,看完下面例子你就更加容易理解了,要求實現一個 add 方法,須要知足以下預期。

add(1, 2, 3) // 6
add(1, 2)(3) // 6
add(1)(2)(3) // 6
add(1)(2, 3) // 6
複製代碼

咱們能夠看到,計算結果就是全部參數的和,若是咱們分兩次調用時 add(1)(2),能夠寫出以下代碼。

function add(a) {
  return function(b) {
  	return a + b;
  }
}

add(1)(2) // 3
複製代碼

add 方法第一次調用後返回一個新函數,經過閉包保存以前的參數,第二次調用時知足參數長度要求而後執行函數。

若是分三次調用時 add(1)(2)(3),能夠寫出以下代碼。

function add(a) {
  return function(b) {
    return function (c) {
    	return a + b + c;
    }
  }
}
console.log(add(1)(2)(3)); // 6
複製代碼

前面兩次調用每次返回一個新函數,第三次調用後知足參數長度要求而後執行函數。

這時候咱們再來看 currying 實現函數,其實就是判斷當前參數長度夠不夠,參數夠了就立馬執行,不夠就返回一個新函數,這個新函數並不執行,而且經過 bind 或者閉包保存以前傳入的參數。

// 註釋同上
function currying(fn, length) {
  length = length || fn.length; 	
  return function (...args) {			
    return args.length >= length	
    	? fn.apply(this, args)			
      : currying(fn.bind(this, ...args), length - args.length) 
  }
}
複製代碼

擴展:函數參數 length

函數 currying 的實現中,使用了 fn.length 來表示函數參數的個數,那 fn.length 表示函數的全部參數個數嗎?並非。

函數的 length 屬性獲取的是形參的個數,可是形參的數量不包括剩餘參數個數,並且僅包括第一個具備默認值以前的參數個數,看下面的例子。

((a, b, c) => {}).length; 
// 3

((a, b, c = 3) => {}).length; 
// 2 

((a, b = 2, c) => {}).length; 
// 1 

((a = 1, b, c) => {}).length; 
// 0 

((...args) => {}).length; 
// 0

const fn = (...args) => {
  console.log(args.length);
} 
fn(1, 2, 3)
// 3
複製代碼

因此在柯里化的場景中,不建議使用 ES6 的函數參數默認值。

const fn = currying((a = 1, b, c) => {
  console.log([a, b, c]); 
}); 

fn();
// [1, undefined, undefined]

fn()(2)(3); 
// Uncaught TypeError: fn(...) is not a function
複製代碼

咱們指望函數 fn 輸出 [1, 2, 3],可是實際上調用柯里化函數時 ((a = 1, b, c) => {}).length === 0,因此調用 fn() 時就已經執行並輸出了 [1, undefined, undefined],而不是理想中的返回閉包函數,因此後續調用 fn()(2)(3) 將會報錯。

小結

咱們經過定義認識了什麼是柯里化函數,而且介紹了三種實際的應用場景:延遲計算、動態建立函數、參數複用,而後實現了強大的通用化 currying 函數,不過更像是柯里化 (currying) 和偏函數 (partial application) 的綜合應用,而且在最後介紹了函數的 length,算是意外之喜。

  • 定義:柯里化是一種將使用多個參數的函數轉換成一系列使用一個參數的函數,而且返回接受餘下的參數並且返回結果的新函數的技術
  • 實際應用
    • 延遲計算:部分求和、bind 函數
    • 動態建立函數:添加監聽 addEvent、惰性函數
    • 參數複用:Function.prototype.call.bind(Object.prototype.toString)
  • 實現 currying 函數:用閉包把傳入參數保存起來,當傳入參數的數量足夠執行函數時,就開始執行函數
  • 函數參數 length:獲取的是形參的個數,可是形參的數量不包括剩餘參數個數,並且僅包括第一個具備默認值以前的參數個數

參考資料

JavaScript 專題之函數柯里化

JavaScript 專題之惰性函數

柯里化在工程中有什麼好處?

文章穿梭機

交流

進階系列文章彙總以下,以爲不錯點個Star,歡迎 加羣 互相學習。

github.com/yygmind/blo…

我是木易楊,公衆號「高級前端進階」做者,跟着我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高級前端的世界,在進階的路上,共勉!

相關文章
相關標籤/搜索