Jin Lee
Posted on December 8, 2023
Background
Since I started using Kotlin for Android development in 2017, I've been using functions like apply, let, also, and run. These functions are super handy and make my code much easier to read. But here's the thing: How exactly do they work? What makes them so special?
High-order functions
According to the Kotlin doc:
A higher-order function is a function that takes functions as parameters, or returns a function.
Higher-order functions aren't something new in the programming world; yes, even in Java, they've been around since version 8, iirc. They promote reusability and conciseness. They've also unlocked easier implementations like .apply{}
(the function we're delving into today) as well as famous asynchronous libraries such as Coroutines.
So enough about the background, let's look at some code.
Inside apply.{} function
As I'm writing this, I was using kotlin-stdlib-common-1.9.0
@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}
There are three components I want to talk about which are:
- inline keyword
- contract
- Function literals with receiver
inline keyword
So, when we use the inline keyword, we are telling the compiler to swap out the call-site with the actual code of the inline function. This little trick is to cut down on the extra work that high-order functions bring during the runtime phase. High-order functions aren't as lightweight as regular functions.
To illustrate how inline keyword work, let's take a look at javabyte code when the keyword is used:
inline fun foo1(block:() -> Unit) {
print("Starting foo1 now!")
block()
print("Finished foo1!")
}
fun foo2(block:() -> Unit) {
print("Starting foo2 now!")
block()
print("Finished foo2!")
}
And the main function that calls this method:
fun main(args: Array<String>) {
print("Cheesy Coder")
foo1 {
print("I am running foo1 now!")
}
foo2 {
print("I am running foo2 now!")
}
}
Now, let's look at what the Java bytecode says (I will only display what matters the most since I want to make comparison between these two methods)
public final static main([Ljava/lang/String;)V
// Skipping until interesting part
L1
LINENUMBER 2 L1
LDC "Cheesy Coder"
ASTORE 1
L2
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 1
INVOKEVIRTUAL java/io/PrintStream.print (Ljava/lang/Object;)V
L3
L4
LINENUMBER 3 L4
L5
ICONST_0
ISTORE 1
L6
LINENUMBER 22 L6
LDC "Starting foo1 now!"
ASTORE 2
L7
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2
INVOKEVIRTUAL java/io/PrintStream.print (Ljava/lang/Object;)V
// Skipping until interesting part
L17
LINENUMBER 24 L17
LDC "Finished foo1!"
ASTORE 2
L18
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2
INVOKEVIRTUAL java/io/PrintStream.print (Ljava/lang/Object;)V
L19
L20
LINENUMBER 25 L20
NOP
L21
LINENUMBER 6 L21
GETSTATIC MainKt$main$2.INSTANCE : LMainKt$main$2;
CHECKCAST kotlin/jvm/functions/Function0
INVOKESTATIC MainKt.foo2 (Lkotlin/jvm/functions/Function0;)V
L22
LINENUMBER 9 L22
RETURN
The Java bytecode appears distinct when the inline keyword is used. Instead of invoking foo1
within the main function, the implementation contents of foo1
have been copied into the main function. Labels L1
and L2
illustrate the loading of the 'Cheesy Coder' string and the use of java/io/PrintStream
to print the loaded string. Labels L6
and L7
represent the loading of the 'Starting foo1 now!' string and its subsequent printing. There is no mention of the reference to foo1
method anywhere in this code. However, upon encountering L21
, the instruction is to invoke foo2
method.
Not only that but there are more Java bytecode created around foo2
:
public final static foo2(Lkotlin/jvm/functions/Function0;)V
// Skipping implementation
final class MainKt$main$2 extends kotlin/jvm/internal/Lambda implements kotlin/jvm/functions/Function0 {
// access flags 0x1041
public synthetic bridge invoke()Ljava/lang/Object;
ALOAD 0
INVOKEVIRTUAL MainKt$main$2.invoke ()V
GETSTATIC kotlin/Unit.INSTANCE : Lkotlin/Unit;
ARETURN
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x11
public final invoke()V
L0
LINENUMBER 7 L0
LDC "I am running foo2 now!"
ASTORE 1
// Skipping implementation
This represents the overhead cost. Not only foo2
method was created but an additional class is also generated for its parameters. It's very interesting to see how the Java bytecode demonstrates the impact of the inline keyword!
Contract
This API reminds me of require or requireNonNull. I have seen these kind of checks in many different codebase and it can sometimes assure developers certain conditions are met.
Unlike above, contract
tells information or hints to the compiler so that it can make informed decision how smartcast works or give warning to developers. In this function .apply{}
, the contract is called callsInPlace
.
According to Kotlin docs:
Specifies that the function parameter lambda is invoked in place.
This contract specifies that:
- the function lambda can only be invoked during the call of the owner function, and it won't be invoked after that owner function call is completed;
- (optionally) the function lambda is invoked the amount of times specified by the kind parameter, see the InvocationKind enum for possible values. A function declaring the callsInPlace effect must be inline.
It's a bit difficult to understand so let's use real example and see the behavior. We are going to create very simple methods called foo1
& foo2
:
@ExperimentalContracts
inline fun foo1(condition: Boolean, block: () -> Unit) {
contract { callsInPlace(block, InvocationKind.UNKNOWN) }
if (condition) {
block()
}
}
@ExperimentalContracts
inline fun foo2(condition: Boolean, block: () -> Unit) {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
if (condition) {
block()
}
}
I added condition
boolean parameter so that it does not take in account of how many times block is called on runtime. Let's test out foo1
first:
@OptIn(ExperimentalContracts::class)
fun main(args: Array<String>) {
val someInput: Int
foo1(false) {
someInput = 10
}
print(someInput)
}
The code itself may not be interesting so let me include the screenshot of IDE:
Because we told the compiler that block()
parameter is called unknown amount (could not be called), the compiler thinks that someInput
may not be initialized or it's called many times that val
cannot be re-assigned to a new value.
What about foo2
?
@OptIn(ExperimentalContracts::class)
fun main(args: Array<String>) {
val someInput: Int
foo2(false) {
someInput = 10
}
print(someInput)
}
It looks like foo2
has no problem even though we know val someInput
would not be initialized. What happens if we run this method?
0
Process finished with exit code 0
It appears that the Kotlin compiler might utilize default values to prevent the application from encountering errors or crashing. Typically, this operation might not be feasible, but by using contracts, we can notify the compiler about the state of the called block.
In essence, when the .apply{}
function is invoked, the compiler explicitly acknowledges that the block has been called and respects whatever operations occur within the caller.
There are many different contracts so check out if you have some free time.
Function literals with receiver
This is the main part of the .apply{} function. Initially, looking at this function made me scratch my head. However, once we understand extension functions, it becomes really easy to grasp.
Extension functions seamlessly add functionality without requiring modifications to the class.
fun String.printWithCheese() {
println("$this Cheese. Prefix length ${this.length}")
}
fun main(args: Array<String>) {
"my".printWithCheese()
}
We can add the class with new methods that we want to implement without altering the source code (as shown in this example with the String class).
By using this knowledge of extension functions, we can observe that the block parameter data type in the apply{} function is T.() -> Unit. This data type translates as "Here is the lambda function that has a receiver type, T." It utilizes the same concept as extension functions, allowing access to and modification of member instances.
If we were to look at .run{}
implementation, we can also observe something similar:
@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
The only difference is the return type for this lambda functions, R. .run{}
uses result from the block and return it as its return type.
Conclusion
I hope you enjoyed diving into .apply{}
method and seeing many different components played out in just one simple call. I am amazed by the growth of Kotlin over the years and hope to cover more Kotlin-related topics in the future.
Posted on December 8, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.