工作上用得到的函數式程式設計
透過容易上手的函數式語言 Elixir ,讓你寫出精簡、好除錯的漂亮程式碼!
隨著多核心電腦成為主流、分散式系統架構也成為顯學,函數式程式設計的重要性也與日俱增。跟物件導向程式設計相比,函數式程式設計著重於用更簡潔的方向表達程式碼真正的意圖。因此當學會用與物件導向程式程計不同的角度來寫程式後,可以讓你在切換不同的程式語言時依然能游刃有餘。 這門課將會透過容易上手的函數式語言 Elixir,教大家最重要的函數式程式設計觀念。接著介紹如何在 Ruby、JavaScript(也許還有一點 C#)上使用函數式程式設計的技巧。讓你寫出精簡、好除錯的漂亮程式碼!
你將學到什麼
程式新手到進階必備!
不管你是前端工程師 、 Ruby 工程師或是擅長其他語言的前後端工程師,只要你是程式新手或是想要了解目前業界關注的 『函數式程式設計(Functional Programming)』的資深工程師,這門課都能讓你對寫程式這件事有全新的認識。
自 2014 Java SE 8 加入了 Lambda 功能之後,可說現代語言都有函數式程式設計的能力了。甚至在 Ruby 及 JavaScript 這類天生帶著函數式基因的語言裡,受限於舊的思考方式及長久以來的習慣,許多人還是持續用指令式的做法寫出繁瑣的程式。就算看了網路上的文章,也是會使用 map、reduce、filter 這些函式,卻不知道這背後有一整套優雅簡潔的世界觀。
為什麼要學函數式程式設計?
簡潔 + 強大 = 優雅
用更少的行數,更易懂的方式,寫出不容易出錯、好測試及閱讀的程式碼,也能看得懂 LeetCode 上厲害解法的思考脈絡了!
易於維護,容易閱讀和除錯
用全新的角度來理解程式組合及運作的方式。
跨程式語言的觀念
學會一種觀念,就能在 JavaScript、Ruby、Python、C# 3.0 及 Java 8.0 上寫出高效且漂亮優雅的解法。
透過這門課程,你將會學到:
- 工作上用得到的 Ruby / JavaScript 函數式程式設計手法。
- 函數式程式設計是什麼。
- 純函數式語言們寫起來是怎麼樣子。
- 函數式程式設計的基本概念、手法及好處。
- 好用的函數式 Library。
課程大綱
8 章節 · 63 單元 · 7.1 小時函數式程式設計的核心優勢在於容易平行化處理。現代電腦和手機都有多核心 CPU,但傳統命令式程式設計依賴狀態的持續改變,導致程式預設只能使用單一核心。函數式語言能讓程式充分運用所有核心,這是它越來越流行的主因。此外,函數式風格寫出的程式碼更簡潔易讀。雖然 Ruby、JavaScript、Swift 等語言本質上仍是物件導向,但設計時已融入許多函數式概念。學習函數式思考方式後,通常三週到兩個月內就能寫出比原本語言更快、更簡潔的程式碼。
寫程式時,函式通常包含六種行為:收集輸入、執行核心邏輯、整理輸出、處理失敗、偵測環境、善後清理。大多數函式會用到前四種。當程式碼混雜這些行為時,會變得難以理解。建議用空行將同類型的程式碼分組,讓意圖更清晰。例如,與其用條件判斷處理 nil 值,不如在一開始就把輸入整理成統一格式,程式碼會直接少一半。進階練習是限制每個函式只做兩到三種事,把其他行為抽成獨立函式。像是專門收集輸入的函式,負責整理好所有需要的資料後回傳,讓主函式保持乾淨,只專注在核心邏輯上。
程式碼的組織方式很像小說的敘事風格,讀者對奇幻故事有預期的世界觀,突然出現太空船會讓人出戲。OO 和 Functional Programming 也是兩種不同的敘事方式。OO 的核心是封裝、繼承、多型,把資料藏在物件裡,只讓特定方法操作。這符合人類直覺,但封裝會帶來複雜的抽象和 design pattern,導致測試困難、重構麻煩。Functional Programming 則不封裝資料,用 pure function 處理資料,函式可以直接剪貼到其他檔案還能運作,也容易平行化。Lisp 是最古老的函數式語言,學會它的概念後,寫任何語言都只是用不同語法表達同樣的思維。
為什麼選擇用 Elixir 來教 Functional Programming?網路上大部分教材要嘛用 Haskell(硬到爆炸),要嘛用 JavaScript,但這兩者都不是理想選擇。因為在 Imperative 語言中,有些事情本來就比較容易做,當你用 JavaScript 講 Monad、Functor 時,會不懂為何要這麼麻煩。但在 Elixir 這種較純的函數式語言中,當某些「稀鬆平常」的事情做不到時,你才會理解這些限制帶來的好處。所以這堂課會先用 Elixir 讓大家體驗 functional code 的思維,之後再把這些概念對應到 Ruby、JavaScript 等語言上。
抽象是人類思維的核心能力。小孩學會三個蘋果加兩個蘋果等於五個後,很快就能推論三支鉛筆加兩支鉛筆也是五支,這就是數字系統的抽象——脫離具體物件,單純處理數字。第二層抽象是代數,用變數代表未知數,再進一步發展成函數,只要給定輸入就能產出結果。程式設計也有類似的層次:最底層是 primitive type,如數字、布林值、字元;往上則有 list、map、tuple 等結構。Tuple 特別適合固定長度且每個位置意義不同的資料,例如座標的 x 和 y。雖然許多語言用陣列代替 tuple,但理解兩者差異有助於寫出更清晰的程式碼。
在程式語言中,一等公民(first class citizen)必須符合三個條件:可以指派給變數、可以當作函式的引數傳遞、可以作為函式的回傳值。順帶一提,定義函式時的參數叫 parameter,呼叫時傳入的值叫 argument(引數),雖然實務上大家常混用。數字、布林值、字串、陣列、鍵值對等型別都是一等公民,但函式是否為一等公民則因語言而異。JavaScript 的函式是一等公民,可以指派給變數、傳入其他函式、作為回傳值,這也是為什麼網路上教 functional programming 常用 JavaScript。但 Ruby 的函式不是一等公民,無法直接做到這些事,需要用其他手法來解決。
函式是一種可以呼叫的東西,而在某些程式語言中,函式是「一級公民」,意思是可以像變數一樣傳遞。當我們看到兩段結構相似的程式碼,差別只在呼叫不同函式時,可以把這個結構抽象出來。定義一個新函式,接收另一個函式作為參數,不管傳進來的函式實作是什麼,只要知道它可以被呼叫就行。這樣就能把函式直接「丟進」另一個函式裡使用。甚至可以用匿名函式,不需要名字就能傳遞。這種能力讓我們可以抽象討論函式之間的關係,就像用代數討論數字一樣。JavaScript 天生支援這種做法,但不是所有語言都行,例如 Java 8 之前就沒有這個概念。
Elixir 的副檔名分為 .ex 和 .exs,前者會編譯成 .beam 檔案供正式環境使用,後者則編譯後直接在記憶體中執行。Elixir 的函式分為具名與匿名兩種,具名函式必須放在 module 內,寫法類似 Ruby;匿名函式則用 fn 定義。兩者最大差異在於作用域:具名函式是「乾淨的」,只能使用參數傳入的變數,若引用外部變數會報錯;匿名函式則可往外層尋找變數,形成閉包。這種設計源自 Ruby,目的是讓開發者能根據需求選擇要隔離作用域還是允許引用外部環境。
Pure function 是指給它相同輸入,永遠會得到相同輸出的函式。例如一個 +1 函式,給它 5 永遠會拿到 6,不會因為資料庫狀態或環境變數而改變結果。這種函式非常好測試,也容易除錯,還能輕鬆平行化處理。寫程式時,核心運算邏輯應盡量用 pure function,避免亂抓外部變數或直接存取資料庫,把這些副作用切分到其他層處理。在 Ruby 和 Elixir 中,具名函式不會引用外界變數,所以比較容易保證是 pure function;匿名函式則要特別注意是否引用了外部變數。
具名函式不會任意取用外界變數,匿名函式則會。Closure(閉包)是函式在定義時封裝外界變數值的機制。以 Elixir 為例,當 i 等於 100 時定義一個匿名函式 baz,它會取得 x 並回傳 x + i。呼叫 baz.(1) 得到 101。接著把 i 改成 999,再次呼叫 baz.(1),結果仍是 101。這就是 Closure 的核心概念,函式在定義當下會把外界變數的值「凍結」起來,就像琥珀封存昆蟲一樣。不論之後外界變數如何改變,函式永遠使用定義時的值。「閉包」的「閉」就是這個意思,把變數封存在定義的那一刻。
函數式語言的變數具有 immutable(不可變)特性,這是 Elixir、Haskell 等純函數式語言的核心概念。用 JavaScript 教 functional programming 的問題在於,它原生不支援這種特性。在 JavaScript 中,變數可以隨時被修改,導致函數內部引用的值也跟著改變。為了模擬 immutable 行為,必須透過 Closure 技巧:先定義一個會回傳函數的函數,第一次呼叫時把值傳入,讓內層函數「抓住」該值,之後外部怎麼改都不影響。這種在 JavaScript 需要費力模擬的行為,對 Elixir 來說是再自然不過的事。
Mutable 語言在變數重新賦值時,會直接修改記憶體中的值。例如 a = 1 改成 a = 2,就是把原本那格記憶體的內容從 1 改成 2。Immutable 語言則不同,當 a = 2 時,不會改動原本的 1,而是在記憶體中建立新的 2,再把變數 a 重新綁定(rebind)到這個新值。原本的 1 還在記憶體裡,函式定義時捕捉到的值不會改變。Immutable 的好處是容易平行化和測試,但缺點是每次修改都產生新的記憶體空間,若不謹慎處理,記憶體容易爆掉。
在 functional programming 語言中,變數不會直接修改記憶體中的值,而是透過「綁定」將變數指向新的位置。Elixir 的具名函式是一等公民,可以當作參數傳遞,但不會往外抓環境變數,匿名函式則會。Elixir 完全是 immutable,無法修改記憶體。JavaScript 的函式雖然是一等公民,但不論具名或匿名都會往外抓變數,這常讓新手困擾。Ruby 的具名函式無法當一等公民使用。Swift 和 F# 等較新的語言可同時支援 mutable 與 immutable,預設不可變,但可明確宣告為可變,這是比較好的設計方向。
Ruby 號稱所有東西都是物件,但 method 和 block 並非物件。Block 是語法結構,無法單獨存在,直接寫會產生 syntax error。若想讓 block 變成可傳遞的物件,可用 lambda 或 proc 將其轉換為匿名函式。Lambda 與 proc 的差別在於:lambda 會檢查參數數量,proc 不會;兩者在 return 行為上也有些微不同。Ruby 中有三種東西需要互相轉換:block 語法結構、匿名函式、具名函式。用「&」可在 block 與 lambda 間轉換,用 method() 可將具名函式轉為匿名函式物件。這些轉換的目的通常是為了將函式當作一級公民到處傳遞。
JavaScript 的 function 天生就是 first class citizen,非常適合用 functional style 來寫程式,因為它的爸爸 Scheme 本來就是 functional programming language。不過有幾件事要特別小心:第一,不管具名或匿名函式都會綁定外界變數,搞懂這點就解決六成問題;第二,如果用到 this 關鍵字,尤其是箭頭函式裡的 this,一定要先冷靜十秒搞清楚它在幹嘛。ES6 的箭頭函式加上 map、reduce、filter 讓 JavaScript 變得非常適合 functional 寫法。JavaScript 的 good part 幾乎都是從 functional programming 學來的,而那些為了迎合市場、模仿 Java 語法的部分,反而是需要小心的地方。
函式在程式語言中可以是一級公民,就像數字、布林值、陣列一樣。當函式具備一級公民的特性時,它可以被指派給變數、當作參數傳遞、作為回傳值。這三個特性讓我們能夠進一步探索函式的運用方式與特殊性質。就像數字可以加減乘除,函式也有其核心操作:最重要的就是它可以被呼叫。無論函式被傳遞到哪裡,最終目的都是在某個時間點呼叫它。呼叫的方式很簡單,在函式後面加上括號即可,Ruby 的 lambda 則是用點加括號。
Pattern Matching 是 Elixir 等函數式語言最吸引人的特性,學會它大概就掌握了六成的 Elixir 能力。許多語言如 JavaScript、Ruby 2.7、C# 都想模仿這功能。在 Elixir 中,等號不是指派,而是模式比對,會嘗試讓左右兩邊對應起來。例如 `[a, a, b] = [1, 2, 3]` 會失敗,因為 a 不能同時是 1 又是 2;但 `[a, a, b] = [1, 1, 3]` 就能成功,a 綁定為 1,b 綁定為 3。同理,`[a, 10, b] = [1, 2, 3]` 會失敗,因為 10 不等於 2。Pattern Matching 成功就綁定變數,失敗就拋出錯誤。
Elixir 和 Haskell 大量使用 Pattern Matching 來同時做檢查和變數綁定。比對成功就綁定變數,失敗就報錯。許多函式回傳 tuple,左邊是 :ok 或 :error,右邊是結果或錯誤訊息,例如 File.read 讀檔成功回傳 {:ok, binary},失敗則回傳 {:error, reason}。Pattern Matching 只需部分符合即可,能從 map 中只取需要的 key。由於 Elixir 所有東西都是 expression,可以串連多層比對,一次綁定多個變數並確保資料結構符合預期,省去繁瑣的 if-else 檢查,這正是大家想學這個特性的原因。
List 和 Array 是不同的資料結構。Array 支援隨機存取,不論取第 0 個或第 999 個元素,時間都是常數。但 Elixir 的 List 是單向鏈結串列,只記錄 head 值和指向下一個 List 的 tail,所以取越後面的元素越慢。`[1, 2, 3]` 其實是語法糖,實際結構是 `[1 | [2 | [3 | []]]]`。透過 Pattern Matching 可以拆解 List,用 `[head | tail]` 取出頭尾。在 Map 比對時,底線 `_` 代表「不在乎這個值」,只確認 key 存在。若要確保變數值與先前綁定相同,需用 Pin Operator `^` 把變數釘住,避免重新綁定。
Elixir 的函式定義在接收參數的括弧中可以直接使用 pattern matching,把值寫在參數位置。當呼叫函式時,系統會由上往下尋找第一個符合的定義來執行。例如定義 `plusOne(0)` 時,若傳入的參數剛好是零,就執行對應的程式區塊;若不符合則繼續往下找,直到找到匹配的定義,或回報找不到可執行的函式。這種寫法讓程式碼更簡潔直觀,不需要在函式內部用條件判斷處理不同情況,而是透過多個函式定義搭配 pattern matching 來分流處理邏輯。
Elixir 的 pattern matching 能在函式定義時完成參數檢查與變數綁定,大幅減少 if..else.. 的使用。處理 response 時,可直接在函式簽名中解構 tuple,ok 時綁定 payload,error 時取出訊息,讓 function body 專注於核心運算。當需要做範圍判斷(如 x、y 都大於 100),可使用 guard,在 do 前加上 when 條件。guard 是編譯期決定的,只能用有限的運算子和函式,如比較運算、is_list、is_number、abs 等。若想組合自訂的 guard,可用 defguard 定義,但只能使用內建的基本元件。
FizzBuzz 是個經典練習題,給定一個數字,判斷它的倍數關係:3 的倍數回傳 Fizz,5 的倍數回傳 Buzz,同時是 3 和 5 的倍數則回傳 FizzBuzz,其他情況就把數字轉成字串回傳。在 Ruby 中可以用 String Interpolation 輕鬆處理字串轉換。實作時會建立一個 FizzBuzz 模組,裡面定義一個 exec 函數來處理這個邏輯,例如傳入 3 就回傳 Fizz,傳入 5 就回傳 Buzz。
FizzBuzz 問題的解法使用 Elixir 的 pattern matching 和 guard clause。把能被 15 整除的案例放最上面,接著是 3 和 5 的案例,最後用一個通用的 function 攔截所有不符合條件的情況。這是常見的做法,讓 pattern matching 失敗的 case 都被最下面的 function 接住。當 function body 只有一行時,寫 `do...end` 顯得冗長,Elixir 提供了縮寫語法:在 do 前面加逗號,後面加冒號,就能把整個定義寫成一行,讓程式碼更簡潔。
Elixir 的 case 語法與 Ruby 類似,但使用箭頭符號來表示條件對應的動作。case 內可進行 pattern matching,從結構中取出特定值並綁定變數,還能加上 guard 條件。通常會在最後加底線來攔截所有未匹配的情況。由於 Elixir、Haskell 等函數式語言的 pattern matching 是核心特性,其他語言便嘗試模仿:一是從結構中提取值並綁定變數,二是透過 switch case 對值進行比對。Ruby 2.7 開始支援部分 pattern matching,JavaScript 的 TC39 也有相關提案。目前 Rust 和 Swift 在這方面做得最成功,Swift 的 switch case 幾乎涵蓋了除函式定義外的所有 pattern matching 功能。
JavaScript 的解構賦值是 ES6 最實用的功能之一,可以一次從物件或陣列中綁定多個變數。物件解構時,如果變數名稱與 key 相同,甚至可以省略冒號;展開運算子(...)則能輕鬆取得陣列的頭部與剩餘部分。不過 JavaScript 的 pattern matching 只有變數綁定功能,無法做檢查,且允許重複綁定。Ruby 的解構賦值則只支援陣列,無法直接拆解 hash,必須先透過 `.values` 轉成陣列才能使用。此外,Ruby 的解構需特別注意順序問題,同樣也缺乏檢查機制。
函數式語言無法使用傳統 for loop,因為變數具有 immutability 特性。以 Elixir 為例,當宣告 a = 0 後在 for 迴圈中嘗試累加,每次迭代中的 a 都會是原始值 0,因為 0 本身不可變。迴圈內的賦值只在該 scope 內有效,外部的 a 始終不變。例如 a = 1 時,對 `[1, 2, 3]` 做 x + a 運算,因為 a 永遠是 1,結果會是 `[2, 3, 4]`。這種不可變特性讓習慣命令式程式設計的開發者需要改變思維,改用 map 等高階函式來處理資料轉換。
遞迴是函式在定義中呼叫自己的技巧。以階乘為例,5 階乘等於 5 乘以 4 乘以 3 一路乘下去。在 Elixir 這類函數式語言中,遞迴搭配 pattern matching 寫起來很直覺:先定義邊界條件(數字為 0 時回傳 1),其他情況就是當前數字乘以下一個遞迴呼叫的結果。這個結構跟數學歸納法一模一樣:先證明 x=1 成立,再證明若 n 成立則 n+1 也成立。Haskell 的 Curry-Howard 對應甚至證明了寫程式和數學證明本質上是同一件事。掌握遞迴的關鍵就是:知道邊界值是什麼,以及每一步要對當前值做什麼處理。
遞迴確實比迴圈慢,但原因不像初學者想的那麼簡單。以階乘函數為例,呼叫 fact(4) 時,程式會在記憶體中建立 call stack,由下往上堆疊。每次遞迴呼叫都會新增一層,直到遇到終止條件才開始逐層消解並計算結果。問題在於:如果呼叫 fact(20) 就會堆疊 20 層,呼叫到 1 萬層時記憶體就撐不住了,這就是所謂的 Stack Overflow。這是遞迴最大的缺點,輸入值稍大就容易把 stack 撐爆。實際測試時,階乘超過 40 執行速度就會明顯下降,超過 70 可能要等半小時以上。
尾呼叫優化(tail call optimization)能解決遞迴造成的記憶體堆疊問題。當函式的最後一行是單純的函式呼叫,而非額外運算時,系統就不會增加堆疊,而是直接原地替換。要實現尾遞迴,程式語言本身必須支援,同時程式碼也要調整,通常是加一個 accumulator 參數來累積結果。以階乘為例,傳統寫法是 n 乘以 fact(n-1),尾遞迴版本則把累積值放進參數傳遞。大多數現代語言都支援尾呼叫優化,包括 Ruby 和 ES6,但 Rust 因特殊原因未實作。若不想多傳參數,可用預設參數保持 API 一致性。
尾遞迴優化完成後,接著來談大家常搞不懂的 reduce。其實只要有遞迴,就能實作出 reduce,因為 reduce 本質上就是抽象化一層後的遞迴。實作方式是:遇到空陣列就回傳 accumulator,否則拆分頭尾,遞迴呼叫時將處理過的值作為新的 accumulator 往下傳。這解決了 Immutable 變數無法修改的問題,透過每次呼叫時改變參數來達成狀態變化。使用 reduce 時,起始值通常是 identity(如加法的 0、乘法的 1、空陣列、空 hash),從起始值就能推測最終運算類型。另外要注意各語言中匿名函式參數順序不同,i 和 accumulator 誰前誰後需查閱文件。
map 跟 filter 是常用的陣列處理函式。map 把每個元素丟進指定函式處理,輸入跟輸出的陣列長度永遠一樣。它的好處是你只需思考「拿到一個元素要做什麼」,不用管整個陣列怎麼處理,map 會自動幫你套用到所有元素。filter 則是篩選元素,回傳布林值決定是否保留,結果只會包含原陣列的子集,不會產生新東西。有了遞迴就能做出 reduce,有了 reduce 就能做出 map 跟 filter。這正是函數式程式設計的核心概念:用最小的元素一層層組出更大的模組。
map 和 filter 這類操作強調工作沒有順序依賴,元素誰先處理誰後處理都無所謂,只要最終順序正確即可。相對地,for loop 和 reduce 則有順序依賴,每次計算需要用到上一次的結果。學會區分這兩種情況,就能獲得 concurrent(並行)的好處。以 50 嵐點餐為例,for loop 像是一個店員從頭做到尾才能服務下一位;而 map 的思維則是把工作拆開,多個店員可以同時處理不同任務。在 Elixir 中,只需四行程式碼就能實作 pmap,讓工作自動分散到多顆 CPU 上執行。這在 Ruby 或 JavaScript 中困難許多,但掌握了順序相依性的概念,善用多核心就變得簡單。
zip 是一種高階函式,用來處理元素間的小量依賴關係。當需要比較陣列中相鄰元素時,例如「只保留比前一個元素小的值」,就可以用 zip 把原陣列與其尾部配對。zip 的運作像拉鍊,將兩個陣列對應位置的元素組成 tuple,長度不同時多餘的會被捨棄。配對後再用 filter 篩選符合條件的組合,最後用 map 取出需要的值。zip_with 則可在配對時同時套用函式做額外處理。JavaScript 需要透過 Ramda、Lodash 等函式庫才能使用 zip,而 Ruby、Swift、C# 則有內建支援。
`flat_map` 會把函數回傳的陣列「打散」後合併,避免產生巢狀陣列。搭配 `List.wrap` 使用時效果更強大,`List.wrap` 能將任意值統一成陣列:單一元素變成只有一個元素的陣列,`nil` 變成空陣列,陣列則維持原樣。這個組合技的妙處在於處理混合資料時,不需要寫 `if..else` 或 pattern matching 來判斷 `nil`,因為 `nil` 被包成空陣列後,經過 `flat_map` 展開就自動消失了。實際應用上,例如要從包含老師、助教、學生的教室資料中取出所有人的名字,即使助教欄位是 `nil`,透過這個連續技也能一行搞定。
寫程式時常需要把函式結果暫存到變數,再傳給下一個函式,但這些中間變數其實沒什麼用。如果不用變數,就得把函式層層巢狀,程式碼會變得難以閱讀。Elixir 的 Pipe Operator 解決了這個問題,它會自動把上一個函式的結果當作下一個函式的第一個參數傳入,讓程式碼可以像水管一樣串接,既清晰又不需要多餘的變數。另一個好用的工具是 IO.inspect,它不只印出資料,還會原封不動地回傳,方便在 pipe 中間除錯。學會 pattern matching、基本資料結構和 Pipe Operator,就能處理大部分 Elixir 的任務了。
處理資料時,我們會拿到一個資料結構,透過一連串的函式轉換,逐步把它「削」成想要的樣子。例如從 Map 取出 values 變成 list,再用 flat_map 攤平,最後用 map 取出需要的欄位。這就像雕刻一樣,一層一層去除不需要的部分。Functional Programming 的核心概念就是:每個函式接收資料、做些處理、再傳給下一個函式。理想的程式架構是把真正做計算的邏輯抽成 pure function,這種函式沒有副作用、非常好測試,也容易跟其他函式組合。另外再用流程控制型的函式,負責把資料傳遞給各個 pure function 處理。
函式有三個特性:可被呼叫、可遞迴定義、可與其他函式組合。組合其實很簡單,就是把一個函式的結果傳給另一個函式處理。Elixir 提供 Pipe Operator 語法,讓你把 x 傳給 f,結果再傳給 g,可以無限串接下去。這跟 Ruby 或 JavaScript 的連續點號不同,那些語言的點號依賴上一步結果必須是 Array 才能繼續呼叫 map、filter、reduce。一旦結果變成數字或物件,就無法繼續點下去了。但 Elixir 的 pipe 不管上一步結果是什麼型別,只要下一個函式能接收該結果,就能繼續組合下去,不需要中斷流程。
當習慣用資料轉換的思維寫程式時,可以很輕鬆地在轉換過程中插入想要的操作。例如要從班級資料中篩選出 18 歲以上的人,用 for loop 寫會很痛苦,因為要搞清楚資料怎麼組合、怎麼撿取。但如果用 pipe 的方式,只要在適當的位置插入一條 filter 就搞定了。這種資料轉換的思路學會之後,寫其他語言雖然會有點不習慣,但核心概念是一樣的。不管是 Ruby 還是 JavaScript,都可以用同樣的方式思考:把資料不斷轉換再轉換,最後輸出結果,這比命令式寫法好寫得多。
Elixir 是由前 Rails 核心團隊成員 Jose Valim 所創造的語言。他在開發 Ruby 時遇到並行處理的瓶頸,因為 Ruby 有 Global Interpreter Lock,後來找到了 Erlang。Erlang 是易立信在 1980 年代為電話交換機開發的語言,核心設計理念是「讓它當」,任何程式崩潰都不會影響其他部分。透過 OTP Supervision Tree,系統能在運行中熱更新程式碼,甚至能在四軸飛行器飛行時即時修復 bug。Erlang 曾在 ATM 系統達到 99.9999999% 的可用性,五年內只當機兩秒。Phoenix 框架能在單機處理兩百萬個連線,因為本質上是兩百萬個獨立 server 各處理一個連線。
Erlang/Elixir 的系統由大量 Process 組成,每個 Process 負責特定任務,如存取資料庫或處理 Web 請求。透過內建的 Observer 工具,可以即時觀察系統中所有運行的 Process。這套架構的核心是 Supervisor(監督者),它的職責是確保底下的 Worker 數量足夠。當某個 Process 死掉時,Supervisor 會自動補上新的;若 Supervisor 本身掛掉,它的上層 Supervisor 會把整組重建。這種層層監控的結構叫做 Supervision Tree。寫 Elixir 一段時間後,思考的重點會變成:系統中最不能掛的是誰?誰該管誰?這讓 Elixir 服務幾乎殺不死,就算強制終止也會自動重啟。
Phoenix 框架讓許多事情變得簡單,包括 concurrent、WebSocket 聊天室和 GraphQL。其中 Phoenix LiveView 是目前最受歡迎的功能。傳統 server-side rendering 的缺點是無法即時互動,例如表單驗證要按好幾次送出才能完成。過去用 jQuery 處理前端驗證,後來改用 React 或 Vue,但技術棧變得複雜,驗證邏輯還得前後端各寫一份。LiveView 利用強大的 concurrent 和 WebSocket 能力,讓邏輯全留在後端,前端幾乎不用寫 JavaScript,驗證也只需一份。當你用純函數和狀態轉換的思維寫程式,concurrent 和容錯機制就變得相當簡單。
Elixir 從 2012 年開始開發,至今約 8 年,但社群規模一直不大。有個網站叫 Classic Programmer Paintings,專門用古典繪畫配上程式相關標題做梗圖,像是「GitHub 當機了」就配一幅看風景的畫,「Haskell Meetup」則暗示參加人數稀少。我自己辦 Elixir Meetup 兩年多,至少有四次只有三個主辦人在場聊天。Functional Programming 給人難入門的印象,因為學校教的都是 C、Java,補習班也只教 Ruby、JavaScript,形成雞蛋問題:沒人學是因為沒公司用。但其實 FP 概念很簡單,學會後跨語言反而輕鬆。
函式可以當作另一個函式的回傳值,這是函式作為一等公民的重要特性之一。在 Ruby 中定義一個需要三個參數的函式,若呼叫時參數數量不對,會直接報錯並清楚提示「Wrong number of arguments」。JavaScript 的處理方式則不同,參數不足時不會報錯,缺少的參數會變成 undefined,進行運算後產生 NaN(Not a Number)。NaN 是個麻煩的值,因為它不等於任何東西,包括自己,一旦出現就像黑洞一樣難以處理。那麼程式語言在設計上,除了報錯或假裝沒事這兩種方式,是否還有其他選擇?
JavaScript 的 function 是一等公民,可以被傳遞和回傳。Currying(柯里化)是把一個接收 n 個參數的函式,轉換成 n 層各接收一個參數的函式。這種函式的特點是:當參數不足時,會回傳另一個函式等你繼續補參數;當參數「飽和」(saturate)時,才會回傳計算結果。Ruby 內建 curry 方法可直接使用,JavaScript 則需要靠函式庫或手動實作。還沒飽和的函式稱為 Partial Application(部分套用),代表已經餵入部分參數,等待剩餘參數補齊後才會執行運算。
JavaScript ES6 的箭頭函式讓 currying 變得簡潔許多。當函式本體只有一行時,可以省略大括號,寫成 `a => b => c => a + b + c` 這種形式,雖然看起來不太像傳統 JavaScript,但在 React 和 Redux 中大量使用。寫事件處理器時,常用箭頭函式包裝另一個函式來綁定環境變數,這其實就是 partial application。另一個技巧是 eta conversion:當 lambda 只是呼叫另一個函式時,可以直接傳入該函式,省略外層包裝,形成 pointfree style。使用 curry function 時,建議建立命名慣例,例如加上 `curry_` 前綴或 `_fn` 後綴,讓團隊成員能一眼辨識變數是值還是函式。
在 Ruby 中,curry 是建在 lambda 上面的方法,要先把函式轉成 lambda,再呼叫 curry 方法,就能得到 curried lambda。只給部分參數的做法叫做 Partial Application。在 Elixir 中,如果有個接受三個參數的函式,想做 partial application,可以在函式前面加上「`&`」把它轉成 lambda,然後把手上現有的參數丟進去,用「`&1`」標示還缺少的參數。這樣就能建立一個已經綁定部分參數的函式,可以把它指派給變數到處傳遞使用。
在 Ruby 和 JavaScript 中,map 是物件的方法,用 `[1,2,3].map()` 這種寫法。但 Elixir 不同,map 是獨立的函式,要寫成 `Enum.map([1,2,3], fn)`。這其實更符合 functional programming 的精神,因為函式不應該「屬於」某個物件。早期 JavaScript 的 Lodash、Underscore、Ramda 也是這樣設計。Ramda 更進一步把資料放在最後一個參數,這樣可以做 partial application,先組合函式,最後再丟資料進去執行。Elixir 則把資料放第一個參數,配合 pipe operator 可以一路串接下去。理解這些設計背後的原因,寫出來的函式就更容易跟別人的程式碼整合。
Curry 和回傳函式的函式可以用來建立 closure。在 JavaScript 中,可以用箭頭函式的語法來實作,例如定義一個雙箭頭函式後直接呼叫,變數就會被鎖定在該作用域中。Ruby 的 lambda 也有類似的縮寫語法,用箭號接收參數,再用花括弧包住函式本體,同樣可以做出 closure。不過在實務上,Ruby 很少需要用到這種收變數的技巧,雖然說不上來具體原因,但以我的經驗來說,寫 Ruby 時幾乎沒有用過這種做法。
高階函式分為「接收函式」和「回傳函式」兩種,實務上我們更常使用別人寫好的高階函式,像是 map、reduce、filter,或是帶有 with、by 字眼的函式如 sort。早期 Java 1.8 之前沒有 Lambda,要排序得先建立 comparable 物件再傳入,相當繁瑣,這就是把函式當次等公民的代價。結合「接收函式」與「回傳函式」,就能做出 Decorator 這種裝飾器模式,把散落各處的共同邏輯(如 AJAX 呼叫)收攏到一處,方便統一管理。Python 雖有內建的裝飾器語法,但本質就是接收函式並回傳函式的函式,理解原理後用任何語言都能輕鬆實作。
Decorator 是一種函數式程式設計的技巧,可以用來「裝飾」既有函數,在不修改原函數的情況下擴充功能。以 JavaScript 為例,decorator 接收一個函數作為參數,回傳一個新函數,這個新函數會先執行額外邏輯(如發送 AJAX 請求取得資料),再將結果傳給原函數處理。Ruby 的做法類似,但因為 method 和 lambda 不同,需要用 `method(:symbol)` 將 method 轉成 lambda 才能傳遞。如果 method 屬於某個物件,也可以用 `物件.method(:symbol)` 取得。函數式程式設計的程式碼通常較精簡,因為把函數視為可操作的資料結構,而非每次都要重新定義。
Railway Oriented Programming 是一種程式設計概念,核心想法是將程式的成功路徑(happy path)與錯誤路徑(error path)分開處理。傳統寫法中,一個函式可能有多個出口,導致程式碼難以閱讀。透過建立統一的協定,成功時回傳 ok something,失敗時回傳 error something,就能讓函式串接起來,任何一步失敗都會自動走錯誤路徑。Elixir 刻意不提供提早 return 的語法,讓爛程式碼看起來特別醜,逼你寫出更好的結構。新版 Elixir 的 with 語法讓這種模式更優雅,可以串接多個有依賴關係的操作,全部成功才執行後續動作,任何一步失敗就跳到 else 處理,讓程式只有兩個出口。
JavaScript 的 Optional Chaining(?.)語法是為了解決 undefined 或 null 造成的連續取值錯誤。當物件連續存取屬性時,任何一步是 nil 就不會拋出例外,而是回傳 undefined。這語法看似方便,但其實是雙面刃,因為它會製造更多可能噴出 undefined 的來源。使用時應該用 if...else 包起來,在失敗時給予適當的預設值或處理,否則當 undefined 出現時,根本無法追蹤是哪個環節出錯。如果 library 任意使用這語法而不做防護,會造成 undefined 到處擴散的嚴重問題。
程式碼可分為「流程型」與「事務型」兩種。流程型負責控制資料在不同函式間的傳遞順序,事務型則是純函式,給定相同輸入永遠得到相同輸出。這種分離讓測試變得容易:純函式直接測,副作用(如存資料庫)集中在流程型函式中處理。當整串 Pipeline 出錯時,可用 Binary Debug 技巧,在中間插入檢查點,將除錯時間從 O(n) 降到 O(log n)。這種寫法讓程式更好測試、更好維護,也是 Functional Core, Imperative Shell 架構的核心概念。
學習 functional programming 概念後,可以應用在 Ruby、JavaScript、Python、Swift 等現代語言上。面對新語言時,我的策略是:首先習慣寫資料轉換流水線型的程式,把資料削成需要的形狀再往下串。接著確認該語言的具名函式是否為一等公民,若不是就改用匿名函式來傳遞。然後找出主要的集合型別如 Array、Dictionary、Set,並確認 map、reduce、filter 能用在哪些集合上。如果像 JavaScript 的 map 只能用在 Array,策略就是先把其他型別轉成 Array 操作,最後再轉回去。進階的高階函式如 zip、group_by、scan 能幫上忙,但看不懂的功能不用勉強,這很正常。
在 Elixir 中可以用 `inspect` 輕鬆查看 pipe 過程中的中間結果,但 Ruby 和 JavaScript 沒有這麼方便。在 Ruby 裡,想知道 `map`、`filter` 等串接過程中某一步的結果,傳統做法是命名新變數再印出來,很麻煩。解決方法是用 Monkey Patch 打開 `Object` class,加一個 `peek` 方法,內容很簡單:把自己印出來再回傳自己,只要三行程式碼。JavaScript 也能做到,改寫 `Array.prototype` 加上 `peek` function,用 `console.log(this)` 印出內容後 `return this`。這樣在 `map`、`reduce`、`filter` 的串接過程中,隨時可以插入 `peek` 偷看中間結果,不用再命名新變數,省下大量除錯時間。
JavaScript 與 Ruby 都支援解構賦值,能幫助收集資料輸入。目前 pipe operator 和 pattern matching 都還在提案階段。Pattern matching 結合解構賦值,可用於分支處理並同時綁定變數,通常以 switch case 形式呈現,Ruby 和 Swift 都已內建相關功能。JavaScript 的 Ramda 是最貼近函數式思維的函式庫,若專案已有 Lodash 或 Underscore 就不需另外引入。Ramda 的函式都是惰性求值,提供 pipe 和 compose 兩種組合方式,還有 curry 功能可讓函式部分套用參數,但使用時務必給予有意義的變數名稱,避免混淆還缺哪些參數。
Ruby 的解構賦值功能較弱,只能用於 Array,pattern matching 雖已加入但仍有改進空間。Lambda 內建的 curry 功能值得善用。Ruby 2.6 引入的 `>>` 和 `<<` 運算子其實就是 pipe 和 compose,但僅適用於 lambda,一般 function 無法使用。`>>` 會依箭頭方向從左到右傳遞,`<<` 則相反。我自己開發了 composel 這個 library,讓你可以串接 lambda 和物件方法,支援 partial application。實作只有 33 行,核心概念是將 method 轉成 lambda 後組合起來再呼叫。這展示了如何在抽象層次上操作物件和 lambda,像樂高一樣組合出有趣的功能。
不管寫什麼程式語言,型別其實一直都在發生,只是有些語言會幫你檢查,有些則完全靠人腦記憶。引進型別系統的代價是程式變得比較難寫,但好處是能在編譯時期就抓出錯誤,讓錯的程式更難被寫出來。Elixir 和 Erlang 使用 Dialyzer 做型別檢查,但它是「樂觀型別檢查」,只要有可能對就放行,所以實用性有限,比較像是文件功能。相較之下,Haskell 的型別系統非常強大,甚至可以把型別當作變數來操作,寫到後來幾乎都在思考型別怎麼組合,只要型別對了,程式能編譯通常就能正確運作。
Macro 是一種 meta-programming 技術,能把程式碼當作資料來操作。程式碼經過編譯後會產生抽象語法樹(AST),而 macro 讓你能直接操作這個樹狀結構。以 Elixir 為例,`1 + 2 * 3` 會被轉成巢狀的 tuple 結構,你可以用 pattern matching 取出運算子和參數,重新組合成新的 AST 再執行。這種能力極其強大,甚至能發明自己的語法,而且在編譯時期決定,沒有執行時間的損耗。但能力越強責任越大,亂用 macro 會讓 debug 變得極痛苦,因為函式可能是動態產生的。我的做法是把 macro 抽成獨立的 library,寫好完整測試,這樣出錯時能清楚分辨問題來源。
Elixir 和 Erlang 的容錯與平行化能力非常強大。它們的 process 不是系統層級的,而是 VM 內部的輕量級單位,開啟成本極低,數萬個 process 同時運作很正常。每個 process 有獨立記憶體,不共享狀態,透過 message passing 溝通。VM 會根據 CPU 數量配置 scheduler,採用搶占式調度,確保沒有 process 能永久霸佔 CPU。搭配 Supervision Tree,當 process 掛掉時可自動重啟或整批重建,維持系統高可用性。這種從 VM 層級強制中斷執行的能力,是 Golang、Rust 等語言做不到的。這讓我們能用更高層次的視角思考系統設計:誰最重要、誰不能死、什麼可以平行化處理。
Elixir 與 Erlang 關係緊密,可直接呼叫彼此的函式庫。透過 ErlPort 和 export,能將 Python、Ruby 甚至 JavaScript 程式碼包裝成 Elixir process,利用 Erlang VM 的搶占式調度和 supervisor 機制管理。Elixir 本身計算速度不快,快是因為平行運算。若需高效能計算,可用 Rustler 整合 Rust,Rust 有記憶體安全保護,不會拖垮整個 VM。Discord 就是用這套組合處理百萬人聊天室的計算。這個技術棧的優勢在於:Elixir 擔任主管角色確保服務穩定,重度計算則委派給 Rust 處理。長遠來看,Elixir 適合作為系統的協調層,搭配 LiveView 等技術發展。
講師介紹
蘇泰安
三年電腦雜誌編輯,十年程式開發經驗。 Elixir.tw 及 RailsGirls Taiwan 共同主辦人。現任企業開發顧問及客座講師。專長為函數式及分散式編程,擅長 Elixir、JavaScript / React、Ruby 及 Haskell。
推薦課程
你可能也會喜歡的學習內容
線上課程
高見龍
AI Coding 實作工作坊
線上課程
PY101
高見龍
為你自己學 Python
線上課程
FE301
廖珀均 aka 奶綠茶