?: 与 let 是 Kotlin 里被低估的两个语法。它们的共同点是——读起来简单,但背后藏着一个可以改变代码组织方式的抽象:把"非空才执行"和"为空则兜底"这两件事,从散落在方法体里的控制流,变成可以组合的表达式。这题答好,要能讲清它们各自的求值时机、几类作用域函数的差异,以及什么时候不该用。
先把结论放在前面:?:(Elvis 运算符)在左值为 null 时返回右值,右值是懒求值的;let 是作用域函数,在接收者非空时把值作为参数(默认 it,也可具名)传入并返回 lambda 结果。两者常配合使用:x?.let { ... } ?: default,完成"有值则处理、无值则兜底"的完整语义。 核心纪律是:Elvis 是表达式,适合做兜底与提前返回;let 是作用域工具,适合在非空范围内消除重复判空。
机制拆解
先说 Elvis。它之所以能替代 if (x == null) x = d,关键在于它是表达式而非语句:整个 a ?: b 有类型、有值,可以赋值、可以作为参数传递、可以作为 return 值。求值时机是它最容易被问也最有价值的一点——右值只在左值为 null 时才求值。 所以 x ?: throw IllegalStateException() 里的抛异常只会在缺失时触发;x ?: loadFromCache() 里的缓存查询也只在缺失时才发生。这个性质让 Elvis 可以放心用来做昂贵兜底。
再说 let。它是一个扩展函数:inline fun <T, R> T.let(block: (T) -> R): R = block(this)。因为是内联函数,lambda 里的代码会被直接展开到调用点,没有闭包对象分配、没有 lambda 类生成,运行时开销基本为零。 x?.let { } 的执行路径是:先判 x 是否为 null,为 null 就整体返回 null;不为 null 就把 x 作为参数调用 block。
这一节值得展开的是 Kotlin 的几个作用域函数(receiver-oriented vs parameter-oriented)之间的区别,这是"胜读十年书"的真正来源:
let:参数式,把值作为参数传进去(it),适合"拿到值做点什么"。run:接收者式,把值作为接收者(this),且 lambda 带Receiver可空签名,作用是"在值的上下文里连续写几行"。also:参数式,返回原对象,适合"对对象做副作用操作后仍返回它"(链式中间步)。apply:接收者式,返回原对象,适合"配置式构造"。with:接收者式,不是扩展而是普通函数,适合对已有对象分组调用一堆操作。
选哪个,取决于"要的是值、是要配置、还是要副作用后返回原对象"。
这些坑的正确绕法
最常见的坑是 ?: 后面接抛异常却写成了赋值语句,表达式与语句混用导致分支不生效。 典型错误是 value ?: value = defaultValue——这里 value = defaultValue 是赋值表达式,语法上勉强能过但语义完全不是"兜底",最终结果不可预期;更常见的是本该 ?: return 的地方写成了 ?: null。 规矩是:?: 的右值必须是表达式——常量、throw、return、函数调用都可以,赋值语句不行;return 在 Kotlin 里是表达式,所以 val n = x ?: return 这种 early return 写法是标准且推荐的。
其次是用 let 嵌套替代了"提前返回",导致代码缩进失控。 表现是三四个 ?.let { } 串起来,中间的业务逻辑被切成碎片,变量作用域难以追踪。对策:超过两层 ?.let 就该改用普通控制流。return@let 可以在 lambda 里提前返回,但用多了同样说明结构该重写。
还有一个更隐蔽的坑:把 ?: 用于"业务上的合法空态",把有意义的状态压成了默认值。 表现是接口返回 status: Int?,用 ?: 0 兜底,结果 0 在业务里其实是"未支付"而不是"未知",于是错误分支走进了正常逻辑。 Elvis 适合处理"缺失就用默认值",但当 null 本身携带信息(未加载 / 加载失败 / 空列表)时,应该用 sealed class 或 Result 显式表达,而不是压成一个魔法值。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 1) Elvis:右值懒求值,可以放昂贵兜底或抛异常
val port = prefs.getInt(KEY, -1).takeIf {
it > 0 } ?: 8080 // 缺失才用默认
val user = repo.current() ?: throw IllegalStateException("未登录") // 缺失就失败
val name = user?.name ?: "未命名"
// 2) ?: + return 做 early return(return 在 Kotlin 里是表达式)
fun submit(): Boolean {
val form = currentForm ?: return false // 没有表单直接返回
if (!form.valid()) return false
viewModel.submit(form)
return true
}
// 3) let:it 是非空值,把值作为参数传进去
val summary = user?.let {
"${it.name}(${it.age})" } ?: "未知用户"
user?.let {
u -> viewModel.select(u.id) } // 具名参数,比 it 更清楚
// 4) 一套作用域函数:按意图选择
val s1 = user?.let {
it.name } // 要值 → let
val s2 = user?.run {
"${name}-${age}" } // 连续写几行 → run
val s3 = user?.also {
cache.put(it.id, it) }?.name // 记副作用再用 → also
val s4 = Builder().apply {
timeout = 5; retries = 3 } // 配置对象 → apply
val s5 = with(report) {
title; total } // 已有对象分组调用 → with
// 5) 完整组合:有值则处理,无值则兜底
val text = repo.findName(id)?.trim()?.takeIf {
it.isNotEmpty() } ?: "未找到"
// 6) 状态要显式:不要把"未加载"压成魔法值
sealed interface LoadState {
data object Loading : LoadState
data class Content(val items: List<Item>) : LoadState
data class Failed(val msg: String) : LoadState
}
这段代码值得盯三处:第一处,?: throw 与 ?: return 展示 Elvis 作为表达式的两大用途;第二处,五种作用域函数按意图并列,能直接背下来用,是这题最实用的收获;第三处,sealed interface LoadState 是对"用 ?: 0 压掉状态"的纠偏——缺失要兜底,未知要显式表达,这是同一条工程原则的两面。
这题在面试里怎么问、怎么答
"?: 的右值什么时候求值?为什么这很重要?"答:只在左值为 null 时求值。重要性在于它让昂贵操作、异常抛出、提前返回都能安全放进右值而不影响正常路径的性能。若换成 if 写法,可能需要在求值前后多写判断;Elvis 的表达性让这些写法自然起来。
"let 有什么性能开销?"答:let 是 inline 函数,lambda 会被内联展开,因此没有额外的闭包对象分配与虚调用(前提是不跨 noinline/crossinline 边界,也不捕获需要逃逸的变量)。真正需要注意的是:如果在 let 里创建了对象,那个对象照样会分配;内联优化的是 lambda 本身。
"什么场景下不用 let?"给判据:①需要多个可空值联合判断时,用 let 会形成嵌套;②需要在多个分支里重复使用同一个可空值,局部变量更清楚;③业务上 null 有含义时,let 会把状态压平。给一句总结:let 用来"在非空范围内减少判空",不是用来"消灭判空语义"。
"使用中遇到过什么问题?"案例一:把 status: Int? 用 ?: 0 兜底,0 在业务里是"未支付",导致未加载状态走了支付成功分支;定位到用魔法值代替了状态建模;修复为改用 sealed class 显式表达四种状态。案例二:深层 ?.let 嵌套导致变量作用域丢失,改写成 early return 后可读性明显提升。
再补一个工程上值得讲清的点:Elvis 与 let 在 Android 里最实用的场景是状态收敛与兜底链路。 具体做法:在 Activity/Fragment 里用 viewModel.stateFlow.collect { state -> when (state) { ... } } 做显式分支,避免"多处 Elvis 兜底"导致状态不可追溯;把 ?: 收敛到少数几个"数据入口的默认值"函数里,让兜底策略集中可见。 兜底值是产品决策,不是随手写的常量——把它写在一个具名常量或配置里,比散在 20 个调用点更容易维护。
给正在准备面试的你
把 Kotlin 作用域函数画成一张"意图 → 工具"的四象限图:横轴是要旧值还是新值(返回原对象 / 返回 lambda 结果),纵轴是把值放哪(作为参数 it / 作为接收者 this)。 四格分别填 also(参数 + 原对象)、let(参数 + 新值)、apply(接收者 + 原对象)、run(接收者 + 新值),再在图外单列 with(不是扩展,是普通函数)并标注用途。图下方贴一行口诀:"要值用 let,要配置用 apply,要副作用后返回用 also,要连续写用 run。"这张图加上 ?: 的求值时机,是这一整串追问的答案骨架。
再补工程案例与踩坑——应用落点是把项目里散落的 Elvis 兜底收敛到具名常量或入口映射函数,把 ?: 压掉的业务状态改成 sealed class 显式建模,把三层以上的 ?.let 重写为 early return 结构。
复习时别孤立刷题:空安全入门——?: 是空安全的兜底运算符,let 是作用域函数,两者都是在前者基础上的一步。
划两句重点:?: 右值懒求值所以能放昂贵兜底与 throw/return;let 内联无额外开销、it 非空;五种作用域函数按"要旧值还是新值 + 参数还是接收者"选择;缺失用 Elvis 兜底、未知状态用 sealed class 显式表达。
下一篇聊 lateinit 与 by lazy:延迟初始化的适用边界——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:空安全入门:?、!! 与 ?. 的三分天下
下一篇预告:lateinit-与-by-lazy:延迟初始化的适用边界
有任何问题欢迎在评论区留言交流。