iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0

https://ithelp.ithome.com.tw/upload/images/20251003/20168201HtnxtpcoDI.png

前言

在前幾天的文章中,我們學會如何用 Maybe 容器處理空值,用 Either 容器處理錯誤路徑,透過容器這種把值包起來的方式,我們可以打造可預測、可靠的純函數。

但就像在 什麼是 Functional Programming? 以及 Pure Function 是什麼? 這兩篇所提到的,程式設計的世界裡不可能全部都是純函數,一個程式如果不能與外界互動,那它其實沒什麼用。我們需要的是:讀取使用者的輸入、從伺服器抓取資料、將結果呈現在畫面上。為了完成這些操作,我們需要讀取 input、發起 fetch 請求、更新 DOM,甚至是簡單的 console.log,而這些操作都屬於「副作用」(Side Effects),可看出很多時候副作用才是我們使用軟體的根本原因,我們不能完全避免副作用,而是要學會如何管理副作用。

那要如何管理副作用呢? Functional Programming 透過 IO 與 Task 這兩項工具來幫助我們管理,透過 IO 與 Task 這兩個容器,我們可以把「想對外界執行的動作」存在容器裡,容器本身並不「執行」動作,它僅僅「描述」了這個動作。這種將「描述」與「執行」分離的策略,能讓我們更安全的管理副作用。

今天會先介紹 IO,明天會再繼續介紹 Task~

為什麼要有 IO?

透過 IO,我們會延遲執行的時間,並且將「描述副作用」與「執行副作用」分離,而這就是 FP 管理副作用的關鍵概念。

先看一個簡單範例,這程式會從瀏覽器的 URL 中讀取一個查詢參數:

const getUrlParam = (key) => new URLSearchParams(window.location.search).get(key);

假設現在的 URL 是 https://example.com?name=ironman,第一次呼叫 getUrlParam 會是:

// 第一次呼叫
console.log(getUrlParam('name')); 
// 輸出: "ironman"

現在假設使用者自己修改 URL,或是我們透過 JavaScript 去修改 URL 參數,URL 變成了 https://example.com?name=ithome,這時第二次呼叫看看,會發現得到不同輸出:

// 第二次呼叫,完全相同的輸入,卻可能得到不同的輸出
console.log(getUrlParam('name')); 
// 輸出: "ithome" 

這個 getUrlParam 函式就是一個「不純」的函式,根據前面文章介紹過的,它變成一個 Action 而不是 Calculation。它的輸出不僅僅取決於它的輸入參數 key,還偷偷地依賴了一個全域、可變的外部狀態:window.location,而外部狀態 window.location 也就是我們前面說的隱性輸入。這個隱性輸入讓它變得不可預測、難以測試,也無法進行可靠的組合

這就是我們面臨的問題,也因此我們需要一種方法來處理這種情況,同時保護我們程式碼核心的純粹性,而針對同步的、會立即發生的副作用,我們可使用 IO 來管理。

所以 IO 是什麼?

IO 是另一種 Functor,但和前面介紹的 Maybe、Either 不同。Maybe 和 Either 容器內存放的是一個「值」,例如 Maybe.of(2)Either.of('hello')

而 IO 存放的則是一個「函數」,通常是沒有參數、回傳某個值的函數(例如 () => A),這個函數本身包含副作用。關鍵在於我們只存放這個函數,不去呼叫它。因為它沒有被執行,所以依然保持純粹;只有在需要時才會真正呼叫並執行它。

IO 的實作

接著我們來看看具體的程式實作,一樣用 JavaScript 來實現 IO。

class IO {
  static of(x) {
    return new IO(() => x); // 延遲執行,避免立刻求值產生副作用,在真正呼叫之前,無法知道 x 的值
  }

  constructor(fn) {
    this.$value = fn; // value 是一個函式,一個會延遲執行的函式
  }

  map(fn) {
    return new IO(compose(fn, this.$value)); // 透過 compose 組合新的函式 fn 和現有的值 $value
  }
}

IO 的核心概念是:

  • $value 永遠是一個函數(描述副作用的計算)。也可把 $value 改名叫 unsafePerformIO,讓我們知道這裡存了一個會執行副作用的函數,這函數是純粹世界與不純粹世界的邊界,清楚提醒「這是副作用,執行它會引發世界變化」
  • map 不會執行這個函數,而是透過函數組合,把新的純函數疊加上去。因此多次 map 只是把「待執行的動作」排隊,而不會真的發生

IO 是 惰性 (lazy) 的。只有在我們呼叫 $value()(或命名為 unsafePerformIO())時,先前累積的所有函數才會按順序執行。在此之前,程式依然保持純粹,不會有任何副作用。


因為明天介紹 Task 的文章會使用 fp-ts 函式庫,其中也會用到 fp-ts 的 IO,因此補充一下,這裡的 class IO 是為了方便理解的版本,讓我們能快速理解「容器內裝著一個描述副作用的函數」。但是在 fp-ts 中,IO 並不是 class,而是簡單的型別別名:

export interface IO<A> {
  (): A;
}

也就是說,IO<A> 就是 () => A。例如:

const ioNumber: IO.IO<number> = () => 42;

所以實務上不需要自己實作 class,直接用 () => A 就能表示 IO。

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

現在我們還是先用自己實作的 class IO,來試試用 IO 改寫前面的取得 URL 參數的範例:

// 一些前面提過的輔助函式
function curry(fn) {
  const arity = fn.length;
  return function $curry(...args) {
    if (args.length < arity) {
      return $curry.bind(null, ...args);
    }
    return fn.call(null, ...args);
  };
}
const map = curry((fn, f) => f.map(fn));
const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x);

// ----- 主要程式 -----

const getUrlParam = (key) => new URLSearchParams(window.location.search).get(key);

// 建立一個 IO 來「描述」讀取 URL 參數的動作,這裡有先傳入要讀取的 key
const readNameParam = new IO(() => getUrlParam('name'))

// 用 point-free 的 compose + map 來組合管線
const createGreeting = compose(
  map(name => `WELCOME, ${name}!`),
  map(name => name.toUpperCase()),
  map(name => name || 'Guest'),  
)(readNameParam)

// 到目前為止,什麼事都還沒發生,createGreeting 仍然只是一個 IO 物件,存放一個函數
console.log(createGreeting); 
// 輸出: IO { $value: [Function] }

// 在需要時才呼叫它
// 只有當我們明確呼叫 $value() 時,整個計算鏈才會被觸發,
// 從讀取 URL 到所有 map 函式,一次性地執行
const result = createGreeting.$value(); 
console.log(result); 

有 IO 與沒有 IO 的區別就像是「一份食譜」和「馬上開煮」的差別,可以這樣比喻:

  • 沒有 IO:直接呼叫 new URLSearchParams(...) 就像你拿到食材就立刻開始切菜、下鍋、烹煮。動作立即發生,廚房立刻變得油煙瀰漫。
  • 有 IO:建立一個 IO 實例,就像是你仔細地寫下一份詳細的食譜。食譜本身只是一張紙,上面記載了步驟,但廚房依然保持乾淨整潔。你並沒有真的開始做菜。

https://ithelp.ithome.com.tw/upload/images/20251003/20168201iQQFANC9TM.png
圖 1 有 IO 與沒有 IO 的差異示意圖(資料來源: ChatGPT & 部分自行繪製)

程式碼上的對比如下:

// 直接執行 (馬上開煮)
const param = new URLSearchParams(window.location.search).get('name');

// 建立描述 (一份食譜)
const readNameParam = new IO(() => new URLSearchParams(window.location.search).get('name'));

在這裡,readNameParam 本身只是一個純粹的物件,內部儲存了「如何讀取 URL」的食譜,但並沒有執行它。副作用被延遲,等待一個明確的時機才被釋放,這正是 IO 作為「延遲計算容器」的價值。

https://ithelp.ithome.com.tw/upload/images/20251003/20168201ZxIkRpUHXo.png
圖 2 IO 管線延遲執行示意圖(資料來源: 自行繪製 & 部分 Gemini 生成)

動作轉化為值

傳統程式碼中,console.log('hello') 是一個指令,會立即執行副作用。但在 FP 世界,我們希望能把「動作」轉化為「值」,方便傳遞與組合。

const logHello = new IO(() => console.log('hello'));

這行程式不會真的輸出東西,而是產生一個 IO 物件,代表「記錄日誌」這個動作。因為它現在是一個值,所以我們可以像處理其他值一樣,對它做 .map、組合或傳遞。這就是 IO 的核心概念:讓動作(Action)能以資料的形式存在並被操作。

更多 IO 的範例

再補充一些簡單的 IO 範例程式,可看到它也可應用在 DOM 取值的副作用上。

// ioWindow :: IO Window
const ioWindow = new IO(() => window);

ioWindow.map(win => win.innerWidth);
// IO(1430)

ioWindow
  .map(prop('location'))
  .map(prop('href'))
  .map(split('/'));
// IO(['http:', '', 'localhost:8000', 'blog', 'posts'])


// $ :: String -> IO [DOM]
const $ = selector => new IO(() => document.querySelectorAll(selector));

$('#myDiv').map(head).map(div => div.innerHTML);
// IO('I am some inner html')

IO 小結

小結來說,IO 有幾個優點:

  • 維持函數純粹:函式可以回傳 IO 而不是直接產生副作用,保持本身純潔。
  • 邏輯可組合:可以用 map 等操作把多個副作用步驟串聯起來,構成清晰的資料處理流程。
  • 延後執行:將副作用延遲到明確的時機才執行,由應用程式的外圍決定何時觸發。只要不調用執行函數,整個世界就保持純淨無暇。

接著明天會看看如何用 Task 來處理非同步的副作用~

Reference


上一篇
[Day 18] Either Functor:處理錯誤
下一篇
[Day 20] Task:處理非同步副作用
系列文
30 天的 Functional Programming 之旅24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言