上上篇(Day8)我們有講到盡可能 run Effect 在你的程式邊界(Edge),如果你整個程式都是由 Effect 組成,那大部分的時候你應該只會執行你的 Effect 程式一次。
這麼做的好處在於:
Date.now()
、Math.random()
)。run
驗證即可。不過,當你必須與非 Effect 的程式或第三方 library 互動時,就會需要在程式執行流程中,取得部分結果傳遞給非 Effect 的程式,因此就需要執行多次 Effect 才能達到目的。
pipe
組裝函式到目前為止,我們所教過的 Effect 語法還相當受限:只能建立非常基礎的 Effect。但「真正的」程式遠比這複雜許多,它們通常涉及資料在流程中不斷轉換。
我們直接來看如何用 pipe
組裝不同的 function。展示一個具有完整資料流的程式。
import { pipe } from "effect";
const getDate = () => Date.now();
const double = (x: number) => x * 2;
const toString = (x: number) => x.toString();
const toUpperCase = (x: string) => x.toUpperCase();
const program = () => pipe(getDate(), double, toString, toUpperCase);
pipe
是一個函式,會接收一個值與一個 list of functions,然後依序把這些函式套用在該值上。它看起來幾乎和「物件方法鏈(Method chaining)」的寫法一樣,但資料不綁定在物件內。
需要注意的是,pipe
裡的每個 function 都必須只收一個參數,因為前一步的輸出會成為下一步的輸入;如果某個函式需要多個參數,就需要先做partial application。
❖ 補充:partial application 是什麼?
把一個需要多個參數的函式,先預填一部分參數,得到一個只需要剩下參數的新函式。這樣就能把多參數函式改造成 pipe
可用的「一元函數 (unary function)」。
function add(a: number, b: number): number {
return a + b;
}
// 預填 a = 10,得到只需要 b 的一元函式
const add10 = add.bind(null, 10);
add10(5); // 15
❖ 補充:
pipe
也可以直接接在 Effect type 後面使用
const after = Effect.succeed(5).pipe(
Effect.map((x) => x * 2),
Effect.map((x) => x.toString()),
);
pipe
的好處pipe
是一種以精簡、模組化方式組織應用程式並處理資料轉換的好方法,帶來以下好處:
pipe
中 function 的輸入/輸出型別明確,確保資料正確流動並降低執行期錯誤。不過我們真正要的是組裝 Effect,而不是組裝 function。這時就要介紹 Effect.map
。我一起來看看它的語法吧~
import { Effect, pipe } from "effect"
// ┌─── Effect.Effect<number, never, never>
// ▼
const getDate = Effect.sync(() => Date.now())
// ┌─── Effect.Effect<string, never, never>
// ▼
const program = pipe(
getDate, // Effect.Effect<number, never, never>
Effect.map((x) => x * 2), // Effect.Effect<number, never, never>
Effect.map((x) => x.toString()), // Effect.Effect<string, never, never>
Effect.map((x) => x.toUpperCase()) // Effect.Effect<string, never, never>
)
可以透過上面這段程式碼看出,我們透過 map
將多個 Effect 轉換成單一一個 Effect。我們觀察每一階段輸出的 Effect type(註解的部分),可以發現用 Effect.map
我們能轉換每一個階段的「值(A)」,不會改變錯誤(E)與依賴(R),且仍為待執行的 Effect。
Effect.map
上一個例子中,我們講解了怎麼用 map
組合多個 function。不過這不太夠用,因為我們會希望 Effect 能夠覆蓋整個程式,所以我們 pipeline 中的步驟很多時候會是 Effect 才對,而不是一個值。這樣我們才能妥善的處理 Error 和依賴。所以我們來組合 Effect 吧~
// ┌─── Effect.Effect<never, Error, never> | Effect.Effect<number, never, never>
// ▼
const divide = (a: number, b: number) =>
b === 0
? Effect.fail(new Error("Cannot divide by zero"))
: Effect.succeed(a / b)
// ┌─── Effect.Effect<Effect.Effect<number, Error, never>, never, never>
// ▼
const program = pipe(
// Effect.Effect<number[], never, never>
Effect.succeed([25, 5]),
// Effect.Effect<Effect.Effect<never, Error, never> | Effect.Effect<number, never, never>, E, R>
Effect.map(([a, b]) => divide(a, b))
)
const result = Effect.runSync(program)
console.log(result) // { _id: 'Exit', _tag: 'Success', value: 5 }
從上面程式碼我們可以發現,我們透過 map
將多個 Effect 轉換成單一一個 Effect。但這個輸出是 Effect<Effect<A, E, R>, E, R>
,而不是我們想要的 Effect<A, E, R>
。因為會造成以下兩個問題:
所以這時我們需要用 flatMap
來展平多層 Effect:
const program = pipe(
Effect.succeed([25, 5] as const),
Effect.flatMap(([a, b]) => divide(a, b))
)
const result = Effect.runSync(program)
console.log(result) // 5
好欸,這樣我們 runSync
後就得到了我們想要的 value 了!
Effect.tap
如果今天有一個情境想要在不中斷資料流的情況下產生副作用,我們可以怎麼做呢?
const program2 = pipe(
Effect.sync(() => Date.now()),
Effect.map((x) => x * 2),
Effect.map((x) => {
console.log(x);
return x;
}),
Effect.map((x) => x.toString()),
Effect.map((x) => x.toUpperCase()),
);
從上面程式碼,我們直接把副作用寫在 Effect.map
裡;這樣做會導致可觀測性變差。副作用應該要用「Effect 的方式」包起來,Runtime 才看得到。看得到,才記得住誰發生、什麼時候發生、發生幾次;直接寫在 map
裡就像黑箱,外層完全感知不到,沒法做相應的處理。容易發生未預期的錯誤。這時就要介紹我們新的語法 Effect.tap
,讓我們執行副作用,但不會改變資料流中的值型別。
const program = pipe(
Effect.sync(() => Date.now()),
Effect.map((x) => x * 2),
Effect.tap((x) => Effect.sync(() => console.log("after double:", x))),
Effect.map((x) => x.toString()),
Effect.map((x) => x.toUpperCase())
)
const result = Effect.runSync(program)
console.log(result)
Effect.as
當你不在意前一步計算出來的值、而只想回傳一個固定常數時,用 Effect.as
最簡潔。它會「忽略目前 Effect 的成功值」,以你提供的常數取代;同時不改變錯誤型別與環境依賴。但請記得 Effect 是不可變的:as
會回傳新的 Effect,不會修改原本的 Effect。
// 以常數取代原值(5 → "new value")
const program = pipe(
Effect.succeed(5),
Effect.as("new value")
)
Effect.runPromise(program).then(console.log) // "new value"
Effect.as(constant)
與 Effect.map(() => constant)
在邏輯上等價,但前者更語意化、可讀性更高。
Effect.all
並行收集多個 Effect 的結果,再做匯總轉換:
const now = Effect.sync(() => Date.now())
const yesterday = Effect.sync(() => Date.now() - 24 * 60 * 60 * 1000)
const arrProgram = pipe(
Effect.all([now, yesterday]),
Effect.map(([a, b]) => a + b)
)
const objProgram = pipe(
Effect.all({ a: now, b: yesterday }),
Effect.map(({ a, b }) => a + b)
)
console.log(Effect.runSync(arrProgram))
console.log(Effect.runSync(objProgram))
陣列或物件都支援;輸出會保持相同結構,方便取值。
zipLeft
/ zipRight
/ andThen
zipRight(a, b)
:先做 a 再做 b,最後只回傳 b 的值(a 的值不保留)。zipLeft(a, b)
:先做 a 再做 b,最後只回傳 a 的值(b 的值不保留)。andThen
:接著做某件事。包含:
import { Console, Effect, pipe } from "effect"
const keepRight = Effect.zipRight(Effect.succeed(5), Console.log("hi"))
const keepLeft = Effect.zipLeft(Effect.succeed("hi"), Effect.succeed(10))
const base = Effect.succeed(5)
const a = Effect.andThen(base, "ok")
const b = Effect.andThen(base, Promise.resolve("ok"))
const c = Effect.andThen(base, Effect.succeed("ok"))
const d = Effect.andThen(base, (x) => `ok ${x}`)
console.log("keepRight", Effect.runSync(keepRight)) // undefined
console.log("keepLeft", Effect.runSync(keepLeft)) // hi
console.log("a", Effect.runSync(a)) // ok
console.log("b", Effect.runPromise(b)) // Promise { <pending> }
console.log("c", Effect.runSync(c)) // ok
console.log("d", Effect.runSync(d)) // ok 5
在 keepRight function 下:先建立一個值為 5 的 Effect,接著執行 Console.log("hi")
。因為是用 zipRight
所以保留的最後輸出是 Console.log
的回傳值 undefined
,因此最後 runSync
的結果會是 undefined
。印出來你會看到:先印 "hi",然後結果是 undefined
。
在 keepLeft function 下:先產生字串 "hi",再跑一個產生 10 的 Effect,但因為我們只保留第一個(左側)結果,所以最後得到的是 "hi"。
在前一步完成後「接著做某件事」。可以接值、Promise、Effect,或是用函式動態決定下一步要做什麼。
a
:接一個字串值 "ok",因此結果就是 "ok"。b
:接一個 Promise.resolve("ok")
。runPromise(b)
會回一個 Promise,所以直接 console.log
會看到一個「未完成的 Promise」。等它完成後拿到的是 "ok"。c
:接一個 Effect.succeed("ok")
,因此解開後的結果是 "ok"。d
:接一個函式 (x) => \
ok ${x}``,這個函式會用到前一步的值(這裡是 5),所以最後結果是 "ok 5"。選擇原則:若下一步「不依賴」前一步的值而只是排程後執行,就偏向 zipRight
/ zipLeft
;若「依賴」前一步的值,則 flatMap
或 andThen
更明確。
當流程有較多條件與分支時,Effect.gen
能用近似 async/await
的方式寫出更直覺的程式,同時保有 Effect 的型別安全與錯誤傳遞:
const programBefore = pipe(
Effect.sync(() => Date.now()),
Effect.map((x) => x * 2),
Effect.flatMap((x) => divide(x, 3)),
Effect.map((x) => x.toString())
);
const programAfter = Effect.gen(function* () {
const x = yield* Effect.sync(() => Date.now());
const y = x * 2;
const z = yield* divide(y, 3);
return z.toString();
});
重點:yield*
類似 await
,能把 Effect 解開成值;錯誤型別會沿途自動被推導與累積。
map
: 我要用前一個結果(A)做轉換,錯誤(E)與依賴(R)不變。flatMap
:展平多層 Effect,用來銜接會產生 Effect 的步驟。tap(A => Effect<void>)
:做副作用,不改值。all
:並行收集多個結果再轉換。zipRight
/ zipLeft
:控制要保留哪一步的值。andThen
:連結兩個動作,其中第二個動作可以取決於第一個動作的結果。在語意上比其他方法更明確。在這篇文章中,我們先用 pipe
把資料與轉換排成一條清楚的路,沿途視情境挑選 map
、flatMap
、tap
、all
,必要時再以 as
、zipLeft
/zipRight
、andThen
強化語意;讓未來建構 Effect 服務時,只在系統邊界執行一次。當出現條件、迴圈或多分支等複雜度時,可以考慮改用 Effect.gen
以近似 async/await
的風格提升可讀性。下一篇我們實際來寫寫看一個複雜的pipeline 體驗一下用 Effect 開發的感覺吧~