Java’s record and sealed classes as categorical product and sum types. Part III
Quick recap
The categorical product
is, 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
orcoproduct
, 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 classes
is “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 Shape
s is equal to the set of all Circle
s plus the set of all Rectangles
s. 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.
Sealed
class 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 type
will be added in the future, because it usually models well-defined domain (such as type of the Shape
s 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 IIenum
isproduct
type. So switch onenum
with match on enum values qualifies as example of pattern-matching on (restricted form of) ofproduct
type. So, it is good that we have the same behavior in both cases, even if inpattern matching
compiler takes part of the job, it both statically checks theswitch
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/