sealed і when: стиль Ok / Error та вичерпна обробка

Kotlin SELF
Рівень 34 , Лекція 4
Відкрита

1. Вступ

println() — дуже спокуслива штука. Напишете його один раз, побачите в консолі «Ура, спрацювало» — і здається, що життя вдалося. Проблема в іншому: щойно зʼявляється кілька функцій і кілька гілок if, код починає нагадувати чат із самим собою. Десь ви друкуєте успіх, десь — помилку, десь — «майже успіх», а десь мовчки не виводите нічого. Усе це стає складно тестувати й так само складно розширювати.

Уявіть наш навчальний консольний проєкт (ми розвиваємо його вже давно): простенький трекер витрат. У нас є Expense (об’єкт витрати), список витрат у MutableList, команди на кшталт add, list, remove. І в якийсь момент постає питання: як функція має повідомляти про результат?

Підхід Як виглядає Що не так
Друкувати всередині функції
println("Додано")
Функція «і рахує, і розмовляє». Її складно перевикористовувати й тестувати.
Повертати String?
рядок успіху або null у разі помилки
null погано пояснює, що пішло не так. А ще його легко забути перевірити.
Повертати sealed‑результат Ok/Error
Ok(message) / Error(reason)
Коду трохи більше, зате контракт зрозумілий, а обробка — сувора.

Ми беремо третій варіант: він дисциплінує і код, і голову. А голова — штука примхлива, тож її краще не дратувати.

2. Єдиний sealed‑результат: Ok і Error

Зараз буде важлива думка. Звучить вона простіше, ніж відчувається на практиці. Ми хочемо, щоб будь‑яка «командна операція» в застосунку повертала один і той самий тип результату. Тоді обробка всюди буде однаковою. А в main ми зможемо написати один охайний when і не розмазувати друк по всьому проєкту.

Почнемо з мінімального:

sealed class CommandResult

data class Ok(val message: String) : CommandResult()
data class Error(val reason: String) : CommandResult()

Тут CommandResult — базовий тип, а Ok і Error — два варіанти (два конкретні типи), які наслідуються від нього. У Kotlin такі sealed‑ієрархії добре поєднуються з вичерпним when: компілятор знає набір варіантів і допомагає не забути про обробку.

Зверніть увагу на конвенцію імен. Ми навмисно використовуємо Ok/Error всюди, а не стрибаємо між Success/Fail, Good/Bad, Fine/Oops. Це дрібниця, але вона пришвидшує читання коду: очі звикають, що результат завжди має одну й ту саму форму.

3. Бізнес‑функції: повертаємо CommandResult

Тепер перенесімо ідею в наш трекер витрат. Припустімо, що Expense і Category у нас уже є (ми вводили їх раніше, коли перейшли до класів і enum). Наприклад, дуже спрощено:

data class Expense(
    val amount: Int,
    val category: Category,
    val comment: String
)

enum class Category { FOOD, TRANSPORT, OTHER }

Тепер пишемо функцію, яка додає витрату. Важливо: вона не друкує. Вона повертає результат.

fun addExpense(
    expenses: MutableList<Expense>,
    amount: Int,
    category: Category,
    comment: String
): CommandResult {
    if (amount <= 0) return Error("Сума має бути > 0")

    expenses.add(Expense(amount, category, comment))
    return Ok("Додано витрату: $amount у категорії ${category.name}")
}

Зверніть увагу, як лінійно це читається: перевірили → якщо погано — повернули Error → інакше додали й повернули Ok. Жодних «внутрішніх розмов» через println(). Ми просто фіксуємо факт: успіх або помилка.

Той самий принцип — для видалення. Припустімо, видаляємо за індексом:

fun removeExpense(expenses: MutableList<Expense>, index: Int): CommandResult {
    if (index !in expenses.indices) return Error("Немає витрати з індексом $index")

    val removed = expenses.removeAt(index)
    return Ok("Видалено витрату: ${removed.amount} (${removed.category.name})")
}

Зауважте приємну річ: результат видалення ми використовуємо, щоб сформувати повідомлення. Якби ми друкували всередині, то вивід був би «вмонтований» у функцію. А так ми повертаємо дані (через рядок) як частину контракту результату.

4. when як диспетчер повідомлень

Найцікавіший момент сьогодні — там, де sealed і when починають працювати як команда.

Ми хочемо одну функцію, яка перетворює результат команди на рядок для користувача. Назвімо її render(...). І ось тут ми робимо when виразом: він повертає рядок.

fun render(result: CommandResult): String =
    when (result) {
        is Ok -> "OK: ${result.message}"
        is Error -> "ERROR: ${result.reason}"
    }

Чому це круто:

По‑перше, when тут саме вираз, тобто повертає значення. Kotlin вимагає, щоб такий when був вичерпним.
По‑друге, для sealed‑типу вичерпний when можна зробити без else, тому що всі варіанти відомі (у нашому випадку їх рівно два). Це означає: якщо завтра ви додасте третій варіант (наприклад, Help(...) або Empty(...)), компілятор чесно скаже: «Здається, ви забули обробити новий тип».

Це і є той самий «пасок безпеки», який ми хочемо отримати безкоштовно від компілятора.

5. Цикл у main: отримали → відформатували → вивели

Тепер давайте зберемо типовий цикл CLI‑застосунку. Ми не будемо ускладнювати парсер команд (він у вас уже зʼявлявся раніше, коли ви вчилися розбирати рядки й писати функції‑утиліти). Тут головне — показати потік даних.

Спочатку — ескіз циклу:

fun main() {
    val expenses = mutableListOf<Expense>()

    while (true) {
        print("> ")
        val input = readln()

        val result = handleCommand(expenses, input)
        println(render(result))
    }
}

Зверніть увагу: main став «координатором», а не звалищем логіки. Він робить усього три речі: читає рядок, отримує результат, друкує відформатоване повідомлення.

Можна навіть намалювати міні‑схему, щоб голові було легше:

flowchart TD
    A[readln: рядок команди] --> B[handleCommand: логіка]
    B --> C[CommandResult: Ok/Error]
    C --> D[render: рядок для користувача]
    D --> E[println]

І ось тут дуже важливо: render відокремлює «внутрішню правду програми» від «того, як ми розмовляємо з користувачем». Це маленька архітектура, але саме вона рятує від великого хаосу.

6. Обробка команд і відповідальність шарів

Обробник команди повертає CommandResult

Зараз зробимо найпростіший handleCommand. Так, він буде доволі «іграшковим», але нам важливий контракт результату.

Скажімо, у нас є команди:

  • add <amount> <category> <comment...>
  • list
  • remove <index>
  • exit

Ось мінімальний обробник (спрощений парсинг через split(), без підтримки лапок):

fun handleCommand(expenses: MutableList<Expense>, input: String): CommandResult {
    val parts = input.trim().split(" ")
    if (parts.isEmpty() || parts[0].isEmpty()) return Error("Порожня команда")

    return when (parts[0].lowercase()) {
        "list" -> listExpenses(expenses)
        "exit" -> Ok("Вихід із програми")
        else -> Error("Невідома команда: '${parts[0]}'")
    }
}

Зверніть увагу: навіть exit ми поки повертаємо як Ok. Чому? Тому що в межах цієї лекції нам важливо показати єдиний потік результату. Пізніше ви можете зробити спеціальну команду виходу й справді перервати цикл. Але зараз важливіша логіка Ok/Error.

Зробимо listExpenses, яка повертає багаторядкове повідомлення. Ми вже знаємо StringBuilder (він був у курсі раніше), тож використаємо його акуратно:

fun listExpenses(expenses: List<Expense>): CommandResult {
    if (expenses.isEmpty()) return Ok("Список витрат порожній")

    val sb = StringBuilder()
    for (i in expenses.indices) {
        val e = expenses[i]
        sb.append("$i) ${e.amount} ${e.category.name} — ${e.comment}\n")
    }
    return Ok(sb.toString().trimEnd())
}

Ми повертаємо Ok навіть для «порожнього списку», тому що це не помилка — це нормальний результат операції. І це важлива дисципліна: помилка — це ситуація, коли команду неможливо виконати коректно, а не коли результат просто «нецікавий».

Принцип: зробити vs сказати

Тут ми підходимо до дуже практичного принципу: функція, яка виконує дію, не повинна одночасно вирішувати, як саме ми повідомимо про це користувачеві. Це як у кафе: кухар готує, офіціант каже, що принесли. Якщо кухар сам виходить у зал і починає пояснювати кожному гостю склад соусу — ресторан швидко перетвориться на стендап.

У коді це виглядає так:

  • addExpense(...) змінює список і повертає CommandResult.
  • render(...) знає, як красиво (і однаково) друкувати Ok/Error.
  • main вирішує, коли друкувати.

Є й дуже корисний наслідок: ви можете змінити стиль повідомлень (наприклад, прибрати OK:), і вам не доведеться лізти в кожну бізнес‑функцію. Достатньо змінити render.

Чому else у when за sealed — погана страховка

Зараз буде трохи провокативно, але по‑доброму: else у when за sealed часто виглядає як «я не хочу думати». Іноді він справді потрібен, але в нашому шаблоні він частіше шкодить.

Порівняйте:

fun render(result: CommandResult): String =
    when (result) {
        is Ok -> "OK: ${result.message}"
        is Error -> "ERROR: ${result.reason}"
    }

І варіант «про всяк випадок»:

fun render(result: CommandResult): String =
    when (result) {
        is Ok -> "OK: ${result.message}"
        else -> "Щось сталося..."
    }

У другому випадку ви втрачаєте головний бонус: якщо зʼявиться новий варіант результату, компілятор уже не змусить вас його обробити. Він скаже: «Ну, у вас же є else, отже ви ніби все передбачили». А ви насправді нічого не передбачили — просто наклеїли пластир.

І ще раз: для when‑виразу вичерпність — не примха, а частина правил мови.
У нашому стилі ми використовуємо цю суворість як допомогу, а не як перешкоду.

Повідомлення — окремо, формат — окремо

Іноді виникає спокуса зробити так: «нехай Ok зберігає вже повністю готовий рядок із OK:», а Error — із ERROR:. Технічно можна, але тоді ви змішаєте «дані результату» й «формат виводу». Сьогодні ми тримаємо дисципліну:

  • Ok.message — смислове повідомлення про успіх (без оформлення).
  • Error.reason — причина помилки (без оформлення).
  • Оформлення (OK:, ERROR:) — тільки в render.

Це робить код охайнішим і дозволяє змінювати оформлення без втручання в логіку.

Якщо хочеться ще суворіше, можна додати маленьку функцію‑помічник:

fun printResult(result: CommandResult) {
    println(render(result))
}

І в main буде:

val result = handleCommand(expenses, input)
printResult(result)

Начебто дрібниця, але читабельність іноді й тримається на таких дрібницях.

7. Типові помилки

Помилка № 1: додавати else у when за sealed «про всяк випадок».
Так ви вимикаєте перевірку вичерпності й втрачаєте головний плюс sealed‑моделі: компілятор більше не сигналізуватиме, що ви забули обробити новий варіант. У результаті застосунок продовжить компілюватися, але почне видавати дивні повідомлення в несподіваних місцях.

Помилка № 2: зберігати помилки в Ok («Ok, але message = 'Помилка: ...'»).
Це виглядає невинно, але ламає сенс моделі: за типом результату вже не можна зрозуміти, що сталося. Під час читання коду ви постійно сумніватиметеся: «Це успіх чи просто рядок із бурчанням?». Якщо це помилка — вона має бути Error, а не «успіх із сумним обличчям».

Помилка № 3: повертати null замість Error.
Патерн T? добрий для деяких задач, але в командній логіці він занадто бідний: null не пояснює причину й легко губиться. Якщо помилка — це нормальний наслідок (наприклад, користувач увів неправильну команду), краще повернути Error(reason) і обробити його передбачувано.

Помилка № 4: друкувати всередині бізнес‑функцій і водночас повертати результат.
Іноді трапляється гібрид: функція і друкує println("OK"), і ще повертає Ok("..."). Це призводить до подвійних повідомлень і плутанини: де саме формується користувацький вивід, хто за нього відповідає, чому щось надрукувалося двічі. Якщо ви обрали контракт CommandResult, то друк краще залишити на верхньому рівні (у main або в окремому «UI»‑шарі).

Помилка № 5: робити надто «товстий» Ok і надто «тонкий» Error.
Наприклад, Ok містить і повідомлення, і дані, і ще якусь статистику, а Error — просто рядок "Error". У реальності помилки часто потребують не меншої ясності: хоча б зрозумілої причини. Навіть якщо ми поки зберігаємо лише рядок reason, важливо, щоб він був конкретним: «Сума має бути > 0», «Невідома команда», «Індекс поза діапазоном», а не «Помилка».

Помилка № 6: перетворювати sealed‑результат на «ще один enum», додаючи купу nullable‑полів.
Якщо ви починаєте писати щось на кшталт data class Ok(val message: String, val removed: Expense?, val index: Int?), то ви поступово повертаєтеся до «універсального мішка з nullable». Краще тримати варіанти простими й змістовними: або різні варіанти результату, або окремі функції для різних сценаріїв. Сьогоднішня ціль — саме ясність контракту, а не максимальна «універсальність будь-якою ціною».

1
Опитування
`enum` і `sealed class`, рівень 34, лекція 4
Недоступний
`enum` і `sealed class`
`enum` і `sealed class`
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ