iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png
今天要介紹令人聞名喪膽的Monad Functor,因為名字聽起來就像怪獸一樣。

Monad

我們要先從Day12最後的例子說起,但是我們將Either修改成Option讓我們更容易理解。

import { none, some } from 'fp-ts/Option'
type SafeReciproca = (x: number) => Option<number>;
const safeReciprocal: SafeReciproca = (x) =>
x === 0 ? none : some(1 / x);

const f = flow(
    safeSqrt, // number -> Option<number>
    map(decrement), // Option<number> -> Option<number>
    map(safeReciprocal), // Option<number> -> Option<Option<number>>
);

我們再檢視函數合成的輸出入型別,safeSqrt的輸出是Option<number>,而decrement的輸入是number, 兩個型別不同,所以無法直接合成,因此必須用map提升decrement到Option型別容器內,所以map(decrement)的輸出入都是Option<number>,safeReciprocal的輸入是number,無法合成,所以我們又將safeReciprocal提升到Option。雖然現在可以進行合成,但是問題來了,safeReciprocal的輸出Option<number>跟著提升,map(safeReciprocal)的輸出提升到Option<Option<number>>,於是有了雙層Option的嵌套,如果我們需要解開一層嵌套得到型別Option,fp-ts在每個容器模組都提供了flatten這個函數,所以上面的f函數只要改成下面的樣子,便可以得到我們所要的答案。

import { flatten, map } from 'fp-ts/Option'
const f = flow(
    safeSqrt, // number -> Option<number>
    map(decrement), // Option<number> -> Option<number>
    map(safeReciprocal), // Option<number> -> Option<Option<number>>
    flatten // Option<number
);

事實上,當你要轉換的函數的輸出本身就在容器內(有效果),轉換之後通常跟隨著flatten,所以將這兩個函數合成便得到flatMap這個函數,上面的函數f就可以改成

import { flatMap, map } from 'fp-ts/Option'
const f = flow(
    safeSqrt, // number -> Option<number>
    map(decrement), // Option<number> -> Option<number>
    flatMap(safeReciprocal), // Option<number> -> Option<number>
);

flatMap另外還有一個名字叫做chain,在fp-ts中兩個名字都可以使用,個人比較習慣用flatMap這個名字,可以清楚的理解是map之後馬上fltten,以後的程式碼會固定用flatMap,有實作flatMap的介面便稱為Monad。
下面的flatMap的HM型別簽名:
flatMap :: (Monad m) => (a -> m b) -> m a -> m b

其中::和=>之間表示型別限制,此例中 Monad m 表示 m 必須是一個Monad型別建構容器。

現在我們再看看day12的Either,

  import { Either, Right, Left, right, map, match} from 'fp-ts/Either'
  type EvaluationError =
  | { type: 'DIVISION'; message: string }
  | { type: 'SQRT'; message: string }

  const divisionError: EvaluationError = {
    type: 'DIVISION',
    message: '除數不能為0',
  };
  const sqrtError: EvaluationError = {
    type: 'SQRT',
    message: '根號裏面不能有負的',
  };

  type SafeSqrt = (x: number) => Either<EvaluationError, number>;
  const safeSqrt: SafeSqrt = (x) =>
    x < 0 ? left(sqrtError) : pipe(x, Math.sqrt, right);
  type Decrement = (x: number) => number;
  const decrement: Decrement = (x) => x - 1;
  type SafeReciprocal = (x: number) => Either<EvaluationError, number>;
  const safeReciprocal: SafeReciprocal = (x) =>
    x === 0 ? left(divisionError) : right(1 / x);

  const f = flow(
    safeSqrt,
    map(decrement),
    flatMap(SafeReciprocal),
    match(
      (e) => e.message,
      (x) => `您得到的值是${x}`
    )
  );
  console.log(f(25)) // 您得到的值是0.25
  console.log(f(-3)) // 根號裏面不能有負的
  console.log(f(1)) // 除數不能為0

實例 - 搶數字遊戲

最後我們用一個昌爸數學工作坊的搶數字遊戲作為結束,這個遊戲在小時候非常受歡迎的卡通「北海小英雄」出現過,這個遊戲有個必勝的技巧,您可以猜猜看。遊戲前先約定要搶的最終數字(target)和添加整數的範圍1~n(Bets),遊戲開始由先玩者first從添加範圍選一個數,後玩者Second再從1~n選一個數加上前者first的報數,first再添加數到Second的報數,如此這般兩人輪流報數,誰能在幾回合後搶到最終數字就贏得勝利。
例如:first、Second兩人輪流報數搶數字40,後者Second可將前者first所報數添加1~5其中一個整數,如果first首先報數是2,則後者Second報數是3至7;如果Second報數6,則first報數7至11,.....直到first或R搶到40為止。
我們先定義的我們的型別,GameoverMessage型別是我們Either的Left類別,作為遊戲終止用的類別

// import
import * as E from 'fp-ts/Either';
// Types Definition
type Bet5 = 1 | 2 | 3 | 4 | 5
type GameoverMessage =
  | { type: 'First'; message: string }
  | { type: 'Second'; message: string };
type Play = (b: Bet5) => (acc: number) => E.Either<GameoverMessage, number>;

const target: number = 30;
const firstGameOver: GameoverMessage = {
  type: 'First',
  message: 'First player wins the game',
};
const secondGameOver: GameoverMessage = {
  type: 'Second',
  message: 'Second player wins the game',
};

const init: number = 0;
const firstPlay: Play = (b) => (acc) =>
  b + acc >= target ? E.left(firstGameOver) : E.right(b + acc);
const secondPlay: Play = (b) => (acc) =>
  b + acc >= target ? E.left(secondGameOver) : E.right(b + acc);

firstPlay是柯里化的函數,b是player要加上的數字,因此firstPlay(3)回傳函數的輸入型別是number,輸出型別是Either<GameoverMessage, number>,secondPlay(2)也是一樣的情形,因此如果我們無法直接合成firstPlay(3)、secondPlay(2),要調整為合成firstPlay(3)、chain(secondPlay(2))。

const game1 = pipe(
    E.right(init),
    E.flatMap(firstPlay(3)),
    E.flatMap(secondPlay(5)),
    E.flatMap(firstPlay(2)),
    E.flatMap(secondPlay(1)),
    E.flatMap(firstPlay(5)),
    E.flatMap(secondPlay(5)),
    E.flatMap(firstPlay(3)),
    E.flatMap(secondPlay(4)),
    E.flatMap(firstPlay(3)), // 數字已經超過30,比賽結束
    E.flatMap(secondPlay(5)), // 不會有任何影響, GameoverMessage會往下傳遞
    E.flatMap(firstPlay(2)),
    E.flatMap(secondPlay(3)),
    E.match(
      (e) => e.message,
      (x) => `目前的總值是${x}`
    )
  );

練習

有興趣的朋友可以在GameoverMessage型別再聯集物件型別{ type: 'Error'; message: string },並定義const errorGameOver: GameoverMessage = { type: 'Error', message: 'Bets is not in the range'}來表示玩的人加的數目超過預定的範圍。

今日小結

今天學了Monad中重要的函數flatten和flatMap,fltten可以將嵌套兩個相同的型別建構容器簡化為一個,如果緊接在map後面使用flatten則可以合併簡化為flatMap,flatMap還有另外一個名字「chain」,在非同步工作的時候,你會發現flatMap的行為和鏈接模式設計的味道類似,而鏈接模式最害怕的是連續巢狀(類似地獄回呼),函數式程式設計針對這種情形,有所謂的Do Notation的設計,明天的IO Action會介紹這個概念。

如果你要轉換的函數是「無效果 -> 無效果」(例如:number -> number),此時便是使用map的時機;而當你要轉換的函數是「無效果 -> 有效果」(例如:number -> Option),此時該使用flatMap的時機。將map和flatMap的使用機會了解清楚,函數的接管(pipe)便會順暢無誤,值得好好練習。今天的分享內容就到這邊,明天再見。


上一篇
Day 13. 隔空取物 - Applicative Functor
下一篇
Day 15. 輸出入處理 - IO & Do notation
系列文
數學老師學函數式程式設計 - 以fp-ts啟航20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言