Java: запечатанные классы в публичном API могут ломать код при запуске
Автор технического блога разбирает запечатанные классы (sealed classes), появившиеся в Java 17. Они позволяют явно перечислить допустимые подтипы интерфейса или базового класса; если список не указан, вложенные классы добавляются в число разрешённых неявно. В игрушечном примере, запечатанный интерфейс SealedType с записями (records) TypeA и TypeB, автор показывает, что switch по такому типу не требует ветки default: sealed-классы (как и enum) включают проверку полноты, то есть компилятор знает все возможные случаи и проверяет, что они покрыты. Если забыть один случай и не писать default, получится ошибка компиляции. Автор проводит параллель с функциональными языками, где есть алгебраические типы данных (объявление data в Haskell или ML).
Обратная сторона, новый вид ломающего изменения. Добавление нового подтипа в запечатанный класс или интерфейс может сломать потребителей кода: если в пример добавить TypeC, метод main перестанет компилироваться из-за отсутствующей ветки. Поэтому если запечатанный тип выставлен в API, добавление подтипа, ломающее изменение, которое по SemVer требует повышения мажорной версии.
Главная мысль статьи, различие между ошибкой компиляции и ошибкой времени выполнения. Компилятор выдаёт ошибку для неполного switch только тогда, когда компилируется потребитель (в примере, Main). Если после изменения запечатанного типа перекомпилировать только SealedType.java, а Main не трогать, то при срабатывании нового случая (запуск java Main C) программа падает с исключением java.lang.MatchException. В Javadoc об этой проблеме раздельной компиляции сказано, там же упомянуты крайние случаи с null в шаблонах. Таким образом, надежда, что sealed-классы статически гарантируют обновление всех потребителей при добавлении случая, не оправдывается, пока не перекомпилировано всё.
Вывод автора, выраженный как личное мнение («я думаю», «вероятно»): sealed-типы полезнее всего внутри реализации компонента и менее полезны в публичном API, который компонент предлагает другим (например, в библиотеке). Внутри, где обычно всё компилируется вместе, работают проверка полноты и более высокие гарантии безопасности на этапе компиляции. На границах модулей же можно получить новые способы поломать код, часто обнаруживаемые только при выполнении.
Эти тонкости автор заметил, рецензируя предварительную (preview) поддержку PEM-кодированных криптографических объектов: по его словам, она делает именно эту ошибку, закладывает запечатанный интерфейс в публичный API и рекомендует клиентам делать сопоставление с образцом по этому типу. Автор предсказывает очень высокий шанс поломок, если разработчики захотят добавить новый случай.
Ключевые факты
- Запечатанные классы (sealed) появились в Java 17 и позволяют явно перечислить допустимые подтипы; вложенные классы без явного списка добавляются неявно.
- Sealed-классы и enum включают проверку полноты: switch без ветки default компилируется, а пропущенный случай даёт ошибку компиляции.
- Добавление подтипа в запечатанный тип, выставленный в API, ломающее изменение (по SemVer нужна новая мажорная версия).
- Если потребитель не перекомпилирован после изменения запечатанного типа, вместо ошибки компиляции при запуске выбрасывается MatchException.
- Вывод автора (его мнение): sealed-типы лучше держать внутри реализации компонента, а не в публичном API; пример ошибки он видит в preview-поддержке PEM-объектов.
Почему это важно
Проверка полноты switch выглядит как чистый выигрыш: компилятор сам находит забытые случаи. Статья показывает, где эта гарантия заканчивается: она работает только при компиляции потребителя. Для библиотек, чей код компилируется и распространяется отдельно от кода клиентов, это означает, что ошибка, которую ждали на этапе сборки, может проявиться уже в работающей программе.
Кому это важно
Прежде всего Java-разработчикам, которые проектируют библиотеки и публичные API на Java 17 и новее и подумывают выставить sealed-интерфейс наружу. Также тем, кто сопровождает версии по SemVer: добавление подтипа к запечатанному типу, по словам автора, должно считаться ломающим изменением.
Как это применить
Автор советует использовать sealed-типы внутри реализации компонента, где обычно всё перекомпилируется вместе и работают проверка полноты и гарантии времени компиляции. В публичном API такой тип лучше не выставлять; конкретной альтернативной схемы для API в статье не предлагается. Если же sealed-тип экспортирован, добавление подтипа стоит считать ломающим изменением и повышать мажорную версию.
Можно ли доверять
Это авторское рассуждение одного блогера, а не результат измерений: примеры игрушечные, замеров и реальных случаев сбоев в тексте нет. Само поведение (ошибка компиляции при перекомпиляции потребителя и MatchException без неё) показано на коротком воспроизводимом коде и упомянуто в Javadoc. Вывод про публичные API автор сам подаёт как личное мнение. Имя автора и дата публикации в тексте не указаны.
Риски и подводные камни
Главный риск, ложное чувство безопасности: при раздельной компиляции проверка полноты не срабатывает, а сбой наступает в рантайме и только на новой ветке, поэтому его легко пропустить при тестировании. Кроме того, в Javadoc упомянуты крайние случаи с null в шаблонах. Прогноз автора про PEM-API, это его предположение, а не зафиксированный факт поломки.