Java’s record and sealed classes as categorical product and sum types. Part III

Why they where added so late to Java

alex_ber
11 min readApr 5, 2024

Quick recap

The categorical productis, informally, of two objects A and B is the most common object in this category, for which there are projections on A and B. In many categories (sets, groups, graphs, programming types…) the product of the objects is their cartesian product. It is C struct or C++ std:tuple.In Java 14+, this is records.

Quote from Wikipedia (informal definition of sum type or coproduct):

In computer science, a tagged union, also called… discriminated union, disjoint union, sum type or coproduct, is a data structure used to hold a value that could take on several different, but fixed, types. Only one of the types can be in use at any one time, and a tag field explicitly indicates which one is in use. It can be thought of as a type that has several “cases”, each of which should be handled correctly when that type is manipulated. This is critical in defining recursive datatypes, in which some component of a value may have the same type as the value itself, for example in defining a type for representing trees, where it is necessary to distinguish multi-node subtrees and leafs. Like ordinary unions, tagged unions can save storage by overlapping storage areas for each type, since only one is in use at a time.

https://en.wikipedia.org/wiki/Tagged_union

Java 16 (second preview feature)sealed classesis “glorified” tagged union. I will explain why below.

See Part I and Part II for more details.

Algebraic Data types: sum, product. Sealed classes. Records.

Records 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. Records, so as product types, are immutable.


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

A class or interface may be declared sealed, which means that only a specific set of classes or interfaces may directly extend it. Sealing allows classes and interfaces to have more control over their permitted subtypes. This is particularly useful for general domain modeling.

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.

Product types and co-product types (aka sum types) are commonly called algebraic data types.

Sealedclass hierarchy can contain class of any kind. It will still be considered as sum. If hierarchy contains only records / enums (as I show in part II enums also models sum types; they models product types), than we have what is called sum of product.

I want to emphasize following point: sums of products can be expressed as sealed class hierarchy where each class is of type record / enums (we will look at the examples below). But, sealed class hierarchy can contain classes of any types and even un-sealed classes, so sealed class hierarchy where each class is of type record is more expressive than “just” sums of products.

Sum of products

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. Any other class or interface that attempts to extend Shape will receive a compilation error (or a runtime error, if you try to cheat). Let’s finish the declaration of Shape using records to declare the subtypes:

sealed interface Shape {

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 { }
}

Sums of products are a very common and useful technique for modeling complex domains in a flexible but type-safe way, such as the nodes of a complex document.

Languages with product types often support destructuring of products with pattern matching; records were designed from the ground up to support easy destructuring.

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):

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

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 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. 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.

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.

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 follows:

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.

Side-note: 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".

We have somewhat similar source of exhaustiveness — a switch expression over an enum that covers all the known constants. Personally, I always add default clause that throws AssertionError. If I got one of the expected enum values, everything is fine, I have corresponding switch case that handles it. But if, new value was added to enum, for example, enum have changed between compile and run time, so I’ve manually added default clause that throws AssertionError.

In pattern matching was decided that in if sum of product pattern was used, it is unlikely that new sealed typewill be added in the future, because it usually models well-defined domain (such as type of the Shapes available) that doesn’t evolve. So, different advice was given to the software engineers, drop default case. In most cases compiler check will be enough to ensure that code go over all cases. In unlikely cases, when new class was added to type hierarchy for whatever reason (for example, we decided to add new type of the shape to the system) there is compile-generated guard that will trigger AssertionError in exact same way as described above with switch with enum.

Note: Technically switch with enum qualifies as example of pattern matching. As discussed in part II enum is product type. So switch on enum with match on enum values qualifies as example of pattern-matching on (restricted form of) of product type. So, it is good that we have the same behavior in both cases, even if in pattern matching compiler takes part of the job, it both statically checks the switch and adds default case for edge-cases).

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.

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.

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.

Based on

https://www.infoq.com/articles/java-sealed-classes/
https://openjdk.org/jeps/409
https://www.infoq.com/articles/java-14-feature-spotlight/
https://openjdk.org/jeps/395
https://habr.com/ru/articles/274103/

--

--