查看原文
其他

Kotlin 1.5 来了,Inline classes 了解一下?| 开发者说·DTalk

The following article is from AndroidPub Author fundroid

本文原作者: fundroid,原文发布于: AndroidPub

https://mp.weixin.qq.com/s/WIFTh3KjU6MUoF3tbUW8Ww



Kotlin 1.5 中的 Inline classes


我在 CSDN 写的第一篇技术文章就是关于 Kotlin 1.4 新特性的介绍,如今 CSDN 上都写了 200 多篇原创了,终于迎来了 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.5inline class Password(private val s: String)
//after 1.5 (For JVM backends)@JvmInlinevalue class Password(private val s: String)


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


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


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


如何安装 Kotlin 1.5


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


Tools > Kotlin > Configure Kotlin Plugin Updates


2. 配置 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 的所有特性,例如拥有成员变量、方法、初始化块等。
@JvmInlinevalue 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}
@JvmInlinevalue 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 那样的自动拆装箱。
@JvmInlinevalue 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) // boxedval list = listOf(color) // boxedval first = list.first() // unboxed back to primitive


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


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



对比其他类型


与 type aliases 的区别 ?


Inline class 与 type aliases 在概念上有点相似,都会在编译后被替换为被代理 (包装) 的类型,区别在于:
  • inline class 本身是实际存在的 Class 只是在字节码中被消除了并被替换为被包装类型;

  • type aliases 仅仅是个别名,它的类型就是被代理类的类型。

typealias NameTypeAlias = String
@JvmInlinevalue 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 方法的形式存在,因此虽然携带了更多信息,但实际上并不存在这样一个包装类实例:

@NotNullpublic 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 中正式转正,未来一定会被在更广泛地使用、发掘出更多应用场景。





长按右侧二维码

查看更多开发者精彩分享




"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。




 点击屏末 | 阅读原文 | 即刻报名参与 "开发者说·DTalk" 




您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存