BT

Facilitating the Spread of Knowledge and Innovation in Professional Software Development

Write for InfoQ

Topics

Choose your language

InfoQ Homepage Articles Java Feature Spotlight: Sealed Classes

Java Feature Spotlight: Sealed Classes

Leia em Português

This item in japanese

Lire ce contenu en français

Key Takeaways

  • The release of Java SE 15 in Sept 2020 will introduce "sealed classes" (JEP 360) as a preview feature.
  • A sealed class is a class or interface which restricts which other classes or interfaces may extend it.
  • Sealed classes, like enums, capture alternatives in domain models, allowing programmers and compilers to reason about exhaustiveness.
  • Sealed classes are also useful for creating secure hierarchies by decoupling accessibility from extensibility, allowing library developers to expose interfaces while still controlling all the implementations.
  • Sealed classes work together with records and pattern matching to support a more data-centric form of programming.

Preview Features

Given the global reach and high compatibility commitments of the Java platform, the cost of a design mistake in a language feature is very high. In the context of a language misfeature, the commitment to compatibility not only means it is very difficult to remove or significantly change the feature, but existing features also constrain what future features can do -- today's shiny new features are tomorrow's compatibility constraints.

The ultimate proving ground for language features is actual use; feedback from developers who have actually tried them out on real codebases is essential to ensure that the feature is working as intended. When Java had multi-year release cycles, there was plenty of time for experimentation and feedback. To ensure adequate time for experimentation and feedback under the newer rapid release cadence, new language features will go through one or more rounds of preview, where they are part of the platform, but must be separately opted into, and which are not yet permanent -- so that in the event they need to be adjusted based on feedback from developers, this is possible without breaking mission-critical code.

Java SE 15 (Sept 2020) introduces sealed classes as a preview feature. Sealing allows classes and interfaces to have more control over their permitted subtypes; this is useful both for general domain modeling and for building more secure platform libraries.

A class or interface may be declared sealed, which means that only a specific set of classes or interfaces may directly extend it:


sealed interface Shape
    permits Circle, Rectangle { ... }

This declares a sealed interface called Shape. The permits list means that only Circle and Rectangle may implement Shape. (In some cases, the compiler may be able to infer the permits clause for us.) Any other class or interface that attempts to extend Shape will receive a compilation error (or a runtime error, if you try to cheat and generate an off-label classfile which declares Shape as a supertype.)

We are already familiar with the notion of restricting extension through final classes; sealing can be thought of as a generalization of finality. Restricting the set of permitted subtypes may lead to two benefits: the author of a supertype can better reason about the possible implementations since they can control all the implementations, and the compiler can better reason about exhaustiveness (such as in switch statements or cast conversion.) Sealed classes also pair nicely with records.

Sum and product types

The interface declaration above makes the statement that a Shape can be either a Circle or a Rectangle and nothing else. To put it another way, the set of all Shapes is equal to the set of all Circles plus the set of all Rectangless. For this reason, sealed classes are often called sum types, because their value set is the sum of the value sets of a fixed list of other types. Sum types, and sealed classes, are not a new thing; for example, Scala also has sealed classes, and Haskell and ML have primitives for defining sum types (sometimes called tagged unions or discriminated unions.)

Sum types are frequently found alongside product types. Records, recently introduced to Java, are a form of product type, so called because their state space is (a subset of) the cartesian product of the state spaces of their components. (If this sounds complicated, think of product types as tuples, and records as nominal tuples.) Let's finish the declaration of Shape using records to declare the subtypes:

sealed interface Shape
    permits Circle, Rectangle {

      record Circle(Point center, int radius) implements Shape { }

      record Rectangle(Point lowerLeft, Point upperRight) implements Shape { } 
}

Here we see how sum and product types go together; we are able to say "a circle is defined by a center and a radius", "a rectangle is defined by two points", and finally "a shape is either a circle or a rectangle". Because we expect it will be common to co-declare the base type with its implementations in this manner, when all the subtypes are declared in the same compilation unit we allow the permits clause to be omitted and infer it to be the set of subtypes declared in that compilation unit:

sealed interface Shape {

      record Circle(Point center, int radius) implements Shape { }

      record Rectangle(Point lowerLeft, Point upperRight) implements Shape { } 
}

Wait, isn't this violating encapsulation?

Historically, object-oriented modeling has encouraged us to hide the set of implementations of an abstract type. We have been discouraged from asking "what are the possible subtypes of Shape", and similarly told that downcasting to a specific implementation class is a "code smell". So why are we suddenly adding language features that seemingly go against these long-standing principles? (We could also ask the same question about records: isn't it violating encapsulation to mandate a specific relationship between a classes representation and its API?)

The answer is, of course, "it depends." When modeling an abstract service, it is a positive benefit that clients interact only with the service through an abstract type, as this reduces coupling and maximizes flexibility to evolve the system. However, when modeling a specific domain where the characteristics of that domain are already well known, encapsulation may have less to offer us. As we saw with records, when modeling something as prosaic as an XY point or an RGB color, using the full generality of objects to model data requires both a lot of low-value work and worse, can often obfuscate what is actually going on. In cases like this, encapsulation has costs not justified by its benefit; modeling data as data is simpler and more direct.

The same arguments apply to sealed classes. When modeling a well-understood and stable domain, the encapsulation of "I'm not going to tell you what kinds of shapes there are" does not necessarily confer the benefits that we would hope to get from the opaque abstraction, and may even make it harder for clients to work with what is actually a simple domain.

This doesn't mean that encapsulation is a mistake; it merely means that sometimes the balance of costs and benefits are out of line, and we can use judgment to determine when it helps and when it gets in the way. When choosing whether to expose or hide the implementation, we have to be clear about what the benefits and costs of encapsulation are. Is it buying us flexibility for evolving the implementation, or is it merely an information-destroying barrier in the way of something that is already obvious to the other side? Often, the benefits of encapsulation are substantial, but in cases of simple hierarchies that model well-understood domains, the overhead of declaring bulletproof abstractions can sometimes exceed the benefit.

When a type like Shape commits not only to its interface but to the classes that implement it, we can feel better about asking "are you a circle" and casting to Circle, since Shape specifically named Circle as one of its known subtypes. Just as records are a more transparent kind of class, sums are a more transparent kind of polymorphism. This is why sums and products are so frequently seen together; they both represent a similar sort of tradeoff between transparency and abstraction, so where one makes sense, the other is likely to as well. (Sums of products are frequently referred to as algebraic data types.)

Exhaustiveness

Sealed classes like Shape commit to an exhaustive list of possible subtypes, which helps both programmers and compilers reason about shapes in a way that we couldn't without this information. (Other tools can take advantage of this information as well; the Javadoc tool lists the permitted subtypes in the generated documentation page for a sealed class.)

Java SE 14 introduces a limited form of pattern matching, which will be extended in the future. The first version allows us to use type patterns in instanceof:

if (shape instanceof Circle c) {
    // compiler has already cast shape to Circle for us, and bound it to c
    System.out.printf("Circle of radius %d%n", c.radius()); 
}

It is a short hop from there to using type patterns in switch. (This is not supported in Java SE 15, but is coming soon.) When we get there, we can compute the area of a shape using a switch expression whose case labels are type patterns, as follows1:

float area = switch (shape) {
    case Circle c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> Math.abs((r.upperRight().y() - r.lowerLeft().y())
                                 * (r.upperRight().x() - r.lowerLeft().x()));
    // no default needed!
}

The contribution of sealing here is that we did not need a default clause, because the compiler knew from the declaration of Shape that Circle and Rectangle cover all shapes, and so a default clause would be unreachable in the above switch. (The compiler still silently inserts a throwing default clause in switch expressions, just in case the permitted subtypes of Shape have changed between compile and run time, but there is no need to insist that the programmer write this default clause "just in case".) This is similar to how we treat another source of exhaustiveness -- a switch expression over an enum that covers all the known constants also does not need a default clause (and it is generally a good idea to omit it in this case, as this is more likely to alert us to having missed a case.)

A hierarchy like Shape gives its clients a choice: they can deal with shapes entirely through their abstract interface, but they also can "unfold" the abstraction and interact through sharper types when it makes sense to. Language features such as pattern matching make this sort of unfolding more pleasant to read and write.

Examples of algebraic data types

The "sum of products" pattern can be a powerful one. In order for it to be appropriate, it must be extremely unlikely that the list of subtypes will change, and we anticipate that it will be easier and more useful for clients to discriminate over the subtypes directly.

Committing to a fixed set of subtypes, and encouraging clients to use those subtypes directly, is a form of tight coupling. All things being equal, we are encouraged to use loose coupling in our designs to maximize the flexibility to change things in the future, but such loose coupling also has a cost. Having both "opaque" and "transparent" abstractions in our language allows us to choose the right tool for the situation.

One place where we might have used a sum of products (had this been an option at the time) is in the API of java.util.concurrent.Future. A Future represents a computation that may run concurrently with its initiator; the computation represented by a Future may have not yet been started, been started but not yet completed, already completed either successfully or with an exception, have timed out, or have been canceled by the interruption. The get() method of Future reflects all these possibilities:

interface Future<V> {
    ...
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

If the computation has not yet finished, get() blocks until one of the completion modes occur, and if successful, returns the result of the computation. If the computation completed by throwing an exception, this exception is wrapped in an ExecutionException; if the computation timed out or was interrupted, a different kind of exception is thrown. This API is quite precise, but is somewhat painful to use because there are multiple control paths, the normal path (where get() returns a value) and numerous failure paths, each of which must be handled in a catch block:

try {
    V v = future.get();
    // handle normal completion
}
catch (TimeoutException e) {
    // handle timeout
}
catch (InterruptedException e) {
    // handle cancelation
}
catch (ExecutionException e) {
    Throwable cause = e.getCause();
    // handle task failure
}

If we had sealed classes, records, and pattern matching when Future was introduced in Java 5, its possible we would have defined the return type as follows:

sealed interface AsyncReturn<V> {
    record Success<V>(V result) implements AsyncReturn<V> { }
    record Failure<V>(Throwable cause) implements AsyncReturn<V> { }
    record Timeout<V>() implements AsyncReturn<V> { }
    record Interrupted<V>() implements AsyncReturn<V> { }
}

...

interface Future<V> {
    AsyncReturn<V> get();
}

Here, we are saying that an async result is either a success (which carries a return value), a failure (which carries an exception), a timeout, or a cancelation. This is a more uniform description of the possible outcomes, rather than describing some of them with the return value and others with exceptions. Clients would still have to deal with all the cases -- there's no way around the fact that the task might fail -- but we can handle the cases uniformly (and more compactly)1:

AsyncResult<V> r = future.get();
switch (r) {
    case Success(var result): ...
    case Failure(Throwable cause): ...
    case Timeout(), Interrupted(): ...
}

Sums of products are generalized enums

A good way to think about sums of products is that they are a generalization of enums. An enum declaration declares a type with an exhaustive set of constant instances:

enum Planet { MERCURY, VENUS, EARTH, ... }

It is possible to associate data with each constant, such as the mass and radius of the planet:

enum Planet {
    MERCURY (3.303e+23, 2.4397e6),
    VENUS (4.869e+24, 6.0518e6),
    EARTH (5.976e+24, 6.37814e6),
    ...
}

Generalizing a little bit, a sealed class enumerates not a fixed list of instances of the sealed class, but a fixed list of kinds of instances. For example, this sealed interface lists various kinds of celestial bodies, and the data relevant to each kind:

sealed interface Celestial {
    record Planet(String name, double mass, double radius)
        implements Celestial {}
    record Star(String name, double mass, double temperature)
        implements Celestial {}
    record Comet(String name, double period, LocalDateTime lastSeen)
        implements Celestial {}
}

Just as you can switch exhaustively over enum constants, you will also be able to switch exhaustively over the various kinds of celestial bodies1:

switch (celestial) {
    case Planet(String name, double mass, double radius): ...
    case Star(String name, double mass, double temp): ...
    case Comet(String name, double period, LocalDateTime lastSeen): ...
}

Examples of this pattern show up everywhere: events in a UI system, return codes in a service-oriented system, messages in a protocol, etc.

More secure hierarchies

So far, we've talked about when sealed classes are useful for incorporating alternatives into domain models. Sealed classes also have another, quite different, application: secure hierarchies.

Java has always allows us to say "this class cannot be extended" by marking the class final. The existence of final in the language acknowledges a basic fact about classes: sometimes they are designed to be extended, and sometimes they are not, and we would like to support both of these modes. Indeed, Effective Java recommends that we "Design and document for extension, or else prohibit it". This is excellent advice, and might be taken more often if the language gave us more help in doing so.

Unfortunately, the language fails to help us here in two ways: the default for classes is extensible rather than final, and the final mechanism is in reality quite weak, in that it forces authors to choose between constraining extension and using polymorphism as an implementation technique. A good example where we pay for this tension is String; it is critical to the security of the platform that strings be immutable, and therefore String cannot be publicly extensible -- but it would be quite convenient for the implementation to have multiple subtypes. (The cost of working around this is substantial; Compact strings delivered significant footprint and performance improvements by giving special treatment to strings consisting exclusively of Latin-1 characters, but it would have been far easier and cheaper to do this if String were a sealed class instead of a final one.)

It is a well-known trick for simulating the effect of sealing classes (but not interfaces) by using a package-private constructor, and putting all the implementations in the same package. This helps, but it is still somewhat uncomfortable to expose a public abstract class that is not meant to be extended. Library authors would prefer to use interfaces to expose opaque abstractions; abstract classes were meant to be an implementation aid, not a modeling tool. (See Effective Java, "Prefer interfaces to abstract classes".)

With sealed interfaces, library authors no longer have to choose between using polymorphism as an implementation technique, permitting uncontrolled extension, or exposing abstractions as interfaces -- they can have all three. In such a situation, the author may choose to make the implementation classes accessible, but more likely, the implementation classes will remain encapsulated.

Sealed classes allow library authors to decouple accessibility from extensibility. It's nice to have this flexibility, but when should we use it? Surely we would not want to seal interfaces like List -- it is totally reasonable and desirable for users to create new kinds of List. Sealing may have costs (users can't create new implementations) and benefits (the implementation can reason globally about all implementations); we should save sealing for when the benefits exceed the costs.

The fine print

The sealed modifier may be applied to classes or interfaces. It is an error to attempt to seal an already-final class, whether explicitly declared with the final modifier, or implicitly final (such as enum and record classes.)

A sealed class has a permits list, which are the only permitted direct subtypes; these must be available at the time the sealed class is compiled, must actually be subtypes of the sealed class and must be in the same module as the sealed class (or in the same package, if in the unnamed module.) This requirement effectively means that they must be co-maintained with the sealed class, which is a reasonable requirement for such tight coupling.

If the permitted subtypes are all declared in the same compilation unit as the sealed class, the permits clause may be omitted and will be inferred to be all subtypes in the same compilation unit. A sealed class may not be used as a functional interface for a lambda expression, or as the base type for an anonymous class.

Subtypes of a sealed class must be more explicit about their extensibility; a subtype of a sealed class must be either sealed, final, or explicitly marked non-sealed. (Records and enums are implicitly final, so do not need to be explicitly marked as such.) It is an error to mark a class or interface as non-sealed if it does not have a sealed direct supertype.

It is a binary- and source-compatible change to make an existing final class sealed. It is neither binary- nor source-compatible to seal a non-final class for which you do not already control all the implementations. It is binary compatible but not source-compatible to add new permitted subtypes to a sealed class (this might break the exhaustiveness of switch expressions.)

Wrap-up

Sealed classes have multiple uses; they are useful as a domain modeling technique when it makes sense to capture an exhaustive set of alternatives in the domain model; they are also useful as an implementation technique when it is desirable to decouple accessibility from extensibility. Sealed types are a natural complement to records, as together they form a common pattern known as algebraic data types; they are also a natural fit for pattern matching, which are coming to Java soon as well.

Footnotes

1This example uses a form of switch expression -- one that uses patterns as case labels -- that is not yet supported by the Java Language. The six-month release-cadence allows us to co-design features but deliver them independently; we fully expect that switch will be able to use patterns as case labels in the near future.

About the Author

Brian Goetz is the Java Language Architect at Oracle and was the specification lead for JSR-335 (Lambda Expressions for the Java Programming Language.) He is the author of the best-selling Java Concurrency in Practice and has been fascinated by programming since Jimmy Carter was President.

Rate this Article

Adoption
Style

BT