$ sudo teach IT
МОДУЛЬ 9 · УРОК 3

Sealed Classes — Запечатанные классы

Sealed classes и interfaces (Java 17+) позволяют контролировать, кто может наследовать ваш класс. Зачем нужны, как работают permits, pattern matching и сравнение с final

~30 минут Для новичков Java 17+

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

Представьте, что вы создаёте систему оплаты. У вас есть интерфейс Payment, и вы знаете три способа оплаты: карта, наличные, перевод.

public interface Payment {
    double getAmount();
}

public class CardPayment implements Payment {
    private final String cardNumber;
    private final double amount;

    public CardPayment(String cardNumber, double amount) {
        this.cardNumber = cardNumber;
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }

    public String getCardNumber() {
        return cardNumber;
    }
}

public class CashPayment implements Payment {
    private final double amount;

    public CashPayment(double amount) {
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }
}

public class TransferPayment implements Payment {
    private final String accountNumber;
    private final double amount;

    public TransferPayment(String accountNumber, double amount) {
        this.accountNumber = accountNumber;
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }

    public String getAccountNumber() {
        return accountNumber;
    }
}

Проблема в том, что кто угодно может создать новый класс, реализующий Payment. Ваш код, который обрабатывает только три известных типа,uddenly может столкнуться с неизвестным типом оплаты.

// Кто-то другой добавил в библиотеку:
public class CryptoPayment implements Payment {
    private final String walletAddress;
    private final double amount;

    public CryptoPayment(String walletAddress, double amount) {
        this.walletAddress = walletAddress;
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }

    public String getWalletAddress() {
        return walletAddress;
    }
}

Теперь ваш switch по типам оплаты не покрывает все случаи, и программа может работать некорректно.

Решение: sealed class

Ключевое слово sealed позволяет явно указать, какие классы могут наследовать ваш класс или реализовывать ваш интерфейс:

public sealed interface Payment
    permits CardPayment, CashPayment, TransferPayment {
    double getAmount();
}

public final class CardPayment implements Payment {
    private final String cardNumber;
    private final double amount;

    public CardPayment(String cardNumber, double amount) {
        this.cardNumber = cardNumber;
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }

    public String getCardNumber() {
        return cardNumber;
    }
}

public final class CashPayment implements Payment {
    private final double amount;

    public CashPayment(double amount) {
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }
}

public final class TransferPayment implements Payment {
    private final String accountNumber;
    private final double amount;

    public TransferPayment(String accountNumber, double amount) {
        this.accountNumber = accountNumber;
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }

    public String getAccountNumber() {
        return accountNumber;
    }
}

Теперь компилятор гарантирует, что только эти три класса могут реализовывать Payment. Если кто-то попытается создать CryptoPayment implements Payment — компилятор выдаст ошибку.

Аналогия: Sealed class — это как VIP-клуб. Только те, кто в списке приглашённых (permits), могут войти. Все остальные — компилятор не пустит.

Модификаторы permitted classes

Каждый класс из списка permits должен иметь один из трёх модификаторов:

  • final — класс не может быть наследован дальше
  • sealed — класс сам является запечатанным и указывает своих наследников
  • non-sealed — класс открыт для наследования (любой может наследовать)
public sealed interface Shape
    permits Circle, Rectangle, Triangle {

    double area();
}

public final class Circle implements Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }

    public double getRadius() {
        return radius;
    }
}

public sealed class Rectangle implements Shape
    permits Square, NonSquareRectangle {

    protected final double width;
    protected final double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() {
        return width * height;
    }
}

public final class Square extends Rectangle {
    public Square(double side) {
        super(side, side);
    }
}

public final class NonSquareRectangle extends Rectangle {
    public NonSquareRectangle(double width, double height) {
        super(width, height);
    }
}

public final class Triangle implements Shape {
    private final double base;
    private final double height;

    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    public double area() {
        return 0.5 * base * height;
    }
}

Здесь:

  • Shape — sealed interface, permits: Circle, Rectangle, Triangle
  • Circle — final, нельзя наследовать
  • Rectangle — sealed, permits: Square, NonSquareRectangle (иерархия на 2 уровня)
  • Triangle — final, нельзя наследовать

Зачем нужны sealed classes?

Sealed classes решают несколько важных задач:

  • Контроль иерархии — вы точно знаете, какие типы возможны
  • Безопасность — никто не добавит неизвестный подтип
  • Pattern matching — компилятор может проверить полноту switch
  • Документирование — permits явно показывает все варианты
  • Производительность — JIT-компилятор может оптимизировать, зная полную иерархию

Pattern matching с sealed classes

Sealed classes идеально сочетаются с pattern matching в switch. Компилятор знает все возможные типы и может:

  • Проверить, что все типы обработаны
  • Предупредить, если забыли case
  • Автоматически привести тип
public sealed interface Payment
    permits CardPayment, CashPayment, TransferPayment {
    double getAmount();
}

public final class CardPayment implements Payment {
    private final String cardNumber;
    private final double amount;

    public CardPayment(String cardNumber, double amount) {
        this.cardNumber = cardNumber;
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }

    public String getCardNumber() {
        return cardNumber;
    }
}

public final class CashPayment implements Payment {
    private final double amount;

    public CashPayment(double amount) {
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }
}

public final class TransferPayment implements Payment {
    private final String accountNumber;
    private final double amount;

    public TransferPayment(String accountNumber, double amount) {
        this.accountNumber = accountNumber;
        this.amount = amount;
    }

    @Override
    public double getAmount() {
        return amount;
    }

    public String getAccountNumber() {
        return accountNumber;
    }
}

public class PaymentProcessor {
    public static String process(Payment payment) {
        return switch (payment) {
            case CardPayment cp ->
                "Оплата картой " + cp.getCardNumber() +
                " на сумму " + cp.getAmount();
            case CashPayment cp ->
                "Оплата наличными: " + cp.getAmount();
            case TransferPayment tp ->
                "Перевод на счёт " + tp.getAccountNumber() +
                " на сумму " + tp.getAmount();
        };
    }

    public static void main(String[] args) {
        Payment card = new CardPayment("1234-5678-9012-3456", 1500);
        Payment cash = new CashPayment(500);
        Payment transfer = new TransferPayment("40702810100000001234", 3000);

        System.out.println(process(card));
        System.out.println(process(cash));
        System.out.println(process(transfer));
    }
}

Вывод программы:

Оплата картой 1234-5678-9012-3456 на сумму 1500.0
Оплата наличными: 500.0
Перевод на счёт 40702810100000001234 на сумму 3000.0

Обратите внимание: в switch нет default, и компилятор не ругается — он знает, что все типы обработаны!

Аналогия: Pattern matching с sealed classes — это как форма с выпадающим списком. Выборка уже знает все возможные варианты и не позволит выбрать несуществующий.

sealed vs final

Не путайте sealed и final:

Характеристика sealed final
Наследование Только разрешённые классы Никто не может наследовать
permits Обязателен (явно или в том же файле) Не нужен
Иерархия Может быть много уровней Только один уровень
Когда использовать Известные, но расширяемые наборы типов Фиксированные типы

Практический пример: форма

Давайте создадим систему форм с разными типами полей:

public sealed interface FormField
    permits TextField, NumberField, CheckboxField {

    String getLabel();
    String getValueAsString();
}

public final class TextField implements FormField {
    private final String label;
    private final String value;
    private final int maxLength;

    public TextField(String label, String value, int maxLength) {
        this.label = label;
        this.value = value;
        this.maxLength = maxLength;
    }

    @Override
    public String getLabel() {
        return label;
    }

    @Override
    public String getValueAsString() {
        return value;
    }

    public boolean isValid() {
        return value != null && value.length() <= maxLength;
    }
}

public final class NumberField implements FormField {
    private final String label;
    private final double value;
    private final double min;
    private final double max;

    public NumberField(String label, double value, double min, double max) {
        this.label = label;
        this.value = value;
        this.min = min;
        this.max = max;
    }

    @Override
    public String getLabel() {
        return label;
    }

    @Override
    public String getValueAsString() {
        return String.valueOf(value);
    }

    public boolean isValid() {
        return value >= min && value <= max;
    }
}

public final class CheckboxField implements FormField {
    private final String label;
    private final boolean checked;

    public CheckboxField(String label, boolean checked) {
        this.label = label;
        this.checked = checked;
    }

    @Override
    public String getLabel() {
        return label;
    }

    @Override
    public String getValueAsString() {
        return checked ? "Да" : "Нет";
    }

    public boolean isChecked() {
        return checked;
    }
}

public class FormProcessor {
    public static String describe(FormField field) {
        return switch (field) {
            case TextField tf ->
                "Текст: " + tf.getLabel() + " = \"" + tf.getValueAsString() + "\"";
            case NumberField nf ->
                "Число: " + nf.getLabel() + " = " + nf.getValueAsString();
            case CheckboxField cb ->
                "Галочка: " + cb.getLabel() + " = " + cb.getValueAsString();
        };
    }

    public static void main(String[] args) {
        FormField name = new TextField("Имя", "Иван", 50);
        FormField age = new NumberField("Возраст", 25, 0, 150);
        FormField agree = new CheckboxField("Согласен", true);

        System.out.println(describe(name));
        System.out.println(describe(age));
        System.out.println(describe(agree));
    }
}

Вывод программы:

Текст: Имя = "Иван"
Число: Возраст = 25.0
Галочка: Согласен = Да

Когда использовать sealed classes?

  • Когда вы точно знаете все варианты типов
  • Когда нужно гарантировать полноту обработки (pattern matching)
  • Для моделирования состояний (светофор, заказ, задача)
  • Для AST (abstract syntax tree) компиляторов
  • Для обработки событий с фиксированным набором типов

non-sealed класс

Модификатор non-sealed открывает класс для наследования — любой класс может его наследовать:

public sealed interface Vehicle
    permits Car, Truck, Motorcycle {

    String getType();
    int getWheels();
}

public final class Car implements Vehicle {
    @Override
    public String getType() { return "Легковой автомобиль"; }
    @Override
    public int getWheels() { return 4; }
}

public non-sealed class Truck implements Vehicle {
    @Override
    public String getType() { return "Грузовик"; }
    @Override
    public int getWheels() { return 6; }
}

public final class Motorcycle implements Vehicle {
    @Override
    public String getType() { return "Мотоцикл"; }
    @Override
    public int getWheels() { return 2; }
}

// Теперь кто угодно может наследовать Truck:
// public class FireTruck extends Truck { ... }
// public class SchoolBus extends Truck { ... }

Расширенный пример: вычисление выражений

Sealed classes идеально подходят для моделирования AST (дерева синтаксиса) компилятора или калькулятора:

public sealed interface Expression
    permits NumberExpr, AdditionExpr, MultiplicationExpr {

    double evaluate();
    String toInfix();
}

public record NumberExpr(double value) implements Expression {
    @Override
    public double evaluate() {
        return value;
    }

    @Override
    public String toInfix() {
        return String.valueOf(value);
    }
}

public record AdditionExpr(Expression left, Expression right) implements Expression {
    @Override
    public double evaluate() {
        return left.evaluate() + right.evaluate();
    }

    @Override
    public String toInfix() {
        return "( " + left.toInfix() + " + " + right.toInfix() + " )";
    }
}

public record MultiplicationExpr(Expression left, Expression right) implements Expression {
    @Override
    public double evaluate() {
        return left.evaluate() * right.evaluate();
    }

    @Override
    public String toInfix() {
        return "( " + left.toInfix() + " * " + right.toInfix() + " )";
    }
}

public class ExpressionDemo {
    public static void main(String[] args) {
        Expression expr = new AdditionExpr(
            new MultiplicationExpr(new NumberExpr(2), new NumberExpr(3)),
            new NumberExpr(4)
        );

        System.out.println("Выражение: " + expr.toInfix());
        System.out.println("Результат: " + expr.evaluate());
    }
}

Вывод программы:

Выражение: ( ( 2.0 * 3.0 ) + 4.0 )
Результат: 10.0

Итоги урока

Итоги урока

  • Sealed classes контролируют, кто может наследовать класс/реализовывать интерфейс
  • Ключевое слово permits указывает разрешённые подтипы
  • Подтипы должны быть: final, sealed или non-sealed
  • Идеально сочетаются с pattern matching в switch
  • Компилятор проверяет полноту обработки всех типов
  • Применимы для: форм, AST, состояний, событий
  • С Java 17+

Следующий урок: Внутренние классы →

Тест: Sealed classes

5 вопросов

Sealed Shape

Premium