ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Kotlin also函数实战指南:从作用域函数到链式调用

Kotlin also函数实战指南:从作用域函数到链式调用 写代码这么多年我发现在Kotlin的作用域函数里also是最容易被低估的一个。很多人天天写let和apply碰到also就只会用来打印日志其实这兄弟远不止这点本事。先说个我自己的经历。有次Code Review同事用let做对象初始化写完我还得琢磨他到底想返回什么。后来我看了下他最初的意图其实只是想对对象做点额外操作根本不需要改变返回值——这种场景also才是标准答案。代码本身没什么错但表达力差了一截。Kotlin提供了五个作用域函数let、run、with、apply、also各有各的脾气用对了代码能瘦一圈用错了就是给自己和别人挖坑。这篇文章我不打算讲API文档上那些枯燥的定义而是从实际项目里摸爬滚打出来的经验出发把also的底细、适用场景、和let/apply的区别一次说透再用几个能直接抄作业的实战案例把用法串起来。最后聊点面试里高频踩坑点。不管你是刚开始学Kotlin还是写了几年想抠细节这篇都能给你点实在的东西。1. 先把also的底细摸清楚1.1 签名与返回值的本质先看also的标准库定义public inline fun T T.also(block: (T) - Unit): T关键信息拆开看它是T的扩展函数谁调用都行任何类型都有这个能力参数是一个lambdalambda接收一个T类型参数在lambda里边你用it来引用当前对象返回值是T本身也就是调用者原封不动地返回。这是什么概念就是无论你在also的lambda里做什么操作它都不影响最终返回的结果。返回值永远是最初那个对象。对比一下就清楚了val user User(张三, 18).also { it.age 19 println(修正年龄${it.age}) } // user.age 是 19打印发生在赋值之后因为also的lambda里对it做修改是直接操作同一个对象引用。返回的it和外部接收的user是同一个东西所以修改会被保留下来但返回值的类型和语义始终没变。1.2 和let、apply的差异性Kotlin里五个作用域函数各司其职最容易搞混的就是also和let、apply。let返回值是lambda的最后一行。apply返回的是调用者本身但lambda接收者是this你不需要用it引用对象。also返回调用者本身lambda参数是it。这几个差异看着小实际语义完全不同。let的核心是转换lambda里算出新值然后返回val nameLength user.let { it.name.length } // 返回的是 Int 而不是 Userapply的核心是配置lambda里以this直接访问对象的属性或方法常用来做初始化val user User().apply { name 张三 // 不需要 it. age 18 }also的核心是副作用lambda里用it显式引用对象做的操作和修改配置无关更像是在旁边插一脚val user User(张三, 18).also { println(创建用户: $it) }一句话总结使用直觉你想改对象属性并继续用这个对象用apply你想对对象做点额外动作日志、校验、计数但不想动返回链用also你想把对象转成另一个东西用let。2. 实战场景also真正发光的地方2.1 链式调用里的观察哨also最高频的使用场景就是在链式调用中间插入观察动作。Java时代调试代码通常要多写一行或者好几行。Kotlin里你在任何链式调用的中间节点用also插入一段逻辑啥都不影响fun getUsers(): ListUser { return userRepository.getUsers() .also { log.d(TAG, 从数据库读取用户列表: ${it.size} 条) } .filter { it.isActive } }这里如果只关心执行过程中的数据快照also在filter之前打日志能清楚看到原始数据量filter之后再看过滤后有多少。两个都不影响最终返回。你做网络请求时也特别香return apiService.fetchUsers() .also { response - log.d(TAG, HTTP 状态码: ${response.code()}) log.d(TAG, 响应体大小: ${response.body()?.byteString()?.size} bytes) } .body() ?.users .orEmpty()调试完直接把also这块删了业务链一行都不用动。这种临时加塞能力配合IDE的行号断点调试效率直接翻倍。2.2 初始化操作里的附加动作also经常在对象初始化链上做文章尤其是需要根据对象的当前状态决定要不要做额外设置时。举个Android里常见的例子RecyclerView的Adapterval adapter UserAdapter().also { it.setHasStableIds(true) it.setOnItemClickListener { _, position - navigateToDetail(position) } } recyclerView.adapter adapter你本来只需要创建Adapter然后把它赋值给RecyclerView但中间还要注册监听器、设置flag。这些操作用apply也行val adapter UserAdapter().apply { setHasStableIds(true) setOnItemClickListener { _, position - navigateToDetail(position) } }两种写法都能跑。但语义上有个微妙区别apply强调当前对象本身的配置also强调围绕对象的额外操作。如果你注册的监听器是Adapter类本身的方法apply更习惯一些如果监听器是外部传入的某种回调管理器的注册方法那also更贴切。比如这样val adapter UserAdapter().also { eventBus.register(it) // 外部事件总线的注册 analytics.track(adapter_created) }eventBus不是Adapter的成员analytics也不是这些操作更像是把这个对象注册到别的地方”用also表达更准确。2.3 Builder模式中的原地校验Java的Builder模式在Kotlin里可以用also做得更简洁。传统写法val dialog AlertDialog.Builder(context) .setTitle(提示) .setMessage(确定删除吗) .setPositiveButton(确定) { _, _ - doDelete() } .setNegativeButton(取消, null) .create()加校验就麻烦了。这时候also能帮上忙val dialog AlertDialog.Builder(context) .setTitle(提示) .setMessage(确定删除吗) .setPositiveButton(确定) { _, _ - doDelete() } .setNegativeButton(取消, null) .create() .also { check(it.window ! null) { Dialog window 不能为空 } it.setCanceledOnTouchOutside(false) }create()返回的AlertDialog在also里做前置条件检查不通过直接抛异常通过就顺手设置属性。外部拿到的仍然是正常的AlertDialog实例。这种创建完原地校验不合格马上报错的节奏非常利于快速失败原则。尤其在配置类对象的构建中把校验逻辑和内聚在一起调用方拿到的对象必然是合法的。2.4 集合操作的中间过程留痕处理集合数据时很多人习惯把一大串操作链写在一起一旦结果不对很难定位是哪一步出了问题。用also做中间过程留痕就舒服多了val result listOf(1, 2, 3, 4, 5, 6) .filter { it % 2 0 } .also { println(过滤后的偶数: $it) } // [2, 4, 6] .map { it * it } .also { println(平方后的结果: $it) } // [4, 16, 36] .sum()这里的两个also是在操作链中间插入的临时检查点。数据量一大、逻辑一复杂这种逐段观察的能力比打断点还直接。有时你还会遇到需要把集合的快照保存下来的需求比如记录处理前的原始状态val originalSnapshot mutableListOfUser() val activeUsers userList .also { originalSnapshot.addAll(it) } .filter { it.isActive }also的lambda里可以直接addAll(it)一次性把当前集合的快照存下来后面无论怎么变换快照都留在那儿了。3.also和let的取舍别再无脑用了3.1 核心区别返回语义很多Kotlin面试题里都会有一道类似的题also和let有什么区别标准答案是返回值不同但这只答对了一半。更关键的是使用场景的不一致。let适合做null安全 转换val length userName?.let { it.length } ?: 0这里let把String?安全地解包成非空String然后转换成Int。这个语义是变换。also适合做null安全 副作用userName?.also { log.d(TAG, 用户名非空: $it) }这里只是观察值不产生新值。这个语义是消费。很多人在需要打印日志的时候下意识写letuserName?.let { log.d(TAG, 用户名: $it) }这没问题但语义不精确。let的名字给人的暗示是让我处理一下然后返回新值结果你只是打了行日志。虽然编译器不管这么多但代码阅读者会疑惑这个let最后返回什么没人关心返回值的话为什么不写also3.2 语义传递的代码即文档价值写代码给同事看语义清晰比技巧花哨重要得多。看到apply读者知道这是一个对象在使用自己的属性做配置。看到also读者知道这个对象只是一个过客操作完还得继续走流程。看到let读者知道后面马上要用一个新的值了。这种意图自解释的能力就是一个团队代码可持续维护的底层保障。举个例子你去看一个封装良好的网络库的内部实现also出现的频率通常很高因为网络响应处理过程中拦截器要记录日志、统计时长、打点上报这些操作不该影响主流程而also恰好闭着眼睛就能表达这个意思。如果你在写公共库或者框架代码用also来保留扩展点别人二次开发时也能更容易理解你的设计意图。3.3 嵌套作用域的可读性对比假设这样一个场景一个对象创建成功之后你要把它存到仓库还要发一个事件还要做埋点。用apply写val user createUser().apply { repository.save(this) eventBus.post(UserCreatedEvent(this)) analytics.logEvent(user_created, mapOf(id to id)) }好像也通。但仔细琢磨一下repository、eventBus、analytics都不是User的成员把它们写在apply内部感觉就像是User自己在做这些事其实User只是个被动的数据对象。换alsoval user createUser().also { repository.save(it) eventBus.post(UserCreatedEvent(it)) analytics.logEvent(user_created, mapOf(id to it.id)) }it这个显式引用把外部在处理这个对象的意味表达得很清楚。User还是那个纯粹的User外部代码在围绕它做各种操作。这种差别的本质是apply的lambda接收者是this隐式调用容易让读者产生对象自己在动的错觉also的lambda参数是it所有的操作都必须显式经过it读者始终清楚是外在代码在处理这个对象。4. 高级玩法also的组合技巧4.1 和run配合的初始化流程run的特点是lambda内部是this上下文可以方便地执行一段独立逻辑并返回结果。把run和also组合能写出既清晰又紧凑的代码。比如创建一笔订单做完初始化后要检查金额合法性val order Order().run { amount calculateAmount() userId currentUserId status OrderStatus.PENDING this // 返回配置完成的对象 }.also { check(it.amount 0) { 订单金额必须大于0 } check(it.userId.isNotBlank()) { 用户ID不能为空 } }run负责构建和配置also负责校验和确认职责划分得明明白白。有人可能会说校验放run里面不行吗也可以但run里面已经有一堆赋值逻辑了再塞几个check就会把构建和校验两件事混在一起。also独立在外面让校验动作成为流水线上的一道工序读代码的时候一眼就能抓住构建完之后要验证什么。4.2 作用域链中的条件性副作用有时候你希望只在满足某个条件时才执行副作用此时also和标准库的takeIf能组合出很优雅的写法。fun processOrder(order: Order) { order .takeIf { it.isValid() } ?.also { // 只有合法订单才会走到这里 log.d(TAG, 处理合法订单: ${it.id}) analytics.trackOrder(it) } ?.let { paymentService.charge(it) } }takeIf负责过滤also负责在有值时发通知、打日志let负责真正的业务转换。三者各司其职。这里有个细节值得注意takeIf返回的是可空类型所以后面要用安全调用?.。also的返回类型是T?吗不是also本身是T的扩展函数作用在T?上时编译器会自动处理空安全上下文。不过为了清晰通常先?.再also可读性更好。4.3 与协程结合Flow中的观察点Kotlin协程的Flow也有类似的中间操作但also在Flow里的使用方法和集合不太一样。Flow的onEach更像alsomap更像let但很多人在Flow里也会使用also做临时观察点fun observeUserUpdates(): FlowUser { return userRepository.observeUserUpdates() .also { println(Flow 已经创建还没开始收集) } .map { it.copy(lastSeen System.currentTimeMillis()) } }这里also的执行时机不在数据流中间而是在Flow创建链上。它只会在构建Flow链时执行一次而不是每个元素都执行。如果要对每个元素做观察用onEach或者mapfun observeUserUpdates(): FlowUser { return userRepository.observeUserUpdates() .onEach { user - println(收到用户: ${user.name}) } .map { it.copy(lastSeen System.currentTimeMillis()) } }这个区别经常被误用。面试中如果聊到Flow你可以主动提一下also和onEach的执行语义差异面试官会眼前一亮。4.4 与with的嵌入式观察with本身不是扩展函数而是把接收者对象作为第一个参数传入然后在lambda内以this引用它。它和also组合可以做成一种临时调试窗口的形式val user User(李四, 25) with(user) { println(名字: $name) // 直接访问属性 println(年龄: $age) } user.also { println(名字: ${it.name}) // 显式引用对象 println(年龄: ${it.age}) }with更像是围绕这个对象开一个上下文窗口在这个窗口里面直接操作属性also则是站在这条流水线旁边用it指指点点。如果你要在一段代码块里频繁访问同一对象的多个属性with比also少写不少it.前缀。但with不能像also一样随意插到链式调用中间它更像一个独立的代码块。5. 容易踩的坑和排查技巧5.1 命名遮蔽导致的it混淆also的lambda里默认参数名是it一旦嵌套内层it会遮蔽外层it很容易出错。比如这样写userList.forEach { user - user.also { // 这里的 it 是 user没问题 println(it) // 但如果还想访问外层的 userList 呢 // 你需要显式用 userList不能再用 it 了 } }多层嵌套时我建议你给also的lambda显式命名参数userList.forEach { user - user.also { currentUser - println(当前处理: $currentUser) println(总列表大小: ${userList.size}) } }一个it的问题是Kotlin新手最常遇到的事情看起来没什么但排查起来很耗时间。显式命名参数只多打几个字清晰度提升不止一个量级。5.2 混淆also和apply导致初始化不生效also的lambda参数是it所以在lambda里如果只写属性名编译器会报错因为它找的是当前作用域中的局部变量不是also接收者的成员。val user User().also { name 王五 // 编译错误Unresolved reference: name }正确写法是val user User().also { it.name 王五 }如果你习惯用apply写初始化突然改成了also这种编译错误会立刻提醒你差异。反过来如果你习惯用also却想让代码更初始化导向应该换成apply而不是强行用also写一堆it.。5.3also中做耗时操作also的lambda是同步执行的如果在里面做了耗时操作比如网络请求、大文件读取会对调用线程造成阻塞。val user createUser().also { // 切忌在这里做网络请求或数据库写入 // 会阻塞当前线程 }这是很多人忽略的坑。also本身不提供任何异步能力它只是普通的内联函数lambda里的内容同步执行。如果你确实需要在对象创建后异步保存应该把also里的内容交给协程或线程池val user createUser().also { CoroutineScope(Dispatchers.IO).launch { repository.save(it) } }但这里还有个更深的坑你可能不希望调用方看到已保存的结果之前就返回了。这时你不能只图方便地把保存丢到后台得看业务到底需不需要同步保证。一个原则also里放的是副作用但如果这些副作用有时序要求你得自己保证时序also不会帮你做任何事。5.4 调试时快速打印结构体also在调试时的作用被低估了。不用打断点不用加临时变量直接写一行val config loadConfig().also { println(config$it) }打印复杂对象时Kotlin data class默认生成的toString()已经够用。如果是普通类你想打印完整结构可以用kotlin的deepToString或者toString自行处理。一行also { println(...) }加在出错点前用二分法缩小范围定位速度比来回打断点快很多。我就是靠这招排查过不少棘手问题这个习惯至今保留。5.5 公共API中避免泄漏also细节在库的公共API里如果返回值是接口类型also不会改变返回类型所以可以放心用它包装内部实现。但有一个细节要注意不要在公共API的lambda里把实现类直接暴露出去。你对外返回UserRepository内部用UserRepositoryImplalso的lambda如果打印了it::class用户会看到实现类名。fun provideRepository(): UserRepository { return UserRepositoryImpl().also { // 这里打印的 it::class 是 UserRepositoryImpl // 不是 UserRepository println(创建仓库: ${it::class.java.simpleName}) } }大多数情况下这不是问题但如果你在做依赖注入框架或者某种对隐私比较敏感的开源库这种日志可能暴露内部结构值得注意一下。6. 面试题视角also的高频考点与回答思路6.1 五个作用域函数的对比速查表先给一张表面试前扫一眼就能回忆起来函数接收者/参数返回值典型场景letit参数lambda最后一行空安全转换、链式变换runthis接收者lambda最后一行对象配置后返回计算结果withthis接收者非扩展函数lambda最后一行对同一对象多次操作applythis接收者调用者本身对象初始化配置alsoit参数调用者本身副作用、日志、校验、链式观察这张表背熟但面试时不要只背表要能结合场景说出为什么。6.2 面试官常问的追问方向面试官一般会接着问几个跟进问题考察你的理解深度。追问一为什么有apply了还需要also回答思路apply的lambda接收者是this更适合做对象自身的配置also的lambda参数是it更适合做与对象本身无强绑定的外部操作。两者语义不同。另外also在链式调用中间插入观察点更自然apply更倾向于端到端的初始化块。追问二also是不是多余的设计直接用apply不行吗回答思路单从功能上说apply确实能模拟also的大部分用法。但从代码可读性、语义表达上两个函数的名称和约定分别承载了不同的意图。Kotlin官方保留五个作用域函数不是为了炫技而是让开发者能以最简洁的形式表达最精准的意图。代码除了跑给机器看更要读给人看。追问三为什么also要设计成内联函数回答思路因为作用域函数太常用了如果每次调用都产生额外函数调用开销性能上不划算。inline可以消除lambda的对象分配和函数调用开销。虽然JIT很强但内联让语义和性能都更可控。同时内联也允许lambda内部使用非局部返回return这在某些控制流场景里很有用。追问四在协程的Flow里可以用also吗回答思路可以但要注意使用位置和含义。直接对Flow对象调用alsolambda会在Flow链构建时执行一次而不是每次发射数据时执行。如果要对每个数据进行观察或副作用操作应该用onEach。两者的命名和概念存在相似性但执行时机不同。追问五开发中如何决定用哪个作用域函数回答思路一个简单判断方法看你最后想得到什么。想得到对象自身考虑apply和alsoapply偏向内部配置also偏向外部副作用想得到另外一个值考虑let和runlet用it引用对象run用this引用对象with则适合对一个对象多次操作后返回某个计算结果的独立场景。6.3 一道典型的考察题面试官可能会让你阅读一段代码并指出问题fun process(user: User?): String { return user?.also { println(user is $it) }?.name ?: unknown }问这里用also合适吗如果要打印用户名字你觉得哪里有问题这里的考察点有两个also的返回值是User所以?.also { ... }?.name这一段链式调用能正确取到name逻辑上是通的。但如果打印用户信息和取名字是两步独立的操作用also作为衔接语义上说得通不过lambda里打印的是整个user对象不是name需要确认是否符合本意。这类题没有唯一答案关键是看你能不能把also的返回语义和执行时机说清楚以及在具体语境中做出合理判断。7. 结合热点的延伸思考热搜里还有几个和Kotlin相关的话题比如kotlin学习、kotlin面试题、kotlin flow面试题、build configuration language等它们看似独立其实和also这类基础语法串联起来构成了Kotlin学习的完整闭环。7.1 学习Kotlin的正确姿势很多人学Kotlin上来就盯协程、Flow、DSL这些高级特性结果基础作用域函数没吃透代码写得花里胡哨却漏洞百出。我见过不少简历写了熟悉Kotlin的人问他also和apply区别支支吾吾说不清楚。Kotlin的精华在于用简洁的语法表达精确的意图。五个作用域函数就是这个精华的最小样本。从这个意义上说also不是一个孤立的语法点它是理解Kotlin设计哲学的一把钥匙。7.2 构建脚本Kotlin DSL与作用域函数的关系热搜里有个“build configuration languagekotlin dsl 与 groovy dsl 区别”的话题。在Gradle的Kotlin DSL脚本里作用域函数也随处可见。比如常见的dependencies块、android块本质上都是lambda接收者为this的形式和apply的语义相近。你理解了apply和also的区别再看Kotlin DSL脚本就能明白为什么有些块里的代码可以直接写配置项因为接收者是this而有些地方需要显式传参因为参数是it。这个理解能直接迁移到Gradle脚本的编写和阅读中去。很多人一开始被Kotlin DSL劝退就是因为没意识到它底层就是一套作用域函数的组合应用。7.3 在Flow数据处理链上的also使用Flow相关的面试题经常出现。also在Flow里的正确用法是包裹Flow构建链在创建阶段做些副作用。而真正每一条数据经过时想观察得用onEach。fun observeMessages(): FlowMessage { return messageRepository.observeMessages() .also { println(Flow 创建完成) } // 构建阶段执行一次 .onEach { message - println(收到新消息: ${message.id}) } // 每条数据都会执行 .filter { !it.isRead } }这个区别面试时主动说出来比死记硬背知识点要加分得多。同样的道理map、filter这些Flow操作符内部也是lambda参数模式和it参数的作用域函数有内在一致性。8. 我的实操经验总结写了几年Kotlin踩过坑也填过坑关于also我有几点体会特别深。第一also是代码评审的亲和力加分项。一段代码全用let和apply也能跑但如果你在合适的位置用also把日志、校验、观察点清晰隔离开评审人读起来会非常顺畅。有一次我们组一个实习生把日志全部堆在let里我帮他改成also之后他说原来代码顺序还能反映思维顺序。第二also不适合做对象属性的最终赋值。有人用它做初始化甚至写出User().also { it.name 张三 }这种代码。能用但语义不如apply干净而且如果你在also里既改了属性又调了外部方法读者很难区分哪些是配置、哪些是副作用。我现在的习惯是改对象自身用apply外部副作用用also两者尽量不混用。第三调试场景里also的性价比无敌。不用打断点、不用加临时变量、不污染事务逻辑一行代码就能在链式调用的任意位置看到中间结果。配合println或者log很多隐藏Bug能快速暴露出来。我经常在排查问题时先加一圈also定位到问题后再决定是保留长期日志还是删掉临时调试。第四团队规范里明确also的适用边界。我们团队代码规范里有一条在流水线型链式调用中需要插入日志、监控、校验等非核心逻辑时优先使用also在对象初始化和配置属性时优先使用apply在需要转换对象类型或安全解包时优先使用let。有了这条边界代码Review的争议少了很多。第五别在also里做过于复杂的逻辑。also的设计初衷是轻量副作用不是给你写业务大块的地方。如果一个also块超过十行你就该考虑抽个函数了。代码整洁和语义精准一样重要。最后再分享一个小技巧如果你在看一个陌生的Kotlin项目想快速理解它的编码风格和作者思维习惯先搜一下项目里also和apply的使用比例。一个大量使用also来埋点、打日志、做校验的项目通常对代码可读性有比较高的要求一个几乎不用also、全是let的项目可能更注重链式转换的风格。这不是绝对标准但往往能给你一个不错的参考起点。also这个函数很小但用好它代码的叙事感会立刻上一个台阶。希望这篇文章能帮你在自己的项目里把它用得更顺手。
返回列表