點燈坊

失くすものさえない今が強くなるチャンスよ

深入探討 Currying

Sam Xiao's Avatar 2021-01-23

不只 OOP 有 Design Pattern,事實上 FP 也有不少 Pattern,而 Currying 算 FP 最基礎、使用最多的 Pattern。ECMAScript 雖然沒有直接支援,但因為有 First-class Function 、Lexical Scope,使得 Currying 在 ECMAScript 中使用成為可能。

Version

ECMAScript 2015

Definition

Currying
There is a way to reduce functions of more than one argument to functions of one argument, a way called currying

將一個多 argument 的 function 改寫成多個只有一個 argument 的 function

Haskell B. Curry

Haskell B. Curry 是位數學家,為了紀念他,Haskell 語言是使用其 ,而 Curry 概念則是使用其

Simple Currying

let greeting = function(hi, target, name) {
  return `${hi} ${target} ${name}`
}

greeting('Hello', 'World', 'Sam') // ?

我們以最簡單的 Hello World 為例,傳統 function 都會有多個 argument,在 greeting() 我們分別有 hitargetname 3 個 argument。

根據 currying 定義,我們可將一個 3 個 argument 的 function,改寫成只有1個 argument 的 function。

currying000

let greeting = function(hi) {
  return function(target) {
    return function(name) {
      return `${hi} ${target} ${name}`
    }
  }
}

greeting('Hello')('World')('Sam') // ?

由於 currying 要求每個 function 都只能有 1 個 argument,因此我們必須 return 兩次 function,直到最後一個 return 才會真正回傳值。

為什麼最內層的 function(name) 可以抓到 hitarget 呢 ? 拜 ECMAScript 的 lexical scope 之賜:

Lexical Scope
內層 function 可以直接存取到外層 funtion 之變數,而不必靠 argument 傳入

因此 function(name) 可直接使用 hitarget

第 9 行

greeting('Hello')('World')('Sam'); // ?

因此 greeting('Hello') 回傳只有 1 個 argument 的 function,可再傳入 World

greeting('Hello')('World') 亦回傳只有 1 個 argument 的 function,可再傳入 Sam

所以 greeting('Hello')('World')('Sam') 其實相當於 greeting('Hello', 'World', 'Sam'),我們將原本 3 個 argument 的 function,變成各有 1 個 argument 的 3 個 function。

currying001

let greeting = hi => target => name => `${hi} ${target} ${name}`

greeting('Hello')('World')('Sam') // ?

拜 ES6 之賜,我們有了 arrow function,就不必再使用 巢狀 function 寫法,程式碼更簡潔,可讀性也變高,這也使得 currying 實用性大大提升。

currying002

Q:將傳統 function 改寫成 currying 不難,但為什麼要這樣寫呢 ?

的確,要改寫成 currying 並不難,尤其在 ES6 之後,arrow function 使得 currying 寫法非常精簡,也沒有必要再因為 巢狀 function 可讀性不高而排斥。

但回到一個更基本的問題,為什麼要使用 currying 這種 pattern 呢 ?

Reuse Function

拆成眾多的小 function,以利後續 code reuse。

let greeting = function(hi, target, name) {
  return `${hi} ${target} ${name}`
}

若一次得傳入 3 個 argument,我們只有一個 greeting() function 可用。

let greeting = hi => target => name => `${hi} ${target} ${name}`

若改用 currying 寫法,我們總共有 3 個 function 可用:

  • greeting()
  • greeting()()
  • greeting()()()

在原本 greeting(),我們若要 reuse,一次就得提供 3 個 argument,否則就無法重複使用。

但 currying 過的 greeting(),變成了 3 個 function,我們可以依實際需求取用 greeting(),儘管只有 1 個 parameter,也一樣能夠使用 greeting()

假設我們有個 function,只有 name 為 argument,回傳為 Hello World SamHello World Kevin,原本 3 個 argument 的 greeting() 就無法被重複使用,但 currying 過的 greeting() 就能被重複使用。

Ramda 提供的 function 都使用 currying 形式,使得其 function 通常有兩種以上用法,一種是求值用,另一種則時產生 callback 用

let greeting = hi => target => name => `${hi} ${target} ${name}`

let helloWorld = greeting('Hello')('World')

helloWorld('Sam') // ?

第 3 行

let helloWorld = greeting('Hello')('World');

藉由 greeting('Hello')('World') 輕鬆建立新的 helloWorld() ,將來只接受 1 個 argument。

Currying 過的 greeting(),因為顆粒變小,因此能被 reuse 機會更高了。

currying003

回想小時候玩樂高積木,哪一種積木最好用 ?

就是顆粒最小的積木最好用,可以說是百搭。

Currying 就是把 function 都切成顆粒最小的單一 argument function,因此可藉由 argument 的組合,由一個 function 不斷地組合出新 function。

Higher Order Function

Higher Order Function
可以傳入 function 或傳回 function 的 function,通常會將 重複部分 抽成 higher order function,將 不同部分 以 arrow function 傳入,最後回傳新 function

要支援 higher order function 有個前提:語言本身必須支援 first-class function,這在 ECMAScript 很早就支援。

let data = [10, 20, 30]

let price1 = a => {
  let sum = a  => a.reduce((a, x) => a + x)

  return sum(a) - 10
}

let price2 = a => {
  let sum = a  => a.reduce((a, x) => a + x)

  return sum(a) * 0.9
}

price1(data) // ?
price2(data) // ?

第 3 行

let price1 = a => {
  let sum = a  => a.reduce((a, x) => a + x)

  return sum(a) - 10
}

第 9 行

let price2 = a => {
  let sum = a  => a.reduce((a, x) => a + x)

  return sum(a) * 0.9
}

非常類似,最少已經看到以下這部分重複:

let sum = a  => a.reduce((a, x) => a + x)

return sum(a) - 10

所以想將這部分抽成 higher order function。

currying004

let data = [10, 20, 30]

let sum = a => a.reduce((a, x) => a + x)

let price = f => a => f(sum(a))

price(x => x - 10)(data) // ?
price(x => x * 0.9)(data) // ?

第 3 行

let sum = a => a.reduce((a, x) => a + x)

sum() 先抽成 function。

第 5 行

let price = f => a => f(sum(a))

將共用部分抽成 price() higher order function,argument 除了原本的 a 外,還多了 f,其中 f 正是 不同部分

sum(a) 運算結果傳給 f() 執行。

第 7 行

price(x => x - 10)(data) // ?
price(x => x * 0.9)(data) // ?

不同部分 分別以 x => x -10x => x * 0.9 帶入 price() higher order function,正式計算其值。

currying005

若我們不將 price() currying 過,則無法傳回 function,只能回傳值,如此就無法將 不同部分 以 arrow function 傳入。

Function Pipeline

let data = [10, 20, 30]

let discount = (rate, a) => a.map(x => x * rate)

let sum = a => a.reduce((a, x) => a + x)

let pipe = (...f) => init => f.reduce((g, f) => f(g), init)

pipe(
  discount(0.8), 
  sum
)(data) // ?

第 3 行

let discount = (rate, a) => a.map(x => x * rate)

宣告 discount() ,使用傳統 2 個 argument 寫法。

第 5 行

let sum = a => a.reduce((a, x) => a + x)

宣告 sum(),使用 reduce() 計算 array 的總和。

第 7 行

let pipe = (...f) => init => f.reduce((g, f) => f(g), init)

自己寫一個 pipe() ,目的將所有 function 透過 reduce() 組合成一個新的 function。

實務上會使用 Ramda 的 pipe()

第 9 行

pipe(
  discount(0.8), 
  sum
)(data) // ?

這裡會出問題,因為 discount() 尚未 currying,必須一次提供 2 個 argument,無法單獨只提供 0.8 一個 argument。

在純 FP 語言如 Haskell、F#、ReasonML 會自動 currying,所以不是問題,但 ECMAScript 必須手動 currying,或者使用 Ramda 的 curry() 將原本 function 加以 currying

currying008

let data = [10, 20, 30]

let discount = rate => a => a.map(x => x * rate)

let sum = a => a.reduce((a, x) => a + x)

let pipe = (...f) => init => f.reduce((g, f) => f(g), init)

pipe(
  discount(0.8), 
  sum
)(data) // ?

第 3 行

let discount = rate => a => a.map(x => x * rate)

discount() 改成 currying 寫法後,就可以使用 pipe()sum()discount() 組合成一個新 function。

currying007

為了使用 function composition,我們會將多個 argument 的 function,currying 成眾多單一 argument 的 function,然後再加以組合。

Conclusion

  • ECMAScript 不像其他 FP 語言支援自動 currying,但所幸支援 first-class function 與 lexical scope,因此仍然可以手動將 function 加以 currying,或者透過 Ramda 的 curry()
  • Currying 會將 function 顆粒拆成更小,更有利於 reuse 與 compose,亦可透過 currying 回傳 higher order function,避免程式碼重複

Reference

歐陽繼超,前端函數式攻城指南
Martin Novak, JavaScript ES6 curry functions with practical examples
Adam Beme, Currying in JavaScript ES6
techsith, JavaScript Currying function (method) explained Tutorial