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 { ... }.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ