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