Thursday 2 February 2023

Kotlin and inline functions

One of the many surprising (at least for me) features that one finds in Kotlin is inline functions. The Inline expansion of a function was not new to me. I know that compilers (either ahead of time compilers or JIT's) do it occasionally. They come across a small function and based on some rules consider that it will be faster to unroll its code in the callsite that invoking it. What was new to me is that the programmer can tell the compiler which functions to inline, it's the first time I come across with this option but I've been investigating and this exists also in F#, Scala and C-C++.

This excellent article explains pretty well why inlining is important in Kotlin (pretty interesting also how, as we saw in my previous post, the compiler creates either singletons or normal classes for hosting function references) . Long in short, in Kotlin we use functions (lambdas) in places where in other languages we just write the code in place (scope functions are a great example, and indeed I still don't find them particularly appealing save for DSL cases). This comes with a performance price, so Kotlin guys (that are really, really clever people) came up with the inline thing.

The article says:

When using inline functions, the compiler inlines the function body. That is, it substitutes the body directly into places where the function gets called. By default, the compiler inlines the code for both the function itself and the lambdas passed to it.

So with regards to inlining the functions passed to the inline function it only mentions lambdas (same as the kotlin documentation does), so I was wondering if an anonymous function would not be inlined. I've just done a fast test to verify that yes, it is inlined. In these 2 functions below (notice that the apply scope function is defined with the inline modifier) both the lambda expression and the anonymous funtion get inlined.


// notice that apply is defined like this:
// inline fun <T> T.apply(block: T.() -> Unit): T

class Person constructor (var name: String, var age: Int, var city: String) {
    fun sayHi(): String {
        return "Bonjour, je suis $name et j'habite a {city}"
    }
}

// lambda gets inlined
fun test1() {
    val p1 = Person("Francois", 4, "Xixón")
    p1.apply {
        name = name.uppercase()
        age += 1
    }
}

// anonymous function defined in place gets inlined
fun test2() {
    val p1 = Person("Francois", 4, "Xixón")
    p1.apply(fun(p: Person) {
        p.name = p.name.uppercase()
        p.age += 1
    })
}

All the examples for inlining that I've seen show the lambda being defined right in the place of the parameter to the function invokation, but what if we define it previously? It's clear that in most cases inlining can not be done. Normally when we have a variable that references the compiler can not know what specific function that variable will be referencing to (maybe the variable is initialized from a parameter, maybe there are conditionals...). But there are very specific cases where the compiler could check the whole function and know the real value being used and inline it. I mean cases that simple as this:


// lambda not defined right in place does not get inlined
fun test3() {
    val p1 = Person("Francois", 4, "Xixón")
    val fn: Person.() -> Unit = { 
        name = name.uppercase()
        age += 1
    }
    p1.apply(fn)
}

//anonymous function not defined right in place does not get inlined
fun test4() {
    val p1 = Person("Francois", 4, "Xixón")
    val fn: (Person) -> Unit = fun (p: Person) {
        p.name = p.name.uppercase()
        p.age += 1
    }    
    p1.apply(fn)
}

I've verified that it's not being inlined.

There's another topic that is a bit related to inlining, the usage of return. Believe it or not, apart from the normal "return" that we are used to in other languages (an unqualified return), Kotlin also has qualified returns, where the return is accompanied by a label. These labelled returns are also called non-local returns, as when we define and invoke a nested function, they allow it to return from the outer scope. Honestly I find this quite a bit confusing, but probably the most important thing to take from this is: Non qualified returns (unlabelled returns) are not allowed in lambda expressions, unless that the lambda expression is inlined.. In this case, returning from the lambda returns from the first "normal function" enclosing the lambda.

At first it seemed odd to me but now I think I understand the reasoning for this restriction. When we use a lambda as in my test1 function above, that is, define a lambda just in the place where it's being passed as parameter to another function, the visual effect is that it really does not look as function, it looks more as a language construct, like an if{}, while{}. In these cases, if we see a return written inside those {}, it could look more as a return from the enclosing function. If the lambda is being inlined that perception would be right as the return ends up as another statement of the enclosing function, but if it's not inlined, the lambda remains a function and that return would be just returning from it, not from the enclosing function. So in the end the programmer would have to be particularly attentive to what the return is really doing. For example in the same lambda expression a return would be doing different things depending on whether the lambda is declared, assigned to a variable and passed to a function, or whether it's directly defined and passed, in which case the behaviour would be different if the function gets inlined or not... So yes, it could be rather confusing, so restricting the usage of return makes things a bit more straight.

All this talking about "inlining" has reminded me of another very different kind of inlining, the possibility provided by C and C++ of writing inline assembly code. I've just learnt that the Rust also allows writing inline assembly!

No comments:

Post a Comment