JEP 397: Sealed Classes (Second Preview)
Sealed Classes (запечатанные классы), вторая версия Preview (предварительная версия)
| Ответственный | Gavin Bierman |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 16 |
| Компонент | specification / language |
| Обсуждение | amber dash dev at openjdk dot java dot net |
| Связан с | JEP 360: Sealed Classes (Preview) |
| JEP 409: Sealed Classes | |
| Рецензенты | Alex Buckley, Brian Goetz |
| Одобрен | Brian Goetz |
| Создан | 2020/06/08 16:26 |
| Обновлён | 2022/03/11 20:15 |
| Задача | 8246775 |
Аннотация
Расширение языка программирования Java sealed-классами и sealed-интерфейсами. Sealed-классы и sealed-интерфейсы ограничивают, какие другие классы или интерфейсы могут их расширять или реализовывать. В JDK 16 это Preview-возможность языка.
История
Sealed Classes были предложены в JEP 360 и вошли в JDK 15 как Preview-возможность.
Этот JEP предлагает повторно выпустить эту возможность в статусе Preview в JDK 16 со следующими уточнениями:
-
Определить понятие контекстного ключевого слова, заменяющее прежние понятия ограниченного идентификатора и ограниченного ключевого слова в JLS. Ввести последовательности символов
sealed,non-sealedиpermitsкак контекстные ключевые слова. -
Как и анонимные классы и лямбда-выражения, локальные классы не могут быть подклассами sealed-классов при определении неявно объявленных разрешённых подклассов класса
sealedили интерфейсаsealed. -
Улучшить сужающее ссылочное преобразование, чтобы при приведении типов выполнялась более строгая проверка с учётом иерархий sealed-типов.
Цели
-
Позволить автору класса или интерфейса управлять тем, какой код отвечает за его реализацию.
-
Предоставить более декларативный, чем модификаторы доступа, способ ограничить использование суперкласса.
-
Поддержать будущее развитие Pattern Matching (сопоставление с образцом), заложив основу для исчерпывающего анализа шаблонов.
Что не является целью
-
Не ставится цель предоставить новые формы управления доступом, такие как «friends».
-
Не ставится цель как-либо изменить
final.
Мотивация
Объектно-ориентированная модель данных — иерархии наследования классов и интерфейсов — показала высокую эффективность при моделировании реальных данных, которые обрабатывают современные приложения. Эта выразительность — важная особенность языка Java.
Однако бывают случаи, когда такую выразительность полезно ограничить. Например, Java поддерживает enum-классы для моделирования ситуации, когда у класса есть только фиксированное число экземпляров. В следующем коде enum-класс перечисляет фиксированный набор планет. Это единственные значения класса, поэтому вы можете выполнить по ним исчерпывающий switch — без необходимости писать ветку default:
enum Planet { MERCURY, VENUS, EARTH }
Planet p = ...
switch (p) {
case MERCURY: ...
case VENUS: ...
case EARTH: ...
}
Использовать enum-классы для моделирования фиксированных наборов значений часто удобно, но иногда мы хотим смоделировать фиксированный набор видов значений. Мы можем сделать это, используя иерархию классов не как механизм наследования и повторного использования кода, а как способ перечислить виды значений. Развивая пример с планетами, мы могли бы смоделировать виды значений в астрономической предметной области так:
interface Celestial { ... }
final class Planet implements Celestial { ... }
final class Star implements Celestial { ... }
final class Comet implements Celestial { ... }
Однако эта иерархия не отражает важного знания о предметной области: в нашей модели есть только три вида небесных объектов. В таких ситуациях ограничение набора подклассов или подынтерфейсов может упростить моделирование.
Рассмотрим другой пример: в графической библиотеке автор класса Shape может предполагать, что расширять Shape могут только определённые классы, поскольку значительная часть работы библиотеки состоит в том, чтобы обрабатывать каждый вид фигуры соответствующим образом. Автору важна ясность кода, обрабатывающего известные подклассы Shape, и он не заинтересован в написании кода для защиты от неизвестных подклассов Shape. Позволить произвольным классам расширять Shape и тем самым наследовать его код для повторного использования в этом случае не является целью. К сожалению, Java исходит из того, что повторное использование кода — всегда цель: если Shape вообще можно расширять, то его может расширять любое число классов. Было бы полезно ослабить это допущение, чтобы автор мог объявить иерархию классов, которая не открыта для расширения произвольными классами. Повторное использование кода по-прежнему было бы возможно внутри такой закрытой иерархии классов, но не за её пределами.
Java-разработчикам знакома идея ограничения набора подклассов, потому что она часто возникает при проектировании API. Язык предоставляет в этой области ограниченные средства: либо сделать класс final, чтобы у него не было подклассов, либо сделать класс или его конструктор package-private (доступным только внутри пакета), чтобы подклассы могли быть только в том же пакете. Пример package-private суперкласса есть в JDK:
package java.lang;
abstract class AbstractStringBuilder { ... }
public final class StringBuffer extends AbstractStringBuilder { ... }
public final class StringBuilder extends AbstractStringBuilder { ... }
Подход с package-private полезен, когда цель — повторное использование кода, например когда подклассы AbstractStringBuilder совместно используют его код для append. Однако этот подход бесполезен, когда цель — моделирование альтернатив, поскольку пользовательский код не может получить доступ к ключевой абстракции — суперклассу, — чтобы выполнить по нему switch. Разрешить пользователям доступ к суперклассу, не разрешая им при этом расширять его, нельзя легко задать, не прибегая к хрупким уловкам с не-public конструкторами, — а они не работают для интерфейсов. В графической библиотеке, объявляющей Shape и его подклассы, было бы досадно, если бы доступ к Shape имел только один пакет.
Итак, суперкласс должен иметь возможность быть широко доступным (поскольку он представляет важную для пользователей абстракцию), но не широко расширяемым (поскольку его подклассы должны ограничиваться теми, что известны автору). Такой суперкласс должен иметь возможность выразить, что он разрабатывается совместно с заданным набором подклассов, — и чтобы задокументировать намерение для читателя, и чтобы компилятор Java мог это обеспечивать. В то же время суперкласс не должен чрезмерно ограничивать свои подклассы, например заставляя их быть final или не давая им определять собственное состояние.
Описание
Расширять или реализовывать 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 { ... }
Если класс объявлен sealed, набор его подклассов ограничен. Пользовательский код может исследовать экземпляр sealed-класса с помощью цепочки if-else из проверок instanceof, по одной проверке на подкласс; универсальная ветка else не нужна. Например, следующий код проверяет три разрешённых подкласса Shape:
Shape rotate(Shape shape, double angle) {
if (shape instanceof Circle) return shape;
else if (shape instanceof Rectangle) return shape.rotate(angle);
else if (shape instanceof Square) return shape.rotate(angle);
// no else needed!
}
Sealed-класс накладывает на свои разрешённые подклассы три ограничения:
-
Sealed-класс и его разрешённые подклассы должны принадлежать одному модулю, а если они объявлены в безымянном модуле, — одному пакету.
-
Каждый разрешённый подкласс должен напрямую расширять sealed-класс.
-
Каждый разрешённый подкласс должен использовать модификатор, описывающий, как он продолжает запечатывание, начатое его суперклассом:
-
Разрешённый подкласс может быть объявлен
final, чтобы его часть иерархии классов нельзя было расширять дальше. (Классы Records (записи) из JEP 395 неявно объявлены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 написать нельзя.)
Класс, который является sealed или non-sealed, может быть abstract и иметь члены abstract. Класс sealed может разрешать подклассы, которые являются abstract, при условии, что они при этом sealed или non-sealed, а не final.
Доступность классов
Поскольку в предложениях extends и permits используются имена классов, разрешённый подкласс и его sealed-суперкласс должны быть доступны друг другу. Однако разрешённые подклассы не обязаны иметь одинаковую доступность ни между собой, ни с sealed-классом. В частности, подкласс может быть менее доступным, чем sealed-класс. Это означает, что в будущем выпуске, когда switch будет поддерживать Pattern Matching, часть кода не сможет выполнить исчерпывающий switch по подклассам, если не использовать ветку default (или другой тотальный шаблон). Компиляторам Java будет рекомендовано обнаруживать случаи, когда switch не так исчерпывающ, как представлял себе его автор, и адаптировать сообщение об ошибке, рекомендуя ветку default.
Sealed-интерфейсы
Как и класс, интерфейс можно запечатать, применив к нему модификатор sealed. После предложения extends, задающего суперинтерфейсы, реализующие классы и подынтерфейсы указываются в предложении permits. Например, пример с планетами из введения можно переписать так:
sealed interface Celestial
permits Planet, Star, Comet { ... }
final class Planet implements Celestial { ... }
final class Star implements Celestial { ... }
final class Comet implements Celestial { ... }
Вот ещё один классический пример иерархии классов с известным набором подклассов: моделирование математических выражений.
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 { ... }
Запечатывание и классы Records
Sealed-классы хорошо сочетаются с классами Records (JEP 395). Классы 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-классов и классов Records иногда называют алгебраическими типами данных: классы Records позволяют нам выражать типы-произведения, а sealed-классы позволяют нам выражать типы-суммы.
Sealed-классы и преобразования
Выражение приведения преобразует значение к типу. Выражение проверки типа instanceof проверяет значение на соответствие типу. Java крайне снисходительна к типам, допустимым в таких выражениях. Например:
interface I {}
class C {} // does not implement I
void test (C c) {
if (c instanceof I)
System.out.println("It's an I");
}
Эта программа допустима, хотя сейчас объект C не может реализовывать интерфейс I. Разумеется, по мере развития программы это может стать возможным:
...
class B extends C implements I {}
test(new B());
// Prints "It's an I"
Правила преобразования типов отражают идею открытой расширяемости. Система типов Java не предполагает замкнутого мира. Классы и интерфейсы могут быть расширены когда-нибудь в будущем, а приведения типов компилируются в проверки во время выполнения, поэтому мы можем безопасно позволить себе гибкость.
Однако на другом конце спектра правила преобразования учитывают случай, когда класс заведомо не может быть расширен, т. е. когда это final-класс.
interface I {}
final class C {}
void test (C c) {
if (c instanceof I)
System.out.println("It's an I");
}
Метод test не компилируется: компилятор знает, что у C не может быть подклассов, а поскольку C не реализует I, значение C никогда не может реализовывать I. Это ошибка компиляции.
А что, если C не final, а sealed? Его прямые подклассы явно перечислены и, по определению sealed, находятся в том же модуле, поэтому мы ожидаем, что компилятор проверит, не может ли он обнаружить аналогичную ошибку компиляции. Рассмотрим следующий код:
interface I {}
sealed class C permits D {}
final class D extends C {}
void test (C c) {
if (c instanceof I)
System.out.println("It's an I");
}
Класс C не реализует I и не является final, поэтому по существующим правилам можно было бы заключить, что преобразование возможно. Однако C является sealed, и у C есть один разрешённый прямой подкласс — D. По определению sealed-типов D должен быть final, sealed или non-sealed. В этом примере все прямые подклассы C являются final и не реализуют I. Поэтому эту программу следует отвергнуть: подтипа C, реализующего I, существовать не может.
Для сравнения рассмотрим похожую программу, в которой один из прямых подклассов sealed-класса является non-sealed:
interface I {}
sealed class C permits D, E {}
non-sealed class D extends C {}
final class E extends C {}
void test (C c) {
if (c instanceof I)
System.out.println("It's an I");
}
Здесь типы корректны, поскольку подтип non-sealed-типа D может реализовывать I.
Этот JEP расширит определение сужающего ссылочного преобразования так, чтобы оно обходило sealed-иерархии и во время компиляции определяло, какие преобразования невозможны.
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 { ... }
Sealed Classes и Pattern Matching
Существенное преимущество Sealed Classes проявится в одном из будущих выпусков в сочетании с Pattern Matching. Вместо того чтобы проверять экземпляр sealed-класса цепочками if-else, пользовательский код сможет использовать switch, расширенный шаблонами проверки типа. Благодаря этому компилятор Java сможет проверять, что шаблоны исчерпывающие.
Например, рассмотрим приведённый ранее код:
Shape rotate(Shape shape, double angle) {
if (shape instanceof Circle) return shape;
else if (shape instanceof Rectangle) return shape.rotate(angle);
else if (shape instanceof Square) return shape.rotate(angle);
// no else needed!
}
Компилятор Java не может гарантировать, что проверки instanceof охватывают все разрешённые подклассы Shape. Например, если бы проверка instanceof Rectangle была пропущена, никакого сообщения об ошибке компиляции не было бы выдано.
Напротив, в следующем коде, где используется выражение switch с Pattern Matching, компилятор может подтвердить, что охвачен каждый разрешённый подкласс Shape, поэтому ветка default (или другой тотальный шаблон) не нужна. Более того, компилятор выдаст сообщение об ошибке, если какой-либо из трёх случаев отсутствует:
Shape rotate(Shape shape, double angle) {
return switch (shape) { // pattern matching switch
case Circle c -> c;
case Rectangle r -> r.rotate(angle);
case Square s -> s.rotate(angle);
// no default needed!
}
}
Грамматика 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.Class[] getPermittedSubclasses()boolean isSealed()
Метод getPermittedSubclasses() возвращает массив объектов java.lang.Class, представляющих разрешённые подклассы класса, если класс объявлен как sealed. Если класс не sealed, возвращается пустой массив.
Метод isSealed возвращает true, если данный класс или интерфейс объявлен как sealed. (Сравните с isEnum.)
Альтернативы
Некоторые языки напрямую поддерживают алгебраические типы данных (ADTs), например возможность data в Haskell. Можно было бы выражать ADT более непосредственно и привычным для Java-разработчиков способом через вариант возможности enum, в котором сумма произведений определялась бы в одном объявлении. Однако это не поддерживало бы все желаемые сценарии использования, например такие, где суммы охватывают классы из более чем одной единицы компиляции, или суммы, охватывающие классы, которые не являются произведениями.
Ветка permits позволяет sealed-классу, такому как показанный ранее класс Shape, быть доступным для вызова из кода в любом модуле, но доступным для реализации только из кода в том же модуле, что и sealed-класс (или в том же пакете, если он находится в безымянном модуле). Благодаря этому система типов оказывается выразительнее системы управления доступом. Если полагаться только на управление доступом, то когда Shape доступен для вызова из кода в любом модуле (потому что его пакет экспортирован), Shape также доступен для реализации в любом модуле; а если Shape недоступен для реализации ни в каком другом модуле, то Shape также недоступен для вызова ни в каком другом модуле.
Зависимости
Sealed Classes не зависят от Records (JEP 395) или Pattern Matching (JEP 394), но хорошо работают с обоими.