JavaScript之柯里化

簡介

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

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

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

在JavaScript中實現Currying

爲了實現只傳遞給函數一部分參數來調用它,讓它返回一個函數去處理剩餘參數的這句話所描述的特徵。我們先實現一個加法函數add:

function add(x, y) {
  return x + y
}

我們現在實現一個被Curryingadd函數,命名該函數爲curriedAdd,則根據上面的定義,curriedAdd需要滿足以下條件:

curriedAdd(1)(3) === 4 // true

var increment = curriedAdd(1)
increment(2) === 3 // true

var addTen = curriedAdd(10)
addTen(2) === 12 // true

滿足以上條件的curriedAdd函數可以用以下代碼實現:

function curriedAdd(x) {
  return function(y) {
    return x + y
  }
}

當然以上實現有一些問題: 它不通用,並且我們並不想通過修改函數被人的方式來實現Currying化。

但是curriedAdd的實現表明了實現Currying的一個基礎--Currying延遲求值的特性需要我們用到JavaScript中的作用域,說得更通俗一些,我們需要使用作用域(即閉包)來保存上一次傳進來的參數。

curriedAdd進行抽象,可以得到如下函數currying:

function currying (fn, ...args1) {
  return function (...args2) {
    return fn(...arg1, ...arg2)
  }
}

var increment = currying(add, 1)
increment(2) === 3 // true

var addTen = currying(add, 10)
addTen(2) === 12 // true

在此實現中,currying函數的返回值其實是一個接受剩餘參數並且立即返回計算值的函數。即它的返回值並沒有自動被Currying。所以我們可以通過遞歸將currying返回的函數也自動Currying

function currying(fn, ...args) {
  if (args.length >= fn.length) {
    return fn(...args)
  }
  
  return function (...args2) {
    return currying(fn, ...args, ...args2)
  }
}

以上函數很簡短,但是已經實現Currying的核心思想。JavaScript中常用庫Lodash中的curry方法,其核心思想和以上並沒有太大差異--比較多次接收的參數總數與函數定義時的形參數量,當接收的參數的數量大於或者等於被Currying函數的形參數量時,就返回運行結果,否則返回一個繼續接受參數的函數

Currying應用場景

參數複用

固定不變的參數,實現參數複用是Currying的主要用途之一。

案例一

上文中的incrementaddTen的一個參數複用的實例。對add方法固定第一個參數爲10後,該方法就變成了一個將接受累加10的方法。

案例二

判斷對象的類型。例如下面這個例子:

function isArray (obj) {
  return Object.prototype.toString.call(obk) === '[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的一個重要使用場景,同樣bind箭頭函數也能實現同樣的功能。
在前端開發中,一個常見的場景就是爲標籤綁定onClick,同時考慮爲綁定的方法傳遞參數。
以下列出了幾種常見的方法,來比較優劣:

通過 data 屬性

<div data-name="name" onClick={handleOnClick} />

通過data屬性本質只能傳遞字符串的數據,如果需要傳遞複雜對象,只能通過 JSON.stringify(data)來傳遞滿足JSON對象格式的數據,但對更加複雜的對象無法支持。(雖然大多數時候也無需傳遞複雜對象)

通過bind方法

<div onClick={handleOnClick.bind(null, data)} />

bind方法和以上實現的currying 方法,在功能上有極大的相似,在實現上也幾乎差不多。可能唯一的不同就是bind方法需要強制綁定context,也就是bind的第一個參數會作爲原函數運行時的this指向。而currying不需要此參數。所以使用currying或者bind只是一個取捨問題。

箭頭函數

<div onClick={() => handleOnClick(data))} />

箭頭函數能夠實現延遲執行,同時也不像bind方法必需指定context

通過currying

<div onClick={currying(handleOnClick, data)} />

性能對比

性能對比
通過jsPerf測試四種方式的性能,結果爲:箭頭函數 > bind > currying > trueCurrying
currying函數相比bind函數,其原理相似,但是性能相差巨大,其原因是bind由瀏覽器實現,運行效率有加成。

爲什麼不需要 Currying

1. Currying 的一些特性有其他解決方案

如果我們只是想提前綁定參數,那麼我們有很多好幾個現成的選擇,bind箭頭函數等,而且性能比Curring更好。

2. Currying 陷於函數式編程

Currying是函數式編程的產物,它生於函數式編程,也服務於函數式編程。

JavaScript並非真正的函數式編程語言,相比Haskell等函數式編程語言,JavaScript 使用Currying等函數式特性有額外的性能開銷,也缺乏類型推導。

從而把JavaScript代碼寫得符合函數式編程思想和規範的項目都較少,從而也限制了 Currying等技術在JavaScript代碼中的普遍使用。

結論

  1. CurryingJavaScript中是低性能的,但是這些性能在絕大多數場景,是可以忽略的。
  2. Currying的思想極大地助於提升函數的複用性。
  3. Currying 生於函數式編程,也陷於函數式編程。假如沒有準備好寫純正的函數式代碼,那麼Currying有更好的替代品。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章