JS中的柯里化

作爲函數式編程語言,JS帶來了很多語言上的有趣特性,比如柯里化和反柯里化。

這裏可以對照另外一篇介紹 JS 反柯里化 的文章一起看~

1. 簡介

柯里化(Currying),又稱部分求值(Partial Evaluation),是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回接受餘下的參數而且返回結果的新函數的技術。

核心思想是把多參數傳入的函數拆成單參數(或部分)函數,內部再返回調用下一個單參數(或部分)函數,依次處理剩餘的參數。

按照Stoyan Stefanov --《JavaScript Pattern》作者 的說法,所謂“柯里化”就是使函數理解並處理部分應用

柯里化有3個常見作用:

  1. 參數複用
  2. 提前返回
  3. 延遲計算/運行

talk is cheap,看看怎麼實現吧~

2. 實現

2.1 通用實現

一個通用實現:

function currying(fn, ...rest1) {
  return function(...rest2) {
    return fn.apply(null, rest1.concat(rest2))
  }
}

注意這裏concat接受非數組元素參數將被當做調用者的一個元素傳入

用它將一個sayHello函數柯里化試試:

function sayHello(name, age, fruit) {
  console.log(console.log(`我叫 ${name},我 ${age} 歲了, 我喜歡吃 ${fruit}`))
}

const curryingShowMsg1 = currying(sayHello, '小明')
curryingShowMsg1(22, '蘋果')            // 我叫 小明,我 22 歲了, 我喜歡吃 蘋果

const curryingShowMsg2 = currying(sayHello, '小衰', 20)
curryingShowMsg2('西瓜')               // 我叫 小衰,我 20 歲了, 我喜歡吃 西瓜

嘻嘻,感覺還行~

2.2 高階柯里化函數

以上柯里化函數已經能解決一般需求了,但是如果要多層的柯里化總不能不斷地進行currying函數的嵌套吧,我們希望經過柯里化之後的函數每次只傳遞一個或者多個參數,那該怎麼做呢:

function curryingHelper(fn, len) {
  const length = len || fn.length  // 第一遍運行length是函數fn一共需要的參數個數,以後是剩餘所需要的參數個數
  return function(...rest) {
    return rest.length >= length    // 檢查是否傳入了fn所需足夠的參數
        ? fn.apply(this, rest)
        : curryingHelper(currying.apply(this, [fn].concat(rest)), length - rest.length)        // 在通用currying函數基礎上
  }
}

function sayHello(name, age, fruit) { console.log(`我叫 ${name},我 ${age} 歲了, 我喜歡吃 ${fruit}`) }    

const betterShowMsg = curryingHelper(sayHello)
betterShowMsg('小衰', 20, '西瓜')      // 我叫 小衰,我 20 歲了, 我喜歡吃 西瓜
betterShowMsg('小豬')(25, '南瓜')      // 我叫 小豬,我 25 歲了, 我喜歡吃 南瓜
betterShowMsg('小明', 22)('倭瓜')      // 我叫 小明,我 22 歲了, 我喜歡吃 倭瓜
betterShowMsg('小拽')(28)('冬瓜')      // 我叫 小拽,我 28 歲了, 我喜歡吃 冬瓜

如此實現一個高階的柯里化函數,使得柯里化一個函數的時候可以不用嵌套的currying,當然是因爲把嵌套的地方放到了curryingHelper裏面進行了...-。-

2.3 瘋狂柯里化函數

儘管柯里化函數已經很牛了,但是它也讓你必須花費點小心思在你所定義函數的參數順序上。在一些函數式編程語言中,會定義一個特殊的“佔位變量”。通常會指定下劃線來幹這事,如果作爲一個函數的參數被傳入,就表明這個是可以“跳過的”,是尚待指定的參數。比如:

var sendAjax = function (url, data, options) { /* ... */ }
var sendPost = function (url, data) {                    // 當然可以這樣
    return sendAjax(url, data, { type: "POST", contentType: "application/json" })
}
// 也可以使用下劃線來指定未確定的參數
var sendPost = sendAjax( _ , _ , { type: "POST", contentType: "application/json" })        

JS不具備這樣的原生支持,可以使用一個全局佔位符變量const _ = { }並且通過===來判斷是否是佔位符,當然你如果使用了lodash的話可以使用別的符號代替。那麼可以這樣改造柯里化函數:

const _ = {}
function crazyCurryingHelper(fn, length, args, holes) {
  length = length || fn.length    // 第一遍是fn所需的參數個數,以後是
  args = args || []
  holes = holes || []
  
  return function(...rest) {
    let _args = args.slice(),
        _holes = holes.slice(),
        argLength = _args.length,        // 存儲接收到的args和holes的長度
        holeLength = _holes.length,
        arg, i = 0
    for (; i < rest.length; i++) {
      arg = rest[i]
      if (arg === _ && holeLength) {
        holeLength--                      // 循環_holes的位置
        _holes.push(_holes.shift())      // _holes最後一個移到第一個
      } else if (arg === _) {
        _holes.push(argLength + i)          // 存儲_hole就是_的位置
      } else if (holeLength) {              // 是否還有沒有填補的hole
        holeLength--
        _args.splice(_holes.shift(), 0, arg)           // 在參數列表指定hole的地方插入當前參數
      } else {
        _args.push(arg)            // 不需要填補hole,直接添加到參數列表裏面
      }
    }
    
    return _args.length >= length                          // 遞歸的進行柯里化
        ? fn.apply(this, _args)
        : crazyCurryingHelper.call(this, fn, length, _args, _holes)
  }
}

function sayHello(name, age, fruit) { console.log(`我叫 ${name},我 ${age} 歲了, 我喜歡吃 ${fruit}`) }

const betterShowMsg = crazyCurryingHelper(sayHello)
betterShowMsg(_, 20)('小衰', _, '西瓜')          // 我叫 小衰,我 20 歲了, 我喜歡吃 西瓜
betterShowMsg(_, _, '南瓜')('小豬')(25)          // 我叫 小豬,我 25 歲了, 我喜歡吃 南瓜
betterShowMsg('小明')(_, 22)(_, _, '倭瓜')          // 我叫 小明,我 22 歲了, 我喜歡吃 倭瓜
betterShowMsg('小拽')(28)('冬瓜')          // 我叫 小拽,我 28 歲了, 我喜歡吃 冬瓜

牛B閃閃

3. 柯里化的常見用法

3.1 參數複用

通過柯里化方法,緩存參數到閉包內部參數,然後在函數內部將緩存的參數與傳入的參數組合後apply/bind/call給函數執行,來實現參數的複用,降低適用範圍,提高適用性。

參看以下栗子,官員無論添加後續老婆,都能和合法老婆組合,通過柯里化方法,getWife方法就無需添加多餘的合法老婆...

var currying = function(fn) {
  var args = [].slice.call(arguments, 1)      // fn 指官員消化老婆的手段,args 指的是那個合法老婆
  return function(...rest) {
    var newArgs = args.concat(...rest)        // 已經有的老婆和新搞定的老婆們合成一體,方便控制
    return fn.apply(null, newArgs)        // 這些老婆們用 fn 這個手段消化利用,完成韋小寶前輩的壯舉並返回
  }
}

var getWife = currying(function() {
  console.log([...arguments].join(';'))          // allwife 就是所有的老婆的,包括暗渡陳倉進來的老婆
}, '合法老婆')

getWife('老婆1', '老婆2', '老婆3')      // 合法老婆;老婆1;老婆2;老婆3
getWife('超越韋小寶的老婆')             // 合法老婆;超越韋小寶的老婆
getWife('超級老婆')                    // 合法老婆;超級老婆

3.2 提高適用性

通用函數解決了兼容性問題,但同時也會再來,使用的不便利性,不同的應用場景往,要傳遞很多參數,以達到解決特定問題的目的。有時候應用中,同一種規則可能會反覆使用,這就可能會造成代碼的重複性。

// 未柯里化前
function square(i) { return i * i; }
function dubble(i) { return i * 2; }
function map(handler, list) { return list.map(handler); }

map(square, [1, 2, 3, 4, 5]);        // 數組的每一項平方
map(square, [6, 7, 8, 9, 10]);
map(dubble, [1, 2, 3, 4, 5]);        // 數組的每一項加倍
map(dubble, [6, 7, 8, 9, 10]);

同一規則重複使用,帶來代碼的重複性,因此可以使用上面的通用柯里化實現改造一下:

// 柯里化後
function square(i) { return i * i; }
function dubble(i) { return i * 2; }
function map(handler, ...list) { return list.map(handler); }

var mapSQ = currying(map, square);
mapSQ([1, 2, 3, 4, 5]);
mapSQ([6, 7, 8, 9, 10]);

var mapDB = currying(map, dubble);
mapDB([1, 2, 3, 4, 5]);
mapDB([6, 7, 8, 9, 10]);

可以看到這裏柯里化方法的使用和偏函數比較類似,順便回顧一下偏函數~

偏函數是創建一個調用另外一個部分(參數或變量已預製的函數)的函數,函數可以根據傳入的參數來生成一個真正執行的函數。比如:

const isType = function(type) {
  return function(obj) {
    return Object.prototype.toString.call(obj) === `[object ${type}]`
  }
}
const isString = isType('String')
const isFunction = isType('Function')

這樣就用偏函數快速創建了一組判斷對象類型的方法~

偏函數固定了函數的某個部分,通過傳入的參數或者方法返回一個新的函數來接受剩餘的參數,數量可能是一個也可能是多個 柯里化是把一個有n個參數的函數變成n個只有1個參數的函數,例如:add = (x, y, z) => x + y + zcurryAdd = x => y => z => x + y + z 當偏函數接受一個參數並且返回了一個只接受一個參數的函數,與兩個接受一個參數的函數curry()()的柯里化函數,這時候兩個概念類似。(個人理解不知道對不對)

3.3 延遲執行

柯里化的另一個應用場景是延遲執行。不斷的柯里化,累積傳入的參數,最後執行。例如累加:

const curryAdd = function(...rest) {
  const _args = rest
  return function cb(...rest) {
    if (rest.length === 0) {
      return _args.reduce((sum, single) => sum += single)
    } else {
      _args.push(...rest)
      return cb
    }
  }
}()                        // 爲了保存添加的數,這裏要返回一個閉包
curryAdd(1)
curryAdd(2)
curryAdd(3)
curryAdd(4)
curryAdd()               // 最後計算輸出:10

更通用的寫法,將處理函數提取出來:

const curry = function(fn) {
  const _args = []
  return function cb(...rest) {
    if (rest.length === 0) {
      return fn.apply(this, _args)
    }
    _args.push(...rest)
    return cb
  }
}

const curryAdd = curry((...T) => 
  T.reduce((sum, single) => sum += single)
)
curryAdd(1)
curryAdd(2)
curryAdd(3)
curryAdd(4)
curryAdd()               // 最後計算輸出:10

4. Function.prototype.bind 方法也是柯里化應用

與 call/apply 方法直接執行不同,bind 方法將第一個參數設置爲函數執行的上下文,其他參數依次傳遞給調用方法(函數的主體本身不執行,可以看成是延遲執行),並動態創建返回一個新的函數, 這符合柯里化特點。

var foo = {x: 888};
var bar = function () {
    console.log(this.x);
}.bind(foo);              // 綁定
bar();                    // 888

下面是一個 bind 函數的模擬,testBind 創建並返回新的函數,在新的函數中將真正要執行業務的函數綁定到實參傳入的上下文,延遲執行了。

Function.prototype.testBind = function(scope) {
  return () => this.apply(scope)
}
var foo = { x: 888 }
var bar = function() {
  console.log(this.x)
}.testBind(foo)              // 綁定
bar()                    // 888

網上的帖子大多深淺不一,甚至有些前後矛盾,在下的文章都是學習過程中的總結,如果發現錯誤,歡迎留言指出~

參考: JS高級程序設計 JS中的柯里化(currying) 前端開發者進階之函數柯里化Currying 淺析 JavaScript 中的 函數 currying 柯里化 掌握JavaScript函數的柯里化 函數式JavaScript(4):函數柯里化

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