Kotlin — A deeper look

It feels like magic… But is it really?

https://trends.google.com/trends/explore?q=%2Fm%2F0_lcrx4

What you’re looking at is Google Trends, where I’ve looked up “kotlin”. Can you see that sudden spike? That’s when Google announced that the Kotlin programming language is now a first-class citizen in Android, at its Google I/O conference that took place a couple of weeks ago.
By now, either you were already using it in the past, or you have been diving your face into the language because everyone is suddenly talking about it.

One of the most prominent features of the language is the interoperability with Java: this means that you can call Kotlin code from Java in the same way that you can call Java code from Kotlin. This is (and has been) probably the most important peculiarity that pushed the adoption forward. You do not need to migrate everything at once: you can simply take a piece of your existing code base and start adding Kotlin code, and just like that, it’ll work. You can experiment with Kotlin, and if you don’t like what you see, you can always go back (even though I dare you to do so).

When I first started using Kotlin at Clue, coming from 5 years of Java, some things felt just like magic.

“Wait, what? I can simply write data class to avoid boilerplate?”
“Wait, so if I write apply then I no longer need to specify the object every time I want to invoke a method on it?”

After the initial sigh of relief for finally having a language that doesn’t feel old and cumbersome, I started feeling a little uncomfortable. If interoperability with Java is a requirement, how exactly does Kotlin implement these nice language features? What’s the catch?

This is what this article is about. I was super curious about knowing how the Kotlin compiler translates certain constructs so that they can interoperate with Java, and I chose to take a look at the four most used methods of the Kotlin Standard Library:

  1. apply
  2. with
  3. let
  4. run

After reading this, you should not feel scared anymore. I feel way more confident now that I know how things work, and I know I can trust the language and the compiler.

Apply

apply is pretty simple: it is an extension function that executes the block parameter on the instance of the extended type (called “receiver”) and returns the receiver itself. 
There are many use cases where this function comes in hand. You may bind the creation of an object to its initial configuration, like so:

val layout = LayoutStyle().apply { orientation = VERTICAL }

As you can see, we are providing a configuration for our new LayoutStyle right at the creation site, which contributes to a cleaner code and to a much less error prone implementation. Has it ever happened to you to call methods on the wrong instance, just because of a similar name? Or worse, a refactoring gone horribly wrong? With this approach it is definitely harder to fall into these pits.
Also, note how we do not have to specify the this parameter: because we are in the same scope as the class itself, it’s as if we were extending that very class, thus this is implicit.

But how does that work? Let’s take a look at a brief example. Consider this simple snippet:

Thanks to IntelliJ IDEA’s “Show Kotlin bytecode” tool (Tools > Kotlin > Show Kotlin Bytecode), we can inspect how the compiler translates our code into JVM bytecode:

If you’re not too familiar with bytecode, I suggest you to read these great articles that will give you a much clearer idea (in this case, one important thing to keep in mind is that every method invocation pops the stack, so the compiler needs to load the object every time).

Let’s break this down:

  1. Create a new instance of LayoutStyle and duplicate it on the stack
  2. Call the constructor with zero parameters
  3. Do a bunch of store/load (more on that later)
  4. Push the Orientation.VERTICAL value to the stack
  5. Invoke setOrientation, which pops the object and the value from the stack

We notice a couple of things here. First of all, there is no magic behind the scene, it all happens as you would expect: the setOrientation method is called on the LayoutStyle instance that we have created. In addition, the apply function is nowhere to be seen, because the compiler was instructed to inline it.

And most of all, the bytecode is almost identical to the one that is generated if you do the same thing in Java! Just see for yourself:

Pro Tip: you may have noticed a lot of ASTORE/ALOAD operations. Those are inserted by the Kotlin compiler so that the debugger works also for lambdas! We’re going to elaborate this in the last section of the article.

With

with may look similar to apply, but denotes some prominent differences. First of all, with is not an extension function over a type: the receiver has to be explicitly passed as a parameter. Moreover, with returns the result of the block function, whereas apply returns the receiver itself.

Since we have the freedom to return whatever we please, something like this is totally plausible:

val layout = with(contextWrapper) { 
// `this` is the contextWrapper
LayoutStyle(context, attrs).apply { orientation = VERTICAL }
}

In this example we can omit the contextWrapper. prefix for context and attrs because contextWrapper is the receiver of the with function. Even though the use cases are far less obvious than what you can think for apply, this function can become really useful in particular circumstances.

With that in mind, let’s go back to our example and see what happens if we use with:

The receiver for with is a singleton called SharedState, which contains an orientation parameter that we would like our layout to have. Inside the block function we create the LayoutStyle instance and, thanks to apply, we are simply able to set the orientation with the one we read from the SharedState.

Now let’s look again at the generated bytecode:

Again, there is really nothing special here. The singleton, which is implemented as a static field on the SharedState class, is retrieved; the LayoutStyle instance is created just like before, there’s a call to the constructor, another invocation to get the value for previousOrientation inside SharedState and one last invocation to assign it to our LayoutStyle instance.

Pro Tip: when using “Show Kotlin Bytecode”, you can also press “Decompile” to see a Java representation of the bytecode produced by the Kotlin compiler. Spoiler alert: it’s exactly what you would expect!

Let

let is very useful when you’re dealing with nullable objects. Instead of chaining endless if-else statements, you can simply combine the ? operator (called “safe call operator”) with let: what you end up with is a lambda where the argument it is a not-nullable version of the original object.

val layout = LayoutStyle()
SharedState.previousOrientation?.let { layout.orientation = it }

Let’s see the entire example:

Now that previousOrientation is nullable, if we tried to assign that directly to our layout, the compiler would complain because a nullable type cannot be assigned to a non-nullable type. Of course we could write an if statement, but that would mean referencing the SharedState.previousOrientation value twice: by using let instead, we get a not-nullable reference to the same parameter, which can safely be assigned to our layout.

From a bytecode perspective, it’s very straightforward:

It all resorts to a simple conditional jump IFNULL, which is essentially what you would have done by hand, except this time the compiler does that efficiently for you and the language offers you a nice way of writing that code. I think this is awesome!

Run

There are two versions of run, one is a simple function and the other is an extension function over a generic type. Since the former does nothing more than calling the block function that is passed as a parameter, we’re going to focus the analysis on the latter.

run is probably the simplest function among the ones that we have met so far. It is defined as an extension function over a type, whose instance is then passed as the receiver, and returns the result of executing the block function. You might think that run is somehow a hybrid between let and apply, and you would be right, the only difference being the return value: in the case of apply we return the receiver itself, in the case of run we return the result of the block function (just like we do on let).

So the following example highlights the fact that run returns the result of the block function, so in this case an assignment (Unit):

The bytecode equivalent is then:

We can see that run has been inlined, just like the other functions, and everything resolves to just plain method invocations. Nothing weird to see here as well!

We have noticed that there are a lot of similarities between the Standard Library functions: this is done on purpose, so to have you covered for as many use cases as possible. On the other hand, figuring out what functions suit you best for a particular task is not so immediate, given the slight variations between each one of them.
To help you navigate the Standard Library, here’s a handy table that sums up the differences between the main functions that we’ve covered (with the exception of also):

Huge thanks to Eugenio for sharing this with me!

Appendix: the extra store/load operations

Before we close the curtains on this analysis, there was still something that I couldn’t really understand when comparing the “Java bytecode” and the “Kotlin bytecode”. More specifically, as I have already mentioned before, there were some extra couples of astore/aload operations coming from Kotlin that Java was missing. I knew it had something to do with lambdas, but I couldn’t really figure out what their use was.

Turns out that those extra operations are necessary for the debugger to treat lambdas as stack frames and, in turn, to allow us to step into them. In this way we can see what the local variables are, who the caller of the lambda is, who will be called from within the lambda, and so on.

However, when we ship an APK to production we don’t really care about debugger features, do we? So we can think of those instructions, even if small and negligible, as overhead that should and could be removed.

ProGuard, the tool we all know and “love”, could be the right tool for the job. It operates at bytecode level and, among other things like obfuscation and shrinking, it also does optimization passes to try and slim down the bytecode. So I tried to write the same piece of code in both Java and Kotlin, apply ProGuard with the same set of rules to both of them, and compare the results. Here’s what I found.

ProGuard configuration

ProGuard configuration

Source code

Java
Kotlin

Bytecode

Java
Kotlin

After comparing the two bytecode listings we can observe the following:

  1. The extra astore/aload operations from the “Kotlin bytecode” are gone, because ProGuard figured they were superfluous and promptly removed them (interestingly enough, a single optimization pass didn’t do the trick, two were required)
  2. The “Java bytecode” and the “Kotlin bytecode” are almost identical; the former has some interesting/weird of dealing with the enum value, whereas in Kotlin there is no such thing

Conclusion

It’s great to have a new language that offers so many more possibilities to developers, but it is also important to know that we can rely on the tools that we use and that we feel confident in using them.

I’m glad that I can say “I can trust Kotlin”, in the sense that I know that the compiler is not doing anything exuberant or risky: it’s simply doing what we would have to do by hand in Java, saving us time and resources (and restoring some of the long lost fun in the activity of coding for the JVM). It also benefits the end users to some extent, because thanks to a much stronger type safety, hopefully we can ship less bugs in our apps.

In addition, the Kotlin compiler is constantly being improved, therefore the code it outputs keeps getting better and more efficient. With that said, we should not try to optimize our Kotlin code based on the compiler, but rather stick to the best (as in, efficient and idiomatic) Kotlin code we can write and leave all the rest to the compiler itself.

Topics of interest

More Related Stories