Closures: What a Lambda Remembers
In part two we noted in passing that a lambda can use a variable from the scope where it was written. That ability has a name, the closure, and it’s worth a stop of its own, for two reasons. It behaves differently in Kotlin than in Java, and when it surprises you, it does so quietly. We’ll build the idea up slowly and then look at where it bites.
This is part three of five.
A lambda remembers its surroundings
A lambda doesn’t only see its own parameters. It can also reach the variables around the place where it was defined. Look at where the lambda below gets limit from:
fun aboveThreshold(numbers: List<Int>, limit: Int): List<Int> {
return numbers.filter { it > limit }
}
limit isn’t a parameter of the lambda — it’s a parameter of aboveThreshold. The lambda passed to filter reaches out and uses it. We say the lambda captures limit, or “closes over” it. A lambda that captures something from its surroundings is a closure. That’s the whole concept; everything else is consequences.
The Java difference: you can change what you capture
Here’s the first thing to surprise a Java developer. In Java, a lambda may only capture effectively final variables — ones you never reassign. Try to modify a captured variable and it won’t compile.
Kotlin has no such restriction. A lambda can capture a var and change it:
var count = 0
listOf("a", "b", "c").forEach { count++ } // mutating a captured var
count // 3
That count++ reaching out of the lambda to update a variable in the enclosing function is perfectly legal in Kotlin and a compile error in Java. (Under the hood, Kotlin quietly wraps the captured var in a small holder object so the change is visible on both sides — which is exactly why the next point holds.)
It captures the variable, not a snapshot
The mental model that prevents most closure confusion: a lambda captures the variable itself, alive and shared, not a copy of its value at the moment of capture. The clearest way to see this is a lambda that outlives the function it came from:
fun counter(): () -> Int {
var n = 0
return { ++n } // returns a lambda that still uses n
}
val next = counter()
next() // 1
next() // 2
next() // 3
counter has already returned by the time we call next, yet n is still there, and each call increments the same one. The lambda kept it alive. That’s the superpower: a closure carries its world with it.
Shared state: a sharp edge
Because capture is by reference, two lambdas that close over the same variable share it — changes through one are visible through the other:
var total = 0
val add = { x: Int -> total += x }
val reset = { total = 0 }
add(5)
add(3)
total // 8
reset()
total // 0
add and reset operate on the same total. That’s powerful when you intend it and a bug when you don’t; if you ever find a captured value changing “on its own,” it’s almost always another closure (or later iteration) sharing it.
Loops capture cleanly
If you’ve been burned by closures-in-loops in other languages — where every lambda ends up seeing the last value — here’s a relief. Kotlin’s for loop variable is a fresh binding on each iteration, so a lambda created in the loop captures that iteration’s value:
val printers = mutableListOf<() -> Unit>()
for (i in 1..3) {
printers.add { print(i) }
}
printers.forEach { it() } // 123 — each lambda kept its own i
This prints 123, not 333. You can capture a loop variable without the classic surprise.
The catch: a closure keeps things alive
The flip side of “a closure carries its world” is that those captured variables — and any objects they point to — stay reachable for as long as the closure does. For a short-lived lambda handed to filter, that’s nothing; it’s gone an instant later. But a closure stored somewhere long-lived — an event listener, a registered callback, a coroutine that runs for a while — keeps its captured references alive that whole time, and can hold a large object in memory longer than you meant to. When a lambda will outlive the moment, be deliberate about what it captures.
A note on cost
One last consequence, which the next part will build on. Because a capturing lambda has to carry its captured variables, it can’t be a single shared instance the way a lambda that captures nothing can — it has to be allocated. That allocation is usually trivial, and Kotlin often removes it entirely; that’s the job of inline, coming up in part five.
Final thoughts
A closure is just a lambda that remembers the variables around it — but the details are what make it click. In Kotlin you can capture and mutate a var (Java can’t); capture is by live, shared reference, not a snapshot; loop variables are captured cleanly; and a long-lived closure keeps whatever it captured alive. Hold those four facts and closures stop being mysterious.
Next: references, returns, and anonymous functions — the conveniences that shorten lambdas, and the one return rule that genuinely surprises people.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments