在 GitHub 上编辑此页面

显式 Null

显式空值是一种选择加入功能,它修改了 Scala 类型系统,该系统使引用类型(扩展 AnyRef 的任何内容)不可空

这意味着以下代码将不再进行类型检查

val x: String = null // error: found `Null`, but required `String`

相反,要将类型标记为可空,我们使用 联合类型

val x: String | Null = null // ok

可空类型在运行时可能具有空值;因此,在不检查其空值的情况下选择成员是不安全的。

x.trim // error: trim is not member of String | Null

通过 -Yexplicit-nulls 标志启用显式空值。

继续阅读以了解详细信息。

新类型层次结构

最初,Null 是所有引用类型的一个子类型。

"Original Type Hierarchy"

当显式空值被启用时,类型层次结构会发生变化,使得 Null 仅是 AnyMatchable 的子类型,而不是每个引用类型,这意味着 null 不再是 AnyRef 及其子类型的某个值。

这是新的类型层次结构

"Type Hierarchy for Explicit Nulls"

擦除后,Null 仍然是所有引用类型的一个子类型(由 JVM 强制执行)。

使用 Null

为了简化使用可空值,我们建议向标准库添加一些实用程序。到目前为止,我们发现了以下有用的方法

  • 扩展方法 .nn 用于“消除”可空性

    extension [T](x: T | Null)
       inline def nn: T =
         assert(x != null)
         x.asInstanceOf[T]
    

    这意味着给定 x: String | Nullx.nn 具有类型 String,因此我们可以调用其上的所有常用方法。当然,如果 xnullx.nn 将抛出 NPE。

    不要直接在可变变量上使用 .nn,因为它可能会在变量的类型中引入一个未知类型。

  • 一个 unsafeNulls 语言特性。

    导入后,T | Null 可用作 T,类似于常规 Scala(没有显式空值)。

    有关更多详细信息,请参阅 UnsafeNulls 部分。

不健全性

新的类型系统对于 null 来说是不健全的。这意味着仍然存在表达式具有不可空类型(如 String)的情况,但其值实际上为 null

不健全性发生是因为类中未初始化的字段最初为 null

class C:
  val f: String = foo(f)
  def foo(f2: String): String = f2

val c = new C()
// c.f == "field is null"

可以使用 -Ysafe-init 选项由编译器捕获上述不健全性。可以在 安全初始化 中找到更多详细信息。

相等性

我们不再允许 AnyRefNull 之间的双等(==!=)和引用(eqne)比较,因为具有不可空类型的变量不能具有 null 作为值。null 只能与 Null、可空联合(T | Null)或 Any 类型进行比较。

由于某种原因,如果我们真的想将 null 与非空值进行比较,我们必须提供类型提示(例如 : Any)。

val x: String = ???
val y: String | Null = ???

x == null       // error: Values of types String and Null cannot be compared with == or !=
x eq null       // error
"hello" == null // error

y == null       // ok
y == x          // ok

(x: String | Null) == null  // ok
(x: Any) == null            // ok

Java 互操作性

Scala 编译器可以通过两种方式加载 Java 类:从源代码或字节码。在任何一种情况下,当加载 Java 类时,我们都会“修补”其成员的类型,以反映 Java 类型仍然隐式可为空。

具体来说,我们修补

  • 字段的类型

  • 方法的参数类型和返回类型

我们通过以下示例来说明规则

  • 前两个规则很简单:我们使引用类型为 null,但不会使值类型为 null。

    class C {
      String s;
      int x;
    }
    

    ==>

    class C:
      val s: String | Null
      val x: Int
    
  • 我们使类型参数为 null,因为在 Java 中,类型参数始终可为空,因此以下代码可以编译。

    class C<T> { T foo() { return null; } }
    

    ==>

    class C[T] { def foo(): T | Null }
    

    请注意,此规则有时过于保守,如下所示

    class InScala:
      val c: C[Bool] = ???  // C as above
      val b: Bool = c.foo() // no longer typechecks, since foo now returns Bool | Null
    
  • 我们可以减少需要添加的冗余可空类型的数量。考虑

    class Box<T> { T get(); }
    class BoxFactory<T> { Box<T> makeBox(); }
    

    ==>

    class Box[T] { def get(): T | Null }
    class BoxFactory[T] { def makeBox(): Box[T] | Null }
    

    假设我们有一个 BoxFactory[String]。请注意,对其调用 makeBox() 会返回一个 Box[String] | Null,而不是 Box[String | Null] | Null。乍一看这似乎不合理(“如果盒子本身内部有 null 呢?”),但这是合理的,因为对 Box[String] 调用 get() 会返回一个 String | Null

    请注意,我们需要修补从正在编译的 Scala 代码可访问的字段或方法的参数或返回类型中传递出现的所有 Java 定义的类。如果没有疯狂的反射魔术,我们认为所有此类 Java 类必须首先对 Typer 可见,因此它们将被修补。

  • 如果泛型类在 Scala 中定义,我们将向类型参数追加 Null

    class BoxFactory<T> {
      Box<T> makeBox(); // Box is Scala-defined
      List<Box<List<T>>> makeCrazyBoxes(); // List is Java-defined
    }
    

    ==>

    class BoxFactory[T]:
      def makeBox(): Box[T | Null] | Null
      def makeCrazyBoxes(): java.util.List[Box[java.util.List[T] | Null]] | Null
    

    在这种情况下,由于 Box 是 Scala 定义的,我们将得到 Box[T | Null] | Null。这是必需的,因为我们的可空性函数仅(模块化地)应用于 Java 类,而不应用于 Scala 类,因此我们需要一种方法来告诉 Box 它包含一个可空值。

    List 是 Java 定义的,因此我们不会向其类型参数追加 Null。但我们仍然需要使其内部为 null。

  • 我们不会使简单的文字常量 (final) 字段为 null,因为已知它们不为 null

    class Constants {
      final String NAME = "name";
      final int AGE = 0;
      final char CHAR = 'a';
    
      final String NAME_GENERATED = getNewName();
    }
    

    ==>

    class Constants:
      val NAME: String("name") = "name"
      val AGE: Int(0) = 0
      val CHAR: Char('a') = 'a'
    
      val NAME_GENERATED: String | Null = getNewName()
    
  • 我们不会向使用 NotNull 注解注释的字段或方法的返回类型追加 Null

    class C {
      @NotNull String name;
      @NotNull List<String> getNames(String prefix); // List is Java-defined
      @NotNull Box<String> getBoxedName(); // Box is Scala-defined
    }
    

    ==>

    class C:
      val name: String
      def getNames(prefix: String | Null): java.util.List[String] // we still need to nullify the paramter types
      def getBoxedName(): Box[String | Null] // we don't append `Null` to the outmost level, but we still need to nullify inside
    

    该注释必须来自以下列表,才能被编译器识别为 NotNull。查看 Definitions.scala 以获取更新的列表。

    // A list of annotations that are commonly used to indicate
    // that a field/method argument or return type is not null.
    // These annotations are used by the nullification logic in
    // JavaNullInterop to improve the precision of type nullification.
    // We don't require that any of these annotations be present
    // in the class path, but we want to create Symbols for the
    // ones that are present, so they can be checked during nullification.
    @tu lazy val NotNullAnnots: List[ClassSymbol] = ctx.getClassesIfDefined(
      "javax.annotation.Nonnull" ::
      "edu.umd.cs.findbugs.annotations.NonNull" ::
      "androidx.annotation.NonNull" ::
      "android.support.annotation.NonNull" ::
      "android.annotation.NonNull" ::
      "com.android.annotations.NonNull" ::
      "org.eclipse.jdt.annotation.NonNull" ::
      "org.checkerframework.checker.nullness.qual.NonNull" ::
      "org.checkerframework.checker.nullness.compatqual.NonNullDecl" ::
      "org.jetbrains.annotations.NotNull" ::
      "lombok.NonNull" ::
      "io.reactivex.annotations.NonNull" :: Nil map PreNamedString)
    

覆盖检查

当我们检查 Scala 类和 Java 类之间的覆盖时,对于 Null 类型,规则会放松此功能,以便帮助用户使用 Java 库。

假设我们有 Java 方法 String f(String x),我们可以使用以下任何形式在 Scala 中重写此方法

def f(x: String | Null): String | Null

def f(x: String): String | Null

def f(x: String | Null): String

def f(x: String): String

请注意,某些定义可能会导致不健全。例如,返回类型不可为空,但实际返回了一个 null 值。

流类型

我们添加了流敏感类型推断的简单形式。其思想是,如果 p 是一个稳定的路径或可跟踪变量,那么如果它与 null 比较,我们就可以知道 p 是非空的。然后,此信息可以传播到 if 语句的 thenelse 分支(以及其他位置)。

示例

val s: String | Null = ???
if s != null then
  // s: String

// s: String | Null

assert(s != null)
// s: String

如果测试为 p == null,则可以对 else 用例进行类似的推断

if s == null then
  // s: String | Null
else
  // s: String

对于流推断的目的,==!= 被视为比较。

逻辑运算符

我们还支持逻辑运算符(&&||!

val s: String | Null = ???
val s2: String | Null = ???
if s != null && s2 != null then
  // s: String
  // s2: String

if s == null || s2 == null then
  // s: String | Null
  // s2: String | Null
else
  // s: String
  // s2: String

条件内部

我们还支持条件内部的类型专业化,考虑到 &&|| 是短路运算

val s: String | Null = ???

if s != null && s.length > 0 then // s: String in `s.length > 0`
  // s: String

if s == null || s.length > 0 then // s: String in `s.length > 0`
  // s: String | Null
else
  // s: String

匹配用例

可以在匹配语句中检测到非空用例。

val s: String | Null = ???

s match
  case _: String => // s: String
  case _ =>

可变变量

我们能够检测到一些局部可变变量的空值性。一个简单的例子是

class C(val x: Int, val next: C | Null)

var xs: C | Null = C(1, C(2, null))
// xs is trackable, since all assignments are in the same method
while xs != null do
  // xs: C
  val xsx: Int = xs.x
  val xscpy: C = xs
  xs = xscpy // since xscpy is non-null, xs still has type C after this line
  // xs: C
  xs = xs.next // after this assignment, xs can be null again
  // xs: C | Null

在处理局部可变变量时,有两个问题

  1. 是否在流类型期间跟踪局部可变变量。如果变量未在闭包中分配,则我们跟踪局部可变变量。例如,在以下代码中,x 由闭包 y 分配,因此我们不对 x 执行流类型。

    var x: String | Null = ???
    def y =
      x = null
    
    if x != null then
       // y can be called here, which would break the fact
       val a: String = x // error: x is captured and mutated by the closure, not trackable
    
  2. 是否针对局部可变变量的特定使用生成并使用流类型。我们只希望对属于局部变量定义的相同方法的使用执行流类型。例如,在以下代码中,即使 x 未由闭包分配,我们也只能在其中一个出现处使用流类型(因为另一个出现处发生在嵌套闭包中)。

    var x: String | Null = ???
    def y =
      if x != null then
        // not safe to use the fact (x != null) here
        // since y can be executed at the same time as the outer block
        val _: String = x
    if x != null then
      val a: String = x // ok to use the fact here
      x = null
    

请参阅 更多示例

目前,我们无法跟踪具有可变变量前缀的路径。例如,如果 x 是可变的,则为 x.a

不支持的惯用法

我们不支持

  • 与可空性无关的流事实(if x == 0 then { // x: 0.type 未推断 }

  • 跟踪不可空路径之间的别名

    val s: String | Null = ???
    val s2: String | Null = ???
    if s != null && s == s2 then
      // s:  String inferred
      // s2: String not inferred
    

UnsafeNulls

处理许多可空值很困难,我们引入了一个语言特性 unsafeNulls。在此“不安全”范围内,所有 T | Null 值均可用作 T

用户可以导入 scala.language.unsafeNulls 来创建此类范围,或使用 -language:unsafeNulls 来全局启用此特性(仅用于迁移目的)。

假设 T 是一个引用类型(AnyRef 的子类型),则在此不安全空值范围内应用以下不安全操作规则

  1. T 的成员可以在 T | Null 上找到

  2. 类型为 T 的值可以与 T | NullNull 进行比较

  3. 假设 T1 不是 T2 的子类型,使用显式空值子类型化(其中 Null 是 Any 的直接子类型),则为 T2 设计的扩展方法和隐式转换可用于 T1,如果 T1T2 的子类型,则使用常规子类型化规则(其中 Null 是每个引用类型的子类型)

  4. 假设 T1 不是 T2 的子类型,使用显式空值子类型化,则类型为 T1 的值可用作 T2,如果 T1T2 的子类型,则使用常规子类型化规则

此外,null 可用作 AnyRefObject),这意味着您可以在其上选择 .eq.toString

unsafeNulls 中的程序将具有与常规 Scala 类似的语义,但等效。

例如,即使使用不安全空值,也无法编译以下代码。由于 Java 互操作,get 方法的类型变为 T | Null

def head[T](xs: java.util.List[T]): T = xs.get(0) // error

由于编译器不知道 T 是否为引用类型,因此无法将 T | Null 转换为 T。用户需要在 xs.get(0) 后手动插入 .nn 以修复错误,这会从其类型中删除 Null

unsafeNulls 的目的是为显式空值提供更好的迁移路径。Scala 2 或常规 Scala 3 的项目可以通过将 -Yexplicit-nulls -language:unsafeNulls 添加到编译选项中来尝试此操作。预计会进行少量的手动修改。要将来迁移到完整的显式空值特性,可以删除 -language:unsafeNulls,仅在需要时添加 import scala.language.unsafeNulls

def f(x: String): String = ???
def nullOf[T >: Null]: T = null

import scala.language.unsafeNulls

val s: String | Null = ???
val a: String = s // unsafely convert String | Null to String

val b1 = s.trim // call .trim on String | Null unsafely
val b2 = b1.length

f(s).trim // pass String | Null as an argument of type String unsafely

val c: String = null // Null to String

val d1: Array[String] = ???
val d2: Array[String | Null] = d1 // unsafely convert Array[String] to Array[String | Null]
val d3: Array[String] = Array(null) // unsafe

class C[T >: Null <: String] // define a type bound with unsafe conflict bound

val n = nullOf[String] // apply a type bound unsafely

如果没有 unsafeNulls,所有这些不安全操作都将不会进行类型检查。

unsafeNulls 也适用于扩展方法和隐式搜索。

import scala.language.unsafeNulls

val x = "hello, world!".split(" ").map(_.length)

given Conversion[String, Array[String]] = _ => ???

val y: String | Null = ???
val z: Array[String | Null] = y

二进制兼容性

我们对早于显式空值和未编译 -Yexplicit-nulls 的新库的 Scala 二进制文件的二进制兼容性策略是不更改类型,并且兼容但不健全。

实现细节