回航 I:带回 Kotlin 与 Java
从这里开始顺流而下,货舱满载。回航卷的原则:不布道、只落地——每个上游思想给出「你的语言里最接近的写法 + 立刻能动手的位置」。本章是 JVM 侧,按投入产出比排序:零依赖就能写的排前面,要引库的排后面,要说服整个团队的排最后。你会发现大部分货物其实不用报关:Kotlin 的语法早就给你留好了泊位,缺的只是「原来该这么用」的那层自觉。
第一批货:零依赖,今天下午就能写
① ADT 建模 + 穷尽分派(第 4 章的正主,头号货物)。sealed interface 写和类型,data class 写积类型,when 表达式拿穷尽检查:
sealed interface OrderState {
data class Cart(val items: List<Item>) : OrderState
data class Paid(val items: List<Item>, val txId: TxId) : OrderState
data class Shipped(val trackNo: TrackNo) : OrderState
}
fun refundable(s: OrderState): Boolean = when (s) { // 少一个分支 = 编译错误
is OrderState.Cart -> false
is OrderState.Paid -> true
is OrderState.Shipped -> false
}
三条上游军规随行(第 4 章原文照搬):状态携带自己的数据(txId 只住在 Paid 里,别的状态想读都没有);when 用表达式形态(赋值或 return 它,才有穷尽检查;当语句用就没了——这是 Kotlin 最容易漏的保护);数一数状态空间(重构 Boolean 堆时,按第 4 章的乘法算一遍旧代码能表示多少鬼状态,写进 PR 描述,比任何布道都有说服力)。
② 智能构造子(parse, don't validate):
@JvmInline
value class Email private constructor(val value: String) {
companion object {
fun of(raw: String): Email? =
raw.takeIf { '@' in it }?.let(::Email) // 全世界唯一的出生通道
}
}
value class 就是 newtype(连零开销都对齐了),私有构造子 + companion 工厂就是模块导出控制。从此接收 Email 的每个函数都免检——校验一次,类型作保,层层判空的防御性代码整批退休。UserId/OrderId/金额/时区这类「都是 String/Long 但语义不同」的参数,同方治疗,传反直接编译不过。
③ 纯函数下沉(第 11 章的架构课,落地版)。不用改架构,只做一个动作:每次写新逻辑,先问「这段能不能写成 (输入) -> 输出、不碰 IO/时间/随机/全局」——能,就把它放进一个无依赖的纯类/顶层函数,IO 留在调用它的地方。三个月后你的 domain 层会自然长成「湖」,测试覆盖率不请自来。时间和随机这两个隐形副作用,用参数注入(now: Instant 当参数传,而不是内部 Instant.now())——这是第 12 章 Reader 思想的最小落地。
第二批货:引一个库——Arrow
Kotlin 官方 Result 的错误位焊死为 Throwable,装不下你的错误 ADT(第 8 章的核心主张)。Arrow 补上这块,现代用法以 Raise DSL 为主轴:
sealed interface RegError {
data object NameTaken : RegError
data class BadEmail(val raw: String) : RegError
}
fun Raise<RegError>.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)
}
// 调用点决定怎么收:fold 就是第 8 章的「铁路终点站」
val outcome = fold({ register(form) },
{ err -> showError(err) }, // Left 轨
{ user -> showWelcome(user) }) // Right 轨
眼熟到不需要解释:raise 是 Left,ensure 是守卫(第 2 章的老朋友),整个函数体是 Either 的 do 块——Arrow 用 Kotlin 的 context receiver 机制重建了第 10 章的语法糖,而且错误类型印在签名的 Raise<RegError> 里,谁调用谁知情。配套的 Either<E, A>、NonEmptyList、累积错误的 zipOrAccumulate(第 9 章 Validation 的化身)一应俱全。落地建议:从一个新模块开始,别全库改造——Arrow 的类型会顺着调用链传播(这是特性),渐进比革命稳。
Java 侧:21 之后,门也开了
sealed interface Shape permits Circle, Rect {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
static double area(Shape s) {
return switch (s) { // 穷尽检查 ✓
case Circle c when c.r() > 100 -> big(c); // 守卫也有了
case Circle c -> Math.PI * c.r() * c.r();
case Rect r -> r.w() * r.h();
};
}
record(积类型)+ sealed(和类型)+ switch 模式匹配(JEP 441,Java 21 定稿)——第 4 章的三件套在 Java 里凑齐了,连解构(record pattern,case Circle(double r))都在,嵌套解构也支持。Optional 则补三条军规:方法返回值可用 Optional,字段和参数别用(序列化和语义都别扭);get() 视为禁用词,用 orElse/map/flatMap(第 8、10 章的三板斧);集合场景直接用 Stream 的 filter/map 管道,别在循环里手剥。
Compose 专线:不可变是性能,不只是洁癖
Android 侧还有一条专属航线。Jetpack Compose 的重组(recomposition)引擎有个核心机制:参数被判定为稳定(stable)的可组合函数,输入没变就整个跳过重组。而「稳定」的头号来源就是不可变:val 字段的 data class 稳定,List 接口不稳定(编译器无法排除底层是可变实现),于是无谓重组、页面卡顿。上游七件套里的「不可变优先」在这里直接兑换成帧率:状态类全 val、集合用 kotlinx.collections.immutable 的 PersistentList(它就是 Haskell 列表那种结构共享的持久化数据结构,第 7 章的共享思想落在数据结构层)、或者用 @Immutable/@Stable 注解向编译器背书。「不可变让系统敢于缓存」——第 7 章讲共享时的那句话,就是 Compose 跳过重组的全部原理。惰性那章不是白读的:LazyColumn 的「可见才组合」、derivedStateOf 的「读到才重算」,全是 thunk 思想的 UI 化身。
测试与架构:两件压舱货
jqwik(第 13 章的承诺):JUnit 平台原生,@Property + @ForAll 两个注解起飞,自带收缩与种子复现。第一个落点选纯函数密集的模块——金额计算、日期归一化、解析器,往返/不变量/参照三句式套上去。MVI/单向数据流:如果你的项目在用 MVI 或 Compose 的 state hoisting,恭喜,你已经在 Elm 架构里了——State 是不可变 data class、Intent 是和类型、reduce 是纯函数 (State, Intent) -> State。上游给你的增量是自觉:守住 reducer 的纯度(不碰仓库、不发请求,副作用全部作为 Effect 值返回给边缘执行),它就永远可单测、可回放、可时间旅行(第 12 章的 Redux 论证,原样适用)。
| 上游思想 | JVM 落地 | 成本 |
|---|---|---|
| ADT + 穷尽匹配(4) | sealed + data class + when 表达式 / Java 21 三件套 | 零 |
| 智能构造子(4) | value class + private constructor + companion of() | 零 |
| 纯核心脏外壳(11) | 逻辑下沉纯类;时间/随机当参数注入 | 零(习惯) |
| Either / do(8、10) | Arrow Either + Raise DSL | 一个依赖 |
| 错误累积(9) | Arrow zipOrAccumulate | 同上 |
| 属性测试(13) | jqwik(Kotlin/Java 通用) | 一个测试依赖 |
| State/Elm 架构(12) | MVI + 纯 reducer + Effect 值 | 架构约定 |
| 不可变优先(7) | val、copy()、kotlinx.collections.immutable | 零到一个依赖 |
回航最大的暗礁是用力过猛。三条刹车:其一,Kotlin 的惯用语优先——能用 ?: 和 ?. 清楚表达的,不必上 Either;字符串拼接不用写 fold。判断标准是「下一个维护者读起来自然吗」,不是「够不够函数式」。其二,point-free 和深度柯里化在 Kotlin 里是逆水行舟(语法不支持,IDE 不友好),放弃执念。其三,团队梯度:先带来「结果」(一个用 ADT 重构后 bug 归零的模块、一条抓住真 bug 的性质),再带来「词汇」——Monad 这个词永远不要出现在你说服同事的第一句话里。上游的纪念品是思想,不是口音。
1. 本周的真实任务:把项目里一个「status: String + 一堆可空字段」的类重构成 sealed 层级。记录:消灭了多少「理论上可表示」的鬼状态?多少处调用点的判空消失了?
2. 给一个已有的 validation 方法换成 Raise 风格(或先用 Either<E, A> 热身),错误从 String 升级成 sealed 的错误 ADT。
3. 挑一个纯函数密集的工具类,写三条 jqwik 性质。至少一条要用「先改坏实现验证性质会红」的仪式确认它有杀伤力(第 13 章暗礁)。
4. 审计你的 ViewModel:reducer 里有几处直接调了 repository/analytics?把其中一处改成「返回 Effect 值、边缘执行」,对比测试代码的前后差异。
5. Compose 性能题:用 Layout Inspector 的重组计数找一个重组过量的 Composable,检查它的参数里是否有 List/可变类——换成 PersistentList 或 @Immutable 包装后再数一次。