Запечатанные типы Java в деталях
Моделируйте закрытые иерархии типов в Java с sealed-классами и интерфейсами в сочетании с сопоставлением с образцом.
Запечатанные классы и интерфейсы (окончательно введённые в Java 17) позволяют типу явно указать, какие именно типы имеют право его расширять или реализовывать. Вместо открытой иерархии, которую может унаследовать кто угодно, вы задаёте закрытое множество, о котором компилятор знает всё. Именно эта единственная гарантия — только эти и никакие другие — лежит в основе исчерпывающего сопоставления с образцом и делает иерархии классов, моделирующие данные, безопасными.
Запечатанный тип является естественным партнёром записей (records). Records дают вам данные; запечатывание даёт вам закрытое множество вариантов. Вместе они привносят в обычный Java алгебраические типы данных («тип-сумма», знакомый из Kotlin, Rust или Scala) и меняют поведение switch по иерархии.
В этой главе рассматривается, как запечатать тип с помощью permits, какой обязательный выбор — final / sealed / non-sealed — должен сделать каждый подтип, почему закрытая иерархия открывает исчерпывающий switch без default, и как запечатывание сочетается с деконструкцией записей и охраняемыми образцами. Глава опирается на материал об интерфейсах и наследовании.
Запечатывание типа с помощью permits
Тип становится запечатанным благодаря модификатору sealed и предложению permits, в котором перечислены все прямые подтипы. Никакой другой класс не сможет войти в иерархию, даже если он находится в том же пакете. Разрешённые подтипы должны быть доступны запечатанному типу и в безымянном модуле располагаться в одном пакете (или в одном модуле).
public sealed interface Payment
permits Cash, Card, BankTransfer {}
public record Cash(int amount) implements Payment {}
public record Card(String number, int amount) implements Payment {}
public record BankTransfer(String iban, int amount) implements Payment {}Если подтип находится в том же исходном файле, предложение permits необязательно — компилятор выведет его из файла. Явно указывать permits нужно только тогда, когда подтипы находятся в отдельных файлах.
// Same file: permits is inferred, so it can be omitted.
sealed interface Expr {
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
}Правило final, sealed и non-sealed
Каждый разрешённый подтип обязан сам указать, как закрыта его часть иерархии. Компилятор требует выбора: каждый прямой подтип должен быть объявлен final, sealed или non-sealed. Варианта «ничего не указывать» нет — отсутствие модификатора является ошибкой компиляции.
| Модификатор | Значение для подтипа |
|---|---|
final | Подтип нельзя расширить далее. Records неявно являются final. |
sealed | Подтип сам закрыт и предоставляет собственный список permits. |
non-sealed | Подтип открывает иерархию снова — его может расширить кто угодно. |
public sealed class Shape permits Circle, Polygon, Freeform {}
public final class Circle extends Shape {} // closed here
public sealed class Polygon extends Shape // closed, but to a set
permits Triangle, Rectangle {}
public non-sealed class Freeform extends Shape {} // reopened: any subclass allowed
public final class Triangle extends Polygon {}
public final class Rectangle extends Polygon {}non-sealed — это аварийный выход: он позволяет одной ветви в иначе закрытой иерархии оставаться открытой для расширения. Используйте его с осторожностью, поскольку это лишает данную ветвь гарантии исчерпанности.
Почему запечатывание делает switch исчерпывающим
Выгода от закрытия иерархии в том, что компилятор знает полный список вариантов. Оператор switch по запечатанному типу, покрывающий все разрешённые подтипы, является исчерпывающим — писать ветку default не нужно. Более того, если кто-то позднее добавит новый разрешённый подтип, каждый неисчерпывающий switch перестанет компилироваться — компилятор укажет на код, забывший учесть новый вариант.
sealed interface Payment permits Cash, Card, BankTransfer {}
record Cash(int amount) implements Payment {}
record Card(String number, int amount) implements Payment {}
record BankTransfer(String iban, int amount) implements Payment {}
static String fee(Payment p) {
return switch (p) { // no default needed
case Cash c -> "no fee";
case Card c -> "2% card fee";
case BankTransfer b -> "flat fee";
};
}Уберите ветку BankTransfer — и код не скомпилируется: "the switch expression does not cover all possible input values." Именно эта проверка на этапе компиляции является главной причиной запечатывать иерархию.
Records, деконструкция и охранные выражения
Поскольку разрешённые подтипы, как правило, являются records, запечатывание можно сочетать с образцами деконструкции записей и охраняемыми образцами (when) — полное описание см. в разделе о сопоставлении с образцом. Деконструкция привязывает компоненты записи прямо в метке case; охранное выражение добавляет логическое условие. Порядок важен: более конкретные охраняемые варианты должны идти перед неохраняемым запасным вариантом для того же типа.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static String describe(Shape s) {
return switch (s) {
case Circle(double r) when r > 10 -> "big circle";
case Circle(double r) -> "circle r=" + r;
case Rectangle(double w, double h) when w == h -> "square";
case Rectangle(double w, double h) -> "rectangle";
};
}Компилятор по-прежнему считает это исчерпывающим: каждый разрешённый подтип покрыт хотя бы одной неохраняемой меткой, поэтому весь switch является полным, даже если некоторые метки охраняемые.
Развёрнутый пример
Запускаемый пример ниже объединяет всё вместе: запечатанный интерфейс Shape с тремя подтипами-records, исчерпывающий switch для вычисления площади, деконструирующий switch с охраной для описания, а также просмотр метаданных запечатывания через рефлексию. Используется только JDK, поэтому пример работает без изменений.
Что важно вынести из запуска:
- В
switchдля площади нет веткиdefault— посколькуShapeзапечатан, покрытие всех трёх records уже является исчерпывающим. describeвыводитbig circle r=12.0только для окружности с радиусом 12, доказывая, что охранаwhen r > 10проверяется перед неохраняемой меткойCircle.- Прямоугольник со сторонами 5 выводит
square side=5.0, показывая, что охранаw == hпобеждает простой вариантRectangle, следующий за ней. - Суммарная площадь (525.96) накапливается по всем подтипам-records, подтверждая, что один полиморфный цикл обрабатывает всю закрытую иерархию.
Shape.class.isSealed()возвращаетtrue, аgetPermittedSubclasses()перечисляет Circle, Rectangle и Triangle — множествоpermitsсохраняется в метаданных во время выполнения.