前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >12. Kotlin 作用域函数(scope function)

12. Kotlin 作用域函数(scope function)

作者头像
sickworm
发布2020-04-26 14:00:18
9630
发布2020-04-26 14:00:18
举报
文章被收录于专栏:sickwormsickworm

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 会是这样的:

代码语言:javascript
复制
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 源码如下:

代码语言:javascript
复制
@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

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0. 绕不开的四兄弟
  • 1. run 方法使用
  • 3. run 方法代码分析
  • 4. 为何 Java 没有作用域函数?
  • 5. 其他作用域函数的使用和适用场景
相关产品与服务
腾讯云代码分析
腾讯云代码分析(内部代号CodeDog)是集众多代码分析工具的云原生、分布式、高性能的代码综合分析跟踪管理平台,其主要功能是持续跟踪分析代码,观测项目代码质量,支撑团队传承代码文化。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档