iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png

同步與非同步

Javascript是單執行緒(Single thread)的語言,也就是說,程式的執行是依照程式碼的順序按步就班的執行。單執行緒最大的困擾便是會產生阻塞的情形,也就是當部分的程式碼執行時間過久,而這些程式碼執行的結果並不影響後面程式的執行,而後面的程式碼依然會被延後執行,而造成程式卡頓的情形。為了解決程式阻塞的問題,Javascript引擎利用事件循環(Event Loop)、事件佇列(Event Queue)和呼叫堆疊(Call Stack)的機制設計了非同步執行的模式。因此,當你有部分的程式執行可以獨立進行時,便可啟動非同步執行;Javascript會將同步執行的部分執行完畢後,再依序啟動非同步執行。
在ES6之後,非同步執行的啟動方法是建構一個Promise物件,它的建構子需要提供一個有兩個輸入參數的函數,這二個參數通常取名為resolve(第一個參數)和reject(第二個參數),二個參數名稱可自行命名,第一個參數resolve函數負責回傳成功的結果,第二個參數reject函數負責回傳失敗的結果。我們傳給建構子的函數會同步執行,但是resolve和reject兩個函數只會執行一個。每個Promise會有PromiseState和PromiseResult兩個屬性,PromiseState的值為pending、fulfilled和rejected三者之一,如果你的建構函數尚未執行resolve或reject,PromiseState的值便是pending,PromiseResult的值是undefined;如果執行了resolve,PromiseState的值便是fulfilled,PromiseResult的值是resolve傳的值;如果執行了reject,PromiseState的值便是rejected,PromiseResult的值是reject傳送的值。

我們先看一個Promise的建構

const p = new Promise((resolve, reject) => {
  console.log('Promise begins')
  resolve(1)
  reject(2)
  console.log('Promise end')
})

console.log('Sync...');

執行結果如下:
Promise begins
Promise end
Sync...

注意,根據上面程式的測試,建構函數裏面的的程式是同步的,即便執行了resolve或reject之後,仍然會執行,而且和其它同步程式的部分依序執行。
Promise還有resolve和reject兩個靜態方法,Promise.resolve(3)等同於new Promise((resolve, reject) => resolve(3)),而Promise.reject(3)等同於new Promise((resolve, reject) => reject(3))

我們建構出來的Promise物件p有then、catch和finally三個方法(函數),這三個方法都會回傳一個新的Promise物件,所以可以用鏈接模式撰寫程式。我們都需要提供一個呼函數(callback function)給它們當參數,下面我們詳細說明這三個方法的邏輯。

  • then:它的回呼函數稱作onFullfilled,必須有一個參數,當PromiseState的值為fulfilled時,它會用PromiseResult的值代入回呼函數的參數,然後執行回呼函數,你的回呼函數回傳一個Promise,then便回傳這個Promise,其它的回傳值 v 則會改成回傳Promise.resolve(v),如果沒有return,則會變成回傳Promise.resolve(undefined);如果沒有提供回呼函數,then會直接回傳一個Promise.resolve(PromiseResult),把這個Promise的PromiseResult往後面傳下去。如果PromiseState的值為rejected時, 則不執行回呼函數,直接回傳一個Promise.reject(PromiseResult)。
  • catch:catch和then是對稱設計,它的回呼函數稱作onRjected,也必須有一個參數。當PromiseState的值為fulfilled時, 則不執行回呼函數,直接回傳一個Promise.resolve(PromiseResult),如果PromiseState的值為rejected時,行為和then遇到PromiseState的值為fulfilled時一模一樣,不同的是,如果沒有提供回呼函數,then會直接回傳一個Promise.reject(PromiseResult)。
  • finally:它的回呼函數不需要參數,finally會執行回呼函數,如果PromiseState的值為fulfilled時,回傳Promise.resolve(PromiseResult);PromiseState的值為rejected時,回傳Promise.reject(PromiseResult),回呼函數的return沒有作用。

如果在三個方法上拋出錯誤,則會回傳Promise.reject(e.message)
依照這個邏輯,你可以隨意鏈結這三個方法。

如果覺得上面的實作細節太過複雜,你只要知道這樣設計的目的是讓then負責fulfilled,catch負責rejected,兩個方法只會選一條路走即可。

const p: Promise<number> = new Promise((resolve, reject) => {
  console.log('Promise begins');
  resolve(1);
  reject(2);
  console.log('Promise end');
});

const p1 = p
  .then((v) => {
    console.log(v);
    // throw Error('Error in then');
    return Promise.reject(2);
  })
  .catch((e) => {
    console.log(e);
    // throw Error('Error in catch');
    return 3;
  })
  .finally(() => {
    // throw Error('Error in finally');
    console.log('finally');
  })
  .then((v) => {
    console.log(`Success ${v}`);
  })
  .catch((e) => {
    console.log(`Failure ${e}`);
  })
  .finally(() => console.log('finally again'));

console.log('Sync...');
console.dir(p1);

你可以調整p裏面resolve和reject的順序,或是依條件回傳,來決定回傳的狀態

const p = (n: number): Promise<number> => new Promise((resolve, reject) => {
  console.log('Promise begins');
  n > 0 ? resolve(n) : reject(n);
  console.log('Promise end');
});

如此便能控制p(n)是resolve還是reject的Promise,你可以多做實驗以確實了解then、catch和finally的行為。實務上,我們會有很多個then用來鏈結處理回傳有的值,而用一個catch則做為錯誤處理,一個fnally處理必然要做的事,上面的程式純綷作為測試了解這三個方法的行為。

我們的函數式程式設計風格並不太使用鍵接模式,我們將採用另一種Javascript提供的async/await語法,上面的p我們可以改寫成

const p = async (n: number) => {
  console.log('Promise begins');
  console.log('Promise end');
  if (n > 0) return n;
  throw n;
};

async語法中的return n等同於Promise建構函數的resolve(n),而async語法中的throw n等同於Promise建構函數的reject(n),你如果看一下編輯器上p的型別簽名,兩種寫法都是const p: (n: number) => Promise<number>。這種async語法建立的p一樣可以使用then,catch和finally的鏈接語法,但是為了風格一致,我們會更傾向於使用aync/awain的寫法。

const chainP = async () => {
  try {
    const v = await p0(3);
    console.log(v);
  } catch (e) {
    console.log(e);
  } finally {
    console.log('finally');
  }
};

chainP();

最後要記得執行chainP,才能得到最後的結果。

setTimeout

setTimeout是在Javascript中的一個計時器函數,可以要求Javascript引擎在一段時間後執行一個回呼函數(callback function),一樣是利用事件迴圈、事件佇列機制的非同步執行。它會回傳一個計時器ID,用這個計時器ID作為clearTimeout的參數便可取消這個回呼函數的執行,因為setTimeout回傳的事計時器ID,並不是Promise,所以它不能使用then、catch和finally三個方法,也不能使用async/await語法,我們可以將setTimeout用Promise包裝,成為延遲時間的函數,讓我們看看setTimeout函數的用法。

const timer = setTimeout(callback, delay) // dealy ms後執行callback
clearTimeout(timer) // 停止callback的執行
import { Task } from 'fp-ts/Task'
type Delay = (ms: number) => Task<void>;
export const delay: Delay = (ms) => async () =>
  new Promise((resolve) => setTimeout(resolve, ms));

這個delay函數便可以在async/await中使用。

const delay3seconds = async () => {
    await delay(3000)()
}
delay3seconds()

Task

接下來要進入我們fp-ts/Task模組的介紹。當我們了解了Promise和相關async/await語法,我們便可以很快的了解Task的型別簽名
type Task<A> = <A> () => Promise<A>Task<A>IO<A>非常類似,IO<A>是同步執行的型別A計算,Task則是非同步執行的型別A計算,兩者都是必須呼叫才能得到計算後的值,兩者都可通稱為LazyArg,因此函數合成的過程中能保持純函數的性質。當然Task<A>也是一個Monad Functor,因此模組中也有map、ap、flatMap函數讓我們轉換函數讓以便函數的合成能夠過順利。我們看看下面的程式範例:

在fp-ts型別簽名中的LazyArg<A>等同於IO<A>LazyArg<Promise<A>>等同於Task<A>

type Config = { logPrefix: string };
const loadConfigT: Task<Config> = async () => {
  await delay(2000)(); // delay(20)必須執行await才有意義
  return { logPrefix: '[Task Main]' };
};

type MakeLogger = (config: Config) => IO<void>;
const makeLogger: MakeLogger = (config) =>
  log(config.logPrefix + 'Hello world!');

const mainTask =
  pipe(
    loadConfigT,
    map(makeLogger), // makeLogger :: Config -> IO<void>
  );

mainTask().then(io => io());

因為makeLogger是Config -> IO<void>,因此map(makeLogger)的輸出型別將是Task<IO<void>>,這也是mainTask的輸出型別,如果執行mainTask()將會得到一個Promise,必須使用then方法取得Promise中resolve的值,而makeLoger回傳的是一個IO<void>,因此then裏面便是將io取出,然後執行這個io[io()],如果不想使用鏈接模式,我們要想辦法把Task<IO<void>型別變成Task<Task<void>>再flatten。
Task模組有一個fromIO的函數,它的型別簽名為
fromIO :: IO<void> -> Task<void>,我們可以用flatMat(fromIO)便可得到Task的輸出型別,最後只要執行mainTask()即可,不必使用then方法,這樣的寫法更符合我們FP的風格。以下是mainTask最終的呈現:

const mainTask =
  pipe(
    loadConfigT,
    map(makeLogger), // makeLogger :: Config -> IO<void>
    // flatMap((loggerIO) => of(loggerIO()))
    flatMap(fromIO) // fromIO :: IO<void> -> Task<void>
  );

mainTask()

ap的使用風格也是和其它Applicative Functor一樣的模式。

const taskNumber = (t: number) => (x: number) => async () => {
  await delay(t)();
  return x;
};
const add = (x: number) => (y: number) => x + y
const logNumber = (n: number) => log(`Sum is ${n}`);
const taskAdd = pipe(
  of(add),
  ap(taskNumber(3000)(2)),
  ap(taskNumber(2000)(3)),
  map(logNumber),
  flatMap(fromIO)
);
taskAdd();

其實我們只是將一些async/await風格轉變成函數式風格,使用「接管」(pipe)將各個資料串接。

我們可以用遞迴和Task寫一個forever的函數取代無窮迴圈while(true){}

import { Task, chain } from 'fp-ts/Task';
const forever = (ma: Task<void>): Task<void> =>
  pipe(
    ma,
    chain(() => forever(ma)) // recursion
  )

用遞迴設計函數時,當遞迴太深時會有堆疊不足(Stack blow)的情形,但是因為Task是非同步且延遲執行,遞迴建立等到非同步執行結果回傳才會發生,因此不會有堆疊不足(Stack blow)的狀況發生;如果使用IO實作forerver則會有堆疊不足(Stack blow)的危險。

TaskEither

Task只能處理Promise中必然resolve的情境,而沒有考慮reject的可能性。如果我們原來的的loadConfigT是下面這種情形,則我們的mainTask可能會合成失敗,造成程式拋出錯誤而中斷結束。

const loadConfigT: Task<Config> = async () => {
  await delay(2000)(); // delay(20)必須執行await才有意義
  const success = Math.random() > 0.2; // 70% chance to succeed
    if (!success) throw new Error('Failed to load config');
  return { logPrefix: '[Task Main]' };
};

要處理Promise中reject的部分,我們需要使用TaskEither<E, A>這個型別建構子,它比Task多了一個型別參數E,通常是我們的錯誤型別。而TaskEither最常用的建構函數是tryCatch,它需要兩個函數參數,第一個參數型別是Task<A>便是我們例子中的loadConfig;第二個函數的型別簽名則是(reason: unknown) => E,用途是將第一個函數拋出的錯誤轉換成型別E。下面是loadConfigT改寫成loadConfigTE並將函數合成的程式範例:

import * as TE from 'fp-ts/TaskEither'
const loadConfigTE: TE.TaskEither<Error, Config> = TE.tryCatch(
  async () => {
    await delay(4000)();
    const success = Math.random() > 0.8; // 70% chance to succeed
    if (!success) throw new Error('Failed to load config');
    return { logPrefix: '[TaskEither Main]' };
  },
  (reason) => (reason instanceof Error ? reason : new Error(String(reason)))
);

const mainTaskEither = pipe(
  loadConfigTE,
  TE.map(makeLogger), // makeLogger :: Config -> IO<void>
  TE.match( // TE
    (e) => error('Error:' + e.message),
    (v) => log('Logging done successfully')
  ),
  flatMap(fromIO)
);
mainTaskEither()

TaskEither & Task<Either>

TaskEitherTask<Either>的子集,在fp-ts中TaskEitherTask<Either>,可以自由轉換。

interface TaskEither<E, A> extends Task<Either<E, A>> {}
const taskOfEither: T.Task<E.Either<Error, string>> = T.of(E.right('Hello'))

const taskEither: TE.TaskEither<Error, string> = TE.right('Hello') ; 

const fromTaskOfEither: TE.TaskEither<Error, string> = taskOfEither;
const fromTaskEither: T.Task<E.Either<Error, string>> = taskEither;

TaskEither模組提供的API比較豐富,所以儘可能轉換TaskEither型別進行處理。

今日小結

Promise是typescript處理非同步非常重要的機制,它的邏輯不像同步執行的程式這麼直覺,需要花一點時間才能完全了解,尤其是then、catch和finally的運作細節。如果不想不求甚解,可以從設計的目的著眼了解,其一是為了能夠讓鏈接模式持續,每一個方法必定回傳一個Promise;其二是then和catch只會選一條走;其三是finally一定會經過。

fp-ts把Promise封裝成為Task或TaskEither,應用了Funtor的概念抽象化後,讓Task的操作和錯誤的處理(Option、Either),或是同步的計算(IO)都沒有什麼區別,使用同一種函數名稱(像map、ap和flatMap)在不同的容器中進行資料的流轉。這樣的思維徹底展現抽象化的威力,即便是複雜的非同步工作,我們如果維持在形而上的抽象層工作,也和其它型別容器沒有差別,這也是函數式程式設計風格的靈魂。

今天的內容就到此結束,明天再見。


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

尚未有邦友留言

立即登入留言