STATION V · 回航CH 18里程 18/20

回航 I:带回 Kotlin 与 Java

从这里开始顺流而下,货舱满载。回航卷的原则:不布道、只落地——每个上游思想给出「你的语言里最接近的写法 + 立刻能动手的位置」。本章是 JVM 侧,按投入产出比排序:零依赖就能写的排前面,要引库的排后面,要说服整个团队的排最后。你会发现大部分货物其实不用报关:Kotlin 的语法早就给你留好了泊位,缺的只是「原来该这么用」的那层自觉。

sealed + whenArrowJava 21jqwik

第一批货:零依赖,今天下午就能写

① 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 写成蹩脚 Haskell

回航最大的暗礁是用力过猛。三条刹车:其一,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 包装后再数一次。