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

扩展与作用域函数:静态分派的真相

扩展函数是 Kotlin 最讨喜的特性 —— 它让你给任何类「加方法」,不用继承、不用包装。但它也是误解最深的特性:因为它长得像方法,所有人都假设它方法。它不是。它是个静态函数,穿着方法的外套。这一章先把外套扒下来(三条推论,每一条都会在真实项目里咬人),再处理那个永恒的团队争论:let / run / also / apply / with,五个长得差不多的东西,到底该用哪个 —— 我给你一棵能一路点到底的决策树。

静态分派扩展属性作用域函数决策树

扩展函数不是方法

第 2 章反编译过它了:fun String.shout() 编译成 static String shout(String receiver)接收者只是第一个参数。三条推论,逐个讲清代价:

推论一:它按静态类型分派(不听多态的话)

open class Animal
class Dog : Animal()

fun Animal.speak() = "..."
fun Dog.speak() = "汪"

val pet: Animal = Dog()
println(pet.speak())     // 打印 "..." —— 不是 "汪"

因为编译器在编译期就根据变量的声明类型(Animal)选定了那个静态函数。运行时它装着什么,无关紧要。

这不是 bug,是定义。但它意味着:凡是需要多态的行为,都不能用扩展函数实现。如果你发现自己在给一个类层级的每一层都写扩展函数、并期待「自动调用最具体的那个」—— 停下,你需要的是成员函数(或者一个 sealed + when,第 7 章)。

推论二:成员函数永远赢(时间炸弹)

如果类里有同名同参的成员函数,你的扩展函数永远不会被调用,而且编译器不会警告你(只有 IDE 会给一个灰色提示)。

这在给第三方库写扩展时是一颗定时炸弹:

// 你写的(库的 2.0 版本里,Response 还没有 isSuccess)
fun Response.isSuccess() = code in 200..299

// 半年后,库升到 2.1,作者给 Response 加了一个 isSuccess() 成员函数,
// 语义是「HTTP 成功 并且 body 非空」——
// 你的扩展静默失效,行为悄悄变了,编译一切正常,测试可能也过

防御措施:给第三方类型写的扩展函数,名字带上你的项目前缀(fun Response.appIsSuccess()),或者干脆放进一个明确的工具对象里。丑一点,但不会在某次依赖升级后悄悄改变行为。

推论三:它碰不到 private(这其实是优点)

扩展函数只能访问接收者的公开 API —— 它不在那个类里,凭什么碰人家的私有状态。

所以:扩展函数不破坏封装。这是它比「继承 + 重写」更安全的根本原因,也是它能被安全地用在第三方类型上的原因。

扩展属性:能加,但别加状态

val String.lastChar: Char
    get() = this[length - 1]        // ✓ 只是个 getter,合法

val String.cache: MutableMap<String, String> = mutableMapOf()   // ✗ 编译错误

为什么第二个不行?因为扩展属性不能有后备字段 —— 编译器没法往 String 类里塞一个新字段(那个类是 JDK 的,你改不了)。

扩展属性只是「一对静态的 getter/setter 函数」,穿着属性的外套。它能计算,不能存储。

什么时候该写扩展函数

场景判决
给第三方类型(StringContextView、Retrofit 的 Response)加便利方法✓ 主场。这是扩展函数存在的理由
把一个类里「不属于核心职责」的方法挪出去(fun User.toDisplayName() 这种 UI 格式化)✓ 好。让 domain 模型保持纯净,UI 的关注点留在 UI 层
给自己写的类加方法⚠ 想一想:为什么不直接写成成员函数?除非是为了分层(上一条),否则成员函数更自然、支持多态
需要多态行为✗ 不行。见推论一
需要访问私有状态✗ 不行。见推论三
一个到处都是扩展函数的「Utils.kt」,一千行⚠ 那不是扩展函数的问题,是你的代码组织问题。按接收者类型拆文件:StringExt.ktViewExt.kt

作用域函数:终结这个争论

五个函数,两个维度,就这么简单:

函数块里怎么称呼对象返回什么一句话
letitlambda 的结果判空 + 变换
runthislambda 的结果配置 + 算出个东西
withthislambda 的结果同 run,但对象当参数传(不是扩展函数
applythis对象自己配置对象,然后把它交出去
alsoit对象自己「并且还」顺便做件事

但表格背不下来。所以下面这棵树 —— 三个问题以内,一定能问到答案

◆ 锭 · 两条能立刻用的判据

① 先问「我要的是对象自己,还是一个新值?」要对象自己 → apply/also;要新值 → let/run/with。这一刀砍下去,五选一变成三选一。

also 的语义是「并且还」。判断标准:把这个 also 块整个删掉,主逻辑还成立吗?成立 → 用 also 是对的(它是日志、缓存、埋点这类旁路副作用);不成立 → 你用错了,它其实是主逻辑,不该藏在 also 里。

两个常见的坏味道

① 套娃

user?.let { u ->
    u.profile?.let { p ->
        p.avatar?.let { a ->
            load(a)                  // 三层缩进,it 满天飞
        }
    }
}

// 更好:
val avatar = user?.profile?.avatar ?: return   // 安全调用链本来就是干这个的
load(avatar)

?.let 不是「判空的唯一写法」。能用 ?. 链 + ?: 说清的,就别开 lambda —— Kotlin 的空安全操作符本来就是为了消灭嵌套而生的,别用 let 把嵌套请回来。

② 用 applyalso 的活

// 坏:this 是隐式的,读者要猜 log() 是谁的方法
fun save(u: User) = repo.save(u).apply { log("saved: $this") }

// 好:it 是显式的,一眼看清
fun save(u: User) = repo.save(u).also { log("saved: $it") }

规则:this(隐式接收者)适合「连着访问它的一堆成员」,it(显式参数)适合「把它当一个值用」。apply 块里如果你几乎不访问对象的成员,那你该用 also

⚠ 炉渣 · 别在 apply 里做校验然后返回
val user = User().apply {
    name = input.name
    if (name.isBlank()) return null   // ⚠ 这个 return 返回的是外层函数!
}

因为 apply 是 inline 的(第 9 章),这里的 return非局部返回 —— 它直接从包着这段代码的函数返回了,而不是「从 apply 块返回」。这有时正是你要的,但更多时候是个惊喜。

想只从 lambda 返回,得用标签:return@apply而如果你需要在配置块里做条件判断和提前退出,那说明它不该是个配置块 —— 拆成普通代码。

为什么 Kotlin 有五个,而不是一个

值得停下来想一秒:这五个函数没有一个是语言特性,它们全是标准库里十几行的普通 inline 函数。你完全可以自己写第六个。

// apply 的真实实现,就这么点东西
inline fun <T> T.apply(block: T.() -> Unit): T {
    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }   // 第 4 章
    block()
    return this
}

它们能存在,靠的是本炉的两块地基:inline(第 9 章)让它们零开销带接收者的 lambda(T.() -> Unit)让 this 能被替换

而后者 —— 带接收者的 lambda —— 是下一章的正题,也是 Kotlin 相对 Java 最有结构性的一处优势。Compose 的整棵 UI 树、Gradle KTS、Ktor 的路由 DSL,全都建在这一个东西上。

▸ 旧模具 · Java 有扩展函数吗

没有,而且短期内不会有。Java 想给 String 加方法,只有两条路:静态工具类(StringUtils.shout(s) —— 于是你的调用永远是 Utils.foo(Utils.bar(x)) 这种由内向外读的洋葱),或者包装类(然后到处 unwrap)。

扩展函数的真正价值不是「省了几个字符」,而是调用顺序变成了从左到右s.trim().shout().take(5) —— 数据流的方向和阅读方向一致。这是可读性上的实质胜利,尤其在链式处理里。

而作用域函数 —— Java 一个都没有,因为它没有带接收者的 lambda

✚ 动手锻打

1. 搜项目里的 ?.let {,看有多少处其实可以直接用 ?. 链 + ?: 写完。这类「用 let 请回嵌套」的代码在 Kotlin 项目里非常普遍。

2. 找一处 apply,检查块里是不是几乎没访问对象成员。是的话改成 also —— 然后感受一下 it 带来的可读性。

3. 检查你给第三方类型(OkHttp、Retrofit、AndroidX)写的扩展函数:它们有没有可能和未来的成员函数撞名?给最危险的那几个加上项目前缀。

4. 团队练习:把这棵决策树贴进 code review 的检查清单。关于作用域函数的争论,从此有了一个可以指着看的东西 —— 这比每次凭感觉吵一遍高效得多。

下一章:把语言掰弯给业务用 —— DSL、运算符重载、解构,以及那个「写反顺序编译器不吭声」的解构陷阱(2.3.20 终于修了)。