Lambdas & Inline Functions Explained

KOTLIN › Types & Classes

A Kotlin interview rarely stays on lambda syntax for long. The syntax is quick to learn, what interviewers actually probe is what happens underneath it: whether a lambda becomes a real object on the JVM, why marking a function inline removes that cost, and why a bare return inside some lambdas exits the whole enclosing function while in others it does not. Getting the mechanics right, not just the keywords, is what separates a strong answer here.

Start with the part you already use daily. A function type describes a value that is itself a function: (Int) -> String takes an Int and returns a String, and you can assign a lambda to a variable of that type or pass one as an argument.

val toLabel: (Int) -> String = { n -> "item $n" }

When a function's last parameter has a function type, Kotlin lets the lambda move outside the parentheses, and the parentheses can be dropped entirely if it's the only argument.

numbers.filter({ it > 0 })   // legal, rarely written this way
numbers.filter { it > 0 }    // idiomatic trailing-lambda form

And when a lambda takes exactly one parameter, you don't have to name it, you can refer to it as the implicit **it**. That combination, the lambda living outside the parentheses plus it standing in for the one argument, is why calls like map { } and filter { } read like built-in language features instead of ordinary function calls.

A lambda that reads or writes a variable from outside its own body is a **closure**, and Kotlin's closures can do something Java's cannot: mutate a captured var, not just read a captured value.

var count = 0
val increment = { count++ }
increment()
increment()
println(count)   // 2

In Java, a lambda can only capture a local that is final or effectively final, you could never write the equivalent of count++ reaching into an outer local. Kotlin allows it because of how it compiles the capture: the compiler wraps the captured var in a small heap-allocated holder object, something like Ref.IntRef, and both the outer scope and the lambda read and write through that same holder. It's one shared mutable cell, not a copy, which is exactly why the mutation is visible on both sides, and it's a real allocation worth remembering if that lambda runs on a hot path.

This same mechanism is what lets a function build and return its own lambda that remembers state between calls. A function that returns a lambda closing over a local var gives you a fresh, independent piece of memory every time it runs.

fun makeCounter(): () -> Int {
    var calls = 0
    return {
        calls++
        calls
    }
}

val counterA = makeCounter()
val counterB = makeCounter()
println(counterA())   // 1
println(counterA())   // 2
println(counterB())   // 1, independent of counterA

Every lambda you pass to an ordinary, non-inline, higher-order function becomes a real object on the JVM. The compiler generates a class implementing an interface like Function1 or Function0, and a fresh instance of it is allocated on every call.

val fn: (Int) -> String = { it.toString() }
println(fn.javaClass.interfaces.first())  // interface kotlin.jvm.functions.Function1

Marking the higher-order function **inline** removes that allocation entirely. The compiler doesn't generate a call to the function at all, it pastes the function's body, and the body of any lambda passed to it, directly into the call site.

inline fun repeat3(action: () -> Unit) {
    action()
    action()
    action()
}
repeat3 { println("hi") }

After inlining, the generated bytecode looks as if you had written println("hi") three times by hand. No Function0 object, no virtual invoke(), none of the indirection a normal higher-order call carries. That's the entire point of inline: it trades code size at the call site for removing allocation and indirection around the lambda.

Because an inlined lambda's code literally becomes part of the function that called it, a bare return inside that lambda does something a normal lambda's return cannot: it exits the *enclosing function*, not just the lambda. This is called a **non-local return**.

fun findFirst(list: List<Int>): Int {
    list.forEach { item ->
        if (item > 0) return item   // exits findFirst, not just the lambda
    }
    return -1
}

This only compiles because forEach is inline. If it weren't, there would be no enclosing call frame left for return to jump out of once the lambda actually ran later as a real object, so the compiler simply forbids a bare return in a lambda passed to a non-inline function.

Sometimes you don't want to exit the whole function, you just want to skip the rest of the current lambda call and let the loop keep going. For that, label the return with the name of the inline function it's inside, return@forEach. It ends only the current invocation of the lambda, the enclosing function keeps running afterward.

fun printPositives(numbers: List<Int>) {
    numbers.forEach { n ->
        if (n <= 0) return@forEach   // skip this element, move to the next
        println(n)
    }
    println("done")   // always reached
}

That non-local behavior belongs to *lambdas* specifically, not to every kind of function literal. Kotlin also has anonymous functions, written with the fun keyword instead of braces, and a bare return inside one of those behaves the ordinary way: it exits only the anonymous function itself, not the caller.

fun process(list: List<Int>) {
    // Lambda: bare return is non-local, exits process()
    list.forEach { x ->
        if (x < 0) return
        println("lambda saw $x")
    }

    // Anonymous function: bare return is local, exits only this function
    list.forEach(fun(x: Int) {
        if (x < 0) return
        println("anon saw $x")
    })
}

Same inline forEach, same if (x < 0) return, different scope for the return depending on whether you wrote a lambda or an anonymous function. If you want that same local, per-iteration behavior without switching to an anonymous function, label the return instead: return@forEach inside a lambda skips just the current call and lets the loop continue, while a bare return in a lambda exits the whole enclosing function.

Sometimes an inline function needs to call its lambda from **inside another execution context**, say, wrapped in a Runnable, rather than directly in the function body. A non-local return can't cross that boundary safely, so Kotlin makes you opt out of it with **crossinline**:

inline fun schedule(crossinline body: () -> Unit) {
    Runnable { body() }.run()   // body() runs inside a different context
}

crossinline keeps body inlined, still no object allocation, it just forbids a bare return inside it. Without crossinline here, the compiler rejects the code, because it can't guarantee a non-local return out of schedule is legal once body is running inside someone else's Runnable.

The opposite problem: sometimes you need a lambda parameter to actually **be an object**, storing it in a field, returning it, or passing it on to a non-inline function all require that. But an inlined lambda has no object form to hand over. **noinline** opts a single parameter out of inlining so it stays a real FunctionN instance:

fun storeForLater(fn: () -> Unit) { /* keeps a reference to fn */ }

inline fun doTwoThings(
    action: () -> Unit,
    noinline callback: () -> Unit
) {
    action()
    storeForLater(callback)   // only legal because callback isn't inlined
}

action still gets pasted at the call site as usual, callback is compiled as an ordinary lambda object, exactly like it would be in a non-inline function.

Inlining has one more consequence worth knowing. A public inline function's body gets copied into every module that calls it, so that body can only reference symbols visible from those call sites. A public inline function cannot call a private or internal declaration, because once its body is pasted into some other module's compiled code, that module has no access to the symbol it's calling.

internal fun hiddenHelper() { /* ... */ }

public inline fun broken() {
    hiddenHelper()   // compile error: hiddenHelper isn't visible where broken() gets inlined
}

If you genuinely need an internal helper from a public inline function, mark the helper @PublishedApi internal. That annotation is a deliberate escape hatch: it keeps the symbol internal for source code, other modules still can't call it directly, while allowing it to appear inside inlined bytecode.

@PublishedApi
internal fun doInternalWork() { /* ... */ }

public inline fun publicInline() {
    doInternalWork()   // fine, doInternalWork is marked @PublishedApi
}

Generics are normally erased on the JVM, at runtime List<String> and List<Int> are just List, the concrete type is gone. An inline function can ask for a **reified** type parameter instead, and because inlining pastes a fresh copy of the function at every call site with the real type substituted in, that type survives to runtime. A reified T lets you write is T, as T, and T::class directly.

inline fun <reified T> Any.isInstance(): Boolean = this is T

println(42.isInstance<Int>())      // true
println("hi".isInstance<Int>())    // false

reified only compiles on an inline function, without inlining there's no call site to substitute a concrete type into, so the type parameter would just be erased like any other generic.

Reification doesn't hand you a constructor, though. Knowing T::class at runtime is not the same as knowing T has a no-argument constructor, so code like T() inside a reified function still won't compile. If you need to build an instance of T, you reach for reflection instead.

import kotlin.reflect.full.createInstance

inline fun <reified T : Any> create(): T {
    // T()  would not compile, no guaranteed no-arg constructor
    return T::class.createInstance()
}

Put all of this together and inline stops looking like a single feature and starts looking like a trade-off you're making on purpose. Inlining duplicates a function's body at every call site, so it isn't free: inline a large function, or one called from dozens of places, and you bloat the compiled bytecode for no benefit. The compiler even warns you when you mark a function inline but it takes no lambda parameters at all, because the entire payoff of inlining is removing lambda-object allocation, and with no lambda there's nothing to save.

// Warning: expected no performance gain from inlining, no lambda parameters
inline fun add(a: Int, b: Int) = a + b

// The one real exception: a reified type parameter needs inline even with no lambda
inline fun <reified T> isType(value: Any): Boolean = value is T

So the judgment call an interviewer wants to hear is not "inline is faster" as a blanket claim. It's this: inline pays for itself on a small function, called often, that takes a lambda on a hot path, forEach, let, a synchronized-style wrapper, because that's exactly where you kill a real per-call allocation. Reach for it too when you need reified, need noinline to keep one lambda parameter as a real object, or need crossinline to keep a parameter inlined while blocking an unsafe non-local return. Outside those cases, marking something inline just duplicates code for nothing, and a large function with no lambda parameters is the clearest sign you don't need it.

Back to Lambdas & Inline Functions