1. Enum і sealed: коли й навіщо
Якби програмування було кухнею, то enum — це набір баночок зі спеціями, де на кожній баночці написано одне слово: SALT, PEPPER, PAPRIKA. А sealed class — це вже набір контейнерів: один може бути «супом з овочами», інший — «супом із локшиною», третій — «порожньою каструлею (помилка: забули увімкнути плиту)». І в кожного контейнера свій склад.
enum class чудово відповідає на запитання «який варіант обрано?». Але інколи цього замало. Вам потрібно, щоб у різних варіантів були різні дані. Наприклад, під час виконання команди в застосунку:
- за успіху ви хочете повернути, скажімо, створений id витрати й текст підтвердження;
- за помилки — причину, а можливо, проблемне поле або підказку для користувача.
Якщо спробувати зробити це через enum, ви дуже швидко прийдете до конструкції «в enum купа полів, і половина з них завжди null». Це вже той самий запах коду, який (як і запах горілої проводки) краще вловлювати завчасно.
Технічно Kotlin дозволяє змоделювати таке через sealed class: ви задаєте базовий тип «Результат операції», а потім явно перелічуєте допустимі варіанти-типи. Наприклад: Ok і Error.
Enum vs sealed: швидка шпаргалка
| Ситуація | Краще enum class | Краще sealed class |
|---|---|---|
| Варіанти — просто значення зі списку | Так | Можна, але це надлишково |
| У кожного варіанта свої дані | Зазвичай незручно | Так, це пряме призначення |
| Хочемо «успіх / помилка» з деталями | Можна зробити напряму, але часто виходить брудно | Так, це ідеальний випадок |
| Хочемо гарантувати, що варіанти не зʼявляться «збоку» | Частково | Так, закрите сімейство |
| Варіанти однієї форми | Так | Не обовʼязково |
І тепер важлива практична думка: sealed — це не «просунутість заради просунутості». Це спосіб зробити так, щоб програма говорила правду типами. Якщо операція може завершитися успіхом або помилкою, то тип результату має це відображати.
2. Головна ідея sealed: закрите сімейство типів
Перш ніж писати код, важливо зрозуміти сенс sealed «людською мовою». Ключова думка проста: sealed class задає закрите сімейство типів. Тобто ви кажете компілятору: «Ось базовий тип, ось список варіантів, і інших варіантів у цього сімейства бути не повинно (принаймні, не раптово десь в іншому місці проєкту)».
Історично й концептуально sealed-типи створили для того, щоб компілятор міг «розуміти» вичерпність розгалужень за варіантами. У специфікації Kotlin про це кажуть прямо: sealed-ієрархії потрібні, щоб when міг бути вичерпним, бо компілятор знає повний набір нащадків.
Але в цій лекції ми не будемо глибоко розбирати обробку варіантів через is і вичерпний when — це буде в наступній лекції. Сьогодні наше завдання простіше й практичніше: навчитися створювати sealed-модель і повертати її з функцій, щоб перестати використовувати «магічні рядки», null і неочевидні прапорці.
3. Мінімальний приклад: Ok і Error
Зараз ми напишемо найменший можливий sealed-результат. Я навмисно зроблю варіанти простими й короткими, щоб ви не потонули в деталях.
Зверніть увагу на один практичний прийом: варіанти можна тримати «всередині» sealed-класу. Тоді імена Ok і Error не засмічують увесь проєкт, а в місці використання одразу видно, до якого сімейства вони належать.
sealed class CommandResult {
data class Ok(val message: String) : CommandResult()
data class Error(val reason: String) : CommandResult()
}
Тут є кілька важливих моментів.
По-перше, CommandResult — це базовий тип. Його екземпляр безпосередньо ми зазвичай не створюємо. Натомість створюємо варіанти: CommandResult.Ok(...) або CommandResult.Error(...).
По-друге, варіанти — це звичайні класи. Дуже часто зручно робити їх data class, тому що вони справді «тримають дані». Kotlin прямо допускає, що data class може розширювати інші класи, зокрема sealed (при цьому сам data class не може бути sealed).
4. Приклад із CLI-трекера витрат
Від цього місця почнемо повʼязувати приклади в один застосунок. Нехай у нас є простий консольний трекер витрат (ідея знайома й доволі життєва: гроші зникають швидше, ніж рядки коду, особливо якщо ви пишете ці рядки в кавʼярні).
Припустімо, у нас уже є модель витрати й категорії (категорію зручно зберігати enumʼом — це якраз «фіксований набір значень»).
enum class Category {
FOOD, TRANSPORT, OTHER
}
data class Expense(
val id: Int,
val title: String,
val amount: Double,
val category: Category
)
Тепер уявімо команду add: користувач вводить назву, суму й категорію, а ми маємо або додати витрату, або пояснити, чому не вийшло.
Чому погано повертати просто String
Типовий «перший» підхід виглядає так: функція повертає текст, який ми друкуємо.
Проблема в тому, що рядок не несе чіткого сенсу. Рядок може означати і успіх, і помилку — зовнішньому коду доводиться «вгадувати» за вмістом.
А потім зʼявляється ось таке:
- "OK: added" — успіх
- "ERROR: invalid amount" — помилка
І ви раптово починаєте розбирати власні рядки. Це приблизно як написати на коробці «Тут їжа», а потім намагатися за запахом зрозуміти, їжа це чи батарейки.
Sealed-результат для команди add
Зробімо результат трохи багатшим: в успіху будемо зберігати expenseId, а в помилці — зрозумілу причину.
sealed class AddExpenseResult
data class AddOk(val expenseId: Int, val message: String) : AddExpenseResult()
data class AddError(val reason: String) : AddExpenseResult()
Так, тут імена трохи довші. Але це «довше» купує вам ясність: ви фізично не переплутаєте успіх і помилку.
5. Повертаємо sealed із функції: помилка як нормальний результат
Зараз буде важливий психологічний момент для початківців. Помилка — це не обовʼязково виняток. Дуже часто помилка — це просто очікуваний результат операції: користувач може ввести нісенітницю, файл може бути відсутнім, сума може бути відʼємною. Це не «катастрофа», а просто гілка сценарію.
Саме тому sealed-результат чудово підходить: ми повертаємо значення, яке каже: «ось що сталося».
Зробімо функцію додавання витрати. Сховище нехай буде MutableList<Expense> (на цьому етапі це нормально).
fun addExpense(
expenses: MutableList<Expense>,
title: String,
amount: Double,
category: Category
): AddExpenseResult {
if (title.isBlank()) return AddError("Назва не повинна бути порожньою")
if (amount <= 0.0) return AddError("Сума має бути більшою за 0")
val newId = (expenses.maxOfOrNull { it.id } ?: 0) + 1
expenses.add(Expense(newId, title, amount, category))
return AddOk(expenseId = newId, message = "Витрату додано")
}
Зверніть увагу, наскільки лінійно читається код: «перевірив → повернув помилку → інакше зробив → повернув успіх». Цей стиль часто називають fail-fast: щойно розуміємо, що операція неможлива, одразу повертаємо зрозумілий результат.
6. Спільний результат для різних команд
У прикладі вище ми зробили AddExpenseResult, але ви доволі швидко помітите: зʼявляться RemoveExpenseResult, UpdateExpenseResult, ParseCommandResult і так далі.
І ось тут хочеться зробити універсальний результат «для команд загалом». Дуже поширена конвенція — варіанти Ok і Error. Вона хороша тим, що в when (який будемо розбирати далі) код читається як «якщо Ok — робимо одне, якщо Error — інше».
У нас такий тип уже є: CommandResult з мінімального прикладу. Скористаймося ним.
fun addExpenseSimple(
expenses: MutableList<Expense>,
title: String,
amount: Double,
category: Category
): CommandResult {
if (title.isBlank()) return CommandResult.Error("Назва не повинна бути порожньою")
if (amount <= 0.0) return CommandResult.Error("Сума має бути більшою за 0")
val newId = (expenses.maxOfOrNull { it.id } ?: 0) + 1
expenses.add(Expense(newId, title, amount, category))
return CommandResult.Ok("Додано: id=$newId")
}
Тут важливо: ми не робили наслідування «глибоким» і складним. Усього два варіанти. Але вже зараз тип результату став чесним.
7. Широка форма результату: різні дані в успіху й помилці
Тепер давайте зробимо те, заради чого sealed часто й беруть: в успіху й помилці — різна «форма» даних. Наприклад:
- успіх додавання повертає Expense
- помилка додавання повертає reason і badInput
Таке через enum зробити акуратно складно, а через sealed — природно.
sealed class AddExpenseResult2
data class AddOk2(val expense: Expense) : AddExpenseResult2()
data class AddError2(val reason: String, val badInput: String) : AddExpenseResult2()
І функція:
fun addExpenseDetailed(
expenses: MutableList<Expense>,
title: String,
amountText: String,
category: Category
): AddExpenseResult2 {
val amount = amountText.toDoubleOrNull()
?: return AddError2("Сума має бути числом", badInput = amountText)
if (amount <= 0.0) return AddError2("Сума має бути більшою за 0", badInput = amountText)
val newId = (expenses.maxOfOrNull { it.id } ?: 0) + 1
val expense = Expense(newId, title, amount, category)
expenses.add(expense)
return AddOk2(expense)
}
Зауважте, як акуратно ми уникли null-логіки. Ми не повертаємо Expense?, де null означає «не додали». Натомість повертаємо значення, яке явно каже: «ось який саме результат стався».
8. Де тримати варіанти й як влаштований пайплайн
Де оголошувати варіанти
З погляду правил Kotlin sealed-ієрархія обмежує те, де можуть міститися її нащадки (у сучасних версіях Kotlin — у тому самому модулі й пакеті). Для початківця найпрактичніша порада ще простіша: тримайте базовий sealed-клас і його варіанти в одному файлі, поки проєкт маленький.
По-перше, так легше читати код: ви відкриваєте файл і одразу бачите «які взагалі бувають варіанти результату».
По-друге, так менше сюрпризів: ніхто не додасть вам «ще один варіант десь там» і не змусить шукати його по всьому проєкту.
Можна мислити так: sealed class — це як папка з документами, на якій написано: «Усередині лише: паспорт, водійське посвідчення і довідка». І ви справді хочете, щоб усе це лежало в одній папці, а не було розкидане по квартирі.
Схема пайплайна команда → результат
Щоб не втратити загальну картину, корисно уявити процес «команда → логіка → результат» як мініпайплайн.
flowchart TD
A[Ввід користувача: рядок команди] --> B[Парсинг і валідація]
B -->|коректно| C[Виконання дії]
B -->|помилка| E["Повернути Error(...)"]
C -->|успіх| D["Повернути Ok(...)"]
sealed тут — як акуратний контейнер для виходу з кроків B і C. Замість того щоб «друкувати всередині» або «кидати винятки з будь-якого приводу», функція повертає те, що сталося, а зовнішній код уже вирішує, що з цим робити.
Sealed для помилок і винятків
А чи можна sealed використовувати для винятків? Так: sealed може бути базою для ієрархії помилок (зокрема винятків), щоб зробити помилки більш структурованими.
Але в межах нашої теми важливо не плутати два підходи.
- Коли помилка — очікувана частина сценарію (неправильний ввід, порожня назва, відʼємна сума), зазвичай зручніше повернути sealed-результат.
- Коли помилка — несподівана поломка (зламалася інфраструктура, порушилися внутрішні інваріанти, «так бути не повинно»), тоді частіше йдеться про винятки.
Сьогодні ми свідомо тренуємо перший стиль: «помилка як значення», але без Result<T> — винятково на sealed class, щоб вам було простіше відчути механіку.
9. Типові помилки під час проєктування sealed-результатів
Помилка №1: намагатися запхати все в один клас із купою nullable-полів.
Часто початківці роблять щось на кшталт class Result(val ok: Boolean, val message: String?, val reason: String?, val expense: Expense?). Формально це працює, але логіка стає неявною: які поля мають бути заповнені за ok=true? А які — за ok=false? Sealed-модель розвʼязує це типами: у Ok лежить лише те, що має сенс за успіху, а в Error — лише те, що має сенс за помилки.
Помилка №2: «успіх» із помилкою всередині.
Іноді трапляється варіант Ok(message = "ERROR: ..."). Це ламає сенс моделі: раз ви повернули Ok, зовнішній код має право вірити, що все добре. Якщо щось пішло не так — це має бути Error. Інакше тип перестає бути контрактом і перетворюється на декорацію.
Помилка №3: різнобій в іменах варіантів.
В одному місці Success/Failure, в іншому Good/Bad, у третьому Ok/Error. Читач коду (зокрема й ви самі за два тижні) починає витрачати сили на «перемикання словника». Оберіть одну конвенцію (у нашому курсі — Ok/Error) і дотримуйтеся її. Тоді обробка результатів виглядатиме однаково в усьому проєкті.
Помилка №4: повертати null замість Error.
null — це «немає значення», але майже ніколи не «пояснення проблеми». Коли ви повертаєте null, ви втрачаєте причину й змушуєте код, що викликає, здогадуватися. Sealed-помилка добра тим, що несе дані: чому не вийшло, що саме було не так, що показати користувачеві.
Помилка №5: змішувати виконання команди й виведення в консоль.
Дуже хочеться всередині addExpense(...) зробити println("Додано!"). Але тоді функція стає негнучкою: її неможливо використати в іншому місці (наприклад, у тестах або в іншому інтерфейсі), і важко контролювати вивід. Краще повернути Ok/Error, а друк робити ззовні — там, де й вирішується, «як саме показуємо результат користувачеві».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ