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.
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:
◉ 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.
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:
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{}
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.
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.
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.
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.
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.
0 comments:
Post a Comment