JavaRush /Courses /Kotlin SELF /Visibility and scope: private/public

Visibility and scope: private/public

Kotlin SELF
Level 8, Lesson 5
Available

1. Why scopes are needed

When you write a small 20-line program, usually everything sits in main, and it feels like no special rules are needed: “I can already see all the code.” But as soon as you start extracting logic into functions (and that is the right thing to do), more and more names appear in the file: printMenu, readChoice, sumExpenses, formatMoney, and so on. At that point, your brain starts working like a compiler: “which of these can I touch? where is it declared? why can’t I see it here?”

To keep your brain from overheating (and to stop the compiler from throwing Unresolved reference at you), Kotlin has two ordering mechanisms:

  1. Visibility modifiers (public, private) answer the question: who can use this declaration from the outside?
  2. Scope answers the question: where in the code does the name exist and is accessible?

From the outside these terms look similar, but the meaning is different. Let’s separate them carefully.

Visibility: public and private

When we talk about “visibility,” we are discussing access boundaries. This is no longer about where a variable is declared, but about who is allowed to access it. Imagine your file as a small “function store.” Some functions are the storefront: a user comes in and sees them. And some are back rooms: only the staff goes there, and there is nothing for the customer to do there (and it is even harmful if they get in).

Within this lecture we only need two modifiers:

  • public — accessible “from the outside.” At the file (top-level) level, this is the default, so you usually don’t need to write public.
  • private — accessible only inside this file (if it is a top-level function or variable).

This is especially useful when you have helper functions: you need them to implement the scenario, but you don’t want someone to accidentally start calling them as an “official API.”

In Kotlin, a very typical approach to visibility is hiding input helper functions as private, so you don’t clutter the file’s “public storefront”: you can see this approach even in template-writing practices, where people do private fun readInt() = ....

Mini example: a public function and a private helper

fun main() {
    runApp()
}

private fun runApp() {
    printHeader()
    println("App started")
}

private fun printHeader() {
    println("=== BudgetBuddy ===") // === BudgetBuddy ===
}

Here main() is public by default (we don’t write it, but it is implied), and runApp() and printHeader() are our internal details. If the file were part of a large project, we would not want someone “from the outside” to call printHeader() directly.

2. Scope: where the name exists

Now about scope — this is no longer about “is it allowed,” but about “is it visible at all.” Even if something is public, it does not become accessible everywhere on the planet: the name is still available only in a specific part of the code.

The most common beginner thought is: “But I declared the variable, why can’t I use it below?” Answer: because you declared it inside a block, and the block ended — the variable “died” (don’t worry, it’s a normal and even useful death).

In Kotlin, scopes are most often defined by curly braces { ... }. And these are not decorative braces; they are literal life boundaries for names.

Example: a variable lives only inside if

fun main() {
    val x = 10

    if (x > 0) {
        val msg = "x is positive"
        println(msg) // x is positive
    }

    println(msg) // Compilation error: msg no longer exists here
}

msg is declared inside the if block. After the closing }, the name msg disappears: you can’t refer to it, even if you really want to.

The same principle for while and for

fun main() {
    var sum = 0

    for (i in 1..3) {
        val add = i * 10
        sum += add
    }

    println(sum) // 60
    println(add) // Error: add is visible only inside the loop body
}

The variable add exists only inside the loop body. This protects you from accidentally using “temporary” variables where they no longer make sense.

3. Useful nuances

private/public and scope are not the same thing

It’s easy to get confused here, so let’s lock it in with a clear analogy. Imagine you have keys and walls.

Scope is the walls of a room. If you leave the room, you can’t see the things that stayed inside.
Visibility (private/public) is the keys to the door. Even if something is in the room, you may not have the right to enter.

Meaning:

  • a name can be in scope (it “lives here”), but be inaccessible because of private (for example, from another file);
  • a name can be public, but still not exist at the current point in the code (if you left the block).

To avoid keeping this in your head as a mess, it helps to look at a table.

What we’re discussing Question Example Who “controls” it
Scope “Where does the name exist?”
{
    val t = 1
}
Curly braces, code structure
Visibility “Who can use it?”
private fun helper()
Modifiers private/public

Local functions and scope

In the previous lecture you learned about local functions: functions declared inside another function. Now is the perfect time to understand them through the lens of scope: a local function is visible only inside the outer function. Outside of it, it does not exist.

This is often very convenient: you don’t clutter the file with a dozen “helperSomething” functions if they’re needed only in one scenario.

Example: a local function for formatting money

fun main() {
    printReceipt(totalCents = 2599)
}

fun printReceipt(totalCents: Int) {
    fun formatMoney(cents: Int): String {
        val dollars = cents / 100
        val rest = cents % 100
        return "$dollars.${rest.toString().padStart(2, '0')}"
    }

    println("Total: ${formatMoney(totalCents)}") // Total: 25.99
}

formatMoney is an “internal employee.” It exists only inside printReceipt. You can’t call it from the outside (and that’s a plus: fewer ways to accidentally break something).

4. Name shadowing

If you like looking for tricks everywhere, you may have already wondered: what happens if there are multiple variables with the same name in scope?

You can declare a variable with the same name as another variable at a higher level. This is called name shadowing (shadowing). The compiler often allows it, but readability almost always suffers.

The main danger is not that “the program doesn’t work,” but that later you read the code and think: “which x is this x?”

Example: same names in different blocks

fun main() {
    val count = 10

    run {
        val count = 3
        println(count) // 3
    }

    println(count) // 10
}

Inside the run { ... } block, count is a different count. The outer one did not go anywhere, but it is “hidden” inside the block. Technically this can be necessary, but in educational and applied projects it is most often just accidental confusion.

5. The BudgetBuddy project and visibility order

Now we’ll carefully apply private/public and scope in practice. Let’s build a simple console application BudgetBuddy: we store expenses in an array, add new amounts, and print statistics. We do this in a single file on purpose, so we don’t drift into project structure and packages.

Let’s agree on the style up front: from the outside we only need the entry point main(). Everything else is implementation details, so it makes sense to make them private, so the file looks like a neat “mini-module.”

Step 1: the file storefront — only main()

fun main() {
    runBudgetBuddy()
}

private fun runBudgetBuddy() {
    println("=== BudgetBuddy ===") // === BudgetBuddy ===
}

Even at this step the file reads nicely: main starts the scenario, and the scenario is hidden inside.

Step 2: add state and show variable scope

We’ll store expenses in an IntArray, where the number is an amount in cents (so we don’t have to deal with Double and precision).

fun main() {
    runBudgetBuddy()
}

private fun runBudgetBuddy() {
    val expenses = IntArray(5)
    var size = 0

    println("Let’s add some test expenses...")
    expenses[0] = 199
    expenses[1] = 550
    size = 2

    println("Records: $size") // Records: 2
}

Here expenses and size live only inside runBudgetBuddy. That’s normal: it’s internal state of the scenario.

Step 3: split into functions and hide helpers

Let’s add a menu and a command selection. Note: we’re not building a “perfect product,” we’re practicing visibility and name scopes.

fun main() {
    runBudgetBuddy()
}

private fun runBudgetBuddy() {
    println("=== BudgetBuddy ===") // === BudgetBuddy ===

    while (true) {
        printMenu()
        val cmd = readln().trim()

        if (cmd == "exit") return
        println("Command: $cmd") // Command: ...
    }
}

private fun printMenu() {
    println()
    println("Enter a command: add / list / exit")
}

printMenu() is a pure helper. No one outside the file needs it, so private is a perfect fit.

By the way, the habit of marking such functions as private is useful even in “single-file” tasks: you prevent your file from turning into a dump of “public random functions.” This approach with private helper functions is often used in input templates (for example, private fun readInt()), so you don’t multiply public declarations.

Step 4: blocks and scope inside if/else if

And this is where you can clearly see how blocks and scope work.

fun main() {
    runBudgetBuddy()
}

private fun runBudgetBuddy() {
    val expenses = IntArray(10)
    var size = 0

    while (true) {
        printMenu()
        val cmd = readln().trim()

        if (cmd == "add") {
            val added = addExpense(expenses, size)
            size += added
        } else if (cmd == "list") {
            printExpenses(expenses, size)
        } else if (cmd == "exit") {
            return
        } else {
            println("Unknown command: $cmd")
        }
    }
}

private fun printMenu() {
    println()
    println("Enter a command: add / list / exit")
}

Look closely: the variable cmd lives inside the loop body of while. And the variable added lives only inside the if block (cmd == "add") { ... }. That’s good: we won’t be able to accidentally use added in the "list" branch.

Now let’s add the functions themselves (short and clear):

private fun addExpense(expenses: IntArray, size: Int): Int {
    if (size >= expenses.size) {
        println("The array is full; you can’t add more")
        return 0
    }

    print("Enter an amount in cents: ")
    val cents = readln().trim().toInt()
    expenses[size] = cents
    return 1
}

private fun printExpenses(expenses: IntArray, size: Int) {
    println("Expenses:")
    for (i in 0 until size) {
        println("- ${expenses[i]} cents")
    }
}

Notice a small but important thing: size in addExpense is a parameter. Parameters behave like val: you can’t reassign them. But expenses[size] = cents is allowed, because that changes an element of the array, not the parameter itself.

But why is this change visible in runBudgetBuddy? Remember val alias = original from the lecture on arrays: assignment doesn’t copy the array, it gives the same array a second name. Function arguments work the same way: when you call addExpense(expenses, size), the parameter expenses gets a reference to the very same array that was created in runBudgetBuddy. That’s why an element written inside the function “shows up” outside as well. And size is just a number: the function gets a copy of its value and can’t change the size variable in runBudgetBuddy. That’s why addExpense returns 1 or 0, and runBudgetBuddy increases size itself: size += added.

Step 5: hide formatting and avoid shadowing

Let’s make money formatting a private function. We could make it local, but we’ll need it in multiple places, so let it be private at the file level.

private fun formatMoney(cents: Int): String {
    val dollars = cents / 100
    val rest = cents % 100
    return "$dollars.${rest.toString().padStart(2, '0')}"
}

private fun printExpenses(expenses: IntArray, size: Int) {
    println("Expenses:")
    for (i in 0 until size) {
        println("- ${formatMoney(expenses[i])}")
    }
}

What matters here is that rest is a name inside formatMoney. It does not “leak” outward. And that’s great: the fewer long-lived names you have, the less likely you are to step on them somewhere else by accident.

Mini diagram: how to read a file with private helpers

When you structure a file so that only main() sticks out, and everything else is details, reading becomes linear: “start → scenario → helpers.”

flowchart TD
    A["public main()"] --> B["private runBudgetBuddy()"]
    B --> C["private printMenu()"]
    B --> D["private addExpense(...)"]
    B --> E["private printExpenses(...)"]
    E --> F["private formatMoney(...)"]

Such a file is easier to maintain: you immediately see which function is the “main” one and which ones are supporting.

6. Common mistakes

Mistake #1: confusing private with scope, expecting private to “hide a variable from a neighboring block.”
Sometimes it feels like if you make something private, it will disappear “somewhere nearby.” But private/public is about access between files (and other external parts of a project), not about blocks { ... }. Blocks are handled by scope: a variable inside if is not visible outside not because it is “private,” but because the block ended.

Mistake #2: declaring a variable in a block and then trying to use it after the block.
Classic: you read input inside if, create val value, and then below you want to print it. The compiler reasonably says Unresolved reference. The fix is usually simple: declare the variable before the block, and assign inside the block. Then the name will live longer, and you consciously choose its lifetime.

Mistake #3: name shadowing “for convenience,” which later turns into confusion.
Beginners sometimes redeclare val x inside a block because “it’s easier for me.” Yes, it’s easier now — but 15 minutes later you yourself will be figuring out which x is used on a specific line. It’s better to use different names: userChoice, menuChoice, newChoice — longer, but then your brain won’t have to work like a detective.

Mistake #4: making everything public just because “it works that way.”
If you leave all top-level functions public, the file turns into a set of random “external promises.” Even if the project is small right now, the habit of hiding helpers as private helps discipline your design: there is an entry scenario and there is an internal kitchen. As a result, the code is easier to read and harder to accidentally use “the wrong” function.

Mistake #5: creating too many variables with a large scope “just in case.”
Sometimes, to defeat visibility errors, a beginner declares everything at the start of a function: var a, var b, var c — and then assigns them along the way. It works, but it makes the code muddy: it’s unclear when a variable is actually needed and what state it is in. It’s much nicer to keep variables as close as possible to where they’re used and let scope limit their lifetime.

1
Task
Kotlin SELF, level 8, lesson 5
Locked
Under the hood
Under the hood
1
Task
Kotlin SELF, level 8, lesson 5
Locked
Scope
Scope
1
Task
Kotlin SELF, level 8, lesson 5
Locked
Printing a receipt
Printing a receipt
1
Task
Kotlin SELF, level 8, lesson 5
Locked
Coupon without shadowing
Coupon without shadowing
1
Survey/quiz
Functions and their structure, 8 level, 5 lesson
Unavailable
Functions and their structure
Functions and their structure
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION