[Javascript] Functional Programming 一文到底全紀錄
Coding as function. Thinking as function
最近吹起了一股 Functional Programming (FP) 的風潮,什麼語言都想與 FP 嘎上一腳。不過,到底什麼是 FP 呢?以下這篇文章是我最近修習 Functional Programming 後的一點心得與筆記。
# 緣起
就如 OOP (object oriented programming) 一般,Functional Programming 也是一種 Programming 的準則。
不同於 OOP 要求我們 「Think as real world, Think as Object.」凡事以物件的角度思考、在程式中建立如同「真實世界」一般的互動;FP 則要求我們 「Everything think as function.」 以 Function 為中心的思考方式、以 Function 為最小單位的程式風格。
聽起來好像就是回到以前寫 C 語言的年代 。這對於每天寫多少程式、建構了多少 Function 的我們來說,有什麼難的?
其實,FP 中的 Function 跟我們一般程式所講的 Function 有些定義上的不同。它比較接近數學意義上的 function。
什麼意思?
以下我們先來講述一下的完整定義。
# 定義
目前大家公認的 FP 定義至少要滿足以下幾點:
1. Function 必須作為一級公民。意即,Function 可以像一般變數一般被當作參數傳入、被當作結果輸出、被任意 assign 給其他變數、被任意進行運算。
2. Function 中只能有 Expression 而非指令( instructions )。
3. Function 必須是 「Pure」、沒有 Side Effect。
4. Function 「不可改變 Input 的資料」、「不可 改變狀態」。
5. Function 「可以任意『組合』得到新的 Function,且依然滿足以上這些守則」。
一、Function 必須作為一級公民
在 JS 中,原本就將 Function 作為一級公民對待。Function 如同一般變數可以被任意傳入任意參數、被作為回傳值回傳。
例如:以下範例
將 function 儲存在變數中
將 function 作為結果回傳
二、Function 中只能有 Expression 而非指令( instructions )。
在 FP 中, Function 只允許純運算 。所有「被執行的指令」是不被允許的。然而對於真正在運行的專案,這其實並不容易被實踐 ( 例如呼叫 API 的部分 — I/O ) ,儘管是已經將 Function 作為一級公民 的 JavaScript 而言也是一樣。
舉個 Function 中只有純運算的
例子:
var add = (a, b) => a + b
這個例子就是很典型的,在 function 只有運算式 「a + b」。
以下例子則非 Function 中只有純運算
這邊我們使用 reduce
來將 ary
中的元素集成起來,並且輸出為一個 object。
在這個例子中 reduce
內的 callback function
,就不是一個「只有純運算」的 function,因為其中有了 acc[ele.id] = ele
這段執行 assign 指令的 statement。
以上的程式碼我們若是要改成符合此規則的話,可以改寫成以下範例。
三、Function 必須是 Pure Function、沒有 Side Effect
什麼是 Pure Function? Side Effect 又是什麼?
就是這個定義,使我認為 FP 對於 function 的定義更接近數學上的 function 。
在數學中,我們講述
y = f(x)
就是一個 x 與 y 的對應關係。x 經由 function f 之後產出 y、 x 與 y 一一對應,不論多少次、外在如何改變,只要 input 是 x,output 就一定是 y。
一個 Pure function 規範其中的 output Y 只與 input X 有關,不受外在的其他貓貓狗狗的參數或是雜七雜八的狀態影響。
就如同數學上 Domain 對應到 Co-domain
的過程。
這樣說太抽象了,我們來看看點例子:
我們假設有一個做「加減乘除工廠函數 (factory function) 」,因為某些原因,產生新的 function 時需要依靠一個 global 變數 outerVal
才能決定應該要產生哪個 function。那麼,這就不是一個Pure function。
以下方的 Code為例
當 outerVal = 0
時,一切都如註解那般正確執行。
然而,當 outerVal
不為零時,這個 factory 就不再回傳原本的結果了。
這就是所謂的 Side Effect。
FP 要求 function 「無論何時何地,Output 都只與 Input 有關係」。
因此在這邊,此 Function 的「正確作法」是我們應該將 outerVal
也作為參數傳入,以顯式地表達此 Function 與 outerVal
也有關係 (FP 有一種叫做 Currying 的方法可以解決此問題,我們之後會講解)。
四、Function 「不可改變 Input 的資料」、「不可 改變狀態」
這個很好理解,簡單說就是 Function 不應該修改 input 的資料。
以 Javascript 原生的兩個 Method Array.splice
與 Array.slice
為例:
Array.splice 由於每 call 一次,便會修改原始 Array,因此並不符合 FP 的準則;而 Array.slice 無論 call 多少次,原始 Array 都不會被更動,因此符合 FP 的準則。
五、Function 可以任意組合得到新的 Function,且依然滿足以上這些守則
這邊我們要回到數學中的「組合」(Compose)。
假設有兩個 function f(x)
與 g(x)
。我們可以任意將此兩個 function 組合起來成為一個新的 function f’(x)
,記做 f’ = f。g
。執行結果等效於 f(g(x))
。
以程式上來說,可能會長成這樣
var compose = function(f, g) {
return function(x) {
return f(g(x));
};
};
其中,兩個 function 都以 x
作為 input。通過 compose 產生的新 function f’
其執行結果等效於,將執行 function g
的結果作為 input 傳給 function f
。
我們舉個例:
假設有兩個 function 「Add2(x)」與「Multiply3(x)」
我們可以使用 compose 將兩個 function 組合成一個新的 function 「Add2ThenMultiply3」
如此,我們可以很輕鬆地利用 compose 產生新的 function,並且此 function 依然滿足以上所有 FP 準則。
# Functional Programming 中常用的工具與技巧
花了大半個篇幅講解了何謂 Functional Programming,之後我想介紹一下 FP 常使用到的一些工具與技巧。
Map
在 Javascript 中,我們有 Array.prototype.map() 可以幫助我們將 Array 中的 element 依照 callback function 的規則一一對應到新的 Array,這有助於幫助我們輕鬆地將元素統一地「擴展」、「轉換」。
以下為一個簡單的例子:
將 Array 中的每個元素統一地 乘60 再加5,之後輸出為 ASCII Code
此作法會得到一個新的 Array [“A”, “}”, “¹”, “õ”, “ı”, “ŭ”, “Ʃ”, “ǥ”, “ȡ”]
並且,原始 Array ary
的內容並沒有因此被更動。
Filter
Array.prototype.filter() 是 Javascript 中原生支援的方法。與 Map 不同的地方是,它會依照 callback function 的規則篩選符合條件的 element 並且蒐集成為一個新的 Array。這很有用,我們可以利用 filter 很輕鬆的將符合條件的 Element 篩選出來,並且不影響到原始的 Array。
舉個簡單的例子
從 Array 中篩選出不是英文字母的 Element 作為 Output
Reduce
Array.prototype.reduce() 一樣是 Javascript 中原生支援的方法。這個方法與前兩個有著極大的區別。Reduce 接受兩個參數: 「callback function」 與 「initial value」。
Callback function 也會接收兩個參數(*註1)
:「previous result」與「current element」。Callback function 會將前一輪處理的結果 (如果此輪是第一輪,則拿 Initial Value 作為此參數的值) 與目前的 element 處理完後,將結果作為下一輪 callback function,如此重複至輪巡完整個 Array。
這個 function 非常有用,當我們希望將 Array 經過一套邏輯收斂成一個值時,就可以使用 Reduce 完成。
註1:事實上 Reduce 的 function 可以接受 4 個參數:(previous result, current element, index, self array)。然而通常很少人會用到後面兩個參數,因此絕大多數時候忽略。
假設一個情境:
有 5組向量 [(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)]
我們想計算出這五組向量的平均長度 ,則可以使用 Reduce 完成。
以程式碼來看就可以使用這一行表達。
Pipe & Compose
Functional Programming 設計哲學之一便是:「以 Function 為最小單位解決問題,任何 Function 都可以任意組合成為新的 Function」。
我們在上面講述 FP 觀念時,有大略提及過 Compose 的用法。
Compose 的用處為:將兩個以上的 function 組合,成為一個新的 function。
以上面的例子 f。g 來說,程式會先執行 g 並將 output 作為 f 的 input 執行。整個執行順序是「由右向左」的。
而 Pipe 則是很直觀地,先執行 f 並將 output 作為 g 的 input 執行。整個執行順序是「由左向右」的。
這兩個 Function 對 FP 的貢獻極大,但可惜的是 Javascript 本身並沒有支援,我們必須自己實作或是引用其他第三方套件 (ex. lodash、Ramda)。
如果不想引用第三方套件的朋友,本文也整理了一般性的 Pipe 與 Compose 。可以直接複製本文下方的兩行 code 到自己的專案使用。
我們假設一個情境:
const ary = [
{id: 1, text: "a"}, {id: 2, text: "b"},
{id: 3, text: "c"}, {id: 4, text: "d"},
{id: 5, text: "e"}, {id: 6, text: "f"},
{id: 7, text: "g"}, {id: 8, text: "h"},
{id: 9, text: "i"}, {id: 10, text: "j"},
];期望
1. 將奇數 id 的 text 轉換為大寫;
2. 將偶數 id 的 text 內容重複;
3. 將3的倍數 id 的 text 內容全都在開頭處加上"3"
4. 將不是5的倍數 id 的 text 內容全部在結尾處加上 "-5"
在此處我們可以把問題分析成如下
- 寫一個 function 將 array 中奇數 id 的 element 的 text 都轉為大寫,並且偶數 id 的 element 的 text 都內容重複
- 寫一個 function 將 array 中 3 的倍數 id 的 element 的 text 在前方加
“3”
- 寫一個 function 將 array 中 非5 的倍數 id 的 element 的 text 在後方加
“-5”
如此,我們有了這三個 function。我們將這三個 function 組合成一個新的 function 來完成我們的任務。
new_function = concatM5InTail。concat3。toUperCaseInOddAndRepeateInEven
我們再利用剛剛新組合出的 function new_function
處理上面的 ary
,如此便達到了這個任務的要求了。
在這邊,可能會有些人有疑問:「我之前使用一個 loop 再加上幾個 condition 一樣可以達到效果,為啥要搞這麼多function 來做這些事情?」
以 FP 處理問題有以下幾點好處:
- 以 function 為最小單位思考。
未來如果有新的需求時,我們可以使用手上有的 function 快速組合出能夠解決問題的 function,不用重新撰寫程式邏輯。
- 抽象出程式邏輯,使用「聲明式」而非「指令式」,增加程式碼的可讀性。
聲明式的程式風格清楚表示程式「要做什麼」而不需在乎「怎麼做」,讓人讀程式碼就像在讀文章一般清晰明瞭;指令式的程式風格,像一個暴露狂,強迫將你不想知道的一切資訊都展露給你,包括內部醜陋不堪的運算邏輯…。
- 不改變 Input 的資料,易於除錯與維護
由於每一個 Function 皆為 Pure function。如果程式有 Bug,我們僅需要定位到 Output 錯誤的環節,即可快速找出問題進行除錯。
柯里化(Currying)
柯里化是一種「將接受多個參數的 function 轉換成一次只接受一個參數的 function」 的技術。擁有柯里化的 Function 可以藉由將部分數值預先帶入,回傳一個接收剩下數值的 function,當所有數值條件皆備齊時,才真正執行此 function。
假設有一個 function fn
接受兩個參數 a, b
長成如下樣子
我們利用柯里化,可以將 function fn
變成一次只需要接收一個數值的function
function curryFn(a) {
return function fn(b) {
// do something ...
console.log("a:", a, "b:", b);
}
}
在 FP 的世界中,我們期望每個 function 都可以任意的組合與複用,這意味著,function 最好能夠達到:
1. Input 參數數量與代表意義一致,方便 function 組合時的參數傳遞。
2. Point free。function 不需要在乎 Input 值是誰,只需要專注在合成的運算過程。
我們可以舉一個簡單的例子:
創立一個 function 接收一個 input string 回傳一個 output string。
此 Output string 需要滿足以下幾個條件:1. 單詞為大寫開頭者,置於最前方
2. 單詞為小寫開頭置於後方。
3. 開頭非字母者一律忽略。
4. 字母需依照其被處理的順序擺放。Example:
Input 1: "hey You, Sort me Already!"
Output 1: "You, Sort Already! hey me"Input 2: "baby You and Me"
Output 2: "You Me baby and"
我們可以先定義一些基礎的 function
然後將這些 function 合併成一個真正解決此問題的 function
3. 柯里化可以將狀態利用 Closure 保存起來。
一樣舉一個簡單的例子:
考慮一個亂數產生 Secret Key 的function
通常我們產生密鑰時字元集合(charSet)都是固定不變的,因此我們可以使用柯里化將字元集合(charSet)保存起來,產生的新 function 只需要給定長度,即可產生相對應長度的密鑰。
柯里化一樣在 Javascript 中本身並不支援,我們一樣必須自己實作或是引用其他第三方套件 (ex. lodash、Ramda)。
以下提供一個簡易的 Curry help function ,對 function 進行柯里化。
柯里化是 FP 中非常重要的一環。套句 《JS Functional Programming》 一書作者所述:「 有些事物在你得到之前是無足輕重的,得到之後就不可或缺了。」
# 總結
Functional Programming 提供給我們不同於 OOP 的思路。兩者在程式撰寫時,各有擅長的部分,使用時並不衝突。
在某些情境下,使用 FP 可以讓我們的程式變得乾淨、清晰、易於維護,但是有時候 FP 反而會犧牲掉許多程式的執行效率。另外在 I/O 方面,也難以完全使用 FP 完成。
關於 FP ,我們應該視時機與場合使用。
重點是,學習 FP 提供給我們另一種解決問題的思考方式,但是不應因此限縮我們解決問題的方法。
# 參考連結
如果覺得這篇有幫到你的話,請不吝情給我點掌聲。