Friday, November 19, 2021

Fight ambiguity and improve your code with Java 17’s sealed classes

Use sealed classes and interfaces to create hierarchies that accurately reflect your business domain and knowledge.

Download a PDF of this article

Assumptions and ambiguities. As developers, we all hate them. Sealed classes—defined and implemented by JEP 409, as a standard language feature in Java 17—can help. Sealed classes will also help you avoid writing unnecessary code.

Sealed classes let you define hierarchies that accurately capture the knowledge of your business domain. By doing so, you can clearly define the code that handles the subclasses in a deliberate way, rather than defining generalized code that handles the unexpected classes in an unwanted manner.

In this article, you’ll learn what sealed classes are, why you need them, and how they will help improve your applications.

What is a sealed class and what does it look like?

Declaring a class or interface as sealed enables you to control the classes or interfaces that can extend or implement it. Figure 1 shows the syntax for a sealed base class and its derived classes. I’ve deliberately used an image so you just get a feel of what this feature looks like and the hierarchy it creates.

Oracle Java Exam Prep, Oracle Java Certification, Oracle Java Guides, Oracle Java Career, Oracle Java Preparation, Oracle Java Skills, Oracle Java Jobs
Figure 1. The syntax for a sealed base class and its derived classes

A couple of details are shown in the preceding image, such as using the new modifier sealed to seal the class Plant, a permits clause that declares a list of derived classes, and modifiers for all these derived classes (final, sealed, and non-sealed). As mentioned above, you can seal interfaces too; don’t worry, I’ll cover all these details shortly.

The goal of this language feature is to let you control the possible hierarchies in your business domain in a declarative manner. But why would you ever need to create such restricted hierarchies?

Imagine you are creating an application that helps users with gardening activities. Depending on the type of plant, a gardener might need to do different activities. You can model the plant hierarchy as follows (I’m intentionally not detailing the classes at this time):

abstract class Plant {}

class Herb extends Plant {}
class Shrub extends Plant {}
class Climber extends Plant {}

class Cucumber extends Climber {}

The following code shows how the Gardener class might use this hierarchy:

public class Gardener {
   int process(Plant plant) {
       if (plant instanceof Cucumber) {
           return harvestCucumber(plant);
       } else if (plant instanceof Climber) {
           return sowClimber(plant);
       } else if (plant instanceof Herb) {
           return sellHerb(plant);
       } else if (plant instanceof Shrub) {
           return pruneShrub(plant);
       } else {
           System.out.println("Unreachable CODE. Unknown Plant type");
           return 0;
       }
   }

   private int pruneShrub(Plant plant) {...}
   private int sellHerb(Plant plant) {...}
   private int sowClimber(Plant plant) {...}
   private int harvestCucumber(Plant plant) {...}
}

The preceding code has two problems:

◉ There is no straightforward way to control the hierarchy of the public class Plant or to restrict it to specific classes. You might need this restriction to align the code with a business use case (for example, if this application is supposed to work with only a specific type type of plants).

◉ There is an assumption that developers have to deal with in the last else construct, which is to define actions even though they are sure that all possible types of the method parameters have been already addressed. In the preceding code, the code in the last else block might look unreachable now, but what happens if some other developer adds a class to this hierarchy? Also, the compiler can’t help the method process to check whether it has handled all the subclasses.

◉ Sealed classes can help by imposing restrictions on the class hierarchies at the language level.

Define secure hierarchies with sealed classes


With the sealed modifier, you can declare a class as a sealed class. A sealed class can provide a permits clause that lists the classes that extend it directly. If a class doesn’t appear in this list, it isn’t allowed to extend the sealed class. Knowing this, I’ll revisit the code from the previous example using this new functionality.

public abstract sealed class Plant permits Herb, Shrub, Climber {
}

public final class Shrub extends Plant {}
public non-sealed class Herb extends Plant {}
public sealed class Climber extends Plant permits Cucumber{}

public final class Cucumber extends Climber {}

There are three types of classes that can extend a sealed class: final, non-sealed, and sealed. You are probably used to working with final classes, which prevent any other class from extending them further. The non-sealed classes are quite interesting: When a derived class is declared as non-sealed, it can be further extended by any other class; in other words, this part of the hierarchy is open to extension. Don’t miss that the base class still has an exhaustive list of its immediate subclasses. When a derived class is defined as a sealed class, it must follow the same rules as a sealed base class.

Before addressing how to iterate exhaustively over the subclasses of Plant, you should be sure you know how to compile the preceding code. Fortunately, sealed classes are a standard language feature in Java 17. You don’t need to use the --enable-preview argument for either your compiler or runtime to use it.

Modify the processing of Plant types in class Gardener


After creating a sealed hierarchy, you will be able to process an instance from the hierarchy in a precise way, and you won’t need to deal with any unknown implementations. For this example, you’ll replace the long chain of if-else statements with pattern matching for switch.

Introduced in Java 17 as a preview language feature, pattern matching for switch can be used to switch over a value based on its type, by using a type pattern.

I will rewrite the process method, in class Gardener, using a switch expression. I’ll use type patterns in the case labels to test the type of a Plant value. The Java compiler is smart enough to exploit the fact that Plant is a sealed class to require only pattern labels testing the permitted subclasses, as follows:

int process(Plant plant) {
   return switch (plant) {
       case Cucumber c -> harvestCucumber(c);
       case Climber cl -> sowClimber(cl);
       case Herb h -> sellHerb(h);
       case Shrub s -> pruneShrub(s);
   };
}

Does the preceding code seem a lot cleaner, easier to write, and easier to understand? I believe it does to the developer in Figure 2.

Oracle Java Exam Prep, Oracle Java Certification, Oracle Java Guides, Oracle Java Career, Oracle Java Preparation, Oracle Java Skills, Oracle Java Jobs
Figure 2. A developer who really likes sealed classes and pattern matching for switch

By the way, there’s a slight difference in how you would compile a class that uses pattern matching for switch, because it is a preview language feature in Java 17.

On the command prompt, include the arguments --enable-preview and -source 17 (or --release 17) to compile code that uses pattern matching for switch. Since it is a preview language feature, it must be explicitly enabled during the compilation process, as follows:

C:\code>javac -source 17 –enable-preview SealClassesAndSwitch.java
Note: SealClassesAndSwitch uses preview features of Java SE 17.
Note: Recompile with -Xlint:preview for details

At runtime, the execution process needs only the --enable-preview argument.

C:\code>java –-enable-preview SealClassesAndSwitch

Revisiting the iteration of Plant types in method process


The process method defined earlier doesn’t need a default case because it handles all the permitted subclasses of class Plant. However, class Cucumber is not a direct subclass of class Plant. The following code also handles all permitted subclasses of class Plant:

int process(Plant plant) {
   return switch (plant) {
       case Climber cl -> sowClimber(cl);
       case Herb h -> sellHerb(h);
       case Shrub s -> pruneShrub(s);
   };
}

The list of cases in the preceding code is exhaustive since class Plant is defined as an abstract class. If you define class Plant as a concrete class, which can be instantiated, you will need to add a case that checks whether the type of instance passed to the switch expression is of type Plant. Here’s the modified code.

int process(Plant plant) {
   return switch (plant) {
       case Climber cl -> sowClimber(cl);
       case Herb h -> sellHerb(h);
       case Shrub s -> pruneShrub(s);
       case Plant s -> 0;
   };
}

Let me show you how sealed classes can free you from needing to use tricks to prevent a class from being subclassed.

Decoupling accessibility and extensibility


If a class is accessible, it shouldn’t necessarily be open for extension too. By permitting a predefined set of classes to extend your class, you can decouple accessibility from extensibility. You can make your sealed class accessible to other packages and modules, while still controlling who can extend it.

In the past, to prevent classes from being extended, developers created package-private classes. But this also meant that these classes had limited accessibility. Another approach to prevent extension was to create public classes with private or package-private constructors. Though it enabled a class to be visible, it gave limited control on the exact types that could extend your class.

This is no longer the case if you use sealed classes. The goal of the sealed classes is to model your business domain with more precision.

You can’t create another class, say, AquaticPlant, that tries to extend the sealed class Plant without adding it to the permits clause of the class Plant. That’s why the following code for class AquaticPlant won’t compile:

public abstract sealed class Plant permits Herb, Shrub, Climber {
}
class AquaticPlant extends Plant {}

Here’s the compilation error when you try to compile class AquaticPlant.

C:\code>javac AquaticPlant.java
AquaticPlant.java:14: error: class is not allowed to extend sealed
class: Plant (as it is not listed in its permits clause)
Class AquaticPlant extended Plant{}
^
1 error

Package and module restrictions


Sealed classes and their implementations are generally a set of classes that are developed together, since the idea is that the developer of the base class is able to control the list of its subclasses. This leads to a few restrictions on where sealed classes can be defined.

Sealed classes and their implementations can’t span multiple Java modules. If a sealed base class is declared in a named Java module, all its implementations must be defined in the same module. However, the classes can appear in different packages. For a sealed class declared in an unnamed Java module, all its implementations must be defined in the same package.

Implicit subclasses. If you define a sealed class and its derived classes in the same source file, you can omit the explicit permits clause; the compiler will infer it for you. In the following example, the compiler will infer that the permitted subclasses of Gas are Nitrogen and Oxygen.

sealed public class Gas {}
final class Nitrogen extends Gas {}
non-sealed class Oxygen extends Gas {}

Sealed interfaces. Unlike classes, interfaces cannot define constructors. Before the introduction of sealed classes, a public class could define a private or package-private constructor to limit its extensibility, but interfaces couldn’t do that.

A sealed interface allows you to explicitly specify the interfaces that can extend it and the classes that can implement it. It follows rules similar to sealed classes.

However, you can’t declare an interface using the modifier final, because doing so would clash with its purpose: Interfaces are meant to be implemented. However, you can specify an inheriting interface as either sealed or non-sealed.

The permits clause of an interface declaration lists the classes that can directly implement a sealed interface and the interfaces that can extend it. An implementing class must be either final, sealed, or non-sealed. Here’s the code for reference.

sealed public interface Move permits Athlete, Jump, Kick{}
final class Athlete implements Move {}
non-sealed interface Jump extends Move {}
sealed interface Kick extends Move permits Karate {}
final class Karate implements Kick {}

Sealed classes and records. Records can implement a sealed interface, and since records are implicitly final, they don’t need an explicit final modifier. In the following code, classes Copper and Aluminum are explicitly final, and record class Gold is implicitly final:

sealed public interface Metal {}
final class Copper implements Metal {}
final class Aluminum implements Metal {}
Record Gold (double price) implements Metal {}

Records, by the way, implicitly extend the java.lang.Record class, so records can’t extend sealed classes.

Explicit casts and usage of instanceof


The instanceof operator evaluates the possibility of an instance being of a specific type. However, the compiler can rule out this possibility in certain cases. Consider the following interface and class:

interface NonLiving {}
class Plant {}

The Java compiler can’t rule out the possibility of a Plant instance being of type NonLiving, because it is feasible for a subclass of Plant to implement the interface NonLiving. Thus, the following code compiles successfully:

void useInstanceof(Plant plant) {
if (Plant instanceof NonLiving {
   System.out.printlin(print.toString());
   }
}

However, if class Plant is modified and defined as a final class, the preceding instanceof comparison will no longer compile. Because Plant can no longer be extended, the compiler is sure there won’t be any Plant instance that implements the interface NonLiving, and you’ll see the error shown in Figure 3.

Oracle Java Exam Prep, Oracle Java Certification, Oracle Java Guides, Oracle Java Career, Oracle Java Preparation, Oracle Java Skills, Oracle Java Jobs
Figure 3. You can’t cast a final class to a nonfinal interface.

Here is how the instanceof operator works with a set of sealed classes.

interface NonLiving {}
sealed class Plant permits Herbs, Climber {}
final class Herb extends Plant {}
sealed class Climber extends Plant permits Cucumber {}
final class Cucumber extends Climber {}

In the preceding code, class Plant is sealed and all its derived classes are either sealed or final—and none of them implements the interface NonLiving. Therefore, the instanceof check in the useInstanceof method won’t compile, as shown in Figure 4.

Oracle Java Exam Prep, Oracle Java Certification, Oracle Java Guides, Oracle Java Career, Oracle Java Preparation, Oracle Java Skills, Oracle Java Jobs
Figure 4. Another casting error

However, if you open the hierarchy by defining any of its subclasses, say, Herb, as a non-sealed class, the useInstanceof method will compile because the compiler can’t ensure that none of the Plant instances would implement the interface NonLiving. Thus, a class that extends non-sealed class Herb might implement the interface NonLiving, as shown in Figure 5.

Oracle Java Exam Prep, Oracle Java Certification, Oracle Java Guides, Oracle Java Career, Oracle Java Preparation, Oracle Java Skills, Oracle Java Jobs
Figure 5. Opening up the hierarchy

Sealed classes and the Java API


Java 17 itself makes use of sealed types. For example, the interface ConstantDesc in the package java.lang.constant is now a sealed interface, as you can see in Figure 6.

Oracle Java Exam Prep, Oracle Java Certification, Oracle Java Guides, Oracle Java Career, Oracle Java Preparation, Oracle Java Skills, Oracle Java Jobs
Figure 6. Java 17 uses sealed types itself, such as in the ConstantDesc interface.

The class java.lang.Class adds two methods, getPermittedSubclasses() and isSealed(), for working with the sealed types. You can also use those methods to enumerate the complete sealed hierarchy at runtime.

public static void outputMetaData() {
   var aClass = Plant.class;
   if (aClass.isSealed()) {
       Arrays.stream(aClass.getPermittedSubclasses())
             .forEach(System.out::println);
   }
}

Stronger code analysis with a closed list of subclasses


Sealed classes and interfaces let you specify an explicit list of inheritors that is known to the compiler, IDE, and the runtime (via reflection). This closed list of subclasses makes code analysis more powerful in IDEs such as IntelliJ IDEA.

For example, consider the following completely sealed hierarchy of the WritingDevice class, which doesn’t have non-sealed subtypes:

interface Erasable {}
sealed class WritingDevice permits Pen, Pencil {}
final class Pencil extends WritingDevice {}
sealed class Pen extends WritingDevice permits Marker {}
final class Marker extends Pen {}

If you code in this style, instanceof and casts can check the complete hierarchy statically. The code on line1 and line2 below generate compilation errors, because the compiler checks all the inheritors from the permits clause and finds that none of them implements the Erasable or the CharSequence interface.

class UseWritingDevice {
   static void write(WritingDevice pen) {
       if (pen instanceof Erasable) {                   // line1
       }
       CharSequence charSequence = ((CharSequence) pen);// line2
   }
}

The video in Figure 7 demonstrates this principle in IntelliJ IDEA.

Figure 7. Sealed classes help with code analysis.

Source: oracle.com

Related Posts

0 comments:

Post a Comment