Inline Functions

An inline function tells the Kotlin compiler to copy the function’s bytecode directly into every call site instead of generating a real function call. This matters most for functions that take lambda parameters: normally each lambda you pass becomes a separate object with its own invoke() method, which costs memory allocation and an indirect call. Inlining removes that cost entirely, and as a side effect it unlocks two features that are otherwise impossible in Kotlin: non-local returns from lambdas, and reified generic type parameters that survive at runtime instead of being erased.

Overview: How Inline Functions Work

Every time you write a lambda like { x -> x * 2 } and pass it to a regular (non-inline) function, the Kotlin compiler generates an anonymous class implementing a functional interface such as Function1. Calling the lambda means calling invoke() on that object — a real virtual method call, plus the memory allocation for the object itself (unless the JIT manages to optimize it away). For a function called in a tight loop millions of times, like forEach or a custom retry helper, that overhead adds up.

When you mark a function inline, the compiler does not generate a call to it at all. Instead, at every call site, it substitutes the function’s body directly, and it substitutes each lambda argument’s body directly too — no object, no invoke(), no extra stack frame. This is why almost all of Kotlin’s small, ubiquitous higher-order functions in the standard library — let, run, apply, also, with, use, forEach, map, filter on arrays and ranges — are declared inline. You get the readability of a lambda-based API with the performance of hand-written imperative code.

Non-local returns

Because an inlined lambda’s code is physically pasted into the calling function’s body, a plain return written inside that lambda returns from the enclosing function, not just from the lambda. This is called a non-local return, and it only compiles when the function receiving the lambda is inline. Try the same thing with a lambda passed to a non-inline function and the compiler rejects it, because a real anonymous class has no way to unwind its caller’s stack.

Reified type parameters

Normally, JVM generics are erased at compile time: a function such as fun <T> isInstance(value: Any): Boolean cannot check value is T at runtime, because by the time the bytecode runs, T no longer exists as a concrete type — the compiler has erased it to Object. Marking the function inline and its type parameter reified changes this: since the compiler pastes the function’s body into every call site, it knows the actual type argument at each site and substitutes it directly into the copied bytecode. reified is only legal on inline functions for exactly this reason.

noinline and crossinline

An inline function can take multiple lambda parameters, and by default the compiler tries to inline all of them. Sometimes you don’t want that: you might want to store a lambda in a variable, return it, or pass it on to another (non-inline) function — none of which are legal for a lambda that’s being inlined. Marking that specific parameter noinline tells the compiler to compile it as a normal function object instead, while the rest of the function and its other lambda parameters are still inlined.

crossinline solves a different problem: it’s for a lambda parameter that will still be inlined, but that gets called from inside another closure (another lambda, a local object, a nested function) rather than directly in the inline function’s own body. In that indirect context a non-local return would be unsafe — there is no straightforward stack to unwind to — so crossinline keeps the lambda inlined but forbids non-local returns from it, catching the mistake at compile time instead of at runtime.

Syntax

inline fun functionName(
    regularParam: Int,
    noinline storedLambda: () -> Unit,
    crossinline indirectLambda: () -> Unit
): ReturnType {
    // body; storedLambda and indirectLambda are used according to their rules
}

inline fun <reified T> typedFunctionName(value: Any): T {
    // T is available as a real type at every call site
}
Modifier Applies to Effect
inline the function Pastes the function body (and its lambda parameters’ bodies) into every call site instead of generating a call
noinline one lambda parameter Keeps that one parameter as a real function object; lets you store, return, or pass it elsewhere
crossinline one lambda parameter Still inlines the lambda’s code, but forbids non-local return from it because it runs inside another closure
reified a type parameter (inline functions only) Keeps the actual type argument available at runtime instead of erasing it to Any

Examples

Example 1: Avoiding lambda overhead

inline fun measureTime(block: () -> Unit): Long {
    val start = System.nanoTime()
    block()
    return System.nanoTime() - start
}

fun main() {
    val elapsed = measureTime {
        var sum = 0
        for (i in 1..1000) {
            sum += i
        }
        println("Sum: $sum")
    }
    println("Block finished executing in a measurable time: ${elapsed >= 0}")
}

Output:

Sum: 500500
Block finished executing in a measurable time: true

Because measureTime is inline, the compiler pastes the loop’s code directly where block() is called — there’s no separate lambda object allocated and no virtual call through invoke(). The generated bytecode reads almost like you’d inlined the loop by hand into main yourself.

Example 2: Reified type parameters

inline fun <reified T> isInstance(value: Any): Boolean {
    return value is T
}

fun main() {
    val a: Any = "Hello"
    val b: Any = 42

    println(isInstance<String>(a))
    println(isInstance<String>(b))
    println(isInstance<Int>(b))
}

Output:

true
false
true

Without inline and reified, value is T would not compile at all — type erasure means the runtime has no idea what T is. Because the function is inlined, each call site (isInstance<String>(a), isInstance<Int>(b), …) gets its own copy of the body with the real type substituted in, so the is check works exactly as if you had written value is String by hand.

Example 3: Non-local return from a lambda

inline fun runIfPositive(value: Int, block: (Int) -> Unit) {
    if (value > 0) {
        block(value)
    }
}

fun checkValue(value: Int): String {
    runIfPositive(value) {
        if (it > 100) {
            return "Large value: $it"
        }
    }
    return "Value processed: $value"
}

fun main() {
    println(checkValue(150))
    println(checkValue(50))
    println(checkValue(-5))
}

Output:

Large value: 150
Value processed: 50
Value processed: -5

The plain return inside the lambda exits checkValue directly, not just the lambda. This only compiles because runIfPositive is inline: the lambda’s code is physically part of checkValue‘s bytecode after inlining, so return behaves exactly like a return written anywhere else in that function. For checkValue(150), it > 100 is true and the function returns early with "Large value: 150". For 50 and -5, the early return never fires, so execution falls through to the final return statement.

Example 4: noinline and crossinline in practice

inline fun processTwice(
    noinline logAction: () -> Unit,
    computeAction: () -> Int
): Int {
    val callback = logAction
    callback()
    return computeAction() * 2
}

fun main() {
    val result = processTwice(
        logAction = { println("Starting computation") },
        computeAction = { 21 }
    )
    println("Result: $result")
}

Output:

Starting computation
Result: 42

logAction is stored in a local variable (callback), which is only legal because it’s marked noinline — an inlined lambda parameter has no real object backing it, so it can’t be assigned to a variable. computeAction has no such restriction and stays inlined, called directly. A related case is crossinline, needed when a lambda parameter is invoked from inside another closure, such as a Runnable:

inline fun runViaRunnable(crossinline action: () -> Unit) {
    val runnable = Runnable {
        action()
    }
    runnable.run()
}

fun main() {
    runViaRunnable {
        println("Running action")
    }
    println("Done")
}

Output:

Running action
Done

Here action is called from inside the Runnable‘s own lambda body, not directly inside runViaRunnable. Without crossinline, the compiler would reject this because it can’t guarantee a non-local return from action would make sense once it’s running inside a different closure’s call frame.

How It Works Step by Step

Conceptually, given a call like log("start") { println("doing work") } to an inline fun log(message: String, block: () -> Unit) whose body prints the message and then calls block(), the compiler does not emit a call instruction at all. Instead, at that exact call site, it splices in the statements of log‘s body with block() replaced by the statements of the lambda you passed:

// Conceptually, what you write:
inline fun log(message: String, block: () -> Unit) {
    println("LOG: $message")
    block()
}

// ...becomes, at each call site, roughly equivalent to writing directly:
println("LOG: start")
println("doing work")

This happens independently at every call site in your program: if log is called from five different places with five different lambdas, the compiler produces five separate copies of log‘s body, each fused with its own lambda. That’s the fundamental trade-off of inlining — zero call/allocation overhead at runtime, at the cost of larger generated bytecode, since the function’s code is duplicated everywhere it’s used instead of existing once.

Common Mistakes

Mistake 1: Making a recursive function inline

Inlining works by copying a function’s body into its call sites at compile time. A function that calls itself can’t be expanded this way — there’s no way to finish substituting a body that contains a call to itself, so the Kotlin compiler flatly forbids it.

// Does not compile:
inline fun factorial(n: Int): Int {
    return if (n <= 1) 1 else n * factorial(n - 1)
}

This fails with a compiler error along the lines of “Inline function ‘factorial’ cannot be recursive”. The fix is simple: factorial doesn’t take a lambda parameter, so there’s no lambda-overhead reason to inline it in the first place — just drop inline.

fun factorial(n: Int): Int {
    return if (n <= 1) 1 else n * factorial(n - 1)
}

fun main() {
    println(factorial(5))
}

Output:

120

Mistake 2: Using reified without inline

reified depends entirely on inlining substituting a real type at each call site. Trying to use it — or trying to check is T on a plain type parameter — on a non-inline function is a compile error, not a runtime surprise.

// Does not compile:
fun <T> isInstance(value: Any): Boolean {
    return value is T
}

This fails with “Cannot check for instance of erased type: T”, because on the JVM, generic type information doesn’t exist at runtime unless inline + reified preserves it. The fix is to add both modifiers:

inline fun <reified T> isInstance2(value: Any): Boolean {
    return value is T
}

fun main() {
    println(isInstance2<Int>(5))
}

Output:

true

Mistake 3: Forgetting noinline when a lambda needs to be stored

An inlined lambda parameter has no backing object at runtime — its code has been pasted directly into the caller. That means you cannot assign it to a variable, return it, or hand it to another function that expects a real function value, unless you opt it out of inlining first.

// Does not compile:
inline fun process(action: () -> Unit) {
    val stored = action
    stored()
}

This fails with “Illegal usage of inline-parameter ‘action’ … Add ‘noinline’ modifier to the parameter declaration”. The fix is exactly what the error suggests — mark that one parameter noinline, as shown in Example 4’s processTwice above, while leaving any other lambda parameters inlined as normal.

Best Practices

  • Only mark a function inline when it takes one or more lambda (function-type) parameters — inlining a function with no lambda parameters only bloats bytecode for no performance benefit.
  • Keep inline function bodies small. Since the body is copied to every call site, a large inline function called from many places can noticeably increase your compiled program’s size.
  • Reach for reified whenever you need a generic function to check, cast, or otherwise use a type parameter at runtime (is T, as T, T::class) — without it, that code simply won’t compile due to type erasure.
  • Use noinline for any lambda parameter you need to store in a variable, return from the function, or pass on to a non-inline API.
  • Use crossinline whenever a lambda parameter is invoked from inside another closure (a nested lambda, a local object, a listener) rather than called directly — it keeps the performance benefit of inlining while ruling out unsafe non-local returns.
  • Prefer the standard library’s existing inline functions (let, run, apply, also, with, use, collection operations) over writing your own for common patterns — they’re already tuned and tested.
  • Remember that IDE tooling and the compiler will warn you if inlining a particular function offers no benefit (for example, a public inline function exposing private members can leak implementation details across module boundaries) — pay attention to those warnings.

Practice Exercises

  • Write an inline function repeatAction(times: Int, action: (Int) -> Unit) that calls action once for each index from 0 until times, then use it to print "Iteration 0" through "Iteration 4".
  • Write an inline function with a reified type parameter called firstOfType that takes a List<Any> and returns the first element that is an instance of T, or null if none match. Test it against a mixed list containing Int, String, and Double values.
  • Write an inline function findOrDefault(numbers: List<Int>, predicate: (Int) -> Boolean, default: Int): Int whose lambda uses a non-local return to exit the calling function early as soon as a match is found, falling back to default only if the loop completes without a match. Hint: you’ll need a loop inside findOrDefault that calls a non-local return directly, then a wrapping function that calls findOrDefault and returns its result.

Summary

  • inline tells the compiler to paste a function’s body (and its lambda arguments’ bodies) directly into every call site instead of generating a real function call, eliminating lambda-object allocation and call overhead.
  • Inlining is what makes non-local return from a lambda legal — the lambda’s code physically becomes part of the enclosing function once inlined.
  • reified type parameters are only allowed on inline functions; they let the actual type argument survive at each call site instead of being erased, enabling checks like value is T.
  • noinline exempts one lambda parameter from inlining so it can be stored, returned, or passed to another function as a real object.
  • crossinline keeps a lambda parameter inlined while forbidding non-local returns from it, for cases where it’s invoked inside another closure rather than directly.
  • Kotlin forbids recursive inline functions and rejects reified on non-inline functions at compile time — both are caught by the compiler, not left as runtime surprises.
  • Most of the standard library’s small higher-order functions (let, run, apply, also, with, use, and collection operations) are inline for exactly these reasons.