JEP 360: Sealed Classes (Preview)
Sealed Classes (запечатанные классы), версия Preview (предварительная версия)
| Автор | Brian Goetz |
| Ответственный | Vicente Arturo Romero Zaldivar |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 15 |
| Компонент | specification / language |
| Обсуждение | amber dash dev at openjdk dot java dot net |
| Трудоёмкость | M |
| Длительность | M |
| Связан с | JEP 397: Sealed Classes (Second Preview) |
| JEP 409: Sealed Classes | |
| Рецензенты | Alex Buckley |
| Одобрен | Mark Reinhold |
| Создан | 2019/07/01 19:45 |
| Обновлён | 2022/03/11 20:20 |
| Задача | 8227043 |
Аннотация
Добавить в язык программирования Java Sealed Classes и sealed-интерфейсы. Sealed-классы и sealed-интерфейсы ограничивают, какие другие классы или интерфейсы могут их расширять или реализовывать. В JDK 15 это возможность языка в статусе Preview.
Цели
- Дать автору класса или интерфейса возможность контролировать, какой код отвечает за его реализацию.
- Предоставить более декларативный, чем модификаторы доступа, способ ограничить использование суперкласса.
- Поддержать будущие направления развития Pattern Matching (сопоставление с образцом), заложив основу для исчерпывающего анализа шаблонов.
Что не является целью
- Цель не в том, чтобы предоставить новые формы контроля доступа, например «друзей».
- Цель не в том, чтобы как-либо изменить
final.
Мотивация
В Java иерархия классов позволяет повторно использовать код через наследование: методы суперкласса могут наследоваться (и тем самым повторно использоваться) многими подклассами. Однако иерархия классов не всегда нужна для повторного использования кода. Иногда её назначение — смоделировать различные варианты, существующие в предметной области, например виды фигур, поддерживаемые графической библиотекой, или виды кредитов, поддерживаемые финансовым приложением. Когда иерархия классов используется таким образом, ограничение набора подклассов может упростить моделирование.
Например, в графической библиотеке автор класса Shape может предполагать, что расширять Shape смогут только определённые классы, поскольку значительная часть работы библиотеки состоит в том, чтобы обрабатывать каждый вид фигуры подходящим образом. Автору важна ясность кода, обрабатывающего известные подклассы Shape, и не нужно писать код для защиты от неизвестных подклассов Shape. Дать произвольным классам возможность расширять Shape и тем самым наследовать его код для повторного использования в этом случае не является целью. К сожалению, Java исходит из того, что повторное использование кода — всегда цель: если Shape вообще можно расширить, то его может расширить любое количество классов. Было бы полезно ослабить это допущение, чтобы автор мог объявить иерархию классов, которая не открыта для расширения произвольными классами. Повторное использование кода по-прежнему было бы возможно внутри такой закрытой иерархии классов, но не за её пределами.
Java-разработчикам знакома идея ограничения набора подклассов, потому что она часто возникает при проектировании API. Язык предоставляет в этой области ограниченные средства: либо сделать класс final, чтобы у него не было ни одного подкласса, либо сделать класс или его конструктор доступным только в пределах пакета (package-private), чтобы подклассы могли быть только в том же пакете. Пример суперкласса, доступного только в пределах пакета, есть в JDK:
package java.lang;
abstract class AbstractStringBuilder {...}
public final class StringBuffer extends AbstractStringBuilder {...}
public final class StringBuilder extends AbstractStringBuilder {...}
Подход с доступом в пределах пакета полезен, когда цель — повторное использование кода, например когда подклассы AbstractStringBuilder совместно используют его код для append. Однако этот подход бесполезен, когда цель — моделирование альтернатив, поскольку пользовательский код не может обратиться к ключевой абстракции — суперклассу, — чтобы выполнить по нему switch. Невозможно дать пользователям доступ к суперклассу, не разрешив им также его расширять. (Даже внутри графической библиотеки, которая объявляет Shape и его подклассы, было бы неудачно, если бы обращаться к Shape мог только один пакет.)
Итак, должна быть возможность сделать суперкласс широко доступным (поскольку он представляет важную для пользователей абстракцию), но не широко расширяемым (поскольку его подклассы должны ограничиваться теми, что известны автору). Такой суперкласс должен иметь возможность выразить, что он разрабатывается совместно с заданным набором подклассов, — как для того, чтобы задокументировать намерение для читателя, так и для того, чтобы компилятор Java мог это обеспечивать. В то же время суперкласс не должен чрезмерно ограничивать свои подклассы, например заставляя их быть final или не давая им определять собственное состояние.
Описание
Sealed-класс или sealed-интерфейс может быть расширен или реализован только теми классами и интерфейсами, которым это разрешено.
Класс становится sealed-классом, если применить к его объявлению модификатор sealed. Затем, после всех предложений extends и implements, предложение permits указывает классы, которым разрешено расширять sealed-класс. Например, следующее объявление Shape задаёт три разрешённых подкласса:
package com.example.geometry;
public abstract sealed class Shape
permits Circle, Rectangle, Square {...}
Классы, указанные в permits, должны располагаться рядом с суперклассом: либо в том же модуле (если суперкласс находится в именованном модуле), либо в том же пакете (если суперкласс находится в безымянном модуле). Например, в следующем объявлении Shape все его разрешённые подклассы расположены в разных пакетах одного и того же именованного модуля:
package com.example.geometry;
public abstract sealed class Shape
permits com.example.polar.Circle,
com.example.quad.Rectangle,
com.example.quad.simple.Square {...}
Когда разрешённые подклассы невелики по размеру и немногочисленны, может быть удобно объявить их в том же исходном файле, что и sealed-класс. Если они объявлены таким образом, класс sealed может опустить предложение permits, и компилятор Java выведет разрешённые подклассы из объявлений в исходном файле (это могут быть вспомогательные или вложенные классы). Например, если следующий код находится в Shape.java, то для sealed-класса Shape будет выведено три разрешённых подкласса:
package com.example.geometry;
abstract sealed class Shape {...}
... class Circle extends Shape {...}
... class Rectangle extends Shape {...}
... class Square extends Shape {...}
Цель запечатывания класса — дать клиентскому коду возможность ясно и окончательно рассуждать обо всех разрешённых подклассах. Традиционный способ рассуждать о подклассах — цепочка if-else из проверок instanceof, но анализировать такие цепочки компилятору сложно, поэтому он не может определить, что проверки охватывают все разрешённые подклассы. Например, следующий метод вызовет ошибку компиляции, потому что компилятор не разделяет уверенности разработчика в том, что каждый подкласс Shape проверяется и приводит к оператору return:
int getCenter(Shape shape) {
if (shape instanceof Circle) {
return ... ((Circle)shape).center() ...
} else if (shape instanceof Rectangle) {
return ... ((Rectangle)shape).length() ...
} else if (shape instanceof Square) {
return ... ((Square)shape).side() ...
}
}
Добавление универсального предложения else противоречило бы уверенности разработчика в том, что проверки уже исчерпывающие. Кроме того, компилятор никак не может уберечь разработчика, если его уверенность окажется ошибочной. Предположим, что в приведённый выше код случайно внесли правку, убрав, скажем, проверку instanceof Rectangle; ошибки компиляции не возникнет. (При трёх разрешённых подклассах такой пропуск, возможно, легко заметить, но не при 10 или 20. Даже при трёх такой код неприятно писать и утомительно читать.)
Возможность ясно и окончательно рассуждать о разрешённых подклассах будет реализована в одном из будущих выпусков, поддерживающем Pattern Matching. Вместо того чтобы проверять экземпляр sealed-класса с помощью if-else, клиентский код сможет выполнять switch по экземпляру с использованием шаблонов проверки типа (JEP 375). Так компилятор сможет проверить, что шаблоны являются исчерпывающими. Например, для следующего кода компилятор выведет, что каждый разрешённый подкласс Shape охвачен, поэтому предложение default (или другой тотальный шаблон) не нужно; более того, компилятор выдаст ошибку, если какой-либо из трёх случаев отсутствует:
int getCenter(Shape shape) {
return switch (shape) {
case Circle c -> ... c.center() ...
case Rectangle r -> ... r.length() ...
case Square s -> ... s.side() ...
};
}
Sealed-класс накладывает на свои разрешённые подклассы (классы, указанные в его предложении permits) три ограничения:
-
Sealed-класс и его разрешённые подклассы должны принадлежать одному и тому же модулю, а если они объявлены в безымянном модуле, то одному и тому же пакету.
-
Каждый разрешённый подкласс должен напрямую расширять sealed-класс.
-
Каждый разрешённый подкласс должен выбрать модификатор, описывающий, как он продолжает запечатывание, начатое его суперклассом:
- Разрешённый подкласс может быть объявлен
final, чтобы его часть иерархии классов нельзя было расширять дальше. - Разрешённый подкласс может быть объявлен
sealed, чтобы его часть иерархии можно было расширять дальше, чем предусмотрено его sealed-суперклассом, но ограниченным образом. - Разрешённый подкласс может быть объявлен
non-sealed, чтобы его часть иерархии снова стала открытой для расширения неизвестными подклассами. (Sealed-класс не может запретить своим разрешённым подклассам это делать.)
В качестве примера третьего ограничения: Circle может быть final, тогда как Rectangle — sealed, а Square — non-sealed:
package com.example.geometry;
public abstract sealed class Shape
permits Circle, Rectangle, Square {...}
public final class Circle extends Shape {...}
public sealed class Rectangle extends Shape
permits TransparentRectangle, FilledRectangle {...}
public final class TransparentRectangle extends Rectangle {...}
public final class FilledRectangle extends Rectangle {...}
public non-sealed class Square extends Shape {...}
Каждый разрешённый подкласс должен использовать один и только один из модификаторов final, sealed и non-sealed. Класс не может быть одновременно sealed (что подразумевает подклассы) и final (что подразумевает отсутствие подклассов), или одновременно non-sealed (что подразумевает подклассы) и final (что подразумевает отсутствие подклассов), или одновременно sealed (что подразумевает ограниченные подклассы) и non-sealed (что подразумевает неограниченные подклассы).
(Модификатор final можно считать сильной формой запечатывания, при которой расширение/реализация запрещены полностью. То есть final концептуально равнозначен sealed + предложение permits, в котором ничего не указано; обратите внимание, что такое предложение permits нельзя записать на Java.)
Абстрактные классы. Класс, который является sealed или non-sealed, может быть abstract и иметь члены abstract. Класс sealed может разрешать подклассы, которые являются abstract (при условии, что они тогда будут sealed или non-sealed, а не final).
Доступность классов. Поскольку в предложениях extends и permits используются имена классов, разрешённый подкласс и его sealed-суперкласс должны быть доступны друг другу. Однако разрешённые подклассы не обязаны иметь ту же доступность, что и друг у друга или у sealed-класса. В частности, подкласс может быть менее доступен, чем sealed-класс; это означает, что в одном из будущих выпусков, когда Pattern Matching будет поддерживаться в switch, некоторые пользователи не смогут выполнить исчерпывающий switch по подклассам, если не использовать предложение default (или другой тотальный шаблон). Компиляторам Java будет рекомендовано обнаруживать случаи, когда switch пользователя не так исчерпывающ, как пользователь предполагал, и адаптировать сообщение об ошибке, рекомендуя предложение default.
Sealed-интерфейсы
Как и в случае с классами, интерфейс становится sealed-интерфейсом, если применить к нему модификатор sealed. После всех предложений extends, задающих суперинтерфейсы, реализующие классы и подынтерфейсы указываются в предложении permits. Например:
package com.example.expression;
public sealed interface Expr
permits ConstantExpr, PlusExpr, TimesExpr, NegExpr {...}
public final class ConstantExpr implements Expr {...}
public final class PlusExpr implements Expr {...}
public final class TimesExpr implements Expr {...}
public final class NegExpr implements Expr {...}
Sealed Classes и Records (записи)
Sealed Classes хорошо сочетаются с Records (JEP 384) — ещё одной Preview-возможностью Java 15. Records неявно являются final, поэтому sealed-иерархия с Records немного лаконичнее приведённого выше примера:
package com.example.expression;
public sealed interface Expr
permits ConstantExpr, PlusExpr, TimesExpr, NegExpr {...}
public record ConstantExpr(int i) implements Expr {...}
public record PlusExpr(Expr a, Expr b) implements Expr {...}
public record TimesExpr(Expr a, Expr b) implements Expr {...}
public record NegExpr(Expr e) implements Expr {...}
Сочетание Sealed Classes и Records иногда называют алгебраическими типами данных: Records позволяют нам выражать типы-произведения, а Sealed Classes — типы-суммы.
Sealed Classes в JDK
Пример того, как Sealed Classes могли бы использоваться в JDK, — пакет java.lang.constant, моделирующий дескрипторы сущностей JVM:
package java.lang.constant;
public sealed interface ConstantDesc
permits String, Integer, Float, Long, Double,
ClassDesc, MethodTypeDesc, DynamicConstantDesc {...}
// ClassDesc is designed for subclassing by JDK classes only
public sealed interface ClassDesc extends ConstantDesc
permits PrimitiveClassDescImpl, ReferenceClassDescImpl {...}
final class PrimitiveClassDescImpl implements ClassDesc {...}
final class ReferenceClassDescImpl implements ClassDesc {...}
// MethodTypeDesc is designed for subclassing by JDK classes only
public sealed interface MethodTypeDesc extends ConstantDesc
permits MethodTypeDescImpl {...}
final class MethodTypeDescImpl implements MethodTypeDesc {...}
// DynamicConstantDesc is designed for subclassing by user code
public non-sealed abstract class DynamicConstantDesc implements ConstantDesc {...}
Грамматика Java
NormalClassDeclaration:
{ClassModifier} class TypeIdentifier [TypeParameters]
[Superclass] [Superinterfaces] [PermittedSubclasses] ClassBody
ClassModifier:
(one of)
Annotation public protected private
abstract static sealed final non-sealed strictfp
PermittedSubclasses:
permits ClassTypeList
ClassTypeList:
ClassType {, ClassType}
Поддержка Sealed Classes в JVM
Виртуальная машина Java распознаёт классы и интерфейсы sealed во время выполнения и не допускает расширения неразрешёнными подклассами и подынтерфейсами.
Хотя sealed — модификатор класса, в структуре ClassFile нет флага ACC_SEALED. Вместо этого файл class sealed-класса содержит атрибут PermittedSubclasses, который неявно обозначает модификатор sealed и явно указывает разрешённые подклассы:
PermittedSubclasses_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 number_of_classes;
u2 classes[number_of_classes];
}
Список разрешённых подклассов обязателен — даже когда разрешённые подклассы выводятся компилятором, эти выведенные подклассы явно включаются в атрибут PermittedSubclasses.
Файл class разрешённого подкласса не содержит новых атрибутов.
Когда JVM пытается определить класс, у суперкласса или суперинтерфейса которого есть атрибут PermittedSubclasses, определяемый класс должен быть назван в этом атрибуте. В противном случае выбрасывается IncompatibleClassChangeError.
API рефлексии
В java.lang.Class будут добавлены следующие методы public:
java.lang.constant.ClassDesc[] getPermittedSubclasses()boolean isSealed()
Метод getPermittedSubclasses() возвращает массив объектов java.lang.constant.ClassDesc, представляющих все разрешённые подклассы класса, если класс является sealed-классом, и возвращает пустой массив, если класс не является sealed-классом.
Метод isSealed возвращает true, если данный класс или интерфейс является sealed. (Сравните с isEnum.)
Альтернативы
Некоторые языки напрямую поддерживают алгебраические типы данных (ADT), например, возможность data в Haskell. Можно было бы выражать ADT более непосредственно и привычным для Java-разработчиков образом через вариант возможности enum, в котором сумму произведений можно было бы определить в одном объявлении. Однако такой подход поддерживал бы не все нужные сценарии использования, например те, где сумма охватывает классы из более чем одной единицы компиляции, или суммы, охватывающие классы, которые не являются произведениями.
Предложение permits позволяет sealed-классу, такому как показанный ранее класс Shape, быть доступным для вызова из кода в любом модуле, но доступным для реализации только из кода в том же модуле, что и sealed-класс (или в том же пакете, если он находится в безымянном модуле). Благодаря этому система типов оказывается выразительнее системы контроля доступа. При одном лишь контроле доступа, если Shape доступен для вызова из кода в любом модуле (потому что его пакет экспортирован), то Shape также доступен для реализации в любом модуле; а если Shape недоступен для реализации ни в одном другом модуле, то Shape также недоступен для вызова ни в одном другом модуле.
Зависимости
Sealed Classes не зависят от Records (JEP 384) и Pattern Matching (JEP 375), но хорошо работают и с тем, и с другим.