Informatik für Nebenfächler - iLearn - Christian-Albrechts

Werbung
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
Herunterladen