1. Вступ
Коли ви вперше бачите, що Json.encodeToString() ніби «сам по собі» робить гарний JSON, легко подумати, що проблему розвʼязано назавжди. Та серіалізація — це не лише «зберегти у файл». Це ще й контракт: яким буде формат даних, чи зможе інша програма його зрозуміти, чи зможете ви самі прочитати його за місяць і не скривитися, і наскільки боляче буде змінювати формат пізніше.
JSON — це мова спілкування, а не просто «рядок для зберігання». І в JSON є свої правила: він підтримує масиви [...], обʼєкти {...}, рядки, числа, true/false і null. Kotlin, як ви знаєте, багатший на типи: ми маємо списки, множини, мапи, nullable-типи, data class і багато іншого. Тож сьогодні наше завдання — навчитися заздалегідь уявляти, на що саме перетвориться Kotlin-колекція в JSON, і де чатують пастки.
Відповідності: колекції Kotlin і форми JSON
Перш ніж писати код, корисно вибудувати «в голові» таблицю відповідностей. Це як перекладач: з Kotlin на JSON.
| Kotlin-тип | Що це означає «за змістом» | На що це майже завжди перетворюється в JSON |
|---|---|---|
|
упорядкована послідовність, повтори можливі | JSON-масив [...] |
|
унікальні елементи, порядок зазвичай не важливий | JSON-масив [...] (так-так, теж масив) |
|
ключ → значення, ключі рядкові | 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 { ... }.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ