skip to content

A teammate claims 'lambdas in Kotlin are always cheap because the compiler reuses them.' How do you reason precisely about when a lambda allocates versus when `inline` makes it free? Mention the non-capturing-lambda nuance.

level: seniorimportance: nice to knowfreq 30%

answer

  1. Inline = no object (any capture)
  2. Non-inline + non-capturing = shared singleton
  3. Non-inline + capturing = per-call alloc
  4. 'Always reused' only true for non-capturing
  5. Verify via Show Kotlin Bytecode

basics

~20 s

It's not automatic. A lambda passed to a normal function becomes an object. inline removes that object entirely. Separately, a lambda that captures nothing can sometimes be reused as a single shared instance, but that's different from inlining.

solid answer

~50 s

The teammate is conflating two things. (1) **Inlining**: if the receiving function is `inline`, the lambda is never an object at all — its body is copied into the call site, so there's truly zero allocation and zero dispatch. (2) **Non-capturing lambda optimization**: if the receiving function is NOT inline but the lambda **captures no state**, the Kotlin compiler can emit it as a **singleton** — one shared `FunctionN` instance reused across calls (often a static field), so repeated calls don't re-allocate. But a **capturing** lambda passed to a non-inline function allocates a fresh object **each time** because it must hold the captured values. So the rule: inline → free; non-inline + non-capturing → one shared instance; non-inline + capturing → per-call allocation. 'Always cheap' is wrong for the capturing-non-inline case, especially in hot loops.

go deeper

for a junior

Knows a lambda to a normal function makes an object and inline avoids it.

for a middle

Distinguishes capturing vs non-capturing and that inline removes the object regardless.

for a senior

Lays out all three cases precisely, including the non-capturing singleton, and can verify via bytecode.

for a principal

Turns this into guidance — inline hot HOFs for unconditional zero-alloc, keep stored lambdas non-capturing — and reasons about GC impact at scale.

## Three distinct cases Whether a lambda costs an allocation depends on **(a)** whether the receiving function is `inline` and **(b)** whether the lambda **captures** anything. ### Case 1 — Passed to an `inline` function → no object at all The lambda body is **copied into the call site**. There is no `FunctionN` instance, no `invoke()` dispatch, regardless of captures. This is the strongest guarantee and the focus of this topic. ```kotlin inline fun run2(b: () -> Unit) { b(); b() } run2 { println(x) } // no lambda object, ever ``` ### Case 2 — Non-inline function, **non-capturing** lambda → shared singleton A lambda that references **no** enclosing state can be reused. The compiler typically emits it once (e.g. a static `INSTANCE` field) and hands the **same** `FunctionN` object to every call. So: ```kotlin fun apply2(b: () -> Unit) { b() } // NOT inline repeat(1000) { apply2 { println("hi") } } // single shared lambda instance ``` No per-call allocation — but this is a **separate** compiler optimization from inlining, and it only holds when the lambda captures nothing. ### Case 3 — Non-inline function, **capturing** lambda → per-call allocation If the lambda closes over a local/`this`/parameter, the object must store those references, so a **new instance is allocated on each evaluation**: ```kotlin fun apply2(b: () -> Unit) { b() } // NOT inline for (item in items) { apply2 { process(item) } // captures `item` -> new Function0 each iteration } ``` In a hot loop this is real GC pressure. ## Why 'always cheap because reused' is wrong The reuse only applies to Case 2. Case 3 allocates every time, and that's common in real code (lambdas usually capture something). The reliable way to get *guaranteed* zero allocation is **inlining (Case 1)** — which is exactly why the stdlib makes its HOFs `inline`. ## How to verify - Read the Kotlin bytecode (IntelliJ: *Tools → Kotlin → Show Kotlin Bytecode → Decompile*) and look for `new ...Function0` at the call site. - Or profile allocations under load. ## Practical guidance - For your own hot HOFs, prefer `inline` to get Case 1 unconditionally. - If you can't inline (e.g. the lambda must be stored), at least keep it **non-capturing** to land in Case 2. - Don't rely on 'the compiler reuses lambdas' as a blanket statement — it's conditional on non-capture and non-inline. ## Related machinery (out of scope here) Keeping a lambda as a real object inside an inline function uses `noinline`; letting it be inlined yet cross into a nested context uses `crossinline`. Both are separate facets but explain *why* sometimes you deliberately fall back to Case 2/3.

  • How can you confirm whether a specific call allocates a lambda?
    Decompile the Kotlin bytecode (IntelliJ 'Show Kotlin Bytecode' → Decompile) and look for a `new ...Function` at the call site, or run an allocation profiler under load.
  • If you must store a lambda for later but want to avoid per-call allocation, what helps?
    Make the lambda non-capturing so the compiler can reuse a single shared instance, or hoist a captured-value-free lambda to a top-level/companion `val`.

saying these in an interview costs you the question

  • Claiming all lambdas are free / always reused
  • Saying capturing lambdas to non-inline functions don't allocate
  • Confusing the non-capturing-singleton optimization with inlining
  • Assuming `inline` only matters for non-capturing lambdas
  • Not knowing how to verify allocation in bytecode

context