写点什么

Kotlin 作用域函数 [Scope Function](上)

作者:子不语Any
  • 2022-11-30
    湖南
  • 本文字数:2554 字

    阅读完需:约 8 分钟

Kotlin作用域函数[Scope Function](上)

前言

Kotlin 中的作用域函数是标准库中包含的几个常用函数,let、run、with、apply 以及 also。从本篇起来介绍一下 Kotlin 中的作用域函数,分上下两篇。上篇会说明几个常见作用域函数,分析一下 run 函数,以及对比一下 Java 中没有作用域函数情况。

概览

1. 常见的四个作用域函数

学习 Kotlin 肯定会碰到 run/let/apply/also 这四个函数,它们是 Kotlin 标准库中的几个常用函数,作用在对象上时,执行给定的 block 代码块。形成一个临时作用域,在这个作用域中,可以访问该对象而无需名称,也被称为作用域函数(scope functions)


下面表格展示常用几个作用域函数的对比,根据表格在业务场景选择合适作用域函数。



根据预期目的选择合适作用域函数的指南:


  • 对一个非空(non-null)对象执行 lambda 表达式:let

  • 将表达式作为变量引入为局部作用域中:let

  • 对象配置:apply

  • 对象配置并且计算结果:run

  • 在需要表达式的地方运行语句:非扩展的 run

  • 附加效果:also

2. run 方法使用

在项目中,有以下一段代码:


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 { // 对象调用run init(path) prepare() start() } }}
复制代码


run 调用是一种函数调用的特殊写法,即当 lambda 作为函数的最后一个参数时,可以写在函数括号外部,也就是说object.run { }object.run({ })是等价的。这种代码写起来看起来都更简洁。run的功能很简单,主要做两件事:


  1. 把 lambda 内部的 this 改成了对应调用对象;

  2. run 函数会返回 lambda 表达式的返回值。run 方法达到以下三个效果:

  3. this 的变化,不再需要重复的输入变量,和链式调用异曲同工;

  4. 把可空对象转换为了非空对象,因为run方法是?.调用,player 不为空才会执行。考虑到并发,Kotlin 要求每次调用可空属性时要进行判空。使用 run 方法等效于先把可空属性用临时变量持有再使用,这样就消除了并发竞争的影响。

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


第 3 点是非常棒的,这样不仅是提高开发效率,更是引导开发者写出好维护的代码。在写 Java 代码时,很容易不自觉的写出某个对象在函数头操作一下,隔几行调用一下,隔几行又操作一下的代码。阅读者很容易误以为这些代码之间有着顺序上的耦合,从而继续按照这个“隐含的规则“来维护代码。却不知当时的开发者只是想到哪写到哪,实际并不存在这样的隐含关系。使用 run可以在函数内部快速建立起一个个代码块,让函数拥有更清晰的结构,又不用花费很大精力维护代码逻辑。

3. run 函数代码分析

run 源码如下:


@kotlin.internal.InlineOnlypublic inline fun <T, R> T.run(block: T.() -> R): R {    contract {        callsInPlace(block, InvocationKind.EXACTLY_ONCE)    }    return block()}
复制代码


从上面函数源码看,涉及的基本都是编译器相关的。包含了泛型,inline,类扩展 lambda(T.() -> R),contract 4 个概念。


inline,表示内联函数,在编译期调用这个函数的地方会被替换为函数包含的代码。


inline 的好处是调用该方法不再有调用方法的性能消耗,即不会跳转和产生栈帧;弊端是可能会使二进制文件体积增大,尤其是函数代码量大的时候。所以 inline 适合用在代码量小的函数,run 就很符合这个条件。可以得出结论:编译器编译时会把 inline 函数内联到实际调用位置,所以使用 run 方法时不会有方法调用的性能损耗。


@kotlin.internal.InlineOnly,实际效果为对 Java 不可见(private),因为 Java 不支持 inline。对 Java 不可见后,这个 inline 方法则可以不在字节码里存在,因为调用的地方全部都内联了。


Java 虽没有内联函数,但 JVM 是有内联优化的,只是这个优化无法精确控制。


类扩展 lambda(关键字 lambda with class extension),即入参的声明 T.() -> R。扩展 lambda 可以理解为给类扩展一个 lambda 函数。它的效果和扩展方法一样,在 扩展 lambda 作用域内,以对象作为 this 来操作这个对象。


contract 契约,指的是代码和 Kotlin 编译器的契约。举一个例子,对局部变量增加了如果为空则 return 的逻辑,Kotlin 编译器便可以智能的识别出 return 之后的局部变量一定不为空,局部变量的类型会退化为非空类型。但如果把是否为空的代码封装进一个扩展方法如 Any?.isNotNull() 里,那么编译器就无法识别 return 后面的代码局部变量是否为空,事实上这个局部变量依然是可空类型。


这里可以声明一个 contract,告知编译器如果Any?.isNotNull() 返回了 true,则表示对象非空。这样在代码里执行了 isNotNull() 方法之后,return 后面的代码,局部变量也能正确退化为非空类型。具体例子我们可以看官方 Collections.kt 的 Collection<T>.isNullOrEmpty()

4. Java 没有作用域函数

作用域函数需要类扩展内联这两个特点,才能最大化体现其价值。没有类扩展,this 的切换需要通过继承或者匿名类来实现,做不到通用;


let 这种不需要切换 this 的作用域函数,因为没有类扩展能力而为了追求通用性,也只能通过静态工具类来实现,效果是打折扣的。


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

发布于: 2022-11-30阅读数: 22
用户头像

子不语Any

关注

If not now,when? 2022-09-17 加入

安卓攻城狮

评论

发布
暂无评论
Kotlin作用域函数[Scope Function](上)_android_子不语Any_InfoQ写作社区