Kotlin Inline classes,你了解吗?

简介: kotlin 1.5 中的 Inline classes 终于进入稳定版。在提高代码的可读性、易用性的同时,不会造成性能的损失,值得大家学习和使用

image.png

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"
    }
}

<br/>

经 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 后的方法

<br/>

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 实例的方法调用,在实际运行时会变为一格静态方法调用。

<br/>

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

<br/>

自动拆装箱


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 时,需要拆箱为被包装类型。

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

<br/>

对比其他类型


与 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 可以有多个成员属性,其主要目的是更高效地处理一组相关数据的集合

<br/>

使用场景

上面说到, 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);
}

<br/>

最后


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

参考

目录
相关文章
|
存储 Java 编译器
Kotlin 学习笔记(四)—— 作用域函数、inline 关键字、反引号等 Kotlin 基本用法(下)
Kotlin 学习笔记(四)—— 作用域函数、inline 关键字、反引号等 Kotlin 基本用法(下)
63 0
|
Java Android开发 开发者
Kotlin 学习笔记(四)—— 作用域函数、inline 关键字、反引号等 Kotlin 基本用法(上)
Kotlin 学习笔记(四)—— 作用域函数、inline 关键字、反引号等 Kotlin 基本用法(上)
107 0
|
Java Kotlin
Kotlin内联函数inline、noinline、crossinline
如果一个函数接收另一个函数作为参数,或返回类型是一个函数类型,那么该函数被称为是高阶函数
160 0
|
编译器 Kotlin
利用 Kotlin inline 解决日志泄漏风险
利用 Kotlin inline 解决日志泄漏风险
135 0
利用 Kotlin inline 解决日志泄漏风险
|
存储 IDE Java
重学 Kotlin —— inline,包治百病的性能良药?
重学 Kotlin —— inline,包治百病的性能良药?
重学 Kotlin —— inline,包治百病的性能良药?
|
2月前
|
JSON 调度 数据库
Android面试之5个Kotlin深度面试题:协程、密封类和高阶函数
本文首发于公众号“AntDream”,欢迎微信搜索“AntDream”或扫描文章底部二维码关注,和我一起每天进步一点点。文章详细解析了Kotlin中的协程、扩展函数、高阶函数、密封类及`inline`和`reified`关键字在Android开发中的应用,帮助读者更好地理解和使用这些特性。
38 1
|
3月前
|
Android开发 开发者 Kotlin
告别AsyncTask:一招教你用Kotlin协程重构Android应用,流畅度飙升的秘密武器
【9月更文挑战第13天】随着Android应用复杂度的增加,有效管理异步任务成为关键。Kotlin协程提供了一种优雅的并发操作处理方式,使异步编程更简单直观。本文通过具体示例介绍如何使用Kotlin协程优化Android应用性能,包括网络数据加载和UI更新。首先需在`build.gradle`中添加coroutines依赖。接着,通过定义挂起函数执行网络请求,并在`ViewModel`中使用`viewModelScope`启动协程,结合`Dispatchers.Main`更新UI,避免内存泄漏。使用协程不仅简化代码,还提升了程序健壮性。
111 1
|
5月前
|
安全 Android开发 Kotlin
Android经典面试题之Kotlin延迟初始化的by lazy和lateinit有什么区别?
**Kotlin中的`by lazy`和`lateinit`都是延迟初始化技术。`by lazy`用于只读属性,线程安全,首次访问时初始化;`lateinit`用于可变属性,需手动初始化,非线程安全。`by lazy`支持线程安全模式选择,而`lateinit`适用于构造函数后初始化。选择依赖于属性特性和使用场景。**
180 5
Android经典面试题之Kotlin延迟初始化的by lazy和lateinit有什么区别?
|
4月前
|
调度 Android开发 开发者
【颠覆传统!】Kotlin协程魔法:解锁Android应用极速体验,带你领略多线程优化的无限魅力!
【8月更文挑战第12天】多线程对现代Android应用至关重要,能显著提升性能与体验。本文探讨Kotlin中的高效多线程实践。首先,理解主线程(UI线程)的角色,避免阻塞它。Kotlin协程作为轻量级线程,简化异步编程。示例展示了如何使用`kotlinx.coroutines`库创建协程,执行后台任务而不影响UI。此外,通过协程与Retrofit结合,实现了网络数据的异步加载,并安全地更新UI。协程不仅提高代码可读性,还能确保程序高效运行,不阻塞主线程,是构建高性能Android应用的关键。
66 4
|
5月前
|
安全 Android开发 Kotlin
Android经典面试题之Kotlin中常见作用域函数
**Kotlin作用域函数概览**: `let`, `run`, `with`, `apply`, `also`. `let`安全调用并返回结果; `run`在上下文中执行代码并返回结果; `with`执行代码块,返回结果; `apply`配置对象后返回自身; `also`附加操作后返回自身
62 8