为什么对对象的 val 的 Scala 测试断言会抛出 NullPointerException?

Why a scala test assertion on an object's val throws NullPointerException?

在测试 Scala 代码时,我 运行 在断言对象的值时遇到了一个奇怪的 NPE。

这是重现问题的最少代码:

main/scala/Playground.scala:

object Playground extends App {
  val greeting = "Hello Scala"
  println(greeting)
}

test/scala/PlaygroundSpec.scala:

import org.scalatest.wordspec._

class PlaygroundSpec extends AnyWordSpec {
  "The playground code" should {
    "say hello" in {
      assert(Playground.greeting.contains("Hello")) // Throws NPE because greeting is null. How???
    }
  }
}

示例程序 运行 很好并打印“hello Scala”,但测试在断言行上抛出 NullPointerException,因为 greetingnull.

如果用字符串常量初始化,greeting怎么会是null呢?

Note: Adding lazy to the val declaration makes it work and the test passes.

您可以这样定义 Playground

object Playground /*extends App*/ {
  val greeting = "Hello Scala"
  println(greeting)
}

或者您可以这样定义 greeting

object Playground extends App {
  lazy val greeting = "Hello Scala"
  println(greeting)
}

对 Scala 2.x 这种奇怪行为的解释是 App 具有延迟加载功能。这在过去对许多其他人来说一直是个问题。 Scala 3 将改变这一点。

对于 Scala 2,最安全的选择是将对象引用移到 App 的主体之外,例如:

class Playground {
  val greeting = "Hello Scala"
  println(greeting)
}

object Playground extends App {
  new Playground
}

在 Scala 2 App extends DelayedInit,所以编译器神奇地重写了初始化代码,这样字段的初始化就移到了 delayedInit 方法,例如,

object Playground extends App {
  val greeting = "Hello Scala"
  println(greeting)
}

变成类似

的东西
object Playground extends App {
  private var greeting: String = null
  def greeting(): String = greeting

  def delayedInit(): Unit = {   
    greeting = "Hello Scala"
    println(greeting()) 
  }

  def main(args: Array[String]) = {
    // indirectly call delayedInit
    ...
  }
}

现在我们可以看到

assert(Playground.greeting.contains("Hello"))

变成

assert(null.contains("Hello"))

as delayedInit 方法未被调用。为了证明这一点,请观察以下工作原理

Playground.main(Array.empty) // delayedInit gets indirectly called
assert(Playground.greeting.contains("Hello")) // ok

Adding lazy to the val declaration makes it work and the test passes.

之所以有效,是因为 lazy val greeting 有效地将字段转换为将其移出初始化代码的方法,因此它不会成为 delayedInit 的一部分。

显然这令人困惑,所以 Scala 3 Dropped: Delayedinit