Hi folks, With JDK17 introducing sealed classes[1] (which has been in preview for JDK15/16), Groovy needs to at least properly handle such classes, i.e. as a minimum not allow such classes to be extended in Groovy. Otherwise, created classes will give an IncompatibleClassChangeError when loaded.
The GEP[2] and related PR[3] proposes an approach to both handle that scenario and also support (almost) the same feature on earlier JDKs using annotations. I am interested in any feedback and a couple of points in particular which are covered later. Just to summarise, sealed classes provide a way to create a hierarchy of related classes where further extension beyond the hierarchy is limited. Public classes are open to extension by anyone. Final classes are closed for extension. A sealed class hierarchy can be thought of as providing a mechanism between these two extremes where extension is open within the hierarchy but closed (or restricted) outside of the hierarchy. As an initial example, we could create a shape hierarchy of just circles and squares as follows (using the annotation syntax): @Sealed(permittedSubclasses='Circle,Square') interface Shape { } final class Circle implements Shape { } final class Square implements Shape { } Or if in the same source file, just: @Sealed interface Shape { } final class Circle implements Shape { } final class Square implements Shape { } Attempts to create further children will result in compilation errors. Sealed hierarchies are useful when creating certain kinds of ADTs: @Sealed interface Tree<T> {} @Singleton final class Empty implements Tree { String toString() { 'Empty' } } @Canonical final class Node<T> implements Tree<T> { T value Tree<T> left, right } We can have "enhanced" enum-like use cases: @Sealed abstract class Weather { } @Immutable class Rainy extends Weather { Integer expectedRainfall } @Immutable class Sunny extends Weather { Integer expectedTemp } @Immutable class Cloudy extends Weather { Integer expectedUV } def forecast = [new Rainy(12), new Sunny(35), new Cloudy(6)] We can mix with traits: interface HasHeight { double getHeight() } interface HasArea { double getArea() } @SelfType([HasHeight, HasArea]) @Sealed(permittedSubclasses='UnitCylinder,UnitCube') trait HasVolume { double getVolume() { height * area } } final class UnitCube implements HasVolume, HasHeight, HasArea { // for the purposes of this example: h=1, w=1, l=1 double height = 1d double area = 1d } final class UnitCylinder implements HasVolume, HasHeight, HasArea { // for the purposes of this example: h=1, diameter=1 // radius=diameter/2, area=PI * r^2 double height = 1d double area = Math.PI * 0.5d**2 } assert new UnitCube().volume == 1d assert new UnitCylinder().volume == 0.7853981633974483d And we can have cases where further extension is not allowed (final), or permitted either in an open (@NonSealed) or controlled (@Sealed) way: @Sealed(permittedSubclasses='Circle,Polygon,Rectangle') class Shape { } final class Circle extends Shape { } @NonSealed class Polygon extends Shape { } final class Pentagon extends Polygon { } @Sealed(permittedSubclasses='Square') class Rectangle extends Shape { } final class Square extends Rectangle { } Notes (covered further in GEP-13): * An extension (and indeed likely soon) will be to extend the grammar to support 'sealed', 'non-sealed' and 'permits' keywords. As mentioned below, the keywords would be Groovy 4+ only. * The current intention is that the annotation will allow sealed classes to work from JDK8+ but would only recognise Java17 sealed classes and produce Java17 compatible bytecode when used on JDK17+. The current PR marks the annotations as @Incubating, so we have some time to alter direction slightly if we merge the PR but want to later change some of the details but it would be good to zero in on desired behavior as quickly as possible, hence the following questions/discussion points. Questions/Discussion points: (1) The annotations would allow some or all of the initial change to be backported to versions of Groovy (e.g. to a future 3.0.x or 3.1.x) where the grammar change isn't made. Are we happy to support both styles? For traits, the @Trait annotation still exists, but we promote use of the keyword variant and regard the annotation as mostly an internal implementation detail. (2) The @NonSealed annotation is aligned with what would be the `non-sealed` modifier. Other forums have discussed the hyphenated 'keyword' as being a little ugly. Kotlin uses 'open' and defaults to classes being final. Scala uses sealed and final but just regards no modifier as being the non-sealed case. Java designers thought that in general, non-sealed would be the least used option and didn't want to align that with no modifier (for fear of developers accidentally choosing that option accidentally). Do we follow Scala's lead and align omitting the non-sealed modifier as designating open inheritance? (This aligns somewhat with Groovy's goal of minimising boilerplate and not assuming its users need excessive hand holding). Do we have an alias like unsealed (and @Unsealed) for those wanting to avoid the "ugly" hyphenated keyword? Java suggests it may likely have further such keywords in the future in any case. (3) We could use Class[] rather than String[] for permittedSubclasses or also support permittedSubclassNames or similar. (4) Java17 will produce an error if not all of the classes mentioned in the permits clause are found at compilation time. Currently the PR doesn't attempt to do this. (5) Like Java17, this PR opens the possibility of compilers (perhaps just for type checked code in Groovy's case) to check for exhaustiveness if sealed hierarchies are used in switch expressions/statements but doesn't currently implement this feature. Any thoughts? Thanks, Paul. [1] https://openjdk.java.net/jeps/409 [2] https://github.com/apache/groovy-website/blob/asf-site/site/src/site/wiki/GEP-13.adoc (Draft) [3] https://github.com/apache/groovy/pull/1606