A class extending its inner class throws a NullPointerException

@main def pdt={}
class C extends o.CI:
  class CI
val o=C()

compiles in 3.4.0 but throws

        at C$CI.<init>(pdt.scala:3)
        at C.<init>(pdt.scala:2)
        at pdt$package$.<clinit>(pdt.scala:4)
        at pdt.main(pdt.scala:1)

at runtime. Wouldn’t a compile-time error be a more intuitive behavior?

Yes, it’s might be unintuitive, but it’s mostly due to code transformation happening to top-level statements.
However, it is possible to check proper initialisation of these under Scala 3.4 using -Ysafe-init-global (do not confuse it with -Ysafe-init existing in all Scala 3 releases). Just be aware that these 2 are still an experimental flags, and might sometimes lead to false-positives

> scala-cli run main.scala -O -Ysafe-init -Ysafe-init-global -S 3.4
[warn] ./main.scala:2:1
[warn] Access uninitialized field value o. Calling trace:
[warn] ├── @main def pdt={}     [ main.scala:1 ]
[warn] │   ^
[warn] ├── val o=C()    [ main.scala:5 ]
[warn] │         ^^^
[warn] └── class C extends o.CI:        [ main.scala:2 ]
[warn]     ^

fwiw, note that Scala 2 errors on this with “illegal cyclic reference involving class C”

I don’t see how it matters here that top-level statements are involved in the originally posted code. Scala 3.4.0 still errors at runtime if you wrap the whole thing to not be top-level anymore:

scala> object O:
     |   class C extends o.CI:
     |     class CI
     |   val o=C()
// defined object O
scala> O.o
  ... 35 elided

I don’t see how it matters here that top-level statements are involved in the originally posted code

I simply didn’t want to get into the details. Top-level statement are converted to an object, like the one you presented above, so it’s expected that is still fails (it also gives the same warnings under -Ysafe-init-global).
I believe it would be very beneficial from the users perspective that stuff like -Ysafe-init-global would be enabled by default in Scala 3 once they’re stable.


Seth recently made the same comment (“works in Scala 2!”) on a similar ticket.

I haven’t tried my example from the ticket, but VerifyError beats NullPointerException every time (in rock, paper, scissors).

1 Like

I also didn’t want to get into the details, but I was curious about why the exception has no message text. First, though, I was curious about the Scala 3 REPL wrapping because of the clinit in the OP stack trace. Then, second, my curiosity evaporated. I guess it’s lazy module val all the way down? but it’s not trivial to ask the REPL to tell me directly. In Scala 2, I would :javap to verify that I’m not misreading -Vprint trees.

My clever remark was going to be that it’s not necessary to “wrap” inside a REPL session, which always wraps (somehow).

Welcome to Scala 3.4.2-RC1-bin-SNAPSHOT-git-e54be6e (21.0.2, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.

scala> class C extends o.CI:
     |   class CI
     | val o=C()
  ... 35 elided

scala> throw null
java.lang.NullPointerException: Cannot throw exception because "null" is null
  ... 33 elided

scala> null.asInstanceOf[String].length
java.lang.NullPointerException: Cannot invoke "String.length()" because "null" is null
  ... 33 elided

Anyway, it’s reassuring to know that, at the end of the day,

"null" is null

I think that regardless of the safe-init flags, this specific piece of code should just give an illegal cyclic reference error like Seth said.