JavaRush /Курси /Kotlin SELF /Циклічні посилання

Циклічні посилання

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

1. Вступ

Коли ви переходите від простих структур (числа, рядки, списки) до «справжніх» об’єктів, ви починаєте пов’язувати дані між собою. Це природно: у витрати є категорія, у категорії — список витрат, у користувача — менеджер, а в менеджера… теж менеджер (інколи це та сама людина, але про це HR краще не розповідати).

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

Проблема з’являється не тоді, коли ви створили посилання, а тоді, коли ви захотіли цей стан серіалізувати — перетворити на текст (JSON) так, щоб потім його можна було відновити.

2. Чому JSON «не про цикли»: дерево vs граф

Якщо пояснювати без академічного пафосу, JSON за своєю природою — дерево. У дереві в кожного вузла є «діти» (вкладені об’єкти/масиви). Якщо ви весь час рухаєтеся «вниз», то гарантовано колись упретеся в листок (рядок/число/булеве/null) — і на цьому завершите.

Циклічні посилання — це вже граф. У графі можна ходити за стрілочками нескінченно: вийшли з A, прийшли в B, потім у C, а потім знову в A — і привіт, нескінченність.

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

flowchart TD
    subgraph JSON_як_дерево
      A1["Root"] --> B1["Child 1"]
      A1 --> C1["Child 2"]
      B1 --> D1["leaf: 123"]
      C1 --> E1["leaf: \"text\""]
    end

    subgraph Обєкти_як_граф_із_циклом
      A2["A"] --> B2["B"]
      B2 --> C2["C"]
      C2 --> A2
    end

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

3. Мініприклад циклу в Kotlin: «Аліса керує Бобом, Боб керує Алісою»

Спочатку зробімо зовсім маленьку модель, щоб було очевидно: цикл — це не щось рідкісне чи містичне, а буквально два посилання.

class User(val id: Int, val name: String) {
    var manager: User? = null
}

fun main() {
    val alice = User(1, "Alice")
    val bob = User(2, "Bob")

    alice.manager = bob
    bob.manager = alice // цикл

    println(alice.manager?.name) // Bob
    println(bob.manager?.name)   // Alice
}

У пам’яті це працює чудово: Kotlin-об’єкти спокійно тримають посилання один на одного, а GC (збирач сміття JVM) від цього в непритомність не падає. І тут з’являється класична думка: «Раз це працює, давайте додамо @Serializable і збережемо все в JSON».

4. Що буде за наївної серіалізації

Коли серіалізатор перетворює об’єкт у JSON, він діє приблизно так: записав поля об’єкта → якщо поле є об’єктом, серіалізуємо його → всередині нього знову поля → і так далі. Часто це реалізують рекурсією.

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

Псевдоприклад (зверніть увагу: так робити не треба, це лише демонстрація проблеми):

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class UserDto(
    val id: Int,
    val name: String,
    val manager: UserDto? = null,
)

fun main() {
    val alice = UserDto(1, "Alice")
    // У реальності тут незручно створити цикл, бо data class immutable,
    // але сенс проблеми саме в циклі посилань.
    println(Json.encodeToString(alice))
}

І тут важливий практичний висновок: неможливо «магічно» зберегти граф із циклами в чистий JSON як вкладеність. Потрібно змінити подання даних для зберігання.

5. Чому це не «огріх бібліотеки»: обмеження формату, а не Kotlin

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

Коли ви пишете Kotlin-код, два поля можуть посилатися на один і той самий об’єкт. А в JSON ви записуєте значення, тобто буквально друкуєте текст. У тексті немає поняття «це той самий об’єкт, що був 20 рядків тому».

Якби серіалізатор спробував розв’язати це сам, йому довелося б:

  1. вводити в JSON службові ідентифікатори об’єктів,
  2. вводити посилання виду {"$ref": "id-123"},
  3. домовитися про формат цих посилань,
  4. вміти відновлювати граф під час читання.

Тобто фактично вигадати новий формат поверх JSON. Іноді так роблять у складних системах, але в навчальному курсі ми обираємо чесніший і практичніший шлях: проєктуємо модель зберігання самі.

6. Стратегія «id замість посилання»: головний трюк проти циклів

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

Тобто не manager: User, а managerId: Int?.

Не category: Category, а categoryId: Int.

А самі об’єкти (умовну «таблицю») зберігаємо окремо: списком або Map за id. По суті, ми робимо мініверсію того, як улаштовані зв’язки в базі даних: дані — окремо, посилання — окремо.

І це чудово лягає на наш навчальний консольний проєкт (умовно назвімо його BudgetBuddy): ми зберігаємо витрати й категорії, друкуємо звіти, а тепер хочемо записувати все в JSON так, щоб воно безболісно читалося назад.

7. Цикл у «витрати ↔ категорії»: чому він з’являється

Уявімо, що ми хочемо «гарну» модель у пам’яті.

Категорія знає свої витрати — так зручно будувати звіти на кшталт «покажи всі витрати за категорією». Витрата знає категорію — так зручно друкувати рядок «2026‑01‑14: кава, категорія Їжа».

Виходить двобічний зв’язок:

flowchart LR
    C["Category"] -->|expenses| E["Expense"]
    E -->|category| C

Код (це демонстрація того, як виникає цикл):

class Category(val id: Int, val title: String) {
    val expenses: MutableList<Expense> = mutableListOf()
}

class Expense(val id: Int, val title: String, val amountCents: Long) {
    lateinit var category: Category
}

Усередині застосунку це зручно, а от для серіалізації — пастка: якщо ми спробуємо записати Category, серіалізатор піде в expenses. Там — Expense. Усередині нього — category. А в category знову expenses… і так по колу.

8. «Модель для зберігання» і «модель для роботи»

Зараз нам не потрібна складна архітектура. Достатньо одного простого правила: те, з чим зручно працювати в пам’яті, не зобов’язане бути тим, що зручно зберігати в JSON.

Зробімо дві моделі:

  • Category і Expense — для роботи в застосунку (можна з посиланнями).
  • CategoryStored і ExpenseStored — для JSON: лише примітиви та id-посилання.

Мінімальні «stored» моделі:

import kotlinx.serialization.Serializable

@Serializable
data class CategoryStored(
    val id: Int,
    val title: String,
)

@Serializable
data class ExpenseStored(
    val id: Int,
    val title: String,
    val amountCents: Long,
    val categoryId: Int,
)

Зверніть увагу: тут немає жодної вкладеності об’єктів — лише числа й рядки. JSON від такого буде спокійний, як кіт після обіду.

9. Контейнер для JSON: «знімок» стану застосунку

Якщо ви зберігаєте «таблицю категорій» і «таблицю витрат», зручно складати їх в один кореневий об’єкт. Саме він і зберігатиметься у файлі.

import kotlinx.serialization.Serializable

@Serializable
data class BudgetDump(
    val categories: List<CategoryStored>,
    val expenses: List<ExpenseStored>,
)

Чому це зручно:

  • у JSON є один корінь (як зазвичай і очікується),
  • нам одразу видно, які колекції ми зберігаємо,
  • пізніше простіше додавати нові розділи (наприклад, налаштування застосунку), не ламаючи формат одразу.

10. Приклад серіалізації без циклів

Тепер — код, який робить «знімок» і серіалізує його.

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

fun main() {
    val dump = BudgetDump(
        categories = listOf(CategoryStored(1, "Food")),
        expenses = listOf(ExpenseStored(10, "Coffee", 350, categoryId = 1)),
    )

    val json = Json { prettyPrint = true }
    println(json.encodeToString(dump))
    // Друкує JSON з categories і expenses, без рекурсії
}

І це ключовий момент: ми не «лікуємо» серіалізатор. Ми робимо дані серіалізовними.

11. Як відновити зв’язки під час читання: збираємо граф назад

Зберегти «плоско» — це половина справи. Друга половина — після decodeFromString повернути зручні посилання в пам’яті.

Ідея збірки проста:

  1. прочитали BudgetDump,
  2. створили категорії й поклали в Map<Int, Category> за id,
  3. створили витрати, для кожної знайшли категорію за categoryId,
  4. проставили посилання та заповнили списки витрат у категорії.

Скелет коду (не надто великий, але показовий):

fun buildState(dump: BudgetDump): List<Category> {
    val categoriesById = dump.categories.associate { it.id to Category(it.id, it.title) }

    for (e in dump.expenses) {
        val category = categoriesById.getValue(e.categoryId)
        val expense = Expense(e.id, e.title, e.amountCents).also { it.category = category }
        category.expenses.add(expense)
    }

    return categoriesById.values.toList()
}

Тут ми використовуємо getValue(...), тому що в коректному дампі категорія зобов’язана існувати. Якщо її немає, значить дамп пошкоджений або застарілий. Це вже окрема тема оброблення помилок (сьогодні не заглиблюємося), але важливо розуміти, де саме програма «може впасти».

12. Корисні нюанси

«Стабільний id» — це ваш мініконтракт даних

Щоб стратегія «id замість посилання» працювала, ідентифікатори мають бути стабільними й унікальними.

Тобто якщо ви одного разу зберегли категорію Food з id = 1, а потім під час наступного запуску знову створили категорію Food з id = 1 — усе гаразд. Але якщо ви випадково присвоїте id = 1 іншій категорії, витрати «переїдуть» у чужу категорію — і вийде бухгалтерський жах.

У навчальних застосунках часто роблять простий генератор id: «максимальний id + 1». Для великих систем це не ідеально, але для консольного проєкту — цілком нормальна дисципліна.

Альтернатива двобічним посиланням: односпрямована модель

Іноді простіше не збирати «повний граф», а залишити модель односпрямованою. Наприклад, Expense зберігає categoryId, а Category не зберігає список витрат. Коли потрібно показати витрати категорії — ви просто фільтруєте список витрат.

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

Невеличка ілюстрація на колекціях:

fun expensesForCategory(expenses: List<ExpenseStored>, categoryId: Int): List<ExpenseStored> {
    return expenses.filter { it.categoryId == categoryId }
}

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

Де тут @Transient: прибираємо «кеш» із JSON

Іноді хочеться зберігати в об’єкті «і посилання, і id, і список витрат, і ще прапорець “вже пораховано”». Але формат JSON має залишатися чистим.

Якщо у вас є обчислюване або кешоване поле, яке не повинно потрапляти в JSON, ви вже знаєте інструмент: @Transient (із дефолтним значенням). Ми не будемо заглиблюватися (це тема попередньої лекції), але важливу думку зафіксуємо: у JSON потрапляє контракт, а не ваша внутрішня зручність.

Пам’ятка-діаграма: «id замість посилання»

flowchart TD
    subgraph InMemory["В пам’яті (зручно працювати)"]
        C1["Category(id=1)"] --> E1["Expense(id=10)"]
        E1 --> C1
    end

    subgraph Stored["У JSON (зручно зберігати)"]
        CS["CategoryStored(id=1)"]
        ES["ExpenseStored(id=10, categoryId=1)"]
    end

    Stored -->|"buildState()"| InMemory

Збереження йде з пам’яті в Stored (зазвичай через мапінг/відображення), а завантаження — назад через збірку.

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

Помилка № 1: намагатися «просто серіалізувати об’єкт із посиланнями» й чекати, що бібліотека здогадається.
Майже завжди це закінчується нескінченною рекурсією: серіалізатор чесно йде по полях, а цикл не дає йому зупинитися. У таких ситуаціях потрібно не «ламати JSON», а змінювати подання даних: зберігати id замість вкладеного об’єкта.

Помилка № 2: тримати одночасно і manager: User, і managerId: Int, але без чіткого правила «хто головний».
Так легко отримати розсинхронізацію: managerId = 2, а manager чомусь указує на користувача 3. Якщо вже ви використовуєте обидва поля (наприклад, одне для зберігання, інше для зручності), має бути зрозуміле правило: що серіалізується, як збирається, коли оновлюється.

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

Помилка № 4: серіалізувати внутрішні кеші та похідні дані.
Наприклад, зберігати в категорії список витрат, хоча його можна відновити з витрат за categoryId. У JSON це дає дублювання й ризик розходження. Зазвичай краще зберігати первинні дані, а похідні відновлювати під час завантаження. Тимчасові поля — прибирати з формату.

Помилка № 5: не продумати, що робити з “немає зв’язку”.
Якщо зв’язок може бути відсутнім (наприклад, у користувача немає менеджера), це має бути явно виражено в моделі зберігання: managerId: Int?. Спроба «запхати» відсутність зв’язку в 0 або -1 часто призводить до дивних крайових випадків і зайвих перевірок.

Помилка № 6: намагатися лікувати цикли кастомним серіалізатором, не змінюючи модель.
Кастомний серіалізатор доречний, коли формат значення має бути нестандартним (наприклад, Money як рядок). Але цикли — це проблема структури даних: граф проти дерева. Зазвичай її розв’язують проєктуванням моделі зберігання, а не хитрими трюками на рівні KSerializer<T>.

1
Опитування
Серіалізація, рівень 48, лекція 4
Недоступний
Серіалізація
Серіалізація
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ