W3docs

Запечатанные типы 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, поэтому пример работает без изменений.

java— editable, runs on the server

Что важно вынести из запуска:

  • В 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 сохраняется в метаданных во время выполнения.

Практика

Практика
Почему исчерпывающий switch по запечатанному интерфейсу может обойтись без ветки default?
Почему исчерпывающий switch по запечатанному интерфейсу может обойтись без ветки default?
Was this page helpful?