Kotlin Vocabulary | 内联类 inline class

Kotlin Vocabulary | 内联类 inline class

*特定条件和情况
这篇博客描述了一个 Kotlin 试验性功能,它还在调整之中。本文基于 Kotlin 1.3.50 撰写。

类型安全帮助我们防止出现错误以及避免回过头去调试错误。对于 Android 资源文件,比如 String、Font 或 Animation 资源,我们可以使用 androidx.annotations,通过使用像 @StringRes、@FontRes 这样的注解,就可以让代码检查工具 (如 Lint) 限制我们只能传递正确类型的参数:

fun myStringResUsage(@StringRes string: Int){ }
 
// 错误: 需要 String 类型的资源
myStringResUsage(1)

扩展阅读:

如果我们的 ID 对应的不是 Android 资源,而是 Doggo 或 Cat 之类的域对象,那么就会很难区分这两个同为 Int 类型的 ID。为了实现类型安全,需要将 ID 包装在一个类中,从而使狗与猫的 ID 编码为不同的类型。这样做的缺点是您要付出额外的性能成本,因为本来只需要一个原生类型,但是却实例化出来了一个新的对象。

通过 Kotlin 内联类 您可以创建包装类型 (wrapper type),却不会有额外的性能消耗。这是 Kotlin 1.3 中添加的实验性功能。内联类只能有一个属性。在编译时,内联类会在可能的地方被替换为其内部的属性 (取消装箱),从而降低常规包装类的性能成本。对于包装对象是原生类型的情况,这尤其重要,因为编译器已经对它们进行了优化。所以将一个原始数据类型包装在内联类里就意味着,在可能的情况下,数据值会以原始数据值的形式出现。

inline class DoggoId(val id: Long)
data class Doggo(val id: DoggoId, … )
 
// 用法
val goodDoggo = Doggo(DoggoId(doggoId), …)
fun pet(id: DoggoId) { … }}

内联

内联类的唯一作用是成为某种类型的包装,因此 Kotlin 对其施加了许多限制:

  • 最多一个参数 (类型不受限制)
  • 没有 backing fields
  • 不能有 init 块
  • 不能继承其他类

不过,内联类可以做到:

  • 从接口继承
  • 具有属性和方法
interface Id
inline class DoggoId(val id: Long) : Id {
  val stringId
  get() = id.toString()

  fun isValid()= id > 0L

}

⚠️ 注意: Typealias 看起来与内联类相似,但是类型别名只是为现有类型提供了可选名称,而内联类则创建了新类型。

声明对象 —— 包装还是不包装?

由于内联类相对于手动包装类型的最大优势是对内存分配的影响,因此请务必记住,这种影响很大程度上取决于您在何处以及如何使用内联类。一般规则是,如果将内联类用作另一种类型,则会对参数进行包装 (装箱)。

参数被用作其他类型时会被装箱。

比如,需要在集合、数组中用到 Object 或者 Any 类型;或者需要 Object 或者 Any 作为可空对象时。根据您比较两个内联类结构的方式的不同,会最终造成 (内联类) 其中一个参数被装箱,也或者所有参数都不会被装箱。

val doggo1 = DoggoId(1L)
val doggo2 = DoggoId(2L)
  • doggo1 == doggo2 — doggo1 和 doggo2 都没有被装箱
  • doggo1.equals(doggo2) — doggo1 是原生类型但是 doggo2 被装箱了

工作原理

让我们实现一个简单的内联类:

interface Id
inline class DoggoId(val id: Long) : Id

让我们逐步分析反编译后的 Java 代码,并分析它们对使用内联类的影响。您可以在下方注释找到 完整的反编译代码

原理 —— 构造函数

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
public final class DoggoId implements Id {
   // $FF: synthetic method
   private DoggoId(long id) {
      this.id = id;
   }

   public static long constructor_impl/* $FF was: constructor-impl*/(long id) {
      return id;
   }
}

DoggoId 有两个构造函数:

  • 私有合成构造函数 DoggoId(long id)
  • 公共构造函数

创建对象的新实例时,将使用公共构造函数:

val myDoggoId = DoggoId(1L)
 
// 反编译过的代码
static final long myDoggoId = DoggoId.constructor-impl(1L);

如果尝试使用 Java 创建 Doggo ID,则会收到一个错误:

DoggoId u = new DoggoId(1L);
// 错误: DoggoId 中的 DoggoId() 方法无法使用 long 类型

您无法在 Java 中实例化内联类。

有参构造函数是私有的,第二个构造函数的名字中包含了一个 "-",其在 Java 中为无效字符。这意味着无法从 Java 实例化内联类。

原理 —— 参数用法

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
public final class DoggoId implements Id {
   private final long id;

   public final long getId() {
      return this.id;
   }

   // $FF: synthetic method
   @NotNull
   public static final DoggoId box_impl/* $FF was: box-impl*/(long v) {
      return new DoggoId(v);
   }
}

参数 id 通过两种方式暴露给外界:

  • 通过 getId() 作为原生类型;
  • 作为一个对象: box_impl 方法会创建一个 DoggoId 实例。

如果在可以使用原生类型的地方使用内联类,则 Kotlin 编译器将知道这一点,并会直接使用原生类型:

fun walkDog(doggoId: DoggoId) {}

// 反编译后的 Java 代码
public final void walkDog_Mu_n4VY(**long** doggoId) { }

**当需要一个对象时,Kotlin 编译器将使用原生类型的包装版本,**从而每次都创建一个新的对象。

当需要一个对象时,Kotlin 编译器将使用原生类型的包装版本,从而每次都创建一个新的对象,例如:

可空对象

fun pet(doggoId: DoggoId?) {}
 
// 反编译后的 Java 代码
public static final void pet_5ZN6hPs/* $FF was: pet-5ZN6hPs*/(@Nullable InlineDoggoId doggo) {}

因为只有对象可以为空,所以使用被装箱的实现。

集合

val doggos = listOf(myDoggoId)
 
// 反编译后的 Java 代码
doggos = CollectionsKt.listOf(DoggoId.box-impl(myDoggoId));

CollectionsKt.listOf 的方法签名是:

fun <T> listOf(element: T): List<T>

因为此方法需要一个对象,所以 Kotlin 编译器将原生类型装箱,以确保使用的是对象。

基类

fun handleId(id: Id) {}
fun myInterfaceUsage() {
    handleId(myDoggoId)
}
 
// 反编译后的 Java 代码
public static final void myInterfaceUsage() {
    handleId(DoggoId.box-impl(myDoggoId));
}

因为这里需要的参数类型是超类: Id,所以这里使用了装箱的实现。

原理 —— 相等性检查

Kotlin 编译器会在所有可能的地方使用非装箱类型参数。为了达到这个目的,内联类有三个不同的相等性检查的方法的实现: 重写的 equals 方法和两个自动生成的方法:

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
public final class DoggoId implements Id {
   public static boolean equals_impl/* $FF was: equals-impl*/(long var0, @Nullable Object var2) {
      if (var2 instanceof DoggoId) {
         long var3 = ((DoggoId)var2).unbox-impl();
         if (var0 == var3) {
            return true;
         }
      }

      return false;
   }

   public static final boolean equals_impl0/* $FF was: equals-impl0*/(long p1, long p2) {
      return p1 == p2;
   }

   public boolean equals(Object var1) {
      return equals-impl(this.id, var1);
   }
}

doggo1.equals(doggo2)

这种情况下,equals 方法会调用另一个生成的方法: equals_impl(long, Object)。因为 equals 方法需要一个 Object 参数,所以 doggo2 的值会被装箱,而 doggo1 将会使用原生类型:

DoggoId.equals-impl(doggo1, DoggoId.box-impl(doggo2))

doggo1 == doggo2

使用 == 会生成:

DoggoId.equals-impl0(doggo1, doggo2)

所以在使用 == 时,doggo1 和 doggo2 都会使用原生类型。

doggo1 == 1L

如果 Kotlin 可以确定 doggo1 事实上是长整型,那这里的相等性检查就应该是有效的。不过,因为我们为了它们的类型安全而使用的是内联类,所以,接下来编译器会首先对两个对象进行类型检查,以判断我们拿来比较的两个对象是否为同一类型。由于它们不是同一类型,我们会看到一个编译器报错: "Operator == can’t be applied to long and DoggoId" (== 运算符无法用于长整形和 DoggoId)。对编译器来说,这种比较就好像是判断 cat1 == doggo1 一样,毫无疑问结果不会是 true。

doggo1.equals(1L)

这里的相等检查可以编译通过,因为 Kotlin 编译器使用的 equals 方法的实现所需要的参数可以是一个长整形和一个 Object。但是因为这个方法首先会进行类型检查,所以相等检查将会返回 false,因为 Object 不是 DoggoId。

覆盖使用原生类型和内联类作为参数的函数

定义一个方法时,Kotlin 编译器允许使用原生类型和不可空内联类作为参数:

fun pet(doggoId: Long) {}
fun pet(doggoId: DoggoId) {}
 
// 反编译的 Java 代码
public static final void pet(long id) { }
public final void pet_Mu_n4VY(long doggoId) { }

在反编译出的代码中,我们可以看到这两种函数,它们的参数都是原生类型。

为了实现此功能,Kotlin 编译器会改写函数的名称,并使用内联类作为函数参数。

在 Java 中使用内联类

我们已经讲过,不能在 Java 中实例化内联类。那可不可以使用呢?

✅ 可以将内联类传递给 Java 函数

我们可以将内联类作为参数传递,它们将会作为对象被使用。我们也可以获取其中包装的属性:

void myJavaMethod(DoggoId doggoId){
    long id = doggoId.getId();
}

在 Java 函数中使用内联类实例

如果我们将内联类声明为顶层对象,就可以在 Java 中以原生类型获得它们的引用,如下:

// Kotlin 的声明
val doggo1 = DoggoId(1L)
 
// Java 的使用
long myDoggoId = GoodDoggosKt.getU1();

✅ & ❌调用参数中含有内联类的 Kotlin 函数

如果我们有一个 Java 函数,它接收一个内联类对象作为参数。函数中调用一个同样接收内联类作为参数的 Kotlin 函数。这种情况下,我们会看到一个编译器报错:

fun pet(doggoId: DoggoId) {}

// Java
void petInJava(doggoId: DoggoId){
    pet(doggoId)
    // 编译器报错: pet(long) cannot be applied to pet(DoggoId)  (pet(长整形) 不能用于 pet(DoggoId))
}

对于 Java 来说,DoggoId 是一个新类型,但是编译器生成的 pet(long) 和 pet(DoggoId) 并不存在。

但是,我们还是可以传递底层类型:

fun pet(doggoId: DoggoId) {}

// Java
void petInJava(doggoId: DoggoId){
    pet(doggoId.getId)
}

如果在一个类中,我们分别覆盖了使用内联类作为参数和使用底层类型作为参数的两个函数,当我们从 Java 中调用这些函数时,就会报错。因为编译器会不知道我们到底想要调用哪个函数:

fun pet(doggoId: Long) {}

fun pet(doggoId: DoggoId) {}

// Java
TestInlineKt.pet(1L);

Error: Ambiguous method call. Both pet(long) and pet(long) match

内联类: 使用还是不使用,这是一个问题

类型安全可以帮助我们写出更健壮的代码,但是经验上来说可能会对性能产生不利的影响。内联类提供了一个两全其美的解决方案 —— 没有额外消耗的类型安全。所以我们就应该总是使用它们吗?

内联类带来了一系列的限制,使得您创建的对象只能做一件事: 成为包装器。这意味着未来,不熟悉这段代码的开发者,也没法像在数据类中那样,可以给构造函数添加参数,从而导致类的复杂度被错误地增加。

在性能方面,我们已经看到 Kotlin 编译器会尽其所能使用底层类型,但在许多情况下仍然会创建新对象。

在 Java 中使用内联类时仍然有诸多限制,如果您还没有完全迁移到 Kotlin,则可能会遇到无法使用的情况。

最后,这仍然是一项实验性功能。它是否会发布正式版,以及正式版发布时,它的实现是否与现在相同,都还是未知数。

因此,既然您了解了内联类的好处和限制,就可以在是否以及何时使用它们的问题上做出明智的决定。

版权声明

禁止一切形式的转载-禁止商用-禁止衍生 申请授权

脉脉不得语
脉脉不得语
Zhengzhou Website
菜鸟 Android 开发一枚/前郑州 GDG 组织者/Android 开发技术周报站长/AndroidDevTools 站长/Toast.show(∞) 播客主播

你已经成功订阅到 Android 开发技术周报
太棒了!接下来,完成检验以获得全部访问权限 Android 开发技术周报
欢迎回来!你已经成功登录了。
成功!您的帐户已完全激活,您现在可以访问所有内容。