Kotlin’s Noinline & Crossline, once for all
Kotlin, as most of the programming languages, has several reserved keywords.
You can find all of them listed on this page of the official documentation: keyword-reference.
I invite you to spend some time going through the list. Do you know all the keywords?
If you wrote some Kotlin code, you probably encountered most of them. Some keywords in that list though are less commonly used, therefore they could be harder to remember. One example: the noinline
and the crossinline
keywords.
The keyword reference gives the following definitions:
noinline
turns off inlining of a lambda passed to an inline functioncrossinline
forbids non-local returns in a lambda passed to an inline function
The latter can sound a bit complicated for an inexperienced developer. Given that those keywords are not frequently used in daily development, remembering the exact semantic could be harder. If you speak with other Kotlin developers, chances are that they never used the crossinline
keyword, and they probably don’t know when to use it.
The official documentation can help understand those keywords, but in this case, I believe an example is worth a thousand words.
In this blog-post, we will learn the semantic of the noinline
and crossinline
keyword, with a simple example using the Kotlin Playground (with snippets that you can run and edit in your browser to follow along the article).
inline
To explain those keywords, we have to first explain the inline
keyword, and the Inline Functions feature of Kotlin.
Let’s start with an example: a function to do something if you’re running on Debug:
fun doOnDebug(block: () -> Unit) {
// On Android you can replace with a BuildConfig.DEBUG
if (Env.DEBUG) {
block()
}
}
fun main() {
doOnDebug {
println("I'm on debug ¯\\_(ツ)_/¯")
}
}
object Env {
val DEBUG = true
}
The function is trivial: it takes a lambda as a parameter and executes it if you execute on a Debug environment (we are simulating it using the Boolean DEBUG
property).
Using the Show Kotlin Bytecode feature of IntelliJ, we can see how the equivalent bytecode code will look like:
Are you wondering what that (Function0)null.INSTANCE
means? 🤨 You’re not alone.
There is a bug (IDEABKL-7385) in the Kotlin Bytecode decompiler of IntelliJ that causes the lambda class to be lost (and replaced with a null
).
If you use JD-GUI to analyze the bytecode instead, you will see that the parameters is actually Foo$main$1.INSTANCE
(decompiled class with JD GUI) and the resulting lambda is also decompiled correctly with the corresponding .INSTANCE
field (decompiled lambda class with JD GUI).
As we can see from the image, the doOnDebug
function is compiled to a function that takes a Function0
as a single parameter. Function0
is an interface declared in the Kotlin Standard Library for the JVM. It’s a functional interface, used to pass a function that takes zero parameters from Java to Kotlin.
This means that for every doOnDebug{}
invocation we create an instance of a Function0
causing memory allocation. This creates runtime overhead that can be problematic, especially if you use a lot of high-order functions with lambdas like this one.
Kotlin offers the Inline Functions feature exactly to mitigate this overhead. If we add the inline
keyword in front of the function definition:
inline fun doOnDebug(block: () -> Unit)
The resulted bytecode will look like this:
As we can see from the bytecode, the doOnDebug
code is inlined in the main()
function body. The lambda parameter is affected as well as its body is also copied.
In other words, inline will copy-paste the function body, and the lambda parameter at every call site.
This means that we can avoid creating an instance of a Function0
at all, resulting in saved memory allocation.
To recap, the inline
keywords allows you to reduce the runtime overhead of functions taking lambda as parameters by:
- Inlining their body at every call site.
- Inlining every lambda parameter at the call site.
Please note that inlining functions can increase the size of your generated code, so make sure you do it only for small functions.
The full example is available in this playground (you can edit the code and run it as well):
With noinline
and crossinline
you can then customize the inline
behavior, let’s see how.
noinline
Let’s now extend our example with some logging on the main()
function:
fun main() {
println("START main()")
doOnDebug {
println("I'm on debug ¯\\_(ツ)_/¯")
}
println("END main()")
}
And let’s add some logging to doOnDebug
as well.
For doOnDebug
, since it’s a util function, we also want to specify which logger to use.
For the sake of simplicity, a logger will be just a simple function:
logger: (String) -> Unit
So we can extend the doOnDebug
with invocations of this logger.
Moreover, we also want to flush it at the end of the function.
Let’s assume we invoke a flush
function:
inline fun doOnDebug(
logger: (String) -> Unit,
block: () -> Unit
) {
if (Env.DEBUG) {
logger("[LOG] Running doOnDebug...")
block()
logger("[LOG] Flushing the log...")
flush(logger)
}
}
fun flush(logger: (String) -> Unit) {
// Flush the logger here
}
As it is right now, this code won’t compile. It will fail with the following error message:
Illegal usage of inline-parameter 'logger' in 'public inline fun doOnDebug(logger: (String) -> Unit = ..., block: () -> Unit): Unit defined in root package in file File.kt'. Add 'noinline' modifier to the parameter declaration
The error message suggests we add the noinline
keyword to the logger
parameter. Adding this will make the code compile:
inline fun doOnDebug(
noinline logger: (String) -> Unit,
Why is this needed?
As previously mentioned, the inline
keyword affects the function body and all the lambda parameters.
In our example, logger
is inlined as well.
Inlining a lambda limits what you can do with it. You can only invoke it. If you wish to store it in a variable or pass them to another function (as we do for the flush
function) you need to tell the compiler to avoid inlining it.
If we try to run, the console output now will be:
START main()
[LOG] Running doOnDebug...
I'm on debug ¯\_(ツ)_/¯
[LOG] Flushing the log...
END main()
To recap, the noinline
keyword is a mechanism to prevent the inlining of a specific lambda parameter of an inline function. It’s useful if you wish to pass the lambda around or store it in a variable.
The full example until here is available in this playground:
crossinline
Let’s go back to the call site, and complicate our function a bit more.
First, we add a times: Int = 1
parameters, to allow repeating the lambda multiple times:
inline fun doOnDebug(
noinline logger: (String) -> Unit,
times : Int = 1,
block: () -> Unit
) {
if (Env.DEBUG) {
logger("[LOG] Running doOnDebug...")
repeat(times) {
logger("[LOG] Iteration #$it...")
block()
}
logger("[LOG] Flushing the log...")
flush(logger)
}
}
Then, our code contains a katakana symbol: ツ. Let’s say we want to play defensive and avoid printing if the terminal doesn’t support UTF-8.
Let’s update the doOnDebug
call site like this:
doOnDebug {
if(!Env.UTF8_SUPPORT) {
return
}
println("I'm on debug ¯\\_(ツ)_/¯")
}
As long as Env.UTF8_SUPPORT
is true
, everything goes smoothly. Here is what is printed on the console:
START main()
[LOG] Running doOnDebug...
[LOG] Iteration #0...
I'm on debug ¯\_(ツ)_/¯
[LOG] Flushing the log...
END main()
But if you try to change Env.UTF8_SUPPORT
to false
, this will happen:
START main()
[LOG] Running doOnDebug...
[LOG] Iteration #0...
Seems like the execution terminated at the return
, but the flushing of the logger and END main()
line are missing 🧐.
Time to (de)-bug
To fully understand what is going on, let’s introduce a bug 🐛.
Instead of Env.UTF8_SUPPORT
returning always either true
or false
, let’s call Random
.
We can introduce some flakyness by letting Env.UTF8_SUPPORT
return true
only 80% of the times:
val UTF8_SUPPORT : Boolean get() = Random.nextInt(5) != 0
Now let’s try to call doOnDebug
with repeat = 10
and see what’s the output on the console.
We expect to see on the average ~9 shrug on the screen:
Run main()
[LOG] Running doOnDebug
[LOG] Iteration 0
¯\_(ツ)_/¯
[LOG] Iteration 1
¯\_(ツ)_/¯
[LOG] Iteration 2
What happens instead is that in my run, after two iterations, UTF8_SUPPORT
is false
and the whole execution is halted.
What is happening here?
Given that the lambda parameter of doOnReturn
is inlined, also the return
is inlined at the call site. This means that in the resulting code, that return
is causing a return of the outer function, the main()
.
This is called a non-local return in Kotlin.
To prevent this behavior we can mark the parameter block
as crossinline
. This keyword prevents non-local returns in the specified lambda parameter.
If we try to apply it to our function:
inline fun doOnDebug(
noinline logger: (String) -> Unit,
times: Int = 1,
crossinline block: () -> Unit
We will see that our code will not compile anymore with the following error message:
'return' is not allowed here
The IDE autocompletion is also suggesting you to use a labeled return: return@doOnDebug
. That will make sure your return will apply only to the doOnDebug
function and not to the main()
function.
The full example of this article is available in this playground:
I hope that with some examples, the inline
, noinline
, and crossinline
keywords are clearer now.
Happy inlining 🚇
Leave a comment