JavaRush /Courses /Kotlin SELF /Methods and state: behavior next to data, this, and basic...

Methods and state: behavior next to data, this, and basic checks

Kotlin SELF
Level 30, Lesson 2
Available

1. Why keep behavior next to data

When you start writing a program, you often want to keep all logic “outside”: here is the data, and separately there are functions that do something with it. And that is normal—it is easier to get started that way. But as the codebase grows, an unpleasant effect appears: the rules for working with data start repeating in different places, and changes start to feel like playing “find every place where I forgot a check.”

Imagine we have an expense: a title and an amount. While you only write it once, everything is fine. But as soon as you start changing the title, changing the amount, checking “is this a large expense,” formatting output—the logic gets smeared across the entire project.

At first it looks roughly like this:

fun isLargeExpense(amount: Int): Boolean {
    return amount >= 1000
}

fun main() {
    val title = "Taxi"
    val amount = 1200

    println("$title: $amount")                 // Taxi: 1200
    println(isLargeExpense(amount))            // true
}

Does it work? Yes. Does it scale? Like a “thesis project hacked together on your knee”—also yes, but not for long.

And this is where an idea appears: if “checking largeness” is a semantic part of an expense, why not make it a method of the expense?

2. Methods: declaration, invocation, and access to properties

Class method: how to declare and how to call

A method is a regular function, but it lives inside a class and is called on an object via dot syntax. In Kotlin this reads very naturally: expense.isLarge() sounds like a phrase, not like a spell from an ancient framework.

Let’s start with the simplest thing: add a method that prints an expense nicely as a single line.

class Expense(val title: String, val amount: Int) {
    fun asLine(): String {
        return "$title: $amount"
    }
}

fun main() {
    val e = Expense("Coffee", 300)
    println(e.asLine()) // Coffee: 300
}

Notice two things. First, the method is declared as fun inside the class braces. Second, it is called as e.asLine(): object on the left, method on the right. This is an important habit, because from here on almost all OOP in Kotlin will be “through a dot.”

Why methods “see” an object’s properties for free

The nicest part of methods for a beginner is that the method already “has the object in hand.” That is, you do not need to pass title and amount separately, because they are already inside the object. It looks like magic, but in reality it is just logic: the method is called on a specific object, so it works with that object’s state.

Let’s compare two approaches—an external function and an internal method.

External function:

class Expense(val title: String, val amount: Int)

fun asLine(e: Expense): String {
    return "${e.title}: ${e.amount}"
}

fun main() {
    val e = Expense("Lunch", 900)
    println(asLine(e)) // Lunch: 900
}

The same thing, but “next to the data”:

class Expense(val title: String, val amount: Int) {
    fun asLine(): String = "$title: $amount"
}

fun main() {
    val e = Expense("Lunch", 900)
    println(e.asLine()) // Lunch: 900
}

The second version is usually easier to read: less noise in the call and less risk of “passing the wrong object.” And even more importantly—formatting rules live in one place instead of creeping all over the project.

3. Object state: var and an “object over time”

State is the current values of an object’s var properties. If an object has mutable fields, then the object can change over time: it was one way, then it became another. This is useful for counters, wallets, current settings, and generally anything that “lives” and changes.

Let’s make a mini example: a counter you can increment.

class Counter(var value: Int = 0) {
    fun inc() {
        value += 1
    }
}

fun main() {
    val c = Counter()
    c.inc()
    c.inc()
    println(c.value) // 2
}

And a very important detail that often bends your brain at first: val c = Counter() prohibits reassigning the reference (that is, you cannot do c = Counter()), but it does not prohibit changing the object’s state if it has var inside.

So val does not make an object “frozen”; it makes the variable “non-reassignable.”

4. this: when you do not need it and when it saves you

The word this in Kotlin means “the current object.” In most cases Kotlin lets you omit it: if you access title, the compiler understands that it is this.title.

But sometimes this is needed to remove ambiguity—especially when a method parameter has the same name as a property. This often happens in “renaming,” “updating the amount,” and similar operations.

Here is an example where this makes the intent unambiguous:

class Person(var name: String) {
    fun rename(name: String) {
        this.name = name.trim()
    }
}

fun main() {
    val p = Person("Ann")
    p.rename("  Anna  ")
    println(p.name) // Anna
}

The practical rule is simple: as long as there are no name conflicts, you can omit this. As soon as you feel “something looks suspicious here,” this helps explicitly show that you are changing the state of the object itself.

5. Invariants and checks: keeping an object in a correct state

An invariant is a simple rule for state correctness. In everyday terms: “what must always be true for the object to make sense.” For example, an expense amount should not be negative, and a title should not be empty.

It is best to check invariants where the change happens—i.e., in methods. Then you get “a single control point,” and you do not have to remember to validate in every place in the program.

Let’s make Expense mutable and give it update methods with minimal checks:

class Expense(var title: String, var amount: Int) {

    fun rename(newTitle: String): Boolean {
        val t = newTitle.trim()
        if (t.isBlank()) return false
        title = t
        return true
    }

    fun changeAmount(newAmount: Int): Boolean {
        if (newAmount < 0) return false
        amount = newAmount
        return true
    }
}

Here the method returns Boolean, and that is a very “down-to-earth” contract: true—it worked, false—it did not, and the object is not broken. This approach is good when an error is an expected part of life (the user typed nonsense—it happens).

Early return and require/check: different scenarios

Sometimes an error is a normal situation, and sometimes it is a logical catastrophe (like “the wallet has a negative balance even though we promised that cannot happen”). Kotlin gives you two common tools: a soft style with return false and a hard style with require/check.

  • require(...) usually validates input arguments and throws IllegalArgumentException on violation.
  • check(...) usually validates state and throws IllegalStateException on violation.

Let’s compare approaches using a wallet example.

Soft option (we expect the user can make a mistake):

class Wallet(var balance: Int) {

    fun withdraw(amount: Int): Boolean {
        if (amount <= 0) return false
        if (amount > balance) return false
        balance -= amount
        return true
    }
}

fun main() {
    val w = Wallet(100)
    println(w.withdraw(30))  // true
    println(w.balance)       // 70
    println(w.withdraw(200)) // false
}

Hard option (we assume “you should not come here with invalid data”):

class Wallet(var balance: Int) {

    fun withdraw(amount: Int) {
        require(amount > 0) { "Amount must be positive: $amount" }
        check(balance >= 0) { "Balance is broken: $balance" }

        require(amount <= balance) {
            "Not enough money. Balance=$balance, amount=$amount"
        }
        balance -= amount
    }
}

The difference is not in the syntax, but in the philosophy.

  • Returning false allows the program to continue and calmly ask the user to “enter it again.”
  • require/check is a signal: “the program must not continue in this situation; this is a contract violation.”

6. Putting it into practice: updating Expense via methods

We can already show the right idea on a small snippet: external code should not know all the rules for changing an expense. External code should ask the object to perform an action, and the object decides what is allowed.

Here is what updating the title might look like: we do not do expense.title = ... directly; we call rename(...).

class Expense(var title: String, var amount: Int) {
    fun rename(newTitle: String): Boolean {
        val t = newTitle.trim()
        if (t.isBlank()) return false
        title = t
        return true
    }
}

fun main() {
    val e = Expense("Coffee", 300)

    val ok = e.rename("   ")
    println(ok)       // false
    println(e.title)  // Coffee
}

Notice that in case of an error the object stays in its previous correct state. That is a small “win” of the invariant: we did not allow creating a weird object that you do not know what to do with later.

If you want to update the amount as well, external code again does not “know” the rules—it simply calls the method:

class Expense(var title: String, var amount: Int) {
    fun changeAmount(newAmount: Int): Boolean {
        if (newAmount < 0) return false
        amount = newAmount
        return true
    }
}

fun main() {
    val e = Expense("Taxi", 1200)

    e.changeAmount(1500)
    println(e.amount) // 1500
}

It looks like a small thing, but in a real project it saves a lot of nerves: you do not search through the whole codebase for “where could we have assigned a negative amount,” because you almost never assign from “outside.”

7. Common mistakes

Mistake #1: the class exists, but all logic still lives outside.
A beginner creates class Expense(...), but then continues writing a dozen functions like formatExpense(e), isLargeExpense(e), renameExpense(e, ...), validateExpense(e), etc. As a result, the class turns into a “bag of fields,” while the rules remain scattered. If an action naturally relates to a specific object, it is better to make it a method: fewer chances to forget a rule in one of the places.

Mistake #2: mutability (var) is added “just in case,” not because it is meaningful.
If you make every property a var, then any piece of code can change the object however it wants and whenever it wants. Then you suddenly see amount = -100 and start suspecting dark magic has entered the project. It is better to start with val, and add var only when a change is truly needed for the scenario.

Mistake #3: state changes are made directly, bypassing methods.
You wrote rename() with checks, but somewhere in the code you still have expense.title = readln(). At that moment, validations lose their meaning: some changes go through the “correct entry,” and some through the “back door.” If you decided to protect invariants with methods, try to call methods rather than changing fields directly.

Mistake #4: a method changes state but does not report whether it succeeded.
If a method can “fail” (for example, the new title is empty), but it returns Unit and silently does nothing, then the calling code does not understand what happened. This creates odd scenarios: the user entered an empty string, the program continued as if everything was fine, but the change was not applied. For expected errors, a Boolean contract is convenient: the caller can show a message and retry the input.

Mistake #5: confusion due to identical names and missing this.
The method rename(name: String) will almost certainly show up in your life, and you will almost certainly once write name = name.trim() and stare at the monitor for a minute waiting for enlightenment. In such places, this is not “extra letters,” but a way to make the intent obvious: this.name = name.trim().

Mistake #6: require/check are used where the scenario should continue.
If you validate user input with require(...), then on error your program will “crash” with an exception, because that is exactly how require works. This is useful for enforcing a contract, but bad for a friendly CLI where the user may simply have made a mistake. For expected errors, it is better to return false/null and ask to re-enter, while keeping require/check for “this must not happen” situations.

1
Task
Kotlin SELF, level 30, lesson 2
Locked
Profile card
Profile card
1
Task
Kotlin SELF, level 30, lesson 2
Locked
Hero's Steps
Hero's Steps
1
Task
Kotlin SELF, level 30, lesson 2
Locked
Nickname without junk
Nickname without junk
1
Task
Kotlin SELF, level 30, lesson 2
Locked
Wallet of Rules
Wallet of Rules
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION