Sealed types in Java

The Java language has recently begun to actively develop. The six-month release of Java versions cannot but please the Java developer with new features.

One of the priority areas for Java development is pattern matching. Pattern matching reveals to the developer the ability to write code more flexibly and more beautifully, while leaving it clear.

The key blocks for pattern matching in Java are planning to record and sealed types.

Records provide concise syntax for declaring classes that are simple carriers of constant, immutable data sets.

Will appear in Java 14 as a preview feature.

record Person(String name, int age) { }

Sealed types are classes or interfaces that impose restrictions on other classes or interfaces that can extend or implement them. With a high degree of probability they may appear in Java 15.

They are enum types on steroids.

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

A sealed modifier is used to declare a sealed class or interface. The list of subtypes can be listed at the declaration time of the sealed class or interface
after the permits keyword. If the subtypes are in the same package or module, then the compiler can display the list of subtypes and permits in the declaration of the sealed
class or interface can be omitted.

If the subtype is abstract, then it becomes implicitly marked with a sealed modifier, unless explicitly marked with a non-sealed modifier.

As you can see, Java introduces a new type of keywords called hyphenated keywords .
Such keywords will consist of two words separated by a dash.

Concrete subtypes implicitly become final unless explicitly marked with a non-sealed modifier. Although it seems to me, it would be better to use non-final if you do not want to make it final.

In order to support sealed types, a new PermittedSubtypes attribute is added to class files, which stores a list of subtypes.

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

Also, in order to work with sealed types through reflection, two methods are added to java.lang.Class.

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

The first method returns an array of java.lang.constant.ClassDesc objects that represent a list of subtypes if the class is marked with a sealed modifier. If the class is not marked with a sealed modifier, an empty array is returned. The second method returns true if the given class or interface is marked with a sealed modifier.

All this allows the compiler to determine if all subtypes are enumerated or not, when iterating over the list of subtypes in a switch expression.

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

The default branch is optional, because the compiler defines all valid subtypes.
And if a new subtype is added, then the compiler will determine that not all subtypes are considered in switch, and it will throw an error in compilation time.

The idea of ​​sealed types is not new. For example, in the Kotlin language, there are sealed classes. But there are no sealed interfaces.

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

Kotlin sealed classes are implicitly abstract and have a private default constructor. Accordingly, subtypes must be nested in the class. In Java, it can be represented as follows.

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

In this case, it can be guaranteed that all subtypes can be determined at compile time.

This is all well and good, but what if you want to try sealed classes, but they haven't come out yet. Fortunately, nothing prevents the implementation of a simple visitor, just like the when-expression works in Kotlin.

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

Visitor implementation is very simple. We take the type of the target object and, depending on the type of branch, we perform lambda expressions.

 public static <V, T1, T2>
 void matches(V value,
                Class<T1> firstClazz,  Consumer<T1> firstBranch,
                Class<T2> secondClazz, Consumer<T2> 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);
        }
}

But in addition, we need to know the subtypes of the target object and compare with the types specified in the branches. And if some subtype is not checked, we will throw an exception.

public static <V> 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");
        }
}

Accordingly, in order not to calculate class subtypes each time, we can cache. In this case, we need to check that the target class is abstract, has a private constructor, and all nested classes inherited from the target class.

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

Let's write a simple benchmark and measure on average how much the visitor’s execution speed and the code written in pure Java differ. The full source benchmark can be viewed here .


@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

To summarize, we can say that sealed types are a very good feature, the use of which will simplify the writing of code and make the code more reliable.

The full source code of the visitor can be viewed on github .

All Articles