Kotlin 扩展函数与高阶函数

扩展函数与高阶函数是 Kotlin 最常用也最容易误用的两块能力,写错时既拖慢性能又破坏可读性。本文讲清扩展的静态解析本质、成员优先规则,以及 let、run、with、apply、also 五个作用域函数的差异与选择依据,并覆盖内联函数、具体化类型参数、非内联与交叉内联、惰性序列求值和领域特定语言构建。读完可写出既高效又可读的链式接口,并避开内联与类型擦除的常见陷阱。

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,返回接收者
函数接收者引用返回值典型用途
letitlambda 结果可空对象链、局部作用域改名
runthislambda 结果对象配置并计算结果
withthislambda 结果对同一对象多次调用(非扩展)
applythis接收者本身对象初始化、属性批量赋值
alsoit接收者本身副作用:日志、埋点、断言

记忆口诀:返回接收者的两个是 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,支持非局部返回绝大多数场景
noinlinelambda 保留为函数对象需要把 lambda 存起来或传给普通函数
crossinlinelambda 被 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 表达力的核心,但必须建立在准确的心智模型上:

  1. 扩展函数是静态的:它只是把接收者当第一个参数的静态函数,没有虚分派,成员函数永远优先,需要多态就回到成员函数。
  2. 高阶函数先想开销:inline 消除 lambda 分配,但只对小函数划算;reified 依赖 inline,noinline 与 crossinline 是应对「lambda 需要逃逸」与「非局部返回」的两把钥匙。
  3. 作用域函数按返回值和接收者选:要接收者用 apply/also,要结果用 let/run/with,用 it 还是 this 决定可读性。
  4. 序列化思维用 Sequence:长链加短路操作用 Sequence,小集合用 Iterable,不要无脑 asSequence()。
  5. DSL 是链式扩展的自然结果:buildString、form { }、Compose Modifier 都是同一模式的变体,理解「接收者 + 返回新实例」就能自己造 DSL。

扩展函数大量出现在可空链路与作用域选择里,可空接收者的处理方式与 Kotlin 语言基础与空安全 中的运算符层级是同一套规则;当这些高阶函数用于异步回调时,更推荐改写成挂起函数,参见 Kotlin 协程与结构化并发 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试