0. 绕不开的四兄弟
学习 Kotlin 一定绕不开 run/let/apply/also 这四兄弟,它们是 Kotlin 使用频率最高的扩展方法(扩展方法在之前文章有介绍),它们也被称为作用域函数(scope functions)。今天我们就来了解一下它们。本文依然是按代码比较,字节码分析,和扩展思考三个方面进行分析。
搞懂其中一个,其他作用域函数可以视为其变种。这篇文章我们先看 run
方法。
1. run 方法使用
在工程中,我们有一段这样的 Java 代码:
代码语言:javascript复制public class PlayManager {
/** 初始值为空,需要在资源初始化之后再拿到对象 */
private Player player = null;
/** 播放 */
public void play(String path) {
player?.init(path);
player?.prepare();
player?.start();
}
}
Kotlin等效代码为:
代码语言:javascript复制public class PlayManager {
/** 初始值为空,需要在资源初始化之后再拿到对象 */
private var player: Player? = null
/** 播放 */
fun play(path: String) {
player?.init(path)
player?.prepare()
player?.start()
}
}
如果用上 Kotlin 的 run
会是这样的:
public class PlayManager {
/** 初始值为空,需要在资源初始化之后再拿到对象 */
private var player: Player? = null
/** 播放 */
fun play(path: String) {
player?.run {
init(path)
prepare()
start()
}
}
}
这里的 run 调用是一种函数调用的特殊写法,即当 lambda 作为函数的最后一个参数时,可以写在函数括号外部,也就是说
object.run { }
和object.run({ })
是等价的。这种写法的好处在我看来,一是不用再去末尾数括号了,写 Java 的时候声明一个匿名类比如View.OnClickListener
,总是忘了加括号,在 Kotlin 没有这个烦恼;二是像run
,map
这种以函数作为参数的高阶函数,代码写起来看起来都更简洁利落。
看起来简洁了不少。
run
的功能很简单,它就做了两件事:
- 把 lambda 内部的
this
改成了对应的调用对象。这个看起来很神奇,我们稍后再分析; - run 函数会返回 lambda 的返回值。
run
方法达到了三个效果:
- 因为
this
的变化,不再需要重复的输入变量,和链式调用异曲同工,但你并不需要额外花费精力来编写链式调用的代码; - 把可空对象转换为了非空对象,因为
run
方法是问号调用,player
不为空才会执行。因为考虑到并发,Kotlin 要求每次调用可空属性的时候都进行判空,如此一来属性这个小朋友就会有很多问号。。使用run
方法等效于先把可空属性用临时变量持有再使用,这样就消除了并发竞争的影响(Java 经常也有这种代码,不过要自己手写罢了)。 - 在一个函数里声明的这个一个小“代码块“,表示和其他无关的代码隔离,实现了函数内部的高内聚。这个效果可以增加代码的可读性,让人一看就明白:“哦,这是针对这个对象的一系列操作,这个函数里关于这个对象的使用我只需要关注这个代码块就可以了”。
第三点是我尤其喜欢的一个点,我觉得这样的设计不仅是为了提高开发效率,它更是在引导开发者写出好维护的代码。在写 Java 的时候,大家都很容易不自觉的写出某个对象在函数头操作一下,隔几行调用一下,隔几行又操作一下的代码。阅读者很容易误以为这些代码之间有着顺序上的耦合,从而继续按照这个“隐含的规则“来维护代码。却不知当时的开发者只是想到哪写到哪,实际并不存在这样的隐含关系。使用 run
可以在函数内部快速建立起一个个代码块,让函数拥有更清晰的结构,又不用花费很大精力把代码块拆成一个个小函数,毕竟给函数起名字可是非常头秃的事情。
说到不用起名字,lambda 本身就有“匿名函数”的外号,这样的使用方法可以说十分贴切了。而从耦合程度来看,代码块介于函数和过程代码之间。
函数是面向过程的产物,它天生就很容易产生耦合度高的代码。就我看来,作用域函数更像是给函数打上的一个“补丁”。
3. run 方法代码分析
run
源码如下:
@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
卧槽看起来好吊(看不懂),不是说好了很简单吗?因为这函数涉及的基本都是编译器相关的,平时开发用不到。这里包含了泛型,inline,类扩展 lambda(T.() -> R
),contract 这 4 个概念。泛型我就默认你懂了,毕竟这里只讲 Kotlin 的新东西,Kotlin 泛型和 Java 的泛型除了写法没有什么区别。剩下的三个概念我们简单过一下吧。
inline,中文名内联函数,是 C/C 的老活儿了。inline 的意思是,虽然你声明了一个函数,但在编译期调用这个函数的地方会被替换为函数包含的代码。inline 的好处是调用该方法不再有调用方法的性能消耗,即不会跳转和产生栈帧;坏处是可能会使二进制文件膨胀,尤其是函数很大的时候。所以 inline 适合被频繁调用但代码量很小的函数,run
就很符合这个条件。我们可以因此得出结论:由于编译器编译时会把 inline 函数内联到实际调用位置,所以使用 run
方法不会有方法调用的性能损耗。
而 @kotlin.internal.InlineOnly
,实际效果为对 Java 不可见(private),因为 Java 不支持 inline。对 Java 不可见后,这个 inline 方法则可以不在字节码里存在,因为调用的地方全部都内联了。
值得注意的是,和 C/C 一样,Kotlin 的 inline 也不是必然内联的。具体机制,我们有机会再聊(还没有学到)。 虽然 Java 没有内联函数,但是 JVM 是有内联优化的,只是这个优化无法精确控制。Java 的设计者一直尽可能让 Java 语言保持简单,这可能也是 Java 为什么能持续热门这么久的原因。
类扩展 lambda(关键字 lambda with class extension),即入参的声明 T.() -> R
。lambda 我们了解了,扩展方法我们也了解了(强行假设你看过之前的文章),扩展 lambda 也可以理解为给类扩展一个 lambda 函数。它的效果也和扩展方法一样,在 扩展 lambda 作用域内,你可以以对象作为 this
来操作这个对象。
最后一个 contract 契约,指的是代码和 Kotlin 编译器的契约。举一个例子,我们对局部变量增加了如果为空则 return 的逻辑,Kotlin 编译器便可以智能的识别出 return 之后的局部变量一定不为空,局部变量的类型会退化为非空类型。但如果我们把是否为空的代码封装进一个扩展方法如 Any?.isNotNull()
里,那么编译器就无法识别 return 后面的代码局部变量是否为空了,这个局部变量依然是可空类型。
那么这个时候 contract 就派上用场了。我们可以声明一个 contract,告诉编译器如果Any?.isNotNull()
返回了 true,则表示对象非空。这样我们在代码里执行了 isNotNull()
方法之后,return 后面的代码,局部变量也能正确退化为非空类型。具体例子我们可以看官方 Collections.kt 的 Collection<T>.isNullOrEmpty()
。
了解了 contract 的作用后我们再看 run
包含的契约。它意思是这个 lambda 只会被 run
方法执行一次,且 run
执行完后不会再被执行。对于了解到这个额外信息的 Kotlin 编译器,他就可以更有针对性的优化这里的代码(怎么针对,也还没有学到。。)。
4. 为何 Java 没有作用域函数?
作用域函数需要类扩展和内联这两个能力,才能最大化体现其价值。没有类扩展,this
的切换需要通过继承或者匿名类来实现,且做不到通用;而像 let
这种不需要切换 this
的作用域函数,因为没有类扩展能力而为了追求通用性,也只能通过静态工具类来实现,效果是打折扣的。
而没有内联能力的 Java,虽然有 JVM 内联优化支撑,但内联优化只对 final 且调用次数数量级较大的方法有效。如果像 Kotlin 这样规模化的使用作用域函数,对性能是有不可忽视的影响的。
5. 其他作用域函数的使用和适用场景
下一篇!
版权所有,转载请注明出处: https://cloud.tencent.com/developer/article/1621073