安全初始化
Scala 3 實作實驗性的安全初始化檢查,可透過編譯器選項 -Ysafe-init
啟用。
初始化檢查器的設計和實作說明在論文中安全物件初始化,抽象地 [3] 中。
快速瀏覽
為了了解其運作方式,我們先在下方顯示幾個範例。
父類-子類互動
針對以下程式碼片段
abstract class AbstractFile:
def name: String
val extension: String = name.substring(4)
class RemoteFile(url: String) extends AbstractFile:
val localFile: String = s"${url.##}.tmp" // error: usage of `localFile` before it's initialized
def name: String = localFile
檢查器會報告
-- Warning: tests/init/neg/AbstractFile.scala:7:4 ------------------------------
7 | val localFile: String = s"${url.##}.tmp" // error: usage of `localFile` before it's initialized
| ^
| Access non-initialized field value localFile. Calling trace:
| -> val extension: String = name.substring(4) [ AbstractFile.scala:3 ]
| -> def name: String = localFile [ AbstractFile.scala:8 ]
內部與外部互動
給定以下程式碼
object Trees:
class ValDef { counter += 1 }
class EmptyValDef extends ValDef
val theEmptyValDef = new EmptyValDef
private var counter = 0 // error
檢查器會報告
-- Warning: tests/init/neg/trees.scala:5:14 ------------------------------------
5 | private var counter = 0 // error
| ^
| Access non-initialized field variable counter. Calling trace:
| -> val theEmptyValDef = new EmptyValDef [ trees.scala:4 ]
| -> class EmptyValDef extends ValDef [ trees.scala:3 ]
| -> class ValDef { counter += 1 } [ trees.scala:2 ]
函式
給定以下程式碼
abstract class Parent:
val f: () => String = () => this.message
def message: String
class Child extends Parent:
val a = f()
val b = "hello" // error
def message: String = b
檢查器報告
-- Warning: tests/init/neg/features-high-order.scala:7:6 -----------------------
7 | val b = "hello" // error
| ^
|Access non-initialized field value b. Calling trace:
| -> val a = f() [ features-high-order.scala:6 ]
| -> val f: () => String = () => this.message [ features-high-order.scala:2 ]
| -> def message: String = b [ features-high-order.scala:8 ]
設計目標
我們建立以下設計目標
- 健全:檢查總是會終止,並且對一般且合理的用法是健全的(過度近似)
- 表達性:支援一般且合理的初始化模式
- 友善:簡單的規則、最小的語法開銷、提供有意義的錯誤訊息
- 模組化:模組化檢查,不分析專案邊界之外的內容
- 快速:即時回饋
- 簡單:不變更核心類型系統,可以用簡單的理論來解釋
在合理的用法中,我們包含以下用例(但不限於這些用例)
- 在初始化期間存取
this
和外部this
上的欄位 - 在初始化期間呼叫
this
和外部this
上的方法 - 在初始化期間建立內部類別並呼叫這些實例上的方法
- 在函式中擷取欄位
原則
為了達成目標,我們堅持以下基本原則:可堆疊性、單調性、可作用域性和權威性。
可堆疊性表示類別的所有欄位都在類別主體的結尾處初始化。Scala 透過在語法中要求所有欄位都在主建構函式的結尾處初始化來強制執行此屬性,但下列語言功能除外
var x: T = _
控制效果(例如例外狀況)可能會中斷此屬性,如下列範例所示
class MyException(val b: B) extends Exception("")
class A:
val b = try { new B } catch { case myEx: MyException => myEx.b }
println(b.a)
class B:
throw new MyException(this)
val a: Int = 1
在上述程式碼中,控制效果會傳送包裝在例外狀況中的未初始化值。在實作中,我們透過確保擲出的值必須是遞移初始化來避免問題。
單調性表示物件的初始化狀態不應後退:已初始化的欄位會持續初始化,指向已初始化物件的欄位不會在稍後指向正在初始化的物件。例如,以下程式碼會被拒絕
trait Reporter:
def report(msg: String): Unit
class FileReporter(ctx: Context) extends Reporter:
ctx.typer.reporter = this // ctx now reaches an uninitialized object
val file: File = new File("report.txt")
def report(msg: String) = file.write(msg)
在上面的程式碼中,假設 ctx
指向一個暫時初始化的物件。現在第 3 行的指定會讓 this
(尚未完全初始化)可以從 ctx
存取。這會讓欄位使用變得危險,因為它可能間接存取到未初始化的欄位。
單調性基於一種稱為「堆單調型態狀態」的知名技術,以確保在別名存在的情況下依然健全 [1]。粗略來說,這表示初始化狀態不應該倒退。
可掃描性表示沒有側頻道可以存取部分建構的物件。控制效果(例如:coroutine、分界控制、可恢復例外)可能會破壞這個屬性,因為它們可以傳送堆疊中較上層(不在範圍內)的值,讓目前的範圍可以存取。靜態欄位也可以作為傳送點,因此會破壞這個屬性。在實作中,我們需要強制執行已傳送的值暫時初始化。
上述三個原則有助於「初始化的局部推理」,表示
已初始化的環境只能產生已初始化的值。
例如,如果 new
表達式的引數暫時初始化,則結果也會初始化。如果方法呼叫中的接收器和引數暫時初始化,則結果也會初始化。
初始化的局部推理會產生快速的初始化檢查器,因為它避免了全程式分析。
權威原則與單調性相輔相成:單調性原則規定初始化狀態不能倒退,而權威原則規定初始化狀態不能因為別名而任意向前。在 Scala 中,我們只能在類別主體中定義欄位為強制初始化項或在物件暫時初始化的局部推理點時,才能提升物件的初始化狀態。
抽象值
物件初始化狀態有三個基本抽象
- 冷:冷物件可能有未初始化的欄位。
- 溫:溫物件的所有欄位都已初始化,但可能會存取到冷物件。
- 熱:熱物件暫時初始化,也就是說它只會存取溫物件。
在初始化檢查器中,抽象 Warm
會經過細化,以處理內部類別和多個建構函式
- Warm[C] { outer = V, ctor, args = Vs }:類別
C
的溫物件,其中C
的立即外部物件為V
,建構函式為ctor
,建構函式引數為Vs
。
初始化檢查器會個別檢查每個具體類別。抽象 ThisRef
代表目前正在初始化的物件
- ThisRef[C]:初始化中的類別
C
的目前物件。
目前物件的初始化狀態儲存在抽象堆疊中,作為抽象物件。抽象堆疊也作為熱物件的欄位值快取。Warm
和 ThisRef
是儲存在抽象堆疊中的抽象物件的「位址」。
引入了兩個抽象概念來支援函式和條件式
-
Fun(e, V, C):抽象函式值,其中
e
是程式碼,V
是函式主體內this
的抽象值,而函式位於類別C
內。 -
Refset(Vs):抽象值
Vs
的集合。
如果符合下列任一條件,則值 v
為實際上已熱
v
為Hot
。v
為ThisRef
,且基礎物件的所有欄位都已指派。v
為Warm[C] { ... }
,且C
不包含內部類別;且- 對
v
呼叫任何方法都不會遇到初始化錯誤,且方法傳回值為實際上已熱;且 v
的每個欄位都為實際上已熱。
v
為Fun(e, V, C)
,且呼叫函式不會遇到錯誤,且函式傳回值為實際上已熱。- 根物件(由
ThisRef
參照)為實際上已熱。
實際上已熱的值可視為傳遞初始化,因此可透過方法引數或重新指派右側安全地洩漏。初始化檢查器會在可能時嘗試將非熱值提升為實際上已熱。
規則
在既定的原則和設計目標下,實施下列規則
-
如果
e
為冷,則欄位存取e.f
或方法呼叫e.m()
為非法。不應使用冷值。
-
如果
e
的值為ThisRef
,且f
未初始化,則欄位存取e.f
無效。 -
在指派
o.x = e
中,表示式e
必須為實際上已熱。這是系統中如何強制單調性的方式。請注意,在初始化
val f: T = e
中,表示式e
可能指向非熱值。 -
方法呼叫的引數必須為實際上已熱。
建構函式中
this
的逸出通常被視為反模式。不過,傳遞非熱值作為另一個建構式的引數是允許的,以支援循環資料結構的建立。檢查程式會確保已逃逸的非初始化物件不會被使用,也就是說,不允許呼叫方法或存取已逃逸物件的欄位。
呼叫案例類別的合成
apply
是個例外。例如,方法呼叫Some.apply(e)
會被解釋為new Some(e)
,因此即使e
不是熱值,仍然有效。這項規則的另一個例外是參數化方法呼叫。例如,在
List.apply(e)
中,引數e
可能是非熱值。如果是這樣,參數化方法呼叫的結果值會被視為冷值。 -
對熱值呼叫方法,並使用有效熱值作為引數,會產生熱值結果。
這項規則是由關於初始化的局部推論所確保的。
-
對
ThisRef
和溫值呼叫方法會在靜態中解析,並檢查對應的方法主體。 -
在新的表達式
new p.C(args)
中,如果p
和args
的值有效熱值,則結果值也是熱值。這項規則是由關於初始化的局部推論所確保的。
-
在新的表達式
new p.C(args)
中,如果p
和args
的任何值都不是有效熱值,則結果值會採取Warm[C] { outer = Vp, args = Vargs }
形式。類別C
的初始化程式碼會再次檢查,以確保非熱值已正確使用。在上述範例中,
Vp
是p
的擴充值 --- 如果p
是溫值Warm[D] { outer = V, args }
,擴充就會發生,而我們會將其擴充為Warm[D] { outer = Cold, args }
。變數
Vargs
代表args
的值,其中非熱值已擴充為Cold
。擴充的動機是將抽象網域有限化,並確保初始化檢查的終止。
-
樣式比對中的被檢驗者和 return 和 throw 陳述式中的值必須是有效熱值。
模組化
分析將具體類別的主要建構式視為進入點。它遵循超類別的建構式,這些建構式可能定義在其他專案中。分析利用 TASTy 分析在其他專案中定義的超類別。
跨越專案邊界會引發對模組化的疑慮。在物件導向程式設計中,超類別和子類別緊密結合,這是眾所周知的。例如,在超類別中新增方法需要重新編譯子類別,以檢查安全的覆寫。
初始化在這個方面並非例外。物件初始化基本上涉及子類別與超類別之間的密切互動。如果超類別是在另一個專案中定義,則為了分析的健全性,無法避免跨專案邊界的交叉。
同時,跨專案邊界的繼承一直受到審查,而開放類別的引入在此緩解了疑慮。例如,初始化檢查可以強制執行開放類別的建構函式可能不包含對 this
的方法呼叫,或將註解作為合約引入。
歡迎社群對此主題的回饋。
後門
偶爾,您可能希望抑制檢查器報告的警告。您可以撰寫 e: @unchecked
來告訴檢查器略過檢查表達式 e
,或者您可以使用舊技巧:將某些欄位標記為延遲。
注意事項
- 在延伸 Java 或 Scala 2 類別時,系統無法提供安全性保證。
- 僅部分檢查了全域物件的安全初始化。
參考文獻
- Fähndrich, M. 和 Leino, K.R.M.,2003 年 7 月。堆單調類型狀態。在物件導向程式設計中關於別名、限制和擁有權的國際研討會 (IWACO)。
- Fengyun Liu、Ondřej Lhoták、Aggelos Biboudis、Paolo G. Giarrusso 和 Martin Odersky。物件初始化的類型和效果系統。OOPSLA,2020 年。
- Fengyun Liu、Ondřej Lhoták、Enze Xing、Nguyen Cao Pham。安全的物件初始化,抽象地。Scala 2021 年。