JavaRush /Курси /Kotlin SELF /Навіщо потрібні класи й як мислити об’єктами: власний тип...

Навіщо потрібні класи й як мислити об’єктами: власний тип і посилання

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

1. Вступ

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

Уявіть, що ви пишете невеликий облік витрат (ми повертатимемося до нього і сьогодні, і далі). У вас є «назва витрати» й «сума». Наївний варіант виглядає так:

fun main() {
    val title = "Coffee"
    val amount = 300

    println("$title: $amount") // Coffee: 300
}

Поки це одна витрата — наче нормально. А тепер уявіть, що витрат стає багато. Варіант «зроблю title1, amount1, title2, amount2» швидко перетворюється на музей болю, де експонати підписані маркером «десь приблизно оце».

Трохи «просунутіший» варіант для новачків — два паралельні списки:

fun main() {
    val titles = mutableListOf("Coffee", "Taxi")
    val amounts = mutableListOf(300, 1200)

    println("${titles[0]}: ${amounts[0]}") // Coffee: 300
}

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

У цей момент хочеться запитати: «Я ж точно знаю, що title і amount — одне ціле. Чому Kotlin цього не знає?». Відповідь проста: тому що ви ще не сказали Kotlin про це явно. Класи — це якраз спосіб сказати: «Оце — одна сутність».

Pair як тимчасове рішення

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

Сценарій із витратою через Pair:

fun main() {
    val expense: Pair<String, Int> = "Coffee" to 300

    println(expense.first)  // Coffee
    println(expense.second) // 300
}

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

Але в Pair є фундаментальна проблема: імена first і second не несуть предметного сенсу. За тиждень ви побачите pair.second і будете згадувати: «друге — це сума? вік? рейтинг? кількість котиків?». Так, можна домовитися, що «second завжди amount», але такі домовленості зазвичай живуть рівно до першого втомленого вечора.

Щоб побачити різницю, корисно порівняти підходи у вигляді невеликої таблиці:

Підхід Як виглядає Що добре Що починає боліти
Окремі змінні
title, amount
просто немає «єдиного цілого», легко переплутати
Pair
"Coffee" to 300
одне значення
first/second
без сенсу
Клас
Expense(title, amount)
зрозумілі імена полів треба один раз оголосити тип

Клас — це наступний крок: ви створюєте власний тип даних із нормальними іменами, і код починає читатися як текст.

Клас і об’єкт

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

З технічного боку клас оголошують словом class. Наприклад, «порожній» клас може виглядати так:

class Expense

Але «порожній» клас марний: він як коробка без вмісту. Тому додамо поля (властивості). Зараз зробимо це найпрямолінійнішим способом — через властивості в тілі класу, щоб не забігати наперед у синтаксис конструктора (це буде окрема тема далі).

class Expense {
    var title: String = ""
    var amount: Int = 0
}

Тепер можна створити об’єкт цього класу. У Kotlin, на відміну від деяких інших мов, під час створення об’єкта не пишуть new — просто викликають імʼя класу «як функцію».

fun main() {
    val e = Expense()
    e.title = "Coffee"
    e.amount = 300

    println("${e.title}: ${e.amount}") // Coffee: 300
}

Що тут важливо на рівні «зрозуміти й перестати боятися»:

  • Expense — це тип.
  • e — це змінна, у якій зберігається посилання на об’єкт типу Expense.
  • Expense() — це момент створення нового об’єкта (екземпляра класу).
  • e.title і e.amount — доступ до даних об’єкта через крапку.

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

Прив’язуємо ідею до застосунку з витратами

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

Зробімо просту заготовку: клас Expense, створення об’єкта і функцію друку. Зверніть увагу: функція приймає один параметр (витрату), а не два (назву й суму). Це вже додає читабельності.

class Expense {
    var title: String = ""
    var amount: Int = 0
}

fun printExpense(e: Expense) {
    println("${e.title}: ${e.amount}")
}

fun main() {
    val e = Expense()
    e.title = "Coffee"
    e.amount = 300

    printExpense(e) // Coffee: 300
}

Тепер порівняйте це з варіантом на Pair. Там printExpense виглядав би приблизно так: «передайте мені пару, а я вже пам’ятатиму, що first — це title». Тобто частина сенсу живе лише у вашій голові. У варіанті з класом сенс живе в коді.

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

class Expense {
    var title: String = ""
    var amount: Int = 0
}

fun createExpense(title: String, amount: Int): Expense {
    val e = Expense()
    e.title = title
    e.amount = amount
    return e
}

fun main() {
    val lunch = createExpense("Lunch", 900)
    println("${lunch.title}: ${lunch.amount}") // Lunch: 900
}

Поки це виглядає трохи багатослівно — і це нормально. Ми свідомо використовуємо максимально явний стиль, щоб було чітко видно, що саме відбувається. Kotlin дозволяє писати коротше, але «коротше» має сенс лише після того, як стане «зрозуміло».

2. Посилання на об’єкти і поведінка змінних

Змінна зберігає посилання на об’єкт

Слово «посилання» часто звучить як початок лекції, після якої хочеться вийти у вікно (або хоча б у коридор). Але тут нам не потрібна теорія про пам’ять, адреси й байти. Нам потрібна прикладна модель поведінки — щоб ви не ловили «дивні баги» і не думали, що Kotlin із вас жартує.

Спростимо до побутової аналогії. Об’єкт — це як папка на столі. Змінна — це як наліпка з назвою, яку ви приклеїли на папку. Якщо ви приклеїте другу наліпку на ту саму папку, папок не стане дві — папка залишиться одна, просто назв у неї тепер дві.

Подивімося на код:

class Box {
    var value: Int = 0
}

fun main() {
    val a = Box()
    a.value = 10

    val b = a
    b.value = 99

    println(a.value) // 99
}

Ключовий рядок тут — val b = a. Він не створює новий об’єкт. Він робить так, що b вказує на той самий об’єкт, що й a. Тому зміна b.value змінює стан одного й того самого об’єкта, і під час читання a.value ви побачите 99.

Корисно зафіксувати це схемою. Це не «справжня пам’ять», а просто картинка — щоб мозок не опирався:

flowchart LR
    a[a] --> obj["Box(value=10)"]
    b[b] --> obj

Після b.value = 99 об’єкт стає Box(value=99), і обидві змінні бачать одне й те саме.

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

val і var: не можна переназначити, але можна змінити

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

Зробімо приклад, який покаже різницю між «переназначити посилання» і «змінити об’єкт»:

class Counter {
    var value: Int = 0
}

fun main() {
    val c = Counter()
    c.value = 5
    c.value += 1

    println(c.value) // 6
}

Тут cval, але c.valuevar. Це означає: змінна c завжди вказуватиме на той самий об’єкт, але всередині об’єкта значення поля можна змінювати.

А от так не можна:

class Counter {
    var value: Int = 0
}

fun main() {
    val c = Counter()
    // c = Counter() // так не можна: val не можна переназначати
}

Якщо перекласти людською мовою: val каже «наліпку не можна переклеїти на іншу папку», але не каже «у папці не можна змінювати документи».

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

«Однакові дані» і «один об’єкт»

Ще одна точка, де новачки зазвичай роблять чесні очі: «Але ж я створив два однакові об’єкти — чому вони не одне й те саме?». Тому що «однакові поля» і «той самий об’єкт» — різні поняття.

Дивіться:

class Box {
    var value: Int = 0
}

fun main() {
    val a = Box()
    a.value = 10

    val b = Box()
    b.value = 10

    println(a === b) // false
}

Оператор === перевіряє, чи вказують дві змінні на один і той самий об’єкт. Тут — ні, тому що ви двічі написали Box(), тобто створили два різні об’єкти.

А тепер порівняймо з «двома іменами для одного об’єкта»:

class Box {
    var value: Int = 0
}

fun main() {
    val a = Box()
    a.value = 10

    val b = a
    println(a === b) // true
}

Ця тема тісно пов’язана з == (порівняння «за значенням») і === (порівняння «за посиланням»). Ви вже зустрічали цю ідею раніше, і сьогодні вам важливо втримати практичну частину: === допомагає перевірити, це «одна папка з двома наліпками» чи «дві окремі папки».

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

3. Типові помилки під час роботи з об’єктами і посиланнями

Помилка № 1: думати, що b = a створює копію об’єкта.
Таке очікування виникає зі звички до чисел і рядків: «присвоїв — значить отримав своє незалежне значення». Але з об’єктами присвоювання копіює посилання, а не створює новий об’єкт. Якщо вам потрібна незалежна сутність, ви маєте створити новий об’єкт явно (наприклад, знову викликати Expense() або інший конструктор).

Помилка № 2: вважати, що val робить об’єкт «незмінним».
val захищає змінну від переназначення, але не забороняє змінювати var-властивості об’єкта. Це корисно, але інколи несподівано: «чому воно змінюється, якщо val?». Тому що змінюється не посилання, а стан об’єкта. Якщо ви хочете менше сюрпризів, корисна звичка — починати з val-посилань і дуже свідомо додавати змінюваність усередину об’єкта.

Помилка № 3: зловживати Pair там, де вже є предметний сенс.
Pair чудовий як тимчасова «склейка», але щойно ви ловите себе на думці «first — це назва витрати», це сигнал: час дати сутності ім’я (Expense) і нормальні властивості. Інакше код із часом перетворюється на набір загадок: «а second тут що?».

Помилка № 4: намагатися одразу «запхати все в клас» без розуміння меж.
У перший день ООП часто хочеться зробити клас, який і зберігає дані, і друкує себе, і читає введення, і сортує список, і ще вміє варити каву. На старті тримайте клас як модель даних (поля) — цього вже достатньо, щоб виграти в читабельності й зменшити кількість помилок.

Помилка № 5: ігнорувати перевірку «один об’єкт чи два» під час налагодження.
Коли «раптом змінюється не там», найкращий швидкий тест — перевірити a === b. Якщо true, отже, ви справді працюєте з одним об’єктом — просто через два імені. Це саме по собі не виправляє баг, але миттєво пояснює, чому він узагалі можливий.

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