skip to content

What is the runtime cost of composing functions with `andThen`/`compose`, and how do `inline` functions, closures, and lambda allocation factor into a senior's decision to use composition?

level: seniorimportance: should knowfreq 30%

answer

  1. andThen returns a closure -> can't inline (lambda captured, not called)
  2. Each link = one FunctionN object + one indirect invoke
  3. inline only helps when the lambda is called immediately (let/run/custom HOF)
  4. Compose once, outside loops; reuse the value
  5. Bound references keep their target alive

basics

~10 s

Each composition allocates a new lambda object and adds an extra call. It's usually cheap, but in hot loops the object allocations and indirection can matter, so measure before composing heavily.

solid answer

~50 s

A plain `andThen` extension can't be `inline` because it stores the receiver and argument in a returned closure — Kotlin only inlines lambda parameters that are *called*, not *captured/returned*. So composing N functions creates a chain of N lambda objects (each a `FunctionN` instance), and invoking the pipeline makes N indirect `invoke` calls. For most code this is negligible. In tight hot paths it adds allocation pressure and prevents JIT inlining across the boundary. Mitigations: keep composition out of inner loops; use `inline` only where you *call* the lambdas immediately (like `let`/`run`/custom inline HOFs), not where you return them; or hand-write the combined function. Also note captured references (bound `obj::method`) keep their target alive. Composition trades a small, predictable runtime cost for declarative clarity — fine by default, profile when it's on a hot path.

code

kotlin · 7 lines
kotlin
// inline works because the lambda is CALLED here, not returned
inline fun <T, R> T.pipe(f: (T) -> R): R = f(this)
// andThen CANNOT inline: it returns a closure capturing g
infix fun <A, B, C> ((A) -> B).andThen(g: (B) -> C): (A) -> C = { g(this(it)) }

val p = String::trim andThen String::length // allocates one wrapper lambda
val n = "  hi ".pipe { it.trim().length }    // inlined: no lambda object

go deeper

for a junior

Knows a lambda is an object but likely unaware of the inline distinction.

for a middle

Understands one allocation per link and that you should compose once, but may not explain why inline doesn't apply.

for a senior

Explains why captured/returned lambdas can't inline, quantifies the cost, and applies the right mitigation under profiling.

for a principal

Sets policy on when composition is acceptable on hot paths, balances clarity vs allocation, and reasons about JIT/megamorphic effects.

## What composition costs at runtime When you write `f andThen g`, the helper **returns a new lambda** `{ x -> g(f(x)) }`. That lambda is a real heap object — under the hood a synthetic class implementing `FunctionN` (e.g. `Function1<P, R>`) with an `invoke` method. Consequences: - **Allocation**: each `andThen`/`compose` allocates one closure object that **captures** `f` and `g`. Composing a 4-stage pipeline once allocates the wrapper lambdas once; that's cheap. Re-composing inside a loop allocates every iteration — avoid that. - **Indirection**: calling the pipeline performs an `invoke` per stage. These are virtual calls; the JIT can often inline short chains, but deep/megamorphic chains may not inline well. ## Why `andThen` can't be `inline` `inline` works by **substituting the lambda body at the call site**. Kotlin only inlines a lambda parameter that the inline function **calls directly**. `andThen` does not call `g`; it **stores `g` inside a returned closure**. A lambda that is captured/returned rather than invoked must be a real object, so the compiler would force you to mark it `noinline`. Therefore: ```kotlin // Not meaningfully inlinable: the lambdas are returned, not called here. infix fun <A, B, C> ((A) -> B).andThen(g: (B) -> C): (A) -> C = { g(this(it)) } ``` Contrast with operators like `let`/`also`/`run`/`with`/`apply` and custom HOFs that **invoke** the lambda immediately — those are `inline`, so the lambda object and call overhead vanish entirely. ## Mitigations a senior reaches for - **Build the pipeline once**, outside loops; reuse the resulting function value. - **Prefer inline call-site HOFs** (`map`/`filter` on `Sequence`, or `inline fun` you call directly) when you want zero-allocation transformation. - **Hand-fuse** the steps into one function when a benchmark shows the composition is hot. - Remember **bound references** (`service::process`) capture and keep their target reachable, which matters for memory/lifecycle, not just speed. ```kotlin // Good: compose once, reuse many times val pipeline = parse andThen validate andThen save items.forEach(pipeline) // no per-iteration composition ``` ## The decision Composition's cost is **small and predictable**: a few object allocations plus extra indirect calls. Default to it for clarity and reuse. Reach for inline operators or hand-written fusion only when profiling flags a genuinely hot path — premature de-composition hurts readability for no measured gain.

  • Why can't you just mark `andThen` as `inline` to remove the overhead?
    Because andThen returns the lambdas inside a closure instead of calling them. Inlining only eliminates lambdas that are invoked at the call site; captured/returned ones must stay as objects (noinline).
  • Where is composition's cost actually a problem?
    Hot inner loops where you re-compose each iteration, or very deep/megamorphic chains the JIT can't inline. Composing once and reusing avoids most of it.

saying these in an interview costs you the question

  • Claims andThen can be inline to erase all overhead
  • Thinks composing functions is free with zero allocation
  • Re-composes the pipeline inside a hot loop
  • Confuses inline (call-site) HOFs with returned-closure helpers
  • Optimizes composition away without profiling, hurting clarity for no gain

context