Closures: What a Lambda Remembers
A lambda can capture variables from the scope where it was written — and in Kotlin it can even change them. The mental model, the boxing that makes it work, the Java contrast, the this-capture leak, and the concurrency caveat. Part 3 of five.
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 earns 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 and then look at where it bites. Everything here was run against Kotlin 2.4.10.
A lambda remembers its surroundings
A lambda doesn’t only see its own parameters; it can reach the variables around the place where it was defined:
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. 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 legal in Kotlin and a compile error in Java. It’s a real convenience, and it works because of a mechanism worth knowing.
How it actually works: the captured var is boxed
A JVM local variable lives on the stack and disappears when its method returns, so a lambda that outlives the method can’t just point at a stack slot. Kotlin’s trick: when a var is captured, the compiler doesn’t store it as a plain local at all. It wraps it in a small heap object — a Ref.IntRef for an Int, an ObjectRef for a reference type — with a single mutable field, and both the enclosing code and the lambda read and write that field. The variable you see as count is really count.element under the hood.
Two consequences fall straight out of this. First, mutation is visible on both sides because there’s one shared box. Second, capturing a var costs a heap allocation, where capturing a val can often be passed by value — a reason to prefer capturing immutable values when a lambda is on a hot path.
It captures the variable, not a snapshot
The mental model that prevents most closure confusion: a lambda captures the variable itself — that shared box — not a copy of its value at capture time. The clearest demonstration 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 — the box outlived the stack frame — and each call increments the same one. That’s the superpower: a closure carries its world with it.
Shared state: a sharp edge
Because capture is by reference to that box, two lambdas closing over the same variable share it, so 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. Powerful when you intend it, a bug when you don’t: if a captured value seems to change “on its own,” it’s almost always another closure sharing the same box.
Loops capture cleanly
If you’ve been burned by closures-in-loops elsewhere — where every lambda ends up seeing the last value — here’s a relief. Kotlin’s for loop variable is a fresh binding 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. (Java’s for has the same fresh-binding guarantee for its enhanced form, but the classic C-style for (int i = ...) does not — a place the two languages differ.)
The catch: a closure keeps things alive
The flip side of “a closure carries its world” is that captured variables, and the objects they point at, stay reachable 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 — pins its captured references for that whole time.
The subtle version of this leak is capturing this. A lambda written inside a method that references an instance member captures the entire enclosing object, even if it only touches one field:
class Screen(val bus: EventBus) {
val title = "Home"
fun register() {
bus.onEvent { println(title) } // captures `this@Screen`, not just `title`
}
}
onEvent’s lambda uses title, which is this.title, so the closure holds a reference to the whole Screen. If bus outlives the screen, the screen can’t be collected. This is the classic listener leak, and the fix is to capture a local copy (val t = title) so the lambda closes over the string, not the object.
The concurrency caveat
One more, because the boxing model makes it concrete. A captured var is an ordinary heap field with no synchronization. If two threads — or two coroutines on different threads — close over the same var and mutate it, that’s a data race, exactly as it would be for a shared mutable field, with the same torn-read and lost-update hazards. Closures don’t make shared mutable state safe; they just make it easy to create by accident. When a captured var will be touched concurrently, reach for an AtomicInteger, a Mutex, or a design that doesn’t share the mutation at all.
Final thoughts
A closure is a lambda that remembers the variables around it, and the details are what make it click. In Kotlin you can capture and mutate a var (Java can’t), because the compiler boxes it on the heap; capture is by that live, shared box, not a snapshot; loop variables are captured cleanly; a long-lived closure keeps whatever it captured alive, this included; and a captured var mutated across threads is a plain data race. Hold those 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