company_banner

JEP 360: Sealed Types (Preview)

Автор оригинала: Brian Goetz
  • Перевод


В то время, как половина мира заточилась в самоизоляции, ходят слухи, что Java 15 может присоединиться к флешмобу. Если в Бельгии нельзя размножаться людям, то почему классы должны наследоваться бесконтрольно, разве это справедливо?


Задача этого JEP — описать улучшение языка программирования Java, новую фичу под названием «sealed types» (изолированные типы). Изолированные типы — это такие классы и интерфейсы, которые могут запрещать наследовать или реализовывать себя.


Цели и задачи


  • Дать автору типа контроль над тем, какой код будет реализовывать этот тип;
  • Предоставить механизм ограничений на использование суперкласса, более декларативный по сравнению с модификаторами доступа;
  • Поддержать развитие паттерн-матчинга, создав фундамент для полного анализа паттернов.

Не является целью


  • Не предполагается делать новые способы контроля доступа, вроде friend;
  • Никак не предполагается менять работу final.

Мотивация


Иерархия классов Java поддерживает наследование как мощнейшую технологию переиспользования кода. Методы суперкласса можно унаследовать в подклассах. Тем не менее, задачи иерархии классов не всегда ограничиваются переиспользованием кода. Иногда, цель иерархии — моделирование различных ситуаций из предметной области, вроде разных видов геометрических фигур в графических библиотеках, или всевозможных видов займов из финансовых приложений. Когда иерархия классов используется таким способом, возможность ограничить набор подклассов может упростить моделирование.


Например, в графической библиотеке, автор класса Shape может подразумевать, что только лишь определенный набор классов может наследоваться от Shape, поскольку большая часть работы этой библиотеки — обеспечить правильную отрисовку каждого конкретного типа фигуры. Автор заинтересован в чистоте кода, который обрабатывает каждый из известных подклассов Shape, и совершенно не интересуется защитой от неизвестных подклассов Shape. Разрешать произвольным классам наследовать Shape, используя наследование ради переиспользования кода, для этого разработчика совершенно неинтересно. К сожалению, Java всегда заботится о переиспользовании: если Shape может наследовать хоть кто-то, его могут наследовать все на свете. Полезно было бы ослабить требования: что если автор сможет описать иерархию классов, которую нельзя будет наследовать с помощью каких-то других произвольных классов. Переиспользование кода никуда не денется, но будет работать только внутри этой "закрытой" иерархии классов, и ни шагу дальше.


Java-разработчики хорошо знакомы с идеей ограничения набора подклассов, поскольку это всегда вылезает в дизайне API. Инструменты языка тут довольно ограничены: можно или сделать класс final, и у него не будет подклассов, или сделать класс (или его конструтор) package-private, и тогда наследники у него возможны только внутри того же самого пакета. Пример того, как package-private суперклассы используются в JDK (реальные примеры можно самостоятельно поглядеть в OpenJDK):


package java.lang;

abstract class AbstractStringBuilder {...}
public final class StringBuffer  extends AbstractStringBuilder {...}
public final class StringBuilder extends AbstractStringBuilder {...}

Подход с package-private полезен, когда наша цель — обеспечить переиспользование кода, вроде подклассов AbstractStringBuilder, что-то добавляющих к коду родителя. Тем не менее, подход этот бесполезен при моделировании альтернатив, поскольку ключевая абстракция — суперкласс — не может использоваться в пользовательском коде. Невозможно дать пользователям доступ до суперкласса без того, чтобы разрешить им ещё и наследоваться от него! (Даже внутри графической библиотеки, которая определяет Shape и его подклассы, довольно неприятно, когда лишь один пакет может использовать Shape).


В итоге хочется, чтобы суперкласс мог быть доступен отовсюду для использования (он является важной абстракцией для пользователей), но недотупен для наследования (набор подклассов должен быть ограничен лишь теми, о ком знает автор). Такие суперклассы должны уметь явно показать всем, что они предназначены для использования только вместе с конкретным набором классов-наследников. Это нужно как для читателя кода (чтобы осознать намерения автора), так для компилятора Java (чтобы проверить правильность использования). С другой стороны, этот суперкласс не должен излишне ограничивать свои подклассы, например, заставив всех использовать final или отказаться от хранения собственного состояния.


Подробности


Изолированный класс или интерфейс можно наследовать или реализовывать только тем классам и интерфейсам, которым это разрешили.


Класс запечатывается применением модификатора sealed прямо в определении класса. После всех extends-ов и implements-ов, отдельный список permits перечисляет классы, которым разрешено наследоваться от этого изолированного класса. Например, следующее определение класса Shape описывает три возможных подкласса:


package com.example.geometry;

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

Классы, описанные в permits должны находиться рядом с суперклассами: или в том же модуле (если суперкласс находится в именованном модулей), или в том же пакете (если суперкласс не находится в именованом модуле). Например, в следующем определении Shape, все разрешенные подклассы хоть и находятся в разных пакетах, но сгруппированы в один и тот же модуль:


package com.example.geometry;

public sealed class Shape 
    permits com.example.polar.Circle,
            com.example.quad.Rectangle,
            com.example.quad.simple.Square {...}

Если эти разрешенные подклассы очень небольшие (по количеству кода и по количеству в штуках), может оказаться удобным определить их в том же файле исходника, что и сам изолированный класс. В таком случае, в изолированном классе можно не писать permits, компилятор Java сам поймёт и выведет список разрешенных подклассов из определений в этом исходнике (то есть, auxilliary и nested классы). Например, в следующем коде из файла Shape.java компилятор найдет один изолированный класс Shape с тремя выведенными разрешенными подклассами:


package com.example.geometry;

sealed class Shape {...}
... class Circle    extends Shape {...}
... class Rectangle extends Shape {...}
... class Square    extends Shape {...}

И вот теперь можно в пользовательском коде использовать внутри цепочки instanceof без необходимости добавлять общий catch:


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);
}

К сожалению, компилятор Java не может проверить, покрывают ли проверки на instanceof все разрешенные подклассы Shape. Например, если пропустить instanceof Rectangle, никакой ошибки компиляции не произойдет.


Реальные преимущества изолированных типов проявляются в сочетании с паттерн-матчингом. Вместо того, чтобы проверять каждый экземпляр класса конструкцией if-else, пользовательский код делает свич по экземплярам, используя паттерны проверки из JEP 375. Это позволяет компилятору Java понять, что произошел полный (exhaustive) охват вариантов. Например, в нижеследующем коде компилятор делает вывод, что охвачены все разрешенные подклассы Shape, и поэтому не нужно описывать ветку default. Более того, компилятор выдаст ошибку компиляции, если хоть один из трёх вариантов потеряется:


Shape rotate(Shape shape, double angle) {
    return switch (shape) {
        case Circle c    -> c;  // no action needed
        case Rectangle r -> r.rotate(angle);
        case Square s    -> s.rotate(angle);
    }
}

Sealed class накладывает три ограничения на разрешенные подклассы (те, что перечислены в списке permits):


  1. Каждый разрешенный подкласс должен быть доступен из изолированного суперкласса во время компиляции. Подклассы не должны быть одинаково доступны ни между собой, ни повторять уровень доступа до изолированного суперкласса. В частности, подкласс может быть менее доступен, чем изолированный суперкласс. То есть, существует ситуация, когда пользователь не сможет полностью перебрать все варианты в свиче, кроме как прибегая к default (или другому всеохватывающему паттерну). Компилятор Java в этом случае постарается понять, что написанное пользователем не отражает намерение перебрать все варианты, и посоветует добавить вариант default;
  2. Каждый разрешенный подкласс должен напрямую наследовать изолированный класс;
  3. Каждый разрешенный подкласс должен выбрать модификатор, показывающий, собирается ли он продолжать процесс изоляции, инициированный своим суперклассом.

  • Разрешенный подкласс может быть описан как final чтоб запретить дальнейшее наследование на этом участке иерархии классов;
  • Разрешенный подкласс может быть описан как sealed, позволяя этой части иерархии продолжать цепочку наследования дальше, чем это предвидел суперкласс, но в безопасном стиле;
  • Разрешенный подкласс может быть описан как non-sealed, и тогда этот участок иерархии возвращается к старой схеме — он открыт для наследования неизвестными подклассами. (Родительский изолированный класс не может запретить разрешенным подклассам заниматься такими фокусами).

Примеры всех трех модификаторов:


package com.example.geometry;

public 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 (подразумевается, что подклассов нет). Или одновременно sealed (подразумеваются ограниченные подклассы) и non-sealed (подразумеваются неограниченные подклассы). Или одновременно non-sealed (подразумевается наличие подклассов) и final (подразумевается отсутствие подклассов).


Модификатор final можно считать сильной формой изоляции, в которой наследование или реализация полностью запрещены. Концептуально, final эквивалентен sealed + permits с пустым списком разрешенных подклассов. Заметьте, что такой пустой permits в Java будет написать нельзя.


Изолированные интерфейсы


Интерфейсы изолируются точно так же, как и классы — применением модификатора 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 {...}

Изолированные типы и записи (records)


Изолированные типы хорошо работают с записями (JEP 359), превью которых появилось в Java 14. Записи неявно являются final, поэтому изолированная иерархия записей выглядит немного более лаконично, чем примеры выше:


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 {...}

Изолированные типы и записи вместе иногда называют алгебраическими типами данных. Записи позволяют записывать типы-произведения, а изолированные типы — типы-суммы.


Изолированные типы в JDK


В качестве примера изолированных типов можно использовать пакет java.lang.constant package, моделирующий дескрипторы сущностей в JVM:


package java.lang.constant;

public sealed interface ConstantDesc
    permits String, Integer, Float, Long, Double,
            ClassDesc, MethodTypeDesc, DynamicConstantDesc {...}

// ClassDesc предназначен для наследования только классами JDK
public sealed interface ClassDesc extends ConstantDesc
    permits PrimitiveClassDescImpl, ReferenceClassDescImpl {...}
final class PrimitiveClassDescImpl implements ClassDesc {...}
final class ReferenceClassDescImpl implements ClassDesc {...} 

// MethodTypeDesc предназначен для наследования только классами JDK
public sealed interface MethodTypeDesc extends ConstantDesc
    permits MethodTypeDescImpl {...}
final class MethodTypeDescImpl implements MethodTypeDesc {...}

// DynamicConstantDesc предназначен для наследования пользовательским кодом
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 — модификатор класса, в структуре ClassFile нет флага ACC_SEALED. Вместо этого, класс изолированного типа имеет аттрибут PermittedSubtypes, который неявно указывает на наличие модификатора sealed и явно перечисляет элементы списка разрешенных типов:


PermittedSubtypes_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 permitted_subtypes_count;
    u2 classes[permitted_subtypes_count];
}

Если в определении изолированного типа в исходнике нет permits, тогда PermittedSubtypes вычисляется в момент компиляции, и в него попадают те подтипы изолированного типа, что определены в той же единице компиляции, что и изолированный тип.


Классфайл разрешенного типа не имеет никаких новых аттрибутов.


Рефлексия


В java.lang.Class предлагается добавить следующие публичные методы:


  • java.lang.constant.ClassDesc[] getPermittedSubtypes()
  • boolean isSealed()

Метод getPermittedSubtypes() возвращает массив, содержащий объекты java.lang.constant.ClassDesc, описывающие все разрешенные подтипы класса, если он изолирован, иначе — возвращает пустой массив.


Метод isSealed возвращает true, если соответствующий класс или интерфейс является изолированным (посмотрите на isEnum).


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


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


Условие permits позляет изолированным типам, таким как Shape в примерах в этой статье, быть доступными-для-вызова из кода, находящегося в любом модуле, но доступными-для-реализации только в модуле изолированного класса (или в том же пакете в unnamed module). Это делает систему типов более выразительной, чем систему контроля доступа. Имея только контроль доступа, если Shape доступна-для-вызова кодом в любом модулей (потому что его пакет уже экспортирован), Shape также доступен-для-реализации в любом модулей. И если Shape не доступен-для-реализации в любом другом модуле, тогда Shape также не доступен-для-вызова в любом другом модуле.




Переводы JEP-ов публикуются при поддержке JUG Ru Group и конференции JPoint. Теперь билеты на JPoint можно приобрести вместе с абонементом на все 8 конференций сразу (подробности — на странице регистрации). И всё это на удалёнке!

JUG Ru Group
Конференции для программистов и сочувствующих. 18+

Комментарии 14

    0

    Статья написана очень быстро, чтобы поддержать дискусссию в чате pro.jvm (@jvmchat в Телеграме). Скорость означает возможность опечаток.


    Все опечатки и неточности просьба сообщать в хабраличку olegchir, а не в комментарии здесь.

      +4

      И все же, почему изолированный, а не запечатанный? Изолированный — несет сильную коннотацию про уровень доступа.

        +3

        Иначе каламбура с самоизоляцией не выйдет :)

        +3
        в погоне за Колином
          0
          Котлином
            +3

            Скорее за скалкой. Там sealed types были, когда про Котлин ещё и не слышали. Но там это проще реализовано, без всяких permits: потомки должны находиться в том же пакете и точка. Как по мне, это удобнее.

              +1

              Вообще-то это фича JVM. Её и Котлин может использовать, если захочет. Проблема с продвинутыми уровнями доступа в Котлине и других JVM-языках в том, что рантайм про них ничего не знает и никак не помешает вам нарушить этот уровень при использовании смеси языков или раздельной компиляции.

            +2
            Сомнительный jep как мне кажется, получается родитель должен знать о потомках, странная иерархия наследования получается. Что мешает использовать package-private для изоляции наследников?
              +2

              Процитирую текст:


              "Подход с package-private полезен, когда наша цель — обеспечить переиспользование кода, вроде подклассов AbstractStringBuilder, что-то добавляющих к коду родителя. Тем не менее, подход этот бесполезен при моделировании альтернатив, поскольку ключевая абстракция — суперкласс — не может использоваться в пользовательском коде. Невозможно дать пользователям доступ до суперкласса без того, чтобы разрешить им ещё и наследоваться от него! (Даже внутри графической библиотеки, которая определяет Shape и его подклассы, довольно неприятно, когда лишь один пакет может использовать Shape).


              В итоге хочется, чтобы суперкласс мог быть доступен отовсюду для использования (он является важной абстракцией для пользователей), но недотупен для наследования (набор подклассов должен быть ограничен лишь теми, о ком знает автор). "

                +1
                + Всякие штуки типа Either можно делать c удобной обработкой.

                  +1

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

                  +2

                  Почему это оформлено как перевод? Брайан ничего не писал про размножение бельгийцев.

                    0

                    То есть, ты за то, чтобы я оформлял переводы со своим вступлением как статьи, а не переводы. Хорошее замечание, так и буду делать, наверное

                    +1

                    Ну наконец-то сделают Enum с дженериками! Не менее 10 версий java ждал.
                    А ведь теперь еще можно будет сделать enum как в rust

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое