Sealed типы в Java

?v=1

Язык Java с недавнего времени стал активно развиваться. Шестимесячный релиз версий Java не может не радовать Java разработчика новыми фичами.

Одним из приоритетных направлений развития Java является сопоставление с образцом (pattern matching). Pattern matching раскрывают перед разработчиком возможность писать код более гибко и красивее, при этом оставляя его понятным.

Ключевыми блоками для pattern matching в Java планируется записи (record) и запечатаные (sealed) типы.

Записи (record) предоставляют лаконичный синтаксис для объявления классов, которые являются простыми носителями постоянных, неизменяемых наборов данных.

Появятся в Java 14 в виде preview feature.

record Person(String name, int age) { }


Запечатанные (sealed) типы — это классы или интерфейсы, которые накладывают ограничения на другие классы или интерфейсы, которые могут расширять или реализовывать их. С большой долей вероятности могут появится в Java 15.

Представляют с себя enum типы на стероидах.

sealed interface Color permits BiColor, TriColor { }
	
record BiColor(int r, int g, int b) implements Color {}
record TriColor(int r, int g, int b) implements Color {}


Для объявления sealed класса или интерфейса используется sealed модификатор. Список подтипов может быть перечислено во времени объявления sealed класса или интерфейса
после ключевого слова permits. В случае если подтипы находятся в том же пакете или модуле, то компилятор сам может вывести список подтипов и permits в объявлении sealed
класса или интерфейса можно опустить.

Если подтип является абстрактным, то он неявно становится помеченный sealed модификатором, если его явно не пометить модификатором non-sealed.

Как видно Java вводит новый тип ключевых слов которые называются hyphenated keywords.
Такие ключевые слова буду состоять из двух слов разделенных черточкой.

Конкретные подтипы неявно становится final, если его явно не пометить модификатором non-sealed. Хотя мне кажется, было бы лучше заиспользовать non-final, если не хочется его делать конечным.

Для того, чтобы осуществить поддержку sealed типов в class-файлы добавляется новый атрибут PermittedSubtypes, который сохраняет список подтипов.

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


Также для того чтобы можно работать с sealed типами через рефлексию в java.lang.Class добавляются два метода.

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


Первый метод возвращает массив объектов java.lang.constant.ClassDesc, которые представляют список подтипов, если класс помеченный sealed модификатор. Если класс не помеченный sealed модификатор, возвращается пустой массив. Второй метод возвращает true, если данный класс или интерфейс помеченный sealed модификатор.

Все это позволяет при переборе списка подтипов в switch-выражении компилятору определить, перебраны все подтипы или нет.

var result = switch (color) {
	case BiColor bc -> 0x1;
	case TriColor tc -> 0x2;
}


Ветку default необязательно указывать, ведь компилятор определяет все допустимые подтипы.
И если добавляется новый подтип, то компилятор определит, что в switch не все подтипы рассмотрены, и выкинет ошибку во времени компиляции.

Идея sealed типов не новая. Например, в языке Kotlin существуют sealed классы. Но sealed интерфейсов нету.

sealed class Color
	
data class BiColor(val r: Int, val g: Int, val b: Int) : Color 
data class TriColor(val r: Int, val g: Int, val b: Int) : Color


sealed классы в языке Kotlin неявно являются абстрактными и имеют приватный конструктор по умолчанию. Соответственно, подтипы должны быть вложенными в класс. На языке Java можно представить следующим образом.

public abstract class Color {
	private Color() {}
	
	public static class BiColor(int r, int g, int b) extends Color {}
	public static class TriColor(int r, int g, int b) extends Color {}
}


В этом случае можно гарантировать, что все подтипы можно определить во времени компиляции.

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

 matches(color).as(
       Color.BiColor.class,  bc -> System.out.println("bi color:  " + bc),
       Color.TriColor.class, tc -> System.out.println("tri color:  " + tc)
 );


Реализация визитера очень проста. Берем тип целевого объекта и в в зависимости от типа ветки, выполняем лямбда-выражения.

 public static 
 void matches(V value,
                Class firstClazz,  Consumer firstBranch,
                Class secondClazz, Consumer secondBranch) {
        verifyExhaustiveness(value, new Class[]{ firstClazz, secondClazz });
        Class valueClass = value.getClass();

        if (firstClazz == valueClass) {
            firstBranch.accept((T1) value);
        } else if (secondClazz == valueClass) {
            secondBranch.accept((T2) value);
        }
}


Но кроме того, нам нужно знать подтипы целевого объекта и сравнить с типами указанных в ветках. И в случае если какой-то подтип не проверили бросим исключение.

public static  void verifyExhaustiveness(V value, Class[] inputClasses) {
        SealedAttribute data = cacheSubclasses.computeIfAbsent(value.getClass(), SealedAttribute::new);
        Class[] subClasses = data.getSubClasses();
        StringBuilder builder = new StringBuilder();
        boolean flag = false;

        if (subClasses.length != inputClasses.length) {
            throw new PatternException("Require " + inputClasses.length + " subclasses. " +
                                       "But checked class has " + subClasses.length + " subclasses.");
        }

        for (Class subClass : subClasses) {
            for (Class inputClass : inputClasses) {
                if (subClass == inputClass) {
                    flag = true;
                    break;
                }
            }

            if (!flag) {
                builder.append(subClass).append(",");
            }

            flag = false;
        }

        if (builder.length() >= 1) {
            throw new PatternException("Must to be exhaustive, add necessary " + builder.toString() +
                                       " branches or else branch instead");
        }
}


Соответственно, чтобы каждый раз не вычислять подтипы класса, можем кешировать. При этом нам нужно проверить, что целевой класс абстрактный, имеет приватный конструктор, и все вложенные классы унаследованные от целевого класса.

public final class SealedAttribute {
    private Class[] subClasses;

    public SealedAttribute(Class clazz) {
        Class sealedClass = clazz.getSuperclass();

        if (!sealedClass.isAnnotationPresent(Sealed.class)) {
            throw new PatternException("Checked class must to be mark as sealed");
        }

        if (!Modifier.isAbstract(sealedClass.getModifiers())) {
            throw new PatternException("Checked class must to be abstract");
        }

        try {
            final Constructor constructor = sealedClass.getDeclaredConstructor();

            if (!Modifier.isPrivate(constructor.getModifiers())) {
                throw new PatternException("Default constructor must to be private");
            }

            this.subClasses = sealedClass.getClasses();

            if (subClasses.length == 0) {
                throw new PatternException("Checked class must to has one or more visible subClasses");
            }

            for (Class subclass : subClasses) {
                if (!Modifier.isStatic(subclass.getModifiers())) {
                    throw new PatternException("Subclass must to be static");
                }

                if (subclass.getSuperclass() != sealedClass) {
                    throw new PatternException("Subclass must to inherit from checked class");
                }
            }
        } catch (NoSuchMethodException e) {
            throw new PatternException("Checked class must to has default constructor " + e.getMessage());
        }
    }

    public Class[] getSubClasses() {
        return subClasses;
    }
}


Напишем простой бенчмарк и померяем в среднем на сколько отличаются скорость выполнения визитера и кода написаного на чистой Java. Полный исходный бенчмарка можно посмотреть здесь.


@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(3)
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class UnionPatternBenchmark {
     private BiColor biColor;
	
    @Setup
    public void setup() {
        biColor = new BiColor.Red();
    }

    @Benchmark
    public int matchesSealedBiExpressionPlain() {
        if (biColor instanceof BiColor.Red) {
               return 0x1;
        } else if (biColor instanceof BiColor.Blue) {
               return 0x2;
        }

        return 0x0;
    }

    @Benchmark
    public int matchesSealedBiExpressionReflective() {
        return matches(biColor).as(
                BiColor.Red.class,  r -> 0x1,
                BiColor.Blue.class, b -> 0x2
        );
    }
}	

UnionPatternBenchmark.matchesSealedBiExpressionPlain           avgt    9   5,992 ±  0,332  ns/op
UnionPatternBenchmark.matchesSealedTriExpressionPlain          avgt    9   7,199 ±  0,356  ns/op

UnionPatternBenchmark.matchesSealedBiExpressionReflective      avgt    9  45,192 ± 11,951  ns/op
UnionPatternBenchmark.matchesSealedTriExpressionReflective     avgt    9  43,413 ±  0,702  ns/op


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

Полный исходной код визитора можно посмотреть на github.

© Habrahabr.ru