Arrow:Raise DSL、错误累积与光学器件
一提 Arrow,大多数 Android 工程师的反应是「函数式那套,看不懂,团队推不动」。这个印象在五年前是对的(那时候它确实满是 Kleisli 和 Monad Transformer),但今天的 Arrow 完全变了:它的旗舰 API 叫 Raise,用起来像普通的顺序代码,没有一个 flatMap。这一章不布道函数式,只讲三个 Arrow 能解决的具体问题:错误类型被焊死成 Throwable、表单校验只能报第一个错、深层不可变更新要写五层 copy。最后给一份诚实的清单:什么时候不该引入它。
问题一:错误类型不该被焊死
上一章的结论: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:库,今天就能用,需要一个依赖,语法是
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 更安全(自动处理异常和取消);resourceScope 是 use 的加强版 |
诚实清单:什么时候不该用 Arrow
- 你的项目没有复杂的错误处理需求。如果失败只有「成功 / 失败」两种,
?:和一个 sealed 类完全够用 —— 别为了「更函数式」引一个依赖。 - 团队没有共识。Arrow 的类型会顺着调用链传播(这是特性)—— 一个人开始用
Raise,整条链上的人都得懂它。先取得共识,再引入;否则你会成为那个「写了没人敢改的模块」的人。 - 你在写库。你的依赖会变成使用者的依赖。库的公开 API 应该只依赖标准库。
- APK 体积极度敏感。arrow-core 不大(R8 之后更小),但如果你在为几百 KB 斤斤计较,先算一下账。
- 你只是想用
Option。别用 —— Kotlin 有可空类型,它更快(零开销)、更地道、更好读。Option<T>在 Kotlin 里是纯粹的多余(Arrow 官方自己也是这么建议的)。
别做全库改造。挑一个新的、边界清晰的模块(比如新的支付流程、新的表单页),在里面用 Raise。三个月后拿数据说话:
- 这个模块的 NPE / 未处理错误崩溃有几起?(大概率是零)
- 新增一种失败类型时,编译器帮你找出了几个调用点?
- 表单错误提示的用户体验有没有改善(一次报全 vs 挤牙膏)?
用结果说服团队,别用词汇。「Monad」这个词永远不要出现在你说服同事的第一句话里 —— 这是本书最实用的一条政治建议。
Vavr(前 Javaslang)是 Java 的对应物,提供 Either、Try、持久化集合。但它在 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-coroutines 的 parZip,对比第 17 章那个 coroutineScope { async + async + await } 骨架。想清楚:你需要它替你管的那些边界情况(异常、取消)吗?
淬火炉最后一章:性能。Kotlin 的抽象大多是零开销的 —— 但「大多」不是「全部」,而这一章把账一笔笔算清。