Informatik für Nebenfächler Priv.-Doz. Dr. Frank Huch Institut für Informatik, Technische Fakultät, Christian-Albrechts-Universität zu Kiel. Skript zur Vorlesung im Wintersemester 2013/14. Version vom 2. Februar 2015 Inhaltsverzeichnis 1 Einleitung 1.1 Grundbegriffe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 4 2 Programmierung 2.1 Ausdrücke . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2 Einfachste Algorithmen und ihre Implementierung als Ausdrücke 2.2.1 Boolesche Werte . . . . . . . . . . . . . . . . . . . . . 2.2.2 Ganze Zahlen . . . . . . . . . . . . . . . . . . . . . . . 2.2.3 Gleitkommazahlen . . . . . . . . . . . . . . . . . . . . . 2.2.4 Auswertung von Ausdrücken . . . . . . . . . . . . . . . 2.3 Anweisungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3.1 while-Schleife: . . . . . . . . . . . . . . . . . . . . . . 2.3.2 Größter gemeinsamer Teiler . . . . . . . . . . . . . . . . 2.3.3 Aufzählen und Überprüfen . . . . . . . . . . . . . . . . 2.3.4 Euklidischer Algorithmus . . . . . . . . . . . . . . . . . 2.3.5 f or-Schleife . . . . . . . . . . . . . . . . . . . . . . . . 2.4 Syntaxbeschreibung . . . . . . . . . . . . . . . . . . . . . . . . 2.4.1 Präzedenzen in der BNF . . . . . . . . . . . . . . . . . 2.5 Zeichenketten . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.6 Objektorientierte Programmierung . . . . . . . . . . . . . . . . 2.7 Prozedurale Abstraktion . . . . . . . . . . . . . . . . . . . . . . 2.7.1 Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . 2.7.2 Prozeduren . . . . . . . . . . . . . . . . . . . . . . . . 2.7.3 Funktion mit Ausgabe . . . . . . . . . . . . . . . . . . . 2.8 Rekursion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.9 Objekte und ihre Identität . . . . . . . . . . . . . . . . . . . . . 2.10 Ausdrucksstärke unterschiedlicher Statements . . . . . . . . . . 2.11 Objektorientierte Datenmodellierung . . . . . . . . . . . . . . . 2.12 Mutierende und nicht mutierende Methoden . . . . . . . . . . . 2.13 Reguläre Ausdrücke . . . . . . . . . . . . . . . . . . . . . . . . 2.13.1 Backtracking . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 7 10 10 12 13 15 20 21 25 25 27 30 32 39 41 45 46 47 49 49 50 52 56 58 63 65 69 3 Datenstrukturen und Algorithmen 3.1 Arrays . . . . . . . . . . . . . . . 3.2 Datenbanken . . . . . . . . . . . . 3.3 Sortieren . . . . . . . . . . . . . . 3.3.1 Sortieren durch Auswählen 3.3.2 Sortieren durch Einfügen . 3.4 Die Fibonacci-Funktion . . . . . . 3.5 O-Notation . . . . . . . . . . . . 3.6 Suchen von Elementen . . . . . . 3.6.1 Binäre Suche . . . . . . . 3.7 Effizientes Sortieren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76 76 81 87 87 89 91 92 93 94 96 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Inhaltsverzeichnis 3.8 3.9 3.7.1 Andere effiziente Sortieralgorithmen . . . Schwere Probleme . . . . . . . . . . . . . . . . . 3.8.1 Halteproblem . . . . . . . . . . . . . . . Besonderheiten von Ruby . . . . . . . . . . . . . 3.9.1 Semikolon . . . . . . . . . . . . . . . . . 3.9.2 Anweisungen und Ausdrücke . . . . . . . 3.9.3 Zuweisung statt Gleichheit . . . . . . . . 3.9.4 Symbole, die beliebigen Werte . . . . . . 3.9.5 Hashes als Verallgemeinerung von Arrays 3.9.6 Blöcke . . . . . . . . . . . . . . . . . . . 3.9.7 Funktionen übergeben . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99 99 101 102 102 102 104 104 105 106 106 3 1 Einleitung Die Informatik ist die Wissenschaft von der systematischen Verarbeitung von Informationen, insbesondere die automatische Verarbeitung mit Rechenanlagen (Computer). Ursprünge Mathematik Berechnen von Folgen, Lösung von Gleichungssystemen Elektrotechnik Computer als Weiterentwicklung von Schaltkreisen Nachrichtentechnik Datenübertragung im Rechner oder im WWW Disziplinen der Informatik Theoretische Informatik Grundlagen, z.B. Komplexitätstheorie, Berechenbarkeit, Graphentheorie Technische Informatik Hardwarenahe Aspekte, z.B. Mikroprozessortechnik, Rechnerarchitekturen, Netzwerksysteme Praktische Informatik Lösen von konkreten Problemen durch Algorithmen/Programme hierbei wichtige Aspekte: Effizienz, Softwaretechnik, Programmiersprachen, Datenbanken 1.1 Grundbegriffe Algorithmus ist die Beschreibung einer Vorgehensweise zur Lösung von Problemen. Beispiele Kochrezept, Berechnung der Quadratwurzel, Dekodierung von DNA-Sequenzen, Berechnung von π, Verwaltungsvorschriften Beachte dabei: - in der Regel löst ein Algorithmus eine Klasse von Problemen (d.h. er ist parametrisiert), z.B. Quadratwurzel für beliebige Zahlen, Verwaltungsvorschrift für Hausbau. Parameter legt konkretes Problem der Klasse fest. 4 1.1 Grundbegriffe - die Vorgehensweise kann unterschiedlich detailliert formuliert werden, z.B. beim Kochrezept: Eiweiß steif schlagen“ oder Schüssel holen, Eiweiß und Eigelb trennen, Schnee” ” besen nehmen, . . .“. Es ist eine Kunst, aber auch Erfahrung, den richtigen bzw. geeigneten Grad an Genauigkeit zu finden. Wichtig hierbei: von Details abstrahieren. Abstraktion ist eines der wichtigsten Konzepte der Informatik. Beispiel Zählen von Weizenkörnern (alle gleich schwer) Hilfsmittel: Apothekerwaage (ohne Gewichte), einen Zettel, Bleistift Algorithmus Wiederhole folgende Schritte, bis du keine Körner mehr hast: - Teile Körner in zwei gleich schwere Haufen - Ist dies nicht möglich lege ein Korn zur Seite. Danach kannst Du sie aufteilen. ( 1, falls du ein Korn zur Seite gelegt hast - Beschrifte den Zettel mit 0, sonst - Wähle einen Haufen aus, mit dem du im nächsten Schritt weiter machst. Drehe die Zahl auf dem Zettel um und lies das Ergebnis als Binärzahl ab. Ggf. kannst du diese Zahl in eine Dezimalzahl wandeln. Beispiel 13 : 2 6:2 3:2 1:2 = = = = 6 3 1 0 R R R R 1 0 1 1 1101b = (8 + 4 + 1)d = 13d Algorithmen werden in der Informatik häufig mit Hilfe von Programmiersprachen programmiert, so dass sie auf Rechnern/Computern ausgeführt werden können. Hierbei ist zunächst keine Abstraktion mehr möglich. Ein Programm muss so konkret sein, dass ein Prozessor (winziger Befehlssatz) den Algorithmus ausführen kann. Programmierung war zunächst nur in Maschinensprache möglich, was aber wenig komfortabel war/ist. Feste Abstraktionen der höheren Programmiersprachen ermöglichen elegantere Programmierung auf abstrakterem Niveau. (In dieser Vorlesung werden wir die Sprache Ruby kennenlernen.) Darüber hinaus sind weitere Abstraktion durch Programmiertechniken (Softwaretechnik) möglich. Programmierung ist heute die Kunst/Technik ein Problem und seine Lösung in Teile zu zerlegen, so dass sich ein kompositionelles System ergibt, welches leicht zu verstehen, zu ändern und zu konfigurieren ist. Programmiersprachen ermöglichen die Kommunikation mit dem Rechner auf möglichst natürliche“ Weise. Programme werden entweder mit Hilfe eines Compilers in Maschinen” sprache übersetzt (z.B. C ) oder durch ein spezielles Programm (Interpreter ) interpretiert (z.B. Ruby ). Mischformen sind ebenfalls möglich, bei denen ein Compiler in eine dann interpretierte Zwischensprache übersetzt (z.B. Java). Bei der Programmierung unterscheidet man drei Bereiche: - Syntax beschreibt die zulässigen Zeichenfolgen der Programme - Semantik beschreibt, wie die Programme ausgeführt werden, also die Bedeutung der Sprachkonstrukte 5 1 Einleitung - Pragmatik beschreibt die Idee, wie die Programmiersprache verwendet werden soll (viele Wege führen nach Rom, aber welche sind gut?). Hier spielt auch das Programmieren als Kunst/Technik hinein. Beachte, dass bisher wenig über die Effizienz von Programmen gesagt wurde. Diese ist in der Regel unwichtig ! Wenige Ausnahmen: zeitkritische Algorithmen Wichtiger meist: Verständlichkeit, Wartbarkeit, Entwicklungszeit 6 2 Programmierung Algorithmus: abstrakte Beschreibung zur Lösung von Problemen Viele Algorithmen können prinzipiell automatisch durch Computer ausgeführt werden. Hierzu muss der Algorithmus in einer konkreten Programmiersprache implementiert werden. Nun wollen wir ein paar grundlegende Konzepte kennenlernen, die in vielen Programmiersprachen verwendet werden und mit deren Hilfe wir einfachste Algorithmen programmieren können. 2.1 Ausdrücke Aus der Mathematik kennt man Ausdrücke 3+4 x2 + 2x + 1 (x + 1)2 Woraus bestehen Ausdrücke? ◦ Basiselemente: – Werte (Konstanten) z.B. 3, 4 ∈ N oder π ∈ R – Variablen z.B. x, y repräsentieren beliebige Werte und können später mit konkreten Werten belegt werden. ◦ Zusammengesetzte Ausdrücke erhält man durch die Anwendung von Funktionen (genauer eigentlich Funktionssymbolen, später hierzu mehr), z.B. +, −, · (∗ in der Informatik) auf bereits gebildete Ausdrücke. Eigentlich müsste man für die Eindeutigkeit bei jeder Funktionsapplikation Klammern verwenden. Oft kann man hierauf aber verzichten, da Operator-Präzedenzen berücksichtigt werden. Auch zu Präzedenzen später noch mehr. Die meisten dieser Funktionen sind zweistellig und verknüpfen zwei Ausdrücke zu einem neuen Ausdruck: 3+4. Zweistellige Funktionen (und manchmal auch einstellige Funktionen), die (meist mit einem Sonderzeichen) zwischen zwei Ausdrücken notiert werden, nennt man auch Operatoren. Beispiele für Operatoren sind +, − und ·. √ Was ist aber mit x2 +1 ? x Auch hier finden wir mehrere Funktionsanwendungen, die aber ungewöhnlich notiert werden: ◦ Die zweistelligen Funktionen +,ˆ(Exponentiation) und / (als Bruchstrich geschrieben) q ◦ und die einstellige Funktionen (Wurzelfunktion). 7 2 Programmierung Der Computer (die Programmiersprache) erwartet eine genormte Darstellung solcher Ausdrücke: x ∗ ∗ 2 statt x2 √ sqrt(x) statt x a a/b statt b √ Somit können wir x2 +1 x schreiben als: sqrt(x ∗ ∗ 2 + 1)/x Entspricht dies tatsächlich dem mathematischen Term? Bei der Quadratfunktion ist es auf Grund der Notation klar, dass nur das x quadriert wird. Hier verwenden wir aber die Funktion ∗∗. Woher wissen wir, dass in diesem Ausdruck x quadriert und nicht hoch 2+1“gerechnet ” wird? Ähnlich wie in der Mathematik verwenden auch die meisten Implementierungen von Ausdrücken in Computern Präzedenzen, die festlegen, welche Operatoren stärker als andere binden. Ein Beispiel für solch eine Präzedenz ist die Punkt-vor-Strich-Rechnung: ∗ und / binden stärker als + und −. Sie ermöglicht es uns unter Umständen auf Klammern zu verzichten. Wie ist das aber mit den Operatoren ∗∗ und +? Hierzu wäre es nützlich, unseren Ausdruck mit diesem Ausdruck: sqrt(x ∗ ∗ (2 + 1))/x zu vergleichen. Wir untersuchen also, wie die Semantik (Bedeutung) dieser beiden Ausdrücke in der Programmiersprache Ruby ist. Zunächst beginnen wir mit einem einfacheren Beispiel: 3 + 4 Um den Wert dieses Ausdrucks zu berechnen, schreiben wir unser erstes Ruby-Programm. Wir editieren (mit Hilfe eines Editors, z.B. Notepad++) eine Ruby-Datei erstesProgramm.rb. Wichtig ist hierbei die Endung .rb für Ruby-Datei“. Der Inhalt unseres ersten Programms ” sieht wie folgt aus: puts (3+4); Haltepro Der Befehl puts dient zur Ausgabe von Werten. Der Ausdrucken, den wir auswerten wollen, schreiben wir in die Klammer hinter den puts-Befehl. Die Zeile beenden wir mit einem Semikolon. Nun können wir dieses Programm, wie folgt ausführen: ◦ in einer Shell tippen wir: ruby erstesProgramm.rb ◦ in Notepad++ drücken wir einfach F8. Das Programm wird ausgeführt, der Ausdruck wir ausgewertet und das Ergebnis 7 erscheint auf dem Bildschirm. Nun wollen wir die Ergebnisse der beiden Ausdrücke sqrt(x ∗ ∗ 2 + 1)/x und sqrt(x ∗ ∗ (2 + 1))/x vergleichen. Hierzu ändern wir den Ausdruck in unserem Programm: p u t s ( s q r t ( x ∗∗ 2 + 1 ) / x ) ; Führen wir dieses Programm aus, erhalten wir folgende Ruby-Fehlermeldung: 8 2.1 Ausdrücke erstesProgramm . rb : 1 : i n ’<main > ’: u n d e f i n e d l o c a l v a r i a b l e o r method ’ x ’ f o r main : Object ( NameError ) Der Grund ist, dass der Wert der Variable x unbekannt ist und Ruby den Wert des Ausdrucks nicht berechnen kann. Es gibt ja auch gar nicht einen Wert“ für diesen Ausdruck. Wir ” müssen also vorher festlegen, für welchen Wert der Variablen x, wir den Ausdruck auswerten wollen. Hierzu belegen wir vorher die Variable mit einem Wert: x = 42; # B e l e g e x mit dem Wert 42 p u t s ( s q r t ( x ∗∗ 2 + 1 ) / x ) ; Die Belegung einer Variablen bezeichnet man auch als Zuweisung. Hierauf werden wir später noch genauer eingehen. Führen wir nun dieses Programm aus, erhalten wir erneut eine Fehlermeldung: erstesProgramm . rb : 2 : i n ’<main > ’: u n d e f i n e d method ’ s q r t ’ f o r main : Object ( NoMethodError ) Das Ruby-System kennt die Funktion sqrt nicht. Diese Funktion ist in Ruby nicht direkt verwendbar. Sie befindet sich in einem speziellen Modul Math und kann mittels Math.sqrt verwendet werden: x = 42; # B e l e g e x mit dem Wert 42 p u t s ( Math . s q r t ( x ∗∗ 2 + 1 ) / x ) ; Nun können wir die unterschiedlich geklammerten Versionen unseres Ausdrucks vergleichen und sehen, dass der Operator ∗∗ stärker als + bindet und die Klammern um den Teilausdruck ∗ ∗ 2 tatsächlich nicht notwendig sind. Der Vorteil von Präzedenzen ist, dass viele Klammern weggelssaen werden können und Ausdrücke so besser lesbar werden. Der Computer, bzw. die Programmiersprache (oder auch andere Anwendungen, wie z.B. eine Tabellenkalkulation) fügt intern automatisch die fehlenden Klammern hinzu. Die vollständig geklammerte Schreibweise für unseren Ausdruck wäre: (sqrt(((x ∗∗ 2) + 1))/x) Jede Operatoranwendung wird geklammert. Neben der Präzedenz ist es auch noch wichtig, zu verstehen, wie Ausdrücke mit Operatoren mit gleicher Präzedenz geklammert werden. Als Beispiel betrachten wir den Ausdruck 5 − 3−2. Wieder gibt es zwei mögliche Interpretationen dieses Terms: 5−(3−2) und (5−3)−2. Vergleichen wir wieder die unterschiedlichen Ergebnis dieser Ausdrücke, sehen wir dass der Terme 5 − 3 − 2 dem Term (5 − 3) − 2 entspricht. Es wird also links geklammert. Man sagt auch der Operator − bindet links(-assoziativ). Andere Operatoren können auch rechtsassoziativ binden, worauf man beim Verständnis von Ausdrücken achten sollte. Generell gilt: wenn man sich nicht sicher ist, wie ein Operator bindet, sollten ruhig zusätzliche (überflüssige) Klammern verwendet werden. 9 2 Programmierung 2.2 Einfachste Algorithmen und ihre Implementierung als Ausdrücke Nachdem wir Ausdrücke und ihre Implementierung in Ruby kennengelernt haben, wollen wir Ausdrücke zur Programmierung einfacher Algorithmen verwenden. Wir betrachten folgendes Problem: Gegeben: Radius r Aufgabe: Bestimme die Fläche eines Kreises mit Radius r Ausdruck: π · r2 Entsprechend können wir diesen Algorithmus als Ruby-Ausdruck implementieren: r = 4; # H i e r l e g e n w i r den k o n k r e t e n Radius f e s t . p u t s ( 3 . 1 4 ∗ ( r ∗∗ 2 ) ) ; Ausdrücke spielen in Programmiersprachen eine wichtige Rolle. Sie kommen aber auch in vielen anderen Anwendungen vor. Als weiteres Beispiel betrachten wir Tabellenkalkulationen, wie z.B. Excel, Openoffice oder Libreoffice. Hier können Ausdrücke als Formeln verwendet werden. Anstelle von Variablen verwendet man die Zellen einer Tabelle. Adressiert werden diese durch einen Buchstaben für die Spalte, gefolgt von einer Zahl für die Zeile. Ist z.B. der Wert für den Radius in der Zelle B1 gespeichert, so können wir den Radius mit Hilfe der Formel (des Ausdrucks) 3.14∗B1∗B1 berechnen. Als nächstes betrachten wir ein etwas schwierigeres Problem: Gegeben: Zwei Zahlen n und m Aufgabe: Bestimme das Maximum von n und m Diese Aufgabe können wir mit den bisherigen Funktionen nicht lösen, es sei denn, wir gehen davon aus, dass wir eine entsprechende vordefinierte Funktion zur Verfügung haben. Ein Algorithmus zur Lösung dieses Problems sieht wie folgt aus: Vergleiche n mit m Falls n < m ist, dann ist m das Maximum, sonst ist n das Maximum. 2.2.1 Boolesche Werte Welche neuen Funktionen benötigen wir, um diesen Algorithmus zu implementieren? Zunächst den Vergleich <, aber was ist das Ergebnis von n < m? Eine Möglichkeit: 0 für n nicht kleiner als m 1 für n kleiner als m So ist dies z.B. in der Programmiersprache C realisiert. Eine bessere Lösung ist aber die Verwendung spezieller boolescher1 Werte: false und true. 1 Benannt nach dem englischen Mathematiker George Boole (1815-1864). 10 2.2 Einfachste Algorithmen und ihre Implementierung als Ausdrücke Für die Vergleichsfunktion < ergibt sich dann: false Ergebnis für nicht kleiner und true Ergebnis für kleiner Also: 3 < 4 ; true 4 < 4 ; f alse. Beachte, dass true und false zwar dem intuitiven wahr bzw. falsch entsprechen, aber dennoch Werte, wie 42 oder -15, sind. Genau wie 3 + 4 zu 7 reduziert wird, wird 3 < 4 zu true reduziert. Entsprechend stehen in Ruby auch Funktionen <= (für ≤), >, >= (für ≥) und ! = (für 6=) zur Verfügung. Nun müssen wir aber noch eine Möglichkeit finden, wie wir das Falls . . . dann . . . sonst ” . . .“ umsetzen. Hierzu könnten wir eine Funktion if then else, welche drei Argumente benötigt, verwenden. Ihre Semantik ist wie folgt definiert: Semantik von if then else if then else(true, e1 , e2 ) = e1 if then else(f alse, e1 , e2 ) = e2 Beachte, dass das erste Argument ein boolescher Wert sein muss, damit diese Funktion ausgewertet werden kann. D.h. es können hier z.B. Vergleiche verwendet werden. Damit können wir nun den Maximumsalgorithmus als Ausdruck implementieren: if then else(n > m, n, m) Werten wir diesen Ausdruck nun für unterschiedliche Variablenbelegungen aus, so ergibt sich: n = 7, m = 42 ⇒ if then else(7 > 42, 7, 42) = if then else(F alse, 7, 42) = 42 und n = 42, m = 8 ⇒ if then else(42 > 8, 42, 8) = if then else(T rue, 42, 8) = 42 In Ruby wird das if then else nicht als Applikation einer dreistelligen Funktion notiert, sondern in einer Mixfixnotation geschrieben: if e0 then e1 else e2 end anstelle von if then else(e0 , e1 , e2 ) In der Anwendung in unserem Ausdruck für die Maximumsberechnung also n = 42; m = 7; p u t s ( i f n > m then n e l s e m end ) ; Die Semantik entspricht aber genau der oben skizzierten, dreistelligen Funktion if then else. Auch in Tabellenkalkulationen können boolesche Ausdrücke zur Definition von Formeln verwendet werden. Hier wird das if-then-else auch tatsächlich als 3-stellige Funktion IF THEN ELSE 11 2 Programmierung oder WENN DANN SONST (in der deutschen Variante) notiert. In einigen Programmen wird allerdings ein Semikolon anstelle eines Kommas zur Trennung der Argumente verwendet. Als weiteres Beispiel betrachten wir die boolesche Funktion OR (Oder). Gegeben: zwei boolesche Werte in x und y Gesucht: OR(x, y) mit OR x = true x = f alse y = true true true y = f alse true f alse Mit der 3-stelligen if then else-Funktion können wir OR als den folgenden Ausdruck definieren: if then else(x,true, if then else(y, true, f alse)) Der entsprechende Ruby-Ausdruck zur Berechnung von OR sieht dann wie folgt aus: x = true ; y = false ; p u t s ( i f x then t r u e e l s e i f y then t r u e else false end end ) ; Es gibt aber auch eine noch kompaktere Definition: p u t s ( i f x then t r u e e l s e y end ) ; In Ruby ist die Funktion OR als Infixoperator || vordefiniert und kann in booleschen Ausdrücken verwendet werden. Entsprechend gibt es auch ein AND (Implementierung als Übung), welches als && zur Verfügung steht. Wir werden später noch genauer auf die korrekte Syntax und Semantik von Ausdrücken eingehen. Zunächst soll diese intuitive Idee ausreichen. Wichtig ist es insbsondere zu verstehen, dass ein Ausdruck nur unter Berücksichtigung einer konkreten Belegung aller vorkommenden Variablen ausgewertet werden kann, seine Semantik also von der aktuellen Variablenbelegung abhängt. ↓↓↓↓↓ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↓↓↓↓↓ 2.2.2 Ganze Zahlen Bisher habe wir Zahlen und boolesche Werte kennengelernt. Die Zahlen wollen wir noch etwas genauer untersuchen. In der Mathematik unterscheidet man unterschiedliche Zahlenmengen: natürliche Zahlen, ganze Zahlen, rationale Zahlen und reelle Zahlen. Keine dieser Zahlenmengen kann in einem Computer repräsentiert werden, da jede Menge unendlich viele 12 2.2 Einfachste Algorithmen und ihre Implementierung als Ausdrücke Zahlen enthält. Ein Computer besitzt aber nur einen endlichen Speicher, so dass auch nur endlich viele Zahlen dargestellt werden können. Welche genauen Zahlendarstellungen verwendet werden, hängt von der jeweiligen Anwendung (Programmiersprache oder Tabellenkalkulation) ab. Wir wollen hier aber die wichtigsten kennenlernen. Ganze Zahlen Unersuchen wir einmal folgende Ausdrücke in Ruby: puts ( 2 ∗ ∗ 0 ) ; puts ( 2 ∗ ∗ 1 ) ; puts ( 2 ∗ ∗ 1 0 ) ; puts ( 2 ∗ ∗ 1 0 0 ) ; puts (2 ∗∗ 10 00) ; p u t s (0 −2∗∗1000); # # # # # # −> −> −> −> −> −> 1 2 1024 1267650600228229401496703205376 10715086071862673209484250490600... −1071508607186267320948425049060... Alle Werte können exakt durch Ruby berechnet werden. Ruby stellt tatsächlich ganze Zahlen (fast) beliebiger Größe dar. Die Beschränkung ergibt sich nur aus dem maximal verfügbaren Speicherplatz, was aber in der Praxis so gut wie nie ein Problem darstellt. In manchen anderen Programmiersprachen und auch vielen Prozessoren wird nur ein endlicher Zahlenbereich zur Verfügung gestellt. Hierdurch ist gewährleistet, dass alle möglichen Zahlenwerte mit einer festen Anzahl von Bits dargestellt werden können. Je nach verwendeter Bit-Länge können die folgenden Werte dargestellt werden: Bit-Länge 8 16 32 64 kleinste Zahl -128 −32.768 −2.147.483.648 −9.223.372.036.854.775.808 größte Zahl 127 32.767 2.147.483.647 9.223.372.036.854.775.807 Falls eine Rechnung über die untere oder obere Grenze hinaus geht, gibt es in der Regel einen Überlauf und somit ein falsches Ergebnis. Z.B. würde bei 8 Bit bei der Berechnung 120+30 das Ergebnis -23 herauskommen (weitere Infos hierzu findet man unter dem Stichwort Zweierkomplement). In manchen Systemen wird dieser Überlauf aber auch abgefangen und man erhält eine Fehlermeldung. In Ruby tritt dieses Problem erst gar nicht auf. 2.2.3 Gleitkommazahlen Für manche praktischen Probleme ist es aber auch sinnvoll, rationale Zahlen bzw. reelle Zahlen zu betrachten. Taschenrechner und Computer bieten scheinbar auch solche Zahlen an. In Ruby heißen diese Zahlen Float und man erhält sie, in dem man mindestens eine Nachkommastelle verwendet (z.B. 3.0 oder 42.75). Alle arithmetischen Operationen liefern dann auch wieder Float-Zahlen, so dass wir gefühlt mit Dezimalbrüchen rechnen können. √ Aus der Mathematik ist klar, dass wir reelle Zahlen, wie z.B. 2, nicht exakt im Computer darstellen können. Diese Zahl weist keine Periode auf und ist bereits ein unendliches Objekt, 13 2 Programmierung welches √ nicht im endlichen Speicher des Computers dargestellt werden kann. Das Ergebnis von 2 kann also nur angenähert werden. Entspricht Float also der Menge der rationalen Zahlen? Dies hat sich in der Praxis ebenfalls nicht bewährt, da Brüche häufig nicht gekürzt werden können und somit nach wenigen Operationen bereits eine Darstellung mit sehr großen Zählern und Nennern erhalten. Welche Darstellung erscheint also geeignet? Eine fest begrenzte Anzahl von Vor- und/oder Nachkommastellen. Solch eine Darstellung nennt man Festkommazahl. Der Nachteil dieser Darstellung ist aber, dass bei sehr großen bzw. kleinen Werten ein recht großer Teil der Zahlendarstellung nicht für Genauigkeit genutzt werden. Als Lösung verwendet man deshalb in der Regel Gleitkommazahlen (floating point numbers, Float). Die Idee ist, dass man sich auf Zahlen einer bestimmten Genauigkeit beschränkt. Hierbei bedeutet Genauigkeit die Anzahl der unterscheidbaren Stellen. Für eine Genauigkeit mit vier Stellen könnne wir z.B folgende Beispielzahlen nennen: 1234, 23, 45, 34560000, −0, 0004567, 120000 Beachte, dass alle Zahlen auch Zahlen einer größeren Genauigkeit sein können, wie man insbesondere am Beispiel 120000 sieht, welche auch eine Zahl mit der Genauigkeit zwei Stellen ist. Für eine klarere Darstellung der Genauigkeit verwendet man besser eine genormte Darstellung: 1234 · 100 , 2345 · 10−2 , 3456 · 104 , −4567 · 10−7 , 1200 · 102 oder 0, 1234 · 104 , 0, 2345 · 102 , 0, 3456 · 108 , −0, 4567 · 10−3 , 0, 12 · 106 Eine Float-Zahl wird also durch zwei Zahlen repräsentiert: die Mantisse (Ziffern der entsprechenden Genauigkeit) und einen Exponenten. Im Rechner steht für beides eine feste Anzahl von Bits (Zahlen im Binärformat) zur Verfügung. Als Beispiel sähe dies für Float (in der Regel 4 Byte) wie folgt aus: | {z 3 Byte Mantisse }| {z } 1 Byte Exponent Hiermit ergibt sich die kleinste bzw. größte darstellbare Zahl als −223 ∗ 2127 ≈ −1.42724769270596 · 1045 (223 − 1) ∗ 2127 ≈ 1.4272475225647764 · 1045 Das Ruby-System versucht, Float-Werte möglichst gut für den Menschen lesbar auszugeben. Zum Beispiel beträgt die Lichtgeschwindigkeit e = 2, 99792458 · 108 m/s = 2, 99792458e8m/s 14 2.2 Einfachste Algorithmen und ihre Implementierung als Ausdrücke In Ruby wird dies als 299792458.0 ausgegeben. Die Gravitationskonstante G = 6, 67384 · m3 10−11 kg·s 2 wird aber genau in dieser Darstellung als 6.67384e−11 präsentiert. Auf Grund der Ungenauigkeiten von Float kann es zu Rundungsfehlern kommen, wie das folgende Beispiel zeigt: p u t s ( i f (1 −0.2 −0.2 −0.2 −0.2 −0.2)==0 then 42 e l s e −1 end ) ; # −> −1 p u t s (1 −0.2 −0.2 −0.2 −0.2 −0.2); # −> 5 . 5 5 1 1 1 5 1 2 3 1 2 5 7 8 3 e −17 Bei Abbruchbedingungen sollte man also immer prüfen, ob man bis auf eine bestimmte Nähe an den Zielwert herangekommen ist: x = 1 −0.2 −0.2 −0.2 −0.2 −0.2; p u t s ( i f x >=−0.0001 | | x<=0.0001 then 42 e l s e −1 end ) ; # −> 42 Bem.: || ist der Operator für das boolesche Oder. Der Ausdruck ist erfüllt, wenn mindestens eine der beiden Vergleiche erfüllt ist. 2.2.4 Auswertung von Ausdrücken Ausdrücke (Terme) haben wir bisher so verstanden, wie sie auch in der Schulmathematik eingeführt wurden. Neu waren dabei die booleschen Werte und die dreistellige if-then-elseFunktion. Um zu verstehen, wie ein Computer mit solchen Ausdrücken umgeht, beschäftigen wir uns nun intensiver mit der Darstellung von Ausdrücken, ihrer Repräsentation im Computer und einer Auswertungsmaschine. Ausdrücke werden (in der Mathematik) häufig auch als Terme bezeichnet. Durch die unterschiedliche Schachtelung der Operatoren und Funktionen bilden sie eine Baumstruktur, welche insbesondere in Form der Termbaum-Darstellung verdeutlicht wird. Hierbei steht die Funktion bzw. der Operator jeweils oberhalb seiner Argumente. Bsp.: 3 + sqrt(x ∗ ∗2 + 1) hat die folgende Termbaum-Repräsentation: + 3 sqrt + ∗∗ x 1 2 Die inneren Knoten des Termbaums sind mit Funktionen/Operatoren beschriftet, die Blätter mit Werten oder Variablen. Der Verzweigungsgrad der inneren Knoten ergibt sich genau aus der Stelligkeit der entsprechenden Funktion/Operation, d.h. die Kindknoten eines Funktionsknoten sind mit den Termbäumen der Argumente beschriftet. So ist die Wurzel des gesamten Termbaums mit dem Operator + beschriftet. Sein linkes Kind enthält die Termbaumdarstellung des Teilterms 3 und sein rechtes Kind die Termbaumdarstellung des Teilterms sqrt(x ∗ ∗2 + 1). 15 2 Programmierung Beachte: Es kommen keine Klammern mehr vor! Diese sind nur in der linearen Darstellung als Zeichenfolge notwendig. In der Baumstruktur werden innere/äußere Funktionsanwendung dadurch repräsentiert, dass sie weiter oben bzw. unten im Termbaum auftreten. Gibt es noch andere Darstellungen für Terme? Bisher: f (g(x, y), 3) und Infixoperatoren (3 + 4). Ist die Stelligkeit aller Funktionen bekannt, ist es auch möglich, die Klammern und Kommata ganz wegzulassen. Man erhält die klammerfreie Präfixnotation: f gxy3 mit f und g 2-stellig ∧ ∧ + 3 − 4 x = +(3, −(4, x)) = (3 + (4 − x)) ∧ ∗ ∗ +sqrt x 1 / 4 2 = ∗ ∗ (+(sqrt(x), 1), /(4, 2)) ∧ = (sqrt(x) + 1) ∗ ∗(4/2) Entsprechend gibt es auch die klammerfreie Postfixnotation, bei der alle Funktionsanwendungen nach den Argumenten folgen: xyg3f 34x − + x sqrt 1 + 4 2 / ∗ ∗ Wozu sind diese Termdarstellungen gut? Insbesondere die Postfixnotation kann gut zur automatischen Auswertung des Ausdrucks verwendet werden, wie sie im Prinzip auch in vielen Implementierungen von Ausdrücken in Computern implementiert wird. Man verwendet hierzu eine sogenannte Stackmaschine. Ein Stack (oder Keller, Stapelspeicher) ist eine Struktur, in der beliebig viele Werte abgelegt und wieder herausgenommen werden können. Die Werte liegen hierbei übereinander“, so ” dass immer nur oben“ auf den zuletzt hinein geschriebenen Werte zugegriffen werden kann. ” Es gibt nur zwei Operationen, mit denen ein Stack verändert werden kann: push v, schiebt v auf den Stack pop, holt oberstes Element vom Stack Bsp.: Beginnen wir mit dem leeren Stack erhalten wir nach der Ausführung von push 3: 3 Nach Ausführung von push 5 erhalten wir: 5 3 Nach Ausführung von push 4 erhalten wir: 16 2.2 Einfachste Algorithmen und ihre Implementierung als Ausdrücke 4 5 3 Nach Ausführung von pop erhalten wir die 4 als Ergebnis und wieder den Stack, von vorher: 5 3 führen wir push 7 aus: 7 5 3 Mit drei weiteren pop Operationen können wir noch die Werte 7, 5 und 3 nacheinander von Stack holen. Es werden also der Reihe nach die Werte 4, 7, 5 und 3 vom Stack gepopt. Im folgenden werden wir Stacks auch horizontal notieren. Wichtig ist aber die Beachtung des LIFO-Prinzips (last-in-first-out) Einschub - FIFO-Prinzip Es gibt auch das FIFO-Prinzip (first-in-first-out). Diese Struktur nennt man Queue (oder Schlange). Bei obigem Beispiel hätten die pop-Operationen die Werte in der folgenden Reihenfolge geliefert: 3, 5, 4, 7 Später werden wir uns noch ausführlicher mit Queues beschäftigen. Wie kann nun der Stack in der Stackmaschine verwendet werden, um den Wert eines Ausdrucks zu berechnen? Hierzu verwendet man am besten die Postfixnotation: Bsp.: (3 + 4) ∗ sqrt(2 ∗ ∗2) Postfix: 3 4 + 2 2 ∗ ∗sqrt∗ Eine Konfiguration der Stackmaschine besteht jeweils aus einem Keller, neben dem der (restliche) Postfixausdruck steht. Begonnen wird mit dem leeren Keller (notieren wir als ε) 17 2 Programmierung und der Postfixnotation des gesamten Ausdrucks: ε | 3 4 + 2 2 ∗ ∗ sqrt ∗ ⇒ 3 | 4 + 2 2 ∗ ∗ sqrt ∗ ⇒ 3 4 | + 2 2 ∗ ∗ sqrt ∗ ⇒ 7 | 2 2 ∗ ∗ sqrt ∗ ⇒ 7 2 | 2 ∗ ∗ sqrt ∗ ⇒ 7 2 2 | ∗ ∗ sqrt ∗ ⇒ 7 4 | sqrt ∗ ⇒ 72| ∗ ⇒ 14 | ↑ - Ergebnis leere Eingabe Allgemein lässt sich die Auswertung der Stackmaschine mit folgenden Regeln beschreiben: Wir notieren eine Konfiguration der Stackmaschine mit einem S für den Stack und einer Eingabe p (einem Teil eines Postfixausdrucks). Die Startkonfiguration der Stackmaschine besteht aus einem leeren Stack und dem auszuwertenden Term in Postfixnotation (p0 ): leerer Stack & initialer, zu berechnender Term in Postfixnotation . ε | p0 Die Konfigurationsübergänge der Stackmaschine sind wie folgt definiert: S | v p mit v ein Wert (z.B. Zahl, true, false ) ⇒S v |p S v1 . . . vn | f p mit f eine n-stelliges Funktionssymbol und f˜ die Semantik von f ⇒ S f˜(v1 . . . vn ) | p Die Endkonfiguration der Stackmaschine hat die Form: v | ↑ nur ein! Element auf Stack Das Ergebnis der Auswertung ist v ↑ leere Eingabe (Term komplett abgearbeitet) Beachte, dass die Argumente der Funktion eigentlich in der falschen Reihenfolge vom Stack gepopt werden. Deshalb wurde für die Termauswertung ursprünglich die Polnische Notation verwendet, in der die Argumente im Vergleich zur Postfixnotation in umgekehrter Reihenfolge angegeben werden. Wir definieren die Stackmaschine hier aber für die Postfixnotation (umgekehrte Polnische Notation) und müssen bei der Funktionsanwendung entsprechend korrekt applizieren. Weiteres Bsp.: 18 2.2 Einfachste Algorithmen und ihre Implementierung als Ausdrücke i f x>0 then x+1 e l s e x−1 end +1 ; Term-Baum: + 1 if then else > x ; Postfix-Notation: + 0 x 1 x 1 x 0 > x 1 + x 1 − if then else 1 + Wir betrachten die Belegung: x = 40 Auswertung mit Stack-Maschine: ⇒ 40 ⇒ 40 0 ⇒ true ⇒ true 40 ⇒ true 40 1 ⇒ true 41 2 ⇒ true 41 40 1 ⇒ true 41 39 ⇒ 41 ⇒ 41 1 ⇒ 42 | | | | | | | | | | | | 40 0 > 40 1 + 40 1 − if then else 1 + 0 > 40 1 + 40 1 − if then else1 + > 40 1 + 40 1 − if then else 1 + 40 1 + 40 1 − if then else 1 + 1 + 40 1 − if then else 1 + + 40 1 − if then else 1 + 40 1 − if then else 1 + − if then else 1 + if then else 1 + 1+ + Beachte im Vergleich die Auswertung als Term: if 40 > 0 then 40 + 1 else 40 − 1 end + 1 ⇒ if true then 40 + 1 else 40 − 1 end + 1 ⇒ (40 + 1) + 1 ⇒ 41 + 1 ⇒ 42 Die Berechnung von 40 − 1 wurde gespart. Das if then else kann auch schon ausgewertet werden, bevor das zweite und dritte Argument ausgewertet wurden, man sagt if then else ist nicht strikt im 2. und 3. Argument. if then else ist aber auch strikt im 1. Argument, genau wie +, welches im ersten und zweiten Argument strikt ist. Da if then else entweder das zweite oder das dritte Argument liefert, ist es sinnvoll, diese vor der if then else-Auswertung nicht zu berechnen. Hierzu sind kompliziertere StackMaschinen notwendig ; Informatik-Studium 19 2 Programmierung In der Praxis ist es aber auch sinnvoll, dass if then else sein zweites und drittes Argument nicht unbedingt auswertet. So können mit Hilfe von if then else auch Fehler verhindert werden: if b == 0 then 0 else a/b end verhindert Division durch Null und liefert im eigentlichen Fehlerfall das Ergebnis 0. ↑↑↑↑↑ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↑↑↑↑↑ 2.3 Anweisungen Ausdrücke können keine Wiederholungen von Vorgängen ausdrücken, welche aber notwendig sind, um viele Algorithmen zu implementieren. Beispiel: Fakultätsfunktion n! = 1 · 2 · 3 · . . . · n Bsp: 5! = 1 · 2 · 3 · 4 · 5 = 120 2! = 1 · 2 = 2 D.h. der Ausdruck zur Fakultätsberechnung ist unterschiedlich groß für unterschiedliche Argumente n (wächst mit wachsendem n). Wie kann das Problem aber algorithmisch gelöst werden? Eine Lösung ist die schrittweise Multiplikation der Faktoren. n 1 2 3 4 5 .. . n! 1 1·2=2 2·3=6 6 · 4 = 24 24 · 5 = 120 .. . Hierbei ist es nicht nötig sich alle vorherigen Ergebnisse zu merken. Das jeweils letzte Ergebnis reicht aus. Man kann von einem Ergebnis auf das nächste schließen: n! = (n − 1)! · n In Programmiersprachen können Werte in Variablen gespeichert werden, wodurch man sich in einem Programm Werte merken kann (Belegung von Variablen mit Werten). Solche Belegungen sind nun nicht mehr nur am Anfang des Programms erlaubt, sondern an jeder beliebigen Stelle. Anstelle von Variablenbelegung sagt man auch Zuweisung. In imperativen Programmiersprachen können Zuweisungen auch die existierenden Belegungen überschreiben. Beispiele für Zuweisungen: x z y y 20 = = = = 3; 4; i f x>z then x e l s e z end ; y ∗ 2; 2.3 Anweisungen Hierbei dürfen auf der rechten Seite der Zuweisung beliebige Ausdrücke stehen, auf der linken nur Variablen (Syntax). Die Semantik der Zuweisung ergibt sich wie folgt: Sind alle im Ausdruck verwendeten Variablen belegt, so kann der Ausdruck zu einem Wert ausgewertet werden und die Variable wird mit diesem Wert belegt. Hierbei wird der Ausdruck stets mit der vor der Zuweisung gültigen Variablenbelegung ausgewertet. Außerdem können Zuweisungen hintereinander geschrieben und damit dann nacheinander (sequentiell) ausgeführt werden. <− H i e r i s t d i e V a r i a b l e y noch n i c h t b e l e g t . y = 2; <− H i e r i s t y mit 2 b e l e g t . y = y ∗ 2; <− H i e r i s t y mit 4 b e l e g t . Die Zeilenumbrüche sind nicht notwendig. Da jede Zuweisung mit einem Semikolon abgeschlossen wird, ist klar, wo sie endet und wo die nächste Zuweisung beginnt. f a c = f a c ∗ n ; n = n+1; Um die Semantik eines solchen Programms nachzuvollziehen, können wir die Programmausführung mit Hilfe einer Programmpunkttabelle simulieren. Hierzu geben wir jedem relevanten Programmpunkt (was zunächst alle Punkte hinter den Semikolons sind) Nummern: fac n = fac n = = 2; 3; = fac ∗ n ; n+1; # # # # 1 2 3 4 Danach erstellen wir eine Tabelle, die in der ersten Spalte die Programmpunkte protokolliert und für jede vorkommende Variable eine zusätzliche Spalte enthält. Danach werden die Zuweisungen der Reihe nach durchgeführt und die Belegung der Variable in der entsprechenden Spalte hinter dem aktuellen Programmpunkt notiert. Im Beispiel: Programmpunkt (PP) 1 2 3 4 f ac 2 n 3 6 4 ← Anfangsbelegung der Variablen f ac ← Anfangsbelegung der Variablen n ← neue Belegungen . Um nun nach und nach die Fakultät zu berechnen benötigen wir noch eine Wiederholungsmöglichkeit, auch Schleife genannt. 2.3.1 while-Schleife: Bsp: n = 1; w h i l e n<4 do n = n+1; end ; #1 #2 #3 21 2 Programmierung Hierbei nennt man den Teil zwischen while und do die Bedingung und den Teil zwischen do und end den Rumpf der Schleife. Bedeutung: So lange die Bedingung gilt (also zu true ausgewertet wird), wird der Rumpf wiederholt. Die Bedingung wird jedesmal vor der Ausführung des Rumpfs überprüft. Programmausführung: PP 1 2 2 2 3 n 1 2 3 4 Am Ende des Programms hat die Variable n den Wert 4. Dies können wir dem Benutzer des Programms auch noch durch die Ausgabe von n am Programmende anzeigen: puts(n); n = 1; w h i l e n<4 do n = n+1; end ; puts (n ) ; #1 #2 #3 #4 Beachte hierbei, dass in den Argumentklammern von puts auch hier ein Ausdruck steht, nämlich der Ausdruck, welcher nur aus der Variablen n besteht. In der Programmtabelle können wir Ausgaben durch die Hinzunahme einer weiteren Spalte verdeutlichen. Das Ende der while-Schleife, sowie die puts-Anweisung vor dem 4. Programmpunkt verändern die verwendete Variable nicht. PP 1 2 2 2 3 4 n 1 2 3 4 Ausgabe 4 Nun können wir die Fakultät berechnen: n max = 4 ; #1 n = 0; #2 fac = 1; #3 w h i l e n < n max do n = n + 1; #4 f a c = f a c ∗ n ; #5 end ; #6 puts ( f a c ) ; #7 # zu b e r e c h n e n d e F a k u l t a e t # Zaehler # ( T e i l −) E r g e b n i s Wieder geben wir das Ergebnis der Berechnung mit Hilfe der puts-Anweisung am Ende des Programms aus. Programmsimulation: Hierzu haben wir in unserem Programm alle Semikolons durchnummeriert (im Kommentar): 22 2.3 Anweisungen PP 1 2 3 4 5 4 5 4 5 4 5 6 7 n max 5 n f ac Ausgabe 0 1 1 1 2 2 3 6 4 24 24 Das Ergebnis lautet also 24 und wird ausgegeben. Zuweisungen, Sequenzen und while-Schleifen nennt man auch Anweisungen (Statements) und sie sind neben Ausdrücken eine weitere wichtige Struktur in imperativen Sprachen. Zunächst betrachten wir ihre Syntax nur an Beispielen. Später werden wir diese auch noch formalisieren. Als nächstes wollen wir uns noch einmal mit dem Ausdruck zur Berechnung des Maximums beschäftigen. Zunächst hatten wir das Ergebnis dieses Ausdrucks nur ausgegeben. Nun können wir dieses Ergebnis auch in einer Variablen speichern und es so in der weiteren Berechnung verwenden: n = ...; m = ...; max = i f n>m then n e l s e m end ; Die Verzweigung mittels if then else ist nicht nur in Ausdrücken möglich. Wir können Sie auch auf Anweisungsebene verwenden und zwischen unterschiedlichen möglichen Anweisungen Verzweigen: n = ...; m = ...; i f n >= m then max = n ; e l s e max = m; end ; Die Bedingung ist auch hier weiterhin ein Ausdruck, der einen booleschen Wert als Ergebnis liefert. Der then- und der else - Teil sind nun aber beliebige Anweisungen, welche entsprechend ausgeführt werden, wenn die Bedingung zu true bzw. false ausgewertet wird. Bei Verzweigungen auf Anweisungsebene kann es manchmal auch sinnvoll sein, den else -Fall weg zu lassen. Dies entspricht einer Verzweigung, bei der der else -Fall leer wäre. Es handelt sich also nur noch um eine so genannte if − then-Anweisung oder bedingte Anweisung, wobei der Begriff bedingte Anweisung ausdrückt, das die Anweisung(ssequenz) im then-Fall nur unter einer gegeben Bedingung ausgeführt wird. Bei unserer Maximumsberechnung können wir die bedingte Anweisung wie folgt einsetzen: 23 2 Programmierung n = ...; m = ...; max = n ; i f m > max then max = m; end ; Will man das Maximum von drei Werten bestimmen, erkennt man den Vorteil der bedingten Anweisung noch deutlicher: n = ...; m = ...; o = ...; max = n ; i f m > max then max = m; end ; i f o > max then max = o ; end ; Durch das schrittweise Ändern der Variablen max benötigen wir nur zwei bedingte Anweisungen, während wir in dem if − then − else-Ausdruck für das Maximum von drei Werten insgesamt vier (verschachtelte) if − then − else Anwendungen benötigt haben. Zum Abschluss dieses Abschnitts wollen wir noch einmal die Fakultätsberechnung betrachten. Anstatt die Faktoren hochzuzählen können wir auch runterzählen (mit Kommutativität von ·): 1 · 2 · . . . · n = n · (n − 1) · . . . · 2 · 1 Im Programm können wir diese Idee verwenden, um eine Variable, hier konkret n max, zu sparen: n = 4 ; #1 f a c = 1 ; #2 w h i l e n>0 do f a c = f a c ∗ n ; #3 n=n−1; #4 end ; #5 p u t s ( f a c ) ; #6 PP 1 2 3 4 3 4 3 4 3 4 5 6 24 n 4 f ac #zu b e r e c h n e n d e F a k u l t a e t Ausgabe 1 4 3 Beachte: Zwischenergebnisse sind keine Fakultätsergebnisse mehr. Die Eingabevariable n wird verändert ⇒ Wert geht verloren. Ungünstig, falls er nochmals benötigt wird! 12 2 24 1 24 0 24 2.3 Anweisungen 2.3.2 Größter gemeinsamer Teiler Um das Programmieren mit Schleifen weiter zu üben, wollen wir uns die Bestimmung des größten gemeinsamen Teilers zweier gegebener Zahlen anschauen. Der größte gemeinsame Teiler zweier Zahlen wird z.B. beim Kürzen von Brüchen verwendet, wo man Nenner und Zähler durch ihren größten gemeinsamen Teiler dividiert. Def.: (ggT) Gegeben: a, b ∈ Z Gesucht: ggT (a, b) = c ∈ N, so dass c teilt a ohne Rest und c teilt b ohne Rest und für alle weiteren Teiler d von a und b gilt c > d. Als Beispiel betrachten wir folgende Zahlen: 21 hat die Teiler: 1, 3, 7, 21 18 hat die Teiler: 1, 2, 3, 6, 9, 18 Somit ist der größte gemeinsame Teiler: ggt(18, 21) = 3 0 hat die Teiler: 1, 2, 3, 4, 5, 6, 7, . . . Somit gilt für alle a 6= 0: ggt(a, 0) = ggt(0, a) = a. ggt(0, 0) ist nicht definiert, da alle Zahlen die 0 ohne Rest teilen und es somit keine größte Zahl gibt, die 0 teilt. Wie könnte nun eine mögliche Lösung dieses Problems aussehen? Bevor wir eine geschickte Lösung mit einem etwas geschickteren Algorithmus verwenden, lernen wir eine Methode kennen, die in vielen Fällen (allerdings oft nicht besonders geschickt) zum Ziel führt. 2.3.3 Aufzählen und Überprüfen Ein großer Vorteil eines Computers gegenüber einem Menschen ist die Fähigkeit, viele Werte sehr schnell aufzählen und gewisse Eigenschaften für diese Werte überprüfen zu können. Somit können viele Probleme, bei denen der Bereich der möglichen Lösungen endlich ist und aufgezählt werden kann, mit der Programmiertechnik Aufzählen und Überprüfen gelöst werden. Dies ist auch für den ggT der Fall. Der ggT von zwei Zahlen liegt sicherlich zwischen 1 und der kleineren der beiden Zahlen. Wir können also diese Werte der Reihe nach aufzählen und jeweils überprüfen, ob die entsprechende Zahl beide gegebenen Zahlen ohne Rest teilt. Für die Überprüfung, ob eine Zahl eine andere ohne Rest teilt, ist der Modulo-Operator sehr hilfreich, welcher den Rest einer ganzzahligen Division liefert. Falls a und b ganzzahlige Werte sind, so liefert / die ganzzahlige Division und % den Rest der ganzzahligen Division. Bsp.: 12/9 ; 1 12%9 ; 3 16/3 ; 5 16%3 ; 1 Der Algorithmus, welcher alle möglichen Teiler aufzählt und überprüft kann wie folgt in Ruby realisiert werden: 25 2 Programmierung a = 12; # n a t u e r l i c h e Zahlen , f u e r d i e d e r b = 9; # ggT bestimmt werden s o l l t e s t g r e n z e = i f a<b then a e l s e b end ; i = 1; ggt = 1 ; w h i l e i<=t e s t g r e n z e do i f a%i==0 && b%i==0 then g g t=i ; end ; i = i +1; end ; puts ( ggt ) ; Beachte, dass der Ausdruck a%i==0 && b%i==0 wegen der Präzedenzen der verwendeten Operatoren, so geklammert ist: ((a%i)==0) && ((b%i)==0), d.h. &&(logisches Und) bindet schwächer als == bindet schwächer als %. Da wir den größten gemeinsamen Teiler suchen ist es für diese Aufgabe aber sinnvoller die Zahlen von oben nach unten aufzuzählen, da man dann bei der ersten Zahl, die beide gegebenen Zahlen ohne Rest teilt aufhören kann und den ggT ausgeben kann. Das Speichern des letzten Teilers wird überflüssig. Es ergibt sich folgendes, einfacheres ggT-Programm: a = 12; # b = 9; # g g t = i f a<b then w h i l e a%g g t !=0 | | g g t = ggt −1; end ; puts ( ggt ) ; n a t u e r l i c h e Zahlen , f u e r d i e d e r ggT bestimmt werden s o l l a e l s e b end ; b%g g t !=0 do Beachte wieder, dass der Ausdruck a%ggt!=0 || b%ggt!=0 wegen der Präzedenzen der verwendeten Operatoren, so geklammert ist: ((a%ggt)!=0) || ((b%ggt)!=0), d.h. || (logisches Oder) bindet schwächer als != bindet schwächer als %. Problematisch sind nun noch die Randfälle a = 0 und/oder b = 0. Hier liefert das Programm einen Laufzeitfehler. Diese müssen nun noch explizit vor der Schleife abgefangen werden, was das Programm aber leider etwas aufbläht: a = 12; # n a t u e r l i c h e Zahlen , f u e r d i e d e r b = 9; # ggT bestimmt werden s o l l i f a==0 then i f b==0 then p u t s ( ”ggT n i c h t d e f i n i e r t ” ) ; e l s e puts (b ) ; end ; else i f b==0 then p u t s ( a ) ; e l s e g g t = i f a<b then a e l s e b end ; w h i l e a%g g t !=0 | | b%g g t !=0 do 26 2.3 Anweisungen g g t = ggt −1; end ; puts ( ggt ) ; end ; end ; Hier zeigt sich, dass es beim Testen der Programme auch wichtig ist, alle Randfälle systematisch zu überprüfen. 2.3.4 Euklidischer Algorithmus Bei großen Zahlen liefert die Aufzählen-und-Testen-Methode die Lösung leider nicht mehr in akzeptabler Zeit. Wir betrachten deshalb eine effizientere Lösung, wie sie bereits ca. 300 v. Chr. von dem griechischen Mathematiker Euklid gefunden wurde. Gegeben zwei Strecken I I I I Bestimme eine Strecke, mit der man beide Strecken messen“ kann, d.h. die in beide Stre” cken ganz hineinpasst“. ” Beide gegebenen Strecken sollen also Vielfache der gesuchten Strecke sein. Hier: I I I I I I Wie findet man so eine Strecke? Wenn beide Strecken gleich lang sind, passen sie natürlich in die jeweils andere hinein und es ist die gesuchte Strecke. Wenn nicht: ziehe die kürzere Strecke von der Längeren ab. Ergibt im Beispiel die Strecke: I I und suche nach einer Strecke, die in die kürzere Strecke und in die Strecke, die durch Abziehen der kürzeren von der längeren Strecke entsteht, hineinpasst. Da die gesuchte Strecke sowohl in die kürzere als auch in die längere Strecke hineinpassen soll, muss sie auch in die Differenz der beiden Strecken hineinpassen. Finde also Strecke, die in diese beiden Strecken passt: I I I I Nach Abziehen erhalten wir die Strecke: I I Finde also eine Strecke, die in diese beiden Strecken passt: I I I I Die Differenz ergibt: 27 2 Programmierung I I Als nächstes suchen wir also die Strecke, die in die verbleibenden beiden Strecken passen: I I I I Da beide Strecken gleich lang sind, ist dies die gesuchte Strecke. Wir setzen das Verfahren also so lange fort, bis beide Strecken gleich lang sind. Diese Strecke ist dann die größte mögliche Strecke, die beide Strecken teilt. Der Euklidische Algorithmus eignet sich also insbesondere auch zur Bestimmung des ggTs. Wir arbeiten ähnlich wie bei der Idee mit den Strecken, allerdings enden wir nicht wenn beide Zahlen gleich sind, sondern erst, wenn eine der beiden 0 ist. Hierdurch bestimmt unser Algorithmus auch den ggt korrekt, falls eine der beiden Zahlen 0 ist. Den Fall, dass beide Zahlen 0 sind, müssen wir dann noch separat überprüfen. Bsp.: ggT (15, 10) 15 ≥ 10 ⇒ 15 − 10 = 5 10 ≥ 5 ⇒ 10 − 5 = 5 5≥5 ⇒ 5−5=0 Also ggT (15, 10) = 5. Bsp.: ggT (12, 9) 12 ≥ 9 9≥3 6≥3 3≥3 ⇒ 12 − 9 = 3 ⇒ 9−3=6 ⇒ 6−3=3 ⇒ 3−3=0 Also ggT (12, 9) = 3. ggT (12, 9) = ggT (3, 9) = ggT (3, 6) = ggT (3, 3) = ggT (0, 3) = 3 Wie können wir diese mathematische Definition nun in Ruby realisieren? Wir wiederholen immer wieder die gleichen Schritte, so dass wir für die Programmierung eine while-Schleife verwenden sollten. Im Rumpf der Schleife muss entweder a=a−b; oder b=b−a; gerechnet werden, je nachdem, welcher Wert größer ist. Der Schleifenrumpf sollte dann so lange wiederholt werden, solange weder a noch b Null sind: a = 1 2 ; #1 b = 9 ; #2 w h i l e a !=0 && b!=0 do i f a<b then b=b−a ; #3 28 #i n i t i a l e Werte 2.3 Anweisungen e l s e a=a−b ; #4 end ; #5 end ; #6 Wenn wir also den Programmpunkt 6 erreichen, wissen wir, dass mindestens eine der Variablen den Wert Null hat. Die andere Variable enthält dann den ggT. Nun müssen wir nur noch herausfinden, welche Null ist. Hierbei identifizieren wir insbesondere noch den Fall, dass beide Null sind und der ggT nicht definiert ist. a = 1 2 ; #1 b = 9 ; #2 w h i l e a !=0 && b!=0 do i f a<b then b=b−a ; #3 e l s e a=a−b ; #4 end ; #5 end ; #6 #i n i t i a l e Werte i f a==0 then i f b==0 then p u t s ( ”ggT n i c h t d e f i n i e r t ” ) ; #7 e l s e p u t s ( b ) ; #8 end ; #9 e l s e p u t s ( a ) ; #10 end ; #11 Die Programmausführung sieht dann wie folgt aus: PP 1 2 4 5 3 5 3 5 4 5 6 8 9 11 a 12 b Ausgabe 9 3 6 3 0 3 Betrachte initiale Belegung: a = 3; b = 0; PP 1 2 6 10 11 a 3 b Ausgabe 0 3 Weitere sinnvolle Testfälle wären: a = 0, b = 3 und a = 0, b = 0. Außerdem können wir 29 2 Programmierung auch für größere Werte die Ausgaben unserer unterschiedlichen ggT-Implementierungen vergleichen und uns so von der Korrektheit unser Programme überzeugen. Der Algorithmus kann auch noch weiter optimiert werden, wenn man die Subtraktion durch eine Division mit Restbildung ersetzt. Näheres hierzu in der Übung. 2.3.5 f or-Schleife Bei vielen Iterationen weiß man genau, wie oft der Schleifen-Rumpf ausgeführt werden soll. Außerdem benötigt man oft eine Zählvariable, welche die Iterationen zählt und automatisch inkrementiert wird (n bei der ersten Version der Fakultät). Zu diesem Zweck kann man f orSchleifen verwenden. Bsp.: n = 4; fac = 1; f o r i i n 1 . . n do fac = i ∗ fac ; end ; puts ( f a c ) ; #1 #2 #3 #4 #5 #6 Das Schlüsselwort for leitet die for-Schleife ein. Als nächstes wird die Zählvariable (hier i) festgelegt. Hinter dem Schlüsselwort for gibt man dann die Schleifengrenzen (Startwert und Endwert) getrennt durch zwei Punkte an. Danach folgt der Rumpf, welcher ggf. wiederholt wird. Hierbei nimmt die Zählvariable der Reihe nach die Werte vom Start- bis zum Endwert an. Mit Hilfe der Programmpunkttabelle (da die for-Schleife die Zählvariable verändert, erhält sie auch einen Programmpunkt) können wir die Ausführung des Programms simulieren: PP 1 2 3 4 3 4 3 4 3 4 5 6 n 4 f ac i Ausgabe 1 1 1 Die Zählvariable wird vor jeder nächsten Iteration hochgezählt. Der letzte Durchlauf wird mit dem Endwert als Belegung für die Zählvariable durchgeführt. 2 2 3 6 4 24 24 Beachte: Eine f or-Schleife wird immer beendet (terminiert immer), im Gegensatz zur while-Schleife: Bsp.: w h i l e t r u e do ... end ; 30 2.3 Anweisungen terminiert nicht. Eine Endlosschleife als Fehler ist z.B. möglich, wenn vergessen wird, die Zählvariable zu verändern: w h i l e n>0 do fac = fac ∗ n ; end ; #n=n−1; v e r g e s s e n kann die while-Schleife ebenfalls nicht terminieren. Dies ist bei der f or-Schleife nicht möglich. In Ruby terminiert die for-Schleife tatsächlich immer (außer der Rumpf terminiert nicht, z.B. weil er eine nicht-terminierende while-Schliefe enthält). Selbst wenn wir im Rumpf die Zählvariable verändern (was man aber auf keinen Fall machen sollte, da es zu unverständlichen Programmen führt!), wird der Wert der Zählvariable, vor dem nächsten Schleifendurchlauf auf den nächsten Zählwert gesetzt. Dies gilt nicht für alle imperativen Programmiersprachen. Dennoch sollte man auch in anderen Sprachen die Zählvariable nicht verändern und for-Schleifen so verwenden, wie hier vorgestellt. Genau wie bei der while-Schleife kann es aber auch bei der for-Schleife sein, dass der Rumpf gar nicht durchlaufen wird. Bei der while-Schleife wird die Bedingung überprüft, bevor der Rumpf ausgeführt wird. Bei der f or-Schleife wird ebenfalls vorher überprüft, ob der Endwert bereits überschritten wurde: x = 0; f o r i i n 5 . . 3 do x = x+1; end ; puts ( x ) ; gibt 0 aus. Bei f o r i i n 5 . . 5 do ... end wird der Rumpf genau einmal mit i = 5 ausgeführt. Nun kennen wir die beiden wichtigsten Schleifenarten: while- und for-Schleife. Beide finden sich so ähnlich in fast allen imperativen Programmiersprachen. Als Pragmatik der imperativen Programmierung gilt, dass eine for-Schleife verwendet werden sollte, wenn bei Schleifenbeginn bekannt ist, wie oft eine Wiederholung statt finden soll. Dies bedeutet nicht zwangsläufig, dass bereits zur Zeit der Programmierung die genaue Anzahl bekannt sein muss und die Schleifengrenzen immer feste Zahlen sein müssen. Es kann auch sein, dass die Grenze für die Wiederholung nur als Wert in einer Variablen bekannt ist und z.B. das Ergebnis einer anderen Berechnung oder auch einer Benutzereingabe (bekommen wir später) sein kann. 31 2 Programmierung 2.4 Syntaxbeschreibung Bevor wir weiter in die Feinheiten der Programmierung einsteigen, wollen wir uns noch mit der Frage beschäftigen, was eigentlich genau gültige Ruby-Programme sind. Bisher haben wir die Syntax an Beispielen kennen gelernt und dann entsprechend auf andere Programme übertragen. Es stellt sich aber die Frage, ob man auch genau festlegen kann, welches die gültigen Rubyprogramme sind. Hierzu bietet die Informatik unterschiedliche Formalismen, welche es ermöglichen Sprachen formal zu definieren. Die Ansätze gehen in der Regel auf den amerikanischen Linguisten Noam Chomsky zurück. Formal gesehen ist eine Sprache eine (möglicherweise unendliche) Menge von Wörtern.2 Als Beispiel betrachten wir die folgenden formalen Sprachen: ◦ Die leere Sprache: ∅ enthält gar kein Wort. ◦ Eine endliche Sprachen mit genau drei Wörtern: {Studierende, hallo, liebe} ◦ Die Sprache, aller Wörter, die aus den Buchstaben G, C, T , A bestehen: {ε3 , G, C, T, A, GG, GC, GT, GA, CG, . . .}. Diese Sprache ist die Sprache unseres Erbguts (der DNA). Jeder Buchstabe repräsentiert hierbei eine Nukleinbase. ◦ Die Sprache aller Palindrome, also der Wörter, die von vorne und hinten gelesen gleich sind: {ε, a, b, c, . . . , aa, bb, cc, . . . , aaa, aba, aca, . . . , aaaa, abba, . . . , aaaaa, aabaa, abcba, . . . , otto, . . .} Zur formalen Beschreibung solcher Sprachen verwendet man in der Informatik (und auch Linguistik) unterschiedliche Formalismen: Syntaxdiagramme, Grammatiken und die BackusNaur-Form (BNF), welche wir hier näher betrachten werden. Die Backus-Naur-Form (BNF) stellt einen Formalismus zur Beschreibung von Sprachen (insbesondere Programmiersprachen) dar. Es werden Nichtterminalsymbole (beginnen mit Großbuchstaben) und Terminalsymbole (Zeichen in ’ ’) unterschieden. Nichtterminalsymbole stellen keine Elemente der zu definierenden Sprache dar. Vielmehr sind sie Strukturelemente, welche weiter verfeinert und letzendlich zu einer Folge von Terminalsymbolen abgeleitet werden. Als erstes Beispiel betrachten wir eine BNF, die die Sprache des Erbguts beschreibt. Wir verwenden nur ein Nichtterminalsymbol DNA. DNA ::= 0 G0 DNA | 0 C 0 DNA | 0 T 0 DNA | 0 A0 DNA | Die möglichen Ableitungen für das Nichterminalsymbol DNA werden hinter dem speziellen Symbol ::= notiert. Hierbei gibt es fünf unterschiedliche Möglichkeiten, wie wir das Nichtterminalsymbol DNA ableiten können. Wir trennen die unterschiedlichen Möglichkeiten durch |. Jede einzelne Möglichkeit bezeichnen wir als Regel und sprechen von der Regel DNA ::= 0 C 0 DNA, wenn wir eine einzelne Regel benennen. Die letzte Regel lautet DNA ::=. 2 Man kann auch natürliche Sprachen, z.B. die deutsche Sprache, als formale Sprache sehen. Die Sätze würde man dann als Wörter der Sprache bezeichnen. Die einzelnen Wörter des Deutschen wären dann die Buchstaben der formalen Sprache. 3 Die Sprache enthält insbesondere auch das leere Wort, also ein Wort, welches aus keinem Zeichen besteht. Dieses notieren wir hier als ε. 32 2.4 Syntaxbeschreibung Nun wollen wir die spezielle Nukleinbasensequenz CAG mit Hilfe dieser BNF herleiten. Dazu beginnen wir mit dem Nichtterminalsymbol, aus dem wir die Sequenz ableiten wollen. In jedem Ableitungsschritt darf jeweils nur ein vorkommendes Nichtterminalsymbol durch eine seiner rechten Seiten ersetzt werden. Kommen keine Nichtterminalsymbole mehr vor, hat man ein gültiges Wort der beschriebenen Sprache abgeleitet: ⇒ ⇒ ⇒ ⇒ DNA C DNA C A DNA C A G DNA C AG Im letzten Schritt verwenden wir die fünfte Regel DNA ::= und ersetzen das Nichtterminalsymbol DNA durch die leere Zeichenfolge, wodurch das Nichtterminalsymbol DNA verschwindet und die gewünschte Zeichenfolge erzeugt wurde. Als nächstes Beispiel betrachten wir die folgende BNF: DNA ::= N B DNA | N B ::= 0 G 0 | 0 C 0 | 0 T 0 | 0 A0 Auch diese Grammatik ermöglicht es uns, alle möglichen DNA-Sequenzen herzuleiten. Als Beispiel betrachten wir noch einmal eine Ableitung der Nukleinbasensequenz CAG: ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ DNA NB DNA NB NB DNA NB NB NB DNA NB NB NB C NB NB C A NB C AG Auf Grund des zusätzlich verwendeten Nichtterminalsymbols N B ist die Ableitung länger geworden. Dennoch beschreibt diese BNF die gleiche Sprache, wie die erste BNF. Wir haben über das Nichtterminalsymbol NB lediglich eine weitere Struktur eingeführt, die besser verdeutlicht, dass alle vier Buchstaben Nukleinbasen repräsentieren. Vergleichen wir die beiden Ableitungen des Wortes CAG noch einmal, stellen wir fest, dass die Stelle, an der wir weiter ersetzen können, nicht mehr eindeutig definiert ist. Vielmehr gibt es mehrere mögliche Ableitungen, welche das Wort CAG herleiten und auch der Schreibaufwand wächst, da immer nur ein Nichtterminalsymbol pro Schritt ersetzt werden darf. Einfacher ist es deshalb anstelle der Ableitung den Ableitungsbaum für ein gegebenes Wort hinzu schreiben: DNA NB C DNA NB A DNA NB DNA G 33 2 Programmierung Die inneren Knoten sind mit Nichtterminalsymbolen beschriftet. Die Blätter mit Terminalsymbolen (bzw. dem leeren Wort). Die Kinder eines Nichtterminalsymbolknotens, sind jeweils durch alle Symbole der rechten Seite der Regel definiert. Hierbei müssen Anzahl der entsprechenden Symbole und deren Reihenfolge beachtet werden. Das abgeleitete Wort erhält man, wenn man die Blätter des Ableitungsbaums von links nach rechts liest. Die Reihenfolge, in der die Nichtterminalsymbole durch ihre rechten Seiten ersetzt werden, ist im Ableitungsbaum egal, so dass er in der Regel kompakter ist, als eine schrittweise Ableitung. BNF für Ruby-Werte Als nächstes Beispiel betrachten wir eine BNF, die die Sprache aller möglichen Ruby-Werte definiert. Zunächst beschränken wir uns auf (ganze) Zahlen und die booleschen Werte als Nichtterminalsymbol Val: Val ::= Num | 0−0 Num | true | f alse Num ::= Dig | Dig Num 0 0 0 | . . . | 0 90 Dig ::= Beachte, dass wir für ganze Zahlen keine führenden Nullen verbieten. Dies wäre auch möglich, soll aber, genau wie Gleitkommazahlen, in der Übung verfeinert bzw. hinzu genommen werden. BNF für Ruby Nachdem wir nun Werte, wie sie in Ruby verwendet werden, definiert haben, können wir dieses Beispiel erweitern und die Sprache aller gültigen (Ruby-)Ausdrücke definieren. Hierbei beschreiben wir der Einfachheit halber nur vollständig geklammerten Ausdrücke Exp4 : Exp ::= Var | Val | 0 0 | Fun 0 (0 Exps 0 )0 | 0 ( Exp Op Exp 0 )0 if 0 Exp 0 then0 Exp 0 else0 Exp 0 end0 Exps ::= Exp | 4 Exp 0 ,0 Exps Op ::= 0 +0 | 0 −0 | 0 ∗0 | 0 /0 | 0 ∗ ∗0 Fun ::= 0 M ath.sqrt0 | 0 Math.sin0 | . . . Var ::= 0 0 x | 0y0 | 0z0 | . . . Wir gehen hier zunächst davon aus, dass es keine Präzedenzen gibt und die Ausdrücke vollständig geklammert werden müssen. In der Vertiefung werden wir später noch sehen, wie Präzedenzen in der BNF ausgedrückt und Klammern vermieden werden können. 34 2.4 Syntaxbeschreibung Die Variablen definieren wir hier nur beispielhaft. Eine genauere Definition wird in den Übungen erfolgen. Als Beispiel für eine Ableitung in dieser BNF zeigen wir, dass das Wort (M ath.sqrt((x ∗ ∗ 2))/x) ein vollständig geklammerter Ausdruck ist, d.h. dieses Wort aus dem Nichtterminal Exp abgeleitet werden kann. Wieder darf in jedem Ableitungsschritt nur ein vorkommendes Nichtterminalsymbol durch eine seiner rechten Seiten ersetzt werden. Kommen keine Nichtterminalsymbole mehr vor, hat man ein gültiges Wort der beschriebenen Sprache abgeleitet: ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ ⇒ Exp (Exp Op Exp) (Exp / Exp) (Exp / V ar) (Exp / x) (Fun(Exps) / x) (Fun(Exp) / x) (M ath.sqrt(Exp) / x) (M ath.sqrt((Exp Op Exp)) / x) (M ath.sqrt((Exp ∗ ∗Exp)) / x) (M ath.sqrt((Exp ∗ ∗Val)) / x) (M ath.sqrt((Exp ∗ ∗Num)) / x) (M ath.sqrt((Exp ∗ ∗Dig)) / x) (M ath.sqrt((Exp ∗ ∗2)) / x) (M ath.sqrt(((Var ∗ ∗2)) / x) (M ath.sqrt((x ∗ ∗2)) / x) Beachte, dass die Zeichenforlge x ∗ ∗2 in diesem vollständig geklammerten Ausdruck zwei mal geklammert werden muss. Eine Klammer für die Funktionsanwendung (M ath.sqrt) und eine Klammer für die Operatoranwendung (∗∗). Bevor wir dieses Beispiel als Ableitungsbaum darstellen, betrachten wir den Ableitungsbaum für den Ausdruck (3 + x): Exp ( Exp Op Exp Val + Var Num ) x Digit 3 Wieder sind die Nichtterminalsymbole die inneren Knoten des Ableitungsbaums. Die Wurzel ist mit dem Nichtterminal beschriftet, aus welchem man das Wort ableiten möchte (hier z.B. Exp). Die Kinder eines Nichtterminalknotens, entsprechen jeweils den Symbolen der rechten Seite der verwendeten Regel. So wurde bei der Wurzel zunächst die Regel Exp ::= 0 (0 Exp Op Exp 0 )0 angewendet, weshalb die Wurzel fünf Kindknoten hat, die von links nach rechts mit den entsprechenden Symbolen beschriftet sind. 35 2 Programmierung Im fertigen Ableitungsbaum sind alle Blätter mit Terminalsymbolen beschriftet. Das abgeleitete Wort ergibt sich, indem man die Blätter des Baumes von links nach rechts abliest, hier also das Wort (3 + x). Der Ableitungsbaum für den Ausdruck (M ath.sqrt((x ∗∗ 2))/x) sieht wie folgt aus: Exp ( Exp Fun ( Exps Math.sqrt Op Exp / Var ) ) x Exp ( Exp Op Exp ) Var ** Val x Num Digit 2 Mit der hier vorgestellten BNF haben wir nun schon als wichtigen Teil von Rubys Syntax Ausdrücke definiert. Im folgenden werden wir diese schrittweise erweitern und auch Anweisungen in Ruby beschreiben. BNF für Palindrome Um vorher aber noch einmal klar zu machen, dass die BNF ein universeller Formalismus zur Beschreibung von Sprachen ist, wollen wir ihn vorher noch verwenden um eine Sprache zu beschreiben, die gar nichts mit Ruby zu tun hat, die Sprache der Palindrome. Ein Palindrom ist ein Wort, welches von vorne und von hinten gelesen gleich ist. Beispiele sind otto, rentner oder (wenn man die Leer-/Satzzeichen ignoriert) o genie, der herr ehre dein ego. Wenn man Palindrome formal spezifizieren will, so kann man dies mit Hilfe folgender BNF machen: P al ::= | 0 0 a P al 0 a0 | . . . | 0 z 0 P al 0 z 0 0 0 a | . . . | 0z0 | Hierbei werden natürlich nicht nur gültige Palindrome der deutschen Sprache beschrieben, sondern vielmehr alle Wörter (über dem Alphabet 0 a0 bis 0 z 0 ), die von vorne und hinten gleich aussehen. Die letzte Regel P al ::= wird verwendet, da auch das leere Wort eine Palindrom ist. Außerdem findet sie Anwendung, falls ein Palindrom hergeleitet wird, welches keinen einzelnen Buchstaben in der Mitte hatte, wie z.B. otto. Untersucht man alle BNF die wir nun programmiert haben genauer, fällt auf, dass immer wieder ähnliche Konstruktionen auftreten, wie z.B. das optionale Vorkommen oder die 36 2.4 Syntaxbeschreibung Wiederholung von Teilausdrücken. Um solche Strukturen einfacher ausdrücken zu können wurden zur BNF spezielle Konstrukte hinzugefügt, was die erweiterten BNF (EBNF) ergibt: ◦ Optionales Vorkommen eines Wertes: [e] Dies können wir für die Definition von Werten verwenden: V al ::= [0 −0 ] N um ◦ Optionale Wiederholung {α}, d.h. α kann 0-mal, 1-mal, 2-mal,. . . vorkommen. ◦ Außerdem besteht noch die Möglichkeit die Alternative (|) auch in Gruppierungen zu verwenden. Ein Beispiel hierzu ist S ::= 0 a0 ( 0 b0 | 0 c0 ) d mit abd und acd aus S ableitbar. Mit diesen Abkürzungen können wir unsere BNF für Ruby-Ausdrücke an einigen Stellen kompakter aufschreiben: Exp ::= V ar | Val | 0 0 | Fun 0 (0 Exp { , Exp } 0 )0 ( Exp Op Exp 0 )0 Val ::= [0 −0 ] Dig { Dig } | 0 true0 | 0 f alse0 Beachte, dass sich für eine EBNF auch die Ableitungsbäume etwas ändert. Die inneren Knoten können nun auch mit den neuen Konstrukten der EBNF beschriftet sein, wobei außen immer ein neues Konstrukt steht. In einem Schritt erhält man dann so viele Kindknoten, wie benötigt werden, um dieses Konstrukt aufzulösen. Als Beispiel betrachten wir die Regel für Num bei der Ableitung des Wortes -1234: Num [0 −0 ] Dig - 1 {Dig} Dig Dig Dig 2 3 4 Für die anderen Regeln gilt entsprechend: [α] oder [α] α und (α | β) α oder (α | β) β 37 2 Programmierung Hierbei stehe α und β für beliebige Folgen von Terminal und Nichtterminalsymbolen und erweiterten Konstrukten der EBNF. Angewendet auf die konkreten Folgen, wie sie in der entsprechenden EBNF-Regel vorkommen. Um nun auch die Syntax kompletter Ruby-Programme formal zu beschreiben, definieren wir noch das Nichtterminalsymbol Stm für Anweisungen (statemants): Stm ::= Stm Stm | Var 0 =0 Exp 0 ;0 | 0 while0 Exp 0 do0 Stm 0 end0 0 ;0 | 0 f or0 Var 0 in0 Exp 0 ..0 Exp 0 do0 Stm 0 end0 0 ;0 | 0 if 0 Exp 0 then0 Stm [ 0 else0 Stm ] 0 end0 0 ;0 | 0 puts0 0 (0 Exp 0 )0 0 ;0 Beachte, jede Anweisung wird in der formalen Syntaxdefinition mit enem Semikolon abgeschlossen. ↓↓↓↓↓ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↓↓↓↓↓ Stellt diese Erweiterung eine echte Erweiterung dar, d.h. können Sprachen/Eigenschaften beschrieben werden, welche vorher nicht möglich waren? Nein, denn jede EBNF kann in eine BNF übersetzt werden, welche die gleiche Sprache beschreibt. Gehe hierzu wie folgt vor: Falls es eine Regel gibt mit N ::= α [ β ] γ Dann ersetze diese durch N ::= α γ | α β γ Falls es eine Regel gibt mit N ::= α { β } γ Dann ersetze diese durch N ::= α M γ M ::= β M | ε, wobei M ein neues Nichtterminalsymbol der EBNF ist, also noch nicht in der aktuellen EBNF verwendet worden sein darf. Die BNF wird auch von Compilern zur Analyse der Programmiersprache verwendet, Aufgabe ist es hierbei zu einem gegebenen Wort (Programm) einen passenden Ableitungsbaum zu konstruieren. Der Compiler kann dann alle möglichen Ableitungen ausprobieren: Bsp: Finde Ableitung für (3 + 4) Exp ⇒ V ar ⇒ N um ⇒ Digit ⇒ Digit N um ⇒ 0 ⇒1 ... ⇒ ( Exp Op Exp ) ⇒ ( V al Op Exp ) ⇒0 −0 N um ⇒ ( V al Op Exp ) ⇒ . . . Diese Suchverfahren, bei denen der Reihe nach alle möglichen Kodierungen durchgetestet werden bezeichnet man als Backtracking. Wir werden aus diese Programmiertechnik später noch genauer eingehen. 38 2.4 Syntaxbeschreibung 2.4.1 Präzedenzen in der BNF Wir wollen uns noch einmal mit der Klammerung für arithmetische Ausdrücke beschäftigen. Bisher haben wir ja in der EBNF nur vollständig geklammerte Ausdrücke betrachtet. Ruby erlaub aber auch Ausdrücke, bei denen auf Grund von Präzedenzen Klammern fehlen, z.B.: 3 + 4 ∗ 5 statt (3 + (4 ∗ 5)) 3 ∗ 4 − 2 statt ((3 ∗ 4) − 2) und Es ist tatsächlich möglich, auch für Ausdrücke eine EBNF anzugeben, so dass die Präzedenzen berücksichtigt werden. Zur Vereinfachung, betrachten wir hier nur die beiden Operatoren + und *. Als ersten Ansatz könnten wir folgende BNF wählen: Exp ::= V ar | V al | Exp 0 +0 Exp | Exp 0 ∗0 Exp | 0 (0 Exp 0 )0 Dann könnte der Ausdruck 3 + 4 ∗ 5 aber auf zwei unterschiedliche Wege abgeleitet werden: Exp Exp und + Exp Exp Val Exp 3 * Exp * Exp Exp Val Val Val 5 3 4 Exp Exp Val Val 4 5 + wobei der rechte Baum aber eben nicht der Strukturierung der Präzedenzen entspricht: ((3+4)*5). Zur Realisierung von Präzedenzen können wir unterschiedliche Ebenen in der EBNF vorsehen. Hierbei sind innerhalb von Multiplikationen dann eben Additionen nur noch erlaubt, wenn diese geklammert werden. Außerdem können wir gleichzeitig noch eine Linksklammerung bei gleichen Operatoren vorsehen, d.h. 3+5+6 wird interpretiert als (3+5)+6 und eben nicht als 3+(5+6). Man sagt der Operator bindet linksassoziativ. 39 2 Programmierung Die EBNF sieht dann wie folgt aus: Exp ::= Exp 0 +0 Exp2 | Exp2 Exp2 ::= Exp2 0 ∗0 Exp3 | Exp3 Exp3 ::= 0 0 ( Exp 0 )0 | V ar | V al Die zusätzlich hinzugenommen Nichtterminale Exp2 und Exp3 dienen dazu, zwischen beliebigen Ausdrücken (Exp), Ausdrücken ohne toplevel Multiplikation (Exp2 ) und Ausdrücken ohne toplevel Multiplikation und toplevel Addition zu unterscheiden. Außerdem ist bei der Regel für die Addition, eine weitere toplevel Addition nur im linken Argument erlaubt, wodurch wir die Linksassoziativität des +-Operators realisieren. Als Beispiele betrachten wir folgende Ableitungen: Exp Exp Exp + Exp2 Exp2 Exp2 Exp3 * + Exp Exp2 Exp3 Exp2 Exp3 Val Exp3 Val 5 Val 4 Exp3 Exp Exp3 Val Val Val 5 3 4 + 3 Exp Exp + Exp2 Exp2 Exp3 Exp3 Val 3 40 Exp2 ( Exp Exp2 + ) Exp2 Exp3 Exp3 Val Val 4 5 2.5 Zeichenketten Für die explizite Rechtklammerung der Addition sind also Klammern notwendig: 3+(4+5), während die Linksklammerung auch ohne Klammern abgeleitet werden kann. Zusammen mit Postfixnotation und Stackmaschine ergibt sich nun ein vollständiges Bild der Auswertung von Ausdrücken in Ruby. Eine gegebene Zeichenkette (z.B. 3+4*5) wird zunächst mit Hilfe einer EBNF mit Präzedenzen anlysiert. Wir erhalten den konkreten Ableitungsbaum: Exp Exp * Exp Exp Val Val Val 5 3 4 Exp + Dieser wird dann in einen Termbaum (auch abstrakten Syntaxbaum genannt) umgebaut: + 3 * 4 5 Dieser kann dann mittels Postfixnotation (3 4 5 * +) und Stackmaschine ausgewertet werden: ε|345 ∗ + ⇒ 3|45 ∗ + ⇒ 34|5 ∗ + ⇒ 345| ∗ + ⇒ 3 20 | + ⇒ 23 | ↑↑↑↑↑ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↑↑↑↑↑ 2.5 Zeichenketten Bei der Programmierung muss auch häufig mit Texten (Zeichenfolgen) gearbeitet werden. Hierzu stellen die meisten Programmiersprachen Zeichenketten (Strings) als Werte zur Verfügung. In Ruby heißt die Klasse String und Werte dieser Klasse können wie folgt definiert werden: “Hallo“ ’noch ein Text’ “Dies ist ein ’Text’.“ ’ein ganz “komischer“ Text’ Strings können ebenfalls mittels puts() ausgegeben werden: puts(“Hallo“); ; Hallo puts(’a“b“c’); ; a“b“c 41 2 Programmierung Operationen auf Strings: Strings können mit der Operation + verbunden werden: “Hallo“ + “ “ + “Leute“ ; “Hallo Leute“ Es gibt noch eine Reihe weiterer Funktionen für Strings. Die erste, die wir kennen lernen, dient zur Bestimmung der Länge eines Strings. Sie heißt length. Für diese und weitere Funktionen auf Strings, gibt es aber eine Besonderheit: sie werden Methoden genannt und mit einem Punkt getrennt hinter ihr Argument geschrieben: ” Hallo ” . length () ; 5 ” I n f o i s t t o l l ” . l e n g t h ( ) ; 13 ” Hi ” ∗ 3 ; ” HiHiHi ” Für die length-Funktion schreiben wir also nicht length(”Hallo”), sondern ”Hallo”.length(), wobei wir den String dennoch als das Argument length verstehen können. Außerdem können Strings auch mittels gets() vom Benutzer eingelesen werden: Bsp.: str = gets ( ) ; puts ( s t r . length ( ) ) ; puts ( s t r ) ; Eingabe: abc ; Ausgaben: 4, abc Warum hat str die Länge 4? Den Grund erkennt man, wenn man in irb den Ausdruck gets() auswertet: i r b ( main ) : 0 0 1 : 0 > g e t s ( ) abc => ” abc \n” \n ist das Zeilenendezeichen! Weitere spezielle Steuerzeichen in Strings: “\t“ Tabulator “\r“ Wagenrücklauf (ggf. bei Zeilenumbruch unter Windows) Oft ist man an diesen \n nicht interessiert. Diese können entfernt werden, mittels (Methoden): ◦ strip() entfernt alle Whitespaces (Leerzeichen, Tabulatoren, Zeilenumbrüche) am Stringanfang und -ende. Bsp.: “ \n \t \n hi \n Leute \n“.strip() ; “hi \n Leute“ ◦ chop() entfernt pauschal das letzte Zeichen. Bsp.: “abc\n“.chop ; “abc“ “abc“.chop() ; “ab“ Außerdem ist es möglich, auf Teilstrings zuzugreifen: str[p, l], wobei p die Position und l die Länge des selektierten Teilstrings angiebt. Beachte hierbei aber, dass die Positionszählung bei Null beginnt, man das erste Zeichen also mit “abcdef“[0, 1] und nicht mit “abcdef“[1, 1] bekommt. Wir beginnen das Zählen also bei 0: 42 2.5 Zeichenketten “abcdef“[3, 2] ; “de“ “abcdef“[2, 1] ; “c“ “abcdef“[3, 10] ; “def“ Jetzt wollen wir mit Strings programmieren. Aufgabe: Zähle, wie oft ein bestimmter Buchstabe in einem Text vorkommt. p u t s ( ” Text : ” ) ; t e x t = g e t s ( ) . chop ( ) ; p u t s ( ” Buchstabe : ” ) ; l e t t e r = g e t s ( ) [ 0 , 1 ] ; #w i r s p e i c h e r n nur den e r s t e n Buchstaben n = 0; f o r i i n 0 . . t e x t . l e n g t h () −1 do i f t e x t [ i , 1 ] == l e t t e r then n = n+1; end ; end ; p u t s ( ” Der Buchstabe ’ ” + l e t t e r + ” ’ kommt ” + n . t o s ( ) + ” mal vor . ” ) ; Die Methode to s () wandelt eine Zahl in einen String um (später mehr). Als Besonderheit ist es in Ruby bei nullstelligen Methoden, also Methoden, die keinen Parameter erwarten, auch möglich, die Klammern wegzulassen, also nur n. to s zu schreiben. Gleiches gilt auch für die String-Methode length, welche auch ohne Klammern notiert werden kann. Im Folgenden werden wir in diesen Fällen die Klammern weglassen. Ausführen: > ruby c o u n t l e t t e r . rb Text : B i o l o g i e i s t e i n s c h o e n e s Fach Buchstabe : i Der Buchstabe ’ i ’ kommt 4 mal vor . > Als nächstes Problem wollen wir untersuchen, ob ein String in einem anderen String vorkommt. Bsp.: t e x t=” Die G i r a f f e t r i n k t K a f f e e . ” ; sub=” a f f e ” ; i =0; w h i l e i <t e x t . l e n g t h && t e x t [ i , sub . l e n g t h ] ! = sub do i = i +1; end ; p u t s ( ” ’ ” + sub + ” ’ kommt i n ’ ” + t e x t + ” ’ ” + i f i==t e x t . l e n g t h then ” n i c h t ” e l s e ”” end + ” vor . ” ) ; 43 2 Programmierung Um auch unsere alten Programme interaktiver gestalten zu können, ist es notwendig auch Zahlen einlesen können. Hierzu bieten Strings die Methoden to i und to f : ” 123 ” . t o i ; 123\\ ”−3a” . t o i ; −3 ( Der Rest wird i g n o r i e r t ) \ \ ” H a l l o ” . t o i ; 0\\ ” −2.34 ” . t o f ; −2 ,34\\ ” −2.34 ”” . t o i ; −2\\ Somit z.B. in Fakultätsprogramm: n str = gets ( ) ; n = n str . to i ; ... oder direkt: n = gets ( ) . to i ; Entsprechend können Zahlen mit der Methode to s in einen String umgewandelt werden: 3 . t o s ; ”3” Nun wäre es schön, auch mit größeren Texten arbeiten zu können, wie z.B. einer große Textdatei. Eine Textdatei kann mit IO . r e a d ( dateiname ) ; eingelesen werden. IO ist, genau wie Math eine Klasse, die auch Methoden zur Verfügung stellt. Ersetzt man also im Programm t e x t = IO . r e a d ( ” l a r g e . t x t ” ) ; puts ( ” Substring : ” ) ; sub = g e t s . chop ( ) ; ... So wird der Teilstring in dem Text der Datei mit dem Namen large . txt gesucht. Abschließen wollen wir dieses Kapitel mit einem Programm abschließen, welches gegebene Strings umdreht. t e x t = g e t s . chop ; r e v = ”” ; # i n r e v bauen w i r den umgedrehten S t r i n g a u f f o r i i n 0 . . t e x t . l e n g t h −1 rev = text [ i ,1]+ rev ; end ; puts ( rev ) ; 44 2.6 Objektorientierte Programmierung 2.6 Objektorientierte Programmierung Bei unseren bisherigen Programmen haben wir zwischen Werten und Funktionen (Operationen die wir auf Werte anwenden können) unterschieden. Im letzten Kapitel haben wir aber schon gesehen, dass Ruby für manche Funktionen aber auch eine Notation als Methoden verwendet. In diesem Abschnitt wollen wir uns die Zusammenhänge etwas genauer anschauen. Wir betrachten noch einmal Ruby-Ausdrücke, wie wir sie schon kennen gelernt haben: z.B. 3+4 37%8 if true then 42 else 43 end 16/3 Um die Fülle von Funktionen zu strukturieren wurde die objektorientierte Sicht von Daten und Funktionen auf diesen Daten erfunden. Idee: Die Welt besteht aus Objekten (z.B. Bello, Waldi, Mietzi) die in Klassen z.B. Hund, Katze eingeteilt werden. Als weiteres Beispiel gibt es die Klasse der Zahlen, welche z.B. die Werte 42, 0 und -7 enthalten, sowie die Klasse der booleschen Werte true und false. Objekte werden dann zu einer Klasse zusammengefasst, wenn sie gleiche Eigenschaften bzw. Operationen besitzen, so können Hunde bellen, Zahlen addiert werden und boolesche Werte mit den booleschen Operationen verknüpft werden. Operationen auf diesen Objekte bezeichnet man auch als Methoden. Bsp.: Objekte der Klasse Hund könnten z.B. die Methoden hatRasse, hatGröße, hatGeschlecht zum Abfragen bestimmter Eigenschaften sein. Methoden, die das Verhalten von Hunden ändern könnten bellen oder spielMit sein. Auch Zahlen haben Methoden +, -, * usw. Entsprechend besitzen boolesche Werte die Methoden & und | (Bem.: && und || sind keine Methoden. Auf die feinen Unteschiede zwischen diesen und den zugehörigen Methoden gehen wir später genauer ein.). Um klar zu machen, dass bestimmte Funktionen/Operationen Methoden eines Objektes sind schreiben wir z.B. 3. + (4) statt 3 + 4 Plus ist also eine Methode, welche für Objekte der Klasse Zahl definiert ist und als weiteren Parameter eine andere Zahl erfordert. Entsprechend würde man bei Hunden schreiben: Die Größe des Objekts Bello können wir durch Zugriff auf die Methode groesse(), welches alle Objkete der Klasse Hund zur Verfügung stellen wie folgt zugreifen: Bello . groesse(). Außerdem können wir Bello bellen lassen, indem wir die Methode Bello . bellen () aufrufen oder ihn mit Waldi spielen lassen: Bello . spiel mit (Waldi). In Ruby können wir dies im Moment noch nicht mit Hunden und Katzen durchführen, aber mit den vordefinierten Datentypen: Bsp.: x = 3; y = x.+(4); puts ( y ) ; ; liefert Ausgabe 7. 45 2 Programmierung Betrachten wir ein weiteres Bsp. true + 3 NoMethodeError: undefined method ’+’ for true: TrueClass Das heißt true + 3 wird übersetzt zu true. + (3) und das Objekt true kennt keine Methode +. Entsprechend liefert 3 + true eine andere Fehlermeldung: TypeError: true can’t be coerced into Fixnum Hier wird also 3. + (true) aufgerufen, was existiert, aber ein Argument der Klasse Fixnum (also eine Zahl) erwartet. Klassen Welche Klassen (Typen) kennen wir schon? ◦ Zahlen: – Klasse Fixnum, Methoden: +, −, ∗, /, %, ==, ! =, to s – Klasse Float (Gleitkommazahlen), Methoden wie Fixnum aber nicht == und ! = verwenden! ◦ boolesche Werte: Klasse TrueClass bzw. FalseClass, Methoden: & boolesches Und, | boolesches Oder, ˆ exklusives Oder, außerdem ! als Präfixoperator (Negation, keine Methode der Klassen!)), to s ◦ String: Methoden: +, length, to i , die Selektion eines Teilstrings [pos,l] und das Umdrehen eines Strings mittels reverse Beispiele für Methodenaufrufe: 3.0 + 4 7/3 7.0/3 7/3.0 trueˆtrue ; ; ; ; ; 3.0. + (4) 7./(3) 7.0./(3.0) 7./(3.0) true.ˆ (true) ; ; ; ; ; 7.0 2 2, 33333 . . . 2, 33333 . . . f alse exklusive Oder Wir werden weitere Klassen (mit Methoden) kennenlernen und später auch eigene Klassen definieren. 2.7 Prozedurale Abstraktion Bereits bei der Einführung des Algorithmenbegriffs haben wir folgende Aspekte diskutiert: ◦ Detailliertheit der Beschreibung (Verständlichkeit) (nimm Ei aus Verpackung, schlage es gegen Schüsselkante vs. trenne Ei, schlage Eiweiß) ◦ Wiederverwendbarkeit (Sauce Hollandaise oder Rührteig anderswo definiert) Auch bei unseren Programmen wäre eine solche Strukturierung wünschenswert. Hierzu bieten Programmiersprachen in der Regel Prozeduren (ohne Rückgabewert) oder Funktionen (mit Rückgabewert). 46 2.7 Prozedurale Abstraktion 2.7.1 Funktionen Als Beispiel betrachten wir die Definition einer Funktion zur Berechnung der Fakultät. def fac (n) res = 1; f o r i i n 1 . . n do res = res ∗ i ; end ; return res ; end ; puts ( f a c ( 5 ) ) ; puts ( f a c ( 6 ) ) ; ; 120 ; 720 Hierbei ist def ein Schlüsselwort, welches den Beginn einer Funktionsdefinition anzeigt und mit end abgeschlossen wird. Nach dem Schllüsselwort def steht der Name der definierten Funktion und danach kommen die formalen Parameter (Varibalen, falls mehrere durch Kommas getrennt), welche von den konkreten Parametern beim Funktionsaufruf abstrahieren. Nach den Parametern kann das Verhalten der Funktion in Form normaler Anweisungen definiert werden. Diesen Teil bezeichnet man als Funktionsrumpf. Der Rückgabe-Wert der Funktionsdefinition wird mit Hilfe des Schlüsselwortes return bestimmt. Die Ausführung der return-Anweisung beendet die Funktion. Um den Code übersichtlicher zu gesatlten sollte man aber darauf achten, dass return immer am Ende einer Funktionsdefinition steht und bei Verzweigungen in allen möglichen Zweigen ein Rückgabewert mittels return festgelegt wird. Die Fakultätsfunktion kann nach der Definition beliebig häufig im weiteren Programm verwendet werden und mit unterschiedlichen Werten aufgerufen werden. Ein Funktionsaufruf liefert genau wie eine vordefinierte Funktionen einen Rückgabewert. Um den Ergebniswert in die weiteren Berechnungen einbauen zu können, werden Funktionen im Rahmen von Ausdrücken aufgerufen, z.B. x = fac (4); i f f a c (5) >100 then puts ( ” g r o s s ” ) ; else puts ( ” k l e i n ” ) ; end ; p u t s ( ” F a k u l t a e t von ” + n . t o s + ” l a u t e t ” + f a c ( n ) . t o s ) ; Beim Aufruf einer definierten Funktion, werden zunächst die Argumente ausgewertet. Danach werden die formalen Parameter (Variablen der Funktionsdefinition) an die aktuellen Parameter (Argumente) gebunden. Mit dieser Bindung wird der Funktionsrumpf ausgewertet und, nach Beendigung dieser Auswertung, der Aufruf durch den Rückgabewert (Wert hinter return) ersetzt. Im Beispiel würde im Ausdruck fac(fac(3))+10 zunächst fac(3) berechnet, wozu mit der Belegung n=3 die Fakultätsfunktion ein erstes Mal ausgeführt wird. Die Variable res wird für die Belegung n=3 beim return an 6 gebunden sein und der Aufruf zu fac(6)+10 ausgewertet werden. Danach erfolgt der nächste Aufruf mit der Belegung n=6. Disesmal erhalten wir die finale Belegung res=720 und die Berechnung wird mit 720+10 fortgesetzt, was abschließend noch zu 730 ausgerechnet wird. 47 2 Programmierung Bei der Definition eigener Funktionen muss man beachten, dass im Funktionsrumpf nur die Parametervariablen bekannt sind. D.h. andere Varibalen, auch die, die vor der Funktion definiert wurden, sind nicht bekannt, wie folgendes Beispiel zeigt: x = 42; def bla () i f x == 42 then r e t u r n ( 4 2 ) ; e l s e return ( 0 ) ; end ; end ; puts ( bla ( ) ) ; Bei der Ausführung dieses Programms wird weder 42 noch 0 ausgegeben. Vielmehr erhält man folgende Fehlermeldung: f u n c . rb : 4 : i n ’ bla ’ : u n d e f i n e d l o c a l v a r i a b l e o r method ’ x ’ f o r main : Object ( NameError ) Beim Vergleich von x mit 42, versucht Ruby auf die (lokal) nicht gebundene Variable x zuzugreifen. Korrigieren, können wir das Programm, indem wir die Variable x als Parameter übergeben: x = 42; def bla (x) i f x == 42 then r e t u r n ( 4 2 ) ; e l s e return ( 0 ) ; end ; end ; puts ( bla ( x ) ) ; Hierbei muss man aber das x innerhalb der Funktion von dem x außerhalb unterscheiden. Wir könnten den Parameter auch anders bennenen, ohne dass sich das Programmverhalten ändern würde (Umbenennung lokaler Variablen hat keinen Effekt). Dass Funktionen abgeschlossene Einheiten darstellen, erkennt man nicht nur daran, dass Variablen von außerhalb nicht im Funktionsrumpf bekannt sind. Umgekehrt sind auch Variablenbindungen, die innerhalb des Funktionsrumpfs vorgenommen wurden, nicht außerhalb der Funktion sichtbar: x = 41; def bla (x) x = x + 1; y = x; i f x == 42 then r e t u r n ( 4 2 ) ; e l s e return ( 0 ) ; end ; 48 2.7 Prozedurale Abstraktion end ; puts ( bla ( x ) ) ; puts ( x ) ; puts ( y ) ; ; 42 ; 41 ; Fehlermeldung Dieses Programm gibt also zunächst 42 aus, dann 41 und bricht dann mit der Fehlermeldung f u n c . rb : 1 4 : i n ’<main > ’: u n d e f i n e d l o c a l v a r i a b l e o r method ’ y ’ f o r main : Object ( NameError ) ab. 2.7.2 Prozeduren Im Gegensatz zu Funktionen haben Prozeduren keinen Rückgabewert, das bedeutet, dass sie auch keine Ergebnisse liefern (keine return!). Auch in Prozeduren können Variablen aus aufgerufenem Programmcode nicht gelesen oder verändert werden. Aber Prozeduren können z.B. Ausgaben erzeugen. Bsp.: Ausgabe eines Quadrats gegebener Größe def put quadrat (n) l i n e = ”” ; f o r i i n 1 . . n do l i n e = l i n e + ”X” ; end ; f o r i i n 1 . . n do puts ( l i n e ) ; end ; end ; # # l i n e = ”X” ∗ n ; # # put quadrat ( 5 ) ; n = gets ( ) . t o i ; put quadrat (n ) ; Da Prozeduren keinen Ergebniswert haben (sie machen höchstens Ausgaben auf dem Bildschirm, welche aber nicht als Ergebnis der Unterberechnung weiterverwendet werden können), können Prozeduren nur als Anweisungen verwendet werden: n = gets . to i ; put quadrat (n ) ; f o r i i n 0 . . 5 do put quadrat ( i ) ; end ; 2.7.3 Funktion mit Ausgabe Eine Funktion, welche sowohl Ausgabe macht, als auch ein Ergebnis liefert, ist die Definition einer komfortableren Eingabe mit Erläuterungstext (Prompt): 49 2 Programmierung Bsp.: Eingabe mit Prompt d e f g e t p ( prompt ) p u t s ( prompt ) ; # p r i n t ( promt ) ; return gets ; end ; n = g e t p ( ” Zahl : ” ) . t o i ; s t r = g e t p ( ”Name : ” ) . chop ; Beobachtung: Zeilenumbruch unschön ⇒ besser print anstelle von puts verwenden. Beachte: Prozeduren und Funktionen können auch andere Prozeduren und Funktionen aufrufen. Bsp.: def n spaces in between ( str , n) return ( str + ” ” ∗ n + str ) ; end ; def t r i a n g l e (n) p u t s ( ”∗” ) ; f o r i i n 0 . . n−3 do p u t s ( n s p a c e s i n b e t w e e n ( ”∗ ” , i ) ) ; end ; p u t s ( ”∗” ∗ n ) ; end ; triangle (3); puts ( ) ; triangle (7); #e r z e u g e e i n e L e e r z e i l e / Zeilenumbruch Wenn Funktionen andere Funktionen aufrufen können, können sie sich dann auch selbst aufrufen? Antwort: Ja, Rekursion 2.8 Rekursion Bsp.: Fakultätsfunktion Die Fakultätsfunktion ist ja mathematisch definiert als: ( 1, falls n = 0 n! = n · (n − 1)! , sonst Dies lässt sich direkt als rekursive Funktion umsetzen: def fac (n) i f n==0 then r e t u r n 1 ; e l s e r e t u r n n ∗ f a c ( n −1); end ; 50 2.8 Rekursion end ; puts ( f a c ( 5 ) ) ; puts ( f a c ( 4 ) ) ; # 120 # 24 Das Verhalten eines rekursiven Programms kann am Beispiel des Aufrufs puts(fac(3)); wie folgt verdeutlicht werden: puts(fac(3)); hier muss zunächst das Ergebnis von fac(3) bestimmt werden: fac(3) ,→ Code von fac mit Belegung n=3 auswerten die Bedingung ist nicht erfüllt, deshalb weiter im else-Zweig return 3*fac(2); hier muss zunächst das Ergebnis von fac(2) bestimmt werden: fac(2) ,→ Code von fac mit Belegung n=2 auswerten die Bedingung ist nicht erfüllt, deshalb weiter im else-Zweig return 2*fac(1); hier muss zunächst das Ergebnis von fac(1) bestimmt werden: fac(1) ,→ Code von fac mit Belegung n=1 auswerten die Bedingung ist nicht erfüllt, deshalb weiter im else-Zweig return 1*fac(0); hier muss zunächst das Ergebnis von fac(0) bestimmt werden: fac(0) ,→ Code von fac mit Belegung n=0 auswerten die Bedingung ist erfüllt, deshalb weiter im then-Zweig return 1; die Rekursion terminiert, Ergebniswert bekannt ←- der Aufruf von fac(0) wird durch 1 ersetzt return 1*1; ausrechnen: 1*1 → 1 return 1; die Rekursion terminiert, Ergebniswert bekannt ←- der Aufruf von fac(1) wird durch 1 ersetzt return 2*1; ausrechnen: 2*1 → 2 return 2; die Rekursion terminiert, Ergebniswert bekannt ←- der Aufruf von fac(2) wird durch 2 ersetzt return 3*2; ausrechnen: 3*2 → 6 return 6; die Rekursion terminiert, Ergebniswert bekannt ←- der Aufruf von fac(3) wird durch 6 ersetzt puts(6); ausgeben ; 6 Beachte, dass es nicht immer der Fall sein muss, dass eine Funktion nach dem rekursiven Aufruf selber auch direkt terminieren muss. Hier können noch weitere Funktionen (auch rekursiv!) aufgerufen werden. ↓↓↓↓↓ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↓↓↓↓↓ Zur Implementierung der Rekursion wird ein Stack (Keller), wie wir ihn in der StackMaschine kennen gelernt haben, verwendet. Bei der Ausführung von Prozeduren und Funktionen nennt man diesen Laufzeitstack (oder Laufzeitkeller). Auf ihm wird zu einem Funktionsaufruf die umgebenden Berechnungen gespeichert. Nach der Beendigung einer (nicht nur rekursiven) Funktion wird die Berechnung in der auf dem Laufzeitkeller gespeicherten Umgebung fortgesetzt. In der Ausführung oben haben wir dies durch die Einrückung dargestellt, d.h. weniger stark eingerückte Ausführungeteile liegen noch auf dem Laufzeitkeller 51 2 Programmierung und werden noch fertig ausgeführt, wenn die Unterberechnung (z.B. fac(2)) beendet wurde. ↑↑↑↑↑ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↑↑↑↑↑ Bemerkung: Rekursion ist genauso ausdrucksstark wie die Verwendung von Schleifen, welche auch als Iteration bezeichnet wird. Es gibt sogar Programmiersprachen, welche nur Rekursion und keine Schleifen anbieten (z.B. Funktionale Programmiersprachen). Um zu verstehen, wie man zu iterativen Programmen äquivalente rekursive Programme entwickeln kann, betrachten wir noch einmal das Umdrehen eines Strings: def reverse ( str ) i f s t r . l e n g t h <=1 then return str ; else r e t u r n r e v e r s e ( s t r [ 1 , s t r . l e n g t h −1])+ s t r [ 0 , 1 ] ; end ; end ; p u t s ( r e v e r s e ( ” abcde ” ) ) ; Alternativ können wir auch den String unverändet lassen und im rekursiven Aufruf nur den Index, welchen wir im aktuellen Schritt übernehmen wollen, runterzählen: def rev ( str , i ) i f i <0 then return ”” ; else r e t u r n s t r [ i , 1 ] + r e v ( s t r , i −1); end ; end ; def reverse ( str ) r e t u r n r e v ( s t r , s t r . l e n g t h −1); end ; Da neben dem Index auch der String, welchen wir umdrehen wollen, im Rumpf der rekursiven Funktion bekannt sein muss, erhält die Funktion rev zwei Parameter. Die eigentliche Funktion reverse mit einem Argument startet dann die zweistellige Hilfsfunktion rev mit einem passenden Wert für den ersten Index. 2.9 Objekte und ihre Identität In Kapitel 3.3 hatten wir bereits Klassen, Objekte und Methoden kennengelernt. Insbesondere haben wir uns mit Methodenaufrufen vertraut gemacht, z.B. “abc“.length ; 3 Objektorientierte Programmierung (Objektorientierte Programmiersprachen) macht aber mehr aus. Objekte haben eine Identität und können verändert werden. Ihr Zustand ist durch ihre Attribute definiert. 52 2.9 Objekte und ihre Identität Bsp.: Bello könnte ein Objekt der Klasse Hund sein. Ein Attribut könnte z.B. sein Gewicht sein. Dann könnte es eine Methode fressen geben, welche natürlich sein Gewicht verändert. Ein anderes Beispiel ist ein Konto. Wenn man von einem Konto Geld abhebt oder Geld einzahlt, erhält man kein neues Konto. Vielmehr verändert sich der Zustand des Kontos, welcher durch ein Attribut, den Kontostand repräsentiert wird. Entsprechend können auch einige der Ruby Objekte verändert werden. Ein wichtiges Beispiel hierbei sind Strings. s t r = ” bla ” ; s t r [ 1 , 1 ] = ”xx” ; puts ( s t r ) ; ; bxxa Hierbei bedeutet, str [p, l]=str’, dass in str der Teilstring an der Position p mit der Länge l durch den String str ’ ersetzt wird. Wir erhalten also vom Prinzip den String str [0, p]+str’+str[p+l,str .length]. Allerdings nicht wie bisher als neues Objekt. Vielmehr wird der alte String (im Beispiel ”bla”) verändert. Dies wird an folgendem Ruby-Programm deutlich: x = ” bla ” ; y = x; y [ 1 , 1 ] = ”” ; puts ( y ) ; puts ( x ) ; ; “ba“ ; “ba“ Die Variablen verweisen beide auf das gleiche String-Objekt, welches mittels [1, 1] = an der Position 1 geändert wird! Im Gegensatz zu x = ” bla ” ; y = ” bla ” ; y [ 1 , 1 ] = ”” ; puts ( y ) ; ; “ba“ puts ( x ) ; ; “bla“ x und y zeigen auf zwei unterschiedliche Objekte, die zwar beide einen String ”bla” darstellen, aber eben unterschiedliche Objekte sind (wie Zwillinge). Somit führt eine Veränderung des Objekts, auf das die Varibale y verweist, keiner Veränderung des anderen Objekts, auf das x verweist. Entsprechend wollen wir zum Vergleich auch noch einmal das entsprechende Programm, welches die Stringkonkatenation verwendet, untersuchen. x = ” bla ” ; y = x; y = y [0 ,1]+y [2 , y . length ] ; puts ( x ) ; puts ( y ) ; ; “bla“ ; “ba“ Hier zeigen x und y zwar auf das gleiche String-Objekt. In der darauf folgenden Zuweisung y = y[0,1]+y[2,y.length ]; wird aber kein Objekt verändert, sondern ein neues StringObjekt konstruiert und die Variable y verweist durch die Zuweisung auf dieses Objekt. x verweist weiter auf das alte, unveränderte String-Objekt. 53 2 Programmierung Objekte besitzen also eine Identität und bestimmte Methoden (hier die 3-stellige Methode []=) können das Objekt verändern. Solche Methoden werden auch als mutierende Methoden (oder destruktive Methoden) bezeichnet. Variablen beinhalten nicht das gesamte Objekt sondern nur einen Verweis auf ein Objekt. Ähnlich wie bei der if −then−else-Funktion, wird dieser Methodenaufruf in Ruby in einer Mix-Fixnotation geschrieben, die syntaktisch zwar an die Zuweisung erinnern soll, aber in Wirklichkeit keine Zweisung ist. Weitere Methoden der String-Objekte stehen ebenfalls in mutierenden Varianten zur Verfügung: “abcdef g”.reverse ; “gf edcba” “abcdef g”.reverse! ; “gf edcba” Die Methode reverse liefert ein neues String-Objekt, während reverse! das gegebene String-Objekt verändert. In Ruby existiert eine Konvention (Teil der Pragmatik von Ruby), dass der Name mutierender Methoden mit einem Ausrufezeichen endet. Hieran sollten sich alle Ruby-Programmierer halten! Der Unterschied der beiden Varianten, wird an folgenden Beispielprogrammen deutlich: x = ” abc ” ; y = x; puts ( y . r e v e r s e ( ) ) ; puts ( x ) ; aber: y . reverse ! ( ) ; puts ( y ) ; puts ( x ) ; ; cba ; abc wie Prozedur ; cba ; cba Beachte: Wenn man sich nicht für das Ergebnis eines Ausdrucks interessiert, kann man den Ausdruck in Ruby auch als Anweisung hinschreiben. Ergibt nur Sinn, wenn Effekte auf Objekten passieren oder Ausgaben, wie in Prozeduren. 3 + 4; x = 3; # <− u n s i n n i g , da k e i n Objekt v e r a e n d e r t wird aber y . reverse ! ( ) ; puts ( y ) ; # <− s i n n v o l l , da das Objekt i n y v e r a e n d e r t wird Oft sind mutierende Methoden auch als Funktionen implementiert, die das veränderte Objekt auch als Ergebnis liefern. Da sie aber Objekte, welche außerhalb der Methodendefinition existieren, auch verändern, würde es eigentlich auch ausreichen, sie als Prozeduren ohne Rückgabewert zu definieren. Prozeduren können also nicht nur mehrere Ausgaben zusammenfassen. Sie können auch übergebene Objekte mutieren. Somit ergeben sich mit mutierenden Updates auch neue sinnvolle Möglichkeiten Prozeduren selber zu definieren. 54 2.9 Objekte und ihre Identität Hier eine Übersicht über einige String-Methoden und ihre mutierenden Varianten: ◦ capitalize / capitalize! ◦ chop / chop! ◦ delete / delete! (löscht alle Character des Argumentstrings) ◦ downcase / downcase! ◦ upcase / upcase! ◦ slice (Methode zum Selektieren von Teilstrings) / slice! (Methode zum Löschen von Teilstrings) ◦ strip / strip! Mit [] = können Teilstrings ersetzt werden. Zur besseren Verständlichkeit wird diese Methode meist Mixfix notiert: s t r = ” abcdef ” ; s t r [ 2 , 3 ] = ”xx” ; # s t r . [ ] = ( 2 , 3 , ” xx ” ) puts ( s t r ) ; ; abxxf Als Beispiel wollen wir nun eine Variante des reverse Programms implementieren, die den String verändert: def rev ! ( s t r ) f o r i i n 0 . . s t r . l e n g t h −1 do s t r [ i , 1 ] = s t r [ s t r . l e n g t h −i − 1 , 1 ] ; end ; end ; # r e t u r n s t r Das ! im Prozedurnamen soll zeigen, dass die definierte Prozedur ihr Argument verändert. Wir verwenden keinen Rückgabewert. s = ” 1234 ” ; rev ! ( s ) ; puts ( s ) ; s = ” 12345 ” ; rev ! ( s ) ; puts ( s ) ; ; 4334 ; 54345 Das Programm verhält sich also noch nicht korrekt. Der Grund ist, dass das Kopieren zunächst Werte überschreibt, die später noch gebraucht werden. Lösung: Tauschen von Werten 55 2 Programmierung def rev ! ( s t r ) f o r i i n 0 . . s t r . l e n g t h /2 − 1 do h = s t r [ s t r . l e n g t h −i − 1 , 1 ] ; s t r [ s t r . l e n g t h −i −1 ,1] = s t r [ i , 1 ] ; str [ i ,1] = h; end ; end ; s = ” 1234 ” ; rev ! ( s ) ; puts ( s ) ; ; 4321 Zum Tauschen zweier Werte verwendet man in der Regel eine Hilfsvariable (hier h), die den Wert, welcher zuerst überschrieben wird, zwischenspeichert. Klassen, bei welchen alle Methoden, ein Objekt nicht mutieren, nennt mann auch immutable. Beispiele sind alle Klassen für Zahlen, wie wir sie bisher kennen gelernt haben und die Klassen für boolesche Werte. In einigen anderen Programmiersprachen (z.B. Java) sind auch Strings immutable. Aber eben nicht in Ruby. Die Kombination von mutierenden und nicht mutierenden Methoden innerhalb einer Klasse birgt einige Gefahren, welche wir uns später noch genauer ansehen werden. ↓↓↓↓↓ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↓↓↓↓↓ 2.10 Ausdrucksstärke unterschiedlicher Statements Bisher haben wir folgende Anweisungen kennengelernt: Stm ::= Stm Stm | V ar 0 =0 Exp 0 ;0 | 0 while0 Exp 0 do0 Stm 0 end0 0 ;0 | 0 f or0 V ar 0 in0 Exp 0 ..0 Exp 0 do0 0 Stm 0 end0 0 ;0 | 0 if 0 Exp 0 then0 Stm 0 else0 Stm 0 end0 0 ;0 | 0 if 0 Exp 0 then0 Stm 0 end0 0 ;0 Im Folgenden wollen wir uns mit der Frage beschäftigen, ob diese wirklich alle notwendig sind oder durch andere simuliert werden können. Hierbei interessieren uns insbesondere die Kontrollstruktur while, die beiden if -Varianten, sowie die for -Schleife. Auf die while-Schleife als einzige Schleife (Wiederholungsmöglichkeit), mit der wir eine Endlosschleife programmieren können, können wir sicherlich nicht verzichten. Da die forSchleife immer terminiert werden wir nicht alle Programme mit ihr realisieren können. Können aber die if then else-Anweisungen simuliert werden? if then ohne else kann durch ein if then else mit leerer else-Anweisung ersetzt werden. Wie konstruiert man eine leere Anweisung? 56 2.10 Ausdrucksstärke unterschiedlicher Statements Falls eine Variable v bereits verwendet wird, können wir eine Zuweisung v = v als leere“ ” Anweisung verwenden. Falls noch gar keine Variable verwendet wird, können wir eine Variable v, welche im Programm nicht verwendet wird initialisieren v = 0. Ist es umgekehrt auch möglich, das if then else durch das if then darzustellen? Wir können einmal die Bedingung und dann die negierte Bedingung überprüfen: i f b then s 1 e l s e s 2 end ; ↓ i f b then s 1 end ; i f ! b then s 2 end ; Dies ist so aber leider falsch, da sich eine Variable verändern kann, so dass b einen anderen Wert hat: x=5; i f x>4 then x=2; e l s e x=7; end ; ↓ x=5; i f x>4 then x=2; end ; i f ! ( x>4) then x=7; end ; Endbelegung: x = 2 Endbelegung: x = 7 Wichtig ist es, beobachten zu können, ob s1 ausgeführt wurde und sonst s2 auszuführen: Eine mögliche Lösung ist die Verwendung eines booleschen Flags: e x e c u t e d = f a l s e ; # neue V a r i a b l e i f b then s 1 e x e c u t e d=t r u e ; end ; i f ! e x e c u t e d then s 2 end ; Alternativ können wir auch das Ergebnis der booleschen Berechnung in einer Variablen zwischenspeichern und dann zweimal im if − then abfragen. Zum Speichern des Wertes der Bedingung verwenden wir eine neu, im Programm noch nicht vorkommende Variable bool: b o o l = b ; # neue V a r i a b l e i f b o o l then s 1 end ; i f ! b o o l then s 2 end ; Nun stellt sich die Frage, ob wir die if then-Anweisung auch durch eine while-Schleife simulieren können. Mit der vorherigen Übersetzung können wir dann auch das if then else mit while simulieren. (; Übung) i f b then s end ; ↓ w h i l e b do s end ; Passt schon ganz gut, s wird nur ausgeführt, wenn b zu true ausgewertet wird. Probleme: s wird in der Regel mehrfach ausgeführt. Lösung: Wir kombnieren, diese Ideen mit der Verwendung eines booleschen Flags um mehrfache Ausführung zu verhindern: 57 2 Programmierung b o o l = b ; # d i e V a r i a b l e b o o l d a r f noch n i c h t verwendet s e i n w h i l e b o o l do s bool = f a l s e ; end ; Als nächsten betrachten wir die Simulation von if-then mit Hilfe einer for -Schleife: i f b then s end ; ↓ f o r i i n 1 . . i f b then 1 e l s e 0 end do s end Hierbei haben wir die if-then Anweisung zwar ersetzen können. Die Verzweigung konnte allerdings nur auf Ausdrucksebene verschoben werden, da unter Verwendung der for -Schleife sonst keine Möglichkeit existiert, in Abhängigkeit einer booleschen Bedingung zu verzweigen. Abschließend wollen wir noch die Simulation der for - mit Hilfe der while-Schleife betrachten. Hierbei müssen aber einige wichtige Punkte berücksichtigt werden: Veränderungen der Zählvariablen bzw. der Bereichsgrenzen haben keinerlei Auswirkungen auf das Zählverhalten der Schleife. Um dies zu realisieren, Verwenden wir eine separate Zählvariable, deren Wert unabhängig von Veränderungen der eigentlichen Zählvariable hochgezählt werden kann. Außerdem wird der Endwert der Schleife einmalig vor Schleifenbeginn berechnet und zwischen gespeichert: f o r z i n e1 . . e2 do s end ; ↓ endValue = e2 ; # neue V a r i a b l e x = e1 ; # neue V a r i a b l e w h i l e x<=endValue do z = x; s x = x+1; end ; 2.11 Objektorientierte Datenmodellierung Nachdem wir die Eigenheiten der Objektidentität und mutierenden Methoden verstanden haben, wollen wir uns die Definition eigener Klassen und Objekte anschauen. In einer Klasse werden Objekte gleicher Art zusammen gefasst. Die Klasse stellt sozusagen das Gerüst für Objekte gleicher Bauart dar. Die Daten, welche in einem Objekt einer Klasse gespeichert werden, heißen Attribute und können in jedem einzelnen Objekt mit anderen Werten belegt werden. Eine Klassedefinition verfügt außerdem über Methoden, 58 2.11 Objektorientierte Datenmodellierung welche später für jedes konkrete Objekt der Klasse aufgerufen werden können und über die der Zugriff auf die Attribute erfolgen kann. Als erstes Beispiel wollen wir als neuen Zahlentyp Brüche (Fractional) definieren. Zunächst definieren wir das Gerüst der Klasse Fractional. Als Konvention beginnen Klassennamen in Ruby mit einem Großbuchstaben. class Fractional d e f i n i t i a l i z e ( z a e h l e r , nenner ) @zaehler = z a e h l e r ; @nenner = nenner ; end ; end ; Die spezielle Methode initialize heißt in der objektorientierten Programmierung Konstruktor und wird aufgerufen, wenn ein neues Objekt der Klasse Fractional angelegt wird. Zur Generierung eines Bruchs verwenden wir zwei Argumente, den Zähler und den Nenner. Beide übergebenen Werte speichern wir in Atributvariablen. Diese Attributvariablen werden durch ein @ am Anfang des Variablennamens gekennzeichnet und sind (nach ihrer Initialisierung) in der gesamten Klassendefinition gültig, also auch außerhalb des Konstruktors, indem sie iniitalisiert werden. Die Attribute werden also dafür verwendet, den eigentlichen Zustand der Objekte zu speichern. Als nächstes wollen wir eine Methode zur Klassendefinition hinzufügen, welche einen Bruch ausgibt: d e f show p u t s ( @ z a e h l e r . t o s+” / ”+@nenner . t o s ) ; end ; Dann können wir unsere Klasse bereits wie folgt verwenden: x = F r a c t i o n a l . new ( 5 , 3 ) ; x . show ; # −−> 5/3 Der Konstruktoraufruf Fractional.new erzeugt ein neues Objekt, welches dann durch den Konstruktor der Klasse Fractional initialisiert wird. Für eigene Objekte erkennt man so ganz genau, an welchen Stellen neue Objekte erstellt werden. Für vordefinierte Objekte, erfolgt dies implizit, wie z.B. bei 42 oder ”Hallo”. In Ruby ist es aber eher ungewöhnlich, spezielle Ausgabemethoden zu definieren. Statt dessen definiert man für seine Objekte meist eine spezielle Methode zur Umwandlung in einen String, welcher dann ausgegeben werden kann. Hierbei können wir direkt auch unsere Ausgabe optimieren und unechte Brüche (Zähler > Nenner) als gemischte Brüche ausgeben. def to s i f @nenner==1 then r e t u r n @ z a e h l e r . t o s ; e l s e r e t u r n ( i f @zaehler >@nenner then ( @ z a e h l e r / @nenner ) . t o s+” ” e l s e ” ” end + ( @ z a e h l e r % @nenner ) . t o s+” / ”+@nenner . t o s ) ; end ; end ; 59 2 Programmierung Dann können wir unsere Klasse bereits wie folgt verwenden: x = F r a c t i o n a l . new ( 5 , 3 ) ; puts ( x ) ; # −−> 1 1/3 Ein explizites to s ist hier nicht notwendig, da die Prozedur puts ihr Argument automatisch mittels to s in einen String umwandelt, falls es noch kein String ist. Unschön ist aber noch, dass es mehrere redundante Darstellungen der gleichen Brüche gibt: x = F r a c t i o n a l . new ( 5 , 3 ) ; y = F r a c t i o n a l . new ( 1 0 , 6 ) ; puts ( x ) ; # −−> 1 1/3 puts ( y ) ; # −−> 1 2/6 Es wäre besser, wenn Brüche immer direkt gekürzt würden, wenn sie angelegt werden. Dies ist aber mit Hilfe der ggt-Funktion kein großes Problem: d e f i n i t i a l i z e ( z a e h l e r , nenner ) i f nenner==0 then p u t s ( ” Achtung D i v i s i o n durch 0 ! ” ) ; end ; g g t = g g t ( z a e h l e r , nenner ) ; @zaehler = z a e h l e r / ggt ; @nenner = nenner / g g t ; end ; def ggt ( a , b) w h i l e a !=0 && b!=0 do i f a>b then a=a−b ; e l s e b=b−a ; end ; end ; i f a==0 then r e t u r n b ; e l s e return a ; end ; end ; Wir erhalten in obigem Programm nun auch für den Bruch Fractional.new(10,6) die Ausgabe 1 1/3. Im nächsten Schritt können wir versuchen die Multiplikation von Brüchen zu definieren. Wie immer benötigt der Operator * einen weiteren Bruch als Argument. Da die Methode * das Objekt selber nicht mutieren soll, müssen wir hier ein neues Fractional Objekt anlegen, welches das Produkt der beiden Brüche repräsentiert: def ∗( other ) r e t u r n ( F r a c t i o n a l . new ( @nenner ∗ o t h e r . nenner , @zaehler ∗ other . z a e h l e r ) ) ; end ; Wie alle Zahlen, sollten auch unsere Brüche immutable sein. Dies funktioniert so aber leider nicht, da die Attribute der Klasse Fractional nicht nach außen sichbar sind. Sie dürfen nur innerhalb der Klassendefinition verwendet werden. Wir 60 2.11 Objektorientierte Datenmodellierung benötigen also zwei zusätzliche Methoden, welche es uns ermöglichen auf diese beiden Attribute (lesend) zuzugreifen. Solche Methoden heißen getter-Methoden und stellen sich in unserem Beispiel wie folgt dar: d e f nenner r e t u r n @nenner ; end ; def zaehler return @zaehler ; end ; Dann funktioniert obige Definition der Multiplikation und wir erhalten: x = F r a c t i o n a l . new ( 5 , 3 ) ; y = F r a c t i o n a l . new ( 6 , 1 0 ) ; p u t s ( ( x∗y ) ) ; # −−> 1 Beachte, dass es nicht notwendig ist, das Ergebnis noch einmal explizit zu kürzen, da der Konstruktor des neuen Objekts dies für uns übernimmt. Entsprechend können wir auch die Addition definieren: d e f +(o ) r e t u r n F r a c t i o n a l . new ( @ z a e h l e r ∗ o . nenner+o . z a e h l e r ∗ @nenner , @nenner ∗o . nenner ) ; end ; Ähnlich, wie der Zugriff auf die Attribute normaler Weise verboten ist, wäre es natürlich auch schön, wenn wir bestimmte Methoden, wie den ggT, nur für den internen Gebracuh definieren könnten. Hierdurch ergäbe sich eine kleinere besser verständliche Schnitstelle der Klasse. Ruby bietet hierzu die Möglichkeit, bestimmte Methoden als private oder public zu deklarieren. Hierbei ist public der Default für alle Methoden. Durch das Schlüsselwort private werden alle folgenden Methodendefinitionen privat, bis entweder die Klassendefinition zu Ende ist oder das Schlüsselwort public kommt, wie wir an der finalen Version der FractionalKlasse sehen: class Fractional d e f i n i t i a l i z e ( z a e h l e r , nenner ) i f nenner==0 then p u t s ( ” Achtung D i v i s i o n durch 0 ! ” ) ; end ; g g t = g g t ( z a e h l e r , nenner ) ; @zaehler = z a e h l e r / ggt ; @nenner = nenner / g g t ; end ; private def ggt ( a , b) w h i l e a !=0 && b!=0 do i f a>b then a=a−b ; e l s e b=b−a ; 61 2 Programmierung end ; end ; i f a==0 then r e t u r n b ; e l s e return a ; end ; end ; public d e f nenner r e t u r n @nenner ; end ; def zaehler return @zaehler ; end ; def to s i f @nenner==1 then r e t u r n @ z a e h l e r . t o s ; else r e t u r n ( i f @zaehler >@nenner then ( @ z a e h l e r / @nenner ) . t o s+” ” e l s e ”” end + ( @ z a e h l e r % @nenner ) . t o s+” / ”+@nenner . t o s ) ; end ; end ; d e f +(o ) r e t u r n F r a c t i o n a l . new ( @ z a e h l e r ∗ o . nenner+o . z a e h l e r ∗ @nenner , @nenner ∗o . nenner ) ; end ; def ∗( o ) r e t u r n F r a c t i o n a l . new ( @ z a e h l e r ∗ o . z a e h l e r , @nenner ∗o . nenner ) ; end ; end ; x = F r a c t i o n a l . new ( 2 , 3 ) ; y = F r a c t i o n a l . new ( 5 , 6 ) ; p u t s ( x+y ) ; Als weiteres Beispiel wollen wir eine Klasse definieren, bei der es mutierende Methoden gibt: ein Konto. c l a s s Konto def i n i t i a l i z e ( betrag ) @kontostand = b e t r a g ; end ; 62 2.12 Mutierende und nicht mutierende Methoden def get kontostand r e t u r n @kontostand ; end ; d e f abheben ! ( b e t r a g ) @kontostand = @kontostand − b e t r a g ; end ; def einzahlen ! ( betrag ) @kontostand = @kontostand + b e t r a g ; end ; end ; Um den aktuellen Kontostand sehen zu können, benötigen wir wieder eine getter-Methode. Die Methoden abheben! und einzahlen! verändern den Zustand unserer Objekte, d.h. es wird keine neues Objekt konstruiert. Vielmehr wird unsere Attributvariable @kontostand verändert. Deshalb benennen wir diese Methoden auch, wie in Ruby üblich, mit einem Ausrufezeichen. k1 = Konto . new ( 2 0 0 ) ; k2 = Konto . new ( 5 0 ) ; k1 . abheben ! ( 5 0 ) ; p u t s ( k1 . g e t k o n t o s t a n d ) ; k2 . e i n z a h l e n ! ( 1 0 0 0 ) ; p u t s ( k2 . g e t k o n t o s t a n d ) ; # −−> 150 # −−> 1050 Das Beispiel verdeutlicht auch die Objektidentität unserer Konten. Wir könenn mehrere Konten anlegen, welche alle einzelnen, unabhängig von einander verändert werden können. ↑↑↑↑↑ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↑↑↑↑↑ 2.12 Mutierende und nicht mutierende Methoden Wir haben mit mutierende und nicht mutierende Methoden zwei unterschiedliche Programmierstile kennen gelernt, welche in der objektorientierten Programmierung kombiniert werden. Es zeigt sich aber, dass die Kombination der beiden Ansätze neue Gefahren birgt, wo man sie bisher eigentlich nicht vermutet hat. Als Beispiel wollen wir ein Programm schreiben, welches zu einer eingegebenen Zeichenreihe ein Palindrom bildet. Hierzu definieren wir eine Hilfsfunktion rev, welche einen String umdreht (solch eine Funktion ist natrürlich schon vordefiniert, wir wollen Sie aber hier selber definieren um an diesem einfachen Beispiel das Problem zu analysieren). Danach können wir dann den String und seine umgedrehte Variante konkatenieren und erhalten das passende Palindrom. def rev ( s t r ) f o r i i n 1 . . s t r . l e n g t h −1 do s t r = s t r [ i , 1 ] + s t r [ 0 , i ]+ s t r [ i +1, s t r . l e n g t h ] end return str end 63 2 Programmierung puts ( ” B i t t e gib e i n e Z e i c h e n r e i h e ein : ” ) s t r = g e t s . chop pal = rev ( s t r ) pal [ 0 , 0 ] = s t r p u t s ( ”Das zu ”+s t r+” p a s s e n d e Palindrom l a u t e t : ”+p a l ) Bei der Funktion rev entschließen wir uns eine nicht mutierende-Variante zu programmieren. Diese sieht zwar anders aus, als unsere bisherigen Varianten, scheint aber durchaus korrekt zu sein, wie auch einige Testläufe (Vorlesung) zeigen. Die Idee ist, dass wir im Rumpf der Schleife einen neuen String zusammen stetzen, der aus dem Zeichen an der i-ten Position, gefolgt von den Zeichen vor dem i-ten Zeichen und den Zeichen hinter dem i-ten Zeichen zusammen gebaut wird. Für das Zusammenfügen der beiden Strings in der Variablen pal verwenden wir eine mutierende Variante, welche den String str ind den String pal vorne einfügt. Führen wir das Programm aus, verhält es sich auch tatsächlich, wie wir es erwarten würden: Bitte gib eine Zeichenreihe ein : ot Das zu o t p a s s e n d e Palindrom l a u t e t : o t t o und Bitte gib eine Zeichenreihe ein : ruby Das zu ruby p a s s e n d e Palindrom l a u t e t : rubyybur Wenn wir aber eine Zeichenreihe der Länge eins eingeben geschieht das folgende: Bitte gib eine Zeichenreihe ein : a Das zu aa p a s s e n d e Palindrom l a u t e t : aa Auch der Inhalt der Variablen str wurde durch die Mutation des Objekts in der Variablen pal verändert. Der Grund ist, dass die Funktion rev im Fall eines Strings der Längen null oder eins, einfach sein Argument (str) unverändert zurück gibt. Dies bedeutet dann natürlich, dass beide Variablen in diesen Fällen genau auf das gleiche Objekt verweisen. Wird dann eines der beiden Objekte verändert, ändert sich das andere Objekt mit. Es zeigt sich also, dass wir auch bei der Programmierung von nicht mutierenden Funktionen und Methoden, sehr vorsichtig vorgehen müssen, wenn im System weitere mutierende Methoden für denselben Datentypen existieren. Untersucht man die anderen nicht mutierenden Methoden für Strings, so sieht man, dass Ruby hierbei tatsächlich vorsichtiger vorgeht, wie die folgenden Beispiele zeigen: s t r = ” abc ” s t r 1 = s t r+” ” # s t r = s t r ∗1 o d e r s t r = ””+ s t r o d e r s t r . r e v s t r [ 1 , 1 ] = ”” puts ( s t r ) # p u t s ( s t r 1 ) # b l e i b t immer u n v e r a e n d e r t 64 2.13 Reguläre Ausdrücke Der String str1 bleibt also in allen Fällen von der Veränderung des Objekts in str in der dirtten Zeile unverändert. Alle verwendenten nicht mutierenden Methoden der Klasse String liefern eine Kopie ihres Parameterobjekts. Für unser Programm bedeutet dies also, dass wir dafür sorgen müssen, dass unsere Funktion rev auch für die Fälle, in denen der übergebene String eine Länge kleiner gleich eins hat, sein Argument kopiert. Objektorientierte Programmiersprachen bieten hierzu meist Methoden zum Klonen von Objekten an: clone. Diese Operation können wir in der ersten Zeile unseres Programms verwenden, um die Definition von rev zu korrigieren: def rev ( s t r ) str = str . clone f o r i i n 1 . . s t r . l e n g t h −1 do s t r = s t r [ i , 1 ] + s t r [ 0 , i ]+ s t r [ i +1, s t r . l e n g t h ] end return str end Dann erhalten wir auch für Eingaben der Länge eins ein korrektes Ergebnis Bitte gib eine Zeichenreihe ein : a Das zu a p a s s e n d e Palindrom l a u t e t : aa Bei der Programmierung von nicht mutierenden Methoden muss man also folgendes beachten: Falls es für den gleichen Datentypen auch noch mutierende Methoden gibt, müssen die nicht mutierenden Methoden für alle Eingaben neue Objekte als Ergebnis liefern, d.h. sie dürfen keine übergebenen Objekte unkopiert zurückgeben. Funktionen und Methoden, die sich an diese Regel nicht halten, müssen als inkorrekt angesehen werden. 2.13 Reguläre Ausdrücke Ähnlich wie die EBNF sind reguläre Ausdrücke ein mächtiges Mittel zur Sprachbeschreibung, wenngleich sie doch weniger ausdrucksstark sind. Mit Hilfe von regulären Ausdrücken können, sehr einfach Teilausdrücke, aber auch bestimmte Muster von Zeichenfolgen in Zeichenreihen gefunden werden. Ein regulärer Ausdruck wird in Ruby durch /-Symbole begrenzt. Reguläre Ausdrücke sind auch Werte, d.h. sie können auch in Variablen und Datenstrukturen gespeichert werden. Die einfachsten reguläre Ausdrücke sind Zeichenfolgen, z.B. /cd/ oder /Hallo/. Mit Hilfe der Methode match können reguläre Ausdrücke gegen Strings gematcht werden. Dies bezeichnet man auch als Pattern Matching, da der reguläre Ausdruck als Muster (engl. Pattern) in einem String gesucht wird (engl. match). Das Ergebnis des Matchings ist ein MatchData-Objekt, welches Informationen zum erfolgreichen Match enthält. Falls der reguläre Ausdruck an keiner Position des Srings matcht, liefert der Aufruf der match-Methode das Ergebnis nil : m1 = / cd / . match ( ” abcdcd ” ) \ \ p u t s (m1) # −> <MatchData ” cd”> m2 = / c e / . match ( ” a b c d e f ” ) \ \ p u t s (m2) # −> n i l 65 2 Programmierung Das MatchData-Objekt enthält eine Reihe von Informationen zum gematchten String: ◦ to s gibt an, welcher String gematcht wurde ◦ pre match gibt den String vor dem gematchten String an ◦ post match gibt den String hinter dem gematchten String an Über die Länge von pre match erhalten wir auch die Position, an welcher gematcht wurde. Diese Funktionen erscheint im Moment noch überflüssig, da wir bisher nur Teilstrings gesucht haben. Es wird aber nützlich sein, wenn allgemeinere Muster formuliert werden können, welche ggf. auch unterschiedliche Teilstrings matchen können. Als erstes allgemeineres Beispiel betrachten wir den regulären Ausdruck, der auf alle Ziffern passt \d: / c \dd / . match ( ” a b c 7 d e f ” ) . t o s ; ” c7d ” In diesem Beispiel ist schon der gemathcte String interessant, da er z.B. aus einer Eingabe kommen kann. Für andere Klassen von Zeichen gibt es entsprechende reguläre Ausdrücke, z.B. für Whitespaces: / c \ sd / . match ( ” abc d e f ” ) . t o s / c \ sd / . match ( ” abc \ n d e f ” ) . t o s / c \ sd / . match ( ” abc \ n d e f ” ) ; ” c d” ; ” c d” ; nil Mit \w können Buchstaben und Ziffern gematcht werden: / c \we / . match ( ” a b c d e f ” ) . t o s / c \wd / . match ( ” a b c 3 d e f ” ) . t o s / c \ sd / . match ( ” abc ( de ) f ” ) ; ” cde ” ; ” c3d ” ; nil Der Punkt . steht für ein beliebiges Zeichen: / c . e / . match ( ” a b c d e f ” ) . t o s ; ” cde ” Außerdem ist es möglich für einzelne Buchstabe Bereiche anzugeben, z.B. [p − t] oder [a − zA − Z] oder [0 − 9] oder konkrete mögliche Buchstaben aufzuzählen, z.B. [ruby]: / [ c−d ] / . match ( ” a b c d e f ” ) . t o s ; ”c” / [ c−d ] [ c−d ] / . match ( ” a b c d e f ” ) . t o s ; ” cd ” / [ ruby ] / . match ( ” a b c d e f ” ) . t o s ; ”b” Außerdem können bestimmte Buchstaben oder Bereiche ausgenommen werden: / [ ˆ ab ] / . match ( ” a b c d e f ” ) . t o s ; ”c” Es ist auch möglich, Alternativen zu definieren: / c | d / . match ( ” a b c d e f ” ) . t o s ; ”c” / c | d / . match ( ” abCdef ” ) . t o s ; ”d” / Hi | H a l l o / . match ( ” Hi W i l l i ” ) . t o s ; ” Hi ” ; ” Hallo ” / Hi | H a l l o / . match ( ” H a l l o W i l l i ” ) . t o s Der Alternativen-Operator | bindet hierbei schwächer als die Hintereinanderschreibung von Buchstaben, so dass 66 2.13 Reguläre Ausdrücke /Hi|Hallo/ identisch mit /(Hi)|(Hallo)/ ist, aber eben nicht identisch mit /H(i|H)allo/. Mit Hilfe von Klammern können reguläre Ausdrücke auch strukturiert werden, z.B.: /b ( c | x ) d / . match ( ” a b c d e f ” ) . t o s /b ( c | x ) d / . match ( ” a b x d e f ” ) . t o s ; ” bcd ” ; ”bxd” Was bedeutet dieser reguläre Ausdruck: /a(|x)b/? /a ( | x ) b / . match ( ”ab” ) . t o s /a ( | x ) b / . match ( ” axb ” ) . t o s ; ”ab” ; ” axb ” Links vom | steht hier der leere reguläre Ausdruck (entspricht dem leeren Wort), welcher auf den String der Länge Null match. So kann man also einfach ausdrücken, dass etwas (beschrieben durch einen regulären Ausdruck) optional ist. Alternativ kann man hierfür auch das ? als nachgestellten Operator (Postfix) verwenden: / cd ? e / . match ( ” a b c d e f ” ) . t o s / cx ?d / . match ( ” a b c d e f ” ) . t o s / ( cd ) ? e / . match ( ” a b c d e f ” ) . t o s / ( cx ) ? e / . match ( ” a b c d e f ” ) . t o s ; ; ; ; ” cde ” ” cd ” ” cde ” ”e” Beachte, dass ? stärker bindet, als das Hintereinanderschreiben, sich also nur auf den letzten regulären Ausdruck vor dem ? bezieht (oft Buchstabe): /ab?c/ ist also idetisch mit /a(b?)c/, aber eben nicht identisch mit /(ab)?c/. Abschließend kann man noch wie bei der EBNF Wiederholungen (+) oder optionale Wiederholungen (∗) zulassen. Man beachte, dass ein Stern in Postfixnotation anstelle geschweifter Klammern wie in der EBNF verwendet wird: / cx ∗d / . match ( ” a b c d e f ” ) . t o s / cx+d / . match ( ” a b c d e f ” ) / cx+d / . match ( ” a b c x x x d e f ” ) . t o s ; ” cd ” ; nil ; ” cxxxd ” Zusätzlich zum Überprüfen, ob ein regulärer Ausdruck passt und an welcher Stelle er passt möchte man häufig auch noch weitere informationen erhalten. Bsp.: Gegeben ist ein String str der Form “Name: Telefonnummer\n Name: Telefonnummer\n“ (z.B.: “Frank: 7277\n Christoph Daniel: 7297\n“) Nun suchen wir Christoph Daniels Telefonnummer. e i n t r a g = / C h r i s t o p h D a n i e l : [0 −9]+/. match ( s t r ) . t o s ; ” C h r i s t o p h D a n i e l : 7297 ” Hieraus können wir nun die Telefonummer extrahieren, z.B. wieder mit einen regulären Ausdruck: /[0 −9]+/. match ( e i n t r a g ) . t o s ; ” 7297 ” Eine Alternative und pft noch komfortablere Möglich, dieses Problem zu lösen bilden sogenannte capture groups. Die Idee ist, dass bei Matchen des regulären Ausdrucks, auch 67 2 Programmierung die Information vorgehalten wird, gegen welche Teilstrings die verwendeten geklammerten Telausdrücke gematcht wurden. So können wir im Beispiel einfach die Telefonnummer im regulären Ausdruck klammern: e i n t r a g = / C h r i s t o p h D a n i e l : ( [ 0 − 9 ] + ) / . match ( s t r ) Im nächsten Schritt können wir dann die Telefonnummer einfach an einem Index abfragen: telefonnummer = e i n t r a g [ 1 ] Die Idee ist, dass alle capture groups von links nach rechts durchnummeriert werden und das Matchobjekt diese an den entsprechenden Indizes zur Verfügung stellt. In der Realisierung leifert ein MatchData-Objekt eine Array, welches dann indiziert wird. Arrays werden wir im nächsten Kapitel genauer kenen lernen und dann sicherlich auch die Notation besser verstehen können. Das folgende Beispiel mit mehreren capture groups verdeutlicht die Indizes, über welche anschließend auf die Teilmatches zugegriffen werden kann: Bsp.: 1 23 4 ↓ ↓↓ ↓ / ( a | b ) ( ( b | a ) + ) ( . . ) / . match ( ” abbbacbb ” ) [ i ] liefert für die folgenden Position i: 1 belegt mit “a“ 2 belegt mit “bbba 3 belegt mit “a“ //Der letzte String, der (b|a) gematcht hat 4 belegt mit “cb“ Als etwas komplexeres Beispiel wollen wir in einem Text ein Kennzeichen suchen. Kennzeichehn können wir mit dem folgenden regulären Ausdruck definieren. r e g e x p k e n n z e i c h e n = / [A−Z ] [ A−Z ] ? [ A−Z]? −[A−Z ] [ A−Z ] ? [ 1 − 9 ] [ 0 − 9 ] ∗ / Dann könenn wir z.B. einen Text aus einer Datei einlesen und alle vorkommenen AutoKennzeichen ausgeben: s t r = IO . r e a d ( ” d a t e i mit k e n n z e i c h e n . t x t ” ) m = r e g e x p k e n n z e i c h e n . match ( s t r ) w h i l e m!= n i l do p u t s (m) # t o s koennen w i r uns h i e r sparen , # da d i e s durch p u t s gemacht wird s t r = m. post match m = r e g e x p k e n n z e i c h e n . match ( s t r ) end Mit der String-Methode sub ist es außerdem möglich, das erste (maximale) Matching eines regulären Ausdrucks durch einen String zu ersetzen: Bsp.: ” abcbcabcbcbc ” . sub ( / ( bc )+/ , ”x” ) ; ” axabcbcbc ” Mit gsub können entsprechend alle passenden Substrings ersetzt werden: 68 2.13 Reguläre Ausdrücke ” abcbcabcbcbc ” . gsub ( / ( bc )+/ , ”x” ) ; ” axax ” Beachte, dass hierbei das Ergebnis nicht wieder gematcht wird: ” abcbcabcbcbc ” . gsub ( / ( bc )+/ , ” bc ” ) ; ” abcabc ” sub und gsub erlauben auch Strings anstelle eines regulären Ausdrucks. Reguläre Ausdrücke werden z.B. auch in Textverarbeitungssystemen eingesetzt. In der Vorlesung schauen wir uns hierzu den Suchen und Ersetzen Mechanismus von Komodo-edit genauer an. ↓↓↓↓↓ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↓↓↓↓↓ 2.13.1 Backtracking Im Folgenden wollen wir verstehen, wie Ruby reguläres Ausdrucks-Matching implementiert. Zunächst versuchen wir hierbei nicht einen passenden Teilstring zu suchen, sondern beschränken uns auf die Überprüfung, ob ein Anfangsstück eines Strings einen festen regulären Ausdruck matcht. Die Idee ist es, den String und den regulären Ausdruck stückweise zu vergleichen. Falls Alternativen existieren, probiere beide nacheinander aus. Diesen Lösungsansatz bezeichnet man als Backtracking. Er kann zur Lösung vieler Suchprobleme verwendet werden. Um zu entscheiden, ob ein regulärer Ausdruck match, müssen wir uns immer die Position im String merken, an der wir den nächsten Buchstaben matchen wollen, also die Position, bis zu der wir bereits erfolgreich gematcht haben. Als Beispiel betrachten wir das Matching /a ( b | c ) ( bb | b ) / . match ( ”abb” ) Wir starten mit der Position 0 und dem gesamten regulären Ausdruck, was wir in der folgenden Konfiguration notieren: 0, /a(b|c)(bb|b)/ 0, /a(b|c)(bb|b)/ 1, /(b|c)(bb|b)/ 1, /b(bb|b)/ 1, /c(bb|b)/ 2, /(bb|b)/ 2, /bb/ 2, /b/ 3, /b/ 3, // Der Baum enthält drei Blätter: ein erfolgreiches (3, //)und zwei nicht erfolreiche Matchings. Als weiteres Beispiel betrachten wir folgendes Matching: / ( a | b ) ∗ b / . match ( ”ab” ) Hier stellt sich insbesondere für den Stern die Frage, wie wir diesen überprüfen können. Wir nutzen folgende Äquivalenz aus: /α ∗ / = /|αα ∗ /, d.h. der reguläre Ausdruck α wird 69 2 Programmierung entweder kein Mal gemacht oder einmal gefolgt von einer optionalen Wiederholung von α. So kann man den Stern während der Berechnung abwickeln“: ” 0, /(a|b) ∗ b/ 0, /(|(a|b)(a|b)∗)b/ 0, /(a|b)(a|b) ∗ b/ 0, /b/ 0, /a(a|b) ∗ b/ 0, /b(a|b) ∗ b/ 1, /(a|b) ∗ b/ 1, /(|(a|b)(a|b)∗)b/ 1, /b/ 1, /(a|b)(a|b) ∗ b/ 2, // 1, /a(a|b) ∗ b/ 1, /b(a|b) ∗ b/ 2, /(a|b) ∗ b/ 2, /(|(a|b)(a|b)∗)b/ 3, /b/ 2, /(a|b)(a|b) ∗ b/ 2, /a(a|b) ∗ b/2, /b(a|b) ∗ b/ Die Blätter des Baumes stellen mehrere fehlgeschlagene Matches (0, /b/, 1, /a(a|b) ∗ b/, 2, /b/, usw.), sowie einen erfolgreichen Match (2, //) dar. Bei den fehlgeschlagenen Matches ist es uns nicht gelungen, den gesamten regulären Ausdruck zu matchen. Hier passt der nächste Buchstabe im String nicht auf den nächsten geforderten Buchstaben im regulären Ausdruck. Im Erfolgsfall konnte der gesamte Reguläre Ausdruck gematcht werden. Es bleibt also der leere reguläre Ausdruck // übrig, welcher natürlich immer matcht. Unter mehreren solchen Matches muss man also den längsten erfolgreichen Match suchen. Formal können wir die Konfigurationen und ihre Ableitungsschritte wie folgt definieren. Den String gegen den wir matchen, nehmen wir dabei als Konstant an. Er stehe in der Variablen str zur Verfügung: ◦ Eine Konfiguration besteht aus einer Position und einem regulären Ausdruck. ◦ Die Startkonfiguration lautet: 0, /α/, wobei α der zu matchende reguläre Ausdruck ist. ◦ Buchstabenübergang: i, /aα/ =⇒ i + 1, /α/, falls str[i, 1] = a. 70 2.13 Reguläre Ausdrücke ◦ Alternative: i, /(α|β)γ/ =⇒ i, /αγ/ und i, /(α|β)γ/ =⇒ i, /βγ/. ◦ Optionale Wiederholung: i, /α ∗ β/ =⇒ i, /(|αα∗)β/. ◦ Endkonfiguration: i, //, wobei i die Position hinter dem letzten gematchten Zeichen bezeichnet. Bei der Alternative haben wir zwei mögliche Nachfolger, was man auch als Nichtdeterminismus bezeichnet. An diesen Stellen werden wir später beide Möglichkeiten der Reihe nach ausprobieren und den längeren Match als Ergebnis verwenden. Bei der optionalen Wiederholung, wickeln wir die Wiederholung ab und führen diese auf die Alternativ aus keiner Wiederholung und der mindestens einmaligen Wiederholung zurück. Hierbei müssen wir natürlich berücksichtigen, dass auch der reguläre Ausdruck hinter der Alternative (β) noch gematcht werden muss. In der Endkonfiguration können wir erkennen, dass der reguläre Ausdruck komplett gematcht wurde. Mit Hilfe der Startposition (im Moment vereinfacht 0) und der Position in der Endkonfiguration können wir außerdem noch bestimmen: ◦ gematchter String: . to s = str [0, p] ◦ String hinter gematchtem String: .post match = str[p,str .length] Den String vor dem gematchten String können wir zur Zeit noch nicht bestimmen, da wir das Matching immer vom Anfang des Strings ausführen. Später mehr hierzu. Nun stellt sich die Frage, wie wir diesen Nichtdeterminismus mit einem deterministischen Algorithmus auflösen können. Eine mögliche Strategie hierzu ist Backtracking. Hierzu durchlaufen wir den Baum aller möglichen Konfigurationen von links nach rechts und suchen die längste matchende Konfiguration: Beachte hierbei, dass man nicht den gesamten Baum als Struktur behält, sondern immer nur dem linkesten Pfad folgt und sich Alternativen merkt, wofür man einen Stack von Alternativen betrachtet. Also: 71 2 Programmierung k0 ⇒ k1 | k2 ⇒ k6 | k7 | k2 ⇒ k7 | k2 ⇒ k8 | k9 | k2 ⇒ k9 | k2 ⇒ k2 ⇒ k3 | k4 | k5 ⇒ k4 | k5 ⇒ k5 In vielen Anwendungen von Backtracking reicht es aus eine Endkonfiguration zu suchen und danach die Suche abzubrechen. Da wir hier aber das längste Matching suchen, müssen wir den gesamten Suchraum nach Lösungen absuchen und die Längste finden. Es bleibt aber noch ein kleines Problem für die optionale Wiederholung, wie folgendes Beispiel /a?∗/.match(”a”) zeigt: 0, /(|a) ∗ / ⇒ 0, /(|a)(|a) ∗ / ⇒ 0, /(|a) ∗ / | 0, /a(|a) ∗ / ⇒ . . . unendliche Berechnung Dieses Problem kann man auch allgemein lösen, hier gehen wir aber vereinfacht davon aus, dass α bei α∗ nicht das leere Wort matcht. Nun wollen wir noch über eine Implementierung in Ruby nachdenken. Allgemein kann Backtracking mit Hilfe von Rekursion realisiert werden. Hierbei verwenden wir den Laufzeitkeller von Ruby (Keller, auf dem die Funktionsaufrufe gespeichert werden), zur Implementierung des Stacks. Wir können für jeden Teilausdruck eines regulären Ausdrucks eine Funktion definieren, die in einem gegeben String versucht diesen Teilausdruck zu matchen. Die unterschiedlichen Fälle der regulären Ausdrücke können dabei nach festen Schemata behandelt werden. Als Parameter verwendet diese Funktion, analog zu unserer Konfiguration, die aktuelle Matchingposition. Der reguläre (Teil-)Ausdruck selber ist kein Parameter, da zu jedem Teilausdruck genau eine Funktion definiert wird, welche diesen Teilausdruck matchen kann. Wir annotieren dies, indem wir den regulären Ausdruck im Funktionsnamen mitkodieren. Da in Ruby Funktionen nicht auf globale Werte (Variablen) zugreifen können, müssen wir zusätzlich auch noch den zu matchenden String übergeben. Für jeden auftretenden Teilausdrucks5 eines regulären Ausdruck der Form ‘/aα/‘ definieren wir eine Funktion nach folgendem Muster: 5 Es handelt sich im strengen Sinne nicht nur um Teilausdrücke, sondern Audrücke, welche sich innerhalb der Konfigurationsübergänge aus einem gegebenen Ausdruck ergeben. Dies sind aber immer endlich viele, so dass wir für jeden eine entsprechende Funktion definieren können. 72 2.13 Reguläre Ausdrücke d e f match aα ( p , s t r ) i f s t r [ p,1]== ” a ” then r e t u r n match α ( p+1, s t r ) ; e l s e return n i l ; end ; end ; Entsprechende Regeln können auch für die anderen möglichen Reguläre Ausdrücke, also ‘/(α|β)γ/‘, ‘/α ∗ β/‘ und ‘//‘ definiert werden (Übungsaufgabe). Hier wollen wir dies exemplarisch nur für den regulären Ausdruck /‘a(b|c)‘/ betrachten. Da in Ruby in Funktionsnamen keine Sonderzeichen erlaubt sind, schreiben wir in den Funktionsdefinitionen O anstelle des ‘|‘, S anstelle des ‘*‘ und lassen Klammern weg. Der eigentlich Funktionsname spielt keine Rolle und wir könnten die Funktionen auch einfach durchnummerieren. Er dient aber dem besseren Verständnis, welcher Ausdruck gematcht werden soll: d e f max( p1 , p2 ) i f p1==n i l then r e t u r n p2 e l s e i f p2==n i l then r e t u r n p1 e l s e i f p1<p2 then r e t u r n p2 e l s e r e t u r n p1 end end end end d e f match abOC ( p , s t r ) # / a ( b | c ) / i f s t r [ p,1]== ” a ” then r e t u r n match bOc ( p+1, s t r ) ; e l s e return n i l ; end ; end ; d e f match bOc ( p , s t r ) # /b | c / p1 = match b ( p , s t r ) ; p2 = match c ( p , s t r ) ; r e t u r n max( p1 , p2 ) ; end ; d e f match b ( p , s t r ) # /b/ i f s t r [ p,1]== ”b” then r e t u r n match ( p+1, s t r ) ; e l s e return n i l ; end ; end ; d e f match c ( p , s t r ) # / c / i f s t r [ p,1]== ” c ” then r e t u r n match ( p+1, s t r ) ; e l s e return n i l ; end ; 73 2 Programmierung end ; d e f match ( p , s t r ) return p ; end ; # // Bei der Umsetzung des Oderübergangs, werden beide Alternativen untersucht und anschließend das Maximum der Positionen bestimmt. Hierbei verwenden wir eine eigene Maximumsfunktion, welche auch nil-Werte berücksichtigt. Die zweite Alternative wird hierbei erst berechnet, nachdem alle Möglichkeiten der ersten Alternative ausprobiert wurden. Dies entspricht der Speicherung der zweiten Alternative auf einem Stack, hier konkret der Laufzeitkeller. Als zweites Beispiel betrachten wir noch einen regulären Ausdruck, der auch eine optionale Wiederholung enthält: d e f match aObSb ( p , s t r ) # / ( a | b ) ∗ b/ = / ( | ( a | b ) ( a | b ) ∗ ) b/ p1 = match b ( p , s t r ) ; # /b/ p2 = match aObaObSb ( p , s t r ) ; # / ( a | b ) ( a | b ) ∗ b/ r e t u r n max( p1 , p2 ) ; end ; d e f match b ( p , s t r ) # /b/ i f s t r [ p,1]== ”b” then r e t u r n match ( p+1, s t r ) ; e l s e return n i l ; end ; end ; d e f match ( p , s t r ) # // return p ; end ; d e f match aObaObSb ( p , s t r ) # / ( a | b ) ( a | b ) ∗ b/ p1 = match aaObSb ( p , s t r ) ; # / a ( a | b ) ∗ b/ p2 = match baOb∗b ( p , s t r ) ; # /b ( a | b ) ∗ b/ r e t u r n max( p1 , p2 ) ; end ; d e f match aaObSb ( p , s t r ) # / a ( a | b ) ∗ b/ i f s t r [ p ,1]= ”a” then r e t u r n match aObSb ( p+1, s t r ) ; # / ( a | b ) ∗ b/ e l s e return n i l ; end ; end ; d e f match baObSb ( p , s t r ) # /b ( a | b ) ∗ b/ i f s t r [ p,1]== ”b” then r e t u r n match aObSb ( p+1, s t r ) ; # / ( a | b ) ∗ b/ e l s e return n i l ; end ; end ; 74 2.13 Reguläre Ausdrücke Bei der Definition der Funktion für den Stern, verzichten wir auf den Zwischenknoten, da der Sternausdruck und seine Abwicklung äquivalent sind und in dem Zwischenschritt nichts passieren würde. Beachte, dass in den Funktionen match aaObSb und match baObSb erneut die Funktion match aObSb aufgerufen wird. Bei der Umsetzung des Sterns also Rekursion verwendet wird. Um einen regulären Ausdruck nicht nur an der ersten Position gegen einen String zu matchen, können wir vor diese Funktionen noch ein Schleife setzen, welche nach und nach das Matching des regulären Ausdrucks an alle Positionen im String ausprobiert. Der linkeste (und dabei längste Match) ist dann das Ergebnis, welches wir hier einfach ausgeben. d e f start match aObSb ( s t r ) # / ( a | b ) ∗ b/ p = 0; matchPos = match aObSb ( p , s t r ) ; w h i l e matchPos==n i l && p<s t r . l e n g t h −1 do p = p+1; matchPos = match aObSb ( p , s t r ) ; end ; p u t s ( ” . t o s l i e f e r t ” + s t r [ p , matchPos−p ] ) ; p u t s ( ” . pre match l i e f e r t ” + s t r [ 0 , p ] ) ; p u t s ( ” . post match l i e f e r t ” + s t r [ matchPos , s t r . l e n g t h ] ) ; p u t s ( ” e r s t e Matching P o s i t i o n ” + p . t o s ) ; end ; In Ruby liefert diese Methode ein MatchData-Objekt als Ergebnis, in welchen alle diese Informationen vorgehallten werden und mittels der getter-Methoden to s , pre match, post match abgerufen werden können. ↑↑↑↑↑ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↑↑↑↑↑ 75 3 Datenstrukturen und Algorithmen Bisher haben wir nur vordefinierte Datentypen kennengelernt. Oft ist es aber auch wichtig mehrere Werte zu neuen zusammengesetzten Werten zu machen. Ein gängiger Datentyp in imperativen Sprachen ist das Array, welches mehrere Werte in einem Speicherbereich hintereinander repräsentiert und die einzelnen Werte über einen Index adressierbar macht. 3.1 Arrays Darstellung: [4, 6, 3, 5, 7, 0, 2] Auf einen Index kann man mittels a[i] zugreifen, wobei a mit einem Array belegt und i ein Index ist. Arrays können mittels a[i] = auch verändert werden. Ein neues Array kann mit folgenden Methoden konstruiert werden: Array.new(n) ; [nil, nil, ..., nil] | {z } n−mal oder Array.new(n, v) ; [v, v, ..., v ] | {z } n−mal Außerdem ist es möglich, kleine Arrays konkret anzugeben, was aber natürlich nur bei kleinen Arrays praktikabel ist: a = [1, 2, 3, 4, 5, 6]; Als erstes Programm wollen wir eine Funktion definieren, welche ein Array von Zahlen nimmt und die Summe der Zahlen berechnet: d e f sum array ( a ) sum = 0 ; f o r i i n 0 . . a . s i z e ( ) − 1 do # Methode zum Bestimmen d e r A r r a y g r o e s s e sum = sum + a [ i ] ; end ; r e t u r n sum ; end ; p u t s ( sum array ( [ 1 , 2 , 3 , 4 , 5 ] ) ) ; # −> 15 76 3.1 Arrays Nun wollen wir eine Funktion definieren, die zwei Zahlen n und m nimmt und ein Array mit den Zahlen n, n + 1, . . . , m zurückgibt: d e f f r o m t o ( n ,m) a = Array . new (m−n +1); f o r i i n 0 . . m−n do a[ i ] = n; n = n+1; end ; return a ; end ; Die Einträge im Array werden durch die Anweisung a[ i ] = n gesetzt. Hierbei handelt es sich nicht um eine Zuweisung. Vielmehr verwenden wir die mutierende Methode []= zur Modifikation des Array-Objekts. Sie verhält sich sehr ähnlich, wie die entsprechende Methode bei Strings, allerdings wird nur ein Element verändert, so dass es keinen zweiten Parameter für die Länge des zu ersetzenden Teils gibt. Beachte aber, dass Arrays, genau wie Strings, Objekte sind. Veränderungen an den Arrays werden in der Regel durch die mutierende Methoden []= vorgenommen. Als Test für unser Programm wollen wir einmal ein generiertes Array ausgeben: puts ( from to ( 3 , 7 ) ) ; Wir erhalten die folgende Ausgabe: 3 4 5 6 7 Diese Ausgabe hätten wir so nicht erwartet. Der Grund für diese ungewöhnliche Ausgabe ist, dass puts in Ruby so definiert ist, dass alle Elemente eines Arrays einzeln, aber jeweils mit einem Zeilenumbruch, ausgegeben werden. Entsprechend gibt print alle Werte eines Arrays ohne Zeilenumbruch aus, was meistens auch nicht sehr nützlich ist: p r i n t ( [ 3 , 4 , 5 , 6 , 7 ] ) ; ; 34567 Besser ist die Verwendung von p(..), das Arrays genau so ausgibt, wie sie auch in Ruby definiert werden können: p([3 ,4 ,5 ,6 ,7]); ; [3 ,4 ,5 ,6 ,7] Als nächstes Beispiel wollen wir das Maximum aller Elemente eines Arrays von Zahlen bestimmen: d e f max( a ) max = a [ 0 ] ; f o r i i n 1 . . a . s i z e () −1 do i f a [ i ]>max then max = a [ i ] ; end ; end ; r e t u r n max ; 77 3 Datenstrukturen und Algorithmen end ; p u t s (max ( [ 3 , 4 , 7 , 5 ] ) ; # −> 7 Vergleicht man Arrays mit Strings, so fallen gewissen Ähnlichkeiten auf. Strings entsprechen Arrays von Zeichen und so ist es auch bei Strings möglich, einzelne Zeichen zu selektieren. Hierbei ist aber darauf zu achten, dass das Ergebnis des Selektierens einzelner Zeichen je nach Ruby-Version entweder einen Character (genauer Strings der Länge eins) oder den ASCII-Wert des Characters liefert. Umgekehrt können in Ruby auch bei Arrays Teilarrays selektiert werden oder zwei Arrays mittels + konkateniert werden: p([1 ,2 ,3]+[4 ,5]); ; [1 ,2 ,3 ,4 ,5] p u t s ( ” H a l l o ” [ 1 ] ) ; ; ” a ” # bzw . 97 i n Ruby 1 . 8 p([1 ,2 ,3 ,4 ,5][2 ,3]); ; [3 ,4 ,5] Außerdem können auch Teilarrays modifiziert werden: a = [1 ,2 ,3 ,4 ,5]; a [2 ,1] = [42 ,43 ,44]; p(a ) ; ; [1 ,2 ,42 ,43 ,44 ,4 ,5] Funktionen zum Selektieren, Zusammenfügen oder Ersetzen von Teilarrays sind nicht in allen Programmiersprachen verfügbar. In der Regel verwendet man nur Operationen, zum Nachschlagen oder Ersetzen einzelner Einträge des Arrays, da diese sehr effizient implementiert werden können. Hierzu später noch mehr. Bisher haben wir Arrays in der Regel dazu verwendet, gleichartige Daten zusammenzufassen. Dies ist in Ruby aber nicht notwendig. Wir können auch unterschiedliche Arten von Daten in einem Array zusammenfassen. Dann sollten wir uns aber darüber im Klaren sein, an welcher Stelle wir welche Art von Daten abgelegt haben, um diese später sinnvoll verwenden zu können. Als Beispiel können wir ein Array verwenden um z.B. ein Tripel bestehend aus einem Namen (String), der Größe der Person (Zahl) und ihrer aktuellen Anwesenheit (booleschen Wert) zu definieren: p e r s o n 1 = [ ” Frank ” , 1 8 8 , t r u e ] ; person2 = [ ” Christoph Daniel ” ,186 , f a l s e ] ; i f p e r s o n 1 [1] > p e r s o n 2 [ 1 ] then p u t s ( p e r s o n 1 [ 0 ] + ” i s t g r o e s s e r a l s ”+p e r s o n 2 [ 0 ] ) ; e l s e p u t s ( p e r s o n 2 [ 0 ] + ” i s t g r o e s s e r a l s ”+p e r s o n 1 [ 0 ] ) ; end ; ↓↓↓↓↓ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↓↓↓↓↓ Eine alternative und sicherlich schönere Variante wäre natürlich die Definition einer eigenen Klasse P erson, mit entsprechenden Attributen, getter/setter-Methoden. ↑↑↑↑↑ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↑↑↑↑↑ Da Arrays selber auch wieder Werte darstellen, können wir tatsächlich auch wieder Arrays als Einträge in einem Array verwenden, wie folgendes Beispiel verdeutlicht: a = [ 2 , t r u e , [ 4 2 , [ 3 , ” H a l l o ” ] , ” Leute ” ] , 5 . 0 ] ; puts ( a . s i z e ) ; 78 # −> 4 3.1 Arrays puts ( a [ 0 ] ) ; puts ( a [ 1 ] ) ; p(a [ 2 ] ) ; puts ( a [ 3 ] ) ; # # # # −> −> −> −> 2 true [ 4 2 , [ 3 , ” H a l l o ” ] , ” Leute ” ] 5.0 puts ( a [ 2 ] [ 2 ] ) ; # −> ” Leute ” p u t s ( a [ 2 ] [ 1 ] [ 1 ] ) ; # −> ” H a l l o ” Wir können also durch mehrfache Array-Zugriffe auch nach und nach in der verschachtelten Array-Struktur absteigen. Beachte folgenden semantischen Unterschied beim Einfügen eines Arrays in eine Array: a = [1 ,2 ,3 ,4 ,5]; a [ 3 , 1 ] = [ 4 2 , 4 3 , 4 4 ] ; # Ersetzen eines Teilarrays a[2] = [0 ,0]; # E r s e t z e n e i n e s Elements durch e i n Array p(a ) ; ; [1 ,2 ,[0 ,0] ,3 ,42 ,43 ,44 ,5] Betrachten wir noch einmal das vorherige Beispiel der Personen. Wenn wir nicht nur zwei Personen speichern wollen, sondern beliebig viele, sollten wir anstelle zweier konkreter Personen besser ein Array von Personen verwenden: p e r s o n e n = [ [ ” Frank ” , 1 8 8 , t r u e ] , [ ” Fabian ” , 1 8 2 , f a l s e ] , [ ” Sandra ” , 1 6 2 , f a l s e ] ] ; Wie kann man solch ein Array von Arrays interpretieren? Es handelt sich um eine Tabelle, d.h. wir haben mehrere Zeilen, für die in jeder Spalte gleichartige Information steht, was man auch mit folgender Darstellung noch unterstreichen kann: p e r s o n e n = [ [ ” Frank ” , 1 8 8 , t r u e ] , [ ” Fabian ” , 1 8 2 , f a l s e ] , [ ” Sandra ” , 1 6 2 , f a l s e ] ] ; Solche Tabellen kennen wir auch schon aus Tabellenkalkulationen. Haben alle Einträge im Array die gleiche Struktur nennt man solche Arrays auch mehrdimensionale Arrays (hier speziell zweidimensional). Einige Programmiersprachen unterstützen mehrdimensionale Arrays explizit und sichern durch statische Typisierung auch zu, dass alle inneren Arrays die gleiche Struktur und Größe besitzen. In Ruby ist dies nicht der Fall und der Programmierer ist hierfür selber verantwortlich. Ein Austausch solcher Tabellen auch über verschiedene Programme hinweg, wäre natürlich wünschenswert. Hierzu verwendet man gerne Dateien des Formats comma separated values (csv), in welchem Tabellen zeilenweise in einer Datei gespeichert werden. Die einzelnen Spalten sind dann durch ein Trennsymbol, in der Regel ein Komma (in der Deutschen version von Tabellenkalkulationsprogrammen standardmäßig durch ein Semikolon) getrennt. In obigem Beispiel, sähe der zugehörige Dateiinhalt wie folgt aus: Frank , 1 8 8 , t r u e C h r i s t o p h Daniel , 1 8 6 , f a l s e Thomas , 1 7 8 , f a l s e Wie können wir nun solche Dateien in ein zweidimensionales Ruby-Array einlesen? Hierzu ist es hilfreich die vordefinierte String-Methode split zu verwenden. Die Seminat der Methode wird schnell an folgenden Beispielaufrufen klar: 79 3 Datenstrukturen und Algorithmen ”a , b , cd , e ” . s p l i t ( ” , ” ) ; [ ” a ” , ”b” , ” cd ” , ” e ” ] ” a b c d e f c d g h ” . s p l i t ( ” cd ” ) ; [ ”ab” , ” e f ” , ”gh” ] ”ab\ ncd \ n e f \n” . s p l i t ( ” \n” ) ; [ ”ab” , ” cd ” , ” e f ” ] Man beachte, dass im letzten Beispiel der leere String hinter dem Zeilenumbruch nicht als separater Eintrag ins Array eingefügt wird. Mit Hilfe der split -Methode können wir nun sehr einfach einen gegebenen String (der den Inhalt einer csv-Datei repräsentiert) zunächst in seine Zeilen und dann jede Zeile in die einzelnen Spalten zerlegen und so ein zweidimensionales Array konstruieren. Wir stellen die entsprechenden Funktion zum Einlesen direkt für eine csv-Datei zur Verfügung: def csv read ( filename ) s t r = IO . r e a d ( f i l e n a m e ) ; l i n e s = s t r . s p l i t ( ” \n” ) ; f o r i i n 0 . . l i n e s . s i z e −1 lines [ i ] = lines [ i ] . split (” ,” ); end ; return ( l i n e s ) ; end ; persons = csv read ( ” t e s t . csv ” ) ; min pos = 0 ; min = p e r s o n s [ 0 ] [ 1 ] ; f o r i i n 1 . . p e r s o n s . s i z e −1 do i f p e r s o n s [ i ] [ 1 ] < min then min = p e r s o n s [ i ] [ 1 ] ; min pos = i ; end ; end ; p u t s ( p e r s o n s [ min pos ] [ 0 ] + ” i s t am k u e r z e s t e n . ” ) ; Das Hauptprogram berechnet abschließend noch, wer die kürzeste Person aus der test-Datei ist. Um in Ruby berechnete Tabellen auch in einer anderen Anwendung nutzen zu können, müssen diese auch aus Ruby-Programmen abgespeichert werden können. Eine entsprechende Funktion csv write können wir ähnlich einfach definieren. Hierzu benötigen wir noch eine Funktion zum Schreiben von Dateien. In Ruby bietet die Klasse File die zweistellige Funktion write zum Schreiben eines Strings in eine Datei an. def csv write ( f i l e , a) s t r = ”” f o r i i n 0 . . a . s i z e −1 f o r j i n 0 . . a [ i ] . s i z e −2 str = str + a [ i ] [ j ] + ” ,” ; end ; s t r = s t r + a [ i ] [ a [ i ] . s i z e −1] + ” \n” ; end ; File . write ( f i l e , str ) 80 3.2 Datenbanken end ; Es gibt auch varianten von csv, bei denen als Separator der Spalten ein Semikolon anstelle eines Kommas verwendet wird (insb. in deutschsprachigen Anwendungen). Außerdem werden die Strings zwischen den Kommas oft auch in Aunführungszeichen gesetzt, um in diesen z.B. auch Kommas zu erlauben. Dies berücksichtigt unsere hier vorgenommene csv-Implementierung noch nicht. Vordefinierte Implementierungen sind hier aber flexibler und können in solchen Fällen ähnlich eifnach, wie die hier vorgestellten Funktionen benutzt werden. In der Vorlesung entwickeln wir außerdem ein etwas komplexeres Beispiel, mit welchem Veranstaltungen im Universitätsbetrieb verwaltet werden können. Hierbei verwenden wir Tabellen (zweidimensionale Arrays), welche wir als csv-Datei auf der Festplatte speichern und so auch in einer Tabellenkalkulation bearbeiten können. 3.2 Datenbanken Bisher haben wir die Strukturierung von Daten innerhalb einer Programmiersprache kennen gelernt. Unsere letzte Anwendung hat einen Eindruck gegeben, wie man dies mit Hilfe von Tabellen (csv) machen kann. Für komplexere Anwendungen reichen einzelne Tabellen aber nicht aus. Datenbanken bieten einem die Möglichkeit mehrere Tabellen zu kombinieren und effizient auf die gespeicherten Daten zuzugreifen. Ausgehend von unserer Veranstaltungsverwaltung wollen wir uns das Thema Datenbanken erarbeiten. Angenommen wir wollten in unserer Veranstaltungsverwaltung nicht nur den Namen des Dozenten speichern, sondern weitere Informationen, wie z.B. seinen Vornamen und seine Email-Adresse speichern. Im ersten Ansatz könnten wir hierzu einfach weitere Spalten in der Tabelle hinzu nehmen. Es fällt aber schnell auf, dass es nicht einfach wird, diese konsistent zu halten. Insbesondere bei Änderungen, müssen wir alle Datensätze anpassen. Es wäre also sinnvoller eine separate Tabelle für Dozenten anzulegen, in denen jeder Dozent nur genau einmal vorkommt. In der Veranstaltungstabelle können wir dann einfach auf den verantwortlichen Dozenten verweisen. Um solch einen Verweis anlegen zu können, benötigt man natürlich einen eindeutigen Identifikator eines Dozenten. Dies könnte die Zeile sein, in der er in der Tabelle steht. Beim Einfügen oder Löschen von Dozenten, würde dies aber immer wieder Anpassungen bedeuten, weshalb man in der Regel eindeutige Identifikatoren (meist Integer-Werte) verwendet. Diesen Identifikator können wir dann in die Dozentenspalte eintragen und so bei der Veranstaltung auf den Dozenten verweisen, der die Veranstaltung hält. Unsere Datensätze sehen dann wie folgt aus: Veranstaltung ID titel art dozent Dozent ID vorname name email Konkrete Tabellen könnten dann wie folgt aussehen: Veranstaltungen: 81 3 Datenstrukturen und Algorithmen ID 0 1 2 4 titel Informatik für Nebenfächler Informatik für Nebenfächler Logik in der Informatik Informatik für Naturwissenschaftler Dozenten: ID vorname 0 Frank 1 Christoph Daniel 3 Thomas name Huch Schulze Wilke art Vorlesung Übung Vorlesung Vorlesung dozent 0 1 2 0 email [email protected] [email protected] [email protected] Die IDs bei den Veranstaltungen sind im Moment noch nicht notwendig, schaden aber auch nicht. Beachte, dass wir die IDs nur eindeutig für die jeweilige Tabelle verwenden. Dies reicht aus, da wir eine eindeutige Zuordnung zwischen der dozenten-Spalte in der Veranstaltungstabelle haben, welche auf die Dozenten verweist. Es ist klar, zu welcher Tabelle diese ID gehört. Im allgemeinen besteht eine Datenbank, ähnlich wie eine Tabellenkalkulation, aus einer Reihe von Tabellen, welche über IDs zusätzlich Verweise anderen Tabelleneinträge enthält. Zur Modellierung solcher Datenbanken vewendet man gerne das Entity-Relationship-Modell (ER-Modell), in welchem Tabellen und Beziehungen zwischen den Tabellen graphisch ansprechend dargestellt werden können. Für unsere bisherige Anwendung ergibt sich das folgende ER-Modell: Veranstaltung ID titel art dozent 1 n Dozent ID vorname name email Jede Zeile entspricht einem Eintrag in die Tabelle. Die Reihenfolge ist eigentlich irrelevant, wichtig ist nur, dass wir die Zeilen unterscheiden können1 . Hierfür eignen sich Namen in der Regel nicht, da es durchaus Menschen mit gleichen Namen gibt. Als Untescheidungsmerkmal führt man deshalb in der Regel Schlüssel ein, welche es uns ermöglichen unserer Datensätze in jedem Fall zu unterscheiden. Solche Schlüssel kennen wir auch aus anderen Bereichen, wie z.B. die Matrikelnummer oder unsere Personalausweisnummer. Hier haben wir der Einfachheithalber eine Zahl hinzugefügt, welche nicht doppelt verwendet werden darf. Dass einzelne Zahlen fehlen stellt kein Problem dar und kann z.B. dadurch entstanden sein, dass eine Vorlesung, welche zuvor mit dem Schlüssel 3 existierte gelöscht wurde. Solche IDs werden von Datenbanksystemen in der Regel automatisch generiert. In unserer csv-Anwendung, mussten wir uns hierum noch selber kümmern. Die Schlüssel benötigen wir außerdem, wie bereits oben motiviert, wenn wir Daten aus unseren beiden Tabellen mit einander zu verknüpfen. Untersucht man die Beziehung zwischen Veranstaltungen und Dozenten, so wird eine Veranstaltung (in der Regel, von der wir hier mal ausgehen) von einem Dozenten gehalten. Ein Dozent kann aber durchaus mehrere Veranstaltungen anbieten. Es gibt also eine so genannte 1:n-Relation zwischen Veranstaltungen und Dozenten, welche man aus Sicht der Veranstaltung wirdgehaltenVon und aus Sicht des Dozenten als haelt bezeichnen kann. Diese Relation können wir im ER-Modell 1 In unserer bishrigen Implementierung mit csv-Dateien, spielt die Reihenfolge natürlich sehr wohl eine Rolle. Beim Übergang zu Datenbanken wird sie aber unwichtig werden. 82 3.2 Datenbanken einfach dadurch ausdrücken, dass wir der Veranstaltung ein weiteres Attribut hinzufügen, welches auf genau einen Dozenten verweist. Der einzelne Dozent kann aber auch bei mehreren Veranstaltungen eingetragen werden, was genau die 1:n-Beziehung dieser Relation wiederspiegelt. Im ER-Modell notieren wir die 1:n-Beziehung an der eingefügten Kante: ein Dozent kann mehrere Veranstaltungen halten und zu einer Veranstaltung gehört genau ein Dozent. In unserer csv-Implementierung haben wir das so implementiert, dass wir der veranstaltungstabelle eine eine Spalte mit der Dozenten-ID hinzugefügt haben. Möchte man diese Relation in umgekehrter Reihenfolge lesen (als alle Veranstaltungen eines Dozenten ausgeben), kann man dies erreichen, in dem man in der Veranstaltungstabelle nach allen Veranstaltungen sucht, welche den entsprechenden Dozenten haben. Im folgenden werden wir eine einfache Datenbank kennenlernen. Hierzu verwenden wir SQLite (genauer sqlite3) und realisieren als Beispiel dieses Datenbankschemas. Gesteuert werden Datenbanken in der Regel durch Structured Query Language (SQL). Die wichtigsten Konstrukte wollen wir schrittweise in SQLite kennen lernen. Zunächst starten wir SQLite, wobei wir als Parameter den Namen der Datenbank angeben, mit der wir arbeiten wollen, mittels sqlite3 veranst. Danach können wir interaktiv mit der Datenbank arbeiten und zunächst eine Tabelle für Dozenten anlegen: s q l i t e > CREATE TABLE d o z e n t ( i d INT , name VARCHAR( 2 5 5 ) , vorname VARCHAR( 2 5 5 ) ) ; Die Schlüsselworte schreibt man in SQL in der Regel mit Großbuchstaben. SQLite erlaubt hier aber auch Kleinbuchstaben. Mit diesem ersten SQL-Befehl generieren wir eine Tabelle mit dem Namen dozent, welche drei Spalten (id, name und vorname) hat. Als id verwedet man in der Regel Zahlen, was wir mit dem Typ INT für die erste Spalte festlegen. In die beiden weiteren Spalten können String (Typ VARCHAR) mit einer maximalen Länge von 255 Zeichen geschrieben werden. Nun können wir einen ersten Datensatz in diese Tabelle eintragen: s q l i t e > INSERT INTO d o z e n t ( id , name , vorname ) VALUES ( 1 , ”Huch” , ” Frank ” ) ; Mit Hilfe der Spaltenbeschriftung zwischen dem Tabellennamen und dem Schlüsselwort VALUES, können wir die Reihenfolge der Werte bestimmen, welche wir eintragen wollen. Außerdem können wir einzelne Spalten leer lassen, was dazu führt, dass entwerde DefaultWerte oder NULL-Werte eingetragen werden. Werden alle Werte in der Reihenfolge, wie sie beim Anlegen der Tabelle definiert wurden, eingetragen, kann des Tupel mit den Spaltennamen auch weggelassen werden: s q l i t e > INSERT INTO d o z e n t VALUES ( 2 , ” Wilke ” , ”Thomas” ) ; s q l i t e > INSERT INTO d o z e n t VALUES ( 3 , ” S c h u l z e ” , ” C h r i s t o p h D a n i e l ” ) ; Nun können wir unsere erste Anfrage an die Datenbank stellen und alle Spalten der Tabelle dozent ausgeben: s q l i t e > SELECT ∗ FROM d o z e n t ; 1 | Huch | Frank 2 | Wilke | Thomas 3 | Schulze | Christoph Daniel 83 3 Datenstrukturen und Algorithmen Die verwendung des * bedeutet hierbei, dass alle Spalten in de Reihenfolge, wie sie bei der Generierung der Tabelle angegeben wurden, ausgegeben werden. Wir können uns aber auch auf die Ausgabe einzelner Spalten einschränken und auch deren Reihenfolge verändern: s q l i t e > SELECT vorname , name FROM d o z e n t ; Frank | Huch Thomas | Wilke Christoph Daniel | Schulze Nun können wir eine zweite Tabelle für die Veranstaltungen anlegen: s q l i t e > CREATE TABLE v e r a n s t a l t u n g ( i d INT , t i t e l VARCHAR( 2 5 5 ) , a r t VARCHAR( 3 0 ) , d o z e n t e n i d INT ) ; s q l i t e > INSERT INTO v e r a n s t a l t u n g VALUES ( 1 , ” I n f o r m a t i k f ü r Nebenf ä c h e r ” , ” V o r l e s u n g ” , 1 ) ; s q l i t e > INSERT INTO v e r a n s t a l t u n g VALUES ( 2 , ” I n f o r m a t i k f ü r Nebenf ä c h e r ” , ”Übung” , 3 ) ; s q l i t e > INSERT INTO v e r a n s t a l t u n g VALUES ( 3 , ” Logik i n d e r I n f o r m a t i k ” , ” V o r l e s u n g ” , 2 ) ; s q l i t e > INSERT INTO v e r a n s t a l t u n g VALUES ( 4 , ” F u n k t i o n a l e Programmierung ” , ” V o r l e s u n g ” , 1 ) ; s q l i t e > SELECT ∗ FROM v e r a n s t a l t u n g ; 1 | I n f o r m a t i k f ü r Nebenf ä c h l e r | V o r l e s u n g | 1 2 | I n f o r m a t i k f ü r Nebenf ä c h e r | Übung | 3 3 | Logik i n d e r I n f o r m a t i k | V o r l e s u n g | 2 4 | F u n k t i o n a l e Programmierung | V o r l e s u n g | 1 Im nächsten Schritt wollen wir nur die Veranstaltungen des Dozenten mit der id 1 ausgeben. Hierzu ist es möglich in der SELECT-Anweisung mittels einer WHERE-Klausel Einschränkungen zu definieren: s q l i t e > SELECT t i t e l , a r t FROM v e r a n s t a l t u n g WHERE d o z e n t e n i d =1; I n f o r m a t i k f ü r Nebenf ä c h l e r | V o r l e s u n g F u n k t i o n a l e Programmierung | V o r l e s u n g In der WHERE-Klausel können auch Ungleichungen (!=,<,>,<=,>=), sowie boolesche Kombinationen dieser (AND bzw. OR als Infixoperatoren) verwendet werden. Im nächsten Schritt möchten wir nicht nur die ids der Dozenten ausgeben, sondern deren Name und Vorname. Dies ist zunächst möglich, in dem wir nicht nur Spalten aus einer Tabelle, sondern direkt aus zwei Tabellen wählen: s q l i t e > SELECT ∗ FROM v e r a n s t a l t u n g , d o z e n t ; 1 | I n f o r m a t i k f ü r Nebenf ä c h l e r | V o r l e s u n g | 1 | 1 | Huch | Frank 2 | I n f o r m a t i k f ü r Nebenf ä c h e r | Übung | 3 | 1 | Huch | Frank 4 | F u n k t i o n a l e Programmierung | V o r l e s u n g | 1 | 1 | Huch | Frank 3 | Logik i n d e r I n f o r m a t i k | V o r l e s u n g | 2 | 1 | Huch | Frank 1 | I n f o r m a t i k f ü r Nebenf ä c h l e r | V o r l e s u n g | 1 | 2 | Wilke | Thomas 2 | I n f o r m a t i k f ü r Nebenf ä c h e r | Übung | 3 | 2 | Wilke | Thomas 4 | F u n k t i o n a l e Programmierung | V o r l e s u n g | 1 | 2 | Wilke | Thomas 3 | Logik i n d e r I n f o r m a t i k | V o r l e s u n g | 2 | 2 | Wilke | Thomas 1 | I n f o r m a t i k f ü r Nebenf ä c h l e r | V o r l e s u n g | 1 | 3 | S c h u l z e | C h r i s t o p h D a n i e l 2 | I n f o r m a t i k f ü r Nebenf ä c h e r | Übung | 3 | 3 | S c h u l z e | C h r i s t o p h D a n i e l 84 3.2 Datenbanken 4 | F u n k t i o n a l e Programmierung | V o r l e s u n g | 1 | 3 | S c h u l z e | C h r i s t o p h D a n i e l 3 | Logik i n d e r I n f o r m a t i k | V o r l e s u n g | 2 | 3 | S c h u l z e | C h r i s t o p h D a n i e l Wir erhalten also alle möglichen Kombinationen von Einträgen der einen und der anderen Tabelle. Diese entstehende Tabelle können wir nun auf die Einträge einschränken, bei denen die Dozenten id übereinstimmt: s q l i t e > SELECT ∗ FROM v e r a n s t a l t u n g , d o z e n t WHERE v e r a n s t a l t u n g . d o z e n t=d o z e n t . i d ; 1 | I n f o r m a t i k f ü r Nebenf ä c h l e r | V o r l e s u n g | 1 | 1 | Huch | Frank 2 | I n f o r m a t i k f ü r Nebenf ä c h e r | Übung | 3 | 3 | S c h u l z e | C h r i s t o p h D a n i e l 3 | Logik i n d e r I n f o r m a t i k | V o r l e s u n g | 2 | 2 | Wilke | Thomas 4 | F u n k t i o n a l e Programmierung | V o r l e s u n g | 1 | 1 | Huch | Frank Schränken wir uns auf die interessanten Spalten ein und ordnen sie zusätzlich (alphabetisch) nach dem Veranstaltungstitel erhalten wir: s q l i t e > SELECT v e r a n s t a l t u n g . t i t e l , v e r a n s t a l t u n g . a r t , d o z e n t . vorname , d o z e n t . name FROM v e r a n s t a l t u n g , d o z e n t WHERE v e r a n s t a l t u n g . d o z e n t=d o z e n t . i d ORDER BY veranstaltung . t i t e l ; F u n k t i o n a l e Programmierung | V o r l e s u n g | Frank | Huch I n f o r m a t i k f ü r Nebenf ä c h e r | Übung | C h r i s t o p h D a n i e l | S c h u l z e I n f o r m a t i k f ü r Nebenf ä c h l e r | V o r l e s u n g | Frank | Huch Logik i n d e r I n f o r m a t i k | V o r l e s u n g | Thomas | Wilke Ein Nachteil des Selektierens aus zwie Tabellen erkennen wir an der Anfrage SELECT ∗ FROM v e r a n s t a l t u n g , d o z e n t ; | Es werden jeweils alle Einträge beider Tabellen mit einander kombiniert, was zu einer sehr großen Zwischentabelle führt und bei großen Tabellen entsprechend ineffizient werden kann. Eine Alternative stellen sogenannte Joins dar, bei denen zwei Tabellen gemäß eines Kriteriums kombiniert werden kann. In unserem Beispiel kann eine effizientere Abfrage wie folgt aussehen: s q l i t e > SELECT v e r a n s t a l t u n g . t i t e l , v e r a n s t a l t u n g . a r t , d o z e n t . vorname , d o z e n t . name FROM v e r a n s t a l t u n g INNER JOIN d o z e n t ON v e r a n s t a l t u n g . d o z e n t=d o z e n t . i d ; I n f o r m a t i k f ü r Nebenf ä c h l e r | V o r l e s u n g | Frank | Huch I n f o r m a t i k f ü r Nebenf ä c h e r | Übung | C h r i s t o p h D a n i e l | S c h u l z e Logik i n d e r I n f o r m a t i k | V o r l e s u n g | Thomas | Wilke F u n k t i o n a l e Programmierung | V o r l e s u n g | Frank | Huch Als letztes SQL-Konstrukt lernen wir noch die DELETE-Anweisung kennen, mit welcher wir auch Tabelleneinträge löschen können. Als Beispiel wollen wir Herrn Wilke aus der Dozenten-Tabelle löschen: DELETE FROM d o z e n t WHERE d o z e n t . name=” Wilke ” ; Danach ist Herr Wilke zwar keine Dozent mehr, aber es gibt natürlich noch Veranstaltungen, welche auf ihn verweisen. Diese können wir aber genauso einfach löschen: 85 3 Datenstrukturen und Algorithmen DELETE FROM v e r a n s t a l t u n g WHERE v e r a n s t a l t u n g . d o z e n t e n i d =2; Um mit Datenbanken auch praktisch arbeiten zu können, haben wir auf der Webseite der Vorlesung eine Bibliothek zur Verfügung (netter Weise erstellt von Stefan Exner) gestellt, welche eine einfache Anbindung von Ruby an SQLite we ermöglicht. Mit ihr könnne SQLBefehle ausgeführt werden. Liefert eine SQL-Befehl (wie z.B. der SELECT-Befehl) eine Tabelle als Ergebnis, wird diese in Form eines zweidimensionalen Arrays zurück geliefert. Die Verwendung sollte mit dem folgenden Beispielprogramm klar werden: require relative (” sqlite connector ”) # Anbindungsmodul f ü r s q l i t e 3 . # D a t e i s q l i t e c o n n e c t o r . rb s o l l t e im g l e i c h e n V e r z e i c h n i s l i e g e n u s e d a t a b a s e ( ” v e r a n s t a l t u n g e n ” ) do | db | db . e x e c u t e ( ”CREATE TABLE d o z e n t e n ( i d INT , name VARCHAR( 2 5 5 ) , ”+ ” vorname VARCHAR( 2 5 5 ) ) ; ” ) ... db . e x e c u t e ( ’INSERT INTO d o z e n t e n VALUES ’+ ’ ( 1 , ” Huch ” , ” Frank ” ) ; ’ ) ... d o z e n t e n = db . e x e c u t e ( ’SELECT ∗ FROM d o z e n t e n ; ’ ) p ( d o z e n t e n ) # Gibt d i e T a b e l l e d e r a k t u e l l e n # Dozenten a l s Array aus end In der Vorlesung werden wir die Veranstaltungsverwaltung von csv auf SQL umstellen. Als nächstes wollen wir die Veranstaltungsverwaltung noch erweitern. Hierzu wollen wir auch Studierende in das System aufnehmen und die Belegung von veranstaltungen modellieren. Studierende modellieren wir wir folgt: Studierende matrikelnummer name vorname Hierbei verwenden wir die Matrikelnummer als Schlüssel, da sie ohnehin eindeutig ist. Wie können wir nun die beiden Tabellen Studierende und Veranstaltung in Beziehung setzen? Studierende belegen eine oder mehrere Veranstaltungen. Umgekehrt sitzen in einer Veranstaltung (meistens) mehrere Studierende. Somit handelt es sich um eine n:m-Beziehung, welche man im ER-Model wir folgt darstellt: Studierende matrikelnummer name vorname veranstaltung m n Veranstaltung ID titel art dozent Wie können wir eine solche Relation nun in der Datenbank abbilden. Ein erster Ansatz wäre die Verwendung einer Liste von Veranstaltungen bei jedem Studierenden. Solche strukturen lassen sich in tabellenbasierten Datenbanken aber nur schwer realisieren, weshalb man in der Regel eine andere Idee verfolgt. 86 3.3 Sortieren Wir führen eine separate Tabelle ein, welche genau die n:m-Beziehung repräsentiert. Diese Tabelle könnte man beispielsweise Teilnahme nennen. In ihr stehen Kombinationen aus Matrikelnummern und Veranstaltungs-IDs (also der beiden Schlüsselwerte, der an der n:mBeziehung beteiligten Tabellen). Studierende matrikelnummer name vorname 1 n Teilnahme matrikelnummer veranstaltungs id 1 n Veranstaltung ID titel art dozent In der Tabelle Teilnahme speichern wir also einfach Paare von Matrikelnummern und Veranstaltungen. Es ist nicht sinnvoll, dass eine Studentin mehrmals an der gleichen Veranstaltung teilnimmt, weshalb man dann beim Füllen dieser Tabelle darauf achten sollte, dass es keine doppelten Einträge gibt. Gleichzeitig ist aber die Kombination aus Matrikelnummer und veranstaltungs id für jede Teilnahme eindeutig, so dass wir beide Attribute in Kombination als sogenanten Verbundschlüssel nutzen können und nicht extra eine ID eintragen müssen. Mit Hilfe dieser Modellierung ist es nun möglich sowohl alle Veranstaltungen einer Studentin als auch alle Studierende einer Veranstaltung abzufragen: 3.3 Sortieren Ein häufiges Problem ist es eine große Anzahl von Werten zu sortieren. Funktionen bzw. Methoden zum Sortieren stellen alle gängigen Programmiersprachen zur Verfügung. Dennoch ist es sinnvoll sich einige Sortieralgroithmen genauer an zu schauen und hierbei auch deren Laufzeitverhalten zu untersuchen. Im Folgenden wollen wir ein gegebenes Array von Zahlen sortieren. 3.3.1 Sortieren durch Auswählen 1. Lösung Minsort (oder Selectionsort) Idee: Zunächst wandeln wir einen Algorithmus zur Bestimmung des Minimums eines Arrays so ab, dass nicht das Minimum, sondern seine Position zurückgeliefert wird. Zusätzlich wird das Minimum nur ab einer vorgegebenen Position gesucht: d e f m i n p o s f r o m ( a , pos ) min pos = pos ; f o r i i n pos+1 . . a . s i z e −1 do i f a [ i ] < a [ min pos ] then minpos = i ; end ; end ; r e t u r n min pos ; end ; Dann: min pos from ([2,7,5,4,8],1) ; 3 Wie können wir hiermit jetzt sortieren? 87 3 Datenstrukturen und Algorithmen Bsp.: D.h. wir laufen von links nach rechts durch das Array und vertauschen jeweils das aktuelle Element mit dem Minimum des Restarrays. Als erstes ist es günstig eine Hilfsprozedur zum destruktiven Vertauschen zweier Arrayelemente zu definieren: d e f swap ! ( a , i , j ) dummy = a [ i ] ; a[ i ] = a[ j ]; a [ j ] = dummy ; end ; def min sort ! ( a) f o r i i n 0 . . a . s i z e −2 do #da e i n e l e m e n t i g e s Array immer s o r t i e r t pos = m i n p o s f r o m ( a , i ) ; swap ! ( a , i , pos ) ; end ; return a ; end ; Dann: p(min sort !([5,3,6,2,7,4])) ; [2,3,4,5,6,7] ↓↓↓↓↓ Zusatzinhalt, der für 5 ECTSler nicht relevant ist Wie soll man einen Sortieralgorithmus beurteilen? ↓↓↓↓↓ Laufzeiten vergleichen! Aber: was sind gute Testfälle? Zunächst scheint es sinnvoll, die Laufzeit in Abhängigkeit der Eingabegröße zu untersuchen. Es zeigt sich folgendes Verhalten: Der Algorithmus scheint also quadratische Laufzeit in der Größe des Arrays zu haben. Hierbei scheinen die konkreten Werte, welche im Array vorkommen, fast völlig unwichtig zu sein. Die Laufzeiten verändern sich nicht, ob man sortierte oder unsortierte Arrays betrachtet. Dies liegt daran, dass das Programm zwei verschachtelte Schleifen verwendet. Bei beiden Schleifen handelt es sich um for-Schleifen, so dass die Anzahl der Schleifendurchläufe nicht von den Werten abhängt. f o r i i n 0 . . a . s i z e −2 do 88 3.3 Sortieren f o r j i n i +1 . . a . s i z e −1 do ... # ∗ end ; end ; Als einziger Unterschied fallen ein paar Zuweisungen in minPosFrom bei einem sortierten Array weg. Welche Werte werden also für i und j durchlaufen? i 0 .. . 1 .. . 2 .. . a.size-3 a.size-2 j 1 2 3 .. . a.size-1 2 3 .. . a.size-1 3 .. . a.size-1 a.size-2 a.size-1 a.size-1 D.h. (∗) wird so oft durchlaufen: a.size − 1 + a.size − 2 + a.size − 3 + . . . + 1 = a.size−1 X n n=1 = = = (a.size − 1) · a.size 2 a.size2 − a.size 2 1 1 a.size2 − a.size 2 2 D.h. bis auf ein paar 21 a.size werden 12 a.size2 viele Werte durchlaufen. Die genaue Zahl der Durchläufe ist nicht so wichtig. Wichtiger ist, dass ihre Anzahl quadratisch mit der Arraygröße wächst, also für doppelte Arraygröße die 4-fache Laufzeit benötigt wird! Geht sowas auch besser? 3.3.2 Sortieren durch Einfügen Unschön an Min-Sort ist insbesondere, dass er auch für bereits sortierte Arrays quadratische Laufzeit hat. Besser ist hier Insertion-Sort: 89 3 Datenstrukturen und Algorithmen Idee: Sortiere wie einen Stapel Spielkarten. Ziehe Karte und füge diese in bereits sortierte Hand ein. Bsp.: Insertion-Sort können wir wie folgt in Ruby implementieren: def i n s s o r t ! ( a) f o r i i n 0 . . a . s i z e − 1 do j = i; w h i l e j >0 && a [ j −1] > a [ j ] do swap ! ( a , j −1, j ) ; j = j − 1; end ; end ; return a ; end ; # Wiederverwendung von swap ! Dann: p(inss ort!([5, 3, 6, 2, 7, 4])) ; [2, 3, 4, 5, 6, 7] Beachte: Bei sortierten Feldern wird fast nichts gemacht: 1 1 1 1 1 | 2 2 2 2 2 | 3 3 3 3 3 | 4 4 4 4 4 | 5 5 5 5 5 | Der Algorithmus hat also im besten Fall lineare Laufzeit in der Größe des zu sortierenden Arrays. Aber warum ist die absolute Laufzeit schlechter als bei Min-Sort? Das Problem ist, dass wir die Elemente so lange tauschen bis wir ein Element an der richtiger Stelle eingefügt habe. Hierdurch ist die Operation, welche im Schleifenrumpf ausgeführt wird aufwendiger als bei Min-Sort. D.h. bis auf ein paar 21 a.size viele werden 12 a.size2 viele Werte durchlaufen und dabei getauscht. Dies kann dadurch optimiert werden, dass man sich das einzufügende Element merkt und die anderen nach hinten schiebt. 90 3.4 Die Fibonacci-Funktion In der Implementierung: def i n s s o r t ! ( a) f o r i i n 0 . . a . s i z e −1 do j = i; ins = a [ i ] ; w h i l e j >0 && i n s < a [ j −1] do a [ j ] = a [ j −1]; j = j − 1; end ; a [ j ] = ins ; end ; return a ; end ; Diese Implementierung ist jetzt ungefähr genau so schnell wie Selection-Sort für unsortierte Arrays und viel schneller für sortierte Arrays. Wir erhalten folgende Komplexitäten: Best-Case (sortiertes Array) Worst-Case (unsortieres Array) Selection-Sort quadratisch in Größe des Arrays quadratisch in Größe des Arrays Insertion-Sort linear in Größe des Arrays quadratisch in Größe des Arrays 3.4 Die Fibonacci-Funktion Im Folgenden werden wir unterschiedliche Algorithmen bzgl. ihres Laufzeitverhaltens analysieren. Zunächst betrachten wir noch einmal die rekursive Implementierung der Fibonacci-Funktion: def f i b (n) i f n == 0 then r e t u r n 0 ; e l s e i f n == 1 then r e t u r n 1 ; e l s e r e t u r n ( f i b ( n−1) + f i b ( n − 2 ) ) ; end ; end ; end ; Praktische Tests: Oha! Woran liegt dies? 91 3 Datenstrukturen und Algorithmen Beachte: In jeder Rekursion werden zwei rekursive Aufrufe gemacht. Hierdurch ergibt sich exponentielle Laufzeit. Wie schlimm ist dies? f ib(2) ; 22 = 4Schritte f ib(10) ; 210 = 1024Schritte f ib(20) ; 220 = 1048576Schritte f ib(30) ; 230 = 1073741824Schritte f ib(50) ; 250 = 1, 125 · 1015 Schritte f ib(100) ; 2100 = 1, 268 · 1030 Schritte Betrachte 1000000 Schritte/Sekunde Dann 1, 268 · 1030 s 1000000 = 1, 268 · 1024 s f ib(100) = = 4, 021 · 1016 Jahre Aber zum Glück ist eine effizientere Implementierung möglich. Es gibt aber auch Probleme, für die keine effizienteren Lösungen als exponentielle Lösungen bekannt sind. Solche Probleme werden wir später in der Vorlesung noch kennen lernen. 3.5 O-Notation Bisher haben wir Algorithmen mit unterschiedlichen Laufzeitverhalten kennengelernt: konstant, linear, quadratisch, exponentiell, usw. Hierbei ist es natürlich (außer bei konstant) 92 3.6 Suchen von Elementen wichtig zu sagen, worin der Algorithmus diese Komplexität hat. Als prägnantere Schreibweise schlug der Zahlentheoretiker Landau die Schreibweise der sogenannten Groß-O-Notation vor. Wir schreiben hierbei ein Algorithmus ist in O(1) falls er konstante Laufzeit besitzt. Lineare Algorithmen in einer Variablen n (was beispielsweise die Länge eines Strings oder die Größe eines Parameters sein kann) notieren wir mit der Laufzeit O(n), quadratische mit O(n2 ) und exponentielle mit O(2n ). Hierbei müssen wir immer noch angeben, worin wir dieses Laufzeitverhalten angeben, d.h., was n ist. Hinter der O-Notation steckt eine formale mathematische Theorie, welche wir hier nicht weiter behandeln wollen. Es ist nur wichtig festzuhalten, dass O(1) = O(2) = O(42), O(n) = O(42 · n + 42) und O(n2 ) = O( 31 n2 + 42n + 5). Konstante Faktoren, die multipliziert oder addiert werden und Faktoren mit kleinerem Exponenten sind also für die Laufzeitanalyse nicht relevant. 3.6 Suchen von Elementen Aufgabe: Gegeben ein Array von Zahlen a und eine Zahl n. Überprüfe, ob n in a vorkommt. Einzige mögliche Lösung: Vergleiche n mit allen Elementen: d e f elem ( n , a ) i =0; b=f a l s e ; w h i l e ! b && i <a . s i z e do i f a [ i ] == n then b=t r u e ; # s t a t t i f auch m o e g l i c h : e l s e i = i +1; # b= a [ i ]==n ; i=i +1; end ; end ; return b ; end ; p u t s ( elem ( 3 , [ 1 , 2 , 3 , 4 , 5 ] ) ) ; ; true Welche Laufzeit hat elem? Im besten Fall benötigt die Suche nur einen Schleifendurchlauf, wenn n = a[0] gilt. Man sagt: die Best-Case-Laufzeit ist konstant. Im schlechtesten Fall findet sie das Element nicht, dann durchläuft sie die Schleife genau a.size-mal. Im Worst-Case ist sie also linear in der Größe von a. Für alle vorkommenden Werte wird die Schleife im Durchschnitt a.size/2-mal durchlaufen. Also 12 · a.size viel Durchläufe. Durch den Faktor 12 wird die Laufzeit zwar verbessert, sie ist aber immer noch linear zu a.size, d.h. auch im Durchschnitt (Average-Case) ist elem linear in der Größe von a. Können wir a besser strukturieren/anordnen, damit elem effizienter werden kann? Hinweis: Telefonbuch Idee: Sortiere die Elemente Dann: Suche nur solange, wie n kleiner als aktuelles Element: d e f elem ( n , a ) i =0; 93 3 Datenstrukturen und Algorithmen b=f a l s e ; w h i l e ! b && i <a . s i z e && a [ i ]<=n do i f a [ i ]==n then b=t r u e ; e l s e i=i +1; end ; end ; return b ; end ; Dann wird auch bei nicht vorkommenden Elementen die Schleife durchschnittlich nur 12 · a.size-mal durchlaufen. Das ist zwar besser, aber immer noch lineare in der Größe von a. Das es auch noch effizienter geht verdeutlichen wir uns mit Hilfe eines kleinen Spiels: Zahl raten: ◦ Ein Spieler denkt sich eine Zahl aus, der andere muss diese Erraten. Hat der zweite Spieler die Zahl nicht erraten, so sagt der erste Spieler dem zweiten Spieler als Hilfe, ob die geratene Zahl kleiner oder größer als die gesuchte Zahl ist. Der Zweite Spieler soll die Zahl möglichst schnell finden. ◦ Um immer direkt die Hälfte der Zahlen ausschließen zu können ist es natürlich sinnvoll, immer die Zahl in der Mitte des in Frage kommenden Bereichs zu nennen. So kann in einem Schritt die Hälfte aller Werte ausgeschlossen werden. ◦ Mögliche Rateschritte wären also für eine Zahl zwischen 1 und 64: 32 - zu klein ; 48 - zu groß ; 40 - zu klein ; 44 - zu groß ; 42 ; gefunden Das hier verwendete Prinzip, den Suchraum in (ungefähr) gleich große Teile aufzuteieln und dann schrittweise zu entscheiden, mit welchem Teil weitergemacht wird, nennt man Teile und Herrsche (devide and conquer). Diese Idee können wir auch auf die Elementsuche übertragen: 3.6.1 Binäre Suche Idee: Vergleiche n mit mittlerem Element von a: n ist gleich n ist kleiner n ist größer ; ; ; gefunden suche in linker Hälfte suche in rechter Hälfte Danach erfolgt die Suche innerhalb der Hälfte nach der gleichen Idee. Als Beispiel suchen wir die 8 in einem Array: Bsp.: 8 in [ 2 , 4 , 6 , 8 , 9 , 11 , 14 , 15 , 16 , 17 , 19 , 22 , 23] x x x x Es werden also nur 4 Vergleichsschritte benötigt, das Element zu finden. Auch im Fall, dass das Element nicht im Array ist, kann man schnell terminieren, wenn nur noch eine Bereich 94 3.6 Suchen von Elementen der Länge eins in Frage kommt. Eine rekursive Implementierung kann einfach angegeben werden. d e f b i n s e a r c h ( a , n , from , t o ) i f from > t o then return f a l s e ; else pos = ( from + t o ) / 2 ; i f n == a [ pos ] then return true ; else i f n < a [ pos ] then r e t u r n b i n s e a r c h ( a , n , from , pos −1); else r e t u r n b i n s e a r c h ( a , n , pos +1, t o ) ; end ; end ; end ; end ; Dann: d e f elem ( n , a ) r e t u r n b i n s e a r c h ( a , n , 0 , a . s i z e −1); end ; Bsp.: =a z }| { elem(3, [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) ; bin search(a, 14, 0, 9) ; pos = (0 + 9)/2 = 4 a[4] = 15 6= 14(> 14) ; bin search(a, 14, 0, 3) ; pos = (0 + 3)/2 = 1 a[1] = 12 6= 14(< 14) ; bin search(a, 14, 2, 3) ; pos = (2 + 3)/2 = 2 a[2] = 13 6= 14(< 14) ; bin search(a, 14, 3, 3) ; pos = (3 + 3)/2 = 3 a[3] = 14 == 14 ; return true Wie viele Schritte (rekursive Aufrufe) benötigt das Programm nun? In jedem Schritt wird die eine Hälfte der Werte weggeschmissen“, d.h. nicht weiter be” trachtet und nur noch die andere Hälfte weiter untersucht. Führt man dies rekursiv immer wieder aus, benötigt man log2 viele Schritte in der Größe des Arrays, der Algorithmus ist 95 3 Datenstrukturen und Algorithmen logarithmisch in der Größe des Arrays, also O(log n) mit n Anzahl der zu sortiertenden Zahlen. Kann dieser Algorithmus auch iterativ implementiert werden? Ja, mit der gleichen Idee: def bin search (n , a ) l e f t =0; r i g h t=a . s i z e −1; pos=( l e f t +r i g h t ) / 2 ; w h i l e l e f t < r i g h t && a [ pos ] != n do i f a [ pos ] < n then l e f t = pos +1; e l s e r i g h t = pos −1; end ; pos=( l e f t +r i g h t ) / 2 ; end ; r e t u r n ( a [ pos]==n ) ; end ; Bemerkung: (lef t + right)/2 = lef t + (right − lef t)/2 3.7 Effizientes Sortieren Es stellt sich die Frage ob es auch Sortierverfahren gibt, welche die Aufgabe schneller als in quadratischer Laufzeit lösen können. Wirklich effizienten Lösungen basieren auf der teile und herrsche Idee, welche wir uns am Beispiel des Quicksort-Algorithmus anschauen wollen. Beim Quicksort-Algorithmus wählt man zunächst ein beliebiges Element (meistens das Erste) des Arrays (Pivot-Element genannt) aus und vergleicht es nach und nach mit allen anderen Elementen des Arrays. Es ist klar, dass alle kleineren Elemente links von diesem einsortiert werden müssen und alle anderen rechts von ihm. Teile also alle anderen Elemente so auf, dass alle kleineren Elemente vor dem Pivotelement stehen und alle größeren Elemente hinter dem Pivotelement. Mit den Kleineren und dem Größeren verfahre rekursiv so weiter, bis nur noch einelementige Listen übrig bleiben, die natürlich sortiert sind. Bsp.: Als erste Implementierung, verwenden wir jeweils ein neues Array, in das wir die kleineren Werte von vorne und die größeren Werte nach hinten packen können. Danach wird rekursiv für die kleineren und größeren Elemente mittels Quicksort sortiert. Wir verwenden jeweils das erste Element des zu sortierenden Bereichs (a[l]) als Pivot-Element. d e f q s o r t h ( a , l , r ) # S o r t i e r t den B e r e i c h von l b i s r i f l >= r then r e t u r n a . c l o n e ; else 96 3.7 Effizientes Sortieren b = a . c l o n e ; # Legt e i n e Kopie von a an j = l; k = r; f o r i i n ( l +1) . . r do i f a [ i ] < a [ l ] # V e r g l e i c h mit Pivot−Element then b [ j ] = a [ i ] ; j = j +1; e l s e b [ k ] = a [ i ] ; k = k −1; end ; end ; b [ j ] = a [ l ] ; # V e r s c h i e b e Pivot−Element i n d i e Mitte c = q s o r t h ( b , l , j −1); r e t u r n q s o r t h ( c , j +1, r ) ; end ; end ; def qsort (a) r e t u r n q s o r t h ( a , 0 , a . s i z e −1); end ; p( qsort ([2 ,6 ,8 ,4 ,6 ,2 ,3 ,8 ,1])); Dieser Algorithmus arbeitet aber nicht in Place, d.h. er benötigt weiteren Speicher. Das Array wird immer wieder kopiert. Hierdurch bleibt das Ursprungsarray zwar erhalten, aber auch für alle Zwischenarrays, wird separater Speicher verwendet, welcher später aber wieder frei gegeben wird. Eine effizientere Implementierung basiert auf der Idee, dass man, wenn man eine Vertauschungsoperation verwendet auch durch geschicktes Vertauschen innerhalb des Arrays die Elemente an Hand des Pivotelementes aufteilen kann: l 5 m 5 l 5 l 5 l 5 4 l 1 i 1 m 1 7 3 6 9 2 4 8 3 i 7 6 9 2 4 8 9 i 9 2 i 7 8 3 9 7 1 3 2 4 m 5 m 4 i 6 8 1 2 m 2 6 i 6 4 1 7 i 3 m 3 9 7 6 8 i 8 i Dies kann wie folgt in ruby umgesetzt werden: def qsort H ! ( a , l , r ) i f l >= r then r e t u r n a ; else m= l; f o r i i n ( l +1) . . r do i f a [ i ] < a [ l ] then m = m+1; 97 3 Datenstrukturen und Algorithmen swap ! ( a ,m, i ) ; # swap ! s i e h e I n s e r t i o n −S o r t end ; end ; swap ! ( a , l ,m) ; q s o r t h ! ( a , l ,m−1); r e t u r n q s o r t h ! ( a ,m+1, r ) ; end ; end ; def qsort ! ( a) r e t u r n qsortH ! ( a , 0 , a . s i z e −1); end ; Alternative Variante (Übung) Laufzeitanalyse: Im Durchschnitt wird das Pivot-Element die Liste in zwei gleich große Hälften teilen welche dann beide jeweils wieder sortiert werden müssen: Die Laufzeitanalyse klingt, als ob wir eine Best-Case-Analyse gemacht hätten. Nur bei guten Pivot-Elementen wird gleichmäßig aufgeteilt. Der Fall tritt allerdings fast immer auf, weshalb man als Average-Case Laufzeit O(n · log n) mit n Größe des zu sortierenden Arrays erhält. Allerdings gibt es immernoch einen schlechten Fall, in dem Quicksort eine schlechtere Laufzeit hat: Die Worst-Case Laufzeit beträgt also immer noch O(n2 ). Man verhindert dies dadurch, dass ein zufälliges Pivotelement gewählt wird. Hierzu kann man zu beginn des else -Falls das erste Element gegen eine anderes zufällig gewähltes Element getauscht werden: def qsort h ! ( a , l , r ) i f l >= r then r e t u r n a ; else swap ! ( a , l , r a n d i n t ( l , r ) ) ; m= l ... end ; def randint ( l , r ) r e t u r n ( l + rand ( r ) ) ; 98 3.8 Schwere Probleme end ; Dann ist der Worst-Case sehr unwahrscheinlich ⇒ in der Praxis immer Laufzeit O(n · log n) mit n = a.size. 3.7.1 Andere effiziente Sortieralgorithmen Merge-Sort Teile Array in zwei gleich große Hälften, sortiere die Hälften rekursiv und mische die beiden sortierten Ergebnisse zusammen. Nachteil gegenüber Quicksort: nicht einfach in Place möglich: beim Mischen wird zweites Array benötigt. 1 3 5 7 2 4 6 8 Wie innerhalb des ; 1 2 3 4 5 6 7 8 Arrays machbar? Aber: Algorithmus hat auch Worst-Case-Komplexität n · log n mit n = # zu sortierende Werte, im Gegensatz zu Quicksort, bei dem dies nur randomisiert erreicht werden kann. Bsp.: Allerdings ist Quicksort in der Praxis oft schneller (insbesondere randomisiert). 3.8 Schwere Probleme Beim Sortieren empfanden wir quadratische Laufzeit als schlecht. Es gibt aber auch Probleme, bei denen man sich quadratische, kubische oder allgemeiner polynomielle Laufzeiten wünschen würde. Die besten bekannten Lösungen sind hier mindestens exponentiell. Bsp.: Planung einer Rundreise (Handlungsreisenden-Problem, Travelling Salesman-Problem) TSP. Gegeben: n zu besuchende Städte und die Entfernungen zwischen je zwei dieser Städte. Gesucht: kürzeste Rundtour über alle Städte. Beispiel: 99 3 Datenstrukturen und Algorithmen 430 580 590 780 2380 300 430 580 590 1900 Im allgemeinen schwierig, nur exponentielle Algorithmen oder eff. Näherungsalgorithmen bekannt. Als weiteres Beispiel für solche Probleme betrachten wir folgendes Problem: SAT – Erfüllbarkeit (satisfyability): Gegeben: Aussagenlogische Formel ϕ mit n booleschen Variablen (b1 , . . . , bn ) Frage: Gibt es boolesche Belegung für b1 bis bn , so dass ϕ unter dieser Belegung zu true auswertet? Bsp.: (b1 &&b2 )||!(b3 &&b2 ) Wähle: b1 = true, b2 = true, b3 = f alse ; true (b1 &&b2 )&&!(b3 ||b2 ) nicht erfüllbar, da b2 = true wegen &&, aber b2 = f alse wegen zweitem || gelten muss. Um SAT zu lösen muss man im Allgemeinen alle möglichen Belegungen durchtesten, was aber exponentiell viele (2n ) Belegungen sind. Man kann aber einen einfachen nicht-deterministischen Algorithmus angeben, der das Problem löst: Wähle nicht-deterministisch einfach eine (richtige) Belegung aus und überprüfe, ob diese zu true auswertet. D.h. es gibt einen nicht-deterministisch linearen Algorithmus in der Größe der Formel. Entsprechend kann man auch für das TSP einen nichtdeterministisch polynomiellen Algorithmus finden. Deterministisch sind bis jetzt aber nur exponentielle Algorithmen bekannt. Eine offene Frage der Informatik ist, ob es generell möglich ist, für solche Algorithmen auch deterministische Algorithmen mit polynomieller Laufzeit zu finden. Hierzu fasst man alle Probleme, für welche es nichtdeteministische polynomielle Algorithmen gibt, in der Klasse NP und alle Probleme, für die deterministische polynomielle Algorithmen existieren in der Klasse P zusammen. Dann ist eine offene Frage der Informatik, ob NP = P 100 oder N P 6= P ? | {z } 3.8 Schwere Probleme Es wird allgemein die Ungleichheit der beiden Klassen vermutet, was aber sehr schwer zu zeigen ist, da über alle möglichen Algortihmen argumentiert werden muss. Beachte: Bei Komplexitätsbetrachtungen ist nicht nur die Laufzeit wichtig, sondern oft auch Speicherbedarf. Quadratischer Speicherverbrauch ist oft kritischer als quadratische Laufzeit. Es gibt aber auch Probleme, für die nicht einmal exponentielle Lösungen bekannt sind. Z.B. ... 2 22 2 Laufzeit 2 | {z }! n-mal ; noch schlimmer, sogar unlösbare Probleme: 3.8.1 Halteproblem Nachdem wir nun ja schon einige Programme geschrieben haben, stellt sich vielleicht folgende (scheinbar) einfache Aufgabe: Gesucht: Programm, welches ein Ruby-Programm einliest und true ausgibt, falls P terminiert, und false , falls P nicht terminiert. Zeige Unlösbarkeit: Angenommen, wir könnten folgendes Programmfragment/Funktionsdefinition definieren: d e f t e r m i n i e r t ( prog name ) s t r = IO . r e a d ( prog name ) ; .... r e t u r n b ; # mit t r u e , f a l l s Programm t e r m i n i e r t # und f a l s e , s o n s t end ; Heirbei würde die Funktion terminiert(prog name) das Ergebnis true liefern, falls das übergebene Programm prog name (in Form des Dateinamens) bei seiner Ausführung terminiert und false sonst. Da Programme ja nicht als Datum übergeben werden können, übergeben wir den Dateinamen und lesen als erste Anweisung in der Definition von terminiert das Programm ein. Danach haben wir es als String in der Variablen str vorliegen, welchen wir dann irgendwie zur weiteren Analyse verarbeiten könnten. Dann könnten wir folgendes Testprogramm schreiben und unter dem Dateinamen test.rb abspeichern: d e f t e r m i n i e r t (pName) s t r = IO . r e a d (pName ) ; ... return b ; end ; i f t e r m i n i e r t ( ” t e s t . rb ” ) then w h i l e t r u e do end ; end ; # # # # # # # # # t e s t . rb Würde man dann ruby t e s t . rb 101 3 Datenstrukturen und Algorithmen aufrufen, so würde dieser Aufruf terminieren, falls test.rb nicht terminiert und es würde nicht terminieren, falls test.rb terminiert. Dies ist ein Widerspruch, woraus direkt folgt, dass terminiert nicht definiert werden kann. 3.9 Besonderheiten von Ruby In dieser Vorlesung haben wir Ruby verwendet, da die Programme elegant und fast wie Pseudocode aussehen. Außerdem bietet Ruby alle wichtigen Konzepte auch anderer Programmiersprachen, so dass Ruby sich zum Erlernen Konzepte der Programmierung besonders gut eignet. Es gibt allerdings ein paar Dinge, welche in Ruby ein wenig anders funktionieren, als in der Vorlesung vorgestellt. Solche Stellen fallen insbesondere auf, wenn Programme fehlerhaft sind und Dinge passieren, die man nicht erwarten würde. Solche Aspekte sollen in diesem Kapitel erläutert und untersucht werden. 3.9.1 Semikolon Als erstes ist sicherlich vielen schon aufgefallen, dass Ruby nicht alle vorgegebenen Semikolons verlangt. Hier sind andere Programmiersprachen sehr viel strenger. Ruby, interpretiert auch den Zeilenumbruch als Semikolon und erlaubt es auch beliebig viele Semikolons hinter einander zu schreiben. Somit sind Semikolons nur wirklich dann erforderlich, wenn man eine Sequenz aus mehreren Ruby-Anweisungen in der gleichen Zeile hintereinander schreiben möchte. 3.9.2 Anweisungen und Ausdrücke In der Vorlesung haben wir klar zwischen Ausdrücken und Anweisungen unterschieden. Anweisungen sind insbesondere Zuweisungen, Sequenzen von Anweisungen, Verzeigungen (if-then-else) und Wiederholungen von Anweisungen (Schleifen). Sie beinhalten auch Seiteneffekte, d.h. es können Ausgaben getätigt werden und Variablen/Objekte verändert werden. Ausdrücke sollten in der Regel keine Seiteneffekte haben und einach nur gemäß einer aktuelle Variablenbelungung ausgewertet werden. Konzeptionell wird diese Unterscheidung in vielen Programmiersprachen gemacht und wurde deshalb auch so in der Vorlesung vorgestellt. Entscheidend ist das Zusammenspiel zwischen Anweisungen und Ausdrücken. Imperative Programme bestehen in der Regel aus Anweisungssequenzen und an bestimmten Stellen können Ausdrücke verwendet werden. Wichtige Stellen sind die rechte Seite einer Zuweisung, die Bedingungen bei while und if-then-else und die Argumente von Funktions- und Prozeduraufrufen. In Ruby gibt es diese Unterscheidung tatsächlich nicht. Alles sind Ausdrücke, die auch einen Wert besitzen, d.h. auch Zuweisungen und Schleifen sind Ausdrücke. Diese Ausdrücke können auch Seiteneffekte haben, was dann bei den Ausdrücken, welche wir bisher als Anweisungen bezeichnet haben, natürlich wichtig ist. Um zu verstehen, wie Anweisungen als Ausdrücke ausgewertet werden, ist es insbesondere wichtig zu verstehen, was die Ergebniswerte der Anweisungen (als Ausdrücke ausgeführt) sind: 102 3.9 Besonderheiten von Ruby Zuweisung Bei einer Zuweisung erhalten wir den zugewiesenen Wert als Ergebnis. Deshalb ist es in Ruby z.B. auch möglich folgende zwei Zuweisungen verschachtelt zu notieren: x=y=42;. Hier wird zum einen die Variable y an den Wert 42 gebunden. Zusätzlich liefert diese innere Zuweisung das Ergebnis 42, an welches dann auch noch die Variable x gebunden wird. x=y=42 steht also insbesondere nicht für den Ausdruck (x=y)=42. Sequenz Der Rückgabewert einer Sequenz ist immer das Ergebnis des letzten Ausdrucks. Die Ergebnisse der zuerst ausgeführten Ausdrücke/Anweisungen werden verworfen. Somit können wir in Ruby auch schreiben: 21+21;x=4;y=42. Das Ergebnis 42 des ersten Ausdrucks 21+21 wird zwar berechnet, dann aber direkt verworfen. Da dieser Ausdruck keine Variablenbindungen oder Ausgaben macht, bleibt von seiner Berechnung auch nichts zurück und das Programm verhält sich genau so, als ob dieser Ausdruck nicht ausgerechnet worden wäre. Der zweite Ausdruck, die Zuweisung x=4, wird ausgewertet und x wird an 4 gebunden. Der Rückgabewert (4) wird aber verworfen, da ein weiterer Ausdruck in der Sequenz folgt. Die gesamte Sequenz liefert als Rückgabewert 42. Wir können in Ruby aber auch folgendes schreiben: p u t s ( ( x =4)+1); Die Variable x wird an 4 gebunden und der Wert 5 wird ausgegeben. Verzweigungen und while-Schleifen Auch while-Schleifen und die if-then(-else) Anweisungen sind in Ruby Ausdrücke. Ähnlich, wie Sequenzen, liefern sie als Rückgabewert, den Wert der letzten ausgewerteten Ausdrucks, also meist der letzten Zuweisung. Da beim Verlassen der while-Schleife immer die Bedingung der zuletzt ausgewertete Ausdruck ist und dieser zu false (oder nil) ausgewertet wurde, hat die while-Schleife den Wert nil als Ergebnis. Bei der if-then Anweisung, bei der der thenZweig nicht ausgeführt wurde, wird ebenfalls der nil-Wert zurückgegeben. Funktionen Da in Ruby also alles einen Ergebniswert besitzt, haben auch Prozeduren einen Rückgabewert. Auch hier ist es das Ergebnis des letzten ausgewerteten Ausdrucks. Dies hat bei manchen Programmen schon geholfen, bei denen man vielleicht mal ein return vergessen hat. So lässt sich die rekrsive Fakultätsfunktion z.B. auch so schreiben: def fac (n) i f n==0 then 1 e l s e n∗ f a c ( n−1) end ; end ; Als guter Programmierstil sollten man aber, insbesondere bei der Verwendung von Sequenzen in Funktionsdefinitionen, das return verwenden, um den Rückgabewert einer Funktion festzulegen. 103 3 Datenstrukturen und Algorithmen for-Schleifen Untersucht man das Ergebnis einer for-Schleife, wird man überrascht. Im Gegensatz zur while-Schleife wird hier nicht der letzte in der Schleife ausgewertete Ausdruck als Rückgabewert verwendet. Vielmehr wird scheinbar der aufgezählte Bereich ausgegeben. Dies liegt daran, dass die Grenzen der for-Schleife in Ruby gar keine syntaktischen Konstrukte sind, sondern vielmehr 3..42 ein spezieller Konstruktor der Klasse Range ist und eine Range konstruiert, welche nach und nach die Werte des zu iterierenden Bereichs an die for-Schleife liefert. Solche Range Objekte können beispielsweise auch in Variablen gespeichert werden (x=3..10;) und dann später verwendet werden (for i in x do ... end;). Die for-Schleife liefert also genau die verwendete Range als Rückgabewert. Vergisst man nun in einer Funktionsdefinition das return und der letzte Ausdruck im Rumpf war eine for-Schleife, so ergibt sich die Range als Rückgabewert der Funktion. Hat man eigentlich ein Array erwartet und versucht auf eine Komponente zuzugreifen erhält man eine entsprechende Fehlermeldung: NoMethodError: undefined method ‘[]’ for 1..10:Range, welche wir jetzt verstehen können. Anstelle von Range-Objekten können auch Arrays ode randere Datenstrukturen, welche mehrere Elemente ausnehmen können, verwendet werden, wie das folgende Beispiel zeigt: f o r elem i n [ 7 , 4 2 , 7 3 ] do p u t s ( elem ) end 3.9.3 Zuweisung statt Gleichheit Ein weiterer häufig gemachter Fehler ist die Verwendung von = statt ==: i = 42; w h i l e i = 42 do i=i +1; end ; puts ( i ) ; Diese while-Schleife treminiert nicht, da wir eine Zuweisung als Bedingung verwendet haben. Als Seiteneffekt wird bei jeder Auswertung der Bedingung die Variable i an 42 gebinden. 42 wird aber wie alle anderen Werte (außer false und nil) wie true interprestiert und der Rumpf der Schleife erneut ausgeführt. Entsprechend verhalten sich auch die booleschen Operatoren && und || : 7 && true. wertet zu true aus. 3.9.4 Symbole, die beliebigen Werte Bisher haben wir Zahlen und Strings als Grunddatentypen kennen gelernt. Außerdem gibt es die booleschen Werte true und false . In vielen Anwendungen möchte man gerne ähnlich sprechende Werte verwenden, wie z.B. einen Wert red für die Farbe rot. Dies ist in Ruby durch voranstellen eines Doppelpunktes möglich: c o l o r s = [ : red , : green , : y e l l o w ] p u t s ( ” Meine L i e b l i n g s f a r b e i s t ”+c o l o r s [ 2 ] . t o s ) 104 3.9 Besonderheiten von Ruby Durch die Verwendung solcher Symbole, werden Programme verständlicher, als wenn wir irgendwelche Kodierungen als Zahlenwerte vornehmen würden. Im Vergleich zu Strings sind diese Symbole effizienter, da sie nur einemal angelegt und effizienter verglichen werden können. 3.9.5 Hashes als Verallgemeinerung von Arrays Wenn wir bisher mehrere Werte zu einem Wert zusammenfassen wollten, haben wir entweder Klassen oder Arrays verwendet. Arrays sind besonders gut geeignet, eine beliebige Anzahl von Werten abzuspeichern. Hierbei ist später der Zugriff aber immer über einen Index zwischen 0 und der Arraygröße-1 notwendig. Wenn man keine gute Zuordnung zwischen solchen Indizes und den gespeicherten Werten kennt, kann das Array die falsche Datenstruktur sein. In manchen Fällen hätte man lieber einen Schlüssel, unter dem man (ähnlich, wie in einer Datenbank) Werte abspeichert und später wieder nachschlagen kann. Als Bespiel betrachten wir ein Telefonbuch. Wenn man die Telefonnummer einer Person finden will, so wird man möglichst nicht das ganze Telefonbuch nach der Person durchsuchen, sondern direkt auf den Namen des gesuchten Teilnehmers zugreifen wollen. Unter Verwendung von binärer Suche haben wir ein Verfahren kennen gelernt, wie wir dies effizient realisieren können. Es gibt aber auch noch andere Ansätze, welche direkt über einen Schlüssel gehen und einen sogenannten Hash zur Verfügung stellen. Ein Hash stellt eine Schlüssel-Wert-Struktur zur Verfügung, bei der unter einem eindeutigen Schlüssel genau ein Wert abgespeichert werden kann. Ein mögliches Telefonbuch sähe als Hash wie folgt aus: t e l e f o n b o o k = { ” Frank ” => 7 2 7 7 , ” C h r i s t o p h D a n i e l ” => 7 2 7 9 , ”Thomas” => 7 p u t s ( t e l e f o n b o o k [ ” Frank ” ] ) Der Zugriffs auf die gespeicherten Werte im Hash erfolgt genau wie bei Arrays, nur dass man den Schlüssel anstelle des Indexes verwendet. So könnne wir z.B. die Telefonnummer von Christoph Daniel wie folgt korrigieren: t e l e f o n b o o k [ ” C h r i s t o p h D a n i e l ” ]=7297 In diesem Beispiel haben wir Strings als Schlüssel verwendet. Oft werden auch Symbole verwendet, wie z.B. in folgendem Beispiel: b e a u t i f u l c o l o r = { : r e d => 4 2 , : g r e e n => 7 , : y e l l o w => 156} bei dem wir einen Hash zur Darstellung einer schönen Farbe (im RGB-Farbsystem) verwenden. Hashes können auch erweitert werden: t e l e f o n b o o k [ ” N o t r u f ” ]=112 fügt einen neues Schlüssel-Wert-Paar zu unserem Telefonbuch hinzu, welches danach genau wie die anderen Schlüssel nachgeschlagen werden kann. Für die Implementierung einer Hash gibt es unterschiedliche Verfahren. Zum einen kann man eine Funktion definieren, welche die Schlüssel auf die Indizes eines gegebenen Array fester Größe abbildet. Eine gute Funktion versucht hierbei möglichst alle Schlüssel mit der gleichen Wahscheinlichkeit zu erreichen. Andere Implementierungen basieren auf 105 3 Datenstrukturen und Algorithmen (höhenbalancierten) Suchbäumen, welche ähnlich wie bei der binären Suche eine Auffinden eines Schlüssels mit logarithmischer Laufzeit ermöglichen. 3.9.6 Blöcke In Ruby gibt es noch eine alternative Verwendung von Range/Iteratorobjekten: Anstelle einer for-Schleife ist es auch möglich, die Methode each zu verwenden, welche einen Block erwartet: f o r i i n [ 1 , 4 , 6 , 9 ] do puts ( i ) end # v e r h a e l t s i c h genau , wie [ 1 , 4 , 6 , 9 ] . each { | i | p u t s ( i ) } # Block mit g e s c h w e i f t e n Klammern # oder [ 1 , 4 , 6 , 9 ] . each do | i | # Block mit do . . . end puts ( i ) end Ein Block ist ein Codestück, welches durch geschweifte Klammern oder die Schlüsselwörter do und end zusammen gefasst wird. Ein Block nimmt zusätzlich optionale Argumente, welche zwischen zwei senkrechten Striche (ggf. durch Kommas getrennt) geschrieben werden. Die Methode each erwartet einen Block, welcher einen Parameter nimmt und wendet diesen Block dann auf jedes Element der Range an, wobei das Blockargument an den aktuellen Wert der Range gebunden wird. Es gibt auch noch weitere Methoden, die Blöcke verwenden, wie z.B. die Methode map: p ( [ 1 , 4 , 6 , 9 ] . map { | x | x+1 } ) # −> [ 2 , 5 , 7 , 1 0 ] Der Block wird hierbei also auf jedes Element des Arrays angewendet und das hierdurch entstehende Array zurück geliefert. Auch eigene Funktionen können einen Block als Argumente verwenden. Der übergeben Block wird dadurch aufgerufen, dass wir innerhalb der Funktiondefinition yield verwenden: def p u t s w i t h f i l t e r (a) f o r i i n 0 . . a . s i z e −1 i f y i e l d a [ i ] then puts ( a [ i ] ) end end end p u t s w i t h f i l t e r ( [ 1 , 2 , 3 , 4 , 5 ] ) { | n | n%2 == 0} 3.9.7 Funktionen übergeben Blöcke entsprechen in etwa Funktionen, wie sie in der Mathematik vorkommen. Nun wäre es ja auch schön, solche Blöcke auch in Variablen oder Datenstrukturen zu speichern oder sie als Parameter an andere Funktionen zu übergeben. 106 3.9 Besonderheiten von Ruby Hierzu verwendet man in Ruby das Schlüsselwort lambda, welches einen Block zu einem Wert macht, welcher gespeichert und später wiederverwendet werden kann: i n c = lambda { | n | n+1 } puts ( inc . c a l l ( 4 1 ) ) puts ( inc . c a l l ( 6 ) ) add = lambda { | x , y | x+y} p u t s ( add . c a l l ( 2 0 , 2 2 ) ) p u t s ( add . c a l l ( 3 , 4 ) ) Es gibt noch weitere Besonderheiten in Ruby, welche hier aber nicht weiter vertiefen wollen. Hierzu findet sich auch viel Erläuterungen im WWW. ↑↑↑↑↑ Zusatzinhalt, der für 5 ECTSler nicht relevant ist ↑↑↑↑↑ 107