前一篇我們介紹了 Effect Type 的定義和創建 Effect 的方法,這一篇我們要來講講如何執行 Effect。但在實際講語法之前,我想先講一下 Effect 為何需要特定語法來執行程式。背後的設計哲學 Continuation-Passing Style (CPS) 又是什麼。
Continuation-Passing Style (CPS) 是一種程式設計風格:function 在執行完成後,不會直接回傳結果,而是將結果傳遞給額外的 function 參數(稱為 continuation)來處理後續流程。在一般程式設計中,function 會「直接回傳結果」。例如:
function add(a: number, b: number): number {
return a + b
}
const result = add(1, 2) // 結果 = 3
但在 Continuation-Passing Style (CPS) 裡,function 不再主動回傳值,而是把計算的結果交給另一個 function(continuation)處理:
// ┌─── 在 CPS 的語境下,這個 callback 特別被稱為 continuation
// ▼
function addCPS(a: number, b: number, cont: (res: number) => void): void {
cont(a + b)
}
addCPS(1, 2, (res) => {
console.log("結果:", res) // 3
})
在這種風格下,function 永遠「不回傳」,而是「把控制權交出去」。這種模式特別適合描述非同步運算,因為我們把「後面要做什麼」包成一個 function(continuation)先存起來,先把目前的執行權讓出來,等到 I/O(像是網路請求、讀檔、計時器)真的完成時,執行環境(runtime)會再幫我們呼叫那個 continuation,繼續後續流程。
我畫了一個序列圖。從圖中應該更好理解整個流程:
簡單例子:
import { readFile } from "fs"
// 這個 function 不回傳檔案內容,而是接收一個 continuation
function readFileCPS(
path: string,
cont: (err: NodeJS.ErrnoException | null, data?: string) => void
): void {
// 發出 I/O 請求給系統,並傳入 callback
readFile(path, "utf-8", (err, data) => {
// I/O 完成後,由 runtime 觸發 continuation
cont(err, data)
})
}
// 使用:程式呼叫,並傳入 "後續該做什麼"
console.log("1. 發出檔案請求")
readFileCPS("./package.json", (err, data) => {
if (err) {
console.error("發生錯誤:", err)
return
}
console.log("3. continuation 被呼叫,檔案內容長度:", data?.length)
})
console.log("2. 我可以繼續做其他事,不被阻塞")
// 輸出:
// 1. 發出檔案請求
// 2. 我可以繼續做其他事,不被阻塞
// 3. continuation 被呼叫,檔案內容長度: 1234
從上面的例子,對照到 Effect 的用法,Effect.async
裡的 resume
其實就是那個 continuation 的觸發器:當非同步工作完成時,呼叫 resume(...)
把結果「交給下一步」。
import { Effect } from "effect"
const delayed = Effect.async<string>((resume) => {
setTimeout(() => {
resume(Effect.succeed("Hi Hi~"))
}, 1000)
})
這段程式碼並不會立刻輸出結果。delayed
只是「描述」:
「等 1 秒後,我會呼叫 continuation,然後把字串
"Hi Hi~"
傳下去。」
不過不是只有上面的例子是用CPS 設計的喔~Effect 本身就是以此風格為基礎建立的。所以才能將多個不一樣的 Effect 組合起來,變成新的 Effect。未來我們會介紹 Effect 之間串連的語法。我們先回到此篇主題,介紹如何執行 Effect 吧~😀
使用 Effect.runSync
運行不會失敗且不包含任何非同步操作的 Effect。
import { Effect } from "effect"
const program = Effect.sync(() => {
console.log("I'm a sweet summer child.")
return 1
})
const result = Effect.runSync(program)
// 輸出:I'm a sweet summer child.
console.log(result)
// 輸出:1
若用 runSync
執行「會失敗」或「含有非同步」的 Effect,會直接丟出例外:
try {
Effect.runSync(Effect.fail("my error"))
} catch (e) {
console.error(e) // 輸出:(FiberFailure) Error: my error
}
try {
Effect.runSync(Effect.promise(() => Promise.resolve(1)))
} catch (e) {
console.error(e) // 輸出:(FiberFailure) AsyncFiberException: Fiber #0 cannot be resolved synchronously. This is caused by using runSync on an effect that performs async work
}
但即便是同步程式還是有可能出錯,預期內的或預期外的錯誤(defect)都有可能。這時候我們就要用 runSyncExit
來處理。
在 Effect 世界裡,當你「執行一個 Effect」時,它的運算結果並不單純只有「成功」或「錯誤」。Effect runtime 需要更完整的資訊來描述「這個 Effect 運行結束時發生了什麼」。這個「封裝任何 Effect 結果的型別」就是 Exit<A, E>
。
可以把它想成:
👉 Exit = Effect 運行後「最終結果(Outcome)」的封裝
_tag
為 Success
,內含值 value
。_tag
為 Failure
,內含物件 Cause
描述失敗原因。// 輸出型別為:Exit<number, never>
console.log(Effect.runSyncExit(Effect.succeed(1)))
/*
輸出:
{
_id: "Exit",
_tag: "Success",
value: 1
}
*/
console.log(Effect.runSyncExit(Effect.fail("my error")))
/*
輸出:
{
_id: "Exit",
_tag: "Failure",
cause: {
_id: "Cause",
_tag: "Fail",
failure: "my error"
}
}
*/
如果 Effect 內含非同步操作,使用Effect.runSyncExit
執行運算的話,會回傳_tag
為Failure
的結果,其 Cause
為 Die
,表示「無法以同步方式完成這個Effect」。
console.log(Effect.runSyncExit(Effect.promise(() => Promise.resolve(1))))
/*
輸出:
{
_id: 'Exit',
_tag: 'Failure',
cause: {
_id: 'Cause',
_tag: 'Die',
defect: [Fiber #0 cannot be resolved synchronously. This is caused by using runSync on an effect that performs async work] {
fiber: [FiberRuntime],
_tag: 'AsyncFiberException',
name: 'AsyncFiberException'
}
}
}
*/
當你需要用 Promise
語法處理運算時,使用 Effect.runPromise
。
// 使用 Promise 函數(10ms 後 resolve "resolved!")
function wait(ms: number): Promise<string> {
return new Promise((resolve) => setTimeout(() => resolve("resolved!"), ms))
}
// ┌─── Promise<string>
// ▼
const asyncProgram = Effect.runPromise(
Effect.promise(() => wait(10))
)
asyncProgram.then((value) => console.log(value))
// 輸出(10ms 後):resolved!
// 包裝原生 Promise(10ms 後 reject)
const asyncFailedProgram = Effect.runPromise(
Effect.promise(() => new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("my error")), 10)
}))
).catch(console.error)
/*
輸出(約 10ms 後):
(FiberFailure) Error: my error
*/
說明:以上示例透過 Effect.promise
包裝原生 Promise
,讓運算在延遲後才完成(或失敗)。Effect.runPromise
的角色是把 Effect 的結果以 Promise
交付,所以要得到回傳值的話,要使用 then
來取得;若想以結構化方式取得成功或失敗結果,並避免被 reject 的話,就要使用 Effect.runPromiseExit
了。
執行 Effect 並回傳一個 Promise<Exit>
。當你想在 Promise
流程中,判斷成功或失敗時,就可以使用 Effect.runPromiseExit
。
Exit
型別:
Success
,內含成功下的 value
。Failure
,內含 Cause
描述失敗原因。import { Effect } from "effect"
Effect.runPromiseExit(Effect.succeed(1)).then(console.log)
/*
輸出:
{
_id: "Exit",
_tag: "Success",
value: 1
}
*/
Effect.runPromiseExit(Effect.fail("my error")).then(console.log)
/*
輸出:
{
_id: "Exit",
_tag: "Failure",
cause: {
_id: "Cause",
_tag: "Fail",
failure: "my error"
}
}
*/
runFork
會在新的 Fiber(輕量執行緒)中啟動一個 Effect,並立即回傳一個可操作的 Fiber 物件,因此不會像 runPromise 那樣等待運算完成後才給出結果。這讓你可以把某個 Effect 當作「背景任務」來執行,並透過 Fiber 提供的方法進行控制(之後的文章會講到)。適合的使用時機是:當你需要啟動長時間持續的工作(像是資料同步、事件監聽、定時任務)而不希望阻塞主要流程時,就應選擇 runFork;若是單純需要取得一次性的運算結果,則用 runPromise 會更合適。
// 一個會在 1 秒後完成的 Effect
const program = Effect.async<number>((resume) => {
setTimeout(() => resume(Effect.succeed(42)), 1000)
})
// 啟動一條背景 fiber
const fiber = Effect.runFork(program)
// 0.5 秒後嘗試中斷(此例中會在完成前被中斷)
setTimeout(() => {
Effect.runFork(Fiber.interrupt(fiber))
}, 500)
程式碼說明:
Effect.async
把非同步工作包裝成 Effect。Effect.runFork
啟動 Effect 並回傳 Fiber
。Fiber.interrupt
嘗試中斷(本例在 0.5 秒時中斷,所以不會完成)。在 Effect 的世界裡,Fiber 是 runtime 管理的「使用者層級輕量執行單元」。它不是作業系統 Thread,而是一種在使用者空間運作的輕量級執行單元(類似協程 coroutine),由 runtime 提供,使應用程式能以受控的方式管理並發、支援中止與確保資源安全。這種抽象單元的調度完全發生在使用者空間(User space),而非 OS Kernel。
Exit<A, E>
;失敗時以 Cause
保留錯誤脈絡,可支援錯誤聚合與併發錯誤追蹤。interrupt
/timeout
/race
等中止語義。Exit
/Cause
保留完整結果與錯誤脈絡。⚠️ 注意:Fiber 不會「把 JS 變多執行緒」。它處理的是邏輯併發(cancelation/supervision/composability),非 CPU 平行運算;若要多核心利用,仍須搭配 Worker Threads/子行程,再由 Effect 包裝。
這裡舉一個我們最常見邏輯併發的例子:
async function main() {
const p1 = fetch("/api/users");
const p2 = fetch("/api/posts");
const [users, posts] = await Promise.all([p1, p2]);
console.log(users, posts);
}
你可以想像 Fiber 就是一個擁有這樣特性的抽象,但擁有更多流程控制的機制。我們在之後的文章再來細談語法。我們回到我們的主題🤣。
runSync
、runPromise
和 runFork
如何選擇呢?runSync
Effect
,並立即回傳結果或丟出錯誤。runPromise
Effect
,並把結果包成原生 JavaScript Promise
。Promise.resolve(value)
Promise.reject(error)
runFork
Fiber
物件。.await()
等待完成,.interrupt()
中斷。(相關機制之後會再細講)runFork
保持與 Effect runtime 的連結,所以往往是「預設建議選擇」。Executor | 回傳型別 | 是否阻塞 | 使用時機 |
---|---|---|---|
runSync |
直接回傳值或拋錯 | 阻塞 | 純粹同步運算(例如初始化變數) |
runPromise |
Promise<T> |
等待結果 | 與 async/await、Node.js API 整合 |
runFork |
Fiber (可控制) |
不阻塞 | 啟動背景任務、監控長時間運算、作為預設選擇 |
runFork
是預設建議?官方文件才會提到:
"Unless you specifically need a
Promise
or synchronous operation,Effect.runFork
is a good default choice."
這是因為:
runPromise
/ runSync
會最終把結果交回給 JavaScript 世界,導致失去 Effect runtime 的掌控能力。runFork
保持在 Effect 的語境內,讓程式運行下的錯誤處理、中斷、並行控制,都能交由 Effect 本身管理。換句話說:
在大多數應用程式中,你需要的是「啟動任務並持續由 Effect runtime 管理」,而不是「一次性拿值」。因此 runFork
更符合 Effect 的設計哲學,通常也是最安全、最合理的預設選擇。
多數情況下,Effect 會在應用程式的「最外層」被執行。以 Effect 為核心的應用,通常只會在入口處啟動一次 main Effect。也就是說:我們大部分程式都在描述「要做什麼」。最後只會有一個「main effect」在程式最外層(aka 程式的邊界 Edge)被執行。這讓程式結構更可控、更純粹,讓副作用的影響都提升到應用最外層。(下一篇我們會更詳細探討這樣做的原因)
非同步行為允許時間上的延續。同步行為runSync
一旦呼叫 → 必須馬上算完並回傳結果。非同步行為runPromise
/runFork
呼叫後,程式不用卡住,可以隨時 等待、取消、錯誤恢復、資源釋放。
以下比較同步與非同步執行的差異:
面向 | 同步 | 非同步 |
---|---|---|
時間性 | 需要立即完成,不可延續 | 可等待、延遲、長時間運作 |
I/O 適配 | 不適合需要等待的 I/O | 自然承載各種 I/O |
錯誤處理 | 當場 try/catch | 集中處理並保留發生步驟與脈絡,較好追蹤 |
取消/中斷 | 難以在執行中介入 | 可發出取消訊號,於安全點停止 |
並行/併發 | 一次只做一件事 | 可同時多工並協調先後 |
資源清理 | 通常在 function 結尾手動關 | 完成/失敗/取消時皆可自動清理連線、檔案、計時器 |
典型場景 | 純計算、格式轉換、產生設定值 | HTTP/資料庫/檔案、伺服器啟動、排程、事件監聽、WebSocket、背景任務 |
fetch
時,從請求送出到回應抵達之間是「等待的瞬間」,你可用 AbortController
取消;系統會在下一個安全點停止並關閉相關資源。圖解(以 fetch + AbortController 為例):
閱讀說明:
->
(實線):主動發出的請求/動作。-->
(虛線):回應/通知事件(例如回應完成、AbortError)。- 本圖僅示範「取消」情境
- 圖中亦示範:取消後若回應晚到,會在網路層被丟棄,不再喚醒應用程式。
runSync
。runPromise
(和 async/await 整合)或 runFork
(啟動背景任務)。