JavaRush /Курсы /Kotlin SELF /Аннотации @SerialName

Аннотации @SerialName и @Transient

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

1. Почему имена и поля в JSON — это почти публичный API

Если вы только начали программировать, легко думать так: «Ну модель же моя, файл мой, завтра поменяю поле — и всё». Но как только вы сохраняете JSON на диск, отправляете его по сети или просто храните между запусками программы, формат превращается в контракт. И вот тут начинаются приключения: вы переименовали свойство в Kotlin ради красоты, а старые файлы внезапно перестали читаться.

Представьте, что JSON — это «анкета», которую заполняют ваши прошлые версии программы. Если вы завтра поменяли в анкете вопрос "name" на "fullName", то старые анкеты не станут волшебным образом другими. Нужен механизм совместимости. Именно для этого и существуют @SerialName и @Transient.

Наша практическая цель на лекцию: научиться менять Kotlin‑модель (читаемость, стиль, рефакторинг), не ломая формат, и наоборот — подстраивать формат под внешние требования, не превращая Kotlin‑код в набор странных snake_case‑имён.

2. @SerialName: одно имя в Kotlin, другое в JSON

Аннотация @SerialName("...") — это способ сказать сериализатору: «В коде свойство называется вот так, но в JSON я хочу видеть другое имя». Звучит почти скучно, но это как раз тот случай, когда скука = надёжность. Вы отделяете внутреннюю читаемость кода от внешнего контракта формата.

Важно понимать: @SerialName — это не про «сделать красивее», а про «сделать устойчивее». У устойчивого формата есть один прекрасный бонус: вы можете рефакторить Kotlin‑код, не боясь, что пользователи (или вы сами через месяц) откроют программу и увидят «не могу прочитать файл, всё пропало».

Мини‑схема, что происходит:

flowchart LR
    A[Kotlin-свойство: note] -->|"@SerialName('title')"| B[JSON-поле: 'title']
    C["JSON-поле: 'title'"] -->|decode| D[Kotlin-свойство: note]

То есть @SerialName — это двусторонняя договорённость: так поле называется и при записи, и при чтении.

Практика: переименовали поле и не сломали старые JSON

Представим, что мы развиваем наше консольное приложение учёта расходов BudgetBuddy. На прошлом дне у нас уже была сериализуемая модель, которую мы сохраняем в JSON.

Пусть раньше расход выглядел так:

import kotlinx.serialization.Serializable

@Serializable
data class ExpenseV1(
    val title: String,
    val amount: Int
)

В какой-то момент вы решаете, что слово title не отражает смысл (это скорее «заметка/описание»), и переименовываете поле в note:

import kotlinx.serialization.Serializable

@Serializable
data class ExpenseV2(
    val note: String,
    val amount: Int
)

Теперь представим старый JSON (он уже лежит на диске):

val oldJson = """{"title":"Coffee","amount":250}"""

Если вы попробуете прочитать его как ExpenseV2, сериализатор честно скажет: «Я не знаю, что такое "title", и не вижу обязательного note». Обычно это заканчивается исключением.

Исправление как раз делается через @SerialName:

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ExpenseV2(
    @SerialName("title") val note: String,
    val amount: Int
)

Теперь старый JSON продолжит читаться, но в Kotlin‑коде у вас будет нормальное имя note.

Мини‑проверка: прочитали старое и записали новое в старом формате

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

@Serializable
data class Expense(
    @SerialName("title") val note: String,
    val amount: Int
)

fun main() {
    val json = Json { prettyPrint = true }
    val e = json.decodeFromString<Expense>("""{"title":"Coffee","amount":250}""")
    println(e)                       // Expense(note=Coffee, amount=250)
    println(json.encodeToString(e))  // {"title":"Coffee","amount":250}
}

Обратите внимание на последний вывод: мы и читаем, и пишем поле как "title", потому что это наш контракт формата. При этом в коде мы живём с note.

@SerialName для совместимости с внешним JSON

Переименование ради рефакторинга — не единственная причина использовать @SerialName. Вторая частая причина: вам приходит JSON из внешнего мира, где принято snake_case, а в Kotlin — camelCase.

Допустим, внешний формат требует:

{
  "user_id": 7,
  "full_name": "Ada Lovelace"
}

В Kotlin мы хотим нормальную модель:

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class UserDto(
    @SerialName("user_id") val userId: Int,
    @SerialName("full_name") val fullName: String
)

И это ровно тот момент, когда @SerialName делает код читаемым и совместимым одновременно. Без него вы либо держите свойства user_id (что плохо в Kotlin‑стиле), либо пишете ручной разбор JSON (что рано и обычно больно).

Маленькая таблица: что когда выбирать

Ситуация Что хочется в Kotlin Что нужно в JSON Решение
Рефакторинг: переименовали titlenote note старое имя "title" @SerialName("title")
Внешний формат в snake_case createdAt, userId "created_at", "user_id" @SerialName("created_at"), @SerialName("user_id")
Вы придумали формат сами и он нигде не жил любые любые можно без @SerialName, но лучше заранее договориться о стиле

3. @Transient: поле нужно объекту, но не нужно формату

Если @SerialName управляет именем в формате, то @Transient управляет самим фактом присутствия свойства в формате. То есть свойство существует в Kotlin‑объекте, но не сериализуется и не десериализуется.

Тут очень важный смысл: @Transient — это про границу между «данные» и «удобства». Например, вы хотите хранить внутри объекта кэш, отладочную пометку, временное значение для UI или любое поле, которое не должно «утекать» в файл. В таком случае @Transient — ваш предохранитель.

Ещё одна важная деталь: для @Transient свойства почти всегда нужно значение по умолчанию, иначе при чтении JSON объект будет невозможно создать (ведь сериализатор не будет брать значение из JSON). В таком случае корректнее либо дать дефолт, либо пересмотреть дизайн.

Кстати, в экосистеме Kotlin существует несколько «Transient», и можно случайно импортировать не то. Даже компилятор/инструменты подсвечивают такие ситуации как частую проблему.

Пример: отладочное поле, которое не должно попадать в сохранение

Вернёмся к BudgetBuddy. Допустим, в какой-то момент вы хотите добавить в расход поле debugNote, чтобы понимать, откуда он взялся: введён руками, импортирован, восстановлен из файла. Это полезно в отладке, но это точно не то, что должно жить в JSON‑хранилище вечно.

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient

@Serializable
data class Expense(
    val note: String,
    val amount: Int,
    @Transient val debugNote: String = ""
)

Теперь, даже если вы создадите объект с debugNote, в JSON он не попадёт.

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

@Serializable
data class Expense(
    val note: String,
    val amount: Int,
    @Transient val debugNote: String = ""
)

fun main() {
    val json = Json { prettyPrint = true }
    val e = Expense(note = "Coffee", amount = 250, debugNote = "typed by user")
    println(json.encodeToString(e))
    // {
    //     "note": "Coffee",
    //     "amount": 250
    // }
}

И да, если вы сейчас подумали «а можно просто сделать поле private?» — можно, но это другая история. private управляет доступом из кода, а @Transient управляет контрактом формата. Это две разные плоскости.

Чтение JSON: почему нужен дефолт и где можно упасть

Когда вы читаете JSON, сериализатор создаёт объект и должен заполнить все свойства, которые входят в сериализацию. Но @Transient в неё не входит, значит значение берётся только из дефолта.

Если дефолта нет, объект «не собирается». В таких ситуациях правильнее падать быстро и понятно. В Kotlin это часто делается через require, check, error — стандартные функции предусловий, которые выбрасывают исключения при нарушении условий.

Мини‑пример: плохой transient без дефолта

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient

@Serializable
data class Session(
    val token: String,
    @Transient val cachedLabel: String // нет дефолта — проблема
)

С высокой вероятностью вы получите ошибку при попытке decodeFromString<Session>(...).

Ещё одна тонкость: что если поле есть в JSON

Представим, что в старом JSON почему-то было поле "debugNote", а теперь вы сделали его @Transient. Для сериализатора это поле становится «неизвестным». Если у вас Json настроен как Json { ignoreUnknownKeys = false } (по умолчанию так и есть), чтение может упасть из-за лишнего поля.

То есть @Transient — это не «магически игнорируй поле», а именно «это поле не часть схемы». Игнорирование неизвестных полей — отдельная настройка Json, с которой мы уже знакомы по прошлым лекциям.

Аккуратный стиль: разделяем хранение и удобство

Иногда студентам хочется сложить в одну @Serializable модель вообще всё: и то, что надо хранить, и то, что удобно для вычислений, и кэш, и какие-то временные флаги. Это работает… до первого серьёзного изменения.

Более спокойный стиль — помнить, что сериализуемая модель похожа на «контракт данных», и она должна быть довольно скучной: простые поля, понятные имена, минимум сюрпризов. А «удобные штуки» либо делаются transient‑полями с дефолтом, либо вычисляются отдельными функциями, либо вообще живут отдельно.

Пока мы не уходим в большую архитектуру (это будет отдельный большой разговор позже), нам достаточно простого правила: если поле не обязано переживать сохранение/загрузку, то либо делайте его @Transient, либо не делайте его частью @Serializable‑модели.

4. Типичные ошибки

Ошибка №1: переименовали свойство в Kotlin и забыли, что JSON‑файлы уже существуют.
Самая обидная ошибка — та, которая появляется не сразу. Вы поменяли title на note, всё прекрасно компилируется, новые файлы сохраняются, но старые больше не читаются. Лечится это дисциплиной: если формат уже «в мире», стабилизируйте имя через @SerialName, а Kotlin‑имя меняйте как хотите.

Ошибка №2: использовать @SerialName как «способ сделать красиво», а не как контракт.
Когда @SerialName ставят «на всякий случай» на каждое поле, формат превращается в набор случайных строк, а переезды становятся тяжелее. Смысл аннотации в том, чтобы закреплять важные места: совместимость со старыми файлами, соответствие внешнему API, единый стиль именования (например, snake_case).

Ошибка №3: поставить @Transient, но не дать значение по умолчанию.
@Transient означает, что поле не будет читаться из JSON. Значит, при decode оно должно откуда-то взяться — почти всегда из дефолта. Если дефолта нет, объект не получится создать. И это не «придирка», а логика конструктора: без значения поле не инициализировано.

Ошибка №4: импортировать «не тот Transient».
В Kotlin/JVM можно встретить другие Transient‑аннотации, и новички иногда импортируют их автоматически через IDE. В итоге поле не исключается из сериализации или ведёт себя не так, как ожидалось. Поэтому полезно глазами проверять импорт: вам нужен именно kotlinx.serialization.Transient. На практике это настолько частая история, что даже инструменты/инспекции отдельно на неё ругаются.

Ошибка №5: считать, что @Transient автоматически «съест» поле из входного JSON.
Если поле есть в JSON, а в модели его нет (или оно @Transient), для сериализатора это unknown key. И если вы не включили ignoreUnknownKeys, чтение может упасть. То есть @Transient — про исключение из схемы, а терпимость к мусору/лишним полям — это настройка Json.

Ошибка №6: хранить в сериализуемой модели то, что не должно жить в файле.
Это уже не столько про компиляцию, сколько про здравый смысл. Отладочные заметки, временные флаги, кеши, производные поля — всё это лучше либо вычислять на лету, либо помечать @Transient с безопасным дефолтом. Иначе вы сами себе создадите «исторический архив мусора», который потом придётся поддерживать ради совместимости.

1
Задача
Kotlin SELF, 48 уровень, 1 лекция
Недоступна
Профиль из API
Профиль из API
1
Задача
Kotlin SELF, 48 уровень, 1 лекция
Недоступна
Переименование заметки
Переименование заметки
1
Задача
Kotlin SELF, 48 уровень, 1 лекция
Недоступна
Служебная пометка
Служебная пометка
1
Задача
Kotlin SELF, 48 уровень, 1 лекция
Недоступна
Лишнее поле в истории
Лишнее поле в истории
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ