标签归档:作用域函数

13. Kotlin 作用域函数 run/let/apply/also 的使用

Published / by sickworm / Leave a Comment

上一篇文章我们介绍了作用域函数,并以其中一个作用函数run为例,介绍了作用域函数的使用和原理。除了run之外,Kotlin 官方还内置了letapplyalso这几个作用域函数,下面我们一起来他们的相同点和区别,并举例说明他们的使用场景。

1. 4 个作用域函数 = 2 个特性的两两组合

runletapplyalso,这 4 个作用域函数,其实是 2 个特性的组合结果:

  • 调用作用域函数的对象,是作为this传入,还是作为唯一参数(默认为it)传入;
  • 作用域函数的返回值,是调用的对象,还是 lambda 的返回值。

配合伪代码解释一下:

val result = object.xxx { param ->
    val lambdaResult = param.toString()
    lambdaResult
}

xxx可以是runletapplyalso其中一个。

如果xxx = run/let,那么 result == lambdaResult
而如果xxx = apply/also,那么result == object。且lambdaResult这一行会报错,因为applyalso“` 的 lambda 返回值必须是 Unit。

lambda 最后一行的值会作为 lambda 的返回值。它等价于 return@xxx lambdaResult@xxx表示返回的是这个lambda,而不是退出整个上层方法。如果是不在最后一行返回的代码,比如异常分支,就可以(也只能)这样用。

如果xxx = let/also,那么param == object
而如果xxx = run/apply的话,那么 lambda 是个无参数 lambda,不存在paramparam -> 这里会报错;

如果 lambda 为单参数 lambda,此时param ->可以省略,Kotlin 提供默认的单参数名it。这个很方便,文章后面的代码都会采用这种简写。

总结成表就是:

特性 返回值为this 返回值为lambda结果
调用对象转换为this apply run
调用对象转换为it also let

我们只需要知道 4 个作用域函数分别是 2 个特性的两两组合即可。用的时候直接查源代码,不需要专门记忆。

2. run/let/apply/also 各自的使用场景举例

我们已经知道这 4 个作用域函数的特点了,那么我们怎么用好它们呢?下面一起来看下这几个作用域函数的使用场景。

run

这是工程中的一段代码:

mRecordViewHelper?.run {
    mEffectsView.visibility = View.INVISIBLE
    mLyricViewer.visibility = View.INVISIBLE
    mStatusView = AudioRecordStatusView(CommonContext.getApplicationContext(), null)
    enableSoloCountBackView()
}

run方法很适合用在对某个对象做一系列操作的地方。这里可空对象mRecordViewHelper使用run方法把自己转换成非空对象this“传入”到 lambda 中,并在 lambda 内部进行一系列的赋值和方法调用。

run的返回值是 lambda 的最后一行结果,在这个例子是Unit。使用 lambda 结果的用例较少,主要就是这种转换为this的用法。如果后续遇到特别适合的再补充。

let

使用run还是使用let更像是一个个人喜好的问题,因为有些人就是不习惯把调用对象转换成this,而是更喜欢用入参it

使用run还是使用let的确没有太大区别。不过还是有这几种情况,我更建议你用let

  • 调用对象主要作为参数,而不是用于初始化或方法调用时。
// 赋值和调用的方法都是类的成员和方法,不是 mRecordViewHelper 的成员和方法
mRecordViewHelper?.run {
    myFirstView = firstView
    mySecondView = secondView
    setTargetView(targetView)
}

// 使用 let,不会存在两个 this 混用,代码可读性更高。
mRecordViewHelper?.let {
    myFirstView = it.firstView
    mySecondView = it.secondView
    setTargetView(it.targetView)
}
  • 当 lambda 中需要用到类的this时;
// 因为 this 的变化,导致引用类的 this 需要加@MainActivity。这个和 Java 匿名类的 MainActivity.this 效果一样
mRecordViewHelper?.run {
    setOnClickListener(this@MainActivity)
}

// let 不会改变 this,在需要引用类的 this 的时候比较方便。
mRecordViewHelper?.let {
    it.setOnClickListener(this)
}

总结一下:当 lambda 主要执行的是调用对象的方法和赋值时,建议使用run;而当调用对象主要用作参数时,建议使用let。当 lambda 会用到类的this时,建议使用let

apply

applyrun的区别主要在于,apply返回的是调用对象。这个特性使得apply很适合用来做类似初始化的工作。如:

class VideoConfig {
    val profile = VideoEncodeProfile().apply {
        audioBitRate = 44100
        videoWidth = 480
        videoHeight = 480
        videoFrameRate = 30
        setDefaultQuality()
    }
}

apply 也很适合用来做 property 的初始化,这样 property 的初始化代码就不用写在 init 块里了,做到了代码的高内聚。上面代码的profile 就是 property 初始化的例子。

also

also 的适用场景,和runlet一样,是与apply来对比的。具体建议也和runlet一样:

当 lambda 主要执行的是调用对象的方法和赋值时,建议使用apply;而当调用对象主要用作参数时,建议使用also。当 lambda 会用到类的this时,建议使用also

3. 只有 4 个作用域函数吗?

细心的同学可能已经发现,在 Standard.kt 中,除了runletapplyalso之外,还有好几个作用域函数。其实掌握了这 4 个作用域函数,已经覆盖了大部分使用场景。剩下的几个使用需求没有那么的迫切,但掌握之后,可以帮助你写出更有 Kotlin 味道的代码。

下一篇文章会介绍 Standard.kt 中剩余的作用域函数。

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

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

Published / by sickworm / Leave a Comment

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