Запечатанные классы и исчерпывающая проверка
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 с этим типом. Предсказываю очень высокую вероятность поломок, если они захотят добавить новый случай).