object PermissionsLevel:
val default = "default"
enum PermissionsLevel(val name: String):
case editor extends PermissionsLevel("editor")
case other extends PermissionsLevel(PermissionsLevel.default)
@main
def main(): Unit =
val e1 = PermissionsLevel.editor.name
val e2 = PermissionsLevel.other.name
println(e1)
println(e2)
Hello,
In the above code, e2 is null.
Is this the expected behavior? Why?
If enum is evaluated before its own companion, then maybe the compiler should warn or even block attempt to reference such fields from the enum.
4 Likes
This is a very unfortunate situation, sadly I believe it is “up to spec” (but this might be a suggestion the spec needs to be improved)
The compiler option “-Ysafe-init” might flag this case
(I don’t remember why you need a flag for this, it seems universally useful)
3 Likes
“-Ysafe-init” indeed flagged this case.
I can’t explain what gives rise to this behavior, either, other than a handwaving “cyclic deps between class and companion”, and I think it’s a mean gotcha, too.
Here, -Ysafe-init
doesn’t seem to flag this with Scala 3.4.2. -Ysafe-init-global
does the trick, though. (However, I recall that I’ve seen sbt compile runs reproducably hanging after adding this option to a project at least once - perhaps due to cyclic dependencies…? )
The issue can be “fixed” by either making the field lazy or by moving it to another object unrelated to the class.
4 Likes
It’s crazy lazy val
fixes it… this is something to keep in mind! I use / teach enum
s a lot. (Init problems are too complicated to explain)
1 Like
What a nasty pitfall! And, btw, i do not see the “cyclic dependence” here at all. Why would the companion object not be evaluated first?
The reason of the problem is the way enum
desugars, the cases are moved to the companion object and it seems they are moved before anything else.
Thus:
sealed abstract class PermissionsLevel(val name: String)
object PermissionsLevel:
val editor = new PermissionsLevel("editor") {}
val other = new PermissionsLevel(PermissionsLevel.default) {}
val values: Array[PermissionsLevel] = Array(editor, other)
val default = "default"
Something like that is how the generated code looks like and that leads to the initialization error.
Not sure how hard would be for the compiler to switch the order of fields based on use.
5 Likes
While I’m slightly surprised by the problem (@BalmungSan’s explanation makes sense, but I wouldn’t have guessed this to be the way it works), I’m not surprised by this solution – adding lazy
is my usual go-to for null
initialization errors like this, and nearly always fixes them.
1 Like
Yes, and making it a def default = ...
also works of cause, but both solutions are not “free” and basically they are introduced after you have hit the wall. This behaviour is highly counter intuitive, even if it is formally not a bug or “within spec”.
Would it be possible and useful to file such behaviour as an issue somehow?
BTW, an inline val default = ...
would be the cheapest “solution” here.
3 Likes
Indeed, it almost reached our production after migrating code to Scala 3.
Luckily one of our tests failed.
I wish something could protect me from successfully compiling this and not knowing a thing about what I just did.
2 Likes
Not only is it cheaper but it also makes more sense.
This is a declaration of a const value after all.
If I’m a future maintainer of the code and I see a lazy val I would ask myself what is going on here. Why would a const value have to be declared as a lazy? Was the previous coder tried to be smart? He tried to cheaply optimize this code? It looks weird - maybe I should even remove it…
For this reason I wouldn’t add the lazy modifier without adding a comment of why I had to do this.
inline on the other hand, is more natural.
If it’s a const, having it evaluated already at compile time seems a reasonable thing to always do in general and not only in the scope of the initialization problem we have here.
I solved it btw by taking it out of the companion and writing a big comment stating not to move it to the companion, with a link to this discussion
2 Likes
By coincidence, I was just looking at this ticket which someone boosted.
A class initializer is not forced to run that you might assume is forced; probably the enclosing companion?
I haven’t looked at it closely yet.
Edit: if someone could change the title from it’s to its, that would be great.
4 Likes
I agree this is a bad trap to fall into. It’s good that the initialization checker catches it, we should use it more.
We could try to change the spec and implementation so that we insert each enum case into the companion object at the point immediately following all vals that are accessed from the case definition. It’s likely going to be a bit messy. Defining what such accesses are will be tricky and tedious with lots of corner cases that we’d have to rule in or out. Still, it might be worth it to avoid the bad surprises.
7 Likes
That’s great to know!
I’m a heavy user of lazy val
s, so I probably never ran into the issue, so I didn’t know it would be fixed that way.
I mean, keep in mind that it’s a little expensive, both in terms of startup time and space overhead. But it’s a very useful tool when appropriate.
2 Likes