DEV Community

Cover image for Prefer Function Reference over Lambda in Kotlin? Wrong!
Vincent Tsen
Vincent Tsen

Posted on • Edited on • Originally published at vtsen.hashnode.dev

Prefer Function Reference over Lambda in Kotlin? Wrong!

You may think Kotlin function reference runs faster than Kotlin lambda, but wrong! In fact, Kotlin lambda runs 2x faster!

Using lambda as callback is very common in Jetpack compose, but do you know you can also use function reference to replace the lambda?

When I first heard about this function reference, I thought the function reference must be better. Unlike anonymous function / lambda, it just a function pointer or reference to a function. It should run faster and use less memory, as it doesn't allocate extra memory. So I replaced lambda with function reference whenever is possible. Well, I was wrong!

After doing some researches, it turns out that function reference still allocates extra memory just like what lambda does, and it also runs 2x slower than lambda. Before we look into why, let's understand their usages and differences first with examples below.

Lambda (call the object's function)

Let's say you have Screen and ViewModel classes below. The id is used to identify a different instance of ViewModel.

class Screen(private val callback: () -> Unit) {
    fun onClick() = callback()
}

class ViewModel(var id: String) {
    fun screenOnClick() {
        println("ViewModel$id onClick() is called!")
    }

    fun screenOnClickDoNothing() {}
}
Enter fullscreen mode Exit fullscreen mode

In main(), it creates the ViewModel object. Then, you pass in the lambda callback parameter - { viewModel.screenOnClick() } to Screen's constructor.

fun main() {
    var viewModel = ViewModel("1")
    val screen = Screen {
        viewModel.screenOnClick()
    }
    viewModel = ViewModel("2")
    screen.onClick()
}
Enter fullscreen mode Exit fullscreen mode

viewModel.screenOnClick() is the object's function.

To demonstrate the difference between lambda and function reference, you update the viewModel variable with another new instance of ViewModel - viewModel = ViewModel("2") before calling screen.onClick().

Calling screen.onClick() is to simulate screen callback

Here is the output:

ViewModel2 onClick() is called!
Enter fullscreen mode Exit fullscreen mode

Please note that ViewModel2 is called instead of ViewModel1. It looks like lambda holds the reference to the viewModel variable. When the variable is updated, it is automatically reflected.

Object's Function Reference

Let's replace the lambda with a function reference - viewModel::screenOnClick. The code looks like this.

fun main() {
    var viewModel = ViewModel("1")
    val screen = Screen(callback = viewModel::screenOnClick)
    viewModel = ViewModel("2")
    screen.onClick()
}
Enter fullscreen mode Exit fullscreen mode

Here is the output

ViewModel1 onClick() is called!
Enter fullscreen mode Exit fullscreen mode

Please note that ViewModel1 is called instead of ViewModel2. It looks like the function reference is caching the ViewModel instance when it is called. Thus, it is the old ViewModel instance and not the updated one. Wrong!

If you don't replace the viewModel instance but modifying ViewModel.id directly instead,

fun main() {
    var viewModel = ViewModel("1")
    val screen = Screen(callback = viewModel::screenOnClick)
    viewModel.id = "2"
    screen.onClick()
}
Enter fullscreen mode Exit fullscreen mode

you get the updated value.

ViewModel2 onClick() is called!
Enter fullscreen mode Exit fullscreen mode

This tells you that function reference doesn't cache the ViewModel instance, but holding its reference. If the original viewModel is replaced, the Screen still holds the old reference of the ViewModel. Thus, it prints out the value of that old reference.

Performance Test (Object's Function)

Let's run some performance tests.

This is the lambda test that creating Screen 1 billions times.

fun lambdaTest() {
    val viewModel = ViewModel("1")

    val timeMillis = measureTimeMillis {
        repeat(1_000_000_000) {
            val screen = Screen {
                viewModel.screenOnClickDoNothing()
            }

            screen.onClick()
        }
    }
    println("lambdaTest: ${timeMillis/1000f} seconds")
}
Enter fullscreen mode Exit fullscreen mode

The output show it runs ~2.4 seconds.

lambdaTest: 2.464 seconds
Enter fullscreen mode Exit fullscreen mode

Now, it is function reference's turn.

fun funRefTest() {

    val viewModel = ViewModel("1")

    val timeMillis = measureTimeMillis {
        repeat(1_000_000_000) {
            val screen = Screen(
                callback = viewModel::screenOnClickDoNothing
            )

            screen.onClick()
        }
    }
    println("funRefTest: ${timeMillis/1000f} seconds")
}
Enter fullscreen mode Exit fullscreen mode

The output shows it runs 5.2 seconds.

funRefTest: 5.225 seconds
Enter fullscreen mode Exit fullscreen mode

This concludes that lambda runs ~2x faster than function reference. Why? Let's look at the decompiled code.

Decompiled Code In Java

Let's look at the decompiled code in Java. This is the simplified version without the measureTimeMillis and repeat code.

lambdaTest()

public static final void lambdaTest() {
  final ViewModel viewModel = new ViewModel();
  Screen screen = new Screen((Function0)(new Function0() {

     public Object invoke() {
        this.invoke();
        return Unit.INSTANCE;
     }

     public final void invoke() {
        viewModel.screenOnClickDoNothing();
     }
  }));
  screen.onClick();
}
Enter fullscreen mode Exit fullscreen mode

funRefTest()

public static final void funRefTest() {
  ViewModel viewModel = new ViewModel();
  Screen screen = new Screen((Function0)(new Function0(viewModel) {

     public Object invoke() {
        this.invoke();
        return Unit.INSTANCE;
     }

     public final void invoke() {
        ((ViewModel)this.receiver).screenOnClickDoNothing();
     }
  }));
  screen.onClick();
}
Enter fullscreen mode Exit fullscreen mode

Few important things here:

  • Function reference allocates new function memory just like the lambda - new Function0() and new Function0(viewModel). So it is not a function pointer.
  • Function reference passes in the viewModel into Function0 and lambda doesn't

Based on these findings, I suspect the passed-in ViewModel reference could be the overhead (requires extra copy to hold the reference?) that contributes to slowness in function reference.

What about non-object's function?

Let's say you move screenOnClickDoNothing() out of ViewModel

fun screenOnClickDoNothing() {}
Enter fullscreen mode Exit fullscreen mode

and call it directly - replace viewModel.screenOnClickDoNothing() with screenOnClickDoNothing() in lambda.

fun lambdaTest() {
    val timeMillis = measureTimeMillis {
        repeat(1_000_000_000) {
            val screen = Screen {
                screenOnClickDoNothing()
            }

            screen.onClick()
        }
    }
    println("lambda1Test: ${timeMillis/1000f} seconds")
}
Enter fullscreen mode Exit fullscreen mode

It takes 0.016 seconds to run.

lambdaTest: 0.016 seconds
Enter fullscreen mode Exit fullscreen mode

What about function reference? Replace viewModel::screenOnClickDoNothing with ::screenOnClickDoNothing

fun globalFunRefTest(){
    val timeMillis = measureTimeMillis {
        repeat(1_000_000_000) {
            val screen = Screen(callback = ::screenOnClickDoNothing)
            screen.onClick()
        }
    }
    println("funRefTest: ${timeMillis/1000f} seconds")
}
Enter fullscreen mode Exit fullscreen mode

and it takes the same amount of time as lambda.

funRefTest: 0.016 seconds
Enter fullscreen mode Exit fullscreen mode

It makes senses because both have the same exact decompiled Java code.

public static final void lambdaTest() {
    Screen screen = new Screen((Function0)null.INSTANCE);
    screen.onClick();
}

public static final void funRestTest() {
    Screen screen = new Screen((Function0)null.INSTANCE);
    screen.onClick();
}
Enter fullscreen mode Exit fullscreen mode

One important thing to note is, although lambda is used, there is no new function memory allocated. Maybe the compiler is smart enough to optimize it. Thus, it runs faster than lambda with object's function in the previous examples.

By the way, all the tests I ran so far is based on Kotlin compiler version 1.7.10.

Conclusion

There is a slight different behavior between using lambda vs function reference, but I don't see any practical use case to use either one of them. By guessing, I think lambda should cover 99% of use cases because it doesn't require the object to be initialized first while registering the callback.

Here is the summary of Lambda vs Function Reference behavior:

Lambda Function Reference
Keeps the up-to-date object reference Keeps the copy of the object reference
Does NOT require the object to be initialized Requires the object to be initialized

Calling the function 1 billions times may not be practical, but it does prove that function reference is NOT better than lambda in Kotlin. Practically, it probably doesn't matter which one you use. The difference is likely negligible.

Maybe because I'm coming for C++/C# background, I had an impression that function reference in Kotlin is like a function pointer. You hold the reference to the function, and you do not allocate new memory. No, it is not the case in Kotlin.

Given this very little research I did, I've decided to use lambda over function reference to implement callbacks because it has less overhead. It also doesn't reduce readability too much, in my opinion. Just the extra { }?

[Updated - Sept 27, 2022]: I take back my word. I do find lambda is a bit less readable especially the lambda has arguments while working on this project.


Originally published at https://vtsen.hashnode.dev.

Top comments (4)

Collapse
 
krisutofu profile image
Elmar • Edited

Thank you for the informative article. As a fresh beginner I did not know all of this information.

I only want to add, I think, the judgment of being "wrong", "correct" or "better" is not appropriate. Both behaviours can be desired, which means a reference to the outer reference variable or a copy of the outer reference variable.

I am actually surprised because I wouldn't expect the lambda expression to behave that way. (Okay, other languages like EcmaScript behave the same way but still… it looks like a code block and a true code block would behave like the method reference.)

I think, The method reference behaviour is actually easier to understand or I'd say more intuitive. There can be cases, where the state of variables, at the time of execution inside the lambda expression, is unknown when it would be clear with the method reference. This behaviour could be grave if you don't know when the passed lambda expression will be evaluated and variables are changed before it is actually called. People might not be able to notice the correct variable content at execution if they don't have deeper understanding of the code. It's dangerous to use mutable global variables in lambda expressions.
Tracking the actual state of the variable is also mental load.

In many practical cases, it's no issue because the lambda expression is consumed before "imported" variables are changed or they are immutable anyways.

If you really need higher speed, you need to create all your lambda objects in the outermost compatible scope (ideally static scope) to avoid any redundant heap allocations.

Collapse
 
vtsen profile image
Vincent Tsen

I think what you said makes a lot of senses. Thanks a lot for your comment.

Collapse
 
simonmarquis profile image
Simon Marquis

Great post, but the whole point of looking for performance is kind of wrong.
If you really need this stuff to be fast (even though there is no point in the Android/ViewModel world), you definitely should use the method reference option, but with one simple change, hoisting the reference outside of the inner loop. This will make running your example almost instantaneous.

fun main() {
    val viewModel = ViewModel()
    val timeMillis = measureTimeMillis {
        val callback = viewModel::onClick
        repeat(1_000_000_000) {
            val screen = Screen(callback)
            screen.onClick()
        }
    }
    println("reference: ${timeMillis/1000f} seconds")
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
vtsen profile image
Vincent Tsen

Thanks a lot for the comment! Yupe, you're right. Hosting the reference outside the inner loop runs a lot faster. I believe this is because only one object instance being created.

I agree, no point in the Android world. I was just merely experimenting it and found out this discrepancy from my understanding.