今天要介紹的是 Container (容器),這會是後續 Functor、Monad、Applicative 概念的基礎。
在之前的文章中,我們學習到 Functional Programming 的核心魅力之一是函式組合 (Function Composition),透過 pipe
或 compose
,我們可以將一系列微小、單一職責的純函式 (Pure Functions) 串聯起來,形成一個清晰、可讀性極高的資料處理管道 ,然而這個優雅的組合鏈仍有些缺點,以下就來看看它在實際應用中可能會遇到的問題吧~
來看一個簡單的範例,假設我們想從一個使用者物件中,提取其名稱、轉為大寫,並截取前五個字元。
// 輔助函式
const getProperty = (prop) => (obj) => obj[prop];
const toUpperCase = (str) => str.toUpperCase();
const slice = (start, end) => (str) => str.slice(start, end);
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
// 建立處理管道
const processUserName = pipe(
getProperty('name'),
toUpperCase,
slice(0, 5)
);
// 理想情況
const user = { name: 'Buckethead' };
processUserName(user); // 'BUCKE'
這段程式碼看起來非常完美,但現實世界的資料不可能是完美的。如果我們從 API 或資料庫收到的 user
物件剛好沒有 name
屬性呢?
const userWithNoName = { id: 1 };
processUserName(userWithNoName);
// 🔺 TypeError: Cannot read properties of undefined (reading 'toUpperCase')
程式碼會出錯,因為 getProperty('name')
回傳了 undefined
,而 toUpperCase
無法在 undefined
上執行。我們pipe
管道,就這樣因為 undefined
而斷裂了。
圖 1 斷裂的 pipe
管道(資料來源: 自行繪製)
為了避免這種情況,我們最直覺的反應就是用 if/else
來預防:
function processImperative(user) {
if (user && user.name) {
const upperName = user.name.toUpperCase();
return upperName.slice(0, 5);
}
return null;
}
這樣寫雖然安全,但我們失去了組合鏈的優雅。核心邏輯被 if
的檢查淹沒,程式碼又變成聚焦在「怎麼做」的實作細節,而非高層次的「做什麼」,簡單來說,這樣寫就不 FP 了XD
這就是我們需要新工具的時刻!我們需要一種方法,既能保持函式組合的宣告性,又能優雅地處理 null
或 undefined
這類潛在的「危險」。
與其在每個函式中都用 if/else
處理一次 null
的檢查,不如將「值可能不存在」這個脈絡 (context) 本身,進行抽象化。
核心思想是:將資料放入一個特殊的「盒子」裡。
這個「盒子」,我們稱之為容器 (Container)。它的職責很單純:包裹一個值,並明確化這個值的「狀態脈絡」。
Container
:值就是值(可能隨時為 undefined
)。Container
:值 + 「這個值可能為空」的脈絡 (context)。一旦值被放進容器,它就與外部世界隔離開來,我們不能再直接對它進行操作,而是透過一套規則來互動。
這個看似簡單的步驟,卻是後續所有強大抽象的基礎。透過將值包裹起來,我們為處理控制流、錯誤處理、非同步操作等複雜概念建立了穩固的基礎。
Container
是一個可以容納任何型別值的物件,我們可以將它視為「一個單純包東西的盒子」。
class Container {
constructor(value) {
// 我們用一個私有屬性來儲存值,$value 可以是任何型別的值
// 在 JS 中,有時會用錢幣符號 ($) 或底線 (_) 開頭來表示這是一個內部屬性,提醒開發者不應直接存取它。
this.$value = value;
}
}
$value
用於儲存容器內的值,它可以是任何型別的資料(數字、字串、物件,甚至是另一個 Container
)。這個結構並非為物件導向設計,它更像是一個純粹的資料載體。
Container.of()
既然容器內要裝一個值,那我們就需要「把值放進容器」的操作方法,那要如何把值放進去呢?
在 FP 中,通常不會直接使用 new Container(x)
來建立實例(或說是把東西放進去),而是透過一個靜態工廠方法 .of()
來建立容器、將值放入。
class Container {
constructor(value) {
this.$value = value;
}
// 靜態工廠方法
static of(value) {
return new Container(value);
}
}
// 使用方式
const myContainer = Container.of('Hello FP!');
至於為什麼要用 of
,而不是用 new Container(x)
呢? 目前可以先用「of
比 new Container(x)
更簡潔、語意更清楚」的優點來思考,of
這個詞本身就帶有「將...放入」的意涵,可以將 of
想成是一種正確放資料進容器的方式。
一旦我們將值放入容器,它就會一直待在裡面。
const numberContainer = Container.of(123);
console.log(numberContainer);
// Container { $value: 123 }
const userContainer = Container.of({ name: 'Monica Lin' });
console.log(userContainer);
// Container { $value: { name: 'Monica Lin' } }
(補充:為了方便理解與閱讀,後續文章會統一使用 Container(x)
這種格式來表示一個包裹著 x
值的容器。)
圖 2 將一個值放入 Container 中(資料來源: 自行繪製)
Container 有幾個特性:
$value
的物件
$value
用於儲存容器內的值,可存放任意型別資料(例如:原始型別、物件、甚至其他 Container)myContainer.$value
來取值)
現在我們可以將一個潛在的 null
或 undefined
值安全地包裹進 Container
中,我們的函式組合鏈可以傳遞一個 Container
物件,而不會因為 null
這種「裸值」而直接崩潰。
回想一下文章開頭那個 processUserName
會報錯的例子:
getProperty('name')
回傳了 undefined
,直接傳給 toUpperCase
,結果爆炸。Container.of(user)
,傳遞下去的就是 Container(undefined)
,而不是「危險的裸值」。這樣一來,Container
就像一個自動檢查員,幫我們擋掉 null
與 undefined
的風險。
但新的問題也出現了:
🔺 我們原本的純函數,無法直接對容器裡的值進行操作。
以下面這段程式碼來說,add
函式期望接收一個數字(例如 3
),但我們卻給了它一個 Container
物件。我們雖然保護了值,但也同時失去了與它互動的能力。
const add = (x) => x + 2;
const containerOfThree = Container.of(3);
add(containerOfThree); // "[object Object]2"
圖 3 add
函式無法接觸盒子內的值(資料來源: flaticon & 部分自行繪製)
我們已經有了一個安全的盒子,但現在浮現出了兩個問題:
[1,2,3]
或一串字串 ['Hello', ' ', 'World']
,是否存在一種通用且可靠的方法,能將它們組合成一個單一的值?我們知道數字可以用加總、字串可以用串接,但能否將這種「組合」的行為抽象出來,找到一個共通的模式?Container(5)
,而我們想對它應用一個簡單的函式 addOne
,該怎麼做?是否存在一種通用的方式,能讓我們將函式「映射 (map)」到容器裡的值,而不必手動拆箱與重包裝?這兩個挑戰——組合值與映射 Context ——正是 Functional Programming 的核心課題。
在下一篇文章中,我們將首先探索「組合」的問題,並介紹我們可能每天都在使用、卻可能從未意識到的強大結構:Monoid。
以下簡單總結今天的重點:
一個簡單的物件,像個盒子,透過 .of()
方法將我們的資料值安全地包裹起來。
為了將「值」與其「脈絡」(例如:這個值可能為空)明確化。這能保護我們的函式組合流程,不因意外的 null
或 undefined
而中斷。
我們學會了如何將值安全地放入容器,但還沒學會如何操作容器裡的值。這問題的答案會在後續文章揭曉。