Sealed classes and interfaces were the big news in Java 17.
In this article, you will learn:
- What are sealed classes and interfaces?
- How exactly do sealed classes and interfaces work?
- What do we need them for?
- Why should we restrict the extensibility of a class hierarchy?
Let's start with an example...
Starting Point: Example Class Hierarchy
Let the following class hierarchy be the starting point:
Here is the Java source code for the example:
public class Shape { ... }
public class Circle extends Shape { ... }
public class Rectangle extends Shape { ... }
public class Square extends Shape { ... }
public class WeirdShape extends Shape { ... }
public class TranspRectangle extends Rectangle { ... }
public class FilledRectangle extends Rectangle { ... }
Code language: Java (java)
Usually, every developer can extend this class hierarchy at any place. An extended structure could look like this (I've colored the added classes light yellow):
Now, we may want to restrict the extension of our class hierarchy. For example, we might want to specify that developers may only extend the WeirdShape
class.
Why would we want to do that, and how can we do it?
Why Restrict the Extensibility of a Class Hierarchy?
There may be several reasons why we want to restrict the free extensibility of our class hierarchy:
- We want to protect the internal state of a class or a hierarchy of classes and not have it manipulated inconsistently by a child class.
- We want to protect internal objects whose thread safety is guaranteed by our class or class hierarchy from being published so that foreign code cannot compromise thread safety.
- We want to ensure that the Liskov substitution principle (LSP) is not violated. That is, we don't want a developer to implement a derived class that breaks the API contract of the parent class.
- We want to take advantage of exhaustiveness analysis in "pattern matching for switch".
Now that we know the reasons for constraining a class hierarchy, we move on to the next question: How can we do that?
Sealing the Class Hierarchy – Step by Step
We already know the first possibility…
Restricting the Class Hierarchy with "final"
By marking classes as "final", we can prevent their extension in general.
A second option would be to mark a class as package-private to permit only subclasses within the same package. However, this would have the consequence that the superclass would no longer be visible outside the package, which is undesirable in most cases.
Let's try to use "final" in our example. We mark the classes Circle
, TranspRectangle
, FilledRectangle
, and Square
as final
(remember: WeirdShape
should remain the only extendable class).
This limits the extensibility of our class hierarchy, as shown in the following class diagram:
To improve clarity, I have removed the crossed-out boxes below the final classes in the following graphic:
This means we are on the right track, but we are still far from our goal. What now? Obviously, we can't make Shape
and Rectangle
final because other classes should extend them.
This is where sealed classes come in...
Sealing the Class Hierarchy with "sealed" and "permits"
Using "sealed types", we can implement what is called a "sealed class hierarchy". It works as follows:
- We mark the class whose subclasses we want to restrict with the
sealed
keyword. - Using the keyword
permits
, we list the allowed subclasses.
We extend the code of the Shape
and Rectangle
classes as follows:
public sealed class Shape permits Circle, Square, Rectangle, WeirdShape { ... }
public sealed class Rectangle extends Shape permits TranspRectangle, FilledRectangle { ... }
Code language: Java (java)
With this code, we state the following:
- The
Shape
class may only be extended by theCircle
,Square
,Rectangle
andWeirdShape
classes. - The
Rectangle
class may only be extended by theTranspRectangle
, andFilledRectangle
classes.
The following class diagram shows the constraints added by sealed
and permits
:
For the sake of clarity, once more without the crossed-out classes:
It looks like we have reached our goal. But one step is still missing…
Opening the Sealed Class Hierarchy with "non-sealed"
With the changes made so far, our code looks like this:
public sealed class Shape permits Circle, Square, Rectangle, WeirdShape { ... }
public final class Circle extends Shape { ... }
public sealed class Rectangle extends Shape permits TranspRectangle, FilledRectangle { ... }
public final class Square extends Shape { ... }
public class WeirdShape extends Shape { ... }
public final class TranspRectangle extends Rectangle { ... }
public final class FilledRectangle extends Rectangle { ... }
Code language: Java (java)
When we try to compile this code, we get the following error message:
$ javac *.java
WeirdShape.java:3: error: sealed, non-sealed or final modifiers expected
public class WeirdShape extends Shape {
^
Code language: plaintext (plaintext)
For preventing accidental openings of the sealed class hierarchy, all classes must be marked sealed
, non-sealed
, or final
.
Our WeirdShape
class should be extensible, i.e., the sealing should be opened at this class. Therefore we have to mark this class as non-sealed
:
public non-sealed class WeirdShape extends Shape { ... }
Code language: Java (java)
Our final class hierarchy thus looks like this:
How to Check if a Class Is Sealed and Which Classes Can Extend It
The Class
class has been extended with the following two methods:
isSealed()
– returnstrue
if the class or interface is sealed.getPermittedSubclasses()
– returns an array of classes or interfaces that are allowed to extend this class or interface, ornull
if this class/interface is not sealed.
Particularities
There are some particularities to keep in mind when using sealed class hierarchies.
Sealing within a "Compilation Unit"
The permits
keyword can be omitted if subclasses derived from a sealed class are defined within the same class file ("compilation unit"). These are then considered "implicitly declared permitted subclasses".
In the following example, ChildInSameCompilationUnit
is such a subclass; therefore, the permits
keyword may be omitted:
public sealed class SealedParentWithoutPermits {
public final class ChildInSameCompilationUnit extends SealedParentWithoutPermits {
// ...
}
}
Code language: Java (java)
Local Classes
Local classes (i.e., classes defined within methods) must not extend sealed classes.
The following code shows a local class extending an unsealed class. This code is valid:
public class NonSealedParent {
public void doSomethingSmart() {
class LocalChild extends NonSealedParent { // Allowed
// ...
}
// ...
}
}
Code language: Java (java)
However, if the outer class is sealed, the local class may not inherit from it:
public sealed class SealedParent {
public void doSomethingSmart() {
class LocalChild extends SealedParent { // Not allowed
// ...
}
// ...
}
}
Code language: Java (java)
instanceof
Tests with Sealed Classes
For instanceof tests, the compiler checks whether the class hierarchy allows the check ever to return true
. If it does not, the compiler reports an "incompatible types" error, such as in the following code:
Number n = getNumber();
if (n instanceof String) { // Not allowed
// ...
}
Code language: Java (java)
A Number
object can never be an instance of String
. The compiler therefore reports:
incompatible types: Number cannot be converted to String
Information from sealed class hierarchies is also included in this check. What this means is best explained with an example:
Let's assume we have an interface A
and a class B
:
interface A {}
class B {}
Code language: Java (java)
Thus, the following check is valid:
public boolean isAaB(A a) {
return a instanceof B;
}
Code language: Java (java)
How can this check return true
? By defining a class C
that inherits from B
and implements A
:
class C extends B implements A {}
Code language: Java (java)
The invocation of isAaB(new C())
then returns true
.
Now we seal interface A
and allow only AChild
as a subclass; we leave class B
unchanged:
sealed interface A permits AChild {}
final class AChild implements A {}
class B {}
Code language: Java (java)
The compiler now recognizes that an object of type A
can never also be an instance of B
. Accordingly, the check if (a instanceof B)
is from now on acknowledged with the following compiler error:
incompatible types: A cannot be converted to B
Contextual Keywords
The introduction of new keywords such as sealed
, non-sealed
, permits
(or yield
from switch expressions) raised the following question among JDK developers: What should happen to existing code that uses these keywords as method or variable names?
Since Java places a high value on backward compatibility, it was decided not to affect existing code as much as possible. That is made possible by so-called "contextual keywords" – keywords that only have a meaning in a specific context.
The terms sealed
and permits
, for example, are such "contextual keywords" and have meaning only in the context of a class definition. In other contexts, they can be used as method or class names. So the following is valid Java code:
public void sealed() {
int permits = 5;
}
Code language: Java (java)
Exhaustiveness Analysis for "Pattern Matching for switch"
In Java 17, "Pattern Matching for switch" was introduced as a preview feature. In combination with this feature, sealed classes allow exhaustiveness analysis, i.e., the compiler can check whether a switch statement or expression covers all possible cases.
Here is a small class hierarchy with a sealed interface as the root:
public sealed interface Color permits Red, Blue {}
public final class Red implements Color {}
public final class Blue implements Color {}
Code language: Java (java)
"Pattern Matching for switch" enables code like the following:
Color color = getColor();
switch (color) {
case Red r -> ...
case Blue b -> ...
}
Code language: Java (java)
The compiler recognizes that the object color
can only be an instance of Red
or Blue
; thus, the switch statement is exhaustive and does not require a default case.
Another advantage is that the compiler will point out the missing switch
case if the class hierarchy is extended later.
Let's extend our class hierarchy with the color green (don't forget to extend the permits
list of Color
):
public sealed interface Color permits Red, Blue, Green {}
public final class Red implements Color {}
public final class Blue implements Color {}
public final class Green implements Color {}
Code language: Java (java)
When trying to recompile the switch
statement, the compiler aborts with the following error message:
$ javac --enable-preview -source 17 SwitchTest.java
SwitchTest.java:6: error: the switch statement does not cover all possible input values
switch (color) {
^
Code language: plaintext (plaintext)
So, with sealed class hierarchies, the compiler can help us avoid incomplete switch statements or expressions – a common cause of errors when extending class hierarchies.
Conclusion
Sealed classes were introduced by JDK Enhancement Proposal 409 in Java 17. They allow us to protect a class hierarchy from unwanted extensions.
For "Pattern Matching for switch", introduced as a preview feature in 17, they also enable exhaustiveness analysis.
Sealed Classes were developed along with other new language features such as records, switch expressions, text blocks, and pattern matching in Project Amber.
If you liked the article, feel free to leave me a comment or share the article using one of the share buttons at the end.
Want to be notified when new articles are published? Then click here to sign up for the HappyCoders newsletter.