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?
answer
- andThen returns a closure -> can't inline (lambda captured, not called)
- Each link = one FunctionN object + one indirect invoke
- inline only helps when the lambda is called immediately (let/run/custom HOF)
- Compose once, outside loops; reuse the value
- Bound references keep their target alive
basics
~10 sEach 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 sA 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// 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 objectgo deeper
Knows a lambda is an object but likely unaware of the inline distinction.
Understands one allocation per link and that you should compose once, but may not explain why inline doesn't apply.
Explains why captured/returned lambdas can't inline, quantifies the cost, and applies the right mitigation under profiling.
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