安全初始化
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]。粗略地说,这意味着初始化状态不应倒退。
可扫性意味着没有访问部分构造对象的侧通道。协程、定界控制、可恢复异常等控制效果可能会破坏此属性,因为它们可以将堆栈中较高的值(不在范围内)传输到当前范围可达。静态字段也可以充当传送点,从而破坏此属性。在实现中,我们需要强制传送值进行传递初始化。
上述三个原则有助于关于初始化的局部推理,这意味着
已初始化的环境只能生成已初始化的值。
例如,如果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
引用)是有效热的。
可以将有效热值视为传递初始化的,因此可以通过方法参数或作为重新赋值的 RHS 安全泄露。初始化检查器会尝试在可能的情况下将非热值提升为有效热值。
规则
根据既定的原则和设计目标,实施以下规则
-
如果
e
是冷的,则字段访问e.f
或方法调用e.m()
是非法的。不应使用冷值。
-
如果
e
的值为ThisRef
且未初始化f
,则字段访问e.f
无效。 -
在赋值
o.x = e
中,表达式e
必须是有效热的。这就是系统中如何强制单调性的。请注意,在初始化
val f: T = e
中,表达式e
可能指向非热值。 -
方法调用的参数必须是有效热的。
在构造函数中转义
this
通常被视为反模式。但是,允许将非热值作为参数传递给另一个构造函数,以支持创建循环数据结构。检查器将确保不使用转义的未初始化对象,即不允许调用方法或访问转义对象上的字段。
一个异常是调用 case 类的合成
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)。
- 冯云刘、翁德雷·洛塔克、阿格洛斯·比布迪斯、保罗·G·贾鲁索和马丁·奥德斯基。对象初始化的类型和效果系统。OOPSLA,2020 年。
- 冯云刘、翁德雷·洛塔克、恩泽·邢、阮曹范。抽象的安全对象初始化。Scala 2021 年。