Home > database >  Why am I able to have null values in non-null variables?
Why am I able to have null values in non-null variables?

Time:02-04

Here is my kotlin class:

class Test{
    val x: String = run {
        y
    }

    val y: String = run {
        x
    }
}

The variables x and y both end up as null, despite being declared as non-nullable strings.

You can run it here. As you can see, you end up with null pointer exceptions from trying to call methods on x or y.

Why is this possible? How can you really have null safety with this in mind?

CodePudding user response:

According to the Kotlin docs, "Data inconsistency with regard to initialization" can result in a NullPointerException.

Here are a couple links related to the topic:

https://kotlinlang.org/docs/null-safety.html#nullable-types-and-non-null-types

https://kotlinlang.org/docs/inheritance.html#derived-class-initialization-order

CodePudding user response:

Well, it's circular. x is not null because y is not null which is not null because x is not null.

So it's not a valid program. Meaningful type inference can only be applied to valid programs.

CodePudding user response:

Well this is what your class decompiles to in Java:

public final class Test {
   @NotNull
   private final String x;
   @NotNull
   private final String y;

   @NotNull
   public final String getX() {
      return this.x;
   }

   @NotNull
   public final String getY() {
      return this.y;
   }

   public Test() {
      Test $this$run = (Test)this;
      int var3 = false;
      String var5 = $this$run.y;
      this.x = var5;
      $this$run = (Test)this;
      var3 = false;
      var5 = $this$run.x;
      this.y = var5;
   }
}

So your backing fields, x and y are declared first. They're not assigned a value yet so, in Java, that means their value is null because that's the default for an unassigned object reference.

After the getters, you have the constructor which is where the assignation is taking place. There are a few weird variables going around, but

Test $this$run = (Test)this;

is basically creating a variable that refers to this, the current object. And we can kinda reduce the assignment code down to

this.x = this.y // y is null, so x is set to null
this.y = this.x // x is null, so y is set to null

Because that default value for object references is null, whichever of your assignments runs first will always be reading a null value from the other variable (which, remember, you haven't explicitly assigned a value to yet).


Basically the order of initialisation matters in Kotlin, you can't refer to something that hasn't been declared yet. Like this won't work either:

class Thing {
    val a = b
    val b = "hi"
}

On the line where a is being assigned, the value of b is currently undefined. It won't run on the JVM either, because that code decompiles to basically this:

public final class Thing {
    @NotNull
    private final String a;
    @NotNull
    private final String b;

    public Thing() {
        this.a = this.b;
        this.b = "hi";
    }
}

and that this.a = this.b line will fail because "b may not have been initialised yet". You can get around that with the same trick in the decompiled version of your code, with the other variable assigned to this:

public Thing() {
    Thing thing = (Thing) this;
    this.a = thing.b;
    this.b = "hi";
}

which will run, but a ends up assigned with the default value of null.


So basically, the code you're using is a tricky way to get around that kind of error and ultimately give you unexpected behaviour. Obviously your example is unrealistic (the results of a = b = a are inherently undefined), but it can happen with this kind of code too, where initialisation goes through other functions:

class Wow {
    val a = doSomething()
    val b = 1
    
    fun doSomething() = b
}

In this case a ends up 0 on the JVM, the default value for an int, because when assigning a it basically goes on a detour through a function that reads b before that's been assigned its value. Kotlin (currently) doesn't seem to be capable of checking the validity of this kind of thing - so you'll run into problems trying to initialise things via functions sometimes:

class Wow {
    // unassigned var
    var a: Int
    val b = 1
    
    init {
        // calls a function that assigns a value to a
        doSomething()
    }
    
    fun doSomething() { a = 5 }
}

That will fail because it can't determine that a has been initialised, even though the init block does so, because it's happening as a side effect of another function. (And you could bury that assignment in any number of chained calls, which is probably why it's not a thing that's been "fixed" - if you start making guarantees about that kind of thing, it needs to be consistent!)


So basically, during initialisation you can do things that are which the compiler isn't able to catch, and that's how you can get around things like non-null guarantees. It doesn't come up often, but it's something to be aware of! And I'm only familiar with the JVM side, I'm assuming the undefined behaviour is platform-specific.

  • Related