1. Вступ
Коли ми пишемо програму, зазвичай мислимо так: «У мене є список витрат, у кожної витрати є сума й категорія, я можу порахувати підсумок». Усе це правда — але лише доти, доки програма запущена. Обʼєкт у памʼяті — це структура даних, яка живе всередині процесу, і вона не призначена для «подорожей у реальний світ»: у файл, у мережу або в іншу програму. Щойно застосунок завершиться, усі наші List<Expense> зникають у цифровому небутті (майже як вкладки в браузері, які «точно були важливими»).
Зафіксуймо просту модель. Поки програма працює, дані існують в оперативній памʼяті як обʼєкти Kotlin/JVM. Але щоб зберігати їх між запусками, потрібна переносима форма — зазвичай рядок або байти. І тут зʼявляється ключове слово сьогоднішньої лекції: серіалізація.
Уявімо, що в нас є модель витрати:
data class Expense(
val id: Int,
val title: String,
val amountCents: Int,
val category: String
)
Такі обʼєкти чудово живуть у списку, сортуються, фільтруються та беруть участь в обчисленнях. Але «покласти обʼєкт прямо у файл» ми не можемо так само просто, як file.writeText(expense): файл «розуміє» текст (або байти), а не наші Kotlin‑типи.
2. Серіалізація і десеріалізація
Коли ми кажемо «зберегти дані», насправді хочемо виконати конкретний трюк: перетворити обʼєкт на переносиму форму й потім повернути його назад. У програмуванні це давно формалізували двома термінами.
Серіалізація — це перетворення обʼєкта (або групи обʼєктів) у формат зберігання чи передавання: рядок або набір байтів.
Десеріалізація — зворотна операція: відновлення обʼєкта з цього рядка або байтів.
У побуті це схоже на переїзд. Поки ви живете в квартирі, речі лежать «як зручно»: горнятка — на полиці, шкарпетки — у шухляді, а зарядка завжди губиться. Але щоб перевезти все в іншу квартиру, ви пакуєте речі в коробки за зрозумілими правилами (і підписуєте їх, якщо ви оптиміст). Отже, серіалізація — це пакування, а десеріалізація — розпакування.
Схема зазвичай виглядає так:
flowchart TB
A["Обʼєкти Kotlin у памʼяті
List⟨Expense⟩"] --> B["Серіалізація
обʼєкт → рядок/байти"]
B --> C["Зберігання/передавання
файл/мережа/БД"]
C --> D["Десеріалізація
рядок/байти → обʼєкт"]
D --> E["Обʼєкти Kotlin у памʼяті
List⟨Expense⟩"]
Чому це важливо? Тому що так ми відокремлюємо «дані як сенс» від «даних як подання». Наші Expense — це сенс, а файл — це подання.
3. Чому toString() не підходить
У цьому місці майже в кожного зʼявляється спокуса: «Зачекайте, у мене ж println(expense) друкує щось зрозуміле. Значить, можна те саме зберегти у файл і потім прочитати назад!»
Подивімося. Якщо в нас data class, Kotlin автоматично дає доволі пристойний toString():
data class Expense(val id: Int, val title: String, val amountCents: Int, val category: String)
fun main() {
val e = Expense(1, "Кава", 299, "food")
println(e) // Expense(id=1, title=Кава, amountCents=299, category=food)
}
Виглядає майже як «формат даних». Але проблема в тому, що toString() — це не контракт зберігання. Це рядок для людини (передусім для налагодження), а не обіцянка, що його можна надійно розібрати назад.
Є кілька причин, чому toString() небезпечно використовувати як «формат»:
- По‑перше, формат toString() може змінитися. Сьогодні Kotlin друкує Expense(id=..., ...), а завтра (або ви самі в майбутньому) перевизначите toString() «для краси» — і все: ваші старі файли більше не читаються.
- По‑друге, toString() не дбає про екранування. Якщо title містить коми, дужки, перенесення рядка — рядок стане неоднозначним. І так, рано чи пізно користувач уведе назву на кшталт Сир (дуже смачний), і ви дізнаєтеся багато нового про розбір рядків.
- По‑третє, рядок toString() не вміє коректно описувати складні випадки: null, вкладені списки, мапи, нові поля та сумісність версій.
Із колекціями ситуація схожа: у списку є рядкове подання, і за замовчуванням воно нагадує joinToString(). У документації Kotlin прямо зазначено, що виклик joinToString() з налаштуваннями за замовчуванням дає результат, схожий на toString() колекції. Але це все одно «читабельно», а не «надійно розбирається».
4. Чому небезпечно збирати JSON вручну
Коли людина розуміє, що toString() не годиться, вона часто переходить на наступний рівень самовпевненості: «Я напишу функцію, яка збере рядок у потрібному форматі». І десь у цей момент програмування починає тихо сміятися в кулак.
Приклад «ручної серіалізації» (сильно спрощений):
data class Expense(val id: Int, val title: String, val amountCents: Int, val category: String)
fun expenseToJsonLike(e: Expense): String {
return """{"id":${e.id},"title":"${e.title}","amountCents":${e.amountCents},"category":"${e.category}"}"""
}
fun main() {
val e = Expense(1, "Кава", 299, "food")
println(expenseToJsonLike(e))
// {"id":1,"title":"Кава","amountCents":299,"category":"food"}
}
Поки в title і category немає лапок, перенесень рядка та інших «радощів життя», здається, що все працює. Але щойно хтось уведе назву:
- Кава "в дорогу" (з лапками),
- Суші\nна двох (з перенесенням рядка),
- або просто C:\Temp (із зворотними слешами),
ваш рядок перестане бути коректним, тому що JSON вимагає екранування.
І це лише перша проблема. Далі приходять коми, пробіли, вкладені обʼєкти, масиви, null, значення за замовчуванням, порядок полів і «ой, ми забули закрити фігурну дужку».
Мораль дуже проста: руками рядки збирати можна, але це схоже на спробу збудувати літак зі скотчу. У якийсь момент він навіть злетить, але ви не захочете бути пасажиром.
Тому в реальних проєктах серіалізацію майже завжди роблять бібліотекою, яка:
- коректно екранує рядки,
- суворо дотримується правил формату,
- уміє кодувати колекції та вкладені структури,
- уміє декодувати назад у типи.
Сьогодні ми не підʼєднуємо бібліотеку (це буде далі за курсом), але важливо зрозуміти мотивацію: бібліотека тут не «мода», а захист від ручних помилок.
5. JSON і контракт даних
Чому саме JSON
Коли ми обираємо формат для серіалізації, ми обираємо компроміс між зручністю, переносимістю, розміром і суворими правилами. JSON став популярним не тому, що він ідеальний, а тому що доволі вдало балансує все одразу.
JSON — це текстовий формат, який:
- По‑перше, його легко читати очима. Це важливо не лише «для краси». Коли ваш файл зламався, можливість відкрити його в текстовому редакторі й зрозуміти «що там узагалі лежить» — це суперсила.
- По‑друге, він підтримується майже всюди. Kotlin, Java, JavaScript, Python — усі вміють читати й писати JSON. Тому JSON зручний для обміну даними між системами й для зберігання «на диску».
- По‑третє, JSON достатньо структурований, щоб не перетворюватися на «простирадло тексту». На відміну від умовного «збережімо все рядком через ;», JSON одразу задає форму: обʼєкт, поля, масиви. Так, суворі правила інколи дратують (лапки, коми, null без лапок), але саме вони рятують нас від хаосу.
І, нарешті, JSON добре поєднується з нашою моделлю даних: «обʼєкт із полями» і «список обʼєктів» природно лягають на JSON‑обʼєкти {...} і масиви [...]. Ми детально розберемо структуру JSON в одній із наступних лекцій, а сьогодні нам достатньо інтуїтивної картинки.
Контракт даних
Коли ви зберігаєте дані, ви насправді укладаєте договір із майбутнім собою. Іноді — з колегою. Іноді — з іншою програмою. Цей договір називається контракт даних.
Контракт — це відповідь на запитання: які поля є в обʼєкта, як вони називаються, які в них типи, які поля обовʼязкові, а які можуть бути відсутні.
Наприклад, для нашого Expense контракт міг би звучати так (простими словами):
- у витрати є id (число),
- title (рядок),
- amountCents (число),
- category (рядок).
Чому це принципово? Тому що десеріалізація — це не «магія відновлення». Це суворе зіставлення: ми читаємо текст і намагаємося побудувати обʼєкт. Якщо контракт порушено, читання може впасти або дати неправильні дані.
Покажу на життєвому прикладі. Припустімо, ви зберегли витрату так:
val text = """{"id":1,"title":"Кава","amountCents":299,"category":"food"}"""
println(text)
А потім за місяць вирішили перейменувати поле amountCents на amount (бо «так коротше»). У коді стало гарніше, але старі файли тепер містять amountCents, а новий код очікує amount. Це конфлікт контракту: дані «з минулого» більше не відповідають моделі «з теперішнього».
Ось чому будь-які формати зберігання даних потребують дисципліни. Серіалізація — це не лише техніка «як записати», а й дизайн: «які дані вважаються коректними».
6. Що зберігати в нашому застосунку
Мініначерк структури файлу
Коли ви вперше додаєте збереження в застосунок, корисно на секунду зупинитися й сформулювати: що саме ми хочемо зберігати між запусками. Це допомагає не змішувати «дані» й «виведення на екран».
Для нашого трекера витрат розумно зберігати список витрат і, можливо, невеликі налаштування. Наприклад, валюту, наступний id або список категорій (якщо ми робимо їх налаштовуваними). Сьогодні ми не заглиблюємося в деталі, але на рівні ідеї це може виглядати так:
Файл даних (наприклад, data/expenses.json)
└── обʼєкт верхнього рівня
├── nextId: число
├── expenses: масив витрат
└── currency: рядок (опційно)
У вигляді JSON це могло б бути приблизно так (це просто приклад форми, а не «єдино правильний» варіант):
fun main() {
val demoJson = """
{
"nextId": 3,
"currency": "USD",
"expenses": [
{ "id": 1, "title": "Кава", "amountCents": 299, "category": "food" },
{ "id": 2, "title": "Проїзд", "amountCents": 250, "category": "transport" }
]
}
""".trimIndent()
println(demoJson)
}
Тут важливий не синтаксис, а думка: JSON дозволяє зберігати і список обʼєктів, і додаткові поля поруч — не перетворюючи все на кашу.
Де місце бібліотеці серіалізації
Тепер, коли в нас є мотивація, можна чесно сказати: «Окей, JSON класний, але хто писатиме код, який перетворює List<Expense> на такий рядок і назад?»
Відповідь: бібліотека серіалізації.
У Kotlin‑світі популярний варіант — kotlinx.serialization, тому що він добре працює з data class, підтримує Kotlin‑типи й поводиться передбачувано. Але сам факт «у нас буде бібліотека» сьогодні менш важливий, ніж розуміння її ролі.
Роль бібліотеки — бути вашим «перекладачем» між двома світами:
- світом Kotlin‑обʼєктів (суворі типи, Int, String, List<Expense>, nullable‑поля),
- і світом тексту (JSON‑рядок, де все має бути коректно розставлено, екрановано та структуровано).
Якщо перекладач хороший, ви пишете менше коду, рідше помиляєтеся, і ваші файли не ламаються через лапку в назві покупки.
7. Типові помилки
Помилка № 1: сприймати toString() як формат зберігання даних.
toString() справді часто виглядає «майже як дані», особливо в data class. Але це подання для читання людиною, а не стабільний контракт для машини. Сьогодні воно одне, завтра ви змінили модель або перевизначили метод — і старі файли перестали читатися.
Помилка № 2: «швидко накидати JSON рядком» без екранування.
Ручне складання JSON через """...$value...""" майже гарантовано ламається на лапках, перенесеннях рядка та зворотних слешах. На тестових даних усе здається нормальним, а потім користувач уводить назву з "..." — і формат розвалюється. Це як перевіряти парашут, кидаючи його з табуретки.
Помилка № 3: змішувати «дані» і «гарне виведення».
Іноді хочеться зберігати у файлі вже готові рядки на кшталт "Кава — 2,99 USD". Це зручно для друку, але незручно для обчислень і змін. Зберігати варто сирі значення (наприклад, amountCents як число), а форматувати для людини — під час виведення.
Помилка № 4: не думати про контракт і перейменовувати поля «як вийде».
Щойно дані починають жити у файлі, їм байдуже, що ви перейменували amountCents на amount. Файл лишається старим. Якщо контракт змінюється без стратегії, читання падає або починає інтерпретувати дані неправильно.
Помилка № 5: вважати, що «раз JSON текстовий, значить він завжди безпечний».
JSON легше лагодити руками, ніж бінарний формат, але він не захищає вас від логічних помилок: неправильних типів, відсутніх полів, null там, де ви не очікували, або зайвих полів зі старих версій. Текстовий формат полегшує діагностику, але не скасовує перевірок і акуратного дизайну.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ