https://trends.google.com/trends/explore?q=%2Fm%2F0_lcrx4
The above is a screenshot of Google Trends when I searched for the word "kotlin". A sudden surge is when Google announced that Kotlin is becoming the main language in Android . It happened at the Google I / O conference a few weeks ago. To date, you have either used this language before, or are interested in it, because everyone around you suddenly started talking about it.
One of the main features of Kotlin is its mutual compatibility with Java: you can call Kotlin code from Java, and Java code from Kotlin. This is perhaps the most important feature by which the language is widely distributed. You do not need to migrate everything at once: just take a piece of the existing code base and start adding the Kotlin code, and this will work. If you experiment with Kotlin and you do not like it, then you can always refuse it (although I recommend trying it).
When I first used Kotlin after five years in Java, some things seemed like real magic to me.
“Wait, what? Can I just write a data class
to avoid templating code? ”
“Stop, so if I write apply
, then I no longer need to define an object every time I want to call a method with reference to it?”
After the first sigh of relief from the fact that at last there was a language that does not look outdated and cumbersome, I began to feel some discomfort. If mutual compatibility with Java is required, how exactly all these great features are implemented in Kotlin? What's the catch?
This article is dedicated to. I was very interested in finding out how the Kotlin compiler transforms concrete constructs so that they become interoperable with Java. For my research, I chose the four most requested methods from the Kotlin standard library :
apply
with
let
run
When you read this article, you will no longer need to fear. Now I feel much more confident, because I understood how everything works, and I know that I can trust the language and the compiler.
/** * [block] `this` `this`. */ @kotlin.internal.InlineOnly public inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this }
apply
is simple: it is an extension function that executes the block parameter in relation to an extended type instance (it is called the "receiver") and returns the receiver itself.
There are many ways to use this feature. You can bind the creation of an object to its initial configuration:
val layout = LayoutStyle().apply { orientation = VERTICAL }
As you can see, we provide the configuration for the new LayoutStyle
right at creation, which contributes to the purity of the code and the implementation, which is much less error prone . Did it happen to call the method in relation to the wrong instance, because it had the same name? Or even worse, when refactoring was completely wrong? With the above approach it will be much more difficult to face such troubles. Also note that it is not necessary to define the this
parameter: we are in the same scope as the class itself . It’s as if we were extending the class itself, so this
is implicitly specified.
But how does this work? Let's take a quick example.
enum class Orientation { VERTICAL, HORIZONTAL } class LayoutStyle { var orientation = HORIZONTAL } fun main(vararg args: Array<String>) { val layout = LayoutStyle().apply { orientation = VERTICAL } }
Thanks to the IntelliJ IDEA Show Kotlin bytecode tool ( Tools > Kotlin > Show Kotlin Bytecode
), we can see how the compiler converts our code into JVM bytecode:
NEW kotlindeepdive/LayoutStyle DUP INVOKESPECIAL kotlindeepdive/LayoutStyle.<init> ()V ASTORE 2 ALOAD 2 ASTORE 3 ALOAD 3 GETSTATIC kotlindeepdive/Orientation.VERTICAL : Lkotlindeepdive/Orientation; INVOKEVIRTUAL kotlindeepdive/LayoutStyle.setOrientation (Lkotlindeepdive/Orientation;)V ALOAD 2 ASTORE 1
If you are not too good at bytecode, then I suggest reading these wonderful articles , after them you will understand much better (remember that when calling each method, the stack is accessed, so the compiler needs to load the object each time).
We will sort by points:
LayoutStyle
and duplicated LayoutStyle
stack.Orientation.VERTICAL
passed to the stack.setOrientation
, which raises an object and a value from the stack.Here we note a couple of things. First, no magic is involved, everything happens as expected: the LayoutStyle
method is called for the setOrientation
instance we created. In addition, the apply
function is nowhere to be seen, because the compiler inline it .
Moreover, the bytecode is almost identical to the one generated by using only Java! Judge for yourself:
// Java enum Orientation { VERTICAL, HORIZONTAL; } public class LayoutStyle { private Orientation orientation = HORIZONTAL; public Orientation getOrientation() { return orientation; } public void setOrientation(Orientation orientation) { this.orientation = orientation; } public static void main(String[] args) { LayoutStyle layout = new LayoutStyle(); layout.setOrientation(VERTICAL); } } // Bytecode NEW kotlindeepdive/LayoutStyle DUP ASTORE 1 ALOAD 1 GETSTATIC kotlindeepdive/Orientation.VERTICAL : kotlindeepdive/Orientation; INVOKEVIRTUAL kotlindeepdive/LayoutStyle.setOrientation (kotlindeepdive/Orientation;)V
Tip: You may have noticed a large number of ASTORE/ALOAD
. They are inserted by the Kotlin compiler, so the debugger works for lambdas too! We will talk about this in the last section of the article.
/** * [block] [receiver] . */ @kotlin.internal.InlineOnly public inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block()
with
looks similar to apply
, but there are some important differences. First, with
not a type extension function: the receiver must be explicitly passed as a parameter. Moreover, with
returns the result of the block function, and apply
- the recipient itself.
Since we can return anything, this example looks very believable:
val layout = with(contextWrapper) { // `this` is the contextWrapper LayoutStyle(context, attrs).apply { orientation = VERTICAL } }
Here you can omit the contextWrapper
prefix. for context
and attrs
, because contextWrapper
is the receiver of the with
function. But even in this case, the application methods are not so obvious as compared to apply
, this function may be useful under certain conditions.
Given this, let us return to our example and see what happens if we use with
:
enum class Orientation { VERTICAL, HORIZONTAL } class LayoutStyle { var orientation = HORIZONTAL } object SharedState { val previousOrientation = VERTICAL } fun main() { val layout = with(SharedState) { LayoutStyle().apply { orientation = previousOrientation } } }
The recipient with
is a SharedState
singleton, it contains an orientation parameter (orientation parameter) that we want to set for our layout. Inside the block function, we create an instance of LayoutStyle
, and thanks to apply
we can simply set the orientation, reading it from SharedState
.
Look again at the generated bytecode:
GETSTATIC kotlindeepdive/SharedState.INSTANCE : Lkotlindeepdive/SharedState; ASTORE 1 ALOAD 1 ASTORE 2 NEW kotlindeepdive/LayoutStyle DUP INVOKESPECIAL kotlindeepdive/LayoutStyle.<init> ()V ASTORE 3 ALOAD 3 ASTORE 4 ALOAD 4 ALOAD 2 INVOKEVIRTUAL kotlindeepdive/SharedState.getPreviousOrientation ()Lkotlindeepdive/Orientation; INVOKEVIRTUAL kotlindeepdive/LayoutStyle.setOrientation (Lkotlindeepdive/Orientation;)V ALOAD 3 ASTORE 0 RETURN
Nothing special. Singleton extracted, implemented as a static field in the SharedState
class; an instance of LayoutStyle
is created in the same way as before, a constructor is called, another call to get the previousOrientation
value inside a SharedState
and the last call to assign a value to an instance of LayoutStyle
.
Tip: when using Show Kotlin Bytecode, you can click Decompile and see the Java representation of the bytecode created for the Kotlin compiler. Spoiler: it will be exactly as you expect!
/** * [block] `this` . */ @kotlin.internal.InlineOnly public inline fun <T, R> T.let(block: (T) -> R): R = block(this)
let
very useful when working with objects that can be null. Instead of creating endless chains of if-else expressions, you can simply combine the operator ?
(called the “safe call operator”) with let
: as a result, you get a lambda, in which the argument it
is a non-nullable version of the original object.
val layout = LayoutStyle() SharedState.previousOrientation?.let { layout.orientation = it }
Consider the whole example:
enum class Orientation { VERTICAL, HORIZONTAL } class LayoutStyle { var orientation = HORIZONTAL } object SharedState { val previousOrientation: Orientation? = VERTICAL } fun main() { val layout = LayoutStyle() // layout.orientation = SharedState.previousOrientation -- this would NOT work! SharedState.previousOrientation?.let { layout.orientation = it } }
Now previousOrientation
can be null. If we try to assign it directly to our layout, the compiler will be indignant, because the nullable type cannot be assigned to the non-nullable type. Of course, you can write an if expression, but this will lead to a double reference to the SharedState.previousOrientation
expression. And if we use let
, we get a non-nullable link to the same parameter that can be safely assigned to our layout.
From the point of view of bytecode, everything is very simple:
NEW kotlindeepdive/let/LayoutStyle DUP INVOKESPECIAL kotlindeepdive/let/LayoutStyle.<init> ()V GETSTATIC kotlindeepdive/let/SharedState.INSTANCE : Lkotlindeepdive/let/SharedState; INVOKEVIRTUAL kotlindeepdive/let/SharedState.getPreviousOrientation ()Lkotlindeepdive/let/Orientation; DUP IFNULL L2 ASTORE 1 ALOAD 1 ASTORE 2 ALOAD 0 ALOAD 2 INVOKEVIRTUAL kotlindeepdive/let/LayoutStyle.setOrientation (Lkotlindeepdive/let/Orientation;)V GOTO L9 L2 POP L9 RETURN
It uses a simple IFNULL
conditional transition, which, in fact, you would have to do manually, except for this time, when the compiler effectively executes it for you, and the language offers a pleasant way of writing such code. I think this is great!
There are two versions of the run: the first is a simple function, the second is an extension function of a generic type (generic type). Since the first only calls the function block, which is passed as a parameter, we will analyze the second.
/** * [block] `this` . */ @kotlin.internal.InlineOnly public inline fun <T, R> T.run(block: T.() -> R): R = block()
Perhaps, run
is the simplest of the functions considered. It is defined as an extension function of the type, whose instance is then passed as the receiver and returns the result of executing the block
function. It may seem that run
is a hybrid of let
and apply
, and this is true. The only difference is in the return value: in the case of apply
we return the receiver itself, and in the case of run
, the result of the block
function (as in let
).
This example emphasizes the fact that run
returns the result of the block
function, in this case, the assignment ( Unit
):
enum class Orientation { VERTICAL, HORIZONTAL } class LayoutStyle { var orientation = HORIZONTAL } object SharedState { val previousOrientation = VERTICAL } fun main() { val layout = LayoutStyle() layout.run { orientation = SharedState.previousOrientation } // returns Unit }
Equivalent bytecode:
NEW kotlindeepdive/LayoutStyle DUP INVOKESPECIAL kotlindeepdive/LayoutStyle.<init> ()V ASTORE 0 ALOAD 0 ASTORE 1 ALOAD 1 ASTORE 2 ALOAD 2 GETSTATIC kotlindeepdive/SharedState.INSTANCE : Lkotlindeepdive/SharedState; INVOKEVIRTUAL kotlindeepdive/SharedState.getPreviousOrientation ()Lkotlindeepdive/Orientation; INVOKEVIRTUAL kotlindeepdive/LayoutStyle.setOrientation (Lkotlindeepdive/Orientation;)V RETURN
run
was inline, like other functions, and it all comes down to simple method calls. Here, too, there is nothing strange!
We noted that there are many similarities between the functions of the standard library: this is done intentionally to cover as many applications as possible. On the other hand, it is not so easy to understand which of the functions is best suited for a particular task, given the small differences between them.
To help you deal with the standard library, I drew a table that summarizes all the differences between the main features considered (except also ):
store/load
operationsI still could not fully understand when comparing "Java-bytecode" and "Kotlin-bytecode". As I said before, in Kotlin, unlike Java, there were additional operations astore/aload
. I knew that it was somehow connected with lambdas, but I could figure out why they were needed.
It seems that these additional operations are necessary for the debugger to process lambdas as stack frames , which allows us to intervene (step into) in their work. We can see what the local variables are, who causes the lambda, who will be called from the lambda, etc.
But when we send the APK to production, we don’t care about the debugger’s capabilities, right? So, these functions can be considered redundant and subject to removal, despite their small size and insignificance.
For this, ProGuard , a well-known and well-loved tool, can be suitable. It works at the bytecode level and, in addition to obfuscation and cutting, also performs optimization passes to make the bytecode more compact. I wrote the same piece of Java and Kotlin code, applied it to both versions of ProGuard with one set of rules, and compared the results. That's what came to light.
-dontobfuscate -dontshrink -verbose -keep,allowoptimization class kotlindeepdive.apply.LayoutStyle -optimizationpasses 2 -keep,allowoptimization class kotlindeepdive.LayoutStyleJ
Java:
package kotlindeepdive enum OrientationJ { VERTICAL, HORIZONTAL; } class LayoutStyleJ { private OrientationJ orientation = HORIZONTAL; public OrientationJ getOrientation() { return orientation; } public LayoutStyleJ() { if (System.currentTimeMillis() < 1) { main(); } } public void setOrientation(OrientationJ orientation) { this.orientation = orientation; } public OrientationJ main() { LayoutStyleJ layout = new LayoutStyleJ(); layout.setOrientation(VERTICAL); return layout.orientation; } }
Kotlin:
package kotlindeepdive.apply enum class Orientation { VERTICAL, HORIZONTAL } class LayoutStyle { var orientation = Orientation.HORIZONTAL init { if (System.currentTimeMillis() < 1) { main() } } fun main() { val layout = LayoutStyle().apply { orientation = Orientation.VERTICAL } layout.orientation } }
Java:
sgotti@Sebastianos-MBP ~/Desktop/proguard5.3.3/lib/PD/kotlindeepdive > javap -c LayoutStyleJ.class Compiled from "SimpleJ.java" final class kotlindeepdive.LayoutStyleJ { public kotlindeepdive.LayoutStyleJ(); Code: 0: aload_0 1: invokespecial #8 // Method java/lang/Object."<init>":()V 4: aload_0 5: getstatic #6 // Field kotlindeepdive/OrientationJ.HORIZONTAL$5c1d747f:I 8: putfield #5 // Field orientation$5c1d747f:I 11: invokestatic #9 // Method java/lang/System.currentTimeMillis:()J 14: lconst_1 15: lcmp 16: ifge 34 19: new #3 // class kotlindeepdive/LayoutStyleJ 22: dup 23: invokespecial #10 // Method "<init>":()V 26: getstatic #7 // Field kotlindeepdive/OrientationJ.VERTICAL$5c1d747f:I 29: pop 30: iconst_1 31: putfield #5 // Field orientation$5c1d747f:I 34: return }
Kotlin:
sgotti@Sebastianos-MBP ~/Desktop/proguard5.3.3/lib/PD/kotlindeepdive > javap -c apply/LayoutStyle.class Compiled from "Apply.kt" public final class kotlindeepdive.apply.LayoutStyle { public kotlindeepdive.apply.LayoutStyle(); Code: 0: aload_0 1: invokespecial #13 // Method java/lang/Object."<init>":()V 4: aload_0 5: getstatic #11 // Field kotlindeepdive/apply/Orientation.HORIZONTAL:Lkotlindeepdive/apply/Orientation; 8: putfield #10 // Field orientation:Lkotlindeepdive/apply/Orientation; 11: invokestatic #14 // Method java/lang/System.currentTimeMillis:()J 14: lconst_1 15: lcmp 16: ifge 32 19: new #8 // class kotlindeepdive/apply/LayoutStyle 22: dup 23: invokespecial #16 // Method "<init>":()V 26: getstatic #12 // Field kotlindeepdive/apply/Orientation.VERTICAL:Lkotlindeepdive/apply/Orientation; 29: putfield #10 // Field orientation:Lkotlindeepdive/apply/Orientation; 32: return }
Conclusions after comparing the two bytecode listings:
astore/aload
in “Kotlin-bytecode” disappeared, because ProGuard considered them redundant and immediately deleted (curiously, this required two optimization passes, after one they were not deleted).It's great to get a new language offering so many possibilities to developers. But it is also important to know that we can rely on the tools used, and feel confident when working with them. I'm glad I can say: “I trust Kotlin”, in the sense that I know: the compiler does nothing extra or risky. It only does what we need to do manually in Java, saving us time and resources (and returns the long lost joy of coding for the JVM). To some extent, this also benefits the end users, because, due to more stringent type safety, we will leave fewer bugs in the applications.
In addition, the Kotlin compiler is constantly improving, so that the generated code becomes more efficient. So no need to try to optimize Kotlin-code using the compiler, it is better to focus on writing more efficient and idiomatic code, leaving everything else to the compiler.
Source: https://habr.com/ru/post/331442/
All Articles