JavaRush /Курсы /Kotlin SELF /Коллекции и Map в JSON

Коллекции и Map в JSON

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

1. Введение

Когда вы впервые видите, что Json.encodeToString() «как-то сам» делает красивый JSON — появляется чувство, что проблема решена навсегда. Но сериализация — это не только «сохранить в файл». Это ещё и контракт: каким будет формат данных, сможет ли другая программа его понять, сможете ли вы сами прочитать его через месяц и не плакать, и насколько больно будет менять формат позже.

JSON — это язык общения, а не просто «строка для хранения». И у JSON есть свои правила: он умеет массивы [...], объекты {...}, строки, числа, true/false и null. Kotlin, как вы знаете, богаче по типам: у нас есть списки, множества, карты, nullable-типы, data class и многое другое. Поэтому сегодня наша задача: научиться заранее представлять, во что именно превратится Kotlin-коллекция в JSON, и где подстерегают ловушки.

Соответствия: коллекции Kotlin и формы JSON

Прежде чем писать код, полезно выстроить «в голове» таблицу соответствий. Это как переводчик: с Kotlin на JSON.

Kotlin-тип Что это значит «по смыслу» Во что это почти всегда превращается в JSON
List<T>
упорядоченная последовательность, повторы возможны JSON-массив [...]
Set<T>
уникальные элементы, порядок обычно не важен JSON-массив [...] (да-да, тоже массив)
Map<String, V>
ключ → значение, ключи строковые JSON-объект { "key": value }
Map<K, V> где K не String ключ → значение, но ключи «не строковые» чаще всего тоже JSON-объект, но ключи станут строками (и тут начинаются нюансы)

Про Map важно помнить базовую вещь: это коллекция пар key-value, ключи уникальны, значения могут повторяться. Kotlin отдельно подчёркивает, что Map — это особый тип коллекции, который хранит пары и даёт доступ по ключу.

Теперь разберём каждый случай отдельно, короткими и понятными примерами.

3. List и Set в JSON: массивы

List<T> в JSON: массив [...]

Списки — самый дружелюбный тип для JSON. Если у вас есть List<String>, почти любой человек (и любая система) без труда поймёт JSON-массив строк. Список хранит элементы в порядке, индексы начинаются с нуля, доступ по индексу возможен — это важная часть контракта List.

Представим, что в нашем учебном мини-приложении (пусть оно будет «BudgetBuddy», трекер расходов) мы хотим хранить теги расхода: еда, кофе, такси. Теги по смыслу часто удобнее держать списком.

Пример 1: List<String> превращается в JSON-массив

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

@Serializable
data class Tags(val items: List<String>)

fun main() {
    val json = Json { prettyPrint = true }
    println(json.encodeToString(Tags(listOf("kotlin", "json"))))
    // {
    //     "items": [
    //         "kotlin",
    //         "json"
    //     ]
    // }
}

Здесь всё максимально предсказуемо: список стал массивом. И это прям тот случай, когда сериализация работает «как вы и ожидали».

Set<T> в JSON: тоже массив

С Set начинается первый лёгкий когнитивный диссонанс: мы в Kotlin чётко различаем List и Set. Set хранит только уникальные элементы, и это его ключевая идея. Kotlin-документация отдельно подчёркивает: Set хранит уникальные элементы, порядок в общем случае не гарантирован, и даже null может быть только один.

А вот JSON не имеет отдельного типа «множество». Поэтому сериализация обычно делает самое логичное: превращает Set в JSON-массив. То есть по форме JSON будет таким же, как для List.

И вот тут важно: уникальность — это не свойство JSON. Уникальность — это ваша договорённость (и логика Kotlin-кода).

Пример 2: Set<String> тоже становится массивом

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

@Serializable
data class UniqueTags(val items: Set<String>)

fun main() {
    val json = Json { prettyPrint = true }
    println(json.encodeToString(UniqueTags(setOf("food", "coffee"))))
    // {
    //     "items": [
    //         "food",
    //         "coffee"
    //     ]
    // }
}

С виду — обычный массив. Поэтому если вы потом отдадите этот JSON «наружу» (например, другой системе), она не узнает, что это множество, пока вы не скажете об этом словами или документацией.

4. Map<String, V> в JSON: объект {...}

Теперь переходим к картам (Map). Смысл Map — хранить пары «ключ → значение», причём ключи уникальны, а доступ по ключу — основная операция.

JSON-объект {...} по своей природе — это тоже «ключ → значение», где ключи — строки. Поэтому Map<String, V> ложится в JSON почти идеально: получается обычный объект, где имена полей — ключи карты.

Для нашего BudgetBuddy это очень полезно, когда мы хотим хранить, например, сумму расходов по категориям: "food" -> 1200, "taxi" -> 450.

Пример 3: Map<String, Int> превращается в JSON-объект

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

@Serializable
data class Counters(val byName: Map<String, Int>)

fun main() {
    val json = Json { prettyPrint = true }
    val data = Counters(mapOf("apples" to 2, "bananas" to 5))
    println(json.encodeToString(data))
    // {
    //     "byName": {
    //         "apples": 2,
    //         "bananas": 5
    //     }
    // }
}

Это выглядит «человечно», компактно и быстро читается глазами. Именно поэтому Map<String, V> — частый гость в JSON.

5. Нестрочные ключи Map: что будет в JSON

В JSON ключи объекта всегда строки. Даже если они выглядят как числа — всё равно это строки (например, "10"). Поэтому если вы сериализуете Map<Int, Int>, то «по форме JSON» это всё равно будет объект {...}, и его ключи станут строками.

Иногда это выглядит безобидно. А иногда ломает интеграцию, потому что кто-то ожидает «числовые ключи», а их в JSON-объекте просто не бывает.

Пример 4: Map<Int, Int> — ключи станут строками "10", "20"

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

@Serializable
data class IntKeyCounters(val byId: Map<Int, Int>)

fun main() {
    val json = Json { prettyPrint = true }
    val data = IntKeyCounters(mapOf(10 to 3, 20 to 7))
    println(json.encodeToString(data))
    // {
    //     "byId": {
    //         "10": 3,
    //         "20": 7
    //     }
    // }
}

Обратите внимание: в Kotlin ключи были 10 и 20 (числа), а в JSON стали "10" и "20" (строки).

Если вы читаете это обратно в Kotlin тем же Map<Int, Int>, библиотека обычно сможет распарсить строковые ключи в Int. Но вот если этот JSON попал в чужую систему, и там ключ "010" отличается от "10", или ключи не всегда числа — начинаются приключения.

6. Когда Map становится плохим форматом

Map в Kotlin — удобная структура. Но удобство структуры «для кода» и удобство структуры «для формата» — разные вещи. Kotlin прямо показывает, что Map — это набор пар, причём порядок пар не является частью смысла (две карты равны независимо от порядка пар).

А теперь представьте типичные проблемные ситуации:

Вы хотите ключом сделать не строку, а сложный объект (например, data class CategoryKey(val name: String, val year: Int)). В Kotlin это возможно, но в JSON-объект такой ключ не положишь «красиво».

Вы хотите ключом сделать Int, но он имеет важный формат, например "00123" как «код», где ведущие нули значимы. В Kotlin вы бы хранили это строкой, но если вы решили хранить как Int, то при сериализации обратно вы уже не отличите "00123" от "123".

Вы хотите расширять формат. Сегодня у пары «ключ → значение» достаточно одного числа, завтра нужно добавить комментарий, дату, автора. Map начинает сопротивляться, потому что значение становится сложным, и формат иногда становится менее очевидным.

В таких случаях часто лучше сделать формат более явным, даже если он чуть длиннее.

7. Альтернатива: «карта как список записей» List<Entry>

Если JSON важнее «красоты в одну строку», то вместо Map<K, V> часто используют список объектов вида:

[
  {"key": 10, "value": 3},
  {"key": 20, "value": 7}
]

Это длиннее, но зато:

  • ключ остаётся числом как значение, а не прячется в строковом ключе JSON-объекта;
  • формат легче расширять (можно добавить поля);
  • проще валидировать и документировать.

Пример 5: список записей вместо Map<Int, Int>

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

@Serializable
data class IntCounterEntry(val key: Int, val value: Int)

@Serializable
data class IntCounterList(val items: List<IntCounterEntry>)

fun main() {
    val json = Json { prettyPrint = true }
    val data = IntCounterList(listOf(IntCounterEntry(10, 3), IntCounterEntry(20, 7)))
    println(json.encodeToString(data))
}

JSON будет выглядеть примерно так (логически важно, не придираемся к отступам):

{
  "items": [
    { "key": 10, "value": 3 },
    { "key": 20, "value": 7 }
  ]
}

Да, больше текста. Зато формат «говорящий»: тут сразу видно, что key — число, value — число.

8. Пример: отчёт по расходам как коллекции + Map

Чтобы примеры не висели в вакууме, давайте привяжем это к нашему условному BudgetBuddy. Пусть у нас есть список расходов, и мы хотим строить отчёт: общая сумма по категориям.

Список расходов — это List<Expense>, а итоговый отчёт удобно держать в Map<String, Int> (категория → сумма). Сам Map по смыслу тут подходит идеально, потому что категория — строка, и JSON-объект получается естественным.

Пример 6: модель «расходы + суммы по категориям»

import kotlinx.serialization.Serializable

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

@Serializable
data class Report(val expenses: List<Expense>, val totalByCategory: Map<String, Int>)

Здесь мы сознательно выбрали:

  • expenses: List<Expense> — это последовательность записей (естественно для файла).
  • totalByCategory: Map<String, Int> — это сводка, где ключи строковые, значит JSON-объект будет корректным и читаемым.

9. Как из списка сделать Map без «магии»

Раз уж мы заговорили про отчёт, почти сразу возникает практический вопрос: «Окей, а как построить Map<String, Int> из списка расходов?»

Вы могли бы сделать цикл и руками накапливать суммы. Но Kotlin-коллекции дают много инструментов для преобразований. Например, для карт есть удобные операции mapKeys() и mapValues() — когда вы хотите трансформировать ключи или значения.

Мы пока сделаем максимально простой и понятный вариант — через обычный for (так новичкам проще читать), а уже в следующих днях вы будете чаще писать это функционально.

Пример 7: накопление сумм в MutableMap

fun buildTotals(expenses: List<Expense>): Map<String, Int> {
    val totals = mutableMapOf<String, Int>()

    for (e in expenses) {
        val old = totals[e.category] ?: 0
        totals[e.category] = old + e.amount
    }

    return totals
}

Заметьте стиль: totals[e.category] ?: 0 — классический паттерн «если ещё нет значения, считаем, что было 0».

10. Конвертация List<Entry> и Map

Иногда вам по формату выгоднее хранить «список записей», но в коде хочется работать как с Map. Тогда возникает задача преобразования туда-сюда.

Kotlin предлагает несколько способов строить Map из коллекции. Один из них — associate(): вы возвращаете Pair(key, value) для каждого элемента, и Kotlin собирает из этого карту. Документация описывает associate() и подчёркивает, что он строит Map из Pair.

Пример 8: список записей → Map

fun asMap(items: List<IntCounterEntry>): Map<Int, Int> {
    return items.associate { it.key to it.value }
}

Пример 9: Map → список записей

fun asList(map: Map<Int, Int>): List<IntCounterEntry> {
    return map.map { (k, v) -> IntCounterEntry(k, v) }
}

Тут важно понимать: Map — это не просто «список пар», у него свои свойства (уникальность ключа и доступ по ключу). Поэтому вы выбираете структуру в зависимости от того, что важнее: удобство работы в коде или ясность/совместимость формата.

11. Как выбрать: Map или List<Entry>

Когда вы проектируете JSON, удобно держать маленькое правило выбора. Не как «абсолютную истину», а как подсказку для здравого смысла.

flowchart TD
    A["Нужно сохранить соответствие key → value"] --> B{Ключи — строки?}
    B -- "Да" --> C["Можно Map⟨String, V⟩ → JSON object"]
    B -- "Нет" --> D{Ключи точно числа и это всех устраивает?}
    D -- "Да" --> E["Map⟨Int, V⟩ возможен, но ключи станут строками"]
    D -- "Нет / сомневаюсь" --> F["Лучше List⟨Entry⟩ с полями key/value"]

И да: иногда правильный ответ — «сомневаюсь». В программировании это вполне легальная позиция, особенно до первой интеграции с чужим сервисом.

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

Ошибка №1: ожидать, что Set сохранится как «множество» в JSON.
Очень хочется думать, что раз в Kotlin это Set, то в JSON тоже будет что-то «особенное». Но JSON — простой формат: там нет отдельного типа «множество», поэтому Set превращается в массив, как и List. Уникальность элементов — это ваша логика, а не свойство формата. Если это критично, вы должны явно следить за уникальностью при добавлении и при чтении данных.

Ошибка №2: использовать Map<Int, ...> и забыть, что ключи в JSON будут строками.
Проблема не в том, что сериализация «плохая» — она делает единственно возможную вещь, потому что ключи JSON-объекта строковые. Проблема в ожиданиях: сегодня вы смотрите на JSON и видите "10": 3, а завтра кто-то другой (или вы сами) начинает воспринимать это как строковый ключ, где возможны ведущие нули, пробелы, особые форматы. Если ключи реально «числовые по смыслу» и формат важен, часто проще перейти на список записей.

Ошибка №3: делать Map со «сложным ключом» и надеяться на «красивый JSON».
В Kotlin ключом карты может быть почти что угодно, но JSON-объект так не умеет: ключи должны быть строками. Поэтому ключи-объекты почти всегда приводят к неудобному или неожиданному формату. В таких случаях лучше сделать явную модель: List<Entry>, где ключ — отдельное поле, и он может быть сложным объектом (потому что значение в JSON может быть объектом, а ключ — нет).

Ошибка №4: выбирать структуру «потому что красивее выглядит», а не потому что её можно однозначно прочитать обратно.
Это очень частая ловушка новичка: «Вот Map выглядит компактно, значит берём его». А потом внезапно оказывается, что чтение обратно требует кучи оговорок: какие ключи допустимы, можно ли их парсить как числа, что делать с пустыми строками и так далее. Хороший критерий — задавать себе вопрос: «Смогу ли я через полгода без подсказок написать decode и не ошибиться?» Если сомневаетесь, делайте формат более явным.

Ошибка №5: смешивать «удобный формат хранения» и «удобный формат работы в коде» в одну кучу.
В коде часто удобно иметь Map (быстрый доступ по ключу), а в файле удобно иметь List (явный порядок, расширяемость, типизированные поля). Это нормально — держать преобразование «в файл» и «из файла» как отдельный шаг. Kotlin-коллекции как раз и хороши тем, что между List и Map обычно можно конвертировать в пару строк через associate() и map { ... }.

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