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
Задача
Kotlin SELF, 34 уровень, 4 лекция
Недоступна
Единый ответ
Единый ответ
1
Задача
Kotlin SELF, 34 уровень, 4 лекция
Недоступна
Позитивное число
Позитивное число
1
Задача
Kotlin SELF, 34 уровень, 4 лекция
Недоступна
Снятие средств
Снятие средств
1
Опрос
`enum` и `sealed class`, 34 уровень, 4 лекция
Недоступен
`enum` и `sealed class`
`enum` и `sealed class`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ