JavaRush /Курси /Kotlin SELF /Методи й стан: поведінка поруч із даними, this і базові п...

Методи й стан: поведінка поруч із даними, this і базові перевірки

Kotlin SELF
Рівень 30 , Лекція 2
Відкрита

1. Навіщо тримати поведінку поруч із даними

Коли ви починаєте писати програму, дуже хочеться тримати всю логіку «ззовні»: ось дані, а ось окремо функції, які з ними щось роблять. І це нормально — так простіше стартувати. Але в міру зростання коду з’являється неприємний ефект: правила роботи з даними починають повторюватися в різних місцях, а зміни перетворюються на гру «знайди всі місця, де я забув про перевірку».

Уявіть, що в нас є витрата: назва й сума. Поки ви вводите її один раз — усе добре. Але щойно ви починаєте змінювати назву, змінювати суму, перевіряти «чи це велика витрата», форматувати виведення — логіка розмазується по всьому проєкту.

Спочатку це виглядає приблизно так:

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
}

Працює? Так. Масштабується? Як дипломний проєкт, зроблений нашвидкуруч, — теж так, але недовго.

І ось тут з’являється ідея: якщо «перевірка на великість» — це смислова частина витрати, то чому б не зробити її методом витрати?

2. Методи: оголошення, виклик і доступ до властивостей

Метод класу: як оголосити й як викликати

Метод — це звичайна функція, але вона живе всередині класу й викликається в об’єкта через крапку. У Kotlin це читається дуже природно: expense.isLarge() звучить як фраза, а не як заклинання зі стародавнього фреймворку.

Почнімо з найпростішого: додамо метод, який гарно друкує витрату в одному рядку.

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
}

Зверніть увагу на два моменти. По-перше, метод оголошується як fun усередині фігурних дужок класу. По-друге, викликається як e.asLine(): об’єкт ліворуч, метод праворуч. Це важлива звичка, тому що далі майже все ООП у Kotlin буде «через крапку».

Чому методи «бачать» властивості об’єкта безплатно

Найприємніше в методах для новачка — те, що метод уже «тримає в руках» об’єкт. Тобто йому не потрібно передавати окремо title і amount, бо вони вже всередині об’єкта. Це виглядає як магія, але насправді все просто: метод викликається для конкретного об’єкта, отже працює з його станом.

Порівняймо два підходи: функція ззовні та метод усередині.

Функція ззовні:

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
}

Те саме, але «поруч із даними»:

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
}

Другий варіант зазвичай читається легше: менше «шуму» у виклику й менший ризик «передати не той об’єкт». А ще важливіше — правила форматування живуть в одному місці, а не розповзаються по всьому проєкту.

3. Стан об’єкта: var і «об’єкт у часі»

Стан — це поточні значення var-властивостей об’єкта. Якщо в об’єкта є змінювані поля, це означає, що об’єкт може змінюватися в часі: був таким — став іншим. Це корисно для лічильників, гаманців, поточних налаштувань і загалом для всього, що «живе» та змінюється.

Зробімо мініприклад: лічильник, який можна збільшувати.

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

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

І дуже важлива деталь, яка часто збиває з пантелику на початку: val c = Counter() забороняє перепризначати посилання (тобто не можна зробити c = Counter()), але не забороняє змінювати стан об’єкта, якщо всередині є var.

Тобто val не робить об’єкт «замороженим» — він робить змінну «неперепризначуваною».

4. this: коли він не потрібен і коли рятує

Слово this у Kotlin означає «поточний об’єкт». У більшості випадків Kotlin дозволяє його не писати: якщо ви звертаєтеся до title, компілятор розуміє, що це this.title.

Але інколи this потрібен, щоб прибрати двозначність — особливо коли параметр методу має те саме імʼя, що й властивість. Це типовий випадок під час «перейменування», «оновлення суми» та подібних операцій.

Ось приклад, у якому this робить намір однозначним:

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
}

Практичне правило просте: доки немає конфліктів імен, можна не писати this. Щойно з’являється відчуття «щось тут підозріло», this допомагає прямо показати, що ви змінюєте стан саме об’єкта.

5. Інваріанти й перевірки: тримаємо об’єкт у коректному стані

Інваріант — це просте правило коректності стану. Побутовий сенс такий: «що має бути завжди правдою, щоб об’єкт мав сенс». Наприклад, у витрати сума не повинна бути від’ємною, а назва не повинна бути порожньою.

Інваріанти краще перевіряти там, де відбувається зміна, тобто в методах. Тоді у вас з’являється «одна точка контролю», і вам не потрібно пам’ятати про перевірки в кожному місці програми.

Зробімо Expense змінюваним і додаймо методи оновлення з мінімальними перевірками:

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
    }
}

Тут метод повертає Boolean, і це дуже «приземлений» контракт: true — вийшло, false — не вийшло, і об’єкт не зламано. Такий підхід доречний, коли помилка — очікувана частина життя (користувач увів нісенітницю — буває).

Ранній return і require/check: різні сценарії

Іноді помилка — це нормальна ситуація, а іноді — логічна катастрофа (у стилі «у гаманці від’ємний баланс, хоча ми обіцяли, що такого не буває»). Kotlin дає два популярні інструменти: м’який стиль через return false і жорсткий стиль через require/check.

  • require(...) зазвичай перевіряє коректність вхідних аргументів і в разі порушення кидає IllegalArgumentException.
  • check(...) зазвичай перевіряє стан і в разі порушення кидає IllegalStateException.

Порівняймо підходи на прикладі гаманця.

М’який варіант (очікуємо, що користувач може помилитися):

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
}

Жорсткий варіант (вважаємо, що «сюди не можна приходити з неправильними даними»):

class Wallet(var balance: Int) {

    fun withdraw(amount: Int) {
        require(amount > 0) { "Сума має бути додатною: $amount" }
        check(balance >= 0) { "Баланс зламаний: $balance" }

        require(amount <= balance) {
            "Недостатньо грошей. Balance=$balance, amount=$amount"
        }
        balance -= amount
    }
}

Різниця не в синтаксисі, а у філософії.

  • Повернення false дозволяє програмі продовжити роботу й спокійно попросити користувача «ввести ще раз».
  • require/check — це сигнал: «програма в такій ситуації не повинна продовжувати роботу; це порушення контракту».

6. Застосовуємо на практиці: оновлюємо Expense через методи

Ми вже можемо побачити правильну ідею на маленькому фрагменті: зовнішній код не повинен знати всі правила зміни витрати. Зовнішній код має попросити об’єкт виконати дію, а об’єкт сам вирішить, що допустимо.

Ось як може виглядати оновлення назви: ми не робимо expense.title = ... напряму, а викликаємо 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
}

Зверніть увагу: у разі помилки об’єкт залишається в попередньому коректному стані. Це і є маленька «перемога» інваріанта: ми не дозволили створити дивний об’єкт, з яким потім незрозуміло що робити.

Якщо ви хочете оновлювати й суму, то зовнішній код знову не «знає» правил — він просто викликає метод:

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
}

На перший погляд це дрібниця, але в реальному проєкті вона економить багато нервів: вам не доводиться шукати по всьому коду «де ми могли присвоїти від’ємну суму», бо присвоювання «ззовні» ви майже не робите.

7. Типові помилки

Помилка № 1: клас є, але вся логіка все одно живе ззовні.
Новачок заводить class Expense(...), але потім продовжує писати десяток функцій на кшталт formatExpense(e), isLargeExpense(e), renameExpense(e, ...), validateExpense(e) тощо. У підсумку клас перетворюється на «мішок полів», а правила залишаються розмазаними. Якщо дія природно стосується конкретного об’єкта, краще зробити її методом: так менше шансів забути якесь правило в одному з місць.

Помилка № 2: змінюваність (var) додана «про всяк випадок», а не за змістом.
Якщо ви зробите всі властивості var, то будь-який шматок коду зможе змінити об’єкт як завгодно й коли завгодно. Потім ви раптом побачите amount = -100 і почнете підозрювати, що в проєкт проникла темна магія. Починати краще з val, а var додавати лише тоді, коли зміна справді потрібна за змістом сценарію.

Помилка № 3: стан змінюють напряму, оминаючи методи.
Ви написали rename() із перевірками, але десь у коді все одно залишилося expense.title = readln(). У цей момент перевірки втрачають сенс: частина змін іде через «правильний вхід», а частина — через «чорний хід». Якщо ви вирішили захищати інваріанти методами, намагайтеся викликати методи, а не змінювати поля напряму.

Помилка № 4: метод змінює стан, але не повідомляє, чи вдалося.
Якщо метод може «не спрацювати» (наприклад, нова назва порожня), але водночас повертає Unit і мовчки нічого не робить, то зовнішній код не розуміє, що сталося. Це породжує дивні сценарії: користувач увів порожній рядок, програма продовжила так, ніби все добре, а зміна не застосувалася. Для очікуваних помилок зручний контракт Boolean: код, що викликає, зможе показати повідомлення й повторити введення.

Помилка № 5: плутанина через однакові імена й відсутність this.
Метод rename(name: String) майже напевно трапиться у вашому житті, і майже напевно ви хоча б раз напишете name = name.trim() та будете хвилину дивитися в монітор, очікуючи просвітлення. У таких місцях this — не «зайві літери», а спосіб зробити намір очевидним: this.name = name.trim().

Помилка № 6: require/check використовують там, де сценарій має продовжуватися.
Якщо ви перевіряєте введення користувача через require(...), то в разі помилки ваша програма «впаде» з винятком, бо require саме так і влаштований. Це корисно для захисту контракту, але погано для дружнього CLI, де користувач просто міг помилитися. Для очікуваних помилок краще повернути false/null і попросити ввести ще раз, а require/check залишати для ситуацій «так бути не повинно».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ