炉 V · 淬火CH 21进度 21/26

Arrow:Raise DSL、错误累积与光学器件

一提 Arrow,大多数 Android 工程师的反应是「函数式那套,看不懂,团队推不动」。这个印象在五年前是对的(那时候它确实满是 KleisliMonad Transformer),但今天的 Arrow 完全变了:它的旗舰 API 叫 Raise,用起来像普通的顺序代码,没有一个 flatMap。这一章不布道函数式,只讲三个 Arrow 能解决的具体问题:错误类型被焊死成 Throwable、表单校验只能报第一个错、深层不可变更新要写五层 copy。最后给一份诚实的清单:什么时候不该引入它。

Raise DSLzipOrAccumulateOptics什么时候别用

问题一:错误类型不该被焊死

上一章的结论:kotlin.Result 的错误位是 Throwable,装不下你的错误 ADT。Arrow 的 Either<E, A> 补上这块 —— 错误类型由你定

但如果只是 Either,你会陷入 flatMap 地狱:

// 三个都可能失败的操作,串起来 —— 传统函数式写法
fun register(form: Form): Either<RegError, User> =
    Email.of(form.email)
        .flatMap { email ->
            checkNameFree(form.name)
                .flatMap { _ ->
                    repo.save(form.name, email)     // 三层嵌套,只为了串三步
                }
        }

这就是「团队推不动」的真正原因 —— 不是概念难,是代码难看。

Raise DSL:把它写回顺序代码

context(raise: Raise<RegError>)          // ← 第 12 章的 context parameters
fun register(form: Form): User {
    val email = Email.of(form.email) ?: raise(RegError.BadEmail(form.email))
    ensure(!repo.exists(form.name)) { RegError.NameTaken }    // 守卫
    return repo.save(form.name, email)                        // 直接返回 User,不包一层
}

// 调用点决定怎么收:
val outcome = fold(
    block = { register(form) },
    recover = { err -> showError(err) },      // 失败轨
    transform = { user -> showWelcome(user) } // 成功轨
)

// 或者转成 Either:
val result: Either<RegError, User> = either { register(form) }

看清楚发生了什么:成功路径读起来完全是普通的顺序代码(没有 flatMap、没有嵌套、没有解包),而失败的类型印在签名的 Raise<RegError>,调用方一清二楚。

它的原理正是第 12 章那块地基:context parameters 让「这个函数可以抛出 E 类型的错误」成为一种能力,由类型系统追踪。—— 换句话说,Arrow 用库重建了受检异常,但这次做对了(可组合、不传染、错误是值)。

◆ 锭 · Raise 和 Rich Errors 是一回事吗

目标相同(把失败写进签名、让成功路径保持顺序代码的形态),但:

  • Raise:库,今天就能用,需要一个依赖,语法是 context(raise: Raise<E>)成熟、稳定、有生产案例。
  • Rich Errors(第 20 章):语言特性,2.4 实验,语法是 User | NotFound更干净,但还会变。

今天的选择:新项目想要类型化错误 → Arrow 的 Raise(成熟)。Rich Errors 稳定后再迁移 —— 而且那时的迁移会很顺,因为心智模型是同一个:错误是值,失败在签名里。

问题二:表单校验只能报第一个错

这是个所有人都遇到过、但很少有人正经解决的问题:

// 短路语义:邮箱错了,就不检查密码和年龄了
// 于是用户改完邮箱,点提交,又被告知「密码太短」—— 一次一个,改五次
val email = Email.of(form.email) ?: return Err(BadEmail)
val pwd = Password.of(form.pwd) ?: return Err(WeakPassword)
val age = Age.of(form.age) ?: return Err(BadAge)

你要的不是短路,而是累积 —— 一次报全所有错误:

fun Raise<NonEmptyList<FormError>>.validate(form: Form): ValidForm =
    zipOrAccumulate(                     // ← 三个校验全部执行,错误汇总
        { Email.of(form.email) ?: raise(BadEmail) },
        { Password.of(form.pwd) ?: raise(WeakPassword) },
        { Age.of(form.age) ?: raise(BadAge) }
    ) { email, pwd, age -> ValidForm(email, pwd, age) }

// 失败时你拿到的是 NonEmptyList —— 所有错误,一次给 UI 全标红

「短路」和「累积」是两种不同的语义,而大多数错误处理方案只提供前者。UI 表单要的恰恰是后者。NonEmptyList(保证至少有一个元素的列表)也是个好东西 —— 「校验失败了但错误列表是空的」这种鬼状态,被类型排除了(第 7 章的思路)。

如果你的 App 有一个多字段的表单,这一条就足以让 Arrow 值回票价。

问题三:五层 copy

第 13 章那个痛点:

// 手写:为了改一个邮编
val newState = state.copy(user = state.user.copy(company = state.user.company.copy(
    address = state.user.company.address.copy(zipCode = "1010"))))

// Arrow Optics:@optics 注解生成「镜头」
@optics data class Address(val zipCode: String) { companion object }
@optics data class Company(val address: Address) { companion object }

val newState = State.user.company.address.zipCode.set(state, "1010")   // 一行
// 或者做变换:
val upper = State.user.company.address.zipCode.modify(state) { it.uppercase() }

Lens(镜头)是一个「可组合的 getter + setter」State.user 是一个从 State 看到 User 的镜头,.company 再接一个,串起来就是一条深入的路径 —— 而且类型安全、编译期检查、可复用

代价:需要 KSP 生成代码(第 24 章)。判据:如果你的状态树深度 ≤ 2 层,别引它 —— 手写 copy 更直白。三层以上、且更新频繁,Optics 才划算。

Arrow 还有什么(一句话各表)

模块解决什么值得引入吗
arrow-core(Either、Raise、NonEmptyList)类型化错误、错误累积✓ 这是主菜。大多数项目只需要它
arrow-optics深层不可变更新⚠ 状态树深才划算
arrow-resilience(Schedule、CircuitBreaker)重试策略、断路器⚠ 重试逻辑复杂时有用;简单重试自己写个 retry(3) 就够了
arrow-fx-coroutines(parZip、parMap、resourceScope)并行组合、资源管理parZip 比手写 async + await 更安全(自动处理异常和取消);resourceScopeuse 的加强版

诚实清单:什么时候不该用 Arrow

⚠ 炉渣 · 五个别引入的理由
  • 你的项目没有复杂的错误处理需求。如果失败只有「成功 / 失败」两种,?: 和一个 sealed 类完全够用 —— 别为了「更函数式」引一个依赖。
  • 团队没有共识。Arrow 的类型会顺着调用链传播(这是特性)—— 一个人开始用 Raise,整条链上的人都得懂它。先取得共识,再引入;否则你会成为那个「写了没人敢改的模块」的人。
  • 你在写库。你的依赖会变成使用者的依赖。库的公开 API 应该只依赖标准库。
  • APK 体积极度敏感。arrow-core 不大(R8 之后更小),但如果你在为几百 KB 斤斤计较,先算一下账。
  • 你只是想用 Option别用 —— Kotlin 有可空类型,它更快(零开销)、更地道、更好读。Option<T> 在 Kotlin 里是纯粹的多余(Arrow 官方自己也是这么建议的)。
◆ 锭 · 落地策略:从一个新模块开始

别做全库改造。挑一个新的、边界清晰的模块(比如新的支付流程、新的表单页),在里面用 Raise。三个月后拿数据说话:

  • 这个模块的 NPE / 未处理错误崩溃有几起?(大概率是零)
  • 新增一种失败类型时,编译器帮你找出了几个调用点?
  • 表单错误提示的用户体验有没有改善(一次报全 vs 挤牙膏)?

用结果说服团队,别用词汇。「Monad」这个词永远不要出现在你说服同事的第一句话里 —— 这是本书最实用的一条政治建议。

▸ 旧模具 · Java 侧有对应物吗

Vavr(前 Javaslang)是 Java 的对应物,提供 EitherTry、持久化集合。但它在 Java 里的体验比 Arrow 在 Kotlin 里差得多,原因很实在:

  • Java 没有 context parameters,所以做不出 Raise DSL 那种「读起来像顺序代码」的形态 —— 只能一路 flatMap 到底。
  • Java 没有扩展函数,API 只能是静态方法或包装类。
  • Java 没有内联,每一层包装都是真实的对象分配。

这是本书里「语言特性如何决定库的上限」的最佳例证:同样的思想,在 Kotlin 里能做出好用的库,在 Java 里只能做出「理论上正确」的库。

✚ 动手锻打

1. 找到你项目里最复杂的那个表单(注册、支付、地址填写)。数一数:用户提交一次,最多只能得到几个错误提示?如果是「一次一个」—— 那就是 zipOrAccumulate 的用武之地。

2. 挑一个新模块,用 Raise 写它的业务逻辑。和你原来的 sealed 结果类型写法对比:调用链上少了多少层解包?

3. 如果你项目里已经有人引了 Arrow:检查有没有 Option<T>有的话全部换成可空类型 —— 那是在用一个更慢、更啰嗦的东西做 Kotlin 原生就做得更好的事。

4. 看一眼 arrow-fx-coroutinesparZip,对比第 17 章那个 coroutineScope { async + async + await } 骨架。想清楚:你需要它替你管的那些边界情况(异常、取消)吗?

淬火炉最后一章:性能。Kotlin 的抽象大多是零开销的 —— 但「大多」不是「全部」,而这一章把账一笔笔算清。