
前面幾篇文章介紹了一些 FP 世界中的容器工具如 Functor、Monad、Applicative 等,其實還有很多沒有介紹到,例如 Reader、State 等容器,轉換容器的自然轉換(natural transformations),和重新排列型別順序的 Traversable 等,不過因為篇幅關係,這裡想先告一段落,統整目前介紹的 FP 工具以及他們和 Monoid 的關聯,最後會發現從函式到運算流程,都能以 Monoid 的方式被組合起來。
其他進一步的 FP 容器和轉換方法就有興趣的人再去了解看看囉!
很簡單的提及一點點範疇論,範疇論(Category Theory)常被視為一門高度抽象的數學分支,但對於程式設計而言,可以將其理解為「關於組合的數學」。它關心的不是事物的內部細節,而是事物之間如何關聯與組合。
在程式設計的語境下,範疇論的核心概念可以這樣對應理解:
User、Order 等型別,都是範疇中的物件。(s: string) => s.length 就是一個從 string 型別到 number 型別的態射。f: A -> B 和 g: B -> C,我們可以將它們組合成一個新的函式 h: A -> C,其定義為 h(x) = g(f(x))。範疇論的一個基本要求是,這種組合必須滿足結合律(associativity),即 (h • g) • f 與 h • (g • f) 是等價的。id: A -> A,它不做任何事,僅僅傳回輸入值。例如 const id = (x) => x。它在函式組合中的作用類似於數字 0 在加法中的角色。在程式設計世界裡,範疇論為我們提供了一套語言,讓我們能精確地描述這些關於「組合」的模式,無論組合的是數值還是整個運算流程。
在 FP 世界裡,我們會參考數學的抽象代數結構來讓程式結構更穩定、如數學般可預測。這些代數結構透過定義集合及其上的運算規則,提供了一套強大的抽象工具。以下是幾個比較常被提及的代數結構:
Magma 是最基礎的代數結構。它由一個非空集合 S 和一個定義在 S 上的二元運算 • 組成。
S 中任意兩個元素 a 和 b,其運算結果 a•b 也必須在 S 中。簡言之,只要有一組東西,以及一種能將其中任意兩個東西組合起來,並產生出同一組東西的運算,就構成了一個 Magma。這確保了運算不會產生集合之外的結果。
Semigroup 在 Magma 的基礎上,增加了一個結合律 (Associativity) 的要求。
•:同 Magma。S 中任意三個元素 a、b、c,運算的順序不影響結果,即 (a•b)•c=a•(b•c)。Semigroup 允許我們安全地「串聯」多個運算,因為無論我們如何分組,最終的結果都會相同。
Monoid 在 Semigroup 的基礎上,再增加了一個單位元素 (Identity Element) 的要求。
S 與二元運算 •:同 Semigroup。S 中存在一個特殊元素 e,對於 S 中任意元素 a,都有 a•e=a 且 e•a=a。單位元素就像一個「無作用」的元素,它在運算中不會改變其他元素的值。
Group 在 Monoid 的基礎上,再增加了一個逆元素 (Inverse Element) 的要求。
S 與二元運算 •:同 Monoid。S 中任意元素 a,都存在一個元素 $a^{-1}$,使得 $a•a^{-1}=e$ 且 $a^{-1}•a=e$ (其中 e 是單位元素)。
圖 1 逆元素示意圖(因為 iThome 無法表示 LaTeX 數學符號,只好用圖表示)(資料來源: 自行繪製)
逆元素的概念可想成是「可撤銷的操作」。對於元素 a,存在一個元素 $a^{-1}$,使得 $a•a^{-1}$ 的結果等於單位元素 $e$。
整數與加法操作就是一個 Group,對於任何整數 n,它的逆元素是 -n,因為 n + (-n) = 0(加法的單位元素)。
然而,我們常用的字串串接就不是 Group。你可以將 "Hello" 和 " World" 串接起來,但你無法輕易地「反向串接」或「撤銷」這個操作。
簡單小結這些代數結構的遞進關係:

圖 2 Magma、Semigroup、Monoid 與 Group 的關係示意圖(資料來源: 自行繪製)
首先從 Semigroup 來理解,一個 Semigroup 由兩部分構成:一個集合(在程式中對應為一個型別 A),以及一個作用於該集合的二元運算(binary operation),這個運算通常被命名為 concat,其型別簽章為 concat: (A, A) -> A。這代表該運算接收兩個型別為 A 的值,經過組合後,回傳一個同樣型別為 A 的值。這個特性被稱為封閉性(closure)。
另外,這個二元運算還需滿足結合律,也就是對於任何 a、b、c,(a.concat(b)).concat(c) 的結果必須與 a.concat(b.concat(c)) 完全相同。
關於封閉性與結合律對於程式設計的意義,已在「初探 Monoid」介紹過,這裡不再贅述。
用 JavaScript 來為加法實作一個 Semigroup Sum 看看:
const Sum = x => ({
  x,
  concat: other => Sum(x + other.x)
});
與另一個 Sum 進行 concat,永遠會回傳一個新的 Sum:
Sum(1).concat(Sum(3)) // Sum(4)
Sum(4).concat(Sum(37)) // Sum(41)
不過要補充的是,Sum 不是「pointed functor」,因為 Sum 不能 map,Sum 只能處理 number -> number,無法轉為其他型別,且 number 並不是一個包著另一個值的容器。
再看看其他型別的 concat 方式:
// 數值相關的
const Product = x => ({ x, concat: o => Product(x * o.x) });
const Min = x => ({ x, concat: o => Min(x < o.x ? x : o.x) });
const Max = x => ({ x, concat: o => Max(x > o.x ? x : o.x) });
// 布林值
const Any = x => ({ x, concat: o => Any(x || o.x) });
const All = x => ({ x, concat: o => All(x && o.x) });
// 使用方式
Any(false).concat(Any(true)) // Any(true)
Any(false).concat(Any(false)) // Any(false)
All(false).concat(All(true)) // All(false)
All(true).concat(All(true)) // All(true)
[1,2].concat([3,4]) // [1,2,3,4]
"hello monoi".concat("d") // "hello monoid"
也可以自己寫一個簡單的 Map 的 Semigroup,並實作 concat 方法來合併物件內容,這裡的 concat 邏輯先簡單寫(相同的 key 會後蓋前),如果要每個 key 內的值都能合併,邏輯會再稍微複雜點,有興趣的可再自己實作看看。
const Map = obj => ({
  obj,
  concat: other => Map({ ...obj, ...other.obj }),
});
// 使用方式
Map({day: 'night'}).concat(Map({white: 'nikes'})) // Map({day: 'night', white: 'nikes'})
為何我們要為這些數值、字串或布林值定義 concat 方法呢? 用 semigroup 來思考程式有什麼用?
定義這些型別的基本元素和 concat 方法,讓我們可以統一介面,雖然他們是不同型別(數字、字串、陣列、布林、物件…),但我們都能用「組合」的思維來統一處理,而「組合」的思維有助於我們處理一些程式世界常見的模式:
用抽象的數學來思考程式,讓我們得以對介面程式設計(program to an interface),並以定律作為正確性的保證。
當容器裡的值本身是 semigroup,就能推導出整個容器也是 semigroup。
我們過去介紹的各種 Functor 如 Maybe、Either、IO 等,其實同時也能實作 Semigroup。

圖 3 當容器裡的值本身是 semigroup,就能推導出整個容器也是 semigroup(資料來源: 自行繪製)
舉例來說,我們可以定義 Identity 這個 Functor 的 concat 方法(Identity 就是以前所稱的 Container)。
Identity.prototype.concat = function(other) {
  return new Identity(this.__value.concat(other.__value))
}
// concat 的行為:把兩個 Identity 的值合併
Identity.of(Sum(2)).concat(Identity.of(Sum(3))) // Identity(Sum(5))
Identity.of(4).concat(Identity.of(1)) // TypeError: this.__value.concat is not a function
從上範例可看出,Identity 這容器是否是 semigroup、是否可順利執行 concat,取決於 __value 是否是 semigroup。
因為 Sum(2) 有定義 .concat,所以能運作(Sum 是 semigroup),而 4 (原始數字) 沒有 .concat 方法,因此會噴錯(4 是原始數字不是 semigroup)。
由此可知,容器是否是 semigroup,取決於內部值是否是 semigroup。
Right(Sum(1)).concat(Right(Sum(4))) // Right(Sum(5))
Right(Sum(2)).concat(Left('some error')) // Left('some error')
如果組合時遇到 Right,那就正常合併,因此 Right(Sum(1)).concat(Right(Sum(4))) 的結果是 Right(Sum(5))
如果遇到 Left,就直接保留錯誤,不會繼續合併。
Task.of([1,2]).concat(Task.of([3,4])) // Task([1,2,3,4])
Task 代表非同步結果,合併時會把結果(陣列)串接起來。在定義 Task 的 concat 方法時,我們可用 semigroup 規則安全合併。
利用 semigroup 的規則,我們可以疊加不同容器,再進行組合。
// --- 底層 Semigroup:Map(其實就是物件) 與 Array ---
const SMapRHS = { concat: (a, b) => ({ ...a, ...b }) };    // 右邊覆蓋
const SArray  = { concat: (a, b) => a.concat(b) };
// --- Maybe ---
const Just = (x) => ({ _tag: 'Just', value: x });
const Nothing = { _tag: 'Nothing' };
const isJust = (m) => m._tag === 'Just';
const SMaybe = (S) => ({
  concat: (ma, mb) =>
    isJust(ma) && isJust(mb) ? Just(S.concat(ma.value, mb.value))
    : isJust(ma) ? ma
    : isJust(mb) ? mb
    : Nothing
});
// --- IO:包住 thunk 的容器 ---
const IO = (thunk) => ({
  run: thunk,
  map: (f) => IO(() => f(thunk())),
});
const SIO = (S) => ({
  concat: (ia, ib) => IO(() => S.concat(ia.run(), ib.run()))
});
// --- Task:這裡用 Promise 當 Task ---
const Task = (thunk) => ({
  run: () => thunk(),
  map: (f) => Task(() => Promise.resolve(thunk()).then(f)),
});
const STask = (S) => ({
  concat: (ta, tb) => Task(async () => S.concat(await ta.run(), await tb.run()))
});
// -------------------- 範例開始 --------------------
// 範例 1:IO(Either(Map)):讀表單資料 → 驗證
// 「先驗證,再合併」,Left 直接傳遞
const Right = (x) => ({ _tag: 'Right', right: x });
const Left  = (e) => ({ _tag: 'Left', left: e });
const isRight = (e) => e._tag === 'Right';
const SEither = (S) => ({
  concat: (ea, eb) =>
    isRight(ea) && isRight(eb)
      ? Right(S.concat(ea.right, eb.right)) // 兩個 Right 才合併
      : isRight(ea)
        ? eb  // 左邊 Right、右邊 Left -> 回 Left(短路)
        : ea  // 左邊 Left -> 回 Left(短路)
});
// formValues :: Selector -> IO(Map)
const formValues = (sel) =>
  IO(() => (sel === '#signup'
    ? { username: 'andre3000' }
    : sel === '#terms'
    ? { accepted: true }
    : {}));
// validate :: Map -> Either Error Map
const validate = (m) =>
  m.accepted === false ? Left('must accept terms') : Right(m);
// IO(Either(Map)) 的 semigroup:由內部 Map 的合併規則一路提升
const SIOEitherMap = SIO(SEither(SMapRHS));
// OK:兩個表單都驗證成功 → 內部 Map 合併
const ok = SIOEitherMap.concat(
  formValues('#signup').map(validate),
  formValues('#terms').map(validate)
).run();
// => Right({ username:'andre3000', accepted:true })
// BAD:其中一邊變成 Left → Left 直通
const bad = SIOEitherMap.concat(
  formValues('#signup').map(validate),
  IO(() => Left('one must accept our totalitarian agreement'))
).run();
// => Left('one must accept our totalitarian agreement')
console.log(ok, bad);
// 範例 2:Task(Array):兩個非同步請求並行 → 結果用陣列 semigroup 合併
const serverA = { get: () => Task(() => Promise.resolve(['friend1'])) };
const serverB = { get: () => Task(() => Promise.resolve(['friend2'])) };
const STaskArray = STask(SArray);
STaskArray.concat(serverA.get('/friends'), serverB.get('/friends'))
  .run().then(console.log);
// => ['friend1','friend2']
// 範例 3:Task(Maybe(Map)):載入兩組設定(可能有缺)
//(有的才合、沒有就跳過)
const loadSetting = (key) => Task(async () => {
  if (key === 'email')   return Just({ backgroundColor: true });
  if (key === 'general') return Just({ autoSave: false });
  return Nothing;
});
const STaskMaybeMap = STask(SMaybe(SMapRHS));
STaskMaybeMap.concat(loadSetting('email'), loadSetting('general'))
  .run().then(console.log);
// => Just({ backgroundColor:true, autoSave:false })
以上程式有三個組合的範例:
IO(Either(Map)) 會讀取表單資料,再驗證,然後合併結果,如果成功就順利組合,失敗就短路回傳錯誤訊息Task(Array) 會從兩個伺服器抓朋友清單,再合併成一個結果Task(Maybe(Map)) 會載入多個設定檔並合併雖然這些也可以用 chain 或 ap 來組合,但用 semigroup 來思考會更簡潔。
由此可知,不同層的容器可以疊加,因為底層值本身都是 semigroup。
如果一個資料結構的每個欄位本身都是 semigroup,那整個資料結構也自然構成 semigroup。如果我們能 concat 零件,就能 concat 整體。
假設有個 UserStats 資料結構,裡面有 posts 貼文數量、tags 使用過的標籤和 longestSession 最長連續使用時間這三個欄位,且這三個欄位各自都是 semigroup,那 UserStats 本身也成為一個 semigroup,UserStats 本身也可以 concat。
// 底層 semigroups
const Sum = (x) => ({
  x,
  concat: (other) => Sum(x + other.x),
  toString: () => `Sum(${x})`
});
const Max = (x) => ({
  x,
  concat: (other) => Max(Math.max(x, other.x)),
  toString: () => `Max(${x})`
});
// UserStats 資料結構
const UserStats = (posts, tags, longestSession) => ({
  posts,          // Sum
  tags,           // Array
  longestSession, // Max
  concat: (other) =>
    UserStats(
      posts.concat(other.posts),
      tags.concat(other.tags),
      longestSession.concat(other.longestSession)
    )
});
// 測試
const stats1 = UserStats(Sum(5), ['fp', 'monoid'], Max(120));
const stats2 = UserStats(Sum(3), ['functor'], Max(90));
const combined = stats1.concat(stats2);
console.log(combined);
// UserStats { posts: Sum(8), tags: ['fp','monoid','functor'], longestSession: Max(120) }
假設我們定義一個事件流的型別 Stream,這個事件流可以想像成一個持續不斷、隨時間發生變化的資料流,其中的每一筆資料都代表一個發生的事件,例如使用者點擊、IoT 裝置回報的感測器讀數、金融交易紀錄等。
在前端應用中,常見的事件流處理函式庫例如 RxJS,RxJS 會在之後介紹,這裡想說明的是,事件流 Stream 也是可以透過 concat 聚合的。而 RxJS 組合事件流的概念也與此有關。
const $ = (sel) => document.querySelector(sel);
// 來源:click 與 Enter
const submitStream = Stream.fromEvent('click', $('#submit'));
const enterStream  = Stream.fromEvent('keydown', $('#myForm'))
  .filter(e => e.key === 'Enter');
// 合併來源 → 統一處理
const submitFlow =
  submitStream
    .concat(enterStream)      // ⬅️ 合併事件流
    .map(e => {
      e.preventDefault();     // 避免表單跳頁
      return $('#username').value; // 當下 input 的值
    })
    .map(submitForm);         // 提交表單的處理邏輯
我們定義了 click 事件觸發的事件流 submitStream,以及 enter 按鍵觸發的事件流 enterStream,然後透過 concat 將事件流合併,這樣不論是 click 觸發的事件還是 enter 觸發的事件,都會匯聚在一起,並執行提交表單的處理邏輯。
這裡聚焦在容器型別 Stream 的 concat 範例,如何 subscribe 事件流來讓整體可運作,就先不細部解說,會在 RxJS 篇章再來說明~不過還是補上可運行的程式範例連結,有興趣的可參考看看。
從上面範例可看出,我們可以把事件串流合併成新的串流,不過合併的前提是,事件串流的內部值也要是 semigroup,這樣才能順利合併內容。
每個型別可以定義不同的合併方式,舉例來說,Task 的合併方式可以是:
具體的合併方式,就依照實際應用的需求來決定。如果想更了解如何選擇合併方式,可再看看 Alternative interface,它實作了一些方案,重點聚焦於「選擇」而不是「層疊組合」。(附上 fp-ts 的Alternative 型別連結)
雖然 Semigroup 提供了組合的方式,但在某些情況下,它還有些不足,假設有個聚合操作,例如加總一個數字列表。如果列表是 [1, 2, 3],我們可以輕易地使用 Semigroup Sum 來得到 6。但如果列表是空的 [] 呢?Semigroup 並沒有告訴我們如何處理這種「無物可合」的情況。這正是 Monoid 登場的時機,也是我們熟悉的單位元素出場的時候。
在「初探 Monoid」的文章中已經介紹過什麼是 Monoid,不過了解 Semigroup 後,再看一次 Monoid 定義會更了解其意義:
一個 Monoid 就是一個帶有「單位元素 (Identity Element)」的 Semigroup。
這個單位元素通常稱為 empty,它必須遵守兩條定律:
empty.concat(a) 的結果必須等於 a
a.concat(empty) 的結果必須等於 a
簡單來說,單位元素就是一個在組合操作中「什麼都不做」的值。它提供了一個安全的起始點。
Array.empty = () => []
String.empty = () => ""
Sum.empty = () => Sum(0)
Product.empty = () => Product(1)
Min.empty = () => Min(Infinity)
Max.empty = () => Max(-Infinity)
All.empty = () => All(true)
Any.empty = () => Any(false)
單位元素對程式設計的意義已經在初探 Monoid說明過,這裡不再重複。
fold:有預設值的 reduce初探 Monoid 文章有提到,reduce 方法可說是 Monoid 模式的完美體現,二元運算函數對應到 concat,初始值則對應到 empty。而如果我們沒有給 reduce 初始值 initialValue 的話,就會出現錯誤,由此也可看出單位元素的重要性。
為了讓 reduce 能更安全的被使用,可定義一個安全版的 reduce,強制一定要傳入初始值(即 Monoid 的 empty),這個安全版的 reduce 可命名為 fold:
// reduce :: (b -> a -> b) -> b -> [a] -> b
// fold :: Monoid m => m -> [m] -> m
const fold = reduce(concat)
fold 接收一個 Monoid 的 empty 作為初始值(初始的 m),然後再壓縮一個 Monoid 陣列 [m] 得到最後的值,這樣做的好處是,即使陣列是空的,也能安全回傳單位元素。
fold 的使用範例以下看一些 fold 的使用範例~
fold(Sum.empty(), [Sum(2), Sum(1)]) // Sum(3)
fold(Sum.empty(), []) // Sum(0)
fold(Any.empty(), [Any(false), Any(true)]) // Any(true)
fold(Any.empty(), []) // Any(false)
fold(Either.of(Max.empty()), [Right(Max(3)), Right(Max(21)), Right(Max(11))]) 
// Right(Max(21))
fold(Either.of(Max.empty()), [Right(Max(3)), Left('error retrieving value'), Right(Max(11))]) 
// Left('error retrieving value')
在 Either 的範例中,可看到如果是 reduce 所有 Right,最後會取最大值 Right(Max(21)),但如果中間有 Left,就會直接傳遞錯誤,不繼續合併。
有些 Semigroup 無法定義一個合理的 empty 值,例如 First 這個型別:
const First = x => ({ 
  x, 
  concat: other => First(x) 
})
First(x) 的意思是保留第一個值,不管後面 concat 什麼,都回傳最初的那個。
實際應用的情境例如為一筆新資料定義 id,不管後面如何整合,都不該覆蓋或合併掉原先定義的 id 值。First 的整合範例如下。
Map({
  id: First(123), 
  isPaid: Any(true), 
  points: Sum(13)
}).concat(
  Map({
    id: First(2241), 
    isPaid: Any(false), 
    points: Sum(1)
  })
)
// 結果: Map({id: First(123), isPaid: Any(true), points: Sum(14)})
由上可知,對於 id 欄位,First(123) 和 First(2241) 整合後只會得到第一個值 First(123)。
而 First 是無法有 empty 元素的,可以想想看,如果要定義 First.empty(),應該回傳什麼?
這沒有合理答案,因為「第一個值」必須來自實際資料,不可能從空值開始。
並非所有 Semigroup 都能成為 Monoid,但這並不代表這種 Semigroup 沒有用,因為在實務上仍有應用情境,例如 First 可用在使用者註冊時的原始 ID 值,或用在處理設定檔的合併,當遇到多個 config 檔案時,First 代表「不論後面的設定是什麼,永遠取第一個載入的值」。
目前為止,我們已經看到 Monoid 如何組合資料:數字相加 (Sum)、布林值判斷 (All)、甚至合併整個 UserStats 物件。我們也看到,只要容器內的值是 Monoid,整個容器 F<Monoid> 也能成為 Monoid。
我們討論了很多「組合」,那「組合」這個行為本身,是否也能形成一個 Monoid 呢?
可以,而這正是 Monoid 如此強大的原因,它不僅僅是關於資料的模式,更是關於運算與行為的模式。接下來會看到,FP 世界中核心的三個概念——函式組合、Monad 和 Applicative——其本質都可以用 Monoid 來詮釋。
函數式程式設計最基礎的運算就是函式組合,來看看為何 g(f(x)) 這樣的程式碼可滿足 Monoid 的定義。
a -> a。例如 (x: number) => x + 1。concat):運算就是 compose 函式。compose(g, f) 會回傳一個新的函式 x->g(f(x))。這個新函式依然是一個 a -> a 的 Endomorphism,滿足封閉性。empty):單位元素是 id 函式,const id = x => x。可建立一個名為 Endo 的 monoid:
const Endo = run => ({
  run,
  concat: other =>
    Endo(compose(run, other.run))
})
Endo.empty = () => Endo(identity)
Endo:包裝一個函數 run
concat:透過 compose 來組合兩個函數(維持輸入輸出型別一致),因為它們都是相同的型別,所以可以用 compose 來 concat,且型別總是對得上(上一個的輸出型別與下一個需要的輸入型別相同)Endo.empty():單位元就是恆等函數 (identity)使用範例如下:
// thingDownFlipAndReverse :: Endo [String] -> [String]
const thingDownFlipAndReverse = fold(
  Endo(() => []),
  [Endo(reverse), Endo(sort), Endo(append('thing down'))]
)
thingDownFlipAndReverse.run(['let me work it', 'is it worth it?'])
// ['thing down', 'let me work it', 'is it worth it?']
這程式建立了一個 fold 函式,將多個 Endo 函數依序組合(compose):
reverse:反轉陣列sort:排序陣列append('thing down'):在陣列後面加上 'thing down'
並且設立初始值 Endo(() => []),若沒有元素,回傳空陣列,最後執行時,compose 會由右到左執行,先 append('thing down') 再排序,最後反轉,輸出新字串陣列。
compose(h, compose(g, f)) 等於 compose(compose(h, g), f)。結合律成立。compose(f, id) 結果等於 f,compose(id, f) 結果等於 f。單位律成立。滿足所有定律,因此可以將函式組合定義為一個 Monoid。
我們之所以能夠安心地建立函式處理管線 (pipeline),並隨意重構 h(g(f(x))) 這樣的程式碼,其背後的數學保證正是 Monoid 定律。
「Monad is a Monoid in the Category of Endofunctors」這句話的解釋在之前 [Day 22] Monad 入門 (2):核心概念與定律 有提到過,今天更認識 Endofunctors 後,再來看看這是什麼意思,也許會有不同的理解。
我們可分兩種層次的理解方式,第一種從程式設計角度的「串接運算」出發,第二種則回歸更根本的範疇論定義。
Monad 為那些「回傳 Monad 的函式」提供了一種符合 Monoid 定律的組合方式。
回想一下我們在 Monad 文章中學到的 chain (或 flatMap)。它的作用就是將一個 M(a) 和一個運算函式 a->M(b) 組合起來。這類 A → M(b) 形式的函式,代表著「接收一個普通值,回傳一個帶有上下文(如 Maybe、Task)的值」,它們被稱為 Kleisli arrow。
Monad 的本質,就是定義如何組合這些 Kleisli arrows。
A->M(B) 形式的函式。compose,稱為 kleisliCompose (在 Haskell 中是 >=>)。它接收兩個 Kleisli arrows,f:A->M(B) 和 g:B->M(C),並將它們組合成一個新的 Kleisli arrow h:A->M(C)。這個組合的內部實現就是 chain。of (或 pure, return) 函式,型別是 A->M(A)。Monad 的三條定律,正是 Monoid 定律在 Kleisli arrow 組合這個情境下的具體體現。
kleisliCompose(h, kleisliCompose(g, f)) 等於 kleisliCompose(kleisliCompose(h, g), f)。對應到 Monad 的結合律意思就是 (m.chain(f)).chain(g) 等於 m.chain(x => f(x).chain(g))。kleisliCompose(f, of) 和 kleisliCompose(of, f) 都等於 f。這對應到 Monad 的左右單位律。Monad 將 Monoid 這個強大的組合模式,應用於帶有上下文的運算流程中。當我們寫下 getUser(id).chain(getPostsByUser) 時,我們真正在做的不是在合併兩個 Task 值,而是在合併兩個「產生 Task 的動作」。
Monad 提供了一個符合 Monoid 定律的、安全可靠的方式來串聯這些動作。
如果以範疇論視角來看 Monad,可看一下在 Endofunctors 這個特殊範疇內的元素:(一個 Endofunctor 指的是像 Maybe 或 Array 這樣,能將一個型別 A 包裹成 F(A),且兩者都存在於同一個型別系統中的 Functor)。
forall a. F(a) -> G(a) 的函式,它能在不改變內部值的結構下,轉換容器的型別。來看看 Monoid 如何在這個「Endofunctors 範疇」中定義:
join 。它是一個自然轉換,型別為 M(M(A)) → M(A)。它的作用是將兩層巢狀的 Monad 壓平成一層。of 或 return。它也是一個自然轉換,型別為 A → M(A) (更精確地說是從 Identity functor 轉換)。它的作用是將一個普通值放入 Monad 這個「單位容器」中。這兩種視角是完全等價的,因為 chain (或 bind) 和 join 可以互相定義:
m.chain(f)  等價於 join(map(f, m))
join(m) 等價於 m.chain(id)
Monad 的定律無論是用 chain 還是 join 來理解,最終都指向 Monoid 的結合律與單位律,因此可將 Monad 定義為一個 Monoid (並且是 Endofunctors 範疇中 Monoid)。
補充:Endomorphism 與 Endofunctors 差異
1. Endomorphism (自態射 / 自函式)
- 層級:值的世界 (World of Values)
- 定義:一個輸入型別與輸出型別完全相同的函式。
- 簽章:
A -> A- Endomorphism 描述的是「同型別的值之間的轉換」。它接收一個值,經過運算後,回傳一個相同型別的值。
- 範例:
const increment = (x: number): number => x + 1;- 恆等函式 id 也是一個典型的 Endomorphism。
- 當我們討論函式組合的 Monoid 時,我們組合的正是這些
A -> A的 Endomorphism。2. Endofunctor (自函子)
- 層級:型別的世界 (World of Types)
- 定義:一個將一個範疇 (Category) 映射回自身的 Functor。
- 概念:在程式設計中,這通常指一個容器或結構,它能接收任何型別
A,並將其包裹成一個新的型別F<A>,而F<A>依然存在於我們原有的型別系統中。- Endofunctor 描述的是「將型別提升到一個結構中」的模式。
- 範例:以 Maybe 來說,你給它
String型別,它產生Maybe<String>型別。
| 特性 | Endomorphism | Endofunctor | 
|---|---|---|
| 操作對象 | 值 (Values) | 型別 (Types) | 
| 本質 | 是一個函式 | 是一個結構/容器 | 
| 簽章/模式 | A -> A | A => F<A> | 
| 簡單來說 | 值的同型別轉換 | 型別的結構化包裹 | 
| 比喻 | 一台「將木頭加工成木椅」的機器 | 一份「為任何材料設計容器」的藍圖 | 
Applicative Functor 之所以能夠「同時」處理多個獨立的計算、再把結果合併,其實背後的關鍵結構就是 Monoid。
這個特性在範疇論中被稱為 Lax Monoidal Functor —— 意即一種「能以 Monoid 方式結合的 Functor」。
我們可以從兩個角度來看這件事:
Applicative 的核心在於它能將兩個獨立的容器「並行」地組合起來,變成一個新的容器。為了更清楚表達這個「並行結合」的概念,我們可以定義一個比 ap 更基礎的操作,稱為 product (或 zip)。
// product :: (F(A), F(B)) -> F((A, B))
這個操作接受兩個容器,並將它們組合成一個裝有 tuple 的新容器。例如以下:
product([1, 2], [3, 4])
// => [[1, 3], [1, 4], [2, 3], [2, 4]]
它做的事其實就像是「容器的 concat」,只不過是「同時」結合兩個容器裡的值。這裡就是對應到之前 [Day 24] Applicative Functor (2):定律與應用範例提到的笛卡兒積的概念。
這操作對應到 Monoid 的結構來看:
F(A),其中 F 是一個 Applicative。product): (F(A), F(B)) -> F((A, B))。此操作接收兩個容器,回傳一個新的容器,裡面裝著包含原先兩個值的元組 (tuple)。of(()),一個包裹著「空元組」或「單位值」的容器。product 操作就是 Applicative 的 monoidal concat。一旦有了 product,我們就可以用它和 map 來重新定義出 ap:
// ap :: Functor f => (f (a -> b), f a) -> f b
const ap = (fab, fa) =>
  map(
    ([f, a]) => f(a),  // 對「裝著 pair」的容器做 map:把每個 pair 解構成 f 與 a,再呼叫 f(a),最後把結果留在原本的容器語境中
    product(fab, fa)   // 先把兩個容器並行結合,得到一個新容器,裡面放著 pair/tuple:[(函式), (值)]
  )
由此可看出 ap 的本質,ap 其實是由一個「Monoid 式的結合 (product)」再加上一個「函數式轉換 (map)」組成的。
這也就是為什麼 Applicative 能自然地組合多個獨立的運算,例如:將多個異步結果合併成單一結果,因為它本質上就是「Monoid 化」的結合邏輯。
前面說的是 Applicative 本身具備 Monoid 結構。
接著我們要看另一種情況:
當一個 Applicative Functor 內部包裹的值本身就是 Monoid 時,整個結構
Applicative<Monoid>也會成為一個 Monoid。
concat 提升到 ApplicativeMaybe、Promise 或 Task,裡面包著 Monoid 值(例如字串或陣列):Maybe("Hello ")
Maybe("World")
字串本身有 Monoid 結構,concat 為字串拼接,empty 為空字串 ""。
那麼我們就可以用 liftA2 來「提升」字串的 concat,讓它能作用在 Applicative 上:
const concatA = liftA2((a, b) => a.concat(b))
concatA(Maybe("Hello "), Maybe("World"))
// => Maybe("Hello World")
liftA2 的作用就是把一個普通的二元函數(這裡是 Monoid 的 concat)提升成「Applicative-aware」的版本。也就是說,它會自動幫我們拆開兩個容器、取出裡面的值、套用函數、再包回容器中。以這裡來說,就是 liftA2 拆開了兩個 Maybe 容器,取出裡面的字串,套用字串的 concat 函數,再把結果包回 Maybe 容器中。
empty 也能被提升F.of(M.empty)
舉例來說:
Maybe.of("")
Promise.resolve([])
Task.of(0)
這樣 Applicative<Monoid> 整體就成為一個新的 Monoid:
concat: 透過 liftA2 來結合內部值,liftA2 接收一個普通的二元函式(在此就是內部 Monoid 的 concat),並用它來合併兩個 Applicative 容器內的值。empty: 單位元素就是內部 Monoid 的 empty 值,被 of 方法提升到 Applicative 的上下文中:F.of(M.empty)。我們從 Semigroup 出發,一路深入到 Monoid 在函式組合、Monad 和 Applicative 中的應用,最後發現 Monoid 將一切都串連起來了。
以下是本文的重點摘要:
compose 是 concat,id 函式是 empty。我們使用的函式組合,其穩定性正源於 Monoid 定律。chain 和 of 遵循 Monoid 定律,為帶有上下文(如錯誤處理、異步)的運算提供了可靠的串聯方式。從更根本的角度看,Monad 本身就是 Endofunctor 範疇中的一個 Monoid,其中 join 是組合操作,of 是單位元素。ap 操作可以從一個更根本的 product 操作(將兩個容器合併為一個內含元組的容器)推導而來,這揭示了其內在的 Monoid 特性。最終我們發現,從 Functor、Applicative 到 Monad,這些看似獨立的概念,最終都可透過 Monoid 的視角被統一和理解。它顯示了函數式程式設計的核心——萬物皆可組合。
一開始讀 FP 相關文章的時候我會覺得這些數學理論、結構很令人頭痛,其實現在也不能說完全理解這些數學,但我覺得以程式設計的角度來看,FP 程式設計的思維只是想去借用數學的理論,例如程式設計中,沒有副作用的純函數就是去參考數學的「函數」,每一個輸入值,都只會對應到一個確切的輸出值,有了純函數的前提,我們又可以進一步去借鏡這些數學的代數結構,參考結合律、單位元素,讓一切變得可隨意組合和拆解,當程式能安全地隨意組合和拆解,開發上就能有更大的彈性,例如可實踐分層設計、分而治之等模式。
也因此我覺得我們只需大概理解背後的數學理論概念即可,雖然我覺得讀這些數學結構也蠻有趣的...有時間的話還是想深入探索 XD
不過回到程式設計本身,重點是這些數學理論能幫助我們解決什麼問題、如何確保我們複雜的程式開發是穩定可預測的。接下來的文章會介紹實務開發上,哪些技術和 FP 有關,也許會發現 FP 概念處處可見~
如果對代數結構(Algebraic Data Type)有興趣的,可參考以下這些連結,我覺得都寫得很好!
這篇超多字,講得很細,來表示一下敬佩之意。 <O>
感謝大大~! taiansu 大大的FP文章也是之前讀 FP 的參考資源之一🙏