Sealed Classes in Java

Sealed Classes in Java

Author image
by Sven WoltmannDezember 28, 2021

Artikelserie: Project Amber

In Project Amber werden neue Java-Sprachfunktionen entwickelt und nach und nach veröffentlicht.

Teil 1: Switch Expressions (Java 14)

Teil 2: Text Blocks (Java 15)

Teil 3: Records (Java 16)

Teil 4: Sealed Classes (Java 17)

(Melde dich für den HappyCoders-Newsletter an,
um sofort über neue Teile informiert zu werden.)

Sealed Classes (deutsch: versiegelten Klassen) und Interfaces waren die große Neuerung in Java 17. In diesem Artikel erfährst du:

  • Was sind versiegelte Klassen und Interfaces?
  • Wie genau funktionieren versiegelte Klassen und Interfaces?
  • Wofür brauchen wir sie?
  • Warum sollte man die Erweiterbarkeit einer Klassenhierarchie einschränken?

Fangen wir an mit einem Beispiel...

Ausgangslage: Beispiel-Klassenhierarchie

Ausgangspunkt sei die folgende Klassenhierarchie:

Sealed Classes Beispiel - Ausgangslage
Sealed Classes Beispiel - Ausgangslage

Hier der Java-Quellcode zu dem Beispiel:

public class Shape { ... } public class Circle extends Shape { ... } public class Rectangle extends Shape { ... } public class Square extends Shape { ... } public class WeirdShape extends Shape { ... } public class TranspRectangle extends Rectangle { ... } public class FilledRectangle extends Rectangle { ... }
Code-Sprache: Java (java)

Für gewöhnlich kann jede Entwicklerin und jeder Entwickler diese Klassenhierarchie an allen Stellen erweitern. Eine erweiterte Struktur könnte wie folgt aussehen (die hinzugekommenen Klassen sind hellgelb gefärbt):

Sealed Classes Beispiel - Erweiterungsmöglichkeiten ohne Versiegelung
Sealed Classes Beispiel - Erweiterungsmöglichkeiten ohne Versiegelung

Nun kann es sein, dass wir die Erweiterung unserer Klassenhierarchie einschränken wollen. Z. B. könnten wir festlegen wollen, dass Entwicklerinnen und Entwickler ausschließlich die Klasse WeirdShape erweitern dürfen.

Warum könnten wir das wollen, und wie können wir das tun?

Warum die Erweiterbarkeit einer Klassenhierarchie einschränken?

Es kann mehrere Gründe geben, warum wir die freie Erweiterbarkeit unserer Klassenhierarchie einschränken wollen:

  • Wir wollen den inneren Zustand einer Klasse bzw. einer Hierarchie von Klassen schützen und diesen nicht durch eine abgeleitete Klasse auf inkonsistente Weise manipulieren lassen.
  • Wir wollen interne Objekte, deren Thread-Sicherheit durch unsere Klasse bzw. Klassenhierarchie garantiert wird, vor Veröffentlichung schützen, damit die Thread-Sicherheit nicht durch fremden Code gefährdet werden kann.
  • Wir wollen sicherstellen, dass das Liskovsche Substitutionsprinzip (LSP) nicht verletzt wird. Das heißt: Wir wollen nicht, dass ein Entwickler eine abgeleitete Klasse implementiert, die den API-Vertrag der Elternklasse bricht.

Da wir jetzt die Gründe kennen, aus denen wir eine Klassenhierarchie einschränken wollen, kommen wir zur nächsten Frage: Wie können wir das tun?

Klassenhierarchie versiegeln – Schritt für Schitt

Die erste Möglichkeit kennen wir bereits...

Klassenhierarchie einschränken mit "final"

Indem wir Klassen als "final" markieren, können wir deren Erweiterung generell verhindern.

Eine zweite Möglichkeit wäre es eine Klasse als package-private zu markieren, um nur Unterklassen innerhalb desselben Pakets zu erlauben. Das hätte allerdings zur Folge, dass die Oberklasse nicht mehr außerhalb des Pakets sichtbar wäre, was in den meisten Fällen unterwünscht ist.

Versuchen wir einmal "final" in unserem Beispiel einzusetzen. Wir markieren die Klassen Circle, TranspRectangle, FilledRectangle und Square als final (zur Erinnerung: WeirdShape soll als einzige Klasse erweiterbar bleiben).

Die Erweiterungsmöglichkeiten unserer Klassenhierarchie werden dadurch wie in folgendem Klassendiagramm dargestellt eingeschränkt:

Klassenhierarchie mit "final" einschränken
Klassenhierarchie mit "final" einschränken

Um die Übersicht zu verbessern, habe ich in der folgenden Grafik die durchgestrichenen Kästchen unter den finalen Klassen entfernt:

Klassenhierarchie mit "final" einschränken
Klassenhierarchie mit "final" einschränken

Damit sind wir auf einem guten Weg, aber noch lange nicht am Ziel. Was jetzt? Shape und Rectangle können wir offensichtlich nicht final machen, denn von diesen Klassen sollen ja andere erben.

An dieser Stelle kommen die Sealed Classes zum Einsatz...

Klassenhierarchie versiegeln mit "sealed" und "permits"

Mit "Sealed Classes" können wir eine sogenannte "versiegelte Klassenhierarchie" implementieren. Das funktioniert wie folgt:

  • Wir markieren die Klasse, deren Unterklassen wir restriktieren wollen, mit dem Keyword sealed.
  • Mit dem Keyword permits listen wir die erlaubten Unterklassen auf.

Wir erweitern den Code der Klassen Shape und Rectangle wie folgt:

public sealed class Shape permits Circle, Square, Rectangle, WeirdShape { ... } public sealed class Rectangle extends Shape permits TranspRectangle, FilledRectangle { ... }
Code-Sprache: Java (java)

Wir sagen mit diesem Code das Folgende aus:

  • Die Klasse Shape darf nur durch die Klassen Circle, Square, Rectangle und WeirdShape erweitert werden.
  • Die Klasse Rectangle darf nur durch die Klassen TranspRectangle und FilledRectangle erweitert werden.

Das folgende Klassendiagramm zeigt die durch sealed und permits hinzugekommenen Einschränkungen:

Klassenhierarchie mit "sealed" und "permits" einschränken
Klassenhierarchie mit "sealed" und "permits" einschränken

Der Übersicht halber, hier noch einmal ohne die durchgestrichenen Klassen:

Klassenhierarchie mit "sealed" und "permits" einschränken
Klassenhierarchie mit "sealed" und "permits" einschränken

Es sieht so aus, als hätten wir damit unser Ziel erreicht. Doch ein Schritt fehlt noch...

Versiegelte Klassenhierarchie mit "non-sealed" öffnen

Durch die bisherigen Änderungen sieht unser Code wie folgt aus:

public sealed class Shape permits Circle, Square, Rectangle, WeirdShape { ... } public final class Circle extends Shape { ... } public sealed class Rectangle extends Shape permits TranspRectangle, FilledRectangle { ... } public final class Square extends Shape { ... } public class WeirdShape extends Shape { ... } public final class TranspRectangle extends Rectangle { ... } public final class FilledRectangle extends Rectangle { ... }
Code-Sprache: Java (java)

Wenn wir versuchen diesen Code zu compilieren, erhalten wir die folgende Fehlermeldung:

$ javac *.java WeirdShape.java:3: error: sealed, non-sealed or final modifiers expected public class WeirdShape extends Shape { ^
Code-Sprache: Klartext (plaintext)

Um versehentliche Öffnungen der versiegelten Klassenhierarchie zu verhindern, müssen alle Klassen der Hierarchie mit sealed, non-sealed oder final markiert werden.

Unsere Klasse WeirdShape soll erweiterbar sein, d. h. an dieser Klasse soll die Versiegelung geöffnet werden. Dazu müssen wir diese Klasse mit non-sealed markieren:

public non-sealed class WeirdShape extends Shape { ... }
Code-Sprache: Java (java)

Unsere abschließende Klassenhierarchie sieht damit so aus:

Versiegelte Klassenhierarchie mit "non-sealed" öffnen
Versiegelte Klassenhierarchie mit "non-sealed" öffnen

Besonderheiten

Bei der Verwendung von versiegelten Klassenhierarchien gilt es einige Besonderheiten zu beachten.

Versiegelung innerhalb einer "Compilation Unit"

Das Keyword permits kann weggelassen werden, wenn innerhalb einer Klassendatei ("compilation unit") von einer versiegelten Klasse abgeleitete Unterklassen definiert werden. Diese gelten dann als "implizit deklarierte zulässige Unterklassen" ("implicitly declared permitted subclasses").

In folgendem Beispiel ist ChildInSameCompilationUnit eine solche Unterklasse; das permits-Keyword darf daher weggelassen werden:

public sealed class SealedParentWithoutPermits { public final class ChildInSameCompilationUnit extends SealedParentWithoutPermits { // ... } }
Code-Sprache: Java (java)

Lokale Klassen

Lokale Klassen (also innerhalb von Methoden definierte Klassen) dürfen versiegelte Klassen nicht erweitern.

Der folgende Code zeigt eine lokale Klasse, die eine nicht versiegelte Klasse erweitert. Dieser Code ist gültig:

public class NonSealedParent { public void doSomething() { class LocalChild extends NonSealedParent { // Allowed // ... } // ... } }
Code-Sprache: Java (java)

Wenn die äußere Klasse allerdings versiegelt ist, darf die lokale Klasse nicht von ihr erben (auch nicht, wenn diese in der permits-Liste angegeben ist):

public sealed class SealedParent { public void doSomething() { class LocalChild extends SealedParent { // Not allowed // ... } // ... } }
Code-Sprache: Java (java)

instanceof-Tests mit versiegelten Klassen

Bei instanceof-Tests prüft der Compiler, ob die Klassenhierarchie es zulässt, dass der Check jemals true ergeben kann. Ist das nicht der Fall, meldet der Compiler einen "incompatible types"-Fehler, wie z. B. in folgendem Code:

Number n = getNumber(); if (n instanceof String) { // Not allowed // ... }
Code-Sprache: Java (java)

Ein Number-Objekt kann nie eine Instanz eines Strings sein. Der Compiler meldet daher:

incompatible types: Number cannot be converted to String

Auch die Informationen aus versiegelten Klassenhierarchien werden mit in diese Prüfung aufgenommen. Was das bedeutet, erkläre ich am besten an einem Beispiel:

Nehmen wir an, wir haben ein Interface A und eine Klasse B:

interface A {} class B {}
Code-Sprache: Java (java)

Damit ist folgender Check valide:

public boolean isAaB(A a) { return a instanceof B; }
Code-Sprache: Java (java)

Wie kann dieser Check true ergeben? Indem wir eine Klasse C definieren, die von B erbt und A implementiert:

class C extends B implements A {}
Code-Sprache: Java (java)

Der Check isAaB(new C()) ergibt dann true.

Nun versiegeln wir das Interface A und erlauben als Unterklasse nur noch AChild; Klasse B lassen wir unverändet:

sealed interface A permits AChild {} final class AChild implements A {} class B {}
Code-Sprache: Java (java)

Der Compiler erkennt nun, dass ein Objekt vom Typ A niemals auch eine Instanz von B sein kann. Entsprechend wird die Prüfung if (a instanceof B) ab sofort mit folgendem Compilerfehler quittiert:

incompatible types: A cannot be converted to B

Contextual Keywords

Die Einführung neuer Keywords wie sealed, non-sealed, permits (oder auch yield aus den Switch-Expressions) warf bei den JDK-Entwicklern folgende Frage auf: Was soll mit bestehendem Code passieren, der diese Keywords als Methoden- oder Variablennamen verwendet?

Da Java einen hohen Wert auf Abwärtskompatibilität legt, entschied man sich dazu bestehenden Code möglichst nicht zu beeinträchtigen. Möglich machen das sogenannte "Contextual Keywords" – Keywords, die nur in einem bestimmten Kontext eine Bedeutung haben.

Die Begriffe sealed und permits z. B. sind solche "Contextual Keywords" und haben nur im Kontext der Klassendefinition eine Bedeutung. In anderen Kontexten können sie als Methoden- oder Klassenname verwendet werden. Folgendes ist also erlaubt:

public void sealed() { int permits = 5; }
Code-Sprache: Java (java)

Vollständigkeitsanalyse bei "Pattern Matching for switch"

In Java 17 wurde "Pattern Matching for switch" als Preview-Feature vorgestellt. In Kombination mit diesem Feature erlauben versiegelte Klassen eine Erschöpfungsanalyse, d. h. der Compiler kann prüfen, ob ein switch-Statement oder -Ausdruck alle möglichen Fälle abdeckt.

Hier eine kleine Klassenhierarchie mit einem versiegelten Interface als Wurzel:

public sealed interface Color permits Red, Blue {} public final class Red implements Color {} public final class Blue implements Color {}
Code-Sprache: Java (java)

"Pattern Matching for switch" ermöglicht Code wie den folgenden:

Color color = getColor(); switch (color) { case Red r -> ... case Blue b -> ... }
Code-Sprache: Java (java)

Der Compiler erkennt, dass das Objekt color nur eine Instanz von Red oder Blue sein kann; das switch-Statement ist also vollständig und benötigt keinen default-Fall.

Ein weiterer Vorteil ist, dass wir bei einer eventuellen späteren Erweiterung der Klassenhierachie vom Compiler auf den fehlenden switch-Fall hingewiesen werden.

Erweitern wir unsere Klassenhierarchie um die Farbe grün (nicht vergessen: die permits-Liste von Color erweitern):

public sealed interface Color permits Red, Blue, Green {} public final class Red implements Color {} public final class Blue implements Color {} public final class Green implements Color {}
Code-Sprache: Java (java)

Beim Versuch das switch-Statement zu compilieren bricht der Compiler jetzt mit folgender Fehlermeldung ab:

$ javac --enable-preview -source 17 SwitchTest.java SwitchTest.java:6: error: the switch statement does not cover all possible input values switch (color) { ^
Code-Sprache: Klartext (plaintext)

Der Compiler kann uns also bei versiegelten Klassenhierarchien helfen unvollständige switch-Statements oder -Ausdrücke – eine häufige Fehlerursache bei der Erweiterung von Klassenhierarchien – zu vermeiden.

Fazit

Versiegelte Klassen wurden durch JDK Enhancement Proposal 409 in Java 17 eingeführt. Sie erlauben uns eine Klassenhierarchie vor ungewünschten Erweiterungen zu schützen.

Für das in 17 als Preview-Feature eingeführte "Pattern Matching for Switch" ermöglichen sie darüber hinaus eine Vollständigkeitsanalyse.

Sealed Classes wurden zusammen mit anderen neuen Sprachfeatures wie Records, Switch Expressions, Text Blocks und Pattern Matching in Projekt Amber entwickelt.

Wenn dir der Artikel gefallen hat, hinterlasse mir gerne einen Kommentar oder teile den Artikel über einen der Share-Buttons am Ende.

Möchtest du informiert werden, wenn neue Artikel veröffentlicht werden? Dann klicke hier, um dich für den HappyCoders-Newsletter anzumelden.