Search code examples
javakotlinnullnull-checkkotlin-null-safety

Why do Kotlin null checks, when decompiled to Java declares some unused variables?


I did what follows with Kotlin 1.5.20 on JVM 11 (AdoptOpenJDK build 11.0.4+11)

Usually in Kotlin I perform null checks favouring x?.let {} or x?.run {} over if(x != null) {}. I decompiled to Java these three approaches, in order to understand if my favorite approach introduces any kind of inefficiency. Decompiling this Kotlin code

class Main {
    fun main1(x: String?){
        x?.run { println(this) }
    }

    fun main2(x: String?){
        x?.let { x1 -> println(x1) }
    }

    fun main3(x: String?){
        if (x != null){
            println(x)
        }
    }

}

I get this Java code

@Metadata(
   mv = {1, 5, 1},
   k = 1,
   d1 = {"\u0000\u001a\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0010\u0002\n\u0000\n\u0002\u0010\u000e\n\u0002\b\u0003\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002J\u0010\u0010\u0003\u001a\u00020\u00042\b\u0010\u0005\u001a\u0004\u0018\u00010\u0006J\u0010\u0010\u0007\u001a\u00020\u00042\b\u0010\u0005\u001a\u0004\u0018\u00010\u0006J\u0010\u0010\b\u001a\u00020\u00042\b\u0010\u0005\u001a\u0004\u0018\u00010\u0006¨\u0006\t"},
   d2 = {"Lcom/cgm/experiments/Main;", "", "()V", "main1", "", "x", "", "main2", "main3", "decompile"}
)
public final class Main {
   public final void main1(@Nullable String x) {
      if (x != null) {
         boolean var3 = false;
         boolean var4 = false;
         int var6 = false;
         boolean var7 = false;
         System.out.println(x);
      }

   }

   public final void main2(@Nullable String x) {
      if (x != null) {
         boolean var3 = false;
         boolean var4 = false;
         int var6 = false;
         boolean var7 = false;
         System.out.println(x);
      }

   }

   public final void main3(@Nullable String x) {
      if (x != null) {
         boolean var2 = false;
         System.out.println(x);
      }

   }
}

As you can notice, several unused variables are allocated (var3, var4, var6, var7 for fun main1 and fun main2 and var2 for fun main3). This is decompiled code, so the bytecode actually allocates and initialize those. int var6 = false; doesn't even compile.

Also with the traditional if(x != null) {println(x)} the unused var2 is introduced.

I would like to understand which is the purpose of those variables and why this happens.

EDIT:

Here it follows the Kotlin bytecode (From IntelliJ --> Tools --> Kotlin --> Show Kotlin Bytecode)

// ================com/cgm/experiments/Main.class =================
// class version 52.0 (52)
// access flags 0x31
public final class com/cgm/experiments/Main {


  // access flags 0x11
  public final main1(Ljava/lang/String;)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
    @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
   L0
    LINENUMBER 5 L0
    ALOAD 1
    DUP
    IFNULL L1
    ASTORE 2
   L2
    ICONST_0
    ISTORE 3
   L3
    ICONST_0
    ISTORE 4
   L4
    ALOAD 2
    ASTORE 5
   L5
    LINENUMBER 21 L5
   L6
    ICONST_0
    ISTORE 6
   L7
    LINENUMBER 5 L7
   L8
    ICONST_0
    ISTORE 7
   L9
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 5
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L10
   L11
    LINENUMBER 5 L11
    NOP
   L12
   L13
    LINENUMBER 5 L13
   L14
    GOTO L15
   L1
    POP
   L15
   L16
    LINENUMBER 6 L16
    RETURN
   L17
    LOCALVARIABLE $this$run Ljava/lang/String; L6 L12 5
    LOCALVARIABLE $i$a$-run-Main$main1$1 I L7 L12 6
    LOCALVARIABLE this Lcom/cgm/experiments/Main; L0 L17 0
    LOCALVARIABLE x Ljava/lang/String; L0 L17 1
    MAXSTACK = 2
    MAXLOCALS = 8

  // access flags 0x11
  public final main2(Ljava/lang/String;)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
    @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
   L0
    LINENUMBER 9 L0
    ALOAD 1
    DUP
    IFNULL L1
    ASTORE 2
   L2
    ICONST_0
    ISTORE 3
   L3
    ICONST_0
    ISTORE 4
   L4
    ALOAD 2
    ASTORE 5
   L5
    LINENUMBER 21 L5
   L6
    ICONST_0
    ISTORE 6
   L7
    LINENUMBER 9 L7
   L8
    ICONST_0
    ISTORE 7
   L9
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 5
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L10
   L11
    LINENUMBER 9 L11
    NOP
   L12
   L13
    LINENUMBER 9 L13
   L14
    GOTO L15
   L1
    POP
   L15
   L16
    LINENUMBER 10 L16
    RETURN
   L17
    LOCALVARIABLE x1 Ljava/lang/String; L6 L12 5
    LOCALVARIABLE $i$a$-let-Main$main2$1 I L7 L12 6
    LOCALVARIABLE this Lcom/cgm/experiments/Main; L0 L17 0
    LOCALVARIABLE x Ljava/lang/String; L0 L17 1
    MAXSTACK = 2
    MAXLOCALS = 8

  // access flags 0x11
  public final main3(Ljava/lang/String;)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
    @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
   L0
    LINENUMBER 13 L0
    ALOAD 1
    IFNULL L1
   L2
    LINENUMBER 14 L2
   L3
    ICONST_0
    ISTORE 2
   L4
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 1
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L5
   L1
    LINENUMBER 16 L1
    RETURN
   L6
    LOCALVARIABLE this Lcom/cgm/experiments/Main; L0 L6 0
    LOCALVARIABLE x Ljava/lang/String; L0 L6 1
    MAXSTACK = 2
    MAXLOCALS = 3

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 3 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this Lcom/cgm/experiments/Main; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  @Lkotlin/Metadata;(mv={1, 5, 1}, k=1, d1={"\u0000\u001a\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\u0008\u0002\n\u0002\u0010\u0002\n\u0000\n\u0002\u0010\u000e\n\u0002\u0008\u0003\u0018\u00002\u00020\u0001B\u0005\u00a2\u0006\u0002\u0010\u0002J\u0010\u0010\u0003\u001a\u00020\u00042\u0008\u0010\u0005\u001a\u0004\u0018\u00010\u0006J\u0010\u0010\u0007\u001a\u00020\u00042\u0008\u0010\u0005\u001a\u0004\u0018\u00010\u0006J\u0010\u0010\u0008\u001a\u00020\u00042\u0008\u0010\u0005\u001a\u0004\u0018\u00010\u0006\u00a8\u0006\u0009"}, d2={"Lcom/cgm/experiments/Main;", "", "()V", "main1", "", "x", "", "main2", "main3", "decompile.main"})
  // compiled from: Main.kt
  // debug info: SMAP
Main.kt
Kotlin
*S Kotlin
*F
+ 1 Main.kt
com/cgm/experiments/Main
+ 2 fake.kt
kotlin/jvm/internal/FakeKt
*L
1#1,20:1
1#2:21
*E

}


// ================META-INF/decompile.main.kotlin_module =================


Solution

  • I would like to understand which is the purpose of those variables and why this happens.

    What you see here are stack entries used in the function. If you follow the actual bytecode, you'll see ASTORE N/ISTORE N/ALOAD N/ILOAD N entries. This is where the variables are put on the stack, and the varN variables represent those stack entries. It doesn't always make sense (or is possible) to decompile these stack entries to java code, because it's effectively a bytecode detail that you e.g. have to push some values onto the stack to have these values passed to a function. You'll usually see more stack entries than variables to do stuff like null checks and intrinsic calls.

    In this case I don't know exactly why this many stack entries are used, but it looks like unused placeholders for capturing lambdas in case you'd use them.

    In any case, decompiled Java code shouldn't be used to reason about the bytecode. If you want to better understand what's happening under the hood, I suggest learning bytecode instructions themselves and analyzing that.

    I would like to understand which is the purpose of those variables and why this happens.

    I expect it's just easier for Kotlin to allocate those stack entries even if they're not used.

    However, the important bit is this:

    understand if my favorite approach introduces any kind of inefficiency

    Such analysis is not very useful. For example Kotlin might generate those empty stack entries because it's trivial for a JVM to optimize them away, seeing as they're never read. Unless you observe an actual performance impact when executing these different approaches on a real JVM, you should assume they're all equally efficient. Even if there's some performance impact, it's negligible, and I doubt it'd be measurable under any other workloads than microbenchmarks, which aren't usually representative or real-world scenarios. Use whichever version you like best.

    Side note: IMO all those three approaches should be used in different situations. Use an if (x != null) if you actually want to check if something is null, use ?.let if you want to map a nullable value to another value, and use ?.run if it's useful to capture the nullable value as a receiver. x?.run { println(this) } is not very idiomatic and is hard to read. In this case, you should worry more about code readability than performance.