kotlin 1.5 中的 Inline classes

Kotlin 1.5 如约而来了。

如果你正在使用Android Studio 4.2.0 、IntelliJ IDEA 2020.3 或更高的版本,近期就会收到 Kotlin 1.5 的Plugin推送了。作为一个大版本,1.5带来了不少新特性,其中最主要的要数inline class了。

早在kotlin 1.3 就已经有了 inline class 的alpha版本。到 1.4.30 进入 beta,如今在 1.5.0 中 终于迎来了 Stable 版本。早期的实验版本的 inline 关键字 在 1.5 中被废弃,转而变为 value关键字

//before 1.5
inline class Password(private val s: String)

//after 1.5 (For JVM backends)
@JvmInline
value class Password(private val s: String)
复制代码

个人很认同从 inline 变为 value 的命名变化,这使得其用途更为明确:

inline class 主要就是用途就是更好地 "包装" value

有时为了语义更有辨识度,我们会使用自定义class包装一些基本型的value,这虽然提高了代码可读性,但额外的包装会带来潜在的性能损失,基本型的value由于被在包装在其他class中,无法享受到jvm的优化(由堆上分配变为栈上分配)。 而 inline class 在最终生成的字节码中被替换成其 “包装”的 value, 进而提高运行时的性能。

// For JVM backends
@JvmInline
value class Password(private val s: String)
复制代码

如上,inline class 构造参数中有且只能有一个成员变量,即最终被inline到字节码中的value。

val securePassword = Password("Don't try this in production")
复制代码

如上,Password实例在字节码中被替换为String类型"Don't try this in production"

PS:如何安装 Kotlin 1.5

  1. 首先更新IDE的 Kotlin Plugin,如果没收到推送,可以手动方式升级:

Tools > Kotlin > Configure Kotlin Plugin Updates

  1. 配置languageVersion & apiVersion
compileKotlin {
    kotlinOptions {
        languageVersion = "1.5"
        apiVersion = "1.5"
    }
}
复制代码

经 inline 处理后代码

inline classes 转化为字节码后究竟是怎样的呢?

fun check(password: Password) {
    //...
}

fun main() {
    val securePassword = Password("Don't try this in production")
    check(securePassword)
}
复制代码

对于Password这个inline class, 字节码反编译的产物如下:

   public static final void check_XYhEtbk/* $FF was: check-XYhEtbk*/(@NotNull String password) {
      Intrinsics.checkNotNullParameter(password, "password");
   }

   public static final void main() {
      String securePassword = Password.constructor-impl("Don't try this in production");
      check-XYhEtbk(securePassword);
   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }
   
复制代码
  • securePassword 的类型由Password替换为String
  • check方法改名为check_XYhEtbk,签名类型也有 Password 替换 String

可见,无论是变量类型或是函数参数类型,所有的inline classes都被替换为其包装的类型。

名字被混淆处理(check_XYhEtbk)主要有两个目的

  1. 防止重载函数的参数经过 inline 后出现相同签名的情况
  2. 防止从Java侧调用到参数经过 inline 后的方法

Inline class 的成员

inline class 具备普通class的所有特性,例如拥有成员变量、方法、初始化块等

@JvmInline
value class Name(val s: String) {
    init {
        require(s.length > 0) { }
    }

    val length: Int
        get() = s.length

    fun greet() {
        println("Hello, $s")
    }
}

fun main() {
    val name = Name("Kotlin")
    name.greet() //  `greet()`作为static方法被调用 
    println(name.length) // property getter 也是一个static方法
}
复制代码

但是,inline class 的成员不能有自己的幕后属性,只能作为代理使用。 inline class的创建的对象在字节码中会被消除,所以这个实例无法拥有自己的状态以及行为,对inline class 实例的方法调用,在实际运行时会变为一格静态方法调用。


Inline class 的继承
interface Printable {
    fun prettyPrint(): String
}

@JvmInline
value class Name(val s: String) : Printable {
    override fun prettyPrint(): String = "Let's $s!"
}

fun main() {
    val name = Name("Kotlin")
    println(name.prettyPrint()) // prettyPrint()也是一个 static方法调用
}
复制代码

inline class 可以实现任意inteface, 但不能继承自class。因为在运行时将无处安放其父类的属性或状态。如果你试图继承另一个Class,IDE会提示错误:Inline class cannot extend classes


自动拆装箱

inline class 在字节码中并非总被消除,有时也是需要存在的。例如当出现在泛型中、或者以 Nullable 类型出现时,此时它会根据情况自动与被包装类型进行转换,实现像Integerint那样的自动拆装箱

@JvmInline
value class WrappedInt(val value: Int)

fun take(w: WrappedInt?) {
    if (w != null) println(w.value)
}

fun main() {
    take(WrappedInt(5))
}
复制代码

如上,take 接受一个 Nulable 的 WrappedInt 后进行 print 处理

public static final void take_G1XIRLQ(@Nullable WrappedInt w) {
    if (Intrinsics.areEqual(w, (Object)null) ^ true) {
        int var1 = w.unbox_impl();
        System.out.println(var1);
    }
}

public static final void main() {
    take_G1XIRLQ(WrappedInt.box_impl(WrappedInt.constructor_impl(5)));
}
复制代码

字节码中,take的参数并没有变为Int,而仍然是原始类型 WrappedInt。因此,在 take 的调用处,需要通过box_impl 做装箱处理, 而在take的实现中,通过 unbox_impl 拆箱后再进行print

同理,在泛型方法或者泛型容器中使用 inline class 时,需要通过装箱保证传入其原始类型:

genericFunc(color)         // boxed
val list = listOf(color)   // boxed
val first = list.first()   // unboxed back to primitive
复制代码

反之,从容器获取 item 时,需要拆箱为被包装类型。

关于自动拆装箱在开发中无需太在意,只要知道有这个特性存在即可。


对比其他类型

与 type aliases 的区别 ?

inline class 与 type aliases 在概念上有点相似,都会在编译后被替换为被代理(包装)的类型, 区别在于

  • inline class 本身是实际存在的Class 只是在字节码中被消除了并被替换为被包装类型
  • type aliases仅仅是个别名,它的类型就是被代理类的类型。
typealias NameTypeAlias = String

@JvmInline
value class NameInlineClass(val s: String)

fun acceptString(s: String) {}
fun acceptNameTypeAlias(n: NameTypeAlias) {}
fun acceptNameInlineClass(p: NameInlineClass) {}

fun main() {
    val nameAlias: NameTypeAlias = ""
    val nameInlineClass: NameInlineClass = NameInlineClass("")
    val string: String = ""

    acceptString(nameAlias) // OK: NameTypeAlias等同String,可以传递
    acceptString(nameInlineClass) // Not OK: NameInlineClass 与 String是两个类,不能等同

    // 反之亦然:
    acceptNameTypeAlias(string) // OK: 传入String也是可以的
    acceptNameInlineClass(string) // Not OK: String不等同于NameInlineClass
}

复制代码

与 data class 的区别 ?

inline class 与 data class 在概念上也很相似,都是对一些数据的包装,但是区别很明显

  • inline class 只能有一个成员属性,其主要目的是通过一个额外类型的包装让代码更易用
  • data clas 可以有多个成员属性,其主要目的是更高效地处理一组相关数据的集合

使用场景

上面说到, inline class 的目的是通过包装让代码更易用,这个易用性体现在诸多方面:

场景1:提高可读性

fun auth(userName: String, password: String) { println("authenticating $userName.") }
复制代码

如上, auth的两个参数都是String,缺乏辨识度,即使像下面这样传错了也难以发觉

auth("12345", "user1") //Error
复制代码
@JvmInline value class Password(val value: String)
@JvmInline value class UserName(val value: String)

fun auth(userName: UserName, password: Password) { println("authenticating $userName.")}

fun main() {
    auth(UserName("user1"), Password("12345"))
    //does not compile due to type mismatch
    auth(Password("12345"), UserName("user1"))
}
复制代码

使用 inline class 使的参数更具辨识度,避免发生错误

场景2:类型安全(缩小扩展函数作用域)

inline fun <reified T> String.asJson() = jacksonObjectMapper().readValue<T>(this)
复制代码

String类型的扩展方法asJson可以转化为指定类型T

val jsonString = """{ "x":200, "y":300 }"""
val data: JsonData = jsonString.asJson()
复制代码

由于扩展函数是top-level的,所有的String类型都可以访问,造成污染

"whatever".asJson<JsonData> //will fail
复制代码

通过inline class可以将Receiver类型缩小为指定类型,避免污染

@JvmInline value class JsonString(val value: String)

inline fun <reified T> JsonString.asJson() = jacksonObjectMapper().readValue<T>(this.value)
复制代码

如上,定义JsonString,并为之定义扩展方法。

场景3:携带额外信息

/**
 * parses string number into BigDecimal with a scale of 2
 */
fun parseNumber(number: String): BigDecimal {
    return number.toBigDecimal().setScale(2, RoundingMode.HALF_UP)
}

fun main() {
    println(parseNumber("100.12212"))
}
复制代码

如上,parseNumber的功能是将任意字符串解析成数字并保留小数点后两位。

如果我们希望通过一个类型将解析前后的值都保存下来然后分别打印,可能首先想到的使用Pair或者data class。但是当这两个值之间是有换算关系时,其实也可以用inline class实现。如下

@JvmInine value class ParsableNumber(val original: String) {
    val parsed: BigDecimal
        get() = original.toBigDecimal().setScale(2, RoundingMode.HALF_UP)
}

fun getParsableNumber(number: String): ParsableNumber {
    return ParsableNumber(number)
}

fun main() {
    val parsableNumber = getParsableNumber("100.12212")
    println(parsableNumber.parsed)
    println(parsableNumber.original)
}
复制代码

ParsableNumber的包装类型是String,同时通过parsed携带了解析后的值。如前文提到的那样,字节码中,parsed getter 会以static方法的形式存在,因此虽然携带了更多信息,但实际上并不存在这样一个包装类实例:

@NotNull
public static final String getParsableNumber(@NotNull String number) {
    Intrinsics.checkParameterIsNotNull(number, "number");
    return ParsableNumber.constructor_impl(number);
}

public static final void main() {
    String parsableNumber = getParsableNumber("100.12212");
    BigDecimal var1 = ParsableNumber.getParsed_impl(parsableNumber);
    System.out.println(var1);
    System.out.println(parsableNumber);
}
复制代码

最后

Inline class 是个好工具,在提高代码的可读性、易用性的同时,不会造成性能的损失。 早期由于一直处于试验状态没有被大家所熟知, 随着如今在 Kotlin 1.5 中的转正,相信未来一定会被在更广泛地使用、发掘更多应用场景。

参考