前面几节讲的是具体特性,这一节做一个横向的收拢:Kotlin 里那些"用了就专业"的惯用法。这一题的面试形态往往不是"讲讲 Kotlin 有什么惯用法",而是给一段代码问"这段可以怎么改",或者直接问"你在项目里怎么用 Kotlin 写得更 Kotlin"。答好这一题,靠的是知道每种惯用法解决什么问题和什么时候不该用——只罗列语法没用。
先把结论放在前面:值得内化的 Kotlin 惯用法可以分成五组,每组解决一类问题。①空安全流(?./?:/let/run/also/apply)——把 null 处理与副作用控制显式化;②集合流水线(map/filter/fold/buildList)——用声明式替代可变循环;③作用域函数的选择——let 管 null、run 管计算、apply 管配置、also 管副作用,with 只在对象自身范围内才用;④解构与数据类——data class + copy + componentN 让数据流转变轻;⑤契约式前置条件(require/check/error/run)——把校验放到正确的位置。
机制背后的执行路径
最值得讲清的是作用域函数为什么分四个。它们签名不同,语义就不同:
val name: String? = load()
name?.let {
nonNull -> use(nonNull) } // it 是非空值,适合"拿它算点东西"
name?.run {
length + 1 } // this 是接收者,适合"在它身上做一串操作"
name?.also {
log("name=$it") } // 传入值不变,适合"顺带做件事"
Foo().apply {
port = 8080 } // 接收者是配置对象,适合"配置一段"
Foo().run {
start() } // 接收者是执行者,适合"调用它的方法"
记忆方法:it vs this 是唯一区别。let/also 用 it(参数),run/apply 用 this(接收者)。再加上"返回值"这层——let/run/with 返回 lambda 结果,apply/also 返回被操作对象。四个函数两两组合,就覆盖了"返回值要谁"与"用 it 还是 this"这两个维度。
再看第二组里的 buildList 与 fold。fold 是在"没有现成容器"时构造集合的底层原语:
val grouped: Map<String, List<User>> = users.fold(mutableMapOf()) {
acc, u ->
acc.getOrPut(u.city) {
mutableListOf() }.add(u) // getOrPut 是唯一、天然的写法
acc
}
getOrPut 看起来平平无奇,但它是"不存在则创建并放入"的原子语义(get + put 合并成一次查找),没有它就得写 if (!map.containsKey(k)) map[k] = ArrayList()。
真实工程场景的推演
场景一:View 初始化的惯用法。用 apply 而不是 with:
// 不好:with 里的 receiver 是 binding,但返回值是 Unit
with(binding) {
rootView = layout; adapter = ... }
// 好:apply 返回 binding,可以链下去
val b = FragmentHomeBinding.inflate(inflater).apply {
rootView.setPadding(dp(16), 0, dp(16), 0)
adapter = HomeAdapter()
button.setOnClickListener {
navToDetail() }
}
这个差别在写 ViewBinding 初始化时代价很高:apply 保留了对象引用,链式调用与后续 b.rootView 都能用。
场景二:副作用与数据变换的分离。数据流式转换用 let/map,日志/校验这类副作用用 also:
val user = rawUser
?.takeIf {
it.id.isNotBlank() } // 过滤:数据变换
?.also {
log("parsed user: ${it.id}") } // 副作用:不动数据
?.map {
it.toDomain() } // 变换:换类型
一行里同时出现过滤、日志、映射,语义清晰且每一步的意图可读。
场景三:契约式前置条件。require 用于外部输入(参数、返回值),check 用于内部状态,两者都抛 IllegalStateException/IllegalArgumentException 且在 release 也生效;error() 用于逻辑分支穷尽。关键是位置:把校验放在"数据刚进入系统的第一站",而不是散落在使用点。
fun pay(amount: Cents) {
require(amount.value > 0) {
"金额需为正数,收到 ${amount.value}" } // 外部输入
val account = checkNotNull(current) {
"未登录" } // 内部状态
when (status) {
Paid, Unpaid -> unit
else -> error("非法状态 $status") // 分支穷尽
}
}
场景四:什么时候不该用惯用法。链式超过三步、包含复杂条件、需要在中间打断时,应改回显式循环或拆成函数——"为了链而链"会让调试断点变得难打、异常堆栈变得难读。这是这一题最该讲出来的一半。
最常见的坑是
为省行数把多逻辑塞进一行链,断点单步与异常定位都困难。 修法:一条链只做一类事(过滤 / 变换 / 副作用),超过三步就拆函数;需要打调试断点的地方优先用显式语句。
其次是作用域函数选错——用 with 做配置然后丢掉返回值、用 let 做配置(需要 it 但其实想用 this)。修法:按上面那两维度对号入座。
还有一个更隐蔽的坑:把 also 当成"万能日志函数"塞进业务主链,污染调用栈还可能引入循环依赖(log 内部反过来依赖该对象)。修法:日志/埋点抽成独立的 track(...) 扩展函数,不在业务链里散落。
现场手写这一段就够了
// 1) 作用域函数:按"返回值要谁"和"it 还是 this"选择
class Report(val id: String, val rows: List<String>) {
var total: Int = 0
fun render(): String = "$id:$total"
}
fun demo(raw: Report?) {
raw?.let {
r -> println("rows=${r.rows.size}") } // it,要 r 的结果
raw?.run {
println("id=$id rows=${rows.size}") } // this,要块内结果
raw?.also {
log("report seen: ${it.id}") } // 返回原对象(链上不变)
Report("x", emptyList()).apply {
total = 42 }.render() // 返回配置好的对象
// 严格区分:配置用 apply,执行用 run,观察用 also
val html = "<body/>".run {
uppercase() }.also {
audit(it) }.let {
"$it!" }
}
// 2) 集合流水线 + fold / buildList
fun summarize(users: List<User>): Report {
val byCity: Map<String, List<User>> = users.fold(mutableMapOf()) {
acc, u ->
acc.getOrPut(u.city) {
mutableListOf() }.add(u); acc
}
val summary = buildList {
// 不需要中间可变集合时
add("城市数=${byCity.size}")
add("总人数=${users.size}")
byCity.forEach {
(city, list) -> add("$city=${list.size}") }
}
return Report("summary", summary)
}
// 3) 解构 + data class + copy
data class Page(val items: List<Item>, val nextCursor: String?)
fun next(p: Page): Page {
val (items, cursor) = p // component1/component2
return p.copy(nextCursor = cursor?.takeIf {
it.isNotBlank() }) // 显式重命名
}
// 4) 契约式前置条件:位置决定可读性
fun parseConfig(raw: Map<String, String>): Config {
require(raw.isNotEmpty()) {
"配置为空" } // 外部输入
val host = requireNotNull(raw["host"]) {
"缺少 host 字段" } // 外部输入 + 具体信息
val port = raw["port"]?.toIntOrNull() ?: 80
require(port in 1..65535) {
"端口越界:$port" }
return Config(host, port)
}
// 5) 反例:链过长就该拆
// 不好:一行做完过滤+排序+两个副作用,出错难定位
val r = list.filter{
it.ok}.map{
it.n}.sorted().also{
logA(it)}.also{
logB(it)}.sum()
// 好:拆成意图明确的函数
val r = summarize(list.filter {
it.ok }).also {
logSummary(it) }
关键行解读:fold 配 getOrPut 是构成分组映射的标准写法;buildList 省掉中间集合;解构让 componentN 有名字;require/check/error 区分"外部输入 / 内部状态 / 分支穷尽";最后一段刻意展示"什么时候该停止使用惯用法"。
面试追问四连
"let/run/apply/also 有什么区别?" 答:let/also 用 it(参数),run/apply 用 this(接收者);let/run/with 返回 lambda 结果,apply/also 返回被操作对象。
"with 为什么用得少?" 答:它没有额外参数,this 指向接收者,容易造成"到底在操作谁"的歧义;只在对象自身范围内连续调用多个成员时才用它,配置场景用 apply 更清晰。
"Kotlin 里替代 apply 的还有?" 答:Object 表达式(object : Base() { ... })用于需要继承的场景,apply 用于不需要继承只做配置的场景;also 用于"保留原对象 + 附加副作用"。
"惯用法的代价是什么?" 答:可读性与调试体验——链式过长时无法在中间打断,异常堆栈被 lambda 包裹难以定位;过度抽象会让新人看不懂意图。判据是"团队里最不熟的人能否一眼读懂这行在做什么"。
落地建议
1. 团队 lint/评审规范:限定链式长度不超过三步;超出就拆函数或用显式语句。 2. View 初始化统一用 ViewBinding + apply,不用 with;封装成 inflateAndConfigure 之类的扩展函数,避免每处重复。 3. 契约式前置条件写进规范:require 校验外部输入、check 校验内部状态、error 穷尽分支,并在异常消息里带上具体值——这比"参数错误"三个字有用得多。 4. 自动化预防:开启 detekt(LongMethod、ComplexCondition、NestedBlockDepth)与 ktlint,把"过长链式"与"过深嵌套"变成 CI 可检项,而不是靠人记。
给正在准备面试的你 把这题画成一张"作用域函数选择器"。中间画一个小菱形"需要结果还是原对象",向右分两支:上支"要结果"再分"用 it(let)"与"用 this(run)",下支"要原对象"再分"用 it(also)"与"用 this(apply)"。四个格子各写一行典型用法。图旁再贴一条纪律:"链式超三步就拆",并画一条红虚线。
再补一个能体现判断力的点:惯用法与团队可读性的权衡。一份规范里最重要的不是"用哪个函数",而是"统一用哪个"。同一个项目里 apply 与 with 混用做配置,读者每次都要重新判断;全项目统一 apply 做配置、also 做日志,读者一眼就知道发生了什么。答"统一性比选最优更重要",通常就是这题的高分点。
再补一个高频但少有人主动讲的惯用法:解构在遍历与 Map 处理里的用法。map.forEach { (key, value) -> ... } 让 Map 处理不再出现 it.key/it.value;for ((i, name) in list.withIndex()) 让位置与值同时可见。这类写法的收益不只是短,而是减少了 it 的歧义——嵌套 lambda 里 it 指的是哪一层,往往是 bug 源头,解构加命名能彻底避开。
还有两个提升表达力的点。一是 when 代替长 if-else 链,配合 is 与解构能让分支条件读起来像一句话:
val label = when (val s = state) {
is UiState.Loading -> "加载中"
is UiState.Error -> "失败:${s.reason}"
else -> "其他"
}
二是 takeIf/takeUnless 做自我过滤,比 if (x != null) x else null 更短且意图明确:
val name = raw.name.takeIf {
it.isNotBlank() }?.trim()
val visible = item.takeUnless {
it.isHidden }
这两条在 code review 里比"用不用惯用法"更能体现实际代码品味——意图可读性优先于行数。
再补一个工程上很实用的组合技:let + also + run 的三段式,把"取值、观察、计算"在一条链里分清:
val summary = report
.takeIf {
it.rows.isNotEmpty() } // 过滤
?.also {
audit("report seen") } // 副作用
?.let {
it.rows.sumOf(Row::value) } // 变换
?: 0 // 兜底
三段职责清晰,调试时每一段都能单独断点,也避免了"一行做完四件事"的黑盒感。
复习时别孤立刷题:DSL 构建原理——上一节讲 DSL 里全是惯用法的组合应用,本节把构成 DSL 的基本单元摊开来讲。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:DSL-构建原理:type-safe-builder-如何工作
下一篇预告:Kotlin-编译期陷阱:Companion、默认参数与混淆
有任何问题欢迎在评论区留言交流。