不只 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()
我們分別有 hi
、target
與 name
3 個 argument。
根據 currying 定義,我們可將一個 3 個 argument 的 function,改寫成只有1個 argument 的 function。
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)
可以抓到 hi
與 target
呢 ? 拜 ECMAScript 的 lexical scope 之賜:
Lexical Scope
內層 function 可以直接存取到外層 funtion 之變數,而不必靠 argument 傳入
因此 function(name)
可直接使用 hi
與 target
。
第 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。
let greeting = hi => target => name => `${hi} ${target} ${name}`
greeting('Hello')('World')('Sam') // ?
拜 ES6 之賜,我們有了 arrow function,就不必再使用 巢狀 function
寫法,程式碼更簡潔,可讀性也變高,這也使得 currying 實用性大大提升。
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 Sam
或 Hello 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 機會更高了。
回想小時候玩樂高積木,哪一種積木最好用 ?
就是顆粒最小的積木最好用,可以說是百搭。
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。
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 -10
與 x => x * 0.9
帶入 price()
higher order function,正式計算其值。
若我們不將 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
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。
為了使用 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