12. Kotlin 作用域函数(scope function)run/let/apply/also

0. 绕不开的四兄弟

学习 Kotlin 一定绕不开 run/let/apply/also 这四兄弟,它们是 Kotlin 使用频率最高的扩展方法(扩展方法在之前文章有介绍),它们也被称为作用域函数(scope functions)。今天我们就来了解一下它们。本文依然是按代码比较,字节码分析,和扩展思考三个方面进行分析。

搞懂其中一个,其他作用域函数可以视为其变种。这篇文章我们先看 run 方法。

1. run 方法使用

在工程中,我们有一段这样的 Java 代码:

public class PlayManager {
    /** 初始值为空,需要在资源初始化之后再拿到对象 */
    private Player player = null;

    /** 播放 */
    public void play(String path) {
        if (player != null) {
            player.init(path);
            player.prepare();
            player.start();
        }
    }
}

Kotlin等效代码为:

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 没有这个烦恼;二是像runmap 这种以函数作为参数的高阶函数,代码写起来看起来都更简洁利落。

看起来简洁了不少。

run的功能很简单,它就做了两件事:

  1. 把 lambda 内部的 this 改成了对应的调用对象。这个看起来很神奇,我们稍后再分析;
  2. run 函数会返回 lambda 的返回值。

run 方法达到了三个效果:

  1. 因为this 的变化,不再需要重复的输入变量,和链式调用异曲同工,但你并不需要额外花费精力来编写链式调用的代码;

  2. 把可空对象转换为了非空对象,因为run方法是问号调用,player 不为空才会执行。因为考虑到并发,Kotlin 要求每次调用可空属性的时候都进行判空,如此一来属性这个小朋友就会有很多问号。。使用 run 方法等效于先把可空属性用临时变量持有再使用,这样就消除了并发竞争的影响(Java 经常也有这种代码,不过要自己手写罢了)。

  3. 在一个函数里声明的这个一个小“代码块“,表示和其他无关的代码隔离,实现了函数内部的高内聚。这个效果可以增加代码的可读性,让人一看就明白:“哦,这是针对这个对象的一系列操作,这个函数里关于这个对象的使用我只需要关注这个代码块就可以了”。

第三点是我尤其喜欢的一个点,我觉得这样的设计不仅是为了提高开发效率,它更是在引导开发者写出好维护的代码。在写 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.isNullOrEmpty()

了解了 contract 的作用后我们再看 run 包含的契约。它意思是这个 lambda 只会被 run 方法执行一次,且 run 执行完后不会再被执行。对于了解到这个额外信息的 Kotlin 编译器,他就可以更有针对性的优化这里的代码(怎么针对,也还没有学到。。)。

4. 为何 Java 没有作用域函数?

作用域函数需要类扩展内联这两个能力,才能最大化体现其价值。没有类扩展,this 的切换需要通过继承或者匿名类来实现,且做不到通用;而像 let 这种不需要切换 this 的作用域函数,因为没有类扩展能力而为了追求通用性,也只能通过静态工具类来实现,效果是打折扣的。

而没有内联能力的 Java,虽然有 JVM 内联优化支撑,但内联优化只对 final 且调用次数数量级较大的方法有效。如果像 Kotlin 这样规模化的使用作用域函数,对性能是有不可忽视的影响的。

5. 其他作用域函数的使用和适用场景

下一篇!

版权所有,转载请注明出处:
https://sickworm.com/?p=1823

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据