You are currently viewing Sealed Types and Inheritance Constraints

Sealed Types and Inheritance Constraints

According to some surveys such as that of JetBrains, version 8 of Java is currently the most used by developers all over the world, despite being a 2014 release. What you are reading is one in a series of articles titled “Going beyond Java 8”, inspired by the contents of my book “Java for Aliens”. These articles will guide the reader step by step to explore the most important features introduced starting from version 9. The aim is to make the reader aware of how important it is to move forward from Java 8, explaining the enormous advantages that the latest versions of the language offer.

In this article we will talk about an important novelty introduced in version 15 as a preview feature (see related article), and definitively made official as a standard feature with Java 17. This feature is known with the name of defined as Sealed Classes, but which actually also concerns interfaces, and therefore we prefer to call Sealed Types. Now we can declare classes and interfaces by imposing some limits on their extension/implementation. Before the advent of this feature, we could only prevent a class from being extended by declaring it final (or by declaring all its constructors as private), but now we can decide which classes it can be extended by. This allows for greater control over inheritance, opening the way to other important features such as pattern matching for the switch construct, which we will mention at the end of this article and which we will explore soon in a dedicated article.

 

Sealed Classes

A sealed class is characterized by the fact that in its declaration it specifies from which subclasses it must be extended explicitly. It does this via the new sealed modifier, and the clause defined by the new contextual keyword permits. For example, if we wanted to write a program that deals with the operation of an optical reader that can only read CDs and DVDs, we could define the OpticalDisk class in such a way that it can be extended only by the CD and DVD classes, as follows :

public sealed class OpticalDisk permits CD, DVD {
  // omitted code
}

Note that to specify the list of classes that can extend the sealed OpticalDisk class, we just used the clause defined by the permits keyword. With this clause we can specify the names of the subclasses separated by commas. However, there are some constraints.

 

First Constraint

The first constraint forces classes that extend a sealed class to be defined using one of the following modifier: final, sealed, non-sealed.

We already know that the use of the final modifier will imply that the subclass can no longer be extended.

If, on the other hand, the subclass is also declared with the sealed modifier, then that subclass should also declare the permits clause, specifying the only classes that must extend it.

Finally, if we do not want to use the sealed or final modifiers, it will be mandatory to use the non-sealed modifier. This modifier is therefore only used to mark ordinary subclasses (that is, which can be extended without particular constraints), when they extend sealed classes. For our example we have chosen to declare the CD class as non-sealed:

public non-sealed class CD extends OpticalDisk {
  // omitted code
}

and the DVD class as final:

public final class DVD extends OpticalDisk {
  // omitted code
}

So, we can still extend the CD class to our liking, while it is not possible to extend the DVD class.

 

Second Constraint

Another constraint we have when declaring a sealed class, is that its subclasses must reside in the same package as the superclass. If we were writing a modular application, the subclasses could also reside in different packages, which however must be defined within the same module.

 

Third Constraint

Another constraint for sealed classes is that the permits clause must always be declared, unless the subclasses are declared in the same file as the superclass. In this case the compiler will automatically elect the subclasses contained in the same file as the only subclasses of the sealed class. For example, we could create a file called OpticalDiskSealed.java where we can write:

sealed class OpticalDisk /* permits CD, DVD */ {
  // omitted code
}

final class DVD extends OpticalDisk {
  // omitted code
}

non-sealed class CD extends OpticalDisk {
  // omitted code
}

Note that the permits clause has been commented out and that the DVD and CD classes have been implicitly elected to only direct subclasses of the OpticalDisk class.

 

Sealed Interfaces

The sealed modifier can also be used for interfaces, the constraints and syntax are the same as we presented for classes. Compared to a sealed class, however, a sealed interface can specify both by which subclasses it can be implemented and by which interfaces it can be extended. In particular, sealed interfaces can also be extended by record types. In fact, the latter cannot extend classes but can implement interfaces. Record types have the advantage of being implicitly declared final, so we won’t have to worry about using another modifier when declaring them (as seen in the First Constraint section). As an example, consider the following Item record:

public record Item(String description, double weight) implements Weighable {
    public double getWeight() {
        return weight;
    }
}

Let’s also create the Packaging record which will contain an item. The total weight of the packaging will be calculated from the sum of the weight of the packaging plus the sum of the weight of the item:

public record Packaging(Item item, double weight) implements Weighable {
    public double getWeight() {
        return weight + item.getWeight();
    }
} 

Now let’s consider the following Weighable sealed interface:

public sealed interface Weighable permits Packaging, Item {
    String UNIT_OF_MEASURE = "kg";
    double getWeight();
}

The Weighable interface permits only the Item and Packaging records as its subclasses.

So, we have created a complete (sealed) hierarchy with a few lines of code.

As with enumerations, records are also implicitly declared final, and the abstract modifier cannot be used. So, when we implement an interface in a record, we must necessarily implement all the inherited methods.

 

Let’s evolve the example

Now let’s try to modify the example making it more interesting. Let’s modify the Weighable interface allowing it to add an enumeration named Measure as a possible extension:

public sealed interface Weighable permits Packaging, Item, Measure {
    String UNIT_OF_MEASURE = "kg";
    double getWeight();
}

The Measure enumeration follows, which implements the Weighable sealed interface:

public enum Measure implements Weighable {
    SMALL(0.05), MEDIUM(0.1), LARGE(0.5), EXTRA_LARGE(0.07);
    private double weight;
    Measure(double weight) {
        this.weight = weight;
    }
    public double getWeight() {
        return weight;
    }
}

Let’s modify the Packaging record by changing the header, defining a Measure enumeration and a varargs of Weighable objects as instance variables:

public record Packaging(Measure measure, Weighable... weighables) implements 
                                                                      Weighable{
    public double getWeight() {
        double totalWeight = measure.getWeight();
        for (Weighable weighable : weighables) {
            totalWeight += weighable.getWeight();
        }
        return totalWeight;
    }

    @Override
    public String toString() {
        String description = "Packaging Measure: " + measure + "\n" ;
        description += "Content:\n" ;
        for (Weighable weighable : weighables) {
            description += "\t"+weighable.toString()+ "\n" ;
        }
        return description;
    } 
}

Note that we have rewritten the getWeight method so that it calculates its weight by summing the weight of the packaging based on the measure and the weight of all the weighables objects included in it. We exploit a similar schema in the overridden toString method, which will print a Packaging type object in a customized way.

We also add a toString method to the Item record:

public record Item(String description, double weight) implements Weighable {
    public double getWeight() {
        return weight;
    }

    @Override
    public String toString() {
        return description;
    }
}

Is interesting to notice how simple it was in terms of the number of lines of code (despite the record customizations), to create this hierarchy that takes advantage of polymorphism. In particular, we can see that we used an enumeration, a sealed interface and two records. Only in one of these records (Packaging) we have had to write a minimum of programming logic using two foreach loops, to loop over the varargs of the Weighable objects. With the right tools the Java code was abstracted correctly and quickly.

 

Main Class

Just look at the following example that takes advantage of polymorphism with the types we have defined:

public class WeightScale {
    public static void main(String args[]) {
        var shoes = new Item("Shoes", 0.8);
        var usbCable = new Item("USB Cable", 0.1);
        var usbCablePackaging = new Packaging(Measure.SMALL, usbCable);
        var sunGlasses = new Item("SunGlasses", 0.2);
        var largePackaging = new Packaging(Measure.LARGE, shoes, sunGlasses, 
            usbCablePackaging);
        pesa(largePackaging);
    }

    public static void weigh(Weighable weighable) {
        System.out.println(weighable + "Weight:");
        System.out.println(weighable.getWeight());
    }
}

In the main  method we have instantiated various objects

Note that we have used the word var for all defined references. We could also have used Weighable references as a type for each instance, but all in all, var in this case seemed like the most suitable choice.

We instantiated three items (shoes, usbCable, e sunGlasses) and two packages (usbCablePackaging and largePackaging). In the usbCablePackaging object, we passed only the usbCable object as varargs of Weighable objects. With this composition we have abstracted the concept that the USB cable has been inserted in a special packaging of small dimensions (SMALL). In the largePackaging object, on the other hand, we have inserted as varargs of Weighable objects, the objects shoes, sunGlasses and usbCablePackaging, which in turn contains the usbCable object. It is possible to insert a packaging inside another packaging, because Packaging also implements Weighable. Finally, the largePackaging object that contains all the others objects is weighed, and its weight is printed. The output will be the following:

Packaging Measure: LARGE
Content:
    Shoes
    SunGlasses
    Packaging Measure: SMALL
Content:
    USB Cable

Weight:
1.65 

 

When to use sealed types

The sealed modifier serves to limit inheritance and consequently the polymorphism. Designing our classes by limiting inheritance is an excellent idea since the extension of a class implies a strong dependency relationship between the superclass and the subclass. This relationship in turn implies that the evolution of a class hierarchy must always be managed globally. In fact, modifying a method of a superclass could also modify the behavior of subclasses. Furthermore, our classes could be extended in an inappropriate way (for example without considering the “is a” test that validates inheritance). We can then use sealed types to design simple hierarchies and in well-known contexts. For this reason, it is recommended to extend sealed types with classes declared final or better with records. From a certain point of view, the introduction of the sealed modifier should also help us design simpler and more robust hierarchies. The syntax also provides more information about the type, adding greater readability to our code and thus improving its overall quality.

We have seen that we can only use the sealed modifier for classes and interfaces. However, it is clear that other Java types such as enumerations and records cannot be declared sealed, as these types are implicitly declared final. Of course, annotations cannot be declared sealed either.

Instead, the sealed modifier can be safely used for abstract classes. For example, it would be desirable to declare the OpticalDisk class abstract:

public abstract sealed class OpticalDisk permits CD, DVD {
  // omitted code
}

This was not our initial choice just because we wanted the reader to be focused on the main topic of this post.

 

Consequences of the introduction of sealed types

The Reflection API has been enhanced with two new methods added to the java.lang.Class class:

  • boolean isSealed(): which returns true if the class (or interface) is declared with the sealed modifier
  • Class[] getPermittedSubclasses: which returns an array of objects of Class type representing the classes (or interfaces) specified in the permits clause

From Java 16 onwards, the term contextual keyword replaces the previous definitions of restricted identifier and restricted keyword introduced starting with version 9 of Java. A contextual keyword, compared to a classic Java keyword (like all those defined up to version 8), does not have the same constraints regarding identifiers. In fact, it is possible to use the word sealed as an identifier of variables, methods, packages and modules. The only constraint that remains, is that we cannot use the word sealed as an identifier for a class or another Java type. However, since we always declare types identifiers capitalized, this constraint should never impact the code previously written.

The most interesting consequence, however, is related to the introduction in version 17 of a new feature preview called “Pattern matching for switch“, to which we will dedicate the next section.

 

Pattern matching for switch example

We could write a method that contains this code using pattern matching for instanceof (read about it in this post):

static void test(Weighable w) {
    if (w instanceof Item i) {
        System.out.println("Item" + i);
    } else if (w instanceof Packaging p) {
        System.out.println("Packaging " + p);
    } else if ((w instanceof Measure m)) {
        System.out.println("Measure " + m);
    }         
}

Considering the definition of the new switch construct (see related post) thanks to sealed types we can rewrite the previous test method as follows:

static void test(Weighable w) {
    switch (w) {
      case Item i -> System.out.println("Item " + i);
      case Packaging p -> System.out.println("Packaging " + p);
      case Measure m -> System.out.println("Measure " + m);    
    }
}

Notice how the switch construct has evolved to also test the type of the input object and by changing the syntax of the case clause so that a type and a reference are specified (for example Item i). In this case there is not even need to add the default clause since the compiler knows a priori all the direct subclasses of Weighable, and can check that they are not extended.

Since pattern matching for switch is a feature preview in Java 17, to compile and run this code, the feature preview must be enabled as explained in this post.

 

Conclusion

In this article we showed what sealed types are, and how the new context keywords sealed, non-sealed, and permits are used. We also saw some examples of new Java code and the impact of this new feature on what existed before (new methods for the Class class, new way to design our hierarchies, pattern matching for switch). In particular, sealed classes and interfaces therefore represent a new step forward for the language. Excluding the possibility of extending the functionality of the switch construct with pattern matching (as preview in Java 17), the greatest usefulness of sealed types from our point of view, is to represent an important tool for designing class and interface hierarchies, consistent with the object-oriented programming principles, and with the domain in which we are developing.

 

Author Notes

Even ignoring the increased security and performance that the latest versions of Java offer, there are plenty of reasons to upgrade your knowledge of Java, or at least your own Java runtime installations. My book “Java for Aliens” (in English), which inspired the  “Going beyond Java 8” series, contains all the information you need to learn Java from scratch, and uses a well-tested teaching method that has been perfected over 20 years of experience, which makes learning simple and exciting. It is also structured to deepen the topics and have superior knowledge that can make a difference to your career.

For more information visit https://www.javaforaliens.com.

Leave a Reply