Sealed types -- updated proposal
Brian Goetz
brian.goetz at oracle.com
Wed Jan 9 18:44:12 UTC 2019
|Here's an update on the sealed type proposal based on recent discussions. |
*Definition.* A /sealed type/ is one for which subclassing is restricted
according to guidance specified with the type’s declaration; finality
can be considered a degenerate form of sealing, where no subclasses at
all are permitted. Sealed types are a sensible means of modeling
/algebraic sum types/ in a nominal type hierarchy; they go nicely with
records (/algebraic product types/), though are also useful on their own.
Sealing serves two distinct purposes. The first, and more obvious, is
that it restricts who can be a subtype. This is largely a
declaration-site concern, where an API owner wants to defend the
integrity of their API. The other is that it potentially enables
exhaustiveness analysis at the use site when switching over sealed types
(and possibly other features.) This is less obvious, and the benefit is
contingent on some other things, but is valuable as it enables better
compile-time type checking.
*Declaration.* We specify that a class is sealed by applying the
|semi-final| modifier to a class, abstract class, or interface:
|semi-final interface Node { ... } |
In this streamlined form, |Node| may be extended only by named classes
declared in the same nest. This may be suitable for many situations, but
not for all; in this case, the user may specify an explicit |permits| list:
|semi-final interface Node permits FooNode, BarNode { ... } |
/Note: |permits| here is a contextual keyword./
The two forms may not be combined; if there is a permits list, it must
list all the permitted subtypes. We can think of the simple form as
merely inferring the |permits| clause from information in the nest.
*Exhaustiveness.* One of the benefits of sealing is that the compiler
can enumerate the permitted subtypes of a sealed type; this in turn lets
us perform exhaustiveness analysis when switching over patterns
involving sealed types. Permitted subtypes must belong to the same
module (or, if not in a module, the same package.)
/Note:/ It is superficially tempting to have a relaxed but less explicit
form, say which allows for a type to be extended by package-mates or
module-mates without listing them all. However, this would undermine the
compiler’s ability to reason about exhaustiveness. This would achieve
the desired subclassing restrictions, but not the desired ability to
reason about exhaustiveness.
*Classfile.* In the classfile, a sealed type is identified with an
|ACC_FINAL| modifier, and a |PermittedSubtypes| attribute which contains
a list of permitted subtypes (similar in structure to the nestmate
attributes.)
*Transitivity.* Sealing is transitive; unless otherwise specified, an
abstract subtype of a sealed type is implicitly sealed (permits list to
be inferred), and a concrete subtype of a sealed type is implicitly
final. This can be reversed by explicitly modifying the subtype with the
|non-final| modifier.
Unsealing a subtype in a hierarchy doesn’t undermine the sealing,
because the (possibly inferred) set of explicitly permitted subtypes
still constitutes a total covering. However, users who know about
unsealed subtypes can use this information to their benefit (much like
we do with exceptions today; you can catch |FileNotFoundException|
separately from |IOException| if you want, but don’t have to.)
/Note:/ Scala made the opposite choice with respect to transitivity,
requiring sealing to be opted into at all levels. This is widely
believed to be a source of bugs; it is rare that one actually wants a
subtype of a sealed type to not be sealed. I suspect the reasoning in
Scala was, at least partially, the desire to not make up a new keyword
for “not sealed”. This is understandable, but I’d rather not add to the
list of “things for which Java got the defaults wrong.”
An example of where explicit unsealing (and private subtypes) is useful
can be found in the JEP-334 API:
|semi-final interface ConstantDesc permits String, Integer, Float, Long,
Double, ClassDesc, MethodTypeDesc, MethodHandleDesc, DynamicConstantDesc
{ } semi-final interface ClassDesc extends ConstantDesc permits
PrimitiveClassDescImpl, ReferenceClassDescImpl { } private class
PrimitiveClassDescImpl implements ClassDesc { } private class
ReferenceClassDescImpl implements ClassDesc { } semi-final interface
MethodTypeDesc extends ConstantDesc permits MethodTypeDescImpl { }
semi-final interface MethodHandleDesc extends ConstantDesc permits
DirectMethodHandleDesc, MethodHandleDescImpl { } semi-final interface
DirectMethodHandleDesc extends MethodHandleDesc permits
DirectMethodHandleDescImpl // designed for subclassing non-final class
DynamicConstantDesc extends ConstantDesc { ... } |
*Enforcement.* Both the compiler and JVM should enforce sealing.
*Accessibility.* Subtypes need not be as accessible as the sealed
parent. In this case, not all clients will get the chance to
exhaustively switch over them; they’ll have to make these switches
exhaustive with a |default| clause or other total pattern. When
compiling a switch over such a sealed type, the compiler can provide a
useful error message (“I know this is a sealed type, but I can’t provide
full exhaustiveness checking here because you can’t see all the
subtypes, so you still need a default.”)
*Javadoc.* The list of permitted subtypes should probably be considered
part of the spec, and incorporated into the Javadoc. Note that this is
not exactly the same as the current “All implementing classes” list that
Javadoc currently includes, so a list like “All permitted subtypes”
might be added (possibly with some indication if the subtype is less
accessible than the parent.)
*Auxilliary subtypes.* With the advent of records, which allow us to
define classes in a single line, the “one class per file” rule starts to
seem both a little silly, and constrain the user’s ability to put
related definitions together (which may be more readable) while
exporting a flat namespace in the public API.
One way to do get there would be to relax the “no public auxilliary
classes” rule to permit for sealed classes, say: allowing public
auxilliary subtypes of the primary type, if the primary type is public
and sealed.
Another would be to borrow a trick from enums; for a sealed type with
nested subtypes, when you |import| the sealed type, you implicitly
import the nested subtypes too. That way you could declare:
|semi-final interface Node { class A implements Node { } class B
implements Node { } } |
but clients could import Node and then refer to A and B directly:
|switch (node) { case A(): ... case B(): ... } |
We do something similar for |enum| constants today.
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.java.net/pipermail/amber-spec-experts/attachments/20190109/ab371ad4/attachment-0001.html>
More information about the amber-spec-experts
mailing list