在 GitHub 上編輯此頁面

明確的 Null

明確的 Null 是一個選擇加入的功能,它修改了 Scala 型別系統,這使得參考型別(任何延伸 AnyRef 的型別)不可為 Null

這表示以下程式碼將不再通過型別檢查

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

相反地,要將型別標記為可為 Null,我們使用 聯集型別

val x: String | Null = null // ok

可為 Null 的型別在執行期間可能具有 Null 值;因此,在不檢查其 Null 性的情況下選擇成員是不安全的。

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

明確的 Null 是透過 -Yexplicit-nulls 旗標啟用的。

請繼續閱讀以了解詳情。

新的型別階層

最初,Null 是所有參考型別的子型別。

"Original Type Hierarchy"

當啟用明確的 null 時,類型階層會變更,使得 Null 僅為 AnyMatchable 的子類型,而不是每個參考類型,這表示 null 不再是 AnyRef 及其子類型的值。

這是新的類型階層

"Type Hierarchy for Explicit Nulls"

抹除後,Null 仍然是所有參考類型的子類型(如 JVM 強制執行)。

使用 Null

為了讓使用可為 null 的值更為容易,我們建議在標準函式庫中加入一些公用程式。到目前為止,我們發現以下內容很有用

  • 擴充方法 .nn 以「捨棄」可為 null 性

    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(沒有明確的 null)。

    請參閱 UnsafeNulls 部分以取得更多詳細資訊。

不健全

新的類型系統對於 null 而言是不健全的。這表示仍然有某些情況,其中表達式具有不可為 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 只能與 Null、可為 null 的聯集(T | Null)或 Any 類型進行比較。

如果我們真的想要將 null 與非 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 中,類型參數永遠可為 null,所以以下程式碼會編譯。

    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
    
  • 我們可以減少需要新增的冗餘可為 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。這是必要的,因為我們的可為 null 函數只會(以模組化方式)套用於 Java 類別,但不會套用於 Scala 類別,所以我們需要一種方法來告訴 Box 它包含一個可為 null 的值。

    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()
    
  • 我們不會將 Null 附加到帶有 NotNull 註解的欄位或方法的回傳類型。

    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 是穩定的路徑或可追蹤變數,則當 pnull 比較時,我們可以知道 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 的直接子類型),則如果 T1T2 的子類型,使用一般子類型化規則(其中 Null 是每個參考類型的子類型),則為 T2 設計的擴充方法和隱式轉換可以用於 T1

  4. 假設 T1 不是 T2 的子類型,使用明確空值子類型化,則如果 T1T2 的子類型,使用一般子類型化規則,則類型為 T1 的值可以用作 T2

此外,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 二進位相容性的策略是保持型別不變,並相容但不健全。

實作細節