W3docs

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.

ModifierBedeutung für den Subtyp
finalDer Subtyp kann nicht weiter abgeleitet werden. Records sind implizit final.
sealedDer Subtyp ist selbst geschlossen und gibt seine eigene permits-Liste an.
non-sealedDer 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.

java— editable, runs on the server

Was man aus dem Lauf mitnehmen kann:

  • Der Flächen-switch hat keinen default-Zweig — da Shape versiegelt ist, ist das Abdecken aller drei Records bereits erschöpfend.
  • describe gibt big circle r=12.0 nur für den Kreis mit Radius 12 aus und beweist damit, dass der when r > 10-Guard vor dem unbewachten Circle-Label geprüft wird.
  • Das Rechteck mit Seitenlänge 5 gibt square side=5.0 aus, was zeigt, dass der w == h-Guard den nachfolgenden einfachen Rectangle-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() gibt true zurück und getPermittedSubclasses() listet Circle, Rectangle und Triangle auf — die permits-Menge bleibt als Laufzeit-Metadaten erhalten.

Übungen

Übung
Warum kann ein erschöpfender switch über ein sealed Interface den default-Zweig weglassen?
Warum kann ein erschöpfender switch über ein sealed Interface den default-Zweig weglassen?
Was this page helpful?