[Javascript] Functional Programming 一文到底全紀錄

Coding as function. Thinking as function

Les Lee
一個小小工程師的隨手筆記
16 min readJul 5, 2019

--

最近吹起了一股 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 的過程。

https://math.stackexchange.com/questions/217271/what-is-the-preimage-of-the-codomain-of-a-function

這樣說太抽象了,我們來看看點例子:

我們假設有一個做「加減乘除工廠函數 (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.spliceArray.slice 為例:

Array.splice 由於每 call 一次,便會修改原始 Array,因此並不符合 FP 的準則;而 Array.slice 無論 call 多少次,原始 Array 都不會被更動,因此符合 FP 的準則。

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. lodashRamda)。

如果不想引用第三方套件的朋友,本文也整理了一般性的 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);
}
}
被柯里化的 function 可以藉由傳遞部分參數,獲得一個接受剩下參數的新 function

在 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. lodashRamda)。

以下提供一個簡易的 Curry help function ,對 function 進行柯里化。

柯里化是 FP 中非常重要的一環。套句 《JS Functional Programming》 一書作者所述:「 有些事物在你得到之前是無足輕重的,得到之後就不可或缺了。

# 總結

Functional Programming 提供給我們不同於 OOP 的思路。兩者在程式撰寫時,各有擅長的部分,使用時並不衝突。

在某些情境下,使用 FP 可以讓我們的程式變得乾淨、清晰、易於維護,但是有時候 FP 反而會犧牲掉許多程式的執行效率。另外在 I/O 方面,也難以完全使用 FP 完成。

關於 FP ,我們應該視時機與場合使用。

重點是,學習 FP 提供給我們另一種解決問題的思考方式,但是不應因此限縮我們解決問題的方法。

# 參考連結

如果覺得這篇有幫到你的話,請不吝情給我點掌聲。

--

--

Les Lee
一個小小工程師的隨手筆記

對新事物總是興緻勃勃、嘗試新技術、解決新挑戰; 也時常陷入許多無謂的思索,卡在其中得不出答案。