Alphasaft
Alphasaft

Reputation: 322

Did I found a bug able to get a null value where Any is expected?

I learned to program with Kotlin a few months ago, and I wrote a bunch of code with it. Recently, as I was working on a personal project, I tried something like that :

1 |  sealed class Base(derivedRef: Any) {
2 |
3 |     init {
4 |         println("$derivedRef shouldn't be null !")
5 |     }
6 |
7 |     object Derived: Base(Derived) 
8 |  }
9 |
10|  fun main() {
11|     val neededToInitializeDerived = Base.Derived
12|  }

At compile time, no problem: all seems to work as it should, and my IDE (Idea Intellij) doesn't highlight any part of my code in red. But, once compiled, if I try to run it, it prints a weird result :

null shouldn't be null !

After thinking a bit, it appears that derivedRef, typed as Any - and also not-null, thanks to the null safety - is, actually, null. My theory is that when I'm passing the Derived object itself as a parameter of its own superclass constructor (and in fact the singleton instance of the Derived class, once compiled, through Derived.INSTANCE), Derived.INSTANCE isn't instantiated since the class Derived itself and its superclass Base aren't. It takes also a provisory null value, normally hidden at compile time and no more available at runtime, but that I successfully capture with this specific code snippet.

The problem is that I now have a null value instead of the normal Any one, throwing a NullPointerException as soon as I call a method needing a non-null Any value with it; I have some fun while doing impossible things using this glitch, but I think that it can be dangerous since it won't crash at runtime, letting you run your code free until the said method is called and making your program throw an unexpected error where the compiler ensures you "Don't worry, all is fine right there". After proceeding to some tests, the bug has further consequences : I'm now able to put null values inside a MutableList<Any> :

val theForbiddenValue: Any // Let's say it's actually null
val myList = mutableListOf<Any>()
myList.add(null) // Normal, won't compile
myList.add(theForbiddenValue) // Compiles ! If I print it, I obtain "[null]" !

And to do other weird things that shouldn't happen at all, such as defining functions supposed to return Any but do not, etc. My last thought - I promise -, by making Base implements List<Any> for example, Derived will also implement it. And thus you can change the type of derivedRef from Any to List<Any>, and also obtain a null value where List<Any> is expected. This also works with every interface and/or non-final class.

So my question is, is it a real bug that I just discovered ? Or is it already known by many developers ? Or is it normal despite the appearances (I will be really surprised in that case) ??

Upvotes: 0

Views: 294

Answers (1)

gidds
gidds

Reputation: 18617

This is about the order of construction — though it's much more subtle than most.

The most common order-of-construction issue is that in general, a constructor shouldn't refer to anything in the class that could be overridden (or changed) by a subclass.

In Java and Kotlin, a constructor always calls the superclass constructor as the very first thing it does.  (If you don't write an explicit call, the compiler inserts one for you.)  After that returns, it runs any field initialisers, and then the rest of the constructor.  So when the superclass constructor runs, none of the subclass initialisers or constructor code have been run yet.  This means that non-primitive fields will be null at that point — even if they're of a non-nullable type.

(I'm guessing that this isn't made a compile-time error because there might be circumstances in which it's perfectly fine: for example, if a subclass overrides a superclass method but doesn't refer to any fields which are overridden or set in the subclass constructor.  Note that the IDE shows a warning, such as "Accessing non-final property […] in constructor" or "Calling non-final function […] in constructor".)

What's happening in this case, however, is much less obvious, because of the combination of a sealed class and an object subclass.  As far as I can tell, the order of events is:

  • Because main() refers to Derived, it will construct the Derived object.

  • The first thing that Derived's constructor does is to call the superclass constructor, passing a reference to itself.  However, because the object hasn't yet been constructed, its reference seems to be null.  ← This is of course the cause of the bug, and ideally would give a compile-time error, or at least a warning.

  • The superclass constructor runs, and prints out the message you see.

  • The rest of the subclass constructor runs (and does nothing).  By this point, Derived gives a valid references, but by then it's too late.

  • The rest of the main() method runs, setting its local variable.


Here's a variation which illustrates the order a little better.  (I've moved the object outside the sealed class, though that makes no practical difference.  I've also made derivedRef a property, so we can see it afterward.)

sealed class Base(val derivedRef: Any) {
    init {
        println("Base.init:    Derived = $Derived, derivedRef = $derivedRef")
    }
}

object Derived: Base(Derived) {
    init {
        println("Derived.init: Derived = $Derived, derivedRef = $derivedRef")
    }
}

fun main() {
    println("main:         Derived = $Derived, derivedRef = ${Derived.derivedRef}")
}

This prints something like:

Base.init:    Derived = null, derivedRef = null
Derived.init: Derived = Derived@87aac27, derivedRef = null
main:         Derived = Derived@87aac27, derivedRef = null

You can see that the Derived reference is valid once the superclass constructor has finished, even before its own constructor has finished; but of course that's too late for the property, which has already been set to null.


By the way, this doesn't happen if the object is made into a normal class: it would have to pass this when it calls the superclass constructor, but the compiler complains that "'this' is not defined in this context".

Alternatively, if the sealed class is made into a plain open class, then it compiles OK but gives a NullPointerException at runtime.

So it's the combination of sealed class and object subclass that leads to this particular issue.  Presumably the compiler can't spot that the object reference isn't valid yet (like it does for an explicit this in the case of a simple subclass).

Upvotes: 1

Related Questions