範例
為了了解問題,我們選擇以下具體範例。
abstract class A {
val x1: String
val x2: String = "mom"
println("A: " + x1 + ", " + x2)
}
class B extends A {
val x1: String = "hello"
println("B: " + x1 + ", " + x2)
}
class C extends B {
override val x2: String = "dad"
println("C: " + x1 + ", " + x2)
}
讓我們透過 Scala REPL 觀察初始化順序
scala> new C
A: null, null
B: hello, null
C: hello, dad
只有當我們進入 C
的建構函式時,x1
和 x2
才會被初始化。因此,A
和 B
的建構函式有發生 NullPointerException
的風險。
說明
「嚴格」或「急切」的 val 是未標記為延遲的 val。
在沒有「早期定義」(見下文)的情況下,嚴格 val 的初始化會按照下列順序進行。
- 超類別會在子類別之前完全初始化。
- 否則,會按照宣告順序初始化。
自然地,當一個 val 被覆寫時,它不會被初始化超過一次。因此,儘管在上述範例中 x2 似乎在每個點都被定義,但實際上並非如此:一個被覆寫的 val 會在建立超類別期間顯示為 null,抽象 val 也是如此。
有一個編譯器旗標可以用來識別這種情況
-Xcheckinit:將執行時期檢查新增到欄位存取器。
不建議在測試之外使用這個旗標。它會透過在所有潛在未初始化的欄位存取周圍加上一個包裝器來大幅增加程式碼大小:包裝器會擲出例外,而不是讓 null(或在基本類型的情況下為 0/false)靜默出現。另請注意,這會新增一個執行時期檢查:它只能告訴你關於你使用它執行的程式碼路徑的任何資訊。
在開頭範例中使用它
% scalac -Xcheckinit a.scala
% scala -e 'new C'
scala.UninitializedFieldError: Uninitialized field: a.scala: 13
at C.x2(a.scala:13)
at A.<init>(a.scala:5)
at B.<init>(a.scala:7)
at C.<init>(a.scala:12)
解決方案
避免 null 值的方法包括
使用延遲 val
abstract class A {
val x1: String
lazy val x2: String = "mom"
println("A: " + x1 + ", " + x2)
}
class B extends A {
lazy val x1: String = "hello"
println("B: " + x1 + ", " + x2)
}
class C extends B {
override lazy val x2: String = "dad"
println("C: " + x1 + ", " + x2)
}
// scala> new C
// A: hello, dad
// B: hello, dad
// C: hello, dad
通常是最好的答案。遺憾的是,你無法宣告一個抽象延遲 val。如果你追求的是這個,你的選項包括
- 宣告一個抽象嚴格 val,並希望子類別會將它實作為延遲 val 或使用早期定義。如果它們沒有這樣做,它會在建立期間的某些點顯示為未初始化。
- 宣告一個抽象 def,並希望子類別會將它實作為延遲 val。如果它們沒有這樣做,它會在每次存取時重新評估。
- 宣告一個會擲出例外的具體延遲 val,並希望子類別會覆寫它。如果它們沒有這樣做,它會…擲出例外。
在延遲 val 初始化期間發生的例外會導致右手邊在下次存取時重新評估:請見 SLS 5.2。
請注意,使用多個 lazy vals 會產生新的風險:lazy vals 之間的循環可能會在第一次存取時導致堆疊溢位。
使用早期定義
abstract class A {
val x1: String
val x2: String = "mom"
println("A: " + x1 + ", " + x2)
}
class B extends {
val x1: String = "hello"
} with A {
println("B: " + x1 + ", " + x2)
}
class C extends {
override val x2: String = "dad"
} with B {
println("C: " + x1 + ", " + x2)
}
// scala> new C
// A: hello, dad
// B: hello, dad
// C: hello, dad
早期定義有點難以使用,對於可以在早期定義區塊中顯示和引用的內容有其限制,而且它們無法像 lazy vals 一樣編寫:但如果 lazy val 不合適,它們會提供另一個選項。它們在 SLS 5.1.6 中有說明。
請注意,Scala 2.13 已棄用早期定義;它們將在 Scala 3 中被特質參數取代。因此,如果未來相容性是個問題,不建議使用早期定義。
使用常數值定義
abstract class A {
val x1: String
val x2: String = "mom"
println("A: " + x1 + ", " + x2)
}
class B extends A {
val x1: String = "hello"
final val x3 = "goodbye"
println("B: " + x1 + ", " + x2)
}
class C extends B {
override val x2: String = "dad"
println("C: " + x1 + ", " + x2)
}
abstract class D {
val c: C
val x3 = c.x3 // no exceptions!
println("D: " + c + " but " + x3)
}
class E extends D {
val c = new C
println(s"E: ${c.x1}, ${c.x2}, and $x3...")
}
//scala> new E
//D: null but goodbye
//A: null, null
//B: hello, null
//C: hello, dad
//E: hello, dad, and goodbye...
有時,你只需要介面中的編譯時期常數。
常數值比嚴格值更嚴格,比早期定義更早,而且有更多限制,因為它們必須是常數。它們在 SLS 4.1 中有說明。