Functional Programming 入門 in JavaScript
本文會試著用最簡單的方式來幫助你入門 functional programming。
什麼是 Functional Programming
其實定義有很多種。簡單的可以是「只要有用到 first class function 就算」,而困難的可以到「必須使用 mathematical function 才算」。而這邊我們採用的定義是:
- 用到 first class function
- 盡量避免 side effect
什麼是 First Class Function?
原本的 JS function 都是這麼定義的
function functionName(parameter) {
// function body
return something;
}
有了 function 當然就要用:
const retValue = functionName(123);
以上就是基本的 function 慨念。那什麼是 first class function 呢?
寫過 JS 一定會看過這種東西
$('button').onClick(() => {
console.log('this btn got clicked');
; })
這邊定義了一個匿名函數,然後塞給 onClick
當參數,這就是一種 first class function 的使用範例。差異是:「在任何可以放 value 的地方你都可以放一個匿名函數。」 這也是為什麼叫做 first class 的原因。
Higher Order Function
所謂的 higher order 指的是 function 除了可以「接受/回傳 value」還可以「接受/回傳 function」。其中一個例子就是上面的 button onClick,它的參數是一個匿名函數。這邊再舉另外一個例子:
const add = (a) => {
return (b) => a + b;
; }
在這個例子裡 add function 會接受一個參數 a 然後回傳一個 function。 這麼做的好處是你可以做 partial parameter apply:
const add3 = add(3)
console.log(add3(4)) // 7
不過雖然這 Higher Order 聽起來是個很厲害的形容詞,不過其實跟「first class function」、「function as value」其實是同樣的意思。因為如果把 function 當作是一般的 value,那就沒有必要特別區分 higher order function 跟 first order function 了。
盡量避免 side effect
什麼是 side effect 呢?讓我們來直接看看例子吧!以下都是 side effect:
- 讀寫檔案,例如使用 readFile
- 印東西,例如使用:console.log
- 修改「外部」變數
最後一個就比較有趣一點了,因為必須討論什麼是「外部」:
let a = 3;
function f() {
= 4;
a let b = 5;
++;
breturn b;
; }
以上面的例子來說,f function 做了一種 side effect:「修改外部變數 a 」,不過修改 b 可以不算 side effect,因為它的生命週期就只侷限在 function f 裡面而已。
我們也可以很簡單的直接把所有的變數修改都看成是 side effect。
為什麼要避免 side effect?
假設我們有個 function f 會把外部變數 a 加ㄧ,然後有另外一個 function g,但你其實不知道 g 的實作是什麼:
let a = 3
function f() {
= a + 1;
a
}
function g() {
// don't know the detail
}
g();
請問:在這個程式執行後,你能知道 a 變成多少嗎?答案是不行,因為你不會知道 g 有沒有用到 f。
但是如果 f 一開始就沒有修改 a,那 g 怎麼使用 f 都不會影響 a。
也就是如果有 side effect 那這個程式就會變得相對難以理解。而只要一個 function 沒有 side effect 我們可以稱它為 pure function。而 pure function 除了比較好理解以外,也可以提高 reuseability, testability 等等等等。接下來都會提到。
那麼怎麼避免 side effect 呢?
很簡單:don’t do it(無誤
其實這個就是大多數人入門 FP 碰到的問題,也就是這篇教學想要幫助大家的地方。
接下來讓我們來看看怎麼用 first order function 來幫助我們避免 side effect。
Avoid side effect: Basic
讓我們從最基本的 array operation 來舉例。
Example 1: Map an Array
假設我們要把一個 array of number 中的所有數字都平方,那麼使用變數的話通常會這麼做:
let origin = [1, 2, 3];
let squared = [];
for (let n of origin) {
.push(n * n);
squared }
push function 會修改 squared 的內容,所以是一種 side effect。
而在 FP 的話通常會這麼做
const squared2 = origin.map(n => n * n);
使用 map function 就不需要使用 push 這個 function。
Example 2: Filter an Array
給定一個數字陣列,如果要把所有整正數抓出來,那一般會這麼做:
let origin = [-1, 3, -7, 6, 9];
let positives = [];
for (let n of origin) {
if (n >= 0) positives.push(n);
}
而 FP 通常都會提供 filter function 可以直接使用
const positives = origin.filter(n => n >= 0);
Example 3: Sum an Array
給定一個數字陣列,如果要把所有數字加總的話,一般會這麼做:
let origin = [1, 2, 3];
let sum = 0;
for (let n of origin) {
+= n;
sum }
而在 FP 中通常會提供各種 helper function 所以
sum(origin);
但如果沒有的話也可以用 reduce
.reduce((acc, n) => acc + n, 0); origin
其中第一個參數是一個 aggregation function 他會把「當前的加總」跟「現在拿到的數字」加在一起。而第二個參數是「當前加總」的初始值。
它運作方式其實跟 loop 很像,也就是範例中的 n
就是每一次 iterate 時拿到的數字,而每次 iterate,它都會使用這個 aggregation function,就跟 for loop 的 body 做的事情一樣。
.reduce(
origin, n) => acc + n, // 相當於 for loop body: sum += n;
(acc0 // 相當於 let sum = 0;
; )
而因為它幾乎跟 loop 作用一樣,所以 loop 能做的它都能做,最主要的差別是:這中間完全沒有使用到變數。有興趣也可以試試看用 reduce
來實作 map
, filter
。
Map, Filter 這些 function 是怎麼運作的?
雖然大多時候我們只需要知道怎麼使用這些 function ,但了解怎麼實做也是很有幫助的,尤其是當你碰到複雜的資料結構的時候。
最簡單的實作就是允許範圍內的 mutability:
const map = (arr, f) => {
let ret = [];
for (let item of arr) {
.push(f);
ret
}return ret;
}
console.log(map([1, 2, 3], add3)); // [4, 5, 6]
其結構非常簡單,還就只是透過一個 for loop 去把每個元素拿出來,然後 apply f function 後再塞回 ret。
雖然這樣有使用到「變數」,嚴格說起來這個實作並不能符合 FP。不過因為它並沒有修改外部變數,所以從「封裝」的角度來說,它用起來跟 pure function 沒有什麼差異,而因為效能考量所以真正的實作也會與此類似。
所以這邊有個有趣的但非必要的小作業:如何不使用變數來實作 map function 呢?
- 提示一:會使用到 array function: splice
- 提示二:會需要使用 recursion
如果覺得這個題目有點困難的話,可以先練習實作 sum function,而它用起來像這樣:
const total = sum(1, 50); // 從 1 加到 50
當然不能用數學公式,也不能用變數喔~
Avoid side effect: Inverse of Control
你可能聽過 Inverse of Control 這個詞,通常都是在 OOP/Design Pattern 相關的文章中看到的。但其實這個觀念在 FP 可以說是基礎中的基礎,因為不用這個觀念的話,很多 function 實作起來很麻煩。
Write a test for setTimeout
假設你今天想要為你的程式寫測試,而一個程式用到了 setTimeout
,好巧不巧你的 timeout 設定是一小時,如果不做什麼特殊處理的話每次跑測試都要一個小時,這肯定是無法接受的。
function doSomething() {
doFirstThing();
setTimeout(doSecondThing, 60 * 60 * 1000);
}
這個時候有兩種做法:1. 用 mock libary 例如 sinonjs
, 2. 用 Dependency Injection。
雖然前者是常見的方法,但是後者的泛用性更高,而且只需要匿名函數就能辦到。
setTimeout 是一個 side-effectful function ,我們可以用 Dependency Injection 來做動態替換:
function doSomething(timeout) {
doFirstThing();
timeout(doSecondThing, 60 * 60 * 1000);
}
注意上面的 timeout 是 doSomething 的一個參數喔!
如此一來,在 test 就可以這樣寫:
function test() {
const myTimeout = (f, time) => f();
doSomething(myTimeout);
}
myTimeout 會直接呼叫 f 也就是 doSecondThing,這樣就不需要等待一個小時。
而在 production 我們可以傳入 setTimeout 來達成原始邏輯:
doSomething(setTimeout);
這樣的作法除了可以幫助我們寫測試以外,我們還可以替換不同的邏輯,例如假設有個特殊的需求是要把所有的 “timeout” 都減半,那有了 dependency injection 我們可以這麼做:
const halfTimeout = (f, time) => {
setTimeout(f, time / 2);
;
}doSomething(halfTimeout);
這就呼應了我們前面講的:除了可以提高 testability,也可以提高 reuseability。
FP 在前端的應用
雖然 UI 有跟 user 有高度互動的關係,所以會用各種變數來存整個 UI 的狀態 (UI State),但是這並不代表 FP 的 pure 觀念不能使用在 UI。相反的它在 UI 可是非常重要的,其中以 React 這個 frontend library 為最常見。官方文件就直接寫上了「render() function 應該是 pure 的。」
也就是,正是因為 UI 有很多狀態需要管理,所以一但程式複雜起來,就必須要對狀態的數量做限制,只要是不必要的狀態都必須避免,而這也符合了 Single Source of Truth 的原則。
除了 React 以外,就連 Angular 這個原本著重於 view model binding 方法的也開始採用 RxJS 這種大量使用 FP 的 library。而且這個原則並不只局限於 web frontend,近幾年使用類似概念的其他 UI library 也出現在 mobile 平台上,像是 Android 的 Compose、iOS 的 SwiftUI。而且這兩者都有官方 support。
結語
其實入門 FP 並不困難,只要掌握 first class function 跟 pure function 的兩個觀念,並學會使用 map, filter, reduce 等常見 function,這樣就對 90% 的日常開發非常有幫助了。
雖然你可能會聽過有人說 Functor, Monad 對於理解 FP 來說很重要,但其實對日常開發來說通常不是必要的。多數狀況都單純的只是對於使用的 library 熟不熟悉罷了。
不過有趣的地方就在這裡,雖然入門 FP 其實非常容易,但正式因為基礎觀念少所以如何延伸就變得有趣許多。有興趣的話,學習 FP 絕對是最值得投資時間的技術之一。