iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0

https://ithelp.ithome.com.tw/upload/images/20250929/20168201BQdm0IAi2y.png

前言

在上一篇初探容器的文章中,留下了一個問題:當面對一組值時,要如何找到一種通用的模式,能可靠地將它們組合為一?

在解答問題之前,先來看看一個更普遍的觀念——組合 (Composition)。之後會介紹的 Functor、Monad、Applicative 等工具,核心都在回答同一個問題:如何在程式中安全且可靠地進行組合。而 Monoid 正是這些組合抽象的基礎。理解 Monoid,能幫助我們更好地看懂 Functional Programming 工具的設計哲學。

舉幾個日常任務來說:加總一個數字陣列、串接一個字串陣列、或合併一系列設定物件。乍看之下,它們毫不相干,但其實背後共享同一種簡單卻強大的結構。在 FP 的世界裡,這個結構有個名字——Monoid。

事實上,我們幾乎每天都在使用 Monoid,只是沒有意識到而已。雖然「Monoid」這個詞聽起來帶點數學的味道(它源自抽象代數與範疇論),但它的本質比名稱要直觀得多。今天就來看看 Monoid 的定義與規則,並理解為什麼它能成為 FP 世界中「組合」的基礎。

Monoid:一個集合與一個符合規則的操作

Monoid 的世界由一個「集合」和一個作用於其上的「二元操作」所構成。
首先,Monoid 的所有活動都發生在一個「集合」(Set)之上,在程式設計的世界裡,我們可以將相同型別的值視為一個集合,簡單回顧一下集合的定義:

集合是一種把若干個元素放在一起的方式,不考慮元素的順序和重複性。集合可以用大括號 {} 來表示,例如 { 2, 4, 6 } 是一個集合,包含了三個元素 2, 4 和 6。集合可以用來描述任何事物的分類或歸納。

我們可以用集合來思考程式設計中的型別,將「同型別的可取值域」來對應集合,例如:

  • 所有 Number 型別的值構成一個集合,集合內會是 {0, 1, 2 ... Max_Number}(假設 Number 都是正整數)
  • 所有 String 型別的值構成一個集合,集合內會是 {'a', 'b', 'c', ...}
  • 所有 Array 型別的值也可以被視為一個集合
  • 所有 Boolean 型別的值構成另一個集合,集合內會是 {true, false}

https://ithelp.ithome.com.tw/upload/images/20250929/20168201xKcKkFAQA5.png
圖 1 同一型別代表一個集合(資料來源: 自行繪製)

這個「單一型別」的約束是 Monoid 世界的基礎,它確保接下來要介紹的操作能夠一致且可預測地進行。

更多集合與型別的說明可參考 用集合思考型別Day02. 「型別」請「集合」 - Type is Set

補充:本文的例子以 JavaScript 情境為主;理論上,請先選定集合 M,再定義其上的二元操作 • 與單位元素 e。

行動:一個用來組合元素的「二元操作」

有了值的集合,我們還需要一種方法來與讓集合內的值彼此互動。這個方法就是「二元操作」(Binary Operation)。它是一個簡單的規則或函式,專門接收來自同一個集合的兩個元素,並將它們組合成第三個元素。  

舉例來說:

  • 對於 Number 集合,加法 + 就是一個二元操作。它接收兩個數字,回傳一個新的數字,例如 3 + 4 會得到新數字 7
  • 對於 String 集合,字串串接 +.concat() 方法就是一個二元操作,例如 "Good" + " Morning" 會得到 "Good Morning"
  • 對於 Array 集合,陣列串接的 .concat() 方法也是一個二元操作,例如 [1, 2].concat([3, 4]) 會得到 [1, 2, 3, 4]

這個操作是 Monoid 的「動詞」,它定義了集合內的元素如何組合彼此。

而這個「二元操作」還需要滿足三個規則,分別介紹如下。

規則 1:封閉性(Closure)

第一條規則是封閉性(Closure),封閉性的定義非常簡單:對於我們集合 M 中的任意兩個元素 ab,執行二元操作 a • b 的結果,必須仍然是集合 M 內的元素。
(可以把 符號想成組合兩個元素的操作)

可以透過一個「混合顏料」的例子來理解。想像你的調色盤就是一個顏色的集合。當你從調色盤上選取兩種顏色(例如紅色和黃色)並將它們混合,你得到的新顏色(橘色)仍然是一種顏色,可以被放回調色盤上。你不會因為混合了兩種顏色而突然得到一個聲音、一個數字或任何不屬於「顏色」這個集合的東西。這就是封閉性。

https://ithelp.ithome.com.tw/upload/images/20250929/20168201qSXh1fohuv.png
圖 2 顏色組合顏色後,仍然會得到一個顏色(資料來源: 自行繪製)

在程式語言的世界中(以 JavaScript 為例),這個特性無處不在:

  • 5 + 3 的結果是 8538 全都是 Number 集合的成員。因此,數字的加法具有封閉性
  • "hello" + " world" 的結果是 "hello world""hello"" world" 和結果 "hello world" 都是 String。因此,字串串接也具有封閉性

封閉性的意義

當一個操作具備封閉性時,它就提供了一種可預測性的保證。我們無需檢查 number + number 的回傳值,因為我們確信它永遠會是一個 number。這種確定性消除了整整一大類的潛在型別錯誤。

更重要的是,由於操作的輸出型別與輸入型別完全相同,這代表運算的結果可以立即被用作下一次相同操作的輸入。這就是實現鏈式操作的關鍵所在,例如 (a • b) • c • d... 。如果沒有封閉性,這個鏈條在第一步之後就會斷裂,因為 (a • b) 的結果可能是一種 c 無法處理的新型別。

規則 2:結合律(Associativity)

結合律(Associativity)的定義是:對於任意三個元素 a、b 和 c,(a • b) • c 的運算結果永遠等於 a • (b • c) 。這代表在一個連續的操作序列中,只要元素的順序不變,我們如何為二元操作的順序分組(也就是括號的位置)並不會影響最終的結果。

來看兩個簡單的例子:

  • 數字加法:(2 + 3) + 4 的結果是 5 + 4 = 9,這與 2 + (3 + 4) 的結果 2 + 7 = 9 完全相同
  • 字串串接:("a" + "b") + "c" 的結果是 "abc",這與 "a" + ("b" + "c") 的結果也完全相同

結合律的意義

結合律的意義遠不止於數學上的優雅。在計算機科學中,它是實現高效能與可擴展性計算的關鍵。它讓我們能將一個龐大的計算任務分解為多個獨立小任務來執行。

假設我們要對一個包含一百萬個數字的陣列進行求和:1 + 2 + 3 +... + 1,000,000。如果嚴格按照從左到右的順序進行,這將是一個漫長的循序處理過程。然而,由於加法滿足結合律,我們可以將這個任務「分而治之」(Divide and Conquer)。例如,我們可以讓一個 CPU 核心計算前半部分 (1 +... + 500,000) 的和,同時讓另一個 CPU 核心計算後半部分 (500,001 +... + 1,000,000) 的和 。最後只需將這兩個中間結果相加,就能得到最終的總和。

https://ithelp.ithome.com.tw/upload/images/20250929/20168201ANP99L7flR.png
圖 3 結合律是平行計算的基礎(資料來源: 自行繪製)

這裡只是簡單以加法來舉例,可以把加法替換成其他複雜運算任務,當我們要執行一個複雜運算時,在單一電腦上計算會耗時很久,但如果可以分別在兩個電腦各運算一部分,兩個電腦可以平行、同時運算,最後再整合起來,這樣就能更省時間,而能夠「分別且平行的運算」的前提,就是這運算需滿足結合律。

我們改變了運算的分組方式,但結合律保證了最終結果的正確性。這種策略是平行處理、分散式計算(例如 MapReduce 框架)甚至是增量計算的理論基礎。因此,結合律是數學上的一項保證,而程式設計的世界參考了這個保證,讓我們能優化效能,同時產出正確的結果。

除了平行處理,結合律還帶來了另一個強大的好處:漸進式累積 (incremental accumulation)。
想像一個需要即時更新的場景,例如一個顯示當日總銷售額的電商後台儀表板。

  • 沒有結合律的作法:每當有一筆新訂單進來,我們可能需要重新撈取資料庫中今天所有的訂單紀錄,然後再次將它們全部加總一次。隨著訂單越多,這個操作會越慢。
  • 符合結合律的作法:我們不必從第一筆開始計算,我們只需要儲存「到目前為止的總金額」,然後當新訂單進來時,將這個「已知的總金額」與「新訂單的金額」相加即可。  

這個看似理所當然的操作,其背後的數學保證正是結合律。它確保了 (前 99 筆訂單總和) + 第 100 筆訂單金額 的結果,與從頭加總 訂單 1 + 訂單 2 +... + 訂單 100 的結果是完全一樣的。

這種模式對於處理串流資料 (streaming data)、事件紀錄 (event logging) 或任何需要隨時間更新狀態的系統至關重要。我們不需要在每次更新時都重新計算整個歷史紀錄,只需將舊的結果與新的資料組合即可,這大大提升了系統的效率和即時性。

規則 3:單位元素(Identity Element)

Monoid 的最後一條法則是必須存在一個特殊的「單位元素」(Identity Element),我們通常用 e 來表示。這個元素的神奇之處在於,當它與集合中的任何元素 a 進行組合時,完全不會改變 a 的值。也就是說,a • e = a 且 e • a = a 。它就像是特定操作下的「空氣」,一個「什麼都不做」的值。

每個 Monoid 都有其專屬的單位元素:

  • 對於數字加法 (+),單位元素是 0,因為任何數字加上 0 都等於它本身(例如 5 + 0 = 5)
  • 對於數字乘法 (*),單位元素是 1,因為任何數字乘以 1 都等於它本身(例如 5 * 1 = 5)
  • 對於字串串接 (+.concat()),單位元素是空字串 "",因為任何字串與空字串串接後都維持不變(例如 "hello" + "" = "hello"
  • 對於陣列合併 (.concat()),單位元素是空陣列(例如 [1, 2].concat([]) 的結果仍然是 [1, 2]

https://ithelp.ithome.com.tw/upload/images/20250929/20168201Xe2rLzazDN.png
圖 4 單位元素就像透明元素(資料來源: 自行繪製)

單位元素的意義

單位元素不僅是個理論上的概念,在實務中,它為我們的程式碼提供了一個安全的起點,同時也是一種強大的容錯保護機制。

它的意義包含以下兩點:
1. 作為沒有東西時的安全預設值/初始值

單位元素為「空集合」的情況提供了一個有意義且邏輯一致的結果。

假設我們要計算一個數字陣列的總和,當陣列為空時,sum([]) 應該回傳什麼?如果我們回傳 nullundefined,程式就必須額外處理特殊情況。但如果我們將空陣列的總和定義為加法操作的單位元素 0,這在邏輯上是完全一致的(「無物之和」即為零),並且函式始終回傳一個 Number 型別的值。這避免了程式潛在的型別錯誤,也與「空物件模式」的設計思想相符。

另舉個實際範例,當我們為 settings 函式的參數定義預設值時,我們就可定義單位元素來避免錯誤,這樣當使用者沒有輸入該參數時,我們的函式運作也不會因為找不到值而出錯。

const settings = (prefix="", overrides=[], total=0) => ...

2. 作為累加器的初始值

單位元素是為累加運算而生的。當我們使用 Array.reduce 這類方法時,initialValue 參數正是單位元素所扮演的角色。它為整個運算提供了一個安全起點。如果我們處理的集合是空的,reduce 會安全地回傳這個 initialValue,而不是拋出 TypeError 錯誤。這解決了當集合為空時,reduce 卻找不到起點的問題。

因此,單位元素為「對零個元素進行操作」這種情況提供了一個有意義且邏輯一致的結果,讓我們的聚合函式更健壯和完備。


補充:Monoid 不要求交換律 (commutativity)。也就是說,a • b 不必等於 b • a。

程式世界中的 Monoid

理解 Monoid 的規則後,我們可能會發現,Monoid 在日常開發中無處不在。這裡會舉例一些隱藏在我們周遭的 Monoid,並為每一個例子明確指出其集合、操作和單位元素。

但必須說明的是,舉例有其局限性,Monoid 是個抽象概念,當我們舉出具體例子時,就侷限了抽象概念的可能性,因此,這些具體實例無法完全代表 Monoid,不過我覺得看越多舉例、越能從中統整出共同模式,藉此能更了解 Monoid 的樣貌。

加法 Monoid (The Sum Monoid)

  • 集合 (Set): Number
  • 二元操作 (Operation): 加法 +
  • 單位元素 (Identity): 0

乘法 Monoid (The Product Monoid)

  • 集合 (Set): Number
  • 二元操作 (Operation): 乘法 *
  • 單位元素 (Identity): 1

這說明了同一個集合(數字)可以根據不同的操作形成不同的 Monoid。

字串 Monoid (The String Monoid)

  • 集合 (Set): String
  • 二元操作 (Operation): 串接 +.concat()
  • 單位元素 (Identity): 空字串 ""

陣列 Monoid (The Array Monoid)

  • 集合 (Set): Array
  • 二元操作 (Operation): 合併 .concat()
  • 單位元素 (Identity): 空陣列 []

只要我們處理的是陣列的合併,而不是元素的修改,它就完美符合 Monoid 的定義。  

布林 Monoid (The Boolean Monoids)

同一個集合({true, false})可透過不同的操作和單位元素,構成兩個截然不同的 Monoid。  

「全真」Monoid (Conjunction / "All" Monoid)

  • 集合 (Set): Boolean
  • 二元操作 (Operation): 邏輯運算 && (AND)
  • 單位元素 (Identity): true

單位元素之所以是 true,是因為對於任何布林值 xx && true 的結果永遠是 x。範例如下:

const a = false;
console.log(a && true); // false
console.log(true && a); // false

const b= true;
console.log(b && true); // true
console.log(true && b); // true

「任一真」Monoid (Disjunction / "Any" Monoid)

  • 集合 (Set): Boolean
  • 二元操作 (Operation): 邏輯運算 || (OR)
  • 單位元素 (Identity): false

單位元素之所以是 false,是因為對於任何布林值 xx || false 的結果永遠是 x。範例如下:

const a = false;
console.log(a || false); // false
console.log(false || a); // false

const b= true;
console.log(b || false); // true
console.log(false || b); // true

函式組合 Monoid (The Function Composition Monoid)

這是更抽象但對 Functional Programming 概念非常重要的例子。

  • 集合 (Set): 所有型別簽章相同的函式,例如,所有從 A 映射到 A 的函式(A -> A)
  • 二元操作 (Operation): 函式組合。給定兩個函式 f 和 g,它們的組合是 x => f(g(x))
  • 單位元素 (Identity): 恆等函式(Identity Function),即 x => x

任何函式與恆等函式組合,結果都等於該函式本身,符合單位元素的定義。


下表總結了上述常見的 JavaScript Monoid。將這些具體範例並列,可以讓我們更清晰地看到它們背後共享的抽象結構。即使它們處理的資料型別和執行的操作各不相同,但「集合、操作、單位元素」這一核心模式始終如一。

Monoid 名稱 集合 (型別) 二元操作 (a • b) 單位元素 (e)
加法 (Sum) Number a + b 0
乘法 (Product) Number a * b 1
字串 (String) String a.concat(b) ""
陣列 (Array) Array a.concat(b) []
全真 (All) Boolean a && b true
任一真 (Any) Boolean a || b false
函式組合 (Endo) Function (a -> a) x => f(g(x)) x => x

補充:還有 Min/Max Monoid,分別使用 Infinity / -Infinity 作為單位元素。

https://ithelp.ithome.com.tw/upload/images/20250929/20168201bPfRcee792.png
圖 5 Monoid 三要素示意圖(資料來源: 自行繪製)

Monoid 的實際應用:Array.reduce()

在 JavaScript 開發者眼中,Array.reduce() 是一個用於將陣列「歸納」為單一值的強大工具。reduce 方法的設計,可說是 Monoid 模式的完美體現。
我們可以建立一個清晰的對應關係:

  • reduce 方法接收的回呼函式 (accumulator, currentValue) =>...,正是我們針對集合內元素的二元操作
  • reduce 方法的第二個可選參數 initialValue,正是我們的單位元素
  • 被操作的陣列本身,提供了來自我們集合的元素

當我們使用 reduce 時,實際上是在告訴 JavaScript:「這裡有一個陣列(集合的元素),請使用這個函式(二元操作)和這個初始值(單位元素),將它們全部摺疊成一個值。」

// 加法 Monoid
const numbers = [2, 1, 3, 4];
const sum = numbers.reduce((acc, val) => acc + val, 0);

// 字串 Monoid
const words = ['Monoid', ' ', 'is', ' ', 'awesome'];
const sentence = words.reduce((acc, val) => acc + val, ''); // 單位元素是 ""
console.log(sentence); // 輸出: "Monoid is awesome"

當我們提供單位元素作為 initialValue 時,reduce 就能安全地處理空陣列:

const emptyNumbers = [];
const sumOfEmpty = emptyNumbers.reduce((acc, val) => acc + val, 0);
console.log(sumOfEmpty); // 輸出: 0,而不是錯誤

這證明了 Monoid 不僅是一個理論概念,它還為我們日常使用的工具提供了健壯、可預測的行為模式。

再認識一點 Monoid

接下來要透過反例來更理解 Monoid 的規則,再稍微介紹 Monoid 和其他設計模式的關聯。

什麼不是 Monoid?

讓我們看看為什麼某些常見的操作無法構成 Monoid。

減法:違反「結合律」與「單位律」

  • 違反結合律:我們知道 (10 - 5) - 2 的結果是 3,但 10 - (5 - 2) 的結果卻是 7。
    • 後果是什麼?:這代表運算的結果完全取決於計算的順序。我們之前提到的「分而治之」或平行計算等優化技巧將完全失效,因為不同的分組會產生不同的結果,這讓組合行為變得不可預測且不可靠。
  • 違反單位律:雖然 x - 0 = x 成立,但 0 - x 卻不等於 x。  
    • 後果是什麼?:這代表減法沒有一個中性的「起點」。如果我們想用 reduce 處理一個空陣列的減法,我們找不到一個安全的初始值可以傳入,這使得處理邊界情況(如空集合)變得困難且容易出錯。

除法:違反「封閉性」與「結合律」

  • 違反封閉性:如果我們的集合是整數,那麼 5 / 2 的結果是 2.5,它不再是整數了。
    • 後果是什麼?:reduce 的核心機制——將上一步的結果作為下一步的輸入——在這裡會因為型別不匹配而中斷。組合的鏈條在第一步就斷裂了,因為後續的操作可能無法處理 2.5 這種非整數的型別。
  • 違反結合律:除法同樣不滿足結合律:(16 / 4) / 2 是 2,而 16 / (4 / 2) 是 8。

由反例可知,Monoid 的規則不是隨意設定的學術要求,它們共同確保了一種穩定、可預測且高度靈活的組合行為,是許多強大程式設計模式能成立的基礎。

Monoid 與設計模式

許多我們聽過的設計模式,其核心就是 Monoid。

複合模式 (Composite Pattern)

複合模式讓我們將物件組合成樹狀結構,並以統一的方式對待單一物件和物件組合。這與 Monoid 的思想相同。一個介面或類別可以形成一個 Monoid,前提是其公開方法的回傳型別本身就是 Monoid。  

  • 組合操作:將兩個元件(Component)組合成一個更大的複合元件(Composite)
  • 單位元素:一個「空的」或「不做任何事」的元件,例如一個不包含任何子元件的 Composite 物件
  • 範例:一個處理訂單的系統,Order 物件的 getPrice() 方法回傳一個數字(Sum Monoid)。一個 CompositeOrder(包含多個子訂單)的 getPrice() 就是將所有子訂單的價格相加。一個空的 CompositeOrder 的價格就是 0(Sum Monoid 的單位元素)

空物件模式 (Null Object Pattern)

空物件模式會提供一個具有預設「無操作」行為的替代物件,來避免處理 null 參考。這個「無操作」的物件,其實就是 Monoid 的單位元素。  

  • 組合操作:取決於具體情境,例如字串串接
  • 單位元素:NullObject 本身。它在組合中不會產生任何效果
  • 範例:一個函式預期回傳一個字串,但在某些情況下可能沒有結果。與其回傳 null 並迫使呼叫端進行 if (result!= null) 檢查,不如回傳一個 NullString 物件,其 toString() 方法回傳 ""(String Monoid 的單位元素)。無論結果如何,呼叫端都可以安全地進行字串串接

可組合的命令 (Composable Commands)

命令模式將請求封裝成一個物件。當這些命令物件可以被組合時,它們就形成了一個 Monoid。  

  • 集合:所有命令物件
  • 組合操作:將兩個命令組合成一個新的命令,該命令會依序執行這兩個命令
  • 單位元素:一個「無操作」(No-op)命令,它什麼也不做

小結

Monoid 本質上是由三個簡單規則所定義的組合模式:

  • 規則一 (封閉性):你有一個東西的集合,以及一種將其中兩個東西組合起來的方法,而組合的結果永遠是同類型的東西。
  • 規則二 (結合律):當組合多個東西時,你先組合哪一對並不重要,只要順序不變,結果都一樣。
  • 規則三 (單位元素):集合中有一個特殊的「中性」或「零」元素,任何東西跟它組合,都會維持原來那個東西的樣子。

https://ithelp.ithome.com.tw/upload/images/20250929/20168201VGIZs6AKcV.png
圖 6 Monoid 的定義(資料來源: The Functional Programmer's Toolkit - Scott Wlaschin)

那這跟 Functional Programming 有什麼關係?為什麼要花一整篇文章來介紹它?

Monoid 是 FP 世界中「組合」的基礎。FP 的核心思想,就是將大型、複雜的問題,分解成微小、單純、可預測的函式,然後再像樂高積木一樣將它們組合起來。當一個複雜運算問題可以輕易的拆解與組合,我們的注意力就可以更集中在微小的運算函式上,確保每個小的函式都運作正常,最後再組合出大型且複雜的運算邏輯。

而 Monoid 正是為這種組合行為提供了一套最基本、最可靠的「遊戲規則」。它告訴我們,只要滿足這三條簡單的法則,我們就可以安心地、大規模地組合事物,無論是數字、字串,甚至是函式本身。  

介紹 Monoid 的目的,是為了建立一個關鍵的心智模型:在 FP 的世界裡,強大的抽象工具都必須遵守簡單的法則。當我們接下來要探索 Functor、Applicative 和 Monad 時,會發現它們也都有各自的規則,其精神與 Monoid 一脈相承。它們都是在玩「組合」這件事,只是組合的對象和情境不同。


現在我們瞭解了 Monoid 的威力,透過 結合律單位元素律,它為我們提供了一個穩健且通用的介面,來組合相同型別的值。這就是昨天文章第一個問題的答案:一種有原則的聚合之道

但還有第二個問題:該如何將一個函數應用於容器內部的值?
Monoid 處理的是「值的組合」 (T, T) -> T,而接下來我們需要的是一個能將函數 (A -> B) 套用到容器 F<A> 上,並得到 F<B> 的機制。這機制就是 Functor,會在下篇文章介紹。

Reference


上一篇
[Day 14] 初探容器 (Container)
下一篇
[Day 16] Functor:操作容器內的值
系列文
30 天的 Functional Programming 之旅18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言