js 高階函數之柯里化

博客地址:https://ainyi.com/74

定義

在計算機科學中,柯里化(Currying)是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回接受餘下的參數且返回結果的新函數的技術

就是隻傳遞給函數某一部分參數來調用,返回一個新函數去處理剩下的參數(==閉包==)

常用的封裝成 add 函數

// reduce 方法
const add = (...args) => args.reduce((a, b) => a + b)

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

// 假設有一個 currying 函數
let sum = currying(params)
sum(1)(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

上面的代碼理解:先定義 add 函數,然後 currying 函數就是用==閉包==把傳入參數保存起來,當傳入參數的數量足夠執行函數時,就開始執行函數

上面的 currying 函數是一種簡化寫法,判斷傳入的參數長度是否爲 0,若爲 0 執行函數,否則收集參數到 args 數組

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

let obj = {
  name: 'Krry'
}
const fun = function () {
  console.log(this.name)
}.bind(obj)

fun() // Krry

這裏 bind 用來改變函數執行時候的上下文==this==,但是函數本身並不執行,所以本質上是延遲計算,這一點和 call / apply 直接執行有所不同

動態創建函數

有一種典型的應用情景是這樣的,每次調用函數都需要進行一次判斷,但其實第一次判斷計算之後,後續調用並不需要再次判斷,這種情況下就非常適合使用柯里化方案來處理

即第一次判斷之後,動態創建一個新函數用於處理後續傳入的參數,並返回這個新函數。當然也可以使用惰性函數來處理,本例最後一個方案會介紹

我們看下面的這個例子,在 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
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 函數

function currying(fn, length) {
  // 第一次調用獲取函數 fn 參數的長度,後續調用獲取 fn 剩餘參數的長度
  length = length || fn.length
  return function (...args) { // 返回一個新函數,接收參數爲 ...args
    // 新函數接收的參數長度是否大於等於 fn 剩餘參數需要接收的長度
    return args.length >= length
        ? fn.apply(this, args) // 滿足要求,執行 fn 函數,傳入新函數的參數
      : currying(fn.bind(this, ...args), length - args.length)
      // 不滿足要求,遞歸 currying 函數
      // 新的 fn 爲 bind 返回的新函數,新的 length 爲 fn 剩餘參數的長度
  }
}

// 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"]

上面使用的是 ES5 和 ES6 的混合語法

那如果不想使用 call/apply/bind 這些方法呢,自然是可以的,看下面的 ES6 極簡寫法,更加簡潔也更加易懂

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"]

如果還很難理解,看下面例子

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) 
  }
}

const add = currying(function(a, b, c) {
    console.log([a, b, c].reduce((a, b) => a + b))
})

add(1, 2, 3) // 6
add(1, 2)(3) // 6
add(1)(2)(3) // 6
add(1)(2, 3) // 6

擴展:函數參數 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) 將會報錯

小結&鏈接

定義:柯里化是一種將使用多個參數的函數轉換成一系列使用一個參數的函數,並且返回接受餘下的參數而且返回結果的新函數的技術

實際應用

  1. 延遲計算:部分求和、bind 函數
  2. 動態創建函數:添加監聽 addEvent、惰性函數
  3. 參數複用:Function.prototype.call.bind(Object.prototype.toString)

實現 Currying 函數:用閉包把傳入參數保存起來,當傳入參數的數量足夠執行函數時,就開始執行函數

函數參數 length:獲取的是形參的個數,但是形參的數量不包括剩餘參數個數,而且僅包括==第一個參數有默認值之前的參數個數==

參考文章:JavaScript專題之函數柯里化

博客地址:https://ainyi.com/74

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章