HUMBOLDT-UNIVERSITÄT ZU BERLIN MATHEMATISCH - NATURWISSENSCHAFTLICHE FAKULTÄT II INSTITUT FÜR INFORMATIK ARBEITSGRUPPE SPEZIFIKATION, VERIFIKATION UND TESTTHEORIE PROF. DR. HOLGER SCHLINGLOFF SS 2010 Skriptum zur Vorlesung Grundlagen der Programmierung Inhalt Kapitel 0: Einführung ......................................................................................................... 0-4 0.1 Inhalt der Vorlesung, Organisatorisches, Literatur..................................................... 0-4 0.2 Einführungsbeispiel .................................................................................................. 0-8 0.3 Was ist Informatik? ................................................................................................. 0-15 Kapitel 1: Mathematische Grundlagen .............................................................................. 1-20 1.1 Mengen, Multimengen, Tupel, Funktionen, Halbordnungen .................................... 1-20 1.2 Induktive Definitionen und Beweise ....................................................................... 1-24 1.3 Alphabete, Wörter, Bäume, Graphen....................................................................... 1-27 Kapitel 2: Informationsdarstellung .................................................................................... 2-32 2.1 Bits und Bytes, Zahl- und Zeichendarstellungen...................................................... 2-32 2.2 Sprachen, Grammatiken, Syntaxdiagramme ............................................................ 2-41 2.3 Darstellung von Algorithmen .................................................................................. 2-46 Kapitel 3: Rechenanlagen ................................................................................................. 3-53 3.1 Historische Entwicklung ......................................................................................... 3-53 3.2 von-Neumann-Architektur ...................................................................................... 3-61 3.3 Aufbau PC/embedded system, Speicher .................................................................. 3-65 Kapitel 4: Programmiersprachen und –umgebungen ......................................................... 4-70 4.1 Programmierparadigmen ......................................................................................... 4-70 4.2 Historie und Klassifikation von Programmiersprachen ............................................ 4-73 4.3 Java und Groovy ..................................................................................................... 4-74 4.4 Programmierumgebungen am Beispiel Eclipse........................................................ 4-77 Kapitel 5: Applikative Programmierung ........................................................................... 5-79 5.1 λ-Kalkül.................................................................................................................. 5-79 5.2 Rekursion, Aufruf, Terminierung ............................................................................ 5-80 Kapitel 6: Konzepte imperativer Sprachen ........................................................................ 6-85 6.1 Variablen, Datentypen, Ausdrücke .......................................................................... 6-85 6.2 Anweisungen und Kontrollstrukturen ...................................................................... 6-89 6.3 Sichtbarkeit und Lebensdauer von Variablen .......................................................... 6-91 0-1 Kapitel 7: Objektorientierung ........................................................................................... 7-93 7.1 abstrakte Datentypen, Objekte, Klassen .................................................................. 7-93 7.2 Klassen, Objekte, Methoden, Datenfelder, Konstruktoren ....................................... 7-97 7.3 Vererbung, Polymorphismus, dynamisches Binden ............................................... 7-101 Kapitel 8: Modellbasierte Softwareentwicklung .............................................................. 8-105 8.1 UML Klassendiagramme und Zustandsmaschinen ................................................ 8-105 8.2 Codegenerierung und Modelltransformationen ...................................................... 8-105 Kapitel 9: Spezielle Programmierkonzepte ..................................................................... 9-106 9.1 Benutzungsschnittstellen, Ereignisbehandlung ...................................................... 9-106 9.2 Abstrakte Klassen, Interfaces, generische Typen ................................................... 9-112 9.3 Fehler, Ausnahmen, Zusicherungen ...................................................................... 9-113 9.4 Parallelität............................................................................................................. 9-116 Kapitel 10: Algorithmen und Datenstrukturen................................................................10-123 10.1 Listen, Bäume, Graphen .....................................................................................10-123 10.2 Graphalgorithmen ..............................................................................................10-130 10.3 Suchen und Sortieren .........................................................................................10-132 0-2 Hinweis zur Nutzung dieses Skriptes: Achtung, drucken Sie das Skript (noch) nicht aus! Es wird parallel zur Vorlesung erstellt und laufend aktualisiert. Wenn Sie jetzt schon ihren Drucker bemühen, ist das entweder eine Verschwendung von Papier (weil Sie es später nochmal drucken) oder sie erhalten ein evtl. inkonsistentes Dokument (wenn Sie verschiedene Teile zusammenheften). Mit Abschluss des Semesters wird Ihnen das gesamte Skript zur Verfügung stehen! Dieses Dokument enthält außerdem Hyperlinks zu weiteren Quellen im Internet, die in Word oder mit dem Acrobat Reader durch Anklicken abrufbar sind. Für die Aktualität der Links übernehme ich keinerlei Gewähr.. HS, 22.4.2010 0-3 Kapitel 0: Einführung 0.1 Inhalt der Vorlesung, Organisatorisches, Literatur Die Vorlesung „Grundlagen der Programmierung“ hat laut der Modulbeschreibung des Bachelor-Studienganges Informatik folgende Lern- und Qualifikationsziele: Studierende verstehen die Funktionsweise von Computern und die Grundlagen der Programmierung. Sie beherrschen eine objektorientierte Programmiersprache und kennen andere Programmierparadigmen. Daraus ergeben sich folgende Vorlesungsinhalte: • • • • • • • Grundlagen: Algorithmus, von-Neumann-Rechner, Programmierparadigmen Konzepte imperativer Programmiersprachen: Grundsätzlicher Programmaufbau; Variablen: Datentypen, Wertzuweisungen, Ausdrücke, Sichtbarkeit, Lebensdauer; Anweisungen: Bedinge Ausführung, Zyklen, Iteration; Methoden: Parameterübergabe; Rekursion; Konzepte der Objektorientierung: Objekte, Klassen, Abstrakte Datentypen; Objekt Variablen/-Methoden, Klassen -Variablen/-Methoden; Werte und Referenztypen; Vererbung, Sichtbarkeit, Überladung, Polymorphie; dynamisches Binden; Ausnahmebehandlung; Oberflächenprogrammierung; Nebenläufigkeit (Threads) Einführung in eine konkrete objektorientierte Sprache (z.B. JAVA): Grundaufbau eines Programms, Entwicklungsumgebungen, ausgewählte Klassen der Bibliothek, Programmierrichtlinien für eigene Klassen, Techniken zur Fehlersuche (Debugging) Einfache Datenstrukturen und Algorithmen: Listen, Stack, Mengen, Bäume, Sortieren und Suchen Softwareentwicklung: Softwarelebenszyklus, Software-Qualitätsmerkmale Alternative Konzepte: Zeiger, maschinennahe Programmierung, alternative Modularisierungstechniken Die Vorlesung (4 SWS) ist nur mit begleitender Übung (2 SWS), Praktikum (2 SWS), Selbststudium, Vorlesungsmitschrift, Hausaufgaben (in Zweiergruppen bearbeitet, korrigiert und bewertet, in der Übung besprochen) sinnvoll. Als Prüfung findet eine Abschlussklausur (120 Minuten Dauer) statt; die Zulassung zur Klausur ist an die Erreichung einer bestimmten Punktzahl in Übungen und Praktikum gebunden. Für die erfolgreiche Teilnahme gibt es 12 Studienpunkte nach dem ECTS-System (European Credit Transfer System). Ein Studienpunkt (SP) entspricht 30 Zeitstunden Arbeitsaufwand; d.h., der Gesamtaufwand liegt bei ca. 360 Arbeitsstunden: • Vorlesung Grundlagen der Programmierung; 4 SWS, 6 SP, 60 Anwesenheitsstunden (4h/Woche), 120 h Aufbereitung (8h/Woche) • Übung Grundlagen der Programmierung; 2 SWS, 3 SP, 30 Anwesenheitsstunden (2h/Woche), 60 h Aufbereitung (4h/Woche) • Praktikum Grundlagen der Programmierung; 2 SWS, 3 SP, 30 Anwesenheitsstunden (2h/Woche), 60 h Aufbereitung (4h/Woche) Für die Übungen wird 14-tägig ein Übungsblatt verfügbar gemacht, dessen Lösungen zwei Wochen später elektronisch abzugeben sind. Das Aufgabenblatt wird in den Übungen vorund nachbesprochen. Für die Bearbeitung eines Aufgabenblattes sind also ca. 8h erforderlich. 0-4 Während für Vorlesung und Übung die Anwesenheit obligatorisch ist, kann beim Praktikum auf eine Anwesenheit verzichtet werden, wenn der/die Teilnehmer anderweitig über einen Rechner (Laptop) verfügt, auf dem die Aufgaben bearbeitet werden können. Die Gesamtzeit für die Bearbeitung der Praktikumsaufgaben beträgt ca. 6h/Woche. Literaturhinweise Leider gibt es kein einzelnes Buch, welches genau den Stoff der Vorlesung enthält. Als Literatur zur Vorlesung empfehle ich das Buch von Gumm und Sommer. (Aktuell: 8. Auflage; frühere Auflagen gibt es zum Teil im Sonderangebot) Als Ergänzung dazu ist nachfolgend eine Liste relevanter Lehrbücher angegeben.. Lehrbücher: Einführung in die Informatik • • • • • • • • M. Broy: Informatik, eine grundlegende Einführung. Band 1: Programmierung und Rechnerstrukturen, Band 2: Systemstrukturen und theoretische Informatik. SpringerLehrbuch (+ Aufgabensammlung) (Monumentalwerk) P. Levi, U. Rembold: Einführung in die Informatik für Naturwissenschaftler und Ingenieure, Hanser, (konzise) G. Goos: Vorlesungen über Informatik (vier Bände: Bd. 1: Grundlagen und funktionales Programmieren, Bd. 2: Objektorientiertes Programmieren und Algorithmen, Bd. 3: Berechenbarkeit, formale Sprachen, Spezifikationen, Bd. 4: Paralleles Rechnen und nicht-analytische Lösungsverfahren) F. L. Bauer, G. Goos: Informatik - Eine einführende Übersicht, 4. Auflage. Bd. 1+2, Springer („der Klassiker“, etwas veraltet) L. Goldschlager, A. Lister: Informatik, Eine moderne Einführung. Hanser Studienbücher, (ebenfalls etwas veraltet; in der Bibliothek 40 Ex. vorhanden) F. Kröger: Einführung in die Informatik – Algorithmenentwicklung. Springer Lehrbuch(formale Grundlagen, keine OO-Konzepte, als Ergänzung empfohlen) H. Balzert: Lehrbuch Grundlagen der Informatik (als Ergänzung der Vorlesungsthemen) H.-J. Appelrath, J. Ludewig: Skriptum Informatik - Eine konventionelle Einfürung. Teubner/VdF (+ Aufgabensammlung) (Betonung auf „konventionell“!) 0-5 • • A. Aho, J. Ullman: Informatik - Datenstrukturen und Konzepte der Abstraktion. (deutsche Fassung von: Foundations of Computer Science) Thomson Publishing / Computer Science Press (für Fortgeschrittene) R. Sedgewick: Algorithmen in Java (auch für Fortgeschrittene) Lehrbücher: Programmieren in Groovy In der Vorlesung „Grundlagen der Programmierung“ und besonders im zugehörigen Praktikum sollen auch Programmierkenntnisse unterrichtet werden. Trotzdem ist diese Veranstaltung auf keinen Fall ein Programmierkurs; es wird erwartet, dass sich die Studierenden selbständig in programmiersprachliche Details an Hand geeigneter Lehrmaterialien (Bücher oder Online-Handbücher) einarbeiten. Als notationelle Basis dient dabei zunächst die Skriptsprache Groovy, die auf der verbreiteten Programmiersprache Java aufbaut und einige moderne Erweiterungen vorsieht. Später gehen wir dann auf Java zurück. Das Haupt-Referenzbuch zu Groovy ist das Buch von König et. al. Weitere Bücher zu Groovy sind • K. Barclay, J. Savage: Groovy Programming: An Introduction for Java Developers Morgan Kaufmann, 2006. • S. Davis: Groovy Recipes – Greasing the Wheels of Java. Pragmatic Bookshelf, 2008. • V. Subramaniam: Programming Groovy: Dynamic Productivity for the Java Developer. Pragmatic Bookshelf, 2008. • C. Judd, J. Nusairat: Beginning Groovy and Grails: From Novice to Professional. Apress 2008. • B. Abdul-Jawad: Groovy and Grails Recipes – a Problem-Solution Approach. Apress 2008. Darüber hinaus gibt es zu Groovy eine umfangreiche Online-Dokumentation, siehe http://groovy.codehaus.org/Documentation. 0-6 Literatur zu Java Zum Erlernen der Programmiersprache Java kann entweder das Buch von Bell und Parr oder das von Bishop dienen. Wer sich intensiver mit Java auseinander setzen möchte, dem sei das Buch von Gosling als ultimative Referenz empfohlen. • • • • • • • J. Bishop: Java lernen (dt. Ausgabe von Java Gently), Addison Wesley / Pearson, 2. Aufl. 2001 (südafrikanisches Flair) K. Arnold, J. Gosling, D. Holmes: Die Programmiersprache Java (dt. Ausgabe von The Java Programming Language). Addison-Wesley 1996 („die Referenz“) D. Barnes, M. Kölling: Objektorientierte Programmierung mit Java (deutsche Ausgabe von: Objects First with Java - A Practical Introduction using BlueJ). Prentice Hall / Pearson, Sept. 2003 (für Vorlesung empfohlen) D. Bell, M. Parr: Java für Studenten – Grundlagen der Programmierung. Prentice Hall / Pearson, 3. Aufl. 2003 (systematischer Java-Lehrgang) E.-E. Doberkat, S. Dißmann: Einführung in die objektorientierte Programmierung mit Java. Oldenbourg-Verlag, 2. Auflage 2002 (Wiener Hofzwerge betreiben Informatik) H. W. Lang: Algorithmen in Java. Oldenbourg-Verlag 2003 Küchlin, Weber: Einführung in die Informatik – Objektorientiert mit Java 0-7 0.2 Einführungsbeispiel Um sich der Frage zu nähern, was eigentlich praktische Informatik ist, betrachtet man am besten ein Beispiel. Ein bekanntes Problem der Informatik ist das Problem des Handlungsreisenden (Travelling Salesman Problem, TSP). Ein Handlungsreisender muss bei seiner Arbeit Kunden besuchen, die in verschiedenen Orten wohnen. Aufgabe der Sekretärin ist es nun, eine Tour zu planen, die ihn zu jedem Kunden genau einmal führt und am Schluss wieder zum Ausgangspunkt zurück bringt. Natürlich sollte die Tour optimal sein, d.h., möglichst wenig Ressourcen verbrauchen. Abhängig davon, welchen Begriff von Ressource man zu Grunde legt, gibt es verschiedene Varianten der Aufgabenstellung. Beim allgemeinen TSP gehen wir davon aus, dass der Handlungsreisende z.B. mit dem Flugzeug unterwegs ist: da es nicht immer von jedem beliebigen Punkt zu jedem anderen eine direkte Flugverbindung gibt, muss die Sekretärin das Streckennetz der Fluggesellschaften und die jeweiligen Flugzeiten (oder Ticketpreise) berücksichtigen. Beim euklidischen TSP bewegt sich der Handlungsreisende zu Fuß oder mit dem Auto, d.h. er kommt überall hin und die Kosten sind proportional zu den Entfernungen zwischen den Punkten auf der Landkarte. Das metrische TSP ist eine Verallgemeinerung des euklidischen und ein Spezialfall des allgemeinen TSP: zwischen je zwei Punkten besteht eine Verbindung, und die Dreiecksungleichung gilt (die Summe der Kosten von A nach B und der Kosten von B nach C ist größer gleich der Kosten von A nach C). Hier ist ein Beispiel für das allgemeine TSP: Die beiden angegebenen Lösungen haben die Länge 33 und 22. Welches ist die optimale Tour? Hier ist ein Beispiel für das euklidische TSP (nach http://mathsrv.kueichstaett.de/MGF/homes/grothmann/java/TSP/): Dieses euklidische TSP hat 250 Punkte. Angegeben sind eine Näherungslösung (Weglänge 12,91) und eine optimale Lösung (Weglänge 12,48). Was ist nun eine geeignete Methode, um das Problem zu lösen? Eine häufige Herangehensweise der Informatik ist es, ein Problem auf ein einfacheres zurückzuführen. Wir wissen, wie die Lösung eines TSP mit nur 2 Punkten (Firmensitz und ein Kunde) aussieht: Der Handlungsreisende muss hin- zurückfahren. Angenommen, wir wissen, wie wir eine Tour 0-8 mit n Punkten löst. Dann kommen wir zu einer Lösung für (n+1) Punkten, indem wir einen beliebigen Punkt zunächst weglassen, eine optimale Tour für n Punkte konstruieren, und den weggelassenen Punkt dann als erstes Ziel in die Tour einfügen. Das ist natürlich noch nicht die optimale Lösung, aber wenn man das für alle Punkte der Reihe nach macht und die Tour mit der minimalen Länge wählt, bekommt man dadurch das Optimum. Diesen Algorithmus könnte man etwa wie folgt notieren: Wir nehmen an, der Startpunkt (Firmensitz) sei fest gegeben und notieren eine Tour durch die Folge der zu besuchenden Punkte (ohne den Startpunkt S) und durch die zugehörige Länge. Tour minTour (Punktmenge M) { Wenn M einelementig (M={A}) dann Rückgabe ((A, 2* |SA|)) Sonst { // Berechne die kürzeste Tour rekursiv Sei minT eine neue Tour, wobei zunächst Punkte (minT) = undefiniert, Länge(minT)=unendlich; Für jedes x aus M { Tour rek = minTour (M-{x}); Sei A der erste Ort von rek; Sei Tour try gegeben durch Punkte (try) = append(x, Punkte(rek))); Länge (try) = Länge(rek) - |SA| + |Sx| + |xA|; Wenn Länge(try) < Länge(minT) {minT=try}; } Rückgabe minT; } } Wenn wir den Ablauf dieser rekursiven Funktion z.B. für die Punkte {ABC} betrachten, stellen wir folgende Aufrufe fest: minTour ({ABC}) ruft minTour({BC}) ruft minTour({C}) und minTour({B}). minTour({AC}) ruft minTour({C}) und minTour({A}). minTour({AB}) ruft minTour({B}) und minTour({A}). Das bedeutet, ein Aufruf mit n Punkten stützt sich auf n Aufrufe mit (n-1) Punkten ab, jeder davon stützt sich auf (n-1) Aufrufe mit (n-2) Punkten ab usw. Insgesamt gibt es n n * (n - 1) * (n - 2) * … * 3 * 2 * 1 = ∏ i = n! i =1 Aufrufe. Im Wesentlichen konstruiert der Algorithmus sämtliche Permutationen der Folge der Punkte. Die Anzahl der Permutationen von n Elementen ist n!. Wir sagen, der Algorithmus hat die Komplexität O(n!). Nachfolgende Tabelle gibt einen Überblick über das Wachstum verschiedener Funktionen. 0-9 n 1 2 3 10 50 256 1000 10000 log(n) 256*n n2 17*n3 2n n! nn 0,0 256 1 17 2 1 1 0,3 512 4 136 4 2 4 0,5 768 9 459 8 6 27 1,0 2560 100 17000 1024 3628800 10000000000 1,7 12800 2500 2125000 1,1259E+15 3,04141E+64 8,88178E+84 2,4 65536 65536 285212672 1,15792E+77 #ZAHL! #ZAHL! 3,0 256000 1000000 1,7E+10 1,0715E+301 #ZAHL! #ZAHL! 4,0 2560000 100000000 1,7E+13 #ZAHL! #ZAHL! #ZAHL! Wenn wir den Algorithmus auf einem schnellen Pentium DualCore ausführen, der bis zu 4 Milliarden Operationen pro Sekunde ausführt, dann müssen wir für n=10 etwa eine Millisekunde warten. Für n=50 erhöht sich unsere Wartezeit allerdings auf etwa 1028=3.000.000.000.000.000.000.000.000.000 oder 3 Quatrilliarden Jahre. Das heisst, für große Werte von n ist der Algorithmus praktisch nicht verwendbar. Auf der anderen Seite ist es ein offenes Problem, ob es tatsächlich einen substantiell besseren Algorithmus gibt! Wer einen Algorithmus entdeckt, welcher das TSP mit einer polynomialen Anzahl von Schritten löst (abhängig von n), wird mit Sicherheit weltberühmt werden. Natürlich gibt es einige Tricks, um die Laufzeit des Algorithmus zu verbessern. Zum Beispiel fällt auf, das während der Rekursion der gleiche Aufruf mehrmals stattfindet. Hier kann man sich mit dem Abspeichern von Zwischenergebnissen behelfen. Dann kann man die Suche stark parallelisieren. Auch hängt es sehr stark von der Implementierung ab, ob man den rekursiven Aufruf naiv oder raffiniert implementiert. Mit solchen Tricks ist es im Jahr 2004 gelungen, ein TSP mit 24.978 schwedischen Städten zu lösen (http://www.tsp.gatech.edu/ http://www.math.princeton.edu/tsp/d15sol/)! Der Weltrekord liegt laut Wikipedia (http://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden) bei der Lösung eines Planungsproblems für das Layout integrierter Schaltkreise mit 85.900 Knoten. Zur Lösung solcher Probleme werden Netze von über hundert Hochleistungscomputern mit einer Gesamtsumme von über 20 Jahren Rechenzeit verwendet. Zum Vergleich: Im Jahr 1977 lag der Rekord noch bei 120 Städten! 0-10 Was macht man aber nun, wenn man das Problem in der Praxis (zum Beispiel in einer mittelständischen Reisebüro-Software) lösen will, wo man kein Hochleistungs-Rechnernetz zur Verfügung hat? Die Antwort der praktischen Informatik heißt Heuristik. Das Wort „Heuristik“ kommt aus der Seefahrt und bezeichnete früher Verfahren, um seine Position annähernd zu bestimmen. Heute bezeichnen wir damit Näherungsverfahren, die zwar nicht die optimale Lösung, aber eine hinreichend genaue Annäherung liefern. Java-Animationen mit verschiedenen Heuristiken sind zu finden unter (http://web.telia.com/~u85905224/tsp/TSP.htm) und (http://www-e.unimagdeburg.de/mertens/TSP/index.html). Heuristik 1: nächster Nachbar Bei dieser Heuristik sucht der Reisende, ausgehend von einem beliebigen Punkt, zunächst den nächsten Nachbarn des aktuellen Punktes auf der Landkarte. Dann bewegt er sich zu diesem und wendet die Heuristik erneut an. Wenn er alle Kunden besucht hat, fährt er nach Hause zurück. Obwohl diese Heuristik sehr häufig im täglichen Leben angewendet wird, liefert sie schlechte Resultate, da der Heimweg und andere unterwegs „vergessene“ Punkte meist hohe Kosten verursachen. 0-11 Heuristik 2: gierige Dreieckstour-Erweiterung Die Sekretärin wählt zunächst zwei Kunden willkürlich aus und startet mit einer einfachen „Dreieckstour“. Diese wird dann nach und nach erweitert, und zwar wird jede Stadt da in die Tour eingefügt, wo sie am besten „passt“, d.h., wo sie die gegebene Tour am wenigsten erweitert. Solche Strategien nennt man „gierig“, da sie nur auf lokale und Optimierung ausgerichtet sind und das Gesamtergebnis nicht berücksichtigen. In der geschilderten Form sind noch zwei Zufallselemente enthalten: Die Wahl der ursprünglichen Dreieckstour, und die Reihenfolge, in der die Knoten hinzugefügt werden. Heuristik 3: Hüllen-Erweiterung Eine andere Variante dieser Heuristik startet mit der konvexen Hülle aller Punkte (d.h., den Punkten, die am weitesten außen liegen), und fügt der Reihe nach diejenigen Knoten hinzu, die die wenigsten Kosten verursachen. Das heißt, es werden der Reihe nach die Knoten hinzugefügt, die den geringsten Abstand von der bisher konstruierten Tour haben. Das ist 0-12 wieder ein „gieriger“ Algorithmus, und zwar in doppelter Hinsicht: bei der Auswahl der Punkte und bei der Bestimmung der Einfügestelle. Heuristik 4: inverse Hüllen-Erweiterung Wieder starten wir mit der konvexen Hülle aller Punkte und fügen der Reihe nach diejenigen Knoten hinzu, die die meisten Zusatzkosten verursachen. Das heißt, es werden die Knoten hinzugefügt, die am weitesten von der bisherigen Tour entfernt liegen. Dadurch wird das Gesamtergebnis (die Form der Tour) frühzeitig stabilisiert. Heuristik 5: Lin-Kernighan Hier versucht man, eine bestehende Tour zu verbessern, indem man zwei Kantenpaare AB und CD sucht und durch die Kanten AC und BD ersetzt (so genannte 2-opt-Strategie). Im endgültigen Lin-Kernighan-Verfahren werden nicht nur Kantenpaare, sondern Mengen von Kanten ersetzt. 0-13 Wie wir sehen, führen konträre Ideen manchmal beide zu guten Ergebnissen. Es ist sehr schwer, die „Güte“ von Heuristiken abzuschätzen, da die Leistung immer vom verwendeten Beispiel abhängt. Für jede der genannten Heuristiken lassen sich Beispiele konstruieren, so dass das Ergebnis schlecht ist (die doppelte Länge der optimalen Tour oder noch mehr hat). Ist es nicht möglich, eine Heuristik zu finden, die „auf jeden Fall“ ein akzeptables Resultat liefert? Auch hier hat die praktische Informatik Beiträge zu liefern. S. Arora konstruierte 1996 einen „nahezu linearen“ randomisierten Algorithmus für das euklidische TSP, der für eine beliebige Konstante c eine (1+1/c)-Approximation in O(n log(n)O(c)) Zeit konstruiert (http://www.cs.princeton.edu/~arora/pubs/tsp.ps). Das heißt, z.B. für c=10 bekommt man eine Tour, die höchstens 10% schlechter als die optimale ist, indem wir jeden Knoten durchschnittlich log(n)10 mal betrachten. Wie wir oben gesehen haben, ist log(n) „fast“ eine Konstante (in allen praktischen Fällen kleiner als 5). Daher ist dies „fast“ ein linearer Algorithmus. Der Algorithmus basiert auf raffinierten geometrischen Überlegungen, nämlich einer baumartigen Zerlegung der Ebene in Quadrate und einer Normierung der Schnittkanten der Verbindungslinien zwischen den Punkten. 0-14 0.3 Was ist Informatik? Das Wort „Informatik“ ist ein Kunstwort, welches aus den Bestandteilen „Information“ und „Automatik“ zusammengesetzt ist. Der Begriff „Informatique“ wurde 1962 in Frankreich von P. Dreyfuss geprägt und 1968 vom Forschungsminister Stoltenberg in Berlin bei der Eröffnung einer Tagung übernommen (http://zeitung.informatica-feminale.de/?p=72, http://atrax.uni-muenster.de:8010/Studieren/Scripten/Lippe/geschichte/pdf/Kap1.pdf). Da die Informatik also eine vergleichsweise junge Wissenschaft ist, die eine stürmische Entwicklung durchläuft, gibt es die verschiedensten Definitionen davon, was unter Informatik zu verstehen ist. Als Beispiel sei hier die Studienordnung der HU von 2003 angeführt: (1) Die Informatik erforscht die grundsätzlichen Verfahrensweisen der Informationsverarbeitung und die allgemeinen Methoden der Anwendung solcher Verfahren in den verschiedensten Bereichen. Ihre Aufgabe ist es, durch Abstraktion und Modellbildung von speziellen Gegebenheiten sowohl der technischen Realisierung existierender Datenverarbeitungsanlagen als auch von Besonderheiten spezieller Anwendungen abzusehen und dadurch zu den allgemeinen Gesetzen, die der Informationsverarbeitung zugrunde liegen, vorzustoßen sowie Standardlösungen für Aufgaben der Praxis zu entwickeln. Die Informatik befasst sich deshalb mit — der Struktur, der Wirkungsweise, den Fähigkeiten und den Konstruktionsprinzipien von Informations- und Kommunikationssystemen und ihrer technischen Realisierung. — Strukturen, Eigenschaften und BeschreibungsmögIichkeiten von Informationen und von Informationsprozessen, — Möglichkeiten der Strukturierung, Formalisierung und Mathematisierung von Anwendungsgebieten sowie der Modellbildung und Simulation. Dabei spielen Untersuchungen über die Effizienz der Verfahren und über Sinn und Nutzen ihrer Anwendung in der Praxis eine wichtige Rolle. Andere Fachbereiche haben ähnliche Festlegungen des Studieninhaltes. Als minimaler Konsens für den Begriff „Informatik“ kann dabei die Definition angesehen werden, welche sich aus der Wortbedeutung ergibt: Informatik ist die Wissenschaft der automatischen Verarbeitung von Informationen. (Im Buch von Gumm/Sommer: „Informatik ist die Wissenschaft von der maschinellen Informationsverarbeitung“.) In dieser Definition sind ein paar weitere undefinierte Grundbegriffe enthalten: „Information“, „Verarbeitung“, „automatisch“ oder „maschinell“. Der Begriff „Information“ ist ein metaphysischer Grundbegriff, mit dem wir uns noch näher beschäftigen werden. An dieser Stelle sei nur bemerkt, dass wir unter „Information“ eine Beschreibung irgendeines Sachverhaltes der uns umgebenden (materiellen oder ideellen) Welt verstehen wollen. Vom Wortstamm her ist eine „Information“ etwas, was in eine bestimmte Form gebracht worden ist, also auf eine bestimmte Weise repräsentiert wird (wenn wir eine Information zur Kenntnis nehmen, bringen wir unseren Verstand in einen bestimmten Zustand). Sehr einfache, wenig strukturierte Informationen bezeichnen wir als Daten (daher der altertümliche Begriff „EDV“), eine Menge komplexer Informationen über ein zusammenhängendes Gebiet bezeichnen wir als Wissen. Unter der „Verarbeitung“ von Informationen verstehen wir den Prozess der Umformung, d.h. der Veränderung von Informationen aus einer Form in eine andere. Da Informatik sich als Wissenschaft mit der Verarbeitung von Informationen beschäftigt, sind ihr Gegenstand die Verfahren, mit denen diese Umformung bewerkstelligt wird: solche Verfahren nennt man 0-15 Algorithmen. Häufig erfolgt heutzutage der räumliche Transport von Informationen dadurch, dass sie beim Absender in eine einfachere Form gebracht (codiert) werden, durch einen elementaren physikalischen Prozess (elektrische Ströme, Funkwellen etc.) übermittelt und beim Empfänger wieder in die ursprüngliche Form zurückübersetzt (decodiert) werden. Daher betrachtet man heute auch die Erforschung von Techniken zur Übertragung von Informationen als Teilgebiet der Informatik. Der dritte undefinierte Grundbegriff aus der obigen Definition ist „automatisch“. Damit soll ausgedrückt werden, dass sich die Informatik nicht mit der Informationsverarbeitung durch Menschen oder andere Lebewesen beschäftigt, sondern durch Automaten, d.h. vom Menschen konstruierte Maschinen. Daher gehört zur Informatik auch das Wissen um den Aufbau und die Entwicklung von Technologien zur Konstruktion informationsverarbeitender Geräte. Aus historischen Gründen bezeichnet man diese Geräte oft auch als „Rechner“ (numerische Berechnungen waren die ersten automatisierten informationsverarbeitenden Prozesse) oder „Computer“. Daher hat sich im Englischen der Begriff „computer science“ für die Informatik durchgesetzt. Dieser Begriff ist allerdings etwas missverständlich, da er suggerieren könnte, dass Informatik die „Wissenschaft von den informationsverarbeitenden Geräten“ ist. Einige Leute verwenden daher die Bezeichnung „computing science“. Aus der Bestimmung des Gegenstands der Informatik ergibt sich unmittelbar, dass die Informatik viele Bezüge zu anderen Disziplinen hat: Der Gleichklang zum Wort „Mathematik“ ergibt sich nicht von ungefähr. Man kann mit Fug und Recht behaupten, dass die Informatik aus der Mathematik erwachsen ist, so wie die Mathematik ihre Wurzeln in der philosophischen Logik hat. Abgesehen davon, dass die Automatisierung numerischer Berechnungen schon immer ein ureigenstes Interesse der Mathematik war, ist auch die Beschäftigung mit abstrakten Begriffen wie „Berechnungsverfahren“ oder „Umformung“ ein Gegenstand der Mathematik. Viele Pioniere der Informatik (Pascal, Leibniz, Babbage, Turing, von Neumann, …) waren Mathematiker oder Logiker und haben sich mit den theoretischen Grundlagen automatischer Berechnungsverfahren beschäftigt, bevor es überhaupt Computer gab. Die zweite Wurzel der Informatik ist die Elektrotechnik. Erst durch den Einsatz elektrischer Schaltungen und Verfahren nach dem zweiten Weltkrieg (durch Zuse, Aiken und andere) wurde gegenüber den davor existierenden mechanischen Rechnern (Hollerith) ein so großer Durchbruch erzielt, dass man über numerische Rechnungen hinausgehen konnte. Da praktisch alle heute existierenden informationsverarbeitenden Prozesse in Automaten auf der Bewegung von elektrischen Ladungen beruhen, ist klar, dass auch heute noch eine enge Verwandschaft zwischen Informatik und Elektrotechnik besteht. Auf der anderen Seite gehören Computer heute mit zu den wichtigsten strombetriebenen Geräten, weshalb sich die Elektrotechnik heute gerne auch als „Informationstechnik“ bezeichnet, Durch die Inhalte der Informatik ergeben sich weiterhin eine ganze Reihe von Querbezügen zu anderen Disziplinen. Wenn Informationsverarbeitung zur Steuerung mechanischer Geräte, etwa von Robotern oder Fertigungsstraßen, genützt wird, müssen Informatiker mit Maschinenbauern und Produktionstechnikern zusammenarbeiten. Durch den Einsatz von Computern zur Übertragung von Informationen per Funkwellen ist eine enge Beziehung zur Nachrichtentechnik gegeben. Wegen der Notwendigkeit der Interaktion von Automaten und Menschen (auf Benutzungs- und Konstruktionsebene) muss die Informatik auf Grundlagen und Ergebnisse der Psychologie, Linguistik, Kommunikationswissenschaften und anderer Fächer zurückgreifen. Da in fast allen Fächern informationsverarbeitende Prozesse vorkommen, die bislang entweder von Menschen durchgeführt wurden oder wegen des hohen Arbeitsaufwandes gar nicht durchgeführt werden konnten, werden Methoden der Informatik in diesen Fächern für die Automatisierung der Verarbeitung von Informationen angewendet. 0-16 Dadurch haben sich eine Reihe spezialisierter Studiengänge gebildet, die so genannten Bindestrich-Informatiken, die die Anwendung der Informatik in anderen Fächern betonen. Zu nennen sind hier Wirtschafts-Informatik, Bio-Informatik, Medien-Informatik, GeoInformatik, Umwelt-, Rechts- oder Medizininformatik, und viele mehr. Wichtig: Mit einer Ausbildung als Informatiker (ohne Bindestrich) kann man sich später für jede dieser Disziplinen weiter qualifizieren! Aus den geschilderten Wurzeln ergibt sich die heute übliche Struktur der Informatik: Man teilt sie ein in • theoretische Informatik • praktische Informatik • technische Informatik, und • angewandte Informatik. Die theoretische Informatik beschäftigt sich mit den formalen Grundlagen, die praktische Informatik mit den Verfahren, die technische Informatik mit den Maschinen zur Verarbeitung von Informationen. In der angewandten Informatik werden die Anwendungen der Informationsverarbeitung für andere Fächer (z.B. Robotik, Bioinformatik, medizinische Bildverarbeitung) untersucht. Oftmals wird die angewandte Informatik als Teil der praktischen Informatik betrachtet; an einigen Universitäten studiert man im Grundstudium zunächst ein beliebiges Nebenfach und spezialisiert sich dann im Hauptstudium auf die angewandte Informatik in diesem Nebenfach. 0-17 Geschichte der Informatik Informatik im eigentlichen Sinne gibt es erst seit dem Endes des zweiten Weltkrieges. Die Wurzeln der Informatik reichen dagegen bis ins Mittelalter bzw. ins Altertum zurück: • • • • • • • • • • • • • • • 300 v. Chr: Euklid entwickelt sein Verfahren zur Bestimmung des größten gemeinsamen Teilers (ggT) um 820: Al-Chwarizmi fasst in einem Buch Lösungen zu bekannten mathematischen Problemen zusammen. 1524: A. Riese veröffentlicht ein Buch über die Grundrechenarten 17-18.Jh.: G. W. Leibniz (1646-1714) entwickelt das Dualsystem (1679) und baut eine Rechenmaschine (1673/1694), Pascal, Schickard u.a. entwickeln ebenfalls mechanische Rechenmaschinen, Babbage konzipiert „difference engine“, Ada Lovelace die erste Programmiersprache dafür Ende 19./ Anf. 20. Jh.: Formalisierung der logischen und mathematischen Grundlagen durch Frege (“Begriffschrift”, 1879), Russell u. Whitehead (“Prinicipia mathematica", 1910-13), Peano u.a. Ende 19./ Mitte 20. Jh.: Perfektionierung mechanischer Rechenmaschinen; 1930-40: Theorie der Berechenbarkeit, Vollständigkeits- und Entscheid-barkeitssätze (Gödel, Turing, Tarski, Church, Kleene, Post, Markov u.a.) 1930-40: erste elektromechanische Computer: Zuses Z1 (1936), Z3 (1943), Aikens Mark1 (1944), Eckert+Mauchlys ENIAC (1946) 1948-49: Konrad Zuse entwickelt seinen “Plankalkül”, C. Shannon seine “Informationstheorie”, J. v. Neumann entwickelt den nach ihm benannten Rechnertyp: Daten und Befehle werden gemeinsam im Rechner gespeichert und ähnlich behandelt. 1955: Erfindung des Transistors 1959-60: erste “höhere Programmiersprachen”: J. McCarthy entwickelt die funktionale Programmiersprache LISP und begründet die “Artificial Intelligence”, Fortran (Masch.bau), Cobol (BWL) und Algol (Math) werden definiert. 1969-70: Entwicklung universaler Programmiersprachen wie Algol68 und PL/I ab 1970: Informatikstudium in Deutschland ab 1980: Objektorientierte Sprachen und Systeme ab 1990: Internet (Gopher, Mosaic), Mobilfunk (1991 D-Netz, 1994 E-Netz), WLAN (ca. 1995), PDAs (1997), Java (1995), Java2 (2002) Die Informatik ist heute in fast alle Aspekte unseres Lebens vorgedrungen: • Einen Großteil ihres Studiums werden Sie mit „e-Learning“ verbringen, die notwendigen Informationen beschaffen Sie sich im Internet. Vielleicht bestellen Sie hier auch Waren oder vergleichen zumindest bei eBay die Preise. • Dokumente (Briefe, Steuererklärungen usw.) verfassen Sie natürlich am Computer; mit Ihren Kommilitonen nebenan und dem Onkel in Amerika tauschen Sie e-Mails aus, die den Empfänger in wenigen Sekunden erreichen. • Wahrscheinlich haben Sie auch ein Mobiltelefon, vielleicht sogar ein Notebook mit WLAN, oder einen elektronischen Organizer. • Ihr Bankkonto wird von einem Computer geführt, Bargeld holen Sie am Geldautomaten, vielleicht habe Sie auch eine elektronische Brieftasche. • Wenn Sie mit dem Auto nach Hause fahren, begleiten Sie bis zu 80 eingebaute Steuergeräte, das Auto ist weitgehend von Robotern gebaut worden. Sogar die Schnittmuster Ihrer Kleidung wurden vom Computer optimiert. • Ihre Armbanduhr, Foto- oder Filmapparat, Ton- und Bildwiedergabegeräte sind schon längst nicht mehr mechanisch, ganz zu schweigen von der Türschließanlage, Fahrstuhlsteuerung, Kühlschrank, Mikrowelle, Waschmaschine, und anderen Geräten zu Hause. 0-18 Das ist aber keinesfalls das Ende der Entwicklung. In ein paar Jahren wird Sie wahrscheinlich die Türschließanlage an Ihrem Aussehen und Fingerabdruck erkennen, Sie werden sich vielleicht wie im Roman „per Anhalter durch die Galaxis“ mit dem Fahrstuhl unterhalten, der Kühlschrank könnte Vorräte selbsttätig nachbestellen, der Herd sich Rezepte aus dem Internet holen und die Waschmaschine wissen, wie heiß die Wäsche gewaschen werden muss. Alle diese „Wunder der Technik“ werden möglich durch systematische Vorschriften für die Verarbeitung von Informationen (Algorithmen, Programme) und Maschinen, die diese Vorschriften ausführen können (Computer, Prozessoren). Natürlich können wir uns in einer Vorlesung nicht mit allen oben genannten Anwendungen beschäftigen, aber die zentralen Gesichtspunkte die in allen gleichermaßen vorhanden sind, bilden den Gegenstand der Vorlesung: Algorithmen und ihre Ausführung auf Rechenanlagen. Das zentrale Ziel der Vorlesung ist es, eine „algorithmische Denkweise“ zu vermitteln: Ein Verständnis dafür, wann und wie ein (informationsbezogenes) Problem mit welchem Aufwand durch eine Maschine gelöst werden kann. Inhaltlich gibt die Vorlesung einen Überblick über das Gebiet der praktischen Informatik. Dazu gehören unter anderem folgende Themen: • Repräsentation von Informationen in Rechenanlagen • programmiersprachliche Konzepte • Methoden der Softwareentwicklung • Algorithmen und Datenstrukturen • Korrektheit und Komplexität von Programmen In den weiteren Vorlesungen des Bachelor-Studiums werden diese Themen ergänzt und vertieft. 0-19 Kapitel 1: Mathematische Grundlagen 1.1 Mengen, Multimengen, Tupel, Funktionen, Halbordnungen Eine Menge ist eine Zusammenfassung von (endlich oder unendlich vielen) verschiedenen Dingen unserer Umwelt oder Vorstellungswelt, welche Elemente dieser Menge genannt werden. Wir schreiben x ∈ M , um auszusagen, dass das Ding x Element der Menge M ist. Andere Sprechweisen: x ist in M enthalten, oder M enthält x . Um auszudrücken, dass x nicht in M enthalten ist, schreiben wir x ∉ M . Die Schreibweise x1 ,..., x n ∈ M steht für x1 ∈ M und … und x n ∈ M , und {x1 ,..., x n } ist die Menge, die genau die Elemente x1 ,..., x n enthält. Beispiele für Mengen sind: • N: Die Menge der natürlichen Zahlen 1,2,3,… • N0: Die Menge der Kardinalzahlen (natürlichen Zahlen einschließlich der Null): 0,1,2,3,… • Z: Menge der ganzen Zahlen …, -3, -2, -1, 0, 1, 2, 3, … • Q, R: Menge der rationalen bzw. reellen Zahlen • • ∅ oder { }: leere Menge B oder boolean = {true, false} oder {1,0} oder {tt, ff} oder {w, f} oder {L,O}: Menge der Wahrheitswerte {Adam, Eva}: Menge der ersten Menschen {A, B, C, …, Z}: Menge der Großbuchstaben im lateinischen Alphabet • • In den Programmiersprachen Groovy und Java gibt es die folgenden vordefinierten Mengen: • Integer oder int: {-2147483648 … 2147483647} • Short: {-32768, …, 32767} • Byte: {-128, …, -127} Menge der ganzen Zahlen zwischen -128 und 127 • Long: {-9223372036854775808, …,-9223372036854775808} (Postfix „L“) • BigInteger: „große“ natürliche Zahlen (Postfix „g“, z.B. 45g) • Dezimalzahlen, Gleitkommazahlen, • Characters und Strings, Boolean Die Menge M 1 ist eine Teilmenge von M 2 ( M 1 ⊆ M 2 ), wenn jedes Element von M 1 auch Element von M 2 ist. Die Mengen M 1 und M 2 sind gleich ( M 1 = M 2 ), wenn sie die gleichen Elemente enthalten (Extensionalitätsaxiom). Für jede (endliche) Menge M bezeichnet | M | die Kardinalität, d.h. die Anzahl ihrer Elemente. Neben der Aufzählung ihrer Elemente können Mengen durch eine charakterisierende Eigenschaft gebildet werden (Komprehensionsaxiom). Beispiel: byte = {x∈ Z | -128≤x und x≤127}. Auf diese Weise können auch unendliche Mengen gebildet werden. Die unbeschränkte Verwendung der Mengenkomprehension kann zu Schwierigkeiten führen (Russells Paradox: „die Menge aller Mengen, die sich nicht selbst enthalten“), daher erlaubt man in der axiomatischen Mengenlehre nur gewisse Eigenschaften. Auf Mengen sind folgende Operationen definiert: Durchschnitt: M 1 ∩ M 2 = {x | x ∈ M 1 ∧ x ∈ M 2 } . Vereinigung: M 1 ∪ M 2 = {x | x ∈ M 1 ∨ x ∈ M 2 } . 1-20 - Differenz: M 1 − M 2 = {x | x ∈ M 1 ∧ x ∉ M 2 } . Beispiele: N∪N0=N0 , N∩N0=N , N – N0 =∅, N0 – N =0. Durchschnitt und Vereinigung sind kommutativ und assoziativ. Daher kann man diese Operationen auf beliebige Mengen von Mengen ausweiten: {M 1 , M 2 ,..., M n } = {x | x ∈ M 1 ∧ x ∈ M 2 ∧ ... ∧ x ∈ M n } . - i∈I M i = {x | x ∈ M i für ein i ∈ I } . Von Mengen kommt man zu „Mengen höherer Ordnung“ durch die Potenzmengenbildung: Wenn M eine Menge ist, so bezeichnet ℘(M ) oder 2 M die Menge aller Teilmengen von M . Cantor bewies, dass die Potenzmenge einer Menge immer mehr Elemente enthält als die Menge selbst. Speziell gilt für jede endliche Menge M : Wenn | M |= n , so ist | ℘( M ) |= 2 n . Beispiel: ℘(∅) = {∅} , ℘({∅}) = {∅, {∅}} und ℘({ A, B, C}) = {∅, { A}, {B}, {C}, { A, B}, { A, C}, {B, C}, { A, B, C}} . Multimengen Während Mengen die grundlegenden Daten der Mathematik sind, hat man es in der Informatik oft mit Multimengen zu tun, bei denen Elemente „mehrfach“ vorkommen können. Beispiele sind • eine Tüte mit roten, gelben und grünen Gummibärchen, • die Vornamen der Studierenden dieser Vorlesung, • die Multimenge der Buchstaben eines bestimmten Wortes, usw. Multimengen können notiert werden, indem man zu jedem Element die entsprechende Vielfachheit angibt, z.B. ist {A:3, B:1, N:2} die Multimenge der Buchstaben im Wort BANANA. Formal können Multimengen definiert werden als Funktionen von einer Grundmenge in die Menge N0 der Kardinalzahlen, siehe unten. Folgen Aus Mengen lassen sich durch Konkatenation Tupel und Folgen bilden. Der einfachste Fall ist dabei die Paarbildung mit dem kartesischen Produkt. Wenn M 1 und M 2 Mengen sind, so bezeichnet M 1 × M 2 = {( x, y ) | x ∈ M 1 ∧ y ∈ M 2 } die Menge aller Paare von Elementen, deren erster Bestandteil ein Element aus M 1 und deren zweiter eines aus M 2 ist. Da die runden Klammern für vielerlei Zwecke verwendet werden, verwendet man manchmal zur Kennzeichnung von Paaren auch spitze oder, besonders in Programmiersprachen, eckige Klammern. Beispiele: N×B ={(1,tt),(1,ff),(2,tt),(2,ff), (3,tt),…}, N×∅=∅, N×{0}={(1,0), (2,0), (3,0), …}, N×N0={(1,0), (1,1), (1,2), …, (2,0), (2,1), …} Eine Verallgemeinerung ist das n-stellige kartesische Produkt, mit dem n-Tupel gebildet werden: M 1 × M 2 × ⋅ ⋅ ⋅ × M n = {( x1 , x 2 ,..., x n ) | x1 ∈ M 1 ∧ x 2 ∈ M 2 ∧ ... ∧ x n ∈ M n } 2-Tupel sind also Paare, statt 3-, 4- oder 5-Tupel sagt man auch Tripel, Quadrupel, Quintupel usw. Die zur Produktbildung umgekehrten Operationen, mit denen man aus einem Produkt die einzelnen Bestandteile wieder erhält, bezeichnet man als Projektionen: Π i ( x1 , x 2 ,..., x n ) = xi Falls alle M i gleich sind ( M 1 = M 2 = ⋅ ⋅ ⋅ = M n = M ) , so schreiben wir statt M × M × ⋅ ⋅ ⋅ × M auch M n und nennen ( x1 , x 2 ,..., x n ) eine Folge oder Sequenz der Länge n 1-21 über M . In Programmiersprachen heißen Folgen auch Arrays, Felder oder Reihungen. Wichtige Spezialfälle sind n = 1 und n = 0 . Im ersten Fall ist die einelementige Folge (x) etwas anderes als das Element x. Im zweiten Fall ist die leere Folge () unabhängig von der verwendeten Grundmenge. Achtung: Die leere Folge ist nicht zu verwechseln mit der leeren Menge! M n enthält nur Sequenzen einer bestimmten fest vorgegebenen Länge n. Unter M * verstehen wir die Menge, die alle beliebig langen Folgen über M enthält: M * = {M i | i ∈ N 0 } . Wenn wir nur nichtleere Folgen betrachten wollen, schreiben wir M + : M+ = M* −M0 . Eine Liste in der Programmiersprache Groovy ist ein Element von M * , wobei M die Menge aller Objekte (Zahlen, Buchstaben, Listen, …) ist. Beispiele: [1, 3, 5, 7] oder [17.5, "GP", 1.234f, 'a', 1e99] Hier sind einige Groovy-Tatsachen zu Listen (http://groovy.codehaus.org/JN1015Collections). In Java können diese Listen mit dem public interface List nachgebildet werden (http://java.sun.com/j2se/1.4.2/docs/api/java/util/List.html). assert [0, "a", 3.14].class == java.util.ArrayList assert [0, 4, 7] + [11] == [0, 4, 7, 11] assert [0, 4, 7] - [4] == [0, 7] assert [0, "a", 3.14] * 2 == [0, "a", 3.14, 0, "a", 3.14] assert [0, 4, 7] != [0, 7, 4] assert [] != [0] assert [] != [[]] assert [[],[]] != [[]] assert [[],[]].size == 2 assert [0,4,7][2] == 7 x=[0,4,7]; assert x[2] == 7 x=[0,4,7]; x[2]=3; assert x == [0,4,3] x=[0,4,7]; x[2]=3; x[5]=2; assert x == [0, 4, 3, null, null, 2] x=[0,4,7]; assert x.contains(4) x=[0,4,7]; assert (7 in x) x=[0,4,7]; x.each{println(it + " sq = " + (it*it))} Mengen können als spezielle (ungeordnete) Listen aufgefasst werden, bei denen jedes Element nur einmal vorkommt und die Reihenfolge egal ist: Set x=[0,4,7], y= [7,4,0]; assert x == y Set z=[0,4,7,4,0]; assert z == y Relationen und Funktionen Eine Relation zwischen zwei Mengen M 1 und M 2 ist eine Teilmenge von M 1 × M 2 . Wenn zum Beispiel M={Anna, Beate} und J={Claus, Dirk, Erich}, so ist liebt={(Anna, Claus), (Beate, Dirk), (Beate, Erich)} eine Relation zwischen M und J. Relationen schreibt man meist in Infixnotation, d.h., statt (Beate, Dirk)∈liebt schreibt man (Beate liebt Dirk). Falls M 1 = M 2 = M , so sagen wir, dass die Relation über M definiert ist. Typische Beispiele sind die Relationen ≤ und = über den natürlichen Zahlen, oder die Verbindungsrelation zwischen Städten im Streckennetz der Air Berlin. Eine Relation R heißt (links-)total, wenn es zu jedem x ∈ M 1 ein y ∈ M 2 mit (xRy ) gibt. Sie heißt (rechts-)eindeutig, wenn es zu jedem 1-22 x ∈ M 1 höchstens ein y ∈ M 2 mit (xRy ) gibt. Eine eindeutige Relation nennt man Abbildung oder partielle Funktion, eine totale und eindeutige Relation heißt Funktion. Bei Funktionen schreiben wir f : M 1 → M 2 und f ( x) = y für f ⊆ M 1 × M 2 und ( x, y ) ∈ f . Die Menge der x ∈ M 1 , für die es ein y ∈ M 2 mit (xRy ) gibt, heißt der Definitionsbereich oder Urbildbereich (domain) der Abbildung; die Menge der y ∈ M 2 , für die es ein x ∈ M 1 mit (xRy ) gibt, heißt der Wertebereich oder Bildbereich (range) der Funktion oder Abbildung. Eine Funktion mit endlichem Definitionsbereich lässt sich angeben durch Auflistung der Menge der Paare (x,y) mit f ( x) = y . In Groovy nennt man eine solche Funktion Map und notiert sie [x1:y1, x2:y2,....,xn:yn], also z.B. [1:5, 2:10, 3:15] oder ["Name":"Anton", "id":573328], aber auch [3.14:'a', "a":2010, 10:10, 7e5:0] Beachte: [1:5, 2:10, 3:15] == [2:10, 3:15, 1:5] Hier wieder einige Groovy Tatsachen über Maps (http://groovy.codehaus.org/JN1035-Maps): assert [A:3, B:1, N:2] assert [A:3, B:1, N:2].B == 1 assert [A:3, B:1, N:2] == [B:1, A:3, N:2] assert [A:3, B:1, N:2] + [C:5] == [A:3, B:1, N:2, C:5] assert [A:3, B:1, N:2] + [A:1] == [A:1, B:1, N:2] // [A:3, B:1, N:2] - [A:1] ist undefiniert // [97:"a", 98:"b", 99:"c"].98 == 'b' ist ein Syntaxfehler assert [97:"a", 98:"b", 99:"c"].get(98) == "b" assert [97:"a", 98:"b", 99:"c"].get(100) == null x=[:]; x[97]="a"; x[98]="b"; assert x == [97:"a", 98:"b"] Genau wie oben lassen sich auch die Begriffe Relation und Funktion verallgemeinern. Eine nstellige Relation zwischen den Mengen M 1 , M 2 ,⋅ ⋅ ⋅, M n ist eine Teilmenge von M 1 × M 2 × ⋅ ⋅ ⋅ × M n . Einstellige Relationen heißen auch Prädikate. Auch für Prädikate schreiben wir (Px) anstatt von (x) ∈ P. Eine n-stellige Funktion f von M 1 , M 2 ,⋅ ⋅ ⋅, M n nach M ist eine (n+1)-stellige Relation zwischen M 1 , M 2 ,⋅ ⋅ ⋅, M n und M , so dass für jedes nTupel ( x1 , x 2 ,..., x n ) ∈ M 1 × M 2 × ⋅ ⋅ ⋅ × M n genau ein y ∈ M existiert mit ( x1 , x 2 ,..., x n , y ) ∈ f . Eine Funktion f : M × M × ⋅ ⋅ ⋅ × M → M heißt (n-stellige) Operation auf M . Beispiele für zweistellige Operationen sind + und * auf N, Z, Q und R. Die Differenz – ist auf Z, Q und R eine Operation, auf N ist sie nur partiell (nicht total). Die Division ist in jedem Fall nur partiell. Typische Prädikate auf natürlichen Zahlen sind prim oder even. Wir haben Funktionen als spezielle Relationen definiert, Es gibt auch die Auffassung, dass der Begriff „Funktion“ grundlegender sei als der Begriff „Relation“, und dass Relationen eine spezielle Art von Funktionen sind. Sei M 1 ⊆ M 2 . Dann ist die charakteristische Funktion ζ : M2 → B von M 1 in M 2 definiert durch ζ (x) = true falls x ∈ M 1 und ζ (x) = false falls x ∉ M 1 . Mit Hilfe der charakteristischen Funktion kann jede Relation zwischen den Mengen M 1 , M 2 ,⋅ ⋅ ⋅, M n als Funktion von M 1 , M 2 ,⋅ ⋅ ⋅, M n nach B aufgefasst werden. Diese Auffassung findet man häufig in Programmiersprachen, bei denen Prädikate als boolesche Funktionen realisiert werden. 1-23 Ordnungen Die Relation R über M heißt • reflexiv, wenn für alle x ∈ M gilt, dass xRx . • irreflexiv, wenn für kein x ∈ M gilt dass xRx . • transitiv, wenn für alle x, y, z ∈ M mit xRy und yRz gilt, dass xRz . • symmetrisch, wenn für alle x, y ∈ M mit xRy gilt, dass yRx . • antisymmetrisch, wenn für alle x, y ∈ M mit xRy und yRx gilt x = y . Eine reflexive, transitive, symmetrische Relation heißt Äquivalenzrelation. Eine reflexive, transitive, antisymmetrische Relation heißt Halbordnung oder partielle Ordnung. Eine irreflexive, transitive, antisymmetrische Relation heißt strikte Halbordnung. Eine partielle Ordnung heißt totale oder lineare Ordnung, wenn für alle x, y ∈ M gilt, dass xRy oder yRx . Bei einer totalen Ordnung lassen sich alle Elemente „der Reihe nach“ anordnen. Die Relation ≤ ist eine totale Ordnung auf natürlichen und reellen Zahlen, nicht aber auf komplexen Zahlen. Ein einfacheres Beispiel für eine Halbordnung über Zahlen ist die Relation „ist Teiler von“. Beispiel für reflexive, aber nicht antisymmetrische Relation: Beispiel für eine antisymmetrische, aber nicht reflexive Relation: 1.2 Induktive Definitionen und Beweise Für fast alle in der Informatik wichtigen Datentypen besteht ein direkter Zusammenhang zwischen ihrer rekursiven Definition (ihrem rekursiven Aufbau) und induktiven Beweisen von Eigenschaften dieser Daten. Wir wollen uns diese Dualität am Beispiel der natürlichen Zahlen betrachten. Die natürlichen Zahlen lassen sich durch die folgenden so genannten Peano-Axiome definieren: • 1 ist eine natürliche Zahl. • Für jede natürliche Zahl gibt es genau eine natürliche Zahl als Nachfolger. • Verschiedene natürliche Zahlen haben auch verschiedene Nachfolger. • 1 ist nicht der Nachfolger irgendeiner natürlichen Zahl. • Sei P eine Menge natürlicher Zahlen mit folgenden Eigenschaften: o 1 ist in P o Für jede Zahl in P ist auch ihr Nachfolger in P. Dann enthält P alle natürlichen Zahlen. Das letzte dieser Axiome ist das so genannte Induktionsaxiom. Es wird oft in der Form gebraucht, dass P eine Eigenschaft natürlicher Zahlen ist: • Sei P eine Eigenschaft natürlicher Zahlen, so dass P(1) gilt und aus P(i) folgt P(i+1). Dann gilt P für alle natürlichen Zahlen. Der erste Mathematiker, der einen formalen Beweis durch vollständige Induktion angab, war der italienische Geistliche Franciscus Maurolicus (1494 -1575). In seinem 1575 veröffentlichten Buch „Arithmetik“ benutzte Maurolicus die vollständige Induktion unter 1-24 anderem dazu, zu zeigen, dass alle Quadratzahlen sich als Summe der ungeraden Zahlen bis zum doppelten ihrer Wurzel ergeben: 1 + 3 + 5 + ... + (2n-1)=n*n Beweis: Sei P die Menge natürlicher Zahlen, die diese Gleichung erfüllen. Um zu beweisen, dass P alle natürlichen Zahlen enthält, müssen wir zeigen 1=1*1 Wenn 1 + 3 + 5 + ... + (2n-1)=n*n, dann 1 + 3 + 5 + ... + (2n-1)+(2(n+1)-1)=(n+1)*(n+1) Die Wahrheit dieser Aussagen ergibt sich durch einfaches Ausrechnen. Hier ist ein geringfügig komplizierteres Beispiel zum selber machen: Die Summe der Kubikzahlen bis n ist das Quadrat der Summe der Zahlen bis n. 2 n = i ∑i ∑ i =1 i =1 Ein wichtiger Gesichtspunkt beim Induktionsaxiom ist, dass die natürlichen Zahlen als „induktiv aufgebaut“ dargestellt werden gemäß den folgenden Regeln: n 3 • 1∈N. • Wenn i∈N, dann auch i+1∈N. • Außer den so erzeugten Objekten enthält N keine weiteren Zahlen. Mit anderen Worten, jede Zahl wird erzeugt durch die endlich-oft-malige Anwendung der Operation (+1) auf die Zahl 1. Das Induktionsaxiom erlaubt es, Funktionen über den natürlichen Zahlen rekursiv zu definieren. Eine rekursive Definition nimmt dabei auf sich selbst Bezug. Solche Definitionen können leicht schief gehen („das Gehalt berechnet sich immer aus dem Gehalt des letzten Jahres plus 3%“ oder „Freiheit ist immer die Freiheit der Andersdenkenden“ oder „GNU is short for »GNU is Not Unix«“). Ein Begriff, der durch solch eine zirkuläre Definition erklärt wird, ist nicht wohldefiniert. Eine Funktion ist nur dann wohldefiniert, wenn sich der Funktionswert eindeutig aus den Argumenten ergibt. Das Prinzip der vollständigen Induktion erlaubt es nun, eine Funktion über den natürlichen Zahlen dadurch zu deklarieren, dass man den Funktionswert für n=1 angibt, und indem man zeigt, wie sich der Funktionswert für (n+1) aus dem Funktionswert für n berechnen lässt. Wenn man nämlich für P die Aussage „der Funktionswert ist eindeutig bestimmt“ einsetzt, so besagt dass Induktionsprinzip, dass dann der Funktionswert für alle natürlichen Zahlen eindeutig bestimmt ist. Zum Beispiel lässt sich die Fakultätsfunktion n!=1*2*…*n ohne „Pünktchen“ dadurch definieren, dass wir festlegen • 1! = 1 • Wenn n!=x, dann ist (n+1)!=(n+1)*x Eine andere Schreibweise der zweiten Zeile ist • (n+1)!=(n+1)*n! Da in dieser Formel „n“ nur ein Stellvertreter für eine beliebige Zahl ist, können wir auch schreiben • Wenn n>1, dann ist n!=n*(n-1)! Diese Schreibweise ist sehr nahe an der Schreibweise in Programmiersprachen, beispielsweise (in Groovy): • def fac(n){ if (n==1) return(1) else return(n*fac(n-1)) } • def fac(n){ (n==1)? 1 : n*fac(n-1) } In der vorgestellten Fassung erlaubt es das Induktionsprinzip nur, bei der Definition von f(n) auf den jeweils vorherigen Wert f(n-1) zurückzugreifen. Eine etwas allgemeinere Fassung ist das Prinzip der transfiniten Induktion: 1-25 • Sei P eine Eigenschaft natürlicher Zahlen, so dass für alle x∈N gilt: Falls P(y) für alle y<x, so auch P(x). Dann gilt P für alle natürlichen Zahlen. Dieses Prinzip gilt nicht nur für die natürlichen Zahlen, sondern für beliebige fundierte Ordnungen (in denen es keine unendlich langen absteigenden Ketten gibt). Der Induktionsanfang ergibt sich dadurch, dass es keine kleinere natürliche Zahl als 1 gibt und für die 1 daher nichts vorausgesetzt werden kann. Im Induktionsschritt erlaubt uns dieses Prinzip, auf beliebige vorher behandelte kleinere Zahlen zurückzugreifen. Das Standardbeispiel sind hier die Fibonacci-Zahlen (nach Leonardo di Pisa, filius Bonacci, 1175-1250, der das Dezimalsystem in Europa einführte) 1, falls n ≤ 2 fib(n) = fib(n − 1) + fib(n − 2), sonst oder, in der programmiersprachlichen Fassung (in Groovy), def fib(n){ (n<=2)? 1 : fib(n-1) + fib(n-2) } Die Werte dieser Funktion sind, der Reihe nach, 1,1,2,3,5,8,13,21,… und sollen das Bevölkerungswachstum von Kaninchenpaaren nachbilden. Als Beispiel für einen Beweis, der auf mehrere Vorgänger zurückgreift, zeigen wir die Formel von Binet: n n 1 1 + 5 1 − 5 − . fib(n) = 5 2 2 1+ 5 1− 5 Als Lemma benötigen wir, dass für ξ = gilt ξ + 1 = ξ 2 , und ebenso für ς = . 2 2 Das sieht man durch einfaches Ausrechnen, ebenso die Gültigkeit der Aussage für n=1,2. 1 n −1 ξ − ς n −1 und Damit können wir als Induktionsannahme voraussetzen, dass fib(n − 1) = 5 1 n−2 ξ − ς n − 2 . Mit fib(n) = fib(n − 1) + fib(n − 2) ergibt sich fib(n − 2) = 5 1 n −1 fib(n) = ξ + ξ n − 2 − ς n −1 − ς n − 2 , d.h., 5 1 1 2 n−2 1 n fib(n) = ξ ξ − ς 2ς n − 2 = ξ − ς n , was zu zeigen (ξ + 1)ξ n − 2 − (ς + 1)ς n − 2 = 5 5 5 war. [ [ ] ] [ ] [ ] [ ] [ ] Die Berechnung der Fibonacci-Zahlen mit der Formel von Binet geht erheblich schneller als mittels der rekursiven Definition: def binet(n){ ((((1 + Math.sqrt(5))/2)**n - ((1-Math.sqrt(5))/2)**n)/Math.sqrt(5))} binet(50) ergibt sofort 1.258626902500002E10 Im Allgemeinen ist es nicht immer möglich, solch eine geschlossene (nichtrekursive) Formel für eine rekursiv definierte Funktion zu finden. Wir haben das Induktionsprinzip für natürliche Zahlen und der fundierten Ordnungsrelation < angewendet. Dies ist nur ein Spezialfall des folgenden allgemeinen Prinzips für induktiv erzeugte Mengen. Das sind Mengen, die definiert werden durch 1-26 • • die explizite Angabe gewisser Elemente der Menge, Regeln zur Erzeugung weiterer Elemente aus schon vorhandenen Elementen der Menge sowie der expliziten oder impliziten Annahme, dass die Menge nur die so erzeugten Elemente enthält. Für induktiv erzeugte Mengen gilt folgendes allgemeine Induktionsprinzip: Sei P eine Eigenschaft, die Elemente der Menge haben können oder nicht, so dass • P für alle explizit angegebenen Elemente der Menge gilt, und • P für alle gemäß den Bildungsregeln erzeugten Elemente gilt, falls es für die bei der Erzeugung verwendeten Elemente gilt. Dann gilt P für alle Elemente der Menge. Beispiele für dieses Erzeugungsprinzip werden später betrachtet. 1.3 Alphabete, Wörter, Bäume, Graphen Unter einem Alphabet A versteht man eine endliche Menge von Zeichen A={a1, …, an}. Das bekannteste Beispiel ist sicher das lateinische Alphabet mit den Zeichen A, B, C, …, Z. Aber bereits davon gibt es verschiedene Varianten, man denke nur an das deutsche Alphabet mit Umlauten ä, ö, ü und der Ligatur ß. Im Laufe der Zeit haben sich bei den Völkern Hunderte von Alphabeten gebildet, von Keilschriften und Hieroglyphen bis hin zu Runen- und Geheimschriften (http://www.schriftgrad.de/). Das chinesische Alphabet umfasst etwa 56000 Zeichen, im Alltag kann man mit 6.000 Schriftzeichen schon relativ gut auskommen; der chinesische Zeichensatz für Computer enthält 7.445 Schriftzeichen. Der ASCII-Zeichensatz enthält 128 bzw. (in der erweiterten Form Latin-1) 256 Druckzeichen, siehe Tabelle. Der Unicode- oder UCS-Zeichensatz umfasst etwa 100.000 Zeichen http://de.wikipedia.org/wiki/Unicode. Man beachte, dass in manchen Alphabeten das Leerzeichen als ein Zeichen enthalten ist; als Ersatzdarstellung wählt man häufig eine Unterstrich-Variante. Ein für die Informatik wichtiges Alphabet ist die Menge B der Wahrheitswerte. Eine (endliche) Folge w∈A* von Zeichen über einem Alphabet A heißt Wort oder Zeichenreihe (string) über A. Normalerweise schreibt man, wenn es sich um Wörter handelt, statt w = (a1,a2,…,an) kurz w = "a1a2…an", manchmal werden die Anführungszeichen auch weggelassen. Die leere Zeichenreihe wird mit dem Symbol ε oder mit "" bezeichnet. Über Wörtern ist die Konkatenation (Hintereinanderschreibung) als Operation definiert: Wenn v = a1a2…an und w = b1b2…bm, dann ist v°w = a1a2…anb1b2…bm. Da die Operation ° assoziativ ist ((u ° v) ° w = u ° (v ° w)), wird das Operationssymbol ° manchmal auch einfach weggelassen.Die leere Zeichenreihe ε ist bezüglich ° ein neutrales Element (w ° ε = ε ° w = w). 1-27 Eine Menge mit einer assoziativen Operation und einem neutralem Element nennt man auch Monoid; da die Menge A* (mit der Operation ° und dem neutralen Element ε ) keinen weiteren Einschränkungen unterliegt, heißt sie auch der freie Monoid über A (wenn a1a2…an = b1b2…bm, mit (ai, bi ∈A), so ist n=m und a1= b1 und… und an= bn). Die Menge der Wörter über einem gegebenen Alphabet lässt sich auch induktiv erzeugen: • ε ∈A* • Wenn a∈A und w∈A*, so ist (a°w)∈A*. Hierbei bezeichnet (a°w) diejenige Zeichenreihe, die als erstes Zeichen a enthält und danach das Wort w. Alternativ dazu hätten wir Wörter induktiv durch das Anfügen (append) von Zeichen an Zeichenreihen erzeugen können. Diese Charakterisierung der Menge der Zeichenreihen erlaubt es, induktive Beweise zu führen und rekursive Funktionen über Wörtern zu definieren. Sei first(w) die partielle Funktion, die zu einem nichtleeren Wort dessen erstes Zeichen liefert, und rest(w) die Funktion, die das Wort ohne das erste Zeichen liefert. Programmiersprachlich etwa def first(w){w[0]} def rest(w){w.substring(1)} Hier sind ein paar rekursiv definierte Funktionen über Wörtern. def laenge(w){if(w=="") return(0) else return(1+laenge(rest(w)))} liefert die Länge eines Wortes. In Groovy/Java schreibt man "" für die leere Zeichenreihe ε, und + für das Konkatenationssymbol °. def invertiere(w){ if(w=="") return(w) else return(invertiere(rest(w))+first(w))} liefert das umgedrehte Wort, also etwa ’negaldnurG’ zu ’Grundlagen’. def ersetze(w,a,b){ if(w=="") return(w) else if(first(w)==a) return(b+ersetze(rest(w),a,b)) else return(first(w)+ersetze(rest(w),a,b))} ersetzt jedes a in w durch b, z.B. ergibt ersetze("hallo",'l','r')=="harro". Wir werden später noch ähnliche solche Funktionen kennen lernen. Wenn w=u°v, so sagen wir, dass u ein Anfangswort von w ist. Wenn w=v1°u°v2, so nennen wir u ein Teilwort von w. Auch die Anfangswortrelation lässt sich leicht induktiv definieren: def anfangswort(w,u){ if(u=="") return(true) else if(first(w)!= first(u)) return(false) else return(anfangswort(rest(w), rest(u)))} Auf Alphabeten ist häufig eine totale Ordnungsrelation erklärt; meist wird diese durch die Reihenfolge der Aufschreibung der Zeichen unterstellt. Wenn A ein Alphabet mit einer totalen Ordnungsrelation ≤ ist, so kann ≤ zur lexikographischen Ordnung auf A* ausgeweitet werden: Sei x= x1x2…xn und y= y1y2…ym. Dann gilt x≤ y, wenn 1-28 • x ein Anfangswort von y ist, oder • es gibt ein Anfangswort z von x und y (x=z°x’, y=z°y’) und first(x’) <first(y’). (a < b bedeutet a ≤ b und nicht a = b) Beispiele für lexikographisch geordnete Wörter über dem lateinischen Alphabet sind "ANTON" < "BERTA", "AACHEN" < "AAL", "AAL" < "AALBORG" und ε < "A". Es ist nicht schwer zu sehen, dass die lexikographische Ordnung eine totale Ordnung ist. Die Buchstaben des deutschen Alphabets sind nicht linear geordnet (a und ä stehen nebeneinander, ß ist nicht eingeordnet), daher entspricht die Reihenfolge der Wörter in einem deutschen Lexikon nicht der lexikographischen Ordnung. Bäume Bäume sind – neben Tupeln, Folgen und Wörtern – eine weitere in der Informatik sehr wichtige Datenstruktur. In der induktiven Definition von Zeichenreihen besteht ein Wort w aus der Konkatenation von first(w) mit rest(w). Ein Binärbaum ist dadurch gekennzeichnet, dass es zwei verschiedene „Reste“ gibt: den linken und den rechten Unterbaum. Daraus ergibt sich folgende induktive Definition der Menge der Binärbäume über einem gegebenen Alphabet A: • ε ∈A^ • Wenn a∈A und l∈A^ und r∈A^, so ist (a,l,r)∈A^. a heißt Wurzel, l und r sind Unterbäume des Baumes (a,l,r). Die Wurzeln von l und r heißen die Kinder oder Nachfolger von a. Ein Baum y ist Teilbaum eines Baumes x, wenn x=y oder y Teilbaum eines Unterbaumes von x ist. Wenn y nichtleerer Teilbaum von x ist, so sagen wir, die Wurzel von y ist ein Knoten von x. Ein Knoten ohne Nachfolger (d.h. ein Teilbaum der Gestalt (a, ε , ε ) ) heißt Blatt. Als Beispiel für Bäume betrachten wir Formelbäume über dem Alphabet (x,y,z,+,*). Die Formel x*y + x*z (mit „Punkt-vor-Strich-Regelung“) kann durch den Baum (+,(*,(x, ε , ε ),(y, ε , ε )),(*,(x, ε , ε ),(z, ε , ε ))) repräsentiert werden. Übersichtlicher ist eine graphische Darstellung: + * x * y x z Wir werden später verschiedene Algorithmen, die auf Bäumen basieren, kennen lernen. Es ist klar, dass sich die obige Definition direkt auf Binärbäume über einer beliebigen Grundmenge verallgemeinern lässt. Eine weitere nahe liegende Erweiterung sind n-äre Bäume, bei denen jeder Knoten entweder keinen oder n Nachfolger hat. Wenn wir erlauben, dass jeder Knoten eine beliebige (endliche) Zahl von Nachfolgern haben kann, sprechen wir von endlich verzweigten Bäumen. Aufrufbäume Eine spezielle Art von (endlich verzweigten) Bäumen sind die Aufrufbäume einer rekursiven Funktion. Die Wurzel eines Aufrufbaumes ist der Name der Funktion mit den Eingabewerten. Die Nachfolger jeden Knotens sind die bei der Auswertung aufgerufenen Funktionen mit ihren Eingabewerten. 1-29 Beispiel: inverse(„ABC“) rest(„ABC“) inverse(„BC“) first(„ABC“) + rest(„BC“) inverse(„C“) first(„BC“) + + (In diesem Baum haben wir die Funktionen „==“ und „if“ nicht weiter berücksichtigt.) Beim verkürzten Aufrufbaum lässt man alle Knoten weg außer denen, die die rekursive Funktion selbst betreffen. Beispiel: inverse(„“) rest(„C“) first(„C“) fib(5) fib(3) fib(4) fib(3) fib(2) fib(2) fib(2) fib(1) fib(1) Unter der Aufrufkomplexität einer rekursiven Funktion verstehen wir die Anzahl der Knoten im verkürzten Aufrufbaum. Die Zeit, die benötigt wird, um eine rekursive Funktion zu berechnen, hängt im Wesentlichen von der Aufrufkomplexität ab. Als Beispiel betrachten wir die Aufrufkomplexität der Fibonacci-Funktion. Aus obigem Beispiel ist sofort klar: 1, falls n ≤ 2 fibComp (n) = 1 + fibComp (n − 1) + fibComp (n − 2), sonst Die rekursive Formulierung hilft leider noch nicht, die Aufrufkomplexität abzuschätzen. Per Induktion nach n zeigen wir: fibComp (n) = 2 * fib(n) − 1 . Für n=1,2 ist dies klar, für n>2 gilt fibComp (n) = 1 + fibComp (n − 1) + fibComp (n − 2) = I .V . 1 + 2 * fib(n − 1) − 1 + 2 * fib(n − 2) − 1 = 2 * ( fib(n − 1) + fib(n − 2)) − 1 = 2 * fib(n) − 1 Mit der früher bewiesenen Gleichung von Binet erhalten wir n n 2 1 + 5 1 − 5 − −1. fibComp (n) = 5 2 2 Eine Wertetabelle für einige Zahlenwerte ist nachfolgend angegeben. Daraus folgt: wenn in einer Sekunde 10.000 Aufrufe erfolgen, benötigt die Rechnung für n=100 etwa 2,2 Milliarden Jahre! (üblicherweise ist vorher der Speicher erschöpft oder ein Zahlbereichsüberlauf eingetreten,). n 3 5 10 20 30 40 50 60 70 80 90 100 fibComp(n) 3 9 109 13529 1.6*106 2*108 2.5*1010 3*1012 3.8*1014 4.6*1016 5.7*1018 7*1020 1-30 Graphen Während ein Wort in der Informatik nur eine spezielle Art von Folgen ist, versteht man unter einem Graphen nur eine spezielle Art von Relationen: Ein Graph ist die bildliche Darstellung einer binären Relationen über einer endlichen Grundmenge. Die Elemente der Grundmenge werden dabei in Kreisen (Knoten) gezeigt. Zwischen je zwei Knoten zeichnet man einen Pfeil (eine Kante), falls das betreffende Paar von Elementen in der Relation enthalten ist. Beispiel: B A C D Dies ist die Relation {(A,B),(B,C),(C,B),(C,A),(B,D),(C,D)}. Für symmetrische Relationen weisen die Pfeile immer in beide Richtungen; man spricht hier von ungerichteten Graphen. Eine Alternative zur obigen Definition besteht darin, einen Graphen als Tupel (V,E) zu definieren, wobei V eine endliche Menge von Knoten (vertices) und E eine endliche Menge von Kanten (edges) ist, so dass zu jeder Kante genau ein Anfangs- und ein Endknoten gehört. Knoten, die nicht Endknoten sind, heißen Quelle, Knoten, die nicht Anfangsknoten sind, heißen Senke im Graphen. Knoten, die weder Anfangs- noch Endknoten sind, heißen isoliert. Eine dritte Art der Definition von Graphen ist durch die so genannte Adjazenzmatrix: Nach dieser Auffassung ist ein Graph eine endliche Matrix (Tabelle) mit booleschen Werten. Die Zeilen und Spalten der Tabelle sind dabei mit der Grundmenge beschriftet; ein Eintrag gibt an, ob das entsprechende Paar (Zeile, Spalte) in der Relation enthalten ist oder nicht. A A B C D X B X X C D X X X Im Gegensatz zu Bäumen können Graphen Zyklen enthalten, daher existiert keine einfache induktive Definition. Umgekehrt können endlich verzweigte Bäume als zyklenfreie Graphen mit nur einer Quelle betrachtet werden. 1-31 Kapitel 2: Informationsdarstellung 2.1 Bits und Bytes, Zahl- und Zeichendarstellungen (siehe Gumm/Sommer * Kap.1.2/1.3) Damit Informationen von einer Maschine verarbeitet werden können, müssen sie in der Maschine repräsentiert werden. Üblich sind dabei Repräsentationsformen, die auf Tupeln oder Folgen über der Menge B aufbauen. Ein Bit (binary digit) ist die kleinste Einheit der Informationsdarstellung: es kann genau zwei Werte annehmen, z. B. 0 oder 1. Genau wie es viele verschiedene Notationen der Menge B gibt, gibt es viele verschiedene Realisierungsmöglichkeiten eines Bits: an/aus, geladen/ungeladen, weiss/schwarz, magnetisiert/entmagnetisiert, reflektierend/lichtdurchlässig, … Lässt eine Frage mehrere Antworten zu, so lassen sich diese durch eine Bitfolge (mehrere Bits) codieren. Beispiel: Die Frage, aus welcher Himmelsrichtung der Wind weht, lässt 8 mögliche Antworten zu. Diese lassen sich durch Bitfolgen der Länge 3 codieren: 000 = Nord 001 = Nordost 010 = Ost 011 = Südwest 100 = Süd 101 = Südost 110 = West 111 = Nordwest Offensichtlich verdoppelt jedes zusätzliche Bit die Anzahl der möglichen Bitfolgen, so dass es genau 2n mögliche Bitfolgen der Länge n gibt (|B|=2 |Bn|=2n) Ein Byte ist ein Oktett von Bits: 8 Bits = 1 Byte. Oft betrachtet man Bytefolgen anstatt von Bitfolgen. Ein Byte kann verwendet werden, um z.B. folgendes zu speichern: ein codiertes Zeichen (falls das Alphabet weniger als 28 Zeichen enthält) eine Zahl zwischen 0 und 255, eine Zahl zwischen -128 und +127, die Farbcodierung eines Punkts in einer Graphik, genannt „Pixel“ (picture element) Gruppen von 16 Bits, 32 Bits, 64 Bits bzw. 128 Bits werden häufig als Halbwort, Wort, Doppelwort bzw. Quadwort bezeichnet. Leider gibt es dafür unterschiedliche Konventionen. Zwischen 2-er und 10-er Potenzen besteht (näherungsweise) der Zusammenhang: 210 = 1024 ≅ 1000 = 103 Für Größenangaben von Dateien, Disketten, Speicherbausteinen, Festplatten etc. benutzt man daher folgende Präfixe: ≅ 103 (k = Kilo) • k = 1024 = 210 • M = 10242 = 1048576=220 ≅ 106 (M = Mega) 3 30 9 • G = 1024 = 2 ≅ 10 (G = Giga) 4 40 12 • T = 1024 = 2 ≅ 10 (T = Tera) 5 50 15 • P = 1024 = 2 ≅ 10 (P = Peta) • E = 10246 = 260 ≅ 1018 (E = Exa) Die Ungenauigkeit der obigen Näherungsformel nimmt man dabei in Kauf. 2-32 Mit 1 GByte können also entweder 230 = 10243 = 1.073.741.824 oder 109 = 1.000.000.000 Bytes gemeint sein. Anhaltspunkte für gängige Größenordnungen von Dateien und Geräten: • eine SMS: ~140 B (160 Zeichen zu je 7 Bit) • ein Brief: ~3 kB • ein „kleines“ Programm: ~300 kB • Diskettenkapazität: 1,44 MB • ein „mittleres“ Programm: ~ 1 MB • ein Musiktitel: ~40 MB (im MP3-Format ~4 MB) • CD-ROM Kapazität: ~ 680 MB • Hauptspeichergröße: 1-8 GB • DVD (Digital Versatile Disk): ~ 4,7 bzw. ~ 9 GB • Festplatte: 250-1000 GB. Für Längen- und Zeiteinheiten werden auch in der Informatik die gebräuchlichen Vielfachen von 10 benutzt. So ist z.B. ein 400 MHz Prozessor mit 400×106 = 400.000.000 Hertz (Schwingungen pro Sekunde) getaktet. Das entspricht einer Schwingungsdauer von 2,5×10-9 sec, d.h. 2,5 ns. Der Präfix n steht hierbei für nano, d.h. den Faktor 10-9. Weitere Präfixe für Faktoren kleiner als 1 sind: • • • • • m = 1/1000 = 10-3 µ = 1/1000000 = 10-6 n = 1/1000000000 = 10-9 p = ... = 10-12 f = ... = 10-15 (m = Milli) (µ = Mikro) (n = Nano) (p = Pico) (f = Femto) Beispiele: 1mm = 1 Millimeter; 1 ms = 1 Millisekunde. Für Längenangaben wird neben den metrischen Maßen eine im Amerikanischen immer noch weit verbreitete Einheit verwendet: 1" = 1 in = 1 inch = 1 Zoll = 2,54 cm = 25,4 mm. Teile eines Zolls werden als Bruch angegeben. Beispiel - Diskettengröße: 3 1/2". Darstellung natürlicher Zahlen, Stellenwertsysteme Die älteste Form der Darstellung von Zahlen ist die Strichdarstellung, bei der jedes Individuum durch einen Strich oder ein Steinchen repräsentiert wird (calculi=Kalksteinchen, vgl. kalkulieren). Bei dieser Darstellung ist die Addition besonders einfach (Zusammen- oder Hintereinanderschreibung von zwei Strichzahlen), allerdings wird sie für große Zahlen schnell unübersichtlich. Die Ägypter führten deshalb für Gruppen von Strichen Abkürzungen ein (http://de.wikipedia.org/wiki/Ägyptische_Zahlen, http://www.informatik.unihamburg.de/WSV/teaching/vorlesungen/WissRep-Unterlagen/WR03Einleitung-1.pdf). Daraus entstand dann das römische Zahlensystem: 2-33 Aus Übersichtlichkeitsgründen werden die großen Zahlen dabei zuerst geschrieben; prinzipiell spielt in solchen direkten Zahlensystemen die Position einer Ziffer keine Rolle. Die Schreibweise IV = 5-1 ist erst viel später entstanden! Direkte Zahlensysteme haben einige Nachteile: Die Darstellung großer Zahlen kann sehr lang werden, und arithmetische Operationen lassen sich in solchen Systemen nur schlecht durchführen. In Indien (und bei den Majas) wurde ein System mit nur zehn verschiedenen Ziffernsymbolen verwendet, bei der die Position jeder Ziffer (von rechts nach links) ihre Wertigkeit angibt. Die wesentliche Neuerung ist dabei die Erfindung der Zahl Null (ein leerer Kreis). Der schon genannte Muhammed ibn Musa al-Khwarizmi verwendete das Dezimalsystem in seinem Arithmetikbuch, das er im 8. Jahrhundert schrieb. Bereits im 10. Jahrhundert wurde das System in Europa eingeführt, durchsetzen konnte es sich jedoch erst im 12. Jahrhundert mit der Übersetzung des genannten Arithmetikbuchs ins Lateinische (durch Fibonacci, siehe oben).(*) Wir haben schon erwähnt, dass das Dualzahlen- oder Binärsystem mit nur zwei Ziffern in Europa 1673 von Gottfried Wilhelm Leibnitz (wieder-)erfunden wurde. Allgemein gilt: In Stellenwertsystemen wird jede Zahl als Ziffernfolgen xn-1... x0 repräsentiert, wobei - bezogen auf eine gegebene Basis b - jede Ziffer xi einen Stellenwert (xi*bi) bekommt: n −1 [xn−1 x0 ]b = ∑ xi ∗ b i i =0 Werden dabei nur Ziffern xi mit Werten zwischen 0 und b-1 benutzt, so ergibt sich eine eindeutige Darstellung; dafür werden offenbar genau b Ziffern benötigt. Zu je zwei Basen b und b' gibt es eine umkehrbar eindeutige Abbildung, die [xn-1... x0]b und [ yn'-1... y0]b' mit 0 ≤ xi ≤ b und 0 ≤ yi ≤ b' ineinander überführt. 2-34 Beispiele: b = 2: b = 3: b = 8: b = 10: [10010011]2 = 1*27 + 1*24 + 1*21 + 1*20 = [147]10 [12110]3 = 1*34 + 2*33 + 1*32 + 1*31 = [147]10 [223]8 = 2*82 + 2*81 + 3*80 = [147]10 [147]10 = 1*102 + 4*101 + 7*100=[10010011]2 Wichtige Spezialfälle sind b=10, 2, 8 und 16. Zahlen mit b=8 bezeichnet man als Oktalzahlen. Unter Sedezimalzahlen (oft auch Hexadezimalzahlen genannt) versteht man Zifferndarstellungen von Zahlen zur Basis 16. Sie dienen dazu, Dualzahlen in komprimierter (und damit leichter überschaubarer) Form darzustellen und lassen sich besonders leicht umrechnen. Je 4 Dualziffern werden zu einer „Hex-ziffer“ zusammengefasst. Da man zur Hexadezimaldarstellung 16 Ziffern benötigt, nimmt man zu den Dezimalziffern 0 ... 9 die ersten Buchstaben A ... F hinzu. Beispiele: Umwandlung von Dezimal - in Oktal- / Hexadezimalzahlen und umgekehrt: [1]10=[1]8=[1]16=[1]2 … [7]10=[7]8=[7]16=[111]2 [8]10=[10]8=[8]16=[1000]2 [9]10=[11]8=[9]16=[1001]2 [10]10=[12]8=[A]16=[1010]2 [11]10=[13]8=[B]16=[1011]2 [12]10=[14]8=[C]16=[1100]2 [13]10=[15]8=[D]16=[1101]2 [14]10=[16]8=[E]16=[1110]2 [15]10=[17]8=[F]16=[1111]2 [16]10=[20]8=[10]16 [17]10=[21]8=[11]16 … [32]10=[40]8=[20]16 [33]10=[41]8=[21]16 … [80]10=[100]8=[50]16 … [160]10=[240]8=[A0]16 … [255]10=[377]8=[FF]16 [256]10=[400]8=[100]16 [1000]10=[3E8]16 … [4096]10=[10000]8=[1000]16 … [10000]10=[2710]16 … [45054]10=[AFFE]16 [65535]10=[177777]8=[FFFF]16 [106]10=[F4240]16 [4294967295]10=[FFFFFFFF]16 Diese Umrechnung ist so gebräuchlich, dass sie von vielen Taschenrechnern bereit gestellt wird. Um mit beliebig großen natürlichen oder ganzen Zahlen rechnen zu können, werden diese als Folgen über dem Alphabet der Ziffern repräsentiert. Diese Darstellung wird beispielsweise im bc verwendet. Numerische Algorithmen mit solchen Repräsentationen sind allerdings häufig komplex, und in vielen Anwendungen wird diese Allgemeinheit nicht benötigt. Daher werden Zahlen oft als Dual- oder Binärwörter einer festen Länge n repräsentiert. Mit Hilfe von n Bits lassen sich 2n Zahlenwerte darstellen: • die natürlichen Zahlen von 0 bis 2n - 1 oder • die ganzen Zahlen zwischen -2n-1 und 2n-1 - 1 oder • ein Intervall der reellen Zahlen (mit begrenzter Genauigkeit) 2-35 Beispiel: Länge 4 8 16 32 darstellbare Zahlen 0 .. 15 0 .. 255 0 .. 65535 0 .. 4 294 967 295 Darstellung ganzer Zahlen Für die Darstellung ganzer Zahlen Z wird ein zusätzliches Bit (das "Vorzeichen-Bit") benötigt. Mit Bitfolgen der Länge n kann also (ungefähr) der Bereich [-2n-1 .. 2n-1] dargestellt werden. Nahe liegend ist die dabei Vorzeichendarstellung: Das erste Bit repräsentiert das Vorzeichen (0 für '+' und 1 für '-') und der Rest den Absolutwert. Nachteile dieser Darstellung: • Die Darstellung der 0 ist nicht mehr eindeutig. • Beim Rechnen "über die 0" müssen umständliche Fallunterscheidungen gemacht werden, Betrag und Vorzeichen sind zu behandeln. Beispiel:3 + (-5) = 0011+1101=1010 Eine geringfügige Verbesserung bringt die Einserkomplement-Darstellung, bei der jedes Bit der Absolutdarstellung umgedreht wird. Meist wird jedoch die so genannte Zweierkomplement-Darstellung (kurz: 2c) benutzt. Sie vereinfacht die arithmetischen Operationen und erlaubt eine eindeutige Darstellung der 0. Bei der Zweierkomplementdarstellung gibt das erste Bit das Vorzeichen an, das 2. bis n-te Bit ist das Komplement der um eins verringerten positiven Zahl. Die definierende Gleichung für die Zweierkomplementdarstellung ist: [-xn-1... x0]2c + [xn-1...x0]2c = [10...0]2c = 2n+1 Um zu einer gegebenen positiven Zweierkomplement-Zahl die entsprechende negative zu bilden, invertiert man alle Bits und addiert 1. Die Addition kann mit den üblichen Verfahren berechnet werden (Beweis: eigene Übung!). Beispiele: n = 4: +5 → [0101]2c , also -5 → (1010 + 0001)2c = [1011]2c 3 + (-5) =[0011]2c +[1011]2c =[1110]2c 4 + 5 =[0100]2c +[0101]2c → [1001]2c = -7 (Achtung!!!) 2-36 Wichtiger Hinweis: In vielen Programmiersprachen wird ein Zahlbereichsüberlauf nicht abgefangen und kann beispielsweise zur Folge haben, dass die nagelneue Rakete abstürzt! Darstellung rationaler und reeller Zahlen Prinzipiell kann man rationale Zahlen Q als Paare ganzer Zahlen (Zähler und Nenner) darstellen. Ein Problem ist hier die Identifikation gleicher Zahlen: (7,-3)=(-14,6). Hier müsste man nach jeder Rechenoperation mit dem ggT normieren; das wäre sehr unpraktisch. Daher werden in der Praxis rationale Zahlen Q meist wie reelle Zahlen R behandelt. Für reelle Zahlen gilt: • Es gibt überabzählbar viele reelle Zahlen R. Also gibt es auch reelle Zahlen, die sich nicht in irgend einer endlichen Form aufschreiben lassen, weder als endlicher oder periodischer Dezimalbruch noch als arithmetischer Ausdruck oder Ergebnis eines Algorithmus. Echte reelle Zahlen lassen sich also nie genau in einem Computer speichern, da es für sie definitionsgemäß keine endliche Darstellung gibt. • Die so genannten „reals“ im Computer sind mathematisch gesehen immer Näherungswerte für reelle Zahlen mit endlicher Genauigkeit. Für reelle Zahlen R gibt es die Festkomma- und die Gleitkommadarstellung. Bei der Festkommadarstellung steht das Komma an einer beliebigen, festen Stelle. Für x=[xn-1xn-2…x1x0x-1x-2…x-m]2 ist x = n −1 ∑x i =− m i ⋅ 2 i . Diese Darstellung gestattet nur einen kleinen Wertebereich und hat auch sonst einige Nachteile (Normierung von Zahlen erforderlich). Daher verwendet man sie in elektronischen Rechenmaschinen nur in Ausnahmefällen (z.B. beim Rechnen mit Geld). Ziel der Gleitkommadarstellung (IEEE754 Standard) ist es, • ein möglichst großes Intervall reeller Zahlen zu umfassen, • die Genauigkeit der Darstellung an die Größenordnung der Zahl anzupassen: bei kleinen Zahlen sehr hoch, bei großen Zahlen niedrig. Daher speichert man neben dem Vorzeichen und dem reinen Zahlenwert - der so genannten Mantisse - auch einen Exponenten (in der Regel zur Basis 2 oder 10), der die Kommaposition in der Zahl angibt. - Das Vorzeichenbit v gibt an, ob die vorliegende Zahl positiv oder negativ ist. - Die Mantisse m besteht aus einer n–stelligen Binärzahl m1....mn 2-37 - Der Exponent e ist eine L-stellige ganze Zahl (zum Beispiel im Bereich -128 bis +127), die angibt, mit welcher Potenz einer Basis b die vorliegende Zahl zu multiplizieren ist. Das Tripel (v, m, e) wird als (-1)v * m * be-n interpretiert. Bei gegebener Wortlänge von 32 Bit verwendet man beispielsweise 24 Bit für Vorzeichen und Mantisse (n=23) sowie L=8 Bit für den Exponenten. Beispiele mit n=L=4: (0, 1001, 0000) = 1 * 9 * 2-4 = [0,1001]2=0,5625 und (1, 1001, 0110) = -1 * 9 * 26-4 = [-100100]2 = -36 und (0, 1001, 1010) = 1 * 9 * 2-6-4 = 0,00878906… Anstatt wie in diesen Beispielen e in Zweierkomplementdarstellung abzuspeichern, verwendet man die sogenannte biased-Notation: E=e+e’, wobei e’=2L-1-1. Damit ist 0≤E≤(2L-1) positiv, und (v, m, E) wird als (-1)v * m * bE -(e’+n) interpretiert. Zum Beispiel ist für L=4 der Wert von e’=7, also ein Exponent E=1010 entspricht e=3. Damit ergibt sich (0, 1001, 1010) = 9 * 23-4 = 4,5. Bei 32-Bit Gleitkommazahl mit 8-Bit Exponent gilt: e’=127, bei 64-Bit Gleitkommazahlen ist e’=1023. In Groovy und Java gibt es die folgenden Datentypen für Gleitpunktzahlen: • Datentyp float (32 Bit) mit dem Wertebereich (+/-)1.4*10-45..3.4*1038.(7 relevante Dezimalstellen: 23 Bit Mantisse, 8 Bit Exponent) • Datentyp double (64 Bit) mit dem Wertebereich (+/-)4.9*10-324..1.79*10308.(15 relevante Stellen: 52 Bit Mantisse, 11 Bit Exponent) • Datentyp BigDecimal mit fast beliebiger Präzision, bestehend aus Mantisse und 32bit Exponent, Wert: (Mantisse * (10 ** (-Exponent))) Beispiel: new BigDecimal(123, 6 ) == 0.000123 Zeichendarstellung Zeichen über einem gegebenen Alphabet A werden meist als Bitfolgen einer festen Länge n≥log(|A|) codiert. Oft wird n so gewählt, dass es ein Vielfaches von 4 oder von 8 ist. Beispiel: Um Texte in einem Buch darzustellen, benötigt man ein Alphabet von 26 Kleinbuchstaben, ebenso vielen Großbuchstaben, einigen Satzzeichen wie etwa Punkt, Komma und Semikolon und Spezialzeichen wie "+", "&", "%". Daher hat eine normale Schreibmaschinentastatur eine Auswahl von knapp hundert Zeichen, für die 7 Bit ausreichen. Bereits in den 1950-ern wurden die Codes ASCII und EBCDIC hierfür entwickelt. (siehe ASCII-Tabelle in Kap.1.3 Alphabete). Mit einem Byte lassen sich 256 Zeichen codieren. Viele PCs benutzen den Code-Bereich [128 .. 255] zur Darstellung von sprachspezifischen Zeichen wie z.B. "ä" (Wert 132 in der erweiterten Zeichentabelle), "ö" (Wert 148) "ü" (Wert 129) und einigen Sonderzeichen anderer Sprachen. Leider ist die Auswahl der sprachspezifischen Sonderzeichen eher zufällig und bei weitem nicht ausreichend für die vielfältigen Symbole fremder Schriften. Daher wurden von der „International Standardisation Organisation“ (ISO) verschiedene ASCIIErweiterungen normiert. In Westeuropa ist dazu die 8-Bit ASCII-Erweiterung „Latin-1“ nützlich, die durch die Norm ISO8859-1 beschrieben wird. Mit steigender Speicherverfügbarkeit geht man heutzutage von 8-Bit Codes zu 16-Bit oder noch mehr stelligen Codes über. Hier gibt es den Standard UCS bzw. Unicode. - [ISO10646]: "Information Technology -- Universal Multiple-Octet Coded Character Set (UCS) -- Part 1: Architecture and Basic Multilingual Plane", ISO/IEC 106461:1993. - [UNICODE]: "The Unicode Standard: Version 2.0", The Unicode Consortium, Addison-Wesley Developers Press, 1996. Siehe auch http://www.unicode.org 2-38 Die ersten 128 Zeichen dieser Codes sind ASCII-kompatibel, die ersten 256 Zeichen sind kompatibel zu dem oben genannten Code ISO-Latin-1. Darüber hinaus codieren sie alle gängigen Zeichen dieser Welt. Herkömmliche Programmiersprachen lassen meist keine Zeichen aus ASCII-Erweiterungen zu. Java und Groovy erlauben die Verwendung beliebiger Unicode-Zeichen in Strings. (Allerdings heißt dies noch lange nicht, dass jede Java-Implementierung einen Editor zur Eingabe von Unicode mitliefern würde!) Zeichenketten (strings) werden üblicherweise durch Aneinanderfügen einzelner codierter Zeichen repräsentiert; da die Länge oftmals statisch nicht festliegt, werden sie intern mit einem speziellen Endzeichen abgeschlossen.. Beispiel: Dem Text "Hallo Welt" entspricht die Zeichenfolge "H", "a", "l", "l", "o", " ", "W", "e", "l", "t" Diese wird in ASCII folgendermaßen codiert: 072 097 108 108 111 032 087 101 108 116. In Hexadezimal-Schreibweise lautet diese Folge: 48 61 6C 6C 6F 20 57 65 6C 74. Dem entspricht die Bitfolge: 01001000 01100001 01101100 01101100 01101111 00100000 01010111 01100101 01101100 01110100. Obwohl diese Repräsentation sicher nicht die speichereffizienteste ist, ist sie weit verbreitet; bei Speichermangel greift man eher auf eine nachträgliche Komprimierung als auf andere Codes zurück. Zeit- und Raumangaben Oft muss man Datumsangaben wie „4. Mai 2010, 9 Uhr 15 pünktlich“ oder Raumangaben, etwa „0310, Rudower Chaussee 26, 12489 Berlin-Adlershof“ in Programmen repräsentieren. Für Termine könnte man das Tripel (4, 5, 2010) durch drei natürliche Zahlen oder mit Uhrzeit als (4, 5, 2010, 9, 15, 0) in sechs Zahlen (Speicherwörtern) ablegen. Um Platz zu sparen, kann man auch eine Folge von 14, 8 oder 6 Zeichen verwenden: "20100504091500", "20100504", "100504". Im letzten Fall bekommt man allerdings ein Y2K-Problem, was sich aber auch im Januar 2010 im Kreditkartenwesen wiederholte. Um den aktuellen Tag in nur einer ganzen Zahl zu codieren, könnte man eine Stellenwertrechnung wie bei den Gleitkommazahlen verwenden. Dies ist jedoch zu aufwändig; besser ist es, die Zeiteinheiten ab einem bestimmten Anfangszeitpunkt zu zählen. Die Römer zählten ab der vermuteten Gründung der Stadt; in der christlichen Zeit zählt man ab der vermuteten Geburt Jesu. In der Unixzeit zählt man die Sekunden ab dem 1.1.1970, wobei Schaltsekunden nicht mitgezählt werden. Dies kann ggf. zu einem „Jahr-20382-39 Problem“ führen. Die Umrechnung von Unixzeit in christliche Zeit erfolgt mit dem Kommando date. Da die inkrementelle Zeitrechnung eine immer größer werdende Differenz zur durch die Erdrotation gegebene „natürliche“ Zeit aufweist, fanden öfters Anpassungen und Korrekturen statt, die einschneidendste durch die Einführung des Gregorianischen Kalenders am 4.10./14.10.1582. Seither erfolgt diese Korrektur durch die Einführung von Schalttagen und Schaltsekunden. Ein Schaltjahr ist definiert dadurch, dass die Jahreszahl durch 4 teilbar ist, aber nicht durch 100, oder durch 400. boolean schaltjahr (x) {(x % 4 == 0) & (x % 100 != 0) | (x % 400 == 0)} assert schaltjahr (2012) & schaltjahr (2000) & ! schaltjahr(1900) Um zu einem gegebenen Datum im Gregorianischen Kalender den Wochentag zu ermitteln, kann man die Zellersche Formel (siehe http://de.wikipedia.org/wiki/Zellers_Kongruenz) verwenden. Um die Zeitrepräsentation auf der ganzen Erde einheitlich referenzieren zu können, wurde 1968 die koordinierte Weltzeit UTC eingeführt. Die lokale Zeit ergibt sich durch Angabe des Offsets zur UTC, etwa 11:30 UTC+1:00 für die mitteleuropäische Ortszeit (MEZ oder CET), die der UTC eine Stunde voraus ist. Die mitteleuropäische Sommerzeit (CEST) ist UTC+2:00. Als Alternative zur koordinierten Weltzeit wurde 1998 die Internetzeit eingeführt (und von der Firma Swatch propagiert), bei der ein Tag aus 1000 Zeiteinheiten besteht und die überall gleich gezählt wird. In Groovy und in der Java-Bibliothek gibt es die Klassen Date und GregorianCalendar, mit der man direkt mit Datumsangaben rechnen kann: def t = new Date () println t // heutiges Datum println t+7 // eine Woche später println t.time // Unixzeit def c=new GregorianCalendar() println c.time // heutiges Datum println c.timeInMillis // etwas später... Zur Repräsentation von Ortsinformationen auf der Erde gibt es sehr viele unterschiedliche Postanschriftssysteme. Für Koordinatenzuweisungen beim Geo-tagging hat sich das System (Längengrad, Breitengrad) durchgesetzt. 2-40 Darstellung sonstiger Informationen Natürlich lassen sich in einem Computer nicht nur Bits, Zahlen und Zeichen repräsentieren, sondern z.B. auch visuelle und akustische Informationen. Für Bilder gibt es dabei prinzipiell zwei verschiedene Verfahren, nämlich als Vektor- und als Pixelgrafik. Auch für Töne gibt es verschiedene Repräsentationsformen: als Folge von Noten (Midi), Schwingungsamplituden (wav, au) oder komprimiert (mp3). Diese Repräsentationsformen lassen sich jedoch auffassen als strukturierte Zusammensetzungen einfacher Datentypen; wir werden später noch detailliert auf verschiedene Strukturierungsmöglichkeiten für Daten eingehen. Wichtig: Der Bitfolge sieht man nicht an, ob sie die Repräsentation einer Zeichenreihe, einer Folge von ganzen Zahlen oder reellen Zahlen in einer bestimmten Genauigkeit ist. Ohne Decodierungsregel ist eine codierte Nachricht wertlos! 2.2 Sprachen, Grammatiken, Syntaxdiagramme Die obigen Darstellungsformen sind geeignet zur Repräsentation einzelner Objekte. Häufig steht man vor dem Problem, Mengen von gleichartigen Objekten repräsentieren zu müssen. Für endliche Mengen kann dies durch die Folge der Elemente geschehen; für unendliche Mengen geht das im Allgemeinen nicht. Daher muss man sich andere (symbolische) Darstellungsformen überlegen. Darstellung von Sprachen Ein besonders wichtiger Fall ist die Darstellung von unendlichen Mengen von Wörtern, die einem bestimmten Bildungsgesetz unterliegen; zum Beispiel die Menge der syntaktisch korrekten Eingaben in einem Eingabefeld, oder die Menge der Programme einer Programmiersprache. Eine Sprache ist eine Menge von Wörtern über einem Alphabet A. - z.B. {a, aab, aac} - z.B. Menge der grammatisch korrekten Sätze der dt. Sprache - z.B. Menge der Groovy-Programme Man unterscheidet zwischen natürlichen Sprachen wie z.B. deutsch und englisch und formalen Sprachen wie z.B. Java, Groovy, C++ oder der Menge der Primzahlen in Hexadezimaldarstellung. Da Leerzeichen in der Lehre von den formalen Sprachen genau wie andere Zeichen behandelt werden, gibt es hier keinen Unterschied zwischen Wörtern und Sätzen. Unter Syntax versteht man die Lehre von der Struktur einer Sprache • Welche Wörter gehören zur Sprache? • Wie sind sie intern strukturiert? z.B. Attribut, Prädikatverbund, Adverbialkonstruktion Unter Semantik versteht man die Lehre von der Bedeutung der Sätze • Welche Information transportiert ein Wort der Sprache? In der Linguistik betrachtet man manchmal noch den Begriff Pragmatik, darunter versteht man die Lehre von der Absicht von sprachlichen Äußerungen • Welchen Zweck verfolgt der Sprecher mit einem Wort? Berühmtes Beispiel für den Unterschied zwischen Semantik und Pragmatik ist die BeifahrerAussage „Die Ampel ist grün“. Grammatiken Grammatiken sind Werkzeuge zur Beschreibung der Syntax einer Sprache. Eine Grammatik stellt bereit 2-41 • • A : Alphabet oder „Terminalzeichen“ H : Hilfssymbole = syntaktische Einheiten (<Objekt<, <Attribut>,..) oder „Nonterminalzeichen“ Aus A und H bildet man Satzformen (Schemata korrekter Sätze) z.B. “<Subjekt> <Prädikat> <Objekt>”, “ Heute <Prädikat> < Subjekt> <Objekt> “ • R: Ableitungsregeln – erlaubte Transformationen auf Satzformen • s: Ein ausgezeichnetes Hilfssymbol („Gesamtsatz”, „Axiom“) Formal ist eine Grammatik ein Tupel G = [A, H, R, s], wobei • A und H Alphabete sind mit A ∩ H = Ø • R ⊆ (A ∪ H)+ × (A ∪ H)* • s∈H Die Relation R wird meist mit dem Symbol oder ::= (in Infixschreibweise) notiert. Zwischen Satzformen definieren wir eine Relation –> (direkte Ableitungsrelation) w–>w’, falls w = w1°u°w2, w’ = w1°v°w2 und (u,v) ∈ R Die Ableitungsrelation => ist die reflexiv-transitive Hülle der direkten Ableitungsrelation: w=>w’, falls w=w’ oder es gibt ein v ∈ (A ∪ H)* mit w–>v und v=>w’ Die von der Grammatik G beschriebene Sprache LG ist definiert durch LG = { w | s => w und w ∈ A* } Beispiel A={“große“, “gute“, “jagen“, “lieben”, “Katzen”, “Mäuse”} H={<attribut>,<objekt>,<prädikatsverband>,<satz>,<subjekt>,<substantiv>,<verb>} s=<satz> R: <satz> <subjekt> <prädikatsverband> “.” <subjekt> <substantiv> <subjekt> <attribut> <substantiv> <attribut>“gute” <attribut>“große” <substantiv>“Katzen” <substantiv>“Mäuse” <prädikatsverband><verb> <objekt> <verb>“lieben” <verb>“jagen” <objekt><substantiv> <objekt><attribut> <substantiv> Beispiel für Generierung: <satz> –> <subjekt> <prädikatverband> “.” –> <attribut> <substantiv> <verb> <objekt> “.” –> “gute” “Katzen” “jagen” “Mäuse” “.” Beispiel für Akzeptierung: “Katzen lieben große Mäuse <– <substantiv> <verb> <attribut> <substantiv> <– <subjekt> <verb> <objekt> <– <subjekt> <prädikatverband> <– <satz> 2-42 .” “.” “.” “.” Noch ein Beispiel <S> <E> <S> <S> “+” <E> <E> <T> <E> <E> “•” <T> <T> <F> <T> “-” <F> <F> “x” <F> “0” <F> “1” <F> “(“ <S> “)” Ableitung aus dem Beispiel: <S> => “1•(1+x)+0•-1” <S> –> <S> “+” <E> –> <E> “+”<E> “•” <T> –> <E>“•” <T> “+”<T>“ •” “-” <F> –> <T>“•” <F> “+” <F>“•” “-” “1” –> <F>“•” “ (“ <S> “)” “+” “0” “•” “-” “1” –> “1” “•” “(“ <S>“+” <E> “)” “+” “0” “•” “-” “1” –> “1” “•” “(“ <E>“+” <T> “)” “+” “0” “•” “-” “1” –> “1” “•” “(“ <T>“+” <F> “)” “+” “0” “•” “-” “1” –> “1” “•” “(“ <F>“+” “x” “)” “+” “0” “•” “-” “1” –> “1” “•” “(“ “1” “+” “x” “)” “+” “0” “•” “-” “1” Grammatiktypen – Die Chomsky-Hierarchie Die Lehre von den Grammatiken wurde vom amerikanischen Linguisten Noam Chomsky (geb. 1928, http://web.mit.edu/linguistics/people/faculty/chomsky/index.html) („America's most prominent political dissident“, http://www.zcommunications.org/chomsky/index.cfm) in den späten 1950-ern entwickelt. Chomsky unterscheidet vier Typen von Grammatiken: • Typ 0 - beliebige Grammatiken • Typ 1 - kontextsensitive Grammatiken o Ersetzt wird ein einziges Hilfssymbol, nichtverkürzend o Alle Regeln haben die Form u+h+w u+v+w mit h ∈ H o u , w heißen linker bzw. rechter Kontext o Sonderregel für das leere Wort • Typ 2 – kontextfreie Grammatiken o Ersetzt wird ein einziges Hilfssymbol, egal in welchem Kontext o Alle Regeln haben die Form h v mit h ∈ H • Typ 3 – reguläre Grammatiken o Wie Typ 2, in v kommt aber max. ein neues Hilfssymbol vor, und zwar ganz rechts Abhängig vom Typ spricht man auch von einer Chomsky-i-Grammatik. Beispiele (A={a, b, c}, H={s, x, y, z, …}): Chomsky-0-Regeln sind z.B. die folgenden: xyz ::= zyx, abc ::= x, axby ::= abbxy Eine Chomsky-0-Grammatik für {anbncn | n>0} erhält man durch folgende Regeln: s::= xs’z; s’ ::= s’s’; s’ ::= abc; ba ::= ab; ca ::= ac; cb ::= bc; xa ::= ax; x ::= y; yb ::= by; y ::= z; zc ::= cz; zz ::=ε 2-43 xyz ::= xyxyz (Chomsky-1-Regel) Anwendung: axyzxa -> axyxyza -> axyxyxyza -> … y ::= yxy (Chomsky-2-Regel) x::= ax; x::= b (Chomsky-3-Sprache a*b ) Eine Sprache heißt Chomsky-i-Sprache, wenn es eine Chomski-i-Grammatik für sie gibt, aber keine Chomsky-(i-1)-Grammatik. Die vier Typen bilden eine echte Hierarchie, d.h., für i=0,1,2 kann man jeweils eine Sprache finden, die durch eine Chomsky-i-Grammatik, nicht aber durch eine Chomsky-i+1-Grammatik beschreibbar ist. Es gilt: - Mit beliebigen Grammatiken lassen sich alle Sprachen beschreiben, die überhaupt berechenbar sind - Kontextsensitive Grammatiken sind algorithmisch beherrschbar - Für die meisten Programmiersprachen wird die Syntax in Form kontextfreier Grammatiken angegeben. - Einfache Konstrukte innerhalb von Programmiersprachen (z.B. Namen, Zahlen) werden durch reguläre Grammatiken beschrieben. Aufschreibkonventionen für kontextfreie Grammatiken Backus-Naur-Form (BNF) verwendet u ::= v | w als Abkürzung für {u ::= v, u ::= w} Beispiel: <S> ::= <Expression> | <S> + <Expression> <Expression> ::= <Term> | <Expression> • <Term> <Term> ::= <Factor> | - <Factor> <Factor> ::= x | 0 | 1 | ( <S> ) Erweiterte Backus-Naur-Form (EBNF) löst direkte Rekursion durch beliebigmalige Wiederholungsklammern {} auf Beispiel: S = Expression { “+” Expression } Expression = Term { “•” Term } Term = [ “-” ] Factor Factor = “x” | “0” | “1” | “(” S “)” Manchmal verwendet man auch zählende Wiederholungen [...]i mit der Bedeutung „…mindestens i und höchstens j mal“, wobei j auch durch einen Stern ersetzt werden kann 1 * (für „beliebig mal“). Es gilt {x} = [x ]0 und [x ] = [x ]0 . j Beispiele: [a ]30 = {ε , a, aa, aaa} ; [a]*2 2-44 = {aa, aaa, aaaa, …} Syntaxdiagramme Syntaxdiagramme werden zur anschaulichen Notation von Programmiersprachen verwendet. Reguläre Ausdrücke Für reguläre Sprachen gibt es die Möglichkeit, sie durch reguläre Ausdrücke aufzuschreiben. Das sind Ausdrücke, die gebildet sind aus • der leeren Sprache, die keine Wörter enthält • den Sprachen bestehend aus einem Wort bestehend aus einem Zeichen des Alphabets • der Vereinigung von Sprachen (gekennzeichnet durch +) • Konkatenation (gekennzeichnet durch Hintereinanderschreibung, ⋅ oder ;) • beliebiger Wiederholung (gekennzeichnet durch *) Beispiele: (aa)* (gerade Anzahl von a) aa* (mindestens ein a, auch a+ geschrieben) ((a + b)b)* (jedes zweite Zeichen ist b) Reguläre Ausdrücke werden z.B. in Editoren verwendet, um bestimmte zu suchende Zeichenreihenmengen zu repräsentieren. Beispiel für eine Suche ist: „Suche eine Zeichenreihe, die mit c beginnt und mit c endet und dazwischen eine gerade Anzahl von a enthält.“ In Groovy sind reguläre Ausdrücke („patterns“) ein fester Bestandteil der Sprache. Sie werden in / … / eingeschlossen, und es gibt vielfältige Operatoren: ==~ prüft ob eine Zeichenreihe in einem regulären Ausdruck enthalten ist =~ gibt ein Matcher-Objekt 2-45 Beispiele: "aaaa" ==~ /(aa)+/ "abbbab" ==~ /(.b)*/ "..." ==~ /\.*/ "Hallo Welt" ==~ /\w+\s\w+/ ["Hut", "Rot", "Rat"].each {assert it ==~ /(H|R)[aeiou]t/} ["cc", "caac", "cabac"].each {assert it ==~ /c[^a]*(a[^a]*a)*[^a]*c/} Endliche Automaten sind Syntaxdiagramme ohne Abkürzungen, d.h. es gibt keine „Kästchen“. Ein endlicher Automat hat genau einen Eingang und mehrere mögliche Ausgänge. a Beispiele: b a a a a b Üblicherweise werden bei Automaten die Kreuzungen als Kreise gemalt und Zustände (states) genannt, die Zustände werden mit Pfeilen verbunden (sogenannten Transitionen, transitions), die mit Terminalsymbolen beschriftet sind. a a a a b a b Ein fundamentaler Satz der Theorie der formalen Sprachen besagt, dass mit regulären Ausdrücken genau die durch reguläre Grammatiken definierbaren Sprachen beschrieben werden können, welches wiederum genau die Sprachen sind, die durch endliche Automaten beschrieben werden können. 2.3 Darstellung von Algorithmen Ein Algorithmus ist ein präzises, schrittweises und endliches Verfahren zur Lösung eines Problems oder einer Aufgabenstellung (insbesondere zur Verarbeitung von Informationen, vgl. Kap. 0). Das bedeutet, an einen Algorithmus sind folgende Anforderungen zu stellen: • Präzise Beschreibung (relativ zu den Kommunikationspartnern) der zu bearbeitenden Informationen und ihrer Repräsentation 2-46 • Explizite, eindeutige und detaillierte Beschreibung der einzelnen Schritte (relativ zu der Person oder Maschine, die den Algorithmus ausführen soll) • Endliche Aufschreibung des Algorithmus, jeder Einzelschritt ist in endlicher Zeit effektiv ausführbar, und jedes Ergebnis wird nach endlich vielen Schritten erzielt. Um Algorithmen von einem Computer ausführen zu lassen, müssen sie (genau wie andere Informationen) in einer für die jeweilige Maschine verständlichen Form repräsentiert werden. Eine solche Repräsentation nennt man Programm. Zum Beispiel kann das eine Folge von Maschinenbefehlen sein, die ein bestimmter Rechner ausführen kann. Die tatsächliche Ausführung eines Algorithmus bzw. Programms nennt man einen Prozess. Sie findet auf einem (menschlichen oder maschinellen) Prozessor statt. Ein Algorithmus bzw. Programm terminiert, wenn seine Ausführung nach einer endlichen Zahl von Schritten (Befehlen) abbricht. Ein Algorithmus (bzw. ein Programm) heißt deterministisch, wenn für jeden Schritt der nächste auszuführende Schritt eindeutig definiert ist. Er bzw. es heißt determiniert, wenn die Berechnung nur ein mögliches Ergebnis hat. Beispiel: Wechselgeldbestimmung (nach Kröger / Hölzl / Hacklinger) Aufgabe: Bestimmung des Wechselgeldes w eines Fahrscheinautomaten auf 5 Euro bei einem Preis von p Euro (zur Vereinfachung fordern wir, p sei ganzzahliges Vielfaches von 10 Cent; es werden nur 10-, 20- und 50-Cent Münzen zurückgegeben). Der Algorithmus ist nicht determiniert! Damit das Ergebnis eindeutig bestimmt ist, legen wir zusätzlich fest: „es sollen möglichst wenige Münzen zurückgegeben werden“. Als erstes muss die Repräsentation der Schnittstellen festgelegt werden: p könnte zum Beispiel als natürliche Zahl in Cent, als rationale Zahl oder als Tupel (Euro, Cent) repräsentiert werden, und w als Multimenge oder Folge von Münzen. Dann erfolgt die Aufschreibung des Algorithmus. 1. Darstellung in natürlicher Sprache: „Die Rückgabe enthält maximal eine 10-Cent-Münze, zwei 20-Cent-Münzen, und den Rest in 50-Cent-Münzen. 10-Cent werden zurückgegeben, falls dies nötig ist, um im Nachkommabereich auf 0, 30, 50, oder 80 Cent zu kommen. Eine oder zwei 20-Cent-Münzen werden zurückgegeben, um auf 0, 40, 50 oder 90 Cent zu kommen. Wenn der Nachkommaanteil 0 oder 50 ist, werden nur 50-Cent-Münzen zurückgegeben.“ Ein Vorteil der natürlichen Sprache ist, dass sie sehr flexibel ist und man damit (fast) alle möglichen Ideen aufschreiben kann. Nachteile bei der Darstellung von Algorithmen in natürlicher Sprache sind, dass keine vollständige formale Syntax bekannt ist, d.h. es ist nicht immer leicht festzustellen, ob eine Beschreibung syntaktisch korrekt ist oder nicht, und dass die Semantik weitgehend der Intuition überlassen bleibt. Dadurch bleibt immer eine gewisse Interpretationsfreiheit, verschiedene Prozessoren können zu verschiedenen Ergebnissen gelangen. 2. Darstellung als nichtdeterministischer Pseudo-Code: Sei w={} Multimenge; Solange p<5 tue [] erste Nachkommastelle von p ∈{2,4,7,9}: p = p + 0.10, w = w ∪ {10c} [] erste Nachkommastelle von p ∈{1,2,3,6,7,8}: p = p + 0.20, w = w ∪ {20c} [] erste Nachkommastelle von p ∈{0,1,2,3,4,5}: p = p + 0.50, w = w ∪ {50c} Ergebnis w 2-47 Auch Pseudo-Code verwendet keine feste Syntax, es dürfen jedoch nur solche Sprachelemente verwendet werden, deren Bedeutung (Semantik) klar definiert ist. Damit ist die Menge der möglichen Ergebnisse zu einer Eingabe eindeutig bestimmt. Berechnungsbeispiel: p=3.20, w={} p=3.70, w={50c} p=3.80, w={50c, 10c} p=4.00, w={50c, 10c, 20c} p=4.50, w={50c, 10c, 20c, 50c} p=5.00, w={50c, 10c, 20c, 50c, 50c} 3. Darstellung mathematisch-rekursiv: Wechselgeld (p) = {}, falls p=5 0,50+Wechselgeld (p+0.50), falls p≠5 und 5-p ≥ 0.50 0,20+Wechselgeld (p+0.20), falls p≠5, 5-p < 0.50 und 5-p ≥ 0.20 0,10+Wechselgeld (p+0.10), sonst Beispiel zum Berechnungsablauf: Wechselgeld(3.20) =0.50+Wechselgeld(3.70) =0.50+0.50+Wechselgeld(4.20) =0.50+0.50+0.50+Wechselgeld(4.70) =0.50+0.50+0.50+0.20+Wechselgeld(4.90) =0.50+0.50+0.50+0.20+0.10+Wechselgeld(5.00) =0.50+0.50+0.50+0.20+0.10 Die Syntax und Semantik in dieser Aufschreibung folgt den üblichen Regeln der Mathematik bzw. Logik, ist fest definiert, aber jederzeit erweiterbar. In der Schreibweise der Informatik könnte man dasselbe etwa wie folgt aufschreiben: Wechselgeld (p) = Falls 5-p=0 dann Rückgabe({}) sonst falls 5-p ≥ 0.50 dann Rückgabe(0.50+Wechselgeld(p+0.50)) sonst falls 5-p ≥ 0.20 dann Rückgabe(0.20+Wechselgeld(p+0.20)) sonst Rückgabe(0.10+Wechselgeld(p+0.10)) Diese Notation lässt sich unmittelbar in funktionale Programmiersprachen übertragen. Als Groovy--Programm aufgeschrieben sieht das etwa so aus: int wg(p){ if (5-p==0) return(0) else if (5-p>=0.50) return(100+wg(p+0.50)) else if (5-p>=0.20) return(10+wg(p+0.20)) else return(1+wg(p+0.10))} Hier wählen wir zur Darstellung des Ergebnisses eine dreistellige natürliche Zahl, deren erste Stelle die Anzahl der 50-Cent-Münzen, die zweite die Anzahl der 20-Cent-Münzen und die letzte die Anzahl der 10-Cent-Münzen angibt. wg(2.70) 411 wg(3.10) 320 Eine andere Form der Ergebnisdarstellung (Multimenge!) wäre etwa als Liste: 2-48 def wg(p){ if (5-p==0) return([]) else if (5-p>=0.50) return([50] + wg(p+0.50)) else if (5-p>=0.20) return([20] + wg(p+0.20)) else return([10] + wg(p+0.10))} assert wg(2.70) == [50, 50, 50, 50, 20, 10] 4. Darstellung als Ablaufdiagramm oder Flussdiagramm. Start Int(Fract(p)*10) ∈ {3, 8}? w={} Int(Fract(p)*10) ∈ {2,4,7,9}? n y w+={10c} p +=0.1 n y w+={20c} p +=0.2 n p<5? y w+={50c} p +=0.5 Int(Fract(p)*10) ∈ {1, 6}? n y w+={20c} p +=0.2 Stop Ein Ablaufdiagramm ist prinzipiell ein endlicher Automat! Zusätzlich dürfen jedoch Variablen, Bedingungen, Zuweisungen, Unterprogramme und andere Erweiterungen verwendet werden. Ausführungsbeispiel: p=3.20, w={} p=3.30, w={10c} p=3.50, w={10c, 20c} p=4.00, w={10c, 20c, 50c} p=4.50, w={10c, 20c, 50c, 50c} p=5.00, w={10c, 20c, 50c, 50c, 50c} 2-49 5. Darstellung als Groovy- oder Java-Programm (mit heftigem Gebrauch von Mathematik) Groovy: int fuenfzigCentStuecke(float p) { ((5 - p) * 10 / 5)} int zehnCentStuecke(float p) { int cent = (p-(int)(p)) * 100 cent in [20, 40, 70, 90] ? 1 : 0} int zwanzigCentStuecke(float p) { int tencent = ((p-(int)(p)) * 10) coins = [0,2,1,1,0,0,2,1,1,0] coins[tencent]} Java: static int fünfzigCentStücke(double p) { return (int)((5 - p) * 10 / 5) ;} static int zehnCentStücke(double p) { int cent = (int)((p-(int)(p)) * 100); return((cent==20 || cent==40 || cent==70 || cent==90) ? 1 : 0) ;} static int zwanzigCentStücke(double p) { int tencent = (int)((p-(int)(p)) * 10); int[] coins = {0,2,1,1,0,0,2,1,1,0}; return coins[tencent] ;} Syntax und Semantik sind eindeutig definiert, jedes in einer Programmiersprache aufschreibbare Programm ist normalerweise determiniert und deterministisch (Ausnahme: Verwendung paralleler Threads). Wie man in diesem Fall auch leicht sieht, terminiert das Programm auch für jede Eingabe. Eine wichtige Frage ist die nach der Korrektheit, d.h. berechnet das Programm wirklich das in der Aufgabenstellung verlangte? 6. Darstellung als Assemblerprogramm In der Frühzeit der Informatik wurden Rechenmaschinen programmiert, indem eine Folge von Befehlen angegeben wurde, die direkt von der Maschine ausgeführt werden konnte. Assemblersprachen sind Varianten solcher Maschinensprachen, bei denen die Maschinenbefehle in mnemotechnischer Form niedergeschrieben sind. Häufig verwendete Adressen (Nummern von Speicherzellen) können mit einem Namen versehen werden und dadurch referenziert werden. Viele Assemblersprachen erlauben auch indirekte Adressierung, d.h. der Inhalt einer Speicherzelle kann die Adresse einer anderen Speicherzelle. Die in einer Assemblersprache verfügbaren Befehle hängen stark von der Art der zu programmierenden Maschine ab. Typische Assemblerbefehle sind z.B. mov rx ry (Bedeutung: transportiere / kopiere den Inhalt von rx nach ry) oder jgz rx lbl (Bedeutung: wenn der Inhalt von rx größer als Null ist, gehe zur Sprungmarke lbl) 2-50 // Eingabe: Preis in Register p in Cent (wird zerstört) // Ergebnisse in fc, zc, tc (fünfzigCent, zwanzigCent, tenCent) mov 0, fc mov 0, zc mov 0, tc loop: mov p, ac // lade p in den Akkumulator sub ac, 450 // subtrahiere 450 jgz hugo // jump if greater zero to hugo add ac, 500 // addiere 500 mov ac, p // speichere Akkumulator nach p mov fc, ac add ac, 1 mov ac, fc // fc := fc + 1 goto loop hugo: mov p, ac // wie oben sub ac, 480 jgz erna add ac, 500 mov ac, p mov zc, ac add ac, 1 mov ac, zc goto hugo erna: mov p, ac sub ac, 490 jgz fertig mov 1, tc fertig: 7. Darstellung als Maschinenprogramm Ein Maschinenprogramm ist eine Folge von Befehlen, die direkt von einer Maschine eines dafür bestimmten Typs ausgeführt werden kann. Jedem Assemblerbefehl entspricht dabei eine bestimmte Anzahl von Bytes im Maschinenprogramm. 2-51 Programmiersprachen Ein Programm ist, wie oben definiert wurde, die Repräsentation eines Algorithmus in einer Programmiersprache. Die Menge der syntaktisch korrekten Programme einer bestimmten Programmiersprache (JAVA, C, Delphi, …) wird im Allgemeinen durch eine kontextfreie Grammatik beschrieben. Maschinen- und Assemblersprachen sind dabei sehr einfache Sprachen (die sogar durch reguläre Grammatiken definiert werden könnten). Das Programmieren in einer Maschinensprache oder Assembler ist außerordentlich mühsam und sehr fehleranfällig. Höhere Programmiersprachen sind nicht an Maschinen orientiert, sondern an den Problemen. Programme, die in einer höheren Programmiersprache geschrieben sind, können nicht unmittelbar auf einem Rechner ausgeführt werden. Sie werden entweder von einem speziellen Programm interpretiert (d.h., direkt ausgeführt) oder von einem Compiler in eine Folge von Maschinenbefehlen übersetzt und erst dann ausgeführt. Bei einigen Prgrammiersprachen (Java, C#, USCD-Pascal) erfolgt die Übersetzung zunächst in die Maschinensprache einer virtuellen Maschine, d.h. einer nicht in Hardware realisierten Maschine, welche daher unabhängig von einer speziellen Hardwaretechnologie ist. Die Ausführung von Maschinenprogrammen der virtuellen auf einer realen Maschine erfolgt von speziellen Interpretern (der natürlich für jede reale Maschine neu entwickelt oder angepasst werden muss). Die Sprachen, die einer maschinellen Behandlung nicht zugänglich sind und von Menschen in Programme überführt werden müssen, nennen wir Spezifikationssprachen. Seit Beginn der Informatik wurden mehr als 1000 unterschiedliche Programmiersprachen erfunden, von denen die meisten in vielen verschiedenen Varianten und Dialekten existieren. Beispielsweise gibt es von Java inzwischen sieben Hauptvarianten (Versionen), und Groovy kann als eine Erweiterung von Java betrachtet werden. Im vierten Kapitel werden wir Möglichkeiten zur Klassifikation von Programmiersprachen betrachten. 2-52 Kapitel 3: Rechenanlagen Anmerkung: Dieses Kapitel wird zum Selbststudium und der Vollständigkeit halber zur Verfügung gestellt. Inhaltlich ist es durch die beiden Exkursionen, zum Deutschen Technikmuseum und zum Potsdamer Platz, abgedeckt. Um Programme auszuführen, ist ein Prozessor erforderlich, der die einzelnen Schritte tätigt. Das kann ein Mensch oder eine Maschine (auf mechanischer, elektronischer oder biochemischer Basis) sein, oder sogar ein anderes Programm, welches eine Ausführungsmaschine nur simuliert. 3.1 Historische Entwicklung Die Entwicklung und den Aufbau moderner Rechner begreift man besser, wenn man sich ihre historischen Wurzeln betrachtet. • • • • • 1842 Charles Babbage / Ada Lovelace: „Die analytische Maschine“; Konzept einer programmierbaren mechanischen Rechenanlage zur Lösung von Differentialgleichungen. Dieser „erste Computer der Weltgeschichte“ wurde jedoch nie realisiert, da Kosten, Machbarkeit und Haltbarkeit nicht einschätzbar waren 1936: Alonzo Church (1903-1995): „lambda-Kalkül“, Begriff der berechenbaren Funktion 1936: Alan Turing: Computer als universelle Maschine; Äquivalenz von Programm und Daten („Turing-Maschine“) 1941 Konrad Zuse: Z3: vollautomatischer, programmierbarer, in binärer Gleitkommarechnung arbeitender Rechner mit Speicher und einer Zentralrecheneinheit aus Telefonrelais 1946 Johan von Neumann (EDVAC-Report): konkrete Vorschläge für Aufbau („vonNeumann-Computer“) Programmierparadigmen: - Analytische Maschine: Rechnen als Durchführung arithmetischer Operationen - lambda-Kalkül, Lisp-Maschine: Rechnen als Termersetzung - Turing-Maschine (vgl. ThI): Rechnen als Schreiben von Zeichen auf ein Band - von Neumann: Rechnen als Modifikation von Wörtern im Speicher. Die analytische Maschine Babbage’s „analytische Maschine“ (*) war das „Nachfolgemodell“ der „Differenzmaschine“ (1821-1833), die arithmetische Berechnungen durchführen können sollte, aber nie funktionierte. Zitat eines Zeitgenossen (L. F. Menabrea, *): “Mr. Babbage has devoted some years to the realization of a gigantic idea. He proposed to himself nothing less than the construction of a machine capable of executing not merely arithmetical calculations, but even all those of analysis, if their laws are known.” Historisches Vorbild waren mit so genannten Jaquard-Lochkarten „programmierbare“ Webstühle (mit bis zu 24000 Karten). Die Rechenmaschine sollte ein „Mill“ genanntes Rechenwerk, ein „Store“ genanntes Speicherwerk (1000 fünfzigstellige Zahlen), Lochkartenleser und stanzer als Ein- und Ausgabe und einen Drucker als Ausgabe enthalten. Die Maschine sollte 3-53 mit Dampf angetrieben und frei programmierbar sein. Ein Programm sollte drei Kartentypen enthalten: • Operationskarten enthalten mögliche Operationen: Addition, Subtraktion, Multiplikation und Division. Die Maschine hat einen Schalter für den auszuführenden Operationstyp, der in seiner Stellung bleibt bis er durch eine Operationskarte umgestellt wird. • Zahlenkarten enthalten numerische Konstanten und dienen als externer Speicher, damit nicht alle benötigten Zahlen im (teuren) Speicherwerk bereit gehalten werden müssen. Auf einer Zahlenkarte steht jeweils neben dem Wert auch die Nummer des Speichers, in welchen dieser Wert geschrieben werden soll. Zwischenergebnisse können von der Maschine auf Karten gestanzt und später wieder eingelesen werden. • Variablenkarten steuern den Transfer von Werten aus dem Store zur Mill und zurück („Adressierung“). Die Maschine besitzt zwei Operandenregister („Ingress-Achsen“, je zweimal 50 Stellen: I1 und I1´, I2 und I2´) und ein Resultatregister („Egress-Achse“, zweimal 50 Stellen: E und E´); es gibt Karten zum Transport einer Variable (eines Speicherwerts) in die Ingress-Achsen und zum Transport der Egress-Achse in den Speicher. Spezielle Karten sind kombinatorische und Indexkarten, die im Kartenstapel vor- und zurückblättern können und somit Sprünge realisieren. Für Verzweigungen gibt es einen „Alarmhebel“, der hochgesetzt wird, falls • bei einer arithmetischen Operation ein Überlauf oder eine Division durch Null auftritt • das Ergebnis einer arithmetischen Operation ein anderes Vorzeichen hat als das erste Argument (d.h. Egress-Achse E hat ein anderes Vorzeichen als die Ingress-Achse I1 ) Ferner gibt es Kontrollkarten wie „Stopp“ und „Pause“. Aus den vorhandenen Dokumenten lässt sich rekonstruieren, dass für die analytischen Maschine folgende Befehle vorgesehen waren (Ausschnitt): <Programm> ::= {Karte} <Karte> ::= <Zahlkarte> | <Opkarte> | <Varkarte> <Zahlkarte> ::= “N”[z ]13 _ [“+|“-][z ]50 0 Die Zahl wird an der bezeichneten Stelle in den Speicher eingetragen <Opkarte> ::= “+” | “-” | “*” | “/” Die Operation wird für nachfolgende Befehle eingestellt <Varkarte> ::= <Transferkarte> | <Kartenkarte> | <Atkarte> <Transferkarte> ::= (“L”|“Z”|“S”)[z ]13 [“´”] Lzzz: Transfer des Inhalts der Variable zzz in die Mill Ingress Achse Zzzz: Wie Lzzz, wobei Variable zzz gleichzeitig auf Null gesetzt wird Szzz: Transfer der Egress-Achse in Variable zzz Falls ein ´ nach der Adresse steht, sind die zweiten 50 Stellen betroffen Transfer in I2 löst die Ausführung der eingestellten Operation aus <Kartenkarte> ::= “C”(“F”|“B”)(“+”|“?”)[z ]50 0 blättert die angegebene Zahl von Karten vor (F) oder zurück (B) + bedeutes unbedingtes, ? bedingtes Blättern (falls Alarmhebel hochgesetzt) 3-54 <Atkarte> ::= “B” | “H” | “P” B läutet eine Glocke um den Operateur zu verständigen H hält die Maschine an (keine weiteren Karten werden gelesen) P druckt den Wert der Egress-Achse auf dem Druckapparat Beispiel: drucke 17 + 4 N001 +00000000000000000000000000000000000000000000000017 N002 +00000000000000000000000000000000000000000000000004 + L001 L002 P Beispiel: Variable in Speicher 003 := 10000 div 28, Variable 004 := 10000 mod 28, N1 10000 N2 28 / L1 L2 S3' S4 Beispiel: Fakultätsfunktion S2 := S1! N1 6 N2 1 N3 1 * L2 L1 S2 L1 L3 S1 L3 L1 CB?11 Beispiel: drucke Tabelle für f(x) = x2 + 6x + 6, x=1..10 V1 = x V2 = x2 V3 = 6 V4 = x2, x2+6x, x2+6x+6 V5 = 6x N1 10 N3 6 * L1 L1 S4 L1 L3 S5 + L4 3-55 L5 S4 L4 L3 S4 … Umformung als f(x) = (x+3)2 – 3 bringt eine Verbesserung: V1 = x V2 = 3 V3 = x+3, (x+3)2, (x+3)2 – 3 N1 10 N2 3 + L1 L2 S3 * L3 L3 S3 L3 L2 S3 Ada Lovelace schlägt etliche solcher Verbesserungs-Transformationen vor. Ähnliche (einfachere) Optimierungen heute im Code-Generator guter Compiler enthalten. Ada Lovelace schreibt: “the Analytical Engine does not occupy common ground with mere `calculating machines´”… “on the contrary, (it) is not merely adapted for tabulating the results of one particular function and of no other, but for developing and tabulating any function whatever. In fact the engine may be described as being the material expression of any indefinite function of any degree of generality and complexity.“ … “It may be desirable to explain, that by the word operation, we mean any process which alters the mutual relation of two or more things, be this relation of what kind it may. This is the most general definition, and would include all subjects in the universe.” … “Supposing, for instance, that the fundamental relations of pitched sounds in the science of harmony and of musical composition were susceptible of such expression and adaptations, the engine might compose elaborate and scientific pieces of music of any degree of complexity or extent.“ … 3-56 Gedanken zur Universalität mathematischer Funktionen Die Turingmaschine Nahezu 100 Jahre später (1936) untersuchte Alan Turing die Grenzen des Berechenbaren. Die Arbeit „On Computable Numbers, with an Application to the Entscheidungsproblem“ kann als der Beginn der modernen Informatik betrachtet werden. Turing definierte darin eine hypothetische Maschine, die die „Essenz des Rechnens“ durchführen können soll. Eine „berechenbare“ reelle Zahl ist eine, deren (unendliche Folge von) Nachkommastellen endliche Mittel (d.h. durch einen Algorithmus) berechnet werden kann. Turing schreibt: „Computing is normally done by writing certain symbols on paper. We may suppose this paper is divided into squares like a child's arithmetic book. In elementary arithmetic the twodimensional character of the paper is sometimes used. But such a use is always avoidable, and I think that it will be agreed that the two-dimensional character of paper is no essential of computation. I assume then that the computation is carried out on one-dimensional paper, i.e. on a tape divided into squares. I shall also suppose that the number of symbols which may be printed is finite. If we were to allow an infinity of symbols, then there would be symbols differing to an arbitrarily small extent. … The behaviour of the computer at any moment is determined by the symbols which he is observing, and his "state of mind" at that moment. We may suppose that there is a bound B to the number of symbols or squares which the computer can observe at one moment. If he wishes to observe more, he must use successive observations. We will also suppose that the number of states of mind which need be taken into account is finite. The reasons for this are of the same character as those which restrict the number of symbols. If we admitted an infinity of states of mind, some of them will be "arbitrarily close" and will be confused.“ Eine Turingmaschine ist also gegeben durch • einen endlichen Automaten zur Programmkontrolle, und • ein (potentiell unbegrenztes) Band auf dem die Maschine Zeichen über einem gegebenen Alphabet notieren kann. Zu jedem Zeitpunkt kann die Maschine genau eines der Felder des Bandes lesen (abtasten, „scannen“), und, ggf.. abhängig von der Inschrift dieses Feldes, das Feld neu beschreiben, zum linken oder rechten Nachbarfeld übergehen, und einen neuen Zustand einstellen. Hier ist eine Syntax, die Turings Originalschreibweise nahe kommt. <Turingtabelle> ::= {<Zeile>} <Zeile> ::= <Zustand> <Abtastzeichen> <Operation> <Zustand> <Zustand> ::= <Identifier> <Abtastzeichen> ::= <Zeichen> <Operation> ::= {R | L | P<Symbol>} Dabei wird angenommen, dass für jeden Zustand und jedes Abtastzeichen des Alphabets genau eine Zeile der Tabelle existiert, welche Operation und Nachfolgezustand festlegt. (Turing nennt solche Maschinen “automatisch”, wir nennen sie heute “deterministisch”. In Turings Worten: „If at any stage the motion of a machine is completely determined by the 3-57 configuration, we shall call the machine an ‚automatic machine’ ... For some purposes we might use machines (choice machines or c-machimes) whose motion is only partially determined by the configuration… When such a machine reaches one of these ambiguous configurations, it cannot go on until some arbitrary choice has been made by an external operator… In this paper I deal only with automatic machines.’’) Da die Tabellen für deterministische Turingmaschinen oft sehr groß werden, darf man Zeilen, die nicht benötigte werden, weglassen, und Zeilen, die sich nur durch das Abtastzeichen unterscheiden, zusammenfassen. In der entsprechenden Zeile sind beliebige Mengen von Abtastzeichen zugelassen. „any“ steht dann für ein beliebiges Abtastzeichen, d.h. für das gesamte Alphabet. Ferner fordert Turing, dass das Alphabet immer ein spezielles Leerzeichen „none“ enthält, und „E“ eine Abkürzung für „P none“ ist.. Beispiel für eine Maschine, die das Muster „0 11 0 11 0 11…“ auf ein leeres Band schreibt: s0 s1 any any P0,R,R s1 P1,R,P1,R,R s0 Ein äquivalentes Programm mit nur einem Zustand s0 ist s0 none P0 s0 s0 0 R,R,P1,R,P1 s0 s0 1 R,R,P0 s0 Beispiel für eine Maschine, die die Sequenz 0 01 011 0111 01111 … erzeugt: Arbeitsweise dieser Maschine: 3-58 Die von Turing verwendete Tabellenschreibweise für Programme betrachten wir heute als unleserlich. Eine moderne Variante („Turing-Assembler“) wäre etwa: <Turingprogram> ::= {<statement>} <statement> ::= <label>“:” | “print” <symbol> “;” | “left;” | “right;” | “goto” <label>“;” <label> ::= <Identifier> In dieser Notation sähe unser Beispielprogramm etwa so aus: label0: print 0; right; right; print 1; right; print 1; right; right; goto s0; Im Internet sind viele Turingmaschinen-Simulatoren verfügbar, versuchen Sie z.B. http://math.hws.edu/TMCM/java/labs/xTuringMachineLab.html 3-59 Andere empfohlene Beispiele: http://www.matheprisma.uni-wuppertal.de/Module/Turing/ http://ais.informatik.uni-freiburg.de/turing-applet/turing/TuringMachineHtml.html Zuse Z3 erster voll funktionsfähiger programmierbarer Digitalrechner viele Merkmale moderner Rechner: • Relais-Gleitkommaarithmetikeinheit für Arithmetik • einem Relais-Speicher aus 64 Wörtern, je 22 bit • einem Lochstreifenleser für Programme auf Filmstreifen • eine Tastatur mit Lampenfeld für Ein- und Ausgabe von Zahlen und der manuellen Steuerung von Berechnungen. • Taktung durch Elektromotor, der Taktwalze antreibt (5rps) Programmiersprache: Plankalkül http://www.zib.de/zuse/Inhalt/Programme/Plankalkuel/Compiler/plankalk.html Von-Neumann-Rechner, EDVAC & ENIAC John von Neumann wurde vor hundert Jahren (im Dezember 1903) in Budapest geboren. 1929 wurde er als jüngster Privatdozent in der Geschichte der Berliner Universität habilitiert. Von Neumann wurde binnen kurzer Zeit weltberühmt durch seine vielfältigen Interessen auf dem Gebieten Mathematik, Physik und Ökonomie. 1930 emigrierte er wegen der Nazis nach USA. In Princeton schuf von Neumann dann mit dem von ihm erdachten Rechnerkonzept die Grundlagen für den Aufbau elektronischer Rechenanlagen, die noch bis heute gültig sind. Er gilt daher als einer der Begründer der Informatik EDVAC, ENIAC (Electronic Numerical Integrator and Computer) • Elektronenröhren zur Repräsentation von Zahlen • elektrische Pulse für deren Übertragung • Dezimalsystem • Anwendung: H-Bomben-Entwicklung • Programmierung durch Kabel und Drehschalter 3-60 3.2 von-Neumann-Architektur 1945 First Draft of a Report on the EDVAC (Electronic Discrete Variable Automatic Computer): Befehle des Programms werden wie zu verarbeitenden Daten behandelt, binär kodiert und im internen Speicher verarbeitet (vgl. Zuse, Turing) Ein von-Neumann-Computer enthält mindestens die folgenden fünf Bestandteile: 1. Input unit (kommuniziert mit der Umgebung) 2. Main memory (Speicher für Programme und Daten) 3. Control unit (führt die Programme aus) 4. Arithmetic logical unit (für arithmetische Berechnungsschritte) 5. Output unit (kommuniziert mit der Umgebung) Rechenwerk (central processing unit, CPU) Steuerwerk (control unit) Rechenwerk (arithmetic logical unit, ALU) Hauptspeicher (Main memory) Eingabe (input) Ausgabe (output) 3-61 Peripherie Konsole Bildschirm Tastatur (+Maus) Drucker Plattenspeicher Prozessor (CPU - central processing unit) Steuerwer k Register: Rechenwerk CDLaufwerk E/A Prozessor (einzelne Operationen) Register: Bus (Verbindung) Hauptspeicher (Arbeitsspeicher) Prinzipien: • Der Rechner enthält zumindest Speicher, Rechenwerk, Steuerwerk und Ein/Ausgabegeräte. „EVA-Prinzip“: Eingabe – Verarbeitung – Ausgabe • Der Rechner ist frei programierbar, d.h., nicht speziell auf ein zu bearbeitendes Problem zugeschnitten; zur Lösung eines Problems wird ein Programm im Speicher ablegt. Dadurch ist jede nach der Theorie der Berechenbarkeit mögliche Berechnung programmierbar. o Programmbefehle und Datenworte liegen im selben Speicher und werden je nach Bedarf gelesen oder geschrieben. o Der Speicher ist unstrukturiert; alle Daten und Befehle sind binär codiert. o Der Speicher wird linear (fortlaufend) adressiert. Er besteht aus einer Folge von Plätzen fester Wortlänge, die über eine bestimmte Adresse einzeln angesprochen werden können und bit-parallel verarbeitet werden. o Die Interpretation eines Speicherinhalts hängt nur vom aktuellen Kontext des laufenden Programms ab. Insbesondere: Befehle können Operanden anderer Befehle sein (Selbstmodifikation)! • Der Befehlsablauf wird vom Steuerwerk bestimmt. Er folgt einer sequentiellen Befehlsfolge, streng seriell und taktgesteuert. o Zu jedem Zeitpunkt führt die CPU nur einen einzigen Befehl aus, und dieser kann (höchstens) einen Datenwert verändern (single-instructionsingle-data). o Die normale Verarbeitung der Programmbefehle geschieht fortlaufend in der Reihenfolge der Speicherung der Programmbefehle. Diese sequentielle Programmabarbeitung kann durch Sprungbefehle oder datenbedingte Verzweigungen verändert werden. 3-62 • Die ALU führt arithmetische Berechnungen durch, indem sie ein oder zwei Datenwerte gemäß eines Befehls verknüpft und das Ergebnis in ein vorgegebenes Register schreibt. Zwei-Phasen-Konzept der Befehlsverarbeitung: o In der Befehlsbereitstellungs- und Decodierphase-Phase wird, adressiert durch den Befehlszähler, der Inhalt einer Speicherzelle geholt und als Befehl interpretiert. o In der Ausführungs-Phase werden die Inhalte von einer oder zwei Speicherzellen bereitgestellt und entsprechend den Opcode als Datenwerte verarbeitet. • Datenbreite, Adressierungsbreite, Registeranzahl und Befehlssatz als Parameter der Architektur • Ein- und Ausgabegeräte sind z.B. Schalter und Lämpchen, aber auch entfernte Speichermedien (Magnetbänder, Lochkarten, Platten, …). Sie sind mit der CPU prinzipiell auf die selbe Art wie der Hauptspeicher verbunden. Zentrale Befehlsschleife (aus *): Vergleiche: Assemblersprache, Ausführung eines Befehles Vor- und Nachteile der von-Neumann-Architektur: + minimaler Hardware-Aufwand, Wiederverwendung von Speicher + Konzentration auf wesentliche Kennzahlen: Speichergröße, Taktfrequenz - Verbindungseinrichtung CPU – Speicher stellt einen Engpass dar („von-NeumannFlaschenhals“) - keine Strukturierung der Daten, Maschinenbefehl bestimmt Operandentyp „von-Neumann-bottleneck“ John Backus, Turing-Award-Vorlesung 1978: When von Neumann and others conceived it [the von Neumann computer] over thirty years ago, it was an elegant, practical, and unifying idea that simplified a number of engineering and programming problems that existed then. Although the conditions that produced its architecture have changed 3-63 radically, we nevertheless still identify the notion of "computer" with this thirty [jetzt fast sechzig] year old concept. In its simplest form, a von Neumann computer has three parts" a central processing unit (or CPU), a store, and a connecting tube that can transmit a single word between the CPU and the store (and send an address to the store). I propose to call this tube the von Neumann bottleneck. The task of a program is to change the store in a major way; when one considers that this task must be accomplished entirely by pumping single words back and forth through the von Neumann bottleneck, the reason for its name becomes clear. Wie oben erwähnt, sind auch heute noch die meisten Computer nach der von-NeumannArchitektur konstruiert. Beispiel: Architektur des Intel Pentium-Prozessors (*): Realisierung der einzelnen Baugruppen durch Mengen von Halbleiterschaltern; z.B. eines Halbaddierers (Summe= E1 XOR E2, Übertrag=E1 AND E2): 3-64 Beschreibung der Hardware auf verschiedenen Ebenen: Physikalische Ebene, TransistorEbene, Gatter-Ebene (s.o.), Register-Ebene, Funktionsebene (siehe TI) Abweichungen und Varianten der von-Neumann-Architektur: • Spezialisierte Eingabe-Ausgabe-Prozessoren o z.B. Grafikkarte mit 3D-Rendering o z.B. Modem oder Soundkarte zur Erzeugung von Tönen o z.B. Tastatur-, Netzwerk- oder USB-Controller, die auf externe Signale warten • Parallelität zwischen/innerhalb von Funktionseinheiten, z.B. o Blocktransfer von Daten o Pipelining in CPU • Duplikation von Funktionseinheiten o z.B. Mehrprozessorrechner, Mehrkern-Architektur: Zwei oder mehr CPU auf einem Chip, Kopplung durch speziellen Memory-Control-Bus; z.B. Pentium Extreme Edition 840 (April 2005), Preis 999 Dollar, „dürfte allerdings nur wenig Käufer finden“; Aktuell: Quadcore, Octocore (Sun UltraSparc, Intel Nehalem 2008); • komplexere Speicherstrukturen, z.B. o Register (einzelne Speicherwörter direkt in der CPU) o Caches (schnelle Pufferspeicher) und Bus-Hierarchien für Verbreiterung des Flaschenhalses o Harvard-Architektur (Trennung von Daten- und Befehlsspeicher) • komplexere Verbindungsstrukturen o externe Standardschnittstellen: IDE, SCSI, USB, IEEE1394/Firewire/iLink o ISA/PCI/AGP: hierarchischer bzw. spezialisierter Aufbau des Bussystems • komplexere Befehle, z.B. o mehrere Operanden o indirekte Adressierung („Adresse von Adresse“) o CISC versus RISC • Programmunterbrechung durch externe Signale o Interrupt-Konzept o Mehrbenutzer-Prozesskonzept 3.3 Aufbau PC/embedded system, Speicher Heutige PCs sind meist prinzipiell nach der von-Neumann-Architektur , mit o.g. Erweiterungen, konstruiert. Beispiel (Gumm/Sommer p55) 3-65 Prinzipiell ist dieser Aufbau auch in den meisten eingebetteten Steuergeräten zu finden. Beispiel: ein Steuergerät im Audi quattro, welches zwei separate Prozessoren für Zündung und Ladung enthält, und ein seriell einstellbares Drehzahlsteuergerät für Elektromotoren. 3-66 Unterschiede zur „normalen“ Rechnerarchitektur: • Ein- und Ausgabegeräte sind Sensoren und Aktuatoren • Wandlung analoger in digitale Signale und umgekehrt auf dem Chip (A/D D/A) • Der Speicher ist oft nichtflüchtig und manchmal mit dem Prozessor integriert • Meist wird nur wenig Datenspeicher benötigt, der Programmspeicher wird nur bei der Produktion oder Wartung neu beschrieben ( andere Speicherkonzepte) • Hohe Stückzahlen verursachen Ressourcenprobleme (Speicherplatz) • Oft komplizierte Berechnungen mit reellen Zahlen (DSP, digitale Signalprozessoren), spezialisierte ALUs für bestimmte numerische Algorithmen. Speicher Speicher dienen zur temporären oder permanenten Aufbewahrung von (binär codierten) Daten. Sie können nach verschiedenen Kriterien klassifiziert werden • Permanenz: flüchtig – nichtflüchtig (bei Ausfall der elektrischen Spannung) o Halbleiterspeicher sind meist flüchtig, magnetische / optische Speichermedien nicht. o So genannte Flash Speicher sind nichtflüchtige Halbleitermedien, die elektrisch beschrieben und gelöscht werden können. Bei Flash-Speichern ist nicht jedes einzelne Bit adressierbar, die Zugriffe erfolgen auf Sektorebene ähnlich wie bei Festplatten. Vorteil: keine mechanisch bewegten Teile. • Geschwindigkeit: Zugriffszeit (Taktzyklen oder ns) pro Wort (mittlere, maximale) o Gängige Zugriffszeiten liegen bei 1-2 Taktzyklen für Register in der ALU, 250 ns für einen schnellen Cache, 100-300 für einen Hauptspeicherzugriff, 1050ms für einen Plattenzugriff, 90 ms für eine CD-ROM, Sekundenbereich für Floppy Disk, Minutenbereich für Magnetbänder • Preis: Cent pro Byte 3-67 • o Ein 512 MB Speicherbaustein oder CompactFlash kostet 100 Euro (2*10-5 c/B=20c/MB), eine 120GB Festplatte etwa genauso viel (8.3*10-8 c/B =83c/GB), ein 700MB CD-Rohling 25 cent (3.5*10-8c/B=35c/GB) Größe: gemessen in cm2 oder cm3 pro Bit o Papier: 6000 Zeichen/630cm²=10B/cm²; Floppy: 1.44MB/ 80cm2=18 KB/cm2; Festplatte: 10-20 GB/cm2, physikalisch machbar 1000GB/cm2 m o für mechanisch bewegte Speicher muss der Platz für Motor und Bewegungsraum mit berücksichtigt werden. Wie man sieht, sind Geschwindigkeit und Preis umgekehrt proportional. Üblicherweise wird der verfügbare Speicherplatz daher hierarchisch strukturiert. Das Vorhalten von Teilen einer niedrigeren Hierarchieebene in einer höheren nennt man Caching. Cache („Geheimlager“): kleiner, schneller Vordergrundspeicher, der Teile der Daten des großen, langsamen Hintergrundspeichers abbildet („spiegelt“). Konzept: Hauptspeicher = Folge von Tupeln (Adresse, Inhalt) Cache-Speicher = Folge von Quadrupeln Cache-Zeilen: (Index, Statusbit, Adresse, Inhalt) Index: Adresse im Cache Statusbits: modifiziert?, gültig?, exklusiv?, … Adresse: Speicherzelle die gespiegelt wird Falls die CPU ein Datum dat einer bestimmten Adresse adr benötigt, wird zunächst geprüft, ob es im Cache ist (d.h. (idx, sbt, adr, dat) im Cache). Zwei Fälle: Cache Hit: D.h. (idx, sbt, adr, dat) im Cache gespiegelt: dat wird Cache Miss: kein idx mit (idx, adr, …) im Cache. Die Speicherzelle adr mit Inhalt dat muss aus dem Hauptspeicher nachgeladen werden; ggf. muss dafür ein bereits belegter Platz im Cache geräumt werden (Verdrängungsstrategie) SPEICHER 3786: 3787: 3788: 3789: 3790: 3791: 3792: 3793: … 17 "c" 3.1415 3786 "x" 123456 3790 NIL … CACHE c1: 0 c2: 1 c3: 0 1 1 1 0 0 0 3787 3788 3792 "c" 3.1415 3790 c1: 0 c2: 1 c3: 0 1 1 1 0 0 0 3790 3788 3792 "x" 3.1415 3790 Falls jede Hintergrundadresse prinzipiell in jede Cache-Zelle geladen werden kann, ist der Cache assoziativ (fully associative). Vorteil: Flexibilität. 3-68 Nachteil: gesamter Cache muss durchsucht werden, ob Hit oder Miss. Falls eine Cache-Zelle nur bestimmte Hintergrundadressen abbilden kann, sagen wir der Cache ist direkt abgebildet (direct mapped) Beispiel: C0 für H00, H10, H20, …, C1 für H01, H11, H21, …Suche H63 nur in C3! Vorteil: schnelle Bestimmung ob Hit oder Miss; Nachteil: Unflexibilität Hitrate ist entscheidend für Leistungssteigerung durch Cache; Verdrängungsstrategie beeinflusst Hitrate entscheidend • LRU: Least recently used • FIFO: First-In, First-Out • LFU: Least frequently used Cache Coherency Problem: Mehrere Prozesse greifen auf Speicher zu, jeder Prozess hat einen eigenen Cache: Wie wird die Konsistenz sichergestellt? 3-69 Kapitel 4: Programmiersprachen und –umgebungen Zur Wiederholung: Informatik ist die Wissenschaft von der automatischen Verarbeitung von Informationen; ein Algorithmus ist ein präzises, schrittweises und endliches Verfahren zur Verarbeitung (Umformung) von Informationen, und ein Programm ist die Repräsentation eines Algorithmus zur Ausführung auf einer Maschine Während v. Neumann noch der Ansicht war, dass „Rechenzeit zu wertvoll für niedere Aufgaben wie Übersetzung“ ist, sind heute dagegen die Personalkosten der Hauptfaktor bei den Softwarekosten. Darüber hinaus ist die Codierung in Maschinensprache oder Assembler sehr fehleranfällig (wie jeder, der die Haufgaben bearbeitet hat, wohl gemerkt haben dürfte). Daher sucht man nach problemorientierten statt maschinenorientierten Beschreibungsformen. Die Verständlichkeit eines Programms ist oftmals wichtiger als die optimale Effizienz. Vorteile höherer Programmiersprachen zeigen sich • bei der Erstellung von Programmen: o schnellere Programmerstellung o sichere Programmierung. • bei der Wartung von Programmen: o bessere Lesbarkeit o besseres Verständnis der Algorithmen. • wenn Programme wiederverwendet werden sollen: o Verfügbarkeit auf vielen unterschiedlichen Rechnern; vom Zielrechner unabhängige Entwicklungsrechner. 4.1 Programmierparadigmen Unter einem Programmierparadigma versteht man ein Sprachkonzept, welches als Muster prägend für die Programme einer bestimmten Gruppe oder Sprache ist. Gängige Programmierparadigmen sind o funktional / applikativ o imperativ o objektorientiert o logikbasiert o deklarativ o visuell / datenflussorientiert o funktionale oder applikative Sprachen betrachten ein Programm als mathematische Funktion f, die eine Eingabe I in eine Ausgabe O überführt: O = f(I). Variablen sind Platzhalter im Sinne der Mathematik (Parameter). Ausführung = Berechnung des Wertes mittels Termersetzung. Beispiele: Lisp, SML, Haskell, ... SML: fun fak (n) = if n < 1 then 1 else n * fak (n-1) LISP: (defun fak (n) (cond (le n 1) 1 (* n (fak (– n 1))))) o imperative Sprachen unterstützen das ablauforientierte Programmieren. Werte werden sog. Variablen (= Speicherplätzen) zugewiesen. Diese können ihren Zustand ändern, d.h. im Laufe der Zeit verschiedene Werte annehmen (vgl. von-Neumann-Konzept). Ausführung = Folge von Variablenzuweisungen. Beispiele: Algol, C, Delphi, ... 4-70 // Fakultätsfunktion in C: #include <stdio.h> int fakultaet(int n) { int i, fak = 1; for ( i=2; i<=n; i++ ) fak *= i; return fak; } int main(void) { int m, n; for ( n=1; n<=17; n++ ) printf("n = %2d n! = %10d\n", n, fakultaet(n)); } o objektorientierte Sprachen sind imperativ, legen aber ein anderes, in Objekten strukturiertes Speichermodell zugrunde. Objekte können eigene lokale Daten und Methoden speichern, voneinander erben und miteinander kommunizieren. Ausführung = Interaktion von Agenten. Beispiele: Smalltalk, C++, Java //Java Polymorphie-Beispiel import java.util.Vector; abstract class Figur { abstract double getFlaeche(); } class Rechteck extends Figur { private double a, b; public Rechteck ( double a, double b ) { this.a = a; this.b = b; } public double getFlaeche() {return a * b;} } class Kreis extends Figur { private double r; public Kreis( double r ) { this.r = r; } public double getFlaeche() {return Math.PI * r * r; } } public static void main( String[] args ) { Rechteck re1 = new Rechteck( 3, 4 ); Figur kr2 = new Kreis( 8 ); … o logikbasierte Sprachen betrachten ein Programm als (mathematisch-) logischen Term t, dessen Auflösung (im Erfolgsfall) zu gegebenen Eingabewerten I passende Ausgabewerte O liefert: t(I,O) → true. 4-71 Ausführung = Lösen eines logischen Problems. Beispiel: Prolog fak(0,1). fak(N,X):- N > 0, M is N - 1, fak(M,Y), X is N * Y. mutter (eva, maria). mutter (maria, uta). grossmutter(X,Y) :- mutter(X,Z), mutter(Z,Y). ?- grossmutter (eva, uta) yes. ?- grossmutter (uta, eva) no. o deklarative Sprachen betrachten ein Programm als eine Menge von Datendefinitionen (Deklarationen) und darauf bezogenen Abfragen. Ausführung = Suche in einem Datenbestand. Beispiel: SQL SELECT A_NR, A_PREIS As Netto, 0.16 As MwSt, A_PREIS * 1.16 As Brutto, FROM ARTIKEL WHERE A_PREIS <= 100 ORDER BY A_PREIS DESC o datenflussorientierte Sprachen stellen ein Programm dar, indem sie den Fluss der Signale oder Datenströme durch Operatoren (Addierer, Integratoren) beschreiben Ausführung = Transformation von Datenströmen Beispiel: Simulink, Microsoft Visual Programming Language (VPL) 4-72 4.2 Historie und Klassifikation von Programmiersprachen Zur obigen Grafik gibt es viele Varianten und Alternativen, je nach Vorliebe des DiagrammErstellers. Programmiersprachen lassen sich klassifizieren nach Anwendungsgebiet: Ingenieurwissenschaften (Fortran), kommerzieller Bereich (Cobol), künstliche Intelligenz (Prolog, Lisp), Systemsoftware (Assembler, C), Steuerungssoftware (C, Ada, Simulink), Robotik (C, NQC, VPL), Internet / Arbeitsplatzsoftware (Java, C++), Datenbanken (SQL), … 4-73 • • o Spezialsprachen („DSL, domain specific languages“): SPSS (Statistik); TeX/LaTeX, PostScript (Textverarbeitung); Lex, Yacc (Compilerbau); Z, B, CSP (Programmspezifikation); UML, SimuLink (Modellierung) Verbreitungsgrad: (derzeit )industrierelevante, überlieferte und akademische Sprachen Historischer Entwicklung: „Stammbaum“ der Programmiersprachen, siehe oben Programmiersprachengeneration: Abstraktionsgrad; o 1. Generation: Maschinensprachen o 2. Generation: Assemblersprachen (x86-MASM, ASEM-51) o 3. Generation: Algorithmische Sprachen (Algol, Delphi, …) o 4. Generation: Anwendungsnahe Sprachen (VBA, SQL, …) o 5. Generation: Problemorientierte Sprachen, KI-Sprachen (Prolog, Haskell, …) Programmierparadigmen (siehe Kapitel 4.1) Verfügbaren Sprachelementen o Kontrollfluss, Datentypen o Rekursion oder iterative Konstrukte o Sequentielle oder parallele Ausführung o Interpretiert oder compiliert o Realzeitfähig oder nicht realzeitfähig o Streng typisiert, schwach typisiert oder untypisiert o Statisch oder dynamisch typisiert o Textuell oder graphisch o … 50 44 45 47 45 40 35 30 25 19 20 15 10 10 13 22 14 6 5 0 Fortran Delphi Pascal Java Cobol C++ Das Balkendiagramm zeigt die in deutschen Softwarehäusern überwiegend verwendeten Sprachen, zitiert nach (Bothe, Quelle: Softwaretechnik-Trends Mai 1998). Mehrfachnennungen waren bei der Befragung möglich. Die „babylonische Sprachvielfalt“ der verschiedenen Programmiersprachen verursacht Kosten: Portabilitätsprobleme, Schulungsmaßnahmen, Programmierfehler. Daher gab es immer wieder Versuche, Sprachen zu vereinheitlichen (ADA, ANSI-C, …). Trotzdem kam es in der Forschung immer wieder zu neuen Ideen, neuen Konzepten und in der Folge zu neuen Programmiersprachen. Daher wird es auf absehbare Zeit viele verschiedene Sprachen und Dialekte geben. Für Informatiker ist es deshalb wichtig, die verschiedenen Konzepte zu kennen und sich schnell in neue Sprachen einarbeiten zu können. 4.3 Java und Groovy Java wurde seit 1995 bei der Firma Sun Microsystems entwickelt. Ursprünglich war die Sprache zur Implementierung von sicheren, plattform-unabhängigen Anwendungen gedacht, die über das Internet verbreitet und bezogen werden können. Ein wichtiges Konzept ist das 4-74 sog. Applet, eine Mini-Anwendung, die innerhalb einer Web-Seite läuft. Inzwischen sind praktisch alle Internet-Browser „Java-fähig“ und können Applets ausführen. Künftige Anwendungen von Java liegen voraussichtlich im Bereich der kommunizierenden Geräte („Internet der Dinge“). Vorzüge von Java sind: o Java ist eine mächtige Programmiersprache, die die Sprachkonzepte herkömmlicher Programmiersprachen wie C oder Pascal mit OO-Konzepten und Konzepten zur Parallelverarbeitung und Netz-Verteilung verbindet. Zielsetzung war es, eine möglichst schlanke Sprache zu schaffen, die das Klassenkonzept ins Zentrum der Sprache stellt. o Das Konzept des Java Byte Code und der Java Sandbox erlauben eine einfache Portierbarkeit und sichere Ausführbarkeit auf den unterschiedlichsten Plattformen. Die Offenheit und hohe Portabilität von Java macht die Sprache zu einem guten Werkzeug für das Programmieren von Netz-Anwendungen. o Java gilt als robuste Sprache, die viele Fehler vermeiden hilft, z.B. aufgrund ihres Typkonzepts und der automatischen Speicherbereinigung. Java-Compiler sind im Allgemeinen vergleichsweise schnell und produzieren effizienten Code. o Durch die große Beliebtheit in akademischen Kreisen und bei Open-Source-Anwendern hat Java eine umfangreiche Klassenbibliothek, und es gibt viele Unterstützungswerkzeuge. Im Kern ist Java jedoch eine Programmiersprache mit imperativen und rekursiven Konzepten ähnlich wie Pascal oder C. So weist Java die meisten aus diesen Sprachen bekannten Konzepte wie Variablen, Zuweisungen, Datentypen, Ausdrücke, Anweisungen etc. auf - mit einer keineswegs verbesserten Syntax Viele syntaktische und strukturelle Mängel und Ungereimtheiten sind nur mit der C / C++ Historie zu erklären Die Ausführung eines Java-Programms geschieht im Allgemeinen in folgenden Schritten: (1) Eingabe des Programmtextes in einem Editor oder einer IDE (integrierten Entwicklungsumgebung). (2) Compilation, d.h. Übersetzung des Programms (oder von Teilen des Programms) in Byte-Code für die „virtuelle Java-Maschine“ (JVM). (3) Binden und Laden, d.h. Zusammentragen aller verwendeten Bibliotheksklassen, Ersetzung von Sprungadressen usw., und ggf. Übertragung des Programms auf die Zielplattform. (4) Aufruf des Programms und Start des Prozesses. Bei der so genannten „just-in-time-compilation“ wird Schritt (2), (3) und (4) verschränkt zur Laufzeit ausgeführt, d.h., es werden immer nur die Teile übersetzt (z.B. von ByteCode in Maschinencode), die gerade benötigt werden. Die meisten modernen Compiler bzw. Laufzeitumgebungen unterstützen diese Methode. Konzept der virtuellen Maschine: Bis in die 1990-Jahre musste für jede Sprache und jede mögliche Hardware-Plattform ein eigener Compiler erstellt werden. Das Konzept einer „virtuellen Maschine“, d.h., eines standardisierten Befehlssatzes, erlaubt es, Compiler zu erstellen, die Code für diese idealisierte Maschine erstellen (in Java: Byte Code für die Java Virtual Machine). Auf den einzelnen Plattformen muss dann nur noch eine „Laufzeitumgebung“ definiert werden, die diese virtuelle Maschine mit der realen Hardware simuliert. 4-75 Delphi Sun-Ray Java Groovy Scala Java PC C C++ C++ Android JVM Groovy C C# Sun-Ray C# PC .NET Android Java-Historie ab 1991 Bei der Firma Sun wird von J. Gosling und Kollegen auf der Basis von C++ eine Sprache namens Oak (Object Application Kernel) für den Einsatz im Bereich der Haushalts- und Konsumelektronik entwickelt. Ziele: Plattform-Unabhängigkeit, Erweiterbarkeit der Systeme und Austauschbarkeit von Komponenten. 1993 Oak wird im Green-Projekt zum Experimentieren mit graphischen BenutzerSchnittstellen eingesetzt und später (wegen rechtlicher Probleme) in Java umbenannt. Zu diesem Namen wurden die Entwickler beim Kaffeetrinken inspiriert. 1994 Das WWW beginnt sich durchzusetzen. Java wird wegen der Applet-Technologie „die Sprache des Internets“ seit 1995 Sun bietet sein Java-Programmiersystem JDK (Java Development Kit) mit einem Java-Compiler und Interpreter kostenlos an. ab 1996 Unter dem Namen JavaBeans wird eine Komponenten-Architektur für JavaProgramme entwickelt und vertrieben. ab 2001 Eclipse-Projekt: integrierte Entwicklungsumgebung für Java und (darauf aufbauend) andere Sprachen und Systeme. 2006-2007 JDK als Open-Source freigegeben (OpenJDK) Groovy Groovy ist eine Erweiterung von Java, die 2003 definiert wurde mit den folgenden Zielen: • skriptartige Sprache, d.h., einzelne Anweisungen können sofort ausgeführt werden • dynamische Typisierung, d.h., es ist möglich, den Typ von Objekten zur Laufzeit vom System bestimmen zu lassen • funktionale Programmierung mit Closures, d.h. Auffassung von Code als Daten, der zur Laufzeit analysiert und übersetzt wird • originäre Unterstützung von Listen, Mengen, endlichen Funktionen; reguläre Ausdrücke und Mustervergleich für Textbearbeitungsaufgaben • Schablonensystem für HTML, SQL; Scripting von Office- und anderen Anwendungen • einfachere, „sauberere“ Syntax als Java • weitgehende Kompatibilität zu Java, Code für die Java Virtual Machine Auf Grund der einfachen Handhabung in der Groovy-Konsole eignet sich Groovy besonders für Programmieranfänger und für die „schnelle“ Erstellung von Programmen („agile Software-Entwicklung“). 4-76 4.4 Programmierumgebungen am Beispiel Eclipse Während der Erstellung eines Programms sind vom Programmierer verschiedene Aufgaben zu erledigen. Dafür stehen verschiedene Werkzeuge zur Verfügung: • Eingabe des Textes – Texteditor, syntaxgesteuerte Formatierer • Übersetzung in Maschinencode – Compiler bzw. Interpreter • Binden zu einem lauffähigen Programm – Linker, Object Code Loader • Finden von semantischen Fehlern – Debugger, Object Inspector • Optimierung des Programms – Profiler, Tracer • Auffinden von Bibliotheksroutinen – Library Class Browser • Design der graphischen Benutzungsoberfläche – GUI-Builder • Modellierung des Problems – Modeling Tools • Verwaltung verschiedener Versionen – Versionskontrollsystem • Dokumentation – Dokumentationsgeneratoren, Klassenhierarchieanzeiger • Testen – Testgeneratoren Ursprünglich waren alle diese Funktionen in separaten Werkzeugen realisiert. Das ist recht umständlich, weil der Programmierer ständig zwischen den Werkzeugen wechseln muss. Daher begann man bereits in den 1970-er Jahren, die verschiedenen Aktivitäten beim Übersetzen und Binden durch Skripten zusammenzufassen (Make-files). Später wurden integrierte Entwicklungsumgebungen (integrated development environments, IDE) geschaffen, die den Texteingabe-, Übersetzungs- und Ausführungsprozess zusammenfassten. 2001 begann, initiiert durch die IBM, die Entwicklung der Eclipse IDE, einer freien, erweiterbaren Entwicklungsumgebung. Ursprünglich war Eclipse nur eine IDE für Java. Durch den Plug-In-Mechanismus ist es beliebigen Entwicklern möglich, Erweiterungen (auch für andere Programmiersprachen) vorzunehmen, so dass heute über 100 verschiedene integrierbare Werkzeuge vorliegen. Eclipse zeichnet sich vor allem aus durch: • minimale Kernfunktionalität, extreme Erweiterbarkeit • Persistenz (gesamter Entwicklungszustand bleibt erhalten) • verschiedene Sichten auf ein Projekt („Views“), projektspezifisch konfigurierbare Perspektiven (Fenster, Leisten,…) 4-77 • • • syntaxgesteuerte Editoren, Just-in-Time Compiler, … JDT, CDT: Java / C++ Development Tools EMF, GEF: Eclipse Modelling Frameworks, Graphical Editing Framework Zu Eclipse gibt es eine umfangreiche online-Dokumentation im Programm selbst. Die aktuelle Version von Eclipse kann bezogen werden unter http://www.eclipse.org/ Zur Installation des Groovy-Plugins ist in Eclipse der folgende Server anzugeben: http://dist.springsource.org/milestone/GRECLIPSE/e3.5/ 4-78 Kapitel 5: Applikative Programmierung In der applikativen Programmierung wird ein Programm als eine mathematische Funktion von Eingabe-in Ausgabewerte betrachtet. Das Ausführen eines Programms besteht dann in der Berechnung des Funktionswertes zu einem gegebenen Eingabewert. Historisch erwachsen ist dieses Paradigma aus dem Church’schen λ–Kalkü (lambda-Kalkül)l. 5.1 λ-Kalkül Gegeben sei ein Alphabet A={x1,…,xn}. Ein λ–Term ist definiert durch folgende Grammatik: λ–Term ::= Variable | (λ–Term λ–Term) | λ Variable . λ–Term Variable ::= x1 | … | xn λ–Terme der Art (t1 t2) heißen Applikation, λ–Terme der Art λx.t heißen λ-Abstraktion. In der λ-Abstraktion (λx.t) ist die Variable x innerhalb von t gebunden. Intuitiv wird der λ– Term (t1 t2) gelesen als „die Funktion t1 angewendet auf Argument t2“ und der λ–Term λx.t als „diejenige Funktion, die x als Argument hat und t als Ergebnis liefert“. Die Auswertung von λ–Termen ist definiert durch die Regel der β-Konversion: (λ x. t1) t2 = t1 [x:=t2] Hierbei bezeichnet t1 [x:=t2] den Term t1, wobei jedes freie (d.h. nicht durch ein λ gebundene) Vorkommnis von x durch t2 ersetzt wird. Klammern werden, soweit eindeutig, weggelassen, wobei Linksassoziativität unterstellt wird: (x y z) = ((x y) z) Eine weitere Regel ist die der α-Konversion, d.h. die Umbenennung von gebundenen Variablen: λ x. t = λ y. t[x:=y] Beispiel für eine Reduktion ist etwa die folgende: ((λx. x x) ((λz. z y) x)) ((λz. z y) x) ((λz. z y) x) ((λz. z y) x) (x y) (x y) (x y) Eine andere Ableitung für denselben Term wäre z.B.: ((λx. x x) ((λz. z y) x)) (λx. x x) (x y) (x y) (x y) Alonzo Church zeigte, dass man mit dem λ-Kalkül und einem kleinen „Trick“ alle arithmetischen Funktionen berechnen kann: Sei 0 ≡ λf.λx. x 1 ≡ λf.λx. f x 2 ≡ λf.λx. f (f x) 3 ≡ λf.λx. f (f (f x)) ... n ≡ λf.λx. fn x Dann lassen sich die arithmetischen Funktionen wie folgt definieren: succ ≡ λn.λf.λx.n f (f x) 5-79 plus ≡ λm.λn.λf.λx. m f (n f x) mult ≡ λm.λn.λf. n (m f) Als Beispiel zeigen wir, dass 1+1=2: (λm.λn.λf.λx. m f (n f x)) (λf.λx. f x)(λf.λx. f x) = (λf.λx. (λf.λx. f x) f ((λf.λx. f x) f x)) = (λf.λx. (λx. f x) ((λx. f x) x)) = (λf.λx. (λx. f x) (f x)) = (λf.λx. f (f x)) Auch die Kodierung boolescher Ausdrücke ist möglich. Anforderung an True, False und If: (If True M N = M) und (If False M N = N) Definitionen: True = (λx.λy.x), False = (λx.λy.y), If = (λi.λt.λe.ite) Beweis: If True M N = (λi.λt.λe.ite) (λx.λy.x) M N = (λt.λe.(λx.λy.x) te) M N = (λe.(λx.λy.x) M e) N = (λx.λy.x) M N = (λy.M) N = M If False M N = (λiλtλe.ite)(λx.λy.y) M N = (λx.λy.y) M N = (λy.y) N = N Auf diese Weise lässt sich jedes Programm einer beliebigen Programmiersprache auch im λKalkül ausdrücken (sogenannte Church’sche These oder Church-Turing-These). Anmerkung: Natürlich lässt sich der λ-Kalkül direkt in Groovy ausdrücken. Beispiel: nul = { f -> { x -> x}} one = { f -> { x -> f (x) }} two = { f -> { x -> f (f (x))}} plus = { m -> { n -> { f -> { x -> (m (f)) ((n(f)) (x))}}}} Leider schlägt aber der folgende Vergleich fehl: assert ((plus (one)) (one)) == two Das liegt daran, dass keine Programmiersprache die Gleichheit von Funktionen feststellen kann! Abhilfe kann dadurch geschaffen werden, dass wir für f etwa die Funktion "I"+x und für x die leere Zeichenreihe einsetzen: f = {x -> "I"+x} assert ((plus (one)) (one)) (f) ("") == two (f) ("") (plus (one) (one)) (plus (one) (one)) (f) ("") 5.2 Rekursion, Aufruf, Terminierung Eine Funktion oder Methode (auch: Funktionsprozedur) besteht aus einem Funktionskopf (auch Signatur genannt) und einem Funktionsrumpf. Der Kopf besteht im Wesentlichen aus dem Namen, den Parametern (oder Argumenten) und dem Ergebnistyp der Funktion. In Java 5-80 kann optional noch eine Anzahl von Modifikatoren (public, private, protected, static, final) davor stehen. Die folgenden Syntaxdiagramme sind aus http://www.infosun.fim.unipassau.de/cl/passau/kuwi05/SyntaxDiagrams.pdf In Groovy gibt es die Möglichkeit, durch Angabe von def den Ergebnistyp dynamisch bestimmen zu lassen. Der Rumpf der Funktion ist (in Java) ein Anweisungsblock bzw. (in Groovy) eine Closure. Ein Anweisungsblock ist ein in {…} durch Semikolon getrennte Liste von Anweisungen. In Groovy kann ein solcher Anweisungsblock auch einer Variablen zugewiesen werden, so dass er später aufgerufen werden kann (closed block oder closure). Ein solcher Anweisungsblock darf auch – genau wie im λ–Kalkül – formale Parameter enthalten. def f(x) {3*(x**2) + 2*x + 5} assert f(5) == 90 vollkommen gleichwertig dazu sind folgende Groovy-Anweisungen: def g = {x -> 3*x**2+2*x+5} assert g(5) == 90 Das bedeutet, der Term λ x. t wird in Groovy notiert als {x -> t}. Natürlich kann eine Funktion mehrere Parameter unterschiedlichen Typs enthalten: def gerade (int a,b, float x) {return a*x+b} def parabel = { int a,b,c, float x -> a*x**2+b*x+c} Rekursion Der Wert einer Funktion ist das Ergebnis der letzen ausgeführten Anweisung. Falls innerhalb des Blocks eine Anweisung einen Ausdruck enthält, der den Namen der Funktion selbst enthält, sagt man, die Funktion ist rekursiv. Bei einer einfachen Rekursion gibt es nur einen einzigen rekursiven Aufruf (Beispiel: Fakultätsfunktion); falls dieser jeweils die letzte Aktion bei der Ausführung ist, sprechen wir von einer Endrekursion (Tail-end-Rekursion). Bei einer Kaskadenrekursion gibt es mehrere rekursive Aufrufe auf der gleichen Schachtelungstiefe 5-81 (Beispiel: Fibonacci). In einer geschachtelten Rekursion kommen rekursive Aufrufe innerhalb von rekursiven Aufrufen vor; Beispiel ist die McCarthy’sche 91-er Funktion: f = {n -> (n>100)? (n-10) : f(f(n+11))} g = {n -> (n>100)? (n-10) : 91} assert (1..200).each {f(it) == g(it)} Intuitiv lässt sich folgende Analogie herstellen: Endrekursion – reguläre (Chomsky-3) Grammatik einfache Rekursion – kontextfreie (Chomsky-2) Grammatik Kaskadenrekursion – kontextsensitive (Chomsky-1) Grammatik geschachtelte Rekursion – allgemeine (Chomsky-0) Grammatik Diese Analogie gilt allerdings nur bedingt, weil sich bei Verfügbarkeit von Zuweisungen jede Funktion als Endrekursion darstellen lässt. Da sich Endrekursionen auf von-NeumannRechnern besonders effizient ausführen lassen (mit nur einer Schleife), war das Thema der „Entrekursivierung“ lange Zeit von Bedeutung. Die so genannte Ackermann-Péter-Funktion ist ein Beispiel für eine arithmetische Funktion, die sich nicht mit einfachen Mitteln entrekursivieren lässt: ack(0,m) = m+1 ack(n+1,0) = ack(n,1) ack(n+1,m+1) = ack(n, ack(n+1,m)) oder in Java/Groovy: def ack(n,m) {(n==0)? m+1 : (m==0)? ack (n-1,1) : ack(n-1, ack(n,m-1))} Hier sind die Werte der Funktion für verschiedene Eingabeparameter m=0 m=1 m=2 m=3 m=4 m=5 m=6 m=7 m=8 m=9 n=0 n=1 n=2 n=3 1 2 3 5 2 3 5 13 3 4 7 29 4 5 9 61 5 6 11 125 6 7 13 253 7 8 15 509 8 9 17 1021 9 10 19 2045 10 11 21 4093 Es ist eine interessante Übung, die Werte für n≥4 zu berechnen. Aufrufmechanismen Falls innerhalb eines λ-Terms ein Teilterm (…((λ x. t1) t2 )…) vorkommt, so kann auf diesen die β-Konversion angewendet werden. Im Falle mehrerer solcher Teilterme erlaubt es der λKalkül, diese Regel an beliebiger Stelle anzuwenden. In einer Programmiersprache muss hierfür jedoch eine Reihenfolge festgelegt werden. Beispiel: f= {x,y -> (x==y)? x : f(f(x,y),f(x,y))} Wie erfolgt die Berechnung von f(0,1)? 5-82 Unter der “leftmost-innermost”-oder normalen Auswertungsregel versteht man die Regel, dass jeweils der am weitesten links stehende Ausdruck, der keinen weiteren rekursiven Aufruf mehr enthält, expandiert wird. Beispiel: f(0,1) = (0==1)? 0: f(f(0,1), f(0,1)) = f(f(0,1), f(0,1)) = f((0==1)? 0: f(f(0,1), f(0,1)), f(0,1)) = f(f(f(0,1), f(0,1)), f(0,1)) = f(f(f(f(0,1), f(0,1)), f(0,1)), f(0,1)) =… Bei der „leftmost-outermost“ oder verzögerten Ausführung („lazy evaluation“) wird jeweils der äußerste linke Aufruf expandiert: Beispiel: f(0,1) = (0==1)? 0: f(f(0,1), f(0,1)) = f(f(0,1), f(0,1)) = (f(0,1) == f(0,1))? f(0,1) : f(f(f(0,1),f(0,1)),f(f(0,1),f(0,1))) = f(0,1) = … Bei der „full substitution“-Regel werden alle Aufrufe gleichzeitig expandiert. Für g= {x,y -> (x==y)? 0 : g(g(x,y),g(x,y))} terminiert die verzögerte, nicht jedoch die normale Auswertung. Java/Groovy verwenden wie die meisten anderen Programmiersprachen die normale Auswertungsregel: fun = {x,y -> println ("Aufruf mit x: "+x+", y: "+y); (y==0)? y : fun (1, y-1) + fun (2, y-1) } fun(0,2) ergibt Aufruf mit Aufruf mit Aufruf mit Aufruf mit Aufruf mit Aufruf mit Aufruf mit Result: 0 x: x: x: x: x: x: x: 0, 1, 1, 2, 2, 1, 2, y: y: y: y: y: y: y: 2 1 0 0 1 0 0 Das Church-Rosser-Theorem im λ–Kalkül besagt, dass die Relation , mit t1 t2 g.d.w. t2 durch β-Reduktion aus t1 entstanden ist, die Rauteneigenschaft („diamond property“) besitzt: Für alle Terme x, y z gilt: falls xy und xz, so existiert ein Term w mit yw und zw. Daraus folgt unmittelbar, dass die Rauteneigenschaft auch für Ableitungsfolgen gilt: Falls x*y und x*z, so existiert w mit y*w und z*w. Daraus lässt sich folgern, dass wenn ein Term nicht mehr weiter reduziert werden kann, das Ergebnis eindeutig sein muss: Falls x*y und x*z und y ist irreduzibel, so gilt z*y Also ist für terminierende Berechnungen die Frage der Reihenfolge letztlich nur für die Effizienz der Berechnung, nicht aber für das richtige Ergebnis wichtig. 5-83 Terminierungsbeweise Um für eine rekursive Funktion zu beweisen, dass sie terminiert, müssen wir eine Abbildung der Parameter in die natürlichen Zahlen (oder, allgemeiner, in eine fundierte Ordnung) finden, so dass für jeden Aufruf die aufgerufenen Parameterwerte echt kleiner sind als die aufrufenden. Beispiel: fun = { x -> (x>=10)? x**2 : x * fun(x+1) } Um zu zeigen, dass die Funktion fun terminiert, betrachten wir die Abbildung f: Z N0 , f(x) = max(10 – x, 0). Dann ist für x ≤ 10 auch f(x+1) = 10 – (x+1) = 9 – x < 10 – x = f(x). Also können wir folgern, dass fun(x) für alle x aus Z terminiert. Formal ist das ein Schluss nach dem Schema der transfiniten Induktion, siehe Kap. 1.2: Aussage: Für alle i und alle x gilt: Wenn f(x) = i, so terminiert fun(x) (a) Induktionsanfang: Wenn f(x) = 0, so terminiert fun(x) Beweis: Wenn f(x)=0, so ist x≥10, in diesem Fall terminiert fun Induktionsvoraussetzung: Für alle x mit f(x) < i gilt dass fun(x) terminiert, zeige: Wenn f(x)=i, so terminiert fun(x). Beweis: fun(x) ruft fun(x+1); oben gezeigt: f(x+1)<f(x), also f(x+1)<i, also terminiert fun(x+1), also auch fun(x). 5-84 Kapitel 6: Konzepte imperativer Sprachen Imperative Sprachen waren historisch die ersten „höheren Programmiersprachen“ (3. Generation). Da Paradigma imperativer Sprachen (Programmieren als strukturierte Folge von Speicheränderungen) entspricht dem Konzept der von-Neumann-Architektur. Das Basiskonzept der imperativen Programmierung besteht darin, dass Anweisungen den Wert von Speicherzellen (Variablen) verändern. Strukturierungskonzepte sind die Gruppierung von Anweisungen in Anweisungsblöcken, Fallunterscheidungs- und Wiederholungsanweisungen, und Prozeduren oder Funktionen. 6.1 Variablen, Datentypen, Ausdrücke Betrachten wir als Beispiel zwei Java-Implementierungen der Fakultätsfunktion: static int fakRek(int x) { return (x<=0) ? 1 : x * fakRek(x-1); } static int fakIt(int x) { int result = 1; while (x>0) { result *= x--; }; return result; } Typische Sprachelemente imperativer Sprachen, die hier auftreten, sind Methodendeklarationen und Methodenaufrufe, Parameter und lokale Variablen, Terme mit arithmetischen und logischen Operatoren, Zuweisungen und Dekremente, bedingte Terme bzw. Fallunterscheidungen, und Wiederholungsanweisungen. Die einzelnen Sprachelemente werden nachfolgend erläutert. Für weitere Informationen verweisen wir auf den „MiniJavakurs“ unter http://java.sun.com/docs/books/tutorial/java/nutsandbolts/index.html. Variablen haben in typisierten Sprachen wie Java immer einen festgelegten Datentyp. Jede Variable entspricht einer Speicherzelle. Dies gilt natürlich nur, falls die Variable einen Typ hat, der durch ein Speicherwort repräsentiert werden kann; eine Variable eines komplexen Typs enthält einen Verweis auf ein Objekt (Gruppe von Speicherzellen). Es gibt mehrere Arten von Variablen: Methoden-Parameter, lokale Variablen, Instanzvariablen und Klassenvariablen. • Methoden haben Parameter (Ein/Ausgabevariablen) • Methoden können lokale Variablen haben • Jedes Objekt hat Instanzvariablen (nicht statisch) (Das Wort „Instanz“ ist eine schlechte Übersetzung des englischen „instance“, die sich aber nichtsdestotrotz eingebürgert hat.) • Klassen können Klassenvariablen besitzen (statisch) Instanzvariablen und Klassenvariablen heißen auch die Datenfelder der Klasse. Betrachten wir ein (willkürliches) Beispiel: public class Main { static double wechselkurs = 1.32; int betrag = 7; static int zehnCentStücke(double p) { int cent = (int)((p-(int)(p)) * 100); return((cent==20) ? 1 : 0) ;} public static void main(String[] args) { double p = 2.70; System.out.print("p= "); System.out.println(p); } 6-85 } Hier ist p in main eine lokale Variable, p in zehnCentStücke ein Parameter, betrag eine Instanzvariable und .wechselkurs eine Klassenvariable. Java kennt folgende primitive Datentypen: • Zahlen: int, byte, short, long, float, double (43.8F, 2.4e5) • Zeichen: char (z.B. 'x') Sonderzeichen '\n', '\”', '\\', '\uFFF0' • Zeichenreihen (Strings): “Infor“ + “matik“ (streng formal sind Zeichenreihen keine primitiven Objekte, obwohl man sie als Konstante aufschreiben kann) • Wahrheitswerte: boolean (true, false) • „Leerer Typ“ void Der leere Typ wird aus formalen Gründen gebraucht, wenn z.B. eine Methode kein Ergebnis liefert. Hier sind einige Beispiele von Datendeklarationen: boolean result = true; char capitalC = 'C'; byte b = 100; short s = 10000; int i = 100000; int decVal = 26; int octVal = 032; int hexVal = 0x1a; double d1 = 123.4; double d2 = 1.234e2; float f1 = 123.4f; String s = „ABCDE“; Benötigt man Variablen für mehrere gleichförmige Daten (d.h. viele Daten mit gleichem Typ), so kann man eine Reihung (Array) deklarieren (http://java.sun.com/docs/books/tutorial/java/nutsandbolts/index.html). Die Reihung könnte etwa wie im folgenden Beispielprogramm verwendet werden. class ArrayDemo { public static void main(String[] args) { int[] anArray; // eine Reihung ganzer Zahlen anArray = new int[10]; // Speicherallokation anArray[8] = 500; System.out.println("Element at index 8: " + anArray[8]); } } Der Rumpf einer Java-Methode besteht aus einem Block, (Groovy: closed block oder closure) d.h. einer Folge von Anweisungen (statements). Bei der Ausführung werden diese der Reihe 6-86 nach abgearbeitet. Die wichtigste Art von Anweisungen sind Ausdrücke (expressions). Bei ihrer Auswertung liefern sie einen Wert ab und können per Nebeneffekt den Inhalt von Variablen verändern. Hier ist ein Ausschnitt aus der Syntax von Java in BNF, in der Ausdrücke definiert werden (vgl. http://java.sun.com/docs/books/jls/second_edition/html/syntax.doc.html, Kap. 18): • • • • • • • Expression::=Expr1 [AssignmentOperator Expr1] Expr1::=Expr2 [? Expression : Expr1] Expr2 ::=Expr3 {Infixop Expr3} Infixop::= + | * | < | == | != | || | && | … Expr3::= PrefixOp Expr3 | ( Expr | Type ) Expr3 | Primary {Selector} {PostfixOp} Prefixop::= ++ | -- | ! | ~ |+ | Primary::= ( Expression ) | Literal | new Creator | Identifier { . Identifier }[ IdentifierSuffix] | … ACHTUNG: nach dieser Syntax ist eine Zuweisung also auch ein Ausdruck! Es stellt sich die Frage, was der Wert einer Zuweisung (etwa x = 3) ist: der Wert ist bei einer Zuweisung immer der zugewiesene Wert (d.h. das Ergebnis der rechten Seite, im Beispiel also 3). Daher ist es möglich, eine Zuweisung auf der rechten Seite einer Zuweisung zu verwenden (also etwa y = (x = 3) oder x = y = z) Beispielweise könnten Ausdrücke etwa wie folgt aussehen: 17 + 4 x = x + 1 b = b != !b a += (a = 3) v[i] != other.v[i] cube[17][x+3][++y] "Länge = " + ia.length + "," + ia[0].length + " Meter" Java verlangt, dass jede lokale Variable vor ihrer Verwendung initialisiert wurde, und zwar so, dass es statisch erkennbar ist. Diese Bedingung wird vom Compiler mit einer so genannten statischen Analyse überprüft und in Eclipse ggf. bereits beim Tippen des Programmtextes als Fehler markiert. Allerdings kann die statische Analyse nur bedingt erfolgreich sein. Es gibt keinen Compiler, der in jedem Fall erkennt, ob alle Variablen korrekt initialisiert sind! Eine gute Faustregel ist es, alle Variablen gleich bei der Deklaration zu initialisieren. In Java gibt es folgende Operatoren (vergleiche die oben angegebene Syntax): • • Arithmetische Operatoren: +, -, *, /, %, ++ und –– / und % sind (ganzzahlige) Division und Rest ++ und –– als Post- und Präfixoperatoren; a++ erhöht a um 1 und ergibt a, während ++a den Wert a+1 liefert + dient auch zur Konkatenation von Zeichenreihen! Relationale Operatoren: ==, !=, <, <=, > und >= Achtung! == und != vergleichen in Java bei Objekten nur Referenzen! Logische Operatoren: !, &&, ||, &, | und ^ a || b wertet b nur aus falls a nicht gilt Zuweisungsoperatoren = , +=, -=, *=, /=, %, &=, |=, ^=, <<=, >>= und >>>= Fallunterscheidung (dreistellig) x? y: z 6-87 • • Bitoperatoren ~, |, &, ^, >>, >>> und <<; Bitoperatoren können auch auf numerische Typen (int, long usw.) angewendet werden (siehe Bit-Repräsentation) Operatoren new und instanceof a instanceof b gibt an, ob Objekt a eine Instanz des Typs b oder einer ihrer Unterklassen ist ; in Groovy kann man das auch mit dem Attribut .class erreichen: int i=5; println i.class • Operatoren für Member- und Array-Zugriff MeineKlasse.meinDatenfeld meinArray[7] Hier sind noch drei Anmerkungen für Spezialisten: public class Demo { static public int sampleMethod() { int i = 0; boolean b = ++i==i++ | ++i==i++; return i; } static public int sampleMethod2() { int i = 0; boolean b = ++i==i++ || ++i==i++; return i; } } Das Ergebnis von sampleMethod und sampleMethod2 unterscheidet sich, da im zweiten Fall der hintere Teil der Disjunktion nicht ausgewertet wird. Merke: Dies ist kein Beispiel für gute Programmierung! Man sagt, dass ein Ausdruck einen Nebeneffekt (oder auch Seiteneffekt) hat, wenn er neben dem eigentlichen Ergebnis weitere Variablen verändert. Ausdrücke mit Nebeneffekten sind schwer verständlich und sollten deshalb möglichst wenig verwendet werden! public class static int static int static int } Rechtsshift { i = -64; j = i>>2; k = i>>>2; In diesem Beispiel hat i den Wert -64, j den Wert -16 und k den Wert 1073741808. Merke: Bit-Shifts sollten nur mit Vorsicht verwendet werden! In Groovy gibt es die Möglichkeit simultaner Mehrfachzuweisungen: def (a, b) = [1, 2] ergibt a==1 und b==2. Wie bereits oben erwähnt, sind Java und Groovy streng typisierte Sprachen. Das bedeutet, Alle Operatoren in Java sind strikt typisiert, d.h., es wird geprüft, ob der Typ dem verlangten entspricht. Z.B. kann << nicht auf float oder String angewendet werden. Zur Typumwandlung gibt es entsprechende Konversions- (Casting-)Operatoren; z.B. (int)3.14 // ergibt 3 (double)3.14f // Ausweitung Dabei ist zu beachten, dass bestimmte Typen inkompatibel sind (z.B. boolean und int) 6-88 In Groovy wird der Typ einer Variablen, wenn er nicht vom Benutzer angegeben wurde, automatisch bestimmt. Dadurch können auch Konversionen automatisch berechnet werden. Es ist trotzdem ein guter Programmierstil, den Typ von Variablen statisch festzulegen und Konversionen explizit anzugeben. Ausdrücke werden gemäß den „üblichen“ Operator-Präzedenz-Regeln ausgewertet. Regeln für die Auswertungsreihenfolge sind: linker vor rechter Operator, dann Operation gemäß Klammerung gemäß Operator-Hierarchie Achtung: Die Reihenfolge der Auswertung von Ausdrücken kann das Ergebnis beeinflussen! Beispiel: int i=2, j = (i=3)*i; // ergibt 9 int i=2, j = i*(i=3); // ergibt 6 Überraschenderweise ist die Multiplikation also nicht kommutativ! Generell ist zu sagen, dass die Verwendung von Nebeneffekten schlechter Programmierstil ist! 6.2 Anweisungen und Kontrollstrukturen In Java gibt es folgende Arten von Anweisungen: • Leere Anweisung ; • Block, d.h. eine mit {…} geklammerte Folge von Anweisungen; Blöcke dürfen auch geschachtelt werden • Lokale Variablendeklaration • Ausdrucksanweisung Zuweisung Inkrement und Dekrement Methodenaufruf Objekterzeugung Ein wichtiges programmiersprachliches Mittel ist der Aufruf von Methoden der API (applications programming interface). Dazu muss die entsprechende Bibliothek importiert werden (falls sie es nicht schon standardmäßig ist): import java.awt.*. (Grafik: © Bothe 2009) Zugriff auf Bibliotheksfunktionen erfolgt mit Qualifizierern, z.B. System.out. println(„Hallo“) java.util.Date d = new java.util.Date (); 6-89 Zur Steuerung des Ablaufs werden Fallunterscheidungen verwendet. • if-Anweisung if (ausdruck) anweisung; [else anweisung;] üblicherweise sind die Anweisungen Blöcke. hängendes else gehört zum innersten if Beispiel: if (b1) if (b2) a1 else a2 wird a2 ausgeführt, wenn b1 wahr und b2 falsch ist? • switch-Anweisung switch (ausdruck) { {case constant : anweisung} [default: anweisung]} D.h., in einer switch-Anweisung kommen beliebig viele case-Anweisungen, optional gefolgt von einer default-Anweisung. Bei der Auswertung wird zunächst der Ausdruck ausgewertet und dann in den entsprechenden Zweig verzweigt. Switch-Anweisungen sind tief geschachtelten if-Anweisungen vorzuziehen, da sie normalerweise übersichtlicher und effizienter sind. Kontrollflusselemente Folgende Wiederholungsanweisungen und Steuerungsbefehle werden häufig gebraucht: • while-Schleife allgemeine Form: while (ausdruck) anweisung; Die while-Schleife ist abweisend: Vor der Ausführung der Anweisung wird auf jeden Fall der Ausdruck geprüft. Beispiel: i=fak=1; while (i++<n) fak*=i; In Java gibt es zusätzlich die nichtabweisende Form do anweisung; while (ausdruck); • diese Schleife wird auf jeden Fall einmal durchlaufen, dann wird geprüft. Beispiel: do fak *= i++; while (i<=n) Jedes do A while (B) kann gleichwertig geschrieben werden als A; while (B) do A; (Achtung: Code-Replikation!) for-Schleife allgemeine Form: for (init; test; update) anweisung; Beispiel: for (int i= fak = 1; i<=n; i++){ fak *= i;}; In Java darf die Initialisierung mehrere Anweisungen (mit Komma getrennt!) enthalten (auch gar keine); in Groovy wird an Hand der Zahl der Anweisungen entschieden, was Initialisierung, Test und Update ist (Benutzung nicht empfohlen!) • continue, break und return continue verlässt den Schleifendurchlauf return verlässt die aufgerufene Methode break verlässt die Schleife komplett Es kann auch zu einer markierten äußeren Schleife gesprungen werden: outer: while (A) { inner: while (B) { dosomething; if (C) break outer } } 6-90 In Groovy sind darüber hinaus folgende vereinfachte Schreibweisen erlaubt: • 5.times{…} • (1..5).each{fak *= it} //Schleifenvariable 'it' • 1.upto(5){fak *= it} • for (i in [1,2,3,4,5]){fak *= i} • for (i in 1..5){fak *= i} • for( c in "abc" ){…} 6.3 Sichtbarkeit und Lebensdauer von Variablen Unter der Sichtbarkeit (Skopus) einer Variablen versteht man den Bereich im Quellcode, in dem dieser Variablenname verwendet werden darf. Der Gültigkeitsbereich ist derjenige Teil der Sichtbarkeit, in der man auf den Inhalt der Variablen zugreifen kann. Die Lebensdauer ist die Zeitspanne, während der für die Variable Speicherplatz reserviert werden muss Für jede der verschiedenen Variablenarten gibt es detaillierte Festlegungen hierzu. Beispiel: public class xyz { int x; public xyz() { x = 1; } public int sampleMethod(int y) { int z = 2; return x + y + z; } } In diesem Beispiel bezieht sich x innerhalb von xyz und in sampleMethod auf die Instanzvariable, y in sampleMethod auf den Parameter, und z auf die lokale Variable. Variablen können sich auch gegenseitig verschatten: Unter Verschattung versteht man die Einschränkung des Gültigkeitsbereichs einer Variablen durch eine andere Variable gleichen Namens. Es gelten folgende Regeln: • Lokale Variablen werden in einem Block (einer Folge von Anweisungen) deklariert und sind nur für diesen verwendbar. • Lokale Variable sind ab ihrer Deklaration bis zum Ende des Blocks oder der Methode sichtbar. • Instanzvariable können von gleichnamigen lokalen Variablen verschattet werden, d.h., sie sind sichtbar aber nicht gültig. Verschattung ist hilfreich, um das Lokalitätsprinzip zu unterstützen (z.B. in Laufanweisungen). Es ist nicht erlaubt, dass lokale Variablen sich gegenseitig oder Methodenparameter verschatten 6-91 Beispiel für Verschattung: public class xyz { int x; public xyz() { x = 1; } public int sampleMethod(int y) { int x = 2; return x + y; } } Gültigkeitsbereich und Sichtbarkeit kann in Java/Groovy auch durch Modifikatoren eingeschränkt werden. Öffentliche (public) Klassenvariablen sind überall sichtbar; private Klassenvariablen (private) sind in der sie enthaltenden Klasse überall sichtbar, dienen also als „gemeinsamer Speicher“ der Objekte. Methodenvariablen sind nur innerhalb der Methode sichtbar, sind also „Hilfsvariablen“ zur Ausführung der Methode; der Speicherplatz wird nur während der Abarbeitung der Methode reserviert. Es gehört zum guten Programmierstil, alle Variablen so lokal wie möglich zu deklarieren, um die Gültigkeitsbereiche überschaubar zu halten. Als Konvention hat sich eingebürgert, Variablennamen immer mit Kleinbuchstaben beginnen zu lassen und Teilworte mit Großbuchstaben direkt anzufügen. Beispiel: int diesIstEineIntegerVariable. Finale Variablen (Konstanten) werden in Großbuchstaben geschrieben, wobei Teilworte durch _ getrennt werden. Beispiel: final float MAX_TEMPO = 5.0; Methodennamen werden wie Variablennamen geschrieben und sollten Verben enthalten, Beispiel: public void fahreRueckwaerts (float tempo); Klassennamen beginnen mit Großbuchstaben und sollten substantivisch sein. Beispiel: class FahrzeugReifen Für weitere Informationen lesen Sie bitte die Java Coding Conventions von SUN unter http://java.sun.com/docs/codeconv/html/CodeConventionsTOC.doc.html. 6-92 Kapitel 7: Objektorientierung Das Grundparadigma der objektorientierten Programmierung ist, dass ein Programm eine Ansammlung von Objekten ist, die miteinander interagieren. Jedes Objekt gehört zu einer bestimmten Klasse, und ist mit Datenfeldern und Methoden ausgerüstet. Der Wert der Datenfelder beschreibt den Zustand des Objektes, die Methoden dienen dazu, diesen Zustand zu verändern und mit anderen Objekten zu kommunizieren. 7.1 abstrakte Datentypen, Objekte, Klassen Abstrakte Datentypen (ADT) sind ein Konzept aus der theoretischen Informatik, welches für die objektorientierte Programmierung eine ähnliche grundlegende Rolle spielt wie der λ-Kalkül für die funktionale Programmierung oder die Turing-Maschine für die imperative Programmierung. Formal definieren wir den Begriff der Signatur: Eine Signatur Σ ist ein Paar, bestehend aus einer Grundmenge Μ und einer Menge von Operationen und Prädikaten Φ: Σ = (Μ, Φ) Zu jedem Element von Φ wird außerdem ihre Stelligkeit angegeben (zur Erinnerung: eine nstellige Operation auf einer Menge Μ ist eine Funktion Μn Μ, eine n-stellige Operation auf einer Menge Μ ist eine Funktion Μn B) Beispiel für eine Signatur ist also etwa : (N0,0,s,+,*). Dabei sei 0 nullstellig, s einstellig, und + und * zweistellig. 0 : → N0 s : N0 → N0 +: N0×N0 → N0 *: N0×N0 → N0 Es erhebt sich die Frage, welche Datenobjekte vom Typ N0 es (mindestens) gibt. Unter der Termalgebra einer Signatur versteht man alle Objekte, die sich als Ergebnis wohlgeformter Terme auf Grund der Signatur darstellen lassen. Beispiele sind 0, s(0), s(s(0)), s(s(s(0))), … 0+0, (0+0)+0, … s(0)+s(0), s(s(0))+s(s(s(0))), … s(s(0))*(s(s(0))*(s(s(0)))), (0*s(0))+s(0), … Die Objekte, die sich so darstellen lassen, nennt man die Termalgebra der Signatur. Nicht alle Terme geben verschiedene Werte: z.B ist s(0)+s(0) gleich zu s(s(0)) (manchmal ist 1+1=2). Daher nimmt man eine Äquivalenzklassenbildung durch Angabe von (allgemeingültigen) Gesetzen vor: x+0=x x+s(y)=s(x+y) x*0=0 x*s(y)=x+(x*y) Damit lässt sich obige Aussage beweisen (1+1=2): s(0)+s(0) =? s(s(0)) 7-93 s(0)+s(0) = s(0 + s(0)) = s(s(0 + 0)) = s(s(0)) Ein abstrakter Datentyp (ADT) Τ besteht aus einer Signatur Σ und einer Menge von algebraischen Gesetzen (Gleichungen) Γ Τ = (Σ, Γ) Wenn die Gesetze ausschließlich aus Gleichungen zwischen Elementen von Μ bestehen, spricht man auch von einer Varietät (engl.: variety). Oft lässt man auch Bedingungen und Ungleichungen zu, dies ist aber eine Erweiterung des ursprünglichen Konzepts. Die Gesetze beschreiben, was für alle Objekte dieses Typs gelten soll. Beispiel: ADT IntSet „Menge ganzer Zahlen“ Signatur:(IntSet, ∅, ∈, ⊆, ∪, ∩, –) ∅ : → IntSet ∪ : IntSet × IntSet → IntSet ∈ : Z × IntSet → boolean Gesetze: z.B. x∪y=y∪x x∩∅=∅ … Konkrete Mengen ganzer Zahlen (z.B. {1,2,3} oder {x | x%2=0} sind Instanzen des abstrakten Typs. Die Gesetze legen fest, was für alle Instanzen gelten soll. Beispiel: {1,2,3} ∪ {4,5} = {4,5} ∪ {1,2,3} {x | x%2=0} ∩ ∅ = ∅ Beispiel: Paare ganzer Zahlen Z×Z: ADT ZZ = (ZZ, first, second, conc) first, second : ZZ → Z conc : Z × Z → ZZ Gesetze: first(conc(x,y))=x second(conc(x,y))=y Instanzen: 〈3,-5〉, 〈0,0〉, 〈-12,12〉, … first(〈3,0〉)=3 conc(1,2)=〈1,2〉 Beispiel: Sequenzen (parametrisiert mit Basistyp σ) ADT seq〈σ〉:( seq〈σ〉, empty, isEmpty, prefix, first, rest, postfix, last, lead): empty : → seq〈σ〉 isEmpty : seq〈σ〉 → boolean 7-94 prefix: σ × seq〈σ〉 → seq〈σ〉 first: seq〈σ〉 → σ rest: seq〈σ〉 → seq〈σ〉 postfix: seq〈σ〉 × σ → seq〈σ〉 last: seq〈σ〉 → σ lead: seq〈σ〉 → seq〈σ〉 isEmpty(empty) = true isEmpty(prefix(a,x)) = false isEmpty(postfix(x,a)) = false first(prefix(a,x))= a rest(prefix(a,x))= x last(postfix(x,a))= a lead(postfix(x,a))= x Eigenschaften first(rest(prefix(a, prefix(b, empty)))) = b rest(rest(prefix(a, prefix(b, empty)))) = empty last(lead(postfix(a, postfix(b,empty)))) = b … prefix(a,empty) =? postfix(empty,a) Typerweiterung abgeleitete Operation +: +: (seq〈σ〉 ∪σ) × (seq〈σ〉 ∪σ)→ seq〈σ〉 prefix(a,x)=a+x postfix(x,a)=x+a x+empty=x empty+x=x x+(y+z)=(x+y)+z + ist also ein “überladener” Operator, der für mehrere verschiedene Eingabetypen eine Sequenz liefert. Er kann wie folgt rekursiv definiert werden: x+empty=x x+postfix(y,a) = postfix(x+y,a) 7-95 empty+x=x prefix(a,x)+y = prefix(a,x+y) Stapel und Schlangen Stapel („stack“): einseitiger Zugriff empty, isEmpty, first, rest, prefix – oder – empty, isEmpty, last, lead, postfix top ≅ first/last, pop ≅ rest/lead, push ≅ prefix/postfix Schlange („queue“): empty, isEmpty, first, rest, postfix – oder – empty, isEmpty, last, lead, prefix head ≅ first/last, tail ≅ rest/lead, append ≅ postfix/prefix Multimengen bag〈σ〉 : empty : → bag〈σ〉 isEmpty : bag〈σ〉 → boolean insert: bag〈σ〉 × σ → bag〈σ〉 delete: bag〈σ〉 × σ → bag〈σ〉 elem: σ × bag〈σ〉 → boolean any: bag〈σ〉 → σ isEmpty(empty) = true isEmpty(insert(x,a)) = false insert(insert(x,a),b)= insert(insert(x,b),a) delete(empty,a) = empty delete(insert(x,a),a) = x delete(insert(x,b),a) = insert(delete(x,a),b) elem(a, empty) = false elem(a, insert(x,a)) = true elem(a,insert(x,b) = elem(a,x) (a≠b) elem(any(x),x) = true (x≠empty) Beispiel-Algorithmus für Multimengen 7-96 card: bag〈σ〉 × σ → N0 int card (bag〈σ〉 x, σ a){ if (isEmpty(x)) return 0; else { σ b = any(x); return card(delete(x,b)) + ((a=b)?1:0); } } 7.2 Klassen, Objekte, Methoden, Datenfelder, Konstruktoren Eine Klasse ist die Java-Realisierung eines abstrakten Datentyps. In der objektorientierten Programmierung sind Klassen das grundlegende Strukturierungsmittel. Klasse = Datenfelder + Methoden Datenfelder sind die Zustandsvariablen des Objekts, Methoden die Operationen, Funktionen oder Prozeduren zur Zustandsänderung. Objekte sind Instanzen von Klassen, ähnlich wie Elemente Bestandteile einer Menge sind. Die folgenden Begriffe werden fast synonym verwendet (mit minimalen, subtilen Unterschiede!) • Klasse ~ Typ ~ Art • Objekt ~ Instanz ~ Exemplar • Datenfeld ~ Objektattribut ~ Instanzvariable ~ Eigenschaft (passives Merkmal) • Methode ~ Funktion/Prozedur ~ Operation ~ Fähigkeit (aktives Merkmal) • Parameter ~ Argument ~ Eingabewert Beispiel für eine Klasse: Philosophen sitzen um einen Tisch, denken, werden hungrig und essen: class Philosoph { boolean hungrig; void denken() {...}; void essen() {...}; } Klassen bestehen also im Wesentlichen aus der Deklaration von Daten- und Funktionsteilen (vgl. Turing/von-Neumann Analogie zwischen Daten und Programmen). Datenfelder bestimmen durch ihren aktuellen Wert während einer Berechnung den Zustand der abgeleiteten Objekte, Methoden definieren die Aktionsmöglichkeiten der Objekte. Beispiel: das Objekt `Sokrates´ ist eine Instanz der Klasse `Mensch´, kann im Zustand `hungrig´ sein und beherrscht die Methode `denken´ mit Ergebnistyp `Erkenntnis´ (void). Objekte können von anderen Objekten erzeugt und aufgerufen werden: public static void main(String[] args) { Philosoph sokrates; sokrates = new Philosoph(); sokrates.denken(); if(sokrates.hungrig) sokrates.essen(); 7-97 System.out.println(sokrates.hungrig); } Objektorientierte Modellierung Klassen modellieren ein Konzept eines Anwendungsbereiches oder eine Gemeinsamkeit mehrerer Dinge (platonische „Idee“); Objekte modellieren die konkreten Akteure oder Gegenstände des betrachteten Bereiches. Berechnungen entstehen dadurch, dass Objekte miteinander und mit dem Benutzer kommunizieren, das heisst, • sich gegenseitig aufrufen • Nachrichten austauschen • neue Objekte erzeugen Wurzel der Interaktion ist die public class Main mit der Methode public static void main(String[] args) {…} Beispiele Klasse Mensch Auto Planet Kreis Objekt sokrates herbie erde kreis1 Datenfeld hungrig tankinhalt masse radius Methode denken fahren drehen farbeAendern Wichtig: Klassen können hierarchisch strukturiert sein! Lebewesen – Mensch – Berliner Fahrzeug – Auto – Porsche usw. Darüber hinaus gibt es statische Datenfelder (Schlüsselwort static): Diese beschreiben Attribute, die alle Objekte der Klasse gemeinsam haben Beispiel: static int anzahl_Finger = 10; als Attribut von class Philosoph Solange kein Objekt einer Klasse erzeugt wurde, muss nur der Speicherplatz für statische Variablen angelegt werden. Der Speicherplatz für Objekte wird erst beim Aufruf von new reserviert. Mit new wird (dynamisch) ein neues Objekt erzeugt. Man nennt diesen Vorgang auch “instanziieren” und das Objekt eine „Instanz“ der Klasse. z.B. Philosoph aristoteles = new Philosoph(); • • • Dadurch werden die objektspezifischen Datenfelder angelegt, d.h., es wird ausreichend Speicherplatz im Adressraum reserviert. Dieser Speicherplatz wird automatisch wieder freigegeben, wenn das Objekt nicht mehr erreichbar ist („garbage collection“) Primitive Datenobjekte (Zahlen oder Zeichen) müssen nicht erzeugt werden, sie existieren sowieso. Vom Instanziieren zu unterscheiden ist das Initialisieren von Objekten: den Datenfeldern werden Anfangswerte zugewiesen; z.B., int x = 0; Eine nichtinitialisierte Variable enthält u.U. einen unvorhersagbaren Wert (was zufällig an dieser Stelle im Speicher stand…) 7-98 Für die Erzeugung von neuen Objekten kann jede Klasse Konstruktor-Methoden zur Verfügung stellen, die genauso wie die Klasse selbst heißen (im Beispiel Philosoph()), und in denen die Datenfelder der Klasse initialisiert werden können. void Philosoph (){ hungrig = true; } Der Konstruktor wird bei der Erzeugung eine Instanz der Klasse automatisch aufgerufen und dient dazu, das betreffende Objekt zu initialisieren. Der Konstruktor hat denselben Namen wie die Methode selbst und kann auch Eingabeparameter enthalten, liefert jedoch kein Ergebnis. class Student { ... String matrikelnummer; int scheine; Student (String matrNr){ matrikelnummer = matrNr; ... scheine = 0; } ... // Konstruktor- Methode Klassen dienen zur Realisierung von abstrakten Datentypen. Dabei sind nur die Zugriffsfunktionen (in der Signatur) sichtbar („public“). Beispiel: Klasse „Stack“ zur Realisierung von Stapeln (nach Bothe): class Stack { private char[] stackElements; private int top; public Stack(int n) { stackElements = new char [n]; top = -1; } public boolean isempty() { return top == -1; } public void push(char x) { // Methode if (top + 1 == stackElements.length){ System.out.println (“Stack ist voll”); return; } top++; stackElements[top] = x; } public char top() { if (isempty()) { System.out.println("Stack leer"); return ' '; } else return stackElements [top]; } public void pop() { if (isempty()) System.out.println("Stack leer"); else top--; } } 7-99 Anwendung der Klasse: z.B. zum Überprüfen von Klammerstrukturen: import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; public class Klammerstruktur { public static void main(String[] args) { System.out.println("Geben Sie eine Klammerstruktur ein"); BufferedReader inputReader = new BufferedReader(new InputStreamReader(System.in)) ; String eingabeZeile = ""; try {eingabeZeile = inputReader.readLine();} catch (IOException e){} int n = eingabeZeile.length(); Stack s = new Stack(n); for (int i = 0; i < n; i++) { char ch = eingabeZeile.charAt(i); if (ch == '(' || ch == '{' || ch == '[') s.push(ch); else if (ch==')' && s.top() =='(' || ch=='}' && s.top() =='{' || ch==']' && s.top() =='[') s.pop(); else if (ch==')' || ch=='}' || ch==']' ) { System.out.println("schließende Klammer passt nicht"); return; } } if (!s.isempty()) System.out.println("schließende Klammer fehlt"); else System.out.println("Klammerstruktur korrekt"); } } (Achtung: Auch hier prüfen wir nicht ob der Keller leer ist.) Anwendungsbeispiel ist z.B. der Term (()([]{})) oder der Term if ( x < ( a [ ( i ) ] / 2 )) { x += f () ;} Natürlich kann man auch mehrere Objekte einer Klasse erzeugen: Stack s7 = new Stack(7); Stack s9 = new Stack(9); Dadurch werden verschiedene Instanzen der Klasse, jeweils mit eigenen Instanzvariablen und –methoden, erzeugt. Der Zugriff erfolgt über Qualifikatoren: s7.push('a'); s9.push('b'); 7-100 Modifikatoren: Klassen, Datenfelder und Operationen können durch Modifikatoren näher attributiert werden. Diese betreffen vorwiegend die Zugriffsmöglichkeiten auf die betreffende Komponente. Folgende Modifikatoren sind erlaubt: • final (für ein Datenfeld): Das Datenfeld ist eine Konstante, hat also für alle Objekte der Klasse stets den gleichen, unveränderlichen Wert. • final (für eine Operation): Die Operation kann in Unterklassen nicht verändert (überschrieben) werden. • static : Die Komponente ist klassenbezogen, d.h. für alle Objekte der Klasse in gleicher Weise verfügbar und wertgleich. Für eine Operation bedeutet das: Die betreffende Operation darf nur static-Datenfelder benutzen. • private : Die Komponente darf nur innerhalb der aktuellen Klasse benutzt werden (aber von beliebigen Objekten der Klasse). • public : Die Komponente ist öffentlich, darf also unbeschränkt von außen benutzt werden. • protected : Auf die Komponente kann man nur innerhalb des Pakets zugreifen, das die betreffende Klasse enthält, und zusätzlich von deren Unterklassen. • package (default-Einstellung): Zugriff wie bei protected, aber auf das aktuelle Paket beschränkt. Auch bei der Verwendung von Modifikatoren sollte man die Sichtbarkeitsregel „so lokal wie möglich, so global wie nötig“ berücksichtigen. 7.3 Vererbung, Polymorphismus, dynamisches Binden Beim Vergleich von Ingenieurswesen und Software-Engineering fällt auf, dass Ingenieure ihren Gegenstandsbereich hierarchisch strukturieren. Ein Beispiel aus dem Maschinenbau ist die hierarchische Strukturierung eines KFZ gemäß dem Aufbau: Fahrzeug – Getriebe – Zahnrad. Innerhalb der Betrachtungsebene „Zahnrad“ gibt es eine Typenreihen-Strukturierung gemäß der Weltsicht („Ontologie“): Es gibt runde und ovale Zahnräder, Zahnräder bei denen die Zähne innen oder außen sind, usw. Auf der Betrachtungsebene „Fahrzeug“ gibt es die Spezialisierungen (z.B.) Volkswagen, Nutzfahrzeuge, Caddy, Life, TDI 1.9 DPF. Die Einteilung entspricht dem, was man in der Informatik „Vererbungsstruktur“ nennt; es gibt verschiedene Zahnrad-Bautypen, die sich voneinander im Detail unterscheiden; es gibt aber auch gewisse Gemeinsamkeiten, die uns veranlassen, von einem „Zahnrad“ zu sprechen. Genauso: es gibt verschiedene KFZ-Marken, aber alle haben 4 Räder und einen Hubraum. Wenn ein Ingenieur ein Getriebe konstruiert, überlegt er sich eine Dekomposition in Komponenten (bzw. einen Bauplan zum Zusammensetzen aus Einzelteilen) und schlägt in Katalogen mit Zahnkranz-Bautypen nach oder modifiziert bestehende Bautypen geeignet. In der objektorientierten Programmierung besteht Softwaredesign aus der Dekomposition des Problems in einzelne Klassen (bzw. der Strukturierung in eine Klassenhierarchie) und dem Zusammenfügen bzw. der Modifikation vorhandener Bibliotheksklassen. Genau wie ein Fahrzeug aus vielen Einzelteilen besteht, ist objektorientierte Software aus vielen Klassen zusammengesetzt. Technisch bedeutet dies, dass Klassendefinitionen auf verschiedene Weise hierarchisch strukturiert sein können: • Klassen dürfen lokale Klassen als Deklaration enthalten • Klassen können zu Paketen zusammengefasst werden • Zwischen Klassen kann eine Erbschaftsbeziehung (Super/Subklassenhierarchie) bestehen 7-101 • Zwischen abstrakten und konkreten Klassen kann eine Implementierungsrelation bestehen Vererbung ist also das fundamentale Konzept der oo- (objektorientierten) Programmierung. Als Beispiel betrachten wir eine Hierarchie von Fahrzeugtypen: © http://www.javabuch.de/ Diese Hierarchie könnte objektorientiert etwa wie folgt nachempfunden werden: public class Fahrzeug { private int tachostand; public Fahrzeug() { tachostand = 0; } public int gibTachostand() { return tachostand; } public void fahre(int strecke) { tachostand += strecke; } } Eine Erweiterung dieser Klasse wäre dann etwa: class LKW extends Fahrzeug {...} Durch diese Definition sind alle Datenfelder und Methoden von Fahrzeug auch für LKW verfügbar (Ausnahme: private Datenfelder und Methoden). Wir sagen, dass die Klasse LKW die Attribute der Klasse Fahrzeug erbt. LKW kann darüber hinaus zusätzliche Variablen oder Methoden enthalten oder Methoden abändern z.B. nutzlast oder transportiere z.B. fahre mit zusätzlichem Argument ladung Der Zugriff auf die Methoden erfolgt mit Member-Selektor (Punkt), z.B. lkw1.ladung Polymorphismus Auf oberster Ebene gilt: Jede Klasse ist von der allgemeinsten Klasse Object abgeleitet und erbt von dieser gewisse Eigenschaften 7-102 Object clone() boolean equals(Object) String toString() ... In den meisten Fällen muss jedoch die generische („ererbte“) Definition der Methoden abgeändert werden. In abgeleiteten Klassen dürfen alle ererbten Methoden neu definiert werden (Ausnahmen: private, final). Dies realisiert den Umstand, dass im Speziellen zusätzliche Maßnahmen gegenüber dem Allgemeinen durchgeführt werden. Beispiel: ein LKW kann nicht rückwärts fahren, daher prüft fahre zunächst ob das Argument positiv ist: public void fahre(int strecke){ if (strecke > 0) super.fahre(strecke); } Die speziellere Methode überlagert die allgemeinere aus der übergeordneten Klasse. Unter Polymorphie (griechisch: „Vielgestaltigkeit“) versteht man die Tatsache, dass unterschiedliche Methoden in verschiedenen Klassen oder auch Methoden mit verschiedenen Parametern innerhalb einer Klasse gleich bezeichnet sein dürfen. Beispiele: • • + für Int-, Float-Addition, String-Konkatenation neue Methode fahre in Klasse LKW mit zusätzlichem Argument last prüft zunächst ob die angegebene Nutzlast überschritten wurde: public void fahre(int strecke, int last) { if (last > nutzlast) System.out.println("überladen!"); else fahre(strecke); } In jedem Fall muss an Hand der Signatur oder des Objekttyps entscheidbar sein, welche Methode gemeint ist. public class LKW extends Fahrzeug { private int nutzlast; public LKW(int nutzlast){ this.nutzlast = nutzlast; } public void fahre(int strecke){ if (strecke > 0) super.fahre(strecke); } public void fahre(int strecke, int last) { if (last > nutzlast) System.out.println("überladen!"); else fahre(strecke); } public void transportiere (int last, int start, int ziel) { /* belade(last); fahre(ziel - start, last); */ } } Dynamisches Binden Es ist nicht immer statisch entscheidbar, welche Methode gerade gemeint ist: Fahrzeug kfz = new LKW(...); kfz.fahre (... ladung); ... 7-103 if (...) kfz = new PKW(); kfz.fahre(... personen); Das bedeutet, dass erst zur Laufzeit entschieden werden kann, welche Methode jetzt eigentlich gemeint ist. Diesen Effekt nennt man „dynamisches Binden“. Zur Realisierung erzeugt der Compiler zusätzlichen Code zur Prüfung (Ausnahmen: private, static, final). public class Fuhrpark { private static Fahrzeug f; public static void sampleMethod() { f = new LKW(20000); // ein 20-Tonner f.fahre(-100); // welches fahre? //f.fahre(100, 10000); // nicht erlaubt f = new LKW(4); // ein 4-Sitzer ((LKW)f).fahre(123,5); } } Bindungsregeln und Objektinitialisierung • • • 7-104 Beim Erzeugen eines Objektes mittels new werden zunächst die Konstruktoren der vererbenden Klassen (Eltern, Großeltern etc.) aufgerufen. Die eigenen Merkmale können mit dem Deskriptor this, die der jeweiligen Elternklasse mit super aufgerufen werden. Eventuelle Typumwandlung (Casting) durch vorgestelltes (Typ) Kapitel 8: Modellbasierte Softwareentwicklung 8.1 UML Klassendiagramme und Zustandsmaschinen – entfällt – 8.2 Codegenerierung und Modelltransformationen – entfällt – 8-105 Kapitel 9: Spezielle Programmierkonzepte 9.1 Benutzungsschnittstellen, Ereignisbehandlung Viele der bislang betrachteten Programme bekamen ihre Eingabe als Parameter beim Aufruf, und lieferten ihre Ausgabe als Ergebnis bei Terminierung ab. Interaktive Programme kommunizieren mit den Benutzern über Ein- und Ausgabegeräte. Bei textbasierten Programmen erfolgt die Ausgabe oft in einem Textfeld („Konsole“); in Java etwa durch die Methode System.out.println(…). Die Komponente „out“ bezeichnet dabei den StandardAusgabestrom des Systems, vom Typ PrintStream. (Ähnlich bezeichnet err die Standardausgabe für Fehlermeldungen, und in den Standard-Eingabestrom. Über System.in lassen sich also vom Benutzer auf der Konsolschnittstelle eingegebene Werte einlesen. Beim Aufruf der Methode System.in.read wartet das Programm, bis der Benutzer Text eingibt und die Eingabetaste drückt. Das folgende Programmfragment liest eine Zahl von der Konsole und gibt sie (um 1 erhöht) wieder aus: int i=0; int len = 20; byte[] b = new byte[len]; try { int l = System.in.read(b, 0, len)-1; String s = new String(b,0,l-1); i = Integer.parseInt(s); } catch (IOException e) {} System.out.println(i+1); System.in.read erwartet als Eingabe eine Referenz auf (hinreichend großes) Byte-Array, liest die Bytes von Konsole und gibt die Anzahl der gelesenen Zeichen (inklusive dem „Zeilenwechsel-Zeichen“) als Ergebnis zurück. Die tatsächlich eingegebene Zeichenzahl ist also um eins niedriger. Da Benutzerinteraktionen grundsätzlich Fehler erzeugen können, müssen sie in einen try–Block eingeschlossen werden, siehe Abschnitt 9.3. Mit der Anweisung String s = new String(b,0,l-1); werden die Zeichen 0 bis l-1 dieses ByteArrays in eine Zeichenreihe umgewandelt. Integer.parseInt(s) schließlich wandelt diese Zeichenreihe (sofern sie nach den Java-Regeln eine korrekte Zahl darstellt) in ein Objekt der Art Integer um. Etwas einfacher wird es, wenn wir aus System.in einen BufferedReader erstellen, da wir mit diesem direkt Strings einlesen können (Methode readLine()): BufferedReader konsole = new BufferedReader( new InputStreamReader (System.in)) ; try {i = Integer.parseInt(konsole.readLine());} catch (IOException e){} Für viele Programme ist allerdings eine rein textbasierte Ein-Ausgabe nicht ausreichend. Üblicherweise wird ein Programm heute mit dem Benutzer über eine GUI (graphical user interface) interagieren, d.h. das Programm stellt graphische Elemente wie Knöpfe, Regler, Textfelder usw. auf dem Bildschirm zur Verfügung, über die die Mensch-MaschineKommunikation erfolgt. Dazu gibt es inzwischen sehr viele verschiedene Möglichkeiten. Programme, die eine GUI bereitstellen, reagieren auf Ereignisse (events). In der ablaufgesteuerten Programmierung ist ein Programm eine Folge von Anweisungen (d.h. Methodenaufrufen); bei der ereignisgesteuerten Programmierung ist ein Programm eine Sammlung von Behandlungsroutinen (= Methoden) für verschiedene Ereignisse der GUI. Typische Ereignisse (events) sind: • Mausklick, Mausziehen, Mausbewegung, Return-Taste, Tastatureingabe, … • Signale des Zeitgebers, Unterbrechungen, … • programmerzeugte Ereignisse 9-106 In einem ereignisgesteuerten Programm wird für jedes Fenster ein Prozess gestartet, der auf die Ereignisse wartet und sie bearbeitet. Für jedes Ereignis wird eine Bearbeitungsroutine (event handler) registriert, die beim Eintreten des Ereignisses bestimmte Aktionen auslöst. Zur konkreten Realisierung von GUIs in Java existieren zwei weit verbreitete Bibliotheken: AWT und Swing. • AWT (Abstract Window Toolkit) ist eine plattformunabhängige schwergewichtige Bibliothek (stützt sich auf Betriebssystem-Routinen ab) • Swing ist Bestandteil der Java-Runtime (leichtgewichtig), hat ein spezifisches „Lookand-Feel“ sowie gegenüber AWT einige zusätzliche Komponenten und ist generell „moderner“. Das folgende Programm erzeugt zwei Fenster und zeigt sie auf dem Bildschirm an: import java.awt.*; class Fenster extends Frame { Fenster (String titel) { super(titel); //Setzen des Textes in der Titelzeile des Fensters setSize(160,120); // Größe in Pixeln einstellen setVisible(true); // das Fenster ist anfangs sichtbar } } public class FensterDemo { public static void main(String[] args) { Fenster zumHof = new Fenster("zum Hof"); Fenster zurTür = new Fenster("zur Tür"); } } Damit diese Fenster z.B. auf das Schaltfeld „Beenden“ (in Windows: das rote Kreuz rechts oben) reagieren, muss man ihnen einen entsprechenden Fensterbeobachter zuordnen: import java.awt.*; import java.awt.event.*; class FensterBeobachter extends WindowAdapter{ public void windowClosing(WindowEvent e) {// Schließen des Fensters System.exit(0); // Aktion beim Schließen: z.B. Programmende } } class Fenster extends Frame { Fenster (String titel) { FensterBeobachter fb = new FensterBeobachter(); addWindowListener(fb); // hier wird der Beobachter registriert super(titel); ...); // wie oben } } public class FensterDemo { public static void main(String[] args) { Fenster f = new Fenster("PI-1"); } } Innerhalb einem Fenster kann man verschiedene Knöpfe definieren. Jedem Knopf wird ein Knopfbeobachter zugewiesen, der auf das Ereignis „Knopf wird gedrückt“ reagiert: class KnopfBeobachter implements ActionListener{ public void actionPerformed(ActionEvent e) { System.out.println("Knopf gedrückt!"); //Aktion beim Klick } } 9-107 class Fenster extends Frame { Fenster (String titel) { ... Button k = new Button("Knopf!"); // neuen Knopf definieren KnopfBeobachter kb = new KnopfBeobachter(); // neuer Beobachter kb k.addActionListener(kb); // Beobachter kb Knopf k zuordnen add(k); // Knopf zum aktuellen Fensterobjekt hinzufügen } } Zur Verwendung aktiver Komponenten (Knöpfe, Schalter, …) in einer GUI sind also immer folgende Schritte nötig • Erzeugen – Button b = new Button () • Ereignisbehandlung zuordnen – b.add.ActionListener(...) • Hinzufügen zum Fenster – add(b) • Methode zur Ereignisbehandlung schreiben actionPerformed, itemStateChanged, adjustmentValueChanged,... Jedem Schaltelement können beliebig viele Beobachter zugeordnet werden, die auf die unterschiedlichen Ereignisse reagieren. Beobachter müssen nicht als eigene Variablen benannt werden, sondern können auch anonym existieren: setLayout(new FlowLayout()); Button knopf2 = new Button("Knopf2"); knopf2.addActionListener (new ActionListener (){ public void actionPerformed(ActionEvent e) { System.out.println("Knopf2 gedrückt!"); }}); add(knopf2); ... Für die graphische Gestaltung der Benutzerinteraktion ist eine Vielzahl von automatischen Optionen verfügbar; zu Layout-Fragen konsultiert man am besten die entsprechende Dokumentation. Gängige Layouts sind z.B. • • • • • BorderLayout FlowLayout GridLayout CardLayout GridBagLayout Es ist auch eine Schachtelung von Layouts möglich; darüber hinaus lässt sich natürlich jedes Objekt auch absolut innerhalb des Fensters positionieren. Als ein etwas größeres Beispiel betrachten wir jetzt eine Stoppuhr, die durch entsprechende Mausklicks gestartet und gestoppt wird, und bei der die Zeitanzeige im Format (Minuten, Sekunden, Hunderstelsekunden) in Textfeldern des Fensters erscheint. Die Klasse enthält die Zeitanzeige mit drei Label (Minuten, Sekunden, Hundertstel) sowie die beiden Knöpfe StartStop und Reset. class Stoppuhr extends Frame { Label zeitAnzeigeMin, zeitAnzeigeSec, zeitAnzeigeCsec; Button startstop, reset; long startTime, elapsedTime, elapsedLast = 0; boolean running = false; 9-108 Stoppuhr(){ // Konstruktor erzeugt Layout zeitAnzeigeMin = new Label(); zeitAnzeigeMin.setBounds(50, 50, 40, 25); add(zeitAnzeigeMin); zeitAnzeigeSec = new Label(); zeitAnzeigeSec.setBounds(100, 50, 40, 25); add(zeitAnzeigeSec); zeitAnzeigeCsec = new Label(); zeitAnzeigeCsec.setBounds(150, 50, 40, 25); add(zeitAnzeigeCsec); startstop = new Button("Start"); startstop.setBounds(50,100,60,25); startstop.addActionListener(new ButtonListenerStartStop()); add(startstop); reset = new Button("Reset"); reset.setBounds(120,100,60,25); reset.addActionListener(new ButtonListenerReset()); add(reset); addWindowListener(new WindowAdapter (){ public void windowClosing(WindowEvent e) { System.exit(0); } }); add(new Button()); // sonst Layoutfehler?!? } public static void main(String[] args) { Stoppuhr uhr = new Stoppuhr(); // erzeuge neues Fenster uhr.setBounds(0, 0, 250, 180); //250 Pixel breit, 180 Pixel hoch uhr.setVisible(true); uhr.new Zeitanzeige ().start(); // starte separaten Thread } Die Main-Methode startet die Uhranzeige als parallelen Thread, der ständig die Anzeige aktualisiert: class Zeitanzeige extends Thread{ public void run(){ while(true){ if (running){ elapsedTime = (System.currentTimeMillis() - startTime) + elapsedLast; // nach stop, weiter- statt neuzählen int csec = (int) (elapsedTime % 1000 / 10); int sec = (int) (elapsedTime / 1000) % 60; int min = (int) (elapsedTime / 60000); zeitAnzeigeCsec.setText(((csec < 10)? "0" : "") + csec); zeitAnzeigeSec.setText(((sec < 10)? "0" : "") + sec); zeitAnzeigeMin.setText(((min < 10)? "0" : "") + min); } } } } Schließlich müssen wir noch die Aktionen definieren, die beim Klicken der Knöpfe ausgeführt werden: 9-109 class ButtonListenerStartStop implements ActionListener{ public void actionPerformed(ActionEvent e){ startTime = System.currentTimeMillis(); // neue Startzeit if (! running){ running = true; startstop.setLabel("Stop"); } else { running = false; elapsedLast = elapsedTime; // Speichern des gestoppten Stands startstop.setLabel("Start"); } } } class ButtonListenerReset implements ActionListener{ public void actionPerformed(ActionEvent e){ startTime = System.currentTimeMillis(); elapsedLast = 0; zeitAnzeigeMin.setText("00"); zeitAnzeigeSec.setText("00"); zeitAnzeigeCsec.setText("00"); startstop.setLabel("Start"); } } } Das entstehende Fenster sieht wie folgt aus: Graphikprogrammierung In einem interaktiven Programm möchte man mit der Maus normalerweise noch mehr machen, als nur Knöpfe anzuklicken. Mausevents (Ziehen, Doppelklick, Rechtsklick usw.) werden nach dem selben Schema wie Knopfdrücke behandelt addMouseListener(new MouseAdapter() { public void mousePressed(MouseEvent e) { x1 = e.getX(); y1 = e.getY(); } ... Auf diese Weise ist es möglich, graphische Elemente (Linien, Kreise, Vielecke, Polygone…) als Objekt der Klasse Graphics auszugeben und mit der Maus zu bearbeiten. • • Verwendete Methoden: drawLine, fillPolygon, drawOval, … Konstruktor getGraphics() Als Beispiel implementieren wir ein Malprogramm: Nach Klicken auf die Knöpfe „Rechteck“ oder „Oval“ können entsprechende Figuren durch Ziehen mit der Maus gezeichnet werden. 9-110 import java.awt.*; import java.awt.event.*; class GrafikFenster extends Frame{ private static int x1, y1, x2, y2; private static int modus = 0; GrafikFenster(String titel, int breite, int höhe){ super(titel); setLayout(new FlowLayout()); Button rechteckButton = new Button ("Rechteck"); rechteckButton.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent event) { modus = 1; } }); add(rechteckButton); Button ovalButton = new Button ("Oval"); ovalButton.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent event) { modus = 2; } }); add(ovalButton); addMouseListener(new MouseAdapter() { public void mousePressed(MouseEvent e) { x1 = e.getX(); y1 = e.getY(); } public void mouseReleased(MouseEvent e) { x2 = e.getX(); y2 = e.getY(); Graphics g = getGraphics(); g.setColor(Color.blue); int w=Math.abs(x1-x2), h=Math.abs(y1-y2); switch (modus) { case 1: g.fillRect(x1,y1,w,h); break; case 2: g.fillOval(x1,y1,w,h); break; default: break; } } }); addWindowListener(new WindowAdapter() { public void windowClosing(WindowEvent e) { dispose(); } }); setBackground(Color.lightGray); setSize(breite, höhe); setVisible(true); } } public class GrafikDemo { public static void main(String[] args) { GrafikFenster f = new GrafikFenster("Künstler",800,600); } } Animationen Ein Problem des obigen Programms ist es, dass bei Veränderung des Fensters (z.B. Vergrößern) die Zeichnung verloren geht. Das kann mit der Methode paint (Graphics g) zur Ausgabe der Zeichnung gelöst werden. paint wird bei Fensterveränderungen automatisch aufgerufen und muss so programmiert werden, dass es alle Zeichnungsobjekte neu ausgibt. Eine Möglichkeit dazu besteht darin, alle Zeichnungsobjekte in einer Collection (z.B. Set) einzutragen und beim Neuzeichnen iterativ alle Elemente auszugeben. Die selbe Technik kann auch für Animationen benutzt werden, indem paint() in eigenem Thread (etwa alle 40 ms) aufgerufen wird. 9-111 9.2 Abstrakte Klassen, Interfaces, generische Typen Abstrakte Klassen Abstrakte Klassen sind solche, die noch nicht „fertig“ sind: sie enthalten Methoden ohne Implementierung. Beispiel: abstract class Figur { protected int x, y; public void setzeX(int xNeu) { x = xNeu; } public abstract float berechneFlaeche(); } Für abstrakte Klassen ist keine Instanzenbildung erlaubt; allerdings können Unterklassen die abstrakte Klasse erweitern und die abstrakten Methoden konkretisieren. Interfaces Ein Interface ist das Java-Äquivalent zur Signatur eines abstrakten Datentyps. Es besteht nur aus den Köpfen der Methoden, enthält keine Datenfelder oder Algorithmen (aber auch keine algebraischen Gesetze). Eingeleitet werden Interfaces mit dem Schlüsselwort interface: Beispiel: public interface StackInterface { public boolean isempty(); public void push(char x); public char top(); public void pop(); } Die implementierende Klasse referenziert dann das Interface: class Stack implements StackInterface { ... } Interfaces sind gut geeignet, um Benutzungsschnittstellen von Klassen noch vor der eigentlichen Implementierung festzulegen. Von einem Interface können keine Instanzen gebildet werden (da ja keine Methodenrümpfe und Datenfelder vorliegen). Allerdings kann der Compiler die Konsistenz einer Benutzung der Klasse sowie die Konsistenz einer Implementierung des Interfaces mit der Definition überprüfen. Eine Variable, die als Typ das Interface hat, darf als Wert Objekte aller implementierenden Klassen haben. Ein wesentlicher Unterschied zwischen abstrakten Klassen und Interfaces besteht darin, dass eine Klasse mehrere Interfaces implementieren kann (sogenannte Mehrfachvererbung). Generische Typen Generische Typen realisieren da Konzept der parametrisierten abstrakten Datentypen. Es ist möglich, einer Klasse einen Typ als Parameter mitzugeben: class Tupel<T> { private T first; private T second; public Tupel(T fst, T scd) { first = fst; second = scd; } public T getFirst() { return first; } public T getSecond() { return second; } } 9-112 Der Zugriff erfolgt dann wie folgt: pi = new Pair<Integer> (17, 24); ps = new Pair<String> ("Hallo", "World"); Allerdings ist das Konzept in Java nur eingeschränkt nutzbar, da z.B. keine Felder über generischen Typen gebildet werden können. 9.3 Fehler, Ausnahmen, Zusicherungen Ausnahmen Eine typische Situation beim Programmieren ist der Entwurf von Klassenbibliotheken für (spätere) Anwendungen. Hier muss man beständig mit unzulässige Eingaben rechnen (z.B. top(empty()), Division durch Null, usw.). Es erhebt sich die Frage, wie man mit solchen unzulässigen Eingabewerten umgehen sollte. Eine Möglichkeit besteht darin, die unzulässige Eingabe zu ignorieren und einen Standardwert zurückgeben. (z.B. x/0 = 0). Dies hat mehrere Nachteile: Der Definitionsbereich der Funktionen wird unzulässig erweitert, der Anwender der Klasse bemerkt seinen FDehler nicht und der Fehler wird unkalkulierbar verschleppt. Besser ist es, das Programm mit einer Fehlermeldung abzubrechen („lieber ein Ende mit Schrecken als ein Schrecken ohne Ende“). Die dritte Möglichkeit besteht darin, zu jeder Methode einen zusätzlichen boole’schen Ergebniswert („ok“) einzuführen, der auf true gesetzt wird wenn der Methodenaufruf fehlerfrei war. Diese Methode erfordert gegebenenfalls erheblichen zusätzlichen Schreibaufwand, wird aber z.B. in der Programmiersprache C praktiziert. In Java besteht die Lösung im „Aufwerfen“ (throw) einer Ausnahmesituation. Das „Abfangen“ (catch) der Ausnahmesituation liegt in der Verantwortung des Aufrufers; wenn er sich nicht darum kümmert, wird die Ausnahmesituation „nach oben weitergereicht“. Wenn sich niemand um den Fehler kümmert, bricht das Programm ab. Dadurch ist es möglich, gewisse Fehler auf der entsprechenden Programmebene kontrolliert zu behandeln, Debugging-Informationen auszugeben, oder das Programm sogar unter Umständen fortzusetzen. public class Ausnahmebehandlung { static void f() throws Exception { Exception e = new Exception("Fehler1"); if (1==1) throw e; } public static void main(String[] args) { // … System.out.println("vor Aufruf"); try { f(); } catch (Exception x) { System.out.println (x + " ist aufgetreten"); } // … } } Achtung: Ausnahmesituationen gehören zur Signatur der Methode! Dies bedeutet, dass Ausnahmesituationen in Java „Bürger erster Klasse“ sind. Eine Ausnahmesituation ist ein Objekt der Klasse Exception. Das bedeutet, es gibt Konstruktoren ohne und mit Argument (String). Das Werfen einer Ausnahmesituation (throw) bewirkt die Beendigung der Methode und die Rückkehr zur Aufrufstelle. Aufruf einer Methode, die eine Ausnahmesituation werfen kann, ist nur in einem try-Block möglich; das Auftreten der Ausnahmesituation wird von nachfolgenden Exception Handlern überwacht. Der erste Exception Handler, dessen Parameter-Typ mit dem Typ der geworfenen Exception übereinstimmt, wird ausgeführt, und 9-113 nach Beendigung wird des Exception Handlers wird das Programm normal fortgesetzt. Auf diese Weise ist es möglich, für jede mögliche Ausnahmesituation eine dezidierte Fehlerbehandlung anzustoßen. Jede Ausnahme muss behandelt oder weitergereicht werden (Ausnahme folgt), d.h., der Compiler zwingt den Programmierer, sich über die notwendigen Maßnahmen beim Eintreten der Ausnahmesituation Gedanken zu machen. f();//geht nicht! static void g() throws Exception { f(); } Exception-Hierarchie • • Basisklasse aller Ausnahmen ist Throwable Davon abgeleitet sind Exception und Error Konvention: Exception für behebbare, Error für nicht vom Anwender behebbare Ursachen Von Exception werden u.a. IOException und RuntimeException abgeleitet RuntimeException und davon abgeleitete Klassen müssen nicht behandelt werden Syntax try { // Anweisungen die Ausnahmen werfen könnten } catch(Typ1 Objekt1) { // Anweisungen für Ausnahme1 } catch(Typ2 Objekt2) { // Anweisungen für Ausnahme2 } finally { // Anweisungen für Endbehandlung } Es ist natürlich möglich, eigene Ausnahmen zu definieren: static class MyException1 extends Exception{} static class MyException2 extends Exception{} public class Ausnahmebehandlung { static void f() throws MyException1, MyException2 { if (1==1) throw new MyException1(); else throw new MyException2(); } public static void main(String[] args) { System.out.println("vor Aufruf"); try { f(); } catch (MyException1 x) { System.out.println (x+" - Handler1"); } catch (MyException2 x) { System.out.println (" Handler2: " + x); } finally {System.out.println("Finally!");} }; } 9-114 Hier noch eine allgemeine Anmerkung zu Ausnahmen: Ausnahmen sollten NICHT zur Ablaufkontrolle missbraucht werden! Ausnahmen von dieser Regel sind möglich und zulässig (z.B. bei Benutzereingaben). Als Beispiel verfeinern wir unser Programm zum Einlesen einer natürlichen Zahl (größer Null). import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; public class ZahlEingabe { public static void main(String[] args) { int i = 0; System.out.println ("Bitte Zahl eingeben!"); while (i <= 0){ BufferedReader inputReader = new BufferedReader(new InputStreamReader(System.in)); String eingabeZeile = ""; try { eingabeZeile = inputReader.readLine(); i = Integer.parseInt (eingabeZeile); if (i<=0) { System.out.println ("Bitte Zahl größer Null eingeben!");} } catch (NumberFormatException e){ System.out.println ("Not a Number");} catch (IOException e){ System.out.println ("I/O Exception!");} } System.out.println ("Eingegeben: " + i); } } Zusicherungen Zusicherungen (Assertions) sind boole‘sche Ausdrücke, die dazu dienen, die Sicherheit beim Programmieren zu erhöhen. Sie können bei der Entwicklung von Programmen helfen, Irrtümer und Missverständnisse zu vermeiden. Eingeleitet werden Zusicherungen durch das Schlüsselwort assert expr; oder auch assert expr : expr; Beispiel für eine Zusicherung: assert (i<liste.length()): "Index " + i + " falsch"; Semantik: Während des Ablaufs des Programms wird geprüft, ob die Zusicherung eingehalten ist. Falls nicht, wird eine Ausnahme geworfen (bzw. der Ausdruck an die Ausnahme übergeben). Verwendung finden Zusicherungen unter anderem als Vor- und Nachbedingungen von Methoden (z.B. beim Aufruf von pop(x) wird zugesichert, daß !isEmpty(x)). Zusicherungen sind, wenn sie richtig eingesetzt werden, eine effiziente Methode zur Entwicklung und zum Debuggen von Programmen. Bei der Ausführung verlangsamt die Auswertung einer Zusicherung natürlich das Programm. Daher können Zusicherungen in der Java-Laufzeitumgebung ein- und ausgeschaltet werden: Argument: -enableassertions oder –ea Wenn die Zusicherungsauswertung ausgeschaltet ist, können Zusicherungen als eine Art Kommentar betrachtet werden. 9-115 9.4 Parallelität Im objektorientierten Paradigma bedeutet Berechnung die Interaktion von Objekten. Das bedeutet, dass mehrere Objekte gleichzeitig existieren. In der sequentiellen Programmierung ist jedoch immer nur eines dieser Objekte aktiv. Anfangs gibt es nur das statische MainObjekt. Dieses erzeugt andere Objekte und ruft diese auf. Jeder Aufruf ist blockierend, d.h. der Aufrufer muss warten, bis das aufgerufene Objekt fertig ist und die Kontrolle zurückgibt. Folglich ist zu einem gegebenen Zeitpunkt immer nur ein Objekt rechnend. Parallelität bedeutet, dass mehrere Handlungsstränge zur selben Zeit ablaufen. Dies ist 1. dem objektorientierten Paradigma angemessener 2. für die Ausführung auf Mehrkernprozessoren besser geeignet. Es gibt in Java mehrere Arten der Parallelität: • schwergewichtige Parallelität: mehrere Prozesse (tasks) gleichzeitig, werden vom Betriebssystem verwaltet, Kommunikation über spezielle BS-Mechanismen (pipes, sockets) • leichtgewichtige Parallelität: mehrere Handlungsfäden (threads) innerhalb einer Task, Verwaltung vom Laufzeitsystem, Kommunikation über gemeinsame Variable Threads in Java In Java gibt es zwei Arten der Definition 1. Erweiterung der Klasse Thread mit Überlagerung der Methode run 2. Implementierung der Schnittstelle Runnable mit neuer Methode run Definition eines objektlokalen Datenfelds t vom Typ Thread, Erzeugung eines zugehörigen Thread-Objektes Aufruf von t.start() führt zur Ausführung von run als separater Handlungsfaden Beispiel (1): Wir erzeugen eine Erweiterungsklasse von Thread mit Methode run. Diese wird dann vom aufrufenden Programm aus gestartet. class Toe extends Thread { public void run() { System.out.println("Prozess Toe gestartet"); } public static void main(String[] args) { Toe toe = new Toe(); toe.start(); System.out.println("Prozess Toe beendet"); } } Achtung: Das Ergebnis ist (in dieser Reihenfolge!): Prozess Toe beendet Prozess Toe gestartet Beispiel (2): Wir erzeugen zwei Instanzen einer Implementierungsklasse zum Interface Runnable. Diese enthalten jeweils einen Thread, der vom Aufrufer gestartet werden kann. class TicTacToe { static class TicTac implements Runnable{ Thread faden; private int wer; public TicTac(int w) { faden = new Thread(this); wer=w;} 9-116 public void run() { System.out.println("Prozess T"+((wer==1)?"i":"a")+"c gestartet"); } } public static void main(String[] args) { TicTac tic = new TicTac(1); TicTac tac = new TicTac(2); tic.faden.start(); tac.faden.start(); try {tic.faden.join(); tac.faden.join(); } catch (Exception e) {} System.out.println("alles beendet"); } } Wie das Beispiel zeigt, kann der Aufrufer auch auf die Beendigung eines Threads warten. Beispiel (3) zeigt, dass man bei der parallelen Programmierung vorsichtig sein muss, weil sich leicht Fehler einschleichen. Wir inkrementieren und dekrementieren eine gemeinsame Variable in zwei verschiedenen Threads gleich oft. class TicTac implements Runnable{ static int summe = 0; Thread faden; private int wer; public TicTac(int w) { faden = new Thread(this); wer=w; } public void run() { for(int i=1; i<10000; i++) { if(wer==1) summe = summe + 1; else summe = summe - 1; } } public static void main(String[] args) { TicTac tic = new TicTac(1); TicTac tac = new TicTac(2); tic.faden.start(); tac.faden.start(); try {tic.faden.join(); tac.faden.join(); } catch (Exception e) {} System.out.println("Summe=" + summe); } } Der Thread tic zählt die Variable summe hoch, der Thread tac zählt sie runter. Das Ergebnis ist aber nicht in jedem Fall 0, sondern unvorhersagbar. (Für „kleine“ Schleifen ergibt sich jedoch immer 0). Interpretation der Ergebnisse • Prinzipiell werden die Aktionen in den Threads parallel und unabhängig voneinander ausgeführt • Falls mehr Prozesse als Prozessoren existieren, muss die Rechenzeit aufgeteilt werden • Zeitscheibenzuteilung: Jeder Thread erhält eine bestimmte Zeitspanne, bevor er unterbrochen wird. Dadurch kann es passieren, dass ein bereits Thread fertig ist, bevor der zweite überhaupt anfängt • Interleaving: Durch parallelen Zugriff auf gemeinsame Variable können Werte verfälscht werden. 9-117 Interleaving Durch verzahnt Ausführung (wie bei einem Reißverschluss) können mehrere Prozesse auf nur einem Prozessor ausgeführt werden. Jeder Prozess erhält dabei der Reihe nach eine Zeitscheibe einer bestimmten Dauer zugewiesen (die Dauer ist nicht notwendigerweise immer gleich (Bild „Einfädeln“ von Autos bei einer Baustelle). Beim Zugriff auf gemeinsame Variable kann es dabei zu unerwarteten Ergebnissen kommen: Der Befehl {s++} wird zur Befehlsfolge {load s; add 1; store s} übersetzt. Analog bedeutet {s--} in Maschinensprache {load s; sub 1; store s}. Verschiedene Prozesse benutzen dabei verschiedene Register für die Rechnung. Bei zwei Prozessen, die beide auf dieselbe Variable zugreifen ergeben sich jedoch bei der quasiparallelen Ausführung von s++ || s-- folgende Szenarien: s=5 (z.B.) Befehl P1 Befehl P2 load s add 1 store s load s sub 1 store s Werte (AC1, AC2, s) (5, -, 5) (6, -, 5) (6, -, 6) (6, 6, 6) (6, 5, 6) (6, 5, 5) aber auch: Befehl P1 Befehl P2 load s load s add 1 sub 1 store s store s Werte (AC1, AC2, s) (5, -, 5) (5, 5, 5) (6, 5, 5) (6, 4, 5) (6, 4, 6) (6, 4, 4) oder Befehl P1 Befehl P2 load s load s sub 1 store s add 1 store s Werte (AC1, AC2, s) (5, -, 5) (5, 5, 5) (5, 4, 5) (5, 4, 4) (6, 4, 4) (6, 4, 6) Das bedeutet, wenn man s simultan inkrementiert und dekrementiert, ist das Ergebnis zufällig s oder s+1 oder s-1! Wenn wir diesen Effekt oft wiederholen (for (int i = 0; i<1000000; i++)) verstärkt sich der Fehler. 9-118 Prozess-Zustände Prozesse können dem Laufzeit- oder Betriebssystem selbst ankündigen, ob sie nach Ablauf ihrer Zeitscheibe weiter ausgeführt werden sollen oder nicht. Das nennt man präemptives Multitasking. Zur Synchronisation von Prozessen gibt es in Java folgende Möglichkeiten: • start() – startet den Thread • sleep(int i) – suspendiert den Prozess i ms • wait() – Wartet auf den Eintritt eines Ereignisses • notify() – benachrichtigt einen wartenden Thread • notifyAll() – benachrichtigt alle wartenden Threads • join() – wartet auf die Beendigung des Threads • interrupt() – unterbricht die Ausführung eines Threads • isInterrupted() – Test ob unterbrochen • synchronized – exklusiver Ressourcenzugriff-Monitor Ein Monitor ist eine Klasse, die die Unteilbarkeit auf die von ihr verwalteten Ressourcen garantiert. Dies geschieht mit dem Schlüsselwort synchronized vor einer Methode. class Monitor { private int i = 0; synchronized void inc() { i++;} synchronized void dec() { i--;} int val() { return i; } class TicTacMon implements Runnable{ static Monitor summe = new Monitor(); Thread faden; private int wer; public TicTacMon(int w) { faden = new Thread(this); wer=w;} public void run() { System.out.println("Prozess " + wer + " gestartet"); for(int i=1;i<10000000;i++) { if(wer==1) summe.inc(); else summe.dec(); } System.out.println("Prozess "+ wer + " beendet"); } public static void main(String[] args) { TicTacMon tic = new TicTacMon(1); TicTacMon tac = new TicTacMon(2); tic.faden.start(); tac.faden.start(); try {tic.faden.join(); tac.faden.join();} catch (Exception e) {} System.out.println("Summe=" + summe.val()); } } } 9-119 synchronisierte Methoden Für jedes Objekt gibt es ein intrinsisches Schloss, welches den Zugang einschränkt. Während des Ablaufs einer synchronisierten Methode wird das Schloss verschlossen, daher kann keine andere synchronisierte Methode dieses Objekts gleichzeitig ablaufen. Diese muss ggf. warten (Gefahr der Verklemmung (deadlock)!) Mit einer Synchronisationsanweisung lässt sich der gleiche Effekt wie mit einem Monitor erzielen: class Mutex {}; class TicTac implements Runnable{ static Mutex m = new Mutex(); ... public void run() { for(int i=1;i<10000000;i++) { synchronized(m) { if(wer==1) summe++; else summe--; } ... Dinierende Philosophen Dieses Standardbeispiel von E.W.Dijkstra verdeutlicht die Probleme, die bei der Koordinierung paralleler Abläufe durch die Konkurrenz um gemeinsame Betriebsmittel auftreten können. 5 Philosophen sitzen um einen runden Tisch, in der Mitte steht eine Schüssel Spaghetti. Zwischen je zwei Philosophen ist eine Gabel (bzw. ein Ess-Stäbchen). Jeder Philosoph betätigt sich zyklisch nur mit den Tätigkeiten denken – essen – denken – essen – denken – essen – denken –... Zum Essen benötigt ein Philosoph allerdings zwei Gabeln / Ess-Stäbchen, nämlich die zu seiner linken und zu seiner rechten. Ein einfacher Algorithmus für jeden Philosophen wäre also: wiederhole immer wieder: denke warte, bis linke Gabel frei, dann nimm sie warte, bis rechte Gabel frei, dann nimm sie iss gib beide Gabeln wieder frei In Java lässt sich das etwa wie folgt formulieren: public class Gabel { private boolean benutzt = false; private int welche; Gabel (int i){welche = i;} synchronized void aufnehmen() throws InterruptedException { while (benutzt) wait(); System.out.println("Gabel " + welche + " aufgenommen"); benutzt = true; } synchronized void hinlegen(){ benutzt = false; System.out.println("Gabel " + welche + " hingelegt"); } } 9-120 public class Philosoph extends Thread { private int wer; private static int n = 5; private static Gabel [] gabel = new Gabel [n]; private static Philosoph [] phil = new Philosoph [n]; Philosoph (int i){wer = i;} public void run (){ System.out.println("Philosoph " + wer + " gestartet"); try{ while (true){ System.out.println("Philosoph " + wer + " denkt"); sleep((long)(10000*Math.random())); // Denken System.out.println("Philosoph " + wer + " hungrig"); gabel[(wer==0)?n-1:wer-1].aufnehmen(); gabel[wer].aufnehmen(); System.out.println("Philosoph " + wer + " isst"); sleep((long)(10000*Math.random())); // Essen gabel[(wer==0)?n-1:wer-1].hinlegen(); gabel[wer].hinlegen(); } } catch(InterruptedException e){} } public static void main(String[] args) { for (int i=0; i<n; i++){ gabel[i] = new Gabel (i); phil[i] = new Philosoph(i); } for (int i=0; i<n; i++){ phil[i].start(); } } } Diese Lösung ist allerdings verklemmungsbedroht! Es kann passieren, dass jeder Philosoph individuell seine linke gabel nimmt (damit liegt keine Gabel mehr auf dem Tisch) und dann wartet, bis er die rechte nehmen kann (was aber nie passieren wird). Allgemein ist eine Verklemmung (deadlock) ein zyklischer Wartezustand: A wartet auf B, B wartet auf C, …, Y wartet auf Z und Z wartet auf A. Ein Applet, um diesen Effekt auszuprobieren, findet sich z.B. unter http://www.doc.ic.ac.uk/~jnm/concurrency/classes/Diners/Diners.html Ein etwas besserer Algorithmus vermeidet das Verklemmungsproblem. Jeder Philosoph macht folgendes: 9-121 wiederhole immer wieder denke wiederhole solange bis beide Gabeln genommen wurden: warte, bis linke Gabel frei, dann nimm sie falls rechte Gabel nicht da, gib linke wieder frei warte, bis rechte Gabel frei, dann nimm sie falls linke Gabel nicht da, gib rechte wieder frei iss gib beide Gabeln wieder frei Obwohl diese Lösung nachweislich verklemmungsfrei ist, hat sie einen anderen gravierenden Nachteil: Es ist möglich, dass sich einzelne oder alle Philosophen endlos in einer sinnlosen Beschäftigung verlieren, in dem sie abwechselnd die linke und rechte Gabel aufnehmen und wieder ablegen. Solch eine Situation nennt man manchmal Endlosschleife (livelock); formal ist das eine Situation, in der intern immer dieselben Handlungen ausgeführt werden, ohne dass nach außen irgend ein Fortschritt erkennbar wäre. Ein noch besserer Algorithmus vermeidet dieses Problem, indem durch einen geeigneten Synchronisationsalgorithmus das Aufnehmen beider Gabeln simultan erfolgt. Jeder Philosoph macht also folgendes: wiederhole immer wieder denke warte, bis beide Gabeln frei, dann nimm sie (beide gleichzeitig) iss gib beide Gabeln wieder frei (und teile dies ggf. den Nachbarn mit) Das simultane Aufnehmen der beiden Gabeln kann in Java dadurch programmiert werden, dass ein Monitor den Zugriff auf die Gabeln regelt. Die Lösung hat jedoch immer noch eine Schwäche: Es kann sein, dass zwei Philosophen sich zusammentun, um den zwischen ihnen sitzenden „auszuhungern“: Philosoph 2 ist hungrig, aber erst isst Philosoph 1 (und 2 wartet auf die linke Gabel), und dann Philosoph 3 ( und 2 wartet auf die rechte Gabel). Allgemein ist ein System aushungerungsfrei (starvation free), wenn garantiert ist, dass jeder kontinuierlich fortsetzungswillige Prozess auch irgendwann fortgesetzt wird. Aushungerungsfreiheit ist ein spezieller Fall der so genannten Fairness, die garantiert, dass kein Prozess immer wieder benachteiligt wird. Allgemeine FairnessEigenschaften lassen sich programmtechnisch nur schwer garantieren; pragmatische Lösungsmöglichkeiten bestehen darin • vor dem Aufnehmen der Gabel(n) eine zufällig bestimmte Zeitdauer zu warten (keine garantierte Aushungerungsfreiheit, wird aber in Kommunikationsprotokollen so gelöst) • einen unabhängigen Aushungerungs-Erkennungsalgorithmus einzusetzen („Wachhund“, watchdog) • die Symmetrie zwischen den Philosophen zu brechen und z.B. eine feste Reihenfolge der Zuteilung vorzugeben, oder • ein Wartenummernverfahren (ähnlich wie auf Behörden-Wartezimmern) einzuführen. 9-122 Kapitel 10: Algorithmen und Datenstrukturen Algorithmen und Datenstrukturen wurden klassischerweise als „das“ Thema der praktischen Informatik betrachtet, in praktisch jedem Informatikstudiengang gibt es Spezialvorlesungen dazu. Andererseits ist die Bedeutung des Themas in der Praxis rückläufig, da es mittlerweise zu fast jedem Standard-Thema umfangreiche Bibliotheken gibt und man daher viele Algorithmen und Datenstrukturen nicht selbst implementieren, sondern einfach importieren wird. Für einen Informatiker ist es jedoch unerlässlich, zu wissen, wie die importierten Routinen prinzipiell funktionieren, sonst ist das Ergebnis vielleicht nicht optimal. Darüber hinaus gibt es auf dem Gebiet immer noch interessante Forschungsfragen und einige überraschende Effekte, besonders was die Komplexität gewisser Probleme betrifft. 10.1 Listen, Bäume, Graphen Im Kapitel über abstrakte Datentypen waren diese durch ihre Signatur (Methodenköpfe) und Eigenschaften (algebraische Gesetze) definiert worden. In der Implementierung hatten wir gesehen, dass Klassen Datenfelder enthalten können. Jedes Objekt gehört zu einer bestimmten Klasse (Beispiel: int i; Thread f; PKW herbie usw.). Objekte können andere Objekte als Bestandteile enthalten (Beispiel: Auto enthält Tachostand). Frage ist, ob ein Objekt andere Objekte derselben Klasse enthalten kann (Beispiel: Schachtel enthält Schachtel)? Falls diese Möglichkeit in einer Sprache zugelassen ist, sprechen wir von rekursiven Datenstrukturen. (Sprachen wie C oder Delphi, die dieses Konzept nicht besitzen, greifen zur Realisierung meist auf maschinennahe Hilfsmittel wie Zeiger zurück.) In rekursiven Datenstrukturen ist es erlaubt, dass ein Objekt eine Komponente vom selben Typ enthält wie das Objekt selbst. Das Rekursionsende wird dann durch das „leere Objekt“ null gekennzeichnet. public class Zelle { int inhalt; Zelle next; // Verweis auf die nächste Zelle Zelle (int i, Zelle n){ inhalt = i; next = n; } } Mit solchen Zellen lassen sich verkettete Listen von Zellen realisieren. Listen sind endliche Folgen von Elementen und dienen hier als Beispiel für eine dynamische Datenstruktur: Im Unterschied zu Arrays, bei denen bereits bei der Erzeugung eine feste Maximallänge angegeben werden muss, können Listen beliebig wachsen und schrumpfen. Neue Elemente werden einfach an die Liste angehängt, nicht mehr benötigte Elemente können wieder aus der Liste entfernt werden. Verkettete Listen kann man sich wie eine Perlenschnur vorstellen: von jeder Perle gibt es eine Verbindung zur folgenden. In den Perlen befindet sich die Information. Im konkreten Beispiel besteht die Liste aus einer Zelle, die den Anfang markiert. Jede Zelle hat einen Inhalt und einen Verweis auf die folgende Zelle. In der Klasse „Liste“ befinden sich darüber hinaus Methoden, um neue Elemente anzufügen, zu suchen, zu löschen usw. Vorne anfügen kann beispielweise dadurch erfolgen, dass man ein neues Element 10-123 erzeugt, das als Nachfolger den bisherigen Anfang hat, und dieses neue Element als neuen Anfang der Liste nimmt. public class Liste { Zelle anfang; void prefix(int n){ Zelle z = new Zelle (n,anfang); anfang = z; } void search(int n){...} } Zum Ausgeben einer Liste überschreiben wir die Objekt-Methode toString. Die Ausgabe erfolgt rekursiv gemäß der rekursiven Definition der Datenstruktur. public class Zelle { ... public String toString(){ return inhalt+((next==null)?"": } } " -> " + next.toString()); public class Liste { Zelle anfang; ... public String toString() { return anfang.toString(); } } Hier ist ein kleines Beispiel zum Testen der Klasse „Liste“. public class ListenAnwendung { static Liste l = new Liste(); public static void main(String[] args) { l.prefix(101); l.prefix(38); l.prefix(42); System.out.println(l); } } Dieser Test druckt 42 -> 38 -> 101. Entfernen von Elementen einer Liste: Um das erste Element zu löschen, setzen wir den Anfang einfach auf das zweite Element: void removeFirst () { anfang = anfang.next; } 10-124 Der vorherige Anfang ist damit nicht mehr zugreifbar! Natürlich darf diese Methode nur ausgeführt werden, falls die Liste nicht leer ist. Um ein inneres Element zu löschen, verbinden wir die Vorgängerzelle mit der Nachfolgerzelle. Man beachte, daß die Liste dadurch verändert wird! Das abgeklemmte Element ist vom Programm aus nicht mehr zugreifbar. Das Java-Laufzeitsystem sorgt dafür, dass dies irgendwann von der Speicherbereinigung entdeckt wird und der Platz wiederverwendet werden kann. Programmiersprachlich lässt sich das Entfernen des n-ten Elementes etwa wie folgt definieren: void removeNth(int n) { if(anfang !=null) { Zelle v = anfang; while (v.next!=null && n>1) { v = v.next; n--; } if(v.next!=null) v.next = v.next.next; } } Achtung: removeFirst() ist nicht dasselbe wie removeNth(1). Auf ganz ähnlich Art lassen sich weitere Listenoperationen rekursiv definieren. int laenge(){ int i = 0; Zelle z = anfang; while (z!=null) { i++; z=z.next; } return i; } boolean enthaelt(int n) { boolean gefunden = false; Zelle z = anfang; while (z!=null && !gefunden) { if (z.inhalt==n) gefunden=true; z=z.next; } return gefunden; } Zelle suche(int n){ Zelle z = anfang; while (z!=null) { if (z.inhalt==n) return z; z=z.next; } return z; 10-125 } Eine Anwendung der Datenstruktur Liste ist die Realisierung von Kellern durch Listen. class Stapel extends Liste { Stapel() {} Stapel(Zelle z) { anfang = z; } boolean isEmpty(){ return anfang==null; } Stapel push(int i) { Zelle z = new Zelle(i, anfang); return new Stapel(z); } Stapel pop() { return new Stapel(anfang.next); } int top() { return anfang.inhalt; } } Ein Problem dieser Implementierung ist, dass bei jedem push und pop ein neuer Listenanfang generiert wird. Dieser wird zwar, wenn er nicht mehr benötigt wird, irgendwann von der Speicherbereinigung aufgesammelt. Trotzdem ergibt sich ein gewisser Effizienzverlust. Eine alternative Implementierung wäre etwa Stapel popSE() { removeFirst(); return this; } Stapel pushSE(int n) { prefix(n); return this; } Bei der Ausführung wird jetzt allerdings nur noch auf ein und derselben Liste gearbeitet! Dadurch ergeben sich weitere Seiteneffekte. Beispiel zur Demonstration: public class StapelDemo { Stapel s1 = new Stapel(); Stapel s2 = new Stapel(); int sampleMethod() { s1=s1.push(111).push(222).push(333); s2=s1.pop(); return s1.top(); } } Es ergibt sich die Ausgabe 333, da s1 = 333 -> 222 -> 111. Beim Austausch von pop durch popSE würde der Stapel s1 überschrieben, d.h. nach Aufruf von s2=s1.popSE(); ist s1 = 222 -> 111 und die Ausgabe ist 222. Schlangen Mit verketteten Listen lassen sich auch Schlangen (queues) realisieren. Dazu braucht man einen zusätzlichen Zeiger auf den Anfang der Schlange. Um den Aufbau am Schlangenende und den Abbau am Anfang zu implementieren, wird die Verkettung sozusagen „umgedreht“ 10-126 public class Schlange extends Liste { Zelle ende; boolean isEmpty(){ return anfang==null; } int head() { return anfang.inhalt; } void tail() { anfang=anfang.next; } void append(int n) { Zelle z = new Zelle(n, null); if (isEmpty()) { anfang = z; ende = z; } else { ende.next = z; ende = z; } } } Ein Beispiel mit Schlangen: class SchlangenDemo{ static Schlange s = new Schlange(); public static void main(String[] args) { s.append(100); System.out.println(s); s.append(200); s.append(300); System.out.println(s); System.out.println(s.head()); s.tail(); System.out.println(s.head()); } } druckt 100 100 -> 200 -> 300 100 200 Doppelt verkettete Listen („Deque“, double-ended queue) Für Listen, die auf beiden Seiten zugreifbar sein sollen, bietet sich eine symmetrische Lösung an. Für jede Zelle wird Nachfolger und Vorgänger in der Liste gespeichert. Beim Einfügen und Löschen müssen die doppelten Verkettungen beachtet werden. public class Deque { class Item{ int inhalt; Item links, rechts; public String printLR(){ return inhalt + ((rechts==null)?"": "->" + rechts.printLR());} public String printRL(){ return ((links==null)? "" : links.printRL() + "<-") + inhalt;} } Item erstes, letztes; Deque() {} Deque(Item e, Item l) { erstes=e; letztes=l; } 10-127 public void print(){ if (! isEmpty()){ System.out.println(erstes.printLR()); System.out.println(letztes.printRL()); } } boolean isEmpty() { return (erstes==null); } int first (){ return erstes.inhalt; } void rest() { erstes.rechts.links = null; erstes=erstes.rechts; } void prefix(int i) { Item neu = new Item(); neu.inhalt = i; if (this.isEmpty()) { erstes = neu; letztes = neu; } else { neu.rechts = erstes; erstes.links = neu; erstes = neu; } } int last (){ return letztes.inhalt; } void lead() { letztes.links.rechts = null; letztes=letztes.links; } void postfix(int i) { Item neu = new Item(); neu.inhalt = i; if (this.isEmpty()) { erstes = neu; letztes = neu; } else { neu.links = letztes; letztes.rechts = neu; letztes = neu; } } } Löschen eines inneren Knotens erfolgt durch Ersetzung zweier verschiedener Zeiger. Man betrachte z.B. die Liste erna <-> mary <-> hugo, löschen von mary erfolgt durch erna.rechts = erna.rechts.rechts; hugo.links = hugo.links.links; oder, alternativ durch erna.rechts = erna.rechts.rechts; erna.rechts.links = erna; Programmiersprachlich kann man das wie folgt realisieren: void removeNth(int n){ if (n==0) rest(); // erstes Element (n==0) ist zu löschen else { Item search = erstes; for (int i=1; i<n; i++) // gehe zu Element vor dem zu löschenden if (search == null || search.rechts == null){ System.out.println("Liste zu kurz"); return; } else search = search.rechts; if (search.rechts == null){//search = letztes System.out.println("Liste zu kurz"); return; } else search.rechts = search.rechts.rechts; if (search.rechts == null) //letztes Element gelöscht letztes = search; else search.rechts.links = search; } } 10-128 Bäume in Java Syntaktisch sehen binäre Bäume genauso wie doppelt verkettete Listen aus. class Bintree{ Bintree left; char node; Bintree right; ...} + * x * y x z Die Verallgemeinerung auf n-äre Bäume ist offensichtlich: class Ntree{ String name; int children; Ntree [] child; Ntree (String s, int n) { name=s; children=n; child=new Ntree [n];} ...} Anwendung von Binärbäumen: • geordnete Binärbäume sind definiert durch die folgende Eigenschft: alle Knoten des linken Unterbaums < Wurzel < alle Knoten des rechten Unterbaums • Das Einfügen geschieht daher je nach einzufügendem Inhalt • Ebenso passiert das Suchen im entsprechenden Unterbaum • Löschen ist etwas komplizierter • Mit sortierten Binärbäumen erreicht man logarithmische Durchschnittskomplexität • Problem: Bäume können entarten (d.h. nur wenige aber sehr lange Zweige haben) Ein Beispiel für einen geordneten Binärbaum ist nebenstehend angegeben. Einfügen in geordneten Binärbäumen macht man am besten rekursiv: void insert (int i){ einfügen(i, this); } private void einfügen (int i, Bintree b) { if (i<b.node) if (b.left==null) b.left=new Bintree(i); else einfügen(i, b.left); else if (i>b.node) if (b.right==null) b.right=new Bintree(i); else einfügen(i, b.right); // else i==b.node, d.h. schon enthalten } 55 11 null null 77 66 null null 99 null null Suchen in geordneten Binärbäumen ist sehr ähnlich zum Einfügen: boolean find(int i) { return finde(i, this); } private boolean finde(int i, Bintree b) { if (i<b.node) if (b.left==null) return false; else return finde(i, b.left); else if (i>b.node) if (b.right==null) return false; else return finde(i, b.right); else return true; //i==b.node } 10-129 Löschen ist dagegen etwas komplizierter.? • Beim Löschen eines Knotens muss ein Unterbaum angehoben werden Wahlfreiheit (linker oder rechter Unterbaum?) • Durch das Anheben entsteht eine Lücke, die wiederum durch Anheben eines Unterbaums gefüllt werden muss Problem mit der Balance (Ausgewogenheit) des Baumes Balancegrad beeinflusst die Komplexität des Suchens! • Lösungsmöglichkeiten: Abspeichern der Baumhöhe oder der Anzahl der Teilbäume für jeden Knoten endlich verzweigte Bäume Endlich verzweigte Bäume class Ntree{ String name; int children; Ntree [] child; Ntree (String s, int n) { name=s; children=n; child=new Ntree [n];} } public class BeispielFamilie { private Ntree t; BeispielFamilie() { Ntree n28=new Ntree("Johanna",3); Ntree n55=new Ntree("Renate",0); Ntree n60=new Ntree("Angelika",2); Ntree n62=new Ntree("Margit",1); Ntree n89=new Ntree("Laura",0); Ntree n93=new Ntree("Linda",0); Ntree n98=new Ntree("Viktoria",0); n28.child[0]=n55; n28.child[1]=n60; n28.child[2]=n62; n60.child[0]=n89; n60.child[1]=n93; n62.child[0]=n98; t=n28; } } Suche in endlich verzweigten Bäumen erfolgt durch Iteration innerhalb einer Rekursion (!): boolean search (String s) { return suche(s, this); } private boolean suche (String s, Ntree t) { if (t==null) return false; if (t.name==s) return true; for (int i=0; i<t.children; i++) if (suche(s, t.child[i])) return true; return false; } 10.2 Graphalgorithmen • Wdh.: Definition Graph Darstellung einer binären Relationen über einer endlichen Grundmenge Tupel (V,E), V endliche Menge von Knoten, E Menge von Kanten, zu jeder Kante genau ein Anfangs- und ein Endknoten • Repräsentationsmöglichkeiten als Relation (Menge von Paaren) 10-130 Knotendarstellung, Verweise als Kanten Adjazenzmatrix Knoten-Kanten-Darstellung Syntaktisch lassen sich Graphen auch genauso wie endlich verzweigte Bäume darstellen: class Graph{ char node; int numberOfEdges; Graph [] edge; Graph (char c, int n) { node=c; numberOfEdges=n; edge=new Graph [n]; } } A B Semantisch können Graphen Zyklen enthalten: C BeispielGraph() { D Graph nodeA=new Graph('A',1); Graph nodeB=new Graph('B',2); Graph nodeC=new Graph('C',3); Graph nodeD=new Graph('D',0); nodeA.edge[0]=nodeB; nodeB.edge[0]=nodeD; nodeB.edge[1]=nodeC; nodeC.edge[0]=nodeA; nodeC.edge[1]=nodeB; nodeC.edge[2]=nodeD; } Anwendung von Graphen • Beispiel: Verbindungsnetz der Bahn • Suche Verbindung zwischen zwei Knoten • Problem: Zyklen führen evtl. zu nicht terminierender Rekursion • Lösung: Markieren bereits untersuchter Knoten (z.B. Eintragen in einer Menge) Erreichbarkeit - fehlerhaft boolean search (char c) { return suche(c, this); } private boolean suche (char c, Graph g) { // Achtung fehlerhaft!! if (g==null) return false; if (g.node==c) return true; for (int i=0; i<g.edges; i++) if (suche(c, g.edge[i])) return true; return false; } // funktioniert nur falls Graph zyklenfrei // d.h. wenn Graph n-fach verzweigter Baum ist Erreichbarkeit – korrigiert import java.util.*; Set s; boolean search (char c) { s = new HashSet(); return suche(c, this); } private boolean suche (char c, Graph g) { if (g==null) return false; if (s.contains(g)) return false; if (g.node==c) return true; s.add(g); 10-131 for (int i=0; i<g.edges; i++) if (suche(c, g.edge[i])) return true; return false; } Darstellung von Graphen als Adjazenzmatrix • Angenommen, die Knoten seien k1…kn • boolesche Matrix m der Größe n×n m[i][j] gibt an, ob eine Verbindung von ki-1 zu kj-1 existiert oder nicht • Vorbesetzung z.B.: static void fillMatrixRandom (boolean matrix[][], float f) { for (int i = 0; i<n; i++) { for (int j = 0; j<n; j++) { matrix[i][j]=(Math.random()<f); } } } transitive Hülle Def. transitive Hülle: xR*y ↔ xRy ∨ ∃z (xR*z ∧ zR*y) • Algorithmus von Warshall: starte mit R*=R für jeden möglichen Zwischenknoten z: - Berechne für alle x und y, ob xR*y ∨ (xR*z ∧ zR*y) Reihenfolge der Schleifen ist wichtig! • Algorithmus von Floyd (kürzeste Wege) Entfernungsmatrix, min statt ∨, + statt ∧ static void warshall(boolean m[][], boolean t[][]) { for (int x = 0; x<n; x++) { for (int y = 0; y<n; y++) { t[x][y]=m[x][y]; } } for (int z=0; z<n; z++) { for (int x = 0; x<n; x++) { for (int y = 0; y<n; y++) { t[x][y]=t[x][y]||t[x][z]&&t[z][y]; } } } } 10.3 Suchen und Sortieren • Gegeben eine (irgendwie strukturierte) Sammlung von Daten Spezielles Suchproblem: entscheide ob ein gegebenes Element in der Sammlung enthalten ist Allgemeines Suchproblem: finde ein (oder alle) Elemente mit einer bestimmten Eigenschaft • Algorithmen hängen stark von der Struktur der Datensammlung ab! im Folgenden: als Reihung (Array) organisiert lineare Suche • Wenn über den Inhalt der Reihung nichts weiter bekannt ist, muss sie von vorne bis hinten durchsucht werden public class Suche { 10-132 public static final int n = 10; static void printReihung(int reihung[]) { for (int i = 0; i<n; i++) System.out.print(reihung[i] + " "); System.out.println(""); } static void fillReihungRandom(int reihung[]) { for (int i = 0; i<n; i++) reihung[i]=(int) (Math.random()*10); } static int sucheLinear(int suchreihung[], int suchinhalt) { for (int i=0;i<n;i++) { if (suchreihung[i]==suchinhalt) return(i); } return (-1);//oder exception } public static void main(String[] args) { int[]r=new int[n]; fillReihungRandom(r); printReihung(r); System.out.println(sucheLinear(r,3)); } } • Komplexität: mindestens 1, höchstens n Schleifendurchläufe O(n) binäre Suche • Wenn der Inhalt der Reihung aufsteigend sortiert ist, können wir es besser machen: public class Suche { public static final int n = 10; static void printReihung(int reihung[]) { for (int i = 0; i<n; i++) System.out.print(reihung[i] + " "); System.out.println(""); } static void fillReihungSorted(int reihung[]) { final int inc = 3; reihung[0]=(int) (Math.random()*inc); for (int i = 1; i<n; i++) reihung[i]= reihung[i-1] + (int) (Math.random()*inc); } static int sucheBZ(int sr[], int si, int lo, int hi) { if (lo > hi) return -1; int mitte = (hi + lo) / 2; if (si == sr[mitte]) return mitte; else if (si < sr[mitte]) return sucheBZ(sr, si, lo, mitte - 1); else /* (si > sr[mitte])*/ return sucheBZ(sr, si, mitte + 1, hi); } static int sucheBinaer(int suchreihung[], int suchinhalt) { return sucheBZ(suchreihung, suchinhalt, 0, n-1); } public static void main(String[] args) { int[]r=new int[n]; fillReihungSorted(r); printReihung(r); System.out.println(sucheBinaer(r,7)); } } 10-133 Hashtabellen • Wenn über den Inhalt der Reihung mehr bekannt ist, können wir es noch besser machen Annahme: 10*i ≤ r[i] ≤ 10*i + 9, z.B. r[7] liegt zwischen 70 und 79, d.h. jedes Datenelement hat höchstens einen möglichen Platz public class Suche { public static final int n = 10; static void printReihung(int reihung[]) { for (int i = 0; i<n; i++) System.out.print(reihung[i] + " "); System.out.println(""); } static void fillReihungHashable(int reihung[]) { for (int i = 0; i<n; i++) reihung[i]= 10*i + (int) (Math.random()*10); } static int sucheHash(int suchreihung[], int suchinhalt) { int idx = suchinhalt / 10; return (suchreihung[idx]==suchinhalt)?idx:-1; } public static void main(String[] args) { int[]r=new int[n]; fillReihungHashable(r); printReihung(r); System.out.println(sucheHash(r,77)); } } Sortieren • Oft lohnt es sich, Daten zu sortieren einmalige Aktion, Abfragen häufig oft gleich beim Eintrag möglich • Wie sortiert man eine unsortierte Reihung? selection sort: größtes Element an letzte Stelle, zweitgrößtes an zweitletzte Stelle, usw. - einfach aber ineffizient! insertion sort: In absteigender Reihenfolge: das i-te Element in den sortierten Bereich [i+1, …, n] einsortieren - noch ineffizienter! • Beispiele Bubblesort • Ineffizienz vorher: „weites“ Vertauschen mit Informationsverlust • Idee: Tauschen von Nachbarn Falls zwei Nachbarn in verkehrter Reihenfolge, vertausche sie nach dem i-ten Durchlauf sind die obersten i Elemente sortiert public class Sortieren { public static final int n = 5; static void printReihung(int reihung[]) { for (int i = 0; i<n; i++) System.out.print(reihung[i] + " "); System.out.println(""); } static void fillReihungRandom(int reihung[]) { final int range = 100; 10-134 for (int i = 0; i<n; i++) reihung[i]=(int) (Math.random()*range); } static void swap (int[] a, int i, int j) { int h = a[i]; a[i] = a[j]; a[j] = h; } static void bubbleSort (int[] a) { for (int k=n-1; k>0; k--) { for(int i=0; i<k; i++) { if (a[i]>a[i+1]) { swap(a,i,i+1); printReihung(a); } } } } public static void main(String[] args) { int[]r=new int[n]; fillReihungRandom(r); printReihung(r); bubbleSort(r); printReihung(r); } } Quicksort • berühmter, schneller Sortieralgorithmus • wähle ein „mittelgroßes“ Element w=a[k], alle kleineren nach links, alle größeren nach rechts • rekursiv linke und rechte Teile sortieren public class Sortieren { public static final int n = 5; static void printReihung(int reihung[]) { for (int i = 0; i<n; i++) System.out.print(reihung[i] + " "); System.out.println(""); } static void fillReihungRandom(int reihung[]) { final int range = 100; for (int i = 0; i<n; i++) reihung[i]=(int) (Math.random()*range); } static void swap (int[] a, int i, int j) { int h = a[i]; a[i] = a[j]; a[j] = h; } private static int partition(int[]a, int lo, int hi) { swap(a,(lo+hi)/2, hi); int w = a[hi], k=lo; for (int i=k; i<hi; i++) if (a[i]<w) {swap(a,i,k); k++;} swap(a,k,hi); return k; } public static void qSort(int[]a, int lo, int hi) { if (lo<hi) { int pivIndex = partition(a,lo,hi); qSort(a,lo,pivIndex-1); qSort(a, pivIndex+1, hi); } } public static void quickSort(int[] a) { 10-135 qSort(a,0,n-1); } public static void main(String[] args) { int[]r=new int[n]; fillReihungRandom(r); printReihung(r); quickSort(r); printReihung(r); } } Partitionierung (Methode partition(int[]a, int lo, int hi)) • Idee: Pivotelement w irgendwo aus der Mitte wählen (eigentlich egal), am rechten Rand (hi) ablegen • Dann 3 Bereiche bilden lo..k-1 : Elemente kleiner als w k..i-1: Elemente größer gleich w i..hi-1: unsortierte Elemente 10-136