1. Чому варіанти краще не зберігати рядками та прапорцями
Коли застосунок тільки народжується, здається, що найпростіше зберігати стан «якось»: рядком "ok"/"error", числом 0/1, двома прапорцями isOk і hasError, а іноді ще й null — «про всяк випадок». На невеликому коді це справді може працювати — рівно до першого «ой, а що як…».
Проблема в тому, що такий підхід не дає компілятору допомагати вам. У рядку легко припуститися друкарської помилки, прапорці можна виставити в суперечливий стан, а null може раптово «прилетіти» туди, де на нього не чекають. У підсумку ви самі собі влаштовуєте квест: «вгадай, які значення взагалі можливі».
Уявімо мініприклад результату команди в консольному застосунку:
fun render(status: String, message: String): String {
return if (status == "ok") "OK: $message" else "ERROR: $message"
}
Запитання, які тут виникають (і вони не філософські — вони практичні):
Що, якщо status = "OK"? А "success"? А порожній рядок? А "ok " із пробілом? Хто гарантує, що інших варіантів не існує?
І ось тут Kotlin пропонує інструмент, який буквально каже: «давайте перелічимо варіанти так, щоб компілятор міг вас підстрахувати». Це і є sealed interface.
2. Що дає sealed interface
Слово sealed часто перекладають як «запечатаний». Ідея проста: ми оголошуємо інтерфейс (контракт), але водночас кажемо компілятору: реалізацій буде обмежена кількість, і Kotlin може враховувати це під час аналізу коду.
Найприємніший наслідок — це when. Ми вже знаємо, що when буває як statement (просто виконуємо гілки), а буває як expression (повертаємо значення). Якщо ж when використовується як вираз, Kotlin вимагає покрити всі випадки — інакше це вже не вираз, а «лотерея».
Тобто sealed interface дає нам одразу дві речі:
- перша — типобезпечність (варіанти стають типами),
- друга — підтримка компілятора («ви забули обробити варіант — я не зберу проєкт»).
Важливо тримати в голові простий образ: sealed interface — це «родина варіантів», а конкретні реалізації — «діти в цій родині». Щойно зʼявляється нова «дитина», компілятор змусить вас оновити всі місця, де ви розбираєте цю родину через when.
3. sealed interface Command: модель команд
Продовжмо розвивати навчальний консольний застосунок (умовно назвемо його ExpenseTracker). Раніше ми вже працювали зі списками/колекціями, рядками, when, класами та data class. Тепер хочемо, щоб користувач вводив команди: add, list, help, exit.
Якщо зберігати команду рядком, ми повертаємося до проблеми «а що як рядок дивний». Тож зробимо тип Command як sealed interface і перелічимо варіанти.
sealed interface Command {
data class Add(val amount: Int, val category: String) : Command
class List : Command
class Help : Command
class Exit : Command
data class Unknown(val raw: String) : Command
}
Зверніть увагу на кілька деталей.
По-перше, Add — це data class, тому що в команди є дані: сума й категорія.
По-друге, List/Help/Exit ми зробили звичайними класами без полів. Так, у них будуть різні екземпляри. Це нормально: сенс команди задається типом, а не тим, скільки обʼєктів ми створили.
По-третє, Unknown — дуже корисний варіант. Новачки часто намагаються зробити «закритий набір» і забувають, що введення користувача може бути будь-яким. У результаті вони або «падають», або пишуть else і втрачають сенс sealed. Варіант Unknown — чесний спосіб сказати: «ми обробили все — навіть сміття».
4. Розбираємо введення в Command
Сприймайте парсинг як контракт: «на вході рядок, на виході — один із варіантів Command». Не String?, не Pair<Boolean, String>, а нормальний тип.
Базовий парсер
fun parseCommand(raw: String): Command {
val parts = raw.trim().split(" ")
val name = parts.firstOrNull()?.lowercase() ?: return Command.Unknown(raw)
return when (name) {
"add" -> Command.Unknown(raw) // пізніше допарсимо аргументи
"list" -> Command.List()
"help" -> Command.Help()
"exit" -> Command.Exit()
else -> Command.Unknown(raw)
}
}
Тут ми поки свідомо «недоопрацювали» add: допарсити аргументи можна, але зараз важливіше побачити загальний принцип — команда стає типом.
Зверніть увагу: тут when — вираз (ми повертаємо результат), тож маємо повернути щось у всіх гілках. Але when поки працює за рядком, а рядок — це безмежний космос варіантів. Тому else тут доречний і чесний.
Парсинг аргументів для add
Щоб приклад став реалістичнішим, поліпшимо парсинг add. Нехай команда буде такою:
add 120 food
Тобто parts[1] — сума, parts[2] — категорія. Безпечний парсинг — це toIntOrNull() і перевірки.
fun parseCommand(raw: String): Command {
val parts = raw.trim().split(" ").filter { it.isNotBlank() }
val name = parts.firstOrNull()?.lowercase() ?: return Command.Unknown(raw)
return when (name) {
"add" -> {
val amount = parts.getOrNull(1)?.toIntOrNull()
val category = parts.getOrNull(2)
if (amount != null && category != null) Command.Add(amount, category)
else Command.Unknown(raw)
}
"list" -> Command.List()
"help" -> Command.Help()
"exit" -> Command.Exit()
else -> Command.Unknown(raw)
}
}
Так, ми все ще повертаємо Unknown. Це не «погана команда», а нормальний варіант результату парсингу. Ми не вдаємо, що введення завжди ідеальне.
5. Вичерпний when за командами
Найцікавіше починається, коли ми обробляємо команду. Ми хочемо написати логіку «залежно від типу команди». Це якраз той випадок, коли when за sealed-типом може бути вичерпним.
Додамо просту модель витрати (у вашому проєкті вона може бути багатшою, але нам зараз важлива сама ідея):
data class Expense(val amount: Int, val category: String)
Тепер зробимо обробник команд:
fun handleCommand(cmd: Command, expenses: MutableList<Expense>): String =
when (cmd) {
is Command.Add -> {
expenses.add(Expense(cmd.amount, cmd.category))
"Додано: ${cmd.amount} у категорії ${cmd.category}"
}
is Command.List -> "Витрат: ${expenses.size}"
is Command.Help -> "Команди: add/list/help/exit"
is Command.Exit -> "Вихід..."
is Command.Unknown -> "Невідома команда: ${cmd.raw}"
}
Ось важливий момент: else не потрібен. Чому? Тому що Command — це sealed interface, і компілятор знає всі варіанти. Якщо ви забудете, наприклад, Command.Help, Kotlin почне лаятися, коли побачить, що when використовується як вираз і не покриває всі гілки.
І ще один приємний бонус: усередині гілки is Command.Add у нас спрацьовує smart cast, і cmd сприймається як Command.Add, тож доступні cmd.amount і cmd.category без ручних приведень типів.
6. sealed interface CommandResult: успіх і помилка
Коли ви пишете команди, неминуче виникає запитання: «команда виконалася успішно чи ні?». Новачки часто роблять так:
- повертають Boolean і окремо друкують текст,
- повертають рядок, де інколи "ERROR: ...",
- повертають null, якщо сталася помилка (а потім ловлять NullPointerException на рівному місці).
Набагато зрозуміліше описати результат як закритий набір варіантів: успіх або помилка. Це буквально навчальний кейс для sealed interface.
sealed interface CommandResult {
val message: String
data class Ok(override val message: String) : CommandResult
data class Error(override val message: String) : CommandResult
}
Тепер handleCommand може повертати не рядок, а CommandResult:
fun handleCommand(cmd: Command, expenses: MutableList<Expense>): CommandResult =
when (cmd) {
is Command.Add -> {
expenses.add(Expense(cmd.amount, cmd.category))
CommandResult.Ok("Додано: ${cmd.amount} у категорії ${cmd.category}")
}
is Command.List -> CommandResult.Ok("Витрат: ${expenses.size}")
is Command.Help -> CommandResult.Ok("Команди: add/list/help/exit")
is Command.Exit -> CommandResult.Ok("Вихід...")
is Command.Unknown -> CommandResult.Error("Невідома команда: ${cmd.raw}")
}
І окремо — рендер результату:
fun renderResult(r: CommandResult): String =
when (r) {
is CommandResult.Ok -> "OK: ${r.message}"
is CommandResult.Error -> "ERROR: ${r.message}"
}
І знову: else не потрібен, тому що набір варіантів закритий. У результаті код читається як сценарій, а не як «набір рядкових милиць».
7. Мініцикл main
Тепер поєднаємо все в мінізастосунок. Він іще простий, але вже приємно структурований: введення → парсинг у Command → виконання → друк результату. І ніде не потрібно вгадувати, «які значення можливі».
fun main() {
val expenses = mutableListOf<Expense>()
while (true) {
print("> ")
val cmd = parseCommand(readln())
val result = handleCommand(cmd, expenses)
println(renderResult(result))
if (cmd is Command.Exit) break
}
}
Приклад діалогу (приблизний):
> add 120 food
OK: Додано: 120 у категорії food
> list
OK: Витрат: 1
> abracadabra
ERROR: Невідома команда: abracadabra
> exit
OK: Вихід...
Зауважте, наскільки мало тут «випадкових» домовленостей. У нас є два закриті набори: Command і CommandResult. І в обох випадках компілятор стає вашим напарником: якщо ви додасте новий варіант, він змусить обробити його в when.
8. Корисні нюанси
Що вибрати: enum, sealed class чи sealed interface
Коли зʼявляється sealed interface, часто хочеться запитати: «а навіщо він, якщо є sealed class або enum?». Це нормальне запитання: інструменти схожі, але призначення в них трохи різне.
Нижче — проста таблиця «за відчуттями», без заглиблення в байткод:
| Інструмент | Коли зручно | Головна думка |
|---|---|---|
|
Варіанти фіксовані та без даних (або з однаковими полями в усіх) | «Перелік значень» |
|
Варіанти фіксовані; інколи хочеться спільного стану/конструктора | «Родина варіантів із базовою реалізацією» |
|
Варіанти фіксовані, але важливіший контракт, а не «база»; плюс можна реалізувати інші інтерфейси | «Закритий контракт: відомі реалізації» |
Сьогодні наш Command — саме контракт: «це команда, у неї є конкретні варіанти». Нам не потрібен спільний конструктор або спільні поля «в базі», тому sealed interface лягає природно.
Guard conditions у when
Іноді одного «варіанта» мало: хочеться сказати «це Add, але сума має бути додатною». У Kotlin є синтаксис guard conditions для when: гілка може бути не просто is Type, а is Type if ....
Застосуймо це обережно до нашої команди. Наприклад, вважатимемо відʼємну суму помилкою:
fun handleCommand(cmd: Command, expenses: MutableList<Expense>): CommandResult =
when (cmd) {
is Command.Add if cmd.amount <= 0 ->
CommandResult.Error("Сума має бути додатною")
is Command.Add -> {
expenses.add(Expense(cmd.amount, cmd.category))
CommandResult.Ok("Додано: ${cmd.amount} у категорії ${cmd.category}")
}
is Command.List -> CommandResult.Ok("Витрат: ${expenses.size}")
is Command.Help -> CommandResult.Ok("Команди: add/list/help/exit")
is Command.Exit -> CommandResult.Ok("Вихід...")
is Command.Unknown -> CommandResult.Error("Невідома команда: ${cmd.raw}")
}
Сенс guard conditions у тому, що ми не розбиваємо логіку на «when за типом» + «усередині ще if». Натомість читаємо гілку як речення: «якщо це Add і сума некоректна — помилка».
9. Типові помилки під час роботи з sealed interface
Помилка №1: робити sealed, але все одно писати «порожній» else у ключових when.
Іноді люди за звичкою додають else -> ... навіть там, де набір варіантів закритий. У результаті ви втрачаєте головний бонус: якщо пізніше зʼявиться новий варіант, компілятор уже не змусить вас його обробити, бо «ну у вас же є else». Це перетворює sealed на декоративну наліпку.
Помилка №2: намагатися замінити Unknown на виняток або на null, а потім розгрібати наслідки.
Unknown — це не слабкість дизайну, а чесне визнання реальності: введення користувача може бути будь-яким. Якщо ви повертаєте null, ви змушуєте весь код бути «nullable-орієнтованим». Якщо кидаєте виняток, ви перетворюєте звичайну помилку користувача на «аварію». Варіант Unknown залишає ситуацію під контролем.
Помилка №3: змішувати в одному sealed-контракті різні відповідальності.
Іноді в sealed interface намагаються вмістити все: і команди, і результати, і події, і «ще одну штуку про всяк випадок». Потім when стає нечитабельним: гілки не про одне й те саме. Якщо відчуваєте, що варіанти перестали бути «родичами», значить контракт час розділити.
Помилка №4: робити варіанти з неочевидними назвами (Good, Bad, X1, X2).
Головна сила sealed — читабельність сценаріїв. Коли ви пишете when (result) { is Ok -> ... is Error -> ... }, це читається як історія. Якщо варіанти називаються надто абстрактно, ваш when перетворюється на ребус, а не на сценарій поведінки.
Помилка №5: створювати «суперечливі» моделі всередині варіантів, повертаючись до проблеми прапорців.
Навіть із sealed можна вистрілити собі в ногу, якщо всередині data class Error(...) ви покладете message: String? і почнете передавати null, або якщо в Ok буде поле isOk: Boolean. Варіанти вже виражають сенс. Не варто заново кодувати сенс прапорцями всередині них — інакше ви отримаєте модель, яка суперечить сама собі.
Помилка №6: забувати, що when буває statement і expression — і плутатися в очікуваннях.
Коли when використовується як вираз, Kotlin вимагає вичерпної обробки. Іноді новачки дивуються: «чому компілятор лається, я ж усе обробив… наче». Корисна звичка — одразу бачити, повертаєте ви значення (val x = when (...)) чи просто виконуєте гілки. Від цього й залежить вимога вичерпності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ