6.7 Vererbung 

Neben der Assoziation von Objekten gibt es in der Objektorientierung eine weitere wichtige Möglichkeit zur Wiederverwendung: die Vererbung. Sie basiert auf der Idee, dass Eltern ihren Kindern Eigenschaften mitgeben. Vererbung bindet die Klassen noch dichter aneinander. Mittels dieser engen Verbindung können wir später sehen, dass Klassen in gewisser Weise austauschbar sind.
|
6.7.1 Vererbung in Java 

Java ordnet Klassen in Hierarchien an, in der sie Ist-Eine-Art-Von-Beziehungen bilden. Eine neu deklarierte Klasse erweitert durch das Schlüsselwort extends eine andere Klasse. Sie wird dann zur Unterklasse (auch Subklasse oder Kindklasse genannt). Die Klasse, von der die Unterklasse erbt, heißt Oberklasse (auch Superklasse oder Elternklasse). Durch den Vererbungsmechanismus werden alle sichtbaren Eigenschaften der Oberklasse auf die Unterklasse übertragen. Eine Oberklasse vererbt also Eigenschaften, und die Unterklasse erbt sie. Alle Klassen haben direkt oder indirekt die Klasse java.lang.Object als Basisklasse.
Syntaktisch wird die Vererbung durch das Schlüsselwort extends beschrieben. Allgemein gilt für eine erbende Klasse Unter und eine Oberklasse Ober:
class Unter extends Ober
{
}
Alle sichtbaren Eigenschaften von Ober werden auf Unter vererbt. Die Klasse Unter kann die vererbten Eigenschaften nutzen, also etwa auf Variablen oder Methoden zurückgreifen. Wenn sich die Implementierung einer Methode der Oberklasse ändert, wird auch die Unterklasse diese Änderung zu spüren bekommen. Daher ist die Kopplung mittels Vererbung sehr eng, denn die Unterklassen sind Änderungen der Oberklassen ausgeliefert, da ja Oberklassen nichts von Unterklassen wissen.
6.7.2 Spielobjekte modelliert 

Wir wollen nun eine Klassenhierarchie für Objekte in unserem Spiel aufbauen. Bisher haben wir Spieler, Schlüssel und Räume, aber andere Objekte kommen später noch hinzu. Eine Gemeinsamkeit der Objekte ist, dass sie Spielobjekte sind und alle im Spiel einen Namen haben: Der Raum heißt etwa »Knochenbrecherburg«, der Spieler »James Blond« und der Schlüssel »Magic Wand«.
All die Objekte sind Spielobjekte und durch ihre Eigenschaft, dass sie alle denselben Namen haben, miteinander verwandt. Die Ist-eine-Art-von-Hierarchie muss aber nicht auf einer Ebene aufhören. Wir könnten uns einen privilegierter Spieler als Spezialisierung vom Spieler vorstellen, der zusätzlich Dinge darf, die ein normaler Spieler nicht darf. Damit ist ein normaler Spieler eine Art von Spielobjekt, ein privilegierter Spieler ist eine Art von Spieler, und transitiv gilt weiterhin, dass ein privilegierter Spieler eine Art von Spielobjekt ist.
Schreiben wir die Hierarchie für zwei Spielobjekte auf: für den Spieler und den Raum. Der Raum hat zusätzlich eine Größe. Die Basisklasse (Oberklasse) soll GameObject sein.
Listing 6.51 com/tutego/insel/game/vd/GameObject.java, GameObject
class GameObject { String name; }
Der Player soll einfach nur das GameObject erweitern und nichts hinzufügen.
Listing 6.52 com/tutego/insel/game/vd/Player.java, Player
class Player extends GameObject
{
}
Die Deklaration der Klasse trägt den Anhang extends GameObject und erbt somit alle sichtbaren Eigenschaften der Oberklasse, also das Attribut name.
Da keine ausdrückliche extends-Anweisung hinter dem Klassennamen GameObject steht, erbt die Klasse automatisch von Object, einer impliziten Basisklasse. Steht also keine ausdrückliche Oberklasse, so ist das gleichwertig zu
class GameObject extends Object
Der Raum soll neben dem geerbten Namen noch eine Größe besitzen:
Listing 6.53 com/tutego/insel/game/vd/Room.java, Room
class Room extends GameObject
{
int size;
}
Damit ergibt sich das nachfolgende UML-Diagramm. Die Vererbung ist durch einen Pfeil in Richtung der Oberklasse angegeben.
Die Unterklassen besitzen alle sichtbaren Eigenschaften der Oberklasse und zusätzlich ihre hinzugefügten.
Listing 6.54 com/tutego/insel/game/vd/Playground.java, Ausschnitt
Room clinic = new Room(); clinic.name = "Clinic"; // Geerbtes Attribut clinic.size = 120000; // Eigenes Attribut Player theDoc = new Player(); theDoc.name = "Dr. Schuwibscho"; // Geerbtes Attribut
6.7.3 Einfach- und Mehrfachvererbung 

In Java ist auf direktem Weg nur die Einfachvererbung (engl. single inheritance) erlaubt, so dass hinter dem Schlüsselwort extends lediglich eine einzige Klasse steht. Andere objektorientierte Programmiersprachen, wie C++ [Bjarne Stroustrup hat Mehrfachvererbung erst in C++ 2.0 (1985 – 1987) eingeführt. ] , Python Perl oder Eiffel, erlauben Mehrfachvererbung und können mehrere Klassen zu einer neuen verbinden. Doch warum bietet Java neben anderen Sprachen wie C#, Objective-C, Simula, Ruby oder Delphi keine Mehrfachvererbung auf Klassenebene?
Nehmen wir an, die Klassen O1 und O2 deklarieren beide eine öffentliche Funktion f(), und U ist eine Klasse, die von O1 und O2 erbt. Steht in U ein Funktionsaufruf f(), ist nicht klar, welche der beiden Funktionen gemeint ist. In C++ löst der Scope-Operator (::) das Problem, indem der Entwickler immer angibt, aus welcher Oberklasse die Funktion anzusprechen ist.
Dazu gesellt sich das Diamanten-Problem (auch Rauten-Problem genannt). Zwei Klassen K1 und K2 erben von einer Oberklasse O eine Eigenschaft x. Eine Unterklasse U erbt von den Klassen K1 und K2. Lässt sich in U auf die Eigenschaft x zugreifen? Eigentlich existiert die Eigenschaft ja nur einmal und dürfte kein Grund zur Sorge sein. Dennoch stellt dieses Szenario ein Problem dar, weil der Compiler »vergessen« hat, dass sich x in den Unterklassen K1 und K2 nicht verändert hat; mit der Einfachvererbung kommt es erst gar nicht zu diesem Dilemma.
Immer wieder wird diskutiert, ob das Fehlen der Mehrfachvererbung Java einschränkt. Die Antwort ist zu verneinen. Java erlaubt zwar keine multiplen Oberklassen, aber immer noch, mehrere Schnittstellen (Interfaces) zu implementieren und so unterschiedliche Typen anzunehmen.
6.7.4 Sichtbarkeit protected 

Eine Unterklasse erbt alle sichtbaren Eigenschaften. Dazu gehören alle public Elemente und, falls sich Unterklasse und Oberklasse im gleichen Paket befinden, auch die paketsichtbaren Eigenschaften. Die Vererbung kann durch private eingeschränkt werden, dann sieht keine andere Klasse die Eigenschaften, weder fremde noch Unterklassen.
Neben diesen drei Sichtbarkeiten kommt eine vierte hinzu: protected. Diese Sichtbarkeit umfasst (seltsamerweise) zwei Eigenschaften:
- Hat eine Oberklasse protected-Eigenschaften, so hat eine Unterklasse darauf Zugriff.
- Klassen, die sich im gleichen Paket befinden, können alle protected-Eigenschaften sehen, denn protected ist eine Erweiterung der Paketsichtbarkeit.
Sind also weitere Klassen im gleichen Paket und Eigenschaften protected, ist die Sichtbarkeit für sie public. Für andere Nicht-Unterklassen in anderen Paketen sind die protected-Eigenschaften private. Damit lassen sich die Sichtbarkeiten so ordnen:
public > protected > paketsichtbar > private
Stellen wir uns ein Beispiel mit zwei Klassen aus zwei unterschiedlichen Paketen vor. Das Paket com.tutego.insel enthält im Unterpaket berlusconi die Klasse Silvio und das Unterpaket stefani die Klasse Stefano.
Listing 6.55 com/tutego/insel/berlusconi/Silvio.java
package com.tutego.insel.berlusconi; public class Silvio { protected String schulz = "einförmiger, supernationalistischer Blonder"; }
Die Klasse Silvio deklariert eine protected Variable schulz. Sie wird also von allen Unterklassen und auch allen Klassen im gleichen Paket nutzbar sein. Wir deklarieren eine zweite Klasse Stefano jedoch in einem anderen Paket:
Listing 6.56 com/tutego/insel/stefani/Silvio.java
package com.tutego.insel.stefani; import com.tutego.insel.berlusconi.Silvio; public class Stefano extends Silvio { String übernommen = schulz; void demokratieverständnis( Silvio s ) { // s.schulz ist hier nicht deklariert } }
Obwohl eine Unterklasse die protected-Eigenschaft schulz nutzen kann (in übernommen = schulz ist ein Zugriff auf die Oberklassenvariable enthalten), ist ihr der Zugriff über den Typ Silvio verwehrt.
6.7.5 Konstruktoren in der Vererbung und super 

Obwohl Konstruktoren Ähnlichkeit mit Methoden haben, etwa in der Eigenschaft, dass sie überladen werden oder Ausnahmen erzeugen können, werden sie im Gegensatz zu Methoden nicht vererbt. Das heißt, eine Unterklasse muss ganz neue Konstruktoren angeben, denn mit den Konstruktoren der Oberklasse kann ein Objekt der Unterklasse nicht erzeugt werden. Ob das nun reine Objektorientierung ist – darüber lässt sich streiten; in der Skriptsprache Python etwa werden auch Konstruktoren vererbt. In Java gehören Konstruktoren eigentlich zum statischen Teil einer Klasse. Die Klasse selbst weiß, wie neue Objekte konstruiert werden. Würden wir Konstruktoren eher als Initialisierungsmethoden ansehen, läge es natürlich näher, sie wie Objektmethoden zu behandeln. Dagegen spricht jedoch, dass eine Unterklasse mehr Eigenschaften hat und der Konstruktor der Oberklasse dann nur einen Teil initialisieren würde.
In Java sammelt eine Unterklasse zwar automatisch alle sichtbaren Eigenschaften der Oberklasse, aber die Initialisierung der einzelnen Eigenschaften pro Hierarchie ist immer noch Aufgabe der jeweiligen Konstruktoren in der Hierarchie. Um diese Initialisierung sicherzustellen, ruft Java im Konstruktor einer jeden Klasse (ausgenommen java.lang.Object) automatisch den Standard-Konstruktor der Oberklasse auf, damit die Oberklasse »ihre« Attribute initialisieren kann. Es ist dabei egal, ob der Konstruktor in der Unterklasse parametrisiert ist oder nicht; jeder Konstruktor der Unterklasse muss einen der Oberklasse aufrufen.
Ein Beispiel mit Konstruktorweiterleitung
Sehen wir uns noch einmal die Konstruktorverkettung an:
class GameObject { } class Player extends GameObject { }
Da wir keine expliziten Konstruktoren haben, fügt der Compiler diese ein, und da GameObject von java.lang.Object erbt, sieht die Laufzeitumgebung die Klassen so:
class GameObject { GameObject() { } } class Player extends GameObject { Player() { } }
Dass automatisch jeder Konstruktor einer Klasse den Standard-Konstruktor der Oberklasse aufruft, lässt sich auch manuell formulieren – das nötige Schlüsselwort ist super(). Daher ergibt sich folgendes Bild für die Laufzeitumgebung:
class GameObject extends Object { GameObject() { super(); // Ruft Standard-Konstruktor von Object auf } } class Player extends GameObject { Player() { super(); // Ruft Standard-Konstruktor von GameObject auf } }
Da der Compiler automatisch super() einfügt, müssen wir das nicht manuell hinschreiben und sollten es uns auch sparen – unsere Fingerkraft ist wichtig für andere Dinge!
|
Nicht nur die Standard-Konstruktoren rufen mit super() den Standard-Konstruktor der Oberklasse auf, sondern auch immer die parametrisierten Konstruktoren. Stellen wir uns einen Raum mit parametrisiertem Konstruktor vor, der die Größe annimmt:
class Room extends GameObject
{
int size;
Room( int size )
{
// super(); // Ruft automatisch Standard-Konstruktor von GameObject auf
this.size = size; // super() muss immer als Erstes stehen
}
}
Mitunter ist es nötig, aus der Unterklasse nicht nur den Standard-Konstruktor anzusteuern, sondern einen anderen (parametrisierten) Konstruktor der Oberklasse anzusprechen. Dazu gibt es das super() mit Argumenten.
super() mit Argumenten füllen
Der Aufruf von super() kann parametrisiert erfolgen, so dass nicht der Standard-Konstruktor, sondern ein parametrisierter Konstruktor aufgerufen wird. Gründe dafür könnten sein:
- Ein parametrisierter Konstruktor der Unterklasse leitet die Argumente an die Oberklasse weiter; es soll nicht der Standard-Konstruktor aufgerufen werden, da der Oberklassen-Konstruktor das Attribut annehmen und verarbeiten soll.
- Wenn wir keinen Standard-Konstruktor in der Oberklasse vorfinden, müssen wir in der Unterklasse mittels super(Argument ...) einen speziellen, parametrisierten Konstruktor aufrufen.
Gehen wir Schritt für Schritt eine Vererbungshierarchie durch, um zu verstehen, dass ein super() mit Parameter nötig ist.
Beginnen wir mit einer Klasse GameObject, die nur einen parametrisierten Konstruktor für den Namen anbietet:
class GameObject { GameObject( String name ) { } }
Erweitert eine Klasse Player die Klasse GameObject, kommt es zu einem Compilerfehler.
class Player extends GameObject { } // Implicit super constructor GameObject() // is undefined. Must explicitly invoke // another constructor
Der Grund ist simpel: Player enthält einen Compiler-generierten Standard-Konstruktor, der mit super() nach einem Standard-Konstruktor in GameObject sucht – den gibt es aber nicht. Wir müssen daher entweder einen Standard-Konstruktor in der Oberklasse anlegen – was bei nicht modifizierbaren Klassen natürlich nicht geht – oder das super() in Player so einsetzen, dass es mit einem Argument den parametrisierten Konstruktor der Oberklasse aufruft.
class Player extends GameObject { Player() { super( "" ); } }
Es spielt dabei keine Rolle, ob Player ein Standard-Konstruktor oder ein parametrisierter Konstruktor ist: in beiden Fällen müssen wir mit super() einen Wert übergeben.
this() und super() stören sich
this() und super() haben eine Gemeinsamkeit: beide wollen sie die erste Anweisung eines Konstruktors sein. Es kommt vor, dass es mit super() einen parametrisierten Aufruf des Konstruktors der Basisklasse gibt, aber gleichzeitig auch ein this() mit Parametern, um in einem zentralen Konstruktor alle Initialisierungen machen zu können. Die Lösung besteht darin, auf das this() zu verzichten, und den gemeinsamen Programmcode in eine private Methode zu setzen. Das kann so aussehen:
Listing 6.57 ColoredLabel.java
import java.awt.Color; import javax.swing.JLabel; public class ColoredLabel extends JLabel { Color color; public ColoredLabel() { initialize( Color.BLACK ); } public ColoredLabel( String label ) { super( label ); initialize( Color.BLACK ); } public ColoredLabel( String label, Color color ) { super( label ); initialize( color ); } private void initialize( Color color ) { this.color = color; } }
Die farbige Beschriftung ColoredLabel ist ein spezielles JLabel. Es kann auf drei Arten initialisiert werden, wobei bei allen Herangehensweisen gleich ist, dass eine Farbe gespeichert werden muss. Das übernimmt die Funktion initialize(), die alle Konstruktoren aufrufen.
Zusammenfassung: Konstruktoren und Methoden
Methoden und Konstruktoren haben einige Gemeinsamkeiten in der Signatur, weisen aber auch einige wichtige Unterschiede auf, wie den Rückgabewert oder den Gebrauch von this und super. Die folgende Tabelle fasst die Unterschiede und Gemeinsamkeiten noch einmal kompakt zusammen: [Schon seltsam, dass synchronized nicht erlaubt ist, aber ein Konstruktor ist implizit synchronized. Auch ein indirekter Weg über die Class-Methode newInstance() bringt uns nicht zum Ziel, sondern wir handeln uns nur eine InstantiationException ein. ]
Benutzung | Konstruktoren | Methoden |
Modifizierer |
Sichtbarkeit public, protected, paketsichtbar und private. Können nicht abstract, final, native, static oder synchronized sein. |
Sichtbarkeit public, protected, paketsichtbar und private. Können abstract, final, native, static oder synchronized sein. |
Rückgabewert |
Kein Rückgabewert, auch nicht void. |
Rückgabetyp oder void. |
Bezeichnername |
Gleicher Name wie die Klasse. Beginnt mit einem Großbuchstaben. |
Beliebig. Beginnt mit einem Kleinbuchstaben. |
this |
this() bezieht sich auf einen anderen Konstruktor der gleichen Klasse. Wird this() benutzt, muss this() in der ersten Zeile stehen. |
this ist eine Referenz in Objektmethoden, die sich auf das aktuelle Exemplar bezieht. |
super |
Ruft einen Konstruktor der Oberklasse auf. Wird super() benutzt, muss super() in der ersten Zeile stehen. |
super ist eine Referenz mit dem Namensraum der Oberklasse. Damit lassen sich überschriebene Methoden aufrufen. |
Vererbung |
Konstruktoren werden nicht vererbt. |
Sichtbare Methoden werden vererbt. |
6.7.6 Automatische und explizite Typanpassung 

Die Klassen Room und Player modellierten wir als Unterklassen von GameObject. Die eigene Oberklasse GameObject erweitert selbst keine explizite Oberklasse, sodass implizit java.lang.Object die Oberklasse ist. In GameObject gibt es das Attribut name, was Player und Room erben, und der Raum hat zusätzlich size für die Raumgröße.
Automatische Typanpassung
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 Raum ist ein Spielobjekt
- Ein Spieler ist ein Spielobjekt
- Ein Spieler ist ein java.lang.Object
- Ein Spielobjekt ist ein java.lang.Object
- Ein Raum ist ein java.lang.Object
- Ein Raum ist ein Raum
- Ein Spieler ist ein Spieler
Kodieren wir das in Java:
Listing 6.58 com/tutego/insel/game/vd/TypeSuptype.java, main()
Player peterPupP = new Player(); GameObject peterPupGO = new Player(); Object peterPupO = new Player(); Room dungeonR = new Room(); GameObject dungeonGO = new Room(); Object dungeonO = new Room();
Es gilt also, dass immer dann, wenn ein Typ gefordert ist, auch ein Untertyp erlaubt ist.
Was wissen Compiler und Laufzeitumgebung über unser Programm?
Wichtig ist zu beobachten, dass Compiler und Laufzeitumgebung unterschiedliche Dinge wissen. Durch den new-Operator ist klar, dass es nur zwei Arten von Objekten gibt: Player und Room. Auch wenn es heißt
GameObject dungeonGO = new Room();
referenziert dungeonGO zur Laufzeit ein Raum-Objekt. Der Compiler aber vergisst dies und glaubt, dungeonGO wäre nur ein einfaches GameObject. In der Klasse GameObject ist jedoch nur name deklariert, aber kein Attribut size, obwohl das tatsächliche Room-Objekt natürlich size deklariert. Auf size könnten wir aber erst einmal nicht zugreifen:
out.println( dungeonGO.name ); // OK: GameObject hat ein Attribut name out.println( dungeonGO.size ); // dungeonGO.size cannot be resolved or is // not a field
Schreiben wir noch einschränkender
Object dungeonO = new Room(); out.println( dungeonO.name ); // dungeonO.name cannot be resolved or is // not a field out.println( dungeonO.size ); // dungeonO.size cannot be resolved or is // not a field
so referenziert auch die Referenzvariable dungeonO ein vollständiges Raumobjekt, aber weder size noch name sind nutzbar; es bleiben nur die Fähigkeiten aus java.lang.Object.
Diese Typeinschränkungen gelten auch an anderer Stelle. Ist eine Variable etwa vom Typ Room, können wir die Variable nicht mit einem Objekt Room initialisieren, wenn es nur über den Compiler-Basistyp GameObject bekannt gemacht wurde.
GameObject r = new Room(); // Es ist ein Raum zur Laufzeit Room s = r; // Type mismatch: cannot convert // from GameObject to Room
Explizite Typanpassung
Mit der impliziten Typanpassung fallen für den Compiler Informationen weg, etwa bei der Deklaration:
GameObject r = new Room();
Es ist aber möglich, das Objekt hinter r durch eine explizite Typumwandlung für den Compiler wieder zu einem vollwertigen Room zu machen, der eine Größe hat.
Room s = (Room) r; System.out.println( r.size ); // Room hat Attribut size
Dies funktioniert aber lediglich dann, wenn r auch wirklich ein Raum ist. Dem Compiler ist das in dem Moment relativ egal. Diese Bedingung wird erst zur Laufzeit geprüft.
6.7.7 Das Substitutionsprinzip 

Stellen wir uns vor, Bekannte kommen ausgehungert von einer Wandertour 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 Frittierfett anbieten.
Diese Ausgangslage führt uns zu einem wichtigen Konzept in der Objektorientierung: Wenn wenig gefordert wird, kann mehr angeboten werden. Genauer gesagt: Wenn eine Unterklasse U die Oberklasse O erweitert, können wir überall, wo O gefordert wird, etwa als Parameter einer Funktion, auch ein U übergeben, da wir mit der Unterklasse nur spezieller werden. Derjenige, dem wir mehr übergeben, kann damit zwar nichts anfangen, ablehnen wird er das Objekt aber nicht, weil es alle geforderten Eigenschaften aufweist.
Weil an Stelle eines Objekts auch ein Objekt der Unterklasse auftauchen kann, sprechen wir von Substitution. Das Prinzip wurde von der Professorin Barbara Liskov [Die Zeitschrift »Discover« zählt sie zu den 50 wichtigsten Frauen in der Wissenschaft. ] formuliert und heißt daher auch Liskov’sches Substitutionsprinzip.
Bleiben wir bei unserem Beispiel Player, Room und GameObject. Überall dort, wo ein GameObject gefordert ist, können wir einen Player übergeben oder auch eine Room, da beide ja vom Typ GameObject sind. Auch können wir weitere Unterklassen von Player und Room übergeben, da auch die Unterklasse weiterhin zusätzlich das »Gen« GameObject in sich trägt. Alle diese Dinge wären vom Typ GameObject und daher typkompatibel. Wenn nun etwa eine Funktion ein GameObject erwartet, kann die Funktion alle Eigenschaften von GameObject nutzen, also das Attribut name, da ja alle Unterklassen die Eigenschaften erben und Unterklasse-Eigenschaften nicht »wegzaubern« können.
Die folgende Klasse QuoteNameFromGameObject nutzt diese Eigenschaft. Sie fordert in der Methode quote() irgendein GameObject, von dem bekannt ist, dass es ein Attribut name hat. Im Hauptprogramm kann quote() ein Spieler oder Raum übergeben werden.
Listing 6.59 com/tutego/insel/game/vd/QuoteNameFromGameObject.java, QuoteNameFromGameObject
public class QuoteNameFromGameObject { public static void quote( GameObject go ) { System.out.println( "'" + go.name + "'" ); } public static void main( String[] args ) { Player player = new Player(); player.name = "Godman"; quote( player ); // 'Godman' GameObject room = new Room(); room.name = "Hogwurz"; quote( room ); // 'Hogwurz' } }
Mit GameObject haben wir eine Basisklasse geschaffen, die verschiedenen Unterklassen Grundfunktionalität beibringt, in unserem Fall das Attribut name. So liefert die Basisklasse einen gemeinsamen Nenner, etwa gemeinsame Attribute oder Methoden, die jede Unterklasse besitzen wird.
In der Java-Bibliothek finden sich zahllose weitere Beispiele. Häufigstes Anwendungsfeld sind Datenstrukturen. Eine Liste nimmt zum Beispiel beim Hinzufügen beliebige Objekte entgegen, denn der Parametertyp ist (intern) Object. Die Substitution besagt, dass wir alle Objekte dort einsetzen können, da alle Klassen von Object abgeleitet sind.
6.7.8 Typen mit dem binären Operator instanceof testen 

Schlauer als der Compiler ist die Laufzeitumgebung schon, aber auch wir wollen mitunter Programmfluss steuern aufgrund der tatsächlichen Typen. Der relationale Operator instanceof hilft dabei, da er Exemplare auf ihre Verwandtschaft mit einem Referenztyp (Klasse oder Schnittstelle) prüft. Er stellt zur Laufzeit fest, ob eine Referenz von einem bestimmten Typen ist.
Listing 6.60 com/tutego/insel/game/vd/InstanceofDemo.java, main()
System.out.println( "Toll" instanceof String ); // true System.out.println( "Toll" instanceof Object ); // true System.out.println( new Player() instanceof Object ); // true
Alles in doppelten Anführungsstrichen ist ein String, so dass instanceof String wahr ergibt. Für den zweiten und dritten Fall gelten: Alle Objekte gehen irgendwie aus Object hervor und sind somit logischerweise Erweiterungen.
Die bisherigen Beziehungen hätte der Compiler bereits herausfinden können. Vervollständigen wir das, um zu sehen, dass instanceof wirklich zur Laufzeit den Test machen muss. In allen Fällen ist das Objekt zur Laufzeit ein Raum:
Room go1 = new Room(); System.out.println( go1 instanceof Room ); // true System.out.println( go1 instanceof GameObject ); // true System.out.println( go1 instanceof Object ); // true GameObject go2 = new Room(); System.out.println( go2 instanceof Room ); // true System.out.println( go2 instanceof GameObject ); // true System.out.println( go2 instanceof Object ); // true System.out.println( go2 instanceof Player ); // false Object go3 = new Room(); System.out.println( go3 instanceof Room ); // true System.out.println( go3 instanceof GameObject ); // true System.out.println( go3 instanceof Object ); // true System.out.println( go3 instanceof Player ); // false System.out.println( go3 instanceof String ); // false
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.
"Toll" instanceof StringBuilder; // Incompatible conditional operand types String // and StringBuilder
Der Ausdruck ist falsch, da StringBuilder keine Basisklasse für String ist.
Zum Schluss:
Object ref1 = new int[ 100 ]; System.out.println( ref1 instanceof String );
Ein instanceof-Test mit einer Referenz-Variable, die mit null belegt ist, gibt immer false zurück.
Object ref2 = null; System.out.println( ref2 instanceof String );
|
6.7.9 Methoden überschreiben 

Wir haben gesehen, dass eine Unterklasse durch Vererbung die sichtbaren Eigenschaften ihrer Oberklasse erbt. Die Unterklasse kann nun wiederum Methoden hinzufügen. Dabei zählen überladene Methoden, also Methoden, die den gleichen Namen wie eine andere Methode aus einer Oberklasse tragen, aber eine andere Parameteranzahl oder -typen haben, zu ganz normalen, hinzugefügten Methoden.
Besitzt eine Unterklasse eine Funktion mit dem gleichen Methodennamen und der exakten Parameterliste (also der gleichen Signatur) wie schon die Oberklasse, so überschreibt die Unterklasse die Methode der Oberklasse. Ist der Rückgabetyp void oder ein primitiven Typ, so muss er in der überschreibenden Methode der gleiche sein. Bei Referenztypen kann der Rückgabetyp etwas variieren, doch das werden wir später genauer sehen.
Implementiert die Unterklasse die Methode neu, so sagt sie auf diese Weise: »Ich kann’s besser.« Die überschreibende Methode der Unterklasse kann demnach den Funktionscode spezialisieren und Eigenschaften nutzen, die in der Oberklasse nicht bekannt sind. Die überschriebene Methode der Oberklasse ist dann erst einmal aus dem Rennen, und ein Methodenaufruf auf einem Objekt der Unterklasse würde sich in der überschriebenen Methode verfangen.
|
Überschreiben von toString()
Aus der absoluten Basisklasse java.lang.Object bekommen alle Unterklassen eine Funktion toString() vererbt, die meist zu Debug-Zwecken eine Objektkennung ausgibt.
Listing 6.61 java/lang/Object.java, toString()
public String toString() { return getClass().getName() + "@" + Integer.toHexString(hashCode()); }
Die Methode liefert den Namen der Klasse, gefolgt von einem »@« und einer hexadezimalen Kennung. Die Klasse GameObject ohne eigenes toString() soll die Wirkung testen:
Listing 6.62 com/tutego/insel/game/ve/GameObject.java, GameObject
class GameObject { private String name; public String getName() { return name; } public void setName( String name ) { this.name = name; } }
Es liefert toString() auf einem GameObject-Objekt eine etwas kryptische Kennung:
GameObject go = new GameObject(); out.println( go.toString() ); // com.tutego.insel.game.ve.GameObject@e48e1b
Es ist also eine gute Idee, toString() in den Unterklassen zu überschreiben. Eine Stringkennung sollte den Namen der Klasse und die Zustände eines Objektes beinhalten. Für einen Raum, der einen (geerbten) Namen und eine eigene Größe hat, kann dies wie folgt aussehen:
Listing 6.63 com/tutego/insel/game/ve/Room.java, Room
class Room extends GameObject { private int size; public void setSize( int size ) { if ( size > 0 ) this.size = size; } public int getSize() { return size; } @Override public String toString() { return String.format( "Room[name=%s, size=%d]", getName(), getSize() ); } }
Und der Test:
Listing 6.64 com/tutego/insel/game/ve/Playground.java, main()
Room winterfield = new Room(); winterfield.setName( "Winterfield" ); winterfield.setSize( 2040000 ); System.out.println( winterfield ); // Room[name=Winterfield, size=2040000]
Zur Erinnerung: Ein println() auf einem beliebigen Objekt ruft die toString()-Funktion von diesem Objekt auf.
Die Annotation @Override
Unser Beispiel nutzt die Annotation @Override bei der Methode toString() und macht auf diese Weise deutlich, dass die Unterklasse eine Methode der Oberklasse überschreibt. Die Annotation @Override bedeutet nicht, dass diese Methode in Unterklassen überschrieben werden muss, sondern nur, dass sie selbst eine Methode überschreibt. Annotationen sind zusätzliche Modifizierer, die entweder vom Compiler überprüft werden oder von uns nachträglich abgefragt werden können. Obwohl wir die Annotation @Override nicht nutzen müssen, hat sie den Vorteil, dass der Compiler überprüft, ob wir tatsächlich eine Methode aus der Oberklasse überschreiben – haben wir uns im Methodennamen verschrieben und würde die Unterklasse auf diese Weise eine neue Methode hinzufügen, so würde der Compiler das als Fehler melden. Fehler wie tostring() fallen schnell auf. Überladene Funktionen und überschriebene Funktionen sind etwas anderes, da eine überladene Funktion mit der Ursprungsfunktion nur »zufällig« den Namen teilt, aber sonst keinen Bezug zur Logik hat. Und so hilft @Override, dass Entwickler wirklich Methoden überschreiben und nicht aus Versehen Methoden mit falschen Parametern überladen.
|
6.7.10 Mit super an die Eltern 

Wenn wir eine Methode überschreiben, dann entscheiden wir uns für eine gänzlich neue Implementierung. Was ist aber, wenn die Funktionalität im Großen und Ganzen gut war und nur eine Kleinigkeit fehlte? Im Fall der überschriebenen toString()-Methode realisiert die Unterklasse eine völlig neue Implementierung und bezieht sich dabei nicht auf die Logik der Oberklasse.
Möchte eine Unterklasse sagen: »Was meine Eltern können, ist doch gar nicht so schlecht«, kann mit der speziellen Referenz super auf die Eigenschaften im Namensraum der Oberklasse zugegriffen werden. (Natürlich ist das Objekt hinter super und this das gleiche, nur der Namensraum ist ein anderer.) Auf diese Weise können Unterklassen immer noch etwas Eigenes machen, aber die Realisierung aus der Elternklasse ist weiterhin verfügbar.
In unserem Spiel gibt es Räume mit einer Größe. Die Größe lässt sich mit setSize() setzen und mit getSize() erfragen. Eine Konsistenzprüfung in setSize() erlaubt nur Größen echt größer null. Wenn nun eine Garage als besonderer Raum eine gewisse Größe nicht überschreiten darf – sonst wäre es keine Garage –, lässt sich setSize() überschreiben und immer dann das setSize() der Oberklasse zum tatsächlichen Setzen des Attributs aufrufen, wenn die Größe im richtigen Bereich lag.
Listing 6.65 com/tutego/insel/game/ve/Garage.java, Garage
public class Garage extends Room { private static final int MAX_GARAGE_SIZE = 40; @Override public void setSize( int size ) { if ( size <= MAX_GARAGE_SIZE ) super.setSize( size ); } }
Stünde statt super.setSize(size) nur setSize(size), würde ein Methodenaufruf in die Endlosrekursion führen.
|
6.7.11 Kovariante Rückgabetypen 

Überschreibt eine Methode mit einem Referenztyp als Rückgabe eine andere, so kann die überschreibende Methode einen Untertyp des Rückgabetyps der überschriebenen Methode als Rückgabetyp besitzen. Das nennt sich kovarianter Rückgabetyp und ist sehr praktisch, da sich auf diese Weise Entwickler oft explizite Typanpassungen sparen können.
Ein Beispiel soll dies verdeutlichen: Die Klasse Loudspeaker deklariert eine Methode get-This(), die lediglich die this-Referenz zurückgibt. Eine Unterklasse überschreibt die Methode und liefert den spezielleren Untertyp.
Listing 6.66 BigBassLoudspeaker.java
class Loudspeaker { Loudspeaker getThis() { return this; } } class BigBassLoudspeaker extends Loudspeaker { @Override BigBassLoudspeaker getThis() // Loudspeaker getThis() { return this; } }
Die Unterklasse BigBassLoudspeaker überschreibt die Methode getThis(), auch wenn der Rückgabetyp nicht Loudspeaker, sondern BigBassLoudspeaker heißt.
Der Rückgabetyp muss auch nicht zwingend der Typ der eigenen Klasse sein. Gäbe es zum Beispiel mit Plasmatweeter eine zweite Unterklasse von Loudspeaker, so könnte getThis() von BigBassLoudspeaker auch den Rückgabetyp Plasmatweeter deklarieren. Hauptsache, der Rückgabetyp der überschreibenden Methode ist eine Unterklasse des Rückgabetyps der überschriebenen Methode der Basisklasse.
|
6.7.12 Array-Typen und Kovarianz 

Die Aussage »Wer wenig will, kann viel bekommen« gilt auch für Arrays, denn wenn eine Klasse U Unterklasse einer Klasse O ist – und dann gilt Exemplar von U instanceof O –, ist auch U[] ein Untertyp von O[]. Diese Eigenschaft nennt sich Kovarianz. Da Object die Basisklasse aller Objekte ist, kann ein Object-Array auch alle anderen Objekte aufnehmen.
Object[] os = new Object[ 1 ]; Player[] ds = new Player[ 1 ]; Player d = new Player(); System.out.println( d instanceof Object ); // true System.out.println( ds instanceof Player[] ); // true os[ 0 ] = d;
Bauen wir uns eine Funktion nono() und schauen wir, was passiert:
public static void nono( Object[] feld, Object d ) { feld[ 0 ] = d; }
Das Element d soll einfach an die erste Stelle ins Feld gesetzt werden. Rufen wir die Methode mit den soeben deklarierten Variablen os, ds und d auf:
nono( ds, d ); nono( os, d );
Kein Problem! Die Variable d referenziert ein Player-Objekt, das sich in einem Player-Array abspeichern lässt. Der zweite Aufruf funktioniert ebenfalls, denn eine Player lässt sich in einem Object-Feld speichern, da ein Object ja ein Basistyp ist. Ein Dilemma wäre es jedoch, wenn ein Feld nicht den richtigen Typ bekommt.
nono( ds, new Date() );
Das Ergebnis ist eine ArrayStoreException. Das haben wir aber auch verdient, denn ein Date-Objekt lässt sich nicht in einem Player-Feld speichern. Selbst ein new Object() hätte zu einem Problem geführt. Das Typsystem von Java kann diese Spitzfindigkeit nicht prüfen. Erst zur Laufzeit ist ein Test möglich, mit dem denkbar bitteren Ergebnis einer ArrayStoreException.
6.7.13 Zusammenfassung zur Sichtbarkeit 

In Java gibt es vier Sichtbarkeiten und drei Sichtbarkeitsmodifizierer:
- Öffentliche Typen und Eigenschaften deklariert der Modifizierer public. Die Typen sind überall sichtbar, also kann jede Klasse und Unterklasse aus einem beliebigen anderen Paket auf öffentliche Eigenschaften zugreifen. Die mit public deklarierten Methoden und Variablen sind überall dort sichtbar, wo auch die Klasse sichtbar ist. Bei einer unsichtbaren Klasse sind auch die Eigenschaften unsichtbar.
- Der Modifizierer private für Typen ist seltener, da er sich nur dann einsetzen lässt, wenn in einer Datei mehrere Typen deklariert werden. Derjenige Typ, der den Dateinamen bestimmt, kann nicht privat sein, doch andere Typen, etwa innere Klassen, dürfen unsichtbar sein – nur der sichtbare Typ kann sie verwenden. Die mit private deklarierten Methoden und Variablen sind nur innerhalb der eigenen Klasse sichtbar. Eine Ausnahme bilden innere Klassen, die auch auf private Eigenschaften zugreifen können. Auch wenn diese Klasse erweitert wird, sind die Elemente nicht sichtbar.
- Während private und public Extreme darstellen, liegt die Paketsichtbarkeit dazwischen. Sie ist die Standard-Sichtbarkeit und kommt ohne Modifizierer aus. Paketsichtbare Typen und Eigenschaften sind nur für die Klassen aus dem gleichen Paket sichtbar, also weder für Klassen noch Unterklassen aus anderen Paketen.
- Der Sichtbarkeitsmodifizierer protected hat eine Doppelfunktion. Zum einen hat er die gleiche Bedeutung wie Paketsichtbarkeit, und zum anderen gibt er die Elemente für Unterklassen frei. Dabei ist es egal, ob die Unterklassen aus dem eigenen Paket stammen (hier würde ja die Standard-Sichtbarkeit reichen) oder aus einem anderen Paket. Eine Kombination aus private protected wäre wünschenswert, um die Eigenschaften nur für die Unterklassen sichtbar zu machen und nicht gleich für die Klassen aus dem gleichen Paket; das aber haben die Entwickler für die aktuelle Java-Version nicht vorgesehen.
Eigenschaften | eigene Klasse | Klasse im gleichen Paket | Unterklasse im anderen Paket | Klasse in anderen Paketen |
public |
Ja |
Ja |
Ja |
Ja |
protected |
Ja |
Ja |
Ja |
Nein |
paketsichtbar |
Ja |
Ja |
Nein |
Nein |
Private |
Ja |
Nein |
Nein |
Nein |
Der Einsatz der Sichtbarkeitsstufen über die Schlüsselwörter public, private und protected und der Standard »paketsichtbar« ohne explizites Schlüsselwort sollten überlegt erfolgen. Objektorientierte Programmierung zeichnet sich durch überlegten Einsatz von Klassen und deren Beziehungen aus. Am besten ist die restriktivste Beschreibung; also nie mehr Öffentlichkeit als notwendig. Das hilft, die Abhängigkeiten zu minimieren und später Inneres einfacher zu verändern.
Sichtbarkeit in der UML
Für die Sichtbarkeit von Attributen und Operationen sieht die UML diverse Symbole vor, die vor die jeweilige Eigenschaft gesetzt werden:
Symbol | Sichtbarkeit |
+ |
öffentlich |
– |
privat |
# |
geschützt (protected) |
~ |
paketsichtbar |
|
Reihenfolge der Eigenschaften in Klassen
Verschiedene Elemente einer Klasse müssen in einer Klasse untergebracht werden. Eine verbreitete Reihenfolge ist die Aufteilung in Sektionen:
- Klassenvariablen
- Objektvariablen
- Konstruktoren
- Methoden (erst Klassenmethoden, dann Setter/Getter, dann beliebige Methoden)
Innerhalb eines Blocks werden die Informationen oft auch bezüglich ihrer Zugriffsrechte sortiert. Am Anfang stehen sichtbare Eigenschaften und tiefer private. Der öffentliche Teil befindet sich deswegen am Anfang, da wir uns auf diese Weise schnell einen Überblick verschaffen können. Der zweite Teil ist dann nur noch für die erbenden Klassen interessant, und der letzte Teil beschreibt allein geschützte Informationen für die Entwickler. Die Reihenfolge kann aber problemlos gebrochen werden, indem private Methoden hinter öffentlichen stehen, um zusammenhängende Teile auch zusammenzuhalten.