上一篇我們初步認識了 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 數值則活在一個帶有額外上下文的世界。
圖 1 單純值的函數應用沒問題,但被 Container 包覆的值無法順利應用函數(資料來源: 自行繪製)
而要如何解決呢?為了解決這問題,我們需要一種標準化的方式來「窺探」容器內部,對裡面的值應用一個函數,然後將結果安全地放回一個新的、相同類型的容器中,整個過程不破壞容器所提供的上下文抽象。
這就是 Functor 登場的時刻,也就是今天的主題~
在程式設計的世界中,Functor 可以被理解為一種賦予容器的「能力」或「介面」。它為容器提供了一個 map
方法,這方法就是連接「純函數世界」與「容器世界」的橋樑,接下來就來了解更多 Functor 吧!
在介紹 Functor 前,先看看我們一般會如何對 Container 內的值進行操作~
這是最直觀的方法,我們將容器視為一個普通的箱子,每次操作前,都親自動手打開它、取出內容物、進行操作,然後再小心翼翼地將操作後的成品重新包裝起來。
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 }
圖 2 手動拆箱->應用函數->手動重新包裝的示意圖(資料來源: 自行繪製)
這方法的確能解決問題,但也帶來一些缺點:
$value
屬性)緊密地綁定在一起。如果未來 Container 的內部結構改變,例如 $value
改成 $val
,則所有使用它的地方都需要修改意識到「手動拆箱」的繁瑣後,我們可能會想:「何不讓我們的函數變得『聰明』一點,讓它自己學會處理容器呢?」這就導致了第二種方式。
我們不再手動拆箱,而是修改 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
)到它的內部值上。」
圖 3 透過 Container 的 map
方法,我們可以將 addOne
函數作用到容器內部的值(資料來源: 自行繪製)
A Functor is a type that implements map and obeys some laws.
回過頭來看 Functor 的定義,現在我們有 Container
、of
方法和 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 a
:map
方法被呼叫在一個 Functor f 上,這個 Functor 內部裝著一個 a 類型的值。f b
:結果會回傳一個全新的、同種類型的 Functor f,它內部裝著經過函數轉換後的 b 類型的值。換句話說,map
做的就是三件事:
(a -> b)
,例如我們的 addOne (number -> number)
Container a
),例如 Container.of(10)
Container b
),也就是 Container.of(11)
補充:有時
map
也會被稱為select
或fmap
,這幾個詞都和map
是相同意思
map
介面我們為 Container 加上 .map
方法,看似只是小小的改動,但其背後代表著一個深刻的典範轉移:
addOne(value)
時,是呼叫者在控制函數的執行。container.map(addOne)
,則是把「要做什麼」的意圖交給容器,由容器決定如何執行。這是一種 控制反轉 (Inversion of Control):
.map
的行為穩定且可預測因此,我們能專注在「傳入純函數」這件事,而不被上下文的細節干擾。
一開始讀到 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
:
1
、2
、3
。[]
本身就是一個容器,它的 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 並不是純 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。
以下幾點歸納一下:
fmap
並遵守 Functor 定律。.map
很接近 Functor 的行為」。連 function 本身都可視為一種 Functor,試著用 Functor 的定義來檢視看看:
那我們要如何定義函式這個 context 的 map
的行為呢?答案是:函數組合 (Function Composition)。
對一個函式 g
進行 map(f)
操作,意思就是將 f
和 g
組合起來,形成一個新的函式 x => f(g(x))
。map
把 f
的運算「串接」到了 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 的定義:「A Functor is a type that implements map and obeys some laws.」,Functor 除了要具備 map
方法,還需要遵守某些定律。剛剛已經介紹了 map
方法,現在來談談 Functor 要遵守的定律 (laws)。
Functor 必須遵守兩條「定律 (laws)」:
F.map(x => x)
必須等價於F
如果我們將一個「恆等函數」(identity function,即一個回傳其輸入參數的函數,x => x
)傳遞給 .map
,那結果應該要和原來的 Functor 完全相同。
簡單來說,如果你要求 Functor 執行一個什麼都不做的操作,那它就不應該改變任何東西。就像你請一位翻譯員把一句英文翻譯成英文,你期望得到的會是完全相同的句子。
圖 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)。只要內容和結構保持不變,就視為遵守了恆等律。
F.map(g).map(f)
必須等價於F.map(x => f(g(x)))
將兩個函數 g
和 f
以鏈式呼叫 .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(變灰階且加對比)
會產出完全相同的最終成品,結果是一樣的。
圖 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」將值提升(lift)出來的用語,一開始不是很理解是要提升什麼,後來看了 The Functional Programmer's Toolkit - Scott Wlaschin 以後,好像理解了一點,我們可以將程式設計中的每個型別都視為擁有自己的世界,例如有 String 的世界、Number 的世界、Array 的世界,而大方向又可以粗略分成「一般值的世界」和「容器包裹值」的世界,例如 String 的世界和 Number 的世界就屬於「一般值的世界」,而 Array 或是後續文章要介紹的 Maybe、Either 就是「容器包裹值」的世界。
這兩個世界需要一種溝通橋樑,讓我們可以將「一般值的世界」的 function 也能應用在「容器包裹值」的世界上,而這個橋樑就是 map
,透過 map
我們可以將「一般值的世界」中的 function 提升到「容器包裹值」的世界上使用,如下示意圖,而這個提升的概念就是 Functor 的核心。
圖 6 透過 map
來將「一般值的世界」中的 function 提升到「容器包裹值」的世界(資料來源: 自行繪製)
補充一下,這裡所說的「型別的世界」跟上一篇提過的「每個型別都有自己的集合」是類似概念,只是用「型別的世界」這說法會比較好理解點~
以下簡單總結今天的重點:
為了連接簡潔、可重用的純函數世界,與現實中帶有「Context 上下文」(如:可能不存在、未來才會出現、在一個集合中)的資料世界。Functor 提供了一套機制,讓我們可以在不破壞容器封裝、不污染純函數的前提下,操作被包裹的值。
map
到你內部的東西上」。容器自己會處理所有「如何做」的細節,讓我們的業務邏輯保持乾淨、可讀、且高度可組合。經過前面的探討,我們可以這樣總結:
在 JavaScript 的世界裡,與其說 Functor 是一種特定的「東西」,不如說它是一種設計模式或抽象概念。
在 Haskell 這樣的純函數式語言中,Functor 有嚴格的數學定義:「一個實作了 fmap
並遵守定律的型別」。
而在 JavaScript 中,我們更關心的是「行為上的契約」。任何資料類型,只要符合以下條件,就能被視為 Functor:
.map()
方法:接收一個函數並應用在內部的值上。map(x => x)
等同於原容器。map(g).map(f)
等同於 map(x => f(g(x)))
。這兩條定律是 Functor 的核心。它讓我們能確信,無論容器內是什麼,也無論函數多複雜,.map
的行為永遠是穩定且可預測的。