Effective Kotlin Item 51: Use the inline modifier for functions with parameters of functional types
This is a chapter from the book Effective Kotlin. You can find it on LeanPub or Amazon.
You might have noticed that nearly all Kotlin higher-order stdlib functions have an inline modifier.
This inline
modifier makes the compiler replace all uses of this function with its body during compilation. Also, all calls of function arguments inside repeat
are replaced with these functions’ bodies. So, the following repeat
function call:
Will be replaced with the following code during compilation:
This is a significant change compared to how functions are executed normally. In a normal function, execution jumps into this function body, invokes all statements, then jumps back to the place from where the function was invoked. Replacing calls with bodies is a significantly different behavior.
There are a few advantages of this behavior:
- A type argument can be reified
- Functions with functional parameters are faster when they are inline
- Non-local return is allowed
There are also some costs to using this modifier. Let’s review all the advantages and costs of the inline
modifier.
A type argument can be reified
Older versions of Java do not have generics. They were added to the Java programming language in 2004 in version J2SE 5.0, but they are still not present in the JVM bytecode, therefore generic types are erased during compilation. For instance, List<Int>
compiles to List
. This is why we cannot check if an object is List<Int>
. We can only check if it is a List
.
For the same reason, we cannot operate on a type argument:
We can overcome this limitation by making a function inline. Function calls are replaced with this function’s body, so uses of type parameters can be replaced with type arguments using the reified modifier:
During compilation, the body of printTypeName
replaces usages, and the reified type argument replaces the type parameter:
reified
is a useful modifier. For instance, it is used in filterIsInstance
from the stdlib to filter only elements of a certain type:
reified
modifier is also used in many libraries and util functions we define ourselves. The example below presents a common implementation of fromJsonOrNull
that uses the Gson library. It also presents how the Koin library uses this kind of function to simplify both dependency injection and module declaration.
Functions with functional parameters are faster when they are inlined
To be more concrete, all functions are slightly faster when they are inlined. There is no need to jump with execution and track the back-stack. This is why small functions that are used very often in the stdlib are often inlined:
However, this difference is most likely insignificant when a function does not have any functional parameters. This is why IntelliJ gives this warning:
To understand why functions with functional parameters are typically faster when marked as inline, we first need to understand what the problem is with operating on functions as objects. These kinds of objects, which are created using function literals, need to be held somehow. In Kotlin/JS, this is simple since JavaScript treats functions as first-class citizens, so there are either functions or function references under functional parameters. In Kotlin/JVM, an object needs to be created using either an anonymous JVM class or a normal class. Therefore, the following lambda expression will be compiled to a class.
// kotlin
val lambda: () -> Unit = {
// code
}
// compiled to JVM equivalent of
Function0<Unit> lambda = new Function0<Unit>() {
public Unit invoke() {
// code
}
};
Notice that this function type is translated to the Function0
type as this is what a function type with no arguments is compiled to in JVM. Functions with more arguments compile to Function1
, Function2
,Function3
, etc.
() -> Unit
compiles toFunction0<Unit>
() -> Int
compiles toFunction0<Int>
(Int) -> Int
compiles toFunction1<Int, Int>
(Int, Int) -> Int
compiles toFunction2<Int, Int, Int>
All these interfaces are generated by the Kotlin compiler. You cannot use them explicitly in Kotlin though because they are generated on demand, so we should use function types instead. However, knowing that function types are just interfaces opens your eyes to some new possibilities. You can, for instance, implement a function type:
As illustrated in Item 47: Avoid unnecessary object creation, wrapping the body of a function into an object will slow down the code. This is why the first of the two functions below will be faster:
The difference is visible but it is rarely significant in real-life examples. However, if we design our test well, you can see this difference clearly:
On my computer, the first one takes 189 ms on average, while the second one takes 447 ms on average. This difference stems from the fact that in the first function we only iterate over numbers and call consume
function (which is empty). In the second function, we call a method that iterates over numbers and calls an object, and this object calls consume
function. All this difference is due to the fact that we use an extra object (Item 47: Avoid unnecessary object creation).
To show a more typical example, let’s say that we have 5,000 products, and we need to sum up the prices of the ones that have been bought. We can do this simply by:
On my machine, it takes 38 ms to calculate on average. How much would it be if the filter
and sumByDouble
functions were not inline? 42 ms on average on my machine. This doesn't look like a lot, but this is around 10% difference every time you use these methods for collection processing.
A more significant difference between inline and non-inline functions manifests itself when we capture local variables in function literals. A captured value needs to be wrapped into some object, and whenever it is used, this needs to happen through this object. For instance, in the following code:
A local variable cannot be used directly in a non-inline lambda. This is why the value of a
will be wrapped into a reference object during compilation:
This is a more significant difference because such objects might be used many times: every time we use a function created by a function literal. For instance, in the above example, we use a
twice, therefore the extra object will be used 2 * 100,000,000 times. To see this difference, let’s compare the following functions:
On my machine, the first one takes 30 ms, while the second takes 274 ms. This is due to the accumulated effects of the fact that a function is an object and the local variable needs to be wrapped. These objects make tiny barriers that need to be overcome many times, again and again, and this makes a significant difference in the end. Since in most cases we don’t know how functions with parameters of functional types will be used, when we define a utility function with such parameters, for instance for collection processing, it is good practice to make it inline. This is why most extension functions with parameters of functional types in the stdlib are inline
.
Non-local return is allowed
The previously defined noinlineRepeat
looks much like a control structure. Just compare it with an if expression or a for loop:
One significant difference is that a return is not allowed inside:
This is the result of what function literals are compiled to. We cannot return from main
if our code is located in another class. There is no such limitation when a function literal is inlined as the code will be located in the main
function anyway.
Thanks to that, functions can look and behave more like control structures:
Costs of inline modifiers
Inline is a useful modifier, but it should not be used everywhere due to its costs and limitations. Let's review them.
Inline functions cannot use elements that have restricted visibility. We cannot use private or internal functions or properties in public inline functions. We cannot use private properties in public or inline functions.
This is why they cannot be used to hide implementation, so they are rarely used in classes. This is why inline functions are mostly utility functions.
Inline functions cannot be recursive. Otherwise, they would replace their calls infinitely. Recurrent cycles are especially dangerous because, at the moment, they do not show an error in IntelliJ:
Inline functions make our code grow. To see the scale of this growth, let’s say that I really like printing 3
. I first defined the following function:
I wanted to call it 3 times, so I added this function:
I still wasn’t satisfied, so I defined the following functions:
What are they all compiled to? The first two are very readable:
The next two were compiled to the following functions:
This is an abstract example, but it shows a big problem with inline functions: code grows really quickly when we overuse them. I have actually encountered this problem in a real-life project. Having too many inline functions calling each other is dangerous because our code might start growing exponentially.
Crossinline and noinline
There are cases in which we want to inline a function, but for some reason, we cannot inline all functions used as arguments. In such cases, we can use the following modifiers:
crossinline
- this means that the function should be inlined but non-local return is not allowed. We use it when this function is used in another scope where non-local return is not allowed; for instance, in another lambda that is not inlined.noinline
- this means that this argument should not be inlined at all. It is used mainly when we use this function as an argument to another function that is not inlined.
It is good to know what the meaning of both modifiers is, but we can live without remembering them as IntelliJ IDEA suggests them when they are needed:
Summary
The main cases in which we use inline functions are:
- Very frequently used functions, like
print
. - Functions that need to have a reified type passed as a type argument, like
filterIsInstance
. - When we define top-level functions with parameters of functional types, especially helper functions, like collection processing functions (like
map
,filter
,flatMap
,joinToString
), scope functions (likealso
,apply
,let
), or top-level utility functions (likerepeat
,run
,with
).
We rarely use inline functions to define an API, and we should be careful when one inline function calls some other inline functions.