Chapter 02

原理:类型如何追踪一切

01 章建立了词汇:Effect 是值、三通道 A/E/R、创建/运行/组合。这一章下沉一层——看 runtime 如何把这个值解释执行,错误如何被类型累积与相减,依赖如何在编译期被追踪,fiber 如何协作调度与中断。这是"会用"到"理解"的分界线。

本章你将建立的 schema

  • 惰性的代价与回报:为什么"值"让重试/超时/注入变得自然
  • E 通道如何随组合累积、随恢复相减;失败 vs 缺陷;Cause 与 Exit 的结构
  • R 通道是编译期需求集;Layer 如何构建与记忆化
  • fiber 是协作调度的绿色线程;结构化并发与中断如何保证不泄漏、跑清理

2.0运行时全景:一个解释器

先建立全局图景。一个 Effect 值本质是一棵描述树(一串 flatMap 步骤)。run* 启动一个 Fiber,runtime 像解释器一样逐步遍历这棵树;遍历到外部依赖时从 Context 取实现;走完产出一个 Exit。后面五节就是放大这张图的各个部件。

Effect 值 一串 flatMap 步骤 run* Fiber Runtime 逐边界步进 可在步间插入中断 Context(服务实现) 注入 R 产出 Exit<A, E> Success | Failure
图 2.0一个值,被一个 fiber 解释执行,产出一个 Exit。注意:Context 从下方注入——R 通道里的依赖就是在这里被填上的;runtime "逐边界步进"是后面中断能安全发生的根本原因。

2.1惰性与引用透明

Effect 是个值,意味着它引用透明:可以被取名、传递、放进数组、去重。"重试三次"不需要特殊机制——把同一个值跑三次即可;"超时"就是 effect.pipe(Effect.timeout("1s")),给值套一层包装,得到一个新值。Promise 做不到:它一构造就在飞,你拿到的是进行中的结果,没法"再来一次"。

表 2.1 · 惰性的备选方案
方案优势为什么没选
Promise(即时求值)语言原生、心智简单构造即执行:无法重试同一个操作、无法在执行前包装超时/中断/注入
手写 thunk () => Promise延迟了执行能延迟但不能组合:错误、依赖仍不在类型里,重试/超时还得手写胶水
Effect(惰性描述)值可组合、可包装、可重跑;错误与依赖进入类型选中
带来的代价

必须记得 run——定义一个 effect(哪怕里面写了 Effect.log)什么都不会发生,新手最常被这点绊倒(04 章陷阱 1)。另外同一个值跑两次就执行两次副作用:这是特性,但也要求你清楚"哪里 run"。

2.2错误如何被类型追踪

这是 Effect 最核心、也最反直觉的机制。错误是被返回的值,不是被抛出的异常——所以类型系统能看见它。

累积与相减

当 flatMap/gen 把两个 effect 串起来,E 通道取并集:串联 Effect<_, HttpError> 和 Effect<_, ParseError>,得到 E = HttpError | ParseError。恢复算子则做减法:catchTag("HttpError", ...) 把这一项从并集里去掉,catchAll 把整个 E collapse 成 never。所以签名是一份精确、静态可查的"还能怎么失败"清单。

err-accumulate.tsTypeScript
import { Effect, Data } from "effect"

class HttpError extends Data.TaggedError("HttpError")<{ status: number }> {}
class ParseError extends Data.TaggedError("ParseError")<{ raw: string }> {}

declare const get: Effect.Effect<string, HttpError>
declare const parse: (s: string) => Effect.Effect<number, ParseError>

const pipeline = Effect.gen(function* () {
  const body = yield* get          // 可能 HttpError
  return yield* parse(body)         // 可能 ParseError
})
// pipeline: Effect<number, HttpError | ParseError>  ← 并集,自动累积

const recovered = pipeline.pipe(
  Effect.catchTag("HttpError", () => Effect.succeed(0)),
)
// recovered: Effect<number, ParseError>  ← HttpError 被减掉,只剩 ParseError

失败 vs 缺陷:两种性质完全不同的"错"

失败(failure)= 预期内、可恢复,进入 E 通道;缺陷(defect)= 意料外的 bug,不进 E 通道。

只有 Effect.fail(x) 产生的失败才进 E。一个真正 throw 出来的异常(或 Effect.die)是缺陷——E 通道保持 never,因为"枚举所有可能的 bug"是不现实的。两者可以互转:Effect.orDie 把失败降级成缺陷(从 E 移除);Effect.exit / catchAllCause / sandbox 把缺陷捞成可检视的值。

关键区分

catchAll 抓不到缺陷——它只处理 E 通道里的失败。一个在 Effect.sync 里 throw 的异常会绕过所有 catchTag/catchAll。要兜住缺陷得用 catchAllCause 或 Effect.exit。这是 04 章好几个陷阱的根源。

Cause 与 Exit:比 Error 多保留的信息

一个 fiber 结束时得到 Exit<A, E> = Success(A) 或 Failure(Cause<E>)。注意失败侧装的是 Cause,不是裸 E——Cause 是完整的失败树,能表达 try/catch 结构上表达不了的东西:同时发生的多个失败。两个并发分支都炸了,Cause 用 Parallel 把两个都留着;try/catch 只能抓到一个,另一个被吞。

Exit<A, E> Success → A 成功值 Failure → Cause<E> Cause<E> 的形态 Fail(e) 预期失败 · 在 E Die(defect) 缺陷 bug · 不在 E Interrupt 被中断 · 带 fiberId Seq / Parallel 并存多个失败
图 2.2失败侧不是裸 E,是一棵 Cause 树。注意:最右的 Parallel 能同时保留两个并发失败——这是 try/catch(只能抓一个异常)在结构上做不到的事。
表 2.2 · 错误模型的备选方案
方案优势为什么没选
throw / 异常语言原生、零样板对类型系统不可见、沿栈展开;调用方不知道会抛什么,重构易漏
返回裸 Error / Result<T, Error>错误变成值、可检查丢失中断信息、丢失"失败 vs 缺陷"区分、无法保留并发的多个失败
E 通道 + Cause / Exit失败进类型可穷尽处理;Cause 无损保留全部失败信息选中
带来的代价

多了一套要学的结构(Cause/Exit、失败/缺陷的边界)和一些样板(定义 TaggedError)。回报是:编译器逼你处理每一种可恢复失败,且重构时错误清单自动更新。

想一想

一段代码 const safe = risky.pipe(Effect.catchAll(() => Effect.succeed(0))),risky 里有个 Effect.sync(() => JSON.parse(bad)) 会抛。safe 能兜住这个抛吗?

展开答案(先停 10 秒)

兜不住。JSON.parse 抛出的异常是缺陷(用了 sync 而非 try),不在 E 通道,catchAll 只管 E 通道。safe 运行时仍会以缺陷告终。修法:要么用 Effect.try 把异常转成 E 通道的失败,要么用 catchAllCause 兜缺陷。这正是 04 章陷阱 3。

2.3Layer:编译期的依赖注入

R 通道是一个编译期需求集:每个 yield* SomeTag 往里加一项,每个 provide 去掉对应项。effect 不到 R = never 不能运行——编译器在替你确认"所有依赖都接好了"。运行时这边,Context 就是一个不可变的 Map<Tag, 实现>。

Layer<ROut, E, RIn> 是"如何构建服务"的配方:它能自己有依赖(RIn)和构建期错误(E)。Layer.provide 把一个 layer 接到另一个 layer 上(接线服务之间的依赖),Effect.provide 把构建好的 layer 接到 effect 上。

洞察 · 记忆化

Layer 在一次构建中按引用相等记忆化:同一个 layer 引用被多处依赖,只构建一次、共享一个实例。反过来,把一个"工厂调用"makeLayer() 调两次会得到两个不同引用 = 两个实例。想要单例就把 layer 存成一个 const、只提供这一个引用。这是 04 章陷阱 5。

表 2.3 · 依赖注入的备选方案
方案优势为什么没选
手动透传参数显式、无魔法深层调用链层层传递,啰嗦;加一个依赖要改一路签名
全局单例 / 模块级实例取用方便测试无法替换实现;隐式耦合,编译器看不见依赖关系
运行时 DI 容器(反射)解耦依赖错误推迟到运行时才暴露;类型不安全
Context.Tag + Layer依赖进 R 通道、编译期校验;测试/生产换 layer 即可选中
带来的代价

类型签名变长(R 通道会列出所有未提供的服务),需要理解 layer 的构建图与记忆化语义。回报是依赖关系完全显式、编译期可查、可测试。

2.4Fiber 运行时:协作式调度

fiber 是 runtime 模拟的轻量虚拟线程(绿色线程):不是 OS 线程(JS 单线程),不是 Promise;惰性、可观测、可中断。

runtime 是个解释器,逐步遍历 effect 这棵描述树,flatMap/gen 的边界就是一"步"。调度是协作式的:一个 fiber 一直跑,直到撞上异步挂起点(Effect.sleep、一个 async effect)或主动让出,runtime 才在事件循环上步进别的 fiber。关键在于——因为 runtime 掌控每一步,它能在步与步之间插入检查点。这就是为什么中断既便宜又安全:永远不会在一个同步块中途被打断,不会有撕裂状态、不需要锁。

时间 → Fiber A Fiber B fetch 请求 解析响应 另一个请求 写日志 边界 · A 挂起 边界 · 切换
图 2.4A 发出请求后在边界处挂起(等 IO),runtime 趁机步进 B。注意:切换只发生在红色边界线上——所以一个纯 CPU 的同步死循环不会让出,也就不会被中断,直到它撞上一个边界。
表 2.4 · 并发执行模型的备选方案
方案优势为什么没选
OS 线程(抢占式)真并行、CPU 密集友好重量级、抢占点任意 → 撕裂状态需要锁;JS 也没有共享内存线程模型
裸 Promise原生、轻不可取消、不可观测、即时求值;并发只能 Promise.all 这类粗粒度
Fiber(协作式绿色线程)可中断、可观测、惰性;几万个也便宜;中断点安全选中
带来的代价

协作式意味着一段 CPU 密集的同步代码不会自动让出,也不会响应中断,直到它到达下一个效应边界。需要时得手动插入让出点(把大循环切成 effect 步骤)。

2.5结构化并发与中断

Effect.fork 把子 fiber 挂到父 fiber 的作用域上。父终止时,子自动终止——"不会有 fiber 在你不知情时还在跑"。要让 fiber 活得比父久,得显式 forkDaemon(全局作用域)或 forkScoped(挂到某个外层 Scope)。这就是"结构化":fiber 的生命周期像调用树一样嵌套。

中断的机制:Fiber.interrupt 发出信号,runtime 在效应之间的边界观察到它(不会在同步块中途),展开执行栈,按注册的反序运行所有 finalizer / onInterrupt,最后得到一个 Cause: Interrupt(fiberId) 的 Exit。

父 fiber 作用域 interrupt 父 fiber fork 子 fiber 1 ✗ 随父中断 子 fiber 2 ✗ 随父中断 finalizer 反序运行 · 必跑
图 2.5中断打到父 fiber,子 fiber 自动跟着中断。注意:和 AbortController"翻个标志位等你自己轮询"不同,Effect 保证中断时 finalizer(关连接、释放锁)一定运行,且按注册反序。
interrupt-finalizer.tsTypeScript
import { Effect, Console } from "effect"

// acquireRelease:acquire 与 release 都不可中断;release 注册为 scope finalizer
const useConn = Effect.acquireRelease(
  Console.log("① 打开连接").pipe(Effect.as("conn")),  // acquire
  () => Console.log("③ 关闭连接(必跑:成功/失败/中断都跑)"), // release
)

const program = Effect.gen(function* () {
  const conn = yield* useConn
  yield* Console.log("② 用连接做事")
  yield* Effect.interrupt          // 主动中断
}).pipe(Effect.scoped)             // scoped 圈定 finalizer 的生命周期

Effect.runPromiseExit(program)
// 输出顺序:① ② ③,最终 Exit.Failure,Cause 是 Interrupt
// —— 即使被中断,③ 关闭连接也照常执行
表 2.5 · 取消与资源安全的备选方案
方案优势为什么没选
AbortController原生、被 fetch 等支持只翻标志位,要手动轮询;不保证清理运行;不自动向下传播
fire-and-forget(detached promise)写起来省事无父子链:父结束子还在跑,泄漏;出错无人知
结构化并发 + 中断 + Scope子随父终止、中断必跑 finalizer、资源不泄漏选中
带来的代价

多了一层运行时机制(Scope、finalizer 注册),要让 fiber 活过父作用域得显式声明。回报是默认就不泄漏、取消即清理。

2.6跨机制综合:一行代码里五个机制

把五节串起来。看这一行:

synthesis.tsTypeScript
const result = fetchWeather("Tokyo").pipe(Effect.timeout("1 second"))
// fetchWeather(city): Effect<Weather, HttpError, HttpClient>
// result:            Effect<Weather, HttpError | TimeoutException, HttpClient>

这一个表达式同时动用了全部五个机制:

  1. 惰性(2.1):result 还是个值,什么都没跑;timeout 只是给原值套了层包装得到新值。
  2. 错误类型(2.2):E 通道自动并入 TimeoutException——签名诚实地多出一种失败。
  3. 依赖(2.3):R 仍是 HttpClient,没 provide 就编译不过、跑不了。
  4. Fiber(2.4):run 时 timeout 把 fetchWeather 与一个 1 秒的 sleep race,各跑在自己的 fiber 上。
  5. 中断(2.5):若 sleep 先赢,fetchWeather 的 fiber 在下一个边界被中断,它的 acquireRelease 清理(关连接)照跑,结果是 TimeoutException 失败、Cause 里带 Interrupt。

§本章 self-check

先合上教程作答,再展开对照。

  1. E 通道在串联(flatMap)和恢复(catchTag)时分别发生什么变化?
  2. "失败"和"缺陷"的边界在哪?为什么 Effect 要区分它们?
  3. 为什么 fiber 的中断"既便宜又安全"?这跟协作式调度有什么关系?
  4. (综合)Effect.race(a, b) 里 a 先完成,b 会怎样?这和 Promise.race 有何不同?涉及本章哪两个机制?
答案(先做完再展开)
  1. 串联取并集(错误类型累积成 union);恢复做减法(catchTag 减掉一项,catchAll collapse 成 never)。签名因此始终是精确的"还能怎么失败"清单。
  2. 失败 = Effect.fail 产生、预期可恢复、进 E 通道;缺陷 = throw/die 产生的 bug、不进 E。区分的理由:可恢复的应被类型强制处理,而枚举所有 bug 不现实,且 catchAll 故意不碰缺陷。
  3. 因为 runtime 掌控每一步,中断只在效应边界发生,绝不在同步块中途——没有撕裂状态、不需要锁;fiber 又是轻量绿色线程,创建和切换都便宜。
  4. b 会被主动中断,它的 finalizer 会运行(机制 2.4 fiber + 2.5 中断)。Promise.race 的失败臂会继续跑到底,无法取消、不会清理。
进阶挑战 · 刚好够不着

预测中断时的输出顺序

一个 scoped 程序依次注册了三个 finalizer F1、F2、F3,然后在第三步被中断。三个 finalizer 的执行顺序是什么?如果 F2 本身又抛了一个错,最终的 Cause 里会有几个失败、什么形态?

提示(卡住再展开)

finalizer 按注册反序执行:F3 → F2 → F1。F2 抛错与原本的中断顺序叠加,会进入 Cause 的 Sequential 形态——两个失败都保留(一个 Interrupt、一个 F2 的错),而不是丢掉一个。回看图 2.2 的 Cause 形态。