JavaRush /Курсы /Kotlin SELF /Знакомство с sealed class

Знакомство с sealed class

Kotlin SELF
34 уровень , 2 лекция
Открыта

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, а печать делать снаружи — там, где решается «как именно показываем результат пользователю».

1
Задача
Kotlin SELF, 34 уровень, 2 лекция
Недоступна
Регистрация ника
Регистрация ника
1
Задача
Kotlin SELF, 34 уровень, 2 лекция
Недоступна
Перевод температуры
Перевод температуры
1
Задача
Kotlin SELF, 34 уровень, 2 лекция
Недоступна
Количество товара
Количество товара
1
Задача
Kotlin SELF, 34 уровень, 2 лекция
Недоступна
Скидка по строке
Скидка по строке
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ