iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png
雖然函數式程式設計一直強調純函數(Pure Function),不要有Side Effect;但是一個系統程式,不太可能不和使用者、資料庫、終端機…等真實世界接觸,所以如何處理這些IO的執行也是非常重要。要處理這些與真實世界的接觸,主要依賴的便是IO和Task兩個型別建構容器,今天我們就來介紹IO。

無參數型別 - IO thunk

為了保持純函數,又要設計要執行的IO函數,fp-ts用的是hunk的技巧;所謂的thunk便是<A>() => A這種無參數型別的函數,我們定義為IO<A>,舉例來說

type IO<A> = () => A
const name = 'John'
const age = 13
const ofName: IO<string> = () => name
const ofAge: IO<number> = () => age

type Log = (message: string) => IO<void>
const log: Log = (message) => () => console.log(message) // 回傳一個IO<void> () => console.log(message)

() => console.log(message)這個thunk只有執行console.log的函數,並沒有回傳值,這種情形它的型別便是IO,其實在執行Javascript沒有回傳值的函數,系統會自動回傳一個undefined,如果沒有任何輸入的函數,我們也可以看作的輸入型別是void。

其本上IO建構子只是將所有要傳遞的資料封裝進一個thunk的輸出端,所以IO這個型別建構子也是一個容器,也可以實作map函數成為IO Functor,我們先看看其它Functor的型別簽名

// Option Functor
map: <A, B>(f: (a: A) => B) => (fa: Option<A>) => Option<B>

// Array Functor 
map: <A, B>(f: (a: A) => B) => (fa: Array<A>) => Array<B>

// Either<E> Functor 
map: <A, B>(f: (a: A) => B) => (fa: Either<E, A>) => Either<E, B>

從上面三個Functor可以看出,任何的Functor F,都要有一個對應的map,它的型別簽名是

<A, B>(f: (a: A) => B) => (fa: F<A>) => F<B>

typescript並不支援Higher Kinded Type,因此這樣的型別簽名在typescript是不合法,這裏是為了說明方便,借用這個寫法,我們之後會針對fp-ts的HKT機制做進一步的說明。

IO的map的型別簽名就會是

<A, B>(f: (a: A) => B) => (fa: IO<A>) => IO<B>

怎麼實作這個IO的map呢?其實就是將我們map的函數f和IO型別的函數合成即可,以下是IO模組的of函數和map函數實作:

const of = <A>(a: A): IO<A> => () => a
const map = (f) => (fa) => () => f(fa())

和其它的Functor一樣,也實作了ap、flatMap(chain)所以稱之為IO Monad更為適合,而這些函數抽象層使用方法與Array、Option和Either完全一樣,在函數合成的時候最常用的便是map和flatMap(chain)兩個,我們再將它們的使用概念說明一次。我們要map和flatMap(chain)的函數,當它們的輸入都是未經型別容器建構的型別(無效果),像是number, string, boolean…等,但是前一個函數的輸出端卻是一個Functor或Monad型別容器所建構的型別(有效果),因些我們需要將我們原來number、 string或boolean等型別轉換到這些型別容器所建構的型別上,如此才能將函數合成;map和flatMap不同的地方是,如果要轉換的函數的輸出也是未經型別容器建構的型別(無效果),那就直接用map函數轉換即可,而要被轉換的函數的輸出是經型別容器建構的型別,那麼要使用flatMap函數來轉換以便處理建構子雙重嵌套的狀況。不論是經過map或flatMap轉換過的函數,它們的輸出一定都是經過型別容器建構的型別。因此,一旦經過map或flatMap之後,就必須一直使用map或flatMap。

最後是IO建構型別取值的部分,IO建構型別取值非常簡單,因為每個IO型別都是一個thunk,只要執行這個thunk就可以了。

import { of } from 'fp-ts/IO'
const ioA = of(3)
const a = ioA() // 3

這些IO thunk的目的是將這些要執行的副作用裝上開關,當開關沒打開時,沒有和外面的世界接觸,所以維持了純函數的特性,要執行了thunk,才會和外面的資料世界有了接觸。所以通常函數式程式設計,會將這些負責IO的部分推到程式最後面。

Console、Date、Random

由於Console、Date和Random都不是純函數,所以fp-ts都已經改寫成IO<A>型別,相關的函數分別放在Console、Date和Random模組。下面的程式碼舉一些最常用的例子。

import { IO, of, map, flatMap, ap, Do, bind, apS } from 'fp-ts/IO';
import { flow, pipe } from 'fp-ts/function';
import { log } from 'fp-ts/Console';
import { create, now } from 'fp-ts/Date';
import { randomInt, randomRange } from 'fp-ts/Random';

const toDateString = (date: Date): string => date.toDateString();

const getDateNumber = pipe(now, map(String), flatMap(log)); // IO<number> -> IO<string> -> IO<void> 
const getDateString = pipe(create, map(toDateString), flatMap(log)); // IO<Date> -> IO<string> -> IO<void>
getDateNumber(); // 1755220025401
getDateString(); // Fri Aug 15 2025

type Add = (x: number) => (y: number) => number;
const add: Add = (x) => (y) => x + y;
const run = <A>(f: IO<A>): A => f();

const randomSum = pipe(
  of(add),
  ap(randomInt(1, 10)),
  ap(randomInt(1, 10)), // IO<number>
  flatMap((sum) => log(`Sum is ${sum}`)) // flatMap(number -> IO<void>) => IO<number> -> IO<void>
);
run(randomSum); // randomSum()

最後我們用prompt-sync這個套件來進行控制台的輸入,建了觀察資料流在pipe中的變化,我們加入了traceIO函數,你可以安置在pipe中的任一階段。

// import 部分省略
// --- 偵錯程式 ---
const traceIO =
  <A>(tag: string) =>
  (x: IO<A>): IO<A> => {
    console.log(tag, x());
    return x;
  };
// --- 輸入函數 ---
type GetLine = (s: string) => IO<string>;
const getLine: GetLine = (p) => of(prompt(p));

// --- 主程式 ---
const main = pipe(
  getLine('Input the first number: '), // IO<string>
  traceIO('First number is '),
  flatMap((s1) => {
    const x: number = parseInt(s1);
    const y: IO<number> = pipe(
      getLine('Input the second number: '), // IO<string>
      map(parseInt) // map(string -> number) => IO<string> -> IO<number>
    );
    const sum: IO<number> = pipe(y, map(add(x)));
    return sum;
  }), // flatMap(string -> IO<number>) => IO<string> -> IO<number>
  flatMap((sum) => log(`Sum is ${sum}`)) // flatMap(number -> IO<void>) => IO<number> -> IO<void>
);
// --- 執行程式 ---
main();

注意程式碼中使用了巢狀flatMap,巢狀愈深,程式碼可讀性會變困難,也就是所謂的「回呼地獄(callback hell)」,fp-ts使用了IO(Record就是typescript的物件)模擬了Haskell的Do Notation,將上層輸入的變數加以綁定(bind)以去除巢狀,以下是Do、apS和bind三個函數的說明:

1. Do函數會起一空的物件,型別是IO。

2. apS函數的第一個參數是字串(string),第二個參數必須是IO<A>型別,它會將第二參數的值以第一個參數為鍵(key),加到pipe中上一個函數輸出的IO<Record>的物件中。

3. bind函數的第一個參數也是字串(string),第二個參數必須是輸出為IO<A>型別的函數,它相當於flatMap的參數,它會將第二參數輸出的值以第一個參數為鍵(key),加到pipe中上一個函數輸出的IO的物件中。
以下是一個簡單的範例:

// 其它部分省略
// --- 主程式 ---
const main = pipe(
  Do, 
  traceIO('Init is'), // Init is {}
  apS('s1', getLine('Input the first number: ')), // Input the first number: 1
  traceIO('s1 is'), // s1 is { s1: '1' } 輸出是IO<Record> 
  bind('x', ({ s1 }) => of(parseInt(s1))), 
  traceIO('x is'), // x is { s1: '1', x: 1 } 輸出是IO<Record>
  apS('s2', getLine('Input the second number: ')), // Input the second number: 2
  traceIO('s2 is'), // s2 is { s1: '1', x: 1, s2: '2' } 輸出是IO<Record>
  bind('y', ({ s2 }) => of(parseInt(s2))),
  traceIO('y is'), // y is { s1: '1', x: 1, s2: '2', y: 2 } 輸出是IO<Record>
  map(({ x, y }) => x + y),
  flatMap((sum) => log(`Sum is ${sum}`)) // flatMap(number -> IO<void>) => IO<number> -> IO<void>
);

// --- 執行程式 ---
main(); // Sum is 3

今日小結

IO容器就是將我們的資料、函數都包上一層開關,只有當開關開啟,這些資料和函數才能運作,如此便能達到純函數的目的。而資料和函數都包著開關,資料無法進入函數中運算,因此需要map函數將外層的開關打開,再重新包上開關,也就將兩個開關整合成一個開關。
函數式程式設計通常會將與IO操作有關的部分獨立分開放在程式入回main的部分,而main本身是一個IO thunk,我們必須記得執行main函數;Haskell程式架構也是類似這樣,由於它是專為函數式程式設計開發的語言,它會自動執行main的程式。今天的內容就到此結束,明天我們將進行非同步執的內容。


上一篇
Day 14. 化同存異 - Monad Functior
下一篇
Day 16. 非同步工作 - Task & TaskEither
系列文
數學老師學函數式程式設計 - 以fp-ts啟航20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言