Java Sealed Types im Detail
Geschlossene Typhierarchien in Java mit sealed classes und Interfaces modellieren, besonders in Kombination mit Pattern Matching.
Sealed Classes und Interfaces (seit Java 17 finalisiert) ermöglichen es einem Typ, genau festzulegen, welche anderen Typen ihn erweitern oder implementieren dürfen. Statt einer offenen Hierarchie, die jeder ableiten kann, definiert man eine geschlossene Menge, über die der Compiler vollständig Bescheid weiß. Diese eine Garantie — diese und nur diese — ist die Grundlage für erschöpfendes Pattern Matching und macht datenorientierte Klassenhierarchien sicher modellierbar.
Ein sealed type ist der natürliche Partner von Records. Records liefern die Daten; Sealing liefert die geschlossene Fallmenge. Zusammen bringen sie algebraische Datentypen (den „Sum Type", wie man ihn aus Kotlin, Rust oder Scala kennt) nach Java und verändern das Verhalten eines switch über eine Hierarchie grundlegend.
Dieses Kapitel behandelt, wie man einen Typ mit permits versiegelt, die obligatorische Wahl zwischen final, sealed und non-sealed für jeden Subtyp, warum eine geschlossene Hierarchie einen erschöpfenden switch ohne default ermöglicht und wie Sealing mit Record-Dekonstruktion und bewachten Mustern kombiniert werden kann. Es baut auf Interfaces und Vererbung auf.
Einen Typ mit permits versiegeln
Ein Typ wird durch den Modifier sealed und eine permits-Klausel, die jeden direkten Subtyp auflistet, versiegelt. Kein anderer Typ kann der Hierarchie beitreten, selbst nicht im selben Paket. Die erlaubten Subtypen müssen für den sealed type zugänglich sein und — im unbenannten Modul — im selben Paket (oder Modul) liegen.
public sealed interface Payment
permits Cash, Card, BankTransfer {}
public record Cash(int amount) implements Payment {}
public record Card(String number, int amount) implements Payment {}
public record BankTransfer(String iban, int amount) implements Payment {}Wenn ein Subtyp in derselben Quelldatei liegt, ist die permits-Klausel optional — der Compiler leitet sie aus der Datei ab. Man muss permits nur dann explizit angeben, wenn die Subtypen in separaten Dateien liegen.
// Same file: permits is inferred, so it can be omitted.
sealed interface Expr {
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
}Die Regel für final, sealed und non-sealed
Jeder erlaubte Subtyp muss selbst festlegen, wie sein Teil der Hierarchie geschlossen ist. Der Compiler erzwingt eine Wahl: Jeder direkte Subtyp muss als final, sealed oder non-sealed deklariert werden. Es gibt keine „nichts tun"-Option — das Weglassen des Modifiers ist ein Kompilierfehler.
| Modifier | Bedeutung für den Subtyp |
|---|---|
final | Der Subtyp kann nicht weiter abgeleitet werden. Records sind implizit final. |
sealed | Der Subtyp ist selbst geschlossen und gibt seine eigene permits-Liste an. |
non-sealed | Der Subtyp öffnet die Hierarchie wieder — jede Klasse darf ihn erneut erweitern. |
public sealed class Shape permits Circle, Polygon, Freeform {}
public final class Circle extends Shape {} // closed here
public sealed class Polygon extends Shape // closed, but to a set
permits Triangle, Rectangle {}
public non-sealed class Freeform extends Shape {} // reopened: any subclass allowed
public final class Triangle extends Polygon {}
public final class Rectangle extends Polygon {}non-sealed ist die Ausstiegsluke: Sie erlaubt es, einen Ast einer sonst geschlossenen Hierarchie offen für Erweiterungen zu halten. Man sollte sie sparsam einsetzen, da sie die Erschöpfungsgarantie für diesen Ast aufgibt.
Warum Sealing einen erschöpfenden Switch ermöglicht
Der Vorteil einer geschlossenen Hierarchie liegt darin, dass der Compiler die vollständige Fallmenge kennt. Ein switch über einen sealed type, der alle erlaubten Subtypen abdeckt, ist erschöpfend — man benötigt keinen default-Zweig. Besser noch: Wenn jemand später einen neuen erlaubten Subtyp hinzufügt, hört jeder nicht-erschöpfende switch auf zu kompilieren — der Compiler zeigt genau auf den Code, der den neuen Fall vergessen hat.
sealed interface Payment permits Cash, Card, BankTransfer {}
record Cash(int amount) implements Payment {}
record Card(String number, int amount) implements Payment {}
record BankTransfer(String iban, int amount) implements Payment {}
static String fee(Payment p) {
return switch (p) { // no default needed
case Cash c -> "no fee";
case Card c -> "2% card fee";
case BankTransfer b -> "flat fee";
};
}Lässt man den BankTransfer-Fall weg, kompiliert der Code nicht mehr: „the switch expression does not cover all possible input values." Dieser Kompilierzeitfehler ist der zentrale Grund, eine Hierarchie zu versiegeln.
Records, Dekonstruktion und Guards
Da erlaubte Subtypen gewöhnlich Records sind, kann man Sealing mit Record-Dekonstruktionsmustern und bewachten Mustern (when) kombinieren — siehe Pattern Matching für die vollständige Funktion. Dekonstruktion bindet die Komponenten eines Records direkt im case-Label; ein Guard fügt eine boolesche Bedingung hinzu. Die Reihenfolge ist wichtig: spezifischere bewachte Fälle müssen vor dem unbewachten Fallback für denselben Typ stehen.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static String describe(Shape s) {
return switch (s) {
case Circle(double r) when r > 10 -> "big circle";
case Circle(double r) -> "circle r=" + r;
case Rectangle(double w, double h) when w == h -> "square";
case Rectangle(double w, double h) -> "rectangle";
};
}Der Compiler behandelt dies weiterhin als erschöpfend: Jeder erlaubte Subtyp wird von mindestens einem unbewachten Label abgedeckt, sodass der gesamte switch vollständig ist, obwohl einige Labels bewacht sind.
Ein vollständiges Beispiel
Das ausführbare Beispiel unten fasst alles zusammen: ein sealed Shape-Interface mit drei Record-Subtypen, ein erschöpfender switch für die Fläche, ein bewachter Dekonstruktions-switch für die Beschreibung und ein Blick auf die Versiegelungsmetadaten über Reflection. Es verwendet nur das JDK und läuft direkt.
Was man aus dem Lauf mitnehmen kann:
- Der Flächen-
switchhat keinendefault-Zweig — daShapeversiegelt ist, ist das Abdecken aller drei Records bereits erschöpfend. describegibtbig circle r=12.0nur für den Kreis mit Radius 12 aus und beweist damit, dass derwhen r > 10-Guard vor dem unbewachtenCircle-Label geprüft wird.- Das Rechteck mit Seitenlänge 5 gibt
square side=5.0aus, was zeigt, dass derw == h-Guard den nachfolgenden einfachenRectangle-Fall übersteuert. - Die Gesamtfläche (525,96) wird über alle Record-Subtypen akkumuliert und bestätigt, dass eine einzige polymorphe Schleife die gesamte geschlossene Hierarchie verarbeitet.
Shape.class.isSealed()gibttruezurück undgetPermittedSubclasses()listet Circle, Rectangle und Triangle auf — diepermits-Menge bleibt als Laufzeit-Metadaten erhalten.