Currying 是 FP 最常用的設計模式

不只 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
2
3
4
5
6
7
8
const greeting = function (hi, target, name) {
return hi + ' ' + target + ' ' + name;
};

const words = greeting('Hello', 'World', 'Sam');
console.log(words);

// Hello World Sam

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

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

CurryingES5.js

1
2
3
4
5
6
7
8
9
10
11
12
const greeting = function (hi) {
return function (target) {
return function (name) {
return hi + ' ' + target + ' ' + name;
}
}
};

const words = greeting('Hello')('World')('Sam');
console.log(words);

// Hello World Sam

第 1 行

1
2
3
4
5
6
7
const greeting = function (hi) {
return function (target) {
return function (name) {
return hi + ' ' + target + ' ' + name;
}
}
};

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

為什麼最內層的 function (name) 可以抓到 hitarget 呢 ? 拜 JavaScript 的 Closure 之賜:內層 function 可以直接 reference 到 funtion 之外的變數,而不必靠 parameter 傳入,因此 function (name) 可直接使用 hitarget

第 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
2
3
4
5
6
7
const greeting = hi => target => name => 
hi + ' ' + target + ' ' + name;

const words = greeting('Hello')('World')('Sam');
console.log(words);

// Hello World Sam

拜 ECMAScript 2015 之賜,我們有了 Arrow Function,就不必再使用 巢狀 function 的寫法,程式碼更簡潔,可讀性也變高,這也使得 Currying 的實用性更高。

在此亂入一下 F# 的 Currying,與 JavaScript 的 Currying 比較:

CurryingFSharp.fs

1
2
3
4
5
6
7
let greeting hi target name =
hi + " " + target + " " + name

let words = greeting "Hello" "World" "Sam"
printfn "%s" words

// Hello World Sam

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
2
3
const greeting = function (hi, target, name) {
return hi + ' ' + target + ' ' + name;
};

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

1
2
const 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() 就能被重複使用。

ReuseSmallFunction.js

1
2
3
4
5
6
const greeting = hi => target => name =>
hi + ' ' + target + ' ' + name;

const helloWorld = greeting('Hello')('World');
const words = helloWorld('Sam');
console.log(words);

第 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const prices = [10, 20, 30];

const calculatePrice1 = prices => {
const sum = prices =>
prices.reduce((acc, elm) => acc + elm);

return sum(prices) - 10;
};

const calculatePrice2 = prices => {
const sum = prices =>
prices.reduce((acc, elm) => acc + elm);

return sum(prices) * 0.9
};

console.log(calculatePrice1(prices));
console.log(calculatePrice2(prices));

// 50
// 54

第 3 行

1
2
3
4
5
6
const calculatePrice1 = prices => {
const sum = prices =>
prices.reduce((acc, elm) => acc + elm);

return sum(prices) - 10;
};

10 行

1
2
3
4
5
6
const calculatePrice2 = prices => {
const sum = prices =>
prices.reduce((acc, elm) => acc + elm);

return sum(prices) * 0.9
};

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

1
2
3
4
const sum = prices  =>
prices.reduce((acc, elm) => acc + elm);

return sum(prices)

所以想將這部分抽成 Higher Order Function。

HigherOrderFunction.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const prices = [10, 20, 30];

const sum = prices =>
prices.reduce((acc, elm) => acc + elm);

const calculate = prices => action =>
action(sum(prices));

const calculatePrice = calculate(prices);
console.log(calculatePrice(sum => sum - 10));
console.log(calculatePrice(sum => sum * 0.9));

// 50
// 54

第 3 行

1
2
const sum = prices =>
prices.reduce((acc, elm) => acc + elm);

sum() 先抽成 function。

第 6 行

1
2
const calculate = prices => action =>
action(sum(prices));

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

sum(prices) 運算結果傳給 action()

第 9 行

1
const calculatePrice = calculate(prices);

由於 calculate() 已經 currying 過,因此 calculate(prices) 回傳為 funciton。

第 10 行

1
2
console.log(calculatePrice(sum => sum - 10));
console.log(calculatePrice(sum => sum * 0.9));

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

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

Function Composition

將小 function 組合成功能強大的新 function

ComposeFailed.js

1
2
3
4
5
6
7
8
9
10
11
12
13
const prices = [10, 20, 30];

const discount = (rate, prices) =>
prices.map(elm => elm * rate);

const sum = prices =>
prices.reduce((acc, elm) => acc + elm);

const compose = (...fns) =>
fns.reduce((f, g) => (...args) => f(g(...args)));

const action = compose(sum, discount(0.8));
console.log(action(prices));

第 3 行

1
2
const discount = (rate, prices) =>
prices.map(elm => elm * rate);

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

第 6 行

1
2
const sum = prices =>
prices.reduce((acc, elm) => acc + elm);

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

第 9 行

1
2
const compose = (...fns) =>
fns.reduce((f, g) => (...args) => f(g(...args)));

自己寫一個 compose() ,目的將所有 function 組合成一個新的 function。

實務上可以使用 Ramda.js 的 R.compose() 將 function 組合

12 行

1
2
const action = compose(sum, discount(0.8));
console.log(action(prices));

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

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

CurryingCompose.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const prices = [10, 20, 30];

const discount = rate => prices =>
prices.map(elm => elm * rate);

const sum = prices =>
prices.reduce((acc, elm) => acc + elm);

const compose = (...fns) =>
fns.reduce((f, g) => (...args) => f(g(...args)));

const action = compose(sum, discount(0.8));
console.log(action(prices));

// 48

第 3 行

1
2
const discount = rate => prices =>
prices.map(elm => elm * rate);

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

2018-05-13