Sealed Classes und Pattern Matching für switch in Java 21
1. Übersicht
Wenn ein Datentyp nur wenige feste Varianten hat – beispielsweise Zahlungen per Karte, Banküberweisung oder E-Wallet – behandelst du sie normalerweise mit einer Kette von if (x instanceof ...). Diese Herangehensweise ist umständlich. Schlimmer noch, es ist leicht, einen Fall zu übersehen, ohne dass es jemand bemerkt.
Java 21 löst dieses Problem mit drei Funktionen, die zusammenwirken: sealed Classes (seit Java 17 offiziell), Pattern Matching für switch (JEP 441) und Record Patterns (JEP 440). Nach diesem Artikel wirst du wissen, wie du eine abgeschlossene Typhierarchie modellierst, switch-Anweisungen schreibst, die der Compiler vollständig überprüft, und häufige Fehler vermeidest.
2. Das Problem mit der alten Herangehensweise
Angenommen, du möchtest die Gebühr für jeden Zahlungstyp berechnen. Vor Java 21 sah der Code typischerweise so aus:
public long fee(Object payment) {
if (payment instanceof CardPayment c) {
return c.amount() * 2 / 100;
} else if (payment instanceof BankTransfer b) {
return 5_500;
}
throw new IllegalArgumentException("Nicht unterstützt: " + payment);
}
Der obige Code hat drei Probleme:
- Du hast
EWalletvergessen, aber der Code kompiliert trotzdem. Der Fehler offenbart sich erst zur Laufzeit. - Der finale
throw-Zweig ist erforderlich, auch wenn es geschäftlich nie vorkommen sollte. - Wenn du einen neuen Zahlungstyp hinzufügst, musst du manuell alle Stellen mit dieser
if-Kette finden und aktualisieren.
3. Sealed Classes
3.1. Deklaration eines sealed Interface
Eine sealed Class oder ein sealed Interface listet exakt auf, welche Klassen es erben dürfen:
public sealed interface Payment permits CardPayment, BankTransfer, EWallet {
long amount();
}
public record CardPayment(String cardNumber, long amount) implements Payment {}
public record BankTransfer(String bankCode, long amount) implements Payment {}
public record EWallet(String provider, long amount) implements Payment {}
Das Schlüsselwort sealed zusammen mit der permits-Klausel teilt dem Compiler mit, dass Payment genau drei Subtypen hat. Wenn eine andere Klasse wie class Crypto implements Payment zu erben versucht, erhältst du einen Compilerfehler.
Falls die Subklassen in der gleichen Datei wie die Superklasse liegen, kannst du permits weglassen. Der Compiler leitet die Liste automatisch ab.
3.2. Regeln für Subklassen
Jede direkte Subklasse eines sealed Type muss einen der drei Modifizierer deklarieren:
| Modifizierer | Bedeutung |
|---|---|
final |
Keine weitere Vererbung erlaubt |
sealed |
Setzt die Einschränkung mit eigenem permits fort |
non-sealed |
Öffnet die Vererbung für alle wieder |
Records sind standardmäßig final, daher brauchst du im obigen Beispiel nichts hinzuzufügen. Bei normalen Klassen musst du es explizit angeben:
public sealed abstract class Notification permits EmailNotification, SmsNotification {}
public final class EmailNotification extends Notification {}
public non-sealed class SmsNotification extends Notification {}
// Gültig, weil SmsNotification non-sealed ist
public class ZaloNotification extends SmsNotification {}
Hier ist EmailNotification vollständig gesperrt. Umgekehrt ist SmsNotification für jede weitere Vererbung offen. Dies bedeutet, dass bei einem switch auf Notification der SmsNotification-Zweig auch ZaloNotification abdeckt.
3.3. Einschränkungen für Package und Modul
Die Klassen in permits müssen sich im gleichen Modul wie die Superklasse befinden. Falls dein Projekt keine Module nutzt (unnamed module), müssen sie im gleichen Package liegen. Das ist beabsichtigt: Der Autor der Superklasse kontrolliert die gesamte Hierarchie.
4. Pattern Matching für switch
4.1. Type Pattern
Ab Java 21 kann ein case-Label ein Typmuster mit einer Variablen sein:
public class FeeCalculator {
public long fee(Payment payment) {
return switch (payment) {
case CardPayment c -> c.amount() * 2 / 100;
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
}
}
Jeder case prüft nicht nur den Typ, sondern bindet auch die bereits kastete Variable (c, b, w). Die Variable ist nur in ihrem case-Zweig sichtbar. Der Code hat kein default und keinen throw.
4.2. Exhaustiveness: Der Compiler überprüft alle Fälle
Weil Payment sealed ist, kennt der Compiler genau die drei Subtypen. Wenn du die Zeile case EWallet w -> 0; löschst, kompiliert der Code nicht:
FeeCalculator.java:5: error: the switch expression does not cover all possible input values
return switch (payment) {
^
Das ist der größte Vorteil. Wenn du QrPayment zu permits hinzufügst, zeigt der Compiler an allen switch-Anweisungen auf Payment Fehler, bis sie aktualisiert sind. Der Compiler wird zu deiner To-Do-Liste.
Anmerkung: Wenn Klassen separat kompiliert werden und zur Laufzeit ein Subtyp erscheint, den der
switchnicht kennt, wirft die JVM eineMatchException. Der Compiler fügt automatisch einen Zweig für dich ein.
4.3. Guard mit when
Du kannst einem Pattern eine Bedingung mit when hinzufügen:
public long fee(Payment payment) {
return switch (payment) {
case CardPayment c when c.amount() >= 10_000_000 -> c.amount() * 1 / 100;
case CardPayment c -> c.amount() * 2 / 100;
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
}
Kartentransaktionen ab 10 Millionen VND haben 1 % Gebühr, der Rest 2 %. Der Zweig mit guard zählt nicht als vollständige Abdeckung von CardPayment. Daher ist der zweite case CardPayment c Zweig immer noch erforderlich.
Probieren wir es mit ein paar Transaktionen:
public class Main {
public static void main(String[] args) {
var calculator = new FeeCalculator();
List<Payment> payments = List.of(
new CardPayment("4111-1111", 500_000),
new CardPayment("4111-2222", 20_000_000),
new BankTransfer("VCB", 1_000_000),
new EWallet("momo", 200_000));
payments.forEach(p -> System.out.println(p + " -> Gebühr " + calculator.fee(p)));
}
}
Ergebnis:
CardPayment[cardNumber=4111-1111, amount=500000] -> Gebühr 10000
CardPayment[cardNumber=4111-2222, amount=20000000] -> Gebühr 200000
BankTransfer[bankCode=VCB, amount=1000000] -> Gebühr 5500
EWallet[provider=momo, amount=200000] -> Gebühr 0
4.4. Case-Reihenfolge und Dominanz
Die switch-Anweisung prüft Patterns von oben nach unten. Falls ein allgemeiner Zweig vor einem spezifischeren Zweig steht, wird der spätere Zweig nie erreicht. Der Compiler erkennt diesen Fehler:
return switch (payment) {
case CardPayment c -> c.amount() * 2 / 100;
case CardPayment c when c.amount() >= 10_000_000 -> c.amount() / 100; // Fehler
case BankTransfer b -> 5_500;
case EWallet w -> 0;
};
error: this case label is dominated by a preceding case label
Einfache Regel: Platziere spezifische Zweige (mit guard oder Subtypen) oben, allgemeine Zweige unten.
4.5. Mit null umgehen
Standardmäßig wirft switch bei null eine NullPointerException, wie das frühere Verhalten. Wenn du null explizit behandeln möchtest, fügst du case null hinzu:
public String label(Payment payment) {
return switch (payment) {
case null -> "Zahlungsart nicht gewählt";
case CardPayment c -> "Karte";
case BankTransfer b -> "Überweisung";
case EWallet w -> "Wallet " + w.provider();
};
}
Du kannst auch case null, default -> kombinieren, wenn die switch-Anweisung einen default-Zweig hat.
5. Record Patterns
Record Patterns erlauben es, die Komponenten eines Record direkt im case zu destructurisieren. Dadurch brauchst du nicht auf Accessor zuzugreifen:
public String describe(Payment payment) {
return switch (payment) {
case CardPayment(String number, long amount) ->
"Karte ****" + number.substring(number.length() - 4) + ": " + amount;
case BankTransfer(var bank, var amount) -> "Überweisung " + bank + ": " + amount;
case EWallet(var provider, var amount) -> "Wallet " + provider + ": " + amount;
};
}
CardPayment(String number, long amount) passt, wenn payment ein CardPayment ist, und bindet gleichzeitig number und amount. Du kannst var verwenden, damit der Compiler den Typ selbst ableitet. Mit new CardPayment("4111-1111", 500_000) ist das Ergebnis Karte ****1111: 500000.
Die eigentliche Kraft von Record Patterns liegt in ihrer Fähigkeit zur Verschachtelung:
public sealed interface Transaction permits Charge, Refund {}
public record Charge(Payment payment) implements Transaction {}
public record Refund(Payment original, String reason) implements Transaction {}
public String audit(Transaction tx) {
return switch (tx) {
case Charge(CardPayment(var number, var amount)) when amount >= 10_000_000 ->
"Überprüfung erforderlich: Karte " + number;
case Charge(Payment p) -> "Eingezogen " + p.amount();
case Refund(var original, var reason) -> "Rückerstattung " + original.amount() + " (" + reason + ")";
};
}
Der erste Zweig passt ein Charge mit einem großen CardPayment in nur einer Zeile. Der Compiler überprüft immer noch die Exhaustiveness für verschachtelte Patterns: Charge(Payment p) deckt alle Charge ab, Refund(...) deckt alle Refund ab.
Anmerkung: Das Unnamed Pattern
_, z.B.case Refund(_, var reason), ist nur Preview in Java 21 und erfordert--enable-preview. Die Funktion wird in Java 22 offiziell. Falls dein Projekt auf Java 21 festgelegt ist, verwendevarmit normalen Variablennamen.
6. Tests
Du musst Java 21 zusammen mit JUnit 5 und AssertJ konfigurieren. Falls du Spring Boot 3.x nutzt, enthält spring-boot-starter-test beide bereits. Für ein reines Maven-Projekt:
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Test für FeeCalculator aus Abschnitt 4.3 und describe aus Abschnitt 5:
class FeeCalculatorTest {
private final FeeCalculator calculator = new FeeCalculator();
@Test
void cardPaymentBelowThreshold_charges2Percent() {
assertThat(calculator.fee(new CardPayment("4111-1111", 500_000))).isEqualTo(10_000);
}
@Test
void cardPaymentAboveThreshold_charges1Percent() {
assertThat(calculator.fee(new CardPayment("4111-2222", 20_000_000))).isEqualTo(200_000);
}
@Test
void bankTransferAndWallet_haveFixedFee() {
assertThat(calculator.fee(new BankTransfer("VCB", 1_000_000))).isEqualTo(5_500);
assertThat(calculator.fee(new EWallet("momo", 200_000))).isZero();
}
@Test
void describe_masksCardNumber() {
assertThat(calculator.describe(new CardPayment("4111-1111", 500_000)))
.isEqualTo("Karte ****1111: 500000")
.doesNotContain("4111-");
}
}
Tests sollten sich auf Guard-Grenzen konzentrieren (knapp unter und über 10 Millionen), da dies die fehleranfälligste Stelle für die case-Reihenfolge ist. Exhaustiveness muss man nicht testen: der Compiler kümmert sich darum.
7. Häufige Fehler und wann man sealed Classes nicht verwenden sollte
default zu einem switch über sealed Type hinzufügen. Der Code kompiliert noch, aber du verlierst den Hauptvorteil. Wenn du einen neuen Subtyp hinzufügst, fällt er stillschweigend in default, statt einen Compilerfehler zu verursachen. Bei sealed Types solltest du alle Zweige auflisten und default weglassen.
Vergessen, dass Zweige mit when den gesamten Typ nicht abdecken. Wenn alle case CardPayment Zweige einen Guard haben, wird der Compiler einen fehlenden Fall melden. Du brauchst immer einen Zweig ohne Guard oder einen allgemeineren Zweig am Ende.
switch überall statt Polymorphismus verwenden. Wenn das Verhalten wirklich zum Objekt selbst gehört, ist eine abstrakte Methode in der Superklasse immer noch die bessere Wahl. Pattern Matching ist sinnvoll, wenn die Logik außerhalb der Hierarchie liegt, z.B. Gebührenberechnung, Rendering, Mapping zu DTOs, und du nicht alles in Record quetschen möchtest.
Anmerkung: Sealed Classes sollten nicht verwendet werden, wenn:
- Du eine Bibliothek oder ein Plugin-System schreibst, bei dem Benutzer ihre eigenen Subtypen hinzufügen können.
- Die Klasse eine JPA Entity mit Lazy Loading ist. Hibernate erzeugt Proxies durch Vererbung, daher würden sealed oder final diese Mechanik blockieren.
- Die Subtypen sind über mehrere Module verteilt. Sealed erfordert, dass sie im gleichen Modul liegen oder im gleichen Package, wenn keine Module genutzt werden.
JSON-Serialisierung. Jackson weiß nicht automatisch, wie ein Payment in den richtigen Subtyp deserialisiert wird, nur weil das Interface sealed ist. Du musst immer noch Typinformationen konfigurieren, z.B. @JsonTypeInfo zusammen mit @JsonSubTypes.
8. Fazit
Sealed Classes ermöglichen es dir, klar zu deklarieren, dass ein Typ nur eine feste Menge von Varianten hat. Pattern Matching für switch und Record Patterns verwandeln diese Menge in prägnanten Code, der vom Compiler vollständig überprüft wird. Du kannst Guards mit when hinzufügen für zusätzliche Bedingungen, case null für explizite Null-Behandlung und verschachtelte Patterns für tiefere Strukturen.
Drei wichtige Grundsätze: Weglassen von default beim switch auf sealed Type, Platzierung spezifischer Zweige vor allgemeinen Zweigen, und Verwendung von sealed nur für Hierarchien, die du wirklich schließen möchtest.