Why Lambdas Are Free, and Lambdas That Read Like Syntax

The performance model behind lambdas — inline functions, reified type parameters, and when NOT to inline — plus lambdas with a receiver, the trick behind apply, buildString, and Kotlin's DSLs. Part 5 of five.

You can now write lambdas, pass them to functions, reason about what they capture, and use references, returns, and anonymous functions. Two pieces finish the picture: why all this lambda-passing is essentially free at runtime, and the one variation — lambdas with a receiver — that lets a lambda read like built-in syntax. This is the last part, run against Kotlin 2.4.10.

The hidden cost — and Kotlin’s answer

A lambda is a value, and values are usually objects (a FunctionN instance, from part one). So a fair worry: every time you call list.map { it * it }, does Kotlin allocate an object for the lambda and make an extra call per element? If it did, the fluent style from part two would be quietly expensive in hot loops.

Kotlin’s answer is the inline keyword. When a higher-order function is inline, the compiler doesn’t pass your lambda as an object at all — it copies the lambda’s body into the call site, as if you’d written the loop by hand:

inline fun repeatTimes(n: Int, action: (Int) -> Unit) {
    for (i in 0..<n) action(i)
}

repeatTimes(3) { println("tick $it") }

Because repeatTimes is inline, the compiled code is effectively:

for (i in 0..<3) println("tick $i")

No lambda object, no extra call — the abstraction vanishes at compile time. The standard library marks map, filter, forEach, and the rest inline, which is why expressive collection pipelines cost the same as a hand-written loop. It’s also what makes the non-local return from part four possible: since the body is copied into the caller, a return inside it really can return from the enclosing function.

reified: the type parameter that survives

Inlining unlocks a second power that’s impossible otherwise. Normally a generic type argument is erased — inside fun <T> foo() you cannot write is T, because at runtime T is gone. But an inline function can mark a type parameter reified, and because the body is copied into each call site with the concrete type substituted in, the type is available:

inline fun <reified T> List<*>.firstOfType(): T? = firstOrNull { it is T } as T?

val mixed: List<Any> = listOf(1, "two", 3.0, "four")
mixed.firstOfType<String>()   // "two"
mixed.firstOfType<Double>()   // 3.0

it is T compiles because, at each call, T has been replaced by the real type before the is check is emitted. This is how filterIsInstance<T>() works, and why you can write clean type-directed APIs without passing a Class<T> token around the way Java forces you to. reified requires inline — it’s the same copy-into-the-caller mechanism, used for types instead of code.

When not to inline

Inlining copies code, and copies have a cost of their own: bigger bytecode at every call site. That trade only pays when there’s a lambda to eliminate. Mark a function inline when it has no function-typed parameter and the compiler tells you the effort is wasted:

warning: expected performance impact from inlining is insignificant.
Inlining works best for functions with parameters of function types.

So the guidance is narrow: inline higher-order functions, especially small ones called in tight loops. Don’t inline large function bodies (you multiply that body across every call site), and don’t reach for inline on ordinary functions hoping for a speedup — the JIT already handles those, and you’d only bloat the output. You’ll mostly use inline functions rather than write them; when you do write one, it’s because it takes a lambda.

Two finer controls exist for the rare cases: noinline opts a single lambda parameter out of inlining (needed when you must store or pass that lambda on as an object), and crossinline forbids non-local returns from a lambda that’s called from a different execution context (a nested lambda, another thread). And as we just saw, inline is also what unlocks reified.

Lambdas with a receiver

The last idea explains a lot of “how does that even work?” Kotlin. Recall a normal function type, (String) -> Unit — it takes a String as a parameter. A lambda with a receiver moves that type to the front with a dot:

String.() -> Unit

Read it as “a lambda that runs on a String.” Inside such a lambda, the String isn’t a named parameter — it’s this, the receiver, exactly like being inside a member function. And just as inside a class you call methods without writing this., inside a receiver lambda you call the receiver’s methods bare:

val build: StringBuilder.() -> Unit = {
    append("Hello, ")    // really this.append(...), this implicit
    append("world")
}

You’ve used this without knowing. apply and buildString take exactly this kind of lambda:

val text = buildString {
    append("Hello, ")    // receiver is a StringBuilder
    append("world")
}                        // "Hello, world"

val file = File("out.txt").apply {
    createNewFile()      // receiver is the File
    setReadable(true)
}                        // returns the configured File

Inside those braces, append and createNewFile need no prefix because the lambda has a receiver. This is the entire basis of Kotlin DSLs — the HTML builders, the Gradle Kotlin scripts, the test frameworks that read like English. They’re all functions taking a lambda with a receiver, so the block can call the receiver’s methods as if they were keywords. (When receiver lambdas nest, @DslMarker annotations keep an inner block from accidentally calling the outer receiver’s methods — the mechanism that makes well-built DSLs refuse nonsense at compile time.)

Where this leads: the scope functions

Once receivers click, the five scope functionslet, run, with, apply, also — stop looking like magic. Each is a tiny library function taking a lambda that’s either a receiver lambda (this) or an ordinary one (it), and either returning the object or the block’s result. Those two axes are the whole design, and they get their own chapter.

Final thoughts

That completes the picture. A lambda starts as a plain value (part one), becomes powerful when handed to functions (part two), remembers the variables around it (part three), gains shortcuts and a return rule (part four), and finally reveals that it costs nothing thanks to inline — which also gives you reified types and non-local returns — and can carry a this to read like native syntax. Inlining and receivers are what make Kotlin’s most expressive features, from the collection API to full DSLs, both pleasant and free.

With functions-as-values behind you, the next building block is the thing that holds them together. Next: classes, and why a Kotlin class is mostly its header.

Practice: reinforce this with the companion workbook — short, click-to-reveal problems.

Comments