Einführung in die Informatik II

Transcription

Einführung in die Informatik II
Einführung in die Informatik II
Franz Kummert
Inhaltsverzeichnis
1 Elementare Grundlagen der objektorientierten Programmierung (Wiederholung)
8
1.1 Datenfelder . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9
1.2 Konstruktoren . . . . . . . . . . . . . . . . . . . . . . . . . . .
9
1.3 Methoden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.4 Anweisungen in Konstruktoren und Methoden . . . . . . . . . 11
1.5 Übersicht: Datenfelder, Parameter, lokale Variablen . . . . . . 11
1.6 Abstraktion und Modularisierung . . . . . . . . . . . . . . . . 12
1.7 Klassen- und Objektdiagramme . . . . . . . . . . . . . . . . . 13
2 Debugger
14
2.1 Das Beispiel eines Mail-Systems . . . . . . . . . . . . . . . . . 14
2.2 Die Benutzung eines Debuggers . . . . . . . . . . . . . . . . . 16
2.2.1
Haltepunkte setzen . . . . . . . . . . . . . . . . . . . . 16
2.2.2
Programmuntersuchung im Debugger . . . . . . . . . . 18
3 Objektsammlungen
19
3.1 Objektsammlungen mit flexibler Größe . . . . . . . . . . . . . 19
3.2 Beispiel: Ein persönliches Notizbuch . . . . . . . . . . . . . . . 19
1
3.3 Die Klasse ArrayList . . . . . . . . . . . . . . . . . . . . . . 21
3.4 Komplette Sammlungen verarbeiten . . . . . . . . . . . . . . . 22
3.4.1
Die while-Schleife . . . . . . . . . . . . . . . . . . . . . 22
3.4.2
Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . 23
3.4.3
Zugriff mit Index oder über Iteratoren . . . . . . . . . 25
3.5 Sammlungen mit fester Größe . . . . . . . . . . . . . . . . . . 25
3.5.1
Die Analyse einer Logdatei . . . . . . . . . . . . . . . . 26
3.5.2
Array-Variablen deklarieren . . . . . . . . . . . . . . . 26
3.5.3
Array-Objekte erzeugen . . . . . . . . . . . . . . . . . 27
3.5.4
Array-Objekte benutzen . . . . . . . . . . . . . . . . . 28
3.5.5
Die for-Schleife . . . . . . . . . . . . . . . . . . . . . . 29
3.6 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . 30
4 Benutzung von Klassenbibliotheken
31
4.1 Beispiel: Ein primitives Kundendienstsystem . . . . . . . . . . 31
4.2 Klassendokumentation lesen . . . . . . . . . . . . . . . . . . . 32
4.2.1
Schnittstelle versus Implementierung . . . . . . . . . . 33
4.2.2
Benutzung einer Methode einer Bibliotheksklasse . . . 33
4.3 Zufälliges Verhalten einbringen
. . . . . . . . . . . . . . . . . 35
4.3.1
Die Klasse Random . . . . . . . . . . . . . . . . . . . . 35
4.3.2
Zufällige Antworten generieren . . . . . . . . . . . . . . 36
4.4 Pakete und Importe . . . . . . . . . . . . . . . . . . . . . . . . 38
4.5 Benutzung von Map-Klassen für Abbildungen . . . . . . . . . 39
4.5.1
Das Konzept einer Map . . . . . . . . . . . . . . . . . . 39
4.5.2
Die Benutzung einer HashMap . . . . . . . . . . . . . . 40
2
4.5.3
Die Benutzung einer Abbildung für das Kundendienstsystem . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
4.6 Der Umgang mit Mengen . . . . . . . . . . . . . . . . . . . . . 42
4.7 Zeichenketten zerlegen . . . . . . . . . . . . . . . . . . . . . . 44
4.8 Öffentliche und private Eigenschaften . . . . . . . . . . . . . . 45
4.9 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . 47
5 Klassenentwurf
49
5.1 Beispielprojekt für einen schlechten Klassenentwurf . . . . . . 49
5.2 Code-Duplizierung . . . . . . . . . . . . . . . . . . . . . . . . 50
5.3 Grundprinzipien: Kopplung und Kohäsion . . . . . . . . . . . 51
5.4 Kapselung zur Reduzierung der Kopplung . . . . . . . . . . . 52
5.5 Entwurf nach Zuständigkeiten . . . . . . . . . . . . . . . . . . 60
5.6 Programmausführung ohne Bluej . . . . . . . . . . . . . . . . 64
5.6.1
Klassenvariablen . . . . . . . . . . . . . . . . . . . . . 64
5.6.2
Klassenmethoden . . . . . . . . . . . . . . . . . . . . . 65
5.6.3
Die Methode main . . . . . . . . . . . . . . . . . . . . 66
5.6.4
Einschränkungen für Klassenmethoden . . . . . . . . . 67
5.7 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . 67
6 Bessere Struktur durch Vererbung
69
6.1 Beispiel: Database of Multimedia Entertainment (DoME) . . . 69
6.1.1
Die Klassen und Objekte in DoME . . . . . . . . . . . 70
6.1.2
Der Quelltext von DoME . . . . . . . . . . . . . . . . . 71
6.2 Einsatz von Vererbung . . . . . . . . . . . . . . . . . . . . . . 71
6.3 Vererbungshierarchien . . . . . . . . . . . . . . . . . . . . . . 73
6.4 Vererbung in Java . . . . . . . . . . . . . . . . . . . . . . . . . 74
3
6.4.1
Vererbung und Zugriffsrechte . . . . . . . . . . . . . . 75
6.4.2
Vererbung und Initialisierung . . . . . . . . . . . . . . 75
6.5 Weitere Medien für DoME . . . . . . . . . . . . . . . . . . . . 77
6.6 Subtyping . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
6.7 Die Klasse Object . . . . . . . . . . . . . . . . . . . . . . . . 82
6.8 Polymorphe Sammlungen
. . . . . . . . . . . . . . . . . . . . 83
6.9 Wrapperklassen . . . . . . . . . . . . . . . . . . . . . . . . . . 85
6.10 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . 86
7 Vertiefter Umgang mit Vererbung
88
7.1 Überschreiben von Methoden . . . . . . . . . . . . . . . . . . 88
7.2 Dynamische Methodensuche . . . . . . . . . . . . . . . . . . . 92
7.3 Methoden aus Object: toString . . . . . . . . . . . . . . . . 95
7.4 Der Zugriff über protected . . . . . . . . . . . . . . . . . . . 97
7.5 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . 98
8 Weitere Techniken zur Abstraktion
99
8.1 Die Füchse-und-Hasen-Simulation . . . . . . . . . . . . . . . . 99
8.1.1
Das Projekt Fuechse-und-Hasen . . . . . . . . . . . . . 99
8.1.2
Ein Simulationsschritt . . . . . . . . . . . . . . . . . . 102
8.2 Abstrakte Methoden und Klassen . . . . . . . . . . . . . . . . 104
8.3 Multiple Vererbung . . . . . . . . . . . . . . . . . . . . . . . . 108
8.4 Interfaces . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
8.5 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . 113
9 Fehlerbehandlung
114
9.1 Adressbuch-Projekt . . . . . . . . . . . . . . . . . . . . . . . . 114
4
9.2 Defensive Programmierung . . . . . . . . . . . . . . . . . . . . 115
9.3 Fehlermeldung . . . . . . . . . . . . . . . . . . . . . . . . . . . 116
9.4 Prinzipien der Ausnahmebehandlung . . . . . . . . . . . . . . 118
9.4.1
Das Auslösen einer Exception . . . . . . . . . . . . . . 118
9.4.2
Exception-Klassen . . . . . . . . . . . . . . . . . . . . 119
9.5 Die Auswirkungen einer Exception . . . . . . . . . . . . . . . 121
9.5.1
Auswirkungen bei ungeprüften Exceptions . . . . . . . 121
9.5.2
Fehlerbehandlung bei geprüften Exceptions . . . . . . . 122
9.6 Definieren von neuen Exception-Klassen . . . . . . . . . . . . 128
9.7 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . 129
10 Ein- und Ausgabe von Daten und Texten
131
10.1 Einlesen von textuellen Daten aus einer Datei . . . . . . . . . 132
10.2 Schreiben von textuellen Daten in eine Datei . . . . . . . . . . 135
10.3 Einlesen von Tastatureingaben und Ausgabe auf den Bildschirm136
10.4 Binärdaten aus einer Datei lesen . . . . . . . . . . . . . . . . . 139
10.5 Schreiben von Binärdaten in eine Datei . . . . . . . . . . . . . 140
11 Entwurf von Algorithmen
141
11.1 Intuitiver Algorithmenbegriff . . . . . . . . . . . . . . . . . . . 141
11.2 Berechnung optimaler Lösungen . . . . . . . . . . . . . . . . . 144
11.2.1 Beispielproblem . . . . . . . . . . . . . . . . . . . . . . 144
11.2.2 Greedy-Algorithmen . . . . . . . . . . . . . . . . . . . 145
11.2.3 Dynamische Programmierung . . . . . . . . . . . . . . 147
11.2.4 Beste-Lösung-Zuerst . . . . . . . . . . . . . . . . . . . 148
11.3 Berechnung einer Lösung . . . . . . . . . . . . . . . . . . . . . 149
5
11.3.1 Teile-und-Herrsche . . . . . . . . . . . . . . . . . . . . 149
11.3.2 Rekursion . . . . . . . . . . . . . . . . . . . . . . . . . 151
11.3.3 Backtracking . . . . . . . . . . . . . . . . . . . . . . . 152
12 Sortieren und Suchen in sortierten Folgen
155
12.1 Suchen in sortierten Folgen . . . . . . . . . . . . . . . . . . . . 155
12.1.1 Sequentielle Suche . . . . . . . . . . . . . . . . . . . . 155
12.1.2 Binäre Suche . . . . . . . . . . . . . . . . . . . . . . . 157
12.2 Sortieren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 158
12.2.1 Sortieren durch Einfügen: insertionSort . . . . . . . . . 159
12.2.2 Sortieren durch Selektion: selectionSort . . . . . . . . . 160
12.2.3 Sortieren durch Vertauschen: bubbleSort . . . . . . . . 160
12.2.4 Sortieren durch Mischen: mergeSort . . . . . . . . . . . 161
12.2.5 Sortieren mittels eines Pivotelements: quickSort . . . . 163
12.2.6 Sortierverfahren im Vergleich . . . . . . . . . . . . . . 165
13 Grundlegende Datenstrukturen
168
13.1 Hochdimensionale Arrays . . . . . . . . . . . . . . . . . . . . . 168
13.2 Der dynamische Container ArrayList . . . . . . . . . . . . . . 170
13.3 Verkettete Listen . . . . . . . . . . . . . . . . . . . . . . . . . 172
13.4 Doppelt verkettete Listen
. . . . . . . . . . . . . . . . . . . . 177
13.5 Das Iterator-Konzept . . . . . . . . . . . . . . . . . . . . . . . 179
13.6 Stapel (stack) . . . . . . . . . . . . . . . . . . . . . . . . . . . 182
14 Hashverfahren
184
14.1 Grundprinzip des Hashens . . . . . . . . . . . . . . . . . . . . 184
14.2 Hashfunktionen . . . . . . . . . . . . . . . . . . . . . . . . . . 185
6
14.3 Behandlung von Kollisionen . . . . . . . . . . . . . . . . . . . 186
14.4 Hashen in Java . . . . . . . . . . . . . . . . . . . . . . . . . . 189
15 Bäume
195
15.1 Bäume: Begriffe und Konzepte . . . . . . . . . . . . . . . . . . 195
15.2 Binärer Baum: Datentyp und Basisalgorithmen . . . . . . . . 197
15.3 Algorithmen zur Traversierung . . . . . . . . . . . . . . . . . . 199
15.4 Suchbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . 203
7
1
Elementare Grundlagen der objektorientierten Programmierung (Wiederholung)
OO-Computerprogramm modelliert Ausschnitt der realen Welt.
In diesem Ausschnitt kommen bestimmte Objekte vor, die vom Programm
repräsentiert werden müssen.
⇒ (Java-)Objekte modellieren Objekte des Anwendungsbereichs.
Unterschiedliche Objekte können derselben Kategorie zugeordnet sein, z.B.
Auto:
Eine Klasse beschreibt auf abstrakte Weise eine bestimmte Kategorie (Art)
von Objekten.
Objekte repräsentieren individuelle Instanzen einer Klasse.
Unterschiedliche Objekte haben unterschiedlichen Zustand, z.B. Farbe, Geschwindigkeit, PS, . . .
Der Quelltext einer Klasse legt die Struktur und das Verhalten aller Instanzen
dieser Klasse fest.
8
Klassendefinition:
public class Klassenname
{
Datenfelder speichern die Daten, die ein Objekt benutzt.
Konstruktoren ermöglichen, dass ein Objekt nach seiner
Erzeugung in einen gültigen Zustand versetzt wird.
Methoden erlauben die Kommunikation mit anderen Objekten
und bewirken oftmals Aktionen.
}
1.1
Datenfelder
Jedes Datenfeld hat eigene Deklaration im Quelltext, z.B.
Datentyp
private
| {z }
z}|{
int
Zugriffsmodifikator
preis
| {z }
;
Name des Datenfeldes
Bislang haben wir als Zugriffsmodifikator public und privat kennengelernt.
privat bedeutet, dass auf dieses Datenfeld nur innerhalb von Instanzen dieser Klasse zugegriffen werden darf. Für Datenfelder ist dies auch die einzig
sinnvolle Belegung (siehe später).
Als Datentyp haben wir primitive Datentypen und Objekttypen kennengelernt.
Primitive Datentypen wie int, boolean, char, double oder long haben keine Methoden und speichern die entsprechenden Werte direkt in den
Datenfeldern.
Objekttypen sind Datentypen, die durch Klassen definiert werden, d.h. ein
Klassenname kann als Typname verwendet werden. Datenfelder von Objekttypen speichern nur Referenzen auf Objekte der entsprechenden Klasse.
1.2
Konstruktoren
Konstruktoren heißen immer so, wie die zugehörige Klasse und sie besitzen
keinen Ergebnistyp:
9
public class Klassenname
{
// Datenfelder
public Klassenname(Parameterliste)
{
// Datenfelder initialisieren
}
}
Die Parameter der Parameterliste werden über Datentyp und Namen deklariert, z.B.
public Rechteck(int hoehe, int breite)
Objekte können andere Objekte erzeugen, z.B.
fenster = new Rechteck(42, 25);
Hierbei wird ein neues Objekt zur Klasse Rechteck erzeugt und anschließend
wird der zugehörige Konstruktor aufgerufen, der die entsprechenden Initialisierungen vornimmt. Die konkreten Parameterwerte beim Konstruktoraufruf
heißen aktuelle Parameter.
1.3
Methoden
Methoden bestehen aus Signatur und aus Deklarationen und Anweisungen
im Rumpf.
Die Signatur benennt die benötigten Parameter und den Typ des Rückgabewertes,
z.B.
Ergebnisdatentyp ∨ void
public
| {z }
z }| {
boolean
HoeherAlsBreit
|
{z
}(Parameterliste)
Methodenname
Zugriffsmodifikator
Im Rumpf einer Methode (wie auch im Konstruktor) können lokale Variablen definiert werden, die auch nur dort zugreifbar sind. Sie müssen explizit
initialisiert werden.
Eine Klasse kann mehr als eine Methode (auch mehr als einen Konstruktor)
mit dem gleichen Namen haben, solange sich jede von ihnen in den Parametertypen unterscheidet. Dies bezeichnet man als Überladen.
10
1.4
Anweisungen in Konstruktoren und Methoden
Zuweisungen speichern den Wert auf der rechten Seite eines Zuweisungsoperators in der Variablen, die auf der linken Seite genannt ist, z.B.
preis = preis + (wert + 25) * 3;
Folgende Kurzformen haben wir kennen gelernt:
preis += (wert + 25) * 3; bzw. preis -= (wert + 25) * 3;
Bedingte Anweisungen führen eine von zwei Anweisungen aus in Abhängigkeit
vom Ergebnis einer Prüfung, z.B.
if (betrag > 0) {
bisherGezahlt += betrag;
}
else {
System.out.println("Bitte nur positive Beträge verwenden: "
+ betrag);
}
Man beachte die automatische Umwandlung einer Integer in eine Zeichenkette.
Im Bedingungsteil steht ein boolescher Ausdruck, der nur die beiden Ergebnisse true oder false haben kann.
Einfache boolesche Ausdrücke können mit Hilfe logischer Operatoren zu komplexeren Ausdrücken kombiniert werden, wobei die wichtigsten sind:
&& logisches UND
|| logisches ODER
! logisches NICHT
1.5
Übersicht: Datenfelder, Parameter, lokale Variablen
Alle drei Arten von Variablen können einen Wert halten, der ihrem jeweiligen
Typ entspricht.
Datenfelder werden außerhalb von Konstruktoren und Methoden deklariert.
11
Datenfelder halten Daten, die über die gesamte Lebensdauer eines Objekts
erhalten bleiben. Sie haben die gleiche Lebensdauer wie das Objekt, in dem
sie definiert sind.
Datenfelder sind in der gesamten Klasse zugreifbar und können somit von
jeder Methode und von jedem Konstruktor genutzt werden.
Formale Parameter und lokale Variablen existieren nur für die Dauer der
Ausführung eines Konstruktors oder einer Methode. Sie dienen somit temporärer statt dauerhafter Datenhaltung.
Formale Parameter werden im Kopf einer Methode oder eines Konstruktors
definiert. Sie bekommen ihre Werte von außen, indem sie mit den Werten
der aktuellen Parameter des Methoden- oder Konstruktoraufrufs initialisiert
werden.
Die Sichtbarkeit formaler Parameter ist auf den definierenden Konstruktor
bzw. auf die definierende Methode beschränkt.
Lokale Variablen werden im Rumpf eines Konstruktors oder einer Methode
definiert und es kann nur dort auf sie zugegriffen werden. Im Gegensatz zu
Datenfeldern müssen lokale Variablen explizit initialisiert werden.
Die Sichtbarkeit lokaler Variablen ist auf den Block beschränkt, in dem sie
definiert wurden. Sie sind außerhalb dieses Blocks nicht zugreifbar.
1.6
Abstraktion und Modularisierung
Bei der Entwicklung von Software ist es wichtig, das komplexe Problem zu
zerlegen (Modularisierung), jedoch bei Betrachtung der Gesamtlösung auf
Detailbetrachtungen zu verzichten (Abstraktion).
Modularisierung ist der Prozess der Zerlegung eines Ganzen in wohldefinierte Teile, die getrennt erstellt und untersucht werden können und die in
wohldefinierter Weise interagieren.
Abstraktion ist die Fähigkeit, Details von Bestandteilen zu ignorieren, um
den Fokus der Betrachtung auf eine höhere Ebene lenken zu können.
12
1.7
Klassen- und Objektdiagramme
Ein Klassendiagramm zeigt die Klassen einer Anwendung und die Beziehungen zwischen diesen Klassen. Es liefert Information über den Quelltext
und präsentiert eine statische Sicht auf ein Programm.
Ein Objektdiagramm zeigt die Objekte und ihre Beziehungen zu einem bestimmten Zeitpunkt während der Ausführung einer Anwendung. Es präsentiert
eine dynamische Sicht auf ein Programm.
13
2
Debugger
Bevor wir uns vertieft mit der objektorientierten Programmierung beschäftigen,
wollen wir zunächst ein Werkzeug kennen lernen, das uns das Verständnis
einer Programmausführung stark erleichtert und zwar den sogenannten Debugger.
Ein Debugger ist ein Werkzeug, mit dem ein Entwickler ein Programm Schritt
für Schritt ausführen lassen kann.
Er bietet üblicherweise Funktionen zum Stoppen und Starten eines Programms an ausgewählten Stellen im Quelltext und zum Betrachten der Werte
von Variablen.
Der Debugger, der in BlueJ integriert ist, ist zwar relativ einfach, jedoch ist
er trotzdem leistungsfähig genug, um uns nützliche Informationen zu liefern.
Die Funktionalität des BlueJ-Debuggers wollen wir uns an dem Projekt MailSystem näher ansehen.
2.1
Das Beispiel eines Mail-Systems
Bei der Untersuchung des Projekts Mail-System stellen wir folgende Dinge
fest:
• Das Projekt enthält die drei Klassen: MailServer, MailClient und Nachricht
• Ein MailServer-Objekt muss erzeugt werden, das von allen Clients benutzt wird. Es kümmert sich um den Austausch von Nachrichten.
• Es können mehrere Mail-Clients erzeugt werden, die jeweils mit einem
Benutzer verknüpft sind.
• Nachrichten können von einem Mail-Client zu einem anderen MailClient über die Methode sendeNachricht verschickt werden.
• Nachrichten können von einem Mail-Client durch die Methode gibNaechsteNachricht
empfangen werden, welche die Nachrichten einzeln vom Server abholt.
• Der Benutzer erzeugt nicht explizit Objekte der Klasse Nachricht. Diese
Klasse wird nur intern in den beiden anderen Klassen verwendet, um
Nachrichtentexte zu speichern und auszutauschen.
14
Die Klasse MailServer ist sehr komplex und enthält viele Konstrukte, die wir
erst später kennen lernen werden, so dass wir sie hier nicht näher betrachten.
Wir verlassen uns unter dem Stichwort Abstraktion einfach auf ihre Funktionsweise und ignorieren zunächst lästige Details.
Der Quelltext der Klasse Nachricht ist relativ einfach. Interessant ist hier nur
der Konstruktor.
Das neue Java-Konstrukt ist hier die Verwendung des Schlüsselwortes this,
z.B:
this.absender = absender;
Es handelt sich um eine Zuweisung, wo der Wert der Variablen absender der
Variablen this.absender zugewiesen wird.
Dieses Konstrukt wird benutzt, da in dieser Situation ein Variablenname
überladen ist, d.h. derselbe Name wird für zwei unterschiedliche Dinge verwendet, nämlich für das Datenfeld absender und für den formalen Parameter
absender des Konstruktors.
Im Prinzip ist dies in Java kein Problem, wenn zwei unterschiedliche Variablen gleich heißen.
Die Frage ist jedoch: Welche Variable ist gemeint, wenn wir den Namen
absender verwenden?
Java regelt dies eindeutig wie folgt: Wird ein Variablenname verwendet, so
bezieht sich dieser auf auf den nächsten umschließenden Block.
In unserem Beispiel ist der formale Parameter ‘näher’ deklariert als das Datenfeld, so dass sich der Name absender auf den formalen Parameter bezieht.
Will man nun jedoch auf das Datenfeld absender zugreifen, so macht man
dies mit der this-Notation kenntlich.
Obige Anweisung hat also folgenden Effekt:
Datenfeld Namens absender = Parameter Namens absender;
Es stellt sich nun allerdings die Frage, warum man zwei unterschiedliche
Variablen gleich benennt?
Der Grund ist die Lesbarkeit des Quelltextes. Im Prinzip enthalten ja beide
Variablen dieselbe Information, außer dass der formale Parameter die Information nur kurzzeitig und das Datenfeld die Information langfristig enthält.
15
Um deutlich zu machen, dass beide Variablen demselben Zweck dienen, wurden sie identisch benannt.
2.2
Die Benutzung eines Debuggers
Die uns interessierende Klasse in der Mail-Anwendung ist MailClient, die wir
nun mit Hilfe des Debuggers näher untersuchen wollen.
Dazu erstellen wir uns zunächst folgendes Szenario:
1. Erzeugen Sie ein Objekt zu MailServer.
2. Erzeugen Sie für den Mail-Server zwei Objekte zu MailClient mit den
Benutzern ‘Sophie’ und ‘Juan’ (die entsprechenden Objekte sollten Sie
genauso benennen).
3. Senden Sie mit Hilfe der Methode sendeNachricht eine Botschaft von
Sophie an Juan.
Wir haben nun eine Situation, in der eine Nachricht für Juan auf dem Server gespeichert ist, die abgerufen werden kann. Nun wollen wir uns genau
ansehen, wie das abläuft.
2.2.1
Haltepunkte setzen
Um unsere Untersuchung zu starten, setzen wir zunächst einen sogenannten
Haltepunkt, der einer Zeile im Quelltext angeheftet wird. Wenn bei Ausführung
dieser Methode diese Stelle erreicht wird, dann wird die Ausführung angehalten.
Sie können einen Haltepunkt setzen, indem Sie im Quelltext eine gewünschte
Zeile selektieren (z.B. die erste Zeile der Methode naechsteNachrichtAusgeben)
und aus dem Menü Werkzeuge den Eintrag Haltepunkte setzen/entfernen
auswählen. Ein Haltepunkt wird in BlueJ durch ein Stop-Zeichen symbolisiert.
Beachten Sie, dass zum Setzen eines Haltepunktes die Klasse übersetzt sein
muss und dass durch ein Übersetzen allen Haltepunkte entfernt werden.
Rufen Sie nun die Methode naechsteNachrichtAusgeben im Juans MailClient auf. Es erscheint das folgende Debugger-Fenster, mit dem Sie den
16
Programmzustand und die weitere Programmausführung gut untersuchen
können.
Am unteren Rand befinden sich einige Kontrollknöpfe, die Sie benutzen
können, um die Ausführung eines Programms anzuhalten oder fortzusetzen.
Zudem sehen Sie auf der rechten Seite drei Bereiche für die Anzeige von
statischen Variablen (diese lernen wir erst später kennen), Datenfelder (Instanzvariablen) und lokalen Variablen.
Wir sehen, dass das Objekt Juan die beiden Datenfelder server und benutzer
hat und wir können die aktuellen Werte sehen.
Das Datenfeld benutzer hat den Wert ‘Juan’ und die Variable server hält
eine Referenz auf ein anderes Objekt (Referenz auf den Mail-Server).
17
Beachten Sie, dass wir noch keine lokalen Variablen sehen, da die Ausführung
immer vor der Zeile mit dem Haltepunkt anhält. Da in der ersten Zeile die
einzige lokale Variable deklariert wird, ist diese noch nicht zu sehen.
2.2.2
Programmuntersuchung im Debugger
Wenn ein Haltepunkt erreicht wurde, führt ein Klick auf die Schaltfläche
Schritt/Step eine Zeile aus und die Ausführung stoppt erneut.
Im Quelltext sehen wir mittels des schwarzen Pfeils die aktuelle Stelle der
Programmausführung.
Zudem wurde die lokale Variable nachricht angelegt und mit einem Wert
versehen.
Klicken wir nun erneut auf Schritt/Step, so können wir nun gut verfolgen,
welcher Teil der bedingten Anweisung ausgeführt wird.
Klicken wir nun erneut auf Schritt/Step, so wird der Methodenaufruf vollständig
ausgeführt. Wollten wir sehen, was im inneren dieser Methode passiert, so
hätten wir Schritt hinein/Step into klicken müssen und das Programm hätte
an der ersten Zeile dieser Methode gestoppt.
Mit dem Weiter/Continue Knopf starten Sie die Programmausführung erneut. Das Programm terminiert, falls der nächste Haltepunkt erreicht oder
die initial aktivierte Methode beendet ist.
Der Beenden/Terminate löscht alle bis dahin generierten Objekte, so dass
mit einer neuen Konstellation das Programm untersucht werden kann.
18
3
Objektsammlungen
Wir beschäftigen uns hier mit unterschiedlichen Möglichkeiten, Objekte in
Sammlungen zusammenzufassen und alle Elemente einer Sammlung zu betrachten.
3.1
Objektsammlungen mit flexibler Größe
Häufig haben wir den Bedarf, Objekte zu Sammlungen zusammenzufassen,
wie z.B.
• Elektronisches Notizbuch, das Notizen über Verabredungen, Treffen,
Geburtstage und Ähnliches speichert
• Bibliotheken verwalten Informationen über Bücher und Zeitschriften
• Universitäten halten Daten über ehemalige und aktuelle Studierende
Hier variiert die Zahl der verwalteten Elemente erheblich über die Zeit, z.B.
neue Termine kommen hinzu, vergangene Termine werden gelöscht.
Im Prinzip könnten wir eine Klasse mit sehr sehr vielen Datenfeldern schreiben, um darin viele Objekte speichern zu können, aber nichts desto trotz
gäbe es eine maximale Anzahl speicherbarer Objekte.
Dies ist jedoch für viele Anwendungen nicht akzeptabel.
Wir werden uns deshalb nun eine der Möglichkeiten näher ansehen, um in
Java beliebig viele Objekte in einem Behälter zu gruppieren.
3.2
Beispiel: Ein persönliches Notizbuch
Wir werden eine Anwendung für ein persönliches Notizbuch entwerfen, das
folgende Funktionen bietet:
• Notizen können gespeichert werden
• die Anzahl der Notizen ist unbegrenzt
• einzelne Notizen können angezeigt werden
19
• die Anzahl der Notizen kann abgefragt werden
Obige Funktionen können wir leicht unterstützen, wenn wir eine Klasse zur
Verfügung haben, in der wir eine beliebige Anzahl von Objekten (Notizen)
speichern können.
Eine solche Klasse steht uns in Java in einer der Bibliotheken zur Verfügung,
die standardmäßig in der Java-Umgebung mitgeliefert werden.
Bevor wir uns diese Klasse ArrayList näher ansehen, wollen wir zunächst
die Verwendung dieser Klasse in dem Projekt Notizbuch1 betrachten.
Öffnen Sie deshalb das Projekt Notizbuch1 und erzeugen Sie ein Objekt der
Klasse Notizbuch. Tragen Sie nun einige Notizen ein und überprüfen Sie, ob
die Methode anzahlNotizen korrekt arbeitet.
Lassen Sie sich nun Ihre Notizen anzeigen (Methode zeigeNotiz und beachten Sie dabei, dass bei ArrayList von 0 (Null) an gezählt wird, d.h. die erste
Notiz hat den Index 0, die zweite den Index 1 usw.
Wir betrachten nun den Quelltext der Klasse Notizbuch.
Die erste Zeile illustriert, wie in Java mit Hilfe der import-Anweisung der
Zugriff auf Bibliotheksklassen ermöglicht wird:
import java.util.ArrayList;
Diese Anweisung macht die Klasse ArrayList aus dem Paket java.util
innerhalb unserer Klassendefinition verfügbar.
Wir können nun diese Klasse in der Klassendefinition von Notizbuch verwenden und deklarieren ein Datenfeld notizen dieser Klasse.
In diesem Datenfeld werden dann alle unsere Notizen gespeichert.
Im Konstruktor wird dem Datenfeld notizen ein Objekt der Klasse ArrayList
zugewiesen, so dass wir nun Notizen in unser Notizbuch einfügen können.
Dies geschieht mit Hilfe der Methode speichereNotiz, wobei wir auf die
Einfüge-Methode add der Klasse ArrayList zurückgreifen.
Analog verwenden wir für die anderen Methoden, vordefinierte Methoden der
Klasse ArrayList.
20
3.3
Die Klasse ArrayList
Wir wollen uns im Sinne der Abstraktion nicht mit der genauen Implementierung der Klasse ArrayList beschäftigen, sondern hier nur die wichtigsten
Methoden dieser Klasse zusammenfassen, um diese Klasse auch für andere
Anwendungen benutzen zu können. Hierbei bedeutet der Objekttyp Object,
dass hier Objekte beliebiger Klassen verwendet werden dürfen (mehr hierzu
später in der Vorlesung).
Sammelbehälter erzeugen:
new ArrayList()
Objekt am Ende des Behälters einfügen:
boolean add(Object element)
Objekt an der Position index einfügen (erste Position ist der Index 0).
Beachte: Dabei verschieben sich die Indizes der Elemente ab der Position
index:
void add(int index, Object element)
Anzahl der Objekte im Behälter bestimmen:
int size()
Objekt an der Position index aus dem Behälter holen (Objekt bleibt im
Behälter):
Object get(int index)
Objekt an der Position index löschen (erste Position ist der Index 0):
Beachte: Dabei verschieben sich die Indizes der Elemente ab der Position
index:
Object remove(int index)
Test, ob Behälter ein Objekt enthält:
boolean contains(Object element)
Behälter vollständig entleeren:
void clear()
Objekt an der Position index ersetzen:
Object set(int index, Object element)
Test, ob Behälter leer ist:
boolean isEmpty()
21
3.4
Komplette Sammlungen verarbeiten
Angenommen wir wollen nun eine Methode schreiben, die alle Notizen ausgibt. Im Prinzip könnte diese Methode folgendermaßen aussehen:
System.out.println(notizen.get(0));
System.out.println(notizen.get(1));
System.out.println(notizen.get(2));
usw.
Da die Anzahl der Notizen jedoch stark schwankt, können wir auf diese Weise
die gewünschte Funktionalität nicht erreichen.
Wir haben es hier mit einer Situation zu tun, in der etwas mehrfach getan
werden muss, aber die genaue Anzahl variieren kann.
Hierzu bietet Java diverse Schleifenkonstrukte an, wobei wir uns zunächst
die sogenannte while-Schleife näher ansehen wollen.
3.4.1
Die while-Schleife
Die while-Schleife bietet die Möglichkeit, eine Menge von Aktionen solange
zu wiederholen bis eine Bedingung nicht mehr erfüllt ist:
while (Schleifenbedingung) {
Schleifenrumpf
}
D.h. solange der boolsche Ausdruck in der Schleifenbedingung true ist, werden die Anweisungen, die im Schleifenrumpf stehen ausgeführt.
Die Schleife bricht ab, falls der boolsche Ausdruck in der Schleifenbedingung
false liefert.
Damit die Schleife also irgendwann einmal terminiert, muss sichergestellt
werden, dass die Schleifenbedingung auch einmal den Wert false annimmt.
Nun können wir ohne Probleme eine Methode schreiben, die alle Notizen
ausgibt:
public void alleNotizenAusgeben()
{
int index = 0;
22
while (index < notizen.size()) {
System.out.println(notizen.get(index));
index++;
}
}
Der Ausdruck index++; ist die verkürzte Form der äquivalenten Anweisung
index += 1; bzw. index = index + 1;
Die while-Schleife kann natürlich nicht nur verwendet werden, um über alle
Elemente einer Sammlung zu laufen, sondern für alle Aufgaben, die eine
gewisse Anzahl mal durchzuführen sind.
Eine weitere direktere Möglichkeit über alle Elemente einer Sammlung zu
laufen sind sogenannte Iteratoren
3.4.2
Iteratoren
Es kommt recht häufig vor, dass man über alle Elemente einer Sammlung
läuft. Deshalb bieten viele Sammelbehälter (und auch die Klasse ArrayList)
eine explizite Möglichkeit an, über den Inhalt zu iterieren.
Hierzu liefert die Methode iterator der Klasse ArrayList ein Objekt der
Klasse Iterator, mit dessen Hilfe alle Elemente einer Sammlung abgelaufen
werden können.
Um die Klasse Iterator verwenden zu können, müssen wir eine weitere
import-Anweisung in den Quelltext einfügen:
import java.util.ArrayList;
import java.util.Iterator;
Ein Objekt der Klasse Iterator bietet zwei Methoden, mit deren Hilfe über
die Sammlung gelaufen werden kann: hasNext und next.
Die Methode hasNext testet, ob es noch ein weiteres Element in der Sammlung gibt.
Die Methode next liefert das nächste Element in der Sammlung zurück.
Wollen wir nun mit Hilfe eines Iterators alle Notizen ausgeben, so könnte die
23
Methode alleNotizenAusgeben nun folgendermaßen aussehen:
public void alleNotizenAusgeben()
{
Iterator it = notizen.iterator();
String notizinhalt;
while (it.hasNext()) {
notizinhalt = (String) it.next();
System.out.println(notizinhalt);
}
}
Wir rufen also zu Beginn die Methode iterator für unser Notizbuch auf und
erhalten einen Iterator, den wir der Variablen it zuweisen.
Solange nun Notizen im Notizbuch sind, d.h. it.hasNext() == true wird
die nächste Notiz von it.next() geliefert und der Variablen notizinhalt
zugewiesen. Hierbei bedeutet der Operator == den Test auf Gleichheit.
Anschließend wird diese Zeichenkette gedruckt.
Es kommt hier ein wichtiges neues Konzept der sogenannte Cast-Operator
zum Einsatz:
notizinhalt = (String) it.next();
Ein Cast-Operator besteht aus dem Namen eines Typs, der zwischen zwei
runden Klammern steht, wie z.B. (String).
Ein Cast-Operator ist notwendig, wenn Elemente aus einer Sammlung geholt
werden, die im Prinzip von beliebigem Typ sind wie es bei der ArrayList
zulässig ist.
Um dem Compiler klar zu machen, welchen Typ von Objekt wir in einer
spezifischen Situation aus der Sammlung herausholen, verwenden wir eine
explizite Typzuweisung über den Cast-Operator. Dies müssen wir immer machen, falls wir die Methode get einer Sammlung oder die Methode next eines
Iterators benutzen.
24
3.4.3
Zugriff mit Index oder über Iteratoren
In den beiden vorigen Abschnitten haben wir zwei Möglichkeiten kennengelernt, um über alle Elemente einer Sammlung zu laufen.
Im Prinzip scheinen beide Ansätze gleich gut, außer dass die erste Methode
eventuell einfacher zu verstehen war.
Beim Sammelbehälter ArrayList sind tatsächlich beide Möglichkeiten gleich
gut. Jedoch bietet Java weitere Sammlungsklasse, bei denen die Iteration
über den Index deutlich ineffizienter ist.
Die zweite Lösung über Iteratoren hingegen wird von allen Sammlungsklassen
der Java-Bibliotheken unterstützt, so dass man im Normalfall Iteratoren für
das Ablaufen aller Elemente einer Sammlung verwenden sollte.
3.5
Sammlungen mit fester Größe
Im den vorigen Abschnitten haben wir den Sammelbehälter ArrayList kennengelernt, der beliebig viele Elemente aufnehmen kann.
Es gibt allerdings Situationen, in denen wir im voraus wissen, wie viele Elemente wir in einer Sammlung ablegen wollen, und diese Anzahl für die Lebensdauer eines Objekts immer konstant bleibt.
Für solche Situationen gibt es Sammlungen mit fester Größe. Diese Sammlungen werden Array genannt.
Obwohl die feste Größe ein deutlicher Nachteil sein kann — siehe unser Beispiel mit dem Notizbuch — bietet sie jedoch auch die folgenden Vorteile:
• Der Zugriff auf die Elemente der Sammlung geschieht viel effizienter
als bei Sammlungen mit flexibler Größe.
• In Arrays können neben Objekten auch Werte primitiver Datentypen
(wie z.B. int oder real) abgelegt werden. Sammlungen mit flexibler
Größe können dagegen nur Objekte speichern.
Der Zugriff erfolgt in Arrays mit Hilfe einer speziellen Syntax, die von den
üblichen Methodenrufen abweicht.
Dies hat in erster Linie historische Gründe, da Arrays eine Sammlungsstruktur sind, die in allen ‘älteren’ Programmiersprachen auch vorkommt. In Java
25
wurde die entsprechende Zugriffsnotation beibehalten, um Programmierern
den Umstieg auf Java zu erleichtern, obwohl diese Syntax mit dem Rest der
Syntax in Java nicht konsistent ist.
Diese Syntax und ein neues Schleifenkonstrukt wollen wir uns nun anhand
des folgenden Beispiels näher anschauen.
3.5.1
Die Analyse einer Logdatei
Webserber verwalten üblicherweise sogenannte Logdateien, in denen Informationen über die Zugriffe auf Webseiten abgelegt werden. Daraus können
Informationen extrahiert werden wie z.B.
• welche die beliebteste Seite im Netz ist,
• wie viele Daten an Kunden geliefert wurden oder
• zu welchen Tageszeiten über die Wochentage die Zugriffe besonders
hoch sind.
Diese Informationen können beispielsweise dazu genutzt werden, um Zeiten
zu bestimmen, die für Wartungsarbeiten aufgrund der geringen Zugriffe am
besten geeignet sind.
Das Projekt Weblog-Auswertung führt eine Analyse solcher Daten eines Webservers durch, wobei angenommen wird, dass bei jedem Zugriff auf eine Webseite
folgende Zeile protokolliert wird:
Jahr Monat Tag Stunde Minute
Das Projekt Weblog-Auswertung besitzt vier Klassen, wobei wir uns hier
jedoch nur die Klasse ProtokollAuswerter näher ansehen werden, da diese
die neuen uns interessierenden Konstrukte enthält.
Ein Objekt dieser Klasse liefert uns Informationen darüber, welche Stunden
des Tages wieviele Zugriffe haben. Dies geschieht, indem für jede Stunde die
entsprechenden Zugriffe gezählt und in einem Array abgelegt werden.
3.5.2
Array-Variablen deklarieren
Die Klasse ProtokollAuswerter enthält ein Datenfeld mit einem Array-Typ.
26
private int[] zugriffeInStunde;
D.h. ein Array eines Datentyps wird deklariert, indem dem entsprechenden
Datentyp eckige Klammern nachgestellt werden.
int[] zugriffeInStunde; besagt also, dass die Variable zugriffeInStunde
ein Array von ganzen Zahlen aufnehmen kann. Wir sagen, dass int der Basistyp dieses Arrays ist.
Durch die Deklaration einer Array-Variablen wurde noch kein Feld erzeugt.
Dies geschieht erst als getrennter Schritt durch eine new-Anweisung, wie wir
es von der Erzeugung anderer Objekte gewohnt sind.
Die Deklaration einer Array-Variablen macht einen wichtigen Unterschied
zwischen Array-Variablen und anderen Sammlungsvariablen deutlich. Die
Deklaration einer Array-Variablen enthält die Angabe über den Typ der Elemente (in unserem Beispiel int), während die Deklaration einer ArrayList
keine Information über den Typ der aufzunehmenden Objekte enthält.
3.5.3
Array-Objekte erzeugen
Der Konstruktor der Klasse ProtokollAuswerter enthält die Anweisung zur
Erzeugung eines Arrays von ganzen Zahlen:
zugriffeInStunde = new int[24];
Diese Anweisung erzeugt ein Array-Objekt, in dem 24 verschiedene intWerte gespeichert werden können, und lässt die Variable zugriffeInStunde
darauf verweisen. Die folgende Abbildung veranschaulicht das Ergebnis dieser
Zuweisung:
zugriffeInStunde
Array vom Typ int[]
0 1 2 3 4 5
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
Die allgemeine Form für die Erzeugung eines Array-Objekts ist:
new Datentyp[int-Ausdruck];
27
Wie bei allen Zuweisungen muss der Typ des erzeugten Objekts zum deklarierten Typ der Variable passen. Bei zugriffeInStunde ist dies der Fall.
Die folgende Zeile deklariert eine Variable für ein Array von Zeichenketten
und lässt diese auf ein Array verweisen, das 10 Zeichenketten aufnehmen
kann:
String[] namensListe = new String[10];
Zu beachten ist, dass hierbei nur ein Behälter erzeugt wird, der 10 Zeichenketten aufnehmen kann. Diese Zeichenketten werden jedoch hiermit noch nicht
erzeugt, sondern müssen später im Programm generiert werden, um in den
Behälter aufgenommen zu werden.
3.5.4
Array-Objekte benutzen
Auf einzelne Elemente eines Arrays wird über einen Index zugegriffen. Ein
Index ist ein ganzzahliger Ausdruck zwischen eckigen Klammern, der auf den
Namen der Variablen folgt:
zugriffInStunde[13] liefert die vierzehnte Integer-Zahl im Array zugriffInStunde.
D.h. analog wie bei den Sammlungsbehältern mit variabler Elementanzahl
liefert der Index 0 das erste Element usw.
zugriffInStunde[aktstunde-2] liefert den Inhalt der Zugriffe vor zwei
Stunden, falls die Integer-Variable aktstunde die aktuelle Stundenzahl enthält.
Dieser Zugriff ist jedoch nicht ungefährlich. Angenommen die Variable aktstunde
enthält den Wert 1, so ist der Index -1 und der Zugriff liefert einen Fehler
bei der Ausführung des Programms, der ArrayIndexOutOfBoundsException
genannt wird. Analog tritt dieser Fehler auf, falls der Index größer oder gleich
der Zahl 24 ist.
Ausdrücke, die ein Element aus einem Array auswählen, können an allen
Stellen stehen, an denen ein Wert des Basistyps erwartet wird. Folgende
Zeilen geben hierfür Beispiele:
namensListe[7] = Franz Kummert";
System.out.println(namensListe[2]);
namensListe[7] = namensListe[4] + index;
zugriffeInStunde[3]++;
28
Die Benutzung eines Array-Index auf der linken Seite einer Zuweisung entspricht dabei einer setze-Methode (set), während die Verwendung auf der
rechten Seite einer gib-Methode (get) entspricht.
Während wir über alle Elemente der Logdatei mit Hilfe der uns bereits bekannte while-Schleife iterieren, benutzen wir bei der Methode stundendatenAusgeben()
ein Konstrukt, das besonders gut für das Iterieren über Arrays geeignet ist.
3.5.5
Die for-Schleife
Die for-Schleife ist eine iterative Kontrollstruktur, die besonders geeignet ist,
wenn
• wir Anweisungen wiederholen wollen und die Anzahl der Wiederholungen feststeht;
• wir innerhalb der Schleife eine Variable benötigen, deren Wert sich bei
jedem Durchlauf um einen festen Wert — typischerweise um 1 —
verändern soll.
Diese Anforderungen passen genau zur Bearbeitung aller Elemente eines Arrays.
Eine for-Schleife hat die folgende allgemeine Form:
for (Initialisierung; Bedingung; Aktion nach jedem Durchlauf) {
Anweisungen, die zu wiederholen sind
}
Die folgende for-Schleife in der Methode stundendatenAusgeben() der Klasse ProtokollAuswerter ist nun leicht zu verstehen:
for (int stunde = 0; stunde < zugriffeInStunde.length; stunde++)
{
System.out.println(stunde + ": " + zugriffeInStunde.[stunde]);
}
Im Initialisierungsteil wird die Integervariable stunde deklariert und mit dem
Wert 0 initialisiert.
Im Bedingungsteil lernen wir einen neuen Aspekt von Arrays kennen. Alle
Arrays enthalten nämlich ein Datenfeld length, das die festgelegte Größe
29
eines Arrays enthält. Der Wert dieses Datenfeldes wird bei der Generierung
des Array-Objekts gesetzt; in unserem Beispiel also auf 24.
Die Schleife wird deshalb solange wiederholt, solange der Wert von stunde
niedriger als 24 ist. Dies wird mit Hilfe des ‘<’-Operators abgeprüft.
Nach jedem Schleifendurchlauf wird stunde um 1 erhöht, so dass sukzessive
alle Elemente von zugriffeInStunde abgelaufen und entsprechend ausgegeben werden können.
3.6
Zusammenfassung
Wir haben zwei Mechanismen zum Speichern von Sammlungen von Objekten
diskutiert.
Die ArrayList ist eine Sammlung mit flexibler Größe und speichert beliebige
Objekte.
Das Array eines Datentyps ist eine Sammlung fester Länge von Werten bzw.
Objekten des entsprechenden Datentyps.
Wir haben zwei Kontrollmechanismen kennengelernt, um einen Block von
Anweisungen wiederholt durchzuführen.
Die while-Schleife wird solange durchgeführt, solange die Bedingung true ist.
Bei der for-Schleife kann zusätzlich eine Laufvariable initialisiert und nach
jedem Schleifendurchgang verändert werden.
Alle Sammlungsbehälter mit flexibler Größe stellen einen Iterator zur Verfügung, um alle Objekte ablaufen zu können.
30
4
Benutzung von Klassenbibliotheken
Im vorigen Kapitel haben wir die Klasse ArrayList aus der Java-Klassenbibliothek
benutzt und uns sehr viel Arbeit gespart, da wir den Sammelbehälter nicht
selbst implementieren mussten.
Die Java-Klassenbibliothek besteht aus tausenden von Klassen, von denen
viele für unsere weitere Arbeit recht nützlich sein können.
Die Fähigkeit auf Klassen aus der Java-Klassenbibliothek zurückzugreifen,
erleichtert die Programmierung erheblich und trägt zu einer effizienten Programmentwicklung bei.
Ein guter Java-Programmier sollte
• einige der wichtigsten Klassen namentlich kennen wie z.B. ArrayList
und
• wissen, wie er sich die anderen Klassen (mitsamt den Details ihrer
Methoden und Parameter) anlesen kann.
Aus diesen Gründen werden wir uns in diesem Kapitel auf den Umgang
mit der Java-Klassenbibliothek konzentrieren und dabei eine Reihe neuer
Techniken kennenlernen.
Wie immer werden wir dies anhand eines Beispiels machen, das wir uns
zunächst kurz ansehen.
4.1
Beispiel: Ein primitives Kundendienstsystem
Diese Anwendung soll den technischen Kundendienst für die Kunden der
fiktiven Softwarefirma SeltsamSoft abwickeln.
Um Geld zu sparen, sind hierfür keine Mitarbeiter mehr vorgesehen, sondern
der Kundendienst soll vollautomatisch online abgewickelt werden.
Das heißt, unser Programm soll Antworten simulieren, die ein Mitarbeiter
im technischen Kundendienst geben könnte, um dem Kunden den Eindruck
zu vermitteln, dass sich die Firma kompetent um seine Probleme kümmert.
Im Projekt Technischer-Kundendienst1 in Kapitel05 gibt es hierzu drei Klassen. Wenn sie in einem Objekt der Klasse Kundendienstsystem die Me31
thode starten() aufrufen, so können Sie Anfragen an den Kundendienst
stellen. Diese werden von einem Objekt der Klasse Eingabeleser eingelesen
und an den Kundendienst übermittelt. Ein Objekt der Klasse Beantworter
übermittelt die vom System generierten Antworten dann an den Benutzer.
Wie Sie schnell bemerken, liefert das System immer die gleiche Antwort.
Dieses unschöne Verhalten wollen wir natürlich im Verlauf dieses Kapitels
deutlich verbessern.
Sehen wir uns nun den Quelltext der Klasse Kundendienstsystem etwas näher
an.
Die beiden Datenfelder leser und beantworter werden im Konstruktor mit
entsprechenden Objekten initialisiert.
Das Herzstück der Klassendefinition ist die Methode starten(), die solange Benutzeranfragen einliest bis sich der Benutzer mit ‘ade’ verabschiedet.
Ansonsten wird eine Antwort generiert, wobei die Eingabe anscheinend nicht
beachtet wird. Dies erklärt auch das stupide Verhalten unserer Kundendienstsystems, insbesondere wenn Sie sich die Klassendefinition von Beantworter
kurz ansehen.
Für die Entscheidung, ob sich der Kunde verabschieden will, wird die Methode startsWith der Klasse String benutzt. Die Frage ist nun, was macht
diese Methode und wie finden wir dies heraus.
Da wir diese Fragen keinesfalls durch Ausprobieren klären wollen und die
Klasse String zur Java-Klassenbibliothek gehört, wollen wir nun die Bibliotheksdokumentation untersuchen.
4.2
Klassendokumentation lesen
Sie erreichen die Dokumentation, indem Sie im Hilfe-Menü den Punkt Java
Class Libraries auswählen oder direkt auf die Internetseite
http://java.sun.com/j2se/1.5.0/docs/api/index.html gehen.
Der Webbrowser zeigt drei Bereiche. Links oben sehen Sie eine Liste aller Pakete und darunter eine Liste aller Klassen in der Java-Bibliothek. Im großen
rechten Bereich zeigt Detailinformation über das selektierte Paket oder die
selektierte Klasse.
Selektieren Sie nun links unten die Klasse String, so dass nun rechts die
32
Detailinformation zu dieser Klasse angezeigt wird.
4.2.1
Schnittstelle versus Implementierung
Sie werden feststellen, dass die Dokumentation unterschiedliche Informationen liefert, wie
• den Namen der Klasse
• eine allgemeine Beschreibung des Zwecks der Klasse
• eine Liste der Konstruktoren und Methoden der Klasse
• die Zugriffsmodifikatoren, Parameter und Ergebnistypen für jeden Konstruktor und jede Methode
• eine Beschreibung des Zwecks jedes Konstruktors und jeder Methode
Diese Informationen werden zusammengefasst als die Schnittstelle der Klasse
bezeichnet. Dagegen wird der vollständige Quelltext einer Klasse Implementierung genannt. Im Sinne der Abstraktion ist es jedoch ausreichend, wenn
wir von einer Klasse nur die Schnittstelle kennen und die Details der Implementierung nicht sichtbar sind.
4.2.2
Benutzung einer Methode einer Bibliotheksklasse
Wir können nun nachlesen, was die Methode startsWith der Klasse String
genau tut. In der Methodenliste gibt es zwei Versionen dieser Methode. Da
in unserem Beispiel nur eine Zeichenkette als Parameter übergeben wird, ist
die zweite Beschreibung relevant.
Wie der Name schon vermuten ließ, überprüft diese Methode eines StringObjekts, ob das Argument ein Präfix des String-Objekts ist.
Die Methode startsWith liefert also für eine Zeichenkette nur dann true,
falls diese Zeichenkette mit ade beginnt.
Falls sich der Benutzer mit ”Ade” oder ” ade” verabschiedet, so erkennt
dies das System nicht als Verabschiedungsfloskel an.
33
Wir wollen dies nun ändern, indem wir den vom Benutzer eingegebenen Text
anpassen. Hierbei ist zu beachten, dass ein String-Objekt nach seiner Erzeugung nicht mehr verändert werden kann. Stattdessen müssen wir auf der
Basis der ursprünglichen Zeichenkette eine neue erzeugen.
Die Schnittstellenbeschreibung der Klasse String zeigt uns, dass es eine Methode trim gibt, die Leerzeichen zu Beginn und am Ende einer Zeichenkette
abschneidet. Damit haben wir den zweiten Problemfall gelöst.
Wir können nun die Methode starten wie folgt modifizieren:
String eingabe = leser.gibEingabe();
eingabe = eingabe.trim();
Die letzte Anweisung veranlasst das String-Objekt, auf das die Variable
eingabe verweist, eine neue Zeichenkette zu erzeugen, in der führende und
anhängige Leerzeichen entfernt sind. Diese neue Zeichenkette wird dann in
der Variablen eingabe gespeichert.
Obige zwei Anweisungen können auch wie folgt zusammengefasst werden:
String eingabe = leser.gibEingabe().trim();
Die rechte Seite ist zu lesen als wäre sie wie folgt geklammert:
(leser.gibEingabe()) .trim();
Welche von beiden Versionen Sie bevorzugen, hängt vom Ihrem persönlichen
Geschmack ab. Vom Effekt her sind sie gleichwertig.
Nun bleibt nur noch das Problem mit der Groß- und Kleinschreibung zu
lösen. Verwenden Sie hierzu die Methode toLowerCase.
Beachte: Der Versuch die Abschiedsfloskel über die Anweisung
if (eingabe == "ade")
abzutesten, geht im Normalfall schief, da der Operator ‘==’ die Identität
zweier Objekte überprüft, es jedoch durchaus unterschiedliche String-Objekte
geben kann, die die gleiche Zeichenkette enthalten.
Welche Methode der Klasse String sollte man deshalb wählen, falls man nur
überprüfen will, ob zwei Zeichenketten gleich aber nicht unbedingt identisch
sind?
34
4.3
Zufälliges Verhalten einbringen
Wir haben im vorigen Abschnitt zwar eine minimale Verbesserung unseres
Kundendienstsystems erreicht, aber eines der Hauptprobleme bleibt bestehen, nämlich dass immer dieselbe Antwort geliefert wird, unabhängig von
der Frage des Benutzers.
Zur Verbesserung dieses Verhaltens gehen wir folgendermaßen vor:
• Lege einige plausibel klingende Antworten in einer ArrayList ab.
• Generiere eine Zufallszahl und benutze diese als Index, um eine Antwort
aus der ArrayList auszuwählen. (Die Auswahl in Abhängigkeit der
Benutzeranfrage erfolgt später.)
Hierzu müssen wir herausfinden, wie man Zufallszahlen generiert. Glücklicherweise
stellt die Klassenbibliothek von Java bereits eine passende Klasse zur Verfügung.
4.3.1
Die Klasse Random
Wenn wir uns die Schnittstelle der Klasse Random näher betrachten, so sehen
wir, dass wir zur Erzeugung von Zufallszahlen ein Objekt der Klasse Random
erzeugen müssen und uns dann diverse next-Methoden zur Generierung einzelner Zufallszahlen zur Verfügung stehen.
Random zufallsgenerator = new Random();
Für das oben definierte Problem der Generierung eines zufälligen Index eignet
sich die Methode
int nextInt(int n)
am besten. Hierbei gibt n die Anzahl unserer Anwortschablonen an.
Es werden also zufällige Indizes zwischen 0 und n − 1 generiert, mit denen
wir auf die ArrayList zugreifen können.
Beachte: Generieren Sie nur ein Objekt zur Klasse Random, um Zufallszahlen zu generiern. Erzeugen Sie auf keinen Fall für jede Zufallszahl ein eigenes
Random-Objekt, da die Erzeugung natürlich nicht zufällig ist, sondern einem komplizierten Algorithmus folgt, der für unterschiedliche Objekte gleich
abläuft.
35
4.3.2
Zufällige Antworten generieren
Der folgende Quelltext zeigt eine Version der Klasse Beantworter, der zufällige
ausgewählte Antworten zurückliefert:
import java.util.ArrayList;
import java.util.Random;
/**
* Die Klasse Beantworter beschreibt Exemplare, die
* automatische Antworten generieren.
*
* Dies ist die zweite Version dieser Klasse. In dieser Version
* generieren wir Antworten, indem wir zufällig eine Antwortphrase
* aus einer Liste auswählen.
*
* @author
Michael Kolling and David J. Barnes
* @version
0.2 (2.Feb.2002)
*/
public class Beantworter
{
private Random zufallsgenerator;
private ArrayList antworten;
/**
* Construct a Beantworter
*/
public Beantworter()
{
zufallsgenerator = new Random();
antworten = new ArrayList();
antwortlisteFuellen();
}
/**
* Generiere eine Antwort.
* @return Einen String, der die Antwort enthält.
*/
public String generiereAntwort()
{
36
// Erzeuge eine Zufallszahl, die als Index in der Liste der
// Antworten benutzt werden kann. Die Zahl wird im Bereich von
// Null (inklusiv) bis zur Größe der Liste (exklusiv) liegen.
int index = zufallsgenerator.nextInt(antworten.size());
return (String) antworten.get(index);
}
/**
* Generiere eine Liste von Standardantworten, aus denen wir eine
* auswählen können, wenn wir keine bessere Antwort wissen.
*/
private void antwortlisteFuellen()
{
antworten.add("Das klingt seltsam. Können Sie das Problem" +
" ausführlicher beschreiben?");
antworten.add("Bisher hat sich noch kein Kunde darüber\n" +
"beschwert. Welche Systemkonfiguration haben Sie?");
antworten.add("Das klingt interessant. Erzählen Sie mehr...");
antworten.add("Da brauche ich etwas ausführlichere Angaben.");
antworten.add("Haben Sie geprüft, ob Sie einen Konflikt mit" +
" einer DLL haben?");
antworten.add("Das steht im Handbuch. Haben Sie das Handbuch" +
" gelesen?");
antworten.add("Das klingt alles etwas Wischi-Waschi. Haben Sie\n" +
"einen Experten in der Nähe, der das etwas\n" +
"präziser beschreiben kann?");
antworten.add("Das ist kein Fehler, das ist eine" +
" Systemeigenschaft!");
antworten.add("Könnten Sie es anders erklären?");
}
}
Die Klasse Beantworter besitzt nun zwei Datenfelder, nämlich zur Aufnahme
einer Antwortenliste und eines Zufallsgenerators.
Beide werden im Konstruktor initialisiert. Zudem werden mit Hilfe der Methode antwortlisteFuellen neun Antworten in die Antwortliste eingetragen.
Zur Antwortgenerierung (Methode generiereAntwort) wird mit Hilfe der
Methode nextInt(int n) eine Zufallszahl zwischen 0 und der aktuellen
37
Größe minus 1 der Antwortenliste generiert. Gemäß diesem Index wird eine
Antwort aus der Antwortenliste herausgeholt und als Ergebnis zurückgegeben,
wobei der Cast-Operator verwendet wird.
Ungeschickt wäre es gewesen, die Methode getInt folgendermaßen aufzurufen:
int index = zufallsgenerator.nextInt(9);
In der augenblicklichen Version würde zwar dasselbe Verhalten erzielt, jedoch
wenn wir in die Antwortliste neue Antworten einfügen, so würde die erste
Version weiterhin korrekt arbeiten, wohingegen die zweite Version die neuen
Antworten nicht berücksichtigen würde. Bei der Löschung von Antworten
würde sogar eine IndexOutOfBoundsException auftreten können.
4.4
Pakete und Importe
Die Java-Klassen in einer Klassenbibliothek stehen nicht automatisch zur
Verfügung wie die übrigen Klassen in unserem Projekt. Stattdessen müssen
wir im Quelltext deklarieren, welche Klasse wir verwenden möchten. Dies
geschieht durch die import-Anweisung in der Form:
import java.util.Random;
Da die Java-Bibliothek aus mehreren tausend Klassen besteht, ist ein Strukturierungsmechanismus notwendig, um den Umgang mit dieser Menge zu
erleichtern. Java nutzt sogenannte Pakete (packages), um Bibliotheksklassen
in zusammengehörige Gruppen zu bündeln. Pakete können geschachtelt sein,
d.h. weitere Pakete enthalten.
Die Klassen ArrayList und Random gehören beide zum Paket java.util,
was man der Klassendokumentation entnehmen kann. Der vollständige oder
qualifizierte Name einer Klasse besteht aus dem Namen des Pakets, gefolgt
von einem Punkt und dem Klassennamen. Der qualifizierte Name der Klasse
Random ist somit java.util.Random.
In Java können auch ganze Pakete importiert werden:
import Paketname.*;
Die Anweisung
import java.util.*;
38
importiert also alle Klassen des Pakets java.util.
Die letzte Form ist zwar platzsparender, jedoch macht das einzelne Importieren von Klassen sehr viel transparenter, welche Klassen wirklich benötigt
werden. Deshalb sollte man dies bevorzugen.
Es gibt eine Ausnahme für die Importregel. Einige Klassen werden so häufig
benötigt, dass man sie praktisch immer importieren müsste. Diese Klassen
sind im Paket java.lang definiert und werden automatisch in jede Klassendefinition importiert. Die Klasse String ist ein Beispiel für eine Klasse aus
java.lang.
4.5
Benutzung von Map-Klassen für Abbildungen
Wir haben nun eine Lösung unseres Kundendienstes, das zufällige Antworten
generiert. Dies ist zwar besser als die initiale Lösung, jedoch noch nicht wirklich überzeugend. Wir wollen nun in unsere Antworten auch die Anfragen des
Benutzers mit einfließen lassen.
Die Grundidee ist, dass wir eine Menge von Wörtern definieren, die typischerweise in Anfragen auftauchen, und diese Wörter mit Antworten verknüpfen.
Wenn die Anfrage des Benutzers eines dieser typischen Wörter enthält, dann
können wir eine passende Antwort geben. Dies ist zwar noch immer keine
optimale Lösung, aber sie kann überraschend effektiv sein.
Für die Realisierung unserer Idee verwenden wir die Klasse HashMap. Sie
finden die Dokumentation dieser Klasse in der Java-Bibliothek. Die Klasse
HashMap ist eine Spezialisierung der Klasse Map. Wir werden uns beide Klassendokumentationen ansehen müssen, um zu verstehen, was eine HashMap ist
und wie sie funktioniert.
4.5.1
Das Konzept einer Map
Eine Map ist eine Sammlung von Schlüssel-Wert-Paaren, wobei sowohl die
Schlüssel als auch Werte Objekte sind. Analog zu einer ArrayList kann eine
Map eine beliebige Anzahl von Einträgen haben. Der Unterschied ist, dass
ein Eintrag nicht ein einzelnes Objekt ist, sondern ein Paar von Objekten ist
bestehend aus Schlüssel-Objekt und Wert-Objekt.
Bei einer Map verwenden wir keinen Index, um ein Element aus einer Samm39
lung zu referenzieren (wie z.B. bei einer ArrayList, sondern wir verwenden
das Schlüssel-Objekt, um das Wert-Objekt zu bekommen. Der Schlüssel wird
also auf den Wert abgebildet.
Ein Alltagsbeispiel einer Map ist ein Telefonbuch, das Einträge enthält, die
aus dem Namen (Schlüssel) und der Telefonnummer (Wert) bestehen. Durch
die alphabetische Ordnung der Namen (Schlüssel), ist es relativ einfach den
zugehörigen Wert nämlich die Telefonnummer zu bekommen. Die umgekehrte
Suche ist bei einem Telefonbuch allerdings immens aufwändig.
4.5.2
Die Benutzung einer HashMap
Eine HashMap eine eine spezifische Implementierung einer Map, wobei besonderer Wert auf das schnelle Auffinden eines Wertes bei gegebenem Schlüssel
gelegt wird. Mehr hierzu erfahren Sie später in der Vorlesung.
Die wichtigsten Methoden sind put und get. Die Methode put fügt der Abbildung einen Eintrag hinzu und get liefert für einen gegebenen Schlüssel
den zugehörigen Wert. Der folgende Quelltext erzeugt eine HashMap fügt drei
Einträge ein. Jeder Eintrag ist ein Schlüssel-Wert-Paar, bestehend aus einem
Namen und einer Telefennummer:
HashMap telefonbuch = new HashMap();
telefonbuch.put("Günther Meister", "(0521) 973926");
telefonbuch.put("Thomas Bauer", "(089) 6538887");
telefonbuch.put("Susanne Müller", "(0951) 729370");
Die folgende Anweisung sucht die Telefonnummer von Thomas Bauer und
gibt sie aus:
String nummer = (String) telefonbuch.get("Thomas Bauer");
System.out.println(nummer);
Analog wie bei der ArrayList müssen wir hier eine Cast-Anweisung verwenden, da eine HashMap beliebige Objekte als Werte haben kann.
40
4.5.3
Die Benutzung einer Abbildung für das Kundendienstsystem
Für unser Kundendienstsystem nehmen wir nun die typisch vorkommenden
Wörter als Schlüssel und ordnen ihnen als Wert die entsprechende Antwort
zu. Der folgende Quelltext zeigt, wie eine HashMap mit drei Antworten für
die Schlüsselwörter langsam, fehler und teuer gefüllt wird.
private HashMap antwortMap;
...
public Beantworter()
{
antwortMap = new HashMap();
antwortMapBefuellen();
}
/**
* Trage alle bekannten Stichwörter mit ihren verknüpften
* Antworten in die Map ’antwortMap’ ein.
*/
private void antwortMapBefuellen()
{
antwortMap.put("langsam",
"Ich vermute, dass das mit Ihrer Hardware zu tun hat. Ein Upgrade\n" +
"für Ihren Prozessor sollte diese Probleme lösen. Haben Sie ein\n" +
"Problem mit unserer Software?");
antwortMap.put("fehler",
"Wissen Sie, jede Software hat Fehler. Aber unsere Entwickler\n" +
"arbeiten sehr hart daran, diese Fehler zu beheben.\n" +
"Können Sie das Problem ein wenig genauer beschreiben?");
antwortMap.put("teuer",
"Unsere Preise sind absolute Marktpreise. Haben Sie sich mal\n" +
"umgesehen und wirklich unser Leistungsspektrum verglichen?");
}
Hierbei bezeichnet ‘\n´, dass die nachfolgende Zeichenkette in einer neuen
Zeile auszugeben ist.
Ein erster Versuch, eine Methode zum Erzeugen von Antworten zu schreiben,
könnte so aussehen:
41
public String generiereAntwort(String wort)
{
String antwort = (String) antwortMap.get(wort);
if(antwort != null) {
return antwort;
}
else {
// Wenn wir hierher gelangen, wurde das Stichwort nicht erkannt.
// In diesem Fall wählen wir eine unserer Standardantworten.
return standardantwortAuswählen();
}
}
Hier suchen wir zu einer Anfrage des Benutzers den zugehörigen Wert. Falls
kein Wert gefunden wurde, dann rufen wir die Methode standardantwortAuswählen
auf, die analog zu Abschnitt 4.3.2 zufällig eine Antwort auswählt.
Führt eine get-Anfrage bei einer Abbildung zu keinem Ergebnis, so wird das
Java-Schlüsselwort null zurückgeliefert. Es hat die Bedeutung ‘kein Objekt´.
Das Schlüsselwort null wird in einer Objektvariablen gespeichert, die auf
kein konkretes Objekt zeigt. Objektvariablen, die deklariert, jedoch nicht
initialisiert wurden, enthalten standardmäßig den Wert null.
Unser obiger Ansatz funktioniert jedoch nur, falls der Benutzer nur einzelne Wörter als Anfrage eingibt. Wir wollen nun unser System so erweitern,
dass der Benutzer ganze Sätze eingeben kann und wir die passende Antwort
auswählen, falls eines der eingegebenen Wörter ein Schlüsselwort ist.
Hierzu müssen wir einzelne Wörter in einer längeren Zeichenkette erkennen.
Wir werden deshalb unser System so erweitern, dass die Wörter einer Anfrage
in einer Menge von Zeichenketten abgelegt werden, die dann nacheinander
abgeprüft werden, ob sie ein Schlüsselwort repräsentieren.
Bevor wir uns ansehen, wie man einen Satz in Wörter zerlegt, müssen wir
lernen, wie man mit Mengen umgeht.
4.6
Der Umgang mit Mengen
Die Java-Bibliothek bietet verschiedene Varianten von Mengen an, die durch
unterschiedliche Klassen implementiert sind. Wir werden im Folgenden die
42
Klasse HashSet benutzen.
Wenn wir uns die Beschreibung der Klassendokumentation ansehen, können
wir erkennen, dass HashSet ähnliche Methoden wie die Klasse ArrayList besitzt. Die für uns wichtigsten sind add zum Einfügen von Elementen in eine Menge und iterator, um über alle Elemente einer Menge iterieren zu
können.
Das Schöne in Java ist, dass der Umgang mit den unterschiedlichen Sammlungen sehr ähnlich ist. Wenn Sie also mit einer Sammlungsklasse umgehen
können, so sollte dies auch für die anderen Sammlungsklassen kein Problem
sein.
Betrachten Sie die beiden folgenden Quelltexte und überlegen Sie sich, welche
Ausgaben jeweils gemacht werden.
// 1. Gebrauch einer ArrayList
import java.util.ArrayList
import java.util.Iterator
...
ArrayList meineListe = new ArrayList;
meineListe.add("eins");
meineListe.add("zwei");
meineListe.add("eins");
meineListe.add("drei");
Iterator it = meineListe.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
// 2. Gebrauch eines HashSet
import java.util.HashSet
import java.util.Iterator
...
HashSet meineMenge = new HashSet;
meineMenge.add("eins");
43
meineMenge.add("zwei");
meineMenge.add("eins");
meineMenge.add("drei");
Iterator it = meineMenge.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
Bevor wir nun die Wörter einer Benutzeranfrage in eine Wortmenge eintragen
können, müssen wir uns noch ansehen, wie wir Zeichenketten in einzelne
Wörter zerlegen können.
4.7
Zeichenketten zerlegen
Hierzu können wir wieder auf eine Methode der Klasse String zurückgreifen,
nämlich auf die Methode split.
Diese bricht eine Zeichenkette an den Stellen auf, die durch einen sogenannten regulären Ausdruck definiert sind, der als Argument übergeben wird. Die
dadurch gewonnenen Zeichenketten werden in einem Array von Zeichenketten zurückgeliefert.
Wir wollen uns an dieser Stelle nicht näher mit regulären Ausdrücken beschäftigen, sondern uns nur den für unsere Zwecke erforderlichen Ausdruck ansehen.
Durch den Ausdruck ”\\s+” wird eine Zeichenkette an den Stellen aufgebrochen, wo ein Leerzeichen, ein Tabulatorzeichen, das Zeichen für neue
Zeile oder ein anderer sogenannter ‘whitespace character’ steht. Dies wird
durch die Sequenz \s ausgedrückt. Für eine nähere Beschreibung regulärer
Ausdrücke siehe die Dokumentation der Klasse Pattern. Zudem muss der
Backslash durch Voranstellen eines weiteren Backslash maskiert werden, da
der Compiler die Sequenz \s sonst als ein Ascii-Zeichen annimmt, das jedoch
nicht definiert ist. Das abschließende +-Zeichen gibt an, dass zwei Wörter
auch durch mehrere ‘whitespace character’ getrennt sein können.
Somit können wir unsere Menge wie folgt füllen:
public HashSet erzeugeWortmenge(String anfrage)
44
{
HashSet woerter = new HashSet;
String [] wortliste = anfrage.split("\\s+);
for (int i=0; i<wortliste.length; i++) {
woerter.add(wortliste[i]);
}
return woerter;
}
Damit können wir die Antwortgenerierung wie folgt realisieren:
public String generiereAntwort(HashSet woerter)
{
Iterator it = woerter.iterator();
while(it.hasNext()) {
String wort = (String) it.next();
String antwort = (String) antwortMap.get(wort);
if(antwort != null) {
return antwort;
}
}
// Wenn wir hierher gelangen, wurde keines der Eingabewörter erkannt.
// In diesem Fall wählen wir eine unserer Standardantworten (die
// wir geben, wenn uns nichts Besseres mehr einfällt).
return standardantwortAuswählen();
}
Das Projekt Technischer-Kundendienst-komplett enthält alle Verbesserungsvorschläge, die wir in diesem Kapitel diskutiert haben. Sie sollten sich dieses
nochmals in Ruhe anschauen.
4.8
Öffentliche und private Eigenschaften
Wir wollen nun ein Konstrukt betrachten, das wir schon häufig verwendet
haben, ohne ausführlich darüber zu sprechen, nämlich die Zugriffsmodifikatoren, die durch Schlüsselwörter wie public oder private realisiert werden.
45
Datenfelder, Konstruktoren und Methoden können entweder als öffentlich
(public) oder als privat (private) vereinbart werden. Sie definieren die
Sichtbarkeit eines Datenfeldes, eines Konstruktors oder einer Methode.
Öffentliche Elemente sind sowohl innerhalb der definierenden Klasse zugreifbar als auch von anderen Klassen aus. Private Elemente sind ausschließlich
innerhalb der definierenden Klasse zugreifbar. Sie sind für andere Klassen
nicht sichtbar.
Eine Motivation für diese Regelung ist das Bestreben, vorhandene Klasse
ohne Kenntnis der Implementierung verwenden zu können. Wir haben im
Verlauf der Vorlesung auch bereits eine Reihe von Klasse wie z.B. ArrayList
oder HashSet verwendet, die wir nur anhand der Schnittstellendefinition benutzen konnten, ohne uns um die Implementierungsdetails zu kümmern.
Das Schlüsselwort public deklariert ein Element einer Klassendefinition als
Teil der Schnittstelle, während private dieses Element als Teil der Implementierung kennzeichnet, das man extern nicht kennen muss, sogar nicht
einmal verwenden darf.
Hierdurch wird explizit kenntlich gemacht, welche Teile einer Klassendefinition problemlos geändert werden können — nämlich die als private gekennzeichneten —, ohne dass andere Klassen betroffen sind. Ein Wartungsprogrammierer kann beispielsweise für die Klasse ArrayList den Zugriff auf
die Elemente des Sammelbehälters efiizienter programmieren, ohne dass dies
andere Klassen negativ beeinflusst, solange die Schnittstelle gleich bleibt.
Würden wir jedoch direkt auf den Datenfelder dieser Klasse operieren, so
wäre dies nicht mehr möglich.
Das Verstecken der Implementierung bezeichnen wir auch als Geheimnisprinzip oder als information hiding.
Aus dem Gesagten könnte man eigentlich direkt ableiten, dass Datenfelder
immer private sind, da diese zur Implemetierung gehören. Im Gegensatz zu
anderen objektorientierten Programmiersprachen erlaubt zwar Java im Prinzip die Deklaration von Datenfeldern als public, wir sehen dies jedoch als
extrem schlechten Programmierstil an, und wollen dies zukünftig vermeiden
wie der Teufel das Weihwasser.
Dass Methoden public gekennzeichnet sind, ist eher der Normalfall, da durch
sie die Kommunikation mit anderen Klassen geschieht. Jedoch ist es zumindest in den beiden folgenden Fällen sinnvoll, eine Methode als private zu
kennzeichen:
46
• Eine sehr komplexe öffentliche Methode wird in mehrere Einzelaufgaben zerlegt, die jeweils durch eine Methode gelöst werden. Die öffentliche
Methode besteht also aus einer Reihe von Methodenaufrufen, die zu einer übersichtlichen Lösung führen. Die Teillösungen sind jedoch nicht
Teil der Schnittstelle, sondern Teil der Implementierung und werden
deshalb als private gekennzeichnet.
• Eine bestimmte Abfolge von Anweisungen wird in mehreren Methoden
einer Klasse verwendet. Anstatt diese Anweisungen in jeder Methode
explizit hinzuschreiben, verpackt man diese in einer privaten Methode,
die dann von den entsprechenden Methoden aufgerufen werden kann.
Jede Klasse sollte zumindest einen öffentlichen Konstruktor haben, da ansonsten keine Objekte zu dieser Klasse generiert werden können. Jedoch kann
es durchaus sinnvoll sein, dass weitere private Konstruktoren definiert sind,
die nur von Objekten dieser Klasse (vielleicht mit einer spezifischen Initialisierung) aufgerufen werden können.
Später werden wir noch eine dritte Variante eines Zugriffsmodifikators kennen
lernen.
4.9
Zusammenfassung
Java-Bibliothek: Die Standardklassenbibliothek von Java enthält viele Klassen, die sehr nützlich sind. Einen Überblick bekommt man unter
http://java.sun.com/j2se/1.5.0/docs/api/index.html
Dokumentation Die Dokumentation einer Klasse sollte die Informationen
bieten, die andere Programmierer benötigen, um die Klasse ohne Kenntnis
der Implementierung benutzen zu können.
Schnittstelle Die Schnittstelle einer Klasse beschreibt, was eine Klasse leistet und wie sie benutzt werden kann, ohne dass die Implementierung sichtbar
wird.
Implementierung Der komplette Quelltext, der eine Klasse definiert, wird
als Implementierung dieser Klasse bezeichnet.
Map Eine Abbildung (Map) ist eine Sammlung, die Schlüssel-Wert-Paare
als Einträge enthält. Ein Wert kann ausgelesen werden, indem der Schlüssel
angegeben wird.
47
Set Eine Menge (Set) ist eine Sammlung, in der jedes Element nur maximal
einmal enthalten ist. Die Elemente haben keine spezifische Ordnung.
Zugriffsmodifikatoren Zugriffsmodifikatoren definieren die Sichtbarkeit eines Datenfeldes, eines Konstruktors oder einer Methode. Öffentliche (public)
Elemente sind sowohl innerhalb der definierenden Klasse zugreifbar als auch
von anderen Klassen aus. Private Elemente sind ausschließlich innerhalb der
definierenden Klasse zugreifbar.
Geheimnisprinzip Das Geheimnisprinzip besagt, dass die internen Details
der Implementierung einer Klasse vor anderen Klassen verborgen sein sollten.
Dies unterstützt eine bessere Modularisierung von Anwendungen.
48
5
Klassenentwurf
Da Sie in Zukunft verstärkt Klassen entwerfen werden, die miteinander kommunizieren, werden wir in diesem Kapitel die Kriterien näher beleuchten, die
man für einen guten Klassenentwurf beachten sollte.
Wie üblich werden wir uns dies an einem praktischen Beispiel ansehen. Wir
gehen hierbei von einem schlechten Entwurf aus, beleuchten die Probleme, die
sich hieraus ergeben, und werden entsprechende Verbesserungen vornehmen.
Unser Beispiel für einen schlechten Entwurf ist das Projekt Zuul-schlecht
aus Kapitel07. Es ist eine sehr einfache rudimentäre Implementierung eines
textbasierten Adventure-Games, bei dem man sich durch vorgegebene Räume
bewegen kann.
5.1
Beispielprojekt für einen schlechten Klassenentwurf
Das Klassendiagramm des Projekts Zuul-schlecht enthält fünf Klassen. Wenn
wir und die (gut) dokumentierten Quelltexte ansehen, verstehen wir relativ
schnell den Zweck der einzelnen Methoden:
• Die Klasse Befehlswörter definiert die gültigen Befehle für das Spiel.
Sie hält dazu ein Array von Zeichenketten mit den Befehlswörtern.
• Die Klasse Parser liest Eingabezeilen von der Konsole und versucht
sie als Befehl zu interpretieren. Er erzeugt Objekte der Klasse Befehl,
die die eingegebenen Befehle repräsentieren.
• Ein Objekt der Klasse Befehl repräsentiert einen Befehl, den der Benutzer eingegeben hat. Es bietet Methoden zum Test der Gültigkeit
eines Befehls und zur Ausgabe der einzelnen Befehlswörter.
• Ein Objekt der Klasse Raum repräsentiert einen Ort im Spielgeschehen,
die Ausgänge zu anderen Räumen haben können.
• Die Klasse Spiel ist die Hauptklasse des Spiels. Sie initialisiert das Spiel
und liest dann die Befehle in einer Schleife ein und führt diese aus.
Obwohl das Programm aufgrund der guten Dokumentation leicht verständlich
ist, birgt der Klassenentwurf eine Reihe von Schwächen, die wir im Folgen49
den nun näher betrachten wollen, d.h. eine gute Dokumentation ist nur ein
wichtiger Baustein beim Schreiben von Klassen.
Bevor wir die Grundprinzipien für einen guten Klassenentwurf näher betrachten und uns diese an praktischen Beispielen verdeutlichen, widmen wir uns
zunächst einer Vorgehensweise, die man in allen Programmierparadigmen auf
alle Fälle meiden sollte, nämlich der Duplizierung von Quelltextabschnitten.
5.2
Code-Duplizierung
Code-Duplizierung sollte auf jeden Fall vermieden werden, da Änderungen an
der einen Stelle immer auch Änderungen an den duplizierten Stellen nach sich
ziehen. Falls die Duplizierungen nicht ersichtlich sind, kann eine Änderung
an einer Stelle leicht zu Inkonsistenzen im Programm führen.
Sehen wir uns den Quelltext der Klasse Spiel näher an. Sowohl in der Methode willkommenstextAusgeben als auch in der Methode wechsleRaum kommt
folgender Quelltext vor:
System.out.println("Sie sind " + aktuellerRaum.gibBeschreibung());
System.out.print("Ausgänge: ");
if(aktuellerRaum.nordausgang != null)
System.out.print("north ");
if(aktuellerRaum.ostausgang != null)
System.out.print("east ");
if(aktuellerRaum.suedausgang != null)
System.out.print("south ");
if(aktuellerRaum.westausgang != null)
System.out.print("west ");
System.out.println();
Code-Duplizierung lässt sich vermeiden, wenn man die Aufgabe in möglichst
kleine Teilaufgaben zerlegt. Damit steigt die Wahrscheinlichkeit, dass unterschiedliche Aufgaben eine identische Teilaufgabe besitzen, die über dieselbe
Methode gelöst werden kann.
In unserem Fall ist dies die Ausgabe des aktuellen Raumes, die wir durch die
folgende Methode realisieren können:
private void rauminfoAusgeben()
50
{
System.out.println("Sie sind " + aktuellerRaum.gibBeschreibung());
System.out.print("Ausgänge: ");
if(aktuellerRaum.nordausgang != null)
System.out.print("north ");
if(aktuellerRaum.ostausgang != null)
System.out.print("east ");
if(aktuellerRaum.suedausgang != null)
System.out.print("south ");
if(aktuellerRaum.westausgang != null)
System.out.print("west ");
System.out.println();
}
Diese Methode kann nun von willkommenstextAusgeben und wechsleRaum
aufgerufen werden. Bei Änderungen der Ausgabe des aktuellen Raumes ist
nunmehr nur noch die eine Methode rauminfoAusgeben zu ändern.
Nach dieser allgemeinen Betrachtung zum Vorgehen beim Programmieren
sehen wir uns nun die beiden Grundprinzipien für einen guten Klassenentwurf
etwas näher an.
5.3
Grundprinzipien: Kopplung und Kohäsion
Die Begriffe Kopplung und Kohäsion spielen eine zentrale Rolle bei der Qualität von Klassenentwürfen.
Der Begriff Kopplung beschreibt den Grad der Abhängigkeiten zwischen
Klassen. Wir streben für unsere Programme eine möglichst lose Kopplung an,
d.h. jede Klasse ist weitgehend unabhängig und kommuniziert mit anderen
Klassen nur über eine möglichst schmale, wohl definierte Schnittstelle.
Der Grad der Kopplung definiert wie schwierig Änderungen an einer Anwendung sind. In einer eng gekoppelten Klassenstruktur kann eine Änderung
an einer Klasse viele Änderungen an anderen Klassen nach sich ziehen. Dies
wollen wir natürlich möglichst vermeiden. In einer lose gekoppelten Anwendung können wir häufig Änderungen an einer Klasse vornehmen, ohne dass
irgendeine andere Klasse modifiziert werden muss.
Der Begriff Kohäsion beschreibt, wie gut ein Programmteil eine logische
Aufgabe oder funktionale Einheit abbildet. In einer Anwendung mit hoher
51
Kohäsion ist jede Programmeinheit (eine Methode, eine Klasse, Gruppe von
Klassen) verantwortlich für genau eine wohl definierte Aufgabe. Eine Methode sollte genau eine logische Operation implementieren und eine Klasse
sollte genau einen Typ von Objekt modellieren.
Die Hauptmotivation für Kohäsion ist die Wiederverwendbarkeit von Programmcode. Wenn eine Methode oder Klasse für eine sehr klar definierte
Aufgabe zuständig ist, dann ist die Wahrscheinlichkeit gröser, dass diese Einheit auch in anderen Zusammenhängen eingesetzt werden kann.
Wir wollen uns nun an einigen Beispielen ansehen, wie die Befolgung bzw. die
Nichtbefolgung der beiden Grundprinzipien lose Kopplung und hohe Kohäsion
die Qualität eines Klassenentwurfs negativ beeinflussen.
5.4
Kapselung zur Reduzierung der Kopplung
Im Prinzip tut das Projekt Zuul-schlecht das, was es soll. Will man jedoch Erweiterungen vornehmen, so stößt man schnell auf Probleme, die sich aus dem
schlechten Klassenentwurf ergeben. Zunächst wollen wir mehr Bewegungsrichtungen in das Spiel einbauen. Wir wollen nun mehrstöckige Gebäude
zulassen und deshalb up und down als mögliche Richtungen einführen.
Eine Untersuchung der gegebenen Klassen zeigt, dass mindestens zwei Klassen von dieser Änderung betroffen sind: Spiel und Raum.
Raum ist die Klasse, die — neben anderen Dingen — die Ausgänge eines
Raumes speichert, und in der Klasse Spiel werden diese Informationen benutzt, um dem Benutzer mögliche Ausgänge für den Raumwechsel zu melden.
Das Listing der Klasse Raum ist nachfolgend zu sehen:
class Raum
{
public
public
public
public
public
String beschreibung;
Raum nordausgang;
Raum suedausgang;
Raum ostausgang;
Raum westausgang;
/**
* Erzeuge einen Raum mit einer Beschreibung. Ein Raum
52
* hat anfangs keine Ausgänge.
* @param beschreibung enthält eine Beschreibung in der Form
*
"in einer Küche" oder "auf einem Sportplatz".
*/
public Raum(String beschreibung)
{
this.beschreibung = beschreibung;
}
/**
* Definiere die Ausgänge dieses Raums.
* führt entweder in einen anderen Raum
* (kein Ausgang).
*/
public void setzeAusgaenge(Raum norden,
Raum sueden,
{
if(norden != null)
nordausgang = norden;
if(osten != null)
ostausgang = osten;
if(sueden != null)
suedausgang = sueden;
if(westen != null)
westausgang = westen;
}
Jede Richtung
oder ist ’null’
Raum osten,
Raum westen)
/**
* Liefere die Beschreibung dieses Raums (die dem Konstruktor
* übergeben wurde).
*/
public String gibBeschreibung()
{
return beschreibung;
}
}
Beim Lesen stellen wir fest, dass die Ausgänge an zwei Stellen erwähnt werden. Sie werden zu Beginn als Datenfelder aufgezählt und dann in der Methode setzeAusgänge gesetzt.
53
Neue Richtungen müssen also an diesen Stellen ergänzt werden.
Das Finden der betroffenen Stellen in der Klasse Spiel ist deutlich aufwändiger.
Wenn wir diese Klasse näher betrachten dann sehen wir, dass hier starker
Bezug auf die Informationen bezüglich der Ausgänge genommen wird. Ein
Spiel-Objekt hält eine Referenz auf einen Raum in der Variablen aktuellerRaum
und greift häufig auf die Ausgangsinformation dieses Raumes zu:
• In der Methode raeumeAnlegen werden die Ausgänge für die vorhandenen Räume definiert.
• In der Methode willkommenstextAusgeben werden die Ausgänge des
aktuellen Raumes ausgegeben, damit ein Spieler weiß, in welche Richtungen er gehen kann.
• In der Methode wechsleRaum werden die Ausgänge benutzt, um den
nächsten Raum zu finden. Zudem werden dessen Ausgänge mitausgegeben.
Wenn wir nun die beiden neuen Ausgangsrichtungen hinzufügen wollen, dann
müssen wir die entsprechenden Optionen an all diesen Stellen einbauen, was
sehr mühsam und natürlich fehleranfällig ist.
Der Umstand, dass alle Ausgänge an so vielen Stellen im Quelltext vorkommen, ist symptomatisch für einen schlechten Klassenentwurf. Wir haben
für jeden Ausgang eine Variable deklariert, so dass wir in den Methoden
willkommenstextAusgeben und wechsleRaum für jeden Ausgang eine bzw.
zwei If-Anweisungen haben, die bei zusätzlichen Ausgängen jeweils eingefügt
werden müssen. Zudem können wir uns — neben dem großen Aufwand —
auch nicht sicher sein, alle Stellen im Quelltext gefunden zu haben.
Deshalb wollen wir nun statt einzelner Variablen eine HashMap zur Speicherung der Ausgänge verwenden. Auf diese Weise wollen wir Quelltext schreiben, der mit einer beliebigen Anzahl von Ausgängen zurechtkommt. Die
HashMap besteht aus der Richtung (kodiert als Zeichenkette) als Schlüssel
und einem Raum-Objekt als Wert.
Wenn wir nun die Datenfelder der Klasse Raum entfernen und durch eine
HashMap ersetzen, lässt sich die Klasse Spiel nicht mehr ersetzen, da diese
an vielen Stellen auf die Datenfelder der Ausgänge Bezug nimmt.
Idealerweise sollten andere Klassen nicht durch eine Änderung der Implementierung betroffen sein, dann hätten wir eine lose Kopplung. In unserem
54
Beispiel ist dies nicht der Fall, so dass wir vor der Einführung der HashMap
die beiden Klassen entkoppeln wollen.
Eines der Hauptprobleme in diesem Beispiel ist die Verwendung öffentlicher
Datenfelder (sie sind in der Klasse Raum als public deklariert). Deshalb kann
die Klasse Spiel direkt auf sie zugreifen und tut dies auch ausführlich. Durch
die öffentlich verfügbaren Datenfelder legt die Klasse Raum offen, wie die
Implementierung realisiert ist. Dies widerspricht dem Prinzip der Kapselung
der Daten in Klassen. Dieses Prinzip fordert, dass die Daten einer Klasse
vor dem direkten Zugriff anderer Klassen abgekapselt sind und man auf die
Daten nur über Methoden zugreifen darf, die die Implementierung der Daten
verbergen.
Es soll nach außen also nur sichtbar werden, was eine Klasse leistet, aber
nicht wie dies realisert (implementiert) ist. Die einfachste Möglichkeit, um
Kapselung zu erzwingen, ist die generelle Deklarierung der Datenfelder einer
Klasse als private, wobei der Zugriff über eine Methode geschieht.
Somit könnte die Klasse Raum wie folgt aussehen:
class Raum
{
private
private
private
private
private
String beschreibung;
Raum nordausgang;
Raum suedausgang;
Raum ostausgang;
Raum westausgang;
public Raum gibAusgang(String richtung)
{
if(richtung.equals("north"))
return nordausgang;
if(richtung.equals("east"))
return ostausgang;
if(richtung.equals("south"))
return suedausgang;
if(richtung.equals("west"))
return westausgang;
return null;
}
}
55
Gemäß dieser Änderung sind nun in der Klasse Spiel alle Stellen anzupassen,
die direkt auf die Datenfelder zugegriffen haben. Wir schreiben beispielsweise
anstatt
Raum naechsterRaum = null;
if(richtung.equals("north"))
naechsterRaum = aktuellerRaum.nordausgang;
if(richtung.equals("east"))
naechsterRaum = aktuellerRaum.ostausgang;
if(richtung.equals("south"))
naechsterRaum = aktuellerRaum.suedausgang;
if(richtung.equals("west"))
naechsterRaum = aktuellerRaum.westausgang;
nun
Raum naechsterRaum = aktuellerRaum.gibAusgang(richtung);
und anstatt
System.out.print("Ausgänge: ");
if(aktuellerRaum.nordausgang != null)
System.out.print("north ");
if(aktuellerRaum.ostausgang != null)
System.out.print("east ");
if(aktuellerRaum.suedausgang != null)
System.out.print("south ");
if(aktuellerRaum.westausgang != null)
System.out.print("west ");
System.out.println();
führen wir in der Klasse Raum eine neue Methode ein, die die Infos über
die Ausgänge als Zeichenkette zurückliefert, die in der Klasse Spiel dann
ausgegeben werden:
System.out.println(aktuellerRaum.gibAusgaengeAlsString();
wobei diese Methode wie folgt aussehen könnte:
56
public String gibAusgaengeAlsString()
{
String ergebnis = "Ausgänge:";
if(gibAusgang("north") != null)
ergebnis += " north";
if(gibAusgang("east") != null)
ergebnis += " east";
if(gibAusgang("south") != null)
ergebnis += " south";
if(gibAusgang("west") != null)
ergebnis += " west";
return ergebnis;
}
Bis jetzt haben wir nur ein bisschen aufgeräumt, jedoch noch nicht die Repräsentation der Ausgänge geändert. Obwohl wir in der Klasse Spiel nicht
viel geändert haben — der direkte Zugriff auf die Datenfelder wurde durch
einen Methodenaufruf ersetzt — ist der Gewinn gewaltig.
Wir können nun leicht die Art der Speicherung von Ausgängen ändern, ohne dass in der Klasse Spiel dadurch etwas nicht mehr funktioniert. Die
Repräsentation der Ausgänge durch eine HashMap ist nun also problemlos
möglich. Der geänderte Quelltext ist im folgenden Listing zu sehen:
class Raum
{
private String beschreibung;
private HashMap ausgaenge;
// die Ausgänge dieses Raums
/**
* Erzeuge einen Raum mit einer Beschreibung. Ein Raum
* hat anfangs keine Ausgänge.
* @param beschreibung enthält eine Beschreibung in der Form
*
"in einer Küche" oder "auf einem Sportplatz".
*/
public Raum(String beschreibung)
{
this.beschreibung = beschreibung;
ausgaenge = new HashMap();
57
}
/**
* Definiere die Ausgänge dieses Raums.
* führt entweder in einen anderen Raum
* (kein Ausgang).
*/
public void setzeAusgaenge(Raum norden,
Raum sueden,
{
if(norden != null)
ausgaenge.put("north", norden);
if(osten != null)
ausgaenge.put("east", osten);
if(sueden != null)
ausgaenge.put("south", sueden);
if(westen != null)
ausgaenge.put("west", westen);
}
Jede Richtung
oder ist ’null’
Raum osten,
Raum westen)
/**
* Liefere den Raum, den wir erreichen, wenn wir aus diesem Raum
* in die angegebene Richtung gehen. Liefere ’null’, wenn in
* dieser Richtung kein Ausgang ist.
* @param richtung die Richtung, in die gegangen werden soll.
*/
public Raum gibAusgang(String richtung)
{
return (Raum)ausgaenge.get(richtung);
}
/**
* Liefere die Beschreibung dieses Raums (die dem Konstruktor
* übergeben wurde).
*/
public String gibBeschreibung()
{
return beschreibung;
}
**
58
* Liefere eine Zeichenkette, die die Ausgänge dieses Raums
* beschreibt, beispielsweise
* "Ausgänge: north west".
*/
public String gibAusgaengeAlsString()
{
// Quelltext siehe oben
}
}
Die ganzen Änderungen haben wir primär durchgeführt, um zusätzliche Ausgänge einfach einführen zu können. Dies ist jetzt in der Tat deutlich einfacher
geworden. Da wir die Ausgänge in einer HashMap speichern, können wir die
zusätzlichen Ausgänge up und down ohne jede Änderung hinzunehmen. Mit
Hilfe der Methode gibAusgang können wir nun auch problemlos Informationen über die Ausgänge bekommen.
Die einzigen Stellen im Quelltext, an der nach wie vor Wissen über die vier
Ausgänge steckt, sind die Methoden setzeAusgaenge und gibAusgaengeAlsString.
Dies ist der letzte Teil, der noch verbessert werden muss. Aktuell sieht die
Signatur der Methode setzeAusgaenge wie folgt aus:
public void setzeAusgaenge(Raum norden, Raum osten,
Raum sueden, Raum westen)
Diese Methode gehört zur Schnittstelle der Klasse Raum, so dass eine Änderung
der Signatur aufgrund der Kopplung auch andere Klassen betrifft. Wir sollten
uns klar machen, dass eine völlige Entkopplung aller Klassen nicht möglich
ist, da sonst keine Interaktionen zwischen den Klassen stattfinden könnte.
Stattdessen versuchen wir den Grad der Kopplung so gering wie möglich zu
halten. Deshalb ersetzen wir diese Methode durch die folgende, um zukünftige
Erweiterungen zu erleichtern:
public void setzeAusgang(String richtung, Raum nachbar)
{
ausgaenge.put(richtung, nachbar);
}
Nun können beliebige Richtungsangaben verwendet werden und die Ausgänge
sind gemäß dem folgenden Beispiel zu definieren:
59
labor.setzeAusgang("north", draussen);
labor.setzeAusgang("east", buero);
labor.setzeAusgang("down", keller);
Ebenso können wir die Methode gibAusgaengeAlsString unabhängig von
der Kenntnis der Richtungen gestalten:
private String gibAusgaengeAlsString()
{
String ergebnis = "Ausgänge:";
Set keys = ausgaenge.keySet();
for(Iterator iter = keys.iterator(); iter.hasNext(); )
ergebnis += " " + iter.next();
return ergebnis;
}
Hierbei liefert die Methode keySet die Menge aller Schlüsselbegriffe einer
HashMap.
Nun könnten wir sogar weitere Richtungen wie northwest, southeast usw.
problemlos verwenden.
5.5
Entwurf nach Zuständigkeiten
Kapselung ist nicht der einzige Faktor, der den Grad der Kopplung beeinflusst. Ein anderer Aspekt wird unter dem Stichwort Entwurf nach Zuständigkeiten gefasst. Die Grundidee ist hier, dass jede Klasse für den Umgang
mit ihren Daten zuständig sein sollte. Wenn man einer Anwendung eine neue
Funktionalität hinzufügt, fragt man sich häufig, welcher Klasse man die Methode mit der neuen Funktionalität zuordnen soll.
Die Antwort lautet: Wenn eine Klasse verantwortlich für bestimmte Daten ist,
dann ist sie auch verantwortlich für den Umgang mit diesen Daten. Dadurch
wird der Grad der Kopplung positiv beeinflusst, so dass Änderungen oder
Erweiterungen leichter möglich sind.
Momentan ist in der Klasse Spiel immer noch fest verdrahtet, dass die Informationen über einen Raum aus einer Raumbeschreibung und einer Beschreibung der Ausgänge bestehen:
60
private void rauminfoAusgeben()
{
System.out.println("Sie sind " + aktuellerRaum.gibBeschreibung());
System.out.println(aktuellerRaum.gibAusgaengeAlsString());
}
Angenommen, wir wollen unseren Räumen Gegenstände hinzufügen, mit denen gewisse Aktionen durchführbar sind. Dann ist es natürlich wichtig, diese
bei der Information über den aktuellen Raum mitauszugeben.
Zunächst würden wir die Methode Raum um eine ArrayList gegenstaende
erweitern, die die Gegenstände eines Raumes aufnimmt.
Wenn wir nun die Rauminfo für den aktuellen Raum ausgeben wollen, dann
müssen wir neben den Änderungen in der Klasse Raum auch den Quelltext in
der Klasse Spiel ändern.
Dieser zusätzliche Aufwand rührt daher, dass wir hier das Prinzip des Entwurfs nach Zuständigkeiten verletzt haben. Da die Klasse Raum die Daten
über Räume verwaltet, sollte diese Klasse auch für die Ausgabe der Informationen über Räume zuständig sein. Wir können dies verbessern, indem wir
der Klasse Raum die folgende Methode hinzufügen:
public String gibLangeBeschreibung()
{
return "Sie sind " + gibBeschreibung() + ".\n" +
gibAusgaengeAlsString();
}
Diese Methode kann dann zukünftig um die Ausgabe der Gegenstände eines
Raumes erweitert werden, ohne dass andere Klassen betroffen sind.
Eine noch schwerwiegendere Auswirkung der Verletzung des Entwurfs nach
Zuständigkeiten sehen wir beim folgenden Beispiel.
Angenommen, wir erweitern die möglichen Befehle um den Befehl look, der
eine Beschreibung des aktuellen Raumes liefert. Dies können wir einfach
machen, indem in der Klasse Befehlswoerter das Array der gültigen Befehlswörter erweitert wird:
// ein konstantes Array mit den gültigen Befehlswörtern
private static final String gueltigeBefehle[] = {
61
"go", "quit", "help", "look"
};
Dies ist übrigens ein gutes Beispiel für hohe Kohäsion. Anstatt die Befehlswörter in der Klasse Parser zu definieren — eine durchaus sinnvolle
Stelle — gibt es eine separate Klasse, die ausschließlich die Befehlswörter
definiert und die Erweiterung des Befehlssatzes problemlos ermöglicht.
Wenn wir diese Änderung testen, sehen wir, dass der Befehl nicht unbekannt
ist — denn dann bekommen wir die Ausgabe Ich weiß nicht, was Sie
meinen ... —, sondern es passiert gar nichts, weil wir noch keine Aktion
für diesen Befehl definiert haben.
Wir müssen also die Methode verarbeiteBefehl in der Klasse Spiel um
eine Aktion erweitern. Dazu fügen wir in der Klasse Spiel die folgende Methode ein
private void umsehen()
{
System.out.println(aktuellerRaum.gibLangeBeschreibung());
}
und modifizieren die Methode verarbeiteBefehl wie folgt:
if (befehlswort.equals("help"))
hilfstextAusgeben();
else if (befehlswort.equals("go"))
wechsleRaum(befehl);
else if (befehlswort.equals("look"))
umsehen();
else if (befehlswort.equals("quit")) {
moechteBeenden = beenden(befehl);
Nun scheint alles zufriedenstellend zu arbeiten. Das Problem wird erst sichtbar, wenn wir den Befehl help eingeben. Die Ausgabe lautet:
...
Ihnen stehen die folgende Befehle zur Verfügung:
go
quit
help
62
Der Hilfstext ist unvollständig. Hir haben wir es mit einer sehr unangenehmen Nebenwirkung des nicht eingehaltenen Entwurfs nach Zuständigkeiten
zu tun, nämlich mit impliziter Kopplung. Die enge Kopplung durch öffentliche
Datenfelder war nicht gut, doch wenigstens war sie aufgrund des Compilerfehlers offensichtlich. Hier liegt nun eine Kopplung vor, die zunächst nicht
sichtbar ist und die wir nur zufällig entdeckt haben.
Gemäß des Entwurfs nach Zuständigkeiten, sollte die Klasse Befehlswoerter
für die Ausgabe der verfügbaren Befehle verantwortlich sein, die dann im
Falle des help-Befehls aufgerufen wird:
public void alleAusgeben()
{
for(int i = 0; i < gueltigeBefehle.length; i++) {
System.out.print(gueltigeBefehle[i] + " ");
}
System.out.println();
}
Die Methode hilfstextAusgeben kann dann wie folgt modifiziert werden:
private void hilfstextAusgeben()
{
System.out.println("Sie haben sich verlaufen. Sie sind allein.");
System.out.println("Sie irren auf dem Unigelände herum.");
System.out.println();
System.out.println("Ihnen stehen folgende Befehle zur Verfügung:");
parser.zeigeBefehle();
}
Die Methode zeigeBefehle im Parser wiederum ruft dann die Methode
alleAusgeben der Klasse Befehlswoerter auf. Dieser Umweg wurde gemacht, um die Kopplung möglichst gering und die Kohäsion möglichst hoch
zu halten. Würde die Methode hilfstextAusgeben direkt die Methode alleAusgeben
aufrufen, dann würde eine zusätzliche Kopplung zwischen den Klassen Spiel
und Befehlswoerter entstehen. Zudem sollte eigentlich nur der Parser wissen, dass die Befehlswörter in einer eigenen Klasse definiert sind und nur der
Parser sollte dann darauf zugreifen.
63
5.6
Programmausführung ohne Bluej
Nachdem wir unseren Entwurf deutlich verbessert haben, können wir nun die
Funktionalität des Spiels viel einfacher erhöhen. Danach wollen wir vielleicht
unser Spiel an Andere zum Testen weitergeben. Dazu wäre es schön, wenn
man das Spiel auch ohne Bluej starten könnte. Um dies tun zu können, lernen
wir nun ein weiteres Java-Konzept kennen, nämlich Klassenmethoden oder
statische Methoden. Zuvor jedoch betrachten wir ein ähnliches Konzept, die
sogenannten Klassenvariablen.
5.6.1
Klassenvariablen
Angenommen wir schreiben eine Klasse Rechnung, die für Einzelhandelsgeschäfte Rechnungen erzeugt und verschickt. Um die Rechnung sinnvoll erzeugen zu können, muss die Mehrwertsteuer explizit ausgewiesen werden.
Legt man nun ein Datenfeld float mehrwertsteuer an und initialisiert dieses im Konstruktor mit dem derzeit gültigen Wert von 16.0%, so wird bei
der Generierung jeder Rechnung (d.h. bei der Erzeugung von Instanzen zu
Rechnung) jedesmal ein Objekt mit den Datenfeld mehrwertsteuer erzeugt
und mit dem Wert 16.0 initialisiert, obwohl dieser Wert für alle Rechnungen
gilt. Um diesen unnützen Overhead zu vermeiden, gibt es die sogenannten
Klassenvariablen, die in der Klasse selbst und nicht in den Objekten dieser
Klasse gespeichert werden.
In Java wird dies durch das Schlüsselwort static vor der Variablendeklaration ausgedrückt, z.B.:
private static float mehrwertsteuer;
Als Konsequenz gibt es immer nur genau eine Kopie dieser Variablen, unabhängig von der Anzahl der Instanzen. Im Quelltext kann jedoch genauso
wie bei Instanzvariablen auf die Klassenvariablen zugegriffen werden.
Eine Methode aendereMehrwertsteuer könnte wie folgt aussehen:
public void aendereMehrwertsteuer( float neueMwst)
{
mehrwertsteuer = neueMwst;
}
64
Im Gegensatz zu einer Modifikation einer Instanzvariablen, die nur den Variablenwert der aufrufenden Instanz ändert, bewirkt obiger Aufruf, dass die
Mehrwertsteuer für alle Instanzen von Rechnung modifiziert wird.
Klassenvariablen werden häufig in Verbindung mit Konstanten verwendet.
Angenommen wir schreiben eine Klasse, die den Wert für die Lichtgeschwindigkeit verwendet. Dieser Wert muss natürlich nicht in jeder Instanz abgelegt
werden, sondern sollte als Klassenvariable deklariert werden. Zudem ändert
sich dieser Wert — im Gegensatz zur Mehrwertsteuer — aber nicht. Um eine
versehentliche Modifikation auszuschließen, können wir diese Klassenvariable auch als unveränderbare Konstante mit Hilfe des Schlüsselworts final
definieren:
private static final int lichtgeschwindigkeit = 299792458;
Natürlich können auch Instanzvariablen als Konstanten deklariert werden,
wenn Instanzen unterschiedliche aber nach der Initialisierung nicht mehr
änderbare Werte haben sollen, wie z.B. Augenfarbe. Konstanten müssen unmittelbar bei ihrer Deklaration oder direkt im Konstruktor initialisiert werden.
5.6.2
Klassenmethoden
Alle bisher betrachteten Methoden waren Instanzmethoden, d.h. sie werden
an einer Instanz einer Klasse aufgerufen. Im Unterschied dazu werden Klassenmethoden direkt von der Klasse aufgerufen, ohne eine Instanz zu erzeugen.
Eine Klassenmethode wird definiert, indem das Schlüsselwort static vor
dem Typnamen in einer Methodensignatur gestellt wird, z.B.
public static int gibAnzahlErzeugterInstanzen()
{
...
}
Solch eine Methode wird aufgerufen, indem in der üblichen Punktschreibweise
dem Punkt der Name der Klasse und nicht der Name einer Instanzvariablen
vorangestellt wird. Gibt es beispielsweise die obige Methode in der Klasse
Gegenstaende so können Instanzen zu anderen Klassen, die Anzahl erzeugter
Gegenstände wie folgt abfragen:
65
anzahlGegenstaende = Gegenstaende.gibAnzahlErzeugterInstanzen();
Obige Methode kann natürlich auch aufgerufen werden, falls noch keine Instanz der Klasse Gegenstaende generiert wurde.
Wenn wir nun unser Spiel ohne Bluej starten wollen, benötigen wir eine
Klassenmethode, die ein Objekt von Spiel generiert und für dieses Objekt
die Methode spielen aufruft.
5.6.3
Die Methode main
Um das Ei-Henne-Problem zum Programmstart zu lösen — um einen Konstruktor zur Generierung eines Objekts aufzurufen benötige ich bereits ein
Objekt — gibt es die Klassenmethode main in der Startklasse.
Der Name dieser Startklasse wird als Parameter dem Startkommando übergeben,
z.B. java Spielstart. Java sucht in dieser Klasse nach einer Methode mit
folgender Signatur
public static void main(String[] args)
und ruft diese auf.
Der Parameter args ist ein Array von Zeichenketten, mit Hilfe dessen zusätzliche
Parameter beim Programmstart mitgegeben werden können.
Um unser Spiel zu starten, könnte diese Methode wie folgt aussehen:
public static void main(String[] args)
{
Spiel spiel = new Spiel();
spiel.spielen();
}
Im Prinzip kann die Methode main noch beliebig viele Anweisungen enthalten, aber es ist guter Stil, dass hier nur eine Instanz einer Klasse generiert
wird und dafür dann eine Methode aufgerufen wird, die den Programmablauf
in Gang setzt.
66
5.6.4
Einschränkungen für Klassenmethoden
Weil Klassenmethoden nur für eine Klasse und nicht für deren Instanzen
aufgerufen werden, gibt es die beiden folgenden Einschränkungen:
• Klassenmethoden können nicht auf Instanzvariablen der eigenen Klasse
zugreifen, sondern nur auf deren Klassenvariablen. (es wäre ja gar nicht
klar auf welche Instanz sich der Variablenzugriff bezieht)
• Klassenmethoden können keine Instanzmethoden der eigenen Klasse
aufrufen, sondern nur andere Klassenmethoden.
5.7
Zusammenfassung
• Der Begriff Kopplung beschreibt den Grad der Abhängigkeiten zwischen Klassen. Wir streben für unsere Programme eine möglichst lose
Kopplung an, d.h. jede Klasse ist weitgehend unabhängig und kommuniziert mit anderen Klassen nur über eine möglichst schmale, wohl
definierte Schnittstelle.
• Der Begriff Kohäsion beschreibt, wie gut ein Programmteil eine logische Aufgabe oder funktionale Einheit abbildet. In einer Anwendung
mit hoher Kohäsion ist jede Programmeinheit (eine Methode, eine Klasse, Gruppe von Klassen) verantwortlich für genau eine wohl definierte
Aufgabe. Eine Methode sollte genau eine logische Operation implementieren und eine Klasse sollte genau einen Typ von Objekt modellieren.
• Code-Duplizierung (ein Quelltextbschnitt erscheint mehr als einmal
in einer Anwendung) ist ein Indiz für einen schlechten Entwurf. Er sollte
unbedingt vermieden werden.
• Das Prinzip der Kapselung fordert, dass die Daten einer Klasse vor
dem direkten Zugriff anderer Klassen abgekapselt sind und man auf die
Daten nur über Methoden zugreifen darf, die die Implementierung der
Daten verbergen.
• Entwurf nach Zuständigkeiten ist ein Entwurfsprozess, bei dem jeder Klasse eine klare Verantwortung zugewiesen wird. Dieser Prozess
kann genutzt werden, um festzulegen, welche Klasse für welche Funktion in einer Anwendung zuständig sein soll.
67
• Auch Klassen können Datenfelder haben. Diese werden Klassenvariablen oder auch statische Variablen genannt. Von einer Klassenvariablen existiert immer genau eine Kopie, unabhängig von der Anzahl der
erzeugten Instanzen.
• Klassenmethoden werden im Gegensatz zu Instanzmethoden nicht
von einem Objekt, sondern von der zugehörigen Klasse aufgerufen.
• Mit Hilfe der speziellen Klassenmethode main kann eine Java-Applikation
auch ohne BlueJ gestartet werden.
68
6
Bessere Struktur durch Vererbung
Vererbung ist ein mächtiges Konstrukt, mit dem Lösungen zu einer Vielfalt von Problemen formuliert werden können. Wie immer werden wir diese
wichtigen Aspekte anhand eines Beispiels diskutieren.
6.1
Beispiel: Database of Multimedia Entertainment
(DoME)
Dieser hochklingende Name steht für ein im Prinzip recht simples Prgramm.
Mit Hilfe von DoME wollen wir Informationen über CDs und Videos speichern. Die Idee ist, einen Katalog aller CDs und Videos anzulegen, die ich
besitze (oder die ich bereits gesehen oder gehört habe).
Die Funktionalität sollte mindestens das Folgende umfassen:
• Informationen über CDs und Videos sollten erfassbar sein
• Permanente Speicherung für spätere Verwendung
• Suchfunktionen für z.B. die Suche aller CDs einer bestimmten Künstlerin
oder die Filme eines bestimmten Regisseurs
• Ausgabe der Liste aller CDs und Videos
• Löschung von Informationen soll möglich sein
Die folgenden Details einer CD wollen wir erfassen:
• den Titel des Albums
• Künstlername (oder den Bandnamen)
• Anzahl der Titel auf der CD
• die Gesamtspielzeit
• eine Markierung ‘habIch’, die anzeigt, ob ich diese CD besitze
• einen Kommentar (beliebiger Text)
Die folgenden Details sollten für ein Video erfasst werden:
69
• den Titel des Videos
• Name des Regisseurs bzw. der Regisseurin
• die Gesamtspielzeit
• eine Markierung ‘habIch’, die anzeigt, ob ich dieses Video besitze
• einen Kommentar (beliebiger Text)
6.1.1
Die Klassen und Objekte in DoME
Der Klassenentwurf ist zunächst relativ einfach. Es ist naheliegend jeweils
eine Klasse CD und Video zu definieren. Die Objekte dieser Klassen stehen
dann für eine zu speichernde CD oder ein zu speicherndes Video.
Zudem brauchen wir noch eine Klasse, die unsere CD- und Videosammlung
speichert, die Klasse Datenbank. Ein Objekt dieser Klasse hält die Sammlung
der CDs und Videos in einer ArrayList (siehe folgende Abbildung).
In der Praxis würden wir für eine vollständige Multimediadatenbank noch
weitere Klassen benötigen, die sich um die Datenspeicherung und um die
Benutzerschnittstelle kümmern müssten. Da diese für die aktuelle Diskussion
nicht wichtig sind, kommen wir erst später darauf zu sprechen.
70
6.1.2
Der Quelltext von DoME
Die Umsetzung unseres Entwurfs in Java-Quelltext ist recht einfach. Öffnen
Sie das Projekt DoME-V1 unter Kapitel08 und sehen Sie sich den Quelltext
der drei Klassen an.
Obwohl unsere Anwendung nicht vollständig ist, haben wir den wichtigsten
Teil vorliegen, nämlich die Datenstruktur, die alle wichtigen Informationen
enthält. Jedoch gibt es einige fundamentale Probleme mit der vorliegenden
Lösung. Das offensichtlichste ist die Code-Duplizierung.
Da die Klassen CD und Video sehr ähnlich sind, sind auch die Quelltexte
bis auf wenige Ausnahmen identisch. Im letzten Kapitel haben wir den nachteil der Code-Duplizierung besprochen, nämlich die schwierigere Wartbarkeit
solcher Programme.
Aber auch in der Klasse Datenbank wird alles doppelt gemacht, einmal für
CDs und einmal für Videos.
Die Probleme mit dieser Duplizierung werden offensichtlich, wenn wir untersuchen, welche Änderungen wir vornehmen müssen, um ein weiteres Medium
aufzunehmen wie z.B. Bücher.
Wir würden eine Klasse Buch anlegen, die den Klassen CD und Video extrem
ähnlich wäre und wir müssten die Klasse Datenbank um eine Listenvariable
buecher erweitern und die ganze Verwaltung entsprechend duplizieren.
Je mehr neue Medien wir in unserer Datenbank speichern wollen, desto größer
wird das Problem der Code-Duplizierung bei Änderungen.
Um dieses Problem zu vermeiden bieten objektorientierte Programmiersprachen das Konzept der Vererbung an. Die Vererbung erlaubt uns, eine Klasse
als Erweiterung einer anderen zu definieren.
6.2
Einsatz von Vererbung
Statt die beiden Klassen CD und Video unabhängig voneinander zu definieren, erstellen wir zunächst eine Klasse, die die Gemeinsamkeiten der beiden
Klassen CD und Video zusammenfasst.
Wir nennen diese Klasse Medium und definieren dann, dass eine CD ein Medium
und ein Video ebenfalls ein Medium ist.
71
Abschließend definieren wir die spezifischen Eigenschaften einer CD bzw.
eines Videos in den entsprechenden Klassen CD und Video.
Der grundlegende Vorteil ist, dass wir gemeinsame Eigenschaften nur einmal
beschreiben müssen.
Die folgende Abbildung zeigt ein Klassendiagramm mit der neuen Struktur.
Es zeigt die Klasse Medium, die alle Datenfelder und Methoden definiert, die
CDs und Videos gemein sind. Unterhalb sind die Klassen CD und Video zu
sehen, die nur diejenigen Datenfelder und Methoden enthalten, die spezifisch
für die jeweiligen Medien sind. Im Klassendiagramm wird diese Beziehung
meist mit einem Pfeil ohne gefüllte Spitze dargestellt.
Wir sagen: Die Klasse CD erbt von der Klasse Medium oder die Klasse CD erweitert die Klasse Medium. Umgekehrt wird die Klasse Medium als Superklasse
72
oder Oberklasse von CD genannt. Die erbende Klasse CD wird als Subklasse
oder Unterklasse bezeichnet.
Die Vererbungsbeziehung wird manchmal auch als ist-ein-Beziehung bezeichnet, weil eine Subklasse als eine Spezialisierung ihrer Oberklasse angesehen
werden kann. Wir können also sagen: Eine CD ist ein Medium.
Der Vorteil von Vererbung ist offensichtlich. Die Instanzen der Klasse CD
haben alle Datenfelder und Methoden, die in der Klasse Medium definiert
sind und müssen für die Klasse Video nicht nochmals geschrieben werden.
Vererbung erlaubt uns somit, Klassen mit großen Ähnlichkeiten zu definieren,
ohne dass wir die gemeinsamen Teile mehrfach formulieren müssen.
6.3
Vererbungshierarchien
Üblicherweise wird Vererbung viel allgemeiner eingesetzt: Es können beliebig viele Klassen von einer Superklasse erben. Eine Subklasse kann wiederum
selbst Superklasse für andere Klassen sein. Man spricht dann von einer Vererbungshierarchie.
Das bekannteste Beispiel ist vermutlich die Taxonomie der Arten in der Biologie. Ein kleiner Ausschnitt ist in der folgenden Abbildung zu sehen:
73
Eine Instanz der Klasse Pudel (also ein spezifischer Pudel) besitzt alle Eigenschaften eines Pudels, eines Hundes, eines Säugetiers und eines Tiers, denn
ein Pudel ist ein Tier, ein Säugetier und ein Hund.
Vererbung ist also ein Abstraktionsmittel, mit dem wir Klassen nach bestimmten Kriterien kategorisieren und die Charakteristika dieser Klassen
festlegen können.
6.4
Vererbung in Java
Bevor wir uns die Mechanismen der Vererbung näher ansehen, wollen wir
zunächst kurz betrachten wie Vererbung in Java ausgedrückt wird.
Während die Definition der Superklasse Medium wie gewohnt aussieht,
public class Medium
{
private String titel;
private int spielzeit;
private boolean habIch;
private String kommentar;
// Konstruktor und Methoden hier ausgelassen
}
sieht die Definition der Subklasse CD wie folgt aus:
public class CD extends Medium
{
private String kuenstler;
private int titelanzahl;
// Konstruktor und Methoden hier ausgelassen
}
Das Schlüsselwort extends gibt an, dass die aktuell definierte Klasse CD
eine Subklasse der Klasse Medium ist. Außerdem definiert die Klasse CD nur
die Datenfelder, die spezifisch für CD-Objekte sind, nämlich kuenstler und
titelanzahl. Die Datenfelder titel, spielzeit, habIch und kommentar
74
werden aus der Klasse Medium geerbt und brauchen nicht mehr aufgeführt
zu werden.
Analog sieht die definition der Klasse Video aus, die nur das spezifische
Datenfeld regisseur definiert und die anderen Datenfelder aus der Klasse
Medium erbt.
public class Video extends Medium
{
private String regisseur;
// Konstruktor und Methoden hier ausgelassen
}
6.4.1
Vererbung und Zugriffsrechte
Bezüglich der Zugriffsrechte unterscheiden sich Super- bzw. Subklassen nicht
von anderen Klassen, d.h.
• mit private deklarierte Datenfelder einer Superklasse können nicht
von der Subklasse direkt genutzt werden, sondern der Zugriff kann nur
über Methoden erfolgen
• jedoch kann eine Subklasse — im Gegensatz zu anderen Klassen —
alle öffentlichen Methoden der Superklasse aufrufen als wären es ihre
eigenen Methoden (es ist also kein Objekt der Superklasse notwendig)
• werden Datenfelder einer Superklasse als public definiert, können zwar
die Subklassen direkt darauf zugreifen, gleichzeitig ist dies jedoch auch
für alle anderen Klassen möglich, so dass wir dies vermeiden werden
• wir werden später eine Möglichkeit kennenlernen, die einen Mittelweg
zwischen private und public definierten Datenfeldern für Subklassen
ermöglicht
6.4.2
Vererbung und Initialisierung
Wir wollen uns nun ansehen, wie die Initialisierung der geerbten Datenfelder
erfolgt.
75
Wenn wir beispielsweise ein CD-Objekt erzeugen, dann übergeben wir mehrere Parameter an den Konstruktor von CD: den Titel, den Künstlernamen,
die Anzahl der Titel und die Spielzeit. Der Titel und die Spielzeit sind für
Datenfelder bestimmt, die in der Superklasse Medium definiert sind. Wie dies
in Java geschieht zeigt das folgende Beispiel:
public class Medium
{
private String titel;
private int spielzeit;
private boolean habIch;
private String kommentar;
/**
* Initialisiere die Datenfelder dieses Mediums.
*/
public Medium(String derTitel, int laenge)
{
titel = derTitel;
spielzeit = laenge;
habIch = false;
kommentar = "";
}
// Methoden hier ausgelassen
}
public class CD extends Medium
{
private String kuenstler;
private int titelanzahl;
/**
* Konstruktor für Objekte der Klasse CD
*/
public CD(String derTitel, String derKuenstler, int stuecke, int laenge)
{
super(derTitel, laenge);
kuenstler = derKuenstler;
titelanzahl = stuecke;
}
76
// Methoden hier ausgelassen
}
Obwohl wir eigentlich keine Objekte der Klasse Medium erzeugen wollen, hat
diese Klasse einen Konstruktor1 . Dieser Konstruktor erhält die Parameter,
die für die Initialisierung der Datenfelder in Medium benötigt werden.
Außerdem bekommt der Konstruktor von CD alle Parameter, die für die Initialisierung der Datenfelder — einschließlich der geerbten — der beiden
Klassen CD und Medium erforderlich sind. Innerhalb des Konstruktors von
CD werden die Parameter für die geerbten Datenfelder mittels des Aufrufs
super(derTitel, laenge) an den Konstruktor der Superklasse übergeben,
der dann die passende Initialisierung vornimmt.
In Java muss im Konstruktor der Subklasse immer als erste Anweisung der
Konstruktor der Superklasse mittels des Kommandos super aufgerufen werden. Wenn man nicht explizit einen solchen Aufruf in den Quelltext schreibt,
dann fügt der Compiler automatisch eine super-Anweisung ohne Parameter
ein. Dies funktioniert jedoch nur dann, falls die Superklasse einen Konstruktor ohne Parameter besitzt. Im Sinne eines guten Programmierstils verzichten wir jedoch auf diese Möglichkeit und geben immer eine explizite superAnweisung an.
6.5
Weitere Medien für DoME
Durch die Einführung der Vererbungshierarchie ist es nun viel einfacher einen
neuen Typ von Medium einzuführen.
Wenn wir beispielsweise Informationen über Videospiele speichern wollen,
dann können wir die Klasse Videospiel als Subklasse von Medium einführen:
1
Momentan hält uns nichts davon ab, tatsächlich eine Instanz von Medium zu erzeugen.
Später werden wir sehen, wie wir dies verhindern können.
77
Da Videospiel eine Subklasse von Medium ist, erbt sie alle Datenfelder und
Methoden, die in Medium definiert sind. Wir können uns also auf die Datenfelder konzentrieren, die für Videospiele spezifisch sind.
Dies ist ein Beispiel dafür, wie wir mit Vererbung bereits geleistete Arbeit
wiederverwenden können. Wir können nämlich den Quelltext, den wir für
die Klassen CD und Video in der Klasse Medium geschrieben haben, so wiederverwenden, dass er auch in der Klasse Videospiel funktioniert. Diese
Möglichkeit der direkten Wiederverwendbarkeit bestehender Softwarekomponenten ist einer der wichtigsten Vorteile von Vererbung. Später werden
wir noch weitere kennenlernen.
Nehmen wir an, dass wir nun auch Brettspiele in unsere Datenbank aufnehmen wollen. Wir könnten dazu eine vierte Subklasse Brettspiel definieren,
wollen uns zuvor jedoch die Vererbungsbeziehung nochmals genauer betrachten. Sowohl Videospiele als auch Brettspiele haben ein Attribut ‘maximale
Anzahl an Spielern’. Es wäre schön, wenn wir dieses Datenfeld nicht in beiden
Klassen definieren müssten. Die Modellierung von Brettspiel als Subklasse
von Videospiel hat den Nachteil, dass das Datenfeld plattform von der
Klasse Brettspiel geerbt wird, obwohl es nur für Videospiele Sinn macht.
Die Lösung ist ein sogenanntes Refactoring2 der Vererbungshierarchie. Wir
führen eine neue Superklasse Spiel ein, die das Datenfeld Spieleranzahl
2
Modifikation des Klassenentwurfs, um die Qualität desselbigen zu erhöhen
78
enthält und definieren Videospiel und Brettspiel als Subklassen hiervon:
6.6
Subtyping
Bisher haben wir noch nicht untersucht, inwieweit sich unsere Umstellung
auf Vererbung auf den Quelltext der Klasse Datenbank ausgewirkt hat. Das
folgende Listing zeigt die dementsprechend modifizierte Version:
import java.util.ArrayList;
import java.util.Iterator;
/**
* Die Klasse Datenbank bieten Möglichkeiten zum Speichern
* von CD- und Video-Objekten. Eine Liste aller CDs und Videos
* kann auf der Konsole ausgegeben werden.
*
* Diese Version speichert die Daten nicht im Dateisystem und
* bietet keine Suchfunktion.
79
*
* @author Michael Kolling and David J. Barnes
* @version 2003-03-31
*/
public class Datenbank
{
private ArrayList medien;
/**
* Erzeuge eine leere Datenbank.
*/
public Datenbank()
{
medien = new ArrayList();
}
/**
* Erfasse das gegebene Medium in dieser Datenbank.
*/
public void erfasseMedium(Medium dasMedium)
{
medien.add(dasMedium);
}
/**
* Gib eine Liste aller aktuell gespeicherten CDs und
* Videos auf der Konsole aus.
*/
public void auflisten()
{
for(Iterator iter = medien.iterator(); iter.hasNext(); )
{
Medium medium = (Medium)iter.next();
medium.ausgeben();
}
}
}
Wir sehen, dass der Quelltext durch die Umstellung auf die Vererbung deutlich kürzer und einfacher geworden ist. Wir konnten dies deshalb tun, weil
wir in der neuen Version durchweg den Typ Medium benutzen, wo wir vorher
80
CD und Video benutzten.
Sehen wir uns zunächst die Methode erfasseMedium genauer an. In der alten
Version gab es hierfür die beiden folgenden Methoden:
public void erfasseCD(CD dieCD)
public void erfasseVideo(Video dasVideo)
Nun haben wir für denselben Zweck nur noch die Methode
public void erfasseMedium(Medium dasMedium)
Die Parameter waren in der ursprünglichen Version durch die Typen CD und
Video definiert; dadurch wurde sichergestellt, dass nur CDs und Videos eingefügt werden können.
Bisher sind wir davon ausgegangen, dass die Typen der aktuellen Parameter mit den Typen der formalen Parameter identisch sein müssen. Um die
Vererbung voll nutzen zu können, wird dieses Prinzip aufgeweicht:
Objekte von Subtypen können an allen Stellen verwendet werden,
an denen ein Supertyp erwartet wird. Hierbei ist ein Objekttyp A
Subtyp von B, falls die Klasse A Subklasse von B ist. Umgekehrt ist
der Objekttyp B Supertyp von A.
Wir nennen dieses Prinzip Ersetzbarkeit.
Deshalb kann beim Aufruf der Methode erfasseMedium auch ein Objekt vom
Typ CD oder Video übergeben werden. Folgende Sequenz ist daher zulässig:
Datenbank db = new Datenbank();
CD cd = new CD(...);
db.erfasseMedium(cd);
Analog sind folgende Variablenzuweisungen möglich:
Medium med1 = new Medium();
Medium med1 = new CD();
Medium med1 = new Spiel();
Spiel med1 = new Brettspiel();
Medium med1 = new Brettspiel();
81
Jedoch ist die Anweisung
CD cd1 = new Medium();
unzulässig.
Wir können nun auch viel einfacher alle Medien ausgeben:
public void auflisten()
{
for(Iterator iter = medien.iterator(); iter.hasNext(); )
{
Medium medium = (Medium)iter.next();
medium.ausgeben();
}
}
Wir können mittels Vererbung also nicht nur den Programmcode in den
Klassen der Vererbungshierarchie vereinfachen, sondern auch in den Klassen,
die solche Klassen verwenden.
Variablen für Objekttypen sind in Java polymorphe Variablen. Der Ausdruck
polymorph (vielgestaltig) bezieht sich auf den Umstand, dass eine Variable
Objekte von verschiedenen Typen (dem deklarierten Typ der Variable und
den zugehörigen Subtypen) halten kann. Polymorphie tritt in objektorientierten Sprachen in unterschiedlichen Formen auf. Weitere werden wir im
nächsten Kapitel besprechen.
6.7
Die Klasse Object
Im Gegensatz zum bisherigen Anschein besitzen alle Klassen eine Superklasse. Diejenigen Klassen, die nicht explizit per extends-Kommando als Subklasse definiert sind, sind automatisch Subklasse der Klasse Object. Eine
Deklaration
public class Person
{ ... }
ist äquivalent zu
public class Person extends Object
{ ... }
82
Jede Klasse (mit Ausnahme der Klasse Object) erbt also von Object entweder direkt oder indirekt.
Eine gemeinsame Superklasse für alle Klassen dient zwei Zwecken:
• Es können polymorphe Variablen vom Typ Object deklariert werden,
die jedes beliebige Objekt halten können.
• In der Klasse Object können Methoden definiert werden, die jede Klasse anbietet.
Während wir den ersten Aspekt im folgenden Abschnitt diskutieren wollen,
werden wir den zweiten Aspekt im nächsten Kapitel näher beleuchten.
6.8
Polymorphe Sammlungen
Im Laufe dieser Vorlesung haben wir bereits einige Typen aus der Sammlungsbibliothek kennengelernt, unter anderem die Klassen ArrayList, HashMap
und HashSet. Wir haben sie bisher benutzt, ohne die Details verstanden zu
haben. Nachdem wir nun das Konzept der Vererbung kennen, können wir
dies nun nachholen.
Die Java-Sammlungen sind polymorphe Sammlungen, d.h. sie können verschiedene Arten von Elementen gleichzeitig halten. Wir können beispielsweise
eine Liste anlegen, in die sowohl Zeichenketten als auch Medien eingetragen
sind (auch wenn dies meist nicht sinnvoll ist). Wichtiger ist, dass wir durch
die eine Klasse ArrayList sowohl eine Liste von Zeichenketten als auch eine
Liste von Medien definieren können.
Beispielhaft können wir dies an der Methode add sehen, deren Signatur lautet:
public void add(Object element)
Da Object die Superklasse aller anderen Klassen ist, können somit Objekte
beliebiger Klassen in die Liste eingetragen werden.
Dieser große Vorteil hat jedoch auch einen kleinen Nachteil. Wenn ein Element aus der Liste herausgeholt wird, kennt das System seinen Typ nicht.
Wenn wir beispielsweise eine ArrayList meineListe mit Zeichenketten befüllen,
dann führt die folgende Anweisung zu einer Fehlermeldung
83
String s1 = meineListe.get(1);
// Fehler !!!!!!
da die Signatur der methode get wie folgt lautet:
public Object get(int index)
Im Abschnitt 6.6 haben wir besprochen, dass ein Objekt eines Supertyps
nicht einer Variablen eines Subtyps zugewiesen werden darf (nur das Umgekehrte ist erlaubt).
Da wir in unsere Liste nur Zeichenketten eingefügt haben, wissen wir zwar,
dass das zurückgelieferte Element den Typ String besitzt und somit die
Anweisung korrekt ist, aber der Java-Compiler weiß dies zum Zeitpunkt der
Übersetzung nicht. Deshalb müssen wir ihm einen expliziten Hinweis geben,
dass die Anweisung in Ordnung ist. Dazu benutzen wir den aus früheren
Kapiteln bekannten Cast-Operator:
String s1 = (String) meineListe.get(1);
// o.k. !!!!!!
Damit ist der Compiler zufriedengestellt und meldet keinen Fehler. Zur Laufzeit jedoch überprüft Java, ob das Element wirklich vom Typ String ist.
Wenn dann der Typ des Elements mit dem Index 1 jedoch keinen passenden
Typ hat, dann meldet das Laufzeitsystem den Fehler3 ClassCastException
und der Programmlauf wird gestoppt.
Analog können wir den cast-Operator auch bei sonstigen Anweisungen benutzen: Sei Auto eine Subklasse von Fahrzeug, dann ist der folgende Quelltext
sowohl übersetzbar und ausführbar:
Fahrzeug fahrz1;
Auto auto1;
Fahrrad rad1;
auto1 = new Auto();
fahrz1 = auto1;
auto1 = (Auto) fahrz1;
Dagegen schlagen im folgenden Beispiel die letzten beiden Anweisungen fehl,
falls Fahrrad zwar eine Subklasse von Fahrzeug, aber nicht von Auto ist:
3
Exceptions werden in einem späteren Kapitel ausführlich behandelt.
84
Fahrzeug fahrz1;
Auto auto1;
auto1 = new Auto();
fahrz1 = auto1;
rad1 = (Fahrrad) auto1;
rad1 = (Fahrrad) fahrz1;
6.9
// Fehler bei der Übersetzung
// Fehler zur Laufzeit
Wrapperklassen
Da der Elementtyp aller Sammlungen Object ist, können wir jeden Objekttyp in eine Sammlung einfügen. Es gibt in Java jedoch auch primitive Typen
wie beispielsweise int oder char. Da diese nicht von der Klasse Object abgeleitet sind, können Elemente von primitiven Datentypen nicht in Sammlungen eingefügt werden. Jedoch benötigt man oftmals Listen von z.B. IntegerWerten. Die Frage ist nun, wie können wir dieses Problem lösen?
Die Lösung in Java sind die sogenannten Wrapper-Klassen. Für jeden primitiven Datentyp gibt es eine korrespondierende Wrapper-Klasse, die den
zugehörigen primitiven Datentyp repräsentiert, jedoch ein echter Objekttyp
ist.
Die folgende Liste zeigt alle primitiven Datentypen in Java mit den zugehörigen Wrapper-Klassen:
Typname
byte
short
int
long
float
double
char
boolean
Beschreibung
Beispiele Wrapper-Klasse
ganze Zahl in 8 Bit
24 -2 Byte
ganze Zahl in 16 Bit
137 -1115 Short
ganze Zahl in 32 Bit
188397 -98115 Integer
ganze Zahl in 64 Bit
6284967296 -577L Long
Fließkommazahl in 32 Bit
43.866F Float
Fließkommazahl in 64 Bit
43.866 2.4e5 Double
einzelnes Zeichen (16 Bit)
’m’ ’ ?’ ’\u00F6’ Character
boolscher Wert
true false Boolean
Hinweise:
• Eine Zahl ohne Dezimalpunkt wird üblicherweise als int aufgefasst,
aber automatisch konvertiert, wenn sie einer Variablen vom Typ byte,
short oder long zugewiesen wird. Eine Konstante wird als long deklariert, falls man ein ’l’ oder ein ’L’ anhägt. Da das kleine ’l’ leicht mit
85
einer ’1’ verwechselt werden kann, sollte man ’L’ bevorzugen.
• Eine Zahl mit einem Dezimalpunkt ist vom Typ double. Man kann eine
float-Konstante definieren, indem man ein ’f’ oder ein ’F’ anhängt.
• ein Zeichen kann als Unicode-Zeichen in einfachen Anführungsstrichen
oder als Unicode-Wert mit einem vorangestellten \u angegeben werden.
Wenn wir nun also einen int-Wert in eine Sammlung einfügen wollen, erzeugen wir zunächst ein Integer-Objekt (eine Instanz des Wrappers), das den
int-Wert hält und fügen dieses Objekt in die Sammlung ein.
Instanzen von Integer sind Objekte mit nur einem einzelnen Datenfeld vom
Typ int. Der int-Wert wird also in einem Integer-Objekt verpackt.
Der folgende Quelltextabschnitt illustriert dies:
int i = 18;
Integer iwrap = new Integer(i);
meineSammlung.add(iwrap);
Integer element = (Integer) meineSammlung.get(1);
int wert = element.intValue();
In analoger Weise können die anderen primitiven Datentypen behandelt werden.
Eine gute Beschreibung der Hierarchie der Sammlungstypen finden Sie unter
http://java.sun.com/docs/books/tutorial/collections/index.html.
Beachten Sie, dass einige Details dieser Hierarchie Wissen über Java-Interfaces
voraussetzen, die wir im übernächsten Kapitel diskutieren werden.
6.10
Zusammenfassung
• Vererbung: Vererbung erlaubt uns, eine Klasse als Erweiterung einer
anderen zu definieren.
• Superklasse: Eine Superklasse ist eine Klasse, die von anderen Klassen
erweitert wird.
86
• Subklasse: Eine Subklasse ist eine Klasse, die eine andere Klasse erweitert und dadurch alle Datenfelder und Methoden von ihrer Superklasse
erbt.
• Vererbungshierarchie: Klassen, die über Vererbungsbeziehungen miteinander verknüpft sind, bilden eine Vererbungshierarchie.
• Konstruktor der Superklasse: Im Konstruktor einer Subklasse muss
immer als erste Anweisung der Konstruktor der Superklasse aufgerufen
werden. Wenn im Quelltext kein solcher Aufruf angegeben ist, versucht
Java automatisch, einen parameterlosen Aufruf einzufügen.
• Wiederverwendung: Vererbung erlaubt die Wiederverwendung bereits erstellter Klassen in neuen Zusammenhängen.
• Subtyp: Analog zur Klassenhierarchie bilden die Objekttypen eine Typhierarchie. Der Typ, der durch eine Subklasse definiert ist, ist ein Subtyp des Typs, der durch die zugeordnete Superklasse definiert wird.
• Object: Alle Klassen ohne explizit deklarierte Superklasse haben Object
als ihre Superklasse.
• Ersetzbarkeit: Objekte von Subtypen können an allen Stellen verwendet werden, an denen ein Supertyp erwartet wird.
87
7
Vertiefter Umgang mit Vererbung
In diesem Kapitel werden wir weiterhin mit dem Projekt DoME arbeiten,
um uns die wichtigsten der übrigen Aspekte rund um Vererbung und Polymorphie anzueignen.
7.1
Überschreiben von Methoden
Rufen Sie das Projekt DoME-V2 aus Kapitel08 auf. Erzeugen Sie ein CD- und
eine Video-Objekt mit den folgenden Daten:
CD: Frank Sinatra: A Swinging’ Affair
16 Titel
64 Minuten
hab Ich: ja
Kommentar: Mein Lieblingsalbum von Sinatra
Video: Matrix
Regisseure: Andy & Larry Wachowski
136 Minuten
hab Ich: nein
Kommentar: Toller Film über Virtual Reality
Tragen Sie die beiden Medien in ein Objekt von Datenbank ein und rufen
Sie die Methode auflisten auf.
Im Gegensatz zu DoME-V1, wo die folgende Ausgabe erfolgt:
CD: A Swingin’ Affair (64 Min)
Frank Sinatra
Titelanzahl: 16
Mein Lieblingsalbum von Sinatra
Video: Matrix (164 Min)
Andy & Larray Wachowski
Toller Film über Virtual Reality
wird nun lediglich ein Teil der Informatonen ausgegeben:
88
Titel: A Swinging’ Affair (64 Min)
Mein Lieblingsalbum von Sinatra
Titel: Matrix (136 Min)
Toller Film über Virtual Reality
Bei der CD fehlen die Angaben über den Künstler und die Titelanzahl und
beim Video die Angabe des Regisseurs. Der Grund ist einfach. Die Methode ausgeben ist in DoME-V2 in der Klasse Medium implementiert, nicht in
Video und CD. In der Klasse Medium stehen jedoch nur die Datenfelder zur
Verfügung, die alle Medien besitzen, während die weggelassenen Angaben
nur den spezifischen Medien Video und CD zu eigen sind. Wenn wir in der
Methode ausgeben auf diese Datenfelder zugreifen würden, dann würde der
Compiler einen Übersetzungsfehler melden. Die Klassen CD und Video erben zwar von der Klasse Medium die Datenfelder, aber dies geschieht nicht
umgekehrt.
Ein erster Versuch zur Lösung dieses Problems könnte sein, die Methode
ausgeben in die Subklassen zu verschieben, die dann jeweils die entsprechenden Angaben machen könnten. Dort kann auf die spezifischen Datenfelder
direkt zugegriffen werden, während die allgemeinen Datenfelder in der Klasse Medium über Methoden zugreifbar sind.
Wenn wir dies in unserem Projekt DoME-V2 realisieren, dann stellen wir
fest, dass sich nun die Klasse Datenbank nicht mehr übersetzen lässt, da
dort die Methode ausgeben nicht gefunden werden kann.
Die betroffenen Zeilen im Quelltext der Klasse Datenbank sind:
Medium medium = (Medium)iter.next();
medium.ausgeben();
Einerseits erscheint uns die Fehlermeldung sinnvoll, da die Klasse Medium
nunmehr keine Methode ausgeben besitzt. Andererseits haben wir in der
Datenbank nur Objekte, die entweder eine CD oder ein Video sind und damit
eine Methode ausgeben besitzen.
Um dies zu verstehen, betrachten wir zunächst das folgende Beispiel:
Video video = new Video();
Wir sagen der Typ von video ist Video. Bevor wir die Vererbung kennengelernt haben, machte es keinen Unterschied, ob wir mit ‘Typ von video’ den
89
Typ der Variablen video, oder den Typ des Objekts, das in der Variablen
video gespeichert ist, meinen.
Jedoch macht dies bei der Anweisung
Medium medium = new Video();
einen Unterschied. Wenn man hier nach dem Typ von medium fragt, dann
ist der deklarierte Typ der Variablen medium unterschiedlich zu dem Typ des
Objekts, das die Variable hält.
Um darüber eindeutig reden zu können führen wir die folgenden Begriffe ein:
• Der deklarierte Typ einer Variablen heißt statischer Typ, der er im
Quelltext unveränderlich festgelegt wird.
• Der Typ des Objekts, auf den eine Variable verweist, nennt man den
dynamischen Typ einer Variablen, da er von Zuweisungen zur Laufzeit des Programms abhängt und veränderbar ist.
Das heißt, für die Anweisung
Medium medium = new Video();
ist Medium der statische Typ der Variablen medium, während Video der dynamische Typ von medium ist.
Deshalb meldet der Compiler auch einen Fehler bei der Anweisung medium.ausgeben();
, da der Compiler zur Übersetzungszeit nur den statischen Typ berücksichtigen
kann, da der dynamische Typ meist erst zur Laufzeit bekannt ist. Dies bedeutet, dass die Klasse Medium auch eine Methode ausgeben benötigt.
Will man den dynamischen Typ einer Variablen zur Programmlaufzeit abfragen, kann man hierfür den Operator instanceof benutzen. Der Test
medium instanceof Spiel
liefert true, falls der dynamische Typ der Variablen medium entweder Spiel
oder einer der Subtypen Brettspiel oder Videospiel ist.
Welche Methode wird aber nun aufgerufen, falls wir eine Methode ausgeben
mit identischer Signatur in den Klassen Medium, Video und CD wie folgt
definiert haben
90
public class Medium
{
...
public void ausgeben()
{
System.out.print("Titel: " + titel + " (" + spielzeit + " Min)");
if(habIch) {
System.out.println("*");
} else {
System.out.println();
}
System.out.println("
" + kommentar);
}
}
public class CD extends Medium
{
...
public void ausgeben()
{
System.out.println("
" + kuenstler);
System.out.println("
" + titelanzahl + " Titel");
}
}
public class Video extends Medium
{
...
public void ausgeben()
{
System.out.println("
Regisseur: " + regisseur);
}
}
und folgende Programmsequenz vorliegt
Medium medium = (Medium)iter.next();
medium.ausgeben();
91
wobei aus der ArrayList nur Objekte des Typs Video bzw. CD ausgegeben
werden?
Wir überprüfen dies anhand des Projekts DoME-V3 in Kapitel09. Wir legen
jeweils ein Objekt für eine Datenbank, eine CD und ein Video an und tragen
die CD und das Video in die Datenbank ein.
Wenn wir nun die Methode auflisten in der Klasse Datenbank aufrufen,
dann wird nur diejenige Information ausgegeben, wie es in den Methoden
ausgeben der Klassen Medium bzw. CD implementiert ist.
Obwohl der Compiler auf eine Methode ausgeben in der Klasse Medium bestanden hat, wird sie offensichtlich gar nicht aufgerufen. Um dies zu verstehen, sehen wir uns nun im folgenden Abschnitt genauer an, wie Methoden
aufgerufen werden.
7.2
Dynamische Methodensuche
In Java gilt:
• Die Typüberprüfung berücksichtigt beim Compilieren den statischen
Typ, aber zur Laufzeit werden die (evtl. geerbten) Methoden des dynamischen Typs aufgerufen.
Wie werden jedoch diese Methoden gefunden? Das folgende Beispiel gibt
einen guten Überblick:
92
Medium
ausgeben
Spiel
ausgeben
v1.ausgeben();
Videospiel
Instanz-von
Medium v1;
:Videospiel
Wenn die Anweisung tt v1.ausgeben(); aufgerufen wird, dann wird die
Methodensuche (auch Methodenbindung oder Methodenauswahl) wie folgt
durchgeführt:
1. Es wird auf die Variable v1 zugegriffen.
2. Das in der Variablen gespeicherte Objekt wird gefunden (indem der
Referenz gefolgt wird).
3. Die Klasse dieses Objekts wird gefunden (indem der Referenz Instanzvon gefolgt wird).
4. Wird in dieser Klasse eine passende Methode gefunden, so wird diese
aufgerufen und die Methodensuche ist beendet.
93
5. Ansonsten wird die Vererbungshierarchie solange nach oben durchlaufen, bis eine Klasse mit der gesuchten Methode gefunden wird. Diese
Methode wird aufgerufen und die Methodensuche ist beendet. (Beim
Durchlaufen der Vererbungshierarchie wird sicher eine passende Methode gefunden, da sonst bereits beim Übersetzen ein Fehler gemeldet
worden wäre.)
Wir haben nun zwar eine Erklärung für das Verhalten bei der Ausgabe unserer Medien, aber immer noch keine befriedigende Lösung.
Diese ist jedoch ganz einfach zu erreichen, indem wir das Super-Konstrukt
benutzen, das wir bereits bei den Konstruktoren von abgeleitetet Klassen
kennengelernt haben. Das folgende Listing zeigt die Lösung für unser Ausgabeproblem für die Klasse Video:
public void ausgeben()
{
super.ausgeben();
System.out.println("
}
Regisseur: " + regisseur);
Mithilfe des Super-Konstrukts kann also zusätzlich eine Methode aus einer
Superklasse aufgerufen werden.
Folgende Details sind hierbei zu beachten:
• Im Gegensatz zum super-Aufruf in Konstruktoren wird der Methodenname der Superklasse ezplizit genannt.
• Der super-Aufruf kann an jeder beliebigen Stelle in der Methode erfolgen (es muss nicht die erste Anweisung sein).
• Es muss kein super-Aufruf erfolgen und es wird auch nicht automatisch
ein Aufruf generiert.
Da zur Laufzeit immer die Methoden des dynamischen Typs aufgerufen werden, und dadurch bei einen Aufruf wie medium.ausgeben();
je nach
Objekttyp unterschiedliche Methoden aufgerufen werden, spricht man hier
von polymorpher Methodensuche bzw. Methoden-Polymorphie.
94
7.3
Methoden aus Object: toString
Im vorigen Kapitel wurde als ein Vorteil der Basisklasse Object herausgestellt, dass die Klasse Object Methoden anbietet, die alle Klassen (da von
Object abgeleitet) nutzen können.
Eine wichtige Methode ist toString. Sie dient dazu, eine String-Repräsentation
eines Objekts zu liefern. Das ist insbesondere dann sinnvoll, wenn ein Objekt
textuell in einer Benutzeroberfläche sichtbar werden soll. Auch kann dies bei
der Fehlersuche ganz nützlich sein.
Da die Standardimplementierung von toString für alle Objekte beliebiger
Klassen aufrufbar ist, können natürlich nicht sehr viele Details eines Objekts
angezeigt werden. Rufen wir beispielsweise toString für ein Video-Objekt
auf, dann bekommen wir eine Zeichenkette in folgender Form:
Video@6acdd1
Das Ergebnis zeigt den Klassennamen des Objekts und die Nummer der
Speicheradresse, in der dieses Objekt gespeichert ist. Wenn diese Nummer
bei zwei Aufrufen gleich ist, dann handelt es sich um dasselbe Objekt. Unterschiedliche Objekte werden natürlich in unterschiedlichen Speicherplätzen
gehalten.
Meist wird jedoch diese Methode überschrieben, um sie etwas nützlicher zu
machen. Wir könnten beispielsweise die Methode ausgeben auf der Basis der
Methode toString definieren. Der folgende Quelltext zeigt, wie dies aussehen
könnte:
public class Medium
{
...
public String toString()
{
String infos = titel + " (" + spielzeit + " Min)";
if(habIch) {
return infos + "*\n
" + kommentar + "\n";
} else {
return infos + "\n
" + kommentar + "\n";
}
95
}
}
In analoger Weise geschieht dies auch in der Klasse CD:
public class CD extends Medium
{
...
public String toString()
{
return super.toString() + "
" + kuenstler
+ "\n
" + titelanzahl + "Titel\n";
}
}
Die Anweisung zur Ausgabe eines Mediums könnte in der Datenbank nun
wie folgt aussehen:
System.out.println(medium.toString());
Es geht aber noch einfacher, denn für die Methoden System.out.print und
System.out.println gilt eine Besonderheit: Wenn der Parameter einer dieser Methoden nicht vom Typ String ist, dann wird dort jeweils automatisch
die dem übergebenen Objekt zugehörige Methode toString aufgerufen4 .
Die Methode auflisten in der Klasse Datenbank könnte also wie folgt aussehen:
public void auflisten()
{
for (Iterator iter = medien.iterator(); iter.hasNext(); ) {
System.out.println(iter.next);
}
}
4
Dies ist auch die Erklärung, warum wir bei einem Aufruf
System.out.println(iter.next() /* Objekt aus Sammelbehaelter */ );
ne Cast-Anweisung benötigen.
96
wie
kei-
Falls Sie erklären können, warum obige Methode sowohl übersetzbar ist und
auch für eine CD die ausführlichen Informationen ausgibt, dann haben sie die
neuen Konzepte dieses Kapitels verstanden.
7.4
Der Zugriff über protected
Im vorigen Kapitel haben wir gesehen, dass die Regeln über öffentliche und
private Sichtbarkeit von Klasseneigenschaften sowohl zwischen einer Subklasse und ihrer Superklasse als auch zwischen Klassen aus unterschiedlichen Vererbungshierarchien gelten. Dies kann manchmal etwas restriktiv sein, da das
Verhältnis zwischen einer Superklasse und ihren Subklassen deutlich enger
ist als zwischen anderen Klassen. Deshalb definiert Java eine Zugriffsstufe,
die zwischen privater und öffentlicher Verfügbarkeit liegt. Diese wird in Java
als protected definiert.
Datenfelder und Methoden mit der Zugriffstufe protected können innerhalb
der Klasse selbst und von allen Subklassen benutzt werden, aber nicht von
anderen Klassen5 . Die folgende Abbildung illustriert dies:
Obwohl alle Teile einer Klasse als protected deklariert werden können, soll5
Dies stimmt in Java nicht ganz, aber vorläufig reicht uns diese Definition
97
te dies üblicherweise nur bei Konstruktoren und Methoden und nicht bei
Datenfeldern gemacht werden, da sonst die Kapselung zu sehr geschwächt
würde.
7.5
Zusammenfassung
• Statischer Typ: Der statische Typ einer Variablen v ist der Typ, mit
die Variable im Quelltext deklariert wurde.
• Dynamischer Typ: Der dynamischer Typ einer Variablen v ist der
Typ des Objekts, das zur Zeit in der Variablen v gehalten wird.
• instanceof: Der Operator instanceof überprüft, ob ein gegebenes
Objekt, direkt oder indirekt, eine Instanz einer gegebenen Klasse ist.
• Überschreiben einer Methode: Einde Subklasse kann die Implementierung einer Methode überschreiben. Dazu deklariert die Subklasse
eine Methode mit der gleichen Signatur wie in der Superklasse, implementiert diese jedoch mit einem anderen Rumpf. Die überschreibende
Methode wird dann bei Aufrufen an Objekte der Unterklasse vorgezogen.
• Methoden-Polymorphie: Methodenaufrufe in Java sind polymorph.
Derselbe Methodenaufruf kann zu unterschiedlichen Zeitpunkten verschiedene Methoden aufrufen, abhängig vom dynamischen Typ der variablen, mit der der Aufruf durchgeführt wird.
• toString: Jedes Objekt in Java bietet eine Methode toString an, die
eine String-Repräsentation des Objekts liefert. Um diese Methode optimal nutzen zu können, kann sie in Subklassen überschrieben werden.
• protected: Datenfelder und Methoden, die als protected deklariert
sind, sind für (direkte und indirekte) Subklassen zugreifbar.
98
8
Weitere Techniken zur Abstraktion
In diesem Kapitel untersuchen wir weitere Techniken, die mit Hilfe von Vererbungskonzepten Entwürfe verbessern und sie wartbarer und erweiterbarer
machen. Wie immer werden wir uns dies an Hand eines Beispiels näher ansehen.
8.1
Die Füchse-und-Hasen-Simulation
Unser Programm beobachtet die Population von Füchsen und Hasen in einem
abgeschlossenen Feld. Sie ist ein Beispiel für eine so genannte Jäger-BeuteSimulation. Solche Simulationen werden häufig benuzt, um die Schwankungen in einer Population zu modellieren, die sich aus dem Zusammenleben von
Raubtieren mit ihren Beutetieren ergeben.
8.1.1
Das Projekt Fuechse-und-Hasen
Öffnen Sie das Projekt Fuechse-und-Hasen-V1. Die folgende Abbildung zeigt
das zugehörige Klassendiagramm.
99
Die zentralen Klassen, die wir uns im folgenden näher ansehen werden, sind
Simulator, Fuchs und Hase. Die Klassen Fuchs und Hase bilden einfache
Modelle für eine Raubtier- und eine Beuterasse. Die Klasse Simulator stellt
den Anfangszustand der Simulation her und kontrolliert ihren Ablauf. Nach
jedem Simulationsschritt wird der aktuelle Zustand des Feldes auf dem Bildschirm angezeigt.
100
Die Funktion der übrigen Klassen, deren Implementierung uns hier nicht
interessiert, lässt sich folgendermaßen zusammenfassen:
• Feld repräsentiert ein zweidimensionales begrenztes Feld, das aus einer
festgelegten Anzahl an Positionen — angelegt in Zeilen und Spalten —
besteht. Eine Position in einem Feld kann höchstens von einem Tier
eingenommen werden. Jede Position in einem Feld hält eine Referenz
auf ein Tier oder ist leer.
• Position repräsentiert eine zweidimensionale Position innerhalb des
Feldes. Eine Position wird durch einen Zeilen- und einen Spaltenwert
definiert.
101
• Simulationsansicht visualisiert den Zustand des Feldes grafisch (siehe obige Abbildung).
• FeldStatistik liefert die Anzahl der Füchse und Hasen im Feld für
die Visualisierung.
• Zaehler speichert die aktuelle Anzahl der Exemplare einer Tierart.
Sehen Sie sich nun den Quelltext der Klasse Hase näher an.
Diese Klasse enthält eine Reihe von statischen Variablen, die Einstellungen
für alle Hasen definieren. Jeder Hase hat außerdem drei Instanzvariablen, die
seinen Zustand beschreiben: Alter, lebendig oder tot, Position im Feld.
Das Verhalten eines Hasen ist in der Methode laufe definiert, die wiederum
die Methoden gebaereNachwuchs und alterErhoehen benutzt. In jedem Simulationsschritt wird die Methode laufe aufgerufen, so dass ein Hase sein
Alter erhöht, sich bewegt und, wenn er alt genug ist, Nachwuchs gebärt. Sowohl die Bewegung als auch das Gebärverhalten sind durch Zufall gesteuert.
Wie man unschwer erkennen kann, sind hier natürlich viele Vereinfachungen
vorgenommen worden, wie z.B. dass Hasen kein Geschlecht haben und sich
in jedem Schritt vermehren können.
In ähnlicher Weise ist die Klasse Fuchs aufgebaut. Für jeden Fuchs wird
in jedem Simulationsschritt die Methode jage aufgerufen, die sein verhalten definiert. Zusätzlich zum Altern und Nachwuchsgebären sucht ein Fuchs
auch nach Nahrung (Methode findeNahrung). Wenn er einen Hasen in einer
Nachbarposition findet, dann wird der Hase gefressen und der Futter-Level
des Fuchses wird erhöht.
Die Klasse Simulator ist das Herzstück der Simulation. Wenn ein SimulatorObjekt erzeugt wird, werden auch alle anderen Teile einer Simulation erzeugt
(das Feld, die Liste für die Tiere und die grafische Darstellung). Danach wird
ein gültiger Startzustand eingenommen (Methode zuruecksetzen, wobei die
Methode bevoelkere eine initiale Verteilung von Füchsen und Hasen auf dem
Feld erzeugt.
8.1.2
Ein Simulationsschritt
Der zentrale Teil der Klasse Simulator ist die Methode simuliereEinenSchritt,
die im folgenden Listing zu sehen ist:
102
public void simuliereEinenSchritt()
{
schritt++;
neueTiere.clear();
// alle Tiere handeln lassen
for(Iterator iter = tiere.iterator(); iter.hasNext(); ) {
Object tier = iter.next();
if(tier instanceof Hase) {
Hase hase = (Hase)tier;
if(hase.istLebendig()) {
hase.laufe(naechstesFeld, neueTiere);
}
else {
iter.remove();
// toten Hasen entfernen
}
}
else if(tier instanceof Fuchs) {
Fuchs fuchs = (Fuchs)tier;
if(fuchs.istLebendig()) {
fuchs.jage(feld, naechstesFeld, neueTiere);
}
else {
iter.remove();
// toten Fuchs entfernen
}
}
else {
System.out.println("unbekanntes Tier entdeckt");
}
}
// neu geborene Tiere in die Liste der Tiere einfügen.
tiere.addAll(neueTiere);
// feld und nächstesFeld am Ende des Schritts austauschen.
Feld temp = feld;
feld = naechstesFeld;
naechstesFeld = temp;
naechstesFeld.raeumen();
// das neue Feld in der Ansicht anzeigen.
ansicht.zeigeStatus(schritt, feld);
103
}
Um jedes Tier agieren zu lassen, hält der Simulator eine Liste mit allen Tieren. Bislang machen wir von der Vererbung nur sehr eingeschränkt Gebrauch.
Da alle Java-Objekte von der Klasse Object erben, werden Füchse und Hasen als Object-Instanzen behandelt, die in derselben Liste gehalten werden.
Wenn wir ein Objekt aus der Liste abfragen, dann müssen wir die tatsächliche
Klasse des Objekts überprüfen und rufen dann entweder die Methode jage
bei Füchsen oder die Methode laufe bei Hasen auf.
Diese Stelle ist natürlich ein guter Kandidat für eine spätere Verbesserung,
da jedesmal, wenn wir neue Tiere in unserer Simulation hinzunehmen eine
entsprechende Erweiterung notwendig ist.
8.2
Abstrakte Methoden und Klassen
Die Klassen Fuchs und Hase haben viele Gemeinsamkeiten, die es nahe legen,
dass diese beiden Klassen Subklassen einer Superklasse Tier sind. Folgende
gemeinsame Teile können problemlos in die Klasse Tier verlagert werden:
• Datenfelder alter, lebendig und position und zugehörige Methoden
istLebendig und setzePosition.
• Lesende und schreibende Methoden auf obige Datenfelder ermöglichen
die Deklaration dieser Datenfelder als private.
• Die Methode setzeGefressen ist eher spezifisch für den Hasen und
könnte durch eine allgemeinere Methode setzeGestorben in der Klasse
Tier ersetzt werden.
Durch diese Maßnahmen können eine Reihe von Code-Duplizierungen in den
Klassen Fuchs und Hase vermieden werden und es können zukünftig leichter
weitere Tierarten eingeführt werden.
Zusätzlich ergeben sich durch obige Maßnahmen aber auch Vereinfachungen
in der Klasse Simulator. Der Quelltext
for(Iterator iter = tiere.iterator(); iter.hasNext(); ) {
Object tier = iter.next();
if(tier instanceof Hase) {
104
Hase hase = (Hase)tier;
if(hase.istLebendig()) {
hase.laufe(naechstesFeld, neueTiere);
}
else {
iter.remove();
// toten Hasen entfernen
}
}
else if(tier instanceof Fuchs) {
Fuchs fuchs = (Fuchs)tier;
if(fuchs.istLebendig()) {
fuchs.jage(feld, naechstesFeld, neueTiere);
}
else {
iter.remove();
// toten Fuchs entfernen
}
}
else {
System.out.println("unbekanntes Tier entdeckt");
}
}
kann nun durch die Einführung der gemeinsamen Superklasse Tier und durch
die Umbenennung der Methoden laufe und jage in agiere folgendermaßen
vereinfacht werden:
for(Iterator iter = tiere.iterator(); iter.hasNext(); ) {
Tier tier = (Tier)iter.next();
if(tier.istLebendig()) {
tier.agiere(feld, naechstesFeld, neueTiere);
}
else {
iter.remove();
// totes Tier entfernen
}
}
Die Methode agiere für einen Hasen hat zwar nun einen Parameter mehr
als die ursprüngliche Methode laufe, aber der zusätzliche Parameter feld
kann ja einfach ignoriert werden.
105
Da bei der Übersetzung des obigen Quelltextes der Compiler nur den statischen Typ der Variable tier überprüft, muss die Klasse Tier jedoch auch
eine Methode agiere besitzen, obwohl diese Methode niemals zur Laufzeit
benötigt wird und auch nicht in sinnvollerweise durch einen Super-Aufruf
innerhalb der Methoden agiere für Fuchs und Hase genutzt werden kann.
Da es niemals in unserer Simulation ein direktes Objekt der Klasse Tier
geben wird, benötigen wir eigentlich keine Implementierung einer Methode
agiere. Jedoch muss es eine solche Methode aus dem oben genannten Grund
geben.
Die Lösung in Java ist die Deklaration einer solchen Methode als abstrakte
Methode, die zwar deklaririert wird, aber zu der es keine Implementierung
gibt. Für die abstrakte Methode agiere sieht dies wie folgt aus:
abstract public void agiere(Feld aktuellesFeld,
naechstesFeld, List neueTiere);
Eine abstrakte Methode hat zwei definierende Merkmale:
• Sie wird mit dem Schlüsselwort abstract definiert
• Sie hat keinen Rumpf. Stattdessen wird der Kopf der Methode durch
ein Semikolon abgeschlossen.
Nicht nur Methoden können als abstrakt definiert werden, sondern dies ist
auch für Klassen möglich. Da wir ja nie ein Objekt eines Tieres erzeugen
wollen können wir dies für die Klasse Tier wie folgt tun:
public abstract class Tier
{
// Datenfelder ausgelassen.
/**
* Erzeuge ein Tier mit Alter Null (ein Neugeborenes).
*/
public Tier()
{
alter = 0;
lebendig = true;
}
106
/**
* Lasse dieses Tier agieren - es soll das tun, was
* es tun muss oder möchte.
*/
abstract public void agiere(Feld aktuellesFeld,
Feld naechstesFeld, List neueTiere);
/**
* Prüfe, ob dieses Tier noch lebendig ist.
* @return true wenn dieses Tier noch lebendig ist.
*/
public boolean istLebendig()
{
return lebendig;
}
// weitere Methoden ausgelassen
}
Obwohl wir nie ein Objekt der Klasse Tier erzeugen wollen und dies auch
gar nicht möglich ist, da der Comiler beim new-Kommando für eine abstrakte
Klasse eine Fehlermeldung generiert, besitzt diese Klasse einen Konstruktor.
Dieser wird benötigt, da in den Konstruktoren der Subklassen der Aufruf
eines Konstruktors der Superklasse erforderlich ist. Wie man weiterhin sieht,
können abstrakte Klassen sowohl abstrakte als auch implementierte Methoden enthalten.
Klassen, die ohne das Schlüsselwort abstract definiert werden, nennt man
konkrete Klassen.
Nur abstrakte Klassen dürfen abstrakte Methoden definieren, so dass für
konkrete Klassen es immer eine ausführbare Implementierung aller Methoden
geben muss. Ist dies nicht der Fall, — sei es, dass eine abstrakte Methode
geerbt wird oder sei es, dass eine abstrakte Methode direkt in einer konkreten
Klasse deklariert wird — dann gibt es einen Übersetzungsfehler.
107
8.3
Multiple Vererbung
In diesem Abschnitt diskutieren wir einige mögliche Erweiterungen und die
Programmkonstrukte, die diese Erweiterungen möglich machen.
Möglicherweise sind nicht alle Teilnehmer an unserer Simulation Tiere. Eine Erweiterung könnte sein, dass wir menschliche Teilnehmer einführen, die
entweder Jäger oder Fallensteller sind. Wir könnten die Simulation auch um
Pflanzen oder sogar Wettereinflüsse erweitern wollen.
Um dies angemessen zu simulieren, scheint die Einführung einer Klasse Akteur
sinnvoll. Diese Klasse würde als Superklasse für alle Arten von Simulationsteilnehmern dienen. Die folgende Abbildung zeigt ein mögliches Klassendiagramm.
Die Klassen Akteur und Tier sind abstrakt, während die restlichen Klassen
108
konkrete Klassen sind.
Die Klasse Akteur würde die Gemeinsamkeiten aller Simulationsteilnehmer
definieren. Die wichtigste Gemeinsamkeit wäre, dass alle Akteure agieren
können. Wir könnten dann die abstrakte Methode
abstract public void agiere(Feld aktuellesFeld,
Feld naechstesFeld, List neueTiere);
in die Klasse Akteur verschieben und schon könnte in der Simulationsschleife
die Klasse Akteur anstelle der Klasse Tier benutzt werden.
Bisher wurden alle Simulationsteilnehmer visualisiert. Will man beispielsweise Ameisen einführen, so sollen diese evtl. aufgrund der großen Anzahl nicht
visualisiert werden. Anstatt wie bisher über das gesamte feld zu iterieren und
den jeweiligen Akteur zu zeichnen, könnten wir eine zusätzliche Sammlung
von sichtbaren Akteuren einführen und über diese beim Zeichnen iterieren.
Dies könnte in etwa so aussehen:
// alle Tiere agieren lassen
for(Iterator iter = akteure.iterator(); iter.hasNext(); ) {
Akteur akteur = (Akteur) iter.next();
akteur.agiere(...);
}
// zeichne alle zeichenbaren Akteure
for(Iterator iter = zeichenbare.iterator(); iter.hasNext(); ) {
Zeichenbar zeichenbar = (Zeichenbar) iter.next();
zeichenbar.zeichnen();
}
Alle Akteure sind in der Sammlung akteure, während sich alle zeichenbaren
Akteure zusätzlich in der Sammlung zeichenbare befinden. Damit dies funktioniert benötigen wir eine abstrakte Klasse Zeichenbar, die eine abstrakte
Methode zeichnen definiert.
Zeichenbare Akteure müssten nun aber von Akteur und Zeichenbar erben
(siehe folgende Abbildung).
109
Das dargestellte Szenario benutzt eine Struktur, die multiple Vererbung genannt wird. Diese tritt auf, wenn eine Klasse mehr als eine Superklasse hat.
Die Subklasse erbt dann über alle Eigenschaften beider Superklassen. Obwohl dies im Prinzip sehr einsichtig ist, kann es bei der Umsetzung zu großen
Schwierigkeiten kommen.
Angenommen die Klassen Akteur und Zeichenbar definieren eine Methode
istLebendig. Wird diese Methode dann für ein Objekt der Klasse Fuchs
aufgerufen, so kann das laufzeitsystem nicht entscheiden, ob die Methode
istLebendig aus der Klasse Akteur oder der Klasse Zeichenbar aufzurufen
ist, da es von der Klasse Fuchs aus zwei unterschiedliche Superklassenpfade
gibt, die für die Methodensuche erfolgreich verfolgt werden können.
Um solche Probleme zu vermeiden, aber dennoch die Vorteile von multipler
Vererbung wenigstens eingeschräkt nutzen zu können, bietet Java sogenannte
Interfaces an, die wir uns im nächsten Abschnitt näher ansehen.
110
8.4
Interfaces
Auf den ersten Blick sind Interfaces Klassen sehr ähnlich. Der offensichtlichste Unterschied besteht darin, dass die Methoden in Interfaces keine Rümpfe
definieren dürfen. Sie sind deshalb abstrakten Klassen, in denen alle Methoden abstrakt sind, sehr ähnlich.
Das folgende Listing zeigt Akteur definiert als ein Interface.
public interface Akteur
{
/**
* Führe die tägliche Aktivität des Akteurs aus.
Trage den Akteur in naechstesFeld ein, wenn er
auch am nächsten Simulationsschritt teilnehmen soll
*/
void agiere(Feld aktuellesFeld, Feld naechstesFeld,
List neueTiere);
}
Interfaces haben in Java eine Reihe von festen Eigenschaften:
• Im Kopf wird statt des Schlüsselworts class das Schlüsselwort interface
verwendet.
• Alle Methoden in einem Interface sind abstrakt. Das Schlüsselwort
abstract wird deshalb nicht benötigt.
• Alle Methodensignaturen in einem Interface sind öffentlich sichtbar.
Die Sichtbarkeit muss deshalb nicht explizit deklariert werden, d.h. das
Schlüsselwort public ist nicht nötig.
• In einem Interface können nur öffentliche konstante Klassenvariablen
deklariert werden, d.h. die Schlüsselworter public, static und final
können weggelassen werden. Soll eine Konstante gleichen Namens aus
unterschiedlichen Interfaces geerbt werden, so gibt es einen Compilerfehler.
• Interfaces enthalten keine Konstruktoren.
Eine Klasse kann auf ähnliche Weise erben, wie von einer Klasse. Hierbei
wird jedoch das Schlüsselwort implements benutzt, wie z.B.:
111
public class Fuchs extends Tier implements Zeichenbar
{
...
}
In einem Fall, in dem eine Klasse sowohl eine Klasse erweitert als auch ein
Interface implementiert, muss die extends-Klausel zuerst aufgeführt werden.
Hingegen kann die Klasse Jaeger als Implementierung der Interfaces Akteur
und Zeichenbar definiert werden:
public class Jaeger implements Akteur, Zeichenbar
{
...
}
Die Klasse Jaeger erbt die Methodenrümpfe agiere und zeichne der beiden
Interfaces und muss hierfür eine Implementierung anbieten. Selbst wenn in
beiden Interfaces die gleiche abstrakte Methode implementiert wäre, gäbe
dies kein Problem, da diese niemals aufgerufen wird.
Welchen konkreten Nutzen hat es überhaupt Interfaces zu definieren, da ja
keine implementierten Methoden vererbt werden können.
1. Ein Interface definiert genauso wie eine Klasse einen Typ. Somit können
Variablen von einem Interface-Typ deklariert werden, obwohl keine Objekte dieses Typs existieren. Unterschiedliche Klassen, die dasselbe Interface implementieren, werden zu Subtypen und können einheitlich
behandelt werden.
2. Die wichtigste Eigenschaft von Interfaces ist, dass sie die Definition der
Funktionalität (die Schnittstelle einer Klasse) vollständig von ihrer Implementierung trennen. Beispielsweise definiert das Interface List die
volle Funktionalität einer Liste (Elemente einfügen, löschen usw.), ohne jedoch eine Implementierung vorzugeben. Die Subklassen ArrayList
und LinkedList bieten zwei verschiedene Implementierungen an, die
bei unterschiedlichen Aktionen unterschiedlich effizient sind. Z.B. kann
bei einer ArrayList sehr schnell auf Elemente in der Mitte der Liste zugegriffen werden, während das Einfügen oder Entfernen von Elementen
bei einer LinkedList viel effizienter geschieht. Welche Implementierung
für eine gegeben Anwendung am besten passt, ist im Voraus oft schwer
112
festzulegen. In der Praxis lässt sich dies am einfachsten durch Ausprobieren beider Versionen herausfinden. Durch das Interface List ist
dies sehr einfach. Wenn man anstatt ArrayList und LinkedList immer List für die Typnamen von Parametern und Variablen verwendet,
wird die Anwendung unabhängig von der gewählten Implementierung
funktionieren. Lediglich bei der Erzeugung der Liste müssen wir uns
festlegen und beispielsweise
private List meineListe = new ArrayList();
schreiben. Wollen wir nun eine LinkedList verwenden, so muss nur diese Anweisung geändert werden, aber nicht die vielen Stellen, an denen
die Variable meineListe benutzt wird.
Abstrakte Klassen, die keine Implementierung enthalten, sollten nicht implementiert werden, da diese Funktionalität genauso gut von einem Interface erfüllt wird. Subklassen abstrakten Klasse können jedoch keine weiteren
Klassen beerben, während ein Interface multiple Vererbung gestattet.
8.5
Zusammenfassung
• Abstrakte Methode: Die Definition einer abstrakten Methode besteht aus einer Methodensignatur ohne einen Rumpf. Sie wird mit dem
Schlüsselwort abstract markiert.
• Abstrakte Klasse: Ein abstrakte Klasse ist eine Klasse, von der keine
Instanzen erzeugt werden sollen (und dürfen). Sie dient ausschließlich
als Superklasse für andere Klassen. Nur abstrakte Klassen dürfen abstrakte Methoden anbieten.
• Abstrakte Subklasse: Damit eine Subklasse einer abstrakten Klasse
eine konkrete Klasse werden kann, muss sie Implementierungen für alle
geerbten abstrakten Methoden anbieten. Ansonsten ist sie ebenfalls
abstrakt und muss mit dem Schlüsselwort abstract markiert werden.
• Multiple Vererbung: Eine Situation, in der eine Klasse von mehr als
einer Superklasse erbt, wird multiple Vererbung genannt.
• Interface: Ein Interface in Java ist eine Spezifikation einer Schnittstelle, die keine Implementierungen für Methoden definiert. Eine Klasse
kann Implementierung mehrerer Interfaces sein, und so multipel erben.
113
9
Fehlerbehandlung
Selbst die bestgetesteten Programme weisen noch Fehler auf oder können
in Situationen kommen, die zum Scheitern des Programms führen (z.B. eine
Datei kann nicht geschrieben werden, da kein Plattenplatz mehr vorhanden
ist).
In diesem Kapitel werden wir betrachten, wie Fehlersituationen vorausgesehen werden können und wie im laufenden Programm auf solche Fehlersituationen reagiert werden kann. Zudem werden wir sehen, wie auftretende
Fehler gemeldet werden können.
Wie immer werden wir uns dies an Hand eines Beispiels (Projekt AdressbuchV1G in Kapitel11) ansehen.
9.1
Adressbuch-Projekt
Dieses Projekt modelliert eine Anwendung zur Verwaltung persönlicher Kontakte — Name, Adresse, Telefonnummer — für eine beliebige Anzahl von
Personen. Die Kontakte werden im Adressbuch sowohl nach den Namen als
auch nach den Telefonnummern indiziert. Die uns interessierenden Klassen
sind Adressbuch und Kontakt, während AdressbuchDemo ein Adressbuch
mit einigen Daten simuliert und AdressbuchGUI eine grafische Benutzerschnittstelle zur Ein- und Ausgabe der Adressen bereitstellt.
Den Quelltext der Klasse AdressbuchGUI können Sie später ruhig studieren, um mit der Erstellung einer GUI Erfahrung zu sammeln. Eine schöne
Einführung bietet die Internetseite:
http://java.sun.com/docs/books/tutorial/uiswing/index.html.
Direkte Informationen zu Tabbed Panes — ein spezieller Fenstertyp — erhalten Sie unter:
http://java.sun.com/docs/books/tutorial/uiswing/components/tabbedpane.html
Um mit der GUI-Erstellung vertrauter zu werden, können Sie auch diese
Klasse nutzen, um Veränderungen bzw. Erweiterungen vorzunehmen, um die
Auswirkungen direkt sehen zu können.
Doch sehen wir uns nun die Klasse Adressbuch an.
Neue Kontakte können mit der Methode neuerKontakt eingetragen werden.
Dabei wird angenommen, dass der Kontakt wirklich neu ist und nicht einen
bereits bestehenden Kontakt ändern soll. Für eine Änderung steht die Me114
thode aendereKontakt zur Verfügung, die einen alten Eintrag entfernt und
durch einen neuen ersetzt. Das Adressbuch bietet zwei Möglichkeiten zum
Abfragen von Kontakten: Die Methode gibKontakt nimmt einen Namen
oder eine Telefonnummer als Suchschlüssel und liefert den passenden Kontakt dazu. Die Methode suche liefert ein Array aller Kontakte, deren Name
oder Telefonnummer mit einem gegebenen Präfix beginnt. Beispielsweise liefert das Präfix 08459 alle telefonnummern, deren Vorwahl mit diesen Ziffern
beginnt.
9.2
Defensive Programmierung
Wenn wir die Klasse Adressbuch eingehender betrachten, dann sehen wir,
dass diese Klasse vollständig im Vertrauen auf eine sinnvolle Benutzung durch
Anwender geschrieben wurde. Wenn wir beispielsweise ein neues Objekt eines
Adressbuchs erzeugen und für dieses Objekt die Methode entferneKontakt
mit irgendeinem Schlüssel aufrufen, dann gibt es die Fehlermeldung
java.lang.NullPointerException.
Das Problem in dieser Methode liegt darin, dass angenommen wird, dass
der gegebene Schlüssel ein gültiger Schlüssel innerhalb des Adressbuchs ist.
Wenn für den Schlüssel aber kein zugeordnetes Objekt existiert, dann enthält
die Variable kontakt den Wert null und es kommt zu einem Laufzeitfehler.
Im Normalfall wird dann das Programm beendet, bevor das Programm seine
Aufgabe erfüllt hat.
Es ist nun relativ einfach, eine NullPointerException in entferneKontakt
zu verhindern. Das folgende Listing zeigt dies:
/**
* Entferne den Eintrag mit dem gegebenen Schlüssel aus
* diesem Adressbuch. Bei einem unbekannten Schlüssel
* tue nichts.
* @param schlüssel einer der Schlüssel des Eintrags,
*
der entfernt werden soll.
*/
public void entferneKontakt(String schluessel)
{
if(schluesselBekannt(schluessel)) {
Kontakt kontakt = (Kontakt) buch.get(schluessel);
buch.remove(kontakt.gibName());
115
buch.remove(kontakt.gibTelefon());
anzahlEintraege--;
}
}
mit
public boolean schluesselBekannt(String schluessel)
{
return buch.containsKey(schluessel);
}
Wenn wir die anderen Methoden der Klasse Adressbuch untersuchen, dann
fallen uns weitere Stellen für ähnliche Verbesserungen auf:
• Die Methode neuerKontakt sollte prüfen, dass ihr Parameter nicht den
Wert null hat.
• Die Methode aendereKontakt sollte prüfen, dass der alte Schlüssel
bekannt ist und der Parameter nicht den Wert null hat.
• Die Methode suche sollte prüfen, dass der Schlüssel nicht null ist.
Diese Änderungen können Sie sich im Projekt Adressbuch-V2G näher ansehen.
9.3
Fehlermeldung
Da obige Probleme entweder durch Fehleingaben des Benutzers oder durch
falsche Programmierung von Klassen entstehen, die die Dienste eines Adressbuchs
benutzen, wäre es daher wünschenswert, wenn wir die Verursacher eines Fehlers entsprechend informieren würden.
Die offensichtlichste Möglichkeit ist, den Benutzer der Anwendung zu benachrichtigen, entweder durch Ausgabe einer Fehlermeldung über System.out.println
oder das Öffnen eines Fensters mit einem Fehlertext. Die Hauptprobleme mit
diesem Ansatz sind:
• Falls die Anwendung unabhängig von einem menschlichen Benutzer
abläuft, bleibt die Fehlermeldung völlig unbeachtet.
116
• Oftmals kann ein Benutzer nichts gegen einen auftretenden Fehler unternehmen, da er in den Programmablauf nicht eingreifen kann.
Eine weitere Möglichkeit ist, wenn ein Dienstprogramm (z.B. Adressbuch)
den Fehler an seinen Klienten (z.B. AdressbuchGUI) meldet. Hierfür gibt es
zwei Wege:
• Ein Dienstleister kann den Ergebniswert einer Methode benutzen, um
entweder den Erfolg oder den Misserfolg des Methodenaufrufs zu signalisieren.
• Ein Dienstleister kann in der Methode eine Exception werfen, wenn
etwas schief geht. Dies werden wir uns im nächsten Abschnitt näher
ansehen.
Beide Techniken haben den Vorteil, dass sie den Programmierer des Klienten auffordern, sich über das mögliche Scheitern eines Aufrufs Gedanken zu
machen. Allerdings hält nur eine Exception einen Klienten aktiv davon ab,
einen fehlerhaften Aufruf zu ignorieren.
Der erste Ansatz ist im Prinzip sehr leicht durchzuführen, falls die Methode
den Ergebnistyp void hat. Man kann dann den Ergebnistyp void durch
boolean ersetzen und je nach Erfolg bzw. Misserfolg des Methodenaufrufs
true bzw. false zurückgeben.
Wenn eine Methode des Dienstleisters jedoch bereits einen Ergebnistyp ungleich void hat, kann man im Fehlerfall einen speziellen Wert zurückgeben,
der einen Fehler anzeigt, z.B. falls eine Instanz einer Klasse zurückgegeben
werden soll, wird im Falle eines Misserfolgs der Wert null zurückgeliefert.
Bei primitiven Datentypen kann oftmals ein Wert außerhalb eines zulässigen
Wertebereichs einen Fehler anzeigen, z.B. der Wert −1, falls eine Position
in einer Liste zurückgeliefert werden soll. Sind jedoch alle Werte zulässig,
dann kann der Fehlerfall nur durch das Auslösen einer Exception angezeigt
werden. Diese Vorgehensweise bietet jedoch noch weitere Vorteile:
• Es gibt keine Möglichkeit, den Klienten explizit zum Überprüfen des
Ergebniswertes aufzufordern. Deshalb kann ein Klient im Fehlerfall weiterarbeiten als wäre nichts geschehen mit evtl. schlimmen Folgen für
den weiteren Programmablauf.
• Meist gibt es unterschiedliche Fehlerfälle, die bei Rückgabewerten kaum
zu unterscheiden sind. Im folgenden Beispiel kann ein Rückgabewert
117
null anzeigen, dass der übergebene Parameter bereits null war oder
dass es zu einem Schlüssel keinen Eintrag gibt. Im ersteren Fall liegt
höchstwahrscheinlich ein Programmierfehler vor, während der zweite
Fall evtl. völlig o.k. ist.
public Kontakt gibKontakt(String schluessel)
{
if (schluessel == null) return null;
if (schluesselBekannt(schluessel)) {
return (Kontakt) buch.get(schluessel);
}
else {
return null;
}
}
9.4
Prinzipien der Ausnahmebehandlung
Das Werfen einer Exception ist die effektivste Möglichkeit über den Misserfolg eines Methodenaufrufs zu informieren, insbesondere da diese nicht einfach ignoriert werden kann. Wenn der Klient eine Exception nicht behandelt,
dann wird die Anwendung automatisch und unmittelbar beendet. Zudem
kann der Exception-Mechanismus unabhängig von bereits bestehenden Ergebnistypen und mit Differenzierungsmöglichkeiten gemäß Fehlertypen verwendet werden.
9.4.1
Das Auslösen einer Exception
Das folgende Listing zeigt, wie eine Exception mit einer throw-Anweisung
geworfen wird:
118
public Kontakt gibKontakt(String schluessel)
{
if (schluessel == null) {
throw new NullPointerException(
"Parameter in gibKontakt ist null");
}
else {
return (Kontakt) buch.get(schluessel);
}
}
Das Auslösen einer Exception besteht aus zwei Schritten: Zuerst wird ein
Exception-Objekt erzeugt (im Beispiel ein Objekt der Klasse NullPointerException),
und dann wird dieses Exception-Objekt mit dem Schlüsselwort throw ‘geworfen’. Diese beiden Schritte werden üblicherweise in einer einzigen Anweisung zusammengefasst. Bei der Erzeugung eines Exception-Objekts kann ein
Fehlertext an den Konstruktor übergeben werden. Diese Zeichenkette kann
später vom Empfänger der Exception wieder über die Methoden getMessage
oder toString abgefragt werden.
9.4.2
Exception-Klassen
Eine Exception ist immer eine Instanz einer Klasse aus einer speziellen Vererbungshierarchie. Wir können neue Exception-Typen definieren, indem wir
Subklassen dieser Hierarchie erzeugen (siehe folgende Abbildung).
119
Genau genommen ist jede Exception-Klasse eine Subklasse der Klasse Throwable,
die im Paket java.lang definiert ist. Üblicherweise definieren wir eigene
Exception-Klassen als Subklasse von Exception, die ebenfalls in java.lang
definiert ist6 . Desweiteren definiert das Paket java.lang eine Reihe von
häufig auftretenden Exception-Klassen, von denen wir einige im Laufe dieser
Vorlesung bereits kennen gelernt haben, z.B. IndexOutOfBoundsException,
NullPointerException.
Java unterteilt die Exceptions in zwei Kategorien: geprüfte Exceptions (checked
exception) und ungeprüfte Exceptions (unchecked exception). Alle Subklassen
der Klasse RuntimeException definieren ungeprüfte Exceptions, alle anderen
Subklassen von Exception geprüfte.
Geprüfte Exception sind für Fälle gedacht, in denen ein Klient mit dem Fehlschlagen einer Operation rechnen muss (z.B. Festplatte ist voll bein Schreiben
in eine Datei). In diesen Fällen ist der Klient gezwungen, den Erfolg einer
Operation zu überprüfen. Ungeprüfte Exceptions sind für Fälle gedacht, die
im normalen Betrieb nicht auftreten sollten, wie z.B. Programmierfehler. Hier
ist der Klient nicht gezwungen eine Maßnahme zu ergreifen.
6
Subklassen von Error sind für Fehler des Laufzeitsystems vorgesehen und nicht für
Fehler auf die ein Programmierer Einfluss hat.
120
Leider gibt es keine klar definierten Regeln, aus welcher Kategorie eine Exception im jeweiligen Fall ausgelöst werden soll. Folgende allgemeine Hinweise
sollen helfen:
• Ungeprüfte Exceptions sollten in den Fällen verwendet werden, die zu
einem Programmabbruch führen sollen, da ein logischer Fehler vorliegt,
der die weitere Ausführung des Programms unmöglich macht.
• Geprüfte Exceptions sollten in den Fällen verwendet werden, in denen
das Problem sinnvoll behandelt werden kann, z.B. Datei zum Einlesen kann nicht geöffnet werden, da nicht vorhanden → Benutzer nach
anderem Dateinamen fragen.
Der Grund für die Unterscheidung liegt darin, dass bei geprüften Exceptions
der Klient eine entsprechende Maßnahme vorsehen muss, während dies bei
einer ungeprüften Exception nicht erforderlich ist.
9.5
Die Auswirkungen einer Exception
Wenn eine Exception ausgelöst wird, wird die Ausführung dieser Methode
sofort beendet, d.h. sie wird nicht bis zum Ende des Methodenrumpfes ausgeführt. Eine Konsequenz daraus ist, dass eine Methode mit einem anderen
Ergebnistyp als void kein Ergebnis zurückliefert, wenn eine Exception ausgelöst wurde.
Die Auswirkungen auf die aufrufende Methode sind komplexer. Insbesondere
hängen sie davon ab, ob an dieser Stelle Anweisungen geschrieben werden,
die die Exception abfangen7 .
9.5.1
Auswirkungen bei ungeprüften Exceptions
Betrachten wir folgenden konstruierten Aufruf von gibKontakt:
Kontakt kontakt = adressen.gibKontakt(null);
Gemäß dem Listing auf S. 119 wird beim Aufruf der Methode gibKontakt
mit dem Parameter null eine NullPointerException geworfen. Da diese
7
Bei geprüften Exceptions müssen Maßnahmen vorgesehen sein. Dies überprüft der
Compiler.
121
nicht abgefangen wird — dies sehen wir uns im nächsten Abschnitt an, da
dies bei geprüften Exceptions zwingend erforderlich ist — wird der Variablen
kontakt kein Wert zugewiesen und das Programm wird mit dem Hinweis,
dass eine NullPointerException aufgetreten ist, sofort beendet.
Exceptions können nicht nur in Methoden, sondern natürlich auch in Konstruktoren geworfen werden. Im folgenden Beispiel wird die Erzeugung eines
Objekts mit ungültigen Parametern verhindert:
public Kontakt(String name, String telefon, String adresse)
{
// name und telefon dürfen nicht gleichzeitig null sein
if(name == null && telefon == null) {
throw new IllegalStateException(
"name und telefon dürfen nicht beide leer sein");
}
// Leere Strings verwenden, wenn einer der Parameter null ist.
if(name == null) {
name = "";
}
if(telefon == null) {
telefon = "";
}
if(adresse == null) {
adresse = "";
}
this.name = name.trim();
this.telefon = telefon.trim();
this.adresse = adresse.trim();
}
9.5.2
Fehlerbehandlung bei geprüften Exceptions
Bei geprüften Exceptions fordert der Compiler, dass die Methode, die eine
geprüfte Exception wirft, dies im Kopf der Methode deklariert. Beispielsweise
würde eine Methode, die die geprüfte Exception IOException aus dem Paket
java.io auslösen kann, folgendermaßen aussehen:
122
public void speicherInDatei(String dateiname) throws IOException
{
...
if (Fehler aufgetreten) {
throw new IOException("Kann dies und das nicht tun");
}
...
}
Dadurch wird der Benutzer einer solchen Methode informiert, dass er bei Aufruf dieser Methode eine Fehlerbehandlung für diese Exception durchführen
muss.
Exceptions fangen Die zweite Anforderung ist, dass der Aufrufer einer
Methode, die eine geprüfte Exception werfen kann, Maßnahmen für den Umgang mit dieser Exception ergreifen muss. Dies geschieht durch einen so genannten Exception-Handler in Form eines try-Blocks. Die meisten try-Blöcke
haben die folgende Form:
try {
eine oder mehrere geschützte Anweisungen
hier wird auch die Methode aufgerufen, die eine
geprüfte Exception werfen kann
}
catch(Exception e) {
die Exception melden und
evtl. Reparaturmaßnahmen treffen
}
Das folgende Beispiel zeigt den try-Block einer Methode, die den Inhalt eines
Adressbuchs aus einer Datei einliest. Der Benutzer wird zuvor in geeigneter
Weise nach dem Namen der Datei gefragt und anschließend wird die Methode leseAusDatei des Adressbuchs aufgerufen, um die Kontakte aus dieser
Datei ins Adressbuch einzulesen. Weil der Leseprozess mit einer geprüften
Exception, nämlich IOException, fehlschlagen kann, muss der Aufruf von
leseAusDatei durch einen try-Block umklammert werden. Die abschließende catch-Klausel fängt die evtl. geworfenen Exceptions auf und veranlasst
entsprechende Maßnahmen.
String dateiname = null;
123
boolean = erfolgreich = false;
dateiname = durch Benutzereingabe;
try {
adressbuch.leseAusDatei(dateiname);
erfolgreich = true;
}
catch(Exception e) {
System.out.println("Lesen aus " + dateiname +
" schlug fehl: " + e);
}
weitere Anweisungen
Wird beim Aufruf von leseAusDatei eine Exception geworfen, so wird der
normale Programmfluss an dieser Stelle sofort unterbrochen, d.h. die Anweisung erfolgreich = true; wird nicht ausgeführt, und die Programmausführung wird im zugehörigen catch-Block fortgeführt. Anschließend wird
das Programm nach dem catch-Block fortgesetzt → ‘weitere Anweisungen’.
Wird beim Aufruf von leseAusDatei keine Exception geworfen, so läuft das
Programm innerhalb des try-Blocks regulär bis zum Ende weiter und der
catch-Block wird übersprungen und es wird mit den ‘weiteren Anweisungen’
fortgesetzt.
Beim obigen Programm wurden zwar alle formalen Bedingungen erfüllt, d.h.
die Exception wurde in einem try-Block abgefangen und es wurde eine Fehlerbehandlung im catch-Block definiert. Nichtsdestotrotz ist die alleinige Fehlermeldung, dass das Lesen fehl schlug nicht ausreichend, da ja keine Kontakte
eingelesen werden konnten. Oftmals ist es sinnvoll, die fehlgeschlagene Aktion
zu wiederholen, z.B. mit einem anderen Dateinamen. Das folgende Beispiel
zeigt eine verbesserte Version:
String dateiname = null;
boolean = erfolgreich = false;
int versuche = 0;
do {
dateiname = durch Benutzereingabe;
try {
adressbuch.leseAusDatei(dateiname);
124
erfolgreich = true;
}
catch(Exception e) {
System.out.println("Lesen aus " + dateiname +
" schlug fehl: " + e);
++versuche;
}
} while (!erfolgreich && versuche < MAX_VERSUCHE);
if (!erfolgreich) {
Das Problem melden und aufgeben
}
weitere Anweisungen
Wir lernen hier eine neue Form der while-Schleife kennen, nämlich die dowhile-Schleife. Hier werden mindestens einmal die Anweisungen im do-Block
ausgeführt und dann erst wird getestet, ob dieser Block nochmals auszuführen
ist. Die allgemeine Syntax lautet:
do {
Anweisungen
} while (logischer Ausdruck);
Wichtig bei obigem Beispiel ist, dass der Reparaturversuch nicht beliebig oft
durchgeführt wird, da sonst ein Programm evtl. nie terminiert.
Im Prinzip können auch ungeprüfte Exceptions mit der throws-Klausel angezeigt und auf die obige Art und Weise gefangen werden, jedoch ist dies
nicht erforderlich. Meistens lassen sich auch keine sinnvollen Maßnahmen
zur Fehlerbehebung durchführen, so dass die Fehlerausgabe und der Programmabbruch die einzige sinnvolle Möglichkeit darstellt.
Werfen und Fangen mehrerer Exceptions Manchmal wirft eine Methode mehr als einen Exception-Typ, um verschiedene Fehlersituationen zu
signalisieren. Wenn es sich dabei um geprüfte Exceptions handelt, dann
müssen diese vollständig in der throws-Klausel aufgeführt werden, durch
Komma getrennt, wie z.B.:
125
public void verarbeiten()
throws EOFException, FileNotFoundException
Es müssen alle überprüften Exceptions behandelt werden. Ein try-Block kann
deshalb mehrere catch-Blöcke enthalten:
try {
...
objekt.verarbeite();
...
}
catch(EOFException e) {
// angemessene Behandlung einer Dateiende-Exception (end-of-file)
...
}
catch(FileNotFoundException e) {
// angemessene Behandlung für eine nicht gefundene Datei
...
}
Wenn eine Exception durch eine der geschützten Anweisungen im try-Block
ausgelöst wird, dann werden die catch-Blöcke in der textuellen Reihenfolge
nach einer passenden Exception durchsucht. Sobald das Ende des ersten passenden catch-Blocks erreicht ist, wird die Programmausführung nach dem
letzten catch-Block fortgesetzt.
Mit Polymorphie kann bei Bedarf vermieden werden, dass mehrere catchBlöcke angegeben werden müssen, z.B.
try {
...
objekt.verarbeite();
...
}
catch(Exception e) {
// angemessene Behandlung aller Exceptions
...
}
126
Das Propagieren einer Exception Bisher haben wir gesagt, dass eine Methode xyz, die eine Methode mit einer geprüften Exception aufruft,
über einen try- und catch-Block für eine angemessene Exception-Behandlung
verantwortlich ist. In Java besteht jedoch auch die Möglichkeit, dass die
Exception-Behandlung weitergereicht werden kann. Eine Methode xyz propagiert eine Exception einfach, indem kein try- und catch-Block für eine
Fehlerbehandlung implementiert wird, sondern im eigenen Methodenkopf eine throws-Klausel angegeben wird, obwohl die Methode xyz die Exception
selbst nicht auslöst. Damit werden diejenigen Methoden für die ExceptionBehandlung verantwortlich gemacht, die die Methode xyz aufrufen.
Ein Propagieren ist üblich, wenn die aufrufende Methode die Exception nicht
vernüftig behandeln kann, dies jedoch auf höherer Ebene möglich ist.
Der finally-Block Die Exception-Behandlung kann einen dritten Abschnitt
enthalten, der optional ist. Mit einem finally-Block können Anweisungen gegeben werden, die auf jeden Fall ausgeführt werden, unabhängig davon, ob
eine Exception ausgelöst wird oder nicht. Das heißt, wird keine Exception
ausgelöst, dann werden die Anweisungen des try-Blocks bis zum Ende abgearbeitet und dann die Anweisungen des finally-Blocks ausgeführt. Tritt
andererseits eine Exception auf, dann wird der try-Block sofort verlassen, es
wird ein passender catch-Block gesucht und ausgeführt und dann werden die
Anweisungen des finally-Blocks abgearbeitet.
Auf den ersten Blick erscheint ein finally-Block redundant, da es auf den
ersten Blick keinen Unterschied zwischen diesem Programmstück
try {
eine oder mehrere geschützte Anweisungen
}
catch(Exception e) {
die Exception melden und evtl. wieder aufsetzen
}
finally {
Anweisungen, die ausgeführt werden, unabhängig
ob eine Exception auftritt oder nicht
}
und diesem Programmstück gibt:
127
try {
eine oder mehrere geschützte Anweisungen
}
catch(Exception e) {
die Exception melden und evtl. wieder aufsetzen
}
Anweisungen, die ausgeführt werden, unabhängig
ob eine Exception auftritt oder nicht
Tatsächlich gibt es sogar zwei Situationen, in denen diese beiden Quelltextabschnitte sich unterschiedlich verhalten:
• Ein finally-Block wird auch dann ausgeführt, wenn eine return-Anweisung
im try-Block oder im catch-Block ausgeführt wird.
• Wenn im try-Block eine Exception ausgelöst wird, die nicht abgefangen wird, dann wird dennoch die finally-Klausel ausgeführt. Entweder
weil eine ungeprüfte Exception ausgelöst wurde oder weil eine geprüfte
Exception weitergereicht wird. Auch dann wird der finally-Block ausgeführt. Es kann also einen try-Block ohne catch-Block geben, falls die
Exception weitergereicht wird und es einen finally-Block gibt.
9.6
Definieren von neuen Exception-Klassen
Falls die vordefinierten Exception-Klassen die Ursache eines Problems nicht
ausreichend beschreiben, können mit Hilfe von Vererbung eigene ExceptionKlassen definiert werden. Eigene Klassen für geprüfte Exceptions werden
als Subklassen von Exception oder als Subklasse von anderen geprüften
Exception-Klassen definiert. Eigene ungeprüfte Exceptions sind Subklassen
in der Hierarchie von RuntimeException.
Einer der Hauptgründe für das Schreiben eigener Exception-Klassen, ist die
Möglichkeit dem Exception-Objekt e problemspezifische Informationen mitzugeben, die die Diagnose beim Wiederaufsetzen erleichtern. Beispielsweise
erwartet eine Methode wie aendereKontakt einen Parameter schluessel,
der auf einen existierenden Eintrag zeigen sollte. Wenn kein passender Eintrag existiert, dann ist es nützlich zu wissen, welcher Schlüssel diesen Fehler
verursacht hat. Über den Konstruktor besteht die Möglichkeit, die notwendigen Informationen für eine Fehlerbeschreibung dem Exception-Objekt zu
übergeben In unserem Beispiel ist dies der fehlerhafte Schlüssel. Mit Hilfe
128
entsprechender Methoden kann dann eine sinnvolle Fehlerbehandlung durchgeführt werden:
public class KeinPassenderSchluesselException extends Exception
{
// Der Schlüssel ohne passende Kontakdaten
private String schluessel;
// Speichere im Konstruktor falschen Schlüssel
public KeinPassenerSchluesselException(String schluessel)
{
this.schluessel = schluessel;
}
// gib fehlerhaften Schlüssel
public String gibSchlussel()
{
return schluessel;
}
// liefere Diagosetext mit fehlerhaften Schlüssel
public String toString()
{
return "Kein passender Kontakt für ‘" +
schluessel + "’ gefunden";
}
}
9.7
Zusammenfassung
• Exception: Eine Exception ist ein Objekt, das Informationen über
einen Programmfehler enthält. Eine Exception wird ausgelöst, um zu
signalisieren, dass ein Fehler aufgetreten ist.
• ungeprüfte Exception: Ungeprüfte Exceptions sind Exception-Typen,
bei deren Verwendung der Compiler keine zusätzlichen Überprüfungen
unternimmt.
• geprüfte Exception: Geprüfte Exceptions sind Exception-Typen, bei
deren Verwendung der Compiler zusätzliche Überprüfungen durchführt
129
und einfordert, dass diese über eine throws-Klausel angezeigt und über
einen try/catch-Block abgefangen werden.
• Exception-Handler: Ein Programmabschnitt, der Anweisungen schützt,
in denen eine Exception ausgelöst werden kann, wird Exception-Handler
genannt. Er definiert Anweisungen zur Meldung und/oder zum Wiederaufsetzen nach einer aufgetretenen Exception.
130
10
Ein- und Ausgabe von Daten und Texten
In Java gibt es zwei prinzipielle Möglichkeiten, Daten einzulesen bzw. auszugeben:
1. Die für uns Menschen intuitiveste Möglichkeit ist, Daten in textueller
Form, sei es als Characters oder sei es als Zeichenketten einzulesen bzw.
auszugeben.
2. Daten werden in der jeweiligen Byte-Repräsentation ausgegeben bzw.
eingelesen.
Am einfachsten kann man sich den Unterschied klarmachen, wenn man die
Zahl ‘18’ in eine Datei schreiben möchte. Bei der textuellen Repräsentation
werden die beiden Character ‘1’ und ‘8’ ausgeben, so dass die Datei auch
problemlos von Menschen gelesen werden kann. In der Byte-Repräsentation
werden die 4 Bytes, in denen jede Integerzahl repräsentiert wird, in die Datei
geschrieben, so dass diese Datei für Menschen nicht lesbar ist, da ein Editor
ja nicht weiß, ob hier Integers, Shorts oder Doubles gespeichert sind.
Textuell gespeicherte Daten haben zwar den Vorteil, dass sie für den Menschen leicht lesbar sind, jedoch müssen textuell gespeicherte Zahlen erst characterweise eingelesen und dann wieder zu Zahlen umgesetzt werden. Dies ist
natürlich ineffizienter, als wenn man direkt 4 Bytes in eine Integervariable
einliest.
Die erste Möglichkeit ist insbesondere dann interessant, wenn die gespeicherten Daten von Menschen gelesen werden sollen bzw. wenn die Daten direkt
von Menschen eingegeben werden, z.B. über eine Tastatur oder über ein
Menü einer grafischen Benutzeroberfläche.
Die API von Java enthält das Paket java.io, das eine Reihe von Klassen für die Unterstützung von plattformunabhängigen Eingabe-/AusgabeOperationen anbietet. Eine komplette Darstellung all dieser Klassen würde
den Rahmen dieser Vorlesung sprengen, so dass wir uns hier nur die wichtigsten näher ansehen werden. Ausführlichere Informationen bekommt man
über:
java.sun.com/docs/books/tutorial/essential/io/index.html
Viele der Klassen im Paket java.io fallen in eine von zwei Hauptkategorien:
Klassen für den Umgang mit Textdateien und Klassen für den Umgang mit
Binärdateien. Klassen für das Lesen von Textdaten sind abgeleitete Klassen
131
der abstrakten Klasse Reader, während Klassen für das Ausgeben von Textdaten abgeleitete Klassen der abstrakten Klasse Writer sind. Analog hierzu
sind Klassen für das Lesen von Binärdaten abgeleitete Klassen der abstrakten Klasse InputStream und Klassen für das Ausgeben von Binärdaten sind
von der abstrakten Klassen OutputStream abgeleitet.
Sehen wir uns nun zunächst die textuelle Ein- und Ausgabe etwas näher an.
10.1
Einlesen von textuellen Daten aus einer Datei
Zum Lesen von Daten aus einer Datei sind drei Schritte nötig:
1. die Datei öffnen
2. die Daten lesen
3. die Datei schließen
Es liegt in der Natur von Dateieingaben, dass jeder dieser Schritte fehlschlagen kann. Deshalb muss bei jedem dieser Schritte damit gerechnet werden,
dass eine Exception ausgelöst wird.
Um aus einer Datei zu lesen, wird üblicherweise ein Objekt der Klasse FileReader
erzeugt, das als Konstruktorparameter den Namen der einzulesenden Datei
bekommt. Die Erzeugung eines FileReader-Objekts führt dazu, dass die
externe Datei geöffnet wird, so dass nun aus dieser Datei gelesen werden
kann. Wenn das Öffnen der Datei fehlschlägt — sei es, dass die Datei nicht
existiert oder sei es, dass keine Leserechte vorhanden sind — so wird eine
FileNotFoundException geworfen.
Wenn eine Datei erfolgreich geöffnet wurde, so kann die read-Methode eines
FileReader für das Lesen von Zeichen verwendet werden. Jeder Leseversuch
kann fehlschlagen, wobei hier eine IOException geworfen wird.
Nachdem alle Daten gelesen wurden, muss die Datei explizit geschlossen werden, so dass dann weitere Programme einen Lese- oder Schreibzugriff auf
diese Datei vornehmen können. Auch das Schließen kann in seltenen Fällen
fehlschlagen, was ebenfalls durch eine IOException angezeigt wird.
Der folgende Quelltext zeigt ein prototypisches Beispiel für das Einlesen einer
Textdatei:
132
// liest anzahl Zeichen aus der Datei dateiname ein
public char[] einlesen(String dateiname, int anzahl)
{
char[] puffer = new char[anzahl];
FileReader reader = null;
int zeichen;
try {
reader = new FileReader(dateiname);
for (int i = 0; i < puffer.length; i++) {
zeichen = reader.read();
if (zeichen == -1) {
/* Dateiende erreicht --> Schleife verlassen
puffer[i] = 0;
break;
}
puffer[i] = (char) zeichen;
}
catch(FileNotFoundException e) {
System.out.println("Datei " + dateiname +
" nicht gefunden");
}
catch(IOException e) {
System.out.println("Fehler beim Lesen der Datei: "
+ dateiname);
}
finally {
// falls reader == null nichts zu tun
if (reader != null) {
try {
reader.close();
}
catch(IOException e) {
System.out.println("Fehler beim Schließen der Datei: "
+ dateiname);
}
}
}
return puffer;
}
133
In Wirklichkeit gibt die Methode read einen Integer-Wert zurück, da die
Zeichen im Unicode mit 4 Byte gespeichert sein können. Wenn wir — wie im
Normalfall — nur Ascii-Zeichen einlesen, dann können wir den Integer-Wert
direkt einer Zeichenvariablen zuweisen, da in den unteren 8 Bit Ascii-Code
und Unicode identisch sind.
Mit dem speziellen Wert -1 wird angezeigt, dass das Dateiende erreicht ist. In
diesem Fall können wir mit der break-Anweisung die nächstliegende Schleife
— in unserem Fall die for-Schleife — verlassen. Die Programmausführung
wird in diesem Fall am Ende der Schleife fortgesetzt.
Eine Variante der obigen Methode könnte die Exception FileNotFoundException
weitergeben, so dass in der aufrufenden Methode ein anderer Dateiname verwendet werden könnte:
// liest anzahl Zeichen aus der Datei dateiname ein
public char[] einlesen(String dateiname, int anzahl)
throws FileNotFoundException
{
char[] puffer = new char[anzahl];
FileReader reader = null;
int zeichen;
reader = new FileReader(dateiname);
try {
for (int i = 0; i < puffer.length; i++) {
int zeichen = reader.read();
if (zeichen == -1) {
/* Dateiende erreicht --> Schleife verlassen
puffer[i] = 0;
break;
}
puffer[i] = (char) zeichen;
}
catch(IOException e) {
System.out.println("Fehler beim Lesen der Datei: "
+ dateiname);
}
finally {
try {
134
reader.close();
}
catch(IOException e) {
System.out.println("Fehler beim Schließen der Datei: "
+ dateiname);
}
}
return puffer;
}
Java bietet auch eine direkte Möglichkeit abzuprüfen, ob eine Datei vorhanden und lesbar ist. Dies erreicht man, indem man ein Objekt der Klasse File
erzeugt, das dann die Methoden exists und canRead anbietet (siehe folgendes Beispiel). Außerdem kann auch über ein File-Objekt ein FileReader
geöffnet werden.
String dateiname = "blablabla";
File file = new File(dateiname);
FileReader reader = null;
if (file.exists() && file.canRead()) {
/* Datei vorhanden und lesbar
reader = new FileReader(file);
...
}
10.2
Schreiben von textuellen Daten in eine Datei
Wie unschwer vorstellbar, sind hier dieselben drei Schritte wie beim Lesen
nötig. Im Gegensatz zum Einlesen können mittels der Methode write auch
ganze Zeichenketten ausgegeben werden. Das folgende Beispiel stammt aus
dem Projekt Adressbuch-IO aus Kapitel11.
/**
* Speichere die Ergebnisse einer Suche im Adressbuch
* in der Datei ’Ergebnisse.txt’ im Projektordner.
* @param praefix Der Schlüssel-Präfix, mit dem gesucht
*
werden soll.
135
*/
public void speichereSuchergebnisse(String praefix) throws IOException
{
File ergebnisdatei = erzeugeAbsolutenDateinamen(DATEINAME);
Kontakt[] ergebnisse = buch.suche(praefix);
FileWriter writer = new FileWriter(ergebnisdatei);
for(int i = 0; i < ergebnisse.length; i++) {
writer.write(ergebnisse[i].toString());
writer.write(’\n’);
writer.write(’\n’);
}
writer.close();
}
Die Methode write kann also mit einzelnen Zeichen und mit Zeichenketten
aufgerufen werden. Die Fehlerbehandlung ist hier nur sehr rudimentär, d.h.
die möglichen Exceptions werden einfach weitergereicht.
10.3
Einlesen von Tastatureingaben und Ausgabe auf
den Bildschirm
Java bietet automatisch sogenannte Ströme zum Einlesen von Daten von einem Eingabegerät — üblicherweise Tastatur — und zum Ausgeben von Daten auf ein Ausgabegerät — üblicherweise Bildschirm. Diese beiden Ströme
sind System.in bzw. das uns bereits bekannte System.out.
Um nun beispielsweise von der Tastatur Daten einzulesen, muss ein Objekt
von InputStreamReader für den Datenstrom System.in erzeugt werden.
Im Prinzip kann man hiermit die Benutzereingaben mittels der Methode
read Zeichen für Zeichen einlesen, aber dies ist doch recht mühsam und
ineffizient. Einfacher ist es, wenn man für ein InputStreamReader-Objekt
ein BufferedReader-Objekt erzeugt, so dass mittels der Methode readline
ganze Zeilen eingelesen werden. Das folgende Beispiel aus dem Projekt Zuulbesser aus Kapitel07, das die Benutzerbefehle einliest, sollte hierfür einen
guten Eindruck geben.
public Befehl liefereBefehl()
{
String eingabezeile = "";
// für die gesamte Eingabezeile
136
String wort1;
String wort2;
System.out.print("> ");
// Eingabeaufforderung
BufferedReader eingabe =
new BufferedReader(new InputStreamReader(System.in));
try {
eingabezeile = eingabe.readLine();
}
catch(IOException e) {
System.out.println ("Fehler beim Einlesen: "
+ e.getMessage());
}
// aus der Eingabezeile Befehlswort und
// evt. Argument extrahieren
...
if(befehle.istBefehl(wort1))
return new Befehl(wort1, wort2);
else
return new Befehl(null, wort2);
}
Beachten Sie, dass bei der Objekterzeugung für InputStreamReader und
BufferedReader keine geprüften Exceptions geworfen werden.
Um das Einlesen von Daten aus einer Datei zu beschleunigen, kann ein
BufferedReader-Objekt auch für ein FileReader-Objekt erzeugt werden,
so dass nun größere Portionen auf einmal eingelesen werden können. Das
folgende Beispiel entstammt dem Projekt Adressbuch-IO aus Kapitel11.
/**
* Zeige die Ergebnisse des letzten Aufrufs von
* speichereSuchergebnisse. Da die Ausgabe auf der
* Konsole erfolgt, werden alle Probleme von dieser
* Methode unmittelbar gemeldet.
*/
public void zeigeSuchergebnisse()
{
137
File ergebnisdatei = erzeugeAbsolutenDateinamen(DATEINAME);
BufferedReader reader = null;
try {
reader = new BufferedReader(
new FileReader(ergebnisdatei));
System.out.println("Ergebnisse ...");
String zeile;
zeile = reader.readLine();
while(zeile != null) {
System.out.println(zeile);
zeile = reader.readLine();
}
System.out.println();
}
catch(FileNotFoundException e) {
System.out.println("Datei nicht gefunden: " +
ergebnisdatei);
}
catch(IOException e) {
System.out.println("Fehler beim Lesen der Datei: " +
ergebnisdatei);
}
finally {
if(reader != null) {
// Fangen jeder Exception, aber nicht viel zu tun
try {
reader.close();
}
catch(IOException e) {
System.out.println("Fehler beim Schließen: " +
ergebnisdatei);
}
}
}
}
Gibt die Methode readline den Wert null zurück, so ist das Dateiende
erreicht.
Analog kann über ein Objekt zu BufferedWriter, das zu einem Objekt
FileWriter erzeugt wird, eine effiziente Ausgabe erreicht werden, indem
138
mittels der Methode write direkt Zeichenketten geschrieben werden. Näheres
hierzu können Sie in der Java-Klassenbibliothek selbst nachlesen.
10.4
Binärdaten aus einer Datei lesen
Im Prinzip sind hier die gleichen Schritte durchzuführen, wie beim Lesen
von textuellen Daten aus Datei, nur dass hier für einen Dateinamen oder für
ein File-Objekt ein Objekt der Klasse FileInputStream erzeugt wird. Mit
Hilfe der read-Methoden von FileInputStream könnten nun zwar entweder
einzelne Bytes oder Arrays von Bytes eingelesen werden, aber die Byte-Daten
müssten dann per Programm in die entsprechenden Daten wie float, long
usw. umgewandelt werden.
Einfacher und direkt geschieht dies, wenn man für ein FileInputStreamObjekt ein ObjectInputStream-Objekt generiert. Dann können mit den entsprechenden read-Methoden die Daten binär eingelesen werden. Das folgende Beispiel, bei dem auf die Fehlerbehandlung verzichtet wurde, sollte einen
ausreichenden Eindruck bieten:
FileInputStream fis = new FileInputStream("MeineDatei");
ObjectInputStream ois = new ObjectInputStream(fis);
int i = ois.readInt();
String today = (String) ois.readObject();
Date date = (Date) ois.readObject();
float laenge = ois.readFloat();
ArrayList liste = (ArrayList) ois.read.Object();
ois.close();
fis.close();
Wie man sieht, können beliebige Objekte ja sogar ganze Listen auf einmal
eingelesen werden. Wichtig hier ist nur, dass die Daten in derselben Reihenfolge eingelesen werden, in der sie vorher rausgeschrieben wurden. Wie dies
auch für komplexe Objekte einfach realisiert werden kann, sehen wir uns nun
an.
139
10.5
Schreiben von Binärdaten in eine Datei
Analog wie beim Einlesen generiert man ein ObjectOutputStream-Objekt
für ein FileOutputStream-Objekt. Objekte beliebiger Klassen können mit
der Methode writeObject geschrieben werden, falls die Klasse das Interface
Serializable implementiert. Da dieses Interface keine Methoden deklariert,
ist dies ohne Aufwand möglich. Im Prinzip könnten die Daten, die wir oben
eingelesen haben wie folgt auf Datei geschrieben worden sein:
FileOutputStream fos = new FileOutputStream("MeineDatei");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeInt(277);
oos.writeObject("Hallo, wie geht es.");
oos.writeObject(new Date());
oos.writeFloat(43.77F);
oos.write.Object(eineBestimmeArrayList);
oos.close();
fos.close();
Damit das Schreiben der ArrayList auch korrekt funktioniert, müssen natürlich
auch alle Objekte in der ArrayList das Interface Serializable implementieren.
Dieses Kapitel konnte natürlich nur einen kleinen Einblick in die vielfältigen
Möglicheiten geben, in Java Daten zu lesen und auszugeben. Für weitergehende Informationen sei auf das Sun-Tutorial und auf die Beschreibung
der entsprechenden Klassen in der Java-Klassenbibliothek verwiesen. Dort
ist auch detailliert beschrieben, welche Exceptions jeweils auftreten können.
140
11
Entwurf von Algorithmen
Nachdem wir nun die grundlegenden Konstrukte der objektorientierten Programmiersprache Java kennen gelernt haben, wollen wir uns nun mit grundlegenden Algorithmen und Datenstrukturen befassen. Zum einen sollen Sie
dadurch in die Lage versetzt werden, beim Schreiben von Methoden aus einem Reservoir an Algorithmenmustern schöpfen zu können, um die geeignete
algorithmische Lösung für ein gegebenes Problem zu finden. Zum anderen
wird Ihnen die genauere Kenntnis von elementaren Datenstrukturen helfen,
die für einen konkreten Anwendungsfall geeignetsten Java-Bibliotheksklassen
auszuwählen und sinnvoll anzuwenden.
In diesem Kapitel werde ich nach einer kurzen intuitiven Definition des Begriffs ‘Algorithmus’ anhand von Beispielen einige typische Muster für Algorithmen vorstellen.
11.1
Intuitiver Algorithmenbegriff
Der Begriff des Algorithmus ist zentral für die Informatik. Wir werden hier
diesen Begriff nur von der intuitiven, d.h. nicht mathematisch formalisierten
Sichtweise betrachten.
Allgemein kann man Algorithmen als Vorschriften zur Ausführung einer
Tätigkeit charakterisieren. Die folgenden Algorithmen im intuitiven Sinn begegnen uns im täglichen Leben:
• Bedienungsanleitungen
• Bauanleitungen
• Kochrezepte
Aus diesen Beispielen lässt sich nun eine intuitive Begriffsbestimmung ableiten:
Ein Algorithmus ist eine präzise (d.h. in einer festgelegten
Sprache abgefasste) endliche Beschreibung eines allgemeinen Verfahrens unter Verwendung ausführbarer elementarer Verarbeitungsschritte.
141
Die Endlichkeit der Beschreibung bezieht sich auf die Tatsache, dass eine Algorithmenbeschreibung eine feste Länge haben muss, und nicht endlos weitergehen darf. Präzise heißt, dass eindeutig klar sein muss, was exakt gemacht
werden soll. Dies ist insbesondere bei einigen Bauanleitungen (leider) nicht
immer gegeben.
Folgende Eigenschaften charakterisieren einen Algorithmus:
• Ein Algorithmus heißt terminierend, wenn er (bei erlaubten Eingaben)
nach endlich vielen Schritten abbricht.
• Ein Algorithmus heißt deterministisch, wenn zu jedem Ausführungszeitpunkt
die Nachfolgeaktion eindeutig bestimmt ist.
• Ein Algorithmus heißt determiniert, wenn ein Algorithmus mit denselben Eingabegrößen immer dieselben Ergebnisse produziert.
Die Unterscheidung zwischen deterministisch und determiniert macht durchaus Sinn, da es Algorithmen gibt, die zwar nicht deterministisch, aber determiniert sind und umgekehrt.
Die folgende Berechnungsvorschrift ist ein Beispiel für einen determinierten,
allerdings nicht deterministischen Algorithmus:
1. nehmen Sie eine Zahl x ungleich 0
2. Entweder: Addieren Sie das Dreifach von x zu x und teilen das Ergebnis
durch x, d.h. ergebnis = (3x + x)/x
Oder: Substrahieren Sie 4 von x und substrahieren Sie das Ergebnis
von x, d.h. ergebnis = x − (x − 4)
3. geben Sie das Ergebnis aus Schritt 2. aus
Obwohl in Punkt 2. nicht eindeutig geregelt ist, welche Anweisung ausgeführt
werden soll, liefert der Algorithmus immer dasselbe Ergebnis, nämlich 4.
Das folgende Beispiel zeigt einen deterministischen Algorithmus, der nicht
determiniert ist:
1. nehmen Sie eine Zahl x ungleich 0
2. addieren Sie eine Zahl zwischen 0 und 10 zu x
142
3. multiplizieren sie das Ergebnis aus 2. mit 364
4. geben Sie das Ergebnis aus Schritt 3. aus
In unserer intuitiven Definition besteht ein Algorithmus aus elementaren
Verarbeitungsschritten, wobei die folgenden gängigsten Bausteine auch aus
Handlungsvorschriften des täglichen Lebens bekannt sein dürften:
• Elementare Operation: Die Basiselemente eines Algorithmus, die
ausgeführt werden, ohne näher aufgeschlüsselt zu werden.
Schneide Fleisch in kleine Würfel
• Sequentielle Ausführung: Das Hintereinanderausführen von Schritten:
Bringe das Wasser zum Kochen, dann gib das Paket Nudeln hinein,
schneide das Fleisch
• Bedingte Ausführung: Schritt, der nur ausgeführt wird, falls bestimmte Bedingung erfültt ist:
Wenn die Soße zu dünn ist, dann füge Mehl hinzu
• Schleife: Wiederholte Ausführung einer Tätigkeit, bis vorgegebene
Endbedingung erreicht ist:
Rühre solange bis die Soße braun ist
• Unterprogramm: Zusammenfassung von elementaren Verarbeitungsschritten. Insbesondere sinnvoll, um größere Probleme sukzessive in
kleinere Probleme zu zerlegen und Lösungen hierfür zu finden (schrittweise Verfeinerung):
Bereite Soße gemäß dem Rezept auf Seite 142
• Rekursion: Anwendung eines Prinzips auf kleinere Teilprobleme, bis
die Teilprobleme so einfach sind, dass sie problemlos gelöst werden
können:
Aufgabe: Schneide Stück Fleisch in 2cm große Stücke
Rekursion: Viertele das Fleich in vier gleich große Teile. Falls die
Stücke größer als 2 cm sind, verfahre mit den einzelnen Stücken wieder
genauso (bis die gewünschte Größe erreicht ist).
Um alle berechenbaren Algorithmen schreiben zu können, reichen die Konstrukte elementare Operation + Sequenz + Bedingte Ausführung + Schleife
aus. Jedoch sind auch andere Kombinationen ausreichend.
Doch wenden wir uns nun unterschiedlichen Algorithmenmustern zu.
143
11.2
Berechnung optimaler Lösungen
Oftmals ist die Berechnung einer beliebigen Lösung für ein Problem relativ einfach. Beispielsweise ist das Problem der Wechselgeldherausgabe einfach lösbar, indem man den zurückzugebenden Betrag einfach in Centstücken
ausgibt. Gesucht ist hier jedoch eine optimale Lösung, die möglichst wenige Geldstücke für einen bestimmten Betrag ausgibt. Diesem Problembereich
der Berechnung einer optimalen Lösung, wollen wir uns in diesem Abschnitt
widmen.
Um die unterschiedlichen Algorithmenmuster im direkten Vergleich sehen zu
können, werde ich diese an derselben Problemstellung demonstrieren, die nun
im nächsten Abschnitt kurz vorgestellt wird.
11.2.1
Beispielproblem
Ein im Bereich der Bioinformatik häufig auftretendes Problem ist die Berechnung der Ähnlichkeit zwischen zwei Proteinen. Jedes Protein besteht primär
aus einer Sequenz von Aminosäuren, wobei Ketten der Länge 100-800 üblich
sind. In Proteinen kommen 20 verschiedene Aminosäuren vor, die jeweils über
einen Großbuchstaben gekennzeichnet werden, z.B. A steht für Alanin. Ein
Protein kann somit über eine Folge von Buchstaben gekennzeichnet werden.
Inwieweit zwei gegebene Proteine ähnliche Struktur, d.h. eine ähnliche Sequenz, haben, ist insbesondere für den Pharmabereich interessant, da ähnliche
Proteine auch ähnliche Wirkungen haben. Ist nun für ein Protein eine medikamentöse Wirkung bekannt, so kann man davon ausgehen, dass ein strukturähnliches Protein eine ähnliche Wirkung hat. Dieses ist dann natürlich ein
interessanter Kandidat in der Medikamentenentwicklung. Um die Entwicklungskosten im Pharmabereich zu beschränken, ist es daher wichtig, einfach
interessante Proteine für die weitere Forschung zu identifizieren bzw. uninteressante Proteine einfach aussondern zu können.
Da einige Proteine ähnliche Eigenschaften haben, ist ein stures Auszählen der
identisch vorkommenden Buchstaben nicht sinnvoll, da z.B. das Protein mit
dem Buchstaben A problemlos ein Protein mit dem Buchstaben F ersetzen
kann, ohne die Wirkungsweise zu ändern. Während evtl. die Vertauschung
zweier anderer Proteine eine große Differenz in der Wirkungsweise impliziert.
Außerdem sind zwei unterschiedliche Proteine seltenst gleich lang, so dass
bei der Berechnung der Ähnlichkeit auch Löschungen und Einfügungen in
den Proteinketten beachtet werden müssen. Auch für diese Einfügungen und
144
Löschungen kann es Unterschiede für die verschiedenen Aminosäuren geben.
Das folgende Beispiel zeigt eine Zuordnung der beiden Proteine
ADLEAAMHRKDY und AEEAVMQHRAD.
A D L E A A M
H R K D Y
A
E E A V M Q H R A D
Wenn man nun davon annimmt, dass man ausgehend von der ersten Kette
die zweite Kette erhält, indem man in der ersten Kette Einfügungen (Spalte 8), Löschungen (Spalte 2 und 13) und Vertauschungen (Spalte 3, 6 und
11) vornimmt, so kann man die Ähnlichkeit zweier Ketten berechnen, indem
man die minimale Anzahl der Einfügungen, Löschungen und Vertauschungen berechnet, um eine Kette in die andere zu überführen. Zudem mus man
in diesem Fall noch betrachten, dass das Einfügen, Löschen und Vertauschen unterschiedlicher Aminosäuren unterschiedlich teuer ist, so dass man
die Ähnlichkeit zweier Ketten berechnet, indem man die minimalen Kosten
der Einfügungen, Löschungen und Vertauschungen berechnet, um eine Kette
in die andere zu überführen.
Die Berechnung dieser optimalen Lösung bzw. die Berechnung einer halbwegs
guten Lösung lässt sich mit den folgenden Algorithmenmustern durchführen.
11.2.2
Greedy-Algorithmen
Greedy steht für gierig. Das Prinzip gieriger Algorithmen ist es, in jedem
Teilschritt so viel wie möglich zu erreichen. Greedy-Algorithmen eignen sich
daher für Probleme, bei denen sich die Lösung schrittweise berechnen lässt.
Jedoch berechnen diese Algorithmentypen meist nicht die optimale Lösung,
sondern nur eine halbwegs gute. Da die Berechnung jedoch sehr effizient ist,
ist man oftmals damit zufrieden.
Die schrittweise Berechnungsmöglichkeit lässt sich an dem folgenden Beispiel gut ableiten. Hier ist die Ähnlichkeit der beiden Ketten AEABCDA
und EACCDABC zu berechnen. Um das Beispiel halbwegs übersichtlich zu
gestalten, beschränken wir uns auf die fünf Symbole A, B, C, D und E mit
den folgenden Kosten für Vertauschungen, Löschungen und Einfügungen.
145
A B C
A 0 2 1
B
0 4
C
0
D
E
D
4
1
4
0
E
2
6
3
3
0
Einf
2
3
2
2
5
Loesch
4
1
3
2
3
Die möglichen Lösungen lassen sich in dem folgendem Schema gut darstellen:
E
A
C
C
D
A
B
C
.
.
.
.
.
.
.
.
.
A
.
.
.
.
.
.
.
.
.
E
.
.
.
.
.
.
.
.
.
A
.
.
.
.
.
.
.
.
.
B
.
.
.
.
.
.
.
.
.
C
.
.
.
.
.
.
.
.
.
D
.
.
.
.
.
.
.
.
.
A
.
.
.
.
.
.
.
.
.
Jede Zuordnung der beiden Ketten lässt sich als Pfad im obigem Schema
darstellen, wobei man im Punkt links oben startet und im Punkt rechts
unten endet. Als mögliche Schritte sind Bewegungen nach rechts, nach unten
und ein Diagonalschritt zulässig. Ein Diagonalschritt ordnet die Buchstaben
in der entsprechenden Spalte und Zeile aneinder zu, während ein RechtsSchritt einer Löschung in der waagrechten Kette und ein Schritt nach unten
einer Einfügung entspricht.
In jedem Punkt hat man also drei Möglichkeiten, nämlich nach rechts, nach
unten und diagonal zu gehen. Von diesem drei Möglichkeiten wählt der
Greedy-Algorithmus jeweils die beste und iteriert diese Vorgehensweise solange bis er eine (suboptimale) Lösung erreicht hat.
Für unser Beispiel ergibt sich daher die folgende Zuordnung, wobei im Schema diejenigen Positionen die Kosten enthalten, die während der Berechnung
des Pfades erreicht wurden.
146
E
A
C
C
D
A
B
C
.
5
.
.
.
.
.
.
.
A
4
2
4
.
.
.
.
.
.
E
.
5
4
6
.
.
.
.
.
A
.
.
8
5
7
.
.
.
.
B
.
.
.
6
9/8
.
.
.
.
C
.
.
.
9
6
8
.
.
.
D
.
.
.
.
8
6
.
.
.
A
.
.
.
.
.
10
6
9
11
Das Problem der Geldrückgabe mit möglichst wenig Münzen, lässt sich mittels des Greedy-Algorithmus sogar optimal lösen (falls man 1, 2, 5, 10, 20
und 50 Centmünzen hat).
Man nimmt immer jeweils die größte Münze unter dem aktuellen Rückgabewert und ziehe sie von diesem Wert ab. Wiederhole dies, bis der Rückgabewert gleich Null ist.
11.2.3
Dynamische Programmierung
Dieser Algorithmentyp findet immer die optimale Lösung, indem er die Berechnung einer optimalen Lösung auf die Berechnung optimaler Lösungen von
kleineren Problemen zurückführt, die dann geeignet zur Lösung des größeren
Problems zusammengesetzt werden. Voraussetzung ist allerdings, dass eine
optimale Lösung eines Teilproblems auch Bestandteil der optimalen Lösung
eines größeren Problems ist.
Für das Problem der Berechnung der optimalen Zuordnung ist dies gegeben,
da die Berechnung eines optimalen Zuordnungspfades auf die Ergebnisse optimaler Subpfade zurückgeführt werden kann.
Anschaulich bedeutet dies: Um beispielsweise in optimaler Weise vom Anfangspunkt zum Endpunkt zu kommen, reicht es aus, wenn man in optimaler
Weise zu den mit x gekennzeichneten Punkten gelangt ist. Der gesuchte optimale Weg ergibt sich aus dem Pfad, der die minimalen Kosten besitzt, um
von einer x-Position zum Endpunkt zu kommen.
147
E
A
C
C
D
A
B
C
.
.
.
.
.
.
.
.
.
A
.
.
.
.
.
.
.
.
.
E
.
.
.
.
.
.
.
.
.
A
.
.
.
.
.
.
.
.
.
B
.
.
.
.
.
.
.
.
.
C
.
.
.
.
.
.
.
.
.
D
.
.
.
.
.
.
.
x
x
A
.
.
.
.
.
.
.
x
.
Dieses Prinzip wird rekursiv angewendet bis man initial den optimalen Pfad
der Länge 1 problemlos berechnen kann. Dies erreicht man, indem man im
Schema diese lokale Optimierung spaltenweise vornimmt, da man dann immer bereits die notwendigen Lösungen der Pfade besitzt, um zum aktuell
betrachteten Punkt zu gelangen.
E
A
C
C
D
A
B
C
A E
.--4--7
|\ \
5 2 4
| |
7 4 .
| |
9 6 .
| |
11 8 .
| |
13 10 .
| |
15 12 .
| |
18 15 .
| |
20 17 .
11.2.4
A
.
B
.
C
.
D
.
A
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Beste-Lösung-Zuerst
Diese Strategie ist eine Erweiterung des Greedy-Algorithmus. Im Gegensatz
dazu wird nicht lokal in jedem Schritt die dort jeweils beste Zwischenlösung
148
ausgwählt und iteriert, sondern es wird die zu einem Zeitpunkt beste globale
Zwischenlösung erweitert. Dies wird solange iteriert, bis die aktuell beste
Zwischenlösung auch eine Lösung des kompletten Problems darstellt.
Am einfachsten sieht man dies wiederum an unserem Zuordnungsbeispiel:
E
A
C
C
D
A
B
C
.
5
.
.
.
.
.
.
.
A
4
2
4
.
.
.
.
.
.
E
.
5
4
6
.
.
.
.
.
A
.
.
8
5
.
.
.
.
.
B
.
.
.
.
.
.
.
.
.
C
.
.
.
.
.
.
.
.
.
D
.
.
.
.
.
.
.
.
.
A
.
.
.
.
.
.
.
.
.
Obige Strategie findet auch immer die optimale Lösung, jedoch kann der Aufwand deutlich höher sein als bei der Dynamischen Programmierung. Dieser
Aufwand kann reduziert werden, falls man eine optimistische Abschätzung
der Restkosten berücksichtigt. Ist die Restkostenabschätzung nicht optimistisch, dann wird eventuell die optimale Lösung nicht gefunden.
11.3
Berechnung einer Lösung
Bei vielen Problemen gibt es nur eine Lösung bzw. ist man an einer beliebigen Lösung interessiert. Diese Problemklassen können gut mit den folgenden
Algorithmenmustern gelöst werden. Hier habe ich als prototypisches Beispiel
das Problem der Sortierung einer Liste bezüglich einer Vergleichsoperation
gewählt.
11.3.1
Teile-und-Herrsche
Das Prinzip ‘Teile-und-Herrsche’ basiert darauf, dass in einem Schritt ein
Teilproblem in mehrere kleinere (im Normallfall gleichartige) Aufgaben zerlegt wird. Diese Zerlegung wird rekursiv fortgesetzt, bis man trivial zu lösende
Probleme hat. Diese werden dann iterativ zur komplexen Lösung zusammengesetzt.
149
Dieses Muster lässt sich für die Sortierung wie folgt umsetzen:
public ArrayList mergeSort(ArrayList liste, Object compobj)
{
if (liste.size() <= 1) {
// triviales Problem:
//
Sortierung einer Liste der Länge 1
return liste;
}
// Teile Problem in kleinere gleichartige Probleme
ArrayList liste1 = new ArrayList();
ArrayList liste2 = new ArrayList();
for (int i = 0; i < (liste.size() / 2); i++) {
liste1.add(liste.get(i));
}
for (int i = liste.size() / 2; i < liste.size(); i++) {
liste2.add(liste.get(i));
}
// Löse die kleineren gleichartigen Probleme
mergeSort(liste1, compobj);
mergeSort(liste2, compobj);
// herrsche: konstruiere aus den Teillösungen
//
komplexe Lösung
ArrayList sortliste = new ArrayList();
while (liste1.size() != 0 && liste2.size() != 0) {
if (compobj.istGroesser(liste1.get(0), liste2.get(0))) {
// füge aktuell kleinstes Element in sortliste ein
sortliste.add(liste2.remove(0));
}
else {
// füge aktuell kleinstes Element in sortliste ein
sortliste.add(liste1.remove(0));
}
}
// Füge die verbliebene Restfolge in liste1 oder liste2
//
an die sortierte Liste sortliste an
if (liste1.size() > 0) {
sortliste.addAll(liste1);
150
}
else if (liste2.size() > 0) {
sortliste.addAll(liste2);
}
// gib sortierte Liste zurück
return sortliste;
}
Bei allgemeinen Sortierroutinen ist es natürlich wünschenswert, wenn man
beliebige Objekte mit beliebigen Vergleichsfunktionen sortieren kann. Da
man in Java keine Methode als Parameter angeben kann, wird die Sortierroutine in ein Object der Klasse Object verpackt, die bei Bedarf aufgerufen
wird. Für ein spezifisches Sortierproblem, können somit beliebige Vergleichsfunktionen verwendet werden.
11.3.2
Rekursion
Wie man sieht, kommt beim Algorithmenmuster ‘Teile-und-Herrsche’ auch
ein Rekursionsprinzip zur Anwendung. Die ‘klassische’ Rekursion jedoch berechnet aus einer Lösung der Dimensionalität n eine Lösung der Dimensionaliät n + 1. Dieses Prinzip wird im folgenden Abschnitt kurz skizziert. Hierbei
wird die Sortierung auf der übergebenen Liste durchgeführt, was eine deutliche Ersparnis im Speicherplatzverbrauch bedeutet. Allerdings benötigt diese
Version der Sortierung im Normalfall mehr Rechenzeit.
// Die
// der
public
{
if
Integer-Variable n gibt an, wieviele Elemente
Liste zu sortieren sind
void rekSort(ArrayList liste, int n, Object compobj)
(n > 1) {
// sortiere die ersten n-1 Elemente
rekSort(liste, n-1, compobj);
// sortiere n-tes Element in sortierte Liste der Länge n-1 ein
Object obj = liste.get(n-1);
for (int i = 0; i < n-1; i++) {
if (compobj.istGroesser(liste.get(i), obj)) {
// Objekt an Position n-1 löschen und
151
//
an Indexposition i einfügen
liste.remove(n-1);
liste.add(i, obj);
break;
}
}
}
}
11.3.3
Backtracking
Backtracking basiert auf einem Suchprozess nach einer Lösung. Dabei können
Sackgassen erreicht werden, so dass man in der Suche einen Schritt zurückgehen muss, um eine andere Suchrichtung einzuschlagen. Am anschaulichsten
lässt sich dieses Prinzip bei der Suche nach einem Ausgang in einem Labyrinth anwenden. Man geht solange gerade aus, bis man eine Wegegabelung
findet. Zunächst nimmt man die am weitest rechts liegende Abzweigung.
Dies wiederholt man solange bis man einen Ausgang erreicht hat oder in
einer Sackgasse landet. In letzterem Fall kehrt man zur letzten Abzweigung
zurück, und nimmt die nächste Abzweigung. Falls keine weitere Abzweigung
vorhanden ist, geht man weiter zurück. Diese Strategie verfolgt man solange
bis man in Freiheit ist.
Eine Java-Sortier-Methode auf der Basis des Backtracking-Prinzips könnte
wie folgt aussehen:
public ArrayList backtrackSort(ArrayList liste, Object compobj)
{
// Backtracking initialisieren
// neue Liste zur Aufnahme der sortierten Objekte
ArrayList sortliste = new ArrayList();
// Anzahl der Elemente in der sortierten Liste sortliste
int sortanz = 0;
// index des gerade betrachteten Elements der Liste liste
int listindex = 0;
boolean passend;
// Suchprozess starten
152
while (sortanz < liste.size()) {
passend = true;
// Testen, ob das neu einzusortierende Objekt passt oder
//
bereits einsortiert ist
for (int i = 0; i < sortindex; i++) {
if (compobj.istGroesser(sortliste.get(i), liste.get(listindex)) ||
sortliste.get(i) == liste.get(listindex)) {
passend = false;
break;
}
}
if (passend) {
// nächstes Element kann ans Ende der sortierten
//
Liste angefügt werden
sortliste.add(liste.get(listindex));
sortanz++;
listindex = 0;
// Testen, ob bereits fertig
if (sortanz == liste.size()) {
return sortliste;
}
}
else {
// Backtracking, falls keine Sortiermöglichkeit mehr
if (listindex == (liste.size()-1)) {
do {
// letzes Element entfernen und listindex erhöhen
Object obj = sortliste.remove(sortliste.size()-1);
sortanz--;
// Listenindex des gelöschten Objekts bestimmen
// und erhöhen, d.h. betrachte nächstes Element
listindex = liste.indexOf(obj);
listindex++;
} while (listindex < liste.size())
}
else {
// nächste Möglichkeit ausprobieren
listindex++;
153
}
}
}
// irgendwas ist schief gelaufen
return null;
}
Während die Backtracking-Strategie für das Sortierproblem sicherlich nicht
optimal geeignet ist, lässt sich mit dieser Strategie beispielsweise das AchtDamen-Problem einfach lösen. Gesucht ist hier eine Konfiguration von acht
Damen auf einem 8×8-Schachbrett, so dass keine Dame eine andere bedroht.
Die Lösung dieses Problems als Java-Programm sei Ihnen als Übung empfohlen.
154
12
Sortieren und Suchen in sortierten Folgen
Während im vorigen Kapitel allgemeine Grundmuster für Algorithmentypen
im Vordergrund standen, widmen wir uns hier der spezifischen Lösung zweier in der Informatik sehr häufig vorkommender Aufgabenstellungen, nämlich
der Suche in sortierten Folgen und dem Sortieren von Listen. Es gibt Untersuchungen, die schätzen, dass ca. 1/4 der kommerziell verbrauchten Rechenzeit auf Sortiervorgänge entfallen. Deshalb ist es wichtig, für eine spezifische Sortieranforderung auch einen passenden Sortieralgorithmus einzusetzen. Darüber hinaus kann die Suche nach bestimmten Elementen in sortierten
Folgen viel effizienter durchgeführt werden, während in unsortierten Folgen
kaum etwas für eine effiziente Suche getan werden kann.
12.1
Suchen in sortierten Folgen
Ein typisches Beispiel für das Problem des Suchens in sortierten Folgen ist
das Finden eines Eintrags im Telefonbuch. Ein Telefonbuch enthält eine Folge
von Einträgen aus Namen und Telefonnummern, wobei die Einträge nach dem
Namen geordnet sind.
Da uns in diesem Kapitel primär die Algorithmen zur Suche und zur Sortierung interessieren, gehen wir zur Vereinfachung im weiteren von einigen Annahmen aus. Eine Folge wird als Array von numerischen Werten repräsentiert
und auf ein einzelnes Element der Folge folge kann über den Index index
zugegriffen werden. Das erste Element ist wie in Java üblich folge[0] und
das letzte Element folge[n-1]. Somit können als Vergleichsoperatoren =,
< und > benutzt werden. Wir betrachten also nur den für die Suche relevanten Teil eines Eintrags — den Suchschlüssel. Die Verallgemeinerung auf die
Suche und die Sortierung von beliebigen Objekten ist einfach möglich, wie
man an dem Beispiel der Methode mergeSort aus Abschnitt 11.3.1 sieht.
12.1.1
Sequentielle Suche
Die einfachste Variante des Suchens ist nahe liegend: Wir durchlaufen einfach
die Folge sequentiell beginnend mit dem ersten Element. In jedem Schritt vergleichen wir das aktuelle Element mit dem Suchschlüssel. Sobald das gesuchte
Element gefunden wurde, können wir die Suche beenden. Anderenfalls wird
als Ergebnis ein spezielles Element NO KEY zurückgegeben. Dieses Verfahren
155
funktioniert natürlich auch bei unsortierten Folgen.
public int seqSearch(int[] folge, int schluessel)
{
for (int index = 0; index < folge.length; index++) {
if (folge[index] == schluessel) {
return index;
}
}
return NO_KEY;
}
Eines der wichtigsten Kriterien für die Beurteilung von Suchverfahren ist der
Rechenzeitaufwand. Wir können den Aufwand anhand der Anzahl der notwendigen Vergleiche bestimmen, die in unserem Fall der Anzahl der Schleifendurchläufe entspricht. Sinnvollerweise betrachten wir diesen Aufwand nicht
absolut, sondern in Abhängigkeit von der Anzahl n der Elemente der Folge.
Im besten Fall — das gesuchte Element ist das erste — benötigen wir nur
einen Schritt, im schlechtesten Fall — das gesuchte Element ist nicht in der
Folge enthalten — natürlich n Schritte. Interessanter ist jedoch der Durchschnittswert. Wenn wir davon ausgehen, dass jedes Element gleich häufig
gesucht wird, so sind im Fall einer erfolgreichen Suche (n + 1)/2 Schritte notwendig. Bei einer nicht erfolgreichen Suche ist der Durchschnittswert
natürlich auch gleich n.
bester Fall
schlechtester Fall
Durchschnitt (erfolgreiche Suche)
Durchschnitt (erfolglose Suche)
Anzahl der Vergleiche
1
n
(n + 1)/2
n
Es ist leicht einzusehen, dass das sequentielle Durchsuchen nicht die beste Variante ist. Schließlich würden wir ja auch nicht das Telefonbuch von
vorn beginnend systematisch mit dem gesuchten Namen vergleichen. Vielmehr schlägt man das Buch auf, vergleicht, ob sich der gesuchte Eintrag vor
oder hinter der aktuellen Stelle befindet, überspringt wieder einige Seiten,
vergleicht wieder usw. Dieses intuitive Suchverfahren, das jedoch nur bei sortierten Folgen funktioniert werden wir uns nun näher ansehen.
156
12.1.2
Binäre Suche
Bei der binären Suche wird der zu durchsuchende Bereich jeweils halbiert, so
dass wir den Algorithmus wie folgt grob beschreiben können.
1. Wähle den mittleren Eintrag und prüfe, ob gesuchter Wert in der ersten
oder in der zweiten Hälfte der Folge ist.
2. Fahre analog 1. mit derjenigen Hälfte fort, in der sich der Eintrag befindet, solange bis der gesuchte Eintrag gefunden ist.
Dieser Algorithmus lässt sich natürlich schön rekursiv implementieren, jedoch
betrachten wir hier nur die iterative Variante:
public int binSearch(int[] folge, int schluessel)
{
int unten = 0;
int oben = folge.length-1;
while (unten <= oben) {
int mitte = (unten+oben)/2;
if (folge[mitte] == schluessel) {
return mitte;
}
else if (folge[mitte] > schluessel) {
oben = mitte - 1;
}
else {
unten = mitte + 1;
}
}
return NO_KEY;
}
Auch für dieses Verfahren wollen wir den Aufwand anhand der Schleifendurchläufe beurteilen. Im günstigsten Fall befindet sich der gesuchte Eintrag
in der Mitte und wird deshalb nach einem Schritt gefunden. Im schlechtesten
Fall müssen wir jedoch nicht die gesamte Folge durchsuchen. Nach dem ersten
Teilen der Folge bleiben nur noch n/2 Elemente, nach dem zweiten Schritt
157
nur noch n/4 usw. Entsprechend werden maximal log2 n Schritte benötigt.
Dieser Wert gilt auch für die erfolglose Suche. Wie man sich an einem kleinen
Beispiel leicht überlgegen kann, ist der durchschnittliche Aufwand für eine
erfolgreiche Suche nur unmerklich kleiner als log2 n, so dass wir als Approximation ebenfalls diesen Wert ansetzen:
bester Fall
schlechtester Fall
Durchschnitt (erfolgreiche Suche)
Durchschnitt (erfolglose Suche)
Anzahl der Vergleiche
1
log2 n
log2 n
log2 n
Vergleichen wir den Suchaufwand bei sehr langen Folgen, dann sieht man,
dass die binäre Suche zu einer dramatischen Beschleunigung des durchschnittlichen Suchaufwands führt.
Verfahren/Folgenlänge
sequentielle Suche n/2)
binäre Suche (log2 n)
12.2
10
100
1000
10000
1000000
ca. 5
ca. 50 ca. 500 ca. 5000 ca. 500000
ca. 3.3 ca. 6.6 ca. 10 ca. 13.3
ca. 19.9
Sortieren
Das Problem der Sortierung stellt sich in vielen Anwendungen. So haben wir
gerade gesehen, dass die Suche viel effizienter realisiert werden kann, wenn die
zu durchsuchende Folge sortiert vorliegt. Auch das Erkennen von mehrfach
auftretenden Datensätzen (Duplikate) ist in sortierten Folgen viel einfacher.
Die Aufgabe besteht also im Ordnen von Datensätzen, die Schlüssel enthalten. Die Datensätze sind derart umzuordnen, dass eine klar definierte Ordnung der Schlüssel entsteht, wobei wir hier wiederum der Einfachheit halber
Integer-Werte als Schlüssel annehmen.
Bevor wir auf konkrete Verfahren zum Sortieren eingehen, klären wir zunächst
einige Grundbegriffe.
Grundsätzlich wird beim Sortieren zwischen zwei Klassen von Verfahren unterschieden:
• Interne Verfahren kommen beim Sortieren von Folgen zum Einsatz, die
als Ganzes in den Hauptspeicher passen.
• Externe Verfahren kommen beim Sortieren von Folgen zum Einsatz,
158
die auf externen Datenmedien wie Festplatte oder Magnetband abgelegt sind. Speziell in Datenbanksystemen, die Giga- oder Terabytes
verwalten, werden externe Verfahren eingesetzt.
Ein weiteres Merkmal ist die Stabilität. Ein Sortierverfahren heißt stabil,
wenn es die relative Reihenfolge gleicher Schlüssel in der Datei beibehält.
Wenn wir beispielsweise eine Liste mit Daten von Personen, die alphabetisch
nach dem Namen sortiert ist, nach dem Alter der Personen sortieren, dann
sind Personeneinträge mit dem gleichen Alter auch weiterhin alphabetisch
geordnet.
12.2.1
Sortieren durch Einfügen: insertionSort
Eine einfache Variante ergibt sich aus der direkten Umsetzung der typisch
menschlichen Vorgehensweise, etwa beim Sortieren eines Stapels von Karten.
1. Starte mit der ersten Karte eines neuen Stapel.
2. Nimm jeweils die nächste Karte des Originalstapels und füge diese an
der richtigen Stelle in den neuen Stapel ein.
Der folgende Algorithmus kommt mit einem Stapel aus, indem eine aktuell
betrachtete Karte in den bereits sortierten Anfangsstapel einsortiert wird,
falls die aktuelle Karte kleiner als das Vorgängerelement ist.
public void insertionSort(int[] feld)
{
for (int index = 0; index < feld.length; index++) {
int vorgindex = index;
// aktuelles Element merken
int merk = feld[index]
// falls aktuelles Element groesser --> einsortieren
while (vorgindex > 0 && feld[vorgindex-1] > merk) {
// verschiebe alle groesseren Elemente nach hinten
feld[vorgindex] = feld[vorgindex-1];
vorgindex--;
}
159
// setze den gemerkten Eintrag auf das freie Feld
feld[vorgindex] = m;
}
}
12.2.2
Sortieren durch Selektion: selectionSort
Auch das zweite hier zu betrachtende Verfahren kann dem Sortieren beim
Kartenspielen entlehnt werden. Die Idee ist hierbei, jeweils das größte Element auszuwählen und an das Ende der Folge zu setzen. Dies wird in jedem
Schritt mit einem jeweils um eins verkleinerten Bereich der Folge ausgeführt,
so dass sich am Ende der Folge die bereits sortierten Elemente sammeln.
Das Prinzip des Auswählens bzw. des Selektieren des größten Elements hat
diesem Verfahren den Namen gegeben.
public void selectionSort(int[] feld)
{
int endeindex = feld.length-1;
while (endeindex >= 0) {
// bestimme groesstes Element
int maxindex = 0;
for (int index = 1; index < endeindex; index++) {
if (feld[index] > feld[maxindex]) {
maxindex = index;
}
}
// vertausche Element an Index endeindex mit maximalem Element
int merk = feld[endeindex];
feld[endeindex] = feld[maxindex];
feld[maxindex] = merk;
endeindex--;
}
}
12.2.3
Sortieren durch Vertauschen: bubbleSort
Eines der bekanntesten, wenn auch kein besonders effizientes Verfahren ist
bubbleSort. Der Name ist aus der Vorstellung abgeleitet, dass sich bei einer
160
vertikalen Anordnung der Elemente der Folge verschieden große, aufsteigende Blasen (‘Bubbles’) wie in einer Flüssigkeit von allein sortieren, da die
größeren Blasen die kleineren überholen.
Das Grundprinzip besteht demzufolge darin, die Folge immer wieder zu
durchlaufen und dabei benachbarte Elemente, die nicht der Ordnung entsprechen, zu vertauschen. Elemente, die größer als ihre Nachfolger sind, überholen
diese daher und steigen zum Ende der Folge hin auf.
public void bubbleSort(int[] feld) {
boolean vertauscht;
int anzdo = 0;
do {
vertauscht = false;
// aktuell größtes Element ans Ende schieben
for (int index = 0; index < (feld.length-anzdo); index++) {
if (feld[index] > feld[index+1]) {
// Elemente vertauschen
int merk = feld[index+1];
feld[index+1] = feld[index];
feld[index] = merk;
// Vertauschung merken
vertauscht = true;
}
}
// die anzdo letzten Elemente sind bereits sortiert
anzdo++;
} while (vertauscht);
}
12.2.4
Sortieren durch Mischen: mergeSort
Die prinzipielle Vorgehensweise haben wir ja bereits in Abschnitt 11.3.1 kennengelernt, so dass ich hier nur der Vollständigkeit halber die Java-Version
für Arrays angeben werde:
161
public void msort(int[] feld, int von, int bis) {
// Laufvariablen für Feldindizes
int i, j, k;
// Hilfsfeld für das Mischen
int[] zwifeld = new int[feld.length];
if (von < bis) {
// zu sortierendes Feld teilen
int mitte = (von+bis) / 2;
// Teilfelder sortieren
msort(feld, von, mitte);
msort(feld, mitte+1, bis);
// Hilfsfeld aufbauen
for (k = von; k <= bis; k++) {
zwifeld[k] = feld[k];
}
// Ergebnisse über Hilfsfeld mischen
i = von; j = mitte+1; k = von;
while (i <= mitte && j <= bis) {
if (zwifeld[i] > zwifeld[j]) {
feld[k] = zwifeld[j];
j++;
}
else {
feld[k] = zwifeld[i];
i++;
}
k++;
}
// restlichen Teil des Hilfsfeldes mischen
for (; i <= mitte; i++) {
feld[k++] = zwifeld[i];
}
for (; j <= bis; j++) {
feld[k++] = zwifeld[j];
}
}
162
}
public void mergeSort(int[] feld) {
msort(feld, 0, feld.length-1);
}
12.2.5
Sortieren mittels eines Pivotelements: quickSort
Das wohl am häufigsten angewandte Sortierverfahren ist qickSort, das wie das
mergeSort-Verfahren auf dem Teile-und-Herrsche-Prinzip basiert. Eine Folge
wird in zwei Teile zerlegt, die unabhängig voneinander sortiert werden. Im
Gegensatz zu mergeSort wird jedoch der Mischvorgang vermieden, da durch
vorherige Umsortierung die eine Teilfolge nur die kleineren Elemente und die
andere Teilfolge nur die größeren Elemente enthält. Für die Umsortierung
wird ein Referenz-Element (das so genannte Pivot-Element) gewählt und es
werden alle Elemente, die kleiner als das Pivot-Element sind, auf die linke
Seite der Folge und alle Elemente, die größer als das Pivot-Element sind, auf
die rechte Seite gebracht. Anschließend wird dieses Prinzip für die beiden
Teilfolgen rekursiv wiederholt, bis die Länge der Folge gleich eins ist.
public void qsort(int[] feld, int von, int bis) {
// Laufvariablen für Feldindizes
int i, k;
int pivotelement;
if (von < bis) {
// wähle Pivot-Element mit mittlerer Größe aus
// dem ersten, mittleren und letzten Element
if (feld[von] < feld[bis]) {
if (feld[von] > feld[(von+bis) / 2]) {
pivotelement = feld[von];
}
else if (feld[bis] < feld[(von+bis) / 2])) {
pivotindex = feld[bis];
}
else {
pivotindex = feld[(von+bis) / 2];
}
}
else {
163
if (feld[bis] > feld[(von+bis) / 2]) {
pivotindex = feld[bis];
}
else if (feld[vons] < feld[(von+bis) / 2])) {
pivotindex = feld[von];
}
else {
pivotindex = feld[(von+bis) / 2];
}
}
// sortiere gemäß Pivot-Element um
i = von; k= bis;
while (i <= k) {
while (i < bis && feld[i] < pivotelement]) {
i++;
}
while (k > von && feld[k] > pivotelement]) {
k--;
}
// wenn Indizes nicht gekreuzt --> vertauschen
if (i <= k) {
int merk = feld[k];
feld[k] = feld[i];
feld[i] = merk;
i++;
k++;
}
}
// sortiere die beiden Teilfolgen, falls erforderlich
if (von < k) {
qsort(feld, von, k);
}
if (i < bis) {
qsort(feld, i, bis);
}
}
}
public void quickSort(int[] feld) {
164
qsort(feld, 0, feld.length-1);
}
12.2.6
Sortierverfahren im Vergleich
Zum Abschluss dieses Kapitels wollen wir die vorgestellten Verfahren bezüglich
des Laufzeitaufwandes vergleichen. Der Aufwand wird im wesentlichen durch
die Anzahl der Vergleiche und die Anzahl der Vertauschungen bestimmt.
insertionSort Hier wird in der for-Schleife die ganze Folge durchlaufen. In
jedem Durchlauf wandert das aktuelle Element nach vorne, falls es kleiner als
seine Vorgänger ist. Im ungünstigsten Fall sind dies im i-ten Durchgang i − 1
Verschiebungen, im günstigsten Fall 0 Verschiebungen. Jeder Schritt umfasst
eine Vergleichsoperation und eine halbe Austauschoperation, da das betroffene Element auf eine freie Position bewegt wird. Im Durchschnitt sind also
ca. i/2 Vergleichsoperationen und i/4 Austauschoperationen im i-ten Schritt
durchzuführen. Der durchschnittliche Aufwand für eine Folge der Länge n
für Vergleiche beträgt daher:
n
X
1
n(n + 1)
n2
i
= (1 + 2 + . . . + n − 1 + n) =
≈
2
2
4
4
i=1
Analog beträgt der Aufwand für Austauschoperationen ca.
n2
8
insertionSort erhält die relative Ordnung der Elemente und ist somit stabil.
selectionSort Hier wird die gesamte Folge rückwärts durchlaufen. In jedem Schritt wird das maximale Element gesucht und dann eine Vertauschung
durchgeführt. Im Schritt i sind also i Vergleiche und eine Vertauschung aus2
zuführen. Durchschnittlich benötigt man daher ca. n2 Vergleiche und n Vertauschungen.
selectionSort erhält die relative Ordnung der Elemente nicht und ist somit
instabil.
bubbleSort Im Normalfall wird die do-while-Schleife ca. n-mal durchlaufen. In jedem Schritt i werden n − i Vergleiche und durchschnittlich (n − i)/2
Vertauschungen vorgenommen. Der durchschnittliche Aufwand beträgt daher
2
2
ca. n2 Vergleiche und n4 Vertauschungen.
165
bubbleSort erhält die relative Ordnung der Elemente und ist somit stabil.
mergeSort Wir erleichtern uns hier die Arbeit, indem wir annehmen, dass
n eine Zweierpotenz ist, d.h. n = 2k . Durch die rekursive Zerlegung einer
Folge in zwei Folgen halber Länge, ergeben sich nach der ersten Zerlegung
2 = 21 Folgen der Länge 2k−1 , nach der zweiten Zerlegung 4 = 22 Folgen der
Länge 2k−2 usw. bis man 2k Folgen der Länge 20 = 1 besitzt. Nimmt man
an, dass man für das Mischen zweier Folgen der Länge n2 ca. n Vergleiche
und Zuweisungen
=
ˆ halbe Vertauschungen) benötigt, so ergibt sich folgender
Pk i−1
Aufwand: i=1 2 22k−i . Der letzte Faktor gibt die Länge der Folge an und
der erste Faktor, deren Anzahl. Da das Mischen den doppelten Aufwand wie
die Länge der Folge erfordert, wird noch einmal mit 2 multipliziert. Es ergibt
sich also folgender Aufwand
k
X
i=1
2
i−1
22
k−i
=
k
X
2k = k2k
i=1
Da gilt n = 2k bzw. k = ld n, ergibt sich der Aufwand zu n ld n Vergleiche
und n/2 ld n Vertauschungen.
mergeSort erhält die relative Ordnung der Elemente und ist somit stabil.
quickSort Da quickSort ebenfalls nach dem Teile-und-Herrsche Prinzip
handelt, gilt hierfür eine analoge Abschätzung. Allerdings wird hier kein
zusätzlicher Hilfsspeicher wie bei mergeSort benötigt.
quickSort erhält die relative Ordnung der Elemente nicht und ist somit instabil.
Die folgende Tabelle fasst unsere Aufwandsabschätzungen nochmals zusammen:
Verfahren
insertionSort
selectionSort
bubbleSort
mergeSort
quickSort
Stabilität Vergleiche Vertauschungen
stabil
n2 /4
n2 /8
2
instabil
n /2
n
2
2
stabil
n /2
n /4
stabil
n ld n
n/2 ld n
instabil
n ld n
n/2 ld n
Da quickSort zum einen sehr effizient ist und außerdem keinen zusätzlichen
Speicherbedarf hat, kommt in den meisten Anwendungen dieser Algorithmus
166
zum Einsatz. Will man jedoch nur einige wenige Elemente in eine bereits sortierte Liste einfügen, dann ist insertionSort bzw. bubbleSort zu bevorzugen,
da deren Laufzeit dann jeweils nahezu linear mit der Länge der Folge ist.
167
13
Grundlegende Datenstrukturen
Nachdem wir in Kapitel 6 bereits die primitiven Datentypen kennengelernt
haben und wir auf der Grundlage dieser Datentypen über die Definition von
Klassen, beliebige eigene Datenstrukturen definieren können, wollen wir uns
in diesem Kapitel weitere universal einsetzbare grundlegende Datenstrukturen ansehen.
13.1
Hochdimensionale Arrays
In Abschnitt 3.5 haben wir bereits sogenannte Arrays benutzt. Dies sind
Felder fester Länge, so dass wir sowohl für primitive Datentypen als auch für
Objekttypen Sammlungen für eine maximale Anzahl von Einträgen benutzen
können:
int[] zahlenliste = new int[25];
zahlenliste[0] = 339;
zahlenliste[24] = 76;
int erstesElement = zahlenliste[0];
String[] text = new String[3];
zeile[0] = "Heute ist"
zeile[1] = "wunderbares"
zeile[2] = "Sommerwetter"
System.out.println(zeile[0] + " " + zeile[1] + " " + zeile[2]);
Zu beachten ist der spezielle Zugriff auf Elemente eines Arrays über die Notation in eckigen Klammern.
In vielen mathematischen Anwendungen aber auch für die Repräsentation
von Zahlentabellen sind zweidimensionale Arrays hilfreich. Um die folgende
Zahlentabelle
3.22
3.9 0.77 333.1 26.98
77.92
9.01 2.87 73.75 288.98
9.82 190.65 28.56 338.1
2.88
adäquat zu repräsentieren, kann man das folgende zweidimensionale Array
verwenden:
168
float[][] tabelle = new float[3][5];
Analog wie beim eindimensionalen Array erfolgt der Zugriff auf ein Element
über einen bei Null beginnenden Index in eckigen Klammern. So liefert der
Quellcode tabelle[1][4] das fünfte Element in der zweiten Zeile, also den
Wert 288.98.
Mit dem Quellcode
for (int i=0; i<3; i++) {
for (int k=0; k<5; k++) {
tabelle[i][k] = 0.0;
}
}
lässt sich beispielsweise die Tabelle mit Nullen initialisieren.
Eine Multiplikation einer 3 × 5-Matrix AA mit einer 5 × 2-Matrix BB lässt
sich folgendermaßen programmieren:
for (int i=0; i<3; i++) {
for (int k=0; k<2; k++) {
CC[i][k] = 0.0;
for (int j=0; j<5; j++) {
CC[i][k] += AA[i][j] * BB[j][k];
}
}
}
In analoger Weise lassen sich beliebig hochdimensionale Arrays erzeugen und
verwenden. Beispielsweise kann eine Tabelle, die für jedes Monat für die
letzten 50 Jahre die kälteste und wärmste Temperatur für einen Ort enthält
folgendermaßen deklariert werden:
float[][][] temptabelle = new float[50][12][2];
Auf die heißeste Temperatur im August vor 45 Jahren könnte somit folgendermaßen zugegeriffen werden: temptabelle[4][7][1]
Der große Vorteil von festen Arrays im Vergleich zu Containern wie ArrayList oder LinkedList ist, dass Arrays auch über primitive Datentypen deklariert werden können. Für die Datentypen ArrayList und LinkedList ist
169
dies nicht möglich, so dass man hier auf die sogenannten Wrapper-Klassen
zurückgreifen muss (siehe Abschnitt 6.9).
Der große Vorteil dagegen ist, dass man bei den Datentypen ArrayList und
LinkedList keine Größe des Containers angeben muss, so dass dort beliebig
viele Objekte aufgenommen werden können. Wie bereits erwähnt, bieten diese beiden Container im Prinzip die gleiche Funktionalität, unterscheiden sich
jedoch in der Effizienz für unterschiedliche Operationen. Damit Sie zukünftig
zielgerichtet den best passendsten Container für Ihre Anwendung wählen
können, wollen wir uns nun beide Varianten etwas näher ansehen.
13.2
Der dynamische Container ArrayList
Wie der Name vermuten lässt, basiert die Implementierung dieses Containers auf einem Array. Im Prinzip kann man sich die Datenfelder und den
parameterlosen Konstruktor der Klasse ArrayList wie folgt vorstellen:
public class ArrayList ... {
int laenge;
int anzahl;
Object[] feld;
public ArrayList() {
feld = new Object[10];
anzahl = 0;
laenge = 10;
}
}
Erzeugt man also ein Objekt von ArrayList, so wird ein Array der Länge 10
vom Typ Object erzeugt. Die aktuelle Array-Größe merkt man sich in der
Variablen laenge, während die Variable anzahl die Anzahl der Objekte im
Container speichert. Initial ist diese Anzahl natürlich gleich Null.
Wird nun mit der Methode add ein Objekt in den Container eingefügt, so
kann mit laenge > anzahl überprüft werden, ob noch Platz im Container
ist. Ist dies der Fall, so wird mit folgenden Anweisungen das aktuelle Objekt
obj im Container gespeichert:
feld[anzahl] = obj;
170
anzahl++;
Ist das statische Feld voll, d.h. laenge == anzahl, dann muss zuerst ein
größeres Feld generiert werden und das alte Feld wird umkopiert. Dies könnte
wie folgt aussehen:
hilffeld = new Object[laenge+VERGROESSERE];
for (int i = 0; i < laenge; i++) {
hilffeld[i] = feld[i];
}
feld = hilffeld;
laenge += VERGROESSERE;
feld[anzahl] = obj;
anzahl++;
Da Arrays immer einen zusammenhängenden Speicherbereich einnehmen,
kann der Zugriff über einen Index extrem schnell ausgeführt werden, da beispielsweise für den Array-Zugriff mit dem Index ind auf die Speicheradresse
des Arrays feld nur ind ∗ BytesRef erenzen addiert werden müssen, wobei
der Wert für BytesRef erenzen je nach Hardware entweder 4 oder 8 Bytes
beträgt.
Das Einfügen oder das Entfernen eines Elements mitten in die bzw. aus der
Liste ist jedoch sehr aufwändig, da die Elemente mit höherem Index jeweils
eine Position nach oben bzw. nach unten kopiert werden müssen. Das folgende
Beispiel für die Methode remove (ohne Fehlerbehandlung für falschen Index)
verdeutlicht dieses Prinzip:
public Object remove(int index) {
Object merk = feld[index];
for (int i=index+1; i<anzahl; i++) {
feld[i-1] = feld[i];
}
anzahl--;
return merk;
}
In analoger Weise kann man sich die Implementierung der anderen Methoden
der Klasse ArrayList wie size oder isEmpty vorstellen.
171
Hat man also Anwendungen, in denen primär Elemente ans Ende der Liste
eingefügt und kaum gelöscht werden und man oft auf die Elemente zugegreift,
so ist die ArrayList zu bevorzugen.
Weiß man von vornherein die ungefähre Anzahl von Objekten, die ein Container aufnehmen soll, so kann beim Konstruktor ein Integer-Parameter verwendet werden, der die initiale Größe des Arrays angibt:
ArrayList grosseListe = new ArrayList(1000000);
Damit vermeidet man das häufige Umkopieren eines vollen Feldes und spart
somit wertvolle Rechenzeit.
13.3
Verkettete Listen
So effizient der Zugriff auf beliebige Elemente in einer ArrayList erfolgen
kann, so ineffizient ist das Einfügen und Löschen von Elementen in der Mitte, aber auch das Einfügen am Ende, falls der Container bereits voll ist. In
diesem Fall muss ja ein neues Array erzeugt werden und das alte muss entsprechend umkopiert werden. Um diese Nachteile zu vermeiden kann man
zur Speicherung beliebig vieler Objekte eine sogenannte verkettete Liste verwenden.
Eine solche Liste besteht aus einer Menge von Knoten, die untereinander
verkettet sind. Jeder Knoten besteht aus einer Referenz auf ein Objekt und
eine Referenz auf den Nachfolgeknoten. Die folgende Abbildung zeigt eine
verkettete Liste mit drei String-Elementen.
Der Beginn der Liste ist mit head bezeichnet. Die einzelnen Knoten sind
durch Rechtecke dargestellt, die gespeicherten Zeichenketten durch Ovale
172
und die Referenzen durch Pfeile. Anhand dieser Struktur wird deutlich, dass
nur so viele Knoten benötigt werden, wie tatsächlich Elemente in der Liste
vorhanden sind. Das Ende der Liste wird durch den Nullzeiger markiert.
Das Einfügen und Löschen eines Elements am Ende der Liste wird durch die
nachstehende Abbildung verdeutlicht:
Eine Implementierung der wichtigsten Methoden in Java könnte wie folgt
aussehen. Ein kleiner Trick hilft dabei, die Behandlung der leeren Liste zu
vereinfachen. Zeigt head nämlich auf das tatsächlich erste Element, so bedeutet dies für den Fall der leeren Liste, dass head == null ist. Da in diesem Fall
beispielsweise die Methode getNext() nicht aufgerufen werden kann, müsste
die Bedingung immer gesondert geprüft werden. Dies vermeidet man, indem
ein echter head-Knoten verwendet wird, der als Element immer null enthält
und auf das eigentliche erste Element bzw. für den Fall der leeren Liste auf
null verweist. Dieser head-Knoten wird beim Erzeugen der Liste im Konstruktor angelegt.
public class ListNode {
private Object element;
private ListNode next;
173
// Konstruktoren
public ListNode(Object obj, ListNode node) {
element = obj;
next = node;
}
public ListNode() {
element = null;
next = null;
}
// Methoden
public void setElement(Object obj) {
element = obj;
}
public Object getElement() {
return element;
}
public Object setNext(ListNode node) {
next = node;
}
public ListNode getNext() {
return next;
}
}
public class VerketteteListe {
private ListNode head = null;
private int lenght;
public VerketteteListe() {
head = new ListNode();
length = 0;
}
public boolean add(Object obj) {
ListNode l = head;
// Ende der Liste suchen
while (l.getNext() != null) {
l = l.getNext();
174
}
// neuen Knoten erzeugen und ans Ende anhaengen
ListNode n = new ListNode(obj, null);
l.setNext(n);
length++;
return true;
}
public void add(Object obj, int index) {
if (index < 0 oder index > length) {
throw new IndexOutOfBoundsException(
"Es sind keine " + index +
" Elemente in der Liste");
}
// Position index in der Liste suchen
ListNode l = head;
int zaehl = 0;
while (zaehl < index) {
l = l.getNext();
zaehl++;
}
// neuen Knoten erzeugen und einhaengen
ListNode n = new ListNode(obj, l.getNext());
l.setNext(n);
length++;
}
public Object remove(int index) {
Object obj;
if (index < 0 oder index >= length) {
throw new IndexOutOfBoundsException(
"Es sind keine " + index +
" Elemente in der Liste");
}
// Position index-1 in der Liste suchen
ListNode l = head;
int zaehl = 0;
175
while (zaehl < index) {
l = l.getNext();
zaehl++;
}
// Knoten aushängen und Verkettung wiederherstellen
obj = l.getNext().getElement();
l.setNext(l.getNext.getNext());
length--;
return obj;
}
public Object get(int index) {
if (index < 0 oder index >= length) {
throw new IndexOutOfBoundsException(
"Es sind keine " + index +
" Elemente in der Liste");
}
// Position index in der Liste suchen
ListNode l = head;
int zaehl = 0;
while (zaehl <= index) {
l = l.getNext();
zaehl++;
}
// Element zurückgeben
return l.getElement();
}
public int size() {
return length;
}
public boolean isEmpty() {
return length == 0;
}
}
Wichtig für die Realisierung einer verketteten Liste ist die Möglichkeit bei
der Definition einer Klasse bereits den Objekttyp dieser Klasse für die Deklaration von Datenfeldern verwenden zu können. Dadurch ist es möglich,
in der Definition der Klasse ListNode das Datenfeld next vom Objekttyp
176
ListNode zu deklarieren. Da diese Möglichkeit auch für viele andere Anwendungen sinnvoll ist, sind in Java deshalb solche rekursiven Klassendefinitionen zulässig.
Der große Nachteil dieser Version einer verketteten Liste ist, dass bei der
Methode add ohne Indexangabe, die gesamte Liste auf der Suche nach dem
letzten Element abzulaufen ist. Insbesondere bei sehr großen Listen wäre
deshalb das Einfügen ans Ende der Liste sehr rechenzeitaufwändig. Auch das
Speichern des letzten Elements in einem extra Datenfeld ist nicht die optimale
Lösung, da beim Löschen des letzten Elements wiederum alle Elemente der
Liste auf der Suche nach dem vorletzten Element, das nach dem Löschen das
letzte Element ist, durchsucht werden müssen. Aus diesem Grund verwendet
man häufig sogenannte doppelt verkettete Listen, die wir uns nun näher
ansehen.
13.4
Doppelt verkettete Listen
Bei einer doppelt verketteten Liste speichert jeder Knoten nicht nur seinen
Nachfolger, sondern auch seinen Vorgänger (siehe folgende Abbildung).
Zusätzlich zum Listenanfang head speichert das Datenfeld tail den letzten
Knoten der Liste, so dass ein Einfügen ans Ende der Liste mit geringem
Aufwand erfolgen kann (siehe folgende Abbildung).
177
Beim Löschen des letzten Elements wird über den prev-Eintrag des letzten
Knotens der vorletzte Knoten bestimmt und als neues Listenende (tail)
gespeichert, wobei dessen next-Eintrag auf null gesetzt wird (siehe folgende
Abbildung).
Das Einfügen eines Elements ans Ende der Liste könnte nun in Java wie
folgt aussehen, wobei sowohl der hed- als auch der tail-Knoten über spezielle Listenknoten realisiert werden, um die Behandlung der leeren Liste zu
vereinfachen:
public class DoppeltVerketteteListe {
private ListNode head = null;
private ListNode tail = null;
private int lenght;
public DoppeltVerketteteListe() {
head = new ListNode();
tail = new ListNode();
// Anfang und Ende verknüpfen
178
head.setNext(tail);
tail.setPrevious(head);
length = 0;
}
public boolean add(Object obj) {
// letzten Knoten der Liste bestimmen
ListNode l = tail.getPrevious();
// neuen Knoten erzeugen und zwischen tail
// und dessen Vorgänger einhängen
ListNode n = new ListNode(obj, l, tail);
l.setNext(n);
tail.setPrevious(n);
length++;
return true;
}
}
In analoger Weise sind die anderen Methoden zu implementieren.
Die von Java bereit gestellte Klasse LinkedList ist eine doppelt verkettete Liste, so dass bei der Verwendung dieses Listentyps, das Einfügen und
Löschen von Elementen ans Ende der Liste sehr effizient geschieht. Aber auch
das Einfügen und Löschen von Elementen in der Mitte der Liste geschieht
im Normalfall deutlich effizienter als bei einer ArrayList, da zwar die entsprechende Position erst gesucht werden muss, aber dann kein Umkopieren
von nachfolgenden Elementen mehr erforderlich ist.
13.5
Das Iterator-Konzept
Ein wichtiges Konstrukt bei der Verwendung von Containern ist der Iterator, der es uns ermöglicht, über alle Elemente einer Liste zu laufen. Nachdem wir nun einen ersten Einblick in die Realisierung der Container-Klassen
ArrayList und LinkedList gewommen haben, können wir nun auch besser verstehen, wie das Iterator-Konzept in diesen beiden Klassen umgesetzt
wird. Dies soll Ihnen auch als Hilfestellung bei der Implementierung eigener
Container-Klassen mit Iterator dienen.
Iteratoren sind Objekte von Klassen, die die vordefinierte Schnittstelle java.util.Iterator
implementieren. Diese Schnittstelle definiert die u.a. die folgenden uns bereits
179
bekannten Methoden:
• boolean hasNext() prüft, ob noch weitere Elemente in der Liste verfügbar
sind. In diesem Fall wird true geliefert. Ist dagegen das Ende erreicht
wird false zurückgegeben.
• Object next() liefert das aktuelle Element zurück und setzt den internen Zeiger des Iterators auf das nächste Element.
Für unsere Klasse VerketteteListe kann dieses Iterator-Konzept wie folgt
implementiert werden.
public class VerketteteListe {
private ListNode head;
int length;
class VListIterator implements Iterator {
private ListNode node = null;
public LinkedListIterator() {
// mit Listenanfang initialisieren
node = head;
}
public boolean hasNext() {
return node.next != null;
}
public Object next() {
if (! hasNext()) {
throw new NoSuchElementException(
"Liste ist bereits durchlaufen");
}
Object obj = node.getElement();
node = node.getNext();
return obj;
}
}
// Konstruktor und Methoden
180
...
public Iterator iterator() {
return new VListIterator();
}
}
Zusätzlich zur Realisierung eines Iterators sehen wir an diesem Beispiel, dass
Klassendefinitionen auch geschachtelt werden können, d.h. eine Klasse kann
innerhalb einer anderen Klasse definiert werden. Dies hat den Vorteil, dass
Namenskonflikte vermieden werden können und die Datenfelder der äußeren
Klasse direkt in der inneren Klasse verwendet werden können. Somit kann
bei Erzeugung eines VListIterator-Objekts der Beginn der Liste im inneren Datenfeld node gespeichert werden, ohne dass dieser als Parameter zu
übergeben ist. Da die innere Klasse nur innerhalb der äußeren Klasse bekannt
ist, wird bei der Klassendefinition der inneren Klasse kein Sichtbarkeitsmodifikator benötigt.
In analoger Weise kann man sich auch die Implementierung des Iterators für
die doppelt verkettet Liste LinkedList vorstellen.
Die Realisierung des Iterator-Konzepts für die Klasse ArrayList kann man
sich wie folgt vorstellen:
public class ArrayList ... {
int laenge;
int anzahl;
Object[] feld;
class ArrayListIterator implements Iterator {
private Object[] itfeld;
private int aktind;
private int anzelem;
public LinkedListIterator() {
// mit Listenanfang initialisieren
itfeld = feld;
aktind = 0;
anzelem = laenge;
}
181
public boolean hasNext() {
return aktind < anzelem;
}
public Object next() {
if (! hasNext()) {
throw new NoSuchElementException(
"Liste ist bereits durchlaufen");
}
Object obj = itfeld[aktind];
aktind++;
return obj;
}
}
}
13.6
Stapel (stack)
Angenommen Sie haben die Aufgabe, in einem Labyrinth einen Schatz zu
suchen. Natürlich wollen Sie sich nicht verirren und nach dem Finden des
Schatzes bzw, wenn Sie keine Lust mehr auf die Schatzsuche haben, möglichst
schnell den Ausgang finden. Unter der Annahme, dass alle Räume einen
Namen besitzen und dass die zu einem Raum benachbarten Räume keine
identischen Namen haben, kann man mit Hilfe der Datenstruktur Stapel
dieses Problem einfach lösen.
Ein Stapel ist eine Datenstruktur, die nach dem LIFO-Prinzip (last-in-firstout) arbeitet. Die Elemente werden am vorderen Ende einer Liste eingefügt
und von dort auch wieder entnommen. Das heißt, die zuletzt eingefügten Elemente werden zuerst entnommen und die zuerst eingefügten zuletzt. Üblicherweise
sind für einen Stapel die folgenden Operationen definiert:
• void push(Object obj) legt das Objekt obj als oberstes Element auf
den Stapel.
• Object pop() nimmt das oberste Element vom Stapel und gibt es
zurück.
• Object top() gibt das oberste Element des Stapels zurück, ohne es zu
entfernen.
182
• boolean isEmpty() liefert true, wenn der Stapel leer ist, anderenfalls
false.
Wenn man nun den ersten Raum des Labyrinths betritt, so kann man hier
nach dem Schatz suchen, macht jedoch erst mal nichts mit dem Stapel. Ab
dann verhält man sich wie folgt:
• Verlässt man einen Raum A in Richtung eines Raumes B, so sieht man
auf dem Stapel nach, ob man von dort kommt, d.h. top() == B.
• Falls top() == B, dann entfernt man das oberste Element mit pop().
• Falls top() != B, dann gibt man den Raum A mit push(A) auf den
Stapel.
• Hat man nun den Schatz gefunden, so holt man sich mit pop() den
Raum von dem man gekommen ist und betritt diesen. Dies iteriert
man solange bis der Stapel leer ist; dann ist man wieder im Freien.
Ein solches Verhalten lässt sich einfach erreichen, indem man eine Klasse
Stack als abgeleitete Klasse entweder von ArrayList oder von LinkedList
definiert, und die entsprechenden Methoden — mit Ausnahme der bereits
vorhandenen Methode isEmpty — wie folgt definiert:
public void push(Object obj) {
add(obj);
}
public Object pop() {
return remove(size()-1);
}
public Object top() {
return get(size()-1);
}
Stapel kommen auch häufig in Programmieranwendungen zum Einsatz, wo
es gilt Klammerstrukturen zu überprüfen oder die Abarbeitung komplexer
arithmetischer Ausdrücke zu regeln.
183
14
Hashverfahren
Hashverfahren ermöglichen es, gespeicherte Einträge mittels Suchschlüsseln
effizient zu finden. Die Datensätze werden dabei in einem Feld mit direktem
Zugriff gespeichert und eine spezielle Funktion, die Hashfunktion, erlaubt für
jeden gespeicherten Wert den direkten Zugriff auf den gesuchten Datensatz.
Sehen wir uns zunächst das Grundprinzip des Hashens an.
14.1
Grundprinzip des Hashens
Das Grundprinzip lässt sich wie folgt charakterisieren:
• Die Speicherung der Datensätze erfolgt in einem Feld mit Indexwerten
0 bis N-1, wobei die einzelnen Positionen oft als Buckets bezeichnet
werden.
• Eine Hashfunktion h bestimmt auf effiziente Weise für ein Element e
die Position h(e) im Feld.
• Die Hashfunktion h sollte natürlich so beschaffen sein, dass möglichst
für jedes Element eine andere Position bestimmt wird.
• Die letzte Anforderung lässt sich natürlich nicht immer erfüllen, so dass
in diesem Fall — man spricht von Kollision — eine entsprechende Maßnahme zur Bestimmung einer freien Position getroffen werden muss.
Die folgende Abbildung zeigt ein Beispiel für Hashen. Gespeichert werden
Integerzahlen in einem Feld der Dimension 10. Die Hashfunktion ist definiert
als h(i) = i mod 10. Die Abbildung zeigt das Feld nach dem Einfügen der
Zahlen 42 und 119.
184
Anhand dieses Beispiels kann man auch gut die Möglichkeit der Kollision
erläutern. Die Hashfunktion h(i) würde die Zahl 69 an dieselbe Stelle wie
119 abspeichern. Bevor wir uns jedoch Maßnahmen zur Kollisionsvermeidung
ansehen, wollen wir betrachten, was man bei der Wahl einer Hashfunktion
beachten sollte.
14.2
Hashfunktionen
Die Hashfunktion hängt natürlich vom Datentyp der zu speichernden Elemente ab und der konkreten Anwendung ab.
Für Integerwerte wird oftmals als Hashfunktion direkt die Funktion h(i) =
i mod N gewählt. Dies funktioniert in der Regel allerdings nur dann gut, wenn
N eine (große) Primzahl ist, die nicht nahe an einer großen Zweierpotenz
liegt, da sonst keine Gleichverteilung der Schlüssel erzielt wird.
Für andere Datentypen kann eine Rückführung auf Integerwerte erfolgen:
• Bei Fließkommazahlen kann man Mantisse und Exponent addieren.
• Bei Zeichenketten kann man den Ascii/Unicode-Wert der einzelnen Zeichen addieren oder den Hashwert wie folgt berechnen:
h(s) =
L−1
X
al w(sl )
mit
al ∈ {0, . . . , N − 1}
l=0
falls die Zeichenkette s aus den Zeichen s0 . . . sL−1 besteht und w(c)
der Ascii- oder Unicodewert eines Zeichens c ist.
185
• Bei Objekten kann man deren Speicheradresse verwenden.
14.3
Behandlung von Kollisionen
Eine Kollision tritt ein, wenn ein Datensatz mittels einer Hashfunktion in einem Feldeintrag (Bucket) abgespeichert werden soll, der bereits durch einen
anderen Datensatz belegt ist. Zur Behandlung von Kollisionen gibt es mehrere Strategien, deren wichtigste im Folgenden dargestellt werden.
• Bei der Verkettung der Überläufer wird bei Kollisionen eine Liste mit
den Elementen aufgebaut, die dieselbe Position belegen.
• Sondieren bezeichnet das Suchen einer alternativen Position im Fall einer Kollision. Wir werden zwei Verfahren betrachten, das lineare Sondieren und das quadratische Sondieren.
Die Wahrscheinlichkeit für Kollisionen nimmt mit dem Füllgrad der Tabelle
zu, so dass es in der Regel einen Füllgrad gibt, ab dem Hashtabellen ineffizient
werden.
Verkettung der Überläufer
Unter einem Überläufer versteht man ein Element, das eine bereits gefüllte
Position in einer Hashtabelle zum Überlaufen bringt. Bei der Verkettung von
Überläufern werden alle zusätzlichen Einträge einer Feldposition jeweils in
einer verketteten Liste verwaltet. Die folgende Abbildung zeigt das Prinzip
der Verkettung von Überläufern anhand einer Tabelle für die Hashfunktion
für N = 10 und h(i) = i mod 10.
186
Die Verkettung von Überläufern kann zu einer Abspeicherung in einer linearen Liste entarten, wenn viele Elemente auf dieselbe Position abgebildet
werden. Falls dies häufig auftritt kann man zur Verkettung auch einen ausgeglichenen Suchbaum aufbauen (siehe nächstes Kapitel), was die Suche nach
einem bestimmten Überläufer deutlich beschleunigt.
Lineares Sondieren
Obiges Verfahren benutzt zusätzlichen Speicherplatz, um Kollisionen zu behandeln. Alternativ benutzt man zur Abspeicherung von Überläufern andere,
noch unbesetzte Positionen in der Hashtabelle. Den Prozess des Suchens einer
derartigen Position nennt man Sondieren.
Das einfachste Sondierverfahren ist das sogenannte lineare Sondieren. Falls
beim linearen Sondieren die Position h(e) in der Hashtabelle T (notiert als
T [h(e)]) besetzt ist, prüft das Verfahren des linearen Sondierens nun der
Reihe nach die Positionen
T [h(e) + 1], T [h(e) + 2], T [h(e) + 3], . . . , T [h(e) + i], . . . ,
um das Element e abzuspeichern. Damit man die Feldgrenzen nicht überschreitet, wird natürlich wieder Modulo der Feldgröße gerechnet, d.h.
T [(h(e) + 1) mod N ], T [(h(e) + 2) mod N ], . . .
187
Dadurch kann ein Element e nun an anderer Stelle als h(e) abgespeichert werden. Dies muss man beim Suchen natürlich beachten: Ist also beim Suchen
nach e die Position h(e) von einem Element e0 mit e0 6= e besetzt, so muss
in der Sondierungsreihenfolge weitergesucht werden, bis das gesuchte Element oder eine unbesetzte Position gefunden wird. Die folgende Abbildung
verdeutlicht den Ablauf beim linearen Sondieren anhand eines einfachen Beispiels:
Beim Einfügen des dritten Elements, der Zahl 49, wird eine besetzte Position gefunden. Das Sondieren prüft nun als nächstes die Position T [(h(49) +
1) mod 10] = T [0], die sich als freie Position erweist und daher das Element
aufnehmen kann. Würde nach dem Einfügeablauf in der obigen Abbildung
die Zahl 28 eingefügt, dann müsste nun insgesamt 5-mal sondiert werden.
Eine derartige Folge von besetzten Feldern neigt dazu, immer schneller zu
wachsen, da die Wahrscheinlichkeit, dass in diesem Bereich ein weiteres Element eingefügt wird, mit der Größe des Bereichs wächst.
Gebräuchliche Varianten des linearen Sondierens sind:
• Statt eines Inkrements um 1 wird jeweils um den Wert c weitergesprungen:
T [(h(e) + c) mod N ], T [(h(e) + 2c) mod N ], T [(h(e) + 3c) mod N ], . . .
Der Wert c muss natürlich in Abhängigkeit von N gewählt werden, damit möglichst alle Positionen abgedeckt werden, d.h. möglichst ggT (c, N ) =
1
188
• Die Sondierung erfolgt in beide Richtungen:
T [(h(e) + 1) mod N ], T [(h(e) − 1) mod N ], T [(h(e) + 2) mod N ], T [(h(e) − 2) mod N ], . . .
Quadratisches Sondieren
Lineares Sondieren neigt zur Bildung von Klumpen, in denen alle Positionen bereits besetzt sind und sich daher lange Sondierungsfolgen aufbauen.
Dieses Manko vermeidet das quadratische Sondieren, indem die Folge der
Quadratzahlen für die Sondierabstände genommen wird.
Falls T [(h(e)] also bereits besetzt ist, versucht das quadratische Sondieren
der Reihe nach mit
T [(h(e) + 1) mod N ], T [(h(e) + 4) mod N ], T [(h(e) + 9) mod N ], . . . T [(h(e)i2 ) mod N ], . . .
einen freien Platz zu finden. Die folgende Abbildung zeigt mit derselben
Eingabefolge wie beim linearen Sondieren die Arbeitsweise des quadratischen
Sondierens.
Auch hier ist natürlich die Variante möglich, die in beide Richtungen abwechselnd sondiert.
14.4
Hashen in Java
In der Klasse Object liefert die Methode int hashCode() für alle von dieser Klasse abgeleiteten Klassen einen Hashwert, der von der Speicheradresse
abgeleitet ist. Eine Hashtabelle muss zwei Funktionalitäten bereitstellen:
189
• Eine Methode add, um Elemente in die Hashtabelle einzufügen.
• Eine Methode contains, die überprüft, ob ein bestimmtes Element
bereits in der Tabelle enthalten ist.
Somit könnte eine Klasse LinkedHashTable, die als Sondierungsmethode die
Verkettung der Überläufer verwendet, wie folgt aussehen.
import java.util.LinkedList
import java.util.Iterator
public class LinkedHashTable implements Hashing {
// Hashtabelle mit verketteter Liste
// für die Überläufer
private LinkeList[] table;
public LinkedHashTable(int size) {
// Hashtabelle erzeugen
table = new LinkedList[size];
}
public void add(Object obj) {
// Feldindex über Hashfunktion bestimmen
// Die Bitweise Verundung mit 0x7fffffff garantiert,
// dass das Hashcode-Ergebnis eine positive Zahl ist.
int index = (obj.hashCode() & 0x7fffffff) % table.length;
if (table[index] == null) {
// noch keine Liste vorhanden, da
// automatische Initialisierung mit null
table[index] = new LinkedList();
// Objekt in Liste eintragen
table[index].add(obj);
}
}
public boolean contains(Object obj) {
// Feldindex über Hashfunktion bestimmen
int index = (obj.hashCode() & 0x7fffffff) % table.length;
190
if (table[index] != null) {
Iterator it = table[index].iterator();
while (it.hasNext()) {
Object o = it.next();
if (o.equals(obj)) {
return true;
}
}
}
return false;
}
}
Analog könnte eine Klasse HashTable, die als Sondierungsmethode das lineare Sondieren verwendet, wie folgt aussehen.
public class HashTable implements Hashing {
private Object[] table;
public HashTable(int size) {
// Hashtabelle erzeugen
table = new Object[size];
}
public void add(Object obj)
throws HashTableOverFlowException {
int index, origind;
// Feldindex über Hashfunktion bestimmen
origind = index = (obj.hashCode() & 0x7fffffff)
% table.length;
while (table[index] != null) {
// lineares Sondieren
index = (index + 1) % table.length;
if (index == origind) {
throw new HashTableOverFlowException();
}
}
table[index] = obj;
191
}
public boolean contains(Object obj) {
int index, origind;
// Feldindex über Hashfunktion bestimmen
origind = index = (obj.hashCode() & 0x7fffffff)
% table.length;
while (table[index] != null) {
if (obj.equals(table[index])) {
return true;
}
index = (index + 1) % table.length;
if (index == origind) {
break;
}
}
return false;
}
}
Bislang können wir Werte in eine Hashtabelle eintragen und überprüfen, ob
ein Wert dort enthalten ist. Dies ist insbesondere für die Realisierung von
Mengen äußerst nützlich, da beim Eintrag eines Wertes in eine Menge ein
Element nur einmal eingetragen werden darf.
Die uns bereits bekannte Klasse HashSet realisiert diese Funktionalität, wobei zur Kollisionsbehandlung ein Sondierungsverfahren zum Einsatz kommt.
Zusätzlich realisiert diese Klasse alle Mengenoperationen, jedoch auf der Basis einer Hashtabelle als grundlegender Datenstruktur. Analog hierzu bietet
die Klasse LinkedHashSet eine Implementierung einer Menge auf der Basis
einer Hashtabelle mit Verkettung der Überläufer an.
Eine weitere uns breits bekannte Klasse auf der Basis einer Hashtabelle ist
HashMap. Diese Klasse ermöglicht es uns Schlüssel-Wert-Paare abzuspeichern
und mittels des Schlüssels auf den zugehörigen Wert zuzugreifen. Diese Funktionalität auf der Grundlage einer Hashtabelle mit Sondierung als Kollisionsstrategie erreicht man, indem man ein zweidimensionales Array als Hashtabelle verwendet. Die Implementierung in der Java-Klassenbibliothek wirft bei
der put-Methode keine HashTableOverFlowException, sondern vergrößert
192
die Hashtabelle automatisch. Darauf möchte ich im Rahmen dieser Vorlesung
jedoch nicht eingehen. Eine Realisierungsmöglichkeit der beiden wichtigsten
Methoden put und get ist im Folgenden dargestellt:
public class HashMap implements ... {
private Object[][] table;
public HashMap(int size) {
// Hashtabelle erzeugen
table = new Object[size][2];
}
public Object put(Object key, Object value)
throws HashTableOverFlowException {
int index, origind;
// Feldindex über Hashfunktion bestimmen
origind = index = (obj.hashCode() & 0x7fffffff)
% table.length;
while (table[index] != null) {
if (key.equals(table[index][0])) {
table[index][1] = obj;
return table[index][1];
}
index = (index + 1) % table.length;
if (index == origind) {
throw new HashTableOverFlowException();
}
}
table[index][0] = key;
table[index][1] = obj;
return null;
}
public Object get(Object key) {
int index, origind;
// Feldindex über Hashfunktion bestimmen
origind = index = (obj.hashCode() & 0x7fffffff)
% table.length;
193
while (table[index] != null) {
if (key.equals(table[index][0])) {
return table[index][1];
}
index = (index + 1) % table.length;
if (index == origind) {
break;
}
}
return null;
}
}
In analoger Weise realisiert die Klasse LinkedHashMap den effizienten Zugriff auf Werte über einen Schlüssel, außer dass hier zur Behandlung von
Kollisionen eine verkettete Liste für die Überläufer verwendet wird.
194
15
Bäume
Mit den Hashverfahren haben wir effiziente Datenstrukturen zum Finden von
Einträgen mittels Suchschlüssel betrachten HashMap. Außerdem konnten wir
mittels Hashverfahren eine effiziente Implementierung für Mengen realisieren
HashSet.
Dieselben Funktionalitäten lassen sich jedoch auch mittels ausgefeilterer Datenstrukturen realisieren, nämlich Bäumen. Da dieser Typ von Datenstruktur zudem auch oftmals zur Repräsentation von Daten im Alltag verwendet
werden kann (z.B. Stammbaum einer Familie, Systematik in der Biologie),
wollen wir uns Bäume im Weiteren nun näher ansehen.
15.1
Bäume: Begriffe und Konzepte
Allgemein verstehen wir unter einem Baum eine Menge von Knoten und
Kanten, die besondere Eigenschaften aufweisen:
• Jeder Baum besitzt genau einen ausgezeichneten Knoten, der Wurzel
genannt wird.
• Jeder Knoten n — außer der Wurzel — ist durch eine Kante mit genau
einem anderen Knoten (Vorgänger oder Elternknoten) verbunden. Der
Knoten n wird dann Kind oder Nachfolger genannt.
• Ein Knoten ohne Kinder heißt Blatt, alle anderen Knoten bezeichnet
man als innere Knoten.
Üblicherweise werden Bäume in der Informatik umgekehrt, d.h. mit der Wurzel nach oben, gezeichnet.
195
Desweiteren werden die folgenden Begriffe im Zusammenhang mit Bäumen
gebraucht:
• Ein Pfad in einem Baum ist eine Folge von unterschiedlichen Knoten,
in der die aufeinanderfolgenden Knoten durch Kanten verbunden sind.
• Die Baumeigenschaft besagt, dass zwischen jedem Knoten und der Wurzel es nur genau einen Pfad gibt, d.h.
– ein Baum ist über Kanten zusammenhängend
– es gibt keine Zyklen
• Unter dem Niveau eines Knotens versteht man die Länge des Pfades
von der Wurzel bis zu diesem Knoten.
• Die Höhe eines Baumes entspricht dem maximalen Niveau aller Blätter
(siehe folgende Abbildung.
• Hat jeder innere Knoten in einem Baum maximal n Nachfolger, so
spricht man von einem n-ären Baum. Ein Baum mit maximal 2 Nachfolgern heißt binärer Baum.
• Sind die Kinder jedes Knotens in einer bestimmten Reihenfolge geordnet, so bezeichnet man einen solchen Baum als geordneten Baum.
• Ein binärer Suchbaum ist ein geordneter binärer Baum, wobei jeder
Knoten einen Schlüsselwert enthält, so dass alle Schlüsselwerte im linken Teilbaum kleiner und alle Schlüsselwerte im rechten Teilbaum größer
sind.
Da in vielen Anwendungsfällen binäre Bäume ausreichend zur Problemmodellierung sind, wollen wir uns im Rahmen dieser Vorlesung nur diesen Baumtyp
näher betrachten.
196
15.2
Binärer Baum: Datentyp und Basisalgorithmen
Analog wie bei den verketteten Listen definiert man sich einen rekursiven
Basistyp TreeNode für die Knoten von Bäumen und benutzt diesen Typ um
in der Klasse BinaryTree einen binären Baum zu definieren. Unter der Annahme, dass die Klasse TreeNode nur zur Definition der Klasse BinaryTree
benötigt wird könnte eine solche Klasse wie folgt aussehen:
public class BinaryTree {
static class TrreNode {
TreeNode left = null;
TreeNode right = null;
Object compobj;
public TreeNode(Object obj) { compobj = obj; }
public
public
public
public
public
public
Object getCompobj() { return compobj; }
TreeNode getLeft() { return left; }
TreeNode getRight() { return right; }
void setLeft(TreeNode n) { left = n; }
void setRight(TreeNode n) { right = n; }
String toString(TreeNode n) { return compobj.toString(); }
}
private TreeNode head;
private TreeNode nullNode;
public BinaryTree() {
head = new TreeNode(null);
nullNode = new TreeNode(null);
head.setRight(nullNode);
nullNode.setLeft(nullNode);
nullNode.setRight(nullNode);
}
// Methoden ...
}
Das Schlüsselwort static zeigt hier an, dass nicht jedes Objekt von BinaryTree
ein Objekt von TreeNode enthält, sondern hier nur statisch die Klasse definiert wird (analog zu einer statischen Klassenvariablen). Die Klasse TreeNode
197
umfasst neben den Datenfeldern left und right mit den Referenzen auf
die beiden Nachfolgeknoten noch ein Datenfeld compobj, das die eigentliche Nutzinformation — ein Objekt — aufnimmt. Für die schnelle Suche
nach einem Objekt in einem Baum, sollte dieses Objekt eine Vergleichsfunktion besitzen, so dass die Objekte im Baum geordnet eingefügt werden können. Dies wird am einfachsten erreicht, falls die eingefügten Objekte
die Schnittstelle Comparable implementieren, so dass sie eine Implementierung der Vergleichsfunktionint compareTo(Object o) besitzen. Beim Aufruf ob1.compareTo(obj2) wird als Ergebnis
• eine negative Zahl geliefert, falls obj1 kleiner als obj2 ist,
• 0 geliefert, falls obj1 gleich obj2 ist bzw.
• eine positive Zahl geliefert, falls obj1 größer als obj2 ist.
In der Klasse BinaryTree werden ähnlich wie bei der Implementierung der
verketteten Liste Pseudoknoten eingeführt, die den Verweis auf die Wurzel
des Baumes (head) und Verweise auf fehlende Nachfolgeknoten (nullNode)
repräsentieren. Dadurch können gesonderte Abfragen bezüglich eines leeren
Baumes oder auf null-Verweise entfallen. Der Konstruktor führt dabei die
notwendigen Initialisierungen durch, wobei der rechte Nachfolger auf den
eigentlichen Baum verweist — im Falle des leeren Baumes auf den nullNodeKnoten. Auf das left-Datenfeld des head-Knotens wird niemals zugegriffen,
so dass keine explizite Initialisierung notwendig ist. Echte Baumknoten (in
der folgenden Abbildung als weiße Knoten dargestellt) werden somit immer
als innere Knoten in den rechten Teilbaum von head eingefügt. Die eigentliche
Wurzel ist deshalb immer über head.getRight() erreichbar.
198
Bevor wir uns ansehen wie Knoten in den Baum eingehängt bzw. wieder
gelöscht werden, wollen wir uns die wichtigsten Möglichkeiten näher ansehen,
wie man alle Knoten eines Baumes ablaufen kann. Dieser Prozess wird auch
Traversierung genannt.
15.3
Algorithmen zur Traversierung
Die unterschiedlichen Traversierungsmöglichkeiten wollen wir an dem folgenden Beispielbaum verdeutlichen:
Inorder-Durchlauf
Hier wird zuerst rekursiv der linke Teilbaum besucht, dann der Knoten selbst
und dann der rechte Teilbaum. Für den binären Baum aus obiger Abbildung
entspricht dies der Reihenfolge:
D→B→E→A→F →C→G
Eine Methode, die die Knoten gemäß der Inorder-Reihenfolge ausgibt, sieht
wie folgt aus:
private void printInorder(TreeNode n) {
if (n != nullNode) {
printInorder(n.getLeft());
System.out.println(n.toString());
printInorder(n.getRight());
}
}
199
Preorder-Durchlauf
Bei dieser Strategie wird zuerst der Knoten selbst besucht und danach erfolgt
die Traversierung des linken bzw. rechten Teilbaums. Für unseren Beispielbaum liefert dies folgende Reihenfolge:
A→B→D→E→C→F →G
Eine Methode, die die Knoten gemäß der Preorder-Reihenfolge ausgibt, sieht
wie folgt aus:
private void printPreorder(TreeNode n) {
if (n != nullNode) {
System.out.println(n.toString());
printInorder(n.getLeft());
printInorder(n.getRight());
}
}
Postorder-Durchlauf
Beim Postorder-Durchlauf werden erst beide Teilbäume durchlaufen, bevor
der Knoten selbst besucht wird. Dies führt zu folgender Reihenfolge:
D→E→B→F →G→C→A
Eine Methode, die die Knoten gemäß der Postorder-Reihenfolge ausgibt, sieht
wie folgt aus:
private void printPostorder(TreeNode n) {
if (n != nullNode) {
printInorder(n.getLeft());
printInorder(n.getRight());
System.out.println(n.toString());
}
}
200
Levelorder-Durchlauf
Der Levelorder-Durchlauf entspricht einer Breitensuche, d.h. auf jedem Niveau eines Baumes werden erst alle Knoten besucht, bevor auf das nächste
Niveau gewechselt wird. Somit ergibt sich diese Reihenfolge:
A→B→C→D→E→F →G
Eine Methode, die die Knoten gemäß der Levelorder-Reihenfolge ausgibt,
sieht wie folgt aus, wobei die als Parameter übergebene Liste zu Beginn nur
den Wurzelknoten des Baumes enthält:
private void printLevelorder(LinkedList list) {
while (!list.isEmpty()) {
TreeNode n = (TreeNode) list.getLast();
if (n.getLeft() != nullNode) {
list.addFirst(n.getLeft());
}
if (n.getRight() != nullNode) {
list.addFirst(n.getRight());
}
System.out.println(n.toString());
}
}
Obige Traversierungsmethoden werden in der Methode traverse aufgerufen,
wobei die Strategie über den Parameter strategy ausgewählt wird. Zu diesem Zweck ist in der Klasse BinaryTree zu jeder Strategie eine Konstante
definiert, die als Parameter verwendet werden kann.
class BinaryTree {
public static final int INORDER = 1;
public static final int PREORDER = 2;
public static final int POSTORDER = 3;
public static final int LEVELORDER = 4;
...
public void traverse(int strategy) {
switch (strategy) {
case: INORDER:
printInorder(head.getRight());
break;
201
case: PREORDER:
printPreorder(head.getRight());
break;
case: POSTORDER:
printPostorder(head.getRight());
break;
case: LEVELORDER:
LinkedList list = new LinkedList();
list.add(head.getRight());
printLevelorder(list);
break;
default:
}
}
}
Hier lernen wir eine weitere Möglichkeit der Strukturierung des Kontrollflusses kennen. Eine switch-Anweisung ermöglicht das Weitergeben des Kontrollflusses an eine von mehreren Anweisungen in ihrem Block mit Unteranweisungen. Sie tritt nur in Verbindung mit dem Schlüsselwort case auf, weshalb sie auch oft switch-case-Anweisung genannt wird. An welche Anweisung innerhalb der switch-Anweisung der Kontrollfluss weitergereicht wird,
hängt vom Wert des Ausdrucks in der Anweisung ab. Es wird die erste Anweisung nach einer case-Bezeichnung ausgeführt, welche denselben Wert wie
der Ausdruck hat. Wenn es keinen passenden case-Wert gibt, wird die erste
Anweisung hinter der mit dem Schlüsselwort default bezeichneten Label
ausgeführt. Dieses Label darf maximal nur einmal verwendet werden. Wenn
es keinen passenden case-Wert gibt und auch kein default-Label vorhanden
ist, dann wird die erste Anweisung nach dem switch-Block ausgeführt. Nachdem ein case- oder default-Label angesprungen wurde, werden alle dahinterstehenden Anweisungen ausgeführt. Es erfolgt auch dann keine Unterbrechung, wenn das nächste Label erreicht wird. Wenn dies erwünscht ist, muß
der Kontrollfluß wie mit Hilfe einer break-Anweisung unterbrochen werden.
Jedes break innerhalb einer switch-Anweisung führt dazu, daß zum Ende
der switch-Anweisung verzweigt wird. Die zu testenden switch-Ausdrücke
und case-Bezeichnungskonstanten müssen alle vom Typ byte, short, char
oder int sein. Mit dieser Auswahlanweisung haben Sie eine übersichtlichere
Auswahlanweisung als mit iterierten if-else-Anweisungen.
202
15.4
Suchbäume
Neben der Repräsentation von hierarchisch strukturierten Daten, wie beispielsweise Stammbäumen, ist die Unterstützung einer effizienten Suche eines
der wichtigsten Einsatzgebiete von Bäumen. Dazu müssen die gespeicherten
Objekte eine Vergleichsfunktion besitzen, die eine totale Ordnung definiert.
Im Weiteren betrachten wir nur binäre Suchbäume, die für jeden inneren
Knoten n folgende Eigenschaften aufweisen:
• Der Knoten n enthält ein vergleichbares Objekt n.compobj.
• Alle Objekte der Knoten im linken Teilbaum n.lef t sind kleiner als
n.compobj.
• Alle Objekte der Knoten im rechten Teilbaum n.right sind größer als
n.compobj.
Für unsere im vorigen Abschnitt skizzierte Implementierung der Klasse BinaryTree
bedeutet dies, dass der Pseudoknoten head das kleinste mögliche Element
aufnehmen muss. Dies wird erreicht, indem dieser Knoten den Wert null als
vergleichbares Objekt enthält und dies in der Vergleichsmethode compareKeyTo
der Klasse BinaryTree entsprechend berücksichtigt wird:
static class TreeNode {
...
public int compareKeyTo(Comparable c) {
if (compobj == null) {
return -1;
}
else {
203
return ((Comparable) compobj).compareTo(c);
}
}
}
Da der Compiler nur den statischen Objekttyp überprüft und compobj vom
Typ Object ist, das keine Methode compareTo kennt, muss eine entsprechende Cast-Anweisung benutzt werden.
Suchen in Suchbäumen
Aus den oben definierten Eigenschaften binärer Suchbäume lässt sich das
Suchprinzip einfach ableiten: Der gesuchte Wert wird mit dem Wert des
Wurzelknotens verglichen. Ist dieser Wert kleiner, so kann sich das gesuchte
Element nur im linken teilbaum befinden, ist der Wert dagegen größer, entsprechend nur im rechten Teilbaum. Anderenfalls enthält der aktuelle Knoten
dieses Element. Die vorige Abbildung zeigt dieses Prinzip, falls man nach dem
Wert 5 sucht.
Die entsprechende Java-Methode könnte wie folgt aussehen:
public boolean contains(Comparable comp) {
TreeNode n = head.getRight();
while (n != nullNode) {
int cval = n.compareKeyTo(comp);
if (cval == 0) {
return true;
}
else if (cval < 0) {
n = n.getRight();
}
else {
n = n.getLeft();
}
}
return false;
}
204
Einfügen und Löschen von Knoten
Bei der Manipulation eines binären Suchbaums, d.h. beim Einfügen oder
Löschen von Knoten, müssen die auf Seite 203 formulierten Eigenschaften
eines Baumes erhalten bleiben, da sonst die Suche nicht mehr funktioniert.
Für die Methode insert bedeutet dies zunächst die korrekte Einfügeposition
zu finden, so dass die Ordnung der Elemente nicht verletzt wird. Diese Position muss ein Knoten sein, dessen Objekt größer als das einzufügende Objekt
ist und der noch keinen linken Nachfolger hat oder ein Knoten, dessen Objekt
größer als das einzufügende Objekt ist und noch keinen rechten Nachfolger
hat. Da es einen solchen Knoten immer gibt — außer das Element ist bereits
im Baum enthalten — kann das Einfügen wie folgt realisiert werden:
public boolean insert(Comparable comp) {
TreeNode parent = head;
TreeNode child = head.getRight();
// Einfügeposition finden
while (child != nullNode) {
parent = child;
int cval = child.compareKeyTo(comp);
if (cval == 0) {
return false;
}
else if (cval > 0) {
child = child.getLeft();
}
else {
child = child.getRight();
}
}
TreeNode node = new TreeNode(comp);
if (parent.compareKeyTo(comp) > 0) {
parent.setLeft(node);
}
else {
parent.setRight(node);
}
node.setLeft(nullNode);
node.setRight(nullNode);
205
}
Die folgende Abbildung zeigt das Einfügen der Zahl 4 in einen binären Suchbaum:
Komplizierter hingegen ist das Löschen von Knoten, da beim Löschen von
inneren Knoten der Baum umgeordnet werden muss. Es müssen hierbei die
folgenden drei Fälle unterschieden werden:
• Der Knoten n ist ein Blatt. Hier ist nur der Elternknoten zu bestimmen
und der Verweis auf Knoten n ist zu entfernen.
• Der Knoten n besitzt nur einen Kindknoten. In diesem Fall ist der
Verweis vom Elternknoten auf den Kindknoten von n umzulenken.
• Der Knoten n ist ein innerer Knoten mit zwei Kindknoten. Hierbei
muss der Knoten durch den am weitest links stehenden Knoten des
rechten Teilbaums ersetzt werden, da dieser in der Sortierreihenfolge
der nächste Knoten ist. Alternativ kann natürlich auch der am weitest
rechts stehende Knoten des linken Teilbaums verwendet werden.
Die entsprechende Java-Methode remove kann dann wie folgt realisiert werden:
public boolean remove(Comparable comp) {
TreeNode parent = head;
TreeNode node = head.getRight();
TreeNode child, tmp;
// zu löschenden Knoten suchen
while (node != nullNode) {
206
int cval = node.compareKeyTo(comp);
if (cval == 0) {
break;
}
else if (cval > 0) {
node = node.getLeft();
}
else {
node = node.getRight();
}
parent = node;
}
if (node == nullNode) {
// kein passender Knoten gefunden
return false;
}
// Betrachte die drei möglichen Fälle
if (node.getLeft() == nullNode &&
node.getRight() == nullNode) {
// Fall 1: Knoten ist Blattknoten
child = nullNode;
}
else if (node.getLeft == nullNode) {
// Fall 2a: nur rechter Kindknoten vorhanden
child = node.getRight();
}
else if (node.getRight == nullNode) {
// Fall 2b: nur linker Kindknoten vorhanden
child = node.getLeft();
}
else {
// Fall 3: zwei Kindknoten vorhanden
// minimales Element des rechten Teilbaums suchen
child = node.getRight();
tmp = node;
while (child.getLeft() != nullNode) {
tmp = child;
child = child.getLeft();
}
child.setLeft(node.getLeft());
207
if (tmp != node) {
tmp.setLeft(child.getRight());
child.setRight(tmp);
}
}
if (parent.getLeft() == node) {
parent.setLeft(child);
}
else {
parent.setRight(child);
}
return true;
}
Den dritten Fall kann man sich anhand der folgenden Abbildung veranschaulichen:
Komplexität der Operationen
Analysiert man die Operationen für binäre Suchbäume, so wird deutlich,
dass jeweils nur ein Pfad von der Wurzel bis zum entsprechenden Knoten
bearbeitet wird. Der Aufwand wird daher ausschließlich durch die Höhe des
Baumes bestimmt.
Problematisch ist jedoch, dass bei unterschiedlicher Einfügereihenfolge unterschiedliche Suchbäume entstehen können. So ergibt die Reihenfolge:
6, 3, 9, 1, 5, 7, 10
den Suchbaum a) in der folgenden Abbildung, währen die Reihenfolge
208
1, 3, 5, 6, 7, 9, 10
zu dem entarteten Baum b) führt.
Ziel sollte es daher sein, möglichst ausgeglichene Bäume zu bekommen, d.h.
mit möglichst niedriger Höhe. Hierzu gibt es eine Reihe von Möglichkeiten,
auf die ich im Rahmen dieser Vorlesung jedoch nicht mehr eingehen kann.
Informationen hierzu finden Sie in Kapitel 13 des Buchs:
• Robert Sedgewick, Algorithmen in Java (Teil 1-4), 3. überarbeitete Auflage, Pearson Studium, München, 2003
209