6.2 作用域函数六件套


6.2 作用域函数六件套

本节摘要:let、run、apply、also、with、takeIf 是标准库以扩展函数实现的六个作用域工具:在一个表达式里对对象"做一串事",同时控制返回什么。它们消灭的是对象初始化与链式处理中的临时变量噪音。本节给全六者的语义矩阵、每个的典型场景、组合套路与过度使用的反模式——作用域函数是全 Kotlin 最容易被滥用的一组语法糖。

一个初始化场景的四种写法

设置一个 TextView 的属性,最朴素的写法:

val tv = binding.tvTitle tv.text = "订单列表" tv.textSize = 16f tv.setTextColor(0xFF333333.toInt()) tv.isVisible = true

四次 tv 重复。Java 时代无解(Builder 模式是另一条路,代价是专用类),Kotlin 用 apply 一行收拢:

binding.tvTitle.apply { text = "订单列表" textSize = 16f setTextColor(0xFF333333.toInt()) isVisible = true }

块内 this 就是 tvTitle,属性直接写。这不是"炫技",是把"这一串操作只关于这个对象"的语义写进结构——块的花括号就是作用域边界,读者一眼看出这四行不碰别的。作用域函数全部服务于此:对象、范围、返回值,三件事一个表达式说清

六件套语义矩阵

六个函数的差异只有两个维度:块内用什么指代对象(this 还是 it)、返回什么(对象本身还是块的结果)。列成矩阵:

函数 块内指代 返回 语义口吻 典型场景
apply this 对象本身 "就这么配置它" 对象初始化、Builder 收尾
also it 对象本身 "顺带做点别的" 链中插日志、校验、埋点
let it 块结果 "把它变成别的" 可空消解、映射转换
run this 块结果 "用它算个结果" 一次性计算、紧凑作用域
with this 块结果 "拿它来算"(非扩展) 同 run,老写法
takeIf it(谓词) 对象或 null "符合才要" 条件过滤、卫语句

前四个是扩展函数,with 是普通函数(参数在前),takeIf 特殊——它是布尔谓词的守门员。记忆锚点:返回自己的是 apply 和 also(a 开头,"配置归位");返回结果的是 let、run、with;this 指代的适合直接访问成员(apply、run、with),it 指代的适合当值传递(also、let、takeIf)

逐个的当家场景

apply:对象初始化的终点站。安卓最高频用法,从 Intent 到 Paint 到 Request:

val intent = Intent(context, OrderDetailActivity::class.java).apply { putExtra("id", order.id) putExtra("from", "push") } context.startActivity(intent) val paint = Paint().apply { color = Color.WHITE style = Paint.Style.FILL isAntiAlias = true }

also:链路里顺带插一手。返回对象本身,适合在链式调用中间插入与变换无关的动作:

val orders = repo.loadOrders() .also { log("订单加载完成 共 ${it.size} 条") } // 顺带记日志 .filter { it.paid } .also { tracker.count("paid", it.size) } // 顺带埋点

也适合"赋值同时留痕":val token = fetch().also { prefs.edit { putString("t", it) } }

let:可空消解的老家(第 2 章已见)与映射:

session.token()?.let { t -> api.auth(t) } // 非空才执行 val name = user?.let { it.profile.name } ?: "匿名" // 可空链的收口

run:用它算个东西。当计算只需要一个紧凑作用域、不想要临时变量泄漏:

val speed = speedMeter.run { reset(); sample(); average() } // 三个成员调用打包 一个结果

with:run 的非扩展前辈,新代码统一用 run(链式位置更自然),with 只在读旧代码时认识即可。

takeIf:条件守门员。谓词真返回对象、假返回 null,与 ?: 组合成卫语句:

val order = repo.find(id)?.takeIf { it.paid } ?: return showUnpaid() // 找到且已支付才继续 否则提前退出

一句话版:apply 配置、also 顺带、let 变换、run 计算、takeIf 把条件变成可空

六件套选择树

六件套选择树

组合套路

真实代码里六者常两两组合,两个高频套路值得形成肌肉记忆。

初始化加校验:apply 配置完,takeIf 验收:

val req = UploadRequest(file).apply { setMaxSize(20 * 1024 * 1024) setCompress(true) }.takeIf { it.file.exists() } ?: return toast("文件不存在")

可空加配置加链:let 接 apply:

binding.tvBadge?.apply { text = "${cart.size}" isVisible = cart.isNotEmpty() } // tvBadge 可空时整块跳过 视图不存在也不崩

第 2 章的可空消解与本章的作用域函数在这里合流:?.apply 同时完成"判空、作用域、成员直取"三件事,是 viewBinding 时代的高频起手式。

过度使用:糖吃多了会腻

作用域函数是全 Kotlin 滥用重灾区,三条红线。嵌套不超两层:apply 里再 let 再 run,this 与 it 交替出现,读者必须在脑内维护一张指代表——超过两层就提取局部变量或函数,可读性立刻回来。块体不超十行:apply 块塞三十行初始化加三行业务逻辑,"这个块只关于这个对象"的语义就破了;业务逻辑该在函数里。别用作用域函数制造谜语x.let { it.a }.let { it.b(it) } 这种纯为链而链的写法,比普通调用难读得多。判断标准始终如一:读者能否一眼说出这个块的指代对象与返回物——说不出,就是滥用。

一个真实对比。有人把第 8 章 ViewModel 的 retry 写成这样:

fun retry() = viewModelScope.launch { runCatching { repo.refresh() }.let { r -> r.fold({ s -> _state.let { it.value = UiState.Data(s) } }, { e -> _state.let { it.value = UiState.Error(e.message ?: "") } }) } }

技术上没错,可读性灾难。等价改写:

fun retry() { viewModelScope.launch { val result = runSuspendCatching { repo.refresh() } _state.value = result.fold( onSuccess = { UiState.Data(it) }, onFailure = { UiState.Error(it.message ?: "刷新失败") } ) } }

临时变量 result 不是落后,是把数据流命名——作用域函数的收益是消除噪音,不是消灭所有中间名。当一个名字能替读者省一次脑内推导,留着它。

标准库源码里的身影与一个选型练习

六个作用域函数在标准库与 Jetpack 里的高频出场,能帮你把它们彻底内化。also 是日志与埋点的专业户measureTimeMillis { }.also { log("耗时 $it") }apply 是构建器的标准收尾:标准库的 StringBuilder 场景、安卓的 Paint、Request、Intent 初始化;let 是可空管线的接头:第 2 章的 ?.let 组合是全 Kotlin 出现频率最高的双人舞;run 常见于"借个作用域算个值":配置读取、一次性求值;takeIf 与 takeUnless 是卫语句的发生器:配合 Elvis 把"条件不满足就退出"压成一行。看源码时会发现标准库自己也在用它们——inline fun <T, R> T.run(block: T.() -> R) 的签名本身就是一个扩展加接收者 lambda。

做一个选型练习收尾。需求:读取缓存的用户偏好,存在则打日志并返回,不存在返回默认值。逐步选型——"存在则……否则……"是条件与卫语句,takeIf 或 let 接 Elvis;"打日志"是顺带动作,also 或直接在 let 里;最终三个版本:

// 版本一 let 收口 可空语义最直白 val v1 = prefs.getString("theme")?.let { log("命中缓存 $it"); it } ?: "light" // 版本二 also 顺带 相同结构 意图更分明 val v2 = prefs.getString("theme")?.also { log("命中缓存 $it") } ?: "light" // 版本三 takeIf 卫语句 语义略歪 不推荐 val v3 = (prefs.getString("theme") ?: "light").takeIf { it != "light" }?.also { log("命中") } ?: "light"

版本二最清晰:also 表达"取到值、顺带记一笔",Elvis 兜底。版本三的 takeIf 语义不匹配("不等于默认值才算命中"是错误推断)。这个练习的要点不是背答案,是每个函数的"口吻"要与业务句式对齐——口吻对了,代码读起来像句子;口吻错了,读起来像谜语。

常见问题

问:apply 与 also 都返回对象本身,到底差在哪?
块内的指代与"适合写什么"。apply 块内是 this,直接写成员名(text = "标题"),适合配置对象;also 块内是 it,对象以值出现(log("已创建 $it")),适合旁路动作。一个味道上的判别:如果块里的语句"看起来像这个对象自己的方法调用",用 apply;如果"把对象当参数传给别人",用 also。写错不影响行为,但影响读者的预期——预期错位就是可读性损耗。

问:嵌套作用域函数时 this 与 it 混淆,有什么系统性解法?
三层防线。第一层,显式命名:lambda 参数写 { value -> ... },不用 it。第二层,同层只用一种指代:外层 this(apply)内层就命名 it,或反过来——嵌套的两层用不同指代方式,读者的解析负担最小。第三层,拆函数:嵌套超过两层(本节红线)时,内层提取成带名字的函数——名字是最强的指代消歧器。绝大多数"看不懂的作用域函数链",拆一个函数就痊愈。

本节要点回顾

  • 两个维度定六者:块内 this 还是 it、返回对象还是结果,矩阵一列即全貌;
  • apply 初始化、also 顺带、let 变换消解、run 计算、takeIf 守门,with 认识即可;
  • 可空加 apply 是 viewBinding 起手式:判空、作用域、成员直取一次完成;
  • takeIf 加 Elvis 组成卫语句:条件变成可空,接第 2 章的消解管线;
  • 三条红线:嵌套两层、块十行、读者说不出指代与返回即滥用;
  • 中间名不是敌人:命名一次数据流,胜过为链而链的谜语。

六件套让表达式层面的代码收放自如,下一节把同样的机制(扩展加带接收者的 lambda)堆成嵌套结构——DSL,从 Gradle 配置到手写小 DSL。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U