炉 III · 塑形CH 12进度 12/26

context parameters:2.4 转正的隐式依赖

你为了在最底层那个函数里打一行日志,把 logger 加进了它的参数表。然后它的调用者也得加,调用者的调用者也得加 —— 五层之后,logger 出现在了十几个和日志毫无关系的函数签名里。这叫参数穿透(parameter drilling),前端的同学管它叫 prop drilling,而它在后端和 Android 里同样普遍:loggerclocktransactiontracerfeatureFlags…… 这一章讲 Kotlin 2.4 转正的解法:让依赖成为「作用域里的空气」,但全程类型安全、编译期可查 —— 这是 DI 框架用反射和运行时图去做的事,而它用类型系统做。

context parameters2.4 转正参数穿透Raise 的地基

问题长什么样

// 只有最底下这个函数真的需要 logger 和 tx
suspend fun syncOrder(
    repo: OrderRepo, user: User,
    logger: Logger, tx: Transaction, clock: Clock      // ← 三个「环境」参数
) {
    val order = repo.load(user.id, logger, tx)          // 往下传
    validate(order, logger, clock)                      // 往下传
    repo.save(order, logger, tx)                        // 往下传
}

三个「环境类」的依赖,污染了整条调用链的签名。而它们和函数的业务语义毫无关系 —— syncOrder 的本质是「同步一个订单」,不是「同步一个订单,顺便还需要一个日志器、一个事务和一个时钟」。

现有的三条出路,各有代价:

做法代价
全局单例Log.d(...)System.currentTimeMillis()不可测(换不掉)、不可控(谁都能用)、隐藏依赖(签名上看不出来)。Android 里遍地都是
构造函数注入到类(Dagger/Hilt)好办法,但只解决类级别的依赖。顶层函数、扩展函数、UseCase 之外的纯逻辑,用不上
扩展函数的接收者fun Logger.syncOrder()只能有一个接收者,而且它占用了「this」这个位置 —— 一旦被日志占了,扩展函数本来该扩展的那个类型就没位置了

2.4 的解法

✦ 新炉料 · context parameters 2.4 转正
context(logger: Logger, tx: Transaction)
suspend fun syncOrder(repo: OrderRepo, user: User) {   // 签名干净了
    logger.info("syncing ${user.id}")                  // 直接用,不用传
    val order = repo.load(user.id)                     // 它也声明了 context,自动接力
    tx.commit()
}

// 调用方:只要作用域里「有」这些东西就行
with(logger) {
    with(transaction) {
        syncOrder(repo, user)      // ✓ 编译器自己把 context 传进去
    }
}

关键词是「有名字」context(logger: Logger) —— 参数有名字,你在函数体里就用这个名字访问它。(旧的 context receivers 是匿名的,只能靠类型解析,这是它被废弃的原因之一。)

三个性质,每一个都重要:

  • 它在签名里。和全局单例的根本区别 —— context(tx: Transaction) 明明白白写着「这个函数需要一个事务」。依赖是显式的,传递是隐式的,这正是我们想要的组合。
  • 它是类型安全的。调用点的作用域里如果没有 Transaction编译错误(不是运行时崩溃 —— 对比一下 DI 框架的运行时依赖图解析失败)。
  • 可以有多个。扩展接收者只能有一个,context parameters 想要几个有几个。而且它不占用 this —— 一个函数可以同时是 String 的扩展函数并且需要 Logger 上下文。

什么时候该用它

判据只有一条:这个依赖是「环境」还是「输入」?

它是…怎么传例子
输入 —— 函数要处理的数据,换一个就是另一次调用普通参数userorderIdquery
接收者 —— 这个函数「属于」谁扩展接收者 / 成员函数fun String.shout()
环境 —— 一片区域内共享、很少变、和业务语义无关context parameterLoggerClockTransactionCoroutineScopeRaise<E>Density(Compose)

另一个绝妙的用法是把「能力」编进类型

// 只有在事务里才能调用的函数 —— 编译器强制
context(tx: Transaction)
fun OrderRepo.save(order: Order) { ... }

repo.save(order)                    // ✗ 编译错误:作用域里没有 Transaction
transaction { repo.save(order) }    // ✓ 只有这样才行

「必须在事务里调用」这条规则,从文档里的一句话,变成了编译器的一条铁律。同样的模式可以用在:必须在主线程调用、必须持有某个权限、必须已登录 —— 把运行时的检查(和随之而来的运行时崩溃)搬到编译期。

它也是 Arrow Raise DSL 的地基

第 21 章会详讲 Arrow,但因果关系在这里:

context(raise: Raise<RegError>)      // 「这个函数可以抛出 RegError 类型的错误」
fun register(form: Form): User {
    val email = Email.of(form.email) ?: raise.raise(RegError.BadEmail)
    return repo.save(form.name, email)
}

「这个函数会失败,失败的类型是 RegError」—— 这个信息印在签名里,而成功路径的代码读起来仍然像普通的顺序代码(没有 .map、没有 .flatMap、没有一层层解包)。Arrow 用 context parameters 重建了「受检异常」,但这次是类型安全、可组合、且不强制的。

这也解释了为什么 Kotlin 团队会推进这个特性:它不只是「少传几个参数」的语法糖,它是一块能力载体(capability) 的地基,很多库都在等它。

⚠ 炉渣 · 隐式性是有代价的

context parameters 让代码里出现了「不知道从哪来的」名字。读代码的人看到 logger.info(...),得往上翻才知道这个 logger 是哪来的。这是所有隐式机制的通病(Scala 的 implicit 就是前车之鉴 —— 它一度让代码变成猜谜游戏)。

三条自律:

  • 只用于真正的「环境」。业务数据(user、order)永远走普通参数。如果你开始用 context 传业务对象,你已经走上歧路。
  • 数量控制在 1—2 个。三个以上就该问:是不是该把它们打包成一个 AppContext
  • 类型要足够独特。Logger,别用 String —— 靠类型来消歧的机制,遇上通用类型就会失灵(context(name: String) 会和其他任何 String 上下文打架)。

2.4 还给了一个逃生舱:显式上下文实参-Xexplicit-context-arguments),允许你在调用点手动指定用哪个上下文 —— 当作用域里有两个同类型上下文时,这是唯一的消歧办法。

⚠ 炉渣 · 如果你用过 context receivers,那套语法作废了

2022 年前后实验性的 context(Logger)没有参数名)已被废弃并替换。旧代码要迁移:

// 旧(context receivers,已废弃)
context(Logger)
fun doWork() { info("x") }        // 靠类型隐式解析,没有名字可用

// 新(context parameters,2.4 转正)
context(logger: Logger)
fun doWork() { logger.info("x") } // 有名字,显式访问

这正是「实验特性会变」的活教材 —— 也是为什么本书对每个新特性都标注稳定状态。实验特性可以学、可以在玩具项目里用,但别拿它当生产架构的地基。

它和 DI 框架是什么关系

会取代 Hilt 吗?不会,它们解决的是相邻但不同的问题。

DI 框架(Hilt/Koin)context parameters
作用范围对象的依赖(构造函数注入)函数的依赖
检查时机Hilt:编译期(KSP 生成代码);Koin:运行时编译期,靠类型系统
解决的问题「谁来创建这个对象,怎么管理它的生命周期」「这段代码需要什么环境才能运行」
典型对象Repository、ViewModel、数据库、网络客户端Logger、Clock、Transaction、Raise

实际用法是互补的:用 Hilt 把 Logger 注入到 ViewModel,然后在 ViewModel 内部用 with(logger) { ... } 把它开成一个上下文,让下面所有纯函数都能用 —— 而那些纯函数的签名保持干净,且完全可以脱离 Android 单测(测试时传一个假 Logger、假 Clock 进去就行)。

▸ 旧模具 · Java 只有 ThreadLocal

Java 里想要「隐式的环境」,唯一的工具是 ThreadLocal(或者 Spring 那种基于代理和反射的运行时魔法)。而 ThreadLocal 有三个致命问题:类型不安全(拿出来要转型)、签名上完全看不见(依赖是隐藏的)、和协程水火不容(协程会换线程,第 16 章)。

Kotlin 的做法是把这件事交给类型系统:编译期解析、编译期报错、零运行时开销(context 参数编译后就是普通参数,只是你不用手写)。这是一个「用类型系统解决工程问题」的漂亮例子 —— 也是 Java 短期内给不了的。

✚ 动手锻打

1. 找出你项目里被穿透最多的那个参数。常见嫌疑人:loggeranalyticsclockdispatcherfeatureFlags。数一数它出现在多少个函数签名里 —— 那个数字就是这个特性能帮你删掉的行数。

2. 找一处「必须在某个前提下才能调用」的函数(必须在事务里、必须已登录、必须在主线程)。现在它靠注释或者运行时检查保证 —— 试着用 context parameter 把它变成编译期约束。

3. 如果你的 Kotlin 还没到 2.4:这一章暂时是「读」的。但请把「升级 Kotlin」的优先级往上提一档 —— 这个特性 + 第 8 章的显式后备字段,加起来能删掉的样板相当可观。

4. 项目里如果直接调用了 System.currentTimeMillis()Log.d():这些是「伪装成便利的全局单例」。把其中一处改成注入 Clockkotlin.time.Clock,2.3 已进标准库),然后写一个用假时钟的单测 —— 感受一下「时间可控」之后测试变得多简单。

塑形炉最后一章:不可变。它不是洁癖 —— 在 Compose 里,它直接就是帧率。