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。后面五节就是放大这张图的各个部件。
2.1惰性与引用透明
Effect 是个值,意味着它引用透明:可以被取名、传递、放进数组、去重。"重试三次"不需要特殊机制——把同一个值跑三次即可;"超时"就是 effect.pipe(Effect.timeout("1s")),给值套一层包装,得到一个新值。Promise 做不到:它一构造就在飞,你拿到的是进行中的结果,没法"再来一次"。
| 方案 | 优势 | 为什么没选 |
|---|---|---|
| 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。所以签名是一份精确、静态可查的"还能怎么失败"清单。
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 只能抓到一个,另一个被吞。
E,是一棵 Cause 树。注意:最右的 Parallel 能同时保留两个并发失败——这是 try/catch(只能抓一个异常)在结构上做不到的事。| 方案 | 优势 | 为什么没选 |
|---|---|---|
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。
| 方案 | 优势 | 为什么没选 |
|---|---|---|
| 手动透传参数 | 显式、无魔法 | 深层调用链层层传递,啰嗦;加一个依赖要改一路签名 |
| 全局单例 / 模块级实例 | 取用方便 | 测试无法替换实现;隐式耦合,编译器看不见依赖关系 |
| 运行时 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 掌控每一步,它能在步与步之间插入检查点。这就是为什么中断既便宜又安全:永远不会在一个同步块中途被打断,不会有撕裂状态、不需要锁。
| 方案 | 优势 | 为什么没选 |
|---|---|---|
| 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。
AbortController"翻个标志位等你自己轮询"不同,Effect 保证中断时 finalizer(关连接、释放锁)一定运行,且按注册反序。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
// —— 即使被中断,③ 关闭连接也照常执行
| 方案 | 优势 | 为什么没选 |
|---|---|---|
AbortController | 原生、被 fetch 等支持 | 只翻标志位,要手动轮询;不保证清理运行;不自动向下传播 |
| fire-and-forget(detached promise) | 写起来省事 | 无父子链:父结束子还在跑,泄漏;出错无人知 |
| 结构化并发 + 中断 + Scope | 子随父终止、中断必跑 finalizer、资源不泄漏 | 选中 |
多了一层运行时机制(Scope、finalizer 注册),要让 fiber 活过父作用域得显式声明。回报是默认就不泄漏、取消即清理。
2.6跨机制综合:一行代码里五个机制
把五节串起来。看这一行:
const result = fetchWeather("Tokyo").pipe(Effect.timeout("1 second"))
// fetchWeather(city): Effect<Weather, HttpError, HttpClient>
// result: Effect<Weather, HttpError | TimeoutException, HttpClient>
这一个表达式同时动用了全部五个机制:
- 惰性(2.1):
result还是个值,什么都没跑;timeout只是给原值套了层包装得到新值。 - 错误类型(2.2):E 通道自动并入
TimeoutException——签名诚实地多出一种失败。 - 依赖(2.3):R 仍是
HttpClient,没provide就编译不过、跑不了。 - Fiber(2.4):
run时timeout把fetchWeather与一个 1 秒的 sleep race,各跑在自己的 fiber 上。 - 中断(2.5):若 sleep 先赢,
fetchWeather的 fiber 在下一个边界被中断,它的acquireRelease清理(关连接)照跑,结果是TimeoutException失败、Cause里带Interrupt。
§本章 self-check
先合上教程作答,再展开对照。
- E 通道在串联(
flatMap)和恢复(catchTag)时分别发生什么变化? - "失败"和"缺陷"的边界在哪?为什么 Effect 要区分它们?
- 为什么 fiber 的中断"既便宜又安全"?这跟协作式调度有什么关系?
- (综合)
Effect.race(a, b)里a先完成,b会怎样?这和Promise.race有何不同?涉及本章哪两个机制?
答案(先做完再展开)
- 串联取并集(错误类型累积成 union);恢复做减法(
catchTag减掉一项,catchAllcollapse 成never)。签名因此始终是精确的"还能怎么失败"清单。 - 失败 =
Effect.fail产生、预期可恢复、进 E 通道;缺陷 =throw/die产生的 bug、不进 E。区分的理由:可恢复的应被类型强制处理,而枚举所有 bug 不现实,且catchAll故意不碰缺陷。 - 因为 runtime 掌控每一步,中断只在效应边界发生,绝不在同步块中途——没有撕裂状态、不需要锁;fiber 又是轻量绿色线程,创建和切换都便宜。
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 形态。