扩展与作用域函数:静态分派的真相
扩展函数是 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 函数」,穿着属性的外套。它能计算,不能存储。
什么时候该写扩展函数
| 场景 | 判决 |
|---|---|
给第三方类型(String、Context、View、Retrofit 的 Response)加便利方法 | ✓ 主场。这是扩展函数存在的理由 |
把一个类里「不属于核心职责」的方法挪出去(fun User.toDisplayName() 这种 UI 格式化) | ✓ 好。让 domain 模型保持纯净,UI 的关注点留在 UI 层 |
| 给自己写的类加方法 | ⚠ 想一想:为什么不直接写成成员函数?除非是为了分层(上一条),否则成员函数更自然、支持多态 |
| 需要多态行为 | ✗ 不行。见推论一 |
| 需要访问私有状态 | ✗ 不行。见推论三 |
| 一个到处都是扩展函数的「Utils.kt」,一千行 | ⚠ 那不是扩展函数的问题,是你的代码组织问题。按接收者类型拆文件:StringExt.kt、ViewExt.kt |
作用域函数:终结这个争论
五个函数,两个维度,就这么简单:
| 函数 | 块里怎么称呼对象 | 返回什么 | 一句话 |
|---|---|---|---|
let | it | lambda 的结果 | 判空 + 变换 |
run | this | lambda 的结果 | 配置 + 算出个东西 |
with | this | lambda 的结果 | 同 run,但对象当参数传(不是扩展函数) |
apply | this | 对象自己 | 配置对象,然后把它交出去 |
also | it | 对象自己 | 「并且还」顺便做件事 |
但表格背不下来。所以下面这棵树 —— 三个问题以内,一定能问到答案:
① 先问「我要的是对象自己,还是一个新值?」要对象自己 → 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 把嵌套请回来。
② 用 apply 干 also 的活
// 坏: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 想给 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 终于修了)。