JavaRush /Курсы /Kotlin SELF /Знакомство с сериализацией

Знакомство с сериализацией

Kotlin SELF
47 уровень , 0 лекция
Открыта

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 там, где вы не ожидали, или лишних полей из старых версий. Текстовый формат облегчает диагностику, но не отменяет проверок и аккуратного дизайна.

1
Задача
Kotlin SELF, 47 уровень, 0 лекция
Недоступна
Контракт расхода
Контракт расхода
1
Задача
Kotlin SELF, 47 уровень, 0 лекция
Недоступна
Опасный JSON
Опасный JSON
1
Задача
Kotlin SELF, 47 уровень, 0 лекция
Недоступна
Спасение кавычек
Спасение кавычек
1
Задача
Kotlin SELF, 47 уровень, 0 лекция
Недоступна
Архив расходов
Архив расходов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ