8.3 Versiegelte Klassen und Schnittstellen
Bevor wir zum eigentlichen Thema, versiegelte Klassen, kommen, wollen wir kurz die Eigenschaften der bisher besprochenen Typen wiederholen:
Typ |
Instanzanzahl |
Unterklassen möglich |
anpassbares Verhalten |
veränderbare Zustände |
---|---|---|---|---|
reguläre Klassendeklaration |
beliebig |
ja |
möglich |
ja |
Klasse mit abstrakter Methode |
beliebig |
Muss |
möglich |
ja |
Klasse mit finaler Methode |
beliebig |
ja |
Ja, nur nicht die finale Methode |
ja |
finale Klasse |
beliebig |
nein |
nein |
ja |
Aufzählungstyp |
fest |
nein |
ja |
nein |
Die Tabelle macht deutlich, dass die einzelnen Typen sich immer irgendwie etwas unterscheiden – bei einer Art ist etwas verboten, bei einer anderen Art wiederum explizit notwendig.
Bei Aufzählungen lassen sich selbst keine Instanzen bilden, sondern die Objekte werden intern gebildet. Es gibt so viele Instanzen, wie es Elemente in dem Aufzählungstyp gibt. Explizite Unterklassen sind von Aufzählungstypen prinzipiell nicht möglich, indirekt allerdings schon. Dazu wird im ersten Schritt eine abstrakte Methode im Aufzählungstyp eingeführt (oder eine Schnittstelle implementiert), die dann die einzelnen Aufzählungselemente implementieren können. So gesehen lassen sich von nichtfinalen regulären Klassen beliebig viele Unterklassen bilden, aber von Aufzählungstypen nur beschränkt viele, und das auch nur intern; die Unterklassen selbst sind gar nicht sichtbar, sondern werden versteckt. Aufzählungstypen dürfen auch keine veränderbaren Zustände speichern, sie müssen Konstanten bleiben.
Nehmen wir an, wir möchten ausdrücken, dass das Ergebnis einer Operation gültig oder ungültig war. Das lässt sich über einen Aufzählungstyp ausdrücken, etwa so:
enum Result {
Failure, Success
}
Eine Methode könnte etwas vom Typ Result liefern:
public class Baking {
static Result cake() { Failure oder Success zurückgeben }
}
Die Aufrufer wissen, dass sie nur mit zwei Arten von Result rechnen müssen: Failure oder Success – etwas anderes ist unmöglich, es kommen keine weiteren Result-Typen dazu.
Jetzt könnte aber die Anforderung kommen, dass ein Zustand mit gespeichert werden soll, dass etwa beide eine beliebige Nachricht merken können oder der Fehler einen Fehlercode haben soll. Schon sind Aufzählungstypen raus, und Klassen kommen ins Spiel. Alternativ:
abstract class Result {
Object body;
}
class Success extends Result { }
class Failure extends Result {
final int errorCode;
}
Mit dieser Implementierung kann der Zustand gespeichert werden. Kommen wir noch einmal zur Methode cake():
Result cake() { ... }
Die Methode liefert wieder Result, doch der Empfänger hat ein Problem, denn neben Success oder Failure könnte es von der Klasse Result noch beliebig viele Unterklassen geben, die cake() aufbauen und liefern könnte.
Gesucht ist eine Möglichkeit, die Anzahl möglicher Unterklassen zu beschränken, aber prinzipiell beliebig viele Instanzen zuzulassen, die unterschiedliche Zustände haben können. Und dann sind wir bei den versiegelten Klassen (engl. sealed classes).
8.3.1 Versiegelte Klassen und Schnittstellen (sealed classes/interfaces)
Bei versiegelten Klassen ist im Vorfeld bekannt, welche Klassen sie erweitern. Der Compiler kann damit gewisse Prüfungen vornehmen, etwa auf die Vollständigkeit bei switch, wie beim enum.
Versiegelte Klassen sind nichtfinal und führen mit permits die erlaubten Unterklassen auf:
abstract sealed class Result permits Failure, Success {
final Object body;
public Result( Object body ) { this.body = body; }
}
Ob die Oberklasse abstrakt ist oder nicht, spielt für permits keine Rolle, in unserem Fall muss es nicht direkt Result-Instanzen geben. permits ist ein wenig wie ein aufgeweichtes final; bei final darf es gar keine Unterklassen geben, bei permits immerhin ein paar spezielle.
In unserem Beispiel darf es nur die zwei Unterklassen Failure und Success geben. Die Unterklassen sind in unserem Beispiel final, damit nicht nachträglich noch neue Typen auftauchen; wir werden gleich sehen, dass es Alternativen gibt.
final class Success extends Result {
Success( Object body ) { super( body ); }
}
final class Failure extends Result {
final int errorCode;
Failure( int errorCode, Object body ) {
super( body );
this.errorCode = errorCode;
}
}
Eine Methode wie cake() kann nur Exemplare von Success und Failure aufbauen:
public class Baking {
static Result cake() {
return Math.random() > 0.5 ? new Success( "Yummy" )
: new Failure( 29, "Burned" );
}
}
Der Empfänger weiß, wie bei den Aufzählungstypen zuvor, dass nur Success oder Failure möglich ist:
var result = Baking.cake();
if ( result instanceof Success )
System.out.println( "Success: " + result.body );
else
System.out.println( "Failure: " + result.body );
Bei der Fallunterscheidung gibt es nur zwei Fälle zu unterscheiden. Wir hätten ein großes Problem, wenn plötzlich eine dritte Result-Unterklasse einzieht, denn die würde in dem bestehenden Code wie ein Failure behandelt.
[»] Ausblick
Bei Sealed Classes ist im Vorfeld bekannt, wie viele Unterklassen es genau gibt. JEP 406: Pattern Matching for switch[ 178 ](https://openjdk.java.net/jeps/406) bereitet den Weg für ein neues Feature, das es nicht final in Java 17 geschafft hat. Die Idee daher ist, Code wie den folgenden
if ( result instanceof Success success ) ...
else if ( result instanceof Failure failure ) ...
else throw ...
umzuschreiben in:
switch ( result ) {
case Success success -> ...
case Failure failure -> ...
}
Der Compiler weiß, dass es nur zwei konkrete Typen gibt, und daher ist kein Default-Zweig nötig.
Neben Klassen können auch Schnittstellen ihre erlaubten Implementierungen aufzählen; wir werden in Abschnitt 8.4, »Records«, noch ein Beispiel sehen.
8.3.2 Unterklassen sind final, sealed, non-sealed
Eine Unterklasse einer sealed Oberklasse muss entweder final, sealed oder non-sealed sein. Ein Beispiel zu final haben wir gesehen – finale Klassen verbieten weitere Unterklassen. Allerdings kann eine Unterklasse selbst wieder sealed sein und damit explizit neue Unterklassen aufführen.
Der Modifizierer non-sealed ist ein wenig exotisch, weil er das einzige (kontextuelle) Schlüsselwort ist, das ein Minuszeichen enthält. Die Bedeutung ist, dass die Versiegelung aufgehoben wird und es beliebig viele Unterklassen geben kann.
In der Mathematik gibt es kommutative Operationen, wo die Argumente einer Operation vertauscht werden können, ohne dass sich das Ergebnis verändert. Addition und Multiplikation sind kommutativ, Subtraktion und Division nicht. Das können wir abbilden:
abstract sealed class BinaryOperation
permits Commutative, Noncommutative { }
abstract sealed class Commutative extends BinaryOperation
permits Addition, Multiplication { }
final class Addition extends Commutative { }
final class Multiplication extends Commutative { }
non-sealed class Noncommutative extends BinaryOperation { }
class Subtraction extends Noncommutative { }
Bei den kommutativen Operationen sind nur Exemplare von Addition und Multiplication erlaubt, es lassen sich keine Unterklassen bilden. Von Noncommutative lassen sich jedoch beliebige Unterklassen bilden.
8.3.3 Abkürzende Schreibweisen
Es gibt zwei Sonderfälle, bei denen permits entfällt.
Unterklassen in gleicher Compilationseinheit
Das Schlüsselwort permits kann wegfallen, wenn die Unterklassen sich in der gleichen Compilationseinheit befinden:
public sealed class State { }
final class Open extends State { }
final class Closed extends State { }
Geschachtelte Typen
In Java lassen sich Typdeklarationen in andere Typdeklarationen setzen und damit eine enge Bindung der Typen ausdrücken. Das wird noch einmal Thema in Kapitel 10, »Geschachtelte Typen«, werden, hier schon einmal ein Vorgriff:
public sealed class Feeling {
public enum Scale {
Not_at_all, A_little, Moderately, Quite_a_lot, Extremely
}
public final Scale scale;
protected Feeling( Scale scale ) { this.scale = Objects.requireNonNull( scale ); }
public static final class Friendly extends Feeling {
public Friendly( Scale scale ) { super( scale ); }
}
public static final class Tense extends Feeling {
public Tense( Scale scale ) { super( scale ); }
}
public static final class Active extends Feeling {
public Active( Scale scale ) { super( scale ); }
}
}
Ein Beispiel für die Nutzung der Typen:
Feeling active = new Feeling.Active( Feeling.Scale.Moderately );
System.out.println( active.scale );