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
Задача
Kotlin SELF, 48 уровень, 4 лекция
Недоступна
Пользователь без вложений
Пользователь без вложений
1
Задача
Kotlin SELF, 48 уровень, 4 лекция
Недоступна
Плоское хранение
Плоское хранение
1
Задача
Kotlin SELF, 48 уровень, 4 лекция
Недоступна
Дамп бюджета
Дамп бюджета
1
Задача
Kotlin SELF, 48 уровень, 4 лекция
Недоступна
Сборка состояния
Сборка состояния
1
Опрос
Сериализация, 48 уровень, 4 лекция
Недоступен
Сериализация
Сериализация
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ