显式 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
是所有引用类型的一个子类型。
当显式空值被启用时,类型层次结构会发生变化,使得 Null
仅是 Any
和 Matchable
的子类型,而不是每个引用类型,这意味着 null
不再是 AnyRef
及其子类型的某个值。
这是新的类型层次结构
擦除后,Null
仍然是所有引用类型的一个子类型(由 JVM 强制执行)。
使用 Null
为了简化使用可空值,我们建议向标准库添加一些实用程序。到目前为止,我们发现了以下有用的方法
-
扩展方法
.nn
用于“消除”可空性extension [T](x: T | Null) inline def nn: T = assert(x != null) x.asInstanceOf[T]
这意味着给定
x: String | Null
,x.nn
具有类型String
,因此我们可以调用其上的所有常用方法。当然,如果x
为null
,x.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
选项由编译器捕获上述不健全性。可以在 安全初始化 中找到更多详细信息。
相等性
我们不再允许 AnyRef
和 Null
之间的双等(==
和 !=
)和引用(eq
和 ne
)比较,因为具有不可空类型的变量不能具有 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,因为已知它们不为 nullclass 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 语句的 then
和 else
分支(以及其他位置)。
示例
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
在处理局部可变变量时,有两个问题
-
是否在流类型期间跟踪局部可变变量。如果变量未在闭包中分配,则我们跟踪局部可变变量。例如,在以下代码中,
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
-
是否针对局部可变变量的特定使用生成并使用流类型。我们只希望对属于局部变量定义的相同方法的使用执行流类型。例如,在以下代码中,即使
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
的子类型),则在此不安全空值范围内应用以下不安全操作规则
-
T
的成员可以在T | Null
上找到 -
类型为
T
的值可以与T | Null
和Null
进行比较 -
假设
T1
不是T2
的子类型,使用显式空值子类型化(其中Null
是 Any 的直接子类型),则为T2
设计的扩展方法和隐式转换可用于T1
,如果T1
是T2
的子类型,则使用常规子类型化规则(其中Null
是每个引用类型的子类型) -
假设
T1
不是T2
的子类型,使用显式空值子类型化,则类型为T1
的值可用作T2
,如果T1
是T2
的子类型,则使用常规子类型化规则
此外,null
可用作 AnyRef
(Object
),这意味着您可以在其上选择 .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 二进制文件的二进制兼容性策略是不更改类型,并且兼容但不健全。