Universität Bielefeld Interner Bericht der Technischen Fakultät Abteilung Informationstechnik Algorithmen und Datenstrukturen II Skript zur Vorlesung im Sommersemester 2010 Universität Bielefeld • Postfach 10 01 31 • 33501 Bielefeld • Germany Impressum: Herausgeber: Robert Giegerich, Ralf Hofestädt, Peter Ladkin, Ralf Möller, Helge Ritter, Gerhard Sagerer, Jens Stoye, Ipke Wachsmuth Technische Fakultät der Universität Bielefeld, Abteilung Informationstechnik, Postfach 10 01 31, 33501 Bielefeld, Germany Vorwort Vorwort zur ersten Auflage: Dieses Skript ist aus der Vorlesung „Algorithmen und Datenstrukturen II“ hervorgegangen, die ich im Sommersemester 1997 an der Universität Bielefeld gehalten habe. Seine Entstehung ist einzig und allein der Initiative der Studierenden Marcel Holtmann und Tanja Becker zu verdanken. Sie haben sich bereit erklärt, das handschriftliche Manuskript nach LATEX zu übertragen. Jeder, der so etwas schon einmal gemacht hat, weiß, wieviel Arbeit und Mühe in diesem Skript steckt. Ich danke den beiden ganz herzlich. Das letzte Kapitel wurde von Christian Büschking gestaltet, der mich in der letzten Vorlesungswoche wegen einer Konferenzteilnahme vertreten hat. Auch ihm gilt mein Dank. Schließlich möchte ich mich bei Nicole Schwerdt bedanken, die mich während der letzten Überarbeitung des Skriptes bei Schreibarbeiten unterstützt hat. Das vorliegende Skript enthält mit an Sicherheit grenzender Wahrscheinlichkeit Fehler. Wenn jemand einen Fehler findet, oder meint, ein Sachverhalt sei unklar formuliert, kann er/sie eine Nachricht an [email protected] schicken. Bielefeld, im Oktober 1997 Enno Ohlebusch Vorwort zur Auflage 2005: Mittlerweile wurde das Skript durch verschiedene Autoren aktualisiert und ergänzt. Im Sommersemester 2001 hat Robert Giegerich Änderungen in Kapitel 1 eingefügt. Für das Sommersemester 2002 habe ich kleinere Umstellungen in den Kapiteln 2 und 7 vorgenommen, um die objektorientierten Anteile der Programmiersprache Java etwas stärker zu betonen. Zum Sommersemester 2004 hat es einige weitere kleine Ergänzungen gegeben, insbesondere ausführliche einleitende Beispiele in den Kapiteln 4.6 und 8, für die ich Peter Menke und Sebastian Kespohl herzlich danke. Bielefeld, im August 2005 Jens Stoye Vorwort zur Auflage 2006: Für das Sommersemester 2006 habe ich nur wenige kleine Korrekturen vorgenommen und die Grafiken verfeinert. Bielefeld, im Mai 2006 Tim Nattkemper Vorwort zur Auflage 2008: Im Sommersemester 2008 wurde das Skript um drei Kapitel erweitert: das Kapitel Graphen gibt eine kurze Einführung in Graphrepräsentationen und einige wenige beispielhafte Algorithmen auf Graphen. Nils Hoffmann hat eine kurze Einführung in Threads verfasst, Jan Krüger eine Einführung in GUI-Programmierung in Java mittels Swing. Bielefeld, im Mai 2008 Alexander Sczyrba ii Inhaltsverzeichnis 1 Syntax und Semantik 1.1 Einführung . . . . . 1.2 EBNF-Definitionen 1.3 Syntaxdiagramme . 1.4 Aufgaben . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 . 1 . 9 . 13 . 16 2 Java: Der Einstieg 2.1 Grundlegendes zu Java . . . . . . . . . . . . . 2.1.1 Historisches . . . . . . . . . . . . . . . 2.1.2 Eigenschaften von Java . . . . . . . . . 2.1.3 Sicherheit . . . . . . . . . . . . . . . . 2.1.4 Erstellen eines Java-Programms . . . . 2.2 Grundzüge imperativer Sprachen . . . . . . . 2.2.1 Das Behältermodell der Variablen . . . 2.2.2 Konsequenzen . . . . . . . . . . . . . 2.3 Klassen, Objekte und Methoden im Überblick 2.3.1 Klassen . . . . . . . . . . . . . . . . . 2.3.2 Das Erzeugen von Objekten . . . . . . 2.3.3 Klassenvariablen . . . . . . . . . . . . 2.3.4 Methoden . . . . . . . . . . . . . . . . 2.3.5 Klassenbezogene Methoden . . . . . . 2.4 Vererbung, Pakete und Gültigkeitsbereiche . . 2.4.1 Vererbung . . . . . . . . . . . . . . . . 2.4.2 Pakete . . . . . . . . . . . . . . . . . . 2.4.3 Gültigkeitsbereiche . . . . . . . . . . . 2.5 Aufgaben . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 19 19 19 21 21 22 22 22 22 23 23 24 24 25 25 25 26 26 27 3 Imperative Programmierung in Java 3.1 Mini-Java . . . . . . . . . . . . . 3.2 Von Mini-Java zu Java . . . . . . 3.2.1 Elementare Datentypen . . 3.2.2 Kommentare . . . . . . . 3.2.3 Bool’sche Operatoren . . . 3.2.4 Bitoperatoren . . . . . . . 3.2.5 Inkrement und Dekrement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 29 32 32 33 34 34 35 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . iii Inhaltsverzeichnis 3.2.6 Zuweisungsoperatoren . . . . 3.2.7 Die nichtabweisende Schleife . 3.2.8 for-Schleife . . . . . . . . . . 3.2.9 if-then-else Anweisung . . . . 3.2.10 Mehrdimensionale Felder . . . 3.2.11 switch-Anweisung . . . . . . 3.3 Aufgaben . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 Algorithmen zur exakten Suche in Texten 4.1 Die Klasse String . . . . . . . . . . . . . . . . . . . . 4.2 Grundlegende Definitionen . . . . . . . . . . . . . . . 4.3 Das Problem der exakten Suche . . . . . . . . . . . . 4.4 Der Boyer-Moore-Algorithmus . . . . . . . . . . . . . 4.4.1 Die bad-character Heuristik . . . . . . . . . . 4.4.2 Die good-suffix Heuristik . . . . . . . . . . . . 4.5 Der Boyer-Moore-Horspool-Algorithmus . . . . . . . 4.6 Der Knuth-Morris-Pratt-Algorithmus . . . . . . . . . 4.6.1 Einführendes Beispiel . . . . . . . . . . . . . . 4.6.2 Funktionsweise . . . . . . . . . . . . . . . . . 4.6.3 Korrektheit der Berechnung der Präfixfunktion 4.7 Aufgaben . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 Objektorientierte Programmierung in Java 5.1 Traditionelle Konzepte der Softwaretechnik . . . . . . . . . . . . 5.1.1 Beispiel: Der ADT Stack . . . . . . . . . . . . . . . . . . . 5.2 Konzepte der objektorientierten Programmierung . . . . . . . . 5.3 Klassen und Objekte . . . . . . . . . . . . . . . . . . . . . . . . 5.4 Kommunikation mit Nachrichten . . . . . . . . . . . . . . . . . . 5.5 Vererbung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.6 Konstruktoren und Initialisierungsblöcke . . . . . . . . . . . . . . 5.7 Java-Klassen als Realisierung und Implementierung von abstrakten Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.8 Methoden in Java . . . . . . . . . . . . . . . . . . . . . . . . . . 5.9 Unterklassen und Vererbung in Java . . . . . . . . . . . . . . . . 5.10 Überschreiben von Methoden und Verdecken von Datenfeldern . 5.11 Konstruktoren in Unterklassen . . . . . . . . . . . . . . . . . . . 5.12 Reihenfolgeabhängigkeit von Konstruktoren . . . . . . . . . . . 5.13 Abstrakte Klassen und Methoden . . . . . . . . . . . . . . . . . 5.14 Aufgaben . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35 36 36 37 39 40 42 . . . . . . . . . . . . 43 43 45 46 48 48 48 49 51 51 52 56 59 . . . . . . . 61 61 62 62 64 64 65 66 . . . . . . . . 69 71 73 75 77 78 79 81 6 Übergang von funktionaler zu OOP 85 6.1 Imperative vs. funktionale Programmierung . . . . . . . . . . . . . 85 6.2 Listen in Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87 iv Inhaltsverzeichnis 6.3 Vergleich zwischen Haskell und Java . . . . . . . . . . . . . . . . . 92 6.4 Aufgaben . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96 7 Programmieren im Großen 7.1 Schnittstellen . . . . . . . . . . . . . . . . . . . . . . . . . 7.1.1 Beispiel: Die vordefinierte Schnittstelle Enumeration 7.2 Pakete . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.2.1 Paketinhalte . . . . . . . . . . . . . . . . . . . . . . 7.2.2 Paketbenennung . . . . . . . . . . . . . . . . . . . 7.3 Ausnahmen (Exceptions) . . . . . . . . . . . . . . . . . . . 7.3.1 throw und throws . . . . . . . . . . . . . . . . . . 7.3.2 try, catch und finally . . . . . . . . . . . . . . . 7.4 Aufgaben . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101 . 101 . 104 . 105 . 106 . 106 . 107 . 109 . 110 . 114 8 Graphen 8.1 Anwendungen von Graphen . . . . . 8.2 Terminologie . . . . . . . . . . . . . . 8.3 Repräsentation von Graphen . . . . . 8.4 Ein Abstrakter Datentyp (ADT) Graph 8.5 Breitensuche . . . . . . . . . . . . . . 8.6 Tiefensuche . . . . . . . . . . . . . . 8.7 Topologisches Sortieren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 127 . 127 . 129 . 131 . 131 . 132 . 134 . 137 . . . . . . 139 . 139 . 140 . 141 . 142 . 143 . 144 . . . . . . . . . . . . . . . . . . . . . 9 Hashing 9.1 Einführendes Beispiel . . . . . . . . . . . . 9.2 Allgemeine Definitionen . . . . . . . . . . 9.3 Strategien zur Behandlung von Kollisionen 9.3.1 Direkte Verkettung . . . . . . . . . 9.3.2 Open Hashing . . . . . . . . . . . . 9.4 Die Klasse Hashtable in Java . . . . . . . . 9.5 Aufgaben . . . . . . . . . . . . . . . . . . 10 Ein- und Ausgabe 10.1 Ströme . . . . . . . . . . . . . . 10.1.1 Die Klasse InputStream 10.1.2 File-Ströme . . . . . . . 10.1.3 Gepufferte Ströme . . . 10.1.4 Datenströme . . . . . . 10.2 Stream Tokenizer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 115 115 116 118 119 121 123 125 11 Graphische Benutzeroberflächen mit Swing 147 11.1 Historisches . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 147 11.2 Ein Fenster zur Welt . . . . . . . . . . . . . . . . . . . . . . . . . . 147 v Inhaltsverzeichnis 11.3 Swing Komponenten . . . . . . . . . . . . . . . . 11.3.1 Eine einfache Schaltfläche - JButton . . . . 11.3.2 Ein Texteingabefeld - JTextField . . . . . . 11.4 Container . . . . . . . . . . . . . . . . . . . . . . 11.5 LayoutManager . . . . . . . . . . . . . . . . . . . 11.5.1 FlowLayout . . . . . . . . . . . . . . . . . 11.5.2 BorderLayout . . . . . . . . . . . . . . . . 11.5.3 GridLayout . . . . . . . . . . . . . . . . . 11.5.4 BoxLayout . . . . . . . . . . . . . . . . . . 11.6 Ereignisse und deren Behandlung . . . . . . . . . 11.6.1 ActionListener . . . . . . . . . . . . . . . . 11.6.2 Events und Listener . . . . . . . . . . . . . 11.7 Java Applet vs. Java WebStart vs. Java Application 11.7.1 Java (Webstart) Application . . . . . . . . 11.7.2 Java Applets . . . . . . . . . . . . . . . . . 11.7.3 Java Sicherheitskonzepte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 Parallele Ausführung - Nebenläufigkeit - Threads 12.1 Das Threadmodell von Java . . . . . . . . . . . . . . . 12.2 Thread Pools . . . . . . . . . . . . . . . . . . . . . . . 12.3 Besonderheiten bei Swing - Der Event Dispatch Thread 12.4 Weiterführende Konzepte . . . . . . . . . . . . . . . . 12.5 Java Beans . . . . . . . . . . . . . . . . . . . . . . . . . 12.5.1 Konventionen . . . . . . . . . . . . . . . . . . . 12.5.2 Speichern und Laden . . . . . . . . . . . . . . . vi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148 148 150 151 151 152 153 154 155 156 156 157 158 159 159 159 . . . . . . . 161 . 161 . 164 . 165 . 168 . 168 . 168 . 169 1 Syntax und Semantik Bevor wir richtig in das Thema dieses zweiten Teiles der Vorlesung Algorithmen und Datenstrukturen einsteigen, werden in diesem Einführungskapitel zunächst einige der notwendigen Definitionen behandelt, um Programmiersprachen überhaupt formal exakt beschreiben und die Bedeutung ihrer Programme festlegen zu können: Formale Sprachen, Grammatiken, Syntax und Semantik. Dies wird anhand von drei Beispielen geschehen: der Steuerung eines Bohrkopfes, einer Sprache zur Beschreibung von RNA-Sekundärstrukturen und an Mini-Java, einer Teilsprache von Java. 1.1 Einführung Programmiersprachen sind formale Sprachen. Es ist präzise festgelegt, • welche Zeichenreihen überhaupt Programme einer Sprache L sind (Syntax), • welche Ein-/Ausgabefunktion ein Programm berechnet (Semantik). konkrete Syntax: genaue textuelle Aufschreibung für Programme. abstrakte Syntax: Bindeglied zur Semantik; gibt an, wie ein Programm(-stück) aufgebaut ist. Beispiel 1.1.1 konkret abstrakt assign x := y + 5 x=y+5 LET x = y + 5 ADD 5 TO y GIVING x STORE y + 5 TO x @ R @ x add var @ R @ Pascal C, Fortran, Java Basic (anno 1963) COBOL dBase const @ y R @ 5 1 1 Syntax und Semantik In der abstrakten Syntax tauchen die primitiven Konstrukte einer Programmiersprache auf, sowie Operatoren, die diese zu neuen Konstrukten kombinieren. primitiv: Bezeichner, Zahl Kombination: var: Bezeichner const: Zahl add: Ausdruck × Ausdruck assign: Bezeichner × Ausdruck → Ausdruck → Ausdruck → Ausdruck → Anweisung Für einen Sprachdesigner und den Übersetzer ist die abstrakte Syntax die wesentliche Programmdarstellung. Zum Programmieren dagegen braucht man die konkrete Syntax. Definition 1.1.2 • Ein Alphabet A ist ein endlicher Zeichenvorrat (eine endliche Menge). • Die Mengen aller endlichen Zeichenreihen über einem Alphabet A bezeichnen wir mit A∗ . • Das leere Wort der Länge 0 bezeichnen wir mit ε. Definition 1.1.3 Eine Menge L ⊆ A∗ heißt formale Sprache über dem Alphabet A. Einen abstrakteren Sprachbegriff kann man kaum definieren. Die einzige Frage, die man sich über w ∈ A∗ stellen kann, ist: Gilt w ∈ L oder w 6∈ L? Diese Frage nennt man das Wortproblem von L. Definition 1.1.4 Eine Programmiersprache ist ein Paar (L, L), wobei L ⊆ A∗ eine formale Sprache und L : L → (A∗ → A∗ ) die Semantik von L ist. Damit ordnet L jedem L-Programm l ∈ L als seine Bedeutung die Ein-/Ausgabefunktion L(l) zu, wobei Ein- und Ausgabe ebenfalls Zeichenreihen über A sind. Für L(l) schreiben wir auch kurz Ll. Definition 1.1.5 Eine kontextfreie Grammatik ist ein 4-Tupel G = (N, A, P, S), wobei 1. N ein Alphabet von sogenannten Nonterminalsymbolen, 2. A ein Alphabet von sogenannten Terminalsymbolen mit N ∩ A = ∅, 3. P eine endliche Menge von Produktionen (Regeln) der Form V → α mit V ∈ N und α ∈ (V ∪ A)∗ und 4. S ∈ N ein Startsymbol ist. 2 1.1 Einführung Die Menge S(G) der Satzformen von G ist die kleinste Teilmenge von (N ∪ A)∗ mit den folgenden Eigenschaften: 1. S ∈ S(G). 2. Wenn αV β ∈ S(G) für ein Nonterminalsymbol V ∈ N und Zeichenfolgen α, β ∈ (N ∪ A)∗ und wenn V → γ ∈ P eine Regel ist, so gilt auch αγβ ∈ S(G) („Ableitungsschritt“). def Die durch G definierte Sprache ist L(G) = S(G)∩A∗ . Den Test, ob ein gegebenes Wort w durch eine Grammatik G erzeugt werden kann, also ob w ∈ L(G) gilt, nennt man das Wortproblem von G. Beispiel 1.1.6 (Syntax und Semantik von Sprachen: Bohrkopfsteuerung) Wir betrachten sehr einfache Programme zur Steuerung eines Bohrkopfes. Das Gerät kennt die folgenden Befehle: N W E O S Bewegung um 1 Gitterpunkt (north, east, west, south) O: Einstellen auf Nullpunkt (origin) D: Senken des Bohrkopfes mit Bohrung (drill) U : Heben des Bohrkopfes (up) N, E, W, S: Programme zur Maschinensteuerung sind z.B. “ON N N EEDO” Bohrung am Punkt (2,3) mit Rückkehr zum Nullpunkt “ODU DU DU ” Dreifach-Bohrung am Nullpunkt “DU N DU W DU SDU ” bohrt Gitterquadrat, wo der Kopf gerade steht In der ersten Variante unserer Steuersprache lassen wir beliebige Befehlsfolgen zu. Außerdem kann jedes Programm als Eingabe ein Paar von Start-Koordinaten erhalten. L1 = {N, E, W, S, U, D, O}∗ Eingabe: (Int, Int) 3 1 Syntax und Semantik Formal heißt dies, dass zu unserem Alphabet auch Koordinatenpaare gehören, die aber in den Programmen selbst nicht auftreten. Intuitiv scheint die Semantik der Sprache klar – der Bohrkopf wird bewegt und bohrt mit jedem D-Befehl am aktuellen Gitterpunkt. Allerdings gibt es da ein paar Feinheiten, auf die wir später zurückkommen. Zunächst definieren wird die Semantik der Sprache L1 . Was soll die Ausgabe sein? Real ist es das gebohrte Werkstück. Mathematisch gesehen entspricht dem eine Liste der Koordinaten aller erfolgten Bohrungen. L1 : L1 → (Int, Int) → [(Int, Int)] Wir beschreiben die Semantik einzelner Befehle als Transformation des Maschinenzustands. Dieser enthält vier Komponenten: x y l cs aktuelle x-Koordinate aktuelle y-Koordinate aktueller Hebezustand des Bohrkopfes: 0 gesenkt, 1 oben Liste bisheriger Bohr-Koordinaten Den Effekt einzelner Befehle beschreibt die Funktion bef :: Befehl → Zustand → Zustand bef b (x, y, l, cs) = case b of O → (0, 0, 1, cs) N → (x, y + 1, l, cs) W → (x − 1, y, l, cs) S → (x, y − 1, l, cs) E → (x + 1, y, l, cs) D → (x, y, 0, (x, y) : cs) U → (x, y, 1, cs) Die Abarbeitung einer Befehlsfolge beschreibt die Funktion beff :: Befehl∗ → Zustand → [(Int, Int)] beff [ ](x, y, l, cs) = reverse cs beff (b : bs)(x, y, l, cs) = beff bs(bef b (x, y, l, cs)) Am Ende drehen wir die Liste der Bohrkoordinaten um, damit sie in der getätigten Reihenfolge erscheint. Mathematisch gesehen ist das nicht nötig. Die Semantik L1 wird beschrieben durch die Funktion prog :: Befehl∗ → (Int, Int) → [(Int, Int)] prog bs (i, j) = beff bs (i, j, 1, []) Programmiersprache L1 und ihre Semantik L1 sind sehr naiv. Man sollte zum Beispiel bedenken, dass das Werkstück und vielleicht auch die Maschine beschädigt 4 1.1 Einführung werden, wenn mit abgesenktem Bohrkopf Bewegungen ausgelöst werden. Freilich – solange niemand solche Steuerprogramme erzeugt, geht alles gut. Jedoch wollen wir uns nicht darauf verlassen . . . Generell gibt es zwei Möglichkeiten, eine Sprache (L, L) zu verfeinern: syntaktisch oder semantisch. Semantisch heisst: das “Unglück” kann programmiert werden, tritt aber nicht ein. Syntaktisch heisst: das “Unglück” wird bereits als syntaktisch fehlerhaftes Programm abgewiesen. Aufgabe 1.1.7 Modifiziere die Semantik L1 (bei unverändertem L1 ) in zwei verschiedenen Weisen. Tritt eine Bewegung mit abgesenktem Bohrkopf auf, so wird a) die Ausführung des Programms abgebrochen und nur die Koordinaten der bisherigen Bohrungen ausgegeben, b) die Ausführung des Programms abgebrochen und keine Koordinaten angegeben. Was bedeuten diese semantischen Unterschiede in der Bohrpraxis? Wir entscheiden uns nun für die syntaktische Verfeinerung und stellen Forderungen an die Steuersprache L: 1. Auf Befehl D muss stets U oder O folgen. 2. Befehl O ist immer möglich, U nur unmittelbar nach D. 3. Alle Programme enden mit dem Bohrkopf am Nullpunkt. 4. Auf dem Weg von einer Bohrung zur nächsten sind gegenläufige Richtungswechsel unerwünscht, zum Beispiel . . . N SN S . . . oder . . . N ES . . . , weil sie die Maschine in Schwingung versetzen können. Alle diese Forderungen lassen sich durch Einschränkungen von L1 erfüllen, erfordern aber verfeinerte Methoden zur syntaktischen Sprachbeschreibung. Solche Mittel sind Grammatiken. Grammatik G1 (zur Beschreibung von L1 ) A N S P ={ ={ = ={ N, E, W, S, U, D, O} moves, move} moves moves → ε| move moves move → N |E|W |S|U |D|O} 5 1 Syntax und Semantik Hier gilt L(G1 ) = A∗ . Es ist w ∈ L(G1 ) mit w = “W EN DEDEN U DOSU ED00 Übung: Leite w mit dieser Grammatik ab. Verfeinerte Grammatik G2 (berücksichtigt Forderungen (1) - (3), aber nicht (4)). A, N, S wie G1 P = { moves → O|DO| move moves move → N |E|W |S|O|DU |DO} Frage: Warum brauchen wir die Regel moves → DO überhaupt? Antwort: Sonst ist “DO” ∈ / S(G2 ). Warum ist nun w = “W EN DEDEN U DOSU ED” ∈ / S(G2 )? Versuch einer Ableitung: moves → → → → →3 → move moves W moves W move moves W E moves W E N move moves W E N ? Hier kann nur DU oder DO erzeugt werden, aber nicht D allein oder DE. Verfeinerte Grammatik G3 (berücksichtigt Forderungen (1) und (4), (2) nur teilweise und (3) gar nicht): A, S wie G1 N = { moves, ne, nw, se, sw, drill } P = { moves → ε | ne moves | nw moves | se moves | sw moves ne → N ne | E ne | drill se → S se | E se | drill nw → N nw | W nw | drill sw → S sw | W sw | drill drill → DU |DO } Durch die Nichtterminale ne, nw, se, sw entscheidet die Grammatik zunächst über die grobe Lage des nächsten Bohrloches, das dann ohne gegenläufigen Richtungswechsel angesteuert wird. Aufgabe 1.1.8 Schreibe eine Grammatik, die alle Forderungen (1) - (4) berücksichtigt. 6 1.1 Einführung Aufgabe 1.1.9 Schreibe eine Grammatik, deren Programme den Befehl O nicht benutzen und den Bohrkopf nach der letzten Bohrung in Umkehrung des gefahrenen Weges zum Startpunkt zurückführen. Ignoriere Forderungen (1) - (4). Hinweis: Verwende Produktionen der Form moves → N moves N . . . Beispiel 1.1.10 (RNA-Sekundärstrukturen) Ein RNA-Molekül besteht aus Basen A,C,G,U. Durch Wasserstoff-Brücken zwischen A–U, G–C, G–U bilden sich Basenpaarungen, die zu einer Sekundärstruktur führen. Primärsequenz C A C C U A A G G U C C Sekundärstruktur C A C C U A A G G U C C C C A C C U ← GHelix-Bildung schafft Stabilität C G U A A Grammatik zur Beschreibung der Sekundärstruktur (Ausschnitt): A = { A, C, G, U } N = { struct, any, stack, loop } S= struct P={ struct → any | any struct | struct any | stack | ε any → A | C | G | U stack → A stack U | U stack A | G stack C | C stack G | G stack U | U stack G | loop loop → any loop | any any any } Allein mit den ersten beiden Produktionen kann man alle RNA-Sequenzen ableiten: struct → any struct → A struct → A any struct → AC struct . . . Damit ist L(G) = A∗ . Der Witz der Grammatik ist, dass manche Ableitungen das Vorliegen möglicher Sekundärstrukturen anzeigen – dann nämlich, wenn sie die Produktionen für stack benutzen. struct →2 C struct →4 C struct CC → C stack CC → CA stack UCC →2 CACC stack GGUCC → CACC loop GGUCC →4 CACCUAAGGUCC 7 1 Syntax und Semantik Noch deutlicher wird das Erkennen der Sekundärstruktur, wenn wir diese Ableitung als sogenannten Syntaxbaum ausschreiben: struct C any struct H structHanyHH H C struct anyHH C stack H H A stack U H C H H G stackH H C stack G U loopH H any any anyHH A A Wer also mit dieser Grammatik und einer gegebenen RNA-Sequenz w das Wortproblem w ∈ L(G) löst, kann Aussagen über mögliche Sekundärstrukturen machen. Die Chomsky-Hierarchie In den 50er Jahren wurden von Noam Chomsky vier Typen von Grammatiken vorgestellt, die unterschiedlich komplexe Klassen von Produktionen erlauben. Wir geben jeweils ein Beispiel für den erlaubten Regeltyp an. Es ist X, Y, Z ∈ N , a, b, c ∈ A. Typ 0: Typ 1: Typ 2: Typ 3: XaY b aXb X X → → → → cXZ aZY b aY bZc aY (allgemein) (kontextsensitiv) (kontextfrei) (regulär) Die Sprachklassen vom Typ 0 – 3 bilden eine echte Hierarchie, ihr Wortproblem liegt in unterschiedlichen Komplexitätsklassen: Typ 0: Typ 1: Typ 2: unentscheidbar exponentiell polynomiell (Θ(n3 ) oder besser; bei Grammatiken für Programmiersprachen in der Regel Θ(n)) Typ 3: linear 8 1.2 EBNF-Definitionen 1.2 EBNF-Definitionen Historisches: • Syntaxbeschreibung von FORTRAN und COBOL (am Anfang) durch Beispiele und Gegenbeispiele. • 1958 formale Beschreibung der Syntax von ALGOL durch John Backus; Backus-Normalform (BNF). • Kleine Verbesserungen in der Notation durch Peter Naur, daher spricht man heute von der Backus-Naur-Form (BNF). • Niklaus Wirth hat die Backus-Naur-Form noch einmal überarbeitet und erweitert (EBNF – Extended BNF). Definition 1.2.1 Die Metazeichen der EBNF (vgl. Klaeren [7], S. 104) sind: das Definitionszeichen = das Alternativzeichen | die Anführungszeichen " " die Wiederholungsklammern { } die Optionsklammern [ ] die Gruppenklammern ( ) der Punkt . Die Menge ET der EBNF-Terme ist gegeben durch: 1. Ist V eine Folge von Buchstaben und Ziffern, die mit einem Buchstaben beginnt, so gilt V ∈ ET und gilt als Nonterminalsymbol. 2. Ist w eine Folge von beliebigen Symbolen, so ist “w”∈ ET und gilt als ein (!) Terminalsymbol. 3. Für α ∈ ET sind auch a) (α) ∈ ET , b) [α] ∈ ET und c) {α} ∈ ET . 4. Für α1 , . . . , αn ∈ ET sind auch a) α1 | . . . |αn ∈ ET und b) α1 α2 . . . αn ∈ ET . 9 1 Syntax und Semantik Eine EBNF-Definition besteht aus einer endlichen Menge von EBNF-Regeln der Form V = α. wobei V ein Nonterminalsymbol entsprechend obiger Konvention und α ein EBNF-Term ist. Das Nonterminalsymbol auf der linken Seite der ersten Regel ist das Startsymbol. Beispiel 1.2.2 EBNF-Definition für Mini-Java, einer Teilsprache von Java. program = “class” ident “{” mainMethod “}”. mainMethod = “public” “static” “void” “main” “(” “String” “[” “]” argsIdent “)” block. statement = “int” ident “=” expression “;” | ident “=” expression “;” | “if” “(” condition “)” statement | “while” “(” condition “)” statement | block | “System” “.” “out” “.” “println” “(” expression “)” “;” | “;” | “int” “[” “]” arrayIdent “=” “new” “int” “[” expression “]” “;” | arrayIdent “[” expression “]” “=” expression “;”. block = “{” { statement } “}”. condition = expression ( “==” | “!=” | “<” | “<=” | “>” | “>=” ) expression. expression = [ ( “+” | “-” ) ] term { ( “+” | “-” ) term }. term = factor { ( “*” | “/” ) factor }. factor = ident | number | “(” expression “)” | “Integer” “.” “parseInt” “(” argsIdent “[” expression “]” “)” | argsIdent “.” “length” | arrayIdent “.” “length” | arrayIdent “[” expression “]”. ident = ( letter | “_” | “$” ) { letter | digit }. number = ( “0” | digit { digit | “0” } ). digit = “1” | “2” | . . . | “9”. letter = “A” | . . . | “Z” | “a” | . . . | “ z”. argsIdent = ident. arrayIdent = ident. 10 1.2 EBNF-Definitionen Um die Semantik einer EBNF-Definition zu definieren, benötigen wir folgende Operationen auf Sprachen: Definition 1.2.3 Seien L, L1 und L2 beliebige Sprachen (Wortmengen) über einem gemeinsamen Alphabet. Dann definieren wir: def 1. Komplex-Produkt: L1 L2 = {w1 w2 | w1 ∈ L1 , w2 ∈ L2 } (also L∅ = ∅L = ∅; L{ε} = {ε}L = L) def 2. n-fache Iteration: L0 = {ε}, Ln+1 := LLn def 3. Stern-Operation: L∗ = S n∈N Ln Beispiel 1.2.4 1. {aa, ab} {aa, ε} = {aaaa, abaa, aa, ab} 2. {a, b, c}2 = {a, b, c} {a, b, c} = {aa, ab, ac, ba, bb, bc, ca, cb, cc} 3. {a, b}∗ = {ε, a, b, aa, ab, ba, bb, . . . } Definition 1.2.5 Die Semantik der EBNF definieren wir durch Rekursion über die EBNF-Terme. Sei E eine EBNF-Definition (wobei S das Startsymbol, N die Menge der Nonterminals und A die Menge der Terminals sei) und ET die Menge der EBNF-Terme. Dann ist die von E erzeugte Sprache L(E) definiert als JSKE , wobei J KE : ET ; P (A∗ ) wie folgt definiert ist (vgl. Klaeren [7], S. 107): JαKE falls V = α. eine Regel in E ist def 1. Für V ∈ N ist JV KE = ∅ sonst def 2. J“w”KE = {w} 3. q y def (α) E = JαKE 4. q y def [α] E = {ε} ∪ JαKE 5. q y def {α} E = JαK∗E def 6. Jα1 . . . αn KE = Jα1 KE . . . Jαn KE def 7. Jα1 | · · · | αn KE = Jα1 KE ∪ · · · ∪ Jαn KE 11 1 Syntax und Semantik Beispiel 1.2.6 Gegeben sei die EBNF-Definition E mit dem Startsymbol Rna sowie den beiden Regeln und Rna = Any [“A”] Any = (“A” | “C” | “G” | “U”). Durch wiederholte Anwendung der verschiedenen Gleichungen aus der Semantikdefinition ergibt sich die von dieser EBNF definierte Sprache folgendermaßen: (1) JRnaKE = (6) = (1),(4) = (3),(2) = (7) = = = = q y Any [“A”] E q y JAnyKE [“A”] E q y (“A” | “C” | “G” | “U”) E {ε} ∪ J“A”KE J“A” | “C” | “G” | “U”KE {ε} ∪ {A} J“A”KE ∪ J“C”KE ∪ J“G”KE ∪ J“U”KE {ε, A} {A} ∪ {C} ∪ {G} ∪ {U} {ε, A} {A, C, G, U}{ε, A} {A, C, G, U, AA, CA, GA, UA}. Beispiel 1.2.7 Folgende Zeichenkette ist ein syntaktisch korrektes Mini-Java Programm (gemäß der EBNF-Definition aus Beispiel 1.2.2). class BubbleSort { public static void main(String[] args) { int[] array = new int[args.length]; int i = 0; while (i < args.length) { array[i] = Integer.parseInt(args[i]); i = i+1; } i = 1; while (i < array.length) { int j = array.length - 1; while (j >= i) { if (array[j] < array[j-1]) { int tmp = array[j]; array[j] = array[j-1]; array[j-1] = tmp; } j = j-1; 12 1.3 Syntaxdiagramme } i = i+1; } i = 0; while (i < array.length) { System.out.println(array[i]); i = i+1; } } } 1.3 Syntaxdiagramme Eine EBNF-Definition kann man folgendermaßen in Syntaxdiagramme überführen: “w” : - V: w - für alle w ∈ A. - V - für alle V ∈ N . - α [α] : ? - α {α} : α1 . . . αn : α1 | · · · | αn : ? - α1 - - α1 .. . - αn ··· - - αn - 6 13 1 Syntax und Semantik Beispiel 1.3.1 Syntaxdiagramme für Mini-Java program - class ident - {n- mainMethod - }n - mainMethod - public - static - void - main - (m- String - [m- ]m- argsIdent - )m- block - statement -expression - ;m int - ident - =m - ident - =m- expression - ;m - if - (m- condition - )m- statement - while - (m- condition - )m- statement - block - System - .m- out - .m- println - (m-expression - )m- ;m - ;m - int - [m- ]m-arrayIdent - =m - new - int - [m-expression - ]m- ;m -arrayIdent - [m-expression - ]m- =m-expression - ;m - block - { 6 condition statement } - - expression expression 6 14 + - 6 - - term 6 term + 6 - - 6 6 6 6 6 6 6 6 - != ?- expression - == 6 - < 6 - <= 6 - >= 6 - > - 1.3 Syntaxdiagramme term - factor 6 factor factor * / - - ident 6 - number 6 - (n- expression - )n 6 - Integer - .n- parseInt - (n- argsIdent - [n- expression - ]n- )n 6 - argsIdent - .n- length 6 - arrayIdent - .n- length 6 - arrayIdent - [n- expression - ]n number ident - letter - _ - $ digit 6 6 6 6 6 - 1 6 - ... 6 - 9 argsIdent - ident letter - digit 6 digit - 0 digit 6 0 letter - A 6 - ... 6 - Z 6 - a 6 - ... 6 - z arrayIdent - - ident - 15 1 Syntax und Semantik 1.4 Aufgaben Aufgabe 1.4.1 Der Linguist N OAM C HOMSKY war sehr enttäuscht, als er feststellte, dass auch die von ihm klassifizierte Typ-0 Sprache sich nicht zur Beschreibung der englischen Sprache eignete. Erst recht hat die kontexfreie Grammatik (Typ-2 Sprache) ihre Grenzen in der Beschreibungsfähigkeit von Sprachen. (a) Die Sprache (an bn cn ) lässt sich nicht durch eine kontextfreie Grammatik beschreiben. Geben Sie dafür einen plausiblen Grund an (keinen Beweis). (b) Geben Sie eine kontextfreie Grammatik an, die die Sprache (an bm cn ) beschreibt. Aufgabe 1.4.2 Palindrome sind Worte, Sätze oder Verse, die von rechts nach links gelesen gleich lauten wie von links nach rechts1 (abgeleitet vom griechischen palindromos für „wieder zurücklaufend“). Geben Sie eine EBNF-Definition an, welche die Sprache der Palindrome über dem Alphabet A = {a, b, c} beschreibt. Aufgabe 1.4.3 Ribosomen übernehmen in biologischen Zellen eine wichtige Aufgabe bei der Proteinbiosynthese: Sie setzen aus einzelnen Aminosäuren ein bestimmtes Protein zusammen. Der Syntheseweg wird durch eine Nukleotidensequenz bestimmt, der mRNA. Jeweils drei Nukleotide werden zu einem sogenannten Triplett zusammengefasst; fast alle der möglichen Tripletts (Anzahl 43 = 64) codieren für Proteine, diese Tripletts werden Codons genannt. Im folgenden soll ein Mini-Ribosom besprochen werden. Unser Ribosom verarbeitet als mRNA eine Sequenz über dem Alphabet {A, C, G, U } zu den Proteinen Asparagin, Glutamin, Arginin und Lysin. Das Ribosom erkennt eine korrekte mRNA dann, wenn sie aus einem Codon besteht, welches links von dem Startcodon Methionin und rechts von einem Stopcodon flankiert wird. Das Ribosom basiert auf der Grammatik G = (N, A, P, Protein), deren Komponenten unten definiert sind. (a) Erstellen Sie die zur Grammatik gehörende EBNF-Definition und die entsprechenden Syntaxdiagramme. (b) Bestimmen Sie die von der EBNF-Definition erzeugte Sprache (die Semantik der EBNF-Definition). (c) In welchem gravierenden, unrealistischen Punkt weicht die Grammatik von der biologischen Realität ab? 1 16 Radar Madam I’m Adam Roma tibi subito motibus ibit amor Ein Neger mit Gazelle zagt im Regen nie Able was I ere I saw Elba 1.4 Aufgaben N = {Protein, Aminosäure, Met, Glu, Asp, Arg, Lys, Start, Stop} A = {A, C, G, U} P = { Protein → Start Aminosäure Stop Start → Met Aminosäure → Asp Aminosäure → Glu Aminosäure → Lys Aminosäure → Arg Stop → UAA Met → AUG Glu → GAA Glu → GAG Asp → GAC Asp → GAU Arg → CGC Arg → CGG Arg → CGA Arg → CGU Arg → AGA Arg → AGG Lys → AAG Lys → AAA Stop → U A A} 17 1 Syntax und Semantik 18 2 Java: Der Einstieg Im vorigen Kapitel haben wir die Syntax der Sprache Mini-Java kennengelernt. Bevor wir in Kapitel 3 ausführlich auf Syntax und Semantik der Programmiersprache Java eingehen werden, soll dieses Kapitel zunächst einige grundlegende Vorbemerkungen zu Java machen, sowie zu den beiden zugrundeliegenden Sprachkonzepten, der imperativen und der objektorientierten Programmierung. 2.1 Grundlegendes zu Java 2.1.1 Historisches • 1990-1991: Entwicklung der Programmiersprache OAK durch James Gosling von Sun Microsystems (zunächst für Toaster, Mikrowellen etc.; unabhängig vom Chip, extrem zuverlässig) • Umbenennung in Java • 1995: α und β Release • von IBM, SGI, Oracle und Microsoft lizensiert 2.1.2 Eigenschaften von Java • durch den Bytecode (Zwischensprachencode) unabhängig von der Plattform • Syntax an C und C++ angelehnt • objektorientiert • streng typisiert • unterstützt parallele Abläufe (Nebenläufigkeit / Threads) • Graphical User Interface (GUI) • netzwerkfähig 19 2 Java: Der Einstieg • modularer Aufbau • Nachteil: Effizienz leidet (ca. 5–10mal langsamer als C und C++) Selbstständig laufende Anwendung Quellprogramm Java-Compiler ? Java-Bytecode Java-Interpreter ? Ablauf des Programms Applet Quellprogramm Java-Compiler ? Java-Bytecode auf dem Server Übertragung per Internet ? Bytecodes auf dem Rechner des Benutzers (Client) Java-Interpreter im Browser oder Applet-Viewer ? Ablauf des Programms A BBILDUNG 2.1: Vom Quellprogramm zum Ablauf Abbildung 2.1 zeigt die notwendigen Schritte, um ein Java-Programm (selbstständig laufende Anwendung) bzw. ein Applet ablaufen zu lassen. Ein Applet ist eine in ein HTML-Dokument eingebettete Anwendung, die auf dem Rechner des Anwenders abläuft. Wir beschränken uns in diesem Skript auf selbstständig laufende Anwendungen. 20 2.1 Grundlegendes zu Java 2.1.3 Sicherheit • keine Pointerarithmetik wie in C • Garbage Collection1 • Überprüfungen zur Laufzeit (Datentypen, Indizes, etc.) durch Mechanismen zur Verifizierung von Java-Bytecode bei der Übertragung • dennoch ist die (Netz-)Sicherheit umstritten 2.1.4 Erstellen eines Java-Programms 1. Quellprogramm erstellen: class Hello { public static void main(String[] args) { System.out.println("Hello World"); } } Dieses Programm besteht aus der Klasse Hello. Der Klassenname muss mit dem Dateinamen übereinstimmen, d.h. das oben gezeigte Programm muss in der Datei Hello.java abgelegt sein. Die Klasse Hello enthält eine Methode namens main. Ein Java-Programm muss eine main-Methode enthalten, denn die main-Methode wird bei der Interpretation aufgerufen. 2. Übersetzen eines Programms: > javac Hello.java liefert eine Datei Hello.class, die das Programm in Bytecode enthält. 3. Interpretation des Bytecodes: > java Hello 1 Ein Garbage Collector entfernt automatisch Objekte, Felder und Variablen, auf die keine Referenz mehr vorhanden ist, aus dem Speicher (siehe Arnold & Gosling [1], S. 12, Kapitel 1.6). 21 2 Java: Der Einstieg 2.2 Grundzüge imperativer Sprachen 2.2.1 Das Behältermodell der Variablen Imperative Programmierung geht aus vom Modell eines Speichers, aufgegliedert in einzelne Variablen, in denen Werte abgelegt werden können. Der Speicher bzw. die Variablen werden verändert durch Befehle bzw. Anweisungen, die selbst vom aktuellen Speicherinhalt abhängen. Ein typisches Beispiel ist die Anweisung x = y + z. Sie bedeutet: Addiere die Variableninhalte von y und z und lege die Summe in der Variablen x ab. Während diese Semantikbeschreibung nun suggeriert, dass der einzige Effekt der Anweisung eine Änderung des Variableninhalts von x ist, ist in der Tat diese Vorstellung zu einfach: • nicht nur der Variableninhalt von x kann sich ändern, sondern auch der von y und z sowie aller möglicher anderer Variablen; • falls x ≡ y oder y ≡ z, dann ist dies unmittelbar einsichtig. Also ist “x = y + z” keine Gleichheit, die zwischen den Werten (Inhalten) von x, y und z gilt, und mittels der wir über Programme nachdenken und Beweise führen können. Das Prinzip der „referential transparency“ (Werttreue), das in der funktionalen Programmierung gilt, ist in der imperativen verletzt. 2.2.2 Konsequenzen 1. Der Nachweis von Programmeigenschaften wird viel schwieriger, ebenso das Verstehen von Programmen. 2. Die Semantik eines Programms hängt von einem strikten Nacheinander der Ausführung der einzelnen Anweisungen ab. 3. Wiederverwendung von Programmteilen in anderem Kontext bedarf besonderer Vorsicht. 2.3 Klassen, Objekte und Methoden im Überblick Java-Programme werden aus Klassen aufgebaut. Aus einer Klassendefinition lassen sich beliebig viele Objekte erzeugen, die auch Instanzen genannt werden (vgl. Arnold & Gosling [1], Kapitel 1.6 und 1.7). 22 2.3 Klassen, Objekte und Methoden im Überblick Eine Klasse enthält folgende Bestandteile: • Objektvariablen (objektbezogene Datenfelder) • objektbezogene Methoden • Klassenvariablen (klassenbezogene Datenfelder) • klassenbezogene Methoden Datenfelder (Synonym: Attribute) enthalten den Zustand des Objektes oder der Klasse. Methoden sind Sammlungen von imperativ formulierten Anweisungen, die auf den Datenfeldern operieren, um deren Zustand zu ändern. 2.3.1 Klassen Beispiel der Deklaration einer einfachen Klasse: class Point { double x, y; } In der Klasse Point sind zwei Objektvariablen deklariert, die die x- und y-Koordinate eines Punktes repräsentieren. double bedeutet, dass die Variablen vom Typ double2 sind. Bis jetzt gibt es noch kein Objekt vom Typ Point, nur einen “Plan”, wie ein solches Objekt aussehen wird. 2.3.2 Das Erzeugen von Objekten Objekte werden mit dem Schlüsselwort new erzeugt. Neu geschaffene Objekte bekommen innerhalb eines Bereiches des Speichers (welcher Heap genannt wird) einen Speicherplatz zugewiesen und werden dort abgelegt. Auf alle Objekte in Java wird über Objektreferenzen zugegriffen – jede Variable, die ein Objekt zu enthalten scheint, enthält tatsächlich eine Referenz auf dieses Objekt (bzw. auf deren Speicherplatz). Objektreferenzen haben den Wert null, wenn sie sich auf kein Objekt beziehen. Wir werden im folgenden Objekte und Objektreferenzen synonym verwenden, es sei denn, die Unterscheidung ist wichtig. Erzeugung und Initialisierung Point lowerLeft = new Point(); Point upperRight = new Point(); 2 Gleitkommazahlen 23 2 Java: Der Einstieg Wertzuweisung lowerLeft.x = 0.0; lowerLeft.y = 0.0; upperRight.x = 1280.0; upperRight.y = 1024.0; 2.3.3 Klassenvariablen class Point { double x, y; static Point origin = new Point(); } Wird eine Variable als static deklariert, so handelt es sich um eine Klassenvariable. Durch obige Deklaration gibt es genau eine Klassenvariable names Point.origin, egal ob und wie viele Point-Objekte erzeugt werden. Point.origin hat den Wert (0,0), weil dies die Voreinstellung für numerische Datenfelder ist, die nicht explizit auf einen anderen Wert initialisiert werden (das wird später noch genauer besprochen). Allerdings kann der Wert von Point.origin geändert werden. Ist dies nicht erwünscht, soll Point.origin also eine Konstante sein, so muss man den Modifizierer final benutzen. static final Point origin = new Point(); 2.3.4 Methoden Eine Methode ist eine Funktion bzw. Prozedur. Sie kann parameterlos sein oder Parameter haben. Sie kann einen Rückgabewert liefern oder als void deklariert sein, wenn sie keinen Wert zurückliefert. Methoden dürfen nicht geschachtelt werden. Innerhalb von Methoden dürfen lokale Variablen deklariert werden. class Point { double x, y; void clear() { x = 0.0; y = 0.0; } } Um eine Methode aufzurufen, gibt man ein Objekt und den Methodennamen, getrennt durch einen Punkt, an. 24 2.4 Vererbung, Pakete und Gültigkeitsbereiche lowerLeft.clear(); upperRight.clear(); Nun definieren wir eine Methode, die die Distanz zwischen dem Punkt, auf den sie angewendet wird und einem übergebenen Punkt p zurückgibt. (Math.sqrt() ist vordefiniert und liefert die Wurzel einer Zahl.) double distance(Point p) { double xdiff, ydiff; // Beispiel fuer lokale Variablen xdiff = x - p.x; ydiff = y - p.y; return Math.sqrt(xdiff*xdiff + ydiff*ydiff); } Aufruf: double d = lowerLeft.distance(upperRight); 2.3.5 Klassenbezogene Methoden Klassenbezogene Methoden werden durch das Schlüsselwort static deklariert, z.B. ist Math.sqrt() eine Klassenmethode der vordefinierten Klasse Math. “distance” als Klassenmethode static double distance(Point p1, Point p2) { double xdiff = p1.x - p2.x; double ydiff = p1.y - p2.y; return Math.sqrt(xdiff*xdiff + ydiff*ydiff); } Aufruf: double d = Point.distance(lowerLeft, upperRight); In der Klasse Point selbst kann man Point. weglassen, d.h. es reicht ein Aufruf der Form: double d = distance(lowerLeft, upperRight); 2.4 Vererbung, Pakete und Gültigkeitsbereiche 2.4.1 Vererbung Klassen in Java können um zusätzliche Variablen und Methoden erweitert werden. Dies wird durch das Schlüsselwort extends angezeigt. Die entstehende Unterklasse besitzt dann alle Eigenschaften der Oberklasse und zusätzlich die in der jeweiligen Erweiterung angegebenen Eigenschaften. Dieses Konzept wird auch als Vererbung bezeichnet, weil die Unterklasse alle Eigenschaften der Oberklasse erbt. 25 2 Java: Der Einstieg Zum Beispiel ist ein farbiger Punkt eine Erweiterung eines Punktes: class ColoredPoint extends Point { String color; } 2.4.2 Pakete Bei größeren Softwareprojekten ist es häufig ratsam, diese in verschiedene, unabhängige Teile aufzuteilen. Solche Teile werden als Module oder Pakete bezeichnet. Java besitzt einige Eigenschaften, die es erlauben, Software modular aufzubauen: Verschiedene (i.d.R. logisch zusammengehörige) Klassen können in einem Paket zusammengefasst werden. Die Klassendefinitionen können in verschiedenen Dateien enthalten sein. Der Paketname muss im Header jeder Datei angegeben sein: A.java package abc; public class A { ... } C.java package abc; class C { ... } class B { ... } Um Definitionen aus anderen Paketen sichtbar zu machen, müssen sie mit dem Schlüsselwort import importiert werden: import abc.A; class test { A a; } Weitere Details zu Paketen folgen in Kapitel 7.2. 2.4.3 Gültigkeitsbereiche Bei dem Zugriff auf Definitionen anderer Klassen oder Pakete gelten Beschränkungen, die beachtet werden müssen. Die Zugriffsrechte werden durch Gültigkeitsmodifizierer geregelt, die sich folgendermaßen auf Datenfelder, Methoden und Klassen auswirken (für Klassen gibt es nur public und default): 26 2.5 Aufgaben zugreifbar für Nicht-Unterklassen im selben Paket zugreifbar für Unterklassen im selben Paket zugreifbar für Nicht-Unterklassen in einem anderen Paket zugreifbar für Unterklassen in einem anderen Paket public ja default (package) ja protected ja private nein ja ja ja nein ja nein nein nein ja nein ja nein 2.5 Aufgaben Aufgabe 2.5.1 Erstellen Sie eine Klasse Circle, deren Instanzen Kreise im zweidimensionalen Raum repräsentieren. Ein Kreis ist z.B. bestimmt durch seinen Radius r und durch die x- und y-Koordinate seines Mittelpunktes in der Ebene. Implementieren Sie für diese Klasse folgende Methoden: (i) circumference berechnet den Umfang des Kreises. (ii) area berechnet den Inhalt des Kreises. Wie können die Methoden in einem Programm aufgerufen werden? 27 2 Java: Der Einstieg 28 3 Imperative Programmierung in Java Im vorigen Kapitel haben wir generelle Eigenschaften der imperativen wie der objektorientierten Programmierung kennengelernt. Auch Teile der Syntax der objektorientierten Anteile von Java wurden vorgestellt. Dieses Kapitel wird sich nun mit der Syntax und Semantik der imperativen Anteile von Java beschäftigen, d.h. im wesentlichen mit den Rümpfen von Methoden. Weder Syntax noch Semantik werden allerdings formal eingeführt, wie wir es in Kapitel 1 kennengelernt haben, sondern im wesentlichen anhand von Beispielen. Zunächst wird die Semantik von Mini-Java erläutert, dessen Syntax wir bereits in Kapitel 1 definiert haben. Danach werden die wichtigsten der noch fehlenden Java-Konstrukte vorgestellt. Eine komplette Syntaxbeschreibung von Java findet sich auf der folgenden SUN-Webseite: http://java.sun.com/docs/books/jls/second_edition/html/syntax.doc.html 3.1 Mini-Java Ein Mini-Java Programm besteht aus genau einer Klasse. In dieser Klasse gibt es genau eine main-Methode. Folgende Konstrukte sind Anweisungen (statements gemäß Mini-Java-Syntax, vgl. Beispiel 1.2.2): 1. Die Deklaration einer Variablen vom Typ int mit sofortiger Initialisierung: int ident = expression; Jeder Bezeichner (ident) darf in höchstens einer Variablendeklaration vorkommen. Diese kontextsensitive Bedingung lässt sich nicht in der EBNFDefinition formulieren. 2. Die Zuweisung eines Wertes an eine Variable: ident = expression; Diese Variable muss vorher deklariert worden sein und den gleichen Typ wie der Ausdruck haben. Diese Nebenbedingung ist ebenfalls nicht in der EBNF-Definition ausgedrückt. 29 3 Imperative Programmierung in Java 3. Eine bedingte Anweisung (if-then Anweisung): if(condition) statement Der Bool’sche Ausdruck (condition) wird ausgewertet; ist er true, so wird die Anweisung (statement) ausgeführt. Ist er false, so wird die Anweisung nicht ausgeführt und die Programmausführung mit der nächsten Anweisung hinter der if-then Anweisung fortgesetzt. 4. Eine abweisende Schleife (while-Schleife): while(condition) statement Der Bool’sche Ausdruck wird ausgewertet; ist er true, so wird die Anweisung so lange ausgeführt, bis der Bool’sche Ausdruck false wird. 5. Ein Block. { statement1; statement2; . . . } Die Statements in der geschweiften Klammer werden von links nach rechts nacheinander abgearbeitet. 6. Eine Anweisung zum Schreiben auf der Standardausgabe: System.out.println(...); System ist eine Klasse, die klassenbezogene Methoden zur Darstellung des Zustandes des Systems bereitstellt. out ist eine Klassenvariable der Klasse System, ihr Inhalt ist der Standardausgabestrom. Die Methode println wird also auf das klassenbezogene Datenfeld out angewendet – es wird ein String mit abschließendem Zeilenvorschub auf dem Standardausgabestrom ausgegeben. 7. Die leere Anweisung. ; Es geschieht nichts. 8. Die Deklaration eines eindimensionalen Feldes (Arrays) mit sofortiger Initialisierung: int[] array = new int[3]; deklariert ein Feld namens array, erzeugt ein Feld mit drei int-Komponenten und weist dieses der Feldvariablen array zu. Beachte, dass die Dimension (3) einer Feldvariablen nicht bei der Deklaration (int[] array) angegeben wird, sondern nur bei der Erzeugung (new int[3]). Die erste Komponente eines Feldes hat den Index 0. Die Länge des Feldes kann aus dessen Datenfeld length ausgelesen werden (array.length). 30 3.1 Mini-Java 9. Die Zuweisung eines Wertes an die i-te Komponente eines Feldes, wobei 0 ≤ i ≤ array.length−1: array[i] = expression; Alle weiteren Konstrukte haben eine offensichtliche Bedeutung, bis auf Integer.parseInt(argsIdent[expression]) Betrachten wir hierfür noch einmal die main-Methode: Sie hat als Parameter ein Feld von Zeichenketten. Diese Zeichenketten sind die Programmargumente und werden normalerweise vom Anwender beim Programmaufruf eingegeben. class Echo { public static void main(String[] args) { int i = 0; while(i < args.length) { System.out.println(args[i]); i = i+1; } } } > java Echo 10 2 10 2 > Um eine Zeichenkette in eine ganze Zahl zu konvertieren, wird die Klassenmethode parseInt der Klasse Integer mit dieser Zeichenkette als Argument aufgerufen. Sie liefert die entsprechende ganze Zahl als Ergebnis zurück bzw. meldet einen Fehler, falls die Zeichenkette keine ganze Zahl dargestellt hat. class BadAddOne { public static void main(String[] args) { int i = 0; while(i < args.length) { int wert = args[i]; wert = wert+1; System.out.println(wert); i = i+1; } } } 31 3 Imperative Programmierung in Java > javac BadAddOne.java BadAddOne.java:6: Incompatible type for declaration. Can’t convert java.lang.String to int. int wert = args[i]; > Stattdessen muss eine explizite Typkonvertierung stattfinden: class AddOne { public static void main(String[] args) { int i = 0; while(i < args.length) { int wert = Integer.parseInt(args[i]); wert = wert+1; System.out.println(wert); i = i+1; } } } > java AddOne 6 3 20 7 4 21 > 3.2 Von Mini-Java zu Java Jedes Mini-Java Programm ist ein Java Programm. In diesem Abschnitt werden die Datentypen und imperativen Konstrukte von Java erläutert, die nicht bereits in Mini-Java vorhanden sind. 3.2.1 Elementare Datentypen Unicode: Java, als Sprache für das World Wide Web, benutzt einen 16-Bit Zeichensatz, genannt Unicode. Die ersten 256 Zeichen von Unicode sind identisch mit dem 8-Bit Zeichensatz Latin-1, wobei wiederum die ersten 128 Zeichen von Latin-1 mit dem 7-Bit ASCII Zeichensatz übereinstimmen. 32 3.2 Von Mini-Java zu Java Elementare Datentypen und deren Literale: Typ boolean true und false Typ int 29 (Dezimalzahl) oder 035 (Oktaldarstellung wegen führender 0) oder 0x1D (Hexadezimaldarstellung wegen führendem 0x) oder 0X1d (Hexadezimaldarstellung wegen führendem 0X) Typ long 29L (wegen angehängtem l oder L) Typ short short i = 29; (Zuweisung, es gibt kein short-Literal) Typ byte byte i = 29; (Zuweisung, es gibt kein byte-Literal) Typ double 18.0 oder 18. oder 1.8e1 oder .18E2 Typ float 18.0f (wegen angehängtem f oder F) Typ char ’Q’, ’\u0022’, ’\u0b87’ Typ String "Hallo" (String ist kein elementarer Datentyp; s. Kapitel 4) Initialbelegungen: Während ihrer Deklaration kann eine Variable wie in MiniJava initialisiert werden. final double PI = 3.141592654; float radius = 1.0f; Sind für Datenfelder einer Klasse keine Anfangswerte angegeben, so belegt Java sie mit voreingestellten Anfangswerten. Der Anfangswert hängt vom Typ des Datenfeldes ab: Feld-Typ Anfangswert boolean false char ’\u0000’ Ganzzahl (byte, short, int, long) 0 Gleitkommazahl +0.0f oder +0.0d andere Referenzen null Lokale Variablen in einer Methode (oder einem Konstruktor oder einem klassenbezogenen Initialisierungsblock) werden von Java nicht mit einem Anfangswert initialisiert. Vor ihrer ersten Benutzung muss einer lokalen Variablen ein Wert zugewiesen werden (ein fehlender Anfangswert ist ein Fehler). 3.2.2 Kommentare // Kommentar bis zum Ende der Zeile /* Kommentar zwischen */ 33 3 Imperative Programmierung in Java Achtung: /* */ können nicht geschachtelt werden! /* falsch /* geschachtelter Kommentar */ */ 3.2.3 Bool’sche Operatoren && logisches und | | logisches oder ! logisches nicht Die Auswertung eines Bool’schen Ausdrucks erfolgt von links nach rechts, bis der Wert eindeutig feststeht. Folgender Ausdruck ist deshalb robust: if(index>=0 && index<array.length && array[index]!=0) ... 3.2.4 Bitoperatoren Die Bitoperatoren & (und) und | (oder) sind definiert durch: & 0 1 0 0 0 1 0 1 | 0 1 0 0 1 1 1 1 int-Zahlen werden durch diese Operatoren bitweise behandelt. Beispiel 3.2.1 Es seien x und y folgendermaßen gewählt: x = 60 (in Binärdarstellung 00111100) und y = 15 (binär: 00001111). In diesem Fall ist x&y = 12 und x|y = 63: x & y 00111100 (60) & 00001111 (15) 00001100 (12) x | y 00111100 (60) | 00001111 (15) 00111111 (63) Wenn man 0 als false und 1 als true interpretiert, so entspricht & dem logischen und (&&) und | dem logischen oder (||). 34 3.2 Von Mini-Java zu Java 3.2.5 Inkrement und Dekrement Man kann den Wert einer Variablen x (nicht den eines Ausdrucks) durch den Operator ++ um 1 erhöhen bzw. durch -- um 1 erniedrigen. Es gibt Präfixund Postfixschreibweisen, die unterschiedliche Wirkungen haben: Bei der Präfixschreibweise wird der Wert zuerst modifiziert und danach der veränderte Wert zurückgeliefert. Bei der Postfixschreibweise wird zuerst der Wert der Variablen zurückgeliefert, dann wird sie modifiziert. int i = 10; int j = i++; System.out.println(j); int i = 10; int j = ++i; System.out.println(j); > 10 > 11 Der Ausdruck i++ ist gleichbedeutend mit i = i+1, jedoch wird i nur einmal ausgewertet! Beispiel 3.2.2 (A) arr[where()]++; Die Methode where() wird einmal aufgerufen. (B) arr[where()] = arr[where()]+1; Hierbei wird die Methode where() jedoch zweimal aufgerufen. Seiteneffekte können hier sogar das Ergebnis beeinflussen: In dem Kontext arr[0] = 0; arr[1] = 1; arr[2] = 2; und private static int zaehler = 0; private static int where() { zaehler = zaehler+1; return zaehler; } liefert (A) arr[1] = 2 bzw. (B) arr[1] = 3. 3.2.6 Zuweisungsoperatoren i += 2; ist gleichbedeutend mit i = i+2; außer, dass der Ausdruck auf der linken Seite von i += 2; nur einmal ausgewertet wird (vgl. Inkrement und Dekrement). Entsprechend sind -=, &= und |= definiert. 35 3 Imperative Programmierung in Java 3.2.7 Die nichtabweisende Schleife Zusätzlich zur abweisenden Schleife gibt es eine nichtabweisende Schleife in Java: do statement while(condition); Die condition wird erst nach der Ausführung von statement ausgewertet. Solange sie true ist, wird statement wiederholt. 3.2.8 for-Schleife for(init-statement; condition; increment-statement) statement ist gleichbedeutend mit (mit Ausnahme vom Verhalten bei continue): { init-statement while(condition) { statement increment-statement } } Übliche Verwendung der for-Schleife: for(int i=0; i<=10; i++) { System.out.println(i); } Der Gültigkeitsbereich der (Lauf-)Variablen i beschränkt sich auf die for-Schleife! int i = 0; for(int i=0; i<=10; i++) { System.out.println(i); } ist jedoch nicht möglich, da die Variable i vorher schon deklariert wurde. Die Initialisierungs- bzw. Inkrementanweisung einer for-Schleife kann eine durch Kommata getrennte Liste von Ausdrücken sein. Diese werden von links nach rechts ausgewertet. 36 3.2 Von Mini-Java zu Java Beispiel 3.2.3 (Arnold & Gosling [1], S. 144) public static int zehnerPotenz(int wert) { int exp, v; for(exp=0,v=wert; v>0; exp++, v=v/10) ; // leere Anweisung return exp; } Alle Ausdrücke dürfen auch leer sein; dies ergibt eine Endlosschleife: for(;;) { System.out.println("Hallo"); } 3.2.9 if-then-else Anweisung if(condition) statement1 else statement2 Die condition wird ausgewertet; ist sie true, so wird statement1 ausgeführt. Ist sie false, so wird statement2 ausgeführt. Der else-Zweig darf entfallen; dies ergibt dann die if-then Anweisung aus Mini-Java. Ein else bezieht sich immer auf das letzte if, das ohne zugehöriges else im Programm vorkam. Was passiert, wenn mehr als ein if ohne ein else vorangeht? Das folgende Beispiel zeigt eine falsche (d.h. nicht intendierte) und eine richtige Verwendung (Schachtelung) von if-then-else Anweisungen. Beispiel 3.2.4 (Arnold & Gosling [1], S. 139) public double positiveSumme(double[] werte) { double sum = 0.0; if(werte.length > 1) for(int i=0; i<werte.length; i++) if(werte[i] > 0) sum += werte[i]; else // hoppla! sum = werte[0]; return sum; } 37 3 Imperative Programmierung in Java Der else-Teil sieht so aus, als ob er an die Feldlängenprüfung gebunden wäre, aber das ist eine durch die Einrückung erweckte Illusion, und Java ignoriert Einrückungen. Statt dessen ist ein else-Teil an das letzte if gebunden, das keinen else-Teil hat. So ist der vorangehende Block äquivalent zu: public double positiveSumme(double[] werte) { double sum = 0.0; if(werte.length > 1) for(int i=0; i<werte.length; i++) if(werte[i] > 0) sum += werte[i]; else // hoppla! sum = werte[0]; return sum; } Das war vielleicht nicht beabsichtigt. Um den else-Teil an das erste if zu binden, müssen Klammern zur Erzeugung eines Blocks verwendet werden: public double positiveSumme(double[] werte) { double sum = 0.0; if(werte.length > 1) { for(int i=0; i<werte.length; i++) if(werte[i] > 0) sum += werte[i]; } else { sum = werte[0]; } return sum; } 38 3.2 Von Mini-Java zu Java 3.2.10 Mehrdimensionale Felder Mehrdimensionale Felder werden in Java durch Felder von Feldern realisiert. Beispiel 3.2.5 (Jobst [6], S. 37) public class Array2Dim { public static void main(String[] args) { int[][] feld = new int[3][3]; //Weise feld[i][j] den Wert (i+1)*10+j zu for(int i=0; i<feld.length; i++) { for(int j=0; j<feld[i].length; j++) { feld[i][j] = (i+1)*10+j; System.out.print(feld[i][j]+" "); } System.out.println(); } } } > java Array2Dim 10 11 12 20 21 22 30 31 32 Da Felder in Java dynamisch sind, kann bei mehrdimensionalen Feldern jedes verschachtelte Feld eine andere Größe aufweisen. Beispiel 3.2.6 (Jobst [6], S. 38) public class DemoArray { public static void main(String[] args) { int[][] feld = new int[3][]; for(int i=0; i<feld.length; i++) { feld[i] = new int[i+1]; for(int j=0; j<feld[i].length; j++) { feld[i][j] = (i+1)*10+j; System.out.print(feld[i][j]+" "); } 39 3 Imperative Programmierung in Java System.out.println(); } } } > java DemoArray 10 20 21 30 31 32 Felder können bei ihrer Deklaration sofort initialisiert werden: Beispiel 3.2.7 (Jobst [6] S. 39) public class DemoFeldInitial { public static void main(String[] args) { int[][] feld = {{1,2,3},{4,5},{7,8,9,10}}; //Ausgabe des Feldes for(int i=0; i<feld.length; i++) { for(int j=0; j<feld[i].length; j++) System.out.print(feld[i][j]+" "); System.out.println(); } } } > 1 4 7 java DemoFeldInitial 2 3 5 8 9 10 3.2.11 switch-Anweisung switch(expression) { case const1: statement1 break; case const2: statement2 break; ... default: statement } 40 3.2 Von Mini-Java zu Java Der Ausdruck (expression) muss ganzzahlig sein. Nach case müssen Konstanten stehen, die bei der Übersetzung des Programms berechnet werden können. Der Ausdruck wird berechnet. Danach wird das Programm an derjenigen caseAnweisung fortgesetzt, deren Konstante dem Wert des Ausdrucks entspricht. Mit break kann man switch verlassen. Nach case darf nur jeweils eine Konstante stehen. Wenn es keine passende Konstante gibt, so wird das Programm bei der default Anweisung fortgesetzt (falls vorhanden). Achtung: Das nächste case erzwingt nicht das Verlassen der switch-Anweisung. Es impliziert auch nicht das Ende der Anweisungsausführung. Beispiel 3.2.8 (Jobst [6], S. 15) public class DemoFuerSwitch { public static void main (String[] args) { for(int i=0; i<=10; i++) switch(i) { case 1: case 2: System.out.println(i+" Fall 1,2"); case 3: System.out.println(i+" Fall 3"); case 7: System.out.println(i+" Fall 7"); break; default: System.out.println(i+" sonst"); } } } > 0 1 1 1 2 2 2 3 3 4 5 6 // Weiter bei Fall 3 // Weiter bei Fall 7 java DemoFuerSwitch sonst Fall 1,2 Fall 3 Fall 7 Fall 1,2 Fall 3 Fall 7 Fall 3 Fall 7 sonst sonst sonst 41 3 Imperative Programmierung in Java 7 Fall 7 8 sonst 9 sonst 10 sonst > 3.3 Aufgaben Aufgabe 3.3.1 Schreiben Sie ein Programm Primes in Mini-Java, das eine Zahl als Argument erhält und überprüft, ob diese Zahl eine Primzahl ist. Wenn sie eine ist, dann soll die Zahl wieder ausgegeben werden; wenn nicht, dann sollen die Zahlen, durch die sie teilbar ist, ausgegeben werden. Aufgabe 3.3.2 Implementieren Sie eine Klasse Factorial, in der die Fakultät einer Zahl durch die Methoden fwhile und ffor berechnet werden kann. Dabei soll die Methode fwhile die Fakultät einer Zahl mit einer while-Schleife berechnen, während ffor dazu eine for-Schleife benutzt. Aufgabe 3.3.3 Schreiben Sie ein Programm, das das Pascalsche Dreieck bis zu einer Tiefe von zwölf berechnet, dabei jede Reihe des Dreiecks in einem Array mit entsprechender Länge speichert und alle zwölf Arrays in einem Array von intArrays hält. Entwerfen Sie Ihre Lösung so, dass die Ergebnisse durch eine Methode ausgegeben werden, die die Länge der einzelnen Arrays des Hauptarrays berücksichtigt und nicht von einer konstanten Länge 12 ausgeht. Anschließend sollten Sie Ihren Code bezüglich der Konstante 12 modifizieren können, ohne dabei die Ausgabemethode ändern zu müssen. Aufgabe 3.3.4 Schreiben Sie unter Verwendung von if/else bzw. unter Verwendung von switch je eine Methode, die einen Stringparameter erhält und einen String zurückliefert, in dem alle Sonderzeichen des Ursprungsstrings durch ihre Java-Äquivalente ersetzt wurden. Ein String, der beispielsweise ein Anführungszeichen (") enthält, sollte einen String zurückliefern, in dem das " durch \" ersetzt worden ist. Bitte ersetzen Sie alle Zeichen, die in der folgenden Tabelle aufgeführt werden durch ihre Äquivalente: Sonderzeichen Java-Äquivalent " \" ´ \´ \ \\ 42 4 Algorithmen zur exakten Suche in Texten In diesem Kapitel wird ein Problem aus der Sequenzanalyse näher betrachtet, die exakte Textsuche: Gegeben ein Text und ein Muster, finde alle Vorkommen von dem Muster in dem Text. Man wird sehen, dass zur Lösung dieses einfachen Problem einige nicht-triviale, aber sehr effiziente Algorithmen entwickelt wurden. Zunächst soll jedoch ein kurzer Blick auf die Art und Weise geworfen werden, wie Sequenzen üblicherweise in Java repräsentiert sind. 4.1 Die Klasse String Zeichenketten sind in Java Objekte. Sie können mit dem +-Operator zu neuen Objekten verknüpft werden. String str1 = "hello"; String str2 = " world"; String str3 = str1 + str2; Grundlegende objektbezogene Methoden der Klasse String: public int length() liefert die Anzahl der Zeichen in der Zeichenkette. public int charAt(int index) liefert das Zeichen an der Position index der Zeichenkette. public int indexOf(char ch, int fromIndex) liefert die erste Position ≥ fromIndex, an der das Zeichen ch in der Zeichenkette vorkommt, bzw. −1, falls das Zeichen nicht vorkommt. public int lastIndexOf(char ch, int fromIndex) liefert die letzte Position ≤ fromIndex, an der das Zeichen ch in der Zeichenkette vorkommt, bzw. −1, falls das Zeichen nicht vorkommt (lastIndexOf liest also im Gegensatz zu indexOf die Zeichenkette von rechts nach links). 43 4 Algorithmen zur exakten Suche in Texten public boolean startsWith(String prefix) liefert true gdw. die Zeichenkette mit dem angegebenen prefix beginnt. public boolean endsWith(String suffix) liefert true gdw. die Zeichenkette mit dem angegebenen suffix endet. public String substring(int beginIndex, int endIndex) liefert das Teilwort der Zeichenkette zwischen den Positionen beginIndex und endIndex−1. public char[] toCharArray() wandelt die Zeichenkette in ein Feld von Zeichen um. public boolean regionMatches (int start, String other, int ostart, int len) liefert true gdw. der Bereich der Zeichenkette mit dem Bereich der Zeichenkette String other übereinstimmt (s.u.). Der Vergleich beginnt bei der Position start bzw. ostart. Es werden nur die ersten len Zeichen verglichen. public boolean equals(Object anObject) liefert true gdw. die übergebene Objektreferenz auf ein Objekt vom Typ String mit demselben Inhalt zeigt. (Dies steht im Gegensatz zu ==, was die Objekt-(referenz-)gleichheit testet.) Man beachte den Rückgabetyp int von charAt bzw. den Parametertyp int von ch in indexOf und lastIndexOf. Dennoch können die Methoden auf (Unicode-) Zeichen angewendet werden: Ein char-Wert in einem Ausdruck wird automatisch in einen int-Wert umgewandelt, wobei die oberen 16 Bit auf Null gesetzt werden. Beispiel 4.1.1 public class DemoStringClass { public static void main(String[] args) { String str = "bielefeld"; System.out.println(str.length()); System.out.println(str.charAt(0)); System.out.println(str.indexOf(’e’,3)); System.out.println(str.lastIndexOf(’e’,3)); System.out.println(str.startsWith("biele")); System.out.println(str.endsWith("feld")); System.out.println(str.substring(0,5)); System.out.println(str.substring(5,9)); System.out.println(str.regionMatches(0,"obiele",1,5)); if(str == args[0]) 44 4.2 Grundlegende Definitionen System.out.println("str == args[0]"); if(str.equals(args[0])) System.out.println("str.equals(args[0])"); } } > java DemoStringClass bielefeld 9 b 4 2 true true biele feld true str.equals(args[0]) 4.2 Grundlegende Definitionen Die Suche nach allen (exakten) Vorkommen eines Musters in einem Text ist ein Problem, das häufig auftritt (z.B. in Editoren, Datenbanken etc.). Wir vereinbaren folgende Konventionen (wobei Σ ein Alphabet ist): Muster P = P [0 . . . m − 1] ∈ Σm Text T = T [0 . . . n − 1] ∈ Σn P [j] = Zeichen an der Position j P [b . . . e] = Teilwort von P zwischen den Positionen b und e Beispiel 4.2.1 P = abcde, P [0] = a, P [3] = d, P [2 . . . 4] = cde. Notation: • leeres Wort: ε • Länge eines Wortes x: |x| • Konkatenation zweier Worte x und y: xy Definition 4.2.2 Ein Wort w ist ein Präfix eines Wortes x (w < x), falls x = wy gilt für ein y ∈ Σ∗ . w ist ein Suffix von x (w = x), falls x = yw gilt für ein y ∈ Σ∗ . x ist also auch ein Präfix bzw. Suffix von sich selbst. 45 4 Algorithmen zur exakten Suche in Texten Lemma 4.2.3 Es seien x, y und z Zeichenketten, so dass x = z und y = z gilt. Wenn |x| ≤ |y| ist, dann gilt x = y. Wenn |x| ≥ |y| ist, dann gilt y = x. Wenn |x| = |y| ist, dann gilt x = y. Beweis: Übung (Aufgabe 4.7.1). 4.3 Das Problem der exakten Suche Definition 4.3.1 Ein Muster P kommt mit der Verschiebung s im Text T vor, falls 0 ≤ s ≤ n − m und T [s . . . s + m − 1] = P [0 . . . m − 1] gilt. In dem Fall nennen wir s eine gültige Verschiebung. Das Problem der exakten Textsuche ist nun, alle gültigen Verschiebungen zu finden. Mit den Bezeichnungen S0 = ε und Sk = S[0 . . . k − 1] (d.h. Sk ist das k-lange Präfix eines Wortes S ∈ Σ` für 0 ≤ k ≤ `) können wir das Problem der exakten Textsuche folgendermaßen formulieren: Finde alle Verschiebungen s, so dass P = Ts+m . Beispiel 4.3.2 T = bielefeld oh bielefeld P = feld Gültige Verschiebungen: s = 5 und s = 18. Die naive Lösung des Problems sieht in Pseudo-Code wie folgt aus: Naive-StringMatcher(T, P ) 1 n ← length[T ] 2 m ← length[P ] 3 for s ← 0 to n − m do 4 if P [0 . . . m − 1] = T [s . . . s + m − 1] then 5 print “Pattern occurs with shift” s Am Beispiel T = abrakadabraxasa und P = abraxas erkennt man das ineffiziente Vorgehen, insbesondere wenn Text und Muster immer komplett verglichen werden: 46 4.3 Das Problem der exakten Suche a b r a k a d a b r a x a s a | | | | o | o a b r a x a s o - o o o o o o a b r a x a s o - o o | o | o a b r a x a s | - o o o o o o a b r a x a s o - o o | o o o a b r a x a s | o o o o | o a b r a x a s - o - o o o o o o a b r a x a s | - | | | | | | a b r a x a s o - o o o o o o a b r a x a s Eine Implementierung in Java verläuft analog: public static void naiveMatcher(String text, String pattern) { int m = pattern.length(); int n = text.length(); for(int s=0; s<=n-m; s++) { if(text.regionMatches(s,pattern,0,m)) System.out.println("naiveMatcher: Pattern occurs with shift "+s); } } Aber selbst wenn Text und Muster zeichenweise von links nach rechts und nur bis zum ersten Mismatch1 verglichen werden, ist die worst-case Zeiteffizienz des naiven Algorithmus O n · m , z.B. wird für T = an , P = am die for-Schleife n − m + 1 mal ausgeführt und in jedem Test in Zeile 4 werden m Zeichen verglichen. Wie kann man den naiven Algorithmus verbessern? Idee 1: Überspringe ein Teilwort w von T , falls klar ist, dass w 6= P (BM-Algorithmus 1977). Idee 2: Merke Informationen über bisherige Vergleiche und nutze diese, um neue, unnötige Vergleiche zu vermeiden (KMP-Algorithmus 1977). 1 Die zwei Zeichen stimmen nicht überein. 47 4 Algorithmen zur exakten Suche in Texten 4.4 Der Boyer-Moore-Algorithmus Der BM-Algorithmus legt wie der naive Algorithmus das Muster zunächst linksbündig an den Text, vergleicht die Zeichen des Musters dann aber von rechts nach links mit den entsprechenden Zeichen des Textes. Beim ersten Mismatch benutzt er zwei Heuristiken, um eine Verschiebung des Musters nach rechts zu bestimmen. 4.4.1 Die bad-character Heuristik Falls beim Vergleich von P [0 . . . m − 1] und T [s . . . s + m − 1] (von rechts nach links) ein Mismatch P [j] 6= T [s + j] für ein j mit 0 ≤ j ≤ m − 1 festgestellt wird, so schlägt die bad-character Heuristik eine Verschiebung des Musters um j − k Positionen vor, wobei k der größte Index (0 ≤ k ≤ m − 1) ist mit T [s + j] = P [k]. Wenn kein k mit T [s + j] = P [k] existiert, so sei k = −1. Man beachte, dass j − k negativ sein kann. 4.4.2 Die good-suffix Heuristik Falls beim Vergleich von P [0 . . . m − 1] und T [s . . . s + m − 1] (von rechts nach links) ein Mismatch P [j] 6= T [s + j] für ein j mit 0 ≤ j ≤ m − 1 festgestellt wird, so wird das Muster so weit nach rechts geschoben, bis das bekannte Suffix T [s + j + 1 . . . s + m − 1] wieder auf ein Teilwort des Musters passt. Hierfür ist eine Vorverarbeitung des Musters notwendig, auf die wir hier aber nicht näher eingehen wollen. Im Boyer-Moore Algorithmus wird das Muster um das Maximum von beiden vorgeschlagenen Verschiebungen verschoben. Es kann gezeigt werden, dass die Laufzeitkomplexität dann im worst case O(n + m) ist. Beispiel 4.4.1 Die Ausgangssituation ist in Abbildung 4.1(a) dargestellt. Die badcharacter Heuristik schlägt eine Verschiebung von j − k = (m − 3) − 5 = (12 − 3) − 5 = 4 Positionen vor; dies ist im Fall (b) illustriert. Aus der Darstellung (c) wird ersichtlich, dass die good-suffix Heuristik eine Verschiebung von 3 Positionen vorschlägt. Also wird das Muster um max{4, 3} = 4 Positionen nach rechts verschoben. 48 4.5 Der Boyer-Moore-Horspool-Algorithmus bad character good suffix ? ?z}|{ ... w r i t t e n _ n o t i c e _ t h a t ... o s- r e m i n i s c e n c e (a) ... w r i t t e n _ n o t i c e _ t h a t ... s+4 - reminiscence (b) ... w r i t t e n _ n o t i c e _ t h a t ... s+3 - r e m i n i s c e n c e (c) A BBILDUNG 4.1: Verhalten des Boyer-Moore Algorithmus 4.5 Der Boyer-Moore-Horspool-Algorithmus Der BM-Algorithmus verdankt seine Schnelligkeit vor allem der bad-charakter Heuristik. Daher wurde 1980 von Horspool eine Vereinfachung des BM-Algorithmus vorgeschlagen: Die bad-charakter Heuristik wird derart modifiziert, dass sie immer eine positive Verschiebung vorschlägt. Damit wird die good-suffix Heuristik überflüssig (und auch die Vorverarbeitung einfacher). Der BMH-Algorithmus geht in den meisten Fällen analog zur bisherigen badcharacter Heuristik vor. Aber: Falls P [j] 6= T [s + j] für ein j (0 ≤ j ≤ m − 1) gilt, so wird s um m − 1 − k erhöht, wobei k der größte Index zwischen 0 und m−2 ist mit T [s+m−1] = P [k]. Wenn kein k (0 ≤ k ≤ m − 2) mit T [s + m − 1] = P [k] existiert, so wird s um m erhöht. Das Muster wird also um λ T [s+m−1] = min {m}∪ m−1−k | 0 ≤ k ≤ m−2 und T [s+m−1] = P [k] verschoben. Wenn ein Match gefunden wurde, dann wird ebenfalls um λ T [s + m − 1] verschoben. Die Funktion λ lässt sich wie folgt berechnen: computeLastOccurrenceFunction(P, Σ) 1 m ← length[P ] 2 for each character a ∈ Σ do 3 λ[a] = m 4 for j ← 0 to m − 2 do 49 4 Algorithmen zur exakten Suche in Texten ... g o l d e n _ f l e e c e _ o f ... o s- r e m i n i s c e n c e ⇓ ... g o l d e n _ f l e e c e _ o f ... s+3 - r e m i n i s c e n c e A BBILDUNG 4.2: Verhalten des BMH-Algorithmus bei einem Mismatch ... g o l d e n _ f l e e c e _ o f ... s - fleece ⇓ ... g o l d e n _ f l e e c e _ o f ... s+2 - fleece A BBILDUNG 4.3: Verhalten des BMH-Algorithmus bei einem Treffer 5 λ P [j] ← m − 1 − j 6 return λ Die worst-case-Komplexität von computeLastOccurrenceFunction ist O |Σ| + m . Der BMH-Algorithmus sieht in Pseudo-Code folgendermaßen aus: BMH-Matcher(T, P, Σ) 1 n ← length[T ] 2 m ← length[P ] 3 λ ← computeLastOccurrenceFunction(P, Σ) 4 s←0 5 while s ≤ n − m do 6 j ←m−1 7 while j ≥ 0 and P [j] = T [s + j] do 8 j ←j−1 9 if j = −1 then 10 print “Pattern occurs with shift” s 11 s ← s + λ T [s + m − 1] Zur Verifikation, ob eine Verschiebung s gültig ist, wird O(m) Zeit benötigt. Ins- 50 4.6 Der Knuth-Morris-Pratt-Algorithmus gesamt hat der BMH-Algorithmus die worst-case Zeitkomplexität O n · m + |Σ| (z.B. für T = an , P = am ). 4.6 Der Knuth-Morris-Pratt-Algorithmus 4.6.1 Einführendes Beispiel Der naive Algorithmus ist ineffizient, da unabhängig von bereits stattgefundenen erfolgreichen Vergleichen das Muster immer um ein Zeichen verschoben wird. Im folgenden Beispiel ist dies noch einmal anhand der Suche nach dem Wort abraxas im Text abrakadabra dargestellt. a b r a | | | | a b r a o - a b r o - a b | - a k a d a b r a o x a s a x a s r a x a s o b r a x a s Man erkennt, dass im zweiten und dritten Schritt das Zeichen a an Position 1 im Muster mit den Zeichen b und r an der zweiten und dritten Position des Textes verglichen wird, obwohl bereits nach dem positiven Vergleich von abra klar sein musste, dass hier keine Übereinstimmung existieren kann. Verwendet man dagegen Information aus vorangegangenen Vergleichen, so muss man nach einer Verschiebung mit den zeichenweisen Vergleichen zwischen Muster und Text nicht wieder am Anfang des Musters anfangen. Der Knuth-Morris-Pratt-Algorithmus geht nach folgendem Schema vor: Wenn ein Teilwort (Präfix) des Musters bereits erkannt wurde, aber dann ein mismatch auftritt, so ermittelt der Algorithmus das längste Präfix dieses Teilwortes, das gleichzeitig echtes Suffix davon ist, und schiebt das Muster dann so weit nach rechts, dass dieses Präfix an der bisherigen Position des Suffixes liegt. Beispiel 4.6.1 Im obigen Beispiel ist das bereits erkannte Teilwort abra. Das längste Präfix von abra, das gleichzeitig echtes Suffix davon ist, ist a. Also schiebt der Algorithmus das Muster so, dass das erste a von abraxas an der Position steht, an der sich vorher das zweite a befunden hat. Der nächste stattfindende Vergleich ist der von k mit b, da die Verschiebung ja gerade so durchgeführt wurde, dass die beiden as übereinander liegen und somit nicht mehr verglichen werden müssen. 51 4 Algorithmen zur exakten Suche in Texten a b r a k a d a b r a | | | | o a b r a x a s o - a b r a x a s Man erkennt, dass hier drei unnötige Vergleiche vermieden wurden. 4.6.2 Funktionsweise Beim KMP-Algorithmus wird das Muster P zeichenweise von links nach rechts mit dem Text T verglichen. Es werden Informationen über bereits stattgefundene Vergleiche ausgenutzt, um weitere unnötige Vergleiche zu vermeiden. Als Ausgangssituation sei Pq ein Präfix von P , das an Position s im Text vorkommt. ... Pq s- Pq ... Dann gehen wir gemäß der folgenden Fälle vor: (A) q = m, d.h. s ist gültige Verschiebung: 1. Bestimme k ∗ = π[m] = max{k : k < m und Pk = Pm }. 2.a) Falls k ∗ > 0, verschiebe das Muster nach rechts, so dass das Präfix Pk∗ von P unter dem Suffix Pk∗ von T [s . . . s + |P | − 1] liegt. ... Pk ∗ ... Pk ∗ ... Pk ∗ ⇓ ... Pk ∗ 2.b) Falls k ∗ = 0, verschiebe das Muster P um die Länge |P |. ... 52 ... 4.6 Der Knuth-Morris-Pratt-Algorithmus (B) q < m: a Pk ... | {z } {z } Pq Pk ... b | Pq a) a 6= b und q 6= 0: • bestimme π[q] = max{k : k < q und Pk = Pq }, d.h. die Länge des längsten Präfixes von P , das auch Suffix von Pq ist; • verschiebe P nach rechts, so dass das Suffix Pk von Pq über dem Präfix Pk von P liegt. a Pk ... Pk ... b b) a 6= b und q = 0: • verschiebe P um 1 nach rechts. ... a ... b c) a = b: • mache weiter mit q + 1. ... Pq a Pq a ... ⇓ ... Pq+1 s- Pq+1 ... Beobachtung: Für jeden der vier Fälle gilt: • das Muster wird nie nach links geschoben • entweder wird ein neues Zeichen des Textes gelesen (B.c), oder das Muster wird um mindestens eine Position nach rechts geschoben (A), (B.a), (B.b). 53 4 Algorithmen zur exakten Suche in Texten Insgesamt können die Fälle also höchstens 2n-mal vorkommen. Wir bestimmen die worst-case Zeitkomplexität des KMP-Algorithmus: • Fälle (B.b) und (B.c) erfordern jeweils konstanten Zeitaufwand. • In (A) und (B.a) wird die Funktion π benötigt mit π[q] = max{k : k < q und Pk = Pq }, d.h. π[q] = Länge des längsten Präfixes von P , das ein echtes Suffix von Pq ist. π hängt nur von P ab und kann daher in einem Vorverarbeitungsschritt (V V ) berechnet und als Tabelle abgespeichert werden. Gegeben diese Tabelle, erfordern auch (A) und (B.a) nur konstanten Zeitaufwand. Damit ergeben sich folgende Komplexitäten: O(V V )+O(n) Zeit und O(m) Speicherplatz im worst-case! Beispiel 4.6.2 Die Präfixfunktion π : {1, 2, . . . , m} → {0, 1, . . . , m − 1} für ein Muster P [0 . . . m − 1] ist definiert durch π[q] = max{k : k < q und Pk = Pq }, also die Länge des längsten Präfixes von P , das ein echtes Suffix von Pq ist. Sie lautet für unser Beispielwort P = abrakadabra: 0 1 2 3 4 5 6 7 8 9 10 11 q P [q] a b r a k a d a b r a ⊥ π[q] ⊥ 0 0 0 1 0 1 0 1 2 3 4 Für q = 9 wäre das entsprechende Teilmuster also abrakadab. Das längste Präfix, das gleichzeitig echtes Suffix davon ist, ist ab. Es hat die Länge 2, darum findet sich in der Tabelle an der Position q = 9 der Eintrag π[q] = 2. Die Präfixfunktion π kann naiv folgendermaßen berechnet werden (die Laufzeit beträgt O(m3 ), aber wir werden später sehen, wie es auch effizienter geht): computePrefixFunction(P ) 1 m ← length[P ] 2 π[1] ← 0 3 for q ← 2 to m do 4 k ←q−1 5 while k > 0 and P [0 . . . k − 1] 6= P [0 . . . q − 1] do 6 k ←k−1 7 π[q] ← k 8 return π Damit können wir den KMP-Algorithmus in Pseudo-Code formulieren: KMP-Matcher(T, P ) 1 n ← length[T ] 2 m ← length[P ] 54 4.6 Der Knuth-Morris-Pratt-Algorithmus 3 π ← computePrefixFunction(P ) 4 q←0 5 for i ← 0 to n − 1 do 6 while q > 0 and P [q] 6= T [i] do 7 q ← π[q] 8 if P [q] = T [i] then 9 q ←q+1 10 if q = m then 11 print “Pattern occurs with shift” i − m + 1 12 q ← π[q] Wir gehen nun der Frage nach, wie die Funktion π effizienter berechnet werden kann. Unter der Annahme, dass die Werte von π für 1, . . . , q − 1 schon berechnet sind, wollen wir π[q] berechnen. Gesucht: π[q] = max{k : k < q und Pk = Pq } Welche Pk kommen in Frage? Alle Pk = P [0 . . . k − 1] für die gilt: Pk−1 = Pq−1 |{z} |{z} P [0...k−2] und P [k − 1] = P [q − 1], P [0...q−2] also alle echten Suffixe von Pq−1 , die sich zu einem echten Suffix von Pq erweitern lassen. Die Menge der Längen aller echten Suffixe von Pq−1 , die auch Präfixe von P sind, ist: {k − 1 : k − 1 < q − 1 : Pk−1 = Pq−1 } = {k 0 : k 0 < q − 1 : Pk0 = Pq−1 }. Damit muss gelten: π[q] = max{k 0 + 1 : k 0 < q − 1, Pk0 = Pq−1 und P [k 0 ] = P [q − 1]}. Nun kann man folgende Gleichheit zeigen (Lemma 4.6.4): {k 0 : k 0 < q − 1 : Pk0 = Pq−1 } = {π[q − 1], π π[q − 1] , π 3 [q − 1], . . . , π t [q − 1]}, | {z } =π 2 [q−1] wobei π t [q − 1] = 0 gilt. Schließlich erhalten wir: π[q] = max{k 0 + 1 : k ∈ {π[q − 1], . . . , π t [q − 1]} und P [k 0 ] = P [q − 1]}. Damit erhalten wir folgendes Verfahren, um π[q] zu berechnen: Wir schauen π[q − 1] in der Tabelle von π nach und prüfen, ob sich das echte Suffix Pπ[q−1] von Pq−1 zu einem echten Suffix von Pq erweitern lässt. Wenn ja, so ist π[q] = 55 4 Algorithmen zur exakten Suche in Texten π[q − 1] + 1. Wenn nein, so iterieren wir diesen Prozess, d.h. wir machen das Gleiche mit π(π[q − 1]) usw. Diese Überlegungen sollten die folgende Berechnungsmethode der Funktion π motivieren. Wir werden deren Korrektheit im nächsten Unterabschnitt beweisen. computePrefixFunction2(P ) 1 m ← length[P ] 2 π[1] ← 0 3 k←0 4 for q ← 2 to m do 5 while k > 0 and P [k] 6= P [q − 1] do 6 k ← π[k] 7 if P [k] = P [q − 1] then 8 k ←k+1 9 π[q] ← k 10 return π Die Laufzeit von computePrefixFunction2 beträgt O(m). Dies kann man folgendermaßen sehen: 1. Die for-Schleife in Zeile 4 wird m − 1-mal durchlaufen, demzufolge auch die Zeilen 7-9. 2. Der Wert von k ist anfangs 0 und wird insgesamt höchstens m − 1-mal in Zeile 8 um jeweils 1 erhöht. 3. Bei jedem Durchlauf der while-Schleife in den Zeilen 5 und 6 wird der Wert von k um mindestens 1 erniedrigt. Da k nicht negativ wird (Zeile 5), kann die while-Schleife insgesamt also höchstens k-mal durchlaufen werden. 4.6.3 Korrektheit der Berechnung der Präfixfunktion Das wesentliche Lemma im Korrektheitsbeweis besagt, dass man alle Präfixe Pk , die Suffix eines gegebenen Präfixes Pq sind, berechnen kann, indem man die Präfixfunktion π iteriert. Definition 4.6.3 Es sei π ∗ [q] = q, π[q], π 2 [q], π 3 [q], . . . , π t [q] wobei π 0 [q] = q und π i+1 [q] = π π i [q] für i ≥ 0; dabei sei vereinbart, dass die Folge in π ∗ [q] abbricht, wenn π t [q] = 0 erreicht wird. Lemma 4.6.4 Es sei P [0 . . . m − 1] ein Muster und π die dazu gehörige Präfixfunktion. Dann gilt π ∗ [q] = {k : Pk = Pq } für q = 1, . . . , m. 56 4.6 Der Knuth-Morris-Pratt-Algorithmus Beweis: Wir beweisen das Lemma, indem wir (1) π ∗ [q] ⊆ {k : Pk = Pq } und (2) {k : Pk = Pq } ⊆ π ∗ [q] zeigen. 1. Wir zeigen: i ∈ π ∗ [q] impliziert Pi = Pq . Sei also i ∈ π ∗ [q]. Für i gibt es ein u ∈ N mit i = π u [q]. Wir beweisen Pπu [q] = Pq durch Induktion über u. Induktionsanfang: u = 0. Dann ist i = π 0 [q] = q und die Behauptung gilt. Induktionshypothese: Für i0 = π u0 [q] gilt Pi0 = Pq . Induktionsschritt: Zu zeigen ist: für i = π u0 +1 [q] gilt Pi = Pq . Es gilt i = u 0 π π [q] = π[i0 ]. Mit der Induktionshypothese folgt Pi0 = Pq . Weiterhin gilt i = π[i0 ] = max{k : k < i0 und Pk = Pi0 }, also gilt Pi = Pi0 = Pq . Da = transitiv ist, folgt Pi = Pq . 2. Wir beweisen {k : Pk = Pq } ⊆ π ∗ [q] durch Widerspruch. Wir nehmen an ∃j ∈ {k : Pk = Pq }\π ∗ [q]. Ohne Einschränkung der Allgemeinheit sei j die größte Zahl mit dieser Eigenschaft. Da j ≤ q und q ∈ {k : Pk = Pq } ∩ π ∗ [q] gilt, folgt j < q. Es sei j 0 die kleinste Zahl in π ∗ [q], die größer als j ist (da q ∈ π ∗ [q] gilt, existiert solch ein j 0 immer). Wegen j ∈ {k : Pk = Pq } gilt Pj = Pq . Weiterhin impliziert j 0 ∈ π ∗ [q] wg. (1) Pj 0 = Pq . Mit j < j 0 folgt damit Pj = Pj 0 . Es gibt kein j 00 , j < j 00 < j 0 , mit Pj = Pj 00 = Pj 0 Gäbe es solch ein j 00 , so müsste j 00 ∈ π ∗ [q] gelten, denn j ist die größte Zahl mit j ∈ {k : Pk = Pq }\π ∗ [q]. j 00 ∈ π ∗ [q] kann aber nicht gelten, denn j 0 ist die kleinste Zahl in π ∗ [q], die größer als j ist. Also muss j = max{k : k < j 0 und Pk = Pj 0 } = π[j 0 ] und damit j ∈ π ∗ [q] gelten. Dieser Widerspruch beweist {k : Pk = Pq } ⊆ π ∗ [q]. 2 Definition 4.6.5 Für q = 2, 3, . . . , m sei Eq−1 = k : k < q − 1, k ∈ π ∗ [q − 1], P [k] = P [q − 1] . Die Menge Eq−1 ⊆ π ∗ [q − 1] enthält alle k ∈ π ∗ [q − 1] = {k : Pk = Pq−1 } mit k < q − 1 und Pk+1 = Pq ; denn aus Pk = P [0 . . . k − 1] = Pq−1 = P [0 . . . q − 2] und P [k] = P [q − 1] folgt Pk+1 = P [0 . . . k] = Pq = P [0 . . . q − 1]). 57 4 Algorithmen zur exakten Suche in Texten Lemma 4.6.6 Sei P [0 . . . m − 1] ein Muster und π die zugehörige Präfixfunktion. Für q = 2, 3, . . . , m gilt: ( 0 falls Eq−1 = ∅ π[q] = 1 + max{k ∈ Eq−1 } falls Eq−1 6= ∅ Beweis: Mit der Vereinbarung max{} = 0 gilt: π[q] = max k : k < q und Pk = Pq = max k : k < q, Pk−1 = Pq−1 und P [k − 1] = P [q − 1] (4.6.4) = max k : k − 1 < q − 1, k − 1 ∈ π ∗ [q − 1] und P [k − 1] = P [q − 1] k0 =k−1 0 ∗ 0 = max k 0 + 1 : k 0 < q − 1, k ∈ π [q − 1] und P [k ] = P [q − 1] = max k 0 + 1 : k 0 ∈ Eq−1 Fall 1: Eq−1 = ∅: Dann gilt π[q] = max{} = 0. Fall 2: Eq−1 6= ∅: Dann gilt π[q] = max{1 + k 0 : k 0 ∈ Eq−1 } = 1 + max{k 0 : k 0 ∈ Eq−1 }. 2 Satz 4.6.7 computePrefixFunction2(P) berechnet die Funktion π korrekt. Beweis: Am Anfang jeder Iteration der for-Schleife gilt k = π[q − 1], denn beim ersten Betreten der for-Schleife gilt k = 0, q = 2 und π[q − 1] = 0 = k und diese Eigenschaft bleibt wegen Zeile 9 bei jeder Iteration der for-Schleife erhalten. Die Eigenschaft k = π[q − 1] wird daher auch Invariante der Schleife genannt. Die while-Schleife untersucht alle Werte k ∈ π ∗ [q − 1]\{q − 1}, bis einer mit P [k] = P [q − 1] gefunden wird; an diesem Punkt gilt k = max{k ∈ Eq−1 }, so dass π[q] den Wert k + 1 = 1 + max{k ∈ Eq−1 } erhält. Wird kein solcher Wert k gefunden, so wird π[q] der Wert 0 zugewiesen, denn es gilt k = 0 in den Zeilen 7-9. Das in Zeile 10 zurückgegebene Feld π hat also folgende Werte: π[1] = 0 und für q = 2, 3, . . . , m 0 π[q] = 1 + max{k ∈ Eq−1 } falls Eq−1 = ∅ falls Eq−1 6= ∅ 2 58 4.7 Aufgaben 4.7 Aufgaben Aufgabe 4.7.1 Beweisen Sie Lemma 4.2.3. Aufgabe 4.7.2 Implementieren Sie den Boyer-Moore-Horspool-Algorithmus in Java. Aufgabe 4.7.3 Bestimmen Sie die worst-case Zeiteffizienz der naiven Implementierung von computePrefixFunction. Aufgabe 4.7.4 Implementieren Sie den Knuth-Morris-Pratt-Algorithmus in Java. Aufgabe 4.7.5 Geben Sie konkrete Beispiele an, in denen (a) der Knuth-Morris-Pratt-Algorithmus (b) der Boyer-Moore-Horspool-Algorithmus (c) der Boyer-Moore-Algorithmus sich besser verhält als die beiden anderen (unter Vernachlässigung der Vorverarbeitung). Das Kriterium ist hierbei die Anzahl der Vergleiche von Zeichen, die jeweils durchgeführt werden müssen. Aufgabe 4.7.6 Man erreicht eine verbesserte Version des Knuth-Morris-PrattMatchers, indem man π in der Zeile 7 (nicht aber in Zeile 12) durch π 0 ersetzt. π 0 ist rekursiv für q = 1, 2, . . . , m − 1 definiert: wenn π[q] = 0, 0 π 0 [π[q]] wenn π[q] 6= 0 und P [π[q]] = P [q], π 0 [q] = π[q] wenn π[q] 6= 0 und P [π[q]] 6= P [q]. Erklären Sie, warum der modifizierte Algorithmus korrekt ist und inwiefern diese Modifikation eine Verbesserung darstellt. Stellen Sie die Funktion π 0 für das Muster P = ababababca tabellarisch dar. Aufgabe 4.7.7 Geben Sie einen Algorithmus an, der herausfindet, ob ein Text T eine zyklische Verschiebung eines Textes T 0 ist. Zum Beispiel ist der Text reif eine zyklische Verschiebung von frei. Der Algorithmus soll linear im Zeitaufwand sein. Implementieren Sie Ihren Algorithmus in Java. Aufgabe 4.7.8 Erklären Sie, wie man alle Vorkommen des Musters P im Text T unter Zuhilfenahme der π Funktion für die Zeichenkette P T bestimmen kann. P T ist die Konkatenation von P und T (also m+n Zeichen lang). Implementieren Sie Ihren Vorschlag in Java. 59 4 Algorithmen zur exakten Suche in Texten 60 5 Objektorientierte Programmierung in Java Grundzüge der objektorientierten Programmierung haben wir bereits in Kapitel 2 kennengelernt, auch Teile der entsprechenden JavaSyntax. Dieses Kapitel soll nun etwas systematischer und ausführlicher noch einmal die entsprechenden Java-Konstrukte zur Umsetzung objektorientierter Programmierung sowie Ausnahmen und Spezialfälle behandeln. Beginnen wollen wir jedoch mit einem Blick in die Historie, denn die Objektorientierung ist nur der (derzeitige) Schlusspunkt einer längeren Entwicklung. 5.1 Traditionelle Konzepte der Softwaretechnik Folgende traditionelle Konzepte des Software-Engineering werden u.a. im objektorientierten Ansatz verwendet: Datenabstraktion (bzw. Datenkapselung) und Information Hiding Die zentrale Idee der Datenkapselung ist, dass auf eine Datenstruktur nicht direkt zugegriffen wird, indem etwa einzelne Komponenten gelesen oder geändert werden, sondern, dass dieser Zugriff ausschließlich über Zugriffsoperatoren erfolgt. Es werden also die Implementierungen der Operationen und die Datenstrukturen selbst versteckt. Vorteil: Implementierungdetails können beliebig geändert werden, ohne Auswirkung auf den Rest des Programmes zu haben. abstrakte Datentypen (ADT) Realisiert wird die Datenabstraktion duch den Einsatz abstrakter Datentypen, die Liskov & Zilles (1974) folgendermaßen definierten: “An abstract data type defines a class of abstract objects which is completely characterized by the operations available on those objects. This means that an abstract data type can be defined by defining the characterizing operations for that type.” 61 5 Objektorientierte Programmierung in Java Oder etwas prägnanter: Datentyp = Menge(n) von Werten + Operationen darauf abstrakter Datentyp = Operationen auf Werten, deren Repräsentation nicht bekannt ist. Der Zugriff erfolgt ausschließlich über Operatoren. Datenabstraktion fördert die Wiederverwendbarkeit von Programmteilen und die Wartbarkeit großer Programme. 5.1.1 Beispiel: Der ADT Stack Stack: Eine Datenstruktur über einem Datentyp T bezeichnet man als Stack1 , wenn die Einträge der Datenstruktur als Folge organisiert sind und es die Operationen push, pop und peek gibt: push fügt ein Element von T stets an das Ende der Folge. pop entfernt stets das letzte Element der Folge. peek liefert das letzte Element der Folge, ohne sie zu verändern. Prinzip: last in first out (LIFO) Typen der Operationen: initStack: push: pop: peek: empty: T × Stack Stack Stack Stack −→ Stack −→ Stack −→ Stack −→ T −→ boolean Spezifikation der Operationen durch Gleichungen. Sei x eine Variable vom Typ T, stack eine Variable vom Typ Stack: empty (initStack) empty (push (x, stack)) peek (push (x, stack)) pop (push (x, stack)) = true = false =x = stack initStack und push sind Konstruktoren (sie konstruieren Terme), daher gibt es keine Gleichungen für sie. 5.2 Konzepte der objektorientierten Programmierung Ziel jeglicher Programmierung ist: • Modellierung von Ausschnitten der Realität 1 bedeutet soviel wie Keller oder Stapel 62 5.2 Konzepte der objektorientierten Programmierung • sachgerechte Abstraktion • realitätsnahes Verhalten • Nachbildung von Ähnlichkeit im Verhalten • Klassifikation von Problemen Je nach Problem können verschiedene Klassifikationen sachgerecht sein, dies ist anhand eines Beispiels aus der Biologie in der Abbildung 5.1 dargestellt. Tiere HH ? Insekten @ @ R @ ? HH j Säugetiere Fische @ HH H @ @ R @ ? @ R @ ? Tiere HH ? Zuchttiere ? HH j H Störtiere Wild @ HH @ @ R @ ? @ @ R @ ? @ R @ A BBILDUNG 5.1: Phylogenetische (oben) und ökonomische Klassifizierung (unten). Es werden immer bestimmte Funktionen auf bestimmte Daten angewendet. Soll nun die Architektur eines Systems (Modells) auf den Daten oder auf den Funktionen aufbauen? Grundsätzlich gibt es drei Vorgehensweisen: 1. die funktionsorientierte 2. die datenorientierte 3. die objektorientierte 63 5 Objektorientierte Programmierung in Java Der Kerngedanke des objektorientierten Ansatzes besteht darin, Daten und Funktionen zu verschmelzen. Im ersten Schritt werden die Daten abgeleitet, im zweiten Schritt werden den Daten die Funktionen zugeordnet, die sie manipulieren. Die entstehenden Einheiten aus Daten und Funktionen werden Objekte genannt. Wir schränken den Begriff Objektorientierung gemäß folgender Gleichung von Coad & Yourdon weiter ein: Objektorientierung = Klassen und Objekte + Kommunikation mit Nachrichten + Vererbung Im folgenden erläutern wir diese Konzepte kurz. 5.3 Klassen und Objekte Eine Klasse besteht konzeptionell aus einer Schnittstelle und einem Rumpf. In der Schnittstelle sind die nach außen zur Verfügung gestellten Methoden (und manchmal auch öffentlich zugängliche Daten), sowie deren Semantik aufgelistet. Diese Auflistung wird oft als Vertrag oder Nutzungsvorschrift zwischen dem Entwerfer der Klasse und dem sie verwendenen Programmierer gedeutet. Der Klassenrumpf enthält alle von außen unsichtbaren Implementierungdetails. Historisch gesehen ist der Klassenbegriff älter als der Begriff des abstrakten Datentypen (ADT). In der Programmiersprache Simula 67 gab es bereits Klassen als Mechanismus zur Datenkapselung (Abstakte Datentypen wurden erstmals 1974 von Liskov & Zilles definiert). Der Kerngedanke der Objektorientierung, Daten und Funktionen konsequent als Objekte zusammenzufassen, wird jedoch auf die Programmiersprache Smalltalk zurückgeführt (entwickelt seit Beginn der 70er Jahre). 5.4 Kommunikation mit Nachrichten Objekte besitzen die Möglichkeit, mit Hilfe ihrer Methoden Aktionen auszuführen. Das Senden einer Nachricht stößt die Ausführung einer Methode an. Eine Nachricht besteht aus einem Empfänger (das Objekt, das die Aktionen ausführen soll), einem Selektor (die Methode, deren Aktionen auszuführen sind) und gegebenenfalls aus Argumenten (Werte, auf die während der Ausführung der Aktion zugegriffen wird). 64 5.5 Vererbung 5.5 Vererbung Gleichartige Objekte werden zu Klassen zusammengefasst. Häufig besitzen Objekte zwar bestimmte Gemeinsamkeiten, sind aber nicht völlig gleichartig. Um solche Ähnlichkeiten auszudrücken, ist es möglich, zwischen Klassen Vererbungsbeziehungen festzulegen. Dazu wird das Verhalten einer existierenden Klasse erweitert. Die Erweiterung erzeugt eine von ihr alle Attribute und Methoden erbende neue Klasse, die um weitere Attribute und Methoden ergänzt wird. Die neue Klasse wird Unterklasse, die ursprüngliche Klasse Oberklasse genannt. Gemeinsamkeiten: in der Oberklasse Unterschiede: in der Unterklasse Eine Unterklasse kann auch von der Oberklasse ererbte Methoden redefinieren (überschreiben). Wir sprechen von Einfachvererbung, wenn jede neue Klasse genau eine Oberklasse erweitert (Abbildung 5.2). Object @ ? System Math @ R @ Point @ @ R @ ... A BBILDUNG 5.2: Einfachvererbung (Java) ... @ @ R @ Tiere Pflanzen @ @ R @ Fleischfresser @ @ R @ ... A BBILDUNG 5.3: Mehrfachvererbung 65 5 Objektorientierte Programmierung in Java Wenn eine Klasse mehrere Oberklassen besitzen kann, sprechen wir von Mehrfachvererbung (Abbildung 5.3). In Java gibt es nur Einfachvererbung (aus gutem Grund). Die einzige Klasse, die keine Oberklasse erweitert, ist die vordefinierte Klasse Object. Klassen, die nicht explizit andere Klassen erweitern, erweitern implizit die Klasse Object. Alle Objektreferenzen sind in polymorpher Weise von der Klasse Object, so dass Object die generische Klasse für Referenzen ist, die sich auf Objekte jeder beliebigen Klasse beziehen können. Das nächste Beispiel verdeutlicht dies. Object oref = new Point(); oref = "eine Zeichenkette"; 5.6 Konstruktoren und Initialisierungsblöcke Einem neu erzeugten Objekt wird ein Anfangszustand zugewiesen. Datenfelder können bei ihrer Deklaration mit einem Wert initialisiert werden, was manchmal ausreicht, um einen sinnvollen Anfangszustand sicherzustellen. Oft ist aber mehr als nur einfache Dateninitialisierung zur Erzeugung eines Anfangszustands nötig; der erzeugende Code muss vielleicht Anfangsdaten liefern oder Operationen ausführen, die nicht als einfache Zuweisungen ausgedrückt werden können. Um mehr als einfache Initialisierungen bewerkstelligen zu können, können Klassen Konstruktoren enthalten. Konstruktoren sind keine Methoden, aber methodenähnlich: Sie haben denselben Namen wie die von ihnen initialisierte Klasse, haben keine oder mehrere Parameter und keinen Rückgabetyp. Bei der Erzeugung eines Objekts mit new werden eventuelle Parameterwerte nach dem Klassennamen in einem Klammernpaar angegeben. Bei der Objekterzeugung werden zuerst den Instanzvariablen ihre voreingestellten Anfangswerte zugewiesen, dann ihre Initialisierungsausdrücke berechnet und zugewiesen und dann der Konstruktor aufgerufen. Im folgenden benutzen wir die Klasse Circle als Standardbeispiel. Ein Kreis besteht aus einer x-Koordinate, einer y-Koordinate sowie dem Radius r. Desweiteren wird die Anzahl der erzeugten Kreise gezählt durch die Anweisung numCircles++;, die bei jedem Aufruf des parameterlosen Konstruktors ausgeführt wird. public class Circle { int x=0, y=0, r=1; static int numCircles=0; public Circle() { numCircles++; } 66 5.6 Konstruktoren und Initialisierungsblöcke public double circumference() { return 2*Math.PI*r; } public double area() { return Math.PI*r*r; } public static void main(String[] args) { Circle c = new Circle(); System.out.println(c.r); System.out.println(c.circumference()); System.out.println(c.area()); System.out.println(numCircles); } } Statt des parameterlosen Konstruktors hätten wir in der Klasse auch einen Konstruktor mit drei Parametern definieren können, der nicht nur Einheitskreise erzeugen kann: public Circle(int xCoord, int yCoord, int radius) { numCircles++; x = xCoord; y = yCoord; r = radius; } Standardmäßig benennt man die Parametervariablen im Konstruktor genauso wie die Variablen in der Klasse. Da aber hierbei Namenskonflikte entstehen, muss man die Variable des Objektes mit this.Variable referenzieren. public Circle(int x, int y, int r) { numCircles++; this.x = x; this.y = y; this.r = r; } Für eine Klasse kann es in Java auch mehrere Konstruktoren geben. Diese müssen sich allerdings in der Anzahl der Attribute bzw. deren Typen unterscheiden. Dies nennt man Überladen von Konstruktoren. In der folgenden Klasse gibt es drei Konstruktoren namens Circle. Die Konstruktoren mit Parametern rufen den 67 5 Objektorientierte Programmierung in Java parameterlosen Konstruktor mittels this() auf. Dies hat den Vorteil, dass Änderungen an den Konstruktoren nicht an drei Stellen gemacht werden müssen (was fehleranfällig ist), sondern nur im parameterlosen Konstruktor. Um die Anzahl der erzeugten Kreise zu zählen, muss man die Programmzeile numCircles++; nur dem parameterlosen Konstruktor hinzufügen. public class Circle { int x = 0, y = 0, r = 1; static int numCircles; public Circle() { numCircles++; } public Circle(int x, int y, int r) { this(); this.x = x; this.y = y; this.r = r; } public Circle(int r) { this(0,0,r); } public static void main(String[] args) { Circle c1 = new Circle(); Circle c2 = new Circle(1,1,2); Circle c3 = new Circle(3); System.out.println(numCircles); } } Klassenvariablen werden initialisiert, wenn die Klasse das erste Mal geladen wird. Das Analogon zu Konstruktoren, um komplexe Initialisierungen von Klassenvariablen durchzuführen, sind die sogenannten Initialisierungsblöcke. Diese Blöcke werden durch static {. . . } umschlossen, wie folgendes Beispiel demonstriert. Beispiel 5.6.1 (Flanagan [5], S. 59) public class Circle { public static double[] sines = new double[1000]; public static double[] cosines = new double[1000]; 68 5.7 Java-Klassen als Realisierung und Implementierung von abstrakten Datentypen static { double x, delta_x; int i; delta_x = (Math.PI/2)/(1000-1); for(i=0,x=0; i<1000; i++,x+=delta_x) { sines[i] = Math.sin(x); cosines[i] = Math.cos(x); } } } Es können mehrere klassenbezogene Initialisierungsblöcke in einer Klasse enthalten sein. Die Klasseninitialisierung erfolgt von links nach rechts und von oben nach unten. 5.7 Java-Klassen als Realisierung und Implementierung von abstrakten Datentypen Durch den Modifizierer private können wir Implementierungsdetails verstecken, denn als private deklarierte Attribute und Methoden sind nur in der Klasse selbst zugreifbar2 . Folgende Klasse implementiert einen ADT Stack mittels eines Feldes: public class Stack { private Object[] stack; private int top = -1; private static final int CAPACITY = 10000; /** liefert einen leeren Keller. */ public Stack() { stack = new Object[CAPACITY]; } /** legt ein Objekt im Keller ab und liefert dieses Objekt zusaetzlich zurueck. */ public Object push(Object item) { stack[++top] = item; return item; } 2 Synonyme für Zugreifbarkeit sind: Gültigkeit bzw. Sichtbarkeit. 69 5 Objektorientierte Programmierung in Java /** entfernt das oberste Objekt vom Keller und liefert es zurueck. Bei leerem Keller wird eine Fehlermeldung ausgegeben und null zurueckgeliefert. */ public Object pop() { if (empty()) { System.out.println("Method pop: empty stack"); return null; } else return stack[top--]; } /** liefert das oberste Objekt des Kellers, ohne ihn zu veraendern. Bei leerem Keller wird eine Fehlermeldung ausgegeben und null zurueckgeliefert. */ public Object peek() { if (empty()) { System.out.println("Method peek: empty stack"); return null; } else return stack[top]; } /** liefert true genau dann, wenn der Keller leer ist. */ public boolean empty() { return (top == -1); } /** liefert die Anzahl der Elemente des Kellers. */ public int size() { return top+1; } } Der Dokumentationskommentar /** ... */ wird zur automatischen Dokumentierung der Attribute und Methoden einer Klasse benutzt. Das Programm javadoc generiert ein HTML-File, in dem alle sichtbaren Attribute und Methoden mit de- 70 5.8 Methoden in Java ren Parameterlisten aufgezeigt und dokumentiert sind. > javadoc Stack.java Dieses HTML-File ist der Vertrag (die Schnittstelle) der Klasse und entspricht dem ADT Stack, wobei die Operationen bzw. Methoden allerdings nur natürlichsprachlich spezifiziert wurden. Die obige verbale Spezifikation entspricht weitgehend der der vordefinierten Java-Klasse Stack (genauer java.util.Stack). Man beachte, dass (aus diesem Grund) die obige Spezifikation von der Gleichungsspezifikation aus dem Unterabschnitt 5.1.1 abweicht. 5.8 Methoden in Java Methoden können wie Konstruktoren überladen werden. In Java besitzt jede Methode eine Signatur, die ihren Namen sowie die Anzahl und Typen der Parameter definiert. Zwei Methoden können denselben Namen haben, wenn ihre Signaturen unterschiedliche Anzahlen oder Typen von Parametern aufweisen; dies wird als Überladen von Methoden bezeichnet. Wird eine Methode aufgerufen, vergleicht der Übersetzer die Anzahl und die Typen der Parameter mit den verfügbaren Signaturen, um die passende Methode zu finden. Die Parameterübergabe zu Methoden erfolgt in Java durch Wertübergabe (call by value). D.h., dass Werte von Parametervariablen in einer Methode Kopien der vom Aufrufer angegebenen Werte sind. Das nächste Beispiel verdeutlicht dies. public class CallByValue { public static int sqr(int i) { i = i*i; return(i); } public static void main(String[] args) { int i = 3; System.out.println(sqr(i)); System.out.println(i); } } > java CallByValue 9 3 71 5 Objektorientierte Programmierung in Java Allerdings ist zu beachten, dass nicht Objekte, sondern Objektreferenzen übergeben werden. Wir betrachten unser Standardbeispiel Circle in folgender abgespeckter Form (gemäß der Devise, Implementierungsdetails zu verbergen, werden die Datenfelder als private deklariert). public class Circle { private int x,y,r; public Circle(int x, int y, int r) { this.x = x; this.y = y; this.r = r; } public double circumference() { return 2 * Math.PI * r; } public double area() { return Math.PI * r * r; } public static void setToZero (Circle arg) { arg.r = 0; arg = null; } public static void main(String[] args) { Circle kreis = new Circle(10,10,1); System.out.println("vorher : r = "+kreis.r); setToZero(kreis); System.out.println("nachher: r = "+kreis.r); } } > java Circle vorher : r = 1 nachher: r = 0 Dieses Verhalten entspricht jedoch nicht der Parameterübergabe call by reference, denn bei der Wertübergabe wird eine Kopie der Referenz erzeugt und die 72 5.9 Unterklassen und Vererbung in Java ursprüngliche Referenz bleibt erhalten. Bei call by reference würde die übergebene Referenz eben nicht kopiert und daher in der Methode setToZero auf null gesetzt. 5.9 Unterklassen und Vererbung in Java Wir wollen die Klasse Circle so erweitern, dass wir deren Instanzen auch graphisch darstellen können. Da ein solcher “graphischer Kreis” ein Kreis ist (es herrscht eine “ist-ein” Beziehung), erweitern wir die Klasse Circle zu der neuen Klasse GraphicCircle3 . Durch das Schlüsselwort extends wird GraphicCircle eine Unterklasse von Circle. Wir sagen auch GraphicCircle erweitert die (Ober)Klasse Circle. Damit erbt die Klasse GraphicCircle alle Attribute und Methoden von Circle, nur die als private deklarierten sind nicht über ihren Namen zugreifbar. Damit ist unsere Entscheidung, die Attribute x, y und r privat zu halten, nicht mehr sinnvoll. Um diese Attribute dennoch vor unerwünschten Zugriffen zu schützen, werden sie als protected deklariert. Damit sind sie zugreifbar für Unterklassen und werden an diese vererbt, in anderen Klassen sind sie nicht zugreifbar4 . import java.awt.Color; import java.awt.Graphics; public class GraphicCircle extends Circle { protected Color outline; // Farbe der Umrandung protected Color fill; // Farbe des Inneren public GraphicCircle(int x,int y,int r,Color outline) { super(x,y,r); this.outline = outline; this.fill = Color.lightGray; } public GraphicCircle(int x,int y,int r,Color outline,Color fill) { this(x,y,r,outline); this.fill = fill; } public void draw(Graphics g) { 3 Nur wenn eine solche “ist-ein” Beziehung herrscht, ist eine Erweiterung sinnvoll. Beispielsweise wäre eine Erweiterung der Klasse Circle zu einer Klasse Ellipse ein Design-Fehler, da eine Ellipse kein Kreis ist. Umgekehrt wäre dieses sinniger, da ein Kreis eine Ellipse ist. 4 Es sei denn, die Klasse befindet sich im selben Paket (siehe Abschnitt 2.4.2)! 73 5 Objektorientierte Programmierung in Java g.setColor(outline); g.drawOval(x-r, y-r, 2*r, 2*r); g.setColor(fill); g.fillOval(x-r, y-r, 2*r, 2*r); } public static void main(String[] args) { GraphicCircle gc = new GraphicCircle(0,0,100,Color.red,Color.blue); double area = gc.area(); System.out.println(area); Circle c = gc; double circumference = c.circumference(); System.out.println(circumference); GraphicCircle gc1 = (GraphicCircle) c; Color color = gc1.fill; System.out.println(color); } } Color und Graphics sind vordefinierte Klassen, die durch import zugreifbar gemacht werden (vgl. Abschnitt 2.4.2). Diese Klassen werden z.B. in [5] beschrieben. Zum Verständnis reicht es hier zu wissen, dass der erste Konstruktor den Konstruktor seiner Oberklasse aufruft (vgl. Abschnitt 5.11) und das Kreisinnere die Farbe hellgrau erhält, sowie, dass die Methode draw einen farbigen Kreis zeichnet. Da GraphicCircle alle Methoden von Circle erbt, können wir z.B. den Flächeninhalt eines Objektes gc vom Typ GraphicCircle berechnen durch: double area = gc.area(); Jedes Objekt gc vom Typ GraphicCircle ist ebenfalls ein Objekt vom Typ Circle bzw. vom Typ Object. Deshalb sind folgende Zuweisungen korrekt. Circle c = gc; double area = c.area(); Man kann c durch casting5 in ein Objekt vom Typ GraphicCircle zurückverwandeln. GraphicCircle gc1 = (GraphicCircle)c; Color color = gc1.fill; Die oben gezeigte Typumwandlung funktioniert nur, weil c tatsächlich ein Objekt vom Typ GraphicCircle ist. 5 explizite Typumwandlung 74 5.10 Überschreiben von Methoden und Verdecken von Datenfeldern 5.10 Überschreiben von Methoden und Verdecken von Datenfeldern Wir betrachten folgendes Java-Programm (Arnold & Gosling [1], S. 66): public class SuperShow { public String str = "SuperStr"; public void show() { System.out.println("Super.show: "+str); } } public class ExtendShow extends SuperShow { public String str = "ExtendStr"; public void show() { System.out.println("Extend.show: "+str); } public static void main(String[] args) { ExtendShow ext = new ExtendShow(); SuperShow sup = ext; sup.show(); ext.show(); System.out.println("sup.str = "+sup.str); System.out.println("ext.str = "+ext.str); } } Verdecken von Datenfeldern Jedes ExtendShow-Objekt hat zwei String-Variablen, die beide str heißen und von denen eine ererbt wurde. Die neue Variable str verdeckt die ererbte; wir sagen auch die ererbte ist verborgen. Sie existiert zwar, man kann aber nicht mehr durch Angabe ihres Namens auf sie zugreifen. Überschreiben von Methoden Die Methode show() der Klasse ExtendShow überschreibt die gleichnamige Methode der Oberklasse. Dies bedeutet, dass die Implementierung der Methode der Oberklasse durch eine neue Implementierung der Unterklasse ersetzt wird. Dabei 75 5 Objektorientierte Programmierung in Java müssen Signatur und Rückgabetyp dieselben sein. Überschreibende Methoden besitzen ihre eigenen Zugriffsangaben. Eine in der Oberklasse als protected deklarierte Methode kann wieder als protected redeklariert werden6 , oder sie wird mit dem Modifizierer public erweitert. Der Gültigkeitsbereich kann aber nicht z.B durch private eingeschränkt werden. (Eine Begründung dafür findet man in Arnold & Gosling [1], S. 66.) Wenn eine Methode von einem Objekt aufgerufen wird, dann bestimmt immer der tatsächliche Typ des Objektes, welche Implementierung benutzt wird. Bei einem Zugriff auf ein Datenfeld wird jedoch der deklarierte Typ der Referenz verwendet. Daher erhalten wir folgende Ausgabe beim Aufruf der main-Methode: > java ExtendShow Extend.show: ExtendStr Extend.show: ExtendStr sup.str = SuperStr ext.str = ExtendStr Die Objektreferenz super Das Schlüsselwort super kann in allen objektbezogenen Methoden und Konstruktoren verwendet werden. In Datenfeldzugriffen und Methodenaufrufen stellt es eine Referenz zum aktuellen Objekt als eine Instanz seiner Oberklasse dar. Wenn super verwendet wird, so bestimmt der Typ der Referenz über die Auswahl der zu verwendenden Methodenimplementierung. Wir illustrieren dies wieder an einem Beispielprogramm. public class T1 { protected int x = 1; protected String s() { return "T1"; } } public class T2 extends T1 { protected int x = 2; protected String s() { return "T2"; } protected void test() { System.out.println("x= "+x); 6 Dies ist die übliche Vorgehensweise. 76 5.11 Konstruktoren in Unterklassen System.out.println("super.x= "+super.x); System.out.println("((T1)this).x= "+((T1)this).x); System.out.println("s(): "+s()); System.out.println("super.s(): "+super.s()); System.out.println("((T1)this).s(): "+((T1)this).s()); } public static void main(String[] args) { new T2().test(); } } > java T2 x= 2 super.x= 1 ((T1)this).x= 1 s(): T2 super.s(): T1 ((T1)this).s(): T2 5.11 Konstruktoren in Unterklassen In Konstruktoren der Unterklasse kann direkt einer der Oberklassenkonstruktoren mittels des super() Konstruktes aufgerufen werden. Achtung: Der super-Aufruf muss die erste Anweisung des Konstruktors sein! Wird kein Oberklassenkonstruktor explizit aufgerufen, so wird der parameterlose Konstruktor der Oberklasse automatisch aufgerufen, bevor die Anweisungen des neuen Konstruktors ausgeführt werden. Verfügt die Oberklasse nicht über einen parameterlosen Konstruktor, so muss ein Konstruktor der Oberklasse explizit mit Parametern aufgerufen werden, da es sonst einen Fehler bei der Übersetzung gibt. Ausnahme: Wird in der ersten Anweisung eines Konstruktors ein anderer Konstruktor derselben Klasse mittels this aufgerufen, so wird nicht automatisch der parameterlose Oberklassenkonstruktor aufgerufen. Java liefert einen voreingestellten parameterlosen Konstruktor für eine erweiternde Klasse, die keinen Konstruktor enthält. Dieser ist äquivalent zu: 77 5 Objektorientierte Programmierung in Java public class ExtendedClass extends SimpleClass { public ExtendedClass () { super(); } } Der voreingestellte Konstruktor hat dieselbe Sichtbarkeit wie seine Klasse. Ausnahme: Enthält die Oberklasse keinen parameterlosen Konstruktor, so muss die Unterklasse mindestens einen Konstruktor bereitstellen. 5.12 Reihenfolgeabhängigkeit von Konstruktoren Wird ein Objekt erzeugt, so werden zuerst alle seine Datenfelder auf voreingestellte Werte initialisiert. Jeder Konstruktor durchläuft dann drei Phasen: • Aufruf des Konstruktors der Oberklasse. • Initialisierung der Datenfelder mittels der Initialisierungsausdrücke. • Ausführung des Rumpfes des Konstruktors. Beispiel 5.12.1 public class X { protected String infix = "fel"; protected String suffix; protected String alles; public X() { suffix = infix; alles = verbinde("Ap"); } public String verbinde(String original) { return (original+suffix); } } public class Y extends X { protected String extra = "d"; public Y() { suffix = suffix+extra; 78 5.13 Abstrakte Klassen und Methoden alles = verbinde("Biele"); } public static void main(String[] args) { new Y(); } } Die Reihenfolge der Phasen ist ein wichtiger Punkt, wenn während des Aufbaus Methoden aufgerufen werden (wie im obigen Beispiel). Wenn man eine Methode aufruft, erhält man immer die Implementierung dieser Methode für den derzeitigen Objekttyp. Verwendet die Methode Datenfelder des derzeitigen Typs, dann sind diese vielleicht noch nicht initialisiert worden. Die folgende Tabelle zeigt die Inhalte der Datenfelder beim Aufruf der main-Methode (d.h. des YKonstruktors). Schritt 0 1 2 3 4 5 6 Aktion infix extra Datenfelder auf Voreinstellungen Y-Konstruktor aufgerufen X-Konstruktor aufgerufen X-Datenfeld initialisiert fel X-Konstruktor ausgeführt fel Y-Datenfeld initialisiert fel d Y-Konstruktor ausgeführt fel d suffix alles fel fel feld Apfel Apfel Bielefeld Die während des Objektaufbaus aufgerufenen Methoden sollten unter Beachtung dieser Faktoren entworfen werden. Auch sollte man alle vom Konstruktor aufgerufenen Methoden sorgfältig dokumentieren, um diejenigen, die den Konstruktor überschreiben möchten, von den potentiellen Einschränkungen in Kenntnis zu setzen. 5.13 Abstrakte Klassen und Methoden Ein sehr nützliches Merkmal der objektorientierten Programmierung ist das der abstrakten Klasse. Mittels abstrakter Klassen können Klassen deklariert werden, die nur einen Teil der Implementierung definieren und erweiternden Klassen die spezifische Implementierung einiger oder aller Methoden überlassen. Abstraktion ist hilfreich, wenn Teile des Verhaltens für alle oder die meisten Objekte eines gegebenen Typs richtig sind, es aber auch Verhalten gibt, das nur für bestimmte Objekte sinnvoll ist und nicht für alle. Es gilt: • eine abstrakte Methode hat keinen Rumpf; 79 5 Objektorientierte Programmierung in Java • jede Klasse, die eine abstrakte Methode enthält, ist selbst abstrakt und muss als solche gekennzeichnet werden; • jede abstrakte Klasse muss mindestens eine abstrakte Methode besitzen; • man kann von einer abstrakten Klasse keine Objekte erzeugen; • von einer Unterklasse einer abstrakten Klasse kann man Objekte erzeugen – vorausgesetzt sie überschreibt alle abstrakten Methoden der Oberklasse und implementiert diese; • eine Unterklasse, die nicht alle abstrakten Methoden der Oberklasse implementiert ist selbst wieder abstrakt. Beispiel 5.13.1 (vgl. Arnold & Gosling [1], S. 72 ff.) Wir wollen ein Programm zur Bewertung von Programm(teilen) schreiben. Unsere Implementierung weiß, wie eine Bewertung gefahren und gemessen wird, aber sie kann nicht im voraus wissen, welches andere Programm bewertet werden soll. Die meisten abstrakten Klassen entsprechen diesem Muster: eine Klasse ist zwar Experte in einem Bereich, doch ein fehlendes Stück kommt aus einer anderen Klasse. In unserem Beispiel ist das fehlende Stück ein Code, der bewertet werden muss. Eine solche Klasse könnte wie folgt aussehen: public abstract class Benchmark { public abstract void benchmark(); public long repeat(int count) { long start = System.currentTimeMillis(); for(int i=0; i<count; i++) benchmark(); return (System.currentTimeMillis()-start); } } Die Klasse ist als abstract deklariert, weil eine Klasse mit abstrakten Methoden selbst als abstract deklariert werden muss. Diese Redundanz hilft dem Leser, schnell zu erfassen, dass die Klasse abstrakt ist, ohne alle Methoden der Klasse durchzusehen, ob zumindest eine von ihnen abstrakt ist. Die Methode repeat stellt das Sachwissen zur Bewertung bereit. Sie weiß, wie der Zeitbedarf für die Ausführung von count Aufrufen des zu bewertenden Codes zu messen ist. Wird die Messung komplizierter (vielleicht durch Messung der Zeiten jeder Ausführung und Berechnung der Varianz als statistisches Maß darüber), so kann diese Methode verbessert werden, ohne die Implementierung des speziellen zu bewertenden Codes in einer erweiternden Klasse zu beeinflussen. 80 5.14 Aufgaben Die abstrakte Methode benchmark muss von jeder selbst nicht wieder abstrakten Unterklasse implementiert werden. Deshalb gibt es in dieser Klasse keine Implementierung, sondern nur eine Deklaration. Hier nun ein Beispiel einer einfachen Erweiterung von Benchmark: public class MethodBenchmark extends Benchmark { public void benchmark() { } public static void main(String[] args) { int count = Integer.parseInt(args[0]); long time = new MethodBenchmark().repeat(count); System.out.println(count+" Methodenaufrufe in "+time+ " Millisekunden"); } } Die Implementierung von benchmark ist denkbar einfach: die Methode hat einen leeren Rumpf. Man kann daher den Zeitbedarf von n Methodenaufrufen feststellen, indem man die main-Methode der Klasse MethodBenchmark mit der Angabe n der gewünschten Testwiederholungen laufen lässt. 5.14 Aufgaben Aufgabe 5.14.1 Eine Folge heißt Schlange (engl. queue), wenn Elemente eines gegebenen Datentyps T nur am Ende eingefügt und am Anfang entfernt werden dürfen (FIFO-Prinzip: first in first out). In Analogie zum abstrakten Datentypen Stack sollen Sie hier einen abstrakten Datentypen Queue spezifizieren, der folgende Operationen enthält: initQueue: Erzeugen einer leeren Schlange. enqueue: Einfügeoperation. dequeue: Entfernt das vorderste Element. peek: Liefert das vorderste Element der Schlange, ohne die Schlange zu verändern. empty: Liefert true gdw. die Schlange leer ist. Die Operationen sind durch Gleichungen zu spezifizieren. Hinweis: Fallunterscheidungen über Schlangen mit nur einem Element und Schlangen mit mindestens zwei Elementen sind hilfreich. Aufgabe 5.14.2 Implementieren Sie eine Klasse Rent in Java, die die Klasse Stack benutzt. Nehmen Sie an, Herr Meier ist Besitzer eines Buches. Herr Meier, der Eigentümer, wird im ersten Eintrag des Stapels beschrieben. Leiht jemand das Buch aus, vielleicht Herr Schmidt, wird dessen Name auf dem Stapel abgelegt. Verleiht 81 5 Objektorientierte Programmierung in Java Herr Schmidt es wiederum weiter, z.B. an Herrn Müller, erscheint dessen Name an der Spitze des Stapels, usw. Wird das Buch an seinen Vorgänger zurückgegeben, wird der Name des Entleihers vom Stapel entfernt. Z.B. wird der Name Müller vom Stapel entfernt, wenn er das Buch Herrn Schmidt zurückgibt. Der letzte Name wird nie aus dem Stapel entfernt, denn sonst ginge die Information über den Bucheigentümer verloren. Hinweis: Die Klassen Stack und Rent müssen sich im selben Verzeichnis befinden. Aufgabe 5.14.3 (a) Harry Hacker hat wieder einmal programmiert, ohne genau nachzudenken. Er wollte mit der folgenden Methode einen Stack kopieren (d.h. einen neuen Stack mit den gleichen Werten kreieren): public static Stack copy(Stack stack) { Stack cpStack = stack; return cpStack; } Was hat Harry nicht bedacht? Und wie kann man Harry helfen? Schreiben Sie in Java eine Methode betterCopy, die den ursprünglichen Gedanken von Harry erfüllt. Ergänzen Sie ebenfalls eine main-Methode, in der die beiden copyMethoden aufgerufen werden, so dass der Unterschied deutlich wird. (b) Implementieren Sie statt der klassenbezogenen Methode betterCopy eine objektbezogene Methode gleichen Namens, die dasselbe leistet. Aufgabe 5.14.4 Objektorientierte Programmierung ermöglicht eine relativ einfache Modellierung von Ausschnitten der realen Welt. In dieser Aufgabe sollen Sie eine Klasse Vehicle implementieren, die zwei Unterklassen enthält: (i) motorgetriebene Fahrzeuge (Motorrad, Auto, Bus, LKW, ...) und (ii) personengetriebene Fahrzeuge (Fahrrad, Tretroller, Inliner, ...). Diese Klassen sollen wiederum Unterklassen besitzen. Z.B. kann man die motorgetriebenen Fahrzeuge in zweirädrige, vierrädrige und mehr-als-vierrädrige Fahrzeuge unterteilen. Modellieren Sie Fahrzeuge in sinnvoller Klassenhierarchie. Obligatorisch sind folgende Attribute und Methoden: (a) Die Klasse Vehicle sollte mindestens Datenfelder für die aktuelle Geschwindigkeit, die aktuelle Richtung in Grad, den Preis und den Besitzernamen enthalten. (b) Eine Klasse EnginePoweredVehicle soll mindestens Datenfelder über die Leistung in kW, Front- oder Heckantrieb und Höchstgeschwindigkeit besitzen. (c) Die Klasse PersonPoweredVehicle soll mindestens ein Datenfeld besitzen, das Auskunft über die Anzahl der Personen gibt, die das Fahrzeug antreiben. (d) Es sollen Klassen Car, Bus, Truck, Bike, Motorbike, Inliner und Scooter geben. Alle besitzen ein Datenfeld für eine eindeutige Identifikationsnummer. 82 5.14 Aufgaben (e) Schreiben Sie Methoden, die die einzelnen Eigenschaften verändern könnnen. Z.B. sollte die Klasse EnginePoweredVehicle eine Methode besitzen, die es ermöglicht, die Höchstgeschwindigkeit zu setzen (verändern). Jede Klasse soll mindestens zwei Methoden enthalten! (f) Schreiben Sie schließlich eine Klasse SomeVehicles mit einer main-Methode, die sechs Fahrzeuge konstruiert. Darauf sollen jeweils mindestens zwei Methoden angewendet werden. (g) Wenn diese Aufgabe Sie unterfordert, brechen Sie die Übung ab. Hauptsache, Sie haben das Prinzip verstanden. Aufgabe 5.14.5 Schreiben Sie eine Klasse LinkedList, die ein Datenfeld vom Typ Object und eine Referenz zum nächsten LinkedList-Element in der Liste enthält. Schreiben Sie zusätzlich für Ihre Klasse LinkedList eine main-Methode, die einige Objekte vom Typ Vehicle erzeugt und sie aufeinanderfolgend in die Liste einfügt. Können Sie mit Ihrer Implementierung eine leere Liste erzeugen? Aufgabe 5.14.6 Sie haben schon die Klasse Circle kennengelernt, die drei Datenfelder besaß: die Koordinaten x und y, die den Mittelpunkt eines Kreises angeben, und eine Variable r, die den Radius enthält. (a) Schreiben Sie analog dazu ein Klasse Rectangle, die vier Datenfelder besitzt. Je zwei Koordinaten x1 und y1, sowie x2 und y2, beschreiben die Endpunkte der Diagonalen eines Rechtecks, das damit vollständig beschrieben ist. (b) Schreiben Sie eine abstrakte Klasse Shape, die die beiden abstrakten Methoden area (berechnet den Inhalt eines geometrischen Objekts) und circumference (berechnet den Umfang eines geometrischen Objekts) beinhaltet. (c) Die Klassen Circle und Rectangle sollen als erweiternde Klassen von Shape implementiert werden. (d) Schreiben Sie dann noch eine main Methode, in der ein Array von ShapeObjekten der Länge 5 konstruiert wird, das Circle- und/oder Rectangle-Objekte enthalten kann. Dann soll das Array mit 5 entsprechenden Objekten gefüllt werden und der Gesamtumfang bzw. der Gesamtflächeninhalt aller Objekte ausgegeben werden. 83 5 Objektorientierte Programmierung in Java 84 6 Übergang von funktionaler zu OOP Nachdem wir nun mit Java und seinen objektorientierten und imperativen Anteilen einigermaßen vertraut sind, ist es an der Zeit für einen Vergleich zwischen diesen Programmierkonzepten und der funktionalen Programmierung, wie wir sie bei Haskell kennengelernt haben. Zunächst wollen wir allgemein funktionale und imperative Programmierung anhand einiger Beispiele gegenüberstellen. Dann gehen wir konkreter auf die Unterschiede zwischen den beiden Sprachen Haskell und Java ein. Diese Gegenüberstellung beginnt mit einem ausführlichen Vergleich einer Java-Implementierung des abstrakten Datentyps „Liste“ mit den vordefinierten Listen in Haskell. Danach werden weitere Punkte diskutiert, in denen sich die beiden Sprachen unterscheiden, und es werden, wo dies möglich ist, Techniken angegeben, wie die Konzepte der einen Sprache in der anderen nachempfunden werden können. 6.1 Imperative vs. funktionale Programmierung Plakativ lassen sich folgende Aussagen treffen: funktional: Berechnung von Werten von Ausdrücken imperativ: Berechnung des Kontrollflusses Beispiele zur Unterscheidung: Java vs. Haskell 1. Berechnung des Betrages einer ganzen Zahl n: public static int abs(int n) { if (n >= 0) return n; else return -n; } > abs’ :: (Ord a, Num a) => a -> a > abs’ n | n >= 0 = n > | otherwise = -n 85 6 Übergang von funktionaler zu OOP 2. Berechnung von sum(n) = n X i2 i=1 public static int sum(int n) { int s = 0; for(int i=1; i<=n; i++) s = s + i * i; return s; } > sum’ :: (Enum a, Num a) => a -> a > sum’ n = foldl g 0 [1..n] > where g x y = x + y * y 3. Berechnung von nextSquare(n) = min{q | q > n, q = s2 , s ∈ N} public static int nextSquare(int n) { int i = 1; int q = 1; while (q <= n) { i++; q = i*i; } return q; } > nextSquare’ :: (Num a, Enum a) => a -> a > nextSquare’ n = (head.dropWhile (<=n).map (^2)) [1..] 86 6.2 Listen in Java Es gibt folgende Entsprechungen zwischen beiden Welten: funktional imperativ • Liste von Zwischenergebnissen (anonym) • Wertabfolgen in Behältern (benannt) • Listentyp [t] • Behältertyp t • Rekursionsschemata (foldl, map) • spezielle Anweisungen (while, for) • abstrahiert von Zwischenergebnissen • Behälter werden weiterbenutzt • Speicheraufwändig • Speicherökonomisch • Listen spielen eine zentrale Rolle • Listen sind ein Datentyp wie viele andere 6.2 Listen in Java Wir geben beispielhaft eine (der vielen möglichen) Implementierungen von (Haskell) Listen in Java an. Der Typ der Listenelemente ist int. Unsere Klasse IntList benutzt die Klasse Node. public class Node { protected int element; protected Node next; public Node(int val, Node node) { element = val; next = node; } public int element() { return element; } public Node next() { return next; } } 87 6 Übergang von funktionaler zu OOP Knoten werden nun folgendermaßen zu Listen verknüpft: [1,2] = ˆ 1r 2r Die Konstruktoren von IntList public class IntList { private Node first = null; public IntList() { } private IntList(Node first) { this.first = first; } } IntList() liefert also eine leere Liste, während IntList(Node first) einen Knoten (dessen next-Datenfeld wiederum auf einen anderen Knoten zeigen kann) in eine Liste verwandelt (es handelt sich also um eine Typkonversion). public IntList () first private IntList (Node first) first 2 ··· n 1 r r r r Die Methode empty /** Tests whether a list is empty. */ public boolean empty() { return first == null; } Die Methode cons /** cons builds up lists. Returns a new reference to the list cons(val,xs), does not change xs. In order to emphasize that cons doesn’t change the list it acts upon, it is declared static. */ 88 6.2 Listen in Java public static IntList cons(int val, IntList xs) { Node n = new Node(val, xs.first); return new IntList(n); } Beispiel 6.2.1 cons(x,xs) wobei in (a) xs leer ist und in (b) xs der Liste [2,3] entspricht. (a) return value xs.first (b) xs.first 3 2 r r xs.first x r xs.first return value 2 3 r r x r Die Methoden head und tail Die Funktionen head und tail sind in Haskell folgendermaßen definiert: head (x:xs) = x tail (x:xs) = xs Da die Funktionen in Haskell partiell definiert sind, haben wir freie Wahl in der Implementierung von head [] und tail []. In unserer Implementierung in Java wird eine Fehlermeldung ausgegeben und der Wert −1 bzw. null zurückgegeben. (Das Auslösen einer Ausnahme wäre besser; vgl. Abschnitt 7.3). /** Returns the first element of a list. Returns -1 and prints an error message if the list is empty; does not change the list. */ public int head() { if (empty()) { System.out.println("Error at method head(): Empty List"); return -1; // an exception would be better } return first.element(); } 89 6 Übergang von funktionaler zu OOP /** Returns a new reference to the list obtained by removing the first element. Returns null and prints an error message if the list is empty; does not change the list. */ public IntList tail() { if (empty()) { System.out.println("Error at method tail(): Empty List"); return null; // an exception would be better } return new IntList(first.next()); } Die Methode append Die Definition von append lautet in Haskell: append [] ys = ys append (x:xs) ys = x:append xs ys Wir betrachten zwei Versionen von append. Eine ist rekursiv und eine ist nicht rekursiv definiert. /** Returns a new reference to the list obtained by concatenation of xs and ys; does not change the lists. In order to emphasize this, it is declared static. */ public static IntList append(IntList xs,IntList ys) { if (xs.empty()) return ys; else return cons(xs.head(),append(xs.tail(),ys)); } private static IntList append2(IntList xs,IntList ys) { Node tmp; if (xs.empty()) return ys; else { for(tmp=xs.first; tmp.next!=null; tmp=tmp.next) ; // Find last node tmp.next = ys.first; 90 6.2 Listen in Java return xs; } } Beispiel 6.2.2 ys.first xs.first 2 1 r r 4 5 3 r r r zs = append(xs,ys) bzw. zs = append2(xs,ys) append (rekursiv) append2 (nicht rekursiv) @ @ (nicht desktruktive Variante) (desktruktive Variante) @ @ R @ xs.first 2 1 r r ys.first zs.first 3 4 5 r r r 1 2 r r ys.first xs.first 3 4 5 1 2 r r r r r zs.first Das Verhalten von append2 ist destruktiv, denn beim Verketten von xs und ys wird xs zerstört (oder milder ausgedrückt, verändert). Diesen Seiteneffekt muss man bei jeder Anwendung von append2 berücksichtigen! Aber auch die erste Version birgt eine Gefahr: Wenn nach zs = append(xs,ys) die Liste ys verändert wird, verändert sich damit auch zs. 91 6 Übergang von funktionaler zu OOP Beispiel 6.2.3 Sonderfall xs = ys xs.first 2 1 r r zs = append(xs,xs) bzw. zs = append2(xs,xs) append append2 @ @ @ R @ xs.first zs.first 1 2 r r 1 2 r r xs.first 1 2 r r 6 zs.first Im Fall von append2 erhält man also eine „endlose“ Liste ohne terminierende null-Referenz. 6.3 Vergleich zwischen Haskell und Java 1) Parametrischer Typpolymorphismus Die Haskell-Funktion swap ist definiert durch: swap (x,y) = (y,x) (der Typ ist (a,b) -> (b,a)) Beispielsweise ist (1,"a") das Ergebnis von swap("a",1). Um diese Funktion in Java zu implementieren, benutzen wir den Typ Object. public class Pair { protected Object x; protected Object y; public Pair(Object x, Object y) { this.x = x; this.y = y; } 92 6.3 Vergleich zwischen Haskell und Java public void swap() { Object tmp = x; x = y; y = tmp; } public static void main(String[] args) { Pair p = new Pair("a",new Integer(1)); p.swap(); System.out.println(((Integer)(p.x)).intValue()+(String)p.y); } } Da elementare Daten keine Objekte sind, müssen sie in Objekte verwandelt werden. Dies geschieht mit Hilfe der Hüllenklassen – elementare Daten werden durch einen Hüllenklassenkonstruktor “eingehüllt” und damit zu Objekten. Object H HH ? Boolean Char HH j Number ) Float Double PP PP @ PP @ R @ q P Integer Long A BBILDUNG 6.1: Klassenhierarchie der Hüllenklassen Die Klassenhierarchie bezüglich der Hüllenklassen ist in Abbildung 6.1 dargestellt. Nachteil dieser Implementierung sind die vielen notwendigen expliziten Typkonversionen. Wenn man die Methode swap z.B. nur auf intZahlen benötigt, kann man folgende Implementierung ohne Typkonversionen wählen. public class IntPair { protected int x; protected int y; public IntPair(int x, int y) { this.x = x; this.y = y; 93 6 Übergang von funktionaler zu OOP } public void swap() { int tmp = x; x = y; y = tmp; } } Man muss unter Umständen aber für jeden elementaren Datentyp eine Version der Klasse Pair erstellen. Dies widerspricht natürlich dem Wiederverwendungsgedanken, denn es wird viel Code dupliziert. Seit Java 5 können Klassen mit Typparametern definiert werden. Solche Klassen werden Generische Klassen oder Parametrisierte Klassen genannt. Der Typparameter wird in spitzen Klammern ’<>’ nach dem Klassennamen angegeben. Generische Klassen werden wie andere Klassen auch benutzt, jedoch muss bei der Instanziierung der Typ angegeben werden Beispiel 6.3.1 Die Klasse Pair mit Generic Types: class Pair<E> { protected E x; protected E y; public Pair(E x, E y) { this.x = x; this.y = y; } public void swap() { E tmp = x; x = y; y = tmp; } public static void main(String[] args) { Pair<Integer> intPair = new Pair<Integer>(1,2); Pair<String> charPair = new Pair<String>("a","b"); intPair.swap(); charPair.swap(); System.out.println(intPair.x + "," + intPair.y); System.out.println(charPair.x + "," + charPair.y); } } 94 6.3 Vergleich zwischen Haskell und Java 2) Funktionen höherer Ordnung In Haskell gibt es keinen Unterschied zwischen Funktionen und Daten: Funktionen können Argumente anderer Funktionen sein, Funktionen können Funktionen als Wert liefern etc. In Java sind Funktionen (Methoden) Bestandteil von Objekten. Da Funktionen Objekte als Argumente oder Rückgabewert haben können, unterstützt Java insofern Funktionen höherer Ordnung. 3) lokale Änderung großer Datenstrukturen • ist in Java ohne weiteres möglich; • in Haskell simulierbar durch Monaden1 . 4) Klassen, Objekte und Vererbung • in Java kein Problem (objektorientierte Programmiersprache); • Haskell-Klassen definieren abstrakte Methoden, jedoch keine Objekte. Eine Instanz einer Klasse muss diese abstrakten Methoden implementieren (d.h. eine eigene Definition angeben). Insofern entspricht eine Haskell-Klasse grob gesprochen einer abstrakten Klasse in Java, die nur abstrakte Methoden enthält (genauer einer Schnittstelle). 5) Keine Entsprechung gibt es z.B. • in Java für die unendlichen Datenstrukturen in Haskell • in Haskell für die von Java unterstützte Nebenläufigkeit 6) Algebraische Datentypen und Pattern Matching Pattern Matching kann nach einer Idee von Odersky & Wadler (1997) auf sehr elegante Art und Weise simuliert werden. Wir demonstrieren dies an Hand der append-Funktion auf Listen. public class List { protected static final int NIL_TAG = 0; protected static final int CONS_TAG = 1; protected int tag; public List append(List ys) { switch (this.tag) { case NIL_TAG: return ys; case CONS_TAG: char x = ((Cons)this).head; 1 Monaden stellen in Haskell eine Möglichkeit dar, imperativ zu programmieren, d.h. es wird ein Kontrollfluss simuliert. 95 6 Übergang von funktionaler zu OOP List xs = ((Cons)this).tail; return new Cons(x, xs.append(ys)); default: return new Nil(); //an exception would be better } } } public class Cons extends List { protected char head; protected List tail; public Cons(char head, List tail) { this.tag = CONS_TAG; this.head = head; this.tail = tail; } } public class Nil extends List { public Nil() { this.tag = NIL_TAG; } } 6.4 Aufgaben Aufgabe 6.4.1 In der Vorlesung Algorithmen und Datenstrukturen I haben Sie das folgende Haskell-Programm für Insertion-Sort kennengelernt: isort :: [Integer] -> [Integer] isort [] = [] isort (a:x) = insert a (isort x) insert :: Integer -> [Integer] -> [Integer] insert a [] = [a] insert a (b:x) = if a<=b then a:b:x else b:insert a x (a) Schreiben Sie ein Java-Programm InsertionSort, das eine Folge von intZahlen einliest, diese gemäß obiger Spezifikation sortiert und dann wieder ausgibt. (b) Implementieren Sie ein nicht rekursives Programm, das dasselbe leistet. (c) Bestimmen Sie die Komplexität Ihrer Implementierungen. 96 6.4 Aufgaben Aufgabe 6.4.2 Das Haskell-Programm reverse reverse :: [a] -> [a] reverse [] = [] reverse (x:xs) = reverse xs ++ [x] ist Ihnen ebenfalls aus Algorithmen und Datenstrukturen I bekannt. (a) Implementieren Sie reverse in Java. Welche Komplexität hat Ihr Programm? (b) Implementieren Sie ein nicht rekursives Programm der Komplexität O(n), das dasselbe leistet. Aufgabe 6.4.3 Schreiben Sie eine Methode length, die die Länge einer Liste vom Typ IntList in konstanter Zeit liefert. Dabei ist es erlaubt, Objekte vom Typ IntList um weitere Datenfelder zu erweitern. Aufgabe 6.4.4 Eine einfach verkettete Liste haben Sie bereits kennengelernt. In dieser Aufgabe geht es um doppelt verkettete Listen. Eine doppelt verkettete Liste ist ein Liste, deren Knoten nicht nur auf den nächsten Knoten zeigen, sondern auch auf den vorherigen (vgl. Cormen et al. [2], S. 204 ff.). Die abstrakte Klasse Dictionary (genauer java.util.Dictionary) enthält abstrakte Methoden zur Speicherung und Ermittlung von Elementen, die über einen Schlüssel indiziert werden (vgl. auch Kapitel 9). Die Methoden von Dictionary sind: public abstract Object put(Object key, Object element); Legt element im Dictonary unter key ab. Das alte unter dem Schlüssel gespeicherte Element wird zurückgegeben. Falls es keins gab, wird null zurückgegeben. public abstract Object get(Object key); Es wird das mit dem Schlüssel key assoziierte Objekt aus dem Dictionary zurückgegeben. Wenn der Schlüssel nicht definiert ist, wird null zurückgegeben. public abstract Object remove(Object key); Der zu key passende Eintrag wird aus dem Dictionary entfernt, und das Element zum Schlüssel key wird zurückgegeben. Wenn es key nicht gibt, wird null zurückgegeben. public abstract int size(); Es wird die Anzahl der im Dictionary definierten Einträge zurückgegeben. public abstract boolean isEmpty(); Liefert true gdw. das Dictionary keine Einträge enthält. 97 6 Übergang von funktionaler zu OOP public abstract Enumeration keys(); Es wird eine Aufzählung der Schlüssel im Dictionary zurückgegeben. public abstract Enumeration elements(); Es wird eine Aufzählung der Elemente aus dem Dictionary zurückgegeben. Schreiben Sie eine Klasse DoLiList, die die ersten fünf abstrakten Methoden von Dictionary mit Hilfe von doppelt verketteten Listen implementiert. Aufgabe 6.4.5 Implementieren Sie unter Zuhilfenahme verketteter Listen eine Klasse (a) Stack, die einen Stack implementiert und (b) Queue, die eine Queue implementiert. Aufgabe 6.4.6 Harry Hacker hat ebenfalls Listen in Java implementiert. Seine Klasse IntList sieht wie folgt aus: public class IntList { private Node first = null; public IntList() { } public void cons(int val) { first = new Node(val,first); } public int head() { if (first == null) { System.out.println("Error at method head(): Empty List"); return -1; } return first.element(); } public void tail() { if (first == null) System.out.println("Error at method tail(): Empty List"); else first = first.next(); } public void append(IntList ys) { if (first == null) first = ys.first; 98 6.4 Aufgaben else { int hd = head(); tail(); append(ys); cons(hd); } } } Lisa Lista kennt sich jedoch mit Listen bestens aus und sieht sofort, dass zumindest die Implementierung einer Methode die entsprechende funktionale Spezifikation nicht erfüllt. Was geht schief? 99 6 Übergang von funktionaler zu OOP 100 7 Programmieren im Großen Die sinnvolle Strukturierung des zu entwickelnden Programmcodes ist eine der Grundvoraussetzungen für die erfolgreiche Durchführung größerer Softwareprojekte. Dieses Kapitel soll zeigen, wie in Java über Klassen und Vererbung hinaus die strukturierte Programmierung mit Hilfe von Schnittstellen und Paketen unterstüzt wird. Zur einheitlichen Fehlerbehandlung, ohne den Programmcode mit bedingten Abfragen zu überfrachten, gibt es eine spezielle Ausnahmebehandlung in Java, die wir ebenfalls kennenlernen werden. 7.1 Schnittstellen Es sei die in Abbildung 7.1 dargestellte Klassenhierarchie gegeben (vgl. 5.14.6). Shape Circle ? GraphicCircle Object H HH HH j Drawable @ @ R @ Rectangle ? GraphicRec A BBILDUNG 7.1: Eine Klassenhierarchie Bei Instanzen der Klassen GraphicCircle und GraphicRec handelt es sich um Objekte, die man zeichnen kann. Um diese “zeichenbaren” Objekte einheitlich behandeln zu können, wäre es wünschenswert, eine abstrakte Oberklasse Drawable der beiden Klassen zu haben. Problem: Es gibt nur Einfachvererbung in Java. Lösung: Schnittstellen (engl. Interfaces). 101 7 Programmieren im Großen Eine Schnittstelle kann man sich als abstrakte Klasse vorstellen, die nur abstrakte objektbezogene Methoden enthält. Während eine abstrakte Klasse auch nichtabstrakte Methoden definieren darf, sind in einer Schnittstelle alle Methoden implizit abstrakte Methoden. Neben abstrakten Methoden darf eine Schnittstelle nur Konstanten enthalten. Beispiel 7.1.1 Eine Schnittstellendeklaration: import java.awt.Graphics; public interface Drawable { public void draw(Graphics g); } Eine Klasse darf gleichzeitig eine andere Klasse erweitern und (evtl. mehrere) Schnittstellen implementieren, wie folgendes Beispiel (vgl. Abschnitt 5.9) zeigt. Beispiel 7.1.2 import java.awt.Color; import java.awt.Graphics; public class GraphicCircle extends Circle implements Drawable { protected Color outline; // Farbe der Umrandung protected Color fill; // Farbe des Inneren public GraphicCircle(int x,int y,int r,Color outline,Color fill) { super(x,y,r); this.outline = outline; this.fill = fill; } public void draw(Graphics g) { g.setColor(fill); g.fillOval(x-r, y-r, 2*r, 2*r); g.setColor(outline); g.drawOval(x-r, y-r, 2*r, 2*r); } } Eine Schnittstelle ist ein Ausdruck reinen Entwurfs, wohingegen eine (abstrakte) Klasse eine Mischung aus Entwurf und Implementierung ist. Schnittstellen können auch mit Hilfe von extends erweitert werden. Im Gegensatz zu Klassen können sie mehr als eine Schnittstelle erweitern. Die Menge der Obertypen einer Klasse besteht aus der von ihr erweiterten Klasse und den von ihr implementierten Schnittstellen einschließlich der Obertypen dieser Klasse und dieser 102 7.1 Schnittstellen Schnittstellen. Der Typ eines Objektes ist also nicht nur seine Klasse, sondern auch jeder seiner Obertypen einschließlich der Schnittstellen. Das folgende Beispiel demonstriert dies. Beispiel 7.1.3 import java.applet.Applet; import java.awt.Color; import java.awt.Graphics; public class DemoShape extends Applet { public void paint(Graphics g) { Shape[] shapes = new Shape[3]; Drawable[] drawables = new Drawable[3]; Drawable gc = new GraphicCircle(300,200,200,Color.red, Color.blue); Drawable gr1 = new GraphicRec(450,200,100,300,Color.green, Color.yellow); Drawable gr2 = new GraphicRec(50,400,300,100,Color.black, Color.magenta); shapes[0] = (Shape) gc; shapes[1] = (Shape) gr1; shapes[2] = (Shape) gr2; drawables[0] = gc; drawables[1] = gr1; drawables[2] = gr2; double totalArea = 0; for(int i=0; i<shapes.length; i++) { totalArea = totalArea+shapes[i].area(); drawables[i].draw(g); } Double total = new Double(totalArea); String str = "Total area = "+total.toString(); g.setColor(Color.black); g.drawString(str,100,550); } } 103 7 Programmieren im Großen 7.1.1 Beispiel: Die vordefinierte Schnittstelle Enumeration Die Schnittstelle Enumeration dient zur Aufzählung aller Elemente eines Datentyps. Sie deklariert zwei Methoden: public abstract boolean hasMoreElements() liefert true zurück, wenn die Aufzählung noch mehr Elemente (als bisher aufgezählt) enthält. Sie darf auch mehr als einmal zwischen aufeinanderfolgenden Aufrufen von nextElement() aufgerufen werden. public abstract Object nextElement() gibt das nächste Element der Aufzählung zurück. Aufrufe dieser Methode zählen aufeinanderfolgende Elemente auf. Wenn keine weiteren Elemente existieren, wird NoSuchElementException ausgelöst (vgl. Abschnitt 7.3). Beispiel 7.1.4 Wir wollen alle Elemente eines Stacks aufzählen. Die Klasse Stack (vgl. Abschnitt 5.7) benutzt dazu die Klasse StackEnum. import java.util.Enumeration; class StackEnum implements Enumeration { private Stack st; private int pos; protected StackEnum(Stack stack) { st = stack; pos = stack.top; } public boolean hasMoreElements() { return (pos != -1); } public Object nextElement() { if(pos != -1) return st.stack[pos--]; else return null; // an exception would be better } } Man beachte, dass die Deklaration der Klasse StackEnum keinen Gültigkeitsmodifizierer enthält. Damit erhält diese Klasse automatisch (default) den Gültigkeitsbereich package. Desweiteren ist zu beachten, dass die Datenfelder stack und 104 7.2 Pakete top der Klasse Stack im Abschnitt 5.7 als private deklariert wurden. D.h. sie sind in der Klasse StackEnum nicht zugreifbar. Aus diesem Grund müssen diese Datenfelder z.B. als protected deklariert werden. Die Klasse Stack muss nun um die Methode elements erweitert werden. public Enumeration elements() { return new StackEnum(this); } Die Anwendung der Methode erfolgt dann durch das Erstellen eines neuen Objektes vom Typ Enumeration. Enumeration e = stack.elements(); while(e.hasMoreElements()) System.out.println(e.nextElement()); Man beachte, dass die Implementierung von elements() völlig verborgen ist und der Typ StackEnum in der Klasse Stack überhaupt nicht auftaucht (stattdessen wird der Typ Enumeration der implementierten Schnittstelle benutzt). Achtung: Die Schnittstelle Enumeration hat keine Schnappschussgarantie. Wird der Inhalt der Sammlung während der Aufzählung verändert, kann das die von den Methoden zurückgegebenen Werte beeinflussen. Ein Schnappschuss würde die Elemente so zurückgeben, wie sie waren, als das Enumeration-Objekt erzeugt wurde. 7.2 Pakete Pakete und Gültigkeitsbereiche wurden schon kurz in Abschnitt 2.4 behandelt. Hier folgt nun eine ausführlichere Beschreibung. Pakete enthalten inhaltlich zusammenhängende Klassen und Schnittstellen. Die Klassen und Schnittstellen können gebräuchliche öffentliche Namen (wie get und put) verwenden, denn durch Voranstellen des Paketnamens können eventuelle Namenskonflikte vermieden werden. Beispielsweise macht es Sinn, die Klassen Stack und StackEnum in ein Paket stack zu stecken.1 Am Anfang jeder Quelltextdatei, deren Klassen zu dem stack-Paket gehören sollen, muss die Paketdeklaration package stack; stehen. package stack; package stack; public class Stack { ... } class StackEnum { ... } 1 Paketnamen werden nach Konvention klein geschrieben. 105 7 Programmieren im Großen Der Paketname ist implizit jedem im Paket enthaltenen Typnamen vorangestellt. Benötigt außerhalb des Paketes definierter Code innerhalb des Paketes deklarierte Typen, kann er sich auf zwei Arten auf diese beziehen: 1. Voranstellen des Paketnames, z.B. stack.Stack. 2. Importieren (von Teilen) des Paketes durch import stack.Stack; oder auch durch Importieren aller zugreifbaren Klassen eines Paketes mit *: import stack.*; Der Paket- und Importmechanismus ermöglicht die Kontrolle über möglicherweise in Konflikt geratene Namen. Es gibt z.B. schon eine vordefinierte Java-Klasse Stack. Diese befindet sich im Paket java.util. Sollen beide Klassen im selben Quelltext verwendet werden, so kann dies geschehen durch: • Angabe der voll qualifizierten Namen (stack.Stack und java.util.Stack). • Importieren von z.B. java.util.Stack oder java.util.* und verwenden von Stack für java.util.Stack sowie des vollen Namens von stack.Stack bzw. umgekehrt. 7.2.1 Paketinhalte Enthält eine Quelltextdatei keine Paketdeklarationen, gehen die in ihr deklarierten Typen in ein unbenanntes Paket ein. Pakete sollen sorgfältig entworfen werden, damit sie nur von der Funktionalität zusammengehörige Klassen und Schnittstellen enthalten, denn Klassen in einem Paket können auf die nichtprivaten Attribute und Methoden anderer Klassen frei zugreifen. Pakete können ineinander geschachtelt werden (z.B. java.util). Die Schachtelung ermöglicht ein hierarchisches Benennungssystem, stellt aber keinen speziellen Zugriff zwischen Paketen zur Verfügung. 7.2.2 Paketbenennung Paketnamen sollen einmalig sein. Folgende Konvention soll dies sicherstellen: package DE.Uni-Bielefeld.TechFak.juser.stack; Der Code für ein Paket muss sich in einem diesen Namen widerspiegelnden Verzeichnis befinden (auf Details wollen wir hier nicht eingehen). 106 7.3 Ausnahmen (Exceptions) 7.3 Ausnahmen (Exceptions) Java stellt einen umfangreichen Mechanismus zur Behandlung sog. Ausnahmen (z.B. Fehlermeldungen) bereit. Wir stellen die Vorteile von Ausnahmen gegenüber der traditionellen Fehlerbehandlung schlagwortartig dar. Ausnahmen • sind eine saubere Art, Fehlerprüfungen vorzunehmen, ohne den Quelltext zu überfrachten; • vermeiden eine Überflutung des grundlegenden Ablaufs des Programms mit vielen Fehlerprüfungen; • zeigen Fehler direkt an, statt Variablen oder Seiteneffekte auf Datenfelder zu nutzen, die anschließend zu prüfen sind; • machen Fehlerbedingungen zum expliziten Bestandteil der Spezifikation einer Methode; • sind daher für Programmierer sichtbar und bei einer Analyse überprüfbar. Eine Ausnahme ist ein Signal, das auf eine unerwartete Fehlerbedingung hinweist. Eine Ausnahme wird ausgelöst durch das throw-Konstrukt. Die Ausnahme wird durch ein umgebendes Konstrukt irgendwo entlang der aktuell ablaufenden Methodenaufrufe abgefangen2 . Wird die Ausnahme nicht abgefangen, tritt die standardmäßige Ausnahmebehandlung in Kraft. Die Klassenhierarchie für Ausnahmen ist in Abbildung 7.2 dargestellt. Man unterscheidet zwischen geprüften und ungeprüften Ausnahmen. Es gilt: • ungeprüfte Ausnahmen erweitern die Klasse Error und RuntimeException und werden nicht abgefangen; • geprüfte Ausnahmen erweitern die Klasse Exception (nach allgemeiner Übereinkunft nicht die Klasse Throwable): der Übersetzer überprüft, dass Methoden nur die von ihnen deklarierten Ausnahmen auslösen. Jedes Objekt vom Typ Throwable hat ein Datenfeld vom Typ String, welches mit der Methode getMessage() gelesen werden kann; dieser String enthält eine Fehlermeldung, die die Ausnahme beschreibt. Das Datenfeld wird bei der Erzeugung eines Ausnahmeobjekts mit Hilfe eines Konstruktors gesetzt. 2 Dies bezeichnet man auch als catch. 107 7 Programmieren im Großen Object ? Throwable HH HH j H Exception Error HH @ H @ HH R @ j AbstractMethodError ... ... RuntimeException ArithmeticException @ @ R @ ... A BBILDUNG 7.2: Klassenhierarchie für Ausnahmen Beispiel 7.3.1 Der folgende Programmcode implementiert die hier dargestellte Hierarchie von Ausnahmeklassen: Exception HH H HH j MyException MyOtherException MySubException class MyException extends Exception { public MyException() { super(); } public MyException(String s) { super(s); } } class MyOtherException extends Exception { public MyOtherException() { super(); } 108 7.3 Ausnahmen (Exceptions) public MyOtherException(String s) { super(s); } } class MySubException extends MyException { public MySubException() { super(); } public MySubException(String s) { super(s); } } Es gibt zwei wesentliche Gründe für die Einführung neuer Ausnahmetypen: • Hinzufügen nützlicher Informationen; • der Typ selbst ist eine wichtige Information über die Ausnahmebedingung, da Ausnahmen aufgrund ihres Typs abgefangen werden. 7.3.1 throw und throws Ausnahmen werden ausgelöst durch die ein Objekt als Parameter erhaltene throwAnweisung. Beispiel 7.3.2 import java.util.EmptyStackException; public class Stack { ... public Object pop() { if(empty()) throw new EmptyStackException(); else return stack[top--]; } public Object peek() { if(empty()) throw new EmptyStackException(); else 109 7 Programmieren im Großen return stack[top]; } } Alle geprüften Ausnahmen, die in einer Methode ausgelöst und nicht in dieser abgefangen und behandelt werden, müssen deklariert werden durch throws. Das Fehlen von throws bedeutet, dass die Methode keine geprüfte Ausnahme auslöst. In der Tat ist EmptyStackException eine Unterklasse von RuntimeException. Daher musste EmptyStackException in Beispiel 7.3.2 nicht deklariert werden. Im folgenden Beispiel ist dies anders. Beispiel 7.3.3 MyException und MySubException seien wie in Beispiel 7.3.1 definiert. public static int c(int i) throws MyException { switch(i) { case 0: throw new MyException("input too low"); case 1: throw new MySubException("input still too low"); default: return i*i; } } Die Methode c kann die beiden Ausnahmen MyException und MySubException auslösen. Diese müssen daher deklariert werden. Da allerdings MySubException eine Unterklasse von MyException ist, reicht es, MyException zu deklarieren. Ruft man eine Methode auf, die nach throws eine zu berücksichtigende Ausnahme aufführt, hat man die drei Möglichkeiten: (a) die Ausnahme abzufangen und zu behandeln, (b) die Ausnahme abzufangen und auf eine eigene Ausnahme abzubilden, die man dann selbst auslösen kann, und deren Typ im eigenen throwsKonstrukt deklariert ist, (c) den Ausnahmetyp im eigenen throws-Konstrukt zu deklarieren und die Ausnahme ohne Behandlung die Methode passieren zu lassen (dabei kann ein eigenes finally-Konstrukt vorher noch zum Aufräumen aktiv werden). 7.3.2 try, catch und finally Das allgemeine Muster zum Umgang mit Ausnahmen in Java ist die try-catchfinally-Sequenz: Man versucht etwas; wenn dieses eine Ausnahme auslöst, fängt man die Ausnahme ab; und schließlich räumt man nach Ende des normalen Ablaufs oder des Ausnahmeablaufs auf, was immer auch passiert ist. Die Syntax der try-catch-finally-Sequenz ist folgendermaßen: 110 7.3 Ausnahmen (Exceptions) try block1 catch(exception_type identifier) block2 catch(exception_type identifier) block3 ... finally blockL Der Rumpf (block1) der try-Anweisung wird ausgeführt, bis entweder eine Ausnahme ausgelöst oder der Rumpf erfolgreich abgeschlossen wird. Wenn eine Ausnahme ausgelöst wurde, werden die catch-Konstrukte betrachtet, um darin die Klasse der Ausnahmen oder eine ihrer Oberklassen zu finden. Wird kein solches catch gefunden, so wird die Ausnahme über diese try-Anweisung hinaus an einen umgebenden try-Block zur Behandlung weitergeleitet. Die Zahl der catch-Konstrukte ist beliebig, sogar kein catch ist möglich. Wenn kein catch in einer Methode zur Behandlung einer Ausnahme in Frage kommt, wird die Ausnahme dem Aufrufer der Methode zur Behandlung weitergereicht. Wenn ein finally-Konstrukt in einem try vorhanden ist, so wird dessen Block nach allen anderen Anweisungen in dem try ausgeführt. Dies erfolgt unabhängig davon, wie die vorherigen Anweisungen abgeschlossen wurden; sei es regulär, durch Ausnahmen oder durch den Ablauf beeinflussende Anweisungen wie return3 . Die catch-Konstrukte werden der Reihe nach untersucht. Deshalb ist es ein Fehler, wenn in den catch-Konstrukten ein Ausnahmetyp vor einer Erweiterung desselben abgefangen wird (denn das catch mit dem Untertyp würde niemals erreicht werden). Dies wird in folgendem Beispiel deutlich. Beispiel 7.3.4 (vgl. Arnold & Gosling [1], S. 155 ff.) MyException und MySubException seien wie in Beispiel 7.3.1 definiert. class BadCatch { public void goodTry() { try { throw new MySubException(); } catch(MyException myRef) { // Abfangen von sowohl MyException als auch MySubException } catch(MySubException mySubRef) { // Dies wird nie erreicht 3 Auch break ist so eine Anweisung, wir werden sie aber nicht genauer behandeln. 111 7 Programmieren im Großen } } } Nur eine Ausnahme wird in einem try-Konstrukt behandelt. Wenn eine weitere Ausnahme ausgelöst wird, werden die catch-Konstrukte des try nicht ein weiteres Mal aktiviert. Das finally-Konstrukt in der try-Anweisung ermöglicht es – unabhängig davon, ob eine Ausnahme ausgelöst wurde – abschließend noch ein Programmstück auszuführen (z.B. das Schließen von offenen Dateien; so kann sparsam mit dieser begrenzten Ressource umgegangen werden). Wenn der finally-Block durch return oder Auslösen einer Ausnahme verlassen wird, kann ein ursprünglicher Rückgabewert in Vergessenheit geraten. Beispiel 7.3.5 (Arnold & Gosling [1], S. 157) try { // ... irgendein Code ... return 1; } finally { return 2; } Es würde immer der Wert 2 zurückgegeben werden. Beispiel 7.3.6 (Flanagan [5], S. 43 ff.) MyException, MyOtherException und MySubException seien wie in Beispiel 7.3.1 definiert. Um das nachfolgende Programm verstehen zu können, muss man wissen, dass instanceof feststellt, ob ein Objekt von einem gegebenen Typ ist. Die null-Referenz ist keine Instanz irgendeines Typs, daher ist false der Rückgabewert von null instanceof Typ. public class ThrowTest { public static void main(String[] args) { int i; try { i = Integer.parseInt(args[0]); } catch(ArrayIndexOutOfBoundsException e) { System.out.println("Must specify an argument"); return; } 112 7.3 Ausnahmen (Exceptions) catch(NumberFormatException e) { System.out.println("Must specify an integer argument"); return; } a(i); } public static void a(int i) { try { b(i); } catch(MyException e) { if(e instanceof MySubException) System.out.print("MySubException:"); else System.out.print("MyException:"); System.out.println(e.getMessage()); System.out.println("Handled at point 1"); } } public static void b(int i) throws MyException { int result; try { System.out.println("i= "+i); result = c(i); System.out.println("c(i)= "+result); } catch(MyOtherException e) { System.out.println("MyOtherException: "+e.getMessage()); System.out.println("Handled at point 2"); } finally { System.out.println(); } } public static int c(int i) throws MyException,MyOtherException { switch(i) { case 0: throw new MyException("input too low"); case 1: throw new MySubException("input still too low"); case 99: throw new MyOtherException("input too high"); default: return i*i; 113 7 Programmieren im Großen } } } > java ThrowTest hello Must specify an integer argument > java ThrowTest 0 i= 0 MyException: input too low Handled at point 1 > java ThrowTest 1 i= 1 MySubException: input still too low Handled at point 1 > java ThrowTest 2 i= 2 c(i)= 4 > java ThrowTest 99 i= 99 MyOtherException: input too high Handled at point 2 7.4 Aufgaben Aufgabe 7.4.1 Vervollständigen Sie die Klasse DoLiList aus Aufgabe 6.4.4, so dass diese als nicht-abstrakte Unterklasse der abstrakten Klasse Dictionary deklariert werden kann. Aufgabe 7.4.2 Reimplementieren Sie schon erstellte Programme derart, dass diese auf dem Ausnahmekonzept beruhen. 114 8 Graphen Graphen und Graphalgorithmen sind allgegenwärtig in der Informatik. Hunderte von interessanten Problemen lassen sich mit Hilfe von Graphen formulieren. Dieses Kapitel beschreibt zunächst Methoden zur Repräsentation von Graphen. Danach wird eine kleine Auswahl der wichtigsten Algorithmen bei ihrer Verarbeitung vorgestellt. Literatur: [3, 4] 8.1 Anwendungen von Graphen Um einen Eindruck von der Vielfalt der Einsatzmöglichkeiten von Graphen und Graphalgorithmen zu bekommen, wollen wir einige Beispiele betrachten: Karten Wenn wir eine Reise planen, wollen wir Fragen beantworten wie: Was ist der kürzeste Weg von Bielefeld nach München? Was der schnellste Weg? Um diese Fragen beantworten zu können, benötigen wir Informationen über Verbindungen (Reiserouten) zwischen Objekten (Städten). Hypertexts Das ganze Web ist ein Graph: Dokumente enthalten Referenzen (Links) auf andere Dokumente, durch die wir navigieren. Graphalgorithmen sind essentielle Komponenten der Suchmaschinen, die uns Informationen im Web finden lassen. Schaltkreise Für elektrische Schaltungen sind wir an kreuzungsfreien Chip-Layouts interessiert und müssen Kurzschlüsse vermeiden. Zeitpläne Die Erledigung einiger Aufgaben hängt evtl. von der Erledigung anderer ab. Diese Abhängigkeiten können als Verbindungen von Aufgaben modelliert werden. Ein klassisches scheduling Problem wäre dann: Wie arbeiten wir die Aufgaben unter den gegeben Voraussetzungen am schnellsten ab? Netzwerke Computernetzwerke bestehen aus untereinander verbundenen Einheiten, die Nachrichten senden, empfangen und weiterleiten. Wir sind nicht nur 115 8 Graphen daran interessiert, welchen Weg eine Nachricht von einem Ort zum anderen nehmen muss. Genauso will man sicherstellen, dass die Konnektivität aller Orte auch dann gewähleistet ist, wenn sich das Netzwerk ändert (Ausfallsicherheit). Genauso muss der Datenfluss sichergestellt sein, so dass das Netzwerk nicht ’verstopft’. 8.2 Terminologie Graphen werden als eine durch Kanten verbundene Menge von Knoten definiert. Definition 8.2.1 Ein Graph G = (V, E) besteht aus einer Menge von Knoten V (auch vertices oder nodes) und einer Menge von Kanten E (edges). Jede Kante ist ein Paar (v, w) ∈ V , das Paare von Knoten verbindet. Wir beschränken uns im Folgenden auf Graphen, die keine doppelten (oder parallele) Kanten besitzen. (Graphen, die doppelte Kanten enthalten, nennt man Multigraphen.) Definition 8.2.2 Ein gewichteter Graph (weighted graph) ist ein Graph, in dem Kanten mit Gewichten versehen sind. Gewichtete Graphen enthalten somit zusätzliche Attribute, in denen z.B. die Länge einer Kante (bei der Modellierung eines Straßennetzes) repräsentiert werden können. Beispiel 8.2.3 Abbildung 8.1 zeigt einen gewichteten Graphen, der Bahnverbindungen zwischen einigen Großstädten Deutschlands repräsentiert. Die Städte sind als Knoten, direkte Verbindungen als Kanten zwischen den jeweiligen Städten dargestellt. Die Entfernungen zwischen zwei Städten ist als Gewicht an der jeweiligen Kante vermerkt. Wir können z.B. folgende Fragen stellen, die mit Hilfe des Graphen beantwortet werden können: • Gibt es eine direkte Verbindung zwischen Stadt BI und M? • Welches ist der kürzeste Weg von BI nach S? • Welches ist der kürzeste Weg, der in Stadt F startet und alle Städte einmal besucht? Definition 8.2.4 Ein gerichteter Graph (directed graph, digraph) ist ein Graph, in dem jede Kante eine Richtung hat. Für u, v ∈ V ist dann (u, v) 6= (v, u). In gerichteten Graphen können zwei Knoten durch zwei Kanten verbunden sein, allerdings nur durch je eine Kante in jede Richtung. 116 8.2 Terminologie HH 160 H 110 115 285 B 293 BI DO 350 220 585 F 630 210 S 230 M A BBILDUNG 8.1: Ein Graph zur Repräsentation von Bahnverbindungen zwischen verschiedenen Städten. Definition 8.2.5 Ein Teilgraph (subgraph) von (V, E) ist ein Paar (V 0 , E 0 ), mit V 0 ⊂ V und E 0 = {(u, v)|(u, v) ∈ E : u ∈ V 0 , v ∈ V 0 }. Definition 8.2.6 Ein Graph heißt verbunden (connected), wenn jeder Knoten von jedem anderen Knoten aus erreicht werden kann. Ein Graph, der nicht verbunden ist, besteht aus einer Menge von Zusammenhangskomponenten (connected components), die maximal verbundene Teilgraphen sind. Definition 8.2.7 Zwei Knoten u, v ∈ V mit u 6= v heißen benachbart (adjacent), wenn (u, v) ∈ E oder (v, u) ∈ E. Definition 8.2.8 Bei der Bestimmung des Grads (degree) eines Knotens muss man zwischen ungerichteten und gerichteten Graphen unterscheiden: • ungerichtete Graphen: Der Grad eines Knotens ist die Zahl seiner Nachbarn. • gerichtete Graphen: Der Eingangsgrad (in-degree) eines Knotens v ∈ V ist die Zahl der Kanten (u, v) ∈ E. 117 8 Graphen Der Ausgangsgrad (out-degree) eines Knotens v ∈ V ist die Zahl der Kanten (v, u) ∈ E. Der Grad ist die Summe von Eingangs- und Ausgangsgrad. Definition 8.2.9 Ein Pfad (path) von u nach v ist einen Folge von Knoten u1 , u2 , . . . , uk , so daß u1 = u und uk = v und (ui , ui+1 ) ∈ E für alle 1 ≤ i < k. Definition 8.2.10 Ein Zyklus (cycle) ist ein Pfad, in dem Start- und Endknoten identisch sind. Definition 8.2.11 Auch Bäume sind spezielle Graphen: Ein Baum (tree) ist ein ungerichteter, verbundener Graph ohne Zyklen (genauer: ohne Kreise, also Zyklen, in denen nur Anfangs- und Endpunkt identisch sind). Eine Menge von Bäumen heißt Wald (forest). Ein Spannbaum (spanning tree) eines verbundenen Graphen (V, E) ist ein Teilgraph, der alle Knoten V enthält und ein Baum ist. 8.3 Repräsentation von Graphen Um Graphen darzustellen gibt es zwei Standardmöglichkeiten: Adjazenzlisten und Adjazenzmatrizen, die beide sowohl für gerichtete als auch ungerichtete Graphen verwendet werden können. Bei dünn besetzten (sparse) Graphen, bei denen die Anzahl der Kanten |E| viel kleiner als |V |2 ist, liefern Adjazenzlisten eine kompakte Darstellung. Die Repräsentation durch Adjazenzmatrizen wird vorgezogen, wenn der Graph dicht besetzt (dense) ist (d.h. wenn |E| nahe an |V |2 liegt, oder wenn ein Algorithmus möglichst schnell hearusfinden muss, ob zwei Knoten verbunden sind. Adjazenzlisten Die Abbildungen 8.2(b) und 8.3(b) zeigen ein Beispiele für die Repräsentation eines Graphen als Adjazenzliste: ein Array Adj enthält für jeden Knoten aus V eine Liste. Für jeden Knoten u ∈ V enthält die Liste Adj[u] alle Knoten v, so dass (u, v) ∈ E. Die Knoten werden dabei üblicherweise in beliebiger Reihenfolge gespeichert. Wenn die Kanten Gewichte haben, werden diese ebenfalls in der Liste gespeichert. Diese Darstellung braucht Θ(V + E) Platz. Alle Knoten, die adjazent zu u sind, können in Θ(degree(u)) bestimmt werden. Um zu überprüfen, ob (u, v) ∈ E gilt, wird O(degree(u)) Zeit benötigt. Adjazenzmatrizen Die Abbildungen 8.2(c) und 8.3(c) zeigen ein Beispiele für die Repräsentation eines Graphen als Adjazenzmatrix: die Knoten des Graphen seien in beliebiger 118 8.4 Ein Abstrakter Datentyp (ADT) Graph 1 1 2 5 2 1 5 3 2 4 4 2 5 5 4 1 1 2 3 4 5 1 0 1 0 0 1 2 1 0 1 1 1 3 0 1 0 1 0 3 4 0 1 1 0 1 2 5 1 1 0 1 0 2 3 5 3 4 4 (a) (b) (c) A BBILDUNG 8.2: Zwei Repräsentationen eines ungerichteten Grapen mit 5 Knoten und 7 Kanten (a). (b) zeigt die Adjazenzlisten-Repräsentation des Graphen, (c) die entsprechende Adjazenzmatrix. (Nach [3], S. 528) 1 2 4 5 4 1 2 3 4 5 6 1 0 1 0 1 0 0 2 0 0 0 0 1 0 1 2 2 5 3 6 3 0 0 0 0 1 1 4 2 4 0 1 0 0 0 0 5 4 5 0 0 0 1 0 0 6 6 6 0 0 0 0 0 1 3 6 (a) 5 (b) (c) A BBILDUNG 8.3: Zwei Repräsentationen eines gerichteten Grapen mit 6 Knoten und 8 Kanten (a). (b) zeigt die Adjazenzlisten-Repräsentation des Graphen, (c) die entsprechende Adjazenzmatrix. (Nach [3], S. 528) Reihenfolge nummeriert. Dann kann der Graph durch eine |V | × |V |-Matrix A = (aij ) beschrieben werden, mit den Elementen ( 1 aij = 0 falls (i, j) ∈ E, sonst. Die Adjazenzmatrix-Darstellung benötigt O(V 2 ) Platz. Alle Knoten, die adjazent zu u sind, können in Θ(V ) bestimmt werden. Um zu überprüfen, ob (u, v) ∈ E gilt, wird O(1) Zeit benötigt. Gewichtete Graphen können dargstellt werden, indem man in der Matrix Gewichte statt Bits speichert. 8.4 Ein Abstrakter Datentyp (ADT) Graph Wir wollen nun einen abstrakten Datentypen für Graphen beschreiben, der als Java-Interface definiert wird. Dieses sehr einfach gehaltene Interface genügt, um die in den nächsten Abschnitten beschriebenen Algorithmen zu implementieren. 119 8 Graphen Der Graph Konstruktor bekommt zwei Parameter: einen Integer-Wert für die Anzahl der Knoten im Graphen und einen Boolean, der angibt, ob der Graph gerichtet ist oder nicht. Die Operationen beschränken sich zunächst auf: numOfV() gibt die Anzahl der Knoten zurück numOfE() gibt die Anzahl der Kanten zurück directed() gibt an, ob der Graph gerichtet ist insert(e) fügt eine Kante in den Graphen ein remove(e) löscht einen Kante aus dem Graphen edge(v,w) überprüft, ob es eine Kante zwischen Knoten v und w gibt getAdjList(v) stellt einen Iterator zur Verfügung, der alle benachbarten Knoten von v aufzählt Das Java-Interface ist folgendermaßen definiert: public interface Graph { int numOfV(); int numOfE(); boolean directed(); void insert(Edge e); void remove(Edge e); boolean edge(int v, int w); AdjList getAdjList(int v); } public interface AdjList { int begin(); int next(); boolean end(); } public class Edge { int v; int w; Edge(int v, int w) { this.v = v; this.w = w; } } 120 8.5 Breitensuche Das oben angegebene Interface ist ein möglichst einfach gehaltenes Beispiel für einen Graph-ADT. Bestimmte Algorithmen erfordern eventuell eine Anpassung dieses Interfaces, z.B. durch Einführen einer speziellen Klasse Vertex oder Erweiterung der Klasse Edge, um zusätzliche Informationen in den Knoten oder Kanten (z.B. Gewichte) speichern zu können. Auch können in unserem Beispiel noch keine Knoten hinzugefügt oder gelöscht werden. Ebenso finden keine Überprüfungen statt, ob z.B. parallele Kanten zwischen Knoten eingefügt werden. Dies wiederum ist abhängig von der jeweiligen Anwendung des Graphen und müßte entsprechend behandelt werden. 8.5 Breitensuche Die Breitensuche (breadth-first search) ist eine der einfachsten Algorithmen zur Suche in Graphen. Gegeben einen Graphen G und einen Startknoten s, durchsucht die Breitensuche systematisch jede Kante von G, um alle Knoten zu finden, die von s erreichbar sind. Dabei wird die Distanz (kleinste Anzahl von Kanten) von s zu jedem erreichbaren Knoten berechnet. Bei der Suche wird ein breadthfirst tree mit Wurzel s erzeugt, der alle erreichbaren Knoten enthält. Der kürzeste Pfad von s nach v in dem Baum entspricht dem kürzesten Pfad von s nach v im Graphen G. Der Name ”Breitensuche” läßt sich dadurch erklären, dass die Suche die Grenze zwischen besuchten und unbesuchten Knoten gleichmäßig über die Breite der Grenze ausdehnt. D.h. der Algorithmus besucht zunächst alle Knoten mit Distanz k von s, bevor Knoten mit Distanz k + 1 besucht werden. Während der Breitensuche werden Knoten weiß, grau oder schwarz eingefärbt. Wenn ein Knoten während der Suche entdeckt wird, ändert sich seine Farbe. Graue und schwarze Knoten sind bereits entdeckt worden, und diese Information wird ausgenutzt, um die Breitensuche voranzutreiben. Wenn es eine Kante (u, v) ∈ E gibt, und u ist schwarz, dann ist v entweder grau oder schwarz. Das bedeutet, dass alle benachbarten Knoten eines schwarzen Knotens bereits entdeckt wurden. Graue Knoten können noch weiße Nachbarn haben; sie repräsentieren die Grenze zwischen entdeckten und unentdeckten Knoten. Die Breitensuche konstruiert einen Breitensuchbaum, dessen Wurzel der Startknoten s ist. Mithilfe dieses Baumes lassen sich Vorgänger- und Nachfolgerrelationen (relativ zu s) aufstellen. Immer dann, wenn ein Knoten entdeckt wird (und auch nur dann), wird dem Baum eine Kante hinzugefügt. Der folgende Algorithmus führt eine Breitensuche in einem Graphen aus. Abbildung 8.4 zeigt die Suche an einem Beispiel. Die Farbe eines Knotens u ∈ V wird in color[u] gespeichert, der Vorgänger von u in π[u]. Wenn u keinen Vorgänger hat (z.B. s oder wenn u noch nicht entdeckt wurde), ist π[u] = N IL. Der Algorithmus berechnet auch die Distanz von s zu jedem Knoten u und speichert sie in d[u] (initial ist die Distanz von s zu allen Knoten unendlich groß). Außerdem wird 121 Breadth-first Search 8 Graphen Breadth-first Search r s t u r s t u ∞ 0 ∞ ∞ ∞ 1 0 ∞ ∞ (a) Breadth-first Search Q ∞ ∞ ∞ ∞ v w x y (b)Breadth-first Search s 0 Q ∞ ∞ 1 ∞ ∞ v w x y r s t u r s t u ∞ 1 0 ∞ 2 ∞ ∞ 1 0 ∞ 2 ∞ (c) Breadth-first Search Q ∞ ∞ 1 ∞ 2 ∞ v w x y r t x 1 2 2 (d)Breadth-first Search Q ∞ 2 ∞ 1 ∞ 2 ∞ v w x y r s t u r s t u ∞ 1 0 ∞ 2 ∞ 3 ∞ 1 0 ∞ 2 ∞ 3 (e) Breadth-first Search Q ∞ 2 ∞ 1 ∞ 2 ∞ v w x y x v u 2 2 3 (f) Breadth-first Search Q ∞ 2 ∞ 1 ∞ 2 ∞ 3 v w x y r s t u r s t u ∞ 1 0 ∞ 2 ∞ 3 ∞ 1 0 ∞ 2 ∞ 3 (g)Breadth-first Search Q ∞ 2 ∞ 1 ∞ 2 ∞ 3 v w x y r s t u ∞ 1 0 ∞ 2 ∞ 3 (i) u y 3 3 (h) Q ∞ 2 ∞ 1 ∞ 2 ∞ 3 v w x y w r 1 1 t x v 2 2 2 v u y 2 3 3 y 3 Q ∞ 2 ∞ 1 ∞ 2 ∞ 3 v w x y A BBILDUNG 8.4: Operationen der Breitensuche auf einem ungerichteten Graphen. Die Kanten des bei der Suche entstehenden Baumes sind schattiert dargestellt. Jeder Knoten u enthält den Wert d[u]. Die Queue Q ist jeweils zu Beginn jeder Iteration der whileSchleife gezeigt. Knotenabstände sind jeweils unter der Queue dargestellt. (Nach [3], S. 533) noch eine Queue Q verwendet, um die grauen Knoten zwischenzuspeichern. Die Laufzeit des Algorithmus zur Breitensuche ist O(V + E). BFS(G, s) 1 for each vertex u ∈ V [G] − {s} 2 do color[u] ← W HIT E 3 d[u] ← ∞ 4 π[u] ← N IL 5 color[s] ← GRAY 6 d[s] ← 0 7 π[s] ← N IL 8 Q←∅ 9 ENQUEUE(Q, s) 122 8.6 Tiefensuche 10 11 12 13 14 15 16 17 18 while Q 6= ∅ do u ← DEQUEUE(Q) for each v ∈ Adj[u] do if color[v] = W HIT E then color[v] ← GRAY d[v] ← d[u] + 1 π[v] ← u ENQUEUE(Q, v) color[u] ← BLACK 8.6 Tiefensuche Die Strategie der Tiefensuche besteht darin, immer zuerst ”tiefer” im Graphen zu suchen. Dabei werden zunächst die Kanten durchsucht, die von dem zuletzt entdeckten Knoten v ausgehen. Erst wenn alle Kanten von v untersucht wurden, geht die Suche zurück zu dem Knoten, von dem aus v entdeckt wurde, um dort wieder unentdeckte Knoten zu finden. Dieser Prozess läuft solange, bis alle Knoten entdeckt wurden, die vom Startknoten aus erreichbar sind. Sollte es dann noch unentdeckte Knoten geben, wird einer von diesen als neuer Startknoten ausgewählt und die Suche von dort neu gestartet. Dies passiert so oft, bis alle Knoten gefunden wurden. Entsprechend zur Breitensuche wird, wenn ein Knoten v während der Suche in der Adjazenzliste eines bereits entdeckten Knotens u gefunden wird, dieses Ereignis notiert. Dazu wird der Vorgänger von v in π gespeichert: π[v] = u. Knoten werden wieder entsprechend ihres Status eingefärbt. Anfangs sind alle knoten weiß, sie werden grau, wenn sie entdeckt werden und schwarz, wenn die Suche abgeschlossen ist (d.h. die Adjazenzliste des Knotens abgearbeitet wurde). Außerdem werden alle Knoten während der Suche mit Zeitstempeln (timestamps) versehen. Jeder Knoten v hat zwei solche Zeitstempel: der erste d[v] notiert, wann v erstmals entdeckt (und grau eingefärbt) wurde, der zweite f [v], wann v abgearbeitet (und schwarz eingefärbt) wurde. Diese Zeitstempel werden in vielen Graphalgorithmen verwendet. Der folgende Pseudocode DFS zeigt den grundlegenden Algorithmus zur Tiefensuche. Der Eingabegraph G kann gerichtet oder ungerichtet sein, die globale Variable time wird zum Zeitstempeln genutzt. Abbildung 8.5 illustriert den Prozess der Tiefensuche an einem Beispiel. 123 8 Graphen Depth-first Search u v Depth-first Search w 1/ Depth-first Search Depth-first Search u v 1/ 2/ Depth-first Search x y z x y (a) u v 1/ 2/ x w u v 1/ 2/ Depth-first Search 4/ 3/ z y w u v 1/ 2/ y z u v 1/ 1/8 2/7 2/ F y z w (i) B z 3/6 3/ x y z (h) u v w u v w 2/7 2/ 9/ 1/ 1/8 2/7 2/ 9/ z y (j) B Depth-first Search 4/5 4/ 3/6 3/ x C F B Depth-first Search 4/5 4/ y w 1/ 1/8 F 3/6 3/ x v 2/7 2/ (g) B Depth-first Search 4/5 4/ 3/6 3/ u 1/ Depth-first Search 4/5 4/ y (f) B w 3/6 3/ x z (d) B z w x (c) y v w y Depth-first Search 4/5 4/ 2/7 2/ v 2/ 3/ x 3/ u u 1/ z B x w Depth-first Search Depth-first Search 4/5 4/ 1/ x v 2/ 3/ (e) Depth-first Search 4/5 4/ u 1/ (b) B Depth-first Search 4/ F w Depth-first Search z 3/6 3/ x y (k) z (l) u v w u v w u v w u v w 1/ 1/8 2/7 2/ 9/ 1/ 1/8 2/7 2/ 9/ 1/ 1/8 2/7 2/ 9/ 1/ 1/8 2/7 2/ 9/12 9/ C F C F B 4/5 4/ 3/6 3/ 10/ 4/5 4/ 3/6 3/ 10/ x y z x y z (m) C F B (n) B 4/5 4/ 3/6 3/ 10/11 10/ x y z (o) C F B B B 4/5 4/ 3/6 3/ 10/11 10/ x y z B (p) A BBILDUNG 8.5: Tiefensuche auf einem gerichteten Graphen. Die Kanten, die gerade von dem Algorithmus verarbeitet werden, sind entweder schattiert (Kanten des Suchsbaumes) oder gestrichelt dargestellt. Kanten, die nicht Teil des Suchbaumes sind, sind mit B,C oder F bezeichnet, je nachdem ob sie back, cross oder forward Kanten sind. Die Knoten sind mit den Zeitpunkten ihrer Entdeckung und Abarbeitung (Endzeit) benannt. (Nach [3], S. 542) DFS(G) 1 for each vertex u ∈ V [G] 2 do color[u] ← W HIT E 3 π[u] ← N IL 4 time ← 0 5 for each vertex u ∈ V [G] 6 do if color[u] = W HIT E 7 then DFS-VISIT(u) 124 8.7 Topologisches Sortieren DFS-VISIT(u) 1 color[u] ← GRAY White vertex u has just been discovered. 2 time ← time + 1 3 d[u] ← time 4 for each v ∈ Adj[u] Explore edge (u, v). 5 do if color[v] = W HIT E 6 then π[v] ← u 7 DFS-VISIT(v) 8 color[u] ← BLACK Blacken u; it is finished. 9 f [u] ← time ← time + 1 8.7 Topologisches Sortieren Als nächstes wollen wir und ein Beispiel ansehen, wie die Tiefensuche verwendet werden kann, um topologisches Sortieren auf einem gerichteten Graphen durchzuführen. Eine topologisches Sortierung eines gerichteten Graphen ist eine lineare Anordnung aller seiner Knoten, so dass für jede Kante (u, v), der Knoten u vor v einsortiert wird. (Dazu muss der Graph azyklich sein, sonst ist eine lineare Anordnung nicht möglich.) Man kann diese Sortierung als Sortierung entlang einer horizontalen Linie ansehen, so dass alle gerichteten Kanten immer von links nach rechts zeigen. Gerichtete azyklische Graphen werden häufig verwendet, um Prioritäten zwischen Ereignissen zu beschreiben. Abbildung 8.6 zeigt ein Beispiel: Wenn Harry Hacker sich morgens anzieht, muss er bestimmte Kleidungsstücke vor anderen anlegen (z.B. die Socken vor den Schuhen). Andere sind unabhängig voneinander, wie z.B. Socken und Gürtel. Eine gerichtete Kante (u, v) gibt dann an, dass Kleidungsstück u vor v angelegt werden muss (Abb.8.6a). Mithilfe einer topologische Sortierung kann Harry Hacker sich einen Plan erstellen, wie er sich ankleiden muss (Abb. 8.6b). Der folgende einfache Algorithmus sortiert den Graphen G topologisch: TOPOLOGICAL-SORT(G) 1 call DFS(G) to compute finishing times f [v] for each vertex v 2 as each vertex is finished, insert it into the front of a linked list 3 return the linked list of vertices 125 8 Graphen 11/16 socks undershorts 17/18 watch pants 12/15 shoes shirt 1/8 tie 2/5 9/10 13/14 (a) 6/7 belt jacket (b) 3/4 socks undershorts pants shoes watch shirt belt tie jacket 17/18 11/16 12/15 13/14 9/10 1/8 6/7 2/5 3/4 A BBILDUNG 8.6: (a) Wenn Harry Hacker sich morgens anzieht, sortiert er seine Kleider zunächst topologisch. Jede gerichtete Kante (u, v) bedeutet, dass Kleidungsstück u vor v angelegt werden muss. Die Entdeckungs- und Endzeiten einer Tiefensuche sind neben den Knoten dargestellt. (b) zeigt denselben Graphen wie (a), diesmal topologisch sortiert.Die Knoten sind von links nach rechts anhand der fallenden Endzeiten sortiert. Beachte, dass alle gerichteten Kanten von links nach rechts verlaufen. (Nach [3], S. 550) 126 9 Hashing Dieses Kapitel beschäftigt sich mit einem wichtigen Speicherungsund Suchverfahren, bei dem die Adressen von Daten aus zugehörigen Schlüsseln errechnet werden, dem Hashing. Dabei stehen die Algorithmen und verschiedene Heuristiken der Kollisionsbehandlung im Vordergrund. Es wird aber auch auf die in Java vordefinierte Klasse Hashtable eingegangen. 9.1 Einführendes Beispiel Ein Pizza-Lieferservice in Bielefeld speichert die Daten seiner Kunden: Name, Vorname, Adresse und Telefonnummer. Wenn ein Kunde seine Bestellung telefonisch aufgibt, um dann mit der Pizza beliefert zu werden, dann muss er seine 127 9 Hashing Telefonnummer angeben, da er über diese Nummer eindeutig identifiziert werden kann. Natürlich existiert in Bielefeld jede Telefonnummer nur einmal, während es mehrere Menschen mit gleichem Vornamen, Nachnamen oder Adresse gibt. Das bedeutet: Wenn der Telefonist in der Pizzeria die Telefonnummer des Kunden erfragt (oder von dem Display seines Telefons abliest) und diese in seinen Computer eingibt, dann bekommt er genau einen Kunden mit Name, Vorname und Adresse angezeigt, vorausgesetzt, der Kunde wurde schon einmal in die Datenbank eingetragen. Die Telefonnummer ist also eine Art Schlüssel für die Suche nach einem Datensatz, in diesem Fall die Kundendaten. Abstrakter bedeutet dies, dass wir über einen Schlüssel (key) Zugriff auf einen Wert (value) erhalten. Stellen wir uns die Repräsentation der Daten in dem Programm, das die Pizzeria benutzt, so vor: Telefonnummer Name 00000000 Müller 00000001 Schmidt 00000002 Schultz ... ... 99999997 Meier 99999998 Neumann 99999999 Schröder Vorname Heinz Werner Hans ... Franz Herbert Georg PLZ Straße 33615 Unistraße 15 33615 Grünweg 1 33602 Arndtstraße 12 ... ... 33609 Kirchweg 4 33612 Jägerallee 15 33647 Mühlweg 2 Die Daten werden also in einer Tabelle gespeichert. Dabei entsprechen die Zeilen den Telefonnummern, die es in Bielefeld möglicherweise geben könnte, nämlich den achtstelligen Zahlen von 00000000 bis 99999999 – wobei natürlich einige Zahlen, wie etwa die 00000000 keine gültigen Telefonnummern sind und wir vereinfachend annehmen wollen, dass Telefonnummern, die weniger als acht Stellen haben, mit führenden Nullen aufgefüllt werden können. Bis hierher würde die Datentabelle also so aussehen wie in der obigen Abbildung mit 108 – also 100 Millionen – verschiedene Nummern, die jeweils einer Zeile in der Tabelle entsprechen. Hierbei fällt schon auf, dass diese Zahl natürlich viel zu groß ist, denn es gibt sicherlich nicht annähernd so viele Telefonanschlüsse in Bielefeld. Die Menge der möglichen Schlüssel, also die Zahlen 00000000 bis 99999999 ist gegenüber der Zahl der tatsächlich informativen Einträge viel zu groß, so dass eine große Menge Speicher für Telefonnummern verbraucht wird, die nie zugeordnet werden. Weiterhin ist zu bedenken, dass nicht jeder Einwohner Bielefelds eine Telefonnummer hat und dass nicht alle, die eine Nummer haben, beim Pizza-Service bestellen, und wenn sie doch eine Pizza bestellen, nicht unbedingt bei dieser Pizzeria. Gehen wir also davon aus, dass von der Menge aller Schlüssel nur ein kleiner Teil wirklichen Datenbankeinträgen entspricht. Bielefeld hat ca. 300.000 Einwoh- 128 9.2 Allgemeine Definitionen ner, dann gibt es vielleicht 200.000 Telefonnummern. Davon bestellt jeder fünfte eine Pizza – bleiben 40.000 potentielle Einträge, verteilt auf mehrere PizzaLieferservices. Optimistisch geschätzt wird unsere Pizzeria also ca. 10.000 Kunden haben. Damit bleiben von den 100 Millionen Schlüsseln nur 10.000 tatsächlich benutzte übrig, das sind 0,01 Prozent. Dies bedeutet für die Tabelle der Kundendaten, dass sie erheblich verkleinert werden kann – nämlich auf 10.000 Zeilen, denn mit mehr Kunden braucht die Pizzeria nicht zu rechnen. Da stellt sich folgende Frage: Wir wissen doch gar nicht, welche Telefonnummern bestellen werden – wie sollen denn dann die Zeilen benannt werden? Unsere Aufgabe ist es, alle 100 Millionen Telefonnummern (denn jede einzelne könnte ja theoretisch bestellen) so abzubilden, dass sie in eine 10.000 Zeilen große Tabelle passen. Hierzu machen wir uns jetzt eine mathematische Operation zunutze, die Modulo-Operation: x mod y liefert als Ergebnis den Rest der ganzzahligen Division x/y. Beispielsweise ergibt 117 mod 20 = 17, da 117 = 5 · 20 + 17. Wenn wir jetzt jede angegebene Telefonnummer modulo 10.000 nehmen, bekommen wir ein Ergebnis zwischen 0 und 9999. Somit können wir alle theoretischen Telefonnummern und damit Pizza-Besteller in einer Tabelle abbilden, die gerade mal 10.000 Zeilen hat. Die Funktion, die wir dazu benutzen, lautet: h(Telefonnummer) = Telefonnummer mod Tabellenlänge, oder allgemein: h(k) = k mod m mit h für Hashfunktion, k für key und m für Tabellenlänge. Wir benutzen also diese Hashfunktion, um jedem Schlüssel einen Index (hier eine Zahl zwischen 0 und 9999) in einer verkleinerten Tabelle (der sogenannten Hashtabelle) zuzuordnen und damit eine Menge Platz zu sparen. Leider kann es allerdings passieren, dass in ungünstigen Fällen zwei oder mehr Schlüssel (Telefonnummern) auf denselben Index in der Hashtabelle abgebildet werden, z.B. ist 01063852 mod 10000 = 08153852 mod 10000 = 3852. Wie solche Kollisionen behandelt werden können, wird im übernächsten Abschnitt diskutiert. 9.2 Allgemeine Definitionen Formal gesehen ist Hashing ein abstrakter Datentyp, der die Operationen insert, delete und search auf (dynamischen) Mengen effizient unterstützt. Hashing ist im Durchschnitt sehr effizient – unter vernünftigen Bedingungen werden obige Operationen in O(1) Zeit ausgeführt (im worst-case kann search O(n) 129 9 Hashing q q q q q q q q q q q q q q q q q q U q q q q (Universum der Schlüssel) q 9 v 0 v q v q q ppppppppp pppp pp p p p p q p pp pppp q 7 v p pp ppq p v p p p 4 p q q pppppppppp p p p p pp pp pppp v pppppppp p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p ppp p p p p p p p p p p p p p q p p p p p p p p p p p p p p p p p p p p p p p p p p p p p q p p p p p p p p p p p p p p p p p p p p p p p p pp p p p p pppp pp p p pppppppp p p p ppp p p p p q pppp pppp q p p p p p p pp p p p pppp p p p p p 1 p p p p p p p p p p p p p p p p p ppp p pppppp q p q ppp K pp pp ppp p pp p pppp ppppppp q pppp pp p p p p p q p p p p ppp p pp (Aktuelle Schlüssel) p p pp ppp q ppppppppppp q ppppp ppp ppp pppp pp p p p p p p p pppppppp p p p p p p p pp pp p p p p pppppppppppp pppppppppppp qp pppppp v q ppp ppp p p p p p p p pp p v p p p p p p p p q 2 3pppppppp pppppppppppp pppppppppppp ppp ppp q ppp p q p p p p p p p p p p p q p p p p p p p ppp pp pppppppppppp q pp p pp p p q pppppppppppppppp v p p p p ppp pp 5 p q p p pp p p p q p ppppp pp p p p q ppppp q v p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p ppp p p p p q p p p ppp p p p p p p p p p p q p p p p p p p p p p p p ppppppppppppppppqpppppppppppp p p ppp ppp p 8 p p p p p p p p p p p p p p p p p p p p p p p p p pp q p ppp p p pp p p pppp p p p q p p p p p q p p pp p p p q p p p p q pp p p p p p p q p p p p p p p p p p p p p p p p p p p p p p p p p pp p p q p p p p p p ppp q q q q q q q q q q q q q q q q q q q 6 ... ............. ............. ............. ..................................... .... ................. ............. ............. ............. .... 0 .... ............. ............. ............. ................................... ..... ................. ............. ............. ............. .... 1 2 q 3 .... ............. ............. ............. ..................................... . . ............. .......................... . . . . . . . . . . . . ....... .... 4 5 .... ............. ............. ............. ............................... ............. .......................... . . . . . . . . . . . . .......... ....... 6 .... ............. ............. ............. .............................. ............ ......................... . . . . . . . . . . . . ............ ......... 7 8 ............. ..... ............. ............. .................................. ..... ................. ............. ............. ............. .... 9 T A BBILDUNG 9.1: Direkte Adressierung Zeit benötigen). In unserer Darstellung folgen wir Cormen et al. [2]. Zur Vereinfachung nehmen wir an, dass die Daten keine weiteren Komponenten enthalten (d.h. mit den Schlüsseln übereinstimmen). Wenn die Menge U aller Schlüssel relativ klein ist, können wir sie injektiv auf ein Feld abbilden; dies nennt man direkte Adressierung (Abbildung 9.1). Ist die Menge U aller Schlüssel aber sehr groß (wie im obigen Beispiel des PizzaServices), so können wir nicht mehr direkt adressieren. Unter der Voraussetzung, dass die Menge K aller Schlüssel, die tatsächlich gespeichert werden, relativ klein ist gegenüber U, kann man die Schüssel effizient in einer Hashtabelle abspeichern. Dazu verwendet man allgemein eine Hashfunktion h : U → {0, 1, . . . , m − 1}, die Schlüssel abbildet auf Werte zwischen 0 und m − 1 (dabei ist m die Größe der Hashtabelle). Dies illustriert Abbildung 9.2. Beispiel 9.2.1 Eine typische Hashfunktion h für U = N ist h(k) = k mod m. 130 9.3 Strategien zur Behandlung von Kollisionen .... ............. ............. ............. ..................................... ... ................ ............. . . . . . . . . . . . . . . . . . . . . . . . . ...... .. q q q q q q q q q q q q q q q q U .... ............. ............. ............. .................................... .. .............. ............. ............. . . . . . . . . . . . . ....... ... q p p pp p p ppppp p pp p p p p p p p (Universum der Schlüssel) q ppppp q q pp pppppp pppppp p p p p p q p ppp ppppp q q q pppppppppp p p pp p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p ppppp pppp p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p q p p p p p p p p p p p p p p p p p q p p p p pp p p p p p pp p p p p p p p p p p p p pppp pppppp p p p p p p p p p p p p p pp p pp p p p pp p p q pppppp ppp q pppppp pp p ppppppp p p v p p p p p p p p p p p p p k1 q q K p ppp ppp pppppppp pp pp pppppp pppp ppp p p q p p p q p p ppp ppp (Aktuelle Schlüssel) p p p p p p p p p p pppppp q p p p p q ppppppppppppppp p p p p ppp p p p ppppp ppppppppp pppppp pppppppppppppppp p p p p p p p p p p p p p p p p p q p p p p p p pp p p p q p pppp k4 v pppqppppppp ppppppp pppppppp pppp q pppppppppp q pppppppppp ppppp pppp p p p p p p p p p p p p ppp p p p p p p p p p p p p ppppppppp q ppp pp ppppppppppp pppppppppppp ppp q p p p pp ppppppppp pppp p p p ppp pppppppppppp p p p p p p p q p v p pp p p p p pp p k q ppppppp p p p 2 p p p p p p p p p p v p p p pp p p q q ppppppppppppppppppp p p p p pp ppppp k5 q p ppp pp ppppppppppppppppppppppppppqppppppppp ppppp ppppp p p p p ppp p ppp p q ppppppppppppppppppppppppp p p ppp q p ppppppppppppppppppppppppp p ppp p p p p p q p p v p p pp q pp p p q p k p pp p p 3 q pp p pp p p p p q q p pp ppp p p p p p p p p q p p p p p p p p p p p p p p p p p p p p p p p p p pp q q q q q q q q q q q q q q q q q q q q 0 h(k1 ) q h(k4 ) .... ............. ............. ............. .............................. ............ ......................... . . . . . . . . . . . . ............ ......... h(k2 ) = h(k5 ) .... ............. ............. ............. .................................... .... ................. ............. ............. ............. .... h(k3 ) ... ............. ............. ............. .................................... ..... ................. ............. ............. ............. .... .... ............. ............. ............. ................................... ..... ................. ............. ............. ............. .... m−1 T A BBILDUNG 9.2: Eine Hashfunktion h weist Schlüsseln ihren Platz in der Hashtabelle T zu. Dabei sollte m eine Primzahl sein, die nicht (zu) nahe an Potenzen von 2 liegt. Der Grund dafür wird in Cormen et al. [2], S. 228, erläutert. Da Hashfunktionen nicht injektiv sind, tritt das Problem der Kollision auf: zwei Schlüsseln wird der gleiche Platz in der Hashtabelle zugewiesen. Kollisionen sind natürlich unvermeidbar, jedoch wird eine “gute” Hashfunktion h die Anzahl der Kollisionen gering halten. D.h. h muss die Schlüssel gleichmäßig auf die Hashtabelle verteilen. Außerdem sollte h einfach zu berechnen sein. In Cormen et al. [2] findet man weitere Erläuterungen zum Thema Hashfunktionen. 9.3 Strategien zur Behandlung von Kollisionen 9.3.1 Direkte Verkettung Man kann Kollisionen auflösen, indem jeder Tabellenplatz einen Zeiger auf eine verkettete Liste enthält. Schlüssel mit demselben Hashwert werden in dieselbe Liste eingetragen (Abbildung 9.3). 131 9 Hashing qqqqqqqqqqqqqqqqqqqqqqq qqqq ppp ppppppppppppppppppppppp qqqqqqqqqqqqqq qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq qqqqqqqqqqqqqqqqqqqqq q q q q q qq ppp qpqpqpqpqppppppp p p p p p p p p p p qq qq p pppppppppppp qqq p p p p p p p p p p p p p p p p qqq p qqq p pppppppppppppp p p p p p p p v p qq k4 pv ppppppppppppppppppppppppppppqqqqqppppp qqq pppppppppppppp pppppppppp k2 q pp ppqqqppppppppppppppppppp ppppppp p p p p p p p p p p p p p p p p p p p p p p p p p p p p p p v pppppppppppppppp qqqq k5 q qq q q q q q q q q q q q q q q qq qqqq qqqqqqq qqqq qqqqqqq q q q q q qqqqqq qqqqq qqqqqqq qqqqqqqqqqqqqqqqqqqqqqqqq u .......... ....... k4 .......... ....... k2 .... ............. ............. ............. ................................... ..... ................. ............. ............. ............. .... u .......... ....... k5 .... ............. ............. ............. ................................... .... ................ ............. . . . . . . . . . . . . . . . . . . . . . . . . ...... .. A BBILDUNG 9.3: Direkte Verkettung Damit ergeben sich folgende worst-case Laufzeiten: insert: O(1) (neues Element vor die Liste hängen) search: O(n) (n = Länge der Liste) delete: O(1) (vorausgesetzt, man verwendet doppelt verkettete Listen und hat das Element schon gefunden) 9.3.2 Open Hashing Beim Open Hashing werden alle Einträge in der Hashtabelle gehalten. Ist eine Komponente der Tabelle schon belegt, so wird ein freier Platz für einen weiteren Eintrag gesucht. qqqqqqqqqqqqqqqqqqqq qqqqq ppppppp qqqqqqqqqqqqqq qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq qqqqqqqqqqqqqqqqqqqqqqq q q q q q q q qpqpqppppppppppppppppppp ppppppppp ppp qqq p p p q p p p p qq qq p pp qqq pppppp pppppppppppp q qqq ppppppppppppppppppp p p qq p p p p p p p p p qqq p p p p p p p k4 v q pppppppppppppppppppppppppppppqqqqqpppp v qqq pppppppppppppppppppppppppp k2 q pppp pp p p p qqq p p pppppppp ppppppppp p p p p p p q v p p qq pp ppppp k5 qq ppppppp pppp p q q p p p p p p p p p p p p pp qqqqqqqqqqqqqqq qqqqqqq qqqq q q q q q q qqqq qqqqqq qqqqqq qqqqq qqqqqqq q qqqqqqqqqqqqqqqqqqqqqqq .... ............. ............. ............. ................................ ............ ......................... . . . . . . . . . . . . ........... ....... A BBILDUNG 9.4: Open Hashing mit linearer Verschiebung Es gibt u.a. folgende Strategien zur Suche eines freien Platzes: 1. Lineare Verschiebung 132 9.3 Strategien zur Behandlung von Kollisionen Falls h(k) bereits durch einen anderen Schlüssel besetzt ist, wird versucht, k in den Adressen h(k) + 1, h(k) + 2, . . . unterzubringen (Abbildung 9.4). Präziser gesagt, wird folgende Hashfunktion verwendet: h0 (k, i) = h(k) + i mod m mit i = 0, 1, 2, . . . , m − 1. Unter der Annahme, dass jeder Tabellenplatz entweder einen Schlüssel oder NIL enthält, wenn der Tabellenplatz leer ist1 , können die Operationen insert und search wie folgt implementiert werden: Hash-Insert(T, k) 1 i←0 2 repeat 3 j ← h0 (k, i) 4 if T [j] = NIL then 5 T [j] ← k 6 return j 7 i←i+1 8 until i = m 9 error “hash table overflow” Hash-Search(T, k) 1 i←0 2 repeat 3 j ← h0 (k, i) 4 if T [j] = k then 5 return j 6 i←i+1 7 until T [j] = NIL or i = m 8 error NIL 2. Quadratische Verschiebung Es wird die Hashfunktion h0 (k, i) = (h(k) + c1 i + c2 i2 ) mod m mit i = 0, 1, 2, . . . , m − 1 verwendet. Dabei sind c1 , c2 ∈ N und c2 6= 0 geeignete Konstanten (s. Cormen et al. [2]). 3. Double Hashing Die Hashfunktion h0 wird definiert durch h0 (k, i) = (h1 (k) + i · h2 (k)) mod m mit i = 0, 1, 2, . . . , m − 1, 1 Wenn die Schlüssel natürliche Zahlen sind, dann können wir z.B. den Wert −1 zur Markierung eines leeren Tabellenplatzes benutzen. 133 9 Hashing wobei h1 und h2 Hashfunktionen sind. Die Verschiebung wird dabei durch eine zweite Hashfunktion realisiert. D.h. es wird zweimal, also doppelt, gehasht. Beispiel 9.3.1 Wir illustrieren Open Hashing mit linearer Verschiebung an einem Beispiel. Unsere Hashtabelle hat nur fünf Plätze und die Hashfunktion sei h(k) = k mod 5. Die Werte 0 und 5 seien bereits eingetragen. 0 1 2 0 1 0 insert 11 1 - 2 0 1 11 0 insert 10 1 - 2 3 3 3 4 4 4 0 1 11 10 0 delete 1 - 1 2 3 0 d 11 10 search 10 - 4 Man erkennt: Gelöschte Felder müssen markiert werden, so dass ein Suchalgorithmus nicht abbricht, obwohl das Element doch in der Liste gewesen wäre. Natürlich kann in die gelöschten Felder wieder etwas eingefügt werden. Dieses Problem muss in der obigen Implementierung zusätzlich berücksichtigt werden. n , wobei n die Anzahl Der Ladefaktor α für eine Hashtabelle T ist definiert als m der gespeicherten Schlüssel und m die Kapazität der Tabelle sind. Theoretische Untersuchungen und praktische Messungen haben ergeben, dass der Ladefaktor einer Hashtabelle den Wert 0.8 nicht überschreiten sollte (d.h. die Hashtabelle darf höchstens zu 80% gefüllt werden). Ist der Ladefaktor ≤ 0.8, so treten beim Suchen im Durchschnitt ≤ 3 Kollisionen auf. Bei einem höheren Ladefaktor steigt die Zahl der Kollisionen rasch an. 9.4 Die Klasse Hashtable in Java Die Klasse java.util.Hashtable implementiert alle Methoden der abstrakten Klasse java.util.Dictionary (vgl. Aufgabe 6.4.4). Außerdem enthält Hashtable noch folgende Methoden2 : public synchronized boolean containsKey(Object key) Es wird true zurückgegeben gdw. die Hashtabelle ein Element unter key verzeichnet hat. public synchronized boolean contains(Object element) Gdw. das angegebene element ein Element der Hashtabelle ist, wird true 2 Das Voranstellen des Schlüsselworts synchronized bewirkt, dass eine Methode nicht in mehreren nebenläufigen Prozessen (threads) gleichzeitig mehrfach ausgeführt wird. Ansonsten könnte es zu Inkonsistenzen in der Hashtabelle kommen. 134 9.4 Die Klasse Hashtable in Java zurückgegeben. Diese Operation ist teurer als die containsKey-Methode, da Hashtabellen nur beim Suchen nach Schlüsseln effizient sind. public synchronized void clear() Alle Schlüssel in der Hashtabelle werden gelöscht. Wenn es keine Referenzen mehr auf die Elemente gibt, werden sie vom Garbage-Collector aus dem Speicher entfernt. public synchronized Object clone() Es wird ein Klon der Hashtabelle erzeugt. Die Elemente und Schlüssel selbst werden aber nicht geklont. Ein Hashtabellenobjekt wächst automatisch, wenn es zu stark belegt wird. Es ist zu stark belegt, wenn der Ladefaktor der Tabelle überschritten wird. Wenn eine Hashtabelle wächst, wählt sie eine neue Kapazität, die ungefähr das doppelte der aktuellen beträgt. Das Wählen einer Primzahl als Kapazität ist wesentlich für die Leistung, daher wird das Hashtabellenobjekt eine Kapazitätsangabe auf eine nahegelegene Primzahl anpassen. Die anfängliche Kapazität und der Ladefaktor kann von den Konstruktoren der Klasse Hashtable gesetzt werden: public Hashtable() Es wird eine neue, leere Hashtabelle mit einer voreingestellten Anfangskapazität von 11 und einem Ladefaktor von 0.75 erzeugt. public Hashtable(int initialCapacity) Eine neue, leere Hashtabelle mit der Anfangskapazität initialCapacity und dem Ladefaktor 0.75 wird generiert. public Hashtable(int initialCapacity, float loadFactor) Es wird eine neue, leere Hastabelle erzeugt, die eine Anfangskapazität der Größe initialCapacity und einen Ladefaktor von loadFactor besitzt. Der Ladefaktor ist eine Zahl zwischen 0.0 und 1.0 und definiert den Beginn eines rehashings der Tabelle in eine größere. Beispiel 9.4.1 Um die Benutzung der Klasse Hashtable als Wörterbuch (Dictionary) zu demonstrieren, entwerfen wir zunächst eine Klasse Pair zur Speicherung von Name-Wert Paaren. Namen sind vom Typ String. Werte können beliebigen Typ haben, deshalb wird der Wert in einer Variable vom Typ Object abgelegt. class Pair { private String name; private Object value; public Pair(String name, Object value) { 135 9 Hashing this.name = name; this.value = value; } public String name() { return name; } public Object value() { return value; } public Object value(Object newValue) { Object oldValue = value; value = newValue; return oldValue; } } Der Name ist nur lesbar, denn er soll als Schlüssel in einer Hashtabelle benutzt werden. Könnte das Datenfeld für den Namen von außerhalb der Klasse modifiziert werden, so könnte der zugehörige Wert verlorengehen: Er würde immer noch unter dem alten Namen eingeordnet sein und nicht unter dem modifizierten Namen. Der Wert hingegen kann jederzeit verändert werden. Folgende Schnittstelle deklariert drei Methoden: eine, um einem Dic-Objekt ein neues Paar hinzuzufügen; eine, um herauszufinden, ob in einem Dic-Objekt bereits ein Paar mit gegebenem Namen enthalten ist; und eine, um ein Paar aus einem Dic-Objekt zu löschen. interface Dic { void add(Pair newPair); Pair find(String pairName); Pair delete(String pairName); } Hier nun das Beispiel einer einfachen Implementierung von Dic, die die Hilfsklasse java.util.Hashtable verwendet: import java.util.Hashtable; class DicImpl implements Dic { protected Hashtable pairTable = new Hashtable(); 136 9.5 Aufgaben public void add(Pair newPair) { pairTable.put(newPair.name(), newPair); } public Pair find(String name) { return (Pair) pairTable.get(name); } public Pair delete(String name) { return (Pair) pairTable.remove(name); } } Der Initialisierer für pairTable erzeugt ein Hashtable-Objekt, um Paare zu speichern. Die Klasse Hashtable erledigt die meiste anfallende Arbeit. Sie verwendet die Methode hashCode des Objekts, um jedes ihr als Schlüssel übergebene Objekt einzuordnen. Wir brauchen keine explizite Hashfunktion bereitzustellen, denn String enthält schon eine geeignete hashCode-Implementierung. Kommt ein neues Paar hinzu, wird das Pair-Objekt in einer Hashtabelle unter seinem Namen gespeichert. Wir können dann einfach die Hashtabelle benutzen, um Paare über deren Namen zu finden und zu entfernen. 9.5 Aufgaben Aufgabe 9.5.1 Implementieren Sie in Java das Verfahren zum Open-Hashing. Beschränken Sie sich dabei auf eine Methode hashinsert zum Einfügen in die Hashtabelle. Verwenden Sie die primäre Hashfunktion h(k) = k mod m und die folgenden drei Verfahren zur Kollisionsbehandlung: (1) lineare Verschiebung; (2) quadratische Verschiebung mit c1 = 1 und c2 = 3; (3) double hashing mit h1 (k) = k mod m und h2 (k) = 1 + (k mod (m − 1)). Verwenden Sie nun Ihre Implementierung, um die Schlüssel 10, 22, 31, 4, 15, 28, 17, 88, 59 in eine Hashtabelle der Größe m = 11 einzutragen. Geben Sie die Hashtabelle nach dem Einfügen jedes Schlüssels an. Aufgabe 9.5.2 Sei m die Größe einer Hashtabelle. Wir definieren die Hashfunktion hstring, die für einen String s einen ganzzahligen Wert hstring(m, s) liefert, wie folgt: hstring(m, s) = hstring0 (0, s) mod m. 137 9 Hashing Dabei ist hstring0 in Haskell-ähnlicher Notation wie folgt definiert: und hstring(i, []) = i hstring0 (i, a : w) = hstring0 (i · 31 + ord(a), w) wobei ord eine Abbildung ist, die für ein Zeichen aus dem ASCII-Alphabet eine eindeutige ganze Zahl zwischen 0 und 255 liefert, nämlich ihren ASCII-Wert. 1. Implementieren Sie die Hashfunktion hstring in Java. hstring0 sollte dabei iterativ implementiert werden. Die Summen zur Berechnung des Hashwertes wachsen sehr schnell. Verwenden Sie daher long-Werte zur Summation, um einen Überlauf zu vermeiden. 2. Erstellen Sie sich eine Datei strings mit 1000 ASCII-Zufallssequenzen der Länge 8. Wenden Sie hstring auf jede dieser Sequenzen an. Wählen Sie dabei nacheinander in verschiedenen Programmläufen m ∈ {67, 101, 113, 197}. 3. Eine gute Hashfunktion wird die 1000 Strings gleichmäßig auf die m Einträge in der Hashtabelle abbilden, d.h. im Idealfall werden etwa 1000/m Strings auf jeden Eintrag abgebildet. Bestimmen Sie die Güte der Hashfunktion hstring, indem Sie m−1 1 X |1000/m − occ(i)| g(m, hstring) = m i=1 berechnen. Dabei ist occ(i) die Anzahl der Strings w mit hstring(m, w) = i. Welcher Wert ergibt sich für die Güte g(m, hstring) für m ∈ {67, 101, 113, 197}? 138 10 Ein- und Ausgabe In den bisherigen Programmbeispielen wurden die Benutzereingaben immer über die Kommandozeile und Ausgaben immer durch Aufruf der Methode System.out.println realisiert. Tatsächlich sind die Ein- und Ausgabemöglichkeiten von Java weit mächtiger. Dieses Kapitel behandelt die Grundlagen hiervon, Ströme und einige Beispiele für deren Verwendung. 10.1 Ströme Input und Output (kurz I/O) in Java basieren auf Strömen (engl. streams). Sie sind geordnete Folgen von Daten, die einen Ursprung (input stream) oder ein Ziel (output stream) haben. Vorteil: Der Programmierer wird von den spezifischen Details des zugrundeliegenden Betriebssystems befreit, indem Zugriffe auf Systemressourcen einheitlich mittels Strömen möglich sind. Die Standard-Ströme eines Java-Programms sind in Abbildung 10.1 dargestellt. Standard−Eingabestrom System.in Java− Programm Standard−Ausgabestrom System.out Standard−Fehlerstrom System.err A BBILDUNG 10.1: Standardströme Das Paket java.io definiert abstrakte Klassen für grundlegende Ein- und Ausgabeströme. Diese abstrakten Klassen werden dann erweitert und liefern mehrere nützliche Stromtypen. Derzeit enthält das I/O-Paket 13 Unterklassen, die teilweise wiederum Unterklassen mit weiteren Unterklassen besitzen (Abbildung 10.2). 139 10 Ein- und Ausgabe Object HH H HH j InputStream OutputStream HH @ FileInputStream @ R @ ... ? FilterInputStream @ @ R @ ... DataInputStream ? BufferedInputStream ... HH j H FileOutputStream ? FilterOutputStream H XXX XXX HH XXX HH z X j ... DataOutputStream PrintStream ? BufferedOutputStream A BBILDUNG 10.2: I/O-Klassenhierarchie 10.1.1 Die Klasse InputStream Alle InputStream erweiternden Klassen können Bytes aus verschiedenen Quellen einlesen. Das Einlesen kann byteweise oder in Blöcken von Bytes beliebiger Größe durchgeführt werden. Wir listen hier nur die Methoden von InputStream auf und gehen nicht detailliert auf deren Unterklassen ein. public abstract int read() throws IOException; Es wird das nächste Byte aus dem Strom gelesen und (als Integerzahl im Bereich von 0 bis 255) zurückgegeben. Bei Erreichen des Stromendes wird −1 zurückgegeben. public int read(byte b[]) throws IOException; Es werden b.length Bytes aus dem Strom gelesen und in dem Feld b abgelegt. Der Rückgabewert ist die tatsächliche Anzahl der gelesenen Bytes oder bei Erreichen des Stromendes −1. public int read(byte b[], int off, int len) throws IOException; Es werden len Bytes gelesen und ab der Position off im Feld b abgelegt. Die Anzahl der gelesenen Bytes wird wieder zurückgegeben. public long skip(long n) throws IOException; Die nächsten n Bytes werden überlesen. 140 10.1 Ströme public int available() throws IOException; Es wird die Anzahl der Bytes zurückgegeben, die von dem Strom noch gelesen werden können. public void close() throws IOException; Der Strom wird geschlossen und die benutzten Ressourcen sofort wieder freigegeben. public boolean markSupported(); Es wird geprüft, ob der Strom die Methoden mark und reset unterstützt. public synchronized void mark(int readLimit); Die aktuelle Position im Strom wird markiert. public synchronized void reset() throws IOException; Es wird zu der Position zurückgesprungen, die mit mark markiert wurde. Analog zu InputStream und seinen Unterklassen gibt es die Klasse OutputStream und deren Erweiterungen, die zum Schreiben von Zeichen benötigt werden. 10.1.2 File-Ströme Um Dateien einlesen zu können, reicht es, ein FileInputStream-Objekt anzulegen. Beispiel 10.1.1 import java.io.*; class ReadDemo { public static void main(String[] args) throws FileNotFoundException, IOException { FileInputStream fileInputStream = new FileInputStream(args[0]); int ch, count=0; while((ch = fileInputStream.read()) != -1) { count++; } System.out.println("Die Datei enthält "+count+" Bytes."); } } Ausgaben werden durch die Klasse FileOutputStream ermöglicht, z.B. FileOutStream fos = new FileOuputStream("out.txt"); fos.write(’a’); 141 10 Ein- und Ausgabe 10.1.3 Gepufferte Ströme Die Klassen BufferedInputStream und BufferedOutputStream unterstützen Objekte, die ihre Daten puffern. Damit verhindern diese, dass jedes Lesen oder Schreiben sofort an den nächsten Strom weitergegeben wird. Diese Klassen werden oft in Verbindung mit File-Strömen verwendet, denn es ist relativ ineffizient, auf eine Datei zuzugreifen, die auf der Festplatte gespeichert ist. Das Puffern hilft, diesen Aufwand erheblich zu reduzieren. Ein BufferedInputStream oder BufferedOutputStream entspricht in seinen Methoden dem InputStream bzw. OutputStream. Die Konstruktoren nehmen einen entsprechenden Strom als Parameter. Bei einem Methodenaufruf werden die Daten gepuffert und bei einem vollen Puffer mit den entsprechenden Methoden des Stroms geschrieben bzw. gelesen. Manuell lässt sich ein Puffer auch immer mit der Methode public void flush() leeren. Beispiel 10.1.2 import java.io.*; import java.util.*; class WriteDemo { private static float time(OutputStream os, long j) throws IOException { Date start = new Date(); for(int i=0; i<j; i++) { os.write(1); } os.close(); Date end = new Date(); return (float)(end.getTime()-start.getTime()); } public static void main(String[] args) throws IOException { FileOutputStream unbufStream; BufferedOutputStream bufStream; long iterate; iterate = Long.parseLong(args[0]); unbufStream = new FileOutputStream("f.unbuffered"); bufStream = new BufferedOutputStream( new FileOutputStream("f.buffered")); float t1 = time(unbufStream,iterate); 142 10.1 Ströme System.out.println("Write file unbuffered: "+t1+" ms"); float t2 = time(bufStream,iterate); System.out.println("Write file buffered: "+t2+" ms"); System.out.println("Thus, the version with buffered streams is "+ (t1/t2)+" times faster."); } } > java WriteDemo 10000 Write file unbuffered: 102.0 ms Write file buffered: 4.0 ms Thus, the version with buffered streams is 25.5 times faster. > java WriteDemo 100000 Write file unbuffered: 964.0 ms Write file buffered: 14.0 ms Thus, the version with buffered streams is 68.85714 times faster. 10.1.4 Datenströme Man stellt bei häufigem Gebrauch von Strömen schnell fest, dass Byteströme allein kein Format bieten, in das alle Daten eingezwängt werden können. Vor allem die elementaren Datentypen von Java können in den bisher behandelten Strömen weder gelesen noch geschrieben werden. Die Klassen DataInputStream und DataOutputStream definieren Methoden zum Lesen und Schreiben, die komplexe Datenströme unterstützen. FileInputStream fis = new FileInputStream(args[0]); BufferedInputStream bis = new BufferedInputStream(fis); DataInputStream dis = new DataInputStream(bis); oder als ein Befehl DataInputStream dis = new DataInputStream( new BufferedInputStream( new FileInputStream(args[0]))); Beispiel 10.1.3 import java.io.*; class DataIODemo { public static void main(String[] args) throws FileNotFoundException, 143 10 Ein- und Ausgabe IOException { DataOutputStream dataOutputStream = new DataOutputStream(new FileOutputStream(args[0])); double[] doubles = {16.57,15.44,9.99}; for(int i=0; i<doubles.length; i++) { dataOutputStream.writeDouble(doubles[i]); } dataOutputStream.close(); DataInputStream dis = new DataInputStream(new FileInputStream(args[0])); double sum = 0.0; try { while(true) { //Read next double until EOF is reached and an EOFException //is thrown; add the double to sum sum += dis.readDouble(); } } catch(EOFException e) { System.out.println("Sum of doubles: "+sum); } finally { dis.close(); } } } > java DataIODemo test.dat Sum of doubles : 42.0 10.2 Stream Tokenizer Das Zerlegen von Eingabedaten in Token ist ein häufiges Problem. Java stellt eine Klasse StreamTokenizer für solche einfachen lexikalischen Analysen zur Verfügung. Dabei wird gegenwärtig nur mit den untersten 8 Bits von Unicode, den Zeichen aus dem Latin-1 Zeichensatz, gearbeitet, da das interne, die Zeichentypen markierende, Feld nur 256 Elemente hat. 144 10.2 Stream Tokenizer Erkennt nextToken() ein Token, gibt es den Tokentyp als seinen Wert zurück und setzt das Datenfeld ttype auf denselben Wert. Es gibt vier Tokentypen: TT_WORD: Ein Wort wurde eingescannt. Das Datenfeld sval vom Typ String enthält das gefundene Wort. TT_NUMBER: Eine Zahl wurde eingescannt. Das Datenfeld nval vom Typ double enthält den Wert der Zahl. Nur dezimale Gleitkommazahlen (mit oder ohne Dezimalpunkt) werden erkannt. Die Analyse versteht weder 3.4e79 als Gleitkommazahl noch 0xffff als Hexadezimalzahl. TT_EOL: Ein Zeilenende wurde gefunden (nur wenn eolIsSignificant true ist). TT_EOF: Das Eingabeende wurde erreicht. Beispiel 10.2.1 import java.io.*; class TokenDemo { public static void main(String[] args) throws IOException { FileReader fileIn = new FileReader(args[0]); StreamTokenizer st = new StreamTokenizer(fileIn); int int int int words numbers eols others = = = = 0; 0; 0; 0; // // // // total total total total word number. int number. eols. others. st.eolIsSignificant(true); // to treat eols as a character while(st.nextToken() != StreamTokenizer.TT_EOF) { // to read next Token and check EOF if(st.ttype == StreamTokenizer.TT_WORD) words++; // if word then total words++. else if(st.ttype == StreamTokenizer.TT_NUMBER) numbers++; // if number then total numbers++. else if(st.ttype == StreamTokenizer.TT_EOL) eols++; // if eols total eols++. else others++; // else others++. } System.out.println("File "+args[0]+" contains\n\t"+ words+" words,\n\t"+ 145 10 Ein- und Ausgabe numbers+" numbers,\n\t"+ eols+" end-of-lines, and\n\t"+ others+" other characters."); } } > java TokenDemo TokenDemo.java File TokenDemo.java contains 61 words, 6 numbers, 31 end-of-lines, and 88 other characters. 146 11 Graphische Benutzeroberflächen mit Swing Anmerkung: Dieses Kapitel des Skripts soll einen kleinen Einblick in die Programmierung von graphischen Benutzeroberflächen mit Java geben. Es erhebt keinen Anspruch auf Vollständigkeit. Literatur: [9, 10]. 11.1 Historisches Die erste API zur Generierung von grafischen Benutzeroberflächen für Java war das Abstract Window Toolkit (AWT). AWT bietet einen Satz von GUI Elementen, Ereignisbehandlung und einfache Funktionen. Jede grafische Komponente des AWT wird auf eine Komponente der darunter liegenden Plattform abgebildet. Auf Grund der Plattformunabhängigkeit Javas und der Verschiedenheit der unterstützten Plattformen beschränkt sich das AWT auf den kleinsten gemeinsamen Nenner an GUI Elementen, die in allen System vorhanden sind. Da jede AWT Komponente Resourcen vom nativen System bezieht und diese nicht von der Java Virtual Machine (JVM) verwaltet werden, spricht man bei AWT Komponenten von sogenannten ”Schwergewichtigen Komponenten” (heavyweight components). Die später entwickelten und mit JDK 1.1 erstmals als Add-On verfügbaren Java Found Classes (JFC), auch Swing genannt, sind sogenannte ”Leichtgewichtige Komponenten” (lightweight components). Swing Komponenten haben kein direktes Gegenstück im nativen System, sondern werden von Java verwaltet und gezeichnet. Der größte Vorteil von JFC Swing sind die Vielzahl von GUI Komponenten, die mit jedem Java Release anwachsen und einfach zu erweitern sind. (JFC/Swing ist im Gegensatz zu dem AWT vollständig OO implementiert). 11.2 Ein Fenster zur Welt Den Einstieg in jedes ”graphische” Programm ist ein Fenster, der sogenannte Top-Level Container. Ein Fenster kann mit der Swing Klasse JFrame erzeugt werden. 147 11 Graphische Benutzeroberflächen mit Swing Beispiel 11.2.1 01: import javax.swing.JFrame; 02: public class HelloSwingFrame { 03: public static void main(String[] args) { 04: JFrame f = new JFrame("Das Fenster zur Welt!"); 05: f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); 06: f.setSize(300,200); 07: f.setVisible(true); 08: } 09: } A BBILDUNG 11.1: Screenshot: Ein Fenster zur Welt! 11.3 Swing Komponenten Die Klasse JComponent bildet die Basisklasse für alle Swing Komponenten und stellt viele grundlegende Funktionen zur Verfügung, wie z.B. die austauschbare Darstellung (Look&Feel), Tooltips, Rahmen. Die Klassen JWindow (Repräsentation eines Fenster) und JFrame sind von ihren jeweiligen AWT Penedants abgeleitet. Neben den im folgenden vorgestellten Komponenten JButton und JTextField gibt es eine (mit jedem Java Release wachsende) Anzahl von GUI Kompenten. Komponenten, die keine Container sind, also keine weiteren Komponenten enthalten können, heissen atomare Komponenten. 11.3.1 Eine einfache Schaltfläche - JButton Eine Schaltfläche ermöglicht es dem Anwender, eine Aktion auszulösen. Schaltflächen sind in der Regel beschriftet und/oder haben eine Grafik(Icon). Wie 148 11.3 Swing Komponenten A BBILDUNG 11.2: Klassenhierachie der grundlegendesten Swing Komponenten man der Klassenhierachie (siehe Abbildung 11.3) entnehmen kann, basieren alle Schaltflächen von Swing auf der gemeinsamen Oberklasse AbstractButton, welche die grundlegendsten Eigenschaften, die allen Schaltflächen gemein sind, implementiert. A BBILDUNG 11.3: Klassenhierachie: Schaltflächen Beispiel 11.3.1 01:import javax.swing.JButton; 02:import javax.swing.JFrame; 03: 04:public class Beispiel_JButton { 05: 06: public static void main(String [] args){ 07: JFrame f = new JFrame("Das Fenster zur Welt!"); 08: f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); 09: f.add(new JButton("Ich bin ein JButton!")); 10: f.setSize(300,200); 11: f.setVisible(true); 12: } 13:} 149 11 Graphische Benutzeroberflächen mit Swing A BBILDUNG 11.4: Screenshot: Eine einfache Schaltfläche - JButton 11.3.2 Ein Texteingabefeld - JTextField Das Texteingabefeld (JTextField) von JFC/Swing ist die einfachste Möglichkeit kleinere (einzeilige) Mengen Text von Seiten des Benutzers (weiter-)zuverarbeiten. Wenn die Texteingabe abgeschlossen ist (durch Return/Enter) wird ein ActionEvent ausgelöst. Weitere Möglichkeiten der Textverarbeitung sind u.a. Labels (JLabel, für statische, durch den Benutzer unveränderbare Textinformationen) und Textflächen (JTextArea- mehrzeilige Benutzereingaben). A BBILDUNG 11.5: Klassenhierachie: Textkomponenten Beispiel 11.3.2 01:import javax.swing.JFrame; 02:import javax.swing.JTextField; 03: 04:public class Beispiel_JTextField { 05: 06: public static void main(String[] args) { 150 11.4 Container 07: 08: 09: 10: 11: 12: 13: 14:} JFrame f = new JFrame("Das Fenster zur Welt!"); f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); f.add(new JTextField("Ich bin ein JTexfield!",60)); f.setSize(300,200); f.setVisible(true); } A BBILDUNG 11.6: Screenshot: Ein Texteingabefeld - JTextfield 11.4 Container Alle Swing Komponenten müssen in einem Container plaziert werden. Container sind Komponenten, die dazu dienen, andere Komponenten aufzunehmen und zu verwalten. Der wichtigste und bekannteste Container neben dem grundlegenden JFrame ist das JPanel, das die ihm zugeordeneten JComponents nach einem zugewiesenen Layoutverfahren anordnet. Daneben gibt es noch JScrollPane, JTabbedPane, JSplitPane, JToolBar und viele mehr. 11.5 LayoutManager Ein LayoutManager ist dafür zuständig, die Komponenten eines Containers anzuordnen. Je nach Art des verwendeten LayoutManagers ist die Strategie der Anordung verschieden. Im folgenden werden die wichtigsten LayoutManager vorgestellt. Die Methode void setLayout(LayoutManager) weist einem Container einen LayoutManager zu. Ausser den hier kurz vorgestellten LayoutManagern FlowLayout, BorderLayout, GridLayout und BoxLayout gibt es noch die 151 11 Graphische Benutzeroberflächen mit Swing GridBagLayout, CardLayout, SpringLayout und GroupLayout Manager. Komplexe grafische Oberflächen lassen sich i.d.R. durch geschicktes Kombinieren der hier vorgestellten LayoutManager erreichen. 11.5.1 FlowLayout Der FlowLayout Manager ordnet die Komponenten von links nach rechts an, ohne die Grösse der Komponenten anzupassen und ist der StandardLayoutManager eines Containers. Beispiel 11.5.1 01:import javax.swing.JButton; 02:import javax.swing.JFrame; 03:import javax.swing.JPanel; 04: 05:public class Beispiel_FlowLayout extends JPanel{ 06: 07: public Beispiel_FlowLayout(){ 08: for(int i = 1; i <= 5; ++i){ 09: add(new JButton("Button "+(Math.pow(10, i)))); 10: } 11: } 12: 13: public static void main(String[] args) { 14: JFrame f = new JFrame("FlowLayout"); 15: f.add(new Beispiel_FlowLayout()); 16: f.pack(); 17: f.setVisible(true); 18: } 19: 20:} A BBILDUNG 11.7: Screenshot: FlowLayout 152 11.5 LayoutManager 11.5.2 BorderLayout Der BorderLayout Manager ordnet die Komponenten nach den Himmelsrichtungen an (Norden, Osten, Süden, Westen, Mitte). Beispiel 11.5.2 01:import java.awt.BorderLayout; 02:import javax.swing.JButton; 03:import javax.swing.JFrame; 04:import javax.swing.JPanel; 05: 06:public class Beispiel_BorderLayout extends JPanel{ 07: 08: public Beispiel_BorderLayout(){ 09: setLayout(new BorderLayout()); 10: add(new JButton("Norden"),BorderLayout.NORTH); 11: add(new JButton("Westen"),BorderLayout.WEST); 12: add(new JButton("Osten"),BorderLayout.EAST); 13: add(new JButton("S\"uden"),BorderLayout.SOUTH); 14: add(new JButton("Mitte"),BorderLayout.CENTER); 15: } 16: 17: public static void main(String[] args) { 18: JFrame f = new JFrame("BorderLayout"); 19: f.add(new Beispiel_BorderLayout()); 20: f.pack(); 21: f.setVisible(true); 22: } 23:} A BBILDUNG 11.8: Screenshot: BorderLayout 153 11 Graphische Benutzeroberflächen mit Swing 11.5.3 GridLayout Der GridLayout Manager ordnet die Komponenten in einem Raster (Gitter) gegebener Grösse an, die Ausmasse aller Komponenten sind gleich. Beispiel 11.5.3 01:import java.awt.GridLayout; 02:import javax.swing.JButton; 03:import javax.swing.JFrame; 04:import javax.swing.JPanel; 05: 06:public class Beispiel_GridLayout extends JPanel { 07: 08: public Beispiel_GridLayout(){ 09: setLayout(new GridLayout(3,3)); 10: for (int i = 9; i >= 1; --i){ 11: add(new JButton(new Integer(i).toString())); 12: } 13: } 14: 15: public static void main(String[] args) { 16: JFrame f = new JFrame("GridLayout"); 17: f.add(new Beispiel_GridLayout()); 18: f.pack(); 19: f.setVisible(true); 20: } 21:} A BBILDUNG 11.9: Screenshot: GridLayout 154 11.5 LayoutManager 11.5.4 BoxLayout Der BoxLayout Manager ordnet die Komponenten in X bzw. Y Richtung an, die Aussmasse aller Komponenten sind gleich. Beispiel 11.5.4 01:import javax.swing.BoxLayout; 02:import javax.swing.JButton; 03:import javax.swing.JFrame; 04:import javax.swing.JPanel; 05: 06:public class Beispiel_BoxLayout extends JPanel { 07: 08: public Beispiel_BoxLayout(){ 09: this(BoxLayout.X_AXIS); 10: } 11: 12: public Beispiel_BoxLayout(int direction){ 13: setLayout(new BoxLayout(this, direction)); 14: for(int i = 1; i <=5; ++i){ 15: add(new JButton(new Integer(i).toString())); 16: } 17: } 18: 19: public static void main(String[] args) { 20: JFrame f = new JFrame("BoxLayout"); 21: f.add(new Beispiel_BoxLayout(BoxLayout.Y_AXIS)); 22: f.pack(); 23: f.setVisible(true); 24: } 25:} A BBILDUNG 11.10: Screenshot: BoxLayout mit Stil BoxLayout.Y_AXIS 155 11 Graphische Benutzeroberflächen mit Swing 11.6 Ereignisse und deren Behandlung Eine Komponente kann ein Ereignis(Event) auslösen, z.B. alle AbstractButton Klassen ein ActionEvent. Jedes Event wird durch eine Klasse repräsentiert, die das entsprechende ListenerInterface implementiert (z.B. ActionListener um ActionEvents zu verarbeiten). Dadurch ist eine strikte Trennung zwischen der Quelle und der Verarbeitung des Ereignis möglich (und meistens auch sinnvoll, vgl. 11.6.1). 11.6.1 ActionListener Klickt man auf die Schaltfläche des Beispiels (siehe 11.3.1), so sollte dies eine (Re-)Aktion des Programms zur Folge haben. Die Aktion wird in Form eines ActionEvent-Objekts an einen möglichen Zuhörer (in diesem Fall einen ActionListener) gesandt. Ein ActionListener wird mit der Methode addActionListener() an die Komponenten gebunden, die Aktionen auslösen können. Der ActionListener ist eine Schnittstelle mit der Methode actionPerformed(), die von der implementierenden Klasse bereitgestellt werden muss. Die verschiedenen Möglichkeiten der Implementierung sind : als externe Klasse, in der Klasse selbst, als interne Klasse oder als anonyme Klasse. Die erste beiden Möglichkeiten sollen an dieser Stelle kurz vorgestellt werden. Externe Klasse Die beste - leider aber oft auch die aufwendigste - Art einen (Action-)Listener zu implementieren ist als externe Klasse. Der grös̈te Vorteil neben der Übersichtlichkeit besteht in der Wiederverwendbarkeit des Listener für mehr als nur eine auslösende Komponente. Beispiel 11.6.1 01: java.awt.event.ActionEvent; 02: 03: public class MyActionListener implements ActionListener { 04: public void actionPerformed(ActionEvent e) { 05: System.out.println("Der JButton wurde gedr\"uckt!"); 06: } 07: } 01:import javax.swing.JButton; 02:import javax.swing.JFrame; 03: 04:public class Beispiel_JButton { 05: 06: public static void main(String [] args){ 156 11.6 Ereignisse und deren Behandlung 07: 08: 09: 10: 11: 12: 13: 14: 15:} JButton j = new JButton("Ich bin ein JButton!"); j.addActionListener(new MyActionListener); JFrame f = new JFrame("Das Fenster zur Welt!"); f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); f.add(j); f.setSize(300,200); f.setVisible(true); } Interne Klasse Den (Action-)Listener in der Klasse zu implementieren ist eine Methode, die gerade von Einsteigern bevorzugt angewant wird, da man direkt auf die Komponenten der Klasse zugreifen (und diese manipulieren) kann. Nachteil, neben der Übersichtlichkeit, wenn die Klasse mehr als nur ein (paar wenige) Komponenten enthält, ist die schlechte Wiederverwendbarkeit. Beispiel 11.6.2 01:public class MyButton extends JFrame implements ActionListener{ 02: 03: JButton button; 04: 05: public MyButton() { 06: button = new JButton("Button 1"); 07: button.addActionListener(this); 08: add(button); 09: } 10: 11: public void actionPerformed(ActionEvent e) { 12: System.out.println("Der Button wurde gedr\"uckt!"); 13: } 14: 15: public static void main(String [] args) { 16: MyButton mybutton = new MyButton(); 17: mybutton.pack(); 18: mybutton.setVisible(true); 19: } 20:} 11.6.2 Events und Listener Die Tabelle (11.6.2) ist eine (unvollständige) Übersicht über von Java/Swing unterstützten Events, den entsprechenden Listener und den Komponenten, die sie 157 11 Graphische Benutzeroberflächen mit Swing unterstützen. ActionEvent ActionListener addActionListener, removeActionListener ItemEvent ItemListener MouseEvent MouseListener TextEvent TextListener addItemListener, removeItemListener addMouseListener removeMouseListener addTextListener, removeTextListener WindowEvent WindowListener addWindowListener, removeWindowListener AbstractButton und alle Abkömmlinge (JButton, JRadioButton, JCheckboxButton, JMenuItem, . . . ) JCheckBox, JComboBox, JList, . . . Components und Abkömmlinge alle Abkömmlinge von JTextComponent (JTextArea, JTextField . . . ) Window und Abkömmlinge (JFrame, JDialog, JFileDialog) 11.7 Java Applet vs. Java WebStart vs. Java Application Java Programme können auf drei verschiedene Arten und Weise gestartet werden, als Java Applikation, als Java Applet im Browser eingebettet, auf einer HTML Seite, oder moderner als Java Webstart Applikation. A BBILDUNG 11.11: Java Applet vs. Java Webstart vs. Java Application Applikationen (Java Webstart und Java Application) verfügen über eine main() Methode und Applets nicht. Applets erweitern stattdessen die Klasse JApplet bzw. Applet. Programme, die sowohl Application als auch Applet sind, erweitern sowohl die Klasse JApplet und implementieren eine main() Funktion. 158 11.7 Java Applet vs. Java WebStart vs. Java Application 11.7.1 Java (Webstart) Application Java Applikationen werden von der Kommandozeile (oder der GUI) mittels einer Java Laufzeitumgebung (JVM) gestartet. In der Regel unterliegen Java Applikationen keinerlei Einschränkungen hinsichtlich ihres Zugriff auf Systemresourcen. Java Webstart Applikation werden ähnlich wie Applets in einer gesicherten Umgebung(Sandbox) gestart (siehe 11.7.3). 11.7.2 Java Applets Ein Programm wird leicht zu einem Applet, wenn die Klasse JApplet erweitert wird. Applets werden in der Regel in Webseiten eingebettet und vom Browser mittels eines Java Plugins ausgeführt. Java Applets sind eine weitere Möglichkeit, komplexe Programme weitestgehend Plattformunabhängig ohne eine lokale Installation auszuführen. Beispiel 11.7.1 01: 02: 03: 04: 05: 06: 07: 08: import javax.swing.JApplet; import javax.swing.JLabel; public class HelloWorld extends JApplet { public void init(){ add(new JLabel("Hello World!"); } } 11.7.3 Java Sicherheitskonzepte Java-Programme können dynamisch Klassen aus einer Vielzahl von vertrauenswürdigen und nicht vertrauenswürdigen Quellen nachladen. Am problematischsten ist diese Eigenschaft bei Java-Programmen, die direkt über das ”Netz” als Java-Applet oder Java Webstart Applikation gestartet werden. Ohne ein Sicherheitskonzept könnte ein böswilliges Programm Dateien löschen/verändern, persönliche Daten verschicken oder ähnlichen Unfug treiben. Das Java-Sicherheitskonzept sieht vor, dass Java-Programme aus nicht vertrauenswürdigen Quellen in einem vom Rest des System abgeschotteten Bereich gestartet werden, der sogenannten Sandbox. Die Sandbox schränkt zum Beispiel den Zugriff auf das lokale Dateisystem ein bzw. unterbindet diesen vollständig. 159 11 Graphische Benutzeroberflächen mit Swing 160 12 Parallele Ausführung Nebenläufigkeit - Threads Unter nebenläufiger Programmierung1 versteht man die Fähigkeit einer Programmiersprache, Programmteile quasi zeitgleich ausführbar zu machen. Dies wird meist unter dem Konzept der Threads (engl. Fäden) behandelt. Ein Thread wird dabei als eine in sich abgeschlossene Aufgabe definiert, die, bis auf Ein- und Ausgabe, unabhängig von anderen Programmteilen laufen soll. Literatur: [11, 12]. 12.1 Das Threadmodell von Java Threads in Java laufen als eigene Programmteile innerhalb der Virtuellen Maschine (VM) ab und teilen sich dabei die für die VM allokierten Systemresourcen, wie den Arbeitsspeicherbereich und offene Dateien. Auf einigen Betriebssystemen korrespondieren diese User-Threads mit echten Threads des Betriebssystems, wie zum Beispiel im Betriebssystem Solaris. Unterstützt das Betriebssystem von sich aus keine Threads, so ändert sich aus Sicht des Java-Programmierers nichts, da die VM die Threadverwaltung im Hintergrund übernimmt. Dies erlaubt es von vorneherein Programme mit Threads umzusetzen, ohne damit die Portabilität des Programms über Betriebssystemgrenzen hinaus einzuschränken. Der einfachste Thread in Java wird automatisch erzeugt, wenn man ein Programm mit einer main-Methode aufruft. Dieser initiale main-Thread führt die Methode aus. Andere Threads, die ständig im Hintergrund aktiv sind, sind zum Beispiel der Garbage Collector und der Just-in-time-Compiler. Eigene Threads kann man erstellen, indem man entweder direkt aus der Klasse java.lang.Thread eine eigene Klasse ableitet, wie in Beispiel 12.4, oder man implementiert das Interface java.lang.Runnable, wie in Beispiel 12.3, das lediglich die Methode public void run() enthält. Die Klasse Thread bietet viele nützliche Methoden zum Starten und Stoppen eines Threads. Implementierungen von Runnable benötigen eine Instanz eines Thread, damit die Methode run ausgeführt werden kann. 1 concurrency bzw. concurrent programming 161 12 Parallele Ausführung - Nebenläufigkeit - Threads GarbageCollector VM-Thread erzeugt User-Thread1 run() Aufruf von MeineKlasse.main() gibt Resourcen frei X Ausführung beendet Virtuelle Maschine A BBILDUNG 12.1: Schematische Darstellung der Ausführung eines eigenen Programms mit main-Methode in der Klasse MeineKlasse.java in der Java Virtual Machine VM-Thread erzeugt GarbageCollector User-Thread1 run() Aufruf von MeineKlasse.main() erzeugt MeinThread run() X Ausführung beendet gibt Resourcen frei gibt Resourcen frei X Ausführung beendet Virtuelle Maschine A BBILDUNG 12.2: Schematische Darstellung der Ausführung eines eigenen Programms mit main-Methode in der Klasse MeineKlasse.java und eines zusätzlichen Threads MeinThread.java in der Java Virtual Machine 162 12.1 Das Threadmodell von Java 01: public class RunnableDemo implements Runnable { 02: //Diese Methode stammt aus Runnable und muss 03: //implementiert werden 04: public void run() { 05: //Zaehlen in einer Schleife von 0 bis 9 06: for(int i=0;i<10;i++) { 07: //Ausgabe jeder Zahl auf der Konsole 08: System.out.println("Zahl "+i); 09: } 10: //fertig! 11: System.out.println("Fertig!"); 12: } 13: 14: public static void main(String[] args) { 15: //Erzeugen einer neuen Instanz 16: RunnableDemo rd = new RunnableDemo(); 17: //Erzeugen eines Thread, um das Runnable auszufuehren 18: Thread t = new Thread(rd); 19: //Thread starten 20: t.start(); 21: } 22:} A BBILDUNG 12.3: Eine Implementierung des Interfaces Runnable 01: public class ThreadDemo extends Thread { 02: //Diese Methode stammt aus Runnable, ist aber in Thread 03: //leer implementiert und muss daher ueberschrieben werden 04: public void run() { 05: //Zaehlen in einer Schleife von 0 bis 9 06: for(int i=0;i<10;i++) { 07: //Ausgabe jeder Zahl auf der Konsole 08: System.out.println("Zahl "+i); 09: } 10: //fertig! 11: System.out.println("Fertig!"); 12: } 13: 14: public static void main(String[] args) { 15: //Erzeugen einer neuen Instanz, inklusive Thread 16: ThreadDemo td = new ThreadDemo(); 17: //Thread starten 18: td.start(); 19: } 20:} A BBILDUNG 12.4: Statt Implementierung des Interfaces Runnable nun Erweiterung der Klasse Thread 163 12 Parallele Ausführung - Nebenläufigkeit - Threads 12.2 Thread Pools Ein Thread ist also ein Hilfsmittel, um ein Runnable ausführen zu können. Bei der Erstellung vieler Threads kann dies zu einem hohen Aufwand für die Initialisierung neuer Objekte führen. Diesen kann man reduzieren, indem man sogenannte Thread Pools benutzt. Diese halten einen Vorrat an Threads bereit, an die man Runnable-Objekte übergeben kann. Abbildung 12.5 zeigt schematisch die Arbeitsweise eines CachedThreadPool, der eine bestimmte Anzahl Threads bereit hält, um damit Runnables ausführen zu können. Der CachedThreadPool kann bei Bedarf auch neue Threads erzeugen. Bei Thread Pools mit fester Anzahl Threads werden überzählige Runnables in eine Warteschlange aufgenommen, um dann von den nächsten freiwerdenden Threads ausgeführt zu werden. Meist gibt es weniger Threads als Runnables, was dazu führt, dass überzählige Runnables in einer Warteschlange für die Ausführung vorgemerkt werden. >>ThreadPool<< Executor Thread2 erzeugt Runnable Thread1 finde nächsten freien Thread run() Aufruf von Runnable bin wieder frei A BBILDUNG 12.5: Schematische Darstellung der Arbeitsweise eines Cached Thread Pool Im Paket java.util.concurrent gibt es sogenannte ExecutorService Klassen, die die Funktionalität eines Thread Pools erfüllen. Ein Thread Pool mit der oben beschriebenen Eigenschaft lässt sich durch den Aufruf der statischen Klassenmethode Executors.newCachedThreadPool() 164 12.3 Besonderheiten bei Swing - Der Event Dispatch Thread erzeugen. Ein Thread Pool mit einer fest vorgegebenen maximalen Anzahl an Threads kann mit Executors.newFixedThreadPool(int numberOfThreads) erzeugt werden.2 12.3 Besonderheiten bei Swing - Der Event Dispatch Thread Insbesondere bei Swing werden häufig Fehler gemacht, die direkt mit Threads zu tun haben. Swing Klassen und deren Methoden sind nur in wenigen Fällen synchronisiert, dass heisst, verschiedene Threads die zur gleichen Zeit versuchen Daten über set Methoden zu verindern, können zu einem inkonsistenten Zustand des Objektes und zum Beispiel zu ArrayIndexOutOfBoundsExceptions führen. Da Instanzen von Swing Klassen üblicherweise etwas anzeigen, kann es so dazu kommen, dass Anzeige und Daten nicht konsistent sind. Daher sollten Änderungen am Zustand eines Swing-Objekts immer auf dem Event Dispatch Thread ausgeführt werden. Dabei ist darauf zu achten, dass wirklich nur das Update auf dem Event Dispatch Thread ausgeführt wird, und nicht etwa die Berechnungen, die zu dem Ergebnis führen sollen. Solange ein Task im Event Dispatch Thread aktiv ist, wird zum Beispiel die GUI nicht aktualisiert, da diese Aktualisierung logisch gesehen nach dem momentanen Task ausgeführt werden würde. Der Event-Dispatch Thread stellt also die Synchronisierung aller Objekte der GUI sicher, neue Tasks werden in eine Warteschlange aufgenommen, in der Reihenfolge ihres Eintreffens.3 Um festzustellen, ob der eigene Code auf dem Event Dispatch Thread ausgeführt wird, kann man boolean b = javax.swing.SwingUtilities.isEventDispatchThread(); aufrufen. Für aufwendige Berechnungen, die im Hintergrund einer grafischen Benutzeroberfläche ausgeführt werden sollen, kann man seit der Version 6 der Java API Implementierungen der Klasse javax.swing.SwingWorker<T,V> verwenden. Eine Beispielimplementierung ist in Beispiel 12.6 gegeben. SwingWorker führt die Methode doInBackground() innerhalb eines eigenen Threads, unabhängig vom Event Dispatch Thread aus. Das Ergebnis der Berechnung kann mit der blockierenden Methode get() abgefragt werden, dies sollte aber erst nach Beendigung der Aufgabe des Workers geschehen, um ein Blockieren der GUI zu verhindern.4 2 http://java.sun.com/javase/6/docs/api/java/util/concurrent/Executors.html http://java.sun.com/docs/books/tutorial/uiswing/concurrency/ 4 http://java.sun.com/products/jfc/tsc/articles/threads/threads2.html 3 165 12 Parallele Ausführung - Nebenläufigkeit - Threads 01: import java.beans.PropertyChangeEvent; 02: import java.beans.PropertyChangeListener; 03: import java.util.ArrayList; 04: import java.util.List; 05: import java.util.Random; 06: import java.util.concurrent.ExecutionException; 07: import javax.swing.JFrame; 08: import javax.swing.JProgressBar; 09: import javax.swing.SwingUtilities; 10: import javax.swing.SwingWorker; 11: 12: public class MyWorker extends SwingWorker<ArrayList<Double>, Integer> { 13: private ArrayList<Double> numbers = null; 14: private int lastIndex = 0; 15: private int nelements = 0; 16: private boolean done = false; 17: 18: public MyWorker(int elementsInList) { 19: this.nelements = elementsInList; 20: numbers = new ArrayList<Double>(this.nelements); 21: } 22: //Diese Methode wird im Hintergrund in einem Thread ausgefuehrt 23: @Override 24: public ArrayList<Double> doInBackground() { 25: System.out.println("DoInBackground called!"); 26: //Initialisiere einen Zufallszahlengenerator mit 27: //der aktuellen Systemzeit in Millisekunden 28: Random r = new Random(System.currentTimeMillis()); 29: //Solange nicht n Zahlen berechnet sind und wir nicht 30: //von ausserhalb gestoppt werden 31: while (! (done) && ! isCancelled()) { 32: //fuege neue Zahlen zu numbers hinzu 33: addNumber(numbers,this.nelements,r); 34: } 35: return numbers; 36: } 37: //Hiermit fuegen wir eine neue Zahl hinzu 38: protected void addNumber(List<Double> numbers, int nelements, Random r) { 39: //Wenn wir schon genuegend Zahlen haben, setze done um abzubrechen 40: if(lastIndex==nelements) { 41: done = true; 42: } 43: else { //sonst erzeuge eine neue Pseudozufallszahl 44: Double d = new Double(r.nextDouble()); 45: numbers.add(d); 46: //Prozentualen Fortschritt berechnen 47: int prog = (int)Math.floor(100.0d*((double)(lastIndex+1)/(double)nelements)); 48: //Fortschritt setzen, benachrichtigt alle 49: //registrierten PropertyChangeListener 50: setProgress(prog); 51: lastIndex++; 52: } 53: } 54:} A BBILDUNG 12.6: Ein Beispiel für die Verwendung des SwingWorker 166 12.3 Besonderheiten bei Swing - Der Event Dispatch Thread 00: public class SwingWorkerExecutor { 01: //Main Methode 02: public static void main(String[] args) { 03: //Erzeugen einer Fortschrittsanzeige 04: final JProgressBar progressBar = new JProgressBar(0, 100); 05: //Zeige Prozentwert an 06: progressBar.setStringPainted(true); 07: //Erzeuge neues Runnable fuer die grafische Oberflaeche 08: Runnable r = new Runnable() { 09: 10: @Override 11: public void run() { 12: JFrame jf = new JFrame("SwingWorkerDemo"); 13: jf.add(progressBar); 14: jf.setVisible(true); 15: jf.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); 16: jf.pack(); 17: } 18: 19: }; 20: //Erzeuge die grafische Oberflaeche auf dem Event-Dispatch-Thread 21: SwingUtilities.invokeLater(r); 22: //Starte unseren Task mit 5.000.000 zu erstellenden Zahlen 23: MyWorker task = new MyWorker(5000000); 24: System.out.println("Setting up task!"); 25: //Fuege die Fortschrittsanzeige als anonymen Listener unserem Task hinzu 26: task.addPropertyChangeListener( 27: new PropertyChangeListener() { 28: public void propertyChange(PropertyChangeEvent evt) { 29: //Wenn die Aenderung der Eigenschaft progress entspricht, 30: //setze die Anzeige auf den mitgelieferten Wert 31: if ("progress".equals(evt.getPropertyName())) { 32: progressBar.setValue((Integer)evt.getNewValue()); 33: } 34: } 35: }); 36: 37: task.execute(); 38: 39: try { 40: //task.get() blockiert, bis der Task fertig gerechnet hat 41: final ArrayList<Double> v = task.get(); 42: //Ausgabe der Ergebnisse, wird ausgelassen 43: //for(int i=0;i<v.size();i++) { 44: // System.out.println("Zahl "+i+" = "+v.get(i)); 45: //} 46: } catch (InterruptedException e) { 47: e.printStackTrace(); 48: } catch (ExecutionException e) { 49: e.printStackTrace(); 50: } 51: } 52: } A BBILDUNG 12.7: Eine Klasse, um den SwingWorker auszuführen 167 12 Parallele Ausführung - Nebenläufigkeit - Threads 12.4 Weiterführende Konzepte Im Paket java.util.concurrent findet man noch viele weitere Klassen, um den Umgang mit Threads und parallelen Programmteilen zu erleichtern.5 12.5 Java Beans Java Beans realisieren plattformunabhängige, austauschbare und in sich abgeschlossene Komponenten in der Programmiersprache Java. Dieses modulare Konzept ermöglicht ein komponentenorientiertes Programmieren, wobei Abhängigkeiten zwischen den Komponenten minimiert werden. Dadurch sind einzelne Teile eines grös̈eren Projekts, zum Beispiel bei veränderten Anforderungen, leichter austausch- und wartbar.6 12.5.1 Konventionen Beans können durch zwei verschiedene Methoden zugänglich gemacht werden. Entweder werden die Eigenschaften (Properties), öffentlichen (public) Methoden und Events per Reflection zur Laufzeit ausgelesen, wenn deren Benennung bestimmten Konventionen folgt (siehe Beispiel 12.8), oder der Entwickler liefert eine Klasse mit, die das Interface BeanInfo implementiert. Es gilt weiter, dass eine Bean einen öffentlichen Konstruktor ohne Parameter anbieten muss, so dass Sie jederzeit instantiiert werden kann, ohne die Parameter eines speziellen Konstruktors zu kennen. In Beispiel 12.8 wird dies durch 00: public SimpleBean(){ 01: setText("Hello world!"); 02: setOpaque(true); 03: setBackground(Color.RED); 04: setForeground(Color.YELLOW); 05: setVerticalAlignment(CENTER); 06: setHorizontalAlignment(CENTER); 07: } abgedeckt. Die am weitesten verbreiteten Beans sind die AWT- und Swing-Klassen, es gibt allerdings auch nicht sichtbare Beans, die aber den gleichen SoftwaredesignRichtlinien folgen.7 Die Eigenschaften von Beans sind über sogenannte set oder get Methoden verfügbar. Dabei setzt man mit 5 http://java.sun.com/javase/6/docs/api/java/util/concurrent/package-summary. html 6 http://java.sun.com/javase/6/docs/technotes/guides/beans/index.html 7 http://java.sun.com/docs/books/tutorial/javabeans/index.html 168 12.5 Java Beans setNameDerEigenschaft(ObjektVomTypDerEigenschaft argument) zum Beispiel mit setText( "Hello world!" ), in Listing 12.8 die Eigenschaft text. Die entsprechende Methode in der Oberklasse von SimpleBean lautet public void setText(String text); in der Klasse JLabel, die mit public class SimpleBean extends JLabel erweitert wird. Es wird zwischen verschiedenen Typen von Eigenschaften unterschieden: • Einfache Eigenschaften (simple properties), wie zum Beispiel String name = "Max Mustermann"; • Eigenschaften, die aus mehreren Werten in einem Datenbereich (Array) bestehen (indexed properties), wie zum Beispiel String[] vornamen = new String[]{"Heike","Max","Moritz"}; • Gebundene Eigenschaften (bound properties), zum Beispiel wurde diese Komponente mit der Maus angeklickt? => Informiere für dieses Ereignis registrierte Listener • Abhängige Eigenschaften (constrained properties), zum Beispiel ist die Änderung der Eigenschaft gültig? Listener kann Veto einlegen, um dies zu verhindern. Die Eigenschaften können lesbar und/oder schreibbar sein. 12.5.2 Speichern und Laden Die sogenannte Persistenz8 wird im Beispiel durch die Implementierung der Schnittstelle (Interface) java.io.Serializable erreicht. Persistenz bedeutet, dass der Zustand eines Objekts, in diesem Fall einer Java Bean, dauerhaft gespeichert (serialisiert) werden kann. Das Objekt wird dazu mit allen Eigenschaften seriell in eine von Sun festgelegte Binärdarstellung geschrieben.9 Diese kann zu einem späteren Zeitpunkt wieder deserialisiert werden, um den ursprünglichen Zustand des Objekts wiederherzustellen. Der Prozess der Serialisierung und Deserialsierung wird dabei vollständig von Java übernommen, das Interface Serializable ist lediglich ein sogenanntes Marker-Interface ohne definierte Methoden. Eine weitere Möglichkeit, die die zugrundeliegende Serialisierungs-API von Java nutzt, ist die Implementierung der folgenden beiden Methoden: 8 9 Im Deutschen am ehesten als Dauerhaftigkeit zu übersetzen http://java.sun.com/docs/books/tutorial/javabeans/persistence/index.html 169 12 Parallele Ausführung - Nebenläufigkeit - Threads 00: 01: 02: 03: 04: 05: 06: 07: 08: 09: 10: 11: 12: 13: 14: 15: import import import import java.awt.Color; java.beans.XMLDecoder; javax.swing.JLabel; java.io.Serializable; public class SimpleBean extends JLabel implements Serializable { public SimpleBean() { setText( "Hello world!" ); setOpaque( true ); setBackground( Color.RED ); setForeground( Color.YELLOW ); setVerticalAlignment( CENTER ); setHorizontalAlignment( CENTER ); } } A BBILDUNG 12.8: Eine einfache Java Bean aus dem Java Beans Tutorial der Firma SUN private void writeObject(java.io.ObjectOutputStream out) throws IOException; private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException; Hiermit sollte man arbeiten, wenn man zum Beispiel bestimmte zusätzliche Informationen über assoziierte Objekte mitspeichern will. Wird mehr Kontrolle über das Speicherformat benötigt, so bietet Java das Interface java.io.Externalizable an, um in ein eigenes Format schreiben und von diesem zurück lesend, ein Objekt im selben Zustand erzeugen zu können. Die Klassen java.beans.XMLEncoder und java.beans.XMLDecoder erlauben es, Objekte über die Serialisierungs-API in XML anstatt in einem Binärformat zu speichern und zu einem späteren Zeitpunkt wieder einzulesen. Beispiel 12.10 zeigt, wie serialisierbare Objekte geschrieben und gelesen werden können. 170 12.5 Java Beans 00: 01: 02: 03: 04: 05: 06: 07: 08: 09: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: import java.awt.Color; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; import java.io.Serializable; public class DemoSimpleBean implements Serializable { private String text = null; private Color foreground = null; private final PropertyChangeSupport pcs = new PropertyChangeSupport(this); //Initialisiere die Variablen im Konstruktor public DemoSimpleBean() { setText("Initialized!"); setForeground(Color.BLUE); } //Auslesen des Textes public String getText() { return text; } //Setzen des Textes public void setText(String text) { //Ablage des alten Textes in lokaler Variable String old = this.text; //Setzen des neuen Textes this.text = text; //Benachrichtigung der registrierten Listener this.pcs.firePropertyChange("text", old, this.text); } //Auslesen der Vordergrundfarbe public Color getForeground() { return foreground; } //Setzen der Vordergrundfarbe public void setForeground(Color c) { Color old = foreground; foreground = c; this.pcs.firePropertyChange("foreground", old, foreground); } //Hinzufuegen von Listenern fuer Aenderung der Eigenschaften public void addPropertyChangeListener(PropertyChangeListener listener) { this.pcs.addPropertyChangeListener(listener); } //Entfernen von Listenern fuer Aenderung der Eigenschaften public void removePropertyChangeListener(PropertyChangeListener listener) { this.pcs.removePropertyChangeListener(listener); } } A BBILDUNG 12.9: Eine eigene Java Bean mit den gebundenen Eigenschaften text und foreground 171 12 Parallele Ausführung - Nebenläufigkeit - Threads 00: 01: 02: 03: 04: 05: 06: 07: 08: 09: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: //importiert alle Klassen im Paket java.io import java.io.*; public class ObjectOutputAndInputDemo{ public static void main(String[] args){ //Oeffnen eines Streams zur Datei "t.tmp", diese wird, //falls Sie nicht existiert, neu angelegt FileOutputStream fos = new FileOutputStream("t.tmp"); //Ein ObjectOutputStream kapselt den FileOutputStream //ObjectOutputStream liest den Typ eines Objekts und //schreibt ihn gemeinsam mit den Objekteigenschaften //auf den FileOutputStream ObjectOutputStream oos = new ObjectOutputStream(fos); //Schreiben eines Integer-Objekts oos.writeInt(12345); //Schreiben eines String-Objekts oos.writeObject("Today"); //Schreiben eines Date-Objekts oos.writeObject(new Date()); //Schliessen der Datei oos.close(); } } A BBILDUNG 12.10: Ein einfaches Beispiel für die Serialisierung 172 Literaturverzeichnis [1] K. Arnold, J. Gosling: JavaT M - Die Programmiersprache. Addison-Wesley, 1996. [2] T.H. Cormen, C.E. Leierson, R.L. Rivest: Introduction to Algorithms. MIT Press, 1990. [3] T.H. Cormen, C.E. Leierson, R.L. Rivest, C. Stein: Introduction to Algorithms. MIT Press, 2003. [4] R. Sedgewick: Algorithms in Java, Part 5: Graph Algorithms. AddisonWesley, 2004, 3rd edition. [5] D. Flanagan: Java in a Nutshell. O’Reilly & Associates Inc., 1996. [6] F. Jobst: Programmieren in Java. Hanser Verlag, 1996. [7] H. Klaeren: Vom Problem zum Programm. 2.Auflage, B.G. Teubner Verlag, 1991. [8] K. Echtle, M. Goedicke: Lehrbuch der Programmierung mit Java. dpunktVerlag, 2000. [9] Christian Ullenboom: Java ist auch eine galileocomputing.de/openbook/javainsel Insel. http://www. [10] Sun Microsystems, Inc.: Java Tutorial - Swing Trail, http://java.sun.com/ docs/books/tutorial/ui/index.html [11] S. Oaks, H. Wong: Java Threads. O’Reilly, 2004, 3rd Edition. [12] J. Gosling, B. Joy, G. Steele, G. Bracha: The Java Language Specification. Addison Wesley, 2005, 3rd Edition 173 Literaturverzeichnis 174 Index abstract, 80 boolean, 33 byte, 33 case, 40 catch, 110 char, 33 class, 23 default, 40 double, 23, 33 do, 36 else, 37 extends, 25, 73 false, 30 finally, 110 final, 24 float, 33 for, 36 if, 30, 37 implements, 102 import, 26, 106 instanceof, 112 int, 29, 33 length, 30 long, 33 new, 23 package, 26, 105 private, 27, 69 protected, 27 public, 27 short, 33 static, 24, 25, 68 String, 33, 43 super, 76 switch, 40 this, 67 throws, 110 throw, 109 true, 30 try, 110 void, 24 while, 30, 36 abstrakte Klassen, 79 Methoden, 79 abstrakte Datentypen, 61 Algorithmen Boyer-Moore (BM), 48 Boyer-Moore-Horspool (BMH), 49 Knuth-Morris-Pratt (KMP), 51 Textsuchalgorithmen, 43 Array, 30 mehrdimensional, 39 Ausgabe, 139 Ausnahmen, 107 Backus-Naur-Form, 9 Bitoperatoren, 34 BNF, 9 Bool’sche Operatoren, 34 Boyer-Moore (BM), 48 Boyer-Moore-Horspool (BMH), 49 call by reference, 72 call by value, 71 casting, 74 concurrent programming, 161 connected components, 117 Datenabstraktion, 61 175 Index Datenkapselung, 61 Datenströme, 143 Datentyp abstrakt, 61 Stack, 62 Dekrement, 35 direkte Verkettung, 131 Dokumentierung, 70 Double Hashing, 133 EBNF, 9 Einfachvererbung, 65 Eingabe, 139 Exceptions, 107 Feld, 30 mehrdimensional, 39 File-Ströme, 141 Generics, 94 Graphen, 115, 116 Adjazenzliste, 118 Adjazenzmatrix, 118 gerichtete, 116 gewichtete, 116 Kante, 116 Knoten, 116 benachbarte, 117 Grad eines, 117 Pfad, 118 Teilgraph, 117 verbundene, 117 Zyklus, 118 Instanzen, 22 Interfaces, 101 Java Beans, 168 Konventionen, 168 Speichern und Laden, 169 Klassen, 22, 23, 64 abstrakt, 79 generische, 94 String, 43 Klassenvariablen, 24 Knuth-Morris-Pratt (KMP), 51 Kollisionen, 131 Kommentare, 70 Konstruktoren, 66 in Unterklassen, 77 Reihenfolgeabhängigkeit, 78 Ladefaktor, 134 lineare Verschiebung, 133 Listen, 87 Literale, 32 Mehrfachvererbung, 66 Methoden, 24, 64 abstrakt, 79 in Java, 71 klassenbezogene Methoden, 25 Signatur, 71 überschreiben, 65 Methodenaufruf, 24 Mini-Java, 29 Hüllenklassen, 93 Hashing, 127 Haskell, 92 Nebenläufigkeit, 161 Event Dispatch Thread, 165 Threadmodell, 161 ThreadPools, 164 Importieren, 106 Initialbelegungen, 33 Initialisierungsblöcke, 68 Inkrement, 35 Input/Output (IO), 139 InputStream, 140 Oberklasse, 65 Obertypen, 102 Objekte, 22, 23, 64 Objektreferenzen, 23 OOP, 61 Open Hashing, 132 176 Index Operatoren Bitoperatoren, 34 Bool’sche Operatoren, 34 Zuweisungsoperatoren, 35 Pakete, 26, 105 Zugriff, 26 Pattern Matching, 95 Postfixschreibweise, 35 Präfix, 45 Präfixschreibweise, 35 Programm interpretieren, 21 übersetzen, 21 Programmierung Imperativ, 29 imperativ, 22 objektorientiert, 61 quadratische Verschiebung, 133 Reihenfolgeabhängigkeit, 78 Schnittstellen, 101 Semantik, 1 Stack, 62 Ströme, 139 Datenströme, 143 gepuffert, 142 Streams, 139 Suffix, 45 Swing, 147 BorderLayout, 153 BoxLayout, 155 FlowLayout, 152 GridLayout, 154 HelloWorld, 148 JButton, 149 JTextField, 150 Listener als externe Klasse, 156 Listener als interne Klasse, 157 Container, 151 Ereignisse, 156 Java Applet vs. Java WebStart vs. Java Application, 158 Komponenten, 148 LayoutManager, 151 Sicherheitskonzepte, 159 Syntax, 1 Syntaxdiagramme, 13 Textsuchalgorithmen, 43 Thread, 161 Tokenizer, 144 Typumwandlung, 74 Überladen von Konstruktoren, 67 von Methoden, 71 Überschreiben von Methoden, 75 Unicode, 32 Unterklasse, 65 Beispiel: GraphicCircle, 73 Variablen Klassenvariablen, 24 Verdecken von Datenfeldern, 75 Vererbung, 65 Beispiel: GraphicCircle, 73 Einfachvererbung, 65 Mehrfachvererbung, 66 Verschiebung lineare, 133 quadratische, 133 Zeichenketten, 43 Zugriff Pakete, 26 Zusammenhangskomponenten, 117 Zuweisungsoperatoren, 35 177