Урок 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 опущен. Каждый переход описан явно, и добавление состояния «Возвращён» заставит пересмотреть все функции переходов.
Сравнение с перечислением
| Вопрос | enum | sealed |
|---|---|---|
| Число вариантов известно заранее | да | да |
| У вариантов разные данные | нет, набор полей общий | да, каждый подтип со своими компонентами |
| Экземпляров каждого варианта | ровно один | сколько угодно |
| Исчерпывающий switch | да | да |
Если вариантам не нужны данные — берите enum. Если у каждого своя структура — sealed-интерфейс с записями.
Частая ошибка
Писать в исчерпывающем switch ветку default «на всякий случай». Она отключает проверку полноты: новый подтип попадёт в default, и компилятор промолчит. Оставляйте default только для незакрытых типов.
Попробуйте сами
- Добавьте к
Shapeчетвёртый вариантSquare(double side)и исправьте все ошибки компиляции, которые появятся в методеarea. - Опишите sealed-интерфейс
LoginResultс вариантами «успех с именем пользователя», «неверный пароль с числом оставшихся попыток» и «учётная запись заблокирована до даты». Напишите метод, который превращает результат в сообщение для экрана. - Попробуйте объявить класс, реализующий
Shape, вне спискаpermits, и прочитайте ошибку компилятора.