Effective Kotlin 翻译系列 - 第一章 - 条目 3 - 尽早消除平台类型

196 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 3 天,点击查看活动详情

📢📢📢 最近我们团队在翻译《Effective Kotlin: Best practices》,关注我们,我们会定时更新,欢迎指正,欢迎收藏 😁

翻译: fengerzhang 校对: jiajianchen

条目 3:尽早消除平台类型

TL;DR:

  1. 平台类型指的是从另一种语言获取并且未知可空性的类型;
  2. 我们可以通过改变编码方式来尽早消除平台类型;
  3. 对于公开的类、方法和参数应该尽量使用注解来指定可空性。

Kotlin 引入的空安全机制「null-safety」是很棒的。Java 因为 NPE「Null-Pointer Exceptions」的问题而饱受诟病,而 Kotlin 的安全机制成功减少甚至完全消除了 NPE。然而,在没有空安全机制的语言(如 Java 或者 C)和 Kotlin 之间的互相调用的场景下,这个问题并不能得到完全的解决。

想象一下,在 Java 中一个方法返回的类型是 String ,那么在 Kotlin 里面应该是个什么类型?

如果它使用了 @Nullable 注解的话,那么在 Kotlin 中我们应该将其视作 String? ;如果使用了 @NonNull 注解,我们则直接将其视为非空的 String,但是如果它并没有使用任何一个注解呢?

// Java
public class JavaTest {
    public String giveName() {
      // ...
    }
}

我们可能会统一把这种代码的返回值都视作可空的字符串,这样做确实很安全,因为在 Java 中所有东西都是可空的。然而当我们经常将对象视作非空的话,我们的代码最终将会难以避免地大量使用非空断言符号“ !! ”。

当我们需要从 Java 中获取泛型类型,这会带来更麻烦的问题。

假设有一个 Java API 返回的类型是List<User> ,并且不带任何注解,如果 Kotlin 默认将类型视为可空,那么当我们需要非空的 User 数据,我们不仅要对整个 List 增加非空断言,还要过滤一遍其中 User 的空值:

// Java
public class UserRepo {
    public List<User> getUsers() {
      // ...
    }
}

Kotlin 调用如下

val users: List<User> = UserRepo().users!!.filterNotNull()

甚至,如果一个方法返回类型是 List<List<User>> 的时候又该怎么做呢?

val users: List<List<User>> = UserRepo().groupedUsers!!.map { it!!.filterNotNull() }

List 至少还有 map 和 filterNotNull 这些能力,但是对于其他的泛型类,可空性会带来更麻烦的问题。

因此,我们将在 Java 中定义且未声明其是否可空的类型,在 Kotlin 中定义成一种特殊类型——平台类型「Platform Type」,而不是简单地将类型视为可空。

概括来说就是,平台类型指的是从其他语言传递过来的,但不知道其可空性的类型。

平台类型的表示方式是在其类型名称后加一个感叹号表示,例如 String! 。当然,这种表示方式并不能在实际代码中使用,也就是说不能直接在编程语言中写下来。当 Kotlin 的变量或属性被一个平台类型赋值或者分配的时候,我们可以去推断它但不能直接显式设置它。除此之外,我们可以自己选择想要使用的类型:可空或者非空类型。

val repo = UserRepo() // Java 类
val user1 = repo.user // user1 的类型是平台类型,即 User!
val user2: User = repo.user // 可以定义 user2 为非空类型 User
val user3: User? = repo.user // 可以定义 user3 为可空类型 User?

基于上述事实,从 Java 中获取泛型也不成问题:

val users: List<User> = UserRepo().users
val users: List<List<User>> = UserRepo().groupedUsers

这种做法存在风险,比如我们认为某个值是非空的,但实际上这个值是可空的。出于安全考虑,我建议从 Java 获取一个平台类型的时候,需要慎重考虑接收的类型。即使一个函数现在的返回值不是 null,也不意味着它以后也永远不会返回 null。如果代码设计者没有在注解或者注释中确切的指定其是否可空,他们可以在不改变代码契约的情况下引入上述的这种行为。

如果你需要执行 Java 和 Kotlin 互调的操作,请引入 @Nullable@NotNull 注解。

// Java
import org.jetbrains.annotations.NotNull;

public class UserRepo {
    public @NotNull User getUser() {
        // ...
    }
}

当我们需要更好地支持 Kotlin 开发时,这一步非常重要(对 Java 开发也同样重要)。在 Kotlin 成为主流的编程语言(成为 Android 开发的推荐语言)之后,Android 官方对 API 其中一个非常重要的改动是,对许多的公开类型都增加了下面的注解,这也让 Android API 对 Kotlin 使用变得更加的友好。

目前已经支持许多种不同类型的注解,包括以下这些:

  • JetBrains(在org.jetbrains.annotations库中定义的 @Nullable@NotNull
  • Android(在androidx.annotation / com.android.annotations / android.support.annotations 库中定义的 @Nullable@NonNull
  • JSR-305 (在 javax.annotation 库中定义的 @Nullable@CheckForNull@Nonnull
  • JavaX(在 javax.annotation 库中定义的 @Nullable@CheckForNull@Nonnull
  • FindBugs(在 edu.umd.cs.findbugs.annotations 库中定义的 @Nullable@CheckForNull@PossiblyNull@NonNull
  • ReactiveX(在 io.reactivex.annotations 库中定义的 @Nullable@NonNull
  • Eclipse(在 org.eclipse.jdt.annotation 库中定义的 @Nullable@NonNull
  • Lombok(在 lombok 库中定义的 @NonNull

或者,你也可以使用 JSR 305 中定义的 @ParametersAreNonnullByDefault 注解,,这个注解可以将 Java 中的所有类型默认标记为非空。

我们也可以在 Kotlin 的代码中做一些修改。**我建议是完全消除这些平台类型来确保代码安全。**原因可以参考下面的示例中 statedType()platformType() 两个函数结果之间的差异:

// Java
public class JavaClass {
    public String getValue() {
        return null;
    }
}
// Kotlin 调用
fun statedType() {
  val value: String = JavaClass().value // 建议的做法:尽早消除平台类型
  // ...
  println(value.length)
}

fun platformType() {
  val value = JavaClass().value // 不建议的做法:平台类型会一直传递下去
  // ...
  println(value.length)
}

在这两种情况下,如果开发人员都认为 getValue() 不会返回 null ,但是实际上却返回了 null 值的话,这在两种情况下都会导致 NPE,但是错误发生的位置有所不同。

  • stateType() 中,NPE 会在我们从 Java 中获取值的同一行中抛出。很明显,我们错误地将返回值假定为非空类型并最终接收了空值。我们只需要更改这个类型为可空并调整其余代码以适应此更改。
  • platformType() 中,当我们将此值用作不可为空时,可能会在一些更复杂的表达式的中间抛出 NPE。当变量的类型是平台类型时可以被视为可空或者不可空,这样的变量可能可以安全地使用几次,然后最后不安全地抛出 NPE。当我们使用这些属性时,编译器并不能识别出来它的可空性,这跟在 Java 中的情况很像。但在 Koltin 中,我们并不期望在使用一个对象时产生 NPE。迟早有人会不安全地使用它,并且最后得到运行时异常,这里出现异常的原因还不太容易能找到。
fun statedType() {
  val value: String = JavaClass().value // NPE
  // ...
  println(value.length)
}

fun platformType() {
  val value = JavaClass().value
  // ...
  println(value.length) // NPE
}

更危险的是,平台类型可能会被进一步传播。例如,我们可能会将一个平台类型作为我们接口的一部分:

interface UserRepo {
    fun getUserName() = JavaClass().value
}

在这种情况下,方法推断类型是平台类型。这意味着任何人都可以决定它是否可以为空。甚至可能在定义时将其视为可为空的,而在使用时其视为不可为空的:

class RepoImpl: UserRepo {
    override fun getUserName(): String? {
        return null
    }
}

fun main() {
    val repo: UserRepo = RepoImpl()
    val text: String = repo.getUserName() // NPE in runtime
    print("User name length is ${text.length}")
}

平台类型的传递是导致问题的根本原因。基于安全考虑,我们应该尽早消除它们。在这种情况下,IDEA IntelliJ 会发出警告:

idea warning

总结

来自另外一种语言获取并且未知可空性的类型称为平台类型。这种类型具有一定的风险,应该被尽早消除,并且不让他们传递下去。其中一个好的做法是在公开的 Java 构造函数、方法和字段上使用注解来指定可空性,因为这些元素对于 Java 和 Kotlin 开发人员来说都是非常宝贵的信息。