Kotlin 1.4.30-M1 加强的内联类是个什么东西?

关键词:Kotlin Newsjava

内联类从 1.3 推出,一直处于实验状态。程序员

内联类 inline class,是从 Kotlin 1.3 开始加入的实验特性,计划 1.4.30 进入 Beta 状态(看来 1.5.0 要转正了?)。web

内联类要解决的问题呢,其实也与以往咱们接触到的内联函数相似,大致思路就是提供某种语法,提高代码编写体验和效率,同时又借助编译器的优化手段来减小这样作的成本。面试

1. 从内联函数提及

咱们先以各种编程语言当中普遍存在的内联函数为例来讲明内联的做用。编程

函数调用时有成本的,这涉及到参数的传递,结果的返回,调用栈的维护等一系列工做。所以,对于一些比较小的函数,能够在编译时使用函数的内容替换函数的调用,以减小函数的调用层次,例如:安全

fun max(a: Int, b: Int)Int = if(a > b) a else b

fun main() {
    println(max(12))
}

在 main 函数当中调用 max 函数,从代码编写的角度来看,使用函数 max 让咱们的代码意图更加明显,也使得求最大值的逻辑更容易复用,所以在平常的开发当中咱们也一直鼓励你们这样作。微信

不过,这样的结果就是一个简单的比较大小的事儿变成了一次函数的调用:编程语言

  public final static main()V
   L0
    LINENUMBER 6 L0
    ICONST_1
    ICONST_2
    INVOKESTATIC com/bennyhuo/kotlin/InlineFunctionKt.max (II)I
    INVOKESTATIC kotlin/io/ConsoleKt.println (I)V

若是咱们把 max 声明成内联函数:编辑器

inline fun max(a: Int, b: Int)Int = if(a > b) a else b

结果就不同了:ide

  public final static main()V
   L0
    LINENUMBER 6 L0
    ICONST_1
    ISTORE 0
    ICONST_2
    ISTORE 1
   L1
    ICONST_0
    ISTORE 2
   L2
    LINENUMBER 8 L2
   L3
    ILOAD 1
   L4
   L5
    ISTORE 0
   L6
    LINENUMBER 6 L6
   L7
    ICONST_0
    ISTORE 1
   L8
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream
;
    ILOAD 0
    INVOKEVIRTUAL java/io/PrintStream.println (I)V

这样咱们就已经看不到 max 函数的调用了。

固然,对于这样的小函数,编译器和运行时已经足够聪明到能够本身自动作优化了,内联函数在 Kotlin 当中最大的做用实际上是高阶函数的内联,咱们就以最为常见的 forEach 为例:

inline fun <T> Array<out T>.forEach(action: (T) -> Unit)Unit {
    for (element in this) action(element)
}

forEach 函数被声明为 inline,这说明它是一个内联函数。按照咱们的前面对内联函数的理解,下面的代码:

arrayOf(1,2,3,4).forEach {
    println(it)
}

编译以后大体至关于:

for (element in arrayOf(1,2,3,4)) {
    { it: Int -> println(it) }(element)
}

这样 forEach 自身的调用就被消除掉了。不过,这还不够,由于咱们看到 { it: Int -> println(it) }(element) 其实就是前面 forEach 定义当中的 action(element),这也是一个函数调用,也是有成本的。更为甚者,每一次循环当中都会建立一个函数对象(Lambda)而且调用它,这样一来,还会有频繁建立对象的开销。

因此,Kotlin 当中的内联函数也会同时对函数类型的参数进行内联,所以前面的调用编译以后实际上至关于:

for (element in arrayOf(1,2,3,4)) {
    println(element)
}

并且这样也更符合咱们的直觉。

总结一下,内联函数能够减小函数对象的建立和函数调用的次数。

提问:因此你知道为何 IDE 会对 max 这样的非高阶函数的内联发出警告了吗?

2. 什么是内联类

内联函数能够减小对象的建立,内联类实际上也是如此。

内联类实际上就是对其余类型的一个包装,就像内联函数实际上是对一段代码的包装同样,在编译的时候对于内联类对象的访问都会被编译器拆掉包装而获得内部真实的类型。所以,内联类必定有且只有一个属性,并且这个属性还不能被修改。

内联类的语法其实也简单,与 Kotlin 当中其余的枚举类、密封类、数据类的定义方式相似,在 class 前面加一个 inline 便可:

inline class PlayerState(val value: Int)

使用时大多数状况下就像普通类型那样:

val idleState = PlayerState(0)
println(idleState.value)

虽然这里建立了一个 PlayerState 的实例 idleState,咱们也对这个实例的成员 value 进行了访问,但编译完以后这段代码大体至关于:

val value = 0
println(value)

由于 PlayerState 这个类型的实例被内联,结果就剩下 value 自己了。

咱们固然也能够给内联类定义其余成员,这其中包括无状态的属性(没有 backing field)和函数:

inline class PlayerState(val value: Int) {
    val isIdle
        get() = value == 0
    
    fun isPlaying() = value == 1
}

访问这些成员的时候,编译器也并不会将内联类的实例建立出来,而是转换成静态方法调用:

val idleState = PlayerState(0)
println(idleState.isIdle)
println(idleState.isPlaying())

于是就至关于:

val value = 0
println(PlayerState.isIdle-impl(value))
println(PlayerState.isPlaying-impl(value))

isIdle-implisPlaying-impl 这两个函数是编译器自动为 PlayerState 生成的静态方法,它们的方法名中加了 - 这样的非法字符,这意味着这些方法对于 Java 来说是不友好的,换句话讲,内联类不能与 Java 的语法兼容。

咱们再看一个稍微复杂的情形:

val idleState = PlayerState(0)
println(idleState)

咱们直接将这个内联类的实例传给 println,这下编译器会怎么办呢?编译器只会在尽量须要的状况下完成内联,但对于这种强制须要内联类实例的状况,也是没法绕过的,所以在这里会发生一次“装箱”操做,把内联类实例真正建立出来,大体至关于:

val value = 0
println(PlayerState(value))

简单总结一下就是:

  1. 在必定范围内,内联类能够像普通类那样使用。言外之意,其实内联类也有挺多限制的,这个咱们待会儿再聊。
  2. 编译以后,编译器会尽量地将内联类的实例替换成其成员,减小对象的建立。

3. 内联类有什么限制?

经过前面对于内联类概念的讨论,咱们已经知道内联类

  1. 有且仅有一个不可变的属性
  2. 能够定义其余属性,但不能有状态

实际上,因为内联类存在状态限制,所以内联类也不能继承其余类型,但这不会影响它实现接口,例如标准库当中的无符号整型 UInt 定义以下:

inline class UInt internal constructor(internal val dataInt) : Comparable<UInt> {
  ...

  override inline operator fun compareTo(other: UInt)Int = uintCompare(this.data, other.data)

  ...
}

这个例子里面其实还有惊喜,那就是 UInt 的构造器是 internal 的,若是你想要同样画葫芦在本身的代码当中这样写,怕是要看一下编译器的脸色了:

如下为 Kotlin 1.4.20 当中的效果

在 Kotlin 1.4.30 之前,内联类的构造器必须是 public 的,这意味着在过去咱们不能经过内联类来完成对某一种特定类型的部分值的包装:由于外部同样能够创造出来新的内联类实例。

不过,1.4.30-M1 当中已经解除了这一限制,详情参见:KT-28056 Consider supporting non-public primary constructors for inline classes(https://youtrack.jetbrains.com/issue/KT-28056),于是咱们如今能够将内联类的构造器声明为 internal 或者 private,以防止外部随意建立新实例:

inline class PlayerState
private constructor(val value: Int) {
    companion object {
        val error = PlayerState(-1)
        val idle = PlayerState(0)
        val playing = PlayerState(1)
    }
}

这样,PlayerState 的实例就仅限于 error、idle、playing 这几个了。

除了前面限制实例的场景,有些状况下咱们其实只是但愿经过内联类提供一些运行时的校验,这就须要咱们在 init 块当中来完成这样的工做了,但内联类的 init 块在 1.4.30 之前也是禁止的:

1.4.30-M1 开始解除了这一限制,详情参见:KT-28055 Consider supporting init blocks inside inline classes(https://youtrack.jetbrains.com/issue/KT-28055)。不过须要注意的是,虽然 init 块当中的逻辑只在运行时有效,但这样的特性可让被包装类型的值与它的条件在代码当中紧密结合起来,提供更良好的一致性。

4. 内联类有什么应用场景?

前面在讨论内联类的概念和限制时,咱们已经给出了一些示例,你们也大概可以想到内联类的具体做用。接下来咱们再总体梳理一下内联类的应用场景。

4.1 增强版的类型别名

内联类最一开始给人的感受就是“类型别名 Plus”,由于内联类在运行时会被尽量替换成被包装的类型,这与类型别名看上去很接近。不过,类型别名本质上就是一个别名,它不会致使新类型的产生,而内联类是确实会产生新类型的:

inline class Flag0(val value: Int)
typealias Flag1 = Int

fun main() {
    println(Flag0::class == Int::class) // false
    println(Flag1::class == Int::class) // true
    
    val flag0 = Flag0(0)
    val flag1 = 0
}

4.2 替代枚举类

内联类在 1.4.30 以后能够经过私有化构造函数来限制实例个数,这样也能够达到枚举的目的,咱们前面已经给出过例子:

内联类的写法

inline class PlayerState
private constructor(val value: Int) {
    companion object {
        val error = PlayerState(-1)
        val idle = PlayerState(0)
        val playing = PlayerState(1)
    }
}

枚举类的写法

enum class PlayerState {
    error, idle, playing
}

咱们还能够为内联类添加各类函数来加强它的功能,这些函数最终都会被编译成静态方法:

inline class PlayerState
private constructor(val value: Int) {
    companion object {
        val error = PlayerState(-1)
        val idle = PlayerState(0)
        val playing = PlayerState(1)
        
        fun values() = arrayOf(error, idle, playing)
    }
    
    fun isIdle() = this == idle
}

虽然内联相似乎写起来稍微啰嗦了一些,但在内存上却跟直接使用整型几乎是同样的效果。

话说到这儿,不知道你们是否是能想起 Android 当中的注解 IntDef,结果上都是使用整型来替代枚举,但内联类显然更安全,IntDef 只是一种提示而已。不只如此,内联类也能够用来包装字符串等其余类型,无疑将是一种更加灵活的手段。

固然,使用的内联类相较于枚举类有一点点小缺点,那就是使用 when 表达式时必须添加 else 分支:

使用内联类

val result = when(state) {
  PlayerState.error -> { ... }
  PlayerState.idle -> { ... }
  PlayerState.playing -> { ... }
  else -> { ... } // 必须,由于编译器没法推断出前面的条件是完备的
}

而因为编译器可以肯定枚举类的实例可数的,所以 else 再也不须要了:

使用枚举类

val result = when(state) {
  PlayerState.error -> { ... }
  PlayerState.idle -> { ... }
  PlayerState.playing -> { ... }
}

4.3 替代密封类

密封类用于子类可数的场景,枚举类则用于实例可数的场景。

咱们前面给出的 PlayerState 其实不够完善,例如状态为 error 时,也应该同时附带错误信息;状态为 playing 时也应该同时有歌曲信息。显然当前一个简单的整型是作不到这一点的,所以咱们很容易能想到用密封类替代枚举:

class Song {
  ...
}

sealed class PlayerState

class Error(val t: Throwable): PlayerState()
object Idle: PlayerState()
class Playing(val song: Song): PlayerState()

若是应用场景对于内存不敏感,这样写实际上一点儿问题都没有,并且代码的可读性和可维护性都会比状态值与其相对应的异常和播放信息独立存储要强得多。

这里的 Error、Playing 这两个类型其实就是包装了另外的两个类型 Throwable 和 Song 而已,是否是咱们能够把它们定义为内联类呢?直接定义确定是不行的,由于 PlayerState 是个密封类,密封类本质上也是一个类,咱们前面提到过内联类有不能继承类型的限制,当时给出的理由是内联类不能包含其余状态。这样看来,若是父类当中足够简单,不包含状态,是否是未来有但愿支持继承呢?

其实问题不仅是状态那么简单,还有多态引起的装箱和拆箱的问题。由于一旦涉及到父类,内联类不少时候都没法实现内联,咱们假定下面的写法是合法的:

sealed class PlayerState

inline class Error(val t: Throwable): PlayerState()
object Idle: PlayerState()
inline class Playing(val song: Song): PlayerState()

那么:

var state: PlayerState = Idle
...
state = Error(IOExeption("...")) // 必须装箱,没法内联
...
state = Playing(Song(...)) // 必须装箱,没法内联

这里内联机制就失效了,由于咱们没法将 Song 的实例直接赋值给 state,IOException 的实例也是如此。

不过,做为变通,其实咱们也能够这样改写上面的例子:

inline class PlayerState(val state: Any?) {
    init {
        require(state == null || state is Throwable || state is Song)
    }
    
    fun isIdle() = state == null
    fun isError() = state is Throwable
    fun isPlaying() = state is Song
}

这样写就与标准库当中大名鼎鼎的 Result 类有殊途同归之妙了:

inline class Result<out Tinternal constructor(
    internal val value: Any?
) : Serializable {

  val isSuccess: Boolean get() = value !is Failure

  val isFailure: Boolean get() = value is Failure
  
  ...
}

5. 小结

本文咱们简单介绍了一下内联类的做用,实现细节,以及使用场景。简单总结以下:

  1. 内联类是对其余类实例的包装
  2. 内联类在编译时会尽量地将实例替换成被包装的对象
  3. 内联类的函数(包括无状态属性)都将被编译成静态函数
  4. 内联类在内存敏感的场景下能够必定程度上替代枚举类、密封类的使用
  5. 内联类不能与 Java 兼容

C 语言是全部程序员应当认真掌握的基础语言,无论你是 Java 仍是 Python 开发者,欢迎你们关注个人新课 《C 语言系统精讲》,上线一个月已经有 400 位同窗在一块儿学习了:

扫描二维码便可进入课程啦!


Kotlin 协程对大多数初学者来说都是一个噩梦,即使是有经验的开发者,对于协程的理解也仍然是懵懵懂懂。若是你们有一样的问题,不妨阅读一下个人新书《深刻理解 Kotlin 协程》,完全搞懂 Kotlin 协程最难的知识点:


若是你们想要快速上手 Kotlin 或者想要全面深刻地学习 Kotlin 的相关知识,能够关注基于 Kotlin 1.3.50 的 《Kotlin 入门到精通》

扫描二维码便可进入课程啦!


Android 工程师也能够关注下《破解Android高级面试》,这门课涉及内容均非浅尝辄止,除知识点讲解外更注重培养高级工程师意识,目前已经有 1100 多位同窗在学习:

扫描二维码便可进入课程啦!


本文分享自微信公众号 - Kotlin(KotlinX)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索