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:
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):
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.
- Wir wollen uns die Vollständigkeitsanalyse bei "Pattern Matching for switch" zunutze machen.
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:
Um die Übersicht zu verbessern, habe ich in der folgenden Grafik die durchgestrichenen Kästchen unter den finalen Klassen entfernt:
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 KlassenCircle
,Square
,Rectangle
undWeirdShape
erweitert werden. - Die Klasse
Rectangle
darf nur durch die KlassenTranspRectangle
undFilledRectangle
erweitert werden.
Das folgende Klassendiagramm zeigt die durch sealed
und permits
hinzugekommenen Einschränkungen:
Der Übersicht halber, hier noch einmal ohne die durchgestrichenen Klassen:
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:
Prüfen, ob eine Klasse versiegelt ist und welche Klassen sie erweitern können
Die Klasse Class
wurde um die folgenden zwei Methoden erweitert:
isSealed()
– gibttrue
zurück, wenn diese Klasse oder dieses Interface versiegelt ist.getPermittedSubclasses()
– gibt ein Array der Klassen oder Interfaces zurück, die diese Klasse bzw. dieses Interface erweitern dürfen, bzw.null
, wenn diese Klasse/dieses Interface nicht versiegelt ist.
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 doSomethingSmart() {
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 doSomethingSmart() {
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.