JavaRush /Courses /Kotlin SELF /Closures: capturing external variables

Closures: capturing external variables

Kotlin SELF
Level 10, Lesson 5
Available

1. Introduction

When you write a local function for the first time, your brain is happy: “Nice! I can hide helper logic right next to where it’s used and not clutter the file.” And then you notice that the local function calmly reads variables from the outer function. And not only reads—sometimes it also changes them.

That moment is called a closure: a local function “closes over” external variables that are available to it via scope.

A bit more formally (but without academic rigor), a closure is a situation where a function uses variables declared not inside it, but outside it, in an outer block. Kotlin allows this: a closure includes variables from the outer scope, and such variables may be mutable.

Capturing val: “context” without surprises

The friendliest kind of closure is when a local function captures an external variable declared as val. Why is it friendly? Because val cannot be reassigned, so the context is stable: the function won’t end up in a situation like “today my prefix is one thing, tomorrow it’s another, because someone somewhere changed the value.”

Imagine we want to print messages with a single prefix. We could pass the prefix as a parameter every time, but that gets annoying quickly. A local function solves the problem: the prefix is declared once, and log(...) uses it.

fun main() {
    val prefix = "[INFO]"

    fun log(message: String) {
        println("$prefix $message")
    }

    log("Start")  // [INFO] Start
    log("Done")   // [INFO] Done
}

Here, log is a local function, and prefix is an external variable available in scope. This is a closure, but a safe kind: we don’t change anything, we only read.

Capturing var: convenient, but hidden state appears

If a local function captures an external var variable, that variable becomes shared mutable state between the outer function and the inner one. This can be convenient (for example, to keep a counter), but behavior starts to depend on call order and on who changed the variable, and when.

Let’s start with the simplest example: a sum that we increase with a small local function.

fun main() {
    var total = 0

    fun add(x: Int) {
        total += x
    }

    add(10)
    add(5)
    println(total) // 15
}

In this code, add changes the external variable total. From the compiler’s point of view, that’s fine: a closure can mutate captured variables.

But there’s a cost: if you see the call add(10), then by the signature add(x: Int) you can’t tell that it changes an “external” sum. That means the state becomes hidden: it isn’t passed through parameters and isn’t returned as a result; it just changes “somewhere in there.”

That’s not always bad. But next we’ll learn to feel the point where “convenient” starts turning into “why did it break, I didn’t touch anything.”

An important nuance: a closure sees the current value of var

A common beginner misconception sounds like this: “A local function kind of remembers the variable’s value at the moment it was declared.” In Kotlin, that’s not the case: the local function refers to the variable, and on each call it sees its current value.

Let’s verify with a short example:

fun main() {
    var mode = "A"

    fun printMode() {
        println("mode=$mode")
    }

    printMode()   // mode=A
    mode = "B"
    printMode()   // mode=B
}

This matters for understanding risks: if mode changes in different places, then the behavior of printMode() can change too. Sometimes that’s exactly what you want (for example, a debug mode), but sometimes it turns into a mystery: “why is the log printing differently now?”

2. Practical example: a mini expense tracker

Now let’s make an example closer to a “real application,” but still simple: a console mini expense tracker. For now, we do not store a list of expenses (collections will come later); we keep only aggregates: the sum and the number of entries. This is convenient for demonstrating closures: session state lives in var variables, and local functions use it neatly.

We want this scenario: the user enters numbers (expenses), and we print the current sum. To exit, they enter exit.

fun main() {
    runExpenseSession()
}

private fun runExpenseSession() {
    val currency = "USD"
    var total = 0
    var count = 0

    fun printStatus() {
        println("Entries=$count, total=$total $currency")
    }

    println("Enter expenses, or 'exit' to finish.")
    printStatus()
}

Note: printStatus() captures currency, total, count. currency is a val, while total/count are var. This is already a closure. So far it’s used peacefully: only reading.

Now let’s add input and parsing.

private fun runExpenseSession() {
    val currency = "USD"
    var total = 0
    var count = 0

    fun printStatus() {
        println("Entries=$count, total=$total $currency")
    }

    while (true) {
        val raw = readln().trim()
        if (raw == "exit") break

        val amount = raw.toIntOrNull()
        if (amount == null) {
            println("Not a number. Try again.")
            continue
        }

        total += amount
        count += 1
        printStatus()
    }
}

There’s an “implicit” moment here: the state total and count changes inside the loop body, but printStatus will always show the current values. This is convenient and predictable as long as you keep state changes in one place and understand who is responsible for it.

Adding logging: a closure over val and over var

Let’s add a simple logger. We’ll start with a safe variant: the logger captures only the val prefix.

private fun runExpenseSession() {
    val prefix = "[EXPENSE]"
    fun log(text: String) {
        println("$prefix $text")
    }

    log("Session started") // [EXPENSE] Session started
}

This reads like “the logger has a constant context.”

Now let’s add a counter for invalid inputs. This is a capture of a var, and it’s important not to lose control here.

private fun runExpenseSession() {
    var invalidInputs = 0

    fun onInvalidInput(raw: String) {
        invalidInputs += 1
        println("Invalid: '$raw' (errors=$invalidInputs)")
    }

    val raw = readln().trim()
    onInvalidInput(raw) // Invalid: '...' (errors=1)
}

If that’s fine in a small function, then in a large codebase such a counter easily becomes a “ghost”: it grows somewhere inside, and it’s not always clear why.

3. Risks and readability: how not to turn code into a detective story

The word “testability” sounds like something from the world of big projects, but in practice it’s about a very simple thing: “can I understand and verify a function on its own.” When a function depends only on its parameters and return value, it’s easy to “run” in your head. When a function also mutates external var variables, you have to keep extra conditions in mind: what the variable’s value was before the call, who might have changed it earlier, how many times you called the function.

Let’s compare two approaches on a toy example.

Option A: changing external state (a closure over var):

private fun demoA() {
    var sum = 0

    fun add(x: Int) {
        sum += x
    }

    add(2)
    add(3)
    println(sum) // 5
}

Option B: the function returns a new result (no hidden state):

private fun demoB() {
    fun add(sum: Int, x: Int): Int {
        return sum + x
    }

    var sum = 0
    sum = add(sum, 2)
    sum = add(sum, 3)
    println(sum) // 5
}

In option B there’s more “busywork” (passing sum back and forth), but the logic is transparent: you can see what changes and where.

A practical rule for beginners: closures with var are not “bad,” but they’re a tool that’s easy to make invisible to the reader. And invisible things in code usually come back to bite you later.

How to use closures carefully

Closures become safer when you stick to one idea: dependencies should be visible to the reader. If a local function uses an external variable, that’s fine, but it’s better if this is obvious at least from the name and the placement of the code.

Usually, a few techniques help.

First, local functions that mutate external state are useful to name in a way that reads like an action on state: addToTotal, incrementInvalidCount, setModeDebug. Then the reader doesn’t expect a “pure function” and immediately understands: something will change now.

Second, if a local function can be written to take data as parameters, that often makes code clearer. Even if access to external variables exists, a parameter turns the dependency into part of the contract: “I work with what I was given.”

Third, it helps to keep the whole “scenario” linear. A closure over var is especially dangerous when you change the same variable from different places: a little bit in one if, a little bit in another, and somewhere else in a local function. In a small example it’s tolerable, but in real code it quickly turns into a series with no ending.

4. Common mistakes when working with closures

Before we finish, it’s useful to spell out the most common “rakes” that almost everyone steps on. Closures are tricky: in a small example they look cute and convenient, and then you add a couple more conditions and suddenly stop understanding why the program prints “the wrong thing.” These mistakes are not about syntax; they’re about style and clarity.

Mistake No. 1: a local function silently mutates an external var, and you can’t tell from the name.
When you have fun add(x: Int) and somewhere separately var total, the reader can easily interpret add as “just a computation” (especially if they’re used to mathematical functions). And then it turns out that add is an action that changes state. If a function mutates an external variable, it’s better to name it so it reads like a side effect: addToTotal, increaseSum, registerExpense.

Mistake No. 2: the same var is changed in multiple places, and order becomes critical.
The nastiest kind of hidden state is when the value depends on history: “how many times you called the function,” “which if branch ran first,” “whether there was already invalid input.” This doesn’t mean you can’t do it, but then the code must remain linear and as simple as possible. Otherwise you get a program where fixing one line breaks everything, like a house of cards.

Mistake No. 3: expecting the closure to remember an old variable value.
Sometimes a developer thinks: “I declared the function when mode was "A", so it will keep working as in mode A.” But the local function sees the current value of the variable on each call. So after mode = "B" the behavior will change, and that’s normal. If you really need to “freeze” the value, then with our current tools the simplest approach is to store it in a separate val and use that inside.

Mistake No. 4: name shadowing that makes you read the wrong variable.
When outside you have var total, and inside you do fun add(total: Int), the brain gets confused even for experienced people. As a result, you may update something other than what you intended, or you may simply misinterpret the code while reading it. A simple naming discipline (not repeating names in nested scopes) sharply reduces the risk.

Mistake No. 5: the closure captures too many external variables and turns into a magic function.
If a local function uses five external variables, its behavior becomes hard to explain at the call site. It starts to look like “something that will figure it out.” Usually this is a sign that the local function should be simplified: either pass some dependencies as parameters, or split it into two small functions, each doing one step and using minimal state.

1
Task
Kotlin SELF, level 10, lesson 5
Locked
Console logger
Console logger
1
Task
Kotlin SELF, level 10, lesson 5
Locked
Queue tickets
Queue tickets
1
Task
Kotlin SELF, level 10, lesson 5
Locked
Output mode
Output mode
1
Task
Kotlin SELF, level 10, lesson 5
Locked
Total for the shift
Total for the shift
1
Survey/quiz
Pair/Triple, 10 level, 5 lesson
Unavailable
Pair/Triple
Pair/Triple
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION