Запечатанные классы и исчерпывающая проверка

Java 17 представила запечатанные классы, позволяющие явно указать разрешённые подтипы интерфейса или базового класса. Вот простой пример с запечатанным интерфейсом и records (внутренние классы автоматически добавляются в список разрешённых подтипов, если явный список не задан):

public sealed interface SealedType {
    record TypeA() implements SealedType {}
    record TypeB() implements SealedType {}

    static SealedType of(String type) {
        return switch (type) {
            case "A" -> new TypeA();
            case "B" -> new TypeB();
            default -> throw new IllegalArgumentException();
        };
    }
}

Если вы знакомы с функциональными языками программирования, имеющими алгебраические типы данных, это можно рассматривать как объявление типа в Haskell или ML:

data SealedType = TypeA | TypeB

Используем это в простом классе Main:

void main(String[] args) {
    var val = SealedType.of(args[0]);
    System.out.println(switch (val) {
        case SealedType.TypeA() -> "A";
        case SealedType.TypeB() -> "B";
    });
}

Неспешное начало, но здесь важно заметить: в switch-выражении в методе main не нужна ветка default. Это благодаря проверке исчерпываемости, которую обеспечивают запечатанные классы и перечисления: компилятор точно знает все возможные случаи и может проверить, охватили ли вы их все. Если да — ветка default не требуется. Если забыли случай и нет default — получите ошибку компиляции.

Это полезно, когда нужно гарантировать, что все использования типа обрабатывают все варианты. Однако это вводит новый тип нарушающих изменений: добавление нового подтипа к запечатанному классу/интерфейсу может сломать код потребителей. Например, добавление нового случая TypeC заставит метод main не скомпилироваться из-за отсутствующего случая. Если вы экспортируете запечатанный тип в своём API, добавление нового подтипа — это нарушающее изменение, требующее увеличения major-версии (если вы следуете SemVer).

Ошибка на этапе компиляции или выполнения?

Хотя Java выдаёт ошибку компиляции для неполного switch в коде потребителя, это возможно только при его переcompilation вместе с изменением запечатанного типа. Например, расширим SealedType ещё одним случаем:

public sealed interface SealedType {
    record TypeA() implements SealedType {}
    record TypeB() implements SealedType {}
    record TypeC() implements SealedType {}

    static SealedType of(String type) {
        return switch (type) {
            case "A" -> new TypeA();
            case "B" -> new TypeB();
            case "C" -> new TypeC();
            default -> throw new IllegalArgumentException();
        };
    }
}

Если переcompилировали только SealedType.java, но не Main, то при срабатывании нового случая получим исключение во время выполнения:

% java Main C          
Exception in thread "main" java.lang.MatchException
	at Main.main(Main.java:3)

Выбрасывается новое исключение MatchException. Javadoc указывает на эту проблему отдельной компиляции и также на граничные случаи с null в паттернах. Поэтому если надеялись, что запечатанные классы статически гарантируют обновление всех потребителей при добавлении нового случая, это не так — только если переcompилировать всё вместе.

Выводы

Запечатанные типы наиболее полезны внутри реализации компоненты, чем при выборе в публичном API, который компонента предлагает другим компонентам (например, библиотеке). При внутреннем использовании, где обычно переcompilируется всё вместе, получаются хорошие свойства проверки исчерпываемости и выше гарантии безопасности на этапе компиляции. Но при использовании через границы модулей вводятся лишь новые способы сломать код, часто обнаруживаемые только во время выполнения.

(Эти тонкости выявил при рецензировании preview-поддержки PEM-кодированных криптографических объектов, которая допускает ту же ошибку — встраивает запечатанный интерфейс в публичный API и рекомендует клиентам использовать pattern matching с этим типом. Предсказываю очень высокую вероятность поломок, если они захотят добавить новый случай).