Урок 6 из 11 базовый около 30 мин

Sealed-интерфейсы: закрытые наборы вариантов

Ограничиваем множество реализаций, получаем исчерпывающий switch без default и моделируем результат операции без исключений.

Проблема открытых иерархий

Обычный интерфейс может реализовать кто угодно. Для библиотек это достоинство, а для прикладной модели — проблема: когда вы пишете switch по типу фигуры, компилятор не знает, сколько фигур существует, и требует default. А значит, новый вариант, добавленный через полгода, молча попадёт в default вместо того, чтобы обратить на себя внимание.

Sealed-типы (стандарт с Java 17) закрывают иерархию: интерфейс или класс сам перечисляет, кто вправе его реализовать. Все остальные попытки отвергает компилятор.

sealed interface Shape permits Circle, Rect, Triangle {}

record Circle(double radius) implements Shape {}
record Rect(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}

Каждый разрешённый подтип должен объявить, что с ним будет дальше: final (наследовать нельзя), sealed (наследовать могут только перечисленные) или non-sealed (наследовать может кто угодно). Записи final по определению, поэтому им ничего дописывать не нужно.

Если все подтипы объявлены в том же файле, список permits можно опустить: компилятор соберёт его сам.

Исчерпывающий switch

Главная награда за закрытую иерархию — switch без default. Компилятор знает все варианты и проверяет, что каждый обработан:

static double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rect r -> r.width() * r.height();
        case Triangle t -> t.base() * t.height() / 2;
    };
}

Добавьте в permits четвёртую фигуру — и этот метод перестанет компилироваться, пока вы не опишете новую ветку. Ошибка компиляции здесь желанна: она перечисляет все места, которые нужно обновить.

Результат операции вместо исключений

Sealed-интерфейсы особенно удобны там, где у операции несколько ожидаемых исходов. Вместо возврата null, магических кодов или исключений для обычных ситуаций опишите исходы явно:

sealed interface ParseResult permits Parsed, Invalid {}
record Parsed(int value) implements ParseResult {}
record Invalid(String input, String reason) implements ParseResult {}

static ParseResult parsePort(String text) {
    try {
        int port = Integer.parseInt(text.strip());
        if (port < 1 || port > 65535) {
            return new Invalid(text, "порт вне диапазона 1–65535");
        }
        return new Parsed(port);
    } catch (NumberFormatException e) {
        return new Invalid(text, "не число");
    }
}

var message = switch (parsePort(" 8080 ")) {
    case Parsed(int value) -> "Порт " + value;
    case Invalid(var input, var reason) -> "«" + input + "»: " + reason;
};
System.out.println(message);   // Порт 8080

Вызывающий код обязан разобрать оба варианта, иначе не скомпилируется. Так «забыть обработать ошибку» становится невозможно на уровне языка, а не на уровне дисциплины.

Состояния и переходы

Ещё один типичный случай — жизненный цикл объекта. Заказ может быть новым, оплаченным, отправленным или отменённым, и у каждого состояния свои данные:

sealed interface OrderState {
    record New() implements OrderState {}
    record Paid(java.time.LocalDateTime at) implements OrderState {}
    record Shipped(String trackingNumber) implements OrderState {}
    record Cancelled(String reason) implements OrderState {}
}

static OrderState pay(OrderState state) {
    return switch (state) {
        case OrderState.New n -> new OrderState.Paid(java.time.LocalDateTime.now());
        case OrderState.Paid p -> p;                 // уже оплачен, ничего не делаем
        case OrderState.Shipped s -> s;              // поздно платить, состояние не меняется
        case OrderState.Cancelled c ->
                throw new IllegalStateException("Отменённый заказ оплатить нельзя: " + c.reason());
    };
}

Подтипы здесь вложены в интерфейс, и permits опущен. Каждый переход описан явно, и добавление состояния «Возвращён» заставит пересмотреть все функции переходов.

Сравнение с перечислением

Вопросenumsealed
Число вариантов известно заранеедада
У вариантов разные данныенет, набор полей общийда, каждый подтип со своими компонентами
Экземпляров каждого вариантаровно одинсколько угодно
Исчерпывающий switchдада

Если вариантам не нужны данные — берите enum. Если у каждого своя структура — sealed-интерфейс с записями.

Частая ошибка

Писать в исчерпывающем switch ветку default «на всякий случай». Она отключает проверку полноты: новый подтип попадёт в default, и компилятор промолчит. Оставляйте default только для незакрытых типов.

Попробуйте сами

  1. Добавьте к Shape четвёртый вариант Square(double side) и исправьте все ошибки компиляции, которые появятся в методе area.
  2. Опишите sealed-интерфейс LoginResult с вариантами «успех с именем пользователя», «неверный пароль с числом оставшихся попыток» и «учётная запись заблокирована до даты». Напишите метод, который превращает результат в сообщение для экрана.
  3. Попробуйте объявить класс, реализующий Shape, вне списка permits, и прочитайте ошибку компилятора.