Kotlin 让 Android 代码变短,一半功劳要记在扩展函数和高阶函数头上。String.isValidPhone()、View.visible()、Flow<T>.debounceClick() 这类写法把工具类里的 TextUtils.isEmpty(s) 变成了 s.isNullOrEmpty() 的阅读体验。
但这两把工具也是最容易被误用的地方:扩展函数不是虚方法,重写它不会有多态;inline 不是免费的优化开关,滥用会显著膨胀字节码;reified 只能用在 inline 函数里。理解这些边界,才能既享受语法红利又不掉坑。
一句话总结: 扩展函数是「编译期把接收者当第一个参数传进去的静态函数」,没有虚分派;高阶函数的开销靠
inline消除,而inline的代价是字节码膨胀——一切取舍都围绕这两个事实。
一、扩展函数的基本规则
1.1 语法与静态解析
扩展函数本质是一个顶层函数,接收者对象作为第一个参数隐式传入。
package com.example.ext
fun String.isValidPhone(): Boolean = length == 11 && all { it.isDigit() }
fun Int.toDp(): Float = this * 3f // this 指接收者
// 调用:"13800138000".isValidPhone() 得 true;16.toDp() 得 48.0
编译后等价于静态方法 public static final boolean isValidPhone(String $this$isValidPhone)。关键推论:扩展函数没有虚分派(no virtual dispatch),它不修改原类,也不能被重写。
1.2 静态解析与没有虚分派
调用哪个扩展函数由编译期的静态类型决定,与运行期实际类型无关。
open class Animal
class Dog : Animal()
fun Animal.speak() = "animal"
fun Dog.speak() = "dog"
val a: Animal = Dog() // 静态类型是 Animal
println(a.speak()) // animal,而不是 dog
同样的规则适用于泛型:List<String> 与 List<Int> 都能调用 List<T>.foo(),但类型参数在运行期被擦除,无法据此分派。
1.3 成员函数优先于扩展函数
当类中已有同名同签名的成员函数时,成员函数永远胜出,扩展函数被忽略且不会报错。
class Logger {
fun log(msg: String) = println("member: $msg")
}
fun Logger.log(msg: String) = println("extension: $msg")
fun main() {
Logger().log("hi") // member: hi
}
| 场景 | 实际调用 | 说明 |
|---|---|---|
| 成员与扩展同名同签名 | 成员函数 | 扩展被静默忽略 |
| 成员与扩展同名但签名不同 | 各自匹配 | 按参数类型重载解析 |
| 两个扩展同名同签名(不同包) | 需要 import 明确 | 否则歧义编译错误 |
扩展与 Any 上的成员冲突 | 成员函数 | 例如 toString 无法被扩展覆盖 |
| 扩展定义在子类作用域内 | 需要在该作用域调用 | 成员扩展(member extension) |
成员扩展(在类内部定义的扩展)只在类的作用域内可见,常用于 DSL 中隐藏内部 API。
二、扩展属性与可空接收者
扩展属性不能有 backing field,因此不能初始化,只能定义 getter(或 setter)。
val String.firstChar: Char?
get() = firstOrNull()
var TextView.textOrEmpty: String
get() = text?.toString().orEmpty()
set(value) { text = value }
// 编译错误:扩展属性不能初始化
// val String.x = 1
给可空接收者定义扩展,可以让 null 值也安全地走扩展逻辑:
fun String?.orDash(): String = this?.takeIf { it.isNotBlank() } ?: "—"
fun main() {
val a: String? = null
println(a.orDash()) // —
println("ok".orDash()) // ok
}
Android 里最常见的扩展集合是一批 View 扩展,例如基于 androidx.core.view.isVisible 封装 fun View.show() { isVisible = true } 与 fun View.hide() { isVisible = false }。
扩展属性与扩展函数一样是静态解析的,写在哪个文件、被谁 import 决定了它能否生效,这与成员的可见性规则完全不同。
三、函数类型与 FunctionN
3.1 函数类型即接口
Kotlin 的函数类型 (A, B) -> C 在 JVM 上编译为 Function2<A, B, C>(参数个数为 N 则对应 FunctionN,N 从 0 到 22,超过 22 个参数才需要自定义接口)。
val add: (Int, Int) -> Int = { a, b -> a + b } // Function2
val noArg: () -> Unit = { println("run") } // Function0
val withReceiver: String.() -> Int = { length } // Function1<String, Int>
fun applyTwice(x: Int, f: (Int) -> Int): Int = f(f(x))
带接收者的函数类型 A.() -> R 与普通 (A) -> R 在类型上不同,但可以互相转换:StringBuilder.() -> Unit 传给期望 (StringBuilder) -> Unit 的位置时,接收者会自动映射为第一个参数。
3.2 SAM 转换
Kotlin 对 Java 的单抽象方法接口(SAM,Single Abstract Method)支持自动转换,包括普通 lambda 与带接收者的 lambda。
// Java 接口:public interface OnClickListener { void onClick(View v); }
button.setOnClickListener { v -> v.alpha = 0.5f }
// 自定义 fun interface(Kotlin 1.4+)
fun interface Predicate<T> { fun test(value: T): Boolean }
fun <T> filterAll(items: List<T>, p: Predicate<T>): List<T> = items.filter { p.test(it) }
// 调用:filterAll(listOf(1, 2, 3, 4)) { it % 2 == 0 } 触发 SAM 转换
| 类型 | 能否 lambda 赋值 | 说明 |
|---|---|---|
| Java 接口(单方法) | 能 | 自动 SAM 转换 |
Kotlin fun interface | 能 | 需要显式声明 fun interface |
| 普通 Kotlin 接口(单方法) | 不能 | 必须用 object : X { } 匿名对象 |
| 抽象类 | 不能 | 只能匿名对象或子类 |
| 带接收者 lambda → SAM | 能 | 接收者映射到方法参数 |
在 Android 中大量使用 view.setOnClickListener { }、Runnable { }、Comparator { a, b -> } 正是 SAM 转换的直接体现。
四、作用域函数差异表
let、run、with、apply、also 五个标准库函数经常让人混淆,区别只有两个维度:接收者如何引用(it 还是 this),以及返回值是什么。
data class User(var name: String = "", var age: Int = 0)
val u = User()
val len = u.let { it.name = "leeting"; it.name.length } // it,返回 lambda 结果
val age = u.run { age = 30; age } // this,返回 lambda 结果
val same = with(u) { name = "yan"; this } // this,返回 lambda 结果
val applied = u.apply { age = 31 } // this,返回接收者
val logged = u.also { println(it) } // it,返回接收者
| 函数 | 接收者引用 | 返回值 | 典型用途 |
|---|---|---|---|
let | it | lambda 结果 | 可空对象链、局部作用域改名 |
run | this | lambda 结果 | 对象配置并计算结果 |
with | this | lambda 结果 | 对同一对象多次调用(非扩展) |
apply | this | 接收者本身 | 对象初始化、属性批量赋值 |
also | it | 接收者本身 | 副作用:日志、埋点、断言 |
记忆口诀:返回接收者的两个是 apply/also(a 打头,配置与附加),返回 lambda 结果的是 let/run/with;用 it 的是 let/also,用 this 的是 run/with/apply。
Android 里最典型的组合是 Intent 构造与 Bundle 赋值:
val intent = Intent(context, DetailActivity::class.java).apply {
putExtra("id", 42L)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
五、inline 与相关修饰符
5.1 inline 的收益与代价
inline 让编译器把函数体和 lambda 一起复制到调用处,从而消除 lambda 对象分配与虚调用开销。
inline fun measure(tag: String, block: () -> Unit) {
val start = System.nanoTime()
try { block() } finally {
println("$tag 耗时 ${(System.nanoTime() - start) / 1_000} us")
}
}
收益:无 Function0 对象分配,减少 GC 压力;支持 reified 类型参数与非局部 return;对高频调用的小函数(如 filter、forEach)效果明显。
代价:字节码膨胀,一个 inline 函数被调用 100 次函数体就复制 100 份;调试栈变浅,断点可能跳到意外位置;访问 private 成员的代码路径会退化为普通调用并给出 warning。
判断标准: 函数体很小(一两行)且接受 lambda 参数时适合 inline;函数体大或 lambda 参数少时不要 inline。
5.2 reified 与类型擦除
JVM 泛型在运行期被擦除,普通函数里无法写 T::class 或 is T。reified 借助 inline 把类型实参在编译期替换进调用处,从而恢复类型信息。
inline fun <reified T> Gson.fromJsonSafe(json: String): T? =
runCatching { fromJson(json, T::class.java) }.getOrNull()
inline fun <reified T : Activity> Context.startActivity() {
startActivity(Intent(this, T::class.java))
}
reified 的两条硬约束:只能用在 inline 函数中;不能用于非 inline 的嵌套 lambda 内部。is T、as T、T::class 在 reified 函数里全部可用,这也是 startActivity<T>() 这类扩展能存在的原因。类型擦除带来的另一个坑是签名冲突,见 5.4。
5.3 noinline 与 crossinline
inline 函数中的 lambda 参数默认也被 inline。当某个 lambda 需要作为对象传递(存储、返回、传给非 inline 函数)时,用 noinline 保留它作为普通函数对象。
inline fun <T> List<T>.forEachChunked(
size: Int,
noinline onChunk: (List<T>) -> Unit, // 需要作为对象传递
crossinline body: (T) -> Unit, // 允许非局部返回被禁止
) {
var i = 0
while (i < this.size) {
val chunk = subList(i, minOf(i + size, this.size))
onChunk(chunk)
chunk.forEach { body(it) }
i += size
}
}
crossinline 的用途是禁止 lambda 内部使用非局部 return,因为该 lambda 会被包装进另一个执行上下文(例如 Runnable),此时直接 return 会破坏调用栈语义,加了 crossinline 后编译器会拒绝这类写法。
| 修饰符 | 效果 | 何时用 |
|---|---|---|
| 默认(无) | lambda 被 inline,支持非局部返回 | 绝大多数场景 |
noinline | lambda 保留为函数对象 | 需要把 lambda 存起来或传给普通函数 |
crossinline | lambda 被 inline,但禁止非局部返回 | lambda 被包进其他上下文执行 |
5.4 @JvmName 与类型擦除冲突
JVM 不允许两个方法的 JVM 签名完全相同,而泛型擦除会让不同泛型的扩展函数撞车。
// 编译错误:JVM 签名冲突(都是 List)
// fun List<String>.joinAll(): String = joinToString()
// fun List<Int>.joinAll(): String = joinToString()
// 正确做法:用 @JvmName 区分
@JvmName("joinAllStrings")
fun List<String>.joinAll(): String = joinToString()
@JvmName("joinAllInts")
fun List<Int>.joinAll(): String = joinToString("|")
同样的冲突会出现在属性的 getter 上,此时用 @get:JvmName:
val <T> List<T>.lastIndexOrNull: Int?
@JvmName("lastIndexOrNullOfList")
get() = if (isEmpty()) null else lastIndex
Kotlin 侧调用 list.joinAll() 完全正常,@JvmName 只影响字节码层的名字,从而绕开擦除冲突。这类擦除问题与 Java 泛型的设计同源,想深挖可参考 Java 泛型与反射
中关于类型擦除与桥接方法的说明。
六、Sequence 惰性求值
Iterable 上的 map/filter 是立即求值的,每个操作都会创建一个中间集合;Sequence 是惰性的,元素逐个流过整条链。
fun main() {
val list = (1..1_000_000).toList()
// 立即求值:产生 2 个中间集合,每个元素被遍历多次
val eager = list.filter { it % 2 == 0 }.map { it * it }.take(3).toList()
// 惰性求值:无中间集合,找到 3 个即停止
val lazyResult = list.asSequence().filter { it % 2 == 0 }.map { it * it }.take(3).toList()
println("$eager / $lazyResult")
}
| 维度 | Iterable(立即) | Sequence(惰性) |
|---|---|---|
| 中间集合 | 每步创建一个 | 无 |
| 短路能力 | 无(先算完再 take) | 有(找到即停) |
| 元素遍历次数 | 每步一次全量 | 单元素走完整条链 |
| 适合数据量 | 小集合 | 大集合、无限序列 |
| 适合操作链 | 短链 | 长链且含 take/first |
| 调试难度 | 低 | 高(栈更深) |
关键结论:当链很长且带短路操作(take、first、find)时,Sequence 明显更快;当数据量小或链很短时,Sequence 的 lambda 间接调用反而比 Iterable 慢。 无限序列是 Sequence 的独占能力,例如 generateSequence(0L to 1L) { (a, b) -> b to a + b }.map { it.first }.take(10).toList()。
七、DSL 构建
7.1 buildString 与 apply
buildString 是标准库提供的典型 DSL,接收者是一个 StringBuilder。
fun buildQuery(params: Map<String, String>): String = buildString {
append("?")
params.entries.forEachIndexed { index, (k, v) ->
if (index > 0) append("&")
append(k).append("=").append(v)
}
}
自定义 DSL 的结构与它一致:一个接收者加一个带接收者的 lambda。
class FormBuilder {
private val fields = mutableListOf<String>()
fun text(name: String) { fields += name }
fun number(name: String) { fields += "$name#num" }
fun build(): List<String> = fields.toList()
}
fun form(block: FormBuilder.() -> Unit): List<String> = FormBuilder().apply(block).build()
// 调用:form { text("email"); number("age") } 得到 [email, age#num]
7.2 Compose Modifier 的链式 DSL
Jetpack Compose(androidx.compose.ui:ui 1.7.x,随 Kotlin 2.0 使用 Compose Compiler Gradle Plugin)里的 Modifier 就是高阶扩展函数链的极致应用:每个 Modifier 方法都返回新的 Modifier,形成不可变链。
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
// 自定义 Modifier 扩展:接收者与返回值都是 Modifier
fun Modifier.cardStyle(): Modifier = this.padding(16.dp)
@Composable
fun Title(text: String) {
Text(text = text, modifier = Modifier.padding(8.dp).cardStyle())
}
要点:
Modifier的每个方法都是扩展函数(不是成员),因此可以自由组合第三方扩展。- 链上每一步返回新实例,保持不可变,避免共享可变状态。
- 顺序有意义:
.padding().background()与.background().padding()渲染结果不同。
Compose 的 @Composable 本身也是编译器插件对函数类型做的额外处理,属于高阶函数的另一种演进形态。
八、常见坑清单
| 坑 | 现象 | 规避方式 |
|---|---|---|
| 期望扩展函数多态 | 子类扩展不生效 | 记住静态解析,需要多态就改成员函数或抽象方法 |
| 成员与扩展同名 | 扩展被静默忽略 | 检查类是否已有同名成员 |
| 扩展属性带初始化 | 编译错误 | 扩展属性只能有 getter/setter |
滥用 inline 大函数 | 包体积膨胀、方法数超 64K | 只 inline 小函数 |
在非 inline 函数里用 reified | 编译错误 | reified 必须配 inline |
| lambda 存进变量后 inline 失效 | 编译错误或警告 | 改用 noinline 或去掉 inline |
crossinline 缺失 | 编译错误 | lambda 被包进其他上下文时加 crossinline |
| 泛型扩展签名冲突 | platform declaration clash | 用 @JvmName / @get:JvmName |
小集合用 Sequence | 性能反而下降 | 小数据用 Iterable |
apply 里用 it | 编译错误 | apply/run/with 用 this |
| DSL 中链式顺序写错 | UI 布局不符预期 | 明确 padding 与 background 的顺序语义 |
| 非局部 return 意外生效 | 循环提前退出 | 用 crossinline 或在 lambda 上标注返回类型 |
非局部返回是 inline lambda 最容易踩的坑之一:nums.forEach { if (it < 0) return it } 里的 return 会直接退出外层函数,因为 forEach 是 inline 的;一旦把 forEach 换成非 inline 的 Sequence.forEach,同一行代码就会编译报错。团队里混用 Iterable 与 Sequence 时要格外注意这类不一致。
小结
扩展函数与高阶函数是 Kotlin 表达力的核心,但必须建立在准确的心智模型上:
- 扩展函数是静态的:它只是把接收者当第一个参数的静态函数,没有虚分派,成员函数永远优先,需要多态就回到成员函数。
- 高阶函数先想开销:
inline消除 lambda 分配,但只对小函数划算;reified依赖inline,noinline与crossinline是应对「lambda 需要逃逸」与「非局部返回」的两把钥匙。 - 作用域函数按返回值和接收者选:要接收者用
apply/also,要结果用let/run/with,用it还是this决定可读性。 - 序列化思维用 Sequence:长链加短路操作用
Sequence,小集合用Iterable,不要无脑asSequence()。 - DSL 是链式扩展的自然结果:
buildString、form { }、ComposeModifier都是同一模式的变体,理解「接收者 + 返回新实例」就能自己造 DSL。
扩展函数大量出现在可空链路与作用域选择里,可空接收者的处理方式与 Kotlin 语言基础与空安全 中的运算符层级是同一套规则;当这些高阶函数用于异步回调时,更推荐改写成挂起函数,参见 Kotlin 协程与结构化并发 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。