FP 之 Currying
不只 OOP 有 Design Pattern,事實上 FP 也有不少 Pattern,而 Currying 算是 FP 最基礎、且用的最多的 Pattern。
一些正統 FP 語言,如 Haskell、Clojure、F#、ReasonML … 都在語言內直接支援 Currying;JavaScript 雖然沒有直接支援,但因為 JavaScript 有 First-class Function 與 Closure,使得 Currying 在 JavaScript 中使用成為可能。
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,稱為 currying
Haskell B. Curry
Haskell B. Curry 是位數學家,為了紀念他,Haskell 語言是使用其 名
,而 Curry 概念則是使用其 姓
。
Simple Currying
NonCurrying.js
1 | const greeting = function (hi, target, name) { |
我們以最簡單的 Hello World 為例,傳統 function 都會有多個 argument,在 greeting()
我們分別有 hi
、target
與 name
3 個 argument。
根據 Currying 的定義,我們可將一個 function 有 3 個 argument,改寫成 3 個 function 各有 1 個 argument 。
CurryingES5.js
1 | const greeting = function (hi) { |
第 1 行
1 | const greeting = function (hi) { |
由於 Currying 要求每個 function 都只能有 1 個 argument,因此我們必須 return
兩次 function,直到最後一個 return
才會真正回傳值。
為什麼最內層的 function (name)
可以抓到 hi
與 target
呢 ? 拜 JavaScript 的 Closure 之賜:內層 function 可以直接 reference 到 funtion 之外的變數,而不必靠 parameter 傳入
,因此 function (name)
可直接使用 hi
與 target
。
第 9 行
1 | const words = 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')
,我們將原本 1 個 function 有 3 個 argument,變成 3 個 function 各有 1 個 argument。
CurryingES6.js
1 | const greeting = hi => target => name => |
拜 ECMAScript 2015 之賜,我們有了 Arrow Function,就不必再使用 巢狀 function
的寫法,程式碼更簡潔,可讀性也變高,這也使得 Currying 的實用性更高。
在此亂入一下 F# 的 Currying,與 JavaScript 的 Currying 比較:
CurryingFSharp.fs
1 | let greeting hi target name = |
JavaScript 的 const
相當於 F# 的 let
。
JavaScript 的 argument 寫在 =
之後,每個參數以 =>
隔開;而 F# 只要在 function 名稱之後以 space 隔開即可。
JavaScript 的 parameter 須以 ()
一一傳入;而 F# 只要在 function 名稱之後以 space 隔開即可。
ECMAScript 2015 有了 Arrow Function 之後,可讀性與簡潔性已經與正統 FP 的 F# 差距不遠。
Q:將傳統 function 改寫成 Currying 不難,但為什麼要這樣寫呢 ?
的確,要改寫成 Currying 並不難,尤其在 ECMAScript 2015 之後,Arrow Function 使得 Currying 寫法非常精簡,也沒有必要再因為 巢狀 function
可讀性不高而排斥 Currying。
但回到一個更基本的問題,為什麼要使用 Currying 這種設計模式呢 ? 請耐心看下去,我將一一說明。
Why Currying ?
Reuse Small Function
拆成眾多的小 function,以利後續 code reuse
1 | const greeting = function (hi, target, name) { |
若一次得傳入 3 個 parameter,我們只有一個 greeting()
function 可用。
1 | const greeting = 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()
就能被重複使用。
ReuseSmallFunction.js
1 | const greeting = hi => target => name => |
第 4 行
1 | const helloWorld = greeting('Hello')('World'); |
藉由 greeting('Hello')('World')
輕鬆建立新的 helloWorld()
,將來只接受 1 個 argument。
Currying 過的 greeting()
,因為顆粒變小,因此能被 reuse 的機會就更高了。
回想小時候玩樂高積木,哪一種積木最好用 ?
就是顆粒最小的積木最好用,可以說是百搭。Currying 就是把 function 都切成顆粒最小的單一 argument function,因此可藉由 argument 的組合,由一個 function 不斷地組合出新的 function
Higher Order Function
可以傳入 function 或傳回 function 的 function,通常會將
重複部分
抽成 higher order function,將不同部分
以 arrow function 傳入
要支援 Higher Order Function 有個前提,語言必須支援 First-Class Function,這在 JavaScript 很早就支援,所以沒有問題。
BeforeRefactoring.js
1 | const prices = [10, 20, 30]; |
第 3 行
1 | const calculatePrice1 = prices => { |
與
10 行
1 | const calculatePrice2 = prices => { |
非常類似,最少已經看到以下這部分重複:
1 | const sum = prices => |
所以想將這部分抽成 Higher Order Function。
HigherOrderFunction.js
1 | const prices = [10, 20, 30]; |
第 3 行
1 | const sum = prices => |
將 sum()
先抽成 function。
第 6 行
1 | const calculate = prices => action => |
將共用部分抽成 calculate()
higher order function,argument 除了原本的 prices
外,還多了 action
,其中 action
正是 不同部分
。
將 sum(prices)
運算結果傳給 action()
。
第 9 行
1 | const calculatePrice = calculate(prices); |
由於 calculate()
已經 currying 過,因此 calculate(prices)
回傳為 funciton。
第 10 行
1 | console.log(calculatePrice(sum => sum - 10)); |
將 不同部分
分別以 sum => sum -10
與 sum => sum * 0.9
帶入 calculate()
higher order function,正式計算其值。
若我們不將
calculate()
currying 過,則無法傳回 function,只能回傳值,如此就無法將不同部分
以 arrow function 傳入
Function Composition
將小 function 組合成功能強大的新 function
ComposeFailed.js
1 | const prices = [10, 20, 30]; |
第 3 行
1 | const discount = (rate, prices) => |
宣告 discount()
,使用傳統 2 個 argument 的寫法。
第 6 行
1 | const sum = prices => |
宣告 sum()
,使用 reduce()
計算 array 的總和。
第 9 行
1 | const compose = (...fns) => |
自己寫一個 compose()
,目的將所有 function 組合成一個新的 function。
實務上可以使用 Ramda.js 的
R.compose()
將 function 組合
12 行
1 | const action = compose(sum, discount(0.8)); |
這裡會出問題,因為 discount()
尚未 currying,必須一次提供 2 個 argument,無法單獨只提供 0.8
一個 argument。
在純 FP 語言如 Haskell、F# 會自動 currying,所以不是問題,但 JavaScript 必須手動 currying,或者使用 Ramda.js 的
R.curry()
將原本的 function 加以 currying
CurryingCompose.js
1 | const prices = [10, 20, 30]; |
第 3 行
1 | const discount = rate => prices => |
將 discount()
改成 currying 寫法後,就可以使用 compose()
將 sum()
與 discount()
組合成一個新的 action()
。
為了使用 Function Composition,我們會將多個 argument 的 function,currying 成眾多單一 argument 的 function,然後再加以組合
Conclusion
- JavaScript 不像其他 FP 語言支援自動 currying,但所幸 JavaScript 支援 First-Class Function 與 Closure,因此仍然可以手動將 function 加以 currying,或者使用 Ramda.js 的
R.curry()
- Currying 會將 function 的顆粒拆成更小,更有利於 reuse 與 compose,亦可透過 currying 回傳 Higher Order Function,避免程式碼重複
Sample Code
完整的範例可以在我的 GitHub 上找到
Reference
歐陽繼超,前端函數式攻城指南
Martin Novak, JavaScript ES6 curry functions with practical examples
Adam Beme, Currying in JavaScript ES6
techsith, JavaScript Currying function (method) explained Tutorial