7.3 Typen in Hierarchien
Die Vererbung bringt einiges Neues in Bezug auf Kompatibilität von Typen mit. Dieser Abschnitt beschäftigt sich mit den Fragen, welche Typen kompatibel sind und wie sich ein Typ zur Laufzeit testen lässt.
7.3.1 Automatische und explizite Typumwandlung
Fassen wir noch einmal zusammen, was wir bisher für Typen haben: Die Klassen Nap und Workout haben wir als Unterklassen von Event modelliert. Die eigene Oberklasse Event erweitert selbst keine explizite Oberklasse, sodass implizit java.lang.Object die Oberklasse ist. In Event gibt es die Objektvariablen about und duration, die Nap und Workout erben, und Workout hat zusätzlich caloriesBurned für die »verbrannten« Kalorien.
Ist-eine-Art-von-Beziehung und die automatische Typumwandlung
Mit der Ist-eine-Art-von-Beziehung ist eine interessante Eigenschaft verbunden, die wir bemerken, wenn wir die Zusammenhänge zwischen den Typen beachten:
-
Ein Event ist ein Event.
-
Ein Nap ist ein Nap.
-
Ein Workout ist ein Workout.
-
Ein Nap ist ein Event.
-
Ein Workout ist ein Event.
-
Ein Event ist ein java.lang.Object.
-
Ein Nap ist ein java.lang.Object.
-
Ein Workout ist ein java.lang.Object.
Kodieren wir das in Java:
Nap napAsNap = new Nap();
Event napAsEvent = new Nap();
Object napAsObject = new Nap();
Workout workoutAsWorkout = new Workout();
Event workoutAsEvent = new Workout();
Object workoutAsObject = new Workout();
Es gilt also, dass immer dann, wenn ein Typ gefordert ist, auch ein Untertyp erlaubt ist. Der Compiler führt eine implizite Typumwandlung durch. Wir werden uns dieses sogenannte liskovsche Substitutionsprinzip im folgenden Abschnitt anschauen.
Was wissen Compiler und Laufzeitumgebung über unser Programm?
Compiler und Laufzeitumgebung haben verschiedene Sichten auf das Programm und wissen unterschiedliche Dinge. Durch den Einsatz von new gibt es zur Laufzeit nur zwei Arten von Objekten: Nap und Workout. Auch dann, wenn es
Event workoutAsEvent = new Workout();
heißt, referenziert workoutAsEvent zur Laufzeit ein Workout-Objekt. Der Compiler aber »vergisst« dies und glaubt, workoutAsEvent wäre nur ein einfaches Event.
In der Klasse Event sind jedoch nur about und duration deklariert, aber keine Objektvariable caloriesBurned, obwohl das tatsächliche Workout-Objekt natürlich ein caloriesBurned hat. Auf caloriesBurned können wir aber erst einmal nicht zugreifen:
System.out.println( workoutAsEvent.about );
System.out.println( workoutAsEvent.caloriesBurned );
// Cannot resolve symbol 'caloriesBurned'
Schreiben wir noch einschränkender
System.out.println( workoutAsObject.about );
// Cannot resolve symbol 'about'
so steht hinter der Referenzvariablen workoutAsObject immer noch ein vollständiges Workout-Objekt, aber weder caloriesBurned noch about/duration sind nutzbar; es bleiben nur die Fähigkeiten aus java.lang.Object.
[»] Begrifflichkeit
Es gibt in Java zwei Typsysteme: die vom Compiler und die der Laufzeitumgebung. Um den Typ, den der Compiler kennt, von dem Typ, den die JVM kennt, zu unterscheiden, nutzen wir die Begriffe Referenztyp und Objekttyp. Im Fall von Event e = new Workout() ist Event der Referenztyp und Workout der Objekttyp (Merkhilfe: Es steht ein Objekt zur Laufzeit im Speicher). Der Compiler sieht nur den Referenztyp, aber nicht den Objekttyp. Vereinfacht gesagt: Der Compiler interessiert sich bei einer Konstruktion wie Event e = new Workout() nur für den linken Teil Event e und die Laufzeitumgebung nur für den rechten Teil e = new Workout().
Explizite Typumwandlung
Diese Typeinschränkung gilt auch an anderer Stelle. Ist eine Variable vom Typ Room deklariert, können wir die Variable nicht mit einem »kleineren« Typ initialisieren:
Event event = new Workout(); // Workout ist Objekttyp zur Laufzeit
Workout running = event; //
// Incompatible types. Found: '[...].Event', required: '[...].Workout'
Auch wenn zur Laufzeit event ein Workout referenziert, können wir running nicht damit initialisieren. Der Compiler kennt event nur unter dem »kleineren« Typ Event, und das reicht nicht zur Initialisierung des »größeren« Typs Workout.
Es ist aber möglich, das Objekt hinter event durch eine explizite Typumwandlung für den Compiler wieder zu einem vollwertigen Workout mit verbrauchten Kalorien zu machen:
Event event = new Workout();
Workout running = (Workout) event;
System.out.println( running.caloriesBurned );
Unmögliche Anpassung und ClassCastException
Dies funktioniert aber lediglich dann, wenn event auch wirklich ein Workout-Objekt referenziert. Der Compiler lässt sich zu einer ungültigen Typumwandlung überreden, und auch Folgendes wird ohne Fehler in Bytecode übersetzt:
Event event = new Nap();
Workout running = (Workout) event; // ClassCastException
System.out.println( running.caloriesBurned );
Der Compiler macht diesen Unsinn mit, aber nicht die JVM; zur Laufzeit kommt es bei diesem Kuckucksobjekt zu einer ClassCastException:
Exception in thread "main" java.lang.ClassCastException: class [...].Nap cannot be cast to class [...].Workout ([...].Nap and [...].Workout are in unnamed module of loader 'app')
at ...
[»] Hinweis
Wenn wir var nutzen, dann wird der Typ der Variablen automatisch so sein wie der auf der rechten Seite. Beispiel: Schreiben wir
Event event = new Nap()
haben wir uns gezielt bei event für den Typ Nap entschieden. Wir sollten uns bewusst sein, dass bei einer Variablendeklaration mit var die neue Variable exakt den Typ der rechten Seite bekommt.
var event = new Nap();
Hier ist event ein Nap, hat und kann also mehr als ein Event. Es kann bei der Programmierung relevant sein, den Typ zu beschränken.
7.3.2 Das Substitutionsprinzip
Nehmen wir an, wir möchten herausfinden, wie lange insgesamt zwei Ereignisse dauern, wobei wir bei Ereignissen von Nap und Workout sprechen. Die erste Idee wäre vermutlich, drei Methoden zu deklarieren, die mit allen Kombinationen von Nap und Workout aufgerufen werden können:
int totalDuration( Nap event1, Nap event2 ) {
return event1.duration + event2.duration;
}
int totalDuration( Workout event1, Nap event2 ) {
return event1.duration + event2.duration;
}
int totalDuration( Workout event1, Workout event2 ) {
return event1.duration + event2.duration;
}
Uns dürfte sofort auffallen, dass der Rumpf der Methoden identisch ist. Und genau hier wird uns die Vererbung helfen, den Code zu verallgemeinern. Zur Verdeutlichung zwei Szenarien aus dem echten Leben:
-
Packen wir einen Koffer, dann kommen dort unterschiedliche Dinge hinein. Wir haben nicht einen speziellen Koffer für die Unterwäsche, dann einen zweiten Koffer für die Hemden und einen dritten Koffer für die Kopfhörer, die wir immer verlieren …
-
Stellen wir uns vor, Bekannte kommen ausgehungert von einer Wandertour zurück und fragen: »Haste was zu essen?« Die Frage zielt wohl darauf ab, dass es bei Hunger ziemlich egal ist, was wir anbieten, wichtig ist nur etwas Essbares. Daher können wir Eis, aber auch gegrillte Heuschrecken und Hákarl (isländischer fermentierter Hai) anbieten, denn das ist alles essbar.
Diese Ausgangslage führt uns zu einem wichtigen Konzept in der Objektorientierung: »Wer wenig will, kann viel bekommen.« Genauer gesagt: Wenn Unterklassen wie Nap oder Workout die Oberklasse Event erweitern, können wir überall, wo etwas vom Typ Event gefordert wird, auch ein Nap- oder Workout-Objekt übergeben, da beide ja vom Typ Event sind und wir mit der Unterklasse nur spezieller werden. Auch können wir weitere Unterklassen von Event und Nap übergeben, da auch Unterklassen das »Gen« Event in sich tragen. Alle diese Dinge wären vom Typ Event und daher typkompatibel.
Im nächsten Beispiel gibt es eine Methode totalDuration(Event… events), die eine variable Argumentliste von Event-Objekten annimmt und die Summe aller Dauern berechnet. Der Methode ist der Objekttyp ganz egal, denn wenn die Methode ein Event erwartet, könnten wir ein Objekt exakt vom Objekttyp Event, aber auch Nap oder Workout übergeben. Jedes Event hat grundsätzlich die Objektvariablen about und duration, da ja alle Unterklassen die Eigenschaften erben und Unterklassen die Eigenschaften nicht »wegzaubern« können.
public class TotalDuration {
public static int totalDuration( Event... events ) {
int sum = 0;
for ( Event event : events )
sum += event.duration;
return sum;
}
public static void main( String[] args ) {
Workout running = new Workout();
running.duration = 50;
Event sleeping = new Nap();
sleeping.duration = 40;
System.out.println( totalDuration( running, sleeping ) );
}
}
Mit Event haben wir eine Basisklasse geschaffen, die allen Unterklassen Grundfunktionalität beibringt, in unserem Fall die Objektvariablen about und duration. So liefert die Basisklasse einen gemeinsamen Nenner, etwa gemeinsame Objektvariablen oder Methoden, die jede Unterklasse besitzen wird. Das ist viel flexibler, als für die konkreten Typen Nap und Workout Spezialmethoden zu schreiben. Denn wenn in einer Anwendung später neue Event-Typen auftauchen, behandelt totalDuration(Event…) diese ganz selbstverständlich mit, und wir müssen den »Algorithmus« nicht anfassen.
Weil anstelle eines Objekts auch ein Objekt der Unterklasse auftauchen kann, sprechen wir von Substitution. Das Prinzip wurde von der Professorin Barbara Liskov[ 163 ](Die Zeitschrift »Discover« zählt sie zu den 50 wichtigsten Frauen in der Wissenschaft. ) formuliert und heißt daher auch liskovsches Substitutionsprinzip.
In der Java-Bibliothek finden sich zahllose weitere Beispiele. Die println(Object)-Methode ist so ein Beispiel. Die Methode nimmt beliebige Objekte entgegen, denn der Parametertyp ist Object. Die Substitution besagt, dass wir alle Objekte dort einsetzen können, da alle Klassen von Object abgeleitet sind.
7.3.3 Typen mit dem instanceof-Operator testen
Im vorangegangenen Beispiel haben wir gesehen, dass der Methode totalDuration(…) eine variable Anzahl von Events-Objekten übergeben werden kann, und damit kann die Methode auf alles zurückgreifen, was ein Event hat. Wenn wir »mehr« übergeben, also konkrete Unterklassen, dann weiß die Methode das nicht. Hin und wieder ist es nötig, den konkreten Typ zu kennen, und der relationale Operator instanceof hilft dabei, Exemplare auf ihre Verwandtschaft mit einem Referenztyp zu prüfen. Er stellt zur Laufzeit fest, ob eine Referenz ungleich null und von einem bestimmten Typ ist. Der Operator ist binär, hat also zwei Operanden.
Ein Beispiel, das in Summation von Zeiten nichts addiert, was vom Typ Nap ist:
public static int totalDurationOfNoNapEvents( Event... events ) {
int sum = 0;
for ( Event event : events )
if ( event instanceof Nap )
sum += event.duration;
return sum;
}
[»] Hinweis
Der Operator instanceof testet ein Objekt auf seine Hierarchie. So ist zum Beispiel o instanceof Object für jedes Objekt o wahr, denn jedes Objekt ist immer Kind von java.lang.Object. Die Programmiersprache Smalltalk unterscheidet hier mit zwei Nachrichten isMemberOf (exakt) und isKindOf (wie Javas instanceof). Um den exakten Typ zu testen, lässt sich mit dem Class-Objekt arbeiten, etwa wie im Ausdruck o.getClass() == Object.class, der testet, ob o genau ein Object-Objekt ist.
Der Compiler kann prinzipiell einige Typen schon kennen, doch der Test wird wirklich zur Laufzeit gemacht. Formulieren wir ein Beispiel, um zu sehen, dass instanceof wirklich zur Laufzeit den Test durchführen muss. In allen Fällen ist das Objekt zur Laufzeit ein Workout:
Workout wo1 = new Workout();
System.out.println( wo1 instanceof Workout ); // true
System.out.println( wo1 instanceof Event ); // true
System.out.println( wo1 instanceof Object ); // true
Event wo2 = new Workout();
System.out.println( wo2 instanceof Workout ); // true
System.out.println( wo2 instanceof Event ); // true
System.out.println( wo2 instanceof Object ); // true
System.out.println( wo2 instanceof Nap ); // false
Object wo3 = new Workout();
System.out.println( wo3 instanceof Workout ); // true
System.out.println( wo3 instanceof Event ); // true
System.out.println( wo3 instanceof Object ); // true
System.out.println( wo3 instanceof Nap ); // false
System.out.println( wo3 instanceof String ); // false
Keine beliebigen Typtests mit instanceof
Der Compiler lässt aber nicht alles durch. Liegen zwei Typen überhaupt nicht in der Typhierarchie, lehnt der Compiler den Test ab, da die Vererbungsbeziehungen schon inkompatibel sind:
System.out.println( "Toll" instanceof StringBuilder );
// Incompatible conditional operand types String and StringBuilder
Der Ausdruck ist falsch, da StringBuilder keine Basisklasse für String ist.
Object ref1 = new int[ 100 ];
System.out.println( ref1 instanceof String );
System.out.println( new int[100] instanceof String ); // Compilerfehler
[»] Hinweis
Mit instanceof lässt sich der Programmfluss anhand der tatsächlichen Typen steuern, etwa mit Anweisungen wie if(reference instanceof Typ) A else B. In der Regel zeigt Kontrolllogik dieser Art aber tendenziell ein Designproblem an und kann oft anders gelöst werden. Das dynamische Binden ist so eine Lösung; sie wird später in Abschnitt 7.5, »Drum prüfe, wer sich dynamisch bindet«, vorgestellt.
instanceof und null
Ein instanceof-Test mit einer Referenz, die null ist, gibt immer false zurück:
String ref2 = null;
System.out.println( ref2 instanceof String ); // false
System.out.println( ref2 instanceof Object ); // false
Das leuchtet ein, denn null entspricht ja keinem konkreten Objekt.
[+] Tipp
Da instanceof einen null-Test enthält, sollte statt etwa
if ( s != null && s instanceof String )
immer vereinfacht so geschrieben werden:
if ( s instanceof String )
7.3.4 Pattern-Matching bei instanceof
Der Java-Compiler und die Laufzeitumgebung besitzen unterschiedlich leistungsstarke Typsysteme. Während die Laufzeitumgebung immer den präzisen Objekttyp kennt, so ist dem Compiler nur der Referenztyp bekannt, und das kann ein Basistyp vom Objekttyp sein. Wenn z. B. eine Referenzvariable vom Typ Object ist und zur Laufzeit ein String referenziert wird, dann ist der Objekttyp String, und der Compiler kennt den Typ nicht, sondern weiß nur, dass es irgendwie ein Object ist.
Mitunter muss im Code eine Weiche programmiert werden, die den tatsächlichen Objekttyp berücksichtigt. Zur Laufzeit lässt sich der Objekttyp mit dem instanceof-Operator testen. Es ist nicht ungewöhnlich, dass es in Programmen Fallunterscheidungen gibt, die zunächst mit instanceof oder mit getClass() den genauen Typ testen und dann eine Referenzvariable auf einen Untertyp anpassen.
Schauen wir uns ein Beispiel an. Eine Methode soll zwei Event-Parameter bekommen, und wenn diese Ereignisse vom Typ Workout sind, soll die Anzahl verbrauchter Kalorien extrahiert und verglichen werden. Eine Variante könnte wie folgt aussehen:
static boolean burnedSameCalories( Event event1, Event event2 ) {
if ( !(event1 instanceof Workout && event2 instanceof Workout) )
return false;
Workout workout1 = (Workout) event1;
Workout workout2 = (Workout) event2;
return workout1.caloriesBurned == workout2.caloriesBurned;
}
Der Lösungsansatz arbeitet nach dem Fail-fast-Verfahren. Früh wird getestet, ob die beiden Events überhaupt vom Typ Workout sind. Wenn nicht, wird die Methode mit false verlassen. Sind dann beide Event-Objekte vom Typ Workout, findet eine explizite Typumwandlung statt; die Kalorien werden extrahiert und verglichen.
Es gibt eine andere Möglichkeit, das zu programmieren, und zwar, in der Fallunterscheidung nicht die Methode zu verlassen, sondern im Fall von speziellen Workout-Objekten im Rumpf der Fallunterscheidung den Vergleich vorzunehmen:
static boolean burnedSameCalories( Event event1, Event event2 ) {
if ( event1 instanceof Workout && event2 instanceof Workout ) {
Workout workout1 = (Workout) event1;
Workout workout2 = (Workout) event2;
return workout1.caloriesBurned == workout2.caloriesBurned;
}
return false;
}
In beiden Fällen lässt sich im Code gut ablesen, dass zunächst ein instanceof-Test steht und anschließend eine neue Variable deklariert wird, die durch eine Typumwandlung aus einem anderen Ausdruck, hier einer Variablen, hervorgeht.
Solche Fälle sind gar nicht so selten. Deswegen wurde in Java 16 der instanceof-Operator im Rahmen des »JEP 394: Pattern Matching for instanceof«[ 164 ](https://openjdk.java.net/jeps/394) erweitert. Und zwar kann hinter dem zu testenden Typ ein Bezeichner für eine neue Variable stehen, die genau dann initialisiert wird, wenn der instanceof-Test wahr ist. In Java wird das Sprachfeature Pattern matching bei instanceof genannt. Das Pattern ist genau dieser instanceof-Test, und die Variable nennt sich Pattern-Variable. Die Pattern-Variable wird nur dann gesetzt, wenn der Test erfolgreich war. Andernfalls wird die Variable nicht initialisiert, und es lässt sich nicht darauf zurückgreifen.
Mit zwei Pattern-Variablen können wir unser Programm ein wenig kürzer formulieren:
static boolean burnedSameCalories( Event event1, Event event2 ) {
if ( event1 instanceof Workout workout1 && event2 instanceof Workout workout2 )
return workout1.caloriesBurned == workout2.caloriesBurned;
return false;
}
Das Beispiel zeigt, dass im Rumpf der Fallunterscheidung auf die initialisierten Variablen workout1 und workout2 zurückgegriffen werden kann.
Wir können aber auch genau den anderen Ansatz wählen, dass bei unpassenden Typen die Methode verlassen wird:
static boolean burnedSameCalories( Event event1, Event event2 ) {
if ( !(event1 instanceof Workout workout1 && event2 instanceof Workout workout2) )
return false;
return workout1.caloriesBurned == workout2.caloriesBurned;
}
Bei diesem Lösungsansatz fällt gut auf, dass die Variablen für den verbleibenden Codeblock deklariert bleiben.
Der Compiler kann ganz präzise verfolgen, an welcher Stelle die Pattern-Variable gültig und initialisiert ist, was eine weitere Variante zulässt:
static boolean burnedSameCalories( Event event1, Event event2 ) {
return event1 instanceof Workout workout1 && event2 instanceof Workout workout2
&& workout1.caloriesBurned == workout2.caloriesBurned;
}
Hier findet das sogenannte flow scoping statt. Die Auswertung der Operanden beim &&-Operator geschieht von links nach rechts. Da der hier eingesetzte Und-Operator nach dem Kurzschlussverfahren arbeitet, werden so viele Teile ausgewertet, bis die Antwort feststeht, also früher abgebrochen, wenn ein Operand false ist. Da jedoch am Anfang schon die Pattern-Variablen eingeführt wurden, weiß der Compiler nach zwei gültigen Typprüfungen, das beim dritten Ausdruck – dem Vergleich – zwei Variablen workout1 und workout2 vom Typ Workout existieren.