Запечатанные классы Java
Ограничьте, какие классы могут расширять или реализовывать тип в Java, используя sealed-классы и предложение permits.
Запечатанный (sealed) класс или интерфейс ограничивает список тех, кто может его расширять или реализовывать, фиксированным набором именованных подтипов. final говорит «никто не может меня расширить». sealed говорит «только эти конкретные классы могут». Это даёт вам закрытую иерархию — компилятор знает всё семейство заранее, что открывает возможность исчерпывающего switch и дисциплинированного моделирования конструкций «одна из N форм».
Без запечатывания abstract class Shape открыт для всего мира: любой, кто имеет доступ к типу, может написать class Banana extends Shape. С sealed автор Shape объявляет ровно те подтипы, которые существуют, и добавление нового требует изменения родительского класса.
Базовый синтаксис
Запечатанный класс перечисляет разрешённые подтипы с помощью permits:
public sealed class Shape
permits Circle, Square, Triangle {
// common state and behavior
}Каждый разрешённый подтип должен сам объявить, что он делает с запечатыванием — одним из: final, sealed (со своим собственным permits) или non-sealed:
public final class Circle extends Shape { /* leaf */ }
public final class Square extends Shape { /* leaf */ }
public non-sealed class Triangle extends Shape { /* re-opens the door */ }final— никаких дальнейших подклассов; это лист в иерархии.sealed— расширяет ту же модель; имеет собственный списокpermits.non-sealed— отказывается от ограничений; теперь кто угодно может расширитьTriangle. Полезно, когда вы хотите закрытое семейство верхнего уровня с одной открытой ветвью.
Запечатанный тип без модификатора у подтипа является ошибкой компиляции — компилятор вынуждает вас сделать выбор.
Запечатанные интерфейсы
Интерфейсы следуют тем же правилам и обычно являются более естественным выбором для моделирования семейств вариантов:
public sealed interface Result<T>
permits Success, Failure {}
public record Success<T>(T value) implements Result<T> {}
public record Failure<T>(String message) implements Result<T> {}В сочетании с записями (records) вы получаете нечто близкое к «сумм-типу» или «помеченному объединению» из функциональных языков — закрытый список именованных альтернатив, каждая со своими данными.
Один модуль, один пакет (или явный permits)
Разрешённые подтипы должны быть доступны для запечатанного объявления во время компиляции. Простейший вариант — поместить запечатанный класс и его разрешённые подтипы в один исходный файл — тогда можно даже опустить permits, потому что компилятор выведет его самостоятельно:
public sealed interface Tree {
record Leaf(int value) implements Tree {}
record Node(Tree left, Tree right) implements Tree {}
}Если они находятся в разных файлах, они должны быть в одном пакете (или в модульном проекте — в одном модуле), и предложение permits обязательно.
Выгода: исчерпывающий switch
Компилятор знает все возможные подтипы запечатанного типа. Это позволяет switch обеспечивать исчерпываемость без default:
double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square q -> q.side() * q.side();
case Triangle t -> 0.5 * t.base() * t.height();
};
}Если впоследствии вы добавите разрешённый Hexagon, этот switch перестанет компилироваться везде, где он используется, до тех пор, пока вы не обработаете новый случай. Именно такую защитную сеть default тихо уничтожил бы.
Правила, которые соблюдает компилятор
Есть несколько ограничений, о которых легко забыть:
- Каждый разрешённый подтип должен напрямую расширять или реализовывать запечатанный тип. Нельзя указывать внука в
permits— только непосредственные подтипы. - Каждый разрешённый подтип должен выбрать модификатор:
final,sealedилиnon-sealed. Пропуск одного является ошибкой компиляции. - Разрешённые типы должны быть доступны во время компиляции — один файл, один пакет или один именованный модуль. Запечатанный тип не может разрешить класс из несвязанного модуля.
- Записи (records) неявно являются
final, поэтому запись может быть разрешённым подтипом без написанияfinalвручную. Именно поэтому комбинация sealed-интерфейса с записями настолько чистая.
Модификатор non-sealed является намеренным аварийным выходом. Используйте его, когда большая часть иерархии должна оставаться закрытой, но одна ветвь является запланированной точкой расширения:
public sealed interface Vehicle permits Car, Truck, CustomBuild {}
public record Car(int doors) implements Vehicle {}
public record Truck(double tons) implements Vehicle {}
// Re-opened: third parties may extend this branch.
public non-sealed interface CustomBuild extends Vehicle {}Поскольку CustomBuild является non-sealed, switch по Vehicle всё равно требует запасного варианта для него — компилятор больше не может доказать, что эта ветвь исчерпывающа.
Когда использовать запечатывание
Прибегайте к запечатыванию, когда абстракция действительно представляет собой закрытый набор вариантов:
- Узлы AST или выражений (
Literal,Add,Multiply...). - Результаты домена, которые являются «успехом или одним из этих отказов».
- Иерархии команд/событий, где каждый потребитель должен обрабатывать каждый случай.
Не запечатывайте типы, которые являются точками расширения — плагинные интерфейсы, хуки фреймворков, всё, что вызывающие стороны ожидают подклассировать. Запечатывание таких типов лишает их смысла.
Рабочий пример
Что дальше
Запечатывание фиксирует список подтипов. Следующая глава о том, как во время выполнения определить, какой именно подтип перед вами — оператор instanceof и его современная форма с сопоставлением шаблонов, которая и делает приведённый выше switch таким лаконичным. Перейдите к оператору Java instanceof.