JavaRush /Курсы /Kotlin SELF /sealed interface — закрытый набор вариантов

sealed interface — закрытый набор вариантов

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

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?». Это нормальный вопрос: инструменты похожи, но предназначения немного разные.

Ниже простая таблица «по ощущениям», без углубления в байткод:

Инструмент Когда удобно Главная мысль
enum class
Варианты фиксированы и без данных (или с одинаковыми полями у всех) «Перечисление значений»
sealed class
Варианты фиксированы, иногда хочется общего состояния/конструктора «Семья вариантов с базовой реализацией»
sealed interface
Варианты фиксированы, но важнее контракт, а не «база»; плюс можно реализовать другие интерфейсы «Закрытый контракт: известные реализации»

Сегодня наш 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 (...)) или просто выполняете ветки. От этого зависит требование исчерпываемости.

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