Исследуем sealed классы в Java 15

Продолжаем исследовать новые возможности, которые появляются в Java. В прошлые разы мы подробно рассматривали улучшенный оператор instanceof и записи, а сегодня объектом исследования будут sealed классы, которые запланированы к выходу в пятнадцатой версии Java.

Идея введения в язык «запечатанных» типов впервые была подробно описана в феврале 2019 года в документе "Data Classes and Sealed Types for Java", и в июле того же года получила свой JEP 360, который был любезно переведён на Хабре Олегом Чирухиным. Стоит отметить, что версия JEP'а, которую перевёл Олег, немного устарела и отличается от актуальной. В частности, в новой версии sealed типы больше не типы, а классы и интерфейсы. Связано это переименование с тем, что термин «тип» слишком перегружен и может означать не только классы и интерфейсы, но и их производные вроде массивов (Object[], String[]) и параметризацией дженериков (List<Integer>, List<?>). Подробнее про это переименование можно прочитать тут.

Впрочем, довольно лирики, и давайте перейдём непосредственно к нашим sealed классам. Программистам, знакомым со Scala и Kotlin, такой вид классов должен быть знаком. В Java он означает в точности то же самое, что и там: модификатор sealed ограничивает круг классов, которые могут наследоваться от данного класса. А вот в C# sealed означает совсем другое. Так что если вы пришли из C#, то будьте осторожнее: аналог ключевого слова sealed в Java – это final, а не sealed.

Давайте же наконец играться с sealed классами. Но для начала проверим нашу версию Java:

> java --version
openjdk 15-ea 2020-09-15
OpenJDK Runtime Environment (build 15-ea+26-1287)
OpenJDK 64-Bit Server VM (build 15-ea+26-1287, mixed mode, sharing)

Как видите, я запустил раннюю сборку JDK 15. В ней sealed классы уже присутствуют.

Напишем и запустим какой-нибудь простой код:

public class Main {
    public static void main(String[] args) {
        Shape rectangle = new Rectangle(1, 2);
        Shape circle = new Circle(3);
        System.out.println(rectangle);
        System.out.println(circle);
    }
}

sealed abstract class Shape {
}

final class Rectangle extends Shape {
    final int width, height;

    Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
}

final class Circle extends Shape {
    final int radius;

    Circle(int radius) {
        this.radius = radius;
    }
}
> java --enable-preview --source 15 Main.java
Note: Main.java uses preview language features.
Note: Recompile with -Xlint:preview for details.
Rectangle@11e21d0e
Circle@1dd02175

В коде выше особо ничего интересного, но заметьте, что мне пришлось сделать классы Rectangle и Shape final, иначе бы код не скомпилировался:

class Rectangle extends Shape {
    …
}

class Circle extends Shape {
    …
}
> java --enable-preview --source 15 Main.java
Main.java:13: error: sealed, non-sealed or final modifiers expected
class Rectangle extends Shape {
^
Main.java:22: error: sealed, non-sealed or final modifiers expected
class Circle extends Shape {
^

Таким образом, подклассы sealed классов могут быть либо sealed, либо non-sealed, либо final.

Кстати, у нас же есть записи. Может, перепишем код, используя их, чтобы стало короче?

sealed interface Shape {
}

record Rectangle(int width, int height) implements Shape {
}

record Circle(int radius) implements Shape {
}
> java --enable-preview --source 15 Main.java
Rectangle[width=1, height=2]
Circle[radius=3]

Так гораздо лучше. Но пришлось сделать Shape интерфейсом, потому что записи не могут наследоваться от классов.

Однако мы так и не проверили, что модификатор sealed действительно работает. Давайте перенесём один из классов в другой файл:

// Main.java
public class Main {
    public static void main(String[] args) {
        Shape rectangle = new Rectangle(1, 2);
        Shape circle = new Circle(3);
        System.out.println(rectangle);
        System.out.println(circle);
    }
}

sealed interface Shape {
}

record Rectangle(int width, int height) implements Shape {
}
// Circle.java
record Circle(int radius) implements Shape {
}
> javac --enable-preview --release 15 Main.java Circle.java
Circle.java:1: error: class is not allowed to extend sealed class: Shape
record Circle(int radius) implements Shape {
^

Чего и следовало ожидать: компилятор разрешает наследоваться от sealed интерфейса только тем классам, которые находятся в том же файле. Но что если мне нужно, чтобы мои классы были в разных файлах? Например, я хочу сделать все классы публичными, а значит они должны находиться в отдельных файлах, но при этом я не хочу терять преимущества sealed. На помощь приходит ключевое слово permits:

// Shape.java
public sealed interface Shape permits Rectangle, Circle {
}
// Rectangle.java
public record Rectangle(int width, int height) implements Shape {
}
// Circle.java
public record Circle(int radius) implements Shape {
}

Теперь всё успешно компилируется. С помощью permits мы указали явно все классы, которые будут наследоваться от Shape.

Давайте попробуем сделать что-нибудь нелегальное. Например, укажем в permits класс, но «забудем» его отнаследовать:

// Shape.java
public sealed interface Shape permits Rectangle {
}
// Rectangle.java
public record Rectangle(int width, int height) {
}
> javac --enable-preview --release 15 Shape.java Rectangle.java
Shape.java:1: error: invalid permits clause
public sealed interface Shape permits Rectangle {
                                      ^
  (subclass Rectangle must extend sealed class)

Это явно ошибочная ситуация, и компилятор надёжно её перехватывает, сообщая нам об ошибке.

А если попробовать вписать в permits класс, который не является прямым наследником?

// Shape.java
public sealed interface Shape permits RectangleShape, Rectangle {
}
// RectangleShape.java
public sealed interface RectangleShape extends Shape permits Rectangle {
}
// Rectangle.java
public record Rectangle(int width, int height) implements RectangleShape {
}

Заметьте, что в данной иерархии Rectangle наследуется от RectangleShape, а RectangleShape – от Shape. Но при этом в Shape permits указан Rectangle. Попробуем скомпилировать это:

> javac --enable-preview --release 15 Shape.java RectangleShape.java Rectangle.java
Shape.java:1: error: invalid permits clause
public sealed interface Shape permits RectangleShape, Rectangle {
                                                      ^
  (subclass Rectangle must extend sealed class)

Получили в общем-то ту же самую ошибку, что и выше: если указываешь в permits класс, то это класс должен быть отнаследован напрямую.

Интересно, что будет, если указать в permits какой-нибудь совершенно левый класс?

public sealed interface Shape permits Integer, Rectangle, Circle {
}
> javac --enable-preview --release 15 Shape.java Rectangle.java Circle.java
Shape.java:1: error: class is not allowed to extend sealed class: Shape
public sealed interface Shape permits Integer, Rectangle, Circle {
                                      ^

Здесь мы тоже совершенно ожидаемо получили ошибку: Java разрешает указывать в permits только классы из того же модуля. Класс java.lang.Integer находится в модуле java.base, поэтому код не компилируется.

Что если сделать sealed без единого наследника?

// Shape.java
public sealed interface Shape {
}
> javac --enable-preview --release 15 Shape.java
Shape.java:1: error: sealed class must have subclasses
public sealed interface Shape {
              ^

И здесь компилятор надёжен. Если класс sealed, то он должен иметь хотя бы один подкласс, иначе какой тогда смысл в sealed? (Хотя это могло бы быть полезным для возможности объявления утилитных интерфейсов, но тут скорее нужно позволить объявлять final интерфейсы, нежели sealed)

Было бы ещё интересно проверить вот что. Java на этапе компиляции умеет делать проверки совместимости типов. Если она сможет доказать, что два типа не могут быть приведены друг к другу, то будет ошибка компиляции. Например:

public class Main {
    public static void main(String[] args) {
        Rectangle rect = new Rectangle();
        System.out.println(rect instanceof Runnable);
    }
}

final class Rectangle {
}

Такой код ожидаемо не скомпилируется, ведь совершенно точно известно, что никакой объект Rectangle не может быть Runnable, потому что Rectangle объявлен как final:

> java Main.java
Main.java:4: error: incompatible types: Rectangle cannot be converted to Runnable
        System.out.println(rect instanceof Runnable);
                           ^

А теперь вопрос: будет ли ошибка компиляции, если усложнить наш пример, введя промежуточный sealed класс? Давайте проверим:

public class Main {
    public static void main(String[] args) {
        Shape rect = new Rectangle();
        System.out.println(rect instanceof Runnable);
    }
}

sealed class Shape permits Rectangle {
}

final class Rectangle extends Shape {
}

В данном примере объект rect тоже абсолютно точно не может быть Runnable, потому что иерархия Shape закрытая, и ни Shape, ни Rectangle не реализуют Runnable. Хватит ли компилятору ума, чтобы обнаружить ошибку?

> java --enable-preview --source 15 Main.java
false

К сожалению, не хватило. Вообще это довольно странно, потому что это было бы совершенно логичным поведением компилятора. Я решил задать про это вопрос в рассылке OpenJDK, и мне ответили, что решили эту возможность пока не реализовывать и отложить её до следующего релиза. Ну что ж, тогда будем ждать.

Что мы ещё не попробовали? Что насчёт анонимных классов? Могут ли они наследоваться от sealed классов?

public class Main {
    public static void main(String[] args) {
        Shape shape = new Shape() {};
    }
}

sealed interface Shape {
}

record Rectangle(int width, int height) implements Shape {
}
> java --enable-preview --source 15 Main.java
Main.java:3: error: local classes must not extend sealed classes
        Shape shape = new Shape() {};
                                  ^

Ага, значит наследоваться не могут не только анонимные, но и вообще любые локальные классы. Ну и, конечно же, логично было бы запретить лямбды:

public class Main {
    public static void main(String[] args) {
        Shape shape = () -> {};
    }
}

sealed interface Shape {
    void f();
}

record Rectangle(int width, int height) implements Shape {
    public void f() {
    }
}
> java --enable-preview --source 15 Main.java
Main.java:3: error: incompatible types: Shape is not a functional interface
        Shape shape = () -> {};
                      ^

Интересное сообщение об ошибке. То есть sealed интерфейс не может быть функциональным интерфейсом. И если просто попытаться использовать аннотацию @FunctionalInterface, то будет ошибка:

…

@FunctionalInterface
sealed interface Shape {
    void f();
}
> java --enable-preview --source 15 Main.java
Main.java:6: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  Shape is not a functional interface

Заключение

Далеко не всегда иерархии классов должны быть открыты для расширения неограниченным кругом лиц. Часто встречается необходимость смоделировать такую иерархию, которая будет открыта для использования, но закрыта для расширения. «Запечатанные» классы и интерфейсы в Java 15, наконец, сделают такое возможным. Особенно хорошо sealed классы будут взаимодействовать с записями, позволив легко моделировать алгебраические типы данных.

Но ещё более эффектной эта возможность будет, когда реализуют полноценный паттерн-матчинг для оператора switch, и компилятор, работая с sealed иерархией, сможет делать проверки исчерпываемости (exhaustiveness). Это поможет разработчику сделать код ещё более безопасным.

Сейчас sealed классы некоторое время будут находиться в режиме preview, но к следующему LTS релизу Java 17 они, скорее всего, станут стабильными. Это даст неплохую мотивацию перейти на последнюю версию Java.

Подписывайтесь на канал в Telegram, чтобы не пропускать новости.

Все материалы на этом сайте выложены под лицензией CC BY-SA 4.0
© Евгений Козлов, 2017-2020