1. Вступ
Коли ви вперше починаєте писати класи, мозок часто робить хитрий фінт: «О, тепер у мене є власні типи — отже, я можу складати в них усе, що бачу, і змінювати як захочу». Це звучить логічно… рівно до першої помилки. Сума раптом стає від’ємною, заголовок витрати «сам змінюється», а метод list() починає друкувати в консоль у найнесподіваніших місцях. Сьогодні розберемо типові граблі на старті ООП — щоб наступати на них трохи рідше й хоча б усвідомлено.
Домовимося про контекст. Ми продовжуємо наш консольний міні‑проєкт обліку витрат (умовно назвемо його BudgetBuddy). У нас є сутність витрати Expense і список MutableList<Expense>, у який ми додаємо елементи та будуємо звіти.
2. Змінюваність: коли var перетворює код на желе
Зазвичай найперша «шкідлива звичка» в ООП виглядає так: ви бачите, що властивості можна оголошувати як var, і рука сама пише var всюди. Бо «а раптом потім знадобиться змінити». У підсумку виходить клас, який можна випадково змінити звідки завгодно — як пластилін: трохи натиснули, і форма вже інша. Проблема не в тому, що var — зло. Проблема в тому, що неконтрольована змінюваність робить поведінку програми непередбачуваною, особливо коли об’єктів багато й вони лежать у колекціях.
Занадто багато var: «про всяк випадок» — найдорожчий аргумент
Почнемо з ілюстрації. Ось «підозрілий» варіант моделі витрати:
class Expense(var title: String, var amount: Int, var category: String)
fun main() {
val e = Expense("Coffee", 300, "food")
e.amount = -10
println(e.amount) // -10
}
Код компілюється, усе чесно. Але з погляду предметної області «витрата = -10» звучить як «анти‑витрата», «кредит від всесвіту» або просто помилка. Якщо клас дозволяє записати некоректне значення, отже, коректність стану тримається на дисципліні програміста (а дисципліна програміста зазвичай закінчується на другій каві).
Здоровіший варіант за замовчуванням для стартової моделі — робити властивості незмінними (val), доки ви точно не знаєте, що саме й навіщо будете змінювати:
class Expense(val title: String, val amount: Int, val category: String)
fun main() {
val e = Expense("Coffee", 300, "food")
println("${e.title}: ${e.amount}") // Coffee: 300
}
Це не робить програму «ідеальною», але суттєво зменшує кількість місць, у яких може з’явитися хаос: після створення об’єкт стабільний, а отже, його простіше читати й розуміти.
val захищає посилання, а не внутрішній стан
Є ще одна пастка. Багато хто думає: «Гаразд, тоді я зберігатиму все в val, і все стане незмінним». Але val для об’єкта забороняє перепризначати посилання, а не змінювати сам об’єкт (якщо в нього є var-властивості). Те саме стосується й колекцій: val захищає змінну від перепризначення, але не робить колекцію «замороженою». Це добре видно на прикладі зі стандартною бібліотекою: val-список можна змінювати, якщо це MutableList.
fun main() {
val expenses = mutableListOf<String>()
expenses.add("Coffee")
expenses.add("Taxi")
println(expenses) // [Coffee, Taxi]
// expenses = mutableListOf() // так не можна, у цьому і є сенс val
}
Саме тому корисно тримати в голові просту таблицю (вона економить нерви):
| Що ви пишете | Що це означає за змістом | Що залишається «змінюваним» |
|---|---|---|
|
не можна перепризначити x | об’єкт усередині може змінюватися, якщо в нього є var |
|
можна перепризначити x | плюс об’єкт усередині також може змінюватися |
|
не можна замінити список на інший | але можна add/remove, бо список mutable |
Правило: val за замовчуванням, var — за потреби
Коли ви пишете модель, спробуйте подумки вимовити фразу: «У реальному житті ця штука змінюється?»
Назва витрати зазвичай не змінюється. Якщо користувач помилився, найчастіше це вже інша операція — «виправити введення», і це окрема дія. Сума також зазвичай фіксується. Категорія інколи змінюється, але це вже усвідомлений сценарій «перекласифікувати». Тож можна починати з val, а для рідкісного сценарію зміни — зробити окремий метод, який виконує перевірку й змінює рівно те, що потрібно.
Ось приклад, де змінюване рівно одне поле, а зміна відбувається через метод:
class Expense(val title: String, val amount: Int, var category: String) {
fun recategorize(newCategory: String) {
val prepared = newCategory.trim()
if (prepared.isBlank()) return
category = prepared.lowercase()
}
}
fun main() {
val e = Expense("Taxi", 1200, "transport")
e.recategorize(" Travel ")
println(e.category) // travel
}
Зауважте: ми не обговорюємо зараз «просунутий захист властивостей» (accessors, private set тощо) — це окремі теми. Тут важлива проста ідея: якщо об’єкт змінюється, нехай він змінюється передбачувано — через методи, які підтримують мінімальні правила коректності.
3. Спільні посилання: один об’єкт, два імені й три проблеми
Друга велика помилка новачка — забути, що змінна об’єктного типу зберігає посилання. Ви це вже бачили, але тепер проблема стає практичною: об’єкт лежить у списку, на нього посилаються різні змінні, ви десь змінили поле — і раптом «зламалося» взагалі в іншому місці. Це не містика, а звичайна реальність посилальних типів.
Ментальна модель: «таблиця об’єктів» і стрілочки‑посилання
Корисно уявляти це так: десь є об’єкт Expense(...), а змінні — це стрілочки, що вказують на нього.
flowchart LR
a[val a] --> O["Expense(title='Coffee', amount=300)"]
b[val b] --> O
Якщо об’єкт змінюваний, то зміна через будь-яку «стрілочку» змінює один і той самий об’єкт.
Приклад: чому змінилося «не там»
class Box(var value: Int)
fun main() {
val a = Box(10)
val b = a
b.value = 99
println(a.value) // 99
}
З погляду JVM тут один об’єкт Box і дві змінні, які на нього вказують. Помилка виникає, коли ви очікували, що b = a «створить копію». Ні: копію створює лише явне створення нового об’єкта Box(...).
Як це ламає колекції об’єктів
Тепер перенесімо ту саму ідею на список витрат. Уявіть, що ви знайшли витрату за індексом, зберегли її в змінну, а десь пізніше змінили. Якщо об’єкт змінюваний, ви змінили елемент списку — бо в списку лежить посилання на той самий об’єкт.
class Expense(var title: String, var amount: Int)
fun main() {
val expenses = mutableListOf(
Expense("Coffee", 300),
Expense("Taxi", 1200)
)
val first = expenses[0]
first.amount = 999
println(expenses[0].amount) // 999
}
Інколи це саме те, що потрібно. Але частіше на старті ви ловите «побічні ефекти»: змінили в одному місці — відобразилося в іншому, і налагоджувати стає важко.
val не прибирає спільні посилання
val не прибирає спільні посилання. Він лише каже: «цю змінну не можна спрямувати на інший об’єкт». Але якщо всередині об’єкта є var, стан усе одно змінюється. Тож якщо ви хочете менше сюрпризів, на ранньому етапі зазвичай найкраще працюють два прості підходи.
- Перший підхід — робити модель більш незмінною (більше val, менше var), щоб навіть за спільних посилань не було чого «випадково змінювати».
- Другий підхід — якщо вам потрібен «незалежний» об’єкт, створюйте новий об’єкт явно, не сподіваючись на «копіювання присвоюванням».
class Expense(val title: String, val amount: Int)
fun main() {
val original = Expense("Coffee", 300)
val independent = Expense(original.title, original.amount)
println(original === independent) // false
}
Ми поки не використовуємо спеціальні механізми «копіювання моделі». Тримаймо думку простою: новий об’єкт з’являється лише через ClassName(...).
4. Відповідальність: модель без readln() і println()
Ще одна типова помилка на старті ООП — зробити клас «богом усього»: і дані зберігає, і введення читає, і меню малює, і звіти друкує. Спочатку це здається зручним: «ну це ж усе про витрати». А потім клас розростається, і будь-яка зміна перетворюється на операцію на відкритому серці — без анестезії.
Ознака проблеми: «клас знає про консоль»
Ось підозрілий приклад (він працює, але починає тягнути проєкт не туди):
class Expense(val title: String, val amount: Int) {
fun printLine() {
println("$title: $amount")
}
}
fun main() {
val e = Expense("Coffee", 300)
e.printLine() // Coffee: 300
}
Чому це проблема? Бо Expense тепер «уміє друкувати». А якщо завтра ви захочете виводити не в консоль, а в рядок (для звіту), або у файл, або в GUI — модель стане незручною. Вона починає залежати від способу відображення.
Набагато гнучкіше, коли модель описує що це таке, а виведення — це окрема логіка (хай навіть поки в main або у функції‑утиліті).
class Expense(val title: String, val amount: Int)
fun formatExpense(e: Expense): String {
return "${e.title}: ${e.amount}"
}
fun main() {
val e = Expense("Coffee", 300)
println(formatExpense(e)) // Coffee: 300
}
Так Expense залишається «чистим»: він не знає ні про консоль, ні про введення, ні про те, як саме ви хочете відображати дані.
Модель не повинна «питати користувача»
Інколи новачки роблять так: «нехай Expense сам запитає в користувача назву і суму». Це виглядає як економія коду, але на практиці так змішуються шари: модель (дані) та інтерфейс (введення/виведення) стають одним і тим самим, а зміни — болісними.
Правильніше так: main (або ваші функції сценарію) читають введення, перевіряють його та створюють об’єкт. Об’єкт не бігає кімнатою з мікрофоном і питанням «а скільки ви витратили?».
5. Імена: чому Item, Data, Info — це туман
На старті ООП часто хочеться назвати все максимально «універсально»: Item, Entity, Data, Record. Здається, що так «професійніше». Насправді це як підписати коробки під час переїзду словами «речі» та «ще речі», а потім шукати зарядку від ноутбука. У програмуванні імена — це частина інтерфейсу, а не прикраса.
Погані імена роблять код довшим
Порівняймо два варіанти. Перший — «універсальний»:
class Item(val a: String, val b: Int)
fun main() {
val x = Item("Coffee", 300)
println(x.a) // Coffee
println(x.b) // 300
}
Другий — предметний:
class Expense(val title: String, val amount: Int)
fun main() {
val e = Expense("Coffee", 300)
println(e.title) // Coffee
println(e.amount) // 300
}
У другому варіанті мозок не робить зайвої роботи. Ви читаєте код і розумієте зміст без додаткового «перекладу»: amount — це сума, а не «якесь b».
Коротко й зрозуміло краще, ніж коротко й незрозуміло
Зрозумілість — це не обов’язково довго. title, amount, category — коротко й ясно. А от t, a, c — це вже ребус. У маленькому навчальному проєкті це терпимо, але щойно з’являється 5–10 функцій і список об’єктів — ребуси починають з’їдати час.
6. Міні‑рефакторинг BudgetBuddy без «архітектури заради архітектури»
Зараз зберемо всі ідеї в один невеликий, приземлений шматок коду: модель витрати + кілька операцій зі списком. Ми не робимо «ідеальну архітектуру», не вводимо нові конструкції мови й не перетворюємо проєкт на ентерпрайз. Ми просто прибираємо типові помилки на старті.
Модель: мінімум змінюваності, максимум ясності
class Expense(val title: String, val amount: Int, val category: String)
fun createExpense(titleInput: String, amount: Int, categoryInput: String): Expense? {
val title = titleInput.trim()
val category = categoryInput.trim().lowercase()
if (title.isBlank()) return null
if (amount <= 0) return null
if (category.isBlank()) return null
return Expense(title = title, amount = amount, category = category)
}
Ми зробили просту фабрику‑функцію, яка повертає Expense?: або коректний об’єкт, або null, якщо дані погані. Це знайомий вам підхід «прочитав → підготував → перевірив» з ранніх днів курсу, просто тепер результатом є об’єкт.
Список: val на колекції, операції — через функції
Важливо пам’ятати: val на mutable‑колекції захищає посилання від перепризначення. Це корисно для передбачуваності.
fun addExpense(expenses: MutableList<Expense>, e: Expense) {
expenses.add(e)
}
fun totalAmount(expenses: List<Expense>): Int {
return expenses.sumOf { it.amount }
}
fun main() {
val expenses = mutableListOf<Expense>()
addExpense(expenses, Expense("Coffee", 300, "food"))
addExpense(expenses, Expense("Taxi", 1200, "transport"))
println(totalAmount(expenses)) // 1500
}
Тут у нас немає «класу‑комбайна». Модель — окремо, операції над колекцією — окремо, main — місце, де «склеюється» сценарій.
Захист від спільних посилань через дизайн
Зараз Expense повністю незмінний. Це означає: навіть якщо у вас тисяча посилань на один і той самий об’єкт витрати (що саме по собі вже підозріло, але припустімо), «випадково змінити» нічого не вийде. Ви або створюєте нову витрату, або працюєте з наявною як із фактом.
Якщо ж ви точно знаєте, що витрата має змінюватися (наприклад, категорія змінюється за командою користувача), робіть зміну через метод. Нехай метод і ухвалює рішення, що допустимо.
7. Типові помилки на старті ООП
Помилка № 1: «усе робимо var, бо потім знадобиться».
Так ви купуєте собі свободу «потім», але платите за неї хаосом «зараз»: будь-який шматок коду може змінити об’єкт, і вам складно довести, де саме це сталося. На старті безпечніше за замовчуванням писати val у властивостях, а var додавати лише під конкретний сценарій зміни — бажано через окремий метод із перевірками.
Помилка № 2: забувати, що присвоювання копіює посилання, а не створює новий об’єкт.
Момент, коли ви робите b = a, не створює клона. Це просто друге ім’я того самого об’єкта. Якщо об’єкт змінюваний, зміна через a буде видима через b, і навпаки. Лікується не «магією», а дисципліною: або робіть об’єкти більш незмінними, або створюйте новий об’єкт явно, коли вам потрібен незалежний екземпляр.
Помилка № 3: зберігати сутність як набір паралельних змінних і списків.
Коли у вас є titles, amounts, categories, ви рано чи пізно отримаєте розсинхрон: видалили елемент з одного списку, забули з іншого — і тепер «таксі коштує 300, а кава — 1200». Один список об’єктів (MutableList<Expense>) розв’язує цю проблему на рівні структури даних.
Помилка № 4: робити модель відповідальною за введення й виведення.
Клас Expense не повинен читати readln() і друкувати println() — не тому, що «так не можна», а тому, що ви втрачаєте гнучкість: модель починає залежати від конкретного інтерфейсу (консолі). Краще тримати введення/виведення у сценаріях (main і функціях навколо нього), а модель — як опис даних і простих правил зміни.
Помилка № 5: незрозумілі імена (Data, Item, Info, a/b/c).
Універсальність імені часто означає лише одне: ви не хочете зараз думати про зміст. Але зміст усе одно доведеться придумати — просто пізніше, під час налагодження. Вдалі імена економлять час читання коду, а час читання в реальній розробці зазвичай дорожчий за час написання.
Помилка № 6: намагатися «лікувати» проблему спільних посилань додатковими var і перестановками змінних.
Коли ви помічаєте неочікувані зміни, дуже хочеться «перекинути об’єкт в іншу змінну» й сподіватися, що стане краще. Але проблема не в змінній, а в тому, що у вас один об’єкт і багато посилань на нього. Якщо потрібен незалежний екземпляр — створюйте новий об’єкт. Якщо незалежність не потрібна — робіть зміни централізовано й передбачувано.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ