林子豪的 PKM
← 返回 Blog

从副作用到 Effect System:把“要做什么”和“怎么执行”分开

从 re-frame、Raft、Free Monad 和 Fetch 出发,理解 effect value、interpreter、effect program 与语言级 Effect System 的区别。

11 min read
software-architecturefunctional-programmingeffect-system

<!-- vault refs: reify to an interpreter redash re-frame2 概念整理 raft-effects-as-data free monad functionk there-is-no-fork-java-fetch -->

从副作用到 Effect System:把“要做什么”和“怎么执行”分开

副作用让程序真正有用。读取数据库、发送网络请求、写文件、启动 timer,最后都要改变程序外部的世界。问题不在于程序有副作用,而在于业务判断经常和执行副作用混在同一段代码里。

例如一个函数判断用户是否可以下单,然后直接查询库存、锁定库存、写订单和发送通知。测试这段业务规则时,也被迫准备数据库、网络 client、transaction 和一组 mock。失败以后,很难马上判断是规则错了,还是测试环境、SQL 或 mock 行为错了。

一种更清晰的边界是:业务程序先返回“接下来应该做什么”,另一个部分再真正执行。

输入 + 当前状态
       ↓
业务程序
       ↓
新状态 + effect values
       ↓
interpreter / handler
       ↓
数据库、网络、timer、文件系统

这条思路可以很小,只是在函数返回值里增加一条 command;也可以逐步发展成 Free Monad、effect runtime,乃至语言级的 Effect System。它们不是同一个东西,但都在回答相似的问题:如何把“程序想做什么”和“这些动作如何发生”分开。

副作用为什么难测试

纯函数的输入和输出都写在函数边界上。给定同样的输入,它返回同样的输出,因此测试只需构造输入,再比较返回值。

直接执行副作用的函数却还有隐藏的输入和输出。系统时间、数据库内容和网络响应是隐藏输入;写文件、发送消息和修改数据库是隐藏输出。测试为了观察这些行为,只能启动外部系统,或者用 mock 截住某个方法调用。

Mock 可以降低测试成本,但没有改变代码本身的边界。业务代码仍然一边决定应该发生什么,一边调用某种具体执行机制。测试也容易从“业务应该产生什么结果”退化成“某个 mock method 有没有被调用”。

所以遇到难测的副作用代码时,可以先问一个更基本的问题:

决定“应该做什么”的代码,是否和真正“把它做掉”的代码混在了一起?

第一步:把副作用变成数据

假设前端需要加载用户资料。event handler 可以直接调用 fetch,也可以返回一段数据:

{:db (assoc db :profile/status :loading)
 :fx [[:http/get-json
       {:url "/api/profile"
        :on-success [:profile/load-success]
        :on-error [:profile/load-error]}]]}

这个 map 没有发出网络请求。它只描述了一个 effect:请求哪个 URL,以及成功或失败后产生什么事件。真正的网络调用由注册在 :http/get-json 下的 handler 执行。

业务测试可以直接比较这个 map。它不需要启动浏览器,也不需要 mock fetch。effect handler 则单独用少量边界测试验证:它是否把描述正确翻译成网络请求和后续事件。

reify to an interpreter 把这个变化概括得很直接:把 mutation function 具体化为代表 mutation 的 data,再写 interpreter 处理这些 data。数据可以断言,interpreter 也可以分析和优化。

这已经形成了一个最小结构:

1. effect description:描述要执行的动作;

2. program:根据输入产生 effect description;

3. handler / interpreter:把描述翻译成真实动作。

它还不是编程语言理论里严格定义的 Effect System,但已经建立了 effect boundary。

前端里的小型 effect runtime

re-frame 和 re-dash 把这套分工做成了日常编程模型:

- event 是用户意图的数据;

- event handler 根据 event 和当前 db 返回新状态与 effect map;

- reg-fx 注册真正接触外部世界的 handler;

- runtime 更新 db,然后解释其他 effects。

例如“给植物浇水并保存日志”可以同时返回新的 db::persist-logs effect。handler 本身仍然是纯函数;只有 reg-fx 中的实现调用存储 API。

这带来一个很实用的系统视图:想知道应用有哪些外部能力,看 effect handler 注册表就够了。网络、存储、timer、浏览器 API 不再散落在所有 event handler 里。

redash 和 re-frame2 概念整理 展示了这个模式在前端中的完整形态。它的价值不只是方便测试,也在于把“哪些代码有权接触外部世界”变成了一条可见的架构边界。

Raft:复杂系统也能把 effect 留在边缘

这个模式并不限于 UI。TinyKV 的 Raft 实现也把网络、时间和持久化留在状态机外部。

Raft 需要 election timer 和 heartbeat timer,但 core 不创建真实 timer,只接收一次逻辑 Tick。生产 scheduler 可以定期调用它,测试也可以直接调用它。时间从隐藏的外部条件变成显式输入。

Raft 节点要给其他节点发送消息时,也不直接操作 socket,而是把 message value 放进 outbox。测试取出这些 messages,直接断言收件节点、term、message type 和 log index;测试网络则把一个节点产生的 message 交给另一个节点。丢包只是丢掉一个 value,network partition 只是过滤两个方向的 messages。

网络消息还不是 Raft 的全部 effects。TinyKV 的 RawNode.Ready() 会返回一批等待外层执行的工作:

type Ready struct {
    *SoftState
    pb.HardState
    Entries          []pb.Entry
    Snapshot         pb.Snapshot
    CommittedEntries []pb.Entry
    Messages         []pb.Message
}

这些字段分别描述持久化状态、保存日志、安装 snapshot、应用 committed entries 和发送网络消息。Raft core 产生 Ready,外层 raftstore 解释并执行它们。

不过,把副作用变成数据不代表 handler 可以随便执行。Raft 要求 HardState 和相关 entries 在 message 发出前可靠持久化。外层处理完一批 Ready 后,还要调用 Advance 确认完成。

因此 Ready → Advance 不只是 command queue,而是一个带顺序和 acknowledgement 的 effect protocol:

1. core 给出当前 effect batch;

2. 外层按照协议持久化、发送和 apply;

3. 外层显式确认完成;

4. core 才推进内部的 stable 和 applied 位置。

raft-effects-as-data 的重要启发不是“所有代码都必须是纯函数”。TinyKV 的 StepTick 仍然会修改内存状态。真正关键的是,它把外部 effect 延迟并具体化成数据,让协议判断和基础设施执行拥有清晰边界。

从 effect value 到 effect program

返回一条 command 很适合描述单步动作,但真实业务还需要顺序、分支和数据依赖。

例如一个 KV 程序可能先 put,然后 get,根据结果决定是否 delete。如果每一步都立即执行,就只能通过实际结果继续控制流。要让整段程序保持为数据,需要定义一套操作的 ADT,再用一种结构记录这些操作如何组合。

Free Monad 提供了这种结构。程序调用 mapflatMap 时不执行副作用,只构造 program/AST:

sealed trait KVStoreA[A]
case class Put[A](key: String, value: A) extends KVStoreA[Unit]
case class Get[A](key: String) extends KVStoreA[Option[A]]
case class Delete(key: String) extends KVStoreA[Unit]

type KVStore[A] = Free[KVStoreA, A]

使用者仍然可以用 for comprehension 按业务顺序写程序,但得到的 KVStore[A] 是一个 value。直到调用 foldMap,interpreter 才把它翻译到 IOFutureId 或其他目标中。

free monad 把它类比成 AST:mapflatMap 只构建数据结构,不做 interpretation。functionk 则进一步展示了 natural transformation,也就是 Cats 中的 FunctionK,如何把一套 DSL 统一翻译到另一种 effect type。

于是 interpreter 不只剩下一种:

- 生产 interpreter 执行真实 IO;

- 测试 interpreter 使用内存状态;

- logging interpreter 记录每一步;

- validation interpreter 拒绝不允许的操作;

- analysis interpreter 统计程序会使用哪些能力。

可测试只是 program reification 带来的第一个能力。更深的变化是:程序在被执行前,变成了可以观察和变换的对象。

当程序成为数据,runtime 才能优化它

there-is-no-fork-java-fetch 中的 Fetch 展示了另一个结果:程序结构可以携带调度信息。

普通命令式代码连续执行两个数据库查询,即使查询互不依赖,执行顺序也已经被语句顺序写死。Fetch 则描述结果之间的关系:Monad 的 flatMap 保留必须等待前一步结果的数据依赖,Applicative composition 暴露可以独立探索的分支。

runtime 先把 program 向前解释到 Blocked,收集当前所有请求,再统一 batch 和执行。查询结果回来以后,continuation 继续运行。调用方仍然按业务结构组合小查询,但 interpreter 获得了全局优化空间。

这揭示了 effect program 和直接 IO 的重要区别:

如果程序已经执行了副作用,runtime 只能接受结果;如果程序描述副作用,runtime 才有机会检查、合并、并行或重新安排它们。

这种能力也有代价。要优化 program,结构必须保留足够的信息;如果所有操作都被藏进一个不透明函数,interpreter 同样无从分析。Free 并不会自动产生优化,ADT 的粒度和组合方式决定了 runtime 到底能看见什么。

Cats-backed workflow:管理执行,而不一定重建 AST

并不是每段 effectful code 都需要定义 ADT,再用 Free Monad 构造一棵完整 AST。

很多业务 workflow 真正需要的是:按顺序调用若干 adapter、遇到业务错误时短路、失败时释放之前取得的资源,并在成功后把长期资源交给新的 owner。这里更直接的做法是使用 effect type 和组合操作管理执行。

Synapse 的 effect-workflow 建在 Clojure 的 funcool/cats 上,并不是 Scala Cats Effect runtime。它用 Either 表达业务控制流:Right 携带成功值,Left 携带 blocked、validation failure 或 adapter failure。mlet 组合顺序步骤,遇到 Left 就跳过后续步骤。

资源生命周期由另一个 scope 管理。acquire! 成功后登记 lazy finalizer;workflow 返回 Left 或抛出异常时,scope 按相反顺序 rollback。普通 Right 会在返回前释放临时资源,只有资源已经交给 session registry 等长期 owner 后,程序才返回 committed value。

这个设计和 effects as data 相关,但重点不同:

Scala Cats Effect 的 IO[A]、其他 effect type 或这里的 workflow API,都可以把 effectful computation 变成 first-class value,并提供组合与执行边界。但这不意味着程序一定保留了可供任意静态分析的操作 AST,也不意味着类型记录了它会使用网络、文件还是数据库。不同抽象保存的信息不同,因而能提供的能力也不同。

这些东西算不算 Effect System?

工程讨论中的 “effect system” 经常指任何集中管理副作用的机制。但在编程语言理论里,Effect System 通常有更严格的含义:类型系统不仅描述输入和返回值,也描述一段计算可能产生的 effects。

可以把函数类型想象成:

UserId ->{Database, Network} User

这里的 effect set 或 effect row 是函数 contract 的一部分。调用方和编译器可以知道某个函数是 pure,还是可能读取状态、抛出错误、进行异步操作或访问某种 capability。

Algebraic Effects 又进一步把 effect operation 和 handler 作为语言或库中的一等概念。程序可以执行抽象的 GetPutRaise,handler 决定这些 operation 的语义,并可能控制 computation 如何继续。

因此,前面的实践最好分成三个层次:

1. 架构级 effect boundary

业务代码产生 command/effect value,边缘 handler 执行它。re-frame、re-dash 和 TinyKV Raft 都属于这一层。它们不需要语言知道 effect 是什么。

2. 库级 effect program

Free Monad、Cats Effect、Fetch 或 workflow DSL 把 effectful computation 变成可组合的 value。不同库可能强调 interpretation、并发、取消、错误处理、资源安全或查询优化。

3. 语言级 Effect System

类型或语言语义显式追踪 effects,编译器能检查 effect contract;algebraic effect handlers 还可以让 effect operation 的解释成为语言模型的一部分。

三者共享“把 effect 从普通函数调用中显式分离出来”的方向,但不能互相替代。返回 effect map 不会自动获得编译期 effect checking;把函数包装成 IO[A] 也不一定告诉类型系统它具体需要哪些 capabilities。

应该引入哪一层

Effect abstraction 不是越完整越好。应该从实际需要的能力反推表示方式。

只想让业务规则容易测试

先返回 command 或 effect value,把 IO 推到 handler。通常不需要 Free Monad,也不需要设计一套通用 effect framework。

同一套动作需要多种执行方式

定义稳定的 effect description 和 interpreter boundary。生产、测试和模拟环境可以各自解释同一种 intent。

需要表达顺序、失败和资源生命周期

使用成熟的 effect type、workflow composition 和 Resource/Scope abstraction。不要让每个业务模块自己发明 rollback protocol。

需要 batch、并行、重排或静态分析

程序必须以足够透明的结构存在。Free、Applicative DSL 或专用 query plan 才能让 runtime 看见依赖关系和操作内容。

需要编译期限制 capability

这时才真正进入语言级 Effect System、effect row、capability type 或 algebraic effects 的范围。它解决的是 contract 和检查问题,而不只是测试问题。

每向后一层,系统都会得到更多控制能力,也会付出更多抽象、类型和学习成本。最小的正确选择,往往只是先把最难处理的副作用从业务判断里移出去。

Effect System 管理的是解释权

Effect abstraction 的目标不是消灭副作用。没有副作用的程序无法读取输入,也无法把结果交给用户。

真正需要管理的是解释权:谁可以决定一个 effect 如何执行、何时执行、失败后如何恢复,以及执行完成后由谁拥有产生的资源。

在 re-frame 里,event handler 描述 intent,reg-fx handler 拥有解释权;在 Raft 里,core 产生 Ready,raftstore 按协议持久化和发送;在 Free Monad 里,program 保留结构,natural transformation 选择目标 effect;在 resource workflow 里,scope 决定退出时如何释放已经取得的资源。

这些设计的共同点可以压缩成一句话:

让业务程序描述“要做什么”,把“怎么执行”交给一个明确、可替换、可检查的边界。

当 effect 变得显式,测试只是最先出现的收益。随后得到的还有可观察性、可替换解释、执行优化、资源安全,以及在语言级 Effect System 中进一步获得的静态检查。