iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0

https://ithelp.ithome.com.tw/upload/images/20251001/20168201zVUCHPnMLL.png

前言

上一篇文章介紹了 Functor:它是一個容器,包裹著一個值,並提供一個 map 方法,讓我們能可靠地對這個值套用函式,無論它究竟是什麼。
但如果我們處理的「值」,不是一個單純有效的值,而可能是空值,例如 nullundefined 呢?這就是今天要介紹的主題:Maybe。

簡單來說 Maybe 屬於另一種 Functor,是一個擁有 map 方法的容器型別,同時可提供 map 時的額外行為,它是一個可能有可能無的容器,可用來處理空值的特殊情況,確保我們的管線永遠不會意外中斷。

從破碎的管線開始

在「初探容器」文章中曾提到,當我們在處理管線(pipeline)中遇到 nullundefined,就會造成管線斷裂。例如,一個處理字串的管線可能是 trimtoUpperCasesplit。這條管線在收到一個有效字串時運作得非常完美,但如果它收到的是 nullundefined,災難就會發生——管線會立即崩潰,程式拋出錯誤。

https://ithelp.ithome.com.tw/upload/images/20251001/20168201wXIZpbhJ6A.png
圖 1 破碎管線示意圖(資料來源: 自行繪製)

這問題的根源,不僅是它會導致執行期錯誤,更深層次的問題在於它顛覆了型別系統本身。一個期望接收 string 型別的函式,理論上不應該能收到 null。而解決這問題的方法就是 Maybe。

沒有 Maybe 前:巢狀的防禦性程式碼

來看一個簡單範例,在還沒有 Maybe 之前,如果我們需要存取深度巢狀的物件屬性,例如要存取 user.accountDetails.address.province,這時如果 useraccountDetailsaddress 其中任何一個是 nullundefined,程式就會出錯。

常見的解法是進行防禦性程式設計,也就是在存取每一層屬性前都先檢查:

function getProvince(user) {
  if (user!== null && user!== undefined) {
    if (user.accountDetails!== null && user.accountDetails!== undefined) {
      if (user.accountDetails.address!== null && user.accountDetails.address!== undefined) {
        return user.accountDetails.address.province;
      }
    }
  }
  return 'Default Province'; // 或 null, 或 undefined...
}

另一種稍微簡潔的寫法是利用邏輯 AND (&&) 運算子的短路特性:

function getProvinceWithLogicalAnd(user) {
  return (user && user.accountDetails && user.accountDetails.address && user.accountDetails.address.province) || 'Default Province';
}

這兩種寫法雖然能解決問題,但卻帶來新的問題。它們最大的缺點是將核心的業務邏輯(「我想要取得 province」)與繁瑣的錯誤處理邏輯(「檢查這個、檢查那個」)混雜在一起。我們的意圖被層層的條件判斷所掩蓋,程式碼的可讀性降低,後續的修改和擴展也變得困難。

為了解決這問題,我們需要使用 Maybe,接著就來看看 Maybe 是什麼吧~

所以 Maybe 是什麼?

可將 Maybe 想成另一種 Functor,上一篇文章有說,不同 Functor 之間的差異在於它們實作 map 的方式,差異在於他們如何處理「把一個純函數應用到容器裡的值」這件事,而 Maybe 這個 Functor 實作 map 的方式就是當你呼叫 Maybe.of(value).map(fn) 的時候.它會判斷 value 是否為空值來決定後續如何應用函數。

Maybe 將「值的缺席」這個可能性變得明確,透過兩種狀態來達成這個目標:

  • Just(value)(有時也稱為 Some):一個容器,明確表示「值存在」,並將其安全地包裹起來。
  • Nothing(有時也稱為 None):一個容器,明確表示「值缺席」。

Nothing 就像是終極的空物件:它是一個適用於任何型別的空容器,它和 Just 共享相同的 map 介面,但其 map 的實作就是一個空操作。

其實 Maybe 有點像「薛丁格的貓」:在打開盒子之前,我們不知道裡面是活貓還是死貓;同樣地,在從 Maybe 這個容器取出值之前,我們也無法確定裡面是否有值,Maybe 是一個包裹著一個可能存在,也可能不存在的值的容器。

https://ithelp.ithome.com.tw/upload/images/20251001/20168201OC7MLXn3Li.png
圖 2 薛丁格的貓與 Maybe 的比喻:在打開盒子前,我們無法確定裡面是否有值。(資料來源: ChatGPT 繪製)

Maybe 的實作

了解 Maybe 的定位後,接著我們來看看具體的程式實作。

以下是 Maybe 的程式實作:
(補充:在此連結中,有其他種 Maybe 的實作方式,但核心概念是相似的)

// 一個工廠物件,用來決定創建哪個容器
const Maybe = {
  // of 是我們的守門員,負責檢查值並決定要回傳 Just 還是 Nothing
  of: (value) => {
    return value === null || value === undefined? new Nothing() : new Just(value);
  }
};

class Just {
  constructor(value) {
    this.$value = value;
  }

  map(fn) {
    // 1. 取出值
    // 2. 套用函式
    // 3. 將結果重新交給 Maybe.of 來包裹,以處理函式本身可能回傳 null 的情況
    return Maybe.of(fn(this.$value));
  }

  // 一個輔助方法,用於在管線末端安全地取出值
  getOrElse(defaultValue) {
    return this.$value;
  }

  toString() {
    return `Just(${this.$value})`;
  }
}

class Nothing {
  map(fn) {
    // 完全忽略傳入的函式,直接回傳自身
    return this;
  }

  getOrElse(defaultValue) {
    return defaultValue;
  }

  toString() {
    return 'Nothing()';
  }
}

Maybe 的 map 鏈之所以強大,關鍵在於其「短路」(short-circuiting)行為。我們可以將一個普通的函式鏈比喻為一條脆弱的玻璃管道;一旦 null 值進入,管道就會碎裂(拋出錯誤)。而一個由 Maybe.map 呼叫組成的鏈,則像一條智慧的、能自我封閉的管道。當它遇到一個空的部分(Nothing)時,它不會破裂;它只會關閉閥門,讓「空」的狀態流向終點,而不會嘗試進一步處理它。

https://ithelp.ithome.com.tw/upload/images/20251001/20168201IO2stPIQc9.png
圖 3 Maybe.map 的決策流程示意圖(資料來源: 自行繪製)

有了 Maybe 的區別:重構前後的比較

Maybe 就像一個防護周全的「探測車」。我們把 user 物件放進探測車裡,然後給它一連串的指令(函式),讓它沿著指定的路徑前進。如果任何一步指令失敗(例如,要找的屬性不存在),探測車會自動停止前進,並安全地回報任務失敗,而不會發生爆炸。

這模式的轉變核心在於一種「控制反轉」。傳統寫法是我們將一個可能為 null 的值傳遞給一系列函式;而 Functor 模式則是我們將一系列函式傳遞給一個包裹著值的「容器」,由容器自己來決定是否以及如何執行這些函式。

重構程式碼

我們可用 Maybe 來重構先前的 getProvince 函式。

// 輔助函式:取得物件屬性
const prop = (key) => (obj) => obj[key];

function getProvinceWithMaybe(user) {
  return Maybe.of(user)
   .map(prop('accountDetails'))
   .map(prop('address'))
   .map(prop('province'))
   .getOrElse('Default Province'); // 在管線末端安全地取出值
}


const userWithProvince = { accountDetails: { address: { province: 'NS' } } };
const userWithoutAddress = { accountDetails: {} };
const userIsNull = null;

console.log(getProvinceWithMaybe(userWithProvince));   // 'NS'
console.log(getProvinceWithMaybe(userWithoutAddress)); // 'Default Province'
console.log(getProvinceWithMaybe(userIsNull));         // 'Default Province'

這段 Maybe.of(user).map(...).map(...) 就像一句英文:「從 user 開始,取得 accountDetails,然後取得 address,最後取得 province。」業務邏輯一目了然。所有關於 nullundefined 的檢查都被巧妙地隱藏在 .map 的實作細節裡。我們只關心「做什麼」,而不必詳細描述「怎麼做」的每一步錯誤檢查。

我們可以逐步追蹤程式的過程:

  • getProvinceWithMaybe(userWithProvince):
    1. Maybe.of(userWithProvince) -> Just({ accountDetails:... })
    2. .map(prop('accountDetails')) -> Just({ address:... })
    3. .map(prop('address')) -> Just({ province: 'NS' })
    4. .map(prop('province')) -> Just('NS')
    5. .getOrElse('Default Province') -> 'NS'
  • getProvinceWithMaybe(userWithoutAddress):
    1. Maybe.of(userWithoutAddress) -> Just({ accountDetails: {} })
    2. .map(prop('accountDetails')) -> Just({})
    3. .map(prop('address')) -> Maybe.of({}[ 'address' ]) -> Maybe.of(undefined) -> Nothing()
    4. .map(prop('province')) -> Nothing() (短路,直接回傳 Nothing)
    5. .getOrElse('Default Province') -> 'Default Province'
  • getProvinceWithMaybe(userIsNull):
    1. Maybe.of(userIsNull) -> Nothing()
    2. .map(prop('accountDetails')) -> Nothing() (短路)
    3. .map(prop('address')) -> Nothing() (短路)
    4. .map(prop('province')) -> Nothing() (短路)
    5. .getOrElse('Default Province') -> 'Default Province'

更多 Maybe 範例

為了更熟悉 Maybe,再來看幾個範例程式:

// match :: RegExp -> String -> Boolean
const match = regex => str => regex.test(str);

// prop :: String -> Object -> Any
const prop = key => obj => obj == null ? undefined : obj[key];

// add :: Number -> Number -> Number
const add = x => y => x + y;

console.log(Maybe.of('Malkovich Malkovich').map(match(/a/ig)));
// Just(true)

console.log(Maybe.of(null).map(match(/a/ig)));
// Nothing

console.log(Maybe.of({ name: 'Boris' }).map(prop('age')).map(add(10)));
// Nothing

console.log(Maybe.of({ name: 'Dinah', age: 14 }).map(prop('age')).map(add(10)));
// Just(24)

上述範例可看出,在使用 map 時,我們不會因為 nullundefined 而出錯,因為 Maybe.of 每次都會檢查值的存在再套用函數。

Point-Free 風格

點語法(dot syntax)的 map 沒問題而且也是 functional programming 的,但如果我們想要 pointfree 風格呢?

(小提醒:這部分會介紹如何將 Maybe 搭配 Point-Free 使用,和 Maybe 本身沒有直接相關,不熟悉的話也可以先略過~)

mapcompose 的關係

.map 的鏈式呼叫本質上就是一種函數組合。Functor 定律中的組合律告訴我們:
m.map(f).map(g) 等同於 m.map(x => g(f(x)))

x => g(f(x)) 正是 compose(g, f) 的定義!可以說鏈式呼叫只是函數組合的一種語法糖。  

打造 Point-free 的工具

要實現 Point-free 風格,我們需要讓資料(也就是我們的 Functor)成為函式的最後一個參數,這樣才能方便地用 pipecompose 組合(可參考之前的 Point-free 介紹,有提到資料通常都會作為最後一個參數)。

.map 這種物件方法 (functor.map(fn)) 的資料在前面,不符合這個慣例。因此我們需要一個獨立的、柯里化的 map 輔助函式。

function curry(fn) {
  const arity = fn.length;
  return (function nextCurried(prevArgs) {
    return function curried(nextArg) {
      const args = [...prevArgs, nextArg];

      if (args.length >= arity) {
        return fn(...args);
      } else {
        return nextCurried(args);
      }
    };
  })(); 
}


// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((f, anyFunctor) => anyFunctor.map(f));

Functor f 這個參數 f 必須為一個 Functor(且具有 map 方法),這樣才可以 .map(f) 的使用它。

現在柯里化後的這個 map 函式,我們就可以這樣用:map(fn)(functor),這正是我們想要的「資料置後」風格。

https://ithelp.ithome.com.tw/upload/images/20251001/20168201v0u17gHiyr.png
圖 4 Functor map 運作示意圖(資料來源: 自行繪製)

point-free map 使用範例(搭配 Ramda)

來看一些沒有 point-free 和有 point-free 的 map 範例吧~以下程式碼有搭配使用 Ramda 套件提供的方法。

假設現在有以下工具與資料:

import { map, prop, add, compose } from 'ramda';

const getAge = prop('age'); // 取出物件的 age 屬性值
const add10 = add(10); // 為數字加 10

一般用點寫法的 map 是這樣寫的:

const result = Maybe.of({ name: 'Alice', age: 20 })
  .map(getAge)
  .map(add10);

point-free 的 map 則是這樣寫:

const process = compose(
  map(add10),
  map(getAge)
);

const result = process(Maybe.of({ name: 'Alice', age: 20 }));
// Just(30)

point-free 的 map(add10) 變成一個通用函數,可作用於任何 Functor(如 Maybe、或是之後介紹的其他 Functor),利用 compose(右到左組合)的方式,可保持 point-free 風格,不需顯式的提及資料(不手動寫 x => f(x)),並且當 Maybe 是空值時,整個鏈條都會自動短路成 Nothing。

再詳細一點分析 map(getAge) 執行流程如下:

  • map(getAge) 的實際作用是 (fa) => fa.map(getAge),其中 fa 是任何實作 .map 方法的物件(例如 Maybe)
  • Maybe.of({ name: 'Alice', age: 20 }) 傳入 process
    process(Maybe.of({ name: 'Alice', age: 20 }));
    
    等價於以下:
    map(add10)(map(getAge)(Maybe.of({ name: 'Alice', age: 20 }))) // map(getAge) 會透過 () 收到 fa 參數,也就是 Maybe.of 回傳的 Maybe 物件
    = map(add10)(Maybe.of({ name: 'Alice', age: 20 }).map(getAge)) // 得到參數後呼叫 Maybe 的 map 方法,之橫會得到新 Maybe 值
    = map(add10)(Maybe.of(20)) // 得到新 Maybe 值傳給 map(add10),作為 map(add10) 的 fa 參數
    = Maybe.of(20).map(add10) // map(add10) 得到參數後呼叫 Maybe 的 map 方法,之橫會得到新 Maybe 值
    = Maybe.of(30) // 拿到新的 Maybe 值
    

map(getAge) 最終會呼叫 Maybe 實例的 .map(getAge),也就是說,這是以 point-free 風格與函數組合(compose)的方式來「延後」傳遞 Functor 的部分,延後呼叫 .map 方法。

Point-free 重構

我們也可以將前面提到的 getProvinceWithMaybe 重構成一個完全 Point-free 的管線。

//柯里化的 prop 函式
const prop = curry((key, obj) => obj? obj[key] : undefined);

// 獨立的、柯里化的 map 函式
const map = curry((fn, functor) => functor.map(fn));

// 建立一個可重用的資料處理管線(compose 由右到左執行)
const getProvincePipeline = compose(
  map(prop('province')),
  map(prop('address')),
  map(prop('accountDetails'))
);

// 最終的 Point-free 函式(資料放最後)
const getProvincePointFree = compose(
  (maybeValue) => maybeValue.getOrElse('Default Province'),
  getProvincePipeline,
  Maybe.of
);

// 使用
console.log(getProvincePointFree(userWithProvince));   // 'NS'
console.log(getProvincePointFree(userWithoutAddress)); // 'Default Province'

Point-free 的 map 不再只是一個物件上的方法,它是一個高階函式,其作用是將一個普通函式「提升(lift)」到一個更複雜的脈絡中去運作。像 prop('address') 這樣的函式只懂如何處理普通物件,它對 Maybe 一無所知。但是 map(prop('address')) 這個組合會回傳一個新的函式,這個新函式知道如何在 Maybe 容器內部安全地執行 prop('address')。Point-free 風格讓這種「提升」的過程變得明確,這正是 Functor 的核心價值:提供一個通用機制,讓我們所有簡單、純粹的工具函式,都能在更複雜的脈絡(如值的缺席)中發揮作用。

小結

以下幾點總結今天的重點。

為什麼要有 Maybe?

為了安全地處理 nullundefined,避免程式在執行期間因空值而崩潰。它讓我們能用宣告式的方式取代繁瑣、容易出錯的 if 判斷,保護函數組合的優雅與完整性。

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

  • 沒有 Maybe:我們必須手動在程式碼中混入大量的防禦性檢查,導致業務邏輯與錯誤處理邏輯耦合在一起,降低了程式碼的可讀性和可維護性。
  • 有了 Maybe:我們將空值檢查的責任轉移給了 Maybe 容器本身。業務邏輯被清晰地表達為一系列的 .map 操作,形成一條線性、易於理解的數據處理管線,實現了關注點分離。

所以 Maybe 是什麼?

Maybe 是一個 Functor,一個明確代表「值可能存在,也可能不存在」的容器。它有兩種狀態:Just(value) 表示值存在,Nothing 表示值缺席。其核心在於 .map 方法的行為:在 Just 上它會套用函式,而在 Nothing 上它會直接跳過,安全地「短路」整個運算鏈,確保管線永不斷裂。

Reference


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

尚未有邦友留言

立即登入留言