openjdk.ruOpenJDK на русском

JEP 409: Sealed Classes

Sealed Classes (запечатанные классы)

ОтветственныйGavin Bierman
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск17
Компонентspecification / language
Обсуждениеamber dash dev at openjdk dot java dot net
Связан сJEP 360: Sealed Classes (Preview)
JEP 397: Sealed Classes (Second Preview)
РецензентыAlex Buckley
ОдобренBrian Goetz
Создан2021/01/27 13:40
Обновлён2024/01/03 01:48
Задача8260514

Аннотация

Добавить в язык программирования Java sealed-классы и sealed-интерфейсы. Sealed-классы и sealed-интерфейсы ограничивают, какие другие классы или интерфейсы могут их расширять или реализовывать.

История

Sealed Classes были предложены в JEP 360 и вошли в JDK 15 как возможность в статусе Preview (предварительная версия). Они были предложены повторно, с доработками, в JEP 397 и вошли в JDK 16 как Preview-возможность. Этот JEP предлагает окончательно утвердить Sealed Classes в JDK 17 без изменений по сравнению с JDK 16.

Цели

  • Позволить автору класса или интерфейса контролировать, какой код отвечает за его реализацию.

  • Предоставить более декларативный, чем модификаторы доступа, способ ограничить использование суперкласса.

  • Поддержать будущие направления развития 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 выведет разрешённые подклассы из объявлений в исходном файле. (Подклассы могут быть вспомогательными или вложенными классами.) Например, если следующий код находится в Root.java, то для sealed-класса Root будут выведены три разрешённых подкласса:

abstract sealed class Root { ... 
    final class A extends Root { ... }
    final class B extends Root { ... }
    final class C extends Root { ... }
}

Классы, указанные в permits, должны иметь каноническое имя, иначе выдаётся ошибка компиляции. Это означает, что анонимные и локальные классы не могут быть разрешёнными подтипами sealed-класса.

Sealed-класс накладывает на свои разрешённые подклассы три ограничения:

  1. Sealed-класс и его разрешённые подклассы должны принадлежать одному модулю, а если они объявлены в безымянном модуле — одному пакету.

  2. Каждый разрешённый подкласс должен непосредственно расширять sealed-класс.

  3. Каждый разрешённый подкласс должен с помощью модификатора указать, как он продолжает запечатывание, начатое его суперклассом:

    • Разрешённый подкласс может быть объявлен final, чтобы его часть иерархии классов нельзя было расширять дальше. (Record-классы неявно объявлены final.)

    • Разрешённый подкласс может быть объявлен sealed, чтобы его часть иерархии можно было расширять дальше, чем предусмотрено его sealed-суперклассом, но ограниченным образом.

    • Разрешённый подкласс может быть объявлен non-sealed, чтобы его часть иерархии снова стала открытой для расширения неизвестными подклассами. Sealed-класс не может запретить своим разрешённым подклассам это сделать. (Модификатор non-sealed — первое ключевое слово с дефисом, предложенное для Java.)

В качестве примера третьего ограничения: Circle и Square могут быть final, а Rectanglesealed, и мы добавляем новый подкласс WeirdShape, который является non-sealed:

package com.example.geometry;

public abstract sealed class Shape
    permits Circle, Rectangle, Square, WeirdShape { ... }

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 final class Square extends Shape { ... }

public non-sealed class WeirdShape extends Shape { ... }

Хотя WeirdShape открыт для расширения неизвестными классами, все экземпляры этих подклассов также являются экземплярами WeirdShape. Поэтому код, который проверяет, является ли экземпляр Shape экземпляром Circle, Rectangle, Square или WeirdShape, остаётся исчерпывающим.

Каждый разрешённый подкласс должен использовать ровно один из модификаторов 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.

Если какой-либо класс расширяет класс, объявленный как sealed, не имея на это разрешения, возникает ошибка компиляции.

Доступность классов

Поскольку в конструкциях extends и permits используются имена классов, разрешённый подкласс и его sealed-суперкласс должны быть доступны друг другу. Однако разрешённые подклассы не обязаны иметь одинаковую доступность ни друг с другом, ни с sealed-классом. В частности, подкласс может быть менее доступен, чем sealed-класс. Это означает, что в будущем выпуске, когда Pattern Matching будет поддерживаться в switch, часть кода не сможет выполнить исчерпывающий switch по подклассам без ветки default (или другого тотального шаблона). Компиляторам Java будет рекомендовано обнаруживать ситуации, когда switch не настолько исчерпывающий, как предполагал его автор, и формулировать сообщение об ошибке так, чтобы рекомендовать ветку default.

Sealed-интерфейсы

Как и класс, интерфейс можно сделать 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 { ... }

Запечатывание и record-классы

Sealed-классы хорошо сочетаются с record-классами. Record-классы неявно являются final, поэтому sealed-иерархия record-классов немного лаконичнее, чем пример выше:

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-классов и record-классов иногда называют алгебраическими типами данных: record-классы позволяют нам выражать типы-произведения, а 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)     // Compile-time error!
        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)     // Compile-time error!
        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.

Таким образом, поддержка sealed-классов приводит к изменению определения сужающего ссылочного преобразования: теперь оно обходит 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 проявится в JEP 406, где предлагается расширить switch возможностью Pattern Matching. Вместо того чтобы проверять экземпляр sealed-класса цепочками if-else, пользовательский код сможет использовать switch, дополненный шаблонами. Благодаря Sealed Classes компилятор Java сможет проверять, что шаблоны исчерпывающие.

Например, рассмотрим следующий код, использующий объявленную ранее иерархию sealed:

Shape rotate(Shape shape, double angle) {
        if (shape instanceof Circle) return shape;
        else if (shape instanceof Rectangle) return shape;
        else if (shape instanceof Square) return shape;
        else throw new IncompatibleClassChangeError();
}

Компилятор Java не может убедиться, что проверки instanceof охватывают все разрешённые подклассы Shape. Последняя ветка else на самом деле недостижима, но компилятор не может это проверить. Что ещё важнее, если бы проверка instanceof Rectangle была пропущена, никакого сообщения об ошибке компиляции не было бы.

Напротив, с Pattern Matching для switch (JEP 406) компилятор может подтвердить, что каждый разрешённый подкласс Shape охвачен, поэтому ветка default или другой тотальный шаблон не нужны. Более того, если какой-либо из трёх случаев отсутствует, компилятор выдаст сообщение об ошибке:

Shape rotate(Shape shape, double angle) {
    return switch (shape) {   // pattern matching switch
        case Circle c    -> c; 
        case Rectangle r -> shape.rotate(angle);
        case Square s    -> shape.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 — модификатор класса, флага ACC_SEALED в структуре ClassFile нет. Вместо этого файл 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-методы:

  • Class<?>[] getPermittedSubclasses()
  • boolean isSealed()

Метод getPermittedSubclasses() возвращает массив объектов java.lang.Class, представляющих разрешённые подклассы класса, если класс sealed. Если класс не sealed, метод возвращает пустой массив.

Метод isSealed возвращает true, если данный класс или интерфейс sealed. (Сравните с isEnum.)

Дальнейшая работа

Распространённый приём, особенно при написании API, — объявить публичный тип как интерфейс и реализовать его одним приватным классом. С Sealed Classes это можно выразить точнее: как публичный sealed-интерфейс с единственной разрешённой приватной реализацией. Тогда тип широко доступен, а реализация — нет, и её никак нельзя расширить.

public sealed interface Foo permits MyFooImpl { } 
private final class MyFooImpl implements Foo { }

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

void m(Foo f) { 
    MyFooImpl mfi = (MyFooImpl) f;
    ...
}

Приведение здесь кажется лишним, поскольку мы знаем, что оно всегда должно быть успешным. Однако в приведении есть неявное семантическое допущение: класс MyFooImpl — единственная реализация Foo. Автор никак не может выразить это интуитивное знание так, чтобы его можно было проверить на этапе компиляции. Если со временем Foo разрешит ещё одну реализацию, это приведение останется корректным по типам, но может завершиться ошибкой во время выполнения. Иначе говоря, семантическое допущение будет нарушено, но компилятор не сможет предупредить об этом разработчика.

Благодаря точности sealed-иерархий, возможно, стоит дать разработчикам средства для выражения таких семантических допущений, а компилятору — возможность их проверять. Этого можно добиться, добавив новую форму ссылочного преобразования для контекстов присваивания, которая позволяет преобразовывать sealed-супертип в его единственный подтип, например:

MyFooImpl mfi = f; // Allowed because the compiler sees that MyFooImpl
                   // is the only permitted subtype of Foo.
                   // (A synthetic cast would be added for safety.)

Другой вариант — ввести новую форму приведения типов, например:

MyFooImpl mfi = (total MyFooImpl) f;

В обоих случаях, если интерфейс Foo изменить так, чтобы он разрешал ещё одну реализацию, то при повторной компиляции оба варианта приведут к ошибкам компиляции.

Альтернативы

В некоторых языках есть прямая поддержка алгебраических типов данных (ADT), например возможность data в Haskell. Можно было бы выражать ADT более непосредственно и привычным для Java-разработчиков способом с помощью разновидности возможности enum, в которой сумму произведений можно определить в одном объявлении. Однако это не покрыло бы все желаемые сценарии использования, например такие, где суммы охватывают классы из нескольких единиц компиляции, или суммы, охватывающие классы, которые не являются произведениями.

Конструкция permits позволяет sealed-классу, такому как показанный ранее класс Shape, быть доступным для вызова из кода в любом модуле, но доступным для реализации только из кода в том же модуле, что и sealed-класс (или в том же пакете, если он в безымянном модуле). Благодаря этому система типов оказывается выразительнее, чем система управления доступом. При одном лишь управлении доступом, если Shape доступен для вызова из кода в любом модуле (потому что его пакет экспортирован), то Shape также доступен для реализации в любом модуле; а если Shape недоступен для реализации ни в каком другом модуле, то Shape также недоступен для вызова ни в каком другом модуле.

Зависимости

Sealed Classes не зависят ни от каких других JEP. Как упоминалось ранее, JEP 406 предлагает расширить switch возможностью Pattern Matching и опирается на Sealed Classes, чтобы улучшить проверку исчерпываемости switch.