明確的 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
是所有參考型別的子型別。
當啟用明確的 null 時,類型階層會變更,使得 Null
僅為 Any
和 Matchable
的子類型,而不是每個參考類型,這表示 null
不再是 AnyRef
及其子類型的值。
這是新的類型階層
抹除後,Null
仍然是所有參考類型的子類型(如 JVM 強制執行)。
使用 Null
為了讓使用可為 null 的值更為容易,我們建議在標準函式庫中加入一些公用程式。到目前為止,我們發現以下內容很有用
-
擴充方法
.nn
以「捨棄」可為 null 性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(沒有明確的 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
由編譯器捕獲。可以在 安全初始化 中找到更多詳細資訊。
相等性
我們不再允許在 AnyRef
和 Null
之間進行雙等(==
和 !=
)和參考(eq
和 ne
)比較,因為具有不可為 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,因為它們已知是非 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()
-
我們不會將
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
是穩定的路徑或可追蹤變數,則當 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 的直接子類型),則如果T1
是T2
的子類型,使用一般子類型化規則(其中Null
是每個參考類型的子類型),則為T2
設計的擴充方法和隱式轉換可以用於T1
-
假設
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 二進位相容性的策略是保持型別不變,並相容但不健全。