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 залишати для ситуацій «так бути не повинно».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ