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 =================
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.