7 Objektorientierte Beziehungsfragen
»Aus einer schlechten Verbindung kann man sich schwerer lösen als aus einer guten.«
– Whitney Elizabeth Houston (1963–2012)
Objekte leben nicht in Isolation, sondern in Beziehungen zu anderen Objekten. Was wir uns in diesem Kapitel anschauen wollen, sind die Objektbeziehungen und Typbeziehungen, die Objekte und Klassen/Schnittstellen eingehen können. Im Grunde läuft das auf zwei einfache Beziehungstypen hinaus: Ein Objekt ist mit einem anderen Objekt über eine Referenz verbunden, oder eine Klasse erbt von einer anderen Klasse, sodass die Objekte Eigenschaften von der Oberklasse erben können. Insofern betrachtet dieses Kapitel Assoziationen für die Objektverbindungen und Vererbungsbeziehungen. Darüber hinaus geht das Kapitel auf abstrakte Klassen und Schnittstellen ein, die besondere Vererbungsbeziehungen darstellen, da sie für die Unterklassen Verhalten vorschreiben können.
7.1 Assoziationen zwischen Objekten
Eine wichtige Eigenschaft objektorientierter Systeme ist der Austausch von Nachrichten untereinander. Dazu »kennt« ein Objekt andere Objekte und kann Anforderungen weitergeben. Diese Verbindung nennt sich Assoziation und ist das wichtigste Werkzeug bei der Konstruktion von Objektverbänden.
Assoziationstypen
Bei Assoziationen ist zu unterscheiden, ob nur eine Seite die andere kennt oder ob eine Navigation in beiden Richtungen möglich ist:
Eine unidirektionale Beziehung geht nur in eine Richtung (ein Fan kennt seine Band, aber nicht umgekehrt).
Eine bidirektionale Beziehung geht in beide Richtungen (Raum kennt Spieler, und Spieler kennt Raum). Eine bidirektionale Beziehung ist natürlich ein großer Vorteil, da die Anwendung die Assoziation in beliebiger Richtung ablaufen kann.
Daneben gibt es bei Beziehungen die Multiplizität, auch Kardinalität genannt. Sie sagt aus, mit wie vielen Objekten eine Seite eine Beziehung hat oder haben kann. Übliche Beziehungen sind 1:1 und 1:n. Weiterhin können wir beschreiben, ob ein Teil existenzabhängig ist oder alleine existieren kann.
7.1.1 Unidirektionale 1:1-Beziehung
Damit ein Spieler sich in einem Raum befinden kann, lässt sich in Player eine Referenzvariable vom Typ Room anlegen. In Java sieht das so aus:
class Player {
Room room;
}
class Room { }
Zur Laufzeit müssen natürlich noch die Verweise gesetzt werden:
Player buster = new Player();
Room tower = new Room();
buster.room = tower; // Buster kommt in den Tower
Assoziationen in der UML
Die UML stellt Assoziationen durch eine Linie zwischen den beteiligten Klassen dar. Hat eine Assoziation eine Richtung, zeigt ein Pfeil am Ende der Assoziation diese an (siehe Abbildung 7.1). Wenn es keine Pfeile gibt, heißt das nur, dass die Richtung noch nicht genauer spezifiziert ist. Es bedeutet nicht automatisch, dass die Beziehung bidirektional ist.
Die Multiplizität wird angegeben als »untere Grenze..obere Grenze«, etwa 1..4. Außerdem lässt sich in UML über eine Rolle angeben, welche Aufgabe die Beziehung für eine Seite hat. Die Rollen sind wichtig für reflexive Assoziationen (auch zirkuläre oder rekursive Assoziationen genannt), wenn ein Typ auf sich selbst zeigt. Ein beliebtes Beispiel ist der Typ Person mit den Rollen Chef und Mitarbeiter.
7.1.2 Zwei Freunde müsst ihr werden – bidirektionale 1:1-Beziehungen
Gerichtete Assoziationen sind in Java sehr einfach umzusetzen, wie wir im Beispiel gesehen haben. Beidseitige Assoziationen erscheinen auf den ersten Blick auch einfach, da nur die Gegenseite um eine Verweisvariable erweitert werden muss. Beginnen wir mit dem Szenario, dass der Spieler seinen Raum und der Raum seinen Spieler kennen soll (siehe Abbildung 7.2):
class Player {
Room room;
}
class Room {
Player player;
}
Verbinden wir das (siehe Abbildung 7.3):
Player buster = new Player();
Room tower = new Room();
buster.room = tower;
tower.player = buster;
Korrektheit der Verbindungen sicherstellen
So einfach ist es aber nicht! Bidirektionale Beziehungen erfordern etwas mehr Programmieraufwand, da sichergestellt sein muss, dass beide Seiten eine gültige Referenz besitzen. Denn wird die Assoziation auf einer Seite aufgekündigt, etwa durch Setzen der Referenz auf null, muss auch die andere Seite die Referenz lösen:
buster.room = null; // Spieler will nicht mehr im Raum sein
Auch kann es passieren, dass zwei Räume angeben, einen Spieler zu besitzen, doch der Spieler kennt von der Modellierung her nur genau einen Raum:
Player buster = new Player();
Room tower = new Room();
buster.room = tower;
tower.player = buster;
Room toilet = new Room();
toilet.player = buster;
System.out.println( buster ); // com.tutego.insel.game.vb.Player@aaaaaa
System.out.println( tower ); // com.tutego.insel.game.vb.Room@444444
System.out.println( toilet ); // com.tutego.insel.game.vb.Room@999999
System.out.println( buster.room ); // com.tutego.insel.game.vb.Room@444444
System.out.println( tower.player ); // com.tutego.insel.game.vb.Player@aaaaaa
System.out.println( toilet.player ); // com.tutego.insel.game.vb.Player@aaaaaa
An der Ausgabe ist abzulesen, dass sich Buster im Tower befindet, aber auch die Toilette sagt, dass Buster dort ist. (Die Kennungen hinter @ sind für das Buch durch gut unterscheidbare Zeichenketten ersetzt worden. Sie sind bei jedem Aufruf anders.)
Die Wurzel des Übels liegt in den Variablen. Variablen können keine Konsistenzbedingungen aufrechterhalten, Methoden können wie in einer Transaktion aber mehrere Operationen durchführen und von einem korrekten Zustand in den nächsten überführen. Daher erfolgt diese Kontrolle am besten mit Zugriffsmethoden, etwa setRoom(…) und setPlayer(…).
7.1.3 Unidirektionale 1:n-Beziehung
Immer dann, wenn ein Objekt mehrere andere Objekte referenzieren muss, reicht eine einfache Referenzvariable vom Typ der anderen Seite nicht mehr aus. Es gibt mehrere Ansätze, dass ein Objekt mehr als ein anderes Objekt referenziert.
Beziehung zu einer kleinen überschaubaren Anzahl von Objekten
Ist die Anzahl der assoziierten Objekte fix und überschaubar, dann lassen sich mehrere Variablen verwenden.
[zB] Beispiel
Ein Raum hat maximal zwei Spieler:
class Room {
Player player1, player2;
}
Ein Raum hat verbundene Räume in alle vier Himmelsrichtungen:
class Room {
Room north;
Room west;
Room east;
Room south;
}
Hier steckt indirekt eine bidirektionale Beziehung drin.
Datenstrukturen (Container)
Soll ein Objekt mehr als eine feste Anzahl Referenzen aufnehmen, ist die Lösung über mehrere Variablen nicht mehr tragbar – vor allem dann, wenn die referenzierten Objekte keine Sonderstellung haben. Es ergibt sicherlich Sinn, bei einer Person den rechten und den linken Arm getrennt zu betrachten, aber bei 16 Räumen der Art north, northnorthwest, northwest, westnorthwest, west, westsouthwest, southwest wird es langsam unübersichtlich.
Wenn sich etwa in einem Raum mehrere Spieler befinden oder wenn ein Spieler eine beliebige Anzahl Gegenstände mit sich trägt, sind Datenstrukturen gefragt. Wir verwenden auf der 1-Seite einen speziellen Container, der für uns die Referenzen speichert. Das ist ein zentraler Schritt, denn wir geben die Verantwortung, wer die Referenzen speichert, ab.
Beziehung zu einer großen bekannten Anzahl von Objekten
Eine Handy-Tastatur hat eine feste Anzahl von Tasten und ein Tisch eine feste Anzahl von Beinen. Bei Sammlungen dieser Art ist ein Array gut geeignet. Bei anderen Beziehungen, wo die Anzahl referenzierter Objekte dynamisch ist, ist ein Array wenig elegant, da die manuellen Vergrößerungen oder Verkleinerungen mühevoll sind.
Beziehung zu einer unbekannten Anzahl von Objekten mit der dynamischen Datenstruktur ArrayList
Wollen wir zum Beispiel erlauben, dass ein Spieler mehrere Gegenstände tragen kann oder dass sich eine unbekannte Anzahl Spieler in einem Raum befinden können, ist eine dynamische Datenstruktur wie java.util.ArrayList sinnvoller. Genauer wollen wir uns zwar erst in Kapitel 17, »Einführung in Datenstrukturen und Algorithmen«, mit besagten Datenstrukturen und Algorithmen beschäftigen, doch seien an dieser Stelle schon drei Methoden der ArrayList vorgestellt, die Elemente in einer Liste (Sequenz) hält:
boolean add( E o ) fügt ein Objekt vom Typ E der Liste hinzu.
int size() liefert die Anzahl der Elemente in der Liste.
E get( int index ) liefert das Element an der Stelle index.
Ein Raum mit vielen Spielern
Mit diesem Wissen wollen wir dem Raum Methoden geben, sodass er beliebig viele Spieler aufnehmen kann. Für den unidirektionalen Fall ist die Player-Klasse wieder einfach:
public class Player {
public String name;
public Player( String name ) {
this.name = name;
}
}
Vorher hat der Raum nur eine Referenzvariable vom Typ Player gehabt, die nur genau einen Spieler referenzieren konnte. Mit dem Einsatz von Datenstrukturen bleibt das sogar gleich: Wir referenzieren einen Container im Raum, und dieser Container verwaltet für uns die Player.
Um von nur einem Spieler zu einer Sammlung von Spielern zu kommen, ändern wir
Player player;
in:
ArrayList<Player> players = new ArrayList<Player>();
Der Raum bekommt ein internes Attribut players vom Typ der ArrayList. Dass Angaben in spitzen Klammern hinter dem Typ stehen, liegt an den Java-Generics – sie besagen, dass die ArrayList nur Player aufnehmen wird und keine anderen Dinge (wie Geister).[ 156 ](Die Schreibweise lässt sich auch noch ein wenig abkürzen zu ArrayList<Player> players = new ArrayList<>();, doch das ist jetzt nicht wichtig. )
Die Details zu Generics sind Thema von Kapitel 11, »Generics<T>«, doch unser Wissen ist an dieser Stelle ausreichend, um die Raum-Klasse fertigzustellen (siehe Abbildung 7.5):
import java.util.ArrayList;
public class Room {
private ArrayList<Player> players = new ArrayList<Player>();
public void addPlayer( Player player ) {
players.add( player );
}
public void listPlayers() {
for ( Player player : players )
System.out.println( player.name );
}
}
Die Datenstruktur selbst ist privat, und die addPlayer(…)-Methode fügt einen Spieler in die ArrayList ein. Eine Besonderheit bietet die Methode listPlayers(), denn sie nutzt das erweiterte for zum Durchlaufen aller Spieler. Beim erweiterten for ist rechts vom Doppelpunkt nicht nur ein Array erlaubt, sondern auch eine Datenstruktur wie die Liste. Nachdem also zwei Spieler mit addPlayer(…) hinzugefügt wurden, wird listPlayers() die beiden Spielernamen ausgeben:
Room oceanLiner = new Room();
oceanLiner.addPlayer( new Player( "Tim" ) );
oceanLiner.addPlayer( new Player( "Jorry" ) );
oceanLiner.listPlayers(); // Tim Jorry
[»] Schnelleinstieg in Generics
Java ist eine typisierte Programmiersprache, was bedeutet, dass jede Variable und jeder Ausdruck einen Typ hat, den der Compiler kennt und der sich zur Laufzeit nicht ändert. Eine Zählvariable ist zum Beispiel vom Typ int, ein Abstand zwischen zwei Punkten ist vom Typ double, und ein Koordinatenpaar ist vom Typ Point. Allerdings gibt es bei der Typisierung Lücken. Nehmen wir etwa eine Liste von Punkten:
List list;
Zwar ist die Variable list nun mit List typisiert, und das ist besser als nichts, jedoch bleibt unklar, was die Liste eigentlich genau für Objekte speichert. Sind es Punkte, Einhörner oder rostige Fähren? Es wäre sinnvoll, nicht nur die Liste selbst als Typ zu haben, sondern sozusagen rekursiv in die Liste hineinzugehen und genau hinzuschauen, was die Liste eigentlich referenziert. Genau das ist die Aufgabe von Generics. Die Datenstruktur wünscht sich eine Typangabe, was sie genau speichert. Dieser Typ erscheint in spitzen Klammern hinter dem eigentlichen »Haupttyp«.
List<Point> list;
Mit Generics haben API-Designer ein Werkzeug, um Typen noch genauer vorzuschreiben. Die Entwickler des Typs List können so vom Nutzer fordern, den Elementtyp anzugeben. So können Entwickler dem Compiler genauer sagen, was sie für Typen verwenden, und es dem Compiler ermöglichen, genauere Tests durchzuführen. Es ist erlaubt und möglich, diesen »Nebentyp« nicht anzugeben, doch das führt zu einer Compilerwarnung und ist nicht empfehlenswert: Je genauer Typangaben sind, desto besser ist das für alle.
Vereinzelt kommen in den nächsten Kapiteln generische Typen vor, etwa Comparable (hilft, Objekte zu vergleichen). An dieser Stelle reicht es, zu verstehen, dass wir als Nutzer einen Typ in spitze Klammern eintragen müssen. Mit Generics selbst beschäftigen wir uns in Kapitel 11, »Generics<T>«, genauer.