iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0

https://ithelp.ithome.com.tw/upload/images/20250930/20168201ArBNfZ8gTd.png

前言

上一篇我們初步認識了 Monoid,學到「組合」其實是程式設計裡無處不在的基礎結構。

現在回到 Day 14 提過的容器,透過將值(value)包裝在一個上下文(context)或「容器」(container)中,我們可以更有效地管理不同的運算情境,避免空值裸露在外而破壞管線。容器為我們解決了表示不同狀態的問題,但也引出了一個新問題:「既然我們的值已經安全地放進了容器裡,我們要如何對它進行操作呢?」

這就是 Functor 要解決的,Functor 提供了一個抽象介面,允許我們將一個普通函式 (A -> B),提升為一個能在容器中運作的函式 (F<A> -> F<B>),並完整保留外層的結構。今天就來認識 Functor 到底是什麼吧~

addOne function 開始

先從上一篇文章的 Container 和一個簡單的純函數(pure function)開始。這個函數的職責非常單純:將傳入的數字加一。

// 一個簡單的容器
class Container {
  constructor(value) {
    this.$value = value;
  }

  static of(value) {
    return new Container(value);
  }
}

const addOne = x => x + 1;

addOne 在處理單純的數字時表現完美。但當我們試圖將它應用於我們的容器時,問題就浮現了。容器雖然保護了值,但也形成了一道屏障,使得像 addOne 這樣只認識原始值的純函數無法直接觸及內部的內容。

addOne(2); // 3 -> ✅ 處理單純的數字時,表現正常


const numberInContainer = Container.of(10);
addOne(numberInContainer); // '[object Object]1' -> 🔺 失敗!addOne 預期的是數字,但得到的是一個 Container 物件。
// 預期結果: 11, 實際結果: "[object Object]1"

這狀況突顯了一個根本性的「類型不匹配」問題。我們的純函數活在一個處理簡單值的世界,而我們的 Container 數值則活在一個帶有額外上下文的世界。

https://ithelp.ithome.com.tw/upload/images/20250930/20168201Rd43O1YjCB.png
圖 1 單純值的函數應用沒問題,但被 Container 包覆的值無法順利應用函數(資料來源: 自行繪製)

而要如何解決呢?為了解決這問題,我們需要一種標準化的方式來「窺探」容器內部,對裡面的值應用一個函數,然後將結果安全地放回一個新的、相同類型的容器中,整個過程不破壞容器所提供的上下文抽象。

這就是 Functor 登場的時刻,也就是今天的主題~

在程式設計的世界中,Functor 可以被理解為一種賦予容器的「能力」或「介面」。它為容器提供了一個 map 方法,這方法就是連接「純函數世界」與「容器世界」的橋樑,接下來就來了解更多 Functor 吧!

沒有 Functor 的世界

在介紹 Functor 前,先看看我們一般會如何對 Container 內的值進行操作~

1. 手動拆箱

這是最直觀的方法,我們將容器視為一個普通的箱子,每次操作前,都親自動手打開它、取出內容物、進行操作,然後再小心翼翼地將操作後的成品重新包裝起來。

const numberInContainer = Container.of(10);

// 1. 手動提取值:打開箱子,拿出裡面的數字
const value = numberInContainer.$value;
// 2. 應用純函數:對取出的原始值進行運算
const addedValue = addOne(value);
// 3. 手動重新包裝:將運算結果放回一個新的 Container 箱子裡
const resultContainer = Container.of(addedValue);

// resultContainer 現在是 { $value: 11 }

https://ithelp.ithome.com.tw/upload/images/20250930/20168201Ihu0fFDqkM.png
圖 2 手動拆箱->應用函數->手動重新包裝的示意圖(資料來源: 自行繪製)

這方法的確能解決問題,但也帶來一些缺點:

  • 高耦合:我們的應用程式邏輯與容器的具體實現(如 $value 屬性)緊密地綁定在一起。如果未來 Container 的內部結構改變,例如 $value 改成 $val,則所有使用它的地方都需要修改
  • 意圖模糊:程式碼的核心意圖——「將容器裡的值加一」——被大量的樣板程式碼(boilerplate)所淹沒。我們需要逐行解析這些步驟,才能理解真正的業務目標
  • 容易出錯:手動操作的步驟越多,越可能引入錯誤

2. 讓 function 自己處理 Container

意識到「手動拆箱」的繁瑣後,我們可能會想:「何不讓我們的函數變得『聰明』一點,讓它自己學會處理容器呢?」這就導致了第二種方式。

我們不再手動拆箱,而是修改 addOne 函數,讓它直接接收 Container 容器作為參數。

const addOneForContainer = (container) => {
  const value = container.$value;
  // function 自己處理重新包裝的邏輯
  return Container.of(addOne(value));
}

const result = addOneForContainer(Container.of(10)); // result 是 { $value: 11 }

這方法看起來比手動拆箱再更方便一點,但還是有些缺點:

  • 失去純粹性與可重用性:addOneForContainer 不再是一個簡單的 number -> number 純函數。它無法被用於處理一個原始的數字 (addOneForContainer(10) 會報錯),也無法被用於處理 Array。它和 Container 永遠綁定了。
  • 職責混淆:addOne 函數的核心職責是「加一」。現在它被迫承擔了額外的職責:「理解 Container 的結構」、「重新包裝結果」。這違反了單一職責原則。

上述兩種方式都無法優雅地解決問題,它們一個讓呼叫端的程式碼變得混亂,另一個則污染了純函數本身,那怎麼辦呢?這就是 Functor 會出現的原因。

(很喜歡 FizzyElt 大大在這篇說的:「你會發現你一直在做這重複又無聊的動作,甚至他把你原本純淨無暇的 code 搞的髒髒的,這當然不能忍!程式必須維持優雅!」🤣)

map 來對容器內的值操作

在介紹 Functor 到底是什麼之前,先來解決我們的問題。
現在我們需要一個方法,讓我們可以在不破壞容器封裝性的前提下,對容器內的值進行操作。

我們來為 Container 增加一個 map 的方法,map 的職責很明確:接收一個函數,將這個函數應用於容器內部的值($value),然後將運算的結果包裹在一個新的 Container 中返回。

// 延續原本的 Container 定義
class Container {
  constructor(value) {
    this.$value = value;
  }

  static of(value) {
    return new Container(value);
  }
}


// 為 Container 建立一個 map 方法
// map:: (a -> b) -> Container a -> Container b
Container.prototype.map = function (f) {
  return Container.of(f(this.$value));
};

我們再看一次原本的範例,用 map 來處理函數的應用:

const addOne = x => x + 1;

const resultContainer = Container.of(10).map(addOne); 

// -> 結果是 Container({ $value: 11 })

透過這個新增的 map 方法,我們不需要手動拆箱,也不需要改原始的函數,map 讓我們可以這樣描述意圖:「取這個容器,將 addOne 函數映射(map)到它的內部值上。」

https://ithelp.ithome.com.tw/upload/images/20250930/20168201edXhoYsfgS.png
圖 3 透過 Container 的 map 方法,我們可以將 addOne 函數作用到容器內部的值(資料來源: 自行繪製)

Functor 是什麼?

A Functor is a type that implements map and obeys some laws.

回過頭來看 Functor 的定義,現在我們有 Containerof 方法和 map 方法,而 Functor 就是一種實作了 .map() 方法,並且遵守特定規則的資料型別。在這裡,我們的 Container 就是一個資料型別。

.map() 的責任是什麼呢?它就是一個標準化的介面,用來描述「如何把一個純函數應用到容器裡的值」。

  • 容器保護了值,使它不會裸露在外
  • map 則提供一個入口,讓我們能安全地觸及裡面的值,把函數作用上去,再回傳一個新的容器。更進一步來說,map 它做的事情就是「將容器內的值拿出來」、「將值傳給一個以值作為輸入的函數,得到一個值的輸出」、「將輸出的值放回容器」

我們之後可能會看到很多種的 Functor,他們的差異不在於「有沒有 map」,他們都有自己的 map 方法,關鍵差異在於「map 的細節怎麼實作」。不同的容器,會根據自己的 context 決定如何應用函數到值上。

map 方法就是「應用函數」這個行為的具體實現。map 方法就像是一個 Functor 的通用介面,遵循一個明確的契約。我們可以用 Hindley–Milner 型別簽章來描述這個行為:

map :: Functor f => (a -> b) -> f a -> f b

用白話文來解讀:

  • Functor f =>:這表示此規則適用於任何一種 Functor f(例如 Container、Array 等)。
  • (a -> b):提供一個普通函數,它能將 a 類型的值轉換為 b 類型的值。
  • f amap 方法被呼叫在一個 Functor f 上,這個 Functor 內部裝著一個 a 類型的值。
  • f b:結果會回傳一個全新的、同種類型的 Functor f,它內部裝著經過函數轉換後的 b 類型的值。

換句話說,map 做的就是三件事:

  1. 它接收一個函數作為參數 (a -> b),例如我們的 addOne (number -> number)
  2. 它作用於一個容器實例上 (Container a),例如 Container.of(10)
  3. 它會回傳一個包裹著新值的、相同類型的新容器 (Container b),也就是 Container.of(11)

補充:有時 map 也會被稱為 selectfmap,這幾個詞都和 map 是相同意思

Functor 的核心:一個通用且可靠的 map 介面

我們為 Container 加上 .map 方法,看似只是小小的改動,但其背後代表著一個深刻的典範轉移:

  • 在傳統呼叫 addOne(value) 時,是呼叫者在控制函數的執行。
  • 當我們寫下 container.map(addOne),則是把「要做什麼」的意圖交給容器,由容器決定如何執行。

這是一種 控制反轉 (Inversion of Control)

  • 呼叫端不用再關心「值怎麼被取出、結果怎麼被重新放回去」
  • 容器封裝了這些細節,保證 .map 的行為穩定且可預測

因此,我們能專注在「傳入純函數」這件事,而不被上下文的細節干擾。

我們可能見過的 Functor:Array

一開始讀到 Functor 時,我會覺得這是一個很學術、很遙遠的概念,和實際開發沒什麼關係,但其實如果我們寫過 JavaScript,我們幾乎每天都會用到類似的東西,我們最常見的 Functor 就是 Array。
Array.prototype.map 可說是 Functor 模式的一種實現。

const numbers = [1, 2, 3, 4, 5];
const double = x => x * 2;

const doubledNumbers = numbers.map(double);
console.log(doubledNumbers); // [2, 4, 6, 8, 10]

試著用 Functor 的定義來檢視 Array.prototype.map

  • 包裹的值 (Wrapped Value):陣列中的每一個元素,如 123
  • 容器 (Context):陣列 [] 本身就是一個容器,它的 context 是「一個值的有序集合」。
  • map 方法:Functor 會定義 map 應用函數到值的方式,而對於「有序集合」這種 context,map 的行為被定義為遍歷 (iteration)。具體來說,map 會接收一個轉換函數 double,將它應用於容器內的每一個值,最後回傳一個全新的、同種類型(陣列)的容器,裡面裝著轉換後的新值。

Array 的 map 方法也符合 Functor 的型別簽章 map :: (a -> b) -> f a -> f b。在這個例子中,f 就是 Array,a 是 number,b 也是 number。

換個角度想,如果把 map 想成「將容器內的值拿出來」、「將值傳給一個以值作為輸入的函數,得到一個值的輸出」、「將輸出的值放回容器」,那 Array 的 map 就是透過迴圈逐一將陣列內的值拿出來,然後將那個值傳給某轉換函數 double,得到新的值,最後逐一將新的值塞回容器,於是我們就得到新的值組成的陣列。

關於 JavaScript Array 和 Functor

嚴格來說 JavaScript 並不是純 Functional Programming 的語言,很多時候我們只能在 JavaScript 的世界中模擬純 FP 語言的概念與實作,實際上,在 Haskell 這類純 Functional Programming 的語言中,「Functor」是一個可以透過 typeclass 來正式定義和約束的數學結構,例如以下是 Functor 在 Haskell 裡的定義方式:(看不懂沒關係因為我也不懂...🥲)

class Functor f where 
    fmap :: (a -> b) -> f a -> f b

關於 Haskell 語言中的 Functor,可參考 可以映射的 Functor

回到 JavaScript,因為 JavaScript 並不是純函數式語言,它沒有 typeclass 這樣的特性,它是多範式語言:可以命令式、物件導向,也能用函數式風格。但 JS 的語法很靈活,我們可以「模擬」Haskell 裡的抽象概念。

因此,當我們在 JavaScript 的語境下說「Array 是一個 Functor」時,這其實是一種比較不精確的說法。更精確的說法是:Array 實作了 Functor 設計模式。它提供了一個遵循 Functor 行為準則的 .map 方法,並達到了相同的抽象目的。

在 JavaScript 中我們關注的是行為模式,而不是嚴格的類型系統約束。任何物件,只要它能提供一個行為符合預期的 .map 方法,我們就可以將其視為一個 Functor 來使用。而剛好 JavaScript Array 有 map 方法,我們就拿它來類比 Functor,但並不是要說 JavaScript 世界的 Array 就完全等於 Functor。

以下幾點歸納一下:

  • 在 Haskell 中,Functor 是嚴格定義的抽象,必須實作 fmap 並遵守 Functor 定律。
  • 在 JavaScript 中,沒有對等的實體,Functor 比較像是一種模式或介面,而不是語言內建的型別。
  • 所以「JavaScript 的 Array 是 Functor」的說法不完全正確,精確來說是「Array 的 .map 很接近 Functor 的行為」。

我們可能見過的 Functor:Function

連 function 本身都可視為一種 Functor,試著用 Functor 的定義來檢視看看:

  • 包裹的值 (Wrapped Value):一個函式的「值」,是它最終的回傳值。這個值不是立即存在的,而是潛在的、等待被計算出來的結果。
  • 容器 (Context):函式的 context 就是它本身的運算過程 (computation)。函式內的邏輯,就是包裹著潛在回傳值的 context。

那我們要如何定義函式這個 context 的 map 的行為呢?答案是:函數組合 (Function Composition)。

對一個函式 g 進行 map(f) 操作,意思就是將 fg 組合起來,形成一個新的函式 x => f(g(x))mapf 的運算「串接」到了 g 的運算之後。
由於 JavaScript 的原生函式沒有 .map 方法,我們可以寫一個簡單的輔助函式來模擬這個行為:

// 模擬 Function 的 map 行為,將 f 映射到 g 上,相當於做函數組合
const mapForFunc = (f, g) => x => f(g(x));

// 使用範例
const g = x => x + 1;
const f = x => x * 2;

const h = mapForFunc(f, g);
console.log(h(10)); // 22

同樣是 map,在 Array 的 context 中,它的「應用函數到值」的具體行為是「遍歷」;而在 Function 的 context 中,它的「應用函數到值」行為是「組合」。Functor 提供了一個統一的介面,來處理各種不同 context 的值轉換。

Functor 的定律

再看一次 Functor 的定義:「A Functor is a type that implements map and obeys some laws.」,Functor 除了要具備 map 方法,還需要遵守某些定律。剛剛已經介紹了 map 方法,現在來談談 Functor 要遵守的定律 (laws)。

Functor 必須遵守兩條「定律 (laws)」:

1. 恆等律/同一律 (Identity Law)

F.map(x => x) 必須等價於 F

如果我們將一個「恆等函數」(identity function,即一個回傳其輸入參數的函數,x => x)傳遞給 .map,那結果應該要和原來的 Functor 完全相同。

簡單來說,如果你要求 Functor 執行一個什麼都不做的操作,那它就不應該改變任何東西。就像你請一位翻譯員把一句英文翻譯成英文,你期望得到的會是完全相同的句子。

https://ithelp.ithome.com.tw/upload/images/20250930/201682016YuOan4GgO.png
圖 4 map(identity function) 不會改變任何東西(資料來源: 自行繪製)

這定律對 Functor 很重要,因為它保證了 .map 的唯一職責是應用轉換,而不會偷偷引入任何副作用或改變容器本身的結構。(翻譯就是翻譯,不會在中間增加其他內容)

補充:這裡的 Identity Law 其實和 Monoid 的「單位元素律」相呼應。
在 Monoid 中,單位元素 e 的特性是 a • e = a,不會改變原來的值。在 Functor 中,恆等函數 x => x 的特性是 F.map(x => x) = F,不會改變原來的容器。
兩者都提供了一種「安全、不變」的保證,讓我們能安心地把它放入更大的組合中使用。

以下為 Container 在 Identity Law 的範例:

const id = x => x;
const container = Container.of(10);

const result = container.map(id); 
// result 是 Container({ $value: 10 })
// result 結構上與 container 完全相同

再看一個 Array 的範例:

const id = x => x;
const numbers = [1, 2, 3, 4, 5];
const sameNumbers = numbers.map(id); // [1, 2, 3, 4, 5];

// 結構上與 numbers 完全相同
// JSON.stringify(numbers) === JSON.stringify(sameNumbers) -> true

補充一下,在 JavaScript 中,numbers === sameNumbers 的結果會是 false,因為 .map 總是回傳一個新的陣列。Functor 定律關心的是結構等價性 (structural equality),而非引用等價性 (reference equality)。只要內容和結構保持不變,就視為遵守了恆等律。

2. 結合律 (Associativity Law)

F.map(g).map(f) 必須等價於 F.map(x => f(g(x)))

將兩個函數 gf 以鏈式呼叫 .map 的方式依序作用於 Functor,其結果必須等同於先將這兩個函數組合成一個新函數 x => f(g(x)),然後再用這個組合後的新函數進行單次 .map 操作。

以上非常繞口,更簡短來說,意思就是「先後呼叫兩次 map,應該等價於一次把兩個函數組合起來後再呼叫 map」。

補充:這裡的「結合律」其實和我們在 Monoid 裡看到的結合律是一脈相承的。在 Monoid 中,結合律確保「值」在某個二元操作下如何分組都不影響結果(不論是數字加法、字串串接,甚至函數組合本身都能形成 Monoid)。在 Functor 中,結合律則確保「函數應用」的鏈接方式不會影響最終結果,讓我們能安心地透過 .map 建構資料轉換管線。

舉一個生活化的例子來說,假設你正在用修圖軟體編輯一張照片,這張「照片檔案」(Functor) 包裹著原始的圖片資料:

  • 分開步驟(F.map(g).map(f))是你先對照片 .map(套用灰階濾鏡),得到一張灰階照片;然後再對這張灰階照片 .map(增加對比度)
  • 組合步驟(F.map(x => f(g(x))))是你預先建立一個「灰階並拉高對比度」的自訂濾鏡(或稱為「動作」、「預設集」),然後一次 .map 到原始照片上

而最後 照片.map(變灰階).map(加對比)照片.map(變灰階且加對比) 會產出完全相同的最終成品,結果是一樣的。

https://ithelp.ithome.com.tw/upload/images/20250930/20168201a8UeoRLRJC.png
圖 5 兩次 map 的結果和一次 map 組合函數的結果相同(資料來源: 自行繪製)

這定律對 Functor 的重要性在於,它確保我們可以安心地鏈式呼叫 .map 來建構複雜的資料轉換管線(pipeline),而不用擔心中間過程產生無法預期的結果。

以下為 Container 在 Composition Law 的範例:

const addOne = x => x + 1;
const double = x => x * 2;
const container = Container.of(5);

// 方式一:鏈式呼叫 map
const result1 = container.map(addOne).map(double); // Container({ $value: 12 })

// 方式二:先組合函數,再呼叫 map
const addOneAndDouble = x => double(addOne(x));
const result2 = container.map(addOneAndDouble); // Container({ $value: 12 })

// result1 和 result2 結構等價,定律成立!

再看一個 Array 的範例:

const numbers = [1, 2, 3];
const addOne = x => x + 1;
const toStr = x => `Number: ${x}`;

// 方式一:鏈式呼叫 map
const result1 = numbers.map(addOne).map(toStr);
// -> ["Number: 2", "Number: 3", "Number: 4"]

// 方式二:先組合函數,再呼叫 map
const composedFunc = x => toStr(addOne(x));
const result2 = numbers.map(composedFunc);
// -> ["Number: 2", "Number: 3", "Number: 4"]

// result1 和 result2 結構等價,定律成立!

如果還記得我們之前介紹過的 compose 函數,會發現這裡的 x => f(g(x)) 正是函數組合的定義。差別在於,可以想成 compose 處理的是單純值的組合,而 Functor 的 .map 則把這個觀念推廣到容器內的值。簡單來說:compose 建立的是「普通值的管線」,.map 建立的則是「容器值的管線」。

補充:Functor 將普通值提升到「容器包裹值」的世界

有時候會看到類似「Functor」將值提升(lift)出來的用語,一開始不是很理解是要提升什麼,後來看了 The Functional Programmer's Toolkit - Scott Wlaschin 以後,好像理解了一點,我們可以將程式設計中的每個型別都視為擁有自己的世界,例如有 String 的世界、Number 的世界、Array 的世界,而大方向又可以粗略分成「一般值的世界」和「容器包裹值」的世界,例如 String 的世界和 Number 的世界就屬於「一般值的世界」,而 Array 或是後續文章要介紹的 Maybe、Either 就是「容器包裹值」的世界。

這兩個世界需要一種溝通橋樑,讓我們可以將「一般值的世界」的 function 也能應用在「容器包裹值」的世界上,而這個橋樑就是 map,透過 map 我們可以將「一般值的世界」中的 function 提升到「容器包裹值」的世界上使用,如下示意圖,而這個提升的概念就是 Functor 的核心。

https://ithelp.ithome.com.tw/upload/images/20250930/20168201KHekoASmhe.png
圖 6 透過 map 來將「一般值的世界」中的 function 提升到「容器包裹值」的世界(資料來源: 自行繪製)

補充一下,這裡所說的「型別的世界」跟上一篇提過的「每個型別都有自己的集合」是類似概念,只是用「型別的世界」這說法會比較好理解點~

小結

以下簡單總結今天的重點:

為什麼要有 Functor?

為了連接簡潔、可重用的純函數世界,與現實中帶有「Context 上下文」(如:可能不存在、未來才會出現、在一個集合中)的資料世界。Functor 提供了一套機制,讓我們可以在不破壞容器封裝、不污染純函數的前提下,操作被包裹的值。

沒有 Functor 跟有 Functor 的區別是什麼?

  • 沒有 Functor:程式碼是命令式 (Imperative) 且感知上下文的。我們要嘛寫下繁瑣的樣板程式碼去手動「拆箱、操作、裝箱」,要嘛就得修改純函數,讓它知道特定容器的內部結構,摧毀了函數的可重用性。
  • 有 Functor:程式碼變得宣告式 (Declarative) 且與上下文無關。我們只需簡單地告訴容器:「請將這個函數 map 到你內部的東西上」。容器自己會處理所有「如何做」的細節,讓我們的業務邏輯保持乾淨、可讀、且高度可組合。

所以 Functor 是什麼?

經過前面的探討,我們可以這樣總結:
在 JavaScript 的世界裡,與其說 Functor 是一種特定的「東西」,不如說它是一種設計模式或抽象概念。

在 Haskell 這樣的純函數式語言中,Functor 有嚴格的數學定義:「一個實作了 fmap 並遵守定律的型別」。
而在 JavaScript 中,我們更關心的是「行為上的契約」。任何資料類型,只要符合以下條件,就能被視為 Functor:

  • 它是一個容器:持有一個或多個值。
  • 它提供 .map() 方法:接收一個函數並應用在內部的值上。
  • 它維持上下文:回傳一個全新的、同類型的容器,絕不改變原始容器。
  • 它遵守兩條定律:
    • 恆等律 (Identity Law):map(x => x) 等同於原容器。
    • 組合律 (Composition Law):map(g).map(f) 等同於 map(x => f(g(x)))

這兩條定律是 Functor 的核心。它讓我們能確信,無論容器內是什麼,也無論函數多複雜,.map 的行為永遠是穩定且可預測的。

Reference


上一篇
[Day 15] 初探 Monoid:組合的力量
下一篇
[Day 17] Maybe Functor:處理空值
系列文
30 天的 Functional Programming 之旅18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言